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