@xingular/harness 0.1.6 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +139 -28
  4. package/dist/cli.js +9598 -2201
  5. package/dist/terminal-pi.js +808 -16
  6. package/docs/README.md +15 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  9. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  10. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  11. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  12. package/docs/terminal-tui.md +344 -0
  13. package/package.json +11 -4
  14. package/src/accounts/accounts.test.ts +197 -1
  15. package/src/accounts/accounts.ts +266 -28
  16. package/src/cli.ts +7 -3
  17. package/src/core/bus.test.ts +22 -0
  18. package/src/core/bus.ts +17 -2
  19. package/src/harness.ts +27 -4
  20. package/src/hub/catalog.json +123 -0
  21. package/src/hub/cli.ts +59 -0
  22. package/src/hub/hub.test.ts +205 -0
  23. package/src/hub/hub.ts +256 -0
  24. package/src/hub/schema.ts +81 -0
  25. package/src/index.ts +12 -2
  26. package/src/mcp/connections.ts +39 -12
  27. package/src/mcp/service.test.ts +49 -3
  28. package/src/mcp/service.ts +14 -3
  29. package/src/mcp/types.ts +10 -0
  30. package/src/memory/api.ts +16 -2
  31. package/src/memory/blocks.ts +3 -1
  32. package/src/memory/caller.ts +13 -1
  33. package/src/memory/curation.test.ts +206 -0
  34. package/src/memory/curation.ts +64 -0
  35. package/src/memory/dispatch.ts +5 -0
  36. package/src/memory/maintenance.test.ts +17 -1
  37. package/src/memory/maintenance.ts +367 -18
  38. package/src/memory/operations.ts +12 -0
  39. package/src/memory/proposals.ts +16 -8
  40. package/src/memory/recall.test.ts +11 -0
  41. package/src/memory/recall.ts +11 -3
  42. package/src/memory/trajectory.test.ts +178 -0
  43. package/src/memory/trajectory.ts +220 -0
  44. package/src/memory/wiki.ts +2 -1
  45. package/src/memory/wikiIndex.ts +3 -2
  46. package/src/plugins/builtin.ts +4 -2
  47. package/src/plugins/catalogue.test.ts +1 -1
  48. package/src/server/methods.ts +43 -2
  49. package/src/server/server.test.ts +20 -1
  50. package/src/sessions/approvals.test.ts +49 -0
  51. package/src/sessions/approvals.ts +26 -4
  52. package/src/sessions/contracts.ts +29 -3
  53. package/src/sessions/conversation.test.ts +83 -0
  54. package/src/sessions/lineageJoin.ts +1 -1
  55. package/src/sessions/policy.ts +4 -0
  56. package/src/sessions/questionBatches.test.ts +110 -0
  57. package/src/sessions/registry.ts +145 -27
  58. package/src/sessions/runStore.ts +14 -0
  59. package/src/sessions/runner.ts +45 -4
  60. package/src/sessions/sessions.test.ts +137 -4
  61. package/src/sessions/sessions.ts +51 -15
  62. package/src/sessions/subagentService.test.ts +159 -0
  63. package/src/sessions/subagentService.ts +236 -0
  64. package/src/sessions/subagents.ts +97 -0
  65. package/src/skills/registry.ts +16 -10
  66. package/src/terminal/accountsMenu.test.ts +225 -0
  67. package/src/terminal/accountsMenu.ts +382 -0
  68. package/src/terminal/control.test.ts +23 -0
  69. package/src/terminal/control.ts +23 -0
  70. package/src/terminal/controller.ts +134 -7
  71. package/src/terminal/hubMenu.ts +24 -0
  72. package/src/terminal/launch.test.ts +5 -14
  73. package/src/terminal/launch.ts +2 -42
  74. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  75. package/src/terminal/piExtension.ts +91 -6
  76. package/src/terminal/socket.test.ts +23 -0
  77. package/src/terminal/socket.ts +55 -20
  78. package/src/terminal/standalone.test.ts +37 -0
  79. package/src/terminal/standalone.ts +28 -7
  80. package/src/terminal/terminal.test.ts +220 -16
  81. package/src/terminal/terminals.ts +234 -16
  82. package/src/terminal/trajectory.test.ts +66 -0
  83. package/src/terminal/trajectory.ts +44 -0
  84. package/src/terminal/tui/application.test.ts +290 -0
  85. package/src/terminal/tui/application.ts +653 -0
  86. package/src/terminal/tui/commands.test.ts +278 -0
  87. package/src/terminal/tui/commands.ts +443 -0
  88. package/src/terminal/tui/dialogs.ts +301 -0
  89. package/src/terminal/tui/editor.test.ts +113 -0
  90. package/src/terminal/tui/editor.ts +109 -0
  91. package/src/terminal/tui/keybindings.test.ts +30 -0
  92. package/src/terminal/tui/keybindings.ts +59 -0
  93. package/src/terminal/tui/piAdapter.ts +62 -0
  94. package/src/terminal/tui/planMode.test.ts +10 -0
  95. package/src/terminal/tui/planMode.ts +12 -0
  96. package/src/terminal/tui/presentation.test.ts +79 -0
  97. package/src/terminal/tui/questions.test.ts +41 -0
  98. package/src/terminal/tui/questions.ts +47 -0
  99. package/src/terminal/tui/runtime.socket.test.ts +178 -0
  100. package/src/terminal/tui/runtime.test.ts +225 -0
  101. package/src/terminal/tui/runtime.ts +403 -0
  102. package/src/terminal/tui/screen.test.ts +143 -0
  103. package/src/terminal/tui/screen.ts +121 -0
  104. package/src/terminal/tui/sessionUi.ts +24 -0
  105. package/src/terminal/tui/settings.test.ts +150 -0
  106. package/src/terminal/tui/settings.ts +188 -0
  107. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  108. package/src/terminal/tui/skillInvocation.ts +51 -0
  109. package/src/terminal/tui/status.test.ts +48 -0
  110. package/src/terminal/tui/status.ts +82 -0
  111. package/src/terminal/tui/theme.ts +185 -0
  112. package/src/terminal/tui/tools.test.ts +145 -0
  113. package/src/terminal/tui/tools.ts +142 -0
  114. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  115. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  116. package/src/terminal/tui/transcript.test.ts +39 -0
  117. package/src/terminal/tui/transcript.ts +135 -0
  118. package/src/terminal/tui/ui.test.ts +313 -0
  119. package/src/terminal/tui/ui.ts +166 -0
  120. package/src/terminal/tui/welcome.ts +102 -0
  121. package/src/terminal/tui/workingStatus.test.ts +44 -0
  122. package/src/terminal/tui/workingStatus.ts +238 -0
  123. package/src/tools/askUser.test.ts +69 -0
  124. package/src/tools/askUser.ts +31 -17
  125. package/src/tools/definitions.ts +42 -1
  126. package/src/tools/maintenance.ts +29 -0
  127. package/src/tools/memoryTools.ts +1 -1
  128. package/src/tools/registryWrite.test.ts +101 -0
  129. package/src/tools/registryWrite.ts +182 -0
  130. package/src/tools/spawnSubagent.ts +47 -0
  131. package/src/tools/types.ts +2 -0
