@dsiloed/silo-link 1.19.15 → 1.19.17

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 (87) hide show
  1. package/README.md +64 -3
  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 +95 -21
  29. package/dist/core/base-tmux-launcher.d.ts.map +1 -1
  30. package/dist/core/base-tmux-launcher.js +330 -85
  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 +219 -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 +64 -17
  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 +57 -0
  53. package/dist/core/idle-completion.d.ts.map +1 -0
  54. package/dist/core/idle-completion.js +71 -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/mcp-config-dir.d.ts +16 -0
  61. package/dist/core/mcp-config-dir.d.ts.map +1 -0
  62. package/dist/core/mcp-config-dir.js +37 -0
  63. package/dist/core/mcp-config-dir.js.map +1 -0
  64. package/dist/core/stray-processes.d.ts +47 -0
  65. package/dist/core/stray-processes.d.ts.map +1 -0
  66. package/dist/core/stray-processes.js +141 -0
  67. package/dist/core/stray-processes.js.map +1 -0
  68. package/dist/core/transcript-usage.d.ts +9 -0
  69. package/dist/core/transcript-usage.d.ts.map +1 -1
  70. package/dist/core/transcript-usage.js +19 -0
  71. package/dist/core/transcript-usage.js.map +1 -1
  72. package/dist/core/usage-ledger.d.ts +1 -1
  73. package/dist/core/usage-ledger.d.ts.map +1 -1
  74. package/dist/core/workspace-manager.d.ts.map +1 -1
  75. package/dist/core/workspace-manager.js +6 -2
  76. package/dist/core/workspace-manager.js.map +1 -1
  77. package/dist/mcp/server.d.ts +4 -0
  78. package/dist/mcp/server.d.ts.map +1 -1
  79. package/dist/mcp/server.js +15 -0
  80. package/dist/mcp/server.js.map +1 -1
  81. package/dist/mcp/tools/register-tools.d.ts +2 -0
  82. package/dist/mcp/tools/register-tools.d.ts.map +1 -1
  83. package/dist/mcp/tools/register-tools.js +154 -36
  84. package/dist/mcp/tools/register-tools.js.map +1 -1
  85. package/dist/types/index.d.ts +6 -0
  86. package/dist/types/index.d.ts.map +1 -1
  87. package/package.json +3 -1
