@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.
- package/README.md +8 -0
- package/dist/api/backend-health.d.ts +44 -0
- package/dist/api/backend-health.d.ts.map +1 -0
- package/dist/api/backend-health.js +80 -0
- package/dist/api/backend-health.js.map +1 -0
- package/dist/api/dsiloed-client.d.ts +7 -3
- package/dist/api/dsiloed-client.d.ts.map +1 -1
- package/dist/api/dsiloed-client.js +29 -4
- package/dist/api/dsiloed-client.js.map +1 -1
- package/dist/cable/subscription-manager.d.ts +26 -1
- package/dist/cable/subscription-manager.d.ts.map +1 -1
- package/dist/cable/subscription-manager.js +72 -13
- package/dist/cable/subscription-manager.js.map +1 -1
- package/dist/cli/claude-md-block.d.ts +1 -1
- package/dist/cli/claude-md-block.d.ts.map +1 -1
- package/dist/cli/claude-md-block.js +3 -1
- package/dist/cli/claude-md-block.js.map +1 -1
- package/dist/cli/file-log.d.ts +58 -3
- package/dist/cli/file-log.d.ts.map +1 -1
- package/dist/cli/file-log.js +110 -7
- package/dist/cli/file-log.js.map +1 -1
- package/dist/core/agent-launcher.d.ts +26 -0
- package/dist/core/agent-launcher.d.ts.map +1 -1
- package/dist/core/auto-updater.d.ts +11 -0
- package/dist/core/auto-updater.d.ts.map +1 -1
- package/dist/core/auto-updater.js +84 -15
- package/dist/core/auto-updater.js.map +1 -1
- package/dist/core/base-tmux-launcher.d.ts +66 -20
- package/dist/core/base-tmux-launcher.d.ts.map +1 -1
- package/dist/core/base-tmux-launcher.js +236 -76
- package/dist/core/base-tmux-launcher.js.map +1 -1
- package/dist/core/bridge.d.ts +28 -0
- package/dist/core/bridge.d.ts.map +1 -1
- package/dist/core/bridge.js +217 -25
- package/dist/core/bridge.js.map +1 -1
- package/dist/core/claude-launcher.d.ts +11 -4
- package/dist/core/claude-launcher.d.ts.map +1 -1
- package/dist/core/claude-launcher.js +59 -16
- package/dist/core/claude-launcher.js.map +1 -1
- package/dist/core/codex-launcher.d.ts +3 -3
- package/dist/core/codex-launcher.d.ts.map +1 -1
- package/dist/core/codex-launcher.js +9 -5
- package/dist/core/codex-launcher.js.map +1 -1
- package/dist/core/gemini-launcher.d.ts +3 -3
- package/dist/core/gemini-launcher.d.ts.map +1 -1
- package/dist/core/gemini-launcher.js +9 -5
- package/dist/core/gemini-launcher.js.map +1 -1
- package/dist/core/health-monitor.d.ts +87 -0
- package/dist/core/health-monitor.d.ts.map +1 -0
- package/dist/core/health-monitor.js +93 -0
- package/dist/core/health-monitor.js.map +1 -0
- package/dist/core/idle-completion.d.ts +38 -0
- package/dist/core/idle-completion.d.ts.map +1 -0
- package/dist/core/idle-completion.js +52 -0
- package/dist/core/idle-completion.js.map +1 -0
- package/dist/core/launch-binding.d.ts +52 -0
- package/dist/core/launch-binding.d.ts.map +1 -0
- package/dist/core/launch-binding.js +81 -0
- package/dist/core/launch-binding.js.map +1 -0
- package/dist/core/transcript-usage.d.ts +9 -0
- package/dist/core/transcript-usage.d.ts.map +1 -1
- package/dist/core/transcript-usage.js +19 -0
- package/dist/core/transcript-usage.js.map +1 -1
- package/dist/core/usage-ledger.d.ts +1 -1
- package/dist/core/usage-ledger.d.ts.map +1 -1
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +15 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools/register-tools.d.ts +2 -0
- package/dist/mcp/tools/register-tools.d.ts.map +1 -1
- package/dist/mcp/tools/register-tools.js +154 -36
- package/dist/mcp/tools/register-tools.js.map +1 -1
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.d.ts.map +1 -1
- 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
|
-
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
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.
|
|
697
|
-
|
|
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
|
|
711
|
-
// fallback
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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.
|
|
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
|
|
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.
|
|
735
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
1473
|
-
//
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
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
|
-
*
|
|
1563
|
-
*
|
|
1564
|
-
* 1.
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1567
|
-
*
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
1570
|
-
*
|
|
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
|
|
1573
|
-
*
|
|
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
|
|
1584
|
-
for (const
|
|
1585
|
-
if (
|
|
1586
|
-
|
|
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
|
|
1700
|
+
const cwd = info?.cwd || this.getWorkingDirectory();
|
|
1701
|
+
const minted = info?.mintedSessionId ?? this.mintedResumeIds.get(conversationId);
|
|
1591
1702
|
let resumeId = null;
|
|
1592
|
-
|
|
1593
|
-
|
|
1703
|
+
let source;
|
|
1704
|
+
if (minted) {
|
|
1705
|
+
resumeId = minted;
|
|
1706
|
+
source = 'minted';
|
|
1594
1707
|
}
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
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
|
-
//
|
|
1602
|
-
//
|
|
1603
|
-
//
|
|
1604
|
-
//
|
|
1605
|
-
//
|
|
1606
|
-
//
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
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
|
-
|
|
1614
|
-
if (
|
|
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
|
-
|
|
1617
|
-
this.
|
|
1618
|
-
session.resumeId
|
|
1619
|
-
|
|
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 =>
|
|
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 =
|
|
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
|
-
|
|
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 ||
|
|
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);
|