@oar-sh/oar 0.9.3 → 0.9.5
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 +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +98 -0
- package/README.md +76 -14
- package/package.json +5 -2
- package/server/README.md +43 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +100 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +385 -43
- package/server/claude-worker/claude-session-worker.mjs +5 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +8 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-session-worker.mjs +5 -0
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +103 -0
- package/server/public/app/bootstrap.js +21 -6
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-view.js +100 -7
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +4 -1
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +4 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +18 -1
- package/server/public/app/steered-turn-merge.mjs +10 -1
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +4 -0
- package/server/public/index.html +264 -11
- package/server/relay-tools.md +5 -1
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/question-repository.mjs +3 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +6 -1
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +98 -19
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +84 -31
- package/server/server-runtime.mjs +238 -0
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/remote-relay-caller-context.mjs +346 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1743 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/session-worker-launch-service.mjs +148 -14
- package/shared/remote-relay-contract.mjs +556 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/steer-settle-failure.mjs +6 -0
- package/shared/steer-settle-markers.mjs +16 -0
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/worker-log-file.mjs +97 -0
|
@@ -35,8 +35,14 @@ import {
|
|
|
35
35
|
isStaleAttemptError,
|
|
36
36
|
} from './claude-turn-publisher.mjs';
|
|
37
37
|
import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
|
|
38
|
+
import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
|
|
38
39
|
import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
|
|
39
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
ANSWERED_ELSEWHERE_KIND,
|
|
42
|
+
DELIVERY_ANSWERED_ELSEWHERE_TEXT,
|
|
43
|
+
STEER_FOLDED_TEXT,
|
|
44
|
+
STEER_STOPPED_TEXT,
|
|
45
|
+
} from '../../shared/steer-settle-markers.mjs';
|
|
40
46
|
import { stripSteerNote, withSteerNoteContent } from '../../shared/steer-note.mjs';
|
|
41
47
|
import {
|
|
42
48
|
stripSlashCommandGuard,
|
|
@@ -336,6 +342,34 @@ function isTaskNotificationReplay(sdkMessage) {
|
|
|
336
342
|
return text !== null && text.trimStart().startsWith(TASK_NOTIFICATION_TAG_PREFIX);
|
|
337
343
|
}
|
|
338
344
|
|
|
345
|
+
/**
|
|
346
|
+
* The CLI's own record of a command that ran locally, replayed on the stream
|
|
347
|
+
* as a user-role message. The one the relay causes is the model switch:
|
|
348
|
+
* `set_model` on a conversation another model already answered writes the
|
|
349
|
+
* "/model" breadcrumbs into the transcript and replays the
|
|
350
|
+
* `<local-command-stdout>Set model to …` one (`isReplay: true`, string
|
|
351
|
+
* content, no `origin`) BEFORE it answers the control request — read in the
|
|
352
|
+
* CLI binaries 2.1.261 to 2.1.283. A session that was never answered gets no
|
|
353
|
+
* breadcrumb, which is why a probe on a fresh session only ever shows the
|
|
354
|
+
* re-init. Only the two output tags count, and only on a replay the CLI wrote
|
|
355
|
+
* itself (no origin, not inside a subagent): output is never a prompt, so it
|
|
356
|
+
* never opens a turn. A `<command-name>` record is left alone on purpose: it
|
|
357
|
+
* heads prompt-type slash commands and skills, which do open one.
|
|
358
|
+
*/
|
|
359
|
+
const LOCAL_COMMAND_OUTPUT_TAG_PREFIXES = Object.freeze([
|
|
360
|
+
'<local-command-stdout>',
|
|
361
|
+
'<local-command-stderr>',
|
|
362
|
+
]);
|
|
363
|
+
|
|
364
|
+
function isLocalCommandReplay(sdkMessage) {
|
|
365
|
+
if (sdkMessage?.isReplay !== true) return false;
|
|
366
|
+
if (sdkMessage.origin || sdkMessage.parent_tool_use_id) return false;
|
|
367
|
+
const text = firstTextBlock(sdkMessage);
|
|
368
|
+
if (text === null) return false;
|
|
369
|
+
const trimmed = text.trimStart();
|
|
370
|
+
return LOCAL_COMMAND_OUTPUT_TAG_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
|
|
371
|
+
}
|
|
372
|
+
|
|
339
373
|
/**
|
|
340
374
|
* Whether a turn the CLI opened by itself may be the compaction re-opening a
|
|
341
375
|
* delivered turn, rather than something else the CLI decided to say.
|
|
@@ -435,6 +469,17 @@ export function createClaudeSessionRunner({
|
|
|
435
469
|
// takes to distinguish a fold from a queued run. Far below the watchdog so a
|
|
436
470
|
// folded row settles as answered, not failed with a "resend" error.
|
|
437
471
|
steeredFoldGraceMs = 2_000,
|
|
472
|
+
// How long a NON-steered entry may sit unattached after a continuation that
|
|
473
|
+
// opened over it has published its answer, with the stream quiet and no
|
|
474
|
+
// turn starting, before it is settled as answered by that continuation. The
|
|
475
|
+
// CLI starts the turn of a message queued behind another one when that
|
|
476
|
+
// turn ends, and every turn opens with an init, which takes the entry out
|
|
477
|
+
// of this settle for good. So the window only has to outlast the gap up to
|
|
478
|
+
// that init (input processing, hooks); it is sized well above it because a
|
|
479
|
+
// settle that comes too early costs the user the real answer's place.
|
|
480
|
+
// Without the settle the row held every later message out of the worker
|
|
481
|
+
// until the watchdog failed it minutes later. 0 = disabled.
|
|
482
|
+
strandedSettleMs = 45_000,
|
|
438
483
|
// Backstop for the compaction hold. A compaction is announced ONCE by
|
|
439
484
|
// `status: 'compacting'` (the CLI's periodic re-emit is gated on the
|
|
440
485
|
// remote-control client's activity callback, which the plain SDK `query()`
|
|
@@ -484,6 +529,9 @@ export function createClaudeSessionRunner({
|
|
|
484
529
|
// the socket link: a hold withdraws the relay's readiness, and its end
|
|
485
530
|
// re-arms it at once so a held message steers into the resumed turn.
|
|
486
531
|
onDeliveryReadinessChange = () => {},
|
|
532
|
+
// Whether CLI processes carry the relay's `remote_relay` tool
|
|
533
|
+
// (shared/remote-relay-tool-core.mjs). Default: ask the relay.
|
|
534
|
+
remoteRelayToolGate = null,
|
|
487
535
|
dbg = () => {},
|
|
488
536
|
} = {}) {
|
|
489
537
|
const publisher = createClaudeTurnPublisher({
|
|
@@ -491,6 +539,13 @@ export function createClaudeSessionRunner({
|
|
|
491
539
|
dbg,
|
|
492
540
|
takeWorkflowRuns: () => drainSettledWorkflowRuns(),
|
|
493
541
|
});
|
|
542
|
+
// `remote_relay` is registered only while this relay has paired remotes.
|
|
543
|
+
// Asked once when the worker starts, and again whenever a CLI process winds
|
|
544
|
+
// down, so a remote paired later reaches the next process. A cold spawn
|
|
545
|
+
// waits for the pending answer (handlePendingPayload): the tool list is
|
|
546
|
+
// fixed per process, and the first delivery can beat the startup GET.
|
|
547
|
+
const remoteRelayGate = remoteRelayToolGate || createRemoteRelayToolGate({ api });
|
|
548
|
+
void remoteRelayGate.refresh();
|
|
494
549
|
const resolveWorkflowSessionDir = resolveWorkflowSessionDirImpl || createDefaultWorkflowSessionDirResolver();
|
|
495
550
|
let claudeNativeSessionId = '';
|
|
496
551
|
let proc = null;
|
|
@@ -754,6 +809,8 @@ export function createClaudeSessionRunner({
|
|
|
754
809
|
function attachDeliveredContext(index = 0, { provisional = false } = {}) {
|
|
755
810
|
const [entry] = proc.pendingDelivered.splice(index, 1);
|
|
756
811
|
entry.ctx.deliveredEntry = entry;
|
|
812
|
+
// It has a turn now; an adoption that is unwound puts it back unmarked.
|
|
813
|
+
entry.overtakenBy = null;
|
|
757
814
|
// A provisional (post-compaction) attach may still be unwound back onto
|
|
758
815
|
// the queue; its un-mark waits for the adoption to commit.
|
|
759
816
|
if (!provisional) unmarkConsumedOnOwnTurn(entry);
|
|
@@ -830,7 +887,89 @@ export function createClaudeSessionRunner({
|
|
|
830
887
|
function handBackAdoptedContext(ctx) {
|
|
831
888
|
restoreAdoptedContext(ctx);
|
|
832
889
|
dbg('post-compaction adoption handed back to a task continuation', ctx.message?.id || '(no id)');
|
|
833
|
-
return openContinuationContext();
|
|
890
|
+
return openContinuationContext({ evidence: 'a task notification replayed into the adopted turn' });
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Mark the next turn as one the CLI opened by itself. `corroborated` says
|
|
895
|
+
* whether the signal proves that on its own (a turn opener's replay, the
|
|
896
|
+
* Stop guard standing down) or only suggests it (a bare init, which the CLI
|
|
897
|
+
* also emits for reasons of its own). Sticky until the boundary is spent: a
|
|
898
|
+
* weaker signal behind a proven one does not take the proof back.
|
|
899
|
+
*
|
|
900
|
+
* Every marking starts with nothing superseding it, a re-marking of an
|
|
901
|
+
* armed boundary included. A bare init marks only with nothing queued, so
|
|
902
|
+
* it precedes any later push; and a boundary can stay armed with the flag
|
|
903
|
+
* set, when the message whose init set it was failed by the watchdog
|
|
904
|
+
* without a turn ever opening. Left standing, that flag made the next turn
|
|
905
|
+
* the CLI opened by itself look superseded by an init it never saw.
|
|
906
|
+
*/
|
|
907
|
+
function markSelfOpenedBoundary(reason, { corroborated = false } = {}) {
|
|
908
|
+
if (proc.lastBoundary === 'self-opened' && proc.boundaryCorroborated) return;
|
|
909
|
+
proc.lastBoundary = 'self-opened';
|
|
910
|
+
proc.boundarySuperseded = false;
|
|
911
|
+
proc.boundaryCorroborated = corroborated;
|
|
912
|
+
proc.boundaryReason = reason;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* What says the CLI really opened the turn the boundary announces, or ''
|
|
917
|
+
* when nothing does. Beyond the signal itself:
|
|
918
|
+
* - a task settled and no continuation has opened since. Counted, not
|
|
919
|
+
* timed, and not reset by a turn ending: a task that settles early in a
|
|
920
|
+
* long turn has its continuation open after that turn, by which time
|
|
921
|
+
* both the notification grace and `settleSeqAtTurnEnd` have moved on.
|
|
922
|
+
* Over-long by design: a settle whose notification was silent or folded
|
|
923
|
+
* into another turn stays evidence until the next continuation opens,
|
|
924
|
+
* which only keeps a boundary the way it was always kept;
|
|
925
|
+
* - a live task, which can report back without settling;
|
|
926
|
+
* - order: the init that armed the boundary came before the waiting
|
|
927
|
+
* message was pushed (an init arms it only with nothing queued), so that
|
|
928
|
+
* turn is not the message's own, and no later init has started another
|
|
929
|
+
* turn in its place (see `boundarySuperseded`).
|
|
930
|
+
*/
|
|
931
|
+
function selfOpenedEvidence() {
|
|
932
|
+
if (proc.boundaryCorroborated) return proc.boundaryReason;
|
|
933
|
+
if (proc.taskSettleSeq !== proc.settleSeqAtContinuationOpen) {
|
|
934
|
+
return 'a background task settled and no continuation has opened since';
|
|
935
|
+
}
|
|
936
|
+
if (proc.liveTasks.size > 0) return 'a live background task can report back';
|
|
937
|
+
if (!proc.boundarySuperseded) return 'its init came before the message was pushed and no turn has started since';
|
|
938
|
+
return '';
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* A self-opened boundary diverts the next turn's answer to a continuation
|
|
943
|
+
* row. One armed by a bare init stays armed until a turn opens, however
|
|
944
|
+
* long that takes, and the CLI inits for reasons of its own: read as
|
|
945
|
+
* self-opened, a settings re-init published the user's answer on a
|
|
946
|
+
* continuation and left the delivered row unattached, holding every later
|
|
947
|
+
* message out until the watchdog failed it (see the init handler).
|
|
948
|
+
*
|
|
949
|
+
* Every turn opens with an init, so what tells a stale boundary from a live
|
|
950
|
+
* one is what follows the push. Traffic with no init in between belongs to
|
|
951
|
+
* the turn the armed init opened: a continuation, as before. A SECOND init,
|
|
952
|
+
* after the push, starts a newer turn; the armed one never produced
|
|
953
|
+
* anything, and unless a task corroborates it the boundary is dropped here
|
|
954
|
+
* and the turn attaches to the waiting message, as if it had never been
|
|
955
|
+
* marked. Steered entries do not count: they settle by the fold reaper. A
|
|
956
|
+
* boundary marked by a replay is never dropped; if that one is false, the
|
|
957
|
+
* settle in reapOvertakenDeliveredEntries recovers.
|
|
958
|
+
*/
|
|
959
|
+
function disownUnprovenBoundary() {
|
|
960
|
+
if (proc.lastBoundary !== 'self-opened') return;
|
|
961
|
+
const waiting = proc.pendingDelivered.find((entry) => !entry.steered);
|
|
962
|
+
if (!waiting || selfOpenedEvidence()) return;
|
|
963
|
+
dbg(
|
|
964
|
+
'self-opened boundary has no corroboration; the turn goes to the waiting message',
|
|
965
|
+
shortMessageId(waiting.ctx.message),
|
|
966
|
+
`boundary=${proc.boundaryReason}`,
|
|
967
|
+
);
|
|
968
|
+
proc.lastBoundary = null;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function shortMessageId(message) {
|
|
972
|
+
return String(message?.id || '').slice(0, 8) || '(no id)';
|
|
834
973
|
}
|
|
835
974
|
|
|
836
975
|
/**
|
|
@@ -840,7 +979,7 @@ export function createClaudeSessionRunner({
|
|
|
840
979
|
* discards the turn's relay output (it still lands in the native
|
|
841
980
|
* transcript) rather than failing the process.
|
|
842
981
|
*/
|
|
843
|
-
function openContinuationContext() {
|
|
982
|
+
function openContinuationContext({ evidence = '' } = {}) {
|
|
844
983
|
const ctx = createContext('continuation', {
|
|
845
984
|
id: null,
|
|
846
985
|
conversationId: sdkSessionId,
|
|
@@ -848,6 +987,26 @@ export function createClaudeSessionRunner({
|
|
|
848
987
|
model: '',
|
|
849
988
|
});
|
|
850
989
|
ctx.registered = false;
|
|
990
|
+
// Delivered messages this turn opens over. Remembered on the entry: if
|
|
991
|
+
// this turn turns out to have taken their prompt, nothing will ever open
|
|
992
|
+
// for them (see reapOvertakenDeliveredEntries). Read before the
|
|
993
|
+
// activation below spends the boundary.
|
|
994
|
+
const overtaken = proc.pendingDelivered.filter((entry) => !entry.steered);
|
|
995
|
+
if (overtaken.length) {
|
|
996
|
+
const selfOpened = proc.lastBoundary === 'self-opened';
|
|
997
|
+
dbg(
|
|
998
|
+
'continuation opens while a delivered message waits unattached',
|
|
999
|
+
overtaken.map((entry) => shortMessageId(entry.ctx.message)).join(','),
|
|
1000
|
+
`boundary=${selfOpened ? proc.boundaryReason : 'none'}`,
|
|
1001
|
+
`reason=${evidence || (selfOpened && selfOpenedEvidence()) || 'none'}`,
|
|
1002
|
+
);
|
|
1003
|
+
for (const entry of overtaken) {
|
|
1004
|
+
entry.overtakenBy = ctx;
|
|
1005
|
+
entry.overtakenSince = 0;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
// Whatever settled up to here has its continuation now.
|
|
1009
|
+
proc.settleSeqAtContinuationOpen = proc.taskSettleSeq;
|
|
851
1010
|
activateContext(ctx);
|
|
852
1011
|
(async () => {
|
|
853
1012
|
// One idempotency key for the whole loop: a retry whose predecessor was
|
|
@@ -1060,6 +1219,18 @@ export function createClaudeSessionRunner({
|
|
|
1060
1219
|
if (openingText !== null) {
|
|
1061
1220
|
const matched = proc.pendingDelivered.findIndex((entry) => entry.expectedText === openingText);
|
|
1062
1221
|
if (matched >= 0) return attachDeliveredContext(matched);
|
|
1222
|
+
// The CLI's record of a local command is not a turn, and no traffic
|
|
1223
|
+
// follows it. Read as a self-opened boundary, the "/model" breadcrumb a
|
|
1224
|
+
// setModel leaves stayed armed through the push that followed: the
|
|
1225
|
+
// delivered message's answer opened a continuation row, and its own row
|
|
1226
|
+
// sat unattached, holding every later message out until the watchdog
|
|
1227
|
+
// failed it or the session was killed (seen live 2026-09-27; the
|
|
1228
|
+
// re-init handling alone cannot cover it). Checked after the match, so
|
|
1229
|
+
// a user who sends such a text still gets their turn.
|
|
1230
|
+
if (isLocalCommandReplay(sdkMessage)) {
|
|
1231
|
+
dbg('local command replay ignored as a turn boundary');
|
|
1232
|
+
return null;
|
|
1233
|
+
}
|
|
1063
1234
|
// A compaction just replayed the conversation: the CLI re-opens the turn
|
|
1064
1235
|
// with its own summary message ("This session is being continued…"),
|
|
1065
1236
|
// whose text matches no delivered entry, and then answers the delivered
|
|
@@ -1098,11 +1269,21 @@ export function createClaudeSessionRunner({
|
|
|
1098
1269
|
// A turn the CLI opened on its own (task-notification replay). The
|
|
1099
1270
|
// context opens lazily on its first real traffic so a bookkeeping
|
|
1100
1271
|
// replay that ends in a phantom result never creates a relay turn.
|
|
1101
|
-
|
|
1272
|
+
// A turn opener that matched no delivered message proves the turn is
|
|
1273
|
+
// the CLI's own, tagged or not: the adoption rules above rest on that.
|
|
1274
|
+
const originKind = String(sdkMessage?.origin?.kind || '').trim();
|
|
1275
|
+
markSelfOpenedBoundary(
|
|
1276
|
+
isTaskNotificationReplay(sdkMessage)
|
|
1277
|
+
? 'a task notification replay'
|
|
1278
|
+
: `an unmatched user replay (origin ${originKind || 'none'})`,
|
|
1279
|
+
{ corroborated: true },
|
|
1280
|
+
);
|
|
1281
|
+
turnStartedForWaitingDeliveries();
|
|
1102
1282
|
return null;
|
|
1103
1283
|
}
|
|
1104
1284
|
|
|
1105
1285
|
if (type === 'assistant' || type === 'stream_event' || type === 'user') {
|
|
1286
|
+
disownUnprovenBoundary();
|
|
1106
1287
|
if (proc.lastBoundary !== 'self-opened') {
|
|
1107
1288
|
// A background subagent's frames arrive at top level carrying
|
|
1108
1289
|
// `parent_tool_use_id` (same marker the adoption commit checks): they
|
|
@@ -1126,6 +1307,7 @@ export function createClaudeSessionRunner({
|
|
|
1126
1307
|
proc.lastBoundary = null;
|
|
1127
1308
|
return null;
|
|
1128
1309
|
}
|
|
1310
|
+
disownUnprovenBoundary();
|
|
1129
1311
|
if (proc.lastBoundary !== 'self-opened' && proc.pendingDelivered.length && !sdkMessage?.parent_tool_use_id) {
|
|
1130
1312
|
return attachDeliveredContext();
|
|
1131
1313
|
}
|
|
@@ -1920,11 +2102,21 @@ export function createClaudeSessionRunner({
|
|
|
1920
2102
|
if (type !== 'system') return;
|
|
1921
2103
|
const subtype = String(sdkMessage.subtype || '');
|
|
1922
2104
|
if (subtype === 'init') {
|
|
1923
|
-
// The
|
|
1924
|
-
//
|
|
1925
|
-
//
|
|
2105
|
+
// The CLI emits an init at the start of EVERY turn, after it has taken
|
|
2106
|
+
// the turn's input and before the API request (read in the CLI, 2.1.282):
|
|
2107
|
+
// a delivered message's own turn opens init → traffic, with no user
|
|
2108
|
+
// replay, exactly like a continuation the CLI opens by itself. The
|
|
2109
|
+
// FIRST init of a process is the spawn's, and a spawn is always driven
|
|
2110
|
+
// by a delivered message.
|
|
1926
2111
|
const firstInit = !proc.sawInit;
|
|
1927
2112
|
proc.sawInit = true;
|
|
2113
|
+
// A turn is starting while delivered messages wait, and it may be
|
|
2114
|
+
// theirs: they leave the overtaken settle for good. Whether a context is
|
|
2115
|
+
// still active does not matter. A continuation whose result arrived
|
|
2116
|
+
// while its row was still registering is active when the next turn's
|
|
2117
|
+
// init lands, and skipping that init settled the message with a pointer
|
|
2118
|
+
// and dropped its real answer.
|
|
2119
|
+
if (!firstInit && proc.pendingDelivered.length) turnStartedForWaitingDeliveries();
|
|
1928
2120
|
persistNativeSessionId(sdkSessionId, sdkMessage.session_id).catch(() => {});
|
|
1929
2121
|
// Tracked on the process too (not only through the persist round-trip):
|
|
1930
2122
|
// the workflow poller needs the native id to find the session dir even
|
|
@@ -1959,17 +2151,20 @@ export function createClaudeSessionRunner({
|
|
|
1959
2151
|
// same way.
|
|
1960
2152
|
if (settingsReinitsOwed > 0) settingsReinitsOwed -= 1;
|
|
1961
2153
|
else if (!settingsWindowOpen || proc.taskSettleSeq !== proc.settleSeqAtTurnEnd) {
|
|
1962
|
-
|
|
2154
|
+
markSelfOpenedBoundary('a bare init with nothing queued');
|
|
1963
2155
|
}
|
|
1964
2156
|
} else if (!proc.activeCtx && !firstInit) {
|
|
1965
|
-
// A LATER init, with a delivered row waiting
|
|
1966
|
-
//
|
|
1967
|
-
//
|
|
1968
|
-
//
|
|
1969
|
-
//
|
|
1970
|
-
//
|
|
1971
|
-
// turn
|
|
1972
|
-
// nothing later replays to correct it.
|
|
2157
|
+
// A LATER init, with a delivered row waiting: a turn is starting, and
|
|
2158
|
+
// the init cannot say whose. It may be the CLI opening a turn of its
|
|
2159
|
+
// own (the live-verified continuation shape is a bare init with no
|
|
2160
|
+
// user replay at all) or the waiting message's own turn, which opens
|
|
2161
|
+
// with an init just the same. For a compaction inside that turn the
|
|
2162
|
+
// flag errs toward the continuation: adopting the queued row for a
|
|
2163
|
+
// turn that was the CLI's own closes it with another turn's answer,
|
|
2164
|
+
// and in this shape nothing later replays to correct it. When the
|
|
2165
|
+
// turn was the message's own, its answer publishes on a continuation
|
|
2166
|
+
// row instead and the row is settled with a pointer to it (see
|
|
2167
|
+
// reapOvertakenDeliveredEntries).
|
|
1973
2168
|
//
|
|
1974
2169
|
// Structural, not a clock. Gating this on "a task settled recently"
|
|
1975
2170
|
// fails twice over: the grace (60 s) is shorter than a compaction
|
|
@@ -1985,6 +2180,9 @@ export function createClaudeSessionRunner({
|
|
|
1985
2180
|
// continuation). This suppresses adoption and nothing else; if the turn
|
|
1986
2181
|
// really is the delivered message's, it attaches exactly as before.
|
|
1987
2182
|
proc.continuationInitPending = true;
|
|
2183
|
+
// A boundary an earlier init armed announced a turn that never
|
|
2184
|
+
// produced anything: this init starts a newer one in its place.
|
|
2185
|
+
if (proc.lastBoundary === 'self-opened') proc.boundarySuperseded = true;
|
|
1988
2186
|
}
|
|
1989
2187
|
return;
|
|
1990
2188
|
}
|
|
@@ -2290,7 +2488,9 @@ export function createClaudeSessionRunner({
|
|
|
2290
2488
|
if (suppressed === 'observe-only') return;
|
|
2291
2489
|
// Its traffic opens a continuation of its own instead of attaching to the
|
|
2292
2490
|
// stopped steer waiting in pendingDelivered (which still settles stopped).
|
|
2293
|
-
if (suppressed === 'continuation')
|
|
2491
|
+
if (suppressed === 'continuation') {
|
|
2492
|
+
markSelfOpenedBoundary('the stop guard stood down for a task continuation', { corroborated: true });
|
|
2493
|
+
}
|
|
2294
2494
|
const ctx = resolveContext(sdkMessage);
|
|
2295
2495
|
if (proc.handoffSettling) {
|
|
2296
2496
|
// An absorbed-steering handoff just settled the previous context;
|
|
@@ -2479,6 +2679,112 @@ export function createClaudeSessionRunner({
|
|
|
2479
2679
|
}
|
|
2480
2680
|
}
|
|
2481
2681
|
|
|
2682
|
+
/**
|
|
2683
|
+
* A turn is starting while delivered messages wait. Whatever opened over
|
|
2684
|
+
* them before, they are no longer known to be left behind: this turn may be
|
|
2685
|
+
* theirs, and its first traffic can take longer than the settle window.
|
|
2686
|
+
*/
|
|
2687
|
+
function turnStartedForWaitingDeliveries() {
|
|
2688
|
+
for (const entry of proc.pendingDelivered) entry.overtakenBy = null;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
/** The continuation ran to a clean end and its answer is on the relay. */
|
|
2692
|
+
function publishedItsAnswer(ctx) {
|
|
2693
|
+
return Boolean(ctx)
|
|
2694
|
+
&& ctx.finalized
|
|
2695
|
+
&& ctx.registered
|
|
2696
|
+
&& !ctx.discarded
|
|
2697
|
+
&& !ctx.interrupted
|
|
2698
|
+
&& Boolean(ctx.message?.id)
|
|
2699
|
+
&& Boolean(ctx.state.result)
|
|
2700
|
+
&& !ctx.state.result.isError;
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
/**
|
|
2704
|
+
* Settle delivered messages whose prompt a continuation's turn took. A
|
|
2705
|
+
* continuation opened over a waiting NON-steered entry either ran ahead of
|
|
2706
|
+
* the entry's own turn, which then follows at once, or WAS that turn: a
|
|
2707
|
+
* false boundary published the answer on the continuation row, or the CLI
|
|
2708
|
+
* folded the queued prompt into the turn it had already begun. In the
|
|
2709
|
+
* second case nothing attaches the entry anymore, and it held every later
|
|
2710
|
+
* message out of the worker until the watchdog failed it with a "resend"
|
|
2711
|
+
* error for a message that had been answered (seen live 2026-09-27).
|
|
2712
|
+
*
|
|
2713
|
+
* Told apart by silence: the continuation published its answer, no turn
|
|
2714
|
+
* started since (any init takes the entry out, see the init handler), and
|
|
2715
|
+
* the stream stayed quiet for the settle window, counted from whichever is
|
|
2716
|
+
* latest: the continuation's end, the entry's push, the last stream event.
|
|
2717
|
+
* Same gates as the fold reaper, so never during a compaction, an API
|
|
2718
|
+
* retry, an open control round-trip or a task continuation that is due. A
|
|
2719
|
+
* continuation that was stopped, failed or never reached the relay settles
|
|
2720
|
+
* nothing: the marker points at its reply, and the watchdog keeps those
|
|
2721
|
+
* rows.
|
|
2722
|
+
*/
|
|
2723
|
+
function reapOvertakenDeliveredEntries(now) {
|
|
2724
|
+
if (!proc || !proc.pendingDelivered.length) return;
|
|
2725
|
+
if (!(strandedSettleMs > 0)) return;
|
|
2726
|
+
if (proc.activeCtx || proc.pendingControlRequests > 0 || isCompacting() || isContinuationImminent()
|
|
2727
|
+
|| isApiRetryPending() || isStopFollowUpPending(now)) {
|
|
2728
|
+
for (const entry of proc.pendingDelivered) entry.overtakenSince = 0;
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
const answered = [];
|
|
2732
|
+
proc.pendingDelivered = proc.pendingDelivered.filter((entry) => {
|
|
2733
|
+
if (entry.steered || !publishedItsAnswer(entry.overtakenBy)) return true;
|
|
2734
|
+
if (!entry.overtakenSince) { entry.overtakenSince = now; return true; }
|
|
2735
|
+
// Any stream event restarts the wait: a CLI that still talks is not done.
|
|
2736
|
+
const quietSince = Math.max(entry.overtakenSince, entry.pushedAt || 0, proc.lastEventAt);
|
|
2737
|
+
if (now - quietSince < strandedSettleMs) return true;
|
|
2738
|
+
answered.push(entry);
|
|
2739
|
+
// Owned until the marker is committed (same claim as a folded steer).
|
|
2740
|
+
claimSettling(entry.ctx.message, 'answeredElsewhere');
|
|
2741
|
+
return false;
|
|
2742
|
+
});
|
|
2743
|
+
if (!answered.length) return;
|
|
2744
|
+
void (async () => {
|
|
2745
|
+
for (const entry of answered) {
|
|
2746
|
+
dbg(
|
|
2747
|
+
'delivered message was answered on a continuation row; settling it',
|
|
2748
|
+
shortMessageId(entry.ctx.message),
|
|
2749
|
+
`continuation=${shortMessageId(entry.overtakenBy.message)}`,
|
|
2750
|
+
);
|
|
2751
|
+
try {
|
|
2752
|
+
await settleOvertakenDeliveredEntry(entry);
|
|
2753
|
+
} catch (error) {
|
|
2754
|
+
dbg('overtaken-delivery settle failed', error?.message || String(error));
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
})();
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
async function settleOvertakenDeliveredEntry(entry) {
|
|
2761
|
+
const ctx = entry.ctx;
|
|
2762
|
+
if (ctx.finalized) {
|
|
2763
|
+
releaseSettling(ctx.message?.id);
|
|
2764
|
+
return;
|
|
2765
|
+
}
|
|
2766
|
+
ctx.finalized = true;
|
|
2767
|
+
const model = entry.overtakenBy.state.responseModel || proc?.model || null;
|
|
2768
|
+
try {
|
|
2769
|
+
// Marked consumed BEFORE the first publish attempt: should the worker
|
|
2770
|
+
// die while the marker is retried, the relay fails the row instead of
|
|
2771
|
+
// running its prompt again. Never rejects (best-effort).
|
|
2772
|
+
await markConsumed(ctx.message?.conversationId, [ctx.message]);
|
|
2773
|
+
// The row closes with a pointer to the answer, under a kind of its own:
|
|
2774
|
+
// a marker, not a reply. Not requeued (the prompt would run twice) and
|
|
2775
|
+
// not failed (it was answered).
|
|
2776
|
+
await publishSettleMarker(ctx.message, {
|
|
2777
|
+
text: DELIVERY_ANSWERED_ELSEWHERE_TEXT,
|
|
2778
|
+
model,
|
|
2779
|
+
kind: ANSWERED_ELSEWHERE_KIND,
|
|
2780
|
+
variant: 'answeredElsewhere',
|
|
2781
|
+
});
|
|
2782
|
+
} finally {
|
|
2783
|
+
// Spliced out of pendingDelivered already: nothing else resolves it.
|
|
2784
|
+
ctx.resolveDone?.(true);
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2482
2788
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2483
2789
|
|
|
2484
2790
|
/**
|
|
@@ -2512,7 +2818,14 @@ export function createClaudeSessionRunner({
|
|
|
2512
2818
|
const workflowRuns = publisher.drainWorkflowRuns();
|
|
2513
2819
|
try {
|
|
2514
2820
|
for (let attempt = 0; ; attempt += 1) {
|
|
2515
|
-
|
|
2821
|
+
// Never requeued, with or without a kind: the CLI has the prompt.
|
|
2822
|
+
const outcome = await publisher.publishResponse(message, {
|
|
2823
|
+
text,
|
|
2824
|
+
model,
|
|
2825
|
+
kind,
|
|
2826
|
+
workflowRuns,
|
|
2827
|
+
requeueOnFailure: false,
|
|
2828
|
+
});
|
|
2516
2829
|
reportFirstAttempt();
|
|
2517
2830
|
if (outcome !== 'failed') return;
|
|
2518
2831
|
if (attempt >= settleRetryDelaysMs.length) break;
|
|
@@ -2608,6 +2921,9 @@ export function createClaudeSessionRunner({
|
|
|
2608
2921
|
// Before the 5-minute watchdog: a folded steered entry settles as merged
|
|
2609
2922
|
// within the short fold grace instead of failing over.
|
|
2610
2923
|
reapFoldedSteeredEntries(now);
|
|
2924
|
+
// Also before the watchdog: a message a continuation answered settles as
|
|
2925
|
+
// answered instead of failing over minutes later.
|
|
2926
|
+
reapOvertakenDeliveredEntries(now);
|
|
2611
2927
|
reapStalePendingDelivered(now);
|
|
2612
2928
|
if (hasLiveWork()) {
|
|
2613
2929
|
// Track how long background tasks alone have held the process, for the
|
|
@@ -2672,6 +2988,8 @@ export function createClaudeSessionRunner({
|
|
|
2672
2988
|
|
|
2673
2989
|
async function cleanupProcess(processRef, streamError) {
|
|
2674
2990
|
const wasCurrent = proc === processRef;
|
|
2991
|
+
// The next spawn reads the registration decision; refresh it now.
|
|
2992
|
+
if (wasCurrent) void remoteRelayGate.refresh();
|
|
2675
2993
|
stopLifecycleTimer(processRef);
|
|
2676
2994
|
if (processRef.taskPublishTimer) {
|
|
2677
2995
|
clearTimeout(processRef.taskPublishTimer);
|
|
@@ -2802,6 +3120,16 @@ export function createClaudeSessionRunner({
|
|
|
2802
3120
|
activeCtx: null,
|
|
2803
3121
|
pendingDelivered: [],
|
|
2804
3122
|
lastBoundary: null,
|
|
3123
|
+
// What marked the boundary, and whether that alone proves the CLI
|
|
3124
|
+
// opened the turn; read only while lastBoundary is 'self-opened'.
|
|
3125
|
+
boundaryReason: '',
|
|
3126
|
+
boundaryCorroborated: false,
|
|
3127
|
+
// An init arrived after the boundary was armed, with a delivered
|
|
3128
|
+
// message waiting: a newer turn started in place of the announced one.
|
|
3129
|
+
boundarySuperseded: false,
|
|
3130
|
+
// taskSettleSeq when a continuation context last opened: a settle since
|
|
3131
|
+
// then still has its continuation coming.
|
|
3132
|
+
settleSeqAtContinuationOpen: 0,
|
|
2805
3133
|
pendingControlRequests: 0,
|
|
2806
3134
|
lastEventAt: Date.now(),
|
|
2807
3135
|
heldForTasksSince: 0,
|
|
@@ -2903,6 +3231,7 @@ export function createClaudeSessionRunner({
|
|
|
2903
3231
|
|| sdkSessionId
|
|
2904
3232
|
|| '',
|
|
2905
3233
|
),
|
|
3234
|
+
remoteRelayTool: remoteRelayGate.isEnabled(),
|
|
2906
3235
|
dbg,
|
|
2907
3236
|
});
|
|
2908
3237
|
processRef.lifecycleTimer = setInterval(() => evaluateLifecycle(), lifecyclePollMs);
|
|
@@ -2940,18 +3269,10 @@ export function createClaudeSessionRunner({
|
|
|
2940
3269
|
// A mode change that would alter the spawn-time system prompt append gets
|
|
2941
3270
|
// a fresh process — but only when nothing lives in the old one.
|
|
2942
3271
|
if (modeAppendClass(relayMode) !== proc.appendClass && !hasLiveWork() && !proc.liveTasks.size) {
|
|
2943
|
-
const previous = proc;
|
|
2944
3272
|
gracefulShutdown('mode-change');
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
]);
|
|
2949
|
-
if (proc === previous) {
|
|
2950
|
-
// The drain timed out: force the old CLI down so it cannot keep
|
|
2951
|
-
// streaming next to the replacement the caller is about to spawn.
|
|
2952
|
-
try { previous.turn.close?.(); } catch {}
|
|
2953
|
-
proc = null;
|
|
2954
|
-
}
|
|
3273
|
+
// A drain that times out forces the old CLI down so it cannot keep
|
|
3274
|
+
// streaming next to the replacement the caller is about to spawn.
|
|
3275
|
+
await drainClosingProcess(10_000);
|
|
2955
3276
|
return null;
|
|
2956
3277
|
}
|
|
2957
3278
|
|
|
@@ -3173,6 +3494,25 @@ export function createClaudeSessionRunner({
|
|
|
3173
3494
|
return { turnActive, canSteer, holdReason, messageId, supported: true };
|
|
3174
3495
|
}
|
|
3175
3496
|
|
|
3497
|
+
/**
|
|
3498
|
+
* A closing process drains on its own; wait (bounded) so two CLIs never
|
|
3499
|
+
* share the native session transcript, then make sure it is gone.
|
|
3500
|
+
*/
|
|
3501
|
+
async function drainClosingProcess(timeoutMs = 15_000) {
|
|
3502
|
+
const previous = proc;
|
|
3503
|
+
if (!previous) return;
|
|
3504
|
+
let timer = null;
|
|
3505
|
+
await Promise.race([
|
|
3506
|
+
previous.consumer,
|
|
3507
|
+
new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); }),
|
|
3508
|
+
]);
|
|
3509
|
+
clearTimeout(timer);
|
|
3510
|
+
if (proc === previous) {
|
|
3511
|
+
try { previous.turn.close?.(); } catch {}
|
|
3512
|
+
proc = null;
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3176
3516
|
async function handlePendingPayload(pending) {
|
|
3177
3517
|
const message = pending?.message || null;
|
|
3178
3518
|
if (!message) return false;
|
|
@@ -3204,20 +3544,22 @@ export function createClaudeSessionRunner({
|
|
|
3204
3544
|
await adaptProcess(message);
|
|
3205
3545
|
}
|
|
3206
3546
|
if (!proc || proc.closing || proc.aborted) {
|
|
3207
|
-
//
|
|
3208
|
-
//
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3547
|
+
// Awaited only when there is one: a cold spawn with the decision
|
|
3548
|
+
// already in pushes synchronously.
|
|
3549
|
+
if (proc) await drainClosingProcess();
|
|
3550
|
+
// The tool set is fixed per CLI process: let the remote_relay
|
|
3551
|
+
// decision settle first. The worker's first delivery can arrive
|
|
3552
|
+
// before its startup GET has answered (seen live: 180 ms after the
|
|
3553
|
+
// spawn). ready() never rejects and is bounded by the gate's timeout.
|
|
3554
|
+
if (!remoteRelayGate.isSettled?.()) await remoteRelayGate.ready();
|
|
3555
|
+
// Another delivery spawned while this one waited: join that process
|
|
3556
|
+
// like any delivery (between turns: adapt; mid-turn: steer below).
|
|
3557
|
+
if (proc && !proc.closing && !proc.aborted && !proc.activeCtx) await adaptProcess(message);
|
|
3558
|
+
// What it joined may be gone by now: that adapt can recycle the
|
|
3559
|
+
// process (a mode change), and a process can wind down under any of
|
|
3560
|
+
// these waits. Only a live one takes the push below.
|
|
3561
|
+
if (proc && (proc.closing || proc.aborted)) await drainClosingProcess();
|
|
3562
|
+
if (!proc) spawnProcess(message);
|
|
3221
3563
|
// A cold start is the one moment a turn can sit silent for many
|
|
3222
3564
|
// seconds (transcript load + first request); say so instead of
|
|
3223
3565
|
// showing bare dots. Posted once here — not in spawnProcess — so a
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
|
|
14
14
|
import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
|
|
15
15
|
import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
|
|
16
|
+
import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
|
|
16
17
|
import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
17
18
|
import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
|
|
18
19
|
import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
|
|
@@ -20,6 +21,10 @@ import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
|
20
21
|
import { createClaudeSessionRunner } from './claude-session-process.mjs';
|
|
21
22
|
import { createRunnerLinkBridge, failSettlingRowsOnShutdown } from './claude-worker-link-wiring.mjs';
|
|
22
23
|
|
|
24
|
+
// Windows console launch: the launcher names the worker log, this copies
|
|
25
|
+
// stdout/stderr into it. A no-op wherever the launcher redirects instead.
|
|
26
|
+
installWorkerLogFile();
|
|
27
|
+
|
|
23
28
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
29
|
const HEARTBEAT_MS = 10_000;
|
|
25
30
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { capThought } from '../../shared/thought-cap.mjs';
|
|
2
2
|
import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
|
|
3
|
+
import { isRemoteRelayToolName, remoteRelayActivitySummary } from '../../shared/remote-relay-tool-core.mjs';
|
|
3
4
|
|
|
4
5
|
const MAX_TOOL_DETAIL_LENGTH = 140;
|
|
5
6
|
const REDACTED_THINKING_PLACEHOLDER = '[Reasoning redacted by the model provider]';
|
|
@@ -89,6 +90,12 @@ export function isSubagentToolName(name) {
|
|
|
89
90
|
|
|
90
91
|
export function summarizeToolInput(toolName, input) {
|
|
91
92
|
if (!input || typeof input !== 'object') return '';
|
|
93
|
+
// `mcp__relay__remote_relay` reads as what it does on which relay
|
|
94
|
+
// (`send → linux-test session 01234567: “…”`), not as its first argument.
|
|
95
|
+
if (isRemoteRelayToolName(toolName)) {
|
|
96
|
+
const summary = remoteRelayActivitySummary(input);
|
|
97
|
+
if (summary) return summary;
|
|
98
|
+
}
|
|
92
99
|
const candidates = [
|
|
93
100
|
input.command,
|
|
94
101
|
input.file_path,
|