@oar-sh/oar 0.9.19 → 0.9.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +529 -0
- package/README.md +14 -14
- package/bin/oar.js +392 -98
- 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 +258 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +10 -0
- package/server/claude-worker/claude-session-worker-main.mjs +8 -1
- 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 +131 -5
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
- package/server/cursor-worker/cursor-session-worker.mjs +12 -1
- package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
- package/server/grok-worker/grok-background-tasks.mjs +9 -0
- package/server/grok-worker/grok-session-worker.mjs +12 -1
- package/server/grok-worker/grok-turn-runner.mjs +47 -6
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- 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-ui.js +1 -1
- package/server/public/app/about-view.mjs +106 -3
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/api-client.js +6 -3
- package/server/public/app/ask-user-view.js +20 -6
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +611 -70
- package/server/public/app/chat-actions-menu-state.mjs +139 -4
- package/server/public/app/chat-header-meta.mjs +27 -0
- package/server/public/app/claude-billing-toggle-ui.js +2 -1
- package/server/public/app/claude-cloud-conversation-ui.js +2 -2
- package/server/public/app/composer-control-state.mjs +12 -2
- package/server/public/app/composer-settings-label.mjs +90 -0
- package/server/public/app/composer-settings-ui.js +442 -0
- package/server/public/app/context-menu.mjs +36 -5
- package/server/public/app/context-usage-view.mjs +16 -0
- package/server/public/app/conversation-deep-link.mjs +15 -11
- package/server/public/app/conversation-group-drag.mjs +12 -2
- package/server/public/app/conversation-group-order.mjs +9 -2
- package/server/public/app/conversation-group-state.mjs +47 -10
- package/server/public/app/conversation-origin-ui.js +17 -1
- package/server/public/app/conversation-view.js +20 -2
- package/server/public/app/cwd-menu-placement.mjs +18 -6
- package/server/public/app/cwd-picker.js +36 -6
- package/server/public/app/emoji-view.js +1 -1
- package/server/public/app/font-scaling.js +26 -0
- package/server/public/app/header-priority-actions.mjs +234 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +50 -2
- package/server/public/app/inbox-view.mjs +141 -5
- package/server/public/app/journal-view.js +311 -58
- package/server/public/app/phone-landscape-ui.js +225 -0
- package/server/public/app/phone-landscape.mjs +99 -0
- package/server/public/app/phone-typing-state.mjs +104 -0
- package/server/public/app/pinned-messages-view.mjs +5 -0
- package/server/public/app/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- 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-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +42 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/relay-schedule-view.mjs +32 -6
- package/server/public/app/relay-schedules-ui.js +69 -20
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +250 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +61 -6
- package/server/public/app/settings-tabs.js +12 -0
- package/server/public/app/share-state.js +1 -1
- package/server/public/app/shared-conversations-settings.js +2 -2
- package/server/public/app/socket-handlers.js +15 -4
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/subagent-viewer.mjs +27 -2
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/usage-limit-ui.js +63 -2
- package/server/public/app/viewer-history.mjs +13 -2
- package/server/public/index.html +2258 -1585
- 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 +5 -3
- package/server/repositories/message-repository.mjs +54 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +25 -1
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +144 -1
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +144 -14
- package/server/server-runtime.mjs +322 -51
- package/server/services/about-service.mjs +81 -53
- package/server/services/cache-rebuild-service.mjs +7 -0
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- 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 +101 -19
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-stop-service.mjs +27 -6
- package/server/services/session-worker-websocket-service.mjs +40 -16
- 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-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/server/services/worker-scope.mjs +77 -0
- package/shared/build-id.mjs +128 -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
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
- package/shared/worker-runtime/working-directory.mjs +1 -1
package/server/public/sw.js
CHANGED
|
@@ -191,6 +191,14 @@ async function handleNotificationClick(event) {
|
|
|
191
191
|
if (ok) return;
|
|
192
192
|
}
|
|
193
193
|
}
|
|
194
|
+
// A card on a paired relay: it carries no question id, only the
|
|
195
|
+
// link of its session there, and is answered there ("Open there").
|
|
196
|
+
const url = String(data.url || '');
|
|
197
|
+
if (!data.questionId && /^https?:\/\//i.test(url)) {
|
|
198
|
+
event.notification.close();
|
|
199
|
+
await self.clients.openWindow(url);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
194
202
|
event.notification.close();
|
|
195
203
|
await focusOrOpenConversation(String(data.conversationId || ''));
|
|
196
204
|
}
|
|
@@ -141,7 +141,9 @@ export function configuredHeapLimit({ configPath, fsImpl = fs } = {}) {
|
|
|
141
141
|
return Number.isInteger(mb) && mb >= MIN_HEAP_LIMIT_MB ? { mb } : { invalid: value };
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
// Free bytes on the disk of `dir`, or null when unknown. Also used by the
|
|
145
|
+
// database backup's space check (db-backup-service.mjs), with diskBytes.
|
|
146
|
+
export function freeBytes(dir, fsImpl) {
|
|
145
147
|
try {
|
|
146
148
|
if (typeof fsImpl.statfsSync !== 'function') return null;
|
|
147
149
|
const stats = fsImpl.statfsSync(dir);
|
|
@@ -151,6 +153,17 @@ function freeBytes(dir, fsImpl) {
|
|
|
151
153
|
}
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
// The size of the disk of `dir`, or null when unknown.
|
|
157
|
+
export function diskBytes(dir, fsImpl) {
|
|
158
|
+
try {
|
|
159
|
+
if (typeof fsImpl.statfsSync !== 'function') return null;
|
|
160
|
+
const stats = fsImpl.statfsSync(dir);
|
|
161
|
+
return Number(stats.blocks) * Number(stats.bsize);
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
154
167
|
/** A path for the log: `~` for the home folder, so a log line names no user. */
|
|
155
168
|
export function displayPath(target, homeDir = os.homedir()) {
|
|
156
169
|
const resolved = path.resolve(String(target || ''));
|
|
@@ -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,12 @@ 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
|
-
- `
|
|
45
|
+
- `list_sessions` and `read_session` show a session's worker process, while it has one, as "worker" (kind, buildId, stale); "stale": true means it runs older code than that relay, which replaces it by itself once the session is idle, so there is nothing to do about it.
|
|
46
|
+
- `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.
|
|
47
|
+
- "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
48
|
- 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
49
|
- 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
50
|
- 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 +53,7 @@ This relay itself is listed with "self": true when its owner switched agent sess
|
|
|
51
53
|
- 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
54
|
- `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
55
|
|
|
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
|
|
56
|
+
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
57
|
|
|
56
58
|
Each wake-up costs you a turn; these `subscribe` options cut them (questions and errors always come at once):
|
|
57
59
|
- "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
|
|
@@ -431,6 +461,29 @@ export function createMessageRepository(db) {
|
|
|
431
461
|
WHERE status = 'processing'
|
|
432
462
|
AND NULLIF(owner_sdk_session_id, '') = ?
|
|
433
463
|
`),
|
|
464
|
+
// The worker recycle's quiet window (worker-recycle-service.mjs): the
|
|
465
|
+
// last time the session had a delivery or a turn end, as an ISO
|
|
466
|
+
// string, or NULL when the queue holds no row of it. Over the rows the
|
|
467
|
+
// session owns: a row is stamped with its owner at enqueue under
|
|
468
|
+
// session-worker routing and at the latest by its claim, and a
|
|
469
|
+
// conversation rebound to another session leaves its old rows with the
|
|
470
|
+
// session that ran them. The queue keeps no completion time
|
|
471
|
+
// (setDone clears processing_at), so a turn's end is its response
|
|
472
|
+
// message's timestamp; the claim and the heartbeat lease
|
|
473
|
+
// (owner_last_claimed_at) and the assignment stand for the delivery.
|
|
474
|
+
// Each column is COALESCEd: SQLite's multi-argument MAX() is NULL as
|
|
475
|
+
// soon as one argument is.
|
|
476
|
+
getOwnerLastActivityAt: db.prepare(`
|
|
477
|
+
SELECT MAX(NULLIF(MAX(
|
|
478
|
+
COALESCE(q.processing_at, ''),
|
|
479
|
+
COALESCE(q.owner_assigned_at, ''),
|
|
480
|
+
COALESCE(q.owner_last_claimed_at, ''),
|
|
481
|
+
COALESCE(m.timestamp, '')
|
|
482
|
+
), '')) AS last_activity_at
|
|
483
|
+
FROM queue q
|
|
484
|
+
LEFT JOIN messages m ON m.id = q.response_message_id
|
|
485
|
+
WHERE NULLIF(q.owner_sdk_session_id, '') = ?
|
|
486
|
+
`),
|
|
434
487
|
recoverProcessingRowKeepOwner: db.prepare(`
|
|
435
488
|
UPDATE queue
|
|
436
489
|
SET status = 'pending',
|
|
@@ -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 = ?`),
|
|
@@ -95,6 +100,25 @@ export function createSessionRepository(db) {
|
|
|
95
100
|
getRuntimeSessionBySdkSessionId: db.prepare(`SELECT * FROM runtime_sessions WHERE sdk_session_id = ?`),
|
|
96
101
|
getRuntimeSessionById: db.prepare(`SELECT * FROM runtime_sessions WHERE id = ?`),
|
|
97
102
|
listRuntimeSessions: db.prepare(`SELECT rs.*, c.title AS conversation_title, c.updated_at AS conversation_updated_at FROM runtime_sessions rs LEFT JOIN conversations c ON c.id = rs.conversation_id ORDER BY rs.last_used_at DESC`),
|
|
103
|
+
// The session ids of archived conversations: the worker sweep at relay
|
|
104
|
+
// start stops what still runs for them. A runtime session id is unique
|
|
105
|
+
// (idx_runtime_sessions_sdk_session_id), but a conversation's own
|
|
106
|
+
// sdk_session_id is not: an id that a conversation in use (not
|
|
107
|
+
// archived, not deleted) names too is left out, so that
|
|
108
|
+
// conversation's worker is never stopped for an archived one.
|
|
109
|
+
listArchivedRuntimeSessionIds: db.prepare(`
|
|
110
|
+
SELECT DISTINCT rs.sdk_session_id AS sdk_session_id
|
|
111
|
+
FROM runtime_sessions rs
|
|
112
|
+
JOIN conversations c ON c.id = rs.conversation_id
|
|
113
|
+
WHERE c.archived = 1
|
|
114
|
+
AND NULLIF(rs.sdk_session_id, '') IS NOT NULL
|
|
115
|
+
AND NOT EXISTS (
|
|
116
|
+
SELECT 1 FROM conversations live_c
|
|
117
|
+
WHERE live_c.sdk_session_id = rs.sdk_session_id
|
|
118
|
+
AND COALESCE(live_c.archived, 0) = 0
|
|
119
|
+
AND live_c.status != 'deleted'
|
|
120
|
+
)
|
|
121
|
+
`),
|
|
98
122
|
insertRuntimeSession: db.prepare(runtimeSessionsSupportProviders
|
|
99
123
|
? `INSERT INTO runtime_sessions (id, conversation_id, strategy, runtime_key, model, status, created_at, last_used_at, sdk_session_id, provider_type, provider_model) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)`
|
|
100
124
|
: `INSERT INTO runtime_sessions (id, conversation_id, strategy, runtime_key, model, status, created_at, last_used_at, sdk_session_id) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?)`),
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
// environment, account, login state
|
|
7
7
|
// POST /api/settings/claude-cloud { enabled?, defaultModel?, environmentId? }
|
|
8
8
|
// POST /api/claude-cloud-session the worker's binding report
|
|
9
|
+
// GET /api/claude-cloud-session `?conversationId=` (or the sdk session
|
|
10
|
+
// id) → the stored binding a starting
|
|
11
|
+
// worker attaches to
|
|
9
12
|
// GET /api/claude-cloud/repos the repositories New Chat may suggest
|
|
10
13
|
// (`?refresh=1` reads them afresh)
|
|
11
14
|
// GET /api/claude-cloud/branches `?repo=<owner/name or URL>` → its branches
|
|
@@ -77,6 +80,22 @@ export function registerClaudeCloudRoutes(app, deps = {}) {
|
|
|
77
80
|
if (!result.ok) return res.status(result.statusCode || 400).json({ error: result.error });
|
|
78
81
|
return res.json({ ok: true, cloud: result.cloud });
|
|
79
82
|
});
|
|
83
|
+
|
|
84
|
+
// GET /api/claude-cloud-session?conversationId=<id> — a starting Claude
|
|
85
|
+
// Cloud worker asks which cloud session it is bound to and from which
|
|
86
|
+
// sequence to read on, so it opens the event stream before any delivery
|
|
87
|
+
// (a relaunched worker follows the turns the session starts by itself
|
|
88
|
+
// from its start). The id may be the conversation id or the sdk session
|
|
89
|
+
// id. `{ ok, sessionId, lastSequence }`, `sessionId: null` when there is
|
|
90
|
+
// no cloud session; 404 only for an unknown conversation.
|
|
91
|
+
app.get('/api/claude-cloud-session', auth, (req, res) => {
|
|
92
|
+
touchCli();
|
|
93
|
+
const raw = req.query?.conversationId ?? req.query?.sdkSessionId;
|
|
94
|
+
const id = Array.isArray(raw) ? raw[0] : raw;
|
|
95
|
+
const result = claudeCloudSessionService.describeStoredBinding(typeof id === 'string' ? id : '');
|
|
96
|
+
if (!result.ok) return res.status(result.statusCode || 400).json({ error: result.error });
|
|
97
|
+
return res.json({ ok: true, sessionId: result.sessionId, lastSequence: result.lastSequence });
|
|
98
|
+
});
|
|
80
99
|
}
|
|
81
100
|
|
|
82
101
|
if (claudeCloudLoginStatus) {
|