@oar-sh/oar 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +407 -424
  3. package/bin/oar.js +25 -10
  4. package/package.json +7 -4
  5. package/server/README.md +1 -1
  6. package/server/claude-worker/claude-session-process.mjs +981 -44
  7. package/server/claude-worker/claude-session-worker.mjs +45 -4
  8. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  9. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  10. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  11. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  12. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +101 -12
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +2269 -492
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  16. package/server/db-schema.mjs +20 -0
  17. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  18. package/server/public/app/api-client.js +22 -2
  19. package/server/public/app/ask-user-view.js +102 -13
  20. package/server/public/app/attachments-view.js +37 -2
  21. package/server/public/app/background-task-timeout-options.mjs +9 -0
  22. package/server/public/app/background-tasks-view.mjs +33 -18
  23. package/server/public/app/bootstrap.js +34 -8
  24. package/server/public/app/composer-control-state.mjs +52 -34
  25. package/server/public/app/conversation-draft-sync.mjs +187 -0
  26. package/server/public/app/conversation-list-filter.mjs +86 -0
  27. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  28. package/server/public/app/conversation-view.js +919 -194
  29. package/server/public/app/emoji-view.js +2 -0
  30. package/server/public/app/font-scaling.js +4 -37
  31. package/server/public/app/journal-view.js +145 -5
  32. package/server/public/app/model-selector-options.mjs +1 -1
  33. package/server/public/app/question-multi-select.mjs +52 -0
  34. package/server/public/app/relay-prompt-sanitizer.mjs +17 -2
  35. package/server/public/app/settings-modal.js +8 -6
  36. package/server/public/app/socket-handlers.js +19 -0
  37. package/server/public/app/steered-turn-merge.mjs +100 -0
  38. package/server/public/app/store.js +126 -15
  39. package/server/public/app/sync-outbox.mjs +16 -8
  40. package/server/public/app/tool-activity-emoji.mjs +36 -0
  41. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  42. package/server/public/index.html +206 -9
  43. package/server/public/sw.js +7 -0
  44. package/server/relay-tools.md +2 -0
  45. package/server/repositories/message-repository.mjs +61 -18
  46. package/server/repositories/question-repository.mjs +15 -0
  47. package/server/repositories/session-repository.mjs +7 -31
  48. package/server/routes/messages-routes.mjs +488 -46
  49. package/server/routes/sessions-routes.mjs +298 -55
  50. package/server/server-runtime.mjs +65 -25
  51. package/server/services/live-turn-picker.mjs +47 -0
  52. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  53. package/server/services/relay-question-context.mjs +46 -0
  54. package/server/services/sdk-session-import-service.mjs +49 -7
  55. package/server/services/session-worker-registry-service.mjs +75 -0
  56. package/server/services/session-worker-supervisor-service.mjs +7 -1
  57. package/server/services/session-worker-websocket-service.mjs +23 -1
  58. package/server/services/steer-resend.mjs +45 -0
  59. package/shared/background-task-timeout.mjs +12 -4
  60. package/shared/control-poller.mjs +4 -1
  61. package/shared/question-multi-select.mjs +16 -0
  62. package/shared/relay-stop-failure.mjs +15 -0
  63. package/shared/slash-command-guard.mjs +76 -0
  64. package/shared/steer-note.mjs +66 -0
  65. package/shared/steer-settle-failure.mjs +89 -0
  66. package/shared/steer-settle-markers.mjs +18 -0
  67. package/shared/worker-crash-guard.mjs +40 -25
  68. package/shared/worker-runtime/heartbeat.mjs +55 -1
  69. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  70. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  71. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  72. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  73. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  74. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  75. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  76. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  77. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  78. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  79. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  80. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  81. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  82. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  83. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  84. package/server/routes/messages-routes-test-harness.mjs +0 -111
  85. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  86. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  87. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  88. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  89. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  90. package/server/services/fixtures/cli-install-stub.sh +0 -4
  91. package/server/services/fixtures/grok-stub.cmd +0 -1
  92. package/server/services/fixtures/grok-stub.mjs +0 -89
  93. package/server/services/fixtures/grok-stub.sh +0 -4
  94. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -0,0 +1,47 @@
