瀏覽代碼

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.
Huanqi Cao 2 月之前
父節點
當前提交
0441312768

+ 25 - 0
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'

+ 1 - 0
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:^",

+ 22 - 1
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<string, unknown>).id === id
       )), `${id} must be absent from minimal`).toBe(false)
     }
+    const group = entries.find((entry): entry is Record<string, unknown> => (
+      typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>).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<string, unknown> => 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' })
   })
 })

+ 62 - 0
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:^"
+  }
+}

+ 476 - 0
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 = '<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>'
+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'
+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<PtySessionId>
+  reset(owner: Agent, reason: string): Promise<void>
+}
+
+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<void> {
+  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<Agent, Promise<PtySessionId>>()
+  const live = new Map<Agent, PtySessionId>()
+  const creating = new Set<Promise<PtySessionId>>()
+  const ownerCleanupInstalled = new WeakSet<Agent>()
+  const lifecycle = new AbortController()
+
+  const close = async (owner: Agent, id: PtySessionId, reason: string): Promise<void> => {
+    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<void> => {
+    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<PtySessionId> => {
+    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<string> {
+  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<Agent, Promise<void>>()
+
+  const serialized = async <T>(owner: Agent, operation: () => Promise<T>): Promise<T> => {
+    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<Config> = 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)
+}

+ 31 - 0
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 */

+ 167 - 0
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<string, unknown>([
+      ['@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<typeof context.loader.internal>
+    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('<response clipped>')
+    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)
+})

+ 594 - 0
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<ReturnType<StubPtySession['result']>>((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<ReturnType<StubPtySession['result']>>, 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('<response clipped>')
+
+    session.mode = 'large'
+    expect(text(await call(ctx, owner, 'large'))).toContain('<response clipped>')
+
+    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('<response clipped>')
+    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<undefined>()
+    const spawnAborted = Promise.withResolvers<undefined>()
+    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')
+  })
+})

+ 17 - 0
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" }
+  ]
+}

+ 58 - 0
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':

+ 1 - 0
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" },