@oar-sh/oar 0.9.12 → 0.9.14

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 (115) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +508 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +155 -94
  7. package/package.json +5 -5
  8. package/server/README.md +479 -157
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +92 -15
  11. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  12. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  13. package/server/claude-worker/task-output-discovery.mjs +28 -2
  14. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  15. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  16. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  17. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  18. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
  20. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  21. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  22. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  23. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  24. package/server/db-schema.mjs +10 -0
  25. package/server/grok-worker/acp-host-services.mjs +6 -3
  26. package/server/grok-worker/grok-background-tasks.mjs +7 -0
  27. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  28. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  29. package/server/migrations/0018-background-task-rows.mjs +101 -0
  30. package/server/public/app/action-confirmations.js +12 -15
  31. package/server/public/app/api-client.js +60 -4
  32. package/server/public/app/ask-user-view.js +29 -4
  33. package/server/public/app/background-tasks-view.mjs +56 -7
  34. package/server/public/app/bootstrap.js +7 -0
  35. package/server/public/app/conversation-preferences.mjs +10 -4
  36. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  37. package/server/public/app/conversation-view.js +33 -9
  38. package/server/public/app/host-suspend-ui.js +45 -44
  39. package/server/public/app/journal-view.js +9 -3
  40. package/server/public/app/push-settings.js +1 -0
  41. package/server/public/app/question-answer-rule.mjs +15 -0
  42. package/server/public/app/question-multi-select.mjs +9 -2
  43. package/server/public/app/relay-event-view.mjs +5 -1
  44. package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
  45. package/server/public/app/relay-restart-note-view.mjs +36 -0
  46. package/server/public/app/relay-restart-ui.js +568 -0
  47. package/server/public/app/settings-modal.js +52 -0
  48. package/server/public/app/socket-handlers.js +1 -1
  49. package/server/public/app/task-console-render.mjs +171 -7
  50. package/server/public/app/task-console.mjs +111 -11
  51. package/server/public/app/task-output-text.mjs +15 -6
  52. package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
  53. package/server/public/app/transcript-jump-bottom.mjs +20 -0
  54. package/server/public/index.html +76 -5
  55. package/server/relay-exit-codes.mjs +6 -0
  56. package/server/relay-self-restart.mjs +6 -1
  57. package/server/relay-tools.md +17 -9
  58. package/server/repositories/message-repository.mjs +32 -28
  59. package/server/repositories/remote-relay-repository.mjs +4 -2
  60. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  61. package/server/routes/ask-user-routes.mjs +41 -7
  62. package/server/routes/messages-routes.mjs +220 -37
  63. package/server/routes/preview-routes.mjs +73 -7
  64. package/server/routes/relay-board-routes.mjs +19 -2
  65. package/server/routes/relay-restart-routes.mjs +73 -0
  66. package/server/routes/sessions-routes.mjs +79 -5
  67. package/server/server-runtime.mjs +335 -218
  68. package/server/services/about-service.mjs +13 -12
  69. package/server/services/background-task-persistence.mjs +133 -0
  70. package/server/services/background-task-store.mjs +240 -13
  71. package/server/services/conversation-model-fallback.mjs +57 -0
  72. package/server/services/cursor-session-root-service.mjs +70 -6
  73. package/server/services/data-dir-resolution.mjs +208 -19
  74. package/server/services/earlier-database-service.mjs +631 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/oar-cli-helpers.mjs +6 -1
  77. package/server/services/oar-state-layout.mjs +107 -0
  78. package/server/services/oar-state-migration-service.mjs +387 -96
  79. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  80. package/server/services/package-data-rescue.mjs +137 -0
  81. package/server/services/preview-proxy-server.mjs +14 -1
  82. package/server/services/preview-registry-service.mjs +3 -2
  83. package/server/services/process-list-service.mjs +2 -1
  84. package/server/services/provider-model-selection.mjs +195 -0
  85. package/server/services/push-dispatch-service.mjs +42 -8
  86. package/server/services/relay-event-service.mjs +210 -14
  87. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  88. package/server/services/relay-restart-picture.mjs +209 -0
  89. package/server/services/relay-restart-queue.mjs +387 -0
  90. package/server/services/relay-shutdown-gate.mjs +4 -1
  91. package/server/services/relay-singleton-guard.mjs +300 -36
  92. package/server/services/remote-relay-caller-context.mjs +7 -2
  93. package/server/services/remote-relay-dispatcher.mjs +217 -28
  94. package/server/services/restart-resume-service.mjs +303 -0
  95. package/server/services/session-activity-tracker.mjs +52 -15
  96. package/server/services/session-worker-launch-service.mjs +31 -1
  97. package/server/services/session-worker-process-service.mjs +6 -4
  98. package/server/services/stale-turn-recovery.mjs +99 -0
  99. package/server/services/subagent-timeline-service.mjs +4 -1
  100. package/server/services/task-output-service.mjs +217 -18
  101. package/server/services/windows-autostart-service.mjs +329 -31
  102. package/server/services/windows-boot-autostart-service.mjs +188 -60
  103. package/shared/preview-instructions.mjs +17 -12
  104. package/shared/preview-tool-core.mjs +32 -14
  105. package/shared/question-answer-rule.mjs +63 -0
  106. package/shared/relay-events.mjs +106 -3
  107. package/shared/remote-relay-contract.mjs +32 -15
  108. package/shared/subagent-timeline.mjs +4 -0
  109. package/shared/task-output-text.mjs +196 -83
  110. package/shared/windows-system-tools.mjs +25 -0
  111. package/shared/worker-runtime/config-loader.mjs +54 -8
  112. package/shared/worker-runtime/prompt-context.mjs +3 -1
  113. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
  114. package/shared/worker-runtime/working-directory.mjs +65 -0
  115. package/server/uploads/image_input.md +0 -540
