Add named keys, input-line reporting and append guard to terminal_type
This commit is contained in:
+2
-2
@@ -423,9 +423,9 @@ Markdown rendered with a task (its transcript, questions, reviews, Markdown file
|
||||
|
||||
Each active task can have one terminal: a login shell (`$SHELL -l`, from byre's environment) on a PTY (`github.com/creack/pty`), started in the task's worktree with byre's environment plus `TERM=xterm-256color`, `COLORTERM=truecolor` and the task's `PORT` (`internal/terminal`, which the server creates and hands `agents.New`). `GET /api/tasks/{taskId}/terminal?cols=&rows=` is a WebSocket (`github.com/coder/websocket`, `internal/server/terminal.go`), the only WebSocket in byre: it attaches to the task's terminal, starting the shell at that size if none is running and resizing it to that size if one is, and works only while the task is active (409 otherwise). The server first sends the last `terminalBacklog` bytes the terminal printed, then its output as it comes, as binary messages; the client sends keystrokes as binary messages and a `TerminalSize` JSON text message on every resize. Any number of sockets can attach to one terminal: they all get its output and all type into it, and the last resize wins. A socket that falls `terminalClientBuf` reads behind is closed and the app reconnects. When the shell exits, every socket is closed with code 4000 and the exit status (`exit status 0`, `signal: hangup`) as the reason, and the next attach starts a new shell. Merging or discarding the task, and stopping byre, sends the shell's process group SIGHUP and closes the PTY, so terminals don't survive a restart. The WebSocket handshake checks that `Origin` matches `Host`, so another site can't open one through the user's Tailscale identity. The log records `terminal started` and `terminal ended` with the task, pid and exit status.
|
||||
|
||||
A task's agents use the same terminal through two MCP tools (`internal/agents/terminal.go`). `terminal_read` returns the backlog as plain text; it starts no shell, and says so when none is running. `terminal_type` writes `text` to the PTY as keystrokes, starting the shell at 120×40 if none is running and leaving a running one's size alone (refused once the task is closed). Within a shell's first 10s, it first waits for the prompt: until the shell has printed something and then gone 500ms without output (`Terminal.Activity` gives the start and last output times), or until 10s after the start. Typing before that would reach the PTY while it still echoes in cooked mode, before zsh's line editor takes over, so the text would show twice. The startup output, prompt included, is part of the reply. It then returns, as plain text, the output that followed, once none has arrived for 500ms or after 10s, whichever comes first, holding at most the last 512 KiB of raw output while it waits. It types on its own goroutine one byte per write and stops between bytes once the call returns. A program in raw mode that stops reading blocks a PTY write, and the PTY's file doesn't support deadlines, so typing one byte at a time keeps the call within its limits while leaving at most one byte to arrive late. The reply then says how many bytes were typed. Typing from an agent doesn't count as the human acting (`last_action_at`). The plain text comes from `terminal.Text` (`internal/terminal/text.go`). It replays the output one line at a time, keeping track of the cursor column, so a carriage return overwrites the line (progress bars, zsh's `\r\r\n` after a command) instead of duplicating it, and a backspace moves the cursor back. It applies erase-to-end-of-line (`CSI K`) and drops every other escape sequence and control character. It keeps the last 64 KiB, cut at a line start. Full-screen programs come out garbled, since it isn't a screen emulator.
|
||||
A task's agents use the same terminal through two MCP tools (`internal/agents/terminal.go`). `terminal_read` returns the backlog as plain text; it starts no shell, and says so when none is running. `terminal_type` writes `keys`, a list of named keys (`terminalKeys`: `ctrl-a` to `ctrl-z`, `enter`, `escape`, `tab`, `backspace` and the arrows), and then `text` to the PTY as keystrokes, starting the shell at 120×40 if none is running and leaving a running one's size alone (refused once the task is closed). Within a shell's first 10s, it first waits for the prompt: until the shell has printed something and then gone 500ms without output (`Terminal.Activity` gives the start and last output times), or until 10s after the start. Typing before that would reach the PTY while it still echoes in cooked mode, before zsh's line editor takes over, so the text would show twice. The startup output, prompt included, is part of the reply. It then returns, as plain text, the output that followed, once none has arrived for 500ms or after 10s, whichever comes first, holding at most the last 512 KiB of raw output while it waits. It types on its own goroutine one byte per write and stops between bytes once the call returns. A program in raw mode that stops reading blocks a PTY write, and the PTY's file doesn't support deadlines, so typing one byte at a time keeps the call within its limits while leaving at most one byte to arrive late. The reply then says how many bytes were typed. Named keys exist because Claude Code passes tool arguments through as the text the model wrote, so an escape like `\u0003` in `text` reaches the shell as six characters. Typing from an agent doesn't count as the human acting (`last_action_at`). The plain text comes from `terminal.Text` (`internal/terminal/text.go`). It replays the output one line at a time, keeping track of the cursor column, so a carriage return overwrites the line (progress bars, zsh's `\r\r\n` after a command) instead of duplicating it, and a backspace moves the cursor back. It applies erase-to-end-of-line (`CSI K`) and drops every other escape sequence and control character. It keeps the last 64 KiB, cut at a line start. Full-screen programs come out garbled, since it isn't a screen emulator.
|
||||
|
||||
A `terminal_type` whose text doesn't end in a newline (`\n` or `\r`) leaves a line for the human to run, and wakes its agent when the shell next returns to its prompt. A zsh terminal gets shell integration for this: `terminal.New` writes a `.zshenv` (the `zshenv` constant in `internal/terminal/terminal.go`) into `<data>/zsh`, and the shell starts with `ZDOTDIR` pointing there and the user's own `ZDOTDIR` (empty when unset) in `BYRE_ZDOTDIR`. That `.zshenv` puts `ZDOTDIR` back (unsetting it when it was empty), sources the user's `.zshenv`, and in an interactive shell turns off `PROMPT_SP` and appends a `precmd` hook that prints `ESC ] 133 ; D ; <exit status> BEL` (the OSC 133 "command finished" mark) before every prompt; the rest of the user's startup files then load from their own `ZDOTDIR` as usual. `PROMPT_SP` would print a reverse-video `%` padded to the full width the shell had at the time, so replaying an agent-opened shell's output (started at `agentTerminalSize`, 120 columns) in a narrower Terminal tab wraps each one onto an extra line; without it, a command whose output lacks a trailing newline has its last line drawn over by the prompt. Terminals ignore OSC sequences they don't know, and `terminal.Text` drops them, so nothing shows. Other shells get no integration (`Terminal.Prompts` is false), and nothing wakes their agents. The reply to such a `terminal_type` ends with a line saying which: byre will message the agent, or, in another shell, the agent must ask the human to say when they have run it. Once all of the text is typed, `Terminal.WakeOnPrompt` arms a one-shot wake that collects the output from then on and fires at the first mark; every `terminal_type` first cancels any armed wake (`CancelWake`), so the agent pressing Enter itself, or typing more, doesn't wake it. A zsh line editor runs `precmd` after Enter on an empty line and after Ctrl-C too (exit status 130), so either wakes the agent. On the mark, byre sends that agent's session a `byre` message (`Manager.wakeOnPrompt`, through `Manager.Message`, so it starts a turn when idle, reaches it mid-turn, and is held while the human has interrupted the agent) with the exit status and the output as plain text. The log records `woke agent on terminal prompt` with the session, task and exit status.
|
||||
A `terminal_type` that leaves text on the input line leaves a line for the human to run, and wakes its agent when the shell next returns to its prompt. A zsh terminal gets shell integration for this: `terminal.New` writes a `.zshenv` (the `zshenv` constant in `internal/terminal/terminal.go`) into `<data>/zsh`, and the shell starts with `ZDOTDIR` pointing there and the user's own `ZDOTDIR` (empty when unset) in `BYRE_ZDOTDIR`. That `.zshenv` puts `ZDOTDIR` back (unsetting it when it was empty), sources the user's `.zshenv`, and in an interactive shell turns off `PROMPT_SP` and appends a `precmd` hook that prints `ESC ] 133 ; D ; <exit status> BEL` (the OSC 133 "command finished" mark) before every prompt. It also loads `zsh/zle` and adds `add-zle-hook-widget` hooks that report the line being edited: when the editor starts a line (`line-init`) and on every redraw (`line-pre-redraw`) it prints `ESC ] 7701 ; L <$PREBUFFER$BUFFER, control characters as spaces> BEL`, so the earlier lines of an unfinished command (an open quote, a trailing `\`) count as part of the line, and when the line is sent (`line-finish`) `ESC ] 7701 ; E BEL`. `Terminal.trackLine` reads both marks and the 133 mark from the output (carrying a mark split across reads) into `Terminal.InputLine`: at the prompt with the line after a 133 or `L` mark, not at the prompt after `E`. Both tools end their reply with `[input line: "…"]` or `[input line: empty]` while zsh is at its prompt. `terminal_type` presses `keys` and waits for the output as for text, then refuses `text`, typing none of it, when the input line still holds text, so its text isn't joined onto a command the human or an earlier call left there; `append` types it anyway. In another shell nothing is checked, and a reply to typed text says the text may have joined unsent text. A user `.zshrc` that sets `zle-line-pre-redraw` with `zle -N` rather than `add-zle-hook-widget` replaces the hook, and the line then stays empty. The rest of the user's startup files then load from their own `ZDOTDIR` as usual. `PROMPT_SP` would print a reverse-video `%` padded to the full width the shell had at the time, so replaying an agent-opened shell's output (started at `agentTerminalSize`, 120 columns) in a narrower Terminal tab wraps each one onto an extra line; without it, a command whose output lacks a trailing newline has its last line drawn over by the prompt. Terminals ignore OSC sequences they don't know, and `terminal.Text` drops them, so nothing shows. Other shells get no integration (`Terminal.Prompts` is false), and nothing wakes their agents. The reply to such a `terminal_type` ends with a line saying which: byre will message the agent, or, in another shell (where text not ending in a newline counts as a line left), the agent must ask the human to say when they have run it. Every `terminal_type` first cancels any armed wake (`CancelWake`); once it has typed and waited, `Terminal.WakeOnPrompt` arms a one-shot wake that collects the output from then on and fires at the first mark. It decides under the terminal's lock, so a prompt can't slip between the check and the arming: it arms when zsh is at its prompt with text on the input line, or when a command is running that the call didn't submit itself (its keys and typed text don't end in a newline) while it typed text or found a line pending, which is the human pressing Enter during the call's wait. So the agent pressing Enter itself or clearing the line doesn't wake it, editing the line with keys or a refused call keeps the wake, and a line the human runs before the reply still reports its end. A zsh line editor runs `precmd` after Enter on an empty line and after Ctrl-C too (exit status 130), so either wakes the agent. On the mark, byre sends that agent's session a `byre` message (`Manager.wakeOnPrompt`, through `Manager.Message`, so it starts a turn when idle, reaches it mid-turn, and is held while the human has interrupted the agent) with the exit status and the output as plain text. The log records `woke agent on terminal prompt` with the session, task and exit status.
|
||||
|
||||
For commands that don't need the human to see them, agents have the `bash` MCP tool (`internal/agents/bash.go`), for agents whose own shell tool runs in a sandbox that gets in the way. byre runs `$SHELL -l -c <command>` itself, as the service user and outside the agent's sandbox, in the task's worktree with byre's environment plus the task's `PORT`, stdin empty, stdout and stderr into one buffer, in its own process group. The group gets `SIGKILL` after `timeout_ms` (default 120000, at most 600000) or when the call is cancelled (in a Claude session, an MCP call's context ends when Claude sends `control_cancel_request` for it, as on an interrupt, or when the session's output ends), and the reply says `killed after <timeout>`; otherwise it starts with the exit status (`exit status 1`, `signal: killed`). The output is the last 64 KiB, as raw text. Background children holding the output open are waited for at most 5s after the command exits, and the reply then says the output was cut off.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user