@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
@@ -0,0 +1,425 @@
1
+ // Observed model + reasoning-effort switching for the SDK worker (audit #9 and
2
+ // the #13 policy: "model switching proceeds without confirmed success").
3
+ //
4
+ // The session facade's `setModel()` forwards to `rpc.model.switchTo` and
5
+ // DISCARDS the result (SDK 1.0.13, session.js ~1606), so a worker that calls it
6
+ // can never tell a committed switch from a refused one — and the old behaviour
7
+ // on any failure was to log and run the prompt on whatever model the session
8
+ // happened to be on. This module talks to the RPC surface directly and treats
9
+ // the result as the contract it is:
10
+ //
11
+ // * `modelId` — the model actually active after the call; a mismatch
12
+ // is a refusal, not a delay.
13
+ // * `deferred: true` — the switch was ENQUEUED behind an active turn and the
14
+ // live model is unchanged until the queue drains. The
15
+ // drain is observed by waiting (bounded) for the
16
+ // `session.model_change` event whose `data.newModel` is
17
+ // the requested model; the waiter is armed BEFORE the
18
+ // RPC is sent so a drain cannot slip between the result
19
+ // arriving and the subscription starting.
20
+ // * `confirmation` — the runtime wants an interactive decision (compaction
21
+ // preflight); a headless worker has nobody to ask.
22
+ //
23
+ // Reasoning effort rides the same machinery. `ReasoningEffort` has NO "none"
24
+ // member in 1.0.13 — the relay's `none` (and an absent value) mean "the model's
25
+ // default", mapped to the model's `defaultReasoningEffort` from a cached
26
+ // `rpc.model.list()` when known, and otherwise simply omitted. An effort-only
27
+ // change uses the `rpc.model.setReasoningEffort` RPC (@experimental; the host
28
+ // must pre-validate against the model's `supportedReasoningEfforts`, which is
29
+ // exactly what the cached catalog is for).
30
+ //
31
+ // Failure policy differs by session type, decided by the CALLER's `byok` flag:
32
+ //
33
+ // * hosted — an unconfirmed explicit selection THROWS
34
+ // (`relay.model-switch-unconfirmed`, naming requested vs actual), and the
35
+ // prompt is never sent on the wrong model.
36
+ // * BYOK — `apply` returns `{ ok: false }` instead of throwing, because the
37
+ // caller has a second mechanism (dispose + resume with a rebuilt
38
+ // `SessionConfig.provider`) that this module knows nothing about. An
39
+ // effort the model is known not to support is skipped SILENTLY for BYOK
40
+ // (openai-compatible ceilings and effort vocabularies differ per provider).
41
+ //
42
+ // No SDK import, no I/O of its own: everything arrives through the session
43
+ // handle and the event feed, which is what keeps the unit suite spawn-free.
44
+
45
+ /**
46
+ * How long a `deferred: true` switch may wait for its `session.model_change`
47
+ * drain before the selection counts as unconfirmed. Deliberately generous
48
+ * against a healthy runtime (a queued switch drains at the next model-call
49
+ * boundary, typically well under a second) and deliberately bounded: the
50
+ * alternative is sending the user's prompt to a model they explicitly
51
+ * deselected. Configurable as `COPILOT_SDK_RELAY_MODEL_SWITCH_TIMEOUT_MS`.
52
+ */
53
+ import { supportedEffortsOf } from '../../shared/model-descriptors.mjs';
54
+
55
+ export const DEFAULT_MODEL_SWITCH_TIMEOUT_MS = 10_000;
56
+
57
+ export const MODEL_SWITCH_UNCONFIRMED_CODE = 'model-switch-unconfirmed';
58
+ export const MODEL_SWITCH_UNCONFIRMED_STABLE_CODE = 'relay.model-switch-unconfirmed';
59
+
60
+ /**
61
+ * Relay effort → the vocabulary this module tracks internally.
62
+ *
63
+ * `null` means "the model's own default": the relay sends `none` (or nothing)
64
+ * for it, and SDK 1.0.13's `ReasoningEffort` union has no `none` member — so it
65
+ * must never reach the wire as a literal value.
66
+ */
67
+ export function normalizeRelayEffort(value) {
68
+ const text = String(value || '').trim().toLowerCase();
69
+ if (!text || text === 'none') return null;
70
+ return text;
71
+ }
72
+
73
+ // The effort-list reader lives with the shared descriptor rules (the model
74
+ // catalog snapshot publishes the same list), re-exported so existing callers
75
+ // and tests keep their import. `null` = no parseable list, and unknown must be
76
+ // PERMISSIVE here: the runtime is the authority on what it supports, this
77
+ // catalog is only a fast-fail.
78
+ export { supportedEffortsOf };
79
+
80
+ /**
81
+ * The terminal failure for a selection the runtime would not (or could not
82
+ * provably) honour. Carried as a marked Error so `classifyCopilotTurnException`
83
+ * can route it to the existing terminal-error publish path without prose
84
+ * sniffing, with a stable code the UI can key on.
85
+ */
86
+ export function createModelSwitchUnconfirmedError({
87
+ requestedModel = '',
88
+ requestedEffort = null,
89
+ actualModel = '',
90
+ detail = '',
91
+ } = {}) {
92
+ const requested = requestedEffort
93
+ ? `"${requestedModel}" (reasoning effort "${requestedEffort}")`
94
+ : `"${requestedModel}"`;
95
+ const actual = actualModel ? `"${actualModel}"` : 'its previous model';
96
+ // An effort-only refusal on the model the session is already on reads
97
+ // nonsensically as "asked for X but still on X"; name the effort instead.
98
+ const effortOnly = !!requestedEffort && !!requestedModel && requestedModel === actualModel;
99
+ const error = new Error(effortOnly
100
+ ? `System note: this message asked for reasoning effort "${requestedEffort}" on "${requestedModel}", `
101
+ + `but the Copilot runtime did not confirm it${detail ? ` (${detail})` : ''}. `
102
+ + 'The message was not sent; pick a supported effort (or another model) and resend it.'
103
+ : `System note: this message asked for model ${requested}, but the Copilot runtime did not confirm `
104
+ + `the switch — the session is still on ${actual}${detail ? ` (${detail})` : ''}. `
105
+ + 'The message was not sent on the wrong model; pick an available model and resend it.');
106
+ error.modelSwitchUnconfirmed = true;
107
+ error.code = MODEL_SWITCH_UNCONFIRMED_CODE;
108
+ error.stableCode = MODEL_SWITCH_UNCONFIRMED_STABLE_CODE;
109
+ error.requestedModel = requestedModel || null;
110
+ error.requestedEffort = requestedEffort || null;
111
+ error.actualModel = actualModel || null;
112
+ return error;
113
+ }
114
+
115
+ export function isModelSwitchUnconfirmedError(error) {
116
+ return error?.modelSwitchUnconfirmed === true;
117
+ }
118
+
119
+ /** switchTo statuses that read as success when the result omits `modelId`. */
120
+ const SWITCH_OK_STATUS_RE = /^(?:ok|success|succeeded|applied|switched|completed)$/i;
121
+
122
+ export function createCopilotModelSwitcher({
123
+ switchTimeoutMs = DEFAULT_MODEL_SWITCH_TIMEOUT_MS,
124
+ dbg = () => {},
125
+ } = {}) {
126
+ // The last (model, effort) pair CONFIRMED on the live session — by an RPC
127
+ // result, by a drained `session.model_change`, or by the caller vouching for
128
+ // a session config it just built (`noteApplied`). `effort: null` means "the
129
+ // model's default". This is the single gate that keeps the common path
130
+ // (same model, same effort, every turn) at zero RPCs.
131
+ let applied = { model: '', effort: null };
132
+ // One `rpc.model.list()` per live session, fetched lazily and only when an
133
+ // effort actually needs validating or defaulting. Reset with the session.
134
+ let catalog = null;
135
+ // Deferred switches waiting for their `session.model_change` drain.
136
+ const drainWaiters = new Set();
137
+
138
+ function settleWaiters(model, value) {
139
+ for (const waiter of [...drainWaiters]) {
140
+ if (model === null || waiter.model === model) {
141
+ drainWaiters.delete(waiter);
142
+ waiter.resolve(value);
143
+ }
144
+ }
145
+ }
146
+
147
+ /** The session is gone (rebuild, idle shutdown): nothing confirmed survives it. */
148
+ function reset() {
149
+ applied = { model: '', effort: null };
150
+ catalog = null;
151
+ settleWaiters(null, false);
152
+ }
153
+
154
+ /**
155
+ * The caller vouches for the session's state without an RPC — a session
156
+ * CREATED with `config.model` (and, for BYOK, `config.reasoningEffort`) is
157
+ * on that model by construction.
158
+ */
159
+ function noteApplied(model, effort = null) {
160
+ applied = { model: String(model || '').trim(), effort: normalizeRelayEffort(effort) };
161
+ }
162
+
163
+ function current() {
164
+ return { ...applied };
165
+ }
166
+
167
+ /**
168
+ * Fed every LIVE session event by the runner (the replay gate runs first —
169
+ * a historical `session.model_change` must not confirm a pending switch).
170
+ * Keeps the tracked pair truthful even for switches this worker did not ask
171
+ * for (a deferred switch draining after its wait expired, a runtime-side
172
+ * refusal fallback), and resolves any drain waiter for the new model.
173
+ */
174
+ function observeEvent(event) {
175
+ if (event?.type !== 'session.model_change') return;
176
+ const newModel = String(event?.data?.newModel || '').trim();
177
+ if (!newModel) return;
178
+ applied = { model: newModel, effort: normalizeRelayEffort(event?.data?.reasoningEffort) };
179
+ settleWaiters(newModel, true);
180
+ }
181
+
182
+ /** Arm a bounded waiter for the drain of a deferred switch onto `model`. */
183
+ function armDrainWaiter(model) {
184
+ let resolveWaiter;
185
+ let timer = null;
186
+ const waiter = { model };
187
+ const promise = new Promise((resolve) => { resolveWaiter = resolve; });
188
+ waiter.resolve = (value) => {
189
+ clearTimeout(timer);
190
+ resolveWaiter(value);
191
+ };
192
+ timer = setTimeout(() => {
193
+ drainWaiters.delete(waiter);
194
+ waiter.resolve(false);
195
+ }, switchTimeoutMs);
196
+ timer.unref?.();
197
+ drainWaiters.add(waiter);
198
+ return {
199
+ promise,
200
+ cancel: () => {
201
+ drainWaiters.delete(waiter);
202
+ waiter.resolve(false);
203
+ },
204
+ };
205
+ }
206
+
207
+ /** Populate the per-session catalog cache from one `rpc.model.list()` call. */
208
+ async function ensureCatalog(session) {
209
+ if (catalog) return;
210
+ const list = typeof session?.rpc?.model?.list === 'function'
211
+ ? await session.rpc.model.list().catch((error) => {
212
+ dbg('rpc.model.list failed; effort validation degrades to the runtime', error?.message || String(error));
213
+ return null;
214
+ })
215
+ : null;
216
+ // A failed list is cached as empty rather than retried per turn: the
217
+ // degradation (send the effort, let the runtime validate) is safe.
218
+ catalog = new Map();
219
+ for (const entry of (Array.isArray(list?.list) ? list.list : [])) {
220
+ const id = String(entry?.id || '').trim();
221
+ if (id) catalog.set(id, entry);
222
+ }
223
+ }
224
+
225
+ /** The catalog entry for a model, from one cached per-session list() call. */
226
+ async function modelInfo(session, model) {
227
+ if (!model) return null;
228
+ await ensureCatalog(session);
229
+ return catalog.get(model) || null;
230
+ }
231
+
232
+ /**
233
+ * The cached catalog as raw ModelInfo entries, fetching it on first use —
234
+ * the SAME single list() the effort validation reads, so a snapshot publish
235
+ * never adds a second RPC to a session that already validated an effort.
236
+ * Empty when the runtime refused the list; callers treat that as "nothing
237
+ * to publish" rather than an error.
238
+ */
239
+ async function catalogEntries(session) {
240
+ await ensureCatalog(session);
241
+ return [...catalog.values()];
242
+ }
243
+
244
+ /**
245
+ * Bring the live session onto (model, effort), doing nothing when both
246
+ * already match.
247
+ *
248
+ * Returns `{ ok: true, changed }` on success. On failure: hosted sessions
249
+ * THROW the unconfirmed error (the caller must not send the prompt); BYOK
250
+ * sessions get `{ ok: false, detail }` so the caller can fall back to its
251
+ * dispose+resume rebuild.
252
+ */
253
+ async function apply(session, { model = '', effort = undefined, byok = false } = {}) {
254
+ const targetModel = String(model || '').trim();
255
+ const targetEffortRaw = normalizeRelayEffort(effort);
256
+ const modelChanged = !!targetModel && targetModel !== applied.model;
257
+ // The model the effort applies to: the one we are switching to, else the
258
+ // one the session is already on. With neither there is nothing to do.
259
+ const effortModel = targetModel || applied.model;
260
+ if (!effortModel) return { ok: true, changed: false };
261
+
262
+ const fail = (detail, requestedEffort = targetEffortRaw) => {
263
+ if (byok) {
264
+ dbg('BYOK model RPC unconfirmed; caller falls back to rebuild', detail);
265
+ return { ok: false, detail };
266
+ }
267
+ throw createModelSwitchUnconfirmedError({
268
+ requestedModel: targetModel || applied.model,
269
+ requestedEffort,
270
+ actualModel: applied.model,
271
+ detail,
272
+ });
273
+ };
274
+
275
+ // ---- resolve the effort actually sent on the wire -----------------------
276
+ // `null` target = "the model's default": mapped to the catalog's
277
+ // `defaultReasoningEffort` when known, otherwise omitted entirely (and the
278
+ // local tracking resets, so the next explicit effort still reads as a
279
+ // delta). An explicit level is pre-validated against the model's
280
+ // `supportedReasoningEfforts` — the setReasoningEffort RPC is
281
+ // @experimental and documents that the HOST validates.
282
+ let effortToSend;
283
+ let trackedEffort;
284
+ if (targetEffortRaw === null) {
285
+ if (!modelChanged && applied.effort === null) {
286
+ effortToSend = undefined;
287
+ trackedEffort = null;
288
+ } else {
289
+ const info = await modelInfo(session, effortModel);
290
+ const supported = supportedEffortsOf(info);
291
+ if (supported?.includes('none')) {
292
+ // The wire catalog offers a literal "none" (GPT-family entries do,
293
+ // despite the TS union lacking the member): send it as the explicit
294
+ // reset, tracked as null so steady-state stays zero-RPC.
295
+ effortToSend = 'none';
296
+ trackedEffort = null;
297
+ } else {
298
+ const fallback = normalizeRelayEffort(info?.defaultReasoningEffort);
299
+ effortToSend = fallback || undefined;
300
+ trackedEffort = fallback;
301
+ }
302
+ }
303
+ } else {
304
+ const info = await modelInfo(session, effortModel);
305
+ const supported = supportedEffortsOf(info);
306
+ // Fail ONLY on a positive exclusion. An entry with no parseable effort
307
+ // list — model absent from the catalog, an effort-less model, or a wire
308
+ // shape this code does not know — sends the level and lets the runtime
309
+ // confirm or refuse: failing closed here bricked every effort-carrying
310
+ // turn when the session list turned out to speak raw CAPI shape.
311
+ if (Array.isArray(supported) && !supported.includes(targetEffortRaw)) {
312
+ const advertised = supported.length ? ` (it supports: ${supported.join(', ')})` : '';
313
+ if (byok) {
314
+ // Openai-compatible providers define their own effort vocabularies
315
+ // (and many models simply have none); a level the catalog rejects is
316
+ // skipped rather than failed, per the BYOK contract.
317
+ dbg('BYOK model does not support the requested effort; skipping it', effortModel, targetEffortRaw);
318
+ effortToSend = undefined;
319
+ trackedEffort = applied.effort;
320
+ } else if (!modelChanged) {
321
+ return fail(`the model does not support reasoning effort "${targetEffortRaw}"${advertised}`);
322
+ } else {
323
+ // Model change + unsupported effort: the selection as a whole cannot
324
+ // be honoured — running the new model on a different effort than the
325
+ // one explicitly picked is the same silent substitution #13 forbids.
326
+ return fail(`"${targetModel}" does not support reasoning effort "${targetEffortRaw}"${advertised}`);
327
+ }
328
+ } else {
329
+ effortToSend = targetEffortRaw;
330
+ trackedEffort = targetEffortRaw;
331
+ }
332
+ }
333
+
334
+ const effortChanged = !modelChanged && effortToSend !== undefined && effortToSend !== applied.effort;
335
+
336
+ if (!modelChanged && !effortChanged) {
337
+ // Nothing to send; an unknown-default reset still updates the tracking.
338
+ if (targetEffortRaw === null) applied = { ...applied, effort: trackedEffort };
339
+ return { ok: true, changed: false };
340
+ }
341
+
342
+ // ---- model change: rpc.model.switchTo, result consumed ------------------
343
+ if (modelChanged) {
344
+ const switchTo = session?.rpc?.model?.switchTo;
345
+ if (typeof switchTo !== 'function') {
346
+ // No RPC surface (an exotic bundle): the facade's throw-on-failure is
347
+ // the only signal left. Hosted trusts it; BYOK falls back to rebuild.
348
+ if (byok || typeof session?.setModel !== 'function') return fail('the runtime exposes no model-switch RPC');
349
+ await session.setModel(targetModel, effortToSend ? { reasoningEffort: effortToSend } : undefined)
350
+ .catch((error) => fail(error?.message || String(error)));
351
+ applied = { model: targetModel, effort: trackedEffort };
352
+ return { ok: true, changed: true };
353
+ }
354
+ // Armed BEFORE the RPC: a deferred switch can drain between the result
355
+ // arriving and any later subscription, and a missed drain here would
356
+ // fail a selection the runtime actually honoured.
357
+ const drain = armDrainWaiter(targetModel);
358
+ let result;
359
+ try {
360
+ result = await switchTo.call(session.rpc.model, {
361
+ modelId: targetModel,
362
+ ...(effortToSend ? { reasoningEffort: effortToSend } : {}),
363
+ });
364
+ } catch (error) {
365
+ drain.cancel();
366
+ return fail(error?.message || String(error));
367
+ }
368
+ if (result?.confirmation) {
369
+ // A compaction preflight wants an interactive decision; a headless
370
+ // worker has nobody to put it to mid-dequeue.
371
+ drain.cancel();
372
+ return fail('the runtime requires interactive confirmation for this switch');
373
+ }
374
+ if (result?.deferred === true) {
375
+ // Enqueued behind an active turn: the live model is unchanged until
376
+ // the queue drains. Wait (bounded) for the model_change that proves it.
377
+ dbg('model switch deferred behind an active turn; awaiting the drain', targetModel);
378
+ const drained = await drain.promise;
379
+ if (!drained) {
380
+ return fail(`the queued switch did not apply within ${Math.round(switchTimeoutMs / 1000)}s`);
381
+ }
382
+ // `observeEvent` already updated `applied` from the event (including
383
+ // the effort the runtime settled on); do not overwrite it blind.
384
+ return { ok: true, changed: true };
385
+ }
386
+ drain.cancel();
387
+ const confirmedModel = String(result?.modelId || result?.modelState?.modelId || '').trim();
388
+ if (confirmedModel && confirmedModel !== targetModel) {
389
+ return fail(`the runtime reports "${confirmedModel}" as the active model`);
390
+ }
391
+ if (!confirmedModel && !SWITCH_OK_STATUS_RE.test(String(result?.status || ''))) {
392
+ return fail(String(result?.message || 'the runtime did not report the active model after the switch'));
393
+ }
394
+ applied = { model: targetModel, effort: trackedEffort };
395
+ return { ok: true, changed: true };
396
+ }
397
+
398
+ // ---- effort-only change: rpc.model.setReasoningEffort -------------------
399
+ const setEffort = session?.rpc?.model?.setReasoningEffort;
400
+ if (typeof setEffort !== 'function') {
401
+ return fail('the runtime exposes no reasoning-effort RPC', effortToSend);
402
+ }
403
+ let effortResult;
404
+ try {
405
+ effortResult = await setEffort.call(session.rpc.model, { reasoningEffort: effortToSend });
406
+ } catch (error) {
407
+ return fail(error?.message || String(error), effortToSend);
408
+ }
409
+ const recorded = String(effortResult?.reasoningEffort || '').trim().toLowerCase();
410
+ if (recorded && recorded !== effortToSend) {
411
+ return fail(`the runtime recorded effort "${recorded}"`, effortToSend);
412
+ }
413
+ applied = { ...applied, effort: trackedEffort };
414
+ return { ok: true, changed: true };
415
+ }
416
+
417
+ return {
418
+ apply,
419
+ observeEvent,
420
+ noteApplied,
421
+ reset,
422
+ current,
423
+ catalogEntries,
424
+ };
425
+ }
@@ -3,9 +3,10 @@
3
3
  // block.