@@ -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({
@@ -282,7 +396,8 @@ describe('a pi terminal session', () => {
282
396
  const listed = await socketCall<{ tools: { name: string; parameters: { type: string } }[] }>(connection, 'tools.list')
283
397
  expect(listed.tools.map((tool) => tool.name)).toEqual(['mcp_list_tools', 'mcp_call_tool'])
284
398
  expect(listed.tools[0].parameters.type).toBe('object')
285
- await expect(socketCall(connection, 'accounts.list')).rejects.toMatchObject({ code: 'unknown_method' })
399
+ // Account calls exist, but only the session's extension holds the key that unlocks them.
400
+ await expect(socketCall(connection, 'accounts.list')).rejects.toMatchObject({ code: 'unauthorized' })
286
401
  await expect(socketCall({ ...connection, token: 'guess' }, 'wiki.list')).rejects.toMatchObject({ code: 'unauthorized' })
287
402
  await harness.terminals.dispose('term-1')
288
403
  await expect(socketCall(connection, 'wiki.list')).rejects.toMatchObject({ code: 'unauthorized' })
@@ -328,3 +443,92 @@ describe('xagent-wiki command', () => {
328
443
  expect(parseCli(['nonsense'])).toEqual({ kind: 'help' })
329
444
  })
330
445
  })
446
+
447
+
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
+
470
+ it('requires the private input-owner key for pending decisions and exact draft state', async () => {
471
+ const { connection, harness, tell } = await setup()
472
+ await tell('ready', { nativeSessionId: '/native.jsonl' })
473
+ await expect(socketCall(connection, 'terminal.pending')).rejects.toMatchObject({ code: 'unauthorized' })
474
+ const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
475
+ await socketCall(connection, 'terminal.draft', { dirty: true }, 1000, key)
476
+ expect(harness.terminals.state('term-1').dirty).toBe(true)
477
+ await socketCall(connection, 'terminal.draft', { dirty: false }, 1000, key)
478
+ expect(harness.terminals.state('term-1').dirty).toBe(false)
479
+ await expect(socketCall(connection, 'terminal.approve', { requestId: 'missing', decision: 'approved' }, 1000, key)).rejects.toMatchObject({ code: 'invalid_request' })
480
+ })
481
+
482
+ it('answers through the existing run registry and rejects stale answers', async () => {
483
+ const { connection, tell, events } = await setup()
484
+ const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
485
+ await tell('ready')
486
+ await tell('begin', { text: 'Ask a question' })
487
+ const answer = tell('question', { requestId: 'native-question', question: 'Choose?', options: [{ label: 'A', description: 'First' }], multiSelect: false })
488
+ let id = ''
489
+ await vi.waitFor(async () => {
490
+ const pending = await socketCall<{ requests: Array<{ requestId: string }> }>(connection, 'terminal.pending', {}, 1000, key)
491
+ expect(pending.requests).toHaveLength(1)
492
+ id = pending.requests[0].requestId
493
+ })
494
+ await socketCall(connection, 'terminal.answer', { requestId: id, answer: { selected: ['A'] } }, 1000, key)
495
+ expect(await answer).toEqual({ selected: ['A'] })
496
+ expect(events.some(event => event.type === 'question.answered')).toBe(true)
497
+ await expect(socketCall(connection, 'terminal.answer', { requestId: id, answer: { selected: ['A'] } }, 1000, key)).rejects.toMatchObject({ code: 'invalid_request' })
498
+ })
499
+
500
+ it('keeps an approval and question with the same native tool id independent', async () => {
501
+ const { connection, tell } = await setup()
502
+ const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
503
+ await tell('ready'); await tell('begin', { text: 'Ask after approval' })
504
+ const approval = tell('approval', { requestId: 'same-tool-call', target: 'ask_user_question', action: '{}' })
505
+ let approvalId = ''
506
+ await vi.waitFor(async () => {
507
+ const { requests } = await socketCall<{ requests: Array<{ requestId: string }> }>(connection, 'terminal.pending', {}, 1000, key)
508
+ expect(requests).toHaveLength(1); approvalId = requests[0].requestId
509
+ })
510
+ await socketCall(connection, 'terminal.approve', { requestId: approvalId, decision: 'approved' }, 1000, key)
511
+ expect(await approval).toEqual({ decision: 'approved' })
512
+ const question = tell('question', { requestId: 'same-tool-call', question: 'Continue?', options: [{ label: 'Yes' }] })
513
+ let questionId = ''
514
+ await vi.waitFor(async () => {
515
+ const { requests } = await socketCall<{ requests: Array<{ requestId: string; type: string }> }>(connection, 'terminal.pending', {}, 1000, key)
516
+ expect(requests).toHaveLength(1); expect(requests[0].type).toBe('question'); questionId = requests[0].requestId
517
+ })
518
+ expect(questionId).not.toBe(approvalId)
519
+ await socketCall(connection, 'terminal.answer', { requestId: questionId, answer: { selected: ['Yes'] } }, 1000, key)
520
+ expect(await question).toEqual({ selected: ['Yes'] })
521
+ })
522
+
523
+ it('cancels a pending question through the run interrupt path', async () => {
524
+ const { connection, tell, harness } = await setup()
525
+ const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
526
+ await tell('ready')
527
+ await tell('begin', { text: 'Wait for a question' })
528
+ const answer = tell('question', { requestId: 'cancel-question', question: 'Wait?', options: [], multiSelect: false })
529
+ await vi.waitFor(async () => expect((await socketCall<{ requests: unknown[] }>(connection, 'terminal.pending', {}, 1000, key)).requests).toHaveLength(1))
530
+ await socketCall(connection, 'terminal.interrupt', {}, 1000, key)
531
+ expect(await answer).toEqual({ selected: [] })
532
+ expect(harness.terminals.state('term-1').active).toBe(false)
533
+ })
534
+ })
@@ -1,15 +1,22 @@
1
- // Terminal sessions: a person working with the `pi` command in a terminal that the embedding
2
- // application draws. The harness prepares the launch (isolated account, compiled instructions,
3
- // skills, the session socket), then follows the process through its extension. It never owns
4
- // the terminal: the application spawns the process from the spec returned here and reports when
5
- // it exits.
1
+ // A host owns the PTY and process supervision; Harness owns the terminal application inside it.
2
+ // This service prepares the isolated account, compiled context, trusted resource selection and
3
+ // socket bridge. The default launch runs our frontend with Pi's persistent SDK runtime.
6
4
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
7
5
  import { dirname, join } from 'node:path'
