@dsiloed/silo-link 1.19.15 → 1.19.16

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/README.md +8 -0
  2. package/dist/api/backend-health.d.ts +44 -0
  3. package/dist/api/backend-health.d.ts.map +1 -0
  4. package/dist/api/backend-health.js +80 -0
  5. package/dist/api/backend-health.js.map +1 -0
  6. package/dist/api/dsiloed-client.d.ts +7 -3
  7. package/dist/api/dsiloed-client.d.ts.map +1 -1
  8. package/dist/api/dsiloed-client.js +29 -4
  9. package/dist/api/dsiloed-client.js.map +1 -1
  10. package/dist/cable/subscription-manager.d.ts +26 -1
  11. package/dist/cable/subscription-manager.d.ts.map +1 -1
  12. package/dist/cable/subscription-manager.js +72 -13
  13. package/dist/cable/subscription-manager.js.map +1 -1
  14. package/dist/cli/claude-md-block.d.ts +1 -1
  15. package/dist/cli/claude-md-block.d.ts.map +1 -1
  16. package/dist/cli/claude-md-block.js +3 -1
  17. package/dist/cli/claude-md-block.js.map +1 -1
  18. package/dist/cli/file-log.d.ts +58 -3
  19. package/dist/cli/file-log.d.ts.map +1 -1
  20. package/dist/cli/file-log.js +110 -7
  21. package/dist/cli/file-log.js.map +1 -1
  22. package/dist/core/agent-launcher.d.ts +26 -0
  23. package/dist/core/agent-launcher.d.ts.map +1 -1
  24. package/dist/core/auto-updater.d.ts +11 -0
  25. package/dist/core/auto-updater.d.ts.map +1 -1
  26. package/dist/core/auto-updater.js +84 -15
  27. package/dist/core/auto-updater.js.map +1 -1
  28. package/dist/core/base-tmux-launcher.d.ts +66 -20
  29. package/dist/core/base-tmux-launcher.d.ts.map +1 -1
  30. package/dist/core/base-tmux-launcher.js +236 -76
  31. package/dist/core/base-tmux-launcher.js.map +1 -1
  32. package/dist/core/bridge.d.ts +28 -0
  33. package/dist/core/bridge.d.ts.map +1 -1
  34. package/dist/core/bridge.js +217 -25
  35. package/dist/core/bridge.js.map +1 -1
  36. package/dist/core/claude-launcher.d.ts +11 -4
  37. package/dist/core/claude-launcher.d.ts.map +1 -1
  38. package/dist/core/claude-launcher.js +59 -16
  39. package/dist/core/claude-launcher.js.map +1 -1
  40. package/dist/core/codex-launcher.d.ts +3 -3
  41. package/dist/core/codex-launcher.d.ts.map +1 -1
  42. package/dist/core/codex-launcher.js +9 -5
  43. package/dist/core/codex-launcher.js.map +1 -1
  44. package/dist/core/gemini-launcher.d.ts +3 -3
  45. package/dist/core/gemini-launcher.d.ts.map +1 -1
  46. package/dist/core/gemini-launcher.js +9 -5
  47. package/dist/core/gemini-launcher.js.map +1 -1
  48. package/dist/core/health-monitor.d.ts +87 -0
  49. package/dist/core/health-monitor.d.ts.map +1 -0
  50. package/dist/core/health-monitor.js +93 -0
  51. package/dist/core/health-monitor.js.map +1 -0
  52. package/dist/core/idle-completion.d.ts +38 -0
  53. package/dist/core/idle-completion.d.ts.map +1 -0
  54. package/dist/core/idle-completion.js +52 -0
  55. package/dist/core/idle-completion.js.map +1 -0
  56. package/dist/core/launch-binding.d.ts +52 -0
  57. package/dist/core/launch-binding.d.ts.map +1 -0
  58. package/dist/core/launch-binding.js +81 -0
  59. package/dist/core/launch-binding.js.map +1 -0
  60. package/dist/core/transcript-usage.d.ts +9 -0
  61. package/dist/core/transcript-usage.d.ts.map +1 -1
  62. package/dist/core/transcript-usage.js +19 -0
  63. package/dist/core/transcript-usage.js.map +1 -1
  64. package/dist/core/usage-ledger.d.ts +1 -1
  65. package/dist/core/usage-ledger.d.ts.map +1 -1
  66. package/dist/mcp/server.d.ts +4 -0
  67. package/dist/mcp/server.d.ts.map +1 -1
  68. package/dist/mcp/server.js +15 -0
  69. package/dist/mcp/server.js.map +1 -1
  70. package/dist/mcp/tools/register-tools.d.ts +2 -0
  71. package/dist/mcp/tools/register-tools.d.ts.map +1 -1
  72. package/dist/mcp/tools/register-tools.js +154 -36
  73. package/dist/mcp/tools/register-tools.js.map +1 -1
  74. package/dist/types/index.d.ts +6 -0
  75. package/dist/types/index.d.ts.map +1 -1
  76. package/package.json +3 -1
