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