@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
@@ -13,12 +13,18 @@
13
13
  import { apiFetch, answerRelayQuestion } from './api-client.js';
14
14
  import { currentConvId, IS_SHARED_VIEW, setCurrentConv, showTransientRelayNotice } from './store.js';
15
15
  import { isStatusViewActive, leaveStatusView } from './status-view.mjs';
16
+ import { actOnRelayBoard } from './relay-board-view.js';
16
17
  import {
17
18
  buildAskCard,
19
+ captureInboxFolds,
18
20
  createCoalescedRefresh,
19
21
  inboxPendingCount,
20
22
  INBOX_FILTERS,
23
+ INBOX_FOLD_SELECTOR,
24
+ noteInboxFold,
25
+ pruneInboxFolds,
21
26
  renderInbox,
27
+ restoreInboxFolds,
22
28
  sessionAsksFromInbox,
23
29
  } from './inbox-view.mjs';
24
30
 
@@ -37,6 +43,10 @@ let clickBound = false;
37
43
  let started = false;
38
44
  // Typed answers, kept across re-renders: `${relayId}:${itemId}` → text.
39
45
  const drafts = new Map();
46
+ // The open folds (a card's Details, a plan card's plan), kept across
47
+ // re-renders the same way: every socket change and the minute's poll draw
48
+ // the cards anew, closed. Forgotten when the item leaves the list.
49
+ const openFolds = new Set();
40
50
  // This relay's asks for the session cards: id → ask (each Inbox load
41
51
  // replaces it; the socket updates single asks in between).
42
52
  let sessionAsks = new Map();
