@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.
- package/CHANGELOG.md +20 -0
- package/README.md +36 -13
- package/dist/cli.js +10728 -8033
- package/dist/terminal-pi.js +365 -24
- package/docs/README.md +3 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +70 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +109 -13
- package/package.json +5 -4
- package/src/accounts/accounts.test.ts +87 -1
- package/src/accounts/accounts.ts +120 -18
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +12 -2
- package/src/hub/hub.test.ts +49 -1
- package/src/hub/hub.ts +122 -6
- package/src/hub/schema.ts +13 -1
- package/src/index.ts +7 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- package/src/memory/curation.test.ts +44 -1
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +169 -8
- package/src/memory/recall.ts +2 -1
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +8 -0
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/conversation.test.ts +56 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/planMode.test.ts +40 -0
- package/src/sessions/planMode.ts +57 -0
- package/src/sessions/registry.ts +38 -7
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +9 -3
- package/src/sessions/sessions.test.ts +69 -7
- package/src/sessions/sessions.ts +47 -39
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/terminal/controller.ts +73 -10
- package/src/terminal/piExtension.ts +42 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +42 -18
- package/src/terminal/standalone.ts +12 -0
- package/src/terminal/terminal.test.ts +150 -15
- package/src/terminal/terminals.ts +113 -5
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +206 -9
- package/src/terminal/tui/application.ts +251 -81
- package/src/terminal/tui/commands.test.ts +70 -9
- package/src/terminal/tui/commands.ts +99 -7
- package/src/terminal/tui/editor.test.ts +56 -27
- package/src/terminal/tui/editor.ts +64 -19
- package/src/terminal/tui/keybindings.test.ts +7 -0
- package/src/terminal/tui/keybindings.ts +9 -1
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +2 -0
- package/src/terminal/tui/runtime.socket.test.ts +77 -3
- package/src/terminal/tui/runtime.test.ts +10 -0
- package/src/terminal/tui/runtime.ts +24 -9
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +24 -46
- package/src/terminal/tui/status.ts +42 -69
- package/src/terminal/tui/theme.ts +18 -7
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +14 -0
- package/src/terminal/tui/transcript.ts +11 -0
- package/src/terminal/tui/ui.ts +2 -2
- package/src/terminal/tui/welcome.ts +4 -4
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +6 -1
- package/src/tools/spawnSubagent.ts +40 -85
- package/src/tools/types.ts +4 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
- 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
|
|
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
|
-
|
|
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
|