1
+ // Which of a conversation's processing queue rows is the LIVE turn.
2
+ //
3
+ // Mid-turn steering means a conversation can have more than one row in
4
+ // `processing` at once: the running turn plus a message the user steered in,
5
+ // which the CLI folds into that turn and which produces no stream or activity
6
+ // of its own — or, after a replay handoff, the row that just took the turn
7
+ // over while the handed-off row is still settling. The live turn is the row
8
+ // that produced output MOST RECENTLY; with no output to separate them, the
9
+ // oldest processing row (the turn a steered message joins). Kept pure so the
10
+ // tie-break rules are testable without a DB.
11
+ //
12
+ // Recency, not volume: relay_stream_events.seq is only unique within one
13
+ // queue_message_id, so comparing it across rows picked "the row with the most
14
+ // events" — a long handed-off turn kept the live bubble (and Stop) from the
15
+ // row that had actually taken over.
16
+
17
+ /**
18
+ * @param {Array<{id:string, lastOutputAtMs?:number, streamDone?:boolean, processingAtMs?:number}>} rows
19
+ * Processing rows for one conversation. `lastOutputAtMs` is when the row
20
+ * last wrote a stream snapshot or activity line (0 = never); `streamDone`
21
+ * marks a row whose main stream snapshot is final — a handed-off row
22
+ * settling, whose closing snapshot re-stamps it as the newest write — and
23
+ * ranks it below every row still open; `processingAtMs` is when it entered
24
+ * processing (final tie-break; smaller = older = wins).
25
+ * @returns {string} the live turn's row id, or '' when there are no rows.
26
+ */
27
+ export function pickLiveTurnRowId(rows = []) {
28
+ const list = Array.isArray(rows) ? rows.filter((row) => row && row.id) : [];
29
+ if (!list.length) return '';
30
+ if (list.length === 1) return String(list[0].id);
31
+ const finite = (value) => (Number.isFinite(Number(value)) ? Number(value) : 0);
32
+ let best = null;
33
+ for (const row of list) {
34
+ if (!best) { best = row; continue; }
35
+ if (Boolean(row.streamDone) !== Boolean(best.streamDone)) {
36
+ if (!row.streamDone) best = row;
37
+ continue;
38
+ }
39
+ const output = finite(row.lastOutputAtMs);
40
+ const bestOutput = finite(best.lastOutputAtMs);
41
+ if (output > bestOutput
42
+ || (output === bestOutput && finite(row.processingAtMs) < finite(best.processingAtMs))) {
43
+ best = row;
44
+ }
45
+ }
46
+ return String(best.id);
47
+ }
@@ -135,6 +135,21 @@ const MEDIA_EMBED_BLOCK_PATTERN = new RegExp(
135
135
  'gi',
136
136
  );
137
137
 
138
+ // A steered message's hidden note (shared/steer-note.mjs) leads the prompt the
139
+ // runtime stored, right after any mode marker. Matched by its stable opening
140
+ // words, because the browser copy of this module cannot import shared/.
141
+ const STEER_NOTE_PATTERN = /^\s*\[Sent while you were still working on my previous message\.[^\]\n]*\]\s*/;
142
+
143
+ // The Claude worker's slash-command guard (shared/slash-command-guard.mjs): a
144
+ // zero-width space in front of a "/x" it delivers as text. trim() keeps it, so
145
+ // it is matched on its own, and only where it guards a "/".
146
+ const SLASH_COMMAND_GUARD_PATTERN = /^\u200B(?=\s*\/)/;
147
+
148
+ function stripLeadingRelayNotes(text) {
149
+ const stripped = String(text || '').replace(STEER_NOTE_PATTERN, '').replace(SLASH_COMMAND_GUARD_PATTERN, '');
150
+ return stripped.trim() ? stripped.trim() : String(text || '').trim();
151
+ }
152
+
138
153
  export function stripRelayPromptContext(text, relayMode = '', attachments = []) {
139
154
  const value = stripAttachmentPromptArtifacts(text)
140
155
  .replace(MEDIA_EMBED_BLOCK_PATTERN, '')
@@ -143,7 +158,7 @@ export function stripRelayPromptContext(text, relayMode = '', attachments = [])
143
158
  const patterns = buildPromptPrefixPatterns(relayMode);
144
159
  for (const pattern of patterns) {
145
160
  const stripped = value.replace(pattern, '').trim();
146
- if (stripped && stripped !== value) return stripped;
161
+ if (stripped && stripped !== value) return stripLeadingRelayNotes(stripped);
147
162
  }
148
- return value;
163
+ return stripLeadingRelayNotes(value);
149
164
  }
