@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.
- package/CHANGELOG.md +25 -0
- package/NOTICE.md +38 -0
- package/README.md +139 -28
- package/dist/cli.js +9598 -2201
- package/dist/terminal-pi.js +808 -16
- package/docs/README.md +15 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +344 -0
- package/package.json +11 -4
- package/src/accounts/accounts.test.ts +197 -1
- package/src/accounts/accounts.ts +266 -28
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +27 -4
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +12 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +206 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +367 -18
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +11 -3
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +43 -2
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +83 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +145 -27
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +45 -4
- package/src/sessions/sessions.test.ts +137 -4
- package/src/sessions/sessions.ts +51 -15
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +134 -7
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +91 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +55 -20
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +28 -7
- package/src/terminal/terminal.test.ts +220 -16
- package/src/terminal/terminals.ts +234 -16
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +290 -0
- package/src/terminal/tui/application.ts +653 -0
- package/src/terminal/tui/commands.test.ts +278 -0
- package/src/terminal/tui/commands.ts +443 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +113 -0
- package/src/terminal/tui/editor.ts +109 -0
- package/src/terminal/tui/keybindings.test.ts +30 -0
- package/src/terminal/tui/keybindings.ts +59 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +178 -0
- package/src/terminal/tui/runtime.test.ts +225 -0
- package/src/terminal/tui/runtime.ts +403 -0
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +48 -0
- package/src/terminal/tui/status.ts +82 -0
- package/src/terminal/tui/theme.ts +185 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +39 -0
- package/src/terminal/tui/transcript.ts +135 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +29 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +47 -0
- 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
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|