@xingular/harness 0.1.7 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +36 -13
  3. package/dist/cli.js +10728 -8033
  4. package/dist/terminal-pi.js +365 -24
  5. package/docs/README.md +3 -0
  6. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  7. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +70 -0
  8. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  9. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  10. package/docs/terminal-tui.md +109 -13
  11. package/package.json +5 -4
  12. package/src/accounts/accounts.test.ts +87 -1
  13. package/src/accounts/accounts.ts +120 -18
  14. package/src/core/bus.test.ts +22 -0
  15. package/src/core/bus.ts +17 -2
  16. package/src/harness.ts +12 -2
  17. package/src/hub/hub.test.ts +49 -1
  18. package/src/hub/hub.ts +122 -6
  19. package/src/hub/schema.ts +13 -1
  20. package/src/index.ts +7 -2
  21. package/src/mcp/connections.ts +39 -12
  22. package/src/mcp/service.test.ts +49 -3
  23. package/src/mcp/service.ts +14 -3
  24. package/src/mcp/types.ts +10 -0
  25. package/src/memory/curation.test.ts +44 -1
  26. package/src/memory/dispatch.ts +5 -0
  27. package/src/memory/maintenance.test.ts +17 -1
  28. package/src/memory/maintenance.ts +169 -8
  29. package/src/memory/recall.ts +2 -1
  30. package/src/memory/trajectory.test.ts +178 -0
  31. package/src/memory/trajectory.ts +220 -0
  32. package/src/memory/wiki.ts +2 -1
  33. package/src/memory/wikiIndex.ts +3 -2
  34. package/src/plugins/builtin.ts +4 -2
  35. package/src/plugins/catalogue.test.ts +1 -1
  36. package/src/server/methods.ts +8 -0
  37. package/src/server/server.test.ts +20 -1
  38. package/src/sessions/approvals.test.ts +49 -0
  39. package/src/sessions/approvals.ts +26 -4
  40. package/src/sessions/conversation.test.ts +56 -0
  41. package/src/sessions/lineageJoin.ts +1 -1
  42. package/src/sessions/planMode.test.ts +40 -0
  43. package/src/sessions/planMode.ts +57 -0
  44. package/src/sessions/registry.ts +38 -7
  45. package/src/sessions/runStore.ts +14 -0
  46. package/src/sessions/runner.ts +9 -3
  47. package/src/sessions/sessions.test.ts +69 -7
  48. package/src/sessions/sessions.ts +47 -39
  49. package/src/sessions/subagentService.test.ts +159 -0
  50. package/src/sessions/subagentService.ts +236 -0
  51. package/src/sessions/subagents.ts +97 -0
  52. package/src/terminal/controller.ts +73 -10
  53. package/src/terminal/piExtension.ts +42 -6
  54. package/src/terminal/socket.test.ts +23 -0
  55. package/src/terminal/socket.ts +42 -18
  56. package/src/terminal/standalone.ts +12 -0
  57. package/src/terminal/terminal.test.ts +150 -15
  58. package/src/terminal/terminals.ts +113 -5
  59. package/src/terminal/trajectory.test.ts +66 -0
  60. package/src/terminal/trajectory.ts +44 -0
  61. package/src/terminal/tui/application.test.ts +206 -9
  62. package/src/terminal/tui/application.ts +251 -81
  63. package/src/terminal/tui/commands.test.ts +70 -9
  64. package/src/terminal/tui/commands.ts +99 -7
  65. package/src/terminal/tui/editor.test.ts +56 -27
  66. package/src/terminal/tui/editor.ts +64 -19
  67. package/src/terminal/tui/keybindings.test.ts +7 -0
  68. package/src/terminal/tui/keybindings.ts +9 -1
  69. package/src/terminal/tui/planMode.test.ts +10 -0
  70. package/src/terminal/tui/planMode.ts +2 -0
  71. package/src/terminal/tui/runtime.socket.test.ts +77 -3
  72. package/src/terminal/tui/runtime.test.ts +10 -0
  73. package/src/terminal/tui/runtime.ts +24 -9
  74. package/src/terminal/tui/screen.test.ts +143 -0
  75. package/src/terminal/tui/screen.ts +121 -0
  76. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  77. package/src/terminal/tui/skillInvocation.ts +51 -0
  78. package/src/terminal/tui/status.test.ts +24 -46
  79. package/src/terminal/tui/status.ts +42 -69
  80. package/src/terminal/tui/theme.ts +18 -7
  81. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  82. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  83. package/src/terminal/tui/transcript.test.ts +14 -0
  84. package/src/terminal/tui/transcript.ts +11 -0
  85. package/src/terminal/tui/ui.ts +2 -2
  86. package/src/terminal/tui/welcome.ts +4 -4
  87. package/src/terminal/tui/workingStatus.test.ts +44 -0
  88. package/src/terminal/tui/workingStatus.ts +238 -0
  89. package/src/tools/definitions.ts +42 -1
  90. package/src/tools/maintenance.ts +6 -1
  91. package/src/tools/spawnSubagent.ts +40 -85
  92. package/src/tools/types.ts +4 -0
  93. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  94. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  95. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  96. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  97. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  98. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  99. package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
  100. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  101. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  102. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  103. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  104. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  105. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  106. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  107. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  108. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  109. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  110. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  111. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  112. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
  113. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  114. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  115. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  116. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  117. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
  118. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  119. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
  120. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
  121. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  122. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  123. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
  124. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
  125. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  126. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
  127. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  128. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  129. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
  130. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  131. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
  132. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
  133. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
  134. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
  135. package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
