@xingular/harness 0.1.6 → 0.1.7

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 (133) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +118 -26
  4. package/dist/cli.js +6106 -912
  5. package/dist/terminal-pi.js +462 -11
  6. package/docs/README.md +12 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  9. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  10. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  11. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  12. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  13. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  14. package/docs/evidence/terminal-2026-10-17/verification.json +130 -0
  15. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  16. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  17. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  18. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  19. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  20. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  21. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  22. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  23. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  24. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  25. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  26. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  27. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +95 -0
  28. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  29. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  30. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  31. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  32. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +27 -0
  33. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  34. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +18 -0
  35. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
  36. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  37. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  38. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +60 -0
  39. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
  40. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  41. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +57 -0
  42. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  43. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  44. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +58 -0
  45. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  46. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +320 -0
  47. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
  48. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
  49. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
  50. package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
  51. package/docs/terminal-tui.md +254 -0
  52. package/package.json +8 -2
  53. package/src/accounts/accounts.test.ts +110 -0
  54. package/src/accounts/accounts.ts +146 -10
  55. package/src/cli.ts +7 -3
  56. package/src/harness.ts +16 -3
  57. package/src/hub/catalog.json +123 -0
  58. package/src/hub/cli.ts +59 -0
  59. package/src/hub/hub.test.ts +205 -0
  60. package/src/hub/hub.ts +256 -0
  61. package/src/hub/schema.ts +81 -0
  62. package/src/index.ts +6 -1
  63. package/src/memory/api.ts +16 -2
  64. package/src/memory/blocks.ts +3 -1
  65. package/src/memory/caller.ts +13 -1
  66. package/src/memory/curation.test.ts +163 -0
  67. package/src/memory/curation.ts +64 -0
  68. package/src/memory/maintenance.ts +199 -11
  69. package/src/memory/operations.ts +12 -0
  70. package/src/memory/proposals.ts +16 -8
  71. package/src/memory/recall.test.ts +11 -0
  72. package/src/memory/recall.ts +9 -2
  73. package/src/server/methods.ts +37 -2
  74. package/src/sessions/contracts.ts +29 -3
  75. package/src/sessions/conversation.test.ts +27 -0
  76. package/src/sessions/policy.ts +4 -0
  77. package/src/sessions/questionBatches.test.ts +110 -0
  78. package/src/sessions/registry.ts +109 -22
  79. package/src/sessions/runner.ts +36 -1
  80. package/src/sessions/sessions.test.ts +95 -1
  81. package/src/sessions/sessions.ts +53 -9
  82. package/src/skills/registry.ts +16 -10
  83. package/src/terminal/accountsMenu.test.ts +225 -0
  84. package/src/terminal/accountsMenu.ts +382 -0
  85. package/src/terminal/control.test.ts +23 -0
  86. package/src/terminal/control.ts +23 -0
  87. package/src/terminal/controller.ts +68 -4
  88. package/src/terminal/hubMenu.ts +24 -0
  89. package/src/terminal/launch.test.ts +5 -14
  90. package/src/terminal/launch.ts +2 -42
  91. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  92. package/src/terminal/piExtension.ts +50 -1
  93. package/src/terminal/socket.ts +13 -2
  94. package/src/terminal/standalone.test.ts +37 -0
  95. package/src/terminal/standalone.ts +16 -7
  96. package/src/terminal/terminal.test.ts +70 -1
  97. package/src/terminal/terminals.ts +122 -12
  98. package/src/terminal/tui/application.test.ts +114 -0
  99. package/src/terminal/tui/application.ts +491 -0
  100. package/src/terminal/tui/commands.test.ts +218 -0
  101. package/src/terminal/tui/commands.ts +361 -0
  102. package/src/terminal/tui/dialogs.ts +301 -0
  103. package/src/terminal/tui/editor.test.ts +84 -0
  104. package/src/terminal/tui/editor.ts +79 -0
  105. package/src/terminal/tui/keybindings.test.ts +22 -0
  106. package/src/terminal/tui/keybindings.ts +50 -0
  107. package/src/terminal/tui/piAdapter.ts +62 -0
  108. package/src/terminal/tui/presentation.test.ts +79 -0
  109. package/src/terminal/tui/questions.test.ts +41 -0
  110. package/src/terminal/tui/questions.ts +47 -0
  111. package/src/terminal/tui/runtime.socket.test.ts +104 -0
  112. package/src/terminal/tui/runtime.test.ts +215 -0
  113. package/src/terminal/tui/runtime.ts +388 -0
  114. package/src/terminal/tui/sessionUi.ts +24 -0
  115. package/src/terminal/tui/settings.test.ts +150 -0
  116. package/src/terminal/tui/settings.ts +188 -0
  117. package/src/terminal/tui/status.test.ts +70 -0
  118. package/src/terminal/tui/status.ts +109 -0
  119. package/src/terminal/tui/theme.ts +174 -0
  120. package/src/terminal/tui/tools.test.ts +145 -0
  121. package/src/terminal/tui/tools.ts +142 -0
  122. package/src/terminal/tui/transcript.test.ts +25 -0
  123. package/src/terminal/tui/transcript.ts +124 -0
  124. package/src/terminal/tui/ui.test.ts +313 -0
  125. package/src/terminal/tui/ui.ts +166 -0
  126. package/src/terminal/tui/welcome.ts +102 -0
  127. package/src/tools/askUser.test.ts +69 -0
  128. package/src/tools/askUser.ts +31 -17
  129. package/src/tools/maintenance.ts +24 -0
  130. package/src/tools/memoryTools.ts +1 -1
  131. package/src/tools/registryWrite.test.ts +101 -0
  132. package/src/tools/registryWrite.ts +182 -0
  133. package/src/tools/spawnSubagent.ts +92 -0
