Bladeren bron

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).
Huanqi Cao 1 maand geleden
bovenliggende
commit
da403d6086

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

+ 2 - 0
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}`)
 }

+ 74 - 0
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<void> {
+    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<void> {
+    // 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<void> {
+    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<void> {
     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 })
+  }
 }

+ 288 - 0
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<number, ProcessEntry[]>()
+  for (const entry of entries) {
+    const children = byParent.get(entry.parentPid) ?? []
+    children.push(entry)
+    byParent.set(entry.parentPid, children)
+  }
+  const visited = new Set<number>()
+  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<typeof koffi.pointer> = 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<typeof koffi.struct>; FILETIME: ReturnType<typeof koffi.struct> } {
+  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<typeof win32Structs> | 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<typeof koffi.pointer> | string,
+    args: Array<ReturnType<typeof koffi.pointer> | 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,
+  }
+}

+ 13 - 4
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> = {}): 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 () => {

+ 4 - 2
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')
   })
 })

+ 157 - 20
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<Parameters<typeof spawnSubprocess>[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')
   })
 })

+ 113 - 14
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([])
+  })
+})

+ 142 - 0
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<number, string>()
+  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()
+  })
+})

+ 6 - 0
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)

+ 9 - 6
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',
     ]
   : []