8
6
  import { fileURLToPath } from 'node:url'
9
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'
10
16
  import type { Accounts } from '../accounts/accounts'
17
+ import type { Approvals } from '../sessions/approvals'
11
18
  import { configDir, launchEnv } from '../accounts/env'
12
- import type { Account } from '../accounts/store'
19
+ import { readAudit, type Account } from '../accounts/store'
13
20
  import type { Bus } from '../core/bus'
14
21
  import { log } from '../core/bus'
15
22
  import type { HarnessEnv } from '../core/env'
@@ -20,8 +27,9 @@ import type { BrokerSession, ConnectedApps } from '../mcp/service'
20
27
  import type { CallerContext } from '../memory/caller'
21
28
  import { dispatch } from '../memory/dispatch'
22
29
  import type { AttachParams, PluginScope } from '../plugins/api'
23
- import type { Plugins } from '../plugins/plugins'
30
+ import type { Loading, Plugins } from '../plugins/plugins'
24
31
  import type { Plugin } from '../plugins/types'
32
+ import type { Hub } from '../hub/hub'
25
33
  import { compile, copyToRun, memoryDelta, record, render, saveForSession, type Launch } from '../prompt/compile'
26
34
  import type { RunRecord } from '../sessions/contracts'
27
35
  import { isActiveStatus } from '../sessions/contracts'
