@oar-sh/oar 0.9.2 → 0.9.3

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 (94) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +407 -424
  3. package/bin/oar.js +25 -10
  4. package/package.json +7 -4
  5. package/server/README.md +1 -1
  6. package/server/claude-worker/claude-session-process.mjs +981 -44
  7. package/server/claude-worker/claude-session-worker.mjs +45 -4
  8. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  9. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  10. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  11. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  12. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +101 -12
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +2269 -492
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  16. package/server/db-schema.mjs +20 -0
  17. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  18. package/server/public/app/api-client.js +22 -2
  19. package/server/public/app/ask-user-view.js +102 -13
  20. package/server/public/app/attachments-view.js +37 -2
  21. package/server/public/app/background-task-timeout-options.mjs +9 -0
  22. package/server/public/app/background-tasks-view.mjs +33 -18
  23. package/server/public/app/bootstrap.js +34 -8
  24. package/server/public/app/composer-control-state.mjs +52 -34
  25. package/server/public/app/conversation-draft-sync.mjs +187 -0
  26. package/server/public/app/conversation-list-filter.mjs +86 -0
  27. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  28. package/server/public/app/conversation-view.js +919 -194
  29. package/server/public/app/emoji-view.js +2 -0
  30. package/server/public/app/font-scaling.js +4 -37
  31. package/server/public/app/journal-view.js +145 -5
  32. package/server/public/app/model-selector-options.mjs +1 -1
  33. package/server/public/app/question-multi-select.mjs +52 -0
  34. package/server/public/app/relay-prompt-sanitizer.mjs +17 -2
  35. package/server/public/app/settings-modal.js +8 -6
  36. package/server/public/app/socket-handlers.js +19 -0
  37. package/server/public/app/steered-turn-merge.mjs +100 -0
  38. package/server/public/app/store.js +126 -15
  39. package/server/public/app/sync-outbox.mjs +16 -8
  40. package/server/public/app/tool-activity-emoji.mjs +36 -0
  41. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  42. package/server/public/index.html +206 -9
  43. package/server/public/sw.js +7 -0
  44. package/server/relay-tools.md +2 -0
  45. package/server/repositories/message-repository.mjs +61 -18
  46. package/server/repositories/question-repository.mjs +15 -0
  47. package/server/repositories/session-repository.mjs +7 -31
  48. package/server/routes/messages-routes.mjs +488 -46
  49. package/server/routes/sessions-routes.mjs +298 -55
  50. package/server/server-runtime.mjs +65 -25
  51. package/server/services/live-turn-picker.mjs +47 -0
  52. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  53. package/server/services/relay-question-context.mjs +46 -0
  54. package/server/services/sdk-session-import-service.mjs +49 -7
  55. package/server/services/session-worker-registry-service.mjs +75 -0
  56. package/server/services/session-worker-supervisor-service.mjs +7 -1
  57. package/server/services/session-worker-websocket-service.mjs +23 -1
  58. package/server/services/steer-resend.mjs +45 -0
  59. package/shared/background-task-timeout.mjs +12 -4
  60. package/shared/control-poller.mjs +4 -1
  61. package/shared/question-multi-select.mjs +16 -0
  62. package/shared/relay-stop-failure.mjs +15 -0
  63. package/shared/slash-command-guard.mjs +76 -0
  64. package/shared/steer-note.mjs +66 -0
  65. package/shared/steer-settle-failure.mjs +89 -0
  66. package/shared/steer-settle-markers.mjs +18 -0
  67. package/shared/worker-crash-guard.mjs +40 -25
  68. package/shared/worker-runtime/heartbeat.mjs +55 -1
  69. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  70. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  71. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  72. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  73. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  74. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  75. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  76. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  77. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  78. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  79. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  80. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  81. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  82. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  83. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  84. package/server/routes/messages-routes-test-harness.mjs +0 -111
  85. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  86. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  87. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  88. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  89. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  90. package/server/services/fixtures/cli-install-stub.sh +0 -4
  91. package/server/services/fixtures/grok-stub.cmd +0 -1
  92. package/server/services/fixtures/grok-stub.mjs +0 -89
  93. package/server/services/fixtures/grok-stub.sh +0 -4
  94. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -18,6 +18,7 @@ import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-win
18
18
  import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
19
19
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
20
20
  import { createClaudeSessionRunner } from './claude-session-process.mjs';
