@oar-sh/oar 0.9.19 → 0.9.20

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 (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  3. package/CHANGELOG.md +317 -0
  4. package/README.md +10 -10
  5. package/bin/oar.js +391 -97
  6. package/package.json +4 -2
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +27 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  10. package/server/claude-worker/claude-session-process.mjs +3 -0
  11. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  12. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  13. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  14. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  15. package/server/copilot-worker/copilot-sdk-session-process.mjs +57 -4
  16. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  17. package/server/db-schema.mjs +18 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  19. package/server/grok-worker/grok-turn-runner.mjs +44 -6
  20. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  21. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  22. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  23. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  24. package/server/migrations/0005-remote-relays.mjs +5 -7
  25. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  26. package/server/migrations/0007-claude-cloud.mjs +5 -7
  27. package/server/migrations/0008-message-pins.mjs +5 -7
  28. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  29. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  30. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  31. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  32. package/server/migrations/0013-previews.mjs +5 -7
  33. package/server/migrations/0014-conversation-groups.mjs +5 -7
  34. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  35. package/server/migrations/0016-share-access-log.mjs +5 -7
  36. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  37. package/server/migrations/0018-background-task-rows.mjs +5 -7
  38. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  39. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  40. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  41. package/server/migrations/0022-relay-schedules.mjs +5 -7
  42. package/server/migrations/0023-relay-asks.mjs +5 -7
  43. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  44. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  45. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  46. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  47. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  48. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  49. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  50. package/server/migrations/0031-queue-provider.mjs +5 -7
  51. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  52. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  53. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  54. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  55. package/server/migrations/0036-token-usage.mjs +267 -0
  56. package/server/public/app/about-view.mjs +87 -3
  57. package/server/public/app/api-client.js +2 -1
  58. package/server/public/app/bootstrap.js +55 -2
  59. package/server/public/app/composer-control-state.mjs +12 -2
  60. package/server/public/app/context-usage-view.mjs +16 -0
  61. package/server/public/app/conversation-view.js +17 -2
  62. package/server/public/app/inbox-ui.js +32 -0
  63. package/server/public/app/inbox-view.mjs +141 -5
  64. package/server/public/app/plan-board-shared.mjs +49 -0
  65. package/server/public/app/plan-usage-view.mjs +1 -1
  66. package/server/public/app/relay-board-inline.mjs +4 -1
  67. package/server/public/app/relay-board-view.js +38 -10
  68. package/server/public/app/relay-event-view.mjs +4 -0
  69. package/server/public/app/socket-handlers.js +10 -2
  70. package/server/public/index.html +14 -0
  71. package/server/public/sw.js +8 -0
  72. package/server/relay-heap-guard.mjs +14 -1
  73. package/server/relay-self-restart.mjs +83 -2
  74. package/server/relay-tools.md +4 -3
  75. package/server/repositories/message-repository.mjs +31 -1
  76. package/server/repositories/remote-relay-repository.mjs +10 -0
  77. package/server/repositories/session-repository.mjs +6 -1
  78. package/server/routes/messages-routes.mjs +87 -0
  79. package/server/routes/relay-ask-routes.mjs +138 -8
  80. package/server/routes/relay-board-routes.mjs +55 -40
  81. package/server/routes/sessions-routes.mjs +103 -7
  82. package/server/server-runtime.mjs +132 -11
  83. package/server/services/about-service.mjs +59 -0
  84. package/server/services/cache-rebuild-service.mjs +7 -0
  85. package/server/services/conversation-preferences-service.mjs +31 -0
  86. package/server/services/database-file.mjs +413 -0
  87. package/server/services/db-backup-service.mjs +809 -0
  88. package/server/services/deepseek-usage-import.mjs +6 -9
  89. package/server/services/delete-archive-service.mjs +3 -0
  90. package/server/services/earlier-database-service.mjs +69 -126
  91. package/server/services/oar-cli-helpers.mjs +214 -12
  92. package/server/services/oar-cli-menu.mjs +1 -0
  93. package/server/services/oar-state-migration-service.mjs +33 -13
  94. package/server/services/package-data-rescue.mjs +4 -2
  95. package/server/services/plan-usage-claude-api.mjs +14 -15
  96. package/server/services/plan-usage-contract.mjs +6 -2
  97. package/server/services/plan-usage-deepseek.mjs +1 -1
  98. package/server/services/plan-usage-tokens.mjs +248 -0
  99. package/server/services/push-dispatch-service.mjs +134 -9
  100. package/server/services/relay-cli-launcher-service.mjs +3 -0
  101. package/server/services/relay-event-service.mjs +30 -0
  102. package/server/services/relay-question-context.mjs +11 -0
  103. package/server/services/relay-restart-picture.mjs +67 -22
  104. package/server/services/remote-inbox-watch-service.mjs +204 -0
  105. package/server/services/remote-relay-caller-context.mjs +80 -7
  106. package/server/services/remote-relay-dispatcher.mjs +40 -5
  107. package/server/services/restart-resume-service.mjs +5 -2
  108. package/server/services/session-worker-launch-service.mjs +12 -3
  109. package/server/services/session-worker-stop-service.mjs +27 -6
  110. package/server/services/token-usage-service.mjs +430 -0
  111. package/server/services/update-install-service.mjs +13 -0
  112. package/server/services/usage-limit-pause-service.mjs +8 -1
  113. package/server/services/worker-scope.mjs +77 -0
  114. package/shared/plan-board-actions.mjs +130 -0
  115. package/shared/plan-only.mjs +43 -0
  116. package/shared/relay-asks.mjs +3 -0
  117. package/shared/relay-events.mjs +38 -4
  118. package/shared/remote-relay-contract.mjs +66 -11
  119. package/shared/turn-token-usage.mjs +144 -0
@@ -18,13 +18,14 @@
18
18
  * detached process, a console window on Windows) and keeps its tasks when
