@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.
- package/CHANGELOG.md +20 -0
- package/README.md +36 -13
- package/dist/cli.js +10728 -8033
- package/dist/terminal-pi.js +365 -24
- package/docs/README.md +3 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +70 -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 +109 -13
- package/package.json +5 -4
- package/src/accounts/accounts.test.ts +87 -1
- package/src/accounts/accounts.ts +120 -18
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +12 -2
- package/src/hub/hub.test.ts +49 -1
- package/src/hub/hub.ts +122 -6
- package/src/hub/schema.ts +13 -1
- package/src/index.ts +7 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- package/src/memory/curation.test.ts +44 -1
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +169 -8
- package/src/memory/recall.ts +2 -1
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +8 -0
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/conversation.test.ts +56 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/planMode.test.ts +40 -0
- package/src/sessions/planMode.ts +57 -0
- package/src/sessions/registry.ts +38 -7
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +9 -3
- package/src/sessions/sessions.test.ts +69 -7
- package/src/sessions/sessions.ts +47 -39
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/terminal/controller.ts +73 -10
- package/src/terminal/piExtension.ts +42 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +42 -18
- package/src/terminal/standalone.ts +12 -0
- package/src/terminal/terminal.test.ts +150 -15
- package/src/terminal/terminals.ts +113 -5
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +206 -9
- package/src/terminal/tui/application.ts +251 -81
- package/src/terminal/tui/commands.test.ts +70 -9
- package/src/terminal/tui/commands.ts +99 -7
- package/src/terminal/tui/editor.test.ts +56 -27
- package/src/terminal/tui/editor.ts +64 -19
- package/src/terminal/tui/keybindings.test.ts +7 -0
- package/src/terminal/tui/keybindings.ts +9 -1
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +2 -0
- package/src/terminal/tui/runtime.socket.test.ts +77 -3
- package/src/terminal/tui/runtime.test.ts +10 -0
- package/src/terminal/tui/runtime.ts +24 -9
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +24 -46
- package/src/terminal/tui/status.ts +42 -69
- package/src/terminal/tui/theme.ts +18 -7
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +14 -0
- package/src/terminal/tui/transcript.ts +11 -0
- package/src/terminal/tui/ui.ts +2 -2
- package/src/terminal/tui/welcome.ts +4 -4
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +6 -1
- package/src/tools/spawnSubagent.ts +40 -85
- package/src/tools/types.ts +4 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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()
|
package/src/terminal/socket.ts
CHANGED
|
@@ -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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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',
|
|
205
|
-
|
|
206
|
-
|
|
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(
|
|
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 =>
|
|
153
|
+
options.runs.listen(event => {
|
|
154
|
+
this.sessions.get(event.sessionId)?.controller.observe(event)
|
|
155
|
+
const record = findRun(options.env.root, event.runId)
|
|
156
|
+
if (record?.relation !== 'subagent') return
|
|
157
|
+
let parentId = record.parentRunId
|
|
158
|
+
const seen = new Set<string>()
|
|
159
|
+
while (parentId && !seen.has(parentId)) {
|
|
160
|
+
seen.add(parentId)
|
|
161
|
+
const parent = findRun(options.env.root, parentId)
|
|
162
|
+
if (!parent) break
|
|
163
|
+
const owner = this.sessions.get(parent.sessionId)
|
|
164
|
+
if (owner?.controller.activeRun?.runId === parent.id) { owner.controller.observe(event, true); break }
|
|
165
|
+
parentId = parent.parentRunId
|
|
166
|
+
}
|
|
167
|
+
})
|
|
140
168
|
this.socket = new SessionSocket(socketPath(options.stateDir), {
|
|
141
169
|
memory: (caller, call, params) => dispatch(options.env, caller, call, params),
|
|
142
170
|
apps: (session, call, params) => this.appsCall(session, call, params),
|
|
@@ -383,19 +411,80 @@ export class Terminals {
|
|
|
383
411
|
const { policy } = this.options
|
|
384
412
|
if (policy.scope) {
|
|
385
413
|
const scope = await policy.scope(call)
|
|
386
|
-
if (!scope.allowed && !scope.requiresApproval) return { allowed: false, reason: scope.reason ?? 'Outside what this session may do.' }
|
|
414
|
+
if (!scope.allowed && (scope.automatic || !scope.requiresApproval)) return { allowed: false, reason: scope.reason ?? 'Outside what this session may do.' }
|
|
387
415
|
try {
|
|
388
416
|
await policy.authorize?.(call)
|
|
389
417
|
} catch (error) {
|
|
390
418
|
return { allowed: false, reason: error instanceof Error ? error.message : 'Tool unavailable in the settings.' }
|
|
391
419
|
}
|
|
392
|
-
|
|
420
|
+
const requiresApproval = !!scope.requiresApproval || (scope.allowed && !!scope.humanOnly)
|
|
421
|
+
return {
|
|
422
|
+
allowed: scope.allowed,
|
|
423
|
+
requiresApproval,
|
|
424
|
+
reason: scope.reason ?? null,
|
|
425
|
+
rememberable: scope.allowed && !scope.humanOnly,
|
|
426
|
+
...(scope.humanOnly ? { standing: scope.allowGlobalApproval ? 'switch' as const : 'none' as const } : {})
|
|
427
|
+
}
|
|
393
428
|
}
|
|
394
429
|
// With no application policy: reads run, a read-only session refuses the rest, and anything else asks.
|
|
395
430
|
const name = tool.toLowerCase()
|
|
396
|
-
if (READ_ONLY_TOOLS.has(name) || this.options.apps.toolNames.includes(name as never)) return { allowed: true }
|
|
431
|
+
if (name === SPAWN_SUBAGENT || name === WAIT_SUBAGENTS || READ_ONLY_TOOLS.has(name) || this.options.apps.toolNames.includes(name as never)) return { allowed: true }
|
|
397
432
|
if (live.input.readOnly) return { allowed: false, reason: 'This session is read-only.' }
|
|
398
|
-
return { allowed: false, requiresApproval: true, reason: 'This native action requires approval.' }
|
|
433
|
+
return { allowed: false, requiresApproval: true, rememberable: true, reason: 'This native action requires approval.' }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private async subagentsCall(live: Live, call: string, params: Record<string, any>): Promise<unknown> {
|
|
437
|
+
const service = this.options.subagents
|
|
438
|
+
if (!service || live.input.subagents === null) {
|
|
439
|
+
if (call === 'terminal.subagents.tools') return { tools: [] }
|
|
440
|
+
throw invalid('Subagents are disabled for this terminal.')
|
|
441
|
+
}
|
|
442
|
+
if (call === 'terminal.subagents.tools') return { tools: subagentTools(service, '').map(tool => ({ name: tool.name, label: tool.label, description: tool.description, parameters: tool.parameters(Type) })) }
|
|
443
|
+
const run = live.controller.activeRun
|
|
444
|
+
if (!run) throw new HarnessError('run_not_active', 'This terminal has no active parent run.')
|
|
445
|
+
if (call === 'terminal.subagents.register') {
|
|
446
|
+
if (!Array.isArray(params.tools) || params.tools.some((name: unknown) => typeof name !== 'string')) throw invalid('Native tool names are required.')
|
|
447
|
+
const readOnly = live.input.readOnly === true
|
|
448
|
+
const readTools = new Set([...READ_ONLY_TOOLS, SPAWN_SUBAGENT, WAIT_SUBAGENTS, 'ask_user', 'mcp_list_tools', 'mcp_call_tool', 'history_search', 'history_read', 'wiki_search', 'wiki_read', 'wiki_list', 'memory_facts', 'skill_load'])
|
|
449
|
+
const tools = params.tools.filter((name: string) => !readOnly || readTools.has(name)) as string[]
|
|
450
|
+
const capabilities = this.options.resolveCapabilities ? await this.options.resolveCapabilities(live.caller, tools, live.input.cwd) : { version: 1 as const, tools, connectors: [], contributions: [] }
|
|
451
|
+
freezeRun(this.options.env.root, run.runId, live.caller, capabilities)
|
|
452
|
+
service.register({
|
|
453
|
+
run: { id: run.runId, sessionId: live.input.sessionId, mode: live.input.run?.mode ?? 'code', projectId: live.input.projectId, worktreeId: live.input.run?.worktreeId },
|
|
454
|
+
cwd: live.input.cwd, sessionDir: this.historyDir(live.input), text: '', caller: live.caller,
|
|
455
|
+
model: typeof params.model === 'string' ? params.model : live.input.model,
|
|
456
|
+
effort: typeof params.effort === 'string' ? params.effort as Effort : null,
|
|
457
|
+
launch: live.input.launch, tools: { allow: capabilities.tools, gated: capabilities.tools.filter(name => !readTools.has(name)) },
|
|
458
|
+
connections: live.input.connections === false ? false : { readOnly }, subagents: live.input.subagents,
|
|
459
|
+
capabilities
|
|
460
|
+
}, live.account.id, capabilities)
|
|
461
|
+
service.restore(run.runId, params.history ?? { owners: [], receipts: [] })
|
|
462
|
+
return { runId: run.runId }
|
|
463
|
+
}
|
|
464
|
+
if (call === 'terminal.subagents.verify') { await service.verify(run.runId, params.history); return {} }
|
|
465
|
+
if (call === 'terminal.subagents.delivery') return service.delivery(run.runId, params.history, params.settle === true)
|
|
466
|
+
if (call === 'terminal.subagents.call') {
|
|
467
|
+
const tool = subagentTools(service, run.runId).find(tool => tool.name === params.name)
|
|
468
|
+
if (!tool) throw invalid('Unknown subagent tool.')
|
|
469
|
+
const reason = await this.decide(live, run, tool.name, params.input ?? {})
|
|
470
|
+
if (!reason.allowed) throw new HarnessError('tool_denied', reason.reason ?? 'Subagents are unavailable.')
|
|
471
|
+
const result = await tool.execute(params.input ?? {})
|
|
472
|
+
return typeof result === 'string' ? { text: result } : result
|
|
473
|
+
}
|
|
474
|
+
throw invalid('Unknown subagent operation.')
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private trajectoryCall(live: Live, call: string, params: Record<string, unknown>): unknown {
|
|
478
|
+
if (call === 'terminal.trajectory.list') {
|
|
479
|
+
return { runs: listTerminalTrajectoryRuns(this.options.env.root, live.input.sessionId, live.controller.nativeSessionId) }
|
|
480
|
+
}
|
|
481
|
+
if (call === 'terminal.trajectory.events') {
|
|
482
|
+
if (typeof params.runId !== 'string' || !params.runId) throw invalid('A run id is required.')
|
|
483
|
+
const afterSequence = params.afterSequence
|
|
484
|
+
if (afterSequence !== undefined && typeof afterSequence !== 'number') throw invalid('The event sequence must be a number.')
|
|
485
|
+
return readTerminalTrajectory(this.options.env.root, live.input.sessionId, live.controller.nativeSessionId, params.runId, afterSequence as number | undefined)
|
|
486
|
+
}
|
|
487
|
+
throw new HarnessError('unknown_method', `Unknown call ${call}.`)
|
|
399
488
|
}
|
|
400
489
|
|
|
401
490
|
/**
|
|
@@ -403,6 +492,7 @@ export class Terminals {
|
|
|
403
492
|
* process's end with `dispose`, also when it never starts.
|
|
404
493
|
*/
|
|
405
494
|
async prepare(input: TerminalPrepareInput): Promise<TerminalLaunch> {
|
|
495
|
+
input = structuredClone(input)
|
|
406
496
|
const { env, accounts, apps, bus } = this.options
|
|
407
497
|
const { root } = env
|
|
408
498
|
if (!input?.sessionId || !input.cwd || !input.sessionFolder) throw invalid('A terminal session needs an id, a working folder, and its own folder.')
|
|
@@ -438,6 +528,7 @@ export class Terminals {
|
|
|
438
528
|
nativeSessionId: input.nativeSessionId ?? null,
|
|
439
529
|
begin: ({ reservedRunId, text }) => this.begin(live, reservedRunId, text),
|
|
440
530
|
policy: (run, tool, args) => this.decide(live, run, tool, args),
|
|
531
|
+
allowToolGlobally: target => { this.options.approvals.allowTool('pi', target) },
|
|
441
532
|
native: (nativeSessionId) => bus.emit({ type: 'terminal.native', sessionId, nativeSessionId }),
|
|
442
533
|
telemetry: (event) => bus.emit({ type: 'terminal.telemetry', sessionId, event }),
|
|
443
534
|
interrupt: runId => this.options.runs.interrupt(runId),
|
|
@@ -451,6 +542,23 @@ export class Terminals {
|
|
|
451
542
|
caller: () => ({ ...caller, runId: live.controller.activeRun?.runId ?? null }),
|
|
452
543
|
broker,
|
|
453
544
|
terminal: { accept: (event, data) => live.controller.accept(event, data), disconnected: () => live.controller.disconnected(), control: async (call, params) => {
|
|
545
|
+
if (call.startsWith('terminal.subagents.')) return this.subagentsCall(live, call, params)
|
|
546
|
+
if (call.startsWith('terminal.trajectory.')) return this.trajectoryCall(live, call, params)
|
|
547
|
+
if (call === 'terminal.permissions.get') {
|
|
548
|
+
const policy = this.options.approvals.read()
|
|
549
|
+
return { full: this.options.approvals.isAuto('pi'), globalTools: policy.globalTools.pi ?? [] }
|
|
550
|
+
}
|
|
551
|
+
if (call === 'terminal.permissions.setFull') {
|
|
552
|
+
if (typeof params.enabled !== 'boolean') throw invalid('A boolean permission state is required.')
|
|
553
|
+
return { full: this.options.approvals.setAuto('pi', params.enabled).auto.pi === true }
|
|
554
|
+
}
|
|
555
|
+
if (call === 'terminal.permissions.setTool') {
|
|
556
|
+
if (typeof params.tool !== 'string' || !params.tool.trim() || typeof params.allowed !== 'boolean') throw invalid('A tool name and boolean permission state are required.')
|
|
557
|
+
const policy = params.allowed
|
|
558
|
+
? this.options.approvals.allowTool('pi', params.tool)
|
|
559
|
+
: this.options.approvals.revokeTool('pi', params.tool)
|
|
560
|
+
return { globalTools: policy.globalTools.pi ?? [] }
|
|
561
|
+
}
|
|
454
562
|
if (call !== 'terminal.resources') return live.controller.control(call, params)
|
|
455
563
|
const current = await this.options.plugins?.loading({ cwd: input.cwd, agentDir: configDir(accounts.dir, account), install: false, caller: { projectId: input.projectId ?? null } }) ?? null
|
|
456
564
|
if (resourceIdentity(current) !== resourceIdentity(loading)) throw new HarnessError('terminal_resources_changed', 'Trusted resources changed since this terminal was prepared. Review /plugins and reopen the terminal to load the new selection.')
|