@oar-sh/oar 0.9.11 → 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 (90) hide show
  1. package/CHANGELOG.md +332 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +201 -12
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +179 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +152 -0
  10. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  13. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  14. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/acp-host-services.mjs +48 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +248 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  22. package/server/migrations/0018-background-task-rows.mjs +101 -0
  23. package/server/public/app/action-confirmations.js +12 -15
  24. package/server/public/app/api-client.js +70 -3
  25. package/server/public/app/background-tasks-view.mjs +334 -16
  26. package/server/public/app/bootstrap.js +102 -5
  27. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  28. package/server/public/app/conversation-payload-cache.mjs +357 -0
  29. package/server/public/app/conversation-preferences.mjs +10 -4
  30. package/server/public/app/conversation-turn-state.mjs +1 -1
  31. package/server/public/app/conversation-view.js +462 -8
  32. package/server/public/app/conversation-window-retention.mjs +96 -0
  33. package/server/public/app/host-suspend-ui.js +45 -44
  34. package/server/public/app/journal-view.js +259 -67
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
  37. package/server/public/app/relay-restart-note-view.mjs +36 -0
  38. package/server/public/app/relay-restart-ui.js +568 -0
  39. package/server/public/app/settings-modal.js +162 -2
  40. package/server/public/app/socket-handlers.js +59 -7
  41. package/server/public/app/store.js +25 -0
  42. package/server/public/app/subagent-viewer.mjs +7 -1
  43. package/server/public/app/task-console-render.mjs +401 -0
  44. package/server/public/app/task-console-watch.mjs +42 -0
  45. package/server/public/app/task-console.mjs +730 -0
  46. package/server/public/app/task-output-text.mjs +243 -0
  47. package/server/public/app/transcript-island.mjs +73 -0
  48. package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
  49. package/server/public/app/transcript-jump-bottom.mjs +68 -11
  50. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  51. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  52. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  53. package/server/public/index.html +306 -6
  54. package/server/relay-tools.md +6 -0
  55. package/server/repositories/message-repository.mjs +11 -0
  56. package/server/repositories/question-repository.mjs +4 -0
  57. package/server/repositories/remote-relay-repository.mjs +4 -2
  58. package/server/routes/messages-routes.mjs +397 -23
  59. package/server/routes/relay-board-routes.mjs +19 -2
  60. package/server/routes/relay-restart-routes.mjs +73 -0
  61. package/server/routes/sessions-routes.mjs +208 -66
  62. package/server/server-runtime.mjs +269 -137
  63. package/server/services/about-service.mjs +13 -12
  64. package/server/services/background-task-persistence.mjs +133 -0
  65. package/server/services/background-task-store.mjs +470 -6
  66. package/server/services/cli-process-runner.mjs +4 -21
  67. package/server/services/conversation-history-page.mjs +199 -0
  68. package/server/services/conversation-model-fallback.mjs +57 -0
  69. package/server/services/host-suspend-command.mjs +2 -1
  70. package/server/services/preview-proxy-server.mjs +14 -1
  71. package/server/services/process-list-service.mjs +2 -1
  72. package/server/services/provider-model-selection.mjs +195 -0
  73. package/server/services/push-dispatch-service.mjs +9 -2
  74. package/server/services/relay-event-service.mjs +27 -9
  75. package/server/services/relay-prompt-sanitizer.mjs +18 -4
  76. package/server/services/relay-restart-picture.mjs +209 -0
  77. package/server/services/relay-restart-queue.mjs +380 -0
  78. package/server/services/relay-shutdown-gate.mjs +4 -1
  79. package/server/services/remote-relay-dispatcher.mjs +316 -20
  80. package/server/services/restart-resume-service.mjs +303 -0
  81. package/server/services/session-activity-tracker.mjs +6 -2
  82. package/server/services/session-worker-process-service.mjs +6 -4
  83. package/server/services/task-output-rooms.mjs +259 -0
  84. package/server/services/task-output-service.mjs +761 -0
  85. package/shared/claude-background-command-limit.mjs +83 -0
  86. package/shared/relay-events.mjs +55 -2
  87. package/shared/remote-relay-contract.mjs +47 -10
  88. package/shared/task-output-text.mjs +850 -0
  89. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  90. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -75,16 +75,29 @@ import { createHostSuspendService } from './services/host-suspend-service.mjs';
75
75
  import { USAGE_LIMIT_ACCOUNT_SOCKET_EVENT, createUsageLimitPauseService } from './services/usage-limit-pause-service.mjs';
76
76
  import { createSessionActivityTracker, resolveBootReportingWindowMs } from './services/session-activity-tracker.mjs';
77
77
  import { createBackgroundTaskStore } from './services/background-task-store.mjs';
78
+ import { createSqliteBackgroundTaskPersistence } from './services/background-task-persistence.mjs';
79
+ import {
80
+ RESTART_RESUME_SETTING_KEY,
81
+ createRestartResumeService,
82
+ parseRestartResumeUpdate,
83
+ readRestartResumeSetting,
84
+ } from './services/restart-resume-service.mjs';
78
85
  import { createBackgroundWorkView } from './services/background-work-view.mjs';
79
86
  import { createRelayEventService } from './services/relay-event-service.mjs';
80
87
  import { createRelayEventLinkService } from './services/relay-event-link-service.mjs';
81
88
  import { buildClaudeAttributionSettings, normalizeClaudeAttributionMode, resolveClaudeAttributionMode, DEFAULT_CLAUDE_ATTRIBUTION_MODE } from '../shared/claude-attribution.mjs';
