@xingular/harness 0.1.7 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +29 -10
- package/dist/cli.js +5179 -2976
- 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 +68 -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 +100 -10
- 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/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 +6 -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/registry.ts +38 -7
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +9 -3
- package/src/sessions/sessions.test.ts +46 -7
- package/src/sessions/sessions.ts +31 -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 +184 -8
- package/src/terminal/tui/application.ts +243 -81
- package/src/terminal/tui/commands.test.ts +69 -9
- package/src/terminal/tui/commands.ts +89 -7
- package/src/terminal/tui/editor.test.ts +56 -27
- package/src/terminal/tui/editor.ts +49 -19
- package/src/terminal/tui/keybindings.test.ts +8 -0
- package/src/terminal/tui/keybindings.ts +10 -1
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -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 +2 -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
|
@@ -22,6 +22,23 @@ afterEach(async () => {
|
|
|
22
22
|
for (const harness of open.splice(0)) await harness.close()
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
+
function tellOn(connection: SocketConnection, event: string, data: Record<string, unknown> = {}) {
|
|
26
|
+
return new Promise<any>((resolve, reject) => {
|
|
27
|
+
const stream = connect(connection.socket)
|
|
28
|
+
let buffer = ''
|
|
29
|
+
stream.on('connect', () => stream.write(`${JSON.stringify({ token: connection.token, event, data })}\n`))
|
|
30
|
+
stream.on('data', (chunk) => {
|
|
31
|
+
buffer += chunk.toString()
|
|
32
|
+
if (!buffer.includes('\n')) return
|
|
33
|
+
const reply = JSON.parse(buffer.slice(0, buffer.indexOf('\n')))
|
|
34
|
+
stream.end()
|
|
35
|
+
if (reply.error) reject(new Error(reply.error.message))
|
|
36
|
+
else resolve(reply.result)
|
|
37
|
+
})
|
|
38
|
+
stream.on('error', reject)
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
25
42
|
async function setup(policy: ToolPolicy = {}) {
|
|
26
43
|
const root = rootWithProject()
|
|
27
44
|
const harness = createHarness({ dataRoot: root, stateDir: mkdtempSync(join(tmpdir(), 'hs-')), secrets: memorySecretStore(), policy, piProgram: () => '/opt/pi/bin/pi', assets: { node: '/opt/node', cli: '/opt/harness/cli.js', piExtension: '/opt/harness/terminal-pi.js' } })
|
|
@@ -39,21 +56,7 @@ async function setup(policy: ToolPolicy = {}) {
|
|
|
39
56
|
const launch = await harness.terminals.prepare({ sessionId: 'term-1', accountId: account.id, projectId: 'p1', cwd: root, sessionFolder, model: 'chosen-model', launch: { connections: 'Use mcp_list_tools.' } })
|
|
40
57
|
const connection: SocketConnection = { socket: String(launch.env[SOCKET_ENV]), token: String(launch.env[TOKEN_ENV]) }
|
|
41
58
|
/** One telemetry line, as the native extension sends it. */
|
|
42
|
-
const tell = (event: string, data: Record<string, unknown> = {}) =>
|
|
43
|
-
new Promise<any>((resolve, reject) => {
|
|
44
|
-
const stream = connect(connection.socket)
|
|
45
|
-
let buffer = ''
|
|
46
|
-
stream.on('connect', () => stream.write(`${JSON.stringify({ token: connection.token, event, data })}\n`))
|
|
47
|
-
stream.on('data', (chunk) => {
|
|
48
|
-
buffer += chunk.toString()
|
|
49
|
-
if (!buffer.includes('\n')) return
|
|
50
|
-
const reply = JSON.parse(buffer.slice(0, buffer.indexOf('\n')))
|
|
51
|
-
stream.end()
|
|
52
|
-
if (reply.error) reject(new Error(reply.error.message))
|
|
53
|
-
else resolve(reply.result)
|
|
54
|
-
})
|
|
55
|
-
stream.on('error', reject)
|
|
56
|
-
})
|
|
59
|
+
const tell = (event: string, data: Record<string, unknown> = {}) => tellOn(connection, event, data)
|
|
57
60
|
return { harness, root, account, events, notices, launch, connection, tell, sessionFolder }
|
|
58
61
|
}
|
|
59
62
|
|
|
@@ -237,6 +240,117 @@ describe('a pi terminal session', () => {
|
|
|
237
240
|
expect(await decision).toEqual({ decision: 'approved' })
|
|
238
241
|
})
|
|
239
242
|
|
|
243
|
+
it('protects permission controls and shares saved terminal grants across sessions', async () => {
|
|
244
|
+
const { connection, harness, account, root } = await setup()
|
|
245
|
+
await expect(socketCall(connection, 'terminal.permissions.get')).rejects.toMatchObject({ code: 'unauthorized' })
|
|
246
|
+
await expect(socketCall(connection, 'terminal.policy', { tool: 'bash', args: {} })).rejects.toMatchObject({ code: 'unauthorized' })
|
|
247
|
+
const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
|
|
248
|
+
expect(await socketCall(connection, 'terminal.permissions.get', {}, 1000, key)).toEqual({ full: false, globalTools: [] })
|
|
249
|
+
await socketCall(connection, 'terminal.permissions.setTool', { tool: 'bash', allowed: true }, 1000, key)
|
|
250
|
+
await socketCall(connection, 'terminal.permissions.setFull', { enabled: true }, 1000, key)
|
|
251
|
+
|
|
252
|
+
const secondLaunch = await harness.terminals.prepare({ sessionId: 'term-2', accountId: account.id, projectId: 'p1', cwd: root, sessionFolder: join(root, 'sessions', 'p1', 'term-2') })
|
|
253
|
+
const secondConnection: SocketConnection = { socket: String(secondLaunch.env[SOCKET_ENV]), token: String(secondLaunch.env[TOKEN_ENV]) }
|
|
254
|
+
const { key: secondKey } = await socketCall<{ key: string }>(secondConnection, 'control.claim')
|
|
255
|
+
expect(await socketCall(secondConnection, 'terminal.permissions.get', {}, 1000, secondKey)).toEqual({ full: true, globalTools: ['bash'] })
|
|
256
|
+
await socketCall(secondConnection, 'terminal.permissions.setTool', { tool: 'bash', allowed: false }, 1000, secondKey)
|
|
257
|
+
expect(await socketCall(connection, 'terminal.permissions.get', {}, 1000, key)).toEqual({ full: true, globalTools: [] })
|
|
258
|
+
await expect(socketCall(connection, 'terminal.permissions.setFull', { enabled: 'yes' }, 1000, key)).rejects.toMatchObject({ code: 'invalid_request' })
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('does not let a permission change retroactively resolve an already pending approval', async () => {
|
|
262
|
+
const { connection, tell } = await setup()
|
|
263
|
+
await tell('ready')
|
|
264
|
+
await tell('begin', { text: 'Wait for a decision' })
|
|
265
|
+
const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
|
|
266
|
+
const approval = socketCall<{ decision: string }>(connection, 'terminal.approval', { requestId: 'already-pending', tool: 'bash', args: { command: 'pwd' } }, 1000, key)
|
|
267
|
+
let requestId = ''
|
|
268
|
+
await vi.waitFor(async () => {
|
|
269
|
+
const { requests } = await socketCall<{ requests: Array<{ requestId: string }> }>(connection, 'terminal.pending', {}, 1000, key)
|
|
270
|
+
expect(requests).toHaveLength(1)
|
|
271
|
+
requestId = requests[0]!.requestId
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
await socketCall(connection, 'terminal.permissions.setFull', { enabled: true }, 1000, key)
|
|
275
|
+
expect(await socketCall(connection, 'terminal.pending', {}, 1000, key)).toMatchObject({ requests: [{ requestId }] })
|
|
276
|
+
await socketCall(connection, 'terminal.approve', { requestId, decision: 'denied' }, 1000, key)
|
|
277
|
+
expect(await approval).toEqual({ decision: 'denied' })
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('rechecks native tool policy when a pending approval is resolved', async () => {
|
|
281
|
+
let denied = false
|
|
282
|
+
const { connection, tell } = await setup({
|
|
283
|
+
scope: async () => denied ? { allowed: false, reason: 'The policy now denies this tool.' } : { allowed: true, requiresApproval: true }
|
|
284
|
+
})
|
|
285
|
+
await tell('ready')
|
|
286
|
+
await tell('begin', { text: 'Run a tool' })
|
|
287
|
+
const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
|
|
288
|
+
expect(await socketCall(connection, 'terminal.policy', { tool: 'bash', args: { command: 'pwd' } }, 1000, key)).toMatchObject({ requiresApproval: true, rememberable: true })
|
|
289
|
+
const approval = socketCall<{ decision: string }>(connection, 'terminal.approval', { requestId: 'call-approval', tool: 'bash', args: { command: 'pwd' } }, 1000, key)
|
|
290
|
+
let requestId = ''
|
|
291
|
+
await vi.waitFor(async () => {
|
|
292
|
+
const pending = await socketCall<{ requests: Array<{ requestId: string }> }>(connection, 'terminal.pending', {}, 1000, key)
|
|
293
|
+
expect(pending.requests).toHaveLength(1)
|
|
294
|
+
requestId = pending.requests[0]!.requestId
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
denied = true
|
|
298
|
+
await socketCall(connection, 'terminal.approve', { requestId, decision: 'approved' }, 1000, key)
|
|
299
|
+
expect(await approval).toEqual({ decision: 'denied' })
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('keeps human-only rules out of tool grants and Full Permission unless the host opts in', async () => {
|
|
303
|
+
const { connection, tell, events, harness } = await setup({
|
|
304
|
+
scope: async () => ({ allowed: true, humanOnly: true, allowGlobalApproval: false })
|
|
305
|
+
})
|
|
306
|
+
harness.approvals.setAuto('pi', true)
|
|
307
|
+
await tell('ready')
|
|
308
|
+
await tell('begin', { text: 'Human decision required' })
|
|
309
|
+
const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
|
|
310
|
+
expect(await socketCall(connection, 'terminal.policy', { tool: 'bash', args: {} }, 1000, key)).toMatchObject({ requiresApproval: true, rememberable: false, standing: 'none' })
|
|
311
|
+
const approval = socketCall<{ decision: string }>(connection, 'terminal.approval', { requestId: 'human-only', tool: 'bash', args: {} }, 1000, key)
|
|
312
|
+
let requestId = ''
|
|
313
|
+
await vi.waitFor(async () => {
|
|
314
|
+
expect(events.some(event => event.type === 'approval.requested')).toBe(true)
|
|
315
|
+
const { requests } = await socketCall<{ requests: Array<{ requestId: string; rememberable?: boolean }> }>(connection, 'terminal.pending', {}, 1000, key)
|
|
316
|
+
expect(requests).toHaveLength(1)
|
|
317
|
+
expect(requests[0]?.rememberable).toBe(false)
|
|
318
|
+
requestId = requests[0]!.requestId
|
|
319
|
+
})
|
|
320
|
+
await socketCall(connection, 'terminal.approve', { requestId, decision: 'denied' }, 1000, key)
|
|
321
|
+
expect(await approval).toEqual({ decision: 'denied' })
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('remembers a tool choice for future Pi terminal sessions sharing the state directory', async () => {
|
|
325
|
+
const { connection, tell, events, harness, account, root } = await setup()
|
|
326
|
+
await tell('ready')
|
|
327
|
+
await tell('begin', { text: 'Allow a tool' })
|
|
328
|
+
const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
|
|
329
|
+
const first = socketCall<{ decision: string }>(connection, 'terminal.approval', { requestId: 'remember-call', tool: 'bash', args: { command: 'pwd' } }, 1000, key)
|
|
330
|
+
let requestId = ''
|
|
331
|
+
await vi.waitFor(async () => {
|
|
332
|
+
const { requests } = await socketCall<{ requests: Array<{ requestId: string; rememberable?: boolean }> }>(connection, 'terminal.pending', {}, 1000, key)
|
|
333
|
+
expect(requests[0]?.rememberable).toBe(true)
|
|
334
|
+
requestId = requests[0]!.requestId
|
|
335
|
+
})
|
|
336
|
+
await socketCall(connection, 'terminal.approve', { requestId, decision: 'approved', alwaysAllow: true }, 1000, key)
|
|
337
|
+
expect(await first).toEqual({ decision: 'approved' })
|
|
338
|
+
expect(harness.approvals.read().globalTools.pi).toEqual(['bash'])
|
|
339
|
+
|
|
340
|
+
const launch = await harness.terminals.prepare({ sessionId: 'term-2', accountId: account.id, projectId: 'p1', cwd: root, sessionFolder: join(root, 'sessions', 'p1', 'term-2') })
|
|
341
|
+
const next: SocketConnection = { socket: String(launch.env[SOCKET_ENV]), token: String(launch.env[TOKEN_ENV]) }
|
|
342
|
+
await tellOn(next, 'startup', { phase: 'ready' })
|
|
343
|
+
await tellOn(next, 'ready')
|
|
344
|
+
await tellOn(next, 'begin', { text: 'Reuse the grant' })
|
|
345
|
+
const { key: nextKey } = await socketCall<{ key: string }>(next, 'control.claim')
|
|
346
|
+
const before = events.filter(event => event.type === 'approval.requested').length
|
|
347
|
+
expect(await socketCall(next, 'terminal.approval', { requestId: 'next-call', tool: 'bash', args: { command: 'git status' } }, 1000, nextKey)).toEqual({ decision: 'approved' })
|
|
348
|
+
const automatic = events.filter(event => event.type === 'approval.requested').slice(before)
|
|
349
|
+
expect(automatic).toHaveLength(1)
|
|
350
|
+
expect(automatic[0]?.payload).toMatchObject({ automatic: 'remembered', target: 'bash' })
|
|
351
|
+
expect(await socketCall(next, 'terminal.pending', {}, 1000, nextKey)).toEqual({ requests: [] })
|
|
352
|
+
})
|
|
353
|
+
|
|
240
354
|
it('applies the application policy to native tool calls', async () => {
|
|
241
355
|
const seen: string[] = []
|
|
242
356
|
const { tell } = await setup({
|
|
@@ -332,6 +446,27 @@ describe('xagent-wiki command', () => {
|
|
|
332
446
|
|
|
333
447
|
|
|
334
448
|
describe('decisions in the Harness-owned frontend', () => {
|
|
449
|
+
it('exposes trajectory reads only to the input owner and scopes them to the current native conversation', async () => {
|
|
450
|
+
const { connection, tell, events } = await setup()
|
|
451
|
+
await tell('ready', { nativeSessionId: '/history/current.jsonl' })
|
|
452
|
+
await tell('begin', { text: 'Inspect this run' })
|
|
453
|
+
await tell('message', { text: 'Recorded response' })
|
|
454
|
+
await tell('settled', {})
|
|
455
|
+
const runId = events.find(event => event.type === 'started')!.runId
|
|
456
|
+
|
|
457
|
+
await expect(socketCall(connection, 'terminal.trajectory.list')).rejects.toMatchObject({ code: 'unauthorized' })
|
|
458
|
+
const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
|
|
459
|
+
const listed = await socketCall<{ runs: Array<{ id: string }> }>(connection, 'terminal.trajectory.list', {}, 1000, key)
|
|
460
|
+
expect(listed.runs.map(run => run.id)).toContain(runId)
|
|
461
|
+
await expect(socketCall(connection, 'terminal.trajectory.events', { runId }, 1000, key)).resolves.toMatchObject({
|
|
462
|
+
run: { id: runId }, events: expect.arrayContaining([expect.objectContaining({ type: 'message.completed' })])
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
await tell('ready', { nativeSessionId: '/history/another.jsonl' })
|
|
466
|
+
await expect(socketCall(connection, 'terminal.trajectory.events', { runId }, 1000, key)).rejects.toMatchObject({ code: 'not_found' })
|
|
467
|
+
await expect(socketCall(connection, 'terminal.trajectory.list', {}, 1000, key)).resolves.toEqual({ runs: [] })
|
|
468
|
+
})
|
|
469
|
+
|
|
335
470
|
it('requires the private input-owner key for pending decisions and exact draft state', async () => {
|
|
336
471
|
const { connection, harness, tell } = await setup()
|
|
337
472
|
await tell('ready', { nativeSessionId: '/native.jsonl' })
|
|
@@ -5,7 +5,16 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync,
|
|
|
5
5
|
import { dirname, join } from 'node:path'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
7
|
|
|
8
|
+
import { Type } from 'typebox'
|
|
9
|
+
|
|
10
|
+
import type { CapabilitySet } from '../capabilities/types'
|
|
11
|
+
import { freezeRun } from '../capabilities/runSnapshot'
|
|
12
|
+
import type { Subagents } from '../sessions/subagentService'
|
|
13
|
+
import { SPAWN_SUBAGENT, WAIT_SUBAGENTS, type SubagentOptions } from '../sessions/subagents'
|
|
14
|
+
import { subagentTools } from '../tools/spawnSubagent'
|
|
15
|
+
import type { Effort } from '../sessions/runner'
|
|
8
16
|
import type { Accounts } from '../accounts/accounts'
|
|
17
|
+
import type { Approvals } from '../sessions/approvals'
|
|
9
18
|
import { configDir, launchEnv } from '../accounts/env'
|
|
10
19
|
import { readAudit, type Account } from '../accounts/store'
|
|
11
20
|
import type { Bus } from '../core/bus'
|
|
@@ -34,6 +43,7 @@ import { SOCKET_ENV, TOKEN_ENV } from './client'
|
|
|
34
43
|
import { TerminalController, type PolicyReply } from './controller'
|
|
35
44
|
import { resolvePiCommand, terminalCommandFiles } from './launch'
|
|
36
45
|
import { SessionSocket, socketPath } from './socket'
|
|
46
|
+
import { listTerminalTrajectoryRuns, readTerminalTrajectory } from './trajectory'
|
|
37
47
|
|
|
38
48
|
/** Identity of the exact resource decision that this terminal was prepared with. */
|
|
39
49
|
function resourceIdentity(loading: Loading | null): string {
|
|
@@ -63,6 +73,7 @@ export function defaultAssets(): TerminalAssets {
|
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
export interface TerminalPrepareInput {
|
|
76
|
+
subagents?: SubagentOptions | null
|
|
66
77
|
sessionId: string
|
|
67
78
|
/** The account the session is pinned to. Omitted only for a session that has none yet; it then takes the default and keeps it. */
|
|
68
79
|
accountId?: string | null
|
|
@@ -100,10 +111,13 @@ interface Live {
|
|
|
100
111
|
}
|
|
101
112
|
|
|
102
113
|
export interface TerminalsOptions {
|
|
114
|
+
subagents?: Subagents
|
|
115
|
+
resolveCapabilities?: (caller: CallerContext, tools: readonly string[], cwd: string) => Promise<CapabilitySet>
|
|
103
116
|
env: HarnessEnv
|
|
104
117
|
stateDir: string
|
|
105
118
|
accounts: Accounts
|
|
106
119
|
runs: RunRegistry
|
|
120
|
+
approvals: Approvals
|
|
107
121
|
apps: ConnectedApps
|
|
108
122
|
policy: ToolPolicy
|
|
109
123
|
bus: Bus
|
|
@@ -136,7 +150,21 @@ export class Terminals {
|
|
|
136
150
|
|
|
137
151
|
constructor(private readonly options: TerminalsOptions) {
|
|
138
152
|
this.assets = options.assets ?? defaultAssets()
|
|
139
|
-
options.runs.listen(event =>
|
|
153
|
+
options.runs.listen(event => {
|
|
154
|
+
this.sessions.get(event.sessionId)?.controller.observe(event)
|
|
155
|
+
const record = findRun(options.env.root, event.runId)
|
|
156
|
+
if (record?.relation !== 'subagent') return
|
|
157
|
+
let parentId = record.parentRunId
|
|
158
|
+
const seen = new Set<string>()
|
|
159
|
+
while (parentId && !seen.has(parentId)) {
|
|
160
|
+
seen.add(parentId)
|
|
161
|
+
const parent = findRun(options.env.root, parentId)
|
|
162
|
+
if (!parent) break
|
|
163
|
+
const owner = this.sessions.get(parent.sessionId)
|
|
164
|
+
if (owner?.controller.activeRun?.runId === parent.id) { owner.controller.observe(event, true); break }
|
|
165
|
+
parentId = parent.parentRunId
|
|
166
|
+
}
|
|
167
|
+
})
|
|
140
168
|
this.socket = new SessionSocket(socketPath(options.stateDir), {
|
|
141
169
|
memory: (caller, call, params) => dispatch(options.env, caller, call, params),
|
|
142
170
|
apps: (session, call, params) => this.appsCall(session, call, params),
|
|
@@ -383,19 +411,80 @@ export class Terminals {
|
|
|
383
411
|
const { policy } = this.options
|
|
384
412
|
if (policy.scope) {
|
|
385
413
|
const scope = await policy.scope(call)
|
|
386
|
-
if (!scope.allowed && !scope.requiresApproval) return { allowed: false, reason: scope.reason ?? 'Outside what this session may do.' }
|
|
414
|
+
if (!scope.allowed && (scope.automatic || !scope.requiresApproval)) return { allowed: false, reason: scope.reason ?? 'Outside what this session may do.' }
|
|
387
415
|
try {
|
|
388
416
|
await policy.authorize?.(call)
|
|
389
417
|
} catch (error) {
|
|
390
418
|
return { allowed: false, reason: error instanceof Error ? error.message : 'Tool unavailable in the settings.' }
|
|
391
419
|
}
|
|
392
|
-
|
|
420
|
+
const requiresApproval = !!scope.requiresApproval || (scope.allowed && !!scope.humanOnly)
|
|
421
|
+
return {
|
|
422
|
+
allowed: scope.allowed,
|
|
423
|
+
requiresApproval,
|
|
424
|
+
reason: scope.reason ?? null,
|
|
425
|
+
rememberable: scope.allowed && !scope.humanOnly,
|
|
426
|
+
...(scope.humanOnly ? { standing: scope.allowGlobalApproval ? 'switch' as const : 'none' as const } : {})
|
|
427
|
+
}
|
|
393
428
|
}
|
|
394
429
|
// With no application policy: reads run, a read-only session refuses the rest, and anything else asks.
|
|
395
430
|
const name = tool.toLowerCase()
|
|
396
|
-
if (READ_ONLY_TOOLS.has(name) || this.options.apps.toolNames.includes(name as never)) return { allowed: true }
|
|
431
|
+
if (name === SPAWN_SUBAGENT || name === WAIT_SUBAGENTS || READ_ONLY_TOOLS.has(name) || this.options.apps.toolNames.includes(name as never)) return { allowed: true }
|
|
397
432
|
if (live.input.readOnly) return { allowed: false, reason: 'This session is read-only.' }
|
|
398
|
-
return { allowed: false, requiresApproval: true, reason: 'This native action requires approval.' }
|
|
433
|
+
return { allowed: false, requiresApproval: true, rememberable: true, reason: 'This native action requires approval.' }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private async subagentsCall(live: Live, call: string, params: Record<string, any>): Promise<unknown> {
|
|
437
|
+
const service = this.options.subagents
|
|
438
|
+
if (!service || live.input.subagents === null) {
|
|
439
|
+
if (call === 'terminal.subagents.tools') return { tools: [] }
|
|
440
|
+
throw invalid('Subagents are disabled for this terminal.')
|
|
441
|
+
}
|
|
442
|
+
if (call === 'terminal.subagents.tools') return { tools: subagentTools(service, '').map(tool => ({ name: tool.name, label: tool.label, description: tool.description, parameters: tool.parameters(Type) })) }
|
|
443
|
+
const run = live.controller.activeRun
|
|
444
|
+
if (!run) throw new HarnessError('run_not_active', 'This terminal has no active parent run.')
|
|
445
|
+
if (call === 'terminal.subagents.register') {
|
|
446
|
+
if (!Array.isArray(params.tools) || params.tools.some((name: unknown) => typeof name !== 'string')) throw invalid('Native tool names are required.')
|
|
447
|
+
const readOnly = live.input.readOnly === true
|
|
448
|
+
const readTools = new Set([...READ_ONLY_TOOLS, SPAWN_SUBAGENT, WAIT_SUBAGENTS, 'ask_user', 'mcp_list_tools', 'mcp_call_tool', 'history_search', 'history_read', 'wiki_search', 'wiki_read', 'wiki_list', 'memory_facts', 'skill_load'])
|
|
449
|
+
const tools = params.tools.filter((name: string) => !readOnly || readTools.has(name)) as string[]
|
|
450
|
+
const capabilities = this.options.resolveCapabilities ? await this.options.resolveCapabilities(live.caller, tools, live.input.cwd) : { version: 1 as const, tools, connectors: [], contributions: [] }
|
|
451
|
+
freezeRun(this.options.env.root, run.runId, live.caller, capabilities)
|
|
452
|
+
service.register({
|
|
453
|
+
run: { id: run.runId, sessionId: live.input.sessionId, mode: live.input.run?.mode ?? 'code', projectId: live.input.projectId, worktreeId: live.input.run?.worktreeId },
|
|
454
|
+
cwd: live.input.cwd, sessionDir: this.historyDir(live.input), text: '', caller: live.caller,
|
|
455
|
+
model: typeof params.model === 'string' ? params.model : live.input.model,
|
|
456
|
+
effort: typeof params.effort === 'string' ? params.effort as Effort : null,
|
|
457
|
+
launch: live.input.launch, tools: { allow: capabilities.tools, gated: capabilities.tools.filter(name => !readTools.has(name)) },
|
|
458
|
+
connections: live.input.connections === false ? false : { readOnly }, subagents: live.input.subagents,
|
|
459
|
+
capabilities
|
|
460
|
+
}, live.account.id, capabilities)
|
|
461
|
+
service.restore(run.runId, params.history ?? { owners: [], receipts: [] })
|
|
462
|
+
return { runId: run.runId }
|
|
463
|
+
}
|
|
464
|
+
if (call === 'terminal.subagents.verify') { await service.verify(run.runId, params.history); return {} }
|
|
465
|
+
if (call === 'terminal.subagents.delivery') return service.delivery(run.runId, params.history, params.settle === true)
|
|
466
|
+
if (call === 'terminal.subagents.call') {
|
|
467
|
+
const tool = subagentTools(service, run.runId).find(tool => tool.name === params.name)
|
|
468
|
+
if (!tool) throw invalid('Unknown subagent tool.')
|
|
469
|
+
const reason = await this.decide(live, run, tool.name, params.input ?? {})
|
|
470
|
+
if (!reason.allowed) throw new HarnessError('tool_denied', reason.reason ?? 'Subagents are unavailable.')
|
|
471
|
+
const result = await tool.execute(params.input ?? {})
|
|
472
|
+
return typeof result === 'string' ? { text: result } : result
|
|
473
|
+
}
|
|
474
|
+
throw invalid('Unknown subagent operation.')
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private trajectoryCall(live: Live, call: string, params: Record<string, unknown>): unknown {
|
|
478
|
+
if (call === 'terminal.trajectory.list') {
|
|
479
|
+
return { runs: listTerminalTrajectoryRuns(this.options.env.root, live.input.sessionId, live.controller.nativeSessionId) }
|
|
480
|
+
}
|
|
481
|
+
if (call === 'terminal.trajectory.events') {
|
|
482
|
+
if (typeof params.runId !== 'string' || !params.runId) throw invalid('A run id is required.')
|
|
483
|
+
const afterSequence = params.afterSequence
|
|
484
|
+
if (afterSequence !== undefined && typeof afterSequence !== 'number') throw invalid('The event sequence must be a number.')
|
|
485
|
+
return readTerminalTrajectory(this.options.env.root, live.input.sessionId, live.controller.nativeSessionId, params.runId, afterSequence as number | undefined)
|
|
486
|
+
}
|
|
487
|
+
throw new HarnessError('unknown_method', `Unknown call ${call}.`)
|
|
399
488
|
}
|
|
400
489
|
|
|
401
490
|
/**
|
|
@@ -403,6 +492,7 @@ export class Terminals {
|
|
|
403
492
|
* process's end with `dispose`, also when it never starts.
|
|
404
493
|
*/
|
|
405
494
|
async prepare(input: TerminalPrepareInput): Promise<TerminalLaunch> {
|
|
495
|
+
input = structuredClone(input)
|
|
406
496
|
const { env, accounts, apps, bus } = this.options
|
|
407
497
|
const { root } = env
|
|
408
498
|
if (!input?.sessionId || !input.cwd || !input.sessionFolder) throw invalid('A terminal session needs an id, a working folder, and its own folder.')
|
|
@@ -438,6 +528,7 @@ export class Terminals {
|
|
|
438
528
|
nativeSessionId: input.nativeSessionId ?? null,
|
|
439
529
|
begin: ({ reservedRunId, text }) => this.begin(live, reservedRunId, text),
|
|
440
530
|
policy: (run, tool, args) => this.decide(live, run, tool, args),
|
|
531
|
+
allowToolGlobally: target => { this.options.approvals.allowTool('pi', target) },
|
|
441
532
|
native: (nativeSessionId) => bus.emit({ type: 'terminal.native', sessionId, nativeSessionId }),
|
|
442
533
|
telemetry: (event) => bus.emit({ type: 'terminal.telemetry', sessionId, event }),
|
|
443
534
|
interrupt: runId => this.options.runs.interrupt(runId),
|
|
@@ -451,6 +542,23 @@ export class Terminals {
|
|
|
451
542
|
caller: () => ({ ...caller, runId: live.controller.activeRun?.runId ?? null }),
|
|
452
543
|
broker,
|
|
453
544
|
terminal: { accept: (event, data) => live.controller.accept(event, data), disconnected: () => live.controller.disconnected(), control: async (call, params) => {
|
|
545
|
+
if (call.startsWith('terminal.subagents.')) return this.subagentsCall(live, call, params)
|
|
546
|
+
if (call.startsWith('terminal.trajectory.')) return this.trajectoryCall(live, call, params)
|
|
547
|
+
if (call === 'terminal.permissions.get') {
|
|
548
|
+
const policy = this.options.approvals.read()
|
|
549
|
+
return { full: this.options.approvals.isAuto('pi'), globalTools: policy.globalTools.pi ?? [] }
|
|
550
|
+
}
|
|
551
|
+
if (call === 'terminal.permissions.setFull') {
|
|
552
|
+
if (typeof params.enabled !== 'boolean') throw invalid('A boolean permission state is required.')
|
|
553
|
+
return { full: this.options.approvals.setAuto('pi', params.enabled).auto.pi === true }
|
|
554
|
+
}
|
|
555
|
+
if (call === 'terminal.permissions.setTool') {
|
|
556
|
+
if (typeof params.tool !== 'string' || !params.tool.trim() || typeof params.allowed !== 'boolean') throw invalid('A tool name and boolean permission state are required.')
|
|
557
|
+
const policy = params.allowed
|
|
558
|
+
? this.options.approvals.allowTool('pi', params.tool)
|
|
559
|
+
: this.options.approvals.revokeTool('pi', params.tool)
|
|
560
|
+
return { globalTools: policy.globalTools.pi ?? [] }
|
|
561
|
+
}
|
|
454
562
|
if (call !== 'terminal.resources') return live.controller.control(call, params)
|
|
455
563
|
const current = await this.options.plugins?.loading({ cwd: input.cwd, agentDir: configDir(accounts.dir, account), install: false, caller: { projectId: input.projectId ?? null } }) ?? null
|
|
456
564
|
if (resourceIdentity(current) !== resourceIdentity(loading)) throw new HarnessError('terminal_resources_changed', 'Trusted resources changed since this terminal was prepared. Review /plugins and reopen the terminal to load the new selection.')
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { mkdtempSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { closeIndex } from '../core/index'
|
|
7
|
+
import type { RunEvent, RunRecord } from '../sessions/contracts'
|
|
8
|
+
import { appendEvent, saveRun } from '../sessions/runStore'
|
|
9
|
+
import { listTerminalTrajectoryRuns, readTerminalTrajectory } from './trajectory'
|
|
10
|
+
|
|
11
|
+
const roots: string[] = []
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
for (const root of roots.splice(0)) closeIndex(root)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
function setup() {
|
|
17
|
+
const root = mkdtempSync(join(tmpdir(), 'harness-terminal-trajectory-'))
|
|
18
|
+
roots.push(root)
|
|
19
|
+
const run = (id: string, sessionId: string, nativeSessionId: string | null): RunRecord => ({
|
|
20
|
+
id, sessionId, nativeSessionId, mode: 'code', goal: id,
|
|
21
|
+
resumedFromRunId: null, parentRunId: null, relation: null, terminalReason: null,
|
|
22
|
+
projectId: null, harness: 'pi', sessionType: 'terminal', workingDirectory: '/project',
|
|
23
|
+
worktreeId: null, attachmentCount: 0, startedAt: `2026-10-18T00:00:0${id.slice(-1)}Z`,
|
|
24
|
+
endedAt: null, status: 'running', exitCode: null, handoffId: null,
|
|
25
|
+
accountId: 'account-1', agentId: null, trigger: null
|
|
26
|
+
})
|
|
27
|
+
return { root, run }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const event = (runId: string, sessionId: string, sequence: number): RunEvent => ({
|
|
31
|
+
id: `${runId}-event-${sequence}`, runId, sessionId, sequence,
|
|
32
|
+
timestamp: `2026-10-18T00:00:0${sequence}Z`, type: 'started', payload: {}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('lists newest runs only for the current terminal session and native conversation', () => {
|
|
36
|
+
const { root, run } = setup()
|
|
37
|
+
for (const item of [
|
|
38
|
+
run('run-1', 'terminal-1', '/history/one.jsonl'),
|
|
39
|
+
run('run-2', 'terminal-1', '/history/two.jsonl'),
|
|
40
|
+
run('run-3', 'terminal-2', '/history/one.jsonl')
|
|
41
|
+
]) saveRun(root, item)
|
|
42
|
+
|
|
43
|
+
expect(listTerminalTrajectoryRuns(root, 'terminal-1', '/history/one.jsonl').map(item => item.id)).toEqual(['run-1'])
|
|
44
|
+
expect(listTerminalTrajectoryRuns(root, 'terminal-1', null)).toEqual([])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('reads a selected run and only returns events after the requested sequence', () => {
|
|
48
|
+
const { root, run } = setup()
|
|
49
|
+
saveRun(root, run('run-1', 'terminal-1', '/history/current.jsonl'))
|
|
50
|
+
appendEvent(root, event('run-1', 'terminal-1', 0))
|
|
51
|
+
appendEvent(root, event('run-1', 'terminal-1', 1))
|
|
52
|
+
|
|
53
|
+
expect(readTerminalTrajectory(root, 'terminal-1', '/history/current.jsonl', 'run-1', 0)).toMatchObject({
|
|
54
|
+
run: { id: 'run-1', goal: 'run-1' },
|
|
55
|
+
events: [{ sequence: 1 }]
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('does not reveal runs from a different terminal or native conversation', () => {
|
|
60
|
+
const { root, run } = setup()
|
|
61
|
+
saveRun(root, run('run-1', 'terminal-1', '/history/one.jsonl'))
|
|
62
|
+
|
|
63
|
+
expect(() => readTerminalTrajectory(root, 'terminal-2', '/history/one.jsonl', 'run-1')).toThrow('Run was not found')
|
|
64
|
+
expect(() => readTerminalTrajectory(root, 'terminal-1', '/history/two.jsonl', 'run-1')).toThrow('Run was not found')
|
|
65
|
+
expect(() => readTerminalTrajectory(root, 'terminal-1', '/history/one.jsonl', 'run-1', -1)).toThrow('Run was not found')
|
|
66
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { notFound } from '../core/errors'
|
|
2
|
+
import type { RunEvent, RunRecord } from '../sessions/contracts'
|
|
3
|
+
import { findRun, listRuns, readEvents } from '../sessions/runStore'
|
|
4
|
+
|
|
5
|
+
export type TerminalTrajectoryRun = Pick<RunRecord, 'id' | 'mode' | 'goal' | 'startedAt' | 'endedAt' | 'status'>
|
|
6
|
+
|
|
7
|
+
export interface TerminalTrajectorySnapshot {
|
|
8
|
+
run: TerminalTrajectoryRun
|
|
9
|
+
events: RunEvent[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function summary(run: RunRecord): TerminalTrajectoryRun {
|
|
13
|
+
return {
|
|
14
|
+
id: run.id,
|
|
15
|
+
mode: run.mode,
|
|
16
|
+
goal: run.goal,
|
|
17
|
+
startedAt: run.startedAt,
|
|
18
|
+
endedAt: run.endedAt,
|
|
19
|
+
status: run.status
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The native conversation file is the terminal's boundary for this read-only view. */
|
|
24
|
+
export function listTerminalTrajectoryRuns(root: string, sessionId: string, nativeSessionId: string | null): TerminalTrajectoryRun[] {
|
|
25
|
+
if (!nativeSessionId) return []
|
|
26
|
+
return listRuns(root, { sessionId })
|
|
27
|
+
.filter(run => run.nativeSessionId === nativeSessionId)
|
|
28
|
+
.map(summary)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read a run only when it belongs to both this terminal and its current native conversation. */
|
|
32
|
+
export function readTerminalTrajectory(
|
|
33
|
+
root: string,
|
|
34
|
+
sessionId: string,
|
|
35
|
+
nativeSessionId: string | null,
|
|
36
|
+
runId: string,
|
|
37
|
+
afterSequence?: number
|
|
38
|
+
): TerminalTrajectorySnapshot {
|
|
39
|
+
if (!nativeSessionId || !Number.isSafeInteger(afterSequence ?? 0) || (afterSequence ?? 0) < 0) throw notFound('Run')
|
|
40
|
+
const run = findRun(root, runId)
|
|
41
|
+
if (!run || run.sessionId !== sessionId || run.nativeSessionId !== nativeSessionId) throw notFound('Run')
|
|
42
|
+
const events = readEvents(root, runId).filter(event => afterSequence === undefined || event.sequence > afterSequence)
|
|
43
|
+
return { run: summary(run), events }
|
|
44
|
+
}
|