@xingular/harness 0.1.7 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +29 -10
  3. package/dist/cli.js +5179 -2976
  4. package/dist/terminal-pi.js +365 -24
  5. package/docs/README.md +3 -0
  6. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  7. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  8. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  9. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  10. package/docs/terminal-tui.md +100 -10
  11. package/package.json +5 -4
  12. package/src/accounts/accounts.test.ts +87 -1
  13. package/src/accounts/accounts.ts +120 -18
  14. package/src/core/bus.test.ts +22 -0
  15. package/src/core/bus.ts +17 -2
  16. package/src/harness.ts +12 -2
  17. package/src/index.ts +7 -2
  18. package/src/mcp/connections.ts +39 -12
  19. package/src/mcp/service.test.ts +49 -3
  20. package/src/mcp/service.ts +14 -3
  21. package/src/mcp/types.ts +10 -0
  22. package/src/memory/curation.test.ts +44 -1
  23. package/src/memory/dispatch.ts +5 -0
  24. package/src/memory/maintenance.test.ts +17 -1
  25. package/src/memory/maintenance.ts +169 -8
  26. package/src/memory/recall.ts +2 -1
  27. package/src/memory/trajectory.test.ts +178 -0
  28. package/src/memory/trajectory.ts +220 -0
  29. package/src/memory/wiki.ts +2 -1
  30. package/src/memory/wikiIndex.ts +3 -2
  31. package/src/plugins/builtin.ts +4 -2
  32. package/src/plugins/catalogue.test.ts +1 -1
  33. package/src/server/methods.ts +6 -0
  34. package/src/server/server.test.ts +20 -1
  35. package/src/sessions/approvals.test.ts +49 -0
  36. package/src/sessions/approvals.ts +26 -4
  37. package/src/sessions/conversation.test.ts +56 -0
  38. package/src/sessions/lineageJoin.ts +1 -1
  39. package/src/sessions/registry.ts +38 -7
  40. package/src/sessions/runStore.ts +14 -0
  41. package/src/sessions/runner.ts +9 -3
  42. package/src/sessions/sessions.test.ts +46 -7
  43. package/src/sessions/sessions.ts +31 -39
  44. package/src/sessions/subagentService.test.ts +159 -0
  45. package/src/sessions/subagentService.ts +236 -0
  46. package/src/sessions/subagents.ts +97 -0
  47. package/src/terminal/controller.ts +73 -10
  48. package/src/terminal/piExtension.ts +42 -6
  49. package/src/terminal/socket.test.ts +23 -0
  50. package/src/terminal/socket.ts +42 -18
  51. package/src/terminal/standalone.ts +12 -0
  52. package/src/terminal/terminal.test.ts +150 -15
  53. package/src/terminal/terminals.ts +113 -5
  54. package/src/terminal/trajectory.test.ts +66 -0
  55. package/src/terminal/trajectory.ts +44 -0
  56. package/src/terminal/tui/application.test.ts +184 -8
  57. package/src/terminal/tui/application.ts +243 -81
  58. package/src/terminal/tui/commands.test.ts +69 -9
  59. package/src/terminal/tui/commands.ts +89 -7
  60. package/src/terminal/tui/editor.test.ts +56 -27
  61. package/src/terminal/tui/editor.ts +49 -19
  62. package/src/terminal/tui/keybindings.test.ts +8 -0
  63. package/src/terminal/tui/keybindings.ts +10 -1
  64. package/src/terminal/tui/planMode.test.ts +10 -0
  65. package/src/terminal/tui/planMode.ts +12 -0
  66. package/src/terminal/tui/runtime.socket.test.ts +77 -3
  67. package/src/terminal/tui/runtime.test.ts +10 -0
  68. package/src/terminal/tui/runtime.ts +24 -9
  69. package/src/terminal/tui/screen.test.ts +143 -0
  70. package/src/terminal/tui/screen.ts +121 -0
  71. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  72. package/src/terminal/tui/skillInvocation.ts +51 -0
  73. package/src/terminal/tui/status.test.ts +24 -46
  74. package/src/terminal/tui/status.ts +42 -69
  75. package/src/terminal/tui/theme.ts +18 -7
  76. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  77. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  78. package/src/terminal/tui/transcript.test.ts +14 -0
  79. package/src/terminal/tui/transcript.ts +11 -0
  80. package/src/terminal/tui/ui.ts +2 -2
  81. package/src/terminal/tui/welcome.ts +4 -4
  82. package/src/terminal/tui/workingStatus.test.ts +44 -0
  83. package/src/terminal/tui/workingStatus.ts +238 -0
  84. package/src/tools/definitions.ts +42 -1
  85. package/src/tools/maintenance.ts +6 -1
  86. package/src/tools/spawnSubagent.ts +40 -85
  87. package/src/tools/types.ts +2 -0
  88. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  89. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  90. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  91. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  92. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  93. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  94. package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
  95. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  96. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  97. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  98. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  99. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  100. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  101. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  102. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  103. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  104. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  105. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  106. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  107. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
  108. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  109. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  110. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  111. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  112. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
  113. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  114. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
  115. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
  116. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  117. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  118. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
  119. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
  120. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  121. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
  122. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  123. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  124. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
  125. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  126. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
  127. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
  128. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
  129. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
  130. package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
@@ -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 => this.sessions.get(event.sessionId)?.controller.observe(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
- return { allowed: scope.allowed, requiresApproval: !!scope.requiresApproval, reason: scope.reason ?? null }
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
+ }