@oar-sh/oar 0.9.19 → 0.9.21

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 (190) 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 +529 -0
  4. package/README.md +14 -14
  5. package/bin/oar.js +392 -98
  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 +258 -1
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
  10. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  11. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  12. package/server/claude-worker/claude-session-process.mjs +10 -0
  13. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  14. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  15. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  16. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  17. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  18. package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
  19. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  20. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  21. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  22. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  23. package/server/db-schema.mjs +18 -0
  24. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  25. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  26. package/server/grok-worker/grok-session-worker.mjs +12 -1
  27. package/server/grok-worker/grok-turn-runner.mjs +47 -6
  28. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  29. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  30. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  31. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  32. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  33. package/server/migrations/0005-remote-relays.mjs +5 -7
  34. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  35. package/server/migrations/0007-claude-cloud.mjs +5 -7
  36. package/server/migrations/0008-message-pins.mjs +5 -7
  37. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  38. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  39. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  40. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  41. package/server/migrations/0013-previews.mjs +5 -7
  42. package/server/migrations/0014-conversation-groups.mjs +5 -7
  43. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  44. package/server/migrations/0016-share-access-log.mjs +5 -7
  45. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  46. package/server/migrations/0018-background-task-rows.mjs +5 -7
  47. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  48. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  49. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  50. package/server/migrations/0022-relay-schedules.mjs +5 -7
  51. package/server/migrations/0023-relay-asks.mjs +5 -7
  52. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  53. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  54. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  55. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  56. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  57. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  58. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  59. package/server/migrations/0031-queue-provider.mjs +5 -7
  60. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  61. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  62. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  63. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  64. package/server/migrations/0036-token-usage.mjs +267 -0
  65. package/server/public/app/about-ui.js +1 -1
  66. package/server/public/app/about-view.mjs +106 -3
  67. package/server/public/app/action-confirmations.js +1 -1
  68. package/server/public/app/api-client.js +6 -3
  69. package/server/public/app/ask-user-view.js +20 -6
  70. package/server/public/app/attachments-view.js +1 -1
  71. package/server/public/app/back-guard.mjs +31 -5
  72. package/server/public/app/bootstrap.js +611 -70
  73. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  74. package/server/public/app/chat-header-meta.mjs +27 -0
  75. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  76. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  77. package/server/public/app/composer-control-state.mjs +12 -2
  78. package/server/public/app/composer-settings-label.mjs +90 -0
  79. package/server/public/app/composer-settings-ui.js +442 -0
  80. package/server/public/app/context-menu.mjs +36 -5
  81. package/server/public/app/context-usage-view.mjs +16 -0
  82. package/server/public/app/conversation-deep-link.mjs +15 -11
  83. package/server/public/app/conversation-group-drag.mjs +12 -2
  84. package/server/public/app/conversation-group-order.mjs +9 -2
  85. package/server/public/app/conversation-group-state.mjs +47 -10
  86. package/server/public/app/conversation-origin-ui.js +17 -1
  87. package/server/public/app/conversation-view.js +20 -2
  88. package/server/public/app/cwd-menu-placement.mjs +18 -6
  89. package/server/public/app/cwd-picker.js +36 -6
  90. package/server/public/app/emoji-view.js +1 -1
  91. package/server/public/app/font-scaling.js +26 -0
  92. package/server/public/app/header-priority-actions.mjs +234 -0
  93. package/server/public/app/hidden-messages-view.mjs +4 -0
  94. package/server/public/app/inbox-ui.js +50 -2
  95. package/server/public/app/inbox-view.mjs +141 -5
  96. package/server/public/app/journal-view.js +311 -58
  97. package/server/public/app/phone-landscape-ui.js +225 -0
  98. package/server/public/app/phone-landscape.mjs +99 -0
  99. package/server/public/app/phone-typing-state.mjs +104 -0
  100. package/server/public/app/pinned-messages-view.mjs +5 -0
  101. package/server/public/app/plan-board-shared.mjs +49 -0
  102. package/server/public/app/plan-usage-view.mjs +1 -1
  103. package/server/public/app/push-settings.js +2 -2
  104. package/server/public/app/pwa-install.js +24 -3
  105. package/server/public/app/relay-board-inline.mjs +4 -1
  106. package/server/public/app/relay-board-view.js +38 -10
  107. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  108. package/server/public/app/relay-event-view.mjs +42 -0
  109. package/server/public/app/relay-restart-ui.js +2 -2
  110. package/server/public/app/relay-schedule-view.mjs +32 -6
  111. package/server/public/app/relay-schedules-ui.js +69 -20
  112. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  113. package/server/public/app/session-history-ui.js +250 -0
  114. package/server/public/app/session-history.mjs +599 -0
  115. package/server/public/app/settings-modal.js +61 -6
  116. package/server/public/app/settings-tabs.js +12 -0
  117. package/server/public/app/share-state.js +1 -1
  118. package/server/public/app/shared-conversations-settings.js +2 -2
  119. package/server/public/app/socket-handlers.js +15 -4
  120. package/server/public/app/status-view.mjs +18 -2
  121. package/server/public/app/store.js +87 -16
  122. package/server/public/app/subagent-viewer.mjs +27 -2
  123. package/server/public/app/tab-strip-fade.mjs +101 -0
  124. package/server/public/app/tap-actions.js +66 -0
  125. package/server/public/app/usage-limit-ui.js +63 -2
  126. package/server/public/app/viewer-history.mjs +13 -2
  127. package/server/public/index.html +2258 -1585
  128. package/server/public/sw.js +8 -0
  129. package/server/relay-heap-guard.mjs +14 -1
  130. package/server/relay-self-restart.mjs +83 -2
  131. package/server/relay-tools.md +5 -3
  132. package/server/repositories/message-repository.mjs +54 -1
  133. package/server/repositories/remote-relay-repository.mjs +10 -0
  134. package/server/repositories/session-repository.mjs +25 -1
  135. package/server/routes/claude-cloud-routes.mjs +19 -0
  136. package/server/routes/messages-routes.mjs +144 -1
  137. package/server/routes/relay-ask-routes.mjs +138 -8
  138. package/server/routes/relay-board-routes.mjs +55 -40
  139. package/server/routes/sessions-routes.mjs +144 -14
  140. package/server/server-runtime.mjs +322 -51
  141. package/server/services/about-service.mjs +81 -53
  142. package/server/services/cache-rebuild-service.mjs +7 -0
  143. package/server/services/claude-billing-swap-service.mjs +7 -20
  144. package/server/services/claude-cloud-session-service.mjs +47 -5
  145. package/server/services/conversation-preferences-service.mjs +31 -0
  146. package/server/services/database-file.mjs +413 -0
  147. package/server/services/db-backup-service.mjs +809 -0
  148. package/server/services/deepseek-usage-import.mjs +6 -9
  149. package/server/services/delete-archive-service.mjs +3 -0
  150. package/server/services/earlier-database-service.mjs +69 -126
  151. package/server/services/oar-cli-helpers.mjs +214 -12
  152. package/server/services/oar-cli-menu.mjs +1 -0
  153. package/server/services/oar-state-migration-service.mjs +33 -13
  154. package/server/services/package-data-rescue.mjs +4 -2
  155. package/server/services/plan-usage-claude-api.mjs +14 -15
  156. package/server/services/plan-usage-contract.mjs +6 -2
  157. package/server/services/plan-usage-deepseek.mjs +1 -1
  158. package/server/services/plan-usage-tokens.mjs +248 -0
  159. package/server/services/push-dispatch-service.mjs +134 -9
  160. package/server/services/relay-cli-launcher-service.mjs +3 -0
  161. package/server/services/relay-event-service.mjs +30 -0
  162. package/server/services/relay-question-context.mjs +11 -0
  163. package/server/services/relay-restart-picture.mjs +67 -22
  164. package/server/services/remote-inbox-watch-service.mjs +204 -0
  165. package/server/services/remote-relay-caller-context.mjs +80 -7
  166. package/server/services/remote-relay-dispatcher.mjs +101 -19
  167. package/server/services/restart-resume-service.mjs +5 -2
  168. package/server/services/session-activity-tracker.mjs +1 -1
  169. package/server/services/session-launch-mutex-service.mjs +23 -0
  170. package/server/services/session-worker-launch-service.mjs +12 -3
  171. package/server/services/session-worker-registry-service.mjs +36 -0
  172. package/server/services/session-worker-stop-service.mjs +27 -6
  173. package/server/services/session-worker-websocket-service.mjs +40 -16
  174. package/server/services/token-usage-service.mjs +430 -0
  175. package/server/services/update-install-service.mjs +13 -0
  176. package/server/services/usage-limit-pause-service.mjs +8 -1
  177. package/server/services/worker-recycle-service.mjs +772 -0
  178. package/server/services/worker-replacement.mjs +197 -0
  179. package/server/services/worker-scope.mjs +77 -0
  180. package/shared/build-id.mjs +128 -0
  181. package/shared/plan-board-actions.mjs +130 -0
  182. package/shared/plan-only.mjs +43 -0
  183. package/shared/relay-asks.mjs +3 -0
  184. package/shared/relay-events.mjs +38 -4
  185. package/shared/remote-relay-contract.mjs +66 -11
  186. package/shared/turn-token-usage.mjs +144 -0
  187. package/shared/worker-runtime/heartbeat.mjs +5 -0
  188. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  189. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  190. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -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();
