@xingular/harness 0.1.6 → 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 (131) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +139 -28
  4. package/dist/cli.js +9598 -2201
  5. package/dist/terminal-pi.js +808 -16
  6. package/docs/README.md +15 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  9. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  10. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  11. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  12. package/docs/terminal-tui.md +344 -0
  13. package/package.json +11 -4
  14. package/src/accounts/accounts.test.ts +197 -1
  15. package/src/accounts/accounts.ts +266 -28
  16. package/src/cli.ts +7 -3
  17. package/src/core/bus.test.ts +22 -0
  18. package/src/core/bus.ts +17 -2
  19. package/src/harness.ts +27 -4
  20. package/src/hub/catalog.json +123 -0
  21. package/src/hub/cli.ts +59 -0
  22. package/src/hub/hub.test.ts +205 -0
  23. package/src/hub/hub.ts +256 -0
  24. package/src/hub/schema.ts +81 -0
  25. package/src/index.ts +12 -2
  26. package/src/mcp/connections.ts +39 -12
  27. package/src/mcp/service.test.ts +49 -3
  28. package/src/mcp/service.ts +14 -3
  29. package/src/mcp/types.ts +10 -0
  30. package/src/memory/api.ts +16 -2
  31. package/src/memory/blocks.ts +3 -1
  32. package/src/memory/caller.ts +13 -1
  33. package/src/memory/curation.test.ts +206 -0
  34. package/src/memory/curation.ts +64 -0
  35. package/src/memory/dispatch.ts +5 -0
  36. package/src/memory/maintenance.test.ts +17 -1
  37. package/src/memory/maintenance.ts +367 -18
  38. package/src/memory/operations.ts +12 -0
  39. package/src/memory/proposals.ts +16 -8
  40. package/src/memory/recall.test.ts +11 -0
  41. package/src/memory/recall.ts +11 -3
  42. package/src/memory/trajectory.test.ts +178 -0
  43. package/src/memory/trajectory.ts +220 -0
  44. package/src/memory/wiki.ts +2 -1
  45. package/src/memory/wikiIndex.ts +3 -2
  46. package/src/plugins/builtin.ts +4 -2
  47. package/src/plugins/catalogue.test.ts +1 -1
  48. package/src/server/methods.ts +43 -2
  49. package/src/server/server.test.ts +20 -1
  50. package/src/sessions/approvals.test.ts +49 -0
  51. package/src/sessions/approvals.ts +26 -4
  52. package/src/sessions/contracts.ts +29 -3
  53. package/src/sessions/conversation.test.ts +83 -0
  54. package/src/sessions/lineageJoin.ts +1 -1
  55. package/src/sessions/policy.ts +4 -0
  56. package/src/sessions/questionBatches.test.ts +110 -0
  57. package/src/sessions/registry.ts +145 -27
  58. package/src/sessions/runStore.ts +14 -0
  59. package/src/sessions/runner.ts +45 -4
  60. package/src/sessions/sessions.test.ts +137 -4
  61. package/src/sessions/sessions.ts +51 -15
  62. package/src/sessions/subagentService.test.ts +159 -0
  63. package/src/sessions/subagentService.ts +236 -0
  64. package/src/sessions/subagents.ts +97 -0
  65. package/src/skills/registry.ts +16 -10
  66. package/src/terminal/accountsMenu.test.ts +225 -0
  67. package/src/terminal/accountsMenu.ts +382 -0
  68. package/src/terminal/control.test.ts +23 -0
  69. package/src/terminal/control.ts +23 -0
  70. package/src/terminal/controller.ts +134 -7
  71. package/src/terminal/hubMenu.ts +24 -0
  72. package/src/terminal/launch.test.ts +5 -14
  73. package/src/terminal/launch.ts +2 -42
  74. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  75. package/src/terminal/piExtension.ts +91 -6
  76. package/src/terminal/socket.test.ts +23 -0
  77. package/src/terminal/socket.ts +55 -20
  78. package/src/terminal/standalone.test.ts +37 -0
  79. package/src/terminal/standalone.ts +28 -7
  80. package/src/terminal/terminal.test.ts +220 -16
  81. package/src/terminal/terminals.ts +234 -16
  82. package/src/terminal/trajectory.test.ts +66 -0
  83. package/src/terminal/trajectory.ts +44 -0
  84. package/src/terminal/tui/application.test.ts +290 -0
  85. package/src/terminal/tui/application.ts +653 -0
  86. package/src/terminal/tui/commands.test.ts +278 -0
  87. package/src/terminal/tui/commands.ts +443 -0
  88. package/src/terminal/tui/dialogs.ts +301 -0
  89. package/src/terminal/tui/editor.test.ts +113 -0
  90. package/src/terminal/tui/editor.ts +109 -0
  91. package/src/terminal/tui/keybindings.test.ts +30 -0
  92. package/src/terminal/tui/keybindings.ts +59 -0
  93. package/src/terminal/tui/piAdapter.ts +62 -0
  94. package/src/terminal/tui/planMode.test.ts +10 -0
  95. package/src/terminal/tui/planMode.ts +12 -0
  96. package/src/terminal/tui/presentation.test.ts +79 -0
  97. package/src/terminal/tui/questions.test.ts +41 -0
  98. package/src/terminal/tui/questions.ts +47 -0
  99. package/src/terminal/tui/runtime.socket.test.ts +178 -0
  100. package/src/terminal/tui/runtime.test.ts +225 -0
  101. package/src/terminal/tui/runtime.ts +403 -0
  102. package/src/terminal/tui/screen.test.ts +143 -0
  103. package/src/terminal/tui/screen.ts +121 -0
  104. package/src/terminal/tui/sessionUi.ts +24 -0
  105. package/src/terminal/tui/settings.test.ts +150 -0
  106. package/src/terminal/tui/settings.ts +188 -0
  107. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  108. package/src/terminal/tui/skillInvocation.ts +51 -0
  109. package/src/terminal/tui/status.test.ts +48 -0
  110. package/src/terminal/tui/status.ts +82 -0
  111. package/src/terminal/tui/theme.ts +185 -0
  112. package/src/terminal/tui/tools.test.ts +145 -0
  113. package/src/terminal/tui/tools.ts +142 -0
  114. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  115. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  116. package/src/terminal/tui/transcript.test.ts +39 -0
  117. package/src/terminal/tui/transcript.ts +135 -0
  118. package/src/terminal/tui/ui.test.ts +313 -0
  119. package/src/terminal/tui/ui.ts +166 -0
  120. package/src/terminal/tui/welcome.ts +102 -0
  121. package/src/terminal/tui/workingStatus.test.ts +44 -0
  122. package/src/terminal/tui/workingStatus.ts +238 -0
  123. package/src/tools/askUser.test.ts +69 -0
  124. package/src/tools/askUser.ts +31 -17
  125. package/src/tools/definitions.ts +42 -1
  126. package/src/tools/maintenance.ts +29 -0
  127. package/src/tools/memoryTools.ts +1 -1
  128. package/src/tools/registryWrite.test.ts +101 -0
  129. package/src/tools/registryWrite.ts +182 -0
  130. package/src/tools/spawnSubagent.ts +47 -0
  131. package/src/tools/types.ts +2 -0
