@oar-sh/oar 0.9.1 → 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 (146) hide show
  1. package/.github/extensions/web-relay/extension.mjs +0 -3
  2. package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
  3. package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
  4. package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
  5. package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
  6. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
  7. package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
  8. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
  9. package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
  10. package/CHANGELOG.md +269 -0
  11. package/README.md +407 -424
  12. package/bin/oar.js +142 -11
  13. package/package.json +8 -4
  14. package/server/README.md +59 -22
  15. package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
  16. package/server/claude-worker/claude-session-process.mjs +1013 -45
  17. package/server/claude-worker/claude-session-worker.mjs +54 -9
  18. package/server/claude-worker/claude-turn-publisher.mjs +110 -19
  19. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  20. package/server/copilot-sdk-runtime.mjs +12 -3
  21. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  22. package/server/copilot-worker/copilot-attachments.mjs +35 -2
  23. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  24. package/server/copilot-worker/copilot-model-switch.mjs +425 -0
  25. package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
  26. package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
  27. package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
  28. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
  29. package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
  30. package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
  31. package/server/cursor-worker/cursor-session-worker.mjs +5 -5
  32. package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
  33. package/server/db-schema.mjs +61 -1
  34. package/server/features.mjs +155 -41
  35. package/server/grok-worker/grok-session-worker.mjs +5 -5
  36. package/server/grok-worker/grok-turn-runner.mjs +45 -4
  37. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  38. package/server/public/app/annotate-core.mjs +181 -0
  39. package/server/public/app/annotate-editor.js +617 -0
  40. package/server/public/app/api-client.js +86 -5
  41. package/server/public/app/ask-user-view.js +102 -13
  42. package/server/public/app/attachments-view.js +172 -3
  43. package/server/public/app/background-task-timeout-options.mjs +9 -0
  44. package/server/public/app/background-tasks-view.mjs +34 -19
  45. package/server/public/app/bootstrap.js +153 -69
  46. package/server/public/app/composer-control-state.mjs +52 -34
  47. package/server/public/app/context-tier-options.mjs +24 -4
  48. package/server/public/app/conversation-draft-sync.mjs +187 -0
  49. package/server/public/app/conversation-list-filter.mjs +86 -0
  50. package/server/public/app/conversation-preferences.mjs +47 -10
  51. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  52. package/server/public/app/conversation-view.js +1032 -218
  53. package/server/public/app/emoji-view.js +2 -0
  54. package/server/public/app/font-scaling.js +4 -37
  55. package/server/public/app/journal-view.js +190 -24
  56. package/server/public/app/model-selector-options.mjs +185 -1
  57. package/server/public/app/new-conversation-model-choice.mjs +29 -9
  58. package/server/public/app/pwa-install.js +64 -136
  59. package/server/public/app/question-multi-select.mjs +52 -0
  60. package/server/public/app/reasoning-effort-labels.mjs +13 -3
  61. package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
  62. package/server/public/app/router.js +120 -11
  63. package/server/public/app/settings-modal.js +225 -20
  64. package/server/public/app/settings-tabs.js +1 -1
  65. package/server/public/app/socket-handlers.js +47 -4
  66. package/server/public/app/steered-turn-merge.mjs +100 -0
  67. package/server/public/app/store.js +159 -16
  68. package/server/public/app/sync-outbox.mjs +16 -8
  69. package/server/public/app/tool-activity-emoji.mjs +36 -0
  70. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  71. package/server/public/app/update-ui.js +194 -0
  72. package/server/public/index.html +483 -24
  73. package/server/public/sw.js +7 -0
  74. package/server/relay-tools.md +2 -0
  75. package/server/relay.mjs +23 -21
  76. package/server/repositories/message-repository.mjs +78 -21
  77. package/server/repositories/question-repository.mjs +19 -1
  78. package/server/repositories/session-repository.mjs +7 -31
  79. package/server/routes/ask-user-routes.mjs +10 -0
  80. package/server/routes/messages-routes.mjs +689 -159
  81. package/server/routes/sessions-routes.mjs +574 -80
  82. package/server/server-runtime.mjs +393 -925
  83. package/server/services/claude-auth-service.mjs +7 -1
  84. package/server/services/cli-install-service.mjs +8 -27
  85. package/server/services/cli-process-runner.mjs +30 -0
  86. package/server/services/copilot-model-discovery-service.mjs +202 -0
  87. package/server/services/data-dir-resolution.mjs +47 -0
  88. package/server/services/grok-auth-service.mjs +9 -1
  89. package/server/services/live-turn-picker.mjs +47 -0
  90. package/server/services/model-variant-catalog-service.mjs +1183 -0
  91. package/server/services/oar-cli-helpers.mjs +6 -2
  92. package/server/services/relay-prompt-sanitizer.mjs +30 -3
  93. package/server/services/relay-question-context.mjs +46 -0
  94. package/server/services/sdk-session-import-service.mjs +144 -16
  95. package/server/services/sdk-session-sync-service.mjs +84 -27
  96. package/server/services/session-worker-registry-service.mjs +116 -3
  97. package/server/services/session-worker-supervisor-service.mjs +7 -1
  98. package/server/services/session-worker-websocket-service.mjs +119 -24
  99. package/server/services/status-event-service.mjs +8 -2
  100. package/server/services/steer-resend.mjs +45 -0
  101. package/server/services/update-check-service.mjs +220 -0
  102. package/server/services/update-install-service.mjs +279 -0
  103. package/server/services/windows-autostart-service.mjs +42 -6
  104. package/server/services/windows-boot-autostart-service.mjs +487 -0
  105. package/shared/ask-user-bridge.mjs +37 -5
  106. package/shared/background-task-timeout.mjs +12 -4
  107. package/shared/claude-model-catalog.mjs +49 -0
  108. package/shared/context-window-fallbacks.mjs +1 -0
  109. package/shared/control-poller.mjs +4 -1
  110. package/shared/copilot-model-order.mjs +182 -0
  111. package/shared/media-embed-instructions.mjs +35 -0
  112. package/shared/model-descriptors.mjs +181 -30
  113. package/shared/model-id.mjs +6 -0
  114. package/shared/pwa-app-name.mjs +44 -0
  115. package/shared/question-multi-select.mjs +16 -0
  116. package/shared/relay-stop-failure.mjs +15 -0
  117. package/shared/slash-command-guard.mjs +76 -0
  118. package/shared/steer-note.mjs +66 -0
  119. package/shared/steer-settle-failure.mjs +89 -0
  120. package/shared/steer-settle-markers.mjs +18 -0
  121. package/shared/update-semver.mjs +50 -0
  122. package/shared/worker-crash-guard.mjs +40 -14
  123. package/shared/worker-runtime/api-client.mjs +34 -0
  124. package/shared/worker-runtime/config-loader.mjs +104 -0
  125. package/shared/worker-runtime/heartbeat.mjs +109 -0
  126. package/shared/worker-runtime/prompt-context.mjs +160 -0
  127. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  128. package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
  129. package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
  130. package/server/claude-worker/claude-session-test-harness.mjs +0 -236
  131. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  132. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
  133. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  134. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  135. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  136. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  137. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  138. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  139. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  140. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  141. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  142. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  143. package/server/routes/messages-routes-test-harness.mjs +0 -111
  144. package/server/services/fixtures/claude-auth-stub.sh +0 -62
  145. package/server/services/fixtures/cli-install-stub.sh +0 -75
  146. package/server/services/fixtures/grok-stub.sh +0 -86
