@xingular/harness 0.1.7 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +29 -10
  3. package/dist/cli.js +5179 -2976
  4. package/dist/terminal-pi.js +365 -24
  5. package/docs/README.md +3 -0
  6. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  7. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  8. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  9. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  10. package/docs/terminal-tui.md +100 -10
  11. package/package.json +5 -4
  12. package/src/accounts/accounts.test.ts +87 -1
  13. package/src/accounts/accounts.ts +120 -18
  14. package/src/core/bus.test.ts +22 -0
  15. package/src/core/bus.ts +17 -2
  16. package/src/harness.ts +12 -2
  17. package/src/index.ts +7 -2
  18. package/src/mcp/connections.ts +39 -12
  19. package/src/mcp/service.test.ts +49 -3
  20. package/src/mcp/service.ts +14 -3
  21. package/src/mcp/types.ts +10 -0
  22. package/src/memory/curation.test.ts +44 -1
  23. package/src/memory/dispatch.ts +5 -0
  24. package/src/memory/maintenance.test.ts +17 -1
  25. package/src/memory/maintenance.ts +169 -8
  26. package/src/memory/recall.ts +2 -1
  27. package/src/memory/trajectory.test.ts +178 -0
  28. package/src/memory/trajectory.ts +220 -0
  29. package/src/memory/wiki.ts +2 -1
  30. package/src/memory/wikiIndex.ts +3 -2
  31. package/src/plugins/builtin.ts +4 -2
  32. package/src/plugins/catalogue.test.ts +1 -1
  33. package/src/server/methods.ts +6 -0
  34. package/src/server/server.test.ts +20 -1
  35. package/src/sessions/approvals.test.ts +49 -0
  36. package/src/sessions/approvals.ts +26 -4
  37. package/src/sessions/conversation.test.ts +56 -0
  38. package/src/sessions/lineageJoin.ts +1 -1
  39. package/src/sessions/registry.ts +38 -7
  40. package/src/sessions/runStore.ts +14 -0
  41. package/src/sessions/runner.ts +9 -3
  42. package/src/sessions/sessions.test.ts +46 -7
  43. package/src/sessions/sessions.ts +31 -39
  44. package/src/sessions/subagentService.test.ts +159 -0
  45. package/src/sessions/subagentService.ts +236 -0
  46. package/src/sessions/subagents.ts +97 -0
  47. package/src/terminal/controller.ts +73 -10
  48. package/src/terminal/piExtension.ts +42 -6
  49. package/src/terminal/socket.test.ts +23 -0
  50. package/src/terminal/socket.ts +42 -18
  51. package/src/terminal/standalone.ts +12 -0
  52. package/src/terminal/terminal.test.ts +150 -15
  53. package/src/terminal/terminals.ts +113 -5
  54. package/src/terminal/trajectory.test.ts +66 -0
  55. package/src/terminal/trajectory.ts +44 -0
  56. package/src/terminal/tui/application.test.ts +184 -8
  57. package/src/terminal/tui/application.ts +243 -81
  58. package/src/terminal/tui/commands.test.ts +69 -9
  59. package/src/terminal/tui/commands.ts +89 -7
  60. package/src/terminal/tui/editor.test.ts +56 -27
  61. package/src/terminal/tui/editor.ts +49 -19
  62. package/src/terminal/tui/keybindings.test.ts +8 -0
  63. package/src/terminal/tui/keybindings.ts +10 -1
  64. package/src/terminal/tui/planMode.test.ts +10 -0
  65. package/src/terminal/tui/planMode.ts +12 -0
  66. package/src/terminal/tui/runtime.socket.test.ts +77 -3
  67. package/src/terminal/tui/runtime.test.ts +10 -0
  68. package/src/terminal/tui/runtime.ts +24 -9
  69. package/src/terminal/tui/screen.test.ts +143 -0
  70. package/src/terminal/tui/screen.ts +121 -0
  71. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  72. package/src/terminal/tui/skillInvocation.ts +51 -0
  73. package/src/terminal/tui/status.test.ts +24 -46
  74. package/src/terminal/tui/status.ts +42 -69
  75. package/src/terminal/tui/theme.ts +18 -7
  76. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  77. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  78. package/src/terminal/tui/transcript.test.ts +14 -0
  79. package/src/terminal/tui/transcript.ts +11 -0
  80. package/src/terminal/tui/ui.ts +2 -2
  81. package/src/terminal/tui/welcome.ts +4 -4
  82. package/src/terminal/tui/workingStatus.test.ts +44 -0
  83. package/src/terminal/tui/workingStatus.ts +238 -0
  84. package/src/tools/definitions.ts +42 -1
  85. package/src/tools/maintenance.ts +6 -1
  86. package/src/tools/spawnSubagent.ts +40 -85
  87. package/src/tools/types.ts +2 -0
  88. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  89. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  90. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  91. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  92. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  93. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  94. package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
  95. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  96. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  97. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  98. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  99. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  100. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  101. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  102. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  103. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  104. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  105. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  106. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  107. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
  108. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  109. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  110. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  111. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  112. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
  113. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  114. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
  115. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
  116. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  117. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  118. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
  119. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
  120. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  121. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
  122. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  123. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  124. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
  125. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  126. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
  127. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
  128. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
  129. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
  130. package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
@@ -21,11 +21,13 @@ import { FIRST_PARTY_PLUGINS, firstPartyExtensions, type InlineExtension } from
21
21
  import * as registry from '../skills/registry'
