@oar-sh/oar 0.9.21 → 0.9.23

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 (82) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/README.md +9 -9
  3. package/bin/oar.js +28 -0
  4. package/package.json +13 -14
  5. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -1
  6. package/server/claude-worker/claude-cli-pinned-env.mjs +4 -1
  7. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  8. package/server/claude-worker/claude-session-process.mjs +9 -4
  9. package/server/claude-worker/claude-session-worker-main.mjs +10 -1
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +1 -0
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +10 -1
  12. package/server/cursor-worker/cursor-session-worker.mjs +9 -1
  13. package/server/db-schema.mjs +19 -0
  14. package/server/grok-worker/grok-background-tasks.mjs +1 -0
  15. package/server/grok-worker/grok-session-worker.mjs +10 -1
  16. package/server/grok-worker/grok-turn-runner.mjs +1 -0
  17. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  18. package/server/migrations/0038-turn-seq.mjs +92 -0
  19. package/server/public/app/activity-replay-state.mjs +160 -42
  20. package/server/public/app/api-client.js +7 -0
  21. package/server/public/app/ask-user-view.js +4 -3
  22. package/server/public/app/bootstrap.js +62 -4
  23. package/server/public/app/chat-actions-menu-state.mjs +2 -0
  24. package/server/public/app/context-usage-view.mjs +68 -0
  25. package/server/public/app/conversation-view.js +306 -49
  26. package/server/public/app/header-priority-actions.mjs +5 -1
  27. package/server/public/app/held-details.mjs +202 -0
  28. package/server/public/app/inbox-ui.js +70 -79
  29. package/server/public/app/inbox-view.mjs +35 -19
  30. package/server/public/app/journal-view.js +17 -0
  31. package/server/public/app/live-activity-cap.mjs +103 -0
  32. package/server/public/app/questions-panel-ui.js +634 -0
  33. package/server/public/app/questions-stub-view.mjs +40 -0
  34. package/server/public/app/questions-view.mjs +89 -0
  35. package/server/public/app/relay-board-view.js +12 -2
  36. package/server/public/app/relay-thoughts.mjs +15 -1
  37. package/server/public/app/session-history-ui.js +1 -0
  38. package/server/public/app/settings-modal.js +6 -0
  39. package/server/public/app/socket-handlers.js +42 -11
  40. package/server/public/app/stream-view-preference.mjs +60 -0
  41. package/server/public/app/subagent-fold.mjs +6 -3
  42. package/server/public/app/subagent-timeline-view.mjs +12 -5
  43. package/server/public/app/subagent-viewer.mjs +8 -6
  44. package/server/public/app/thoughts-preference.mjs +79 -0
  45. package/server/public/app/turn-stream-view.mjs +124 -0
  46. package/server/public/index.html +391 -214
  47. package/server/repositories/message-repository.mjs +10 -0
  48. package/server/repositories/question-repository.mjs +51 -7
  49. package/server/repositories/relay-turn-rows.mjs +56 -0
  50. package/server/routes/ask-user-routes.mjs +34 -5
  51. package/server/routes/messages-routes.mjs +46 -34
  52. package/server/routes/relay-ask-routes.mjs +15 -5
  53. package/server/routes/sessions-routes.mjs +77 -6
  54. package/server/server-runtime.mjs +199 -171
  55. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  56. package/server/services/earlier-database-service.mjs +1 -1
  57. package/server/services/file-serving.mjs +13 -0
  58. package/server/services/idle-timers.mjs +184 -0
  59. package/server/services/native-addon-build-service.mjs +224 -0
  60. package/server/services/oar-cli-helpers.mjs +7 -4
  61. package/server/services/queue-wake-hook.mjs +145 -0
  62. package/server/services/queue-waker.mjs +127 -0
  63. package/server/services/relay-ask-service.mjs +64 -5
  64. package/server/services/relay-console-log.mjs +2 -3
  65. package/server/services/relay-event-service.mjs +4 -1
  66. package/server/services/remote-relay-dispatcher.mjs +23 -4
  67. package/server/services/remote-relay-loopback.mjs +3 -3
  68. package/server/services/sdk-session-sync-service.mjs +1 -1
  69. package/server/services/session-history-refresh-service.mjs +4 -0
  70. package/server/services/session-worker-supervisor-service.mjs +36 -0
  71. package/server/services/session-worker-websocket-service.mjs +143 -15
  72. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  73. package/server/services/update-install-service.mjs +32 -1
  74. package/shared/ask-user-bridge.mjs +9 -6
  75. package/shared/relay-asks.mjs +10 -0
  76. package/shared/relay-events.mjs +1 -0
  77. package/shared/worker-runtime/heartbeat.mjs +26 -0
  78. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  79. package/shared/worker-runtime/worker-websocket-link.mjs +21 -10
  80. package/server/services/package-data-rescue-preinstall.mjs +0 -22
  81. package/server/services/package-data-rescue.mjs +0 -139
  82. package/server/tty-console-bootstrap.mjs +0 -75
@@ -4,7 +4,7 @@ import express from 'express';
4
4
  import http from 'http';
5
5
  import { Server } from 'socket.io';
6
6
  import { WebSocketServer } from 'ws';
7
- import { v4 as uuidv4 } from 'uuid';
7
+ import { randomUUID as uuidv4 } from 'crypto';
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
10
  import { execFile, execFileSync, spawn } from 'child_process';
@@ -25,7 +25,7 @@ import {
25
25
  } from './services/workspace-root-path-policy.mjs';
26
26
  import { stopSessionWorkerProcesses } from './services/session-worker-stop-service.mjs';
27
27
  import { createDetachedWorkerWatch } from './services/detached-worker-watch-service.mjs';
28
- import { applyCdnNoStoreHeaders } from './services/file-serving.mjs';
28
+ import { applyCdnNoStoreHeaders, sendOwnFile } from './services/file-serving.mjs';
29
29
  import { pickLiveTurnRowId } from './services/live-turn-picker.mjs';
30
30
  import { buildSteerSettleFailure, steerAgentLabelForProvider } from '../shared/steer-settle-failure.mjs';
31
31
  import { PLAN_BOARD_APPROVE_ONLY, planBoardShownActions } from '../shared/plan-board-actions.mjs';
