botmux 3.16.0 → 3.16.1

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 (51) hide show
  1. package/dist/.runtime-build-id +1 -1
  2. package/dist/core/dashboard-url.d.ts +10 -1
  3. package/dist/core/dashboard-url.d.ts.map +1 -1
  4. package/dist/core/dashboard-url.js +12 -2
  5. package/dist/core/dashboard-url.js.map +1 -1
  6. package/dist/core/plugins/mcp/gateway.d.ts +27 -3
  7. package/dist/core/plugins/mcp/gateway.d.ts.map +1 -1
  8. package/dist/core/plugins/mcp/gateway.js +272 -26
  9. package/dist/core/plugins/mcp/gateway.js.map +1 -1
  10. package/dist/core/plugins/mcp/host.d.ts +18 -0
  11. package/dist/core/plugins/mcp/host.d.ts.map +1 -1
  12. package/dist/core/plugins/mcp/host.js +80 -16
  13. package/dist/core/plugins/mcp/host.js.map +1 -1
  14. package/dist/core/plugins/mcp/launch-record.d.ts +14 -0
  15. package/dist/core/plugins/mcp/launch-record.d.ts.map +1 -0
  16. package/dist/core/plugins/mcp/launch-record.js +63 -0
  17. package/dist/core/plugins/mcp/launch-record.js.map +1 -0
  18. package/dist/core/worker-pool.d.ts +10 -2
  19. package/dist/core/worker-pool.d.ts.map +1 -1
  20. package/dist/core/worker-pool.js +30 -3
  21. package/dist/core/worker-pool.js.map +1 -1
  22. package/dist/dashboard/control-csrf.d.ts.map +1 -1
  23. package/dist/dashboard/control-csrf.js +6 -1
  24. package/dist/dashboard/control-csrf.js.map +1 -1
  25. package/dist/dashboard/terminal-front-proxy.d.ts.map +1 -1
  26. package/dist/dashboard/terminal-front-proxy.js +19 -1
  27. package/dist/dashboard/terminal-front-proxy.js.map +1 -1
  28. package/dist/i18n/en.d.ts.map +1 -1
  29. package/dist/i18n/en.js +1 -0
  30. package/dist/i18n/en.js.map +1 -1
  31. package/dist/i18n/zh.d.ts.map +1 -1
  32. package/dist/i18n/zh.js +1 -0
  33. package/dist/i18n/zh.js.map +1 -1
  34. package/dist/im/lark/card-handler.d.ts.map +1 -1
  35. package/dist/im/lark/card-handler.js +65 -4
  36. package/dist/im/lark/card-handler.js.map +1 -1
  37. package/dist/platform/binding.d.ts +27 -0
  38. package/dist/platform/binding.d.ts.map +1 -1
  39. package/dist/platform/binding.js +40 -0
  40. package/dist/platform/binding.js.map +1 -1
  41. package/dist/services/bridge-turn-journal.d.ts +63 -0
  42. package/dist/services/bridge-turn-journal.d.ts.map +1 -0
  43. package/dist/services/bridge-turn-journal.js +149 -0
  44. package/dist/services/bridge-turn-journal.js.map +1 -0
  45. package/dist/services/bridge-turn-queue.d.ts +8 -1
  46. package/dist/services/bridge-turn-queue.d.ts.map +1 -1
  47. package/dist/services/bridge-turn-queue.js +2 -1
  48. package/dist/services/bridge-turn-queue.js.map +1 -1
  49. package/dist/worker.js +270 -19
  50. package/dist/worker.js.map +1 -1
  51. package/package.json +1 -1
package/dist/worker.js CHANGED
@@ -49,8 +49,10 @@ import { sanitizePerBotEnv } from './core/per-bot-env.js';
49
49
  import { resolveChildBotsConfig } from './core/config-dir.js';
50
50
  import { evaluateVcMeetingManagedSend, } from './services/vc-meeting-send-policy.js';
51
51
  import { TurnTerminalDeduper } from './services/turn-terminal-deduper.js';