@@ -9,7 +9,8 @@ import { connectionFromEnv, socketCall, terminalCall } from './client'
9
9
  import { runAccountsMenu, type SessionAccount } from './accountsMenu'
10
10
  import { runPluginsMenu } from './pluginMenu'
11
11
  import { runHubMenu } from './hubMenu'
12
- import { claimTerminalControl } from './control'
12
+ import { claimTerminalControl, controlCall } from './control'
13
+ import { SUBAGENT_OWNER, SUBAGENT_RECEIPT, SUBAGENT_RESULTS, subagentHistory, type SubagentReceipt } from '../sessions/subagents'
13
14
  import { askUserTool } from '../tools/askUser'
14
15
  import type { QuestionAnswer } from '../sessions/registry'
15
16
 
@@ -27,9 +28,18 @@ function resultSize(result: unknown): number | null {
27
28
  return content.reduce((total: number, part: { type?: string; text?: unknown }) => total + (part?.type === 'text' && typeof part.text === 'string' ? part.text.length : 0), 0)
28
29
  }
29
30
 
31
+ async function withParentCancellation<T>(signal: AbortSignal | undefined, work: () => Promise<T>): Promise<T> {
32
+ const stop = () => { void controlCall('terminal.interrupt').catch(() => {}) }
33
+ if (signal?.aborted) stop()
34
+ signal?.addEventListener('abort', stop, { once: true })
35
+ try { return await work() } finally { signal?.removeEventListener('abort', stop) }
36
+ }
37
+
30
38
  export default async function (pi: ExtensionAPI) {
31
39
  await terminalCall('startup', { phase: 'Pi extension loaded' })
32
40
  let context: ExtensionContext | null = null
41
+ let nativeSubagents = false
42
+ let subagentParent = false
33
43
  let timer: ReturnType<typeof setInterval> | null = null
34
44
  let polling = false
35
45
  let failure: string | undefined
@@ -105,6 +115,15 @@ export default async function (pi: ExtensionAPI) {
105
115
  }
106
116
  })
107
117
  }