@@ -36,6 +36,7 @@ import { applySchema } from './db-schema.mjs';
36
36
  import { createSessionRepository } from './repositories/session-repository.mjs';
37
37
  import { createMessageRepository } from './repositories/message-repository.mjs';
38
38
  import { createQuestionRepository } from './repositories/question-repository.mjs';
39
+ import { mapRelayActivityRow, mapRelayThoughtRow, sanitizeActivityText } from './repositories/relay-turn-rows.mjs';
39
40
  import { createImageConversationRepository } from './repositories/image-conversation-repository.mjs';
40
41
  import { registerSessionsRoutes } from './routes/sessions-routes.mjs';
41
42
  import { buildDequeuedRelayMessage, claudeApiRemedyText, dequeuePendingMessageForWorkerLoop, registerMessagesRoutes } from './routes/messages-routes.mjs';
@@ -195,8 +196,8 @@ import {
195
196
  resolveDefaultSessionWorkspaceRootState as resolveDefaultSessionWorkspaceRootStateFromService,
196
197
  resolveLaunchWorkspaceRootPath,
197
198
  } from './services/workspace-root-defaults-service.mjs';
198
- import { maybeStartTtyConsole } from './tty-console-bootstrap.mjs';
199
199
  import { installRelayConsoleLog, resolveRelayConsoleLogPath } from './services/relay-console-log.mjs';
