@xingular/harness 0.1.7 → 2.1.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 (135) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +36 -13
  3. package/dist/cli.js +10728 -8033
  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 +70 -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 +109 -13
  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/hub/hub.test.ts +49 -1
  18. package/src/hub/hub.ts +122 -6
  19. package/src/hub/schema.ts +13 -1
  20. package/src/index.ts +7 -2
  21. package/src/mcp/connections.ts +39 -12
  22. package/src/mcp/service.test.ts +49 -3
  23. package/src/mcp/service.ts +14 -3
  24. package/src/mcp/types.ts +10 -0
  25. package/src/memory/curation.test.ts +44 -1
  26. package/src/memory/dispatch.ts +5 -0
  27. package/src/memory/maintenance.test.ts +17 -1
  28. package/src/memory/maintenance.ts +169 -8
  29. package/src/memory/recall.ts +2 -1
  30. package/src/memory/trajectory.test.ts +178 -0
  31. package/src/memory/trajectory.ts +220 -0
  32. package/src/memory/wiki.ts +2 -1
  33. package/src/memory/wikiIndex.ts +3 -2
  34. package/src/plugins/builtin.ts +4 -2
  35. package/src/plugins/catalogue.test.ts +1 -1
  36. package/src/server/methods.ts +8 -0
  37. package/src/server/server.test.ts +20 -1
  38. package/src/sessions/approvals.test.ts +49 -0
  39. package/src/sessions/approvals.ts +26 -4
  40. package/src/sessions/conversation.test.ts +56 -0
  41. package/src/sessions/lineageJoin.ts +1 -1
  42. package/src/sessions/planMode.test.ts +40 -0
  43. package/src/sessions/planMode.ts +57 -0
  44. package/src/sessions/registry.ts +38 -7
  45. package/src/sessions/runStore.ts +14 -0
  46. package/src/sessions/runner.ts +9 -3
  47. package/src/sessions/sessions.test.ts +69 -7
  48. package/src/sessions/sessions.ts +47 -39
  49. package/src/sessions/subagentService.test.ts +159 -0
  50. package/src/sessions/subagentService.ts +236 -0
  51. package/src/sessions/subagents.ts +97 -0
  52. package/src/terminal/controller.ts +73 -10
  53. package/src/terminal/piExtension.ts +42 -6
  54. package/src/terminal/socket.test.ts +23 -0
  55. package/src/terminal/socket.ts +42 -18
  56. package/src/terminal/standalone.ts +12 -0
  57. package/src/terminal/terminal.test.ts +150 -15
  58. package/src/terminal/terminals.ts +113 -5
  59. package/src/terminal/trajectory.test.ts +66 -0
  60. package/src/terminal/trajectory.ts +44 -0
  61. package/src/terminal/tui/application.test.ts +206 -9
  62. package/src/terminal/tui/application.ts +251 -81
  63. package/src/terminal/tui/commands.test.ts +70 -9
  64. package/src/terminal/tui/commands.ts +99 -7
  65. package/src/terminal/tui/editor.test.ts +56 -27
  66. package/src/terminal/tui/editor.ts +64 -19
  67. package/src/terminal/tui/keybindings.test.ts +7 -0
  68. package/src/terminal/tui/keybindings.ts +9 -1
  69. package/src/terminal/tui/planMode.test.ts +10 -0
  70. package/src/terminal/tui/planMode.ts +2 -0
  71. package/src/terminal/tui/runtime.socket.test.ts +77 -3
  72. package/src/terminal/tui/runtime.test.ts +10 -0
  73. package/src/terminal/tui/runtime.ts +24 -9
  74. package/src/terminal/tui/screen.test.ts +143 -0
  75. package/src/terminal/tui/screen.ts +121 -0
  76. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  77. package/src/terminal/tui/skillInvocation.ts +51 -0
  78. package/src/terminal/tui/status.test.ts +24 -46
  79. package/src/terminal/tui/status.ts +42 -69
  80. package/src/terminal/tui/theme.ts +18 -7
  81. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  82. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  83. package/src/terminal/tui/transcript.test.ts +14 -0
  84. package/src/terminal/tui/transcript.ts +11 -0
  85. package/src/terminal/tui/ui.ts +2 -2
  86. package/src/terminal/tui/welcome.ts +4 -4
  87. package/src/terminal/tui/workingStatus.test.ts +44 -0
  88. package/src/terminal/tui/workingStatus.ts +238 -0
  89. package/src/tools/definitions.ts +42 -1
  90. package/src/tools/maintenance.ts +6 -1
  91. package/src/tools/spawnSubagent.ts +40 -85
  92. package/src/tools/types.ts +4 -0
  93. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  94. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  95. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  96. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  97. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  98. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  99. package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
  100. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  101. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  102. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  103. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  104. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  105. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  106. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  107. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  108. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  109. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  110. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  111. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  112. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
  113. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  114. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  115. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  116. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  117. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
  118. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  119. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
  120. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
  121. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  122. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  123. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
  124. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
  125. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  126. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
  127. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  128. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  129. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
  130. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  131. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
  132. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
  133. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
  134. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
  135. package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ import { takeChars } from '../core/text'
