Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cmd/codeaf/telemetry_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ func (c *countingAgent) Submit(ctx context.Context, text string) (<-chan session
return countedEvents(events), nil
}

// SubmitBash counts the explicit human shell stream the same way as messages.
func (c *countingAgent) SubmitBash(ctx context.Context, text string) (<-chan session.Event, error) {
events, err := c.Agent.SubmitBash(ctx, text)
if err != nil {
return nil, err
}
return countedEvents(events), nil
}

// SubmitStanding is [tui3.Agent.SubmitStanding] with the stream counted.
func (c *countingAgent) SubmitStanding(ctx context.Context, text string) (<-chan session.Event, error) {
events, err := c.Agent.SubmitStanding(ctx, text)
Expand Down
6 changes: 6 additions & 0 deletions docs/REMOTE.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ file it was started from was removed or rebuilt and it is holding nothing
holds a workspace on that machine — asking politely first, and naming the
process through the socket's peer credentials when it is too old to be asked.

**Version 20 adds the explicit `SubmitBash` door and `EventToolOutput`.** Only the
conversation's driver can submit a human shell command. Ordinary `Submit` keeps
leading `!` as model text, including automated submissions. Output events carry
literal stdout/stderr in `Text`, paired by `CallID`, before the final tool result.
A version mismatch refuses this feature rather than interpreting it as a chat.

## Decision 4 — Version 2 separates a conversation's life from a pipe's

**Decision.** In version 1 the engine *was* the ssh command: it read frames on
Expand Down
11 changes: 11 additions & 0 deletions docs/changes/unreleased/1654-bash-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
kind: added
title: Run non-interactive shell commands with ! and see their output live
pr: 1654
surface: [chat, engine, remote, docs]
invalidates:
- "A leading ! was ordinary model input. Home and conversation composers now run it as an explicit human shell command in the selected project, without requiring a model or provider key."
- "The composer used the same prompt for every draft. A leading ! now changes it to an amber dollar prompt, and deleting the prefix restores the normal prompt."
- "Shell output normally arrived as a bounded tool preview. Human shell commands now show literal stdout/stderr while running, retain the bounded result in conversation history, and wait for the next message before the model responds."
- "Remote protocol 19 had no human shell submission door. Protocol 20 adds SubmitBash and streamed output events; ordinary and automated Submit calls never gain shell authority from a leading !."
---
21 changes: 19 additions & 2 deletions internal/exec/bare/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,18 @@ func readTool(cwd string, caps Caps) Tool {

const maxTimeoutMs = 2147483647

type bashOutputKey struct{}

// RunBash runs the ordinary non-interactive shell and mirrors its raw output to
// a caller-owned writer as it arrives. The runner retains its normal bounds,
// cancellation, process isolation and spill handling.
func RunBash(ctx context.Context, cwd string, args json.RawMessage, caps Caps, output io.Writer) (string, bool, error) {
if output != nil {
ctx = context.WithValue(ctx, bashOutputKey{}, output)
}
return newBashTool(cwd, caps).Execute(ctx, args)
}

func newBashTool(cwd string, caps Caps) Tool {
caps = caps.resolve()
return Tool{
Expand Down Expand Up @@ -491,6 +503,7 @@ func newBashTool(cwd string, caps Caps) Tool {
// with goroutines raced: cmd.Wait closes the pipes before the
// goroutines drain the last chunk.
acc := newOutputAccumulator(caps)
acc.observer, _ = ctx.Value(bashOutputKey{}).(io.Writer)
cmd.Stdout = acc
cmd.Stderr = acc

Expand Down Expand Up @@ -1014,8 +1027,9 @@ type outputAccumulator struct {
// already been answered, nobody will ask for another snapshot, and the one
// place the rest of the output belongs is the adopter's own log. Two files
// for one command would be two answers to "where is the rest of it".
mirror io.Writer
mu sync.Mutex
observer io.Writer
mirror io.Writer
mu sync.Mutex
}

// Write implements io.Writer so both cmd.Stdout and cmd.Stderr can be set
Expand All @@ -1025,6 +1039,9 @@ func (a *outputAccumulator) Write(data []byte) (int, error) {
a.mu.Lock()
defer a.mu.Unlock()
a.append(data)
if a.observer != nil {
_, _ = a.observer.Write(data)
}
return len(data), nil
}

Expand Down
30 changes: 30 additions & 0 deletions internal/manual/chat/commands.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
# Commands

## Bash mode — run a shell command with ! and keep its output in context

Start a message with `!` to run a non-interactive shell command yourself:
`!ls -lth`, `!pwd`, or `!git status`. Enter runs it in this conversation's workspace;
from Home it opens a conversation in the selected project first. With `--host`,
the command runs on the connected machine. No model is asked to run it or explain
its result, and no provider key is needed for the command itself.

Typing the leading `!` changes the composer prompt to an amber `$`; deleting it
restores the ordinary prompt. Combined stdout/stderr appears as plain text while
the command runs, preserving line breaks and indentation. Wide lines wrap; terminal
control sequences are removed for safe display. The command and output remain in
history after reopening. The model waits for your next message before responding.
A failed command keeps its output and exit status too. Shell paths, `@` names and
slash words remain literal, not mentions or send tags.

Commands receive no interactive input or terminal. Use non-interactive flags;
editors, prompts and terminal apps are not supported. Each command starts a fresh
shell in the workspace: `cd` and `export` apply only within that command. Large
output is truncated with a notice. The foreground shell timeout applies, and
Escape stops the command. These commands cannot be sent to background jobs. Enter
supplies consent for ordinary commands; explicit policy denies still apply. The short
table of dangerous commands (disk wipes, `mkfs`, reboot/shutdown and the fork bomb)
still asks first. Shell commands run with your permissions, including network access.
File-tool workspace guards cannot restrict shell writes; bash mode is not a sandbox.

An empty `!`, attached files, or a busy conversation leaves your draft in place
and explains what to change. Wait for the turn to finish or stop it first.
Task pages accept task messages; run `!` commands from the parent conversation.

## Typing a slash to see the command list

Type `/` in the home or conversation message box to see every available command in
Expand Down
7 changes: 4 additions & 3 deletions internal/manual/chat/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,10 @@ enter steers it in · ctrl+shift+enter stops and sends · esc interrupt
```

That is the terminal-capable form when no command can be kept. A running foreground
command adds `ctrl+g backgrounds` immediately before `esc interrupt`; a terminal that
cannot deliver `ctrl+shift+enter` leaves that clause out. `cmd+enter` still waits, but the
one-line slot no longer advertises it.
command that can be kept adds `ctrl+g backgrounds` immediately before `esc interrupt`;
a `!` command cannot be kept. A terminal that cannot deliver `ctrl+shift+enter`
leaves that clause out. `cmd+enter` still waits, but the one-line slot no longer
advertises it.

## My message went in too late — the answer finished first, so it became the next message

Expand Down
4 changes: 2 additions & 2 deletions internal/manual/chat/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -751,8 +751,8 @@ are asked, exactly as if it had said `ASK`.
## The floor nothing lifts: dangerous shell commands

A short table of shell shapes is asked about **whichever way your settings are
set**. A blanket `allow` does not switch it off. `--yolo` does not switch it
off. A pushed policy cannot widen it.
set**, including a `!` command you type. A blanket `allow` does not switch it off.
`--yolo` does not switch it off. A pushed policy cannot widen it.

The entries, verbatim:

Expand Down
8 changes: 4 additions & 4 deletions internal/manual/chat/what-i-can-do.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,10 +346,10 @@ With the mouse, move over that running row and press its right-hand
still opens the row. The offer is not drawn on a phone-width frame.

The background gesture is absent when there is nothing to send away — no command
running, a command that is already a background job, a call that is not `bash`, or
a session over `--host`, where the local surface has no handoff door. With no command
to keep, `ctrl+g` returns to its other job of hiding or restoring the task column. See
the keys page.
running, a command that is already a background job, a `!` command you ran, a call
that is not `bash`, or a session over `--host`, where the local surface has no
handoff door. With no command to keep, `ctrl+g` returns to its other job of
hiding or restoring the task column. See the keys page.

Starting a command with `background: true` makes it a job from the first instant.
For an ordinary foreground command whose length you did not know in advance, use
Expand Down
6 changes: 6 additions & 0 deletions internal/remote/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -1669,6 +1669,12 @@ func (a *Agent) Submit(ctx context.Context, text string) (<-chan session.Event,
return a.open(ctx, MethodSubmit, SubmitArgs{Text: text})
}

// SubmitBash explicitly runs the person's command; ordinary Submit never
// interprets message content as executable shell syntax.
func (a *Agent) SubmitBash(ctx context.Context, text string) (<-chan session.Event, error) {
return a.open(ctx, MethodSubmitBash, SubmitArgs{Text: text})
}

// SubmitStanding is Submit for a draft the person marked as something to keep
// true. It rides the same method as an ordinary send with one flag on it, for
// the reason [SubmitArgs.Standing] states: the two turns differ only in what the
Expand Down
2 changes: 2 additions & 0 deletions internal/remote/driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ func TestAWindowWithoutTheKeyboardIsRefusedInWordsAndNotInSilence(t *testing.T)
payload any
}{
{MethodSubmit, SubmitArgs{Text: "go"}},
{MethodSubmitBash, SubmitArgs{Text: "!pwd"}},
{MethodFollowUp, SubmitArgs{Text: "and also"}},
{MethodSteer, SubmitArgs{Text: "use the other file"}},
{MethodSubmitImage, SubmitImageArgs{Text: "look"}},
Expand Down Expand Up @@ -420,6 +421,7 @@ func TestAWatchingSurfaceIsRefusedEveryDoorThatChangesAnything(t *testing.T) {
}{
{MethodSetModel, "someone/else"},
{MethodSubmit, "go"},
{MethodSubmitBash, SubmitArgs{Text: "!pwd"}},
{MethodInterrupt, nil},
{MethodSessionNew, nil},
{MethodTaskStop, TaskStopArgs{ID: "7"}},
Expand Down
16 changes: 15 additions & 1 deletion internal/remote/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2006,7 +2006,7 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) {
// card, switching a model, interrupting a turn — stays open to every surface
// in the room: a watcher is a person watching their own work, not a guest.
switch call.Method {
case MethodSubmit, MethodFollowUp, MethodSteer, MethodQuestionReplace, MethodSubmitImage, MethodSubmitFiles,
case MethodSubmitBash, MethodSubmit, MethodFollowUp, MethodSteer, MethodQuestionReplace, MethodSubmitImage, MethodSubmitFiles,
MethodTaskSteer, MethodTaskStop, MethodTaskRetry:
if err := s.mayDrive(); err != nil {
return nil, err
Expand Down Expand Up @@ -2434,6 +2434,20 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) {
}
return nil, nil

case MethodSubmitBash:
args, err := arg[SubmitArgs](call)
if err != nil {
return nil, err
}
door, ok := agent.(interface {
SubmitBash(context.Context, string) (<-chan session.Event, error)
})
if !ok {
return nil, errors.New("engine: this session cannot run ! commands")
}
events, err := door.SubmitBash(context.Background(), args.Text)
return s.stream(MethodSubmitBash, args.Text, events, err)

case MethodSubmit:
args, err := arg[SubmitArgs](call)
if err != nil {
Expand Down
54 changes: 54 additions & 0 deletions internal/remote/userbash_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package remote

import (
"context"
"github.com/Agent-Field/codeaf/internal/session"
"testing"
)

type shellAgent struct {
*fakeAgent
shell string
}

func (a *shellAgent) SubmitBash(ctx context.Context, text string) (<-chan session.Event, error) {
a.shell = text
return a.fakeAgent.Submit(ctx, text)
}

func TestUserBashUsesExplicitDoorAndStreamsLiteralOutput(t *testing.T) {
fake := &fakeAgent{}
shell := &shellAgent{fakeAgent: fake}
engine := engineOn(fake)
engine.Agent = shell
l := dialAgent(t, engine)
l.hello(Hello{Version: Version})
ref := decode[StreamRef](t, l.ok(1, MethodSubmitBash, SubmitArgs{Text: "!pwd"}).Payload)
if shell.shell != "!pwd" {
t.Fatal("shell submitted through generic message door")
}
stream := fake.stream(0)
output := " literal\n\nλ\n"
stream <- session.Event{Kind: session.EventToolOutput, Tool: "bash", CallID: "user_bash_test", Text: output}
fake.finish(stream)
ev := decode[EventWire](t, l.recv().Payload).Unwire()
if ev.Kind != session.EventToolOutput || ev.Text != output || ev.CallID != "user_bash_test" {
t.Fatalf("wire changed output: %+v", ev)
}
closed := l.recv()
if closed.Kind != "closed" || closed.ID != ref.Stream {
t.Fatalf("stream did not close: %+v", closed)
}
}

func TestUserBashRefusesEngineWithoutExplicitDoor(t *testing.T) {
fake := &fakeAgent{}
l := dialAgent(t, engineOn(fake))
l.hello(Hello{Version: Version})
if result := l.call(1, MethodSubmitBash, SubmitArgs{Text: "!pwd"}); result.Error == "" {
t.Fatal("missing shell door silently accepted")
}
if len(fake.sent) != 0 {
t.Fatal("shell fell back to generic message")
}
}
5 changes: 4 additions & 1 deletion internal/remote/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,9 @@ import (
// fail as silence on an older engine: a version-17 engine reads `effort` as a
// field it does not know and starts the task on the crew it would have had,
// and the person is never told their word did nothing. NEVER TO SILENCE.
const Version = 19
// Version 20 adds the explicit human shell door and streamed shell output.
// Older peers must refuse rather than treat a shell command as model input.
const Version = 20

// AND THE NEWS FRAMES RIDE THAT SAME NUMBER, for the reason the places methods
// rode version 5's: neither half can be surprised by them. "phase" and "lane"
Expand Down Expand Up @@ -443,6 +445,7 @@ type Frame struct {
const (
// Agent — payloads are the method's own argument struct below; results are
// the return values likewise.
MethodSubmitBash = "SubmitBash" // SubmitArgs → StreamRef, then shell output events
MethodSubmit = "Submit" // SubmitArgs → StreamRef, then "event" frames
MethodSubmitImage = "SubmitImage" // SubmitImageArgs → StreamRef, then "event" frames
MethodSubmitFiles = "SubmitFiles" // SubmitFilesArgs → StreamRef, then "event" frames
Expand Down
31 changes: 23 additions & 8 deletions internal/session/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -1014,8 +1014,14 @@ func (a *Agent) submitUser(ctx context.Context, user userMessage) (<-chan Event,
// message that arrives mid-turn is journaled like any other, and what the
// journal keeps is what the person said — the block rides the message the
// model reads and nothing else.
a.attachTurnSkillsLocked(&user)
if user.bash == "" {
a.attachTurnSkillsLocked(&user)
}
if a.running {
if user.bash != "" {
a.mu.Unlock()
return nil, errors.New(BashBusyWord)
}
// Steering. The message is queued rather than appended here because
// the transcript's tail is mid-tool-batch: a user message spliced
// between an assistant's tool_calls and their results is a shape every
Expand All @@ -1033,9 +1039,11 @@ func (a *Agent) submitUser(ctx context.Context, user userMessage) (<-chan Event,
// The spend rail is checked here, before anything is recorded: a refused
// turn must do NO work, so the person's text is not journaled either — the
// message is theirs to send again once the rail moves (rail.go).
if err := a.railBlockLocked(); err != nil {
a.mu.Unlock()
return refusedStream(err), nil
if user.bash == "" {
if err := a.railBlockLocked(); err != nil {
a.mu.Unlock()
return refusedStream(err), nil
}
}
events := a.startTurnLocked(ctx, user, nil)
a.mu.Unlock()
Expand Down Expand Up @@ -1174,6 +1182,8 @@ func (a *Agent) attachReplayLocked() (entries []DisplayEntry, events <-chan Even
// Everything the person types is one of these. A text-only message has no
// references and journals exactly as it always did.
type userMessage struct {
// bash is set only by the person's SubmitBash door; model output cannot enter it.
bash string
message ai.Message
refs []journalPart
// replyTags names finished tasks whose reports this message carries. It is
Expand Down Expand Up @@ -1808,7 +1818,9 @@ func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher *
// the transcript on the line above, which is the only thing the namer
// needs; it is started under this lock so that two Submits racing to be
// the first cannot buy two names.
a.startTitleLocked()
if user.bash == "" {
a.startTitleLocked()
}
}
// AND THE RECALL STARTS HERE TOO, beside the title and for a stronger version
// of the title's own reason (memory.go's [Agent.startRecallLocked]). The name
Expand All @@ -1820,7 +1832,9 @@ func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher *
//
// IT IS STARTED UNDER THIS LOCK for the title's reason as well: two Submits
// racing to be the first must not each buy a route.
a.startRecallLocked(turnCtx, user.text())
if user.bash == "" {
a.startRecallLocked(turnCtx, user.text())
}
// THEIR NEXT WORDS ARE WHAT CHANGED. A generation Interrupt minted waits
// here for the sentence that follows Esc, and that sentence is the one
// decision the leftover handlers and this turn's opening share.
Expand Down Expand Up @@ -4340,7 +4354,8 @@ func foldsInto(prev, next Event) bool {
if prev.Kind != next.Kind || prev.Addressed != next.Addressed {
return false
}
return prev.Kind == EventTextDelta || prev.Kind == EventReasoning
return prev.Kind == EventTextDelta || prev.Kind == EventReasoning ||
prev.Kind == EventToolOutput && prev.CallID == next.CallID
}

// close ends every subscriber's channel. It runs after the turn's last event,
Expand Down Expand Up @@ -4865,7 +4880,7 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese
// the row it replaces are the same row, or replay is a second
// rendering of one conversation.
Args: argsText(*call),
Output: capOutput(result),
Output: displayToolOutput(call.ID, result),
Answered: answered,
// And the call's own duration, off the journal's `took` line —
// the same figure EventToolFinished carried while the window was
Expand Down
Loading
Loading