@xingular/harness 0.1.7 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +36 -13
  3. package/dist/cli.js +10728 -8033
  4. package/dist/terminal-pi.js +365 -24
  5. package/docs/README.md +3 -0
  6. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  7. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +70 -0
  8. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  9. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  10. package/docs/terminal-tui.md +109 -13
  11. package/package.json +5 -4
  12. package/src/accounts/accounts.test.ts +87 -1
  13. package/src/accounts/accounts.ts +120 -18
  14. package/src/core/bus.test.ts +22 -0
  15. package/src/core/bus.ts +17 -2
  16. package/src/harness.ts +12 -2
  17. package/src/hub/hub.test.ts +49 -1
  18. package/src/hub/hub.ts +122 -6
  19. package/src/hub/schema.ts +13 -1
  20. package/src/index.ts +7 -2
  21. package/src/mcp/connections.ts +39 -12
  22. package/src/mcp/service.test.ts +49 -3
  23. package/src/mcp/service.ts +14 -3
  24. package/src/mcp/types.ts +10 -0
  25. package/src/memory/curation.test.ts +44 -1
  26. package/src/memory/dispatch.ts +5 -0
  27. package/src/memory/maintenance.test.ts +17 -1
  28. package/src/memory/maintenance.ts +169 -8
  29. package/src/memory/recall.ts +2 -1
  30. package/src/memory/trajectory.test.ts +178 -0
  31. package/src/memory/trajectory.ts +220 -0
  32. package/src/memory/wiki.ts +2 -1
  33. package/src/memory/wikiIndex.ts +3 -2
  34. package/src/plugins/builtin.ts +4 -2
  35. package/src/plugins/catalogue.test.ts +1 -1
  36. package/src/server/methods.ts +8 -0
  37. package/src/server/server.test.ts +20 -1
  38. package/src/sessions/approvals.test.ts +49 -0
  39. package/src/sessions/approvals.ts +26 -4
  40. package/src/sessions/conversation.test.ts +56 -0
  41. package/src/sessions/lineageJoin.ts +1 -1
  42. package/src/sessions/planMode.test.ts +40 -0
  43. package/src/sessions/planMode.ts +57 -0
  44. package/src/sessions/registry.ts +38 -7
  45. package/src/sessions/runStore.ts +14 -0
  46. package/src/sessions/runner.ts +9 -3
  47. package/src/sessions/sessions.test.ts +69 -7
  48. package/src/sessions/sessions.ts +47 -39
  49. package/src/sessions/subagentService.test.ts +159 -0
  50. package/src/sessions/subagentService.ts +236 -0
  51. package/src/sessions/subagents.ts +97 -0
  52. package/src/terminal/controller.ts +73 -10
  53. package/src/terminal/piExtension.ts +42 -6
  54. package/src/terminal/socket.test.ts +23 -0
  55. package/src/terminal/socket.ts +42 -18
  56. package/src/terminal/standalone.ts +12 -0
  57. package/src/terminal/terminal.test.ts +150 -15
  58. package/src/terminal/terminals.ts +113 -5
  59. package/src/terminal/trajectory.test.ts +66 -0
  60. package/src/terminal/trajectory.ts +44 -0
  61. package/src/terminal/tui/application.test.ts +206 -9
  62. package/src/terminal/tui/application.ts +251 -81
  63. package/src/terminal/tui/commands.test.ts +70 -9
  64. package/src/terminal/tui/commands.ts +99 -7
  65. package/src/terminal/tui/editor.test.ts +56 -27
  66. package/src/terminal/tui/editor.ts +64 -19
  67. package/src/terminal/tui/keybindings.test.ts +7 -0
  68. package/src/terminal/tui/keybindings.ts +9 -1
  69. package/src/terminal/tui/planMode.test.ts +10 -0
  70. package/src/terminal/tui/planMode.ts +2 -0
  71. package/src/terminal/tui/runtime.socket.test.ts +77 -3
  72. package/src/terminal/tui/runtime.test.ts +10 -0
  73. package/src/terminal/tui/runtime.ts +24 -9
  74. package/src/terminal/tui/screen.test.ts +143 -0
  75. package/src/terminal/tui/screen.ts +121 -0
  76. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  77. package/src/terminal/tui/skillInvocation.ts +51 -0
  78. package/src/terminal/tui/status.test.ts +24 -46
  79. package/src/terminal/tui/status.ts +42 -69
  80. package/src/terminal/tui/theme.ts +18 -7
  81. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  82. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  83. package/src/terminal/tui/transcript.test.ts +14 -0
  84. package/src/terminal/tui/transcript.ts +11 -0
  85. package/src/terminal/tui/ui.ts +2 -2
  86. package/src/terminal/tui/welcome.ts +4 -4
  87. package/src/terminal/tui/workingStatus.test.ts +44 -0
  88. package/src/terminal/tui/workingStatus.ts +238 -0
  89. package/src/tools/definitions.ts +42 -1
  90. package/src/tools/maintenance.ts +6 -1
  91. package/src/tools/spawnSubagent.ts +40 -85
  92. package/src/tools/types.ts +4 -0
  93. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  94. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  95. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  96. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  97. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  98. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  99. package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
  100. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  101. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  102. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  103. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  104. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  105. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  106. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  107. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  108. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  109. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  110. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  111. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  112. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
  113. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  114. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  115. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  116. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  117. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
  118. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  119. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
  120. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
  121. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  122. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  123. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
  124. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
  125. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  126. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
  127. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  128. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  129. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
  130. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  131. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
  132. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
  133. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
  134. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
  135. package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