2
+ import { findRun, loadRun, readEvents } from './runStore'
3
+
4
+ export const SPAWN_SUBAGENT = 'spawn_subagent'
5
+ export const WAIT_SUBAGENTS = 'wait_subagents'
6
+ export const SUBAGENT_RELATION = 'subagent'
7
+
8
+ /** How deep subagents may nest when the caller does not say. */
9
+ export const DEFAULT_SUBAGENT_DEPTH = 1
10
+ export const MAX_SUBAGENT_DEPTH = 3
11
+
12
+ export interface SubagentOptions {
13
+ /** Maximum consecutive native subagent levels below a caller (1 to 3). */
14
+ maxDepth?: number
15
+ }
16
+
17
+ export function subagentDepth(options: SubagentOptions | undefined): number {
18
+ const value = Math.trunc(options?.maxDepth ?? DEFAULT_SUBAGENT_DEPTH)
19
+ return Math.min(MAX_SUBAGENT_DEPTH, Math.max(1, Number.isFinite(value) ? value : DEFAULT_SUBAGENT_DEPTH))
20
+ }
21
+
22
+ export interface SubagentInput {
23
+ parentRunId: string
24
+ brief: string
25
+ tools?: string[]
26
+ wait?: boolean
27
+ }
28
+
29
+ export interface SubagentSelection {
30
+ parentRunId: string
31
+ runIds?: string[]
32
+ }
33
+
34
+ export interface SubagentResult {
35
+ runId: string
36
+ status: string
37
+ text: string
38
+ terminalReason: string | null
39
+ }
40
+
41
+ export interface SubagentReceipt {
42
+ version: 1
43
+ ownerRunId: string
44
+ spawned?: string[]
45
+ delivered?: string[]
46
+ }
47
+
48
+ export const SUBAGENT_RECEIPT = 'xingularSubagents'
49
+ export const SUBAGENT_OWNER = 'xingular-subagent-owner'
50
+ export const SUBAGENT_RESULTS = 'xingular-subagent-results'
51
+
52
+ /** Read explicit native metadata, including entries retained before compaction. */
53
+ export function subagentHistory(entries: readonly unknown[]): { owners: string[]; receipts: SubagentReceipt[] } {
54
+ const owners: string[] = []
55
+ const receipts: SubagentReceipt[] = []
56
+ for (const raw of entries) {
57
+ if (!raw || typeof raw !== 'object') continue
58
+ const entry = raw as Record<string, any>
59
+ if (entry.type === 'custom' && entry.customType === SUBAGENT_OWNER && typeof entry.data?.runId === 'string') owners.push(entry.data.runId)
60
+ const message = entry.type === 'message' ? entry.message : entry
61
+ const receipt = message?.details?.[SUBAGENT_RECEIPT]
62
+ if (!receipt || receipt.version !== 1 || typeof receipt.ownerRunId !== 'string') continue
63
+ const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every((id) => typeof id === 'string')
64
+ if ((receipt.spawned !== undefined && !strings(receipt.spawned)) || (receipt.delivered !== undefined && !strings(receipt.delivered))) continue
65
+ receipts.push(receipt)
66
+ }
67
+ return { owners, receipts }
68
+ }
69
+
70
+ /** Native nesting is independent of a host's other uses of the generic caller depth. */
71
+ export function subagentNesting(root: string, run: { relation?: string | null; parentRunId?: string | null }): number {
72
+ let current: typeof run = run
73
+ let depth = 0
74
+ const visited = new Set<string>()
75
+ while (current.relation === SUBAGENT_RELATION) {
76
+ const parent = current.parentRunId
77
+ if (!parent || visited.has(parent)) return MAX_SUBAGENT_DEPTH
78
+ visited.add(parent)
79
+ depth++
80
+ const record = findRun(root, parent)
81
+ if (!record) return MAX_SUBAGENT_DEPTH
82
+ current = record
83
+ }
84
+ return depth
85
+ }
86
+
87
+ /** The child's final reply, or why it ended without one. */
88
+ export function subagentReport(root: string, runId: string): string {
89
+ const run = loadRun(root, runId)
90
+ const reply = readEvents(root, runId).filter((event) => event.type === 'message.completed' && (event.payload as { role?: unknown }).role === 'assistant').at(-1)
91
+ const text = typeof (reply?.payload as { text?: unknown } | undefined)?.text === 'string' ? takeChars((reply!.payload as { text: string }).text, 16_000) : ''
92
+ const lines = [`Subagent run ${runId} · ${run.status}`]
93
+ if (text) lines.push(`Result:\n${text}`)
94
+ else if (run.status === 'completed') lines.push('It finished without a written reply.')
95
+ if (run.status !== 'completed' && run.terminalReason) lines.push(`Stopped because: ${run.terminalReason}`)
96
+ return lines.join('\n')
97
+ }
@@ -12,6 +12,8 @@ export interface PolicyReply {
12
12
  allowed: boolean
13
13
  reason?: string | null
14
14
  requiresApproval?: boolean
15
+ rememberable?: boolean
16
+ standing?: 'full' | 'switch' | 'none'
15
17
  }
