index.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. /* jscpd:ignore-start -- deliberate mirror of tool-bash-persistent (persistent-pty note 2026-08-11-pwsh-persistent-pty):
  2. the PowerShell counterpart shares the session registry, polling loop, and reset contract by design. */
  3. /**
  4. * Model-facing persistent `pwsh` tool over the owner-scoped PTY seam.
  5. * @module @deepseek-ai/dsh-tool-pwsh-persistent
  6. */
  7. import { randomUUID } from 'node:crypto'
  8. import type { Context } from '@deepseek-ai/cordis'
  9. import z from '@deepseek-ai/schemastery'
  10. import type { Agent } from '@deepseek-ai/dsh-agent'
  11. import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
  12. import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
  13. import { defineTool } from '@deepseek-ai/dsh-tools'
  14. // TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file.
  15. const TRUNCATED_MESSAGE = '<response clipped><NOTE>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.</NOTE>'
  16. const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.</NOTE>\n'
  17. 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.'
  18. const SHELL_PROMPT = '__DSH_PERSISTENT_PWSH_PROMPT__ '
  19. const TIMEOUT_CODE = 'PERSISTENT_PWSH_TIMEOUT'
  20. // One page is enough to find a just-emitted completion marker; the full
  21. // scrollback is assembled only when a command settles or needs partial output.
  22. const SCROLLBACK_PAGE_LINES = 1_000
  23. const POLL_INTERVAL_MS = 25
  24. 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.'
  25. interface ResolvedConfig {
  26. backendType: string
  27. timeoutMs: number
  28. maxOutputChars: number
  29. description: string
  30. }
  31. interface CommandMarkers {
  32. start: string
  33. end: string
  34. }
  35. interface RetainedOutput {
  36. text: string
  37. truncated: boolean
  38. }
  39. interface CapturedOutput {
  40. text: string
  41. incomplete: boolean
  42. exitCode?: number
  43. }
  44. interface PersistentShells {
  45. get(owner: Agent, signal: AbortSignal): Promise<TerminalSessionId>
  46. reset(owner: Agent, reason: string): Promise<void>
  47. }
  48. function maybeTruncate(content: string, maxOutputChars: number, incomplete = false): string {
  49. if (content.length <= maxOutputChars && !incomplete) return content
  50. return content.length <= maxOutputChars
  51. ? content + TRUNCATED_MESSAGE
  52. : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE
  53. }
  54. function markers(): CommandMarkers {
  55. const nonce = randomUUID()
  56. return {
  57. start: `__DSH_PERSISTENT_PWSH_START_${nonce}__`,
  58. end: `__DSH_PERSISTENT_PWSH_END_${nonce}:`,
  59. }
  60. }
  61. /**
  62. * Escape a command body for embedding in the wrapper's double-quoted string.
  63. * Backtick escapes keep every character literal: backtick first so the
  64. * escapes this function inserts are never re-escaped, `$` so no expansion
  65. * happens at wrapper construction, and `\r\n`/ESC so multi-line commands and
  66. * raw control bytes ride one physical input line without PSReadLine mangling.
  67. * @param value - the model's PowerShell command text.
  68. * @returns the escaped double-quoted-string body.
  69. */
  70. function quoteForPwsh(value: string): string {
  71. return value
  72. .replaceAll('`', '``')
  73. .replaceAll('"', '`"')
  74. .replaceAll('$', '`$')
  75. .replaceAll('\r', '')
  76. .replaceAll('\n', '`n')
  77. .replaceAll('\x1b', '`e')
  78. }
  79. function wrapCommand(command: string, marker: CommandMarkers): string {
  80. // Keep the wrapper on one physical line: PSReadLine renders the echoed
  81. // input, and a wrapped line would split the echo the extraction strips.
  82. // The echoed END nonce can never fabricate completion because the status
  83. // regex needs digits immediately after it and the echo continues with
  84. // quote characters.
  85. const body = quoteForPwsh(command)
  86. 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)`
  87. }
  88. function stripPrompt(text: string): string {
  89. let result = text.replace(/\r?\n$/, '')
  90. while (result.endsWith(SHELL_PROMPT)) {
  91. result = result.slice(0, -SHELL_PROMPT.length)
  92. }
  93. return result.endsWith('\n') ? result.slice(0, -1) : result
  94. }
  95. function commandOutput(
  96. snapshot: RetainedOutput,
  97. marker: CommandMarkers,
  98. wrapper: string,
  99. ): CapturedOutput | undefined {
  100. const text = snapshot.text
  101. const end = text.lastIndexOf(marker.end)
  102. const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1]
  103. if (status === undefined) return undefined
  104. const startMarker = text.lastIndexOf(marker.start, end)
  105. const start = startMarker < 0 ? 0 : startMarker + marker.start.length
  106. let captured = text.slice(start, end)
  107. // The PSReadLine echo carries the wrapper source (including both marker
  108. // nonces) before the real markers; anchor on the real markers excludes it,
  109. // and stripping the wrapper covers the rare case where the real START
  110. // scrolled out and extraction fell back to the echoed copy.
  111. captured = captured.replaceAll(wrapper, '')
  112. return {
  113. text: captured.replace(/^\r?\n/, '').replace(/\r?\n$/, ''),
  114. incomplete: startMarker < 0,
  115. exitCode: Number(status),
  116. }
  117. }
  118. function promptCompleted(result: TerminalSendResult): boolean {
  119. return result.viewport.endsWith(SHELL_PROMPT)
  120. || result.viewport.endsWith(`${SHELL_PROMPT}\r\n`)
  121. || result.viewport.endsWith(`${SHELL_PROMPT}\n`)
  122. }
  123. function partialOutput(
  124. snapshot: RetainedOutput,
  125. marker: CommandMarkers,
  126. wrapper: string,
  127. fallback: string,
  128. fallbackTruncated = false,
  129. ): CapturedOutput {
  130. const startMarker = snapshot.text.lastIndexOf(marker.start)
  131. if (startMarker >= 0) {
  132. return {
  133. text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
  134. incomplete: false,
  135. }
  136. }
  137. const fallbackStart = fallback.lastIndexOf(marker.start)
  138. const afterStart = fallbackStart < 0
  139. ? fallback
  140. : fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '')
  141. const fallbackEnd = afterStart.lastIndexOf(marker.end)
  142. const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd)
  143. return {
  144. text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '').replaceAll(wrapper, '')),
  145. incomplete: fallbackTruncated || fallbackStart < 0,
  146. }
  147. }
  148. async function pause(): Promise<void> {
  149. await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
  150. }
  151. function nextScrollbackOffset(page: TerminalReadResult, offset: number): number | undefined {
  152. if (page.text.length === 0 || page.lineEnd <= offset) return undefined
  153. return page.lineEnd
  154. }
  155. function retainedScrollback(
  156. ctx: Context,
  157. owner: Agent,
  158. id: TerminalSessionId,
  159. latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }),
  160. ): RetainedOutput {
  161. const pages: string[] = latest.text.length === 0 ? [] : [latest.text]
  162. let offset = latest.lineEnd
  163. let truncated = latest.truncated
  164. while (true) {
  165. if (offset >= latest.totalLines) break
  166. const page = ctx.terminals.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES })
  167. truncated ||= page.truncated
  168. if (page.text.length > 0) pages.unshift(page.text)
  169. const next = nextScrollbackOffset(page, offset)
  170. if (next === undefined || next >= page.totalLines) break
  171. offset = next
  172. }
  173. return { text: pages.join('\n'), truncated }
  174. }
  175. function renderCaptured(output: CapturedOutput, maxOutputChars: number): string {
  176. const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete)
  177. const withPrefix = output.incomplete && output.text.length > 0
  178. ? LOST_PREFIX_MESSAGE + rendered
  179. : rendered
  180. const marker = output.exitCode !== undefined && output.exitCode !== 0
  181. ? `[exit code: ${output.exitCode}]`
  182. : undefined
  183. return appendStatusMarker(withPrefix, marker)
  184. }
  185. function appendStatusMarker(content: string, marker: string | undefined): string {
  186. if (marker === undefined) return content
  187. return content.length === 0 ? marker : `${content}\n${marker}`
  188. }
  189. function renderShellExitStatus(
  190. content: string,
  191. exitCode: number | null,
  192. signal: NodeJS.Signals | null,
  193. ): string {
  194. const marker = signal !== null
  195. ? `[shell killed by signal: ${signal}]`
  196. : exitCode !== null
  197. ? `[shell exited: code ${exitCode}]`
  198. : '[shell exited]'
  199. return appendStatusMarker(content, marker)
  200. }
  201. /**
  202. * Render the exited-session result, reset the owner's shell, and reset the
  203. * message that tells the model the next call starts fresh.
  204. * @param shells - the owner-scoped registry to reset.
  205. * @param status - the exited session status (exit code and signal).
  206. * @returns the complete model-facing result.
  207. */
  208. async function respondToSessionExit(
  209. ctx: Context,
  210. shells: PersistentShells,
  211. owner: Agent,
  212. id: TerminalSessionId,
  213. status: { exitCode: number | null; signal: NodeJS.Signals | null },
  214. marker: CommandMarkers,
  215. wrapped: string,
  216. fallback: string,
  217. fallbackTruncated: boolean,
  218. config: ResolvedConfig,
  219. ): Promise<string> {
  220. const snapshot = retainedScrollback(ctx, owner, id)
  221. await shells.reset(owner, 'persistent pwsh shell exited')
  222. return [
  223. renderShellExitStatus(
  224. renderCaptured(partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), config.maxOutputChars),
  225. status.exitCode,
  226. status.signal,
  227. ),
  228. SHELL_RESET_MESSAGE,
  229. ].filter(part => part.length > 0).join('\n')
  230. }
  231. /**
  232. * The pwsh prompt function that overrides the backend bootstrap value with
  233. * this tool's own prompt. `[char]27`/`[char]7` build the OSC bytes at runtime
  234. * because raw ESC characters in submitted input are unreliable under
  235. * PSReadLine.
  236. */
  237. const PWSH_PROMPT_SETUP =
  238. "function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + SHELL_PROMPT + "' }"
  239. function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells {
  240. const pending = new WeakMap<Agent, Promise<TerminalSessionId>>()
  241. const live = new Map<Agent, TerminalSessionId>()
  242. const creating = new Set<Promise<TerminalSessionId>>()
  243. const ownerCleanupInstalled = new WeakSet<Agent>()
  244. const lifecycle = new AbortController()
  245. const close = async (owner: Agent, id: TerminalSessionId, reason: string): Promise<void> => {
  246. if (!ctx.terminals.list(owner).some(snapshot => snapshot.sessionId === id)) return
  247. await ctx.terminals.kill(owner, id, reason)
  248. }
  249. ctx.effect(() => async () => {
  250. lifecycle.abort(new Error('tool-pwsh-persistent disposed during shell creation'))
  251. await Promise.allSettled([...creating])
  252. const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-pwsh-persistent disposed') })
  253. await Promise.all(closing)
  254. live.clear()
  255. }, 'tool-pwsh-persistent shell cleanup')
  256. const reset = async (owner: Agent, reason: string): Promise<void> => {
  257. pending.delete(owner)
  258. const id = live.get(owner)
  259. live.delete(owner)
  260. if (id !== undefined) await close(owner, id, reason)
  261. }
  262. const get = (owner: Agent, signal: AbortSignal): Promise<TerminalSessionId> => {
  263. const existing = pending.get(owner)
  264. if (existing !== undefined) return existing
  265. const combinedSignal = AbortSignal.any([signal, lifecycle.signal])
  266. const creation = (async () => {
  267. try {
  268. const cwd = owner.session.header.cwd
  269. const spawned = await ctx.terminals.spawn(owner, {
  270. type: config.backendType,
  271. ...cwd === undefined ? {} : { cwd },
  272. }, combinedSignal)
  273. live.set(owner, spawned.sessionId)
  274. if (!ownerCleanupInstalled.has(owner)) {
  275. ownerCleanupInstalled.add(owner)
  276. owner.ctx.effect(() => () => {
  277. pending.delete(owner)
  278. live.delete(owner)
  279. }, 'tool-pwsh-persistent owner cache cleanup')
  280. }
  281. const setup = ctx.terminals.startSend(owner, spawned.sessionId, {
  282. text: PWSH_PROMPT_SETUP,
  283. submit: true,
  284. signal: combinedSignal,
  285. })
  286. const result = await setup.done
  287. if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') {
  288. throw new Error('persistent pwsh shell did not accept initialization')
  289. }
  290. return spawned.sessionId
  291. } catch (error: unknown) {
  292. await reset(owner, 'persistent pwsh initialization failed')
  293. throw error
  294. }
  295. })()
  296. const tracked = creation.finally(() => {
  297. creating.delete(tracked)
  298. })
  299. creating.add(tracked)
  300. pending.set(owner, tracked)
  301. return tracked
  302. }
  303. return { get, reset }
  304. }
  305. async function executeCommand(
  306. ctx: Context,
  307. shells: PersistentShells,
  308. owner: Agent,
  309. command: string,
  310. config: ResolvedConfig,
  311. upstream: AbortSignal,
  312. ): Promise<string> {
  313. using commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE)
  314. const id = await shells.get(owner, commandDeadline.signal)
  315. const marker = markers()
  316. const wrapped = wrapCommand(command, marker)
  317. let first = true
  318. let fallback = ''
  319. let fallbackTruncated = false
  320. while (true) {
  321. // The shell may flip to exited between iterations (a fast `exit` can
  322. // settle the previous send while its exit event is still in flight, and
  323. // the echoed wrapper can then carry a marker end without status digits);
  324. // re-observing status before the next send closes that gap.
  325. const status = ctx.terminals.list(owner).find(session => session.sessionId === id)?.status
  326. if (status?.kind === 'exited') {
  327. return await respondToSessionExit(
  328. ctx, shells, owner, id, status, marker, wrapped, fallback, fallbackTruncated, config,
  329. )
  330. }
  331. let operation
  332. let result
  333. try {
  334. operation = ctx.terminals.startSend(owner, id, {
  335. text: first ? wrapped : '',
  336. submit: first,
  337. signal: commandDeadline.signal,
  338. })
  339. first = false
  340. result = await operation.done
  341. } catch (error: unknown) {
  342. await shells.reset(owner, 'persistent pwsh send failed')
  343. throw error
  344. }
  345. const incremental = operation.readOutput()
  346. fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport
  347. fallbackTruncated ||= incremental.truncated || result.truncated
  348. const latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES })
  349. const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE)
  350. if (timedOut !== undefined) {
  351. const snapshot = retainedScrollback(ctx, owner, id, latest)
  352. const partial = renderCaptured(
  353. partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated),
  354. config.maxOutputChars,
  355. )
  356. await shells.reset(owner, 'persistent pwsh command timed out')
  357. return [
  358. // TODO: Report a timeout only; this signal does not establish an OOM.
  359. `Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`,
  360. partial,
  361. SHELL_RESET_MESSAGE,
  362. ].join('\n')
  363. }
  364. if (commandDeadline.signal.aborted) {
  365. await shells.reset(owner, 'persistent pwsh command aborted')
  366. commandDeadline.signal.throwIfAborted()
  367. }
  368. if (latest.text.includes(marker.end)) {
  369. const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker, wrapped)
  370. if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
  371. }
  372. if (result.sessionStatus.kind === 'exited') {
  373. return await respondToSessionExit(
  374. ctx, shells, owner, id, result.sessionStatus, marker, wrapped, fallback, fallbackTruncated, config,
  375. )
  376. }
  377. if (promptCompleted(result)) {
  378. const snapshot = retainedScrollback(ctx, owner, id, latest)
  379. return renderCaptured(
  380. partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated),
  381. config.maxOutputChars,
  382. )
  383. }
  384. await pause()
  385. }
  386. }
  387. /**
  388. * Register the model-facing persistent `pwsh` tool.
  389. * @param ctx - plugin context carrying tools and the owner-scoped PTY service.
  390. * @param config - selected PTY backend and command deadline.
  391. */
  392. function registerPersistentPwsh(ctx: Context, config: ResolvedConfig): void {
  393. const shells = persistentShells(ctx, config)
  394. const queues = new WeakMap<Agent, Promise<void>>()
  395. const serialized = async <T>(owner: Agent, operation: () => Promise<T>): Promise<T> => {
  396. const prior = queues.get(owner) ?? Promise.resolve()
  397. const run = prior.then(operation, operation)
  398. const tail = run.then(() => undefined, () => undefined)
  399. queues.set(owner, tail)
  400. try {
  401. return await run
  402. } finally {
  403. if (queues.get(owner) === tail) queues.delete(owner)
  404. }
  405. }
  406. ctx.tools.register(defineTool({
  407. name: 'pwsh',
  408. description: config.description,
  409. parameters: {
  410. command: {
  411. type: 'string',
  412. required: true,
  413. description: 'The PowerShell command to run. Relative path is preferred in the command.',
  414. },
  415. },
  416. output: {
  417. schema: { type: 'string' },
  418. render: (_args, value) => [{ type: 'text', text: value }],
  419. },
  420. async execute(args, exec) {
  421. if (args.command.trim().length === 0) throw new Error('command must be a non-empty string')
  422. const owner = exec.agent
  423. if (owner === undefined) throw new Error('pwsh requires an owning agent session')
  424. return serialized(owner, async () => {
  425. exec.signal.throwIfAborted()
  426. return executeCommand(ctx, shells, owner, args.command, config, exec.signal)
  427. })
  428. },
  429. presentCall: args => ({ card: 'terminal', title: args.command }),
  430. }))
  431. }
  432. export const name = 'tool-pwsh-persistent'
  433. export const inject = ['tools', 'terminals']
  434. /** Configuration for the persistent pwsh tool. */
  435. export interface Config {
  436. /** PTY backend used for each owner-isolated persistent shell (default `shell`). */
  437. backendType?: string
  438. /** Wall-clock limit for one command (default 300000). */
  439. timeoutMs?: number
  440. /** Maximum returned command-output characters before clipping (default 16000). */
  441. maxOutputChars?: number
  442. /** Model-facing tool description; deployments may describe their environment. */
  443. description?: string
  444. }
  445. /** Runtime configuration schema for the persistent pwsh tool. */
  446. export const Config: z<Config> = z.object({
  447. backendType: z.string().default('shell'),
  448. timeoutMs: z.number().default(300_000),
  449. maxOutputChars: z.number().default(16_000),
  450. description: z.string().default(DEFAULT_DESCRIPTION),
  451. })
  452. /** Register one owner-scoped persistent `pwsh` tool. */
  453. export function apply(ctx: Context, config: Config): void {
  454. const resolved: ResolvedConfig = {
  455. backendType: config.backendType ?? 'shell',
  456. timeoutMs: config.timeoutMs ?? 300_000,
  457. maxOutputChars: config.maxOutputChars ?? 16_000,
  458. description: config.description ?? DEFAULT_DESCRIPTION,
  459. }
  460. if (resolved.backendType.trim().length === 0) {
  461. throw new Error('tool-pwsh-persistent: backendType must be non-empty')
  462. }
  463. if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0) {
  464. throw new Error('tool-pwsh-persistent: timeoutMs must be a positive safe integer')
  465. }
  466. if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) {
  467. throw new Error('tool-pwsh-persistent: maxOutputChars must be a positive safe integer')
  468. }
  469. if (resolved.description.trim().length === 0) {
  470. throw new Error('tool-pwsh-persistent: description must be non-empty')
  471. }
  472. registerPersistentPwsh(ctx, resolved)
  473. }
  474. /* jscpd:ignore-end */