@@ -32,6 +32,13 @@
32
32
  * boot. A subscription ends on unsubscribe, when its watcher is archived or
33
33
  * deleted, when its last listed session is, or after firing (`once`).
34
34
  *
35
+ * relay_restarted is the relay's own event, opt-in by name: each start of
36
+ * the runtime (noteRelayStarted) wakes the subscriptions of this relay's
37
+ * watchers that name it, once, with how the run before ended
38
+ * (noteRelayExit). A conversation whose turn answered relay_restarted when
39
+ * the restart was requested (noteRestartRequested) is not woken by it: a
40
+ * woken turn that restarts again ends the chain.
41
+ *
35
42
  * Across paired relays (migrations/0012-cross-relay-event-subscriptions.mjs)
36
43
  * this service plays both sides:
37
44
  *
@@ -57,13 +64,21 @@ import {
57
64
  RELAY_EVENT_LINK_LOST_TYPE,
58
65
  RELAY_EVENT_LINK_RESTORED_TYPE,
59
66
  RELAY_EVENT_MESSAGE_KIND,
67
+ RELAY_EVENT_RELAY_LEVEL_TYPES,
68
+ RELAY_EVENT_RELAY_RESTARTED_TYPE,
69
+ RELAY_EVENT_SUBAGENT_FINISHED_TYPE,
70
+ RELAY_EVENT_SUBSCRIBABLE_TYPES,
60
71
  RELAY_EVENT_TYPES,
61
72
  RELAY_EVENT_VERSION,
62
73
  buildRelayEventMessageText,
63
74
  compileRelayEventMatch,
75
+ effectiveRelayEventTypes,
76
+ isLegacyDefaultRelayEventTypes,
64
77
  isRelayEventType,
78
+ isRelayLevelOnly,
65
79
  isUrgentRelayEventType,
66
80
  relayEventMatchText,
81
+ relayEventTypesTake,
67
82
  } from '../../shared/relay-events.mjs';
68
83
 
69
84
  export const RELAY_EVENT_SUBSCRIPTIONS_SOCKET_EVENT = 'relay_event_subscriptions';
@@ -72,6 +87,12 @@ export const RELAY_EVENT_SELF_RELAY_ID = 'self';
72
87
  // conversation id, so nothing a paired relay asks for is ever written into
73
88
  // one of this relay's conversations.
74
89
  export const RELAY_EVENT_SERVED_WATCHER_PREFIX = 'remote:';
90
+ // app_settings: how the runtime before this one ended, written on its way
91
+ // out (noteRelayExit) and taken by the next start (noteRelayStarted).
92
+ export const RELAY_LAST_EXIT_SETTING_KEY = 'relay_last_exit';
93
+ // Older than this, a record is not of the run just before this one. Long:
94
+ // a relay stopped for a weekend still says how; the line shows the stop time.
95
+ export const RELAY_LAST_EXIT_MAX_AGE_MS = 30 * 24 * 60 * 60_000;
75
96
 
76
97
  const HOUR_MS = 60 * 60_000;
77
98
  const DAY_MS = 24 * HOUR_MS;
