@xingular/harness 0.1.7 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +29 -10
- package/dist/cli.js +5179 -2976
- 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 +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 +100 -10
- 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/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 +6 -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/registry.ts +38 -7
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +9 -3
- package/src/sessions/sessions.test.ts +46 -7
- package/src/sessions/sessions.ts +31 -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 +184 -8
- package/src/terminal/tui/application.ts +243 -81
- package/src/terminal/tui/commands.test.ts +69 -9
- package/src/terminal/tui/commands.ts +89 -7
- package/src/terminal/tui/editor.test.ts +56 -27
- package/src/terminal/tui/editor.ts +49 -19
- package/src/terminal/tui/keybindings.test.ts +8 -0
- package/src/terminal/tui/keybindings.ts +10 -1
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -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 +2 -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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { takeChars } from '../core/text'
|
|
2
|
+
import { findRun, loadRun, readEvents } from './runStore'
|
|
3
|
+
|
|
4
|
+
export const SPAWN_SUBAGENT = 'spawn_subagent'
|
|
5
|
+
export const WAIT_SUBAGENTS = 'wait_subagents'
|
|
6
|
+
export const SUBAGENT_RELATION = 'subagent'
|
|
7
|
+
|
|
8
|
+
/** How deep subagents may nest when the caller does not say. */
|
|
9
|
+
export const DEFAULT_SUBAGENT_DEPTH = 1
|
|
10
|
+
export const MAX_SUBAGENT_DEPTH = 3
|
|
11
|
+
|
|
12
|
+
export interface SubagentOptions {
|
|
13
|
+
/** Maximum consecutive native subagent levels below a caller (1 to 3). */
|
|
14
|
+
maxDepth?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function subagentDepth(options: SubagentOptions | undefined): number {
|
|
18
|
+
const value = Math.trunc(options?.maxDepth ?? DEFAULT_SUBAGENT_DEPTH)
|
|
19
|
+
return Math.min(MAX_SUBAGENT_DEPTH, Math.max(1, Number.isFinite(value) ? value : DEFAULT_SUBAGENT_DEPTH))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SubagentInput {
|
|
23
|
+
parentRunId: string
|
|
24
|
+
brief: string
|
|
25
|
+
tools?: string[]
|
|
26
|
+
wait?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SubagentSelection {
|
|
30
|
+
parentRunId: string
|
|
31
|
+
runIds?: string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SubagentResult {
|
|
35
|
+
runId: string
|
|
36
|
+
status: string
|
|
37
|
+
text: string
|
|
38
|
+
terminalReason: string | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SubagentReceipt {
|
|
42
|
+
version: 1
|
|
43
|
+
ownerRunId: string
|
|
44
|
+
spawned?: string[]
|
|
45
|
+
delivered?: string[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const SUBAGENT_RECEIPT = 'xingularSubagents'
|
|
49
|
+
export const SUBAGENT_OWNER = 'xingular-subagent-owner'
|
|
50
|
+
export const SUBAGENT_RESULTS = 'xingular-subagent-results'
|
|
51
|
+
|
|
52
|
+
/** Read explicit native metadata, including entries retained before compaction. */
|
|
53
|
+
export function subagentHistory(entries: readonly unknown[]): { owners: string[]; receipts: SubagentReceipt[] } {
|
|
54
|
+
const owners: string[] = []
|
|
55
|
+
const receipts: SubagentReceipt[] = []
|
|
56
|
+
for (const raw of entries) {
|
|
57
|
+
if (!raw || typeof raw !== 'object') continue
|
|
58
|
+
const entry = raw as Record<string, any>
|
|
59
|
+
if (entry.type === 'custom' && entry.customType === SUBAGENT_OWNER && typeof entry.data?.runId === 'string') owners.push(entry.data.runId)
|
|
60
|
+
const message = entry.type === 'message' ? entry.message : entry
|
|
61
|
+
const receipt = message?.details?.[SUBAGENT_RECEIPT]
|
|
62
|
+
if (!receipt || receipt.version !== 1 || typeof receipt.ownerRunId !== 'string') continue
|
|
63
|
+
const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every((id) => typeof id === 'string')
|
|
64
|
+
if ((receipt.spawned !== undefined && !strings(receipt.spawned)) || (receipt.delivered !== undefined && !strings(receipt.delivered))) continue
|
|
65
|
+
receipts.push(receipt)
|
|
66
|
+
}
|
|
67
|
+
return { owners, receipts }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Native nesting is independent of a host's other uses of the generic caller depth. */
|
|
71
|
+
export function subagentNesting(root: string, run: { relation?: string | null; parentRunId?: string | null }): number {
|
|
72
|
+
let current: typeof run = run
|
|
73
|
+
let depth = 0
|
|
74
|
+
const visited = new Set<string>()
|
|
75
|
+
while (current.relation === SUBAGENT_RELATION) {
|
|
76
|
+
const parent = current.parentRunId
|
|
77
|
+
if (!parent || visited.has(parent)) return MAX_SUBAGENT_DEPTH
|
|
78
|
+
visited.add(parent)
|
|
79
|
+
depth++
|
|
80
|
+
const record = findRun(root, parent)
|
|
81
|
+
if (!record) return MAX_SUBAGENT_DEPTH
|
|
82
|
+
current = record
|
|
83
|
+
}
|
|
84
|
+
return depth
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The child's final reply, or why it ended without one. */
|
|
88
|
+
export function subagentReport(root: string, runId: string): string {
|
|
89
|
+
const run = loadRun(root, runId)
|
|
90
|
+
const reply = readEvents(root, runId).filter((event) => event.type === 'message.completed' && (event.payload as { role?: unknown }).role === 'assistant').at(-1)
|
|
91
|
+
const text = typeof (reply?.payload as { text?: unknown } | undefined)?.text === 'string' ? takeChars((reply!.payload as { text: string }).text, 16_000) : ''
|
|
92
|
+
const lines = [`Subagent run ${runId} · ${run.status}`]
|
|
93
|
+
if (text) lines.push(`Result:\n${text}`)
|
|
94
|
+
else if (run.status === 'completed') lines.push('It finished without a written reply.')
|
|
95
|
+
if (run.status !== 'completed' && run.terminalReason) lines.push(`Stopped because: ${run.terminalReason}`)
|
|
96
|
+
return lines.join('\n')
|
|
97
|
+
}
|
|
@@ -12,6 +12,8 @@ export interface PolicyReply {
|
|
|
12
12
|
allowed: boolean
|
|
13
13
|
reason?: string | null
|
|
14
14
|
requiresApproval?: boolean
|
|
15
|
+
rememberable?: boolean
|
|
16
|
+
standing?: 'full' | 'switch' | 'none'
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export interface ControllerHooks {
|
|
@@ -21,6 +23,8 @@ export interface ControllerHooks {
|
|
|
21
23
|
begin(input: { reservedRunId: string | null; nativeTurnId: string; text: string }): ActiveRun
|
|
22
24
|
/** Whether a native tool call may run. */
|
|
23
25
|
policy(run: ActiveRun, tool: string, args: unknown): Promise<PolicyReply>
|
|
26
|
+
/** Saves a user-selected tool grant in the shared local approval store. */
|
|
27
|
+
allowToolGlobally(target: string): void
|
|
24
28
|
/** The native conversation identity changed. */
|
|
25
29
|
native(nativeSessionId: string): void
|
|
26
30
|
telemetry(event: string): void
|
|
@@ -148,18 +152,23 @@ export class TerminalController {
|
|
|
148
152
|
}
|
|
149
153
|
|
|
150
154
|
/** Project registry-owned questions, including grouped requests, into the terminal inbox. */
|
|
151
|
-
observe(event: RunEvent): void {
|
|
152
|
-
if (this.disposed || event.runId !== this.activeRun
|
|
155
|
+
observe(event: RunEvent, descendant = false): void {
|
|
156
|
+
if (this.disposed || !this.activeRun || (!descendant && event.runId !== this.activeRun.runId)) return
|
|
153
157
|
if (event.type === 'question.requested') {
|
|
154
158
|
const payload = event.payload as Record<string, unknown>
|
|
155
|
-
this.pending.set(String(payload.questionId), { ...payload, type: 'question', requestId: payload.questionId })
|
|
159
|
+
this.pending.set(String(payload.questionId), { ...payload, type: 'question', requestId: payload.questionId, runId: event.runId })
|
|
160
|
+
} else if (descendant && event.type === 'approval.requested') {
|
|
161
|
+
const payload = event.payload as Record<string, unknown>
|
|
162
|
+
if (!payload.automatic) this.pending.set(String(payload.approvalId), { ...payload, type: 'approval', requestId: payload.approvalId, runId: event.runId })
|
|
163
|
+
} else if (event.type === 'approval.resolved') {
|
|
164
|
+
this.pending.delete(String((event.payload as { approvalId: string }).approvalId))
|
|
156
165
|
} else if (event.type === 'question.answered') {
|
|
157
166
|
this.pending.delete(String((event.payload as { questionId: string }).questionId))
|
|
158
167
|
}
|
|
159
168
|
}
|
|
160
169
|
|
|
161
170
|
/** The socket grants this interface only to the process that owns terminal input. */
|
|
162
|
-
control(call: string, data: Record<string, any>): unknown {
|
|
171
|
+
async control(call: string, data: Record<string, any>): Promise<unknown> {
|
|
163
172
|
if (this.disposed) throw new HarnessError('terminal_not_ready', 'This terminal has ended.')
|
|
164
173
|
if (call === 'terminal.draft') {
|
|
165
174
|
this.dirty = data.dirty === true
|
|
@@ -168,12 +177,18 @@ export class TerminalController {
|
|
|
168
177
|
}
|
|
169
178
|
if (call === 'terminal.pending') return { requests: [...this.pending.values()] }
|
|
170
179
|
if (call === 'terminal.interrupt') return this.activeRun && this.hooks.interrupt ? this.hooks.interrupt(this.activeRun.runId) : {}
|
|
180
|
+
if (call === 'terminal.policy') {
|
|
181
|
+
const run = this.activeRun
|
|
182
|
+
if (!run || typeof data.tool !== 'string' || !data.tool) throw new HarnessError('invalid_request', 'An active run and tool name are required.')
|
|
183
|
+
return this.hooks.policy(run, data.tool, data.args ?? {})
|
|
184
|
+
}
|
|
185
|
+
if (call === 'terminal.approval') return this.requestNativeApproval(data)
|
|
171
186
|
if (call === 'terminal.answers' && this.hooks.answerQuestions) {
|
|
172
187
|
const run = this.activeRun
|
|
173
188
|
const batchId = String(data.batchId ?? '')
|
|
174
189
|
const requests = [...this.pending.values()].filter(request => request.batchId === batchId)
|
|
175
190
|
if (!run || !batchId || !requests.length || !Array.isArray(data.answers)) throw new HarnessError('invalid_request', 'This question batch is no longer pending.')
|
|
176
|
-
this.hooks.answerQuestions(run.runId, batchId, data.answers)
|
|
191
|
+
this.hooks.answerQuestions(String(requests[0].runId ?? run.runId), batchId, data.answers)
|
|
177
192
|
return {}
|
|
178
193
|
}
|
|
179
194
|
const requestId = String(data.requestId ?? '')
|
|
@@ -182,17 +197,65 @@ export class TerminalController {
|
|
|
182
197
|
if (!request || !run) throw new HarnessError('invalid_request', 'This terminal decision is no longer pending.')
|
|
183
198
|
if (call === 'terminal.approve' && request.type === 'approval' && this.hooks.resolveApproval) {
|
|
184
199
|
if (data.decision !== 'approved' && data.decision !== 'denied') throw new HarnessError('invalid_request', 'Choose approve or deny.')
|
|
185
|
-
|
|
200
|
+
const alwaysAllow = data.alwaysAllow === true
|
|
201
|
+
if (alwaysAllow && request.rememberable !== true) throw new HarnessError('invalid_request', 'This approval cannot be remembered for other sessions.')
|
|
202
|
+
let decision: ApprovalDecision = data.decision
|
|
203
|
+
if (decision === 'approved' && request.direct === true) {
|
|
204
|
+
const operation = request.operation as { tool?: unknown; args?: unknown } | undefined
|
|
205
|
+
const tool = typeof operation?.tool === 'string' ? operation.tool : ''
|
|
206
|
+
const current = await this.hooks.policy(run, tool, operation?.args ?? {})
|
|
207
|
+
if (!current.allowed && !current.requiresApproval) decision = 'denied'
|
|
208
|
+
if (alwaysAllow && current.rememberable !== true) {
|
|
209
|
+
request.rememberable = false
|
|
210
|
+
throw new HarnessError('invalid_request', 'This tool can no longer be allowed across sessions. Review the approval again.')
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (decision === 'approved' && alwaysAllow) this.hooks.allowToolGlobally(String(request.target ?? ''))
|
|
214
|
+
this.hooks.resolveApproval(String(request.runId ?? run.runId), requestId, decision)
|
|
186
215
|
return {}
|
|
187
216
|
}
|
|
188
217
|
if (call === 'terminal.answer' && request.type === 'question' && this.hooks.answerQuestion) {
|
|
189
218
|
if (!data.answer || !Array.isArray(data.answer.selected)) throw new HarnessError('invalid_request', 'A question answer is required.')
|
|
190
|
-
this.hooks.answerQuestion(run.runId, requestId, data.answer)
|
|
219
|
+
this.hooks.answerQuestion(String(request.runId ?? run.runId), requestId, data.answer)
|
|
191
220
|
return {}
|
|
192
221
|
}
|
|
193
222
|
throw new HarnessError('unknown_method', `Unknown terminal control ${call}.`)
|
|
194
223
|
}
|
|
195
224
|
|
|
225
|
+
private async requestNativeApproval(data: Record<string, any>): Promise<unknown> {
|
|
226
|
+
const run = this.activeRun
|
|
227
|
+
const requestId = typeof data.requestId === 'string' ? data.requestId : ''
|
|
228
|
+
const tool = typeof data.tool === 'string' ? data.tool : ''
|
|
229
|
+
if (!run || !requestId || !tool) throw new HarnessError('invalid_request', 'An active run, request id, and tool name are required.')
|
|
230
|
+
const args = data.args && typeof data.args === 'object' && !Array.isArray(data.args) ? data.args as Record<string, unknown> : {}
|
|
231
|
+
const policy = await this.hooks.policy(run, tool, args)
|
|
232
|
+
if (!policy.allowed && !policy.requiresApproval) return { decision: 'denied' }
|
|
233
|
+
if (policy.allowed && !policy.requiresApproval) return { decision: 'approved' }
|
|
234
|
+
const key = this.requestId(`approval:${requestId}`)
|
|
235
|
+
const existing = this.seen.get(key)
|
|
236
|
+
if (existing) return { decision: await existing }
|
|
237
|
+
const action = JSON.stringify(args)
|
|
238
|
+
const operation = { tool, args }
|
|
239
|
+
const response = run.requestApproval({
|
|
240
|
+
requestId: key,
|
|
241
|
+
target: tool,
|
|
242
|
+
action,
|
|
243
|
+
operation,
|
|
244
|
+
rememberable: policy.rememberable === true,
|
|
245
|
+
standing: policy.standing
|
|
246
|
+
})
|
|
247
|
+
this.pending.set(key, {
|
|
248
|
+
type: 'approval', requestId: key, runId: run.runId, target: tool, action,
|
|
249
|
+
operation, rememberable: policy.rememberable === true, direct: true
|
|
250
|
+
})
|
|
251
|
+
this.seen.set(key, response)
|
|
252
|
+
try {
|
|
253
|
+
return { decision: await response }
|
|
254
|
+
} finally {
|
|
255
|
+
this.pending.delete(key)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
196
259
|
async accept(event: string, data: Record<string, any>): Promise<unknown> {
|
|
197
260
|
if (this.disposed) throw new Error('This terminal controller was disposed.')
|
|
198
261
|
if (event !== 'poll') this.hooks.telemetry(event)
|
|
@@ -220,8 +283,8 @@ export class TerminalController {
|
|
|
220
283
|
return {}
|
|
221
284
|
}
|
|
222
285
|
if (event === 'begin') {
|
|
223
|
-
this.begin(String(data.turnId ?? randomUUID()), String(data.text ?? ''))
|
|
224
|
-
return {}
|
|
286
|
+
const run = this.begin(String(data.turnId ?? randomUUID()), String(data.text ?? ''))
|
|
287
|
+
return { runId: run.runId }
|
|
225
288
|
}
|
|
226
289
|
if (event === 'shutdown') {
|
|
227
290
|
this.ready = false
|
|
@@ -229,7 +292,7 @@ export class TerminalController {
|
|
|
229
292
|
return {}
|
|
230
293
|
}
|
|
231
294
|
if (event === 'settled') {
|
|
232
|
-
this.finish(data.error ? 'failed' : 'completed', data.error ? String(data.error) : undefined)
|
|
295
|
+
this.finish(data.aborted ? 'interrupted' : data.error ? 'failed' : 'completed', data.error ? String(data.error) : undefined)
|
|
233
296
|
return {}
|
|
234
297
|
}
|
|
235
298
|
const run = this.run
|
|
@@ -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
|
}
|