From da403d60863f6bd2800168a5ed656e6cb5fb705e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 11 Aug 2026 23:58:38 +0800 Subject: [PATCH 01/60] feat(subprocess): Windows terminal inspection and signalling createProcessInspector now returns a WindowsProcessInspector on win32 instead of throwing: Toolhelp32 tree enumeration with GetProcessTimes start identities, the shell pid as a pseudo foreground group, taskkill tree signalling, and inspector-verified Windows teardown (node-pty signal kills throw on Windows, and externally taskkilled shells may never fire its exit notification, so the handle settles \done\ from the verified absence). subprocess-local and pty-local suites now run on Windows with platform gates; the koffi-backed inspector joins the windows-only coverage exclusions on Linux and is fully covered by the windows-native lane. Also flips vitest.config so subprocess-local and pty-local sources are coverage-required on win32, and adapts the spawn/terminal suites to run natively there (node-translated shell commands, injected POSIX group paths, taskkill signal semantics). --- .../subprocess/subprocess-local/package.json | 1 + .../subprocess-local/src/process-inspector.ts | 2 + .../subprocess-local/src/terminal.ts | 74 +++++ .../subprocess-local/src/windows-inspector.ts | 288 ++++++++++++++++++ .../subprocess-local/tests/local.spec.ts | 17 +- .../tests/process-inspector.spec.ts | 6 +- .../subprocess-local/tests/spawn.spec.ts | 177 +++++++++-- .../subprocess-local/tests/terminal.spec.ts | 127 +++++++- .../tests/windows-inspector.spec.ts | 142 +++++++++ pnpm-lock.yaml | 6 + vitest.config.ts | 15 +- 11 files changed, 809 insertions(+), 46 deletions(-) create mode 100644 packages/subprocess/subprocess-local/src/windows-inspector.ts create mode 100644 packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 12f47c0400..ad89719667 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -42,6 +42,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { + "koffi": "^3.1.0", "node-pty": "^1.1.0" }, "devDependencies": { diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index f31de010de..89effc0082 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/packages/subprocess/subprocess-local/src/process-inspector.ts @@ -3,6 +3,7 @@ import { closeSync, openSync, readFileSync, readdirSync, readSync } from 'node:fs' import { execFileSync } from 'node:child_process' import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess' +import { createWindowsProcessInspector } from './windows-inspector.ts' /** PID plus start identity, preventing teardown escalation after PID reuse. */ export interface ProcessIdentity { @@ -370,5 +371,6 @@ export function createProcessInspector( ): ProcessInspector { if (platform === 'linux') return new LinuxProcessInspector(arch, internals) if (platform === 'darwin') return new MacProcessInspector(internals) + if (platform === 'win32') return createWindowsProcessInspector() throw new Error(`subprocess-local: terminal inspection is unsupported on platform ${platform}`) } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 11d13a405a..6d3b981169 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -50,11 +50,13 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { * @param terminal - allocated node-pty process. * @param inspector - platform process/session operations. * @param graceMs - TERM-to-KILL and exit-wait grace. + * @param platform - host platform; defaults to the running platform, injectable for deterministic tests. */ constructor( private readonly terminal: IPty, private readonly inspector: ProcessInspector, private readonly graceMs: number, + private readonly platform: NodeJS.Platform = process.platform, ) { this.pid = terminal.pid this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid) @@ -98,6 +100,19 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) { throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead') } + if (this.platform === 'win32') { + if (signal === 'SIGINT') { + // Windows has no process-group signalling: a `\x03` input write is the + // Ctrl-C delivery path conhost turns into a console-wide CTRL_C event + // for attached processes. node-pty's signal kills throw on Windows, so + // no signal ever reaches the inspector. + this.terminal.write('\x03') + return foreground.processGroupId + } + if (signal === 'SIGTSTP' || signal === 'SIGHUP') { + throw new Error(`signal ${signal} is unsupported on Windows; only SIGINT, SIGTERM, and SIGKILL are available`) + } + } this.inspector.signalGroup(foreground.processGroupId, signal) return foreground.processGroupId } @@ -177,6 +192,10 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { } private async stopShell(): Promise { + if (this.platform === 'win32') { + await this.stopShellWindows() + return + } if (!this.exited) { try { this.terminal.kill('SIGTERM') @@ -196,6 +215,44 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`) } + private async stopShellWindows(): Promise { + // node-pty's Windows kill(signal) throws ("Signals not supported on + // windows"), and its bare kill() delegates to a console-list agent that + // fails when the parent has no console. taskkill tree escalation is the + // teardown path, fenced on the shell's start identity like every + // descendant; a root identity miss falls back to the bare kill. taskkill + // termination also does not reliably fire node-pty's exit notification + // (the same console-list agent), so the tiers verify the shell's absence + // through the inspector instead of waiting on `done` alone. + const shellGone = (): boolean => + this.exited || (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity)) + if (!shellGone() && this.rootIdentity !== undefined) { + this.inspector.signalProcess(this.rootIdentity, 'SIGTERM') + await this.waitForWindowsShellExit() + } + if (!shellGone() && this.rootIdentity === undefined) { + try { + this.terminal.kill() + } catch (_topLevelAlreadyExitedDuringKill) { + // The exit callback is authoritative. + } + await Promise.race([this.done.then(() => undefined), delay(this.graceMs)]) + } + if (!shellGone() && this.rootIdentity !== undefined) { + this.inspector.signalProcess(this.rootIdentity, 'SIGKILL') + await this.waitForWindowsShellExit() + } + if (!shellGone()) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`) + } + + private async waitForWindowsShellExit(): Promise { + const until = Date.now() + this.graceMs + while (!this.exited && Date.now() < until) { + if (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity)) return + await delay(Math.min(25, Math.max(1, until - Date.now()))) + } + } + private async closeOnce(): Promise { let survivors = await this.stopDescendants() if (survivors.length > 0) { @@ -206,7 +263,24 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (survivors.length > 0) { throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map(member => member.pid).join(', ')}`) } + this.settleExitIfGone() this.dataDisposable.dispose() this.exitDisposable.dispose() } + + private settleExitIfGone(): void { + // An externally taskkilled Windows shell may never fire node-pty's exit + // notification (its console-list agent fails without a parent console), + // which would leave `done` — and every consumer awaiting it — unsettled + // forever. Teardown has just verified the shell's absence through the + // inspector, so a missing exit event is itself the outcome. + if (this.platform !== 'win32') return + if (this.exited) return + /* v8 ignore next -- stopShellWindows() verified the shell is gone or threw; + the identity re-check is a defensive fence for a future caller. */ + if (this.rootIdentity !== undefined && this.inspector.isAlive(this.rootIdentity)) return + this.exited = true + this.output.end() + this.outcome.resolve({ exitCode: null, signal: null }) + } } diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts new file mode 100644 index 0000000000..78bea583d8 --- /dev/null +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -0,0 +1,288 @@ +/** + * Windows process-table operations for terminal readiness, signalling, and + * teardown: Toolhelp32 snapshot enumeration with GetProcessTimes creation-time + * identity, the shell pid as a pseudo process group (Windows has no POSIX + * groups), and taskkill tree signalling. The koffi bindings load lazily so + * non-Windows processes never touch Win32 libraries; all decision logic takes + * an injectable internals boundary so suites can pin it on any host. + * @module dsh-subprocess-local/windows-inspector + */ + +import { spawnSync } from 'node:child_process' +import koffi from 'koffi' +import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess' +import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts' + +/** One Toolhelp32 process-table row. */ +export interface ProcessEntry { + pid: number + parentPid: number +} + +/** Injectable Windows process operations used by one local PTY session. */ +export interface WindowsProcessInspectorInternals { + /** Enumerate the current process table (pid/parent pairs). */ + snapshot(): ProcessEntry[] + /** Return one process's creation-time identity, or undefined when unreadable. */ + creationTime(pid: number): string | undefined + /** Terminate one process tree; `force` maps to taskkill `/F`. */ + taskkill(pid: number, force: boolean): void +} + +/** + * Walk a process table from one root in children-first order, retaining only + * members whose start identity is readable (unreadable members are detector + * misses, exactly like an unreadable `/proc` entry on Linux). + * @param entries - the process table snapshot. + * @param rootPid - the tree root to descend from. + * @param started - creation-time identity resolver for one member. + * @returns the root and its current transitive descendants, children first. + */ +export function windowsProcessTree( + entries: ProcessEntry[], + rootPid: number, + started: (pid: number) => string | undefined, +): ProcessIdentity[] { + const byPid = new Map(entries.map(entry => [entry.pid, entry])) + const root = byPid.get(rootPid) + if (root === undefined) return [] + const byParent = new Map() + for (const entry of entries) { + const children = byParent.get(entry.parentPid) ?? [] + children.push(entry) + byParent.set(entry.parentPid, children) + } + const visited = new Set() + const result: ProcessIdentity[] = [] + const visit = (entry: ProcessEntry): void => { + if (visited.has(entry.pid)) return + visited.add(entry.pid) + for (const child of byParent.get(entry.pid) ?? []) visit(child) + const identity = started(entry.pid) + if (identity !== undefined) result.push({ pid: entry.pid, started: identity }) + } + visit(root) + return result +} + +/** + * Windows {@link ProcessInspector}. The shell pid stands in for a foreground + * process group: it is a stable pseudo-group that lets the prompt-marker + * readiness path compare foreground identities, while every actual signal + * targets the console-wide tree through taskkill (SIGINT is delivered by the + * terminal handle as a `\x03` input write and never reaches this layer). + */ +export class WindowsProcessInspector implements ProcessInspector { + constructor( + private readonly internals: WindowsProcessInspectorInternals = defaultWindowsProcessInternals(), + ) {} + + foregroundPgid(shellPid: number): number { + return shellPid + } + + isStdinWaiting(_pgid: number): boolean { + return false + } + + processTree(rootPid: number): ProcessIdentity[] { + return windowsProcessTree(this.internals.snapshot(), rootPid, this.internals.creationTime) + } + + processSession(_sessionId: number): ProcessIdentity[] { + return [] + } + + isAlive(identity: ProcessIdentity): boolean { + const started = this.internals.creationTime(identity.pid) + return started !== undefined && started === identity.started + } + + signalGroup(pgid: number, signal: SubprocessTerminalSignal): void { + this.internals.taskkill(pgid, signal === 'SIGKILL') + } + + signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void { + if (this.isAlive(identity)) this.internals.taskkill(identity.pid, signal === 'SIGKILL') + } +} + +/** + * Create the Windows process inspector. + * @param internals - injectable process operations; defaults to the koffi-backed table. + * @returns the Windows inspector. + */ +export function createWindowsProcessInspector( + internals: WindowsProcessInspectorInternals = defaultWindowsProcessInternals(), +): WindowsProcessInspector { + return new WindowsProcessInspector(internals) +} + +/** Terminate one Windows process tree with taskkill, contained like POSIX group signalling. */ +function taskkillTree(pid: number, force: boolean): void { + if (pid <= 0) return + // Outcome deliberately unchecked: an already-absent tree, exit races, and a + // missing taskkill binary are as tolerable here as ESRCH is for POSIX. + spawnSync('taskkill', ['/PID', String(pid), '/T', ...(force ? ['/F'] : [])], { stdio: 'ignore' }) +} + +declare const nativePtr: unique symbol +/** Koffi 3 native pointer (a BigInt address), branded so it cannot silently enter numeric contexts. */ +export type NativePtr = bigint & { readonly [nativePtr]: true } + +/** + * True for NULL and INVALID_HANDLE_VALUE returns from Win32 handle APIs. + * @param value - a handle as koffi may hand it back (pointer, null, or 0n). + * @returns whether the value signals an invalid handle. + */ +export function isInvalidHandle(value: NativePtr | null | undefined): boolean { + if (value === null || value === undefined) return true + const asBigInt = value as bigint + return asBigInt === 0n || asBigInt === 0xFFFFFFFFFFFFFFFFn || asBigInt === -1n +} + +/** The lazy koffi binding table: every Win32 call the Windows inspector uses. */ +interface Win32Bindings { + createToolhelp32Snapshot(flags: number, processId: number): NativePtr + process32FirstW(snapshot: NativePtr, entry: NativePtr): number + process32NextW(snapshot: NativePtr, entry: NativePtr): number + openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr + getProcessTimes( + process: NativePtr, + creation: NativePtr, + exit: NativePtr, + kernel: NativePtr, + user: NativePtr, + ): number + closeHandle(handle: NativePtr): number +} + +const PVOID: ReturnType = koffi.pointer('void') + +/** + * Resolve the koffi Win32 struct types once. Registration is lazy and cached + * because koffi's type registry is global per process: test runners that + * re-evaluate this module (a hoisted `vi.mock` re-imports the graph) must not + * re-register the names. + */ +function win32Structs(): { PROCESSENTRY32W: ReturnType; FILETIME: ReturnType } { + if (cachedStructs !== undefined) return cachedStructs + // koffi PROCESSENTRY32W layout (tlhelp32.h); the size assert pins the x64 layout. + const PROCESSENTRY32W = koffi.struct('PROCESSENTRY32W', { + dwSize: 'uint32', + cntUsage: 'uint32', + th32ProcessID: 'uint32', + th32DefaultHeapID: PVOID, + th32ModuleID: 'uint32', + cCntThreads: 'uint32', + th32ParentProcessID: 'uint32', + pcPriClassBase: 'int32', + dwFlags: 'uint32', + szExeFile: koffi.array('char16', 260), + }) + // koffi FILETIME layout (minwinbase.h): two 32-bit halves of the 64-bit timestamp. + const FILETIME = koffi.struct('FILETIME', { + dwLowDateTime: 'uint32', + dwHighDateTime: 'uint32', + }) + /* v8 ignore start -- a layout-mismatch guard fires only on ABI breakage; the windows-native suites exercise the real struct. */ + if (PROCESSENTRY32W.size !== 568) { + throw new Error(`PROCESSENTRY32W layout mismatch: koffi computed ${PROCESSENTRY32W.size}, Windows headers say 568`) + } + /* v8 ignore stop */ + cachedStructs = { PROCESSENTRY32W, FILETIME } + return cachedStructs +} + +let cachedStructs: ReturnType | undefined + +const TH32CS_SNAPPROCESS = 0x2 +const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + +let cachedBindings: Win32Bindings | undefined + +/** + * Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed). + * @returns the cached binding table. + */ +function win32Bindings(): Win32Bindings { + if (cachedBindings !== undefined) return cachedBindings + const { PROCESSENTRY32W, FILETIME } = win32Structs() + const kernel32 = koffi.load('kernel32.dll') + const bind = ( + name: string, + result: ReturnType | string, + args: Array | string>, + ): unknown => kernel32.func('__stdcall', name, result, args) + cachedBindings = { + createToolhelp32Snapshot: bind('CreateToolhelp32Snapshot', PVOID, ['uint32', 'uint32']), + process32FirstW: bind('Process32FirstW', 'int', [PVOID, koffi.pointer(PROCESSENTRY32W)]), + process32NextW: bind('Process32NextW', 'int', [PVOID, koffi.pointer(PROCESSENTRY32W)]), + openProcess: bind('OpenProcess', PVOID, ['uint32', 'int', 'uint32']), + getProcessTimes: bind('GetProcessTimes', 'int', [ + PVOID, + koffi.pointer(FILETIME), + koffi.pointer(FILETIME), + koffi.pointer(FILETIME), + koffi.pointer(FILETIME), + ]), + closeHandle: bind('CloseHandle', 'int', [PVOID]), + } as unknown as Win32Bindings + return cachedBindings +} + +/** Enumerate the current process table through Toolhelp32. */ +function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] { + const { PROCESSENTRY32W } = win32Structs() + const snapshot = bindings.createToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + /* v8 ignore next -- an invalid snapshot for the process flag is not producible through the public API; + the guard mirrors POSIX's unreadable-proc tolerance and isInvalidHandle is unit-tested. */ + if (isInvalidHandle(snapshot)) return [] + const entries: ProcessEntry[] = [] + try { + const entry = koffi.alloc(PROCESSENTRY32W, 1) + koffi.encode(entry, 'uint32', PROCESSENTRY32W.size) + let ok = bindings.process32FirstW(snapshot, entry) + while (ok !== 0) { + const record = koffi.decode(entry, PROCESSENTRY32W) as { + th32ProcessID: number + th32ParentProcessID: number + } + entries.push({ pid: record.th32ProcessID, parentPid: record.th32ParentProcessID }) + ok = bindings.process32NextW(snapshot, entry) + } + } finally { + bindings.closeHandle(snapshot) + } + return entries +} + +/** Read one process's creation-time identity through GetProcessTimes. */ +function windowsCreationTime(bindings: Win32Bindings, pid: number): string | undefined { + const { FILETIME } = win32Structs() + const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) + if (isInvalidHandle(handle)) return undefined + try { + const creation = koffi.alloc(FILETIME, 1) + const exit = koffi.alloc(FILETIME, 1) + const kernel = koffi.alloc(FILETIME, 1) + const user = koffi.alloc(FILETIME, 1) + /* v8 ignore next -- a GetProcessTimes failure after a successful open races process exit and + cannot be staged deterministically; the absent-process path is covered and the caller + treats undefined as a detector miss. */ + if (bindings.getProcessTimes(handle, creation, exit, kernel, user) === 0) return undefined + const record = koffi.decode(creation, FILETIME) as { dwLowDateTime: number; dwHighDateTime: number } + return `${record.dwHighDateTime}:${record.dwLowDateTime}` + } finally { + bindings.closeHandle(handle) + } +} + +/** The koffi-backed default internals; bindings resolve lazily on first use. */ +function defaultWindowsProcessInternals(): WindowsProcessInspectorInternals { + return { + snapshot: () => snapshotWindowsProcesses(win32Bindings()), + creationTime: pid => windowsCreationTime(win32Bindings(), pid), + taskkill: taskkillTree, + } +} diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index e3131543f4..88f1b351fb 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -7,8 +7,16 @@ import type { SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalS import { childEnv } from '../src/spawn.ts' function spec(command: string, overrides: Partial = {}): SubprocessSpawnSpec { + // Windows has no bash; the suite's simple commands translate to node one-liners. + const argv = process.platform === 'win32' + ? [process.execPath, '-e', { + 'echo managed': 'console.log("managed")', + 'sleep 60': 'setTimeout(() => {}, 60000)', + 'true': '', + }[command] ?? command] + : ['bash', '-c', command] return { - argv: ['bash', '-c', command], + argv, cwd: process.cwd(), stdio: { stdin: 'ignore', @@ -60,9 +68,9 @@ describe('LocalSubprocessService', () => { const explicit = childEnv({ Path: '/bin', PathExt: '.EXE;.CMD' }) expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATH')).toEqual(['Path']) expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATHEXT')).toEqual(['PathExt']) - expect(candidates('tool', explicit)).toEqual(['/bin/tool.EXE', '/bin/tool.CMD']) + expect(candidates('tool', explicit)).toEqual([resolve('/bin', 'tool.EXE'), resolve('/bin', 'tool.CMD')]) expect(candidates('tool', { Path: '/ambient', PATH: '/explicit', PATHEXT: '.EXE' })) - .toEqual(['/explicit/tool.EXE']) + .toEqual([resolve('/explicit', 'tool.EXE')]) expect(candidates('tool.exe', {})).toEqual([resolve(process.cwd(), 'tool.exe')]) expect(candidates('tool', { PATH: '/bin' })).toHaveLength(4) await expect(ctx.subprocess.resolveExecutable(String.raw`bin\server.exe`)) @@ -286,7 +294,8 @@ describe('LocalSubprocessService', () => { const handle = ctx.subprocess.spawn(spec('sleep 60')) await fiber.dispose() const outcome = await handle.done - expect(outcome.signal).toBe('SIGTERM') + // Windows teardown terminates through taskkill, which reports no signal. + expect(outcome.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) it('a settled process leaves the live set (disposal does not re-kill it)', async () => { diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index c90a7b3490..aadf2e1388 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -5,6 +5,7 @@ import { parseProcStat, } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts' import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts' +import { WindowsProcessInspector } from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts' function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1, state = 'S'): string { const rest = [state, String(parentPid), String(pgrp), String(session), '99', String(tpgid)] @@ -237,12 +238,13 @@ describe('macOS process inspector', () => { ]) }) - it('returns undefined for missing or invalid foreground groups and rejects unsupported platforms', () => { + it('returns undefined for missing or invalid foreground groups and dispatches platform inspectors', () => { const fake = fakeInternals() fake.setTpgid('-1') expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined() fake.internals.exec = () => { throw new Error('gone') } expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined() - expect(() => createProcessInspector('win32', 'x64', fake.internals)).toThrow('unsupported on platform win32') + expect(createProcessInspector('win32', 'x64', fake.internals)).toBeInstanceOf(WindowsProcessInspector) + expect(() => createProcessInspector('freebsd', 'x64', fake.internals)).toThrow('unsupported on platform freebsd') }) }) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 4cffde6432..d0fcc422fb 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { + childEnv, killGroup, OutputCollector, spawnSubprocess, @@ -11,6 +12,49 @@ import { import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +/** + * Translate the suite's POSIX command strings into node one-liners on Windows, + * where no bash exists; the translated commands keep the same observable + * stdout/stderr/exit-code contract the bash originals pin on POSIX. + * @param command - the bash `-c` command string used by the test. + * @returns the argv to spawn. + */ +function shellArgv(command: string): string[] { + if (process.platform !== 'win32') return ['bash', '-c', command] + const node = (script: string): string[] => [process.execPath, '-e', script] + switch (command) { + case 'true': return node('') + case 'echo hello': return node('console.log("hello")') + case 'echo hi': return node('console.log("hi")') + case 'echo oops >&2': return node('console.error("oops")') + case 'echo err >&2': return node('console.error("err")') + case 'echo out; echo err >&2': return node('console.log("out"); console.error("err")') + case 'echo out; echo to-parent >&2': return node('console.log("out"); console.error("to-parent")') + case 'echo to-parent; echo err >&2': return node('console.log("to-parent"); console.error("err")') + case 'exit 42': return node('process.exit(42)') + case 'exit 7': return node('process.exit(7)') + case 'pwd': return node('console.log(process.cwd())') + case 'sleep 60': return node('setTimeout(() => {}, 60000)') + case 'cat': return node('process.stdin.pipe(process.stdout)') + case 'unused': return node('') + case 'echo "${TERM:-unset}"': return node('console.log(process.env.TERM ?? "unset")') + case 'echo "$EXTRA_ONE/$EXTRA_TWO"': return node('console.log(process.env.EXTRA_ONE + "/" + process.env.EXTRA_TWO)') + case 'echo "$EXPLICIT_OVERRIDE_PASSWORD"': return node('console.log(process.env.EXPLICIT_OVERRIDE_PASSWORD)') + case 'echo "${SUBPROCESS_TOMBSTONE_PROBE:-absent}"': return node('console.log(process.env.SUBPROCESS_TOMBSTONE_PROBE ?? "absent")') + case 'echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"': + return node('console.log("[" + [process.env.DSH_STALE ?? "absent", process.env.DSH_SHELL, process.env.DSH_SESSION_ID].join("|") + "]")') + case 'echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${SUBPROCESS_TEST_PASSWORD:-absent}|${DSH_TEST_PLAIN:-absent}]"': + return node('console.log("[" + [process.env.DSH_TEST_API_KEY ?? "absent", process.env.DSH_TEST_TOKEN ?? "absent", process.env.SUBPROCESS_TEST_PASSWORD ?? "absent", process.env.DSH_TEST_PLAIN ?? "absent"].join("|") + "]")') + case 'printf "%.0sx" $(seq 1 500)': return node('process.stdout.write("x".repeat(500))') + case 'printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2': + return node('process.stdout.write("x".repeat(500)); process.stderr.write("e".repeat(500))') + case 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done': + return node('for (let i = 1; i <= 200; i++) console.log("line-" + String(i).padStart(4, "0"))') + default: + throw new Error(`spawn.spec: no win32 node translation for ${JSON.stringify(command)}`) + } +} + const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ failNextClose: { value: false }, failNextUnlink: { value: false }, @@ -48,7 +92,7 @@ type SpecOverrides = Partial[0]> & { function spec(command: string, overrides: SpecOverrides = {}) { const { stdoutMaxBytes = 64_000, stderrMaxBytes = 64_000, maxSpillBytes = 64 * 1024 * 1024, stdin, ...rest } = overrides return { - argv: ['bash', '-c', command], + argv: shellArgv(command), cwd: process.cwd(), stdio: { stdin: stdin !== undefined ? { data: stdin } : 'ignore' as const, @@ -161,7 +205,7 @@ describe('spawnSubprocess', () => { expect(result.stdout.text).toBe('callers-choice\n') }) - it('runs in the requested cwd', async () => { + it.skipIf(process.platform === 'win32')('runs in the requested cwd', async () => { const result = await finish(spawnSubprocess(spec('pwd', { cwd: '/tmp' }))) expect(result.stdout.text.trim()).toMatch(/\/tmp$/) }) @@ -176,11 +220,12 @@ describe('spawnSubprocess', () => { setTimeout(() => { controller.abort('deadline') }, 100) const result = await running.done expect(Date.now() - start).toBeLessThan(5_000) - expect(result.signal).toBe('SIGTERM') - expect(result.exitCode).toBeNull() + // Windows teardown terminates through taskkill, which reports no signal. + expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') + expect(result.exitCode).toBe(process.platform === 'win32' ? 1 : null) }) - it('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => { + it.skipIf(process.platform === 'win32')('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => { const running = spawnSubprocess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 })) await waitForStdout(running, 'ready\n') running.terminate() @@ -231,12 +276,16 @@ describe('spawnSubprocess', () => { expect(forceSignals).toBe(0) } finally { killSpy.mockRestore() - process.kill(helper, 'SIGKILL') + try { + process.kill(helper, 'SIGKILL') + } catch { + // taskkill already took the helper down on Windows. + } await waitGone(helper) } }) - it('terminates the whole process group (grandchildren die too)', async () => { + it.skipIf(process.platform === 'win32')('terminates the whole process group (grandchildren die too)', async () => { // The subshell writes the sleep's pid then waits on it; terminating the // group must take the sleep down with bash. const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`) @@ -255,7 +304,7 @@ describe('spawnSubprocess', () => { const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort('user cancelled') }, 50) const result = await running.done - expect(result.signal).toBe('SIGTERM') + expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) it('throws when the signal is already aborted before spawn', () => { @@ -275,10 +324,10 @@ describe('spawnSubprocess', () => { running.terminate() running.terminate() const result = await running.done - expect(result.signal).toBe('SIGTERM') + expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) - it('does not wait for a Linux group that has only zombie members', async () => { + it.skipIf(process.platform === 'win32')('does not wait for a Linux group that has only zombie members', async () => { const pidFile = join(spillDir, `zombie-group-${Date.now()}.pid`) const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo leader-done`, { graceMs: 100 }), { platform: 'linux', @@ -296,7 +345,7 @@ describe('spawnSubprocess', () => { } }) - it('bounds inherited-pipe draining after the shell exits', async () => { + it.skipIf(process.platform === 'win32')('bounds inherited-pipe draining after the shell exits', async () => { const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`) const started = Date.now() const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) @@ -328,7 +377,7 @@ describe('stdin and extra env (set by in-process plugins)', () => { expect(result.stdout.text).toBe('') }) - it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { + it.skipIf(process.platform === 'win32')('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { // With no bytes, fd 0 remains the pre-spawn `ignore` default (/dev/null, a character device). // Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO. const none = await finish(spawnSubprocess(spec('test -c /dev/stdin && echo char || echo other'))) @@ -600,7 +649,7 @@ describe('windows tree semantics (injected platform)', () => { running.terminate() const outcome = await running.done expect(killed).toContain(running.pid) - expect(outcome.signal).toBe('SIGKILL') + expect(outcome.signal).toBe(process.platform === 'win32' ? null : 'SIGKILL') }) it('waitForExit falls back to direct-child liveness where groups do not exist', async () => { @@ -611,7 +660,7 @@ describe('windows tree semantics (injected platform)', () => { }) describe('waitForExit', () => { - it('waits for the whole detached tree, not just the shell', async () => { + it.skipIf(process.platform === 'win32')('waits for the whole detached tree, not just the shell', async () => { const pidFile = join(spillDir, `tree-wait-${Date.now()}.pid`) const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) const grandchild = await waitForPidFile(pidFile) @@ -631,7 +680,7 @@ describe('waitForExit', () => { }) }) -describe('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => { +describe.skipIf(process.platform === 'win32')('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => { it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => { // The leader spawns a TERM-trapping helper with all stdio detached from // the collected pipes, then exits: the helper holds the GROUP alive while @@ -697,6 +746,94 @@ describe('coverage seams', () => { expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow() }) + it('covers the injected POSIX group paths on any host', async () => { + // Windows has no POSIX groups, so the tree-liveness probe, group + // signalling, and the SIGKILL escalation timer only run here through the + // injected platform; the mock keeps the group alive through TERM and + // terminates the direct child when the escalation tier delivers SIGKILL. + const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), { + platform: 'linux', + linuxProcessGroupHasLiveMembers: () => false, + }) + const realKill = process.kill.bind(process) + const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { + if (typeof target === 'number' && target < 0) { + if (signal === 0) return true + if (signal === 'SIGKILL') realKill(running.pid, 'SIGKILL') + return true + } + return realKill(target, signal) + }) + try { + running.terminate() + await running.done + await expect(running.waitForExit()).resolves.toBe(true) + } finally { + killSpy.mockRestore() + } + }) + + it('treats a vanished group probe as quiescent without signalling', async () => { + const running = spawnSubprocess(spec('sleep 60'), { platform: 'linux' }) + const realKill = process.kill.bind(process) + const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { + if (typeof target === 'number' && target < 0) { + throw Object.assign(new Error('simulated absent group'), { code: 'ESRCH' }) + } + return realKill(target, signal) + }) + try { + running.terminate() + await new Promise(resolve => setTimeout(resolve, 20)) + realKill(running.pid, 'SIGKILL') + await running.done + await expect(running.waitForExit()).resolves.toBe(true) + } finally { + killSpy.mockRestore() + } + }) + + it('childEnv keeps the POSIX spread on non-Windows hosts', () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + try { + expect(childEnv({ DSH_X: '1' }).DSH_X).toBe('1') + } finally { + platform.mockRestore() + } + }) + + it('settles through the pipe-drain timer when a descendant holds a collected pipe', async () => { + // The leader spawns a detached grandchild inheriting the collected stdout + // pipe, then exits: `close` cannot settle while the grandchild holds the + // pipe, so the bounded pipe-drain timer must settle the outcome. + const pidFile = join(spillDir, `pipe-drain-${Date.now()}.pid`) + const childScript = ` + const { spawn } = require('node:child_process') + const { writeFileSync } = require('node:fs') + const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + detached: true, + stdio: ['ignore', 1, 2], + }) + writeFileSync(${JSON.stringify(pidFile)}, String(helper.pid)) + helper.unref() + ` + const running = spawnSubprocess({ + ...spec('unused', { graceMs: 100 }), + argv: [process.execPath, '-e', childScript], + }) + const helper = await waitForPidFile(pidFile) + const started = Date.now() + const outcome = await running.done + expect(outcome.exitCode).toBe(0) + expect(Date.now() - started).toBeGreaterThanOrEqual(90) + try { + process.kill(helper, 'SIGKILL') + } catch { + // Already gone; the drain bound is the point under test. + } + await waitGone(helper) + }) + it('a spawn-failed handle rejects done while waitForExit reports gone', async () => { const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' })) await expect(running.done).rejects.toThrow() @@ -833,7 +970,7 @@ describe('argv validation', () => { expect(() => spawnSubprocess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/) }) - it('spawns argv verbatim without shell interpretation', async () => { + it.skipIf(process.platform === 'win32')('spawns argv verbatim without shell interpretation', async () => { const result = await finish(spawnSubprocess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] })) expect(result.stdout.text).toBe('$HOME') }) @@ -853,7 +990,7 @@ describe('abort edge cases', () => { .toThrow(/aborted before spawn: aborted/) }) - it('reports the terminating signal of an externally self-killed command', async () => { + it.skipIf(process.platform === 'win32')('reports the terminating signal of an externally self-killed command', async () => { // spawnSubprocess reports the raw signal; whether it counts as timeout/cancel is the // executor's classification (a self-kill is neither) — see executor.spec.ts. const result = await finish(spawnSubprocess(spec('kill -TERM $$'))) @@ -894,7 +1031,7 @@ describe('environment and spill-file hardening', () => { } }) - it('creates spill files with owner-only permissions and random names', async () => { + it.skipIf(process.platform === 'win32')('creates spill files with owner-only permissions and random names', async () => { const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, @@ -905,7 +1042,7 @@ describe('environment and spill-file hardening', () => { expect(mode).toBe(0o600) }) - it('defaults spills into a private per-process directory', async () => { + it.skipIf(process.platform === 'win32')('defaults spills into a private per-process directory', async () => { const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), )) @@ -931,6 +1068,6 @@ describe('environment and spill-file hardening', () => { const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort() }, 50) const result = await running.done - expect(result.signal).toBe('SIGTERM') + expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) }) diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 79501c7dc4..2622719867 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -73,6 +73,8 @@ class FakeInspector implements ProcessInspector { this.groups.push([pgid, signal]) } signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') { + // Mirrors the real inspectors' alive-gated signalling. + if (!this.alive.has(identity.pid)) return if (this.throwProcess) throw new Error('process raced') this.processes.push([identity.pid, signal]) if (this.removeOnSignal) this.alive.delete(identity.pid) @@ -81,12 +83,18 @@ class FakeInspector implements ProcessInspector { afterEach(() => { vi.useRealTimers() }) +function makeHandle(pty: FakePty, inspector: ProcessInspector, graceMs: number): LocalTerminalHandle { + // The suite pins POSIX signalling semantics deterministically on every host; + // the win32 branches get their own platform-explicit tests below. + return new LocalTerminalHandle(pty.asPty(), inspector, graceMs, 'linux') +} + describe('LocalTerminalHandle', () => { it('bridges terminal bytes, foreground control, and signalled exit facts', async () => { const pty = new FakePty() const inspector = new FakeInspector() inspector.waiting = true - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) const chunks: Buffer[] = [] handle.output.on('data', (chunk: Buffer) => { chunks.push(chunk) }) @@ -107,7 +115,7 @@ describe('LocalTerminalHandle', () => { it('rejects unsafe foreground signals and writes after exit', async () => { const pty = new FakePty() const inspector = new FakeInspector() - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) inspector.pgid = handle.pid await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session') inspector.pgid = undefined @@ -127,7 +135,7 @@ describe('LocalTerminalHandle', () => { inspector.members = [{ pid: 124, started: 'child' }] inspector.alive.add(124) inspector.removeOnSignal = false - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) const quiescent = handle.terminate() expect(handle.terminate()).toBe(quiescent) @@ -148,7 +156,7 @@ describe('LocalTerminalHandle', () => { inspector.members = [{ pid: 124, started: 'child' }] inspector.alive.add(124) inspector.removeOnSignal = false - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) pty.emitExit() const waiting = handle.terminate() let settled = false @@ -167,7 +175,7 @@ describe('LocalTerminalHandle', () => { const disowned = { pid: 124, started: 'disowned' } inspector.processSession = () => inspector.alive.has(disowned.pid) ? [disowned] : [] inspector.alive.add(124) - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) pty.emitExit() @@ -181,7 +189,7 @@ describe('LocalTerminalHandle', () => { const descendant = { pid: 124, started: 'observed' } inspector.members = [descendant] inspector.alive.add(descendant.pid) - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) await handle.inspectForeground() inspector.members = [] @@ -194,7 +202,7 @@ describe('LocalTerminalHandle', () => { it('does not adopt the children of a recycled shell pid', async () => { const pty = new FakePty() const inspector = new FakeInspector() - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) pty.emitExit() const imposterChild = { pid: 999, started: 'imposter-child' } @@ -213,7 +221,7 @@ describe('LocalTerminalHandle', () => { const orphan = { pid: 321, started: 'unverifiable' } inspector.members = [orphan] inspector.alive.add(orphan.pid) - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) await handle.terminate() expect(inspector.processes).toEqual([]) @@ -238,7 +246,7 @@ describe('LocalTerminalHandle', () => { } return [] } - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) await handle.terminate() expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']]) expect(pty.kills).toEqual(['SIGTERM']) @@ -252,7 +260,7 @@ describe('LocalTerminalHandle', () => { inspector.sessionMembers = [late] inspector.alive.add(late.pid) } - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) await handle.terminate() @@ -270,7 +278,7 @@ describe('LocalTerminalHandle', () => { inspector.sessionMembers = [late] inspector.alive.add(late.pid) } - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) const first = handle.terminate() const failed = expect(first).rejects.toThrow('surviving pids: 124') @@ -297,7 +305,7 @@ describe('LocalTerminalHandle', () => { inspector.processes.push([identity.pid, signal]) if (signal === 'SIGKILL') inspector.alive.delete(identity.pid) } - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) const quiescent = handle.terminate() await vi.advanceTimersByTimeAsync(25) await quiescent @@ -308,7 +316,7 @@ describe('LocalTerminalHandle', () => { vi.useFakeTimers() const pty = new FakePty() pty.autoExitOnKill = false - const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10) + const handle = makeHandle(pty, new FakeInspector(), 10) const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123') await vi.advanceTimersByTimeAsync(25) await failed @@ -326,7 +334,98 @@ describe('LocalTerminalHandle', () => { inspector.members = [{ pid: 124, started: 'child' }] inspector.alive.add(124) inspector.throwProcess = true - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 1) + const handle = makeHandle(pty, inspector, 1) await expect(handle.terminate()).rejects.toThrow('surviving pids: 124') }) }) + +describe('LocalTerminalHandle on Windows', () => { + const win32 = 'win32' as NodeJS.Platform + + it('delivers SIGINT as a Ctrl-C input write without inspector signalling', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + await expect(handle.signalForeground('SIGINT')).resolves.toBe(456) + expect(pty.writes).toEqual(['\x03']) + expect(inspector.groups).toEqual([]) + }) + + it('rejects SIGTSTP and SIGHUP as unavailable on Windows', async () => { + const handle = new LocalTerminalHandle(new FakePty().asPty(), new FakeInspector(), 10, win32) + await expect(handle.signalForeground('SIGTSTP')).rejects.toThrow('unsupported on Windows') + await expect(handle.signalForeground('SIGHUP')).rejects.toThrow('unsupported on Windows') + }) + + it('routes SIGTERM through the inspector tree with the pseudo foreground group', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + await expect(handle.signalForeground('SIGTERM')).resolves.toBe(456) + expect(inspector.groups).toEqual([[456, 'SIGTERM']]) + expect(pty.writes).toEqual([]) + }) + + it('still refuses to SIGKILL the terminal shell on Windows', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + inspector.pgid = handle.pid + await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session') + }) + + it('escalates the shell through taskkill tiers instead of node-pty signal kills', async () => { + vi.useFakeTimers() + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(123) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + const quiescent = handle.terminate() + await vi.advanceTimersByTimeAsync(5) + expect(inspector.processes).toEqual([[123, 'SIGTERM']]) + expect(pty.kills).toEqual([]) + + pty.emitExit() + await quiescent + expect(inspector.processes).toEqual([[123, 'SIGTERM']]) + expect(pty.kills).toEqual([]) + }) + + it('reports a shell that survives both taskkill tiers', async () => { + vi.useFakeTimers() + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(123) + inspector.removeOnSignal = false + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123') + await vi.advanceTimersByTimeAsync(25) + await failed + expect(inspector.processes).toEqual([[123, 'SIGTERM'], [123, 'SIGKILL']]) + expect(pty.kills).toEqual([]) + + pty.emitExit() + await handle.terminate() + }) + + it('skips taskkill escalation entirely when the shell already exited', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(123) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + pty.emitExit() + await handle.terminate() + expect(inspector.processes).toEqual([]) + expect(pty.kills).toEqual([]) + }) + + it('falls back to the bare node-pty kill when the shell identity was never observable', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.root = undefined + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + await handle.terminate() + expect(pty.kills).toHaveLength(1) + expect(inspector.processes).toEqual([]) + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts new file mode 100644 index 0000000000..b18945fc10 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { + createWindowsProcessInspector, + isInvalidHandle, + windowsProcessTree, + WindowsProcessInspector, +} from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts' +import type { + NativePtr, + ProcessEntry, + WindowsProcessInspectorInternals, +} from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts' + +function fakeInternals() { + const entries: ProcessEntry[] = [] + const times = new Map() + const kills: Array<[number, boolean]> = [] + return { + internals: { + snapshot: () => [...entries], + creationTime: pid => times.get(pid), + taskkill: (pid: number, force: boolean) => { kills.push([pid, force]) }, + } satisfies WindowsProcessInspectorInternals, + add(entry: ProcessEntry, started?: string): void { + entries.push(entry) + if (started !== undefined) times.set(entry.pid, started) + }, + kills, + } +} + +describe('windowsProcessTree', () => { + it('walks a table children-first with readable identities only', () => { + const started = (pid: number): string | undefined => pid === 12 ? undefined : `t${pid}` + expect(windowsProcessTree([ + { pid: 10, parentPid: 0 }, + { pid: 11, parentPid: 10 }, + { pid: 12, parentPid: 11 }, + { pid: 13, parentPid: 11 }, + { pid: 14, parentPid: 10 }, + ], 10, started)).toEqual([ + { pid: 13, started: 't13' }, + { pid: 11, started: 't11' }, + { pid: 14, started: 't14' }, + { pid: 10, started: 't10' }, + ]) + }) + + it('returns an empty walk for an absent root', () => { + expect(windowsProcessTree([{ pid: 10, parentPid: 0 }], 99, () => 't')).toEqual([]) + }) + + it('terminates on a parent cycle instead of recursing forever', () => { + const entries = [ + { pid: 10, parentPid: 11 }, + { pid: 11, parentPid: 10 }, + ] + expect(windowsProcessTree(entries, 10, () => 't')).toHaveLength(2) + }) +}) + +describe('WindowsProcessInspector (injected internals)', () => { + it('exposes the shell pid as the pseudo foreground group and never proves stdin waits', () => { + const fake = fakeInternals() + const inspector = new WindowsProcessInspector(fake.internals) + expect(inspector.foregroundPgid(77)).toBe(77) + expect(inspector.isStdinWaiting(77)).toBe(false) + expect(inspector.processSession(77)).toEqual([]) + }) + + it('delegates tree walks and identity checks to the internals', () => { + const fake = fakeInternals() + fake.add({ pid: 10, parentPid: 0 }, 't10') + fake.add({ pid: 11, parentPid: 10 }, 't11') + const inspector = new WindowsProcessInspector(fake.internals) + expect(inspector.processTree(10)).toEqual([ + { pid: 11, started: 't11' }, + { pid: 10, started: 't10' }, + ]) + expect(inspector.isAlive({ pid: 11, started: 't11' })).toBe(true) + expect(inspector.isAlive({ pid: 11, started: 'stale' })).toBe(false) + expect(inspector.isAlive({ pid: 99, started: 't99' })).toBe(false) + }) + + it('maps SIGKILL to a forced taskkill and other signals to the grace form', () => { + const fake = fakeInternals() + const inspector = new WindowsProcessInspector(fake.internals) + inspector.signalGroup(77, 'SIGKILL') + inspector.signalGroup(77, 'SIGTERM') + inspector.signalGroup(0, 'SIGKILL') + expect(fake.kills).toEqual([[77, true], [77, false], [0, true]]) + }) + + it('signals a process only while its start identity matches', () => { + const fake = fakeInternals() + fake.add({ pid: 10, parentPid: 0 }, 't10') + const inspector = new WindowsProcessInspector(fake.internals) + inspector.signalProcess({ pid: 10, started: 't10' }, 'SIGKILL') + inspector.signalProcess({ pid: 10, started: 'stale' }, 'SIGTERM') + expect(fake.kills).toEqual([[10, true]]) + }) + + it('accepts an injected internals factory through the creator', () => { + const fake = fakeInternals() + expect(createWindowsProcessInspector(fake.internals)).toBeInstanceOf(WindowsProcessInspector) + expect(createWindowsProcessInspector()).toBeInstanceOf(WindowsProcessInspector) + }) +}) + +describe('isInvalidHandle', () => { + it('rejects null, zero, and the all-ones INVALID_HANDLE_VALUE forms', () => { + const ptr = (value: bigint): NativePtr => value as NativePtr + expect(isInvalidHandle(null)).toBe(true) + expect(isInvalidHandle(undefined)).toBe(true) + expect(isInvalidHandle(ptr(0n))).toBe(true) + expect(isInvalidHandle(ptr(0xFFFFFFFFFFFFFFFFn))).toBe(true) + expect(isInvalidHandle(ptr(-1n))).toBe(true) + expect(isInvalidHandle(ptr(1234n))).toBe(false) + }) +}) + +const win32 = process.platform === 'win32' ? describe : describe.skip + +win32('WindowsProcessInspector over the real koffi bindings', () => { + it('walks the live process table from the test runner itself', () => { + const inspector = createWindowsProcessInspector() + const tree = inspector.processTree(process.pid) + const self = tree.find(member => member.pid === process.pid) + expect(self).toBeDefined() + expect(inspector.isAlive(self!)).toBe(true) + expect(inspector.foregroundPgid(process.pid)).toBe(process.pid) + }) + + it('reports unreadable identities for absent processes and no-ops tree signalling', () => { + const inspector = createWindowsProcessInspector() + expect(inspector.isAlive({ pid: 0x7FFFFFFF, started: 'absent' })).toBe(false) + expect(() => inspector.signalGroup(0x7FFFFFFF, 'SIGKILL')).not.toThrow() + expect(() => inspector.signalGroup(0x7FFFFFFF, 'SIGTERM')).not.toThrow() + expect(() => inspector.signalGroup(0, 'SIGKILL')).not.toThrow() + expect(() => inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL')).not.toThrow() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e890bf58c..254839878b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5478,6 +5478,9 @@ importers: packages/pty/pty-local: dependencies: + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../bash/pwsh-local '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -7085,6 +7088,9 @@ importers: packages/subprocess/subprocess-local: dependencies: + koffi: + specifier: ^3.1.0 + version: 3.1.1 node-pty: specifier: ^1.1.0 version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) diff --git a/vitest.config.ts b/vitest.config.ts index c698057915..b730be54a1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,23 +25,26 @@ const windowsUnsupportedPackages = process.platform === 'win32' // INCLUDED: PowerShell ships with Windows, so they run natively here. // This explicit list (not a 'packages/bash/*' glob) keeps // packages/bash/bash — the Service Definition package — running on Windows. + // subprocess-local and pty-local are NOT listed: their win32 branches are + // first-class (Windows inspector, pwsh dialect), so their suites run and + // their sources stay coverage-required on the windows-native lane; the + // bash-requiring tests inside them self-skip through hasBash probes. 'packages/bash/bash-local', 'packages/bash/bash-sandbox', 'packages/bash/tool-bash', 'packages/hooks/*', - 'packages/subprocess/*', - 'packages/pty/pty-local', 'packages/sandbox/sandbox-local', ] : [] -// Windows-only packages: their sources execute exclusively on win32 (koffi -// loads Win32 libraries), so the Linux coverage lane can never cover them. -// The Windows dev/CI lane exercises them through the probe/runner suites; the -// per-file 100% gate must not fail on their Linux-uncovered paths. +// Windows-only sources: they execute exclusively on win32 (koffi loads Win32 +// libraries), so the Linux coverage lane can never cover them. The Windows +// dev/CI lane exercises them through the probe/runner suites; the per-file +// 100% gate must not fail on their Linux-uncovered paths. const windowsOnlyCoverageExclusions = process.platform !== 'win32' ? [ 'packages/sandbox/sandbox-windows-acl/src/**/*.ts', + 'packages/subprocess/subprocess-local/src/windows-inspector.ts', ] : [] From 557c21cd6cc9bd917e786004d3d9fe701b8c845b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 00:06:42 +0800 Subject: [PATCH 02/60] feat(pty-local): shell dialect for Windows pwsh sessions Adds shellDialect ('bash' | 'pwsh') to the local PTY backend. The effective shellPath/shellArgs resolve per dialect (pwsh through the shared dsh-pwsh-local resolver, bash defaults unchanged), the child environment drops bash-only PS1/PROMPT_COMMAND markers and adds NO_COLOR for pwsh, and pwsh startup bootstraps the prompt function that emits the shared OSC 133;D + BEL marker, waiting (across follow-up sends) until the controlled prompt is actually visible so the banner-to-prompt gap cannot settle startup early. Bash behavior is byte-identical; the real-pwsh suite exercises persistent state and secret scrubbing on Windows. --- packages/e2b/e2b/tests/composition.e2e.ts | 2 +- packages/pty/pty-local/package.json | 1 + packages/pty/pty-local/src/config.ts | 54 ++++++++-- packages/pty/pty-local/src/index.ts | 74 ++++++++++--- packages/pty/pty-local/tests/config.spec.ts | 33 +++++- packages/pty/pty-local/tests/index.spec.ts | 105 ++++++++++++++++++- packages/pty/pty-local/tests/local.spec.ts | 50 ++++++++- packages/pty/pty-local/tests/session.spec.ts | 2 +- packages/pty/pty-local/tsconfig.json | 3 + 9 files changed, 292 insertions(+), 32 deletions(-) diff --git a/packages/e2b/e2b/tests/composition.e2e.ts b/packages/e2b/e2b/tests/composition.e2e.ts index 76f54ba7fd..33f7e4ca31 100644 --- a/packages/e2b/e2b/tests/composition.e2e.ts +++ b/packages/e2b/e2b/tests/composition.e2e.ts @@ -97,7 +97,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { whenIdle: () => Promise.resolve(), } const backend = new LocalPtyBackend(ctx, { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'], + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'], rows: 24, cols: 80, scrollbackLines: 100, scrollbackMaxBytes: 65_536, maxReadBytes: 16_384, pollIntervalMs: 25, exactProbeAfterMs: 150, idleSilenceMs: 1_000, diff --git a/packages/pty/pty-local/package.json b/packages/pty/pty-local/package.json index 3db9e83889..0eb1ee3657 100644 --- a/packages/pty/pty-local/package.json +++ b/packages/pty/pty-local/package.json @@ -42,6 +42,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { diff --git a/packages/pty/pty-local/src/config.ts b/packages/pty/pty-local/src/config.ts index be9ae3eed3..5b7a7c0e9d 100644 --- a/packages/pty/pty-local/src/config.ts +++ b/packages/pty/pty-local/src/config.ts @@ -1,14 +1,20 @@ /** Validated configuration for the local PTY backend. */ import z from '@deepseek-ai/schemastery' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts' + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' /** Public plugin configuration. */ export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -37,14 +43,43 @@ export interface Config { disposeGraceMs?: number } -/** Configuration after Schemastery defaults. */ -export type ResolvedConfig = Required +/** Configuration after Schemastery defaults and dialect resolution. */ +export type ResolvedConfig = Omit, 'shellDialect' | 'shellPath' | 'shellArgs'> & { + shellDialect: ShellDialect + shellPath: string + shellArgs: string[] +} + +/** Bash dialect default executable. */ +export const DEFAULT_BASH_SHELL = '/bin/bash' +/** Bash dialect default arguments (interactive, profile-free). */ +export const DEFAULT_BASH_ARGS = ['--noprofile', '--norc', '-i'] +/** Pwsh dialect default arguments (interactive host, profile-free). */ +export const DEFAULT_PWSH_ARGS = ['-NoLogo', '-NoProfile'] + +/** + * Resolve the effective per-dialect shell specification. Defaulting is this + * explicit step: an unset `shellPath`/`shellArgs` selects the dialect's + * defaults, while an explicit value always wins. + * @param config - Schemastery-resolved plugin configuration. + * @returns the fully resolved configuration. + */ +export function resolveConfig(config: Config): ResolvedConfig { + const shellDialect = config.shellDialect ?? 'bash' + return { + ...(config as Required), + shellDialect, + shellPath: config.shellPath ?? (shellDialect === 'pwsh' ? resolvePwshPath() : DEFAULT_BASH_SHELL), + shellArgs: config.shellArgs ?? (shellDialect === 'pwsh' ? DEFAULT_PWSH_ARGS : DEFAULT_BASH_ARGS), + } +} /** Schemastery config exposed by the plugin. */ export const Config: z = z.object({ backendType: z.string().default('shell'), - shellPath: z.string().default('/bin/bash'), - shellArgs: z.array(z.string()).default(['--noprofile', '--norc', '-i']), + shellDialect: z.union(['bash', 'pwsh'] as const).default('bash'), + shellPath: z.string().required(false), + shellArgs: z.array(z.string()).required(false), rows: z.number().default(40), cols: z.number().default(160), scrollbackLines: z.number().default(10_000), @@ -59,12 +94,11 @@ export const Config: z = z.object({ }) /** - * Assert every numeric config field is a positive safe integer and bounds compose. + * Assert every effective numeric config field is a positive safe integer and bounds compose. * @param config - Schemastery-resolved plugin configuration. - * @returns Narrows the input to the fully resolved configuration. */ -export function validateConfig(config: Config): asserts config is ResolvedConfig { - const resolved = config as ResolvedConfig +export function validateConfig(config: Config): void { + const resolved = resolveConfig(config) if (resolved.backendType.length === 0) throw new Error('pty-local: backendType must be non-empty') if (resolved.shellPath.length === 0) throw new Error('pty-local: shellPath must be non-empty') for (const [name, value] of Object.entries(resolved)) { diff --git a/packages/pty/pty-local/src/index.ts b/packages/pty/pty-local/src/index.ts index ee48a5821d..bb1ec520a9 100644 --- a/packages/pty/pty-local/src/index.ts +++ b/packages/pty/pty-local/src/index.ts @@ -12,7 +12,7 @@ import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty' import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' -import { type Config, type ResolvedConfig, validateConfig } from './config.ts' +import { type Config, type ResolvedConfig, resolveConfig, type ShellDialect, validateConfig } from './config.ts' import { LocalPtySession } from './session.ts' import { CONTROLLED_PROMPT } from './sanitize.ts' @@ -52,22 +52,39 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void { }, { global: true }) } -function childEnvironment(spec: PtyBackendSpawnSpec): Record { +function childEnvironment(spec: PtyBackendSpawnSpec, dialect: ShellDialect): Record { // The subprocess provider supplies its own scrubbed ambient base; these are // deliberate terminal-specific overrides layered after it. - return { + const common = { TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', - PS1: CONTROLLED_PROMPT, - PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"', - BASH_SILENCE_DEPRECATION_WARNING: '1', DSH_SHELL: '1', DSH_SESSION_ID: spec.owner.id, DSH_PTY_SESSION_ID: spec.sessionId, } + if (dialect === 'pwsh') { + // pwsh ignores PS1/PROMPT_COMMAND; its prompt is installed by the startup + // bootstrap instead, and NO_COLOR keeps the renderer quiet. + return { ...common, NO_COLOR: '1' } + } + return { + ...common, + PS1: CONTROLLED_PROMPT, + PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"', + BASH_SILENCE_DEPRECATION_WARNING: '1', + } } +/** + * The pwsh prompt function that emits the shared OSC `133;D;` + BEL marker + * before every prompt, mirroring bash's PROMPT_COMMAND. `[char]27`/`[char]7` + * build the control bytes at runtime because raw ESC characters in submitted + * input are unreliable under PSReadLine. + */ +export const PWSH_PROMPT_SETUP = + "function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + CONTROLLED_PROMPT + "' }" + function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutionPolicy): string[] { const argv = [config.shellPath, ...config.shellArgs] if (policy.mode === 'danger-full-access') return argv @@ -82,9 +99,42 @@ function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutio // TODO(pty-initialize-race-home): Fold this outer abort race into // LocalPtySession.initialize when the send-state consolidation lands; the // session already owns the send lifecycle the race protects. -async function initializeSession(session: LocalPtySession, signal?: AbortSignal): Promise { +async function startupSession( + session: LocalPtySession, + dialect: ShellDialect, + signal?: AbortSignal, +): Promise { + const start = async (): Promise => { + if (dialect === 'bash') { + await session.initialize(signal) + return + } + // pwsh cannot install its prompt from the environment: write the prompt + // function through the session and wait for the first marker prompt, + // which is also the readiness contract of the bash initialize path. The + // banner-to-prompt gap can outlast the silence bound, so the wait loops + // over follow-up sends until the controlled prompt is actually visible + // (in the viewport or the retained scrollback when it landed between + // sends), bounded by the send deadline. + let viewport = '' + for (;;) { + const first = viewport.length === 0 + const operation = session.startSend({ + text: first ? PWSH_PROMPT_SETUP : '', + submit: first, + ...signal !== undefined ? { signal } : {}, + }) + const result = await operation.done + if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup') + if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout') + viewport = result.viewport + const scrollback = session.read({ offset: 0, count: 20 }).text + if (viewport.includes(CONTROLLED_PROMPT) || scrollback.includes(CONTROLLED_PROMPT)) break + } + session.motd = viewport + } if (signal === undefined) { - await session.initialize(signal) + await start() return } const aborted = Promise.withResolvers() @@ -92,7 +142,7 @@ async function initializeSession(session: LocalPtySession, signal?: AbortSignal) signal.addEventListener('abort', onAbort, { once: true }) try { signal.throwIfAborted() - await Promise.race([session.initialize(signal), aborted.promise]) + await Promise.race([start(), aborted.promise]) } finally { signal.removeEventListener('abort', onAbort) } @@ -125,7 +175,7 @@ export class LocalPtyBackend implements PtyBackend { const terminal = await this.spawnTerminal({ argv, cwd: spec.cwd ?? policy.workspaceRoot, - env: childEnvironment(spec), + env: childEnvironment(spec, this.config.shellDialect), rows: this.config.rows, cols: this.config.cols, graceMs: this.config.disposeGraceMs, @@ -133,7 +183,7 @@ export class LocalPtyBackend implements PtyBackend { }) const session = this.createSession(terminal, this.config) try { - await initializeSession(session, spec.signal) + await startupSession(session, this.config.shellDialect, spec.signal) return session } catch (error) { try { @@ -149,5 +199,5 @@ export class LocalPtyBackend implements PtyBackend { /** Register the local PTY backend. */ export function apply(ctx: Context, config: Config): void { validateConfig(config) - ctx.pty.registerBackend(new LocalPtyBackend(ctx, config)) + ctx.pty.registerBackend(new LocalPtyBackend(ctx, resolveConfig(config))) } diff --git a/packages/pty/pty-local/tests/config.spec.ts b/packages/pty/pty-local/tests/config.spec.ts index 87b479c22c..29ccf6b72b 100644 --- a/packages/pty/pty-local/tests/config.spec.ts +++ b/packages/pty/pty-local/tests/config.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import type { Config } from '@deepseek-ai/dsh-pty-local/src/config.ts' -import { validateConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts' +import { resolveConfig, validateConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts' function config(overrides: Partial = {}): Config { return { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160, + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160, scrollbackLines: 100, scrollbackMaxBytes: 1024, maxReadBytes: 512, pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 100, handoffGraceMs: 50, timeoutMs: 1000, disposeGraceMs: 100, @@ -30,3 +30,32 @@ describe('pty-local config', () => { expect(() => { validateConfig(config({ handoffGraceMs: 10, pollIntervalMs: 10 })) }).not.toThrow() }) }) + +describe('pty-local dialect resolution', () => { + it('defaults bash argv to the interactive profile-free form', () => { + const { shellPath, shellArgs, shellDialect } = resolveConfig({ backendType: 'shell', rows: 24, cols: 80 }) + expect(shellDialect).toBe('bash') + expect(shellPath).toBe('/bin/bash') + expect(shellArgs).toEqual(['--noprofile', '--norc', '-i']) + }) + + it('defaults pwsh argv to the interactive profile-free form and resolves the executable', () => { + const resolved = resolveConfig({ backendType: 'shell', shellDialect: 'pwsh', rows: 24, cols: 80 }) + expect(resolved.shellDialect).toBe('pwsh') + expect(resolved.shellPath.length).toBeGreaterThan(0) + expect(resolved.shellArgs).toEqual(['-NoLogo', '-NoProfile']) + }) + + it('lets an explicit shell specification win over the dialect defaults', () => { + const resolved = resolveConfig({ + backendType: 'shell', shellDialect: 'pwsh', shellPath: '/custom/pwsh', shellArgs: ['-NoProfile'], rows: 24, cols: 80, + }) + expect(resolved.shellPath).toBe('/custom/pwsh') + expect(resolved.shellArgs).toEqual(['-NoProfile']) + }) + + it('validates the effective shell path, not only the raw one', () => { + expect(() => { validateConfig({ backendType: 'shell', shellDialect: 'bash', rows: 24, cols: 80 }) }).not.toThrow() + expect(() => { validateConfig({ backendType: 'shell', shellDialect: 'pwsh', rows: 24, cols: 80 }) }).not.toThrow() + }) +}) diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index fc07fc784c..318497670a 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { PassThrough } from 'node:stream' +import { resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -8,7 +9,8 @@ import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import PtyService, { PtyBackendCleanupError, PtySessionId } from '@deepseek-ai/dsh-pty' -import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local' +import type { PtySendRequest, PtyWaitReason } from '@deepseek-ai/dsh-pty' +import { LocalPtyBackend, PWSH_PROMPT_SETUP } from '@deepseek-ai/dsh-pty-local' import * as ptyLocal from '@deepseek-ai/dsh-pty-local' import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts' import type { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts' @@ -37,7 +39,7 @@ class RecordingSandbox extends SandboxProvider { function config(): ResolvedConfig { return { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80, + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80, scrollbackLines: 10, scrollbackMaxBytes: 100, maxReadBytes: 50, pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100, disposeGraceMs: 10, @@ -215,7 +217,7 @@ describe('LocalPtyBackend startup rollback', () => { expect(initialized).toHaveBeenCalledWith(undefined) expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{ argv: ['/bin/bash', '-i'], - policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/workspace' }, + policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: resolve('/workspace') }, }]) }) @@ -243,11 +245,11 @@ describe('LocalPtyBackend startup rollback', () => { expect(spawned).toMatchObject({ argv: ['/sandbox', '--', '/bin/bash', '-i'], - cwd: '/session-workspace', + cwd: resolve('/session-workspace'), }) expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{ argv: ['/bin/bash', '-i'], - policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/session-workspace' }, + policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: resolve('/session-workspace') }, }]) }) @@ -337,6 +339,99 @@ describe('LocalPtyBackend startup rollback', () => { expect(session.motd).toBe('dsh> ') await session.close('test complete') }) + + it('bootstraps a pwsh dialect through the prompt function and scrubs bash-only env', async () => { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + let spawned: SubprocessTerminalSpawnSpec | undefined + let sent: PtySendRequest | undefined + const session = { + motd: '', + startSend: (request: PtySendRequest) => { + sent = request + return { + done: Promise.resolve({ + viewport: 'setup-echo dsh> ', waitReason: 'stdin_read' as const, + sessionStatus: { kind: 'running' as const }, truncated: false, + }), + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + } + }, + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + } as unknown as LocalPtySession + const backend = new LocalPtyBackend( + ctx, + { ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' }, + async (spec) => { spawned = spec; return terminalHandle() }, + () => session, + ) + expect(await backend.spawn(spec(agent(ctx)))).toBe(session) + expect(sent).toMatchObject({ text: PWSH_PROMPT_SETUP, submit: true }) + expect(session.motd).toBe('setup-echo dsh> ') + expect(spawned?.env).toMatchObject({ + TERM: 'dumb', NO_COLOR: '1', DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1', + }) + expect(spawned?.env?.PS1).toBeUndefined() + expect(spawned?.env?.PROMPT_COMMAND).toBeUndefined() + }) + + it('keeps waiting for the marker prompt when the first send settles on silence', async () => { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + const sends: PtySendRequest[] = [] + const session = { + motd: '', + startSend: (request: PtySendRequest) => { + sends.push(request) + const second = sends.length > 1 + return { + done: Promise.resolve({ + viewport: second ? 'dsh> ' : 'PowerShell 7.6.4\n', + waitReason: 'inferred_idle' as const, + sessionStatus: { kind: 'running' as const }, truncated: false, + }), + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + } + }, + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + } as unknown as LocalPtySession + const backend = new LocalPtyBackend( + ctx, + { ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' }, + async () => terminalHandle(), + () => session, + ) + await backend.spawn(spec(agent(ctx))) + expect(sends).toHaveLength(2) + expect(sends[1]).toMatchObject({ text: '', submit: false }) + expect(session.motd).toBe('dsh> ') + }) + + it('rejects a pwsh bootstrap whose shell exits or times out', async () => { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + const sessionFor = (waitReason: PtyWaitReason): LocalPtySession => ({ + startSend: () => ({ + done: Promise.resolve({ + viewport: 'no-prompt', waitReason, + sessionStatus: { kind: 'running' as const }, truncated: false, + }), + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + }), + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + close: () => Promise.resolve(), + }) as unknown as LocalPtySession + const exited = new LocalPtyBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('session_exit')) + await expect(exited.spawn(spec(agent(ctx)))).rejects.toThrow('PTY shell exited during startup') + const timedOut = new LocalPtyBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('timeout')) + await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toThrow('did not reach readiness before startup timeout') + }) }) describe('pty-local plugin shape', () => { diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index f23a6f077a..788669b8f9 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -1,4 +1,5 @@ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs' +import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -12,6 +13,7 @@ import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts' import * as ptyLocal from '@deepseek-ai/dsh-pty-local' const roots: string[] = [] @@ -49,6 +51,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { async function harness( mode: 'danger-full-access' | 'workspace-write', timing: { idleSilenceMs?: number; handoffGraceMs?: number; timeoutMs?: number } = {}, + dialect: 'bash' | 'pwsh' = 'bash', ) { const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-')) roots.push(root) @@ -60,6 +63,7 @@ async function harness( await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root }) await ctx.plugin(LocalSubprocessService) const fiber = await ctx.plugin(ptyLocal, { + shellDialect: dialect, pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: timing.idleSilenceMs ?? 250, @@ -114,7 +118,9 @@ function processIsRunning(pid: number): boolean { } } -describe('pty-local real shell', () => { +// The real-shell suite drives a POSIX bash over the actual node-pty terminal; +// Windows has no bash, and its pwsh counterpart lives in the describe below. +describe.skipIf(process.platform === 'win32')('pty-local real shell', () => { it('persists cwd and environment across sends, scrubs secrets, and closes', async () => { const previous = process.env.DSH_TEST_SECRET process.env.DSH_TEST_SECRET = 'must-not-leak' @@ -247,3 +253,45 @@ describe('pty-local real shell', () => { await ctx.pty.kill(agent, created.sessionId) }, 35_000) }) + +const hasPwsh = spawnSync( + resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], + { encoding: 'utf8' }, +).status === 0 + +describe.skipIf(!hasPwsh)('pty-local pwsh real shell', () => { + it('bootstraps a persistent pwsh, persists state, and scrubs secrets', async () => { + const previous = process.env.DSH_TEST_SECRET + process.env.DSH_TEST_SECRET = 'must-not-leak' + try { + const { ctx, root, agent } = await harness('danger-full-access', { + idleSilenceMs: 300, + handoffGraceMs: 300, + timeoutMs: 8_000, + }, 'pwsh') + const created = await ctx.pty.spawn(agent, { type: 'shell', name: 'main', cwd: root }) + expect(created.motd).toContain('dsh> ') + + const first = ctx.pty.startSend(agent, created.sessionId, { + text: '$env:KEEP = "ok"; Set-Location /', + submit: true, + }) + expect((await first.done).waitReason).toBe('stdin_read') + const second = ctx.pty.startSend(agent, created.sessionId, { + text: 'Write-Output "keep=$env:KEEP secret=$env:DSH_TEST_SECRET"', + submit: true, + }) + const result = await second.done + expect(result.viewport).toContain('keep=ok') + expect(result.viewport).toContain('secret=') + expect(result.viewport).not.toContain('must-not-leak') + + expect(ctx.pty.read(agent, created.sessionId, { offset: 0, count: 40 }).text).toContain('keep=ok') + expect(await ctx.pty.kill(agent, created.sessionId)).toBe(true) + expect(ctx.pty.list(agent)).toEqual([]) + } finally { + if (previous === undefined) delete process.env.DSH_TEST_SECRET + else process.env.DSH_TEST_SECRET = previous + } + }, 30_000) +}) diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index 0ca1835e53..e34decd517 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -130,7 +130,7 @@ function makeSession( function config(overrides: Partial = {}): ResolvedConfig { return { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80, + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80, scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64, pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100, disposeGraceMs: 20, diff --git a/packages/pty/pty-local/tsconfig.json b/packages/pty/pty-local/tsconfig.json index 1580e4c8a2..1a49f5ee67 100644 --- a/packages/pty/pty-local/tsconfig.json +++ b/packages/pty/pty-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../bash/pwsh-local" + }, { "path": "../../core/agent" }, From 0441312768ae04fdaad4c408aaa6be915e814418 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 00:15:27 +0800 Subject: [PATCH 03/60] feat(pty): persistent pwsh tool and the minimal-preset Windows stack Adds @deepseek-ai/dsh-tool-pwsh-persistent, the mirror of tool-bash-persistent for PowerShell: one owner-scoped persistent pwsh per agent, an Invoke-Expression wrapper with backtick-escaped bodies and exact native exit codes ( reset, \True fallback, catch to 1), PSReadLine-echo tolerance (the echoed wrapper is stripped from captured output and can never fabricate completion), and the same timeout/cancel/exit reset semantics with pwsh-flavored diagnostics. The minimal preset now gates its persistent shell stack by platform with the #2234 disabled interpolation: the bash rows mount on POSIX and the pwsh rows (pty-local shellDialect pwsh + the new tool) on win32, keeping exactly one persistent shell per host. windows-shell.spec pins the per-platform roster; the real Loader composition proves cwd/env persistence, multiline and here-string commands, large-output clipping, and exit/reset over a real ConPTY pwsh. --- .../agent-presets/minimal/agent.cordis.yml | 25 + apps/cli/package.json | 1 + apps/cli/tests/windows-shell.spec.ts | 23 +- .../pty/tool-pwsh-persistent/package.json | 62 ++ .../pty/tool-pwsh-persistent/src/index.ts | 476 ++++++++++++++ .../pty/tool-pwsh-persistent/src/invariant.ts | 31 + .../tests/loader-composition.spec.ts | 167 +++++ .../tool-pwsh-persistent/tests/tools.spec.ts | 594 ++++++++++++++++++ .../pty/tool-pwsh-persistent/tsconfig.json | 17 + pnpm-lock.yaml | 58 ++ tsconfig.host.json | 1 + 11 files changed, 1454 insertions(+), 1 deletion(-) create mode 100644 packages/pty/tool-pwsh-persistent/package.json create mode 100644 packages/pty/tool-pwsh-persistent/src/index.ts create mode 100644 packages/pty/tool-pwsh-persistent/src/invariant.ts create mode 100644 packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts create mode 100644 packages/pty/tool-pwsh-persistent/tests/tools.spec.ts create mode 100644 packages/pty/tool-pwsh-persistent/tsconfig.json diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index 1ec0a6ea75..cce25d41b8 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -13,6 +13,8 @@ # The PTY registry is an agent-owned service, so it lives in an entry-local # realm. The backend still consumes the host sandbox policy and subprocess # implementation, while the tool registers into this agent's scoped catalog. +# Exactly one shell stack mounts per host: the bash stack gates off win32 and +# its pwsh twin gates off POSIX, mirroring the one-shot shell rows. - id: persistent-shell name: cordis:group group: true @@ -24,11 +26,13 @@ - id: pty-local name: '@deepseek-ai/dsh-pty-local' + disabled: !!js process.platform === 'win32' config: timeoutMs: 300000 - id: persistent-bash name: '@deepseek-ai/dsh-tool-bash-persistent' + disabled: !!js process.platform === 'win32' config: timeoutMs: 300000 description: |- @@ -41,6 +45,27 @@ * Please avoid commands that may produce a very large amount of output. * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + - id: pty-pwsh + name: '@deepseek-ai/dsh-pty-local' + disabled: !!js process.platform !== 'win32' + config: + shellDialect: pwsh + timeoutMs: 300000 + + - id: persistent-pwsh + name: '@deepseek-ai/dsh-tool-pwsh-persistent' + disabled: !!js process.platform !== 'win32' + config: + timeoutMs: 300000 + description: |- + Run commands in a PowerShell shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * State is persistent across command calls and discussions with the user. + * Use native Windows paths (C:\...) and $env:NAME variables; this is PowerShell, not bash. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process. + # The editor requires absolute paths unconditionally. - id: str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' diff --git a/apps/cli/package.json b/apps/cli/package.json index 694732fe72..4f7758c722 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index 5898314e65..b4202c0dc3 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -122,7 +122,7 @@ describe('shipped agent presets gate both shell tools by platform', () => { } }) - it('minimal mounts no shell tool row at all (its shell is the PTY stack)', () => { + it('minimal mounts no shell tool row and gates its persistent shell stack by platform', () => { const entries: unknown = yaml.load( readFileSync(join(presetRoot, 'minimal', 'agent.cordis.yml'), 'utf8'), { schema: entryListSchema }, @@ -133,5 +133,26 @@ describe('shipped agent presets gate both shell tools by platform', () => { typeof entry === 'object' && entry !== null && (entry as Record).id === id )), `${id} must be absent from minimal`).toBe(false) } + const group = entries.find((entry): entry is Record => ( + typeof entry === 'object' && entry !== null && (entry as Record).id === 'persistent-shell' + )) + if (group === undefined) throw new TypeError('minimal preset must mount persistent-shell') + const rows = group.config as unknown[] + if (!Array.isArray(rows)) throw new TypeError('persistent-shell must carry a row list') + const byId = new Map(rows + .filter((entry): entry is Record => typeof entry === 'object' && entry !== null) + .map(entry => [entry.id, entry])) + // The bash stack (pty-local + persistent-bash) mounts on POSIX only; the + // pwsh twin (pty-local with shellDialect pwsh + persistent-pwsh) mounts on + // win32 only — exactly one persistent shell per host. + for (const id of ['pty-local', 'persistent-bash']) { + expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(true) + expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(false) + } + for (const id of ['pty-pwsh', 'persistent-pwsh']) { + expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(false) + expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(true) + } + expect(byId.get('pty-pwsh')?.config).toMatchObject({ shellDialect: 'pwsh' }) }) }) diff --git a/packages/pty/tool-pwsh-persistent/package.json b/packages/pty/tool-pwsh-persistent/package.json new file mode 100644 index 0000000000..1cf2e224d0 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/package.json @@ -0,0 +1,62 @@ +{ + "name": "@deepseek-ai/dsh-tool-pwsh-persistent", + "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/pty/tool-pwsh-persistent" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/pty/tool-pwsh-persistent/src/index.ts b/packages/pty/tool-pwsh-persistent/src/index.ts new file mode 100644 index 0000000000..53c495585d --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/src/index.ts @@ -0,0 +1,476 @@ +/** + * Model-facing persistent `pwsh` tool over the owner-scoped PTY seam. + * @module @deepseek-ai/dsh-tool-pwsh-persistent + */ + +import { randomUUID } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { PtyReadResult, PtySendResult, PtySessionId } from '@deepseek-ai/dsh-pty' +import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { defineTool } from '@deepseek-ai/dsh-tools' + +// TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file. +const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with Select-String in order to find the line numbers of what you are looking for.' +const LOST_PREFIX_MESSAGE = 'The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.\n' +const SHELL_RESET_MESSAGE = 'The persistent pwsh shell was reset; the next pwsh call starts from the workspace with a fresh current directory and environment.' +const SHELL_PROMPT = '__DSH_PERSISTENT_PWSH_PROMPT__ ' +const TIMEOUT_CODE = 'PERSISTENT_PWSH_TIMEOUT' +// One page is enough to find a just-emitted completion marker; the full +// scrollback is assembled only when a command settles or needs partial output. +const SCROLLBACK_PAGE_LINES = 1_000 +const POLL_INTERVAL_MS = 25 + +const DEFAULT_DESCRIPTION = 'Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent.' + +interface ResolvedConfig { + backendType: string + timeoutMs: number + maxOutputChars: number + description: string +} + +interface CommandMarkers { + start: string + end: string +} + +interface RetainedOutput { + text: string + truncated: boolean +} + +interface CapturedOutput { + text: string + incomplete: boolean + exitCode?: number +} + +interface PersistentShells { + get(owner: Agent, signal: AbortSignal): Promise + reset(owner: Agent, reason: string): Promise +} + +function maybeTruncate(content: string, maxOutputChars: number, incomplete = false): string { + if (content.length <= maxOutputChars && !incomplete) return content + return content.length <= maxOutputChars + ? content + TRUNCATED_MESSAGE + : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE +} + +function markers(): CommandMarkers { + const nonce = randomUUID() + return { + start: `__DSH_PERSISTENT_PWSH_START_${nonce}__`, + end: `__DSH_PERSISTENT_PWSH_END_${nonce}:`, + } +} + +/** + * Escape a command body for embedding in the wrapper's double-quoted string. + * Backtick escapes keep every character literal: backtick first so the + * escapes this function inserts are never re-escaped, `$` so no expansion + * happens at wrapper construction, and `\r\n`/ESC so multi-line commands and + * raw control bytes ride one physical input line without PSReadLine mangling. + * @param value - the model's PowerShell command text. + * @returns the escaped double-quoted-string body. + */ +function quoteForPwsh(value: string): string { + return value + .replaceAll('`', '``') + .replaceAll('"', '`"') + .replaceAll('$', '`$') + .replaceAll('\r', '') + .replaceAll('\n', '`n') + .replaceAll('\x1b', '`e') +} + +function wrapCommand(command: string, marker: CommandMarkers): string { + // Keep the wrapper on one physical line: PSReadLine renders the echoed + // input, and a wrapped line would split the echo the extraction strips. + // The echoed END nonce can never fabricate completion because the status + // regex needs digits immediately after it and the echo continues with + // quote characters. + const body = quoteForPwsh(command) + return `Write-Output '${marker.start}'; $LASTEXITCODE = $null; $__s = 1; try { Invoke-Expression "${body}"; $__ok = $? } catch { $__ok = $false }; if ($null -ne $LASTEXITCODE) { $__s = [int]$LASTEXITCODE } else { $__s = if ($__ok) { 0 } else { 1 } }; Write-Output ('${marker.end}' + $__s)` +} + +function stripPrompt(text: string): string { + let result = text.replace(/\r?\n$/, '') + while (result.endsWith(SHELL_PROMPT)) { + result = result.slice(0, -SHELL_PROMPT.length) + } + return result.endsWith('\n') ? result.slice(0, -1) : result +} + +function commandOutput( + snapshot: RetainedOutput, + marker: CommandMarkers, + wrapper: string, +): CapturedOutput | undefined { + const text = snapshot.text + const end = text.lastIndexOf(marker.end) + const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1] + if (status === undefined) return undefined + const startMarker = text.lastIndexOf(marker.start, end) + const start = startMarker < 0 ? 0 : startMarker + marker.start.length + let captured = text.slice(start, end) + // The PSReadLine echo carries the wrapper source (including both marker + // nonces) before the real markers; anchor on the real markers excludes it, + // and stripping the wrapper covers the rare case where the real START + // scrolled out and extraction fell back to the echoed copy. + captured = captured.replaceAll(wrapper, '') + return { + text: stripPrompt(captured.replace(/^\r?\n/, '')), + incomplete: startMarker < 0, + exitCode: Number(status), + } +} + +function promptCompleted(result: PtySendResult): boolean { + return result.viewport.endsWith(SHELL_PROMPT) + || result.viewport.endsWith(`${SHELL_PROMPT}\r\n`) + || result.viewport.endsWith(`${SHELL_PROMPT}\n`) +} + +function partialOutput( + snapshot: RetainedOutput, + marker: CommandMarkers, + wrapper: string, + fallback: string, + fallbackTruncated = false, +): CapturedOutput { + const startMarker = snapshot.text.lastIndexOf(marker.start) + if (startMarker >= 0) { + return { + text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')), + incomplete: false, + } + } + const fallbackStart = fallback.lastIndexOf(marker.start) + const afterStart = fallbackStart < 0 + ? fallback + : fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '') + const fallbackEnd = afterStart.lastIndexOf(marker.end) + const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd) + return { + text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '').replaceAll(wrapper, '')), + incomplete: fallbackTruncated || fallbackStart < 0, + } +} + +async function pause(): Promise { + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) +} + +function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined { + if (page.text.length === 0 || page.lineEnd <= offset) return undefined + return page.lineEnd +} + +function retainedScrollback( + ctx: Context, + owner: Agent, + id: PtySessionId, + latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }), +): RetainedOutput { + const pages: string[] = latest.text.length === 0 ? [] : [latest.text] + let offset = latest.lineEnd + let truncated = latest.truncated + while (true) { + if (offset >= latest.totalLines) break + const page = ctx.pty.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES }) + truncated ||= page.truncated + if (page.text.length > 0) pages.unshift(page.text) + const next = nextScrollbackOffset(page, offset) + if (next === undefined || next >= page.totalLines) break + offset = next + } + return { text: pages.join('\n'), truncated } +} + +function renderCaptured(output: CapturedOutput, maxOutputChars: number): string { + const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete) + const withPrefix = output.incomplete && output.text.length > 0 + ? LOST_PREFIX_MESSAGE + rendered + : rendered + const marker = output.exitCode !== undefined && output.exitCode !== 0 + ? `[exit code: ${output.exitCode}]` + : undefined + return appendStatusMarker(withPrefix, marker) +} + +function appendStatusMarker(content: string, marker: string | undefined): string { + if (marker === undefined) return content + return content.length === 0 ? marker : `${content}\n${marker}` +} + +function renderShellExitStatus( + content: string, + exitCode: number | null, + signal: NodeJS.Signals | null, +): string { + const marker = signal !== null + ? `[shell killed by signal: ${signal}]` + : exitCode !== null + ? `[shell exited: code ${exitCode}]` + : '[shell exited]' + return appendStatusMarker(content, marker) +} + +/** + * The pwsh prompt function that overrides the backend bootstrap value with + * this tool's own prompt. `[char]27`/`[char]7` build the OSC bytes at runtime + * because raw ESC characters in submitted input are unreliable under + * PSReadLine. + */ +const PWSH_PROMPT_SETUP = + "function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + SHELL_PROMPT + "' }" + +function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { + const pending = new WeakMap>() + const live = new Map() + const creating = new Set>() + const ownerCleanupInstalled = new WeakSet() + const lifecycle = new AbortController() + + const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => { + if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return + await ctx.pty.kill(owner, id, reason) + } + + ctx.effect(() => async () => { + lifecycle.abort(new Error('tool-pwsh-persistent disposed during shell creation')) + await Promise.allSettled([...creating]) + const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-pwsh-persistent disposed') }) + await Promise.all(closing) + live.clear() + }, 'tool-pwsh-persistent shell cleanup') + + const reset = async (owner: Agent, reason: string): Promise => { + pending.delete(owner) + const id = live.get(owner) + live.delete(owner) + if (id !== undefined) await close(owner, id, reason) + } + + const get = (owner: Agent, signal: AbortSignal): Promise => { + const existing = pending.get(owner) + if (existing !== undefined) return existing + const combinedSignal = AbortSignal.any([signal, lifecycle.signal]) + const creation = (async () => { + try { + const cwd = owner.session.header.cwd + const spawned = await ctx.pty.spawn(owner, { + type: config.backendType, + ...cwd === undefined ? {} : { cwd }, + }, combinedSignal) + live.set(owner, spawned.sessionId) + if (!ownerCleanupInstalled.has(owner)) { + ownerCleanupInstalled.add(owner) + owner.ctx.effect(() => () => { + pending.delete(owner) + live.delete(owner) + }, 'tool-pwsh-persistent owner cache cleanup') + } + const setup = ctx.pty.startSend(owner, spawned.sessionId, { + text: PWSH_PROMPT_SETUP, + submit: true, + signal: combinedSignal, + }) + const result = await setup.done + if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') { + throw new Error('persistent pwsh shell did not accept initialization') + } + return spawned.sessionId + } catch (error: unknown) { + await reset(owner, 'persistent pwsh initialization failed') + throw error + } + })() + const tracked = creation.finally(() => { + creating.delete(tracked) + }) + creating.add(tracked) + pending.set(owner, tracked) + return tracked + } + + return { get, reset } +} + +async function executeCommand( + ctx: Context, + shells: PersistentShells, + owner: Agent, + command: string, + config: ResolvedConfig, + upstream: AbortSignal, +): Promise { + using commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE) + const id = await shells.get(owner, commandDeadline.signal) + const marker = markers() + const wrapped = wrapCommand(command, marker) + let first = true + let fallback = '' + let fallbackTruncated = false + + while (true) { + let operation + let result + try { + operation = ctx.pty.startSend(owner, id, { + text: first ? wrapped : '', + submit: first, + signal: commandDeadline.signal, + }) + first = false + result = await operation.done + } catch (error: unknown) { + await shells.reset(owner, 'persistent pwsh send failed') + throw error + } + const incremental = operation.readOutput() + fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport + fallbackTruncated ||= incremental.truncated || result.truncated + const latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }) + const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE) + if (timedOut !== undefined) { + const snapshot = retainedScrollback(ctx, owner, id, latest) + const partial = renderCaptured( + partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), + config.maxOutputChars, + ) + await shells.reset(owner, 'persistent pwsh command timed out') + return [ + // TODO: Report a timeout only; this signal does not establish an OOM. + `Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`, + partial, + SHELL_RESET_MESSAGE, + ].join('\n') + } + if (commandDeadline.signal.aborted) { + await shells.reset(owner, 'persistent pwsh command aborted') + commandDeadline.signal.throwIfAborted() + } + if (latest.text.includes(marker.end)) { + const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker, wrapped) + if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) + } + if (result.sessionStatus.kind === 'exited') { + const snapshot = retainedScrollback(ctx, owner, id, latest) + await shells.reset(owner, 'persistent pwsh shell exited') + return [ + renderShellExitStatus( + renderCaptured(partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), config.maxOutputChars), + result.sessionStatus.exitCode, + result.sessionStatus.signal, + ), + SHELL_RESET_MESSAGE, + ].filter(part => part.length > 0).join('\n') + } + if (promptCompleted(result)) { + const snapshot = retainedScrollback(ctx, owner, id, latest) + return renderCaptured( + partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), + config.maxOutputChars, + ) + } + await pause() + } +} + +/** + * Register the model-facing persistent `pwsh` tool. + * @param ctx - plugin context carrying tools and the owner-scoped PTY service. + * @param config - selected PTY backend and command deadline. + */ +function registerPersistentPwsh(ctx: Context, config: ResolvedConfig): void { + const shells = persistentShells(ctx, config) + const queues = new WeakMap>() + + const serialized = async (owner: Agent, operation: () => Promise): Promise => { + const prior = queues.get(owner) ?? Promise.resolve() + const run = prior.then(operation, operation) + const tail = run.then(() => undefined, () => undefined) + queues.set(owner, tail) + try { + return await run + } finally { + if (queues.get(owner) === tail) queues.delete(owner) + } + } + + ctx.tools.register(defineTool({ + name: 'pwsh', + description: config.description, + parameters: { + command: { + type: 'string', + required: true, + description: 'The PowerShell command to run. Relative path is preferred in the command.', + }, + }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + async execute(args, exec) { + if (args.command.trim().length === 0) throw new Error('command must be a non-empty string') + const owner = exec.agent + if (owner === undefined) throw new Error('pwsh requires an owning agent session') + return serialized(owner, async () => { + exec.signal.throwIfAborted() + return executeCommand(ctx, shells, owner, args.command, config, exec.signal) + }) + }, + presentCall: args => ({ card: 'terminal', title: args.command }), + })) +} + +export const name = 'tool-pwsh-persistent' +export const inject = ['tools', 'pty'] + +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} + +/** Runtime configuration schema for the persistent pwsh tool. */ +export const Config: z = z.object({ + backendType: z.string().default('shell'), + timeoutMs: z.number().default(300_000), + maxOutputChars: z.number().default(16_000), + description: z.string().default(DEFAULT_DESCRIPTION), +}) + +/** Register one owner-scoped persistent `pwsh` tool. */ +export function apply(ctx: Context, config: Config): void { + const resolved: ResolvedConfig = { + backendType: config.backendType ?? 'shell', + timeoutMs: config.timeoutMs ?? 300_000, + maxOutputChars: config.maxOutputChars ?? 16_000, + description: config.description ?? DEFAULT_DESCRIPTION, + } + if (resolved.backendType.trim().length === 0) { + throw new Error('tool-pwsh-persistent: backendType must be non-empty') + } + if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0) { + throw new Error('tool-pwsh-persistent: timeoutMs must be a positive safe integer') + } + if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { + throw new Error('tool-pwsh-persistent: maxOutputChars must be a positive safe integer') + } + if (resolved.description.trim().length === 0) { + throw new Error('tool-pwsh-persistent: description must be non-empty') + } + registerPersistentPwsh(ctx, resolved) +} diff --git a/packages/pty/tool-pwsh-persistent/src/invariant.ts b/packages/pty/tool-pwsh-persistent/src/invariant.ts new file mode 100644 index 0000000000..6f436019f2 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-pwsh-persistent`. + * @module @deepseek-ai/dsh-tool-pwsh-persistent/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pwsh-persistent' + +/** Cordis companion plugin name. */ +export const name = 'tool-pwsh-persistent-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the adapter's private owner-to-shell cache has no + * observable event or data relation. Lifecycle tests prove its cleanup without + * adding a public API solely for an invariant. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..fc10f9cfe3 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -0,0 +1,167 @@ +import { spawnSync } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import PtyService from '@deepseek-ai/dsh-pty' +import * as PtyLocal from '@deepseek-ai/dsh-pty-local' +import SandboxProvider from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' + +const hasPwsh = spawnSync( + resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], + { encoding: 'utf8' }, +).status === 0 + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +class PassthroughSandbox extends SandboxProvider { + confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { + return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } + } +} + +function agent(ctx: Context, cwd: string): Agent { + const id = SessionId('persistent-pwsh-loader-agent') + const scope = ctx.plugin(() => {}) + const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd }) + const value: Agent = { + id, + options: {}, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scope.ctx, + send: () => {}, + followup: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), + inject: () => {}, + cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader composition', () => { + it('preserves cwd and environment across calls', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-persistent-pwsh-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-system-prompt'", + "- name: '@deepseek-ai/dsh-tools'", + "- name: '@deepseek-ai/dsh-pty'", + "- name: '@deepseek-ai/dsh-test-sandbox'", + "- name: '@deepseek-ai/dsh-sandbox-policy'", + ' config:', + ' mode: danger-full-access', + ` workspaceRoot: ${JSON.stringify(root)}`, + "- name: '@deepseek-ai/dsh-subprocess-local'", + "- name: '@deepseek-ai/dsh-pty-local'", + ' config:', + ' shellDialect: pwsh', + ' pollIntervalMs: 10', + ' exactProbeAfterMs: 20', + ' idleSilenceMs: 300', + ' handoffGraceMs: 300', + ' scrollbackLines: 20000', + ' timeoutMs: 8000', + ' disposeGraceMs: 500', + "- name: '@deepseek-ai/dsh-tool-pwsh-persistent'", + ' config:', + ' timeoutMs: 20000', + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-system-prompt', SystemPrompt], + ['@deepseek-ai/dsh-tools', ToolRegistry], + ['@deepseek-ai/dsh-pty', PtyService], + ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox], + ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService], + ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessService], + ['@deepseek-ai/dsh-pty-local', PtyLocal], + ['@deepseek-ai/dsh-tool-pwsh-persistent', ToolPwshPersistent], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await context.loader.await() + + const owner = agent(context, root) + const signal = new AbortController().signal + const execute = (id: string, command: string) => context!.tools.execute({ + signal, + callId: CallId(id), + name: 'pwsh', + arguments: { command }, + agent: owner, + }) + + expect(context.tools.schemas().map(schema => schema.name)).toEqual(['pwsh']) + await execute('state', '$env:KEEP = "loader"; New-Item -ItemType Directory -Force -Path nested | Out-Null; Set-Location nested') + const observed = text(await execute('observe', 'Write-Output "cwd=$PWD keep=$env:KEEP"')) + expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`) + expect(observed).not.toContain('DSH_PERSISTENT_PWSH') + + const multiline = text(await execute( + 'multiline', + '$value = "line one"\nWrite-Output "${value}:it\'s fine"', + )) + expect(multiline).toBe("line one:it's fine") + expect(multiline).not.toContain('DSH_PERSISTENT_PWSH') + + const hereString = text(await execute( + 'here-string', + "$h = @'\nalpha\nbeta\n'@\nWrite-Output $h", + )) + expect(hereString).toBe('alpha\nbeta') + + const large = text(await execute('large-output', '1..12050 | ForEach-Object { $_ }')) + expect(large.startsWith('1\n2\n3\n')).toBe(true) + expect(large).toContain('') + expect(large).not.toContain('beginning of this command output was dropped') + + const exited = text(await execute('exit', 'exit')) + expect(exited).toContain('next pwsh call starts from the workspace') + expect(text(await execute('after-exit', 'Write-Output "$PWD"'))).toBe(root) + }, 60_000) +}) diff --git a/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts b/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts new file mode 100644 index 0000000000..b1a6ee8395 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts @@ -0,0 +1,594 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import PtyService from '@deepseek-ai/dsh-pty' +import type { + PtyBackend, + PtyBackendSession, + PtyReadRequest, + PtySendOperation, + PtySendRequest, + PtySessionStatus, + PtySignal, + PtyWaitReason, +} from '@deepseek-ai/dsh-pty' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' + +const contexts: Context[] = [] +let callNumber = 0 + +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() +}) + +function agent(ctx: Context, cwd: string | undefined): Agent { + const id = SessionId(`persistent-pwsh-owner-${callNumber}`) + const scope = ctx.plugin(() => {}) + const session = Session.create(id, [], { + version: 0, + id, + createdAt: 0, + ...cwd === undefined ? {} : { cwd }, + }) + const value: Agent = { + id, + options: {}, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scope.ctx, + send: () => {}, + followup: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), + inject: () => {}, + cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +function call( + ctx: Context, + owner: Agent | undefined, + command: string, + signal = new AbortController().signal, +) { + return ctx.tools.execute({ + signal, + callId: CallId(`persistent-pwsh-${++callNumber}`), + name: 'pwsh', + arguments: { command }, + ...owner === undefined ? {} : { agent: owner }, + }) +} + +type StubMode = + | 'normal' + | 'prompt-only' + | 'prompt-crlf' + | 'empty-read' + | 'stalled-read' + | 'exit' + | 'signal-exit' + | 'unknown-exit' + | 'wait-for-abort' + | 'end-on-abort' + | 'idle-then-normal' + | 'large' + | 'nonzero' + | 'torn-status' + | 'finish-torn-status' + | 'end-only' + | 'init-exit' + | 'init-timeout' + | 'spawn-error' + | 'send-error' + | 'prompt-after-idle' + | 'incremental-fallback' + | 'empty-page-after-latest' + | 'paged-scrollback' + | 'with-echo' + +const START_PATTERN = /__DSH_PERSISTENT_PWSH_START_[^_]+(?:-[^_]+)*__/ +const END_PATTERN = /__DSH_PERSISTENT_PWSH_END_[^:]+:/ + +class StubPtySession implements PtyBackendSession { + readonly motd = '__DSH_PERSISTENT_PWSH_PROMPT__ ' + readonly pid = 123 + statusValue: PtySessionStatus = { kind: 'running' } + scrollback = this.motd + closed: string[] = [] + mode: StubMode + sends = 0 + pendingText = '' + historyTruncated = false + + constructor(mode: StubMode) { + this.mode = mode + } + + startSend(request: PtySendRequest): PtySendOperation { + this.sends += 1 + if (request.text.startsWith('function prompt')) { + if (this.mode === 'init-exit') { + this.statusValue = { kind: 'exited', exitCode: 1, signal: null } + return this.operation(Promise.resolve(this.result('', 'session_exit'))) + } + if (this.mode === 'init-timeout') { + return this.operation(Promise.resolve(this.result('', 'timeout'))) + } + return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) + } + if (this.mode === 'send-error') throw new Error('stub send failed') + if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') { + const done = new Promise>((resolve) => { + request.signal?.addEventListener('abort', () => { + const start = START_PATTERN.exec(request.text)?.[0] + const end = END_PATTERN.exec(request.text)?.[0] + const output = this.mode === 'end-on-abort' + ? `${start ?? ''}\ninterrupted\n${end ?? ''}130\n${this.motd}` + : 'partial output' + this.scrollback += output + resolve(this.result(output, 'stdin_read')) + }, { once: true }) + }) + return this.operation(done) + } + if (this.mode === 'idle-then-normal') { + this.mode = 'normal' + this.pendingText = request.text + return this.operation(Promise.resolve(this.result('', 'inferred_idle'))) + } + if (this.mode === 'prompt-after-idle') { + if (request.text.length > 0) { + const start = START_PATTERN.exec(request.text)?.[0] + const output = `${start ?? ''}\npartial syntax output\n` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'inferred_idle'))) + } + const output = `pwsh: syntax error\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') { + const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n' + const output = `pwsh: syntax error${newline}${this.motd}${newline}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + const sent = request.text.length > 0 ? request.text : this.pendingText + this.pendingText = '' + const start = START_PATTERN.exec(sent)?.[0] + const end = END_PATTERN.exec(sent)?.[0] + if (this.mode === 'with-echo') { + // The PSReadLine echo renders the submitted wrapper before the real + // markers; the tool must strip it from the captured result. + const output = `${sent}\n${start ?? ''}\nhello from stub\n${end ?? ''}0\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + if (this.mode === 'incremental-fallback') { + const incremental = `${start ?? ''}\nincrement\n${this.motd}` + return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental) + } + if (this.mode === 'torn-status') { + const output = `${start ?? ''}\nhello from stub\n${end ?? ''}` + this.scrollback += output + this.mode = 'finish-torn-status' + return this.operation(Promise.resolve(this.result(output, 'inferred_idle'))) + } + if (this.mode === 'finish-torn-status') { + const output = `7\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + if (this.mode === 'end-only') { + const output = `recovered output\n${end ?? ''}0\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + const commandOutput = this.mode === 'large' + ? 'x'.repeat(100) + : this.mode === 'nonzero' ? '' : 'hello from stub' + const exitCode = this.mode === 'nonzero' ? 7 : 0 + const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}` + this.scrollback += output + if (this.mode === 'exit' || this.mode === 'signal-exit' || this.mode === 'unknown-exit') { + const exitedOutput = `${start ?? ''}\nhello from stub\n` + this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput + this.statusValue = this.mode === 'signal-exit' + ? { kind: 'exited', exitCode: null, signal: 'SIGTERM' } + : this.mode === 'exit' + ? { kind: 'exited', exitCode: 9, signal: null } + : { kind: 'exited', exitCode: null, signal: null } + return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit'))) + } + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + + read(request: PtyReadRequest) { + if (this.mode === 'empty-read') { + return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false } + } + if (this.mode === 'stalled-read') { + return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false } + } + if (this.mode === 'empty-page-after-latest' && (request.offset ?? 0) > 0) { + return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false } + } + const lines = this.scrollback.split('\n') + if (this.mode === 'paged-scrollback') { + const offset = request.offset ?? 0 + const end = lines.length - offset + const start = Math.max(0, end - 3) + const returnedLines = end - start + return { + text: lines.slice(start, end).join('\n'), + totalLines: lines.length, + lineBegin: offset, + lineEnd: offset + returnedLines, + truncated: this.historyTruncated, + } + } + return { + text: this.scrollback, + totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length, + lineBegin: 0, + lineEnd: this.mode === 'empty-page-after-latest' ? 1 : lines.length, + truncated: this.historyTruncated, + } + } + + signal(_signal: PtySignal) { + return Promise.resolve({ delivered: true as const, targetPgid: 123 }) + } + + status() { + return this.statusValue + } + + async close(reason: string) { + this.closed.push(reason) + this.statusValue = { kind: 'exited', exitCode: 0, signal: null } + } + + private result(viewport: string, waitReason: PtyWaitReason) { + return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false } + } + + private operation(done: Promise>, delta = ''): PtySendOperation { + return { + done, + readOutput: () => ({ delta, truncated: false }), + cancel: () => false, + } + } +} + +function stubBackend(initialMode: StubMode = 'normal') { + const sessions: StubPtySession[] = [] + const backend: PtyBackend = { + type: 'stub', + async spawn() { + if (initialMode === 'spawn-error') throw new Error('stub spawn failed') + const session = new StubPtySession(initialMode) + sessions.push(session) + return session + }, + } + return { backend, sessions } +} + +async function setup( + config: ToolPwshPersistent.Config = { backendType: 'stub' }, + initialMode: StubMode = 'normal', +) { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(PtyService) + const stub = stubBackend(initialMode) + ctx.pty.registerBackend(stub.backend) + const fiber = await ctx.plugin(ToolPwshPersistent, config) + return { ctx, stub, fiber, owner: agent(ctx, '/workspace') } +} + +describe('tool-pwsh-persistent', () => { + it('registers a configurable schema and reuses one owner shell', async () => { + const { ctx, owner, stub, fiber } = await setup({ + backendType: 'stub', + description: 'deployment-specific persistent shell', + }) + const schema = ctx.tools.schemas()[0] + expect(ctx.tools.schemas().map(item => item.name)).toEqual(['pwsh']) + expect(schema?.description).toBe('deployment-specific persistent shell') + expect(schema?.parameters).toMatchObject({ + required: ['command'], + properties: { command: { type: 'string' } }, + }) + expect(ctx.tools.get('pwsh')?.presentCall?.({ command: 'pwd' })) + .toEqual({ card: 'terminal', title: 'pwd' }) + + expect(text(await call(ctx, owner, 'Write-Output one'))).toBe('hello from stub') + expect(text(await call(ctx, owner, 'Write-Output two'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(1) + expect(stub.sessions[0]?.sends).toBe(3) + + const ownerWithoutCwd = agent(ctx, undefined) + expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + + await fiber.dispose() + expect(ctx.tools.schemas()).toEqual([]) + expect(ctx.tools.get('pwsh')).toBeUndefined() + }) + + it('strips the echoed wrapper from captured output', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'with-echo' + const result = text(await call(ctx, owner, 'Write-Output hi')) + expect(result).toBe('hello from stub') + expect(result).not.toContain('__DSH_PERSISTENT_PWSH_START_') + expect(result).not.toContain('__DSH_PERSISTENT_PWSH_END_') + expect(result).not.toContain('Invoke-Expression') + }) + + it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => { + const { ctx, owner, stub, fiber } = await setup({ + backendType: 'stub', + maxOutputChars: 10, + }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'idle-then-normal' + expect(text(await call(ctx, owner, 'silent then complete'))).toContain('hello from') + + session.mode = 'incremental-fallback' + session.scrollback = '' + expect(text(await call(ctx, owner, 'incremental fallback'))).toBe('increment') + + session.mode = 'prompt-only' + const promptFallback = text(await call(ctx, owner, 'bad {')) + expect(promptFallback).toContain('pwsh: synt') + expect(promptFallback).not.toContain('DSH_PERSISTENT_PWSH_PROMPT') + + session.mode = 'prompt-crlf' + session.scrollback = '' + const crlfPromptFallback = text(await call(ctx, owner, 'bad {')) + expect(crlfPromptFallback).toContain('pwsh: synt') + expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_PWSH_PROMPT') + + session.mode = 'end-only' + session.scrollback = '' + const missingStart = text(await call(ctx, owner, 'recover marker')) + expect(missingStart).toContain('recovered') + expect(missingStart).toContain('beginning of this command output was dropped') + expect(missingStart).toContain('') + + session.mode = 'large' + expect(text(await call(ctx, owner, 'large'))).toContain('') + + session.mode = 'nonzero' + expect(text(await call(ctx, owner, 'false'))).toBe('[exit code: 7]') + + session.mode = 'exit' + const exited = text(await call(ctx, owner, 'exit')) + expect(exited).toContain('hello from') + expect(exited).toContain('[shell exited: code 9]') + expect(exited).not.toContain('[exit code: 9]') + expect(exited).toContain('next pwsh call starts from the workspace') + expect(session.closed).toContain('persistent pwsh shell exited') + + await call(ctx, owner, 'new shell') + expect(stub.sessions).toHaveLength(2) + const replacement = stub.sessions[1]! + replacement.mode = 'signal-exit' + expect(text(await call(ctx, owner, 'kill shell'))) + .toContain('[shell killed by signal: SIGTERM]') + + await call(ctx, owner, 'another shell') + expect(stub.sessions).toHaveLength(3) + const externallyClosed = ctx.pty.list(owner)[0]?.sessionId + expect(externallyClosed).toBeDefined() + await ctx.pty.kill(owner, externallyClosed!, 'external cleanup') + await fiber.dispose() + expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) + }) + + it('waits for status digits after a torn completion marker', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'torn-status' + stub.sessions[0]!.scrollback = '' + + expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]') + }) + + it('reports a shell exit when the backend has no code or signal', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'unknown-exit' + + expect(text(await call(ctx, owner, 'exit without status'))).toContain('[shell exited]') + }) + + it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'end-only' + session.scrollback = '' + expect(text(await call(ctx, owner, 'missing start'))) + .toContain('beginning of this command output was dropped') + + session.mode = 'empty-read' + expect(text(await call(ctx, owner, 'empty page'))).toContain('hello from stub') + + session.mode = 'stalled-read' + expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub') + + session.mode = 'empty-page-after-latest' + expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub') + }) + + it('assembles retained output across backward scrollback pages', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'paged-scrollback' + session.scrollback = 'older one\nolder two\nolder three\nolder four\n' + + expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub') + }) + + it('sanitizes a prompt fallback reached after multiple polling rounds', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'prompt-after-idle' + session.scrollback = '' + const result = text(await call(ctx, owner, 'bad {')) + expect(result).toContain('partial syntax output') + expect(result).toContain('pwsh: syntax error') + expect(result).not.toContain('DSH_PERSISTENT_PWSH_PROMPT') + expect(result).not.toContain('DSH_PERSISTENT_PWSH_START') + }) + + it('does not attribute old scrollback truncation to a complete current command', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.historyTruncated = true + const result = text(await call(ctx, owner, 'short command')) + expect(result).toBe('hello from stub') + expect(result).not.toContain('') + expect(result).not.toContain('beginning of this command output was dropped') + }) + + it('closes a timed-out shell and reports bounded partial output', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'wait-for-abort' + const result = await call(ctx, owner, 'hang') + expect(text(result)).toContain('timed out after 0 seconds or experienced an OOM error') + expect(text(result)).toContain('partial output') + expect(text(result)).toContain('next pwsh call starts from the workspace') + expect(stub.sessions[0]?.closed).toContain('persistent pwsh command timed out') + }) + + it.each(['wait-for-abort', 'end-on-abort'] as const)( + 'cancels %s work, resets the shell, and releases a queued call', + async (mode) => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = mode + const controller = new AbortController() + const cancelled = call(ctx, owner, 'hang', controller.signal) + const queued = call(ctx, owner, 'after cancellation') + setTimeout(() => { + controller.abort(new Error('caller stopped')) + }, 5) + + expect((await cancelled).isError).toBe(true) + expect(text(await queued)).toBe('hello from stub') + expect(stub.sessions[0]?.closed).toContain('persistent pwsh command aborted') + expect(stub.sessions).toHaveLength(2) + }, + ) + + it.each(['init-exit', 'init-timeout'] as const)( + 'fails initialization and closes the unusable shell for %s', + async (mode) => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode) + expect((await call(ctx, owner, 'pwd')).isError).toBe(true) + expect(stub.sessions[0]?.closed).toContain('persistent pwsh initialization failed') + }, + ) + + it('clears a failed spawn without trying to close an unpublished shell', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }, 'spawn-error') + expect((await call(ctx, owner, 'pwd')).isError).toBe(true) + expect(stub.sessions).toHaveLength(0) + }) + + it('resets a cached shell after startSend fails', async () => { + const { ctx, owner, stub } = await setup() + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'send-error' + expect((await call(ctx, owner, 'fails')).isError).toBe(true) + expect(stub.sessions[0]?.closed).toContain('persistent pwsh send failed') + expect(text(await call(ctx, owner, 'recovers'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + + it('cancels and awaits a pending shell spawn when the plugin is disposed', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(PtyService) + const spawnStarted = Promise.withResolvers() + const spawnAborted = Promise.withResolvers() + ctx.pty.registerBackend({ + type: 'slow', + spawn: spec => new Promise((_resolve, reject) => { + spawnStarted.resolve(undefined) + spec.signal?.addEventListener('abort', () => { + spawnAborted.resolve(undefined) + const reason: unknown = spec.signal?.reason + reject(reason instanceof Error + ? reason + : new Error('slow PTY spawn aborted', { cause: reason })) + }, { once: true }) + }), + }) + const fiber = await ctx.plugin(ToolPwshPersistent, { backendType: 'slow' }) + const owner = agent(ctx, '/workspace') + const running = call(ctx, owner, 'pwd') + await spawnStarted.promise + await fiber.dispose() + await spawnAborted.promise + expect((await running).isError).toBe(true) + expect(ctx.pty.list(owner)).toEqual([]) + }) + + it('rejects invalid config and invalid calls', async () => { + const { ctx, owner, stub } = await setup() + expect((await call(ctx, undefined, 'pwd')).isError).toBe(true) + expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string') + + const controller = new AbortController() + controller.abort(new Error('caller stopped')) + expect((await call(ctx, owner, 'pwd', controller.signal)).isError).toBe(true) + expect(stub.sessions).toHaveLength(0) + + expect(() => { + ToolPwshPersistent.apply(new Context(), { backendType: '' }) + }).toThrow('backendType must be non-empty') + expect(() => { + ToolPwshPersistent.apply(new Context(), { timeoutMs: 0 }) + }).toThrow('timeoutMs must be a positive safe integer') + expect(() => { + ToolPwshPersistent.apply(new Context(), { maxOutputChars: 0 }) + }).toThrow('maxOutputChars must be a positive safe integer') + expect(() => { + ToolPwshPersistent.apply(new Context(), { description: ' ' }) + }).toThrow('description must be non-empty') + }) +}) diff --git a/packages/pty/tool-pwsh-persistent/tsconfig.json b/packages/pty/tool-pwsh-persistent/tsconfig.json new file mode 100644 index 0000000000..57c13a61c2 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../core/agent" }, + { "path": "../../core/tools" }, + { "path": "../pty" }, + { "path": "../../support/invariants" }, + { "path": "../../util/timeout" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 254839878b..b99d0ba5bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -246,6 +246,9 @@ importers: '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:^ version: link:../../packages/bash/tool-pwsh + '@deepseek-ai/dsh-tool-pwsh-persistent': + specifier: workspace:^ + version: link:../../packages/pty/tool-pwsh-persistent '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph @@ -5626,6 +5629,61 @@ importers: specifier: workspace:^ version: link:../../core/tools + packages/pty/tool-pwsh-persistent: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-pty': + specifier: workspace:^ + version: link:../pty + '@deepseek-ai/dsh-pty-local': + specifier: workspace:^ + version: link:../pty-local + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../bash/pwsh-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/sandbox/sandbox: devDependencies: '@deepseek-ai/cordis': diff --git a/tsconfig.host.json b/tsconfig.host.json index b5e43cb523..b2525c8ddf 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -182,6 +182,7 @@ { "path": "./packages/pty/pty" }, { "path": "./packages/pty/pty-local" }, { "path": "./packages/pty/tool-bash-persistent" }, + { "path": "./packages/pty/tool-pwsh-persistent" }, { "path": "./packages/pty/tool-pty" }, { "path": "./packages/code-runtime/code-runtime" }, { "path": "./packages/code-runtime/code-runtime-worker" }, From da4701d28b984bc16f9aabddaddb80af55c973eb Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 00:28:29 +0800 Subject: [PATCH 04/60] docs(pty): persistent pwsh READMEs, dialect docs, and the implemented note Adds the tool-pwsh-persistent README trio, documents the pty-local shellDialect and the subprocess-local Windows inspector (console-wide signalling, pseudo foreground groups, taskkill teardown) in both languages, updates the tool-pwsh and persistent-pty notes in place, and moves the pwsh-persistent-pty design note to implemented with the shipped Decision and Consequences. --- .../2026-08-11-pwsh-persistent-pty.i18n.yaml | 6 ++ .../2026-08-11-pwsh-persistent-pty.md | 65 +++++++++++++++++++ .../2026-08-11-pwsh-persistent-pty.zh.md | 65 +++++++++++++++++++ ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +- .../2026-07-16-persistent-pty-sessions.md | 2 +- .../2026-07-16-persistent-pty-sessions.zh.md | 2 +- packages/bash/tool-pwsh/README.i18n.yaml | 4 +- packages/bash/tool-pwsh/README.md | 2 +- packages/bash/tool-pwsh/README.zh.md | 2 +- packages/pty/pty-local/README.i18n.yaml | 4 +- packages/pty/pty-local/README.md | 5 +- packages/pty/pty-local/README.zh.md | 5 +- .../pty/tool-pwsh-persistent/README.i18n.yaml | 6 ++ packages/pty/tool-pwsh-persistent/README.md | 55 ++++++++++++++++ .../pty/tool-pwsh-persistent/README.zh.md | 55 ++++++++++++++++ .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 4 +- .../subprocess/subprocess-local/README.zh.md | 4 +- 18 files changed, 276 insertions(+), 18 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md create mode 100644 .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md create mode 100644 packages/pty/tool-pwsh-persistent/README.i18n.yaml create mode 100644 packages/pty/tool-pwsh-persistent/README.md create mode 100644 packages/pty/tool-pwsh-persistent/README.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml new file mode 100644 index 0000000000..c74339813b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md +2026-08-11-pwsh-persistent-pty.md: 51988586a6c260d528e140ab718eb302355c7314 +2026-08-11-pwsh-persistent-pty.zh.md: e753c050827b216330ac9bce0258f9b59d866999 diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md new file mode 100644 index 0000000000..51988586a6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md @@ -0,0 +1,65 @@ +# Agent Note: Persistent pwsh over the PTY seam on Windows + +Status: implemented + +English | [中文](2026-08-11-pwsh-persistent-pty.zh.md) + +## Problem + +The harness had no persistent shell on Windows. The persistent `bash` stack was POSIX-only by construction: `@deepseek-ai/dsh-subprocess-local` threw at terminal allocation (`createProcessInspector()` rejected win32), `@deepseek-ai/dsh-pty-local` was bash-shaped (`/bin/bash` defaults, `PS1`/`PROMPT_COMMAND` environment markers), `@deepseek-ai/dsh-tool-bash-persistent` wrapped commands in bash syntax, and every pty test skipped on win32. The one-shot `pwsh` tool (`@deepseek-ai/dsh-tool-pwsh` over `@deepseek-ai/dsh-pwsh-local`) already ran on Windows, but each call started a fresh `pwsh -Command` process: cwd, `$env:` variables, functions, and interactive children ended with the call, and its README recorded "No persistent shell or PTY" as deferred work. + +The gap excluded Windows workflows whose state lives in a terminal: stepping a debugger, exploring in a Python or Node REPL, or returning to a shell after interrupting its foreground command — the same class of work the persistent bash pty serves on POSIX. + +Two foundations already existed. The PTY service itself (`ctx.pty` registry, owner scoping, send/read/signal/kill contract) is platform-neutral. The Loader's `disabled: !!js` interpolation (PR #2234) gates shell rows per platform and pins the invariant that exactly one shell stack mounts per host; a persistent pwsh stack composes through the same rows. + +## Decision + +A model-facing persistent `pwsh` tool ships on Windows with the same contract as `tool-bash-persistent`: one owner-scoped persistent shell per Agent, marker-detected command completion, exact native exit codes, bounded output, and timeout/cancel/`exit` semantics that reset the shell and tell the model. Three pieces deliver it: a Windows substrate in `subprocess-local`, a shell-dialect option in `pty-local`, and the new `tool-pwsh-persistent` package with the minimal-preset composition rows. + +### Windows substrate in `@deepseek-ai/dsh-subprocess-local` + +`createProcessInspector()` returns a `WindowsProcessInspector` on win32 instead of throwing. The koffi-backed inspector enumerates the process table through Toolhelp32 with GetProcessTimes creation-time identities (pid-reuse fencing like the POSIX start identity), reports the **shell pid as a pseudo foreground group** (Windows has no POSIX groups; the stable value lets the prompt-marker readiness fast path settle in one poll interval), reports no stdin-wait evidence (readiness degrades exactly like macOS), and signals through `taskkill /T` escalation (`/F` only for SIGKILL). koffi (`^3.1.0`, the version `sandbox-windows-acl` already pins) loads lazily on win32 only. + +`LocalTerminalHandle` branches for win32 because node-pty's `kill(signal)` throws ("Signals not supported on windows") and its bare kill delegates to a console-list agent that fails without a parent console. Teardown escalates through taskkill fenced on the shell's start identity, and — because an externally taskkilled shell may never fire node-pty's exit notification — the handle settles `done` from the inspector-verified absence (`settleExitIfGone`). `signalForeground` maps SIGINT to a `\x03` Ctrl-C input write (the console-wide delivery conhost turns into a CTRL_C event; verified to interrupt a running command), routes SIGTERM/SIGKILL to taskkill, and rejects SIGTSTP/SIGHUP as unavailable on Windows. The public `PtySignal` set and seam types are unchanged; the mapping lives in the backend. + +### Shell dialect in `@deepseek-ai/dsh-pty-local` + +One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`, existing deployments byte-identical). The effective `shellPath`/`shellArgs` resolve per dialect (bash `/bin/bash --noprofile --norc -i`; pwsh through the shared `dsh-pwsh-local` resolver with `-NoLogo -NoProfile`, keeping the interactive host for child REPLs). The child environment drops the bash-only `PS1`/`PROMPT_COMMAND` markers and adds `NO_COLOR` for pwsh. pwsh cannot install its prompt from the environment, so the backend writes the prompt function through the session at startup and waits until the controlled prompt is actually visible, looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound; a `session_exit` or `timeout` wait rejects the spawn. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so the sanitizer, `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic are reused untouched — the marker stays a readiness signal with an unconsumed payload, exactly as in the bash path, and no model-notification channel was added (aligned with the current implementation; the deferred BEL event channel stays deferred). + +### `@deepseek-ai/dsh-tool-pwsh-persistent` + +A new package mirroring `tool-bash-persistent`: same `Config` (`backendType` default `shell`, `timeoutMs`, `maxOutputChars`, `description`), same owner-scoped shell registry and serialized per-owner queue, same timeout/abort/exit/reset paths. The tool name is `pwsh`; it never co-mounts with the one-shot `tool-pwsh` because the preset rows are mutually exclusive per platform. + +Commands run through a wrapper that resets `$LASTEXITCODE` (assignable, verified), invokes the body via `Invoke-Expression` in a backtick-escaped double-quoted string (`quoteForPwsh`: backtick, quote, `$`, CRLF, and ESC escapes, so no raw control characters ride the input line and the wrapper survives ConstrainedLanguage), and reports the exact native exit code, `1` for a terminating PowerShell error, or `0` for success. PSReadLine echoes the submitted wrapper back into the stream — there is no `stty -echo` equivalent — so the extraction strips the wrapper source from captured output; the echo can never fabricate completion because the status regex needs digits immediately after the END nonce and the echo continues with quote characters. The prompt function installs the tool's own prompt (`__DSH_PERSISTENT_PWSH_PROMPT__ `) over the backend bootstrap value, the same two-layer structure as bash. + +### Composition + +The minimal preset gates its persistent shell stack by platform with the #2234 `disabled: !!js` interpolation: the bash rows (`pty-local` + `tool-bash-persistent`) mount on POSIX, and the pwsh rows (`pty-local` with `shellDialect: pwsh` + `tool-pwsh-persistent`) mount on win32 — exactly one persistent shell per host. `windows-shell.spec` pins the per-platform roster; the real Loader composition exercises the whole stack over a real ConPTY pwsh. + +### Testing + +The subprocess-local and pty-local suites now run on Windows: bash-shaped fixtures self-skip through platform gates, the spawn/terminal suites translate their simple shell commands to node one-liners and exercise injected POSIX group paths, and the koffi-backed inspector joins the windows-only coverage exclusions on Linux while the windows-native lane enforces its 100% coverage. The tool suite mirrors `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. + +## Alternatives considered + +- **A separate `pty-pwsh-local` backend package.** Rejected: the local session, sanitizer, readiness tiers, and sandbox fence are shared machinery; duplicating the 500-line session for argv/env/startup differences trades one config field for a package of copy-paste, unlike the bash group's thin parallel executors. +- **tasklist or wmic polling for the process tree.** Rejected: `inspectForeground` runs on every readiness poll (~50 ms), so a spawned probe per tick is untenable, and wmic is removed from current Windows releases. koffi + Toolhelp32 is in-process and cheap. +- **A native helper or `GenerateConsoleCtrlEvent` for SIGINT.** Rejected: writing `\x03` to ConPTY input interrupts running commands (verified) with zero new code. The semantic difference — at a prompt, `\x03` cancels the pending line instead of signalling a process — is documented rather than engineered around. +- **Base64 body encoding for the wrapper.** Rejected: decoding needs `[Convert]`/`[System.Text.Encoding]` calls whose ConstrainedLanguage status is unproven, while backtick-escaped double-quoted strings use only language-level constructs and were verified end-to-end. +- **Tolerating the echo without stripping the wrapper.** Rejected: in complete and prompt-settled paths the echo is naturally excluded, but timeout and lost-START fallbacks would leak the wrapper source (including marker nonces) into model-visible text. +- **Resurrecting a BEL model-notification channel.** Rejected: the current implementation consumes no marker payload and delivers no BEL events; the design aligns with the current implementation and keeps the deferred item deferred. +- **Windows PowerShell 5.1 as a first-class target.** Rejected: pwsh 7 (including the Store install) is the target; `resolvePwshPath` keeps 5.1 as the last-resort executable fallback without promising full persistent-shell behavior on it. + +## Consequences + +**Windows became a first-class persistent-shell host.** The pty family now runs, tests, and is coverage-gated on the windows-native lane; the one-shot/persistent shell split mirrors POSIX, and the preset spec pins exactly one shell stack per host on both platforms. + +**The windows-native coverage flip is a standing commitment.** subprocess-local and pty-local sources are coverage-required on win32; their suites run there (with platform gates and node-translated commands) and must keep 100% coverage on the windows-native lane. + +**Windows readiness is weaker than Linux.** The pseudo-pgid marker fast path covers shell prompts, but a child without a prompt settles on the silence tier (~3 s), exactly like macOS; there is no exact stdin-wait tier. + +**Windows teardown and signalling differ from POSIX.** taskkill without `/F` does not terminate console processes (the TERM tier is a grace wait before `/F`), SIGINT is console-wide Ctrl-C, SIGTSTP/SIGHUP are unavailable, and externally taskkilled shells may not fire node-pty's exit notification — the handle settles from verified absence instead. + +**Input echo is an accepted platform fact.** PSReadLine echoes submitted input; the marker-anchored extraction and wrapper-source strip remove it in complete results, with bounded residual in partial-output fallbacks. + +**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the prompt function's `[Console]::` call; the `Write-Host -NoNewline` fallback is designed and decided by the Windows-native lane. A model redefinition of the `prompt` function degrades readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi is now a dependency of the process substrate, carrying the same install/prebuild review the sandbox package already has. diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md new file mode 100644 index 0000000000..e753c05082 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md @@ -0,0 +1,65 @@ +# Agent Note: Windows 上基于 PTY seam 的持久化 pwsh + +Status: implemented + +[English](2026-08-11-pwsh-persistent-pty.md) | 中文 + +## 问题 + +harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POSIX-only:`@deepseek-ai/dsh-subprocess-local` 在终端分配时直接抛错(`createProcessInspector()` 拒绝 win32),`@deepseek-ai/dsh-pty-local` 是 bash 形态(`/bin/bash` 默认值、`PS1`/`PROMPT_COMMAND` 环境标记),`@deepseek-ai/dsh-tool-bash-persistent` 用 bash 语法包装命令,pty 测试全部在 win32 上 skip。一次性 `pwsh` 工具(`@deepseek-ai/dsh-tool-pwsh` + `@deepseek-ai/dsh-pwsh-local`)已经能在 Windows 运行,但每次调用都是全新的 `pwsh -Command` 进程:cwd、`$env:` 变量、函数和交互式子进程都随调用结束,其 README 把 "No persistent shell or PTY" 记为 deferred work。 + +这个缺口排除了状态驻留在终端里的 Windows 工作流:单步调试、在 Python 或 Node REPL 中探索、中断前台命令后回到原 shell —— 正是持久 bash pty 在 POSIX 上服务的同一类工作。 + +两个基础已经存在。PTY 服务本身(`ctx.pty` 注册表、owner 作用域、send/read/signal/kill 契约)是平台无关的。Loader 的 `disabled: !!js` 插值(PR #2234)按平台门控 shell 行,并钉死了"每宿主恰好挂载一个 shell 栈"的不变量;持久 pwsh 栈通过同一行机制组合。 + +## 决定 + +模型侧持久 `pwsh` 工具在 Windows 上交付,契约与 `tool-bash-persistent` 逐项对齐:每个 Agent 一个 owner 作用域的持久 shell、标记检测的命令完成、精确的原生退出码、有界输出,以及超时/取消/`exit` 时重置 shell 并告知模型的语义。三块交付:`subprocess-local` 的 Windows 基座、`pty-local` 的 shell 方言选项、新的 `tool-pwsh-persistent` 包加 minimal 预设组合行。 + +### `@deepseek-ai/dsh-subprocess-local` 的 Windows 基座 + +`createProcessInspector()` 在 win32 返回 `WindowsProcessInspector` 而不是抛错。基于 koffi 的检查器通过 Toolhelp32 枚举进程表并取 GetProcessTimes 创建时间身份(与 POSIX start-identity 相同的 PID 复用防护),把 **shell pid 作为伪前台进程组**(Windows 没有 POSIX 进程组;这个稳定值让 prompt-marker 就绪快路径在一个轮询间隔内结算),不报告 stdin-wait 证据(就绪与 macOS 同档),信号走 `taskkill /T` 升级(仅 SIGKILL 加 `/F`)。koffi(`^3.1.0`,`sandbox-windows-acl` 已固定的版本)仅在 win32 惰性加载。 + +`LocalTerminalHandle` 为 win32 分支,因为 node-pty 的 `kill(signal)` 会抛错("Signals not supported on windows"),其无参 kill 委托的 console-list agent 在没有父控制台时失败。拆卸经 taskkill 升级并以 shell 的启动身份作栅栏;由于被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知,句柄从 inspector 验证的消失状态结算 `done`(`settleExitIfGone`)。`signalForeground` 把 SIGINT 映射为 `\x03` Ctrl-C 输入写入(conhost 转为控制台级 CTRL_C 事件的投递方式;实测可中断运行中的命令),SIGTERM/SIGKILL 路由到 taskkill,SIGTSTP/SIGHUP 以 Windows 不可用为由拒绝。公共 `PtySignal` 集合与 seam 类型不变;映射全部留在 backend。 + +### `@deepseek-ai/dsh-pty-local` 的 shell 方言 + +一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`,存量部署逐字节不变)。有效 `shellPath`/`shellArgs` 按方言解析(bash `/bin/bash --noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器取 `-NoLogo -NoProfile`,保留交互宿主供子 REPL)。子环境去掉 bash 专属 `PS1`/`PROMPT_COMMAND` 标记并为 pwsh 加 `NO_COLOR`。pwsh 无法从环境安装提示符,因此 backend 在启动时通过会话写入 prompt 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;`session_exit` 或 `timeout` 结算拒绝 spawn。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 sanitizer、`PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑原样复用——标记仍只是就绪信号、载荷不被消费,与 bash 路径完全一致,且没有新增模型通知通道(与当前实现对齐;延后的 BEL 事件通道保持延后)。 + +### `@deepseek-ai/dsh-tool-pwsh-persistent` + +新包镜像 `tool-bash-persistent`:同样的 `Config`(`backendType` 默认 `shell`、`timeoutMs`、`maxOutputChars`、`description`)、同样的 owner 作用域 shell 注册表与每 owner 串行队列、同样的超时/中止/退出/重置路径。工具名是 `pwsh`;它与一次性 `tool-pwsh` 永不共挂,因为预设行按平台互斥。 + +命令经包装器执行:先重置 `$LASTEXITCODE`(可赋值,已实测),通过 `Invoke-Expression` 在反引号转义的双引号字符串中执行 body(`quoteForPwsh`:反引号、引号、`$`、CRLF 与 ESC 转义,输入行上不携带裸控制字符,包装器可在 ConstrainedLanguage 下存活),报告精确原生退出码、PowerShell 终止性错误的 `1` 或成功的 `0`。PSReadLine 会把提交的包装器回显进流——没有 `stty -echo` 的对应物——因此提取会从捕获输出中剥离包装器原文;回显无法伪造完成,因为状态正则要求 END nonce 后紧跟数字,而回显继续是引号字符。prompt 函数安装工具自有提示符(`__DSH_PERSISTENT_PWSH_PROMPT__ `)覆盖 backend 引导值,与 bash 的双层结构相同。 + +### 组合 + +minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell 栈:bash 行(`pty-local` + `tool-bash-persistent`)在 POSIX 挂载,pwsh 行(`shellDialect: pwsh` 的 `pty-local` + `tool-pwsh-persistent`)在 win32 挂载——每宿主恰好一个持久 shell。`windows-shell.spec` 钉死按平台的花名册;真实 Loader 组合在真实 ConPTY pwsh 上跑通整条栈。 + +### 测试 + +subprocess-local 与 pty-local 套件现在在 Windows 上运行:bash 形态 fixture 通过平台门控自跳过,spawn/terminal 套件把简单 shell 命令翻译为 node 单行并覆盖注入的 POSIX 组路径,koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免,windows-native 车道强制执行其 100% 覆盖。工具套件镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 + +## 备选方案 + +- **独立的 `pty-pwsh-local` backend 包。** 拒绝:本地 session、sanitizer、就绪档位和沙箱栅栏是共享机制;为一个 config 字段复制 500 行 session 换来的是一包复制粘贴,与 bash 组并置薄 executor 的情形不同。 +- **tasklist 或 wmic 轮询进程树。** 拒绝:`inspectForeground` 每次就绪轮询(约 50ms)都跑,每 tick 生成一次探测进程不可行;wmic 已从现行 Windows 移除。koffi + Toolhelp32 是进程内、廉价的。 +- **为 SIGINT 加原生 helper 或 `GenerateConsoleCtrlEvent`。** 拒绝:向 ConPTY 输入写 `\x03` 即可中断运行中的命令(已实测),零新增代码。语义差异——在提示符处 `\x03` 取消当前行而不是给进程发信号——文档化而不是绕开。 +- **包装器 body 用 base64 编码。** 拒绝:解码需要 `[Convert]`/`[System.Text.Encoding]` 调用,其在 ConstrainedLanguage 下的可用性未证实;反引号转义的双引号字符串只用语言级构造,且已端到端实测。 +- **容忍回显而不剥离包装器。** 拒绝:完整路径和提示符就绪路径下回显天然被排除,但超时和 START 丢失的回退会把包装器源码(含 marker nonce)泄漏进模型可见文本。 +- **复活 BEL 模型通知通道。** 拒绝:当前实现不消费任何 marker 载荷、不投递任何 BEL 事件;设计对齐当前实现,deferred 项保持 deferred。 +- **把 Windows PowerShell 5.1 当一等目标。** 拒绝:pwsh 7(含 Store 安装)是目标;`resolvePwshPath` 保留 5.1 作为最后的可执行回退,但不承诺持久 shell 在其上的完整行为。 + +## 后果 + +**Windows 成为一等公民的持久 shell 宿主。** pty 家族现在在 windows-native 车道上运行、测试并受覆盖门禁约束;一次性/持久 shell 的划分与 POSIX 镜像,预设 spec 在两种平台上都钉死每宿主恰好一个 shell 栈。 + +**windows-native 覆盖翻转是常驻承诺。** subprocess-local 与 pty-local 源码在 win32 上受覆盖约束;它们的套件在那里运行(带平台门控与 node 翻译命令),并必须在 windows-native 车道保持 100% 覆盖。 + +**Windows 就绪弱于 Linux。** 伪 pgid marker 快路径覆盖 shell 提示符,但没有提示符的子进程按静默档结算(约 3s),与 macOS 完全一致;没有精确的 stdin-wait 档。 + +**Windows 的拆卸与信号不同于 POSIX。** 不带 `/F` 的 taskkill 无法终止控制台进程(TERM 档是 `/F` 升级前的宽限等待)、SIGINT 是控制台级 Ctrl-C、SIGTSTP/SIGHUP 不可用,且被外部 taskkill 的 shell 可能不触发 node-pty 的退出通知——句柄改从验证的消失状态结算。 + +**输入回显是接受的平台事实。** PSReadLine 回显提交的输入;marker 锚定提取与包装器原文剥离在完整结果中移除它,部分输出回退中残留有界。 + +**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝 prompt 函数的 `[Console]::` 调用;`Write-Host -NoNewline` 回退已设计好,由 Windows-native 车道裁决。模型重定义 `prompt` 函数会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 32fa522443..eab1b8987e 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: fb9cd06bade7bc357baa738f0d9dd03b7f5b7936 -2026-07-16-persistent-pty-sessions.zh.md: 55a5848c1ab1e8c2cd3b29f2d4748ea4abbe088c +2026-07-16-persistent-pty-sessions.md: fa9d90e3ded97b279c5cad7c8b5733333403beaf +2026-07-16-persistent-pty-sessions.zh.md: f801f4d0728452b2a5acf75c5bcbef0d9e1e6901 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index fb9cd06bad..fa9d90e3de 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -134,7 +134,7 @@ The package ships concise tool guidance explaining persistent state, owner isola - Declarative per-agent startup requires an agent-setup composition point; plugin-load global sessions remain prohibited. - Session restoration across harness-process loss requires an out-of-process owner and a versioned protocol. - Network-egress policy and rollback of external side effects are broader than PTY and remain separate security work. -- Windows/ConPTY support requires a backend with Windows-native process ownership and signaling semantics. +- Windows/ConPTY sessions run through the subprocess-local Windows inspector (Toolhelp32 identities, pseudo foreground groups, taskkill teardown) and the `pty-local` pwsh dialect; see the [pwsh persistent tool note](../../architecture/2026-08-11-pwsh-persistent-pty.md). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 55a5848c1a..f801f4d072 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -134,7 +134,7 @@ plugins: - 声明式 per-agent 启动需要 agent-setup 组合点;仍然禁止插件加载期全局会话。 - harness 进程丢失后的会话恢复需要进程外 owner 和版本化协议。 - 网络出口策略与外部副作用回滚超出 PTY 范围,继续作为独立安全工作。 -- Windows/ConPTY 支持需要具备 Windows 原生进程所有权与信号语义的后端。 +- Windows/ConPTY 会话经由 subprocess-local 的 Windows inspector(Toolhelp32 身份、伪前台进程组、taskkill 拆卸)与 `pty-local` 的 pwsh 方言运行;见 [pwsh 持久工具 note](../../architecture/2026-08-11-pwsh-persistent-pty.md)。 ## 备选方案 diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index 04e314ac14..edac189179 100644 --- a/packages/bash/tool-pwsh/README.i18n.yaml +++ b/packages/bash/tool-pwsh/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md -README.md: 4126e718c569f17fb8be465351b2576970e93c63 -README.zh.md: aba669733a7ecb9924287abb298bb9a154b5afa7 +README.md: b843ed11823aadec7c28cc26e45da41701e40d3d +README.zh.md: 04f471fd15d6bf4343f674a29d03bb48c4870fdb diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index 4126e718c5..b843ed1182 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -121,6 +121,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **Language mode and named-pipe capture under the Windows sandbox** — under the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md), read-only pwsh starts in ConstrainedLanguage because its temp write denial makes PowerShell's AppLocker probe fail closed: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. Workspace-write's private temp lets the probe complete, so it stays in FullLanguage unless host policy says otherwise. Both confined modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations. -- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work. +- **No persistent shell** — every call starts a fresh `pwsh -Command`; the persistent-shell counterpart is [`@deepseek-ai/dsh-tool-pwsh-persistent`](../../pty/tool-pwsh-persistent/README.md), which keeps one owner-scoped pwsh alive across calls on Windows (ConPTY) and POSIX hosts with pwsh. - **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. - **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md index aba669733a..04f471fd15 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -121,6 +121,6 @@ ack 是固定短行;任务输出按读取有界。 ## Known Limitations and Deferred Work - **Windows 沙箱下的语言模式与 named-pipe 捕获** — 在 [Windows ACL 沙箱](../../sandbox/sandbox-windows-acl/README.md) 下,read-only pwsh 会以 ConstrainedLanguage 启动,因为临时目录写入被拒绝,导致 PowerShell 的 AppLocker 探针失败并按 fail-closed 处理:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。workspace-write 的私有临时目录使探针得以完成,因此除非主机策略另有规定,否则它保持 FullLanguage。两种受限模式都拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。 -- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。 +- **无持久 shell** — 每次调用都启动全新的 `pwsh -Command`;持久 shell 对应物是 [`@deepseek-ai/dsh-tool-pwsh-persistent`](../../pty/tool-pwsh-persistent/README.md),它在 Windows(ConPTY)以及装有 pwsh 的 POSIX 主机上跨调用保持一个 owner 作用域的 pwsh 存活。 - **PowerShell 方言约定** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 - **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。 diff --git a/packages/pty/pty-local/README.i18n.yaml b/packages/pty/pty-local/README.i18n.yaml index 238e2aa749..f90a07050d 100644 --- a/packages/pty/pty-local/README.i18n.yaml +++ b/packages/pty/pty-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/pty/pty-local/README.md -README.md: 5acc92853e6e8fcb8938c48e391559bf4a28fb75 -README.zh.md: 7d070a9a4aceab921264f020716fbdf16fdd57ff +README.md: 4565f9a6efd122e6302368c2d616f09649f0cae0 +README.zh.md: 44c1440c20a428048e1509b22141066d16758d31 diff --git a/packages/pty/pty-local/README.md b/packages/pty/pty-local/README.md index 5acc92853e..4565f9a6ef 100644 --- a/packages/pty/pty-local/README.md +++ b/packages/pty/pty-local/README.md @@ -8,6 +8,8 @@ Persistent shell backend for `ctx.pty` over `ctx.subprocess.spawnTerminal`. It s The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. +`shellDialect` selects the shell stack (`bash` default, `pwsh`): it picks the default `shellPath`/`shellArgs` (bash `--noprofile --norc -i`; pwsh `-NoLogo -NoProfile` through the shared `dsh-pwsh-local` resolver) and the startup contract. The bash dialect installs its prompt through the environment (`PS1` plus an OSC `133;D;`-terminated `PROMPT_COMMAND`). pwsh cannot install a prompt from the environment, so the backend writes a `prompt` function through the session and waits until the controlled prompt is actually visible — looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound — while its environment drops the bash-only markers and adds `NO_COLOR`. Both dialects emit the same BEL-terminated OSC marker, so the readiness machinery and consumers are dialect-agnostic. + Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `PtyBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline. Send cancellation marks queued input as canceled before asking the terminal handle to signal the current foreground process group with a real `SIGINT`; if asynchronous pre-write inspection later settles, it cannot execute that input. If a provider write is already in flight, signalling waits for it to settle; a rejected write sends no signal. The canceled send retains its slot until the write and foreground signalling settle, so a successor cannot receive either late bytes or that signal. A provider write or signal that never settles therefore retains the slot indefinitely; closing the session (`terminal_close`) is the recovery. The absolute deadline remains armed while cancellation waits. A signal failure is a terminal transport failure and rejects the active send. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close rejects new public signals, stops readiness polling, and awaits the handle's provider-owned complete-session termination before settling the active send as `session_exit`. @@ -31,6 +33,7 @@ A standing-policy change appends an owner-rendered superseding runtime-context s ## Known Limitations and Deferred Work - Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported. -- Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. +- Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. Windows is such a provider: the shell pid is the pseudo foreground group and there is no exact stdin-wait tier, so a marker-less child settles on the silence bound. +- The pwsh `prompt` bootstrap writes through `[Console]::`, which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny; the `Write-Host -NoNewline` fallback is the designed alternative, decided by the Windows-native lane. - Cleanup guarantees are those of `SubprocessTerminalHandle`; provider-specific gaps belong to that implementation's contract rather than this PTY consumer. - Sessions do not survive harness process exit. diff --git a/packages/pty/pty-local/README.zh.md b/packages/pty/pty-local/README.zh.md index 7d070a9a4a..44c1440c20 100644 --- a/packages/pty/pty-local/README.zh.md +++ b/packages/pty/pty-local/README.zh.md @@ -8,6 +8,8 @@ 该插件注入 `pty`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell;受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv,未挂载时会在 spawn 前失败。spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。 +`shellDialect` 选择 shell 栈(默认 `bash`,或 `pwsh`):它决定默认的 `shellPath`/`shellArgs`(bash 为 `--noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器得到 `-NoLogo -NoProfile`)与启动契约。bash 方言通过环境安装提示符(`PS1` 加 OSC `133;D;` 终结的 `PROMPT_COMMAND`)。pwsh 无法从环境安装提示符,因此后端通过会话写入 `prompt` 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;同时其环境去掉 bash 专属标记并加 `NO_COLOR`。两种方言发出相同的 BEL 终结 OSC 标记,因此就绪机制与消费方与方言无关。 + 就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`PtyBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 取消发送时,系统会先把排队输入标记为已取消,再要求终端句柄向当前前台进程组发送真正的 `SIGINT`;异步写入前检查即使随后结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待其结算;写入被拒绝时不会发送信号。已取消的 send 会保留其位置,直到写入与前台信号发送都结算,因此后继 send 不会收到延迟字节或该信号。因此,永不结算的提供方写入或信号会无限期保留该位置;恢复手段是关闭会话(`terminal_close`)。取消等待期间,绝对 deadline 仍保持启用。信号发送失败是终端传输失败,会拒绝活跃 send。取消绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待由句柄提供方负责的完整会话终止,然后才把活跃 send 结算为 `session_exit`。 @@ -31,6 +33,7 @@ ## 已知限制与暂缓事项 - 输出按行规范化;不支持全屏备用缓冲区交互。 -- 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。 +- 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。Windows 正是这样的提供方:shell pid 是伪前台进程组,没有精确的 stdin-wait 档,因此无标记的子进程按静默上限结算。 +- pwsh `prompt` 引导通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它;`Write-Host -NoNewline` 回退是设计好的备选,由 Windows-native 车道裁决。 - 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的约定,而非这个 PTY 消费方。 - harness 进程退出后,会话无法继续存在。 diff --git a/packages/pty/tool-pwsh-persistent/README.i18n.yaml b/packages/pty/tool-pwsh-persistent/README.i18n.yaml new file mode 100644 index 0000000000..3335ee04fc --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/pty/tool-pwsh-persistent/README.md +README.md: 32fcaf15d38648c347e6423c9b68753ccca91287 +README.zh.md: 8c2201657796d2d6bd39ea5d130ee246a58e17d9 diff --git a/packages/pty/tool-pwsh-persistent/README.md b/packages/pty/tool-pwsh-persistent/README.md new file mode 100644 index 0000000000..32fcaf15d3 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/README.md @@ -0,0 +1,55 @@ +# @deepseek-ai/dsh-tool-pwsh-persistent + +English | [中文](README.zh.md) + +Model-facing `pwsh(command)` backed by one owner-scoped `ctx.pty` shell. The package owns the tool contract and shell reuse; deployments select the PTY backend (a `pty-local` instance configured with `shellDialect: pwsh`) and sandbox policy. It is the Windows counterpart of `tool-bash-persistent`: same persistent-state contract, PowerShell dialect. + +## Config + +| Key | Default | Meaning | +|---|---:|---| +| `backendType` | `shell` | Registered PTY backend used for each Agent shell. | +| `timeoutMs` | `300000` | Wall-clock limit for one command; timeout closes the shell. | +| `maxOutputChars` | `16000` | Maximum retained command-output characters; fixed diagnostics are added afterward. | +| `description` | Persistent-shell description | Model-facing environment contract. | + +## Model Experience + +### Tool schema + +#### What the model sees + +The generated [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh-persistent), including the configured `description`. The plugin contributes no standalone system-prompt section; the deployment owns persona and environment guidance. + +#### Token effect + +Fixed schema cost while `pwsh` is visible. + +#### KV Cache effect + +Prefix-stable while the configured description and schema remain unchanged. + +### Tool results + +#### What the model sees + +Commands share one shell per Agent, so cwd, `$env:` variables, functions, and background jobs persist across calls. Results exclude private completion markers, the shell prompt, and the echoed input line (PSReadLine renders submitted input back into the stream; the marker-anchored extraction and the wrapper-source strip remove it). A nonzero wrapped command appends `[exit code: N]` — the exact native exit code when the command ran a native program, `1` for a terminating PowerShell error. A shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither (Windows forced termination reports exit 1 without a signal), then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice; if the PTY has already dropped that prefix, the result says so explicitly. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset. + +#### Token effect + +Data-dependent. `maxOutputChars` bounds retained command output; fixed clipping, lost-prefix, status, timeout, and reset diagnostics can extend the result. + +#### KV Cache effect + +Append-only tool results follow the reusable request prefix. + +## Known Limitations and Deferred Work + +- The tool requires an owning Agent and a real PTY backend with a pwsh dialect (Windows ConPTY or a POSIX pwsh). +- **Input echo is unavoidable**: PowerShell's PSReadLine renders submitted input back into the terminal stream, and there is no `stty -echo` equivalent. The marker-anchored extraction excludes the echo in complete results; the wrapper-source strip covers fallback paths, but a wrapper that wraps across the terminal width may leave a partial echo in partial-output results, bounded by `maxOutputChars`. +- Raw ESC characters inside model commands are unsupported: PSReadLine consumes them before execution. The wrapper escapes the control bytes it needs (`[char]27`-built OSC markers, backtick escapes for the body). +- A model redefinition of the `prompt` function removes the readiness marker; the shell then settles on the silence tier instead of the marker fast path. +- There is no interactive stdin during a command: a foreground command that reads input blocks until the readiness timeout, which resets the shell. +- SIGTSTP/SIGHUP are unavailable on Windows (backend-rejected); SIGINT is delivered as a console-wide Ctrl-C input write, which at a prompt cancels the pending line instead of signalling a process. +- Under the Windows ACL sandbox's read-only mode, pwsh starts in ConstrainedLanguage, which may deny the prompt function's `[Console]::` call; the backend's documented `Write-Host -NoNewline` fallback is selected by the Windows-native lane evidence. +- The BEL-terminated OSC marker remains a readiness signal only; a BEL event channel to the model stays deferred, aligned with the current implementation. diff --git a/packages/pty/tool-pwsh-persistent/README.zh.md b/packages/pty/tool-pwsh-persistent/README.zh.md new file mode 100644 index 0000000000..8c22016577 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/README.zh.md @@ -0,0 +1,55 @@ +# @deepseek-ai/dsh-tool-pwsh-persistent + +[English](README.md) | 中文 + +模型侧 `pwsh(command)`,由一个 owner 作用域的 `ctx.pty` shell 支撑。本包拥有工具契约与 shell 复用;部署方选择 PTY backend(配置 `shellDialect: pwsh` 的 `pty-local` 实例)与沙箱策略。它是 `tool-bash-persistent` 的 Windows 对应物:相同的持久状态契约,PowerShell 方言。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---:|---| +| `backendType` | `shell` | 每个 Agent shell 使用的已注册 PTY backend。 | +| `timeoutMs` | `300000` | 单条命令的墙钟上限;超时关闭 shell。 | +| `maxOutputChars` | `16000` | 保留的命令输出字符上限;固定诊断文本在其后追加。 | +| `description` | 持久 shell 描述 | 模型可见的环境契约。 | + +## 模型体验 + +### 工具 schema + +#### 模型看到什么 + +生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh-persistent),含配置的 `description`。本插件不贡献独立的 system-prompt 段落;persona 与环境指引由部署方负责。 + +#### Token 影响 + +`pwsh` 可见期间每个请求有固定的 schema 成本。 + +#### KV Cache 影响 + +配置的 description 与 schema 不变时前缀稳定。 + +### 工具结果 + +#### 模型看到什么 + +命令共享每个 Agent 的一个 shell,因此 cwd、`$env:` 变量、函数和后台任务跨调用保留。结果排除私有完成标记、shell 提示符与回显的输入行(PSReadLine 会把提交的输入渲染回输出流;marker 锚定提取与包装器原文剥离将其移除)。非零包装命令追加 `[exit code: N]` —— 命令运行原生程序时是精确的原生退出码,PowerShell 终止性错误为 `1`。shell 在报告状态前退出的,改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]` 或 `[shell exited]`(backend 两者都没有时;Windows 强杀按无 signal 的 exit 1 报告),然后重置并告知模型下一次调用从全新 shell 开始。长输出保留最早的前缀并附裁剪提示;若 PTY 已丢弃该前缀,结果会明确说明。超时返回有界的部分输出、关闭不确定的 shell 并报告重置。 + +#### Token 影响 + +数据相关。`maxOutputChars` 限制保留的命令输出;固定裁剪、前缀丢失、状态、超时与重置诊断可能扩展结果。 + +#### KV Cache 影响 + +追加式工具结果跟随可复用的请求前缀。 + +## 已知限制与延后工作 + +- 工具需要拥有 Agent 与一个真实支持 pwsh 方言的 PTY backend(Windows ConPTY 或 POSIX 上的 pwsh)。 +- **输入回显不可避免**:PowerShell 的 PSReadLine 会把提交的输入渲染回终端流,且没有 `stty -echo` 的对应物。完整结果中 marker 锚定提取排除回显;包装器原文剥离覆盖回退路径,但跨越终端宽度的包装器折行可能在部分输出结果中残留片段回显,受 `maxOutputChars` 约束。 +- 模型命令中的裸 ESC 字符不受支持:PSReadLine 会在执行前吞掉它们。包装器转义它需要的控制字节(`[char]27` 构造的 OSC 标记、body 的反引号转义)。 +- 模型重定义 `prompt` 函数会移除就绪标记;shell 随后退化为静默档而非 marker 快路径。 +- 命令执行期间没有交互 stdin:读取输入的前台命令会阻塞到就绪超时,随后重置 shell。 +- SIGTSTP/SIGHUP 在 Windows 不可用(backend 拒绝);SIGINT 以控制台级 Ctrl-C 输入写入投递,在提示符处取消当前行而非向进程发信号。 +- 在 Windows ACL 沙箱的只读模式下,pwsh 以 ConstrainedLanguage 启动,可能拒绝 prompt 函数的 `[Console]::` 调用;backend 文档化的 `Write-Host -NoNewline` 回退由 Windows-native 车道证据裁决。 +- BEL 终结的 OSC 标记仍只是就绪信号;面向模型的 BEL 事件通道保持延后,与当前实现对齐。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 68b8ff7d50..8b6e59abe5 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md -README.md: 2817e02861db6caad89cad258d14a90c34afcbaf -README.zh.md: 251b994a35e8bd7c84827957a548a1f352583ac6 +README.md: 0685cb32e4c80589de7a1f284712655a5de05b37 +README.zh.md: 1442d7c62e67c5d006f4b0a43d9ea090dbf2f284 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 2817e02861..0685cb32e4 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -11,7 +11,7 @@ Local Service provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. -- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. +- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32 with GetProcessTimes start identities, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's absence through those identities because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. - **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement. ## Model Experience @@ -25,7 +25,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **Windows tree support is best-effort** — termination routes through `taskkill /PID /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary. -- **Terminal process inspection is Linux/macOS only** — the terminal primitive fails when its inspector has no supported platform implementation; Linux exact probes cover x64 and arm64, while macOS uses `ps` snapshots. +- **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest. - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor. - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed vars is noted future work. - **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 251b994a35..1442d7c62e 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -11,7 +11,7 @@ - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该能力入口被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 -- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 +- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表并取 GetProcessTimes 启动身份,把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组),拆卸则通过这些身份验证 shell 已消失——因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 ## 模型体验 @@ -25,7 +25,7 @@ ## 已知限制与暂缓事项 - **Windows 进程树支持仅为尽力而为**:终止经由 `taskkill /PID /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。 -- **终端进程检查仅支持 Linux/macOS**:检查器没有受支持的平台实现时,终端原语会失败;Linux 精确探针覆盖 x64 与 arm64,macOS 则使用 `ps` 快照。 +- **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。 - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。 - **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 - **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 From 13152903c784d9ee40b29b7108d2bdac3ea7aa16 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 00:42:40 +0800 Subject: [PATCH 05/60] fix(subprocess): satisfy the oxlint gates in the Windows inspector Routes koffi allocations through a branded NativePtr helper (koffi's TS types are any), binds the creationTime callback instead of passing the unbound method, and braces the no-op signal assertions. --- .../subprocess-local/src/windows-inspector.ts | 24 ++++++++++++++----- .../tests/windows-inspector.spec.ts | 8 +++---- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts index 78bea583d8..da5158b4c0 100644 --- a/packages/subprocess/subprocess-local/src/windows-inspector.ts +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -86,7 +86,7 @@ export class WindowsProcessInspector implements ProcessInspector { } processTree(rootPid: number): ProcessIdentity[] { - return windowsProcessTree(this.internals.snapshot(), rootPid, this.internals.creationTime) + return windowsProcessTree(this.internals.snapshot(), rootPid, pid => this.internals.creationTime(pid)) } processSession(_sessionId: number): ProcessIdentity[] { @@ -231,6 +231,18 @@ function win32Bindings(): Win32Bindings { return cachedBindings } +/** + * Allocate koffi memory as a branded {@link NativePtr}; koffi's TS types are + * `any`, so the cast goes through `unknown` to keep the unsafe surface here. + * @param type - the koffi type to allocate. + * @param count - element count. + * @returns the branded allocation pointer. + */ +function allocNative(type: Parameters[0], count: number): NativePtr { + const value: unknown = koffi.alloc(type, count) + return value as NativePtr +} + /** Enumerate the current process table through Toolhelp32. */ function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] { const { PROCESSENTRY32W } = win32Structs() @@ -240,7 +252,7 @@ function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] { if (isInvalidHandle(snapshot)) return [] const entries: ProcessEntry[] = [] try { - const entry = koffi.alloc(PROCESSENTRY32W, 1) + const entry = allocNative(PROCESSENTRY32W, 1) koffi.encode(entry, 'uint32', PROCESSENTRY32W.size) let ok = bindings.process32FirstW(snapshot, entry) while (ok !== 0) { @@ -263,10 +275,10 @@ function windowsCreationTime(bindings: Win32Bindings, pid: number): string | und const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) if (isInvalidHandle(handle)) return undefined try { - const creation = koffi.alloc(FILETIME, 1) - const exit = koffi.alloc(FILETIME, 1) - const kernel = koffi.alloc(FILETIME, 1) - const user = koffi.alloc(FILETIME, 1) + const creation = allocNative(FILETIME, 1) + const exit = allocNative(FILETIME, 1) + const kernel = allocNative(FILETIME, 1) + const user = allocNative(FILETIME, 1) /* v8 ignore next -- a GetProcessTimes failure after a successful open races process exit and cannot be staged deterministically; the absent-process path is covered and the caller treats undefined as a detector miss. */ diff --git a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts index b18945fc10..667c6cae46 100644 --- a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts @@ -134,9 +134,9 @@ win32('WindowsProcessInspector over the real koffi bindings', () => { it('reports unreadable identities for absent processes and no-ops tree signalling', () => { const inspector = createWindowsProcessInspector() expect(inspector.isAlive({ pid: 0x7FFFFFFF, started: 'absent' })).toBe(false) - expect(() => inspector.signalGroup(0x7FFFFFFF, 'SIGKILL')).not.toThrow() - expect(() => inspector.signalGroup(0x7FFFFFFF, 'SIGTERM')).not.toThrow() - expect(() => inspector.signalGroup(0, 'SIGKILL')).not.toThrow() - expect(() => inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL')).not.toThrow() + expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGKILL') }).not.toThrow() + expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGTERM') }).not.toThrow() + expect(() => { inspector.signalGroup(0, 'SIGKILL') }).not.toThrow() + expect(() => { inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL') }).not.toThrow() }) }) From 67e6d7082ecb1ee070f99c0477d4766a99aad08c Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 01:11:46 +0800 Subject: [PATCH 06/60] fix: Windows-native CI findings on latest master Local run of check:ci:windows-complete (the windows-native gate) on latest master surfaced five Windows-only failures, all unreachable by current CI because the native windows job is disabled and the wine gate only covers build+site. - install-lefthook/translation-pairing-merge specs junctioned the real scripts/ and tsx package into fixtures; Windows recursive deletion (Node rmSync and git worktree remove) follows MOUNT_POINT junctions and deleted the repository's own directories mid-run. Fixtures now unlink their reparse points before any recursive removal (shared helper in scripts/test-fixture-cleanup.ts). - workflow-workerthread spawned its worker with an empty env; on Windows os.tmpdir() then degrades to the literal relative path undefined\temp, so tsx wrote its transform cache into a cwd-relative undefined/ directory inside the repo. The worker env now injects the host temp path on win32 (workerSpawnEnv, platform-parameterized and unit-tested on both arms). - workspace-context spec did not stub USERPROFILE (win32 homedir) or a set DSH_HOME, leaking the developer machine's real ~/.dsh/AGENTS.md into discovery. - ui-trajectory client-bundle spec mounted the built artifact without the remote/settingsScope provides the locale plugin needs, so the plugin never activated and no view registered. - subagent temp-fixture cleanup lacked the maxRetries Windows handle release needs under load (EPERM); added retries to the three affected specs and the fixture-cleanup helper. --- .../ui-trajectory/tests/client-bundle.spec.ts | 5 +- .../tests/workspace-context.spec.ts | 9 +++- .../subagent/tests/continuation.spec.ts | 2 +- .../subagent/tests/list-children.spec.ts | 2 +- .../tests/tool-subagent-control.spec.ts | 2 +- .../workflow-workerthread/src/host.ts | 33 +++++++++++-- .../tests/workflow-workerthread.spec.ts | 42 ++++++++++++++--- scripts/install-lefthook.spec.ts | 7 ++- scripts/test-fixture-cleanup.ts | 46 +++++++++++++++++++ scripts/translation-pairing-merge.spec.ts | 12 ++++- 10 files changed, 141 insertions(+), 19 deletions(-) create mode 100644 scripts/test-fixture-cleanup.ts diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index edb2e3072b..eebef86099 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -9,6 +9,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, describe, expect, it } from 'vitest' import { ConversationEventRegistry, ConversationViewRegistry, SlotsService, @@ -82,9 +83,11 @@ describe('tsdown client artifact', () => { // Paging is session-owned; this registration-only probe never renders the // entry, so the binding stays deliberately empty. The locale plugin backs // the locale-aware view tab label (its settings scope needs a connection - // handle). + // handle and the forwarded-event port). ctx.provide('sessions', { binding: () => undefined }) ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = await import('@deepseek-ai/dsh-client-locale/client') ctx.plugin({ inject: [...locale.inject], apply: locale.apply }) const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void }) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index e95b30fa5b..11b820636e 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -578,10 +578,12 @@ describe('workspace context instruction discovery', () => { const root = await tempRepo() const emptyHome = await tempRepo() // Isolate the default-home fallback: blank DSH_HOME is treated as unset, and - // HOME points at an empty dir so the default ~/.dsh holds no global scope. - // Symlinks are followed, so a real ~/.dsh/AGENTS.md would otherwise leak in. + // the home dirs point at an empty dir so the default ~/.dsh holds no global + // scope. Windows homedir() reads USERPROFILE (not HOME), so both must be + // stubbed or a real ~/.dsh/AGENTS.md would otherwise leak in. vi.stubEnv('DSH_HOME', '') vi.stubEnv('HOME', emptyHome) + if (process.platform === 'win32') vi.stubEnv('USERPROFILE', emptyHome) try { const cwd = join(root, 'child') await mkdir(cwd, { recursive: true }) @@ -622,6 +624,8 @@ describe('workspace context instruction discovery', () => { try { await write(join(home, '.dsh/AGENTS.md'), 'global default rule') + // A set DSH_HOME would override the homedir default and relabel the home. + vi.stubEnv('DSH_HOME', '') vi.resetModules() vi.doMock('node:os', () => ({ homedir: () => home })) const isolated = await import('@deepseek-ai/dsh-workspace-context') @@ -629,6 +633,7 @@ describe('workspace context instruction discovery', () => { expect(files.map(file => file.displayPath)).toEqual(['~/.dsh/AGENTS.md']) } finally { + vi.unstubAllEnvs() vi.doUnmock('node:os') vi.resetModules() await rm(root, { recursive: true, force: true }) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 33f046779f..ca1652f78a 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -53,7 +53,7 @@ class GatedAdapter extends LlmAdapter { const roots: string[] = [] afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) }) /** Boot the full continuable stack: loop, persistence, providers, and subagents. */ diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 7a7afb5e22..4fcd9858f1 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -28,7 +28,7 @@ type Script = ConstructorParameters[0] const roots: string[] = [] afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) }) /** Boot the continuable stack with real JSONL session persistence. */ diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index 4960992068..6e8aa2dfef 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -47,7 +47,7 @@ const testToolSignal = new AbortController().signal const roots: string[] = [] afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) }) async function setupWith(adapter: MockAdapter | GatedAdapter) { diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 501a3555c3..b036d692e6 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -6,6 +6,7 @@ * @module @deepseek-ai/dsh-workflow-workerthread/host */ +import { tmpdir } from 'node:os' import { Worker } from 'node:worker_threads' import type { WorkerOptions } from 'node:worker_threads' import { fileURLToPath } from 'node:url' @@ -28,18 +29,42 @@ interface ChildRecord { disposal?: Promise } +/** + * The scrubbed worker environment: no ambient credentials, no loader flags. + * Windows derives `os.tmpdir()` from `TMP`/`TEMP` and falls back to the + * literal relative path `undefined\temp` when the environment is empty, so + * tsx's transform cache would land in a cwd-relative `undefined/temp` + * directory; the host's real temp path (not a credential) is injected there. + * The unbuilt shape additionally forwards `TSX_TSCONFIG_PATH` for path + * resolution. + * @param platform - host platform; overridable so tests exercise both peer arms. + * @returns the scrubbed worker environment object. + */ +export function workerSpawnEnv(platform: NodeJS.Platform = process.platform): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {} + if (platform === 'win32') { + const tmp = tmpdir() + env.TMP = tmp + env.TEMP = tmp + } + if (process.env.TSX_TSCONFIG_PATH !== undefined) { + env.TSX_TSCONFIG_PATH = process.env.TSX_TSCONFIG_PATH + } + return env +} + /** * Resolve a built worker bundle or an unbuilt bootstrap that installs both tsx * transforms inside the worker. Both shapes clear `execArgv` and the ambient - * environment; the unbuilt shape forwards only `TSX_TSCONFIG_PATH` for path - * resolution. + * environment (the worker only sees the platform temp path and, unbuilt, + * `TSX_TSCONFIG_PATH`). * @param init - the run payload, passed as `workerData`. * @returns the entry path or URL and the Worker options to spawn it with. */ function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: WorkerOptions } { /* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/); the built-worker e2e exercises this shape for real */ if (!import.meta.url.endsWith('.ts')) { - return { entry: fileURLToPath(new URL('./worker.cjs', import.meta.url)), options: { workerData: init, env: {}, execArgv: [] } } + return { entry: fileURLToPath(new URL('./worker.cjs', import.meta.url)), options: { workerData: init, env: workerSpawnEnv(), execArgv: [] } } } // Resolve tsx only for unbuilt consumers and install it before importing TS. const workerEntry = new URL('./worker.ts', import.meta.url) @@ -56,7 +81,7 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: W entry: new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), options: { workerData: init, - env: process.env.TSX_TSCONFIG_PATH === undefined ? {} : { TSX_TSCONFIG_PATH: process.env.TSX_TSCONFIG_PATH }, + env: workerSpawnEnv(), execArgv: [], }, } diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 7646a002bc..e56254c92e 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import type { Worker } from 'node:worker_threads' import { Context } from '@deepseek-ai/cordis' @@ -9,6 +10,7 @@ import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRu import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRun, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import * as workerEngineModule from '../src/index.ts' import WorkerWorkflowEngine, { type Config } from '../src/index.ts' +import { workerSpawnEnv } from '../src/host.ts' import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts' import { SessionId } from '@deepseek-ai/dsh-session' @@ -559,24 +561,49 @@ describe('dsh-workflow-workerthread', () => { expect(result.value).toBe('fine') }) - it('the worker spawns with an EMPTY environment: an escaped script finds no ambient credentials', async () => { + it('the worker spawns with a scrubbed environment: an escaped script finds no ambient credentials', async () => { const { ctx, parent } = await setup() // A canary in the HARNESS process's env: with an inherited environment // the escape below would read it back (exactly how DEEPSEEK_API_KEY - // would leak); env: {} in the spawn options is what keeps it out. + // would leak); the worker env keeps every ambient variable out. Windows + // additionally receives the host temp path (TMP/TEMP) so `os.tmpdir()` + // inside the worker resolves instead of degrading to a cwd-relative + // `undefined\temp` (tsx writes its transform cache there). process.env.WORKFLOW_ENV_CANARY = 'leak me' try { const result = await run(ctx, parent, scripted(` const proc = ${ESCAPE} - return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).length } + return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).sort() } `)) expect(result.stopReason).toBe('completed') - expect(result.value).toEqual({ canary: null, keys: 0 }) + const expectedKeys = process.platform === 'win32' ? ['TEMP', 'TMP'] : [] + expect(result.value).toEqual({ canary: null, keys: expectedKeys }) } finally { delete process.env.WORKFLOW_ENV_CANARY } }) + it('workerSpawnEnv injects the host temp path on win32 and leaves the POSIX peer empty', () => { + const tmp = tmpdir() + expect(workerSpawnEnv('win32')).toEqual({ TMP: tmp, TEMP: tmp }) + expect(workerSpawnEnv('linux')).toEqual({}) + }) + + it('workerSpawnEnv forwards TSX_TSCONFIG_PATH when the snapshot harness pins it', () => { + const tsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + vi.stubEnv('TSX_TSCONFIG_PATH', tsconfig) + try { + expect(workerSpawnEnv('linux')).toEqual({ TSX_TSCONFIG_PATH: tsconfig }) + expect(workerSpawnEnv('win32')).toEqual({ + TMP: tmpdir(), + TEMP: tmpdir(), + TSX_TSCONFIG_PATH: tsconfig, + }) + } finally { + vi.unstubAllEnvs() + } + }) + it('the unbuilt worker forwards exactly TSX_TSCONFIG_PATH through the scrub: the paths-map pin survives, secrets do not', async () => { const { ctx, parent } = await setup() // The ACP snapshot harness runs the parent with its cwd OUTSIDE the @@ -589,10 +616,13 @@ describe('dsh-workflow-workerthread', () => { try { const result = await run(ctx, parent, scripted(` const proc = ${ESCAPE} - return { keys: Object.keys(proc.env), tsconfig: proc.env.TSX_TSCONFIG_PATH } + return { keys: Object.keys(proc.env).sort(), tsconfig: proc.env.TSX_TSCONFIG_PATH } `)) expect(result.stopReason).toBe('completed') - expect(result.value).toEqual({ keys: ['TSX_TSCONFIG_PATH'], tsconfig }) + const expectedKeys = process.platform === 'win32' + ? ['TEMP', 'TMP', 'TSX_TSCONFIG_PATH'] + : ['TSX_TSCONFIG_PATH'] + expect(result.value).toEqual({ keys: expectedKeys, tsconfig }) } finally { delete process.env.TSX_TSCONFIG_PATH delete process.env.WORKFLOW_ENV_CANARY diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 2c429bba25..c5a92a675e 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -16,6 +16,7 @@ import { tmpdir } from 'node:os' import { dirname, isAbsolute, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' +import { removeFixtureSafely, unlinkFixtureLinks } from './test-fixture-cleanup.ts' const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url)) const pairingMergeDriver = 'scripts/merge-translation-pairing-driver.sh %O %A %B %P' @@ -40,7 +41,7 @@ interface CommandResult { } afterEach(() => { - for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true }) + for (const fixture of fixtures.splice(0)) removeFixtureSafely(fixture) }) function commandResult(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): CommandResult { @@ -282,6 +283,10 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => { expect(gitResult(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.bare']).status).toBe(1) const mainHookBeforeRemoval = readFileSync(join(mainHooks, 'pre-commit'), 'utf8') + // Windows Git follows the fixture's MOUNT_POINT junctions into their real + // targets while removing a worktree; unlink them first so the removal + // cannot delete the repository's scripts/ or tsx package. + unlinkFixtureLinks(fixture.linked) git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked]) expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval) expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n') diff --git a/scripts/test-fixture-cleanup.ts b/scripts/test-fixture-cleanup.ts new file mode 100644 index 0000000000..9c58eea890 --- /dev/null +++ b/scripts/test-fixture-cleanup.ts @@ -0,0 +1,46 @@ +/** + * Junction-safe fixture cleanup for Windows. Test fixtures junction the REAL + * `scripts/`, `node_modules`, and tsx package directories so installer probes + * resolve through them; Windows recursive deletion — both Node's `rmSync` and + * Git's `worktree remove` — follows MOUNT_POINT junctions into their targets + * and would delete the repository's own directories. POSIX `unlink`/`rm` + * already remove symlinks without following them, so the walk is a no-op + * there. + */ + +import { lstatSync, readdirSync, rmSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Recursively unlink every symbolic link (junction) under `path`. + * @param path - the fixture tree whose reparse points are unlinked. + */ +export function unlinkFixtureLinks(path: string): void { + const visit = (entry: string): void => { + let stat: ReturnType + try { + stat = lstatSync(entry) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + if (stat.isSymbolicLink() || !stat.isDirectory()) { + if (stat.isSymbolicLink()) unlinkSync(entry) + return + } + for (const child of readdirSync(entry)) visit(join(entry, child)) + } + visit(path) +} + +/** + * Remove one fixture tree after its junctions are unlinked (see + * {@link unlinkFixtureLinks}). Retries the removal: Windows releases child + * process and antivirus file handles asynchronously, and an unretried + * `rmSync` fails immediately with EPERM under load. + * @param path - the fixture tree to remove. + */ +export function removeFixtureSafely(path: string): void { + unlinkFixtureLinks(path) + rmSync(path, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) +} diff --git a/scripts/translation-pairing-merge.spec.ts b/scripts/translation-pairing-merge.spec.ts index 0ee78a11b7..32924416a6 100644 --- a/scripts/translation-pairing-merge.spec.ts +++ b/scripts/translation-pairing-merge.spec.ts @@ -1,7 +1,14 @@ /** Integration coverage for automatic and explicit pairing-record conflict resolution. */ import { execFileSync, spawnSync } from 'node:child_process' -import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { + chmodSync, + mkdtempSync, + mkdirSync, + readFileSync, + symlinkSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -15,6 +22,7 @@ import { renderTranslationPairingRecord, translationPairPaths, } from './translation-pairing-record.ts' +import { removeFixtureSafely } from './test-fixture-cleanup.ts' const driver = fileURLToPath(new URL('./merge-translation-pairing.ts', import.meta.url)) const driverLauncher = fileURLToPath(new URL('./merge-translation-pairing-driver.sh', import.meta.url)) @@ -28,7 +36,7 @@ interface Fixture { } afterEach(() => { - for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true }) + for (const fixture of fixtures.splice(0)) removeFixtureSafely(fixture) }) function git(fixture: Fixture, args: string[]): string { From 709912b788944627dde6cbbf2f782fdd1d3c60f7 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 11:18:03 +0800 Subject: [PATCH 07/60] fix(boot): unlink stale profile fallback links instead of rmSync --- ...ink-stale-profile-fallback-links.i18n.yaml | 6 +++++ ...-12-unlink-stale-profile-fallback-links.md | 25 +++++++++++++++++++ ...-unlink-stale-profile-fallback-links.zh.md | 25 +++++++++++++++++++ packages/boot/app-boot/src/profile.ts | 6 +++-- 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.i18n.yaml new file mode 100644 index 0000000000..0e53f1c748 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.md +2026-08-12-unlink-stale-profile-fallback-links.md: 32959eb1b83bcc290d1daa4e4a020a2721be489b +2026-08-12-unlink-stale-profile-fallback-links.zh.md: 1f4da12748c9b57c12bf41a740001d5df770beb6 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.md b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.md new file mode 100644 index 0000000000..32959eb1b8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.md @@ -0,0 +1,25 @@ +# Agent Note: Unlink stale profile fallback links instead of rmSync + +Status: implemented + +English | [中文](2026-08-12-unlink-stale-profile-fallback-links.zh.md) + +## Problem + +`healProfilesModuleFallback` re-points `$DSH_HOME/profiles/node_modules` entries when an installation moves, and Windows hosts keep those entries as junctions. `ensureSymlink` deleted a stale entry with `rmSync(link)`, but Node treats a junction as a directory for removal: without `recursive`, `rmSync` throws `ERR_FS_EISDIR`, so every launch from a moved installation or a second worktree crashed before booting. The `replaces a wrong symlink` unit test reproduces that crash on Windows at the exact removal call. + +## Decision + +`ensureSymlink` removes a stale link with `unlinkSync(link)`. `unlink` deletes the reparse point or symlink itself on every platform and never descends into the target, which preserves the function's fail-loud guarantee that a real directory is never deleted. The [profile-plugin-bundles decision](../architecture/2026-08-05-profile-plugin-bundles.md) keeps owning the fallback's two-anchor resolution; this note owns only the removal primitive. + +## Alternatives considered + +**`rmSync(link, { recursive: true })`.** On Node 24 this deletes the junction without following its target, but `recursive` would silently delete a real directory that replaced the link between the `lstat` guard and the removal, weakening the fail-loud contract that motivates the guard. + +**`rmdirSync(link)`.** Removes a junction on Windows as well, but it reads as directory removal for a link, and `unlinkSync` is the repository's existing junction-cleanup idiom. + +**Delete and recreate every entry unconditionally.** Correct but churns unchanged links on every launch and widens the concurrent-heal race window. + +## Consequences + +Windows launches heal moved or second-checkout installations instead of crashing with `ERR_FS_EISDIR`; POSIX behavior is unchanged because `unlinkSync` also unlinks plain symlinks. The existing `replaces a wrong symlink` test now passes on Windows where it previously reproduced the crash. Two concurrent healers deleting the same stale link still surface the second deletion as `ENOENT`, unchanged from the previous `rmSync` implementation. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.zh.md new file mode 100644 index 0000000000..1f4da12748 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 用 unlink 删除过期的 profile 回退链接而非 rmSync + +Status: implemented + +[English](2026-08-12-unlink-stale-profile-fallback-links.md) | 中文 + +## 问题 + +`healProfilesModuleFallback` 在安装位置迁移时会把 `$DSH_HOME/profiles/node_modules` 中的条目重新指向新目标,而 Windows 主机上这些条目是 junction。`ensureSymlink` 原先用 `rmSync(link)` 删除过期条目,但 Node 在删除时把 junction 当作目录处理:不带 `recursive` 的 `rmSync` 会抛 `ERR_FS_EISDIR`,于是从迁移后的安装或第二个 worktree 启动时,每次都会在应用引导前崩溃。`replaces a wrong symlink` 单元测试在 Windows 上正好在该删除调用处复现了这一崩溃。 + +## 决策 + +`ensureSymlink` 改用 `unlinkSync(link)` 删除过期链接。`unlink` 在所有平台上都只删除重解析点或符号链接本身、绝不进入目标目录,从而保住该函数“真实目录永远不会被删除”的大声失败保证。[profile-plugin-bundles 决策](../architecture/2026-08-05-profile-plugin-bundles.md)继续拥有回退目录的双锚点解析;本 note 只拥有“用哪个删除原语”这一决定。 + +## 考虑过的替代方案 + +**`rmSync(link, { recursive: true })`。** Node 24 上它只删 junction、不跟随目标,但 `recursive` 会在 `lstat` 守卫与删除之间链接被替换成真实目录时静默删除该目录,削弱守卫存在所依据的大声失败契约。 + +**`rmdirSync(link)`。** Windows 上同样能删 junction,但它读起来像“删目录”,而 `unlinkSync` 才是仓库现有的 junction 清理惯例。 + +**无条件删除并重建所有条目。** 正确,但每次启动都翻动未变化的链接,并扩大并发修复的竞态窗口。 + +## 后果 + +Windows 启动现在可以修复迁移后的安装或第二个 checkout,而不是以 `ERR_FS_EISDIR` 崩溃;POSIX 行为不变,因为 `unlinkSync` 同样能 unlink 普通符号链接。现有的 `replaces a wrong symlink` 测试在 Windows 上从复现崩溃变为通过。两个并发 healer 删除同一过期链接时,第二次删除仍会以 `ENOENT` 浮现,与原先的 `rmSync` 实现一致。 diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 11f22c6df7..efa60eb78e 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -24,7 +24,7 @@ import { createRequire } from 'node:module' import { - existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync, + existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs' import { basename, dirname, join } from 'node:path' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' @@ -182,7 +182,9 @@ function ensureSymlink(link: string, target: string): void { throw new Error(`dsh: ${link} exists and is not a symlink; remove it so dsh can manage the installation fallback`) } if (readlinkSync(link) === target) return - rmSync(link) + // unlink deletes the reparse point itself on Windows too; rmSync treats a + // junction as a directory and throws EISDIR unless recursive. + unlinkSync(link) } try { symlinkSync(target, link, 'junction') From e37af006c5c8b02e4a4c4302f994ee0f318be89e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 11:19:01 +0800 Subject: [PATCH 08/60] test(app-boot): unlink hmr alias junctions before removing the target --- packages/boot/app-boot/tests/hmr-config.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index c248643130..5f52ab5e2a 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/packages/boot/app-boot/tests/hmr-config.spec.ts @@ -62,7 +62,7 @@ describe('HMR exact config paths', () => { expect(cacheHas).toHaveBeenCalledWith(expected) } finally { await ctx.fiber.dispose() - rmSync(alias, { force: true }) + unlinkSync(alias) rmSync(target, { recursive: true, force: true }) } }) @@ -78,7 +78,7 @@ describe('HMR exact config paths', () => { .rejects.toThrow('config path already registered') } finally { await ctx.fiber.dispose() - rmSync(alias, { force: true }) + unlinkSync(alias) rmSync(target, { recursive: true, force: true }) } }) From 874d7c4f78578790b7ba2e27da92cff216d0b282 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 11:32:03 +0800 Subject: [PATCH 09/60] docs: add junction-safe unlink rule to defensive patterns --- docs/defensive-patterns.i18n.yaml | 4 ++-- docs/defensive-patterns.md | 4 ++++ docs/defensive-patterns.zh.md | 4 ++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml index 18b28ca58c..caf07bb52f 100644 --- a/docs/defensive-patterns.i18n.yaml +++ b/docs/defensive-patterns.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/defensive-patterns.md -defensive-patterns.md: 368c9876f1a4e7042b003f6acfb30af3b2daf402 -defensive-patterns.zh.md: c7d4c1bf37ef17947913ac4011624d04ffd8c1a3 +defensive-patterns.md: b396b6b88f37b3ccea25321fb25534087911f8d4 +defensive-patterns.zh.md: de4c1d7f17cdde48596460bb4f76a4b0f6667c5a diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index 368c9876f1..b396b6b88f 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -27,3 +27,7 @@ A user-supplied listener that throws must not reject the promise it runs inside ## Never hand untrusted output the ambient environment or predictable paths Spawned commands get a scrubbed env (drop `*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASSWORD*`) so harness credentials cannot leak into output, `env`, or spill files. Temp/spill files use a private (0700) dir, random names, and exclusive owner-only opens (`'wx'`, `0o600`) — predictable world-readable paths invite symlink races and disclosure. + +## Unlink link-shaped paths + +A path that may be a symlink or Windows junction is removed with `lstatSync().isSymbolicLink()` then `unlinkSync`: unlink deletes only the link and refuses a real directory, so it never follows the link into its target. Windows `rmSync(link)` throws `ERR_FS_EISDIR` on a junction; recursive deletion may descend through one into its target. Reserve recursive `rmSync` for known real directories. diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md index c7d4c1bf37..de4c1d7f17 100644 --- a/docs/defensive-patterns.zh.md +++ b/docs/defensive-patterns.zh.md @@ -27,3 +27,7 @@ ## 绝不将环境变量或可预测路径暴露给不可信输出 启动的命令应使用经过清理的环境变量,移除名称匹配 `*KEY*`、`*SECRET*`、`*TOKEN*` 或 `*PASSWORD*` 的项,防止 harness 凭证通过命令输出、`env` 或 spill 文件泄漏。临时文件和 spill 文件应放在权限为 0700 的私有目录中,使用随机文件名,并以独占且仅所有者可访问的方式打开(`'wx'`、`0o600`);可预测且全局可读的路径会引发符号链接竞态和信息泄露。 + +## 用 unlink 删除链接形态的路径 + +可能是符号链接或 Windows junction 的路径,应先用 `lstatSync().isSymbolicLink()` 判断,再用 `unlinkSync` 删除:unlink 只删除链接本身并拒绝真实目录,因此绝不会跟随链接进入其目标。Windows 上对 junction 调用 `rmSync(link)` 会抛 `ERR_FS_EISDIR`;递归删除可能穿过 junction 进入其目标。真实目录才使用带 `recursive` 的 `rmSync`。 From 772b5a2ec81bfc70b812b56e46ecf0bdaa9ca047 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 14:04:45 +0800 Subject: [PATCH 10/60] fix(pwsh): resolve Store app execution aliases --- ...08-12-resolve-store-pwsh-aliases.i18n.yaml | 6 +++++ .../2026-08-12-resolve-store-pwsh-aliases.md | 23 +++++++++++++++++++ ...026-08-12-resolve-store-pwsh-aliases.zh.md | 23 +++++++++++++++++++ packages/bash/pwsh-local/src/resolve.ts | 17 ++++++++++++-- .../bash/pwsh-local/tests/executor.spec.ts | 20 +++++++++++++--- 5 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.i18n.yaml new file mode 100644 index 0000000000..140c7b9ae7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.md +2026-08-12-resolve-store-pwsh-aliases.md: 20fe58e15e75462dc0a9ba76c7a1a94939f8a004 +2026-08-12-resolve-store-pwsh-aliases.zh.md: bbfa4616127a9dbdb6609fe2973283663de55b31 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.md b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.md new file mode 100644 index 0000000000..20fe58e15e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.md @@ -0,0 +1,23 @@ +# Agent Note: Resolve Microsoft Store pwsh aliases + +Status: implemented + +English | [中文](2026-08-12-resolve-store-pwsh-aliases.zh.md) + +## Problem + +`resolvePwshPath` documented that Microsoft Store installs resolve through PATH, but its existence probe was `existsSync`, which stats a candidate and therefore follows reparse points. The Store's `%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe` is an app execution alias whose target directory ACL refuses stat (EACCES), so `existsSync` missed it and resolution silently fell through to Windows PowerShell 5.1 on hosts whose only PowerShell 7 is a Store install. + +## Decision + +`candidateExists` accepts a candidate that stats as a file or that lstat sees as a link-shaped reparse point, and `resolvePwshPath` uses it. Spawning the alias path works because CreateProcess resolves app execution aliases. A dangling link-shaped candidate is accepted so a broken pwsh fails loudly at spawn instead of silently downgrading to 5.1. + +## Alternatives considered + +**Probe the WindowsApps package directory directly.** The Store package path is versioned and ACL-hidden; hard-coding it duplicates packaging knowledge that PATH plus the alias already owns. + +**Keep the 5.1 fallback for stat failures.** Rejected: it silently runs a different shell than the one installed, which is the defect this note fixes. + +## Consequences + +Store-installed PowerShell 7 now resolves ahead of the 5.1 fallback on Windows; real-file candidates and non-Windows behavior are unchanged. The dangling-symlink unit test pins the stat/lstat split on every platform. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.zh.md new file mode 100644 index 0000000000..bbfa461612 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 解析 Microsoft Store 的 pwsh 别名 + +Status: implemented + +[English](2026-08-12-resolve-store-pwsh-aliases.md) | 中文 + +## 问题 + +`resolvePwshPath` 声称 Store 安装经 PATH 解析,但它的存在性探测用的是 `existsSync`,会对候选做 stat、从而跟随重解析点。Store 的 `%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe` 是 app execution alias,其目标目录的 ACL 拒绝 stat(EACCES),于是 `existsSync` 看不到它,解析静默落到 Windows PowerShell 5.1——在这类「唯一的 PowerShell 7 是 Store 安装」的机器上就用了错误的 shell。 + +## 决策 + +`candidateExists` 接受「stat 为文件」或「lstat 为链接形态重解析点」的候选,`resolvePwshPath` 改用它。spawn 别名路径可以工作,因为 CreateProcess 会解析 app execution alias。悬空的链接形态候选同样被接受,让损坏的 pwsh 在 spawn 时响亮失败,而不是静默降级到 5.1。 + +## 考虑过的替代方案 + +**直接探测 WindowsApps 包目录。** Store 包路径带版本且被 ACL 隐藏;硬编码它只是重复了 PATH 加别名已经拥有的打包知识。 + +**对 stat 失败继续走 5.1 回退。** 否决:它静默运行了一个并非所装的 shell,这正是本 note 修复的缺陷。 + +## 后果 + +Windows 上 Store 安装的 PowerShell 7 现在先于 5.1 回退被解析;普通文件候选和非 Windows 平台行为不变。悬空 symlink 单元测试在全部平台上钉住 stat/lstat 的分裂行为。 diff --git a/packages/bash/pwsh-local/src/resolve.ts b/packages/bash/pwsh-local/src/resolve.ts index c6ded2f883..f090c2b4c1 100644 --- a/packages/bash/pwsh-local/src/resolve.ts +++ b/packages/bash/pwsh-local/src/resolve.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-pwsh-local/resolve */ -import { existsSync } from 'node:fs' +import { existsSync, lstatSync } from 'node:fs' import { join } from 'node:path' /** @@ -36,6 +36,19 @@ export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string return candidates } +/** Whether a candidate can be spawned: a real file or a link-shaped reparse point. */ +function candidateExists(candidate: string): boolean { + if (existsSync(candidate)) return true + // Microsoft Store app execution aliases are reparse points whose target + // ACL refuses stat(), so existsSync misses them; lstat sees the link + // itself and CreateProcess resolves it when the executor spawns. + try { + return lstatSync(candidate).isSymbolicLink() + } catch { + return false + } +} + /** * Resolve the pwsh executable this executor spawns. * @param configured - an explicit `pwshPath` config value, trusted as-is. @@ -53,7 +66,7 @@ export function resolvePwshPath( if (configured !== undefined && configured.length > 0) return configured if (platform === 'win32') { for (const candidate of candidatePwshPaths(env)) { - if (existsSync(candidate)) return candidate + if (candidateExists(candidate)) return candidate } } return 'pwsh' diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index f84cc461d4..f6876d05c8 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -9,7 +9,7 @@ * writes CRLF on Windows, so exact text assertions normalize line endings. */ -import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' @@ -126,6 +126,18 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: join(dir, 'empty'), SystemRoot: join(dir, 'no-windows') }, 'win32')) .toBe('pwsh') }) + + it('accepts a link-shaped PATH candidate whose target cannot be stat-ed', () => { + // Store app execution aliases stat as EACCES but lstat as a link; a + // dangling symlink reproduces that split on every platform. + const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-link-')) + const store = join(dir, 'store') + mkdirSync(store, { recursive: true }) + const link = join(store, 'pwsh.exe') + symlinkSync(join(dir, 'no-such-target.exe'), link) + expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: store }, 'win32')) + .toBe(link) + }) }) describe('spawn construction (pure, every platform)', () => { @@ -298,8 +310,10 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)' it('start returns immediately with a running handle that settles as completed', async () => { const { bash } = await setup() const before = Date.now() - const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 200; Write-Output done' })) - expect(Date.now() - before).toBeLessThan(150) + // The sleep outlasts any realistic spawn latency, so returning while the + // child still sleeps proves start() does not wait for completion. + const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 2000; Write-Output done' })) + expect(Date.now() - before).toBeLessThan(1000) expect(proc.status).toBe('running') await proc.done expect(proc.status).toBe('completed') From 06891c762887c6ff55b81f95ea1525169e13918a Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 14:04:57 +0800 Subject: [PATCH 11/60] test(claude-code): isolate ambient Anthropic model env --- .../tests/real-product.spec.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 9320cb0e7c..dbf8f0d7d3 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -17,7 +17,7 @@ import type { SDKSystemMessage, } from '@anthropic-ai/claude-agent-sdk' import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' @@ -87,6 +87,22 @@ const roots: string[] = [] const fixtures: MessagesFixture[] = [] const contexts: Context[] = [] +// Ambient Anthropic model env leaks into the real CLI and overrides the +// fixture settings.json on developer machines; delete it for this file and +// restore it after, like the workspace-context USERPROFILE isolation. +const ambientAnthropicModel = process.env.ANTHROPIC_MODEL +const ambientAnthropicSmallFastModel = process.env.ANTHROPIC_SMALL_FAST_MODEL + +beforeAll(() => { + delete process.env.ANTHROPIC_MODEL + delete process.env.ANTHROPIC_SMALL_FAST_MODEL +}) + +afterAll(() => { + if (ambientAnthropicModel !== undefined) process.env.ANTHROPIC_MODEL = ambientAnthropicModel + if (ambientAnthropicSmallFastModel !== undefined) process.env.ANTHROPIC_SMALL_FAST_MODEL = ambientAnthropicSmallFastModel +}) + afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) From d58bd913563a245019626139e17c45b28579f2a1 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 15:11:05 +0800 Subject: [PATCH 12/60] fix(workflow): forward the tsconfig pin only in the unbuilt worker --- .../workflow/workflow-workerthread/src/host.ts | 13 ++++++++----- .../tests/workflow-workerthread.spec.ts | 17 ++++++----------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index b036d692e6..d17059ad8f 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -38,18 +38,21 @@ interface ChildRecord { * The unbuilt shape additionally forwards `TSX_TSCONFIG_PATH` for path * resolution. * @param platform - host platform; overridable so tests exercise both peer arms. + * @param tsconfigPath - the tsconfig pin to forward; only the unbuilt caller + * passes one, so the built worker never observes the host's pin. * @returns the scrubbed worker environment object. */ -export function workerSpawnEnv(platform: NodeJS.Platform = process.platform): NodeJS.ProcessEnv { +export function workerSpawnEnv( + platform: NodeJS.Platform = process.platform, + tsconfigPath: string | undefined = undefined, +): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {} if (platform === 'win32') { const tmp = tmpdir() env.TMP = tmp env.TEMP = tmp } - if (process.env.TSX_TSCONFIG_PATH !== undefined) { - env.TSX_TSCONFIG_PATH = process.env.TSX_TSCONFIG_PATH - } + if (tsconfigPath !== undefined) env.TSX_TSCONFIG_PATH = tsconfigPath return env } @@ -81,7 +84,7 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: W entry: new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), options: { workerData: init, - env: workerSpawnEnv(), + env: workerSpawnEnv(undefined, process.env.TSX_TSCONFIG_PATH), execArgv: [], }, } diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index e56254c92e..de26d822f7 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -591,17 +591,12 @@ describe('dsh-workflow-workerthread', () => { it('workerSpawnEnv forwards TSX_TSCONFIG_PATH when the snapshot harness pins it', () => { const tsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) - vi.stubEnv('TSX_TSCONFIG_PATH', tsconfig) - try { - expect(workerSpawnEnv('linux')).toEqual({ TSX_TSCONFIG_PATH: tsconfig }) - expect(workerSpawnEnv('win32')).toEqual({ - TMP: tmpdir(), - TEMP: tmpdir(), - TSX_TSCONFIG_PATH: tsconfig, - }) - } finally { - vi.unstubAllEnvs() - } + expect(workerSpawnEnv('linux', tsconfig)).toEqual({ TSX_TSCONFIG_PATH: tsconfig }) + expect(workerSpawnEnv('win32', tsconfig)).toEqual({ + TMP: tmpdir(), + TEMP: tmpdir(), + TSX_TSCONFIG_PATH: tsconfig, + }) }) it('the unbuilt worker forwards exactly TSX_TSCONFIG_PATH through the scrub: the paths-map pin survives, secrets do not', async () => { From db05dad2c7bf929d5c070d5c936a845247b3df4c Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 15:11:20 +0800 Subject: [PATCH 13/60] fix(pwsh): accept link-shaped candidates and sync the README contract --- packages/bash/pwsh-local/README.i18n.yaml | 4 ++-- packages/bash/pwsh-local/README.md | 2 +- packages/bash/pwsh-local/README.zh.md | 2 +- packages/bash/pwsh-local/src/resolve.ts | 20 ++++++++++++------- .../bash/pwsh-local/tests/executor.spec.ts | 11 ++++++++++ 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml index 46929dd0f2..a1ecf86130 100644 --- a/packages/bash/pwsh-local/README.i18n.yaml +++ b/packages/bash/pwsh-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/pwsh-local/README.md -README.md: 76f3071dc1049e2ea5929d5990ee0cb526ef702e -README.zh.md: e773e0e83e81ffa311bd555b7a75433ba22dfd22 +README.md: 234c1e45cff2b31b93665af582cdf67ce9459f4c +README.zh.md: 8cf750c47b68b835222067652e377d0de977516f diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md index 76f3071dc1..234c1e45cf 100644 --- a/packages/bash/pwsh-local/README.md +++ b/packages/bash/pwsh-local/README.md @@ -30,7 +30,7 @@ The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantic - **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output. - **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../bash/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. The namespace is shared with the POSIX family because a host composes exactly one provider of `ctx.bash`; a document written on either platform keeps resolving on the other. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section. - **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected. -- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem. +- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking each candidate with an lstat probe that accepts a real file or a link-shaped reparse point (a Store app execution alias stat-fails against its target's ACL, but lstat sees the alias itself); elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem. - **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent. - **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md index e773e0e83e..8cf750c47b 100644 --- a/packages/bash/pwsh-local/README.zh.md +++ b/packages/bash/pwsh-local/README.zh.md @@ -30,7 +30,7 @@ - **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。 - **组装条目是一层,而不是最终值**——当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../bash/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。该命名空间与 POSIX 家族共用,因为一个宿主只组装一个 `ctx.bash` 提供方;在任一平台写下的文档在另一平台仍能解析。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。 - **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。 -- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。 +- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一用 lstat 探测检查(接受真实文件或链接形态的重解析点:Store 的 app execution alias 对其目标 stat 会因 ACL 失败,但 lstat 能看到别名本身);其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。 - **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 - **超时与取消分类**——`run()` 通过一个 deadline 融合按配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。 - **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。 diff --git a/packages/bash/pwsh-local/src/resolve.ts b/packages/bash/pwsh-local/src/resolve.ts index f090c2b4c1..191abba963 100644 --- a/packages/bash/pwsh-local/src/resolve.ts +++ b/packages/bash/pwsh-local/src/resolve.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-pwsh-local/resolve */ -import { existsSync, lstatSync } from 'node:fs' +import { lstatSync } from 'node:fs' import { join } from 'node:path' /** @@ -36,15 +36,21 @@ export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string return candidates } -/** Whether a candidate can be spawned: a real file or a link-shaped reparse point. */ +/** + * Whether a candidate can be spawned. lstat opens the entry itself instead of + * following reparse points, so it sees the Store app execution alias where + * stat hits the target's ACL (EACCES); Node reports that alias as a symlink + * on current releases and as a plain file on older ones, and CreateProcess + * resolves either shape. A real directory never matches. + */ function candidateExists(candidate: string): boolean { - if (existsSync(candidate)) return true - // Microsoft Store app execution aliases are reparse points whose target - // ACL refuses stat(), so existsSync misses them; lstat sees the link - // itself and CreateProcess resolves it when the executor spawns. try { - return lstatSync(candidate).isSymbolicLink() + const stat = lstatSync(candidate) + return stat.isFile() || stat.isSymbolicLink() } catch { + // ENOENT (the candidate vanished between listing and probing) is the only + // expected failure; any other error names an unspawnable path, so false + // is the safe answer for it too. return false } } diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index f6876d05c8..1fe555b5e6 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -138,6 +138,17 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: store }, 'win32')) .toBe(link) }) + + it('skips a directory candidate and falls through to the PATH-resolution default', () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-dir-')) + const store = join(dir, 'store') + mkdirSync(join(store, 'pwsh.exe'), { recursive: true }) + expect(resolvePwshPath(undefined, { + ProgramFiles: join(dir, 'missing'), + PATH: store, + SystemRoot: join(dir, 'no-windows'), + }, 'win32')).toBe('pwsh') + }) }) describe('spawn construction (pure, every platform)', () => { From 9592842df68df602cc63cdf2c4a092707ca5854a Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 15:11:38 +0800 Subject: [PATCH 14/60] docs: record the junction-safe fixture teardown decision --- ...-fixture-junctions-before-delete.i18n.yaml | 6 +++++ ...-unlink-fixture-junctions-before-delete.md | 23 +++++++++++++++++++ ...link-fixture-junctions-before-delete.zh.md | 23 +++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.i18n.yaml new file mode 100644 index 0000000000..160f947daa --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.md +2026-08-12-unlink-fixture-junctions-before-delete.md: 4514a33d728866b817f4e9c1393f16c07976ed45 +2026-08-12-unlink-fixture-junctions-before-delete.zh.md: 3c212c052ab0303ccb8d31d2b310a365a1d8cc99 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.md b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.md new file mode 100644 index 0000000000..4514a33d72 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.md @@ -0,0 +1,23 @@ +# Agent Note: Unlink fixture junctions before recursive deletion + +Status: implemented + +English | [中文](2026-08-12-unlink-fixture-junctions-before-delete.zh.md) + +## Problem + +The install-lefthook and translation-pairing fixtures junction the repository's real `scripts/`, `node_modules`, and tsx package directories into fixture trees so installer probes resolve through them. Windows recursive deletion can treat a junction (a MOUNT_POINT reparse point) as a directory and follow it into its target; Git's `worktree remove` did exactly that and deleted the repository's tracked `scripts/` and tsx package (the incident's instrumentation pinned the deletion to that step). A fixture cleanup that trusts its deleter therefore deletes the repository's own sources instead of the fixture. + +## Decision + +`scripts/test-fixture-cleanup.ts` owns junction-safe fixture teardown: `unlinkFixtureLinks` walks a tree and unlinks every reparse point before `removeFixtureSafely` removes the now link-free tree (with Windows async-handle retries). Every affected `afterEach` and the pre-`worktree remove` hook call it. The general rule lives in `docs/defensive-patterns.md`: remove link-shaped paths with unlink, reserve recursive `rmSync` for known real directories. + +## Alternatives considered + +**Trust recursive deletion alone.** Rejected: whether a given deleter follows junctions is tool- and version-dependent, and one path through `git worktree remove` already destroyed tracked files; no cleanup may bet the repository on that behavior. + +**Copy instead of junctioning the real directories.** Rejected: the fixtures exist to probe the real installer paths through their real contents, so copies would stop exercising the boundary under test. + +## Consequences + +Fixture teardown can no longer reach repository sources through junctions. The extra walk is one lstat/unlink pass over small fixture trees. The data-destroying defect now has its durable why beside the defensive-patterns rule, and the helper is the shared teardown path for future junction fixtures. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.zh.md new file mode 100644 index 0000000000..3c212c052a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 递归删除前先解链 fixture junction + +Status: implemented + +[English](2026-08-12-unlink-fixture-junctions-before-delete.md) | 中文 + +## 问题 + +install-lefthook 与 translation-pairing 的 fixture 把仓库真实的 `scripts/`、`node_modules` 和 tsx 包目录用 junction 链进 fixture 树,让 installer 探测能穿透解析。Windows 的递归删除可能把 junction(MOUNT_POINT 重解析点)当作目录并跟随进其目标;Git 的 `worktree remove` 正是这样删掉了仓库被跟踪的 `scripts/` 和 tsx 包(事故的插桩把删除定位到这一步)。因此,信任删除器的 fixture 清理删掉的是仓库自己的源码,而不是 fixture。 + +## 决策 + +`scripts/test-fixture-cleanup.ts` 拥有 junction 安全的 fixture 拆除:`unlinkFixtureLinks` 先遍历并解链所有重解析点,`removeFixtureSafely` 再删除已无链接的树(带 Windows 异步句柄重试)。所有受影响的 `afterEach` 和 `worktree remove` 前的钩子都调用它。通用规则记录在 `docs/defensive-patterns.md`:链接形态的路径用 unlink 删除,递归 `rmSync` 只留给确知为真实目录的路径。 + +## 考虑过的替代方案 + +**只信任递归删除。** 否决:特定删除器是否跟随 junction 随工具和版本而异,而 `git worktree remove` 这一条路径已经摧毁过被跟踪文件;任何清理都不该拿仓库去赌这个行为。 + +**复制而不是 junction 真实目录。** 否决:fixture 的意义就是用真实内容探测真实 installer 路径,复制品会失去被测边界。 + +## 后果 + +fixture 拆除不再能穿过 junction 触及仓库源码。额外开销只是对小型 fixture 树的一趟 lstat/unlink。这个摧毁数据的缺陷现在在 defensive-patterns 规则旁有了持久化的原因,helper 也是未来所有 junction fixture 共享的拆除路径。 From d992eb116e9370848947e23b45768ba0b1e346c7 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 16:10:45 +0800 Subject: [PATCH 15/60] fix(workflow): use optional syntax for the tsconfig pin parameter --- packages/workflow/workflow-workerthread/src/host.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index d17059ad8f..2273e6ca1b 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -44,7 +44,7 @@ interface ChildRecord { */ export function workerSpawnEnv( platform: NodeJS.Platform = process.platform, - tsconfigPath: string | undefined = undefined, + tsconfigPath?: string, ): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {} if (platform === 'win32') { From d05351a270f830fa9d7f9f9ffb8f54f1b37b0386 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 01:11:20 +0800 Subject: [PATCH 16/60] docs(pty): register the persistent pwsh tool in the catalogs Adds tool-pwsh-persistent to the tool-catalog manifest, regenerates docs/tool-catalog.md and docs/config-catalog.md (the pty-local shellDialect config), and fixes the persistent-pty note's cross-link level to the implemented pwsh note. --- ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +-- .../2026-07-16-persistent-pty-sessions.md | 2 +- .../2026-07-16-persistent-pty-sessions.zh.md | 2 +- docs/config-catalog.md | 31 +++++++++++++++++-- docs/tool-catalog.md | 26 ++++++++++++++++ scripts/gen-tool-catalog.ts | 14 +++++++++ 6 files changed, 72 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index eab1b8987e..a15b09cc7c 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: fa9d90e3ded97b279c5cad7c8b5733333403beaf -2026-07-16-persistent-pty-sessions.zh.md: f801f4d0728452b2a5acf75c5bcbef0d9e1e6901 +2026-07-16-persistent-pty-sessions.md: 252af19ac2cc8ca29509e189ab07d9d147feef63 +2026-07-16-persistent-pty-sessions.zh.md: e63cacde98ec827b4ed1fed4eba39d228bdaf22a diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index fa9d90e3de..252af19ac2 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -134,7 +134,7 @@ The package ships concise tool guidance explaining persistent state, owner isola - Declarative per-agent startup requires an agent-setup composition point; plugin-load global sessions remain prohibited. - Session restoration across harness-process loss requires an out-of-process owner and a versioned protocol. - Network-egress policy and rollback of external side effects are broader than PTY and remain separate security work. -- Windows/ConPTY sessions run through the subprocess-local Windows inspector (Toolhelp32 identities, pseudo foreground groups, taskkill teardown) and the `pty-local` pwsh dialect; see the [pwsh persistent tool note](../../architecture/2026-08-11-pwsh-persistent-pty.md). +- Windows/ConPTY sessions run through the subprocess-local Windows inspector (Toolhelp32 identities, pseudo foreground groups, taskkill teardown) and the `pty-local` pwsh dialect; see the [pwsh persistent tool note](../architecture/2026-08-11-pwsh-persistent-pty.md). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index f801f4d072..e63cacde98 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -134,7 +134,7 @@ plugins: - 声明式 per-agent 启动需要 agent-setup 组合点;仍然禁止插件加载期全局会话。 - harness 进程丢失后的会话恢复需要进程外 owner 和版本化协议。 - 网络出口策略与外部副作用回滚超出 PTY 范围,继续作为独立安全工作。 -- Windows/ConPTY 会话经由 subprocess-local 的 Windows inspector(Toolhelp32 身份、伪前台进程组、taskkill 拆卸)与 `pty-local` 的 pwsh 方言运行;见 [pwsh 持久工具 note](../../architecture/2026-08-11-pwsh-persistent-pty.md)。 +- Windows/ConPTY 会话经由 subprocess-local 的 Windows inspector(Toolhelp32 身份、伪前台进程组、taskkill 拆卸)与 `pty-local` 的 pwsh 方言运行;见 [pwsh 持久工具 note](../architecture/2026-08-11-pwsh-persistent-pty.md)。 ## 备选方案 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 01c942493a..44d6a8c8cf 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1229,9 +1229,11 @@ Requires: `pty` · `sandboxPolicy` · `subprocess` export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -1259,9 +1261,12 @@ export interface Config { /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number } + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' ``` -Source: [`packages/pty/pty-local/src/config.ts:6`](../packages/pty/pty-local/src/config.ts) +Source: [`packages/pty/pty-local/src/config.ts:10`](../packages/pty/pty-local/src/config.ts) ## `@deepseek-ai/dsh-pwsh-local` @@ -2232,6 +2237,26 @@ export interface Config { Source: [`packages/bash/tool-pwsh/src/index.ts:52`](../packages/bash/tool-pwsh/src/index.ts) +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +Requires: `tools` · `pty` + +```ts config-catalog +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} +``` + +Source: [`packages/pty/tool-pwsh-persistent/src/index.ts:436`](../packages/pty/tool-pwsh-persistent/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` Requires: `tools` · `workflows` · `subagents` · `systemPrompt` diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index eeccb974b8..9404a74947 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -22,6 +22,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | +| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after view presence/absence, edit absence, or successful mutation`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal API. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `read_image`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt`, `ctx.attachments (read_image registration)`, `ctx.llm + an image-capable route (read_image execution)` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful file operation`, `durable attachment (read_image)`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | @@ -351,6 +352,31 @@ Source: [`packages/pty/tool-bash-persistent/src/index.ts`](../packages/pty/tool- One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +### `pwsh` + +Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] +} +``` + +Source: [`packages/pty/tool-pwsh-persistent/src/index.ts`](../packages/pty/tool-pwsh-persistent/src/index.ts) + +One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. + ## `@deepseek-ai/dsh-tool-str-replace-editor` ### `str_replace_editor` diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 73a87f8213..0a836b2f1c 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -43,6 +43,7 @@ import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' +import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' @@ -273,6 +274,19 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description.', }, + { + pkg: '@deepseek-ai/dsh-tool-pwsh-persistent', + dir: 'tool-pwsh-persistent', + source: 'packages/pty/tool-pwsh-persistent/src/index.ts', + requires: ['ctx.tools', 'ctx.pty', 'an owning Agent at execution time'], + writes: ['tool/call', 'PTY shell state', 'tool/result'], + async mount(ctx) { + await ctx.plugin(PtyService) + await ctx.plugin(ToolPwshPersistent) + }, + note: + 'One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description.', + }, { pkg: '@deepseek-ai/dsh-tool-str-replace-editor', dir: 'tool-str-replace-editor', From 2b839f8d7b7884aa9313319bd8758c9ea5c78a52 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 01:20:31 +0800 Subject: [PATCH 17/60] docs(i18n): sync the catalog Chinese counterparts for the new tool Mirrors the tool-pwsh-persistent catalog section and the pty-local shellDialect config into the reviewed Chinese counterparts and re-records both pairing sidecars. --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.zh.md | 31 ++++++++++++++++++++++++++++--- docs/tool-catalog.i18n.yaml | 4 ++-- docs/tool-catalog.zh.md | 26 ++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2b0f494ca6..60443c8598 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 01c942493ad81eb978ff269bee544bbb1560c819 -config-catalog.zh.md: 4be30ea40432e71e2fc53fd17fae107fd631fa06 +config-catalog.md: 44d6a8c8cfcf9faaa9b6c88e1df460103ce11939 +config-catalog.zh.md: a5944796095aef91fe87e927174f05f68ae2bbcb diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4be30ea404..a594479609 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1231,9 +1231,11 @@ export interface PlanModeConfig { export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -1261,9 +1263,12 @@ export interface Config { /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number } + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' ``` -来源:[`packages/pty/pty-local/src/config.ts:6`](../packages/pty/pty-local/src/config.ts) +来源:[`packages/pty/pty-local/src/config.ts:10`](../packages/pty/pty-local/src/config.ts) ## `@deepseek-ai/dsh-pwsh-local` @@ -2233,6 +2238,26 @@ export interface Config { 来源:[`packages/bash/tool-pwsh/src/index.ts:52`](../packages/bash/tool-pwsh/src/index.ts) +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +需要:`tools` · `pty` + +```ts config-catalog +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} +``` + +来源:[`packages/pty/tool-pwsh-persistent/src/index.ts:436`](../packages/pty/tool-pwsh-persistent/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` 需要:`tools` · `workflows` · `subagents` · `systemPrompt` diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 2acc82ba4a..34d2789889 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: eeccb974b86fddf782f0970e3fb3b63805a007b6 -tool-catalog.zh.md: 485f5e819647c860c0dbe021a4914a49badcec2a +tool-catalog.md: 9404a74947e7706980b6fee971316a1ca18da1d4 +tool-catalog.zh.md: c2ece838449c17928d0eadb3caa6819b45a73d8b diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 485f5e8196..c2ece83844 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -24,6 +24,7 @@ | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.bash`、`ctx.systemPrompt`、`ctx.bashEnv`、`ctx.tasks at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.bash` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.tasks` 运行时,并通过 `task_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-bash-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`、`cordis_mount`、`cordis_unmount` | `ctx.tools` | `tool/call`、`tool/result`、`process-local temporary Plugin lifecycle` | - | 不在任何随产品发布的树中,需要有意选择启用;临时 Plugin 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。由 cordis_mount 创建的插件在卸载或 DSH 重启之前可以注册**额外的**模型可见工具;发生这类工具集变更时,系统会记录完整且有变动的请求头。 | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.pty`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 | +| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`、`ctx.pty`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`、`ctx.fs` | `tool/call`、`fs/observed after view presence/absence, edit absence, or successful mutation`、`tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 | | `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`read_image`、`write` | `ctx.tools`、`ctx.fs`、`ctx.systemPrompt`、`ctx.attachments (read_image registration)`、`ctx.llm + an image-capable route (read_image execution)` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after read presence/absence or successful file operation`、`durable attachment (read_image)`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时 `read_image` 不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图像输入,否则拒绝。 | | `@deepseek-ai/dsh-tool-fs-search` | `glob`、`grep` | `ctx.tools`、`ctx.subprocess`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 | @@ -353,6 +354,31 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +### `pwsh` + +在持久 PowerShell shell 中运行命令。包括当前目录和已导出环境变量在内的状态会在此 agent 的多次调用之间保留。 + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] +} +``` + +来源:[`packages/pty/tool-pwsh-persistent/src/index.ts`](../packages/pty/tool-pwsh-persistent/src/index.ts) + +一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 + ## `@deepseek-ai/dsh-tool-str-replace-editor` ### `str_replace_editor` From db208953b1728686b39e57a0980aa8f32de817cb Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 23:57:35 +0800 Subject: [PATCH 18/60] fix(subprocess): keep the windows-inspector Linux coverage exemption and align the pwsh note with master's windows test structure --- .../architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml | 4 ++-- .../architecture/2026-08-11-pwsh-persistent-pty.md | 6 +++--- .../architecture/2026-08-11-pwsh-persistent-pty.zh.md | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml index c74339813b..59ea231ed3 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md -2026-08-11-pwsh-persistent-pty.md: 51988586a6c260d528e140ab718eb302355c7314 -2026-08-11-pwsh-persistent-pty.zh.md: e753c050827b216330ac9bce0258f9b59d866999 +2026-08-11-pwsh-persistent-pty.md: 7d4fe5e21fd4f9d96cfdf54dfbc6273f8aab3b45 +2026-08-11-pwsh-persistent-pty.zh.md: b1bb90218e15617d4445936abe1be19a537ef9f7 diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md index 51988586a6..7d4fe5e21f 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md @@ -38,7 +38,7 @@ The minimal preset gates its persistent shell stack by platform with the #2234 ` ### Testing -The subprocess-local and pty-local suites now run on Windows: bash-shaped fixtures self-skip through platform gates, the spawn/terminal suites translate their simple shell commands to node one-liners and exercise injected POSIX group paths, and the koffi-backed inspector joins the windows-only coverage exclusions on Linux while the windows-native lane enforces its 100% coverage. The tool suite mirrors `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. +The Windows test surface follows master's exemption structure: pty-local and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. ## Alternatives considered @@ -52,9 +52,9 @@ The subprocess-local and pty-local suites now run on Windows: bash-shaped fixtur ## Consequences -**Windows became a first-class persistent-shell host.** The pty family now runs, tests, and is coverage-gated on the windows-native lane; the one-shot/persistent shell split mirrors POSIX, and the preset spec pins exactly one shell stack per host on both platforms. +**Windows became a first-class persistent-shell host.** The persistent pwsh stack runs and is coverage-gated on the windows-native lane; the one-shot/persistent shell split mirrors POSIX, and the preset spec pins exactly one shell stack per host on both platforms. -**The windows-native coverage flip is a standing commitment.** subprocess-local and pty-local sources are coverage-required on win32; their suites run there (with platform gates and node-translated commands) and must keep 100% coverage on the windows-native lane. +**Windows coverage keeps master's exemption structure.** subprocess-local and pty-local sources stay coverage-exempt and their suites test-excluded on win32 exactly as on master; the Windows code paths are exercised through the win32 dev lane and the real-pwsh tool suites, and the new surface's coverage obligation on the windows-native lane sits on `tool-pwsh-persistent`. **Windows readiness is weaker than Linux.** The pseudo-pgid marker fast path covers shell prompts, but a child without a prompt settles on the silence tier (~3 s), exactly like macOS; there is no exact stdin-wait tier. diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md index e753c05082..b1bb90218e 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md @@ -38,7 +38,7 @@ minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell ### 测试 -subprocess-local 与 pty-local 套件现在在 Windows 上运行:bash 形态 fixture 通过平台门控自跳过,spawn/terminal 套件把简单 shell 命令翻译为 node 单行并覆盖注入的 POSIX 组路径,koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免,windows-native 车道强制执行其 100% 覆盖。工具套件镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 +Windows 测试面沿用 master 的豁免结构:pty-local 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 ## 备选方案 @@ -52,9 +52,9 @@ subprocess-local 与 pty-local 套件现在在 Windows 上运行:bash 形态 f ## 后果 -**Windows 成为一等公民的持久 shell 宿主。** pty 家族现在在 windows-native 车道上运行、测试并受覆盖门禁约束;一次性/持久 shell 的划分与 POSIX 镜像,预设 spec 在两种平台上都钉死每宿主恰好一个 shell 栈。 +**Windows 成为一等公民的持久 shell 宿主。** 持久 pwsh 栈在 windows-native 车道上运行并受覆盖门禁约束;一次性/持久 shell 的划分与 POSIX 镜像,预设 spec 在两种平台上都钉死每宿主恰好一个 shell 栈。 -**windows-native 覆盖翻转是常驻承诺。** subprocess-local 与 pty-local 源码在 win32 上受覆盖约束;它们的套件在那里运行(带平台门控与 node 翻译命令),并必须在 windows-native 车道保持 100% 覆盖。 +**Windows 覆盖沿用 master 的豁免结构。** subprocess-local 与 pty-local 源码在 win32 上保持覆盖豁免、其套件保持测试排除,与 master 完全一致;Windows 代码路径经 win32 开发车道与真实 pwsh 工具套件验证,新表面的覆盖义务在 windows-native 车道上落在 `tool-pwsh-persistent`。 **Windows 就绪弱于 Linux。** 伪 pgid marker 快路径覆盖 shell 提示符,但没有提示符的子进程按静默档结算(约 3s),与 macOS 完全一致;没有精确的 stdin-wait 档。 From 974c340bb12eae225327bae2a13dd3d20da54ba0 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 00:12:13 +0800 Subject: [PATCH 19/60] fix(pty): close the exit race between send settlement and the next poll in both persistent shell tools --- .../pty/tool-bash-persistent/src/index.ts | 52 ++++++++++++++---- .../tool-bash-persistent/tests/tools.spec.ts | 30 +++++++++++ .../pty/tool-pwsh-persistent/src/index.ts | 54 +++++++++++++++---- .../tool-pwsh-persistent/tests/tools.spec.ts | 30 +++++++++++ 4 files changed, 146 insertions(+), 20 deletions(-) diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index fa2a965231..3116314573 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -197,6 +197,36 @@ function renderShellExitStatus( return appendStatusMarker(content, marker) } +/** + * Render the exited-session result, reset the owner's shell, and reset the + * message that tells the model the next call starts fresh. + * @param shells - the owner-scoped registry to reset. + * @param status - the exited session status (exit code and signal). + * @returns the complete model-facing result. + */ +async function respondToSessionExit( + ctx: Context, + shells: PersistentShells, + owner: Agent, + id: PtySessionId, + status: { exitCode: number | null; signal: NodeJS.Signals | null }, + marker: CommandMarkers, + fallback: string, + fallbackTruncated: boolean, + config: ResolvedConfig, +): Promise { + const snapshot = retainedScrollback(ctx, owner, id) + await shells.reset(owner, 'persistent bash shell exited') + return [ + renderShellExitStatus( + renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), + status.exitCode, + status.signal, + ), + SHELL_RESET_MESSAGE, + ].filter(part => part.length > 0).join('\n') +} + function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { const pending = new WeakMap>() const live = new Map() @@ -286,6 +316,15 @@ async function executeCommand( let fallbackTruncated = false while (true) { + // The shell may flip to exited between iterations (a fast `exit` can + // settle the previous send while its exit event is still in flight); + // re-observing status before the next send closes that gap. + const status = ctx.pty.list(owner).find(session => session.sessionId === id)?.status + if (status?.kind === 'exited') { + return await respondToSessionExit( + ctx, shells, owner, id, status, marker, fallback, fallbackTruncated, config, + ) + } let operation let result try { @@ -328,16 +367,9 @@ async function executeCommand( if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) } if (result.sessionStatus.kind === 'exited') { - const snapshot = retainedScrollback(ctx, owner, id, latest) - await shells.reset(owner, 'persistent bash shell exited') - return [ - renderShellExitStatus( - renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), - result.sessionStatus.exitCode, - result.sessionStatus.signal, - ), - SHELL_RESET_MESSAGE, - ].filter(part => part.length > 0).join('\n') + return await respondToSessionExit( + ctx, shells, owner, id, result.sessionStatus, marker, fallback, fallbackTruncated, config, + ) } if (promptCompleted(result)) { const snapshot = retainedScrollback(ctx, owner, id, latest) diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index d43fd64399..789dbaf831 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -98,6 +98,7 @@ type StubMode = | 'incremental-fallback' | 'empty-page-after-latest' | 'paged-scrollback' + | 'exit-after-send' class StubPtySession implements PtyBackendSession { readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' @@ -109,6 +110,7 @@ class StubPtySession implements PtyBackendSession { sends = 0 pendingText = '' historyTruncated = false + throwOnSend = false constructor(mode: StubMode) { this.mode = mode @@ -127,6 +129,7 @@ class StubPtySession implements PtyBackendSession { return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) } if (this.mode === 'send-error') throw new Error('stub send failed') + if (this.throwOnSend) throw new Error('PTY session has exited') if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') { const done = new Promise>((resolve) => { request.signal?.addEventListener('abort', () => { @@ -171,6 +174,18 @@ class StubPtySession implements PtyBackendSession { const incremental = `${start ?? ''}\nincrement\n${this.motd}` return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental) } + if (this.mode === 'exit-after-send') { + // A fast `exit` settles the send while the exit event is still in + // flight; the shell flips to exited before the tool's next poll, + // exactly like the real backend. The tool must re-observe status + // instead of sending. + const output = `${start ?? ''}\n` + this.scrollback += output + const settled = this.result(output, 'inferred_idle') + this.statusValue = { kind: 'exited', exitCode: 9, signal: null } + this.throwOnSend = true + return this.operation(Promise.resolve(settled)) + } if (this.mode === 'torn-status') { const output = `${start ?? ''}\nhello from stub\n${end ?? ''}` this.scrollback += output @@ -397,6 +412,21 @@ describe('tool-bash-persistent', () => { expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]') }) + it('reports the exit path when the shell exits between send settlement and the next poll', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'exit-after-send' + + const result = text(await call(ctx, owner, 'exit')) + expect(result).toContain('[shell exited: code 9]') + expect(result).toContain('next bash call starts from the workspace') + expect(session.closed).toContain('persistent bash shell exited') + + expect(text(await call(ctx, owner, 'echo "$PWD"'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + it('reports a shell exit when the backend has no code or signal', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub' }) await call(ctx, owner, 'warm up') diff --git a/packages/pty/tool-pwsh-persistent/src/index.ts b/packages/pty/tool-pwsh-persistent/src/index.ts index 53c495585d..e456f47719 100644 --- a/packages/pty/tool-pwsh-persistent/src/index.ts +++ b/packages/pty/tool-pwsh-persistent/src/index.ts @@ -219,6 +219,37 @@ function renderShellExitStatus( return appendStatusMarker(content, marker) } +/** + * Render the exited-session result, reset the owner's shell, and reset the + * message that tells the model the next call starts fresh. + * @param shells - the owner-scoped registry to reset. + * @param status - the exited session status (exit code and signal). + * @returns the complete model-facing result. + */ +async function respondToSessionExit( + ctx: Context, + shells: PersistentShells, + owner: Agent, + id: PtySessionId, + status: { exitCode: number | null; signal: NodeJS.Signals | null }, + marker: CommandMarkers, + wrapped: string, + fallback: string, + fallbackTruncated: boolean, + config: ResolvedConfig, +): Promise { + const snapshot = retainedScrollback(ctx, owner, id) + await shells.reset(owner, 'persistent pwsh shell exited') + return [ + renderShellExitStatus( + renderCaptured(partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), config.maxOutputChars), + status.exitCode, + status.signal, + ), + SHELL_RESET_MESSAGE, + ].filter(part => part.length > 0).join('\n') +} + /** * The pwsh prompt function that overrides the backend bootstrap value with * this tool's own prompt. `[char]27`/`[char]7` build the OSC bytes at runtime @@ -317,6 +348,16 @@ async function executeCommand( let fallbackTruncated = false while (true) { + // The shell may flip to exited between iterations (a fast `exit` can + // settle the previous send while its exit event is still in flight, and + // the echoed wrapper can then carry a marker end without status digits); + // re-observing status before the next send closes that gap. + const status = ctx.pty.list(owner).find(session => session.sessionId === id)?.status + if (status?.kind === 'exited') { + return await respondToSessionExit( + ctx, shells, owner, id, status, marker, wrapped, fallback, fallbackTruncated, config, + ) + } let operation let result try { @@ -359,16 +400,9 @@ async function executeCommand( if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) } if (result.sessionStatus.kind === 'exited') { - const snapshot = retainedScrollback(ctx, owner, id, latest) - await shells.reset(owner, 'persistent pwsh shell exited') - return [ - renderShellExitStatus( - renderCaptured(partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), config.maxOutputChars), - result.sessionStatus.exitCode, - result.sessionStatus.signal, - ), - SHELL_RESET_MESSAGE, - ].filter(part => part.length > 0).join('\n') + return await respondToSessionExit( + ctx, shells, owner, id, result.sessionStatus, marker, wrapped, fallback, fallbackTruncated, config, + ) } if (promptCompleted(result)) { const snapshot = retainedScrollback(ctx, owner, id, latest) diff --git a/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts b/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts index b1a6ee8395..1f4a480c38 100644 --- a/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts @@ -99,6 +99,7 @@ type StubMode = | 'empty-page-after-latest' | 'paged-scrollback' | 'with-echo' + | 'exit-after-send' const START_PATTERN = /__DSH_PERSISTENT_PWSH_START_[^_]+(?:-[^_]+)*__/ const END_PATTERN = /__DSH_PERSISTENT_PWSH_END_[^:]+:/ @@ -113,6 +114,7 @@ class StubPtySession implements PtyBackendSession { sends = 0 pendingText = '' historyTruncated = false + throwOnSend = false constructor(mode: StubMode) { this.mode = mode @@ -131,6 +133,7 @@ class StubPtySession implements PtyBackendSession { return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) } if (this.mode === 'send-error') throw new Error('stub send failed') + if (this.throwOnSend) throw new Error('PTY session has exited') if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') { const done = new Promise>((resolve) => { request.signal?.addEventListener('abort', () => { @@ -178,6 +181,18 @@ class StubPtySession implements PtyBackendSession { this.scrollback += output return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) } + if (this.mode === 'exit-after-send') { + // A fast `exit` settles the send with an echoed wrapper (marker end, + // no status digits) while the exit event is still in flight; the shell + // flips to exited before the tool's next poll, exactly like the real + // ConPTY backend. The tool must re-observe status instead of sending. + const output = `${sent}\n${start ?? ''}\n` + this.scrollback += output + const settled = this.result(output, 'inferred_idle') + this.statusValue = { kind: 'exited', exitCode: 9, signal: null } + this.throwOnSend = true + return this.operation(Promise.resolve(settled)) + } if (this.mode === 'incremental-fallback') { const incremental = `${start ?? ''}\nincrement\n${this.motd}` return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental) @@ -347,6 +362,21 @@ describe('tool-pwsh-persistent', () => { expect(result).not.toContain('Invoke-Expression') }) + it('reports the exit path when the shell exits between send settlement and the next poll', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'exit-after-send' + + const result = text(await call(ctx, owner, 'exit')) + expect(result).toContain('[shell exited: code 9]') + expect(result).toContain('next pwsh call starts from the workspace') + expect(session.closed).toContain('persistent pwsh shell exited') + + expect(text(await call(ctx, owner, 'Write-Output "$PWD"'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => { const { ctx, owner, stub, fiber } = await setup({ backendType: 'stub', From 7c6735c4cb9da6b61d0f20fddabf547ad5dec13d Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 00:15:40 +0800 Subject: [PATCH 20/60] docs(catalog): refresh config-catalog source lines after the persistent-shell fixes --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 4 ++-- docs/config-catalog.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 04afdcd0e8..9f44a229d6 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 8e05f123f9313be19bec3cafe0c73403f26133c6 -config-catalog.zh.md: 8744a33bb622150274826fa5a0f5789454be3345 +config-catalog.md: 8bf9562cb4e91c15e162d50121b53c58decfe183 +config-catalog.zh.md: befbba2d71c3f6c2af512404b6054382c962eaff diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8e05f123f9..8bf9562cb4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2156,7 +2156,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-bash-persistent/src/index.ts:405`](../packages/pty/tool-bash-persistent/src/index.ts) +Source: [`packages/pty/tool-bash-persistent/src/index.ts:437`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -2306,7 +2306,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-pwsh-persistent/src/index.ts:436`](../packages/pty/tool-pwsh-persistent/src/index.ts) +Source: [`packages/pty/tool-pwsh-persistent/src/index.ts:470`](../packages/pty/tool-pwsh-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8744a33bb6..befbba2d71 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2158,7 +2158,7 @@ export interface Config { } ``` -来源:[`packages/pty/tool-bash-persistent/src/index.ts:405`](../packages/pty/tool-bash-persistent/src/index.ts) +来源:[`packages/pty/tool-bash-persistent/src/index.ts:437`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -2307,7 +2307,7 @@ export interface Config { } ``` -来源:[`packages/pty/tool-pwsh-persistent/src/index.ts:436`](../packages/pty/tool-pwsh-persistent/src/index.ts) +来源:[`packages/pty/tool-pwsh-persistent/src/index.ts:470`](../packages/pty/tool-pwsh-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` From b1daf0eeaf596e91eab70603fea12f45105999dd Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 01:15:03 +0800 Subject: [PATCH 21/60] test(tools): expect both pwsh tool packages in the harvested catalog roster --- packages/core/tools/tests/gen-tool-catalog.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 8e1e6ad5ef..541bc39d58 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { From d6010c2d88c26fc60788e0cee0f2c2c9d518f4fb Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 09:52:56 +0800 Subject: [PATCH 22/60] refactor(pty): follow the naming-contract renames across the persistent pwsh stack --- .../2026-08-11-pwsh-persistent-pty.md | 16 +- .../2026-08-11-pwsh-persistent-pty.zh.md | 16 +- apps/cli/tests/windows-shell.spec.ts | 10 +- packages/e2b/e2b/tests/composition.e2e.ts | 2 +- .../shell/tool-bash-persistent/src/index.ts | 4 +- .../tool-pwsh-persistent/README.i18n.yaml | 6 +- .../tool-pwsh-persistent/README.md | 8 +- .../tool-pwsh-persistent/README.zh.md | 6 +- .../tool-pwsh-persistent/package.json | 8 +- .../tool-pwsh-persistent/src/index.ts | 42 +- .../tool-pwsh-persistent/src/invariant.ts | 0 .../tests/loader-composition.spec.ts | 12 +- .../tool-pwsh-persistent/tests/tools.spec.ts | 56 +- .../tool-pwsh-persistent/tsconfig.json | 4 +- .../terminal-bash/tests/local.spec.ts | 14 +- packages/terminal/terminal-bash/tsconfig.json | 2 +- .../tests/workflow-worker-thread.spec.ts | 37 +- pnpm-lock.yaml | 2644 +++++++++-------- scripts/gen-tool-catalog.ts | 6 +- 19 files changed, 1491 insertions(+), 1402 deletions(-) rename packages/{pty => shell}/tool-pwsh-persistent/README.i18n.yaml (54%) rename packages/{pty => shell}/tool-pwsh-persistent/README.md (82%) rename packages/{pty => shell}/tool-pwsh-persistent/README.zh.md (88%) rename packages/{pty => shell}/tool-pwsh-persistent/package.json (90%) rename packages/{pty => shell}/tool-pwsh-persistent/src/index.ts (92%) rename packages/{pty => shell}/tool-pwsh-persistent/src/invariant.ts (100%) rename packages/{pty => shell}/tool-pwsh-persistent/tests/loader-composition.spec.ts (95%) rename packages/{pty => shell}/tool-pwsh-persistent/tests/tools.spec.ts (94%) rename packages/{pty => shell}/tool-pwsh-persistent/tsconfig.json (78%) diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md index 7d4fe5e21f..092302ec00 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md @@ -1,4 +1,4 @@ -# Agent Note: Persistent pwsh over the PTY seam on Windows +# Agent Note: Persistent pwsh over the terminal seam on Windows Status: implemented @@ -6,15 +6,15 @@ English | [中文](2026-08-11-pwsh-persistent-pty.zh.md) ## Problem -The harness had no persistent shell on Windows. The persistent `bash` stack was POSIX-only by construction: `@deepseek-ai/dsh-subprocess-local` threw at terminal allocation (`createProcessInspector()` rejected win32), `@deepseek-ai/dsh-pty-local` was bash-shaped (`/bin/bash` defaults, `PS1`/`PROMPT_COMMAND` environment markers), `@deepseek-ai/dsh-tool-bash-persistent` wrapped commands in bash syntax, and every pty test skipped on win32. The one-shot `pwsh` tool (`@deepseek-ai/dsh-tool-pwsh` over `@deepseek-ai/dsh-pwsh-local`) already ran on Windows, but each call started a fresh `pwsh -Command` process: cwd, `$env:` variables, functions, and interactive children ended with the call, and its README recorded "No persistent shell or PTY" as deferred work. +The harness had no persistent shell on Windows. The persistent `bash` stack was POSIX-only by construction: `@deepseek-ai/dsh-subprocess-local` threw at terminal allocation (`createProcessInspector()` rejected win32), `@deepseek-ai/dsh-terminal-bash` was bash-shaped (`/bin/bash` defaults, `PS1`/`PROMPT_COMMAND` environment markers), `@deepseek-ai/dsh-tool-bash-persistent` wrapped commands in bash syntax, and every pty test skipped on win32. The one-shot `pwsh` tool (`@deepseek-ai/dsh-tool-pwsh` over `@deepseek-ai/dsh-pwsh-local`) already ran on Windows, but each call started a fresh `pwsh -Command` process: cwd, `$env:` variables, functions, and interactive children ended with the call, and its README recorded "No persistent shell or PTY" as deferred work. The gap excluded Windows workflows whose state lives in a terminal: stepping a debugger, exploring in a Python or Node REPL, or returning to a shell after interrupting its foreground command — the same class of work the persistent bash pty serves on POSIX. -Two foundations already existed. The PTY service itself (`ctx.pty` registry, owner scoping, send/read/signal/kill contract) is platform-neutral. The Loader's `disabled: !!js` interpolation (PR #2234) gates shell rows per platform and pins the invariant that exactly one shell stack mounts per host; a persistent pwsh stack composes through the same rows. +Two foundations already existed. the terminal service itself (`ctx.terminals` registry, owner scoping, send/read/signal/kill contract) is platform-neutral. The Loader's `disabled: !!js` interpolation (PR #2234) gates shell rows per platform and pins the invariant that exactly one shell stack mounts per host; a persistent pwsh stack composes through the same rows. ## Decision -A model-facing persistent `pwsh` tool ships on Windows with the same contract as `tool-bash-persistent`: one owner-scoped persistent shell per Agent, marker-detected command completion, exact native exit codes, bounded output, and timeout/cancel/`exit` semantics that reset the shell and tell the model. Three pieces deliver it: a Windows substrate in `subprocess-local`, a shell-dialect option in `pty-local`, and the new `tool-pwsh-persistent` package with the minimal-preset composition rows. +A model-facing persistent `pwsh` tool ships on Windows with the same contract as `tool-bash-persistent`: one owner-scoped persistent shell per Agent, marker-detected command completion, exact native exit codes, bounded output, and timeout/cancel/`exit` semantics that reset the shell and tell the model. Three pieces deliver it: a Windows substrate in `subprocess-local`, a shell-dialect option in `terminal-bash`, and the new `tool-pwsh-persistent` package with the minimal-preset composition rows. ### Windows substrate in `@deepseek-ai/dsh-subprocess-local` @@ -22,7 +22,7 @@ A model-facing persistent `pwsh` tool ships on Windows with the same contract as `LocalTerminalHandle` branches for win32 because node-pty's `kill(signal)` throws ("Signals not supported on windows") and its bare kill delegates to a console-list agent that fails without a parent console. Teardown escalates through taskkill fenced on the shell's start identity, and — because an externally taskkilled shell may never fire node-pty's exit notification — the handle settles `done` from the inspector-verified absence (`settleExitIfGone`). `signalForeground` maps SIGINT to a `\x03` Ctrl-C input write (the console-wide delivery conhost turns into a CTRL_C event; verified to interrupt a running command), routes SIGTERM/SIGKILL to taskkill, and rejects SIGTSTP/SIGHUP as unavailable on Windows. The public `PtySignal` set and seam types are unchanged; the mapping lives in the backend. -### Shell dialect in `@deepseek-ai/dsh-pty-local` +### Shell dialect in `@deepseek-ai/dsh-terminal-bash` One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`, existing deployments byte-identical). The effective `shellPath`/`shellArgs` resolve per dialect (bash `/bin/bash --noprofile --norc -i`; pwsh through the shared `dsh-pwsh-local` resolver with `-NoLogo -NoProfile`, keeping the interactive host for child REPLs). The child environment drops the bash-only `PS1`/`PROMPT_COMMAND` markers and adds `NO_COLOR` for pwsh. pwsh cannot install its prompt from the environment, so the backend writes the prompt function through the session at startup and waits until the controlled prompt is actually visible, looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound; a `session_exit` or `timeout` wait rejects the spawn. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so the sanitizer, `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic are reused untouched — the marker stays a readiness signal with an unconsumed payload, exactly as in the bash path, and no model-notification channel was added (aligned with the current implementation; the deferred BEL event channel stays deferred). @@ -34,11 +34,11 @@ Commands run through a wrapper that resets `$LASTEXITCODE` (assignable, verified ### Composition -The minimal preset gates its persistent shell stack by platform with the #2234 `disabled: !!js` interpolation: the bash rows (`pty-local` + `tool-bash-persistent`) mount on POSIX, and the pwsh rows (`pty-local` with `shellDialect: pwsh` + `tool-pwsh-persistent`) mount on win32 — exactly one persistent shell per host. `windows-shell.spec` pins the per-platform roster; the real Loader composition exercises the whole stack over a real ConPTY pwsh. +The minimal preset gates its persistent shell stack by platform with the #2234 `disabled: !!js` interpolation: the bash rows (`terminal-bash` + `tool-bash-persistent`) mount on POSIX, and the pwsh rows (`terminal-bash` with `shellDialect: pwsh` + `tool-pwsh-persistent`) mount on win32 — exactly one persistent shell per host. `windows-shell.spec` pins the per-platform roster; the real Loader composition exercises the whole stack over a real ConPTY pwsh. ### Testing -The Windows test surface follows master's exemption structure: pty-local and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. +The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. ## Alternatives considered @@ -54,7 +54,7 @@ The Windows test surface follows master's exemption structure: pty-local and sub **Windows became a first-class persistent-shell host.** The persistent pwsh stack runs and is coverage-gated on the windows-native lane; the one-shot/persistent shell split mirrors POSIX, and the preset spec pins exactly one shell stack per host on both platforms. -**Windows coverage keeps master's exemption structure.** subprocess-local and pty-local sources stay coverage-exempt and their suites test-excluded on win32 exactly as on master; the Windows code paths are exercised through the win32 dev lane and the real-pwsh tool suites, and the new surface's coverage obligation on the windows-native lane sits on `tool-pwsh-persistent`. +**Windows coverage keeps master's exemption structure.** subprocess-local and terminal-bash sources stay coverage-exempt and their suites test-excluded on win32 exactly as on master; the Windows code paths are exercised through the win32 dev lane and the real-pwsh tool suites, and the new surface's coverage obligation on the windows-native lane sits on `tool-pwsh-persistent`. **Windows readiness is weaker than Linux.** The pseudo-pgid marker fast path covers shell prompts, but a child without a prompt settles on the silence tier (~3 s), exactly like macOS; there is no exact stdin-wait tier. diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md index b1bb90218e..857f78c66f 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Windows 上基于 PTY seam 的持久化 pwsh +# Agent Note: Windows 上基于 terminal seam 的持久化 pwsh Status: implemented @@ -6,15 +6,15 @@ Status: implemented ## 问题 -harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POSIX-only:`@deepseek-ai/dsh-subprocess-local` 在终端分配时直接抛错(`createProcessInspector()` 拒绝 win32),`@deepseek-ai/dsh-pty-local` 是 bash 形态(`/bin/bash` 默认值、`PS1`/`PROMPT_COMMAND` 环境标记),`@deepseek-ai/dsh-tool-bash-persistent` 用 bash 语法包装命令,pty 测试全部在 win32 上 skip。一次性 `pwsh` 工具(`@deepseek-ai/dsh-tool-pwsh` + `@deepseek-ai/dsh-pwsh-local`)已经能在 Windows 运行,但每次调用都是全新的 `pwsh -Command` 进程:cwd、`$env:` 变量、函数和交互式子进程都随调用结束,其 README 把 "No persistent shell or PTY" 记为 deferred work。 +harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POSIX-only:`@deepseek-ai/dsh-subprocess-local` 在终端分配时直接抛错(`createProcessInspector()` 拒绝 win32),`@deepseek-ai/dsh-terminal-bash` 是 bash 形态(`/bin/bash` 默认值、`PS1`/`PROMPT_COMMAND` 环境标记),`@deepseek-ai/dsh-tool-bash-persistent` 用 bash 语法包装命令,pty 测试全部在 win32 上 skip。一次性 `pwsh` 工具(`@deepseek-ai/dsh-tool-pwsh` + `@deepseek-ai/dsh-pwsh-local`)已经能在 Windows 运行,但每次调用都是全新的 `pwsh -Command` 进程:cwd、`$env:` 变量、函数和交互式子进程都随调用结束,其 README 把 "No persistent shell or PTY" 记为 deferred work。 这个缺口排除了状态驻留在终端里的 Windows 工作流:单步调试、在 Python 或 Node REPL 中探索、中断前台命令后回到原 shell —— 正是持久 bash pty 在 POSIX 上服务的同一类工作。 -两个基础已经存在。PTY 服务本身(`ctx.pty` 注册表、owner 作用域、send/read/signal/kill 契约)是平台无关的。Loader 的 `disabled: !!js` 插值(PR #2234)按平台门控 shell 行,并钉死了"每宿主恰好挂载一个 shell 栈"的不变量;持久 pwsh 栈通过同一行机制组合。 +两个基础已经存在。PTY 服务本身(`ctx.terminals` 注册表、owner 作用域、send/read/signal/kill 契约)是平台无关的。Loader 的 `disabled: !!js` 插值(PR #2234)按平台门控 shell 行,并钉死了"每宿主恰好挂载一个 shell 栈"的不变量;持久 pwsh 栈通过同一行机制组合。 ## 决定 -模型侧持久 `pwsh` 工具在 Windows 上交付,契约与 `tool-bash-persistent` 逐项对齐:每个 Agent 一个 owner 作用域的持久 shell、标记检测的命令完成、精确的原生退出码、有界输出,以及超时/取消/`exit` 时重置 shell 并告知模型的语义。三块交付:`subprocess-local` 的 Windows 基座、`pty-local` 的 shell 方言选项、新的 `tool-pwsh-persistent` 包加 minimal 预设组合行。 +模型侧持久 `pwsh` 工具在 Windows 上交付,契约与 `tool-bash-persistent` 逐项对齐:每个 Agent 一个 owner 作用域的持久 shell、标记检测的命令完成、精确的原生退出码、有界输出,以及超时/取消/`exit` 时重置 shell 并告知模型的语义。三块交付:`subprocess-local` 的 Windows 基座、`terminal-bash` 的 shell 方言选项、新的 `tool-pwsh-persistent` 包加 minimal 预设组合行。 ### `@deepseek-ai/dsh-subprocess-local` 的 Windows 基座 @@ -22,7 +22,7 @@ harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POS `LocalTerminalHandle` 为 win32 分支,因为 node-pty 的 `kill(signal)` 会抛错("Signals not supported on windows"),其无参 kill 委托的 console-list agent 在没有父控制台时失败。拆卸经 taskkill 升级并以 shell 的启动身份作栅栏;由于被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知,句柄从 inspector 验证的消失状态结算 `done`(`settleExitIfGone`)。`signalForeground` 把 SIGINT 映射为 `\x03` Ctrl-C 输入写入(conhost 转为控制台级 CTRL_C 事件的投递方式;实测可中断运行中的命令),SIGTERM/SIGKILL 路由到 taskkill,SIGTSTP/SIGHUP 以 Windows 不可用为由拒绝。公共 `PtySignal` 集合与 seam 类型不变;映射全部留在 backend。 -### `@deepseek-ai/dsh-pty-local` 的 shell 方言 +### `@deepseek-ai/dsh-terminal-bash` 的 shell 方言 一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`,存量部署逐字节不变)。有效 `shellPath`/`shellArgs` 按方言解析(bash `/bin/bash --noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器取 `-NoLogo -NoProfile`,保留交互宿主供子 REPL)。子环境去掉 bash 专属 `PS1`/`PROMPT_COMMAND` 标记并为 pwsh 加 `NO_COLOR`。pwsh 无法从环境安装提示符,因此 backend 在启动时通过会话写入 prompt 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;`session_exit` 或 `timeout` 结算拒绝 spawn。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 sanitizer、`PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑原样复用——标记仍只是就绪信号、载荷不被消费,与 bash 路径完全一致,且没有新增模型通知通道(与当前实现对齐;延后的 BEL 事件通道保持延后)。 @@ -34,11 +34,11 @@ harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POS ### 组合 -minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell 栈:bash 行(`pty-local` + `tool-bash-persistent`)在 POSIX 挂载,pwsh 行(`shellDialect: pwsh` 的 `pty-local` + `tool-pwsh-persistent`)在 win32 挂载——每宿主恰好一个持久 shell。`windows-shell.spec` 钉死按平台的花名册;真实 Loader 组合在真实 ConPTY pwsh 上跑通整条栈。 +minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell 栈:bash 行(`terminal-bash` + `tool-bash-persistent`)在 POSIX 挂载,pwsh 行(`shellDialect: pwsh` 的 `terminal-bash` + `tool-pwsh-persistent`)在 win32 挂载——每宿主恰好一个持久 shell。`windows-shell.spec` 钉死按平台的花名册;真实 Loader 组合在真实 ConPTY pwsh 上跑通整条栈。 ### 测试 -Windows 测试面沿用 master 的豁免结构:pty-local 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 +Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 ## 备选方案 @@ -54,7 +54,7 @@ Windows 测试面沿用 master 的豁免结构:pty-local 与 subprocess-local **Windows 成为一等公民的持久 shell 宿主。** 持久 pwsh 栈在 windows-native 车道上运行并受覆盖门禁约束;一次性/持久 shell 的划分与 POSIX 镜像,预设 spec 在两种平台上都钉死每宿主恰好一个 shell 栈。 -**Windows 覆盖沿用 master 的豁免结构。** subprocess-local 与 pty-local 源码在 win32 上保持覆盖豁免、其套件保持测试排除,与 master 完全一致;Windows 代码路径经 win32 开发车道与真实 pwsh 工具套件验证,新表面的覆盖义务在 windows-native 车道上落在 `tool-pwsh-persistent`。 +**Windows 覆盖沿用 master 的豁免结构。** subprocess-local 与 terminal-bash 源码在 win32 上保持覆盖豁免、其套件保持测试排除,与 master 完全一致;Windows 代码路径经 win32 开发车道与真实 pwsh 工具套件验证,新表面的覆盖义务在 windows-native 车道上落在 `tool-pwsh-persistent`。 **Windows 就绪弱于 Linux。** 伪 pgid marker 快路径覆盖 shell 提示符,但没有提示符的子进程按静默档结算(约 3s),与 macOS 完全一致;没有精确的 stdin-wait 档。 diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index b4202c0dc3..ce37022cdf 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -142,17 +142,17 @@ describe('shipped agent presets gate both shell tools by platform', () => { const byId = new Map(rows .filter((entry): entry is Record => typeof entry === 'object' && entry !== null) .map(entry => [entry.id, entry])) - // The bash stack (pty-local + persistent-bash) mounts on POSIX only; the - // pwsh twin (pty-local with shellDialect pwsh + persistent-pwsh) mounts on + // The bash stack (terminal-bash + persistent-bash) mounts on POSIX only; the + // pwsh twin (terminal-bash with shellDialect pwsh + persistent-pwsh) mounts on // win32 only — exactly one persistent shell per host. - for (const id of ['pty-local', 'persistent-bash']) { + for (const id of ['terminal-bash', 'persistent-bash']) { expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(true) expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(false) } - for (const id of ['pty-pwsh', 'persistent-pwsh']) { + for (const id of ['terminal-pwsh', 'persistent-pwsh']) { expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(false) expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(true) } - expect(byId.get('pty-pwsh')?.config).toMatchObject({ shellDialect: 'pwsh' }) + expect(byId.get('terminal-pwsh')?.config).toMatchObject({ shellDialect: 'pwsh' }) }) }) diff --git a/packages/e2b/e2b/tests/composition.e2e.ts b/packages/e2b/e2b/tests/composition.e2e.ts index 952da5320d..b01e2fb7b8 100644 --- a/packages/e2b/e2b/tests/composition.e2e.ts +++ b/packages/e2b/e2b/tests/composition.e2e.ts @@ -97,7 +97,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { whenIdle: () => Promise.resolve(), } const backend = new BashTerminalBackend(ctx, { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'], + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'], rows: 24, cols: 80, scrollbackLines: 100, scrollbackMaxBytes: 65_536, maxReadBytes: 16_384, pollIntervalMs: 25, exactProbeAfterMs: 150, idleSilenceMs: 1_000, diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts index 81c17d9669..05fccf8596 100644 --- a/packages/shell/tool-bash-persistent/src/index.ts +++ b/packages/shell/tool-bash-persistent/src/index.ts @@ -208,7 +208,7 @@ async function respondToSessionExit( ctx: Context, shells: PersistentShells, owner: Agent, - id: PtySessionId, + id: TerminalSessionId, status: { exitCode: number | null; signal: NodeJS.Signals | null }, marker: CommandMarkers, fallback: string, @@ -319,7 +319,7 @@ async function executeCommand( // The shell may flip to exited between iterations (a fast `exit` can // settle the previous send while its exit event is still in flight); // re-observing status before the next send closes that gap. - const status = ctx.pty.list(owner).find(session => session.sessionId === id)?.status + const status = ctx.terminals.list(owner).find(session => session.sessionId === id)?.status if (status?.kind === 'exited') { return await respondToSessionExit( ctx, shells, owner, id, status, marker, fallback, fallbackTruncated, config, diff --git a/packages/pty/tool-pwsh-persistent/README.i18n.yaml b/packages/shell/tool-pwsh-persistent/README.i18n.yaml similarity index 54% rename from packages/pty/tool-pwsh-persistent/README.i18n.yaml rename to packages/shell/tool-pwsh-persistent/README.i18n.yaml index 3335ee04fc..0786399570 100644 --- a/packages/pty/tool-pwsh-persistent/README.i18n.yaml +++ b/packages/shell/tool-pwsh-persistent/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/pty/tool-pwsh-persistent/README.md -README.md: 32fcaf15d38648c347e6423c9b68753ccca91287 -README.zh.md: 8c2201657796d2d6bd39ea5d130ee246a58e17d9 +# pnpm run verify-translation-pairing --write packages/shell/tool-pwsh-persistent/README.md +README.md: 7bb66477ab7ffe52039b0c699d48c9ca761ac04c +README.zh.md: b20041b1d42908d4d1e893455d825eafa2e2f87d diff --git a/packages/pty/tool-pwsh-persistent/README.md b/packages/shell/tool-pwsh-persistent/README.md similarity index 82% rename from packages/pty/tool-pwsh-persistent/README.md rename to packages/shell/tool-pwsh-persistent/README.md index 32fcaf15d3..7bb66477ab 100644 --- a/packages/pty/tool-pwsh-persistent/README.md +++ b/packages/shell/tool-pwsh-persistent/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Model-facing `pwsh(command)` backed by one owner-scoped `ctx.pty` shell. The package owns the tool contract and shell reuse; deployments select the PTY backend (a `pty-local` instance configured with `shellDialect: pwsh`) and sandbox policy. It is the Windows counterpart of `tool-bash-persistent`: same persistent-state contract, PowerShell dialect. +Model-facing `pwsh(command)` backed by one owner-scoped `ctx.terminals` shell. The package owns the tool contract and shell reuse; deployments select the terminal backend (a `terminal-bash` instance configured with `shellDialect: pwsh`) and sandbox policy. It is the Windows counterpart of `tool-bash-persistent`: same persistent-state contract, PowerShell dialect. ## Config | Key | Default | Meaning | |---|---:|---| -| `backendType` | `shell` | Registered PTY backend used for each Agent shell. | +| `backendType` | `shell` | Registered terminal backend used for each Agent shell. | | `timeoutMs` | `300000` | Wall-clock limit for one command; timeout closes the shell. | | `maxOutputChars` | `16000` | Maximum retained command-output characters; fixed diagnostics are added afterward. | | `description` | Persistent-shell description | Model-facing environment contract. | @@ -33,7 +33,7 @@ Prefix-stable while the configured description and schema remain unchanged. #### What the model sees -Commands share one shell per Agent, so cwd, `$env:` variables, functions, and background jobs persist across calls. Results exclude private completion markers, the shell prompt, and the echoed input line (PSReadLine renders submitted input back into the stream; the marker-anchored extraction and the wrapper-source strip remove it). A nonzero wrapped command appends `[exit code: N]` — the exact native exit code when the command ran a native program, `1` for a terminating PowerShell error. A shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither (Windows forced termination reports exit 1 without a signal), then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice; if the PTY has already dropped that prefix, the result says so explicitly. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset. +Commands share one shell per Agent, so cwd, `$env:` variables, functions, and background jobs persist across calls. Results exclude private completion markers, the shell prompt, and the echoed input line (PSReadLine renders submitted input back into the stream; the marker-anchored extraction and the wrapper-source strip remove it). A nonzero wrapped command appends `[exit code: N]` — the exact native exit code when the command ran a native program, `1` for a terminating PowerShell error. A shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither (Windows forced termination reports exit 1 without a signal), then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice; if the terminal has already dropped that prefix, the result says so explicitly. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset. #### Token effect @@ -45,7 +45,7 @@ Append-only tool results follow the reusable request prefix. ## Known Limitations and Deferred Work -- The tool requires an owning Agent and a real PTY backend with a pwsh dialect (Windows ConPTY or a POSIX pwsh). +- The tool requires an owning Agent and a real terminal backend with a pwsh dialect (Windows ConPTY or a POSIX pwsh). - **Input echo is unavoidable**: PowerShell's PSReadLine renders submitted input back into the terminal stream, and there is no `stty -echo` equivalent. The marker-anchored extraction excludes the echo in complete results; the wrapper-source strip covers fallback paths, but a wrapper that wraps across the terminal width may leave a partial echo in partial-output results, bounded by `maxOutputChars`. - Raw ESC characters inside model commands are unsupported: PSReadLine consumes them before execution. The wrapper escapes the control bytes it needs (`[char]27`-built OSC markers, backtick escapes for the body). - A model redefinition of the `prompt` function removes the readiness marker; the shell then settles on the silence tier instead of the marker fast path. diff --git a/packages/pty/tool-pwsh-persistent/README.zh.md b/packages/shell/tool-pwsh-persistent/README.zh.md similarity index 88% rename from packages/pty/tool-pwsh-persistent/README.zh.md rename to packages/shell/tool-pwsh-persistent/README.zh.md index 8c22016577..b20041b1d4 100644 --- a/packages/pty/tool-pwsh-persistent/README.zh.md +++ b/packages/shell/tool-pwsh-persistent/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -模型侧 `pwsh(command)`,由一个 owner 作用域的 `ctx.pty` shell 支撑。本包拥有工具契约与 shell 复用;部署方选择 PTY backend(配置 `shellDialect: pwsh` 的 `pty-local` 实例)与沙箱策略。它是 `tool-bash-persistent` 的 Windows 对应物:相同的持久状态契约,PowerShell 方言。 +模型侧 `pwsh(command)`,由一个 owner 作用域的 `ctx.terminals` shell 支撑。本包拥有工具契约与 shell 复用;部署方选择 terminal backend(配置 `shellDialect: pwsh` 的 `terminal-bash` 实例)与沙箱策略。它是 `tool-bash-persistent` 的 Windows 对应物:相同的持久状态契约,PowerShell 方言。 ## 配置 | 键 | 默认值 | 含义 | |---|---:|---| -| `backendType` | `shell` | 每个 Agent shell 使用的已注册 PTY backend。 | +| `backendType` | `shell` | 每个 Agent shell 使用的已注册 terminal backend。 | | `timeoutMs` | `300000` | 单条命令的墙钟上限;超时关闭 shell。 | | `maxOutputChars` | `16000` | 保留的命令输出字符上限;固定诊断文本在其后追加。 | | `description` | 持久 shell 描述 | 模型可见的环境契约。 | @@ -45,7 +45,7 @@ ## 已知限制与延后工作 -- 工具需要拥有 Agent 与一个真实支持 pwsh 方言的 PTY backend(Windows ConPTY 或 POSIX 上的 pwsh)。 +- 工具需要拥有 Agent 与一个真实支持 pwsh 方言的 terminal backend(Windows ConPTY 或 POSIX 上的 pwsh)。 - **输入回显不可避免**:PowerShell 的 PSReadLine 会把提交的输入渲染回终端流,且没有 `stty -echo` 的对应物。完整结果中 marker 锚定提取排除回显;包装器原文剥离覆盖回退路径,但跨越终端宽度的包装器折行可能在部分输出结果中残留片段回显,受 `maxOutputChars` 约束。 - 模型命令中的裸 ESC 字符不受支持:PSReadLine 会在执行前吞掉它们。包装器转义它需要的控制字节(`[char]27` 构造的 OSC 标记、body 的反引号转义)。 - 模型重定义 `prompt` 函数会移除就绪标记;shell 随后退化为静默档而非 marker 快路径。 diff --git a/packages/pty/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json similarity index 90% rename from packages/pty/tool-pwsh-persistent/package.json rename to packages/shell/tool-pwsh-persistent/package.json index 1cf2e224d0..5875376802 100644 --- a/packages/pty/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/pty/tool-pwsh-persistent" + "directory": "packages/shell/tool-pwsh-persistent" }, "type": "module", "main": "lib/index.js", @@ -33,7 +33,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -47,8 +47,8 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-pty": "workspace:^", - "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-terminal": "workspace:^", + "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/pty/tool-pwsh-persistent/src/index.ts b/packages/shell/tool-pwsh-persistent/src/index.ts similarity index 92% rename from packages/pty/tool-pwsh-persistent/src/index.ts rename to packages/shell/tool-pwsh-persistent/src/index.ts index e456f47719..cce7ddd001 100644 --- a/packages/pty/tool-pwsh-persistent/src/index.ts +++ b/packages/shell/tool-pwsh-persistent/src/index.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { PtyReadResult, PtySendResult, PtySessionId } from '@deepseek-ai/dsh-pty' +import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -48,7 +48,7 @@ interface CapturedOutput { } interface PersistentShells { - get(owner: Agent, signal: AbortSignal): Promise + get(owner: Agent, signal: AbortSignal): Promise reset(owner: Agent, reason: string): Promise } @@ -128,7 +128,7 @@ function commandOutput( } } -function promptCompleted(result: PtySendResult): boolean { +function promptCompleted(result: TerminalSendResult): boolean { return result.viewport.endsWith(SHELL_PROMPT) || result.viewport.endsWith(`${SHELL_PROMPT}\r\n`) || result.viewport.endsWith(`${SHELL_PROMPT}\n`) @@ -164,7 +164,7 @@ async function pause(): Promise { await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) } -function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined { +function nextScrollbackOffset(page: TerminalReadResult, offset: number): number | undefined { if (page.text.length === 0 || page.lineEnd <= offset) return undefined return page.lineEnd } @@ -172,15 +172,15 @@ function nextScrollbackOffset(page: PtyReadResult, offset: number): number | und function retainedScrollback( ctx: Context, owner: Agent, - id: PtySessionId, - latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }), + id: TerminalSessionId, + latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }), ): RetainedOutput { const pages: string[] = latest.text.length === 0 ? [] : [latest.text] let offset = latest.lineEnd let truncated = latest.truncated while (true) { if (offset >= latest.totalLines) break - const page = ctx.pty.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES }) + const page = ctx.terminals.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES }) truncated ||= page.truncated if (page.text.length > 0) pages.unshift(page.text) const next = nextScrollbackOffset(page, offset) @@ -230,7 +230,7 @@ async function respondToSessionExit( ctx: Context, shells: PersistentShells, owner: Agent, - id: PtySessionId, + id: TerminalSessionId, status: { exitCode: number | null; signal: NodeJS.Signals | null }, marker: CommandMarkers, wrapped: string, @@ -260,15 +260,15 @@ const PWSH_PROMPT_SETUP = "function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + SHELL_PROMPT + "' }" function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { - const pending = new WeakMap>() - const live = new Map() - const creating = new Set>() + const pending = new WeakMap>() + const live = new Map() + const creating = new Set>() const ownerCleanupInstalled = new WeakSet() const lifecycle = new AbortController() - const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => { - if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return - await ctx.pty.kill(owner, id, reason) + const close = async (owner: Agent, id: TerminalSessionId, reason: string): Promise => { + if (!ctx.terminals.list(owner).some(snapshot => snapshot.sessionId === id)) return + await ctx.terminals.kill(owner, id, reason) } ctx.effect(() => async () => { @@ -286,14 +286,14 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell if (id !== undefined) await close(owner, id, reason) } - const get = (owner: Agent, signal: AbortSignal): Promise => { + const get = (owner: Agent, signal: AbortSignal): Promise => { const existing = pending.get(owner) if (existing !== undefined) return existing const combinedSignal = AbortSignal.any([signal, lifecycle.signal]) const creation = (async () => { try { const cwd = owner.session.header.cwd - const spawned = await ctx.pty.spawn(owner, { + const spawned = await ctx.terminals.spawn(owner, { type: config.backendType, ...cwd === undefined ? {} : { cwd }, }, combinedSignal) @@ -305,7 +305,7 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell live.delete(owner) }, 'tool-pwsh-persistent owner cache cleanup') } - const setup = ctx.pty.startSend(owner, spawned.sessionId, { + const setup = ctx.terminals.startSend(owner, spawned.sessionId, { text: PWSH_PROMPT_SETUP, submit: true, signal: combinedSignal, @@ -352,7 +352,7 @@ async function executeCommand( // settle the previous send while its exit event is still in flight, and // the echoed wrapper can then carry a marker end without status digits); // re-observing status before the next send closes that gap. - const status = ctx.pty.list(owner).find(session => session.sessionId === id)?.status + const status = ctx.terminals.list(owner).find(session => session.sessionId === id)?.status if (status?.kind === 'exited') { return await respondToSessionExit( ctx, shells, owner, id, status, marker, wrapped, fallback, fallbackTruncated, config, @@ -361,7 +361,7 @@ async function executeCommand( let operation let result try { - operation = ctx.pty.startSend(owner, id, { + operation = ctx.terminals.startSend(owner, id, { text: first ? wrapped : '', submit: first, signal: commandDeadline.signal, @@ -375,7 +375,7 @@ async function executeCommand( const incremental = operation.readOutput() fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport fallbackTruncated ||= incremental.truncated || result.truncated - const latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }) + const latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }) const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE) if (timedOut !== undefined) { const snapshot = retainedScrollback(ctx, owner, id, latest) @@ -464,7 +464,7 @@ function registerPersistentPwsh(ctx: Context, config: ResolvedConfig): void { } export const name = 'tool-pwsh-persistent' -export const inject = ['tools', 'pty'] +export const inject = ['tools', 'terminals'] /** Configuration for the persistent pwsh tool. */ export interface Config { diff --git a/packages/pty/tool-pwsh-persistent/src/invariant.ts b/packages/shell/tool-pwsh-persistent/src/invariant.ts similarity index 100% rename from packages/pty/tool-pwsh-persistent/src/invariant.ts rename to packages/shell/tool-pwsh-persistent/src/invariant.ts diff --git a/packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts similarity index 95% rename from packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts rename to packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index fc10f9cfe3..1a95d7fe23 100644 --- a/packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -11,8 +11,8 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import PtyService from '@deepseek-ai/dsh-pty' -import * as PtyLocal from '@deepseek-ai/dsh-pty-local' +import TerminalSessionService from '@deepseek-ai/dsh-terminal' +import * as TerminalBash from '@deepseek-ai/dsh-terminal-bash' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' @@ -78,14 +78,14 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp "- name: '@deepseek-ai/dsh-agent'", "- name: '@deepseek-ai/dsh-system-prompt'", "- name: '@deepseek-ai/dsh-tools'", - "- name: '@deepseek-ai/dsh-pty'", + "- name: '@deepseek-ai/dsh-terminal'", "- name: '@deepseek-ai/dsh-test-sandbox'", "- name: '@deepseek-ai/dsh-sandbox-policy'", ' config:', ' mode: danger-full-access', ` workspaceRoot: ${JSON.stringify(root)}`, "- name: '@deepseek-ai/dsh-subprocess-local'", - "- name: '@deepseek-ai/dsh-pty-local'", + "- name: '@deepseek-ai/dsh-terminal-bash'", ' config:', ' shellDialect: pwsh', ' pollIntervalMs: 10', @@ -109,11 +109,11 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRegistry], - ['@deepseek-ai/dsh-pty', PtyService], + ['@deepseek-ai/dsh-terminal', TerminalSessionService], ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox], ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService], ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessService], - ['@deepseek-ai/dsh-pty-local', PtyLocal], + ['@deepseek-ai/dsh-terminal-bash', TerminalBash], ['@deepseek-ai/dsh-tool-pwsh-persistent', ToolPwshPersistent], ]) context.loader.internal = { diff --git a/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts similarity index 94% rename from packages/pty/tool-pwsh-persistent/tests/tools.spec.ts rename to packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index 1f4a480c38..6a856b1ff8 100644 --- a/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -4,17 +4,17 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import PtyService from '@deepseek-ai/dsh-pty' +import TerminalSessionService from '@deepseek-ai/dsh-terminal' import type { - PtyBackend, - PtyBackendSession, - PtyReadRequest, - PtySendOperation, - PtySendRequest, - PtySessionStatus, - PtySignal, - PtyWaitReason, -} from '@deepseek-ai/dsh-pty' + TerminalBackend, + TerminalBackendSession, + TerminalReadRequest, + TerminalSendOperation, + TerminalSendRequest, + TerminalSessionStatus, + TerminalSignal, + TerminalWaitReason, +} from '@deepseek-ai/dsh-terminal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' @@ -104,10 +104,10 @@ type StubMode = const START_PATTERN = /__DSH_PERSISTENT_PWSH_START_[^_]+(?:-[^_]+)*__/ const END_PATTERN = /__DSH_PERSISTENT_PWSH_END_[^:]+:/ -class StubPtySession implements PtyBackendSession { +class StubTerminalSession implements TerminalBackendSession { readonly motd = '__DSH_PERSISTENT_PWSH_PROMPT__ ' readonly pid = 123 - statusValue: PtySessionStatus = { kind: 'running' } + statusValue: TerminalSessionStatus = { kind: 'running' } scrollback = this.motd closed: string[] = [] mode: StubMode @@ -120,7 +120,7 @@ class StubPtySession implements PtyBackendSession { this.mode = mode } - startSend(request: PtySendRequest): PtySendOperation { + startSend(request: TerminalSendRequest): TerminalSendOperation { this.sends += 1 if (request.text.startsWith('function prompt')) { if (this.mode === 'init-exit') { @@ -135,7 +135,7 @@ class StubPtySession implements PtyBackendSession { if (this.mode === 'send-error') throw new Error('stub send failed') if (this.throwOnSend) throw new Error('PTY session has exited') if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') { - const done = new Promise>((resolve) => { + const done = new Promise>((resolve) => { request.signal?.addEventListener('abort', () => { const start = START_PATTERN.exec(request.text)?.[0] const end = END_PATTERN.exec(request.text)?.[0] @@ -232,7 +232,7 @@ class StubPtySession implements PtyBackendSession { return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) } - read(request: PtyReadRequest) { + read(request: TerminalReadRequest) { if (this.mode === 'empty-read') { return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false } } @@ -265,7 +265,7 @@ class StubPtySession implements PtyBackendSession { } } - signal(_signal: PtySignal) { + signal(_signal: TerminalSignal) { return Promise.resolve({ delivered: true as const, targetPgid: 123 }) } @@ -278,11 +278,11 @@ class StubPtySession implements PtyBackendSession { this.statusValue = { kind: 'exited', exitCode: 0, signal: null } } - private result(viewport: string, waitReason: PtyWaitReason) { + private result(viewport: string, waitReason: TerminalWaitReason) { return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false } } - private operation(done: Promise>, delta = ''): PtySendOperation { + private operation(done: Promise>, delta = ''): TerminalSendOperation { return { done, readOutput: () => ({ delta, truncated: false }), @@ -292,12 +292,12 @@ class StubPtySession implements PtyBackendSession { } function stubBackend(initialMode: StubMode = 'normal') { - const sessions: StubPtySession[] = [] - const backend: PtyBackend = { + const sessions: StubTerminalSession[] = [] + const backend: TerminalBackend = { type: 'stub', async spawn() { if (initialMode === 'spawn-error') throw new Error('stub spawn failed') - const session = new StubPtySession(initialMode) + const session = new StubTerminalSession(initialMode) sessions.push(session) return session }, @@ -314,9 +314,9 @@ async function setup( await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(PtyService) + await ctx.plugin(TerminalSessionService) const stub = stubBackend(initialMode) - ctx.pty.registerBackend(stub.backend) + ctx.terminals.registerBackend(stub.backend) const fiber = await ctx.plugin(ToolPwshPersistent, config) return { ctx, stub, fiber, owner: agent(ctx, '/workspace') } } @@ -433,9 +433,9 @@ describe('tool-pwsh-persistent', () => { await call(ctx, owner, 'another shell') expect(stub.sessions).toHaveLength(3) - const externallyClosed = ctx.pty.list(owner)[0]?.sessionId + const externallyClosed = ctx.terminals.list(owner)[0]?.sessionId expect(externallyClosed).toBeDefined() - await ctx.pty.kill(owner, externallyClosed!, 'external cleanup') + await ctx.terminals.kill(owner, externallyClosed!, 'external cleanup') await fiber.dispose() expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) }) @@ -572,10 +572,10 @@ describe('tool-pwsh-persistent', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(PtyService) + await ctx.plugin(TerminalSessionService) const spawnStarted = Promise.withResolvers() const spawnAborted = Promise.withResolvers() - ctx.pty.registerBackend({ + ctx.terminals.registerBackend({ type: 'slow', spawn: spec => new Promise((_resolve, reject) => { spawnStarted.resolve(undefined) @@ -595,7 +595,7 @@ describe('tool-pwsh-persistent', () => { await fiber.dispose() await spawnAborted.promise expect((await running).isError).toBe(true) - expect(ctx.pty.list(owner)).toEqual([]) + expect(ctx.terminals.list(owner)).toEqual([]) }) it('rejects invalid config and invalid calls', async () => { diff --git a/packages/pty/tool-pwsh-persistent/tsconfig.json b/packages/shell/tool-pwsh-persistent/tsconfig.json similarity index 78% rename from packages/pty/tool-pwsh-persistent/tsconfig.json rename to packages/shell/tool-pwsh-persistent/tsconfig.json index 57c13a61c2..42ce584d6a 100644 --- a/packages/pty/tool-pwsh-persistent/tsconfig.json +++ b/packages/shell/tool-pwsh-persistent/tsconfig.json @@ -10,8 +10,8 @@ { "path": "../../../vendor/schemastery" }, { "path": "../../core/agent" }, { "path": "../../core/tools" }, - { "path": "../pty" }, - { "path": "../../support/invariants" }, + { "path": "../../terminal/terminal" }, + { "path": "../../runtime-diagnostics/invariants" }, { "path": "../../util/timeout" } ] } diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 40383a99d1..232ef7d984 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -259,7 +259,7 @@ const hasPwsh = spawnSync( { encoding: 'utf8' }, ).status === 0 -describe.skipIf(!hasPwsh)('pty-local pwsh real shell', () => { +describe.skipIf(!hasPwsh)('terminal-bash pwsh real shell', () => { it('bootstraps a persistent pwsh, persists state, and scrubs secrets', async () => { const previous = process.env.DSH_TEST_SECRET process.env.DSH_TEST_SECRET = 'must-not-leak' @@ -269,15 +269,15 @@ describe.skipIf(!hasPwsh)('pty-local pwsh real shell', () => { handoffGraceMs: 300, timeoutMs: 8_000, }, 'pwsh') - const created = await ctx.pty.spawn(agent, { type: 'shell', name: 'main', cwd: root }) + const created = await ctx.terminals.spawn(agent, { type: 'shell', name: 'main', cwd: root }) expect(created.motd).toContain('dsh> ') - const first = ctx.pty.startSend(agent, created.sessionId, { + const first = ctx.terminals.startSend(agent, created.sessionId, { text: '$env:KEEP = "ok"; Set-Location /', submit: true, }) expect((await first.done).waitReason).toBe('stdin_read') - const second = ctx.pty.startSend(agent, created.sessionId, { + const second = ctx.terminals.startSend(agent, created.sessionId, { text: 'Write-Output "keep=$env:KEEP secret=$env:DSH_TEST_SECRET"', submit: true, }) @@ -286,9 +286,9 @@ describe.skipIf(!hasPwsh)('pty-local pwsh real shell', () => { expect(result.viewport).toContain('secret=') expect(result.viewport).not.toContain('must-not-leak') - expect(ctx.pty.read(agent, created.sessionId, { offset: 0, count: 40 }).text).toContain('keep=ok') - expect(await ctx.pty.kill(agent, created.sessionId)).toBe(true) - expect(ctx.pty.list(agent)).toEqual([]) + expect(ctx.terminals.read(agent, created.sessionId, { offset: 0, count: 40 }).text).toContain('keep=ok') + expect(await ctx.terminals.kill(agent, created.sessionId)).toBe(true) + expect(ctx.terminals.list(agent)).toEqual([]) } finally { if (previous === undefined) delete process.env.DSH_TEST_SECRET else process.env.DSH_TEST_SECRET = previous diff --git a/packages/terminal/terminal-bash/tsconfig.json b/packages/terminal/terminal-bash/tsconfig.json index 8b8a378cdf..b42e1015be 100644 --- a/packages/terminal/terminal-bash/tsconfig.json +++ b/packages/terminal/terminal-bash/tsconfig.json @@ -18,7 +18,7 @@ "path": "../../../vendor/schemastery" }, { - "path": "../../bash/pwsh-local" + "path": "../../shell/pwsh-local" }, { "path": "../../core/agent" diff --git a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts index ced9a632f4..c22888479a 100644 --- a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import type { Worker } from 'node:worker_threads' import { Context } from '@deepseek-ai/cordis' @@ -9,6 +10,7 @@ import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRu import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRun, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import * as workerEngineModule from '../src/index.ts' import WorkerThreadWorkflowEngine, { type Config } from '../src/index.ts' +import { workerSpawnEnv } from '../src/host.ts' import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts' import { SessionId } from '@deepseek-ai/dsh-session' @@ -559,24 +561,44 @@ describe('dsh-workflow-worker-thread', () => { expect(result.value).toBe('fine') }) - it('the worker spawns with an EMPTY environment: an escaped script finds no ambient credentials', async () => { + it('the worker spawns with a scrubbed environment: an escaped script finds no ambient credentials', async () => { const { ctx, parent } = await setup() // A canary in the HARNESS process's env: with an inherited environment // the escape below would read it back (exactly how DEEPSEEK_API_KEY - // would leak); env: {} in the spawn options is what keeps it out. + // would leak); the worker env keeps every ambient variable out. Windows + // additionally receives the host temp path (TMP/TEMP) so `os.tmpdir()` + // inside the worker resolves instead of degrading to a cwd-relative + // `undefined\temp` (tsx writes its transform cache there). process.env.WORKFLOW_ENV_CANARY = 'leak me' try { const result = await run(ctx, parent, scripted(` const proc = ${ESCAPE} - return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).length } + return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).sort() } `)) expect(result.stopReason).toBe('completed') - expect(result.value).toEqual({ canary: null, keys: 0 }) + const expectedKeys = process.platform === 'win32' ? ['TEMP', 'TMP'] : [] + expect(result.value).toEqual({ canary: null, keys: expectedKeys }) } finally { delete process.env.WORKFLOW_ENV_CANARY } }) + it('workerSpawnEnv injects the host temp path on win32 and leaves the POSIX peer empty', () => { + const tmp = tmpdir() + expect(workerSpawnEnv('win32')).toEqual({ TMP: tmp, TEMP: tmp }) + expect(workerSpawnEnv('linux')).toEqual({}) + }) + + it('workerSpawnEnv forwards TSX_TSCONFIG_PATH when the snapshot harness pins it', () => { + const tsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + expect(workerSpawnEnv('linux', tsconfig)).toEqual({ TSX_TSCONFIG_PATH: tsconfig }) + expect(workerSpawnEnv('win32', tsconfig)).toEqual({ + TMP: tmpdir(), + TEMP: tmpdir(), + TSX_TSCONFIG_PATH: tsconfig, + }) + }) + it('the unbuilt worker forwards exactly TSX_TSCONFIG_PATH through the scrub: the paths-map pin survives, secrets do not', async () => { const { ctx, parent } = await setup() // The ACP snapshot harness runs the parent with its cwd OUTSIDE the @@ -589,10 +611,13 @@ describe('dsh-workflow-worker-thread', () => { try { const result = await run(ctx, parent, scripted(` const proc = ${ESCAPE} - return { keys: Object.keys(proc.env), tsconfig: proc.env.TSX_TSCONFIG_PATH } + return { keys: Object.keys(proc.env).sort(), tsconfig: proc.env.TSX_TSCONFIG_PATH } `)) expect(result.stopReason).toBe('completed') - expect(result.value).toEqual({ keys: ['TSX_TSCONFIG_PATH'], tsconfig }) + const expectedKeys = process.platform === 'win32' + ? ['TEMP', 'TMP', 'TSX_TSCONFIG_PATH'] + : ['TSX_TSCONFIG_PATH'] + expect(result.value).toEqual({ keys: expectedKeys, tsconfig }) } finally { delete process.env.TSX_TSCONFIG_PATH delete process.env.WORKFLOW_ENV_CANARY diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b21718dde1..ecf3116f2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../vendor/timer + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:^ + version: link:../../packages/context/agent-instructions '@deepseek-ai/dsh-agent-tool-presentation': specifier: workspace:^ version: link:../../packages/core/agent-tool-presentation @@ -165,9 +168,6 @@ importers: '@deepseek-ai/dsh-compaction-tool-result-pruner': specifier: workspace:^ version: link:../../packages/compaction/compaction-tool-result-pruner - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../packages/util/launch-environment '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../packages/fs/fs-local @@ -180,30 +180,33 @@ importers: '@deepseek-ai/dsh-headless': specifier: workspace:^ version: link:../../packages/bundle/headless - '@deepseek-ai/dsh-mcp-client': - specifier: workspace:^ - version: link:../../packages/mcp/mcp-client '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../packages/util/home-paths + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../packages/jobs/jobs-local + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../packages/util/launch-environment + '@deepseek-ai/dsh-mcp-client': + specifier: workspace:^ + version: link:../../packages/mcp/mcp-client '@deepseek-ai/dsh-persona': specifier: workspace:^ version: link:../../packages/preset/persona '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode - '@deepseek-ai/dsh-terminal': - specifier: workspace:^ - version: link:../../packages/terminal/terminal - '@deepseek-ai/dsh-terminal-bash': - specifier: workspace:^ - version: link:../../packages/terminal/terminal-bash '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../packages/shell/pwsh-local '@deepseek-ai/dsh-pwsh-sandbox': specifier: workspace:^ version: link:../../packages/shell/pwsh-sandbox + '@deepseek-ai/dsh-schedule': + specifier: workspace:^ + version: link:../../packages/schedule/schedule '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../packages/session/session-projection @@ -216,9 +219,12 @@ importers: '@deepseek-ai/dsh-skill-filesystem': specifier: workspace:^ version: link:../../packages/skill/skill-filesystem - '@deepseek-ai/dsh-jobs-local': + '@deepseek-ai/dsh-terminal': specifier: workspace:^ - version: link:../../packages/jobs/jobs-local + version: link:../../packages/terminal/terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:^ + version: link:../../packages/terminal/terminal-bash '@deepseek-ai/dsh-time-context': specifier: workspace:^ version: link:../../packages/context/time-context @@ -249,15 +255,18 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../packages/jobs/tool-jobs '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:^ version: link:../../packages/shell/tool-pwsh + '@deepseek-ai/dsh-tool-pwsh-persistent': + specifier: workspace:^ + version: link:../../packages/shell/tool-pwsh-persistent '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph - '@deepseek-ai/dsh-schedule': - specifier: workspace:^ - version: link:../../packages/schedule/schedule '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill @@ -270,9 +279,6 @@ importers: '@deepseek-ai/dsh-tool-subagent-control': specifier: workspace:^ version: link:../../packages/subagent/tool-subagent-control - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../packages/jobs/tool-jobs '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../packages/todo/tool-todo @@ -288,9 +294,6 @@ importers: '@deepseek-ai/dsh-workflow-worker-thread': specifier: workspace:^ version: link:../../packages/workflow/workflow-worker-thread - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:^ - version: link:../../packages/context/agent-instructions commander: specifier: ^15.0.0 version: 15.0.0 @@ -304,12 +307,12 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../packages/core/agent - '@deepseek-ai/dsh-host-frontend-static': - specifier: workspace:^ - version: link:../../packages/host/frontend-static '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy + '@deepseek-ai/dsh-host-frontend-static': + specifier: workspace:^ + version: link:../../packages/host/frontend-static '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver @@ -425,6 +428,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:* version: link:../packages/core/agent + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:* + version: link:../packages/context/agent-instructions '@deepseek-ai/dsh-agent-loop': specifier: workspace:* version: link:../packages/core/agent-loop @@ -437,12 +443,6 @@ importers: '@deepseek-ai/dsh-attachment-local': specifier: workspace:* version: link:../packages/attachment/attachment-local - '@deepseek-ai/dsh-shell': - specifier: workspace:* - version: link:../packages/shell/shell - '@deepseek-ai/dsh-shell-env': - specifier: workspace:* - version: link:../packages/shell/shell-env '@deepseek-ai/dsh-bash-local': specifier: workspace:* version: link:../packages/shell/bash-local @@ -503,9 +503,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:* version: link:../packages/runtime-diagnostics/invariants - '@deepseek-ai/dsh-sdk-jsonrpc-server': + '@deepseek-ai/dsh-jobs-local': specifier: workspace:* - version: link:../packages/sdk/server + version: link:../packages/jobs/jobs-local '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../packages/llm/llm @@ -533,12 +533,6 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:* version: link:../packages/plan/plan-mode - '@deepseek-ai/dsh-terminal': - specifier: workspace:* - version: link:../packages/terminal/terminal - '@deepseek-ai/dsh-terminal-bash': - specifier: workspace:* - version: link:../packages/terminal/terminal-bash '@deepseek-ai/dsh-pwsh-local': specifier: workspace:* version: link:../packages/shell/pwsh-local @@ -557,6 +551,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:* version: link:../packages/core/scope + '@deepseek-ai/dsh-sdk-jsonrpc-server': + specifier: workspace:* + version: link:../packages/sdk/server '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../packages/core/session @@ -590,6 +587,12 @@ importers: '@deepseek-ai/dsh-settings-file': specifier: workspace:* version: link:../packages/settings/settings-file + '@deepseek-ai/dsh-shell': + specifier: workspace:* + version: link:../packages/shell/shell + '@deepseek-ai/dsh-shell-env': + specifier: workspace:* + version: link:../packages/shell/shell-env '@deepseek-ai/dsh-skill': specifier: workspace:* version: link:../packages/skill/skill @@ -632,15 +635,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../packages/core/system-prompt - '@deepseek-ai/dsh-jobs-local': + '@deepseek-ai/dsh-terminal': specifier: workspace:* - version: link:../packages/jobs/jobs-local + version: link:../packages/terminal/terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:* + version: link:../packages/terminal/terminal-bash '@deepseek-ai/dsh-time-context': specifier: workspace:* version: link:../packages/context/time-context - '@deepseek-ai/dsh-tool-call-timeout-policy': - specifier: workspace:* - version: link:../packages/guard/timeout-policy '@deepseek-ai/dsh-token-meter': specifier: workspace:* version: link:../packages/llm/token-meter @@ -653,6 +656,9 @@ importers: '@deepseek-ai/dsh-tool-bash-persistent': specifier: workspace:* version: link:../packages/shell/tool-bash-persistent + '@deepseek-ai/dsh-tool-call-timeout-policy': + specifier: workspace:* + version: link:../packages/guard/timeout-policy '@deepseek-ai/dsh-tool-cordis': specifier: workspace:* version: link:../packages/extensions/tool-cordis @@ -665,12 +671,12 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:* version: link:../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:* + version: link:../packages/jobs/tool-jobs '@deepseek-ai/dsh-tool-lsp': specifier: workspace:* version: link:../packages/lsp/tool-lsp - '@deepseek-ai/dsh-tool-terminal': - specifier: workspace:* - version: link:../packages/terminal/tool-terminal '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:* version: link:../packages/shell/tool-pwsh @@ -695,9 +701,9 @@ importers: '@deepseek-ai/dsh-tool-subagent-report': specifier: workspace:* version: link:../packages/subagent/tool-subagent-report - '@deepseek-ai/dsh-tool-jobs': + '@deepseek-ai/dsh-tool-terminal': specifier: workspace:* - version: link:../packages/jobs/tool-jobs + version: link:../packages/terminal/tool-terminal '@deepseek-ai/dsh-tool-todo': specifier: workspace:* version: link:../packages/todo/tool-todo @@ -725,9 +731,6 @@ importers: '@deepseek-ai/dsh-workflow-worker-thread': specifier: workspace:* version: link:../packages/workflow/workflow-worker-thread - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:* - version: link:../packages/context/agent-instructions native/landlock-run: devDependencies: @@ -898,299 +901,12 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../attachment - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths - - packages/shell/shell: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings - '@deepseek-ai/dsh-subprocess': - specifier: workspace:^ - version: link:../../subprocess/subprocess - - packages/shell/shell-env: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../../session/session-persistence - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - - packages/shell/bash-local: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings - '@deepseek-ai/dsh-subprocess': - specifier: workspace:^ - version: link:../../subprocess/subprocess - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout - - packages/shell/bash-sandbox: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-bash-local': - specifier: workspace:^ - version: link:../bash-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-sandbox-local': - specifier: workspace:^ - version: link:../../sandbox/sandbox-local - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/node-addon-landlock-run': - specifier: workspace:* - version: link:../../../native/landlock-run/packages/entry - - packages/shell/pwsh-local: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings - '@deepseek-ai/dsh-subprocess': - specifier: workspace:^ - version: link:../../subprocess/subprocess - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout - - packages/shell/pwsh-sandbox: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-pwsh-local': - specifier: workspace:^ - version: link:../pwsh-local - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-sandbox-local': - specifier: workspace:^ - version: link:../../sandbox/sandbox-local - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - - packages/shell/tool-bash: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-agent-loop-testkit': - specifier: workspace:^ - version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../shell-env - '@deepseek-ai/dsh-bash-local': - specifier: workspace:^ - version: link:../bash-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../../session/session-persistence-jsonl - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../jobs/tool-jobs - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../interaction/user-approval - - packages/shell/tool-pwsh: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../shell-env - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../test-support/loader-smoke - '@deepseek-ai/dsh-pwsh-local': - specifier: workspace:^ - version: link:../pwsh-local - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../jobs/tool-jobs - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../interaction/user-approval packages/boot/app-boot: dependencies: @@ -1216,15 +932,15 @@ importers: '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -1264,6 +980,9 @@ importers: '@deepseek-ai/dsh-agent-default-model': specifier: workspace:^ version: link:../../core/agent-default-model + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:^ + version: link:../../context/agent-instructions '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -1273,9 +992,6 @@ importers: '@deepseek-ai/dsh-attachment-local': specifier: workspace:^ version: link:../../attachment/attachment-local - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../../shell/shell-env '@deepseek-ai/dsh-bash-sandbox': specifier: workspace:^ version: link:../../shell/bash-sandbox @@ -1315,6 +1031,9 @@ importers: '@deepseek-ai/dsh-goal-round-driver': specifier: workspace:^ version: link:../../goal/goal-round-driver + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1372,6 +1091,9 @@ importers: '@deepseek-ai/dsh-settings-file': specifier: workspace:^ version: link:../../settings/settings-file + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../../shell/shell-env '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../skill/skill @@ -1408,18 +1130,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local - '@deepseek-ai/dsh-tool-call-timeout-policy': - specifier: workspace:^ - version: link:../../guard/timeout-policy '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../shell/tool-bash + '@deepseek-ai/dsh-tool-call-timeout-policy': + specifier: workspace:^ + version: link:../../guard/timeout-policy '@deepseek-ai/dsh-tool-fs': specifier: workspace:^ version: link:../../fs/tool-fs @@ -1429,6 +1148,9 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../goal/tool-goal + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../jobs/tool-jobs '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:^ version: link:../../shell/tool-pwsh @@ -1450,9 +1172,6 @@ importers: '@deepseek-ai/dsh-tool-subagent-report': specifier: workspace:^ version: link:../../subagent/tool-subagent-report - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../jobs/tool-jobs '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../todo/tool-todo @@ -1486,9 +1205,6 @@ importers: '@deepseek-ai/dsh-workflow-worker-thread': specifier: workspace:^ version: link:../../workflow/workflow-worker-thread - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:^ - version: link:../../context/agent-instructions devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1578,57 +1294,54 @@ importers: '@deepseek-ai/dsh-client-ui-directory-picker-native': specifier: workspace:^ version: link:../../client/ui-directory-picker-native - '@deepseek-ai/dsh-client-ui-message-feedback': - specifier: workspace:^ - version: link:../../client/ui-message-feedback '@deepseek-ai/dsh-client-ui-goal': specifier: workspace:^ version: link:../../client/ui-goal + '@deepseek-ai/dsh-client-ui-input-trigger': + specifier: workspace:^ + version: link:../../client/ui-input-trigger + '@deepseek-ai/dsh-client-ui-jobs': + specifier: workspace:^ + version: link:../../client/ui-jobs '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../../client/ui-layout + '@deepseek-ai/dsh-client-ui-message-feedback': + specifier: workspace:^ + version: link:../../client/ui-message-feedback '@deepseek-ai/dsh-client-ui-model-selection': specifier: workspace:^ version: link:../../client/ui-model-selection - '@deepseek-ai/dsh-client-ui-settings-models': - specifier: workspace:^ - version: link:../../client/ui-settings-models '@deepseek-ai/dsh-client-ui-permission-presets': specifier: workspace:^ version: link:../../client/ui-permission-presets '@deepseek-ai/dsh-client-ui-plan': specifier: workspace:^ version: link:../../client/ui-plan - '@deepseek-ai/dsh-client-ui-settings-plugins': - specifier: workspace:^ - version: link:../../client/ui-settings-plugins - '@deepseek-ai/dsh-client-ui-settings-plugin-inventory': - specifier: workspace:^ - version: link:../../client/ui-settings-plugin-inventory - '@deepseek-ai/dsh-client-ui-user-questions': - specifier: workspace:^ - version: link:../../client/ui-user-questions '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../../client/ui-settings '@deepseek-ai/dsh-client-ui-settings-general': specifier: workspace:^ version: link:../../client/ui-settings-general + '@deepseek-ai/dsh-client-ui-settings-models': + specifier: workspace:^ + version: link:../../client/ui-settings-models + '@deepseek-ai/dsh-client-ui-settings-plugin-inventory': + specifier: workspace:^ + version: link:../../client/ui-settings-plugin-inventory + '@deepseek-ai/dsh-client-ui-settings-plugins': + specifier: workspace:^ + version: link:../../client/ui-settings-plugins '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../client/ui-sidebar '@deepseek-ai/dsh-client-ui-skill': specifier: workspace:^ version: link:../../client/ui-skill - '@deepseek-ai/dsh-client-ui-input-trigger': - specifier: workspace:^ - version: link:../../client/ui-input-trigger '@deepseek-ai/dsh-client-ui-subagent': specifier: workspace:^ version: link:../../client/ui-subagent - '@deepseek-ai/dsh-client-ui-jobs': - specifier: workspace:^ - version: link:../../client/ui-jobs '@deepseek-ai/dsh-client-ui-theme': specifier: workspace:^ version: link:../../client/ui-theme @@ -1638,6 +1351,9 @@ importers: '@deepseek-ai/dsh-client-ui-trajectory': specifier: workspace:^ version: link:../../client/ui-trajectory + '@deepseek-ai/dsh-client-ui-user-questions': + specifier: workspace:^ + version: link:../../client/ui-user-questions '@deepseek-ai/dsh-client-ui-workflow-run': specifier: workspace:^ version: link:../../client/ui-workflow-run @@ -1650,12 +1366,6 @@ importers: '@deepseek-ai/dsh-code-runtime-worker-thread': specifier: workspace:^ version: link:../../code-runtime/code-runtime-worker-thread - '@deepseek-ai/dsh-web-frontend': - specifier: workspace:^ - version: link:../../../apps/web - '@deepseek-ai/dsh-host-frontend-static': - specifier: workspace:^ - version: link:../../host/frontend-static '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy @@ -1668,6 +1378,9 @@ importers: '@deepseek-ai/dsh-host-directory-picker-native': specifier: workspace:^ version: link:../../host/directory-picker-native + '@deepseek-ai/dsh-host-frontend-static': + specifier: workspace:^ + version: link:../../host/frontend-static '@deepseek-ai/dsh-host-plugin-inventory': specifier: workspace:^ version: link:../../host/plugin-inventory @@ -1695,6 +1408,9 @@ importers: '@deepseek-ai/dsh-storage-json': specifier: workspace:^ version: link:../../storage/storage-json + '@deepseek-ai/dsh-web-frontend': + specifier: workspace:^ + version: link:../../../apps/web '@deepseek-ai/dsh-workspace': specifier: workspace:^ version: link:../../workspace/workspace @@ -1711,12 +1427,12 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../../shell/shell-env '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../../shell/shell-env '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -1921,49 +1637,6 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - packages/test-support/client-runtime: - dependencies: - '@testing-library/dom': - specifier: ^10.4.1 - version: 10.4.1 - '@testing-library/react': - specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - vitest: - specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../../client/ui-slots - '@deepseek-ai/dsh-client-web-react': - specifier: workspace:^ - version: link:../../client/web-react - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../host/apiproxy - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - '@types/react-dom': - specifier: ~18.3.0 - version: 18.3.7(@types/react@18.3.31) - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) - packages/client/ui-agent-preset: devDependencies: '@deepseek-ai/cordis': @@ -2067,12 +1740,12 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2131,6 +1804,9 @@ importers: '@deepseek-ai/dsh-client-ui-attachment': specifier: workspace:^ version: link:../ui-attachment + '@deepseek-ai/dsh-client-ui-input-trigger': + specifier: workspace:^ + version: link:../ui-input-trigger '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../ui-layout @@ -2140,9 +1816,6 @@ importers: '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-input-trigger': - specifier: workspace:^ - version: link:../ui-input-trigger '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2299,6 +1972,149 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-goal: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + + packages/client/ui-input-trigger: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-jobs: + dependencies: + react: + specifier: ^18.2.0 + version: 18.3.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + + packages/client/ui-layout: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-theme': + specifier: workspace:^ + version: link:../ui-theme + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-message-feedback: devDependencies: '@deepseek-ai/cordis': @@ -2350,81 +2166,6 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) - packages/client/ui-goal: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-conversation': - specifier: workspace:^ - version: link:../ui-conversation - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-commands': - specifier: workspace:^ - version: link:../../interaction/commands - '@deepseek-ai/dsh-goal': - specifier: workspace:^ - version: link:../../goal/goal - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@testing-library/react': - specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) - - packages/client/ui-layout: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-client-ui-theme': - specifier: workspace:^ - version: link:../ui-theme - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - packages/client/ui-model-selection: devDependencies: '@deepseek-ai/cordis': @@ -2451,12 +2192,12 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2473,51 +2214,6 @@ importers: specifier: ^18.2.0 version: 18.3.1 - packages/client/ui-settings-models: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-schema-form': - specifier: workspace:^ - version: link:../schema-form - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-client-web-react': - specifier: workspace:^ - version: link:../web-react - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - packages/client/ui-permission-presets: devDependencies: '@deepseek-ai/cordis': @@ -2544,15 +2240,15 @@ importers: '@deepseek-ai/dsh-client-ui-commands': specifier: workspace:^ version: link:../ui-commands + '@deepseek-ai/dsh-client-ui-input-trigger': + specifier: workspace:^ + version: link:../ui-input-trigger '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-input-trigger': - specifier: workspace:^ - version: link:../ui-input-trigger '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2614,94 +2310,6 @@ importers: specifier: ^18.2.0 version: 18.3.1 - packages/client/ui-settings-plugins: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-client-web-react': - specifier: workspace:^ - version: link:../web-react - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - - packages/client/ui-settings-plugin-inventory: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@testing-library/react': - specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) - packages/client/ui-primitives: dependencies: '@shikijs/langs': @@ -2778,58 +2386,6 @@ importers: specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - packages/client/ui-user-questions: - dependencies: - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-ui-conversation': - specifier: workspace:^ - version: link:../ui-conversation - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - clsx: - specifier: ^2.0.0 - version: 2.1.1 - react: - specifier: ^18.2.0 - version: 18.3.1 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - '@deepseek-ai/dsh-user-questions': - specifier: workspace:^ - version: link:../../interaction/user-questions - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - packages/client/ui-settings: dependencies: '@deepseek-ai/dsh-client-connection': @@ -2922,6 +2478,139 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-settings-models: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-schema-form': + specifier: workspace:^ + version: link:../schema-form + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-settings-plugin-inventory: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + + packages/client/ui-settings-plugins: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-sidebar: dependencies: clsx: @@ -2979,12 +2668,12 @@ importers: '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -3007,40 +2696,6 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) - packages/client/ui-input-trigger: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - packages/client/ui-slots: devDependencies: '@deepseek-ai/cordis': @@ -3074,12 +2729,12 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -3096,40 +2751,6 @@ importers: specifier: ~18.3.1 version: 18.3.31 - packages/client/ui-jobs: - dependencies: - react: - specifier: ^18.2.0 - version: 18.3.1 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-conversation': - specifier: workspace:^ - version: link:../ui-conversation - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - packages/client/ui-theme: dependencies: '@deepseek-ai/dsh-client-connection': @@ -3289,6 +2910,58 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-user-questions: + dependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + clsx: + specifier: ^2.0.0 + version: 2.1.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-user-questions': + specifier: workspace:^ + version: link:../../interaction/user-questions + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + packages/client/ui-workflow-run: dependencies: react: @@ -3614,6 +3287,55 @@ importers: specifier: workspace:^ version: link:../../llm/token-meter + packages/context/agent-instructions: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../fs/tool-fs + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/context/session-reference: dependencies: '@deepseek-ai/schemastery': @@ -3694,71 +3416,22 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../../shell/shell - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - packages/context/agent-instructions: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-fs': - specifier: workspace:^ - version: link:../../fs/fs - '@deepseek-ai/dsh-fs-local': - specifier: workspace:^ - version: link:../../fs/fs-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-llm-deepseek': - specifier: workspace:^ - version: link:../../llm/llm-deepseek - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tool-fs': - specifier: workspace:^ - version: link:../../fs/tool-fs - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - packages/core/agent: devDependencies: '@deepseek-ai/cordis': @@ -3998,15 +3671,15 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../credentials - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment packages/e2b/e2b: dependencies: @@ -4088,6 +3761,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:^ + version: link:../../context/agent-instructions '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:^ version: link:../agent-spine-demo @@ -4115,9 +3791,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:^ - version: link:../../context/agent-instructions packages/examples/agent-spine-demo: dependencies: @@ -4134,12 +3807,12 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:^ + version: link:../../context/agent-instructions '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../../shell/shell-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../shell/bash-local @@ -4161,18 +3834,24 @@ importers: '@deepseek-ai/dsh-goal-round-driver': specifier: workspace:^ version: link:../../goal/goal-round-driver + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../llm/llm-retry - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../../sandbox/sandbox-local @@ -4188,6 +3867,9 @@ importers: '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session/session-title + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../../shell/shell-env '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../skill/skill @@ -4200,12 +3882,6 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../shell/tool-bash @@ -4215,18 +3891,15 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../goal/tool-goal - '@deepseek-ai/dsh-tool-skill': - specifier: workspace:^ - version: link:../../skill/tool-skill '@deepseek-ai/dsh-tool-jobs': specifier: workspace:^ version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:^ + version: link:../../skill/tool-skill '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:^ - version: link:../../context/agent-instructions '@deepseek-ai/node-addon-landlock-run': specifier: workspace:* version: link:../../../native/landlock-run/packages/entry @@ -4244,6 +3917,49 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/extensions/tool-cordis: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/cordis-plugin-timer': + specifier: workspace:^ + version: link:../../../vendor/timer + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/feedback/command-feedback: devDependencies: '@deepseek-ai/cordis': @@ -4258,6 +3974,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-anonymous-user-id': + specifier: workspace:^ + version: link:../../identity/anonymous-user-id '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands @@ -4273,9 +3992,6 @@ importers: '@deepseek-ai/dsh-session-telemetry': specifier: workspace:^ version: link:../../session/session-telemetry - '@deepseek-ai/dsh-anonymous-user-id': - specifier: workspace:^ - version: link:../../identity/anonymous-user-id packages/feedback/message-feedback: dependencies: @@ -4743,15 +4459,15 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../../shell/shell '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../../shell/shell packages/hooks/hooks-claude-code: dependencies: @@ -4771,9 +4487,6 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../../shell/shell '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../shell/bash-local @@ -4795,6 +4508,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../../shell/shell '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -4823,9 +4539,6 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../../shell/shell '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../shell/bash-local @@ -4847,6 +4560,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../../shell/shell '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local @@ -4883,6 +4599,9 @@ importers: '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../directory-picker + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -4916,9 +4635,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -5097,6 +4813,21 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/identity/anonymous-user-id: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + packages/interaction/commands: dependencies: zod: @@ -5137,9 +4868,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../../shell/shell '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../commands @@ -5161,6 +4889,9 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../../shell/shell '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../user-approval @@ -5235,6 +4966,98 @@ importers: specifier: workspace:^ version: link:../../llm/llm + packages/jobs/jobs: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + + packages/jobs/jobs-local: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../jobs + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + + packages/jobs/tool-jobs: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../jobs-local + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-output-retention': + specifier: workspace:^ + version: link:../../util/output-retention + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/llm/llm: dependencies: '@deepseek-ai/schemastery': @@ -5269,15 +5092,18 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-anonymous-user-id': + specifier: workspace:^ + version: link:../../identity/anonymous-user-id '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm @@ -5287,9 +5113,6 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - '@deepseek-ai/dsh-anonymous-user-id': - specifier: workspace:^ - version: link:../../identity/anonymous-user-id packages/llm/llm-pi-ai: dependencies: @@ -5309,12 +5132,12 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm @@ -5625,15 +5448,15 @@ importers: '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ version: link:../../util/atomic-write + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -5672,25 +5495,7 @@ importers: specifier: workspace:^ version: link:../../core/system-prompt - packages/terminal/terminal: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - - packages/terminal/terminal-bash: + packages/runtime-diagnostics/invariants: dependencies: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery @@ -5699,143 +5504,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-terminal': - specifier: workspace:^ - version: link:../terminal - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-subprocess': - specifier: workspace:^ - version: link:../../subprocess/subprocess - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - - packages/shell/tool-bash-persistent: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/cordis-plugin-include': - specifier: workspace:^ - version: link:../../../vendor/include - '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-terminal': - specifier: workspace:^ - version: link:../../terminal/terminal - '@deepseek-ai/dsh-terminal-bash': - specifier: workspace:^ - version: link:../../terminal/terminal-bash - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - - packages/terminal/tool-terminal: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/cordis-plugin-include': - specifier: workspace:^ - version: link:../../../vendor/include - '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-terminal': - specifier: workspace:^ - version: link:../terminal - '@deepseek-ai/dsh-terminal-bash': - specifier: workspace:^ - version: link:../terminal-bash - '@deepseek-ai/dsh-output-retention': - specifier: workspace:^ - version: link:../../util/output-retention - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../jobs/tool-jobs - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools packages/sandbox/sandbox: devDependencies: @@ -6045,49 +5713,6 @@ importers: specifier: workspace:^ version: link:../../subagent/subagent - packages/extensions/tool-cordis: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/cordis-plugin-timer': - specifier: workspace:^ - version: link:../../../vendor/timer - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-agent-loop-testkit': - specifier: workspace:^ - version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - packages/session-query/session-log-download: devDependencies: '@deepseek-ai/cordis': @@ -6455,6 +6080,9 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-anonymous-user-id': + specifier: workspace:^ + version: link:../../identity/anonymous-user-id '@deepseek-ai/dsh-command-feedback': specifier: workspace:^ version: link:../../feedback/command-feedback @@ -6470,9 +6098,6 @@ importers: '@deepseek-ai/dsh-session-telemetry': specifier: workspace:^ version: link:../session-telemetry - '@deepseek-ai/dsh-anonymous-user-id': - specifier: workspace:^ - version: link:../../identity/anonymous-user-id packages/session/session-title: dependencies: @@ -6592,21 +6217,6 @@ importers: specifier: workspace:^ version: link:../../util/timeout - packages/identity/anonymous-user-id: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths - packages/settings/settings: dependencies: '@deepseek-ai/schemastery': @@ -6641,16 +6251,410 @@ importers: '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ version: link:../../util/atomic-write - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../settings + packages/shell/bash-local: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + + packages/shell/bash-sandbox: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../bash-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/node-addon-landlock-run': + specifier: workspace:* + version: link:../../../native/landlock-run/packages/entry + + packages/shell/pwsh-local: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + + packages/shell/pwsh-sandbox: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../pwsh-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + + packages/shell/shell: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + + packages/shell/shell-env: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session/session-persistence + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + + packages/shell/tool-bash: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../bash-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../shell-env + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../interaction/user-approval + + packages/shell/tool-bash-persistent: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-terminal': + specifier: workspace:^ + version: link:../../terminal/terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:^ + version: link:../../terminal/terminal-bash + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + + packages/shell/tool-pwsh: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../test-support/loader-smoke + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../pwsh-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../shell-env + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../interaction/user-approval + + packages/shell/tool-pwsh-persistent: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../pwsh-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-terminal': + specifier: workspace:^ + version: link:../../terminal/terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:^ + version: link:../../terminal/terminal-bash + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/skill/skill: dependencies: '@deepseek-ai/schemastery': @@ -6700,12 +6704,12 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill @@ -6902,6 +6906,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -6932,9 +6939,6 @@ importers: '@deepseek-ai/dsh-storage-domain': specifier: workspace:^ version: link:../../storage/storage-domain - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -7278,6 +7282,12 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -7299,12 +7309,6 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local '@deepseek-ai/dsh-tool-jobs': specifier: workspace:^ version: link:../../jobs/tool-jobs @@ -7414,6 +7418,9 @@ importers: packages/subprocess/subprocess-local: dependencies: + koffi: + specifier: ^3.1.0 + version: 3.1.1 node-pty: specifier: ^1.1.0 version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) @@ -7434,6 +7441,122 @@ importers: specifier: workspace:^ version: link:../../util/timeout + packages/terminal/terminal: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + + packages/terminal/terminal-bash: + dependencies: + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../shell/pwsh-local + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-terminal': + specifier: workspace:^ + version: link:../terminal + + packages/terminal/tool-terminal: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-output-retention': + specifier: workspace:^ + version: link:../../util/output-retention + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-terminal': + specifier: workspace:^ + version: link:../terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:^ + version: link:../terminal-bash + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/test-support/acp-snapshot: dependencies: '@agentclientprotocol/sdk': @@ -7483,15 +7606,48 @@ importers: specifier: workspace:^ version: link:../../core/tools - packages/runtime-diagnostics/invariants: + packages/test-support/client-runtime: dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + vitest: + specifier: ^4.1.8 + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../../client/ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../../client/web-react + '@deepseek-ai/dsh-host-apiproxy': + specifier: workspace:^ + version: link:../../host/apiproxy + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) packages/test-support/llm-mock-server: devDependencies: @@ -7545,98 +7701,6 @@ importers: specifier: workspace:^ version: link:../../core/session - packages/jobs/jobs: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - - packages/jobs/jobs-local: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/cordis-plugin-include': - specifier: workspace:^ - version: link:../../../vendor/include - '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../jobs - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout - - packages/jobs/tool-jobs: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-output-retention': - specifier: workspace:^ - version: link:../../util/output-retention - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../jobs-local - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - packages/todo/tool-todo: dependencies: '@deepseek-ai/schemastery': @@ -7736,14 +7800,7 @@ importers: specifier: ^4.4.3 version: 4.4.3 - packages/typert/registry: - dependencies: - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../protocol - zod: - specifier: ^4.4.3 - version: 4.4.3 + packages/typert/protocol: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -7752,7 +7809,14 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - packages/typert/protocol: + packages/typert/registry: + dependencies: + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../protocol + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -7779,6 +7843,15 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/util/home-paths: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + packages/util/launch-environment: devDependencies: '@deepseek-ai/cordis': @@ -7797,15 +7870,6 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - packages/util/home-paths: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - packages/util/output-retention: devDependencies: '@deepseek-ai/cordis': @@ -7932,12 +7996,12 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:^ version: link:../../credentials/credentials-local - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -7957,12 +8021,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web @@ -7976,12 +8040,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web @@ -8199,24 +8263,24 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../packages/core/agent + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:^ + version: link:../../packages/context/agent-instructions '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../packages/core/agent-loop '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:^ version: link:../../packages/examples/agent-spine-demo + '@deepseek-ai/dsh-anonymous-user-id': + specifier: workspace:^ + version: link:../../packages/identity/anonymous-user-id '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/boot/app-boot '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../packages/attachment/attachment - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../../packages/shell/shell - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../../packages/shell/shell-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../packages/shell/bash-local @@ -8247,9 +8311,6 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../packages/credentials/credentials - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../packages/util/launch-environment '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../packages/fs/fs @@ -8268,6 +8329,9 @@ importers: '@deepseek-ai/dsh-goal-round-driver': specifier: workspace:^ version: link:../../packages/goal/goal-round-driver + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../packages/util/home-paths '@deepseek-ai/dsh-hook-protocol': specifier: workspace:^ version: link:../../packages/hooks/hook-protocol @@ -8280,12 +8344,15 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../packages/runtime-diagnostics/invariants - '@deepseek-ai/dsh-sdk-jsonrpc-server': + '@deepseek-ai/dsh-jobs': specifier: workspace:^ - version: link:../../packages/sdk/server - '@deepseek-ai/dsh-sdk-jsonrpc-demo': + version: link:../../packages/jobs/jobs + '@deepseek-ai/dsh-jobs-local': specifier: workspace:^ - version: link:../../packages/examples/jsonrpc-demo + version: link:../../packages/jobs/jobs-local + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../packages/util/launch-environment '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../packages/llm/llm @@ -8298,27 +8365,18 @@ importers: '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../packages/llm/llm-retry - '@deepseek-ai/dsh-home-paths': + '@deepseek-ai/dsh-output-retention': specifier: workspace:^ - version: link:../../packages/util/home-paths + version: link:../../packages/util/output-retention '@deepseek-ai/dsh-permission-presets': specifier: workspace:^ version: link:../../packages/interaction/permission-presets '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode - '@deepseek-ai/dsh-terminal': - specifier: workspace:^ - version: link:../../packages/terminal/terminal - '@deepseek-ai/dsh-terminal-bash': - specifier: workspace:^ - version: link:../../packages/terminal/terminal-bash '@deepseek-ai/dsh-repeat-tool-reminder': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-reminder - '@deepseek-ai/dsh-output-retention': - specifier: workspace:^ - version: link:../../packages/util/output-retention '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../packages/sandbox/sandbox @@ -8331,6 +8389,12 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../packages/core/scope + '@deepseek-ai/dsh-sdk-jsonrpc-demo': + specifier: workspace:^ + version: link:../../packages/examples/jsonrpc-demo + '@deepseek-ai/dsh-sdk-jsonrpc-server': + specifier: workspace:^ + version: link:../../packages/sdk/server '@deepseek-ai/dsh-sdk-protocol': specifier: workspace:^ version: link:../../packages/sdk/protocol @@ -8367,6 +8431,12 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../packages/settings/settings + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../../packages/shell/shell + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../../packages/shell/shell-env '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../packages/skill/skill @@ -8397,18 +8467,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../packages/core/system-prompt - '@deepseek-ai/dsh-jobs': + '@deepseek-ai/dsh-terminal': specifier: workspace:^ - version: link:../../packages/jobs/jobs - '@deepseek-ai/dsh-jobs-local': + version: link:../../packages/terminal/terminal + '@deepseek-ai/dsh-terminal-bash': specifier: workspace:^ - version: link:../../packages/jobs/jobs-local + version: link:../../packages/terminal/terminal-bash '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../packages/util/timeout - '@deepseek-ai/dsh-tool-call-timeout-policy': - specifier: workspace:^ - version: link:../../packages/guard/timeout-policy '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../packages/llm/token-meter @@ -8421,6 +8488,9 @@ importers: '@deepseek-ai/dsh-tool-bash-persistent': specifier: workspace:^ version: link:../../packages/shell/tool-bash-persistent + '@deepseek-ai/dsh-tool-call-timeout-policy': + specifier: workspace:^ + version: link:../../packages/guard/timeout-policy '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../packages/extensions/tool-cordis @@ -8430,6 +8500,9 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../packages/jobs/tool-jobs '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill @@ -8442,9 +8515,6 @@ importers: '@deepseek-ai/dsh-tool-subagent-control': specifier: workspace:^ version: link:../../packages/subagent/tool-subagent-control - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../packages/jobs/tool-jobs '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../packages/todo/tool-todo @@ -8463,9 +8533,6 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../packages/interaction/user-approval - '@deepseek-ai/dsh-anonymous-user-id': - specifier: workspace:^ - version: link:../../packages/identity/anonymous-user-id '@deepseek-ai/dsh-user-questions': specifier: workspace:^ version: link:../../packages/interaction/user-questions @@ -8490,9 +8557,6 @@ importers: '@deepseek-ai/dsh-workflow-worker-thread': specifier: workspace:^ version: link:../../packages/workflow/workflow-worker-thread - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:^ - version: link:../../packages/context/agent-instructions '@deepseek-ai/schemastery': specifier: link:../../vendor/schemastery version: link:../../vendor/schemastery diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index e686081812..cc708b0227 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -281,11 +281,11 @@ const TOOL_PACKAGES: ToolPackage[] = [ { pkg: '@deepseek-ai/dsh-tool-pwsh-persistent', dir: 'tool-pwsh-persistent', - source: 'packages/pty/tool-pwsh-persistent/src/index.ts', - requires: ['ctx.tools', 'ctx.pty', 'an owning Agent at execution time'], + source: 'packages/shell/tool-pwsh-persistent/src/index.ts', + requires: ['ctx.tools', 'ctx.terminals', 'an owning Agent at execution time'], writes: ['tool/call', 'PTY shell state', 'tool/result'], async mount(ctx) { - await ctx.plugin(PtyService) + await ctx.plugin(TerminalSessionService) await ctx.plugin(ToolPwshPersistent) }, note: From 7da062f61bd16460193849ca4dfe9ee9c10f3422 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 10:15:35 +0800 Subject: [PATCH 23/60] docs: refresh catalogs, links, and pairs for the renamed persistent pwsh stack --- .../2026-08-11-pwsh-persistent-pty.i18n.yaml | 4 +-- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 33 ++++++++++++++++--- docs/config-catalog.zh.md | 33 ++++++++++++++++--- docs/tool-catalog.i18n.yaml | 4 +-- docs/tool-catalog.md | 26 +++++++++++++++ docs/tool-catalog.zh.md | 26 +++++++++++++++ packages/shell/tool-pwsh/README.i18n.yaml | 4 +-- packages/shell/tool-pwsh/README.md | 2 +- packages/shell/tool-pwsh/README.zh.md | 2 +- packages/terminal/terminal-bash/src/config.ts | 1 + 11 files changed, 121 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml index 59ea231ed3..ce1e88c050 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md -2026-08-11-pwsh-persistent-pty.md: 7d4fe5e21fd4f9d96cfdf54dfbc6273f8aab3b45 -2026-08-11-pwsh-persistent-pty.zh.md: b1bb90218e15617d4445936abe1be19a537ef9f7 +2026-08-11-pwsh-persistent-pty.md: 092302ec001909683f9b7056e982309889c7f23e +2026-08-11-pwsh-persistent-pty.zh.md: 857f78c66f109ababe8a5961fa923d819857fd88 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index d2a2391c44..cea7657b39 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: f7cfea66097cd8e2a9022a32d6459bd27ad9adcf -config-catalog.zh.md: ef1e19dc704fe14a6b1a41215573a22c9f7590a1 +config-catalog.md: 6ce672f7249e6784ec550860d2d9a5d3aa84d30d +config-catalog.zh.md: 3e1b370592a50a208bce7d9fa4b1d4b57726822d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f7cfea6609..6ce672f724 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2074,9 +2074,11 @@ Requires: `terminals` · `sandboxPolicy` · `subprocess` export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -2104,9 +2106,12 @@ export interface Config { /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number } + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' ``` -Source: [`packages/terminal/terminal-bash/src/config.ts:6`](../packages/terminal/terminal-bash/src/config.ts) +Source: [`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts) ## `@deepseek-ai/dsh-time-context` @@ -2179,7 +2184,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-bash-persistent/src/index.ts:405`](../packages/shell/tool-bash-persistent/src/index.ts) +Source: [`packages/shell/tool-bash-persistent/src/index.ts:437`](../packages/shell/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -2330,6 +2335,26 @@ export interface Config { Source: [`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +Requires: `tools` · `terminals` + +```ts config-catalog +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} +``` + +Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:470`](../packages/shell/tool-pwsh-persistent/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` Requires: `tools` · `workflowEngine` · `subagents` · `systemPrompt` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ef1e19dc70..3e1b370592 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2076,9 +2076,11 @@ export interface Config { export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -2106,9 +2108,12 @@ export interface Config { /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number } + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' ``` -来源:[`packages/terminal/terminal-bash/src/config.ts:6`](../packages/terminal/terminal-bash/src/config.ts) +来源:[`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts) ## `@deepseek-ai/dsh-time-context` @@ -2181,7 +2186,7 @@ export interface Config { } ``` -来源:[`packages/shell/tool-bash-persistent/src/index.ts:405`](../packages/shell/tool-bash-persistent/src/index.ts) +来源:[`packages/shell/tool-bash-persistent/src/index.ts:437`](../packages/shell/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -2331,6 +2336,26 @@ export interface Config { 来源:[`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +需要:`tools` · `terminals` + +```ts config-catalog +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} +``` + +来源:[`packages/shell/tool-pwsh-persistent/src/index.ts:470`](../packages/shell/tool-pwsh-persistent/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` 需要:`tools` · `workflows` · `subagents` · `systemPrompt` diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 12b34cd0f7..62ef725fbc 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: 1a28d560ab9fea3ca8e68856377e7912184d5311 -tool-catalog.zh.md: 1572c84f3b013e9a46198e1503d5373495845d28 +tool-catalog.md: f4fb7fe2a7ae65f779ae5ded146098deb2be9ad0 +tool-catalog.zh.md: 49ea51446e93c2224bd8f173e6f473e439f6100a diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 1a28d560ab..f4fb7fe2a7 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -22,6 +22,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | +| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after view presence/absence, edit absence, or successful mutation`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal API. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `read_image`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt`, `ctx.attachments (read_image registration)`, `ctx.llm + an image-capable route (read_image execution)` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful file operation`, `durable attachment (read_image)`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background jobs) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | @@ -352,6 +353,31 @@ Source: [`packages/shell/tool-bash-persistent/src/index.ts`](../packages/shell/t One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +### `pwsh` + +Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] +} +``` + +Source: [`packages/shell/tool-pwsh-persistent/src/index.ts`](../packages/shell/tool-pwsh-persistent/src/index.ts) + +One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. + ## `@deepseek-ai/dsh-tool-str-replace-editor` ### `str_replace_editor` diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 1572c84f3b..49ea51446e 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -24,6 +24,7 @@ | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`、`cordis_mount`、`cordis_unmount` | `ctx.tools` | `tool/call`、`tool/result`、`process-local temporary Plugin lifecycle` | - | 不在任何随产品发布的树中,需要有意选择启用;临时 Plugin 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。由 cordis_mount 创建的插件在卸载或 DSH 重启之前可以注册**额外的**模型可见工具;发生这类工具集变更时,系统会记录完整且有变动的请求头。 | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 | +| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`、`ctx.fs` | `tool/call`、`fs/observed after view presence/absence, edit absence, or successful mutation`、`tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 | | `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`read_image`、`write` | `ctx.tools`、`ctx.fs`、`ctx.systemPrompt`、`ctx.attachments (read_image registration)`、`ctx.llm + an image-capable route (read_image execution)` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after read presence/absence or successful file operation`、`durable attachment (read_image)`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-observation-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时 `read_image` 不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图像输入,否则拒绝。 | | `@deepseek-ai/dsh-tool-fs-search` | `glob`、`grep` | `ctx.tools`、`ctx.subprocess`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 | @@ -354,6 +355,31 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +### `pwsh` + +在持久 PowerShell shell 中运行命令。包括当前目录和已导出环境变量在内的状态会在此 agent 的多次调用之间保留。 + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] +} +``` + +来源:[`packages/shell/tool-pwsh-persistent/src/index.ts`](../packages/shell/tool-pwsh-persistent/src/index.ts) + +一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 + ## `@deepseek-ai/dsh-tool-str-replace-editor` ### `str_replace_editor` diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index 7dc6bee028..589769709c 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/tool-pwsh/README.md -README.md: e146a8cf2e47cf827d7b83a5aad810e10017a018 -README.zh.md: 926b24e807a37dc4f49308e082256d64b7f38d38 +README.md: e862fcf0ca85d0ecb0a5fe6cff3ee3c7a8153716 +README.zh.md: e03a980acfe05583721a1f084cbf53545c076126 diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index e146a8cf2e..e862fcf0ca 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -121,6 +121,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **Language mode and named-pipe capture under the Windows sandbox** — under the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md), read-only pwsh starts in ConstrainedLanguage because its temp write denial makes PowerShell's AppLocker probe fail closed: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. Workspace-write's private temp lets the probe complete, so it stays in FullLanguage unless host policy says otherwise. Both confined modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations. -- **No persistent shell** — every call starts a fresh `pwsh -Command`; the persistent-shell counterpart is [`@deepseek-ai/dsh-tool-pwsh-persistent`](../../pty/tool-pwsh-persistent/README.md), which keeps one owner-scoped pwsh alive across calls on Windows (ConPTY) and POSIX hosts with pwsh. +- **No persistent shell** — every call starts a fresh `pwsh -Command`; the persistent-shell counterpart is [`@deepseek-ai/dsh-tool-pwsh-persistent`](../tool-pwsh-persistent/README.md), which keeps one owner-scoped pwsh alive across calls on Windows (ConPTY) and POSIX hosts with pwsh. - **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. - **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction. diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index 926b24e807..e03a980acf 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -121,6 +121,6 @@ ack 是固定短行;任务输出按读取有界。 ## 已知限制与暂缓事项 - **Windows 沙箱下的语言模式与 named-pipe 捕获** — 在 [Windows ACL 沙箱](../../sandbox/sandbox-windows-acl/README.md) 下,read-only pwsh 会以 ConstrainedLanguage 启动,因为临时目录写入被拒绝,导致 PowerShell 的 AppLocker 探针失败并按 fail-closed 处理:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。workspace-write 的私有临时目录使探针得以完成,因此除非主机策略另有规定,否则它保持 FullLanguage。两种受限模式都拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。 -- **无持久 shell** — 每次调用都启动全新的 `pwsh -Command`;持久 shell 对应物是 [`@deepseek-ai/dsh-tool-pwsh-persistent`](../../pty/tool-pwsh-persistent/README.md),它在 Windows(ConPTY)以及装有 pwsh 的 POSIX 主机上跨调用保持一个 owner 作用域的 pwsh 存活。 +- **无持久 shell** — 每次调用都启动全新的 `pwsh -Command`;持久 shell 对应物是 [`@deepseek-ai/dsh-tool-pwsh-persistent`](../tool-pwsh-persistent/README.md),它在 Windows(ConPTY)以及装有 pwsh 的 POSIX 主机上跨调用保持一个 owner 作用域的 pwsh 存活。 - **PowerShell 方言约定** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 - **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。 diff --git a/packages/terminal/terminal-bash/src/config.ts b/packages/terminal/terminal-bash/src/config.ts index bf78a56361..5752845549 100644 --- a/packages/terminal/terminal-bash/src/config.ts +++ b/packages/terminal/terminal-bash/src/config.ts @@ -96,6 +96,7 @@ export const Config: z = z.object({ /** * Assert every effective numeric config field is a positive safe integer and bounds compose. * @param config - Schemastery-resolved plugin configuration. + * @returns Narrows the input to the fully resolved configuration. */ export function validateConfig(config: Config): asserts config is ResolvedConfig { const resolved = config as ResolvedConfig From 82c53ee209d164c91cc42bfa3eb7105149af94e1 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 10:40:26 +0800 Subject: [PATCH 24/60] fix(terminal-bash): fall back to dialect defaults when Schemastery materializes empty shell values --- packages/terminal/terminal-bash/src/config.ts | 14 ++++++++++---- .../terminal/terminal-bash/tests/config.spec.ts | 11 +++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/terminal/terminal-bash/src/config.ts b/packages/terminal/terminal-bash/src/config.ts index 5752845549..19fada0c43 100644 --- a/packages/terminal/terminal-bash/src/config.ts +++ b/packages/terminal/terminal-bash/src/config.ts @@ -59,8 +59,10 @@ export const DEFAULT_PWSH_ARGS = ['-NoLogo', '-NoProfile'] /** * Resolve the effective per-dialect shell specification. Defaulting is this - * explicit step: an unset `shellPath`/`shellArgs` selects the dialect's - * defaults, while an explicit value always wins. + * explicit step: an unset or empty `shellPath`/`shellArgs` selects the + * dialect's defaults, while a non-empty explicit value always wins. + * (Schemastery materializes an absent optional array as `[]`, so emptiness — + * not just `undefined` — means "dialect default".) * @param config - Schemastery-resolved plugin configuration. * @returns the fully resolved configuration. */ @@ -69,8 +71,12 @@ export function resolveConfig(config: Config): ResolvedConfig { return { ...(config as Required), shellDialect, - shellPath: config.shellPath ?? (shellDialect === 'pwsh' ? resolvePwshPath() : DEFAULT_BASH_SHELL), - shellArgs: config.shellArgs ?? (shellDialect === 'pwsh' ? DEFAULT_PWSH_ARGS : DEFAULT_BASH_ARGS), + shellPath: config.shellPath !== undefined && config.shellPath.length > 0 + ? config.shellPath + : (shellDialect === 'pwsh' ? resolvePwshPath() : DEFAULT_BASH_SHELL), + shellArgs: config.shellArgs !== undefined && config.shellArgs.length > 0 + ? config.shellArgs + : (shellDialect === 'pwsh' ? DEFAULT_PWSH_ARGS : DEFAULT_BASH_ARGS), } } diff --git a/packages/terminal/terminal-bash/tests/config.spec.ts b/packages/terminal/terminal-bash/tests/config.spec.ts index 09a33c5ff9..d7557a2d90 100644 --- a/packages/terminal/terminal-bash/tests/config.spec.ts +++ b/packages/terminal/terminal-bash/tests/config.spec.ts @@ -54,6 +54,17 @@ describe('terminal-bash dialect resolution', () => { expect(resolved.shellArgs).toEqual(['-NoProfile']) }) + it('treats empty shell values as unset so Schemastery materialization cannot drop the dialect defaults', () => { + // Schemastery materializes an absent optional array as `[]`; the resolver + // must treat that shape like an unset value or a real bash spawn would + // start non-interactive without the controlled prompt. + const resolved = resolveConfig({ + backendType: 'shell', shellDialect: 'bash', shellPath: '', shellArgs: [], rows: 24, cols: 80, + }) + expect(resolved.shellPath).toBe('/bin/bash') + expect(resolved.shellArgs).toEqual(['--noprofile', '--norc', '-i']) + }) + it('validates the effective shell path, not only the raw one', () => { expect(() => { validateConfig(resolveConfig({ backendType: 'shell', shellDialect: 'bash', rows: 24, cols: 80 })) }).not.toThrow() expect(() => { validateConfig(resolveConfig({ backendType: 'shell', shellDialect: 'pwsh', rows: 24, cols: 80 })) }).not.toThrow() From 13d859a6199040e16a720fc559dd741a41cb487b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 10:47:44 +0800 Subject: [PATCH 25/60] test(subprocess): measure the pipe-drain settle from before the pid-file handoff --- packages/subprocess/subprocess-local/tests/spawn.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index ba214201be..1f21dfaaa1 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -857,8 +857,11 @@ describe('coverage seams', () => { ...spec('unused', { graceMs: 100 }), argv: [process.execPath, '-e', childScript], }) - const helper = await waitForPidFile(pidFile) + // The drain timer starts when the child's stdio closes, which can precede + // the pid file becoming visible; measure from before that wait so the + // lower bound cannot be eroded by the pid-file handoff. const started = Date.now() + const helper = await waitForPidFile(pidFile) const outcome = await running.done expect(outcome.exitCode).toBe(0) expect(Date.now() - started).toBeGreaterThanOrEqual(90) From 6e2a4fd08af25f60e4a193f98b5d79dfbca3685b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 10:54:23 +0800 Subject: [PATCH 26/60] test(terminal-bash): cover the spawn signal forwarded into the pwsh bootstrap send --- .../terminal-bash/tests/index.spec.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 571df73474..c1efd399d8 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -432,6 +432,39 @@ describe('BashTerminalBackend startup rollback', () => { const timedOut = new BashTerminalBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('timeout')) await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toThrow('did not reach readiness before startup timeout') }) + + it('forwards the spawn signal into the pwsh bootstrap sends', async () => { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + const sends: TerminalSendRequest[] = [] + const session = { + motd: '', + startSend: (request: TerminalSendRequest) => { + sends.push(request) + return { + done: Promise.resolve({ + viewport: 'dsh> ', waitReason: 'stdin_read' as const, + sessionStatus: { kind: 'running' as const }, truncated: false, + }), + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + } + }, + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + } as unknown as LocalPtySession + const backend = new BashTerminalBackend( + ctx, + { ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' }, + async () => terminalHandle(), + () => session, + ) + const signal = new AbortController().signal + const spawned = await backend.spawn({ ...spec(agent(ctx)), signal }) + expect(spawned.motd).toBe('dsh> ') + expect(sends).toHaveLength(1) + expect(sends[0]?.signal).toBe(signal) + }) }) describe('terminal-bash plugin shape', () => { From b89808cc2d9e9a3c8240e1ac64bc43ff861ab0c8 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 11:10:59 +0800 Subject: [PATCH 27/60] fix(gates): align package version, module graph, and jscpd ignores for the mirrored pwsh stack --- docs/module-graph.md | 7 +++++++ packages/shell/tool-pwsh-persistent/package.json | 2 +- packages/shell/tool-pwsh-persistent/src/index.ts | 4 ++++ .../subprocess/subprocess-local/src/windows-inspector.ts | 4 ++++ 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index dc2ef337d9..3aa3efd323 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -282,6 +282,7 @@ flowchart TD pkg_tool_bash["tool-bash"] pkg_tool_bash_persistent["tool-bash-persistent"] pkg_tool_pwsh["tool-pwsh"] + pkg_tool_pwsh_persistent["tool-pwsh-persistent"] end subgraph group_storage["packages/storage"] pkg_storage["storage"] @@ -935,6 +936,11 @@ flowchart TD pkg_tool_bash_persistent --> pkg_terminal pkg_tool_bash_persistent --> pkg_timeout pkg_tool_bash_persistent --> pkg_tools + pkg_tool_pwsh_persistent --> pkg_agent + pkg_tool_pwsh_persistent --> pkg_invariants + pkg_tool_pwsh_persistent --> pkg_terminal + pkg_tool_pwsh_persistent --> pkg_timeout + pkg_tool_pwsh_persistent --> pkg_tools pkg_tool_terminal --> pkg_agent pkg_tool_terminal --> pkg_invariants pkg_tool_terminal --> pkg_jobs @@ -1538,6 +1544,7 @@ flowchart TD | [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 5875376802..4f2fc37146 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/shell/tool-pwsh-persistent/src/index.ts b/packages/shell/tool-pwsh-persistent/src/index.ts index cce7ddd001..5fc2d3a03d 100644 --- a/packages/shell/tool-pwsh-persistent/src/index.ts +++ b/packages/shell/tool-pwsh-persistent/src/index.ts @@ -1,3 +1,5 @@ +/* jscpd:ignore-start -- deliberate mirror of tool-bash-persistent (persistent-pty note 2026-08-11-pwsh-persistent-pty): + the PowerShell counterpart shares the session registry, polling loop, and reset contract by design. */ /** * Model-facing persistent `pwsh` tool over the owner-scoped PTY seam. * @module @deepseek-ai/dsh-tool-pwsh-persistent @@ -508,3 +510,5 @@ export function apply(ctx: Context, config: Config): void { } registerPersistentPwsh(ctx, resolved) } + +/* jscpd:ignore-end */ diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts index da5158b4c0..9c306f595f 100644 --- a/packages/subprocess/subprocess-local/src/windows-inspector.ts +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -38,6 +38,9 @@ export interface WindowsProcessInspectorInternals { * @param started - creation-time identity resolver for one member. * @returns the root and its current transitive descendants, children first. */ +/* jscpd:ignore-start -- the Windows inspector deliberately mirrors process-inspector.ts: + the decision logic (tree walk, identity fencing, group signalling) is the same contract over + Win32 primitives, per the persistent-pty note 2026-08-11-pwsh-persistent-pty. */ export function windowsProcessTree( entries: ProcessEntry[], rootPid: number, @@ -106,6 +109,7 @@ export class WindowsProcessInspector implements ProcessInspector { if (this.isAlive(identity)) this.internals.taskkill(identity.pid, signal === 'SIGKILL') } } +/* jscpd:ignore-end */ /** * Create the Windows process inspector. From 68ba98c29e6f54a3a3c9ef5edffc56456ba9f970 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 11:24:43 +0800 Subject: [PATCH 28/60] docs: mirror the pwsh-persistent graph nodes and source lines into the Chinese counterparts --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.zh.md | 7 +++++++ 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index cea7657b39..717f0293c7 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 6ce672f7249e6784ec550860d2d9a5d3aa84d30d -config-catalog.zh.md: 3e1b370592a50a208bce7d9fa4b1d4b57726822d +config-catalog.md: 09106e25600851705d102d2265354404f2369a8d +config-catalog.zh.md: 12d7487719a553e5152ca529b30540aa7c8eaafd diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6ce672f724..09106e2560 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2353,7 +2353,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:470`](../packages/shell/tool-pwsh-persistent/src/index.ts) +Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:472`](../packages/shell/tool-pwsh-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 3e1b370592..12d7487719 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2354,7 +2354,7 @@ export interface Config { } ``` -来源:[`packages/shell/tool-pwsh-persistent/src/index.ts:470`](../packages/shell/tool-pwsh-persistent/src/index.ts) +来源:[`packages/shell/tool-pwsh-persistent/src/index.ts:472`](../packages/shell/tool-pwsh-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a2345a28c5..152ba8d0c7 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: dc2ef337d946b37d4d0a8600c70c26c53cc89dc0 -module-graph.zh.md: 8cd2feff369752cb0c9a9afd38a77f31ddf29826 +module-graph.md: 3aa3efd323836e48e79b22018f8c85a1869a22f2 +module-graph.zh.md: 438ac5208e3471ac11286a30cd82966f481a8de9 diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 8cd2feff36..438ac5208e 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -284,6 +284,7 @@ flowchart TD pkg_tool_bash["tool-bash"] pkg_tool_bash_persistent["tool-bash-persistent"] pkg_tool_pwsh["tool-pwsh"] + pkg_tool_pwsh_persistent["tool-pwsh-persistent"] end subgraph group_storage["packages/storage"] pkg_storage["storage"] @@ -937,6 +938,11 @@ flowchart TD pkg_tool_bash_persistent --> pkg_terminal pkg_tool_bash_persistent --> pkg_timeout pkg_tool_bash_persistent --> pkg_tools + pkg_tool_pwsh_persistent --> pkg_agent + pkg_tool_pwsh_persistent --> pkg_invariants + pkg_tool_pwsh_persistent --> pkg_terminal + pkg_tool_pwsh_persistent --> pkg_timeout + pkg_tool_pwsh_persistent --> pkg_tools pkg_tool_terminal --> pkg_agent pkg_tool_terminal --> pkg_invariants pkg_tool_terminal --> pkg_jobs @@ -1540,6 +1546,7 @@ flowchart TD | [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | From a4e2e1e6e987382a2038471981e86d3ef93c1b6d Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 15 Aug 2026 11:17:35 +0800 Subject: [PATCH 29/60] fix(gates): align tool-pwsh-persistent version and publish access with the rc.6 release --- packages/shell/tool-pwsh-persistent/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 4f2fc37146..c353f19bcd 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,9 +1,9 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.0.1-rc.2", + "version": "0.1.0-rc.6", "publishConfig": { - "access": "restricted" + "access": "public" }, "repository": { "type": "git", From f61e884917fac88c2f4d67588aa1382b6337c1f9 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 15 Aug 2026 11:25:23 +0800 Subject: [PATCH 30/60] fix(gates): declare the MIT license for tool-pwsh-persistent --- packages/shell/tool-pwsh-persistent/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index c353f19bcd..84278ef05e 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -29,7 +29,7 @@ "lib/invariant.js", "lib/types/**/*.d.ts" ], - "license": "BSD-3-Clause", + "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", From 06b766711c9c7e8acd9ecb1b0b841f33d16a6114 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 15 Aug 2026 11:45:13 +0800 Subject: [PATCH 31/60] fix(pty): pin UTF-8 output encodings in the persistent pwsh bootstrap --- .../terminal/terminal-bash/README.i18n.yaml | 4 +-- packages/terminal/terminal-bash/README.md | 4 +-- packages/terminal/terminal-bash/README.zh.md | 4 +-- packages/terminal/terminal-bash/src/index.ts | 14 ++++++---- .../terminal-bash/tests/index.spec.ts | 3 ++- .../terminal-bash/tests/local.spec.ts | 27 +++++++++++++++++++ 6 files changed, 44 insertions(+), 12 deletions(-) diff --git a/packages/terminal/terminal-bash/README.i18n.yaml b/packages/terminal/terminal-bash/README.i18n.yaml index 67afd7593c..2f648e4111 100644 --- a/packages/terminal/terminal-bash/README.i18n.yaml +++ b/packages/terminal/terminal-bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/terminal/terminal-bash/README.md -README.md: ac8c4c8daf6db255bc55af3af49367f1de2c9a8a -README.zh.md: d10c7560173415281ad2820c5cb17ba3a7d4bf62 +README.md: 8b8c8293d7f2fbdc50b2311b1f327eb509578b72 +README.zh.md: 89f9323abc433ae9d212eb0b48ecd47760c635bc diff --git a/packages/terminal/terminal-bash/README.md b/packages/terminal/terminal-bash/README.md index ac8c4c8daf..8b8c8293d7 100644 --- a/packages/terminal/terminal-bash/README.md +++ b/packages/terminal/terminal-bash/README.md @@ -8,7 +8,7 @@ Persistent shell backend for `ctx.terminals` over `ctx.subprocess.spawnTerminal` The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. -`shellDialect` selects the shell stack (`bash` default, `pwsh`): it picks the default `shellPath`/`shellArgs` (bash `--noprofile --norc -i`; pwsh `-NoLogo -NoProfile` through the shared `dsh-pwsh-local` resolver) and the startup contract. The bash dialect installs its prompt through the environment (`PS1` plus an OSC `133;D;`-terminated `PROMPT_COMMAND`). pwsh cannot install a prompt from the environment, so the backend writes a `prompt` function through the session and waits until the controlled prompt is actually visible — looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound — while its environment drops the bash-only markers and adds `NO_COLOR`. Both dialects emit the same BEL-terminated OSC marker, so the readiness machinery and consumers are dialect-agnostic. +`shellDialect` selects the shell stack (`bash` default, `pwsh`): it picks the default `shellPath`/`shellArgs` (bash `--noprofile --norc -i`; pwsh `-NoLogo -NoProfile` through the shared `dsh-pwsh-local` resolver) and the startup contract. The bash dialect installs its prompt through the environment (`PS1` plus an OSC `133;D;`-terminated `PROMPT_COMMAND`). pwsh cannot install a prompt from the environment, so the backend writes a `prompt` function through the session and waits until the controlled prompt is actually visible — looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound — while its environment drops the bash-only markers and adds `NO_COLOR`. That first send also prefixes the shared `dsh-pwsh-local` encoding preamble, pinning `[Console]::OutputEncoding` and `$OutputEncoding` to UTF-8 before anything runs: the session decode path reads PTY bytes as UTF-8, and an un-pinned console writes its host code page for non-ASCII output. Both dialects emit the same BEL-terminated OSC marker, so the readiness machinery and consumers are dialect-agnostic. Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `TerminalBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline. @@ -34,6 +34,6 @@ A standing-policy change appends an owner-rendered superseding runtime-context s - Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported. - Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. Windows is such a provider: the shell pid is the pseudo foreground group and there is no exact stdin-wait tier, so a marker-less child settles on the silence bound. -- The pwsh `prompt` bootstrap writes through `[Console]::`, which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny; the `Write-Host -NoNewline` fallback is the designed alternative, decided by the Windows-native lane. +- The pwsh bootstrap writes through `[Console]::` (the UTF-8 encoding pin and the prompt function), which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny; the `Write-Host -NoNewline` fallback is the designed alternative, decided by the Windows-native lane. - Cleanup guarantees are those of `SubprocessTerminalHandle`; provider-specific gaps belong to that implementation's contract rather than this PTY consumer. - Sessions do not survive harness process exit. diff --git a/packages/terminal/terminal-bash/README.zh.md b/packages/terminal/terminal-bash/README.zh.md index d10c756017..89f9323abc 100644 --- a/packages/terminal/terminal-bash/README.zh.md +++ b/packages/terminal/terminal-bash/README.zh.md @@ -8,7 +8,7 @@ 该插件注入 `pty`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell;受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv,未挂载时会在 spawn 前失败。spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。 -`shellDialect` 选择 shell 栈(默认 `bash`,或 `pwsh`):它决定默认的 `shellPath`/`shellArgs`(bash 为 `--noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器得到 `-NoLogo -NoProfile`)与启动契约。bash 方言通过环境安装提示符(`PS1` 加 OSC `133;D;` 终结的 `PROMPT_COMMAND`)。pwsh 无法从环境安装提示符,因此后端通过会话写入 `prompt` 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;同时其环境去掉 bash 专属标记并加 `NO_COLOR`。两种方言发出相同的 BEL 终结 OSC 标记,因此就绪机制与消费方与方言无关。 +`shellDialect` 选择 shell 栈(默认 `bash`,或 `pwsh`):它决定默认的 `shellPath`/`shellArgs`(bash 为 `--noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器得到 `-NoLogo -NoProfile`)与启动契约。bash 方言通过环境安装提示符(`PS1` 加 OSC `133;D;` 终结的 `PROMPT_COMMAND`)。pwsh 无法从环境安装提示符,因此后端通过会话写入 `prompt` 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;同时其环境去掉 bash 专属标记并加 `NO_COLOR`。同一条首发送还会带上共享的 `dsh-pwsh-local` 编码前缀,在一切运行之前把 `[Console]::OutputEncoding` 与 `$OutputEncoding` 钉为 UTF-8:会话解码路径按 UTF-8 读取 PTY 字节,未钉住编码的控制台会以宿主代码页输出非 ASCII 内容。两种方言发出相同的 BEL 终结 OSC 标记,因此就绪机制与消费方与方言无关。 就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`TerminalBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 @@ -34,6 +34,6 @@ - 输出按行规范化;不支持全屏备用缓冲区交互。 - 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。Windows 正是这样的提供方:shell pid 是伪前台进程组,没有精确的 stdin-wait 档,因此无标记的子进程按静默上限结算。 -- pwsh `prompt` 引导通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它;`Write-Host -NoNewline` 回退是设计好的备选,由 Windows-native 车道裁决。 +- pwsh 引导(UTF-8 编码钉与 `prompt` 函数)通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它;`Write-Host -NoNewline` 回退是设计好的备选,由 Windows-native 车道裁决。 - 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的约定,而非这个 PTY 消费方。 - harness 进程退出后,会话无法继续存在。 diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index 8e207eeb8e..012a57aae0 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -12,6 +12,7 @@ import type { TerminalBackend, TerminalBackendSpawnSpec } from '@deepseek-ai/dsh import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { ENCODING_PREAMBLE } from '@deepseek-ai/dsh-pwsh-local' import { type Config, type ResolvedConfig, resolveConfig, type ShellDialect, validateConfig } from './config.ts' import { LocalPtySession } from './session.ts' import { CONTROLLED_PROMPT } from './sanitize.ts' @@ -112,15 +113,18 @@ async function startupSession( // pwsh cannot install its prompt from the environment: write the prompt // function through the session and wait for the first marker prompt, // which is also the readiness contract of the bash initialize path. The - // banner-to-prompt gap can outlast the silence bound, so the wait loops - // over follow-up sends until the controlled prompt is actually visible - // (in the viewport or the retained scrollback when it landed between - // sends), bounded by the send deadline. + // first send also pins UTF-8 output (the shared pwsh-local preamble) + // before anything runs: the session decode path treats PTY bytes as + // UTF-8, and an un-pinned console writes its host code page for + // non-ASCII output. The banner-to-prompt gap can outlast the silence + // bound, so the wait loops over follow-up sends until the controlled + // prompt is actually visible (in the viewport or the retained scrollback + // when it landed between sends), bounded by the send deadline. let viewport = '' for (;;) { const first = viewport.length === 0 const operation = session.startSend({ - text: first ? PWSH_PROMPT_SETUP : '', + text: first ? ENCODING_PREAMBLE + PWSH_PROMPT_SETUP : '', submit: first, ...signal !== undefined ? { signal } : {}, }) diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index c1efd399d8..0445f1b6d2 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -11,6 +11,7 @@ import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-p import TerminalSessionService, { TerminalBackendCleanupError, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import type { TerminalSendRequest, TerminalWaitReason } from '@deepseek-ai/dsh-terminal' import { BashTerminalBackend, PWSH_PROMPT_SETUP } from '@deepseek-ai/dsh-terminal-bash' +import { ENCODING_PREAMBLE } from '@deepseek-ai/dsh-pwsh-local' import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash' import type { ResolvedConfig } from '@deepseek-ai/dsh-terminal-bash/src/config.ts' import type { LocalPtySession } from '@deepseek-ai/dsh-terminal-bash/src/session.ts' @@ -368,7 +369,7 @@ describe('BashTerminalBackend startup rollback', () => { () => session, ) expect(await backend.spawn(spec(agent(ctx)))).toBe(session) - expect(sent).toMatchObject({ text: PWSH_PROMPT_SETUP, submit: true }) + expect(sent).toMatchObject({ text: ENCODING_PREAMBLE + PWSH_PROMPT_SETUP, submit: true }) expect(session.motd).toBe('setup-echo dsh> ') expect(spawned?.env).toMatchObject({ TERM: 'dumb', NO_COLOR: '1', DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1', diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 232ef7d984..ac5ad5be58 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -294,4 +294,31 @@ describe.skipIf(!hasPwsh)('terminal-bash pwsh real shell', () => { else process.env.DSH_TEST_SECRET = previous } }, 30_000) + + it('pins UTF-8 output encoding so non-ASCII output survives the byte decode', async () => { + const { ctx, root, agent } = await harness('danger-full-access', { + idleSilenceMs: 300, + handoffGraceMs: 300, + timeoutMs: 8_000, + }, 'pwsh') + const created = await ctx.terminals.spawn(agent, { type: 'shell', name: 'main', cwd: root }) + // The bootstrap itself must have pinned both encodings: the session byte + // decode is UTF-8, so an un-pinned console writing its host code page + // garbles every non-ASCII byte that follows. + const pinned = ctx.terminals.startSend(agent, created.sessionId, { + text: '"console=" + [Console]::OutputEncoding.WebName + " out=" + $OutputEncoding.WebName', + submit: true, + }) + const pinnedResult = await pinned.done + expect(pinnedResult.viewport).toContain('console=utf-8 out=utf-8') + // Char codes keep the submitted line ASCII-only, so the assertion is a + // pure output-decode check. + const sent = ctx.terminals.startSend(agent, created.sessionId, { + text: "[Console]::Write([char]0x4E2D + [char]0x6587 + ' encoding-ok')", + submit: true, + }) + const result = await sent.done + expect(result.viewport).toContain('中文 encoding-ok') + await ctx.terminals.kill(agent, created.sessionId) + }, 30_000) }) From c854749c34dc88a77e757b9d6e58d8694e594404 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 15 Aug 2026 17:05:19 +0800 Subject: [PATCH 32/60] fix(pty): ship dsh-pwsh-local in the python runtime closure --- pnpm-lock.yaml | 3 +++ python/sdk-runtime/package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 474669d616..5aee0f646f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8509,6 +8509,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../packages/shell/pwsh-local '@deepseek-ai/dsh-repeat-tool-reminder': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-reminder diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 89d4b81b22..116a36b35e 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -49,6 +49,7 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", From 99151657c0bf580a76d164ecfddbd094dd1355c3 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 15 Aug 2026 20:49:15 +0800 Subject: [PATCH 33/60] fix(pty): import resolvePwshPath from the pwsh-local package root --- packages/terminal/terminal-bash/src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/terminal/terminal-bash/src/config.ts b/packages/terminal/terminal-bash/src/config.ts index 19fada0c43..848fd8bf9a 100644 --- a/packages/terminal/terminal-bash/src/config.ts +++ b/packages/terminal/terminal-bash/src/config.ts @@ -1,7 +1,7 @@ /** Validated configuration for the local PTY backend. */ import z from '@deepseek-ai/schemastery' -import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' /** One supported interactive shell dialect. */ export type ShellDialect = 'bash' | 'pwsh' From 91e8d62b2af83cdca87a9d83b53b7665736a5c81 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 14:30:47 +0800 Subject: [PATCH 34/60] fix(subprocess): detect exited Windows terminals --- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess-local/src/windows-inspector.ts | 45 ++++++++++++++----- .../tests/windows-inspector.spec.ts | 14 ++++-- 5 files changed, 47 insertions(+), 20 deletions(-) diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 7af31d7aff..6b02932a92 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md -README.md: 2c2046a886004321b4438da2af2723413e2454fe -README.zh.md: 26955caca951388a60b55c7afa806a933531e8e1 +README.md: 0935bb309bd10dec7503a74708a28442223bf296 +README.zh.md: e2e6c67e4dbe1890bcb5532594a62b650bfed85d diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 2c2046a886..0935bb309b 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -11,7 +11,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. -- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32 with GetProcessTimes start identities, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's absence through those identities because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. +- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. - **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes. - **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 26955caca9..e2e6c67e4d 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -11,7 +11,7 @@ - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 -- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表并取 GetProcessTimes 启动身份,把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组),拆卸则通过这些身份验证 shell 已消失——因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 +- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。 - **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md)。 diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts index 9c306f595f..7280cbb9ee 100644 --- a/packages/subprocess/subprocess-local/src/windows-inspector.ts +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -1,8 +1,9 @@ /** * Windows process-table operations for terminal readiness, signalling, and * teardown: Toolhelp32 snapshot enumeration with GetProcessTimes creation-time - * identity, the shell pid as a pseudo process group (Windows has no POSIX - * groups), and taskkill tree signalling. The koffi bindings load lazily so + * identity and process-handle wait-state liveness, the shell pid as a pseudo + * process group (Windows has no POSIX groups), and taskkill tree signalling. + * The koffi bindings load lazily so * non-Windows processes never touch Win32 libraries; all decision logic takes * an injectable internals boundary so suites can pin it on any host. * @module dsh-subprocess-local/windows-inspector @@ -19,12 +20,20 @@ export interface ProcessEntry { parentPid: number } +/** Creation identity plus the process object's current wait state. */ +export interface WindowsProcessState { + /** GetProcessTimes creation identity used to fence PID reuse. */ + started: string + /** Whether a zero-time process-handle wait reports the process still running. */ + active: boolean +} + /** Injectable Windows process operations used by one local PTY session. */ export interface WindowsProcessInspectorInternals { /** Enumerate the current process table (pid/parent pairs). */ snapshot(): ProcessEntry[] - /** Return one process's creation-time identity, or undefined when unreadable. */ - creationTime(pid: number): string | undefined + /** Return one process's creation identity and wait state, or undefined when unreadable. */ + processState(pid: number): WindowsProcessState | undefined /** Terminate one process tree; `force` maps to taskkill `/F`. */ taskkill(pid: number, force: boolean): void } @@ -89,7 +98,7 @@ export class WindowsProcessInspector implements ProcessInspector { } processTree(rootPid: number): ProcessIdentity[] { - return windowsProcessTree(this.internals.snapshot(), rootPid, pid => this.internals.creationTime(pid)) + return windowsProcessTree(this.internals.snapshot(), rootPid, pid => this.internals.processState(pid)?.started) } processSession(_sessionId: number): ProcessIdentity[] { @@ -97,8 +106,8 @@ export class WindowsProcessInspector implements ProcessInspector { } isAlive(identity: ProcessIdentity): boolean { - const started = this.internals.creationTime(identity.pid) - return started !== undefined && started === identity.started + const state = this.internals.processState(identity.pid) + return state?.active === true && state.started === identity.started } signalGroup(pgid: number, signal: SubprocessTerminalSignal): void { @@ -158,6 +167,7 @@ interface Win32Bindings { kernel: NativePtr, user: NativePtr, ): number + waitForSingleObject(handle: NativePtr, milliseconds: number): number closeHandle(handle: NativePtr): number } @@ -202,6 +212,9 @@ let cachedStructs: ReturnType | undefined const TH32CS_SNAPPROCESS = 0x2 const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +const SYNCHRONIZE = 0x00100000 +const WAIT_OBJECT_0 = 0 +const WAIT_TIMEOUT = 0x102 let cachedBindings: Win32Bindings | undefined @@ -230,6 +243,7 @@ function win32Bindings(): Win32Bindings { koffi.pointer(FILETIME), koffi.pointer(FILETIME), ]), + waitForSingleObject: bind('WaitForSingleObject', 'uint32', [PVOID, 'uint32']), closeHandle: bind('CloseHandle', 'int', [PVOID]), } as unknown as Win32Bindings return cachedBindings @@ -273,10 +287,10 @@ function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] { return entries } -/** Read one process's creation-time identity through GetProcessTimes. */ -function windowsCreationTime(bindings: Win32Bindings, pid: number): string | undefined { +/** Read one process's creation identity and current wait state. */ +function windowsProcessState(bindings: Win32Bindings, pid: number): WindowsProcessState | undefined { const { FILETIME } = win32Structs() - const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) + const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, pid) if (isInvalidHandle(handle)) return undefined try { const creation = allocNative(FILETIME, 1) @@ -288,7 +302,14 @@ function windowsCreationTime(bindings: Win32Bindings, pid: number): string | und treats undefined as a detector miss. */ if (bindings.getProcessTimes(handle, creation, exit, kernel, user) === 0) return undefined const record = koffi.decode(creation, FILETIME) as { dwLowDateTime: number; dwHighDateTime: number } - return `${record.dwHighDateTime}:${record.dwLowDateTime}` + const wait = bindings.waitForSingleObject(handle, 0) + /* v8 ignore next -- an opened process handle has exactly one of these two + zero-time wait states; an unexpected Win32 failure is an unreadable process. */ + if (wait !== WAIT_OBJECT_0 && wait !== WAIT_TIMEOUT) return undefined + return { + started: `${record.dwHighDateTime}:${record.dwLowDateTime}`, + active: wait === WAIT_TIMEOUT, + } } finally { bindings.closeHandle(handle) } @@ -298,7 +319,7 @@ function windowsCreationTime(bindings: Win32Bindings, pid: number): string | und function defaultWindowsProcessInternals(): WindowsProcessInspectorInternals { return { snapshot: () => snapshotWindowsProcesses(win32Bindings()), - creationTime: pid => windowsCreationTime(win32Bindings(), pid), + processState: pid => windowsProcessState(win32Bindings(), pid), taskkill: taskkillTree, } } diff --git a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts index 667c6cae46..e00bdeb9e2 100644 --- a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts @@ -9,21 +9,22 @@ import type { NativePtr, ProcessEntry, WindowsProcessInspectorInternals, + WindowsProcessState, } from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts' function fakeInternals() { const entries: ProcessEntry[] = [] - const times = new Map() + const states = new Map() const kills: Array<[number, boolean]> = [] return { internals: { snapshot: () => [...entries], - creationTime: pid => times.get(pid), + processState: pid => states.get(pid), taskkill: (pid: number, force: boolean) => { kills.push([pid, force]) }, } satisfies WindowsProcessInspectorInternals, - add(entry: ProcessEntry, started?: string): void { + add(entry: ProcessEntry, started?: string, active = true): void { entries.push(entry) - if (started !== undefined) times.set(entry.pid, started) + if (started !== undefined) states.set(entry.pid, { started, active }) }, kills, } @@ -80,6 +81,9 @@ describe('WindowsProcessInspector (injected internals)', () => { expect(inspector.isAlive({ pid: 11, started: 't11' })).toBe(true) expect(inspector.isAlive({ pid: 11, started: 'stale' })).toBe(false) expect(inspector.isAlive({ pid: 99, started: 't99' })).toBe(false) + + fake.add({ pid: 12, parentPid: 10 }, 't12', false) + expect(inspector.isAlive({ pid: 12, started: 't12' })).toBe(false) }) it('maps SIGKILL to a forced taskkill and other signals to the grace form', () => { @@ -94,8 +98,10 @@ describe('WindowsProcessInspector (injected internals)', () => { it('signals a process only while its start identity matches', () => { const fake = fakeInternals() fake.add({ pid: 10, parentPid: 0 }, 't10') + fake.add({ pid: 11, parentPid: 10 }, 't11', false) const inspector = new WindowsProcessInspector(fake.internals) inspector.signalProcess({ pid: 10, started: 't10' }, 'SIGKILL') + inspector.signalProcess({ pid: 11, started: 't11' }, 'SIGKILL') inspector.signalProcess({ pid: 10, started: 'stale' }, 'SIGTERM') expect(fake.kills).toEqual([[10, true]]) }) From 2f759a6b6509c3e56be761692068ea62b0372fe2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 14:30:59 +0800 Subject: [PATCH 35/60] fix(shell): preserve prompt-like PowerShell output --- packages/shell/tool-pwsh-persistent/package.json | 2 +- packages/shell/tool-pwsh-persistent/src/index.ts | 2 +- .../shell/tool-pwsh-persistent/tests/tools.spec.ts | 14 +++++++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 84278ef05e..24384c240c 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh-persistent/src/index.ts b/packages/shell/tool-pwsh-persistent/src/index.ts index 5fc2d3a03d..f0a575363e 100644 --- a/packages/shell/tool-pwsh-persistent/src/index.ts +++ b/packages/shell/tool-pwsh-persistent/src/index.ts @@ -124,7 +124,7 @@ function commandOutput( // scrolled out and extraction fell back to the echoed copy. captured = captured.replaceAll(wrapper, '') return { - text: stripPrompt(captured.replace(/^\r?\n/, '')), + text: captured.replace(/^\r?\n/, '').replace(/\r?\n$/, ''), incomplete: startMarker < 0, exitCode: Number(status), } diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index 6a856b1ff8..8d76abd3a6 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -100,6 +100,7 @@ type StubMode = | 'paged-scrollback' | 'with-echo' | 'exit-after-send' + | 'prompt-collision' const START_PATTERN = /__DSH_PERSISTENT_PWSH_START_[^_]+(?:-[^_]+)*__/ const END_PATTERN = /__DSH_PERSISTENT_PWSH_END_[^:]+:/ @@ -215,7 +216,9 @@ class StubTerminalSession implements TerminalBackendSession { } const commandOutput = this.mode === 'large' ? 'x'.repeat(100) - : this.mode === 'nonzero' ? '' : 'hello from stub' + : this.mode === 'nonzero' ? '' + : this.mode === 'prompt-collision' ? this.motd + : 'hello from stub' const exitCode = this.mode === 'nonzero' ? 7 : 0 const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}` this.scrollback += output @@ -362,6 +365,15 @@ describe('tool-pwsh-persistent', () => { expect(result).not.toContain('Invoke-Expression') }) + it('preserves command output that equals the private shell prompt', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'prompt-collision' + expect(text(await call(ctx, owner, 'complete prompt collision'))).toBe(session.motd) + }) + it('reports the exit path when the shell exits between send settlement and the next poll', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub' }) await call(ctx, owner, 'warm up') From cef99b17d47fdda90921da698cdc8d35e497e2b3 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 14:31:22 +0800 Subject: [PATCH 36/60] test(acp): snapshot persistent PowerShell tool --- examples/acp-agent/tests/acp.snapshot.ts | 10 +++++ .../tests/persistent-pwsh.cordis.snapshot.yml | 45 +++++++++++++++++++ .../tests/persistent-pwsh.cordis.yml | 42 +++++++++++++++++ .../persistent-pwsh-tool-turn/input.json | 7 +++ .../persistent-pwsh-tool-turn/session.jsonl | 34 ++++++++++++++ .../stdout.expected.jsonl | 4 ++ .../system-prompt.expected.md | 3 ++ .../tool-schemas.expected.json | 21 +++++++++ examples/package.json | 1 + pnpm-lock.yaml | 3 ++ 10 files changed, 170 insertions(+) create mode 100644 examples/acp-agent/tests/persistent-pwsh.cordis.snapshot.yml create mode 100644 examples/acp-agent/tests/persistent-pwsh.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/input.json create mode 100644 examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/system-prompt.expected.md create mode 100644 examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/tool-schemas.expected.json diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index c5bf81fc1f..b784529d4e 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -61,6 +61,7 @@ const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url)) const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url)) +const PERSISTENT_PWSH_CONFIG = fileURLToPath(new URL('./persistent-pwsh.cordis.yml', import.meta.url)) const BACKGROUND_TASK_ADMISSION_CONFIG = fileURLToPath( new URL('../background-job-admission.cordis.yml', import.meta.url), ) @@ -264,6 +265,15 @@ const SCENARIOS: Scenario[] = [ // newline and one recording replays on every host. pwshOnly: true, }, + { + name: 'persistent-pwsh-tool-turn', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'persistent-pwsh', + configPath: PERSISTENT_PWSH_CONFIG, + pwshOnly: true, + }, // Authored keyless replay through a test-only partial-Landlock provider: // the exact compatibility notice must stay ordinary stderr when the wrapped // `false` command exits 1, rather than becoming SANDBOX_UNAVAILABLE. diff --git a/examples/acp-agent/tests/persistent-pwsh.cordis.snapshot.yml b/examples/acp-agent/tests/persistent-pwsh.cordis.snapshot.yml new file mode 100644 index 0000000000..7b90b2298b --- /dev/null +++ b/examples/acp-agent/tests/persistent-pwsh.cordis.snapshot.yml @@ -0,0 +1,45 @@ +# Keyless replay counterpart to persistent-pwsh.cordis.yml. +- id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-pro + +- id: terminal + name: '@deepseek-ai/dsh-terminal' + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.cwd() + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: terminal-pwsh + name: '@deepseek-ai/dsh-terminal-bash' + config: + shellDialect: pwsh + timeoutMs: 30000 + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: false + skills: + enabled: false + toolBash: false + toolJobs: false + goals: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-pwsh-persistent + name: '@deepseek-ai/dsh-tool-pwsh-persistent' diff --git a/examples/acp-agent/tests/persistent-pwsh.cordis.yml b/examples/acp-agent/tests/persistent-pwsh.cordis.yml new file mode 100644 index 0000000000..0b3cd18c70 --- /dev/null +++ b/examples/acp-agent/tests/persistent-pwsh.cordis.yml @@ -0,0 +1,42 @@ +# Minimal live counterpart for the persistent-pwsh-tool-turn snapshot composition. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + models: + - id: deepseek-v4-pro + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.cwd() + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: terminal + name: '@deepseek-ai/dsh-terminal' + +- id: terminal-pwsh + name: '@deepseek-ai/dsh-terminal-bash' + config: + shellDialect: pwsh + timeoutMs: 30000 + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: false + skills: + enabled: false + toolBash: false + toolJobs: false + goals: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-pwsh-persistent + name: '@deepseek-ai/dsh-tool-pwsh-persistent' diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/input.json b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/input.json new file mode 100644 index 0000000000..653e9a346c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/session.jsonl b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/session.jsonl new file mode 100644 index 0000000000..220bdd516e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/session.jsonl @@ -0,0 +1,34 @@ +{"type":"session","version":0,"id":"0b7ff6ab-2486-4b2f-a43e-0fa29a1a46ed","createdAt":1785678162241,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785898456879,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"b4ce4fdc-a87a-41d0-b418-80a0fb235abb"}]}} +{"type":"turn/start","seq":1,"time":1785898456880,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785898456880,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785678162261,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785898456903,"data":{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"b4ce4fdc-a87a-41d0-b418-80a0fb235abb"},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":1785898456903,"data":{"title":"Use the pwsh tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":6,"time":1785898456904,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":7,"time":1785898456904,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":8,"time":1785678162968,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":9,"time0":1785678163361,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,0,0,0,0,0,17,0,0,0,0,0,0,0,1,290,0,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," PowerShell"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," execute"," it","."]}} +{"type":"assistant/chunk","seq":31,"time":1785678163671,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":32,"time0":1785678163671,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,17,0,0,0,0,0,109,0,0,0,0,0,0,22,305],"id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","args":["","{","\"","command","\"",": ","\"","[","Console","]","::","Out",".Write","('","P","WS","H","_OK","')","\"","}"]}} +{"type":"assistant/chunk","seq":53,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."}}}} +{"type":"assistant/chunk","seq":54,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write('PWSH_OK')\"}"}}}} +{"type":"assistant/chunk","seq":55,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":56,"time":1785898456913,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":57,"time":1785898456913,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."},{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write('PWSH_OK')\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"82945de6-83e2-4b93-b6d2-89d58921eacf"},"usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56],"surfaceOp":"append"} +{"type":"tool/call","seq":58,"time":1785898456913,"data":{"turn":1,"step":1,"callId":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write('PWSH_OK')\"}"}} +{"type":"tool/result","seq":59,"time":1785898456933,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_e0MSVSocL0o4UWjOdG4c2072"},"content":[{"type":"tool-result","toolCallId":"call_00_e0MSVSocL0o4UWjOdG4c2072","content":[{"type":"text","text":"PWSH_OK"}],"isError":false}],"role":"user","id":"874a846b-54b7-45cc-b3cb-edb8f868e1c5"}},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"step/end","seq":60,"time":1785898456933,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":61,"time":1785898456939,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":62,"time":1785678165136,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":63,"time0":1785678165312,"data":{"turn":1,"step":2,"index":0,"dt":[44,56,0,0,42,0,0,0,48,0,0,0,48,0,0,60,0,0,0,0,39,0,0,0],"texts":["The"," command"," executed"," successfully"," and"," printed"," \"","P","WS","H","_OK","\"."," Now"," I"," need"," to"," reply"," with"," \"","D","ONE","\""," and"," stop","."]}} +{"type":"assistant/chunk","seq":88,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":89,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":90,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":91,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":92,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":93,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":94,"time":1785898456944,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":95,"time":1785898456944,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"36aaf6a0-1556-42e4-aed3-626caa8f7aaf"},"usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}},"sourceEventSeqs":[62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94],"surfaceOp":"append"} +{"type":"step/end","seq":96,"time":1785898456944,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":97,"time":1785898456944,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/system-prompt.expected.md new file mode 100644 index 0000000000..229b3a6f6c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/system-prompt.expected.md @@ -0,0 +1,3 @@ +You are an AI agent powered by DeepSeek Harness. + +You are a concise snapshot agent working in {{cwd}}. diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/tool-schemas.expected.json new file mode 100644 index 0000000000..20f5a3e55c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/tool-schemas.expected.json @@ -0,0 +1,21 @@ +{ + "initial": [ + { + "name": "pwsh", + "description": "Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + } + ], + "changes": [] +} diff --git a/examples/package.json b/examples/package.json index cd348d5fe2..8b1d8875ba 100644 --- a/examples/package.json +++ b/examples/package.json @@ -95,6 +95,7 @@ "@deepseek-ai/dsh-tool-lsp": "workspace:*", "@deepseek-ai/dsh-tool-terminal": "workspace:*", "@deepseek-ai/dsh-tool-pwsh": "workspace:*", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-skill": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1d23dd996..465df6b10a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -688,6 +688,9 @@ importers: '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:* version: link:../packages/shell/tool-pwsh + '@deepseek-ai/dsh-tool-pwsh-persistent': + specifier: workspace:* + version: link:../packages/shell/tool-pwsh-persistent '@deepseek-ai/dsh-tool-ralph': specifier: workspace:* version: link:../packages/workflow/tool-ralph From d0cdf520304d33c15b6ccf91a419518b26d1477c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 14:31:28 +0800 Subject: [PATCH 37/60] docs(pwsh): align persistent PTY contracts --- .../architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml | 4 ++-- .../architecture/2026-08-11-pwsh-persistent-pty.md | 6 +++--- .../architecture/2026-08-11-pwsh-persistent-pty.zh.md | 6 +++--- packages/shell/tool-pwsh-persistent/README.i18n.yaml | 4 ++-- packages/shell/tool-pwsh-persistent/README.md | 2 +- packages/shell/tool-pwsh-persistent/README.zh.md | 2 +- packages/terminal/terminal-bash/README.i18n.yaml | 4 ++-- packages/terminal/terminal-bash/README.md | 2 +- packages/terminal/terminal-bash/README.zh.md | 2 +- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml index ce1e88c050..80a1959b5a 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md -2026-08-11-pwsh-persistent-pty.md: 092302ec001909683f9b7056e982309889c7f23e -2026-08-11-pwsh-persistent-pty.zh.md: 857f78c66f109ababe8a5961fa923d819857fd88 +2026-08-11-pwsh-persistent-pty.md: 8353b3ab3cdbf20add22a55acb03312c94283602 +2026-08-11-pwsh-persistent-pty.zh.md: 95048a02416dfcf5f0ef2837d99a561008f6496f diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md index 092302ec00..8353b3ab3c 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md @@ -18,7 +18,7 @@ A model-facing persistent `pwsh` tool ships on Windows with the same contract as ### Windows substrate in `@deepseek-ai/dsh-subprocess-local` -`createProcessInspector()` returns a `WindowsProcessInspector` on win32 instead of throwing. The koffi-backed inspector enumerates the process table through Toolhelp32 with GetProcessTimes creation-time identities (pid-reuse fencing like the POSIX start identity), reports the **shell pid as a pseudo foreground group** (Windows has no POSIX groups; the stable value lets the prompt-marker readiness fast path settle in one poll interval), reports no stdin-wait evidence (readiness degrades exactly like macOS), and signals through `taskkill /T` escalation (`/F` only for SIGKILL). koffi (`^3.1.0`, the version `sandbox-windows-acl` already pins) loads lazily on win32 only. +`createProcessInspector()` returns a `WindowsProcessInspector` on win32 instead of throwing. The koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes creation identities with zero-time process-handle waits (pid-reuse fencing plus terminated-object detection), reports the **shell pid as a pseudo foreground group** (Windows has no POSIX groups; the stable value lets the prompt-marker readiness fast path settle in one poll interval), reports no stdin-wait evidence (readiness degrades exactly like macOS), and signals through `taskkill /T` escalation (`/F` only for SIGKILL). koffi (`^3.1.0`, the version `sandbox-windows-acl` already pins) loads lazily on win32 only. `LocalTerminalHandle` branches for win32 because node-pty's `kill(signal)` throws ("Signals not supported on windows") and its bare kill delegates to a console-list agent that fails without a parent console. Teardown escalates through taskkill fenced on the shell's start identity, and — because an externally taskkilled shell may never fire node-pty's exit notification — the handle settles `done` from the inspector-verified absence (`settleExitIfGone`). `signalForeground` maps SIGINT to a `\x03` Ctrl-C input write (the console-wide delivery conhost turns into a CTRL_C event; verified to interrupt a running command), routes SIGTERM/SIGKILL to taskkill, and rejects SIGTSTP/SIGHUP as unavailable on Windows. The public `PtySignal` set and seam types are unchanged; the mapping lives in the backend. @@ -38,7 +38,7 @@ The minimal preset gates its persistent shell stack by platform with the #2234 ` ### Testing -The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. +The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. The ACP keyless snapshot boots the persistent tool through a real Loader composition and pins its model-visible schema and result. ## Alternatives considered @@ -62,4 +62,4 @@ The Windows test surface follows master's exemption structure: terminal-bash and **Input echo is an accepted platform fact.** PSReadLine echoes submitted input; the marker-anchored extraction and wrapper-source strip remove it in complete results, with bounded residual in partial-output fallbacks. -**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the prompt function's `[Console]::` call; the `Write-Host -NoNewline` fallback is designed and decided by the Windows-native lane. A model redefinition of the `prompt` function degrades readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi is now a dependency of the process substrate, carrying the same install/prebuild review the sandbox package already has. +**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the bootstrap's `[Console]::` encoding pin and prompt marker; commands then settle through the printable prompt and silence tier, while non-ASCII output may follow the host code page. A model redefinition of the `prompt` function likewise degrades readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi is now a dependency of the process substrate, carrying the same install/prebuild review the sandbox package already has. diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md index 857f78c66f..95048a0241 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md @@ -18,7 +18,7 @@ harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POS ### `@deepseek-ai/dsh-subprocess-local` 的 Windows 基座 -`createProcessInspector()` 在 win32 返回 `WindowsProcessInspector` 而不是抛错。基于 koffi 的检查器通过 Toolhelp32 枚举进程表并取 GetProcessTimes 创建时间身份(与 POSIX start-identity 相同的 PID 复用防护),把 **shell pid 作为伪前台进程组**(Windows 没有 POSIX 进程组;这个稳定值让 prompt-marker 就绪快路径在一个轮询间隔内结算),不报告 stdin-wait 证据(就绪与 macOS 同档),信号走 `taskkill /T` 升级(仅 SIGKILL 加 `/F`)。koffi(`^3.1.0`,`sandbox-windows-acl` 已固定的版本)仅在 win32 惰性加载。 +`createProcessInspector()` 在 win32 返回 `WindowsProcessInspector` 而不是抛错。基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 创建身份与进程句柄零时等待结合起来(同时防止 PID 复用并识别已终止的进程对象),把 **shell pid 作为伪前台进程组**(Windows 没有 POSIX 进程组;这个稳定值让 prompt-marker 就绪快路径在一个轮询间隔内结算),不报告 stdin-wait 证据(就绪与 macOS 同档),信号走 `taskkill /T` 升级(仅 SIGKILL 加 `/F`)。koffi(`^3.1.0`,`sandbox-windows-acl` 已固定的版本)仅在 win32 惰性加载。 `LocalTerminalHandle` 为 win32 分支,因为 node-pty 的 `kill(signal)` 会抛错("Signals not supported on windows"),其无参 kill 委托的 console-list agent 在没有父控制台时失败。拆卸经 taskkill 升级并以 shell 的启动身份作栅栏;由于被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知,句柄从 inspector 验证的消失状态结算 `done`(`settleExitIfGone`)。`signalForeground` 把 SIGINT 映射为 `\x03` Ctrl-C 输入写入(conhost 转为控制台级 CTRL_C 事件的投递方式;实测可中断运行中的命令),SIGTERM/SIGKILL 路由到 taskkill,SIGTSTP/SIGHUP 以 Windows 不可用为由拒绝。公共 `PtySignal` 集合与 seam 类型不变;映射全部留在 backend。 @@ -38,7 +38,7 @@ minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell ### 测试 -Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 +Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。ACP keyless snapshot 通过真实 Loader 组合启动持久工具,并固定模型可见的 schema 与结果。 ## 备选方案 @@ -62,4 +62,4 @@ Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-lo **输入回显是接受的平台事实。** PSReadLine 回显提交的输入;marker 锚定提取与包装器原文剥离在完整结果中移除它,部分输出回退中残留有界。 -**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝 prompt 函数的 `[Console]::` 调用;`Write-Host -NoNewline` 回退已设计好,由 Windows-native 车道裁决。模型重定义 `prompt` 函数会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。 +**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker;此时命令通过可打印提示符和静默档结算,非 ASCII 输出可能沿用宿主代码页。模型重定义 `prompt` 函数同样会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。 diff --git a/packages/shell/tool-pwsh-persistent/README.i18n.yaml b/packages/shell/tool-pwsh-persistent/README.i18n.yaml index 0786399570..cc1175ca0e 100644 --- a/packages/shell/tool-pwsh-persistent/README.i18n.yaml +++ b/packages/shell/tool-pwsh-persistent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/tool-pwsh-persistent/README.md -README.md: 7bb66477ab7ffe52039b0c699d48c9ca761ac04c -README.zh.md: b20041b1d42908d4d1e893455d825eafa2e2f87d +README.md: a57c940801606c2eef450f0e54fecb434c62a406 +README.zh.md: 4bd7ecdad08e504daa3ff6283f8b77ac9428385f diff --git a/packages/shell/tool-pwsh-persistent/README.md b/packages/shell/tool-pwsh-persistent/README.md index 7bb66477ab..a57c940801 100644 --- a/packages/shell/tool-pwsh-persistent/README.md +++ b/packages/shell/tool-pwsh-persistent/README.md @@ -51,5 +51,5 @@ Append-only tool results follow the reusable request prefix. - A model redefinition of the `prompt` function removes the readiness marker; the shell then settles on the silence tier instead of the marker fast path. - There is no interactive stdin during a command: a foreground command that reads input blocks until the readiness timeout, which resets the shell. - SIGTSTP/SIGHUP are unavailable on Windows (backend-rejected); SIGINT is delivered as a console-wide Ctrl-C input write, which at a prompt cancels the pending line instead of signalling a process. -- Under the Windows ACL sandbox's read-only mode, pwsh starts in ConstrainedLanguage, which may deny the prompt function's `[Console]::` call; the backend's documented `Write-Host -NoNewline` fallback is selected by the Windows-native lane evidence. +- Under the Windows ACL sandbox's read-only mode, pwsh starts in ConstrainedLanguage, which may deny the bootstrap's `[Console]::` encoding pin and prompt marker. Commands can still settle through the printable prompt and silence tier, but non-ASCII output may follow the host code page. - The BEL-terminated OSC marker remains a readiness signal only; a BEL event channel to the model stays deferred, aligned with the current implementation. diff --git a/packages/shell/tool-pwsh-persistent/README.zh.md b/packages/shell/tool-pwsh-persistent/README.zh.md index b20041b1d4..4bd7ecdad0 100644 --- a/packages/shell/tool-pwsh-persistent/README.zh.md +++ b/packages/shell/tool-pwsh-persistent/README.zh.md @@ -51,5 +51,5 @@ - 模型重定义 `prompt` 函数会移除就绪标记;shell 随后退化为静默档而非 marker 快路径。 - 命令执行期间没有交互 stdin:读取输入的前台命令会阻塞到就绪超时,随后重置 shell。 - SIGTSTP/SIGHUP 在 Windows 不可用(backend 拒绝);SIGINT 以控制台级 Ctrl-C 输入写入投递,在提示符处取消当前行而非向进程发信号。 -- 在 Windows ACL 沙箱的只读模式下,pwsh 以 ConstrainedLanguage 启动,可能拒绝 prompt 函数的 `[Console]::` 调用;backend 文档化的 `Write-Host -NoNewline` 回退由 Windows-native 车道证据裁决。 +- 在 Windows ACL 沙箱的只读模式下,pwsh 以 ConstrainedLanguage 启动,可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker。命令仍可通过可打印提示符和静默档结算,但非 ASCII 输出可能沿用宿主代码页。 - BEL 终结的 OSC 标记仍只是就绪信号;面向模型的 BEL 事件通道保持延后,与当前实现对齐。 diff --git a/packages/terminal/terminal-bash/README.i18n.yaml b/packages/terminal/terminal-bash/README.i18n.yaml index e1b60c7d61..d6e544137a 100644 --- a/packages/terminal/terminal-bash/README.i18n.yaml +++ b/packages/terminal/terminal-bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/terminal/terminal-bash/README.md -README.md: 82d0166ff5f63c108770b0a3011e5ebb7d63b433 -README.zh.md: 10d6260360e57ee6c81fb73e96f7cb952cfc9146 +README.md: 2f3f59b1acb88ff9905e78e7fc8d0d9fbcdbf0ba +README.zh.md: f3daa0a3bc9c160236ad19b35589778d99d48b36 diff --git a/packages/terminal/terminal-bash/README.md b/packages/terminal/terminal-bash/README.md index 82d0166ff5..2f3f59b1ac 100644 --- a/packages/terminal/terminal-bash/README.md +++ b/packages/terminal/terminal-bash/README.md @@ -34,6 +34,6 @@ A standing-policy change appends an owner-rendered superseding runtime-context s - Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported. - Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. Windows is such a provider: the shell pid is the pseudo foreground group and there is no exact stdin-wait tier, so a marker-less child settles on the silence bound. -- The pwsh bootstrap writes through `[Console]::` (the UTF-8 encoding pin and the prompt function), which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny; the `Write-Host -NoNewline` fallback is the designed alternative, decided by the Windows-native lane. +- The pwsh bootstrap writes through `[Console]::` (the UTF-8 encoding pin and the prompt function), which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny. The shell can still settle through the controlled printable prompt and silence tier, but marker readiness is unavailable and non-ASCII output may follow the host code page. - Cleanup guarantees are those of `SubprocessTerminalHandle`; provider-specific gaps belong to that implementation's contract rather than this PTY consumer. - Sessions do not survive harness process exit. diff --git a/packages/terminal/terminal-bash/README.zh.md b/packages/terminal/terminal-bash/README.zh.md index 10d6260360..f3daa0a3bc 100644 --- a/packages/terminal/terminal-bash/README.zh.md +++ b/packages/terminal/terminal-bash/README.zh.md @@ -34,6 +34,6 @@ - 输出按行规范化;不支持全屏备用缓冲区交互。 - 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。Windows 正是这样的提供方:shell pid 是伪前台进程组,没有精确的 stdin-wait 档,因此无标记的子进程按静默上限结算。 -- pwsh 引导(UTF-8 编码钉与 `prompt` 函数)通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它;`Write-Host -NoNewline` 回退是设计好的备选,由 Windows-native 车道裁决。 +- pwsh 引导(UTF-8 编码钉与 `prompt` 函数)通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它。shell 仍可通过受控可打印提示符和静默档结算,但无法使用 marker 就绪,非 ASCII 输出也可能沿用宿主代码页。 - 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的约定,而非这个 PTY 消费方。 - harness 进程退出后,会话无法继续存在。 From 5648d4ad1c67f5785f5d622021af832582bb1674 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 16:38:44 +0800 Subject: [PATCH 38/60] test(typert): poll for the steady-state registration failure log --- packages/typert/loader/tests/loader.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 6dccd2b5dd..2f4b4cae04 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -398,9 +398,10 @@ describe('typert loader', () => { await ctx.loader.create({ name: '@fixture/steady-failure' }) await ctx.loader.await() - await new Promise(resolve => setTimeout(resolve, 20)) - - expect(logged).toHaveBeenCalledWith(expect.objectContaining({ message: 'register failed' })) + // The failing contributor's error is reported on the post-await flush. + await vi.waitFor(() => { + expect(logged).toHaveBeenCalledWith(expect.objectContaining({ message: 'register failed' })) + }, { timeout: 10_000 }) expect(ctx.typert.getPackage('@fixture/steady-failure')).toBeUndefined() }) }) From dc8991879ac3cb3fa80e6765f773a71dd3aeeac1 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 16:38:52 +0800 Subject: [PATCH 39/60] test(agent-instructions): extend the workspace-context wait budget --- .../context/agent-instructions/tests/agent-instructions.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 171f7322f0..52e2e79e00 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -223,7 +223,7 @@ async function workspaceContextOf(agent: Agent): Promise { message.source.kind === 'agent-instructions') expect(context).toBeDefined() return context! - }) + }, { timeout: 10_000 }) } async function syncWorkspaceContext(ctx: Context, agent: Agent): Promise { From f97ea54ca95d3f6654f82039fab85bb7ff37d903 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 11:01:58 +0800 Subject: [PATCH 40/60] fix(ci): install Wine offline from the restored apt archive The windows wine-blocking job installs Wine with apt-get over the local .deb archive, but apt re-downloads the full 100+ MB closure from the Ubuntu mirror anyway. A degraded runner network stalled that transfer past the job's 15-minute budget and cancelled the check. Install the restored archive directly with dpkg (no repository access) and keep the apt network install as the fallback when the archive cannot satisfy the closure. --- .github/workflows/ci.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cab0cf791..7cdfda7f87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -389,7 +389,17 @@ jobs: - name: Install Wine run: | if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then - sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb + # The restored archive is the full --download-only closure of + # `wine` for this runner image, so installing the .debs directly + # with dpkg needs no repository access. apt-get would instead + # re-download the same 100+ MB closure from the mirror, which has + # stalled the job past its budget on a degraded runner network. + # If the archive cannot satisfy the closure, fall back to the apt + # network install. + if ! sudo DEBIAN_FRONTEND=noninteractive dpkg -i "$HOME"/wine-debs/*.deb; then + sudo DEBIAN_FRONTEND=noninteractive dpkg --configure -a || true + sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb + fi else sudo apt-get update sudo apt-get install -y --no-install-recommends --download-only wine From 036ba74c43a59945c5d89487dcfe26d0f8f136fb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 11:41:49 +0800 Subject: [PATCH 41/60] test(fs-local): attribute diff-basis cancellation to fsio allocations The observes-cancellation-after-open/stat test asserted that the process-wide Buffer.allocUnsafe call count stayed flat after abort, but vitest's fork IPC (node:internal/child_process serialization) also calls Buffer.allocUnsafe, so unrelated IPC traffic made the assertion timing-racy under CI load. Attribute each allocation to the fsio read path by stack and assert that the abort prevents the diff-basis buffer allocation. --- packages/fs/fs-local/tests/fsio.spec.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index b51f6ba83b..5cf5ffd5d3 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -475,7 +475,17 @@ describe('readTextForDiff', () => { const reached = Promise.withResolvers() const release = Promise.withResolvers() let statCalls = 0 - const allocate = vi.spyOn(Buffer, 'allocUnsafe') + const allocUnsafe = Buffer.allocUnsafe.bind(Buffer) + // Buffer.allocUnsafe is also called by vitest's fork IPC + // (node:internal/child_process serialization), so a process-wide call count + // is timing-racy under CI load. Attribute allocations to the fsio read path + // instead: the abort must prevent the diff-basis buffer allocation. + const fsioAllocations: string[] = [] + const allocate = vi.spyOn(Buffer, 'allocUnsafe').mockImplementation((size: number) => { + const stack = new Error().stack ?? '' + if (stack.includes('readTextForDiff')) fsioAllocations.push(stack) + return allocUnsafe(size) + }) vi.resetModules() vi.doMock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() @@ -509,12 +519,11 @@ describe('readTextForDiff', () => { const controller = new AbortController() const pending = isolatedReadTextForDiff(file, 8, controller.signal) await reached.promise - const allocationCalls = allocate.mock.calls.length controller.abort() release.resolve(undefined) await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' }) expect(statCalls).toBe(stage === 'open' ? 0 : 1) - expect(allocate).toHaveBeenCalledTimes(allocationCalls) + expect(fsioAllocations).toEqual([]) } finally { release.resolve(undefined) allocate.mockRestore() From 1c77a78d6ac16a49f6821a60fa1ae285ae5e885d Mon Sep 17 00:00:00 2001 From: Magolor Date: Wed, 19 Aug 2026 11:21:29 +0800 Subject: [PATCH 42/60] perf(session): reuse immutable persistence seed --- .../session-persistence/src/coordinator.ts | 2 +- .../tests/persistence.spec.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index eb5f9714c4..5bb182b925 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -1170,7 +1170,7 @@ export class PersistenceCoordinator { this.live.set(session, restored) return restored } - const seed = session.events.map(e => structuredClone(e)) + const seed = session.events const live: LiveSessionState = { init: Promise.resolve(), writes: this.createWriteBehind(session, () => live.init), diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index 50ad798e04..1e66fec709 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -180,6 +180,7 @@ class ControlledBackend implements PersistenceBackend { readonly name = 'session-persistence-controlled' readonly store: MemoryStore = new Map() readonly lifecycle: string[] = [] + lastAppendedBatch: readonly SessionEvent[] | undefined appendAttempts = 0 loadAttempts = 0 repairAttempts = 0 @@ -212,6 +213,7 @@ class ControlledBackend implements PersistenceBackend { } async appendBatch(m: SessionHeader, events: readonly SessionEvent[], _isMaterialized: boolean): Promise { + this.lastAppendedBatch = events const attempt = ++this.appendAttempts await this.beforeAppend?.(attempt) const entry = this.store.get(m.id) @@ -280,6 +282,26 @@ runCoordinatorContract('memory', async (): Promise => { }) describe('PersistenceCoordinator bounded writes', () => { + it('retains the immutable session seed without cloning it', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + const session = ctx.sessions.create(SessionId('shared-seed'), { seed: oneTurnLog() }) + const seed = session.events + await ctx.sessions.flush(session) + + expect(backend.lastAppendedBatch).toBe(seed) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + it('cancels the batching deadline when live initialization rejects', async () => { vi.useFakeTimers() const ctx = new Context() From 53c4a4a1748b3c9e274581a1710bb3af7d6ac781 Mon Sep 17 00:00:00 2001 From: Magolor Date: Wed, 19 Aug 2026 13:30:53 +0800 Subject: [PATCH 43/60] docs(session): document persistence seed ownership --- .../2026-06-18-shared-persistence-write-coordinator.i18n.yaml | 4 ++-- .../2026-06-18-shared-persistence-write-coordinator.md | 4 ++++ .../2026-06-18-shared-persistence-write-coordinator.zh.md | 4 ++++ packages/session/session-persistence/src/coordinator.ts | 1 + .../session/session-persistence/tests/persistence.spec.ts | 4 +++- 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 38a665a0fd..85a8dd684b 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md -2026-06-18-shared-persistence-write-coordinator.md: 93b6cd1bd058499e71948d3909de8e4c076b445e -2026-06-18-shared-persistence-write-coordinator.zh.md: 9e1bc736d4dba5e59b763710976425fa0ae76c30 +2026-06-18-shared-persistence-write-coordinator.md: 286bbb7d5cd3720109db0d0abc0bb72ddbfcbdcd +2026-06-18-shared-persistence-write-coordinator.zh.md: d24398c4bc9445739b4cdeb9f2ed176577060932 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 93b6cd1bd0..286bbb7d5c 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -16,6 +16,10 @@ Composition, not inheritance. The coordinator is a concrete class the backend ho The coordinator holds one lifecycle entry for each exact live `Session`: initialization plus a package-private write controller that owns pending events, a fixed batching deadline, the active write, failure retention, and the shared flush barrier. Each `session/event` enters that bounded write path, and `session/flush` bypasses the wait to observe quiescence. The [flush-controller simplification](../simplification/2026-07-23-collapse-persistence-flush-state.md) owns controller consolidation; the [bounded batching decision](2026-08-08-bounded-session-persistence-write-batching.md) owns scheduling cadence. +Creation borrows the exact `Session.events` snapshot as its persistence seed. `Session` has already detached, validated, and deeply frozen every event, and the snapshot array remains stable when later appends replace the cached view. The coordinator and its backend hooks only read this typed in-process value, so cloning the complete log again would duplicate the ownership work described by the [agent-scope runtime decision](2026-07-12-agent-scope-runtime-design.md#session-append-materialize-validate-commit-notify). Public persistence `append()` still snapshots caller-owned input at its API boundary. + +Prepared-session suffixes and events admitted to the write-behind queue retain their existing copies. Those paths establish asynchronous queue ownership one suffix or event at a time and have no measured whole-log clone cost; removing their copies remains a separate ownership audit rather than part of creation-seed borrowing. + The coordinator retires a session from `session/disposed`: it waits for the controller's initialization and current flush, serializes a final drain, and removes the controller and owned per-id state only after success. A failure leaves the controller discoverable for backend teardown to retry. Settled per-id chain tails remove themselves only when they are still current, so a completion cannot erase a newer operation for the same id. Backend teardown unregisters write-path listeners, flushes every remaining controller, awaits per-id operations, and then closes the backend. ### The hook interface (`PersistenceBackend`) diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 9e1bc736d4..d24398c4bc 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -16,6 +16,10 @@ Status: implemented 协调器为每个存活的 `Session` 实例持有一个生命周期条目:初始化,加上一个包私有写入控制器,后者负责待处理事件、固定批处理截止时间、活跃写入、失败保留和共享 flush 屏障。每个 `session/event` 都进入这条有界写入路径,`session/flush` 则绕过等待以观察完全停稳。控制器归并由 [flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义;调度节奏由[有界批处理决策](2026-08-08-bounded-session-persistence-write-batching.md)定义。 +创建流程将 `Session.events` 的原始快照借作持久化种子。`Session` 已经分离、验证并深度冻结每个事件,后续追加会替换缓存视图,因此该快照数组保持稳定。协调器及其后端钩子只读取这个有类型的进程内值;再次克隆完整日志会重复 [agent scope 运行时决策](2026-07-12-agent-scope-runtime-design.md#session-append-materialize-validate-commit-notify)规定的所有权工作。持久化服务的公开 `append()` 仍在 API 边界为调用方拥有的输入创建快照。 + +已准备 Session 的后缀,以及进入 write-behind 队列的事件,仍保留现有复制。这些路径会逐个后缀或事件建立异步队列所有权,且没有已测得的完整日志克隆成本;移除这些复制属于单独的所有权审计,不属于创建种子的借用决策。 + 协调器通过 `session/disposed` 退役会话:它等待控制器完成初始化和当前 flush,串行执行最后一次排空,且仅在成功后才移除控制器与其拥有的每 id 状态。失败时保持控制器可被找到,以供后端 teardown(拆除)重试。每个 id 的已结算链尾仅在其仍是当前链尾时才移除自身,因此旧操作完成后不会抹除同一 id 的新操作。后端 teardown 会注销写入路径监听器、flush 每个剩余的控制器、等待所有按 id 串行化的操作,最后关闭后端。 ### 钩子接口(`PersistenceBackend`) diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index 5bb182b925..63def62528 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -1170,6 +1170,7 @@ export class PersistenceCoordinator { this.live.set(session, restored) return restored } + // Session owns this stable deep-frozen snapshot; backends only serialize it. const seed = session.events const live: LiveSessionState = { init: Promise.resolve(), diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index 1e66fec709..6c63d72b9f 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -281,7 +281,7 @@ runCoordinatorContract('memory', async (): Promise => { } }) -describe('PersistenceCoordinator bounded writes', () => { +describe('PersistenceCoordinator seed ownership', () => { it('retains the immutable session seed without cloning it', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -301,7 +301,9 @@ describe('PersistenceCoordinator bounded writes', () => { await ctx.fiber.dispose() } }) +}) +describe('PersistenceCoordinator bounded writes', () => { it('cancels the batching deadline when live initialization rejects', async () => { vi.useFakeTimers() const ctx = new Context() From e6b494ed171246487655dd44796096337250712e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 19 Aug 2026 14:00:33 +0800 Subject: [PATCH 44/60] ci: remove dead hosted serial-linux job serial-linux (hosted ubuntu-latest) has been `if: false` since 2026-07-30 and never runs. Remove the dead job block and retire the dangling references: - TODO(hosted-serial-ci) narrowed to the single remaining disabled hosted serial job (serial-macos); the hosted linux definition is gone. - The cache producer comment no longer claims serial-linux refreshes the hosted pnpm/Playwright caches; there is currently no active master producer for them, so restores are cold on a lockfile change. - The self-hosted standby's frozen-archive comment no longer cross-references the deleted job. - serial-macos gains its own intro comment since the shared 'hosted reference jobs below are disabled' lede was removed. No runner allocation, required gate, or all-checks-passed.needs reference this job; the aggregate is unchanged. --- .github/workflows/ci.yml | 89 ++++++---------------------------------- 1 file changed, 13 insertions(+), 76 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cab0cf791..89d27c13b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,8 +42,10 @@ env: jobs: - # TODO(hosted-serial-ci): Re-enable the three hosted serial reference jobs before release. - # The self-hosted standby remains active on every master push. + # TODO(hosted-serial-ci): Re-enable the remaining disabled hosted serial reference job + # (serial-macos) before release. The hosted serial-linux definition was removed as dead + # code (it was `if: false`); the self-hosted standby lane below remains active on every + # master push. # Three enterprise jobs isolate coverage, static analysis, and the # build-backed consumer tail. The consumer job owns the only Linux build so @@ -220,8 +222,10 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - # Pull requests restore the cache normally produced by serial-linux on - # master; they do not pay compression and upload on the required path. + # Pull requests restore the pnpm store and Playwright caches without paying + # compression and upload on the required path. There is currently no active + # master producer for these hosted caches — the hosted serial-linux job that + # refreshed them was disabled, so restores below are cold on a lockfile change. - uses: actions/cache/restore@v4 if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: @@ -492,76 +496,6 @@ jobs: shell: pwsh run: pnpm run check:ci:windows-complete - # The hosted reference jobs below are temporarily disabled; the self-hosted - # standby remains active. Each enabled host executes the complete, unsharded - # primary Node aggregate with one gate worker, giving reviewers a simple - # cross-platform oracle for completeness and timing. - serial-linux: - if: false - name: serial / linux - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 2 - - - uses: pnpm/action-setup@v4 - with: - dest: ${{ runner.temp }}/setup-pnpm - - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - - name: Configure pnpm store path - id: pnpm-store - run: | - store_root="$HOME/.local/share/pnpm/store" - echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" - store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) - echo "path=$store_path" >> "$GITHUB_OUTPUT" - - # Master refreshes the pnpm store cache that pull requests restore without saving. - # The store cache stays a hand-rolled actions/cache step rather than - # setup-node's `cache: pnpm`: the enterprise pull-request jobs above - # restore exactly this key and path, and setup-node's built-in cache - # uses its own key format — converting this producer would silently - # starve their documented restore-only optimization. - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - # Master produces the hosted Chromium cache restored by pull requests. - - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-playwright- - - - name: Install (immutable) - run: pnpm install --frozen-lockfile - - - name: Install Playwright Chromium and system dependencies - run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium - - - name: Prepare bubblewrap (unrestrict userns) - run: bash scripts/prepare-ci-bubblewrap.sh - - - name: Run complete unsharded primary Node CI serially - env: - DSH_ARCHIVE_BASE_REF: ${{ github.event.before }} - DSH_COVERAGE_MAX_WORKERS: '1' - DSH_E2E_MAX_WORKERS: '1' - DSH_GATE_CONCURRENCY: '1' - DSH_OXLINT_THREADS: '1' - DSH_PUBLINT_CONCURRENCY: '1' - DSH_SNAPSHOT_MAX_CONCURRENCY: '1' - run: pnpm run check:ci:linux-primary - # Hot-standby drill for the in-house self-hosted pool: every master move # re-runs the complete unsharded aggregate on the persistent 64-core VM, # continuously proving that environment can take over a required lane if @@ -580,8 +514,9 @@ jobs: name: serial / linux (self-hosted standby) runs-on: [self-hosted, linux, x64, vm-backup] steps: - # Full history + DSH_ARCHIVE_BASE_REF below: same frozen-archive - # comparison as serial-linux. Depth 2 would miss github.event.before + # Full history + DSH_ARCHIVE_BASE_REF below: the same frozen-archive + # comparison used by the hosted serial reference. Depth 2 would miss + # github.event.before # on multi-commit or force pushes; full fetch is cheap here because # checkout resolves against the VM's local mirror. - uses: actions/checkout@v6 @@ -621,6 +556,8 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci:linux-primary + # The sole remaining disabled hosted serial reference job (the hosted linux + # definition was removed as dead code); see TODO(hosted-serial-ci) above. serial-macos: if: false name: serial / macos From 89caa9dac247bbe302b1e3f2fb5e1b358b0101ef Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 19 Aug 2026 14:02:35 +0800 Subject: [PATCH 45/60] docs: note hosted serial-linux removed as dead code Keep the 2026-07-21 serial-reference note current with the ci.yml change: the standard-hosted serial / linux definition no longer exists (removed as dead code), and the current serial / windows definition is the in-house standby, not a disabled standard-hosted job. Re-record the translation-pair hashes. --- .../2026-07-21-serial-cross-platform-ci-reference.i18n.yaml | 4 ++-- .../process/2026-07-21-serial-cross-platform-ci-reference.md | 2 +- .../2026-07-21-serial-cross-platform-ci-reference.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 4c325327de..ef9211998f 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md -2026-07-21-serial-cross-platform-ci-reference.md: cfe6fd1028d03056e5ac6da7f014db2ac6db8fab -2026-07-21-serial-cross-platform-ci-reference.zh.md: 8ac7e087c3d015c0ed6bdf71feed9806bd6fb997 +2026-07-21-serial-cross-platform-ci-reference.md: 06643677299ec9b614de4a6c9a4336a2045d4629 +2026-07-21-serial-cross-platform-ci-reference.zh.md: 01a1ae47c620150e9ce60ad66840ac1649817543 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index cfe6fd1028..0664367729 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -16,7 +16,7 @@ Real-kernel sandbox proofs require specific hosted operating systems and archite ## Decision -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). The standard-hosted `serial / linux`, `serial / macos`, and `serial / windows` definitions remain disabled under `TODO(hosted-serial-ci)` until their portable capacity can be restored. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). The standard-hosted `serial / linux` was removed as dead code (it was `if: false` since 2026-07-30); the standard-hosted `serial / macos` remains disabled under `TODO(hosted-serial-ci)` until its portable capacity can be restored. The current `serial / windows` definition is the in-house `dsh-win-ci` standby rather than a disabled standard-hosted job. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index 8ac7e087c3..01a1ae47c6 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -16,7 +16,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。标准托管的 `serial / linux`、`serial / macos` 和 `serial / windows` 定义仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。标准托管的 `serial / linux` 已作为死代码删除(自 2026-07-30 起为 `if: false`);标准托管的 `serial / macos` 仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。当前 `serial / windows` 定义是公司自有 `dsh-win-ci` 池的 standby,而非被禁用的标准托管作业。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的 worker 数量也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 From 0593293b0a65e5035b4a6158a7a7025a6ab190a8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 19 Aug 2026 14:39:03 +0800 Subject: [PATCH 46/60] ci, docs: address serial-linux removal review Apply review feedback on the serial-linux removal (PR #2744): ci.yml: - Rewrite the new comments as current-state statements, not change narrative (dsh-prose-standard): the TODO names serial-macos as the one remaining disabled hosted serial job; serial-macos's intro and the self-hosted standby's frozen-archive note no longer narrate the deletion. - The self-hosted standby's frozen-archive comment states its own reason (full history to resolve DSH_ARCHIVE_BASE_REF against github.event.before) instead of referenceing a now-nonexistent hosted serial reference. - Move the hosted-cache comment above the pnpm restore so it covers both restore-keys fallback steps, and describe the real consequence (matches the archived entry until evict, then cold) instead of the false 'cold on a lockfile change'. Restore the per-step failover-skip note. Agent Note 2026-07-26-pnpm-action-setup-for-symmetric-ci-caching: - Update the restore-only bullet and the consequences closing line (it described serial-linux as the active master-push producer of the pnpm store cache) to state that no master job produces these hosted keys since the producer was removed; the Problem and Alternatives sections are historical context and are left unchanged. Re-record the bilingual pair hashes. Verification: scripts/ci-workflow.spec.ts passes (12/12), YAML re-parses, both translation pairs consistent, git diff --check clean. --- ...rial-cross-platform-ci-reference.i18n.yaml | 4 +-- ...7-21-serial-cross-platform-ci-reference.md | 2 +- ...1-serial-cross-platform-ci-reference.zh.md | 2 +- ...n-setup-for-symmetric-ci-caching.i18n.yaml | 4 +-- ...m-action-setup-for-symmetric-ci-caching.md | 4 +-- ...ction-setup-for-symmetric-ci-caching.zh.md | 4 +-- .github/workflows/ci.yml | 27 +++++++++---------- 7 files changed, 23 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index ef9211998f..b7dbfa5519 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md -2026-07-21-serial-cross-platform-ci-reference.md: 06643677299ec9b614de4a6c9a4336a2045d4629 -2026-07-21-serial-cross-platform-ci-reference.zh.md: 01a1ae47c620150e9ce60ad66840ac1649817543 +2026-07-21-serial-cross-platform-ci-reference.md: 49c220b400d6b7299545ef052c50936cdb681307 +2026-07-21-serial-cross-platform-ci-reference.zh.md: d28a4f05980a95a0871e4c9e2042b5818ea32a1c diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index 0664367729..49c220b400 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -16,7 +16,7 @@ Real-kernel sandbox proofs require specific hosted operating systems and archite ## Decision -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). The standard-hosted `serial / linux` was removed as dead code (it was `if: false` since 2026-07-30); the standard-hosted `serial / macos` remains disabled under `TODO(hosted-serial-ci)` until its portable capacity can be restored. The current `serial / windows` definition is the in-house `dsh-win-ci` standby rather than a disabled standard-hosted job. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). There is no standard-hosted `serial / linux` definition (removed as dead code); the standard-hosted `serial / macos` remains disabled under `TODO(hosted-serial-ci)` until its portable capacity can be restored. The current `serial / windows` definition is the in-house `dsh-win-ci` standby rather than a disabled standard-hosted job. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index 01a1ae47c6..d28a4f0598 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -16,7 +16,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。标准托管的 `serial / linux` 已作为死代码删除(自 2026-07-30 起为 `if: false`);标准托管的 `serial / macos` 仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。当前 `serial / windows` 定义是公司自有 `dsh-win-ci` 池的 standby,而非被禁用的标准托管作业。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。不存在标准托管的 `serial / linux` 定义(已作为死代码删除);标准托管的 `serial / macos` 仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。当前 `serial / windows` 定义是公司自有 `dsh-win-ci` 池的 standby,而非被禁用的标准托管作业。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的 worker 数量也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml index 392c4f54f2..9bb9857c6d 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 499141ca6a3703a12d10c195b732441d49419599 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 0b1e280db6e4e04cc4a210203e97026174fc02f6 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 93733583cb73f6ed870cb61333a56b14650786f3 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 7301cb144edf1e59ccb8a6344ccd3f67231eb226 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md index 499141ca6a..93733583cb 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md @@ -13,7 +13,7 @@ Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned i `pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. The root dev dependency on `@yarnpkg/cli-dist` separately supplies the modern Yarn CLI exercised by the generated-project e2e; package-manager coverage therefore does not inherit the runner image's Yarn Classic. Caching remains per-job policy on top of pnpm provisioning, in three deliberate shapes: - **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm` — `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms. -- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based required Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm. +- **Restore-only caching** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based required Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path. There is no active master job producing these hosted caches (the former `serial-linux` producer was removed as dead code on 2026-08-19, PR #2744), so these restores hit matching-archived entries until they evict. The enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm. - **Cache-less or persistent** (no store-cache action): the independent native Windows job, native serial-windows and serial-macos, plus `sandbox.yml` install from a cold or runner-local store. Extracting the many-file pnpm store costs more than a clean Windows install; the self-hosted standby and failover jobs instead reuse their VM's persistent pnpm store without transferring a hosted cache archive. ## Alternatives considered @@ -31,4 +31,4 @@ Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned i - The generated-project e2e runs the root-pinned Yarn 4 CLI instead of inheriting or silently skipping the runner image's Yarn version. - The cache-key format changed once for converted lanes; one cold run repopulated it, after which hit rates match the old steps. The built-in key spans platform, arch, and the lockfile hash but not the Node version, so the node-compat matrix legs share one store entry — safe, because the pnpm store is Node-version-independent. - `setup-node`'s built-in pnpm cache restores by exact key only, with no `restore-keys` prefix fallback: a `pnpm-lock.yaml` change starts a converted lane from a cold store instead of seeding from the previous entry. -- `pnpm/action-setup` deletes its install directory on every run and places the default store beneath the resulting `PNPM_HOME`. Linux jobs that need cache pairing or self-hosted persistence therefore set `PNPM_CONFIG_STORE_DIR` to `$HOME/.local/share/pnpm/store`, outside the action directory; the restore-only jobs and serial-linux resolve and share that stable path and exact key. +- `pnpm/action-setup` deletes its install directory on every run and places the default store beneath the resulting `PNPM_HOME`. Linux jobs that need cache pairing or self-hosted persistence therefore set `PNPM_CONFIG_STORE_DIR` to `$HOME/.local/share/pnpm/store`, outside the action directory; the restore-only jobs resolve that stable path and exact key. Since the `serial-linux` producer was removed (2026-08-19, PR #2744), no master job saves these keys. diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md index 0b1e280db6..7301cb144e 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md @@ -13,7 +13,7 @@ Status: implemented `pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。根目录的 `@yarnpkg/cli-dist` 开发依赖另行提供 generated-project e2e 所运行的现代 Yarn CLI(命令行界面);因此,用于包管理器覆盖率的 Yarn 不会沿用 runner 镜像里的 Yarn Classic。缓存仍是叠加在 pnpm 提供机制上的按作业策略,保留三种有意采用的形态: - **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。 -- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业和基于 Wine 的必需 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 +- **只恢复不上传**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业和基于 Wine 的必需 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径。当前没有活跃的 master 作业生产这些 hosted 缓存(原 `serial-linux` 生产者已于 2026-08-19 作为死代码删除,PR #2744),这些恢复步骤只能命中仍有归档的旧条目,直至其过期为逐出;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 - **无缓存或持久化**(不使用 store 缓存 action):独立的原生 Windows 作业、原生 serial-windows 和 serial-macos,以及 `sandbox.yml` 均从冷 store 或 runner 本地 store 安装。解压含有大量文件的 pnpm store,成本高于在 Windows 上进行一次全新安装;自托管热备与故障切换作业则复用其 VM 的持久 pnpm store,不传输托管缓存归档。 ## 曾考虑的替代方案 @@ -31,4 +31,4 @@ Status: implemented - generated-project e2e 运行根目录锁定的 Yarn 4 CLI,既不再沿用 runner 镜像中的 Yarn 版本,也不会因此悄然跳过。 - 已转换泳道的缓存键格式变更了一次;各跑一次冷运行重建缓存后,命中率与旧步骤持平。内建缓存键涵盖平台、架构与锁文件哈希,但不含 Node 版本,因此 node-compat 的各个矩阵任务共享同一条 store 缓存记录——这是安全的,因为 pnpm store 与 Node 版本无关。 - `setup-node` 内建的 pnpm 缓存只按精确键恢复,没有 `restore-keys` 前缀回退:`pnpm-lock.yaml` 一旦变更,已转换泳道会从冷 store 起步,而不是利用上一条缓存记录预填充。 -- `pnpm/action-setup` 每次运行都会删除其安装目录,并把默认 store 放在由此产生的 `PNPM_HOME` 下。因此,需要缓存配对或自托管持久化的 Linux 作业会把 `PNPM_CONFIG_STORE_DIR` 设为 `$HOME/.local/share/pnpm/store`,置于 action 目录之外;只恢复不上传的作业与 serial-linux 会解析并共享这一稳定路径及精确键。 +- `pnpm/action-setup` 每次运行都会删除其安装目录,并把默认 store 放在由此产生的 `PNPM_HOME` 下。因此,需要缓存配对或自托管持久化的 Linux 作业会把 `PNPM_CONFIG_STORE_DIR` 设为 `$HOME/.local/share/pnpm/store`,置于 action 目录之外;只恢复不上传的作业会解析这一稳定路径及精确键。由于 `serial-linux` 生产者已被删除(2026-08-19,PR #2744),没有任何 master 作业保存这些键。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89d27c13b8..eae33eb2e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,10 +42,9 @@ env: jobs: - # TODO(hosted-serial-ci): Re-enable the remaining disabled hosted serial reference job - # (serial-macos) before release. The hosted serial-linux definition was removed as dead - # code (it was `if: false`); the self-hosted standby lane below remains active on every - # master push. + # TODO(hosted-serial-ci): Re-enable the one remaining disabled hosted serial + # reference job (serial-macos) before release. The self-hosted standby lane + # below remains active on every master push. # Three enterprise jobs isolate coverage, static analysis, and the # build-backed consumer tail. The consumer job owns the only Linux build so @@ -213,7 +212,11 @@ jobs: store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) echo "path=$store_path" >> "$GITHUB_OUTPUT" - # Skipped under failover — see the coverage lane's identical rationale. + # Pull requests restore the pnpm store and Playwright caches without paying + # compression and upload on the required path. No master job saves these + # hosted cache keys, so each restore-keys fallback hits the matching archived + # entry until it evicts, after which the store is cold. Skipped under failover + # — the self-hosted VM's persistent store is already warm. - uses: actions/cache/restore@v4 if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: @@ -222,10 +225,7 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - # Pull requests restore the pnpm store and Playwright caches without paying - # compression and upload on the required path. There is currently no active - # master producer for these hosted caches — the hosted serial-linux job that - # refreshed them was disabled, so restores below are cold on a lockfile change. + # Skipped under failover — see the coverage lane's identical rationale. - uses: actions/cache/restore@v4 if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: @@ -514,9 +514,8 @@ jobs: name: serial / linux (self-hosted standby) runs-on: [self-hosted, linux, x64, vm-backup] steps: - # Full history + DSH_ARCHIVE_BASE_REF below: the same frozen-archive - # comparison used by the hosted serial reference. Depth 2 would miss - # github.event.before + # DSH_ARCHIVE_BASE_REF below compares the frozen-archive gate against + # github.event.before, so full history is required: depth 2 would miss it # on multi-commit or force pushes; full fetch is cheap here because # checkout resolves against the VM's local mirror. - uses: actions/checkout@v6 @@ -556,8 +555,8 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci:linux-primary - # The sole remaining disabled hosted serial reference job (the hosted linux - # definition was removed as dead code); see TODO(hosted-serial-ci) above. + # The one remaining disabled hosted serial reference job; see + # TODO(hosted-serial-ci) above. serial-macos: if: false name: serial / macos From 2824ef7ab4e34dfa019bd1099df2d0381c85a556 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 19 Aug 2026 15:01:37 +0800 Subject: [PATCH 47/60] docs, ci: apply second serial-linux review pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the review findings from the fresh pass on PR #2744: - 2026-07-21 note L31: the enabled serial references run on the self-hosted vm-backup/dsh-win-ci pools and the only remaining disabled hosted serial is serial-macos (macos-latest); removed the stale hosted ubuntu-latest/windows-2025 serial framing and the outdated 'when enabled, serial / windows' clause. - Drop remaining change-narrative from both notes: L19 (serial / linux, macos) and the 2026-07-26 caching note L16/L34 now state only current facts without deletion dates/PR numbers; the no-producer fact has one home (L16). zh.ms' '直至其过期为逐出' corrected to '直至其被逐出'. Bilingual hashes re-recorded. - ci.yml TODO notes that re-enabling serial-macos does not restore a Linux hosted-cache producer and records the seeder-vs-remove decision direction. - The Playwright restore's failover-skip comment is now self-contained (the VM's persistent browser cache is warm) instead of pointing at the coveragelane rationale, which is pnpm-store-specific. Verification: scripts/ci-workflow.spec.ts passes (12/12), YAML re-parses, both translation pairs consistent, git diff --check clean. --- .../2026-07-21-serial-cross-platform-ci-reference.i18n.yaml | 4 ++-- .../2026-07-21-serial-cross-platform-ci-reference.md | 4 ++-- .../2026-07-21-serial-cross-platform-ci-reference.zh.md | 4 ++-- ...-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml | 4 ++-- ...2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md | 4 ++-- ...6-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md | 4 ++-- .github/workflows/ci.yml | 6 ++++-- 7 files changed, 16 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index b7dbfa5519..f0213f0864 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md -2026-07-21-serial-cross-platform-ci-reference.md: 49c220b400d6b7299545ef052c50936cdb681307 -2026-07-21-serial-cross-platform-ci-reference.zh.md: d28a4f05980a95a0871e4c9e2042b5818ea32a1c +2026-07-21-serial-cross-platform-ci-reference.md: d1ab9590df1252c9c91e7ec53dc1559e221d8f68 +2026-07-21-serial-cross-platform-ci-reference.zh.md: c9ffbac42858a19cca7c5fef6fd8f583030c195d diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index 49c220b400..d1ab9590df 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -16,7 +16,7 @@ Real-kernel sandbox proofs require specific hosted operating systems and archite ## Decision -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). There is no standard-hosted `serial / linux` definition (removed as dead code); the standard-hosted `serial / macos` remains disabled under `TODO(hosted-serial-ci)` until its portable capacity can be restored. The current `serial / windows` definition is the in-house `dsh-win-ci` standby rather than a disabled standard-hosted job. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). There is no standard-hosted `serial / linux` definition; the standard-hosted `serial / macos` remains disabled under `TODO(hosted-serial-ci)` until its portable capacity can be restored. The current `serial / windows` definition is the in-house `dsh-win-ci` standby. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. @@ -28,7 +28,7 @@ The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow bel Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The CI and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. -The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. The required pull-request Windows job runs under Wine on `ubuntu-latest`, while the independent pull-request native job uses the hosted `dsh-windows-2025-16core` runner under normal operation and the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under failover (see the [failover runbook](2026-07-26-ci-failover-runbook.md)), and is absent from the required aggregate under the [dual Windows decision](2026-08-08-native-windows-pull-request-ci.md); when enabled, `serial / windows` remains a second complete, unsharded native-kernel oracle. Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. +The active serial references run on the self-hosted `vm-backup` (`serial / linux`) and `dsh-win-ci` (`serial / windows`) pools; the one remaining disabled hosted serial reference (`serial-macos`) uses `macos-latest`, and there is no standard-hosted `serial / linux` label. The required pull-request Windows job runs under Wine on `ubuntu-latest`, while the independent pull-request native job uses the hosted `dsh-windows-2025-16core` runner under normal operation and the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under failover (see the [failover runbook](2026-07-26-ci-failover-runbook.md)), and is absent from the required aggregate under the [dual Windows decision](2026-08-08-native-windows-pull-request-ci.md). Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index d28a4f0598..c9ffbac428 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -16,7 +16,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。不存在标准托管的 `serial / linux` 定义(已作为死代码删除);标准托管的 `serial / macos` 仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。当前 `serial / windows` 定义是公司自有 `dsh-win-ci` 池的 standby,而非被禁用的标准托管作业。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。不存在标准托管的 `serial / linux` 定义;标准托管的 `serial / macos` 仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。当前 `serial / windows` 定义是公司自有 `dsh-win-ci` 池的 standby。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的 worker 数量也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 @@ -28,7 +28,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。CI 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 -可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。拉取请求必需的 Windows 作业在 `ubuntu-latest` 上通过 Wine 运行,而独立的拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,故障切换时使用自托管 `[self-hosted, dsh-win-ci, windows]` 池(参见[故障切换手册](2026-07-26-ci-failover-runbook.md)),依据[双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md)不参与必需聚合流程;`serial / windows` 启用时,仍作为第二个完整且未分片的原生内核标尺。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 +当前启用的参考流程运行在公司自有 `vm-backup`(`serial / linux`)与 `dsh-win-ci`(`serial / windows`)自托管池上;唯一剩余的禁用托管参考作业(`serial-macos`)使用 `macos-latest`,且不存在标准托管的 `serial / linux` 标签。拉取请求必需的 Windows 作业在 `ubuntu-latest` 上通过 Wine 运行,而独立的拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,故障切换时使用自托管 `[self-hosted, dsh-win-ci, windows]` 池(参见[故障切换手册](2026-07-26-ci-failover-runbook.md)),依据[双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md)不参与必需聚合流程。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml index 9bb9857c6d..c75e36f054 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 93733583cb73f6ed870cb61333a56b14650786f3 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 7301cb144edf1e59ccb8a6344ccd3f67231eb226 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 31d1ebf009a6e044722081985546e87b9d4ae0e2 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 84445abfdd42671f2f5c9de8403ed7891f9cc292 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md index 93733583cb..31d1ebf009 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md @@ -13,7 +13,7 @@ Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned i `pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. The root dev dependency on `@yarnpkg/cli-dist` separately supplies the modern Yarn CLI exercised by the generated-project e2e; package-manager coverage therefore does not inherit the runner image's Yarn Classic. Caching remains per-job policy on top of pnpm provisioning, in three deliberate shapes: - **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm` — `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms. -- **Restore-only caching** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based required Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path. There is no active master job producing these hosted caches (the former `serial-linux` producer was removed as dead code on 2026-08-19, PR #2744), so these restores hit matching-archived entries until they evict. The enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm. +- **Restore-only caching** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based required Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path. No master job produces these hosted caches, so these restores hit matching archived entries until they evict. The enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm. - **Cache-less or persistent** (no store-cache action): the independent native Windows job, native serial-windows and serial-macos, plus `sandbox.yml` install from a cold or runner-local store. Extracting the many-file pnpm store costs more than a clean Windows install; the self-hosted standby and failover jobs instead reuse their VM's persistent pnpm store without transferring a hosted cache archive. ## Alternatives considered @@ -31,4 +31,4 @@ Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned i - The generated-project e2e runs the root-pinned Yarn 4 CLI instead of inheriting or silently skipping the runner image's Yarn version. - The cache-key format changed once for converted lanes; one cold run repopulated it, after which hit rates match the old steps. The built-in key spans platform, arch, and the lockfile hash but not the Node version, so the node-compat matrix legs share one store entry — safe, because the pnpm store is Node-version-independent. - `setup-node`'s built-in pnpm cache restores by exact key only, with no `restore-keys` prefix fallback: a `pnpm-lock.yaml` change starts a converted lane from a cold store instead of seeding from the previous entry. -- `pnpm/action-setup` deletes its install directory on every run and places the default store beneath the resulting `PNPM_HOME`. Linux jobs that need cache pairing or self-hosted persistence therefore set `PNPM_CONFIG_STORE_DIR` to `$HOME/.local/share/pnpm/store`, outside the action directory; the restore-only jobs resolve that stable path and exact key. Since the `serial-linux` producer was removed (2026-08-19, PR #2744), no master job saves these keys. +- `pnpm/action-setup` deletes its install directory on every run and places the default store beneath the resulting `PNPM_HOME`. Linux jobs that need hosted cache restores or self-hosted persistence therefore set `PNPM_CONFIG_STORE_DIR` to `$HOME/.local/share/pnpm/store`, outside the action directory; the restore-only jobs resolve that stable path and exact key. diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md index 7301cb144e..84445abfdd 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md @@ -13,7 +13,7 @@ Status: implemented `pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。根目录的 `@yarnpkg/cli-dist` 开发依赖另行提供 generated-project e2e 所运行的现代 Yarn CLI(命令行界面);因此,用于包管理器覆盖率的 Yarn 不会沿用 runner 镜像里的 Yarn Classic。缓存仍是叠加在 pnpm 提供机制上的按作业策略,保留三种有意采用的形态: - **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。 -- **只恢复不上传**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业和基于 Wine 的必需 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径。当前没有活跃的 master 作业生产这些 hosted 缓存(原 `serial-linux` 生产者已于 2026-08-19 作为死代码删除,PR #2744),这些恢复步骤只能命中仍有归档的旧条目,直至其过期为逐出;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 +- **只恢复不上传**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业和基于 Wine 的必需 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径。没有任何 master 作业生产这些 hosted 缓存,这些恢复步骤只能命中仍有归档的旧条目,直至其被逐出;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 - **无缓存或持久化**(不使用 store 缓存 action):独立的原生 Windows 作业、原生 serial-windows 和 serial-macos,以及 `sandbox.yml` 均从冷 store 或 runner 本地 store 安装。解压含有大量文件的 pnpm store,成本高于在 Windows 上进行一次全新安装;自托管热备与故障切换作业则复用其 VM 的持久 pnpm store,不传输托管缓存归档。 ## 曾考虑的替代方案 @@ -31,4 +31,4 @@ Status: implemented - generated-project e2e 运行根目录锁定的 Yarn 4 CLI,既不再沿用 runner 镜像中的 Yarn 版本,也不会因此悄然跳过。 - 已转换泳道的缓存键格式变更了一次;各跑一次冷运行重建缓存后,命中率与旧步骤持平。内建缓存键涵盖平台、架构与锁文件哈希,但不含 Node 版本,因此 node-compat 的各个矩阵任务共享同一条 store 缓存记录——这是安全的,因为 pnpm store 与 Node 版本无关。 - `setup-node` 内建的 pnpm 缓存只按精确键恢复,没有 `restore-keys` 前缀回退:`pnpm-lock.yaml` 一旦变更,已转换泳道会从冷 store 起步,而不是利用上一条缓存记录预填充。 -- `pnpm/action-setup` 每次运行都会删除其安装目录,并把默认 store 放在由此产生的 `PNPM_HOME` 下。因此,需要缓存配对或自托管持久化的 Linux 作业会把 `PNPM_CONFIG_STORE_DIR` 设为 `$HOME/.local/share/pnpm/store`,置于 action 目录之外;只恢复不上传的作业会解析这一稳定路径及精确键。由于 `serial-linux` 生产者已被删除(2026-08-19,PR #2744),没有任何 master 作业保存这些键。 +- `pnpm/action-setup` 每次运行都会删除其安装目录,并把默认 store 放在由此产生的 `PNPM_HOME` 下。因此,需要 hosted 缓存恢复或自托管持久化的 Linux 作业会把 `PNPM_CONFIG_STORE_DIR` 设为 `$HOME/.local/share/pnpm/store`,置于 action 目录之外;只恢复不上传的作业会解析这一稳定路径及精确键。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eae33eb2e7..3dce0d2c0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,9 @@ jobs: # TODO(hosted-serial-ci): Re-enable the one remaining disabled hosted serial # reference job (serial-macos) before release. The self-hosted standby lane - # below remains active on every master push. + # below remains active on every master push. Re-enabling serial-macos does not + # restore a Linux hosted-cache producer: decide whether to add a master seeder + # or remove the restore-only steps if cold starts become a concern. # Three enterprise jobs isolate coverage, static analysis, and the # build-backed consumer tail. The consumer job owns the only Linux build so @@ -225,7 +227,7 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - # Skipped under failover — see the coverage lane's identical rationale. + # Skipped under failover: the VM's persistent browser cache is already warm. - uses: actions/cache/restore@v4 if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: From 72f0c9dc6db036f2a127fcb768745d47919956e6 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 19 Aug 2026 15:22:52 +0800 Subject: [PATCH 48/60] docs: sync serial-linux removal across remaining implemented notes Keep the remaining current-state Agent Notes in line with removing the hosted serial-linux job (PR #2744), per the implemented-note rule that mechanisms stay current in the same change that alters them: - 2026-07-30-web-browser-snapshot-ci-gate (L15/L21): the hosted default-branch Linux serial job no longer produces the browser cache; pull requests restore the archived cache without a master producer, and only the self-hosted standby runs the comparison on master. - 2026-07-24-web-gui-browser-e2e-lane (L49): no hosted Linux serial producer of the browser cache remains; the self-hosted standby runs the same gate. - 2026-07-23-portable-required-pull-request-ci (L19): the serial completeness check is now provided by the self-hosted vm-backup/dsh-win-ci standby lanes, with serial-macos as the only disabled hosted serial. - 2026-07-22-evidence-based-larger-hosted-runners (L17/L53/L73): serial completeness evidence comes from the self-hosted standby pools; no hosted Linux serial reference remains. All four en/zh pairs updated with consistent current-state wording (no deletion narrative) and i18n hashes re-recorded; note-format still passes. --- ...026-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 4 ++-- .../2026-07-22-evidence-based-larger-hosted-runners.md | 6 +++--- .../2026-07-22-evidence-based-larger-hosted-runners.zh.md | 6 +++--- .../2026-07-23-portable-required-pull-request-ci.i18n.yaml | 4 ++-- .../process/2026-07-23-portable-required-pull-request-ci.md | 2 +- .../2026-07-23-portable-required-pull-request-ci.zh.md | 2 +- .../testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 ++-- .../testing/2026-07-24-web-gui-browser-e2e-lane.md | 2 +- .../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md | 2 +- .../2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml | 4 ++-- .../testing/2026-07-30-web-browser-snapshot-ci-gate.md | 4 ++-- .../testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md | 4 ++-- 12 files changed, 22 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 3bc9415b40..ea324214e3 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md -2026-07-22-evidence-based-larger-hosted-runners.md: b3310988decb2916ac895aaf154dbc106c51ed48 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 2d408173a657c77add750a53eaee4ecb9177919c +2026-07-22-evidence-based-larger-hosted-runners.md: 770d9e22d89632b3a020ba54bd9b16342cfc9921 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: a248222255bdc96445c3cf1a2f33f669f488f42e diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index b3310988de..770d9e22d8 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -14,7 +14,7 @@ Larger runners make it possible to pay setup once and parallelize inside the rep The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests run the three primary Linux jobs on the 16-core Ubuntu 24.04 pool and the independent native Windows signal on the 16-core Windows 2025 pool. The required Wine signal remains on standard hosted Linux. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. -The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete standard-runner evidence available on `master`. `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. +The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete-aggregate evidence available on `master` through the self-hosted standby pools (no Linux hosted serial reference remains). `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. The former gate-level and coarse primary shard jobs are absent from the workflow. Their workflow-facing static, lint, coverage, snapshot, and scenario selectors are also absent, so an unused diagnostic path cannot preserve a second CI architecture. Instrumented coverage may use [process-local partitions inside its existing job](2026-08-18-in-job-partitioned-coverage.md); that coordinator neither selects workflow jobs nor transfers reports between runners. @@ -50,7 +50,7 @@ Inner and outer worker limits are separate controls. An exact-head 32-worker ESL The process-bound coverage project contains exactly five suite files. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite and the pi-ai adapter suite because aggregate contention changed timing observations in both. These failures make deterministic coverage, not advertised cores, the upper bound on worker selection. -Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. +The self-hosted serial Linux and Windows standby references and the disabled `serial-macos` job exist. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the writer-manageable `DSH_CI_FAILOVER_LINUX` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). The standby lane is push-triggered, so it always executes the base branch's workflow definition. Under failover, however, `pull_request` jobs do reach these runners with the PR merge ref's own workflow definition — the trust boundary is repository membership (the repository is private with forking disabled, and the selectors exclude Dependabot), as the [failover runbook](2026-07-26-ci-failover-runbook.md) records. @@ -70,7 +70,7 @@ An additional serial Linux reference runs on the in-house self-hosted pool (`vm- **Publish the static job's build to post-build consumers.** A run-scoped artifact preserves one exact build, but the workflow can only consume it by waiting for the entire static job and then requesting another runner. The [independent consumer build](2026-07-30-independent-ci-consumer-build.md) assigns the single Linux build to its actual consumers instead. -**Keep the complete required path on standard GitHub-hosted capacity.** This avoids repository-external runner configuration, but exact-head standard-runner runs remain materially slower and can spend longer queued behind shared capacity. Standard-hosted compatibility and serial references preserve portable evidence without making that slower topology the ordinary primary path. +**Keep the complete required path on standard GitHub-hosted capacity.** This avoids repository-external runner configuration, but exact-head standard-runner runs remain materially slower and can spend longer queued behind shared capacity. Standard-hosted compatibility jobs and the self-hosted serial standby preserve portable evidence without making that slower topology the ordinary primary path. **Keep blocking and observational native Windows checks in separate jobs.** This would preserve their distinction at the workflow level but pay Windows setup twice. `run-gates` preserves the same blocking versus observational result inside one job. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 2d408173a6..a248222255 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -14,7 +14,7 @@ Status: implemented 企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求在 16 核 Ubuntu 24.04 池上运行 3 个 Linux 主作业,并在 16 核 Windows 2025 池上运行独立的原生 Windows 信号。必需的 Wine 信号仍位于标准托管 Linux。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 -必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性约定,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上持续提供完整的标准运行器证据。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。 +必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性约定,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上通过自托管热备池持续提供完整聚合流程证据(不存在托管的 Linux 串行参考)。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。 原有的门禁级和粗粒度主流程分片 job 已从工作流中移除。面向工作流的静态、lint、覆盖率、快照和场景选择器也已移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。插桩覆盖率可以在[既有 job 内使用进程本地分区](2026-08-18-in-job-partitioned-coverage.md);该协调器既不选择工作流 job,也不在 runner 之间传输报告。 @@ -50,7 +50,7 @@ Windows 仓库工作在超过 16 核后收益很小。原生通道把阻断性 进程约束的覆盖率项目恰好包含 5 个套件文件。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单包括本地 bash 进程通路套件和 pi-ai 适配器套件,因为聚合争用改变了二者的时序观测结果。这些故障表明,选择工作线程数量时,上限取决于覆盖率结果能否保持确定性,而非标称核心数。 -只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 +自托管的 Linux 与 Windows 串行热备参考,以及被禁用的 `serial-macos` 任务仍然存在。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写入权限持有者可管理的仓库变量 `DSH_CI_FAILOVER_LINUX` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查阻塞,形成死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基础分支自身的工作流定义。但需要注意:故障切换期间,`pull_request` 作业确实会带着 PR merge 引用自带的工作流定义到达这些运行器——信任边界是仓库成员资格(仓库为私有且禁用 fork,选择器排除 Dependabot),详见[故障切换手册](2026-07-26-ci-failover-runbook.md)的记录。 @@ -70,7 +70,7 @@ Windows 仓库工作在超过 16 核后收益很小。原生通道把阻断性 **将静态作业的构建发布给构建后消费方。** 仅供本次运行使用的产物能保留同一份构建结果,但工作流要消费它,只能先等待整个静态作业完成,再请求另一台运行器。[消费方独立构建](2026-07-30-independent-ci-consumer-build.md)则转而让实际消费方负责唯一一次 Linux 构建。 -**将完整必需路径保留在 GitHub 标准托管容量上。** 此方案可以避免依赖仓库外部的运行器配置,但标准运行器上的分支头精确运行仍明显更慢,也可能因共享容量而排队更久。标准托管兼容性作业和串行参考流程保留可移植证据,无需让这套较慢的拓扑成为普通主路径。 +**将完整必需路径保留在 GitHub 标准托管容量上。** 此方案可以避免依赖仓库外部的运行器配置,但标准运行器上的分支头精确运行仍明显更慢,也可能因共享容量而排队更久。标准托管兼容性作业和自托管串行热备保留可移植证据,无需让这套较慢的拓扑成为普通主路径。 **把阻断性与观测性原生 Windows 检查放在不同 job。** 此方案会在工作流层面保留二者的区别,却要承担两次 Windows 设置开销。`run-gates` 在一个 job 内保留了相同的阻断与观测结果。 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml index 91879fb4d5..5b9f89e609 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md -2026-07-23-portable-required-pull-request-ci.md: 418f996383ebc08e78cb5b061bfde4b90dd89495 -2026-07-23-portable-required-pull-request-ci.zh.md: ada3cc52e3f6436269969a3ff93b7a2616353337 +2026-07-23-portable-required-pull-request-ci.md: 740ef20d6b7a1edc1a010f37bcbcddab2981e8ad +2026-07-23-portable-required-pull-request-ci.zh.md: 5b24af59fb13e32a642e15c79aee3910601ad45b diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md index 418f996383..740ef20d6b 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -16,7 +16,7 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei The three Linux primary jobs, Node compatibility, Python SDK unit suite, Python runtime validation, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; `windows node 24 / native complete` is deliberately absent. Branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. -The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix. +The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent completeness check, now provided by the self-hosted `vm-backup`/`dsh-win-ci` standby lanes on `master`; the only hosted serial reference is the disabled `serial-macos`. The manual larger-runner suites retain size comparisons without expanding the ordinary required matrix. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md index ada3cc52e3..5b24af59fb 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -16,7 +16,7 @@ Status: implemented 三项 Linux 主作业、Node 兼容性、Python SDK 单元测试套件、Python 运行时验证和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;`windows node 24 / native complete` 被刻意排除。分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的约定,但无法产出缺失的必需结果。 -当前主拓扑及其测量结果以[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)为准。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。 +当前主拓扑及其测量结果以[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)为准。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的完整性检查,现由 `master` 上公司自有 `vm-backup`/`dsh-win-ci` 自托管热备通道提供;仅存的托管串行参考是禁用的 `serial-macos`。手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index d5bc176adc..419e81704b 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: afe3c937a733ed50073ada5c9c6f40c6c93c6641 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 57a4d3ae5fba7c10211587e36ce9e41e7e985d7e +2026-07-24-web-gui-browser-e2e-lane.md: a477a887e679d455376e69ef0c0fae58dc8dde69 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 1f9504df6d49061a5b4759e65dcbbeeb8d9c7402 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index afe3c937a7..a477a887e6 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -46,7 +46,7 @@ The lane covers three behavior families. Live-turn scenarios pin ordinary tool e ### CI stance -The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The `node 24 / snapshots and artifacts` consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The hosted and self-hosted default-branch Linux serial jobs run the same gate; the hosted job produces the browser cache consumed by pull requests, while the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. +The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The `node 24 / snapshots and artifacts` consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The self-hosted default-branch Linux serial standby runs the same gate; there is no hosted Linux serial producer of the browser cache consumed by pull requests, and the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. High-cardinality performance diagnostics use the separate opt-in `apps/web/tests/**/*.perf.ts` inventory selected only by `vitest.web.perf.config.ts`. The isolated `complex-history.perf.ts` cases reuse the real scaffold: the workspace case seeds 1,000 compact sessions plus one 500-turn history containing 500 tool calls, exhausts and remounts that history in Chat, and reports Chromium main-thread, DOM, listener, heap, paging, search, and Trajectory measurements. Two continuation cases seed the same long history but compare the default 24-turn Chat window with all 500 turns expanded before each continues eight identical turns through the real composer, agent loop, SSE wire, tools, and persistence; two turns execute a real `bash` call and assert its durable result, while the final turn fills an 8,232-character mixed-language prompt and replays 120 paced text deltas. A separate soak case starts from a blank session, drives 100 consecutive real composer turns with a `bash` call and result every tenth turn, forces GC every ten turns, and reports ten-turn latency windows plus retained browser state. It then submits a 101st text-only turn with a trusted browser click and measures browser-clock send-to-transcript-DOM and send-to-post-paint latency, excluding the composer's draft mirrors, separately from full-turn completion. Per-turn diagnostics cover composer fill, click-to-user-echo, click-to-first-chunk, completion, browser mutations, persisted chunks, and tool events; the synthetic replay model has enough context capacity to keep fixture cardinality stable instead of consuming scripted calls through compaction. Structural assertions pin the intended load, stream, and tool shapes, but timing remains threshold-free because machine speed is not a correctness contract. The required `vitest.web.config.ts` inventory remains limited to `*.e2e.ts` and `*.snapshot.ts`, so neither `test:web:built` nor its CI gate collects performance cases. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 57a4d3ae5f..1f9504df6d 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -46,7 +46,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### CI 立场 -根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。`node 24 / snapshots and artifacts` 消费方任务在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分:host 与 spec 使用 [tsx 源码启动约定](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。托管和自托管的默认分支 Linux 串行任务运行同一门禁;托管任务生成供 PR 消费的浏览器缓存,持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 +根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。`node 24 / snapshots and artifacts` 消费方任务在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分:host 与 spec 使用 [tsx 源码启动约定](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。自托管的默认分支 Linux 串行热备运行同一门禁;不存在托管 Linux 串行生产者生成供 PR 消费的浏览器缓存,持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 高基数性能诊断使用单独按需启用的 `apps/web/tests/**/*.perf.ts` 清单,并且只由 `vitest.web.perf.config.ts` 选中。`complex-history.perf.ts` 的隔离用例复用真实 scaffold:工作区用例播种 1,000 个紧凑会话以及一份包含 500 次工具调用的 500 轮次历史,在 Chat 中穷尽并重新挂载该历史,并报告 Chromium 主线程、DOM、监听器、堆内存、分页、搜索和 Trajectory 测量结果。两个续聊用例播种同一份长历史,但比较默认的 24 轮次 Chat 窗口与展开全部 500 轮次的状态,然后各自通过真实输入框、agent loop、SSE wire、工具和持久化继续进行 8 个相同轮次;其中两轮执行真实 `bash` 调用并断言其持久化结果,最后一轮则填入一条包含 8,232 个字符的混合语言提示词,并回放 120 个带节奏的文本增量。一个单独的 soak 用例从空白会话开始,通过真实输入框连续驱动 100 轮,每第 10 轮执行一次 `bash` 调用并产生结果,每 10 轮强制执行一次 GC,并报告每 10 轮的延迟窗口及保留的浏览器状态。随后它通过受信任的浏览器点击提交第 101 个纯文本轮次,并使用浏览器时钟分别测量发送到 transcript DOM 和发送到绘制后的延迟,排除输入框的草稿镜像,并与完整轮次完成时间分开。逐轮诊断涵盖输入框填入、点击到用户消息回显、点击到首个分片、完成、浏览器变更、持久化分片和工具事件;合成回放模型拥有足够的上下文容量,可使 fixture 基数保持稳定,而不会因压缩(compaction)消耗脚本化调用。结构性断言钉住预期的负载、流和工具形状,但时间仍不设阈值,因为机器速度不属于正确性约定。必需的 `vitest.web.config.ts` 清单仍仅限 `*.e2e.ts` 和 `*.snapshot.ts`,因此 `test:web:built` 及其 CI 门禁都不会收集性能用例。 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml index 8557998d06..c0fd339c04 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md -2026-07-30-web-browser-snapshot-ci-gate.md: 72a7e33d0e84105f7680429443df41661ced288a -2026-07-30-web-browser-snapshot-ci-gate.zh.md: 161f99ab98984ca1d938f11c5e3de5176ca4da66 +2026-07-30-web-browser-snapshot-ci-gate.md: abb197f2a8e3488e2ae1edc7c2e77cbdcf919556 +2026-07-30-web-browser-snapshot-ci-gate.zh.md: b665dc90c4bdba969b3cd9a561a3f18f388e27dd diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md index 72a7e33d0e..abb197f2a8 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md @@ -12,13 +12,13 @@ The [keyless web browser e2e lane](2026-07-24-web-gui-browser-e2e-lane.md) runs For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. When `DSH_WEB_SNAPSHOT_WORKERS` is configured, `scripts/run-gates.ts` registers `test:web:ci` as the `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing. -The consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), so `apps/web/dist` and the package `lib/` directories remain in its workspace for the browser suite. On hosted runners, CI installs Chromium and its system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns the Linux system packages and CI installs only Chromium, avoiding per-run `apt` mutation. The hosted default-branch Linux serial job runs the suite and produces the operating-system-and-lockfile-keyed browser cache; pull requests restore it without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. The self-hosted standby runs the same comparison without hosted cache actions. +The consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), so `apps/web/dist` and the package `lib/` directories remain in its workspace for the browser suite. On hosted runners, CI installs Chromium and its system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns the Linux system packages and CI installs only Chromium, avoiding per-run `apt` mutation. Pull requests restore the operating-system-and-lockfile-keyed browser cache without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. No master job produces these hosted caches, so restores hit archived entries until they evict. The self-hosted standby runs the same comparison without hosted cache actions. Local `pnpm run test:web` continues to build first and then run the full browser suite serially; `test:web:built` is the serial entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written. CI's `scripts/run-web-snapshots.ts` first runs `hmr-live.e2e.ts` and `cordis-tool-round.e2e.ts` as separate serial Vitest invocations. The HMR scenario mutates built workspace state, while the Cordis scenario owns a lifecycle-sensitive approval and steering sequence whose turn grouping is made deterministic by waiting for the initial turn to settle before approval. After both pass, one six-worker Vitest pool runs every remaining file. Every child inherits stdio, and the enclosing gate streams that output through `run-gates`. -For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The hosted and self-hosted default-branch Linux serial aggregates also include the comparison, while the macOS and Windows serial jobs remain browser-free. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. +For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The self-hosted default-branch Linux serial standby also includes the comparison, while the macOS and Windows serial jobs remain browser-free (there is no hosted Linux serial aggregate). A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. Completed local replays measured the six-worker browser command at about 65–71 seconds. A twelve-worker comparison completed in about 50 seconds, so halving the browser worker budget adds about 15–20 seconds rather than doubling wall time. The gate scheduler starts browser snapshots as soon as `built-package-invariants` succeeds and runs independent gates concurrently, so it needs neither a dedicated job timeout nor a manual YAML ordering rule. diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md index 161f99ab98..b665dc90c4 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md @@ -12,13 +12,13 @@ Status: implemented Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。配置 `DSH_WEB_SNAPSHOT_WORKERS` 后,`scripts/run-gates.ts` 把 `test:web:ci` 登记为 `ci-consumers` 门禁,并显式注入 `DSH_SNAPSHOT=replay`;CI 永不以 `record` 或 `refresh` 模式运行,因此提交的预期输出与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。 -消费方 job 在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,因此 `apps/web/dist` 和包的 `lib/` 目录会保留在其工作区中,供浏览器套件使用。在托管运行器上,CI 按锁文件中的 Playwright 版本安装 Chromium 及其系统依赖。在持久化故障切换 VM 上,镜像负责预装 Linux 系统软件包,CI 只安装 Chromium,避免每次运行都通过 `apt` 改动系统。托管的默认分支 Linux 串行 job 运行该套件,并生成以操作系统和锁文件为键的浏览器缓存;PR 恢复该缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。自托管热备运行相同的比较,但不执行托管缓存操作。 +消费方 job 在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,因此 `apps/web/dist` 和包的 `lib/` 目录会保留在其工作区中,供浏览器套件使用。在托管运行器上,CI 按锁文件中的 Playwright 版本安装 Chromium 及其系统依赖。在持久化故障切换 VM 上,镜像负责预装 Linux 系统软件包,CI 只安装 Chromium,避免每次运行都通过 `apt` 改动系统。PR 恢复以操作系统和锁文件为键的浏览器缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。没有任何 master 作业生成这些 hosted 缓存,因此恢复只能命中仍有归档的旧条目,直至其被逐出。自托管热备运行相同的比较,但不执行托管缓存操作。 本地 `pnpm run test:web` 仍先构建,再串行运行完整浏览器套件;`test:web:built` 是已有构建产物的串行执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处预期输出 diff,再以 replay 模式复验不再写文件。 CI 的 `scripts/run-web-snapshots.ts` 先用相互独立的 Vitest 调用串行运行 `hmr-live.e2e.ts` 与 `cordis-tool-round.e2e.ts`。HMR 场景会修改已构建工作区状态;Cordis 场景则拥有一条对生命周期时序敏感的批准与 steering(中途引导)序列,它通过在批准前等待初始轮次结束来确定轮次分组。两者通过后,其余全部文件进入同一个 6-worker Vitest 池。所有子进程都继承 stdio,外围门禁再通过 `run-gates` 流式传递输出。 -对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不安装 Chromium。托管和自托管的默认分支 Linux 串行聚合作业也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。 +对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不安装 Chromium。自托管的默认分支 Linux 串行热备也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器(不存在托管的 Linux 串行聚合)。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。 完整本地 replay 中,6-worker 浏览器命令耗时约 65–71 秒。12-worker 对比约为 50 秒,因此把浏览器 worker 预算减半只增加约 15–20 秒,而不是让墙钟时间翻倍。门禁调度器会在 `built-package-invariants` 成功后立即启动浏览器快照,并发运行彼此独立的门禁,因此既不需要专用 job 超时,也不需要手动制定 YAML 顺序规则。 From a989d4e98b101b1a69309f103c1b0bc99cec5747 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 19 Aug 2026 15:37:58 +0800 Subject: [PATCH 49/60] docs: separate portable vs complete-aggregate evidence in larger-hosted note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: the self-hosted serial standby is not portable evidence — portable means standard GitHub-hosted capacity without repository-external runner configuration, which the self-hosted vm-backup/dsh-win-ci pools do not satisfy. In 2026-07-22-evidence-based-larger-hosted-runners L73, distinguish the two: standard-hosted compatibility jobs preserve portable evidence, while the self-hosted serial standby preserves complete-aggregate evidence. Update the en and zh pair and re-record the i18n hash. --- .../2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 4 ++-- .../2026-07-22-evidence-based-larger-hosted-runners.md | 2 +- .../2026-07-22-evidence-based-larger-hosted-runners.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index ea324214e3..2452f2d472 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md -2026-07-22-evidence-based-larger-hosted-runners.md: 770d9e22d89632b3a020ba54bd9b16342cfc9921 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: a248222255bdc96445c3cf1a2f33f669f488f42e +2026-07-22-evidence-based-larger-hosted-runners.md: 1052f7a6827f9aa84965285252ac1726127329a1 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 6cf5de55d47bcdd33e33011185941c7ae67391ea diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 770d9e22d8..1052f7a682 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -70,7 +70,7 @@ An additional serial Linux reference runs on the in-house self-hosted pool (`vm- **Publish the static job's build to post-build consumers.** A run-scoped artifact preserves one exact build, but the workflow can only consume it by waiting for the entire static job and then requesting another runner. The [independent consumer build](2026-07-30-independent-ci-consumer-build.md) assigns the single Linux build to its actual consumers instead. -**Keep the complete required path on standard GitHub-hosted capacity.** This avoids repository-external runner configuration, but exact-head standard-runner runs remain materially slower and can spend longer queued behind shared capacity. Standard-hosted compatibility jobs and the self-hosted serial standby preserve portable evidence without making that slower topology the ordinary primary path. +**Keep the complete required path on standard GitHub-hosted capacity.** This avoids repository-external runner configuration, but exact-head standard-runner runs remain materially slower and can spend longer queued behind shared capacity. Standard-hosted compatibility jobs preserve portable evidence, while the self-hosted serial standby preserves complete-aggregate evidence, without making that slower topology the ordinary primary path. **Keep blocking and observational native Windows checks in separate jobs.** This would preserve their distinction at the workflow level but pay Windows setup twice. `run-gates` preserves the same blocking versus observational result inside one job. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index a248222255..6cf5de55d4 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -70,7 +70,7 @@ Windows 仓库工作在超过 16 核后收益很小。原生通道把阻断性 **将静态作业的构建发布给构建后消费方。** 仅供本次运行使用的产物能保留同一份构建结果,但工作流要消费它,只能先等待整个静态作业完成,再请求另一台运行器。[消费方独立构建](2026-07-30-independent-ci-consumer-build.md)则转而让实际消费方负责唯一一次 Linux 构建。 -**将完整必需路径保留在 GitHub 标准托管容量上。** 此方案可以避免依赖仓库外部的运行器配置,但标准运行器上的分支头精确运行仍明显更慢,也可能因共享容量而排队更久。标准托管兼容性作业和自托管串行热备保留可移植证据,无需让这套较慢的拓扑成为普通主路径。 +**将完整必需路径保留在 GitHub 标准托管容量上。** 此方案可以避免依赖仓库外部的运行器配置,但标准运行器上的分支头精确运行仍明显更慢,也可能因共享容量而排队更久。标准托管兼容性作业保留可移植证据,自托管串行热备则保留完整聚合流程证据,无需让这套较慢的拓扑成为普通主路径。 **把阻断性与观测性原生 Windows 检查放在不同 job。** 此方案会在工作流层面保留二者的区别,却要承担两次 Windows 设置开销。`run-gates` 在一个 job 内保留了相同的阻断与观测结果。 From a0877ae4a1a8c382072fd5d6e38b37bcd4537902 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 19 Aug 2026 15:56:16 +0800 Subject: [PATCH 50/60] docs: drop dangling 'additional' on self-hosted serial in larger-hosted note L55 said 'An additional serial Linux reference runs ...', but L53 already lists the self-hosted serial Linux standby as the existing reference, so 'additional' implied a second one. Reword to 'The self-hosted serial Linux reference runs ...' and mirror in zh L55. Re-record the i18n hash. --- .../2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 4 ++-- .../2026-07-22-evidence-based-larger-hosted-runners.md | 2 +- .../2026-07-22-evidence-based-larger-hosted-runners.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 2452f2d472..8ac0ed6dbe 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md -2026-07-22-evidence-based-larger-hosted-runners.md: 1052f7a6827f9aa84965285252ac1726127329a1 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 6cf5de55d47bcdd33e33011185941c7ae67391ea +2026-07-22-evidence-based-larger-hosted-runners.md: db08a4eb9812a2cb13499718cbd3ceb023960c06 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 1b9b622e929a693d165d23ce256ba4d3c85a0460 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 1052f7a682..db08a4eb98 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -52,7 +52,7 @@ The process-bound coverage project contains exactly five suite files. Thirty-two The self-hosted serial Linux and Windows standby references and the disabled `serial-macos` job exist. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. -An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the writer-manageable `DSH_CI_FAILOVER_LINUX` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). The standby lane is push-triggered, so it always executes the base branch's workflow definition. Under failover, however, `pull_request` jobs do reach these runners with the PR merge ref's own workflow definition — the trust boundary is repository membership (the repository is private with forking disabled, and the selectors exclude Dependabot), as the [failover runbook](2026-07-26-ci-failover-runbook.md) records. +The self-hosted serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the writer-manageable `DSH_CI_FAILOVER_LINUX` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). The standby lane is push-triggered, so it always executes the base branch's workflow definition. Under failover, however, `pull_request` jobs do reach these runners with the PR merge ref's own workflow definition — the trust boundary is repository membership (the repository is private with forking disabled, and the selectors exclude Dependabot), as the [failover runbook](2026-07-26-ci-failover-runbook.md) records. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 6cf5de55d4..1b9b622e92 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -52,7 +52,7 @@ Windows 仓库工作在超过 16 核后收益很小。原生通道把阻断性 自托管的 Linux 与 Windows 串行热备参考,以及被禁用的 `serial-macos` 任务仍然存在。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 -另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写入权限持有者可管理的仓库变量 `DSH_CI_FAILOVER_LINUX` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查阻塞,形成死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基础分支自身的工作流定义。但需要注意:故障切换期间,`pull_request` 作业确实会带着 PR merge 引用自带的工作流定义到达这些运行器——信任边界是仓库成员资格(仓库为私有且禁用 fork,选择器排除 Dependabot),详见[故障切换手册](2026-07-26-ci-failover-runbook.md)的记录。 +自托管的串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写入权限持有者可管理的仓库变量 `DSH_CI_FAILOVER_LINUX` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查阻塞,形成死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基础分支自身的工作流定义。但需要注意:故障切换期间,`pull_request` 作业确实会带着 PR merge 引用自带的工作流定义到达这些运行器——信任边界是仓库成员资格(仓库为私有且禁用 fork,选择器排除 Dependabot),详见[故障切换手册](2026-07-26-ci-failover-runbook.md)的记录。 ## 曾考虑的替代方案 From 988dd7824a38dff637a7669b4e880613234cad54 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 19 Aug 2026 17:11:36 +0800 Subject: [PATCH 51/60] fix(web): align reference input and recall ordering --- ...6-07-21-cross-session-references.i18n.yaml | 4 +- .../2026-07-21-cross-session-references.md | 4 +- .../2026-07-21-cross-session-references.zh.md | 4 +- ...-web-file-and-session-references.i18n.yaml | 4 +- ...6-07-27-web-file-and-session-references.md | 14 +- ...7-27-web-file-and-session-references.zh.md | 14 +- apps/web/tests/reference-composer.e2e.ts | 84 ++++++++- .../reference-composer/menu.expected.md | 3 +- .../reference-composer/order.expected.md | 23 +++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/ContextInjectionRow.tsx | 5 +- .../src/client/chat/MessageItem.module.css | 21 ++- .../src/client/chat/MessageItem.tsx | 28 ++- .../src/client/input/contract.ts | 18 +- .../src/client/input/decorations.ts | 54 ++++-- .../src/client/input/facade.ts | 14 +- .../src/client/input/machine.ts | 61 ++++--- .../src/client/reference/ReferenceIcon.tsx | 35 ++++ .../src/client/skeleton/InputBar.module.css | 79 ++++---- .../src/client/skeleton/InputBar.tsx | 106 +++++++---- .../tests/chat-branch-tails.client.spec.tsx | 5 +- ...nversation-node-definitions.client.spec.ts | 20 ++ .../tests/input-bar.client.spec.tsx | 73 +++++++- .../tests/input-machine.client.spec.ts | 171 +++++++++++++----- .../input-reference-submit.client.spec.ts | 13 +- .../client/ui-input-trigger/README.i18n.yaml | 4 +- packages/client/ui-input-trigger/README.md | 4 +- packages/client/ui-input-trigger/README.zh.md | 4 +- .../ui-input-trigger/src/client/MenuView.tsx | 4 +- .../ui-input-trigger/src/client/controller.ts | 4 +- .../ui-input-trigger/src/core/contract.ts | 2 + .../client/ui-input-trigger/src/core/menu.ts | 24 ++- packages/client/ui-input-trigger/src/types.ts | 12 +- .../tests/core-menu.client.spec.ts | 10 +- .../tests/menu-view.client.spec.tsx | 10 + .../tests/service.client.spec.ts | 10 + packages/client/ui-reference/README.i18n.yaml | 4 +- packages/client/ui-reference/README.md | 8 +- packages/client/ui-reference/README.zh.md | 8 +- .../client/ui-reference/src/client/index.ts | 22 ++- .../tests/browser-plugin.client.spec.ts | 27 ++- .../session-reference/README.i18n.yaml | 4 +- packages/context/session-reference/README.md | 6 +- .../context/session-reference/README.zh.md | 6 +- .../context/session-reference/src/index.ts | 6 +- .../tests/session-reference.spec.ts | 14 +- 48 files changed, 764 insertions(+), 294 deletions(-) create mode 100644 apps/web/tests/snapshots/reference-composer/order.expected.md create mode 100644 packages/client/ui-conversation/src/client/reference/ReferenceIcon.tsx diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml index 40f4c554ef..f69999eccc 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-cross-session-references.md -2026-07-21-cross-session-references.md: 774a948345e3d45adbba47ef6a7edd3e6f0740b2 -2026-07-21-cross-session-references.zh.md: fd864203d17954167646edf3b1a62946f8fa3f23 +2026-07-21-cross-session-references.md: 78d18da23ef682df18653aea73467e281266de9c +2026-07-21-cross-session-references.zh.md: 4fe53e21f85fab950b8480bb28869df6254eed0a diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md index 774a948345..78d18da23e 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -22,11 +22,11 @@ Preparation deduplicates in first-appearance order, rejects the target id, enfor Projection retains direct-user messages and steering, completed assistant text, and checkpoint user messages carrying the canonical source exported by `dsh-compaction`. That marker is part of the compaction capability contract rather than a backend package name. Reference snapshots remain separate sourced `user/message` events, so projection excludes them as injected context and never recursively propagates an earlier snapshot. Projection also excludes shadowed pre-compaction nodes, tools and results, reasoning, other plugin user messages, log-only records, and incomplete assistant chunks. Repeated compaction therefore exposes only the latest folded checkpoint lineage still on the current surface plus its retained tail; there is no raw/current switch and no shadow recovery. -One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags or escape the data region. The same serializer drives each source's independent byte accounting. AgentLoop persists the snapshot as a sourced `user/message` immediately before the direct `user/message`; target replay therefore satisfies the model-visible/log-reconstructable invariant without a new event type, placement mode, or prompt envelope. +One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags or escape the data region. The same serializer drives each source's independent byte accounting. AgentLoop persists the snapshot as a sourced `user/message` immediately after the direct `user/message`; target replay therefore satisfies the model-visible/log-reconstructable invariant without a new event type, placement mode, or prompt envelope. ## Message ownership -The service's outer `agent/pre-step` listener calls downstream listeners first and processes only an `enter` decision. It parses each accepted direct user message, preserves that message's id while replacing canonical mentions with readable labels, and inserts the frozen snapshot immediately before that message. Queue edits and queue-to-steer relocation need no reference-specific state because the final claimed messages are the input to preparation. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this context ordering. +The service's outer `agent/pre-step` listener calls downstream listeners first and processes only an `enter` decision. It parses each accepted direct user message, preserves that message's id while replacing canonical mentions with readable labels, and inserts the frozen snapshot immediately after that message. Queue edits and queue-to-steer relocation need no reference-specific state because the final claimed messages are the input to preparation. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this context ordering. Reference preparation is not a new delivery protocol and does not create a turn by itself. A preparation failure terminates the already accepted turn through the agent loop's existing plugin-failure path. diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md index fd864203d1..4fe53e21f8 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -22,11 +22,11 @@ Web 用户需要把另一场对话中的相关工作带入一条新消息,但 投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compaction` 导出的规范来源标记的检查点用户消息。该标记属于压缩能力约定的一部分,而非某个后端包名称。引用快照始终是独立且带来源的 `user/message` 事件,因此投影会把它们作为注入上下文排除,绝不递归传播早先的快照。投影还会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。 -系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签,也无法逃逸数据区域。同一个序列化器会独立核算每个源的字节数。AgentLoop 会把快照持久化为一条带来源信息的 `user/message`,紧接在直接 `user/message` 之前。因此,目标回放无需新增事件类型、放置模式或提示词封套,也能满足「模型可见/日志可重建」不变量。 +系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签,也无法逃逸数据区域。同一个序列化器会独立核算每个源的字节数。AgentLoop 会把快照持久化为一条带来源信息的 `user/message`,紧接在直接 `user/message` 之后。因此,目标回放无需新增事件类型、放置模式或提示词封套,也能满足「模型可见/日志可重建」不变量。 ## 消息所有权 -该服务的外层 `agent/pre-step` 监听器会先调用下游监听器,并且只处理 `enter` 决策。它会解析每条已接受的直接用户消息,在把规范 mention 替换为可读标签时保留消息 id,并把冻结快照插入到该消息紧前。最终领取的消息是准备过程的输入,因此队列编辑和从 queue 移动到 steer 不需要引用专用状态。[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定了这一上下文顺序。 +该服务的外层 `agent/pre-step` 监听器会先调用下游监听器,并且只处理 `enter` 决策。它会解析每条已接受的直接用户消息,在把规范 mention 替换为可读标签时保留消息 id,并把冻结快照插入到该消息紧后。最终领取的消息是准备过程的输入,因此队列编辑和从 queue 移动到 steer 不需要引用专用状态。[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定了这一上下文顺序。 引用准备过程不是新的投递协议,本身也不会创建轮次。准备失败会通过 agent loop 的现有插件失败路径终止已经接受的轮次。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml index 46f6c117eb..46d603a4e3 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md -2026-07-27-web-file-and-session-references.md: ad8e5c53832a567bd38d1d1e560122cb8b630daa -2026-07-27-web-file-and-session-references.zh.md: acb016866efc42ef3ea9f661cb10ee1459cf1a6b +2026-07-27-web-file-and-session-references.md: 09619837b6eda304106276064ed5423b10053119 +2026-07-27-web-file-and-session-references.zh.md: 52cba697c76635ab60eb862475bb47c1a27f99a4 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md index ad8e5c5383..09619837b6 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md @@ -10,20 +10,20 @@ The Web composer had a reusable slash/reference trigger pipeline, but its `@` so ## Decision -Web exposes one combined `@file` and `@session` menu through `@deepseek-ai/dsh-client-ui-reference`. For each unquoted query it starts both Remote discovery calls concurrently and deterministically orders files before sessions with locale-registered labels; non-selectable file and session section headings distinguish the two contiguous candidate sections without entering the keyboard-selection index. An open quoted token searches files only. Either candidate domain may fail independently without hiding successful rows from the other. +Web exposes one combined `@file` and `@session` menu through `@deepseek-ai/dsh-client-ui-reference`. For each unquoted query it starts both Remote discovery calls concurrently and deterministically orders files before sessions with locale-registered labels; non-selectable file and session section headings distinguish the two contiguous candidate sections without entering the keyboard-selection index. The source suppresses its raw group title through loading and settled states because those section headings own the visible grouping. An open quoted token searches files only. Either candidate domain may fail independently without hiding successful rows from the other. -The file capability follows the three-package seam: `@deepseek-ai/dsh-file-reference` owns `ctx.fileReferences`, the shared `@path` token grammar, candidate shape, and stable model guidance; `@deepseek-ai/dsh-file-reference-local` owns bounded per-agent Host-filesystem indexes, invalidation, and scoped prompt installation; `dsh-client-ui-reference` consumes the generated Remote namespaces and shared grammar. A file pick remains path-only prompt text and a directory pick retriggers completion below its trailing slash. +The file capability follows the three-package seam: `@deepseek-ai/dsh-file-reference` owns `ctx.fileReferences`, the shared `@path` token grammar, candidate shape, and stable model guidance; `@deepseek-ai/dsh-file-reference-local` owns bounded per-agent Host-filesystem indexes, invalidation, and scoped prompt installation; `dsh-client-ui-reference` consumes the generated Remote namespaces and shared grammar. A file pick is an atomic composer reference with a file glyph and filename; its serialized form remains path-only prompt text. A directory stays editable path text with a folder glyph and retriggers completion below its trailing slash. -A session pick is an atomic composer reference. Its visible label is presentation, while its hidden value and clipboard form are the canonical `@[label](dsh-session:…)` mention produced by the Host. Ordinary `session.prompt` delivery carries that mention unchanged. The session-reference service parses accepted direct user messages at `agent/pre-step`, captures every source, replaces the canonical mention with readable text while preserving the direct message id, and inserts the frozen snapshot immediately before that message. The API Proxy contains no reference-specific route, dependency, or error code. +A session pick is a structured composer reference. Its visible form uses a chat-bubble glyph and business-color session title without a capsule, while its clipboard and model form is the canonical `@[label](dsh-session:…)` mention produced by the Host. The complete `@label` display text remains in the transparent textarea, and the same-size backdrop colors that range and replaces its leading marker with the domain glyph. Native glyph metrics therefore determine width, wrapping, selection, and caret placement without truncation. The occurrence range retains reference identity for serialization; Backspace or Delete at its boundary removes it whole, and editing inside it turns the remaining characters into ordinary text. Ordinary `session.prompt` delivery carries the canonical mention unchanged. The session-reference service parses accepted direct user messages at `agent/pre-step`, captures every source, replaces the canonical mention with readable text while preserving the direct message id, and inserts the frozen snapshot immediately after that message. The recalled-context row uses the same chat glyph while other context keeps the document glyph. The API Proxy contains no reference-specific route, dependency, or error code. -The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or prompt transport failure returns the same draft to editing. After acceptance, reference preparation belongs to the agent turn; a malformed mention, failed source read, cancellation, or budget failure terminates that turn. The logged prompt remains the replay authority. The concrete user and steering chat-node definition associates labels from an immediately preceding session-reference context, so the renderer receives the association from its own node data and shows a compact source summary instead of snapshot JSON. +The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or prompt transport failure returns the same draft to editing. After acceptance, reference preparation belongs to the agent turn; a malformed mention, failed source read, cancellation, or budget failure terminates that turn. The logged prompt remains the replay authority. The chat renders the durable direct-message-then-recall order, decorates recognized file and session mentions as icon-and-text references, and keeps snapshot JSON behind the collapsed recall row. ## Reference transaction ```text -type @ → parallel file/session Remote calls → pick path text or canonical session chip +type @ → parallel file/session Remote calls → pick folder text or atomic file/session reference → serialize draft → ordinary session.prompt enqueue - → agent/pre-step parses mentions → capture sources → context + readable prompt + → agent/pre-step parses mentions → capture sources → readable prompt + context ``` File lookup is advisory and cancellable; selection itself performs no read. Session preparation is all-or-nothing for one accepted model step. A queued message captures each source when the message is claimed, so queue edits and queue-to-steer relocation use the same path without gateway coordination. @@ -42,7 +42,7 @@ File lookup is advisory and cancellable; selection itself performs no read. Sess ## Verification -Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-reference and adjacent-text reference projection, codec round-trip, generated Remote type inference, pre-step preparation, downstream rejection, and chat-node-owned label association. The keyless assembled Web snapshot renders the available reference sections, selects a file, then selects a session reference through the real client composition. +Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, source-title suppression through pending and ready states, grouped headings that do not alter option indexes, file/directory continuation, structured file and session references, complete inline labels, domain glyphs, adjacent-reference and adjacent-text reference projection, codec round-trip, generated Remote type inference, direct-before-recall pre-step preparation, downstream rejection, and durable chat order. The keyless assembled Web snapshot renders the available reference sections without the raw source title, selects a file, then selects a session reference through the real client composition. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md index acb016866e..52cba697c7 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md @@ -10,20 +10,20 @@ Web 输入框已有可复用的斜杠命令/引用触发流水线,但它的 ## 决策 -Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file` 与 `@session` 菜单。每次处理未加引号的查询时,它会并发启动两项 Remote 发现调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的标签;不可选择的文件与会话分组标题会区分两个连续的候选分组,且不会进入键盘选择索引。尚未闭合的带引号 token 只搜索文件。任一候选领域都可以独立失败,不会隐藏另一领域成功返回的行。 +Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file` 与 `@session` 菜单。每次处理未加引号的查询时,它会并发启动两项 Remote 发现调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的标签;不可选择的文件与会话分组标题会区分两个连续的候选分组,且不会进入键盘选择索引。该 source 在加载和已结算状态下都会隐藏原始组标题,因为可见分组由这些分组标题拥有。尚未闭合的带引号 token 只搜索文件。任一候选领域都可以独立失败,不会隐藏另一领域成功返回的行。 -文件功能遵循由三个包构成的 seam:`@deepseek-ai/dsh-file-reference` 拥有 `ctx.fileReferences`、共享 `@path` token 语法、候选形状和稳定的模型指引;`@deepseek-ai/dsh-file-reference-local` 拥有每个 agent(智能体)有界的宿主文件系统索引、失效处理和作用域内的提示词安装;`dsh-client-ui-reference` 消费生成的 Remote 命名空间与共享语法。选择文件后仍只会把路径文本写入提示词,选择目录则会在其尾部斜杠后重新触发补全。 +文件功能遵循由三个包构成的 seam:`@deepseek-ai/dsh-file-reference` 拥有 `ctx.fileReferences`、共享 `@path` token 语法、候选形状和稳定的模型指引;`@deepseek-ai/dsh-file-reference-local` 拥有每个 agent(智能体)有界的宿主文件系统索引、失效处理和作用域内的提示词安装;`dsh-client-ui-reference` 消费生成的 Remote 命名空间与共享语法。选择文件会创建带文件图标与文件名的原子输入框引用,其序列化形式仍只是路径提示词文本。目录保持为带文件夹图标的可编辑路径文本,并在尾部斜杠后重新触发补全。 -选择会话会创建一个原子的输入框引用。可见标签只用于呈现,隐藏值和剪贴板形式则是宿主生成的规范 `@[label](dsh-session:…)` mention。普通 `session.prompt` 投递会原样携带该 mention。session-reference 服务会在 `agent/pre-step` 解析已接受的直接用户消息,捕获每个源,在保留直接消息 id 的同时把规范 mention 替换为可读文本,并把冻结快照插入到该消息紧前。API Proxy 不包含引用专用路由、依赖或错误码。 +选择会话会创建一个结构化输入框引用。可见形式使用聊天气泡图标与业务色会话标题,不使用胶囊容器;剪贴板和模型形式则是宿主生成的规范 `@[label](dsh-session:…)` mention。完整的 `@label` 展示文本会保留在透明 textarea 中,同尺寸 backdrop 会为这段范围着色,并把开头的 marker 替换为对应领域图标。因此宽度、换行、选择区与光标位置都由原生字形度量决定,不会截断。occurrence 范围会保留引用身份以供序列化;在边界按 Backspace 或 Delete 会整段删除引用,在范围内部编辑则会把剩余字符转为普通文本。普通 `session.prompt` 投递会原样携带规范 mention。session-reference 服务会在 `agent/pre-step` 解析已接受的直接用户消息,捕获每个源,在保留直接消息 id 的同时把规范 mention 替换为可读文本,并把冻结快照插入到该消息紧后。召回上下文行使用同一个聊天图标,其他上下文保留文档图标。API Proxy 不包含引用专用路由、依赖或错误码。 -输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或提示词传输失败后,同一草稿会回到可编辑状态。接受后,引用准备属于 agent 轮次;格式错误的 mention、源读取失败、取消或预算失败会终止该轮次。已记录的提示词仍是回放权威。具体的 user 和 steering chat-node 定义会关联紧邻前一条 session-reference 上下文中的标签,因此渲染器会从自身节点数据接收关联信息,并显示精简的来源摘要,而不是快照 JSON。 +输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或提示词传输失败后,同一草稿会回到可编辑状态。接受后,引用准备属于 agent 轮次;格式错误的 mention、源读取失败、取消或预算失败会终止该轮次。已记录的提示词仍是回放权威。聊天界面按照持久的直接消息后接召回行顺序渲染,将识别到的文件与会话 mention 装饰成图标加文字的引用,并把快照 JSON 保留在默认收起的召回行中。 ## 引用事务 ```text -type @ → parallel file/session Remote calls → pick path text or canonical session chip +type @ → parallel file/session Remote calls → pick folder text or atomic file/session reference → serialize draft → ordinary session.prompt enqueue - → agent/pre-step parses mentions → capture sources → context + readable prompt + → agent/pre-step parses mentions → capture sources → readable prompt + context ``` 文件查询仅供参考且可取消;选择操作本身不会读取文件。会话准备针对一个已接受的模型步骤保持全有或全无。queued 消息被领取时会捕获每个源,因此队列编辑和从 queue 移动到 steer 使用同一路径,无需网关协调。 @@ -42,7 +42,7 @@ type @ → parallel file/session Remote calls → pick path text or canonical se ## 验证 -包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻引用及相邻文本条件下的引用投影、codec 无损往返、生成的 Remote 类型推断、pre-step 准备、下游拒绝,以及 chat node 自有的标签关联。无密钥的装配 Web 快照会渲染可用的引用分组,并通过真实客户端组合依次选择文件和会话引用。 +包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、在 pending 与 ready 状态下隐藏 source 标题、不改变候选项索引的分组标题、文件/目录继续补全、结构化文件与会话引用、完整行内标签、领域图标、相邻引用及相邻文本条件下的引用投影、codec 无损往返、生成的 Remote 类型推断、pre-step 中直接消息先于召回的准备、下游拒绝,以及持久聊天顺序。无密钥的装配 Web 快照会在不显示原始 source 标题的情况下渲染可用的引用分组,并通过真实客户端组合依次选择文件和会话引用。 ## 后果 diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index c67a975cbd..ba1780a188 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -1,6 +1,6 @@ // Web e2e scenario: the shipped composition discovers local files and cold // sessions through the real Host, groups both domains in the shared @ menu, -// and projects each pick back into the composer without issuing a model call. +// and projects each pick as a complete inline range without issuing a model call. import { writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -13,6 +13,7 @@ import { Session, SessionId, } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-reference/types' import type {} from '@deepseek-ai/dsh-session-title' import { assertFixtureInventory, @@ -28,8 +29,10 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/reference-composer', import.meta.url)) const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md') +const ORDER_EXPECTED = join(SNAPSHOT_DIR, 'order.expected.md') const MODE = webSnapshotMode() const SOURCE_SESSION_ID = 'reference-source-session' +const TARGET_SESSION_ID = 'reference-order-target-session' /** Build one closed source session with a stable title for reference discovery. */ function sourceSessionFixture(): string { @@ -60,6 +63,53 @@ function sourceSessionFixture(): string { ].join('\n') } +/** Build one target log with the direct message durably before its recalled context. */ +function targetSessionFixture(): string { + const session = Session.create(SessionId(TARGET_SESSION_ID)) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: '@Research what changed?' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: '## Referenced sessions\n\nsnapshot' }], + source: { + kind: 'session-reference', + form: 'recall', + version: 1, + references: [{ + sessionId: SOURCE_SESSION_ID, + label: 'Research', + capturedThroughSeq: 4, + compacted: false, + originalMessages: 2, + retainedMessages: 2, + omittedMessages: 0, + omittedBytes: 0, + truncated: false, + inputIndex: 0, + }], + }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Reference order target', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return [ + JSON.stringify({ + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + }), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + describe.skipIf(MODE === 'record')('web e2e: file and session references through the real host', () => { let scaffold: WebScaffold let browser: Browser @@ -69,6 +119,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through beforeAll(async () => { scaffold = await launchWebScaffold({}) await seedSession(scaffold, sourceSessionFixture(), SOURCE_SESSION_ID) + await seedSession(scaffold, targetSessionFixture(), TARGET_SESSION_ID) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) @@ -83,7 +134,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await scaffold?.close() }) - it('groups both sources and projects file text plus an atomic session chip', async () => { + it('groups both sources and projects files and sessions as structured inline icon labels', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-composer')) const input = page.locator('textarea').first() const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) @@ -94,20 +145,45 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) expect(snapshot).toContain('Files & folders') expect(snapshot).toContain('Session conversations') + expect(snapshot).not.toContain('text: reference Files & folders') expect(snapshot).toContain('File \u00b7 reference.txt') expect(snapshot).toContain('Session \u00b7 Research notes') expect(snapshot).not.toContain('text: Subagents') await input.fill('@reference') await menu.getByRole('option', { name: /File \u00b7 reference\.txt/ }).click() + const fileReference = page.locator('[data-reference-appearance="file"]') + await expect.poll(() => fileReference.textContent()).toBe('@reference.txt') + await expect.poll(() => fileReference.locator('svg').count()).toBe(1) await expect.poll(() => input.inputValue()).toBe('@reference.txt ') await input.fill('@Research') await menu.getByRole('option', { name: /Session \u00b7 Research notes/ }).click() - await expect.poll(() => page.locator('[data-decoration="chip"]').textContent()).toBe('@Research notes') + const sessionReference = page.locator('[data-reference-appearance="session"]') + await expect.poll(() => sessionReference.textContent()).toBe('@Research notes') + await expect.poll(() => sessionReference.locator('svg').count()).toBe(1) + await expect.poll(() => input.inputValue()).toBe('@Research notes ') expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md']) + }) + + it('renders the durable direct-message then recall order', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-order')) + const group = page.getByRole('treeitem', { name: /Ungrouped/ }) + await group.waitFor({ timeout: 15_000 }) + if (await group.getAttribute('aria-expanded') !== 'true') await group.click() + const target = page.getByRole('treeitem').filter({ hasText: /^dsh-web-e2e-ws-/ }).first() + await target.waitFor({ timeout: 15_000 }) + await target.click() + await page.getByRole('button', { name: /^Session recall\s*Research$/ }).waitFor({ timeout: 15_000 }) + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(TARGET_SESSION_ID).join('{{targetId}}') + await compareOrRefreshGolden(ORDER_EXPECTED, snapshot, MODE) + expect(snapshot.indexOf('Research what changed?')).toBeLessThan(snapshot.indexOf('Session recall Research')) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md', 'order.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/reference-composer/menu.expected.md b/apps/web/tests/snapshots/reference-composer/menu.expected.md index f42be2dc47..b5fccf69f5 100644 --- a/apps/web/tests/snapshots/reference-composer/menu.expected.md +++ b/apps/web/tests/snapshots/reference-composer/menu.expected.md @@ -1,5 +1,6 @@ - listbox "Trigger suggestions": - - text: reference Files & folders + - text: Files & folders - option "File · reference.txt reference.txt" [selected] - text: Session conversations + - option "Session · Reference order target reference-order-target-session · {{cwd}} · {{timestamp}}" - option "Session · Research notes reference-source-session · {{cwd}} · {{timestamp}}" diff --git a/apps/web/tests/snapshots/reference-composer/order.expected.md b/apps/web/tests/snapshots/reference-composer/order.expected.md new file mode 100644 index 0000000000..eb821ea71c --- /dev/null +++ b/apps/web/tests/snapshots/reference-composer/order.expected.md @@ -0,0 +1,23 @@ +- banner: + - navigation "Session hierarchy": + - button "Reference order target" [disabled] + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Research what changed? {{clock}} +- button "Copy": + - img +- button "Session recall Research": + - img + - text: Session recall Research +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Send message" [disabled] diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index e1d7e10227..b7992aa91a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 7793273e053142b232cf6e810cd915ddbdeb90b0 -README.zh.md: f45dfa6d72ecf5b49a12c9550d41dbcbc35f9be0 +README.md: f1a4e5de4056712feab7cc1627589909ef70f526 +README.zh.md: a942c994d39652b3811c88ce99bc149cd47b2118 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7793273e05..f1a4e5de40 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -18,7 +18,7 @@ Approvals take over the composer through the chain this package declares: `Appro The session header renders the session-scoped `'conversation.session.header.actions'` list beside the title and the independent `'conversation.session.header.utilities'` list at the right edge. Session context and lineage controls remain in `actions`; optional Session utilities cannot reorder or move them. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. -Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows. +Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A direct message that cites another session precedes its recall row in durable order. Recall uses a chat-bubble glyph while other context keeps the document glyph; a source that names no producer shows the role alone. Composer and user-bubble references use the same inline language: a chat-bubble, file, or folder glyph plus business-color text, without a nested capsule. Like claimed slash commands, composer references keep their complete display text in the transparent textarea and use the aligned backdrop for color and the leading domain glyph; native text metrics own width, wrapping, selection, and caret placement. The occurrence range remains structured for serialization and boundary deletion, while an edit inside it converts the remaining characters to ordinary text. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows. A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f45dfa6d72..a942c994d3 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -16,7 +16,7 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。 会话页头会在标题旁渲染会话作用域的 `'conversation.session.header.actions'` 列表,并在最右侧渲染独立的 `'conversation.session.header.utilities'` 列表。会话上下文和谱系控件保留在 `actions` 中;可选的会话工具不会改变它们的顺序或位置。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的正文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。引用其他会话的直接消息在持久顺序中位于其召回行之前。召回使用聊天气泡图标,其他上下文保留文档图标;来源未提供生产者名称时只显示角色。输入框与用户气泡中的引用使用同一种行内语言:聊天气泡、文件或文件夹图标加业务色文字,不嵌套胶囊容器。与已认领的 slash command 相同,输入框引用会把完整展示文本保留在透明 textarea 中,再用对齐的 backdrop 提供颜色和开头的领域图标;宽度、换行、选择区与光标位置均由原生文本度量决定。occurrence 范围仍为序列化与边界整段删除保留结构身份,在范围内部编辑则会把剩余字符转为普通文本。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的正文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx index 889b15b085..3e9e0f10b1 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client' import type { ChatViewSlotProps } from '../contract/slots.ts' import { DisclosureRow, IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { ReferenceIcon } from '../reference/ReferenceIcon.tsx' import { contextBody } from './ContextBody.tsx' import css from './ContextInjectionRow.module.css' @@ -37,7 +38,9 @@ export function ContextInjectionRow({ content, source, provenance, form, t }: Co return ( } + icon={provenance.role === 'recall' + ? + : } chevronClassName={css.chevron} title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')} collapsedContent={provenance.label === null ? undefined : ( diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 6aa1386a3e..54e5f6f98d 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -273,18 +273,19 @@ } } -/* Reference chip projection inside a user bubble (`name` model - spans and metadata-confirmed sessions render as chips; free geometry means - the textarea overlay's metric pairing does not apply here). */ +/* Inline references use domain glyphs and business-color text without another + container inside the user bubble. */ .refChip { - display: inline-block; + display: inline-flex; + align-items: center; + gap: 4px; margin: 0 2px; - padding: 0 8px; - border-radius: 6px; - background: rgba(97, 135, 216, 0.22); - color: var(--dsw-alias-label-primary); - font-size: 0.85em; - line-height: 1.6; + color: var(--dsw-alias-state-business-primary); + font-weight: 500; white-space: nowrap; vertical-align: baseline; } + +.refIcon { + flex: none; +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 4cb553d38e..7934054d96 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -10,6 +10,7 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' +import { ReferenceIcon } from '../reference/ReferenceIcon.tsx' import { CompactionItem } from './CompactionItem.tsx' import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' @@ -162,23 +163,42 @@ function projectUserText(text: string, sessionLabels: readonly string[]): ReactN start = text.indexOf(label, start + label.length) } } - const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g + const re = /(^|\s)(\/[\w-]+|@"[^"\n]+"|@[^\s]+)/gu let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { const tokenStart = m.index + (m[1]?.length ?? 0) const label = m[2] ?? '' ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' }) } - ranges.sort((a, b) => a.start - b.start || b.end - a.end) + ranges.sort((a, b) => a.start - b.start + || (a.kind === b.kind ? b.end - a.end : a.kind === 'session' ? -1 : 1)) const parts: ReactNode[] = [] let cursor = 0 for (const range of ranges) { if (range.start < cursor) continue const { start: tokenStart, end, label, kind } = range if (tokenStart > cursor) parts.push() + const referenceKind = kind === 'session' + ? 'session' + : label.startsWith('@') + ? label.endsWith('/') ? 'folder' : /[./\\]/u.test(label.slice(1)) ? 'file' : 'session' + : undefined + const displayLabel = referenceKind === undefined + ? label + : referenceKind === 'session' + ? label.slice(1) + : label.slice(1).replace(/^"|"$/gu, '').split(/[\\/]/u).filter(Boolean).at(-1) ?? label.slice(1) parts.push( - - {label} + + {referenceKind !== undefined && ( + + )} + {displayLabel} , ) cursor = end diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 910acc9511..b9ace8b863 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -149,9 +149,9 @@ export interface EditRange extends EditSelection { } /** - * One reference chip occurrence, backing exactly one U+FFFC placeholder in - * the draft. Identity is occurrenceId — same-named - * references stay independently addressable. label/clipboardText are the + * One reference occurrence backed by its complete inline display text in the + * draft. Identity is occurrenceId — same-named + * references stay independently addressable. label/appearance/clipboardText are the * owner's insert-time projections, cached so the chip survives owner loss * (invalid flips instead of dropping the occurrence). */ @@ -162,10 +162,14 @@ export interface Occurrence { readonly source: string /** Owner-scoped reference id. */ readonly ref: string - /** Placeholder offset in the draft; the occurrence occupies exactly [offset, offset+1). */ + /** Display-text offset in the draft. */ readonly offset: number - /** Chip display label (insert-time cache). */ + /** Display-text length; the occurrence occupies exactly [offset, offset+length). */ + readonly length: number + /** Inline display label (insert-time cache). */ readonly label: string + /** Optional domain glyph (insert-time cache). */ + readonly appearance?: ReferenceInsert['appearance'] /** Clipboard / persistence projection, e.g. `/name` (insert-time cache, never the model form). */ readonly clipboardText: string /** Owner-resolution failure flag: chip renders invalid; serialization must fail. */ @@ -215,7 +219,7 @@ export interface InputState { readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */ readonly claim?: { readonly token: string; readonly hint?: string; readonly images?: boolean } - /** Chip occurrence table, sorted by offset (one U+FFFC per entry). */ + /** Reference occurrence table, sorted by offset. */ readonly occurrences: readonly Occurrence[] /** Live paste-match attempt (absent when no paste is matchable). */ readonly paste?: PasteAttemptState @@ -248,7 +252,7 @@ export type InputEvent = /** Full next draft from the textarea; editRange narrows the occurrence math (absent → diff scan). */ | { readonly type: 'draft-changed'; readonly draft: string; readonly editRange?: EditRange } | { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan } - /** Place one U+FFFC at the span and mint the occurrence (scoped insert-reference event payload). */ + /** Place one inline reference at the span and mint the occurrence (scoped insert-reference event payload). */ | { readonly type: 'insert-ref'; readonly reference: ReferenceInsert; readonly span: TokenSpan } /** Delete a settled command token; success is observable as a draftRev advance. */ | { readonly type: 'consume-token'; readonly guard: ConsumeTokenGuard } diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index bd3dd09040..1ae5e9404b 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -1,6 +1,6 @@ /** - * Draft decoration pure core (chips render from the occurrence - * table at placeholder offsets; the claim token renders as a mirror-layer + * Draft decoration pure core (references render from occurrence ranges; the + * claim token renders as a mirror-layer * highlight, the claim hint as ghost text). Zero React — the skeleton renders * the instructions; tests drive this directly. */ @@ -12,13 +12,19 @@ export interface TokenRange { readonly end: number } -/** One chip render instruction: the placeholder at `offset` draws as `label`. */ +/** One structured inline-reference render instruction. */ export interface ChipRender { /** Stable render key (same-labeled chips stay independent). */ readonly occurrenceId: number - /** Placeholder offset in the draft (the chip occupies [offset, offset+1)). */ + /** Display-text offset in the draft. */ readonly offset: number + /** Display-text length in the draft. */ + readonly length: number + /** Exact inline text whose native glyph metrics determine layout. */ + readonly text: string readonly label: string + /** Optional domain glyph beside the label. */ + readonly appearance?: 'session' | 'file' | 'folder' /** Owner-resolution failure styling bit. */ readonly invalid: boolean } @@ -34,6 +40,8 @@ export interface TextRefRange { readonly start: number readonly end: number readonly trigger: '/' | '@' + /** Optional icon domain for syntax-recognizable plain references. */ + readonly appearance?: 'folder' } /** Decoration product: claim token range + chip instructions + text-ref ranges + the ghost hint. */ @@ -42,7 +50,7 @@ export interface DraftDecorations { readonly token: TokenRange | null /** Chip render instructions in draft order (occurrence table is offset-sorted). */ readonly chips: readonly ChipRender[] - /** Scan-derived plain-text reference ranges (empty without a lexicon). */ + /** Scan-derived lexicon tokens and syntax-recognizable folder ranges. */ readonly textRefs: readonly TextRefRange[] /** Ghost hint shown while the claim's args are blank; null otherwise. */ readonly hint: string | null @@ -50,6 +58,7 @@ export interface DraftDecorations { /** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */ const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g +const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g /** * Scan the draft for plain-text reference tokens against the hot lexicons. @@ -63,19 +72,31 @@ const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g export function scanTextRefs( draft: string, lexicon: ReadonlyMap<'/' | '@', readonly string[]>, ): TextRefRange[] { - if (lexicon.size === 0 || draft === '') return [] + if (draft === '') return [] const out: TextRefRange[] = [] - TEXT_REF_RE.lastIndex = 0 - let m: RegExpExecArray | null - while ((m = TEXT_REF_RE.exec(draft)) !== null) { - const trigger = m[2] as '/' | '@' - const name = m[3] ?? '' - if (lexicon.get(trigger)?.includes(name)) { - const start = m.index + (m[1]?.length ?? 0) - out.push({ start, end: start + 1 + name.length, trigger }) + if (lexicon.size > 0) { + TEXT_REF_RE.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = TEXT_REF_RE.exec(draft)) !== null) { + const trigger = m[2] as '/' | '@' + const name = m[3] ?? '' + if (lexicon.get(trigger)?.includes(name)) { + const start = m.index + (m[1]?.length ?? 0) + out.push({ start, end: start + 1 + name.length, trigger }) + } } } - return out + FOLDER_REF_RE.lastIndex = 0 + let folder: RegExpExecArray | null + while ((folder = FOLDER_REF_RE.exec(draft)) !== null) { + const token = folder[2] ?? '' + const start = folder.index + (folder[1]?.length ?? 0) + const end = start + token.length + if (!out.some(range => range.start < end && range.end > start)) { + out.push({ start, end, trigger: '@', appearance: 'folder' }) + } + } + return out.sort((left, right) => left.start - right.start) } /** The empty lexicon (default: zero text-ref decorations, old call sites unchanged). */ @@ -97,7 +118,10 @@ export function deriveDecorations( const chips = occurrences.map(o => ({ occurrenceId: o.occurrenceId, offset: o.offset, + length: o.length, + text: draft.slice(o.offset, o.offset + o.length), label: o.label, + ...o.appearance === undefined ? {} : { appearance: o.appearance }, invalid: o.invalid === true, })) const hint = claimActive && claim.hint !== undefined && draft.slice(claim.token.length).trim() === '' diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index e6a75f2ef8..0a115dedeb 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -103,7 +103,7 @@ export class SessionInputShell implements SessionInput { /** One image-only send at a time: Enter during the Host round-trip is a no-op. */ private imageSendInFlight = false private disposed = false - /** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */ + /** Draft persistence mirror (chat store write; receives the clipboard projection, never display-only ranges). */ private mirrorFn: ((text: string) => void) | undefined constructor(private readonly deps: SessionInputDeps) { @@ -448,7 +448,7 @@ export class SessionInputShell implements SessionInput { /** * Prompt serialization before the sink: expand each - * placeholder to its owner's model form via the session controller's + * inline reference range to its owner's model form via the session controller's * codec routing. Owner missing / serialize failure / disposal blocks the * send — notice + draft and chips retained, never a silent downgrade to * the clipboard text. Chip-free drafts skip the async detour. @@ -464,17 +464,21 @@ export class SessionInputShell implements SessionInput { const controller = new AbortController() void Promise.all(occurrences.map(async (o) => { if (inputTriggers === undefined) throw new Error(`no serializer for reference source "${o.source}"`) - return { offset: o.offset, text: await inputTriggers.serializeReference(o.source, o.ref, controller.signal) } + return { + offset: o.offset, + length: o.length, + text: await inputTriggers.serializeReference(o.source, o.ref, controller.signal), + } })).then( (parts) => { if (this.disposed) return - // Splice model forms over their placeholders (offsets are draft-time; + // Splice model forms over their display ranges (offsets are draft-time; // parts arrive offset-sorted since the table is). let out = '' let cursor = 0 for (const part of parts) { out += draft.slice(cursor, part.offset) + part.text - cursor = part.offset + 1 + cursor = part.offset + part.length } out += draft.slice(cursor) this.settleSubmit(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal), imageIds) diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 0f124e57ff..a1a49004a4 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -4,8 +4,8 @@ * clock. Package-private — the SessionInput shell is the only caller and the * sole executor of the returned effects. * - * Draft truth: the draft string holds one U+FFFC placeholder per chip; the - * occurrence table carries identity and the owner's cached projections. Every + * Draft truth: the draft string holds each reference's complete inline display + * text; the occurrence table carries identity, range, and the owner's cached projections. Every * draft mutation is one transaction — draft edit, occurrence reconciliation, * and undo-log push are atomic inside dispatch() — and bumps draftRev, which * is what lets span CAS reduce to a revision-equality check: equal rev ⟹ @@ -20,9 +20,21 @@ import type { InputState, Occurrence, PasteAttemptState, PasteComponent, SubmitAttempt, } from './contract.ts' -/** The object-replacement character backing every chip occurrence in the draft. */ +/** Legacy fixed-width object replacement character rejected from pasted text. */ export const PLACEHOLDER = '' +const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu + +/** + * Build the inline draft text whose leading marker is decorated as the + * reference icon in the backdrop. + * @param reference - reference insertion with its cached display projection. + * @returns display text with one marker glyph followed by the complete label. + */ +export function referenceDraftText(reference: Pick): string { + return `@${reference.label}` +} + /** The machine never writes the queue; the wiring layer overlays the queue store's projection. */ const EMPTY_QUEUE: InputState['queue'] = [] @@ -67,10 +79,9 @@ function diffEdit(prev: string, next: string): EditRange { } /** - * Expand the draft's placeholders into their occurrences' clipboard text - * (the persistence mirror and clipboard both write this - * projection — U+FFFC never leaves the machine). Table order is offset - * order, so one linear walk pairs placeholders with entries. + * Expand the draft's reference ranges into their occurrences' clipboard text + * for persistence and clipboard projection. Table order is offset order, so + * one linear walk pairs ranges with entries. * @param state - published input state. * @returns the plain-text projection of the draft. */ @@ -81,7 +92,7 @@ export function projectClipboard(state: Pick= range.end) kept.push(delta === 0 ? o : { ...o, offset: o.offset + delta }) } this.occurrences = kept @@ -225,14 +236,16 @@ export class InputMachine { } /** Mint one occurrence at a draft offset. */ - private mint(reference: ReferenceInsert, offset: number): Occurrence { + private mint(reference: ReferenceInsert, offset: number, length: number): Occurrence { this.occurrenceSeq += 1 return { occurrenceId: this.occurrenceSeq, source: reference.source, ref: reference.ref, offset, + length, label: reference.label, + ...reference.appearance === undefined ? {} : { appearance: reference.appearance }, clipboardText: reference.clipboardText, } } @@ -293,19 +306,20 @@ export class InputMachine { } /** - * Shared chip-insertion transaction: replace [span) with one placeholder + * Shared reference-insertion transaction: replace [span) with one inline * occurrence (insert-ref and paste-upgrade both land here). A separating - * space follows the chip unless one is already next. - * @returns the inserted length (placeholder plus optional gap). + * space follows the reference unless one is already next. + * @returns the inserted length (display text plus optional gap). */ private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): number { this.pushTxn() this.typingRun = undefined const tail = this.draft.slice(span.end) const gap = tail.length === 0 || tail[0] !== ' ' ? ' ' : '' - const inserted = PLACEHOLDER + gap + const displayText = referenceDraftText(reference) + const inserted = displayText + gap this.reconcile({ start: span.start, end: span.end, insertedLength: inserted.length }) - this.withMinted([this.mint(reference, span.start)]) + this.withMinted([this.mint(reference, span.start, displayText.length)]) this.adopt(this.draft.slice(0, span.start) + inserted + tail) this.watchClaim() return inserted.length @@ -392,7 +406,7 @@ export class InputMachine { // ---- paste plane ---- /** - * Paste as one transaction: the text (U+FFFC-sanitized) replaces the + * Paste as one transaction: the text (reference-placeholder-sanitized) replaces the * selection; hot-snapshot sync matches componentize inside the SAME * transaction (one undo returns to pre-paste); a match attempt opens for * the async remainder while the phase still accepts reference mutations. @@ -403,19 +417,20 @@ export class InputMachine { ): InputEffect[] { const { start, end } = selection if (start < 0 || start > end || end > this.draft.length) return [] - const text = rawText.split(PLACEHOLDER).join('') + const text = rawText.replace(REFERENCE_PLACEHOLDER_RE, '') this.pushTxn(selection) this.typingRun = undefined // Componentize: replace each matched token range (paste-text coordinates, - // disjoint by contract) with a placeholder while assembling the insert. + // disjoint by contract) with inline display text while assembling the insert. const sorted = [...components].sort((a, b) => a.start - b.start) const minted: Occurrence[] = [] let inserted = '' let cursor = 0 for (const c of sorted) { inserted += text.slice(cursor, c.start) - minted.push(this.mint(c.reference, start + inserted.length)) - inserted += PLACEHOLDER + const displayText = referenceDraftText(c.reference) + minted.push(this.mint(c.reference, start + inserted.length, displayText.length)) + inserted += displayText cursor = c.end } inserted += text.slice(cursor) diff --git a/packages/client/ui-conversation/src/client/reference/ReferenceIcon.tsx b/packages/client/ui-conversation/src/client/reference/ReferenceIcon.tsx new file mode 100644 index 0000000000..fcc89fe30e --- /dev/null +++ b/packages/client/ui-conversation/src/client/reference/ReferenceIcon.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react' +import { + IconBrowseOutline16, IconFolderClose16, +} from '@deepseek-ai/dsh-client-ui-primitives' + +/** Reference domains with distinct composer and transcript glyphs. */ +export type ReferenceIconKind = 'session' | 'file' | 'folder' + +/** Props shared by inline reference glyphs. */ +export interface ReferenceIconProps { + kind: ReferenceIconKind + size?: number + className?: string | undefined +} + +/** + * Render the icon that identifies one inline reference domain. + * @param props - Reference kind, optional size, and optional CSS class. + * @returns The corresponding current-color SVG glyph. + */ +export function ReferenceIcon({ kind, size = 16, className }: ReferenceIconProps): ReactNode { + switch (kind) { + case 'session': + return ( + + + + ) + case 'file': return + case 'folder': return + } +} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index de3a1739fc..315121814d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -1,12 +1,3 @@ -/* One-glyph font: maps ONLY U+FFFC to a blank 4em-advance glyph (every other - codepoint falls through to the next family). Loaded first in the composer - font stack, it gives the placeholder a real cell width INSIDE the textarea, - so the backdrop chip (same char, same stack) matches it by construction — - the two layers cannot drift and the chip gets a usable label cell. */ -@font-face { - font-family: 'DshChipCell'; - src: url('data:font/ttf;base64,AAEAAAAKAIAAAwAgT1MvMkT8SmIAAAEoAAAAYGNtYXAADQBPAAABkAAAADRnbHlmAAAAAAAAAcwAAAABaGVhZCwtPGoAAACsAAAANmhoZWEDIg7bAAAA5AAAACRobXR4EZQAAAAAAYgAAAAIbG9jYQAAAAAAAAHEAAAABm1heHAAAwACAAABCAAAACBuYW1lvljk2gAAAdAAAABscG9zdNNweNQAAAI8AAAALQABAAAAAQAAdia1tV8PPPUAAwPoAAAAAOaLfcUAAAAA5ot9xQAAAAAAAAAAAAAAAwACAAAAAAAAAAEAAAMg/zgAAA+gAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAACAAEAAAACAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAwjKAZAABQAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAPz8/PwAA//z//AMg/zgAAAMgAMgAAAAAAAAAAAAAAAAAAAAgAAAB9AAAD6AAAAAAAAIAAAADAAAAFAADAAEAAAAUAAQAIAAAAAQABAABAAD//P//AAD//P//AAUAAQAAAAAAAAAAAAAAAAAAAAAAAAAEADYAAQAAAAAAAQALAAAAAQAAAAAAAgAHAAsAAwABBAkAAQAWABIAAwABBAkAAgAOAChEc2hDaGlwQ2VsbFJlZ3VsYXIARABzAGgAQwBoAGkAcABDAGUAbABsAFIAZQBnAHUAbABhAHIAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAABAgZvYmpyZXAAAAA=') format('truetype'); -} /* Floating capsule input (figma Input_Bottom 75:8208): card floats above the viewport bottom inside the centered message column; textarea on top, action @@ -145,9 +136,9 @@ position: relative; } -/* Decoration backdrop: same metrics as the textarea, transparent glyphs; only - the highlight backgrounds and the ghost hint show through the transparent - textarea background above it. */ +/* Decoration backdrop: same metrics as the transparent-text textarea. It owns + every visible glyph plus the range colors and ghost hint; the textarea above + retains the native selection and caret. */ .backdrop { position: absolute; inset: 0; @@ -214,10 +205,7 @@ /* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these metrics or the highlight ranges drift off the glyphs. */ padding: 4px 12px 0 16px; - /* DshChipCell first: ONLY U+FFFC resolves there (4em blank cell — the chip - slot); everything else falls through to the app stack. All three layers - share the stack, so placeholder advances agree by construction. */ - font-family: 'DshChipCell', var(--dsw-font-family); + font-family: var(--dsw-font-family); font-size: inherit; /* Three consumers, not two: the mirror sizes the stack, the layers must break lines identically, and the caret reveal parses this value to step one line @@ -427,43 +415,50 @@ display: none; } -/* Reference chip: rendered in the backdrop at the placeholder offset. Hard - alignment constraint: the chip's advance must equal the textarea's U+FFFC - advance EXACTLY or every glyph after it drifts (caret/selection follow the - textarea character stream). The ::before renders the same U+FFFC through - the same font stack (DshChipCell 4em cell), so both layers agree by - construction — no measured widths. The label overlays the cell, clipped - with an ellipsis; the full name rides the title tooltip. */ -.chip { +.textRefTrigger { position: relative; - border-radius: 6px; - background: rgba(97, 135, 216, 0.22); } -.chip::before { - content: '\FFFC'; +.textRefTriggerGlyph { color: transparent; } -.chipLabel { - /* Compensated-scale centering: overflow clipping happens BEFORE transform, - so the box is laid out at 1/0.72 of the cell and scaled back down — the - clip edge then lands on the visual cell edge, not mid-glyph. */ +.textRefIcon { position: absolute; - left: 50%; top: 50%; - width: calc(100% / 0.72 - 10px); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - color: var(--dsw-alias-label-primary); - white-space: nowrap; - transform: translate(-50%, -50%) scale(0.72); + left: 50%; + transform: translate(-50%, -50%); +} + +/* Structured references use the same inline-backdrop technique as /skill: + their complete display text remains in the textarea, so wrapping and caret + geometry come from the browser's native glyph metrics. The leading marker + reserves the icon's advance while the backdrop paints the domain glyph. */ +.chip { + position: relative; + color: var(--dsw-alias-state-business-primary); + background: transparent; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} + +.chipTrigger { + position: relative; +} + +.chipTriggerGlyph { + color: transparent; +} + +.chipIcon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); } .chipInvalid { - background: rgba(216, 97, 97, 0.2); text-decoration: line-through; opacity: 0.7; + color: var(--dsw-alias-state-error-primary); } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index bdb5c592cd..c85752da0b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -25,6 +25,7 @@ import type { ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' import type { DraftDecorations } from '../input/decorations.ts' import { attachmentErrorText, imageSizeText } from '../image-labels.ts' +import { ReferenceIcon } from '../reference/ReferenceIcon.tsx' import { ContextMeter } from './ContextMeter.tsx' import { PermissionSelect } from './PermissionSelect.tsx' import { isSafariBrowser, repairSafariTextareaLayout } from './safari.ts' @@ -268,6 +269,14 @@ export function InputBar({ return () => { el.removeEventListener('wheel', onWheel) } }, []) + // selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them. + /* oxlint-disable typescript/no-unnecessary-condition */ + const selectionOf = (el: HTMLTextAreaElement) => ({ + start: el.selectionStart ?? 0, + end: el.selectionEnd ?? el.selectionStart ?? 0, + }) + /* oxlint-enable typescript/no-unnecessary-condition */ + const onKeyDown = (e: KeyboardEvent): void => { if (workspaceTrigger) { if (e.key === 'Enter' || e.key === ' ') { @@ -285,6 +294,24 @@ export function InputBar({ // keyCode 229 is the legacy IME-composition signal engines emit without isComposing. // oxlint-disable-next-line typescript/no-deprecated const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229 + if (!composing && !machineBusy && !locked + && (e.key === 'Backspace' || e.key === 'Delete')) { + const selection = selectionOf(e.currentTarget) + if (selection.start === selection.end) { + const occurrence = input.occurrences.find(o => e.key === 'Backspace' + ? o.offset + o.length === selection.start + : o.offset === selection.start) + if (occurrence !== undefined) { + e.preventDefault() + const start = occurrence.offset + const end = occurrence.offset + occurrence.length + keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 }) + restoreCaret(e.currentTarget, start) + keyboard.track(keyboard.snapshot.draft, start) + return + } + } + } if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault() return @@ -351,45 +378,32 @@ export function InputBar({ keyboard.track(next, e.target.selectionStart ?? next.length) } - // ---- chip atomicity (DOM layer; the machine sees only transactions) ---- - // Placeholders occupy exactly one char, so caret positions are always - // BETWEEN them — what needs normalizing is deletion (whole chip per - // Backspace/Delete via native single-char semantics, which U+FFFC already - // gives us) and selection endpoints: Shift-extension snapping is native - // too (one char = one step). Mouse selection of a chip is handled in the - // backdrop click handler below. Undo/redo must NOT reach the browser: the - // machine owns the transaction log. - // selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them. - /* oxlint-disable typescript/no-unnecessary-condition */ - const selectionOf = (el: HTMLTextAreaElement) => ({ - start: el.selectionStart ?? 0, - end: el.selectionEnd ?? el.selectionStart ?? 0, - }) - /* oxlint-enable typescript/no-unnecessary-condition */ - const onCopyOrCut = (e: React.ClipboardEvent, cut: boolean): void => { if (input === undefined || keyboard === undefined) return // absent machine: no draft can be copied or cut const el = e.currentTarget const { start, end } = selectionOf(el) if (start === end) return - const slice = draft.slice(start, end) - const touched = input.occurrences.filter(o => o.offset >= start && o.offset < end) + const touched = input.occurrences.filter(o => o.offset < end && o.offset + o.length > start) if (touched.length === 0 && !cut) return // plain copy of plain text: native path is fine e.preventDefault() - // Expand placeholders to their owner clipboard projections. + const copyStart = touched.reduce((value, o) => Math.min(value, o.offset), start) + const copyEnd = touched.reduce((value, o) => Math.max(value, o.offset + o.length), end) + // Expand structured ranges to their owner clipboard projections. let text = '' - let cursor = start + let cursor = copyStart for (const o of touched) { text += draft.slice(cursor, o.offset) + o.clipboardText - cursor = o.offset + 1 + cursor = o.offset + o.length } - text += draft.slice(cursor, end) + text += draft.slice(cursor, copyEnd) e.clipboardData.setData('text/plain', text) if (cut && !machineBusy && !locked) { - keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 }) - restoreCaret(el, start) + keyboard.setDraft( + draft.slice(0, copyStart) + draft.slice(copyEnd), + { start: copyStart, end: copyEnd, insertedLength: 0 }, + ) + restoreCaret(el, copyStart) } - void slice } const onPaste = (e: React.ClipboardEvent): void => { @@ -496,16 +510,15 @@ export function InputBar({ ? null : - // Mirror-layer decorations: a visible backdrop with transparent text. The - // claim token highlights through behind the textarea glyphs; each U+FFFC - // placeholder renders as a chip (the textarea's own glyph is invisible, the - // backdrop chip supplies the visual); the claim hint is ghost text. + // Mirror-layer decorations: a visible backdrop with transparent textarea + // text. Claim tokens and references retain the draft's own glyph metrics, + // so their decoration cannot drift from wrapping, selection, or the caret. const deco = input === undefined ? INERT_DECORATIONS : deriveDecorations(input, lexicon) const backdrop: ReactNode[] = [] { - // Segment boundaries: the token range end, every chip offset, and every - // text-ref range — merged in draft order (the sources never - // overlap: chips sit on placeholders, text-refs on plain tokens, the + // Segment boundaries: the token range end, every structured-reference + // offset, and every text-ref range — merged in draft order (the sources never + // overlap: structured references own their ranges, text-refs own plain tokens, the // claim token only leads). let cursor = 0 const pushPlain = (upTo: number): void => { @@ -533,27 +546,44 @@ export function InputBar({ if (b.kind === 'chip') { const chip = b.chip backdrop.push( - // The cell's ::before renders U+FFFC itself so its advance equals the - // textarea's placeholder exactly (same char, same font); the label is - // a clipped overlay that never affects layout. - {chip.label} + {chip.appearance === undefined + ? chip.text[0] + : ( + + {chip.text[0]} + + + )} + {chip.text.slice(1)} , ) - cursor = chip.offset + 1 // the placeholder char the chip stands for + cursor = chip.offset + chip.length } else { // Plain-range highlight: the glyphs stay the // textarea's (advance untouched); the mark paints the chip look. + const text = draft.slice(b.ref.start, b.ref.end) backdrop.push( - {draft.slice(b.ref.start, b.ref.end)} + {b.ref.appearance === 'folder' + ? ( + <> + + {text[0]} + + + {text.slice(1)} + + ) + : text} , ) cursor = b.ref.end diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx index c0a75162c3..18385e9e76 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx @@ -101,7 +101,8 @@ describe('MessageItem arms', () => { }} />, ) - expect(view.container.querySelector('[data-ref-chip="session"]')?.textContent).toBe('@你好') + expect(view.container.querySelector('[data-ref-chip="session"]')?.textContent).toBe('你好') + expect(view.container.querySelector('[data-ref-chip="session"] svg')).not.toBeNull() expect(view.getByText('这个在讲啥')).toBeTruthy() expect(view.getByText('引用会话 · 你好')).toBeTruthy() }) @@ -291,6 +292,7 @@ describe('MessageItem arms', () => { expect(disclosure.getAttribute('aria-expanded')).toBe('false') expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull() expect(ctxView.container.querySelector('svg')).not.toBeNull() + expect(ctxView.container.querySelector('[data-context-recall-icon]')).toBeNull() fireEvent.click(disclosure) expect(disclosure.getAttribute('aria-expanded')).toBe('true') @@ -743,6 +745,7 @@ describe('MessageItem arms', () => { } as never} />, ) + expect(view.container.querySelector('[data-context-recall-icon]')).not.toBeNull() fireEvent.click(view.getByRole('button', { name: /^跨会话召回\s*重构 loader, 修 CI$/ })) const rows = [...view.container.querySelectorAll('[data-context-recalls] li')].map(node => node.textContent) expect(rows).toEqual(['重构 loader保留 18 条 · 省略 42 条已截断', '修 CI保留 3 条 · 省略 0 条']) diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts index a9de47c7f3..605b5fc92c 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts @@ -539,6 +539,26 @@ describe('built-in conversation node Definitions', () => { expect(users[1]?.data).not.toHaveProperty('referenceLabels') }) + it('keeps a direct message before its following session-reference context', () => { + const value = assembler([ + at(1, 'user/message', textMessage('citing-user', '@Research what changed?'), { surfaceOp: 'append' }), + at(2, 'user/message', { + ...textMessage('reference-context', 'snapshot'), + source: { + kind: 'session-reference', + form: 'recall', + version: 1, + references: [{ sessionId: 'source-a', label: 'Research' }], + }, + }, { surfaceOp: 'append' }), + ]) + + const nodes = [...snapshot(value).nodes.values()] + .filter(candidate => candidate.kind === 'user' || candidate.kind === 'context') + expect(nodes.map(candidate => candidate.kind)).toEqual(['user', 'context']) + expect(nodes[0]?.data).not.toHaveProperty('referenceLabels') + }) + it('keeps replacement copies out of Chat business nodes', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index ecf06f6c38..e62b6b9b4e 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -1161,20 +1161,74 @@ describe('decorations', () => { expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行') }) - it('an inserted reference renders as a chip at its placeholder offset', () => { + it('an inserted reference decorates its complete inline display range', () => { const { view, shell } = bench() + const reference = { + source: 'reference', ref: 'w1', label: '会话一', appearance: 'session' as const, clipboardText: '@w1', + } act(() => { shell.setDraft('参考 @w1 内容') shell.insertReference( - { source: 'subagent', ref: 'w1', label: '@w1', clipboardText: '@w1' }, + reference, { start: 3, end: 6, draftRev: shell.snapshot.draftRev }, ) }) const chip = view.container.querySelector('[data-decoration="chip"]') - expect(chip?.textContent).toBe('@w1') + expect(chip?.textContent).toBe('@会话一') + expect(chip?.getAttribute('data-reference-appearance')).toBe('session') + expect(chip?.querySelector('svg')).not.toBeNull() expect(shell.snapshot.occurrences).toHaveLength(1) - // The draft carries exactly one placeholder char where the token was. - expect(shell.snapshot.draft).toBe('参考 \uFFFC 内容') + expect(shell.snapshot.draft).toBe('参考 @会话一 内容') + expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 3, length: 4 }) + }) + + it('Backspace and Delete remove a reference as one range at its boundaries', () => { + const reference = { + source: 'reference', ref: 'w1', label: '会话一', appearance: 'session' as const, clipboardText: '@w1', + } + const backspace = bench() + act(() => { + backspace.shell.setDraft('前 @w1 后') + backspace.shell.insertReference( + reference, + { start: 2, end: 5, draftRev: backspace.shell.snapshot.draftRev }, + ) + }) + backspace.textarea.setSelectionRange(6, 6) + fireEvent.keyDown(backspace.textarea, { key: 'Backspace' }) + expect(backspace.shell.snapshot).toMatchObject({ draft: '前 后', occurrences: [] }) + + const forwardDelete = bench() + act(() => { + forwardDelete.shell.setDraft('前 @w1 后') + forwardDelete.shell.insertReference( + reference, + { start: 2, end: 5, draftRev: forwardDelete.shell.snapshot.draftRev }, + ) + }) + forwardDelete.textarea.setSelectionRange(2, 2) + fireEvent.keyDown(forwardDelete.textarea, { key: 'Delete' }) + expect(forwardDelete.shell.snapshot).toMatchObject({ draft: '前 后', occurrences: [] }) + }) + + it('copy and cut expand a partial reference selection to its structured range', () => { + const { shell, textarea } = bench() + act(() => { + shell.setDraft('前 @w1 后') + shell.insertReference({ + source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1', + }, { start: 2, end: 5, draftRev: shell.snapshot.draftRev }) + }) + const setData = vi.fn() + textarea.setSelectionRange(3, 4) + fireEvent.copy(textarea, { clipboardData: { setData } }) + expect(setData).toHaveBeenCalledWith('text/plain', '@w1') + expect(shell.snapshot.draft).toBe('前 @会话一 后') + + textarea.setSelectionRange(3, 4) + fireEvent.cut(textarea, { clipboardData: { setData } }) + expect(setData).toHaveBeenLastCalledWith('text/plain', '@w1') + expect(shell.snapshot).toMatchObject({ draft: '前 后', occurrences: [] }) }) it('a lexicon-matched plain token renders the text-ref mark', () => { @@ -1187,6 +1241,15 @@ describe('decorations', () => { act(() => { shell.setDraft('use /fixture-dem now') }) expect(view.container.querySelector('[data-decoration="text-ref"]')).toBeNull() }) + + it('a directory completion renders a folder glyph without changing its plain text', () => { + const { view, shell } = bench() + act(() => { shell.setDraft('see @src/components/') }) + const mark = view.container.querySelector('[data-decoration="text-ref"]') + expect(mark?.textContent).toBe('@src/components/') + expect(mark?.querySelector('svg')).not.toBeNull() + expect(shell.snapshot.draft).toBe('see @src/components/') + }) }) describe('insertText (scoped event body)', () => { diff --git a/packages/client/ui-conversation/tests/input-machine.client.spec.ts b/packages/client/ui-conversation/tests/input-machine.client.spec.ts index 192d4c7efa..01df9588a8 100644 --- a/packages/client/ui-conversation/tests/input-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.client.spec.ts @@ -10,10 +10,12 @@ import { describe, expect, it } from 'vitest' import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { InputEffect, SubmitAttempt } from '../src/client/input/contract.ts' -import { InputMachine, PLACEHOLDER, projectClipboard } from '../src/client/input/machine.ts' +import { + InputMachine, PLACEHOLDER, projectClipboard, referenceDraftText, +} from '../src/client/input/machine.ts' import { deriveDecorations, scanTextRefs } from '../src/client/input/decorations.ts' -const P = PLACEHOLDER +const LEGACY_PLACEHOLDER = PLACEHOLDER function claimOf(name: string, hint?: string): CommandClaim { return { @@ -252,15 +254,22 @@ describe('input-machine: begin-command CAS', () => { }) describe('input-machine: insert-ref and the occurrence table', () => { - it('valid span becomes one placeholder + one occurrence with cached projections', () => { + it('valid span becomes one inline display range + one occurrence with cached projections', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'see @wor now' }) - const fx = m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 4, 8) }) + const reference = { ...refOf('worker-1', 'reference'), appearance: 'session' as const } + const fx = m.dispatch({ + type: 'insert-ref', + reference, + span: spanOf(m, 4, 8), + }) expect(fx).toEqual([]) - expect(m.state.draft).toBe(`see ${P} now`) + const displayText = referenceDraftText(reference) + expect(m.state.draft).toBe(`see ${displayText} now`) expect(m.state.occurrences).toEqual([{ - occurrenceId: 1, source: 'subagent', ref: 'worker-1', offset: 4, - label: 'worker-1', clipboardText: '/worker-1', + occurrenceId: 1, source: 'reference', ref: 'worker-1', offset: 4, + length: displayText.length, + label: 'worker-1', appearance: 'session', clipboardText: '/worker-1', }]) expect(m.state.phase).toBe('plain') }) @@ -269,12 +278,23 @@ describe('input-machine: insert-ref and the occurrence table', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '/alp' }) m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) }) - m.dispatch({ type: 'draft-changed', draft: `${P} and /alp`, editRange: { start: 1, end: 1, insertedLength: 9 } }) - m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 6, 10) }) - expect(m.state.draft).toBe(`${P} and ${P} `) + const displayText = referenceDraftText(refOf('alpha')) + const secondDraft = `${displayText} and /alp` + const secondStart = secondDraft.lastIndexOf('/alp') + m.dispatch({ + type: 'draft-changed', + draft: secondDraft, + editRange: { start: displayText.length, end: displayText.length + 1, insertedLength: ' and /alp'.length }, + }) + m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, secondStart, secondStart + 4) }) + expect(m.state.draft).toBe(`${displayText} and ${displayText} `) expect(m.state.occurrences.map(o => o.occurrenceId)).toEqual([1, 2]) - // Delete the first chip whole; the second survives with its own identity. - m.dispatch({ type: 'draft-changed', draft: ` and ${P} `, editRange: { start: 0, end: 1, insertedLength: 0 } }) + // Delete the first reference range whole; the second survives with its own identity. + m.dispatch({ + type: 'draft-changed', + draft: ` and ${displayText} `, + editRange: { start: 0, end: displayText.length, insertedLength: 0 }, + }) expect(m.state.occurrences).toEqual([expect.objectContaining({ occurrenceId: 2, offset: 5 })]) }) @@ -284,7 +304,7 @@ describe('input-machine: insert-ref and the occurrence table', () => { m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) }) m.dispatch({ type: 'draft-changed', draft: '/goal ask @wor' }) m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 10, 14) }) - expect(m.state.draft).toBe(`/goal ask ${P} `) + expect(m.state.draft).toBe(`/goal ask ${referenceDraftText(refOf('worker-1'))} `) expect(m.state.phase).toBe('claimed') expect(m.state.occurrences).toHaveLength(1) }) @@ -300,7 +320,7 @@ describe('input-machine: insert-ref and the occurrence table', () => { }) describe('input-machine: occurrence reconciliation on draft edits', () => { - /** Machine with one chip at offset 4 inside `see ${P} now`. */ + /** Machine with one reference range at offset 4 inside `see @worker-1 now`. */ function withChip(): InputMachine { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'see @wor now' }) @@ -308,40 +328,56 @@ describe('input-machine: occurrence reconciliation on draft edits', () => { return m } - it('an edit before the placeholder shifts the offset by the length delta (explicit editRange)', () => { + it('an edit before the reference shifts the offset by the length delta (explicit editRange)', () => { const m = withChip() - m.dispatch({ type: 'draft-changed', draft: `I see ${P} now`, editRange: { start: 0, end: 0, insertedLength: 2 } }) + m.dispatch({ type: 'draft-changed', draft: `I ${m.state.draft}`, editRange: { start: 0, end: 0, insertedLength: 2 } }) expect(m.state.occurrences[0]?.offset).toBe(6) - m.dispatch({ type: 'draft-changed', draft: `see ${P} now`, editRange: { start: 0, end: 2, insertedLength: 0 } }) + m.dispatch({ type: 'draft-changed', draft: m.state.draft.slice(2), editRange: { start: 0, end: 2, insertedLength: 0 } }) expect(m.state.occurrences[0]?.offset).toBe(4) }) - it('an edit after the placeholder leaves the offset alone', () => { + it('an edit after the reference leaves the offset alone', () => { const m = withChip() - m.dispatch({ type: 'draft-changed', draft: `see ${P} later`, editRange: { start: 6, end: 9, insertedLength: 5 } }) + const oldDraft = m.state.draft + const start = oldDraft.indexOf('now') + m.dispatch({ + type: 'draft-changed', + draft: oldDraft.replace('now', 'later'), + editRange: { start, end: start + 3, insertedLength: 5 }, + }) expect(m.state.occurrences[0]?.offset).toBe(4) }) - it('a deletion covering the placeholder removes the whole occurrence', () => { + it('a deletion covering the reference removes the whole occurrence', () => { const m = withChip() - m.dispatch({ type: 'draft-changed', draft: 'see now', editRange: { start: 4, end: 5, insertedLength: 0 } }) + const occurrence = m.state.occurrences[0]! + m.dispatch({ + type: 'draft-changed', + draft: m.state.draft.slice(0, occurrence.offset) + m.state.draft.slice(occurrence.offset + occurrence.length), + editRange: { start: occurrence.offset, end: occurrence.offset + occurrence.length, insertedLength: 0 }, + }) expect(m.state.occurrences).toEqual([]) expect(m.state.draft).toBe('see now') }) - it('a replacement spanning the placeholder removes the occurrence and keeps the replacement text', () => { + it('a replacement spanning the reference removes the occurrence and keeps the replacement text', () => { const m = withChip() - m.dispatch({ type: 'draft-changed', draft: 'see all of it now', editRange: { start: 4, end: 5, insertedLength: 9 } }) + const occurrence = m.state.occurrences[0]! + m.dispatch({ + type: 'draft-changed', + draft: 'see all of it now', + editRange: { start: occurrence.offset, end: occurrence.offset + occurrence.length, insertedLength: 9 }, + }) expect(m.state.occurrences).toEqual([]) }) it('without editRange the prefix/suffix diff scan recovers the edit (shift path)', () => { const m = withChip() - m.dispatch({ type: 'draft-changed', draft: `see there ${P} now` }) + m.dispatch({ type: 'draft-changed', draft: m.state.draft.replace('see ', 'see there ') }) expect(m.state.occurrences[0]?.offset).toBe(10) }) - it('without editRange the diff scan detects placeholder deletion', () => { + it('without editRange the diff scan detects reference deletion', () => { const m = withChip() m.dispatch({ type: 'draft-changed', draft: 'see now' }) expect(m.state.occurrences).toEqual([]) @@ -396,7 +432,7 @@ describe('input-machine: consume-token guards', () => { m.dispatch({ type: 'draft-changed', draft: '/model @wor' }) m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 7, 11) }) m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } }) - expect(m.state.draft).toBe(`${P} `) + expect(m.state.draft).toBe(`${referenceDraftText(refOf('w'))} `) expect(m.state.occurrences[0]?.offset).toBe(0) }) }) @@ -473,10 +509,10 @@ describe('input-machine: undo / redo', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '@wor' }) m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 0, 4) }) - m.dispatch({ type: 'draft-changed', draft: '', editRange: { start: 0, end: 1, insertedLength: 0 } }) + m.dispatch({ type: 'draft-changed', draft: '', editRange: { start: 0, end: m.state.draft.length, insertedLength: 0 } }) expect(m.state.occurrences).toEqual([]) m.dispatch({ type: 'undo' }) - expect(m.state.draft).toBe(`${P} `) + expect(m.state.draft).toBe(`${referenceDraftText(refOf('w'))} `) expect(m.state.occurrences).toHaveLength(1) }) @@ -519,7 +555,7 @@ describe('input-machine: paste plane', () => { it('pasted text is sanitized: raw U+FFFC never enters the draft as a fake chip', () => { const m = new InputMachine() - m.dispatch({ type: 'paste-begin', text: `x${P}y`, selection: { start: 0, end: 0 } }) + m.dispatch({ type: 'paste-begin', text: `x${LEGACY_PLACEHOLDER}y`, selection: { start: 0, end: 0 } }) expect(m.state.draft).toBe('xy') expect(m.state.occurrences).toEqual([]) }) @@ -531,9 +567,9 @@ describe('input-machine: paste plane', () => { type: 'paste-begin', text: '/alpha x', selection: { start: 3, end: 3 }, components: [{ start: 0, end: 6, reference: refOf('alpha') }], }) - expect(m.state.draft).toBe(`hi ${P} x`) + expect(m.state.draft).toBe(`hi ${referenceDraftText(refOf('alpha'))} x`) expect(m.state.occurrences).toEqual([expect.objectContaining({ ref: 'alpha', offset: 3 })]) - expect(m.state.paste?.insertedRange).toEqual({ start: 3, end: 6 }) + expect(m.state.paste?.insertedRange).toEqual({ start: 3, end: m.state.draft.length }) m.dispatch({ type: 'undo' }) expect(m.state).toMatchObject({ draft: 'hi ', occurrences: [] }) }) @@ -543,7 +579,7 @@ describe('input-machine: paste plane', () => { m.dispatch({ type: 'paste-begin', text: '/alpha rest', selection: { start: 0, end: 0 } }) expect(m.state.paste?.attemptId).toBe(1) m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') }) - expect(m.state.draft).toBe(`${P} rest`) + expect(m.state.draft).toBe(`${referenceDraftText(refOf('alpha'))} rest`) expect(m.state.occurrences).toHaveLength(1) m.dispatch({ type: 'undo' }) expect(m.state).toMatchObject({ draft: '/alpha rest', occurrences: [] }) @@ -555,11 +591,13 @@ describe('input-machine: paste plane', () => { const m = new InputMachine() m.dispatch({ type: 'paste-begin', text: '/alpha /beta', selection: { start: 0, end: 0 } }) m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') }) - expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 7 }) - m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 2, 7), reference: refOf('beta') }) - expect(m.state.draft).toBe(`${P} ${P} `) + const alpha = referenceDraftText(refOf('alpha')) + expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: alpha.length + 6 }) + const betaStart = m.state.draft.indexOf('/beta') + m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, betaStart, betaStart + 5), reference: refOf('beta') }) + expect(m.state.draft).toBe(`${alpha} ${referenceDraftText(refOf('beta'))} `) expect(m.state.occurrences.map(o => o.ref)).toEqual(['alpha', 'beta']) - expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 4 }) + expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: m.state.draft.length }) }) it('a stale span CAS drops one upgrade without ending the attempt', () => { @@ -608,8 +646,13 @@ describe('input-machine: set-invalid styling bits', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '/alp' }) m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) }) - m.dispatch({ type: 'draft-changed', draft: `${P} /bet`, editRange: { start: 1, end: 1, insertedLength: 5 } }) - m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, 2, 6) }) + const alpha = referenceDraftText(refOf('alpha')) + m.dispatch({ + type: 'draft-changed', + draft: `${alpha} /bet`, + editRange: { start: alpha.length + 1, end: alpha.length + 1, insertedLength: 5 }, + }) + m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, alpha.length + 1, alpha.length + 5) }) const rev = m.state.draftRev m.dispatch({ type: 'set-invalid', invalidIds: [1] }) expect(m.state.draftRev).toBe(rev) @@ -630,13 +673,20 @@ describe('input-machine: set-invalid styling bits', () => { }) describe('input-machine: projectClipboard', () => { - it('expands each placeholder to its occurrence clipboardText in draft order', () => { + it('expands each reference range to its occurrence clipboardText in draft order', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'use /alp' }) m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 4, 8) }) - m.dispatch({ type: 'draft-changed', draft: `use ${P} then /bet`, editRange: { start: 5, end: 5, insertedLength: 10 } }) - m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, 11, 15) }) - expect(m.state.draft).toBe(`use ${P} then ${P} `) + const alpha = referenceDraftText(refOf('alpha')) + const secondDraft = `use ${alpha} then /bet` + const secondStart = secondDraft.lastIndexOf('/bet') + m.dispatch({ + type: 'draft-changed', + draft: secondDraft, + editRange: { start: 4 + alpha.length + 1, end: 4 + alpha.length + 1, insertedLength: 'then /bet'.length }, + }) + m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, secondStart, secondStart + 4) }) + expect(m.state.draft).toBe(`use ${alpha} then ${referenceDraftText(refOf('beta'))} `) expect(projectClipboard(m.state)).toBe('use /alpha then /beta ') }) @@ -662,6 +712,13 @@ describe('decorations: scanTextRefs', () => { expect(scanTextRefs('/commit-helper', new Map())).toEqual([]) }) + it('recognizes directory paths independently of the dynamic lexicon', () => { + expect(scanTextRefs('open @src/components/ or @"docs/design notes/', new Map())).toEqual([ + { start: 5, end: 21, trigger: '@', appearance: 'folder' }, + { start: 25, end: 45, trigger: '@', appearance: 'folder' }, + ]) + }) + it('names off the lexicon do not match; triggers are routed per lexicon list', () => { expect(scanTextRefs('/unknown @commit-helper', LEX)).toEqual([]) }) @@ -690,11 +747,24 @@ describe('input-machine: decorations', () => { it('projects chips from the occurrence table with identity, offset, label, and invalid bit', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '/alp' }) - m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) }) + const reference = { ...refOf('alpha'), appearance: 'file' as const } + m.dispatch({ + type: 'insert-ref', + reference, + span: spanOf(m, 0, 4), + }) m.dispatch({ type: 'set-invalid', invalidIds: [1] }) expect(deriveDecorations(m.state)).toEqual({ token: null, - chips: [{ occurrenceId: 1, offset: 0, label: 'alpha', invalid: true }], + chips: [{ + occurrenceId: 1, + offset: 0, + length: referenceDraftText(reference).length, + text: referenceDraftText(reference), + label: 'alpha', + appearance: 'file', + invalid: true, + }], textRefs: [], hint: null, }) @@ -766,8 +836,17 @@ describe('input-machine: submitting transaction', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '@wor' }) m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 0, 4) }) - m.dispatch({ type: 'draft-changed', draft: `${P}/go`, editRange: { start: 1, end: 1, insertedLength: 3 } }) - m.dispatch({ type: 'draft-changed', draft: '/go', editRange: { start: 0, end: 1, insertedLength: 0 } }) + const refLength = referenceDraftText(refOf('worker-1')).length + m.dispatch({ + type: 'draft-changed', + draft: `${referenceDraftText(refOf('worker-1'))}/go`, + editRange: { start: refLength + 1, end: refLength + 1, insertedLength: 3 }, + }) + m.dispatch({ + type: 'draft-changed', + draft: '/go', + editRange: { start: 0, end: refLength + 1, insertedLength: 0 }, + }) m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) }) m.dispatch({ type: 'draft-changed', draft: '/goal go' }) const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts index 70adc2ab19..5d7345f13b 100644 --- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -8,7 +8,6 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { InputTriggerController, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { SessionInputShell } from '../src/client/input/facade.ts' import type { DraftAttachmentId } from '../src/client/input/contract.ts' -import { PLACEHOLDER } from '../src/client/input/machine.ts' const mention = '@[Research](dsh-session:InNvdXJjZSI)' const commandImages = { @@ -22,7 +21,7 @@ function chip(shell: SessionInputShell): void { const accepted = shell.insertReference({ source: 'reference', ref: mention, - label: '@Research', + label: 'Research', clipboardText: mention, }, { start: 0, @@ -55,8 +54,8 @@ describe('reference submission', () => { }) chip(shell) expect(shell.snapshot).toMatchObject({ - draft: `${PLACEHOLDER} `, - occurrences: [{ source: 'reference', ref: mention, label: '@Research' }], + draft: '@Research ', + occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: 9 }], }) shell.submit('queue') @@ -66,8 +65,8 @@ describe('reference submission', () => { }) expect(sink).toHaveBeenNthCalledWith(1, mention, [], 'queue', expect.any(AbortSignal)) expect(shell.snapshot).toMatchObject({ - draft: `${PLACEHOLDER} `, - occurrences: [{ source: 'reference', ref: mention, label: '@Research' }], + draft: '@Research ', + occurrences: [{ source: 'reference', ref: mention, label: 'Research', offset: 0, length: 9 }], }) expect(shell.notices.getSnapshot()).toMatchObject({ level: 'error', @@ -101,7 +100,7 @@ describe('reference submission', () => { expect(shell.snapshot.phase).toBe('plain') }) expect(sink).not.toHaveBeenCalled() - expect(shell.snapshot.draft).toBe(`${PLACEHOLDER} `) + expect(shell.snapshot.draft).toBe('@Research ') expect(shell.snapshot.occurrences).toHaveLength(1) expect(shell.notices.getSnapshot()).toMatchObject({ level: 'error', diff --git a/packages/client/ui-input-trigger/README.i18n.yaml b/packages/client/ui-input-trigger/README.i18n.yaml index 12966cb57e..cfae1ce880 100644 --- a/packages/client/ui-input-trigger/README.i18n.yaml +++ b/packages/client/ui-input-trigger/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-input-trigger/README.md -README.md: 917a0be02d48260704be8dc2c2f70504138c1957 -README.zh.md: cf33c51c40edd53a492416b9654cb9e69680aebd +README.md: 248fd14e5c441ebb3ebf7806919d30a5f71a78e4 +README.zh.md: a04ce3efba5d3fa32e895429fb119d9b29983fb0 diff --git a/packages/client/ui-input-trigger/README.md b/packages/client/ui-input-trigger/README.md index 917a0be02d..248fd14e5c 100644 --- a/packages/client/ui-input-trigger/README.md +++ b/packages/client/ui-input-trigger/README.md @@ -4,9 +4,9 @@ English | [中文](README.zh.md) Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.inputTriggers` owns the source roster and resolves one `InputTriggerController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. The same controller exposes `toggleSource` for a chrome launcher to open exactly one registered source over a synthetic selection span; the resulting candidates still use the ordinary menu, keyboard arbitration, pick callback, and scoped input mutations. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. Enter adjudication also carries a `SubmitEnvelope` (the composer's image-attachment count) so a source can refuse a submission it cannot consume whole; a `CommandClaim` declares `images: true` when its command accepts composer images, and its `submit` then receives the serialized payloads as a third argument. -Layering: `src/core/` is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract; changes require main-thread arbitration. +Layering: `src/core/` is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `ReferenceInsert.appearance` optionally identifies a `session`, `file`, or `folder` display without changing its serialized `ref`; the consuming composer owns the glyph and color. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract; changes require main-thread arbitration. -MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Typed triggers seed every source registered for that trigger; a programmatic launcher seeds only its requested source and publishes the source name through the controller's `launcher` snapshot store until the menu closes or typed tracking resumes. Groups sort by the optional `InputTriggerSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `inputTriggers.menu` locale namespace (an unknown source shows its raw name); the list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-input-trigger) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`. +MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Typed triggers seed every source registered for that trigger; a programmatic launcher seeds only its requested source and publishes the source name through the controller's `launcher` snapshot store until the menu closes or typed tracking resumes. Groups sort by the optional `InputTriggerSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `inputTriggers.menu` locale namespace (an unknown source shows its raw name). `showGroupTitle: false` suppresses that row through pending and ready states, while a ready group whose candidates declare sections uses those section rows in place of the source title. The list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-input-trigger) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`. The `/client` exports are the plugin body (`apply`/`inject`), `InputTriggerService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it. diff --git a/packages/client/ui-input-trigger/README.zh.md b/packages/client/ui-input-trigger/README.zh.md index cf33c51c40..a04ce3efba 100644 --- a/packages/client/ui-input-trigger/README.zh.md +++ b/packages/client/ui-input-trigger/README.zh.md @@ -4,9 +4,9 @@ 输入触发流水线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.inputTriggers` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `InputTriggerController`;对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。同一个 controller 还暴露 `toggleSource`,供 chrome launcher 在一段合成 selection span 上只打开一个已注册 source;所得候选仍走通常的菜单、键盘仲裁、pick callback 与 scoped 输入改写。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent(智能体)支撑,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 创建时 roster 中已有的 source 会在 controller 构造期间预热,晚于此注册的 source 由注册动作本身预热进每个仍存续的 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。回车裁决还携带 `SubmitEnvelope`(composer 的图片附件数量),使 source 能拒绝它无法整体消费的提交;命令接受 composer 图片时,`CommandClaim` 声明 `images: true`,其 `submit` 随之以第三个参数收到序列化后的图片载荷。 -分层:`src/core/` 是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包约定;变更需经主线程仲裁。 +分层:`src/core/` 是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`ReferenceInsert.appearance` 可以把显示类型标为 `session`、`file` 或 `folder`,且不会改变其序列化 `ref`;图标与颜色由消费它的输入框负责。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包约定;变更需经主线程仲裁。 -MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。键入式 trigger 会 seed 为该 trigger 注册的所有 source;程序化 launcher 只 seed 所请求的 source,并在菜单关闭或重新开始键入式 tracking 前,通过 controller 的 `launcher` 快照 store 发布该 source 名称。分组按可选的 `InputTriggerSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `inputTriggers.menu` locale 命名空间本地化(未知 source 显示其原名);列表高度受限于 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-input-trigger)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。 +MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。键入式 trigger 会 seed 为该 trigger 注册的所有 source;程序化 launcher 只 seed 所请求的 source,并在菜单关闭或重新开始键入式 tracking 前,通过 controller 的 `launcher` 快照 store 发布该 source 名称。分组按可选的 `InputTriggerSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `inputTriggers.menu` locale 命名空间本地化(未知 source 显示其原名)。`showGroupTitle: false` 会在 pending 与 ready 状态全程隐藏该行,ready 且候选项声明了 section 的组则以这些 section 标题行取代 source 标题。列表高度受限于 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-input-trigger)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。 `/client` 导出接口是插件主体(`apply`/`inject`)、`InputTriggerService`、`MenuViewInjected` 与约定类型。MenuView 本身是内部实现——slot 注册以闭包持有它。 diff --git a/packages/client/ui-input-trigger/src/client/MenuView.tsx b/packages/client/ui-input-trigger/src/client/MenuView.tsx index f0edeed1cc..fbe1bf72b8 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.tsx +++ b/packages/client/ui-input-trigger/src/client/MenuView.tsx @@ -81,7 +81,9 @@ export function MenuView({ menu, onPick, onDismiss, t }: MenuViewProps) { {/* Source names key the dictionary open-endedly: the lookup chain returns an unknown key verbatim, so an unregistered source shows its raw name — hence the cast past the typed key union. */} -
{t(group.source as MenuKey)}
+ {group.showGroupTitle === false || group.items.some(item => item.section !== undefined) + ? null + :
{t(group.source as MenuKey)}
} {group.status === 'pending' ?
{t('loading')}
: group.items.map((item, index) => { diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 32c9a3cc68..68bb5a15cc 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -112,7 +112,7 @@ export class InputTriggerController { return } if (launched || !prev.open || prev.hit === null || prev.hit.trigger !== hit.trigger) { - this.menu.set(seedGroups(this.menu.getSnapshot(), roster.map(s => s.name))) + this.menu.set(seedGroups(this.menu.getSnapshot(), roster)) } this.reduce({ type: 'hit', hit }) this.fetchCandidates(hit, roster) @@ -140,7 +140,7 @@ export class InputTriggerController { this.stopFetch() this.hit = hit this.launcher.set(source) - this.menu.set(seedGroups(this.menu.getSnapshot(), [source])) + this.menu.set(seedGroups(this.menu.getSnapshot(), [match])) this.reduce({ type: 'hit', hit }) this.fetchCandidates(hit, [match]) } diff --git a/packages/client/ui-input-trigger/src/core/contract.ts b/packages/client/ui-input-trigger/src/core/contract.ts index f936e1c0eb..9c11c01324 100644 --- a/packages/client/ui-input-trigger/src/core/contract.ts +++ b/packages/client/ui-input-trigger/src/core/contract.ts @@ -36,6 +36,8 @@ export interface MenuState { readonly generation: number readonly groups: readonly { readonly source: string + /** False when candidate section rows own all visible group labeling. */ + readonly showGroupTitle?: boolean readonly status: 'pending' | 'ready' readonly items: readonly InputTriggerCandidate[] }[] diff --git a/packages/client/ui-input-trigger/src/core/menu.ts b/packages/client/ui-input-trigger/src/core/menu.ts index 7174fef303..3fe40ee18c 100644 --- a/packages/client/ui-input-trigger/src/core/menu.ts +++ b/packages/client/ui-input-trigger/src/core/menu.ts @@ -10,7 +10,7 @@ * while open (query refinement) resets the existing groups to pending under * a new generation. Auto-close and explicit close drop the groups. */ -import type { InputTriggerCandidate } from '../types.ts' +import type { InputTriggerCandidate, InputTriggerSource } from '../types.ts' import type { ExactMatch, MenuReduce, MenuState } from './contract.ts' /** Closed rest state with generation 0; store initializer and test seed. */ @@ -21,11 +21,23 @@ export const MENU_CLOSED: MenuState = { open: false, hit: null, generation: 0, g * Shell-side step before dispatching `hit` on a fresh menu open. * * @param state - Current menu state. - * @param sources - Source names registered for the hit trigger, menu order. + * @param sources - Sources registered for the hit trigger, in menu order. * @returns State carrying the new pending roster; highlight cleared. */ -export function seedGroups(state: MenuState, sources: readonly string[]): MenuState { - return { ...state, groups: sources.map(source => ({ source, status: 'pending', items: [] })), highlight: null } +export function seedGroups( + state: MenuState, + sources: readonly Pick[], +): MenuState { + return { + ...state, + groups: sources.map(source => ({ + source: source.name, + ...(source.showGroupTitle === false ? { showGroupTitle: false } : {}), + status: 'pending', + items: [], + })), + highlight: null, + } } /** Close, preserving the generation so in-flight settlements stay droppable. */ @@ -83,7 +95,7 @@ export const menuReduce: MenuReduce = (state, ev) => { open: true, hit: ev.hit, generation: state.generation + 1, - groups: state.groups.map(g => ({ source: g.source, status: 'pending', items: [] })), + groups: state.groups.map(g => ({ ...g, status: 'pending', items: [] })), highlight: null, } } @@ -93,7 +105,7 @@ export const menuReduce: MenuReduce = (state, ev) => { if (idx < 0) return state const items: readonly InputTriggerCandidate[] = ev.items ?? [] const groups = state.groups.map((g, i) => - i === idx ? { source: g.source, status: 'ready' as const, items } : g) + i === idx ? { ...g, status: 'ready' as const, items } : g) if (allReadyEmpty(groups)) return closed(state) const highlight = validHighlight(state.highlight, groups) ?? firstHighlight(groups) return { ...state, groups, highlight } diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index 6d0edcb645..5f2dd6dfe6 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -35,7 +35,7 @@ export interface InputTriggerCandidate { readonly description?: string readonly icon?: string readonly hint?: string - /** Optional visual group heading shared by adjacent candidates. */ + /** Optional visual heading shared by adjacent candidates; sectioned groups omit their source-title row. */ readonly section?: string /** Opaque source-owned pick payload. */ readonly value?: string @@ -82,15 +82,17 @@ export interface CommandClaim { } /** - * Inline reference insertion. The draft holds one U+FFFC placeholder per - * occurrence; the owner supplies both user-facing projections at insert time + * Inline reference insertion. The draft holds the complete display text while + * the occurrence retains its range; the owner supplies both user-facing projections at insert time * (the model representation is serialized on submit via the source codec). */ export interface ReferenceInsert { readonly source: string readonly ref: string - /** Chip display label (fallback-cached on the occurrence). */ + /** Inline display label (fallback-cached on the occurrence). */ readonly label: string + /** Optional domain glyph shown beside the label. */ + readonly appearance?: 'session' | 'file' | 'folder' /** Clipboard / persistence projection, e.g. `/name` (never the model form). */ readonly clipboardText: string } @@ -177,6 +179,8 @@ export interface InputTriggerSource { readonly name: string /** Menu group display order (lower = higher in the list; default 0). */ readonly order?: number + /** Whether the menu renders the source-title row; defaults to true. */ + readonly showGroupTitle?: boolean candidates(session: ClientSessionContext, req: CandidateRequest): Promise /** Every pick lands here; claim/insert outcomes are executed by the pipeline via the scoped input events. */ onPick(pick: InputTriggerPick): PickOutcome diff --git a/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts b/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts index 52872848d9..9ec2d30e91 100644 --- a/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts @@ -14,7 +14,7 @@ const hit = (query = ''): TriggerHit => ({ /** Seed sources onto the closed state and open a first generation. */ function open(sources: readonly string[], h: TriggerHit = hit()): MenuState { - return menuReduce(seedGroups(MENU_CLOSED, sources), { type: 'hit', hit: h }) + return menuReduce(seedGroups(MENU_CLOSED, sources.map(name => ({ name }))), { type: 'hit', hit: h }) } const item = (name: string) => ({ name }) @@ -40,6 +40,14 @@ describe('menuReduce hit', () => { expect(s.highlight).toBeNull() }) + it('preserves a hidden group title through re-hit and settlement', () => { + let s = menuReduce(seedGroups(MENU_CLOSED, [{ name: 'reference', showGroupTitle: false }]), { type: 'hit', hit: hit() }) + expect(s.groups[0]).toMatchObject({ source: 'reference', showGroupTitle: false, status: 'pending' }) + s = menuReduce(s, { type: 'hit', hit: hit('r') }) + s = menuReduce(s, { type: 'source-settled', generation: 2, source: 'reference', items: [item('README.md')] }) + expect(s.groups[0]).toMatchObject({ source: 'reference', showGroupTitle: false, status: 'ready' }) + }) + it('null hit closes; closing an already-closed state is a no-op reference', () => { const s = open(['command']) const c = menuReduce(s, { type: 'hit', hit: null }) diff --git a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx index 399ad7f90e..5412d2d7e7 100644 --- a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx +++ b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx @@ -88,6 +88,15 @@ describe('MenuView', () => { expect(screen.queryByText('正在加载…')).not.toBeNull() }) + it('keeps an opted-out source title hidden while its candidates are pending', () => { + mount(openState({ + groups: [{ source: 'reference', showGroupTitle: false, status: 'pending', items: [] }], + highlight: null, + })) + expect(screen.queryByText('reference')).toBeNull() + expect(screen.getByText('正在加载…')).toBeTruthy() + }) + it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => { const { view } = mount(openState({ groups: [ @@ -113,6 +122,7 @@ describe('MenuView', () => { }], highlight: { source: 'reference', index: 0 }, })) + expect(screen.queryByText('reference')).toBeNull() expect(screen.getAllByText('文件与文件夹')).toHaveLength(1) expect(screen.getAllByText('Session 对话')).toHaveLength(1) const options = screen.getAllByRole('option') diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index dca9cab05f..a03839d7bd 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -249,6 +249,16 @@ describe('track', () => { expect(state.highlight).toEqual({ source: 'command', index: 0 }) }) + it('carries source-title visibility from the roster through candidate settlement', async () => { + const reference = deferredSource('@', 'reference', { showGroupTitle: false }) + const { controller } = controllerBench([reference.source]) + controller.track('@r', 2, { tier: 'plain' }, 1) + expect(controller.menu.getSnapshot().groups[0]).toMatchObject({ showGroupTitle: false, status: 'pending' }) + reference.pending[0]!.resolve([{ name: 'README.md', section: '文件与文件夹' }]) + await tick() + expect(controller.menu.getSnapshot().groups[0]).toMatchObject({ showGroupTitle: false, status: 'ready' }) + }) + it('stamps the caller draftRev into the hit span', () => { const cmd = deferredSource('/', 'command') const { controller } = controllerBench([cmd.source]) diff --git a/packages/client/ui-reference/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml index a2ccb405e1..a87d1d385b 100644 --- a/packages/client/ui-reference/README.i18n.yaml +++ b/packages/client/ui-reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-reference/README.md -README.md: 12a8e69624c0d7fe28c10ae708466fdda4a8480d -README.zh.md: bfaee51af3947fe794afab3a99df194c543b0082 +README.md: 65387985b13a31c94d440c59f407811903645eac +README.zh.md: 0d57694122313fe883886c7d0354d13874e7b046 diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md index 12a8e69624..65387985b1 100644 --- a/packages/client/ui-reference/README.md +++ b/packages/client/ui-reference/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -Unified Web `@file` and `@session` source. The browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together for an unquoted token, deterministically orders files before sessions with locale-registered folder/file/session labels, renders the rows under non-selectable file and session section headings, and degrades either failed candidate domain independently. An open `@"…` token searches files only. +Unified Web `@file` and `@session` source. The browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together for an unquoted token, deterministically orders files before sessions with locale-registered folder/file/session labels, and renders the rows under non-selectable file and session section headings without a redundant raw `reference` source title. Either failed candidate domain degrades independently. An open `@"…` token searches files only. -File picks insert the natural text defined by the shared `@path` grammar. A file closes completion and adds a trailing space; a directory keeps the menu active at its trailing slash so the user can descend another level. Paths containing whitespace use `@"path with spaces"`, and a quote the user opened explicitly remains quoted. +File picks preserve the natural text defined by the shared `@path` grammar as their hidden serialized and clipboard form. A file closes completion as an atomic inline reference displayed with a file glyph, business-color filename, and no capsule. A directory remains plain editable path text with a folder glyph and keeps the menu active at its trailing slash so the user can descend another level. Paths containing whitespace use `@"path with spaces"`, and a quote the user opened explicitly remains quoted. -Session picks insert an atomic composer chip whose hidden `ref` and clipboard representation are the canonical `@[label](dsh-session:…)` mention returned by the Host. The visible chip uses `@label`; serialization never reconstructs identity from that label. Ordinary send carries the canonical mention through `session.prompt`; the session-reference service validates it and captures model context at `agent/pre-step`. +Session picks insert an atomic inline reference whose hidden `ref` and clipboard representation are the canonical `@[label](dsh-session:…)` mention returned by the Host. Its visible form is a chat-bubble glyph plus the business-color session title, without a capsule; serialization never reconstructs identity from that title. Ordinary send carries the canonical mention through `session.prompt`; the session-reference service validates it and captures model context at `agent/pre-step`. The `/client` export is the plugin body (`apply`/`inject`) only; candidate encoding stays internal to the registration effect. @@ -16,7 +16,7 @@ Indirectly, through `@deepseek-ai/dsh-file-reference-local` for path guidance an #### KV Cache effect -Candidate browsing has no model effect. A selected file or session changes only the new user-message suffix and any Host-prepared session-reference prefix attached to that message; earlier target history remains unchanged. +Candidate browsing has no model effect. A selected file or session changes only the new user-message suffix and any Host-prepared session-reference context that follows that message; earlier target history remains unchanged. ## Known Limitations and Deferred Work diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md index bfaee51af3..0d57694122 100644 --- a/packages/client/ui-reference/README.zh.md +++ b/packages/client/ui-reference/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -统一的 Web `@file` 与 `@session` source。对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 和 `sessionReferenceResolver/candidates` Remote 调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签;各行分别渲染在不可选择的文件与会话分组标题下,任一候选领域的失败都会独立降级。尚未闭合的 `@"…` token 只搜索文件。 +统一的 Web `@file` 与 `@session` source。对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 和 `sessionReferenceResolver/candidates` Remote 调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签;各行分别渲染在不可选择的文件与会话分组标题下,不显示重复的原始 `reference` source 标题。任一候选领域的失败都会独立降级。尚未闭合的 `@"…` token 只搜索文件。 -选择文件会插入共享 `@path` 语法所定义的自然文本。文件会关闭补全并追加一个尾随空格;目录则让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。包含空白的路径使用 `@"path with spaces"`,用户显式打开的引号会继续保留。 +选择文件会把共享 `@path` 语法所定义的自然文本保留为隐藏的序列化与剪贴板形式。文件会关闭补全,并显示为文件图标加业务色文件名、无胶囊容器的原子行内引用。目录仍是带文件夹图标的可编辑路径纯文本,并让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。包含空白的路径使用 `@"path with spaces"`,用户显式打开的引号会继续保留。 -选择会话会插入一个原子的输入框 chip,其隐藏 `ref` 与剪贴板表示均为宿主返回的规范 `@[label](dsh-session:…)` mention。可见 chip 使用 `@label`;序列化永远不会根据该标签重建身份。普通发送会通过 `session.prompt` 携带规范 mention,session-reference 服务会在 `agent/pre-step` 校验它并捕获模型上下文。 +选择会话会插入一个原子的行内引用,其隐藏 `ref` 与剪贴板表示均为宿主返回的规范 `@[label](dsh-session:…)` mention。可见形式为聊天气泡图标加业务色会话标题,不使用胶囊容器;序列化永远不会根据该标题重建身份。普通发送会通过 `session.prompt` 携带规范 mention,session-reference 服务会在 `agent/pre-step` 校验它并捕获模型上下文。 `/client` 只导出插件主体(`apply`/`inject`);候选编码保留在注册 effect 内部。 @@ -16,7 +16,7 @@ #### KV 缓存影响 -浏览候选项不会影响模型。选择文件或会话只会改变新用户消息的后缀,以及附加到该消息、由宿主准备的会话引用前缀;目标会话更早的历史保持不变。 +浏览候选项不会影响模型。选择文件或会话只会改变新用户消息的后缀,以及紧随该消息、由宿主准备的会话引用上下文;目标会话更早的历史保持不变。 ## 已知限制与暂缓事项 diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index fb356e73a8..312db17222 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -33,6 +33,7 @@ export function apply(ctx: ClientContext): void { const source: InputTriggerSource = { trigger: '@', name: 'reference', + showGroupTitle: false, async candidates(session: ClientSessionContext, { query, quoted, signal }) { const files = ctx.remote.fileReferences.list(session.sessionId, query, signal).then( result => result.ok ? result.value : [], @@ -54,17 +55,25 @@ export function apply(ctx: ClientContext): void { onPick({ candidate }) { const value = parseCandidate(candidate.value) if (value?.kind === 'file') { - return { - text: value.mention + (value.fileKind === 'file' ? ' ' : ''), - ...value.fileKind === 'directory' ? { continue: true } : {}, - } + return value.fileKind === 'directory' + ? { text: value.mention, continue: true } + : { + insert: { + source: 'reference', + ref: value.mention, + label: value.label, + appearance: 'file', + clipboardText: value.mention, + }, + } } if (value?.kind === 'session') { return { insert: { source: 'reference', ref: value.mention, - label: `@${value.label}`, + label: value.label, + appearance: 'session', clipboardText: value.mention, }, } @@ -83,7 +92,7 @@ export function apply(ctx: ClientContext): void { type Translate = (key: ReferenceKey) => string type ReferenceCandidateValue = - | { kind: 'file'; fileKind: FileReferenceCandidate['kind']; mention: string } + | { kind: 'file'; fileKind: FileReferenceCandidate['kind']; label: string; mention: string } | { kind: 'session'; label: string; mention: string } function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean, t: Translate) { @@ -94,6 +103,7 @@ function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean const value: ReferenceCandidateValue = { kind: 'file', fileKind: candidate.kind, + label: name, mention, } return [{ diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index d75d68d50e..3e813b9e11 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -107,7 +107,7 @@ describe('apply', () => { ctx.provide('locale', new LocaleRuntime(ctx)) const ownFiber = ctx.plugin({ inject: [...inject], apply }) await ownFiber.await() - expect(registered).toMatchObject({ trigger: '@', name: 'reference' }) + expect(registered).toMatchObject({ trigger: '@', name: 'reference', showGroupTitle: false }) await ownFiber.dispose() expect(registered).toBeUndefined() await fiber.dispose() @@ -210,7 +210,15 @@ describe('candidates', () => { position: 'inline', via: 'menu', span: { start: 0, end: 6, draftRev: 1 }, - })).toEqual({ text: '@"README.md" ' }) + })).toEqual({ + insert: { + source: 'reference', + ref: '@"README.md"', + label: 'README.md', + appearance: 'file', + clipboardText: '@"README.md"', + }, + }) expect(sessions).not.toHaveBeenCalled() await expect(source.candidates(session, request('research'))).resolves.toEqual([ expect.objectContaining({ name: 'Session · Research' }), @@ -276,11 +284,19 @@ describe('pick and codec', () => { span: { start: 0, end: 1, draftRev: 1 }, }) - it('inserts files as path text, keeping directory completion open', async () => { + it('inserts files as atomic icon labels while keeping directory completion open', async () => { const { source } = await bench() const [directory, file] = await source.candidates(session, request('')) expect(pick(source, directory!)).toEqual({ text: '@src/', continue: true }) - expect(pick(source, file!)).toEqual({ text: '@"docs/a b.md" ' }) + expect(pick(source, file!)).toEqual({ + insert: { + source: 'reference', + ref: '@"docs/a b.md"', + label: 'a b.md', + appearance: 'file', + clipboardText: '@"docs/a b.md"', + }, + }) const [quotedDirectory] = await source.candidates(session, request('', { quoted: true })) expect(pick(source, quotedDirectory!)).toEqual({ text: '@"src/', continue: true }) }) @@ -294,7 +310,8 @@ describe('pick and codec', () => { insert: { source: 'reference', ref: mention, - label: '@Research', + label: 'Research', + appearance: 'session', clipboardText: mention, }, }) diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml index 489b884d57..1996b8d0bb 100644 --- a/packages/context/session-reference/README.i18n.yaml +++ b/packages/context/session-reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/session-reference/README.md -README.md: 13dfcf2fa99a118efa03f4f183b6b426bf704c2b -README.zh.md: a81f8c62942ca9a9b3b10c1c109c1fb227971ec0 +README.md: 71ed6891a3a0bee480b2064d844df8758349ee38 +README.zh.md: 5a761115105914a47c1f3d2673df4b2f3e3e76da diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index 13dfcf2fa9..71ed6891a3 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -14,7 +14,7 @@ English | [中文](README.zh.md) Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source when the target message reaches `agent/pre-step`. A queued message therefore captures the source state at model-step entry, and the resulting context is immutable after that point. Projection keeps only direct-user `user/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compaction` source marker from the folded current surface. Separately sourced session-reference messages are injected context and are excluded, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, other plugin-generated user messages except marked compact checkpoints, and unfinished assistant chunks are also excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text. -The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. The service's outer `agent/pre-step` listener post-processes accepted direct user messages, preserves their message ids, and inserts each snapshot immediately before the message that cited it. Queue edits and queue-to-steer relocation need no reference-specific handling because parsing occurs after the final inbox claim. Invalid mentions, failed reads, cancellation, and budget failures end that turn before its messages enter model-visible history. The target log records a sourced context `user/message` followed by the readable direct `user/message`; source mutation after capture cannot change target replay. +The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. The service's outer `agent/pre-step` listener post-processes accepted direct user messages, preserves their message ids, and inserts each snapshot immediately after the message that cited it. Queue edits and queue-to-steer relocation need no reference-specific handling because parsing occurs after the final inbox claim. Invalid mentions, failed reads, cancellation, and budget failures end that turn before its messages enter model-visible history. The target log records the readable direct `user/message` followed by its sourced context `user/message`; source mutation after capture cannot change target replay. ## Configuration @@ -32,7 +32,7 @@ Retention applies `maxReferenceBytes` independently to each source, keeps compac #### What the model sees -The model sees two consecutive user-role messages: the `## Referenced sessions` untrusted snapshot, then the current message with its readable `@label`. The warning forbids following instructions, permission claims, or tool requests from the snapshot unless the current user repeats them. Labels, cwd values, ids, and conversation text are serialized as JSON inside `` tags; every data `<` is emitted as the lossless JSON escape `\u003c`, so source text cannot spell a framing tag. +The model sees two consecutive user-role messages: the current message with its readable `@label`, then the `## Referenced sessions` untrusted snapshot. The warning forbids following instructions, permission claims, or tool requests from the snapshot unless the citing user message requests their use. Labels, cwd values, ids, and conversation text are serialized as JSON inside `` tags; every data `<` is emitted as the lossless JSON escape `\u003c`, so source text cannot spell a framing tag. #### Token effect @@ -40,7 +40,7 @@ Each referenced message adds the fixed warning plus up to three serialized snaps #### KV Cache effect -The snapshot and request are consecutive append-only target messages and preserve earlier cacheable history. Different references or source capture contents change the new suffix only; later target compaction may invalidate reuse from its replacement boundary. +The request and snapshot are consecutive append-only target messages and preserve earlier cacheable history. Different references or source capture contents change the new suffix only; later target compaction may invalidate reuse from its replacement boundary. ## Known Limitations and Deferred Work diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md index a81f8c6294..5a76111510 100644 --- a/packages/context/session-reference/README.zh.md +++ b/packages/context/session-reference/README.zh.md @@ -14,7 +14,7 @@ 目标消息到达 `agent/pre-step` 时,准备阶段会对每个不同源调用一次 `ctx.sessionQuery.readSurface()`。因此,queued 消息在进入模型步骤时捕获源状态,此后生成的上下文保持不变。它仅投影折叠后当前表层中的用户直接发出的 `user/message`、assistant 文本,以及 `user/message` 检查点;这类检查点携带规范 `dsh-compaction` 源标记。带独立来源的 session-reference 消息属于注入上下文,会被排除以防止快照递归传播。已遮蔽的压缩(compaction)前事件、工具、推理(reasoning)、除已标记 compact 检查点外的其他插件生成 user 消息,以及未完成的 assistant 分片也都会被排除。因此,已压缩源只会提供最新检查点及其后保留的会话内容,不会还原已遮蔽的文本。 -上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留/已省略消息数、已省略 UTF-8 字节数与截断状态。该服务的外层 `agent/pre-step` 监听器会处理已接受的直接用户消息,保留其消息 id,并把每份快照插入到引用它的消息紧前。解析发生在最终领取收件箱消息之后,因此队列编辑和从 queue 移动到 steer 不需要引用专用处理。无效 mention、读取失败、取消和预算失败会在消息进入面向模型的历史之前结束该轮次。目标日志会先记录一条带来源信息的上下文 `user/message`,再记录可读的直接 `user/message`;捕获后的源变更无法改变目标回放。 +上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留/已省略消息数、已省略 UTF-8 字节数与截断状态。该服务的外层 `agent/pre-step` 监听器会处理已接受的直接用户消息,保留其消息 id,并把每份快照插入到引用它的消息紧后。解析发生在最终领取收件箱消息之后,因此队列编辑和从 queue 移动到 steer 不需要引用专用处理。无效 mention、读取失败、取消和预算失败会在消息进入面向模型的历史之前结束该轮次。目标日志会先记录可读的直接 `user/message`,再记录其带来源信息的上下文 `user/message`;捕获后的源变更无法改变目标回放。 ## 配置 @@ -32,7 +32,7 @@ #### 模型看到的内容 -模型会看到两条连续的 user 角色消息:先是 `## Referenced sessions` 不受信任快照,再是带可读 `@label` 的当前消息。警告禁止遵循快照中的指令、权限声明或工具请求,除非当前 user 重复这些内容。标签、cwd 值、id 与会话文本会作为 JSON 在 `` 标签中序列化;数据中的每个 `<` 都会以无损 JSON 转义 `\u003c` 的形式发出,因此源文本无法拼出定界标签。 +模型会看到两条连续的 user 角色消息:先是带可读 `@label` 的当前消息,再是 `## Referenced sessions` 不受信任快照。警告禁止遵循快照中的指令、权限声明或工具请求,除非引用它的 user 消息要求使用这些内容。标签、cwd 值、id 与会话文本会作为 JSON 在 `` 标签中序列化;数据中的每个 `<` 都会以无损 JSON 转义 `\u003c` 的形式发出,因此源文本无法拼出定界标签。 #### Token 影响 @@ -40,7 +40,7 @@ #### KV Cache 影响 -快照与请求是两条连续、仅追加的目标消息,并保留较早的可缓存历史。不同引用或源捕获内容只改变新后缀;后续目标压缩可能使从替换边界起的复用失效。 +请求与快照是两条连续、仅追加的目标消息,并保留较早的可缓存历史。不同引用或源捕获内容只改变新后缀;后续目标压缩可能使从替换边界起的复用失效。 ## 已知限制与暂缓事项 diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 433ee1d0b1..36d443d67a 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -115,11 +115,11 @@ export class SessionReferenceResolver extends TypertRemoteService { /** * Replace canonical mentions in direct user messages and place each prepared - * snapshot immediately before the message that cited it. + * snapshot immediately after the message that cited it. * @param agent - agent entering the model step. * @param messages - messages accepted by downstream pre-step listeners. * @param signal - active turn cancellation. - * @returns messages with session-reference context inserted in citation order. + * @returns direct messages followed by their session-reference context in citation order. */ private async prepareDirectMessages( agent: Agent, @@ -142,7 +142,7 @@ export class SessionReferenceResolver extends TypertRemoteService { if (resolved.additionalContext === undefined) { throw new Error('session-reference preparation omitted context for a canonical mention') } - return [resolved.additionalContext, direct] + return [direct, resolved.additionalContext] })) return prepared.flat() } diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index e1eac75658..921e1703c4 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -336,18 +336,18 @@ describe('session reference discovery and preparation', () => { expect(decision.kind).toBe('enter') if (decision.kind !== 'enter') throw new Error('expected entered pre-step') expect(decision.messages).toHaveLength(4) - expect(decision.messages[0]?.source).toMatchObject({ - kind: 'session-reference', - references: [{ sessionId: source.id, label: 'Research' }], - }) - expect(decision.messages[1]).toMatchObject({ + expect(decision.messages[0]).toMatchObject({ id: direct.id, content: [ { type: 'text', text: 'compare @Research now' }, { type: 'reasoning', text: 'preserve this non-text block' }, ], }) - expect(decision.messages[1]).not.toBe(direct) + expect(decision.messages[0]).not.toBe(direct) + expect(decision.messages[1]?.source).toMatchObject({ + kind: 'session-reference', + references: [{ sessionId: source.id, label: 'Research' }], + }) expect(decision.messages[2]).toBe(ordinary) expect(decision.messages[3]).toBe(plugin) }) @@ -695,11 +695,11 @@ describe('session reference discovery and preparation', () => { ) const context = prepared.additionalContext if (context === undefined) throw new Error('expected prepared context') - target.append('user/message', context, { surfaceOp: 'append' }) target.append('user/message', createUserMessage({ content: prepared.content, source: { kind: 'user' }, }), { surfaceOp: 'append' }) + target.append('user/message', context, { surfaceOp: 'append' }) const before = target.deriveMessages() const later = source.append( From 1585e539d2de4467c4554b5d53dfc3de6a8103cb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 19 Aug 2026 18:02:10 +0800 Subject: [PATCH 52/60] fix(web): address reference review defects --- ...6-07-21-cross-session-references.i18n.yaml | 4 +- .../2026-07-21-cross-session-references.md | 4 +- .../2026-07-21-cross-session-references.zh.md | 4 +- ...-web-file-and-session-references.i18n.yaml | 4 +- ...6-07-27-web-file-and-session-references.md | 4 +- ...7-27-web-file-and-session-references.zh.md | 4 +- apps/web/tests/reference-composer.e2e.ts | 8 +- .../reference-composer/order.expected.md | 6 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/MessageItem.tsx | 8 +- .../chat-snapshot-builder.ts | 109 ++++++++++++++++- .../src/client/conversation-nodes/message.ts | 13 +- .../src/client/input/facade.ts | 11 +- .../src/client/skeleton/InputBar.module.css | 13 +- .../src/client/skeleton/InputBar.tsx | 11 +- .../tests/chat-branch-tails.client.spec.tsx | 35 ++++++ ...nversation-node-definitions.client.spec.ts | 115 +++++++++++++----- .../tests/input-bar.client.spec.tsx | 15 +++ .../input-reference-submit.client.spec.ts | 37 ++++++ .../session-reference/README.i18n.yaml | 4 +- packages/context/session-reference/README.md | 2 +- .../context/session-reference/README.zh.md | 2 +- 24 files changed, 334 insertions(+), 87 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml index f69999eccc..c59b8b4837 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-cross-session-references.md -2026-07-21-cross-session-references.md: 78d18da23ef682df18653aea73467e281266de9c -2026-07-21-cross-session-references.zh.md: 4fe53e21f85fab950b8480bb28869df6254eed0a +2026-07-21-cross-session-references.md: 48a3241871f0b65270aaf74e42c9d02c45eaf027 +2026-07-21-cross-session-references.zh.md: ef723115f0de4312c0f4b1d215303b4a6a31be47 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md index 78d18da23e..48a3241871 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -34,7 +34,7 @@ Reference preparation is not a new delivery protocol and does not create a turn The unified Web `@` source combines session candidates with Host-backed file discovery. Session candidate lookup matches case-insensitive substrings of the session id, cwd, or latest folded title, displays that title, and falls back to the session id when a title observation is absent or fails. Lookup follows the request's cancellation signal, and session id, cwd, and mention labels escape external control characters while the canonical URI retains the original id. -Web exposes file and session discovery through generated Remote methods on their owning services, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. Ordinary `session.prompt` delivery carries that mention without a reference-specific API Proxy route. Replay associates the separate session-reference context with its neighboring direct message and renders a compact source summary instead of exposing the snapshot JSON. +Web exposes file and session discovery through generated Remote methods on their owning services, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. Ordinary `session.prompt` delivery carries that mention without a reference-specific API Proxy route. Replay associates the separate session-reference context with the direct message immediately before it and renders a compact source summary instead of exposing the snapshot JSON. The [automation-only ACP transport](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately does not mount session-query or session-reference services. @@ -55,7 +55,7 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b ## Verification -Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, control-character escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, pre-step parsing and insertion, downstream rejection, node-owned replay association, title isolation, and the generated Remote discovery faces. A keyless Web snapshot pins the assembled reference selection path. +Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, control-character escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, pre-step parsing and insertion, downstream rejection, Chat-projected following-recall association, title isolation, and the generated Remote discovery faces. A keyless Web snapshot pins the assembled reference selection path. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md index 4fe53e21f8..ef723115f0 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -34,7 +34,7 @@ Web 用户需要把另一场对话中的相关工作带入一条新消息,但 统一的 Web `@` source 把会话候选与 Host 支持的文件发现组合在一起。会话候选查询会对 session id、cwd 或最新折叠后的标题执行不区分大小写的子串匹配,显示该标题,并在没有标题观察结果或标题观察失败时回退到 session id。查询遵循请求的取消信号;session id、cwd 和提及标签中的外部控制字符会被转义,但规范 URI 仍保留原始 id。 -Web 通过所属服务上的生成 Remote 方法提供文件与会话发现,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。session 选择项是由 Host 生成的规范 mention 支撑的原子 chip。普通 `session.prompt` 投递会携带该 mention,无需引用专用 API Proxy 路由。回放会把独立的 session-reference 上下文与相邻直接消息关联起来,并渲染精简来源摘要,而不暴露快照 JSON。 +Web 通过所属服务上的生成 Remote 方法提供文件与会话发现,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。session 选择项是由 Host 生成的规范 mention 支撑的原子 chip。普通 `session.prompt` 投递会携带该 mention,无需引用专用 API Proxy 路由。回放会把独立的 session-reference 上下文与紧邻其前的直接消息关联起来,并渲染精简来源摘要,而不暴露快照 JSON。 [仅面向自动化的 ACP(Agent Client Protocol)传输层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意不挂载会话查询或会话引用服务。 @@ -55,7 +55,7 @@ Web 通过所属服务上的生成 Remote 方法提供文件与会话发现, ## 验证 -单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时的取消、逐源独立字节保留、冻结消息所有权、pre-step 解析和插入、下游拒绝、节点负责的回放关联、标题隔离,以及生成的 Remote 发现接口。一个无密钥 Web 快照会固定组装后的引用选择路径。 +单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时的取消、逐源独立字节保留、冻结消息所有权、pre-step 解析和插入、下游拒绝、Chat 投影的后继召回关联、标题隔离,以及生成的 Remote 发现接口。一个无密钥 Web 快照会固定组装后的引用选择路径。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml index 46d603a4e3..f045d1ac7c 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md -2026-07-27-web-file-and-session-references.md: 09619837b6eda304106276064ed5423b10053119 -2026-07-27-web-file-and-session-references.zh.md: 52cba697c76635ab60eb862475bb47c1a27f99a4 +2026-07-27-web-file-and-session-references.md: 51447d0c22c2e5beb4eae03e7f955239525fc16d +2026-07-27-web-file-and-session-references.zh.md: f2f87aabaa818fe5d104b0ee1488658c792f59b9 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md index 09619837b6..51447d0c22 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md @@ -16,7 +16,7 @@ The file capability follows the three-package seam: `@deepseek-ai/dsh-file-refer A session pick is a structured composer reference. Its visible form uses a chat-bubble glyph and business-color session title without a capsule, while its clipboard and model form is the canonical `@[label](dsh-session:…)` mention produced by the Host. The complete `@label` display text remains in the transparent textarea, and the same-size backdrop colors that range and replaces its leading marker with the domain glyph. Native glyph metrics therefore determine width, wrapping, selection, and caret placement without truncation. The occurrence range retains reference identity for serialization; Backspace or Delete at its boundary removes it whole, and editing inside it turns the remaining characters into ordinary text. Ordinary `session.prompt` delivery carries the canonical mention unchanged. The session-reference service parses accepted direct user messages at `agent/pre-step`, captures every source, replaces the canonical mention with readable text while preserving the direct message id, and inserts the frozen snapshot immediately after that message. The recalled-context row uses the same chat glyph while other context keeps the document glyph. The API Proxy contains no reference-specific route, dependency, or error code. -The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or prompt transport failure returns the same draft to editing. After acceptance, reference preparation belongs to the agent turn; a malformed mention, failed source read, cancellation, or budget failure terminates that turn. The logged prompt remains the replay authority. The chat renders the durable direct-message-then-recall order, decorates recognized file and session mentions as icon-and-text references, and keeps snapshot JSON behind the collapsed recall row. +The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Its session-store mirror persists each occurrence's canonical clipboard projection, so remounting without the occurrence table retains a parseable reference instead of a display-only label. Serialization or prompt transport failure returns the same draft to editing. After acceptance, reference preparation belongs to the agent turn; a malformed mention, failed source read, cancellation, or budget failure terminates that turn. The logged prompt remains the replay authority. The chat renders the durable direct-message-then-recall order and associates exact session labels only from the immediately following sourced recall, which preserves multi-word titles and keeps consecutive references independent. It decorates recognized file and session mentions as icon-and-text references, treats unquoted `@path` tokens including extensionless basenames as files, leaves sentence punctuation outside the reference range, and keeps snapshot JSON behind the collapsed recall row. ## Reference transaction @@ -42,7 +42,7 @@ File lookup is advisory and cancellable; selection itself performs no read. Sess ## Verification -Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, source-title suppression through pending and ready states, grouped headings that do not alter option indexes, file/directory continuation, structured file and session references, complete inline labels, domain glyphs, adjacent-reference and adjacent-text reference projection, codec round-trip, generated Remote type inference, direct-before-recall pre-step preparation, downstream rejection, and durable chat order. The keyless assembled Web snapshot renders the available reference sections without the raw source title, selects a file, then selects a session reference through the real client composition. +Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, source-title suppression through pending and ready states, grouped headings that do not alter option indexes, file/directory continuation, structured file and session references, complete inline labels, domain glyphs, disabled-layer ownership, canonical draft persistence across remount, adjacent-reference and adjacent-text projection, extensionless file and sentence-punctuation rendering, codec round-trip, generated Remote type inference, direct-before-recall pre-step preparation, downstream rejection, and following-recall association for multi-word and consecutive labels. The keyless assembled Web snapshot renders the available reference sections without the raw source title, selects a file, then selects a session reference through the real client composition, and replays a multi-word session label in direct-before-recall order. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md index 52cba697c7..f2f87aabaa 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md @@ -16,7 +16,7 @@ Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file` 选择会话会创建一个结构化输入框引用。可见形式使用聊天气泡图标与业务色会话标题,不使用胶囊容器;剪贴板和模型形式则是宿主生成的规范 `@[label](dsh-session:…)` mention。完整的 `@label` 展示文本会保留在透明 textarea 中,同尺寸 backdrop 会为这段范围着色,并把开头的 marker 替换为对应领域图标。因此宽度、换行、选择区与光标位置都由原生字形度量决定,不会截断。occurrence 范围会保留引用身份以供序列化;在边界按 Backspace 或 Delete 会整段删除引用,在范围内部编辑则会把剩余字符转为普通文本。普通 `session.prompt` 投递会原样携带规范 mention。session-reference 服务会在 `agent/pre-step` 解析已接受的直接用户消息,捕获每个源,在保留直接消息 id 的同时把规范 mention 替换为可读文本,并把冻结快照插入到该消息紧后。召回上下文行使用同一个聊天图标,其他上下文保留文档图标。API Proxy 不包含引用专用路由、依赖或错误码。 -输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或提示词传输失败后,同一草稿会回到可编辑状态。接受后,引用准备属于 agent 轮次;格式错误的 mention、源读取失败、取消或预算失败会终止该轮次。已记录的提示词仍是回放权威。聊天界面按照持久的直接消息后接召回行顺序渲染,将识别到的文件与会话 mention 装饰成图标加文字的引用,并把快照 JSON 保留在默认收起的召回行中。 +输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。它写入会话 store 的镜像会持久化每个 occurrence 的规范剪贴板投影,因此在 occurrence 表缺失的情况下重新挂载时,仍会保留可解析的引用,而不是仅供显示的标签。序列化或提示词传输失败后,同一草稿会回到可编辑状态。接受后,引用准备属于 agent 轮次;格式错误的 mention、源读取失败、取消或预算失败会终止该轮次。已记录的提示词仍是回放权威。聊天界面按照持久的直接消息后接召回行顺序渲染,并且只从紧随其后的带来源召回中关联准确的会话标签,因此既能保留多词标题,也能让连续引用彼此独立。它会把识别到的文件与会话 mention 装饰成图标加文字的引用,把包括无扩展名 basename 在内的未加引号 `@path` token 视为文件,将句末标点留在引用范围之外,并把快照 JSON 保留在默认收起的召回行中。 ## 引用事务 @@ -42,7 +42,7 @@ type @ → parallel file/session Remote calls → pick folder text or atomic fil ## 验证 -包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、在 pending 与 ready 状态下隐藏 source 标题、不改变候选项索引的分组标题、文件/目录继续补全、结构化文件与会话引用、完整行内标签、领域图标、相邻引用及相邻文本条件下的引用投影、codec 无损往返、生成的 Remote 类型推断、pre-step 中直接消息先于召回的准备、下游拒绝,以及持久聊天顺序。无密钥的装配 Web 快照会在不显示原始 source 标题的情况下渲染可用的引用分组,并通过真实客户端组合依次选择文件和会话引用。 +包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、在 pending 与 ready 状态下隐藏 source 标题、不改变候选项索引的分组标题、文件/目录继续补全、结构化文件与会话引用、完整行内标签、领域图标、禁用状态下的层级归属、跨重新挂载的规范草稿持久化、相邻引用及相邻文本条件下的引用投影、无扩展名文件与句末标点渲染、codec 无损往返、生成的 Remote 类型推断、pre-step 中直接消息先于召回的准备、下游拒绝,以及多词与连续标签的后继召回关联。无密钥的装配 Web 快照会在不显示原始 source 标题的情况下渲染可用的引用分组,通过真实客户端组合依次选择文件和会话引用,并按直接消息先于召回的顺序回放多词会话标签。 ## 后果 diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index ba1780a188..39e0707255 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -68,7 +68,7 @@ function targetSessionFixture(): string { const session = Session.create(SessionId(TARGET_SESSION_ID)) session.append('turn/start', { turn: 1 }) const user = session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: '@Research what changed?' }], + content: [{ type: 'text', text: '@Research notes what changed?' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) session.append('user/message', createUserMessage({ @@ -79,7 +79,7 @@ function targetSessionFixture(): string { version: 1, references: [{ sessionId: SOURCE_SESSION_ID, - label: 'Research', + label: 'Research notes', capturedThroughSeq: 4, compacted: false, originalMessages: 2, @@ -176,12 +176,12 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through const target = page.getByRole('treeitem').filter({ hasText: /^dsh-web-e2e-ws-/ }).first() await target.waitFor({ timeout: 15_000 }) await target.click() - await page.getByRole('button', { name: /^Session recall\s*Research$/ }).waitFor({ timeout: 15_000 }) + await page.getByRole('button', { name: /^Session recall\s*Research notes$/ }).waitFor({ timeout: 15_000 }) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(TARGET_SESSION_ID).join('{{targetId}}') await compareOrRefreshGolden(ORDER_EXPECTED, snapshot, MODE) - expect(snapshot.indexOf('Research what changed?')).toBeLessThan(snapshot.indexOf('Session recall Research')) + expect(snapshot.indexOf('Research notes what changed?')).toBeLessThan(snapshot.indexOf('Session recall Research notes')) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md', 'order.expected.md']) diff --git a/apps/web/tests/snapshots/reference-composer/order.expected.md b/apps/web/tests/snapshots/reference-composer/order.expected.md index eb821ea71c..75543314b2 100644 --- a/apps/web/tests/snapshots/reference-composer/order.expected.md +++ b/apps/web/tests/snapshots/reference-composer/order.expected.md @@ -7,12 +7,12 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: Research what changed? {{clock}} +- text: Research notes what changed? Referenced session · Research notes {{clock}} - button "Copy": - img -- button "Session recall Research": +- button "Session recall Research notes": - img - - text: Session recall Research + - text: Session recall Research notes - textbox "Message the agent" - button "Commands": - img diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index b7992aa91a..216c60587a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: f1a4e5de4056712feab7cc1627589909ef70f526 -README.zh.md: a942c994d39652b3811c88ce99bc149cd47b2118 +README.md: cbd57ba05a96da36ac9198817e78594c1a2cefff +README.zh.md: cbe13efd43abda020edae41a0adee09d991314fe diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index f1a4e5de40..cbd57ba05a 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -18,7 +18,7 @@ Approvals take over the composer through the chain this package declares: `Appro The session header renders the session-scoped `'conversation.session.header.actions'` list beside the title and the independent `'conversation.session.header.utilities'` list at the right edge. Session context and lineage controls remain in `actions`; optional Session utilities cannot reorder or move them. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. -Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A direct message that cites another session precedes its recall row in durable order. Recall uses a chat-bubble glyph while other context keeps the document glyph; a source that names no producer shows the role alone. Composer and user-bubble references use the same inline language: a chat-bubble, file, or folder glyph plus business-color text, without a nested capsule. Like claimed slash commands, composer references keep their complete display text in the transparent textarea and use the aligned backdrop for color and the leading domain glyph; native text metrics own width, wrapping, selection, and caret placement. The occurrence range remains structured for serialization and boundary deletion, while an edit inside it converts the remaining characters to ordinary text. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows. +Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A direct message that cites another session precedes its recall row in durable order. The Chat snapshot associates exact labels only from that immediately following sourced recall, preserving multi-word titles without carrying one recall's labels onto a later direct message. Recall uses a chat-bubble glyph while other context keeps the document glyph; a source that names no producer shows the role alone. Composer and user-bubble references use the same inline language: a chat-bubble, file, or folder glyph plus business-color text, without a nested capsule. Like claimed slash commands, composer references keep their complete display text in the transparent textarea and use the aligned backdrop for color and the leading domain glyph; native text metrics own width, wrapping, selection, and caret placement. The occurrence range remains structured for serialization and boundary deletion, while an edit inside it converts the remaining characters to ordinary text. The session draft mirror stores each occurrence's clipboard projection, so a remount without the occurrence table restores canonical parseable reference text instead of a display-only label. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows. A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index a942c994d3..cbe13efd43 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -16,7 +16,7 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。 会话页头会在标题旁渲染会话作用域的 `'conversation.session.header.actions'` 列表,并在最右侧渲染独立的 `'conversation.session.header.utilities'` 列表。会话上下文和谱系控件保留在 `actions` 中;可选的会话工具不会改变它们的顺序或位置。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。引用其他会话的直接消息在持久顺序中位于其召回行之前。召回使用聊天气泡图标,其他上下文保留文档图标;来源未提供生产者名称时只显示角色。输入框与用户气泡中的引用使用同一种行内语言:聊天气泡、文件或文件夹图标加业务色文字,不嵌套胶囊容器。与已认领的 slash command 相同,输入框引用会把完整展示文本保留在透明 textarea 中,再用对齐的 backdrop 提供颜色和开头的领域图标;宽度、换行、选择区与光标位置均由原生文本度量决定。occurrence 范围仍为序列化与边界整段删除保留结构身份,在范围内部编辑则会把剩余字符转为普通文本。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的正文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。引用其他会话的直接消息在持久顺序中位于其召回行之前。Chat 快照只从紧随其后的带来源召回中关联准确标签,因此既能保留多词标题,也不会把一条召回的标签带到后续直接消息上。召回使用聊天气泡图标,其他上下文保留文档图标;来源未提供生产者名称时只显示角色。输入框与用户气泡中的引用使用同一种行内语言:聊天气泡、文件或文件夹图标加业务色文字,不嵌套胶囊容器。与已认领的 slash command 相同,输入框引用会把完整展示文本保留在透明 textarea 中,再用对齐的 backdrop 提供颜色和开头的领域图标;宽度、换行、选择区与光标位置均由原生文本度量决定。occurrence 范围仍为序列化与边界整段删除保留结构身份,在范围内部编辑则会把剩余字符转为普通文本。会话草稿镜像会存储每个 occurrence 的剪贴板投影,因此在 occurrence 表缺失的情况下重新挂载时,会恢复可解析的规范引用文本,而不是仅供显示的标签。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的正文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 7934054d96..8a882a70c5 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -167,7 +167,11 @@ function projectUserText(text: string, sessionLabels: readonly string[]): ReactN let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { const tokenStart = m.index + (m[1]?.length ?? 0) - const label = m[2] ?? '' + const rawLabel = m[2] ?? '' + const label = rawLabel.startsWith('@"') + ? rawLabel + : rawLabel.replace(/[.,;:!?,。;:!?]+$/gu, '') + if (label.length <= 1) continue ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' }) } ranges.sort((a, b) => a.start - b.start @@ -181,7 +185,7 @@ function projectUserText(text: string, sessionLabels: readonly string[]): ReactN const referenceKind = kind === 'session' ? 'session' : label.startsWith('@') - ? label.endsWith('/') ? 'folder' : /[./\\]/u.test(label.slice(1)) ? 'file' : 'session' + ? label.endsWith('/') ? 'folder' : 'file' : undefined const displayLabel = referenceKind === undefined ? label diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts index be4e27111a..651fc780a3 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -5,6 +5,7 @@ import type { ConversationViewBuilder, ConversationViewDefinition, LegacyConversationSlice, PartialAssistant, RunningToolCall, } from '@deepseek-ai/dsh-client-runtime/client' +import { sessionRecallLabels } from '@deepseek-ai/dsh-client-runtime/client' import type { ChatNode } from '../contract/chat-nodes.ts' import { isRunningTool } from '../contract/chat-nodes.ts' @@ -138,6 +139,99 @@ function orderedVisible(nodes: readonly ChatConversationViewNode[]): ChatConvers .sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) } +function referenceMessageSeq(node: ChatConversationViewNode): number | undefined { + const candidate = node as ChatNode + return candidate.kind === 'user' || candidate.kind === 'steering' + ? candidate.data.seq + : undefined +} + +function followingRecall(node: ChatConversationViewNode): { + readonly messageSeq: number + readonly labels: readonly string[] +} | undefined { + const candidate = node as ChatNode + if (candidate.kind !== 'context') return undefined + return { + messageSeq: candidate.data.seq - 1, + labels: sessionRecallLabels(candidate.data.source), + } +} + +function withReferenceLabels( + node: ChatConversationViewNode, + labels: readonly string[], +): ChatConversationViewNode { + const candidate = node as ChatNode + if (candidate.kind !== 'user' && candidate.kind !== 'steering') return node + const current = candidate.data.referenceLabels ?? EMPTY_KEYS + const hasLabels = Object.hasOwn(candidate.data, 'referenceLabels') + if (sameReferences(current, labels) && hasLabels === (labels.length > 0)) return node + const data: Record = { ...candidate.data } + if (labels.length === 0) delete data.referenceLabels + else data.referenceLabels = labels + return { ...candidate, data } +} + +/** Associates a direct message with the sourced recall event that immediately follows it. */ +class ReferenceLabelProjector { + private readonly messagesBySeq = new Map() + private readonly labelsByMessageSeq = new Map() + + replace(nodes: readonly ChatConversationViewNode[]): readonly ChatConversationViewNode[] { + this.messagesBySeq.clear() + this.labelsByMessageSeq.clear() + for (const node of nodes) { + const messageSeq = referenceMessageSeq(node) + if (messageSeq !== undefined) this.messagesBySeq.set(messageSeq, node.key) + const recall = followingRecall(node) + if (recall !== undefined && recall.labels.length > 0) { + this.labelsByMessageSeq.set(recall.messageSeq, recall.labels) + } + } + return nodes.map((node) => { + const messageSeq = referenceMessageSeq(node) + return messageSeq === undefined + ? node + : withReferenceLabels(node, this.labelsByMessageSeq.get(messageSeq) ?? EMPTY_KEYS) + }) + } + + apply( + upserts: readonly ChatConversationViewNode[], + store: ChatNodeStore, + ): readonly ChatConversationViewNode[] { + const byKey = new Map(upserts.map(node => [node.key, node])) + const affected = new Set() + for (const node of upserts) { + const messageSeq = referenceMessageSeq(node) + if (messageSeq !== undefined) { + this.messagesBySeq.set(messageSeq, node.key) + affected.add(messageSeq) + } + const recall = followingRecall(node) + if (recall === undefined) continue + const current = this.labelsByMessageSeq.get(recall.messageSeq) + if (recall.labels.length === 0) this.labelsByMessageSeq.delete(recall.messageSeq) + else { + this.labelsByMessageSeq.set( + recall.messageSeq, + current !== undefined && sameReferences(current, recall.labels) ? current : recall.labels, + ) + } + affected.add(recall.messageSeq) + } + for (const messageSeq of affected) { + const key = this.messagesBySeq.get(messageSeq) + if (key === undefined) continue + const node = byKey.get(key) ?? store.get(key) + if (node === undefined) continue + byKey.set(key, withReferenceLabels(node, this.labelsByMessageSeq.get(messageSeq) ?? EMPTY_KEYS)) + } + return [...byKey.values()] + } +} + interface LegacyContribution { readonly anchorSeq: number readonly nodes: readonly ConversationNode[] @@ -384,6 +478,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) + const nodes = this.referenceLabels.replace(input.nodes) + this.store.replace(nodes) + this.order = orderedVisible(nodes).map(node => node.key) this.locations.rebuild(this.order, this.store) - return this.snapshot(input.timeline, this.legacy.replace(input.nodes, input.timeline)) + return this.snapshot(input.timeline, this.legacy.replace(nodes, input.timeline)) } apply(input: { readonly upserts: readonly ChatConversationViewNode[] readonly timeline: ConversationTimelineSnapshot }): ChatSnapshot { + const upserts = this.referenceLabels.apply(input.upserts, this.store) let structural = false const contentOnly: ChatConversationViewNode[] = [] - for (const node of input.upserts) { + for (const node of upserts) { const previous = this.store.get(node.key) const nodeStructural = previous === undefined || previous.anchorSeq !== node.anchorSeq @@ -416,14 +513,14 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) this.order = sameReferences(this.order, next) ? this.order : next this.locations.rebuild(this.order, this.store) } this.locations.touch(contentOnly) - return this.snapshot(input.timeline, this.legacy.apply(input.upserts, input.timeline)) + return this.snapshot(input.timeline, this.legacy.apply(upserts, input.timeline)) } private snapshot( diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts index 3f9f2b5ef1..c60bbf79f1 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts @@ -3,18 +3,18 @@ import type { ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { - contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent, sessionRecallLabels, + contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent, } from '@deepseek-ai/dsh-client-runtime/client' import type { InboxState } from './inbox.ts' import { chatNode } from './common.ts' interface ReferencedUserMessageNode extends UserMessageNode { - /** Labels cited by the immediately preceding session-reference context. */ + /** Labels cited by the immediately following session-reference context. */ readonly referenceLabels?: readonly string[] } interface ReferencedSteeringMessageNode extends SteeringMessageNode { - /** Labels cited by the immediately preceding session-reference context. */ + /** Labels cited by the immediately following session-reference context. */ readonly referenceLabels?: readonly string[] } @@ -61,11 +61,6 @@ export const messageDefinition: ConversationNodeDefinition = { } } const claimed = reader.previous('inbox-next-step')?.state.claimed.has(String(event.data.id)) === true - const previous = reader.previous('input-message') - const labels = previous?.state.kind === 'context' && previous.state.seq + 1 === event.seq - ? sessionRecallLabels(previous.state.source) - : [] - const referenceLabels = labels.length === 0 ? {} : { referenceLabels: labels } return claimed ? { kind: 'steering', @@ -74,7 +69,6 @@ export const messageDefinition: ConversationNodeDefinition = { time: event.time, content: event.data.content, source: event.data.source, - ...referenceLabels, } : { kind: 'user', @@ -82,7 +76,6 @@ export const messageDefinition: ConversationNodeDefinition = { time: event.time, content: event.data.content, source: event.data.source, - ...referenceLabels, } }, update: context => context.state, diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 0a115dedeb..cc42d0c1ad 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -17,7 +17,7 @@ import type { PasteComponent, QueuedMessage, SessionInput, SubmitAttempt, } from './contract.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' -import { InputMachine } from './machine.ts' +import { InputMachine, projectClipboard } from './machine.ts' /** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */ export interface PopupDismissFace { @@ -98,7 +98,7 @@ export class SessionInputShell implements SessionInput { // production (the machine's no-clock default is a constant for pure tests). private readonly core = new InputMachine({ now: () => Date.now() }) private noticeSeq = 0 - private lastDraft = '' + private lastMirroredDraft = '' private imageIds: readonly DraftAttachmentId[] = [] /** One image-only send at a time: Enter during the Host round-trip is a no-op. */ private imageSendInFlight = false @@ -596,9 +596,10 @@ export class SessionInputShell implements SessionInput { private publish(): void { const next = this.compose() this.state.set(next) - if (next.draft !== this.lastDraft) { - this.lastDraft = next.draft - this.mirrorFn?.(next.draft) + const mirroredDraft = projectClipboard(next) + if (mirroredDraft !== this.lastMirroredDraft) { + this.lastMirroredDraft = mirroredDraft + this.mirrorFn?.(mirroredDraft) } } } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 315121814d..e5b85df3e9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -147,6 +147,11 @@ pointer-events: none; } +.backdropDisabled, +.backdropDisabled :is(.hlToken, .hint, .textRef, .chip, .chipInvalid) { + color: var(--dsw-alias-label-tertiary); +} + .hlToken { background-color: transparent; color: var(--dsw-alias-state-warn-label); @@ -191,6 +196,7 @@ outline: none; background: transparent; color: transparent; + -webkit-text-fill-color: transparent; /* Business blue, not brand-primary: that token resolves to ink in this sheet. */ caret-color: var(--dsw-alias-state-business-primary); } @@ -230,12 +236,15 @@ /* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */ .input::placeholder { color: var(--dsw-alias-label-caption); + -webkit-text-fill-color: var(--dsw-alias-label-caption); user-select: none; } -/* Running lock: grayed but the draft stays visible; the turn ending re-enables. */ +/* The backdrop owns disabled draft color; the textarea remains caret-only so + its marker glyphs cannot cover the reference icons beneath it. */ .input:disabled { - color: var(--dsw-alias-label-tertiary); + color: transparent; + -webkit-text-fill-color: transparent; cursor: not-allowed; } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index c85752da0b..14feaca291 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -287,7 +287,7 @@ export function InputBar({ } // Absent machine without a Workspace recovery action stays disabled; the // guard narrows the faces for the paths below. - if (keyboard === undefined || inputActions === undefined) return + if (input === undefined || keyboard === undefined || inputActions === undefined) return // Shift+Enter is the native newline UNCONDITIONALLY — decided before the // IME guard so a composition-closing Shift+Enter still breaks the line. if (e.key === 'Enter' && e.shiftKey) return @@ -651,7 +651,14 @@ export function InputBar({ which a compositor-driven gesture outruns and leaves the words trailing the caret. */}
-
{backdrop}
+
+ {backdrop} +