52
+ import { appendBridgeTurnJournalEntry, BridgeRestoreGate, clearBridgeTurnJournal, readBridgeTurnJournal, removeBridgeTurnJournalEntry, selectRestorableBridgeTurns, writeBridgeTurnJournal, } from './services/bridge-turn-journal.js';
52
53
  import { defaultGatewayEntry, ensureGatewayEntry } from './core/plugins/mcp/gateway-installer.js';
53
- import { sessionMcpGatewayPathRegex, startSessionMcpGatewayHost, } from './core/plugins/mcp/host.js';
54
+ import { sessionMcpGatewayPathRegex, sessionMcpGatewaySocketPath, startSessionMcpGatewayHost, } from './core/plugins/mcp/host.js';
55
+ import { clearMcpGatewayLaunchRecord, mcpGatewayPaneReattachSafe, readMcpGatewayLaunchRecord, writeMcpGatewayLaunchRecord, } from './core/plugins/mcp/launch-record.js';
54
56
  import { MCP_GATEWAY_REQUIRED_ENV, MCP_GATEWAY_SOCKET_ENV, } from './core/plugins/mcp/environment.js';
55
57
  import { readSessionMcpRuntimeManifest, sessionMcpRuntimeHostOnlyPaths, } from './core/plugins/mcp/session-runtime.js';
56
58
  import { prepareCliPluginGeneration } from './core/plugins/cli-generation.js';
@@ -60,6 +62,7 @@ import { deriveTerminalWriteToken, resolveTerminalAccessForRequest, safeTerminal
60
62
  import { deriveWorkerViewGeneration, looksLikeTerminalControlGrant, TERMINAL_VIEW_FORWARD_HEADER, verifyTerminalControlGrant, verifyTerminalViewForward, } from './core/terminal-control-grant.js';
61
63
  import { appendControlAudit, controlAuditRecord } from './dashboard/control-audit.js';
62
64
  import { readPlatformBinding } from './platform/binding.js';
65
+ import { buildPlatformDashboardLoginUrl } from './core/dashboard-url.js';
63
66
  import { loadDashboardSecret, loadPersistedToken } from './dashboard/auth.js';
64
67
  import { InflightInputTracker } from './core/inflight-input-tracker.js';
65
68
  import { InputTurnDeduper } from './core/input-turn-dedupe.js';
@@ -1172,16 +1175,38 @@ async function prepareCliPluginGenerationAndGateway(cfg, adapter) {
1172
1175
  const manifest = readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir);
1173
1176
  stopSessionMcpGatewayHost();
1174
1177
  if (adapter.mcpGateway && manifest?.entries.length) {
1175
- sessionMcpGatewayHost = await startSessionMcpGatewayHost({
1176
- sessionId: cfg.sessionId,
1177
- dataDir: config.session.dataDir,
1178
- trustedTurnIdentity: currentGatewayTrustedTurnIdentity,
1179
- onError: error => log(`[mcp-gateway] host error: ${error.message}`),
1180
- });
1178
+ await startAndRecordSessionMcpGatewayHost(cfg.sessionId);
1181
1179
  log(`[mcp-gateway] trusted host listening for ${manifest.entries.length} plugin server(s)`);
1182
1180
  }
1181
+ else {
1182
+ clearMcpGatewayLaunchRecord(config.session.dataDir, cfg.sessionId);
1183
+ }
1183
1184
  return manifest;
1184
1185
  }
