index.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. /**
  2. * Out-of-process ACP subagent backend. Each child has its own process, session, model, and
  3. * tools, so it shares no Cordis context and advertises no parent-enforced start capabilities;
  4. * the ONE thing it reads off `request.parent` is the session's workspace cwd (see
  5. * {@link resolveCwd}). This plugin uses named exports only; a default would hide its
  6. * loader metadata (see `docs/postmortem/0001-acp-default-export-drops-inject.md`).
  7. * @module @deepseek-ai/dsh-subagent-acp
  8. */
  9. import { accessSync, constants, statSync } from 'node:fs'
  10. import { isAbsolute, resolve } from 'node:path'
  11. import type { Context } from 'cordis'
  12. import z from 'schemastery'
  13. import type {
  14. ResolvedSubagentStartRequest,
  15. SubagentCapabilities,
  16. SubagentProvider,
  17. SubagentStartRequest,
  18. } from '@deepseek-ai/dsh-subagent'
  19. import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts'
  20. export const name = 'subagent-acp'
  21. export const inject = ['subagents', 'subprocess']
  22. /** Config: how to spawn and drive the child ACP agent process. */
  23. export interface Config {
  24. /** Provider name on `ctx.subagents` (default `acp`). */
  25. providerName: string
  26. /** The executable to spawn for each run (the child ACP agent). */
  27. command: string
  28. /** Arguments passed to {@link command}. */
  29. args: string[]
  30. /**
  31. * Working directory override for the child process and its ACP session.
  32. * Must be non-empty; a relative path resolves against the harness launch
  33. * directory at load, and the result must be an existing directory. When
  34. * omitted, each child inherits its delegating parent session's cwd — and
  35. * starting one from a parent session that has no cwd fails.
  36. */
  37. cwd?: string
  38. /**
  39. * How to auto-answer the child's `session/request_permission` prompts:
  40. * `reject` (default — decline every prompt) or `allow` (approve via the first
  41. * allow-shaped option). The first cut surfaces no prompt to a human.
  42. */
  43. permission: PermissionPolicy
  44. /**
  45. * Extra environment variables for the child process — e.g. the child
  46. * harness's own `DEEPSEEK_API_KEY`. Forwarded on top of a credential-scrubbed
  47. * copy of the parent env, so an explicit key here reaches the child while
  48. * ambient secrets do not leak implicitly.
  49. */
  50. env: Record<string, string>
  51. /**
  52. * Grace period (ms) for the child's EOF-driven quiesce on dispose — its
  53. * window to flush persistence and tear down its own nested subprocesses
  54. * before the parent escalates to a signal.
  55. */
  56. disposeEofGraceMs?: number
  57. /** Termination confirmation window (ms), including forced exit on every platform. */
  58. disposeGraceMs?: number
  59. }
  60. export const Config: z<Config> = z.object({
  61. providerName: z.string().default('acp'),
  62. command: z.string().required(),
  63. args: z.array(z.string()).default([]),
  64. cwd: z.string(),
  65. permission: z.union(['allow', 'reject'] as const).default('reject'),
  66. env: z.dict(z.string()).default({}),
  67. disposeEofGraceMs: z.number().default(DEFAULT_DISPOSE_EOF_GRACE_MS),
  68. disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
  69. })
  70. /** A dispose grace must be a positive finite number (it bounds the teardown wait). */
  71. function assertPositiveFinite(name: string, value: number): void {
  72. if (!Number.isFinite(value) || value <= 0) {
  73. throw new Error(`subagent-acp: ${name} must be a positive finite number`)
  74. }
  75. }
  76. /** The shape after schemastery applied the defaults (cwd has none). */
  77. type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
  78. /**
  79. * Whether `path` names an existing directory the harness can ENTER. The
  80. * search-permission probe matters: `statSync().isDirectory()` is true for a
  81. * mode-600 directory, but a subprocess cwd needs `X_OK` or spawn fails EACCES.
  82. */
  83. function isDirectory(path: string): boolean {
  84. try {
  85. if (!statSync(path).isDirectory()) return false
  86. accessSync(path, constants.X_OK)
  87. return true
  88. } catch {
  89. // statSync/accessSync throw only filesystem access errors here
  90. // (ENOENT/EACCES/ENOTDIR/…), and every one of them means the path cannot
  91. // serve as the child's cwd.
  92. return false
  93. }
  94. }
  95. /**
  96. * Assert `cwd` can actually host the child: absolute (it doubles as the ACP
  97. * session workspace, and a relative path would be re-anchored to the server
  98. * process's launch directory) and an existing directory (fail here, before the
  99. * process boundary, instead of as an ambiguous spawn ENOENT).
  100. * @param label - which source supplied the value, for the diagnostic.
  101. * @param cwd - the candidate working directory.
  102. * @returns `cwd`, validated.
  103. */
  104. function assertUsableCwd(label: string, cwd: string): string {
  105. if (!isAbsolute(cwd)) {
  106. throw new Error(`subagent-acp: ${label} must be an absolute path: ${cwd}`)
  107. }
  108. if (!isDirectory(cwd)) {
  109. throw new Error(`subagent-acp: ${label} is not an accessible directory: ${cwd}`)
  110. }
  111. return cwd
  112. }
  113. /**
  114. * Resolve the child's working directory: the deployment `cwd` override when
  115. * configured (already validated at load), else the parent session's workspace
  116. * cwd (validated here, its earliest resolvable point). Fails loud when neither
  117. * exists — falling back to the harness process cwd would silently bind the
  118. * child to the server's launch directory instead of the delegating session's
  119. * workspace (one server process serves many sessions, each with its own cwd).
  120. */
  121. function resolveCwd(configured: string | undefined, request: SubagentStartRequest): string {
  122. if (configured !== undefined) return configured
  123. const parentCwd = request.parent.session.header.cwd
  124. if (parentCwd === undefined) {
  125. throw new Error('subagent-acp: no working directory for the child — configure `cwd` or delegate from a parent session that has one')
  126. }
  127. return assertUsableCwd('parent session cwd', parentCwd)
  128. }
  129. /**
  130. * The ACP provider. Advertises NO start-time capabilities: an out-of-process
  131. * child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects
  132. * a request needing any of them before `start` runs).
  133. */
  134. class AcpProvider implements SubagentProvider {
  135. readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
  136. // Context contract: an out-of-process ACP child starts fresh — no parent conversation crosses the process boundary.
  137. readonly inheritsParentContext = false
  138. constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {}
  139. start(request: ResolvedSubagentStartRequest) {
  140. const spec: AcpRunSpec = {
  141. command: this.config.command,
  142. args: this.config.args,
  143. cwd: resolveCwd(this.config.cwd, request),
  144. permission: this.config.permission,
  145. env: this.config.env,
  146. disposeEofGraceMs: this.config.disposeEofGraceMs,
  147. disposeGraceMs: this.config.disposeGraceMs,
  148. spawn: spec => this.ctx.subprocess.spawn(spec),
  149. onError: (error, stopReason) => {
  150. // The seam forbids `result` rejecting, so a child-level failure is
  151. // flattened to a stop reason — preserve it here rather than losing it.
  152. this.ctx.logger.warn(`subagent-acp "${this.name}": child run failed (${stopReason}): ${error.message}`)
  153. },
  154. }
  155. return startAcpRun(request, spec)
  156. }
  157. }
  158. export function apply(ctx: Context, config: Config): void {
  159. // schemastery (Config) has already filled every defaulted field.
  160. const resolved = config as ResolvedConfig
  161. assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs)
  162. assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs)
  163. // `path.resolve('')` is the process cwd — an empty string would silently
  164. // reintroduce the launch-directory fallback this resolution removed.
  165. if (resolved.cwd === '') {
  166. throw new Error('subagent-acp: config cwd must not be empty — omit the key to inherit the parent session cwd')
  167. }
  168. // Interpret a relative configured cwd against the harness launch directory
  169. // ONCE, at load, and fail a misconfigured directory here — not per start.
  170. const validated: ResolvedConfig = resolved.cwd === undefined
  171. ? resolved
  172. : { ...resolved, cwd: assertUsableCwd('config cwd', resolve(resolved.cwd)) }
  173. ctx.subagents.registerProvider(new AcpProvider(validated.providerName, ctx, validated))
  174. }