@@ -1,10 +1,13 @@
1
- // Bridges the Copilot runtime's two blocking interactive surfaces onto the
1
+ // Bridges the Copilot runtime's blocking interactive surfaces onto the
2
2
  // relay's question cards:
3
3
  //
4
4
  // * `onUserInputRequest` — the model called `ask_user`. Phase 1 answered these
5
5
  // in-band with a "not supported" note; this asks the human.
6
6
  // * `onPermissionRequest` in **ask** mode — the model wants to run a mutating
7
7
  // tool. Phase 1 blanket-rejected; this offers approve/deny.
8
+ // * `onElicitationRequest` — a structured form (MCP elicitation). The card
9
+ // carries the `requestedSchema`, the relay validates the submission, and
10
+ // the answer comes back as `structuredAnswer` (`askStructured`).
8
11
  //
9
12
  // Both are wired to the SAME relay endpoints the extension's
10
13
  // `skills/question-bridge.mjs` uses, so a question raised by the SDK engine
@@ -20,6 +23,7 @@
20
23
  // question array. Composing the waiter instead of copying it keeps one
21
24
  // implementation of the abort semantics.
22
25
  import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
26
+ import { looksLikeMultiSelectQuestion } from '../../shared/question-multi-select.mjs';
23
27
  import {
24
28
  DEFAULT_QUESTION_TIMEOUT_MS,
25
29
  QUESTION_TIMEOUT_CONTINUATION_TEXT,
@@ -85,9 +89,27 @@ export function createCopilotQuestionBridge({
85
89
  // walks these so a pending card is timed out deliberately rather than left
86
90
  // spinning in the UI until the relay's 10s expiry sweeper notices.
87
91
  const pendingQuestionIds = new Set();
92
+ // Creates still in flight. Shutdown's snapshot of `pendingQuestionIds` only
93
+ // covers cards whose create already RETURNED — a POST in flight when
94
+ // `cancelPendingQuestions` runs would otherwise resolve just after the
95
+ // snapshot and leave its card pending forever. Each tracked promise also
96
+ // covers the expiry the create performs itself when it lands after closing
97
+ // started, so awaiting the set is awaiting the whole late-card teardown.
98
+ const inflightCreates = new Set();
99
+ // One-way: set by `cancelPendingQuestions`. From then on no new card is
100
+ // minted and any card a late create produces is expired at birth.
101
+ let closing = false;
88
102
 
89
- async function createQuestion({ prompt, choices, allowFreeform, source, rationale, extra = {} }) {
90
- const activeMsg = typeof getActiveMessage === 'function' ? getActiveMessage() : null;
103
+ async function expireQuestion(questionId) {
104
+ await api('POST', `/api/relay-question/${questionId}/timeout`, {}).catch(() => {});
105
+ }
106
+
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);
91
113
  const payload = {
92
114
  // The relay 409s ("No active relay turn") unless this queue row is
93
115
  // `processing`, which is exactly the state a blocking handler runs in.
@@ -98,8 +120,16 @@ export function createCopilotQuestionBridge({
98
120
  prompt,
99
121
  choices,
100
122
  allowFreeform,
123
+ // Top-level, as the create route reads it: the relay stores it as the
124
+ // card's `requestSchema` and validates the eventual `structuredAnswer`
125
+ // against it.
126
+ ...(requestedSchema ? { requestedSchema } : {}),
101
127
  sdk_session_id: sdkSessionId || undefined,
102
- timeout_ms: questionTimeoutMs,
128
+ // Fences the card to the delivering attempt: the server refuses creation
129
+ // once the row has been requeued to a newer attempt (same field the
130
+ // shared bridge sends).
131
+ attemptId: activeMsg?.attemptId || undefined,
132
+ timeout_ms: timeoutMs ?? questionTimeoutMs,
103
133
  context: {
104
134
  source,
105
135
  rationale,
@@ -115,17 +145,75 @@ export function createCopilotQuestionBridge({
115
145
  return questionId;
116
146
  }
117
147
 
118
- async function ask(spec, { signal } = {}) {
119
- const questionId = await createQuestion(spec);
120
- pendingQuestionIds.add(questionId);
148
+ async function ask(spec, { signal, message } = {}) {
149
+ // Shutdown already began: nothing will ever poll an answer, so minting a
150
+ // card would only strand it in the UI. Shaped like a timeout so the caller
151
+ // degrades exactly as it does for an unanswered card.
152
+ if (closing) return { answer: QUESTION_TIMEOUT_CONTINUATION_TEXT, timedOut: true };
153
+ const create = (async () => {
154
+ const questionId = await createQuestion(spec, message);
155
+ if (closing) {
156
+ // The create raced `cancelPendingQuestions` and lost: its card was
157
+ // born after the shutdown snapshot, so it is expired here, at birth —
158
+ // the one place that still knows its id.
159
+ dbg('relay question expired at birth (bridge closing)', questionId);
160
+ await expireQuestion(questionId);
161
+ return null;
162
+ }
163
+ pendingQuestionIds.add(questionId);
164
+ return questionId;
165
+ })();
166
+ inflightCreates.add(create);
167
+ let questionId;
168
+ try {
169
+ questionId = await create;
170
+ } finally {
171
+ inflightCreates.delete(create);
172
+ }
173
+ if (questionId === null) return { answer: QUESTION_TIMEOUT_CONTINUATION_TEXT, timedOut: true };
121
174
  dbg('relay question created', questionId, spec.source, spec.prompt.slice(0, 80));
122
175
  try {
123
- return await shared.waitForRelayQuestionAnswer(questionId, { signal });
176
+ return await shared.waitForRelayQuestionAnswer(questionId, {
177
+ signal,
178
+ ...(spec.timeoutMs === undefined ? {} : { timeoutMs: spec.timeoutMs }),
179
+ });
124
180
  } finally {
125
181
  pendingQuestionIds.delete(questionId);
126
182
  }
127
183
  }
128
184
 
185
+ /**
186
+ * A structured elicitation (audit #17) → a schema-carrying relay card →
187
+ * `{ structuredAnswer, answer, timedOut }`.
188
+ *
189
+ * The card is created with a top-level `requestedSchema`; the relay's answer
190
+ * route validates the submission against it and stores `structuredAnswer`,
191
+ * which the shared waiter reads back off the GET payload. A card that times
192
+ * out, is cancelled, or was answered without a validatable structured body
193
+ * comes back with `structuredAnswer: null` — the caller declines in that
194
+ * case, so a half-valid submission can never be forced into a form result.
195
+ */
196
+ async function askStructured({ prompt, requestedSchema, timeoutMs }, { signal, message } = {}) {
197
+ const result = await ask({
198
+ prompt: String(prompt || '').trim() || 'Copilot needs structured input to continue this turn.',
199
+ choices: [],
200
+ allowFreeform: true,
201
+ requestedSchema,
202
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
203
+ source: 'onElicitationRequest',
204
+ rationale: 'Copilot requested a structured form answer to continue this turn.',
205
+ }, { signal, message });
206
+ const structuredAnswer = result?.structuredAnswer && typeof result.structuredAnswer === 'object'
207
+ && !Array.isArray(result.structuredAnswer)
208
+ ? result.structuredAnswer
209
+ : null;
210
+ return {
211
+ structuredAnswer,
212
+ answer: String(result?.answer ?? ''),
213
+ timedOut: result?.timedOut === true,
214
+ };
215
+ }
216
+
129
217
  /**
130
218
  * `onUserInputRequest` → a relay question card → `{ answer, wasFreeform }`.
131
219
  *
@@ -134,20 +222,37 @@ export function createCopilotQuestionBridge({
134
222
  * silently. A relay that is unreachable therefore degrades to the same
135
223
  * in-band note phase 1 always returned, which lets the model continue.
136
224
  */
137
- async function askUserInput(request, { signal } = {}) {
225
+ async function askUserInput(request, { signal, message } = {}) {
138
226
  const question = String(request?.question || '').trim();
139
227
  const choices = normalizeUserInputChoices(request?.choices);
140
228
  // `allowFreeform` is only meaningful alongside choices; with none, the card
141
229
  // must accept free text or it cannot be answered.
142
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));
143
244
  const result = await ask({
144
245
  prompt: question || 'Copilot asked for input to continue this turn.',
145
246
  choices,
146
247
  allowFreeform,
147
- source: 'onUserInputRequest',
248
+ source: String(request?.source || '').trim() || 'onUserInputRequest',
148
249
  rationale: 'Copilot requested clarification to continue this turn.',
149
- extra: { requestId: String(request?.requestId || '') || undefined },
150
- }, { signal });
250
+ extra: {
251
+ requestId: String(request?.requestId || '') || undefined,
252
+ ...(multiSelect ? { multiSelect: true } : {}),
253
+ ...(canPickSeveral ? { allowMultiSelect: true } : {}),
254
+ },
255
+ }, { signal, message });
151
256
  const answer = String(result?.answer ?? '');
152
257
  return {
153
258
  answer,
@@ -163,7 +268,7 @@ export function createCopilotQuestionBridge({
163
268
  * so a human can type *why* they are refusing and the model receives it as
164
269
  * feedback instead of a bare refusal.
165
270
  */
166
- async function askToolApproval(request, { signal } = {}) {
271
+ async function askToolApproval(request, { signal, message } = {}) {
167
272
  const description = describePermissionRequest(request);
168
273
  const result = await ask({
169
274
  prompt: `Copilot wants to run:\n\n${description}\n\nApprove this action?`,
@@ -175,7 +280,7 @@ export function createCopilotQuestionBridge({
175
280
  requestId: String(request?.requestId || '') || undefined,
176
281
  permissionKind: String(request?.kind || '') || undefined,
177
282
  },
178
- }, { signal });
283
+ }, { signal, message });
179
284
  const answer = String(result?.answer ?? '').trim();
180
285
  const approved = !result?.timedOut && answer.toLowerCase() === PERMISSION_APPROVE_CHOICE.toLowerCase();
181
286
  // The denial note is composed HERE rather than in the permission handler so
@@ -194,18 +299,26 @@ export function createCopilotQuestionBridge({
194
299
  * Settle every card this worker is still waiting on. Called on shutdown: the
195
300
  * process is about to stop polling, so a card left `pending` would sit in the
196
301
  * UI inviting an answer that nothing will ever read.
302
+ *
303
+ * `closing` is set FIRST, then the in-flight creates are awaited, so the
304
+ * snapshot below cannot miss a card: a create that resolves after this line
305
+ * sees `closing` and expires its own card before its tracked promise
306
+ * settles. Returns only the count of cards timed out from the snapshot —
307
+ * late-born cards are torn down but were never pending.
197
308
  */
198
309
  async function cancelPendingQuestions() {
310
+ closing = true;
311
+ if (inflightCreates.size) await Promise.allSettled([...inflightCreates]);
199
312
  const ids = [...pendingQuestionIds];
200
313
  pendingQuestionIds.clear();
201
- await Promise.all(ids.map((questionId) => api('POST', `/api/relay-question/${questionId}/timeout`, {})
202
- .catch(() => {})));
314
+ await Promise.all(ids.map((questionId) => expireQuestion(questionId)));
203
315
  return ids.length;
204
316
  }
205
317
 
206
318
  return {
207
319
  askUserInput,
208
320
  askToolApproval,
321
+ askStructured,
209
322
  cancelPendingQuestions,
210
323
  pendingQuestionCount: () => pendingQuestionIds.size,
211
324
  // Re-exported so the runner can wait on a question id it created itself.
@@ -23,7 +23,12 @@ import {
23
23
  isStructuredQuotaError,
24
24
  normalizeTerminalSendAndWaitError,
25
25
  toKebabToken,
26
- } from '../../.github/extensions/web-relay/runtime/send-and-wait-errors.mjs';
26
+ } from '../../shared/worker-runtime/send-and-wait-errors.mjs';
27
+ import {
28
+ MODEL_SWITCH_UNCONFIRMED_CODE,
29
+ MODEL_SWITCH_UNCONFIRMED_STABLE_CODE,
30
+ isModelSwitchUnconfirmedError,
31
+ } from './copilot-model-switch.mjs';
27
32
  import { copilotRuntimeEntry } from '../copilot-sdk-runtime.mjs';
28
33
 
29
34
  const VERSION_DIR_RE = /^\d+\.\d+\.\d+$/;
@@ -344,7 +349,18 @@ export function copilotAgentModeForRelayMode(relayMode) {
344
349
  export function isCopilotQuotaError(data) {
345
350
  if (isStructuredQuotaError(data)) return true;
346
351
  const message = String(data?.message || '');
347
- return /quota/i.test(message) && /(exceed|exhaust)/i.test(message);
352
+ if (/quota/i.test(message) && /(exceed|exhaust)/i.test(message)) return true;
353
+ // GitHub's CURRENT wording ("You have reached your monthly quota …",
354
+ // "reached your usage limit/allowance") says "reached", not "exceeded" — and
355
+ // the SDK's `sendAndWait` rebuilds a plain Error from the session error's
356
+ // MESSAGE alone (session.js ~467-495), so a `session.error` can arrive with
357
+ // no structured field to save it from the prose branch. The possessive
358
+ // ("your") keeps this off impersonal transient limits ("context length
359
+ // limit reached"), and rate limits are excluded outright: they reset in
360
+ // seconds, quota resets with the billing window.
361
+ if (/rate.?limit/i.test(message)) return false;
362
+ return /\b(?:reached|hit|used up|exhausted)\b[^.!\n]{0,60}\byour\b[^.!\n]{0,40}\b(?:quota|limit|allowance)\b/i
363
+ .test(message);
348
364
  }
349
365
 
350
366
  /**
@@ -373,6 +389,14 @@ export function classifyCopilotSessionError(data) {
373
389
  quota,
374
390
  };
375
391
  }
392
+ // The SAME auth branch the thrown-exception path takes (audit #15): a
393
+ // `session.error` tagged `errorType: "authentication"`/`"authorization"`, an
394
+ // auth `errorCode`, or HTTP 401 is a relay-host sign-in problem, and generic
395
+ // "retry" guidance sends the user in circles. `isCopilotAuthError` reads the
396
+ // structured `ErrorData` fields first and falls back to prose.
397
+ if (isCopilotAuthError(data)) {
398
+ return { ...copilotAuthClassification(detail), quota: false };
399
+ }
376
400
  const code = toKebabToken(data?.errorType) || toKebabToken(data?.errorCode) || 'session-error';
377
401
  return {
378
402
  code,
@@ -415,6 +439,21 @@ const AUTH_ERROR_CODES = new Set([
415
439
  'credentials_invalid',
416
440
  ]);
417
441
 
442
+ /**
443
+ * The one auth failure record, shared verbatim by the session-event and
444
+ * thrown-exception classifiers so the remediation cannot drift between them:
445
+ * the fix is a user action on the relay host, never a retry.
446
+ */
447
+ function copilotAuthClassification(detail) {
448
+ return {
449
+ code: 'authentication_failed',
450
+ stableCode: 'copilot.authentication_failed',
451
+ text: `System note: the Copilot runtime could not authenticate (${detail}). `
452
+ + 'Run `copilot` on the relay host and sign in, then retry.',
453
+ detail,
454
+ };
455
+ }
456
+
418
457
  /**
419
458
  * Structured auth signals first (the runtime tags `errorType: "authentication"`
420
459
  * and HTTP 401), prose only as the fallback.
@@ -437,23 +476,32 @@ export function isCopilotAuthError(error) {
437
476
  */
438
477
  export function classifyCopilotTurnException(error) {
439
478
  const detail = String(error?.message || error || '').trim() || 'unknown error';
440
- const terminal = normalizeTerminalSendAndWaitError(error);
479
+ // An unconfirmed explicit model selection (audit #13). The error's own
480
+ // message already names requested vs actual model, so it IS the user-facing
481
+ // text; the stable code is what lets the UI (and tests) key on it.
482
+ if (isModelSwitchUnconfirmedError(error)) {
483
+ return {
484
+ code: MODEL_SWITCH_UNCONFIRMED_CODE,
485
+ stableCode: MODEL_SWITCH_UNCONFIRMED_STABLE_CODE,
486
+ text: detail,
487
+ detail,
488
+ };
489
+ }
490
+ // The quota hint mirrors the session-event path: sendAndWait-shaped errors
491
+ // carry only the prose, and "reached your monthly quota" must be terminal
492
+ // however the error arrived.
493
+ const hint = { quota: isCopilotQuotaError(error) };
494
+ const terminal = normalizeTerminalSendAndWaitError(error, hint);
441
495
  if (terminal) {
442
496
  return {
443
497
  code: terminal.code,
444
498
  stableCode: terminal.stableCode,
445
- text: buildTerminalFailureText(error),
499
+ text: buildTerminalFailureText(error, hint),
446
500
  detail,
447
501
  };
448
502
  }
449
503
  if (isCopilotAuthError(error)) {
450
- return {
451
- code: 'authentication_failed',
452
- stableCode: 'copilot.authentication_failed',
453
- text: `System note: the Copilot runtime could not authenticate (${detail}). `
454
- + 'Run `copilot` on the relay host and sign in, then retry.',
455
- detail,
456
- };
504
+ return copilotAuthClassification(detail);
457
505
  }
458
506
  return {
459
507
  code: 'turn-error',
@@ -83,6 +83,26 @@ export function formatToolActivityText(toolName, input) {
83
83
  return truncate(summary ? `Tool (${name}): ${summary}` : `Tool (${name})`);
84
84
  }
85
85
 
86
+ /**
87
+ * The closing-message summary out of a task_complete TOOL call's arguments.
88
+ * Some model/runtime combinations (live-observed with gpt-5.3-codex) deliver
89
+ * task_complete only as `tool.execution_start` and never as the
90
+ * `session.task_complete` event — arguments may arrive as an object or as a
91
+ * JSON-encoded string.
92
+ */
93
+ export function taskCompleteSummaryFromToolArguments(rawArguments) {
94
+ let args = rawArguments;
95
+ if (typeof args === 'string') {
96
+ try {
97
+ args = JSON.parse(args);
98
+ } catch {
99
+ return '';
100
+ }
101
+ }
102
+ if (!args || typeof args !== 'object' || Array.isArray(args)) return '';
103
+ return String(args.summary || '').trim();
104
+ }
105
+
86
106
  /**
87
107
  * True when an event belongs to a SUBAGENT rather than the root agent.
88
108
  *
@@ -208,7 +228,11 @@ function createUsageAccumulator() {
208
228
  * `{ channel, payload }` actions; the session process dispatches them to the
209
229
  * relay and treats the first `result` action as the turn's terminator.
210
230
  */
211
- export function createCopilotEventNormalizer() {
231
+ export function createCopilotEventNormalizer({
232
+ // The runner's answer to "is this agent id a background agent?" — shared
233
+ // across turns, unlike this normalizer's own `backgroundRuns`.
234
+ isBackgroundAgent = () => false,
235
+ } = {}) {
212
236
  let sessionModel = '';
213
237
  let sessionId = '';
214
238
  let lastEmittedStreamText = '';
@@ -286,6 +310,13 @@ export function createCopilotEventNormalizer() {
286
310
  // runtime tags only one of them.
287
311
  const subagentRuns = new Map(); // runId -> { displayName, status, opened }
288
312
  const subagentAliases = new Map(); // agentId|toolCallId -> runId
313
+ // Runs spawned with `executionMode: "background"`: task-panel cards, not
314
+ // lanes. This normalizer lives for ONE turn while a background agent
315
+ // outlives it, so the runner's knowledge (`isBackgroundAgent`) is consulted
316
+ // too — a later turn must not open a lane for an agent spawned earlier.
317
+ const backgroundRuns = new Set();
318
+ const isBackgroundRun = (runId, agentId) => backgroundRuns.has(runId)
319
+ || (!!agentId && isBackgroundAgent(agentId) === true);
289
320
 
290
321
  /**
291
322
  * `useToolCallId` is true only for `subagent.*` lifecycle events, where
@@ -412,6 +443,17 @@ export function createCopilotEventNormalizer() {
412
443
  return { channel: 'activity', payload: { text, subagentRunId } };
413
444
  }
414
445
 
446
+ /**
447
+ * A line that only tracks background work: the main agent polling its
448
+ * background agent (`read_agent`), or a permission granted for a tool call
449
+ * (the call's own line, when it is the main agent's, is the real work). It
450
+ * still shows on the row; it just does not make a runtime-opened row worth
451
+ * keeping on its own (the task card covers the background agent).
452
+ */
453
+ function bookkeepingActivityAction(text) {
454
+ return { ...activityAction(text), bookkeeping: true };
455
+ }
456
+
415
457
  /**
416
458
  * Build the single terminal `result` action for this turn. Guarded so a
417
459
  * `session.error` arriving after a `session.idle` (or a second error) cannot
@@ -478,23 +520,43 @@ export function createCopilotEventNormalizer() {
478
520
  case 'subagent.started': {
479
521
  const runId = resolveSubagentRun(event, { useToolCallId: true });
480
522
  if (!runId) return [];
523
+ // A BACKGROUND agent (`executionMode: "background"`) is not a lane in
524
+ // this reply: it outlives the turn and shows in the task panel as a
525
+ // stoppable card (the runner reads `rpc.tasks`), and its completion
526
+ // arrives as a continuation. Opening a lane for it would leave a
527
+ // bubble spinning after the reply settled — or, if closed at the
528
+ // terminal, would mark a running agent failed. A one-line note keeps
529
+ // the transcript honest; its tagged events are dropped below.
530
+ if (String(data.executionMode || '').trim().toLowerCase() === 'background') {
531
+ backgroundRuns.add(runId);
532
+ const name = subagentDisplayName(data);
533
+ const agentType = String(data.agentType || '').trim();
534
+ return [activityAction(truncate(
535
+ `Started background agent: ${name}${agentType && agentType !== name ? ` (${agentType})` : ''} — see the task panel`,
536
+ ))];
537
+ }
481
538
  return ensureSubagentRun(runId, subagentDisplayName(data));
482
539
  }
483
540
  case 'subagent.configured':
484
541
  case 'subagent.selected': {
485
- // `subagent.configured` is UNDOCUMENTED — it appears in no published
486
- // schema and a live probe found it between started and completed,
487
- // carrying `agentId` but neither `toolCallId` nor a display name. It is
488
- // handled rather than ignored so the lane still opens if it ever
489
- // arrives first, and it must never clobber the roster's display name
490
- // with its own absent one (hence the `||` in ensureSubagentRun).
542
+ // `subagent.configured` (since documented: `{model, multiTurn, …}`)
543
+ // arrives between started and completed, carrying `agentId` but
544
+ // neither `toolCallId` nor a display name. It is handled rather than
545
+ // ignored so the lane still opens if it ever arrives first, and it
546
+ // must never clobber the roster's display name with its own absent
547
+ // one (hence the `||` in ensureSubagentRun). A background agent's
548
+ // arrives too, and must not open the lane `subagent.started` withheld.
491
549
  const runId = resolveSubagentRun(event, { useToolCallId: true });
492
550
  if (!runId) return [];
551
+ if (isBackgroundRun(runId, subagentEventKeys(event).agentId)) return [];
493
552
  return ensureSubagentRun(runId, subagentDisplayName(data));
494
553
  }
495
554
  case 'subagent.completed': {
496
555
  const runId = resolveSubagentRun(event, { useToolCallId: true });
497
556
  if (!runId) return [];
557
+ // A background agent finishing inside a later turn is the task panel's
558
+ // business (the runner watches the same event); no lane to close.
559
+ if (isBackgroundRun(runId, subagentEventKeys(event).agentId)) return [];
498
560
  const actions = closeSubagentRun(runId, 'completed', subagentDisplayName(data));
499
561
  const stats = formatSubagentStats(data);
500
562
  if (stats) actions.push(activityAction(truncate(`Finished · ${stats}`), runId));
@@ -505,6 +567,11 @@ export function createCopilotEventNormalizer() {
505
567
  if (!runId) return [];
506
568
  const name = subagentDisplayName(data);
507
569
  const reason = String(data.error || '').trim() || 'unknown error';
570
+ // A background agent's failure still deserves a main-thread line (the
571
+ // parent agent recovers silently), but there is no lane to close.
572
+ if (isBackgroundRun(runId, subagentEventKeys(event).agentId)) {
573
+ return [activityAction(truncate(`Background agent failed (${name}): ${reason}`))];
574
+ }
508
575
  const actions = closeSubagentRun(runId, 'failed', name);
509
576
  actions.push(activityAction(truncate(`Subagent failed: ${reason}`), runId));
510
577
  // ALSO on the main thread. The parent agent recovers from a failed
@@ -531,6 +598,14 @@ export function createCopilotEventNormalizer() {
531
598
  if (isSubagentEvent(event)) {
532
599
  const runId = resolveSubagentRun(event);
533
600
  if (!runId) return [];
601
+ // A background agent's traffic belongs to its task card (the runner
602
+ // tracks its live tool call and tokens from the same events), not to a
603
+ // lane in this reply — but its model calls still cost real money, so
604
+ // they count toward the spend of whichever turn is live, as before.
605
+ if (isBackgroundRun(runId, subagentEventKeys(event).agentId)) {
606
+ if (type === 'assistant.usage') usage.add(data, { allowModel: false });
607
+ return [];
608
+ }
534
609
  if (type === 'assistant.message') {
535
610
  const content = String(data.content || '');
536
611
  if (!content.trim()) return [];
@@ -683,7 +758,23 @@ export function createCopilotEventNormalizer() {
683
758
  const toolCallId = String(data.toolCallId || '').trim();
684
759
  const toolName = String(data.toolName || '').trim() || 'tool';
685
760
  if (toolCallId) toolNames.set(toolCallId, toolName);
686
- return [activityAction(formatToolActivityText(toolName, data.arguments))];
761
+ // task_complete is the agent's closing message wearing a tool call's
762
+ // clothes. gpt-5.3-codex (live, 2026-09-12) closes with ONLY this tool
763
+ // call — no session.task_complete event follows — and whatever text
764
+ // was mid-stream gets cut, so without adopting the summary here the
765
+ // reply renders as the truncated fragment. Same precedence as the
766
+ // event case below; no generic activity row, the summary IS the reply.
767
+ if (toolName === 'task_complete') {
768
+ const summary = taskCompleteSummaryFromToolArguments(data.arguments);
769
+ if (!summary) return [];
770
+ taskCompleteSummary = summary;
771
+ const text = finalText();
772
+ if (!shouldEmitStreamUpdate(text, lastEmittedStreamText)) return [];
773
+ lastEmittedStreamText = text;
774
+ return [{ channel: 'stream', payload: { text, done: false, subagentRunId: null } }];
775
+ }
776
+ const toolLine = formatToolActivityText(toolName, data.arguments);
777
+ return [toolName === 'read_agent' ? bookkeepingActivityAction(toolLine) : activityAction(toolLine)];
687
778
  }
688
779
  case 'tool.execution_complete': {
689
780
  // There is no `tool.*_failed` event type — failure is `success: false`
@@ -708,9 +799,9 @@ export function createCopilotEventNormalizer() {
708
799
  const summary = permissionRequests.get(requestId) || '';
709
800
  permissionRequests.delete(requestId);
710
801
  const kind = String(data.result?.kind || 'unknown').trim();
711
- return [activityAction(truncate(summary
712
- ? `Permission ${kind}: ${summary}`
713
- : `Permission ${kind}`))];
802
+ const line = truncate(summary ? `Permission ${kind}: ${summary}` : `Permission ${kind}`);
803
+ // A denial is news; a grant is bookkeeping for the call it unblocked.
804
+ return [kind.startsWith('approved') ? bookkeepingActivityAction(line) : activityAction(line)];
714
805
  }
715
806
  case 'user_input.requested': {
716
807
  // The question is forwarded to the relay as a question card; this row
@@ -756,7 +847,40 @@ export function createCopilotEventNormalizer() {
756
847
  lastEmittedStreamText = text;
757
848
  return [{ channel: 'stream', payload: { text, done: false, subagentRunId: null } }];
758
849
  }
759
- // ---- the one true terminator -----------------------------------------
850
+ // ---- compaction ------------------------------------------------------
851
+ //
852
+ // Surfaced as activity lines so a long pause reads as what it is. The
853
+ // runner ALSO watches these two events directly for its steering hold
854
+ // (a message must not be pushed into a compaction's replay).
855
+ case 'session.compaction_start': {
856
+ return [activityAction('Compacting the conversation context…')];
857
+ }
858
+ case 'session.compaction_complete': {
859
+ if (data.success === false) {
860
+ const reason = String(data.error || '').trim();
861
+ return [activityAction(truncate(reason ? `Context compaction failed: ${reason}` : 'Context compaction failed'))];
862
+ }
863
+ const removed = Number(data.messagesRemoved);
864
+ const tokens = Number(data.tokensRemoved);
865
+ const parts = [];
866
+ if (Number.isFinite(removed) && removed > 0) parts.push(`${removed} messages`);
867
+ if (Number.isFinite(tokens) && tokens > 0) parts.push(`${Math.round(tokens / 1000)}k tokens`);
868
+ return [activityAction(parts.length ? `Context compacted (removed ${parts.join(', ')})` : 'Context compacted')];
869
+ }
870
+ // ---- the terminators ---------------------------------------------------
871
+ //
872
+ // `assistant.idle` is "the main agent's processing loop went idle" and
873
+ // fires even while background agents / attached shells keep the
874
+ // session-level idle deferred (runtime 1.0.88, live-probed 2026-09-25).
875
+ // It is the terminator that lets a row settle while a background agent
876
+ // the turn spawned keeps running under the task panel — exactly the
877
+ // Claude worker's lingering model. `session.idle` stays the hard
878
+ // terminator for runtimes that do not emit the assistant-level one and
879
+ // for the drain's end; `terminalEmitted` makes the second a no-op.
880
+ case 'assistant.idle': {
881
+ if (data.aborted === true) aborted = true;
882
+ return terminalAction({ subtype: aborted ? 'aborted' : 'completed' });
883
+ }
760
884
  case 'session.idle': {
761
885
  if (data.aborted === true) aborted = true;
762
886
  return terminalAction({ subtype: aborted ? 'aborted' : 'completed' });