118
+ const native = await controlCall<{ tools: SocketTool[] }>('terminal.subagents.tools')
119
+ nativeSubagents = native.tools.length > 0
120
+ for (const tool of native.tools) pi.registerTool({
121
+ name: tool.name, label: tool.label, description: tool.description, parameters: Type.Unsafe(tool.parameters),
122
+ execute: async (_id, input, signal) => {
123
+ const result = await withParentCancellation(signal, () => controlCall<{ text: string; details?: Record<string, unknown> }>('terminal.subagents.call', { name: tool.name, input }, 2_147_483_647))
124
+ return { content: [{ type: 'text' as const, text: result.text }], details: result.details ?? {} }
125
+ }
126
+ })
108
127
  } catch (error) {
109
128
  await terminalCall('startupFailed', { reason: `${stage} failed: ${error instanceof Error ? error.message : 'Unavailable'}` }).catch(() => {})
110
129
  throw error
@@ -142,8 +161,21 @@ export default async function (pi: ExtensionAPI) {
142
161
  reasoningTokens = undefined
143
162
  costReported = false
144
163
  // A run a plugin starts with a custom message has no user prompt to record; it still begins a turn.
145
- await terminalCall('begin', { text: event.messageType === 'user' ? event.prompt : '' })
164
+ const begun = await terminalCall<{ runId: string }>('begin', { text: event.messageType === 'user' ? event.prompt : '' })
165
+ if (nativeSubagents) {
166
+ pi.appendEntry(SUBAGENT_OWNER, { runId: begun.runId })
167
+ await controlCall('terminal.subagents.register', { tools: pi.getActiveTools(), model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null, effort: pi.getThinkingLevel(), history: subagentHistory(ctx.sessionManager.getBranch()) })
168
+ subagentParent = true
169
+ }
146
170
  })
