@oar-sh/oar 0.9.12 → 0.9.13

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 (62) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/README.md +9 -9
  3. package/package.json +1 -1
  4. package/server/README.md +186 -11
  5. package/server/claude-worker/claude-session-process.mjs +53 -2
  6. package/server/claude-worker/task-output-discovery.mjs +28 -2
  7. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  8. package/server/copilot-worker/copilot-sdk-session-process.mjs +10 -0
  9. package/server/db-schema.mjs +10 -0
  10. package/server/grok-worker/acp-host-services.mjs +6 -3
  11. package/server/grok-worker/grok-background-tasks.mjs +7 -0
  12. package/server/migrations/0018-background-task-rows.mjs +101 -0
  13. package/server/public/app/action-confirmations.js +12 -15
  14. package/server/public/app/api-client.js +57 -3
  15. package/server/public/app/background-tasks-view.mjs +56 -7
  16. package/server/public/app/bootstrap.js +7 -0
  17. package/server/public/app/conversation-preferences.mjs +10 -4
  18. package/server/public/app/conversation-view.js +33 -9
  19. package/server/public/app/host-suspend-ui.js +45 -44
  20. package/server/public/app/journal-view.js +9 -3
  21. package/server/public/app/push-settings.js +1 -0
  22. package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
  23. package/server/public/app/relay-restart-note-view.mjs +36 -0
  24. package/server/public/app/relay-restart-ui.js +568 -0
  25. package/server/public/app/settings-modal.js +52 -0
  26. package/server/public/app/socket-handlers.js +1 -1
  27. package/server/public/app/task-console-render.mjs +171 -7
  28. package/server/public/app/task-console.mjs +111 -11
  29. package/server/public/app/task-output-text.mjs +15 -6
  30. package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
  31. package/server/public/app/transcript-jump-bottom.mjs +20 -0
  32. package/server/public/index.html +67 -0
  33. package/server/relay-tools.md +5 -0
  34. package/server/repositories/message-repository.mjs +3 -0
  35. package/server/repositories/remote-relay-repository.mjs +4 -2
  36. package/server/routes/messages-routes.mjs +155 -27
  37. package/server/routes/relay-board-routes.mjs +19 -2
  38. package/server/routes/relay-restart-routes.mjs +73 -0
  39. package/server/routes/sessions-routes.mjs +75 -3
  40. package/server/server-runtime.mjs +192 -137
  41. package/server/services/about-service.mjs +13 -12
  42. package/server/services/background-task-persistence.mjs +133 -0
  43. package/server/services/background-task-store.mjs +240 -13
  44. package/server/services/conversation-model-fallback.mjs +57 -0
  45. package/server/services/host-suspend-command.mjs +2 -1
  46. package/server/services/preview-proxy-server.mjs +14 -1
  47. package/server/services/process-list-service.mjs +2 -1
  48. package/server/services/provider-model-selection.mjs +195 -0
  49. package/server/services/push-dispatch-service.mjs +9 -2
  50. package/server/services/relay-event-service.mjs +27 -9
  51. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  52. package/server/services/relay-restart-picture.mjs +209 -0
  53. package/server/services/relay-restart-queue.mjs +380 -0
  54. package/server/services/relay-shutdown-gate.mjs +4 -1
  55. package/server/services/remote-relay-dispatcher.mjs +187 -19
  56. package/server/services/restart-resume-service.mjs +303 -0
  57. package/server/services/session-worker-process-service.mjs +6 -4
  58. package/server/services/task-output-service.mjs +217 -18
  59. package/shared/relay-events.mjs +55 -2
  60. package/shared/remote-relay-contract.mjs +15 -8
  61. package/shared/task-output-text.mjs +196 -83
  62. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -2984,6 +2984,15 @@
2984
2984
  }
2985
2985
  .pending-action-text { overflow-wrap: anywhere; }
2986
2986
  .pending-action-cancel { margin-left: 0; }
2987
+ /* The queued relay restart's row (relay-restart-ui.js): the headline, the
2988
+ sessions it waits for (links), held messages, background tasks, buttons. */
2989
+ .relay-restart-row { flex-direction: column; gap: 3px; }
2990
+ .relay-restart-head { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; }
2991
+ .relay-restart-waited { opacity: 0.85; }
2992
+ .relay-restart-detail { color: var(--text); overflow-wrap: anywhere; max-height: 4.5em; overflow-y: auto; }
2993
+ .relay-restart-session-link { color: inherit; text-decoration: underline; }
2994
+ .relay-restart-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; }
2995
+ .relay-restart-row[data-asking="1"] .relay-restart-head { font-weight: 600; }
2987
2996
  .suspend-host-blockers {
2988
2997
  margin: 6px 0 10px;
2989
2998
  padding-left: 20px;
@@ -3438,6 +3447,26 @@
3438
3447
  font-size: var(--fs-meta-sm); text-decoration: underline; text-underline-offset: 3px;
3439
3448
  }
3440
3449
  #task-console-stop:hover:not(:disabled) { border-color: #f85149; color: #f85149; }
3450
+ /* "Load earlier" within reach while following the end; the one at the top
3451
+ of the output stays. */
3452
+ #task-console-earlier-top {
3453
+ background: none; border: 0; padding: 0; color: var(--accent); cursor: pointer;
3454
+ font-size: var(--fs-meta-sm); text-decoration: underline; text-underline-offset: 3px;
3455
+ }
3456
+ #task-console-earlier-top:disabled { color: var(--muted); text-decoration: none; cursor: default; }
3457
+ #task-console-earlier-top[hidden] { display: none; }
3458
+ /* Where the output of a command piped into tail stops until it ends. */
3459
+ #task-console-hold {
3460
+ margin: 8px 0 0; padding: 6px 10px; border-left: 3px solid var(--warn); border-radius: 4px;
3461
+ background: var(--bg2); color: var(--text); font-size: var(--fs-meta-sm);
3462
+ }
3463
+ #task-console-hold[hidden] { display: none; }
3464
+ .tc-hold-text { font-weight: 600; }
3465
+ .tc-hold-hint { color: var(--muted); margin-top: 2px; }
3466
+ /* Wider screens: the console ends above the composer's open task row, so
3467
+ the row's chip, clock and Stop stay in view (task-console.mjs sets the
3468
+ bottom). Phones keep the full-screen console. */
3469
+ #task-console-modal.tc-above-panel .task-console-dialog { height: 100%; }
3441
3470
  #task-console-scroll {
