From 6f62d19905e1b3f0602ed5a9af0235188272e3a3 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Mon, 10 Aug 2026 19:08:58 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E4=B8=BA=20agent=20=E6=9A=B4?= =?UTF-8?q?=E9=9C=B2=208=20=E4=B8=AA=E7=BB=88=E7=AB=AF=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=EF=BC=88terminal=5Fcreate/list/send/read/wait=5Ffor/resize/sig?= =?UTF-8?q?nal/close=EF=BC=89=EF=BC=8C=E4=BE=A7=E8=BE=B9=E6=A0=8F=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=90=8C=E6=AD=A5=20agent=20=E7=BB=88=E7=AB=AF=20tab?= =?UTF-8?q?=EF=BC=8Cv0.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh.plugin.json | 2 +- package.json | 8 +- pnpm-lock.yaml | 31 ++- src/agent-pty.ts | 491 ++++++++++++++++++++++++++++++++++++ src/client/Sidebar.tsx | 57 ++++- src/client/TerminalView.tsx | 46 +++- src/client/api.ts | 3 + src/client/state.ts | 49 ++++ src/context-types.ts | 26 ++ src/index.ts | 189 +++++++++++++- src/tools.ts | 462 +++++++++++++++++++++++++++++++++ tests/agent-pty.spec.ts | 305 ++++++++++++++++++++++ tests/plugin-shape.spec.ts | 2 +- tests/smoke.spec.ts | 6 +- tests/unit.spec.ts | 58 ++++- 15 files changed, 1704 insertions(+), 31 deletions(-) create mode 100644 src/agent-pty.ts create mode 100644 src/tools.ts create mode 100644 tests/agent-pty.spec.ts diff --git a/dsh.plugin.json b/dsh.plugin.json index 2a97498..b977154 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,6 +1,6 @@ { "id": "dsh-external/dsh-better-sidebar", - "version": "0.1.9", + "version": "0.2.0", "main": "./lib/index.js", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离", "engines": { diff --git a/package.json b/package.json index 8baee56..13eacf6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-better-sidebar", - "version": "0.1.9", + "version": "0.2.0", "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session.", "private": true, "type": "module", @@ -50,6 +50,7 @@ }, "license": "MIT", "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-schema-form": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", @@ -59,8 +60,10 @@ "@deepseek-ai/dsh-client-web-react": "^0.0.1", "@deepseek-ai/dsh-host-webserver": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7", "react": "^18.2.0", "react-dom": "^18.2.0" @@ -101,6 +104,7 @@ "xterm": "^5.3.0" }, "devDependencies": { + "@deepseek-ai/dsh-agent": "link:/Users/menghuan/.dsh/source/current/packages/core/agent", "@deepseek-ai/dsh-client-runtime": "link:/Users/menghuan/.dsh/source/current/packages/client/runtime", "@deepseek-ai/dsh-client-schema-form": "link:/Users/menghuan/.dsh/source/current/packages/client/schema-form", "@deepseek-ai/dsh-client-ui-conversation": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation", @@ -110,8 +114,10 @@ "@deepseek-ai/dsh-client-web-react": "link:/Users/menghuan/.dsh/source/current/packages/client/web-react", "@deepseek-ai/dsh-host-webserver": "link:/Users/menghuan/.dsh/source/current/packages/host/webserver", "@deepseek-ai/dsh-invariants": "link:/Users/menghuan/.dsh/source/current/packages/support/invariants", + "@deepseek-ai/dsh-llm": "link:/Users/menghuan/.dsh/source/current/packages/llm/llm", "@deepseek-ai/dsh-session": "link:/Users/menghuan/.dsh/source/current/packages/core/session", "@deepseek-ai/dsh-settings": "link:/Users/menghuan/.dsh/source/current/packages/settings/settings", + "@deepseek-ai/dsh-tools": "link:/Users/menghuan/.dsh/source/current/packages/core/tools", "@types/node": "^24.0.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae6c51c..3ea70ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,39 +111,48 @@ importers: '@cordisjs/plugin-loader': specifier: ^1.0.0-rc.5 version: 1.0.0-rc.5(cordis@4.0.0-rc.7) + '@deepseek-ai/dsh-agent': + specifier: link:/Users/menghuan/.dsh/source/current/packages/core/agent + version: link:/Users/menghuan/.dsh/source/current/packages/core/agent '@deepseek-ai/dsh-client-runtime': specifier: link:/Users/menghuan/.dsh/source/current/packages/client/runtime - version: link:../../.dsh/source/current/packages/client/runtime + version: link:/Users/menghuan/.dsh/source/current/packages/client/runtime '@deepseek-ai/dsh-client-schema-form': specifier: link:/Users/menghuan/.dsh/source/current/packages/client/schema-form - version: link:../../.dsh/source/current/packages/client/schema-form + version: link:/Users/menghuan/.dsh/source/current/packages/client/schema-form '@deepseek-ai/dsh-client-ui-conversation': specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation - version: link:../../.dsh/source/current/packages/client/ui-conversation + version: link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-primitives - version: link:../../.dsh/source/current/packages/client/ui-primitives + version: link:/Users/menghuan/.dsh/source/current/packages/client/ui-primitives '@deepseek-ai/dsh-client-ui-settings': specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-settings - version: link:../../.dsh/source/current/packages/client/ui-settings + version: link:/Users/menghuan/.dsh/source/current/packages/client/ui-settings '@deepseek-ai/dsh-client-ui-slots': specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots - version: link:../../.dsh/source/current/packages/client/ui-slots + version: link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots '@deepseek-ai/dsh-client-web-react': specifier: link:/Users/menghuan/.dsh/source/current/packages/client/web-react - version: link:../../.dsh/source/current/packages/client/web-react + version: link:/Users/menghuan/.dsh/source/current/packages/client/web-react '@deepseek-ai/dsh-host-webserver': specifier: link:/Users/menghuan/.dsh/source/current/packages/host/webserver - version: link:../../.dsh/source/current/packages/host/webserver + version: link:/Users/menghuan/.dsh/source/current/packages/host/webserver '@deepseek-ai/dsh-invariants': specifier: link:/Users/menghuan/.dsh/source/current/packages/support/invariants - version: link:../../.dsh/source/current/packages/support/invariants + version: link:/Users/menghuan/.dsh/source/current/packages/support/invariants + '@deepseek-ai/dsh-llm': + specifier: link:/Users/menghuan/.dsh/source/current/packages/llm/llm + version: link:/Users/menghuan/.dsh/source/current/packages/llm/llm '@deepseek-ai/dsh-session': specifier: link:/Users/menghuan/.dsh/source/current/packages/core/session - version: link:../../.dsh/source/current/packages/core/session + version: link:/Users/menghuan/.dsh/source/current/packages/core/session '@deepseek-ai/dsh-settings': specifier: link:/Users/menghuan/.dsh/source/current/packages/settings/settings - version: link:../../.dsh/source/current/packages/settings/settings + version: link:/Users/menghuan/.dsh/source/current/packages/settings/settings + '@deepseek-ai/dsh-tools': + specifier: link:/Users/menghuan/.dsh/source/current/packages/core/tools + version: link:/Users/menghuan/.dsh/source/current/packages/core/tools '@types/node': specifier: ^24.0.0 version: 24.13.3 diff --git a/src/agent-pty.ts b/src/agent-pty.ts new file mode 100644 index 0000000..a424167 --- /dev/null +++ b/src/agent-pty.ts @@ -0,0 +1,491 @@ +/** + * Agent-owned terminal registry: a uuid-keyed table of long-lived PTY + * sessions created by the model through the `terminal_create` tool. Each + * handle survives across tool calls (and across WebSocket disconnects from + * the sidebar view) until the model calls `terminal_close` or the user + * closes the corresponding sidebar tab — tmux semantics, scoped per agent + * session. + * + * This is a parallel registry to {@link PtyManager}: UI tabs are keyed by + * `${sessionId}:${tabId}` and capped per session, while agent terminals are + * keyed by uuid and uncapped (the model is trusted to close unused ones). + * Both registries share the same shell resolver and spawn-helper fix. + */ +import { randomUUID } from 'node:crypto' +import * as nodePty from 'node-pty' +import { ensureSpawnHelper } from './pty-manager.ts' +import { SidebarError } from './wire.ts' + +/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */ +const TRANSCRIPT_LIMIT = 1 << 20 + +/** POSIX signals the registry forwards to a live pty. */ +export const ALLOWED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGHUP', 'SIGTSTP'] as const +/** Signal name accepted by `signal()`. */ +export type AgentTerminalSignal = (typeof ALLOWED_SIGNALS)[number] + +/** Default read page size (lines) when the caller omits `count`. */ +export const DEFAULT_READ_COUNT = 500 + +/** + * Serializable snapshot of one agent terminal — the shape the model sees + * through `terminal_list` and the sidebar sees through the push endpoint. + * Carries no pty reference and no transcript (those are reached through + * dedicated read/attach paths). + */ +export interface AgentTerminalSnapshot { + /** Stable opaque handle the model passes back to other terminal_* tools. */ + uuid: string + /** The conversation id this terminal belongs to (scope filter for list). */ + sessionId: string + /** Display title the model chose at create time. */ + title: string + /** The command the model asked to run at create time (verbatim). */ + command: string + /** Whether the top-level process has exited. */ + exited: boolean + /** Exit code if exited normally; absent until the process exits. */ + exitCode?: number | null + /** Exit signal name if the process was killed by a signal; null otherwise. */ + exitSignal?: string | null +} + +/** Map a POSIX signal number to its conventional name (best-effort). */ +const SIGNAL_NAMES: Record = { + 1: 'SIGHUP', 2: 'SIGINT', 3: 'SIGQUIT', 4: 'SIGILL', 6: 'SIGABRT', + 9: 'SIGKILL', 11: 'SIGSEGV', 13: 'SIGPIPE', 14: 'SIGALRM', 15: 'SIGTERM', + 17: 'SIGCHLD', 18: 'SIGCONT', 19: 'SIGSTOP', 20: 'SIGTSTP', +} + +/** Convert a raw signal number to a name (or null when absent/unknown). */ +function signalNameOf(signal: number | null | undefined): string | null { + if (signal === null || signal === undefined) return null + return SIGNAL_NAMES[signal] ?? `signal ${signal}` +} + +/** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */ +function locateNeedle(transcript: string, needle: string): { line: number; column: number } | undefined { + if (needle === '') return undefined + const idx = transcript.indexOf(needle) + if (idx === -1) return undefined + // Walk the transcript up to the match index, counting newlines to derive + // the 0-based line; the column is the offset within that line. + let line = 0 + let lineStart = 0 + for (let i = 0; i < idx; i += 1) { + if (transcript.charCodeAt(i) === 0x0a /* \n */) { + line += 1 + lineStart = i + 1 + } + } + return { line, column: idx - lineStart } +} + +/** One live agent terminal. */ +export interface AgentTerminalHandle { + /** Stable opaque handle. */ + uuid: string + /** Owning conversation id. */ + sessionId: string + /** Display title. */ + title: string + /** The command written to stdin right after spawn. */ + command: string + /** The working directory the process was spawned with. */ + cwd: string + /** The live pty process. */ + pty: nodePty.IPty + /** Output accumulated since spawn (bounded; head dropped when over the limit). */ + transcript: string + /** Whether the top-level process exited (transcript stays replayable). */ + exited: boolean + /** Exit code once known. */ + exitCode?: number | null + /** Exit signal number once known (POSIX only; undefined on Windows). */ + exitSignal?: number | null +} + +/** Read result shape (mirrors the official tool-pty terminal_read contract). */ +export interface AgentTerminalReadResult { + /** The slice of transcript text for the requested page. */ + text: string + /** Total lines in the retained transcript (the page may be a subset). */ + totalLines: number + /** 0-based index of the first line in `text` (inclusive). */ + lineBegin: number + /** 0-based index of the last line in `text` (exclusive). */ + lineEnd: number + /** Whether `text` was truncated to fit the read cap. */ + truncated: boolean +} + +/** Outcome of {@link AgentPtyRegistry.waitFor}. */ +export type AgentTerminalWaitResult = + | { + /** The needle was found in the transcript. */ + kind: 'found' + /** The matched substring. */ + needle: string + /** 0-based line index (in the retained transcript) where the needle first appeared. */ + line: number + /** 0-based column index within that line where the match starts. */ + column: number + /** Elapsed wall-clock milliseconds from the wait start to the match. */ + elapsedMs: number + } + | { + /** The needle did not appear before the timeout. */ + kind: 'timeout' + /** The needle that was awaited. */ + needle: string + /** The configured timeout in milliseconds. */ + timeoutMs: number + /** Total lines retained when the timeout fired (call terminal_read to inspect). */ + totalLines: number + } + | { + /** The terminal exited before the needle appeared. */ + kind: 'exited' + /** The needle that was awaited. */ + needle: string + /** The exit code, if known. */ + exitCode?: number | null + /** The exit signal name, if the process was killed by a signal. */ + exitSignal?: string | null + } + +/** Snapshot projection of a handle (drops the pty reference and transcript). */ +export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot { + const out: AgentTerminalSnapshot = { + uuid: handle.uuid, + sessionId: handle.sessionId, + title: handle.title, + command: handle.command, + exited: handle.exited, + } + if (handle.exited) { + out.exitCode = handle.exitCode ?? null + out.exitSignal = signalNameOf(handle.exitSignal) + } + return out +} + +/** + * The agent terminal registry. The constructor takes the resolved shell + * binary (the same `defaultShell()` the UI-tab registry uses) and runs the + * spawn-helper chmod fix once at construction so the first agent terminal + * does not race a lazy fixer. + */ +export class AgentPtyRegistry { + private readonly sessions = new Map() + private readonly changeListeners = new Set<() => void>() + + constructor(private readonly shell: string) { + ensureSpawnHelper() + } + + /** + * Spawn one agent terminal: start the shell in `cwd`, then write + * `command + '\n'` to stdin so the command runs in the fresh shell. The + * terminal stays alive after the command exits — the model can send more + * input through `terminal_send` until it calls `terminal_close` or the + * user closes the sidebar tab. An empty `command` spawns a bare shell. + * @returns the new handle's uuid (the model-facing opaque id). + */ + create( + sessionId: string, + title: string, + command: string, + cwd: string, + cols = 80, + rows = 24, + ): string { + const uuid = randomUUID() + const pty = nodePty.spawn(this.shell, [], { + name: 'xterm-256color', + cols: Math.max(2, Math.floor(cols)), + rows: Math.max(2, Math.floor(rows)), + cwd, + env: { ...process.env }, + }) + const handle: AgentTerminalHandle = { + uuid, + sessionId, + title, + command, + cwd, + pty, + transcript: '', + exited: false, + } + pty.onData((data) => { + handle.transcript += data + if (handle.transcript.length > TRANSCRIPT_LIMIT) { + handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT) + } + }) + pty.onExit(({ exitCode, signal }) => { + handle.exited = true + handle.exitCode = exitCode + handle.exitSignal = signal + this.notify() + }) + if (command !== '') { + // Write the command + Enter so it runs in the freshly spawned shell. + // Use \r (carriage return) — the actual character a terminal sends for + // the Enter key — not \n (line feed). PowerShell treats a bare \n as a + // soft line break (continuation prompt ">>") rather than a command + // submit; \r is the cross-shell Enter semantics on both POSIX and Windows. + try { + pty.write(`${command}\r`) + } catch { + // A spawn that failed between onData and onExit surfaces its own + // exit; the create call still returns the uuid so the model can + // read the transcript and see the failure. + } + } + this.sessions.set(uuid, handle) + this.notify() + return uuid + } + + /** All live agent terminals belonging to one conversation. */ + list(sessionId: string): AgentTerminalSnapshot[] { + const out: AgentTerminalSnapshot[] = [] + for (const handle of this.sessions.values()) { + if (handle.sessionId === sessionId) out.push(snapshotOf(handle)) + } + return out + } + + /** Resolve a live handle by uuid, or throw `not-found`. */ + private expect(uuid: string): AgentTerminalHandle { + const handle = this.sessions.get(uuid) + if (handle === undefined) { + throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404) + } + return handle + } + + /** Resolve a handle's snapshot, or undefined if it does not exist. */ + snapshot(uuid: string): AgentTerminalSnapshot | undefined { + const handle = this.sessions.get(uuid) + return handle === undefined ? undefined : snapshotOf(handle) + } + + /** Write raw text to a terminal's stdin (tmux `send-keys` semantics). */ + send(uuid: string, text: string): void { + const handle = this.expect(uuid) + if (handle.exited) { + throw new SidebarError('bad-request', `agent terminal "${uuid}" has exited`, 400) + } + handle.pty.write(text) + } + + /** + * Read one bounded page of the retained transcript. `offset` is a 0-based + * line index from the start of the retained transcript (default 0); + * `count` caps the page size (default 500). A negative `offset` reads + * from the end (e.g. -50 reads the last 50 lines). Returns `totalLines` + * so the model can paginate. + */ + read(uuid: string, offset?: number, count?: number): AgentTerminalReadResult { + const handle = this.expect(uuid) + const lines = handle.transcript.split('\n') + const totalLines = lines.length + const pageSize = Math.max(1, Math.min(count ?? DEFAULT_READ_COUNT, DEFAULT_READ_COUNT)) + let start: number + if (offset === undefined || offset === 0) { + start = 0 + } else if (offset < 0) { + // Negative offset: read from the end (e.g. -50 → last 50 lines). + start = Math.max(0, totalLines + offset) + } else { + start = Math.min(offset, totalLines) + } + const end = Math.min(start + pageSize, totalLines) + const slice = lines.slice(start, end).join('\n') + return { + text: slice, + totalLines, + lineBegin: start, + lineEnd: end, + truncated: false, + } + } + + /** Resize a terminal's pty (clamped to a 2..1024 sane range). */ + resize(uuid: string, cols: number, rows: number): void { + const handle = this.expect(uuid) + if (handle.exited) return + handle.pty.resize(Math.max(2, Math.floor(cols)), Math.max(2, Math.floor(rows))) + } + + /** + * Wait for `needle` to appear in a terminal's transcript, or for the + * terminal to exit, or for the timeout to elapse — whichever happens + * first. The wait polls the live transcript every ~50ms and short-circuits + * on `signal` abort (re-thrown as the abort reason so the tool layer + * surfaces cancellation). + * + * The match scans the FULL retained transcript on each poll, not just the + * delta since the last poll — a needle that scrolled past the most recent + * chunk but is still within the ~1 MiB bound is still a match. The + * returned line/column locate the FIRST occurrence (oldest), which is what + * a user watching the terminal would have seen first. + * + * The implementation uses polling (not pty onData subscription) because + * node-pty's onData fires before the registry's own onData listener + * updates the transcript (listener order is not guaranteed), and on + * Windows ConPTY output can arrive in bursts with batching delays that + * make event-driven wakeups unreliable. A 50ms poll is fast enough for + * interactive use and simple enough to be obviously correct. + * @param uuid - terminal to watch. + * @param needle - substring to search for (case-sensitive, verbatim). + * @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms. + * @param signal - caller-owned cancellation; aborts the wait re-throwing. + * @returns one of `found` / `timeout` / `exited`. + */ + async waitFor( + uuid: string, + needle: string, + timeoutMs = 10_000, + signal?: AbortSignal, + ): Promise { + if (needle === '') { + throw new SidebarError('bad-request', 'needle must be a non-empty string', 400) + } + const handle = this.expect(uuid) + const timeout = Math.max(100, Math.floor(timeoutMs)) + const start = Date.now() + const deadline = start + timeout + // Fast path: already exited, or the needle is already in the transcript + // (a `terminal_send` may have produced the expected output before this + // call even started). + if (handle.exited) { + return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) } + } + const firstHit = locateNeedle(handle.transcript, needle) + if (firstHit !== undefined) { + return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, elapsedMs: Date.now() - start } + } + // Poll loop: check the transcript every 50ms, exit on match / exit / + // abort / timeout. The handle is read live each iteration (its transcript + // and exited fields mutate as the pty produces output). + while (true) { + if (signal?.aborted) signal.throwIfAborted() + if (handle.exited) { + return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) } + } + const hit = locateNeedle(handle.transcript, needle) + if (hit !== undefined) { + return { kind: 'found', needle, line: hit.line, column: hit.column, elapsedMs: Date.now() - start } + } + if (Date.now() >= deadline) { + return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length } + } + await new Promise(resolve => { + const t = setTimeout(resolve, 50) + // Allow the Node process to exit even if the timer is pending. + if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref() + }) + } + } + + /** + * Send a POSIX signal to a terminal's foreground process. + * + * Two delivery paths, by signal kind: + * - **Interactive control signals** (SIGINT, SIGTSTP) are delivered by + * writing the corresponding control character to the pty stdin. This is + * how a real terminal sends Ctrl+C / Ctrl+Z: the byte hits the kernel + * line discipline (POSIX ISIG mode) or the ConPTY input pipeline + * (Windows), which translates it into a SIGINT/SIGTSTP for the + * foreground process group. This works on every platform — calling + * `node-pty.kill('SIGINT')` throws on Windows and is fragile on POSIX, + * but writing `\x03` is universally correct. + * - **Termination signals** (SIGKILL, SIGTERM, SIGHUP) use `pty.kill()`, + * which maps to the platform's process-termination path (POSIX + * `kill(2)`, Windows `TerminateProcess`). These cannot be faked with + * control characters. + */ + signal(uuid: string, signal: AgentTerminalSignal): void { + const handle = this.expect(uuid) + if (handle.exited) return + // Ctrl+C → 0x03 (ETX), Ctrl+Z → 0x1A (SUB). Writing these to the pty + // master is the cross-platform way to deliver the signal to the + // foreground process group through the terminal's input pipeline. + if (signal === 'SIGINT' || signal === 'SIGTSTP') { + const ctrlByte = signal === 'SIGINT' ? '\x03' : '\x1a' + try { + handle.pty.write(ctrlByte) + } catch { + // A pty that rejects writes is already tearing down; the next + // onExit will mark it exited. Not an error from the caller's view. + } + return + } + // SIGKILL / SIGTERM / SIGHUP: use the process-termination path. + try { + handle.pty.kill(signal) + } catch { + // node-pty on Windows rejects named signals other than the default; + // fall back to the default kill (TerminateProcess on Windows, + // SIGKILL-equivalent on POSIX) so the signal still takes effect. + try { + handle.pty.kill() + } catch { + // Already exited or gone; nothing left to kill. + } + } + } + + /** + * Close a terminal and drop its state. Idempotent: a second close of the + * same uuid is a no-op. Returns true iff a live handle was actually + * dropped. + */ + close(uuid: string): boolean { + const handle = this.sessions.get(uuid) + if (handle === undefined) return false + this.sessions.delete(uuid) + try { + handle.pty.kill() + } catch { + // Already exited or gone; nothing left to kill. + } + this.notify() + return true + } + + /** Resolve a live handle by uuid (for the WS attach path). */ + get(uuid: string): AgentTerminalHandle | undefined { + return this.sessions.get(uuid) + } + + /** + * Subscribe to registry changes (create / close / exit). The sidebar push + * endpoint uses this to forward snapshots to the connected view. Returns + * the unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.changeListeners.add(listener) + return () => { this.changeListeners.delete(listener) } + } + + /** Close every agent terminal (plugin teardown). */ + disposeAll(): void { + for (const uuid of [...this.sessions.keys()]) this.close(uuid) + } + + /** Fire every change listener (callers wrap in try/catch if needed). */ + private notify(): void { + for (const listener of [...this.changeListeners]) { + try { + listener() + } catch { + // A listener throwing must not break the others or the registry. + } + } + } +} diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 58015bb..a571424 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -16,6 +16,7 @@ import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutlin import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts' import { allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab, + reconcileAgentTerminals, resizeSplit, setWidth, toggleExpanded, togglePanel, TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode, } from './state.ts' @@ -137,6 +138,49 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { }, [sessionId, summaryCwd]) const cwd = summaryCwd ?? fetchedCwd + /** + * Agent terminals push: subscribe to the host's live list of agent-owned + * terminals for this session (created by the model through the + * `terminal_create` tool). The host pushes a JSON array on every + * create / close / exit; the sidebar reconciles the list into tabs + * (id `agent:`, title from the agent). A disconnected socket + * retries with a short backoff so a refresh or transient drop reattaches + * the same shell without losing the agent's work. + */ + useEffect(() => { + if (sessionId === undefined) return + let socket: WebSocket | null = null + let retry: number | undefined + let closed = false + const connect = (): void => { + if (closed) return + const url = new URL('/sidebar/ws/agent-terminals', location.origin) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + url.search = new URLSearchParams({ sessionId }).toString() + socket = new WebSocket(url.toString()) + socket.onmessage = (event) => { + if (typeof event.data !== 'string') return + try { + const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }> + if (!Array.isArray(list)) return + store.reduce(s => reconcileAgentTerminals(s, list)) + } catch { + // Malformed push: ignore (the next push will reconcile). + } + } + socket.onclose = () => { + if (!closed) retry = window.setTimeout(connect, 2000) + } + socket.onerror = () => { socket?.close() } + } + connect() + return () => { + closed = true + window.clearTimeout(retry) + socket?.close() + } + }, [sessionId, store]) + /** * Subagent auto-activation: the moment the current conversation spawns its * FIRST direct subagent (a 0 → N transition on the list feed) and the @@ -212,12 +256,21 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // A closed terminal releases its pty immediately — including when its // socket is mid-reconnect, where the unmount close frame never reaches // the host and the process would hold the quota until the grace ends. + // Agent terminals (tabId `agent:`) close through a different + // host route: the WS close frame is the primary path (sent by + // TerminalView on unmount), and the agent-pty.close HTTP route is the + // fallback when the WS is down. const current = store.getSnapshot().state const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId) const tab = leaf?.tabs.find(candidate => candidate.id === tabId) store.reduce(s => closeTab(s, paneId, tabId)) - if (sessionId !== undefined && tab?.type === 'terminal') { - void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ }) + if (tab?.type === 'terminal') { + if (tabId.startsWith('agent:')) { + const uuid = tabId.slice('agent:'.length) + void api.agentPtyClose(uuid).catch(() => { /* the host may already have released it */ }) + } else if (sessionId !== undefined) { + void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ }) + } } }, activateTab: (paneId, tabId) => { diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index 9e72015..d3ac7c3 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -7,6 +7,17 @@ * a failed pty spawn) stops the loop and shows the reason with a manual * retry, and repeated unreasoned failures surface the close code after three * attempts, so the banner never spins forever. + * + * Two attach modes share one upgrade endpoint: + * - `tabId` starting with `agent:` is an agent-owned terminal (created by + * the `terminal_create` tool). The uuid is the suffix after `agent:`; the + * view connects with `?uuid=...`. A close frame kills the pty (the agent's + * terminal closes when the user closes the tab); a bare socket drop + * leaves the pty alive (the agent owns the lifetime). + * - Any other `tabId` is a UI-tab terminal (the user created it from the + + * menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close + * frame schedules a 0-ms close; a bare socket drop gets the host's + * reconnect grace. */ import { useEffect, useRef, useState } from 'react' import { Terminal, type ITheme } from 'xterm' @@ -21,6 +32,19 @@ import css from './sidebar.module.css' /** How many consecutive unreasoned failures before showing the error banner. */ const FAILURE_LIMIT = 3 +/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */ +const AGENT_TAB_PREFIX = 'agent:' + +/** Whether a tab id refers to an agent-owned terminal. */ +function isAgentTab(tabId: string): boolean { + return tabId.startsWith(AGENT_TAB_PREFIX) +} + +/** Extract the agent terminal uuid from an `agent:` tab id. */ +function agentUuidOf(tabId: string): string { + return tabId.slice(AGENT_TAB_PREFIX.length) +} + /** * Curated ANSI palettes for the terminal. The surface colors (background, * foreground, cursor, selection) ride the theme tokens so the terminal @@ -97,14 +121,21 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: let failures = 0 const wsUrl = (): string => { - const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId }) - if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd) + const url = new URL('/sidebar/ws/terminal', location.origin) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + // Agent terminals attach by uuid (the host looks them up in the agent + // pty registry); UI-tab terminals attach by sessionId+tab (the host + // uses the UI-tab pty manager). Same upgrade endpoint, different query. + if (isAgentTab(tabId)) { + url.search = new URLSearchParams({ uuid: agentUuidOf(tabId) }).toString() + } else { + const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId }) + if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd) + url.search = params.toString() + } // Same construction the app's own downlink WebSockets use (new URL // over location.origin + protocol swap): whatever the environment // does to the app's websockets applies identically here. - const url = new URL('/sidebar/ws/terminal', location.origin) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - url.search = params.toString() return url.toString() } @@ -175,11 +206,14 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: schemeSub() inputSub.dispose() // The close frame tells the host the owning tab is GONE (immediate - // quota release). A bare unmount — conversation switch, re-render, + // pty release). A bare unmount — conversation switch, re-render, // page unload — leaves the tab open, so the socket drop alone hands // the process to the host's reconnect grace: switching back or // refreshing reattaches the SAME shell instead of respawning one. // (The host respawns on its own when the authoritative cwd changed.) + // Agent terminals follow the same rule: a close frame kills the pty + // (the user closed the sidebar tab); a bare socket drop leaves it + // alive (the agent owns the lifetime). if (!store.tabOpen(scope.sessionId, tabId) && socket !== null && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: 'close' })) diff --git a/src/client/api.ts b/src/client/api.ts index afddcb1..172ba40 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -119,6 +119,9 @@ export const api = { * this explicit route). */ ptyClose: (scope: SessionScope, tab: string) => call<{ ok: true }>('pty.close', scopePayload(scope, { tab })), + /** Release an agent terminal by uuid (tab closed while WS was down). */ + agentPtyClose: (uuid: string) => + call<{ ok: true }>('agent-pty.close', { uuid }), /** Read the side card preferences (plugin-global, no session scope). */ settingsGet: () => call<{ value?: unknown; revision?: number }>('settings.get', {}), diff --git a/src/client/state.ts b/src/client/state.ts index 6bda316..49f37a9 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -396,6 +396,55 @@ export function resizeSplit(node: SplitNode, splitId: string, index: number, del } } +/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */ +export const AGENT_TAB_PREFIX = 'agent:' + +/** + * Reconcile the sidebar's agent-terminal tabs with the host's live list. + * The host pushes the current list of agent terminals (created by the model + * through the `terminal_create` tool) over a dedicated WebSocket; this + * reducer mirrors that list into tabs: new uuids get a tab, vanished uuids + * lose theirs. The agent owns the lifetime — the user closing a tab sends a + * WS close frame that kills the pty, which fires a change, which converges + * the view. Idempotent: a no-op when the lists already match. + * @param state - the current per-session sidebar state. + * @param agentTerminals - the live agent terminal snapshots from the host. + * @returns the next state (or the same reference if no change was needed). + */ +export function reconcileAgentTerminals( + state: SidebarState, + agentTerminals: ReadonlyArray<{ uuid: string; title: string }>, +): SidebarState { + const existingTabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs) + const existingAgentTabs = existingTabs.filter(tab => tab.id.startsWith(AGENT_TAB_PREFIX)) + const existingUuids = new Set(existingAgentTabs.map(tab => tab.id.slice(AGENT_TAB_PREFIX.length))) + const serverUuids = new Set(agentTerminals.map(t => t.uuid)) + const toAdd = agentTerminals.filter(t => !existingUuids.has(t.uuid)) + const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(tab.id.slice(AGENT_TAB_PREFIX.length))) + if (toAdd.length === 0 && toRemove.length === 0) return state + // Remove tabs whose uuids vanished from the server list (the agent closed + // them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup. + let splits = state.splits + for (const tab of toRemove) { + const leaf = leafWithTab(splits, tab.id) + if (leaf !== undefined) { + splits = closeTab({ ...state, splits }, leaf.id, tab.id).splits + } + } + // Add tabs for new uuids (the agent created a terminal). They land in the + // active pane via openTab; the next reconcile is a no-op for them. + let next: SidebarState = { ...state, splits } + for (const terminal of toAdd) { + const tab: SidebarTab = { + id: `${AGENT_TAB_PREFIX}${terminal.uuid}`, + type: 'terminal', + title: terminal.title, + } + next = openTab(next, tab) + } + return next +} + // ── The per-session store ────────────────────────────────────────────────── const STORAGE_PREFIX = 'dsh-sidebar:v1' diff --git a/src/context-types.ts b/src/context-types.ts index 67c0418..7994734 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -274,6 +274,31 @@ export interface SidebarSettingsService { update(ns: string, patch: object, expectedRevision?: number): Promise } +/** + * The tools service face (mirror of @deepseek-ai/dsh-tools' ToolRegistry). + * The host half registers model-facing tools here; the registry attaches the + * returned disposer to the contributing fiber so unloading unregisters them. + */ +export interface SidebarToolsService { + /** Register one tool definition (raw JSON-Schema or defineTool-sugar form). */ + register(tool: unknown): () => void +} + +/** + * The agent face a tool sees on `exec.agent` (mirror of @deepseek-ai/dsh-agent's + * Agent). Only the slices the terminal tools touch are restated: the live + * session identity and its header cwd, both readonly. + */ +export interface SidebarAgent { + /** The live session identity shared with the session log. */ + readonly id: string + /** The live session this agent drives. */ + readonly session: { + /** The session's header (validated cwd, lineage metadata). */ + readonly header: { readonly cwd?: string } + } +} + declare module 'cordis' { interface Context { httpServer: SidebarHttpServer @@ -283,6 +308,7 @@ declare module 'cordis' { slots: SidebarSlotsService settings: SidebarSettingsService invariants: SidebarInvariantsService + tools: SidebarToolsService /** * Register a lifecycle callback (DSH-vendored cordis): runs at plugin * activation; its returned cleanup runs at disposal. diff --git a/src/index.ts b/src/index.ts index 92e6b54..951b12b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,8 @@ import { isTrustedApiRequest } from './trust-fence.ts' import * as git from './git.ts' import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts' +import { AgentPtyRegistry, type AgentTerminalHandle } from './agent-pty.ts' +import { registerTools } from './tools.ts' import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts' export { Config } @@ -37,8 +39,8 @@ export type { SidebarConfig, ResolvedSidebarConfig } /** Plugin identity for cordis.yml rows. */ export const name = 'dsh-better-sidebar' -/** Services required before mounting: the webserver routes, the session store, and the loader's connection row. */ -export const inject = ['httpServer', 'sessions', 'loader'] +/** Services required before mounting: the webserver routes, the session store, the loader's connection row, and the tool registry. */ +export const inject = ['httpServer', 'sessions', 'loader', 'tools'] /** Content types for the media route, by extension. */ const MEDIA_TYPES: Record = { @@ -135,10 +137,11 @@ export interface SidebarSettingsFace { update(patch: Record, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }> } -/** Build the API method table bound to the plugin context, pty manager, and resolved config. */ +/** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */ function buildApi( ctx: Context, ptyManager: PtyManager, + agentPtyRegistry: AgentPtyRegistry, resolved: ResolvedSidebarConfig, getSettings: () => SidebarSettingsFace | undefined, ): Record { @@ -240,6 +243,15 @@ function buildApi( ptyManager.close(`${sessionId}:${tab}`) return { ok: true } }, + // Release an agent terminal by uuid. The WS close frame already does + // this while the socket is open; this route covers the tab-close that + // happens while the socket is down (reconnect loop) so a closed agent + // tab never leaves a zombie pty behind. Idempotent. + 'agent-pty.close': (payload) => { + const uuid = requireString(payload, 'uuid') + agentPtyRegistry.close(uuid) + return { ok: true } + }, // The side card preferences. The settings service is optional in the // composition; while absent the routes report undefined and the client // keeps the schema defaults. Writes are revision-guarded: a stale editor @@ -287,6 +299,12 @@ export function apply(ctx: Context, config?: SidebarConfig): void { const trustedHosts = trustedHostsOf(ctx) const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts) const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession) + // The agent-owned terminal registry: parallel to the UI-tab ptyManager, + // keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`, + // uncapped, and torn down with the plugin. The model creates terminals here + // through the terminal_create tool; the sidebar view attaches through the + // same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=... + const agentPtyRegistry = new AgentPtyRegistry(defaultShell()) // ── User-facing "Side card" preferences ────────────────────────────────── // Register the namespace with the settings provider so the Settings page @@ -315,8 +333,15 @@ export function apply(ctx: Context, config?: SidebarConfig): void { } }) + // ── Model-facing tools (terminal_create / list / send / read / wait_for / resize / signal / close) ─ + // Registered once at activation; the disposer is attached to the plugin + // fiber by the tools registry, so unloading unregisters them. The cwd + // resolver threads the live session cwd (authoritative from the session + // store, falling back to the process cwd) so the model never passes it. + registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId)) + // ── JSON API ──────────────────────────────────────────────────────────── - const api = buildApi(ctx, ptyManager, resolved, () => settingsFace) + const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace) ctx.effect(() => ctx.httpServer.register({ kind: 'prefix', path: '/sidebar/api', @@ -398,6 +423,13 @@ export function apply(ctx: Context, config?: SidebarConfig): void { }), 'dsh-better-sidebar: /sidebar/file media route') // ── Terminal WebSocket ────────────────────────────────────────────────── + // One upgrade endpoint serves both UI-tab terminals (?tab=...) and + // agent-owned terminals (?uuid=...). The two paths attach to different + // registries but share the wire protocol: input frames are raw text, + // resize frames are JSON `{type:'resize',cols,rows}`, and a close frame + // `{type:'close'}` releases the underlying pty (immediate for agent + // terminals, scheduled-0 for UI tabs which keep the same reconnect grace + // contract the host has always had). const wss = new WebSocketServer({ noServer: true }) ctx.effect(() => ctx.httpServer.registerUpgrade({ path: '/sidebar/ws/terminal', @@ -406,30 +438,105 @@ export function apply(ctx: Context, config?: SidebarConfig): void { socket.destroy() return } - wss.handleUpgrade(req, socket, head, (ws) => { void attachTerminal(ctx, ptyManager, ws, req, resolved) }) + wss.handleUpgrade(req, socket, head, (ws) => { + void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved) + }) }, }), 'dsh-better-sidebar: terminal WebSocket') + // ── Agent terminals push WebSocket ────────────────────────────────────── + // Pushes the live list of agent terminals for one session to the sidebar + // view: the client mirrors the list into tabs (id `agent:`, + // title from the agent's `terminal_create` call). The host fires on every + // create / close / exit; the client reconciles by adding tabs for new + // uuids and dropping tabs whose uuids disappeared (the user closing a tab + // sends `{type:'close'}` on the terminal WS, which kills the pty, which + // fires a change here, which converges the view). + const agentListWss = new WebSocketServer({ noServer: true }) + ctx.effect(() => ctx.httpServer.registerUpgrade({ + path: '/sidebar/ws/agent-terminals', + handler: (req, socket, head) => { + if (!fence(req)) { + socket.destroy() + return + } + agentListWss.handleUpgrade(req, socket, head, (ws) => { + void attachAgentList(agentPtyRegistry, ws, req) + }) + }, + }), 'dsh-better-sidebar: agent-terminals push WebSocket') + ctx.effect(() => () => { ptyManager.disposeAll() + agentPtyRegistry.disposeAll() wss.close() + agentListWss.close() }, 'dsh-better-sidebar: teardown') } -/** Wire one terminal socket to its pty: replay transcript, pump both ways. */ +/** Push the live agent-terminal list for one session to a connected sidebar view. */ +async function attachAgentList( + registry: AgentPtyRegistry, + ws: WebSocket, + req: IncomingMessage, +): Promise { + try { + const url = new URL(req.url ?? '/', 'http://dsh.internal') + const sessionId = url.searchParams.get('sessionId') + if (sessionId === null) { + ws.close(1008, 'sessionId is required') + return + } + const send = (): void => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(registry.list(sessionId))) + } + } + send() + const unsubscribe = registry.subscribe(send) + ws.on('close', () => { unsubscribe() }) + ws.on('error', () => { unsubscribe() }) + } catch (error) { + ws.close(1011, error instanceof Error ? error.message : String(error)) + } +} + +/** + * Wire one terminal socket to its pty: replay transcript, pump both ways. + * Two attach modes share the wire protocol: + * - `?uuid=...` attaches to an agent-owned terminal (created by the + * `terminal_create` tool). The close frame kills the pty immediately + * (the agent's terminal closes when the user closes the sidebar tab); a + * bare socket drop (refresh, tab switch) leaves the pty alive for the + * reconnect grace, exactly like UI-tab terminals. + * - `?tab=...&sessionId=...` attaches to a UI-tab terminal (the user + * created it from the + menu). The close frame schedules a 0-ms close + * (the host's reconnect grace keeps the shell alive across a refresh). + */ async function attachTerminal( ctx: Context, ptyManager: PtyManager, + agentPtyRegistry: AgentPtyRegistry, ws: WebSocket, req: IncomingMessage, resolved: ResolvedSidebarConfig, ): Promise { try { const url = new URL(req.url ?? '/', 'http://dsh.internal') + const uuid = url.searchParams.get('uuid') + if (uuid !== null) { + const handle = agentPtyRegistry.get(uuid) + if (handle === undefined) { + ws.close(1011, `agent terminal "${uuid}" not found`) + return + } + pumpAgentTerminal(agentPtyRegistry, handle, ws, resolved) + return + } const sessionId = url.searchParams.get('sessionId') const tabId = url.searchParams.get('tab') if (sessionId === null || tabId === null) { - ws.close(1008, 'sessionId and tab are required') + ws.close(1008, 'either ?uuid or ?sessionId+?tab are required') return } const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined) @@ -487,3 +594,71 @@ async function attachTerminal( ws.close(1011, error instanceof Error ? error.message : String(error)) } } + +/** + * Pump one agent terminal's pty to a connected view. The close frame kills + * the pty immediately (the agent's terminal closes when the user closes the + * sidebar tab); a bare socket drop leaves the pty alive — the agent owns + * the lifetime, and only `terminal_close`, a `{type:'close'}` frame, or + * plugin teardown kills it. + */ +function pumpAgentTerminal( + registry: AgentPtyRegistry, + handle: AgentTerminalHandle, + ws: WebSocket, + _resolved: ResolvedSidebarConfig, +): void { + if (handle.transcript !== '') ws.send(handle.transcript) + const onData = (data: string): void => { + if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) { + ws.send(data) + } + } + const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => { + onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`) + } + const dataSub = handle.pty.onData(onData) + const exitSub = handle.pty.onExit(onExit) + ws.on('message', (data) => { + if (handle.exited) return + const text = data.toString('utf8') + let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null + try { + const parsed: unknown = JSON.parse(text) + if (parsed !== null && typeof parsed === 'object') { + control = parsed as { type?: unknown; cols?: unknown; rows?: unknown } + } + } catch { + // Not JSON: terminal input. + } + if (control !== null && control.type === 'close') { + // The user closed the sidebar tab: kill the pty immediately. The + // agent's next terminal_list / terminal_send will see it gone. + registry.close(handle.uuid) + return + } + if ( + control !== null + && control.type === 'resize' + && typeof control.cols === 'number' && typeof control.rows === 'number' + ) { + handle.pty.resize(Math.max(2, Math.floor(control.cols)), Math.max(2, Math.floor(control.rows))) + } else if (control === null) { + // Raw text input (a JSON-looking string the pty would have received + // verbatim is reachable in theory but is exotic for an agent terminal; + // preserve the UI-tab semantics and forward as input). + handle.pty.write(text) + } + // An unrecognized JSON control frame is dropped (the UI-tab path also + // treats non-resize JSON controls as input, but for an agent terminal + // there is no realistic input that is also valid JSON). + }) + ws.on('close', () => { + dataSub.dispose() + exitSub.dispose() + // A bare socket drop (refresh, tab switch) leaves the agent's pty alive. + // The agent owns the lifetime: only `terminal_close`, a `{type:'close'}` + // frame, or plugin teardown kills it. A reconnecting view reattaches the + // same shell and gets the full transcript replayed. + }) +} diff --git a/src/tools.ts b/src/tools.ts new file mode 100644 index 0000000..7cff9f6 --- /dev/null +++ b/src/tools.ts @@ -0,0 +1,462 @@ +/** + * Eight model-facing tools for the agent-owned sidebar terminals (tmux + * semantics: spawn-and-detach, send-keys, read, wait-for, resize, signal, + * close, list). Each tool binds to the calling agent's session through + * `exec.agent.session.id`, so the model never passes a sessionId — the + * agent identity is the scope. + * + * Conventions (per plugin-development-guide.md §3): + * C1 — parameters schema-validated before `execute` runs. + * C4 — `execute` returns one canonical JSON value; `render` is a separate + * pure text projection. + * C6 — `exec.signal.throwIfAborted()` before any spawn. + * C10 — no UI/transport vocabulary in the canonical value. + */ +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Context } from './context-types.ts' +import { + AgentPtyRegistry, + ALLOWED_SIGNALS, + type AgentTerminalSignal, + type AgentTerminalSnapshot, +} from './agent-pty.ts' + +/** Maximum UTF-8 bytes of one `terminal_read` result text. */ +const READ_BYTE_LIMIT = 256 * 1024 + +/** Bound a string to a byte limit, marking truncation. */ +function boundBytes(text: string, maxBytes: number): { text: string; truncated: boolean } { + const bytes = Buffer.byteLength(text, 'utf8') + if (bytes <= maxBytes) return { text, truncated: false } + // Trim by character count proportional to the overage, then walk back to a + // valid UTF-8 boundary (Buffer.subarray with garbage on a trailing multibyte + // sequence would decode to U+FFFD). + const ratio = maxBytes / bytes + const cut = Math.floor(text.length * ratio) + const buf = Buffer.from(text, 'utf8').subarray(0, maxBytes) + return { text: buf.toString('utf8').slice(0, cut), truncated: true } +} + +/** Pure text projection helper (the canonical value is already structured). */ +function textRender(fn: (value: T) => string): (_args: unknown, value: unknown) => ContentBlock[] { + return (_args, value) => [{ type: 'text', text: fn(value as T) }] +} + +/** Extract the calling agent or throw the canonical "no agent" error. */ +function requireAgent(agent: Agent | undefined): Agent { + if (agent === undefined) { + throw new Error('sidebar terminal tools require an initiating agent') + } + return agent +} + +/** Resolve the calling agent's session id (the registry scope filter). */ +function sessionOf(exec: ToolRunContext): { agent: Agent; sessionId: string } { + const agent = requireAgent(exec.agent) + return { agent, sessionId: agent.session.id } +} + +/** + * Register the seven terminal tools against the host tool registry. The + * `resolveCwd` callback threads the live session cwd (authoritative from the + * session store, falling back to the process cwd) so a freshly-created + * terminal lands in the right directory without the model passing it. + * @param ctx - host plugin context (carries the tools service). + * @param registry - the agent-owned terminal registry. + * @param resolveCwd - live cwd resolver for one session id. + */ +export function registerTools( + ctx: Context, + registry: AgentPtyRegistry, + resolveCwd: (sessionId: string) => string, +): void { + ctx.tools.register(defineTool({ + name: 'terminal_create', + description: + 'Open a persistent terminal in the sidebar and run a command in it. ' + + 'Spawns an interactive shell, writes the command + Enter to its stdin, and returns a uuid handle. ' + + 'The terminal stays alive after the command exits — send more input with terminal_send (set submit=true to run a command), ' + + 'read output with terminal_read, send Ctrl+C with terminal_signal(signal="SIGINT"), ' + + 'and close it with terminal_close when done. ' + + 'Use this for interactive shells, REPLs, long-running dev servers, ' + + 'or any work that needs persistent terminal state across tool calls. ' + + 'The terminal appears as a new tab in the right sidebar (titled with the `title` you provide) so the user can watch and interact with it.', + parameters: { + title: { + type: 'string', + required: true, + description: 'Short human-readable label for the terminal tab (e.g. "dev server", "python repl").', + }, + command: { + type: 'string', + required: true, + description: 'Shell command to run in the freshly spawned shell. The host appends an Enter key automatically — do NOT include a trailing newline. Pass "" to open a bare shell with no command.', + }, + }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + uuid: { type: 'string', required: true, description: 'Opaque handle for the new terminal. Pass to terminal_send / terminal_read / terminal_resize / terminal_signal / terminal_close.' }, + title: { type: 'string', required: true, description: 'The title you provided (echoed for confirmation).' }, + }, + }, + render: textRender((v: { uuid: string; title: string }) => + `Opened terminal "${v.title}" (uuid: ${v.uuid}). The sidebar tab appears automatically; use terminal_read to see output and terminal_send (with submit=true) to run more commands.`, + ), + }, + execute: (args: { title: string; command: string }, exec) => { + exec.signal.throwIfAborted() + const { sessionId } = sessionOf(exec) + const cwd = resolveCwd(sessionId) + const uuid = registry.create(sessionId, args.title, args.command, cwd, 80, 24) + return Promise.resolve({ uuid, title: args.title }) + }, + })) + + ctx.tools.register(defineTool({ + name: 'terminal_list', + description: + 'List every terminal the current agent has opened in this session. Returns each terminal\'s uuid, title, ' + + 'the command it was started with, and whether the top-level process has exited (with exit code/signal if so). ' + + 'Use this to recover state after a long sequence of tool calls or to find a terminal you forgot to close.', + parameters: {}, + output: { + schema: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + uuid: { type: 'string', required: true }, + title: { type: 'string', required: true }, + command: { type: 'string', required: true }, + exited: { type: 'boolean', required: true }, + exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }] }, + exitSignal: { oneOf: [{ type: 'string' }, { type: 'null' }] }, + }, + }, + }, + render: (_args, value) => { + const list = value as AgentTerminalSnapshot[] + if (list.length === 0) return [{ type: 'text', text: 'No agent terminals open in this session.' }] + const lines = list.map((t) => { + const status = t.exited + ? `exited (code ${t.exitCode ?? '?'}, signal ${t.exitSignal ?? 'none'})` + : 'running' + return ` ${t.uuid} "${t.title}" [${status}] $ ${t.command}` + }) + return [{ type: 'text', text: `Agent terminals in this session:\n${lines.join('\n')}` }] + }, + }, + execute: (_args, exec) => { + const { sessionId } = sessionOf(exec) + return Promise.resolve(registry.list(sessionId)) + }, + })) + + ctx.tools.register(defineTool({ + name: 'terminal_send', + description: + 'Send raw text (keystrokes) to a terminal opened with terminal_create — tmux send-keys semantics. ' + + 'The text is written verbatim to the pty stdin. ' + + 'To submit a command, set submit=true (appends an Enter key); do NOT put "\\n" or "\\r" in the text yourself. ' + + 'To send Ctrl+C (interrupt the running command), use the terminal_signal tool with signal="SIGINT" — do NOT try to send the control character "\\u0003" as text. ' + + 'Similarly use terminal_signal for Ctrl+D (EOF), Ctrl+Z (suspend), etc. ' + + 'This tool does NOT wait for the command to finish or for output to settle — pair with terminal_read to observe the result. ' + + 'Throws if the terminal has exited.', + parameters: { + uuid: { + type: 'string', + required: true, + description: 'Terminal uuid from terminal_create or terminal_list.', + }, + text: { + type: 'string', + required: true, + description: 'UTF-8 text to write to the terminal stdin (verbatim, no shell escaping). Do not include trailing newlines — use the submit flag instead.', + }, + submit: { + type: 'boolean', + description: 'Append an Enter key (carriage return) after the text to submit a command. Default: false. Set to true when sending a command to run; leave false for partial input or control sequences.', + }, + }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + uuid: { type: 'string', required: true }, + bytes: { type: 'integer', required: true, description: 'Number of UTF-8 bytes written (including the Enter key if submit was true).' }, + }, + }, + render: textRender((v: { uuid: string; bytes: number }) => + `Sent ${v.bytes} byte(s) to terminal ${v.uuid}.`, + ), + }, + execute: (args: { uuid: string; text: string; submit?: boolean }, exec) => { + exec.signal.throwIfAborted() + const payload = args.submit === true ? `${args.text}\r` : args.text + registry.send(args.uuid, payload) + return Promise.resolve({ uuid: args.uuid, bytes: Buffer.byteLength(payload, 'utf8') }) + }, + })) + + ctx.tools.register(defineTool({ + name: 'terminal_read', + description: + 'Read a bounded page of retained output from an agent terminal without sending input. ' + + 'The host keeps up to ~1 MiB of scrollback; this tool returns up to 500 lines per call. ' + + 'Use `offset` to paginate forward ( 0-based from the start of the retained transcript ) or backward ( negative reads from the end, e.g. -50 reads the last 50 lines ). ' + + 'Returns `totalLines` so you know how much scrollback remains. ' + + 'Output is bounded to 256 KiB per call; longer pages are truncated with the `truncated` flag.', + parameters: { + uuid: { + type: 'string', + required: true, + description: 'Terminal uuid from terminal_create or terminal_list.', + }, + offset: { + type: 'number', + description: '0-based line offset from the start of the retained transcript (default 0). Negative reads from the end (e.g. -50 = last 50 lines).', + }, + count: { + type: 'number', + description: 'Maximum lines to return (default 500, hard cap 500).', + }, + }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + text: { type: 'string', required: true, description: 'The slice of transcript for the requested page.' }, + totalLines: { type: 'integer', required: true, description: 'Total lines in the retained transcript.' }, + lineBegin: { type: 'integer', required: true, description: '0-based index of the first line in `text` (inclusive).' }, + lineEnd: { type: 'integer', required: true, description: '0-based index of the last line in `text` (exclusive).' }, + truncated: { type: 'boolean', required: true, description: 'Whether `text` was truncated to fit the 256 KiB read cap.' }, + }, + }, + render: (_args, value) => { + const v = value as { text: string; totalLines: number; lineBegin: number; lineEnd: number; truncated: boolean } + const head = `[lines ${v.lineBegin}..${v.lineEnd} of ${v.totalLines}${v.truncated ? '; truncated to 256KiB' : ''}]` + return [{ type: 'text', text: `${head}\n${v.text}` }] + }, + }, + execute: (args: { uuid: string; offset?: number; count?: number }, exec) => { + exec.signal.throwIfAborted() + const result = registry.read(args.uuid, args.offset, args.count) + const bounded = boundBytes(result.text, READ_BYTE_LIMIT) + return Promise.resolve({ + text: bounded.text, + totalLines: result.totalLines, + lineBegin: result.lineBegin, + lineEnd: result.lineEnd, + truncated: bounded.truncated, + }) + }, + })) + + ctx.tools.register(defineTool({ + name: 'terminal_wait_for', + description: + 'Block until a substring appears in a terminal\'s retained transcript, or until the timeout elapses, or until the terminal exits — whichever happens first. ' + + 'Use this to synchronize on command completion cues ( e.g. a shell prompt, "done", "Listening on", "Build successful" ) ' + + 'without busy-polling terminal_read. ' + + 'The wait scans the FULL retained transcript (up to ~1 MiB) on every poll, so a needle that scrolled past the most recent chunk is still a match. ' + + 'Returns `found` with the line/column of the first occurrence, `timeout` if the needle did not appear in time, or `exited` if the terminal process died before the needle appeared. ' + + 'Default timeout is 10 seconds; raise it for long-running commands ( dev servers, test suites ). ' + + 'The wait is cooperative: a tool-call cancel ( or agent turn end ) aborts it immediately.', + parameters: { + uuid: { + type: 'string', + required: true, + description: 'Terminal uuid from terminal_create or terminal_list.', + }, + needle: { + type: 'string', + required: true, + description: 'Substring to wait for (case-sensitive, verbatim). Must be non-empty.', + }, + timeout_ms: { + type: 'number', + description: 'Maximum wait in milliseconds (default 10000, i.e. 10s). Clamped to a minimum of 100ms.', + }, + }, + output: { + schema: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'found' }, + needle: { type: 'string', required: true }, + line: { type: 'integer', required: true, description: '0-based line index in the retained transcript where the needle first appeared.' }, + column: { type: 'integer', required: true, description: '0-based column index within that line where the match starts.' }, + elapsed_ms: { type: 'integer', required: true, description: 'Wall-clock milliseconds from wait start to match.' }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'timeout' }, + needle: { type: 'string', required: true }, + timeout_ms: { type: 'integer', required: true, description: 'The configured timeout that elapsed.' }, + total_lines: { type: 'integer', required: true, description: 'Total lines retained when the timeout fired. Call terminal_read to inspect the tail.' }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'exited' }, + needle: { type: 'string', required: true }, + exit_code: { type: 'integer', description: 'Exit code, if known.' }, + exit_signal: { type: 'string', description: 'Exit signal name, if killed by a signal.' }, + }, + }, + ], + }, + render: (_args, value) => { + const v = value as { kind: 'found' | 'timeout' | 'exited'; needle: string; elapsed_ms?: number; timeout_ms?: number; line?: number; column?: number; exit_code?: number | null; exit_signal?: string | null } + if (v.kind === 'found') { + return [{ type: 'text', text: `Found "${v.needle}" at line ${v.line}, column ${v.column} (after ${v.elapsed_ms}ms).` }] + } + if (v.kind === 'timeout') { + return [{ type: 'text', text: `Timed out after ${v.timeout_ms}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.` }] + } + const exitInfo = v.exit_code !== undefined ? ` (exit code ${v.exit_code})` : '' + return [{ type: 'text', text: `Terminal exited before "${v.needle}" appeared${exitInfo}.` }] + }, + }, + async execute(args: { uuid: string; needle: string; timeout_ms?: number }, exec) { + exec.signal.throwIfAborted() + if (args.needle === '') { + throw new Error('needle must be a non-empty string') + } + const timeoutMs = args.timeout_ms ?? 10_000 + const result = await registry.waitFor(args.uuid, args.needle, timeoutMs, exec.signal) + // Project the registry's camelCase result into the snake_case canonical + // value declared by the output schema (the model sees snake_case). + if (result.kind === 'found') { + return { kind: 'found' as const, needle: result.needle, line: result.line, column: result.column, elapsed_ms: result.elapsedMs } + } + if (result.kind === 'timeout') { + return { kind: 'timeout' as const, needle: result.needle, timeout_ms: result.timeoutMs, total_lines: result.totalLines } + } + const out: { kind: 'exited'; needle: string; exit_code?: number; exit_signal?: string } = { + kind: 'exited', + needle: result.needle, + } + if (result.exitCode !== null && result.exitCode !== undefined) out.exit_code = result.exitCode + if (result.exitSignal !== null && result.exitSignal !== undefined) out.exit_signal = result.exitSignal + return out + }, + })) + + ctx.tools.register(defineTool({ + name: 'terminal_resize', + description: + 'Resize an agent terminal\'s pty ( cols × rows ). The host clamps both to a 2..1024 sane range. ' + + 'Most shells redraw their prompt and any full-screen TUI on the next output frame. ' + + 'No-op if the terminal has exited.', + parameters: { + uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' }, + cols: { type: 'integer', required: true, description: 'New column count ( clamped to 2..1024 ).' }, + rows: { type: 'integer', required: true, description: 'New row count ( clamped to 2..1024 ).' }, + }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + uuid: { type: 'string', required: true }, + cols: { type: 'integer', required: true }, + rows: { type: 'integer', required: true }, + }, + }, + render: textRender((v: { uuid: string; cols: number; rows: number }) => + `Resized terminal ${v.uuid} to ${v.cols}×${v.rows}.`, + ), + }, + execute: (args: { uuid: string; cols: number; rows: number }, exec) => { + exec.signal.throwIfAborted() + registry.resize(args.uuid, args.cols, args.rows) + return Promise.resolve({ uuid: args.uuid, cols: args.cols, rows: args.rows }) + }, + })) + + ctx.tools.register(defineTool({ + name: 'terminal_signal', + description: + 'Send a POSIX signal to an agent terminal\'s foreground process — this is how you send Ctrl+C, Ctrl+D, Ctrl+Z, etc. ' + + 'Use signal="SIGINT" for Ctrl+C (interrupt the running command), signal="SIGTERM" to request termination, ' + + 'signal="SIGKILL" to force-kill the pty, signal="SIGHUP" to hang up (many shells exit), signal="SIGTSTP" for Ctrl+Z (suspend). ' + + 'Do NOT try to send control characters (like "\\u0003") through terminal_send — use this tool instead. ' + + 'On Windows, only SIGKILL and SIGTERM are effective — others are accepted but may no-op. ' + + 'No-op if the terminal has already exited. Use terminal_close to dispose of the terminal entirely.', + parameters: { + uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' }, + signal: { + type: 'string', + required: true, + enum: ALLOWED_SIGNALS as readonly string[], + description: 'Signal to deliver: SIGINT (Ctrl+C) | SIGTERM | SIGKILL | SIGHUP | SIGTSTP (Ctrl+Z).', + }, + }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + uuid: { type: 'string', required: true }, + signal: { type: 'string', required: true }, + }, + }, + render: textRender((v: { uuid: string; signal: AgentTerminalSignal }) => + `Sent ${v.signal} to terminal ${v.uuid}.`, + ), + }, + execute: (args: { uuid: string; signal: AgentTerminalSignal }, exec) => { + exec.signal.throwIfAborted() + registry.signal(args.uuid, args.signal) + return Promise.resolve({ uuid: args.uuid, signal: args.signal }) + }, + })) + + ctx.tools.register(defineTool({ + name: 'terminal_close', + description: + 'Close an agent terminal and release its process. The uuid becomes invalid for all subsequent tool calls. ' + + 'Idempotent: closing an already-closed uuid is a no-op. ' + + 'The corresponding sidebar tab is removed automatically when the host pushes the updated terminal list. ' + + 'Always close terminals you no longer need — the host keeps the pty alive until you do.', + parameters: { + uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' }, + }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + uuid: { type: 'string', required: true }, + closed: { type: 'boolean', required: true, description: 'Whether a live terminal was actually dropped (false if the uuid was already gone).' }, + }, + }, + render: textRender((v: { uuid: string; closed: boolean }) => + v.closed ? `Closed terminal ${v.uuid}.` : `Terminal ${v.uuid} was already closed.`, + ), + }, + execute: (args: { uuid: string }, exec) => { + exec.signal.throwIfAborted() + const closed = registry.close(args.uuid) + return Promise.resolve({ uuid: args.uuid, closed }) + }, + })) +} diff --git a/tests/agent-pty.spec.ts b/tests/agent-pty.spec.ts new file mode 100644 index 0000000..a8afa7e --- /dev/null +++ b/tests/agent-pty.spec.ts @@ -0,0 +1,305 @@ +/** + * AgentPtyRegistry unit tests: spawn real ptys (mirroring the pty-manager + * smoke tests) and verify uuid-keyed CRUD, transcript bounding, exit + * tracking, and change-event subscription. The "AttachConsole failed" + * console noise from node-pty's ConPTY module on Windows is expected and + * does not affect the assertions. + */ +import { describe, expect, it } from 'vitest' +import { AgentPtyRegistry, ALLOWED_SIGNALS, snapshotOf, type AgentTerminalSnapshot } from '../src/agent-pty.ts' + +/** + * Resolve a shell binary for tests: on Windows use PowerShell (available on + * every modern Windows host), on POSIX use /bin/sh (universally available). + */ +function testShell(): string { + return process.platform === 'win32' ? 'powershell.exe' : '/bin/sh' +} + +/** Wait for a terminal's transcript to contain a substring (or timeout). */ +async function waitForTranscript( + registry: AgentPtyRegistry, + uuid: string, + needle: string, + timeoutMs = 5000, +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const handle = registry.get(uuid) + if (handle !== undefined && handle.transcript.includes(needle)) return handle.transcript + await new Promise(resolve => setTimeout(resolve, 50)) + } + const handle = registry.get(uuid) + return handle?.transcript ?? '' +} + +describe('AgentPtyRegistry', () => { + it('creates a terminal with a uuid, writes the command to stdin, and lists it', async () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const uuid = registry.create('s1', 'echo test', 'echo hello-agent-pty', process.cwd(), 80, 24) + expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) + const list = registry.list('s1') + expect(list).toHaveLength(1) + expect(list[0]!.uuid).toBe(uuid) + expect(list[0]!.title).toBe('echo test') + expect(list[0]!.command).toBe('echo hello-agent-pty') + expect(list[0]!.exited).toBe(false) + // The command was written to stdin; wait for the output. + const transcript = await waitForTranscript(registry, uuid, 'hello-agent-pty') + expect(transcript).toContain('hello-agent-pty') + } finally { + registry.disposeAll() + } + }) + + it('spawns a bare shell when command is empty', () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const uuid = registry.create('s1', 'bare', '', process.cwd(), 80, 24) + const handle = registry.get(uuid) + expect(handle).toBeDefined() + expect(handle!.command).toBe('') + expect(handle!.exited).toBe(false) + // The pty process exists; its transcript may take a moment to + // accumulate on Windows ConPTY. The handle is alive and listed. + expect(registry.list('s1')).toHaveLength(1) + } finally { + registry.disposeAll() + } + }) + + it('sends raw text to stdin (send-keys semantics)', async () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const uuid = registry.create('s1', 'sender', '', process.cwd(), 80, 24) + // Wait for the shell prompt, then send a command. + await waitForTranscript(registry, uuid, '', 1000) + // The registry's send() writes verbatim; the caller (tool layer) is + // responsible for appending \r when submit=true. Here we test the + // registry directly: send text + \r to submit. + registry.send(uuid, 'echo sent-via-send\r') + const transcript = await waitForTranscript(registry, uuid, 'sent-via-send') + expect(transcript).toContain('sent-via-send') + } finally { + registry.disposeAll() + } + }) + + it('reads a bounded page of the transcript', async () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const uuid = registry.create('s1', 'reader', 'echo line1\necho line2\necho line3', process.cwd(), 80, 24) + await waitForTranscript(registry, uuid, 'line3') + const page = registry.read(uuid) + expect(page.totalLines).toBeGreaterThan(0) + expect(page.text).toContain('line1') + expect(page.text).toContain('line3') + expect(page.truncated).toBe(false) + // Negative offset reads from the end. + const tail = registry.read(uuid, -2) + expect(tail.lineBegin).toBeGreaterThanOrEqual(0) + expect(tail.lineEnd).toBe(tail.lineBegin + tail.text.split('\n').length) + } finally { + registry.disposeAll() + } + }) + + it('resizes without throwing', () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const uuid = registry.create('s1', 'resizer', '', process.cwd(), 80, 24) + expect(() => registry.resize(uuid, 120, 40)).not.toThrow() + } finally { + registry.disposeAll() + } + }) + + it('closes a terminal and removes it from the list', () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const uuid = registry.create('s1', 'closer', '', process.cwd(), 80, 24) + expect(registry.list('s1')).toHaveLength(1) + const closed = registry.close(uuid) + expect(closed).toBe(true) + expect(registry.list('s1')).toHaveLength(0) + // Closing again is a no-op. + const closedAgain = registry.close(uuid) + expect(closedAgain).toBe(false) + } finally { + registry.disposeAll() + } + }) + + it('scopes list by session id', () => { + const registry = new AgentPtyRegistry(testShell()) + try { + registry.create('s1', 'a', '', process.cwd(), 80, 24) + registry.create('s1', 'b', '', process.cwd(), 80, 24) + registry.create('s2', 'c', '', process.cwd(), 80, 24) + expect(registry.list('s1')).toHaveLength(2) + expect(registry.list('s2')).toHaveLength(1) + expect(registry.list('s3')).toHaveLength(0) + } finally { + registry.disposeAll() + } + }) + + it('fires change listeners on create, close, and exit', async () => { + const registry = new AgentPtyRegistry(testShell()) + try { + let changeCount = 0 + registry.subscribe(() => { changeCount += 1 }) + const uuid = registry.create('s1', 'watched', '', process.cwd(), 80, 24) + // create fires one change. + expect(changeCount).toBeGreaterThanOrEqual(1) + registry.close(uuid) + // close fires another change. + expect(changeCount).toBeGreaterThanOrEqual(2) + } finally { + registry.disposeAll() + } + }) + + it('disposeAll closes every terminal', () => { + const registry = new AgentPtyRegistry(testShell()) + registry.create('s1', 'a', '', process.cwd(), 80, 24) + registry.create('s2', 'b', '', process.cwd(), 80, 24) + registry.disposeAll() + expect(registry.list('s1')).toHaveLength(0) + expect(registry.list('s2')).toHaveLength(0) + }) + + it('snapshotOf drops the pty reference and transcript', () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const uuid = registry.create('s1', 'snap', '', process.cwd(), 80, 24) + const handle = registry.get(uuid)! + const snap = snapshotOf(handle) + expect(snap.uuid).toBe(uuid) + expect(snap.title).toBe('snap') + expect(snap.exited).toBe(false) + // No pty or transcript fields on the snapshot. + expect('pty' in snap).toBe(false) + expect('transcript' in snap).toBe(false) + } finally { + registry.disposeAll() + } + }) + + it('ALLOWED_SIGNALS includes SIGINT, SIGTERM, SIGKILL, SIGHUP, SIGTSTP', () => { + expect(ALLOWED_SIGNALS).toContain('SIGINT') + expect(ALLOWED_SIGNALS).toContain('SIGTERM') + expect(ALLOWED_SIGNALS).toContain('SIGKILL') + expect(ALLOWED_SIGNALS).toContain('SIGHUP') + expect(ALLOWED_SIGNALS).toContain('SIGTSTP') + }) + + it('waitFor returns found when the needle is already in the transcript', async () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const uuid = registry.create('s1', 'echo-test', 'echo wait-for-fast', process.cwd(), 80, 24) + await waitForTranscript(registry, uuid, 'wait-for-fast') + // The needle is already present; waitFor should return immediately. + const result = await registry.waitFor(uuid, 'wait-for-fast', 2000) + expect(result.kind).toBe('found') + if (result.kind === 'found') { + expect(result.needle).toBe('wait-for-fast') + expect(result.elapsedMs).toBeLessThan(500) + } + } finally { + registry.disposeAll() + } + }) + + it('waitFor returns found after the needle appears (async output)', async () => { + const registry = new AgentPtyRegistry(testShell()) + try { + // Spawn a shell WITH a command (so output is guaranteed), but start + // the wait for a DIFFERENT needle that only appears after we send + // a follow-up command. This exercises the subscribe/poll path: the + // wait must notice output arriving AFTER it started, not just find + // pre-existing content. + const uuid = registry.create('s1', 'async-echo', 'echo shell-ready', process.cwd(), 80, 24) + // Wait for the initial command's output so we know the shell is live. + await waitForTranscript(registry, uuid, 'shell-ready', 10_000) + // Start the wait BEFORE sending the follow-up command. The needle is + // NOT in the transcript yet. ConPTY under test concurrency can be slow, + // so the timeout is generous. + const waitPromise = registry.waitFor(uuid, 'UNIQUE_NEEDLE_42', 30_000) + // Give the wait a moment to subscribe, then send the command. + await new Promise(resolve => setTimeout(resolve, 200)) + registry.send(uuid, 'echo UNIQUE_NEEDLE_42\r') + const result = await waitPromise + expect(result.kind).toBe('found') + if (result.kind === 'found') { + expect(result.needle).toBe('UNIQUE_NEEDLE_42') + } + } finally { + registry.disposeAll() + } + }, 40_000) + + it('waitFor returns timeout when the needle never appears', async () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const uuid = registry.create('s1', 'silent', '', process.cwd(), 80, 24) + await waitForTranscript(registry, uuid, '', 1000) + // Wait for a needle that will never appear; use a short timeout to + // keep the test fast. + const result = await registry.waitFor(uuid, 'this-needle-will-never-appear', 500) + expect(result.kind).toBe('timeout') + if (result.kind === 'timeout') { + expect(result.timeoutMs).toBe(500) + expect(result.totalLines).toBeGreaterThan(0) + } + } finally { + registry.disposeAll() + } + }) + + it('waitFor throws on an empty needle', async () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const uuid = registry.create('s1', 'empty-needle', '', process.cwd(), 80, 24) + await expect(registry.waitFor(uuid, '', 500)).rejects.toThrow() + } finally { + registry.disposeAll() + } + }) + + it('waitFor throws on an unknown uuid', async () => { + const registry = new AgentPtyRegistry(testShell()) + await expect(registry.waitFor('nonexistent-uuid', 'foo', 500)).rejects.toThrow() + }) + + it('delivers SIGINT and SIGTSTP by writing control characters (cross-platform)', () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const uuid = registry.create('s1', 'signal-test', '', process.cwd(), 80, 24) + // SIGINT and SIGTSTP are delivered by writing \x03 / \x1a to the pty + // stdin — the cross-platform way terminals send Ctrl+C / Ctrl+Z. + // These must NOT throw (the old kill(SIGINT) path threw on Windows). + expect(() => registry.signal(uuid, 'SIGINT')).not.toThrow() + expect(() => registry.signal(uuid, 'SIGTSTP')).not.toThrow() + // The control byte was written to the pty; the shell received it. + // On POSIX the line discipline generates the signal; on Windows + // ConPTY translates it. Either way the write path is exercised. + } finally { + registry.disposeAll() + } + }) + + it('delivers SIGKILL via the process-termination path', () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const uuid = registry.create('s1', 'kill-test', '', process.cwd(), 80, 24) + // SIGKILL uses pty.kill() (TerminateProcess on Windows). Must not + // throw even when the pty's kill() rejects named signals — the + // fallback to the default kill ensures the signal takes effect. + expect(() => registry.signal(uuid, 'SIGKILL')).not.toThrow() + } finally { + registry.disposeAll() + } + }) +}) diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index 4fde843..f13f38b 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -17,7 +17,7 @@ describe('dsh-better-sidebar plugin export shape', () => { const unwrapped = loader.unwrapExports(sidebar) as Record expect(unwrapped).toBe(sidebar) expect(unwrapped.name).toBe('dsh-better-sidebar') - expect(unwrapped.inject).toEqual(['httpServer', 'sessions', 'loader']) + expect(unwrapped.inject).toEqual(['httpServer', 'sessions', 'loader', 'tools']) expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') }) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 68085bd..cf6b29d 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -20,6 +20,7 @@ interface FakeContext { registerUpgrade: (route: SidebarWebUpgradeRoute) => () => void } sessions: { get: (id: string) => { header: { cwd?: string } } | undefined } + tools: { register: (tool: unknown) => () => void } effect: (fn: () => void | (() => void), label?: string) => void /** The settings service never appears in the smoke context: the inject * callback must never run (mirror of cordis' service-less inject). */ @@ -43,6 +44,7 @@ describe('host plugin smoke', () => { registerUpgrade: (route) => { upgrades.push(route); return () => {} }, }, sessions: { get: () => undefined }, + tools: { register: () => () => {} }, // The DSH-vendored cordis runs the registration effect immediately and // keeps its cleanup for disposal. effect: (fn) => { @@ -55,7 +57,7 @@ describe('host plugin smoke', () => { } apply(ctx as never) expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/file']) - expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal']) + expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals']) // Teardown runs without throwing (pty manager has nothing open). for (const cleanup of effects) cleanup() }) @@ -177,6 +179,7 @@ describe('session cwd resolution over the API route', () => { registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} }, }, sessions: overrides.sessions ?? { get: () => undefined }, + tools: { register: () => () => {} }, // The vendored cordis runs registration effects immediately. effect: (fn: () => void | (() => void)) => { fn() }, // No settings service: the namespace registration never runs. @@ -296,6 +299,7 @@ describe('side card settings routes', () => { registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} }, }, sessions: { get: () => undefined }, + tools: { register: () => () => {} }, effect: (fn: () => void | (() => void)) => { fn() }, inject: (deps: string[], callback: (sctx: { settings: unknown }) => void) => { if (deps.includes('settings') && settings !== undefined) callback({ settings }) diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index e9e2ab1..c65dc5c 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -3,7 +3,7 @@ import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from ' import { parseLogLines, parsePorcelainZ } from '../src/git.ts' import { activateTab, allLeaves, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState, - moveTab, moveTabToEdge, openTab, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded, + moveTab, moveTabToEdge, openTab, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded, type SidebarState, type SplitNode, } from '../src/client/state.ts' import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts' @@ -253,6 +253,62 @@ describe('sidebar state', () => { }) }) +describe('agent terminal reconciliation', () => { + it('adds tabs for new agent terminals', () => { + let s = makeDefaultState(400, true) + s = reconcileAgentTerminals(s, [ + { uuid: 'aaa-111', title: 'dev server' }, + { uuid: 'bbb-222', title: 'python repl' }, + ]) + expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true) + expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true) + // The titles are preserved. + const tabs = allLeaves(s.splits).flatMap(leaf => leaf.tabs) + const agentTab = tabs.find(t => t.id === 'agent:aaa-111') + expect(agentTab?.title).toBe('dev server') + expect(agentTab?.type).toBe('terminal') + }) + + it('removes tabs for agent terminals that vanished from the server list', () => { + let s = makeDefaultState(400, true) + s = reconcileAgentTerminals(s, [ + { uuid: 'aaa-111', title: 'keep' }, + { uuid: 'bbb-222', title: 'remove' }, + ]) + expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true) + // Next push drops bbb-222. + s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'keep' }]) + expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true) + expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false) + }) + + it('is a no-op (same reference) when the lists already match', () => { + let s = makeDefaultState(400, true) + s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }]) + const next = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }]) + expect(next).toBe(s) + }) + + it('does not touch non-agent terminal tabs', () => { + let s = makeDefaultState(400, true) + s = openTab(s, { id: 'terminal:1', type: 'terminal', title: 'UI Tab' }) + s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'agent' }]) + expect(tabOpenIn(s, 'terminal:1')).toBe(true) + expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true) + }) + + it('handles an empty server list (removes all agent tabs)', () => { + let s = makeDefaultState(400, true) + s = reconcileAgentTerminals(s, [ + { uuid: 'aaa-111', title: 'a' }, + { uuid: 'bbb-222', title: 'b' }, + ]) + s = reconcileAgentTerminals(s, []) + expect(tabOpenIn(s, 'agent:aaa-111')).toBe(false) + expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false) + }) +}) + describe('pty helpers', () => { it('falls back from an empty SHELL to a usable shell', () => { const previous = process.env.SHELL From 8edfbfde4498996480d1c2ce051fc5144296fa74 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Mon, 10 Aug 2026 21:43:58 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20agent=20?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=E5=B7=A5=E5=85=B7=E5=AE=A1=E6=9F=A5=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=88terminal=5Flist=20=E8=BE=93=E5=87=BA=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E3=80=81=E8=B7=A8=E4=BC=9A=E8=AF=9D=E5=BD=92=E5=B1=9E?= =?UTF-8?q?=E3=80=81UTF-8=20=E6=88=AA=E6=96=AD=E8=BE=B9=E7=95=8C=E7=AD=89?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E5=B7=A5=E5=85=B7=E6=B3=A8=E5=85=A5=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E8=AE=BE=E7=BD=AE=E9=9D=A2=E6=9D=BF=E5=BC=80=E5=85=B3?= =?UTF-8?q?=EF=BC=88=E9=BB=98=E8=AE=A4=E5=85=B3=E9=97=AD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- src/agent-pty.ts | 54 ++++++-- src/client/SideCardSection.tsx | 20 +++ src/client/Sidebar.tsx | 39 ++++-- src/client/TerminalView.tsx | 17 +-- src/client/locales.ts | 4 + src/client/prefs.ts | 3 + src/client/state.ts | 23 +++- src/config.ts | 1 + src/index.ts | 51 ++++++-- src/prefs-shared.ts | 8 ++ src/tools.ts | 135 +++++++++++--------- tests/agent-pty.spec.ts | 37 +++++- tests/plugin-shape.spec.ts | 5 +- tests/smoke.spec.ts | 64 +++++++++- tests/tools.spec.ts | 223 +++++++++++++++++++++++++++++++++ tests/unit.spec.ts | 23 ++-- 17 files changed, 581 insertions(+), 130 deletions(-) create mode 100644 tests/tools.spec.ts diff --git a/README.md b/README.md index 3b05ecc..b55675a 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,10 @@ dsh registry enable dsh-external/dsh-better-sidebar ### 💻 终端 -- xterm.js + node-pty **真实 shell**,每会话最多 3 个 +- xterm.js + node-pty **真实 shell**,每会话最多 3 个(仅 UI 手动新建的 tab 计入上限) - **Tab 打开期间一直保活**:切换 Tab/分栏、切换会话(30 秒内返回)、刷新页面都重连到同一个 shell 进程并回放转录 - 真正关闭 Tab 立即释放配额;连续失败停止重连并显示原因与重试按钮 +- **Agent 终端(默认关闭)**:设置页开启「为模型注入终端工具」后,模型可通过 `terminal_create` / `list` / `send` / `read` / `wait_for` / `resize` / `signal` / `close` 8 个工具创建持久终端,自动同步为侧边栏 tab(id `agent:`,不占用 3 个 UI 上限)。终端由 agent 拥有——`terminal_close` 或用户关闭 tab 才销毁,WS 断开(刷新/切会话)不杀进程;`terminal_signal(SIGINT)` 通过写入 `\x03` 控制字符实现,Windows ConPTY 与 POSIX 通用 ### 🌿 Git 面板 @@ -148,6 +149,7 @@ dsh registry enable dsh-external/dsh-better-sidebar - **新会话默认打开**:新建会话是否自动展开侧边卡片(已存在的会话保持各自布局) - **默认宽度占比**:新建会话时侧边卡片占窗口宽度的百分比(20–60,默认 30) - **检测到子代理时自动展开子代理页面**(默认开启):当前会话产生新的子代理时,自动展开侧边栏并打开 Subagent 页面 + - **为模型注入终端工具**(默认关闭):开启后模型可通过 8 个 `terminal_*` 工具创建并操作侧边栏终端;关闭会注销工具并释放已创建的 agent 终端 ## ⌨️ 快捷键速查 diff --git a/src/agent-pty.ts b/src/agent-pty.ts index a424167..5f94231 100644 --- a/src/agent-pty.ts +++ b/src/agent-pty.ts @@ -27,17 +27,29 @@ export type AgentTerminalSignal = (typeof ALLOWED_SIGNALS)[number] /** Default read page size (lines) when the caller omits `count`. */ export const DEFAULT_READ_COUNT = 500 +/** Smallest pty dimension the registry accepts (mirrors the tool contract). */ +export const TERMINAL_DIM_MIN = 2 +/** Largest pty dimension the registry accepts (mirrors the tool contract). */ +export const TERMINAL_DIM_MAX = 1024 + +/** Clamp one cols×rows pair into the supported pty range (flooring decimals). */ +export function clampDims(cols: number, rows: number): { cols: number; rows: number } { + const clamp = (value: number): number => + Math.min(TERMINAL_DIM_MAX, Math.max(TERMINAL_DIM_MIN, Math.floor(value))) + return { cols: clamp(cols), rows: clamp(rows) } +} + /** * Serializable snapshot of one agent terminal — the shape the model sees * through `terminal_list` and the sidebar sees through the push endpoint. * Carries no pty reference and no transcript (those are reached through - * dedicated read/attach paths). + * dedicated read/attach paths), and no sessionId: ownership is registry + * internals, scoped by the caller (list filters by session, the push + * endpoint scopes by its query param), never part of the serialized view. */ export interface AgentTerminalSnapshot { /** Stable opaque handle the model passes back to other terminal_* tools. */ uuid: string - /** The conversation id this terminal belongs to (scope filter for list). */ - sessionId: string /** Display title the model chose at create time. */ title: string /** The command the model asked to run at create time (verbatim). */ @@ -115,8 +127,6 @@ export interface AgentTerminalReadResult { lineBegin: number /** 0-based index of the last line in `text` (exclusive). */ lineEnd: number - /** Whether `text` was truncated to fit the read cap. */ - truncated: boolean } /** Outcome of {@link AgentPtyRegistry.waitFor}. */ @@ -158,7 +168,6 @@ export type AgentTerminalWaitResult = export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot { const out: AgentTerminalSnapshot = { uuid: handle.uuid, - sessionId: handle.sessionId, title: handle.title, command: handle.command, exited: handle.exited, @@ -201,10 +210,11 @@ export class AgentPtyRegistry { rows = 24, ): string { const uuid = randomUUID() + const dims = clampDims(cols, rows) const pty = nodePty.spawn(this.shell, [], { name: 'xterm-256color', - cols: Math.max(2, Math.floor(cols)), - rows: Math.max(2, Math.floor(rows)), + cols: dims.cols, + rows: dims.rows, cwd, env: { ...process.env }, }) @@ -267,6 +277,20 @@ export class AgentPtyRegistry { return handle } + /** + * Resolve a live handle that belongs to `sessionId`, or throw `not-found`. + * The model-facing tools call this before every uuid-keyed operation: a + * uuid from another session is indistinguishable from an unknown one, so a + * model can never reach (or probe) a terminal it does not own. + */ + assertOwned(uuid: string, sessionId: string): AgentTerminalHandle { + const handle = this.expect(uuid) + if (handle.sessionId !== sessionId) { + throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404) + } + return handle + } + /** Resolve a handle's snapshot, or undefined if it does not exist. */ snapshot(uuid: string): AgentTerminalSnapshot | undefined { const handle = this.sessions.get(uuid) @@ -310,15 +334,19 @@ export class AgentPtyRegistry { totalLines, lineBegin: start, lineEnd: end, - truncated: false, } } - /** Resize a terminal's pty (clamped to a 2..1024 sane range). */ - resize(uuid: string, cols: number, rows: number): void { + /** + * Resize a terminal's pty, clamped to the 2..1024 sane range. + * @returns the dimensions actually applied (the caller echoes these, so the + * reported value always matches the pty). + */ + resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } { const handle = this.expect(uuid) - if (handle.exited) return - handle.pty.resize(Math.max(2, Math.floor(cols)), Math.max(2, Math.floor(rows))) + const dims = clampDims(cols, rows) + if (!handle.exited) handle.pty.resize(dims.cols, dims.rows) + return dims } /** diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index 696590a..295fba4 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -128,6 +128,13 @@ export function SideCardSection({ store }: SideCardSectionProps) { void commit({ autoOpenSubagent: next }).then(outcome => applyOutcome(previous, outcome)) } + const onToggleTools = (next: boolean): void => { + const previous = prefs + setPrefs({ ...previous, agentTerminalTools: next }) + setError(null) + void commit({ agentTerminalTools: next }).then(outcome => applyOutcome(previous, outcome)) + } + const commitWidth = (): void => { const parsed = Number(widthDraft) if (!Number.isFinite(parsed)) { @@ -193,6 +200,19 @@ export function SideCardSection({ store }: SideCardSectionProps) { onChange={event => { onToggleSubagent(event.currentTarget.checked) }} /> + {error !== null && (
{error} diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index a571424..629007b 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -15,7 +15,7 @@ import clsx from 'clsx' import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts' import { - allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab, + agentUuidOf, allLeaves, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab, reconcileAgentTerminals, resizeSplit, setWidth, toggleExpanded, togglePanel, TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode, @@ -36,6 +36,10 @@ import { api } from './api.ts' import { openSidebarFile } from './intercept.tsx' import css from './sidebar.module.css' +/** How many consecutive reconnect failures stop the agent-terminals push loop + * (mirror of the terminal view's own cap; the loop restarts on session switch). */ +const FAILURE_LIMIT = 3 + /** Render the content of one tab (dispatched by type). */ function TabContent(props: { tab: SidebarTab @@ -83,19 +87,21 @@ function TabContent(props: { } } -/** The + menu options for the current state (terminal cap applied). */ +/** The + menu options for the current state (UI-tab terminal cap applied). + * Agent-owned terminals (`agent:` tabs) are the model's, not the user's: + * they never count toward the per-session UI cap. */ function buildNewTabOptions(state: SidebarState): NewTabOption[] { - const terminalCount = allLeaves(state.splits) + const uiTerminalCount = allLeaves(state.splits) .flatMap(leaf => leaf.tabs) - .filter(tab => tab.type === 'terminal').length + .filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length return [ { id: 'explorer', label: t('openExplorer'), icon: tabTypeIcon('explorer', 16) }, { id: 'git', label: t('openGit'), icon: tabTypeIcon('git', 16) }, { id: 'subagent', label: t('openSubagent'), icon: tabTypeIcon('subagent', 16) }, { id: 'terminal', - label: terminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'), - disabled: terminalCount >= TERMINAL_LIMIT, + label: uiTerminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'), + disabled: uiTerminalCount >= TERMINAL_LIMIT, icon: tabTypeIcon('terminal', 16), }, ] @@ -145,13 +151,16 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { * create / close / exit; the sidebar reconciles the list into tabs * (id `agent:`, title from the agent). A disconnected socket * retries with a short backoff so a refresh or transient drop reattaches - * the same shell without losing the agent's work. + * the same shell without losing the agent's work — capped like the + * terminal view's own reconnect loop, so a refused endpoint never spins + * forever (the next session switch restarts the loop). */ useEffect(() => { if (sessionId === undefined) return let socket: WebSocket | null = null let retry: number | undefined let closed = false + let failures = 0 const connect = (): void => { if (closed) return const url = new URL('/sidebar/ws/agent-terminals', location.origin) @@ -169,7 +178,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { } } socket.onclose = () => { - if (!closed) retry = window.setTimeout(connect, 2000) + if (closed) return + failures += 1 + if (failures >= FAILURE_LIMIT) { + console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId) + return + } + retry = window.setTimeout(connect, 2000) } socket.onerror = () => { socket?.close() } } @@ -265,8 +280,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const tab = leaf?.tabs.find(candidate => candidate.id === tabId) store.reduce(s => closeTab(s, paneId, tabId)) if (tab?.type === 'terminal') { - if (tabId.startsWith('agent:')) { - const uuid = tabId.slice('agent:'.length) + if (isAgentTabId(tabId)) { + const uuid = agentUuidOf(tabId) void api.agentPtyClose(uuid).catch(() => { /* the host may already have released it */ }) } else if (sessionId !== undefined) { void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ }) @@ -350,7 +365,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { return openTab(s, { id: 'subagent', type: 'subagent', title: t('subagent') }) } if (optionId === 'terminal') { - const count = allLeaves(s.splits).flatMap(leaf => leaf.tabs).filter(tab => tab.type === 'terminal').length + const count = allLeaves(s.splits) + .flatMap(leaf => leaf.tabs) + .filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length if (count >= TERMINAL_LIMIT) return s const tab: SidebarTab = { id: `terminal:${s.nextTerminal}`, diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index d3ac7c3..5990ad4 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -25,26 +25,13 @@ import { FitAddon } from '@xterm/addon-fit' import 'xterm/css/xterm.css' import { t } from './locales.ts' import type { SessionScope } from './api.ts' -import type { SidebarStore } from './state.ts' +import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts' import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts' import css from './sidebar.module.css' /** How many consecutive unreasoned failures before showing the error banner. */ const FAILURE_LIMIT = 3 -/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */ -const AGENT_TAB_PREFIX = 'agent:' - -/** Whether a tab id refers to an agent-owned terminal. */ -function isAgentTab(tabId: string): boolean { - return tabId.startsWith(AGENT_TAB_PREFIX) -} - -/** Extract the agent terminal uuid from an `agent:` tab id. */ -function agentUuidOf(tabId: string): string { - return tabId.slice(AGENT_TAB_PREFIX.length) -} - /** * Curated ANSI palettes for the terminal. The surface colors (background, * foreground, cursor, selection) ride the theme tokens so the terminal @@ -126,7 +113,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: // Agent terminals attach by uuid (the host looks them up in the agent // pty registry); UI-tab terminals attach by sessionId+tab (the host // uses the UI-tab pty manager). Same upgrade endpoint, different query. - if (isAgentTab(tabId)) { + if (isAgentTabId(tabId)) { url.search = new URLSearchParams({ uuid: agentUuidOf(tabId) }).toString() } else { const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId }) diff --git a/src/client/locales.ts b/src/client/locales.ts index 26a0844..cffef70 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -90,6 +90,8 @@ const zh = { zoomHint: 'Alt + 滚轮', settingsSubagentTitle: '检测到子代理时自动展开子代理页面', settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开', + settingsToolsTitle: '为模型注入终端工具', + settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)', subagent: '子代理', openSubagent: '子代理', subagentMainAgent: '主代理', @@ -192,6 +194,8 @@ const en: Record = { zoomHint: 'Alt + wheel', settingsSubagentTitle: 'Auto-open the Subagent page when a subagent appears', settingsSubagentDesc: 'Expand the side card and open the Subagent page when the current conversation spawns a new subagent; turn off to open it manually', + settingsToolsTitle: 'Inject terminal tools for the model', + settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)', subagent: 'Subagents', openSubagent: 'Subagents', subagentMainAgent: 'Main agent', diff --git a/src/client/prefs.ts b/src/client/prefs.ts index 14a8746..3693240 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -41,6 +41,9 @@ export function parsePrefs(value: unknown): SidebarPrefs { autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean' ? record.autoOpenSubagent : SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent, + agentTerminalTools: typeof record.agentTerminalTools === 'boolean' + ? record.agentTerminalTools + : SIDEBAR_PREFS_DEFAULTS.agentTerminalTools, } } diff --git a/src/client/state.ts b/src/client/state.ts index 49f37a9..a2c4143 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -399,6 +399,21 @@ export function resizeSplit(node: SplitNode, splitId: string, index: number, del /** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */ export const AGENT_TAB_PREFIX = 'agent:' +/** Whether a tab id refers to an agent-owned terminal. */ +export function isAgentTabId(tabId: string): boolean { + return tabId.startsWith(AGENT_TAB_PREFIX) +} + +/** Extract the agent terminal uuid from an `agent:` tab id. */ +export function agentUuidOf(tabId: string): string { + return tabId.slice(AGENT_TAB_PREFIX.length) +} + +/** Build the sidebar tab id for one agent terminal uuid. */ +export function agentTabId(uuid: string): string { + return `${AGENT_TAB_PREFIX}${uuid}` +} + /** * Reconcile the sidebar's agent-terminal tabs with the host's live list. * The host pushes the current list of agent terminals (created by the model @@ -416,11 +431,11 @@ export function reconcileAgentTerminals( agentTerminals: ReadonlyArray<{ uuid: string; title: string }>, ): SidebarState { const existingTabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs) - const existingAgentTabs = existingTabs.filter(tab => tab.id.startsWith(AGENT_TAB_PREFIX)) - const existingUuids = new Set(existingAgentTabs.map(tab => tab.id.slice(AGENT_TAB_PREFIX.length))) + const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id)) + const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id))) const serverUuids = new Set(agentTerminals.map(t => t.uuid)) const toAdd = agentTerminals.filter(t => !existingUuids.has(t.uuid)) - const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(tab.id.slice(AGENT_TAB_PREFIX.length))) + const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id))) if (toAdd.length === 0 && toRemove.length === 0) return state // Remove tabs whose uuids vanished from the server list (the agent closed // them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup. @@ -436,7 +451,7 @@ export function reconcileAgentTerminals( let next: SidebarState = { ...state, splits } for (const terminal of toAdd) { const tab: SidebarTab = { - id: `${AGENT_TAB_PREFIX}${terminal.uuid}`, + id: agentTabId(terminal.uuid), type: 'terminal', title: terminal.title, } diff --git a/src/config.ts b/src/config.ts index bc9c107..7ab2209 100644 --- a/src/config.ts +++ b/src/config.ts @@ -79,4 +79,5 @@ export const PrefsSchema: z = z.object({ openByDefault: z.boolean().default(true), defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT), autoOpenSubagent: z.boolean().default(true), + agentTerminalTools: z.boolean().default(false), }) diff --git a/src/index.ts b/src/index.ts index 951b12b..4ce6b3e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,13 +23,14 @@ import { SIDEBAR_PREFS_NS, type ResolvedSidebarConfig, type SidebarConfig, + type SidebarPrefs, } from './config.ts' import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts' import { isTrustedApiRequest } from './trust-fence.ts' import * as git from './git.ts' import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts' -import { AgentPtyRegistry, type AgentTerminalHandle } from './agent-pty.ts' +import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts' import { registerTools } from './tools.ts' import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts' @@ -315,9 +316,34 @@ export function apply(ctx: Context, config?: SidebarConfig): void { // which call the seam in-process. Deployments without a settings service // simply never fill the face and the client falls back to the defaults. let settingsFace: SidebarSettingsFace | undefined + // The model-facing terminal tools are gated on the side-card setting + // `agentTerminalTools` (default off): nothing is injected until the user + // turns the feature on, and turning it off mid-session unregisters the + // tools and releases the agent terminals they created. + let toolsDisposers: (() => void) | null = null + const syncToolsGate = (scope: { get(): SidebarPrefs }): void => { + if (scope.get().agentTerminalTools) { + if (toolsDisposers === null) { + toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId)) + } + } else if (toolsDisposers !== null) { + toolsDisposers() + toolsDisposers = null + // The feature is off: release every agent terminal the model created + // while it was on (they are only reachable through the tools). The + // registry change fires the push, so the sidebar reconciles them away. + agentPtyRegistry.disposeAll() + } + } ctx.inject(['settings'], (sctx) => { const ns: SettingsNamespace = settingsNamespace(SIDEBAR_PREFS_NS) - sctx.settings.register(ns, PrefsSchema) + // The structural settings mirror types `schema` as unknown, so the + // generic is not inferred here; the real service resolves it from the + // schemastery schema (PrefsSchema) — narrow the owner scope explicitly. + const scope = sctx.settings.register(ns, PrefsSchema) as { + get(): SidebarPrefs + watch(callback: (next: SidebarPrefs, prev: SidebarPrefs) => void): () => void + } const viewOf = (): { value?: unknown; revision?: number } => { const descriptor = sctx.settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === ns) return descriptor === undefined @@ -331,15 +357,12 @@ export function apply(ctx: Context, config?: SidebarConfig): void { return viewOf() }, } + // Register (or unregister) the terminal tools from the current setting, + // and keep them in sync with every settings commit. + syncToolsGate(scope) + scope.watch(() => { syncToolsGate(scope) }) }) - // ── Model-facing tools (terminal_create / list / send / read / wait_for / resize / signal / close) ─ - // Registered once at activation; the disposer is attached to the plugin - // fiber by the tools registry, so unloading unregisters them. The cwd - // resolver threads the live session cwd (authoritative from the session - // store, falling back to the process cwd) so the model never passes it. - registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId)) - // ── JSON API ──────────────────────────────────────────────────────────── const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace) ctx.effect(() => ctx.httpServer.register({ @@ -467,6 +490,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void { }), 'dsh-better-sidebar: agent-terminals push WebSocket') ctx.effect(() => () => { + toolsDisposers?.() ptyManager.disposeAll() agentPtyRegistry.disposeAll() wss.close() @@ -530,7 +554,7 @@ async function attachTerminal( ws.close(1011, `agent terminal "${uuid}" not found`) return } - pumpAgentTerminal(agentPtyRegistry, handle, ws, resolved) + pumpAgentTerminal(agentPtyRegistry, handle, ws) return } const sessionId = url.searchParams.get('sessionId') @@ -577,7 +601,8 @@ async function attachTerminal( && control.type === 'resize' && typeof control.cols === 'number' && typeof control.rows === 'number' ) { - handle.pty.resize(Math.max(2, Math.floor(control.cols)), Math.max(2, Math.floor(control.rows))) + const dims = clampDims(control.cols, control.rows) + handle.pty.resize(dims.cols, dims.rows) } else { handle.pty.write(text) } @@ -606,7 +631,6 @@ function pumpAgentTerminal( registry: AgentPtyRegistry, handle: AgentTerminalHandle, ws: WebSocket, - _resolved: ResolvedSidebarConfig, ): void { if (handle.transcript !== '') ws.send(handle.transcript) const onData = (data: string): void => { @@ -642,7 +666,8 @@ function pumpAgentTerminal( && control.type === 'resize' && typeof control.cols === 'number' && typeof control.rows === 'number' ) { - handle.pty.resize(Math.max(2, Math.floor(control.cols)), Math.max(2, Math.floor(control.rows))) + const dims = clampDims(control.cols, control.rows) + handle.pty.resize(dims.cols, dims.rows) } else if (control === null) { // Raw text input (a JSON-looking string the pty would have received // verbatim is reachable in theory but is exotic for an agent terminal; diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index b4012c8..386b60c 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -20,6 +20,13 @@ export interface SidebarPrefs { * Subagent page when the current conversation spawns a new subagent. */ autoOpenSubagent: boolean + /** + * Whether the model-facing agent terminal tools (terminal_create / list / + * send / read / wait_for / resize / signal / close) are injected into the + * model's toolset. Off by default: the feature stays dormant until the + * user explicitly enables it in the side card settings. + */ + agentTerminalTools: boolean } /** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */ @@ -32,6 +39,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { openByDefault: true, defaultWidthPercent: WIDTH_PERCENT_DEFAULT, autoOpenSubagent: true, + agentTerminalTools: false, } /** Clamp one width percent into the contract range (shared by schema and client reads). */ diff --git a/src/tools.ts b/src/tools.ts index 7cff9f6..559bb4d 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -27,17 +27,22 @@ import { /** Maximum UTF-8 bytes of one `terminal_read` result text. */ const READ_BYTE_LIMIT = 256 * 1024 -/** Bound a string to a byte limit, marking truncation. */ -function boundBytes(text: string, maxBytes: number): { text: string; truncated: boolean } { - const bytes = Buffer.byteLength(text, 'utf8') - if (bytes <= maxBytes) return { text, truncated: false } - // Trim by character count proportional to the overage, then walk back to a - // valid UTF-8 boundary (Buffer.subarray with garbage on a trailing multibyte - // sequence would decode to U+FFFD). - const ratio = maxBytes / bytes - const cut = Math.floor(text.length * ratio) - const buf = Buffer.from(text, 'utf8').subarray(0, maxBytes) - return { text: buf.toString('utf8').slice(0, cut), truncated: true } +/** + * Bound a string to a byte limit, marking truncation. Truncation never + * splits a multi-byte UTF-8 sequence: when the byte cap lands inside one, + * the walk-back retreats to the sequence's leading byte so the retained + * prefix decodes cleanly (a split would decode to U+FFFD). + * @internal exported for the unit tests, like {@link snapshotOf}. + */ +export function boundBytes(text: string, maxBytes: number): { text: string; truncated: boolean } { + const buf = Buffer.from(text, 'utf8') + if (buf.byteLength <= maxBytes) return { text, truncated: false } + // The byte at `end` is a continuation byte (10xxxxxx) exactly when the + // character that started before `end` spills past the cap. Stop at the + // first non-continuation byte — the retained prefix is then intact. + let end = maxBytes + while (end > 0 && ((buf[end] ?? 0) & 0xc0) === 0x80) end -= 1 + return { text: buf.subarray(0, end).toString('utf8'), truncated: true } } /** Pure text projection helper (the canonical value is already structured). */ @@ -53,27 +58,36 @@ function requireAgent(agent: Agent | undefined): Agent { return agent } -/** Resolve the calling agent's session id (the registry scope filter). */ -function sessionOf(exec: ToolRunContext): { agent: Agent; sessionId: string } { - const agent = requireAgent(exec.agent) - return { agent, sessionId: agent.session.id } +/** Resolve the calling agent's session id (the registry scope + ownership key). */ +function sessionIdOf(exec: ToolRunContext): string { + return requireAgent(exec.agent).session.id } /** - * Register the seven terminal tools against the host tool registry. The + * Register the eight terminal tools against the host tool registry. The * `resolveCwd` callback threads the live session cwd (authoritative from the * session store, falling back to the process cwd) so a freshly-created * terminal lands in the right directory without the model passing it. + * Every uuid-keyed tool first asserts the terminal belongs to the calling + * session (`registry.assertOwned`), so one agent can never reach another + * session's terminals. * @param ctx - host plugin context (carries the tools service). * @param registry - the agent-owned terminal registry. * @param resolveCwd - live cwd resolver for one session id. + * @returns a disposer that unregisters all eight tools (the caller gates + * registration on the side-card setting and calls this to turn them off). */ export function registerTools( ctx: Context, registry: AgentPtyRegistry, resolveCwd: (sessionId: string) => string, -): void { - ctx.tools.register(defineTool({ +): () => void { + const disposers: Array<() => void> = [] + const register = (tool: ReturnType): void => { + disposers.push(ctx.tools.register(tool)) + } + + register(defineTool({ name: 'terminal_create', description: 'Open a persistent terminal in the sidebar and run a command in it. ' @@ -111,14 +125,14 @@ export function registerTools( }, execute: (args: { title: string; command: string }, exec) => { exec.signal.throwIfAborted() - const { sessionId } = sessionOf(exec) + const sessionId = sessionIdOf(exec) const cwd = resolveCwd(sessionId) const uuid = registry.create(sessionId, args.title, args.command, cwd, 80, 24) return Promise.resolve({ uuid, title: args.title }) }, })) - ctx.tools.register(defineTool({ + register(defineTool({ name: 'terminal_list', description: 'List every terminal the current agent has opened in this session. Returns each terminal\'s uuid, title, ' @@ -154,19 +168,19 @@ export function registerTools( }, }, execute: (_args, exec) => { - const { sessionId } = sessionOf(exec) + const sessionId = sessionIdOf(exec) return Promise.resolve(registry.list(sessionId)) }, })) - ctx.tools.register(defineTool({ + register(defineTool({ name: 'terminal_send', description: 'Send raw text (keystrokes) to a terminal opened with terminal_create — tmux send-keys semantics. ' + 'The text is written verbatim to the pty stdin. ' + 'To submit a command, set submit=true (appends an Enter key); do NOT put "\\n" or "\\r" in the text yourself. ' + 'To send Ctrl+C (interrupt the running command), use the terminal_signal tool with signal="SIGINT" — do NOT try to send the control character "\\u0003" as text. ' - + 'Similarly use terminal_signal for Ctrl+D (EOF), Ctrl+Z (suspend), etc. ' + + 'Use terminal_signal with signal="SIGTSTP" for Ctrl+Z (suspend) as well. ' + 'This tool does NOT wait for the command to finish or for output to settle — pair with terminal_read to observe the result. ' + 'Throws if the terminal has exited.', parameters: { @@ -200,13 +214,15 @@ export function registerTools( }, execute: (args: { uuid: string; text: string; submit?: boolean }, exec) => { exec.signal.throwIfAborted() + const sessionId = sessionIdOf(exec) + registry.assertOwned(args.uuid, sessionId) const payload = args.submit === true ? `${args.text}\r` : args.text registry.send(args.uuid, payload) return Promise.resolve({ uuid: args.uuid, bytes: Buffer.byteLength(payload, 'utf8') }) }, })) - ctx.tools.register(defineTool({ + register(defineTool({ name: 'terminal_read', description: 'Read a bounded page of retained output from an agent terminal without sending input. ' @@ -249,6 +265,8 @@ export function registerTools( }, execute: (args: { uuid: string; offset?: number; count?: number }, exec) => { exec.signal.throwIfAborted() + const sessionId = sessionIdOf(exec) + registry.assertOwned(args.uuid, sessionId) const result = registry.read(args.uuid, args.offset, args.count) const bounded = boundBytes(result.text, READ_BYTE_LIMIT) return Promise.resolve({ @@ -261,7 +279,7 @@ export function registerTools( }, })) - ctx.tools.register(defineTool({ + register(defineTool({ name: 'terminal_wait_for', description: 'Block until a substring appears in a terminal\'s retained transcript, or until the timeout elapses, or until the terminal exits — whichever happens first. ' @@ -298,7 +316,7 @@ export function registerTools( needle: { type: 'string', required: true }, line: { type: 'integer', required: true, description: '0-based line index in the retained transcript where the needle first appeared.' }, column: { type: 'integer', required: true, description: '0-based column index within that line where the match starts.' }, - elapsed_ms: { type: 'integer', required: true, description: 'Wall-clock milliseconds from wait start to match.' }, + elapsedMs: { type: 'integer', required: true, description: 'Wall-clock milliseconds from wait start to match.' }, }, }, { @@ -307,8 +325,8 @@ export function registerTools( properties: { kind: { type: 'string', required: true, const: 'timeout' }, needle: { type: 'string', required: true }, - timeout_ms: { type: 'integer', required: true, description: 'The configured timeout that elapsed.' }, - total_lines: { type: 'integer', required: true, description: 'Total lines retained when the timeout fired. Call terminal_read to inspect the tail.' }, + timeoutMs: { type: 'integer', required: true, description: 'The configured timeout that elapsed.' }, + totalLines: { type: 'integer', required: true, description: 'Total lines retained when the timeout fired. Call terminal_read to inspect the tail.' }, }, }, { @@ -317,55 +335,42 @@ export function registerTools( properties: { kind: { type: 'string', required: true, const: 'exited' }, needle: { type: 'string', required: true }, - exit_code: { type: 'integer', description: 'Exit code, if known.' }, - exit_signal: { type: 'string', description: 'Exit signal name, if killed by a signal.' }, + exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }], description: 'Exit code, if known.' }, + exitSignal: { oneOf: [{ type: 'string' }, { type: 'null' }], description: 'Exit signal name, if killed by a signal.' }, }, }, ], }, render: (_args, value) => { - const v = value as { kind: 'found' | 'timeout' | 'exited'; needle: string; elapsed_ms?: number; timeout_ms?: number; line?: number; column?: number; exit_code?: number | null; exit_signal?: string | null } + const v = value as { kind: 'found' | 'timeout' | 'exited'; needle: string; elapsedMs?: number; timeoutMs?: number; line?: number; column?: number; exitCode?: number | null; exitSignal?: string | null } if (v.kind === 'found') { - return [{ type: 'text', text: `Found "${v.needle}" at line ${v.line}, column ${v.column} (after ${v.elapsed_ms}ms).` }] + return [{ type: 'text', text: `Found "${v.needle}" at line ${v.line}, column ${v.column} (after ${v.elapsedMs}ms).` }] } if (v.kind === 'timeout') { - return [{ type: 'text', text: `Timed out after ${v.timeout_ms}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.` }] + return [{ type: 'text', text: `Timed out after ${v.timeoutMs}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.` }] } - const exitInfo = v.exit_code !== undefined ? ` (exit code ${v.exit_code})` : '' + const exitInfo = v.exitCode !== undefined && v.exitCode !== null ? ` (exit code ${v.exitCode})` : '' return [{ type: 'text', text: `Terminal exited before "${v.needle}" appeared${exitInfo}.` }] }, }, async execute(args: { uuid: string; needle: string; timeout_ms?: number }, exec) { exec.signal.throwIfAborted() - if (args.needle === '') { - throw new Error('needle must be a non-empty string') - } + const sessionId = sessionIdOf(exec) + registry.assertOwned(args.uuid, sessionId) + // The registry validates the needle (empty → bad-request) and returns + // the camelCase result the schema above declares directly — no + // field-by-field projection. const timeoutMs = args.timeout_ms ?? 10_000 - const result = await registry.waitFor(args.uuid, args.needle, timeoutMs, exec.signal) - // Project the registry's camelCase result into the snake_case canonical - // value declared by the output schema (the model sees snake_case). - if (result.kind === 'found') { - return { kind: 'found' as const, needle: result.needle, line: result.line, column: result.column, elapsed_ms: result.elapsedMs } - } - if (result.kind === 'timeout') { - return { kind: 'timeout' as const, needle: result.needle, timeout_ms: result.timeoutMs, total_lines: result.totalLines } - } - const out: { kind: 'exited'; needle: string; exit_code?: number; exit_signal?: string } = { - kind: 'exited', - needle: result.needle, - } - if (result.exitCode !== null && result.exitCode !== undefined) out.exit_code = result.exitCode - if (result.exitSignal !== null && result.exitSignal !== undefined) out.exit_signal = result.exitSignal - return out + return await registry.waitFor(args.uuid, args.needle, timeoutMs, exec.signal) }, })) - ctx.tools.register(defineTool({ + register(defineTool({ name: 'terminal_resize', description: 'Resize an agent terminal\'s pty ( cols × rows ). The host clamps both to a 2..1024 sane range. ' + 'Most shells redraw their prompt and any full-screen TUI on the next output frame. ' - + 'No-op if the terminal has exited.', + + 'No-op if the terminal has exited. Returns the dimensions actually applied.', parameters: { uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' }, cols: { type: 'integer', required: true, description: 'New column count ( clamped to 2..1024 ).' }, @@ -387,15 +392,17 @@ export function registerTools( }, execute: (args: { uuid: string; cols: number; rows: number }, exec) => { exec.signal.throwIfAborted() - registry.resize(args.uuid, args.cols, args.rows) - return Promise.resolve({ uuid: args.uuid, cols: args.cols, rows: args.rows }) + const sessionId = sessionIdOf(exec) + registry.assertOwned(args.uuid, sessionId) + const dims = registry.resize(args.uuid, args.cols, args.rows) + return Promise.resolve({ uuid: args.uuid, ...dims }) }, })) - ctx.tools.register(defineTool({ + register(defineTool({ name: 'terminal_signal', description: - 'Send a POSIX signal to an agent terminal\'s foreground process — this is how you send Ctrl+C, Ctrl+D, Ctrl+Z, etc. ' + 'Send a POSIX signal to an agent terminal\'s foreground process — this is how you send Ctrl+C, Ctrl+Z, etc. ' + 'Use signal="SIGINT" for Ctrl+C (interrupt the running command), signal="SIGTERM" to request termination, ' + 'signal="SIGKILL" to force-kill the pty, signal="SIGHUP" to hang up (many shells exit), signal="SIGTSTP" for Ctrl+Z (suspend). ' + 'Do NOT try to send control characters (like "\\u0003") through terminal_send — use this tool instead. ' @@ -425,12 +432,14 @@ export function registerTools( }, execute: (args: { uuid: string; signal: AgentTerminalSignal }, exec) => { exec.signal.throwIfAborted() + const sessionId = sessionIdOf(exec) + registry.assertOwned(args.uuid, sessionId) registry.signal(args.uuid, args.signal) return Promise.resolve({ uuid: args.uuid, signal: args.signal }) }, })) - ctx.tools.register(defineTool({ + register(defineTool({ name: 'terminal_close', description: 'Close an agent terminal and release its process. The uuid becomes invalid for all subsequent tool calls. ' @@ -455,8 +464,14 @@ export function registerTools( }, execute: (args: { uuid: string }, exec) => { exec.signal.throwIfAborted() + const sessionId = sessionIdOf(exec) + registry.assertOwned(args.uuid, sessionId) const closed = registry.close(args.uuid) return Promise.resolve({ uuid: args.uuid, closed }) }, })) + + return () => { + for (const dispose of disposers) dispose() + } } diff --git a/tests/agent-pty.spec.ts b/tests/agent-pty.spec.ts index a8afa7e..f3530fd 100644 --- a/tests/agent-pty.spec.ts +++ b/tests/agent-pty.spec.ts @@ -95,7 +95,6 @@ describe('AgentPtyRegistry', () => { expect(page.totalLines).toBeGreaterThan(0) expect(page.text).toContain('line1') expect(page.text).toContain('line3') - expect(page.truncated).toBe(false) // Negative offset reads from the end. const tail = registry.read(uuid, -2) expect(tail.lineBegin).toBeGreaterThanOrEqual(0) @@ -115,6 +114,38 @@ describe('AgentPtyRegistry', () => { } }) + it('resize clamps to the 2..1024 range and returns the applied dimensions', () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const uuid = registry.create('s1', 'clamp', '', process.cwd(), 80, 24) + // Oversized / undersized requests report the clamped values, so the + // echoed result always matches the pty (the tool contract). + expect(registry.resize(uuid, 5000, 1)).toEqual({ cols: 1024, rows: 2 }) + expect(registry.resize(uuid, -3, 80.9)).toEqual({ cols: 2, rows: 80 }) + expect(registry.resize(uuid, 120, 40)).toEqual({ cols: 120, rows: 40 }) + // create clamps through the same helper. + const other = registry.create('s1', 'clamped-create', '', process.cwd(), 1, 9999) + expect(registry.get(other)!.pty.cols).toBe(2) + expect(registry.get(other)!.pty.rows).toBe(1024) + } finally { + registry.disposeAll() + } + }) + + it('assertOwned rejects a uuid owned by another session', () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const mine = registry.create('s1', 'mine', '', process.cwd(), 80, 24) + const theirs = registry.create('s2', 'theirs', '', process.cwd(), 80, 24) + expect(() => registry.assertOwned(mine, 's1')).not.toThrow() + // A foreign uuid is indistinguishable from an unknown one. + expect(() => registry.assertOwned(theirs, 's1')).toThrow(/not found/) + expect(() => registry.assertOwned('nope', 's1')).toThrow(/not found/) + } finally { + registry.disposeAll() + } + }) + it('closes a terminal and removes it from the list', () => { const registry = new AgentPtyRegistry(testShell()) try { @@ -182,6 +213,10 @@ describe('AgentPtyRegistry', () => { // No pty or transcript fields on the snapshot. expect('pty' in snap).toBe(false) expect('transcript' in snap).toBe(false) + // sessionId is registry-internal ownership, never serialized (it would + // violate the terminal_list output schema's additionalProperties:false). + expect('sessionId' in snap).toBe(false) + expect('sessionId' in registry.list('s1')[0]!).toBe(false) } finally { registry.disposeAll() } diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index f13f38b..18909f6 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -45,12 +45,15 @@ describe('dsh-better-sidebar plugin export shape', () => { expect(resolved.openByDefault).toBe(true) expect(resolved.defaultWidthPercent).toBe(30) expect(resolved.autoOpenSubagent).toBe(true) + // The terminal tools default OFF (the feature is dormant until the user + // enables it in the side card settings). + expect(resolved.agentTerminalTools).toBe(false) // A stored overridden value resolves through (the range contract is // enforced by the settings service on write); the new pref keeps its // default when the stored document predates it. const overridden = (PrefsSchema as unknown as { (input: Record | undefined): Record })({ openByDefault: false, defaultWidthPercent: 45 }) - expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true }) + expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false }) }) }) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index cf6b29d..82c9ec5 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -255,8 +255,7 @@ describe('session cwd resolution over the API route', () => { describe('side card settings routes', () => { /** A minimal settings seam: register/describe/update with the revision guard. */ - const createFakeSettings = () => { - const namespaces = new Map { const namespaces = new Map | undefined revision: number @@ -343,7 +342,7 @@ describe('side card settings routes', () => { const read = await invoke(route, 'settings.get', {}) expect(read.ok).toBe(true) expect(read.value).toEqual({ - value: { openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true }, + value: { openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false }, revision: 0, }) @@ -375,3 +374,62 @@ describe('side card settings routes', () => { expect(result.error?.message).toMatch(/plain object/) }) }) + +describe('agent terminal tool gating', () => { + it('injects the eight tools only when the side-card setting is enabled (default off)', () => { + let registered = 0 + let disposed = 0 + // The tools currently registered (registered minus disposed). + const live = (): number => registered - disposed + // A ref container: the watch callback is only assigned inside a closure, + // which TypeScript's control-flow analysis ignores (the bare variable + // would narrow to null and refuse the optional call). + const watcherRef: { current: (() => void) | null } = { current: null } + let enabled = false + const settings = { + register() { + return { + get: () => ({ agentTerminalTools: enabled }), + watch: (callback: () => void) => { watcherRef.current = callback; return () => {} }, + update: async () => {}, + replace: async () => {}, + } + }, + describe: () => [], + async update() {}, + } + const ctx = { + loader: { entries: () => [] }, + httpServer: { + register: (route: SidebarWebRoute) => { void route; return () => {} }, + registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} }, + }, + sessions: { get: () => undefined }, + tools: { register: () => { registered += 1; return () => { disposed += 1 } } }, + effect: (fn: () => void | (() => void)) => { fn() }, + inject: (deps: readonly string[], callback: (sctx: { settings: unknown }) => void) => { + if (deps.includes('settings')) callback({ settings }) + return () => {} + }, + } + apply(ctx as never) + // Default off: no tools are registered even though the settings service is mounted. + expect(live()).toBe(0) + // Flipping the setting on registers all eight tools. + enabled = true + watcherRef.current?.() + expect(live()).toBe(8) + expect(disposed).toBe(0) + // Flipping it back off unregisters them (and releases any agent terminals). + enabled = false + watcherRef.current?.() + expect(live()).toBe(0) + expect(disposed).toBe(8) + // And a redundant toggle registers them fresh (no double-registration per + // flip: the guard only skips when the tools are already live). + enabled = true + watcherRef.current?.() + expect(live()).toBe(8) + expect(registered).toBe(16) + }) +}) diff --git a/tests/tools.spec.ts b/tests/tools.spec.ts new file mode 100644 index 0000000..be7ce66 --- /dev/null +++ b/tests/tools.spec.ts @@ -0,0 +1,223 @@ +/** + * Tool-layer tests for the eight agent-terminal tools: registration shape, + * the combined disposer, canonical-output contract (validated against each + * tool's declared output schema exactly like the registry's + * `createSuccessResult` does), per-session ownership, and the UTF-8 + * truncation boundary. The pty registry is stubbed (no real spawn): the + * wiring of the tools themselves is the subject here — pty behavior is + * covered by agent-pty.spec.ts. + */ +import { describe, expect, it } from 'vitest' +import { validateJsonSchemaValue, type ToolDefinition, type ToolRunContext } from '@deepseek-ai/dsh-tools' +import { boundBytes, registerTools } from '../src/tools.ts' +import type { AgentPtyRegistry } from '../src/agent-pty.ts' +import type { Context } from '../src/context-types.ts' + +/** One registration captured by the fake tools service. */ +interface CapturedTool { + definition: ToolDefinition +} + +/** Stub registry: uuid-keyed rows without a real pty. */ +class FakeRegistry { + readonly terminals = new Map() + + create(sessionId: string, title: string, command: string): string { + const uuid = `uuid-${this.terminals.size + 1}` + this.terminals.set(uuid, { sessionId, title, command, exited: false }) + return uuid + } + + list(sessionId: string): Array<{ uuid: string; title: string; command: string; exited: boolean }> { + return [...this.terminals.entries()] + .filter(([, row]) => row.sessionId === sessionId) + .map(([uuid, row]) => ({ uuid, title: row.title, command: row.command, exited: row.exited })) + } + + assertOwned(uuid: string, sessionId: string): void { + const row = this.terminals.get(uuid) + if (row === undefined || row.sessionId !== sessionId) { + throw new Error(`agent terminal "${uuid}" not found`) + } + } + + send(): void {} + + read(): { text: string; totalLines: number; lineBegin: number; lineEnd: number } { + // A page larger than the tool's 256 KiB read cap, to exercise boundBytes. + return { text: 'x'.repeat(300 * 1024), totalLines: 1, lineBegin: 0, lineEnd: 1 } + } + + resize(_uuid: string, cols: number, rows: number): { cols: number; rows: number } { + return { cols, rows } + } + + signal(): void {} + + close(): boolean { + return true + } + + waitFor(_uuid: string, needle: string): Promise<{ kind: 'found'; needle: string; line: number; column: number; elapsedMs: number }> { + // Mirrors the registry's empty-needle rejection (the tool layer no + // longer duplicates this check). + if (needle === '') return Promise.reject(new Error('needle must be a non-empty string')) + return Promise.resolve({ kind: 'found', needle, line: 0, column: 0, elapsedMs: 1 }) + } +} + +/** A minimal ToolRunContext for one calling session (only the slices the tools touch). */ +function exec(sessionId: string): ToolRunContext { + return { + signal: { throwIfAborted: () => {}, aborted: false }, + agent: { session: { id: sessionId } }, + } as unknown as ToolRunContext +} + +/** Register the eight tools against a fake service; the tools bind to `registry`. */ +function mount(): { captured: CapturedTool[]; registry: FakeRegistry; dispose: () => number } { + const captured: CapturedTool[] = [] + let disposeCount = 0 + const ctx = { + tools: { + register: (tool: unknown): (() => void) => { + captured.push({ definition: tool as ToolDefinition }) + return () => { disposeCount += 1 } + }, + }, + } as unknown as Context + const registry = new FakeRegistry() + const dispose = registerTools(ctx, registry as unknown as AgentPtyRegistry, () => '/cwd') + return { captured, registry, dispose: () => { dispose(); return disposeCount } } +} + +/** The captured definition for one tool name. */ +function toolOf(captured: CapturedTool[], name: string): ToolDefinition { + const found = captured.find(candidate => candidate.definition.name === name) + if (found === undefined) throw new Error(`tool "${name}" was not registered`) + return found.definition +} + +describe('agent terminal tools', () => { + it('registers all eight tools and the combined disposer unregisters them all', () => { + const { captured, dispose } = mount() + expect(captured.map(candidate => candidate.definition.name)).toEqual([ + 'terminal_create', + 'terminal_list', + 'terminal_send', + 'terminal_read', + 'terminal_wait_for', + 'terminal_resize', + 'terminal_signal', + 'terminal_close', + ]) + expect(dispose()).toBe(8) + // Disposing twice must not throw (each registry disposer is idempotent). + expect(() => mount().dispose()).not.toThrow() + }) + + it('terminal_create returns the uuid and echoed title (schema-valid)', async () => { + const { captured, registry } = mount() + const tool = toolOf(captured, 'terminal_create') + const value = await tool.execute({ title: 'dev server', command: 'npm run dev' }, exec('s1')) + expect(value).toMatchObject({ title: 'dev server' }) + expect(typeof (value as { uuid: unknown }).uuid).toBe('string') + expect(registry.terminals.size).toBe(1) + expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([]) + }) + + it('terminal_list output matches the declared schema — no sessionId leaks', async () => { + const { captured, registry } = mount() + const tool = toolOf(captured, 'terminal_list') + const uuid = registry.create('s1', 'dev server', 'npm run dev') + registry.create('s2', 'other session', 'top secret') + const value = await tool.execute({}, exec('s1')) + const list = value as Array> + expect(list).toHaveLength(1) + expect(list[0]!.uuid).toBe(uuid) + // The session id is registry-internal ownership, never serialized. + expect('sessionId' in list[0]!).toBe(false) + // And the whole value validates against the declared output schema — + // this is the contract the registry enforces at runtime (additionalProperties: false). + expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([]) + }) + + it('rejects a uuid owned by another session (send/read/resize/signal/close/wait_for)', async () => { + const { captured, registry } = mount() + const mine = registry.create('s1', 'mine', '') + const foreign = registry.create('s2', 'theirs', '') + for (const name of ['terminal_send', 'terminal_read', 'terminal_resize', 'terminal_signal', 'terminal_close', 'terminal_wait_for']) { + const tool = toolOf(captured, name) + const args = name === 'terminal_send' ? { uuid: foreign, text: 'ls' } + : name === 'terminal_read' ? { uuid: foreign } + : name === 'terminal_resize' ? { uuid: foreign, cols: 80, rows: 24 } + : name === 'terminal_signal' ? { uuid: foreign, signal: 'SIGINT' } + : name === 'terminal_close' ? { uuid: foreign } + : { uuid: foreign, needle: 'x', timeout_ms: 100 } + // A foreign uuid is indistinguishable from an unknown one. + await expect(tool.execute(args, exec('s1'))).rejects.toThrow(/not found/) + } + // Own-session uuids still work. + const sendTool = toolOf(captured, 'terminal_send') + await expect(sendTool.execute({ uuid: mine, text: 'ls' }, exec('s1'))).resolves.toBeDefined() + const closeTool = toolOf(captured, 'terminal_close') + await expect(closeTool.execute({ uuid: mine }, exec('s1'))).resolves.toMatchObject({ closed: true }) + }) + + it('terminal_read truncates at the byte cap and reports it (schema-valid)', async () => { + const { captured, registry } = mount() + const tool = toolOf(captured, 'terminal_read') + const uuid = registry.create('s1', 'reader', '') + const value = await tool.execute({ uuid }, exec('s1')) + const result = value as { text: string; truncated: boolean } + expect(result.truncated).toBe(true) + expect(Buffer.byteLength(result.text, 'utf8')).toBeLessThanOrEqual(256 * 1024) + expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([]) + }) + + it('terminal_resize echoes the registry-applied dimensions (schema-valid)', async () => { + const { captured, registry } = mount() + const tool = toolOf(captured, 'terminal_resize') + const uuid = registry.create('s1', 'resizer', '') + const value = await tool.execute({ uuid, cols: 120, rows: 40 }, exec('s1')) + expect(value).toEqual({ uuid, cols: 120, rows: 40 }) + expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([]) + }) + + it('terminal_wait_for returns the registry result in camelCase (schema-valid, no projection)', async () => { + const { captured, registry } = mount() + const tool = toolOf(captured, 'terminal_wait_for') + const uuid = registry.create('s1', 'waiter', '') + const value = await tool.execute({ uuid, needle: 'done' }, exec('s1')) + expect(value).toEqual({ kind: 'found', needle: 'done', line: 0, column: 0, elapsedMs: 1 }) + expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([]) + }) + + it('terminal_wait_for rejects an empty needle through the registry (no duplicate check)', async () => { + const { captured, registry } = mount() + const tool = toolOf(captured, 'terminal_wait_for') + const uuid = registry.create('s1', 'waiter', '') + await expect(tool.execute({ uuid, needle: '' }, exec('s1'))).rejects.toThrow(/non-empty/) + }) +}) + +describe('boundBytes', () => { + it('never splits a multi-byte UTF-8 sequence at the cap', () => { + // '€' is 3 bytes (E2 82 AC); the cap must retreat to a sequence boundary. + expect(boundBytes('a€b', 2)).toEqual({ text: 'a', truncated: true }) + expect(boundBytes('a€b', 3)).toEqual({ text: 'a', truncated: true }) + expect(boundBytes('a€b', 4)).toEqual({ text: 'a€', truncated: true }) + expect(boundBytes('a€b', 5)).toEqual({ text: 'a€b', truncated: false }) + expect(boundBytes('€', 1)).toEqual({ text: '', truncated: true }) + // ASCII pages truncate at the byte cap directly. + expect(boundBytes('hello', 3)).toEqual({ text: 'hel', truncated: true }) + }) + + it('every retained prefix decodes without U+FFFD', () => { + for (let n = 1; n <= 18; n += 1) { + const { text } = boundBytes('中文字符串测试 123', n) + expect(text.includes('\uFFFD')).toBe(false) + expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(n) + } + }) +}) diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index c65dc5c..b061f77 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -602,29 +602,36 @@ describe('side card preferences', () => { }) it('parses a valid value and clamps the percent into the contract range', async () => { - expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80, autoOpenSubagent: false }))) - .toEqual({ openByDefault: false, defaultWidthPercent: 60, autoOpenSubagent: false }) + expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80, autoOpenSubagent: false, agentTerminalTools: true }))) + .toEqual({ openByDefault: false, defaultWidthPercent: 60, autoOpenSubagent: false, agentTerminalTools: true }) }) it('falls back per-field when a stored field is malformed', async () => { - expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no' }))) - .toEqual({ openByDefault: true, defaultWidthPercent: 33, autoOpenSubagent: true }) + expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no', agentTerminalTools: 'yes' }))) + .toEqual({ openByDefault: true, defaultWidthPercent: 33, autoOpenSubagent: true, agentTerminalTools: false }) }) - it('defaults autoOpenSubagent to true when the stored value is absent or malformed', async () => { + it('defaults autoOpenSubagent to true and agentTerminalTools to false when the stored value is absent or malformed', async () => { expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 40 }))) - .toEqual({ openByDefault: false, defaultWidthPercent: 40, autoOpenSubagent: true }) + .toEqual({ openByDefault: false, defaultWidthPercent: 40, autoOpenSubagent: true, agentTerminalTools: false }) expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenSubagent: 1 }))).autoOpenSubagent) .toBe(true) + // The terminal-tools feature is OFF by default; only an explicit true turns it on. + expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40 }))).agentTerminalTools) + .toBe(false) + expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: 1 }))).agentTerminalTools) + .toBe(false) + expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools) + .toBe(true) }) it('seeds new-session defaults from the store prefs (open flag + width)', () => { const store = createSidebarStore() // Node environment: no window → the width falls back to PANEL_DEFAULT, // while the open flag still follows the preference. - store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true }) + store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false }) store.setSession('fresh-session') - expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true }) + expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false }) const snapshot = store.getSnapshot() expect(snapshot.sessionId).toBe('fresh-session') expect(snapshot.state?.panelOpen).toBe(false)