4
4
  //
5
5
  // This mirrors the extension's `createRelayPromptBuilder`
6
- // (`.github/extensions/web-relay/skills/prompt-context.mjs`) and reuses its
7
- // text verbatim through `buildModePrompt`, so a conversation reads the same
8
- // guidance whichever engine ran it. It does NOT reuse the extension's builder
6
+ // (`shared/worker-runtime/prompt-context.mjs`, relocated from the extension
7
+ // tree for its retirement) and reuses its text verbatim through
8
+ // `buildModePrompt`, so a conversation reads the same guidance whichever
9
+ // engine ran it. It does NOT reuse the extension's builder
9
10
  // wholesale for one reason: that builder also composes the message BODY via its
10
11
  // own `buildPrompt`, which renders attachments as a text note. This worker
11
12
  // passes real `MessageOptions.attachments` to the runtime
@@ -24,8 +25,9 @@ import {
24
25
  applyPreviewInstructions,
25
26
  createPreviewInstructionsProvider,
26
27
  } from '../../shared/preview-instructions.mjs';
27
- import { buildModePrompt } from '../../.github/extensions/web-relay/skills/prompt-context.mjs';
28
- import { loadRelayInstructionsFromFile } from '../../.github/extensions/web-relay/runtime/config-loader.mjs';
28
+ import { applyMediaEmbedInstructions } from '../../shared/media-embed-instructions.mjs';
29
+ import { buildModePrompt } from '../../shared/worker-runtime/prompt-context.mjs';
30
+ import { loadRelayInstructionsFromFile } from '../../shared/worker-runtime/config-loader.mjs';
29
31
 