@@ -173,12 +186,28 @@ export function leaveInboxView() {
173
186
  return true;
174
187
  }
175
188
 
176
- export async function toggleInboxView() {
189
+ /**
190
+ * Nothing to go back to (opened with nothing open, as after an unknown
191
+ * link): the header and the transcript give way to the empty screen.
192
+ * Loaded on demand: the transcript's module is already in the page.
193
+ */
194
+ async function showNothingOpen() {
195
+ const { clearOpenConversation } = await import('./journal-view.js');
196
+ clearOpenConversation({ history: false });
197
+ }
198
+
199
+ /**
200
+ * Opens the Inbox, or leaves it for the conversation it was opened from.
201
+ * Opened from Status there is none stored: `returnTo` (the session the
202
+ * history stands on) is opened instead, so the transcript never keeps the list.
203
+ */
204
+ export async function toggleInboxView({ returnTo = '' } = {}) {
177
205
  if (inboxActive) {
178
206
  leaveInboxView();
179
- const restoreId = previousConversationId;
207
+ const restoreId = previousConversationId || String(returnTo || '').trim();
180
208
  previousConversationId = '';
181
209
  if (restoreId) await window.openConversation?.(restoreId, { restoreScroll: true });
210
+ else await showNothingOpen();
182
211
  return;
183
212
  }
184
213
  if (isStatusViewActive()) leaveStatusView();
@@ -268,6 +297,15 @@ async function answerCard(card, data, { choice = '' } = {}) {
268
297
  return true;
269
298
  }
270
299
 
300
+ // A plan card's button in the Inbox: the card's own action, with
301
+ // the plan-only question first; a card settled meanwhile just shows so.
302
+ async function actOnPlanItem(data) {
303
+ const own = relays.find((relay) => relay?.self === true);
304
+ const item = (Array.isArray(own?.items) ? own.items : []).find((entry) => entry?.kind === 'plan' && entry.id === data.boardId) || null;
305
+ const board = item ? { ...item, conversationId: item.session?.id } : null;
306
+ return !!(await actOnRelayBoard(data.boardId, data.actionId, { board }));
307
+ }
308
+
271
309
  async function handleAction(target) {
272
310
  const data = { ...target.dataset };
273
311
  const card = target.closest('.inbox-item');
@@ -294,6 +332,7 @@ async function handleAction(target) {
294
332
  else if (data.inboxAction === 'dismiss') done = await answerAsk(card, data, { dismiss: true });
295
333
  else if (data.inboxAction === 'card-choice') done = await answerCard(card, data, { choice: data.choice });
296
334
  else if (data.inboxAction === 'card-answer') done = await answerCard(card, data);
335
+ else if (data.inboxAction === 'plan-action') done = await actOnPlanItem(data);
297
336
  } finally {
298
337
  if (!done) for (const node of buttons) node.disabled = false;
299
338
  }
@@ -318,6 +357,13 @@ function bindClicks() {
318
357
  const key = field ? draftKey(field) : '';
319
358
  if (key) drafts.set(key, field.value);
320
359
  });
360
+ // A fold opened or closed by hand. `toggle` does not bubble: caught on the
361
+ // way down. Also covers a session's card that a transcript render dropped
362
+ // before renderSessionAskCards drew it again.
363
+ host?.addEventListener('toggle', (event) => {
364
+ const details = event.target instanceof Element && event.target.matches(INBOX_FOLD_SELECTOR) ? event.target : null;
365
+ if (details && details.isConnected) noteInboxFold(openFolds, details);
366
+ }, true);
321
367
  }
322
368
 
323
369
  // ─── The card in the session ──────────────────────────────────────────────────
@@ -341,6 +387,7 @@ export function renderSessionAskCards() {
341
387
  if (key === sessionCardKey && existing.length === shown.length && [...existing].every((node) => node.isConnected)) return;
342
388
  sessionCardKey = key;
343
389
  const focused = captureDrafts(host);
390
+ existing.forEach((node) => captureInboxFolds(openFolds, node));
344
391
  existing.forEach((node) => node.remove());
345
392
  const firstQuestion = host.querySelector('.relay-question-container');
346
393
  for (const ask of shown) {
@@ -350,6 +397,7 @@ export function renderSessionAskCards() {
350
397
  wrapper.append(buildAskCard(document, { ...ask, session: ask.session || { id } }, { relay: { id: 'self', self: true }, mode: 'session', now: Date.now() }));
351
398
  if (firstQuestion) host.insertBefore(wrapper, firstQuestion);
352
399
  else host.append(wrapper);
400
+ restoreInboxFolds(openFolds, wrapper);
353
401
  }
354
402
  restoreDrafts(host, focused);
355
403
  }
@@ -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