@@ -95,6 +95,8 @@ export interface Evidence {
95
95
  sourceText?: string | null
96
96
  /** Ids of the blocks attached to the run. */
97
97
  blocks?: string[]
98
+ runs?: Record<string, string>
99
+ sources?: Record<string, string>
98
100
  }
99
101
 
100
102
  const MIN_EXCERPT = 12
@@ -175,6 +177,7 @@ export function captureHashes(wiki: string): Map<string, string> {
175
177
  for (const page of store.loadPages(wiki)) add(page.frontmatter.id, page.path)
176
178
  for (const memory of facts.loadMemories(wiki)) add(memory.front.id, memory.path)
177
179
  for (const block of blocks.loadAll(wiki)) add(block.id, block.path)
180
+ for (const item of registry.list(dirname(wiki))) add(item.id, item.path)
178
181
  return hashes
179
182
  }
180
183
 
@@ -223,7 +226,11 @@ interface Write {
223
226
  }
224
227
 
225
228
  /** Validates proposals and applies the valid ones as one operation. Pure file logic. */
226
- export function apply(wiki: string, defaultNamespace: string, trigger: Trigger, proposals: ProposalSet, captured: ReadonlyMap<string, string>, evidence: Evidence): ApplyOutcome {
229
+ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger, proposals: ProposalSet, captured: ReadonlyMap<string, string>, evidence: Evidence, operationKey?: string, appliedBy?: string): ApplyOutcome {
230
+ if (operationKey) {
231
+ const committed = readJson<Operation>(join(operationsDir(wiki), `${safeId(operationKey)}.json`))
232
+ if (committed) return { operation: committed, parked: 0, flagged: 0, notes: ['This request was already applied.'] }
233
+ }
227
234
  const existing = store.loadPages(wiki)
228
235
  const existingMemories = facts.loadMemories(wiki)
229
236
  const root = dirname(wiki)
@@ -240,7 +247,7 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
240
247
  ])
241
248
  const outcome: ApplyOutcome = { operation: null, parked: 0, flagged: 0, notes: [] }
242
249
  const writes: Write[] = []
243
- const actor = triggerActor(trigger)
250
+ const actor = appliedBy ?? triggerActor(trigger)
244
251
  const described = describeTrigger(trigger)
245
252
  const fromUser = trigger.kind === 'user'
246
253
 
@@ -248,7 +255,7 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
248
255
  const park = (kind: string, title: string, label: string, reason: string, body: string, entryId: string, payload: unknown): void => {
249
256
  outcome.parked += 1
250
257
  outcome.notes.push(`${label} went to the Inbox: ${reason}`)
251
- inbox(wiki, kind, title, `${reason}\n\n${takeChars(body, 600)}`, [entryId], null, payload)
258
+ inbox(wiki, kind, title, `${reason}\n\n${takeChars(body, 600)}`, [entryId], null, operationKey && payload && typeof payload === 'object' ? { ...payload, curationJobId: operationKey } : payload)
252
259
  }
253
260
 
254
261
  // Evidence: a source in the Wiki, the triggering run, a URL, or the user's acceptance. A run
@@ -259,11 +266,12 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
259
266
  for (const reference of refs) {
260
267
  let id: string | null = null
261
268
  if (reference.kind === 'source') {
262
- if (sourceIds.has(reference.ref) && (evidence.sourceText == null || quotedIn(evidence.sourceText, reference.excerpt))) id = reference.ref
269
+ const source = evidence.sources?.[reference.ref] ?? (trigger.kind === 'ingest' && trigger.sourceId === reference.ref ? evidence.sourceText : null)
270
+ if (sourceIds.has(reference.ref) && (operationKey ? quotedIn(source, reference.excerpt) : (evidence.sourceText == null || quotedIn(evidence.sourceText, reference.excerpt)))) id = reference.ref
263
271
  } else if (reference.kind === 'run') {
264
- if (triggerRunId(trigger) === reference.ref && quotedIn(evidence.runText, reference.excerpt)) id = `run:${reference.ref}`
272
+ if (quotedIn(evidence.runs?.[reference.ref] ?? (triggerRunId(trigger) === reference.ref ? evidence.runText : null), reference.excerpt)) id = `run:${reference.ref}`
265
273
  } else if (reference.kind === 'url') {
266
- if (reference.ref.startsWith('https://') || reference.ref.startsWith('http://')) id = `url:${reference.ref}`
274
+ if (!operationKey && (reference.ref.startsWith('https://') || reference.ref.startsWith('http://'))) id = `url:${reference.ref}`
267
275
  } else if (reference.kind === 'user') {
268
276
  if (fromUser) id = 'user:accepted'
269
277
  }
@@ -277,7 +285,7 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
277
285
  .filter((link) => knownIds.has(link.toId) || proposedIds.has(link.toId))
278
286
  .filter((link) => link.toId !== ownId)
279
287
  .map((link) => ({ fromId: ownId, toId: link.toId, type: link.type }))
280
- const changedSince = (id: string, path: string): boolean => captured.has(id) && captured.get(id) !== fileHash(path)
288
+ const changedSince = (id: string, path: string): boolean => (captured.has(id) || Boolean(operationKey)) && captured.get(id) !== fileHash(path)
281
289
 
282
290
  for (const proposal of proposals.pages) {
283
291
  const pageId = store.slug(proposal.id ?? proposal.title)
@@ -464,7 +472,7 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
464
472
  }
465
473
  })
