@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
@@ -1,7 +1,8 @@
1
- import { spawn } from 'child_process';
1
+ import { spawn, spawnSync } from 'child_process';
2
2
  import path from 'path';
3
3
  import { RELAY_RESTART_EXIT_CODE, RELAY_STOP_EXIT_CODE } from './relay-exit-codes.mjs';
4
4
  import { relayRuntimeNodeFlags, relayRuntimeNodeFlagsOrNone } from './relay-heap-guard.mjs';
5
+ import { detectRelayHosting } from './services/relay-restart-picture.mjs';
5
6
 
6
7
  // Role selection travels on argv, never in the environment. The runtime's env is
7
8
  // inherited by tmux worker sessions and by the Copilot CLI it launches, and the
@@ -76,6 +77,64 @@ function waitForChildExit(child) {
76
77
  });
77
78
  }
78
79
 
80
+ // A systemctl --user call of the handover: its result, never a throw.
81
+ function runSystemctl(spawnSyncImpl, args) {
82
+ try {
83
+ return spawnSyncImpl('systemctl', ['--user', ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15_000 }) || { status: null };
84
+ } catch (error) {
85
+ return { status: null, error };
86
+ }
87
+ }
88
+
89
+ function systemctlFailure(result) {
90
+ if (result.error) return result.error.message || String(result.error);
91
+ const said = String(result.stderr || '').trim().split('\n')[0];
92
+ return `status ${result.status ?? 'none'}${said ? `: ${said}` : ''}`;
93
+ }
94
+
95
+ /**
96
+ * The runtime asked for a restart (exit 75). Under a user service whose main
97
+ * process this supervisor is, systemd restarts the whole service, so the new
98
+ * supervisor and runtime run the code on disk with fresh Node flags: true
99
+ * once systemd's stop (SIGTERM) has come. False, with one log line, wherever
100
+ * that cannot be done (not the unit's main process: a relay started inside a
101
+ * worker of the service), systemctl fails, or no stop comes within
102
+ * `handoverWaitMs`; the caller relaunches the runtime itself then.
103
+ */
104
+ async function handRestartToSystemd({ hosting, spawnSyncImpl, pid, handoverWaitMs, shutdownRequested, isShuttingDown, delay, logger }) {
105
+ if (!hosting?.systemd || !hosting.userManager || !hosting.unit) return false;
106
+ const { unit } = hosting;
107
+ const main = runSystemctl(spawnSyncImpl, ['show', '-p', 'MainPID', '--value', unit]);
108
+ const mainPid = Number(String(main.stdout ?? '').trim());
109
+ if (main.status !== 0 || mainPid !== pid) {
110
+ const whose = main.status === 0 ? `its main process is ${mainPid || 'none'}` : `its main process could not be read (${systemctlFailure(main)})`;
111
+ logger?.log?.(`[relay] restart: not handed to systemd, ${unit} is not this supervisor's (pid ${pid}; ${whose}).`);
112
+ return false;
113
+ }
114
+ // Clears a failed state and the restart backoff; restart's answer alone decides.
115
+ runSystemctl(spawnSyncImpl, ['reset-failed', unit]);
116
+ // A stop that came in meanwhile wins: a queued restart could replace its job.
117
+ // The calls above block; a signal that arrived during them waits on the
118
+ // signal pipe, which only the loop's poll phase reads. This runs from the
119
+ // runtime's 'exit' callback, itself in a poll phase, so one setImmediate
120
+ // would run in the check phase right after it, before the pipe is read;
121
+ // the second runs after the next poll phase has delivered the signal.
122
+ await new Promise((resolve) => setImmediate(() => setImmediate(resolve)));
123
+ if (isShuttingDown()) {
124
+ logger?.log?.(`[relay] restart: a stop came in; not restarting ${unit}.`);
125
+ return true;
126
+ }
127
+ const restart = runSystemctl(spawnSyncImpl, ['restart', '--no-block', unit]);
128
+ if (restart.status !== 0) {
129
+ logger?.log?.(`[relay] restart: systemctl --user restart --no-block ${unit} failed (${systemctlFailure(restart)}).`);
130
+ return false;
131
+ }
132
+ logger?.log?.(`[relay] restart handed to systemd: ${unit} restarts with the code on disk.`);
133
+ const stopped = await Promise.race([shutdownRequested.then(() => true), Promise.resolve(delay(handoverWaitMs)).then(isShuttingDown)]);
134
+ if (!stopped) logger?.log?.(`[relay] restart: systemd did not stop ${unit} within ${Math.round(handoverWaitMs / 1000)} s.`);
135
+ return stopped;
136
+ }
137
+
79
138
  export async function runDirectRelaySupervisor({
80
139
  scriptPath,
81
140
  args = [],
@@ -96,16 +155,26 @@ export async function runDirectRelaySupervisor({
96
155
  // crashes days apart are not a crash loop.
97
156
  crashCountResetMs = 10 * 60 * 1000,
98
157
  now = Date.now,
158
+ // How this process is run (detectRelayHosting); read at the first restart
159
+ // request when not given.
160
+ hosting = null,
161
+ spawnSyncImpl = spawnSync,
162
+ pid = process.pid,
163
+ // How long a restart handed to systemd waits for its SIGTERM.
164
+ handoverWaitMs = 30_000,
99
165
  } = {}) {
100
166
  let runtimeProc = null;
101
167
  let shuttingDown = false;
102
168
  let shutdownExitCode = 0;
169
+ let shutdownArrived;
170
+ const shutdownRequested = new Promise((resolve) => { shutdownArrived = resolve; });
103
171
 
104
172
  const requestShutdown = (signal = 'SIGTERM', exitCode = 0) => {
105
173
  if (shuttingDown) return;
106
174
  shuttingDown = true;
107
175
  shutdownExitCode = exitCode;
108
176
  stopChildProcess(runtimeProc, { signal });
177
+ shutdownArrived();
109
178
  };
110
179
 
111
180
  const handleSigInt = () => requestShutdown('SIGINT', 0);
@@ -165,8 +234,20 @@ export async function runDirectRelaySupervisor({
165
234
  : null;
166
235
  const signal = exitCode === null ? (result.signal || null) : null;
167
236
  if (exitCode === RELAY_RESTART_EXIT_CODE) {
168
- logger?.log?.(`[relay] runtime requested restart; relaunching ${path.basename(String(scriptPath || ''))}...`);
169
237
  crashCount = 0;
238
+ const handedOver = await handRestartToSystemd({
239
+ hosting: hosting || detectRelayHosting({ env }),
240
+ spawnSyncImpl,
241
+ pid,
242
+ handoverWaitMs,
243
+ shutdownRequested,
244
+ isShuttingDown: () => shuttingDown,
245
+ delay,
246
+ logger,
247
+ });
248
+ // systemd's stop: this supervisor ends, systemd starts the next one.
249
+ if (handedOver || shuttingDown) return await exitImpl(shutdownExitCode);
250
+ logger?.log?.(`[relay] runtime requested restart; relaunching ${path.basename(String(scriptPath || ''))}...`);
170
251
  await delay(restartDelayMs);
171
252
  continue;
172
253
  }
@@ -39,10 +39,11 @@ A paired relay stays locked until the user mentions it in this conversation (`@n
39
39
 
40
40
  This relay itself is listed with "self": true when its owner switched agent sessions on, and is addressed by its name or as "this"; it needs no mention. Use it to hand parts of your task to other sessions:
41
41
 
42
- - `create_session` starts a session with any provider that `relay_info` lists: a local one in a folder ("cwd"), or provider "claude-cloud" with "repo" (a GitHub URL or owner/repo) and an optional "branch". The provider ids are the target relay's, as `relay_info` shows them; an id it does not know is refused with the list it has. A session started before a relay update can use providers the update added, without a restart. At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
42
+ - `create_session` starts a session with any provider that `relay_info` lists: a local one in a folder ("cwd"), or provider "claude-cloud" with "repo" (a GitHub URL or owner/repo) and an optional "branch". The provider ids are the target relay's, as `relay_info` shows them; an id it does not know is refused with the list it has. A session started before a relay update can use providers the update added, without a restart. Without "mode" the new session gets your session's relay mode. At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
43
43
  - The first `create_session` on this relay shows the user an approval card, once per conversation. A session that an agent created cannot create sessions itself (one level only), unless the owner allowed it for that session: `list_sessions` and `read_session` then show "mayCreateSessions": true. Only the owner sets that.
44
44
  - Sessions you start here are grouped under this session in the user's sidebar. `list_sessions` lists in the sidebar's order (a group sorts by its newest session, head first) and shows the group as a "group" field (id, name, role); `read_session` adds the group's live members with their state. `create_session` takes "group" (a group id, or its name in any case, from `relay_info`'s "groups") to put the new session into an existing group instead, here or on a paired relay; an unknown group is refused with the groups listed and nothing is created, and the result carries the "group". Renaming and dissolving groups is the user's.
45
- - `update_session` ("session" plus any of "model", "effort", "mode", "auto_compact_window", "title", "group", "thinking_enabled", "thinking_display", "cwd") changes another session's settings for its next turns without prompting it, here or on a paired relay, and answers the settings and "changed"; a group name that does not exist makes one, "none" takes the session out (a group the user made stays, even empty; one update_session made goes once empty), a new "cwd" restarts its CLI there (refused while it works), "auto_compact_window" 0 is the model default. The model is checked as for `send`. A failed call still lists in "changed" what it wrote before the failing step. A mode set this way lasts until the next `send` that names another mode or comes from a caller in another mode: a `send` without "mode" passes the caller's own mode.
45
+ - `update_session` ("session" plus any of "model", "effort", "mode", "auto_compact_window", "title", "group", "thinking_enabled", "thinking_display", "cwd", "planOnly") changes another session's settings for its next turns without prompting it, here or on a paired relay, and answers the settings and "changed"; a group name that does not exist makes one, "none" takes the session out (a group the user made stays, even empty; one update_session made goes once empty), a new "cwd" restarts its CLI there (refused while it works), "auto_compact_window" 0 is the model default. The model is checked as for `send`. A failed call still lists in "changed" what it wrote before the failing step. A mode set this way lasts until the next `update_session` that names another mode; a `send` that names a mode runs only that turn in it, and one without "mode" leaves the session in its own mode.
46
+ - "planOnly": true makes a plan-only session: agents can't take it out of plan mode. Set it only when the user asks; plan mode alone never sets it. `create_session` with "planOnly": true and no "mode" starts in plan; with another mode named it is refused and nothing is created. A target relay too old for planOnly creates the session without the lock and says so in "planOnlyWarning". `update_session` with "planOnly": true sets it on an existing session (with "mode": "plan" unless it is in plan already). While it is set, a `send` or `update_session` that names another mode is refused with 409, code "plan_only" ("This session is plan-only; agents can't change its mode. Clear planOnly first, or ask the user."); a `send` without "mode" runs in the session's saved mode. To clear it, pass "planOnly": false in the same `update_session` that names the new mode (`{"planOnly":false,"mode":"agent"}`); alone, or with "mode": "plan", it is ignored, and on a plan-only session the answer notes "planOnly unchanged: clear it in the same call as the new mode." Use `update_session` for all this, not the preferences route (it refuses an agent's mode change with 409 and any planOnly change with 403). `list_sessions` and `read_session` show "planOnly": true. In a plan-only conversation you count as a plan-mode caller, also in the one agent turn after "Approve plan": your `create_session` defaults to plan and your write actions ask the user first.
46
47
  - Only a session your conversation started may be updated freely; any other (the user's, another agent's, the one that started you) needs the user's approval card once per conversation and relay (refused: `REMOTE_RELAY_PERMISSION`); never your own conversation.
47
48
  - A "model" for `create_session` or `send` must be an id `relay_info` lists for the provider (Claude also takes "opus", "sonnet", "haiku"). An unknown id is refused with the close matches and the valid ids (`Unknown model 'gpt-5-4' for Copilot on relay "…"; did you mean 'gpt-5.4'? Valid ids: … Nothing was sent.`), and nothing is created or sent: pick from the list instead of guessing. Copilot ids use dots (`claude-opus-5.5`, not the Claude form `claude-opus-5-5`); a hyphen form is refused with the close match named, and a Copilot session never moves to another provider because of its model name. If the answer has a "modelNote" saying the model could not be checked, the relay had no model list yet.
48
49
  - A new session knows nothing of this conversation: write a self-contained prompt. A Claude Cloud session works on a fresh clone in a sandbox at Anthropic, so name the branch it should work on and push.
@@ -51,7 +52,7 @@ This relay itself is listed with "self": true when its owner switched agent sess
51
52
  - Pass only the arguments an action takes. `create_session` and `update_session` refuse any other one, list their valid arguments, and change nothing; every other action still runs and names the arguments it did not take in "ignored".
52
53
  - `read_task_output` ("session", "task_id" from `read_session`'s tasks, optional "lines") reads the end of a background shell or Monitor task's output, also for an hour after it ended: the last lines, minutes since its last output, and pass/fail counts for a test run. Credentials are scrubbed out.
53
54
 
54
- On this relay and on a paired one, `subscribe` ("sessions": ids, or ["all"]) wakes you when something happens in the sessions you watch, instead of polling them. Leave "events" out unless you need more: the default (question_asked, error, turn_end, subagent_finished, task_failed, stalled, all_idle) wakes you for questions, errors, replies, finished sub-agents, failed tasks (a non-zero exit included), stalls and all_idle, but not for each shell command that ended well. To get those too, pass that list plus "task_finished" (and "task_started" for the starts); each one costs you a turn, so do it only if you must follow every command. "relay_restarted" (this relay only, never in the default) wakes you when the relay runs again after a restart, with who requested it and why; named alone it needs no sessions.
55
+ On this relay and on a paired one, `subscribe` ("sessions": ids, or ["all"]) wakes you when something happens in the sessions you watch, instead of polling them. Leave "events" out unless you need more: the default (question_asked, error, turn_end, subagent_finished, task_failed, stalled, all_idle, board_settled) wakes you for questions, errors, replies, finished sub-agents, failed tasks (a non-zero exit included), stalls, all_idle and a plan card the user settled (board_settled: the button chosen and the message it queued, if any), but not for each shell command that ended well. To get those too, pass that list plus "task_finished" (and "task_started" for the starts); each one costs you a turn, so do it only if you must follow every command. "relay_restarted" (this relay only, never in the default) wakes you when the relay runs again after a restart, with who requested it and why; named alone it needs no sessions.
55
56
 
56
57
  Each wake-up costs you a turn; these `subscribe` options cut them (questions and errors always come at once):
57
58
  - "digest_minutes": N gathers the subscription's other events into one message every N minutes (1 to 1440).
@@ -5,6 +5,33 @@ import { createMessagePinStatements } from './message-pin-statements.mjs';
5
5
  import { createConversationHistoryPageStatements } from '../services/conversation-history-page.mjs';
6
6
  import { sessionWorkerProviderSqlList } from '../../shared/provider-routing.mjs';
7
7
 
8
+ /**
9
+ * The claim's plan-boundary hold, part of every findPending*
10
+ * statement: a pending row waits while its conversation has a live turn on
11
+ * the other side of plan mode, because handing it to the worker would steer
12
+ * it into that turn and switch what the turn may do. The conversation's later
13
+ * rows wait behind it whatever their mode, so one conversation keeps its
14
+ * order, while the claim goes on to other conversations' rows.
15
+ * `PLAN_BOUNDARY_HOLD_MARK` lets a test find any claim without it.
16
+ */
17
+ export const PLAN_BOUNDARY_HOLD_MARK = '/* plan-boundary-hold */';
18
+
19
+ export function planBoundaryHoldSql(alias) {
20
+ return `${PLAN_BOUNDARY_HOLD_MARK} NOT EXISTS (
21
+ SELECT 1
22
+ FROM queue held
23
+ JOIN queue live
24
+ ON live.conversation_id = held.conversation_id
25
+ AND live.status = 'processing'
26
+ AND live.id != held.id
27
+ WHERE held.conversation_id = ${alias}.conversation_id
28
+ AND held.status = 'pending'
29
+ AND COALESCE(held.kind, '') != 'continuation'
30
+ AND (held.timestamp < ${alias}.timestamp OR (held.timestamp = ${alias}.timestamp AND held.rowid <= ${alias}.rowid))
31
+ AND (LOWER(TRIM(COALESCE(live.relay_mode, ''))) = 'plan') != (LOWER(TRIM(COALESCE(held.relay_mode, ''))) = 'plan')
32
+ )`;
33
+ }
34
+
8
35
  export function createMessageRepository(db) {
9
36
  const shareVisibility = createShareVisibilityStatements(db);
10
37
  const messagePins = createMessagePinStatements(db);
@@ -187,7 +214,7 @@ export function createMessageRepository(db) {
187
214
  // steer-recovered row waited 5 minutes while newer messages jumped
188
215
  // it). Head-of-line protection is the WHERE clause's next_attempt_at
189
216
  // backoff plus MAX_REQUEUE_RETRIES, which fails a row for good.
190
- findPending: db.prepare(`SELECT * FROM queue WHERE status = 'pending' AND COALESCE(kind, '') != 'continuation' AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY timestamp ASC, rowid ASC LIMIT 1`),
217
+ findPending: db.prepare(`SELECT q.* FROM queue q WHERE q.status = 'pending' AND COALESCE(q.kind, '') != 'continuation' AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?) AND ${planBoundaryHoldSql('q')} ORDER BY q.timestamp ASC, q.rowid ASC LIMIT 1`),
191
218
  // Global fallback for requesters without a session identity (the legacy
192
219
  // Copilot relay CLI). Conversations bound to a session-worker provider
193
220
  // (claude/cursor/grok) are excluded outright: handing one of their turns
@@ -218,6 +245,7 @@ export function createMessageRepository(db) {
218
245
  NULLIF(rsc.provider_type, ''),
219
246
  'github'
220
247
  )) NOT IN (${sessionWorkerProviderSqlList()})
248
+ AND ${planBoundaryHoldSql('q')}
221
249
  ORDER BY
222
250
  q.timestamp ASC,
223
251
  q.rowid ASC
@@ -254,6 +282,7 @@ export function createMessageRepository(db) {
254
282
  NULLIF(c.sdk_session_id, '')
255
283
  ) = ?
256
284
  )
285
+ AND ${planBoundaryHoldSql('q')}
257
286
  ORDER BY
258
287
  CASE
259
288
  WHEN COALESCE(
@@ -282,6 +311,7 @@ export function createMessageRepository(db) {
282
311
  NULLIF(rs.sdk_session_id, ''),
283
312
  NULLIF(c.sdk_session_id, '')
284
313
  ) = ?
314
+ AND ${planBoundaryHoldSql('q')}
285
315
  ORDER BY
286
316
  q.timestamp ASC,
287
317
  q.rowid ASC
@@ -75,6 +75,12 @@ export function createRemoteRelayRepository(db) {
75
75
  const getMayCreateSessions = hasMayCreateColumn
76
76
  ? db.prepare(`SELECT may_create_sessions FROM conversations WHERE id = ?`)
77
77
  : null;
78
+ // Plan-only (migration 0035). Without the column no session is.
79
+ const hasPlanOnlyColumn = db.prepare(`PRAGMA table_info(conversations)`).all()
80
+ .some((column) => column.name === 'plan_only');
81
+ const getPlanOnly = hasPlanOnlyColumn
82
+ ? db.prepare(`SELECT plan_only FROM conversations WHERE id = ?`)
83
+ : null;
78
84
 
79
85
  return {
80
86
  hasUnlock(conversationId, remoteRelayId) {
@@ -161,6 +167,10 @@ export function createRemoteRelayRepository(db) {
161
167
  mayCreateSessions(conversationId) {
162
168
  return Number(getMayCreateSessions?.get(String(conversationId || ''))?.may_create_sessions) === 1;
163
169
  },
170
+ /** Is this conversation plan-only (agents cannot take it out of plan mode)? */
171
+ planOnly(conversationId) {
172
+ return Number(getPlanOnly?.get(String(conversationId || ''))?.plan_only) === 1;
173
+ },
164
174
  };
165
175
  }
166
176
 
@@ -45,6 +45,10 @@ export function createSessionRepository(db) {
45
45
  const conversationsHaveMayCreateSessions = db.prepare(`PRAGMA table_info(conversations)`).all()
46
46
  .some((column) => String(column?.name || '').trim() === 'may_create_sessions');
47
47
  const conversationMayCreateSelect = conversationsHaveMayCreateSessions ? 'c.may_create_sessions' : 'NULL AS may_create_sessions';
48
+ // Plan-only (migration 0035), the same way.
49
+ const conversationsHavePlanOnly = db.prepare(`PRAGMA table_info(conversations)`).all()
50
+ .some((column) => String(column?.name || '').trim() === 'plan_only');
51
+ const conversationPlanOnlySelect = conversationsHavePlanOnly ? 'c.plan_only' : 'NULL AS plan_only';
48
52
  return {
49
53
  // conversations
50
54
  getConv: db.prepare(`SELECT * FROM conversations WHERE id = ? AND status != 'deleted'`),
@@ -53,7 +57,8 @@ export function createSessionRepository(db) {
53
57
  listConvIdsMissingRuntimeSession: db.prepare(`SELECT c.id AS id FROM conversations c LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE rs.id IS NULL AND c.status != 'deleted'`),
54
58
  runtimeSessionsSupportProviders,
55
59
  conversationsHaveMayCreateSessions,
56
- listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, ${conversationCloudSelect}, ${conversationGroupSelect}, ${conversationMayCreateSelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = ?) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
60
+ conversationsHavePlanOnly,
61
+ listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, ${conversationCloudSelect}, ${conversationGroupSelect}, ${conversationMayCreateSelect}, ${conversationPlanOnlySelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = ?) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
57
62
  insertConv: db.prepare(`INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`),
58
63
  updateConvTime: db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`),
59
64
  updateConvTitle: db.prepare(`UPDATE conversations SET title = ?, title_source = 'manual' WHERE id = ?`),
@@ -15,6 +15,7 @@ import {
15
15
  } from '../services/relay-queue-gate-service.mjs';
16
16
  import {
17
17
  persistConversationModelPreference as persistConversationModelPreferenceTx,
18
+ readPlanOnly,
18
19
  } from '../services/conversation-preferences-service.mjs';
19
20
  import { fallbackReasoningEffort, inheritedConversationModel } from '../services/conversation-model-fallback.mjs';
20
21
  import { killTmuxSession } from '../services/session-worker-launch-service.mjs';
@@ -86,8 +87,11 @@ import {
86
87
  import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
87
88
  import { recordClaudeCliStart } from '../services/claude-session-leftovers.mjs';
88
89
  import { normalizeClaudeBilling } from '../services/claude-billing-switch-service.mjs';
90
+ import { normalizeTurnTokenUsageReport } from '../../shared/turn-token-usage.mjs';
89
91
  import { createClaudeApiStopNoteLedger } from '../services/plan-usage-claude-api.mjs';
90
92
  import { normalizeClaudeApiUsageReport } from '../services/claude-api-usage-service.mjs';
93
+ import { planBoundaryHoldSql } from '../repositories/message-repository.mjs';
94
+ import { PLAN_ONLY_REFUSAL, planOnlyRefusesMode } from '../../shared/plan-only.mjs';
91
95
  import {
92
96
  admitRemoteRelayRequest,
93
97
  readRemoteRelayRequest,
@@ -921,6 +925,23 @@ export function resolveResponderConversationMismatch({
921
925
  };
922
926
  }
923
927
 
928
+ /**
929
+ * Whether a pending row must wait for its conversation's live turn to end
930
+ * instead of being handed to the worker, which would steer it into that turn.
931
+ * A steer switches the live turn's mode (the Claude worker re-applies the
932
+ * permission mode on every delivery), so across plan mode it would change
933
+ * what the running turn may do: an "Approve plan" follow-up would give a
934
+ * running plan turn write access, and a plan message would take it from a
935
+ * running write turn. Rows on the same side of plan mode steer as before.
936
+ */
937
+ export function crossesPlanBoundaryOfLiveTurn({ stmts, row } = {}) {
938
+ const conversationId = String(row?.conversation_id || '').trim();
939
+ if (!conversationId || typeof stmts?.listProcessingQueueByConversation?.all !== 'function') return false;
940
+ const isPlan = (mode) => String(mode || '').trim().toLowerCase() === 'plan';
941
+ const pendingIsPlan = isPlan(row.relay_mode);
942
+ return stmts.listProcessingQueueByConversation.all(conversationId)
943
+ .some((live) => String(live?.id || '') !== String(row.id || '') && isPlan(live?.relay_mode) !== pendingIsPlan);
944
+ }
924
945
  export function dequeuePendingMessage({
925
946
  db,
926
947
  stmts,
@@ -970,6 +991,10 @@ export function dequeuePendingMessage({
970
991
  : stmts.findPending.get(currentIso);
971
992
  }
972
993
  if (!next) return null;
994
+ // Across plan mode, a row waits for the live turn instead of steering in.
995
+ // The claim statements leave such rows out (planBoundaryHoldSql); this is
996
+ // the fail-closed backstop for one that does not.
997
+ if (crossesPlanBoundaryOfLiveTurn({ stmts, row: next })) return null;
973
998
  // One processing attempt = one attempt_id. Minted here for every claim —
974
999
  // even for callers that never echo it back — so finalization, requeue, and
975
1000
  // question writes can be fenced to the attempt that actually owns the row.
@@ -2049,6 +2074,8 @@ export function registerMessagesRoutes(app, deps) {
2049
2074
  claudeBillingSwitchService = null,
2050
2075
  // The Claude API booking (claude-api-usage-service.mjs).
2051
2076
  claudeApiUsageService = null,
2077
+ // Tokens per turn for every provider (token-usage-service.mjs).
2078
+ tokenUsageService = null,
2052
2079
  // The delivery fence's runtime half for GET /api/pending, as on the
2053
2080
  // socket: the Claude API claim check (null: no refusal) and what a fenced
2054
2081
  // row does (the swap, or fail closed).
@@ -2640,6 +2667,7 @@ export function registerMessagesRoutes(app, deps) {
2640
2667
  AND rs.sdk_session_id = ?
2641
2668
  )
2642
2669
  AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?)
2670
+ AND ${planBoundaryHoldSql('q')}
2643
2671
  ORDER BY q.timestamp ASC, q.rowid ASC
2644
2672
  LIMIT 1
2645
2673
  `);
@@ -4804,6 +4832,12 @@ export function registerMessagesRoutes(app, deps) {
4804
4832
  ? normalizeRelayMode(existingConversation.preferred_relay_mode)
4805
4833
  : null;
4806
4834
  const requestedRelayMode = savedRelayMode || normalizeRelayMode(relayMode || mode);
4835
+ // Plan-only: an agent's send that names another mode is refused
4836
+ // and nothing is queued (nor a billing switched). Without a mode it runs
4837
+ // in the session's own. The owner's own sends are not limited.
4838
+ if (remoteRequest.remote && readPlanOnly(existingConversation) && planOnlyRefusesMode(relayMode || mode)) {
4839
+ return res.status(409).json({ ...PLAN_ONLY_REFUSAL, conversationId });
4840
+ }
4807
4841
  const trimmedText = stripRelayPromptContext(text, requestedRelayMode || relayMode || mode);
4808
4842
  const normalizedAttachments = normalizeAttachments(rawAttachments);
4809
4843
  const requestedAttachmentCount = Math.min(
@@ -6611,6 +6645,59 @@ export function registerMessagesRoutes(app, deps) {
6611
6645
  res.json({ ok: true, billing: booked.billing, basis: booked.basis, models: booked.booked.map((entry) => entry.model) });
6612
6646
  });
6613
6647
 
6648
+ // POST /api/turn-token-usage — every worker reports, after a turn, the
6649
+ // turn's tokens per model (`models`), or a Claude cloud worker the
6650
+ // session's running totals (`sessionTotals`), keyed by turn, run and model
6651
+ // (shared/turn-token-usage.mjs). Advisory and idempotent: a retried or
6652
+ // reordered report books only what it adds (token-usage-service.mjs).
6653
+ // Who ran the turn is the relay's to say: the chat's provider, and for a
6654
+ // Claude chat the posting worker's registered kind (the billing can switch
6655
+ // between turns). A body that says otherwise is logged. A deleted chat
6656
+ // takes no report, so a late retry cannot book its turn a second time; an
6657
+ // archived one does.
6658
+ app.post('/api/turn-token-usage', auth, (req, res) => {
6659
+ touchCli();
6660
+ if (!tokenUsageService) return res.status(501).json({ error: 'Token usage storage is unavailable' });
6661
+ const parsed = normalizeTurnTokenUsageReport(req.body);
6662
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
6663
+ const { report } = parsed;
6664
+ const conversationId = report.conversationId;
6665
+ if (!stmts.getConv?.get?.(conversationId)) return res.status(404).json({ error: 'Conversation not found' });
6666
+ // The binding's provider wins over the report's. A chat whose binding is
6667
+ // gone for a moment (a cache rebuild) still books, as the provider the
6668
+ // report states.
6669
+ const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
6670
+ const chatProvider = String(runtimeSession?.provider_type || report.provider || '').trim().toLowerCase();
6671
+ let provider = chatProvider;
6672
+ let billing = '';
6673
+ if (chatProvider === 'openai') {
6674
+ // A chat on the user's own OpenAI key runs on the Copilot SDK.
6675
+ provider = 'github';
6676
+ billing = 'byok-openai';
6677
+ } else if (chatProvider === 'github') {
6678
+ billing = report.billing;
6679
+ } else if (normalizeClaudeBilling(chatProvider)) {
6680
+ const requesterSid = normalizeSessionWorkerId(readBridgeIdentity(req)?.sessionId);
6681
+ const registeredKind = normalizeClaudeBilling(requesterSid ? sessionWorkerRegistry?.getWorker?.(requesterSid)?.kind : '');
6682
+ // The worker that ran the turn, then the chat's billing; the report's
6683
+ // own word only when the chat has no binding.
6684
+ provider = registeredKind || normalizeClaudeBilling(chatProvider);
6685
+ } else if (!['claude-cloud', 'deepseek', 'cursor', 'grok'].includes(chatProvider)) {
6686
+ return res.status(409).json({ error: 'Conversation keeps no token usage' });
6687
+ }
6688
+ if (report.provider && report.provider !== provider) {
6689
+ console.warn(`[${ts()}] token usage conv=${conversationId.slice(0, 8)} stated=${report.provider} booked=${provider}`);
6690
+ }
6691
+ if (report.sessionTotals && provider !== 'claude-cloud') {
6692
+ return res.status(400).json({ error: 'sessionTotals is for Claude cloud only' });
6693
+ }
6694
+ const booked = report.sessionTotals
6695
+ ? tokenUsageService.recordCloudSessionTotals({ conversationId, provider, billing, report })
6696
+ : tokenUsageService.recordTurn({ conversationId, provider, billing, report });
6697
+ if (!booked) return res.status(501).json({ error: 'Token usage storage is unavailable' });
6698
+ res.json({ ok: true, provider, billing, models: booked.map((entry) => entry.model) });
6699
+ });
6700
+
6614
6701
  // POST /api/claude-plan-usage — Claude worker reports the session's
6615
6702
  // structured /usage data (plan rate-limit windows + session cost totals).
6616
6703
  //
@@ -27,12 +27,15 @@
27
27
  // relay from its Inbox: asks only;
28
28
  // an approval or any other card is
29
29
  // never answered over the pairing
30
- // GET /api/relay-asks/remote/inbox what its Inbox shows of this relay
30
+ // GET /api/relay-asks/remote/inbox what its Inbox shows of this relay;
31
+ // `?push=1`: it can push, and each
32
+ // card a turn waits on says who does
33
+ // (`notice`)
31
34
  //
32
35
  // Every text of an ask or card is an agent's: it goes out as data, and the
33
36
  // browser shows it as plain text.
34
37
 
35
- import { REMOTE_RELAY_ERROR_CODES } from '../../shared/remote-relay-contract.mjs';
38
+ import { REMOTE_INBOX_CLAIMS, REMOTE_INBOX_NOTICES, REMOTE_RELAY_ERROR_CODES } from '../../shared/remote-relay-contract.mjs';
36
39
  import { RELAY_ASK_LIMITS, relayQuestionKind } from '../../shared/relay-asks.mjs';
37
40
  import { schemaFields } from '../../shared/question-schema.mjs';
38
41
  import { findOriginRemoteRelay, admitRemoteRelayRequest, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
@@ -53,6 +56,8 @@ const RELAY_ASK_ERROR_STATUS = Object.freeze({
53
56
  const INBOX_REMOTE_TIMEOUT_MS = 5_000;
54
57
  // Cards answered in the last day are listed under the divider, at most these.
55
58
  const INBOX_ANSWERED_CARDS_MAX = 50;
59
+ // Plan cards, pending (one per session) and settled in the last day, at most these each.
60
+ const INBOX_PLAN_CARDS_MAX = 50;
56
61
  // Registry states that mean "do not even try" for the Inbox.
57
62
  const UNREACHABLE_STATES = new Set(['offline', 'unauthorized']);
58
63
 
@@ -90,7 +95,15 @@ export function inboxItemOfCard(question, session, { forRemote = false } = {}) {
90
95
  createdAt: question?.createdAt || null,
91
96
  ...(question?.status === 'answered' ? { answeredAt: question?.answeredAt || null } : {}),
92
97
  };
93
- if (forRemote && kind === 'approval') return { ...base, question: '' };
98
+ // Its kind and summary say what it is about, never what it would send.
99
+ if (forRemote && kind === 'approval') {
100
+ return {
101
+ ...base,
102
+ question: '',
103
+ ...(toText(context.variant) ? { variant: toText(context.variant) } : {}),
104
+ ...(toText(context.summary) ? { summary: toText(context.summary) } : {}),
105
+ };
106
+ }
94
107
  const fields = schemaFields(question?.requestSchema);
95
108
  return {
96
109
  ...base,
@@ -107,6 +120,37 @@ export function inboxItemOfCard(question, session, { forRemote = false } = {}) {
107
120
  };
108
121
  }
109
122
 
123
+ /**
124
+ * One Inbox item of a plan card (a formatted relay board). A paired
125
+ * relay's Inbox gets its title and status alone, "Open there": a card is
126
+ * never answered over the pairing. `question` repeats the title for one
127
+ * that does not know plan items (0.9.19 shows it as a card's question).
128
+ */
129
+ export function inboxItemOfPlanBoard(board, session, { forRemote = false } = {}) {
130
+ const title = toText(board?.title) || 'Plan ready for review';
131
+ const status = toText(board?.status) || 'pending';
132
+ const base = {
133
+ id: toText(board?.id),
134
+ kind: 'plan',
135
+ session,
136
+ title,
137
+ status,
138
+ createdAt: board?.createdAt || null,
139
+ };
140
+ if (forRemote) return { ...base, question: title };
141
+ return {
142
+ ...base,
143
+ ...(status !== 'pending' ? { actedAt: board?.actedAt || null } : {}),
144
+ body: String(board?.body || ''),
145
+ actions: (Array.isArray(board?.actions) ? board.actions : [])
146
+ .map((action) => ({ id: toText(action?.id), label: toText(action?.label) || toText(action?.id) }))
147
+ .filter((action) => action.id),
148
+ recommendedAction: toText(board?.recommendedAction) || null,
149
+ planOnly: board?.planOnly === true,
150
+ selectedAction: toText(board?.selectedAction) || null,
151
+ };
152
+ }
153
+
110
154
  /** One Inbox item of an ask (relay-ask-service.mjs shape). */
111
155
  export function inboxItemOfAsk(ask) {
112
156
  const {
@@ -139,6 +183,8 @@ export function registerRelayAskRoutes(app, deps) {
139
183
  db,
140
184
  stmts,
141
185
  formatQuestionRow,
186
+ // Plan cards (relay boards) in the Inbox, formatted as their routes do.
187
+ formatRelayBoardRow = null,
142
188
  relayAskService = null,
143
189
  relayAskLinkService = null,
144
190
  remoteRelayInbound = null,
@@ -146,6 +192,9 @@ export function registerRelayAskRoutes(app, deps) {
146
192
  // calls them (remote-relay-client.mjs): the Inbox of paired relays.
147
193
  remoteRelayRegistry = null,
148
194
  remoteRelayClient = null,
195
+ // push-dispatch-service.mjs questionNotice: whether this relay's own push
196
+ // of a card reached a device.
197
+ questionNotice = null,
149
198
  now = () => Date.now(),
150
199
  } = deps;
151
200
 
@@ -154,6 +203,26 @@ export function registerRelayAskRoutes(app, deps) {
154
203
  SELECT * FROM relay_questions WHERE status = 'answered' AND answered_at >= ? ORDER BY answered_at DESC LIMIT ?
155
204
  `) || null;
156
205
 
206
+ // Plan cards: of each session that is neither deleted nor
207
+ // archived the newest pending one (an older one stays in its session),
208
+ // and the ones settled in the last day.
209
+ const planBoardSessionFilter = `c.status != 'deleted' AND COALESCE(c.archived, 0) = 0`;
210
+ const pendingPlanBoardsStmt = db?.prepare?.(`
211
+ SELECT b.* FROM relay_boards b JOIN conversations c ON c.id = b.conversation_id
212
+ WHERE b.status = 'pending' AND b.board_type = 'plan_ready' AND ${planBoardSessionFilter}
213
+ AND NOT EXISTS (
214
+ SELECT 1 FROM relay_boards newer
215
+ WHERE newer.conversation_id = b.conversation_id AND newer.status = 'pending' AND newer.board_type = 'plan_ready'
216
+ AND (newer.created_at > b.created_at OR (newer.created_at = b.created_at AND newer.rowid > b.rowid))
217
+ )
218
+ ORDER BY b.created_at DESC LIMIT ?
219
+ `) || null;
220
+ const settledPlanBoardsStmt = db?.prepare?.(`
221
+ SELECT b.* FROM relay_boards b JOIN conversations c ON c.id = b.conversation_id
222
+ WHERE b.status IN ('acted', 'dismissed') AND b.board_type = 'plan_ready' AND b.acted_at >= ? AND ${planBoardSessionFilter}
223
+ ORDER BY b.acted_at DESC LIMIT ?
224
+ `) || null;
225
+
157
226
  function sessionOf(id) {
158
227
  let row = null;
159
228
  try {
@@ -204,13 +273,20 @@ export function registerRelayAskRoutes(app, deps) {
204
273
  return { relayId, name: toText(paired?.name) || claimed || toText(request.origin?.relayName) || relayId };
205
274
  }
206
275
 
207
- /** What an Inbox shows of this relay: its asks, and the cards turns wait on. */
208
- function localInboxItems({ forRemote = false } = {}) {
276
+ /**
277
+ * What an Inbox shows of this relay: its asks, and the cards turns wait on.
278
+ * `read`, when given, learns whether the pending cards could be read
279
+ * (`read.pendingCards`): a failed read lists none, which is not "none pending".
280
+ */
281
+ function localInboxItems({ forRemote = false, read = null } = {}) {
209
282
  const items = (relayAskService?.isAvailable?.() ? relayAskService.listHere() : []).map(inboxItemOfAsk);
210
283
  let pending = [];
211
284
  try {
212
285
  pending = stmts.listQuestions.all('pending', null, null).map(formatQuestionRow).filter(Boolean);
213
- } catch {}
286
+ if (read) read.pendingCards = true;
287
+ } catch {
288
+ if (read) read.pendingCards = false;
289
+ }
214
290
  let answered = [];
215
291
  try {
216
292
  const since = new Date(now() - RELAY_ASK_LIMITS.answeredKeepMs).toISOString();
@@ -219,6 +295,19 @@ export function registerRelayAskRoutes(app, deps) {
219
295
  for (const question of [...pending, ...answered]) {
220
296
  items.push(inboxItemOfCard(question, sessionOf(question.conversationId), { forRemote }));
221
297
  }
298
+ if (typeof formatRelayBoardRow === 'function') {
299
+ let boards = [];
300
+ try {
301
+ const since = new Date(now() - RELAY_ASK_LIMITS.answeredKeepMs).toISOString();
302
+ boards = [
303
+ ...(pendingPlanBoardsStmt?.all(INBOX_PLAN_CARDS_MAX) || []),
304
+ ...(settledPlanBoardsStmt?.all(since, INBOX_PLAN_CARDS_MAX) || []),
305
+ ].map(formatRelayBoardRow).filter(Boolean);
306
+ } catch {}
307
+ for (const board of boards) {
308
+ items.push(inboxItemOfPlanBoard(board, sessionOf(board.conversationId), { forRemote }));
309
+ }
310
+ }
222
311
  return items.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
223
312
  }
224
313
 
@@ -375,11 +464,52 @@ export function registerRelayAskRoutes(app, deps) {
375
464
  }
376
465
  });
377
466
 
378
- // GET /api/relay-asks/remote/inbox — what a paired relay's Inbox shows of this one.
467
+ // Who pushes a card a turn waits on: question id → the paired
468
+ // relay (its id) that claimed it. In memory: after a restart a
469
+ // card is just claimable again.
470
+ const claims = new Map();
471
+
472
+ /**
473
+ * `notice` (and with `you`, `claimed`) for each pending approval and
474
+ * ask_user item, for a caller that can push: this relay's own push
475
+ * decides first, then the first caller to find it missed claims it.
476
+ */
477
+ function addNotices(items, callerRelayId) {
478
+ const pendingIds = new Set();
479
+ const noticed = items.map((item) => {
480
+ if (item.status !== 'pending' || (item.kind !== 'approval' && item.kind !== 'ask_user')) return item;
481
+ pendingIds.add(item.id);
482
+ let own = null;
483
+ try {
484
+ own = typeof questionNotice === 'function' ? questionNotice(item.id) : null;
485
+ } catch {}
486
+ if (own === 'reached') return { ...item, notice: REMOTE_INBOX_NOTICES.here };
487
+ if (own === 'pending') return { ...item, notice: REMOTE_INBOX_NOTICES.wait };
488
+ const claim = claims.get(item.id);
489
+ if (!claim) {
490
+ claims.set(item.id, callerRelayId);
491
+ return { ...item, notice: REMOTE_INBOX_NOTICES.you, claimed: REMOTE_INBOX_CLAIMS.now };
492
+ }
493
+ if (claim === callerRelayId) return { ...item, notice: REMOTE_INBOX_NOTICES.you, claimed: REMOTE_INBOX_CLAIMS.before };
494
+ return { ...item, notice: REMOTE_INBOX_NOTICES.other };
495
+ });
496
+ // A card no longer pending needs no claim.
497
+ for (const id of claims.keys()) {
498
+ if (!pendingIds.has(id)) claims.delete(id);
499
+ }
500
+ return noticed;
501
+ }
502
+
503
+ // GET /api/relay-asks/remote/inbox — what a paired relay's Inbox shows of
504
+ // this one. `?push=1`: the caller can push, and learns who pushes each card.
379
505
  app.get('/api/relay-asks/remote/inbox', auth, (req, res) => {
380
506
  const caller = admitPairedRelay(req, res);
381
507
  if (!caller) return;
382
- const items = localInboxItems({ forRemote: true });
508
+ const read = {};
509
+ let items = localInboxItems({ forRemote: true, read });
510
+ // Pending cards that could not be read are not "no card pending": the
511
+ // claims stay, and no card is claimed or offered on this read.
512
+ if (toText(req.query?.push) === '1' && read.pendingCards) items = addNotices(items, caller.relayId);
383
513
  res.json({ ok: true, items, pending: pendingOf(items) });
384
514
  });
385
515