16
18
 
17
19
  export interface ControllerHooks {
@@ -21,6 +23,8 @@ export interface ControllerHooks {
21
23
  begin(input: { reservedRunId: string | null; nativeTurnId: string; text: string }): ActiveRun
22
24
  /** Whether a native tool call may run. */
23
25
  policy(run: ActiveRun, tool: string, args: unknown): Promise<PolicyReply>
26
+ /** Saves a user-selected tool grant in the shared local approval store. */
27
+ allowToolGlobally(target: string): void
24
28
  /** The native conversation identity changed. */
25
29
  native(nativeSessionId: string): void
26
30
  telemetry(event: string): void
@@ -148,18 +152,23 @@ export class TerminalController {
148
152
  }
149
153
 
150
154
  /** Project registry-owned questions, including grouped requests, into the terminal inbox. */
151
- observe(event: RunEvent): void {
152
- if (this.disposed || event.runId !== this.activeRun?.runId) return
155
+ observe(event: RunEvent, descendant = false): void {
156
+ if (this.disposed || !this.activeRun || (!descendant && event.runId !== this.activeRun.runId)) return
153
157
  if (event.type === 'question.requested') {
154
158
  const payload = event.payload as Record<string, unknown>
155
- this.pending.set(String(payload.questionId), { ...payload, type: 'question', requestId: payload.questionId })
159
+ this.pending.set(String(payload.questionId), { ...payload, type: 'question', requestId: payload.questionId, runId: event.runId })
160
+ } else if (descendant && event.type === 'approval.requested') {
161
+ const payload = event.payload as Record<string, unknown>
162
+ if (!payload.automatic) this.pending.set(String(payload.approvalId), { ...payload, type: 'approval', requestId: payload.approvalId, runId: event.runId })
163
+ } else if (event.type === 'approval.resolved') {
164
+ this.pending.delete(String((event.payload as { approvalId: string }).approvalId))
156
165
  } else if (event.type === 'question.answered') {
157
166
  this.pending.delete(String((event.payload as { questionId: string }).questionId))
158
167
  }
159
168
  }
160
169
 
161
170
  /** The socket grants this interface only to the process that owns terminal input. */
162
- control(call: string, data: Record<string, any>): unknown {
171
+ async control(call: string, data: Record<string, any>): Promise<unknown> {
163
172
  if (this.disposed) throw new HarnessError('terminal_not_ready', 'This terminal has ended.')
164
173
  if (call === 'terminal.draft') {
165
174
  this.dirty = data.dirty === true
@@ -168,12 +177,18 @@ export class TerminalController {
168
177
  }
169
178
  if (call === 'terminal.pending') return { requests: [...this.pending.values()] }
170
179
  if (call === 'terminal.interrupt') return this.activeRun && this.hooks.interrupt ? this.hooks.interrupt(this.activeRun.runId) : {}
180
+ if (call === 'terminal.policy') {
181
+ const run = this.activeRun
182
+ if (!run || typeof data.tool !== 'string' || !data.tool) throw new HarnessError('invalid_request', 'An active run and tool name are required.')
183
+ return this.hooks.policy(run, data.tool, data.args ?? {})
184
+ }
185
+ if (call === 'terminal.approval') return this.requestNativeApproval(data)
171
186
  if (call === 'terminal.answers' && this.hooks.answerQuestions) {
172
187
  const run = this.activeRun
173
188
  const batchId = String(data.batchId ?? '')
174
189
  const requests = [...this.pending.values()].filter(request => request.batchId === batchId)
175
190
  if (!run || !batchId || !requests.length || !Array.isArray(data.answers)) throw new HarnessError('invalid_request', 'This question batch is no longer pending.')
176
- this.hooks.answerQuestions(run.runId, batchId, data.answers)
191
+ this.hooks.answerQuestions(String(requests[0].runId ?? run.runId), batchId, data.answers)
177
192
  return {}
178
193
  }
179
194
  const requestId = String(data.requestId ?? '')
@@ -182,17 +197,65 @@ export class TerminalController {
182
197
  if (!request || !run) throw new HarnessError('invalid_request', 'This terminal decision is no longer pending.')
183
198
  if (call === 'terminal.approve' && request.type === 'approval' && this.hooks.resolveApproval) {
184
199
  if (data.decision !== 'approved' && data.decision !== 'denied') throw new HarnessError('invalid_request', 'Choose approve or deny.')
185
- this.hooks.resolveApproval(run.runId, requestId, data.decision)
200
+ const alwaysAllow = data.alwaysAllow === true
201
+ if (alwaysAllow && request.rememberable !== true) throw new HarnessError('invalid_request', 'This approval cannot be remembered for other sessions.')
202
+ let decision: ApprovalDecision = data.decision
203
+ if (decision === 'approved' && request.direct === true) {
204
+ const operation = request.operation as { tool?: unknown; args?: unknown } | undefined
205
+ const tool = typeof operation?.tool === 'string' ? operation.tool : ''
206
+ const current = await this.hooks.policy(run, tool, operation?.args ?? {})
207
+ if (!current.allowed && !current.requiresApproval) decision = 'denied'
208
+ if (alwaysAllow && current.rememberable !== true) {
209
+ request.rememberable = false
210
+ throw new HarnessError('invalid_request', 'This tool can no longer be allowed across sessions. Review the approval again.')
211
+ }
212
+ }
213
+ if (decision === 'approved' && alwaysAllow) this.hooks.allowToolGlobally(String(request.target ?? ''))
214
+ this.hooks.resolveApproval(String(request.runId ?? run.runId), requestId, decision)
186
215
  return {}
187
216
  }
188
217
  if (call === 'terminal.answer' && request.type === 'question' && this.hooks.answerQuestion) {
189
218
  if (!data.answer || !Array.isArray(data.answer.selected)) throw new HarnessError('invalid_request', 'A question answer is required.')
190
- this.hooks.answerQuestion(run.runId, requestId, data.answer)
219
+ this.hooks.answerQuestion(String(request.runId ?? run.runId), requestId, data.answer)
191
220
  return {}
192
221
  }
193
222
  throw new HarnessError('unknown_method', `Unknown terminal control ${call}.`)
194
223
  }
195
224
 
225
+ private async requestNativeApproval(data: Record<string, any>): Promise<unknown> {
226
+ const run = this.activeRun
227
+ const requestId = typeof data.requestId === 'string' ? data.requestId : ''
228
+ const tool = typeof data.tool === 'string' ? data.tool : ''
229
+ if (!run || !requestId || !tool) throw new HarnessError('invalid_request', 'An active run, request id, and tool name are required.')
230
+ const args = data.args && typeof data.args === 'object' && !Array.isArray(data.args) ? data.args as Record<string, unknown> : {}
231
+ const policy = await this.hooks.policy(run, tool, args)
232
+ if (!policy.allowed && !policy.requiresApproval) return { decision: 'denied' }
233
+ if (policy.allowed && !policy.requiresApproval) return { decision: 'approved' }
234
+ const key = this.requestId(`approval:${requestId}`)
235
+ const existing = this.seen.get(key)
236
+ if (existing) return { decision: await existing }
237
+ const action = JSON.stringify(args)
238
+ const operation = { tool, args }
239
+ const response = run.requestApproval({
240
+ requestId: key,
241
+ target: tool,
242
+ action,
243
+ operation,
244
+ rememberable: policy.rememberable === true,
245
+ standing: policy.standing
246
+ })
247
+ this.pending.set(key, {
248
+ type: 'approval', requestId: key, runId: run.runId, target: tool, action,
249
+ operation, rememberable: policy.rememberable === true, direct: true
250
+ })
251
+ this.seen.set(key, response)
252
+ try {
253
+ return { decision: await response }
254
+ } finally {
255
+ this.pending.delete(key)
256
+ }
257
+ }
258
+
196
259
  async accept(event: string, data: Record<string, any>): Promise<unknown> {
197
260
  if (this.disposed) throw new Error('This terminal controller was disposed.')
198
261
  if (event !== 'poll') this.hooks.telemetry(event)
@@ -220,8 +283,8 @@ export class TerminalController {
220
283
  return {}
221
284
  }
222
285
  if (event === 'begin') {
223
- this.begin(String(data.turnId ?? randomUUID()), String(data.text ?? ''))
224
- return {}
286
+ const run = this.begin(String(data.turnId ?? randomUUID()), String(data.text ?? ''))
287
+ return { runId: run.runId }
225
288
  }
226
289
  if (event === 'shutdown') {
227
290
  this.ready = false
@@ -229,7 +292,7 @@ export class TerminalController {
229
292
  return {}
230
293
  }
231
294
  if (event === 'settled') {
232
- this.finish(data.error ? 'failed' : 'completed', data.error ? String(data.error) : undefined)
295
+ this.finish(data.aborted ? 'interrupted' : data.error ? 'failed' : 'completed', data.error ? String(data.error) : undefined)
233
296
  return {}
234
297
  }
235
298
  const run = this.run