1186
+ /** Bind the trusted MCP Gateway host at the session's DETERMINISTIC socket path
1187
+ * and persist its launch record. Deliberately does NOT refresh the plugin/Skill
1188
+ * generation: prepareCliPluginGenerationAndGateway does that for fresh/resumed
1189
+ * spawns, whereas a warm reattach to a surviving pane keeps the CLI's existing
1190
+ * catalog untouched — but the host itself died with the previous worker, so the
1191
+ * reattach path must still re-serve it here so the pane's surviving relay can
1192
+ * reconnect to the same path (see spawnCli's paneRelayReattachSafe branch).
1193
+ * The caller owns stopping any prior host first (prepare*) or gating on absence
1194
+ * (reattach). Token rotation + stale-socket removal are handled inside
1195
+ * startSessionMcpGatewayHost. The trusted-turn-identity provider (#917) is
1196
+ * wired here so BOTH the fresh/resumed host and the re-served reattach host
1197
+ * inject the per-turn host-signed caller — the reattach path must not lose it. */
1198
+ async function startAndRecordSessionMcpGatewayHost(sessionId) {
1199
+ sessionMcpGatewayHost = await startSessionMcpGatewayHost({
1200
+ sessionId,
1201
+ dataDir: config.session.dataDir,
1202
+ trustedTurnIdentity: currentGatewayTrustedTurnIdentity,
1203
+ onError: error => log(`[mcp-gateway] host error: ${error.message}`),
1204
+ });
1205
+ // The NEXT worker generation reads this record to decide whether a surviving
1206
+ // persistent pane can be reattached (relay reconnects to the same path)
1207
+ // instead of killed for a cold-resume.
1208
+ writeMcpGatewayLaunchRecord(config.session.dataDir, sessionId, sessionMcpGatewayHost.socketPath);
1209
+ }
1185
1210
  /** v2 read isolation — provision a bot's PER-BOT config dir under its BOT_HOME so the
1186
1211
  * CLI (redirected there via CLAUDE_CONFIG_DIR/CODEX_HOME) starts fully set up despite
1187
1212
  * the global ~/.claude|~/.codex being Seatbelt-denied. Idempotent (guards on
@@ -1534,6 +1559,18 @@ function refreshTerminalWriteToken() {
1534
1559
  if (secret && sessionId)
1535
1560
  writeToken = deriveTerminalWriteToken(secret, sessionId);
1536
1561
  }
1562
+ /**
1563
+ * Owner-login URL for THIS session's read-only web terminal, or '' when the
1564
+ * machine is unbound / remote access is off (banner then shows non-link text).
1565
+ * `next=/s/<sessionId>` is what makes the round-trip land the owner back on a
1566
+ * writable terminal: the platform routes a `/s/`-prefixed next to the terminal
1567
+ * subdomain surface and mints the host-only proxy-session cookie there, which
1568
+ * is the credential the owner write-check reads (the cross-subdomain SSO cookie
1569
+ * alone does not). See buildPlatformDashboardLoginUrl.
1570
+ */
1571
+ function deriveTerminalLoginUrl(sid) {
1572
+ return buildPlatformDashboardLoginUrl(`/s/${encodeURIComponent(sid)}`) ?? '';
1573
+ }
1537
1574
  function resolveTerminalAccessForReq(req, url) {
1538
1575
  // P1-6 invariant: an explicit, matching write-link `?token=` is an
1539
1576
  // independent capability with the HIGHEST priority. It wins outright — before
@@ -3505,6 +3542,19 @@ const bridgeSecondaryPaths = new Map(); // path → offset
3505
3542
  let bridgeOffset = 0;
3506
3543
  let bridgePendingTail = '';
3507
3544
  const bridgeQueue = new BridgeTurnQueue();
3545
+ /** Journal-restore of interrupted Lark turns is allowed AT MOST ONCE per worker
3546
+ * process, and only on the FIRST baseline this process runs. Rationale: the
3547
+ * journal exists to recover a turn that a *cross-process* death (daemon
3548
+ * restart / worker crash) orphaned — there the whole worker is new and no
3549
+ * other recovery owner survives. An *in-worker* CLI restart (crash-respawn,
3550
+ * cwd-move, durable lease expiry) keeps this process alive, so its
3551
+ * InflightInputTracker carryover already re-delivers the interrupted plain-IM
3552
+ * input (a full fresh answer) — letting the journal ALSO restore that turn
3553
+ * would post the recovered partial AND the fresh answer (same turnId, different
3554
+ * lastUuid → daemon dedupe can't collapse them). Armed on every watcher
3555
+ * teardown so restore is confined to the one baseline with no competing
3556
+ * recovery owner. */
3557
+ const bridgeRestoreGate = new BridgeRestoreGate();
3508
3558
  /** uuids of structured transcript rate-limit records already turned into a
3509
3559
  * `limited` emit. Readers may re-read from offset 0 after rotation, so the
3510
3560
  * stable record uuid keeps the notification idempotent. */
@@ -3666,6 +3716,46 @@ function bridgeMarkerPath() {
3666
3716
  return undefined;
3667
3717
  return join(process.env.SESSION_DATA_DIR, 'turn-sends', `${sessionId}.jsonl`);
3668
3718
  }
3719
+ /** Durable journal of pending Lark bridge turns (services/bridge-turn-journal).
3720
+ * Written at mark time, cleared at every turn-terminal outcome; consulted by
3721
+ * bridgeAbsorbBaseline after a restart so an interrupted turn's output can
3722
+ * still reach Lark. Sibling of the turn-sends marker file. */
3723
+ function bridgeTurnJournalFilePath() {
3724
+ if (!process.env.SESSION_DATA_DIR || !sessionId)
3725
+ return undefined;
3726
+ return join(process.env.SESSION_DATA_DIR, 'turn-marks', `${sessionId}.json`);
3727
+ }
3728
+ function journalBridgeTurnMark(entry) {
3729
+ const path = bridgeTurnJournalFilePath();
3730
+ if (!path)
3731
+ return;
3732
+ try {
3733
+ appendBridgeTurnJournalEntry(path, entry);
3734
+ }
3735
+ catch (err) {
3736
+ log(`Bridge turn journal write failed (${err.message}) — restart recovery unavailable for turn ${entry.turnId.substring(0, 8)}`);
3737
+ }
3738
+ }
3739
+ function journalBridgeTurnClear(turnId, dispatchAttempt) {
3740
+ const path = bridgeTurnJournalFilePath();
3741
+ if (!path)
3742
+ return;
3743
+ try {
3744
+ removeBridgeTurnJournalEntry(path, turnId, dispatchAttempt);
3745
+ }
3746
+ catch (err) {
3747
+ log(`Bridge turn journal clear failed (${err.message}) for turn ${turnId.substring(0, 8)}`);
3748
+ }
3749
+ }
3750
+ function clearBridgeTurnJournalFile() {
3751
+ const path = bridgeTurnJournalFilePath();
3752
+ if (!path)
3753
+ return;
3754
+ try {
3755
+ clearBridgeTurnJournal(path);
3756
+ }
3757
+ catch { /* best-effort — session is closing */ }
3758
+ }
3669
3759
  function readSendMarkers() {
3670
3760
  if (closeRequested)
3671
3761
  return [];
@@ -3916,6 +4006,13 @@ function bridgeAbsorbBaseline() {
3916
4006
  if (!bridgeJsonlPath)
3917
4007
  return;
3918
4008
  if (!lastInitConfig?.adoptMode) {
4009
+ // Restart recovery: if the previous generation left pending Lark turns in
4010
+ // the durable journal (worker/daemon died mid-turn), re-mark them and
4011
+ // baseline BEHIND their recorded offsets instead of skipping to EOF — the
4012
+ // interrupted turn's user line and partial assistant output are then
4013
+ // re-attributed and delivered through the normal fallback gate.
4014
+ if (tryRestoreInterruptedBridgeTurns())
4015
+ return;
3919
4016
  const cursor = baselineJsonlCursor(bridgeJsonlPath);
3920
4017
  bridgeOffset = cursor.newOffset;
3921
4018
  bridgePendingTail = cursor.pendingTail;
@@ -3935,6 +4032,72 @@ function bridgeAbsorbBaseline() {
3935
4032
  if (lastInitConfig?.adoptMode)
3936
4033
  maybeEmitAdoptPreamble(result.events);
3937
4034
  }
4035
+ /** Restore journal-persisted pending turns after a restart interrupted them.
4036
+ *
4037
+ * Returns true when it established the bridge cursor itself (restore mode);
4038
+ * false when there is nothing to restore and the caller should run the
4039
+ * normal baseline-to-EOF.
4040
+ *
4041
+ * Scope guards (all deliberate):
4042
+ * - non-adopt only (caller gates);
4043
+ * - same transcript file only — a sessionId rotation between generations
4044
+ * invalidates offsets and risks cross-file mis-attribution;
4045
+ * - bounded age (BRIDGE_TURN_RESTORE_MAX_AGE_MS) — a partial answer from
4046
+ * days ago is noise, not recovery.
4047
+ *
4048
+ * Safety properties:
4049
+ * - events before the earliest mark (minus the same 5s guard the
4050
+ * fingerprint switch uses) are ABSORBED as history, so old turns are
4051
+ * never re-emitted;
4052
+ * - a stray prior-turn assistant tail after the cutoff can only synthesize
4053
+ * a local turn, which the non-adopt emit gate always suppresses;
4054
+ * - a restored mark whose user line never landed in the jsonl simply
4055
+ * never starts, and pruneExpired clears it (journal entry included) on
4056
+ * the first idle tick;
4057
+ * - the send-marker gate still applies at emit — turn-sends markers
4058
+ * persist across restarts, so an answer the model already `botmux
4059
+ * send`-ed before the kill stays suppressed. */
4060
+ function tryRestoreInterruptedBridgeTurns() {
4061
+ // At-most-once, and only on this worker process's FIRST bridge baseline. Any
4062
+ // baseline that runs after a watcher teardown is an in-worker CLI restart
4063
+ // whose InflightInputTracker carryover already re-delivers the interrupted
4064
+ // turn — restoring here too would double-deliver (see bridgeRestoreGate,
4065
+ // armed in stopBridgeWatcher).
4066
+ if (!bridgeRestoreGate.mayRestoreOnThisBaseline())
4067
+ return false;
4068
+ const journalPath = bridgeTurnJournalFilePath();
4069
+ if (!journalPath || !bridgeJsonlPath)
4070
+ return false;
4071
+ const entries = readBridgeTurnJournal(journalPath);
4072
+ if (entries.length === 0)
4073
+ return false;
4074
+ const restorable = selectRestorableBridgeTurns(entries, { currentJsonlPath: bridgeJsonlPath });
4075
+ if (restorable.length !== entries.length) {
4076
+ // Entries for rotated-away files or beyond the age cap can never restore —
4077
+ // drop them now so they don't linger across future generations.
4078
+ try {
4079
+ writeBridgeTurnJournal(journalPath, restorable);
4080
+ }
4081
+ catch { /* best-effort */ }
4082
+ }
4083
+ if (restorable.length === 0)
4084
+ return false;
4085
+ for (const entry of restorable) {
4086
+ bridgeQueue.mark(entry.turnId, entry.fingerprint, entry.markTimeMs, entry.contentNormalized, entry.dispatchAttempt, { restoredFromJournal: true });
4087
+ }
4088
+ const fromOffset = Math.min(...restorable.map(entry => entry.offsetAtMark));
4089
+ const drained = drainTranscript(bridgeJsonlPath, fromOffset);
4090
+ bridgeOffset = drained.newOffset;
4091
+ bridgePendingTail = drained.pendingTail;
4092
+ const cutoffMs = Math.min(...restorable.map(entry => entry.markTimeMs)) - 5_000;
4093
+ const { history, live } = splitTranscriptEventsByCutoff(drained.events, cutoffMs);
4094
+ bridgeQueue.absorb(history);
4095
+ if (live.length > 0)
4096
+ bridgeQueue.ingest(live, bridgeJsonlPath);
4097
+ bridgeBaselineDone = true;
4098
+ log(`Bridge restored ${restorable.length} interrupted turn(s) from journal (drain ${fromOffset}→${bridgeOffset}, absorbed=${history.length}, live=${live.length})`);
4099
+ return true;
4100
+ }
3938
4101
  /** Record `bridgeStalePidStateSessionId` if the pid file's current sid
3939
4102
  * disagrees with the just-accepted candidate's sid. Stops the next pid
3940
4103
  * resolver tick from pulling the watcher back to the stale spawn-time
@@ -4501,6 +4664,7 @@ function bridgeIngest() {
4501
4664
  for (const t of expired) {
4502
4665
  if (t.contentFingerprint)
4503
4666
  bridgeFingerprintScanLastMs.delete(t.contentFingerprint);
4667
+ journalBridgeTurnClear(t.turnId, t.dispatchAttempt);
4504
4668
  log(`Bridge mark expired after ${Math.round(BRIDGE_PENDING_TURN_TTL_MS / 1000)}s without matching a jsonl user line (turnId=${t.turnId}) — dropped to prevent rotation-fallback scan loop.`);
4505
4669
  }
4506
4670
  // Drain secondary paths first so any trailing assistant text on an old
@@ -4755,6 +4919,12 @@ function stopBridgeWatcher() {
4755
4919
  // A fresh backend must not inherit a terminal/fingerprint from the exited
4756
4920
  // process; durable replay is owned by the receiver with a new attempt.
4757
4921
  bridgeQueue.clearPending();
4922
+ // A watcher teardown means this worker process has now had at least one CLI
4923
+ // generation, so any FUTURE baseline is an in-worker restart — never the
4924
+ // first-baseline cross-process recovery the journal is for. Disarm journal
4925
+ // restore: the restart's InflightInputTracker carryover already re-delivers
4926
+ // the interrupted turn, and restoring on top would double-deliver.
4927
+ bridgeRestoreGate.markGenerationRetired();
4758
4928
  bridgePreambleSent = false;
4759
4929
  }
4760
4930
  /**
@@ -4816,7 +4986,30 @@ function bridgeMarkPendingTurn(messageText, preferredTurnId, dispatchAttempt) {
4816
4986
  const normalised = normaliseForFingerprint(messageText);
4817
4987
  const contentNormalized = normalised.length > 0 ? normalised : undefined;
4818
4988
  const turnId = preferredTurnId ?? randomBytes(8).toString('hex');
4819
- bridgeQueue.mark(turnId, fingerprint, Date.now(), contentNormalized, dispatchAttempt);
4989
+ const markTimeMs = Date.now();
4990
+ bridgeQueue.mark(turnId, fingerprint, markTimeMs, contentNormalized, dispatchAttempt);
4991
+ // Journal the mark so a worker/daemon restart mid-turn can restore it
4992
+ // (bridgeAbsorbBaseline). Excluded:
4993
+ // - adopt mode: its baseline absorbs the whole transcript with different
4994
+ // semantics and never suppresses — restore marks would double-attribute;
4995
+ // - durable turns (dispatchAttempt !== undefined): a durable delivery has
4996
+ // its OWN cross-restart recovery owner (the daemon's persisted
4997
+ // queuedActivation* write-ahead + receipt auto-redispatch), so a journal
4998
+ // restore is always redundant for it and — because the recovered partial
4999
+ // and the re-dispatched attempt carry the same turnId but different
5000
+ // lastUuid — would double-deliver. Only PLAIN Lark IM turns (no other
5001
+ // recovery owner) actually need the journal.
5002
+ if (!lastInitConfig?.adoptMode && bridgeJsonlPath && dispatchAttempt === undefined) {
5003
+ journalBridgeTurnMark({
5004
+ turnId,
5005
+ dispatchAttempt,
5006
+ markTimeMs,
5007
+ fingerprint,
5008
+ contentNormalized,
5009
+ jsonlPath: bridgeJsonlPath,
5010
+ offsetAtMark: bridgeOffset,
5011
+ });
5012
+ }
4820
5013
  return turnId;
4821
5014
  }
4822
5015
  function bridgeDrainAndMaybeEmit() {
@@ -4846,6 +5039,14 @@ function emitReadyTurns(opts = {}) {
4846
5039
  });
4847
5040
  if (ready.length === 0)
4848
5041
  return;
5042
+ // Every popped turn is terminal (emitted or suppressed below) — retire its
5043
+ // durable journal entry NOW, before the final_output IPC. Clear-before-send:
5044
+ // if the worker dies in between, the answer is lost rather than duplicated
5045
+ // (the daemon-side dedupe key does not survive restarts).
5046
+ for (const turn of ready) {
5047
+ if (!turn.isLocal)
5048
+ journalBridgeTurnClear(turn.turnId, turn.dispatchAttempt);
5049
+ }
4849
5050
  const adoptMode = lastInitConfig?.adoptMode === true;
4850
5051
  // Send markers (`botmux send` landed in own thread) + the queue's first
4851
5052
  // still-unready turn. The latter caps the LAST ready turn's window —
@@ -4956,9 +5157,16 @@ function emitReadyTurns(opts = {}) {
4956
5157
  });
4957
5158
  continue;
4958
5159
  }
5160
+ // A restored turn's fallback is a RECOVERED partial/final produced around
5161
+ // a worker restart — label it so the user can tell it from a live answer.
5162
+ // Prefix is applied AFTER the suppress gate so dedup compares the model's
5163
+ // own text, never the notice.
5164
+ const deliveredText = turn.restoredFromJournal
5165
+ ? `${t('worker.bridge_restored_turn_notice')}\n\n${postText}`
5166
+ : postText;
4959
5167
  send({
4960
5168
  type: 'final_output',
4961
- content: postText,
5169
+ content: deliveredText,
4962
5170
  lastUuid,
4963
5171
  turnId: turn.turnId,
4964
5172
  ...(turn.dispatchAttempt !== undefined ? { dispatchAttempt: turn.dispatchAttempt } : {}),
@@ -9329,6 +9537,7 @@ function dropFailedBridgeMark(bridgeTurnId, dispatchAttempt) {
9329
9537
  if (dropped) {
9330
9538
  if (dropped.contentFingerprint)
9331
9539
  bridgeFingerprintScanLastMs.delete(dropped.contentFingerprint);
9540
+ journalBridgeTurnClear(bridgeTurnId, dispatchAttempt);
9332
9541
  log(`Bridge mark dropped after submit failure (turnId=${bridgeTurnId}) — rotation-fallback scan will stop spinning on this fingerprint.`);
9333
9542
  }
9334
9543
  if (droppedStructured) {
@@ -11927,11 +12136,35 @@ async function spawnCli(cfg, opts = {}) {
11927
12136
  if (effectiveBackendType === 'zmx') {
11928
12137
  resolvedZmxSessionProbe = paneProbe;
11929
12138
  }
11930
- if (paneProbe === 'exists') {
11931
- // The trusted Gateway host belongs to the worker and cannot survive a
11932
- // worker/daemon replacement. Cold-resume the CLI so its MCP client gets a
11933
- // fresh relay socket instead of reattaching to a dead connection.
11934
- log(`[mcp-gateway] persistent pane ${cfg.sessionId} has plugin MCP state cold-resuming with a fresh host`);
12139
+ const paneRelayReattachSafe = paneProbe === 'exists'
12140
+ && mcpGatewayPaneReattachSafe(readMcpGatewayLaunchRecord(config.session.dataDir, cfg.sessionId), sessionMcpGatewaySocketPath(cfg.sessionId, config.session.dataDir));
12141
+ if (paneRelayReattachSafe) {
12142
+ // The pane's CLI was launched against the deterministic Gateway socket
12143
+ // path by a reconnect-capable relay. Reattaching preserves whatever turn
12144
+ // the CLI is executing — the whole point of the persistent backend — but
12145
+ // the trusted host died with the previous worker, so we must RE-SERVE it
12146
+ // here at the same deterministic path for the pane's surviving relay to
12147
+ // reconnect to. This reattach path skips prepareCliPluginGenerationAndGateway
12148
+ // (it must not refresh the catalog on a warm reattach), which is the only
12149
+ // other host starter; without this the relay would reconnect forever to a
12150
+ // socket nothing binds. Start only when this fresh worker has no live host
12151
+ // yet — never stomp a host an in-worker restart already brought up.
12152
+ if (!sessionMcpGatewayHost) {
12153
+ await startAndRecordSessionMcpGatewayHost(cfg.sessionId);
12154
+ if (spawnGeneration !== cliSpawnGeneration)
12155
+ throw new CliSpawnSupersededError();
12156
+ log(`[mcp-gateway] persistent pane ${cfg.sessionId} keeps its relay socket path — re-served trusted host at the same path; relay reconnects on its own`);
12157
+ }
12158
+ else {
12159
+ log(`[mcp-gateway] persistent pane ${cfg.sessionId} keeps its relay socket path — reattaching; live host already bound, relay stays connected`);
12160
+ }
12161
+ }
12162
+ else if (paneProbe === 'exists') {
12163
+ // Legacy pane (mkdtemp-random socket path, or a relay predating
12164
+ // reconnect support): its MCP client can never reach the replacement
12165
+ // host. Cold-resume the CLI so it gets a fresh relay socket instead of
12166
+ // reattaching to a dead connection.
12167
+ log(`[mcp-gateway] persistent pane ${cfg.sessionId} has plugin MCP state without a reconnect-capable relay — cold-resuming with a fresh host`);
11935
12168
  const persistentBackendType = effectiveBackendType;
11936
12169
  const persistentTarget = selectedBackend.persistentBackendTarget;
11937
12170
  if (effectiveBackendType === 'zmx') {
@@ -14719,7 +14952,22 @@ function startWebServer(host, preferredPort) {
14719
14952
  return;
14720
14953
  }
14721
14954
  const loginHdr = req.headers['x-botmux-login-url'];
14722
- const loginUrl = typeof loginHdr === 'string' && /^https?:\/\/[^"'<>\s]+$/.test(loginHdr) ? loginHdr : '';
14955
+ // The central front proxy only injects X-Botmux-Login-Url on the SPA 401
14956
+ // path, never on `/s/` terminal requests — so a read-only web terminal
14957
+ // showed an owner-login banner that was a dead <div> (no href). When the
14958
+ // header is absent, self-derive the platform owner-login URL for THIS
14959
+ // session's terminal path: `/open/<machineId>?next=/s/<sessionId>`. The
14960
+ // `/s/` next routes the platform to the terminal subdomain surface and,
14961
+ // after login, lands the browser back on this terminal WITH the host-only
14962
+ // proxy-session cookie the owner check requires (the cross-subdomain SSO
14963
+ // cookie alone does not make the request writable). Returns '' when this
14964
+ // machine is unbound or remote access is off, so the banner falls back to
14965
+ // its non-link text form rather than a broken link.
14966
+ const headerLoginUrl = typeof loginHdr === 'string' && /^https?:\/\/[^"'<>\s]+$/.test(loginHdr)
14967
+ ? loginHdr
14968
+ : '';
14969
+ const loginUrl = headerLoginUrl
14970
+ || (sessionId ? deriveTerminalLoginUrl(sessionId) : '');
14723
14971
  // Herdr snapshots contain screen cells but not terminal mode state. On a
14724
14972
  // refreshed page an alt-screen CLI would otherwise look like an empty
14725
14973
  // normal buffer until resize causes a redraw. Preserve that mode so
@@ -17359,11 +17607,13 @@ process.on('message', async (raw) => {
17359
17607
  catch { /* logged by backend */ }
17360
17608
  }
17361
17609
  killCli();
17362
- // Bridge marker file outlives a single CLI process (we keep it across
17363
- // restarts so a mid-flight send is still credited), but a real close
17364
- // tears down the session purge the file so a future re-use of the
17365
- // same sessionId starts clean.
17610
+ // Bridge marker + turn-journal files outlive a single CLI process (kept
17611
+ // across restarts so a mid-flight send is still credited and an
17612
+ // interrupted turn can be restored), but a real close tears down the
17613
+ // session — purge both so a future re-use of the same sessionId starts
17614
+ // clean.
17366
17615
  clearSendMarkers();
17616
+ clearBridgeTurnJournalFile();
17367
17617
  cleanup();
17368
17618
  process.exit(0);
17369
17619
  }
@@ -17536,6 +17786,7 @@ process.on('message', async (raw) => {
17536
17786
  stopCodexBridge();
17537
17787
  killCli();
17538
17788
  clearSendMarkers();
17789
+ clearBridgeTurnJournalFile();
17539
17790
  cleanup();
17540
17791
  process.exit(0);
17541
17792
  }