30
32
  export { createPreviewInstructionsProvider, loadRelayInstructionsFromFile };
31
33
 
@@ -54,6 +56,14 @@ export function loadDefaultRelayToolInstructions({ env = process.env } = {}) {
54
56
  * its standing instructions every message. The mode marker itself is always
55
57
  * present.
56
58
  *
59
+ * Returns `{ prefix, commit }`, and the mode is recorded as prompted only when
60
+ * the caller invokes `commit()` — AFTER `session.send()` accepted the message
61
+ * (audit #32). Committing at build time was the bug: a failed send meant the
62
+ * runtime never received the guidance, yet the same-mode retry omitted it
63
+ * because the builder believed it had already been delivered. The rare double
64
+ * delivery the late commit allows (two builds racing before either send lands)
65
+ * costs a repeated instruction block, which is harmless; the omission was not.
66
+ *
57
67
  * `agentMode` is threaded to the runtime natively as well
58
68
  * (`copilotAgentModeForRelayMode`); the text is not a substitute for it but a
59
69
  * reinforcement, same as on the extension path.
@@ -72,11 +82,13 @@ export function createCopilotPromptContextBuilder({
72
82
  const previewBlock = typeof getPreviewInstructions === 'function'
73
83
  ? await Promise.resolve().then(getPreviewInstructions).catch(() => '')
74
84
  : '';
75
- instructions = applyPreviewInstructions(toolInstructions, previewBlock);
85
+ instructions = applyMediaEmbedInstructions(applyPreviewInstructions(toolInstructions, previewBlock));
76
86
  }
77
87
  const prefix = buildModePrompt(relayMode, instructions, { includeInstructions });
78
- lastPromptedRelayMode = relayMode;
79
- return prefix;
88
+ return {
89
+ prefix,
90
+ commit: () => { lastPromptedRelayMode = relayMode; },
91
+ };
80
92
  };
81
93
  }
82
94