89
+ import {
90
+ claudeBackgroundCommandLimitMinutesToMs,
91
+ parseClaudeBackgroundCommandLimitUpdate,
92
+ readClaudeBackgroundCommandLimitSetting,
93
+ } from '../shared/claude-background-command-limit.mjs';
82
94
  import { createWorkerLaunchNoteService } from './services/worker-launch-note-service.mjs';
83
95
  import { createGitHubCiActivityService } from './services/github-ci-activity-service.mjs';
84
96
  import { runHostSuspendToRam } from './services/host-suspend-command.mjs';
85
97
  import { createActiveDeviceTracker } from './services/push-active-device-service.mjs';
86
98
  import { registerPushRoutes } from './routes/push-routes.mjs';
87
99
  import { registerAboutRoutes } from './routes/about-routes.mjs';
100
+ import { registerRelayRestartRoutes } from './routes/relay-restart-routes.mjs';
88
101
  import { createAboutService, resolveBuildCommit } from './services/about-service.mjs';
89
102
  import { createImageOperationService } from './services/image-operation-service.mjs';
90
103
  import {
@@ -145,6 +158,8 @@ import { createTmuxInspectorAccessPolicy } from './services/tmux-inspector-acces
145
158
  import { createTmuxInspectorStreamService } from './services/tmux-inspector-stream-service.mjs';
146
159
  import { createTmuxInspectorSocketService } from './services/tmux-inspector-socket-service.mjs';
147
160
  import { createSubagentViewerRooms } from './services/subagent-viewer-rooms.mjs';
161
+ import { createTaskOutputRooms } from './services/task-output-rooms.mjs';
162
+ import { createTaskHealthPoller, createTaskOutputSnapshots } from './services/task-output-service.mjs';
148
163
  import {
149
164
  resolveDefaultSessionWorkspaceRootState as resolveDefaultSessionWorkspaceRootStateFromService,
150
165
  resolveLaunchWorkspaceRootPath,
@@ -176,7 +191,15 @@ import {
176
191
  parseSharePathPrivacyUpdate,
177
192
  readSharePathPrivacySetting,
178
193
  } from '../shared/share-path-privacy.mjs';
179
- import { isRelayShutdownQueued, queuedShutdownCanProceed, relayShutdownGateReply } from './services/relay-shutdown-gate.mjs';
194
+ import { isRelayShutdownQueued, relayShutdownGateReply } from './services/relay-shutdown-gate.mjs';
195
+ import {
196
+ createRelayRestartQueue,
197
+ parseRelayRestartMaxWaitUpdate,
198
+ readRelayRestartMaxWaitSetting,
199
+ resolveRelayRestartMaxWaitOverrideMs,
200
+ RELAY_RESTART_MAX_WAIT_SETTING_KEY,
201
+ } from './services/relay-restart-queue.mjs';
202
+ import { createRestartPictureCollector } from './services/relay-restart-picture.mjs';
180
203
  import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
181
204
  import {
182
205
  STALE_SUBAGENT_RUN_BOOT_DELAY_MS,
@@ -208,6 +231,11 @@ import {
208
231
  readBackgroundTaskTimeoutSetting,
209
232
  backgroundTaskTimeoutMinutesToMs,
210
233
  } from '../shared/background-task-timeout.mjs';
234
+ import {
235
+ TASK_STALL_MINUTES_SETTING_KEY,
236
+ parseTaskStallMinutesUpdate,
237
+ readTaskStallMinutesSetting,
238
+ } from '../shared/task-output-text.mjs';
211
239
  import { normalizeRelayThoughtList } from './public/app/relay-thoughts.mjs';
212
240
  import { capRelayActivityEntries } from './public/app/activity-replay-state.mjs';
213
241
  import {
@@ -504,10 +532,15 @@ function getClaudeProviderSettings() {
504
532
  effortsByModel,
505
533
  modelsDiscovered,
506
534
  attributionMode: normalizeClaudeAttributionMode(readAppSettingValue(CLAUDE_ATTRIBUTION_SETTING_KEY)) || DEFAULT_CLAUDE_ATTRIBUTION_MODE,
535
+ backgroundCommandLimitMinutes: getClaudeBackgroundCommandLimitMinutes(),
507
536
  providerType: 'claude',
508
537
  };
509
538
  }
510
539
 
540
+ function getClaudeBackgroundCommandLimitMinutes() {
541
+ return readClaudeBackgroundCommandLimitSetting(readAppSettingValue(CLAUDE_BACKGROUND_COMMAND_LIMIT_SETTING_KEY));
542
+ }
543
+
511
544
  function setClaudeProviderSettings(update = {}) {
512
545
  const payload = update && typeof update === 'object' ? update : {};
513
546
  const enabled = payload.enabled;
@@ -528,6 +561,12 @@ function setClaudeProviderSettings(update = {}) {
528
561
  if (attributionMode === null) {
529
562
  return { ok: false, error: 'Invalid Claude attribution mode' };
530
563
  }
564
+ const backgroundCommandLimit = payload.backgroundCommandLimitMinutes === undefined
565
+ ? null
566
+ : parseClaudeBackgroundCommandLimitUpdate(payload.backgroundCommandLimitMinutes);
567
+ if (backgroundCommandLimit && !backgroundCommandLimit.ok) {
568
+ return { ok: false, error: backgroundCommandLimit.error };
569
+ }
531
570
  const nowIso = new Date().toISOString();
532
571
  const nextEnabled = typeof enabled === 'boolean' ? enabled : existing.enabled;
533
572
  stmts.upsertAppSetting.run(CLAUDE_ENABLED_SETTING_KEY, nextEnabled ? 'true' : 'false', nowIso);
@@ -535,6 +574,9 @@ function setClaudeProviderSettings(update = {}) {
535
574
  if (attributionMode) {
536
575
  stmts.upsertAppSetting.run(CLAUDE_ATTRIBUTION_SETTING_KEY, attributionMode, nowIso);
537
576
  }
577
+ if (backgroundCommandLimit) {
578
+ stmts.upsertAppSetting.run(CLAUDE_BACKGROUND_COMMAND_LIMIT_SETTING_KEY, String(backgroundCommandLimit.minutes), nowIso);
579
+ }
538
580
  if (Array.isArray(models)) {
539
581
  const normalizedModels = models
540
582
  .map((value) => String(value || '').trim())
@@ -1404,6 +1446,9 @@ const CLAUDE_MODEL_EFFORTS_SETTING_KEY = 'claude_model_efforts';
1404
1446
  // oar | vanilla | off — what commits and PRs made in Claude sessions say
1405
1447
  // (shared/claude-attribution.mjs); absent means oar.
1406
1448
  const CLAUDE_ATTRIBUTION_SETTING_KEY = 'claude_attribution_mode';
1449
+ // Minutes a backgrounded Bash command of a Claude session may run without its
1450
+ // own timeout (shared/claude-background-command-limit.mjs); absent means 2 h.
1451
+ const CLAUDE_BACKGROUND_COMMAND_LIMIT_SETTING_KEY = 'claude_background_command_limit_minutes';
1407
1452
  // The set of levels the SDK itself can report/accept as EffortLevel values.
1408
1453
  // Discovery results are filtered through this, so it must NOT contain the
1409
1454
  // relay's derived 'ultracode' sentinel — supportedModels() never reports it
@@ -1848,7 +1893,7 @@ const runtimeTimers = {
1848
1893
  questionExpiry: null,
1849
1894
  pendingWorkerPrime: null,
1850
1895
  sharedViewerPrune: null,
1851
- shutdownDrain: null,
1896
+ restoredTaskSettle: null,
1852
1897
  };
1853
1898
 
1854
1899
  function normalizeRemotePath(value) {
@@ -2158,6 +2203,36 @@ function setBackgroundTaskTimeoutMinutes(value) {
2158
2203
  return { ok: true, timeoutMinutes: parsed.minutes };
2159
2204
  }
2160
2205
 
2206
+ // "Mark a background task as quiet after N minutes": the panel's stall hint.
2207
+ function getTaskStallMinutes() {
2208
+ return readTaskStallMinutesSetting(readAppSettingValue(TASK_STALL_MINUTES_SETTING_KEY));
2209
+ }
2210
+
2211
+ function setTaskStallMinutes(value) {
2212
+ if (typeof stmts?.upsertAppSetting?.run !== 'function') {
2213
+ return { ok: false, error: 'Task stall settings are unavailable' };
2214
+ }
2215
+ const parsed = parseTaskStallMinutesUpdate(value);
2216
+ if (!parsed.ok) return parsed;
2217
+ stmts.upsertAppSetting.run(TASK_STALL_MINUTES_SETTING_KEY, String(parsed.minutes), new Date().toISOString());
2218
+ return { ok: true, minutes: parsed.minutes };
2219
+ }
2220
+
2221
+ // "After a relay restart, tell interrupted sessions to carry on" (off by default).
2222
+ function getRestartResumeEnabled() {
2223
+ return readRestartResumeSetting(readAppSettingValue(RESTART_RESUME_SETTING_KEY));
2224
+ }
2225
+
2226
+ function setRestartResumeEnabled(value) {
2227
+ if (typeof stmts?.upsertAppSetting?.run !== 'function') {
2228
+ return { ok: false, error: 'Restart settings are unavailable' };
2229
+ }
2230
+ const parsed = parseRestartResumeUpdate(value);
2231
+ if (!parsed.ok) return parsed;
2232
+ stmts.upsertAppSetting.run(RESTART_RESUME_SETTING_KEY, parsed.enabled ? '1' : '0', new Date().toISOString());
2233
+ return { ok: true, enabled: parsed.enabled };
2234
+ }
2235
+
2161
2236
  function resolveDefaultSessionWorkspaceRootState() {
2162
2237
  return resolveDefaultSessionWorkspaceRootStateFromService({
2163
2238
  storedPath: readAppSettingValue(DEFAULT_SESSION_WORKSPACE_ROOT_KEY),
@@ -3407,140 +3482,87 @@ function attachTtyConsoleRuntimeTitle(runtime) {
3407
3482
  }
3408
3483
  attachTtyConsoleRuntimeTitle(ttyConsoleRuntime);
3409
3484
 
3410
- let pendingRelayShutdownRequest = null;
3411
- function normalizeRestartRequestFlag(value) {
3412
- if (value === true || value === 1) return true;
3413
- if (value === false || value === 0 || value == null) return false;
3414
- const normalized = String(value).trim().toLowerCase();
3415
- return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
3416
- }
3417
-
3418
- function getRelayShutdownState() {
3419
- const pending = pendingRelayShutdownRequest;
3420
- if (!pending) {
3421
- return {
3422
- status: runtimeShutdownStarted ? 'shutting_down' : 'idle',
3423
- action: runtimeShutdownStarted ? 'shutdown' : null,
3424
- restart: false,
3425
- reason: null,
3426
- requestedBy: null,
3427
- requestedAt: null,
3428
- };
3485
+ // ─── Queued relay restart / shutdown ─────────────────────────────────────────
3486
+ // A request waits until no turn is processing and holds new turns meanwhile
3487
+ // (services/relay-shutdown-gate.mjs); past the maximum wait it asks instead
3488
+ // of acting (services/relay-restart-queue.mjs). Every client shows a banner
3489
+ // with what it waits for, from `relay_shutdown_state` (sent on each change
3490
+ // and on connect).
3491
+ const relayRestartMaxWaitOverrideMs = resolveRelayRestartMaxWaitOverrideMs(process.env);
3492
+ if (relayRestartMaxWaitOverrideMs !== null) {
3493
+ console.log(`${runtimeLogPrefix()}Relay restart max wait pinned to ${relayRestartMaxWaitOverrideMs} ms (OAR_RELAY_RESTART_MAX_WAIT_MS)`);
3494
+ }
3495
+ function getRelayRestartMaxWaitMinutes() {
3496
+ return readRelayRestartMaxWaitSetting(readAppSettingValue(RELAY_RESTART_MAX_WAIT_SETTING_KEY));
3497
+ }
3498
+ function setRelayRestartMaxWaitMinutes(value) {
3499
+ if (!stmts.upsertAppSetting) return { ok: false, error: 'Relay restart settings are unavailable' };
3500
+ const parsed = parseRelayRestartMaxWaitUpdate(value);
3501
+ if (!parsed.ok) return parsed;
3502
+ stmts.upsertAppSetting.run(RELAY_RESTART_MAX_WAIT_SETTING_KEY, String(parsed.minutes), new Date().toISOString());
3503
+ // A queued request measures its current period against the new value.
3504
+ relayRestartQueue.refresh();
3505
+ return { ok: true, maxWaitMinutes: parsed.minutes };
3506
+ }
3507
+ let restartPictureCollector = null;
3508
+ // What a restart would wait for, hold and leave running (the confirmation
3509
+ // asks before anything is queued). Built on first use: the background-work
3510
+ // view is set up further down.
3511
+ function collectRestartPicture({ action = 'restart' } = {}) {
3512
+ if (!restartPictureCollector) {
3513
+ restartPictureCollector = createRestartPictureCollector({
3514
+ db,
3515
+ backgroundWorkView,
3516
+ listHeldTasks: () => backgroundTaskStore.listRestored().map(({ key, task }) => ({
3517
+ conversationId: resolveConversationRecord({ conversationId: key, sdkSessionId: key })?.id || key,
3518
+ description: task?.description || task?.taskType || '',
3519
+ })),
3520
+ providerOf: (conversationId) => stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.provider_type,
3521
+ titleOf: (conversationId) => hostActivityTitle(conversationId),
3522
+ });
3429
3523
  }
3430
- return {
3431
- status: runtimeShutdownStarted ? 'shutting_down' : 'queued',
3432
- action: pending.action || 'shutdown',
3433
- restart: pending.action === 'restart',
3434
- reason: pending.reason || null,
3435
- requestedBy: pending.requestedBy || null,
3436
- requestedAt: pending.requestedAt || null,
3437
- };
3524
+ return restartPictureCollector({ action });
3438
3525
  }
3526
+ const relayRestartQueue = createRelayRestartQueue({
3527
+ getCounts: () => queueCounts(),
3528
+ collectPicture: collectRestartPicture,
3529
+ startShutdown: ({ action, reason }) => {
3530
+ const exitCode = action === 'restart' ? RELAY_RESTART_EXIT_CODE : 0;
3531
+ sessionWorkerWebSocketService.emitDraining(`api-${action}:${reason}`);
3532
+ void shutdownRuntime(`api-${action}:${reason}`, { exitCode });
3533
+ },
3534
+ getMaxWaitMs: () => (relayRestartMaxWaitOverrideMs !== null
3535
+ ? relayRestartMaxWaitOverrideMs
3536
+ : getRelayRestartMaxWaitMinutes() * 60_000),
3537
+ isShuttingDown: () => runtimeShutdownStarted,
3538
+ onStateChange: (state) => {
3539
+ try { io.emit('relay_shutdown_state', state); } catch {}
3540
+ },
3541
+ onMaxWaitReached: (state) => {
3542
+ const sessions = Array.isArray(state.waitingFor) ? state.waitingFor.length : 0;
3543
+ const minutes = Math.max(1, Math.round(Number(state.maxWaitMs || 0) / 60_000));
3544
+ void pushDispatchService.notifyRelayRestart({
3545
+ body: `The queued relay ${state.action} has waited ${minutes} min for ${sessions} session${sessions === 1 ? '' : 's'}. Restart now, keep waiting or cancel it.`,
3546
+ });
3547
+ },
3548
+ logger: console,
3549
+ logPrefix: () => runtimeLogPrefix(),
3550
+ });
3439
3551
 
3440
- // Every client shows a banner while a shutdown/restart is queued, so the
3441
- // state goes out on each transition (request, cancel, drain) and on connect.
3442
3552
  function relayShutdownStatePayload() {
3443
- return { ...getRelayShutdownState(), queue: queueCounts() };
3444
- }
3445
- function emitRelayShutdownState() {
3446
- try { io.emit('relay_shutdown_state', relayShutdownStatePayload()); } catch {}
3553
+ return relayRestartQueue.getState();
3447
3554
  }
3448
3555
 
3449
3556
  function cancelRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
3450
- if (runtimeShutdownStarted) {
3451
- return { cancelled: false, status: 'shutting_down', ...relayShutdownStatePayload() };
3452
- }
3453
- if (!pendingRelayShutdownRequest) {
3454
- return { cancelled: false, ...relayShutdownStatePayload() };
3455
- }
3456
- const dropped = pendingRelayShutdownRequest;
3457
- pendingRelayShutdownRequest = null;
3458
- if (runtimeTimers.shutdownDrain) {
3459
- try { clearInterval(runtimeTimers.shutdownDrain); } catch {}
3460
- runtimeTimers.shutdownDrain = null;
3461
- }
3462
- console.log(
3463
- `${runtimeLogPrefix()}Relay ${dropped.action || 'shutdown'} request cancelled by `
3464
- + `${String(requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api'}`,
3465
- );
3466
- emitRelayShutdownState();
3467
- return { cancelled: true, dropped, ...relayShutdownStatePayload() };
3557
+ return relayRestartQueue.cancel({ requestedBy });
3468
3558
  }
3469
3559
 
3470
- function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localhost-api', restart = false } = {}) {
3471
- const safeReason = String(reason || 'manual-request').trim().slice(0, 140) || 'manual-request';
3472
- const safeRequestedBy = String(requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
3473
- const restartRequested = normalizeRestartRequestFlag(restart);
3474
- const requestedAction = restartRequested ? 'restart' : 'shutdown';
3475
- if (runtimeShutdownStarted) {
3476
- return {
3477
- accepted: false,
3478
- status: 'shutting_down',
3479
- action: requestedAction,
3480
- restart: restartRequested,
3481
- reason: safeReason,
3482
- };
3483
- }
3484
-
3485
- // Shutdown is deferred until no turn is processing; it never interrupts an
3486
- // active turn. From here on no pending row is handed out (the dequeue paths
3487
- // read relayShutdownQueued): a turn started meanwhile would postpone the
3488
- // restart again. Pending and parked rows wait in the database and run
3489
- // after the restart.
3490
- if (!pendingRelayShutdownRequest) {
3491
- pendingRelayShutdownRequest = {
3492
- action: requestedAction,
3493
- reason: safeReason,
3494
- requestedBy: safeRequestedBy,
3495
- requestedAt: new Date().toISOString(),
3496
- };
3497
- } else if (restartRequested && pendingRelayShutdownRequest.action !== 'restart') {
3498
- pendingRelayShutdownRequest = {
3499
- ...pendingRelayShutdownRequest,
3500
- action: 'restart',
3501
- reason: safeReason,
3502
- requestedBy: safeRequestedBy,
3503
- requestedAt: new Date().toISOString(),
3504
- };
3505
- }
3506
-
3507
- const tryShutdownWhenIdle = () => {
3508
- if (runtimeShutdownStarted) return;
3509
- const counts = queueCounts();
3510
- if (!queuedShutdownCanProceed(counts)) return;
3511
- const requestInfo = pendingRelayShutdownRequest;
3512
- pendingRelayShutdownRequest = null;
3513
- if (runtimeTimers.shutdownDrain) {
3514
- try { clearInterval(runtimeTimers.shutdownDrain); } catch {}
3515
- runtimeTimers.shutdownDrain = null;
3516
- }
3517
- const action = requestInfo?.action === 'restart' ? 'restart' : 'shutdown';
3518
- const exitCode = action === 'restart' ? RELAY_RESTART_EXIT_CODE : 0;
3519
- console.log(
3520
- `${runtimeLogPrefix()}Relay ${action} request accepted by ${requestInfo?.requestedBy || 'unknown'}`
3521
- + `; no turn is processing (${counts.pendingCount} pending, ${counts.parkedCount} parked wait), exiting now (reason=${requestInfo?.reason || 'manual-request'})`,
3522
- );
3523
- sessionWorkerWebSocketService.emitDraining(`api-${action}:${requestInfo?.reason || 'manual-request'}`);
3524
- void shutdownRuntime(`api-${action}:${requestInfo?.reason || 'manual-request'}`, { exitCode });
3525
- emitRelayShutdownState();
3526
- };
3560
+ function keepWaitingForRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
3561
+ return relayRestartQueue.keepWaiting({ requestedBy });
3562
+ }
3527
3563
 
3528
- if (!runtimeTimers.shutdownDrain) {
3529
- runtimeTimers.shutdownDrain = setInterval(tryShutdownWhenIdle, 1000);
3530
- }
3531
- tryShutdownWhenIdle();
3532
- if (pendingRelayShutdownRequest) emitRelayShutdownState();
3533
- const counts = queueCounts();
3534
- return {
3535
- accepted: true,
3536
- status: 'queued',
3537
- action: pendingRelayShutdownRequest?.action || requestedAction,
3538
- restart: (pendingRelayShutdownRequest?.action || requestedAction) === 'restart',
3539
- requestedAt: pendingRelayShutdownRequest?.requestedAt || new Date().toISOString(),
3540
- reason: pendingRelayShutdownRequest?.reason || safeReason,
3541
- requestedBy: pendingRelayShutdownRequest?.requestedBy || safeRequestedBy,
3542
- queue: counts,
3543
- };
3564
+ function requestRelayShutdown(options = {}) {
3565
+ return relayRestartQueue.request(options);
3544
3566
  }
3545
3567
 
3546
3568
  function toNullableInt(value) {
@@ -5204,10 +5226,17 @@ function previewCardsForResponse(responseMessageId) {
5204
5226
  .slice(0, 5);
5205
5227
  }
5206
5228
 
5207
- // Live background-task sets, as the session workers publish them. Memory
5208
- // only: after a runtime start the heartbeat's digest brings each set back
5209
- // (services/background-task-store.mjs).
5210
- const backgroundTaskStore = createBackgroundTaskStore();
5229
+ // Live background-task sets, as the session workers publish them. The sets
5230
+ // are memory only: after a runtime start the heartbeat's digest brings each
5231
+ // set back. The console rows (the output file of a running shell, the ended
5232
+ // rows of the last hour) are stored (migration 0018) and restored here; a
5233
+ // running one is held until its worker reports (services/background-task-store.mjs).
5234
+ const backgroundTaskStore = createBackgroundTaskStore({
5235
+ persistence: createSqliteBackgroundTaskPersistence(db, { logger: console }),
5236
+ });
5237
+ // The output a worker posts for a task with no output file (the console's
5238
+ // fallback); memory only, an hour.
5239
+ const taskOutputSnapshots = createTaskOutputSnapshots();
5211
5240
 
5212
5241
  // ─── Deferred host suspend ───────────────────────────────────────────────────
5213
5242
  // "💤 Suspend host" waits until nothing runs any more: no queued/processing
@@ -5319,6 +5348,29 @@ const usageLimitPauseService = createUsageLimitPauseService({
5319
5348
  logger: console,
5320
5349
  });
5321
5350
 
5351
+ // Turns a restart cut off for good (their worker went with the relay, and a
5352
+ // second delivery would run the prompt twice): with the setting on, their
5353
+ // session gets one note to carry on. The rows processing right now are the
5354
+ // ones the runtime before left; the recovery paths below report what became
5355
+ // of each (services/restart-resume-service.mjs).
5356
+ const restartResumeService = createRestartResumeService({
5357
+ db,
5358
+ stmts,
5359
+ isEnabled: getRestartResumeEnabled,
5360
+ emit: (event, payload) => io.emit(event, payload),
5361
+ // The note is an ordinary pending message: the primer starts a stopped
5362
+ // worker, the socket service hands it to a running one.
5363
+ wake: () => {
5364
+ void primePendingSessionWorkers('restart-resume');
5365
+ sessionWorkerWebSocketService?.emitQueueChanged?.('restart-resume');
5366
+ },
5367
+ logger: console,
5368
+ });
5369
+ restartResumeService.captureBoot();
5370
+ function noteRestartTurnOutcome(row, outcome) {
5371
+ try { restartResumeService.noteOutcome(row, outcome); } catch {}
5372
+ }
5373
+
5322
5374
  // ─── Relay events ─────────────────────────────────────────────────────────────
5323
5375
  // What each session is doing (state, tasks, quiet time) and the subscriptions
5324
5376
  // through which a watching session is woken with the events of the sessions
@@ -5492,6 +5544,7 @@ function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso
5492
5544
  for (const row of staleContinuations) {
5493
5545
  stmts.dropStaleContinuation?.run?.(row.id);
5494
5546
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
5547
+ noteRestartTurnOutcome(row, 'cut');
5495
5548
  // A teardown is a turn ending with no reply; it must be visible in the
5496
5549
  // console log (the only trace of a silent one was `ignored=already_failed`
5497
5550
  // hours later, when the worker's reply arrived for the dead row).
@@ -5500,13 +5553,19 @@ function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso
5500
5553
  console.warn(`${runtimeLogPrefix()}STALE CONTINUATION ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} dropped: ${reason}`);
5501
5554
  }
5502
5555
  const listedRows = stmts.listRecoverableProcessing.all(params);
5503
- for (const row of listedRows.filter((entry) => entry?.consumed_at)) failConsumedRowTerminally(row, 'stale-recovery');
5556
+ for (const row of listedRows.filter((entry) => entry?.consumed_at)) {
5557
+ failConsumedRowTerminally(row, 'stale-recovery');
5558
+ noteRestartTurnOutcome(row, 'cut');
5559
+ }
5504
5560
  const allRows = listedRows.filter((entry) => !entry?.consumed_at);
5505
5561
  if (!allRows.length) return [];
5506
5562
  // Enforce the retry budget before requeueing: failing these rows first
5507
5563
  // flips them out of 'processing' so the recovery UPDATE below skips them.
5508
5564
  const overBudget = allRows.filter((row) => Number(row?.retry_count || 0) + 1 >= MAX_REQUEUE_RETRIES);
5509
- for (const row of overBudget) failRecoveredRowTerminally(row, 'stale-recovery');
5565
+ for (const row of overBudget) {
5566
+ failRecoveredRowTerminally(row, 'stale-recovery');
5567
+ noteRestartTurnOutcome(row, 'failed');
5568
+ }
5510
5569
  const rows = allRows.filter((row) => Number(row?.retry_count || 0) + 1 < MAX_REQUEUE_RETRIES);
5511
5570
  if (!rows.length) return [];
5512
5571
 
@@ -5531,6 +5590,7 @@ function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso
5531
5590
 
5532
5591
  for (const row of rows) {
5533
5592
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
5593
+ noteRestartTurnOutcome(row, 'requeued');
5534
5594
  }
5535
5595
  io.emit('queue_updated', { recovered: rows.length });
5536
5596
  return rows;
@@ -6274,6 +6334,12 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6274
6334
  attribution: claudeAttributionForConversation(conv, out.providerModel || out.model, {
6275
6335
  relayFolderFallback: deliveredProvider === 'claude',
6276
6336
  }),
6337
+ // The Claude tab's background command limit; the worker
6338
+ // hands it to the CLI it spawns next. Claude Cloud runs no
6339
+ // local CLI, so it does not get one.
6340
+ ...(deliveredProvider === 'claude'
6341
+ ? { claudeBackgroundCommandLimitMs: claudeBackgroundCommandLimitMinutesToMs(getClaudeBackgroundCommandLimitMinutes()) }
6342
+ : {}),
6277
6343
  };
6278
6344
  })()),
6279
6345
  };
@@ -6389,14 +6455,17 @@ function recoverDeadWorkerProcessingRows(sessionId, reason) {
6389
6455
  // No user prompt to replay: a dead worker's continuation is torn down.
6390
6456
  stmts.dropStaleContinuation?.run?.(row.id);
6391
6457
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
6458
+ noteRestartTurnOutcome(row, 'cut');
6392
6459
  continue;
6393
6460
  }
6394
6461
  if (row.consumed_at) {
6395
6462
  failConsumedRowTerminally(row, `worker-died-${reason}`);
6463
+ noteRestartTurnOutcome(row, 'cut');
6396
6464
  continue;
6397
6465
  }
6398
6466
  if (Number(row.retry_count || 0) + 1 >= MAX_REQUEUE_RETRIES) {
6399
6467
  failRecoveredRowTerminally(row, `worker-died-${reason}`);
6468
+ noteRestartTurnOutcome(row, 'failed');
6400
6469
  continue;
6401
6470
  }
6402
6471
  // The dead worker's subagent runs can never be terminated by the replay
@@ -6404,6 +6473,7 @@ function recoverDeadWorkerProcessingRows(sessionId, reason) {
6404
6473
  closeStrandedSubagentRuns(stmts.listRunningSubagentRunsByQueueMessage?.all?.(row.id) || [], row.id);
6405
6474
  stmts.recoverProcessingRowKeepOwner?.run?.(requeueAt, row.id);
6406
6475
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
6476
+ noteRestartTurnOutcome(row, 'requeued');
6407
6477
  recovered += 1;
6408
6478
  }
6409
6479
  if (recovered > 0) {
@@ -6516,7 +6586,7 @@ function sessionHasBackgroundWork(sid) {
6516
6586
  const detachedWorkerWatch = createDetachedWorkerWatch({
6517
6587
  isEnabled: () => featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true
6518
6588
  && !runtimeShutdownStarted
6519
- && !pendingRelayShutdownRequest,
6589
+ && !relayRestartQueue.isQueued(),
6520
6590
  listWaitingSessionIds: () => (stmts.listPendingWorkerOwnerSessionIds?.all?.(25) || [])
6521
6591
  .map((row) => row?.sdk_session_id),
6522
6592
  hasWorkerSocket: (sid) => sessionWorkerWebSocketService.hasWorkerSocket(sid) === true,
@@ -6713,10 +6783,10 @@ const runtimeState = {
6713
6783
  get relayPaused() { return relayPaused; },
6714
6784
  set relayPaused(value) { relayPaused = value; },
6715
6785
  get ttyConsoleActive() { return Boolean(ttyConsoleRuntime?.tty); },
6716
- get relayShutdown() { return getRelayShutdownState(); },
6786
+ get relayShutdown() { return relayRestartQueue.getState(); },
6717
6787
  // A shutdown or restart waits for the processing turns: no pending row
6718
- // goes out meanwhile (see requestRelayShutdown).
6719
- get relayShutdownQueued() { return !!pendingRelayShutdownRequest && !runtimeShutdownStarted; },
6788
+ // goes out meanwhile (services/relay-restart-queue.mjs).
6789
+ get relayShutdownQueued() { return relayRestartQueue.isQueued(); },
6720
6790
  get hostSuspend() { return hostSuspendService.getState(); },
6721
6791
  get usageLimit() {
6722
6792
  return { account: usageLimitPauseService.getAccountState(), pauses: usageLimitPauseService.listPauses() };
@@ -6959,6 +7029,14 @@ const sharedRouteDeps = {
6959
7029
  resolveLiveTurnQueueRow,
6960
7030
  backgroundTaskStore,
6961
7031
  backgroundWorkView,
7032
+ taskOutputSnapshots,
7033
+ // A closure: the poller is built further down the file.
7034
+ pokeTaskHealth: () => taskHealthPoller.poke(),
7035
+ getTaskStallMinutes,
7036
+ setTaskStallMinutes,
7037
+ getRestartResumeEnabled,
7038
+ setRestartResumeEnabled,
7039
+ noteRestartTurnOutcome,
6962
7040
  // A closure, not the registry itself: this deps object is built before the
6963
7041
  // preview lane is constructed further down the file.
6964
7042
  listConversationPreviews: (conversationId) => previewRegistry.listForConversation(conversationId),
@@ -7088,6 +7166,16 @@ registerRelayEventRoutes(app, {
7088
7166
  relayEventService,
7089
7167
  remoteRelayInbound,
7090
7168
  });
7169
+ registerRelayRestartRoutes(app, {
7170
+ auth,
7171
+ // Read only: a GET neither fires the restart nor asks the question.
7172
+ getState: () => relayRestartQueue.getState(),
7173
+ collectRestartPicture,
7174
+ keepWaiting: keepWaitingForRelayShutdown,
7175
+ getMaxWaitMinutes: getRelayRestartMaxWaitMinutes,
7176
+ setMaxWaitMinutes: setRelayRestartMaxWaitMinutes,
7177
+ maxWaitOverrideMs: relayRestartMaxWaitOverrideMs,
7178
+ });
7091
7179
  registerAboutRoutes(app, {
7092
7180
  auth,
7093
7181
  aboutService: createAboutService({
@@ -7178,6 +7266,8 @@ function recoverStaleMessages() {
7178
7266
  const ceilingBeforeIso = ceilingMs > 0 ? new Date(Date.now() - ceilingMs).toISOString() : null;
7179
7267
  const requeueAt = addMsIso(cliOnline ? 30_000 : 2_000);
7180
7268
  const recoveredRows = recoverProcessingOlderThan(cutoff, requeueAt, { ceilingBeforeIso });
7269
+ // Turns of the start that ended on their own decide their session's note.
7270
+ try { restartResumeService.tick(); } catch {}
7181
7271
  if (recoveredRows.length > 0) {
7182
7272
  const ceilingNote = ceilingBeforeIso ? `, ceiling ${Math.round(ceilingMs / 60_000)}min` : '';
7183
7273
  console.log(
@@ -7487,6 +7577,43 @@ function sanitizeRelayQuestionContext(rawContext) {
7487
7577
  const subagentViewerRooms = createSubagentViewerRooms({
7488
7578
  getRun: (runId) => stmts.getSubagentRun?.get?.(runId) || null,
7489
7579
  });
7580
+ // A background task's output goes live only to the pages whose console shows
7581
+ // it; the task must be in the conversation's live or ended set, under the
7582
+ // conversation's id or its worker's session id (as the output route checks).
7583
+ const taskOutputRooms = createTaskOutputRooms({
7584
+ io,
7585
+ resolveTask: (conversationId, taskId) => {
7586
+ const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
7587
+ if (!conversation) return null;
7588
+ const keys = [conversationId, String(conversation.sdk_session_id || conversationId)];
7589
+ return backgroundTaskStore.findTask(keys, taskId);
7590
+ },
7591
+ });
7592
+ // The stall hint and the pass/fail chip of the panel's rows: every 10 s over
7593
+ // the live tasks with an output file.
7594
+ const taskHealthPoller = createTaskHealthPoller({ store: backgroundTaskStore, io });
7595
+ taskHealthPoller.start();
7596
+ // Console tasks held since the restart whose worker has not reported within
7597
+ // twice the boot window (at least 15 s: a test relay has none): that worker
7598
+ // did not survive the restart. They are marked ended (their output stays
7599
+ // readable for the hour), never running.
7600
+ runtimeTimers.restoredTaskSettle = setTimeout(() => {
7601
+ runtimeTimers.restoredTaskSettle = null;
7602
+ const keys = backgroundTaskStore.endUnconfirmed();
7603
+ if (!keys.length) return;
7604
+ console.log(`${runtimeLogPrefix()}BG-TASKS ${keys.length} session(s) did not report after the restart; their console tasks are marked ended`);
7605
+ for (const key of keys) {
7606
+ try {
7607
+ io.emit('background_tasks', {
7608
+ conversationId: key,
7609
+ tasks: backgroundTaskStore.withHealth(backgroundTaskStore.get(key), key),
7610
+ endedTasks: backgroundTaskStore.ended(key),
7611
+ });
7612
+ } catch {}
7613
+ }
7614
+ taskHealthPoller.poke();
7615
+ }, Math.max(15_000, 2 * resolveBootReportingWindowMs(process.env)));
7616
+ runtimeTimers.restoredTaskSettle?.unref?.();
7490
7617
  // The handshake presents the token as `auth.token` or the cookie; a query
7491
7618
  // `token` is not read (see services/relay-auth.mjs).
7492
7619
  io.use(relayAuth.socketMiddleware);
@@ -7524,6 +7651,7 @@ io.on('connection', (socket) => {
7524
7651
  activeDeviceTracker.registerSocket(socket);
7525
7652
  tmuxInspectorSocketService.registerSocket(socket);
7526
7653
  subagentViewerRooms.registerSocket(socket);
7654
+ taskOutputRooms.registerSocket(socket);
7527
7655
  });
7528
7656
 
7529
7657
  // ─── SSH Reverse Tunnel ────────────────────────────────────────────────────────
@@ -7654,15 +7782,19 @@ function clearRuntimeTimers() {
7654
7782
  for (const timer of Object.values(runtimeTimers)) {
7655
7783
  if (!timer) continue;
7656
7784
  try { clearInterval(timer); } catch {}
7785
+ try { clearTimeout(timer); } catch {}
7657
7786
  }
7658
7787
  subagentRecordRetention?.stop();
7788
+ try { taskHealthPoller.stop(); } catch {}
7789
+ try { taskOutputRooms.stop(); } catch {}
7659
7790
  runtimeTimers.cliStatus = null;
7660
7791
  runtimeTimers.ownerWatchdog = null;
7661
7792
  runtimeTimers.staleRecovery = null;
7662
7793
  runtimeTimers.questionExpiry = null;
7663
7794
  runtimeTimers.pendingWorkerPrime = null;
7664
7795
  runtimeTimers.sharedViewerPrune = null;
7665
- runtimeTimers.shutdownDrain = null;
7796
+ runtimeTimers.restoredTaskSettle = null;
7797
+ try { relayRestartQueue.dispose(); } catch {}
7666
7798
  }
7667
7799
 
7668
7800
  let runtimeShutdownPromise = null;