@@ -35,6 +43,17 @@ import { SOCKET_ENV, TOKEN_ENV } from './client'
35
43
  import { TerminalController, type PolicyReply } from './controller'
36
44
  import { resolvePiCommand, terminalCommandFiles } from './launch'
37
45
  import { SessionSocket, socketPath } from './socket'
46
+ import { listTerminalTrajectoryRuns, readTerminalTrajectory } from './trajectory'
47
+
48
+ /** Identity of the exact resource decision that this terminal was prepared with. */
49
+ function resourceIdentity(loading: Loading | null): string {
50
+ if (!loading) return 'null'
51
+ return JSON.stringify({
52
+ extensions: loading.extensionPaths, builtins: loading.builtins, skills: loading.skillPaths,
53
+ prompts: loading.promptPaths, themes: loading.themePaths, detached: loading.detached,
54
+ project: loading.projectCode, packages: loading.packageCode
55
+ })
56
+ }
38
57
 
39
58
  /** The files a terminal session needs on disk, outside this process. */
40
59
  export interface TerminalAssets {
@@ -54,6 +73,7 @@ export function defaultAssets(): TerminalAssets {
54
73
  }
55
74
 
56
75
  export interface TerminalPrepareInput {
76
+ subagents?: SubagentOptions | null
57
77
  sessionId: string
58
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. */
59
79
  accountId?: string | null
@@ -91,10 +111,13 @@ interface Live {
91
111
  }
92
112
 
93
113
  export interface TerminalsOptions {
114
+ subagents?: Subagents
115
+ resolveCapabilities?: (caller: CallerContext, tools: readonly string[], cwd: string) => Promise<CapabilitySet>
94
116
  env: HarnessEnv
95
117
  stateDir: string
96
118
  accounts: Accounts
97
119
  runs: RunRegistry
120
+ approvals: Approvals
98
121
  apps: ConnectedApps
99
122
  policy: ToolPolicy
100
123
  bus: Bus
@@ -102,8 +125,9 @@ export interface TerminalsOptions {
102
125
  plugins?: Plugins
103
126
  /** Lists plugins and applies a person's choices, for the session's `/plugins` command. */
104
127
  pluginControl?: PluginControl
128
+ hub?: Hub
105
129
  assets?: TerminalAssets
106
- /** The `pi` executable. Defaults to the PATH command on POSIX and the bundled CLI on Windows. */
130
+ /** The `pi` executable. Defaults to the pinned bundled CLI through the active Node runtime. */
107
131
  program?: () => string | null
108
132
  }
109
133
 
@@ -121,13 +145,31 @@ export class Terminals {
121
145
  private readonly sessions = new Map<string, Live>()
122
146
  private readonly socket: SessionSocket
123
147
  private readonly assets: TerminalAssets
148
+ /** A session's `/accounts` switch, taken once its Pi process has ended. */
149
+ private readonly relaunches = new Map<string, { accountId: string; nativeSessionId: string | null }>()
124
150
 
125
151
  constructor(private readonly options: TerminalsOptions) {
126
152
  this.assets = options.assets ?? defaultAssets()
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
+ })
127
168
  this.socket = new SessionSocket(socketPath(options.stateDir), {
128
169
  memory: (caller, call, params) => dispatch(options.env, caller, call, params),
129
170
  apps: (session, call, params) => this.appsCall(session, call, params),
130
- plugins: (grant, call, params, control) => this.pluginsCall(grant.sessionId, call, params, control)
171
+ plugins: (grant, call, params, control) => this.pluginsCall(grant.sessionId, call, params, control),
172
+ accounts: (grant, call, params, control) => this.accountsCall(grant.sessionId, call, params, control)
131
173
  })
132
174
  }
133
175
 
@@ -200,6 +242,12 @@ export class Terminals {
200
242
  if (!api || !live) throw new HarnessError('unknown_method', `Unknown call ${call}.`)
201
243
  const projectId = live.input.projectId ?? null
202
244
  const scope = { cwd: live.input.cwd, accountId: live.account.id, projectId }
245
+ if (call === 'hub.list' && this.options.hub) return this.options.hub.list(params.refresh === true)
246
+ if (call === 'hub.install' && this.options.hub) {
247
+ if (!control) throw new HarnessError('unauthorized', 'Only a person at this terminal can install items. Type /hub.')
248
+ if (typeof params.id !== 'string' || typeof params.version !== 'string') throw invalid('An item id and version are required.')
249
+ return this.options.hub.install(params.id, params.version)
250
+ }
203
251
  if (call === 'plugins.list') return api.list(scope)
204
252
  if (call === 'plugins.session') return { projectId }
205
253
  if (!control) throw new HarnessError('unauthorized', 'Only a person at this terminal can change plugins. Type /plugins.')
@@ -220,6 +268,86 @@ export class Terminals {
220
268
  }
221
269
  }
222
270
 
271
+ /**
272
+ * `/accounts` in a terminal. Every call needs the session's control key: adding, signing in,
273
+ * or removing an account is a person's choice, never the model's shell command.
274
+ */
275
+ private async accountsCall(sessionId: string, call: string, params: Record<string, unknown>, control: boolean): Promise<unknown> {
276
+ const { accounts } = this.options
277
+ const live = this.sessions.get(sessionId)
278
+ if (!live) throw new HarnessError('unknown_method', `Unknown call ${call}.`)
279
+ if (!control) throw new HarnessError('unauthorized', 'Only a person at this terminal can manage accounts. Type /accounts.')
280
+ const text = (name: string): string => {
281
+ if (typeof params[name] !== 'string' || !params[name]) throw invalid(`${name} is required.`)
282
+ return params[name] as string
283
+ }
284
+ const optional = (name: string): string | null => (typeof params[name] === 'string' ? (params[name] as string) : null)
285
+ switch (call) {
286
+ case 'accounts.deviceId':
287
+ return accounts.installationDeviceId()
288
+ case 'accounts.list':
289
+ return accounts.overview()
290
+ case 'accounts.session':
291
+ return { accountId: live.account.id, label: live.account.label }
292
+ case 'accounts.create':
293
+ return accounts.create(text('label'), optional('authKind') ?? 'subscription')
294
+ case 'accounts.rename':
295
+ return accounts.rename(text('accountId'), text('label'))
296
+ case 'accounts.setDefault':
297
+ return accounts.setDefault(text('accountId'))
298
+ case 'accounts.remove': {
299
+ const accountId = text('accountId')
300
+ // Every terminal in this process counts, this session's own included. Another process's terminals are not visible here.
301
+ if (accounts.terminalCount(accountId) > 0) throw new HarnessError('account_in_use', 'A running terminal is using this account, this one included.', 'Switch this session to another account first, then remove it.')
302
+ return accounts.remove(accountId)
303
+ }
304
+ case 'accounts.refresh':
305
+ return accounts.refresh(optional('accountId'))
306
+ case 'accounts.providers':
307
+ return accounts.providers()
308
+ case 'accounts.loginStart':
309
+ return accounts.loginStart(text('accountId'), text('provider'), optional('method'))
310
+ case 'accounts.loginStatus':
311
+ return accounts.loginStatus(text('accountId'))
312
+ case 'accounts.loginRespond':
313
+ accounts.loginRespond(text('loginId'), text('promptId'), optional('value'))
314
+ return {}
315
+ case 'accounts.loginCancel':
316
+ accounts.loginCancel(text('accountId'), optional('loginId'))
317
+ return {}
318
+ case 'accounts.setApiKey':
319
+ return accounts.setApiKey(text('accountId'), text('key'), text('provider'))
320
+ case 'accounts.removeProvider':
321
+ return accounts.removeProvider(text('accountId'), text('provider'))
322
+ case 'accounts.signOut':
323
+ return accounts.signOut(text('accountId'))
324
+ case 'accounts.savePool': {
325
+ const pool = params.pool as { id?: unknown; label?: unknown; accountIds?: unknown } | undefined
326
+ if (!pool || typeof pool.label !== 'string' || !Array.isArray(pool.accountIds) || !pool.accountIds.every((id) => typeof id === 'string')) throw invalid('A pool needs a label and its account ids.')
327
+ return accounts.savePool({ id: typeof pool.id === 'string' ? pool.id : undefined, label: pool.label, accountIds: pool.accountIds as string[] })
328
+ }
329
+ case 'accounts.removePool':
330
+ return accounts.removePool(text('poolId'))
331
+ case 'accounts.audit':
332
+ return readAudit(accounts.dir, optional('accountId'), typeof params.limit === 'number' ? params.limit : 100)
333
+ case 'accounts.relaunch': {
334
+ // Checked here so a bad choice is refused before the session is asked to end.
335
+ const target = accounts.resolve(text('accountId'))
336
+ this.relaunches.set(sessionId, { accountId: target.id, nativeSessionId: live.controller.nativeSessionId })
337
+ return { accountId: target.id, label: target.label }
338
+ }
339
+ default:
340
+ throw new HarnessError('unknown_method', `Unknown call ${call}.`)
341
+ }
342
+ }
343
+
344
+ /** The account a session asked to switch to. Taken once, after its Pi process has ended and before `dispose`. */
345
+ takeRelaunch(sessionId: string): { accountId: string; nativeSessionId: string | null } | null {
346
+ const request = this.relaunches.get(sessionId) ?? null
347
+ this.relaunches.delete(sessionId)
348
+ return request
349
+ }
350
+
223
351
  private live(sessionId: string): Live {
224
352
  const live = this.sessions.get(sessionId)
225
353
  if (!live) throw new HarnessError('terminal_not_ready', 'The terminal controller is unavailable. Reopen this session.')
@@ -283,19 +411,80 @@ export class Terminals {
283
411
  const { policy } = this.options
284
412
  if (policy.scope) {
285
413
  const scope = await policy.scope(call)
286
- 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.' }
287
415
  try {
288
416
  await policy.authorize?.(call)
289
417
  } catch (error) {
290
418
  return { allowed: false, reason: error instanceof Error ? error.message : 'Tool unavailable in the settings.' }
291
419
  }
292
- 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
+ }
293
428
  }
294
429
  // With no application policy: reads run, a read-only session refuses the rest, and anything else asks.
295
430
  const name = tool.toLowerCase()
296
- 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 }
297
432
  if (live.input.readOnly) return { allowed: false, reason: 'This session is read-only.' }
298
- 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}.`)
299
488
  }
300
489
 
301
490
  /**
@@ -303,11 +492,12 @@ export class Terminals {
303
492
  * process's end with `dispose`, also when it never starts.
304
493
  */
305
494
  async prepare(input: TerminalPrepareInput): Promise<TerminalLaunch> {
495
+ input = structuredClone(input)
306
496
  const { env, accounts, apps, bus } = this.options
307
497
  const { root } = env
308
498
  if (!input?.sessionId || !input.cwd || !input.sessionFolder) throw invalid('A terminal session needs an id, a working folder, and its own folder.')
309
499
  await this.dispose(input.sessionId)
310
- const command = resolvePiCommand({ platform: process.platform, node: this.assets.node, programOverride: this.options.program })
500
+ const command = resolvePiCommand({ platform: process.platform, node: this.assets.node, cli: this.assets.cli, programOverride: this.options.program })
311
501
  // A pinned account is used as it is; it is never exchanged for another one.
312
502
  const account = input.accountId ? accounts.find(input.accountId) : accounts.resolve(null, null)
313
503
  const launch = launchEnv(accounts.dir, account)
@@ -338,15 +528,42 @@ export class Terminals {
338
528
  nativeSessionId: input.nativeSessionId ?? null,
339
529
  begin: ({ reservedRunId, text }) => this.begin(live, reservedRunId, text),
340
530
  policy: (run, tool, args) => this.decide(live, run, tool, args),
531
+ allowToolGlobally: target => { this.options.approvals.allowTool('pi', target) },
341
532
  native: (nativeSessionId) => bus.emit({ type: 'terminal.native', sessionId, nativeSessionId }),
342
- telemetry: (event) => bus.emit({ type: 'terminal.telemetry', sessionId, event })
533
+ telemetry: (event) => bus.emit({ type: 'terminal.telemetry', sessionId, event }),
534
+ interrupt: runId => this.options.runs.interrupt(runId),
535
+ resolveApproval: (runId, id, decision) => this.options.runs.resolveApproval(runId, id, decision),
536
+ answerQuestion: (runId, id, answer) => this.options.runs.answerQuestion(runId, id, answer),
537
+ answerQuestions: (runId, batchId, answers) => this.options.runs.answerQuestions(runId, batchId, answers)
343
538
  })
344
539
  }
345
540
  token = this.socket.issue({
346
541
  sessionId,
347
542
  caller: () => ({ ...caller, runId: live.controller.activeRun?.runId ?? null }),
348
543
  broker,
349
- terminal: { accept: (event, data) => live.controller.accept(event, data), disconnected: () => live.controller.disconnected() }
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
+ }
562
+ if (call !== 'terminal.resources') return live.controller.control(call, params)
563
+ const current = await this.options.plugins?.loading({ cwd: input.cwd, agentDir: configDir(accounts.dir, account), install: false, caller: { projectId: input.projectId ?? null } }) ?? null
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.')
565
+ return {}
566
+ } }
350
567
  })
351
568
  live.token = token
352
569
  this.sessions.set(sessionId, live)
@@ -370,6 +587,7 @@ export class Terminals {
370
587
  args,
371
588
  env: {
372
589
  ...launch,
590
+ XINGULAR_HARNESS_NAME: env.productName,
373
591
  ...(process.platform === 'win32' ? { XINGULAR_HARNESS_NODE: this.assets.node, XINGULAR_HARNESS_CLI: this.assets.cli } : {}),
374
592
  ...prependSearchPath(this.ensureCommands(), searchPath()),
375
593
  [SOCKET_ENV]: this.socket.path,