@@ -104,9 +114,11 @@ function renderInboxNow({ opening = false } = {}) {
104
114
  const host = document.getElementById('messages');
105
115
  if (!host) return;
106
116
  const focused = captureDrafts(host);
117
+ if (host.querySelector('.inbox-view')) captureInboxFolds(openFolds, host);
107
118
  const top = opening ? inboxScrollTop : host.scrollTop;
108
119
  host.replaceChildren(renderInbox(document, { relays, filter, now: Date.now(), loading: !loaded }));
109
120
  restoreDrafts(host, focused);
121
+ restoreInboxFolds(openFolds, host);
110
122
  host.scrollTop = top;
111
123
  const title = document.getElementById('chat-title');
112
124
  if (title) {
@@ -122,6 +134,7 @@ async function loadInbox() {
122
134
  loaded = true;
123
135
  // Built anew from each load: an ask withdrawn or cancelled meanwhile goes.
124
136
  sessionAsks = sessionAsksFromInbox(relays);
137
+ pruneInboxFolds(openFolds, relays);
125
138
  }
126
139
  syncInboxBadge();
127
140
  renderInboxNow();
@@ -268,6 +281,15 @@ async function answerCard(card, data, { choice = '' } = {}) {
268
281
  return true;
269
282
  }
270
283
 
284
+ // A plan card's button in the Inbox: the card's own action, with
285
+ // the plan-only question first; a card settled meanwhile just shows so.
286
+ async function actOnPlanItem(data) {
287
+ const own = relays.find((relay) => relay?.self === true);
288
+ const item = (Array.isArray(own?.items) ? own.items : []).find((entry) => entry?.kind === 'plan' && entry.id === data.boardId) || null;
289
+ const board = item ? { ...item, conversationId: item.session?.id } : null;
290
+ return !!(await actOnRelayBoard(data.boardId, data.actionId, { board }));
291
+ }
292
+
271
293
  async function handleAction(target) {
272
294
  const data = { ...target.dataset };
273
295
  const card = target.closest('.inbox-item');
@@ -294,6 +316,7 @@ async function handleAction(target) {
294
316
  else if (data.inboxAction === 'dismiss') done = await answerAsk(card, data, { dismiss: true });
295
317
  else if (data.inboxAction === 'card-choice') done = await answerCard(card, data, { choice: data.choice });
296
318
  else if (data.inboxAction === 'card-answer') done = await answerCard(card, data);
319
+ else if (data.inboxAction === 'plan-action') done = await actOnPlanItem(data);
297
320
  } finally {
298
321
  if (!done) for (const node of buttons) node.disabled = false;
299
322
  }
@@ -318,6 +341,13 @@ function bindClicks() {
318
341
  const key = field ? draftKey(field) : '';
319
342
  if (key) drafts.set(key, field.value);
320
343
  });
344
+ // A fold opened or closed by hand. `toggle` does not bubble: caught on the
345
+ // way down. Also covers a session's card that a transcript render dropped
346
+ // before renderSessionAskCards drew it again.
347
+ host?.addEventListener('toggle', (event) => {
348
+ const details = event.target instanceof Element && event.target.matches(INBOX_FOLD_SELECTOR) ? event.target : null;
349
+ if (details && details.isConnected) noteInboxFold(openFolds, details);
350
+ }, true);
321
351
  }
322
352
 
323
353
  // ─── The card in the session ──────────────────────────────────────────────────
@@ -341,6 +371,7 @@ export function renderSessionAskCards() {
341
371
  if (key === sessionCardKey && existing.length === shown.length && [...existing].every((node) => node.isConnected)) return;
342
372
  sessionCardKey = key;
343
373
  const focused = captureDrafts(host);
374
+ existing.forEach((node) => captureInboxFolds(openFolds, node));
344
375
  existing.forEach((node) => node.remove());
345
376
  const firstQuestion = host.querySelector('.relay-question-container');
346
377
  for (const ask of shown) {
@@ -350,6 +381,7 @@ export function renderSessionAskCards() {
350
381
  wrapper.append(buildAskCard(document, { ...ask, session: ask.session || { id } }, { relay: { id: 'self', self: true }, mode: 'session', now: Date.now() }));
351
382
  if (firstQuestion) host.insertBefore(wrapper, firstQuestion);
352
383
  else host.append(wrapper);
384
+ restoreInboxFolds(openFolds, wrapper);
353
385
  }
354
386
  restoreDrafts(host, focused);
355
387
  }
@@ -11,11 +11,26 @@
11
11
  // values of shared/relay-asks.mjs this needs are kept here, and
12
12
  // inbox-view.test.mjs asserts they match.
13
13
 
14
+ import { boardOutcomeLine } from './relay-board-inline.mjs';
15
+
14
16
  export const RELAY_ASK_SOCKET_EVENT = 'relay_ask_updated';
17
+ export const INBOX_REMOTE_CHANGED_EVENT = 'inbox_remote_changed';
15
18
  export const INBOX_ANSWERED_KEEP_MS = 24 * 60 * 60_000;
16
19
  export const INBOX_FILTERS = Object.freeze(['all', 'mine', 'agents']);
17
20
 
18
- const CLOSED_STATUSES = new Set(['answered', 'dismissed']);
21
+ // The head label of a paired relay's approval card by its `variant`
22
+ // (REMOTE_RELAY_APPROVAL_VARIANTS of shared/remote-relay-contract.mjs).
23
+ export const INBOX_APPROVAL_VARIANT_LABELS = Object.freeze({
24
+ create_session: 'New session',
25
+ loop_guard: 'Loop guard',
26
+ write: 'Write',
27
+ session_settings: 'Session settings',
28
+ watch: 'Watch',
29
+ local_sessions: 'Start sessions',
30
+ });
31
+
32
+ // A plan card that was acted on is closed too.
33
+ const CLOSED_STATUSES = new Set(['answered', 'dismissed', 'acted']);
19
34
 
20
35
  function toText(value) {
21
36
  return String(value ?? '').trim();
@@ -71,6 +86,11 @@ function isClosed(item) {
71
86
  return CLOSED_STATUSES.has(toText(item?.status));
72
87
  }
73
88
 
89
+ // When an item was closed: answered, a plan card acted on, else made.
90
+ function closedAt(item) {
91
+ return item?.answeredAt || item?.actedAt || item?.createdAt;
92
+ }
93
+
74
94
  function newestFirst(a, b) {
75
95
  return toText(b?.createdAt).localeCompare(toText(a?.createdAt));
76
96
  }
@@ -85,8 +105,8 @@ export function inboxSections(relays, { filter = 'all', now = Date.now() } = {})
85
105
  const items = (Array.isArray(relay?.items) ? relay.items : []).filter((item) => item && toText(item.id) && inboxFilterMatches(item, filter));
86
106
  const pending = items.filter((item) => toText(item.status) === 'pending').sort(newestFirst);
87
107
  const closed = items
88
- .filter((item) => isClosed(item) && now - (Date.parse(toText(item.answeredAt || item.createdAt)) || 0) <= INBOX_ANSWERED_KEEP_MS)
89
- .sort((a, b) => toText(b.answeredAt || b.createdAt).localeCompare(toText(a.answeredAt || a.createdAt)));
108
+ .filter((item) => isClosed(item) && now - (Date.parse(toText(closedAt(item))) || 0) <= INBOX_ANSWERED_KEEP_MS)
109
+ .sort((a, b) => toText(closedAt(b)).localeCompare(toText(closedAt(a))));
90
110
  return {
91
111
  relay: {
92
112
  id: toText(relay?.id),
@@ -147,6 +167,59 @@ export function sessionAsksFromInbox(relays) {
147
167
  return asks;
148
168
  }
149
169
 
170
+ // The folds a card has: an ask's `Details` and a plan card's `The plan`.
171
+ export const INBOX_FOLD_SELECTOR = 'details.inbox-item-detail, details.inbox-plan-body';
172
+
173
+ /**
174
+ * A fold's key: its card's relay and id, and which fold it is. Every render
175
+ * builds the cards anew, closed; the keys of the open ones carry them over.
176
+ */
177
+ export function inboxFoldKey(details) {
178
+ const card = details?.closest?.('.inbox-item');
179
+ const itemId = toText(card?.dataset?.itemId);
180
+ if (!itemId) return '';
181
+ const fold = details.classList?.contains('inbox-plan-body') ? 'plan' : 'detail';
182
+ return `${toText(card.dataset.relayId) || 'self'}\n${itemId}\n${fold}`;
183
+ }
184
+
185
+ /** Notes whether `details` is open in `openFolds` (a Set of fold keys). */
186
+ export function noteInboxFold(openFolds, details) {
187
+ const key = inboxFoldKey(details);
188
+ if (!key) return;
189
+ if (details.open) openFolds.add(key);
190
+ else openFolds.delete(key);
191
+ }
192
+
193
+ /** Notes every fold under `root`, as it is now. */
194
+ export function captureInboxFolds(openFolds, root) {
195
+ for (const details of root?.querySelectorAll?.(INBOX_FOLD_SELECTOR) || []) noteInboxFold(openFolds, details);
196
+ }
197
+
198
+ /** Opens the folds under `root` that were open before its re-render. */
199
+ export function restoreInboxFolds(openFolds, root) {
200
+ for (const details of root?.querySelectorAll?.(INBOX_FOLD_SELECTOR) || []) {
201
+ if (openFolds.has(inboxFoldKey(details))) details.open = true;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Forgets the folds of items no longer listed (an Inbox load, GET
207
+ * /api/inbox). A relay that did not answer keeps its folds: its items did
208
+ * not leave, they are just not shown.
209
+ */
210
+ export function pruneInboxFolds(openFolds, relays, { now = Date.now() } = {}) {
211
+ const listed = new Set();
212
+ const kept = new Set();
213
+ for (const section of inboxSections(relays, { now })) {
214
+ if (!section.relay.reachable) kept.add(section.relay.id || 'self');
215
+ for (const item of [...section.pending, ...section.closed]) listed.add(`${section.relay.id || 'self'}\n${toText(item.id)}`);
216
+ }
217
+ for (const key of [...openFolds]) {
218
+ const [relayId, itemId] = key.split('\n');
219
+ if (!kept.has(relayId) && !listed.has(`${relayId}\n${itemId}`)) openFolds.delete(key);
220
+ }
221
+ }
222
+
150
223
  /** Pending items across every relay (the sidebar's badge), whatever the filter. */
151
224
  export function inboxPendingCount(relays) {
152
225
  return (Array.isArray(relays) ? relays : [])
@@ -269,10 +342,17 @@ export function buildCardItem(doc, item, { relay = null, now = Date.now() } = {}
269
342
  card.dataset.kind = toText(item.kind) || 'ask_user';
270
343
  if (relay?.id) card.dataset.relayId = relay.id;
271
344
  card.dataset.status = toText(item.status) || 'pending';
272
- card.append(headLine(doc, item, { relay, now, label: approval ? 'Approval · the session waits' : 'Question · the session waits' }));
345
+ // A paired relay's approval says what it is about; one from a
346
+ // relay without summaries (0.9.19) keeps the plain text.
347
+ const summary = approval && !relay?.self ? toText(item.summary) : '';
348
+ const variant = toText(item.variant);
349
+ const variantLabel = Object.hasOwn(INBOX_APPROVAL_VARIANT_LABELS, variant) ? INBOX_APPROVAL_VARIANT_LABELS[variant] : '';
350
+ const kindLabel = summary ? (variantLabel || 'Approval') : (approval ? 'Approval' : 'Question');
351
+ card.append(headLine(doc, item, { relay, now, label: `${kindLabel} · the session waits` }));
273
352
  card.append(sessionLine(doc, item, relay));
274
353
  if (toText(item.header)) card.append(el(doc, 'div', 'inbox-item-header', item.header));
275
- if (toText(item.question)) card.append(el(doc, 'p', 'inbox-item-question', item.question));
354
+ if (summary) card.append(el(doc, 'p', 'inbox-item-question inbox-item-summary', `${toText(relay?.name) || 'that relay'} · ${summary}`));
355
+ else if (toText(item.question)) card.append(el(doc, 'p', 'inbox-item-question', item.question));
276
356
  else if (approval) card.append(el(doc, 'p', 'inbox-item-question', 'An Allow / Deny card on that relay.'));
277
357
  const outcome = outcomeLine(doc, item);
278
358
  if (outcome) card.append(outcome);
@@ -321,8 +401,64 @@ export function buildCardItem(doc, item, { relay = null, now = Date.now() } = {}
321
401
  return card;
322
402
  }
323
403
 
404
+ /**
405
+ * A plan card in the Inbox: the plan folded, and the card's own
406
+ * buttons, which act as the card in its session does (a plan-only session
407
+ * asks first). Settled, it says what was chosen. A paired relay's shows its
408
+ * title alone: "Open there".
409
+ */
410
+ export function buildPlanItem(doc, item, { relay = null, now = Date.now() } = {}) {
411
+ const card = el(doc, 'article', 'inbox-item inbox-card inbox-plan');
412
+ card.dataset.itemId = toText(item.id);
413
+ card.dataset.kind = 'plan';
414
+ if (relay?.id) card.dataset.relayId = relay.id;
415
+ card.dataset.status = toText(item.status) || 'pending';
416
+ card.append(headLine(doc, item, { relay, now, label: 'Plan · ready for review' }));
417
+ card.append(sessionLine(doc, item, relay));
418
+ card.append(el(doc, 'div', 'inbox-item-header', toText(item.title) || 'Plan ready for review'));
419
+ if (relay?.self && toText(item.body)) {
420
+ const details = el(doc, 'details', 'inbox-plan-body');
421
+ details.append(el(doc, 'summary', '', 'The plan'));
422
+ details.append(el(doc, 'div', 'inbox-plan-text', item.body));
423
+ card.append(details);
424
+ }
425
+ if (item.status !== 'pending') {
426
+ const outcome = boardOutcomeLine(item);
427
+ if (outcome) card.append(el(doc, 'div', 'inbox-item-outcome', outcome));
428
+ return card;
429
+ }
430
+ if (!relay?.self) {
431
+ const url = inboxSessionUrl(relay?.url, item?.session?.id);
432
+ const actions = el(doc, 'div', 'inbox-item-actions');
433
+ if (url) {
434
+ const link = el(doc, 'a', 'inbox-open-there inbox-open-there-button', 'Open there');
435
+ link.href = url;
436
+ link.target = '_blank';
437
+ link.rel = 'noopener noreferrer';
438
+ actions.append(link);
439
+ } else {
440
+ actions.append(el(doc, 'span', 'inbox-item-note', 'Answer it on that relay.'));
441
+ }
442
+ card.append(actions);
443
+ return card;
444
+ }
445
+ const recommended = toText(item.recommendedAction).toLowerCase();
446
+ const row = el(doc, 'div', 'inbox-item-options inbox-plan-actions');
447
+ for (const action of Array.isArray(item.actions) ? item.actions : []) {
448
+ const actionId = toText(action?.id).toLowerCase();
449
+ if (!actionId) continue;
450
+ const node = button(doc, `inbox-choice inbox-plan-action${actionId === recommended ? ' inbox-plan-action-recommended' : ''}`, toText(action?.label) || actionId, {
451
+ relayId: 'self', inboxAction: 'plan-action', boardId: item.id, actionId, sessionId: item?.session?.id,
452
+ });
453
+ row.append(node);
454
+ }
455
+ card.append(row);
456
+ return card;
457
+ }
458
+
324
459
  function buildItem(doc, item, relay, now) {
325
460
  if (item.kind === 'ask') return buildAskCard(doc, item, { relay, now, mode: 'inbox', answerable: true });
461
+ if (item.kind === 'plan') return buildPlanItem(doc, item, { relay, now });
326
462
  return buildCardItem(doc, item, { relay, now });
327
463
  }
328
464
 
@@ -0,0 +1,49 @@
1
+ // Browser mirror of the pieces of shared/plan-board-actions.mjs the web UI
2
+ // needs. The browser cannot import shared/ (only server/public is served), so
3
+ // these are hand-kept copies; plan-board-shared.test.mjs asserts they match.
4
+ // Below them, the web UI's own plan-only wording.
5
+
6
+ /** The actions that take the session out of plan mode for good. */
7
+ export const PLAN_BOARD_LEAVES_PLAN = Object.freeze(['autopilot', 'autopilot_fleet', 'interactive']);
8
+
9
+ export const PLAN_BOARD_LABELS = Object.freeze({
10
+ approve_only: 'Approve plan',
11
+ autopilot: 'Implement in autopilot',
12
+ autopilot_fleet: 'Implement with autopilot fleet',
13
+ interactive: 'Stop here and prompt myself',
14
+ exit_only: 'Stop here',
15
+ });
16
+
17
+ /** The button's label; an id the table does not know is spelled out. */
18
+ export function planBoardActionLabel(id) {
19
+ const key = String(id || '').trim().toLowerCase();
20
+ if (Object.prototype.hasOwnProperty.call(PLAN_BOARD_LABELS, key)) return PLAN_BOARD_LABELS[key];
21
+ return key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase()) || 'Select';
22
+ }
23
+
24
+ /**
25
+ * On a plan-only session the actions that leave plan mode for good ask
26
+ * first; "Approve plan", "Stop here" and an action the relay does not know
27
+ * never do. The question, or '' when there is none.
28
+ */
29
+ export function planBoardConfirmText({ planOnly = false, actionId = '', label = '' } = {}) {
30
+ const id = String(actionId || '').trim().toLowerCase();
31
+ if (planOnly !== true || !PLAN_BOARD_LEAVES_PLAN.includes(id)) return '';
32
+ const name = String(label || '').trim() || planBoardActionLabel(id);
33
+ return `This session is plan-only. ${name} leaves plan mode. Go on?`;
34
+ }
35
+
36
+ /** The mode select's hint on a plan-only session (title and the plan option's text). */
37
+ export function planOnlyModeHint(planOnly) {
38
+ return planOnly === true
39
+ ? { title: 'Mode · plan-only: agents can\'t take this session out of plan mode', planLabel: 'Plan 🔒' }
40
+ : { title: 'Mode', planLabel: 'Plan' };
41
+ }
42
+ /**
43
+ * The plan-only flag after a conversation_preferences_updated broadcast: the
44
+ * one it carries, else the one already known (an older relay, or the mode
45
+ * follow-up of a plan card, says nothing about it).
46
+ */
47
+ export function planOnlyAfterBroadcast(broadcast, known) {
48
+ return typeof broadcast === 'boolean' ? broadcast : known === true;
49
+ }
@@ -280,7 +280,7 @@ function renderChart(chart) {
280
280
 
281
281
  function renderDetailSection(section) {
282
282
  const rows = section.rows.map((row) => `
283
- <tr>
283
+ <tr${row.title ? ` title="${escapeHtml(row.title)}"` : ''}>
284
284
  <td class="plan-usage-detail-label">${escapeHtml(row.label)}${row.hint ? `<span class="plan-usage-detail-hint">${escapeHtml(row.hint)}</span>` : ''}</td>
285
285
  <td class="plan-usage-detail-value">${String(row.value).split(' · ').map((part) => `<span class="plan-usage-detail-part">${escapeHtml(part)}</span>`).join(' · ')}</td>
286
286
  </tr>
@@ -11,6 +11,8 @@
11
11
  // board when the agent wrote its plan as the answer. Then the text is shown
12
12
  // once, as the reply, and the board adds only its buttons.
13
13
 
14
+ import { PLAN_BOARD_LABELS } from './plan-board-shared.mjs';
15
+
14
16
  function plain(text) {
15
17
  return String(text || '').replace(/\s+/g, ' ').trim().toLowerCase();
16
18
  }
@@ -32,7 +34,8 @@ export function chosenActionLabel(board) {
32
34
  if (!chosen) return '';
33
35
  const actions = Array.isArray(board?.actions) ? board.actions : [];
34
36
  const action = actions.find((item) => String(item?.id || '').trim().toLowerCase() === chosen);
35
- return String(action?.label || '').trim() || chosen.replace(/[_-]+/g, ' ');
37
+ // A card stored before "Approve plan" existed does not list it.
38
+ return String(action?.label || '').trim() || (Object.hasOwn(PLAN_BOARD_LABELS, chosen) ? PLAN_BOARD_LABELS[chosen] : chosen.replace(/[_-]+/g, ' '));
36
39
  }
37
40
 
38
41
  /** The line that replaces the buttons once the board is settled; '' while it is pending. */
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  IS_SHARED_VIEW,
3
+ conversations,
3
4
  currentConvId,
4
5
  escHtml,
5
6
  relayBoards,
@@ -12,9 +13,11 @@ import {
12
13
  boardsOfConversation,
13
14
  } from './relay-board-inline.mjs';
14
15
  import {
16
+ loadRelayBoard as loadRelayBoardApi,
15
17
  loadRelayBoards as loadRelayBoardsApi,
16
18
  submitRelayBoardAction as submitRelayBoardActionApi,
17
19
  } from './api-client.js';
20
+ import { planBoardConfirmText } from './plan-board-shared.mjs';
18
21
  import { renderMarkdownPreview } from './router.js';
19
22
  import { attachCodeCopyButtons } from './code-copy.mjs';
20
23
  import { beginOwnSend, followsTranscriptEnd, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
@@ -203,27 +206,52 @@ export function renderRelayBoards() {
203
206
  return added;
204
207
  }
205
208
 
206
- export async function submitRelayBoardAction(boardId, actionId) {
209
+ /**
210
+ * Settle a board, from its card or from the Inbox (`board`: what the caller
211
+ * shows, when the store has no copy; `controls`: the buttons to hold while
212
+ * it goes). On a plan-only session an action that leaves plan mode asks
213
+ * first. A board the other view settled meanwhile is shown as settled, with
214
+ * no alert. Returns the board as it is now, or null when nothing was done.
215
+ */
216
+ export async function actOnRelayBoard(boardId, actionId, { board: shown = null, controls = [] } = {}) {
207
217
  const id = String(boardId || '').trim();
208
218
  const nextActionId = String(actionId || '').trim();
209
- if (!id || !nextActionId) return;
210
- const card = document.querySelector(`.relay-board-inline[data-board-id="${CSS.escape(id)}"]`);
211
- const controls = card ? card.querySelectorAll('button') : [];
219
+ if (!id || !nextActionId) return null;
220
+ const board = relayBoards.get(id) || shown;
221
+ // The flag as the page knows it now; the board's own copy otherwise.
222
+ const conversation = conversations[String(board?.conversationId || shown?.session?.id || '').trim()] || null;
223
+ const planOnly = typeof conversation?.planOnly === 'boolean' ? conversation.planOnly : board?.planOnly === true;
224
+ const action = (Array.isArray(board?.actions) ? board.actions : [])
225
+ .find((item) => String(item?.id || '').trim().toLowerCase() === nextActionId.toLowerCase());
226
+ const question = planBoardConfirmText({ planOnly, actionId: nextActionId, label: action?.label });
227
+ if (question && !window.confirm(question)) return null;
212
228
  controls.forEach((control) => { control.disabled = true; });
213
- const send = beginOwnSend();
214
-
229
+ let status = 0;
215
230
  try {
216
- const r = await submitRelayBoardActionApi(id, nextActionId);
217
- if (!r?.board) throw new Error('Failed to submit board action');
231
+ const r = await submitRelayBoardActionApi(id, nextActionId, { onErrorResponse: (response) => { status = response.status; } });
232
+ let settled = r?.board || null;
233
+ // Settled by the other view (or another page) meanwhile: show what was chosen.
234
+ if (!settled && status === 409) settled = (await loadRelayBoardApi(id))?.board || null;
235
+ if (!settled) throw new Error('Failed to submit board action');
218
236
  // Kept once it is settled: the reply goes on showing the plan and says
219
237
  // what was chosen.
220
- relayBoards.set(id, r.board);
238
+ relayBoards.set(id, settled);
221
239
  window.renderConvList?.();
222
240
  renderRelayBoards();
223
- scrollAfterOwnSend(send);
241
+ return settled;
224
242
  } catch (error) {
225
243
  controls.forEach((control) => { control.disabled = false; });
226
244
  alert(error.message || 'Failed to submit board action');
245
+ return null;
227
246
  }
228
247
  }
229
248
 
249
+ export async function submitRelayBoardAction(boardId, actionId) {
250
+ const id = String(boardId || '').trim();
251
+ if (!id || !String(actionId || '').trim()) return;
252
+ const card = document.querySelector(`.relay-board-inline[data-board-id="${CSS.escape(id)}"]`);
253
+ const controls = card ? [...card.querySelectorAll('button')] : [];
254
+ const send = beginOwnSend();
255
+ if (await actOnRelayBoard(id, actionId, { controls })) scrollAfterOwnSend(send);
256
+ }
257
+
@@ -14,6 +14,7 @@ export const RELAY_EVENT_SOCKET_EVENT = 'relay_event_subscriptions';
14
14
 
15
15
  const EVENT_TYPES = new Set([
16
16
  'question_asked',
17
+ 'question_answered',
17
18
  'error',
18
19
  'turn_end',
19
20
  'task_started',
@@ -21,6 +22,7 @@ const EVENT_TYPES = new Set([
21
22
  'task_failed',
22
23
  'stalled',
23
24
  'all_idle',
25
+ 'board_settled',
24
26
  'heartbeat',
25
27
  'link_lost',
26
28
  'link_restored',
@@ -35,6 +37,7 @@ const LINE_PREFIX = '[relay event] ';
35
37
 
36
38
  const TYPE_LABELS = Object.freeze({
37
39
  question_asked: 'question',
40
+ question_answered: 'answer',
38
41
  error: 'error',
39
42
  turn_end: 'reply',
40
43
  task_started: 'task started',
@@ -42,6 +45,7 @@ const TYPE_LABELS = Object.freeze({
42
45
  task_failed: 'task failed',
43
46
  stalled: 'stalled',
44
47
  all_idle: 'all idle',
48
+ board_settled: 'plan card settled',
45
49
  heartbeat: 'heartbeat',
46
50
  link_lost: 'link lost',
47
51
  link_restored: 'link back',
@@ -40,7 +40,7 @@ import { applyBackgroundTasksToConversation, applyTurnStatusToConversation } fro
40
40
  import { applyRelayEventSubscriptions, loadRelayEventSubscriptions } from './relay-event-subscriptions-ui.js';
41
41
  import { applyRelaySchedules, loadRelaySchedules } from './relay-schedules-ui.js';
42
42
  import { noteInboxQuestionsChanged, noteRelayAskUpdated, refreshInbox } from './inbox-ui.js';
43
- import { RELAY_ASK_SOCKET_EVENT } from './inbox-view.mjs';
43
+ import { INBOX_REMOTE_CHANGED_EVENT, RELAY_ASK_SOCKET_EVENT } from './inbox-view.mjs';
44
44
  import {
45
45
  upsertRelayQuestion,
46
46
  loadRelayQuestions,
@@ -104,6 +104,7 @@ import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
104
104
  import { isLikelyLiveDuplicateMessage } from './live-message-dedupe.mjs';
105
105
  import { mergeRelayThoughts } from './relay-thoughts.mjs';
106
106
  import { conversationPayloadCache } from './conversation-payload-cache.mjs';
107
+ import { planOnlyAfterBroadcast } from './plan-board-shared.mjs';
107
108
 
108
109
  // A live row of the conversation on screen joins its kept window, so a
109
110
  // switch away and back still shows it. Rows of other conversations do not:
@@ -664,12 +665,16 @@ export async function connectSocket(overrideDeps) {
664
665
  loadRelayQuestions(currentConvId);
665
666
  noteInboxQuestionsChanged();
666
667
  });
668
+ // A paired relay's pending cards changed (this relay checks them every ~20 s).
669
+ socket.on(INBOX_REMOTE_CHANGED_EVENT, () => noteInboxQuestionsChanged());
667
670
  // Questions that don't block: one ask, after every change of it.
668
671
  socket.on(RELAY_ASK_SOCKET_EVENT, (payload) => noteRelayAskUpdated(payload));
669
672
  socket.on('relay_board', ({ board }) => upsertRelayBoard(board));
670
673
  socket.on('relay_board_updated', ({ board }) => upsertRelayBoard(board));
671
674
  socket.on('relay_board_changed', () => {
672
675
  loadRelayBoards();
676
+ // Plan cards are in the Inbox too.
677
+ noteInboxQuestionsChanged();
673
678
  });
674
679
  // Relay event subscriptions: the whole active list, after every change.
675
680
  socket.on('relay_event_subscriptions', ({ subscriptions }) => {
@@ -846,7 +851,7 @@ export async function connectSocket(overrideDeps) {
846
851
  applyConversationTitleUpdate(conversationId, title, updatedAt);
847
852
  syncChatTitleControls();
848
853
  });
849
- socket.on('conversation_preferences_updated', ({ conversationId, preferredRelayMode, preferredModel, preferredReasoningEffort, autoCompactWindow, thinkingEnabled, thinkingDisplay, mayCreateSessions, senderClientId }) => {
854
+ socket.on('conversation_preferences_updated', ({ conversationId, preferredRelayMode, preferredModel, preferredReasoningEffort, autoCompactWindow, thinkingEnabled, thinkingDisplay, mayCreateSessions, planOnly, senderClientId }) => {
850
855
  if (senderClientId && senderClientId === CLIENT_ID) return;
851
856
  const id = String(conversationId || '').trim();
852
857
  if (!id || !conversations[id]) return;
@@ -872,6 +877,9 @@ export async function connectSocket(overrideDeps) {
872
877
  mayCreateSessions: typeof mayCreateSessions === 'boolean'
873
878
  ? mayCreateSessions
874
879
  : conversations[id].mayCreateSessions === true,
880
+ // Plan-only: a broadcast without it (an older relay, a board's mode
881
+ // follow-up) keeps the known value.
882
+ planOnly: planOnlyAfterBroadcast(planOnly, conversations[id].planOnly),
875
883
  };
876
884
  if (String(currentConvId || '').trim() === id) {
877
885
  applyConversationPreferencesForConversation(id, {
@@ -937,6 +937,10 @@
937
937
  gap: 6px;
938
938
  }
939
939
  .inbox-choice.recommended { border-color: var(--accent); }
940
+ /* A plan card in the Inbox: the plan folded, its own buttons. */
941
+ .inbox-choice.inbox-plan-action-recommended { border-color: var(--accent); }
942
+ .inbox-plan-body summary { cursor: pointer; color: var(--muted); font-size: var(--fs-meta-sm); }
943
+ .inbox-plan-text { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 40vh; overflow: auto; margin-top: 4px; }
940
944
  .inbox-recommended-tag { color: var(--accent); font-size: var(--fs-meta-xs); }
941
945
  .inbox-answer { border-color: var(--accent); color: var(--accent); }
942
946
  .inbox-dismiss { color: var(--muted); }
@@ -3193,6 +3197,10 @@
3193
3197
  .about-usage-provider { font-size: 0.82rem; font-weight: 600; margin-top: 8px; }
3194
3198
  .about-usage-provider:first-child { margin-top: 0; }
3195
3199
  .about-state { font-weight: 600; }
3200
+ /* A path (the database file, the backup folder) wraps anywhere rather than
3201
+ pushing the card wider than a phone's screen. */
3202
+ .about-section .plan-usage-detail-value { white-space: normal; overflow-wrap: anywhere; }
3203
+ .about-section .plan-usage-detail-hint { overflow-wrap: anywhere; }
3196
3204
  .about-state[data-state="online"] { color: var(--success); }
3197
3205
  .about-state[data-state="offline"] { color: var(--warn); }
3198
3206
  .about-state[data-state="idle"] { color: var(--muted); }
@@ -4417,6 +4425,12 @@
4417
4425
  min-width: 84px; width: 84px; outline: none;
4418
4426
  }
4419
4427
  #mode-select:focus { border-color: var(--accent); }
4428
+ /* A plan-only session: agents can't take it out of plan mode. */
4429
+ #mode-select[data-plan-only="true"] { border-style: dashed; border-color: var(--accent); }
4430
+ /* Room for "Plan 🔒" beside the arrow; the phone layouts size the select themselves. */
4431
+ @media (min-width: 681px) {
4432
+ #mode-select[data-plan-only="true"] { min-width: 104px; width: 104px; }
4433
+ }
4420
4434
  #send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
4421
4435
  #send-btn:not(:disabled):hover { opacity: 0.85; }
4422
4436
 
@@ -191,6 +191,14 @@ async function handleNotificationClick(event) {
191
191
  if (ok) return;
192
192
  }
193
193
  }
194
+ // A card on a paired relay: it carries no question id, only the
195
+ // link of its session there, and is answered there ("Open there").
196
+ const url = String(data.url || '');
197
+ if (!data.questionId && /^https?:\/\//i.test(url)) {
198
+ event.notification.close();
199
+ await self.clients.openWindow(url);
200
+ return;
201
+ }
194
202
  event.notification.close();
195
203
  await focusOrOpenConversation(String(data.conversationId || ''));
196
204
  }
@@ -141,7 +141,9 @@ export function configuredHeapLimit({ configPath, fsImpl = fs } = {}) {
141
141
  return Number.isInteger(mb) && mb >= MIN_HEAP_LIMIT_MB ? { mb } : { invalid: value };
142
142
  }
143
143
 
144
- function freeBytes(dir, fsImpl) {
144
+ // Free bytes on the disk of `dir`, or null when unknown. Also used by the
145
+ // database backup's space check (db-backup-service.mjs), with diskBytes.
146
+ export function freeBytes(dir, fsImpl) {
145
147
  try {
146
148
  if (typeof fsImpl.statfsSync !== 'function') return null;
147
149
  const stats = fsImpl.statfsSync(dir);
@@ -151,6 +153,17 @@ function freeBytes(dir, fsImpl) {
151
153
  }
152
154
  }
153
155
 
156
+ // The size of the disk of `dir`, or null when unknown.
157
+ export function diskBytes(dir, fsImpl) {
158
+ try {
159
+ if (typeof fsImpl.statfsSync !== 'function') return null;
160
+ const stats = fsImpl.statfsSync(dir);
161
+ return Number(stats.blocks) * Number(stats.bsize);
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
154
167
  /** A path for the log: `~` for the home folder, so a log line names no user. */
155
168
  export function displayPath(target, homeDir = os.homedir()) {
156
169
  const resolved = path.resolve(String(target || ''));