@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.
- package/.github/extensions/web-relay/extension.mjs +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- package/server/services/fixtures/grok-stub.sh +0 -86
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
// Bridges the Copilot runtime's
|
|
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
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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, {
|
|
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: {
|
|
150
|
-
|
|
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) =>
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
// ----
|
|
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' });
|