@oar-sh/oar 0.9.2 → 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/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +25 -10
- package/package.json +7 -4
- package/server/README.md +1 -1
- package/server/claude-worker/claude-session-process.mjs +981 -44
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +101 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2269 -492
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- package/server/db-schema.mjs +20 -0
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/api-client.js +22 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +34 -8
- package/server/public/app/composer-control-state.mjs +52 -34
- 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-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +919 -194
- 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 +145 -5
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +17 -2
- package/server/public/app/settings-modal.js +8 -6
- package/server/public/app/socket-handlers.js +19 -0
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- 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/index.html +206 -9
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/repositories/message-repository.mjs +61 -18
- package/server/repositories/question-repository.mjs +15 -0
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/messages-routes.mjs +488 -46
- package/server/routes/sessions-routes.mjs +298 -55
- package/server/server-runtime.mjs +65 -25
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- 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/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- 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.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Which of a conversation's processing queue rows is the LIVE turn.
|
|
2
|
+
//
|
|
3
|
+
// Mid-turn steering means a conversation can have more than one row in
|
|
4
|
+
// `processing` at once: the running turn plus a message the user steered in,
|
|
5
|
+
// which the CLI folds into that turn and which produces no stream or activity
|
|
6
|
+
// of its own — or, after a replay handoff, the row that just took the turn
|
|
7
|
+
// over while the handed-off row is still settling. The live turn is the row
|
|
8
|
+
// that produced output MOST RECENTLY; with no output to separate them, the
|
|
9
|
+
// oldest processing row (the turn a steered message joins). Kept pure so the
|
|
10
|
+
// tie-break rules are testable without a DB.
|
|
11
|
+
//
|
|
12
|
+
// Recency, not volume: relay_stream_events.seq is only unique within one
|
|
13
|
+
// queue_message_id, so comparing it across rows picked "the row with the most
|
|
14
|
+
// events" — a long handed-off turn kept the live bubble (and Stop) from the
|
|
15
|
+
// row that had actually taken over.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {Array<{id:string, lastOutputAtMs?:number, streamDone?:boolean, processingAtMs?:number}>} rows
|
|
19
|
+
* Processing rows for one conversation. `lastOutputAtMs` is when the row
|
|
20
|
+
* last wrote a stream snapshot or activity line (0 = never); `streamDone`
|
|
21
|
+
* marks a row whose main stream snapshot is final — a handed-off row
|
|
22
|
+
* settling, whose closing snapshot re-stamps it as the newest write — and
|
|
23
|
+
* ranks it below every row still open; `processingAtMs` is when it entered
|
|
24
|
+
* processing (final tie-break; smaller = older = wins).
|
|
25
|
+
* @returns {string} the live turn's row id, or '' when there are no rows.
|
|
26
|
+
*/
|
|
27
|
+
export function pickLiveTurnRowId(rows = []) {
|
|
28
|
+
const list = Array.isArray(rows) ? rows.filter((row) => row && row.id) : [];
|
|
29
|
+
if (!list.length) return '';
|
|
30
|
+
if (list.length === 1) return String(list[0].id);
|
|
31
|
+
const finite = (value) => (Number.isFinite(Number(value)) ? Number(value) : 0);
|
|
32
|
+
let best = null;
|
|
33
|
+
for (const row of list) {
|
|
34
|
+
if (!best) { best = row; continue; }
|
|
35
|
+
if (Boolean(row.streamDone) !== Boolean(best.streamDone)) {
|
|
36
|
+
if (!row.streamDone) best = row;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const output = finite(row.lastOutputAtMs);
|
|
40
|
+
const bestOutput = finite(best.lastOutputAtMs);
|
|
41
|
+
if (output > bestOutput
|
|
42
|
+
|| (output === bestOutput && finite(row.processingAtMs) < finite(best.processingAtMs))) {
|
|
43
|
+
best = row;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return String(best.id);
|
|
47
|
+
}
|
|
@@ -135,6 +135,21 @@ const MEDIA_EMBED_BLOCK_PATTERN = new RegExp(
|
|
|
135
135
|
'gi',
|
|
136
136
|
);
|
|
137
137
|
|
|
138
|
+
// A steered message's hidden note (shared/steer-note.mjs) leads the prompt the
|
|
139
|
+
// runtime stored, right after any mode marker. Matched by its stable opening
|
|
140
|
+
// words, because the browser copy of this module cannot import shared/.
|
|
141
|
+
const STEER_NOTE_PATTERN = /^\s*\[Sent while you were still working on my previous message\.[^\]\n]*\]\s*/;
|
|
142
|
+
|
|
143
|
+
// The Claude worker's slash-command guard (shared/slash-command-guard.mjs): a
|
|
144
|
+
// zero-width space in front of a "/x" it delivers as text. trim() keeps it, so
|
|
145
|
+
// it is matched on its own, and only where it guards a "/".
|
|
146
|
+
const SLASH_COMMAND_GUARD_PATTERN = /^\u200B(?=\s*\/)/;
|
|
147
|
+
|
|
148
|
+
function stripLeadingRelayNotes(text) {
|
|
149
|
+
const stripped = String(text || '').replace(STEER_NOTE_PATTERN, '').replace(SLASH_COMMAND_GUARD_PATTERN, '');
|
|
150
|
+
return stripped.trim() ? stripped.trim() : String(text || '').trim();
|
|
151
|
+
}
|
|
152
|
+
|
|
138
153
|
export function stripRelayPromptContext(text, relayMode = '', attachments = []) {
|
|
139
154
|
const value = stripAttachmentPromptArtifacts(text)
|
|
140
155
|
.replace(MEDIA_EMBED_BLOCK_PATTERN, '')
|
|
@@ -143,7 +158,7 @@ export function stripRelayPromptContext(text, relayMode = '', attachments = [])
|
|
|
143
158
|
const patterns = buildPromptPrefixPatterns(relayMode);
|
|
144
159
|
for (const pattern of patterns) {
|
|
145
160
|
const stripped = value.replace(pattern, '').trim();
|
|
146
|
-
if (stripped && stripped !== value) return stripped;
|
|
161
|
+
if (stripped && stripped !== value) return stripLeadingRelayNotes(stripped);
|
|
147
162
|
}
|
|
148
|
-
return value;
|
|
163
|
+
return stripLeadingRelayNotes(value);
|
|
149
164
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The `context` block a relay question card carries: who asked, why, for which
|
|
2
|
+
// queue row, and the few presentation hints the card renders from. Workers
|
|
3
|
+
// send it on POST /api/relay-question; it is stored inside the question's
|
|
4
|
+
// request envelope and served back to the client. Anything not listed here is
|
|
5
|
+
// dropped, so a new hint the client reads must be added here too (the
|
|
6
|
+
// multi-select flag was once silently stripped this way).
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {unknown} rawContext
|
|
10
|
+
* @param {{ normalizeRelayMode?: (mode: string) => string | null, defaultRelayMode?: string }} [options]
|
|
11
|
+
*/
|
|
12
|
+
export function sanitizeRelayQuestionContext(rawContext, {
|
|
13
|
+
normalizeRelayMode = (mode) => String(mode || '').trim().toLowerCase() || null,
|
|
14
|
+
defaultRelayMode = 'agent',
|
|
15
|
+
} = {}) {
|
|
16
|
+
if (!rawContext || typeof rawContext !== 'object' || Array.isArray(rawContext)) return null;
|
|
17
|
+
const context = {};
|
|
18
|
+
if (typeof rawContext.source === 'string' && rawContext.source.trim()) {
|
|
19
|
+
context.source = rawContext.source.trim().slice(0, 64);
|
|
20
|
+
}
|
|
21
|
+
if (typeof rawContext.rationale === 'string' && rawContext.rationale.trim()) {
|
|
22
|
+
context.rationale = rawContext.rationale.trim().slice(0, 240);
|
|
23
|
+
}
|
|
24
|
+
if (typeof rawContext.queueMessageId === 'string' && rawContext.queueMessageId.trim()) {
|
|
25
|
+
context.queueMessageId = rawContext.queueMessageId.trim();
|
|
26
|
+
}
|
|
27
|
+
if (typeof rawContext.conversationId === 'string' && rawContext.conversationId.trim()) {
|
|
28
|
+
context.conversationId = rawContext.conversationId.trim();
|
|
29
|
+
}
|
|
30
|
+
if (typeof rawContext.relayMode === 'string' && rawContext.relayMode.trim()) {
|
|
31
|
+
context.relayMode = normalizeRelayMode(rawContext.relayMode) || defaultRelayMode;
|
|
32
|
+
}
|
|
33
|
+
// The card renders checkmarks instead of one-shot buttons when several
|
|
34
|
+
// answers are allowed (Claude's AskUserQuestion `multiSelect`, or the
|
|
35
|
+
// Copilot worker's wording heuristic). Only an explicit `true` is kept.
|
|
36
|
+
if (rawContext.multiSelect === true) context.multiSelect = true;
|
|
37
|
+
// Providers whose ask tool cannot always say so (Copilot) mark their choice
|
|
38
|
+
// cards with this: the card then offers a "Select several" switch, so the
|
|
39
|
+
// user can pick several answers even when the model did not flag it.
|
|
40
|
+
if (rawContext.allowMultiSelect === true) context.allowMultiSelect = true;
|
|
41
|
+
// Claude's short question label ("Workers", "Visibility").
|
|
42
|
+
if (typeof rawContext.header === 'string' && rawContext.header.trim()) {
|
|
43
|
+
context.header = rawContext.header.trim().slice(0, 120);
|
|
44
|
+
}
|
|
45
|
+
return Object.keys(context).length ? context : null;
|
|
46
|
+
}
|
|
@@ -77,7 +77,7 @@ export function createSdkSessionImportService({
|
|
|
77
77
|
logger = console,
|
|
78
78
|
} = {}) {
|
|
79
79
|
if (!db || !stmts || typeof createClient !== 'function') throw new Error('SDK session importer requires database, statements, and a client factory');
|
|
80
|
-
let
|
|
80
|
+
let runtimeCreation = null;
|
|
81
81
|
let activeRun = null;
|
|
82
82
|
let closing = false;
|
|
83
83
|
const countConversationMessages = db.prepare(`
|
|
@@ -149,8 +149,18 @@ export function createSdkSessionImportService({
|
|
|
149
149
|
// Refusing after dispose() is what stops a mid-shutdown import from
|
|
150
150
|
// resurrecting a fresh SDK client the server would never tear down.
|
|
151
151
|
if (closing) throw new Error('SDK session importer is shutting down');
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
// Callers share the start in flight: parallel deletes, or a delete during
|
|
153
|
+
// the startup import, each started a runtime of their own, and only the
|
|
154
|
+
// last one was ever disposed. A start that fails is dropped, so the next
|
|
155
|
+
// call tries again.
|
|
156
|
+
if (!runtimeCreation) {
|
|
157
|
+
const creation = Promise.resolve().then(() => createClient());
|
|
158
|
+
runtimeCreation = creation;
|
|
159
|
+
creation.catch(() => {
|
|
160
|
+
if (runtimeCreation === creation) runtimeCreation = null;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return runtimeCreation;
|
|
154
164
|
}
|
|
155
165
|
|
|
156
166
|
function isTombstoned(sdkSessionId) {
|
|
@@ -329,6 +339,34 @@ export function createSdkSessionImportService({
|
|
|
329
339
|
return activeRun;
|
|
330
340
|
}
|
|
331
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Remove a Copilot session from the runtime's own store, for a conversation
|
|
344
|
+
* deleted in the relay. Goes through the SDK rather than the session-state
|
|
345
|
+
* folder so the runtime's bookkeeping stays consistent. Throws when the
|
|
346
|
+
* runtime cannot do it; the caller decides what that means.
|
|
347
|
+
*/
|
|
348
|
+
async function deleteSession(sdkSessionId) {
|
|
349
|
+
const sid = text(sdkSessionId);
|
|
350
|
+
if (!sid) throw new Error('Missing session id');
|
|
351
|
+
const client = await getRuntime();
|
|
352
|
+
if (typeof client?.client?.deleteSession !== 'function') {
|
|
353
|
+
throw new Error('deleteSession() is unavailable in this Copilot runtime');
|
|
354
|
+
}
|
|
355
|
+
await client.client.deleteSession(sid);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Whether the relay only imported this session and never ran it: the ledger
|
|
360
|
+
* says the importer brought it in, and no queued turn or relay worker says
|
|
361
|
+
* the relay has run it since. Continuing an imported conversation does not
|
|
362
|
+
* touch the ledger, so that evidence is checked here too, and it flips the
|
|
363
|
+
* ledger to 'relay' for good, as it does on import.
|
|
364
|
+
*/
|
|
365
|
+
function isImportedOnly(sdkSessionId) {
|
|
366
|
+
const sid = text(sdkSessionId);
|
|
367
|
+
return !!sid && !relayOwnsSession(sid);
|
|
368
|
+
}
|
|
369
|
+
|
|
332
370
|
async function refreshConversation(conversation) {
|
|
333
371
|
const sdkSessionId = text(conversation?.sdk_session_id || conversation?.sdkSessionId || conversation?.id);
|
|
334
372
|
if (!sdkSessionId || isTombstoned(sdkSessionId)) {
|
|
@@ -343,14 +381,18 @@ export function createSdkSessionImportService({
|
|
|
343
381
|
runStartupImport,
|
|
344
382
|
importSession,
|
|
345
383
|
refreshConversation,
|
|
384
|
+
deleteSession,
|
|
385
|
+
isImportedOnly,
|
|
346
386
|
async dispose() {
|
|
347
387
|
// Flag first: getRuntime() must refuse before the client goes away, or a
|
|
348
|
-
// concurrent import
|
|
349
|
-
//
|
|
388
|
+
// concurrent import finds no runtime and starts a replacement while the
|
|
389
|
+
// server is exiting.
|
|
350
390
|
closing = true;
|
|
351
391
|
try { await activeRun; } catch {}
|
|
352
|
-
|
|
353
|
-
|
|
392
|
+
// A start still in flight is waited for, so its runtime is disposed too.
|
|
393
|
+
const creation = runtimeCreation;
|
|
394
|
+
runtimeCreation = null;
|
|
395
|
+
const current = await creation?.catch(() => null);
|
|
354
396
|
await current?.dispose?.();
|
|
355
397
|
},
|
|
356
398
|
};
|
|
@@ -60,6 +60,54 @@ function normalizeStatus(value) {
|
|
|
60
60
|
return ALLOWED_STATUSES.has(mapped) ? mapped : 'new';
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
const STEERING_HOLD_REASONS = new Set(['question', 'compaction', 'adoption', 'delivery', 'other']);
|
|
64
|
+
const STEERING_MESSAGE_ID_MAX_LENGTH = 64;
|
|
65
|
+
const STEERING_CANCELLABLE_IDS_MAX = 50;
|
|
66
|
+
|
|
67
|
+
// Queue message ids the worker still holds pushed-but-unconsumed (the Copilot
|
|
68
|
+
// SDK's queued lane): trimmed strings only, deduped, capped — a worker cannot
|
|
69
|
+
// grow the status payload without bound.
|
|
70
|
+
function normalizeSteeringCancellableIds(raw) {
|
|
71
|
+
if (!Array.isArray(raw)) return [];
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
const ids = [];
|
|
74
|
+
for (const value of raw) {
|
|
75
|
+
if (typeof value !== 'string') continue;
|
|
76
|
+
const id = value.trim();
|
|
77
|
+
if (!id || id.length > STEERING_MESSAGE_ID_MAX_LENGTH || seen.has(id)) continue;
|
|
78
|
+
seen.add(id);
|
|
79
|
+
ids.push(id);
|
|
80
|
+
if (ids.length >= STEERING_CANCELLABLE_IDS_MAX) break;
|
|
81
|
+
}
|
|
82
|
+
return ids;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The worker's composer-facing steering snapshot, published on its heartbeat.
|
|
86
|
+
// Callers follow the registry convention of spreading the existing entry into
|
|
87
|
+
// upserts, so an update without `steering` keeps the last known snapshot.
|
|
88
|
+
// Exported so the heartbeat route compares apples to apples: normalizing there
|
|
89
|
+
// with different rules would make an unknown holdReason never converge and
|
|
90
|
+
// re-upsert on every heartbeat.
|
|
91
|
+
//
|
|
92
|
+
// `supported` is the worker's opt-in to mid-turn steering and the client
|
|
93
|
+
// composer's only steering gate: a worker that never sets it — the extension
|
|
94
|
+
// path, or a Claude worker old enough to send the 4-field snapshot — keeps
|
|
95
|
+
// "Queue". `cancellableIds` lists the pushed rows the worker can still pull
|
|
96
|
+
// back out of its runtime (un-steer).
|
|
97
|
+
export function normalizeWorkerSteeringSnapshot(raw) {
|
|
98
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
99
|
+
const holdReason = String(raw.holdReason || '').trim().toLowerCase();
|
|
100
|
+
const messageId = String(raw.messageId || '').trim().slice(0, STEERING_MESSAGE_ID_MAX_LENGTH);
|
|
101
|
+
return {
|
|
102
|
+
turnActive: raw.turnActive === true,
|
|
103
|
+
canSteer: raw.canSteer === true,
|
|
104
|
+
holdReason: STEERING_HOLD_REASONS.has(holdReason) ? holdReason : null,
|
|
105
|
+
messageId: messageId || null,
|
|
106
|
+
supported: raw.supported === true,
|
|
107
|
+
cancellableIds: normalizeSteeringCancellableIds(raw.cancellableIds),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
63
111
|
function sanitizeState(raw = null) {
|
|
64
112
|
const nowIso = new Date().toISOString();
|
|
65
113
|
const base = raw && typeof raw === 'object' ? raw : {};
|
|
@@ -74,11 +122,23 @@ function sanitizeState(raw = null) {
|
|
|
74
122
|
queueDepth: toNonNegativeInt(base.queueDepth, 0),
|
|
75
123
|
retryCount: toNonNegativeInt(base.retryCount, 0),
|
|
76
124
|
lastError: String(base.lastError || '').trim() || null,
|
|
125
|
+
steering: normalizeWorkerSteeringSnapshot(base.steering),
|
|
77
126
|
updatedAt: normalizeTimestamp(base.updatedAt, nowIso),
|
|
78
127
|
createdAt,
|
|
79
128
|
};
|
|
80
129
|
}
|
|
81
130
|
|
|
131
|
+
// Statuses that mean the process a snapshot described is gone or not yet
|
|
132
|
+
// replaced by one that has reported. 'error' counts only on the way IN: a
|
|
133
|
+
// live worker marked errored (a requeue retry) keeps heartbeating, and its
|
|
134
|
+
// next snapshot must stick rather than be cleared on every upsert.
|
|
135
|
+
const WORKER_GONE_STATUSES = new Set(['new', 'starting', 'stopped', 'error']);
|
|
136
|
+
|
|
137
|
+
function isWorkerReplacedOrGone(existing, next) {
|
|
138
|
+
if (existing.pid && next.pid !== existing.pid) return true;
|
|
139
|
+
return next.status !== existing.status && WORKER_GONE_STATUSES.has(next.status);
|
|
140
|
+
}
|
|
141
|
+
|
|
82
142
|
function toSnapshot(entry) {
|
|
83
143
|
return Object.freeze({ ...entry });
|
|
84
144
|
}
|
|
@@ -163,12 +223,26 @@ export function createSessionWorkerRegistry() {
|
|
|
163
223
|
createdAt: existing?.createdAt || state.createdAt,
|
|
164
224
|
updatedAt: new Date().toISOString(),
|
|
165
225
|
});
|
|
226
|
+
// A steering snapshot describes one live worker process. Callers spread
|
|
227
|
+
// the old entry into every upsert, so without this a crashed worker's
|
|
228
|
+
// hold outlived it and the composer showed it until a replacement
|
|
229
|
+
// heartbeated — indefinitely, if none ever did.
|
|
230
|
+
if (existing && isWorkerReplacedOrGone(existing, next)) next.steering = null;
|
|
166
231
|
clearIndexesForSession(existing);
|
|
167
232
|
bySession.set(state.sdkSessionId, next);
|
|
168
233
|
updateIndexesForSession(next);
|
|
169
234
|
return toSnapshot(next);
|
|
170
235
|
}
|
|
171
236
|
|
|
237
|
+
/** Drop a session's steering snapshot (its worker process is known dead). */
|
|
238
|
+
function clearSteering(sdkSessionId) {
|
|
239
|
+
const sessionId = normalizeSessionId(sdkSessionId);
|
|
240
|
+
const existing = sessionId ? bySession.get(sessionId) : null;
|
|
241
|
+
if (!existing?.steering) return false;
|
|
242
|
+
bySession.set(sessionId, { ...existing, steering: null, updatedAt: new Date().toISOString() });
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
172
246
|
function removeWorker(sdkSessionId) {
|
|
173
247
|
const sessionId = normalizeSessionId(sdkSessionId);
|
|
174
248
|
if (!sessionId) return false;
|
|
@@ -224,6 +298,7 @@ export function createSessionWorkerRegistry() {
|
|
|
224
298
|
getWorkerByRuntimeSessionId,
|
|
225
299
|
listWorkers,
|
|
226
300
|
upsertWorker,
|
|
301
|
+
clearSteering,
|
|
227
302
|
removeWorker,
|
|
228
303
|
rekeyWorker,
|
|
229
304
|
clearAll,
|
|
@@ -482,10 +482,16 @@ export function createSessionWorkerSupervisor({
|
|
|
482
482
|
if (current && !isTransitionAllowed(current.status, requestedStatus)) {
|
|
483
483
|
return current;
|
|
484
484
|
}
|
|
485
|
+
// The steering snapshot is the worker's to set (its heartbeat) and the
|
|
486
|
+
// registry's to clear (process gone or replaced). Callers build nextState
|
|
487
|
+
// from an older copy of the entry — ensureWorker from the 'starting' one,
|
|
488
|
+
// taken before the new process heartbeated — so passing theirs through
|
|
489
|
+
// would overwrite the snapshot the worker sent in the meantime.
|
|
490
|
+
const { steering: _staleSteering, ...patch } = nextState;
|
|
485
491
|
const next = registry.upsertWorker({
|
|
486
492
|
...(current || {}),
|
|
487
493
|
sdkSessionId: sessionId,
|
|
488
|
-
...
|
|
494
|
+
...patch,
|
|
489
495
|
status: requestedStatus,
|
|
490
496
|
updatedAt: nowIso(),
|
|
491
497
|
});
|
|
@@ -91,6 +91,13 @@ function queueSnapshotChanged(left, right) {
|
|
|
91
91
|
// heartbeats and a retained socket no longer proves the worker is alive.
|
|
92
92
|
const WORKER_SOCKET_LIVENESS_MAX_SILENCE_MS = 30_000;
|
|
93
93
|
|
|
94
|
+
// Protocol features this relay understands, advertised in server.hello. A
|
|
95
|
+
// worker that updated on disk before the relay restarted must not rely on
|
|
96
|
+
// them otherwise: an older relay ignores worker.unready / hello ready:false
|
|
97
|
+
// and treats a `steering-held` requeue as a failure (retry, backoff,
|
|
98
|
+
// worker marked errored).
|
|
99
|
+
export const WORKER_PROTOCOL_CAPABILITIES = Object.freeze(['worker-unready', 'steering-held']);
|
|
100
|
+
|
|
94
101
|
export function createSessionWorkerWebSocketService({
|
|
95
102
|
WebSocketServerImpl,
|
|
96
103
|
httpServer,
|
|
@@ -378,6 +385,7 @@ export function createSessionWorkerWebSocketService({
|
|
|
378
385
|
emitEvent(ws, {
|
|
379
386
|
type: 'server.hello',
|
|
380
387
|
reason: 'connected',
|
|
388
|
+
capabilities: WORKER_PROTOCOL_CAPABILITIES,
|
|
381
389
|
queue: normalizeQueueSnapshot(queueCounts()),
|
|
382
390
|
sessionId: identity.sessionId,
|
|
383
391
|
timestamp: nowIso(),
|
|
@@ -396,12 +404,15 @@ export function createSessionWorkerWebSocketService({
|
|
|
396
404
|
if (payload.type === 'worker.hello') {
|
|
397
405
|
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
398
406
|
meta.lastSeenAtMs = nowMsSafe();
|
|
399
|
-
|
|
407
|
+
// A worker holding deliveries (open question card, compaction) binds
|
|
408
|
+
// its identity with hello but must not be handed work.
|
|
409
|
+
meta.ready = payload.ready !== false;
|
|
400
410
|
meta.lastHelloAt = nowIso();
|
|
401
411
|
noteHeartbeat(meta, 'worker-hello');
|
|
402
412
|
emitEvent(ws, {
|
|
403
413
|
type: 'server.hello',
|
|
404
414
|
reason: 'ack',
|
|
415
|
+
capabilities: WORKER_PROTOCOL_CAPABILITIES,
|
|
405
416
|
queue: normalizeQueueSnapshot(queueCounts()),
|
|
406
417
|
sessionId: meta.sessionId,
|
|
407
418
|
timestamp: nowIso(),
|
|
@@ -418,6 +429,17 @@ export function createSessionWorkerWebSocketService({
|
|
|
418
429
|
void maybeDeliverToSocket(ws, String(payload.reason || 'worker-ready'));
|
|
419
430
|
return;
|
|
420
431
|
}
|
|
432
|
+
if (payload.type === 'worker.unready') {
|
|
433
|
+
// Readiness is otherwise sticky until the next delivery: a ready sent
|
|
434
|
+
// while the worker could steer would still stand after a hold began,
|
|
435
|
+
// and the delivery it drew would only be handed back.
|
|
436
|
+
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
437
|
+
meta.lastSeenAtMs = nowMsSafe();
|
|
438
|
+
meta.ready = false;
|
|
439
|
+
meta.lastUnreadyAt = nowIso();
|
|
440
|
+
noteHeartbeat(meta, String(payload.reason || 'worker-unready'));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
421
443
|
if (payload.type === 'worker.ping') {
|
|
422
444
|
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
423
445
|
meta.lastSeenAtMs = nowMsSafe();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Resend of a steer a Stop cut off unanswered (kind='stopped'): shared by the
|
|
2
|
+
// POST /api/message guard and the transcript payload, so "Resent" on screen
|
|
3
|
+
// and the relay's refusal of a second Resend agree.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether an earlier Resend still stands. A cancelled queued message loses
|
|
7
|
+
* its queue row; a failed one keeps it as 'failed'; a finished one may have
|
|
8
|
+
* been pruned from the queue, which its answer (linked by source) proves.
|
|
9
|
+
*/
|
|
10
|
+
export function isLiveResend({ hasQueueRow = false, queueStatus = null, answered = false } = {}) {
|
|
11
|
+
if (hasQueueRow) {
|
|
12
|
+
return !['failed', 'cancelled'].includes(String(queueStatus || '').trim().toLowerCase());
|
|
13
|
+
}
|
|
14
|
+
return !!answered;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Original message id → the id of a Resend of it that still stands, from one
|
|
19
|
+
* conversation's messages (DB rows) and queue rows ({ id, status }).
|
|
20
|
+
*/
|
|
21
|
+
export function liveResendsByOriginal(dbMessages = [], queueRows = []) {
|
|
22
|
+
const queueById = new Map((Array.isArray(queueRows) ? queueRows : [])
|
|
23
|
+
.map((row) => [String(row?.id || '').trim(), row]));
|
|
24
|
+
const answeredSources = new Set();
|
|
25
|
+
for (const message of Array.isArray(dbMessages) ? dbMessages : []) {
|
|
26
|
+
if (message?.role !== 'assistant') continue;
|
|
27
|
+
const source = String(message?.source_message_id || '').trim();
|
|
28
|
+
if (source) answeredSources.add(source);
|
|
29
|
+
}
|
|
30
|
+
for (const row of queueById.values()) {
|
|
31
|
+
if (row?.response_message_id) answeredSources.add(String(row.id || '').trim());
|
|
32
|
+
}
|
|
33
|
+
const result = new Map();
|
|
34
|
+
for (const message of Array.isArray(dbMessages) ? dbMessages : []) {
|
|
35
|
+
if (message?.role !== 'user') continue;
|
|
36
|
+
const original = String(message?.resend_of_message_id || '').trim();
|
|
37
|
+
if (!original || result.has(original)) continue;
|
|
38
|
+
const id = String(message?.id || '').trim();
|
|
39
|
+
const queueRow = queueById.get(id);
|
|
40
|
+
if (isLiveResend({ hasQueueRow: !!queueRow, queueStatus: queueRow?.status, answered: answeredSources.has(id) })) {
|
|
41
|
+
result.set(original, id);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
@@ -2,14 +2,22 @@
|
|
|
2
2
|
// persistent Claude CLI process alive, shared so the settings slider and the
|
|
3
3
|
// worker enforcing it agree on bounds and formatting.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// The default is 4 hours: long enough for real long-running work to finish,
|
|
6
|
+
// short enough that a forgotten shell or agent does not keep its runtime
|
|
7
|
+
// alive indefinitely. 0 (chosen explicitly) means no limit: a process holding
|
|
8
|
+
// live tasks stays up until the tasks finish, the user stops them from the
|
|
9
|
+
// composer panel, or the worker/relay goes down. Mirrors
|
|
10
|
+
// shared/turn-ceiling.mjs.
|
|
11
|
+
//
|
|
12
|
+
// The browser mirrors the default in
|
|
13
|
+
// server/public/app/background-task-timeout-options.mjs (only server/public is
|
|
14
|
+
// served), and the slider's static markup in server/public/index.html starts
|
|
15
|
+
// at it; unit tests pin both to this module.
|
|
8
16
|
|
|
9
17
|
export const BACKGROUND_TASK_TIMEOUT_MIN_MINUTES = 0;
|
|
10
18
|
export const BACKGROUND_TASK_TIMEOUT_MAX_MINUTES = 600;
|
|
11
19
|
export const BACKGROUND_TASK_TIMEOUT_STEP_MINUTES = 5;
|
|
12
|
-
export const DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES =
|
|
20
|
+
export const DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES = 240;
|
|
13
21
|
|
|
14
22
|
/** 0 disables the timeout entirely (unlimited). */
|
|
15
23
|
export function normalizeBackgroundTaskTimeoutMinutes(value, fallback = DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES) {
|
|
@@ -63,7 +63,10 @@ export function createControlPoller({
|
|
|
63
63
|
|
|
64
64
|
dbg('abort_turn control received', control.id);
|
|
65
65
|
try {
|
|
66
|
-
|
|
66
|
+
// The control names the row the user stopped. A runner that owns several
|
|
67
|
+
// rows at once (a turn plus the messages steered into it) needs it to
|
|
68
|
+
// tell the stopped row from the ones it must settle itself.
|
|
69
|
+
await onAbortTurn(control);
|
|
67
70
|
await api('POST', `/api/control/${encodeURIComponent(control.id)}/result`, {
|
|
68
71
|
ok: true,
|
|
69
72
|
note: abortAckNote,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Whether a choice question reads as "pick several" — for providers whose
|
|
2
|
+
// ask-user tool has no multi-select flag (Copilot's `ask_user`; Claude's
|
|
3
|
+
// AskUserQuestion carries `multiSelect: true` itself). The card then offers
|
|
4
|
+
// checkmarks instead of one-shot buttons and answers with the selected labels
|
|
5
|
+
// joined by ", ". Conservative on purpose: an ordinary "which one?" keeps its
|
|
6
|
+
// buttons, because a wrongly multi-select card costs the user a second click
|
|
7
|
+
// while a wrongly single-select one forces them into the free-text field.
|
|
8
|
+
export function looksLikeMultiSelectQuestion(questionText, choices = []) {
|
|
9
|
+
if (!Array.isArray(choices) || choices.length < 2) return false;
|
|
10
|
+
const text = String(questionText || '');
|
|
11
|
+
return /\ball that apply\b/i.test(text)
|
|
12
|
+
|| /\bmulti-?select\b/i.test(text)
|
|
13
|
+
|| /\b(select|choose|pick|check|tick|enable|turn on)\b[^.!?\n]{0,40}\b(all|multiple|several|any number|one or more|as many|more than one)\b/i.test(text)
|
|
14
|
+
|| /\bmultiple (choices|options|selections|answers)\b/i.test(text)
|
|
15
|
+
|| /\bone or more\b/i.test(text);
|
|
16
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The terminal outcome of a turn the user stopped — what the relay records
|
|
2
|
+
// when a worker acknowledges an abort control. Shared so a worker that dies
|
|
3
|
+
// before its ack lands fails the stopped row the same way instead of letting
|
|
4
|
+
// it be requeued (a requeue re-runs a prompt the user explicitly stopped).
|
|
5
|
+
export function buildRelayStopFailure() {
|
|
6
|
+
return {
|
|
7
|
+
kind: 'turn-aborted',
|
|
8
|
+
error: 'turn-aborted',
|
|
9
|
+
code: 'turn-aborted',
|
|
10
|
+
stableCode: 'relay.turn-aborted',
|
|
11
|
+
message: 'System note: This turn was stopped from the relay UI before completion.',
|
|
12
|
+
guidance: 'Send a new message to continue when you are ready.',
|
|
13
|
+
failedAt: new Date().toISOString(),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Keeps a "/x" message the relay delivers to the Claude CLI plain text.
|
|
2
|
+
//
|
|
3
|
+
// The relay itself runs only /compact and /preview as commands, and neither
|
|
4
|
+
// reaches a worker as text: the composer (and the /api/message route, for
|
|
5
|
+
// /compact) intercept them. Any other "/x" the composer sends only after its
|
|
6
|
+
// warn-once guard ("press send again to send as text"), and the API accepts
|
|
7
|
+
// it as-is — so it must reach the model as the text the user typed. The Claude
|
|
8
|
+
// CLI, however, runs a prompt as a slash command when the string, or its LAST
|
|
9
|
+
// text block trimmed, starts with "/". Between turns that ran "/help what is
|
|
10
|
+
// 3 + 4?" as the CLI's /help (a `local_command` with "isn't available in this
|
|
11
|
+
// environment" as its output): the model never saw the question, and the
|
|
12
|
+
// relay attributed the next turn's reply to that row (live, 2026-09-26).
|
|
13
|
+
//
|
|
14
|
+
// The guard is a zero-width space in front of the "/". JavaScript's trim()
|
|
15
|
+
// does not strip U+200B, so neither the CLI's command check nor its queue's
|
|
16
|
+
// "is this a command" check fires, and the message is handled exactly like
|
|
17
|
+
// any other plain-text prompt (live probe, CLI 2.1.283: no local_command, the
|
|
18
|
+
// model answered the question, the transcript keeps the character). The
|
|
19
|
+
// relay stores and shows the user's own text; only the CLI sees the guard.
|
|
20
|
+
//
|
|
21
|
+
// A steered message needs none: the steer note (shared/steer-note.mjs) already
|
|
22
|
+
// leads its text, so the guard only applies where the text itself starts
|
|
23
|
+
// with "/".
|
|
24
|
+
//
|
|
25
|
+
// Not the SDK's per-message `client_composed: true`: that suppresses the
|
|
26
|
+
// dispatch too (same probe), but older CLIs ignore the field, it also turns
|
|
27
|
+
// off `@path` expansion and drops part of the turn-start context for that
|
|
28
|
+
// message (the probe lost the token-budget reminder), and per the CLI bundle
|
|
29
|
+
// its queue still classifies the text by the leading "/" (e.g. never merges
|
|
30
|
+
// it with other queued prompts) — the guard makes it plain text everywhere.
|
|
31
|
+
|
|
32
|
+
export const SLASH_COMMAND_GUARD = '\u200B';
|
|
33
|
+
|
|
34
|
+
function readsAsSlashCommand(text) {
|
|
35
|
+
return typeof text === 'string' && text.trim().startsWith('/');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Prefix the guard when the CLI would read the text as a slash command. */
|
|
39
|
+
export function withSlashCommandGuard(text) {
|
|
40
|
+
return readsAsSlashCommand(text) ? `${SLASH_COMMAND_GUARD}${text}` : text;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The guard applied to Claude user content: a string, or every text block of a
|
|
45
|
+
* content-block array (the CLI inspects the last block; guarding each keeps
|
|
46
|
+
* that true whatever the block order). Returns the input itself when nothing
|
|
47
|
+
* needed a guard; never mutates it.
|
|
48
|
+
*/
|
|
49
|
+
export function withSlashCommandGuardContent(content) {
|
|
50
|
+
if (typeof content === 'string') return withSlashCommandGuard(content);
|
|
51
|
+
if (!Array.isArray(content)) return content;
|
|
52
|
+
if (!content.some((block) => block?.type === 'text' && readsAsSlashCommand(block.text))) return content;
|
|
53
|
+
return content.map((block) => (block?.type === 'text' && readsAsSlashCommand(block.text)
|
|
54
|
+
? { ...block, text: withSlashCommandGuard(block.text) }
|
|
55
|
+
: block));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The user's own text from a guarded prompt: the leading guard is dropped only
|
|
60
|
+
* where it stands in front of a "/", so other text is returned unchanged.
|
|
61
|
+
*/
|
|
62
|
+
export function stripSlashCommandGuard(text) {
|
|
63
|
+
const value = String(text ?? '');
|
|
64
|
+
return value.startsWith(SLASH_COMMAND_GUARD) && readsAsSlashCommand(value.slice(SLASH_COMMAND_GUARD.length))
|
|
65
|
+
? value.slice(SLASH_COMMAND_GUARD.length)
|
|
66
|
+
: value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** stripSlashCommandGuard over a string or each text block of a content array. */
|
|
70
|
+
export function stripSlashCommandGuardContent(content) {
|
|
71
|
+
if (typeof content === 'string') return stripSlashCommandGuard(content);
|
|
72
|
+
if (!Array.isArray(content)) return content;
|
|
73
|
+
return content.map((block) => (block?.type === 'text' && typeof block.text === 'string'
|
|
74
|
+
? { ...block, text: stripSlashCommandGuard(block.text) }
|
|
75
|
+
: block));
|
|
76
|
+
}
|