@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.
Files changed (76) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +98 -0
  4. package/README.md +76 -14
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +100 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +385 -43
  10. package/server/claude-worker/claude-session-worker.mjs +5 -0
  11. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  12. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
  16. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  17. package/server/cursor-worker/cursor-session-worker.mjs +5 -0
  18. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  19. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  20. package/server/db-schema.mjs +8 -0
  21. package/server/grok-worker/acp-client.mjs +92 -6
  22. package/server/grok-worker/grok-session-worker.mjs +5 -0
  23. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  24. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  25. package/server/mcp/oar-mcp-server.mjs +473 -0
  26. package/server/migrations/0005-remote-relays.mjs +90 -0
  27. package/server/public/app/action-confirmations.js +141 -13
  28. package/server/public/app/api-client.js +103 -0
  29. package/server/public/app/bootstrap.js +21 -6
  30. package/server/public/app/conversation-deep-link.mjs +42 -0
  31. package/server/public/app/conversation-view.js +100 -7
  32. package/server/public/app/host-suspend-ui.js +272 -0
  33. package/server/public/app/journal-view.js +4 -1
  34. package/server/public/app/mention-autocomplete.mjs +298 -0
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/pwa-install.js +8 -3
  37. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  38. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  39. package/server/public/app/remote-relay-shared.mjs +85 -0
  40. package/server/public/app/remote-relays-settings.js +618 -0
  41. package/server/public/app/remote-relays-store.mjs +126 -0
  42. package/server/public/app/settings-modal.js +4 -2
  43. package/server/public/app/settings-tabs.js +1 -1
  44. package/server/public/app/socket-handlers.js +18 -1
  45. package/server/public/app/steered-turn-merge.mjs +10 -1
  46. package/server/public/app/thinking-follow.js +260 -0
  47. package/server/public/app/tool-activity-emoji.mjs +4 -0
  48. package/server/public/index.html +264 -11
  49. package/server/relay-tools.md +5 -1
  50. package/server/repositories/message-repository.mjs +7 -0
  51. package/server/repositories/question-repository.mjs +3 -0
  52. package/server/repositories/remote-relay-repository.mjs +75 -0
  53. package/server/repositories/session-repository.mjs +6 -1
  54. package/server/routes/ask-user-routes.mjs +42 -2
  55. package/server/routes/messages-routes.mjs +98 -19
  56. package/server/routes/remote-relay-routes.mjs +133 -0
  57. package/server/routes/sessions-routes.mjs +84 -31
  58. package/server/server-runtime.mjs +238 -0
  59. package/server/services/github-ci-activity-service.mjs +199 -0
  60. package/server/services/host-suspend-command.mjs +38 -0
  61. package/server/services/host-suspend-service.mjs +254 -0
  62. package/server/services/push-dispatch-service.mjs +37 -4
  63. package/server/services/remote-relay-caller-context.mjs +346 -0
  64. package/server/services/remote-relay-client.mjs +332 -0
  65. package/server/services/remote-relay-dispatcher.mjs +1743 -0
  66. package/server/services/remote-relay-inbound.mjs +187 -0
  67. package/server/services/remote-relay-pairing.mjs +189 -0
  68. package/server/services/remote-relay-registry-service.mjs +581 -0
  69. package/server/services/session-worker-launch-service.mjs +148 -14
  70. package/shared/remote-relay-contract.mjs +556 -0
  71. package/shared/remote-relay-mentions.mjs +129 -0
  72. package/shared/remote-relay-tool-core.mjs +314 -0
  73. package/shared/steer-settle-failure.mjs +6 -0
  74. package/shared/steer-settle-markers.mjs +16 -0
  75. package/shared/worker-runtime/api-client.mjs +112 -22
  76. 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 { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT } from '../../shared/steer-settle-markers.mjs';
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
- proc.lastBoundary = 'self-opened';
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 FIRST init of a process is the spawn's, and a spawn is always
1924
- // driven by a delivered message; a LATER one is the CLI opening a turn
1925
- // by itself.
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
- proc.lastBoundary = 'self-opened';
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, is the CLI opening a turn
1966
- // of its own — the live-verified continuation shape is a bare init with
1967
- // no user replay at all. A delivered turn cannot produce one: the
1968
- // process is already running, and only a spawn inits (which is
1969
- // `firstInit`, and always has its row queued before the stream is
1970
- // read). A compaction inside such a turn would otherwise see no
1971
- // turn-level boundary and adopt the queued row — and in this shape
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') proc.lastBoundary = 'self-opened';
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
- const outcome = await publisher.publishResponse(message, { text, model, kind, workflowRuns });
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
- await Promise.race([
2946
- previous.consumer,
2947
- new Promise((resolve) => setTimeout(resolve, 10_000)),
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
- // A closing process drains on its own; wait so two CLIs never share
3208
- // the native session transcript.
3209
- const previous = proc;
3210
- if (previous) {
3211
- await Promise.race([
3212
- previous.consumer,
3213
- new Promise((resolve) => setTimeout(resolve, 15_000)),
3214
- ]);
3215
- if (proc === previous) {
3216
- try { previous.turn.close?.(); } catch {}
3217
- proc = null;
3218
- }
3219
- }
3220
- spawnProcess(message);
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,