171
+ const deliverChildren = async (event: any, ctx: ExtensionContext, settle: boolean) => {
172
+ if (!nativeSubagents || (settle && event.outcome !== 'completed') || ctx.signal?.aborted) return
173
+ const result = await withParentCancellation(ctx.signal, () => controlCall<{ text: string; receipt: SubagentReceipt } | null>('terminal.subagents.delivery', { history: subagentHistory([...ctx.sessionManager.getBranch(), ...event.entries]), settle }, 2_147_483_647))
174
+ if (!result) return
175
+ return { entries: [...event.entries, { type: 'custom_message' as const, customType: SUBAGENT_RESULTS, content: result.text, display: false, details: { [SUBAGENT_RECEIPT]: result.receipt } }], continue: true }
176
+ }
177
+ pi.on('turn_end', (event, ctx) => deliverChildren(event, ctx, false))
178
+ pi.on('agent_before_settle', (event, ctx) => deliverChildren(event, ctx, true))
147
179
  pi.on('message_end', async (event) => {
148
180
  if (event.message.role !== 'assistant') return
149
181
  if (event.message.stopReason === 'error' || event.message.stopReason === 'aborted') failure = event.message.errorMessage ?? `Pi stopped: ${event.message.stopReason}`
@@ -193,9 +225,9 @@ export default async function (pi: ExtensionAPI) {
193
225
  })
194
226
  pi.on('tool_call', async (event) => {
195
227
  try {
196
- const policy = await terminalCall<{ allowed: boolean; reason?: string; requiresApproval?: boolean }>('policy', { tool: event.toolName, args: event.input })
228
+ const policy = await controlCall<{ allowed: boolean; reason?: string; requiresApproval?: boolean }>('terminal.policy', { tool: event.toolName, args: event.input })
197
229
  if (policy.requiresApproval) {
198
- const approval = await terminalCall<{ decision: string }>('approval', { requestId: event.toolCallId, target: event.toolName, action: JSON.stringify(event.input), tool: event.toolName, args: event.input })
230
+ const approval = await controlCall<{ decision: string }>('terminal.approval', { requestId: event.toolCallId, tool: event.toolName, args: event.input })
199
231
  if (approval.decision === 'approved') return
200
232
  return { block: true, reason: 'The user denied this action. Ask how they would like to proceed.' }
201
233
  }
@@ -218,8 +250,12 @@ export default async function (pi: ExtensionAPI) {
218
250
  fileTools.delete(event.toolCallId)
219
251
  if (!event.isError && path) await terminalCall('file', { path, change: 'modified' })
220
252
  })
221
- pi.on('agent_settled', async () => {
222
- await terminalCall('settled', { error: failure })
253
+ pi.on('agent_settled', async (event, ctx) => {
254
+ if (subagentParent && !event.aborted && !failure) {
255
+ try { await controlCall('terminal.subagents.verify', { history: subagentHistory(ctx.sessionManager.getBranch()) }, 2_147_483_647) } catch (error) { failure = error instanceof Error ? error.message : String(error) }
256
+ }
257
+ subagentParent = false
258
+ await terminalCall('settled', { error: failure, aborted: event.aborted })
223
259
  })
224
260
  pi.on('user_bash', async () => {
225
261
  throw new Error('Use the bash tool so this action can be checked.')
@@ -33,6 +33,29 @@ describe('session socket path', () => {
33
33
  })
34
34
 
35
35
  describe('session socket', () => {
36
+ it.skipIf(process.platform === 'win32')('keeps an existing Harness socket reachable when another instance uses the same state folder', async () => {
37
+ const base = mkdtempSync(join(tmpdir(), 'sock-shared-state-'))
38
+ const path = socketPath(base)
39
+ const first = new SessionSocket(path, handlers)
40
+ const second = new SessionSocket(path, handlers)
41
+ const canConnect = (socketPath: string): Promise<void> => new Promise((resolve, reject) => {
42
+ const client = createConnection(socketPath)
43
+ client.once('connect', () => { client.end(); resolve() })
44
+ client.once('error', reject)
45
+ })
46
+ try {
47
+ await first.listen()
48
+ await second.listen()
49
+ expect(second.path).not.toBe(first.path)
50
+ await second.close()
51
+ await expect(canConnect(first.path)).resolves.toBeUndefined()
52
+ } finally {
53
+ await first.close()
54
+ await second.close()
55
+ rmSync(base, { recursive: true, force: true })
56
+ }
57
+ })
58
+
36
59
  it.skipIf(process.platform !== 'win32')('listens on a named pipe and answers a request on Windows', async () => {
37
60
  const socket = new SessionSocket(socketPath('unused'), handlers)
38
61
  await socket.listen()
@@ -56,6 +56,11 @@ export function socketPath(stateDir: string): string {
56
56
  return join(tmpdir(), `xh-${createHash('sha256').update(path).digest('hex').slice(0, 16)}.sock`)
57
57
  }
58
58
 
59
+ function alternateSocketPath(path: string): string {
60
+ const candidate = join(dirname(path), `harness-${process.pid}-${randomBytes(8).toString('hex')}.sock`)
61
+ return Buffer.byteLength(candidate) <= MAX_SOCKET_PATH ? candidate : join(tmpdir(), `xh-${randomBytes(16).toString('hex')}.sock`)
62
+ }
63
+
59
64
  /** A named pipe has no folder, file mode, or owner to check or clean up. */
60
65
  const isPipePath = (path: string): boolean => path.startsWith('\\\\.\\pipe\\')
61
66
 
@@ -88,6 +93,7 @@ function refusedBind(path: string, error: unknown): unknown {
88
93
  export class SessionSocket {
89
94
  private server: Server | null = null
90
95
  private listening: Promise<void> | null = null
96
+ private socketPathValue: string
91
97
  private readonly grants = new Map<string, SocketGrant>()
92
98
  /**
93
99
  * A second key per token, handed out once. The token is in the session's environment, where a
@@ -97,10 +103,11 @@ export class SessionSocket {
97
103
  private readonly controls = new Map<string, string>()
98
104
  private readonly clients = new Set<Socket>()
99
105
 
100
- constructor(
101
- readonly path: string,
102
- private readonly handlers: SocketHandlers
103
- ) {}
106
+ constructor(path: string, private readonly handlers: SocketHandlers) {
107
+ this.socketPathValue = path
108
+ }
109
+
110
+ get path(): string { return this.socketPathValue }
104
111
 
105
112
  issue(grant: SocketGrant): string {
106
113
  const token = `${newId()}${newId()}`.replaceAll('-', '')
@@ -141,6 +148,7 @@ export class SessionSocket {
141
148
  const call = typeof request.call === 'string' ? request.call : ''
142
149
  const params = (request.params && typeof request.params === 'object' ? request.params : {}) as Record<string, unknown>
143
150
  if (['wiki.', 'memory.', 'recall.'].some((prefix) => call.startsWith(prefix))) return { result: this.handlers.memory(grant.caller(), call, params) }
151
+ if (call === 'tools.list' && !grant.broker) return { result: { tools: [] } }
144
152
  if (['mcp.', 'tools.'].some((prefix) => call.startsWith(prefix))) {
145
153
  if (!grant.broker) throw new HarnessError('mcp_unauthorized', 'Connected apps are not available to this session.')
146
154
  return { result: await this.handlers.apps(grant.broker, call, params) }
@@ -193,22 +201,14 @@ export class SessionSocket {
193
201
  })
194
202
  }
195
203
 
196
- /** Binds the socket once; later calls wait for the same bind. */
197
- listen(): Promise<void> {
198
- this.listening ??= new Promise<void>((resolve, reject) => {
199
- if (!isPipePath(this.path)) {
200
- mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
201
- rmSync(this.path, { force: true })
202
- }
204
+ private bind(path: string): Promise<void> {
205
+ return new Promise<void>((resolve, reject) => {
203
206
  const server = createServer((client) => this.serve(client))
204
- server.once('error', (error) => {
205
- this.listening = null
206
- reject(refusedBind(this.path, error))
207
- })
208
- server.listen(this.path, () => {
209
- if (!isPipePath(this.path)) {
207
+ server.once('error', reject)
208
+ server.listen(path, () => {
209
+ if (!isPipePath(path)) {
210
210
  try {
211
- chmodSync(this.path, 0o600)
211
+ chmodSync(path, 0o600)
212
212
  } catch (error) {
213
213
  log('session socket permissions', error instanceof Error ? error.message : String(error))
214
214
  }
@@ -217,6 +217,30 @@ export class SessionSocket {
217
217
  resolve()
218
218
  })
219
219
  })
220
+ }
221
+
222
+ /** Binds the usual path, or an instance-specific path if another Harness owns it. */
223
+ private async bindAvailablePath(): Promise<void> {
224
+ if (!isPipePath(this.path)) mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
225
+ try {
226
+ await this.bind(this.path)
227
+ } catch (error) {
228
+ if (isPipePath(this.path) || (error as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw refusedBind(this.path, error)
229
+ this.socketPathValue = alternateSocketPath(this.path)
230
+ try {
231
+ await this.bind(this.path)
232
+ } catch (alternateError) {
233
+ throw refusedBind(this.path, alternateError)
234
+ }
235
+ }
236
+ }
237
+
238
+ /** Binds the socket once; later calls wait for the same bind. */
239
+ listen(): Promise<void> {
240
+ this.listening ??= this.bindAvailablePath().catch(error => {
241
+ this.listening = null
242
+ throw error
243
+ })
220
244
  return this.listening
221
245
  }
222
246
 
@@ -1,9 +1,11 @@
1
1
  // The standalone host prepares an isolated Harness terminal and supervises its frontend.
2
2
  import { spawn as nodeSpawn } from 'node:child_process'
3
3
  import { createHash, randomUUID } from 'node:crypto'
4
+ import { appendFileSync } from 'node:fs'
4
5
  import { homedir } from 'node:os'
5
6
  import { isAbsolute, join, resolve } from 'node:path'
6
7
 
8
+ import { setLogSink } from '../core/bus'
7
9
  import { createHarness, type Harness, type HarnessOptions } from '../harness'
8
10
  import type { TerminalLaunch } from './terminals'
9
11
 
@@ -128,6 +130,15 @@ export async function runStandaloneTerminal(argv: string[], dependencies: Standa
128
130
  const harness = makeHarness({ dataRoot: paths.dataRoot, stateDir: paths.stateDir, productName: 'Xingular Harness' })
129
131
  const newSessionId = dependencies.createSessionId ?? randomUUID
130
132
  let current = { sessionId: newSessionId(), accountId: null as string | null, nativeSessionId: null as string | null }
133
+ // The frontend draws on this terminal, so the host's diagnostics go to a file the person can read instead.
134
+ const logFile = join(paths.stateDir, 'harness.log')
135
+ setLogSink((text) => {
136
+ try {
137
+ appendFileSync(logFile, `${new Date().toISOString()} ${text}\n`)
138
+ } catch {
139
+ // Diagnostics are best effort; a full disk must not stop the terminal.
140
+ }
141
+ })
131
142
 
132
143
  try {
133
144
  await harness.ready
@@ -149,5 +160,6 @@ export async function runStandaloneTerminal(argv: string[], dependencies: Standa
149
160
  } finally {
150
161
  await harness.terminals.dispose(current.sessionId)
151
162
  await harness.close()
163
+ setLogSink(null)
152
164
  }
153
165
  }
@@ -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.')