19
19
  * the runtime exits. A restart (exit 75) is taken by the relay's own
20
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.
21
+ * the workers report back to it. Under a systemd user service the
22
+ * supervisor hands that restart to systemd, which stops and starts the
23
+ * whole service, and a shutdown stops it: systemd ends every process of the
24
+ * service then. The workers this relay starts there run in scopes of their
25
+ * own (worker-scope.mjs) and keep running; a worker on a tmux server or in a
26
+ * process started before 0.9.20, inside the service, may stop once. A Claude Cloud session runs at
27
+ * Anthropic and is never affected. `oar restart` and `oar stop` come through
28
+ * here too (they ask the relay); only their `--now` goes to systemd directly.
28
29
  */
29
30
 
30
31
  import fs from 'node:fs';
@@ -37,8 +38,10 @@ function toText(value) {
37
38
 
38
39
  /**
39
40
  * 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.
41
+ * systemd service runs it (`unit`: the service, `userManager`: a user's
42
+ * systemd runs it, not the system's), and whether an outer supervisor (the
43
+ * Copilot CLI extension, `--supervised`) owns its restarts. The supervisor
44
+ * and the runtime ask the same way.
42
45
  */
43
46
  export function detectRelayHosting({
44
47
  env = process.env,
@@ -47,20 +50,64 @@ export function detectRelayHosting({
47
50
  readCgroup = () => fs.readFileSync('/proc/self/cgroup', 'utf8'),
48
51
  } = {}) {
49
52
  // 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`).
53
+ // cgroup ends in `.service` (a terminal's shell sits in a `.scope`). Every
54
+ // process the service starts inherits INVOCATION_ID; one in a scope of its
55
+ // own (a session worker) is not the service.
51
56
  let systemd = false;
57
+ let unit = '';
58
+ let userManager = false;
52
59
  if (platform === 'linux' && toText(env?.INVOCATION_ID)) {
53
60
  let cgroup = '';
54
61
  try { cgroup = String(readCgroup() || ''); } catch {}
55
- systemd = cgroup.split('\n').some((line) => /\.service\/?$/.test(line.trim()));
62
+ // The unified line (`0::/…`, the only one on cgroup v2) when it names a
63
+ // service, else v1's `name=systemd` hierarchy: on a hybrid host the
64
+ // unified line may be a bare `0::/` that says nothing. Controller lines
65
+ // (`10:memory:/…`) are not systemd's view: on a hybrid host they stop at
66
+ // `user@<uid>.service`.
67
+ const isService = (entry) => /\.service\/?$/.test(entry || '');
68
+ const lines = cgroup.split('\n').map((entry) => entry.trim());
69
+ const unified = lines.find((entry) => entry.startsWith('0::'));
70
+ const named = lines.find((entry) => /^\d+:name=systemd:/.test(entry));
71
+ const line = isService(unified) ? unified : isService(named) ? named : '';
72
+ if (line) {
73
+ systemd = true;
74
+ const cgroupPath = line.slice(line.indexOf(':', line.indexOf(':') + 1) + 1).replace(/\/$/, '');
75
+ unit = cgroupPath.split('/').pop();
76
+ userManager = /\/user@\d+\.service\//.test(cgroupPath);
77
+ }
56
78
  }
57
79
  const supervised = Array.isArray(argv) && argv.includes('--supervised');
58
- return { systemd, supervised, platform };
80
+ return { systemd, supervised, platform, unit, userManager };
59
81
  }
60
82
 
61
- /** Whether the session workers outlive this action, and why, in one line each. */
83
+ /**
84
+ * Whether the session workers outlive this action, and why, in one line each.
85
+ * `hosting.workerScopes`: whether this relay starts its workers in scopes of
86
+ * their own (worker-scope.mjs `inUse`); unset counts as no.
87
+ */
62
88
  export function describeRestartSurvival({ action = 'restart', hosting = {} } = {}) {
63
89
  const restart = action === 'restart';
90
+ // Under a user service a restart is handed to systemd, which restarts the
91
+ // whole service; elsewhere only the runtime is started anew.
92
+ const wholeService = hosting.systemd && (!restart || hosting.userManager);
93
+ if (wholeService && hosting.workerScopes !== true) {
94
+ return {
95
+ action: restart ? 'restart' : 'shutdown',
96
+ workersSurvive: false,
97
+ summary: `The relay runs as a systemd service: ${restart ? 'a restart restarts' : 'shutting it down stops'} the service and every process in it, `
98
+ + 'and this relay could not start its session workers outside it, so they and their background tasks may stop too '
99
+ + '(a worker in a tmux session the service did not start keeps running).',
100
+ };
101
+ }
102
+ if (wholeService) {
103
+ return {
104
+ action: restart ? 'restart' : 'shutdown',
105
+ workersSurvive: true,
106
+ summary: `The relay runs as a systemd service: ${restart ? 'a restart restarts' : 'shutting it down stops'} the service and every process in it. `
107
+ + 'Session workers this relay started run in scopes of their own and keep running, with their background tasks; '
108
+ + `they report back ${restart ? 'to the new relay within a minute' : 'when it starts again'}. A worker on a tmux server or in a process started before OAR 0.9.20, inside the service, may stop once.`,
109
+ };
110
+ }
64
111
  if (restart) {
65
112
  return {
66
113
  action: 'restart',
@@ -69,14 +116,6 @@ export function describeRestartSurvival({ action = 'restart', hosting = {} } = {
69
116
  + 'they report back to the new relay within a minute.',
70
117
  };
71
118
  }
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
119
  return {
81
120
  action: 'shutdown',
82
121
  workersSurvive: true,
@@ -185,6 +224,8 @@ export function createRestartPictureCollector({
185
224
  // () => [{ conversationId, description }]: tasks held since a restart.
186
225
  listHeldTasks = () => [],
187
226
  hosting = detectRelayHosting(),
227
+ // Whether the workers start in scopes of their own (worker-scope.mjs `inUse`).
228
+ workerScopes = () => false,
188
229
  providerOf = () => '',
189
230
  titleOf = undefined,
190
231
  } = {}) {
@@ -204,6 +245,10 @@ export function createRestartPictureCollector({
204
245
  try { backgroundBlockers = backgroundWorkView?.backgroundBlockers?.() || []; } catch {}
205
246
  let heldTasks = [];
206
247
  try { heldTasks = listHeldTasks() || []; } catch {}
207
- return buildRestartPicture({ action, queueRows, backgroundBlockers, heldTasks, hosting, providerOf, ...(titleOf ? { titleOf } : {}) });
248
+ let scopes;
249
+ try { scopes = workerScopes(); } catch {}
250
+ return buildRestartPicture({
251
+ action, queueRows, backgroundBlockers, heldTasks, hosting: { ...hosting, workerScopes: scopes }, providerOf, ...(titleOf ? { titleOf } : {}),
252
+ });
208
253
  };
209
254
  }
@@ -0,0 +1,204 @@
1
+ 'use strict';
2
+
3
+ // Paired relays' cards, watched from here: every ~20 s, even with no
4
+ // browser open, this relay reads the Inbox of each paired relay
5
+ // (GET /api/relay-asks/remote/inbox) and
6
+ //
7
+ // - tells its browsers when the set of pending remote items changed (the
8
+ // socket event `inbox_remote_changed`, no payload), so the sidebar badge is
9
+ // current without waiting for the Inbox's own poll;
10
+ // - with a device that takes question pushes here, asks with `?push=1` and
11
+ // pushes a card a turn there waits on (an approval, a blocking ask_user)
12
+ // when that relay says this one claimed it just now (`notice: 'you'`,
13
+ // `claimed: 'now'`). One push per card: the card's relay pushes first,
14
+ // and only one paired relay ever holds the claim.
15
+ //
16
+ // A sweep with neither question subscribers nor a connected browser does
17
+ // nothing. Everything here is in memory: after a restart, a card this relay
18
+ // claimed before comes back `claimed: 'before'` and is not pushed again.
19
+
20
+ import { INBOX_REMOTE_CHANGED_EVENT } from '../../shared/relay-asks.mjs';
21
+ import { REMOTE_INBOX_CLAIMS, REMOTE_INBOX_NOTICES } from '../../shared/remote-relay-contract.mjs';
22
+ import { RELAY_ASK_REMOTE_PATHS } from './relay-ask-link-service.mjs';
23
+ import { PUSH_SEND_TIMEOUT_MS } from './push-dispatch-service.mjs';
24
+
25
+ export const REMOTE_INBOX_CHANGED_EVENT = INBOX_REMOTE_CHANGED_EVENT;
26
+ export const REMOTE_INBOX_WATCH_INTERVAL_MS = 20_000;
27
+ export const REMOTE_INBOX_WATCH_JITTER_MS = 3_000;
28
+ const REQUEST_TIMEOUT_MS = 5_000;
29
+ const CONCURRENCY = 4;
30
+ // Pushes per paired relay and sweep; the rest go on the next sweep.
31
+ const PUSHES_PER_RELAY = 3;
32
+ // Registry states that mean "do not even try" (as for the Inbox).
33
+ const UNREACHABLE_STATES = new Set(['offline', 'unauthorized']);
34
+ const PUSHED_KINDS = new Set(['approval', 'ask_user']);
35
+ // A sweep waits this long for one push at most: a push that never settles
36
+ // must not hold the sweep, and with it every later one (the next is
37
+ // scheduled when a sweep ends). The send itself fails after its own timeout.
38
+ const PUSH_WAIT_MS = PUSH_SEND_TIMEOUT_MS + 5_000;
39
+
40
+ function toText(value) {
41
+ return String(value ?? '').trim();
42
+ }
43
+
44
+ /**
45
+ * @param {object} options
46
+ * @param {{ list: () => object[] }} options.registry the pairings
47
+ * @param {{ request: Function }} options.client calls a paired relay
48
+ * @param {{ hasQuestionSubscribers: () => boolean, notifyRemoteCard: Function }} options.push
49
+ * @param {() => boolean} options.hasSockets a browser is connected here
50
+ * @param {(event: string) => void} options.emit to every browser
51
+ */
52
+ export function createRemoteInboxWatchService({
53
+ registry,
54
+ client,
55
+ push,
56
+ hasSockets = () => false,
57
+ emit = () => {},
58
+ logger = console,
59
+ intervalMs = REMOTE_INBOX_WATCH_INTERVAL_MS,
60
+ jitterMs = REMOTE_INBOX_WATCH_JITTER_MS,
61
+ random = Math.random,
62
+ setTimer = setTimeout,
63
+ clearTimer = clearTimeout,
64
+ pushWaitMs = PUSH_WAIT_MS,
65
+ } = {}) {
66
+ // `${relayId}:${id}` of the pending items each paired relay listed last.
67
+ let lastPending = null;
68
+ const pendingByRelay = new Map();
69
+ // Cards pushed from here, and cards claimed here but held back by the cap.
70
+ const pushed = new Set();
71
+ const deferred = new Set();
72
+ let timer = null;
73
+ let running = false;
74
+ let stopped = true;
75
+ // Each start() begins a new timer chain; a tick of an older one (a sweep
76
+ // still running across stop() and start()) schedules nothing.
77
+ let generation = 0;
78
+
79
+ function safe(fn, fallback) {
80
+ try {
81
+ return fn();
82
+ } catch {
83
+ return fallback;
84
+ }
85
+ }
86
+
87
+ async function readRelay(relay, withPush) {
88
+ const path = `${RELAY_ASK_REMOTE_PATHS.inbox}${withPush ? '?push=1' : ''}`;
89
+ try {
90
+ const response = await client.request(relay, 'GET', path, { timeoutMs: REQUEST_TIMEOUT_MS });
91
+ return Array.isArray(response?.items) ? response.items : [];
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ async function pushCards(relay, items) {
98
+ let sent = 0;
99
+ for (const item of items) {
100
+ if (toText(item?.status) !== 'pending' || !PUSHED_KINDS.has(toText(item?.kind))) continue;
101
+ if (item.notice !== REMOTE_INBOX_NOTICES.you) continue;
102
+ const key = `${relay.id}:${toText(item.id)}`;
103
+ if (pushed.has(key)) continue;
104
+ // Claimed by this request, or claimed earlier and held back by the cap.
105
+ if (item.claimed !== REMOTE_INBOX_CLAIMS.now && !deferred.has(key)) continue;
106
+ if (sent >= PUSHES_PER_RELAY) {
107
+ deferred.add(key);
108
+ continue;
109
+ }
110
+ sent += 1;
111
+ pushed.add(key);
112
+ deferred.delete(key);
113
+ let waitTimer = null;
114
+ try {
115
+ await Promise.race([
116
+ Promise.resolve().then(() => push.notifyRemoteCard({ relay, item })),
117
+ new Promise((_resolve, reject) => {
118
+ waitTimer = setTimer(() => reject(new Error(`no answer from the push service in ${Math.round(pushWaitMs / 1000)} s`)), pushWaitMs);
119
+ waitTimer?.unref?.();
120
+ }),
121
+ ]);
122
+ } catch (error) {
123
+ try { logger?.warn?.(`[remote-inbox] push for relay ${toText(relay.name) || relay.id} failed: ${error?.message || error}`); } catch {}
124
+ } finally {
125
+ if (waitTimer) clearTimer(waitTimer);
126
+ }
127
+ }
128
+ }
129
+
130
+ /** One sweep; resolves `{ skipped }` or `{ changed, relays }`. */
131
+ async function sweep() {
132
+ const withPush = safe(() => push?.hasQuestionSubscribers?.() === true, false);
133
+ if (!withPush && !safe(() => hasSockets() === true, false)) return { skipped: true };
134
+ const relays = safe(() => registry.list() || [], [])
135
+ .filter((relay) => relay?.id && !UNREACHABLE_STATES.has(toText(relay.lastStatus).toLowerCase()));
136
+ const listed = new Set(relays.map((relay) => relay.id));
137
+ for (const id of pendingByRelay.keys()) {
138
+ if (!listed.has(id)) pendingByRelay.delete(id);
139
+ }
140
+ const queue = [...relays];
141
+ async function worker() {
142
+ while (queue.length) {
143
+ const relay = queue.shift();
144
+ const items = await readRelay(relay, withPush);
145
+ // A relay that did not answer keeps what it listed last.
146
+ if (!items) continue;
147
+ const pending = items.filter((item) => toText(item?.status) === 'pending' && toText(item?.id));
148
+ const keys = new Set(pending.map((item) => `${relay.id}:${toText(item.id)}`));
149
+ pendingByRelay.set(relay.id, keys);
150
+ for (const key of [...pushed, ...deferred]) {
151
+ if (key.startsWith(`${relay.id}:`) && !keys.has(key)) {
152
+ pushed.delete(key);
153
+ deferred.delete(key);
154
+ }
155
+ }
156
+ if (withPush) await pushCards(relay, pending);
157
+ }
158
+ }
159
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
160
+ const now = new Set([...pendingByRelay.values()].flatMap((keys) => [...keys]));
161
+ const changed = lastPending === null
162
+ ? now.size > 0
163
+ : now.size !== lastPending.size || [...now].some((key) => !lastPending.has(key));
164
+ lastPending = now;
165
+ if (changed) safe(() => emit(REMOTE_INBOX_CHANGED_EVENT), null);
166
+ return { changed, relays: relays.length };
167
+ }
168
+
169
+ function schedule(chain) {
170
+ if (stopped || chain !== generation) return;
171
+ const delay = Math.max(1_000, intervalMs + Math.round((random() * 2 - 1) * jitterMs));
172
+ timer = setTimer(async () => {
173
+ if (chain !== generation) return;
174
+ timer = null;
175
+ if (!running) {
176
+ running = true;
177
+ try {
178
+ await sweep();
179
+ } catch (error) {
180
+ try { logger?.warn?.(`[remote-inbox] sweep failed: ${error?.message || error}`); } catch {}
181
+ } finally {
182
+ running = false;
183
+ }
184
+ }
185
+ schedule(chain);
186
+ }, delay);
187
+ timer?.unref?.();
188
+ }
189
+
190
+ function start() {
191
+ if (!stopped) return;
192
+ stopped = false;
193
+ generation += 1;
194
+ schedule(generation);
195
+ }
196
+
197
+ function stop() {
198
+ stopped = true;
199
+ if (timer) clearTimer(timer);
200
+ timer = null;
201
+ }
202
+
203
+ return { start, stop, sweep };
204
+ }
@@ -4,6 +4,7 @@ import { randomUUID as nodeRandomUUID } from 'crypto';
4
4
 
5
5
  import {
6
6
  REMOTE_RELAY_APPROVAL_SOURCE,
7
+ REMOTE_RELAY_APPROVAL_SUMMARY_MAX,
7
8
  REMOTE_RELAY_ERROR_CODES,
8
9
  REMOTE_RELAY_PROVIDERS,
9
10
  } from '../../shared/remote-relay-contract.mjs';
@@ -103,15 +104,19 @@ const APPROVAL_VERBS = Object.freeze({
103
104
 
104
105
  /**
105
106
  * How the remote turn would run, as far as this relay knows it: the mode (the
106
- * requested one, else the calling session's, which the dispatcher passes on),
107
+ * requested one; for a new session else the calling session's, which the
108
+ * dispatcher passes on; a send without one leaves the target's own),
107
109
  * the provider a new session gets (requested, else the caller's), the model
108
110
  * and working directory when the agent asked for them, and the session group
109
111
  * a new session goes into (`group`, as the target resolved it).
110
112
  */
111
113
  function describeRemoteTurn(action, args, caller, group = null) {
112
114
  const parts = [];
113
- const mode = toText(args.mode).toLowerCase() || toText(caller?.mode).toLowerCase();
115
+ const planOnly = action === 'create_session' && args.planOnly === true;
116
+ const mode = toText(args.mode).toLowerCase()
117
+ || (action === 'create_session' ? (planOnly ? 'plan' : toText(caller?.mode).toLowerCase()) : '');
114
118
  if (mode) parts.push(`Mode: ${mode}`);
119
+ if (planOnly) parts.push('Plan only');
115
120
  if (action === 'create_session') {
116
121
  const requested = toText(args.provider).toLowerCase();
117
122
  const callerProvider = toText(caller?.provider).toLowerCase();
@@ -147,6 +152,7 @@ function describeSettingsChange(args) {
147
152
  if (typeof args.thinking_enabled === 'boolean') parts.push(`Thinking: ${args.thinking_enabled ? 'on' : 'off'}`);
148
153
  if (toText(args.thinking_display)) parts.push(`Thinking text: ${toText(args.thinking_display) === 'omitted' ? 'hidden' : 'visible'}`);
149
154
  if (toText(args.cwd)) parts.push(`Folder: ${excerpt(args.cwd)} (its CLI restarts there)`);
155
+ if (typeof args.planOnly === 'boolean') parts.push(`planOnly: ${args.planOnly}`);
150
156
  return parts.join(' · ');
151
157
  }
152
158
 
@@ -274,6 +280,58 @@ export function formatRemoteRelayApprovalPrompt({ relay, action, args = {}, call
274
280
  return lines.join('\n\n');
275
281
  }
276
282
 
283
+ // A name inside a summary line: short, so the line keeps its point.
284
+ const SUMMARY_NAME_MAX = 60;
285
+
286
+ function summaryName(value) {
287
+ const text = toText(value).replace(/\s+/g, ' ');
288
+ return text.length > SUMMARY_NAME_MAX ? `${text.slice(0, SUMMARY_NAME_MAX - 1)}…` : text;
289
+ }
290
+
291
+ /**
292
+ * What an approval card is about, for another relay's Inbox and push:
293
+ * `{ variant, summary }`. The summary names the kind, the
294
+ * requesting session and the reason (titles, a model, counts), never the
295
+ * prompt, the answer or the question the agent would send: those stay on
296
+ * this relay, in the card itself. Same inputs as the card text.
297
+ */
298
+ export function formatApprovalSummary({ relay, action, args = {}, caller = null, agentPrompts = 0, localSessions = false, watch = null, sessionSettings = null } = {}) {
299
+ const requester = `'${summaryName(caller?.title) || shortId(caller?.conversationId)}'`;
300
+ const by = `requested by ${requester}`;
301
+ const local = relay?.local === true;
302
+ const where = local ? 'this relay' : `relay "${summaryName(relay?.name) || 'a remote relay'}"`;
303
+ let variant;
304
+ let summary;
305
+ if (watch && typeof watch === 'object') {
306
+ variant = 'watch';
307
+ if (watch.kind === 'transfer') {
308
+ const count = Number(watch.count) || 0;
309
+ summary = `Take over ${count} event subscription${count === 1 ? '' : 's'} ${by}`;
310
+ } else {
311
+ const count = Array.isArray(watch.sessions) ? watch.sessions.length : 0;
312
+ summary = `Watch ${watch.all ? 'ALL sessions' : `${count} session${count === 1 ? '' : 's'}`} ${by}`;
313
+ }
314
+ } else if (sessionSettings && typeof sessionSettings === 'object') {
315
+ variant = 'session_settings';
316
+ summary = `Change settings of '${summaryName(sessionSettings.title) || shortId(sessionSettings.session || args.session)}' ${by}`;
317
+ } else if (agentPrompts > 0) {
318
+ variant = 'loop_guard';
319
+ summary = `Loop guard: ${agentPrompts} prompts by ${requester}`;
320
+ } else if (localSessions) {
321
+ variant = 'local_sessions';
322
+ summary = `Start sessions on this relay ${by}`;
323
+ } else if (action === 'create_session') {
324
+ variant = 'create_session';
325
+ const details = [summaryName(args.title), summaryName(args.model)].filter(Boolean).join(', ');
326
+ summary = `New session ${by}${details ? `: ${details}` : ''}`;
327
+ } else {
328
+ variant = 'write';
329
+ const verb = APPROVAL_VERBS[action] || toText(action).replace(/_/g, ' ') || 'act';
330
+ summary = `${verb.charAt(0).toUpperCase()}${verb.slice(1)} on ${where} ${by}`;
331
+ }
332
+ return { variant, summary: summary.slice(0, REMOTE_RELAY_APPROVAL_SUMMARY_MAX) };
333
+ }
334
+
277
335
  export function createRemoteRelayCallerContext({
278
336
  db,
279
337
  repository,
@@ -292,8 +350,12 @@ export function createRemoteRelayCallerContext({
292
350
  questionTimeoutMs = DEFAULT_QUESTION_TIMEOUT_MS,
293
351
  } = {}) {
294
352
  const questionStatements = questions || createQuestionRepository(db);
353
+ // Plan-only (migration 0035): NULL on a schema without it.
354
+ const conversationsHavePlanOnly = db.prepare(`PRAGMA table_info(conversations)`).all()
355
+ .some((column) => column.name === 'plan_only');
295
356
  const getConversation = db.prepare(`
296
- SELECT id, title, preferred_relay_mode, preferred_model, preferred_reasoning_effort FROM conversations WHERE id = ?
357
+ SELECT id, title, preferred_relay_mode, preferred_model, preferred_reasoning_effort,
358
+ ${conversationsHavePlanOnly ? 'plan_only' : 'NULL AS plan_only'} FROM conversations WHERE id = ?
297
359
  `);
298
360
  const getRuntimeSession = db.prepare(`SELECT provider_type, provider_model, model FROM runtime_sessions WHERE conversation_id = ?`);
299
361
  const listProcessingRows = db.prepare(`
@@ -360,9 +422,10 @@ export function createRemoteRelayCallerContext({
360
422
  }
361
423
 
362
424
  /**
363
- * `{ conversationId, title, provider, model, effort, mode, processingRowId,
364
- * attemptId, userMessageId, hops, originRelayIds }` for the conversation
365
- * making the call.
425
+ * `{ conversationId, title, provider, model, effort, mode, planOnly,
426
+ * processingRowId, attemptId, userMessageId, hops, originRelayIds }` for the
427
+ * conversation making the call. A plan-only conversation's `mode` is its
428
+ * saved one, not its live turn's.
366
429
  * `effort` is the reasoning effort of the live turn, else the one the
367
430
  * conversation prefers ('' when neither is known).
368
431
  * `hops` is how many relays the current turn's prompt already crossed: 0
@@ -388,13 +451,21 @@ export function createRemoteRelayCallerContext({
388
451
  const originRelayIds = [...new Set(origins.map((origin) => toText(origin.relayId)))];
389
452
  // The prompt with the most hops: what a schedule of this turn's passes on.
390
453
  const origin = origins.reduce((best, entry) => (!best || originHops(entry) > originHops(best) ? entry : best), null);
454
+ const planOnly = Number(conversation?.plan_only) === 1;
391
455
  return {
392
456
  conversationId: id,
393
457
  title: toText(conversation?.title),
394
458
  provider: toText(runtime?.provider_type).toLowerCase() || 'github',
395
459
  model: toText(live?.model) || toText(runtime?.provider_model) || toText(runtime?.model) || toText(conversation?.preferred_model) || '',
396
460
  effort: (toText(live?.reasoning_effort) || toText(conversation?.preferred_reasoning_effort)).toLowerCase(),
397
- mode: normalizeMode(live?.relay_mode || conversation?.preferred_relay_mode),
461
+ // A plan-only conversation's mode is its saved one (plan): the one write
462
+ // turn of "Approve plan" runs in agent mode, and what it starts or sends
463
+ // must not pass for an agent-mode caller's (an agent session by default,
464
+ // no approval card).
465
+ mode: normalizeMode(planOnly
466
+ ? (conversation?.preferred_relay_mode || live?.relay_mode)
467
+ : (live?.relay_mode || conversation?.preferred_relay_mode)),
468
+ planOnly,
398
469
  processingRowId: live?.id || null,
399
470
  attemptId: toText(live?.attempt_id) || null,
400
471
  userMessageId: live?.id || null,
@@ -474,6 +545,8 @@ export function createRemoteRelayCallerContext({
474
545
  conversationId: toText(conversationId) || row.conversation_id,
475
546
  relayMode,
476
547
  header: watch ? WATCH_HEADER : (localSessions || (sessionSettings && local) ? LOCAL_SESSIONS_HEADER : APPROVAL_HEADER),
548
+ // What a paired relay's Inbox and push show of this card.
549
+ ...formatApprovalSummary({ relay, action, args, caller: callerContext, agentPrompts, localSessions, watch, sessionSettings }),
477
550
  }, { normalizeRelayMode: normalizeMode, defaultRelayMode });
478
551
  questionStatements.insertQuestion.run(
479
552
  questionId,
@@ -31,6 +31,7 @@ import {
31
31
  withRemotePromptHeader,
32
32
  } from '../../shared/remote-relay-contract.mjs';
33
33
  import { schemaFields } from '../../shared/question-schema.mjs';
34
+ import { PLAN_ONLY_ERROR_CODE, planOnlyRefusesMode } from '../../shared/plan-only.mjs';
34
35
  import { matchQuestionChoice } from '../../shared/question-answer-rule.mjs';
35
36
  import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
36
37
  import { RELAY_EVENT_DEFAULT_TYPES, RELAY_EVENT_RELAY_LEVEL_TYPES } from '../../shared/relay-events.mjs';
@@ -1540,6 +1541,8 @@ export function createRemoteRelayDispatcher({
1540
1541
  ...(createdBy && ctx?.relay?.local && createdBy === ctx.conversationId ? { startedByYou: true } : {}),
1541
1542
  // The owner let this agent-started session's agents start sessions.
1542
1543
  ...(row?.mayCreateSessions === true ? { mayCreateSessions: true } : {}),
1544
+ // Agents cannot take it out of plan mode.
1545
+ ...(row?.planOnly === true ? { planOnly: true } : {}),
1543
1546
  // The session group it sits in (sessions an agent started here are
1544
1547
  // grouped under that agent's session; the owner can group by hand).
1545
1548
  ...sessionGroupField(row, groups),
@@ -1794,6 +1797,7 @@ export function createRemoteRelayDispatcher({
1794
1797
  ...sessionActivityFields(conversation, { console: true }),
1795
1798
  ...(via ? { via } : {}),
1796
1799
  ...(conversation?.mayCreateSessions === true ? { mayCreateSessions: true } : {}),
1800
+ ...(conversation?.planOnly === true ? { planOnly: true } : {}),
1797
1801
  ...(group ? { group } : {}),
1798
1802
  openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
1799
1803
  },
@@ -2148,7 +2152,8 @@ export function createRemoteRelayDispatcher({
2148
2152
  // Grok pins its model at bootstrap and refuses any other, so it gets none.
2149
2153
  const model = requestedModel
2150
2154
  || (provider === 'grok' ? '' : (toText(conversation?.preferredModel) || toText(runtime.providerModel) || toText(runtime.model)));
2151
- const mode = args.mode || ctx.caller.mode || toText(conversation?.preferredRelayMode) || '';
2155
+ // Only a mode the agent names: without one the target runs in its own.
2156
+ const mode = toText(args.mode);
2152
2157
  // The remote runs a prompt that names no effort with its provider's
2153
2158
  // default, not with the one the session is set to: pass that one on, so
2154
2159
  // the session keeps its own. This session's effort is never mirrored here.
@@ -2296,7 +2301,14 @@ export function createRemoteRelayDispatcher({
2296
2301
  note = `${provider} on relay "${ctx.relay.name}" does not offer ${ctx.caller.model}; the session uses its default model ${model || '(none)'}.`;
2297
2302
  }
2298
2303
  }
2299
- const mode = args.mode || ctx.caller.mode || toText(catalog.status.defaultRelayMode) || '';
2304
+ // planOnly starts the session locked in plan mode: with no mode named it
2305
+ // starts in plan; another mode named is refused before anything is made.
2306
+ const planOnly = args.planOnly === true || toText(args.planOnly).toLowerCase() === 'true';
2307
+ if (planOnly && planOnlyRefusesMode(args.mode)) {
2308
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
2309
+ `planOnly keeps a session in plan mode; it cannot start in ${toText(args.mode)} mode. Leave mode out or name "plan". No session was created.`);
2310
+ }
2311
+ const mode = args.mode || (planOnly ? 'plan' : ctx.caller.mode) || toText(catalog.status.defaultRelayMode) || '';
2300
2312
  const title = args.title || firstLine(args.text, TITLE_MAX) || 'Remote session';
2301
2313
  const origin = await buildOrigin(ctx);
2302
2314
 
@@ -2327,6 +2339,7 @@ export function createRemoteRelayDispatcher({
2327
2339
  providerType: provider,
2328
2340
  ...(model ? { model } : {}),
2329
2341
  ...(mode ? { relayMode: mode } : {}),
2342
+ ...(planOnly ? { planOnly: true } : {}),
2330
2343
  ...(effort ? { reasoningEffort: effort } : {}),
2331
2344
  title,
2332
2345
  ...(args.cwd ? { cwd: args.cwd } : {}),
@@ -2372,6 +2385,11 @@ export function createRemoteRelayDispatcher({
2372
2385
  } else if (args.group && (!group || (ctx.group?.id && group.id !== ctx.group.id))) {
2373
2386
  groupWarning = `The session was created, but not in group "${clipLine(args.group, 80)}": ${groupWhere(ctx)} put it ${group ? `in "${group.name || group.id}" (${group.id})` : 'in no group'}. Tell the user.`;
2374
2387
  }
2388
+ // A relay that knows planOnly echoes it; an older one created the session
2389
+ // without the lock (in the mode asked for).
2390
+ const planOnlyWarning = planOnly && bootstrap?.planOnly !== true
2391
+ ? `The session was created WITHOUT the plan-only lock: ${groupWhere(ctx)} ignored planOnly (its OAR is older and does not take it). It started in ${mode || 'its default'} mode, but agents can switch it to another mode. Tell the user.`
2392
+ : null;
2375
2393
  const boundModel = toText(bootstrap?.selectedModel) || model;
2376
2394
  // A relay that swapped the asked model for another (one that predates
2377
2395
  // these checks does for Copilot) is named, never passed over in silence.
@@ -2436,14 +2454,20 @@ export function createRemoteRelayDispatcher({
2436
2454
  // The session group it went into, as list_sessions shows it.
2437
2455
  group,
2438
2456
  ...(groupWarning ? { groupWarning } : {}),
2457
+ ...(planOnly ? { planOnly: bootstrap?.planOnly === true } : {}),
2458
+ ...(planOnlyWarning ? { planOnlyWarning } : {}),
2439
2459
  },
2440
2460
  });
2441
- if (!groupWarning) return result;
2461
+ if (!groupWarning && !planOnlyWarning) return result;
2442
2462
  // Never a plain success: the summary and the note say what is missing.
2463
+ const missing = [
2464
+ groupWarning ? `no group: ${groupReported ? 'not applied' : 'target too old'}` : '',
2465
+ planOnlyWarning ? 'no plan-only lock: target too old' : '',
2466
+ ].filter(Boolean).join('; ');
2443
2467
  return {
2444
2468
  ...result,
2445
- summary: `${result.summary} (no group: ${groupReported ? 'not applied' : 'target too old'})`,
2446
- note: [groupWarning, result.note].map(toText).filter(Boolean).join(' '),
2469
+ summary: `${result.summary} (${missing})`,
2470
+ note: [groupWarning, planOnlyWarning, result.note].map(toText).filter(Boolean).join(' '),
2447
2471
  };
2448
2472
  }
2449
2473
 
@@ -2639,6 +2663,10 @@ export function createRemoteRelayDispatcher({
2639
2663
  if (remoteCode === SESSION_SETTINGS_CODES.busy) {
2640
2664
  return failed(409, C.busy, text, { reason: toText(body.reason) || null });
2641
2665
  }
2666
+ // A plan-only session: no other mode while the flag stays.
2667
+ if (remoteCode === PLAN_ONLY_ERROR_CODE) {
2668
+ return failed(409, PLAN_ONLY_ERROR_CODE, text, { note: 'Clear the flag in the same call (planOnly: false with the mode), or ask the user.' });
2669
+ }
2642
2670
  if (remoteCode === SESSION_SETTINGS_CODES.groupNotFound || remoteCode === SESSION_SETTINGS_CODES.groupAmbiguous) {
2643
2671
  return failed(400, C.unknownGroup, text, {
2644
2672
  groups: mapGroupList(body).slice(0, REMOTE_RELAY_GROUP_LIMITS.refusalMax).map((group) => ({ id: group.id, name: group.name })),
@@ -3486,6 +3514,13 @@ export function createRemoteRelayDispatcher({
3486
3514
  return failure(404, C.notFound, what ? `${what} was not found on relay "${relay.name}".` : message, extra);
3487
3515
  }
3488
3516
  const remoteCode = toText(remoteBody?.code) || null;
3517
+ // A plan-only session refused a mode the agent named.
3518
+ if (remoteCode === PLAN_ONLY_ERROR_CODE) {
3519
+ const session = args.session ? `Session ${args.session} on relay "${relay.name}"` : `Relay "${relay.name}"`;
3520
+ return failure(409, PLAN_ONLY_ERROR_CODE,
3521
+ `${session} is plan-only: agents can't switch it to another mode. Send without mode (it runs in plan), or clear planOnly with update_session first, or ask the user. Nothing was sent.`,
3522
+ { ...extra, remoteCode });
3523
+ }
3489
3524
  const supportedModels = Array.isArray(remoteBody?.supportedModels) ? remoteBody.supportedModels.slice(0, 50) : null;
3490
3525
  // A refused effort: the ones the remote's model takes, so the agent can pick one.
3491
3526
  const supportedEfforts = isEffortRejection(error) ? supportedEffortsOf(error) : [];
@@ -39,6 +39,8 @@
39
39
  */
40
40
 
41
41
  import { randomUUID } from 'node:crypto';
42
+ import { resumeRelayMode } from '../../shared/plan-only.mjs';
43
+ import { readPlanOnly } from './conversation-preferences-service.mjs';
42
44
 
43
45
  export const RESTART_RESUME_SETTING_KEY = 'restart_resume_notes';
44
46
  export const RESTART_RESUME_KIND = 'relay-restart-resume';
@@ -91,7 +93,7 @@ export function createRestartResumeService({
91
93
  if (!db || !stmts) throw new Error('createRestartResumeService requires db and stmts');
92
94
  const listProcessing = db.prepare(`
93
95
  SELECT id, conversation_id, model, model_variant_id, reasoning_effort, context_tier, relay_mode,
94
- owner_sdk_session_id, runtime_session_id
96
+ owner_sdk_session_id, runtime_session_id, text
95
97
  FROM queue WHERE status = 'processing'
96
98
  `);
97
99
  const statusOf = db.prepare(`SELECT status FROM queue WHERE id = ?`);
@@ -212,7 +214,8 @@ export function createRestartResumeService({
212
214
  // The model and mode the interrupted turn ran with.
213
215
  const model = text(cutRow?.model) || text(latestModel) || text(runtimeSession?.provider_model) || text(runtimeSession?.model) || null;
214
216
  const modelVariant = text(cutRow?.model_variant_id) || model;
215
- const relayMode = text(cutRow?.relay_mode) || 'agent';
217
+ // On a plan-only session only the "Approve plan" turn goes on in agent mode.
218
+ const relayMode = resumeRelayMode({ relayMode: cutRow?.relay_mode, text: cutRow?.text, planOnly: readPlanOnly(conversation) });
216
219
  const owner = text(cutRow?.owner_sdk_session_id) || text(runtimeSession?.sdk_session_id) || null;
217
220
  const write = db.transaction(() => {
218
221
  stmts.insertMsg.run(