200
+ import { isMissingSqliteBindingError, missingSqliteBindingLines } from './services/native-addon-build-service.mjs';
200
201
  import {
201
202
  computeFeatureFlagState,
202
203
  FEATURE_REGISTRY,
@@ -233,6 +234,9 @@ import {
233
234
  import { createRestartPictureCollector, detectRelayHosting } from './services/relay-restart-picture.mjs';
234
235
  import { createWorkerScope } from './services/worker-scope.mjs';
235
236
  import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
237
+ import { createArmableInterval, createCliPresence } from './services/idle-timers.mjs';
238
+ import { installQueueWakeHook } from './services/queue-wake-hook.mjs';
239
+ import { createPendingWorkerPrimer, createQueueWaker } from './services/queue-waker.mjs';
236
240
  import { createStaleTurnRecovery } from './services/stale-turn-recovery.mjs';
237
241
  import {
238
242
  STALE_SUBAGENT_RUN_BOOT_DELAY_MS,
@@ -270,7 +274,11 @@ import {
270
274
  readTaskStallMinutesSetting,
271
275
  } from '../shared/task-output-text.mjs';
272
276
  import { normalizeRelayThoughtList } from './public/app/relay-thoughts.mjs';
273
- import { capRelayActivityEntries } from './public/app/activity-replay-state.mjs';
277
+ import {
278
+ capFinishedActivityEntries,
279
+ capRelayActivityEntries,
280
+ inFlightActivityCap,
281
+ } from './public/app/activity-replay-state.mjs';
274
282
  import {
275
283
  isOpenAIModelId,
276
284
  isSafeClaudeModelId,
@@ -319,13 +327,15 @@ const PWA_VERSION_PLACEHOLDER = /const __PWA_VERSION = '[^']*';/;
319
327
  // after a restart or an incident.
320
328
  const relayConsoleLogPath = resolveRelayConsoleLogPath({ env: process.env, serverDir: __dirname });
321
329
  const relayConsoleLog = installRelayConsoleLog({ logPath: relayConsoleLogPath });
322
- const ttyConsoleRuntime = await maybeStartTtyConsole({
323
- serverDir: __dirname,
324
- logsDir: path.join(__dirname, 'logs'),
325
- logger: console,
326
- });
327
- // The terminal console takes the console's methods over when it starts.
328
- relayConsoleLog?.rewrap();
330
+ // Where better-sqlite3 ships no binary (Termux, 32-bit ARM) the binding is
331
+ // built by `oar build-native`; until then say so instead of a stack trace.
332
+ try {
333
+ new Database(':memory:').close();
334
+ } catch (error) {
335
+ if (!isMissingSqliteBindingError(error)) throw error;
336
+ for (const line of missingSqliteBindingLines({ error })) console.error(`[startup] ${line}`);
337
+ process.exit(1);
338
+ }
329
339
  // TEMP and TMP for this relay and everything it starts (normalizeTempEnv).
330
340
  for (const change of normalizeTempEnv()) console.log(`[startup] ${change} (quotes taken off, or the folder is not there)`);
331
341
 
@@ -2005,12 +2015,9 @@ let runtimeShutdownStarted = false;
2005
2015
  // Set once the database is open (see the retention sweep near the other sweeps).
2006
2016
  let subagentRecordRetention = null;
2007
2017
  const runtimeTimers = {
2008
- cliStatus: null,
2009
2018
  ownerWatchdog: null,
2010
2019
  staleRecovery: null,
2011
2020
  questionExpiry: null,
2012
- pendingWorkerPrime: null,
2013
- sharedViewerPrune: null,
2014
2021
  restoredTaskSettle: null,
2015
2022
  claudeApiCapClock: null,
2016
2023
  memoryLog: null,
@@ -2019,6 +2026,25 @@ const runtimeTimers = {
2019
2026
  archivedWorkerSweep: null,
2020
2027
  };
2021
2028
 
2029
+ // The worker prime and the detached-worker sweep, every 5 s while a pending
2030
+ // row waits for its worker (rows in retry backoff included, so the worker is
2031
+ // warm when the backoff matures). Armed at startup, by every prime that finds
2032
+ // a row and by every queue snapshot with a row pending; a pass that leaves no
2033
+ // such row disarms it. Declared up here so a prime early in the boot can arm
2034
+ // it; its tick runs only after the boot.
2035
+ const pendingWorkerPrimeTimer = createArmableInterval({
2036
+ intervalMs: 5_000,
2037
+ tick: async () => {
2038
+ await Promise.allSettled([
2039
+ primePendingSessionWorkers('maintenance'),
2040
+ detachedWorkerWatch.sweep().catch((error) => {
2041
+ console.warn(`${runtimeLogPrefix()}WORKER DETACHED sweep failed: ${error?.message || error}`);
2042
+ }),
2043
+ ]);
2044
+ return hasPendingWorkerOwnerRows();
2045
+ },
2046
+ });
2047
+
2022
2048
  function normalizeRemotePath(value) {
2023
2049
  if (typeof value !== 'string') return '';
2024
2050
  const trimmed = value.trim();
@@ -2088,7 +2114,6 @@ function workspaceRootPayload() {
2088
2114
  }
2089
2115
 
2090
2116
  const ACTIVE_SESSION_CWD_STATUSES = new Set(['starting', 'ready', 'processing']);
2091
- const ACTIVE_TTY_CLI_WORKER_STATUSES = new Set(['starting', 'ready', 'processing']);
2092
2117
 
2093
2118
  function normalizeConversationWorkspaceRootPath(candidatePath) {
2094
2119
  const normalized = normalizeWorkspaceRootPath(candidatePath);
@@ -2861,6 +2886,16 @@ const stmts = {
2861
2886
  ...createQuestionRepository(db),
2862
2887
  ...createImageConversationRepository(db),
2863
2888
  };
2889
+ // Every queue write that can make a row deliverable wakes the delivery
2890
+ // (services/queue-wake-hook.mjs): installed before any route or startup
2891
+ // write. The waker exists once the worker socket service does; a write
2892
+ // before that is covered by the startup prime and the service's start.
2893
+ // Without the hook (it could not be installed) the queue is polled.
2894
+ let queueWaker = null;
2895
+ const queueWakeHook = installQueueWakeHook(db, {
2896
+ onWake: (reason) => { queueWaker?.wake(reason, { prime: false }); },
2897
+ log: (line) => console.warn(`[queue-wake] ${line}`),
2898
+ });
2864
2899
  const statusEventService = createStatusEventService(db);
2865
2900
  // Public shares and their access log (migration 0016).
2866
2901
  const shareRepository = createShareRepository(db);
@@ -3422,6 +3457,8 @@ const sessionWorkerSupervisor = createSessionWorkerSupervisor({
3422
3457
  diagnosticPlanReference: () => path.join(currentWorkspaceRootPath(), '.cursor', 'plans', 'worker-startup-monitoring-plan.md'),
3423
3458
  log: (message) => console.warn(`${runtimeLogPrefix()}${message}`),
3424
3459
  onLaunchState: (event) => workerLaunchNoteService.handle(event),
3460
+ // A kill's grace ended: the session's rows may go to its next worker.
3461
+ onKillBlockLifted: () => { wakeSessionWorkerQueue('kill-block-lifted'); },
3425
3462
  });
3426
3463
  // Feature flags moved from config.json into app_settings; adopt any legacy
3427
3464
  // `features` key once, then resolve the boot snapshot (defaults < stored rows
@@ -3724,27 +3761,6 @@ function queueCounts() {
3724
3761
  return { pendingCount: map.pending || 0, processingCount: map.processing || 0, parkedCount: map.parked || 0 };
3725
3762
  }
3726
3763
 
3727
- function countActiveCliWorkers() {
3728
- const workers = sessionWorkerRegistry?.listWorkers?.() || [];
3729
- return workers.reduce((acc, worker) => {
3730
- const status = String(worker?.status || '').trim().toLowerCase();
3731
- return ACTIVE_TTY_CLI_WORKER_STATUSES.has(status) ? acc + 1 : acc;
3732
- }, 0);
3733
- }
3734
-
3735
- function attachTtyConsoleRuntimeTitle(runtime) {
3736
- const addConsoleTitle = runtime?.tty?.addConsoleTitle;
3737
- if (typeof addConsoleTitle !== 'function') return;
3738
- addConsoleTitle(() => {
3739
- const counts = queueCounts();
3740
- const connectedClients = Number(io?.sockets?.sockets?.size || 0);
3741
- const activeCliWorkers = countActiveCliWorkers();
3742
- const cliState = cliOnline ? 'online' : 'offline';
3743
- return `Clients: ${connectedClients} :: Pen: ${counts.pendingCount} :: Proc: ${counts.processingCount} :: parked: ${counts.parkedCount} :: CLIs: ${activeCliWorkers} :: CLI: ${cliState}`;
3744
- });
3745
- }
3746
- attachTtyConsoleRuntimeTitle(ttyConsoleRuntime);
3747
-
3748
3764
  // ─── Queued relay restart / shutdown ─────────────────────────────────────────
3749
3765
  // A request waits until no turn is processing and holds new turns meanwhile
3750
3766
  // (services/relay-shutdown-gate.mjs); past the maximum wait it asks instead
@@ -3822,7 +3838,11 @@ function relayShutdownStatePayload() {
3822
3838
 
3823
3839
  function cancelRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
3824
3840
  const result = relayRestartQueue.cancel({ requestedBy });
3825
- if (result?.cancelled) relayEventService?.noteRestartCancelled();
3841
+ if (result?.cancelled) {
3842
+ relayEventService?.noteRestartCancelled();
3843
+ // The queued restart held deliveries back: they go out now.
3844
+ wakeSessionWorkerQueue('restart-cancelled');
3845
+ }
3826
3846
  return result;
3827
3847
  }
3828
3848
 
@@ -5391,52 +5411,28 @@ function createCompactedConversation(sourceConversationId) {
5391
5411
  };
5392
5412
  }
5393
5413
 
5394
- function sanitizeActivityText(value) {
5395
- const text = String(value || '').trim();
5396
- if (!text) return '';
5397
- return text.slice(0, 200);
5398
- }
5399
-
5400
- function mapRelayActivityRow(row) {
5401
- const text = sanitizeActivityText(row?.text);
5402
- if (!text) return null;
5403
- const subagentRunId = row?.subagent_run_id ? String(row.subagent_run_id).trim() : null;
5404
- // Structured rows (compaction boundaries) carry their payload alongside the
5405
- // prose so the transcript can promote them to a break row; junk JSON is
5406
- // simply dropped back to a prose-only entry.
5407
- let metadata = null;
5408
- if (row?.metadata_json) {
5409
- try {
5410
- const parsed = JSON.parse(String(row.metadata_json));
5411
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) metadata = parsed;
5412
- } catch { metadata = null; }
5413
- }
5414
- return {
5415
- text,
5416
- subagentRunId: subagentRunId || null,
5417
- ...(metadata ? { metadata } : {}),
5418
- };
5419
- }
5420
-
5421
- const RELAY_ACTIVITY_ROW_LIMIT = 48;
5422
-
5414
+ // Per thread (the main agent, each sub-agent), the newest rows; structured
5415
+ // rows are always kept (activity-replay-state.mjs). A finished turn: the main
5416
+ // agent's 200, each sub-agent's 50, and a turn whose main agent ran more says
5417
+ // how many it left out.
5423
5418
  function relayActivityForResponse(responseMessageId) {
5424
- return capRelayActivityEntries(
5419
+ return capFinishedActivityEntries(
5425
5420
  stmts.listActivityByResponse
5426
5421
  .all(responseMessageId)
5427
5422
  .map(mapRelayActivityRow)
5428
5423
  .filter(Boolean),
5429
- RELAY_ACTIVITY_ROW_LIMIT,
5430
5424
  );
5431
5425
  }
5432
5426
 
5433
- function relayActivityForQueueMessage(queueMessageId) {
5427
+ // A running turn: as many per thread as the browser shows (`activityCap`, the
5428
+ // request's param; 50 when it sends none, 500 at most).
5429
+ function relayActivityForQueueMessage(queueMessageId, activityCap = undefined) {
5434
5430
  return capRelayActivityEntries(
5435
5431
  stmts.listActivityByQueueMessage
5436
5432
  .all(queueMessageId)
5437
5433
  .map(mapRelayActivityRow)
5438
5434
  .filter(Boolean),
5439
- RELAY_ACTIVITY_ROW_LIMIT,
5435
+ inFlightActivityCap(activityCap),
5440
5436
  );
5441
5437
  }
5442
5438
 
@@ -5458,19 +5454,6 @@ function relayStreamEventsForQueueMessage(queueMessageId) {
5458
5454
  .slice(0, 5000);
5459
5455
  }
5460
5456
 
5461
- function mapRelayThoughtRow(row) {
5462
- const seq = Math.max(0, Math.trunc(Number(row?.seq || 0)));
5463
- const subagentRunId = row?.subagent_run_id ? String(row.subagent_run_id).trim() : null;
5464
- return {
5465
- reasoningId: row?.reasoning_id ? String(row.reasoning_id) : null,
5466
- seq,
5467
- text: String(row?.text || ''),
5468
- done: Number(row?.done || 0) === 1,
5469
- timestamp: row?.created_at || null,
5470
- subagentRunId: subagentRunId || null,
5471
- };
5472
- }
5473
-
5474
5457
  function relayThoughtsForResponse(responseMessageId) {
5475
5458
  const rows = stmts.listThoughtsByResponse?.all(responseMessageId) || [];
5476
5459
  return normalizeRelayThoughtList(rows.map(mapRelayThoughtRow)).slice(0, 5000);
@@ -5654,7 +5637,7 @@ const usageLimitPauseService = createUsageLimitPauseService({
5654
5637
  stmts,
5655
5638
  emit: (event, payload) => io.emit(event, payload),
5656
5639
  // A follow-up resumed by hand is due at once: wake its worker.
5657
- onQueueChanged: (reason) => { void primePendingSessionWorkers(reason); },
5640
+ onQueueChanged: (reason) => { wakeSessionWorkerQueue(reason); },
5658
5641
  logger: console,
5659
5642
  });
5660
5643
 
@@ -5686,8 +5669,7 @@ const claudeBillingSwitchService = createClaudeBillingSwitchService({
5686
5669
  // What the swap waits for: a turn, a question, a compaction, background tasks.
5687
5670
  getPending: (runtime) => claudeBillingSwapService.pendingFor(runtime?.sdk_session_id || runtime?.conversation_id),
5688
5671
  onQueueChanged: (reason) => {
5689
- void primePendingSessionWorkers(reason);
5690
- sessionWorkerWebSocketService?.emitQueueChanged?.(reason);
5672
+ wakeSessionWorkerQueue(reason);
5691
5673
  },
5692
5674
  logger: console,
5693
5675
  });
@@ -5735,8 +5717,7 @@ const restartResumeService = createRestartResumeService({
5735
5717
  // The note is an ordinary pending message: the primer starts a stopped
5736
5718
  // worker, the socket service hands it to a running one.
5737
5719
  wake: () => {
5738
- void primePendingSessionWorkers('restart-resume');
5739
- sessionWorkerWebSocketService?.emitQueueChanged?.('restart-resume');
5720
+ wakeSessionWorkerQueue('restart-resume');
5740
5721
  },
5741
5722
  logger: console,
5742
5723
  });
@@ -5766,8 +5747,7 @@ relayEventService = createRelayEventService({
5766
5747
  // The event row is an ordinary pending message: the primer starts a
5767
5748
  // stopped worker, the socket service hands it to a running one.
5768
5749
  wakeWatcher: () => {
5769
- void primePendingSessionWorkers('relay-event');
5770
- sessionWorkerWebSocketService?.emitQueueChanged?.('relay-event');
5750
+ wakeSessionWorkerQueue('relay-event');
5771
5751
  },
5772
5752
  relayName: () => remoteRelayRegistry?.selfName?.() || getPwaAppName?.() || '',
5773
5753
  onRemoteChange: (relayId) => { void relayEventLinkService?.fetchNow(relayId); },
@@ -5783,8 +5763,7 @@ const relayScheduleService = createRelayScheduleService({
5783
5763
  emit: (event, payload) => io.emit(event, payload),
5784
5764
  // The scheduled message is an ordinary pending one, like a relay event's.
5785
5765
  wake: () => {
5786
- void primePendingSessionWorkers('relay-schedule');
5787
- sessionWorkerWebSocketService?.emitQueueChanged?.('relay-schedule');
5766
+ wakeSessionWorkerQueue('relay-schedule');
5788
5767
  },
5789
5768
  // A firing into another session is a prompt of the conversation that set
5790
5769
  // it: the agents' loop guard counts it (and holds it once it wants the user).
@@ -5847,7 +5826,9 @@ function resolveLiveTurnQueueRow(conversationId) {
5847
5826
  return byId.get(liveId) || rows[0];
5848
5827
  }
5849
5828
 
5850
- function inFlightStateForConversation(conversationId) {
5829
+ // `activityCap`: the tool lines per thread the asking page shows (the
5830
+ // conversation route passes its `activityCap` param; others get the default).
5831
+ function inFlightStateForConversation(conversationId, { activityCap = undefined } = {}) {
5851
5832
  const row = resolveLiveTurnQueueRow(conversationId);
5852
5833
  if (!row) return null;
5853
5834
  const streamEvents = relayStreamEventsForQueueMessage(row.id);
@@ -5859,7 +5840,7 @@ function inFlightStateForConversation(conversationId) {
5859
5840
  mode: normalizeRelayMode(row.relay_mode) || DEFAULT_RELAY_MODE,
5860
5841
  timestamp: row.timestamp || null,
5861
5842
  processingAt: row.processing_at || null,
5862
- activities: relayActivityForQueueMessage(row.id),
5843
+ activities: relayActivityForQueueMessage(row.id, activityCap),
5863
5844
  thoughts: relayThoughtsForQueueMessage(row.id),
5864
5845
  streamEvents,
5865
5846
  streamDone: !!lastStreamEvent?.done,
@@ -6392,6 +6373,15 @@ const SHARED_VIEWER_MAX_GLOBAL = Number.isFinite(Number(config.sharedPresenceMax
6392
6373
  ? Math.max(SHARED_VIEWER_MAX_PER_CONVERSATION, Math.trunc(Number(config.sharedPresenceMaxGlobal)))
6393
6374
  : 5_000;
6394
6375
  const sharedViewersByConversation = new Map();
6376
+ // Armed when the first share viewer arrives, disarmed by the prune that
6377
+ // leaves none: a relay nobody watches through a share link has no timer.
6378
+ const sharedViewerPruneTimer = createArmableInterval({
6379
+ intervalMs: 5_000,
6380
+ tick: () => {
6381
+ pruneSharedViewerPresence();
6382
+ return sharedViewersByConversation.size > 0;
6383
+ },
6384
+ });
6395
6385
 
6396
6386
  function sanitizeSharedViewerId(value) {
6397
6387
  const id = String(value || '').trim().replace(/[^a-zA-Z0-9:_-]+/g, '');
@@ -6532,6 +6522,7 @@ function markSharedViewerPresence({ conversationId, token, viewerId } = {}) {
6532
6522
  }
6533
6523
  const before = viewers.size;
6534
6524
  viewers.set(key, { lastSeenAt: now });
6525
+ sharedViewerPruneTimer.arm();
6535
6526
  const watcherCount = viewers.size;
6536
6527
  if (before !== watcherCount) {
6537
6528
  emitConversationWatcherCount(convId);
@@ -6808,7 +6799,7 @@ async function recoverUndeliveredSessionWorkerMessage({ pending = null, sessionI
6808
6799
  console.warn(`${runtimeLogPrefix()}WS REQUEUE ${messageId.slice(0, 8)} session=${requesterSessionId ? requesterSessionId.slice(0, 8) : 'unknown'} retry=${retryCount} reason=${String(reason || 'ws-send-failed')}`);
6809
6800
  io.emit('message_status', { messageId, conversationId: row.conversation_id, status: 'pending' });
6810
6801
  io.emit('queue_updated', { recovered: 1, reason: 'worker-ws-send-failed' });
6811
- sessionWorkerWebSocketService?.emitQueueChanged?.('worker-ws-send-failed');
6802
+ wakeSessionWorkerQueue('worker-ws-send-failed');
6812
6803
  return true;
6813
6804
  }
6814
6805
 
@@ -6898,11 +6889,10 @@ function recoverDeadWorkerProcessingRows(sessionId, reason) {
6898
6889
  }
6899
6890
  if (recovered > 0) {
6900
6891
  io.emit('queue_updated', { recovered, reason: `worker-died:${reason}` });
6901
- sessionWorkerWebSocketService?.emitQueueChanged?.(`worker-died:${reason}`);
6902
6892
  }
6903
6893
  sessionWorkerSupervisor?.markError?.(sid, `worker-died:${reason}`);
6904
6894
  // The rows stay owned, so the primer is what respawns their worker.
6905
- void primePendingSessionWorkers(`worker-died:${reason}`);
6895
+ wakeSessionWorkerQueue(`worker-died:${reason}`);
6906
6896
  return recovered;
6907
6897
  }
6908
6898
 
@@ -6981,7 +6971,19 @@ async function sweepDeadWorkerProcessingRowsOnce() {
6981
6971
  // work. The worker recycle raises it; the billing swap does not, since its
6982
6972
  // own delivery fence already keeps the other billing's rows from the old
6983
6973
  // worker.
6984
- const workerReplacementFence = createWorkerReplacementFence();
6974
+ // A lifted fence (the replacement launched, or its stop failed and the old
6975
+ // worker serves again) hands out what waited behind it.
6976
+ const workerReplacementFence = (() => {
6977
+ const fence = createWorkerReplacementFence();
6978
+ return {
6979
+ ...fence,
6980
+ release(sid, pid) {
6981
+ const released = fence.release(sid, pid);
6982
+ if (released) wakeSessionWorkerQueue('replacement-fence-lifted');
6983
+ return released;
6984
+ },
6985
+ };
6986
+ })();
6985
6987
  function createRuntimeWorkerReplacement(tag) {
6986
6988
  return createWorkerReplacement({
6987
6989
  registry: sessionWorkerRegistry,
@@ -7288,6 +7290,21 @@ const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
7288
7290
  },
7289
7291
  onDeliverySendFailed: recoverUndeliveredSessionWorkerMessage,
7290
7292
  onWorkerSocketClosed: handleWorkerSocketClosed,
7293
+ // The CLI presence window is wider while a worker socket is open.
7294
+ onSocketCountChange: (count) => cliPresence.noteWorkerSocketCount(count),
7295
+ // A row pending (in backoff too) keeps the worker prime armed, and the
7296
+ // poll primes now and then while rows wait.
7297
+ onQueueSnapshot: (snapshot) => {
7298
+ if (snapshot.pendingCount > 0) queueWaker?.primeFromBackstop('backstop');
7299
+ },
7300
+ // A retry time or a usage-limit hold coming due wakes the queue on time.
7301
+ nextDueAtMs: () => {
7302
+ if (runtimeShutdownStarted) return null;
7303
+ const due = stmts.nextPendingAttemptAt?.get?.(new Date().toISOString())?.due;
7304
+ const dueMs = due ? Date.parse(due) : NaN;
7305
+ return Number.isFinite(dueMs) ? dueMs : null;
7306
+ },
7307
+ onDue: (reason) => { wakeSessionWorkerQueue(reason); },
7291
7308
  requestWork: requestSessionWorkerSocketDelivery,
7292
7309
  // What a worker says it is ("claude", "claude-api", ...): the registry
7293
7310
  // keeps it for the billing fence and for what a response ran on.
@@ -7309,9 +7326,23 @@ const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
7309
7326
  buildId: BUILD_ID,
7310
7327
  isWorkerProcessAlive: (pid) => isProcessAlive(Number(pid)),
7311
7328
  pathPrefix: remotePath,
7312
- pollIntervalMs: 500,
7329
+ // A backstop under the queue's own wakes; the poll every 500 ms it was
7330
+ // when those cannot be installed.
7331
+ pollIntervalMs: queueWakeHook.installed ? 10_000 : 500,
7313
7332
  logger: console,
7314
7333
  });
7334
+ let queueWakeStarted = false;
7335
+ queueWaker = createQueueWaker({
7336
+ prime: (reason) => primePendingSessionWorkers(reason),
7337
+ notify: (reason) => {
7338
+ sessionWorkerWebSocketService.notifyQueueChanged(reason).catch((error) => {
7339
+ console.warn(`${runtimeLogPrefix()}QUEUE WAKE failed reason=${reason} err=${error?.message || error}`);
7340
+ });
7341
+ },
7342
+ armPrime: () => pendingWorkerPrimeTimer.arm(),
7343
+ isReady: () => queueWakeStarted && !runtimeShutdownStarted,
7344
+ warn: (line) => console.warn(`${runtimeLogPrefix()}${line}`),
7345
+ });
7315
7346
  runtimeTimers.deadWorkerSweep = setInterval(() => {
7316
7347
  sweepDeadWorkerProcessingRows().catch((error) => {
7317
7348
  console.warn(`${runtimeLogPrefix()}WORKER SWEEP failed: ${error?.message || error}`);
@@ -7338,40 +7369,39 @@ const tmuxInspectorSocketService = createTmuxInspectorSocketService({
7338
7369
  logger: console,
7339
7370
  });
7340
7371
 
7341
- async function primePendingSessionWorkers(reason = 'pending-worker-prime') {
7342
- if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED !== true) return 0;
7343
- if (runtimeShutdownStarted) return 0;
7344
- if (typeof sessionWorkerSupervisor?.ensureWorker !== 'function') return 0;
7345
- if (typeof stmts.listPendingWorkerOwnerSessionIds?.all !== 'function') return 0;
7346
-
7347
- // No next_attempt_at filter here: rows sitting in retry backoff still need
7348
- // their worker warm, otherwise the 2s relay poll wins the race the moment
7349
- // the backoff matures and the turn executes on the wrong provider.
7350
- const rows = stmts.listPendingWorkerOwnerSessionIds.all(25);
7351
- let requested = 0;
7352
- for (const row of rows) {
7353
- const sdkSessionId = String(row?.sdk_session_id || '').trim();
7354
- if (!sdkSessionId) continue;
7355
- if (sessionWorkerSupervisor?.isKillBlocked?.(sdkSessionId) === true) continue;
7356
- try {
7357
- // Per session: a throw here costs this session its launch, never the
7358
- // rest of the pass.
7359
- if (failClaudeApiRowsWithoutLaunch(sdkSessionId)) continue;
7360
- } catch (error) {
7361
- console.warn(`${runtimeLogPrefix()}WORKER PRIME skipped session=${sdkSessionId.slice(0, 8)} err=${error?.message || error}`);
7362
- continue;
7363
- }
7364
- requested += 1;
7365
- try {
7366
- const result = await sessionWorkerSupervisor.ensureWorker(sdkSessionId);
7367
- if (!result?.ok) {
7368
- console.warn(`${runtimeLogPrefix()}WORKER PRIME blocked session=${sdkSessionId.slice(0, 8)} reason=${String(result?.error || reason || 'pending-worker-prime')}`);
7369
- }
7370
- } catch (error) {
7371
- console.warn(`${runtimeLogPrefix()}WORKER PRIME failed session=${sdkSessionId.slice(0, 8)} reason=${String(reason || 'pending-worker-prime')} err=${error?.message || error}`);
7372
- }
7373
- }
7374
- return requested;
7372
+ // No next_attempt_at filter in the listing: rows sitting in retry backoff
7373
+ // still need their worker warm, otherwise the poll wins the race the moment
7374
+ // the backoff matures and the turn executes on the wrong provider.
7375
+ const pendingWorkerPrimer = createPendingWorkerPrimer({
7376
+ isEnabled: () => featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true
7377
+ && !runtimeShutdownStarted
7378
+ && typeof sessionWorkerSupervisor?.ensureWorker === 'function'
7379
+ && typeof stmts.listPendingWorkerOwnerSessionIds?.all === 'function',
7380
+ listPendingOwnerSessionIds: (limit) => stmts.listPendingWorkerOwnerSessionIds.all(limit),
7381
+ isKillBlocked: (sid) => sessionWorkerSupervisor?.isKillBlocked?.(sid) === true,
7382
+ failRowsWithoutLaunch: (sid) => failClaudeApiRowsWithoutLaunch(sid),
7383
+ ensureWorker: (sid) => sessionWorkerSupervisor.ensureWorker(sid),
7384
+ // Rows waiting for their worker: the maintenance pass retries until none is.
7385
+ onRowsWaiting: () => pendingWorkerPrimeTimer.arm(),
7386
+ warn: (line) => console.warn(`${runtimeLogPrefix()}${line}`),
7387
+ });
7388
+
7389
+ function primePendingSessionWorkers(reason = 'pending-worker-prime') {
7390
+ return pendingWorkerPrimer(reason);
7391
+ }
7392
+
7393
+ /**
7394
+ * The queue may hold work for a worker now (a row written, a gate lifted, a
7395
+ * retry come due): hand rows to the ready workers, prime the sessions with
7396
+ * pending rows, keep the maintenance pass armed.
7397
+ */
7398
+ function wakeSessionWorkerQueue(reason = 'queue-wake') {
7399
+ return queueWaker?.wake(reason) === true;
7400
+ }
7401
+
7402
+ function hasPendingWorkerOwnerRows() {
7403
+ if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED !== true || runtimeShutdownStarted) return false;
7404
+ return (stmts.listPendingWorkerOwnerSessionIds?.all?.(1) || []).length > 0;
7375
7405
  }
7376
7406
 
7377
7407
  // Guard first, so a tunnelled request to a worker path is rejected before any
@@ -7380,6 +7410,12 @@ app.use(tunnelWorkerPathGuard.requestMiddleware);
7380
7410
  // Uploads travel as raw bodies on POST /api/upload (express.raw, capped by
7381
7411
  // MAX_UPLOAD_BYTES), so the JSON limit only bounds inline image data URLs.
7382
7412
  app.use(express.json({ limit: '20mb' }));
7413
+ // Express 5 leaves req.body undefined when no parser read a body (a GET, a
7414
+ // POST without a JSON body); the routes read it as express 4 gave it, {}.
7415
+ app.use((req, _res, next) => {
7416
+ if (req.body === undefined) req.body = {};
7417
+ next();
7418
+ });
7383
7419
  app.use((req, _res, next) => {
7384
7420
  stripRequestPathPrefix(req, remotePath);
7385
7421
  next();
@@ -7411,9 +7447,7 @@ app.use((req, res, next) => {
7411
7447
  });
7412
7448
  app.get('/socket.io/socket.io.js', (req, res, next) => {
7413
7449
  res.setHeader('Cache-Control', 'no-store');
7414
- res.sendFile(SOCKET_IO_CLIENT_JS_PATH, (error) => {
7415
- if (error) next(error);
7416
- });
7450
+ sendOwnFile(res, SOCKET_IO_CLIENT_JS_PATH, next);
7417
7451
  });
7418
7452
  app.get('/manifest.webmanifest', (req, res, next) => {
7419
7453
  try {
@@ -7427,9 +7461,7 @@ app.get('/manifest.webmanifest', (req, res, next) => {
7427
7461
  });
7428
7462
  app.get('/sw.js', (req, res, next) => {
7429
7463
  res.setHeader('Cache-Control', 'no-store');
7430
- res.sendFile(PWA_SW_PATH, (error) => {
7431
- if (error) next(error);
7432
- });
7464
+ sendOwnFile(res, PWA_SW_PATH, next);
7433
7465
  });
7434
7466
  app.get(['/', '/index.html'], (req, res, next) => {
7435
7467
  try {
@@ -7475,7 +7507,14 @@ app.get(['/share-preview/:conversationId', '/share-preview/:conversationId/'], (
7475
7507
  app.use(express.static(PUBLIC_DIR, { etag: false, setHeaders: (res) => res.setHeader('Cache-Control', 'no-store') }));
7476
7508
 
7477
7509
  // ─── CLI Status Tracking ──────────────────────────────────────────────────────
7478
- let cliLastSeen = null;
7510
+ // When the workers were last heard from, without a polling timer: the window
7511
+ // is 45 s while a worker socket is open (an idle worker beats every 30 s) and
7512
+ // 10 s once none is (services/idle-timers.mjs).
7513
+ const cliPresence = createCliPresence({
7514
+ onExpired: () => {
7515
+ if (cliOnline) markCliOffline('heartbeat-timeout');
7516
+ },
7517
+ });
7479
7518
  let cliOnline = false;
7480
7519
  let relayPaused = false;
7481
7520
  const relayBridgeOwnerService = createRelayBridgeOwnerService();
@@ -7491,8 +7530,12 @@ const runtimeState = {
7491
7530
  get cliOnline() { return cliOnline; },
7492
7531
  set cliOnline(value) { cliOnline = value; },
7493
7532
  get relayPaused() { return relayPaused; },
7494
- set relayPaused(value) { relayPaused = value; },
7495
- get ttyConsoleActive() { return Boolean(ttyConsoleRuntime?.tty); },
7533
+ set relayPaused(value) {
7534
+ const wasPaused = relayPaused;
7535
+ relayPaused = value;
7536
+ // Resumed: what was queued while paused goes out now.
7537
+ if (wasPaused && !relayPaused) wakeSessionWorkerQueue('relay-resumed');
7538
+ },
7496
7539
  get relayShutdown() { return relayRestartQueue.getState(); },
7497
7540
  // A shutdown or restart waits for the processing turns: no pending row
7498
7541
  // goes out meanwhile (services/relay-restart-queue.mjs).
@@ -8015,7 +8058,7 @@ registerAboutRoutes(app, {
8015
8058
  }),
8016
8059
  });
8017
8060
  function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
8018
- cliLastSeen = null;
8061
+ cliPresence.clear();
8019
8062
  const wasOnline = cliOnline;
8020
8063
  cliOnline = false;
8021
8064
  if (clearOwner) {
@@ -8029,20 +8072,6 @@ function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
8029
8072
  }
8030
8073
  }
8031
8074
 
8032
- function checkCliStatus() {
8033
- const wasOnline = cliOnline;
8034
- cliOnline = cliLastSeen !== null && (Date.now() - cliLastSeen) < 10_000;
8035
- if (wasOnline !== cliOnline) {
8036
- if (!cliOnline) {
8037
- markCliOffline('heartbeat-timeout');
8038
- return;
8039
- }
8040
- console.log(`${runtimeLogPrefix()}CLI ONLINE`);
8041
- io.emit('cli_status', { online: true });
8042
- }
8043
- }
8044
- runtimeTimers.cliStatus = setInterval(checkCliStatus, 2000);
8045
-
8046
8075
  if (managedOwnerPid) {
8047
8076
  runtimeTimers.ownerWatchdog = setInterval(() => {
8048
8077
  if (isProcessAlive(managedOwnerPid)) return;
@@ -8072,6 +8101,10 @@ const staleRecoveryClockGuard = createStaleRecoveryClockGuard({
8072
8101
  });
8073
8102
  function recoverStaleMessages() {
8074
8103
  if (staleRecoveryClockGuard.tick().skip) return;
8104
+ // The tick itself stays: the guard reads the gaps between ticks, and a timer
8105
+ // that slept while nothing ran would look like a clock jump. Only the work
8106
+ // is skipped while no row is processing and no restart outcome is open.
8107
+ if (!stmts.hasProcessingRow.get() && !restartResumeService.pending().length) return;
8075
8108
  const staleWindowMs = cliOnline
8076
8109
  ? processingTimeoutMs
8077
8110
  : Math.min(processingTimeoutMs, OFFLINE_STALE_RECOVER_MS);
@@ -8111,8 +8144,6 @@ function expirePendingQuestions() {
8111
8144
  }
8112
8145
  runtimeTimers.questionExpiry = setInterval(expirePendingQuestions, 10_000);
8113
8146
  expirePendingQuestions();
8114
- runtimeTimers.sharedViewerPrune = setInterval(pruneSharedViewerPresence, 5_000);
8115
- if (typeof runtimeTimers.sharedViewerPrune.unref === 'function') runtimeTimers.sharedViewerPrune.unref();
8116
8147
  // Memory in the status log: now, every 10 minutes, and once per crossing of
8117
8148
  // 80 % of the heap limit (relay-heap-guard.mjs).
8118
8149
  {
@@ -8169,7 +8200,7 @@ if (runtimeBindingsBootstrapped > 0) {
8169
8200
 
8170
8201
  function ts() { return new Date().toISOString().slice(11, 23); }
8171
8202
  function runtimeLogPrefix() {
8172
- return ttyConsoleRuntime?.tty ? '' : `[${ts()}] `;
8203
+ return `[${ts()}] `;
8173
8204
  }
8174
8205
 
8175
8206
  // ─── Auth Middleware ──────────────────────────────────────────────────────────
@@ -8344,7 +8375,7 @@ function emitToClientsExceptSessionId(event, payload, sessionId) {
8344
8375
  // ─── CLI Routes ───────────────────────────────────────────────────────────────
8345
8376
 
8346
8377
  function touchCli() {
8347
- cliLastSeen = Date.now();
8378
+ cliPresence.touch();
8348
8379
  relayRestartOrchestrator.noteCliOnline();
8349
8380
  if (!cliOnline) {
8350
8381
  cliOnline = true;
@@ -8353,6 +8384,7 @@ function touchCli() {
8353
8384
  }
8354
8385
  }
8355
8386
  sessionWorkerWebSocketService.start();
8387
+ queueWakeStarted = true;
8356
8388
  void primePendingSessionWorkers('startup');
8357
8389
  // Undelivered events get their timers back; stalled, all_idle and the
8358
8390
  // heartbeat are swept from here on.
@@ -8365,13 +8397,7 @@ relayScheduleService.start();
8365
8397
  relayAskLinkService.start();
8366
8398
  // Paired relays' Inboxes, every ~20 s.
8367
8399
  remoteInboxWatchService.start();
8368
- runtimeTimers.pendingWorkerPrime = setInterval(() => {
8369
- void primePendingSessionWorkers('maintenance');
8370
- detachedWorkerWatch.sweep().catch((error) => {
8371
- console.warn(`${runtimeLogPrefix()}WORKER DETACHED sweep failed: ${error?.message || error}`);
8372
- });
8373
- }, 5_000);
8374
- if (typeof runtimeTimers.pendingWorkerPrime.unref === 'function') runtimeTimers.pendingWorkerPrime.unref();
8400
+ pendingWorkerPrimeTimer.arm();
8375
8401
 
8376
8402
  // POST /api/heartbeat — CLI sends a ping every poll interval
8377
8403
 
@@ -8500,6 +8526,7 @@ const sshTunnelManager = createSshTunnelManager({
8500
8526
  logger: console,
8501
8527
  runtimeShutdownRef: () => runtimeShutdownStarted,
8502
8528
  configBaseDir: __dirname,
8529
+ onBlockingLifted: () => { wakeSessionWorkerQueue('ssh-tunnel-connected'); },
8503
8530
  });
8504
8531
  const tunnelState = sshTunnelManager.state;
8505
8532
  runtimeState.tunnelState = tunnelState;
@@ -8513,6 +8540,7 @@ const cloudflaredTunnelManager = createCloudflaredTunnelManager({
8513
8540
  logger: console,
8514
8541
  runtimeShutdownRef: () => runtimeShutdownStarted,
8515
8542
  configBaseDir: __dirname,
8543
+ onBlockingLifted: () => { wakeSessionWorkerQueue('cloudflared-tunnel-connected'); },
8516
8544
  });
8517
8545
  runtimeState.cloudflaredTunnelState = cloudflaredTunnelManager.state;
8518
8546
 
@@ -8633,15 +8661,15 @@ function clearRuntimeTimers() {
8633
8661
  try { workerRecycleService.stop(); } catch {}
8634
8662
  try { taskHealthPoller.stop(); } catch {}
8635
8663
  try { taskOutputRooms.stop(); } catch {}
8636
- runtimeTimers.cliStatus = null;
8637
8664
  runtimeTimers.ownerWatchdog = null;
8638
8665
  runtimeTimers.staleRecovery = null;
8639
8666
  runtimeTimers.questionExpiry = null;
8640
- runtimeTimers.pendingWorkerPrime = null;
8641
- runtimeTimers.sharedViewerPrune = null;
8667
+ pendingWorkerPrimeTimer.stop();
8668
+ sharedViewerPruneTimer.stop();
8642
8669
  runtimeTimers.restoredTaskSettle = null;
8643
8670
  runtimeTimers.archivedWorkerSweep = null;
8644
8671
  try { relayRestartQueue.dispose(); } catch {}
8672
+ try { cliPresence.clear(); } catch {}
8645
8673
  }
8646
8674
 
8647
8675
  let runtimeShutdownPromise = null;