@dsiloed/silo-link 1.19.12 → 1.19.14

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 (37) hide show
  1. package/README.md +8 -0
  2. package/dist/cable/subscription-manager.d.ts +26 -1
  3. package/dist/cable/subscription-manager.d.ts.map +1 -1
  4. package/dist/cable/subscription-manager.js +72 -13
  5. package/dist/cable/subscription-manager.js.map +1 -1
  6. package/dist/cli/file-log.d.ts +58 -3
  7. package/dist/cli/file-log.d.ts.map +1 -1
  8. package/dist/cli/file-log.js +110 -7
  9. package/dist/cli/file-log.js.map +1 -1
  10. package/dist/core/agent-launcher.d.ts +19 -0
  11. package/dist/core/agent-launcher.d.ts.map +1 -1
  12. package/dist/core/base-tmux-launcher.d.ts +64 -19
  13. package/dist/core/base-tmux-launcher.d.ts.map +1 -1
  14. package/dist/core/base-tmux-launcher.js +273 -75
  15. package/dist/core/base-tmux-launcher.js.map +1 -1
  16. package/dist/core/bridge.d.ts.map +1 -1
  17. package/dist/core/bridge.js +9 -4
  18. package/dist/core/bridge.js.map +1 -1
  19. package/dist/core/claude-launcher.d.ts +7 -2
  20. package/dist/core/claude-launcher.d.ts.map +1 -1
  21. package/dist/core/claude-launcher.js +30 -8
  22. package/dist/core/claude-launcher.js.map +1 -1
  23. package/dist/core/codex-launcher.d.ts +1 -1
  24. package/dist/core/codex-launcher.d.ts.map +1 -1
  25. package/dist/core/codex-launcher.js +7 -3
  26. package/dist/core/codex-launcher.js.map +1 -1
  27. package/dist/core/gemini-launcher.d.ts +1 -1
  28. package/dist/core/gemini-launcher.d.ts.map +1 -1
  29. package/dist/core/gemini-launcher.js +7 -3
  30. package/dist/core/gemini-launcher.js.map +1 -1
  31. package/dist/core/transcript-usage.d.ts +9 -0
  32. package/dist/core/transcript-usage.d.ts.map +1 -1
  33. package/dist/core/transcript-usage.js +19 -0
  34. package/dist/core/transcript-usage.js.map +1 -1
  35. package/dist/types/index.d.ts +2 -0
  36. package/dist/types/index.d.ts.map +1 -1
  37. package/package.json +1 -1
