Files
apes-sidebar/src/agent-pty.ts
T
Atlas Zeng 6c891514b5 feat(injection): 统一面板宿主注入重构——含块层+几何写路径+chunk缓存+锚点弱化+桌面/移动适配 (#232)
* feat(injection): 统一面板宿主——固定含块层 + 挂载自检降级 + body 锚点守卫

- Sidebar 渲染根包 [data-dsh-panel-host](fixed inset-0 pointer-events:none)
- 面板/开关簇由 fixed 改 absolute(层内视口坐标等价),免疫套壳中间层 transform 劫持
- 挂载后一次性几何自检:页面级 transform 时降级 absolute + rAF 视口同步
- MutationObserver(body childList)守卫锚点被宿主清空时重挂

* perf(injection): 几何写路径统一为 writeGeometry + 面板 layout 隔离

- 拖拽帧与打开态共用单一推挤写入器(--dsh-sidebar-width/height)
- .panel/.bottomPanel contain: layout style——内部重排不外泄
- 拖拽期(body[data-dsh-sidebar-dragging])才挂 will-change: transform

* perf(chunks): HMR 激活保留未变 chunk 的已解析 exports + ETag HEAD 重验证

- 新增 revalidateChunksOnReactivate():激活时 HEAD 每个已加载 chunk 对比 ETag,
  未变保留内存缓存(跳过重注入/重执行),变更或不可达则 fail-open 重取
- 测试夹具与孤儿缓存条目不跨激活存活
- index.tsx 激活路径由 resetChunks() 改为 revalidateChunksOnReactivate()

* fix(layout): 推挤锚点改 data 属性 + #root calc 宽度防桌面壳溢出

- nth-child(2) → #root [data-dsh-frame] > [data-pane="conversation"](实证同元素)
- #root 宽度 calc(100% - var) 与 margin 同步过渡:web 视觉不变(实测 1140+300=1440 无溢出),
  Desktop 壳(#208)width:100% 的加性溢出根因消除

* feat(desktop): 桌面信号自动探测 + win32 顶栏 overlay 自动避让 + 键盘/safe-area 适配

- 新增 desktop-env.ts:解析 dsh-desktop-mode/platform URL 信号与 preload 标记
- win32 advanced 壳自动启用 titleBarCompat(32px overlay),手动 pref 仍可覆盖
- visualViewport 键盘 inset:底部面板/窄屏抽屉键盘弹起时上移
- 面板与开关簇 safe-area-inset 适配(env() 桌面端为 0)
- 单测 7 例(含 URLSearchParams 前导 ? 剥除修复)

* docs+e2e: 注入方案实施记录(路线B关闭、偏差记录、基线数据)+ 契约同步 + e2e 新断言

- 设计文档:路线 B 标记关闭、§8.1 改单 PR 步骤(含 commit 对照)、拖拽基线数据、实施偏差记录
- AGENTS.md §8:data-dsh-panel-host 含块层、推挤锚点契约(禁 nth-child)、桌面信号
- mount.e2e:data-dsh-panel-host 断言 + win32 advanced 桌面信号场景

* fix(cr): 响应 #232 CR 三条 findings

- P1 降级同步:按未修正几何判定(跟踪自身补偿量),祖先 transform 消失才退出循环——
  修正后矩形不再被误判为无需修正(自清除缺陷)
- P1 chunk 重验证成为 loadChunk 屏障:重验证挂起期间不提供缓存,杜绝 HMR 竞态陈旧 exports;
  测试夹具加载不再被误记为生产加载
- P2 键盘 inset:innerHeight - (vv.height + vv.offsetTop),并监听 scroll(offsetTop 变化)
- 新增屏障单测 1 例;设计文档偏差记录更新

* fix(chunks): 屏障 HEAD 加超时兜底 + resetChunks 清 revalidation

- revalidateChunksOnReactivate 的 HEAD 与 recordEtag 均带
  AbortSignal.timeout(5s):路由挂起时 fail-open(drop + 重取),
  屏障不再可能无限期阻塞所有懒加载 chunk(CR #232 P1 修复的健壮性缺口)
- resetChunks 同步清 revalidation:重验证挂起期间重置后,
  loadChunk 不再等待已丢弃状态的孤儿任务(finally 身份判断 no-op)
- 单测 +2:HEAD 挂起超时 fail-open(fake AbortSignal.timeout 触发中止)、
  resetChunks 清屏障后 loadChunk 立即放行

* fix(deps): align @deepseek-ai/* peers with DSH 0.1.0-rc.8

- peer/devDependencies: 13 个 @deepseek-ai/* 从 ^0.1.0-rc.7 升至
  ^0.1.0-rc.8;删除随 rc.8 消失的 dsh-client-web-react / dsh-client-schema-form
  (无 rc.8 发布、rc.8 依赖树与壳 bundle 零引用、插件 src 零引用);
  cordis peer/devDep 同步升 ^4.0.0-rc.8
- 版本 0.13.1 -> 0.14.0(package.json / dsh.plugin.json /
  SIDEBAR_SERVICE_VERSION 三处同步)
- CHUNK_EXTERNALS / CLIENT_EXTERNALS / CLIENT_REQUIRE_ALLOWED 三处镜像
  同步删除两个死包(primitives/slots 仍是 rc.8 壳静态模块表种子,保留)
- CI 挂载冒烟钉版 @deepseek-ai/dsh@0.1.0-rc.7 -> rc.8
- pnpm clean --lockfile 全量重解析:lockfile 内 @deepseek-ai/* rc.7 残留
  清零(传递链同升 rc.8,杜绝 #206 式混用树);pnpm-workspace.yaml
  minimumReleaseAgeExclude 按 #207 模式整体换为 rc.8 列表
- 文档同步:README 安装说明、AGENTS.md CI 钉版、外部插件指南与设计文档
  cordis 示例、issue 模板版本示例

* feat(prefs): editorExplorer 默认独立(关)——文件各自新开 tab

- SIDEBAR_PREFS_DEFAULTS 与 host PrefsSchema 的 editorExplorer 默认
  true -> false:新会话树点击/打开文件按 path 新开 tab,无路径窗口即
  纯资源管理器;合并模式保留为可选手动开启(持久化过的用户设置不受影响)
- 测试同步:prefs.spec 默认断言翻转、plugin-shape.spec 与 smoke.spec
  默认对象翻转;editor-host 合并模式用例显式开启 editorExplorer(split
  用例本已显式);side-card-section-rows 的 select 行用例固定选中 Merged
- e2e 重写:mount.e2e 文件打开流程从 in-place 断言改为独立模式
  (新开 hello.txt tab、Files home tab 保留),文件头注释同步
- 契约文档同步:AGENTS.md §3.4 editor 行默认描述翻转 + CI 冒烟描述、
  README/README_EN v0.13.0 特性描述 + v0.14.0 changelog

* fix(client): rc.8 模块系统经 ctx.modules 注入 + chunk 副本全局共享

rc.8 不再暴露 window.__DSH_MODULES__ 页面全局(改由 ctx.modules 服务
提供),chunk 外部依赖解析全面失效(terminal/editor/mermaid 全部
"client module system unavailable")。修复:

- src/client/index.tsx: inject 增加 'modules'(Cordis 服务访问守卫,
  此前直接读 ctx.modules 抛 "cannot get property modules without
  inject");apply() 激活时 setChunkModuleSystem(ctx.modules)
- package.json dsh.client.inject 增加 @deepseek-ai/dsh-client-modules
- chunk-loader: 注入写插件自有页面全局 __dshSidebarModuleSystem__——
  懒加载 chunk bundle 各自内联一份 loader(rc.7 靠官方全局回退工作,
  rc.8 全局消失后副本注入变量恒空);moduleSystem() 按
  注入变量 → 插件全局 → rc.7 全局回退解析
- context-types: Context 镜像补 modules 服务声明(client half)
- e2e: assertNoCrash 失败时输出错误条文本;独立模式断言修正
  (Files 数量按存在性、mermaid 流程先切回 Files、md 预览 locator 去掉
  重复的 [data-dsh-better-sidebar] 嵌套)

验证:typecheck / 639 测试全绿;本地 rc.8 CLI + Chromium 挂载冒烟 3/3
通过(drag-layout、mount 深扫含 editor/mermaid chunk、桌面信号)

* feat(settings): 终端 shell/shellArgs 可在设置页配置(bash 管理)

- SidebarPrefs 新增 terminalShell / terminalShellArgs(默认空 = 保持
  yaml config.shell → $SHELL / 登录 shell / powershell.exe 的既有解析)
- 终端卡齿轮二级页面新增两行 text 配置(与字体行同列)
- host 侧生效链路:UI 终端 WS 打开与 agent terminal_create 均读取
  设置文档覆盖(ptyManager.open / AgentPtyRegistry.create 增加可选
  shell 覆盖参数;shellOverridesOf 解析设置文档);设置页写入即时
  影响之后打开的终端(既有 pty 保持原 shell)
- locales zh/en 文案;parsePrefs 校验新字段;测试同步

* feat(settings): 侧边卡片设置页加 DSH-better-sidebar 版本徽标

- 设置页 intro 下新增身份徽标:名称 + v{service.version}(服务实例
  版本,与 package.json 由 tests/service.spec.ts 保持同步)
- token 驱动样式(--dsw-alias-*),名称/版本 pill 布局

* fix(drag): 快速拖动提交最后位置 + HMR 后中心列重定位兜底

快速释放(浏览器合并/丢失 pointermove 突发,超快速 flick 甚至可能
完全无 move)时的回退问题:
- pointerup 优先采用最后一次已知拖动值(rAF pending),而非 up 位置
  (事件合并时可能等于按下位置)
- pointercancel / lostpointercapture 中断时同样提交最后已知位置;
  无 move 时用中断事件自身的坐标计算(clamp 后提交)——只有真正
  未产生位移的拖动才回滚
- commitDrag 统一 flush + 同值提交 + 立即重测中心列(消除提交后
  React 用旧 centerRect 覆盖底栏 right 的中间帧)

HMR 重激活后底栏空白(输入框被推挤上移、强制刷新恢复):
- 根因:新组件实例重跑 locate 时壳的 centerCol 尚未就绪,而壳重建
  复用 #root 子节点(childList 观察器不触发)→ centerRect 卡 {0,0}
  → 底栏 visibility:hidden 永久空白
- 修复:额外观察 <html> 的 style 属性(layout push 写回 --dsh-sidebar-*
  时重定位重测);底栏打开时 effect 重跑 locate/measure 兜底
- 底栏未测量时由「不渲染」改为 visibility:hidden(渲染链不被几何
  门控,jsdom 测试不回归)

e2e 新增:极快拖动(单 move 立即 up)提交拖动位置、稳定后不回退

* feat(plugins): 添加插件目录支持搜索/分组/独立滚动(多插件场景)

- PluginListBody 顶部加实时搜索框(按名称/id/描述过滤,空结果提示)
- PluginEntry 增加可选 category 分组字段(i18n 友好),目录条目按组
  渲染(未分类条目保持平铺);数量徽标跟随过滤结果
- 条目列表 max-height + overflow-y 独立滚动,插件多时弹窗不再无限
  增长;渲染数量由搜索天然收敛
- locales zh/en(搜索占位、无匹配);测试文件固定英文环境
  (vitest 4.1.11+ jsdom 跟随系统 locale,本机 zh-CN 导致英文文案
  断言漂移)

* chore(deps): 适配 pnpm 11.8 supply-chain 校验(exclude 通配)

pnpm 11.8.0 起对 lockfile 既有条目强制 minimumReleaseAge 校验,
且精确 exclude(@scope/pkg@version)对带 peer 依赖后缀的解析条目
匹配失效(44 条误报违规,CI 全红)。@deepseek-ai/* scope 为官方
可信 scope,minimumReleaseAgeExclude 改为 '@deepseek-ai/*' 通配
(保留 rc.1 历史与 stable 条目);通配经实测通过全部 504 条校验。

* docs: v0.14.0 changelog 补全(自 v0.13.1 全部更改 + 特别声明 rc.8 适配)

新增条目:终端 shell/shellArgs 设置页配置、设置页版本徽标、
添加插件目录搜索/分组/滚动、rc.8 模块系统迁移、拖拽健壮性修复;
版本说明块醒目声明本版适配 DSH 0.1.0-rc.8(rc.7 及更早无法解析依赖)
2026-08-20 02:09:36 +08:00

528 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 type { IPty } from 'node-pty'
import { ensureSpawnHelper, shellSpawnArgs } from './pty-manager.ts'
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.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
/** Smallest pty dimension the registry accepts (mirrors the tool contract). */
export const TERMINAL_DIM_MIN = 2
/** Largest pty dimension the registry accepts (mirrors the tool contract). */
export const TERMINAL_DIM_MAX = 1024
/** Clamp one cols×rows pair into the supported pty range (flooring decimals). */
export function clampDims(cols: number, rows: number): { cols: number; rows: number } {
const clamp = (value: number): number =>
Math.min(TERMINAL_DIM_MAX, Math.max(TERMINAL_DIM_MIN, Math.floor(value)))
return { cols: clamp(cols), rows: clamp(rows) }
}
/**
* Serializable snapshot of one agent terminal — the shape the model sees
* through `terminal_list` and the sidebar sees through the push endpoint.
* Carries no pty reference and no transcript (those are reached through
* dedicated read/attach paths), and no sessionId: ownership is registry
* internals, scoped by the caller (list filters by session, the push
* endpoint scopes by its query param), never part of the serialized view.
*/
export interface AgentTerminalSnapshot {
/** Stable opaque handle the model passes back to other terminal_* tools. */
uuid: string
/** 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: 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
}
/** 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,
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,
private readonly shellArgs: string[] = [],
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
) {
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,
shell?: string,
shellArgs?: string[],
): string {
const uuid = randomUUID()
const dims = clampDims(cols, rows)
const pty = this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
name: 'xterm-256color',
cols: dims.cols,
rows: dims.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 live handle that belongs to `sessionId`, or throw `not-found`.
* The model-facing tools call this before every uuid-keyed operation: a
* uuid from another session is indistinguishable from an unknown one, so a
* model can never reach (or probe) a terminal it does not own.
*/
assertOwned(uuid: string, sessionId: string): AgentTerminalHandle {
const handle = this.expect(uuid)
if (handle.sessionId !== sessionId) {
throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
}
return handle
}
/** Resolve a handle's snapshot, or undefined if it does not exist. */
snapshot(uuid: string): AgentTerminalSnapshot | undefined {
const handle = this.sessions.get(uuid)
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,
}
}
/**
* Resize a terminal's pty, clamped to the 2..1024 sane range.
* @returns the dimensions actually applied (the caller echoes these, so the
* reported value always matches the pty).
*/
resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
const handle = this.expect(uuid)
const dims = clampDims(cols, rows)
if (!handle.exited) handle.pty.resize(dims.cols, dims.rows)
return dims
}
/**
* 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.
}
}
}
}