@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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replacing a session's worker: the stop and the launch that the Claude
|
|
3
|
+
* billing swap (claude-billing-swap-service.mjs) and the worker recycle
|
|
4
|
+
* (worker-recycle-service.mjs) share, and the predicate both use to tell
|
|
5
|
+
* whether a worker may be replaced now.
|
|
6
|
+
*
|
|
7
|
+
* The stop is the kill route's: nothing may start the session while it is
|
|
8
|
+
* stopped, every process of the session must be gone before it counts as
|
|
9
|
+
* stopped, and only then is the registry entry dropped. The launch never
|
|
10
|
+
* reuses a live process, so two CLIs never share a session's transcript.
|
|
11
|
+
* The fence keeps the deliveries away from the process being replaced for the
|
|
12
|
+
* whole of it (createWorkerReplacementFence).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { stopSessionWorkerProcesses as defaultStopSessionWorkerProcesses } from './session-worker-stop-service.mjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What keeps a worker from being replaced now: null when nothing does, else
|
|
19
|
+
* 'turn', 'question', 'compaction' or 'background'. From the steering
|
|
20
|
+
* snapshot its heartbeat sends; a worker too old to say `releasable` is read
|
|
21
|
+
* from the rest of it.
|
|
22
|
+
*/
|
|
23
|
+
export function workerReleaseHold(worker) {
|
|
24
|
+
if (!worker) return null;
|
|
25
|
+
const steering = worker.steering || null;
|
|
26
|
+
if (steering && typeof steering.releasable === 'boolean') {
|
|
27
|
+
return steering.releasable ? null : (steering.releaseHold || 'turn');
|
|
28
|
+
}
|
|
29
|
+
if (steering) {
|
|
30
|
+
if (steering.holdReason === 'question') return 'question';
|
|
31
|
+
if (steering.holdReason === 'compaction') return 'compaction';
|
|
32
|
+
if (steering.turnActive) return 'turn';
|
|
33
|
+
if (steering.autoCompactWindow?.backgroundWork) return 'background';
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return String(worker.status || '') === 'processing' ? 'turn' : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toFencePid(value) {
|
|
40
|
+
const pid = Number(value);
|
|
41
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The replacement fence: while a worker process is being replaced, no work
|
|
46
|
+
* is handed to it.
|
|
47
|
+
*
|
|
48
|
+
* The killed marker the stop sets does not do this. It lapses after the
|
|
49
|
+
* supervisor's short kill grace (a stop that verifies every process gone can
|
|
50
|
+
* take longer), and any path that relaunches a session lifts it at once
|
|
51
|
+
* (`clearRestartSchedule({ resetKilledMarker: true })`, the delivery's own
|
|
52
|
+
* fallback restart among them), after which a delivery would start a turn on
|
|
53
|
+
* the very process being killed. The fence is its own structure, so nothing
|
|
54
|
+
* that resets the supervisor's lifecycle state touches it.
|
|
55
|
+
*
|
|
56
|
+
* Keyed by session and the pid being replaced: the new worker has another
|
|
57
|
+
* pid and is never fenced, so it takes the work that waited meanwhile. A
|
|
58
|
+
* request that names no pid cannot be told from the old process and is
|
|
59
|
+
* refused while the session's fence is up.
|
|
60
|
+
*
|
|
61
|
+
* - `hold(sid, pid)`: raise the fence for that process of the session.
|
|
62
|
+
* - `isHeld(sid, pid)`: true while the fence is up for that pid (or for any
|
|
63
|
+
* pid of the session, when `pid` is unknown).
|
|
64
|
+
* - `release(sid, pid?)`: lower it; with a pid, only if that is the fenced
|
|
65
|
+
* one, so a late release from an older replacement never lifts a newer one.
|
|
66
|
+
*/
|
|
67
|
+
export function createWorkerReplacementFence() {
|
|
68
|
+
const held = new Map();
|
|
69
|
+
|
|
70
|
+
function key(sid) {
|
|
71
|
+
return String(sid || '').trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function hold(sid, pid) {
|
|
75
|
+
const sessionId = key(sid);
|
|
76
|
+
if (!sessionId) return false;
|
|
77
|
+
held.set(sessionId, toFencePid(pid));
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isHeld(sid, pid = null) {
|
|
82
|
+
const sessionId = key(sid);
|
|
83
|
+
if (!sessionId || !held.has(sessionId)) return false;
|
|
84
|
+
const fencedPid = held.get(sessionId);
|
|
85
|
+
const askingPid = toFencePid(pid);
|
|
86
|
+
// An unknown pid on either side: the old process cannot be ruled out.
|
|
87
|
+
if (fencedPid === null || askingPid === null) return true;
|
|
88
|
+
return fencedPid === askingPid;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function release(sid, pid = undefined) {
|
|
92
|
+
const sessionId = key(sid);
|
|
93
|
+
if (!sessionId || !held.has(sessionId)) return false;
|
|
94
|
+
if (pid !== undefined && toFencePid(pid) !== held.get(sessionId)) return false;
|
|
95
|
+
held.delete(sessionId);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function list() {
|
|
100
|
+
return Array.from(held, ([sdkSessionId, pid]) => ({ sdkSessionId, pid }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { hold, isHeld, release, list };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createWorkerReplacement({
|
|
107
|
+
// The session worker registry: getWorker(sid), removeWorker(sid).
|
|
108
|
+
registry,
|
|
109
|
+
// The session worker supervisor: markKilled, cancelPendingStart,
|
|
110
|
+
// clearRestartSchedule, resetHealth, ensureWorker.
|
|
111
|
+
supervisor,
|
|
112
|
+
// Handed to stopSessionWorkerProcesses to find every process of a session.
|
|
113
|
+
processInspector = null,
|
|
114
|
+
// session-worker-stop-service.mjs by default; tests inject a fake so they
|
|
115
|
+
// need no processes: async ({ sdkSessionId, worker, processInspector,
|
|
116
|
+
// isPidAliveImpl, killTmuxSessionImpl }) → { ok, error?, remainingPids? }.
|
|
117
|
+
stopSessionWorkerProcesses = defaultStopSessionWorkerProcesses,
|
|
118
|
+
isProcessAlive = undefined,
|
|
119
|
+
killTmuxSession = undefined,
|
|
120
|
+
logger = console,
|
|
121
|
+
// The caller's tag for its warnings ('BILLING SWAP', 'WORKER RECYCLE'), and
|
|
122
|
+
// a function for the runtime's own log prefix, or a fixed string.
|
|
123
|
+
logPrefix = '',
|
|
124
|
+
// The replacement fence the caller raises before the stop and lowers once
|
|
125
|
+
// the launch is done (createWorkerReplacementFence). Passed in so the
|
|
126
|
+
// runtime's replacements and its delivery paths share one; this module
|
|
127
|
+
// only hands it back.
|
|
128
|
+
fence = createWorkerReplacementFence(),
|
|
129
|
+
} = {}) {
|
|
130
|
+
if (typeof stopSessionWorkerProcesses !== 'function') {
|
|
131
|
+
throw new Error('createWorkerReplacement requires stopSessionWorkerProcesses');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function prefix() {
|
|
135
|
+
try {
|
|
136
|
+
return typeof logPrefix === 'function' ? String(logPrefix() || '') : String(logPrefix || '');
|
|
137
|
+
} catch {
|
|
138
|
+
return '';
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Stop every process of the session. `{ ok: true }` once they are all gone
|
|
144
|
+
* and the registry and the supervisor's health state are cleared; else
|
|
145
|
+
* `{ ok: false, error, remainingPids }`, and the caller retries with a
|
|
146
|
+
* backoff.
|
|
147
|
+
*
|
|
148
|
+
* `keepKilled: true` is for a session that must stay stopped (an archived
|
|
149
|
+
* conversation): the killed marker set here is left in place on either
|
|
150
|
+
* outcome, as the archive route leaves it, so nothing relaunches the
|
|
151
|
+
* session meanwhile.
|
|
152
|
+
*/
|
|
153
|
+
async function stopWorker(sid, { keepKilled = false } = {}) {
|
|
154
|
+
const worker = registry?.getWorker?.(sid) || null;
|
|
155
|
+
// Nothing may start the session while it is being stopped: a launch then
|
|
156
|
+
// would adopt the old process, kind and build and all.
|
|
157
|
+
supervisor?.markKilled?.(sid);
|
|
158
|
+
// A stop that fails backs off (the caller retries it). Unless the session
|
|
159
|
+
// is to stay stopped, the killed marker goes with it, so the session's
|
|
160
|
+
// worker, alive or relaunched, keeps asking for work meanwhile.
|
|
161
|
+
const backOff = (error, remainingPids = []) => {
|
|
162
|
+
if (!keepKilled) supervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
|
|
163
|
+
const remaining = remainingPids.length ? ` remaining=${remainingPids.join(',')}` : '';
|
|
164
|
+
const marker = keepKilled ? 'kill marker kept' : 'kill marker cleared';
|
|
165
|
+
try {
|
|
166
|
+
logger.warn?.(`${prefix()}STOP FAILED session=${String(sid).slice(0, 8)} ${error}${remaining} — ${marker}, retrying with a backoff`);
|
|
167
|
+
} catch {}
|
|
168
|
+
return { ok: false, error, remainingPids };
|
|
169
|
+
};
|
|
170
|
+
let stopped = null;
|
|
171
|
+
try {
|
|
172
|
+
await supervisor?.cancelPendingStart?.(sid, { wait: true });
|
|
173
|
+
stopped = await stopSessionWorkerProcesses({
|
|
174
|
+
sdkSessionId: sid,
|
|
175
|
+
worker,
|
|
176
|
+
processInspector,
|
|
177
|
+
isPidAliveImpl: isProcessAlive,
|
|
178
|
+
killTmuxSessionImpl: killTmuxSession,
|
|
179
|
+
});
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return backOff(String(error?.message || error || 'worker-stop-failed'));
|
|
182
|
+
}
|
|
183
|
+
if (!stopped?.ok) return backOff(stopped?.error || 'worker-survived-stop', stopped?.remainingPids || []);
|
|
184
|
+
registry?.removeWorker?.(sid);
|
|
185
|
+
supervisor?.resetHealth?.(sid, { clearFailureCount: false });
|
|
186
|
+
return { ok: true };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Launch the session's worker, never reusing a live process: `{ ok, error }`. */
|
|
190
|
+
async function launchWorker(sid) {
|
|
191
|
+
supervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
|
|
192
|
+
const launched = await supervisor?.ensureWorker?.(sid, { allowProcessReuse: false });
|
|
193
|
+
return { ok: launched?.ok === true, error: launched?.error || null };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { stopWorker, launchWorker, fence };
|
|
197
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session workers of a relay that a systemd user service runs start in a
|
|
3
|
+
* scope of their own (`systemd-run --user --scope`), so a stop or restart of
|
|
4
|
+
* the service, which ends every process in its cgroup (KillMode=mixed), does
|
|
5
|
+
* not take them along. A tmux server the call starts lands in that scope; a
|
|
6
|
+
* running one is only talked to. With `--scope` systemd-run executes the
|
|
7
|
+
* command in place: its process id, environment and exit stay the worker's.
|
|
8
|
+
*
|
|
9
|
+
* Whether it can is asked once per runtime, at the first worker start (or
|
|
10
|
+
* the first restart picture, which says whether the workers survive):
|
|
11
|
+
* the runtime's parent must be the unit's main process (a relay started inside
|
|
12
|
+
* a worker that still sits in the service's cgroup looks like the service,
|
|
13
|
+
* but is not it), and systemd-run must work (it needs the user bus). Anything
|
|
14
|
+
* else starts workers as before, with one log line.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
|
|
19
|
+
export const WORKER_SCOPE_PREFIX = Object.freeze(['--user', '--scope', '--collect', '--quiet', '--']);
|
|
20
|
+
|
|
21
|
+
function failure(result) {
|
|
22
|
+
if (result?.error) return result.error.code || result.error.message || String(result.error);
|
|
23
|
+
const said = String(result?.stderr || '').trim().split('\n')[0];
|
|
24
|
+
return `status ${result?.status ?? 'none'}${said ? `: ${said}` : ''}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `wrap(command, args)` → `{ command, args }`: the call through systemd-run
|
|
29
|
+
* where workers get scopes of their own, else the call as it is. `hosting`
|
|
30
|
+
* is detectRelayHosting()'s answer for this runtime.
|
|
31
|
+
*/
|
|
32
|
+
export function createWorkerScope({
|
|
33
|
+
hosting = null,
|
|
34
|
+
spawnSyncImpl = spawnSync,
|
|
35
|
+
parentPid = process.ppid,
|
|
36
|
+
log = null,
|
|
37
|
+
} = {}) {
|
|
38
|
+
let scoped = null;
|
|
39
|
+
|
|
40
|
+
const decide = () => {
|
|
41
|
+
if (!hosting?.systemd || !hosting.userManager || !hosting.unit) return false;
|
|
42
|
+
const run = (command, args) => {
|
|
43
|
+
try {
|
|
44
|
+
return spawnSyncImpl(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5_000 }) || { status: null };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return { status: null, error };
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const main = run('systemctl', ['--user', 'show', '-p', 'MainPID', '--value', hosting.unit]);
|
|
50
|
+
const mainPid = Number(String(main.stdout ?? '').trim());
|
|
51
|
+
if (main.status !== 0 || mainPid !== parentPid) {
|
|
52
|
+
log?.(`session workers start without a scope of their own: ${hosting.unit} is not this relay's service (main process ${main.status === 0 ? mainPid || 'none' : `unknown, ${failure(main)}`}, relay supervisor ${parentPid}).`);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
const probe = run('systemd-run', [...WORKER_SCOPE_PREFIX, 'true']);
|
|
56
|
+
if (probe.status !== 0) {
|
|
57
|
+
log?.(`session workers start without a scope of their own (systemd-run: ${failure(probe)}); a stop or restart of ${hosting.unit} ends them.`);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
log?.(`session workers start in scopes of their own (systemd-run --user --scope): they keep running through a stop or restart of ${hosting.unit}.`);
|
|
61
|
+
return true;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const inUse = () => {
|
|
65
|
+
if (scoped === null) scoped = decide();
|
|
66
|
+
return scoped;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
// Whether workers start in scopes of their own; asked once, by whichever
|
|
71
|
+
// comes first (a worker start, or a restart picture).
|
|
72
|
+
inUse,
|
|
73
|
+
wrap(command, args = []) {
|
|
74
|
+
return inUse() ? { command: 'systemd-run', args: [...WORKER_SCOPE_PREFIX, command, ...args] } : { command, args };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// The build id names the code a process runs, so the relay can tell a session
|
|
5
|
+
// worker that started on older code from one that runs what the relay runs.
|
|
6
|
+
//
|
|
7
|
+
// It is `${version}+${commit.slice(0, 12)}` when both are known, the version
|
|
8
|
+
// alone when there is no commit, and null when there is no version either. The
|
|
9
|
+
// commit is part of it because on a git checkout a deploy is a new
|
|
10
|
+
// commit at the same version; on an npm install without a `gitHead` stamp the
|
|
11
|
+
// version alone decides.
|
|
12
|
+
//
|
|
13
|
+
// The relay and every worker compute it once at process start and never read
|
|
14
|
+
// it again, so a tree that moved on under a running process still describes
|
|
15
|
+
// the code that process started with.
|
|
16
|
+
//
|
|
17
|
+
// This module lives in shared/ because both the relay and the workers import
|
|
18
|
+
// it; it depends on nothing beyond node:fs and node:path for the same reason.
|
|
19
|
+
|
|
20
|
+
const COMMIT_PATTERN = /^[0-9a-f]{40}$/i;
|
|
21
|
+
const COMMIT_ID_LENGTH = 12;
|
|
22
|
+
|
|
23
|
+
// The relay stores the build id a worker states, so what it accepts is bounded
|
|
24
|
+
// in length and limited to the characters a version and a commit use.
|
|
25
|
+
const BUILD_ID_MAX_LENGTH = 80;
|
|
26
|
+
const BUILD_ID_PATTERN = /^[A-Za-z0-9+._-]+$/;
|
|
27
|
+
|
|
28
|
+
function readFileText(fsImpl, filePath) {
|
|
29
|
+
try {
|
|
30
|
+
return String(fsImpl.readFileSync(filePath, 'utf8'));
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readPackageManifest(packageRoot, { fsImpl, pathImpl }) {
|
|
37
|
+
if (!packageRoot) return null;
|
|
38
|
+
try {
|
|
39
|
+
const manifest = JSON.parse(readFileText(fsImpl, pathImpl.join(packageRoot, 'package.json')) || '');
|
|
40
|
+
return manifest && typeof manifest === 'object' ? manifest : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The commit a git checkout has checked out, read from its .git files rather
|
|
48
|
+
* than from a `git` process: HEAD, then the loose ref, then packed-refs. A
|
|
49
|
+
* linked worktree's .git is a file naming its git dir, whose refs live in the
|
|
50
|
+
* common dir. Anything unexpected reads as null. `pathImpl` is the host's path
|
|
51
|
+
* module; tests pass path.posix and path.win32.
|
|
52
|
+
*/
|
|
53
|
+
export function readGitCommit(root, { fsImpl = fs, pathImpl = path } = {}) {
|
|
54
|
+
if (!root) return null;
|
|
55
|
+
const dotGit = pathImpl.join(root, '.git');
|
|
56
|
+
let gitDir = dotGit;
|
|
57
|
+
const pointer = readFileText(fsImpl, dotGit);
|
|
58
|
+
if (pointer !== null) {
|
|
59
|
+
const match = pointer.match(/^gitdir:\s*(.+)$/m);
|
|
60
|
+
if (!match) return null;
|
|
61
|
+
gitDir = pathImpl.resolve(root, match[1].trim());
|
|
62
|
+
}
|
|
63
|
+
const head = readFileText(fsImpl, pathImpl.join(gitDir, 'HEAD'));
|
|
64
|
+
if (head === null) return null;
|
|
65
|
+
const headText = head.trim();
|
|
66
|
+
if (COMMIT_PATTERN.test(headText)) return headText.toLowerCase();
|
|
67
|
+
const refMatch = headText.match(/^ref:\s*(\S+)$/);
|
|
68
|
+
if (!refMatch) return null;
|
|
69
|
+
const ref = refMatch[1];
|
|
70
|
+
const commonDirText = readFileText(fsImpl, pathImpl.join(gitDir, 'commondir'));
|
|
71
|
+
const commonDir = commonDirText ? pathImpl.resolve(gitDir, commonDirText.trim()) : gitDir;
|
|
72
|
+
for (const dir of new Set([gitDir, commonDir])) {
|
|
73
|
+
const loose = readFileText(fsImpl, pathImpl.join(dir, ...ref.split('/')));
|
|
74
|
+
if (loose && COMMIT_PATTERN.test(loose.trim())) return loose.trim().toLowerCase();
|
|
75
|
+
}
|
|
76
|
+
const packed = readFileText(fsImpl, pathImpl.join(commonDir, 'packed-refs')) || '';
|
|
77
|
+
for (const line of packed.split(/\r?\n/)) {
|
|
78
|
+
const [hash, name] = line.trim().split(/\s+/);
|
|
79
|
+
if (name === ref && COMMIT_PATTERN.test(hash || '')) return hash.toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The commit this build came from: the checkout's HEAD, else a `gitHead` stamp in package.json. */
|
|
85
|
+
export function resolveBuildCommit(packageRoot, { fsImpl = fs, pathImpl = path } = {}) {
|
|
86
|
+
const fromGit = readGitCommit(packageRoot, { fsImpl, pathImpl });
|
|
87
|
+
if (fromGit) return fromGit;
|
|
88
|
+
const stamp = String(readPackageManifest(packageRoot, { fsImpl, pathImpl })?.gitHead || '').trim();
|
|
89
|
+
return COMMIT_PATTERN.test(stamp) ? stamp.toLowerCase() : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The package.json `version`, or null when the manifest cannot be read or names none. */
|
|
93
|
+
export function readPackageVersion(packageRoot, { fsImpl = fs, pathImpl = path } = {}) {
|
|
94
|
+
const version = readPackageManifest(packageRoot, { fsImpl, pathImpl })?.version;
|
|
95
|
+
return typeof version === 'string' && version.trim() ? version.trim() : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The build id of the tree at `packageRoot`: `{ version, commit, id }`. Call it
|
|
100
|
+
* once at process start and keep the result; see the header for why.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveBuildId(packageRoot, { fsImpl = fs, pathImpl = path } = {}) {
|
|
103
|
+
const version = readPackageVersion(packageRoot, { fsImpl, pathImpl });
|
|
104
|
+
const commit = resolveBuildCommit(packageRoot, { fsImpl, pathImpl });
|
|
105
|
+
let id = null;
|
|
106
|
+
if (version && commit) id = `${version}+${commit.slice(0, COMMIT_ID_LENGTH)}`;
|
|
107
|
+
else if (version) id = version;
|
|
108
|
+
return { version, commit, id };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* True only when both ids are known and differ. An unknown id on either side
|
|
113
|
+
* is not evidence of a change, so it never reads as one.
|
|
114
|
+
*/
|
|
115
|
+
export function buildIdsDiffer(a, b) {
|
|
116
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
117
|
+
const left = a.trim();
|
|
118
|
+
const right = b.trim();
|
|
119
|
+
return !!left && !!right && left !== right;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A stated build id, trimmed, or null when it is too long or carries other characters. */
|
|
123
|
+
export function normalizeBuildId(value) {
|
|
124
|
+
if (typeof value !== 'string') return null;
|
|
125
|
+
const text = value.trim();
|
|
126
|
+
if (!text || text.length > BUILD_ID_MAX_LENGTH || !BUILD_ID_PATTERN.test(text)) return null;
|
|
127
|
+
return text;
|
|
128
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The buttons of a `plan_ready` card, one list for every provider: the Claude,
|
|
3
|
+
* Copilot, Cursor and Grok workers post it, the CLI extension adds
|
|
4
|
+
* "Approve plan" to the Copilot CLI's own list, and the relay's action route
|
|
5
|
+
* builds the follow-up message from the same table.
|
|
6
|
+
*
|
|
7
|
+
* - `approve_only` marks the plan approved. Its follow-up runs one turn in
|
|
8
|
+
* agent mode (plan mode blocks writes, and the plan doc still has to be
|
|
9
|
+
* committed); the session's saved mode stays plan.
|
|
10
|
+
* - `autopilot` / `autopilot_fleet` and `interactive` leave plan mode: the
|
|
11
|
+
* session goes on in the mode the follow-up runs in.
|
|
12
|
+
* - `exit_only` only settles the card.
|
|
13
|
+
*
|
|
14
|
+
* No follow-up carries the plan: the session wrote it, and repeating it read
|
|
15
|
+
* as a new instruction to carry it out.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const PLAN_BOARD_APPROVE_ONLY = 'approve_only';
|
|
19
|
+
|
|
20
|
+
export const PLAN_BOARD_ACTIONS = Object.freeze([
|
|
21
|
+
Object.freeze({ id: 'approve_only', label: 'Approve plan', mode: 'agent' }),
|
|
22
|
+
Object.freeze({ id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' }),
|
|
23
|
+
Object.freeze({ id: 'interactive', label: 'Stop here and prompt myself', mode: 'agent' }),
|
|
24
|
+
Object.freeze({ id: 'exit_only', label: 'Stop here', mode: 'agent' }),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** The actions that take the session out of plan mode for good. */
|
|
28
|
+
export const PLAN_BOARD_LEAVES_PLAN = Object.freeze(['autopilot', 'autopilot_fleet', 'interactive']);
|
|
29
|
+
|
|
30
|
+
export const PLAN_BOARD_LABELS = Object.freeze({
|
|
31
|
+
approve_only: 'Approve plan',
|
|
32
|
+
autopilot: 'Implement in autopilot',
|
|
33
|
+
autopilot_fleet: 'Implement with autopilot fleet',
|
|
34
|
+
interactive: 'Stop here and prompt myself',
|
|
35
|
+
exit_only: 'Stop here',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const FOLLOWUP_TEXTS = Object.freeze({
|
|
39
|
+
approve_only: 'Plan approved by the user. Do not implement. Finish only the plan-only steps you were given, then stop.',
|
|
40
|
+
autopilot: 'Please implement the approved plan now.',
|
|
41
|
+
autopilot_fleet: 'Please implement the approved plan using autopilot_fleet where useful.',
|
|
42
|
+
interactive: 'I approved the plan. Let\'s proceed interactively with implementation.',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const FOLLOWUP_MODES = Object.freeze({
|
|
46
|
+
approve_only: 'agent',
|
|
47
|
+
autopilot: 'autopilot',
|
|
48
|
+
autopilot_fleet: 'autopilot',
|
|
49
|
+
interactive: 'agent',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function actionId(value) {
|
|
53
|
+
return String(value || '').trim().toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether `id` is one of the actions this table knows. */
|
|
57
|
+
export function isKnownPlanBoardAction(id) {
|
|
58
|
+
return Object.prototype.hasOwnProperty.call(PLAN_BOARD_LABELS, actionId(id));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The button's label; an id the table does not know is spelled out. */
|
|
62
|
+
export function planBoardActionLabel(id) {
|
|
63
|
+
const key = actionId(id);
|
|
64
|
+
if (Object.prototype.hasOwnProperty.call(PLAN_BOARD_LABELS, key)) return PLAN_BOARD_LABELS[key];
|
|
65
|
+
return key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase()) || 'Select';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The message the action queues, or '' when it queues none. */
|
|
69
|
+
export function planBoardFollowupText(id) {
|
|
70
|
+
const key = actionId(id);
|
|
71
|
+
return Object.prototype.hasOwnProperty.call(FOLLOWUP_TEXTS, key) ? FOLLOWUP_TEXTS[key] : '';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The relay mode the follow-up runs in, or null for an action without one. */
|
|
75
|
+
export function planBoardFollowupMode(id) {
|
|
76
|
+
const key = actionId(id);
|
|
77
|
+
return Object.prototype.hasOwnProperty.call(FOLLOWUP_MODES, key) ? FOLLOWUP_MODES[key] : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `actions` with "Approve plan" first when it is missing. The others keep
|
|
82
|
+
* their order and their entries (a CLI's own list, a card stored before the
|
|
83
|
+
* button existed). Entries may be ids or `{ id }` objects.
|
|
84
|
+
*/
|
|
85
|
+
export function withApproveOnly(actions) {
|
|
86
|
+
const list = Array.isArray(actions) ? actions : [];
|
|
87
|
+
const has = list.some((entry) => actionId(typeof entry === 'string' ? entry : entry?.id) === PLAN_BOARD_APPROVE_ONLY);
|
|
88
|
+
if (has) return list.slice();
|
|
89
|
+
return [{ ...PLAN_BOARD_ACTIONS[0] }, ...list];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Whether a card is a pending plan card, the one kind "Approve plan" is added to. */
|
|
93
|
+
export function isPendingPlanBoard(board) {
|
|
94
|
+
return String(board?.boardType || '').trim().toLowerCase() === 'plan_ready'
|
|
95
|
+
&& (String(board?.status || 'pending').trim().toLowerCase() || 'pending') === 'pending';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The actions a card shows: a pending plan card gets "Approve plan" even when
|
|
100
|
+
* it was stored before the button existed. A settled card shows what it was
|
|
101
|
+
* stored with.
|
|
102
|
+
*/
|
|
103
|
+
export function planBoardShownActions(board, actions = board?.actions) {
|
|
104
|
+
return isPendingPlanBoard(board) ? withApproveOnly(actions) : (Array.isArray(actions) ? actions : []);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The card's actions from the Copilot CLI's own `exit_plan_mode` arguments
|
|
109
|
+
* (ids or `{ id | actionId | value }` entries), in the CLI's order, each id
|
|
110
|
+
* once, with "Approve plan" added. Without any, the shared list. An id the
|
|
111
|
+
* table does not know keeps no mode: pressing it only settles the card.
|
|
112
|
+
*/
|
|
113
|
+
export function planBoardActionsFromCli(rawActions) {
|
|
114
|
+
const ids = Array.isArray(rawActions)
|
|
115
|
+
? rawActions
|
|
116
|
+
.map((entry) => {
|
|
117
|
+
if (typeof entry === 'string') return actionId(entry);
|
|
118
|
+
if (entry && typeof entry === 'object') return actionId(entry.id || entry.actionId || entry.value);
|
|
119
|
+
return '';
|
|
120
|
+
})
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
: [];
|
|
123
|
+
if (!ids.length) return PLAN_BOARD_ACTIONS.map((entry) => ({ ...entry }));
|
|
124
|
+
const unique = [...new Set(ids)];
|
|
125
|
+
return withApproveOnly(unique.map((id) => ({
|
|
126
|
+
id,
|
|
127
|
+
label: planBoardActionLabel(id),
|
|
128
|
+
mode: id === 'exit_only' ? 'agent' : planBoardFollowupMode(id),
|
|
129
|
+
})));
|
|
130
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan-only sessions: a conversation flagged `plan_only` stays in
|
|
3
|
+
* plan mode as far as agents go. A relay_tool send or update_session that
|
|
4
|
+
* names another mode is refused with 409 and this code; the owner's own
|
|
5
|
+
* controls still change the mode, and clearing the flag frees the session.
|
|
6
|
+
* The lock protects against mistakes, not against an agent that clears it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { planBoardFollowupMode, planBoardFollowupText } from './plan-board-actions.mjs';
|
|
10
|
+
|
|
11
|
+
export const PLAN_ONLY_ERROR_CODE = 'plan_only';
|
|
12
|
+
|
|
13
|
+
export const PLAN_ONLY_REFUSAL_TEXT = 'This session is plan-only; agents can\'t change its mode. Clear planOnly first, or ask the user.';
|
|
14
|
+
|
|
15
|
+
export const PLAN_ONLY_REFUSAL = Object.freeze({ ok: false, code: PLAN_ONLY_ERROR_CODE, error: PLAN_ONLY_REFUSAL_TEXT });
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether naming `mode` is refused on a plan-only session: any mode but plan,
|
|
19
|
+
* one the relay does not know included (it must not fall back to a writing
|
|
20
|
+
* mode). No mode named refuses nothing.
|
|
21
|
+
*/
|
|
22
|
+
export function planOnlyRefusesMode(mode) {
|
|
23
|
+
const text = String(mode ?? '').trim().toLowerCase();
|
|
24
|
+
return !!text && text !== 'plan';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The mode a turn cut off (a usage limit, a relay restart) goes on in. As it
|
|
29
|
+
* ran, except on a plan-only session: there only the "Approve plan" turn
|
|
30
|
+
* finishes in the agent mode it ran in (the same single turn); any other
|
|
31
|
+
* goes on in plan. The session's saved mode is not touched either way.
|
|
32
|
+
* The board does not store the message it queued, so that turn is known by
|
|
33
|
+
* both its exact line and its agent mode; anything else goes on in plan.
|
|
34
|
+
*/
|
|
35
|
+
export function resumeRelayMode({ relayMode = '', text = '', planOnly = false } = {}) {
|
|
36
|
+
const stored = String(relayMode || '').trim().toLowerCase();
|
|
37
|
+
const mode = stored || 'agent';
|
|
38
|
+
if (!planOnly || mode === 'plan') return mode;
|
|
39
|
+
// The mode as stored: the board stores agent, never an empty one.
|
|
40
|
+
const approveTurn = stored === planBoardFollowupMode('approve_only')
|
|
41
|
+
&& String(text || '').trim() === planBoardFollowupText('approve_only');
|
|
42
|
+
return approveTurn ? mode : 'plan';
|
|
43
|
+
}
|
package/shared/relay-asks.mjs
CHANGED
|
@@ -35,6 +35,9 @@ export const RELAY_ASK_CLOSED_STATUSES = Object.freeze(['answered', 'dismissed']
|
|
|
35
35
|
// Socket event the relay sends on every change of an ask (`{ ask }`) and
|
|
36
36
|
// of the Inbox's count (`{ pending }`).
|
|
37
37
|
export const RELAY_ASK_SOCKET_EVENT = 'relay_ask_updated';
|
|
38
|
+
// Socket event (no payload) the relay sends when the pending items of its
|
|
39
|
+
// paired relays' Inboxes changed (remote-inbox-watch-service.mjs).
|
|
40
|
+
export const INBOX_REMOTE_CHANGED_EVENT = 'inbox_remote_changed';
|
|
38
41
|
|
|
39
42
|
export const RELAY_ASK_LIMITS = Object.freeze({
|
|
40
43
|
questionMax: 500,
|
package/shared/relay-events.mjs
CHANGED
|
@@ -33,6 +33,9 @@ export const RELAY_EVENT_TYPES = Object.freeze([
|
|
|
33
33
|
'task_failed',
|
|
34
34
|
'stalled',
|
|
35
35
|
'all_idle',
|
|
36
|
+
// The user settled a plan card (board) of the session: its action, and the
|
|
37
|
+
// message it queued (none for exit_only).
|
|
38
|
+
'board_settled',
|
|
36
39
|
]);
|
|
37
40
|
|
|
38
41
|
// Not one a subscription names in `events`: `heartbeat_minutes` turns it on.
|
|
@@ -97,6 +100,26 @@ export const RELAY_EVENT_LEGACY_DEFAULT_TYPES = Object.freeze([
|
|
|
97
100
|
'all_idle',
|
|
98
101
|
]);
|
|
99
102
|
|
|
103
|
+
// The default from subagent_finished until board_settled (0.9.19): a
|
|
104
|
+
// subscription made without events then stored exactly this list, and takes
|
|
105
|
+
// today's default as well. Unlike the older one, a watcher who names exactly
|
|
106
|
+
// this list now gets it read as the default too (board_settled added): there
|
|
107
|
+
// is no type to add beside it that changes nothing.
|
|
108
|
+
export const RELAY_EVENT_PRE_BOARD_DEFAULT_TYPES = Object.freeze([
|
|
109
|
+
'question_asked',
|
|
110
|
+
'error',
|
|
111
|
+
'turn_end',
|
|
112
|
+
'subagent_finished',
|
|
113
|
+
'task_failed',
|
|
114
|
+
'stalled',
|
|
115
|
+
'all_idle',
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
function sameTypeSet(types, reference) {
|
|
119
|
+
const unique = new Set((Array.isArray(types) ? types : []).map(toText).filter(Boolean));
|
|
120
|
+
return unique.size === reference.length && reference.every((type) => unique.has(type));
|
|
121
|
+
}
|
|
122
|
+
|
|
100
123
|
/**
|
|
101
124
|
* The events a stored subscription takes. A list that is exactly the old
|
|
102
125
|
* default (any order, no more, no less) was the default, not a choice, and
|
|
@@ -106,14 +129,14 @@ export const RELAY_EVENT_LEGACY_DEFAULT_TYPES = Object.freeze([
|
|
|
106
129
|
*/
|
|
107
130
|
export function effectiveRelayEventTypes(types) {
|
|
108
131
|
const list = Array.isArray(types) ? types.map(toText).filter(Boolean) : [];
|
|
109
|
-
return isLegacyDefaultRelayEventTypes(list)
|
|
132
|
+
return isLegacyDefaultRelayEventTypes(list) || sameTypeSet(list, RELAY_EVENT_PRE_BOARD_DEFAULT_TYPES)
|
|
133
|
+
? [...RELAY_EVENT_DEFAULT_TYPES]
|
|
134
|
+
: list;
|
|
110
135
|
}
|
|
111
136
|
|
|
112
137
|
/** Whether a list is exactly the old default as a set (any order, repeats ignored). */
|
|
113
138
|
export function isLegacyDefaultRelayEventTypes(types) {
|
|
114
|
-
|
|
115
|
-
return unique.size === RELAY_EVENT_LEGACY_DEFAULT_TYPES.length
|
|
116
|
-
&& RELAY_EVENT_LEGACY_DEFAULT_TYPES.every((type) => unique.has(type));
|
|
139
|
+
return sameTypeSet(types, RELAY_EVENT_LEGACY_DEFAULT_TYPES);
|
|
117
140
|
}
|
|
118
141
|
|
|
119
142
|
/**
|
|
@@ -403,6 +426,9 @@ export function relayEventMatchText(event) {
|
|
|
403
426
|
if (event?.type === 'task_finished' || event?.type === 'task_failed') {
|
|
404
427
|
return [detail.description, detail.summary].map(toText).filter(Boolean).join('\n');
|
|
405
428
|
}
|
|
429
|
+
if (event?.type === 'board_settled') {
|
|
430
|
+
return [detail.action, detail.actionLabel, detail.title].map(toText).filter(Boolean).join('\n');
|
|
431
|
+
}
|
|
406
432
|
return null;
|
|
407
433
|
}
|
|
408
434
|
|
|
@@ -576,6 +602,14 @@ export function formatRelayEventLine(event) {
|
|
|
576
602
|
const what = oneLine(detail.what, 80) || 'work';
|
|
577
603
|
return `${head}: no activity for ${Number.isFinite(minutes) ? minutes : '?'} min while ${what} is still running`;
|
|
578
604
|
}
|
|
605
|
+
case 'board_settled': {
|
|
606
|
+
const action = oneLine(detail.action, 80);
|
|
607
|
+
const label = oneLine(detail.actionLabel, 120) || action || 'settled';
|
|
608
|
+
const ids = [action ? `action ${action}` : '', oneLine(detail.boardId, 80) ? `board_id ${oneLine(detail.boardId, 80)}` : ''].filter(Boolean);
|
|
609
|
+
// exit_only queues nothing: the session stops there.
|
|
610
|
+
ids.push(oneLine(detail.queuedMessageId, 80) ? `queued message ${oneLine(detail.queuedMessageId, 80)}` : 'nothing queued');
|
|
611
|
+
return `${head} ${quote(detail.title || 'plan card', 200)} → ${quote(label, 120)} (${ids.join(', ')})`;
|
|
612
|
+
}
|
|
579
613
|
default:
|
|
580
614
|
return head;
|
|
581
615
|
}
|