@oar-sh/oar 0.9.2 → 0.9.4

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 (138) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +336 -0
  4. package/README.md +455 -423
  5. package/bin/oar.js +25 -10
  6. package/package.json +11 -5
  7. package/server/README.md +44 -4
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  10. package/server/claude-worker/claude-session-process.mjs +1073 -69
  11. package/server/claude-worker/claude-session-worker.mjs +45 -4
  12. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  13. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  15. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  16. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  17. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  18. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  19. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
  20. package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
  21. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  22. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  23. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  24. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  25. package/server/db-schema.mjs +28 -0
  26. package/server/grok-worker/acp-client.mjs +92 -6
  27. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  28. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  29. package/server/mcp/oar-mcp-server.mjs +473 -0
  30. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  31. package/server/migrations/0005-remote-relays.mjs +90 -0
  32. package/server/public/app/action-confirmations.js +141 -13
  33. package/server/public/app/api-client.js +125 -2
  34. package/server/public/app/ask-user-view.js +102 -13
  35. package/server/public/app/attachments-view.js +37 -2
  36. package/server/public/app/background-task-timeout-options.mjs +9 -0
  37. package/server/public/app/background-tasks-view.mjs +33 -18
  38. package/server/public/app/bootstrap.js +55 -14
  39. package/server/public/app/composer-control-state.mjs +52 -34
  40. package/server/public/app/conversation-deep-link.mjs +42 -0
  41. package/server/public/app/conversation-draft-sync.mjs +187 -0
  42. package/server/public/app/conversation-list-filter.mjs +86 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  44. package/server/public/app/conversation-view.js +1016 -200
  45. package/server/public/app/emoji-view.js +2 -0
  46. package/server/public/app/font-scaling.js +4 -37
  47. package/server/public/app/host-suspend-ui.js +272 -0
  48. package/server/public/app/journal-view.js +149 -6
  49. package/server/public/app/mention-autocomplete.mjs +298 -0
  50. package/server/public/app/model-selector-options.mjs +1 -1
  51. package/server/public/app/push-settings.js +1 -0
  52. package/server/public/app/pwa-install.js +8 -3
  53. package/server/public/app/question-multi-select.mjs +52 -0
  54. package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
  55. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  56. package/server/public/app/remote-relay-shared.mjs +85 -0
  57. package/server/public/app/remote-relays-settings.js +618 -0
  58. package/server/public/app/remote-relays-store.mjs +126 -0
  59. package/server/public/app/settings-modal.js +12 -8
  60. package/server/public/app/settings-tabs.js +1 -1
  61. package/server/public/app/socket-handlers.js +37 -1
  62. package/server/public/app/steered-turn-merge.mjs +100 -0
  63. package/server/public/app/store.js +126 -15
  64. package/server/public/app/sync-outbox.mjs +16 -8
  65. package/server/public/app/thinking-follow.js +260 -0
  66. package/server/public/app/tool-activity-emoji.mjs +40 -0
  67. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  68. package/server/public/index.html +463 -17
  69. package/server/public/sw.js +7 -0
  70. package/server/relay-tools.md +7 -1
  71. package/server/repositories/message-repository.mjs +68 -18
  72. package/server/repositories/question-repository.mjs +18 -0
  73. package/server/repositories/remote-relay-repository.mjs +75 -0
  74. package/server/repositories/session-repository.mjs +13 -32
  75. package/server/routes/ask-user-routes.mjs +42 -2
  76. package/server/routes/messages-routes.mjs +572 -56
  77. package/server/routes/remote-relay-routes.mjs +133 -0
  78. package/server/routes/sessions-routes.mjs +378 -84
  79. package/server/server-runtime.mjs +303 -25
  80. package/server/services/github-ci-activity-service.mjs +199 -0
  81. package/server/services/host-suspend-command.mjs +38 -0
  82. package/server/services/host-suspend-service.mjs +254 -0
  83. package/server/services/live-turn-picker.mjs +47 -0
  84. package/server/services/push-dispatch-service.mjs +37 -4
  85. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  86. package/server/services/relay-question-context.mjs +46 -0
  87. package/server/services/remote-relay-caller-context.mjs +341 -0
  88. package/server/services/remote-relay-client.mjs +332 -0
  89. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  90. package/server/services/remote-relay-inbound.mjs +187 -0
  91. package/server/services/remote-relay-pairing.mjs +189 -0
  92. package/server/services/remote-relay-registry-service.mjs +581 -0
  93. package/server/services/sdk-session-import-service.mjs +49 -7
  94. package/server/services/session-worker-launch-service.mjs +131 -12
  95. package/server/services/session-worker-registry-service.mjs +75 -0
  96. package/server/services/session-worker-supervisor-service.mjs +7 -1
  97. package/server/services/session-worker-websocket-service.mjs +23 -1
  98. package/server/services/steer-resend.mjs +45 -0
  99. package/shared/background-task-timeout.mjs +12 -4
  100. package/shared/control-poller.mjs +4 -1
  101. package/shared/question-multi-select.mjs +16 -0
  102. package/shared/relay-stop-failure.mjs +15 -0
  103. package/shared/remote-relay-contract.mjs +539 -0
  104. package/shared/remote-relay-mentions.mjs +129 -0
  105. package/shared/remote-relay-tool-core.mjs +314 -0
  106. package/shared/slash-command-guard.mjs +76 -0
  107. package/shared/steer-note.mjs +66 -0
  108. package/shared/steer-settle-failure.mjs +89 -0
  109. package/shared/steer-settle-markers.mjs +18 -0
  110. package/shared/worker-crash-guard.mjs +40 -25
  111. package/shared/worker-runtime/api-client.mjs +112 -22
  112. package/shared/worker-runtime/heartbeat.mjs +55 -1
  113. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  114. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  115. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  116. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  117. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  118. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  119. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  120. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  121. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  122. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  123. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  124. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  125. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  126. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  127. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  128. package/server/routes/messages-routes-test-harness.mjs +0 -111
  129. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  130. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  131. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  132. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  133. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  134. package/server/services/fixtures/cli-install-stub.sh +0 -4
  135. package/server/services/fixtures/grok-stub.cmd +0 -1
  136. package/server/services/fixtures/grok-stub.mjs +0 -89
  137. package/server/services/fixtures/grok-stub.sh +0 -4
  138. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -0,0 +1,314 @@