@@ -54,6 +54,30 @@ export class BaseTmuxLauncher {
54
54
  // Panes already reported as orphaned-but-working, so the warning is logged
55
55
  // once rather than every idle cycle.
56
56
  orphanPaneNoted = new Set();
57
+ // Conversations whose CURRENT session came from an auto-launch (a message
58
+ // arriving for a conversation with no live session — the "resurrect to
59
+ // deliver" path) rather than a deliberate control-channel dispatch. Such
60
+ // sessions get an automatic idle wind-down: when the delivered message
61
+ // needed no work, nothing else ever cleans the stub up (idle-kill is
62
+ // usually off, the backend sees live heartbeats, and daemons no longer
63
+ // restart routinely). A later deliberate launch for the same conversation
64
+ // clears the mark.
65
+ autoLaunchedConversations = new Set();
66
+ static RESURRECT_IDLE_KILL_DEFAULT_MS = 12 * 60 * 1000;
67
+ getResurrectIdleKillMs() {
68
+ const configured = this.config.resurrect_idle_kill_ms;
69
+ return typeof configured === 'number' ? configured : BaseTmuxLauncher.RESURRECT_IDLE_KILL_DEFAULT_MS;
70
+ }
71
+ /**
72
+ * The idle-kill threshold that applies to this conversation's RESURRECT
73
+ * status: >0 only when its current session was auto-launched and the
74
+ * resurrect wind-down is enabled.
75
+ */
76
+ resurrectIdleKillThresholdMs(conversationId) {
77
+ if (!conversationId || !this.autoLaunchedConversations.has(conversationId))
78
+ return 0;
79
+ return this.getResurrectIdleKillMs();
80
+ }
57
81
  // A REGISTERED session with a live pane that has made no MCP call at all for
58
82
  // this long is wedged, not busy: a healthy SiloLink agent polls every few
59
83
  // seconds, so lastActivity advances constantly and a working session can
@@ -79,12 +103,26 @@ export class BaseTmuxLauncher {
79
103
  static PROMPT_STALL_THRESHOLD_MS = 3 * 60 * 1000;
80
104
  // Re-notify cadence while the SAME stall persists unanswered.
81
105
  static PROMPT_STALL_RENOTIFY_MS = 30 * 60 * 1000;
82
- lastSessionId = null;
83
- // Which conversation lastSessionId belongs to. A pinned resume id replays ONE
84
- // conversation's CLI context; multi-conversation daemons (personal) must never
85
- // resume it for a different conversation or the worker re-registers under the
86
- // old conversation (the nightly-audit-review attribution leak).
87
- lastSessionConversationId = null;
106
+ // Resume ids restored PER CONVERSATION for its next launch (from a dying
107
+ // session, Rails metadata at boot, or the process-dead sweep). Key 0 holds a
108
+ // pin of unknown provenance, usable only by a launch with no target
109
+ // conversation. This replaces the launcher-global `lastSessionId` slot: one
110
+ // slot shared by every conversation let a capture for conv A become conv B's
111
+ // `--resume` target (three transcripts pinned to conv 3331 in turn, then
112
+ // handed to 3337 and 3369 — 2026-08-24). A pinned resume id replays ONE
113
+ // conversation's CLI context and must never cross conversations.
114
+ restoredResumeIds = new Map();
115
+ // resumeId → owning conversation. Survives unregister: a transcript that once
116
+ // belonged to conv A is never attributed to conv B later in this process
117
+ // lifetime, even after A's session is gone (the old guard only looked at
118
+ // LIVE sessions, so 3331's transcript was captured for 3337 the moment 3331
119
+ // stopped).
120
+ resumeIdOwner = new Map();
121
+ // Resume ids minted at launch (Claude `--session-id`), per conversation.
122
+ mintedResumeIds = new Map();
123
+ // `${agentSessionId}:${resumeId}` pairs already persisted to Rails — the
124
+ // metadata PUT happens ONCE per pair, not on every liveness tick.
125
+ persistedResumeIds = new Set();
88
126
  pendingTriggerMessage = null;
89
127
  pendingWorkspaceConfig = null;
90
128
  pendingWorkspaceCwd = null;
@@ -441,6 +479,10 @@ export class BaseTmuxLauncher {
441
479
  // isAutoRespawnEnabled() at their own call sites.
442
480
  if (!this.enabled)
443
481
  return;
482
+ // Idempotent: a second call must not overwrite idleCheckTimer and orphan
483
+ // the first interval (the Codex launcher used to be started twice).
484
+ if (this.idleCheckTimer)
485
+ return;
444
486
  // Reap tmux sessions left over from a prior daemon run before the
445
487
  // monitor starts. The new daemon's tmuxSessions map is empty by
446
488
  // definition, and the spawned CCs in those panes were bound to the
@@ -456,6 +498,28 @@ export class BaseTmuxLauncher {
456
498
  const respawnState = this.isAutoRespawnConfigured() ? 'auto-respawn on' : 'auto-respawn off';
457
499
  console.log(` ${this.getLogTag()} idle monitor running (idle timeout: ${this.getIdleTimeoutMs()}ms, ${respawnState})`);
458
500
  }
501
+ /** The env name this daemon was started as (\`--env <name>\`). */
502
+ ownerEnv() {
503
+ return this.config.config_env || 'default';
504
+ }
505
+ /**
506
+ * SILOLINK_OWNER_ENV stamped on a tmux session at launch, or null when the
507
+ * pane predates the marker (or tmux can't answer).
508
+ */
509
+ readPaneOwnerEnv(name) {
510
+ return new Promise((resolve) => {
511
+ const show = spawn('tmux', ['show-environment', '-t', name, 'SILOLINK_OWNER_ENV'], {
512
+ stdio: ['ignore', 'pipe', 'ignore'],
513
+ });
514
+ let out = '';
515
+ show.stdout.on('data', (d) => { out += d.toString(); });
516
+ show.on('exit', () => {
517
+ const m = out.trim().match(/^SILOLINK_OWNER_ENV=(.*)$/);
518
+ resolve(m ? m[1] : null);
519
+ });
520
+ show.on('error', () => resolve(null));
521
+ });
522
+ }
459
523
  /**
460
524
  * Scan tmux for sessions matching this launcher's prefix and kill any
461
525
  * we don't currently track. Intended to run once at daemon startup to
@@ -481,12 +545,28 @@ export class BaseTmuxLauncher {
481
545
  if (names.length === 0)
482
546
  return;
483
547
  const reaped = [];
548
+ const skipped = [];
484
549
  for (const name of names) {
485
550
  if (this.tmuxSessions.has(name))
486
551
  continue;
552
+ // Only reap panes THIS env owns. On a multi-env host the tmux namespace
553
+ // is shared, so an untracked pane is usually another daemon's LIVE
554
+ // session, not our leftover (2026-08-23: rolling fleet auto-update —
555
+ // every env boot reaped the video env's in-flight recording). A pane
556
+ // with no marker (pre-1.19.13 launch) is also skipped: a leaked pane is
557
+ // reconciled server-side within minutes, a killed live session is not
558
+ // recoverable at all.
559
+ const owner = await this.readPaneOwnerEnv(name);
560
+ if (!shouldReapOrphanPane(owner, this.ownerEnv())) {
561
+ skipped.push(`${name}(owner=${owner ?? 'unmarked'})`);
562
+ continue;
563
+ }
487
564
  reaped.push(name);
488
565
  spawn('tmux', ['kill-session', '-t', name], { stdio: 'ignore' });
489
566
  }
567
+ if (skipped.length > 0) {
568
+ console.log(`${this.getLogTag()} Startup reap: left ${skipped.length} pane(s) owned elsewhere/unmarked: ${skipped.join(', ')}`);
569
+ }
490
570
  if (reaped.length > 0) {
491
571
  console.log(`${this.getLogTag()} Reaped ${reaped.length} orphan tmux session(s) from prior daemon run: ${reaped.join(', ')}`);
492
572
  }
@@ -631,10 +711,23 @@ export class BaseTmuxLauncher {
631
711
  return prompt;
632
712
  }
633
713
  setLastResumeId(resumeId, conversationId) {
634
- this.lastSessionId = resumeId;
635
- this.lastSessionConversationId = conversationId ?? null;
714
+ this.restoredResumeIds.set(conversationId ?? 0, resumeId);
715
+ if (conversationId)
716
+ this.resumeIdOwner.set(resumeId, conversationId);
636
717
  console.log(`${this.getLogTag()} Restored resume ID: ${resumeId} (conv=${conversationId ?? 'unknown'})`);
637
718
  }
719
+ /** The resume id minted at launch for a conversation (Claude), if any. */
720
+ getMintedResumeId(conversationId) {
721
+ return this.mintedResumeIds.get(conversationId);
722
+ }
723
+ /**
724
+ * Public entry to the capture path — the Bridge calls it right after it
725
+ * pre-registers a launched session so a minted id lands on the Session (and
726
+ * is persisted once) immediately rather than on the next liveness tick.
727
+ */
728
+ syncResumeId(conversationId) {
729
+ this.captureResumeId(conversationId);
730
+ }
638
731
  /**
639
732
  * The resume id to use when launching for `conversationId`, or null to launch
640
733
  * fresh. Only a pin captured for the SAME conversation may be resumed — a pin
@@ -645,32 +738,35 @@ export class BaseTmuxLauncher {
645
738
  resumeTargetFor(conversationId) {
646
739
  // Per-conversation pin is authoritative: each Session stores the resume id
647
740
  // captured for ITS OWN transcript, so two conversations sharing a cwd never
648
- // resume each other's CLI session. The global lastSessionId is only a
649
- // fallback, and only when it was captured for THIS same conversation.
741
+ // resume each other's CLI session. The per-conversation restored id is the
742
+ // fallback (dying session / Rails metadata at boot). Nothing else — a pin
743
+ // from another conversation or of unknown provenance launches fresh.
650
744
  if (conversationId) {
651
745
  const pinned = this.sessionManager.getByConversationId(conversationId)?.resumeId;
652
746
  if (pinned)
653
747
  return pinned;
654
- if (this.lastSessionConversationId !== conversationId) {
655
- if (this.lastSessionId) {
656
- console.warn(`${this.getLogTag()} Resume id ${this.lastSessionId} belongs to conv=${this.lastSessionConversationId ?? 'unknown'} — launching conv=${conversationId} fresh instead of resuming it.`);
657
- }
658
- return null;
748
+ const restored = this.restoredResumeIds.get(conversationId);
749
+ if (restored)
750
+ return restored;
751
+ const orphanPin = this.restoredResumeIds.get(0);
752
+ if (orphanPin) {
753
+ console.warn(`${this.getLogTag()} Resume id ${orphanPin} has no recorded conversation — launching conv=${conversationId} fresh instead of resuming it.`);
659
754
  }
755
+ return null;
660
756
  }
661
- return this.lastSessionId;
757
+ return this.restoredResumeIds.get(0) ?? null;
662
758
  }
663
759
  /**
664
760
  * Forget a resume target that turned out to be unresumable so the next
665
761
  * relaunch starts FRESH instead of re-crashing on the same `--resume <id>`.
666
- * Clears all three stores captureResumeId writes to: the global `lastSessionId`,
667
- * the in-memory SessionInfo (which finishPaneTeardown would otherwise restore),
762
+ * Clears every store a resume target lives in: the per-conversation restored
763
+ * and minted ids, the in-memory SessionInfo (which finishPaneTeardown would otherwise restore),
668
764
  * and the persisted rails AgentSession metadata (so a daemon restart can't
669
765
  * resurrect it). Best-effort; never throws into the teardown path.
670
766
  */
671
767
  clearResumeId(conversationId) {
672
- this.lastSessionId = null;
673
- this.lastSessionConversationId = null;
768
+ this.restoredResumeIds.delete(conversationId || 0);
769
+ this.mintedResumeIds.delete(conversationId);
674
770
  const session = conversationId
675
771
  ? this.sessionManager.getByConversationId(conversationId)
676
772
  : this.sessionManager.getAll()[0];
@@ -982,6 +1078,16 @@ export class BaseTmuxLauncher {
982
1078
  return { command, resolvedPath: this.resolveCommandPath(command) };
983
1079
  }
984
1080
  async launchInternal(reason = 'manual', conversationId) {
1081
+ // Record the launch origin for the resurrect idle wind-down. Respawns
1082
+ // ('process-exit') keep the conversation's existing origin — a respawned
1083
+ // deliberate session stays deliberate, a respawned resurrect stays a
1084
+ // resurrect.
1085
+ if (conversationId) {
1086
+ if (reason === 'inbound-message')
1087
+ this.autoLaunchedConversations.add(conversationId);
1088
+ else if (reason !== 'process-exit')
1089
+ this.autoLaunchedConversations.delete(conversationId);
1090
+ }
985
1091
  if (!this.enabled) {
986
1092
  console.log(`${this.getLogTag()} Not configured`);
987
1093
  return false;
@@ -1167,6 +1273,12 @@ export class BaseTmuxLauncher {
1167
1273
  });
1168
1274
  tmuxCreate.unref();
1169
1275
  spawn('tmux', ['set-option', '-t', tmuxSession, 'remain-on-exit', 'on'], { stdio: 'ignore' });
1276
+ // Ownership marker for the startup orphan reaper: on a multi-env host
1277
+ // every daemon shares the same tmux namespace, and an unmarked pane is
1278
+ // indistinguishable from a leftover of THIS env's prior run. 2026-08-23:
1279
+ // a fleet-wide auto-update rolled every env daemon in sequence and each
1280
+ // boot's reaper killed the video env's LIVE recording session — twice.
1281
+ spawn('tmux', ['set-environment', '-t', tmuxSession, 'SILOLINK_OWNER_ENV', this.ownerEnv()], { stdio: 'ignore' });
1170
1282
  // Wait for tmux session to start
1171
1283
  await new Promise(resolve => setTimeout(resolve, 3000));
1172
1284
  const sessionExists = await new Promise((resolve) => {
@@ -1181,10 +1293,16 @@ export class BaseTmuxLauncher {
1181
1293
  return false;
1182
1294
  }
1183
1295
  console.log(`${this.getLogTag()} tmux session "${tmuxSession}" created`);
1184
- this.tmuxSessions.set(tmuxSession, { conversationId: conversationId || 0, startedAt: new Date(), cwd });
1296
+ const mintedSessionId = conversationId ? this.mintedResumeIds.get(conversationId) : undefined;
1297
+ this.tmuxSessions.set(tmuxSession, { conversationId: conversationId || 0, startedAt: new Date(), cwd, mintedSessionId });
1185
1298
  if (workspaceSessionId) {
1186
1299
  this.tmuxSessionWorkspaces.set(tmuxSession, workspaceSessionId);
1187
1300
  }
1301
+ // A minted id needs no discovery — pin it on an already-registered session
1302
+ // (relaunch) right away. A fresh launch's session is pre-registered by the
1303
+ // Bridge afterwards, which calls syncResumeId.
1304
+ if (mintedSessionId && conversationId)
1305
+ this.captureResumeId(conversationId);
1188
1306
  // Wait for agent to be ready, then send the prompt
1189
1307
  const sessionToPrompt = tmuxSession;
1190
1308
  const promptToSend = this.formatPrompt(prompt);
@@ -1361,7 +1479,7 @@ export class BaseTmuxLauncher {
1361
1479
  // forget that id so finishPaneTeardown relaunches FRESH instead of
1362
1480
  // re-issuing the same doomed `--resume`/`resume` and looping forever.
1363
1481
  // Applies to every CLI via the per-launcher isResumeFailure() override.
1364
- const hadResumeTarget = this.lastSessionId != null
1482
+ const hadResumeTarget = this.restoredResumeIds.has(info.conversationId)
1365
1483
  || this.sessionManager.getByConversationId(info.conversationId)?.resumeId != null;
1366
1484
  if (hadResumeTarget && this.isResumeFailure(paneContent)) {
1367
1485
  console.warn(`${this.getLogTag()} Pane died on an unresumable session for conv=${info.conversationId} — clearing resume id and relaunching fresh.`);
@@ -1391,12 +1509,12 @@ export class BaseTmuxLauncher {
1391
1509
  const stillRegistered = this.sessionManager.getByConversationId(conversationId);
1392
1510
  if (!stillRegistered)
1393
1511
  return;
1394
- // RESTORE resume id from the dying SESSION's stored value (not a stale
1395
- // global) BEFORE we unregister it — this is what lets the auto-respawn relaunch
1396
- // the SAME CLI session (`--resume <id>`) and continue an in-progress task
1397
- // instead of starting fresh. Best-effort: if the worker never captured an id
1398
- // (capture failed / CLI has no resume), lastSessionId stays null and the
1399
- // relaunch falls back to a fresh session — current behavior, never blocked.
1512
+ // RESTORE resume id from the dying SESSION's stored value BEFORE we
1513
+ // unregister it — this is what lets the auto-respawn relaunch the SAME CLI
1514
+ // session (`--resume <id>`) and continue an in-progress task instead of
1515
+ // starting fresh. Best-effort: if the worker never captured an id (capture
1516
+ // failed / CLI has no resume), nothing is restored and the relaunch falls
1517
+ // back to a fresh session — never blocked.
1400
1518
  const resumeId = stillRegistered.resumeId || stillRegistered.claudeResumeId;
1401
1519
  if (resumeId) {
1402
1520
  this.setLastResumeId(resumeId, conversationId);
@@ -1477,23 +1595,27 @@ export class BaseTmuxLauncher {
1477
1595
  *
1478
1596
  * Best-effort: returns null on any failure. NEVER throws into the launch path.
1479
1597
  */
1480
- detectResumeId(_cwd) {
1598
+ detectResumeId(_cwd, _notBefore) {
1481
1599
  return null;
1482
1600
  }
1483
1601
  /**
1484
- * Capture the running worker's CLI session id and remember it so the next
1485
- * respawn relaunches the SAME session (`--resume`). Stores on three places:
1486
- * 1. `this.lastSessionId` — what buildLaunchPrompt reads to add resume args.
1487
- * 2. the SessionInfo (`resumeId`) — survives in-memory so finishPaneTeardown
1488
- * can restore it just before relaunch (keyed off the SESSION, not a stale
1489
- * global).
1490
- * 3. the rails AgentSession metadata (`cli_resume_id` + legacy
1491
- * `claude_resume_id`) — survives a daemon restart so bridge startup /
1492
- * remote_load_context can restore it.
1602
+ * Pin the running worker's CLI session id on its Session so the next respawn
1603
+ * relaunches the SAME session (`--resume`). Two sources, in order:
1604
+ * 1. the id MINTED at launch (Claude `--session-id`) — authoritative, no
1605
+ * discovery, never overwritten;
1606
+ * 2. discovery from the CLI's on-disk transcript (Gemini/Codex, and Claude
1607
+ * launches that resumed) — accepted only if the transcript was touched
1608
+ * at/after the pane's startedAt.
1609
+ * Either way the id is refused when another conversation owns it — a LIVE
1610
+ * session or any conversation seen earlier this process lifetime
1611
+ * (`resumeIdOwner`, which survives unregister). Stores on the Session
1612
+ * (`resumeId`) and persists `cli_resume_id` + legacy `claude_resume_id` to the
1613
+ * rails AgentSession metadata ONCE per (agent session, id) pair — that
1614
+ * survives a daemon restart so bridge startup / remote_load_context can
1615
+ * restore it.
1493
1616
  *
1494
- * Idempotent + throttled by the caller (runs on the live-usage tick and once
1495
- * shortly after launch). Best-effort — a capture failure leaves prior state
1496
- * intact and never blocks anything.
1617
+ * Idempotent; runs on the liveness tick and once right after launch.
1618
+ * Best-effort — a capture failure leaves prior state intact and never blocks.
1497
1619
  */
1498
1620
  captureResumeId(conversationId) {
1499
1621
  const session = conversationId
@@ -1502,44 +1624,63 @@ export class BaseTmuxLauncher {
1502
1624
  if (!session)
1503
1625
  return;
1504
1626
  // Locate by the ACTUAL launch cwd (same resolution extractTranscriptUsage uses).
1505
- let cwd;
1506
- for (const info of this.tmuxSessions.values()) {
1507
- if (info.conversationId === conversationId) {
1508
- cwd = info.cwd;
1627
+ let info;
1628
+ for (const candidate of this.tmuxSessions.values()) {
1629
+ if (candidate.conversationId === conversationId) {
1630
+ info = candidate;
1509
1631
  break;
1510
1632
  }
1511
1633
  }
1512
- cwd ||= this.getWorkingDirectory();
1634
+ const cwd = info?.cwd || this.getWorkingDirectory();
1635
+ const minted = info?.mintedSessionId ?? this.mintedResumeIds.get(conversationId);
1513
1636
  let resumeId = null;
1514
- try {
1515
- resumeId = this.detectResumeId(cwd);
1637
+ let source;
1638
+ if (minted) {
1639
+ resumeId = minted;
1640
+ source = 'minted';
1516
1641
  }
1517
- catch (err) {
1518
- console.warn(`${this.getLogTag()} detectResumeId failed for conv=${conversationId}: ${err}`);
1519
- return;
1642
+ else {
1643
+ try {
1644
+ resumeId = this.detectResumeId(cwd, info?.startedAt);
1645
+ }
1646
+ catch (err) {
1647
+ console.warn(`${this.getLogTag()} detectResumeId failed for conv=${conversationId}: ${err}`);
1648
+ return;
1649
+ }
1650
+ source = 'discovered';
1520
1651
  }
1521
1652
  if (!resumeId)
1522
1653
  return;
1523
- // Cross-conversation guard: detectResumeId is cwd-scoped ("most recent
1524
- // transcript in the launch cwd"), so when two conversations share a cwd it
1525
- // can return the OTHER conversation's transcript. Never pin a transcript id
1526
- // that a different live conversation's session already owns — that would
1527
- // make this conversation resume the other's CLI session (observed 2026-07-18:
1528
- // conv 2170 stole conv 2158's id, both in the demo-kit cwd, so the 2170
1529
- // pane ended up polling 2158). Leave this conversation fresh instead.
1530
- const otherOwner = this.sessionManager.getAll().find((s) => s.resumeId === resumeId && s.conversationId !== conversationId);
1531
- if (otherOwner) {
1532
- console.warn(`${this.getLogTag()} Resume id ${resumeId} already owned by conv=${otherOwner.conversationId} — NOT capturing it for conv=${conversationId} (shared cwd). Leaving fresh.`);
1654
+ // Ownership guard. detectResumeId is cwd-scoped ("most recent transcript in
1655
+ // the launch cwd"), so when two conversations share a cwd it can return the
1656
+ // OTHER conversation's transcript. Never pin an id that a different
1657
+ // conversation owns — live (2026-07-18: conv 2170 stole conv 2158's id and
1658
+ // the 2170 pane ended up polling 2158) or previously seen (2026-08-24: once
1659
+ // 3331 stopped, its transcript was captured for 3337, then 3337's for 3369).
1660
+ const priorOwner = this.resumeIdOwner.get(resumeId);
1661
+ if (priorOwner !== undefined && priorOwner !== conversationId) {
1662
+ console.warn(`${this.getLogTag()} Resume id ${resumeId} is owned by conv=${priorOwner} — NOT capturing it for conv=${conversationId} (${source}). Leaving fresh.`);
1533
1663
  return;
1534
1664
  }
1535
- // No change — already captured this id.
1536
- if (this.lastSessionId === resumeId && session.resumeId === resumeId)
1665
+ const otherLive = this.sessionManager.getAll().find((s) => s.resumeId === resumeId && s.conversationId !== conversationId);
1666
+ if (otherLive) {
1667
+ console.warn(`${this.getLogTag()} Resume id ${resumeId} already owned by live conv=${otherLive.conversationId} — NOT capturing it for conv=${conversationId} (shared cwd). Leaving fresh.`);
1537
1668
  return;
1538
- this.lastSessionId = resumeId;
1539
- this.lastSessionConversationId = conversationId || session.conversationId || null;
1540
- session.resumeId = resumeId;
1541
- session.claudeResumeId = resumeId; // keep legacy alias in sync
1669
+ }
1670
+ this.resumeIdOwner.set(resumeId, conversationId);
1671
+ const changed = session.resumeId !== resumeId;
1672
+ if (changed) {
1673
+ if (session.resumeId) {
1674
+ console.log(`${this.getLogTag()} Resume id for conv=${conversationId}: ${session.resumeId} → ${resumeId} (${source})`);
1675
+ }
1676
+ session.resumeId = resumeId;
1677
+ session.claudeResumeId = resumeId; // keep legacy alias in sync
1678
+ }
1542
1679
  if (this.dsiloedClient && session.agentSessionId) {
1680
+ const key = `${session.agentSessionId}:${resumeId}`;
1681
+ if (this.persistedResumeIds.has(key))
1682
+ return;
1683
+ this.persistedResumeIds.add(key);
1543
1684
  // MERGE — only the resume keys; a full replace would wipe workspace_* and
1544
1685
  // server-set keys. Persist BOTH a generic key and the legacy claude key the
1545
1686
  // bridge startup / remote_load_context already read.
@@ -1547,11 +1688,14 @@ export class BaseTmuxLauncher {
1547
1688
  cli_resume_id: resumeId,
1548
1689
  claude_resume_id: resumeId,
1549
1690
  }).then(() => {
1550
- console.log(`${this.getLogTag()} Captured resume id ${resumeId} for conv=${conversationId} (agent session ${session.agentSessionId})`);
1551
- }).catch(err => console.error(`${this.getLogTag()} Failed to persist resume id:`, err));
1691
+ console.log(`${this.getLogTag()} Captured resume id ${resumeId} for conv=${conversationId} (agent session ${session.agentSessionId}, ${source})`);
1692
+ }).catch(err => {
1693
+ this.persistedResumeIds.delete(key); // retry on the next tick
1694
+ console.error(`${this.getLogTag()} Failed to persist resume id:`, err);
1695
+ });
1552
1696
  }
1553
- else {
1554
- console.log(`${this.getLogTag()} Captured resume id ${resumeId} for conv=${conversationId} (in-memory only)`);
1697
+ else if (changed) {
1698
+ console.log(`${this.getLogTag()} Captured resume id ${resumeId} for conv=${conversationId} (in-memory only, ${source})`);
1555
1699
  }
1556
1700
  }
1557
1701
  // Returns true if usage was found and a report was initiated (used by the
@@ -1585,7 +1729,7 @@ export class BaseTmuxLauncher {
1585
1729
  }
1586
1730
  }
1587
1731
  cwd ||= ledgerEntry?.cwd || this.getWorkingDirectory();
1588
- const cliSessionId = this.lastSessionId ?? session?.resumeId ?? ledgerEntry?.cliSessionId ?? null;
1732
+ const cliSessionId = session?.resumeId ?? ledgerEntry?.cliSessionId ?? this.restoredResumeIds.get(conversationId) ?? null;
1589
1733
  let extracted;
1590
1734
  try {
1591
1735
  extracted = this.extractTranscriptUsage(cwd, cliSessionId);
@@ -1707,7 +1851,7 @@ export class BaseTmuxLauncher {
1707
1851
  agentSessionId: session.agentSessionId,
1708
1852
  cliType: this.getCliType(),
1709
1853
  cwd: cwd || this.getWorkingDirectory(),
1710
- cliSessionId: session.resumeId || this.lastSessionId || undefined,
1854
+ cliSessionId: session.resumeId || undefined,
1711
1855
  tmuxSession,
1712
1856
  });
1713
1857
  }
@@ -1788,9 +1932,19 @@ export class BaseTmuxLauncher {
1788
1932
  // inbound message. Skips sessions with undelivered messages so we
1789
1933
  // never kill mid-delivery.
1790
1934
  const killAfter = this.getIdleKillAfterMs();
1791
- if (killAfter > 0) {
1935
+ const resurrectDefault = this.getResurrectIdleKillMs();
1936
+ if (killAfter > 0 || resurrectDefault > 0) {
1792
1937
  for (const s of sessions) {
1793
- if (now - s.lastActivity.getTime() <= killAfter)
1938
+ // A resurrect (auto-launched) session gets its own, always-on
1939
+ // threshold: the tightest of the configured idle-kill and the
1940
+ // resurrect wind-down. Deliberate sessions keep the configured
1941
+ // idle-kill semantics exactly (0 = never).
1942
+ const resurrectAfter = this.resurrectIdleKillThresholdMs(s.conversationId);
1943
+ const thresholds = [killAfter, resurrectAfter].filter(t => t > 0);
1944
+ if (thresholds.length === 0)
1945
+ continue;
1946
+ const effectiveKillAfter = Math.min(...thresholds);
1947
+ if (now - s.lastActivity.getTime() <= effectiveKillAfter)
1794
1948
  continue;
1795
1949
  if (this.messageQueue?.hasUndeliveredMessages(s.sessionId))
1796
1950
  continue;
@@ -1805,6 +1959,18 @@ export class BaseTmuxLauncher {
1805
1959
  continue;
1806
1960
  }
1807
1961
  const idleMin = Math.round((now - s.lastActivity.getTime()) / 60000);
1962
+ if (resurrectAfter > 0 && now - s.lastActivity.getTime() > resurrectAfter) {
1963
+ // Wind-down, not a plain kill: close the rails-side row FIRST with a
1964
+ // reason the backend's completion relay recognizes as
1965
+ // nothing-to-announce (model_api skips the dispatcher re-wake for
1966
+ // 'resurrect-idle:*'), THEN kill the pane. The later process-dead
1967
+ // completion is an idempotent no-op server-side.
1968
+ console.log(`${this.getLogTag()} Resurrect wind-down: conv ${s.conversationId} auto-launched session idle ${idleMin}min (threshold ${Math.round(resurrectAfter / 60000)}min) — closing session + pane`);
1969
+ this.completeAgentSessionRow(s, `resurrect-idle:${idleMin}min`);
1970
+ this.killByConversation(s.conversationId, `resurrect-idle:${idleMin}min`);
1971
+ this.autoLaunchedConversations.delete(s.conversationId);
1972
+ continue;
1973
+ }
1808
1974
  console.log(`${this.getLogTag()} Idle-kill: conv ${s.conversationId} idle ${idleMin}min (threshold ${Math.round(killAfter / 60000)}min) — killing CC; auto-respawn will pick up next inbound`);
1809
1975
  this.killByConversation(s.conversationId, `idle-kill:${idleMin}min`);
1810
1976
  }
@@ -2015,6 +2181,30 @@ export class BaseTmuxLauncher {
2015
2181
  this.killByConversation(s.conversationId, `wedged-session:${idleMin}min`);
2016
2182
  }
2017
2183
  }
2184
+ /**
2185
+ * Wedge verdict for one conversation, from the SAME primitives
2186
+ * reapWedgedSessions uses: pane present + capturable, not actively thinking,
2187
+ * and substantive content frozen for at least FROZEN_THRESHOLD_MS. Used by the
2188
+ * stale-on-inbound recycler (2026-08-23: it recycled on MCP silence alone and
2189
+ * killed live panes mid-work — conv 3337 six times in one evening).
2190
+ */
2191
+ isSessionWedged(conversationId) {
2192
+ const paneName = this.tmuxNameForConversation(conversationId);
2193
+ if (!paneName)
2194
+ return { wedged: false, reason: 'no-pane', paneName: null };
2195
+ if (this.capturePaneTailForProgress(paneName) === null) {
2196
+ return { wedged: false, reason: 'pane-gone', paneName };
2197
+ }
2198
+ if (this.isActivelyThinking(paneName))
2199
+ return { wedged: false, reason: 'pane-active', paneName };
2200
+ const prog = this.paneProgress.get(paneName);
2201
+ const frozenMs = prog ? Date.now() - prog.lastChangeAt : 0;
2202
+ const frozenMin = Math.round(frozenMs / 60000);
2203
+ if (frozenMs < BaseTmuxLauncher.FROZEN_THRESHOLD_MS) {
2204
+ return { wedged: false, reason: `frozen-${frozenMin}min<threshold`, paneName };
2205
+ }
2206
+ return { wedged: true, reason: `frozen-${frozenMin}min`, paneName };
2207
+ }
2018
2208
  isActivelyThinking(tmuxSession) {
2019
2209
  const now = Date.now();
2020
2210
  const prev = this.paneProgress.get(tmuxSession);
@@ -2104,4 +2294,12 @@ export class BaseTmuxLauncher {
2104
2294
  }
2105
2295
  }
2106
2296
  }
2297
+ /**
2298
+ * Startup-reap decision: reap ONLY a pane this env provably owns. Unmarked
2299
+ * (legacy) and other-env panes are left alone — cross-env kills are strictly
2300
+ * worse than a leaked pane, which the backend reconciles anyway.
2301
+ */
2302
+ export function shouldReapOrphanPane(ownerEnv, myEnv) {
2303
+ return ownerEnv !== null && ownerEnv === myEnv;
2304
+ }
2107
2305
  //# sourceMappingURL=base-tmux-launcher.js.map