@@ -1,9 +1,14 @@
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 { isInPollLoop } from './idle-completion.js';
10
+ import { reapStrayProcesses, snapshotStrayProcesses } from './stray-processes.js';
11
+ import { randomUUID } from 'node:crypto';
7
12
  /**
8
13
  * Abstract base class for tmux-based agent launchers.
9
14
  * Implements all shared lifecycle, monitoring, and session management logic.
@@ -103,12 +108,26 @@ export class BaseTmuxLauncher {
103
108
  static PROMPT_STALL_THRESHOLD_MS = 3 * 60 * 1000;
104
109
  // Re-notify cadence while the SAME stall persists unanswered.
105
110
  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;
111
+ // Resume ids restored PER CONVERSATION for its next launch (from a dying
112
+ // session, Rails metadata at boot, or the process-dead sweep). Key 0 holds a
113
+ // pin of unknown provenance, usable only by a launch with no target
114
+ // conversation. This replaces the launcher-global `lastSessionId` slot: one
115
+ // slot shared by every conversation let a capture for conv A become conv B's
116
+ // `--resume` target (three transcripts pinned to conv 3331 in turn, then
117
+ // handed to 3337 and 3369 — 2026-08-24). A pinned resume id replays ONE
118
+ // conversation's CLI context and must never cross conversations.
119
+ restoredResumeIds = new Map();
120
+ // resumeId → owning conversation. Survives unregister: a transcript that once
121
+ // belonged to conv A is never attributed to conv B later in this process
122
+ // lifetime, even after A's session is gone (the old guard only looked at
123
+ // LIVE sessions, so 3331's transcript was captured for 3337 the moment 3331
124
+ // stopped).
125
+ resumeIdOwner = new Map();
126
+ // Resume ids minted at launch (Claude `--session-id`), per conversation.
127
+ mintedResumeIds = new Map();
128
+ // `${agentSessionId}:${resumeId}` pairs already persisted to Rails — the
129
+ // metadata PUT happens ONCE per pair, not on every liveness tick.
130
+ persistedResumeIds = new Set();
112
131
  pendingTriggerMessage = null;
113
132
  pendingWorkspaceConfig = null;
114
133
  pendingWorkspaceCwd = null;
@@ -129,6 +148,42 @@ export class BaseTmuxLauncher {
129
148
  // stage's prompt into the config file forever (personal daemon, 2026-08-13 —
130
149
  // a 9,948-char Fleet-Auditor prompt naming a long-finished stage execution).
131
150
  pendingSessionPrompt = null;
151
+ // Launch → MCP transport bindings (shared with the MCP server). See launch-binding.ts.
152
+ launchBindings = null;
153
+ // One-shot backend linkage for the NEXT launch (agent_session_id /
154
+ // launch_correlation_id from the control-channel launch payload).
155
+ pendingLaunchLinkage = null;
156
+ setLaunchBindings(registry) {
157
+ this.launchBindings = registry;
158
+ }
159
+ setLaunchLinkage(linkage) {
160
+ this.pendingLaunchLinkage = linkage;
161
+ }
162
+ /**
163
+ * mcp-remote `--header` args that stamp the LOCAL silolink server entry with
164
+ * this launch's token, so the daemon's MCP server can bind the transport to
165
+ * the pane on initialize.
166
+ */
167
+ launchHeaderArgs(launch) {
168
+ return launch ? ['--header', `${LAUNCH_HEADER}: ${launch.token}`] : [];
169
+ }
170
+ /** Remove per-launch MCP config files older than `maxAgeMs` from `dir` (best-effort). */
171
+ cleanupPerLaunchConfigs(dir, maxAgeMs = 24 * 60 * 60 * 1000) {
172
+ try {
173
+ if (!existsSync(dir))
174
+ return;
175
+ const now = Date.now();
176
+ for (const f of readdirSync(dir)) {
177
+ const full = join(dir, f);
178
+ try {
179
+ if (now - statSync(full).mtimeMs > maxAgeMs)
180
+ unlinkSync(full);
181
+ }
182
+ catch { /* already gone */ }
183
+ }
184
+ }
185
+ catch { /* best-effort */ }
186
+ }
132
187
  constructor(config, sessionManager, dsiloedClient, messageQueue, workspaceManager, usageLedger) {
133
188
  this.config = config;
134
189
  this.sessionManager = sessionManager;
@@ -241,8 +296,18 @@ export class BaseTmuxLauncher {
241
296
  // Spare it only while its pane shows genuine new output AND it is still
242
297
  // under the hard idle ceiling; past the ceiling a session making no MCP
243
298
  // progress is wedged and must be killed regardless of pane animation.
244
- shouldSuppressIdleKill(tmuxName, idleMs) {
245
- return idleMs < this.getIdleKillHardCeilMs() && this.isActivelyThinking(tmuxName);
299
+ //
300
+ // A session in its remote_poll loop is never spared: it has told us, via
301
+ // the poll tool itself, that it has nothing to do. Its pane churns anyway
302
+ // (tool-call counters, spinner, "still nothing — continuing to poll"), and
303
+ // that churn used to pass as "genuine new output" — every Doug video
304
+ // session on 2026-08-26 ran 4h past its threshold to the 8h hard ceiling.
305
+ shouldSuppressIdleKill(tmuxName, idleMs, session) {
306
+ if (idleMs >= this.getIdleKillHardCeilMs())
307
+ return false;
308
+ if (session && isInPollLoop(session))
309
+ return false;
310
+ return this.isActivelyThinking(tmuxName);
246
311
  }
247
312
  /**
248
313
  * Whether to paste the prompt in tmux bracketed-paste mode (`paste-buffer -p`).
@@ -417,6 +482,18 @@ export class BaseTmuxLauncher {
417
482
  console.log(`${this.getLogTag()} Nudged ${this.tmuxSessions.size} session(s) (attempt ${this.nudgeCount + 1})`);
418
483
  return true;
419
484
  }
485
+ /**
486
+ * Nudge a single conversation's pane. Unlike nudge() this is not throttled:
487
+ * it answers a deliberate operator/control-channel request for THIS pane.
488
+ */
489
+ nudgeConversation(conversationId, message) {
490
+ const name = this.tmuxNameForConversation(conversationId);
491
+ if (!name)
492
+ return false;
493
+ this.sendLineToPane(name, message || 'Continue polling SiloLink for messages. Do not stop the poll loop.');
494
+ console.log(`${this.getLogTag()} Nudged "${name}" (conv ${conversationId})`);
495
+ return true;
496
+ }
420
497
  /**
421
498
  * Proactive heartbeat — sent when the session is alive but silent AND the
422
499
  * inbound queue is empty. The existing nudge() only fires when there are
@@ -447,13 +524,29 @@ export class BaseTmuxLauncher {
447
524
  return false;
448
525
  }
449
526
  this.lastHeartbeatAt = now;
450
- const message = 'Heartbeat check: if you are idle, call remote_poll to resume checking for messages. If you are mid-task, ignore this and keep going.';
451
- for (const [name] of this.tmuxSessions) {
452
- this.sendLineToPane(name, message);
527
+ let finalReported = 0;
528
+ for (const [name, info] of this.tmuxSessions) {
529
+ const session = info.conversationId ? this.sessionManager.getByConversationId(info.conversationId) : undefined;
530
+ const done = !!session?.finalReportAt;
531
+ if (done)
532
+ finalReported++;
533
+ this.sendLineToPane(name, BaseTmuxLauncher.heartbeatMessage(done));
453
534
  }
454
- console.log(`${this.getLogTag()} Heartbeat nudge sent to ${this.tmuxSessions.size} session(s)`);
535
+ console.log(`${this.getLogTag()} Heartbeat nudge sent to ${this.tmuxSessions.size} session(s)${finalReported ? ` (${finalReported} told to unregister — final report already filed)` : ''}`);
455
536
  return true;
456
537
  }
538
+ /**
539
+ * The heartbeat line typed into a pane. A session that has already filed
540
+ * its final report is told to END, not to keep polling: the generic
541
+ * "call remote_poll" wording kept finished workers answering "Mid-task,
542
+ * continuing" for hours (Doug's video sessions, 2026-08-26). Everything
543
+ * else keeps the classic wording, which is what idle prod agents rely on.
544
+ */
545
+ static heartbeatMessage(finalReportFiled) {
546
+ return finalReportFiled
547
+ ? 'Heartbeat check: you already posted your final report and no new work has arrived. Call remote_unregister now to end this session. Only if you are still mid-task, ignore this and keep going.'
548
+ : 'Heartbeat check: if you are idle, call remote_poll to resume checking for messages. If you are mid-task, ignore this and keep going.';
549
+ }
457
550
  isEnabled() { return this.enabled; }
458
551
  isAutoRespawnEnabled() { return this.enabled && this.isAutoRespawnConfigured(); }
459
552
  hasLiveProcess() { return this.tmuxSessions.size > 0; }
@@ -465,6 +558,10 @@ export class BaseTmuxLauncher {
465
558
  // isAutoRespawnEnabled() at their own call sites.
466
559
  if (!this.enabled)
467
560
  return;
561
+ // Idempotent: a second call must not overwrite idleCheckTimer and orphan
562
+ // the first interval (the Codex launcher used to be started twice).
563
+ if (this.idleCheckTimer)
564
+ return;
468
565
  // Reap tmux sessions left over from a prior daemon run before the
469
566
  // monitor starts. The new daemon's tmuxSessions map is empty by
470
567
  // definition, and the spawned CCs in those panes were bound to the
@@ -550,6 +647,7 @@ export class BaseTmuxLauncher {
550
647
  console.log(`${this.getLogTag()} Startup reap: left ${skipped.length} pane(s) owned elsewhere/unmarked: ${skipped.join(', ')}`);
551
648
  }
552
649
  if (reaped.length > 0) {
650
+ healthMonitor.noteReapedAtBoot(reaped.length);
553
651
  console.log(`${this.getLogTag()} Reaped ${reaped.length} orphan tmux session(s) from prior daemon run: ${reaped.join(', ')}`);
554
652
  }
555
653
  // Fix #3 (main-checkout guard): a prior daemon run may have left a shared
@@ -693,10 +791,23 @@ export class BaseTmuxLauncher {
693
791
  return prompt;
694
792
  }
695
793
  setLastResumeId(resumeId, conversationId) {
696
- this.lastSessionId = resumeId;
697
- this.lastSessionConversationId = conversationId ?? null;
794
+ this.restoredResumeIds.set(conversationId ?? 0, resumeId);
795
+ if (conversationId)
796
+ this.resumeIdOwner.set(resumeId, conversationId);
698
797
  console.log(`${this.getLogTag()} Restored resume ID: ${resumeId} (conv=${conversationId ?? 'unknown'})`);
699
798
  }
799
+ /** The resume id minted at launch for a conversation (Claude), if any. */
800
+ getMintedResumeId(conversationId) {
801
+ return this.mintedResumeIds.get(conversationId);
802
+ }
803
+ /**
804
+ * Public entry to the capture path — the Bridge calls it right after it
805
+ * pre-registers a launched session so a minted id lands on the Session (and
806
+ * is persisted once) immediately rather than on the next liveness tick.
807
+ */
808
+ syncResumeId(conversationId) {
809
+ this.captureResumeId(conversationId);
810
+ }
700
811
  /**
701
812
  * The resume id to use when launching for `conversationId`, or null to launch
702
813
  * fresh. Only a pin captured for the SAME conversation may be resumed — a pin
@@ -707,32 +818,35 @@ export class BaseTmuxLauncher {
707
818
  resumeTargetFor(conversationId) {
708
819
  // Per-conversation pin is authoritative: each Session stores the resume id
709
820
  // 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.
821
+ // resume each other's CLI session. The per-conversation restored id is the
822
+ // fallback (dying session / Rails metadata at boot). Nothing else — a pin
823
+ // from another conversation or of unknown provenance launches fresh.
712
824
  if (conversationId) {
713
825
  const pinned = this.sessionManager.getByConversationId(conversationId)?.resumeId;
714
826
  if (pinned)
715
827
  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;
828
+ const restored = this.restoredResumeIds.get(conversationId);
829
+ if (restored)
830
+ return restored;
831
+ const orphanPin = this.restoredResumeIds.get(0);
832
+ if (orphanPin) {
833
+ console.warn(`${this.getLogTag()} Resume id ${orphanPin} has no recorded conversation — launching conv=${conversationId} fresh instead of resuming it.`);
721
834
  }
835
+ return null;
722
836
  }
723
- return this.lastSessionId;
837
+ return this.restoredResumeIds.get(0) ?? null;
724
838
  }
725
839
  /**
726
840
  * Forget a resume target that turned out to be unresumable so the next
727
841
  * 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),
842
+ * Clears every store a resume target lives in: the per-conversation restored
843
+ * and minted ids, the in-memory SessionInfo (which finishPaneTeardown would otherwise restore),
730
844
  * and the persisted rails AgentSession metadata (so a daemon restart can't
731
845
  * resurrect it). Best-effort; never throws into the teardown path.
732
846
  */
733
847
  clearResumeId(conversationId) {
734
- this.lastSessionId = null;
735
- this.lastSessionConversationId = null;
848
+ this.restoredResumeIds.delete(conversationId || 0);
849
+ this.mintedResumeIds.delete(conversationId);
736
850
  const session = conversationId
737
851
  ? this.sessionManager.getByConversationId(conversationId)
738
852
  : this.sessionManager.getAll()[0];
@@ -784,7 +898,9 @@ export class BaseTmuxLauncher {
784
898
  for (const [name, info] of this.tmuxSessions) {
785
899
  if (info.conversationId === conversationId) {
786
900
  this.capturePaneTail(name, reason); // also reports token usage (see capturePaneTail)
901
+ const strays = this.snapshotStrays(conversationId);
787
902
  spawn('tmux', ['kill-session', '-t', name], { stdio: 'ignore' });
903
+ this.reapStrays(strays, reason);
788
904
  this.tmuxSessions.delete(name);
789
905
  const wsId = this.tmuxSessionWorkspaces.get(name);
790
906
  if (wsId && this.workspaceManager) {
@@ -813,13 +929,15 @@ export class BaseTmuxLauncher {
813
929
  });
814
930
  }
815
931
  kill() {
816
- for (const [name] of this.tmuxSessions) {
932
+ for (const [name, info] of this.tmuxSessions) {
817
933
  this.capturePaneTail(name, 'kill-all');
818
934
  const wsSessionId = this.tmuxSessionWorkspaces.get(name);
819
935
  if (wsSessionId && this.workspaceManager) {
820
936
  this.workspaceManager.removeWorktree(wsSessionId);
821
937
  }
938
+ const strays = info.conversationId ? this.snapshotStrays(info.conversationId) : null;
822
939
  spawn('tmux', ['kill-session', '-t', name], { stdio: 'ignore' });
940
+ this.reapStrays(strays, 'kill-all');
823
941
  }
824
942
  this.tmuxSessions.clear();
825
943
  this.tmuxSessionWorkspaces.clear();
@@ -828,6 +946,44 @@ export class BaseTmuxLauncher {
828
946
  this.tmuxMonitorTimer = null;
829
947
  }
830
948
  }
949
+ /**
950
+ * Value of the SILOLINK_PANE env marker every process under a pane inherits:
951
+ * `<owner-env>:conv<id>`. Env-qualified because conversation ids are only
952
+ * unique per platform host — a DEV and a PROD daemon on one laptop can both
953
+ * own a "conv 3432".
954
+ */
955
+ paneMarker(conversationId) {
956
+ return `${this.ownerEnv()}:conv${conversationId}`;
957
+ }
958
+ /**
959
+ * Stray-process reaping (2026-08-26): killing a tmux session kills the shell
960
+ * and the CLI, but anything the CLI started detached (a backgrounded
961
+ * `ffmpeg`, `nohup node …`) is re-parented to init and lives on — one
962
+ * orphaned ffmpeg from a finished Doug video session pinned a core at 114%
963
+ * for 6h43m. Process lineage is gone by then, but the ENVIRONMENT is
964
+ * inherited: every pane is launched with SILOLINK_PANE=<env>:conv<id>, so
965
+ * survivors are findable by marker. Snapshot BEFORE the kill (so a relaunch
966
+ * of the same conversation seconds later is never in the set), sweep after.
967
+ */
968
+ snapshotStrays(conversationId) {
969
+ try {
970
+ return snapshotStrayProcesses(this.paneMarker(conversationId));
971
+ }
972
+ catch (err) {
973
+ console.warn(`${this.getLogTag()} stray-process snapshot failed for conv ${conversationId}: ${err}`);
974
+ return null;
975
+ }
976
+ }
977
+ reapStrays(snapshot, reason) {
978
+ if (!snapshot || snapshot.pids.length === 0)
979
+ return;
980
+ reapStrayProcesses(snapshot).then(result => {
981
+ if (result.killed.length === 0)
982
+ return;
983
+ console.warn(`${this.getLogTag()} reaped ${result.killed.length} stray process(es) left behind by ${snapshot.marker} (reason: ${reason}): ` +
984
+ result.killed.map(p => `${p.pid} ${p.command}`).join(' | '));
985
+ }).catch(err => console.warn(`${this.getLogTag()} stray-process sweep failed for ${snapshot.marker}: ${err}`));
986
+ }
831
987
  /**
832
988
  * Capture the last ~200 lines of a tmux pane and log them. Called before
833
989
  * kill-session so the last output survives the kill. The `remain-on-exit on`
@@ -1128,7 +1284,21 @@ export class BaseTmuxLauncher {
1128
1284
  // deterministic and independent of racy shared-state approvals.
1129
1285
  const mcpToolAllowlist = this.pendingMcpToolAllowlist;
1130
1286
  this.pendingMcpToolAllowlist = null;
1131
- const mcpExtraArgs = await this.setupMcpConfig(cwd, mcpToolAllowlist);
1287
+ // Per-launch identity: the token rides into the CLI's MCP config as an
1288
+ // X-SiloLink-Launch header, and the binding lets remote_register tell
1289
+ // which pane is calling (see launch-binding.ts).
1290
+ const launch = { token: randomUUID(), conversationId };
1291
+ const linkage = this.pendingLaunchLinkage;
1292
+ this.pendingLaunchLinkage = null;
1293
+ if (conversationId && this.launchBindings) {
1294
+ this.launchBindings.register({
1295
+ token: launch.token,
1296
+ conversationId,
1297
+ agentSessionId: linkage?.agentSessionId,
1298
+ launchCorrelationId: linkage?.launchCorrelationId,
1299
+ });
1300
+ }
1301
+ const mcpExtraArgs = await this.setupMcpConfig(cwd, mcpToolAllowlist, launch);
1132
1302
  if (Array.isArray(mcpExtraArgs) && mcpExtraArgs.length > 0)
1133
1303
  args.push(...mcpExtraArgs);
1134
1304
  // Fetch agent launch context
@@ -1225,9 +1395,13 @@ export class BaseTmuxLauncher {
1225
1395
  // runs daemons AND has the hook installed. Uses an `env` prefix (not
1226
1396
  // getAgentEnv) because tmux doesn't propagate caller env to panes on an
1227
1397
  // already-running server.
1398
+ // SILOLINK_PANE=<env>:conv<id> is inherited by everything the CLI spawns,
1399
+ // detached or not — it is how stray subprocesses are found after the
1400
+ // pane is gone (see snapshotStrays).
1401
+ const paneEnv = conversationId ? [`SILOLINK_PANE=${this.paneMarker(conversationId)}`] : [];
1228
1402
  const tmuxArgs = [
1229
1403
  'new-session', '-d', '-s', tmuxSession, '-c', cwd, '-x', '200', '-y', '50',
1230
- 'env', 'SILOLINK_MANAGED=1', command, ...args,
1404
+ 'env', 'SILOLINK_MANAGED=1', ...paneEnv, command, ...args,
1231
1405
  ];
1232
1406
  const tmuxCreate = spawn('tmux', tmuxArgs, {
1233
1407
  stdio: 'ignore',
@@ -1259,10 +1433,16 @@ export class BaseTmuxLauncher {
1259
1433
  return false;
1260
1434
  }
1261
1435
  console.log(`${this.getLogTag()} tmux session "${tmuxSession}" created`);
1262
- this.tmuxSessions.set(tmuxSession, { conversationId: conversationId || 0, startedAt: new Date(), cwd });
1436
+ const mintedSessionId = conversationId ? this.mintedResumeIds.get(conversationId) : undefined;
1437
+ this.tmuxSessions.set(tmuxSession, { conversationId: conversationId || 0, startedAt: new Date(), cwd, mintedSessionId });
1263
1438
  if (workspaceSessionId) {
1264
1439
  this.tmuxSessionWorkspaces.set(tmuxSession, workspaceSessionId);
1265
1440
  }
1441
+ // A minted id needs no discovery — pin it on an already-registered session
1442
+ // (relaunch) right away. A fresh launch's session is pre-registered by the
1443
+ // Bridge afterwards, which calls syncResumeId.
1444
+ if (mintedSessionId && conversationId)
1445
+ this.captureResumeId(conversationId);
1266
1446
  // Wait for agent to be ready, then send the prompt
1267
1447
  const sessionToPrompt = tmuxSession;
1268
1448
  const promptToSend = this.formatPrompt(prompt);
@@ -1439,7 +1619,7 @@ export class BaseTmuxLauncher {
1439
1619
  // forget that id so finishPaneTeardown relaunches FRESH instead of
1440
1620
  // re-issuing the same doomed `--resume`/`resume` and looping forever.
1441
1621
  // Applies to every CLI via the per-launcher isResumeFailure() override.
1442
- const hadResumeTarget = this.lastSessionId != null
1622
+ const hadResumeTarget = this.restoredResumeIds.has(info.conversationId)
1443
1623
  || this.sessionManager.getByConversationId(info.conversationId)?.resumeId != null;
1444
1624
  if (hadResumeTarget && this.isResumeFailure(paneContent)) {
1445
1625
  console.warn(`${this.getLogTag()} Pane died on an unresumable session for conv=${info.conversationId} — clearing resume id and relaunching fresh.`);
@@ -1469,12 +1649,12 @@ export class BaseTmuxLauncher {
1469
1649
  const stillRegistered = this.sessionManager.getByConversationId(conversationId);
1470
1650
  if (!stillRegistered)
1471
1651
  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.
1652
+ // RESTORE resume id from the dying SESSION's stored value BEFORE we
1653
+ // unregister it — this is what lets the auto-respawn relaunch the SAME CLI
1654
+ // session (`--resume <id>`) and continue an in-progress task instead of
1655
+ // starting fresh. Best-effort: if the worker never captured an id (capture
1656
+ // failed / CLI has no resume), nothing is restored and the relaunch falls
1657
+ // back to a fresh session — never blocked.
1478
1658
  const resumeId = stillRegistered.resumeId || stillRegistered.claudeResumeId;
1479
1659
  if (resumeId) {
1480
1660
  this.setLastResumeId(resumeId, conversationId);
@@ -1555,23 +1735,27 @@ export class BaseTmuxLauncher {
1555
1735
  *
1556
1736
  * Best-effort: returns null on any failure. NEVER throws into the launch path.
1557
1737
  */
1558
- detectResumeId(_cwd) {
1738
+ detectResumeId(_cwd, _notBefore) {
1559
1739
  return null;
1560
1740
  }
1561
1741
  /**
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.
1742
+ * Pin the running worker's CLI session id on its Session so the next respawn
1743
+ * relaunches the SAME session (`--resume`). Two sources, in order:
1744
+ * 1. the id MINTED at launch (Claude `--session-id`) — authoritative, no
1745
+ * discovery, never overwritten;
1746
+ * 2. discovery from the CLI's on-disk transcript (Gemini/Codex, and Claude
1747
+ * launches that resumed) — accepted only if the transcript was touched
1748
+ * at/after the pane's startedAt.
1749
+ * Either way the id is refused when another conversation owns it — a LIVE
1750
+ * session or any conversation seen earlier this process lifetime
1751
+ * (`resumeIdOwner`, which survives unregister). Stores on the Session
1752
+ * (`resumeId`) and persists `cli_resume_id` + legacy `claude_resume_id` to the
1753
+ * rails AgentSession metadata ONCE per (agent session, id) pair — that
1754
+ * survives a daemon restart so bridge startup / remote_load_context can
1755
+ * restore it.
1571
1756
  *
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.
1757
+ * Idempotent; runs on the liveness tick and once right after launch.
1758
+ * Best-effort — a capture failure leaves prior state intact and never blocks.
1575
1759
  */
1576
1760
  captureResumeId(conversationId) {
1577
1761
  const session = conversationId
@@ -1580,44 +1764,64 @@ export class BaseTmuxLauncher {
1580
1764
  if (!session)
1581
1765
  return;
1582
1766
  // 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;
1767
+ let info;
1768
+ for (const candidate of this.tmuxSessions.values()) {
1769
+ if (candidate.conversationId === conversationId) {
1770
+ info = candidate;
1587
1771
  break;
1588
1772
  }
1589
1773
  }
1590
- cwd ||= this.getWorkingDirectory();
1774
+ const cwd = info?.cwd || this.getWorkingDirectory();
1775
+ const minted = info?.mintedSessionId ?? this.mintedResumeIds.get(conversationId);
1591
1776
  let resumeId = null;
1592
- try {
1593
- resumeId = this.detectResumeId(cwd);
1777
+ let source;
1778
+ if (minted) {
1779
+ resumeId = minted;
1780
+ source = 'minted';
1594
1781
  }
1595
- catch (err) {
1596
- console.warn(`${this.getLogTag()} detectResumeId failed for conv=${conversationId}: ${err}`);
1597
- return;
1782
+ else {
1783
+ try {
1784
+ resumeId = this.detectResumeId(cwd, info?.startedAt);
1785
+ }
1786
+ catch (err) {
1787
+ console.warn(`${this.getLogTag()} detectResumeId failed for conv=${conversationId}: ${err}`);
1788
+ return;
1789
+ }
1790
+ source = 'discovered';
1598
1791
  }
1599
1792
  if (!resumeId)
1600
1793
  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.`);
1794
+ // Ownership guard. detectResumeId is cwd-scoped ("most recent transcript in
1795
+ // the launch cwd"), so when two conversations share a cwd it can return the
1796
+ // OTHER conversation's transcript. Never pin an id that a different
1797
+ // conversation owns — live (2026-07-18: conv 2170 stole conv 2158's id and
1798
+ // the 2170 pane ended up polling 2158) or previously seen (2026-08-24: once
1799
+ // 3331 stopped, its transcript was captured for 3337, then 3337's for 3369).
1800
+ const priorOwner = this.resumeIdOwner.get(resumeId);
1801
+ if (priorOwner !== undefined && priorOwner !== conversationId) {
1802
+ console.warn(`${this.getLogTag()} Resume id ${resumeId} is owned by conv=${priorOwner} — NOT capturing it for conv=${conversationId} (${source}). Leaving fresh.`);
1611
1803
  return;
1612
1804
  }
1613
- // No change — already captured this id.
1614
- if (this.lastSessionId === resumeId && session.resumeId === resumeId)
1805
+ const otherLive = this.sessionManager.getAll().find((s) => s.resumeId === resumeId && s.conversationId !== conversationId);
1806
+ if (otherLive) {
1807
+ 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
1808
  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
1809
+ }
1810
+ this.resumeIdOwner.set(resumeId, conversationId);
1811
+ const changed = session.resumeId !== resumeId;
1812
+ if (changed) {
1813
+ healthMonitor.noteResumeCapture();
1814
+ if (session.resumeId) {
1815
+ console.log(`${this.getLogTag()} Resume id for conv=${conversationId}: ${session.resumeId} → ${resumeId} (${source})`);
1816
+ }
1817
+ session.resumeId = resumeId;
1818
+ session.claudeResumeId = resumeId; // keep legacy alias in sync
1819
+ }
1620
1820
  if (this.dsiloedClient && session.agentSessionId) {
1821
+ const key = `${session.agentSessionId}:${resumeId}`;
1822
+ if (this.persistedResumeIds.has(key))
1823
+ return;
1824
+ this.persistedResumeIds.add(key);
1621
1825
  // MERGE — only the resume keys; a full replace would wipe workspace_* and
1622
1826
  // server-set keys. Persist BOTH a generic key and the legacy claude key the
1623
1827
  // bridge startup / remote_load_context already read.
@@ -1625,11 +1829,14 @@ export class BaseTmuxLauncher {
1625
1829
  cli_resume_id: resumeId,
1626
1830
  claude_resume_id: resumeId,
1627
1831
  }).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));
1832
+ console.log(`${this.getLogTag()} Captured resume id ${resumeId} for conv=${conversationId} (agent session ${session.agentSessionId}, ${source})`);
1833
+ }).catch(err => {
1834
+ this.persistedResumeIds.delete(key); // retry on the next tick
1835
+ console.error(`${this.getLogTag()} Failed to persist resume id:`, err);
1836
+ });
1630
1837
  }
1631
- else {
1632
- console.log(`${this.getLogTag()} Captured resume id ${resumeId} for conv=${conversationId} (in-memory only)`);
1838
+ else if (changed) {
1839
+ console.log(`${this.getLogTag()} Captured resume id ${resumeId} for conv=${conversationId} (in-memory only, ${source})`);
1633
1840
  }
1634
1841
  }
1635
1842
  // Returns true if usage was found and a report was initiated (used by the
@@ -1663,7 +1870,7 @@ export class BaseTmuxLauncher {
1663
1870
  }
1664
1871
  }
1665
1872
  cwd ||= ledgerEntry?.cwd || this.getWorkingDirectory();
1666
- const cliSessionId = this.lastSessionId ?? session?.resumeId ?? ledgerEntry?.cliSessionId ?? null;
1873
+ const cliSessionId = session?.resumeId ?? ledgerEntry?.cliSessionId ?? this.restoredResumeIds.get(conversationId) ?? null;
1667
1874
  let extracted;
1668
1875
  try {
1669
1876
  extracted = this.extractTranscriptUsage(cwd, cliSessionId);
@@ -1689,7 +1896,10 @@ export class BaseTmuxLauncher {
1689
1896
  }).then((result) => {
1690
1897
  // 'rejected' means the server can never accept it — settle the entry rather
1691
1898
  // than leave it to be retried on every future startup sweep, forever.
1692
- if (result !== 'failed' && final)
1899
+ // 'failed' and 'blocked' (terminal 403 — endpoint blocked, no request made)
1900
+ // both keep the entry; 'blocked' is silent here because the tracker
1901
+ // already logged the transition once.
1902
+ if ((result === 'reported' || result === 'rejected') && final)
1693
1903
  this.usageLedger?.remove(conversationId);
1694
1904
  if (result === 'rejected') {
1695
1905
  console.warn(`${this.getLogTag()} Usage unreportable for conv=${conversationId} — entry settled (not retryable)`);
@@ -1785,7 +1995,7 @@ export class BaseTmuxLauncher {
1785
1995
  agentSessionId: session.agentSessionId,
1786
1996
  cliType: this.getCliType(),
1787
1997
  cwd: cwd || this.getWorkingDirectory(),
1788
- cliSessionId: session.resumeId || this.lastSessionId || undefined,
1998
+ cliSessionId: session.resumeId || undefined,
1789
1999
  tmuxSession,
1790
2000
  });
1791
2001
  }
@@ -1888,7 +2098,7 @@ export class BaseTmuxLauncher {
1888
2098
  // NEW output. A pane frozen past FROZEN_THRESHOLD_MS is wedged and gets
1889
2099
  // killed despite any spinner animation.
1890
2100
  const tmuxName = this.tmuxNameForConversation(s.conversationId);
1891
- if (tmuxName && this.shouldSuppressIdleKill(tmuxName, now - s.lastActivity.getTime())) {
2101
+ if (tmuxName && this.shouldSuppressIdleKill(tmuxName, now - s.lastActivity.getTime(), s)) {
1892
2102
  console.log(`${this.getLogTag()} conv ${s.conversationId} past idle-kill threshold but pane shows active work — suppressing idle-kill`);
1893
2103
  continue;
1894
2104
  }
@@ -2103,6 +2313,11 @@ export class BaseTmuxLauncher {
2103
2313
  const idleMs = now - s.lastActivity.getTime();
2104
2314
  if (idleMs < BaseTmuxLauncher.WEDGED_SESSION_MS)
2105
2315
  continue;
2316
+ // An empty remote_poll does NOT advance lastActivity, so a healthy idle
2317
+ // poller looks MCP-silent here. Its poll loop is the liveness proof —
2318
+ // never a wedge, regardless of what its pane hash does.
2319
+ if (isInPollLoop(s, now))
2320
+ continue;
2106
2321
  const tmuxName = this.tmuxNameForConversation(s.conversationId);
2107
2322
  if (!tmuxName)
2108
2323
  continue; // no pane — the dead-pane reconcile owns it
@@ -2115,6 +2330,36 @@ export class BaseTmuxLauncher {
2115
2330
  this.killByConversation(s.conversationId, `wedged-session:${idleMin}min`);
2116
2331
  }
2117
2332
  }
2333
+ /**
2334
+ * Wedge verdict for one conversation, from the SAME primitives
2335
+ * reapWedgedSessions uses: pane present + capturable, not actively thinking,
2336
+ * and substantive content frozen for at least FROZEN_THRESHOLD_MS. Used by the
2337
+ * stale-on-inbound recycler (2026-08-23: it recycled on MCP silence alone and
2338
+ * killed live panes mid-work — conv 3337 six times in one evening).
2339
+ */
2340
+ isSessionWedged(conversationId) {
2341
+ const paneName = this.tmuxNameForConversation(conversationId);
2342
+ if (!paneName)
2343
+ return { wedged: false, reason: 'no-pane', paneName: null };
2344
+ if (this.capturePaneTailForProgress(paneName) === null) {
2345
+ return { wedged: false, reason: 'pane-gone', paneName };
2346
+ }
2347
+ // In its poll loop ⇒ alive AND idle. Reported as 'polling' rather than
2348
+ // 'pane-active' so idle-completion acts on it (a parked worker) while the
2349
+ // recycler still leaves it alone (not wedged).
2350
+ const session = this.sessionManager.getByConversationId(conversationId);
2351
+ if (session && isInPollLoop(session))
2352
+ return { wedged: false, reason: 'polling', paneName };
2353
+ if (this.isActivelyThinking(paneName))
2354
+ return { wedged: false, reason: 'pane-active', paneName };
2355
+ const prog = this.paneProgress.get(paneName);
2356
+ const frozenMs = prog ? Date.now() - prog.lastChangeAt : 0;
2357
+ const frozenMin = Math.round(frozenMs / 60000);
2358
+ if (frozenMs < BaseTmuxLauncher.FROZEN_THRESHOLD_MS) {
2359
+ return { wedged: false, reason: `frozen-${frozenMin}min<threshold`, paneName };
2360
+ }
2361
+ return { wedged: true, reason: `frozen-${frozenMin}min`, paneName };
2362
+ }
2118
2363
  isActivelyThinking(tmuxSession) {
2119
2364
  const now = Date.now();
2120
2365
  const prev = this.paneProgress.get(tmuxSession);