1
+ // The provider-facing side of the `remote_relay` tool, in one place.
2
+ //
3
+ // Every adapter (Claude's in-process MCP server, Cursor's customTools, the
4
+ // Copilot SDK engine's session tools, and the OAR stdio MCP server that Grok
5
+ // and the Copilot extension engine reach) registers the SAME name, description
6
+ // and schema from the contract and forwards every call to the one local relay
7
+ // endpoint. The relay does the gating, the approval, the forwarding and the
8
+ // waiting; nothing here knows about remote relays beyond the wire shape.
9
+ //
10
+ // SDK-free and dependency-injected, following preview-tool-core.mjs: all relay
11
+ // traffic goes through the worker's injected `api(method, path, body, options)`
12
+ // helper (shared/worker-runtime/api-client.mjs).
13
+
14
+ import {
15
+ REMOTE_RELAY_ERROR_CODES,
16
+ REMOTE_RELAY_TOOL_DESCRIPTION,
17
+ REMOTE_RELAY_TOOL_ENDPOINT,
18
+ REMOTE_RELAY_TOOL_INPUT_SCHEMA,
19
+ REMOTE_RELAY_TOOL_NAME,
20
+ summarizeRemoteRelayCall,
21
+ validateRemoteRelayToolInput,
22
+ } from './remote-relay-contract.mjs';
23
+
24
+ export {
25
+ REMOTE_RELAY_TOOL_DESCRIPTION,
26
+ REMOTE_RELAY_TOOL_ENDPOINT,
27
+ REMOTE_RELAY_TOOL_INPUT_SCHEMA,
28
+ REMOTE_RELAY_TOOL_NAME,
29
+ summarizeRemoteRelayCall,
30
+ };
31
+
32
+ // The adapters' crisp early check; the relay validates again authoritatively.
33
+ export const validateRemoteRelayTool = validateRemoteRelayToolInput;
34
+
35
+ /**
36
+ * A private deep copy of the contract schema for SDKs that take ownership of
37
+ * the object they are handed (the contract's is frozen at the top level, and
38
+ * an SDK that annotates its input would throw on it).
39
+ */
40
+ export function cloneRemoteRelayToolInputSchema() {
41
+ return JSON.parse(JSON.stringify(REMOTE_RELAY_TOOL_INPUT_SCHEMA));
42
+ }
43
+
44
+ // How many remotes this relay knows (`{ count }`): the registration decision.
45
+ export const REMOTE_RELAY_SUMMARY_PATH = '/api/remote-relays/summary';
46
+ // Calls of one conversation the relay is still working on (`{ inflight }`):
47
+ // the Grok inactivity hold, which cannot see the tool call itself.
48
+ export const REMOTE_RELAY_INFLIGHT_PATH = '/api/remote-relays/inflight';
49
+
50
+ // The local relay could not be asked at all, or answered without a body the
51
+ // model could read (a transport error, a crash page). Distinct from every
52
+ // contract code, which describe a refusal the relay itself made.
53
+ export const REMOTE_RELAY_CALL_FAILED = 'REMOTE_RELAY_CALL_FAILED';
54
+
55
+ // The provider cancelled the call (the user's Stop, a host's cancel, a
56
+ // runtime going away) before the relay answered. The relay sees the request
57
+ // close and stops too: it withdraws an open approval card and stops waiting
58
+ // on the remote. A prompt already handed over is NOT taken back.
59
+ export const REMOTE_RELAY_CANCELLED = 'REMOTE_RELAY_CANCELLED';
60
+
61
+ // How long the registration probe may take before the tool is left out.
62
+ export const REMOTE_RELAY_DECISION_TIMEOUT_MS = 5_000;
63
+
64
+ // A hard ceiling for one tool call on the providers that impose their own
65
+ // (MCP clients). The relay already bounds the wait (wait_seconds ≤ 600) but not
66
+ // the time an approval card may sit unanswered, so this only has to outlast a
67
+ // human, never a remote turn.
68
+ export const REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS = 8 * 60 * 60_000;
69
+
70
+ function toText(value) {
71
+ return String(value ?? '').trim();
72
+ }
73
+
74
+ function withTimeout(promise, timeoutMs, label) {
75
+ if (!(Number(timeoutMs) > 0)) return promise;
76
+ let timer = null;
77
+ return Promise.race([
78
+ promise,
79
+ new Promise((_, reject) => {
80
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
81
+ timer.unref?.();
82
+ }),
83
+ ]).finally(() => { if (timer) clearTimeout(timer); });
84
+ }
85
+
86
+ function errorBodyOf(error) {
87
+ // `body` is the parsed JSON error body when the api client kept it; a
88
+ // client that parsed it into `detail` instead is tolerated too.
89
+ for (const candidate of [error?.body, error?.detail]) {
90
+ if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) return candidate;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Maps an api-client failure onto the result shape the model reads. The relay
97
+ * answers a refusal with `{ ok:false, code, error }` and a 4xx/5xx status; that
98
+ * body IS the answer (LOCKED, FORBIDDEN, OFFLINE, …), so it is passed through
99
+ * as-is. Anything without such a body becomes REMOTE_RELAY_CALL_FAILED.
100
+ */
101
+ export function remoteRelayErrorResult(error) {
102
+ const status = Number(error?.status) || 0;
103
+ const body = errorBodyOf(error);
104
+ const detail = typeof error?.detail === 'string' ? toText(error.detail) : '';
105
+ if (body && (body.code || body.error)) {
106
+ return {
107
+ ...body,
108
+ ok: false,
109
+ code: toText(body.code) || REMOTE_RELAY_CALL_FAILED,
110
+ error: toText(body.error || body.message) || detail || `HTTP ${status}`,
111
+ };
112
+ }
113
+ if (status === 404) {
114
+ // A relay that predates the feature (or runs without its routes).
115
+ return {
116
+ ok: false,
117
+ code: REMOTE_RELAY_ERROR_CODES.unsupported,
118
+ error: 'This relay does not offer remote relays (the tool endpoint is missing). Tell the user.',
119
+ };
120
+ }
121
+ return {
122
+ ok: false,
123
+ code: REMOTE_RELAY_CALL_FAILED,
124
+ ...(status ? { status } : {}),
125
+ error: detail || toText(error?.message || error) || 'The relay could not be reached',
126
+ };
127
+ }
128
+
129
+ function isAbortError(error) {
130
+ return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
131
+ }
132
+
133
+ /** The answer for a call its provider cancelled before the relay answered. */
134
+ export function remoteRelayCancelledResult() {
135
+ return {
136
+ ok: false,
137
+ code: REMOTE_RELAY_CANCELLED,
138
+ error: 'The call was cancelled before the relay answered. A message it had already handed over may still run on the remote.',
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Executes one `remote_relay` call: validate, then forward
144
+ * `{ conversationId, action, args }` to the local relay and hand back its JSON.
145
+ * Never throws — a refusal reads as an answer the model can act on, the same
146
+ * contract as executePreviewTool.
147
+ *
148
+ * The call goes out as a long call (`{ longCall: true }`): no client-side
149
+ * timeout at all, because the relay bounds the wait itself (wait_seconds plus
150
+ * approval time) and fetch's 300 s header limit would cut both short.
151
+ * `signal` (the provider's cancellation) is the only way to end it early.
152
+ */
153
+ export async function executeRemoteRelayTool(input, { api, conversationId = '', signal = null } = {}) {
154
+ if (typeof api !== 'function') {
155
+ return { ok: false, code: REMOTE_RELAY_CALL_FAILED, error: 'Remote relay API unavailable in this worker' };
156
+ }
157
+ const conversation = toText(conversationId);
158
+ if (!conversation) {
159
+ return { ok: false, code: REMOTE_RELAY_CALL_FAILED, error: 'No conversation is bound to this call' };
160
+ }
161
+ const parsed = validateRemoteRelayToolInput(input);
162
+ if (!parsed.ok) return parsed;
163
+ if (signal?.aborted) return remoteRelayCancelledResult();
164
+ try {
165
+ const response = await api('POST', REMOTE_RELAY_TOOL_ENDPOINT, {
166
+ conversationId: conversation,
167
+ action: parsed.action,
168
+ args: parsed.args,
169
+ }, { longCall: true, ...(signal ? { signal } : {}) });
170
+ if (response && typeof response === 'object' && !Array.isArray(response)) return response;
171
+ return { ok: true, result: response ?? null };
172
+ } catch (error) {
173
+ if (signal?.aborted || isAbortError(error)) return remoteRelayCancelledResult();
174
+ return remoteRelayErrorResult(error);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Whether a worker should register the tool: at least one remote is paired.
180
+ * Fails closed — any error, a malformed answer or a slow relay leaves the tool
181
+ * out, and the next worker start asks again.
182
+ */
183
+ export async function shouldRegisterRemoteRelayTool({ api, timeoutMs = REMOTE_RELAY_DECISION_TIMEOUT_MS } = {}) {
184
+ if (typeof api !== 'function') return false;
185
+ try {
186
+ const response = await withTimeout(
187
+ Promise.resolve().then(() => api('GET', REMOTE_RELAY_SUMMARY_PATH)),
188
+ timeoutMs,
189
+ 'remote relay summary',
190
+ );
191
+ return Number(response?.count) > 0;
192
+ } catch {
193
+ return false;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * The registration decision as a worker holds it: asked once when the worker
199
+ * starts (`refresh()`), read synchronously where a tool set is fixed
200
+ * (`isEnabled()`), and awaitable where the first build may wait for it
201
+ * (`ready()`, which never rejects). `fixed` pins the answer (tests, kill
202
+ * switches) without asking the relay.
203
+ */
204
+ export function createRemoteRelayToolGate({
205
+ api,
206
+ fixed = undefined,
207
+ decide = shouldRegisterRemoteRelayTool,
208
+ timeoutMs = REMOTE_RELAY_DECISION_TIMEOUT_MS,
209
+ } = {}) {
210
+ if (typeof fixed === 'boolean') {
211
+ return {
212
+ isEnabled: () => fixed,
213
+ isSettled: () => true,
214
+ refresh: () => Promise.resolve(fixed),
215
+ ready: () => Promise.resolve(fixed),
216
+ };
217
+ }
218
+ let enabled = false;
219
+ let settledOnce = false;
220
+ let pending = null;
221
+ function refresh() {
222
+ if (!pending) {
223
+ pending = Promise.resolve()
224
+ .then(() => decide({ api, timeoutMs }))
225
+ .then((value) => { enabled = value === true; }, () => { enabled = false; })
226
+ .then(() => {
227
+ settledOnce = true;
228
+ pending = null;
229
+ return enabled;
230
+ });
231
+ }
232
+ return pending;
233
+ }
234
+ return {
235
+ isEnabled: () => enabled,
236
+ // True once an answer is in and no refresh is running: a caller can then
237
+ // read isEnabled() without awaiting ready().
238
+ isSettled: () => settledOnce && !pending,
239
+ refresh,
240
+ ready: () => (settledOnce && !pending ? Promise.resolve(enabled) : (pending || refresh())),
241
+ };
242
+ }
243
+
244
+ /**
245
+ * How many remote_relay calls of this conversation the relay is still working
246
+ * on. 0 on any failure: the caller's watchdog then does what it would have
247
+ * done without the question.
248
+ */
249
+ export async function fetchRemoteRelayInflight({ api, conversationId = '', timeoutMs = 10_000 } = {}) {
250
+ const conversation = toText(conversationId);
251
+ if (typeof api !== 'function' || !conversation) return 0;
252
+ try {
253
+ const response = await withTimeout(
254
+ Promise.resolve().then(() => api(
255
+ 'GET',
256
+ `${REMOTE_RELAY_INFLIGHT_PATH}?conversationId=${encodeURIComponent(conversation)}`,
257
+ )),
258
+ timeoutMs,
259
+ 'remote relay inflight',
260
+ );
261
+ const count = Number(response?.inflight);
262
+ return Number.isFinite(count) && count > 0 ? count : 0;
263
+ } catch {
264
+ return 0;
265
+ }
266
+ }
267
+
268
+ /** The text every adapter hands the model: the relay's JSON, pretty-printed. */
269
+ export function formatRemoteRelayToolResult(result) {
270
+ try {
271
+ const text = JSON.stringify(result ?? null, null, 2);
272
+ return text === undefined ? 'null' : text;
273
+ } catch {
274
+ return String(result);
275
+ }
276
+ }
277
+
278
+ // ─── Activity lines ──────────────────────────────────────────────────────────
279
+
280
+ // A provider may report the tool under its bare name or namespaced by the MCP
281
+ // server that carries it (`mcp__relay__remote_relay`, `oar__remote_relay`,
282
+ // `oar/remote_relay`, `oar-remote_relay`); all of them are this tool.
283
+ const REMOTE_RELAY_NAME_PATTERN = new RegExp(`(?:^|__|[^A-Za-z0-9_])${REMOTE_RELAY_TOOL_NAME}$`, 'i');
284
+
285
+ export function isRemoteRelayToolName(name) {
286
+ return REMOTE_RELAY_NAME_PATTERN.test(toText(name));
287
+ }
288
+
289
+ /**
290
+ * The arguments of a tool call as an object, whether the provider delivered
291
+ * them parsed or as a JSON string. Null when there is nothing usable.
292
+ */
293
+ export function remoteRelayCallInput(rawInput) {
294
+ let input = rawInput;
295
+ if (typeof input === 'string') {
296
+ try {
297
+ input = JSON.parse(input);
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+ return input && typeof input === 'object' && !Array.isArray(input) ? input : null;
303
+ }
304
+
305
+ /**
306
+ * The summary part of the activity line (`send → linux-test session 01234567:
307
+ * “…”`), or '' when the call carries no action yet (a streaming frame before
308
+ * its arguments arrived) so the caller keeps its generic line.
309
+ */
310
+ export function remoteRelayActivitySummary(rawInput) {
311
+ const input = remoteRelayCallInput(rawInput);
312
+ if (!input || !toText(input.action)) return '';
313
+ return summarizeRemoteRelayCall(input);
314
+ }
@@ -0,0 +1,76 @@
1
+ // Keeps a "/x" message the relay delivers to the Claude CLI plain text.
2
+ //
3
+ // The relay itself runs only /compact and /preview as commands, and neither
4
+ // reaches a worker as text: the composer (and the /api/message route, for
5
+ // /compact) intercept them. Any other "/x" the composer sends only after its
6
+ // warn-once guard ("press send again to send as text"), and the API accepts
7
+ // it as-is — so it must reach the model as the text the user typed. The Claude
8
+ // CLI, however, runs a prompt as a slash command when the string, or its LAST
9
+ // text block trimmed, starts with "/". Between turns that ran "/help what is
10
+ // 3 + 4?" as the CLI's /help (a `local_command` with "isn't available in this
11
+ // environment" as its output): the model never saw the question, and the
12
+ // relay attributed the next turn's reply to that row (live, 2026-09-26).
13
+ //
14
+ // The guard is a zero-width space in front of the "/". JavaScript's trim()
15
+ // does not strip U+200B, so neither the CLI's command check nor its queue's
16
+ // "is this a command" check fires, and the message is handled exactly like
17
+ // any other plain-text prompt (live probe, CLI 2.1.283: no local_command, the
18
+ // model answered the question, the transcript keeps the character). The
19
+ // relay stores and shows the user's own text; only the CLI sees the guard.
20
+ //
21
+ // A steered message needs none: the steer note (shared/steer-note.mjs) already
22
+ // leads its text, so the guard only applies where the text itself starts
23
+ // with "/".
24
+ //
25
+ // Not the SDK's per-message `client_composed: true`: that suppresses the
26
+ // dispatch too (same probe), but older CLIs ignore the field, it also turns
27
+ // off `@path` expansion and drops part of the turn-start context for that
28
+ // message (the probe lost the token-budget reminder), and per the CLI bundle
29
+ // its queue still classifies the text by the leading "/" (e.g. never merges
30
+ // it with other queued prompts) — the guard makes it plain text everywhere.
31
+
32
+ export const SLASH_COMMAND_GUARD = '\u200B';
33
+
34
+ function readsAsSlashCommand(text) {
35
+ return typeof text === 'string' && text.trim().startsWith('/');
36
+ }
37
+
38
+ /** Prefix the guard when the CLI would read the text as a slash command. */
39
+ export function withSlashCommandGuard(text) {
40
+ return readsAsSlashCommand(text) ? `${SLASH_COMMAND_GUARD}${text}` : text;
41
+ }
42
+
43
+ /**
44
+ * The guard applied to Claude user content: a string, or every text block of a
45
+ * content-block array (the CLI inspects the last block; guarding each keeps
46
+ * that true whatever the block order). Returns the input itself when nothing
47
+ * needed a guard; never mutates it.
48
+ */
49
+ export function withSlashCommandGuardContent(content) {
50
+ if (typeof content === 'string') return withSlashCommandGuard(content);
51
+ if (!Array.isArray(content)) return content;
52
+ if (!content.some((block) => block?.type === 'text' && readsAsSlashCommand(block.text))) return content;
53
+ return content.map((block) => (block?.type === 'text' && readsAsSlashCommand(block.text)
54
+ ? { ...block, text: withSlashCommandGuard(block.text) }
55
+ : block));
56
+ }
57
+
58
+ /**
59
+ * The user's own text from a guarded prompt: the leading guard is dropped only
60
+ * where it stands in front of a "/", so other text is returned unchanged.
61
+ */
62
+ export function stripSlashCommandGuard(text) {
63
+ const value = String(text ?? '');
64
+ return value.startsWith(SLASH_COMMAND_GUARD) && readsAsSlashCommand(value.slice(SLASH_COMMAND_GUARD.length))
65
+ ? value.slice(SLASH_COMMAND_GUARD.length)
66
+ : value;
67
+ }
68
+
69
+ /** stripSlashCommandGuard over a string or each text block of a content array. */
70
+ export function stripSlashCommandGuardContent(content) {
71
+ if (typeof content === 'string') return stripSlashCommandGuard(content);
72
+ if (!Array.isArray(content)) return content;
73
+ return content.map((block) => (block?.type === 'text' && typeof block.text === 'string'
74
+ ? { ...block, text: stripSlashCommandGuard(block.text) }
75
+ : block));
76
+ }
@@ -0,0 +1,66 @@
1
+ // The hidden note a steered message carries to the model.
2
+ //
3
+ // A message the user sends while a turn is running is pushed into that turn
4
+ // (steering). When it lands before the model has produced anything, the model
5
+ // sees two user messages at once — and in the 2026-09-25 burn-in gpt-5.4-mini
6
+ // answered only the newest and silently dropped the original request (the
7
+ // runtime's own event log confirmed nothing was lost by the relay). The note
8
+ // tells the model the new message is an addition, not a replacement, unless
9
+ // the user says otherwise. Only the model sees it: the relay stores and shows
10
+ // the user's own text.
11
+ //
12
+ // Shared by the Claude and Copilot SDK workers (maintainer decision, 2026-09-25: both).
13
+
14
+ // Worded to stay true whether the runtime folds the message into the running
15
+ // turn or runs it as a turn of its own after that one finished: "if not
16
+ // finished yet" never asks the model to redo work it already completed. The
17
+ // opening words are the stable handle the prompt sanitizers match on
18
+ // (services/relay-prompt-sanitizer.mjs and its browser copy).
19
+ export const STEERED_MESSAGE_NOTE = '[Sent while you were still working on my previous message. '
20
+ + 'If that request is not finished yet, finish it too; do not drop it unless this message says so.]';
21
+
22
+ // The note LEADS the user's text, inside the same text block, and that
23
+ // position is load-bearing beyond the replay's shape. The Claude CLI runs a
24
+ // prompt as a slash command when the string, or its LAST text block trimmed,
25
+ // starts with "/" — and one dequeued mid-turn is deferred to a turn of its
26
+ // own, whose output the relay would misattribute. The relay itself runs only
27
+ // /compact and /preview as commands (any other "/x" is confirmed as text
28
+ // first), so a steered "/cmd …" must reach the model as plain text folded
29
+ // into the running turn, which the leading note guarantees. Appended, the
30
+ // prompt would still start with "/" and run as a command whose arguments
31
+ // include the note; as a separate leading block, the last block would still
32
+ // start with "/". A "/x" that is not steered (or is pushed into a continuation,
33
+ // which gets no note) is kept text by the Claude worker's slash-command guard
34
+ // instead (shared/slash-command-guard.mjs).
35
+
36
+ /** Prefix a plain-text prompt with the steer note. */
37
+ export function withSteerNote(text) {
38
+ const body = String(text ?? '');
39
+ return body.trim() ? `${STEERED_MESSAGE_NOTE}\n\n${body}` : STEERED_MESSAGE_NOTE;
40
+ }
41
+
42
+ /**
43
+ * Prefix a content-block array (Claude's user message content) with the note:
44
+ * into the first text block when there is one, so the block structure — and
45
+ * with it the replay the CLI echoes back — keeps its shape; otherwise as a new
46
+ * leading text block. Returns a new array; the input is not mutated.
47
+ */
48
+ export function withSteerNoteContent(content) {
49
+ if (typeof content === 'string') return withSteerNote(content);
50
+ if (!Array.isArray(content)) return content;
51
+ const index = content.findIndex((block) => block?.type === 'text');
52
+ if (index < 0) return [{ type: 'text', text: STEERED_MESSAGE_NOTE }, ...content];
53
+ return content.map((block, i) => (i === index ? { ...block, text: withSteerNote(block.text) } : block));
54
+ }
55
+
56
+ /**
57
+ * The user's own text from a prompt that may carry the steer note — what a
58
+ * worker compares a replayed message against. Note-agnostic matching: the
59
+ * replay the CLI echoes back carries the note, the relay row does not.
60
+ */
61
+ export function stripSteerNote(text) {
62
+ const value = String(text ?? '');
63
+ if (value === STEERED_MESSAGE_NOTE) return '';
64
+ const prefix = `${STEERED_MESSAGE_NOTE}\n\n`;
65
+ return value.startsWith(prefix) ? value.slice(prefix.length) : value;
66
+ }
@@ -0,0 +1,89 @@
1
+ // The terminal failure for a message whose prompt the agent already consumed
2
+ // but whose outcome could not be recorded. At-most-once: such a row is never
3
+ // requeued (a re-delivery would run the prompt a second time), so it fails for
4
+ // good with wording that tells the user where the answer went. Shared by the
5
+ // session workers (their own settle paths) and the relay (recovery paths that
6
+ // meet a row marked consumed).
7
+ //
8
+ // Provider-neutral: the wording names `agentLabel` ("the agent" unless the
9
+ // caller knows better — the Claude worker passes 'Claude').
10
+
11
+ export const STEER_SETTLE_FAILED_CODE = 'steer-settle-failed';
12
+ export const STEER_SETTLE_FAILED_STABLE_CODE = `relay.${STEER_SETTLE_FAILED_CODE}`;
13
+ export const DEFAULT_STEER_AGENT_LABEL = 'the agent';
14
+
15
+ function normalizeAgentLabel(value) {
16
+ const text = String(value || '').trim();
17
+ return text || DEFAULT_STEER_AGENT_LABEL;
18
+ }
19
+
20
+ // "the agent" → "The agent" at the start of a sentence; a proper name is
21
+ // left as it is.
22
+ function sentenceCase(label) {
23
+ return label.charAt(0).toUpperCase() + label.slice(1);
24
+ }
25
+
26
+ const VARIANTS = Object.freeze({
27
+ // Folded into a turn whose reply answered it (or the path is unknown — the
28
+ // relay's recovery paths cannot tell which).
29
+ folded: (label) => ({
30
+ message: `This message was already sent to ${label} — check the reply above.`,
31
+ guidance: 'Resend it only if it went unanswered.',
32
+ }),
33
+ // Handed off mid-reply: its own partial reply stands, and the rest of the
34
+ // answer continued on the next message.
35
+ handoff: (label) => ({
36
+ message: `${sentenceCase(label)} started answering this message, then carried on with your next one — check both replies.`,
37
+ guidance: 'Resend it only if something went unanswered.',
38
+ }),
39
+ // Steered into a turn the user stopped.
40
+ stopped: (label) => ({
41
+ message: `This message was sent to ${label} just before you stopped the turn, so it was not answered.`,
42
+ guidance: 'Resend it if you still want an answer.',
43
+ }),
44
+ // Pulled back out of the runtime's queue by the user (un-steer) — the
45
+ // prompt never ran — but the relay could not be told in time.
46
+ cancelled: (label) => ({
47
+ message: `This message was cancelled before ${label} started on it, so it was not answered.`,
48
+ guidance: 'Resend it if you still want an answer.',
49
+ }),
50
+ });
51
+
52
+ /**
53
+ * The name a provider's runtime goes by in the settle wording, for callers
54
+ * that only know the conversation's provider type (the relay's recovery
55
+ * paths). Workers that know exactly which runtime took the prompt pass the
56
+ * label themselves.
57
+ */
58
+ export function steerAgentLabelForProvider(providerType) {
59
+ switch (String(providerType || '').trim().toLowerCase()) {
60
+ case 'claude': return 'Claude';
61
+ case 'github':
62
+ case 'openai': return 'Copilot';
63
+ case 'cursor': return 'Cursor';
64
+ case 'grok': return 'Grok';
65
+ default: return DEFAULT_STEER_AGENT_LABEL;
66
+ }
67
+ }
68
+
69
+ function wordingFor(variant, agentLabel) {
70
+ const build = VARIANTS[variant] || VARIANTS.folded;
71
+ return build(normalizeAgentLabel(agentLabel));
72
+ }
73
+
74
+ export function steerSettleFailureText(variant = 'folded', agentLabel = DEFAULT_STEER_AGENT_LABEL) {
75
+ return wordingFor(variant, agentLabel).message;
76
+ }
77
+
78
+ export function buildSteerSettleFailure(message, { variant = 'folded', agentLabel = DEFAULT_STEER_AGENT_LABEL } = {}) {
79
+ const wording = wordingFor(variant, agentLabel);
80
+ return {
81
+ kind: STEER_SETTLE_FAILED_CODE,
82
+ code: STEER_SETTLE_FAILED_CODE,
83
+ stableCode: STEER_SETTLE_FAILED_STABLE_CODE,
84
+ message: wording.message,
85
+ guidance: wording.guidance,
86
+ failedAt: new Date().toISOString(),
87
+ queueMessageId: String(message?.id || '') || null,
88
+ };
89
+ }
@@ -0,0 +1,18 @@
1
+ // The settle stubs of steered messages the Claude CLI consumed without opening
2
+ // a turn of their own. Each is stamped with its own message kind, which is
3
+ // what the client renders from; the texts are shared so the relay's migration
4
+ // can recognise stubs saved before the kind existed.
5
+
6
+ /**
7
+ * A steer folded into the running turn and answered by that turn's reply.
8
+ * Stamped kind='folded' (before 0.9.3: kind='absorbed', which the client read
9
+ * as "the reply continues through the next message" and mis-merged the next
10
+ * ordinary message on reload).
11
+ */
12
+ export const STEER_FOLDED_TEXT = '_(Handled together with the previous reply — this message was steered into that turn.)_';
13
+
14
+ /**
15
+ * A steer pushed into a turn the user then stopped: never answered. Stamped
16
+ * kind='stopped' — the stable handle the client keys its Resend on.
17
+ */
18
+ export const STEER_STOPPED_TEXT = '_(Stopped with the turn — not answered.)_';
@@ -11,6 +11,45 @@
11
11
  * The requeue is strictly bounded: a crash handler that hangs is worse than
12
12
  * no handler at all.
13
13
  */
14
+ /**
15
+ * Post a bounded, fenced requeue for each owed row. Entries are plain ids or
16
+ * { id, attemptId, terminalError } objects: the attempt id fences the requeue
17
+ * (a row that has moved on to another attempt must not be yanked back by a
18
+ * dying predecessor), and a `terminalError` makes the requeue route fail the
19
+ * row outright — for a row whose prompt was already consumed, which must not
20
+ * be requeued and run a second time. Never waits longer than `timeoutMs`.
21
+ */
22
+ export async function requeueOwedRows({ api, entries = [], timeoutMs = 2_000 } = {}) {
23
+ const seen = new Set();
24
+ const owed = (Array.isArray(entries) ? entries : [])
25
+ .map((entry) => (entry && typeof entry === 'object'
26
+ ? {
27
+ id: String(entry.id || '').trim(),
28
+ attemptId: String(entry.attemptId || '').trim() || null,
29
+ terminalError: entry.terminalError && typeof entry.terminalError === 'object' ? entry.terminalError : null,
30
+ }
31
+ : { id: String(entry || '').trim(), attemptId: null, terminalError: null }))
32
+ .filter((entry) => {
33
+ if (!entry.id || seen.has(entry.id)) return false;
34
+ seen.add(entry.id);
35
+ return true;
36
+ });
37
+ if (!owed.length || typeof api !== 'function') return;
38
+ let timer = null;
39
+ await Promise.race([
40
+ Promise.allSettled(owed.map((entry) => api('POST', '/api/requeue', {
41
+ messageId: entry.id,
42
+ ...(entry.attemptId ? { attemptId: entry.attemptId } : {}),
43
+ ...(entry.terminalError ? { terminalError: entry.terminalError } : {}),
44
+ }))),
45
+ new Promise((resolve) => {
46
+ timer = setTimeout(resolve, Math.max(0, Number(timeoutMs) || 0));
47
+ timer.unref?.();
48
+ }),
49
+ ]);
50
+ clearTimeout(timer);
51
+ }
52
+
14
53
  export function installWorkerCrashGuard({
15
54
  api,
16
55
  workerName = 'session-worker',
@@ -28,31 +67,7 @@ export function installWorkerCrashGuard({
28
67
  logError(`${workerName} ${kind}:`, error?.stack || error?.message || error);
29
68
  (async () => {
30
69
  try {
31
- // Entries are plain ids or { id, attemptId } objects. The attempt id,
32
- // when known, fences the requeue: a row that has already moved on to
33
- // another attempt must not be yanked back by a dying predecessor.
34
- const seen = new Set();
35
- const entries = (getActiveQueueMessageIds() || [])
36
- .map((entry) => (entry && typeof entry === 'object'
37
- ? { id: String(entry.id || '').trim(), attemptId: String(entry.attemptId || '').trim() || null }
38
- : { id: String(entry || '').trim(), attemptId: null }))
39
- .filter((entry) => {
40
- if (!entry.id || seen.has(entry.id)) return false;
41
- seen.add(entry.id);
42
- return true;
43
- });
44
- if (entries.length && typeof api === 'function') {
45
- await Promise.race([
46
- Promise.allSettled(entries.map((entry) => api('POST', '/api/requeue', {
47
- messageId: entry.id,
48
- ...(entry.attemptId ? { attemptId: entry.attemptId } : {}),
49
- }))),
50
- new Promise((resolve) => {
51
- const timer = setTimeout(resolve, Math.max(0, Number(requeueTimeoutMs) || 0));
52
- timer.unref?.();
53
- }),
54
- ]);
55
- }
70
+ await requeueOwedRows({ api, entries: getActiveQueueMessageIds() || [], timeoutMs: requeueTimeoutMs });
56
71
  } catch {}
57
72
  try { onBeforeExit(); } catch {}
58
73
  exit(1);