@@ -21,10 +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 { subagentTools } from '../tools/spawnSubagent'
24
25
  import { WIKI_TOOL_NAMES } from '../tools/definitions'
25
26
  import { memoryTools } from '../tools/memoryTools'
26
27
  import type { ToolProvider, ToolSession, ToolSpec } from '../tools/types'
27
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'
28
31
  import { selectHistory } from './history'
29
32
  import { runLineageJoin } from './lineageJoin'
30
33
  import type { ToolPolicy } from './policy'
@@ -66,6 +69,10 @@ export interface SessionRunInput {
66
69
  capabilities?: CapabilitySet
67
70
  /** Hops from the user to this session (0 when the user started it). */
68
71
  depth?: number
72
+ /** Interactive default: native child tools. Null disables; unattended callers must opt in. */
73
+ subagents?: SubagentOptions | null
74
+ /** Ask the application's tool providers for tools (the default). False runs with the harness's own tools only. */
75
+ providers?: boolean
69
76
  }
70
77
 
71
78
  /** The connected-apps broker, as a session sees it. */
@@ -75,6 +82,7 @@ export interface SessionBroker {
75
82
  }
76
83
 
77
84
  export interface SessionsOptions {
85
+ subagents: Subagents
78
86
  env: DispatchEnv
79
87
  accounts: Accounts
80
88
  runs: RunRegistry
@@ -100,6 +108,7 @@ export class Sessions {
100
108
 
101
109
  constructor(private readonly options: SessionsOptions) {
102
110
  this.factory = options.factory ?? piSessionFactory
111
+
103
112
  }
104
113
 
105
114
  private get env(): HarnessEnv {
@@ -126,9 +135,15 @@ export class Sessions {
126
135
  } catch (error) {
127
136
  return error instanceof Error ? error.message : 'Tool unavailable in the app settings.'
128
137
  }
129
- if (scope.allowed && (scope.automatic || !gated.has(tool))) return null
130
- // A call outside the scope is approved one at a time; a gated tool may be remembered.
131
- const decision = await run.requestApproval({ target: tool, action: summarize(args), operation: { tool, args: (args && typeof args === 'object' ? args : {}) as Record<string, unknown> }, rememberable: scope.allowed })
138
+ if (scope.allowed && !scope.humanOnly && (scope.automatic || !gated.has(tool))) return null
139
+ // A call outside the scope is approved one at a time; a gated tool may be remembered, unless only a person may decide it.
140
+ const decision = await run.requestApproval({
141
+ target: tool,
142
+ action: summarize(args),
143
+ operation: { tool, args: (args && typeof args === 'object' ? args : {}) as Record<string, unknown> },
144
+ rememberable: scope.allowed && !scope.humanOnly,
145
+ standing: scope.humanOnly ? (scope.allowGlobalApproval ? 'switch' : 'none') : undefined
146
+ })
132
147
  if (decision !== 'approved') return 'The user denied this action. Ask how they would like to proceed.'
133
148
  try {
134
149
  await policy.authorize?.(call)
@@ -140,6 +155,11 @@ export class Sessions {
140
155
  }
141
156
  }
142
157
 
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)
161
+ }
162
+
143
163
  /**
144
164
  * Starts one turn. Returns once the Pi session is open; the reply arrives as run events. A
145
165
  * session that cannot be opened ends its run as failed and returns no native session id.
@@ -147,18 +167,22 @@ export class Sessions {
147
167
  async run(input: SessionRunInput): Promise<{ runId: string; nativeSessionId: string | null }> {
148
168
  const { root } = this.env
149
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)
150
171
  const historyFile = selectHistory({ sessionDir: input.sessionDir, resume: input.resume })
151
172
  const account = this.options.accounts.resolve(input.accountId, input.poolId)
152
173
  const context = this.options.accounts.context(account)
153
174
  const runId = input.run.id
154
- const caller: CallerContext = { ...input.caller, runId, sessionId: input.caller.sessionId ?? input.run.sessionId }
175
+ const caller: CallerContext = { ...input.caller, maintenanceJobIds: input.caller.maintenanceJobIds?.slice(), runId, sessionId: input.caller.sessionId ?? input.run.sessionId }
155
176
  const broker = input.connections ? this.options.broker : null
177
+ const depth = input.depth ?? 0
178
+ const canSpawn = input.subagents !== null && (!input.automatic || input.subagents !== undefined) && subagentNesting(root, input.run) < subagentDepth(input.subagents ?? undefined)
156
179
  const requestedTools = [...new Set([
157
- ...input.tools.allow,
180
+ ...input.tools.allow.filter((name) => name !== SPAWN_SUBAGENT && name !== WAIT_SUBAGENTS),
158
181
  ASK_USER,
159
182
  ...(input.automatic ? [] : [PLUGIN_PROPOSE]),
160
183
  ...WIKI_TOOL_NAMES,
161
- ...(broker?.toolNames ?? [])
184
+ ...(broker?.toolNames ?? []),
185
+ ...(canSpawn ? [SPAWN_SUBAGENT, WAIT_SUBAGENTS] : [])
162
186
  ])]
163
187
  const available = this.options.resolveCapabilities
164
188
  ? await this.options.resolveCapabilities(caller, requestedTools, input.cwd)
@@ -232,20 +256,22 @@ export class Sessions {
232
256
  if (run.finished) return { runId, nativeSessionId: null }
233
257
  // What every session has, after the tools the caller switched on and in a fixed order.
234
258
  const allow = capabilities.tools
235
- const session: ToolSession = { run, caller, allow, depth: input.depth ?? 0, cwd: input.cwd, capabilities }
259
+ const session: ToolSession = { run, caller, allow, depth, cwd: input.cwd, capabilities }
236
260
  const { plugins } = this.options
237
261
 
238
- let pi: PiSessionLike
262
+ let pi: PiSessionLike | undefined
239
263
  try {
240
264
  const tools: ToolSpec[] = [
241
265
  ...(broker ? broker.tools(session, { readOnly: typeof input.connections === 'object' && input.connections.readOnly === true }) : []),
242
266
  ...memoryTools(this.options.env, caller, capabilities.contributions),
243
267
  askUserTool(run),
244
- pluginProposeTool(this.options.env, caller)
268
+ pluginProposeTool(this.options.env, caller),
269
+ ...subagentTools(this.options.subagents, runId).filter(tool => allow.includes(tool.name))
245
270
  ]
246
- for (const provider of this.providers) tools.push(...(await provider(session)))
271
+ if (input.providers !== false) for (const provider of this.providers) tools.push(...(await provider(session)))
247
272
  const params: TurnParams = {
248
273
  runId,
274
+ restrictTools: input.run.relation === 'subagent',
249
275
  cwd: input.cwd,
250
276
  sessionDir: input.sessionDir,
251
277
  historyFile,
@@ -258,7 +284,7 @@ export class Sessions {
258
284
  automatic: input.automatic,
259
285
  account: context,
260
286
  resources: registry.resources(root, caller.projectId ?? null, caller.agentId ?? null, capabilities.contributions),
261
- inline: [...firstPartyFor(root, capabilities.contributions), runLineageJoin(root, runId)],
287
+ inline: [...firstPartyFor(root, capabilities.contributions), runLineageJoin(root, runId), this.options.subagents.extension(runId)],
262
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) } } : {}),
263
289
  system,
264
290
  update
@@ -266,25 +292,35 @@ export class Sessions {
266
292
  pi = await this.factory({
267
293
  params,
268
294
  allow,
269
- 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
+ })(),
270
299
  tools,
271
300
  runtime: () => this.options.accounts.runtime(context),
272
301
  // An unattended run loads no extension, so it has nobody to relay for.
273
302
  ...(input.automatic ? {} : { extensions: { ask: (question) => run.requestAnswer(question), notice: (notice) => run.emit('extension.notice', notice) } })
274
303
  })
275
- if ((input.requiresImageInput || input.images?.length) && !pi.model?.input.includes('image')) {
304
+ if (run.finished) {
276
305
  pi.dispose()
306
+ return { runId, nativeSessionId: null }
307
+ }
308
+ if ((input.requiresImageInput || input.images?.length) && !pi.model?.input.includes('image')) {
277
309
  throw new HarnessError('image_input_unsupported', 'Choose a model that supports images, or remove the image attachments and send again.')
278
310
  }
279
311
  if (input.resume?.nativeSessionId && pi.sessionId !== input.resume.nativeSessionId) {
280
- pi.dispose()
281
312
  run.finish('failed', { code: 'native_resume_unavailable', message: 'The saved conversation could not be restored.' })
282
313
  throw new HarnessError('native_resume_unavailable', 'The saved conversation could not be restored.')
283
314
  }
284
- 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)
285
320
  void runTurn(run, pi, params)
286
321
  return { runId, nativeSessionId: pi.sessionId }
287
322
  } catch (error) {
323
+ pi?.dispose()
288
324
  if (error instanceof HarnessError && error.code === 'native_resume_unavailable') throw error
289
325
  const message = error instanceof Error ? error.message : String(error)
290
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
+ }