* 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 及更早无法解析依赖)
528 lines
20 KiB
TypeScript
528 lines
20 KiB
TypeScript
/**
|
||
* 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.
|
||
}
|
||
}
|
||
}
|
||
}
|