@xingular/harness 0.1.6 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +139 -28
  4. package/dist/cli.js +9598 -2201
  5. package/dist/terminal-pi.js +808 -16
  6. package/docs/README.md +15 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  9. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  10. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  11. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  12. package/docs/terminal-tui.md +344 -0
  13. package/package.json +11 -4
  14. package/src/accounts/accounts.test.ts +197 -1
  15. package/src/accounts/accounts.ts +266 -28
  16. package/src/cli.ts +7 -3
  17. package/src/core/bus.test.ts +22 -0
  18. package/src/core/bus.ts +17 -2
  19. package/src/harness.ts +27 -4
  20. package/src/hub/catalog.json +123 -0
  21. package/src/hub/cli.ts +59 -0
  22. package/src/hub/hub.test.ts +205 -0
  23. package/src/hub/hub.ts +256 -0
  24. package/src/hub/schema.ts +81 -0
  25. package/src/index.ts +12 -2
  26. package/src/mcp/connections.ts +39 -12
  27. package/src/mcp/service.test.ts +49 -3
  28. package/src/mcp/service.ts +14 -3
  29. package/src/mcp/types.ts +10 -0
  30. package/src/memory/api.ts +16 -2
  31. package/src/memory/blocks.ts +3 -1
  32. package/src/memory/caller.ts +13 -1
  33. package/src/memory/curation.test.ts +206 -0
  34. package/src/memory/curation.ts +64 -0
  35. package/src/memory/dispatch.ts +5 -0
  36. package/src/memory/maintenance.test.ts +17 -1
  37. package/src/memory/maintenance.ts +367 -18
  38. package/src/memory/operations.ts +12 -0
  39. package/src/memory/proposals.ts +16 -8
  40. package/src/memory/recall.test.ts +11 -0
  41. package/src/memory/recall.ts +11 -3
  42. package/src/memory/trajectory.test.ts +178 -0
  43. package/src/memory/trajectory.ts +220 -0
  44. package/src/memory/wiki.ts +2 -1
  45. package/src/memory/wikiIndex.ts +3 -2
  46. package/src/plugins/builtin.ts +4 -2
  47. package/src/plugins/catalogue.test.ts +1 -1
  48. package/src/server/methods.ts +43 -2
  49. package/src/server/server.test.ts +20 -1
  50. package/src/sessions/approvals.test.ts +49 -0
  51. package/src/sessions/approvals.ts +26 -4
  52. package/src/sessions/contracts.ts +29 -3
  53. package/src/sessions/conversation.test.ts +83 -0
  54. package/src/sessions/lineageJoin.ts +1 -1
  55. package/src/sessions/policy.ts +4 -0
  56. package/src/sessions/questionBatches.test.ts +110 -0
  57. package/src/sessions/registry.ts +145 -27
  58. package/src/sessions/runStore.ts +14 -0
  59. package/src/sessions/runner.ts +45 -4
  60. package/src/sessions/sessions.test.ts +137 -4
  61. package/src/sessions/sessions.ts +51 -15
  62. package/src/sessions/subagentService.test.ts +159 -0
  63. package/src/sessions/subagentService.ts +236 -0
  64. package/src/sessions/subagents.ts +97 -0
  65. package/src/skills/registry.ts +16 -10
  66. package/src/terminal/accountsMenu.test.ts +225 -0
  67. package/src/terminal/accountsMenu.ts +382 -0
  68. package/src/terminal/control.test.ts +23 -0
  69. package/src/terminal/control.ts +23 -0
  70. package/src/terminal/controller.ts +134 -7
  71. package/src/terminal/hubMenu.ts +24 -0
  72. package/src/terminal/launch.test.ts +5 -14
  73. package/src/terminal/launch.ts +2 -42
  74. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  75. package/src/terminal/piExtension.ts +91 -6
  76. package/src/terminal/socket.test.ts +23 -0
  77. package/src/terminal/socket.ts +55 -20
  78. package/src/terminal/standalone.test.ts +37 -0
  79. package/src/terminal/standalone.ts +28 -7
  80. package/src/terminal/terminal.test.ts +220 -16
  81. package/src/terminal/terminals.ts +234 -16
  82. package/src/terminal/trajectory.test.ts +66 -0
  83. package/src/terminal/trajectory.ts +44 -0
  84. package/src/terminal/tui/application.test.ts +290 -0
  85. package/src/terminal/tui/application.ts +653 -0
  86. package/src/terminal/tui/commands.test.ts +278 -0
  87. package/src/terminal/tui/commands.ts +443 -0
  88. package/src/terminal/tui/dialogs.ts +301 -0
  89. package/src/terminal/tui/editor.test.ts +113 -0
  90. package/src/terminal/tui/editor.ts +109 -0
  91. package/src/terminal/tui/keybindings.test.ts +30 -0
  92. package/src/terminal/tui/keybindings.ts +59 -0
  93. package/src/terminal/tui/piAdapter.ts +62 -0
  94. package/src/terminal/tui/planMode.test.ts +10 -0
  95. package/src/terminal/tui/planMode.ts +12 -0
  96. package/src/terminal/tui/presentation.test.ts +79 -0
  97. package/src/terminal/tui/questions.test.ts +41 -0
  98. package/src/terminal/tui/questions.ts +47 -0
  99. package/src/terminal/tui/runtime.socket.test.ts +178 -0
  100. package/src/terminal/tui/runtime.test.ts +225 -0
  101. package/src/terminal/tui/runtime.ts +403 -0
  102. package/src/terminal/tui/screen.test.ts +143 -0
  103. package/src/terminal/tui/screen.ts +121 -0
  104. package/src/terminal/tui/sessionUi.ts +24 -0
  105. package/src/terminal/tui/settings.test.ts +150 -0
  106. package/src/terminal/tui/settings.ts +188 -0
  107. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  108. package/src/terminal/tui/skillInvocation.ts +51 -0
  109. package/src/terminal/tui/status.test.ts +48 -0
  110. package/src/terminal/tui/status.ts +82 -0
  111. package/src/terminal/tui/theme.ts +185 -0
  112. package/src/terminal/tui/tools.test.ts +145 -0
  113. package/src/terminal/tui/tools.ts +142 -0
  114. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  115. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  116. package/src/terminal/tui/transcript.test.ts +39 -0
  117. package/src/terminal/tui/transcript.ts +135 -0
  118. package/src/terminal/tui/ui.test.ts +313 -0
  119. package/src/terminal/tui/ui.ts +166 -0
  120. package/src/terminal/tui/welcome.ts +102 -0
  121. package/src/terminal/tui/workingStatus.test.ts +44 -0
  122. package/src/terminal/tui/workingStatus.ts +238 -0
  123. package/src/tools/askUser.test.ts +69 -0
  124. package/src/tools/askUser.ts +31 -17
  125. package/src/tools/definitions.ts +42 -1
  126. package/src/tools/maintenance.ts +29 -0
  127. package/src/tools/memoryTools.ts +1 -1
  128. package/src/tools/registryWrite.test.ts +101 -0
  129. package/src/tools/registryWrite.ts +182 -0
  130. package/src/tools/spawnSubagent.ts +47 -0
  131. package/src/tools/types.ts +2 -0