@@ -0,0 +1,46 @@
1
+ // The `context` block a relay question card carries: who asked, why, for which
2
+ // queue row, and the few presentation hints the card renders from. Workers
3
+ // send it on POST /api/relay-question; it is stored inside the question's
4
+ // request envelope and served back to the client. Anything not listed here is
5
+ // dropped, so a new hint the client reads must be added here too (the
6
+ // multi-select flag was once silently stripped this way).
7
+
8
+ /**
9
+ * @param {unknown} rawContext
10
+ * @param {{ normalizeRelayMode?: (mode: string) => string | null, defaultRelayMode?: string }} [options]
11
+ */
12
+ export function sanitizeRelayQuestionContext(rawContext, {
13
+ normalizeRelayMode = (mode) => String(mode || '').trim().toLowerCase() || null,
14
+ defaultRelayMode = 'agent',
15
+ } = {}) {
16
+ if (!rawContext || typeof rawContext !== 'object' || Array.isArray(rawContext)) return null;
17
+ const context = {};
18
+ if (typeof rawContext.source === 'string' && rawContext.source.trim()) {
19
+ context.source = rawContext.source.trim().slice(0, 64);
20
+ }
21
+ if (typeof rawContext.rationale === 'string' && rawContext.rationale.trim()) {
22
+ context.rationale = rawContext.rationale.trim().slice(0, 240);
23
+ }
24
+ if (typeof rawContext.queueMessageId === 'string' && rawContext.queueMessageId.trim()) {
25
+ context.queueMessageId = rawContext.queueMessageId.trim();
26
+ }
27
+ if (typeof rawContext.conversationId === 'string' && rawContext.conversationId.trim()) {
28
+ context.conversationId = rawContext.conversationId.trim();
29
+ }
30
+ if (typeof rawContext.relayMode === 'string' && rawContext.relayMode.trim()) {
31
+ context.relayMode = normalizeRelayMode(rawContext.relayMode) || defaultRelayMode;
32
+ }
33
+ // The card renders checkmarks instead of one-shot buttons when several
34
+ // answers are allowed (Claude's AskUserQuestion `multiSelect`, or the
35
+ // Copilot worker's wording heuristic). Only an explicit `true` is kept.
36
+ if (rawContext.multiSelect === true) context.multiSelect = true;
37
+ // Providers whose ask tool cannot always say so (Copilot) mark their choice
38
+ // cards with this: the card then offers a "Select several" switch, so the
39
+ // user can pick several answers even when the model did not flag it.
40
+ if (rawContext.allowMultiSelect === true) context.allowMultiSelect = true;
41
+ // Claude's short question label ("Workers", "Visibility").
42
+ if (typeof rawContext.header === 'string' && rawContext.header.trim()) {
43
+ context.header = rawContext.header.trim().slice(0, 120);
44
+ }
45
+ return Object.keys(context).length ? context : null;
46
+ }
@@ -77,7 +77,7 @@ export function createSdkSessionImportService({
77
77
  logger = console,
78
78
  } = {}) {
79
79
  if (!db || !stmts || typeof createClient !== 'function') throw new Error('SDK session importer requires database, statements, and a client factory');
80
- let runtime = null;
80
+ let runtimeCreation = null;
81
81
  let activeRun = null;
82
82
  let closing = false;
83
83
  const countConversationMessages = db.prepare(`
@@ -149,8 +149,18 @@ export function createSdkSessionImportService({
149
149
  // Refusing after dispose() is what stops a mid-shutdown import from
150
150
  // resurrecting a fresh SDK client the server would never tear down.
151
151
  if (closing) throw new Error('SDK session importer is shutting down');
152
- if (!runtime) runtime = await createClient();
153
- return runtime;
152
+ // Callers share the start in flight: parallel deletes, or a delete during
153
+ // the startup import, each started a runtime of their own, and only the
154
+ // last one was ever disposed. A start that fails is dropped, so the next
155
+ // call tries again.
156
+ if (!runtimeCreation) {
157
+ const creation = Promise.resolve().then(() => createClient());
158
+ runtimeCreation = creation;
159
+ creation.catch(() => {
160
+ if (runtimeCreation === creation) runtimeCreation = null;
161
+ });
162
+ }
163
+ return runtimeCreation;
154
164
  }
155
165
 
156
166
  function isTombstoned(sdkSessionId) {
@@ -329,6 +339,34 @@ export function createSdkSessionImportService({
329
339
  return activeRun;
330
340
  }
331
341
 
342
+ /**
343
+ * Remove a Copilot session from the runtime's own store, for a conversation
344
+ * deleted in the relay. Goes through the SDK rather than the session-state
345
+ * folder so the runtime's bookkeeping stays consistent. Throws when the
346
+ * runtime cannot do it; the caller decides what that means.
347
+ */
348
+ async function deleteSession(sdkSessionId) {
349
+ const sid = text(sdkSessionId);
350
+ if (!sid) throw new Error('Missing session id');
351
+ const client = await getRuntime();
352
+ if (typeof client?.client?.deleteSession !== 'function') {
353
+ throw new Error('deleteSession() is unavailable in this Copilot runtime');
354
+ }
355
+ await client.client.deleteSession(sid);
356
+ }
357
+
358
+ /**
359
+ * Whether the relay only imported this session and never ran it: the ledger
360
+ * says the importer brought it in, and no queued turn or relay worker says
361
+ * the relay has run it since. Continuing an imported conversation does not
362
+ * touch the ledger, so that evidence is checked here too, and it flips the
363
+ * ledger to 'relay' for good, as it does on import.
364
+ */
365
+ function isImportedOnly(sdkSessionId) {
366
+ const sid = text(sdkSessionId);
367
+ return !!sid && !relayOwnsSession(sid);
368
+ }
369
+
332
370
  async function refreshConversation(conversation) {
333
371
  const sdkSessionId = text(conversation?.sdk_session_id || conversation?.sdkSessionId || conversation?.id);
334
372
  if (!sdkSessionId || isTombstoned(sdkSessionId)) {
@@ -343,14 +381,18 @@ export function createSdkSessionImportService({
343
381
  runStartupImport,
344
382
  importSession,
345
383
  refreshConversation,
384
+ deleteSession,
385
+ isImportedOnly,
346
386
  async dispose() {
347
387
  // Flag first: getRuntime() must refuse before the client goes away, or a
348
- // concurrent import observes runtime = null and creates a replacement
349
- // while the server is exiting.
388
+ // concurrent import finds no runtime and starts a replacement while the
389
+ // server is exiting.
350
390
  closing = true;
351
391
  try { await activeRun; } catch {}
352
- const current = runtime;
353
- runtime = null;
392
+ // A start still in flight is waited for, so its runtime is disposed too.
393
+ const creation = runtimeCreation;
394
+ runtimeCreation = null;
395
+ const current = await creation?.catch(() => null);
354
396
  await current?.dispose?.();
355
397
  },
356
398
  };
@@ -60,6 +60,54 @@ function normalizeStatus(value) {
60
60
  return ALLOWED_STATUSES.has(mapped) ? mapped : 'new';
61
61
  }
62
62
 
63
+ const STEERING_HOLD_REASONS = new Set(['question', 'compaction', 'adoption', 'delivery', 'other']);
64
+ const STEERING_MESSAGE_ID_MAX_LENGTH = 64;
65
+ const STEERING_CANCELLABLE_IDS_MAX = 50;
66
+
67
+ // Queue message ids the worker still holds pushed-but-unconsumed (the Copilot
68
+ // SDK's queued lane): trimmed strings only, deduped, capped — a worker cannot
69
+ // grow the status payload without bound.
70
+ function normalizeSteeringCancellableIds(raw) {
71
+ if (!Array.isArray(raw)) return [];
72
+ const seen = new Set();
73
+ const ids = [];
74
+ for (const value of raw) {
75
+ if (typeof value !== 'string') continue;
76
+ const id = value.trim();
77
+ if (!id || id.length > STEERING_MESSAGE_ID_MAX_LENGTH || seen.has(id)) continue;
78
+ seen.add(id);
79
+ ids.push(id);
80
+ if (ids.length >= STEERING_CANCELLABLE_IDS_MAX) break;
81
+ }
82
+ return ids;
83
+ }
84
+
85
+ // The worker's composer-facing steering snapshot, published on its heartbeat.
86
+ // Callers follow the registry convention of spreading the existing entry into
87
+ // upserts, so an update without `steering` keeps the last known snapshot.
88
+ // Exported so the heartbeat route compares apples to apples: normalizing there
89
+ // with different rules would make an unknown holdReason never converge and
90
+ // re-upsert on every heartbeat.
91
+ //
92
+ // `supported` is the worker's opt-in to mid-turn steering and the client
93
+ // composer's only steering gate: a worker that never sets it — the extension
94
+ // path, or a Claude worker old enough to send the 4-field snapshot — keeps
95
+ // "Queue". `cancellableIds` lists the pushed rows the worker can still pull
96
+ // back out of its runtime (un-steer).
97
+ export function normalizeWorkerSteeringSnapshot(raw) {
98
+ if (!raw || typeof raw !== 'object') return null;
99
+ const holdReason = String(raw.holdReason || '').trim().toLowerCase();
100
+ const messageId = String(raw.messageId || '').trim().slice(0, STEERING_MESSAGE_ID_MAX_LENGTH);
101
+ return {
102
+ turnActive: raw.turnActive === true,
103
+ canSteer: raw.canSteer === true,
104
+ holdReason: STEERING_HOLD_REASONS.has(holdReason) ? holdReason : null,
105
+ messageId: messageId || null,
106
+ supported: raw.supported === true,
107
+ cancellableIds: normalizeSteeringCancellableIds(raw.cancellableIds),
108
+ };
109
+ }
110
+
63
111
  function sanitizeState(raw = null) {
64
112
  const nowIso = new Date().toISOString();
65
113
  const base = raw && typeof raw === 'object' ? raw : {};
@@ -74,11 +122,23 @@ function sanitizeState(raw = null) {
74
122
  queueDepth: toNonNegativeInt(base.queueDepth, 0),
75
123
  retryCount: toNonNegativeInt(base.retryCount, 0),
76
124
  lastError: String(base.lastError || '').trim() || null,
125
+ steering: normalizeWorkerSteeringSnapshot(base.steering),
77
126
  updatedAt: normalizeTimestamp(base.updatedAt, nowIso),
78
127
  createdAt,
79
128
  };
80
129
  }
81
130
 
131
+ // Statuses that mean the process a snapshot described is gone or not yet
132
+ // replaced by one that has reported. 'error' counts only on the way IN: a
133
+ // live worker marked errored (a requeue retry) keeps heartbeating, and its
134
+ // next snapshot must stick rather than be cleared on every upsert.
135
+ const WORKER_GONE_STATUSES = new Set(['new', 'starting', 'stopped', 'error']);
136
+
137
+ function isWorkerReplacedOrGone(existing, next) {
138
+ if (existing.pid && next.pid !== existing.pid) return true;
139
+ return next.status !== existing.status && WORKER_GONE_STATUSES.has(next.status);
140
+ }
141
+
82
142
  function toSnapshot(entry) {
83
143
  return Object.freeze({ ...entry });
84
144
  }
@@ -163,12 +223,26 @@ export function createSessionWorkerRegistry() {
163
223
  createdAt: existing?.createdAt || state.createdAt,
164
224
  updatedAt: new Date().toISOString(),
165
225
  });
226
+ // A steering snapshot describes one live worker process. Callers spread
227
+ // the old entry into every upsert, so without this a crashed worker's
228
+ // hold outlived it and the composer showed it until a replacement
229
+ // heartbeated — indefinitely, if none ever did.
230
+ if (existing && isWorkerReplacedOrGone(existing, next)) next.steering = null;
166
231
  clearIndexesForSession(existing);
167
232
  bySession.set(state.sdkSessionId, next);
168
233
  updateIndexesForSession(next);
169
234
  return toSnapshot(next);
170
235
  }
171
236
 
237
+ /** Drop a session's steering snapshot (its worker process is known dead). */
238
+ function clearSteering(sdkSessionId) {
239
+ const sessionId = normalizeSessionId(sdkSessionId);
240
+ const existing = sessionId ? bySession.get(sessionId) : null;
241
+ if (!existing?.steering) return false;
242
+ bySession.set(sessionId, { ...existing, steering: null, updatedAt: new Date().toISOString() });
243
+ return true;
244
+ }
245
+
172
246
  function removeWorker(sdkSessionId) {
173
247
  const sessionId = normalizeSessionId(sdkSessionId);
174
248
  if (!sessionId) return false;
@@ -224,6 +298,7 @@ export function createSessionWorkerRegistry() {
224
298
  getWorkerByRuntimeSessionId,
225
299
  listWorkers,
226
300
  upsertWorker,
301
+ clearSteering,
227
302
  removeWorker,
228
303
  rekeyWorker,
229
304
  clearAll,
@@ -482,10 +482,16 @@ export function createSessionWorkerSupervisor({
482
482
  if (current && !isTransitionAllowed(current.status, requestedStatus)) {
483
483
  return current;
484
484
  }
485
+ // The steering snapshot is the worker's to set (its heartbeat) and the
486
+ // registry's to clear (process gone or replaced). Callers build nextState
487
+ // from an older copy of the entry — ensureWorker from the 'starting' one,
488
+ // taken before the new process heartbeated — so passing theirs through
489
+ // would overwrite the snapshot the worker sent in the meantime.
490
+ const { steering: _staleSteering, ...patch } = nextState;
485
491
  const next = registry.upsertWorker({
486
492
  ...(current || {}),
487
493
  sdkSessionId: sessionId,
488
- ...nextState,
494
+ ...patch,
489
495
  status: requestedStatus,
490
496
  updatedAt: nowIso(),
491
497
  });
@@ -91,6 +91,13 @@ function queueSnapshotChanged(left, right) {
91
91
  // heartbeats and a retained socket no longer proves the worker is alive.
92
92
  const WORKER_SOCKET_LIVENESS_MAX_SILENCE_MS = 30_000;
93
93
 
94
+ // Protocol features this relay understands, advertised in server.hello. A
95
+ // worker that updated on disk before the relay restarted must not rely on
96
+ // them otherwise: an older relay ignores worker.unready / hello ready:false
97
+ // and treats a `steering-held` requeue as a failure (retry, backoff,
98
+ // worker marked errored).
99
+ export const WORKER_PROTOCOL_CAPABILITIES = Object.freeze(['worker-unready', 'steering-held']);
100
+
94
101
  export function createSessionWorkerWebSocketService({
95
102
  WebSocketServerImpl,
96
103
  httpServer,
@@ -378,6 +385,7 @@ export function createSessionWorkerWebSocketService({
378
385
  emitEvent(ws, {
379
386
  type: 'server.hello',
380
387
  reason: 'connected',
388
+ capabilities: WORKER_PROTOCOL_CAPABILITIES,
381
389
  queue: normalizeQueueSnapshot(queueCounts()),
382
390
  sessionId: identity.sessionId,
383
391
  timestamp: nowIso(),
@@ -396,12 +404,15 @@ export function createSessionWorkerWebSocketService({
396
404
  if (payload.type === 'worker.hello') {
397
405
  if (!bindSocketIdentity(ws, meta, payload)) return;
398
406
  meta.lastSeenAtMs = nowMsSafe();
399
- meta.ready = true;
407
+ // A worker holding deliveries (open question card, compaction) binds
408
+ // its identity with hello but must not be handed work.
409
+ meta.ready = payload.ready !== false;
400
410
  meta.lastHelloAt = nowIso();
401
411
  noteHeartbeat(meta, 'worker-hello');
402
412
  emitEvent(ws, {
403
413
  type: 'server.hello',
404
414
  reason: 'ack',
415
+ capabilities: WORKER_PROTOCOL_CAPABILITIES,
405
416
  queue: normalizeQueueSnapshot(queueCounts()),
406
417
  sessionId: meta.sessionId,
407
418
  timestamp: nowIso(),
@@ -418,6 +429,17 @@ export function createSessionWorkerWebSocketService({
418
429
  void maybeDeliverToSocket(ws, String(payload.reason || 'worker-ready'));
419
430
  return;
420
431
  }
432
+ if (payload.type === 'worker.unready') {
433
+ // Readiness is otherwise sticky until the next delivery: a ready sent
434
+ // while the worker could steer would still stand after a hold began,
435
+ // and the delivery it drew would only be handed back.
436
+ if (!bindSocketIdentity(ws, meta, payload)) return;
437
+ meta.lastSeenAtMs = nowMsSafe();
438
+ meta.ready = false;
439
+ meta.lastUnreadyAt = nowIso();
440
+ noteHeartbeat(meta, String(payload.reason || 'worker-unready'));
441
+ return;
442
+ }
421
443
  if (payload.type === 'worker.ping') {
422
444
  if (!bindSocketIdentity(ws, meta, payload)) return;
423
445
  meta.lastSeenAtMs = nowMsSafe();
@@ -0,0 +1,45 @@
1
+ // Resend of a steer a Stop cut off unanswered (kind='stopped'): shared by the
2
+ // POST /api/message guard and the transcript payload, so "Resent" on screen
3
+ // and the relay's refusal of a second Resend agree.
4
+
5
+ /**
6
+ * Whether an earlier Resend still stands. A cancelled queued message loses
7
+ * its queue row; a failed one keeps it as 'failed'; a finished one may have
8
+ * been pruned from the queue, which its answer (linked by source) proves.
9
+ */
10
+ export function isLiveResend({ hasQueueRow = false, queueStatus = null, answered = false } = {}) {
11
+ if (hasQueueRow) {
12
+ return !['failed', 'cancelled'].includes(String(queueStatus || '').trim().toLowerCase());
13
+ }
14
+ return !!answered;
15
+ }
16
+
17
+ /**
18
+ * Original message id → the id of a Resend of it that still stands, from one
19
+ * conversation's messages (DB rows) and queue rows ({ id, status }).
20
+ */
21
+ export function liveResendsByOriginal(dbMessages = [], queueRows = []) {
22
+ const queueById = new Map((Array.isArray(queueRows) ? queueRows : [])
23
+ .map((row) => [String(row?.id || '').trim(), row]));
24
+ const answeredSources = new Set();
25
+ for (const message of Array.isArray(dbMessages) ? dbMessages : []) {
26
+ if (message?.role !== 'assistant') continue;
27
+ const source = String(message?.source_message_id || '').trim();
28
+ if (source) answeredSources.add(source);
29
+ }
30
+ for (const row of queueById.values()) {
31
+ if (row?.response_message_id) answeredSources.add(String(row.id || '').trim());
32
+ }
33
+ const result = new Map();
34
+ for (const message of Array.isArray(dbMessages) ? dbMessages : []) {
35
+ if (message?.role !== 'user') continue;
36
+ const original = String(message?.resend_of_message_id || '').trim();
37
+ if (!original || result.has(original)) continue;
38
+ const id = String(message?.id || '').trim();
39
+ const queueRow = queueById.get(id);
40
+ if (isLiveResend({ hasQueueRow: !!queueRow, queueStatus: queueRow?.status, answered: answeredSources.has(id) })) {
41
+ result.set(original, id);
42
+ }
43
+ }
44
+ return result;
45
+ }
@@ -2,14 +2,22 @@
2
2
  // persistent Claude CLI process alive, shared so the settings slider and the
3
3
  // worker enforcing it agree on bounds and formatting.
4
4
  //
5
- // 0 means no limit — the default: a process holding live tasks stays up until
6
- // the tasks finish, the user stops them from the composer panel, or the
7
- // worker/relay goes down. Mirrors shared/turn-ceiling.mjs.
5
+ // The default is 4 hours: long enough for real long-running work to finish,
6
+ // short enough that a forgotten shell or agent does not keep its runtime
7
+ // alive indefinitely. 0 (chosen explicitly) means no limit: a process holding
8
+ // live tasks stays up until the tasks finish, the user stops them from the
9
+ // composer panel, or the worker/relay goes down. Mirrors
10
+ // shared/turn-ceiling.mjs.
11
+ //
12
+ // The browser mirrors the default in
13
+ // server/public/app/background-task-timeout-options.mjs (only server/public is
14
+ // served), and the slider's static markup in server/public/index.html starts
15
+ // at it; unit tests pin both to this module.
8
16
 
9
17
  export const BACKGROUND_TASK_TIMEOUT_MIN_MINUTES = 0;
10
18
  export const BACKGROUND_TASK_TIMEOUT_MAX_MINUTES = 600;
11
19
  export const BACKGROUND_TASK_TIMEOUT_STEP_MINUTES = 5;
12
- export const DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES = 0;
20
+ export const DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES = 240;
13
21
 
14
22
  /** 0 disables the timeout entirely (unlimited). */
15
23
  export function normalizeBackgroundTaskTimeoutMinutes(value, fallback = DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES) {
@@ -63,7 +63,10 @@ export function createControlPoller({
63
63
 
64
64
  dbg('abort_turn control received', control.id);
65
65
  try {
66
- await onAbortTurn();
66
+ // The control names the row the user stopped. A runner that owns several
67
+ // rows at once (a turn plus the messages steered into it) needs it to
68
+ // tell the stopped row from the ones it must settle itself.
69
+ await onAbortTurn(control);
67
70
  await api('POST', `/api/control/${encodeURIComponent(control.id)}/result`, {
68
71
  ok: true,
69
72
  note: abortAckNote,
@@ -0,0 +1,16 @@
1
+ // Whether a choice question reads as "pick several" — for providers whose
2
+ // ask-user tool has no multi-select flag (Copilot's `ask_user`; Claude's
3
+ // AskUserQuestion carries `multiSelect: true` itself). The card then offers
4
+ // checkmarks instead of one-shot buttons and answers with the selected labels
5
+ // joined by ", ". Conservative on purpose: an ordinary "which one?" keeps its
6
+ // buttons, because a wrongly multi-select card costs the user a second click
7
+ // while a wrongly single-select one forces them into the free-text field.
8
+ export function looksLikeMultiSelectQuestion(questionText, choices = []) {
9
+ if (!Array.isArray(choices) || choices.length < 2) return false;
10
+ const text = String(questionText || '');
11
+ return /\ball that apply\b/i.test(text)
12
+ || /\bmulti-?select\b/i.test(text)
13
+ || /\b(select|choose|pick|check|tick|enable|turn on)\b[^.!?\n]{0,40}\b(all|multiple|several|any number|one or more|as many|more than one)\b/i.test(text)
14
+ || /\bmultiple (choices|options|selections|answers)\b/i.test(text)
15
+ || /\bone or more\b/i.test(text);
16
+ }
@@ -0,0 +1,15 @@
1
+ // The terminal outcome of a turn the user stopped — what the relay records
2
+ // when a worker acknowledges an abort control. Shared so a worker that dies
3
+ // before its ack lands fails the stopped row the same way instead of letting
4
+ // it be requeued (a requeue re-runs a prompt the user explicitly stopped).
5
+ export function buildRelayStopFailure() {
6
+ return {
7
+ kind: 'turn-aborted',
8
+ error: 'turn-aborted',
9
+ code: 'turn-aborted',
10
+ stableCode: 'relay.turn-aborted',
11
+ message: 'System note: This turn was stopped from the relay UI before completion.',
12
+ guidance: 'Send a new message to continue when you are ready.',
13
+ failedAt: new Date().toISOString(),
14
+ };
15
+ }
@@ -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
+ }