466
474
 
467
- const operationId = newId()
475
+ const operationId = operationKey ?? newId()
468
476
  const short = operationId.slice(0, 8)
469
477
  const pages: OperationPage[] = writes.map((write) => ({
470
478
  pageId: write.entryId,
@@ -87,6 +87,17 @@ test('an agent recalls its own chats by default and everything on request', () =
87
87
  expect(searchTool(env, { ...agent('agent-a'), runId: 'chat-1' }, { query: 'pnpm' })).not.toContain('run chat-1')
88
88
  })
89
89
 
90
+ test('a caller cannot recall the conversations of agents its host excludes', () => {
91
+ const env = setup()
92
+ sync(env.root)
93
+ const caller = { ...agent('agent-a'), recallExcludeAgents: ['agent-b'] }
94
+ const all = searchTool(env, caller, { query: 'pnpm', scope: 'all' })
95
+ expect(all).toContain('chat-1')
96
+ expect(all).not.toContain('chat-2')
97
+ expect(() => readTool(env, caller, { run: 'chat-2' })).toThrow()
98
+ expect(readTool(env, agent('agent-a'), { run: 'chat-2' })).toContain('Plan the launch')
99
+ })
100
+
90
101
  test('a session recalls only session runs of its project', () => {
91
102
  const env = setup()
92
103
  sync(env.root)
@@ -221,6 +221,13 @@ export function sourceLabel(env: HarnessEnv, mode: string, sessionId: string, pr
221
221
  }
222
222
  }
223
223
 
224
+ /** Whether a conversation belongs to an agent the caller may not recall. */
225
+ function excluded(env: HarnessEnv, context: CallerContext, sessionId: string): boolean {
226
+ const hidden = context.recallExcludeAgents ?? []
227
+ if (!hidden.length) return false
228
+ return hidden.includes(sessionId) || hidden.includes(env.directory.sessionAgent(sessionId) ?? '')
229
+ }
230
+
224
231
  /** `recall.search` as text for a model. */
225
232
  export function searchTool(env: HarnessEnv, context: CallerContext, params: Params): string {
226
233
  const query = textParam(params, 'query')
@@ -229,7 +236,7 @@ export function searchTool(env: HarnessEnv, context: CallerContext, params: Para
229
236
  const requested = params?.limit
230
237
  const limit = typeof requested === 'number' && Number.isInteger(requested) && requested >= 0 ? requested : SEARCH_DEFAULT
231
238
  // The caller's current run is what it is in the middle of, not something to recall.
232
- const hits = search(env.root, query, scope, limit + 4).filter((hit) => hit.runId !== context.runId).slice(0, Math.min(Math.max(limit, 1), SEARCH_MAX))
239
+ const hits = search(env.root, query, scope, limit + 4 + (context.recallExcludeAgents?.length ? SEARCH_MAX : 0)).filter((hit) => hit.runId !== context.runId && !excluded(env, context, hit.sessionId)).slice(0, Math.min(Math.max(limit, 1), SEARCH_MAX))
233
240
  if (!hits.length) {
234
241
  return scope.sessionId && !isSession(context) ? 'Nothing in your own conversations matches. Pass scope "all" to search every conversation and Harness run.' : 'Nothing in the recorded history matches.'
235
242
  }
@@ -242,7 +249,7 @@ export function readTool(env: HarnessEnv, context: CallerContext, params: Params
242
249
  const run = findRun(env.root, runId)
243
250
  if (!run) throw notFound('Run')
244
251
  // Reading is as wide as an "all" search for an agent, and as narrow as a search for a session.
245
- if (!mayRead(callerScope(env.root, context, { scope: 'all' }), run)) throw notFound('Run')
252
+ if (!mayRead(callerScope(env.root, context, { scope: 'all' }), run) || excluded(env, context, run.sessionId)) throw notFound('Run')
246
253
  const messages = read(env.root, run.id)
247
254
  if (!messages.length) return 'That run has no recorded messages.'
248
255
  let text = `Run ${run.id} · ${run.startedAt.slice(0, 16)} · ${sourceLabel(env, run.mode, run.sessionId, run.projectId)} · ${run.status}\n`
@@ -14,7 +14,7 @@ import { capabilitySet } from '../capabilities/runSnapshot'
14
14
  import type { Harness } from '../harness'
15
15
  import * as blocks from '../memory/blocks'
16
16
  import { validateScopeIds } from '../mcp/connections'
17
- import type { CallerContext } from '../memory/caller'
17
+ import { callerBlocks, type CallerContext } from '../memory/caller'
18
18
  import { dispatch } from '../memory/dispatch'
19
19
  import * as facts from '../memory/facts'
20
20
  import * as graph from '../memory/graph'
@@ -46,6 +46,16 @@ const text = (params: Params, name: string): string => {
46
46
  return value
47
47
  }
48
48
  const optional = (params: Params, name: string): string | null => (typeof params[name] === 'string' && params[name] ? (params[name] as string) : null)
49
+ const questionAnswer = (value: unknown): { selected: string[]; text?: string; notes?: Record<string, string> } => {
50
+ const answer = value && typeof value === 'object' && !Array.isArray(value) ? value as Params : {}
51
+ return {
52
+ selected: Array.isArray(answer.selected) ? answer.selected.filter((label: unknown): label is string => typeof label === 'string') : [],
53
+ ...(typeof answer.text === 'string' && answer.text ? { text: answer.text } : {}),
54
+ ...(answer.notes && typeof answer.notes === 'object' && !Array.isArray(answer.notes)
55
+ ? { notes: Object.fromEntries(Object.entries(answer.notes).filter((entry): entry is [string, string] => typeof entry[1] === 'string')) }
56
+ : {})
57
+ }
58
+ }
49
59
  const pluginScope = (params: Params): plugins.PluginScope => ({ cwd: optional(params, 'cwd'), accountId: optional(params, 'accountId'), agentId: optional(params, 'agentId'), projectId: optional(params, 'projectId') })
50
60
 
51
61
  /** Sources larger than this are not imported. */
@@ -65,6 +75,14 @@ export function methods(harness: Harness): Record<string, Handler> {
65
75
  'harness.status': () => ({ dataRoot: harness.dataRoot, productName: env.productName, recovery: harness.recovery, activeRuns: runs.activeRunIds() }),
66
76
  'harness.open': (params: Params) => harness.open(text(params, 'dataRoot'), Array.isArray(params.agentIds) ? params.agentIds : []),
67
77
  'harness.rebuildIndex': () => harness.rebuildIndex(),
78
+ 'maintenance.list': (params: Params) => maintenance.jobs().filter((job) => params.runId ? job.runId === params.runId || (Array.isArray(params.ids) && params.ids.includes(job.id)) : params.includeCompleted === true || !['completed', 'rejected'].includes(job.status)).map(({ prepared: _prepared, hashes: _hashes, ...job }) => job),
79
+ 'maintenance.review': (params: Params) => { maintenance.enqueue({ kind: 'review', requestId: optional(params, 'requestId') ?? String(Date.now()), manual: params.manual === true }); return { queued: true } },
80
+ 'maintenance.failRun': (params: Params) => { maintenance.finishRun(text(params, 'runId'), true); return null },
81
+ 'maintenance.retry': (params: Params) => maintenance.retry(text(params, 'id')),
82
+ 'hub.list': (params: Params) => harness.hub.list(params.refresh === true),
83
+ 'hub.get': (params: Params) => harness.hub.get(text(params, 'id'), text(params, 'version'), params.preview === true),
84
+ 'hub.install': (params: Params) => harness.hub.install(text(params, 'id'), text(params, 'version')),
85
+ 'hub.readDocument': (params: Params) => harness.hub.readDocument(text(params, 'id'), text(params, 'version')),
68
86
 
69
87
  // ---- Accounts --------------------------------------------------------------------------------
70
88
  'accounts.list': () => accounts.overview(),
@@ -84,6 +102,7 @@ export function methods(harness: Harness): Record<string, Handler> {
84
102
  'accounts.sharedDir': () => ensureSharedDir(accounts.dir),
85
103
  'accounts.refresh': (params: Params) => accounts.refresh(optional(params, 'accountId')),
86
104
  'accounts.loginStart': (params: Params) => accounts.loginStart(text(params, 'accountId'), text(params, 'provider'), optional(params, 'method')),
105
+ 'accounts.loginStatus': (params: Params) => accounts.loginStatus(text(params, 'accountId')),
87
106
  'accounts.loginRespond': (params: Params) => accounts.loginRespond(text(params, 'loginId'), text(params, 'promptId'), typeof params.value === 'string' ? params.value : null),
88
107
  'accounts.loginCancel': (params: Params) => accounts.loginCancel(text(params, 'accountId'), optional(params, 'loginId')),
89
108
  'accounts.setApiKey': (params: Params) => accounts.setApiKey(text(params, 'accountId'), text(params, 'key'), text(params, 'provider')),
@@ -196,6 +215,11 @@ export function methods(harness: Harness): Record<string, Handler> {
196
215
  },
197
216
 
198
217
  // ---- What a session calls, on behalf of a caller the host names -------------------------------
218
+ 'memory.permissions': (params: Params) => {
219
+ const context = (params.context ?? {}) as CallerContext
220
+ const { caller, attached } = callerBlocks(env, context)
221
+ return attached.map((block) => ({ id: block.id, writable: blocks.editRefusal(caller, block) === null, reason: blocks.editRefusal(caller, block) }))
222
+ },
199
223
  'memory.call': (params: Params) => dispatch(env, (params.context ?? {}) as CallerContext, text(params, 'call'), params.params ?? {}),
200
224
 
201
225
  // ---- Recall ------------------------------------------------------------------------------------
@@ -278,6 +302,13 @@ export function methods(harness: Harness): Record<string, Handler> {
278
302
  await runs.interrupt(runId)
279
303
  return loadRun(root(), runId)
280
304
  },
305
+ // A message the person sends into a live reply. It reaches the model after the current step;
306
+ // `message.steered` marks the moment, and a message the run never takes is reported by its end.
307
+ 'runs.steer': async (params: Params) => {
308
+ const runId = text(params, 'runId')
309
+ await runs.steer(runId, text(params, 'text'))
310
+ return { runId }
311
+ },
281
312
  // What the application reports into a live run, and an approval it asks a person for.
282
313
  'runs.emit': (params: Params) => {
283
314
  const run = runs.active(text(params, 'runId'))
@@ -298,7 +329,11 @@ export function methods(harness: Harness): Record<string, Handler> {
298
329
  return { decision }
299
330
  },
300
331
  'runs.resolveApproval': (params: Params) => runs.resolveApproval(text(params, 'runId'), text(params, 'approvalId'), params.decision),
301
- 'runs.answerQuestion': (params: Params) => runs.answerQuestion(text(params, 'runId'), text(params, 'questionId'), { selected: Array.isArray(params.answer?.selected) ? params.answer.selected : [], ...(typeof params.answer?.text === 'string' && params.answer.text ? { text: params.answer.text } : {}) }),
332
+ 'runs.answerQuestion': (params: Params) => runs.answerQuestion(text(params, 'runId'), text(params, 'questionId'), questionAnswer(params.answer)),
333
+ 'runs.answerQuestions': (params: Params) => {
334
+ if (!Array.isArray(params.answers)) throw invalid('answers must be an array.')
335
+ runs.answerQuestions(text(params, 'runId'), text(params, 'batchId'), params.answers.map((item: Params) => ({ questionId: text(item, 'questionId'), answer: questionAnswer(item.answer) })))
336
+ },
302
337
 
303
338
  // ---- Sessions ----------------------------------------------------------------------------------
304
339
  'sessions.run': (params: Params) => sessions.run(params as SessionRunInput),
@@ -10,6 +10,7 @@ export const runEventTypeSchema = z.enum([
10
10
  'context.snapshot',
11
11
  'message.delta',
12
12
  'message.completed',
13
+ 'message.steered',
13
14
  'tool.started',
14
15
  'tool.completed',
15
16
  'retry.started',
@@ -35,6 +36,8 @@ export type RunEventType = z.infer<typeof runEventTypeSchema>
35
36
 
36
37
  const messageDeltaPayload = z.object({ messageId: z.string(), text: z.string() })
37
38
  const messageCompletedPayload = z.object({ messageId: z.string(), role: z.enum(['user', 'assistant']), text: z.string() })
39
+ /** A message the person sent into the run while it was going; the model reads it from its next request on. */
40
+ const messageSteeredPayload = z.object({ text: z.string() })
38
41
  const contextSnapshotPayload = z.object({
39
42
  systemPrompt: z.string().max(100_000),
40
43
  contextUpdate: z.string().max(100_000).optional(),
@@ -96,15 +99,37 @@ const approvalRequestedPayload = z.object({
96
99
  const approvalResolvedPayload = z.object({ approvalId: z.string(), decision: z.enum(['approved', 'denied', 'native']), automatic: approvalAutomaticSchema.optional() })
97
100
  export const questionOptionSchema = z.object({ label: z.string().min(1), description: z.string().optional() })
98
101
  export type QuestionOption = z.infer<typeof questionOptionSchema>
102
+ const questionBatchFields = {
103
+ batchId: z.string().min(1).optional(),
104
+ batchIndex: z.number().int().min(1).max(5).optional(),
105
+ batchCount: z.number().int().min(1).max(5).optional()
106
+ }
107
+ const questionBatchRefinement = <T extends { batchId?: string; batchIndex?: number; batchCount?: number }>(value: T, context: z.RefinementCtx): void => {
108
+ const fields = [value.batchId, value.batchIndex, value.batchCount]
109
+ if (fields.some((field) => field !== undefined) && fields.some((field) => field === undefined)) {
110
+ context.addIssue({ code: z.ZodIssueCode.custom, message: 'Question batch fields must be supplied together.' })
111
+ }
112
+ if (value.batchIndex !== undefined && value.batchCount !== undefined && value.batchIndex > value.batchCount) {
113
+ context.addIssue({ code: z.ZodIssueCode.custom, message: 'Question batch index cannot exceed its count.' })
114
+ }
115
+ }
99
116
  const questionRequestedPayload = z.object({
100
117
  questionId: z.string(),
101
118
  question: z.string().min(1),
119
+ summary: z.string().min(1).max(100).optional(),
102
120
  options: z.array(questionOptionSchema),
103
121
  multiSelect: z.boolean(),
104
122
  allowText: z.boolean(),
105
- userOnly: z.boolean().optional()
106
- })
107
- const questionAnsweredPayload = z.object({ questionId: z.string(), selected: z.array(z.string()), text: z.string().optional() })
123
+ userOnly: z.boolean().optional(),
124
+ ...questionBatchFields
125
+ }).superRefine(questionBatchRefinement)
126
+ const questionAnsweredPayload = z.object({
127
+ questionId: z.string(),
128
+ selected: z.array(z.string()),
129
+ text: z.string().optional(),
130
+ notes: z.record(z.string(), z.string().max(10_000)).optional(),
131
+ ...questionBatchFields
132
+ }).superRefine(questionBatchRefinement)
108
133
  /** A short emoji sequence, never words or numbers. */
109
134
  const reactionAddedPayload = z.object({ emoji: z.string().min(1).refine((text) => Buffer.byteLength(text) <= 16 && !/[A-Za-z0-9\s]/.test(text)) })
110
135
  /** A message this run's agent sent to (`to`) or received back from (`from`) another agent. */
@@ -196,6 +221,7 @@ export const runEventPayloadSchemas = {
196
221
  'context.snapshot': contextSnapshotPayload,
197
222
  'message.delta': messageDeltaPayload,
198
223
  'message.completed': messageCompletedPayload,
224
+ 'message.steered': messageSteeredPayload,
199
225
  'tool.started': toolPayload,
200
226
  'tool.completed': toolPayload,
201
227
  'retry.started': retryStartedPayload,
@@ -147,4 +147,31 @@ describe('a whole reply, with a scripted model', () => {
147
147
  await until(() => settled(id))
148
148
  expect(model.lastToolResults().at(-1)).not.toContain('waiting in the Inbox')
149
149
  })
150
+
151
+ it('delivers a message sent during a tool call after that call, before the next request', async () => {
152
+ const { harness, model, call, start, until, settled, of } = await setup()
153
+ model.script([model.calls(['bash', { command: 'ls' }]), model.says('Noted.')])
154
+ const id = await start({ tools: { allow: ['read', 'bash'], gated: ['bash'] } })
155
+ await until(() => of(id).some((event) => event.type === 'approval.requested'))
156
+ const approval = of(id).find((event) => event.type === 'approval.requested')!.payload as { approvalId: string }
157
+ await call('runs.steer', { runId: id, text: 'Also check the logs.' })
158
+ harness.runs.resolveApproval(id, approval.approvalId, 'denied')
159
+ await until(() => settled(id))
160
+ const types = of(id).map((event) => event.type)
161
+ expect(of(id).find((event) => event.type === 'message.steered')?.payload).toEqual({ text: 'Also check the logs.' })
162
+ expect(types.indexOf('message.steered')).toBeGreaterThan(types.indexOf('approval.resolved'))
163
+ expect(model.requests()).toBe(2)
164
+ // The context update reaches the model as a user-role message too; the prompt and the steer come last.
165
+ expect(model.lastUserTexts().slice(-2)).toEqual(['Go.', 'Also check the logs.'])
166
+ expect(of(id).at(-1)?.type).toBe('completed')
167
+ })
168
+
169
+ it('refuses a message once the reply has ended, so the app sends it as a new reply', async () => {
170
+ const { call, model, start, until, settled, of } = await setup()
171
+ model.script([model.says('Done.')])
172
+ const id = await start()
173
+ await until(() => settled(id))
174
+ await expect(call('runs.steer', { runId: id, text: 'Too late.' })).rejects.toMatchObject({ code: 'run_not_active' })
175
+ expect(of(id).some((event) => event.type === 'message.steered')).toBe(false)
176
+ })
150
177
  })
@@ -17,6 +17,10 @@ export interface ScopeDecision {
17
17
  allowed: boolean
18
18
  /** Not allowed as it stands, but a person may approve this one call. */
19
19
  requiresApproval?: boolean
20
+ /** Per-tool remembered approvals never answer this call. */
21
+ humanOnly?: boolean
22
+ /** Whether the global always-approve switch may answer a human-only call. Defaults to false. */
23
+ allowGlobalApproval?: boolean
20
24
  reason?: string | null
21
25
  }
22
26
 
@@ -0,0 +1,110 @@
1
+ import { mkdtempSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, expect, test } from 'vitest'
5
+
6
+ import { closeIndex } from '../core/index'
7
+ import { runEventPayloadSchemas, runRecordSchema, type RunEvent } from './contracts'
8
+ import { RunRegistry } from './registry'
9
+ import { readEvents, saveRun } from './runStore'
10
+
11
+ const roots: string[] = []
12
+ afterEach(() => {
13
+ for (const root of roots.splice(0)) closeIndex(root)
14
+ })
15
+
16
+ function fixture() {
17
+ const root = mkdtempSync(join(tmpdir(), 'harness-question-batch-'))
18
+ roots.push(root)
19
+ saveRun(root, runRecordSchema.parse({
20
+ id: 'run-1',
21
+ sessionId: 'session-1',
22
+ projectId: null,
23
+ mode: 'chat',
24
+ harness: 'pi',
25
+ sessionType: 'structured',
26
+ nativeSessionId: null,
27
+ workingDirectory: null,
28
+ worktreeId: null,
29
+ goal: null,
30
+ status: 'running',
31
+ startedAt: 'start',
32
+ endedAt: null
33
+ }))
34
+ const registry = new RunRegistry({ root: () => root })
35
+ const run = registry.start('session-1', 'run-1')
36
+ return { root, registry, run }
37
+ }
38
+
39
+ test('question batches validate all answers before resolving and retain choice notes', async () => {
40
+ const { root, registry, run } = fixture()
41
+ expect(() => run.requestAnswers([])).toThrow(/between one and five/)
42
+ expect(() => run.requestAnswers(Array.from({ length: 6 }, () => ({ question: 'Q', options: [], multiSelect: false, allowText: true })))).toThrow(/between one and five/)
43
+
44
+ const result = run.requestAnswers([
45
+ { summary: 'Project scope', question: 'What should change?', options: [{ label: 'UI' }, { label: 'API' }], multiSelect: true, allowText: true },
46
+ { summary: 'Target date', question: 'When should it ship?', options: [{ label: 'Soon' }, { label: 'Later' }], multiSelect: false, allowText: false }
47
+ ])
48
+ let settled = false
49
+ void result.then(() => { settled = true })
50
+
51
+ const requested = readEvents(root, 'run-1').filter((event) => event.type === 'question.requested')
52
+ expect(requested).toHaveLength(2)
53
+ const [first, second] = requested.map((event) => event.payload as { questionId: string; summary: string; batchId: string; batchIndex: number; batchCount: number })
54
+ expect(first).toMatchObject({ summary: 'Project scope', batchIndex: 1, batchCount: 2 })
55
+ expect(second).toMatchObject({ summary: 'Target date', batchId: first.batchId, batchIndex: 2, batchCount: 2 })
56
+ expect(() => registry.answerQuestion('run-1', first.questionId, { selected: ['UI'] })).toThrow(/together/)
57
+
58
+ expect(() => registry.answerQuestions('run-1', first.batchId, [
59
+ { questionId: first.questionId, answer: { selected: ['UI'] } },
60
+ { questionId: second.questionId, answer: { selected: ['Later'], notes: { Soon: 'wrong choice' } } }
61
+ ])).toThrow(/selected choices/)
62
+ await Promise.resolve()
63
+ expect(settled).toBe(false)
64
+
65
+ registry.answerQuestions('run-1', first.batchId, [
66
+ { questionId: second.questionId, answer: { selected: ['Soon'] } },
67
+ { questionId: first.questionId, answer: { selected: ['UI'], notes: { UI: 'Keep keyboard navigation.' } } }
68
+ ])
69
+ await expect(result).resolves.toEqual([
70
+ { selected: ['UI'], notes: { UI: 'Keep keyboard navigation.' } },
71
+ { selected: ['Soon'] }
72
+ ])
73
+
74
+ const answered = readEvents(root, 'run-1').filter((event) => event.type === 'question.answered')
75
+ expect(answered).toHaveLength(2)
76
+ expect(answered.find((event) => (event.payload as { questionId: string }).questionId === first.questionId)?.payload).toMatchObject({
77
+ batchId: first.batchId,
78
+ batchIndex: 1,
79
+ batchCount: 2,
80
+ notes: { UI: 'Keep keyboard navigation.' }
81
+ })
82
+ })
83
+
84
+ test('legacy single questions remain valid alongside batch event metadata', () => {
85
+ const { root, registry, run } = fixture()
86
+ const answer = run.requestAnswer({ question: 'Continue?', options: [{ label: 'Yes' }], multiSelect: false, allowText: false })
87
+ const request = readEvents(root, 'run-1').find((event) => event.type === 'question.requested')!
88
+ expect(runEventPayloadSchemas['question.requested'].safeParse(request.payload).success).toBe(true)
89
+ expect(runEventPayloadSchemas['question.requested'].safeParse({ ...(request.payload as object), batchId: 'partial' }).success).toBe(false)
90
+ registry.answerQuestion('run-1', (request.payload as { questionId: string }).questionId, { selected: ['Yes'] })
91
+ return expect(answer).resolves.toEqual({ selected: ['Yes'] })
92
+ })
93
+
94
+ test('answer batch method rejects duplicate and foreign question ids without settling the batch', async () => {
95
+ const { root, registry, run } = fixture()
96
+ const result = run.requestAnswers([
97
+ { summary: 'First', question: 'First?', options: [], multiSelect: false, allowText: true },
98
+ { summary: 'Second', question: 'Second?', options: [], multiSelect: false, allowText: true }
99
+ ])
100
+ const requested = readEvents(root, 'run-1').filter((event) => event.type === 'question.requested')
101
+ const [first, second] = requested.map((event) => event.payload as { questionId: string; batchId: string })
102
+ const answers = [{ questionId: first.questionId, answer: { selected: [], text: 'a' } }, { questionId: second.questionId, answer: { selected: [], text: 'b' } }]
103
+ expect(() => registry.answerQuestions('run-1', 'other-batch', answers)).toThrow(/batch/)
104
+ expect(() => registry.answerQuestions('run-1', first.batchId, [answers[0], answers[0]])).toThrow(/batch/)
105
+ expect(() => registry.answerQuestions('another-run', first.batchId, answers)).toThrow(/batch/)
106
+ const events: RunEvent[] = readEvents(root, 'run-1')
107
+ expect(events.filter((event) => event.type === 'question.answered')).toHaveLength(0)
108
+ registry.answerQuestions('run-1', first.batchId, answers)
109
+ await expect(result).resolves.toHaveLength(2)
110
+ })
@@ -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,12 +84,12 @@ 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>()
74
94
  private readonly stopping = new Set<string>()
75
95
  private readonly listeners: RunListener[] = []
@@ -111,9 +131,32 @@ export class RunRegistry {
111
131
  }
112
132
  let finished = false
113
133
  const interruptHandlers: Array<() => Promise<void> | void> = []
134
+ let steerHandler: ((text: string) => Promise<void> | void) | null = null
114
135
  const waitingStatus = (): string =>
115
136
  [...this.approvals.values()].some((request) => request.runId === runId) ? 'waiting_for_approval' : [...this.questions.values()].some((request) => request.runId === runId) ? 'waiting_for_input' : 'running'
116
137
 
138
+ const requestQuestion = (input: QuestionInput, batch?: QuestionBatchMetadata, statusEvent = true): Promise<QuestionAnswer> => {
139
+ const { requestId, ...payload } = input
140
+ const questionId = requestId ?? randomUUID()
141
+ if (finished || this.nativeCleared.delete(questionId)) return Promise.resolve({ selected: [] })
142
+ if (this.questions.has(questionId)) throw new HarnessError('invalid_request', 'This question id is already pending.')
143
+ return new Promise<QuestionAnswer>((resolve) => {
144
+ this.questions.set(questionId, {
145
+ runId,
146
+ input,
147
+ ...(batch ? { batch } : {}),
148
+ resolve: (answer) => {
149
+ this.questions.delete(questionId)
150
+ emit('question.answered', { questionId, ...answer, ...(batch ?? {}) })
151
+ emit('status.changed', { status: waitingStatus() })
152
+ resolve(answer)
153
+ }
154
+ })
155
+ emit('question.requested', { questionId, ...payload, ...(batch ?? {}) })
156
+ if (statusEvent) emit('status.changed', { status: 'waiting_for_input' })
157
+ })
158
+ }
159
+
117
160
  const emit = (type: RunEventType, payload: unknown, native?: unknown): void => {
118
161
  if (finished) return
119
162
  const parsed = runEventSchema.safeParse({ id: randomUUID(), runId, sessionId, sequence, timestamp: new Date().toISOString(), type, payload, ...(native === undefined ? {} : { native }) })
@@ -130,6 +173,13 @@ export class RunRegistry {
130
173
  sessionId,
131
174
  emit,
132
175
  onInterrupt: (handler) => void interruptHandlers.push(handler),
176
+ onSteer: (handler) => {
177
+ steerHandler = handler
178
+ },
179
+ steer: async (text) => {
180
+ if (finished || !steerHandler) throw new HarnessError('run_not_active', 'This run is not accepting messages.')
181
+ await steerHandler(text)
182
+ },
133
183
  requestApproval: async (input) => {
134
184
  const { requestId, standing = 'full', timeoutMs, ...payload } = input
135
185
  const approvalId = requestId ?? randomUUID()
@@ -160,24 +210,20 @@ export class RunRegistry {
160
210
  })
161
211
  })
162
212
  },
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 })
213
+ requestAnswer: (input) => requestQuestion(input),
214
+ requestAnswers: (inputs) => {
215
+ if (!Array.isArray(inputs) || inputs.length < 1 || inputs.length > 5) {
216
+ throw new HarnessError('invalid_request', 'A question batch must contain between one and five questions.')
217
+ }
218
+ const requestedIds = inputs.flatMap((input) => input.requestId ? [input.requestId] : [])
219
+ if (new Set(requestedIds).size !== requestedIds.length || requestedIds.some((id) => this.questions.has(id))) {
220
+ throw new HarnessError('invalid_request', 'Question ids in a batch must be unique and not already pending.')
221
+ }
222
+ if (finished) return Promise.resolve(inputs.map(() => ({ selected: [] })))
223
+ const batchId = randomUUID()
224
+ const answers = inputs.map((input, index) => requestQuestion(input, { batchId, batchIndex: index + 1, batchCount: inputs.length }, false))
168
225
  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
- })
226
+ return Promise.all(answers)
181
227
  },
182
228
  finish: (outcome, failure) => {
183
229
  if (finished) return
@@ -243,6 +289,13 @@ export class RunRegistry {
243
289
  return null
244
290
  }
245
291
 
292
+ /** Hands a message to a live run's model after its current step. Throws `run_not_active` when the run cannot take it. */
293
+ async steer(runId: string, text: string): Promise<void> {
294
+ const run = this.runs.get(runId)
295
+ if (!run) throw new HarnessError('run_not_active', 'This run is not active.')
296
+ await run.steer(text)
297
+ }
298
+
246
299
  async interrupt(runId: string): Promise<void> {
247
300
  const run = this.runs.get(runId)
248
301
  if (!run) throw new HarnessError('run_not_active', 'This run is not active.')
@@ -272,10 +325,44 @@ export class RunRegistry {
272
325
  answerQuestion(runId: string, questionId: string, answer: QuestionAnswer): void {
273
326
  const question = this.questions.get(questionId)
274
327
  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.')
328
+ if (question.batch) throw new HarnessError('question_batch_required', 'Answer every question in this batch together.')
329
+ question.resolve(this.validateAnswer(question.input, answer))
330
+ }
331
+
332
+ answerQuestions(runId: string, batchId: string, answers: Array<{ questionId: string; answer: QuestionAnswer }>): void {
333
+ const questions = [...this.questions.entries()].filter(([, pending]) => pending.runId === runId && pending.batch?.batchId === batchId)
334
+ if (!questions.length || answers.length !== questions.length || new Set(answers.map(({ questionId }) => questionId)).size !== answers.length) {
335
+ throw new HarnessError('question_not_pending', 'This question batch is not waiting for an answer.')
336
+ }
337
+ const pendingById = new Map(questions)
338
+ const validated = answers.map(({ questionId, answer }) => {
339
+ const pending = pendingById.get(questionId)
340
+ if (!pending) throw new HarnessError('question_not_pending', 'Every question in the batch must be answered together.')
341
+ return { pending, answer: this.validateAnswer(pending.input, answer) }
342
+ })
343
+ for (const { pending, answer } of validated) pending.resolve(answer)
344
+ }
345
+
346
+ private validateAnswer(input: QuestionInput, answer: QuestionAnswer): QuestionAnswer {
347
+ const selected = answer.selected
348
+ const selectedLabels = new Set(selected)
349
+ const notes: Record<string, string> = {}
350
+ for (const [label, note] of Object.entries(answer.notes ?? {})) {
351
+ const normalized = typeof note === 'string' ? note.trim() : ''
352
+ if (normalized) notes[label] = normalized
353
+ }
354
+ const text = answer.text?.trim()
355
+ if (
356
+ (!input.multiSelect && selected.length > 1) ||
357
+ selectedLabels.size !== selected.length ||
358
+ selected.some((label) => !input.options.some((option) => option.label === label)) ||
359
+ (text && !input.allowText) ||
360
+ Object.keys(notes).some((label) => !selectedLabels.has(label)) ||
361
+ Object.values(notes).some((note) => note.length > 10_000)
362
+ ) {
363
+ throw new HarnessError('invalid_answer', 'Choose the offered options or an allowed text answer, and attach notes only to selected choices.')
277
364
  }
278
- question.resolve(answer)
365
+ return { selected, ...(text ? { text } : {}), ...(Object.keys(notes).length ? { notes } : {}) }
279
366
  }
280
367
 
281
368
  activeRunIds(): string[] {