@@ -286,6 +307,19 @@ export function createRelayEventService({
286
307
  AND COALESCE(m.kind, '') <> ?
287
308
  `),
288
309
  deleteDeliveredBefore: db.prepare(RELAY_EVENT_PRUNE_SQL.deleteDeliveredBefore),
310
+ // The loop guard: conversations whose running turn answers relay_restarted.
311
+ listRestartWokenRunning: db.prepare(`
312
+ SELECT DISTINCT q.conversation_id FROM queue q JOIN relay_events e ON e.message_id = q.id
313
+ WHERE q.status = 'processing' AND e.type = ?
314
+ `),
315
+ } : null;
316
+ const exitSql = available && tableExists('app_settings') ? {
317
+ get: db.prepare(`SELECT value FROM app_settings WHERE key = ?`),
318
+ put: db.prepare(`
319
+ INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
320
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
321
+ `),
322
+ take: db.prepare(`DELETE FROM app_settings WHERE key = ?`),
289
323
  } : null;
290
324
  // Without migration 0012 the table has no remote_sync column.
291
325
  const deleteEndedSubscriptionsBefore = available
@@ -376,6 +410,12 @@ export function createRelayEventService({
376
410
  let sweepTimer = null;
377
411
  let started = false;
378
412
  let lastPruneAt = 0;
413
+ // relay_restarted: the conversations a requested restart does not wake
414
+ // (the loop guard), whether this runtime's end is written, and whether its
415
+ // start was noted.
416
+ const restartNoWake = new Set();
417
+ let exitNoted = false;
418
+ let startNoted = false;
379
419
 
380
420
  function isoNow() {
381
421
  return new Date(now()).toISOString();
@@ -403,6 +443,11 @@ export function createRelayEventService({
403
443
  return !!row && Number(row.archived || 0) !== 1 && toText(row.status) !== 'deleted';
404
444
  }
405
445
 
446
+ /** A subscription's events; the old default list counts as today's default. */
447
+ function storedTypes(row) {
448
+ return effectiveRelayEventTypes(parseJson(row?.events_json, []));
449
+ }
450
+
406
451
  function shapeSubscription(row) {
407
452
  if (!row) return null;
408
453
  const scope = toText(row.scope) === 'all' ? 'all' : 'sessions';
@@ -431,7 +476,10 @@ export function createRelayEventService({
431
476
  sessions: scope === 'all'
432
477
  ? 'all'
433
478
  : sessionIds.map((id) => ({ id, title: mirror ? (remoteTitles.get(id) || null) : (toText(conversation(id)?.title) || null) })),
434
- events: parseJson(row.events_json, []).filter((type) => RELAY_EVENT_TYPES.includes(type)),
479
+ // A mirror shows what the paired relay applies; a subscription here,
480
+ // stored with the old default, the default that applies now.
481
+ events: (mirror ? parseJson(row.events_json, []) : storedTypes(row))
482
+ .filter((type) => RELAY_EVENT_SUBSCRIBABLE_TYPES.includes(type)),
435
483
  once: Number(row.once || 0) === 1,
436
484
  ...(toText(row.match) ? { match: row.match } : {}),
437
485
  stalledMinutes: Number(row.stalled_minutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault,
@@ -487,12 +535,25 @@ export function createRelayEventService({
487
535
  /**
488
536
  * What a subscribe asks for, checked: `{ all, ids, types, matchText,
489
537
  * stalled, heartbeat }`. Throws RelayEventError (`invalid`, `notFound`).
490
- * `exclude` is the watcher, never watched by itself.
538
+ * `exclude` is the watcher, never watched by itself. `relayLevel` allows
539
+ * relay_restarted (this relay's own watchers only); a subscription that
540
+ * names nothing else needs no sessions.
491
541
  */
492
- function prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude = '' }) {
542
+ function prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude = '', relayLevel = false }) {
493
543
  const all = scope === 'all';
494
544
  const ids = all ? [] : [...new Set((Array.isArray(sessionIds) ? sessionIds : []).map(toText).filter(Boolean))];
495
- if (!all && !ids.length) throw new RelayEventError('invalid', 'subscribe needs sessions (ids from list_sessions) or "all".');
545
+ const named = Array.isArray(events) ? events.map(toText).filter(Boolean) : [];
546
+ const relayLevelNamed = named.filter((type) => RELAY_EVENT_RELAY_LEVEL_TYPES.includes(type));
547
+ if (relayLevelNamed.length && !relayLevel) {
548
+ throw new RelayEventError('invalid', `${relayLevelNamed.join(', ')} is about the relay the watcher runs on and cannot be watched on another relay (an outage there shows as link_lost and link_restored).`);
549
+ }
550
+ if (!all && !ids.length && !isRelayLevelOnly(named)) {
551
+ throw new RelayEventError('invalid', `subscribe needs sessions (ids from list_sessions) or "all"${relayLevel ? ', unless events names only relay_restarted' : ''}.`);
552
+ }
553
+ // No session, no work that could run: a heartbeat would never come.
554
+ if (!all && !ids.length && heartbeatMinutes !== null && heartbeatMinutes !== undefined && heartbeatMinutes !== '') {
555
+ throw new RelayEventError('invalid', 'heartbeat_minutes needs sessions to watch; relay_restarted alone has none.');
556
+ }
496
557
  if (ids.length > RELAY_EVENT_LIMITS.sessionsMax) {
497
558
  throw new RelayEventError('invalid', `subscribe takes at most ${RELAY_EVENT_LIMITS.sessionsMax} sessions; use "all" for more.`);
498
559
  }
@@ -504,9 +565,13 @@ export function createRelayEventService({
504
565
  throw new RelayEventError('notFound', `No live session with id ${missing.join(', ')} on this relay (archived sessions cannot be watched).`, { sessions: missing });
505
566
  }
506
567
  const types = Array.isArray(events) && events.length
507
- ? [...new Set(events.map(toText).filter((type) => RELAY_EVENT_TYPES.includes(type)))]
568
+ ? [...new Set(events.map(toText).filter((type) => RELAY_EVENT_SUBSCRIBABLE_TYPES.includes(type)))]
508
569
  : [...RELAY_EVENT_DEFAULT_TYPES];
509
- if (!types.length) throw new RelayEventError('invalid', `events must name some of: ${RELAY_EVENT_TYPES.join(', ')}`);
570
+ if (!types.length) throw new RelayEventError('invalid', `events must name some of: ${RELAY_EVENT_SUBSCRIBABLE_TYPES.join(', ')}`);
571
+ // Named exactly as the old default was stored, this list would be read
572
+ // as today's default and lose its task_finished (effectiveRelayEventTypes);
573
+ // subagent_finished beside it changes nothing it takes and keeps it named.
574
+ if (isLegacyDefaultRelayEventTypes(types)) types.push(RELAY_EVENT_SUBAGENT_FINISHED_TYPE);
510
575
  const compiled = compileRelayEventMatch(match);
511
576
  if (!compiled.ok) throw new RelayEventError('invalid', compiled.error);
512
577
  const stalled = Number.isFinite(Number(stalledMinutes)) && stalledMinutes !== null && stalledMinutes !== ''
@@ -538,7 +603,7 @@ export function createRelayEventService({
538
603
  if (!available) throw new RelayEventError('unavailable', 'Event subscriptions are not available on this relay right now (its storage is missing; see the server log).');
539
604
  const watcher = toText(watcherConversationId);
540
605
  if (!isLive(conversation(watcher))) throw new RelayEventError('notFound', 'The watching conversation was not found or is archived.');
541
- const prepared = prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude: watcher });
606
+ const prepared = prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude: watcher, relayLevel: true });
542
607
  const { all, ids, types } = prepared;
543
608
  checkWatcherCapacity(watcher);
544
609
  const id = `sub_${randomBytes(5).toString('hex')}`;
@@ -560,7 +625,8 @@ export function createRelayEventService({
560
625
  // The watched set starts from what it is doing now: all_idle fires when
561
626
  // work that is running now (or starts later) has ended.
562
627
  noteBusy(sql.getSubscription.get(id));
563
- log(`conv=${shortId(watcher)} subscribed ${id} to ${all ? 'all sessions' : `${ids.length} session${ids.length === 1 ? '' : 's'}`} (${types.join(',')})${once ? ' once' : ''}`);
628
+ const what = all ? 'all sessions' : (ids.length ? `${ids.length} session${ids.length === 1 ? '' : 's'}` : 'this relay');
629
+ log(`conv=${shortId(watcher)} subscribed ${id} to ${what} (${types.join(',')})${once ? ' once' : ''}`);
564
630
  broadcast();
565
631
  return subscription;
566
632
  }
@@ -771,8 +837,9 @@ export function createRelayEventService({
771
837
  return parseJson(row.session_ids_json, []).map(toText).includes(sessionId);
772
838
  }
773
839
 
774
- function wants(row, type) {
775
- return parseJson(row.events_json, []).includes(type);
840
+ /** `event` is `{ type, detail }`: a task_finished's kind decides under subagent_finished. */
841
+ function wants(row, event) {
842
+ return relayEventTypesTake(storedTypes(row), event);
776
843
  }
777
844
 
778
845
  /** `match` filters the events it can be tried on; the others pass. */
@@ -807,7 +874,7 @@ export function createRelayEventService({
807
874
  if (!available || !RELAY_EVENT_TYPES.includes(type)) return 0;
808
875
  const id = toText(sessionId);
809
876
  if (!id) return 0;
810
- const rows = localWatchRows().filter((row) => wants(row, type) && subscriptionCovers(row, id));
877
+ const rows = localWatchRows().filter((row) => wants(row, { type, detail }) && subscriptionCovers(row, id));
811
878
  if (!rows.length) return 0;
812
879
  const event = buildEvent(type, id, detail);
813
880
  return enqueueForRows(rows, event, id);
@@ -1271,7 +1338,7 @@ export function createRelayEventService({
1271
1338
  const sessions = all ? [] : (Array.isArray(remote.sessions) ? remote.sessions : [])
1272
1339
  .map((session) => ({ id: toText(session?.id), title: toText(session?.title) || null }))
1273
1340
  .filter((session) => session.id);
1274
- const types = (Array.isArray(remote.events) ? remote.events : []).filter((type) => RELAY_EVENT_TYPES.includes(type));
1341
+ const types = (Array.isArray(remote.events) ? remote.events : []).filter((type) => RELAY_EVENT_SUBSCRIBABLE_TYPES.includes(type));
1275
1342
  const id = `sub_${randomBytes(5).toString('hex')}`;
1276
1343
  const at = isoNow();
1277
1344
  remoteSql.insertMirror.run(
@@ -1599,6 +1666,131 @@ export function createRelayEventService({
1599
1666
  return ended;
1600
1667
  }
1601
1668
 
1669
+ // ─── The relay's own start: relay_restarted ────────────────────────────────
1670
+
1671
+ /** Conversations whose running turn answers a relay_restarted event. */
1672
+ function restartWokenConversations() {
1673
+ if (!available) return [];
1674
+ try {
1675
+ return sql.listRestartWokenRunning.all(RELAY_EVENT_RELAY_RESTARTED_TYPE).map((row) => toText(row.conversation_id)).filter(Boolean);
1676
+ } catch {
1677
+ return [];
1678
+ }
1679
+ }
1680
+
1681
+ /**
1682
+ * A restart or shutdown was requested: a conversation whose turn answers
1683
+ * relay_restarted right now is not woken by it (the request may be that
1684
+ * turn's). Called on every request while one is queued; returns them.
1685
+ */
1686
+ function noteRestartRequested() {
1687
+ const woken = restartWokenConversations();
1688
+ for (const id of woken) restartNoWake.add(id);
1689
+ if (woken.length) log(`restart requested while ${woken.map(shortId).join(', ')} answer${woken.length === 1 ? 's' : ''} relay_restarted: not woken by it`);
1690
+ return [...restartNoWake];
1691
+ }
1692
+
1693
+ /** The queued request was withdrawn (or never queued): nothing is held back. */
1694
+ function noteRestartCancelled() {
1695
+ restartNoWake.clear();
1696
+ }
1697
+
1698
+ /**
1699
+ * This runtime ends cleanly: how, for the next start to tell. The first
1700
+ * call wins (a queued request's details before the generic exit's); none
1701
+ * before noteRelayStarted. `requestedAt` set means the end was a queued (or immediate) request.
1702
+ */
1703
+ function noteRelayExit({ action = 'shutdown', reason = '', requestedBy = null, requestedAt = null } = {}) {
1704
+ // A runtime stopped before it listened (and took the record) leaves the
1705
+ // record of the run before it for the next start.
1706
+ if (!exitSql || exitNoted || !startNoted) return false;
1707
+ exitNoted = true;
1708
+ const requested = !!toText(requestedAt);
1709
+ // The runtime's own reasons may carry a process id (`owner-pid-missing:<pid>`):
1710
+ // not for an agent's eyes.
1711
+ const ownReason = requested ? toText(reason) : toText(reason).replace(/:\d+\b/g, '');
1712
+ const record = {
1713
+ v: 1,
1714
+ requested,
1715
+ action: action === 'restart' ? 'restart' : 'shutdown',
1716
+ reason: ownReason.slice(0, 140) || null,
1717
+ requestedBy: toText(requestedBy).slice(0, 80) || null,
1718
+ requestedAt: toText(requestedAt) || null,
1719
+ stoppedAt: isoNow(),
1720
+ noWake: [...restartNoWake],
1721
+ };
1722
+ try {
1723
+ exitSql.put.run(RELAY_LAST_EXIT_SETTING_KEY, JSON.stringify(record), record.stoppedAt);
1724
+ return true;
1725
+ } catch (error) {
1726
+ warn(`how this run ended was not stored: ${toText(error?.message || error).slice(0, 200)}`);
1727
+ return false;
1728
+ }
1729
+ }
1730
+
1731
+ function restartedDetail(exit, startedAt) {
1732
+ if (!exit) return { stop: 'unknown', startedAt };
1733
+ const base = { startedAt, stoppedAt: toText(exit.stoppedAt) || null, reason: toText(exit.reason) || null };
1734
+ if (exit.requested !== true) return { stop: 'stopped', ...base };
1735
+ return {
1736
+ stop: 'requested',
1737
+ ...base,
1738
+ action: exit.action === 'shutdown' ? 'shutdown' : 'restart',
1739
+ requestedBy: toText(exit.requestedBy) || null,
1740
+ requestedAt: toText(exit.requestedAt) || null,
1741
+ };
1742
+ }
1743
+
1744
+ /**
1745
+ * The runtime started (it listens). Takes how the run before ended (read
1746
+ * and deleted with the events written, so a start is told once) and gives
1747
+ * every active subscription of this relay's live watchers that names
1748
+ * relay_restarted one event; a `once` one ends with it. Watchers the
1749
+ * restart's request held back (noteRestartRequested) are left out, and
1750
+ * their `once` subscriptions end too (`held-back`): that restart was the
1751
+ * one they were for, and a later one would wake them with a stale "carry
1752
+ * on". A record older than RELAY_LAST_EXIT_MAX_AGE_MS is not the run just
1753
+ * before (an older OAR, which neither writes nor takes it, may have run in
1754
+ * between): it is dropped unread. Once per runtime. Returns `{ recorded,
1755
+ * skipped }`.
1756
+ */
1757
+ function noteRelayStarted() {
1758
+ if (!available || startNoted) return { recorded: 0, skipped: [] };
1759
+ startNoted = true;
1760
+ const startedAt = isoNow();
1761
+ let recorded = 0;
1762
+ let skipped = [];
1763
+ const heldBack = [];
1764
+ db.transaction(() => {
1765
+ let exit = null;
1766
+ if (exitSql) {
1767
+ exit = parseJson(exitSql.get.get(RELAY_LAST_EXIT_SETTING_KEY)?.value, null);
1768
+ exitSql.take.run(RELAY_LAST_EXIT_SETTING_KEY);
1769
+ const stoppedAt = Date.parse(String(exit?.stoppedAt || ''));
1770
+ if (exit && !(Number.isFinite(stoppedAt) && now() - stoppedAt <= RELAY_LAST_EXIT_MAX_AGE_MS)) exit = null;
1771
+ }
1772
+ const noWake = new Set((Array.isArray(exit?.noWake) ? exit.noWake : []).map(toText));
1773
+ const rows = activeRows().filter((row) => !isMirror(row)
1774
+ && !isServedKey(row.watcher_conversation_id)
1775
+ && wants(row, { type: RELAY_EVENT_RELAY_RESTARTED_TYPE })
1776
+ && isLive(conversation(row.watcher_conversation_id)));
1777
+ const held = rows.filter((row) => noWake.has(row.watcher_conversation_id));
1778
+ skipped = [...new Set(held.map((row) => row.watcher_conversation_id))];
1779
+ for (const row of held) {
1780
+ if (Number(row.once || 0) === 1 && endSubscription(row.id, 'held-back')) heldBack.push(row.id);
1781
+ }
1782
+ const woken = rows.filter((row) => !noWake.has(row.watcher_conversation_id));
1783
+ if (!woken.length) return;
1784
+ recorded = enqueueForRows(woken, buildEvent(RELAY_EVENT_RELAY_RESTARTED_TYPE, '', restartedDetail(exit, startedAt)), '');
1785
+ })();
1786
+ if (recorded || skipped.length) {
1787
+ log(`relay started: ${recorded} watcher${recorded === 1 ? '' : 's'} told${skipped.length ? `; not woken (answering relay_restarted when this restart was requested): ${skipped.map(shortId).join(', ')}` : ''}`
1788
+ + `${heldBack.length ? `; one-shot ${heldBack.join(', ')} ended (held-back)` : ''}`);
1789
+ }
1790
+ if (heldBack.length) broadcast();
1791
+ return { recorded, skipped };
1792
+ }
1793
+
1602
1794
  // ─── Hooks ─────────────────────────────────────────────────────────────────
1603
1795
 
1604
1796
  function isRelayEventTurn(queueMessageId) {
@@ -1814,7 +2006,7 @@ export function createRelayEventService({
1814
2006
  }
1815
2007
  busyBySubscription.set(row.id, false);
1816
2008
  idleSinceBySubscription.delete(row.id);
1817
- if (!wants(row, 'all_idle')) continue;
2009
+ if (!wants(row, { type: 'all_idle' })) continue;
1818
2010
  const count = toText(row.scope) === 'all' ? null : parseJson(row.session_ids_json, []).length;
1819
2011
  const event = buildEvent('all_idle', '', { scope: toText(row.scope), sessionCount: count, subscriptionId: row.id });
1820
2012
  enqueueForRows([row], event, '');
@@ -1832,7 +2024,7 @@ export function createRelayEventService({
1832
2024
  function evaluateStalled() {
1833
2025
  const at = now();
1834
2026
  for (const row of localWatchRows()) {
1835
- if (!wants(row, 'stalled')) continue;
2027
+ if (!wants(row, { type: 'stalled' })) continue;
1836
2028
  const limitMs = (Number(row.stalled_minutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault) * 60_000;
1837
2029
  for (const sessionId of candidateSessions(row)) {
1838
2030
  const view = describe(sessionId);
@@ -1944,6 +2136,10 @@ export function createRelayEventService({
1944
2136
  noteQuestion,
1945
2137
  noteTaskEdge,
1946
2138
  noteConversationGone,
2139
+ noteRestartRequested,
2140
+ noteRestartCancelled,
2141
+ noteRelayExit,
2142
+ noteRelayStarted,
1947
2143
  afterStateChange,
1948
2144
  sweep,
1949
2145
  start,
@@ -14,6 +14,7 @@ const DEFAULT_RELAY_TOOL_GUIDANCE = [
14
14
  'For relay restarts in extension-managed mode, require explicit user permission first, then use the authenticated localhost API `POST /api/relay/shutdown`. Do not restart by killing processes or using respawn scripts.',
15
15
  'Note: shutdown is queued and only completes when the current turn finishes, so it is pointless to wait for it to interrupt an active turn.',
16
16
  '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 }`.',
17
+ '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.',
17
18
  ].join(' ');
18
19
 
19
20
  function loadRelayToolGuidance() {
@@ -32,6 +33,10 @@ function loadRelayToolGuidance() {
32
33
  }
33
34
 
34
35
  const RELAY_TOOL_GUIDANCE = loadRelayToolGuidance();
36
+ // Prompts stored before the `immediate` line was added carry the guidance
37
+ // without it.
38
+ const IMMEDIATE_GUIDANCE_LINE = '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.';
39
+ const PREVIOUS_RELAY_TOOL_GUIDANCE = RELAY_TOOL_GUIDANCE.replace(` ${IMMEDIATE_GUIDANCE_LINE}`, '');
35
40
 
36
41
  const MODE_MARKERS = {
37
42
  plan: '[Relay mode: plan]',
@@ -117,6 +122,8 @@ function buildPromptPrefixPatterns(mode) {
117
122
  return [
118
123
  new RegExp(`^${whitespaceFlexiblePattern(currentFullPrefix)}\\s*`, 'i'),
119
124
  new RegExp(`^${whitespaceFlexiblePattern(legacyFullPrefix)}\\s*`, 'i'),
125
+ new RegExp(`^${whitespaceFlexiblePattern(`${marker} ${MODE_INSTRUCTIONS[key]} ${PREVIOUS_RELAY_TOOL_GUIDANCE}`)}\\s*`, 'i'),
126
+ new RegExp(`^${whitespaceFlexiblePattern(`${marker} ${LEGACY_MODE_INSTRUCTIONS[key]} ${PREVIOUS_RELAY_TOOL_GUIDANCE}`)}\\s*`, 'i'),
120
127
  new RegExp(`^${whitespaceFlexiblePattern(marker)}\\s*`, 'i'),
121
128
  ];
122
129
  });
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * What a relay restart (or shutdown) waits for, holds and leaves running,
5
+ * read from the state the relay already has:
6
+ *
7
+ * - waitingFor: the sessions with a processing turn. A queued restart waits
8
+ * for these and only these (relay-shutdown-gate.mjs); "Restart now" does
9
+ * not wait for them.
10
+ * - held: pending and parked messages. While a restart is queued none of
11
+ * them is handed to a worker; they run after it.
12
+ * - backgroundTasks: sessions with running background work of a live
13
+ * worker (shell, monitor, sub-agents; the task panel's data, through
14
+ * background-work-view.mjs), each with whether it survives.
15
+ *
16
+ * Whether background work survives is a property of the session workers,
17
+ * not of the relay runtime: every worker is its own process (tmux, a
18
+ * detached process, a console window on Windows) and keeps its tasks when
19
+ * the runtime exits. A restart (exit 75) is taken by the relay's own
20
+ * supervisor or by the Copilot CLI extension, which start a new runtime;
21
+ * the workers report back to it. A shutdown under a systemd service is
22
+ * different: the service stops, and systemd stops every process of the
23
+ * service, which takes the workers with it unless they live outside it (a
24
+ * tmux session on a tmux server the user started elsewhere survives). The
25
+ * relay cannot tell which, so they "may stop". A Claude Cloud session runs at
26
+ * Anthropic and is never affected. `oar restart` and `oar stop` through
27
+ * systemd do not come through here at all.
28
+ */
29
+
30
+ import fs from 'node:fs';
31
+
32
+ const MAX_ROWS = 50;
33
+
34
+ function toText(value) {
35
+ return String(value ?? '').trim();
36
+ }
37
+
38
+ /**
39
+ * How this relay process is run, from its environment and argv: whether a
40
+ * systemd service runs it, and whether an outer supervisor (the Copilot CLI
41
+ * extension, `--supervised`) owns its restarts.
42
+ */
43
+ export function detectRelayHosting({
44
+ env = process.env,
45
+ argv = process.argv,
46
+ platform = process.platform,
47
+ readCgroup = () => fs.readFileSync('/proc/self/cgroup', 'utf8'),
48
+ } = {}) {
49
+ // systemd sets INVOCATION_ID for the processes of a unit, and a service's
50
+ // cgroup ends in `.service` (a terminal's shell sits in a `.scope`).
51
+ let systemd = false;
52
+ if (platform === 'linux' && toText(env?.INVOCATION_ID)) {
53
+ let cgroup = '';
54
+ try { cgroup = String(readCgroup() || ''); } catch {}
55
+ systemd = cgroup.split('\n').some((line) => /\.service\/?$/.test(line.trim()));
56
+ }
57
+ const supervised = Array.isArray(argv) && argv.includes('--supervised');
58
+ return { systemd, supervised, platform };
59
+ }
60
+
61
+ /** Whether the session workers outlive this action, and why, in one line each. */
62
+ export function describeRestartSurvival({ action = 'restart', hosting = {} } = {}) {
63
+ const restart = action === 'restart';
64
+ if (restart) {
65
+ return {
66
+ action: 'restart',
67
+ workersSurvive: true,
68
+ summary: 'Session workers are separate processes and keep running through a relay restart, with their background tasks; '
69
+ + 'they report back to the new relay within a minute.',
70
+ };
71
+ }
72
+ if (hosting.systemd) {
73
+ return {
74
+ action: 'shutdown',
75
+ workersSurvive: false,
76
+ summary: 'The relay runs as a systemd service: shutting it down stops the service and every process in it, '
77
+ + 'so session workers and their background tasks may stop too (a worker in a tmux session the service did not start keeps running).',
78
+ };
79
+ }
80
+ return {
81
+ action: 'shutdown',
82
+ workersSurvive: true,
83
+ summary: 'Session workers are separate processes and keep running while the relay is down, with their background tasks; '
84
+ + 'they report back when it starts again.',
85
+ };
86
+ }
87
+
88
+ /**
89
+ * The picture for one action. `queueRows` is the queue grouped by
90
+ * conversation and status ({ conversation_id, status, cnt, title });
91
+ * `backgroundBlockers` the `background` rows of background-work-view.mjs.
92
+ */
93
+ export function buildRestartPicture({
94
+ action = 'restart',
95
+ queueRows = [],
96
+ backgroundBlockers = [],
97
+ // Tasks held since a relay restart (background-task-store.mjs
98
+ // `listRestored`, mapped to conversations): `{ conversationId, description }`.
99
+ heldTasks = [],
100
+ hosting = {},
101
+ providerOf = () => '',
102
+ titleOf = (conversationId) => toText(conversationId).slice(0, 8),
103
+ } = {}) {
104
+ const survival = describeRestartSurvival({ action, hosting });
105
+ const turns = new Map();
106
+ const held = new Map();
107
+ for (const row of Array.isArray(queueRows) ? queueRows : []) {
108
+ const conversationId = toText(row?.conversation_id);
109
+ const count = Number(row?.cnt || 0);
110
+ if (!conversationId || !(count > 0)) continue;
111
+ const status = toText(row?.status);
112
+ const target = status === 'processing' ? turns : (status === 'pending' || status === 'parked') ? held : null;
113
+ if (!target) continue;
114
+ const entry = target.get(conversationId) || { conversationId, title: toText(row?.title) || titleOf(conversationId), count: 0 };
115
+ entry.count += count;
116
+ target.set(conversationId, entry);
117
+ }
118
+ const waitingFor = [...turns.values()].slice(0, MAX_ROWS).map((entry) => ({
119
+ conversationId: entry.conversationId,
120
+ title: entry.title,
121
+ turns: entry.count,
122
+ }));
123
+ const heldConversations = [...held.values()];
124
+ const backgroundTasks = (Array.isArray(backgroundBlockers) ? backgroundBlockers : [])
125
+ .filter((blocker) => blocker && blocker.kind === 'background' && toText(blocker.conversationId))
126
+ .slice(0, MAX_ROWS)
127
+ .map((blocker) => {
128
+ const conversationId = toText(blocker.conversationId);
129
+ const cloud = toText(providerOf(conversationId)).toLowerCase() === 'claude-cloud';
130
+ const survives = cloud || survival.workersSurvive;
131
+ return {
132
+ conversationId,
133
+ title: toText(blocker.title) || titleOf(conversationId),
134
+ count: Number(blocker.count) > 0 ? Math.trunc(Number(blocker.count)) : 1,
135
+ detail: toText(blocker.detail).slice(0, 400),
136
+ survives,
137
+ why: cloud
138
+ ? 'runs in the cloud'
139
+ : survives ? 'its worker keeps running' : 'its worker may stop with the service',
140
+ };
141
+ });
142
+ // Held since the last restart: neither running nor ended as far as the
143
+ // relay knows, until the worker's set confirms them. Whether they survive
144
+ // the next one is not known either.
145
+ const listed = new Set(backgroundTasks.map((task) => task.conversationId));
146
+ const unconfirmed = new Map();
147
+ for (const entry of Array.isArray(heldTasks) ? heldTasks : []) {
148
+ const conversationId = toText(entry?.conversationId);
149
+ if (!conversationId || listed.has(conversationId)) continue;
150
+ const list = unconfirmed.get(conversationId) || [];
151
+ list.push(toText(entry?.description) || 'task');
152
+ unconfirmed.set(conversationId, list);
153
+ }
154
+ for (const [conversationId, descriptions] of unconfirmed) {
155
+ if (backgroundTasks.length >= MAX_ROWS) break;
156
+ const count = descriptions.length;
157
+ backgroundTasks.push({
158
+ conversationId,
159
+ title: titleOf(conversationId),
160
+ count,
161
+ detail: `${count} task${count === 1 ? '' : 's'} not yet confirmed since the last relay restart: ${descriptions.slice(0, 3).map((text) => text.slice(0, 80)).join('; ')}`,
162
+ survives: null,
163
+ why: 'its worker has not confirmed it since the last restart',
164
+ });
165
+ }
166
+ return {
167
+ waitingFor,
168
+ held: {
169
+ count: heldConversations.reduce((sum, entry) => sum + entry.count, 0),
170
+ conversations: heldConversations.slice(0, MAX_ROWS).map((entry) => ({
171
+ conversationId: entry.conversationId,
172
+ title: entry.title,
173
+ count: entry.count,
174
+ })),
175
+ },
176
+ backgroundTasks,
177
+ survival,
178
+ };
179
+ }
180
+
181
+ /** The live collector over the relay's database and background-work view. */
182
+ export function createRestartPictureCollector({
183
+ db,
184
+ backgroundWorkView = null,
185
+ // () => [{ conversationId, description }]: tasks held since a restart.
186
+ listHeldTasks = () => [],
187
+ hosting = detectRelayHosting(),
188
+ providerOf = () => '',
189
+ titleOf = undefined,
190
+ } = {}) {
191
+ let listQueue = null;
192
+ try {
193
+ listQueue = db?.prepare(`
194
+ SELECT q.conversation_id, q.status, COUNT(*) AS cnt, MAX(c.title) AS title
195
+ FROM queue q LEFT JOIN conversations c ON c.id = q.conversation_id
196
+ WHERE q.status IN ('pending','processing','parked')
197
+ GROUP BY q.conversation_id, q.status
198
+ `) || null;
199
+ } catch {}
200
+ return function collectRestartPicture({ action = 'restart' } = {}) {
201
+ let queueRows = [];
202
+ try { queueRows = listQueue?.all() || []; } catch {}
203
+ let backgroundBlockers = [];
204
+ try { backgroundBlockers = backgroundWorkView?.backgroundBlockers?.() || []; } catch {}
205
+ let heldTasks = [];
206
+ try { heldTasks = listHeldTasks() || []; } catch {}
207
+ return buildRestartPicture({ action, queueRows, backgroundBlockers, heldTasks, hosting, providerOf, ...(titleOf ? { titleOf } : {}) });
208
+ };
209
+ }