22
22
  import { ASK_USER, askUserTool } from '../tools/askUser'
23
23
  import { PLUGIN_PROPOSE, pluginProposeTool } from '../tools/pluginPropose'
24
- import { SPAWN_SUBAGENT, SUBAGENT_RELATION, spawnSubagentTool, subagentDepth, type SubagentOptions } from '../tools/spawnSubagent'
24
+ import { subagentTools } from '../tools/spawnSubagent'
25
25
  import { WIKI_TOOL_NAMES } from '../tools/definitions'
26
26
  import { memoryTools } from '../tools/memoryTools'
27
27
  import type { ToolProvider, ToolSession, ToolSpec } from '../tools/types'
28
28
  import type { RunRecord } from './contracts'
29
+ import { SPAWN_SUBAGENT, WAIT_SUBAGENTS, subagentDepth, subagentHistory, subagentNesting, type SubagentInput, type SubagentOptions } from './subagents'
30
+ import type { Subagents } from './subagentService'
29
31
  import { selectHistory } from './history'
30
32
  import { runLineageJoin } from './lineageJoin'
31
33
  import type { ToolPolicy } from './policy'
@@ -67,7 +69,7 @@ export interface SessionRunInput {
67
69
  capabilities?: CapabilitySet
68
70
  /** Hops from the user to this session (0 when the user started it). */
69
71
  depth?: number
70
- /** Offer `spawn_subagent`: the model may start child runs of this caller, up to this depth. */
72
+ /** Interactive default: native child tools. Null disables; unattended callers must opt in. */
71
73
  subagents?: SubagentOptions | null
72
74
  /** Ask the application's tool providers for tools (the default). False runs with the harness's own tools only. */
73
75
  providers?: boolean
@@ -80,6 +82,7 @@ export interface SessionBroker {
80
82
  }
81
83
 
82
84
  export interface SessionsOptions {
85
+ subagents: Subagents
83
86
  env: DispatchEnv
84
87
  accounts: Accounts
85
88
  runs: RunRegistry
@@ -105,6 +108,7 @@ export class Sessions {
105
108
 
106
109
  constructor(private readonly options: SessionsOptions) {
107
110
  this.factory = options.factory ?? piSessionFactory
111
+
108
112
  }
109
113
 
110
114
  private get env(): HarnessEnv {
@@ -151,33 +155,9 @@ export class Sessions {
151
155
  }
152
156
  }
153
157
 
154
- /** `spawn_subagent` for a run: each child is this caller again, one hop deeper, with no more tools than the parent. */
155
- private subagentTool(input: SessionRunInput, runId: string, accountId: string, parent: CapabilitySet): ToolSpec {
156
- const parentTools = parent.tools.filter((name) => name !== SPAWN_SUBAGENT)
157
- return spawnSubagentTool({
158
- root: this.env.root,
159
- parentRunId: runId,
160
- parentSessionId: input.run.sessionId,
161
- parentTools,
162
- sessionDir: input.sessionDir,
163
- start: (child) => this.run({
164
- ...input,
165
- run: { ...input.run, id: child.runId, sessionId: child.sessionId, parentRunId: runId, relation: SUBAGENT_RELATION, goal: takeChars(child.brief, 4000), resumedFromRunId: null, handoffId: null },
166
- accountId,
167
- poolId: null,
168
- sessionDir: child.sessionDir,
169
- resume: null,
170
- text: child.brief,
171
- images: undefined,
172
- requiresImageInput: false,
173
- preamble: null,
174
- caller: { ...input.caller, runId: undefined, sessionId: child.sessionId },
175
- tools: { allow: [...child.tools, SPAWN_SUBAGENT], gated: (input.tools.gated ?? []).filter((name) => child.tools.includes(name)) },
176
- // Never wider than the parent's frozen set; the session intersects it with what is available now.
177
- capabilities: { ...parent, tools: [...parent.tools.filter((name) => child.tools.includes(name)), SPAWN_SUBAGENT] },
178
- depth: (input.depth ?? 0) + 1
179
- })
180
- })
158
+ /** Compatibility alias for the shared native service. */
159
+ spawnSubagent(request: SubagentInput): Promise<{ runId: string; nativeSessionId: string | null }> {
160
+ return this.options.subagents.spawn(request)
181
161
  }
182
162
 
183
163
  /**
@@ -187,6 +167,7 @@ export class Sessions {
187
167
  async run(input: SessionRunInput): Promise<{ runId: string; nativeSessionId: string | null }> {
188
168
  const { root } = this.env
189
169
  if (!input?.run?.id || !input.run.sessionId || typeof input.text !== 'string') throw invalid('A session turn needs a run id, a session, and text.')
170
+ input = structuredClone(input)
190
171
  const historyFile = selectHistory({ sessionDir: input.sessionDir, resume: input.resume })
191
172
  const account = this.options.accounts.resolve(input.accountId, input.poolId)
192
173
  const context = this.options.accounts.context(account)
@@ -194,14 +175,14 @@ export class Sessions {
194
175
  const caller: CallerContext = { ...input.caller, maintenanceJobIds: input.caller.maintenanceJobIds?.slice(), runId, sessionId: input.caller.sessionId ?? input.run.sessionId }
195
176
  const broker = input.connections ? this.options.broker : null
196
177
  const depth = input.depth ?? 0
197
- const canSpawn = Boolean(input.subagents) && depth < subagentDepth(input.subagents ?? undefined)
178
+ const canSpawn = input.subagents !== null && (!input.automatic || input.subagents !== undefined) && subagentNesting(root, input.run) < subagentDepth(input.subagents ?? undefined)
198
179
  const requestedTools = [...new Set([
199
- ...input.tools.allow.filter((name) => name !== SPAWN_SUBAGENT),
180
+ ...input.tools.allow.filter((name) => name !== SPAWN_SUBAGENT && name !== WAIT_SUBAGENTS),
200
181
  ASK_USER,
201
182
  ...(input.automatic ? [] : [PLUGIN_PROPOSE]),
202
183
  ...WIKI_TOOL_NAMES,
203
184
  ...(broker?.toolNames ?? []),
204
- ...(canSpawn ? [SPAWN_SUBAGENT] : [])
185
+ ...(canSpawn ? [SPAWN_SUBAGENT, WAIT_SUBAGENTS] : [])
205
186
  ])]
206
187
  const available = this.options.resolveCapabilities
207
188
  ? await this.options.resolveCapabilities(caller, requestedTools, input.cwd)
@@ -278,18 +259,19 @@ export class Sessions {
278
259
  const session: ToolSession = { run, caller, allow, depth, cwd: input.cwd, capabilities }
279
260
  const { plugins } = this.options
280
261
 
281
- let pi: PiSessionLike
262
+ let pi: PiSessionLike | undefined
282
263
  try {
283
264
  const tools: ToolSpec[] = [
284
265
  ...(broker ? broker.tools(session, { readOnly: typeof input.connections === 'object' && input.connections.readOnly === true }) : []),
285
266
  ...memoryTools(this.options.env, caller, capabilities.contributions),
286
267
  askUserTool(run),
287
268
  pluginProposeTool(this.options.env, caller),
288
- ...(allow.includes(SPAWN_SUBAGENT) ? [this.subagentTool(input, runId, account.id, capabilities)] : [])
269
+ ...subagentTools(this.options.subagents, runId).filter(tool => allow.includes(tool.name))
289
270
  ]
290
271
  if (input.providers !== false) for (const provider of this.providers) tools.push(...(await provider(session)))
291
272
  const params: TurnParams = {
292
273
  runId,
274
+ restrictTools: input.run.relation === 'subagent',
293
275
  cwd: input.cwd,
294
276
  sessionDir: input.sessionDir,
295
277
  historyFile,
@@ -302,7 +284,7 @@ export class Sessions {
302
284
  automatic: input.automatic,
303
285
  account: context,
304
286
  resources: registry.resources(root, caller.projectId ?? null, caller.agentId ?? null, capabilities.contributions),
305
- inline: [...firstPartyFor(root, capabilities.contributions), runLineageJoin(root, runId)],
287
+ inline: [...firstPartyFor(root, capabilities.contributions), runLineageJoin(root, runId), this.options.subagents.extension(runId)],
306
288
  ...(plugins ? { plugins: { loading: () => plugins.loading({ cwd: input.cwd, agentDir: context.configDir, install: true, caller: { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null }, contributions: capabilities.contributions }), loaded: (extensions) => plugins.loaded(extensions) } } : {}),
307
289
  system,
308
290
  update
@@ -310,25 +292,35 @@ export class Sessions {
310
292
  pi = await this.factory({
311
293
  params,
312
294
  allow,
313
- gate: this.gate(run, caller, new Set((input.tools.gated ?? []).filter((name) => capabilities.tools.includes(name)))),
295
+ gate: (() => {
296
+ const gate = this.gate(run, caller, new Set((input.tools.gated ?? []).filter((name) => capabilities.tools.includes(name))))
297
+ return { ...gate, check: (name: string, args: unknown) => input.run.relation === 'subagent' && !capabilities.tools.includes(name) ? Promise.resolve('This tool is outside the child capability snapshot.') : gate.check(name, args) }
298
+ })(),
314
299
  tools,
315
300
  runtime: () => this.options.accounts.runtime(context),
316
301
  // An unattended run loads no extension, so it has nobody to relay for.
317
302
  ...(input.automatic ? {} : { extensions: { ask: (question) => run.requestAnswer(question), notice: (notice) => run.emit('extension.notice', notice) } })
318
303
  })
319
- if ((input.requiresImageInput || input.images?.length) && !pi.model?.input.includes('image')) {
304
+ if (run.finished) {
320
305
  pi.dispose()
306
+ return { runId, nativeSessionId: null }
307
+ }
308
+ if ((input.requiresImageInput || input.images?.length) && !pi.model?.input.includes('image')) {
321
309
  throw new HarnessError('image_input_unsupported', 'Choose a model that supports images, or remove the image attachments and send again.')
322
310
  }
323
311
  if (input.resume?.nativeSessionId && pi.sessionId !== input.resume.nativeSessionId) {
324
- pi.dispose()
325
312
  run.finish('failed', { code: 'native_resume_unavailable', message: 'The saved conversation could not be restored.' })
326
313
  throw new HarnessError('native_resume_unavailable', 'The saved conversation could not be restored.')
327
314
  }
328
- updateRun(root, runId, (record) => void (record.nativeSessionId = pi.sessionId))
315
+ const opened = pi
316
+ updateRun(root, runId, (record) => void (record.nativeSessionId = opened.sessionId))
317
+ this.options.subagents.register(input, account.id, capabilities, Boolean(pi.sessionManager))
318
+ if (pi.model?.id && pi.model.provider) this.options.subagents.resolvedModel(runId, `${pi.model.provider}/${pi.model.id}`, pi.thinkingLevel)
319
+ params.settle = () => this.options.subagents.verify(runId, opened.sessionManager ? subagentHistory(opened.sessionManager.getBranch()) : undefined)
329
320
  void runTurn(run, pi, params)
330
321
  return { runId, nativeSessionId: pi.sessionId }
331
322
  } catch (error) {
323
+ pi?.dispose()
332
324
  if (error instanceof HarnessError && error.code === 'native_resume_unavailable') throw error
333
325
  const message = error instanceof Error ? error.message : String(error)
334
326
  log('session failed to start', message)
@@ -0,0 +1,159 @@
1
+ import { mkdtempSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import { memorySecretStore } from '../accounts/secrets'
7
+ import { createHarness, type Harness } from '../harness'
8
+ import type { SessionRunInput } from './sessions'
9
+ import { SUBAGENT_OWNER, SUBAGENT_RECEIPT, subagentHistory } from './subagents'
10
+ import { loadRun, recordSubagent, saveRun } from './runStore'
11
+
12
+ const open: Harness[] = []
13
+ afterEach(async () => { for (const harness of open.splice(0)) await harness.close() })
14
+
15
+ function setup() {
16
+ const finish = new Map<string, (text: string) => void>()
17
+ const root = mkdtempSync(join(tmpdir(), 'native-subagents-'))
18
+ const harness = createHarness({ dataRoot: join(root, 'data'), stateDir: join(root, 'state'), secrets: memorySecretStore(), sessionFactory: async ({ params }) => {
19
+ let emit: (event: any) => void = () => {}
20
+ let done: () => void = () => {}
21
+ return { sessionId: 'native-history', subscribe: (listener) => { emit = listener; return () => {} }, sendCustomMessage: async () => {},
22
+ prompt: () => new Promise<void>((resolve) => { done = resolve; finish.set(params.runId, (text) => {
23
+ emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text }] } }); resolve()
24
+ }) }), abort: async () => done(), dispose: () => {} }
25
+ } })
26
+ open.push(harness)
27
+ harness.accounts.create('Main', 'apiKey')
28
+ const start = (id: string, extra: Partial<SessionRunInput> = {}) => harness.sessions.run({
29
+ run: { id, sessionId: 'opaque-parent' }, caller: { agentId: 'caller', actor: 'Chat' }, cwd: root,
30
+ sessionDir: join(root, 'history'), text: 'Parent', tools: { allow: ['read'] }, ...extra
31
+ })
32
+ return { harness, finish, start }
33
+ }
34
+ const empty = () => ({ owners: [], receipts: [] })
35
+
36
+ describe('native subagent ownership and delivery', () => {
37
+ it('spawns asynchronously, joins selected children, and rejects foreign handles', async () => {
38
+ const { harness, finish, start } = setup()
39
+ await start('parent')
40
+ const a = await harness.subagents.spawn({ parentRunId: 'parent', brief: 'First', tools: ['read'] })
41
+ const b = await harness.subagents.spawn({ parentRunId: 'parent', brief: 'Second', tools: [] })
42
+ expect(harness.subagents.results({ parentRunId: 'parent' }).map(result => result.status)).toEqual(['running', 'running'])
43
+ await expect(harness.subagents.wait({ parentRunId: 'parent', runIds: ['foreign'] })).rejects.toMatchObject({ code: 'invalid_request' })
44
+ let joined = false
45
+ const pending = harness.subagents.wait({ parentRunId: 'parent', runIds: [b.runId] }).then(results => { joined = true; return results })
46
+ finish.get(a.runId)!('First result')
47
+ await harness.runs.wait(a.runId)
48
+ expect(joined).toBe(false)
49
+ const canceled = new AbortController()
50
+ canceled.abort()
51
+ await expect(harness.subagents.wait({ parentRunId: 'parent', runIds: [b.runId] }, canceled.signal)).rejects.toMatchObject({ code: 'run_not_active' })
52
+ finish.get(b.runId)!('Second result')
53
+ expect(await pending).toMatchObject([{ runId: b.runId, status: 'completed', text: 'Second result' }])
54
+ expect(loadRun(harness.env.root, a.runId).sessionId).not.toContain(':subagent:')
55
+ })
56
+
57
+ it('delivers already-settled results, retries uncommitted drafts, and trusts only typed receipts', async () => {
58
+ const { harness, finish, start } = setup()
59
+ await start('parent')
60
+ const child = await harness.subagents.spawn({ parentRunId: 'parent', brief: 'Find evidence' })
61
+ finish.get(child.runId)!('Evidence')
62
+ await harness.runs.wait(child.runId)
63
+ const mentioned = subagentHistory([{ content: `I mentioned ${child.runId}` }])
64
+ const first = await harness.subagents.delivery('parent', mentioned, true)
65
+ expect(first?.text).toContain('Evidence')
66
+ expect(await harness.subagents.delivery('parent', empty(), true)).toEqual(first)
67
+ const committed = subagentHistory([{ type: 'custom_message', details: { [SUBAGENT_RECEIPT]: first!.receipt } }])
68
+ expect(await harness.subagents.delivery('parent', committed, true)).toBeNull()
69
+ })
70
+
71
+ it('waits before final delivery and releases pending joins when the parent stops', async () => {
72
+ const { harness, start } = setup()
73
+ await start('parent')
74
+ const child = await harness.subagents.spawn({ parentRunId: 'parent', brief: 'Wait' })
75
+ const pending = harness.subagents.delivery('parent', empty(), true)
76
+ const interrupted = expect(pending).rejects.toMatchObject({ code: 'run_not_active' })
77
+ await harness.runs.interrupt('parent')
78
+ await interrupted
79
+ expect(loadRun(harness.env.root, child.runId).status).toBe('interrupted')
80
+ expect(harness.subagents.results({ parentRunId: 'parent' })).toMatchObject([{ runId: child.runId, status: 'interrupted' }])
81
+ })
82
+
83
+ it('recovers a child whose spawn return was lost, only from its owning history and account', async () => {
84
+ const { harness, finish, start } = setup()
85
+ await start('original')
86
+ const child = await harness.subagents.spawn({ parentRunId: 'original', brief: 'Recover' })
87
+ finish.get(child.runId)!('Retained result')
88
+ await harness.runs.wait(child.runId)
89
+ recordSubagent(harness.env.root, 'original', 'reserved-before-crash')
90
+ await harness.runs.interrupt('original')
91
+ await start('resumed')
92
+ expect(await harness.subagents.delivery('resumed', empty(), true)).toBeNull()
93
+ const branch = subagentHistory([{ type: 'custom', customType: SUBAGENT_OWNER, data: { runId: 'original' } }])
94
+ const recovered = await harness.subagents.delivery('resumed', branch, true)
95
+ expect(recovered?.text).toContain('Retained result')
96
+ expect(recovered?.receipt.delivered).toEqual([child.runId, 'reserved-before-crash'])
97
+ expect(loadRun(harness.env.root, 'reserved-before-crash')).toMatchObject({ status: 'interrupted', relation: 'subagent', parentRunId: 'original' })
98
+ await harness.runs.interrupt('resumed')
99
+ const other = harness.accounts.create('Other', 'apiKey').accounts.find(account => account.label === 'Other')!
100
+ await start('wrong-account', { accountId: other.id })
101
+ expect(await harness.subagents.delivery('wrong-account', branch, true)).toBeNull()
102
+ })
103
+
104
+ it('reconstructs disk ownership after reopening without automatically restarting model work', async () => {
105
+ const { harness, finish, start } = setup()
106
+ await start('original')
107
+ const child = await harness.subagents.spawn({ parentRunId: 'original', brief: 'Persisted work' })
108
+ finish.get(child.runId)!('On-disk result')
109
+ await harness.runs.wait(child.runId)
110
+ recordSubagent(harness.env.root, 'original', 'orphan-after-crash')
111
+ const original = loadRun(harness.env.root, 'original')
112
+ saveRun(harness.env.root, { ...original, id: 'orphan-after-crash', sessionId: 'orphan-session', parentRunId: 'original', relation: 'subagent', status: 'running' })
113
+ const dataRoot = harness.env.root
114
+ const stateDir = harness.stateDir
115
+ await harness.close()
116
+ open.splice(open.indexOf(harness), 1)
117
+ let opened = 0
118
+ const restarted = createHarness({ dataRoot, stateDir, secrets: memorySecretStore(), sessionFactory: async () => {
119
+ opened++
120
+ return { sessionId: 'native-history', subscribe: () => () => {}, prompt: async () => new Promise<void>(() => {}), sendCustomMessage: async () => {}, abort: async () => {}, dispose: () => {} }
121
+ } })
122
+ open.push(restarted)
123
+ expect(opened).toBe(0)
124
+ expect(loadRun(dataRoot, 'orphan-after-crash').status).toBe('interrupted')
125
+ await restarted.sessions.run({ run: { id: 'explicit-resume', sessionId: original.sessionId, resumedFromRunId: original.id }, caller: { agentId: 'caller', actor: 'Chat' }, cwd: original.workingDirectory!, sessionDir: join(original.workingDirectory!, 'history'), text: 'Resume', tools: { allow: ['read'] } })
126
+ const delivery = await restarted.subagents.delivery('explicit-resume', { owners: ['original'], receipts: [] }, true)
127
+ expect(delivery?.text).toContain('On-disk result')
128
+ expect(delivery?.receipt.delivered).toEqual([child.runId, 'orphan-after-crash'])
129
+ expect(opened).toBe(1)
130
+ })
131
+
132
+ it('enables interactive callers at a host depth without extending native nesting or unattended access', async () => {
133
+ const { harness, start } = setup()
134
+ await start('host-depth', { depth: 3 })
135
+ const child = await harness.subagents.spawn({ parentRunId: 'host-depth', brief: 'One native level' })
136
+ await expect(harness.subagents.spawn({ parentRunId: child.runId, brief: 'Too deep' })).rejects.toMatchObject({ code: 'invalid_request' })
137
+ await harness.runs.interrupt('host-depth')
138
+ await start('unattended', { automatic: true })
139
+ await expect(harness.subagents.spawn({ parentRunId: 'unattended', brief: 'Not enabled' })).rejects.toMatchObject({ code: 'invalid_request' })
140
+ })
141
+
142
+ it('does not acknowledge results omitted from a bounded delivery batch', async () => {
143
+ const { harness, finish, start } = setup()
144
+ await start('parent')
145
+ const children = await Promise.all([1, 2, 3].map(n => harness.subagents.spawn({ parentRunId: 'parent', brief: String(n) })))
146
+ for (const child of children) finish.get(child.runId)!('x'.repeat(16_000))
147
+ await harness.subagents.wait({ parentRunId: 'parent' })
148
+ const receipts: any[] = []
149
+ const delivered: string[] = []
150
+ for (let i = 0; i < 3; i++) {
151
+ const batch = await harness.subagents.delivery('parent', { owners: [], receipts }, true)
152
+ expect(batch).not.toBeNull()
153
+ delivered.push(...batch!.receipt.delivered!)
154
+ receipts.push(batch!.receipt)
155
+ }
156
+ expect(delivered).toEqual(children.map(child => child.runId))
157
+ expect(await harness.subagents.delivery('parent', { owners: [], receipts }, true)).toBeNull()
158
+ })
159
+ })
@@ -0,0 +1,236 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { join } from 'node:path'
3
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
4
+
5
+ import { loadRun as loadCapabilities } from '../capabilities/runSnapshot'
6
+ import type { CapabilitySet } from '../capabilities/types'
7
+ import type { HarnessEnv } from '../core/env'
8
+ import { HarnessError, invalid } from '../core/errors'
9
+ import { now } from '../core/fs'
10
+ import { takeChars } from '../core/text'
11
+ import type { InlineExtension } from '../plugins/firstParty'
12
+ import type { RunRegistry } from './registry'
13
+ import type { Effort } from './runner'
14
+ import type { SessionRunInput } from './sessions'
15
+ import { isActiveStatus } from './contracts'
16
+ import { findRun, loadRun, readEvents, recordedSubagents, recordSubagent, saveRun } from './runStore'
17
+ import { SPAWN_SUBAGENT, SUBAGENT_RELATION, SUBAGENT_OWNER, SUBAGENT_RECEIPT, SUBAGENT_RESULTS, subagentHistory, type SubagentInput, type SubagentReceipt, type SubagentResult, type SubagentSelection } from './subagents'
18
+
19
+ type Started = { runId: string; nativeSessionId: string | null }
20
+ interface Parent {
21
+ input: SessionRunInput
22
+ accountId: string
23
+ capabilities: CapabilitySet
24
+ stopped: AbortController
25
+ recovered: Set<string>
26
+ launching: Map<string, Promise<Started>>
27
+ ready: Promise<void>
28
+ started: () => void
29
+ }
30
+ export interface SubagentHistory {
31
+ owners: string[]
32
+ receipts: SubagentReceipt[]
33
+ }
34
+
35
+ function until<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
36
+ if (signal.aborted) {
37
+ void promise.catch(() => {})
38
+ return Promise.reject(new HarnessError('run_not_active', 'The parent run stopped.'))
39
+ }
40
+ return new Promise((resolve, reject) => {
41
+ const abort = () => reject(new HarnessError('run_not_active', 'The parent run stopped.'))
42
+ signal.addEventListener('abort', abort, { once: true })
43
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort))
44
+ })
45
+ }
46
+
47
+ /** One owner of native child execution and delivery, shared by SDK and terminal adapters. */
48
+ export class Subagents {
49
+ private readonly parents = new Map<string, Parent>()
50
+
51
+ constructor(private readonly env: HarnessEnv, private readonly runs: RunRegistry, private readonly launch: (input: SessionRunInput) => Promise<Started>) {
52
+ runs.listen((event) => {
53
+ if (event.type !== 'completed' && event.type !== 'failed' && !(event.type === 'status.changed' && (event.payload as { status?: string }).status === 'interrupted')) return
54
+ this.parents.get(event.runId)?.stopped.abort()
55
+ this.parents.delete(event.runId)
56
+ if (event.type !== 'completed') for (const id of runs.activeRunIds()) {
57
+ const child = findRun(this.env.root, id)
58
+ if (child?.relation === SUBAGENT_RELATION && child.parentRunId === event.runId) void runs.interrupt(id).catch(() => {})
59
+ }
60
+ })
61
+ }
62
+
63
+ /** Internal adapter contract: context comes from a live run, never from model arguments. */
64
+ register(input: SessionRunInput, accountId: string, capabilities: CapabilitySet, deferUntilPrompt = false): void {
65
+ if (!this.runs.active(input.run.id)) throw new HarnessError('run_not_active', 'The parent run is not active.')
66
+ if (this.parents.has(input.run.id)) return
67
+ const frozen = loadCapabilities(this.env.root, input.run.id)
68
+ if (!frozen) throw invalid('A native parent requires a frozen capability snapshot.')
69
+ const ceiling: CapabilitySet = {
70
+ version: 1,
71
+ tools: capabilities.tools.filter(name => frozen.tools.includes(name)),
72
+ connectors: capabilities.connectors.filter(id => frozen.connectors.includes(id)),
73
+ contributions: capabilities.contributions.filter(id => frozen.contributions.includes(id))
74
+ }
75
+ let started!: () => void
76
+ const ready = new Promise<void>(resolve => { started = resolve })
77
+ if (!deferUntilPrompt) started()
78
+ this.parents.set(input.run.id, { ready, started, input: structuredClone({ ...input, text: '', images: undefined, requiresImageInput: false, preamble: null, resume: null }), accountId, capabilities: ceiling, stopped: new AbortController(), recovered: new Set(), launching: new Map() })
79
+ }
80
+
81
+ resolvedModel(runId: string, model: string, effort?: Effort): void {
82
+ const parent = this.parents.get(runId)
83
+ if (parent) {
84
+ parent.input.model = model
85
+ parent.input.effort = effort ?? parent.input.effort
86
+ }
87
+ }
88
+
89
+ private parent(runId: string): Parent {
90
+ const parent = this.parents.get(runId)
91
+ if (!parent || !this.runs.active(runId)) throw new HarnessError('run_not_active', 'The parent run is not accepting subagents.')
92
+ return parent
93
+ }
94
+
95
+ async spawn(request: SubagentInput): Promise<Started> {
96
+ const parent = this.parent(request.parentRunId)
97
+ const { input, accountId, capabilities } = parent
98
+ if (!capabilities.tools.includes(SPAWN_SUBAGENT)) throw invalid('Subagents are not enabled for this run.')
99
+ const brief = typeof request.brief === 'string' ? request.brief.trim() : ''
100
+ if (!brief) throw invalid('Give the subagent a brief.')
101
+ if (request.wait !== undefined && typeof request.wait !== 'boolean') throw invalid('wait must be a boolean.')
102
+ if (request.tools !== undefined && (!Array.isArray(request.tools) || request.tools.some((name) => typeof name !== 'string'))) throw invalid('Tools must be an array of tool names.')
103
+ const outside = request.tools?.filter((name) => !capabilities.tools.includes(name)) ?? []
104
+ if (outside.length) throw invalid(`A subagent cannot have tools you do not have: ${outside.join(', ')}.`)
105
+ const tools = capabilities.tools.filter((name) => !request.tools || request.tools.includes(name))
106
+ await until(parent.ready, parent.stopped.signal)
107
+ const runId = randomUUID()
108
+ const sessionId = randomUUID()
109
+ recordSubagent(this.env.root, request.parentRunId, runId)
110
+ const child: SessionRunInput = {
111
+ ...input,
112
+ run: { ...input.run, id: runId, sessionId, parentRunId: request.parentRunId, relation: SUBAGENT_RELATION, goal: takeChars(brief, 4000), attachmentCount: 0, resumedFromRunId: null, handoffId: null },
113
+ accountId, poolId: null, sessionDir: join(input.sessionDir, 'subagents', runId), resume: null,
114
+ text: brief, images: undefined, requiresImageInput: false, preamble: null,
115
+ caller: { ...input.caller, runId: undefined, sessionId },
116
+ tools: { allow: tools, gated: (input.tools.gated ?? []).filter((name) => tools.includes(name)) },
117
+ capabilities: { ...capabilities, tools }, depth: (input.depth ?? 0) + 1
118
+ }
119
+ const started = this.launch(child).catch((error) => {
120
+ if (!findRun(this.env.root, runId)) {
121
+ const source = loadRun(this.env.root, request.parentRunId)
122
+ saveRun(this.env.root, { ...source, id: runId, sessionId, parentRunId: request.parentRunId, relation: SUBAGENT_RELATION, nativeSessionId: null, resumedFromRunId: null, handoffId: null, exitCode: null, goal: takeChars(brief, 4000), attachmentCount: 0, status: 'failed', startedAt: now(), endedAt: now(), terminalReason: error instanceof HarnessError ? error.message : 'Subagent startup failed before its session opened.' })
123
+ }
124
+ return { runId, nativeSessionId: null }
125
+ })
126
+ parent.launching.set(runId, started)
127
+ void started.finally(() => parent.launching.delete(runId))
128
+ const result = await until(started, parent.stopped.signal)
129
+ if (request.wait) await this.wait({ parentRunId: request.parentRunId, runIds: [runId] })
130
+ return result
131
+ }
132
+
133
+ private selected(selection: SubagentSelection, parent?: Parent): string[] {
134
+ const owned = new Set([...recordedSubagents(this.env.root, selection.parentRunId), ...(parent?.recovered ?? [])])
135
+ if (selection.runIds !== undefined && (!Array.isArray(selection.runIds) || selection.runIds.some((id) => typeof id !== 'string'))) throw invalid('runIds must be an array of child run IDs.')
136
+ const ids = [...new Set(selection.runIds ?? [...owned])]
137
+ if (ids.some((id) => !owned.has(id))) throw invalid('Select only subagents owned by this parent.')
138
+ return ids
139
+ }
140
+
141
+ results(selection: SubagentSelection): SubagentResult[] {
142
+ loadRun(this.env.root, selection.parentRunId)
143
+ const parent = this.parents.get(selection.parentRunId)
144
+ return this.selected(selection, parent).map((runId) => {
145
+ const run = findRun(this.env.root, runId)
146
+ const reply = run && readEvents(this.env.root, runId).filter((event) => event.type === 'message.completed' && (event.payload as { role?: unknown }).role === 'assistant').at(-1)
147
+ const payload = reply?.payload as { text?: string } | undefined
148
+ return { runId, status: run?.status ?? 'queued', text: takeChars(payload?.text ?? '', 16_000), terminalReason: run?.terminalReason ?? null }
149
+ })
150
+ }
151
+
152
+ async wait(selection: SubagentSelection, signal?: AbortSignal): Promise<SubagentResult[]> {
153
+ const parent = this.parent(selection.parentRunId)
154
+ const ids = this.selected(selection, parent)
155
+ const abort = signal ? AbortSignal.any([signal, parent.stopped.signal]) : parent.stopped.signal
156
+ await until(Promise.all(ids.map(async (id) => {
157
+ const starting = parent.launching.get(id)
158
+ if (starting) await starting
159
+ const run = findRun(this.env.root, id)
160
+ if (run && isActiveStatus(run.status)) await this.runs.wait(id, abort)
161
+ })), abort)
162
+ return this.results({ ...selection, runIds: ids })
163
+ }
164
+
165
+ /** Reconcile only owners present on the selected native branch, with matching account and caller. */
166
+ restore(parentRunId: string, history: SubagentHistory): void {
167
+ const parent = this.parent(parentRunId)
168
+ const current = loadRun(this.env.root, parentRunId)
169
+ for (const owner of history.owners) {
170
+ if (owner === parentRunId) continue
171
+ const old = findRun(this.env.root, owner)
172
+ if (!old || old.sessionId !== current.sessionId || old.accountId !== current.accountId || old.agentId !== current.agentId || old.projectId !== current.projectId || old.workingDirectory !== current.workingDirectory || !old.nativeSessionId || old.nativeSessionId !== current.nativeSessionId) continue
173
+ for (const id of recordedSubagents(this.env.root, owner)) {
174
+ let child = findRun(this.env.root, id)
175
+ if (!child) {
176
+ // The process can die after reserving ownership but before sessions.run saves the child.
177
+ saveRun(this.env.root, { ...old, id, sessionId: id, parentRunId: owner, relation: SUBAGENT_RELATION, nativeSessionId: null, resumedFromRunId: null, handoffId: null, exitCode: null, goal: null, attachmentCount: 0, status: 'interrupted', startedAt: now(), endedAt: now(), terminalReason: 'Subagent startup was interrupted before its session opened.' })
178
+ child = findRun(this.env.root, id)
179
+ }
180
+ if (child?.parentRunId === owner && child.relation === SUBAGENT_RELATION && child.accountId === current.accountId) parent.recovered.add(id)
181
+ }
182
+ }
183
+ }
184
+
185
+ async delivery(parentRunId: string, history: SubagentHistory, settle: boolean, signal?: AbortSignal): Promise<{ text: string; receipt: SubagentReceipt } | null> {
186
+ const parent = this.parent(parentRunId)
187
+ this.restore(parentRunId, history)
188
+ if (settle) {
189
+ // A host may start another child while earlier children are settling.
190
+ for (;;) {
191
+ await this.wait({ parentRunId }, signal)
192
+ if (!parent.launching.size && this.results({ parentRunId }).every((result) => !isActiveStatus(result.status))) break
193
+ }
194
+ }
195
+ const delivered = new Set(history.receipts.flatMap((receipt) => receipt.delivered ?? []))
196
+ const pending = this.results({ parentRunId }).filter((result) => !isActiveStatus(result.status) && !delivered.has(result.runId))
197
+ const chunks: string[] = []
198
+ const ids: string[] = []
199
+ let size = 0
200
+ for (const result of pending) {
201
+ const text = `Subagent run ${result.runId} · ${result.status}\n${result.text || result.terminalReason || 'No written reply.'}`
202
+ if (size + text.length > 32_000 && ids.length) break
203
+ chunks.push(text)
204
+ ids.push(result.runId)
205
+ size += text.length
206
+ }
207
+ return ids.length ? { text: chunks.join('\n\n'), receipt: { version: 1, ownerRunId: parentRunId, delivered: ids } } : null
208
+ }
209
+
210
+ /** A broken adapter must fail visibly rather than complete with an undelivered child result. */
211
+ async verify(parentRunId: string, history?: SubagentHistory): Promise<void> {
212
+ if (history) this.restore(parentRunId, history)
213
+ await this.wait({ parentRunId })
214
+ if (history && await this.delivery(parentRunId, history, false)) throw new HarnessError('subagent_delivery_failed', 'A child result was not persisted in this conversation. Resume this reply to recover it.')
215
+ }
216
+
217
+ extension(runId: string): InlineExtension {
218
+ return { name: 'native-subagents', factory: (pi: ExtensionAPI) => {
219
+ pi.on('before_agent_start', (_event, ctx) => {
220
+ if (!this.parents.has(runId)) return
221
+ this.restore(runId, subagentHistory(ctx.sessionManager.getBranch()))
222
+ pi.appendEntry(SUBAGENT_OWNER, { runId })
223
+ this.parent(runId).started()
224
+ })
225
+ const deliver = async (event: any, ctx: any, settle: boolean) => {
226
+ if (!this.parents.has(runId) || (settle && event.outcome !== 'completed') || ctx.signal?.aborted) return
227
+ const history = subagentHistory([...ctx.sessionManager.getBranch(), ...event.entries])
228
+ const result = await this.delivery(runId, history, settle, ctx.signal)
229
+ if (!result) return
230
+ return { entries: [...event.entries, { type: 'custom_message' as const, customType: SUBAGENT_RESULTS, content: result.text, display: false, details: { [SUBAGENT_RECEIPT]: result.receipt } }], continue: true }
231
+ }
232
+ pi.on('turn_end', (event, ctx) => deliver(event, ctx, false))
233
+ pi.on('agent_before_settle', (event, ctx) => deliver(event, ctx, true))
234
+ } }
235
+ }
236
+ }