@oar-sh/oar 0.9.1 → 0.9.3
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/extension.mjs +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- package/server/services/fixtures/grok-stub.sh +0 -86
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
function normalizeText(value) {
|
|
2
|
+
const text = String(value || "").trim();
|
|
3
|
+
return text || null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function normalizePositiveInt(value) {
|
|
7
|
+
const numeric = Number(value);
|
|
8
|
+
if (!Number.isFinite(numeric)) return null;
|
|
9
|
+
const intValue = Math.trunc(numeric);
|
|
10
|
+
return intValue > 0 ? intValue : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function resolveSocketStateValue(socket, stateName, fallback) {
|
|
14
|
+
const candidates = [
|
|
15
|
+
socket?.[stateName],
|
|
16
|
+
socket?.constructor?.[stateName],
|
|
17
|
+
globalThis?.WebSocket?.[stateName],
|
|
18
|
+
];
|
|
19
|
+
for (const value of candidates) {
|
|
20
|
+
const numeric = Number(value);
|
|
21
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
22
|
+
}
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toWebSocketUrl(serverUrl, token, getSessionId, getPid) {
|
|
27
|
+
const source = new URL(String(serverUrl || "http://localhost:3333"));
|
|
28
|
+
source.protocol = source.protocol === "https:" ? "wss:" : "ws:";
|
|
29
|
+
source.pathname = "/api/session-worker/ws";
|
|
30
|
+
source.search = "";
|
|
31
|
+
if (token) source.searchParams.set("token", token);
|
|
32
|
+
const sessionId = normalizeText(typeof getSessionId === "function" ? getSessionId() : null);
|
|
33
|
+
if (sessionId) source.searchParams.set("sessionId", sessionId);
|
|
34
|
+
const pid = normalizePositiveInt(typeof getPid === "function" ? getPid() : null);
|
|
35
|
+
if (pid) source.searchParams.set("pid", String(pid));
|
|
36
|
+
return source.toString();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createWorkerWebSocketLink({
|
|
40
|
+
serverUrl,
|
|
41
|
+
token,
|
|
42
|
+
dbg = () => {},
|
|
43
|
+
onDeliver = async () => {},
|
|
44
|
+
// Fire-and-forget control pushes from the server (e.g. stop a background
|
|
45
|
+
// task). Never blocks delivery; failures are the handler's to log.
|
|
46
|
+
onControl = () => {},
|
|
47
|
+
getSessionReady = () => false,
|
|
48
|
+
// Mid-turn steering opt-in. A worker whose runner can absorb another
|
|
49
|
+
// delivered message into the live turn (the Claude session runner; the
|
|
50
|
+
// Copilot runner's steerIntoActiveTurn is compatible but not yet opted in)
|
|
51
|
+
// reports so here, and the link then keeps signalling readiness while a
|
|
52
|
+
// delivery is still in flight and runs the resulting concurrent delivery
|
|
53
|
+
// instead of coalescing it. Workers that leave this unset keep the strict
|
|
54
|
+
// single-flight contract.
|
|
55
|
+
getSteeringReady = null,
|
|
56
|
+
// Steering-capable workers only: true while the runner is holding deliveries
|
|
57
|
+
// (a question card or plan approval is open, a compaction or adoption is in
|
|
58
|
+
// progress, a turn-opening delivery is mid-flight). A held worker never
|
|
59
|
+
// signals readiness — idle or not — and tells the server so with
|
|
60
|
+
// worker.unready, because a delivery landing in a hold is handed back.
|
|
61
|
+
getDeliveryHeld = null,
|
|
62
|
+
// Called with the hello's reason once per connection, on the relay's first
|
|
63
|
+
// server.hello: from then on the relay (possibly a restarted one that knows
|
|
64
|
+
// nothing of this worker) has the socket. Later hellos only ack the
|
|
65
|
+
// worker.hello every idle readiness refresh re-sends, and are not reported.
|
|
66
|
+
onServerHello = () => {},
|
|
67
|
+
getSessionId = () => null,
|
|
68
|
+
getPid = () => null,
|
|
69
|
+
minBackoffMs = 1000,
|
|
70
|
+
maxBackoffMs = 8_000,
|
|
71
|
+
jitterMs = 250,
|
|
72
|
+
readyRefreshMs = 10_000,
|
|
73
|
+
staleConnectionMs = 30_000,
|
|
74
|
+
WebSocketImpl = globalThis.WebSocket,
|
|
75
|
+
setTimeoutImpl = setTimeout,
|
|
76
|
+
clearTimeoutImpl = clearTimeout,
|
|
77
|
+
setIntervalImpl = setInterval,
|
|
78
|
+
clearIntervalImpl = clearInterval,
|
|
79
|
+
now = () => Date.now(),
|
|
80
|
+
} = {}) {
|
|
81
|
+
let ws = null;
|
|
82
|
+
let stopped = false;
|
|
83
|
+
let reconnectTimer = null;
|
|
84
|
+
let readyRefreshTimer = null;
|
|
85
|
+
let reconnectAttempt = 0;
|
|
86
|
+
let reconnectDelayMs = 0;
|
|
87
|
+
// Insertion-ordered: the first entry is the oldest in-flight delivery, which
|
|
88
|
+
// is what a legacy worker's coalesced delivery waits on.
|
|
89
|
+
const deliveriesInFlight = new Set();
|
|
90
|
+
// message id → in-flight delivery. Only a redelivery of the SAME message is
|
|
91
|
+
// coalesced; a different message is never folded onto another's promise.
|
|
92
|
+
const deliveriesById = new Map();
|
|
93
|
+
let lastOpenAt = null;
|
|
94
|
+
let lastMessageAt = null;
|
|
95
|
+
let lastHelloSentAt = null;
|
|
96
|
+
let lastReadySentAt = null;
|
|
97
|
+
let lastUnreadySentAt = null;
|
|
98
|
+
// What the connected relay advertised in server.hello (see
|
|
99
|
+
// WORKER_PROTOCOL_CAPABILITIES in the relay's worker websocket service).
|
|
100
|
+
let serverCapabilities = new Set();
|
|
101
|
+
let lastPingSentAt = null;
|
|
102
|
+
let lastPongAt = null;
|
|
103
|
+
let lastQueueChangedAt = null;
|
|
104
|
+
let lastDeliveryAt = null;
|
|
105
|
+
|
|
106
|
+
function getNowMs() {
|
|
107
|
+
const value = Number(now());
|
|
108
|
+
return Number.isFinite(value) ? value : Date.now();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isSocketOpen() {
|
|
112
|
+
return !!ws && ws.readyState === resolveSocketStateValue(ws, "OPEN", 1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function send(payload) {
|
|
116
|
+
if (!isSocketOpen()) return false;
|
|
117
|
+
try {
|
|
118
|
+
ws.send(JSON.stringify(payload));
|
|
119
|
+
return true;
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function steeringCapable() {
|
|
126
|
+
return typeof getSteeringReady === "function";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function steeringReady() {
|
|
130
|
+
if (!steeringCapable()) return false;
|
|
131
|
+
try {
|
|
132
|
+
return getSteeringReady() === true;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function deliveryHeld() {
|
|
139
|
+
if (typeof getDeliveryHeld !== "function") return false;
|
|
140
|
+
try {
|
|
141
|
+
return getDeliveryHeld() === true;
|
|
142
|
+
} catch {
|
|
143
|
+
// Fail closed: a held worker that signals ready bounces every delivery.
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function notifyHello(reason = "worker-hello") {
|
|
149
|
+
if (!getSessionReady()) return false;
|
|
150
|
+
// The server treats hello as readiness; a held worker still has to bind
|
|
151
|
+
// its identity, so it says hello without claiming to be ready.
|
|
152
|
+
const held = deliveryHeld();
|
|
153
|
+
const sent = send({
|
|
154
|
+
type: "worker.hello",
|
|
155
|
+
reason,
|
|
156
|
+
sessionId: normalizeText(getSessionId()),
|
|
157
|
+
pid: normalizePositiveInt(getPid()),
|
|
158
|
+
...(held ? { ready: false } : {}),
|
|
159
|
+
});
|
|
160
|
+
if (sent) lastHelloSentAt = getNowMs();
|
|
161
|
+
return sent;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function notifyUnready(reason = "worker-unready") {
|
|
165
|
+
if (!getSessionReady()) return false;
|
|
166
|
+
const sent = send({
|
|
167
|
+
type: "worker.unready",
|
|
168
|
+
reason,
|
|
169
|
+
sessionId: normalizeText(getSessionId()),
|
|
170
|
+
pid: normalizePositiveInt(getPid()),
|
|
171
|
+
});
|
|
172
|
+
if (sent) lastUnreadySentAt = getNowMs();
|
|
173
|
+
return sent;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function notifyReady(reason = "worker-ready") {
|
|
177
|
+
if (!getSessionReady()) return false;
|
|
178
|
+
if (deliveryHeld()) return false;
|
|
179
|
+
if (deliveriesInFlight.size && !steeringReady()) return false;
|
|
180
|
+
const sent = send({
|
|
181
|
+
type: "worker.ready",
|
|
182
|
+
reason,
|
|
183
|
+
sessionId: normalizeText(getSessionId()),
|
|
184
|
+
pid: normalizePositiveInt(getPid()),
|
|
185
|
+
});
|
|
186
|
+
if (sent) lastReadySentAt = getNowMs();
|
|
187
|
+
return sent;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function notifyPing(reason = "readiness-refresh") {
|
|
191
|
+
if (!getSessionReady()) return false;
|
|
192
|
+
const sent = send({
|
|
193
|
+
type: "worker.ping",
|
|
194
|
+
reason,
|
|
195
|
+
sessionId: normalizeText(getSessionId()),
|
|
196
|
+
pid: normalizePositiveInt(getPid()),
|
|
197
|
+
});
|
|
198
|
+
if (sent) lastPingSentAt = getNowMs();
|
|
199
|
+
return sent;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function deliverPending(pending, reason = "queue-deliver") {
|
|
203
|
+
if (!getSessionReady()) return false;
|
|
204
|
+
// A redelivery of a message that is already running (socket reconnect)
|
|
205
|
+
// coalesces onto it rather than double-running the turn.
|
|
206
|
+
const messageId = normalizeText(pending?.message?.id);
|
|
207
|
+
if (messageId && deliveriesById.has(messageId)) return deliveriesById.get(messageId);
|
|
208
|
+
// A DIFFERENT message while one is in flight: a steering-capable worker's
|
|
209
|
+
// runner decides — it steers the message into the live turn, or hands it
|
|
210
|
+
// back to the queue when the turn is holding (the server's readiness can
|
|
211
|
+
// lag a hold by a round-trip). Coalescing it here used to drop its payload
|
|
212
|
+
// and strand the row `processing` until heartbeat recovery. Workers
|
|
213
|
+
// without the steering opt-in keep strict single-flight: their runners
|
|
214
|
+
// have no hand-back path, and the heartbeat recovers the row.
|
|
215
|
+
if (deliveriesInFlight.size && !steeringCapable()) {
|
|
216
|
+
return deliveriesInFlight.values().next().value;
|
|
217
|
+
}
|
|
218
|
+
const delivery = Promise.resolve()
|
|
219
|
+
.then(() => onDeliver(pending, reason))
|
|
220
|
+
.catch((error) => {
|
|
221
|
+
dbg("worker ws delivery failed", reason, error?.message || String(error));
|
|
222
|
+
return false;
|
|
223
|
+
})
|
|
224
|
+
.finally(() => {
|
|
225
|
+
lastDeliveryAt = getNowMs();
|
|
226
|
+
deliveriesInFlight.delete(delivery);
|
|
227
|
+
if (messageId && deliveriesById.get(messageId) === delivery) deliveriesById.delete(messageId);
|
|
228
|
+
});
|
|
229
|
+
deliveriesInFlight.add(delivery);
|
|
230
|
+
if (messageId) deliveriesById.set(messageId, delivery);
|
|
231
|
+
return delivery;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function clearReconnectTimer() {
|
|
235
|
+
if (!reconnectTimer) return;
|
|
236
|
+
clearTimeoutImpl(reconnectTimer);
|
|
237
|
+
reconnectTimer = null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function clearReadyRefreshTimer() {
|
|
241
|
+
if (!readyRefreshTimer) return;
|
|
242
|
+
clearIntervalImpl(readyRefreshTimer);
|
|
243
|
+
readyRefreshTimer = null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function closeStaleSocket(reason) {
|
|
247
|
+
if (!isSocketOpen()) return false;
|
|
248
|
+
dbg("worker ws stale connection closing", reason);
|
|
249
|
+
try { ws?.close?.(); } catch {}
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function refreshReadiness(reason = "readiness-refresh") {
|
|
254
|
+
if (stopped || !getSessionReady() || !isSocketOpen()) return false;
|
|
255
|
+
const nowMs = getNowMs();
|
|
256
|
+
const staleMs = Math.max(0, Number(staleConnectionMs) || 0);
|
|
257
|
+
if (staleMs > 0 && lastMessageAt && (nowMs - lastMessageAt) > staleMs) {
|
|
258
|
+
return closeStaleSocket(`no-server-message:${reason}`);
|
|
259
|
+
}
|
|
260
|
+
notifyPing(reason);
|
|
261
|
+
if (deliveryHeld()) {
|
|
262
|
+
// Re-asserted on every refresh: a ready frame that crossed the hold on
|
|
263
|
+
// the wire would otherwise leave the server's readiness stuck on.
|
|
264
|
+
notifyUnready(reason);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
if (deliveriesInFlight.size) {
|
|
268
|
+
// notifyReady gates itself: it only goes through mid-delivery when the
|
|
269
|
+
// worker is steering-ready. Hello stays an idle-only affair.
|
|
270
|
+
void notifyReady(reason);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
notifyHello(reason);
|
|
274
|
+
void notifyReady(reason);
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function startReadyRefreshTimer() {
|
|
279
|
+
clearReadyRefreshTimer();
|
|
280
|
+
const intervalMs = Math.max(1000, Number(readyRefreshMs) || 10_000);
|
|
281
|
+
readyRefreshTimer = setIntervalImpl(() => {
|
|
282
|
+
refreshReadiness("readiness-refresh");
|
|
283
|
+
}, intervalMs);
|
|
284
|
+
if (typeof readyRefreshTimer?.unref === "function") readyRefreshTimer.unref();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function scheduleReconnect() {
|
|
288
|
+
if (stopped) return;
|
|
289
|
+
clearReconnectTimer();
|
|
290
|
+
reconnectAttempt += 1;
|
|
291
|
+
const backoff = Math.min(maxBackoffMs, minBackoffMs * (2 ** Math.max(0, reconnectAttempt - 1)));
|
|
292
|
+
const jitter = Math.floor(Math.random() * Math.max(0, jitterMs));
|
|
293
|
+
reconnectDelayMs = Math.min(maxBackoffMs, backoff + jitter);
|
|
294
|
+
dbg("worker ws reconnect scheduled", `attempt=${reconnectAttempt}`, `delayMs=${reconnectDelayMs}`);
|
|
295
|
+
reconnectTimer = setTimeoutImpl(() => {
|
|
296
|
+
reconnectTimer = null;
|
|
297
|
+
connect();
|
|
298
|
+
}, reconnectDelayMs);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function connect() {
|
|
302
|
+
if (stopped) return;
|
|
303
|
+
if (ws) {
|
|
304
|
+
const openState = resolveSocketStateValue(ws, "OPEN", 1);
|
|
305
|
+
const connectingState = resolveSocketStateValue(ws, "CONNECTING", 0);
|
|
306
|
+
if (ws.readyState === openState || ws.readyState === connectingState) return;
|
|
307
|
+
}
|
|
308
|
+
if (typeof WebSocketImpl !== "function") {
|
|
309
|
+
dbg("worker ws unavailable: global WebSocket is missing");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const url = toWebSocketUrl(serverUrl, token, getSessionId, getPid);
|
|
313
|
+
ws = new WebSocketImpl(url);
|
|
314
|
+
let serverHelloSeen = false;
|
|
315
|
+
ws.addEventListener("open", () => {
|
|
316
|
+
const openedAt = getNowMs();
|
|
317
|
+
reconnectAttempt = 0;
|
|
318
|
+
reconnectDelayMs = 0;
|
|
319
|
+
lastOpenAt = openedAt;
|
|
320
|
+
lastMessageAt = openedAt;
|
|
321
|
+
dbg("worker ws connected");
|
|
322
|
+
notifyHello("ws-open");
|
|
323
|
+
void notifyReady("ws-open");
|
|
324
|
+
startReadyRefreshTimer();
|
|
325
|
+
});
|
|
326
|
+
ws.addEventListener("message", (event) => {
|
|
327
|
+
lastMessageAt = getNowMs();
|
|
328
|
+
let payload = null;
|
|
329
|
+
try {
|
|
330
|
+
payload = JSON.parse(String(event?.data || ""));
|
|
331
|
+
} catch {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (payload?.type === "queue.deliver") {
|
|
335
|
+
void deliverPending(payload.pending || null, String(payload.reason || "queue-deliver"))
|
|
336
|
+
.finally(() => notifyReady("delivery-complete"));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (payload?.type === "server.hello") {
|
|
340
|
+
serverCapabilities = new Set(Array.isArray(payload.capabilities) ? payload.capabilities.map(String) : []);
|
|
341
|
+
if (!serverHelloSeen) {
|
|
342
|
+
serverHelloSeen = true;
|
|
343
|
+
try {
|
|
344
|
+
onServerHello(String(payload.reason || "server-hello"));
|
|
345
|
+
} catch (error) {
|
|
346
|
+
dbg("worker ws hello handler failed", error?.message || String(error));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
void notifyReady(String(payload.reason || "server-hello"));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (payload?.type === "server.pong") {
|
|
353
|
+
lastPongAt = getNowMs();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (payload?.type === "server.draining") {
|
|
357
|
+
dbg("worker ws draining", payload.reason || "relay-shutdown");
|
|
358
|
+
try { ws?.close?.(); } catch {}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (payload?.type === "queue.changed") {
|
|
362
|
+
lastQueueChangedAt = getNowMs();
|
|
363
|
+
void notifyReady("queue-changed");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (payload?.type === "worker.control") {
|
|
367
|
+
try {
|
|
368
|
+
void onControl(payload.control || null);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
dbg("worker ws control handler failed", error?.message || String(error));
|
|
371
|
+
}
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (payload?.type === "queue.blocked") {
|
|
375
|
+
dbg("worker ws blocked", payload.reason || "blocked");
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
ws.addEventListener("close", () => {
|
|
379
|
+
clearReadyRefreshTimer();
|
|
380
|
+
// The next connection may reach a different (restarted) relay.
|
|
381
|
+
serverCapabilities = new Set();
|
|
382
|
+
if (stopped) return;
|
|
383
|
+
dbg("worker ws disconnected");
|
|
384
|
+
scheduleReconnect();
|
|
385
|
+
});
|
|
386
|
+
ws.addEventListener("error", (error) => {
|
|
387
|
+
dbg("worker ws error", error?.message || String(error));
|
|
388
|
+
try { ws?.close?.(); } catch {}
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function start() {
|
|
393
|
+
stopped = false;
|
|
394
|
+
connect();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function stop() {
|
|
398
|
+
stopped = true;
|
|
399
|
+
clearReconnectTimer();
|
|
400
|
+
clearReadyRefreshTimer();
|
|
401
|
+
if (ws) {
|
|
402
|
+
try { ws.close(); } catch {}
|
|
403
|
+
ws = null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function status() {
|
|
408
|
+
// Some WebSocket implementations expose OPEN only on the constructor —
|
|
409
|
+
// the same reason every other check here goes through the resolver.
|
|
410
|
+
const connected = !!ws && ws.readyState === resolveSocketStateValue(ws, "OPEN", 1);
|
|
411
|
+
return {
|
|
412
|
+
connected,
|
|
413
|
+
delivering: deliveriesInFlight.size > 0,
|
|
414
|
+
reconnectAttempt,
|
|
415
|
+
reconnectDelayMs,
|
|
416
|
+
lastOpenAt,
|
|
417
|
+
lastMessageAt,
|
|
418
|
+
lastHelloSentAt,
|
|
419
|
+
lastReadySentAt,
|
|
420
|
+
lastUnreadySentAt,
|
|
421
|
+
lastPingSentAt,
|
|
422
|
+
lastPongAt,
|
|
423
|
+
lastQueueChangedAt,
|
|
424
|
+
lastDeliveryAt,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
notifyReady,
|
|
430
|
+
notifyUnready,
|
|
431
|
+
serverSupports: (capability) => serverCapabilities.has(String(capability || "")),
|
|
432
|
+
start,
|
|
433
|
+
stop,
|
|
434
|
+
status,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
// Shared test harness for the Claude session runner suites
|
|
2
|
-
// (claude-session-process.test.mjs and
|
|
3
|
-
// claude-session-process.routes-integration.test.mjs): the scripted SDK
|
|
4
|
-
// stand-ins, SDK message builders, api stub, and runner factory they both use.
|
|
5
|
-
// Not a *.test.mjs file on purpose — the node --test glob must not pick it up.
|
|
6
|
-
|
|
7
|
-
import { createClaudeSessionRunner } from './claude-session-process.mjs';
|
|
8
|
-
|
|
9
|
-
// Keeps the tests off the real `~/.claude/projects`; transcript relocation has
|
|
10
|
-
// its own suite in claude-transcript-relocator.test.mjs.
|
|
11
|
-
export const noopRelocate = () => ({ status: 'skipped' });
|
|
12
|
-
|
|
13
|
-
export const tick = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
-
|
|
15
|
-
export async function waitFor(predicate, { timeoutMs = 3000, label = 'condition' } = {}) {
|
|
16
|
-
const start = Date.now();
|
|
17
|
-
for (;;) {
|
|
18
|
-
const value = predicate();
|
|
19
|
-
if (value) return value;
|
|
20
|
-
if (Date.now() - start > timeoutMs) throw new Error(`waitFor timed out: ${label}`);
|
|
21
|
-
await tick();
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function makeApiStub({ failRoutes = new Set(), continuationIds = [] } = {}) {
|
|
26
|
-
const calls = [];
|
|
27
|
-
let continuationCounter = 0;
|
|
28
|
-
return {
|
|
29
|
-
calls,
|
|
30
|
-
api: async (method, routePath, body) => {
|
|
31
|
-
calls.push({ method, routePath, body });
|
|
32
|
-
if (failRoutes.has(routePath)) throw new Error(`stubbed failure for ${routePath}`);
|
|
33
|
-
if (routePath === '/api/continuation-turn') {
|
|
34
|
-
continuationCounter += 1;
|
|
35
|
-
return { messageId: continuationIds[continuationCounter - 1] || `cont-${continuationCounter}` };
|
|
36
|
-
}
|
|
37
|
-
return { ok: true };
|
|
38
|
-
},
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* A scriptable stand-in for the SDK Query: the test emits SDK messages when it
|
|
44
|
-
* chooses, the stream only ends on endInput/close (like the real CLI), and
|
|
45
|
-
* pushed user turns are recorded (and optionally echoed back as replays).
|
|
46
|
-
* NOTE: the real CLI replays a pushed message when it DEQUEUES it — between
|
|
47
|
-
* turns for queued-behind messages, or mid-turn when it absorbs the push as
|
|
48
|
-
* steering — never at push time. echoPushes replays at push time, so it only
|
|
49
|
-
* models pushes made while no turn is active; scripts that queue a message
|
|
50
|
-
* behind a running turn must emit the replay manually at the dequeue point.
|
|
51
|
-
*/
|
|
52
|
-
export function scriptedTurn({ echoPushes = false } = {}) {
|
|
53
|
-
const queued = [];
|
|
54
|
-
let wake = null;
|
|
55
|
-
let ended = false;
|
|
56
|
-
const turn = {
|
|
57
|
-
pushed: [],
|
|
58
|
-
endInputCalls: 0,
|
|
59
|
-
closed: false,
|
|
60
|
-
interrupts: 0,
|
|
61
|
-
stoppedTasks: [],
|
|
62
|
-
pushUserMessage(content) {
|
|
63
|
-
if (ended) throw new Error('user message stream already ended');
|
|
64
|
-
turn.pushed.push(content);
|
|
65
|
-
if (echoPushes) {
|
|
66
|
-
turn.emit({ type: 'user', parent_tool_use_id: null, message: { role: 'user', content } });
|
|
67
|
-
}
|
|
68
|
-
},
|
|
69
|
-
endInput() {
|
|
70
|
-
turn.endInputCalls += 1;
|
|
71
|
-
ended = true;
|
|
72
|
-
wake?.();
|
|
73
|
-
},
|
|
74
|
-
close() {
|
|
75
|
-
turn.closed = true;
|
|
76
|
-
ended = true;
|
|
77
|
-
wake?.();
|
|
78
|
-
},
|
|
79
|
-
async interrupt() {
|
|
80
|
-
turn.interrupts += 1;
|
|
81
|
-
},
|
|
82
|
-
async stopTask(taskId) {
|
|
83
|
-
turn.stoppedTasks.push(taskId);
|
|
84
|
-
},
|
|
85
|
-
emit(message) {
|
|
86
|
-
queued.push(message);
|
|
87
|
-
wake?.();
|
|
88
|
-
},
|
|
89
|
-
fail(error) {
|
|
90
|
-
queued.push({ __throw: error });
|
|
91
|
-
wake?.();
|
|
92
|
-
},
|
|
93
|
-
async* [Symbol.asyncIterator]() {
|
|
94
|
-
for (;;) {
|
|
95
|
-
while (queued.length) {
|
|
96
|
-
const next = queued.shift();
|
|
97
|
-
if (next?.__throw) throw next.__throw;
|
|
98
|
-
yield next;
|
|
99
|
-
}
|
|
100
|
-
if (ended) return;
|
|
101
|
-
await new Promise((resolve) => { wake = resolve; });
|
|
102
|
-
wake = null;
|
|
103
|
-
}
|
|
104
|
-
},
|
|
105
|
-
};
|
|
106
|
-
return turn;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Emits the given messages, then the stream ends by itself. */
|
|
110
|
-
export function fakeTurn(messages) {
|
|
111
|
-
return {
|
|
112
|
-
pushUserMessage() {},
|
|
113
|
-
endInput() {},
|
|
114
|
-
async* [Symbol.asyncIterator]() {
|
|
115
|
-
for (const message of messages) yield message;
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export function initMessage(sessionId) {
|
|
121
|
-
return { type: 'system', subtype: 'init', session_id: sessionId, model: 'claude-sonnet-5' };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function resultMessage(text, sessionId) {
|
|
125
|
-
return {
|
|
126
|
-
type: 'result', subtype: 'success', is_error: false, result: text, session_id: sessionId, num_turns: 1, duration_api_ms: 100,
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function phantomResultMessage(sessionId) {
|
|
131
|
-
return {
|
|
132
|
-
type: 'result', subtype: 'success', is_error: false, result: '', session_id: sessionId, num_turns: 0, duration_api_ms: 0,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export function backgroundTasksMessage(tasks) {
|
|
137
|
-
return { type: 'system', subtype: 'background_tasks_changed', tasks };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export function taskNotificationMessage(taskId, status = 'completed', { skipTranscript = false } = {}) {
|
|
141
|
-
return {
|
|
142
|
-
type: 'system',
|
|
143
|
-
subtype: 'task_notification',
|
|
144
|
-
task_id: taskId,
|
|
145
|
-
status,
|
|
146
|
-
summary: 'settled',
|
|
147
|
-
skip_transcript: skipTranscript,
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* The live wire shape: snake_case under `compact_metadata`, and `post_tokens`
|
|
153
|
-
* absent on every auto-compact payload observed (conv 563e252e, 2026-08-20).
|
|
154
|
-
*/
|
|
155
|
-
export function compactBoundaryMessage({ preTokens = 614117, postTokens = null, trigger = 'auto' } = {}) {
|
|
156
|
-
return {
|
|
157
|
-
type: 'system',
|
|
158
|
-
subtype: 'compact_boundary',
|
|
159
|
-
compact_metadata: {
|
|
160
|
-
trigger,
|
|
161
|
-
pre_tokens: preTokens,
|
|
162
|
-
...(postTokens === null ? {} : { post_tokens: postTokens }),
|
|
163
|
-
},
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* The CLI's compaction announcement. Emitted ONCE, when compaction starts: the
|
|
169
|
-
* bundle's 30 s re-emit is gated on the remote-control client's activity
|
|
170
|
-
* callback, which the plain SDK `query()` this worker uses never registers.
|
|
171
|
-
*/
|
|
172
|
-
export function compactingStatusMessage(status = 'compacting') {
|
|
173
|
-
return { type: 'system', subtype: 'status', status };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* The INFERRED shape of what re-opens the turn after an auto-compaction: the
|
|
178
|
-
* CLI's own summary message, which matches no delivered entry's text. Read off
|
|
179
|
-
* the on-disk transcript (`isCompactSummary: true`) plus the runner's observed
|
|
180
|
-
* behaviour in conv 563e252e — not captured from the SDK stream. What the
|
|
181
|
-
* tests actually pin is the runner's response to ANY turn-opening user message
|
|
182
|
-
* that follows a compaction and matches nothing pending.
|
|
183
|
-
*/
|
|
184
|
-
export function compactSummaryReplay() {
|
|
185
|
-
return userReplay('This session is being continued from a previous conversation that ran out of context.\n\nSummary:\n1. Primary Request…');
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export function userReplay(text) {
|
|
189
|
-
return { type: 'user', parent_tool_use_id: null, message: { role: 'user', content: [{ type: 'text', text }] } };
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* A settled task's continuation as the SDK actually stamps it: `origin.kind`
|
|
194
|
-
* carries the provenance (SDKMessageOrigin) and `content` is a plain string,
|
|
195
|
-
* not a block array. Deliberately WITHOUT the `<task-notification>` tag in the
|
|
196
|
-
* text, so anything that passes only by tag-sniffing fails here.
|
|
197
|
-
*/
|
|
198
|
-
export function taskNotificationReplay(text = 'The background agent has finished its work.') {
|
|
199
|
-
return {
|
|
200
|
-
type: 'user',
|
|
201
|
-
parent_tool_use_id: null,
|
|
202
|
-
origin: { kind: 'task-notification' },
|
|
203
|
-
message: { role: 'user', content: text },
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export function assistantText(text) {
|
|
208
|
-
return { type: 'assistant', parent_tool_use_id: null, message: { content: [{ type: 'text', text }] } };
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Frozen: tests derive per-test messages via spread ({ ...baseMessage, ... }),
|
|
212
|
-
// never by mutating this object.
|
|
213
|
-
export const baseMessage = Object.freeze({
|
|
214
|
-
id: 'q-1',
|
|
215
|
-
conversationId: 'conv-1',
|
|
216
|
-
relayMode: 'agent',
|
|
217
|
-
text: 'hello',
|
|
218
|
-
model: 'claude-sonnet-5',
|
|
219
|
-
attachments: [],
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
export function makeRunner({ stub, startImpl, ...overrides }) {
|
|
223
|
-
return createClaudeSessionRunner({
|
|
224
|
-
api: stub.api,
|
|
225
|
-
sdkSessionId: 'conv-1',
|
|
226
|
-
cwd: '/tmp',
|
|
227
|
-
relocateTranscriptImpl: noopRelocate,
|
|
228
|
-
startClaudeSessionImpl: startImpl,
|
|
229
|
-
continuationRetryDelayMs: 10,
|
|
230
|
-
...overrides,
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export async function settled(runner) {
|
|
235
|
-
await waitFor(() => !runner._getProcess(), { label: 'process teardown' });
|
|
236
|
-
}
|