@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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +317 -0
- package/README.md +10 -10
- package/bin/oar.js +391 -97
- package/package.json +4 -2
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +27 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +3 -0
- package/server/claude-worker/claude-turn-publisher.mjs +163 -8
- package/server/claude-worker/claude-usage-totals.mjs +58 -0
- package/server/copilot-worker/copilot-plan-board.mjs +4 -7
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
- package/server/copilot-worker/copilot-sdk-session-process.mjs +57 -4
- package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +44 -6
- package/server/migrations/0001-add-structured-answer.mjs +5 -7
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
- package/server/migrations/0003-push-subscriptions.mjs +5 -7
- package/server/migrations/0004-steer-settle-markers.mjs +5 -7
- package/server/migrations/0005-remote-relays.mjs +5 -7
- package/server/migrations/0006-workspace-root-settings.mjs +5 -7
- package/server/migrations/0007-claude-cloud.mjs +5 -7
- package/server/migrations/0008-message-pins.mjs +5 -7
- package/server/migrations/0009-subagent-timeline.mjs +5 -7
- package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
- package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0013-previews.mjs +5 -7
- package/server/migrations/0014-conversation-groups.mjs +5 -7
- package/server/migrations/0015-conversation-group-position.mjs +5 -7
- package/server/migrations/0016-share-access-log.mjs +5 -7
- package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
- package/server/migrations/0018-background-task-rows.mjs +5 -7
- package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
- package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
- package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
- package/server/migrations/0022-relay-schedules.mjs +5 -7
- package/server/migrations/0023-relay-asks.mjs +5 -7
- package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
- package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
- package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
- package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
- package/server/migrations/0031-queue-provider.mjs +5 -7
- package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
- package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
- package/server/migrations/0035-conversation-plan-only.mjs +87 -0
- package/server/migrations/0036-token-usage.mjs +267 -0
- package/server/public/app/about-view.mjs +87 -3
- package/server/public/app/api-client.js +2 -1
- package/server/public/app/bootstrap.js +55 -2
- package/server/public/app/composer-control-state.mjs +12 -2
- package/server/public/app/context-usage-view.mjs +16 -0
- package/server/public/app/conversation-view.js +17 -2
- package/server/public/app/inbox-ui.js +32 -0
- package/server/public/app/inbox-view.mjs +141 -5
- package/server/public/app/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- package/server/public/app/relay-board-inline.mjs +4 -1
- package/server/public/app/relay-board-view.js +38 -10
- package/server/public/app/relay-event-view.mjs +4 -0
- package/server/public/app/socket-handlers.js +10 -2
- package/server/public/index.html +14 -0
- package/server/public/sw.js +8 -0
- package/server/relay-heap-guard.mjs +14 -1
- package/server/relay-self-restart.mjs +83 -2
- package/server/relay-tools.md +4 -3
- package/server/repositories/message-repository.mjs +31 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +6 -1
- package/server/routes/messages-routes.mjs +87 -0
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +103 -7
- package/server/server-runtime.mjs +132 -11
- package/server/services/about-service.mjs +59 -0
- package/server/services/cache-rebuild-service.mjs +7 -0
- package/server/services/conversation-preferences-service.mjs +31 -0
- package/server/services/database-file.mjs +413 -0
- package/server/services/db-backup-service.mjs +809 -0
- package/server/services/deepseek-usage-import.mjs +6 -9
- package/server/services/delete-archive-service.mjs +3 -0
- package/server/services/earlier-database-service.mjs +69 -126
- package/server/services/oar-cli-helpers.mjs +214 -12
- package/server/services/oar-cli-menu.mjs +1 -0
- package/server/services/oar-state-migration-service.mjs +33 -13
- package/server/services/package-data-rescue.mjs +4 -2
- package/server/services/plan-usage-claude-api.mjs +14 -15
- package/server/services/plan-usage-contract.mjs +6 -2
- package/server/services/plan-usage-deepseek.mjs +1 -1
- package/server/services/plan-usage-tokens.mjs +248 -0
- package/server/services/push-dispatch-service.mjs +134 -9
- package/server/services/relay-cli-launcher-service.mjs +3 -0
- package/server/services/relay-event-service.mjs +30 -0
- package/server/services/relay-question-context.mjs +11 -0
- package/server/services/relay-restart-picture.mjs +67 -22
- package/server/services/remote-inbox-watch-service.mjs +204 -0
- package/server/services/remote-relay-caller-context.mjs +80 -7
- package/server/services/remote-relay-dispatcher.mjs +40 -5
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-stop-service.mjs +27 -6
- package/server/services/token-usage-service.mjs +430 -0
- package/server/services/update-install-service.mjs +13 -0
- package/server/services/usage-limit-pause-service.mjs +8 -1
- package/server/services/worker-scope.mjs +77 -0
- package/shared/plan-board-actions.mjs +130 -0
- package/shared/plan-only.mjs +43 -0
- package/shared/relay-asks.mjs +3 -0
- package/shared/relay-events.mjs +38 -4
- package/shared/remote-relay-contract.mjs +66 -11
- 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
|
}
|
package/server/relay-tools.md
CHANGED
|
@@ -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 `
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|