21
+ import { createRunnerLinkBridge, failSettlingRowsOnShutdown } from './claude-worker-link-wiring.mjs';
21
22
 
22
23
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
24
  const HEARTBEAT_MS = 10_000;
@@ -82,6 +83,9 @@ async function main() {
82
83
  onAbortSubagent: (subagentRunId) => turnRunner.stopBackgroundTaskByToolUseId(subagentRunId),
83
84
  dbg,
84
85
  });
86
+ const linkBridge = createRunnerLinkBridge();
87
+ // Late-bound: the heartbeat controller is built after the runner.
88
+ let heartbeat = null;
85
89
  const turnRunner = createClaudeSessionRunner({
86
90
  api,
87
91
  sdkSessionId,
@@ -102,11 +106,19 @@ async function main() {
102
106
  getBackgroundTaskTimeoutMs: () => backgroundTaskTimeoutMs,
103
107
  getAutoCompactWindow: () => autoCompactWindow,
104
108
  getThinking: () => thinking,
109
+ // A readiness flip is a hold starting or ending (question card,
110
+ // compaction, a turn-opening delivery): the steering snapshot changed
111
+ // with it, so it goes out now rather than on the next 10 s heartbeat.
112
+ onDeliveryReadinessChange: (ready) => {
113
+ linkBridge.onDeliveryReadinessChange(ready);
114
+ heartbeat?.requestPulse();
115
+ },
116
+ canHandBackHeldDelivery: () => linkBridge.canHandBackHeldDelivery(),
105
117
  dbg,
106
118
  });
107
119
 
108
120
  let heartbeatTimer = null;
109
- const heartbeat = createHeartbeatController({
121
+ heartbeat = createHeartbeatController({
110
122
  api,
111
123
  pollMs: HEARTBEAT_MS,
112
124
  getSessionReady: () => true,
@@ -118,6 +130,13 @@ async function main() {
118
130
  // the crash guard below takes the entries whole so its requeues stay
119
131
  // fenced to this attempt.
120
132
  getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds().map((entry) => entry.id),
133
+ // Composer steering snapshot. Holds reach the relay within a pulse window
134
+ // (onDeliveryReadinessChange above) and a relay that just came up gets it
135
+ // on its first hello; the periodic beat only backstops the rest.
136
+ getSteeringState: () => turnRunner.steeringState(),
137
+ // Consumed rows the runner could not fail itself; the relay fails them.
138
+ getSettleFailed: () => turnRunner.getSettleFailed(),
139
+ onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
121
140
  });
122
141
 
123
142
  const wsLink = createWorkerWebSocketLink({
@@ -125,6 +144,18 @@ async function main() {
125
144
  token,
126
145
  dbg,
127
146
  getSessionReady: () => true,
147
+ // Mid-turn steering: while a turn is running and the runner can absorb
148
+ // another message into it, the link keeps signalling readiness, so a
149
+ // message sent while Claude is thinking is pushed into the live turn
150
+ // instead of waiting for the result (docs/plans/2026-09-19-claude-mid-
151
+ // turn-steering.md).
152
+ getSteeringReady: () => turnRunner.canAcceptSteering(),
153
+ // A hold (open question card, compaction, adoption) suppresses readiness
154
+ // even between deliveries — a delivery drawn into it is only handed back.
155
+ getDeliveryHeld: () => turnRunner.isDeliveryHeld(),
156
+ // A relay that just (re)started knows nothing of this worker until it
157
+ // heartbeats; the composer's steering snapshot rides that beat.
158
+ onServerHello: () => heartbeat.requestPulse(),
128
159
  getSessionId: () => sdkSessionId,
129
160
  getPid: () => process.pid,
130
161
  onDeliver: async (pending, reason) => {
@@ -158,20 +189,30 @@ async function main() {
158
189
  dbg('unknown worker control ignored', type || '(none)');
159
190
  },
160
191
  });
192
+ linkBridge.attach(wsLink);
161
193
 
162
- const shutdown = (signal) => {
194
+ let shuttingDown = false;
195
+ const shutdown = async (signal) => {
196
+ if (shuttingDown) return;
197
+ shuttingDown = true;
163
198
  dbg(`shutting down (${signal})`);
164
199
  try { wsLink.stop(); } catch {}
165
200
  try { heartbeat.stopHeartbeat(); } catch {}
166
201
  try { controlPoller.stop(); } catch {}
202
+ // Consumed steers still settling must fail, not be left for dead-worker
203
+ // recovery to requeue and run twice (same 2 s cap as the crash guard).
204
+ try {
205
+ const failed = await failSettlingRowsOnShutdown({ api, runner: turnRunner, timeoutMs: 2_000 });
206
+ if (failed) dbg(`failed ${failed} settling row(s) before exit`);
207
+ } catch {}
167
208
  // Hard-close the persistent CLI process; a worker teardown cannot save
168
209
  // its background tasks, and the orphan-notification replay on the next
169
210
  // resume reports whatever they left behind.
170
211
  try { turnRunner.shutdown({ graceful: false }); } catch {}
171
212
  process.exit(0);
172
213
  };
173
- process.on('SIGTERM', () => shutdown('SIGTERM'));
174
- process.on('SIGINT', () => shutdown('SIGINT'));
214
+ process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
215
+ process.on('SIGINT', () => { void shutdown('SIGINT'); });
175
216
  installWorkerCrashGuard({
176
217
  api,
177
218
  workerName: 'claude-session-worker',
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
3
3
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
4
+ import { buildSteerSettleFailure, steerSettleFailureText } from '../../shared/steer-settle-failure.mjs';
4
5
 
5
6
  const PLAN_BOARD_ACTIONS = [
6
7
  { id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
@@ -25,6 +26,9 @@ export function isStaleAttemptError(error) {
25
26
  return detail.includes('stale_attempt');
26
27
  }
27
28
 
29
+ export { buildSteerSettleFailure, steerSettleFailureText };
30
+ export const STEER_SETTLE_FAILED_TEXT = steerSettleFailureText('folded');
31
+
28
32
  export function buildClaudePlanReadyBoardPayload({ message, planText = '' } = {}) {
29
33
  const summary = String(planText || '').trim();
30
34
  if (!summary) return null;
@@ -55,6 +59,11 @@ export function buildClaudePlanReadyBoardPayload({ message, planText = '' } = {}
55
59
  * (delivered or background-continuation) through one publisher.
56
60
  */
57
61
  export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRuns = null } = {}) {
62
+ function drainWorkflowRuns({ terminal = false } = {}) {
63
+ if (terminal || typeof takeWorkflowRuns !== 'function') return null;
64
+ return takeWorkflowRuns();
65
+ }
66
+
58
67
  async function postActivity(message, text, subagentRunId = null, metadata = null) {
59
68
  if (!text) return;
60
69
  await api('POST', '/api/activity', {
@@ -180,36 +189,86 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
180
189
  }).catch(() => {});
181
190
  }
182
191
 
183
- async function publishResponse(message, { text, model, terminalError = null, modelOrigin }) {
192
+ /**
193
+ * Returns the outcome: 'published' (the relay took it), 'stale' (another
194
+ * attempt owns the row), 'failed' (not taken and NOT requeued), or
195
+ * 'requeued'. A `kind` marks a row whose prompt the CLI already consumed —
196
+ * 'absorbed' (the reply continues through the next, steered message),
197
+ * 'folded' (answered by the turn it was steered into) or 'stopped' (steered
198
+ * into a turn the user stopped) — and such a row is never
199
+ * requeued, since re-delivery would run the prompt a second time; the
200
+ * caller owns retrying it. `requeueOnFailure: false` opts any other publish
201
+ * into the same contract.
202
+ */
203
+ async function publishResponse(message, {
204
+ text,
205
+ model,
206
+ terminalError = null,
207
+ modelOrigin,
208
+ absorbed = false,
209
+ kind = null,
210
+ requeueOnFailure = true,
211
+ // Ids of steered queue rows pushed into the turn this response finishes
212
+ // and still unsettled: the relay marks them consumed in the same
213
+ // transaction, so no recovery path ever re-runs them (at-most-once even
214
+ // if this worker dies before settling them).
215
+ consumedSteerIds = null,
216
+ // A caller retrying one publish drains the settled workflow digests ONCE
217
+ // and passes them to every attempt, so a failed attempt cannot lose them.
218
+ workflowRuns: presetWorkflowRuns,
219
+ }) {
220
+ const responseKind = kind || (absorbed ? 'absorbed' : null);
184
221
  // Final digests of workflows that settled since the last response ride the
185
222
  // next successful response publish — the summarizing turn is the natural
186
223
  // transcript anchor for the "Finished background task" card. Terminal
187
224
  // failures skip the drain so the digests stay buffered for a later turn;
188
225
  // if this POST fails (→ requeue) or the process dies before any response
189
226
  // publishes, the drained digests are lost — acceptable v1.
190
- const workflowRuns = (!terminalError && typeof takeWorkflowRuns === 'function')
191
- ? takeWorkflowRuns()
192
- : null;
193
- await api('POST', '/api/response', {
194
- messageId: message.id,
195
- conversationId: message.conversationId,
196
- text: String(text || ''),
197
- model: model || null,
198
- modelOrigin: modelOrigin
199
- || (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
200
- ...(Array.isArray(workflowRuns) && workflowRuns.length ? { workflowRuns } : {}),
201
- ...(terminalError ? { terminalError } : {}),
202
- ...attemptFields(message),
203
- }).catch(async (error) => {
227
+ const workflowRuns = presetWorkflowRuns !== undefined
228
+ ? presetWorkflowRuns
229
+ : drainWorkflowRuns({ terminal: Boolean(terminalError) });
230
+ // Entries are { id, attemptId } (the relay fences each mark on its
231
+ // attempt) or bare ids.
232
+ const consumed = (Array.isArray(consumedSteerIds) ? consumedSteerIds : [])
233
+ .map((entry) => (entry && typeof entry === 'object'
234
+ ? { id: String(entry.id || '').trim(), attemptId: entry.attemptId || null }
235
+ : { id: String(entry || '').trim(), attemptId: null }))
236
+ .filter((entry) => entry.id);
237
+ try {
238
+ await api('POST', '/api/response', {
239
+ messageId: message.id,
240
+ conversationId: message.conversationId,
241
+ text: String(text || ''),
242
+ model: model || null,
243
+ modelOrigin: modelOrigin
244
+ || (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
245
+ ...(Array.isArray(workflowRuns) && workflowRuns.length ? { workflowRuns } : {}),
246
+ ...(terminalError ? { terminalError } : {}),
247
+ // The relay stamps the assistant message's kind from these: 'absorbed'
248
+ // renders as merged into the next (steered) user message, 'folded' as
249
+ // a compact marker on its own message, 'stopped' as a steer the
250
+ // user's Stop cut off unanswered.
251
+ ...(responseKind === 'absorbed' ? { absorbed: true } : {}),
252
+ ...(responseKind && responseKind !== 'absorbed' ? { kind: responseKind } : {}),
253
+ ...(consumed.length ? { consumedSteerIds: consumed } : {}),
254
+ ...attemptFields(message),
255
+ });
256
+ return 'published';
257
+ } catch (error) {
204
258
  // A stale_attempt 409 means the row already moved on to another attempt
205
259
  // (requeued and re-delivered); the requeue would 409 the same way, and
206
260
  // the newer attempt owes the row its answer — drop this one.
207
261
  if (isStaleAttemptError(error)) {
208
262
  dbg('response refused as stale_attempt; dropping', message.id);
209
- return;
263
+ return 'stale';
264
+ }
265
+ if (responseKind || !requeueOnFailure) {
266
+ dbg('response publish failed; not requeuing a consumed prompt', message.id, error?.message || String(error));
267
+ return 'failed';
210
268
  }
211
269
  await api('POST', '/api/requeue', { messageId: message.id, ...attemptFields(message) }).catch(() => {});
212
- });
270
+ return 'requeued';
271
+ }
213
272
  }
214
273
 
215
274
  /**
@@ -244,7 +303,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
244
303
  * plan-mode fallback board, response row. Mirrors the pre-session-process
245
304
  * turn runner's tail exactly.
246
305
  */
247
- async function publishCompletedTurn({ message, state, responseModel, planBoardPosted }) {
306
+ async function publishCompletedTurn({ message, state, responseModel, planBoardPosted, consumedSteerIds = null }) {
248
307
  const result = state.result;
249
308
  const finalText = String(
250
309
  state.resultTexts.join('\n\n') || result?.text || state.lastStreamedText || '',
@@ -266,10 +325,10 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
266
325
  // fails the message with a misleading "Relay timeout".
267
326
  const publishedText = finalText || EMPTY_TURN_COMPLETION_NOTE;
268
327
  await publishFinalStream(message, publishedText);
269
- await publishResponse(message, { text: publishedText, model: responseModel });
328
+ await publishResponse(message, { text: publishedText, model: responseModel, consumedSteerIds });
270
329
  }
271
330
 
272
- async function publishErrorResult({ message, state, responseModel }) {
331
+ async function publishErrorResult({ message, state, responseModel, consumedSteerIds = null }) {
273
332
  const result = state.result;
274
333
  const errorText = result.text
275
334
  || `Claude turn failed (${result.subtype || 'unknown error'}).`;
@@ -285,6 +344,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
285
344
  failedAt: new Date().toISOString(),
286
345
  queueMessageId: String(message.id || '') || null,
287
346
  },
347
+ consumedSteerIds,
288
348
  });
289
349
  }
290
350
 
@@ -308,6 +368,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
308
368
 
309
369
  return {
310
370
  postActivity,
371
+ drainWorkflowRuns,
311
372
  dispatchAction,
312
373
  publishContextUsage,
313
374
  publishPlanUsage,
@@ -0,0 +1,9 @@
1
+ // The Claude worker's glue between its session runner and the relay socket
2
+ // link. The implementation moved to shared/worker-runtime/runner-link-wiring.mjs
3
+ // when the Copilot SDK worker adopted the same delivery gate; this module is
4
+ // the import path the Claude worker and its tests still use.
5
+
6
+ export {
7
+ createRunnerLinkBridge,
8
+ failSettlingRowsOnShutdown,
9
+ } from '../../shared/worker-runtime/runner-link-wiring.mjs';
@@ -0,0 +1,89 @@
1
+ // The relay's own `ask_user` tool for Copilot SDK sessions.
2
+ //
3
+ // The runtime's built-in `ask_user` offers the model only `question` and
4
+ // `choices` (captured from the request body, runtime 1.0.88), so a model has
5
+ // no way to say "the user may pick several of these" — and the relay could
6
+ // only guess from the wording. The SDK lets a host register a tool that
7
+ // overrides a built-in one of the same name (`overridesBuiltInTool`), so the
8
+ // worker registers this superset instead: the same name and fields every
9
+ // model already knows, plus `multi_select`. Verified with the fake-provider
10
+ // probe (2026-09-25): the model sees exactly one `ask_user` with this schema,
11
+ // the handler receives the call, and the built-in path is never used.
12
+ //
13
+ // Pure: schema, argument parsing and the result wording. The session process
14
+ // wires the handler to the relay question bridge.
15
+
16
+ import { normalizeUserInputChoices } from './copilot-question-bridge.mjs';
17
+
18
+ export const RELAY_ASK_USER_TOOL_NAME = 'ask_user';
19
+
20
+ export const RELAY_ASK_USER_TOOL_DESCRIPTION = [
21
+ 'Ask the user a question and wait for their response.',
22
+ 'Use this tool when you need to ask the user questions during execution. This allows you to:',
23
+ '1. Gather user preferences or requirements',
24
+ '2. Clarify ambiguous instructions',
25
+ '3. Get decisions on implementation choices as you work',
26
+ '4. Offer choices to the user about what direction to take',
27
+ 'The question is shown to the user as a card with a button per choice.',
28
+ 'Set multi_select to true when the user may pick several of the choices (the choices are not',
29
+ 'mutually exclusive); the answer then lists every pick, separated by ", ". Leave it false or',
30
+ 'omit it when exactly one choice applies.',
31
+ ].join('\n');
32
+
33
+ export const RELAY_ASK_USER_TOOL_PARAMETERS = Object.freeze({
34
+ type: 'object',
35
+ properties: {
36
+ question: {
37
+ type: 'string',
38
+ description: 'The question to ask the user. Ensure only one question is asked at a time - do not bundle multiple questions together.',
39
+ },
40
+ choices: {
41
+ type: 'array',
42
+ items: { type: 'string' },
43
+ description: 'Optional list of choices for a multiple choice question. Prefer providing choices when possible.',
44
+ },
45
+ multi_select: {
46
+ type: 'boolean',
47
+ description: 'true when the user may pick several of the choices; false or omitted when exactly one applies.',
48
+ },
49
+ },
50
+ required: ['question'],
51
+ });
52
+
53
+ /** The call's arguments, tolerating the camelCase spelling some models emit. */
54
+ export function parseAskUserToolArguments(args) {
55
+ const source = args && typeof args === 'object' && !Array.isArray(args) ? args : {};
56
+ return {
57
+ question: String(source.question || '').trim(),
58
+ choices: normalizeUserInputChoices(source.choices),
59
+ multiSelect: source.multi_select === true || source.multiSelect === true,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * The tool result the model reads, worded like the built-in's ("User
65
+ * selected: b" for a choice, "User responded: …" for anything else) so a
66
+ * model's habits carry over. A timed-out card returns the bridge's
67
+ * continuation text as it is.
68
+ */
69
+ export function formatAskUserToolResult({ answer, wasFreeform = true, timedOut = false } = {}) {
70
+ const text = String(answer ?? '').trim();
71
+ if (timedOut) return text;
72
+ if (!text) return 'User responded with an empty answer.';
73
+ // `wasFreeform` comes from the question bridge (`deriveWasFreeform`), the
74
+ // single owner of "was this one of the offered choices".
75
+ return wasFreeform === false ? `User selected: ${text}` : `User responded: ${text}`;
76
+ }
77
+
78
+ /**
79
+ * Whether a session create/resume failure is the runtime refusing the
80
+ * override (an older runtime without `overridesBuiltInTool`, or one that
81
+ * rejects replacing this built-in). Anything else is a real failure.
82
+ */
83
+ export function isToolOverrideRejection(error) {
84
+ const message = String(error?.message || error || '');
85
+ if (/overridesBuiltInTool/i.test(message)) return true;
86
+ // The tool named together with an override/clash complaint — a message that
87
+ // merely mentions ask_user (a replayed call in history, say) is not one.
88
+ return /\bask_user\b/i.test(message) && /(built-?in|clash|conflict|overrid|duplicate)/i.test(message);
89
+ }
@@ -54,6 +54,51 @@ export const SHELL_SETTLED_NOTIFICATION_KINDS = Object.freeze([
54
54
  /** Tools that address an already-running shell by id. */
55
55
  export const SHELL_HANDLE_TOOLS = Object.freeze(['read_bash', 'write_bash', 'stop_bash']);
56
56
 
57
+ /**
58
+ * `system.notification` kinds that mean "a background agent this session
59
+ * started is done" — `agent_idle` for a multi-turn agent that finished its
60
+ * prompt and now waits for messages, `agent_completed` for a single-shot one.
61
+ * Both make the runtime re-invoke the model (`read_agent` + a reply under the
62
+ * spawning interaction), so both herald a continuation exactly like the shell
63
+ * kinds do (live-probed on runtime 1.0.88, 2026-09-25).
64
+ */
65
+ export const AGENT_SETTLED_NOTIFICATION_KINDS = Object.freeze([
66
+ 'agent_completed',
67
+ 'agent_idle',
68
+ ]);
69
+
70
+ /**
71
+ * The background agent a `system.notification` reports as settled, or null.
72
+ * `{ agentId, agentType, displayName, description, status }` — `status` is
73
+ * 'completed' | 'failed' for `agent_completed`, 'idle' for `agent_idle`.
74
+ */
75
+ export function settledAgentFromNotification(event) {
76
+ if (String(event?.type || '') !== 'system.notification') return null;
77
+ const data = eventData(event);
78
+ const kind = data.kind && typeof data.kind === 'object' ? data.kind : {};
79
+ const type = String(kind.type || '');
80
+ if (!AGENT_SETTLED_NOTIFICATION_KINDS.includes(type)) return null;
81
+ const agentId = String(kind.agentId || '').trim();
82
+ if (!agentId) return null;
83
+ return {
84
+ agentId,
85
+ agentType: String(kind.agentType || '').trim(),
86
+ displayName: String(kind.displayName || '').trim(),
87
+ description: String(kind.description || '').trim(),
88
+ status: type === 'agent_idle' ? 'idle' : (String(kind.status || '').trim() || 'completed'),
89
+ };
90
+ }
91
+
92
+ /** A one-line transcript note for a settled background agent, posted into its continuation. */
93
+ export function describeSettledAgent(agent) {
94
+ if (!agent?.agentId) return '';
95
+ const name = agent.displayName || agent.description || agent.agentType || 'agent';
96
+ const verb = agent.status === 'failed' ? 'failed' : 'finished';
97
+ return agent.description && agent.description !== name
98
+ ? `Background agent ${name} ${verb}: ${agent.description}`
99
+ : `Background agent ${name} ${verb}`;
100
+ }
101
+
57
102
  // `<command started in detached background with shellId: 1>`
58
103
  const SHELL_OPENED_RE = /detached\s+(?:background|command)[^<>]*?shellId:\s*([^\s>,]+)/i;
59
104
  // `<detached command with shellId: 1 completed with exit code 0>`
@@ -118,7 +163,17 @@ export const CONTINUATION_OPENING_EVENT_TYPES = Object.freeze([
118
163
 
119
164
  const OPENING_TYPES = new Set(CONTINUATION_OPENING_EVENT_TYPES);
120
165
 
166
+ /**
167
+ * Only ROOT-agent events open a turn. A background subagent keeps emitting
168
+ * `agentId`-tagged `tool.execution_start` / `assistant.message` / its own
169
+ * `user.message` after the turn that spawned it has settled (live-probed on
170
+ * runtime 1.0.88: the subagent's whole life happens under the main loop's
171
+ * idle), and none of that is the main agent starting work. The root agent's
172
+ * follow-up when the subagent finishes (`read_agent` + a reply) carries no
173
+ * `agentId` and opens the continuation as before.
174
+ */
121
175
  export function isContinuationOpeningEvent(event) {
176
+ if (String(event?.agentId || '').trim()) return false;
122
177
  return OPENING_TYPES.has(String(event?.type || ''));
123
178
  }
124
179
 
@@ -23,6 +23,7 @@
23
23
  // question array. Composing the waiter instead of copying it keeps one
24
24
  // implementation of the abort semantics.
25
25
  import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
26
+ import { looksLikeMultiSelectQuestion } from '../../shared/question-multi-select.mjs';
26
27
  import {
27
28
  DEFAULT_QUESTION_TIMEOUT_MS,
28
29
  QUESTION_TIMEOUT_CONTINUATION_TEXT,
@@ -103,8 +104,12 @@ export function createCopilotQuestionBridge({
103
104
  await api('POST', `/api/relay-question/${questionId}/timeout`, {}).catch(() => {});
104
105
  }
105
106
 
106
- async function createQuestion({ prompt, choices, allowFreeform, source, rationale, requestedSchema, timeoutMs, extra = {} }) {
107
- const activeMsg = typeof getActiveMessage === 'function' ? getActiveMessage() : null;
107
+ async function createQuestion({ prompt, choices, allowFreeform, source, rationale, requestedSchema, timeoutMs, extra = {} }, owner) {
108
+ // A caller that knows which row the card belongs to names it (`message`);
109
+ // by the time the card is created the "active" row can be another one.
110
+ const activeMsg = owner !== undefined
111
+ ? owner
112
+ : (typeof getActiveMessage === 'function' ? getActiveMessage() : null);
108
113
  const payload = {
109
114
  // The relay 409s ("No active relay turn") unless this queue row is
110
115
  // `processing`, which is exactly the state a blocking handler runs in.
@@ -140,13 +145,13 @@ export function createCopilotQuestionBridge({
140
145
  return questionId;
141
146
  }
142
147
 
143
- async function ask(spec, { signal } = {}) {
148
+ async function ask(spec, { signal, message } = {}) {
144
149
  // Shutdown already began: nothing will ever poll an answer, so minting a
145
150
  // card would only strand it in the UI. Shaped like a timeout so the caller
146
151
  // degrades exactly as it does for an unanswered card.
147
152
  if (closing) return { answer: QUESTION_TIMEOUT_CONTINUATION_TEXT, timedOut: true };
148
153
  const create = (async () => {
149
- const questionId = await createQuestion(spec);
154
+ const questionId = await createQuestion(spec, message);
150
155
  if (closing) {
151
156
  // The create raced `cancelPendingQuestions` and lost: its card was
152
157
  // born after the shutdown snapshot, so it is expired here, at birth —
@@ -188,7 +193,7 @@ export function createCopilotQuestionBridge({
188
193
  * comes back with `structuredAnswer: null` — the caller declines in that
189
194
  * case, so a half-valid submission can never be forced into a form result.
190
195
  */
191
- async function askStructured({ prompt, requestedSchema, timeoutMs }, { signal } = {}) {
196
+ async function askStructured({ prompt, requestedSchema, timeoutMs }, { signal, message } = {}) {
192
197
  const result = await ask({
193
198
  prompt: String(prompt || '').trim() || 'Copilot needs structured input to continue this turn.',
194
199
  choices: [],
@@ -197,7 +202,7 @@ export function createCopilotQuestionBridge({
197
202
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
198
203
  source: 'onElicitationRequest',
199
204
  rationale: 'Copilot requested a structured form answer to continue this turn.',
200
- }, { signal });
205
+ }, { signal, message });
201
206
  const structuredAnswer = result?.structuredAnswer && typeof result.structuredAnswer === 'object'
202
207
  && !Array.isArray(result.structuredAnswer)
203
208
  ? result.structuredAnswer
@@ -217,20 +222,37 @@ export function createCopilotQuestionBridge({
217
222
  * silently. A relay that is unreachable therefore degrades to the same
218
223
  * in-band note phase 1 always returned, which lets the model continue.
219
224
  */
220
- async function askUserInput(request, { signal } = {}) {
225
+ async function askUserInput(request, { signal, message } = {}) {
221
226
  const question = String(request?.question || '').trim();
222
227
  const choices = normalizeUserInputChoices(request?.choices);
223
228
  // `allowFreeform` is only meaningful alongside choices; with none, the card
224
229
  // must accept free text or it cannot be answered.
225
230
  const allowFreeform = choices.length ? request?.allowFreeform !== false : true;
231
+ // Multi-select, in order of trust: the model's own flag (the relay's
232
+ // `ask_user` tool has `multi_select`; the runtime's built-in one has no
233
+ // such field), then the wording ("select all that apply"). Either way
234
+ // every Copilot choice card also offers the "Select several" switch
235
+ // (`allowMultiSelect`), because a model can forget to say so. The answer
236
+ // comes back as the labels joined with ", ", which the model reads as a
237
+ // freeform reply.
238
+ // A request that demands exactly one of the offered choices
239
+ // (`allowFreeform: false`) gets neither: a joined answer would be outside
240
+ // the set the runtime asked for.
241
+ const canPickSeveral = choices.length >= 2 && allowFreeform;
242
+ const multiSelect = canPickSeveral
243
+ && (request?.multiSelect === true || looksLikeMultiSelectQuestion(question, choices));
226
244
  const result = await ask({
227
245
  prompt: question || 'Copilot asked for input to continue this turn.',
228
246
  choices,
229
247
  allowFreeform,
230
- source: 'onUserInputRequest',
248
+ source: String(request?.source || '').trim() || 'onUserInputRequest',
231
249
  rationale: 'Copilot requested clarification to continue this turn.',
232
- extra: { requestId: String(request?.requestId || '') || undefined },
233
- }, { signal });
250
+ extra: {
251
+ requestId: String(request?.requestId || '') || undefined,
252
+ ...(multiSelect ? { multiSelect: true } : {}),
253
+ ...(canPickSeveral ? { allowMultiSelect: true } : {}),
254
+ },
255
+ }, { signal, message });
234
256
  const answer = String(result?.answer ?? '');
235
257
  return {
236
258
  answer,
@@ -246,7 +268,7 @@ export function createCopilotQuestionBridge({
246
268
  * so a human can type *why* they are refusing and the model receives it as
247
269
  * feedback instead of a bare refusal.
248
270
  */
249
- async function askToolApproval(request, { signal } = {}) {
271
+ async function askToolApproval(request, { signal, message } = {}) {
250
272
  const description = describePermissionRequest(request);
251
273
  const result = await ask({
252
274
  prompt: `Copilot wants to run:\n\n${description}\n\nApprove this action?`,
@@ -258,7 +280,7 @@ export function createCopilotQuestionBridge({
258
280
  requestId: String(request?.requestId || '') || undefined,
259
281
  permissionKind: String(request?.kind || '') || undefined,
260
282
  },
261
- }, { signal });
283
+ }, { signal, message });
262
284
  const answer = String(result?.answer ?? '').trim();
263
285
  const approved = !result?.timedOut && answer.toLowerCase() === PERMISSION_APPROVE_CHOICE.toLowerCase();
264
286
  // The denial note is composed HERE rather than in the permission handler so