@@ -0,0 +1,57 @@
1
+ // Plan mode: a run that may only look, ask and recall, and ends with a proposed plan. The tool list
2
+ // the model sees is unchanged, so switching the mode does not change the cached prompt prefix; the
3
+ // gate refuses everything else, and the instruction arrives as a context update.
4
+ import { deliveredSnapshot, type HostSection } from '../prompt/compile'
5
+ import { ASK_USER } from '../tools/askUser'
6
+
7
+ /**
8
+ * Read-only inspection, questions and recall. Everything else is refused in plan mode: file
9
+ * changes, commands, memory writes, delegation and connected apps, since none of them can be
10
+ * judged read-only from their names. A provider marks its own read-only tools with `readOnly`.
11
+ */
12
+ export const PLAN_READ_ONLY_TOOLS: ReadonlySet<string> = new Set([
13
+ 'read', 'grep', 'find', 'ls', ASK_USER, 'ask_user_question',
14
+ 'history_search', 'history_read', 'trajectory_search', 'trajectory_read',
15
+ 'wiki_entrypoint', 'wiki_search', 'wiki_read', 'wiki_list', 'memory_facts', 'skill_load', 'mcp_list_tools'
16
+ ])
17
+
18
+ export const PLAN_SECTION_ID = 'plan'
19
+ /** Between the application's role and the memory blocks. */
20
+ export const PLAN_SECTION_ORDER = 25
21
+ export const PLAN_OPEN_TAG = '<proposed_plan>'
22
+ export const PLAN_CLOSE_TAG = '</proposed_plan>'
23
+
24
+ export const PLAN_INSTRUCTIONS = `Plan mode is on for this request. Investigate and design; do not carry out the work.
25
+ - Read, search and recall freely. When a decision belongs to the user, ask with ${ASK_USER} instead of guessing.
26
+ - Do not change files, run commands, write memory, delegate, or call connected apps. Such calls are refused while plan mode is on.
27
+ - When the plan is ready, end the reply with exactly one block in this form, after any other text:
28
+ ${PLAN_OPEN_TAG}
29
+ # <Plan title>
30
+ <Markdown: the context, the steps with the files they change, and how to verify the result>
31
+ ${PLAN_CLOSE_TAG}
32
+ - If you need the user's input before a plan is possible, ask instead of proposing one.`
33
+
34
+ /** The tools a plan-mode run may call: the read-only set and the provider tools marked read-only. */
35
+ export function planModeTools(normal: readonly string[], readOnly: Iterable<string> = []): string[] {
36
+ const allowed = new Set([...PLAN_READ_ONLY_TOOLS, ...readOnly])
37
+ return normal.filter((name) => allowed.has(name))
38
+ }
39
+
40
+ /** Why a plan-mode run may not call this tool, or null when it may. */
41
+ export function planRefusal(tool: string, readOnly: ReadonlySet<string>): string | null {
42
+ if (PLAN_READ_ONLY_TOOLS.has(tool) || readOnly.has(tool)) return null
43
+ return `Plan mode is read-only: ${tool} is not available until the user leaves plan mode. Continue investigating, or finish with a ${PLAN_OPEN_TAG} block.`
44
+ }
45
+
46
+ /**
47
+ * The plan section for this request. While plan mode is on it carries the instructions; on the
48
+ * first request after it, the empty slot withdraws the earlier instructions from the conversation.
49
+ * Otherwise there is no section, so a conversation that never planned is delivered exactly as before.
50
+ */
51
+ export function planSection(plan: boolean, historyFile: string | null): HostSection | null {
52
+ if (plan) return { id: PLAN_SECTION_ID, title: 'Plan mode', body: PLAN_INSTRUCTIONS, budget: 2000, order: PLAN_SECTION_ORDER, dynamic: true }
53
+ const delivered = historyFile ? deliveredSnapshot(historyFile) : null
54
+ return delivered?.some((entry) => entry.id === PLAN_SECTION_ID)
55
+ ? { id: PLAN_SECTION_ID, title: 'Plan mode', body: null, budget: 2000, order: PLAN_SECTION_ORDER, dynamic: true }
56
+ : null
57
+ }
@@ -91,6 +91,7 @@ export class RunRegistry {
91
91
  private readonly approvals = new Map<string, { runId: string; resolve: (decision: ApprovalDecision) => void }>()
92
92
  private readonly questions = new Map<string, PendingQuestion>()
93
93
  private readonly nativeCleared = new Map<string, string>()
94
+ private readonly waiters = new Map<string, Array<() => void>>()
94
95
  private readonly stopping = new Set<string>()
95
96
  private readonly listeners: RunListener[] = []
96
97
 
@@ -130,6 +131,7 @@ export class RunRegistry {
130
131
  sequence = 0
131
132
  }
132
133
  let finished = false
134
+ let finishing = false
133
135
  const interruptHandlers: Array<() => Promise<void> | void> = []
134
136
  let steerHandler: ((text: string) => Promise<void> | void) | null = null
135
137
  const waitingStatus = (): string =>
@@ -138,7 +140,7 @@ export class RunRegistry {
138
140
  const requestQuestion = (input: QuestionInput, batch?: QuestionBatchMetadata, statusEvent = true): Promise<QuestionAnswer> => {
139
141
  const { requestId, ...payload } = input
140
142
  const questionId = requestId ?? randomUUID()
141
- if (finished || this.nativeCleared.delete(questionId)) return Promise.resolve({ selected: [] })
143
+ if (finished || finishing || this.nativeCleared.delete(questionId)) return Promise.resolve({ selected: [] })
142
144
  if (this.questions.has(questionId)) throw new HarnessError('invalid_request', 'This question id is already pending.')
143
145
  return new Promise<QuestionAnswer>((resolve) => {
144
146
  this.questions.set(questionId, {
@@ -186,7 +188,7 @@ export class RunRegistry {
186
188
  // What the user already allowed is known here; if it cannot be read, ask as usual.
187
189
  const record = findRun(this.options.root(), runId)
188
190
  const automatic = record && this.options.automatic && standing !== 'none' ? await this.options.automatic(record, input.target, input.operation, standing).catch(() => null) : null
189
- if (finished) return 'denied'
191
+ if (finished || finishing) return 'denied'
190
192
  if (this.nativeCleared.delete(approvalId)) return 'native'
191
193
  if (automatic) {
192
194
  // Marked on the request too, so nothing treats it as waiting on someone.
@@ -219,22 +221,26 @@ export class RunRegistry {
219
221
  if (new Set(requestedIds).size !== requestedIds.length || requestedIds.some((id) => this.questions.has(id))) {
220
222
  throw new HarnessError('invalid_request', 'Question ids in a batch must be unique and not already pending.')
221
223
  }
222
- if (finished) return Promise.resolve(inputs.map(() => ({ selected: [] })))
224
+ if (finished || finishing) return Promise.resolve(inputs.map(() => ({ selected: [] })))
223
225
  const batchId = randomUUID()
224
226
  const answers = inputs.map((input, index) => requestQuestion(input, { batchId, batchIndex: index + 1, batchCount: inputs.length }, false))
225
227
  emit('status.changed', { status: 'waiting_for_input' })
226
228
  return Promise.all(answers)
227
229
  },
228
230
  finish: (outcome, failure) => {
229
- if (finished) return
231
+ if (finished || finishing) return
232
+ finishing = true
233
+ // Persist cancellation receipts while emit is still open, so every interface clears them.
234
+ for (const [, approval] of [...this.approvals]) if (approval.runId === runId) approval.resolve('denied')
235
+ for (const [, question] of [...this.questions]) if (question.runId === runId) question.resolve({ selected: [] })
230
236
  if (outcome === 'failed') emit('failed', failure ?? { code: 'failed', message: 'The run failed.' })
231
237
  else if (outcome === 'interrupted') emit('status.changed', { status: 'interrupted' })
232
238
  else emit('completed', {})
233
239
  finished = true
234
240
  this.runs.delete(runId)
241
+ for (const resolve of [...(this.waiters.get(runId) ?? [])]) resolve()
242
+ this.waiters.delete(runId)
235
243
  for (const [id, owner] of this.nativeCleared) if (owner === runId) this.nativeCleared.delete(id)
236
- for (const [, approval] of [...this.approvals]) if (approval.runId === runId) approval.resolve('denied')
237
- for (const [, question] of [...this.questions]) if (question.runId === runId) question.resolve({ selected: [] })
238
244
  try {
239
245
  this.options.ended?.(runId, sessionId)
240
246
  } catch (error) {
@@ -245,6 +251,8 @@ export class RunRegistry {
245
251
  return finished
246
252
  },
247
253
  interrupt: async () => {
254
+ // Release approvals/questions before SDK abort waits for their blocked tools.
255
+ run.finish('interrupted')
248
256
  for (const handler of interruptHandlers) {
249
257
  try {
250
258
  await handler()
@@ -252,7 +260,6 @@ export class RunRegistry {
252
260
  log(`an interrupt handler failed for run ${runId}`, error instanceof Error ? error.message : String(error))
253
261
  }
254
262
  }
255
- run.finish('interrupted')
256
263
  }
257
264
  }
258
265
 
@@ -278,6 +285,30 @@ export class RunRegistry {
278
285
  return false
279
286
  }
280
287
 
288
+ /** Wait for a recorded run to settle without polling storage or retaining an event listener. */
289
+ wait(runId: string, signal?: AbortSignal): Promise<void> {
290
+ const record = findRun(this.options.root(), runId)
291
+ if (!record) throw new HarnessError('not_found', 'Run was not found.')
292
+ if (!isActiveStatus(record.status)) return Promise.resolve()
293
+ if (!this.runs.has(runId)) throw new HarnessError('run_not_active', 'This run is not active in this process.')
294
+ if (signal?.aborted) return Promise.reject(new HarnessError('run_not_active', 'The wait was interrupted.'))
295
+ return new Promise((resolve, reject) => {
296
+ const cleanup = () => {
297
+ signal?.removeEventListener('abort', abort)
298
+ const pending = this.waiters.get(runId)
299
+ const index = pending?.indexOf(done) ?? -1
300
+ if (index >= 0) pending!.splice(index, 1)
301
+ if (!pending?.length) this.waiters.delete(runId)
302
+ }
303
+ const done = () => { cleanup(); resolve() }
304
+ const abort = () => { cleanup(); reject(new HarnessError('run_not_active', 'The wait was interrupted.')) }
305
+ const pending = this.waiters.get(runId) ?? []
306
+ pending.push(done)
307
+ this.waiters.set(runId, pending)
308
+ signal?.addEventListener('abort', abort, { once: true })
309
+ })
310
+ }
311
+
281
312
  /** The live run with this id, for a tool or broker that reports into it. */
282
313
  active(runId: string): ActiveRun | null {
283
314
  return this.runs.get(runId) ?? null
@@ -74,6 +74,20 @@ export function findRun(root: string, runId: string): RunRecord | null {
74
74
  }
75
75
  }
76
76
 
77
+ /** A parent's reserved child IDs survive a crash before the spawn tool returns. */
78
+ export function recordSubagent(root: string, parentRunId: string, childRunId: string): void {
79
+ loadRun(root, parentRunId)
80
+ const ids = recordedSubagents(root, parentRunId)
81
+ if (!ids.includes(childRunId)) writeJson(join(runDir(root, parentRunId), 'subagents.json'), [...ids, childRunId])
82
+ }
83
+
84
+ export function recordedSubagents(root: string, parentRunId: string): string[] {
85
+ const value = readJson(join(runDir(root, parentRunId), 'subagents.json'))
86
+ if (value === null) return []
87
+ if (!Array.isArray(value) || value.some(id => typeof id !== 'string')) throw new Error('Recorded subagent ownership is unreadable.')
88
+ return value as string[]
89
+ }
90
+
77
91
  /** Changes a run's record. A settled run keeps its outcome whatever the change says. */
78
92
  export function updateRun(root: string, runId: string, update: (record: RunRecord) => void): void {
79
93
  const record = readRecord(recordPath(root, runId))
@@ -23,6 +23,9 @@ export type Effort = (typeof EFFORTS)[number]
23
23
  /** What one turn needs once its prompt is compiled and its tools are chosen. */
24
24
  export interface TurnParams {
25
25
  runId: string
26
+ /** Child runs may not gain tools registered by extensions outside their snapshot. */
27
+ restrictTools?: boolean
28
+ settle?: () => Promise<void>
26
29
  cwd: string
27
30
  sessionDir: string
28
31
  /** The native history to open, or `null` to start a fresh one. */
@@ -77,9 +80,11 @@ export type PiEvent =
77
80
 
78
81
  export interface PiSessionLike {
79
82
  sessionId: string
83
+ sessionManager?: { getBranch(): unknown[] }
84
+ thinkingLevel?: Effort
80
85
  systemPrompt?: string
81
86
  extensionCommands?: readonly string[]
82
- model?: { input: string[]; contextWindow?: number }
87
+ model?: { input: string[]; contextWindow?: number; id?: string; provider?: string }
83
88
  getActiveToolNames?(): string[]
84
89
  getAllTools?(): Array<{ name: string; description?: string; parameters?: unknown }>
85
90
  subscribe(listener: (event: PiEvent) => void): () => void
@@ -460,6 +465,7 @@ export async function runTurn(run: ActiveRun, session: PiSessionLike, params: Tu
460
465
  accepting = false
461
466
  session.clearQueue?.()
462
467
  }
468
+ if (!aborted && !lastError) await params.settle?.()
463
469
  if (aborted) run.finish('interrupted')
464
470
  else if (lastError) run.finish('failed', { code: 'provider_error', message: lastError })
465
471
  else run.finish('completed')
@@ -540,7 +546,7 @@ export const piSessionFactory: SessionFactory = async ({ params, allow, gate, to
540
546
  const loaded = registeredBy(resourceLoader)
541
547
  // A tool an extension brought is offered to the model, and asks before every call: the person
542
548
  // trusted the code to load, and still decides each thing it does through a tool.
543
- const extensionTools = [...new Set(loaded.flatMap((extension) => extension.tools))].filter((name) => !allow.includes(name))
549
+ const extensionTools = [...new Set(loaded.flatMap((extension) => extension.tools))].filter((name) => !allow.includes(name) && !params.restrictTools)
544
550
  gate.ask?.([...new Set(loaded.flatMap((extension) => extension.tools))])
545
551
 
546
552
  let supportsImageInput = false
@@ -558,7 +564,7 @@ export const piSessionFactory: SessionFactory = async ({ params, allow, gate, to
558
564
  if (output.image && !supportsImageInput) throw new HarnessError('image_input_unsupported', 'This model cannot inspect images. Choose a model that supports image input.')
559
565
  return {
560
566
  content: [{ type: 'text', text: output.text }, ...(output.image ? [{ type: 'image', data: output.image.data, mimeType: output.image.mimeType }] : [])] as never,
561
- details: output.attachments?.length ? { attachments: output.attachments } : {}
567
+ details: { ...output.details, ...(output.attachments?.length ? { attachments: output.attachments } : {}) }
562
568
  }
563
569
  }
564
570
  })
@@ -400,6 +400,29 @@ describe('session turns on Pi', () => {
400
400
  expect(contexts).toEqual([policyContext])
401
401
  })
402
402
 
403
+ it('refuses non-read-only calls in plan mode and delivers its instructions without changing the system prompt', async () => {
404
+ const seen: { system: string; update: string | null; reasons: (string | null)[] }[] = []
405
+ const { run, harness } = setup(async ({ gate, params }) => fakeSession(async () => {
406
+ const reasons = [await gate.check('bash', {}), await gate.check('write', {}), await gate.check('memory_append', {}), await gate.check('lookup', {}), await gate.check('change', {})]
407
+ seen.push({ system: params.system, update: params.update?.content ?? null, reasons })
408
+ }, gate))
409
+ harness.approvals.setAuto('chat', true)
410
+ harness.sessions.provide(() => [
411
+ { name: 'lookup', label: 'Lookup', description: 'Reads', readOnly: true, parameters: (Type) => Type.Object({}), execute: async () => 'ok' },
412
+ { name: 'change', label: 'Change', description: 'Writes', parameters: (Type) => Type.Object({}), execute: async () => 'ok' }
413
+ ])
414
+ await run('plan-run', { plan: true, tools: { allow: ['read', 'bash', 'write', 'lookup', 'change'] } })
415
+ await tick()
416
+ await run('normal-run', { tools: { allow: ['read', 'bash', 'write', 'lookup', 'change'] } })
417
+ await tick()
418
+ const [planned, normal] = seen
419
+ expect(planned.reasons.map((reason) => reason?.startsWith('Plan mode is read-only') ?? null)).toEqual([true, true, true, null, true])
420
+ expect(normal.reasons).toEqual([null, null, null, null, null])
421
+ expect(planned.system).toBe(normal.system)
422
+ expect(planned.update).toContain('<proposed_plan>')
423
+ expect(normal.update ?? '').not.toContain('Plan mode is on')
424
+ })
425
+
403
426
  it('aborts the Pi session on interrupt', async () => {
404
427
  let session: (PiSessionLike & { aborted: boolean }) | null = null
405
428
  const { events, run, harness } = setup(async ({ gate }) => (session = fakeSession(() => new Promise((resolve) => setTimeout(resolve, 50)), gate)))
@@ -423,7 +446,7 @@ describe('session turns on Pi', () => {
423
446
  await tick()
424
447
  expect(received!.allow.slice(0, 3)).toEqual(['react', 'ask_user', 'plugin_propose'])
425
448
  expect(received!.allow).toEqual(expect.arrayContaining(['memory_append', 'history_search', 'wiki_search', 'wiki_read', 'wiki_propose']))
426
- expect(received!.tools.map((tool) => tool.name)).toEqual(['memory_append', 'memory_replace', 'memory_rethink', 'history_search', 'history_read', 'wiki_search', 'wiki_read', 'wiki_list', 'skill_load', 'memory_facts', 'wiki_propose', 'ask_user', 'plugin_propose', 'react'])
449
+ expect(received!.tools.map((tool) => tool.name)).toEqual(['memory_append', 'memory_replace', 'memory_rethink', 'history_search', 'history_read', 'wiki_search', 'wiki_read', 'wiki_list', 'skill_load', 'memory_facts', 'wiki_propose', 'trajectory_search', 'trajectory_read', 'ask_user', 'plugin_propose', 'spawn_subagent', 'wait_subagents', 'react'])
427
450
  expect(await received!.tools[0].execute({ block: 'notes', text: 'Remember this.' })).toContain('Updated the notes block')
428
451
  expect(received!.params.system).toContain('# Xingular Harness context')
429
452
  })
@@ -496,7 +519,7 @@ describe('subagents', () => {
496
519
  if (params.runId === 'parent') {
497
520
  const spawn = tools.find((tool) => tool.name === 'spawn_subagent')!
498
521
  expect(await spawn.execute({ brief: 'Find the bug', tools: ['bash', 'web'] })).toMatch(/cannot have tools you do not have: web/)
499
- parentResult = await spawn.execute({ brief: 'Find the bug', tools: ['read'] })
522
+ parentResult = await spawn.execute({ brief: 'Find the bug', tools: ['read'], wait: true })
500
523
  return
501
524
  }
502
525
  emit({ type: 'message_start', message: { role: 'assistant' } })
@@ -505,7 +528,7 @@ describe('subagents', () => {
505
528
  })
506
529
  await run('parent', { subagents: { maxDepth: 1 } })
507
530
  for (let attempt = 0; attempt < 50 && !parentResult; attempt++) await tick()
508
- expect(parentResult).toMatch(/Subagent run .* · completed\nResult:\nFound it in parser.ts/)
531
+ expect(parentResult).toMatchObject({ text: expect.stringContaining('Found it in parser.ts'), details: expect.any(Object) })
509
532
  expect(seen.get('parent')!.allow).toContain('spawn_subagent')
510
533
  const [childId] = [...seen.keys()].filter((id) => id !== 'parent')
511
534
  const child = seen.get(childId)!
@@ -516,14 +539,53 @@ describe('subagents', () => {
516
539
  expect(loadRun(harness.env.root, childId)).toMatchObject({ parentRunId: 'parent', relation: 'subagent', agentId: 'agent-1', status: 'completed' })
517
540
  })
518
541
 
519
- it('offers no subagent tool unless the caller asks for it, and can run without the application\'s tool providers', async () => {
542
+ it('exposes native child runs, freezes the launch, respects nesting, and settles waiters on parent stop', async () => {
543
+ const seen = new Map<string, string[]>()
544
+ const { harness, run } = setup(async ({ gate, params, allow }) => {
545
+ seen.set(params.runId, [...allow])
546
+ return fakeSession(async () => new Promise<void>(() => undefined), gate)
547
+ })
548
+ const subagents = { maxDepth: 2 }
549
+ const starting = run('parent', { subagents })
550
+ subagents.maxDepth = 3
551
+ await starting
552
+ await expect(harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Bad tools', tools: ['outside'] })).rejects.toMatchObject({ code: 'invalid_request' })
553
+ const child = await harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Search' })
554
+ const grandchild = await harness.sessions.spawnSubagent({ parentRunId: child.runId, brief: 'Review' })
555
+ expect(seen.get(child.runId)).toContain('spawn_subagent')
556
+ expect(seen.get(grandchild.runId)).not.toContain('spawn_subagent')
557
+ await expect(harness.sessions.spawnSubagent({ parentRunId: grandchild.runId, brief: 'Too deep' })).rejects.toMatchObject({ code: 'invalid_request' })
558
+ const narrowed = await harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Read only', tools: ['read'] })
559
+ expect(seen.get(narrowed.runId)).toEqual(['read'])
560
+ const settled = Promise.all([child, grandchild, narrowed].map(({ runId }) => harness.runs.wait(runId)))
561
+ await harness.runs.interrupt('parent')
562
+ await settled
563
+ for (const { runId } of [child, grandchild, narrowed]) expect(loadRun(harness.env.root, runId).status).toBe('interrupted')
564
+ await expect(harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Too late' })).rejects.toMatchObject({ code: 'run_not_active' })
565
+ await harness.runs.wait(child.runId)
566
+ })
567
+
568
+ it('settles a failed child and attributes the tool to the Harness plugin', async () => {
569
+ const { harness, run } = setup(async ({ gate, params }) => {
570
+ if (params.runId !== 'parent') throw new Error('No provider')
571
+ return fakeSession(async () => new Promise<void>(() => undefined), gate)
572
+ })
573
+ await run('parent', { subagents: {} })
574
+ const child = await harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Try' })
575
+ await harness.runs.wait(child.runId)
576
+ expect(loadRun(harness.env.root, child.runId)).toMatchObject({ status: 'failed', parentRunId: 'parent', terminalReason: 'No provider' })
577
+ const { HARNESS_PLUGINS } = await import('../plugins/builtin')
578
+ expect(HARNESS_PLUGINS.find((plugin) => plugin.id === 'subagents')?.tools).toContainEqual(expect.objectContaining({ name: 'spawn_subagent' }))
579
+ })
580
+
581
+ it('can explicitly disable subagents and run without the application\'s tool providers', async () => {
520
582
  const offered: string[][] = []
521
583
  const { harness, run } = setup(async ({ gate, tools }) => {
522
584
  offered.push(tools.map((tool) => tool.name))
523
585
  return fakeSession(async () => undefined, gate)
524
586
  })
525
587
  harness.sessions.provide(() => [{ name: 'app_tool', label: 'App', description: 'An app tool', parameters: (Type) => Type.Object({}), execute: async () => 'ok' }])
526
- await run('plain', { tools: { allow: ['app_tool'] } })
588
+ await run('plain', { tools: { allow: ['app_tool'] }, subagents: null })
527
589
  await run('solo', { tools: { allow: ['app_tool'] }, providers: false })
528
590
  await tick()
529
591
  expect(offered[0]).toContain('app_tool')
@@ -533,7 +595,7 @@ describe('subagents', () => {
533
595
  })
534
596
 
535
597
  describe('standing approvals', () => {
536
- const policy = { auto: { pi: true }, remembered: { 'pane-1': ['shell'] } }
598
+ const policy = { auto: { pi: true }, globalTools: {}, remembered: { 'pane-1': ['shell'] } }
537
599
  const facts = (unattended: boolean, readOnly: boolean): ApprovalFacts => ({ unattended, readOnly })
538
600
 
539
601
  it('the switch reaches attended sessions but not unattended or read-only ones', () => {
@@ -544,6 +606,6 @@ describe('standing approvals', () => {
544
606
  expect(grant(policy, 'pi', 'pane-1', 'shell', facts(true, false))).toBeNull()
545
607
  expect(grant(policy, 'chat', 'pane-1', 'edit files', facts(false, false))).toBeNull()
546
608
  expect(grant(policy, 'pi', 'pane-2', 'shell', {})).toBe('always')
547
- expect(grant({ auto: {}, remembered: policy.remembered }, 'pi', 'pane-2', 'shell', {})).toBeNull()
609
+ expect(grant({ auto: {}, globalTools: {}, remembered: policy.remembered }, 'pi', 'pane-2', 'shell', {})).toBeNull()
548
610
  })
549
611
  })
@@ -21,17 +21,20 @@ import { FIRST_PARTY_PLUGINS, firstPartyExtensions, type InlineExtension } from
21
21
  import * as registry from '../skills/registry'
22
22
  import { ASK_USER, askUserTool } from '../tools/askUser'
23
23
  import { PLUGIN_PROPOSE, pluginProposeTool } from '../tools/pluginPropose'
24
- import { SPAWN_SUBAGENT, SUBAGENT_RELATION, spawnSubagentTool, subagentDepth, type SubagentOptions } from '../tools/spawnSubagent'
24
+ import { subagentTools } from '../tools/spawnSubagent'
25
25
  import { WIKI_TOOL_NAMES } from '../tools/definitions'
26
26
  import { memoryTools } from '../tools/memoryTools'
27
27
  import type { ToolProvider, ToolSession, ToolSpec } from '../tools/types'
28
28
  import type { RunRecord } from './contracts'
29
+ import { SPAWN_SUBAGENT, WAIT_SUBAGENTS, subagentDepth, subagentHistory, subagentNesting, type SubagentInput, type SubagentOptions } from './subagents'
30
+ import type { Subagents } from './subagentService'
29
31
  import { selectHistory } from './history'
30
32
  import { runLineageJoin } from './lineageJoin'
31
33
  import type { ToolPolicy } from './policy'
32
34
  import type { ActiveRun, RunRegistry } from './registry'
33
35
  import { piSessionFactory, runTurn, summarize, type Effort, type PiSessionLike, type SessionFactory, type ToolGate, type TurnParams } from './runner'
34
36
  import { loadRun, runDir, saveRun, updateRun } from './runStore'
37
+ import { planRefusal, planSection } from './planMode'
35
38
 
36
39
  /** The fields of a run record the caller decides; the harness fills in the rest. */
37
40
  export type RunFields = Pick<RunRecord, 'id' | 'sessionId'> & Partial<Pick<RunRecord, 'mode' | 'harness' | 'sessionType' | 'projectId' | 'agentId' | 'trigger' | 'goal' | 'attachmentCount' | 'resumedFromRunId' | 'parentRunId' | 'relation' | 'worktreeId' | 'handoffId'>>
@@ -67,10 +70,15 @@ export interface SessionRunInput {
67
70
  capabilities?: CapabilitySet
68
71
  /** Hops from the user to this session (0 when the user started it). */
69
72
  depth?: number
70
- /** Offer `spawn_subagent`: the model may start child runs of this caller, up to this depth. */
73
+ /** Interactive default: native child tools. Null disables; unattended callers must opt in. */
71
74
  subagents?: SubagentOptions | null
72
75
  /** Ask the application's tool providers for tools (the default). False runs with the harness's own tools only. */
73
76
  providers?: boolean
77
+ /**
78
+ * Plan mode: the run may only read, search, recall and ask, and is told to end with a
79
+ * `<proposed_plan>` block. Other calls are refused at the gate; the tool list is unchanged.
80
+ */
81
+ plan?: boolean
74
82
  }
75
83
 
76
84
  /** The connected-apps broker, as a session sees it. */
@@ -80,6 +88,7 @@ export interface SessionBroker {
80
88
  }
81
89
 
82
90
  export interface SessionsOptions {
91
+ subagents: Subagents
83
92
  env: DispatchEnv
84
93
  accounts: Accounts
85
94
  runs: RunRegistry
@@ -105,6 +114,7 @@ export class Sessions {
105
114
 
106
115
  constructor(private readonly options: SessionsOptions) {
107
116
  this.factory = options.factory ?? piSessionFactory
117
+
108
118
  }
109
119
 
110
120
  private get env(): HarnessEnv {
@@ -151,33 +161,9 @@ export class Sessions {
151
161
  }
152
162
  }
153
163
 
154
- /** `spawn_subagent` for a run: each child is this caller again, one hop deeper, with no more tools than the parent. */
155
- private subagentTool(input: SessionRunInput, runId: string, accountId: string, parent: CapabilitySet): ToolSpec {
156
- const parentTools = parent.tools.filter((name) => name !== SPAWN_SUBAGENT)
157
- return spawnSubagentTool({
158
- root: this.env.root,
159
- parentRunId: runId,
160
- parentSessionId: input.run.sessionId,
161
- parentTools,
162
- sessionDir: input.sessionDir,
163
- start: (child) => this.run({
164
- ...input,
165
- run: { ...input.run, id: child.runId, sessionId: child.sessionId, parentRunId: runId, relation: SUBAGENT_RELATION, goal: takeChars(child.brief, 4000), resumedFromRunId: null, handoffId: null },
166
- accountId,
167
- poolId: null,
168
- sessionDir: child.sessionDir,
169
- resume: null,
170
- text: child.brief,
171
- images: undefined,
172
- requiresImageInput: false,
173
- preamble: null,
174
- caller: { ...input.caller, runId: undefined, sessionId: child.sessionId },
175
- tools: { allow: [...child.tools, SPAWN_SUBAGENT], gated: (input.tools.gated ?? []).filter((name) => child.tools.includes(name)) },
176
- // Never wider than the parent's frozen set; the session intersects it with what is available now.
177
- capabilities: { ...parent, tools: [...parent.tools.filter((name) => child.tools.includes(name)), SPAWN_SUBAGENT] },
178
- depth: (input.depth ?? 0) + 1
179
- })
180
- })
164
+ /** Compatibility alias for the shared native service. */
165
+ spawnSubagent(request: SubagentInput): Promise<{ runId: string; nativeSessionId: string | null }> {
166
+ return this.options.subagents.spawn(request)
181
167
  }
182
168
 
183
169
  /**
@@ -187,6 +173,7 @@ export class Sessions {
187
173
  async run(input: SessionRunInput): Promise<{ runId: string; nativeSessionId: string | null }> {
188
174
  const { root } = this.env
189
175
  if (!input?.run?.id || !input.run.sessionId || typeof input.text !== 'string') throw invalid('A session turn needs a run id, a session, and text.')
176
+ input = structuredClone(input)
190
177
  const historyFile = selectHistory({ sessionDir: input.sessionDir, resume: input.resume })
191
178
  const account = this.options.accounts.resolve(input.accountId, input.poolId)
192
179
  const context = this.options.accounts.context(account)
@@ -194,14 +181,14 @@ export class Sessions {
194
181
  const caller: CallerContext = { ...input.caller, maintenanceJobIds: input.caller.maintenanceJobIds?.slice(), runId, sessionId: input.caller.sessionId ?? input.run.sessionId }
195
182
  const broker = input.connections ? this.options.broker : null
196
183
  const depth = input.depth ?? 0
197
- const canSpawn = Boolean(input.subagents) && depth < subagentDepth(input.subagents ?? undefined)
184
+ const canSpawn = input.subagents !== null && (!input.automatic || input.subagents !== undefined) && subagentNesting(root, input.run) < subagentDepth(input.subagents ?? undefined)
198
185
  const requestedTools = [...new Set([
199
- ...input.tools.allow.filter((name) => name !== SPAWN_SUBAGENT),
186
+ ...input.tools.allow.filter((name) => name !== SPAWN_SUBAGENT && name !== WAIT_SUBAGENTS),
200
187
  ASK_USER,
201
188
  ...(input.automatic ? [] : [PLUGIN_PROPOSE]),
202
189
  ...WIKI_TOOL_NAMES,
203
190
  ...(broker?.toolNames ?? []),
204
- ...(canSpawn ? [SPAWN_SUBAGENT] : [])
191
+ ...(canSpawn ? [SPAWN_SUBAGENT, WAIT_SUBAGENTS] : [])
205
192
  ])]
206
193
  const available = this.options.resolveCapabilities
207
194
  ? await this.options.resolveCapabilities(caller, requestedTools, input.cwd)
@@ -259,8 +246,10 @@ export class Sessions {
259
246
  try {
260
247
  if (!existsSync(input.sessionDir)) mkdirSync(input.sessionDir, { recursive: true })
261
248
  const launch = input.launch ?? {}
249
+ const plan = planSection(input.plan === true, historyFile)
262
250
  const effectiveLaunch = {
263
251
  ...launch,
252
+ sections: plan ? [...(launch.sections ?? []).filter((section) => section.id !== plan.id), plan] : launch.sections,
264
253
  tools: capabilities.tools,
265
254
  connectionTools: (launch.connectionTools ?? []).filter((name) => capabilities.tools.includes(name)),
266
255
  toolGroups: launch.toolGroups?.map((group) => ({ ...group, tools: group.tools.filter((name) => capabilities.tools.includes(name)) })).filter((group) => group.tools.length > 0)
@@ -278,18 +267,20 @@ export class Sessions {
278
267
  const session: ToolSession = { run, caller, allow, depth, cwd: input.cwd, capabilities }
279
268
  const { plugins } = this.options
280
269
 
281
- let pi: PiSessionLike
270
+ let pi: PiSessionLike | undefined
282
271
  try {
283
272
  const tools: ToolSpec[] = [
284
273
  ...(broker ? broker.tools(session, { readOnly: typeof input.connections === 'object' && input.connections.readOnly === true }) : []),
285
274
  ...memoryTools(this.options.env, caller, capabilities.contributions),
286
275
  askUserTool(run),
287
276
  pluginProposeTool(this.options.env, caller),
288
- ...(allow.includes(SPAWN_SUBAGENT) ? [this.subagentTool(input, runId, account.id, capabilities)] : [])
277
+ ...subagentTools(this.options.subagents, runId).filter(tool => allow.includes(tool.name))
289
278
  ]
290
279
  if (input.providers !== false) for (const provider of this.providers) tools.push(...(await provider(session)))
280
+ const readOnly = new Set(tools.filter((tool) => tool.readOnly).map((tool) => tool.name))
291
281
  const params: TurnParams = {
292
282
  runId,
283
+ restrictTools: input.run.relation === 'subagent',
293
284
  cwd: input.cwd,
294
285
  sessionDir: input.sessionDir,
295
286
  historyFile,
@@ -302,7 +293,7 @@ export class Sessions {
302
293
  automatic: input.automatic,
303
294
  account: context,
304
295
  resources: registry.resources(root, caller.projectId ?? null, caller.agentId ?? null, capabilities.contributions),
305
- inline: [...firstPartyFor(root, capabilities.contributions), runLineageJoin(root, runId)],
296
+ inline: [...firstPartyFor(root, capabilities.contributions), runLineageJoin(root, runId), this.options.subagents.extension(runId)],
306
297
  ...(plugins ? { plugins: { loading: () => plugins.loading({ cwd: input.cwd, agentDir: context.configDir, install: true, caller: { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null }, contributions: capabilities.contributions }), loaded: (extensions) => plugins.loaded(extensions) } } : {}),
307
298
  system,
308
299
  update
@@ -310,25 +301,42 @@ export class Sessions {
310
301
  pi = await this.factory({
311
302
  params,
312
303
  allow,
313
- gate: this.gate(run, caller, new Set((input.tools.gated ?? []).filter((name) => capabilities.tools.includes(name)))),
304
+ gate: (() => {
305
+ const gate = this.gate(run, caller, new Set((input.tools.gated ?? []).filter((name) => capabilities.tools.includes(name))))
306
+ return {
307
+ ...gate,
308
+ check: (name: string, args: unknown) => {
309
+ if (input.run.relation === 'subagent' && !capabilities.tools.includes(name)) return Promise.resolve('This tool is outside the child capability snapshot.')
310
+ const refusal = input.plan ? planRefusal(name, readOnly) : null
311
+ return refusal ? Promise.resolve(refusal) : gate.check(name, args)
312
+ }
313
+ }
314
+ })(),
314
315
  tools,
315
316
  runtime: () => this.options.accounts.runtime(context),
316
317
  // An unattended run loads no extension, so it has nobody to relay for.
317
318
  ...(input.automatic ? {} : { extensions: { ask: (question) => run.requestAnswer(question), notice: (notice) => run.emit('extension.notice', notice) } })
318
319
  })
319
- if ((input.requiresImageInput || input.images?.length) && !pi.model?.input.includes('image')) {
320
+ if (run.finished) {
320
321
  pi.dispose()
322
+ return { runId, nativeSessionId: null }
323
+ }
324
+ if ((input.requiresImageInput || input.images?.length) && !pi.model?.input.includes('image')) {
321
325
  throw new HarnessError('image_input_unsupported', 'Choose a model that supports images, or remove the image attachments and send again.')
322
326
  }
323
327
  if (input.resume?.nativeSessionId && pi.sessionId !== input.resume.nativeSessionId) {
324
- pi.dispose()
325
328
  run.finish('failed', { code: 'native_resume_unavailable', message: 'The saved conversation could not be restored.' })
326
329
  throw new HarnessError('native_resume_unavailable', 'The saved conversation could not be restored.')
327
330
  }
328
- updateRun(root, runId, (record) => void (record.nativeSessionId = pi.sessionId))
331
+ const opened = pi
332
+ updateRun(root, runId, (record) => void (record.nativeSessionId = opened.sessionId))
333
+ this.options.subagents.register(input, account.id, capabilities, Boolean(pi.sessionManager))
334
+ if (pi.model?.id && pi.model.provider) this.options.subagents.resolvedModel(runId, `${pi.model.provider}/${pi.model.id}`, pi.thinkingLevel)
335
+ params.settle = () => this.options.subagents.verify(runId, opened.sessionManager ? subagentHistory(opened.sessionManager.getBranch()) : undefined)
329
336
  void runTurn(run, pi, params)
330
337
  return { runId, nativeSessionId: pi.sessionId }
331
338
  } catch (error) {
339
+ pi?.dispose()
332
340
  if (error instanceof HarnessError && error.code === 'native_resume_unavailable') throw error
333
341
  const message = error instanceof Error ? error.message : String(error)
334
342
  log('session failed to start', message)