3442
3471
  flex: 1; overflow: auto; overscroll-behavior: contain; padding: 8px 14px 28px;
3443
3472
  background: var(--bg);
@@ -3496,6 +3525,9 @@
3496
3525
  .tc-bg-13 { background: var(--tc-ansi-13); }
3497
3526
  .tc-bg-14 { background: var(--tc-ansi-14); }
3498
3527
  .tc-bg-15 { background: var(--tc-ansi-15); }
3528
+ /* Inverse text (SGR 7) with a side left unset: the console's own colours, swapped. */
3529
+ .tc-fg-inverse { color: var(--bg); }
3530
+ .tc-bg-inverse { background: var(--text); }
3499
3531
  #task-console-status { color: var(--muted); padding: 10px 0; font-size: var(--fs-meta-sm); }
3500
3532
  #task-console-status[hidden] { display: none; }
3501
3533
  #task-console-jump {
@@ -3712,6 +3744,8 @@
3712
3744
  .relay-event-time { flex: 0 0 auto; }
3713
3745
  .relay-event-lines { margin: 2px 0 4px; padding-left: 18px; }
3714
3746
  .relay-event-line { margin: 2px 0; overflow-wrap: anywhere; white-space: pre-wrap; }
3747
+ /* The relay's note to carry on after a restart (relay-restart-note-view.mjs). */
3748
+ .relay-restart-note-text { padding-left: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
3715
3749
  .relay-event-line.relay-event-question-asked,
3716
3750
  .relay-event-line.relay-event-error,
3717
3751
  .relay-event-line.relay-event-task-failed,
@@ -7229,6 +7263,30 @@
7229
7263
  >
7230
7264
  </div>
7231
7265
  <div class="settings-help" style="margin-top: 6px;">A shell or Monitor task that has written no output for this long gets an amber "no output for N min" note in the composer panel and its console.</div>
7266
+ <div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
7267
+ <label for="restart-resume-toggle">After a relay restart, tell interrupted sessions to carry on</label>
7268
+ <input
7269
+ id="restart-resume-toggle"
7270
+ type="checkbox"
7271
+ onchange="updateRestartResumeSettingFromToggle(this.checked)"
7272
+ aria-label="After a relay restart, tell interrupted sessions to carry on"
7273
+ >
7274
+ </div>
7275
+ <div class="settings-help" style="margin-top: 6px;">Most turns a restart interrupts run again by themselves. A turn that cannot (the agent had already taken the message, or had started on its own after a background task) just stops. With this on, such a session gets one relay note once the relay is back, saying when it restarted and asking the agent to check what it was doing and carry on. Your message is never sent again; idle, archived and usage-limit-paused sessions get nothing. Off by default; a setting of this relay.</div>
7276
+ <div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
7277
+ <label for="relay-restart-max-wait-select">Ask after a queued relay restart has waited</label>
7278
+ <select id="relay-restart-max-wait-select" onchange="updateRelayRestartMaxWaitSetting(this.value)" aria-label="Ask after a queued relay restart has waited">
7279
+ <option value="5">5 min</option>
7280
+ <option value="10">10 min</option>
7281
+ <option value="15">15 min</option>
7282
+ <option value="30" selected>30 min</option>
7283
+ <option value="60">1 h</option>
7284
+ <option value="120">2 h</option>
7285
+ <option value="240">4 h</option>
7286
+ <option value="0">No limit</option>
7287
+ </select>
7288
+ </div>
7289
+ <div class="settings-help" style="margin-top: 6px;">A queued relay restart waits for the running turns, and new messages wait for the restart. After this long the banner asks whether to restart now, keep waiting or cancel; it never restarts while the turns run, and still restarts as soon as they end. No limit waits until the turns end.</div>
7232
7290
  <div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
7233
7291
  <label for="subagent-record-retention-select">Keep sub-agent tool output</label>
7234
7292
  <select id="subagent-record-retention-select" onchange="updateSubagentRecordRetentionSetting(this.value)" aria-label="Keep sub-agent tool output">
@@ -8073,6 +8131,10 @@
8073
8131
  <label for="push-event-host-suspend-toggle">Host suspend fired or was dropped</label>
8074
8132
  <input id="push-event-host-suspend-toggle" type="checkbox" onchange="updatePushPreferencesFromControls()" checked disabled>
8075
8133
  </div>
8134
+ <div class="settings-row" style="width: 100%; margin-top: 6px; margin-bottom: 0;">
8135
+ <label for="push-event-relay-restart-toggle">Queued relay restart waited its maximum time</label>
8136
+ <input id="push-event-relay-restart-toggle" type="checkbox" onchange="updatePushPreferencesFromControls()" checked disabled>
8137
+ </div>
8076
8138
  <label style="margin-top: 10px;">Devices</label>
8077
8139
  <div id="push-device-list" style="width: 100%;"></div>
8078
8140
  <div class="settings-help">Notifications are only sent while no device has the app in the foreground. Content defaults to generic text; message previews are opt-in per device.</div>
@@ -8251,11 +8313,16 @@
8251
8313
  <span id="task-console-stall" class="tc-stall" hidden></span>
8252
8314
  <button id="task-console-chip" class="bg-task-chip" type="button" hidden></button>
8253
8315
  <button id="task-console-failure" type="button" hidden>first failure</button>
8316
+ <button id="task-console-earlier-top" type="button" hidden>Load earlier</button>
8254
8317
  </div>
8255
8318
  </div>
8256
8319
  <div id="task-console-scroll">
8257
8320
  <button id="task-console-earlier" type="button" hidden>Load earlier</button>
8258
8321
  <pre id="task-console-body"></pre>
8322
+ <div id="task-console-hold" role="note" hidden>
8323
+ <div class="tc-hold-text"></div>
8324
+ <div class="tc-hold-hint"></div>
8325
+ </div>
8259
8326
  <div id="task-console-status" hidden></div>
8260
8327
  </div>
8261
8328
  <button id="task-console-jump" type="button" hidden>Jump to end ↓</button>
@@ -22,6 +22,8 @@ Note: shutdown is queued and only completes when the current turn finishes, so i
22
22
 
23
23
  Use `restart: true` in the request body when the user wants a real relay restart rather than a plain shutdown. Example request body: `{ "reason": "manual-restart", "requestedBy": "localhost-api", "restart": true }`.
24
24
 
25
+ Never pass `immediate` in that request body: it restarts without waiting for the running turns, which only the user may decide, with Restart now in the web UI.
26
+
25
27
  ## Preview servers
26
28
 
27
29
  Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close existing previews. Use this whenever the user wants to see, try, or share something with a web UI — never ask them to open localhost or forward a port. For a dev server: call this FIRST with {action:"create", port} to get the basePath, then start the server configured to serve under that basePath (Vite --base, Next basePath, Express mount prefix); the proxy forwards X-Forwarded-Prefix but does not rewrite bodies, so root-absolute asset paths will not work. For plain files or a build output: pass {action:"create", dir} instead and the relay serves the directory itself — no dev server needed. Always tell the user the returned URL and that the link is public: anyone who has it can reach the app without logging in. Publishing the same port or directory again in this conversation returns the same link. Previews never expire and survive a relay restart (a port preview until the machine reboots); close them with {action:"close"} when the user is done.
@@ -39,9 +41,12 @@ This relay itself is listed with "self": true when its owner switched agent sess
39
41
  - `create_session` starts a session with any provider that `relay_info` lists: a local one in a folder ("cwd"), or provider "claude-cloud" with "repo" (a GitHub URL or owner/repo) and an optional "branch". At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
40
42
  - The first `create_session` on this relay shows the user an approval card, once per conversation. A session that an agent created cannot create sessions itself (one level only).
41
43
  - Sessions you start here are grouped under this session in the user's sidebar. `list_sessions` lists in the sidebar's order (a group sorts by its newest session, head first) and shows the group as a "group" field (id, name, role); `read_session` adds the group's live members with their state. Agents only read groups, the user arranges them.
44
+ - A "model" for `create_session` or `send` must be an id `relay_info` lists for the provider (Claude also takes "opus", "sonnet", "haiku"). An unknown id is refused with the close matches ("unknown model 'gpt-5-4' for Copilot; did you mean 'gpt-5.4'?") and nothing is created or sent: pick from the list instead of guessing. If the answer has a "modelNote" saying the model could not be checked, the relay had no model list yet.
42
45
  - A new session knows nothing of this conversation: write a self-contained prompt. A Claude Cloud session works on a fresh clone in a sandbox at Anthropic, so name the branch it should work on and push.
43
46
  - Follow each session with `wait` (the message id comes back from `create_session` and `send`) and `read_session`; "wait_seconds" is limited to the maximum set on the relay (default 600).
44
47
  - Sessions run on the relay owner's accounts and cost their usage: start only what the task needs.
45
48
  - `read_task_output` ("session", "task_id" from `read_session`'s tasks, optional "lines") reads the end of a background shell or Monitor task's output, also for an hour after it ended: the last lines, minutes since its last output, and pass/fail counts for a test run. Credentials are scrubbed out.
46
49
 
50
+ On this relay and on a paired one, `subscribe` wakes you when something happens in the sessions you watch, instead of polling them. Leave "events" out unless you need more: the default (question_asked, error, turn_end, subagent_finished, task_failed, stalled, all_idle) wakes you for questions, errors, replies, finished sub-agents, failed tasks (a non-zero exit included), stalls and all_idle, but not for each shell command that ended well. To get those too, pass that list plus "task_finished" (and "task_started" for the starts); each one costs you a turn, so do it only if you must follow every command.
51
+
47
52
  Write actions may first wait for the user's approval. If a result lists `pendingQuestions`, the other agent is waiting for an answer: ask the user (unless they told you to decide), then pass it on with `answer_question`. Always tell the user which relay and session you worked on.
@@ -37,6 +37,9 @@ export function createMessageRepository(db) {
37
37
  listPinnedMessages: messagePins.listPinnedMessages,
38
38
  countPinnedMessages: messagePins.countPinnedMessages,
39
39
  getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
40
+ // The model of the conversation's latest user turn: one of the
41
+ // fallbacks for a send that names no model.
42
+ getLatestUserMessageModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND role = 'user' AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
40
43
  getRecentMessagesDesc: db.prepare(`SELECT id, role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
41
44
  insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
42
45
  // Provenance is derived from the authenticated responder identity at
@@ -27,7 +27,9 @@ function parseOrigin(json) {
27
27
 
28
28
  export function createRemoteRelayRepository(db) {
29
29
  const hasUnlock = db.prepare(`SELECT 1 FROM conversation_remote_relay_unlocks WHERE conversation_id = ? AND remote_relay_id = ?`);
30
- const listUnlocks = db.prepare(`SELECT remote_relay_id, message_id, created_at FROM conversation_remote_relay_unlocks WHERE conversation_id = ? ORDER BY created_at ASC`);
30
+ // Two unlocks can share a millisecond (Windows' clock is coarse); the rowid
31
+ // keeps them in the order they were recorded rather than by relay id.
32
+ const listUnlocks = db.prepare(`SELECT remote_relay_id, message_id, created_at FROM conversation_remote_relay_unlocks WHERE conversation_id = ? ORDER BY created_at ASC, rowid ASC`);
31
33
  const insertUnlock = db.prepare(`INSERT OR IGNORE INTO conversation_remote_relay_unlocks (conversation_id, remote_relay_id, message_id, created_at) VALUES (?, ?, ?, ?)`);
32
34
  // The relay's unlocks and the approvals stored under its id (`<id>:watch`).
33
35
  const deleteUnlocksForRelay = db.prepare(`
@@ -64,7 +66,7 @@ export function createRemoteRelayRepository(db) {
64
66
  hasUnlock(conversationId, remoteRelayId) {
65
67
  return !!hasUnlock.get(String(conversationId || ''), String(remoteRelayId || ''));
66
68
  },
67
- /** `[{ remoteRelayId, messageId, createdAt }]`, oldest first. */
69
+ /** `[{ remoteRelayId, messageId, createdAt }]`, oldest first, ties in the order recorded. */
68
70
  listUnlocks(conversationId) {
69
71
  return listUnlocks.all(String(conversationId || '')).map((row) => ({
70
72
  remoteRelayId: row.remote_relay_id,
@@ -16,6 +16,7 @@ import {
16
16
  import {
17
17
  persistConversationModelPreference as persistConversationModelPreferenceTx,
18
18
  } from '../services/conversation-preferences-service.mjs';
19
+ import { fallbackReasoningEffort, inheritedConversationModel } from '../services/conversation-model-fallback.mjs';
19
20
  import { killTmuxSession } from '../services/session-worker-launch-service.mjs';
20
21
  import { normalizeWorkerSteeringSnapshot } from '../services/session-worker-registry-service.mjs';
21
22
  import {
@@ -45,6 +46,8 @@ import { normalizeBackgroundTaskDigest } from '../../shared/worker-runtime/backg
45
46
  import {
46
47
  DEFAULT_TASK_STALL_MINUTES,
47
48
  TASK_OUTPUT_LIMITS,
49
+ cleanTaskText,
50
+ normalizeTaskCommand,
48
51
  normalizeTaskOutputKind,
49
52
  normalizeTaskOutputPath,
50
53
  } from '../../shared/task-output-text.mjs';
@@ -68,6 +71,7 @@ import { createStaleAttemptLogLimiter } from '../services/stale-attempt-log-limi
68
71
  import { createSubagentTimelinePublisher } from '../services/subagent-viewer-rooms.mjs';
69
72
  import { createLocalRequestCheck } from '../services/request-boundary.mjs';
70
73
  import { buildClaudeCloudDelivery, isClaudeCloudProviderType } from '../services/claude-cloud-session-service.mjs';
74
+ import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
71
75
  import {
72
76
  admitRemoteRelayRequest,
73
77
  readRemoteRelayRequest,
@@ -244,9 +248,17 @@ function findRecentDuplicateUserMessage({
244
248
  const exempt = new Set((Array.isArray(exemptMessageIds) ? exemptMessageIds : [])
245
249
  .map((id) => String(id || '').trim()).filter(Boolean));
246
250
  const isExempt = (row) => exempt.has(String(row?.id || '').trim());
251
+ // A message whose turn failed never ran, so sending it again is a retry,
252
+ // not a double submit. Only failed rows: a cancelled or finished turn stays
253
+ // a duplicate, as before.
254
+ const failedTurnIds = new Set((Array.isArray(recentQueueRows) ? recentQueueRows : [])
255
+ .filter((row) => String(row?.status || '').trim().toLowerCase() === 'failed')
256
+ .map((row) => String(row?.id || '').trim())
257
+ .filter(Boolean));
247
258
 
248
259
  for (const row of Array.isArray(recentUserMessages) ? recentUserMessages : []) {
249
260
  if (isExempt(row)) continue;
261
+ if (failedTurnIds.has(String(row?.id || '').trim())) continue;
250
262
  const rowText = normalizeDuplicateMessageText(row?.text || '');
251
263
  if (!rowText || rowText !== fingerprint) continue;
252
264
  const rowTime = Date.parse(row?.timestamp || '');
@@ -1792,6 +1804,9 @@ export function registerMessagesRoutes(app, deps) {
1792
1804
  taskOutputSnapshots = null,
1793
1805
  // Has the health poller read soon (a task with a console ended).
1794
1806
  pokeTaskHealth = null,
1807
+ // What a recovery did with a turn that was running when the relay
1808
+ // started (services/restart-resume-service.mjs noteOutcome).
1809
+ noteRestartTurnOutcome = () => {},
1795
1810
  getTaskStallMinutes = () => DEFAULT_TASK_STALL_MINUTES,
1796
1811
  sendWorkerControl,
1797
1812
  ensureSessionId,
@@ -2727,10 +2742,12 @@ export function registerMessagesRoutes(app, deps) {
2727
2742
  // quietly.
2728
2743
  stmts.dropStaleContinuation?.run?.(row.id);
2729
2744
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
2745
+ noteRestartTurnOutcome(row, 'cut');
2730
2746
  continue;
2731
2747
  }
2732
2748
  if (row.consumed_at) {
2733
2749
  failConsumedQueueRow(row, { reason });
2750
+ noteRestartTurnOutcome(row, 'cut');
2734
2751
  continue;
2735
2752
  }
2736
2753
  const relayActivityRows = stmts.listActivityByQueueMessage?.all?.(row.id);
@@ -2767,6 +2784,7 @@ export function registerMessagesRoutes(app, deps) {
2767
2784
  error: reason,
2768
2785
  });
2769
2786
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
2787
+ noteRestartTurnOutcome(row, 'requeued');
2770
2788
  }
2771
2789
  for (const row of rowsToFail) {
2772
2790
  const failureRecord = buildHeartbeatIdleReplayFailure({
@@ -2792,6 +2810,8 @@ export function registerMessagesRoutes(app, deps) {
2792
2810
  ok: false,
2793
2811
  error: failureRecord.code,
2794
2812
  });
2813
+ // Started, so not replayed: a turn the agent never heard the end of.
2814
+ noteRestartTurnOutcome(row, 'cut');
2795
2815
  }
2796
2816
  }
2797
2817
  if (rowsToRecover.length || rowsToFail.length) {
@@ -4429,7 +4449,17 @@ export function registerMessagesRoutes(app, deps) {
4429
4449
  const requesterSessionId = normalizeSessionWorkerId(requesterIdentity?.sessionId);
4430
4450
  const sessionWorkerRoutingEnabled = isSessionWorkerRoutingEnabled(featureFlags);
4431
4451
  let conversationSdkSessionId = null;
4432
- const requestedRelayMode = normalizeRelayMode(relayMode || mode);
4452
+ // The conversation's saved choices, for a send that leaves one out: a
4453
+ // send to an existing conversation that names no mode runs in the mode
4454
+ // the conversation is in, not the relay default.
4455
+ const existingConversation = (!newConversation && conversationId)
4456
+ ? (stmts.getConvAnyStatus?.get?.(conversationId) || null)
4457
+ : null;
4458
+ const explicitRelayModeGiven = !!String(relayMode || mode || '').trim();
4459
+ const savedRelayMode = !explicitRelayModeGiven && existingConversation?.preferred_relay_mode
4460
+ ? normalizeRelayMode(existingConversation.preferred_relay_mode)
4461
+ : null;
4462
+ const requestedRelayMode = savedRelayMode || normalizeRelayMode(relayMode || mode);
4433
4463
  const trimmedText = stripRelayPromptContext(text, requestedRelayMode || relayMode || mode);
4434
4464
  const normalizedAttachments = normalizeAttachments(rawAttachments);
4435
4465
  const requestedAttachmentCount = Math.min(
@@ -4786,6 +4816,34 @@ export function registerMessagesRoutes(app, deps) {
4786
4816
  // sends set_model before the message), so a changed model is taken as
4787
4817
  // for a local Claude chat.
4788
4818
  const claudeCloudModel = claudeCloudModelSelection ? claudeCloudModelSelection.model : '';
4819
+ // What the request itself chose. Only these are saved as the
4820
+ // conversation's preference; a value filled in below never is.
4821
+ const explicitModelGiven = !!requestedOpenAIModel;
4822
+ const conversationReasoningEffort = String(existingConversation?.preferred_reasoning_effort || '').trim().toLowerCase();
4823
+ // A Copilot conversation sent no model keeps its own: the saved
4824
+ // preference, the runtime session's model, the latest user message's,
4825
+ // and the catalog default only when it has none of these. A model the
4826
+ // catalog no longer lists is refused rather than swapped for another.
4827
+ const usesGitHubCatalog = !useOpenAIProvider && !runtimeBoundToExplicitProvider;
4828
+ const inheritedModel = usesGitHubCatalog && !shouldCreateConversation && !explicitModelGiven
4829
+ ? inheritedConversationModel({
4830
+ conversation: existingConversation,
4831
+ runtimeSession: existingRuntimeSession,
4832
+ latestUserMessageModel: stmts.getLatestUserMessageModel?.get?.(conversationId)?.model || '',
4833
+ })
4834
+ : null;
4835
+ const githubModelResolution = usesGitHubCatalog
4836
+ ? resolveRequestedModel(inheritedModel ? inheritedModel.model : model)
4837
+ : null;
4838
+ if (inheritedModel && !githubModelResolution?.ok) {
4839
+ return res.status(409).json({
4840
+ error: `This conversation's model "${inheritedModel.model}" is no longer available. Choose a model and send again.`,
4841
+ code: 'CONVERSATION_MODEL_UNAVAILABLE',
4842
+ conversationId,
4843
+ model: inheritedModel.model,
4844
+ supportedModels: githubModelResolution?.available || [],
4845
+ });
4846
+ }
4789
4847
  const modelResolution = useOpenAIProvider
4790
4848
  ? {
4791
4849
  ok: !!openAIModel,
@@ -4826,7 +4884,7 @@ export function registerMessagesRoutes(app, deps) {
4826
4884
  reasoningEffort: null,
4827
4885
  error: claudeCloudModel ? null : 'Claude Cloud model is not configured',
4828
4886
  }
4829
- : resolveRequestedModel(model)))));
4887
+ : githubModelResolution))));
4830
4888
  if (!modelResolution.ok) return res.status(400).json({ error: modelResolution.error, supportedModels: modelResolution.available || [] });
4831
4889
  const requestedModel = String(modelResolution.model || '').trim();
4832
4890
  const requestedAutoModel = requestedModel.toLowerCase() === AUTO_MODEL_SENTINEL;
@@ -4851,6 +4909,15 @@ export function registerMessagesRoutes(app, deps) {
4851
4909
  code: 'IMAGE_TARGET_REQUIRES_IMAGE_MODEL',
4852
4910
  });
4853
4911
  }
4912
+ // A send that names no effort runs with the conversation's own, clamped
4913
+ // to the nearest level the model has. Null when there is nothing to keep
4914
+ // or it neither matches nor clamps (an ultracode on a model without it):
4915
+ // the provider's own default applies then.
4916
+ const conversationEffortFor = (supported) => (
4917
+ !explicitReasoningEffort && conversationReasoningEffort
4918
+ ? (fallbackReasoningEffort([conversationReasoningEffort], supported, { fallback: '' }) || null)
4919
+ : null
4920
+ );
4854
4921
  // Claude conversations validate effort against the Claude provider's
4855
4922
  // per-model levels ('none' = SDK default). Effort is per-turn: each Claude
4856
4923
  // turn is a fresh query(), so it can change between messages.
@@ -4859,6 +4926,8 @@ export function registerMessagesRoutes(app, deps) {
4859
4926
  const supported = Array.isArray(configuredClaude?.effortsByModel?.[String(requestedModel || '').trim().toLowerCase()])
4860
4927
  ? configuredClaude.effortsByModel[String(requestedModel || '').trim().toLowerCase()]
4861
4928
  : [...DEFAULT_CLAUDE_REASONING_EFFORTS];
4929
+ const kept = conversationEffortFor(supported);
4930
+ if (kept) return { ok: true, effort: kept, supported };
4862
4931
  const effort = supported.includes(requestedEffort) ? requestedEffort : 'none';
4863
4932
  return { ok: true, effort: effort === 'none' ? 'none' : effort, supported };
4864
4933
  };
@@ -4874,8 +4943,11 @@ export function registerMessagesRoutes(app, deps) {
4874
4943
  // the worker validates it against the resolved model's live params
4875
4944
  // and falls back to the model default on any mismatch. Clamping to
4876
4945
  // 'none' here would actively disable thinking on the resolved model.
4877
- return { ok: true, effort: requestedEffort, supported: [] };
4946
+ // The conversation's own effort goes through the same way.
4947
+ return { ok: true, effort: requestedEffort || conversationReasoningEffort, supported: [] };
4878
4948
  }
4949
+ const kept = conversationEffortFor(discovered);
4950
+ if (kept) return { ok: true, effort: kept, supported: discovered };
4879
4951
  const effort = discovered.includes(requestedEffort) ? requestedEffort : 'none';
4880
4952
  return { ok: true, effort, supported: discovered };
4881
4953
  };
@@ -4884,11 +4956,16 @@ export function registerMessagesRoutes(app, deps) {
4884
4956
  const supported = Array.isArray(configuredGrok?.effortsByModel?.[String(requestedModel || '').trim().toLowerCase()])
4885
4957
  ? configuredGrok.effortsByModel[String(requestedModel || '').trim().toLowerCase()]
4886
4958
  : ['none', 'low', 'medium', 'high'];
4959
+ const kept = conversationEffortFor(supported);
4960
+ if (kept) return { ok: true, effort: kept, supported };
4887
4961
  const effort = supported.includes(requestedEffort) ? requestedEffort : 'none';
4888
4962
  return { ok: true, effort, supported };
4889
4963
  };
4890
4964
  let reasoningResolution = useOpenAIProvider
4891
- ? resolveOpenAIReasoningEffort(explicitReasoningEffort, openAIModel)
4965
+ ? resolveOpenAIReasoningEffort(
4966
+ explicitReasoningEffort || conversationEffortFor(openAIReasoningEffortsForModel(openAIModel)) || '',
4967
+ openAIModel,
4968
+ )
4892
4969
  : (runtimeUsesClaude
4893
4970
  ? resolveClaudeReasoningEffort()
4894
4971
  : (runtimeUsesCursor
@@ -4902,15 +4979,21 @@ export function registerMessagesRoutes(app, deps) {
4902
4979
  requestedModel,
4903
4980
  explicitReasoningEffort || modelResolution.reasoningEffort || null,
4904
4981
  )))));
4982
+ // No effort sent (or the model's variant carried one it lacks): the
4983
+ // conversation's own effort, clamped to the model's levels, else its
4984
+ // lowest level that is not 'none'. Some models list 'none' and the API
4985
+ // still refuses it, so it is taken only when the conversation chose it or
4986
+ // the model has nothing else.
4905
4987
  if (!reasoningResolution?.ok && !explicitReasoningEffort) {
4906
4988
  const supportedEfforts = Array.isArray(reasoningResolution?.supported)
4907
4989
  ? reasoningResolution.supported.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean)
4908
4990
  : [];
4909
- const fallbackReasoningEffort = supportedEfforts.includes('none')
4910
- ? 'none'
4911
- : (supportedEfforts[0] || null);
4912
- if (fallbackReasoningEffort) {
4913
- reasoningResolution = resolveRequestedReasoningEffort(requestedModel, fallbackReasoningEffort);
4991
+ const fallbackEffort = fallbackReasoningEffort(
4992
+ [modelResolution.reasoningEffort, conversationReasoningEffort],
4993
+ supportedEfforts,
4994
+ );
4995
+ if (fallbackEffort) {
4996
+ reasoningResolution = resolveRequestedReasoningEffort(requestedModel, fallbackEffort);
4914
4997
  }
4915
4998
  }
4916
4999
  if (!reasoningResolution?.ok) {
@@ -4920,6 +5003,9 @@ export function registerMessagesRoutes(app, deps) {
4920
5003
  });
4921
5004
  }
4922
5005
  const requestedReasoningEffort = reasoningResolution.effort || null;
5006
+ // The request's own effort: sent as such, or carried by the variant id of
5007
+ // a model it named.
5008
+ const explicitEffortGiven = !!explicitReasoningEffort || (explicitModelGiven && !!modelResolution.reasoningEffort);
4923
5009
  let normalizedContextTier = requestedContextTier;
4924
5010
  if (openAIImageModel) {
4925
5011
  normalizedContextTier = normalizeOpenAIImageSize(requestedContextTier) || 'auto';
@@ -4964,6 +5050,7 @@ export function registerMessagesRoutes(app, deps) {
4964
5050
  return res.json({
4965
5051
  ok: true,
4966
5052
  duplicate: true,
5053
+ queued: false,
4967
5054
  alreadyResent: true,
4968
5055
  duplicateOfMessageId: resend.alreadyResentAs,
4969
5056
  conversationId,
@@ -4994,6 +5081,8 @@ export function registerMessagesRoutes(app, deps) {
4994
5081
  return res.json({
4995
5082
  ok: true,
4996
5083
  duplicate: true,
5084
+ // Said outright for a caller that reads only `ok`: nothing new runs.
5085
+ queued: false,
4997
5086
  duplicateWindowMs: DUPLICATE_USER_MESSAGE_WINDOW_MS,
4998
5087
  duplicateSource: duplicateMessage.source,
4999
5088
  duplicateOfMessageId: duplicateMessage.messageId,
@@ -5175,15 +5264,25 @@ export function registerMessagesRoutes(app, deps) {
5175
5264
  }
5176
5265
  // Another relay's agent picks the model and mode of its own turn; the
5177
5266
  // composer keeps restoring the user's choice for this conversation.
5178
- conversationPreferences = remoteRequest.remote && !shouldCreateConversation
5179
- ? readConversationModelPreference(convId, requestedRelayMode)
5180
- : persistConversationModelPreference(
5267
+ // Any other send to an existing conversation saves only what it named:
5268
+ // a model or an effort filled in from a fallback is never written back
5269
+ // as the conversation's choice (an empty effort keeps the stored one).
5270
+ const savedModel = String(existingConversation?.preferred_model || '').trim();
5271
+ if (shouldCreateConversation) {
5272
+ conversationPreferences = persistConversationModelPreference(
5273
+ convId, requestedRelayMode, requestedModel, requestedReasoningEffort, now,
5274
+ );
5275
+ } else if (remoteRequest.remote || (!explicitModelGiven && !(explicitEffortGiven && savedModel))) {
5276
+ conversationPreferences = readConversationModelPreference(convId, requestedRelayMode);
5277
+ } else {
5278
+ conversationPreferences = persistConversationModelPreference(
5181
5279
  convId,
5182
5280
  requestedRelayMode,
5183
- requestedModel,
5184
- requestedReasoningEffort,
5281
+ explicitModelGiven ? requestedModel : savedModel,
5282
+ explicitEffortGiven ? requestedReasoningEffort : '',
5185
5283
  now,
5186
5284
  );
5285
+ }
5187
5286
  stmts.insertQ.run(
5188
5287
  msgId,
5189
5288
  convId,
@@ -5527,6 +5626,9 @@ export function registerMessagesRoutes(app, deps) {
5527
5626
  const leaseExpiresAt = addMsToIso(now, SESSION_WORKER_OWNER_LEASE_MS);
5528
5627
  for (const id of activeQueueMessageIds) {
5529
5628
  refreshProcessingLeaseForOwnedMessage.run(leaseExpiresAt, now, id, requesterSessionId);
5629
+ // The worker has the turn: if it was running when the relay
5630
+ // started, the restart did not cut it off.
5631
+ noteRestartTurnOutcome({ id }, 'claimed');
5530
5632
  }
5531
5633
  recoverOwnedProcessingRowsForSession(requesterSessionId, {
5532
5634
  excludeMessageIds: activeQueueMessageIds,
@@ -6570,7 +6672,17 @@ export function registerMessagesRoutes(app, deps) {
6570
6672
  || rawRestart === 1
6571
6673
  || String(rawRestart || '').trim().toLowerCase() === 'true'
6572
6674
  || String(rawRestart || '').trim() === '1';
6573
- const result = requestRelayShutdown({ reason, requestedBy, restart });
6675
+ // `immediate` starts it now, without waiting for the running turns (the
6676
+ // banner's "Restart now"); the answer names what it did not wait for.
6677
+ const rawImmediate = req.body?.immediate;
6678
+ const immediate = rawImmediate === true
6679
+ || rawImmediate === 1
6680
+ || String(rawImmediate || '').trim().toLowerCase() === 'true'
6681
+ || String(rawImmediate || '').trim() === '1';
6682
+ // The banner's "Restart now" names the queued request it saw (see
6683
+ // services/relay-restart-queue.mjs); a stale one is refused.
6684
+ const expectRequestedAt = String(req.body?.expectRequestedAt || '').trim().slice(0, 40) || null;
6685
+ const result = requestRelayShutdown({ reason, requestedBy, restart, immediate, expectRequestedAt });
6574
6686
  return res.json({ ok: true, ...result });
6575
6687
  });
6576
6688
 
@@ -7694,7 +7806,8 @@ export function registerMessagesRoutes(app, deps) {
7694
7806
  // Only a number the worker sent: a missing or null exit code stays null
7695
7807
  // (Number(null) would read as a clean exit).
7696
7808
  const exitCode = wholeNumber(req.body?.exitCode);
7697
- const settledDescription = String(req.body?.description || '').trim().slice(0, 500);
7809
+ // Scrubbed like the output: a description can be the command line.
7810
+ const settledDescription = cleanTaskText(String(req.body?.description || ''), 500);
7698
7811
  // The store is keyed as the worker posts. A task with a console stays
7699
7812
  // viewable as an ended row; one that ended while this runtime was away
7700
7813
  // becomes one when the post names its output.
@@ -7706,6 +7819,7 @@ export function registerMessagesRoutes(app, deps) {
7706
7819
  outputPath: settledOutputPath || undefined,
7707
7820
  outputKind: settledOutputPath ? normalizeTaskOutputKind(req.body?.outputKind, settledOutputPath) : undefined,
7708
7821
  description: settledDescription,
7822
+ command: normalizeTaskCommand(req.body?.command) || undefined,
7709
7823
  taskType: String(req.body?.taskType || '').trim().slice(0, 80),
7710
7824
  startedAt: Number(req.body?.startedAt) || null,
7711
7825
  }) || null;
@@ -7717,7 +7831,7 @@ export function registerMessagesRoutes(app, deps) {
7717
7831
  taskId,
7718
7832
  toolUseId: sanitizeSubagentRunId(req.body?.toolUseId) || null,
7719
7833
  status: String(req.body?.status || '').trim().slice(0, 40) || 'completed',
7720
- summary: String(req.body?.summary || '').trim().slice(0, 500) || null,
7834
+ summary: cleanTaskText(String(req.body?.summary || ''), 500) || null,
7721
7835
  exitCode: exitCode ?? null,
7722
7836
  // What the task was, from a worker that sends it: the end of a task
7723
7837
  // this runtime never knew (it ended while the relay was away) is
@@ -7747,11 +7861,18 @@ export function registerMessagesRoutes(app, deps) {
7747
7861
  .map((task) => ({
7748
7862
  taskId: String(task?.taskId || '').trim(),
7749
7863
  taskType: String(task?.taskType || '').trim(),
7750
- description: String(task?.description || '').trim().slice(0, 500),
7864
+ // Scrubbed like the output before anything keeps or sends them: a
7865
+ // description can be the command line (Grok's terminals, a Copilot
7866
+ // shell without one), a summary or tool call can quote one.
7867
+ description: cleanTaskText(String(task?.description || ''), 500),
7868
+ // A shell task's command line, where the worker knows it: scrubbed
7869
+ // like the output and cut at 2,000 characters before it is kept, so
7870
+ // every reader (the pages, read_session, the stored row) gets it so.
7871
+ command: normalizeTaskCommand(task?.command) || undefined,
7751
7872
  startedAt: Number(task?.startedAt) || null,
7752
- summary: String(task?.summary || '').trim().slice(0, 500) || null,
7873
+ summary: cleanTaskText(String(task?.summary || ''), 500) || null,
7753
7874
  lastToolName: String(task?.lastToolName || '').trim() || null,
7754
- lastToolCall: String(task?.lastToolCall || '').replace(/\s+/g, ' ').trim().slice(0, 300) || null,
7875
+ lastToolCall: cleanTaskText(String(task?.lastToolCall || '').replace(/\s+/g, ' '), 300) || null,
7755
7876
  totalTokens: Number.isFinite(Number(task?.totalTokens)) ? Number(task.totalTokens) : null,
7756
7877
  subagentType: String(task?.subagentType || '').trim().slice(0, 120) || null,
7757
7878
  model: String(task?.model || '').trim().slice(0, 120) || null,
@@ -7870,9 +7991,11 @@ export function registerMessagesRoutes(app, deps) {
7870
7991
  // or a snapshot). A task of theirs without one comes from a worker that
7871
7992
  // started before the console existed.
7872
7993
  const TASK_OUTPUT_PROVIDERS = new Set(['claude', 'github', 'copilot', 'grok']);
7873
- const SUBAGENT_OUTPUT_MESSAGE = 'Sub-agents have no shell output; open the sub-agent view.';
7994
+ const SUBAGENT_OUTPUT_MESSAGE = TASK_OUTPUT_NONE_MESSAGES.subagent;
7874
7995
 
7875
- // Why a task has no output to show, for the console's `none` answer.
7996
+ // Why a task has no output to show, for the console's `none` answer. The
7997
+ // texts are the page's own (task-console-render.mjs), so the row's hover
7998
+ // title and the console say the same.
7876
7999
  function noOutputMessage(conversationId, task) {
7877
8000
  if (taskKindOf(task?.taskType) === 'subagent') return SUBAGENT_OUTPUT_MESSAGE;
7878
8001
  let provider = '';
@@ -7880,9 +8003,9 @@ export function registerMessagesRoutes(app, deps) {
7880
8003
  provider = String(stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.provider_type || '').trim().toLowerCase();
7881
8004
  } catch {}
7882
8005
  if (!provider || TASK_OUTPUT_PROVIDERS.has(provider)) {
7883
- return 'This session did not report an output file for this task. Sessions started before the task console need a restart to show output.';
8006
+ return TASK_OUTPUT_NONE_MESSAGES.noOutputFile;
7884
8007
  }
7885
- return 'Output is not available for this provider.';
8008
+ return TASK_OUTPUT_NONE_MESSAGES.provider;
7886
8009
  }
7887
8010
 
7888
8011
  // A task the session's task list shows that the task store does not hold:
@@ -7949,13 +8072,17 @@ export function registerMessagesRoutes(app, deps) {
7949
8072
  });
7950
8073
  }
7951
8074
  const { task, ended, key } = found;
8075
+ // Held since a relay restart: its worker has not said yet whether it
8076
+ // still runs, so it is neither running nor stoppable.
8077
+ const held = !ended && found.restored === true;
7952
8078
  const { kind, path } = taskOutputSource(task);
7953
8079
  const head = {
7954
8080
  ok: true,
7955
8081
  taskId,
7956
8082
  kind,
7957
8083
  ended,
7958
- status: ended ? task.status : 'running',
8084
+ status: ended ? task.status : (held ? 'held' : 'running'),
8085
+ ...(held ? { held: true } : {}),
7959
8086
  exitCode: ended ? (task.exitCode ?? null) : null,
7960
8087
  health: task.health || null,
7961
8088
  taskStallMinutes: taskStallMinutes(),
@@ -7963,9 +8090,10 @@ export function registerMessagesRoutes(app, deps) {
7963
8090
  taskId,
7964
8091
  taskType: task.taskType || null,
7965
8092
  description: task.description || '',
8093
+ ...(task.command ? { command: task.command } : {}),
7966
8094
  startedAt: task.startedAt || null,
7967
8095
  endedAt: task.endedAt || null,
7968
- stoppable: !ended && task.stoppable !== false,
8096
+ stoppable: !ended && !held && task.stoppable !== false,
7969
8097
  },
7970
8098
  };
7971
8099
  if (kind === 'none') {
@@ -7982,7 +8110,7 @@ export function registerMessagesRoutes(app, deps) {
7982
8110
  if (!cached) return res.json({ ...head, pending: true, text: '', lines: [], startOffset: 0, endOffset: 0, size: 0, mtime: null, hasMoreBefore: false });
7983
8111
  return res.json({ ...head, ...shapeSnapshot(cached) });
7984
8112
  }
7985
- if (!path) return res.json({ ...head, kind: 'none', message: 'Output is not available for this task.' });
8113
+ if (!path) return res.json({ ...head, kind: 'none', message: TASK_OUTPUT_NONE_MESSAGES.noPath });
7986
8114
  const lines = req.query?.lines;
7987
8115
  const before = req.query?.before;
7988
8116
  const read = before !== undefined && before !== ''