@@ -27,6 +27,7 @@ export interface ApprovalInput {
27
27
  export interface QuestionInput {
28
28
  requestId?: string
29
29
  userOnly?: boolean
30
+ summary?: string
30
31
  question: string
31
32
  options: QuestionOption[]
32
33
  multiSelect: boolean
@@ -36,6 +37,21 @@ export interface QuestionInput {
36
37
  export interface QuestionAnswer {
37
38
  selected: string[]
38
39
  text?: string
40
+ /** Optional notes keyed by the selected choice label. */
41
+ notes?: Record<string, string>
42
+ }
43
+
44
+ interface QuestionBatchMetadata {
45
+ batchId: string
46
+ batchIndex: number
47
+ batchCount: number
48
+ }
49
+
50
+ interface PendingQuestion {
51
+ runId: string
52
+ input: QuestionInput
53
+ batch?: QuestionBatchMetadata
54
+ resolve: (answer: QuestionAnswer) => void
39
55
  }
40
56
 
41
57
  export interface ActiveRun {
@@ -43,10 +59,14 @@ export interface ActiveRun {
43
59
  sessionId: string
44
60
  emit(type: RunEventType, payload: unknown, native?: unknown): void
45
61
  onInterrupt(handler: () => Promise<void> | void): void
62
+ /** Hands a message to the model after its current step; the session registers how. */
63
+ onSteer(handler: (text: string) => Promise<void> | void): void
46
64
  /** Resolves when the user decides; only the approval with this exact id can be resolved by it. */
47
65
  requestApproval(input: ApprovalInput): Promise<ApprovalDecision>
48
66
  /** Asks the user a question and resolves with their answer. */
49
67
  requestAnswer(input: QuestionInput): Promise<QuestionAnswer>
68
+ /** Asks one to five related questions and resolves only after the whole batch is answered. */
69
+ requestAnswers(inputs: QuestionInput[]): Promise<QuestionAnswer[]>
50
70
  finish(outcome: 'completed' | 'failed' | 'interrupted', failure?: { code: string; message: string }): void
51
71
  readonly finished: boolean
52
72
  }
@@ -64,13 +84,14 @@ export interface RegistryOptions {
64
84
  redact?: (event: RunEvent) => void
65
85
  }
66
86
 
67
- type LiveRun = ActiveRun & { interrupt: () => Promise<void> }
87
+ type LiveRun = ActiveRun & { interrupt: () => Promise<void>; steer: (text: string) => Promise<void> }
68
88
 
69
89
  export class RunRegistry {
70
90
  private readonly runs = new Map<string, LiveRun>()
71
91
  private readonly approvals = new Map<string, { runId: string; resolve: (decision: ApprovalDecision) => void }>()
72
- private readonly questions = new Map<string, { runId: string; input: QuestionInput; resolve: (answer: QuestionAnswer) => void }>()
92
+ private readonly questions = new Map<string, PendingQuestion>()
73
93
  private readonly nativeCleared = new Map<string, string>()
94
+ private readonly waiters = new Map<string, Array<() => void>>()
74
95
  private readonly stopping = new Set<string>()
75
96
  private readonly listeners: RunListener[] = []
76
97
 
@@ -110,10 +131,34 @@ export class RunRegistry {
110
131
  sequence = 0
111
132
  }
112
133
  let finished = false
134
+ let finishing = false
113
135
  const interruptHandlers: Array<() => Promise<void> | void> = []
136
+ let steerHandler: ((text: string) => Promise<void> | void) | null = null
114
137
  const waitingStatus = (): string =>
115
138
  [...this.approvals.values()].some((request) => request.runId === runId) ? 'waiting_for_approval' : [...this.questions.values()].some((request) => request.runId === runId) ? 'waiting_for_input' : 'running'
116
139
 
140
+ const requestQuestion = (input: QuestionInput, batch?: QuestionBatchMetadata, statusEvent = true): Promise<QuestionAnswer> => {
141
+ const { requestId, ...payload } = input
142
+ const questionId = requestId ?? randomUUID()
143
+ if (finished || finishing || this.nativeCleared.delete(questionId)) return Promise.resolve({ selected: [] })
144
+ if (this.questions.has(questionId)) throw new HarnessError('invalid_request', 'This question id is already pending.')
145
+ return new Promise<QuestionAnswer>((resolve) => {
146
+ this.questions.set(questionId, {
147
+ runId,
148
+ input,
149
+ ...(batch ? { batch } : {}),
150
+ resolve: (answer) => {
151
+ this.questions.delete(questionId)
152
+ emit('question.answered', { questionId, ...answer, ...(batch ?? {}) })
153
+ emit('status.changed', { status: waitingStatus() })
154
+ resolve(answer)
155
+ }
156
+ })
157
+ emit('question.requested', { questionId, ...payload, ...(batch ?? {}) })
158
+ if (statusEvent) emit('status.changed', { status: 'waiting_for_input' })
159
+ })
160
+ }
161
+
117
162
  const emit = (type: RunEventType, payload: unknown, native?: unknown): void => {
118
163
  if (finished) return
119
164
  const parsed = runEventSchema.safeParse({ id: randomUUID(), runId, sessionId, sequence, timestamp: new Date().toISOString(), type, payload, ...(native === undefined ? {} : { native }) })
@@ -130,13 +175,20 @@ export class RunRegistry {
130
175
  sessionId,
131
176
  emit,
132
177
  onInterrupt: (handler) => void interruptHandlers.push(handler),
178
+ onSteer: (handler) => {
179
+ steerHandler = handler
180
+ },
181
+ steer: async (text) => {
182
+ if (finished || !steerHandler) throw new HarnessError('run_not_active', 'This run is not accepting messages.')
183
+ await steerHandler(text)
184
+ },
133
185
  requestApproval: async (input) => {
134
186
  const { requestId, standing = 'full', timeoutMs, ...payload } = input
135
187
  const approvalId = requestId ?? randomUUID()
136
188
  // What the user already allowed is known here; if it cannot be read, ask as usual.
137
189
  const record = findRun(this.options.root(), runId)
138
190
  const automatic = record && this.options.automatic && standing !== 'none' ? await this.options.automatic(record, input.target, input.operation, standing).catch(() => null) : null
139
- if (finished) return 'denied'
191
+ if (finished || finishing) return 'denied'
140
192
  if (this.nativeCleared.delete(approvalId)) return 'native'
141
193
  if (automatic) {
142
194
  // Marked on the request too, so nothing treats it as waiting on someone.
@@ -160,35 +212,35 @@ export class RunRegistry {
160
212
  })
161
213
  })
162
214
  },
163
- requestAnswer: (input) => {
164
- const { requestId, ...payload } = input
165
- const questionId = requestId ?? randomUUID()
166
- if (finished || this.nativeCleared.delete(questionId)) return Promise.resolve({ selected: [] })
167
- emit('question.requested', { questionId, ...payload })
215
+ requestAnswer: (input) => requestQuestion(input),
216
+ requestAnswers: (inputs) => {
217
+ if (!Array.isArray(inputs) || inputs.length < 1 || inputs.length > 5) {
218
+ throw new HarnessError('invalid_request', 'A question batch must contain between one and five questions.')
219
+ }
220
+ const requestedIds = inputs.flatMap((input) => input.requestId ? [input.requestId] : [])
221
+ if (new Set(requestedIds).size !== requestedIds.length || requestedIds.some((id) => this.questions.has(id))) {
222
+ throw new HarnessError('invalid_request', 'Question ids in a batch must be unique and not already pending.')
223
+ }
224
+ if (finished || finishing) return Promise.resolve(inputs.map(() => ({ selected: [] })))
225
+ const batchId = randomUUID()
226
+ const answers = inputs.map((input, index) => requestQuestion(input, { batchId, batchIndex: index + 1, batchCount: inputs.length }, false))
168
227
  emit('status.changed', { status: 'waiting_for_input' })
169
- return new Promise<QuestionAnswer>((resolve) => {
170
- this.questions.set(questionId, {
171
- runId,
172
- input,
173
- resolve: (answer) => {
174
- this.questions.delete(questionId)
175
- emit('question.answered', { questionId, ...answer })
176
- emit('status.changed', { status: waitingStatus() })
177
- resolve(answer)
178
- }
179
- })
180
- })
228
+ return Promise.all(answers)
181
229
  },
182
230
  finish: (outcome, failure) => {
183
- 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: [] })
184
236
  if (outcome === 'failed') emit('failed', failure ?? { code: 'failed', message: 'The run failed.' })
185
237
  else if (outcome === 'interrupted') emit('status.changed', { status: 'interrupted' })
186
238
  else emit('completed', {})
187
239
  finished = true
188
240
  this.runs.delete(runId)
241
+ for (const resolve of [...(this.waiters.get(runId) ?? [])]) resolve()
242
+ this.waiters.delete(runId)
189
243
  for (const [id, owner] of this.nativeCleared) if (owner === runId) this.nativeCleared.delete(id)
190
- for (const [, approval] of [...this.approvals]) if (approval.runId === runId) approval.resolve('denied')
191
- for (const [, question] of [...this.questions]) if (question.runId === runId) question.resolve({ selected: [] })
192
244
  try {
193
245
  this.options.ended?.(runId, sessionId)
194
246
  } catch (error) {
@@ -199,6 +251,8 @@ export class RunRegistry {
199
251
  return finished
200
252
  },
201
253
  interrupt: async () => {
254
+ // Release approvals/questions before SDK abort waits for their blocked tools.
255
+ run.finish('interrupted')
202
256
  for (const handler of interruptHandlers) {
203
257
  try {
204
258
  await handler()
@@ -206,7 +260,6 @@ export class RunRegistry {
206
260
  log(`an interrupt handler failed for run ${runId}`, error instanceof Error ? error.message : String(error))
207
261
  }
208
262
  }
209
- run.finish('interrupted')
210
263
  }
211
264
  }
212
265
 
@@ -232,6 +285,30 @@ export class RunRegistry {
232
285
  return false
233
286
  }
234
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
+
235
312
  /** The live run with this id, for a tool or broker that reports into it. */
236
313
  active(runId: string): ActiveRun | null {
237
314
  return this.runs.get(runId) ?? null
@@ -243,6 +320,13 @@ export class RunRegistry {
243
320
  return null
244
321
  }
245
322
 
323
+ /** Hands a message to a live run's model after its current step. Throws `run_not_active` when the run cannot take it. */
324
+ async steer(runId: string, text: string): Promise<void> {
325
+ const run = this.runs.get(runId)
326
+ if (!run) throw new HarnessError('run_not_active', 'This run is not active.')
327
+ await run.steer(text)
328
+ }
329
+
246
330
  async interrupt(runId: string): Promise<void> {
247
331
  const run = this.runs.get(runId)
248
332
  if (!run) throw new HarnessError('run_not_active', 'This run is not active.')
@@ -272,10 +356,44 @@ export class RunRegistry {
272
356
  answerQuestion(runId: string, questionId: string, answer: QuestionAnswer): void {
273
357
  const question = this.questions.get(questionId)
274
358
  if (!question || question.runId !== runId) throw new HarnessError('question_not_pending', 'This question is not waiting for an answer.')
275
- if ((!question.input.multiSelect && answer.selected.length > 1) || answer.selected.some((label) => !question.input.options.some((option) => option.label === label)) || (answer.text && !question.input.allowText)) {
276
- throw new HarnessError('invalid_answer', 'Choose the offered options or an allowed text answer.')
359
+ if (question.batch) throw new HarnessError('question_batch_required', 'Answer every question in this batch together.')
360
+ question.resolve(this.validateAnswer(question.input, answer))
361
+ }
362
+
363
+ answerQuestions(runId: string, batchId: string, answers: Array<{ questionId: string; answer: QuestionAnswer }>): void {
364
+ const questions = [...this.questions.entries()].filter(([, pending]) => pending.runId === runId && pending.batch?.batchId === batchId)
365
+ if (!questions.length || answers.length !== questions.length || new Set(answers.map(({ questionId }) => questionId)).size !== answers.length) {
366
+ throw new HarnessError('question_not_pending', 'This question batch is not waiting for an answer.')
367
+ }
368
+ const pendingById = new Map(questions)
369
+ const validated = answers.map(({ questionId, answer }) => {
370
+ const pending = pendingById.get(questionId)
371
+ if (!pending) throw new HarnessError('question_not_pending', 'Every question in the batch must be answered together.')
372
+ return { pending, answer: this.validateAnswer(pending.input, answer) }
373
+ })
374
+ for (const { pending, answer } of validated) pending.resolve(answer)
375
+ }
376
+
377
+ private validateAnswer(input: QuestionInput, answer: QuestionAnswer): QuestionAnswer {
378
+ const selected = answer.selected
379
+ const selectedLabels = new Set(selected)
380
+ const notes: Record<string, string> = {}
381
+ for (const [label, note] of Object.entries(answer.notes ?? {})) {
382
+ const normalized = typeof note === 'string' ? note.trim() : ''
383
+ if (normalized) notes[label] = normalized
384
+ }
385
+ const text = answer.text?.trim()
386
+ if (
387
+ (!input.multiSelect && selected.length > 1) ||
388
+ selectedLabels.size !== selected.length ||
389
+ selected.some((label) => !input.options.some((option) => option.label === label)) ||
390
+ (text && !input.allowText) ||
391
+ Object.keys(notes).some((label) => !selectedLabels.has(label)) ||
392
+ Object.values(notes).some((note) => note.length > 10_000)
393
+ ) {
394
+ throw new HarnessError('invalid_answer', 'Choose the offered options or an allowed text answer, and attach notes only to selected choices.')
277
395
  }
278
- question.resolve(answer)
396
+ return { selected, ...(text ? { text } : {}), ...(Object.keys(notes).length ? { notes } : {}) }
279
397
  }
280
398
 
281
399
  activeRunIds(): string[] {
@@ -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,13 +80,19 @@ 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
86
91
  prompt(text: string, options?: { images?: unknown[] }): Promise<void>
92
+ /** Queues a message for the model after the current step. Resolves `handled` when Pi takes it without queueing. */
93
+ steer?(text: string): Promise<'handled' | 'queued'>
94
+ /** Drops queued messages Pi has not delivered. */
95
+ clearQueue?(): unknown
87
96
  sendCustomMessage?(message: { customType: string; content: string; display: boolean; details: unknown }, options: { triggerTurn: boolean }): Promise<void>
88
97
  abort(): Promise<void>
89
98
  dispose(): void
@@ -255,11 +264,35 @@ export async function runTurn(run: ActiveRun, session: PiSessionLike, params: Tu
255
264
  await session.abort()
256
265
  })
257
266
 
267
+ // Steers handed to Pi and not yet delivered, oldest first. Pi delivers them in that order, after
268
+ // the prompt's own user message, which is the first user message it starts.
269
+ const pendingSteers: string[] = []
270
+ let accepting = false
271
+ let promptStarted = false
272
+ const forgetSteer = (text: string): void => {
273
+ const index = pendingSteers.lastIndexOf(text)
274
+ if (index >= 0) pendingSteers.splice(index, 1)
275
+ }
276
+ run.onSteer(async (text) => {
277
+ if (!accepting || !session.steer) throw new HarnessError('run_not_active', 'This reply is not accepting messages.')
278
+ pendingSteers.push(text)
279
+ try {
280
+ if ((await session.steer(text)) === 'handled') forgetSteer(text)
281
+ } catch (error) {
282
+ forgetSteer(text)
283
+ throw error
284
+ }
285
+ })
286
+
258
287
  const unsubscribe = session.subscribe((event) => {
259
288
  switch (event.type) {
260
289
  case 'message_start': {
261
290
  const { message } = event as Extract<PiEvent, { type: 'message_start' }>
262
291
  if (message.role === 'assistant') currentMessageId = `${run.runId}:m${messageIndex++}`
292
+ else if (message.role === 'user' && promptStarted) {
293
+ const text = pendingSteers.shift()
294
+ if (text !== undefined) run.emit('message.steered', { text })
295
+ } else if (message.role === 'user') promptStarted = true
263
296
  break
264
297
  }
265
298
  case 'turn_start': {
@@ -424,7 +457,15 @@ export async function runTurn(run: ActiveRun, session: PiSessionLike, params: Tu
424
457
  }
425
458
  const prompt = params.preamble && !extensionCommand ? `${params.preamble}\n\n${params.text}` : params.text
426
459
  const images = (params.images ?? []).map((image) => ({ type: 'image', mimeType: image.mediaType, data: image.data }))
427
- await session.prompt(prompt, images.length ? { images } : undefined)
460
+ accepting = true
461
+ try {
462
+ await session.prompt(prompt, images.length ? { images } : undefined)
463
+ } finally {
464
+ // A message Pi has not delivered by now never will be; the caller sends it again as a new reply.
465
+ accepting = false
466
+ session.clearQueue?.()
467
+ }
468
+ if (!aborted && !lastError) await params.settle?.()
428
469
  if (aborted) run.finish('interrupted')
429
470
  else if (lastError) run.finish('failed', { code: 'provider_error', message: lastError })
430
471
  else run.finish('completed')
@@ -505,7 +546,7 @@ export const piSessionFactory: SessionFactory = async ({ params, allow, gate, to
505
546
  const loaded = registeredBy(resourceLoader)
506
547
  // A tool an extension brought is offered to the model, and asks before every call: the person
507
548
  // trusted the code to load, and still decides each thing it does through a tool.
508
- 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)
509
550
  gate.ask?.([...new Set(loaded.flatMap((extension) => extension.tools))])
510
551
 
511
552
  let supportsImageInput = false
@@ -523,7 +564,7 @@ export const piSessionFactory: SessionFactory = async ({ params, allow, gate, to
523
564
  if (output.image && !supportsImageInput) throw new HarnessError('image_input_unsupported', 'This model cannot inspect images. Choose a model that supports image input.')
524
565
  return {
525
566
  content: [{ type: 'text', text: output.text }, ...(output.image ? [{ type: 'image', data: output.image.data, mimeType: output.image.mimeType }] : [])] as never,
526
- details: output.attachments?.length ? { attachments: output.attachments } : {}
567
+ details: { ...output.details, ...(output.attachments?.length ? { attachments: output.attachments } : {}) }
527
568
  }
528
569
  }
529
570
  })
@@ -326,6 +326,36 @@ describe('session turns on Pi', () => {
326
326
  expect(events.at(-1)?.type).toBe('completed')
327
327
  })
328
328
 
329
+ it('asks a person for a humanOnly call even when the tool was remembered', async () => {
330
+ let blockReason: string | null | undefined
331
+ const policy: ToolPolicy = { scope: async () => ({ allowed: false, requiresApproval: true, humanOnly: true, reason: 'Run this command?' }) }
332
+ const { events, run, harness } = setup(async ({ gate }) => fakeSession(async (_emit, check) => void (blockReason = await check()), gate), policy)
333
+ harness.approvals.remember('agent-1', 'bash')
334
+ harness.approvals.setAuto('chat', true)
335
+ await run('run-human', { text: 'npm install' })
336
+ await tick()
337
+ const requested = events.find((event) => event.type === 'approval.requested')!.payload as { approvalId: string }
338
+ expect(requested).toMatchObject({ target: 'bash', rememberable: false })
339
+ expect(requested).not.toHaveProperty('automatic')
340
+ expect(events.some((event) => event.type === 'status.changed' && (event.payload as { status: string }).status === 'waiting_for_approval')).toBe(true)
341
+ harness.runs.resolveApproval('run-human', requested.approvalId, 'approved')
342
+ await tick()
343
+ expect(blockReason).toBeNull()
344
+ })
345
+
346
+ it('lets the Agent chat switch approve humanOnly calls without remembering the tool', async () => {
347
+ let blockReason: string | null | undefined
348
+ const policy: ToolPolicy = { scope: async () => ({ allowed: false, requiresApproval: true, humanOnly: true, allowGlobalApproval: true, reason: 'Run this command?' }) }
349
+ const { events, run, harness } = setup(async ({ gate }) => fakeSession(async (_emit, check) => void (blockReason = await check()), gate), policy)
350
+ harness.approvals.setAuto('chat', true)
351
+ await run('run-human-auto', { text: 'run command' })
352
+ await tick()
353
+ expect(blockReason).toBeNull()
354
+ expect(events.find((event) => event.type === 'approval.requested')?.payload).toMatchObject({ target: 'bash', rememberable: false, automatic: 'always' })
355
+ expect(events.find((event) => event.type === 'approval.resolved')?.payload).toMatchObject({ decision: 'approved', automatic: 'always' })
356
+ expect(events.some((event) => event.type === 'status.changed' && (event.payload as { status: string }).status === 'waiting_for_approval')).toBe(false)
357
+ })
358
+
329
359
  it('still asks when the application cannot say what is allowed', async () => {
330
360
  const { events, run } = setup(async ({ gate }) => fakeSession(async (_emit, check) => void (await check()), gate), { approval: async () => { throw new Error('down') } })
331
361
  await run('run-ask', { text: 'list files' })
@@ -344,14 +374,32 @@ describe('session turns on Pi', () => {
344
374
  if (authorized > 1) throw new Error('Tool unavailable in the app settings.')
345
375
  }
346
376
  }
347
- const { run } = setup(async ({ gate }) => fakeSession(async () => {
377
+ const { run, harness } = setup(async ({ gate }) => fakeSession(async () => {
348
378
  reasons.push(await gate.check('bash', {}), await gate.check('read', {}), await gate.check('read', {}))
349
379
  }, gate), policy)
380
+ harness.approvals.setAuto('chat', true)
350
381
  await run('run-scope')
351
382
  await tick()
352
383
  expect(reasons).toEqual(['Scheduled workers may use scoped file tools only.', null, 'Tool unavailable in the app settings.'])
353
384
  })
354
385
 
386
+ it('forwards opaque caller policy context to the application tool policy', async () => {
387
+ const contexts: unknown[] = []
388
+ const policy: ToolPolicy = {
389
+ scope: async ({ caller }) => {
390
+ contexts.push(caller.policyContext)
391
+ return { allowed: true }
392
+ }
393
+ }
394
+ const { run } = setup(async ({ gate }) => fakeSession(async (_emit, check) => void (await check()), gate), policy)
395
+ const policyContext = { delegatesTo: ['worker-1'], snapshot: 'run-start' }
396
+ await run('run-policy-context', {
397
+ caller: { agentId: 'agent-1', agentName: 'Helper', actor: 'Chat', policyContext }
398
+ })
399
+ await tick()
400
+ expect(contexts).toEqual([policyContext])
401
+ })
402
+
355
403
  it('aborts the Pi session on interrupt', async () => {
356
404
  let session: (PiSessionLike & { aborted: boolean }) | null = null
357
405
  const { events, run, harness } = setup(async ({ gate }) => (session = fakeSession(() => new Promise((resolve) => setTimeout(resolve, 50)), gate)))
@@ -375,7 +423,7 @@ describe('session turns on Pi', () => {
375
423
  await tick()
376
424
  expect(received!.allow.slice(0, 3)).toEqual(['react', 'ask_user', 'plugin_propose'])
377
425
  expect(received!.allow).toEqual(expect.arrayContaining(['memory_append', 'history_search', 'wiki_search', 'wiki_read', 'wiki_propose']))
378
- 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'])
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', 'trajectory_search', 'trajectory_read', 'ask_user', 'plugin_propose', 'spawn_subagent', 'wait_subagents', 'react'])
379
427
  expect(await received!.tools[0].execute({ block: 'notes', text: 'Remember this.' })).toContain('Updated the notes block')
380
428
  expect(received!.params.system).toContain('# Xingular Harness context')
381
429
  })
@@ -438,8 +486,93 @@ describe('history selection', () => {
438
486
  })
439
487
  })
440
488
 
489
+ describe('subagents', () => {
490
+ it('starts a child run of the same caller with no more tools than its parent, and returns its result', async () => {
491
+ const seen = new Map<string, { allow: string[]; tools: string[]; depth: string | null }>()
492
+ let parentResult: unknown = null
493
+ const { harness, run } = setup(async ({ gate, params, allow, tools }) => {
494
+ seen.set(params.runId, { allow: [...allow], tools: tools.map((tool) => tool.name), depth: null })
495
+ return fakeSession(async (emit) => {
496
+ if (params.runId === 'parent') {
497
+ const spawn = tools.find((tool) => tool.name === 'spawn_subagent')!
498
+ 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'], wait: true })
500
+ return
501
+ }
502
+ emit({ type: 'message_start', message: { role: 'assistant' } })
503
+ emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'Found it in parser.ts' }] } })
504
+ }, gate)
505
+ })
506
+ await run('parent', { subagents: { maxDepth: 1 } })
507
+ for (let attempt = 0; attempt < 50 && !parentResult; attempt++) await tick()
508
+ expect(parentResult).toMatchObject({ text: expect.stringContaining('Found it in parser.ts'), details: expect.any(Object) })
509
+ expect(seen.get('parent')!.allow).toContain('spawn_subagent')
510
+ const [childId] = [...seen.keys()].filter((id) => id !== 'parent')
511
+ const child = seen.get(childId)!
512
+ expect(child.allow).toContain('read')
513
+ expect(child.allow).not.toContain('bash')
514
+ // At the depth limit the child cannot start subagents of its own.
515
+ expect(child.allow).not.toContain('spawn_subagent')
516
+ expect(loadRun(harness.env.root, childId)).toMatchObject({ parentRunId: 'parent', relation: 'subagent', agentId: 'agent-1', status: 'completed' })
517
+ })
518
+
519
+ it('exposes native child runs, freezes the launch, respects nesting, and settles waiters on parent stop', async () => {
520
+ const seen = new Map<string, string[]>()
521
+ const { harness, run } = setup(async ({ gate, params, allow }) => {
522
+ seen.set(params.runId, [...allow])
523
+ return fakeSession(async () => new Promise<void>(() => undefined), gate)
524
+ })
525
+ const subagents = { maxDepth: 2 }
526
+ const starting = run('parent', { subagents })
527
+ subagents.maxDepth = 3
528
+ await starting
529
+ await expect(harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Bad tools', tools: ['outside'] })).rejects.toMatchObject({ code: 'invalid_request' })
530
+ const child = await harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Search' })
531
+ const grandchild = await harness.sessions.spawnSubagent({ parentRunId: child.runId, brief: 'Review' })
532
+ expect(seen.get(child.runId)).toContain('spawn_subagent')
533
+ expect(seen.get(grandchild.runId)).not.toContain('spawn_subagent')
534
+ await expect(harness.sessions.spawnSubagent({ parentRunId: grandchild.runId, brief: 'Too deep' })).rejects.toMatchObject({ code: 'invalid_request' })
535
+ const narrowed = await harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Read only', tools: ['read'] })
536
+ expect(seen.get(narrowed.runId)).toEqual(['read'])
537
+ const settled = Promise.all([child, grandchild, narrowed].map(({ runId }) => harness.runs.wait(runId)))
538
+ await harness.runs.interrupt('parent')
539
+ await settled
540
+ for (const { runId } of [child, grandchild, narrowed]) expect(loadRun(harness.env.root, runId).status).toBe('interrupted')
541
+ await expect(harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Too late' })).rejects.toMatchObject({ code: 'run_not_active' })
542
+ await harness.runs.wait(child.runId)
543
+ })
544
+
545
+ it('settles a failed child and attributes the tool to the Harness plugin', async () => {
546
+ const { harness, run } = setup(async ({ gate, params }) => {
547
+ if (params.runId !== 'parent') throw new Error('No provider')
548
+ return fakeSession(async () => new Promise<void>(() => undefined), gate)
549
+ })
550
+ await run('parent', { subagents: {} })
551
+ const child = await harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Try' })
552
+ await harness.runs.wait(child.runId)
553
+ expect(loadRun(harness.env.root, child.runId)).toMatchObject({ status: 'failed', parentRunId: 'parent', terminalReason: 'No provider' })
554
+ const { HARNESS_PLUGINS } = await import('../plugins/builtin')
555
+ expect(HARNESS_PLUGINS.find((plugin) => plugin.id === 'subagents')?.tools).toContainEqual(expect.objectContaining({ name: 'spawn_subagent' }))
556
+ })
557
+
558
+ it('can explicitly disable subagents and run without the application\'s tool providers', async () => {
559
+ const offered: string[][] = []
560
+ const { harness, run } = setup(async ({ gate, tools }) => {
561
+ offered.push(tools.map((tool) => tool.name))
562
+ return fakeSession(async () => undefined, gate)
563
+ })
564
+ harness.sessions.provide(() => [{ name: 'app_tool', label: 'App', description: 'An app tool', parameters: (Type) => Type.Object({}), execute: async () => 'ok' }])
565
+ await run('plain', { tools: { allow: ['app_tool'] }, subagents: null })
566
+ await run('solo', { tools: { allow: ['app_tool'] }, providers: false })
567
+ await tick()
568
+ expect(offered[0]).toContain('app_tool')
569
+ expect(offered[0]).not.toContain('spawn_subagent')
570
+ expect(offered[1]).not.toContain('app_tool')
571
+ })
572
+ })
573
+
441
574
  describe('standing approvals', () => {
442
- const policy = { auto: { pi: true }, remembered: { 'pane-1': ['shell'] } }
575
+ const policy = { auto: { pi: true }, globalTools: {}, remembered: { 'pane-1': ['shell'] } }
443
576
  const facts = (unattended: boolean, readOnly: boolean): ApprovalFacts => ({ unattended, readOnly })
444
577
 
445
578
  it('the switch reaches attended sessions but not unattended or read-only ones', () => {
@@ -450,6 +583,6 @@ describe('standing approvals', () => {
450
583
  expect(grant(policy, 'pi', 'pane-1', 'shell', facts(true, false))).toBeNull()
451
584
  expect(grant(policy, 'chat', 'pane-1', 'edit files', facts(false, false))).toBeNull()
452
585
  expect(grant(policy, 'pi', 'pane-2', 'shell', {})).toBe('always')
453
- expect(grant({ auto: {}, remembered: policy.remembered }, 'pi', 'pane-2', 'shell', {})).toBeNull()
586
+ expect(grant({ auto: {}, globalTools: {}, remembered: policy.remembered }, 'pi', 'pane-2', 'shell', {})).toBeNull()
454
587
  })
455
588
  })