feat: 为 agent 暴露 8 个终端工具(terminal_create/list/send/read/wait_for/resize/signal/close),侧边栏自动同步 agent 终端 tab,v0.2.0
This commit is contained in:
+1
-1
@@ -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": {
|
||||
|
||||
+7
-1
@@ -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",
|
||||
|
||||
Generated
+20
-11
@@ -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
|
||||
|
||||
@@ -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<number, string> = {
|
||||
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<string, AgentTerminalHandle>()
|
||||
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<AgentTerminalWaitResult> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
-2
@@ -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:<uuid>`, 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:<uuid>`) 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) => {
|
||||
|
||||
@@ -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:<uuid>` 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' }))
|
||||
|
||||
@@ -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', {}),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -274,6 +274,31 @@ export interface SidebarSettingsService {
|
||||
update(ns: string, patch: object, expectedRevision?: number): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
+182
-7
@@ -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<string, string> = {
|
||||
@@ -135,10 +137,11 @@ export interface SidebarSettingsFace {
|
||||
update(patch: Record<string, unknown>, 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<string, ApiMethod> {
|
||||
@@ -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:<uuid>`,
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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.
|
||||
})
|
||||
}
|
||||
|
||||
+462
@@ -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<T>(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 })
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -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<string> {
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,7 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
const unwrapped = loader.unwrapExports(sidebar) as Record<string, unknown>
|
||||
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')
|
||||
})
|
||||
|
||||
+5
-1
@@ -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 })
|
||||
|
||||
+57
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user