@@ -1,9 +1,12 @@
1
1
  import { spawn, execFileSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { existsSync, copyFileSync, writeFileSync, unlinkSync, readdirSync, readFileSync } from 'node:fs';
3
+ import { existsSync, copyFileSync, writeFileSync, unlinkSync, readdirSync, readFileSync, statSync } from 'node:fs';
4
4
  import { createRequire } from 'node:module';
5
5
  import { join, dirname } from 'node:path';
6
6
  import { tmpdir, homedir } from 'node:os';
7
+ import { LAUNCH_HEADER } from './launch-binding.js';
8
+ import { healthMonitor } from './health-monitor.js';
9
+ import { randomUUID } from 'node:crypto';
7
10
  /**
8
11
  * Abstract base class for tmux-based agent launchers.
9
12
  * Implements all shared lifecycle, monitoring, and session management logic.
@@ -103,12 +106,26 @@ export class BaseTmuxLauncher {
103
106
  static PROMPT_STALL_THRESHOLD_MS = 3 * 60 * 1000;
104
107
  // Re-notify cadence while the SAME stall persists unanswered.
105
108
  static PROMPT_STALL_RENOTIFY_MS = 30 * 60 * 1000;
106
- lastSessionId = null;
107
- // Which conversation lastSessionId belongs to. A pinned resume id replays ONE
108
- // conversation's CLI context; multi-conversation daemons (personal) must never
109
- // resume it for a different conversation or the worker re-registers under the
110
- // old conversation (the nightly-audit-review attribution leak).
111
- lastSessionConversationId = null;
109
+ // Resume ids restored PER CONVERSATION for its next launch (from a dying
110
+ // session, Rails metadata at boot, or the process-dead sweep). Key 0 holds a
111
+ // pin of unknown provenance, usable only by a launch with no target
112
+ // conversation. This replaces the launcher-global `lastSessionId` slot: one
113
+ // slot shared by every conversation let a capture for conv A become conv B's
114
+ // `--resume` target (three transcripts pinned to conv 3331 in turn, then
115
+ // handed to 3337 and 3369 — 2026-08-24). A pinned resume id replays ONE
116
+ // conversation's CLI context and must never cross conversations.
117
+ restoredResumeIds = new Map();
118
+ // resumeId → owning conversation. Survives unregister: a transcript that once
119
+ // belonged to conv A is never attributed to conv B later in this process
120
+ // lifetime, even after A's session is gone (the old guard only looked at
121
+ // LIVE sessions, so 3331's transcript was captured for 3337 the moment 3331
122
+ // stopped).
123
+ resumeIdOwner = new Map();
124
+ // Resume ids minted at launch (Claude `--session-id`), per conversation.
125
+ mintedResumeIds = new Map();
126
+ // `${agentSessionId}:${resumeId}` pairs already persisted to Rails — the
127
+ // metadata PUT happens ONCE per pair, not on every liveness tick.
128
+ persistedResumeIds = new Set();
112
129
  pendingTriggerMessage = null;
113
130
  pendingWorkspaceConfig = null;
114
131
  pendingWorkspaceCwd = null;
@@ -129,6 +146,42 @@ export class BaseTmuxLauncher {
129
146
  // stage's prompt into the config file forever (personal daemon, 2026-08-13 —
130
147
  // a 9,948-char Fleet-Auditor prompt naming a long-finished stage execution).
131
148
  pendingSessionPrompt = null;
149
+ // Launch → MCP transport bindings (shared with the MCP server). See launch-binding.ts.
150
+ launchBindings = null;
151
+ // One-shot backend linkage for the NEXT launch (agent_session_id /
152
+ // launch_correlation_id from the control-channel launch payload).
153
+ pendingLaunchLinkage = null;
154
+ setLaunchBindings(registry) {
155
+ this.launchBindings = registry;
156
+ }
157
+ setLaunchLinkage(linkage) {
158
+ this.pendingLaunchLinkage = linkage;
159
+ }
160
+ /**
161
+ * mcp-remote `--header` args that stamp the LOCAL silolink server entry with
162
+ * this launch's token, so the daemon's MCP server can bind the transport to
163
+ * the pane on initialize.
164
+ */
165
+ launchHeaderArgs(launch) {
166
+ return launch ? ['--header', `${LAUNCH_HEADER}: ${launch.token}`] : [];
167
+ }
168
+ /** Remove per-launch MCP config files older than `maxAgeMs` from `dir` (best-effort). */
169
+ cleanupPerLaunchConfigs(dir, maxAgeMs = 24 * 60 * 60 * 1000) {
170
+ try {
171
+ if (!existsSync(dir))
172
+ return;
173
+ const now = Date.now();
174
+ for (const f of readdirSync(dir)) {
175
+ const full = join(dir, f);
176
+ try {
177
+ if (now - statSync(full).mtimeMs > maxAgeMs)
178
+ unlinkSync(full);
179
+ }
180
+ catch { /* already gone */ }
181
+ }
182
+ }
183
+ catch { /* best-effort */ }
184
+ }
132
185
  constructor(config, sessionManager, dsiloedClient, messageQueue, workspaceManager, usageLedger) {
133
186
  this.config = config;
134
187
  this.sessionManager = sessionManager;
@@ -417,6 +470,18 @@ export class BaseTmuxLauncher {
417
470
  console.log(`${this.getLogTag()} Nudged ${this.tmuxSessions.size} session(s) (attempt ${this.nudgeCount + 1})`);
418
471
  return true;
419
472
  }
473
+ /**
474
+ * Nudge a single conversation's pane. Unlike nudge() this is not throttled:
475
+ * it answers a deliberate operator/control-channel request for THIS pane.
476
+ */
477
+ nudgeConversation(conversationId, message) {
478
+ const name = this.tmuxNameForConversation(conversationId);
479
+ if (!name)
480
+ return false;
481
+ this.sendLineToPane(name, message || 'Continue polling SiloLink for messages. Do not stop the poll loop.');
482
+ console.log(`${this.getLogTag()} Nudged "${name}" (conv ${conversationId})`);
483
+ return true;
484
+ }
420
485
  /**
421
486
  * Proactive heartbeat — sent when the session is alive but silent AND the
422
487
  * inbound queue is empty. The existing nudge() only fires when there are
@@ -465,6 +530,10 @@ export class BaseTmuxLauncher {
465
530
  // isAutoRespawnEnabled() at their own call sites.
466
531
  if (!this.enabled)
467
532
  return;
533
+ // Idempotent: a second call must not overwrite idleCheckTimer and orphan
534
+ // the first interval (the Codex launcher used to be started twice).
535
+ if (this.idleCheckTimer)
536
+ return;
468
537
  // Reap tmux sessions left over from a prior daemon run before the
469
538
  // monitor starts. The new daemon's tmuxSessions map is empty by
470
539
  // definition, and the spawned CCs in those panes were bound to the
@@ -550,6 +619,7 @@ export class BaseTmuxLauncher {
550
619
  console.log(`${this.getLogTag()} Startup reap: left ${skipped.length} pane(s) owned elsewhere/unmarked: ${skipped.join(', ')}`);
551
620
  }
552
621
  if (reaped.length > 0) {
622
+ healthMonitor.noteReapedAtBoot(reaped.length);
553
623
  console.log(`${this.getLogTag()} Reaped ${reaped.length} orphan tmux session(s) from prior daemon run: ${reaped.join(', ')}`);
554
624
  }
555
625
  // Fix #3 (main-checkout guard): a prior daemon run may have left a shared
@@ -693,10 +763,23 @@ export class BaseTmuxLauncher {
693
763
  return prompt;
694
764
  }
695
765
  setLastResumeId(resumeId, conversationId) {
696
- this.lastSessionId = resumeId;
697
- this.lastSessionConversationId = conversationId ?? null;
766
+ this.restoredResumeIds.set(conversationId ?? 0, resumeId);
767
+ if (conversationId)
768
+ this.resumeIdOwner.set(resumeId, conversationId);
698
769
  console.log(`${this.getLogTag()} Restored resume ID: ${resumeId} (conv=${conversationId ?? 'unknown'})`);
699
770
  }
771
+ /** The resume id minted at launch for a conversation (Claude), if any. */
772
+ getMintedResumeId(conversationId) {
773
+ return this.mintedResumeIds.get(conversationId);
774
+ }
775
+ /**
776
+ * Public entry to the capture path — the Bridge calls it right after it
777
+ * pre-registers a launched session so a minted id lands on the Session (and
778
+ * is persisted once) immediately rather than on the next liveness tick.
779
+ */
780
+ syncResumeId(conversationId) {
781
+ this.captureResumeId(conversationId);
782
+ }
700
783
  /**
701
784
  * The resume id to use when launching for `conversationId`, or null to launch
702
785
  * fresh. Only a pin captured for the SAME conversation may be resumed — a pin
@@ -707,32 +790,35 @@ export class BaseTmuxLauncher {
707
790
  resumeTargetFor(conversationId) {
708
791
  // Per-conversation pin is authoritative: each Session stores the resume id
709
792
  // captured for ITS OWN transcript, so two conversations sharing a cwd never
710
- // resume each other's CLI session. The global lastSessionId is only a
711
- // fallback, and only when it was captured for THIS same conversation.
793
+ // resume each other's CLI session. The per-conversation restored id is the
794
+ // fallback (dying session / Rails metadata at boot). Nothing else — a pin
795
+ // from another conversation or of unknown provenance launches fresh.
712
796
  if (conversationId) {
713
797
  const pinned = this.sessionManager.getByConversationId(conversationId)?.resumeId;
714
798
  if (pinned)
715
799
  return pinned;
716
- if (this.lastSessionConversationId !== conversationId) {
717
- if (this.lastSessionId) {
718
- console.warn(`${this.getLogTag()} Resume id ${this.lastSessionId} belongs to conv=${this.lastSessionConversationId ?? 'unknown'} — launching conv=${conversationId} fresh instead of resuming it.`);
719
- }
720
- return null;
800
+ const restored = this.restoredResumeIds.get(conversationId);
801
+ if (restored)
802
+ return restored;
803
+ const orphanPin = this.restoredResumeIds.get(0);
804
+ if (orphanPin) {
805
+ console.warn(`${this.getLogTag()} Resume id ${orphanPin} has no recorded conversation — launching conv=${conversationId} fresh instead of resuming it.`);
721
806
  }
807
+ return null;
722
808
  }
723
- return this.lastSessionId;
809
+ return this.restoredResumeIds.get(0) ?? null;
724
810
  }
725
811
  /**
726
812
  * Forget a resume target that turned out to be unresumable so the next
727
813
  * relaunch starts FRESH instead of re-crashing on the same `--resume <id>`.
728
- * Clears all three stores captureResumeId writes to: the global `lastSessionId`,
729
- * the in-memory SessionInfo (which finishPaneTeardown would otherwise restore),
814
+ * Clears every store a resume target lives in: the per-conversation restored
815
+ * and minted ids, the in-memory SessionInfo (which finishPaneTeardown would otherwise restore),
730
816
  * and the persisted rails AgentSession metadata (so a daemon restart can't
731
817
  * resurrect it). Best-effort; never throws into the teardown path.
732
818
  */
733
819
  clearResumeId(conversationId) {
734
- this.lastSessionId = null;
735
- this.lastSessionConversationId = null;
820
+ this.restoredResumeIds.delete(conversationId || 0);
821
+ this.mintedResumeIds.delete(conversationId);
736
822
  const session = conversationId
737
823
  ? this.sessionManager.getByConversationId(conversationId)
738
824
  : this.sessionManager.getAll()[0];
@@ -1128,7 +1214,21 @@ export class BaseTmuxLauncher {
1128
1214
  // deterministic and independent of racy shared-state approvals.
1129
1215
  const mcpToolAllowlist = this.pendingMcpToolAllowlist;
1130
1216
  this.pendingMcpToolAllowlist = null;
1131
- const mcpExtraArgs = await this.setupMcpConfig(cwd, mcpToolAllowlist);
1217
+ // Per-launch identity: the token rides into the CLI's MCP config as an
1218
+ // X-SiloLink-Launch header, and the binding lets remote_register tell
1219
+ // which pane is calling (see launch-binding.ts).
1220
+ const launch = { token: randomUUID(), conversationId };
1221
+ const linkage = this.pendingLaunchLinkage;
1222
+ this.pendingLaunchLinkage = null;
1223
+ if (conversationId && this.launchBindings) {
1224
+ this.launchBindings.register({
1225
+ token: launch.token,
1226
+ conversationId,
1227
+ agentSessionId: linkage?.agentSessionId,
1228
+ launchCorrelationId: linkage?.launchCorrelationId,
1229
+ });
1230
+ }
1231
+ const mcpExtraArgs = await this.setupMcpConfig(cwd, mcpToolAllowlist, launch);
1132
1232
  if (Array.isArray(mcpExtraArgs) && mcpExtraArgs.length > 0)
1133
1233
  args.push(...mcpExtraArgs);
1134
1234
  // Fetch agent launch context
@@ -1259,10 +1359,16 @@ export class BaseTmuxLauncher {
1259
1359
  return false;
1260
1360
  }
1261
1361
  console.log(`${this.getLogTag()} tmux session "${tmuxSession}" created`);
1262
- this.tmuxSessions.set(tmuxSession, { conversationId: conversationId || 0, startedAt: new Date(), cwd });
1362
+ const mintedSessionId = conversationId ? this.mintedResumeIds.get(conversationId) : undefined;
1363
+ this.tmuxSessions.set(tmuxSession, { conversationId: conversationId || 0, startedAt: new Date(), cwd, mintedSessionId });
1263
1364
  if (workspaceSessionId) {
1264
1365
  this.tmuxSessionWorkspaces.set(tmuxSession, workspaceSessionId);
1265
1366
  }
1367
+ // A minted id needs no discovery — pin it on an already-registered session
1368
+ // (relaunch) right away. A fresh launch's session is pre-registered by the
1369
+ // Bridge afterwards, which calls syncResumeId.
1370
+ if (mintedSessionId && conversationId)
1371
+ this.captureResumeId(conversationId);
1266
1372
  // Wait for agent to be ready, then send the prompt
1267
1373
  const sessionToPrompt = tmuxSession;
1268
1374
  const promptToSend = this.formatPrompt(prompt);
@@ -1439,7 +1545,7 @@ export class BaseTmuxLauncher {
1439
1545
  // forget that id so finishPaneTeardown relaunches FRESH instead of
1440
1546
  // re-issuing the same doomed `--resume`/`resume` and looping forever.
1441
1547
  // Applies to every CLI via the per-launcher isResumeFailure() override.
1442
- const hadResumeTarget = this.lastSessionId != null
1548
+ const hadResumeTarget = this.restoredResumeIds.has(info.conversationId)
1443
1549
  || this.sessionManager.getByConversationId(info.conversationId)?.resumeId != null;
1444
1550
  if (hadResumeTarget && this.isResumeFailure(paneContent)) {
1445
1551
  console.warn(`${this.getLogTag()} Pane died on an unresumable session for conv=${info.conversationId} — clearing resume id and relaunching fresh.`);
@@ -1469,12 +1575,12 @@ export class BaseTmuxLauncher {
1469
1575
  const stillRegistered = this.sessionManager.getByConversationId(conversationId);
1470
1576
  if (!stillRegistered)
1471
1577
  return;
1472
- // RESTORE resume id from the dying SESSION's stored value (not a stale
1473
- // global) BEFORE we unregister it — this is what lets the auto-respawn relaunch
1474
- // the SAME CLI session (`--resume <id>`) and continue an in-progress task
1475
- // instead of starting fresh. Best-effort: if the worker never captured an id
1476
- // (capture failed / CLI has no resume), lastSessionId stays null and the
1477
- // relaunch falls back to a fresh session — current behavior, never blocked.
1578
+ // RESTORE resume id from the dying SESSION's stored value BEFORE we
1579
+ // unregister it — this is what lets the auto-respawn relaunch the SAME CLI
1580
+ // session (`--resume <id>`) and continue an in-progress task instead of
1581
+ // starting fresh. Best-effort: if the worker never captured an id (capture
1582
+ // failed / CLI has no resume), nothing is restored and the relaunch falls
1583
+ // back to a fresh session — never blocked.
1478
1584
  const resumeId = stillRegistered.resumeId || stillRegistered.claudeResumeId;
1479
1585
  if (resumeId) {
1480
1586
  this.setLastResumeId(resumeId, conversationId);
@@ -1555,23 +1661,27 @@ export class BaseTmuxLauncher {
1555
1661
  *
1556
1662
  * Best-effort: returns null on any failure. NEVER throws into the launch path.
1557
1663
  */
1558
- detectResumeId(_cwd) {
1664
+ detectResumeId(_cwd, _notBefore) {
1559
1665
  return null;
1560
1666
  }
1561
1667
  /**
1562
- * Capture the running worker's CLI session id and remember it so the next
1563
- * respawn relaunches the SAME session (`--resume`). Stores on three places:
1564
- * 1. `this.lastSessionId` — what buildLaunchPrompt reads to add resume args.
1565
- * 2. the SessionInfo (`resumeId`) — survives in-memory so finishPaneTeardown
1566
- * can restore it just before relaunch (keyed off the SESSION, not a stale
1567
- * global).
1568
- * 3. the rails AgentSession metadata (`cli_resume_id` + legacy
1569
- * `claude_resume_id`) — survives a daemon restart so bridge startup /
1570
- * remote_load_context can restore it.
1668
+ * Pin the running worker's CLI session id on its Session so the next respawn
1669
+ * relaunches the SAME session (`--resume`). Two sources, in order:
1670
+ * 1. the id MINTED at launch (Claude `--session-id`) — authoritative, no
1671
+ * discovery, never overwritten;
1672
+ * 2. discovery from the CLI's on-disk transcript (Gemini/Codex, and Claude
1673
+ * launches that resumed) — accepted only if the transcript was touched
1674
+ * at/after the pane's startedAt.
1675
+ * Either way the id is refused when another conversation owns it — a LIVE
1676
+ * session or any conversation seen earlier this process lifetime
1677
+ * (`resumeIdOwner`, which survives unregister). Stores on the Session
1678
+ * (`resumeId`) and persists `cli_resume_id` + legacy `claude_resume_id` to the
1679
+ * rails AgentSession metadata ONCE per (agent session, id) pair — that
1680
+ * survives a daemon restart so bridge startup / remote_load_context can
1681
+ * restore it.
1571
1682
  *
1572
- * Idempotent + throttled by the caller (runs on the live-usage tick and once
1573
- * shortly after launch). Best-effort — a capture failure leaves prior state
1574
- * intact and never blocks anything.
1683
+ * Idempotent; runs on the liveness tick and once right after launch.
1684
+ * Best-effort — a capture failure leaves prior state intact and never blocks.
1575
1685
  */
1576
1686
  captureResumeId(conversationId) {
1577
1687
  const session = conversationId
@@ -1580,44 +1690,64 @@ export class BaseTmuxLauncher {
1580
1690
  if (!session)
1581
1691
  return;
1582
1692
  // Locate by the ACTUAL launch cwd (same resolution extractTranscriptUsage uses).
1583
- let cwd;
1584
- for (const info of this.tmuxSessions.values()) {
1585
- if (info.conversationId === conversationId) {
1586
- cwd = info.cwd;
1693
+ let info;
1694
+ for (const candidate of this.tmuxSessions.values()) {
1695
+ if (candidate.conversationId === conversationId) {
1696
+ info = candidate;
1587
1697
  break;
1588
1698
  }
1589
1699
  }
1590
- cwd ||= this.getWorkingDirectory();
1700
+ const cwd = info?.cwd || this.getWorkingDirectory();
1701
+ const minted = info?.mintedSessionId ?? this.mintedResumeIds.get(conversationId);
1591
1702
  let resumeId = null;
1592
- try {
1593
- resumeId = this.detectResumeId(cwd);
1703
+ let source;
1704
+ if (minted) {
1705
+ resumeId = minted;
1706
+ source = 'minted';
1594
1707
  }
1595
- catch (err) {
1596
- console.warn(`${this.getLogTag()} detectResumeId failed for conv=${conversationId}: ${err}`);
1597
- return;
1708
+ else {
1709
+ try {
1710
+ resumeId = this.detectResumeId(cwd, info?.startedAt);
1711
+ }
1712
+ catch (err) {
1713
+ console.warn(`${this.getLogTag()} detectResumeId failed for conv=${conversationId}: ${err}`);
1714
+ return;
1715
+ }
1716
+ source = 'discovered';
1598
1717
  }
1599
1718
  if (!resumeId)
1600
1719
  return;
1601
- // Cross-conversation guard: detectResumeId is cwd-scoped ("most recent
1602
- // transcript in the launch cwd"), so when two conversations share a cwd it
1603
- // can return the OTHER conversation's transcript. Never pin a transcript id
1604
- // that a different live conversation's session already owns — that would
1605
- // make this conversation resume the other's CLI session (observed 2026-07-18:
1606
- // conv 2170 stole conv 2158's id, both in the demo-kit cwd, so the 2170
1607
- // pane ended up polling 2158). Leave this conversation fresh instead.
1608
- const otherOwner = this.sessionManager.getAll().find((s) => s.resumeId === resumeId && s.conversationId !== conversationId);
1609
- if (otherOwner) {
1610
- console.warn(`${this.getLogTag()} Resume id ${resumeId} already owned by conv=${otherOwner.conversationId} — NOT capturing it for conv=${conversationId} (shared cwd). Leaving fresh.`);
1720
+ // Ownership guard. detectResumeId is cwd-scoped ("most recent transcript in
1721
+ // the launch cwd"), so when two conversations share a cwd it can return the
1722
+ // OTHER conversation's transcript. Never pin an id that a different
1723
+ // conversation owns — live (2026-07-18: conv 2170 stole conv 2158's id and
1724
+ // the 2170 pane ended up polling 2158) or previously seen (2026-08-24: once
1725
+ // 3331 stopped, its transcript was captured for 3337, then 3337's for 3369).
1726
+ const priorOwner = this.resumeIdOwner.get(resumeId);
1727
+ if (priorOwner !== undefined && priorOwner !== conversationId) {
1728
+ console.warn(`${this.getLogTag()} Resume id ${resumeId} is owned by conv=${priorOwner} — NOT capturing it for conv=${conversationId} (${source}). Leaving fresh.`);
1611
1729
  return;
1612
1730
  }
1613
- // No change — already captured this id.
1614
- if (this.lastSessionId === resumeId && session.resumeId === resumeId)
1731
+ const otherLive = this.sessionManager.getAll().find((s) => s.resumeId === resumeId && s.conversationId !== conversationId);
1732
+ if (otherLive) {
1733
+ console.warn(`${this.getLogTag()} Resume id ${resumeId} already owned by live conv=${otherLive.conversationId} — NOT capturing it for conv=${conversationId} (shared cwd). Leaving fresh.`);
1615
1734
  return;
1616
- this.lastSessionId = resumeId;
1617
- this.lastSessionConversationId = conversationId || session.conversationId || null;
1618
- session.resumeId = resumeId;
1619
- session.claudeResumeId = resumeId; // keep legacy alias in sync
1735
+ }
1736
+ this.resumeIdOwner.set(resumeId, conversationId);
1737
+ const changed = session.resumeId !== resumeId;
1738
+ if (changed) {
1739
+ healthMonitor.noteResumeCapture();
1740
+ if (session.resumeId) {
1741
+ console.log(`${this.getLogTag()} Resume id for conv=${conversationId}: ${session.resumeId} → ${resumeId} (${source})`);
1742
+ }
1743
+ session.resumeId = resumeId;
1744
+ session.claudeResumeId = resumeId; // keep legacy alias in sync
1745
+ }
1620
1746
  if (this.dsiloedClient && session.agentSessionId) {
1747
+ const key = `${session.agentSessionId}:${resumeId}`;
1748
+ if (this.persistedResumeIds.has(key))
1749
+ return;
1750
+ this.persistedResumeIds.add(key);
1621
1751
  // MERGE — only the resume keys; a full replace would wipe workspace_* and
1622
1752
  // server-set keys. Persist BOTH a generic key and the legacy claude key the
1623
1753
  // bridge startup / remote_load_context already read.
@@ -1625,11 +1755,14 @@ export class BaseTmuxLauncher {
1625
1755
  cli_resume_id: resumeId,
1626
1756
  claude_resume_id: resumeId,
1627
1757
  }).then(() => {
1628
- console.log(`${this.getLogTag()} Captured resume id ${resumeId} for conv=${conversationId} (agent session ${session.agentSessionId})`);
1629
- }).catch(err => console.error(`${this.getLogTag()} Failed to persist resume id:`, err));
1758
+ console.log(`${this.getLogTag()} Captured resume id ${resumeId} for conv=${conversationId} (agent session ${session.agentSessionId}, ${source})`);
1759
+ }).catch(err => {
1760
+ this.persistedResumeIds.delete(key); // retry on the next tick
1761
+ console.error(`${this.getLogTag()} Failed to persist resume id:`, err);
1762
+ });
1630
1763
  }
1631
- else {
1632
- console.log(`${this.getLogTag()} Captured resume id ${resumeId} for conv=${conversationId} (in-memory only)`);
1764
+ else if (changed) {
1765
+ console.log(`${this.getLogTag()} Captured resume id ${resumeId} for conv=${conversationId} (in-memory only, ${source})`);
1633
1766
  }
1634
1767
  }
1635
1768
  // Returns true if usage was found and a report was initiated (used by the
@@ -1663,7 +1796,7 @@ export class BaseTmuxLauncher {
1663
1796
  }
1664
1797
  }
1665
1798
  cwd ||= ledgerEntry?.cwd || this.getWorkingDirectory();
1666
- const cliSessionId = this.lastSessionId ?? session?.resumeId ?? ledgerEntry?.cliSessionId ?? null;
1799
+ const cliSessionId = session?.resumeId ?? ledgerEntry?.cliSessionId ?? this.restoredResumeIds.get(conversationId) ?? null;
1667
1800
  let extracted;
1668
1801
  try {
1669
1802
  extracted = this.extractTranscriptUsage(cwd, cliSessionId);
@@ -1689,7 +1822,10 @@ export class BaseTmuxLauncher {
1689
1822
  }).then((result) => {
1690
1823
  // 'rejected' means the server can never accept it — settle the entry rather
1691
1824
  // than leave it to be retried on every future startup sweep, forever.
1692
- if (result !== 'failed' && final)
1825
+ // 'failed' and 'blocked' (terminal 403 — endpoint blocked, no request made)
1826
+ // both keep the entry; 'blocked' is silent here because the tracker
1827
+ // already logged the transition once.
1828
+ if ((result === 'reported' || result === 'rejected') && final)
1693
1829
  this.usageLedger?.remove(conversationId);
1694
1830
  if (result === 'rejected') {
1695
1831
  console.warn(`${this.getLogTag()} Usage unreportable for conv=${conversationId} — entry settled (not retryable)`);
@@ -1785,7 +1921,7 @@ export class BaseTmuxLauncher {
1785
1921
  agentSessionId: session.agentSessionId,
1786
1922
  cliType: this.getCliType(),
1787
1923
  cwd: cwd || this.getWorkingDirectory(),
1788
- cliSessionId: session.resumeId || this.lastSessionId || undefined,
1924
+ cliSessionId: session.resumeId || undefined,
1789
1925
  tmuxSession,
1790
1926
  });
1791
1927
  }
@@ -2115,6 +2251,30 @@ export class BaseTmuxLauncher {
2115
2251
  this.killByConversation(s.conversationId, `wedged-session:${idleMin}min`);
2116
2252
  }
2117
2253
  }
2254
+ /**
2255
+ * Wedge verdict for one conversation, from the SAME primitives
2256
+ * reapWedgedSessions uses: pane present + capturable, not actively thinking,
2257
+ * and substantive content frozen for at least FROZEN_THRESHOLD_MS. Used by the
2258
+ * stale-on-inbound recycler (2026-08-23: it recycled on MCP silence alone and
2259
+ * killed live panes mid-work — conv 3337 six times in one evening).
2260
+ */
2261
+ isSessionWedged(conversationId) {
2262
+ const paneName = this.tmuxNameForConversation(conversationId);
2263
+ if (!paneName)
2264
+ return { wedged: false, reason: 'no-pane', paneName: null };
2265
+ if (this.capturePaneTailForProgress(paneName) === null) {
2266
+ return { wedged: false, reason: 'pane-gone', paneName };
2267
+ }
2268
+ if (this.isActivelyThinking(paneName))
2269
+ return { wedged: false, reason: 'pane-active', paneName };
2270
+ const prog = this.paneProgress.get(paneName);
2271
+ const frozenMs = prog ? Date.now() - prog.lastChangeAt : 0;
2272
+ const frozenMin = Math.round(frozenMs / 60000);
2273
+ if (frozenMs < BaseTmuxLauncher.FROZEN_THRESHOLD_MS) {
2274
+ return { wedged: false, reason: `frozen-${frozenMin}min<threshold`, paneName };
2275
+ }
2276
+ return { wedged: true, reason: `frozen-${frozenMin}min`, paneName };
2277
+ }
2118
2278
  isActivelyThinking(tmuxSession) {
2119
2279
  const now = Date.now();
2120
2280
  const prev = this.paneProgress.get(tmuxSession);