@dsiloed/silo-link 1.19.11 → 1.19.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- 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/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 +19 -0
- package/dist/core/agent-launcher.d.ts.map +1 -1
- package/dist/core/base-tmux-launcher.d.ts +82 -19
- package/dist/core/base-tmux-launcher.d.ts.map +1 -1
- package/dist/core/base-tmux-launcher.js +304 -76
- package/dist/core/base-tmux-launcher.js.map +1 -1
- package/dist/core/bridge.d.ts.map +1 -1
- package/dist/core/bridge.js +9 -4
- package/dist/core/bridge.js.map +1 -1
- package/dist/core/claude-launcher.d.ts +7 -2
- package/dist/core/claude-launcher.d.ts.map +1 -1
- package/dist/core/claude-launcher.js +41 -18
- package/dist/core/claude-launcher.js.map +1 -1
- package/dist/core/codex-launcher.d.ts +1 -1
- package/dist/core/codex-launcher.d.ts.map +1 -1
- package/dist/core/codex-launcher.js +17 -14
- package/dist/core/codex-launcher.js.map +1 -1
- package/dist/core/gemini-launcher.d.ts +1 -1
- package/dist/core/gemini-launcher.d.ts.map +1 -1
- package/dist/core/gemini-launcher.js +17 -14
- package/dist/core/gemini-launcher.js.map +1 -1
- 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/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +2 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn, execFileSync } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { existsSync, copyFileSync, writeFileSync, unlinkSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { existsSync, copyFileSync, writeFileSync, unlinkSync, readdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
4
5
|
import { join, dirname } from 'node:path';
|
|
5
6
|
import { tmpdir, homedir } from 'node:os';
|
|
6
7
|
/**
|
|
@@ -53,6 +54,30 @@ export class BaseTmuxLauncher {
|
|
|
53
54
|
// Panes already reported as orphaned-but-working, so the warning is logged
|
|
54
55
|
// once rather than every idle cycle.
|
|
55
56
|
orphanPaneNoted = new Set();
|
|
57
|
+
// Conversations whose CURRENT session came from an auto-launch (a message
|
|
58
|
+
// arriving for a conversation with no live session — the "resurrect to
|
|
59
|
+
// deliver" path) rather than a deliberate control-channel dispatch. Such
|
|
60
|
+
// sessions get an automatic idle wind-down: when the delivered message
|
|
61
|
+
// needed no work, nothing else ever cleans the stub up (idle-kill is
|
|
62
|
+
// usually off, the backend sees live heartbeats, and daemons no longer
|
|
63
|
+
// restart routinely). A later deliberate launch for the same conversation
|
|
64
|
+
// clears the mark.
|
|
65
|
+
autoLaunchedConversations = new Set();
|
|
66
|
+
static RESURRECT_IDLE_KILL_DEFAULT_MS = 12 * 60 * 1000;
|
|
67
|
+
getResurrectIdleKillMs() {
|
|
68
|
+
const configured = this.config.resurrect_idle_kill_ms;
|
|
69
|
+
return typeof configured === 'number' ? configured : BaseTmuxLauncher.RESURRECT_IDLE_KILL_DEFAULT_MS;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The idle-kill threshold that applies to this conversation's RESURRECT
|
|
73
|
+
* status: >0 only when its current session was auto-launched and the
|
|
74
|
+
* resurrect wind-down is enabled.
|
|
75
|
+
*/
|
|
76
|
+
resurrectIdleKillThresholdMs(conversationId) {
|
|
77
|
+
if (!conversationId || !this.autoLaunchedConversations.has(conversationId))
|
|
78
|
+
return 0;
|
|
79
|
+
return this.getResurrectIdleKillMs();
|
|
80
|
+
}
|
|
56
81
|
// A REGISTERED session with a live pane that has made no MCP call at all for
|
|
57
82
|
// this long is wedged, not busy: a healthy SiloLink agent polls every few
|
|
58
83
|
// seconds, so lastActivity advances constantly and a working session can
|
|
@@ -78,12 +103,26 @@ export class BaseTmuxLauncher {
|
|
|
78
103
|
static PROMPT_STALL_THRESHOLD_MS = 3 * 60 * 1000;
|
|
79
104
|
// Re-notify cadence while the SAME stall persists unanswered.
|
|
80
105
|
static PROMPT_STALL_RENOTIFY_MS = 30 * 60 * 1000;
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
106
|
+
// Resume ids restored PER CONVERSATION for its next launch (from a dying
|
|
107
|
+
// session, Rails metadata at boot, or the process-dead sweep). Key 0 holds a
|
|
108
|
+
// pin of unknown provenance, usable only by a launch with no target
|
|
109
|
+
// conversation. This replaces the launcher-global `lastSessionId` slot: one
|
|
110
|
+
// slot shared by every conversation let a capture for conv A become conv B's
|
|
111
|
+
// `--resume` target (three transcripts pinned to conv 3331 in turn, then
|
|
112
|
+
// handed to 3337 and 3369 — 2026-08-24). A pinned resume id replays ONE
|
|
113
|
+
// conversation's CLI context and must never cross conversations.
|
|
114
|
+
restoredResumeIds = new Map();
|
|
115
|
+
// resumeId → owning conversation. Survives unregister: a transcript that once
|
|
116
|
+
// belonged to conv A is never attributed to conv B later in this process
|
|
117
|
+
// lifetime, even after A's session is gone (the old guard only looked at
|
|
118
|
+
// LIVE sessions, so 3331's transcript was captured for 3337 the moment 3331
|
|
119
|
+
// stopped).
|
|
120
|
+
resumeIdOwner = new Map();
|
|
121
|
+
// Resume ids minted at launch (Claude `--session-id`), per conversation.
|
|
122
|
+
mintedResumeIds = new Map();
|
|
123
|
+
// `${agentSessionId}:${resumeId}` pairs already persisted to Rails — the
|
|
124
|
+
// metadata PUT happens ONCE per pair, not on every liveness tick.
|
|
125
|
+
persistedResumeIds = new Set();
|
|
87
126
|
pendingTriggerMessage = null;
|
|
88
127
|
pendingWorkspaceConfig = null;
|
|
89
128
|
pendingWorkspaceCwd = null;
|
|
@@ -172,6 +211,35 @@ export class BaseTmuxLauncher {
|
|
|
172
211
|
const nodeBin = dirname(process.execPath);
|
|
173
212
|
return base.split(':').includes(nodeBin) ? base : `${nodeBin}:${base}`;
|
|
174
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Resolve the mcp-remote bridge to an invocation: { command, args } to
|
|
216
|
+
* PREPEND to the server-specific args (URL, headers).
|
|
217
|
+
*
|
|
218
|
+
* Prefers the mcp-remote copy bundled as a dependency of THIS package,
|
|
219
|
+
* launched with the daemon's own node binary. `npx mcp-remote` at session
|
|
220
|
+
* start did a cold install into the SHARED per-user npx cache, and two MCP
|
|
221
|
+
* servers starting concurrently raced the same cache entry (npm ENOTEMPTY
|
|
222
|
+
* rename) — the loser's server died and the session came up missing its
|
|
223
|
+
* control-plane tools (Doug v6 second failure, 2026-08-23). A bundled copy
|
|
224
|
+
* launched by absolute node has no install step, no cache, no PATH and no
|
|
225
|
+
* shebang to go wrong. Falls back to resolved npx for dev layouts where the
|
|
226
|
+
* dependency is missing.
|
|
227
|
+
*/
|
|
228
|
+
resolveMcpRemoteInvocation() {
|
|
229
|
+
try {
|
|
230
|
+
const req = createRequire(import.meta.url);
|
|
231
|
+
const pkgPath = req.resolve('mcp-remote/package.json');
|
|
232
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
233
|
+
const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.['mcp-remote'];
|
|
234
|
+
if (binRel) {
|
|
235
|
+
const binAbs = join(dirname(pkgPath), binRel);
|
|
236
|
+
if (existsSync(binAbs))
|
|
237
|
+
return { command: process.execPath, args: [binAbs] };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch { /* dependency missing — fall back to npx below */ }
|
|
241
|
+
return { command: this.resolveNpxCommand(), args: ['mcp-remote'] };
|
|
242
|
+
}
|
|
175
243
|
/** Idle-kill threshold in ms; 0 disables. Override in subclass. */
|
|
176
244
|
getIdleKillAfterMs() { return 0; }
|
|
177
245
|
// A session making zero MCP progress past this HARD CEILING is wedged and
|
|
@@ -411,6 +479,10 @@ export class BaseTmuxLauncher {
|
|
|
411
479
|
// isAutoRespawnEnabled() at their own call sites.
|
|
412
480
|
if (!this.enabled)
|
|
413
481
|
return;
|
|
482
|
+
// Idempotent: a second call must not overwrite idleCheckTimer and orphan
|
|
483
|
+
// the first interval (the Codex launcher used to be started twice).
|
|
484
|
+
if (this.idleCheckTimer)
|
|
485
|
+
return;
|
|
414
486
|
// Reap tmux sessions left over from a prior daemon run before the
|
|
415
487
|
// monitor starts. The new daemon's tmuxSessions map is empty by
|
|
416
488
|
// definition, and the spawned CCs in those panes were bound to the
|
|
@@ -426,6 +498,28 @@ export class BaseTmuxLauncher {
|
|
|
426
498
|
const respawnState = this.isAutoRespawnConfigured() ? 'auto-respawn on' : 'auto-respawn off';
|
|
427
499
|
console.log(` ${this.getLogTag()} idle monitor running (idle timeout: ${this.getIdleTimeoutMs()}ms, ${respawnState})`);
|
|
428
500
|
}
|
|
501
|
+
/** The env name this daemon was started as (\`--env <name>\`). */
|
|
502
|
+
ownerEnv() {
|
|
503
|
+
return this.config.config_env || 'default';
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* SILOLINK_OWNER_ENV stamped on a tmux session at launch, or null when the
|
|
507
|
+
* pane predates the marker (or tmux can't answer).
|
|
508
|
+
*/
|
|
509
|
+
readPaneOwnerEnv(name) {
|
|
510
|
+
return new Promise((resolve) => {
|
|
511
|
+
const show = spawn('tmux', ['show-environment', '-t', name, 'SILOLINK_OWNER_ENV'], {
|
|
512
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
513
|
+
});
|
|
514
|
+
let out = '';
|
|
515
|
+
show.stdout.on('data', (d) => { out += d.toString(); });
|
|
516
|
+
show.on('exit', () => {
|
|
517
|
+
const m = out.trim().match(/^SILOLINK_OWNER_ENV=(.*)$/);
|
|
518
|
+
resolve(m ? m[1] : null);
|
|
519
|
+
});
|
|
520
|
+
show.on('error', () => resolve(null));
|
|
521
|
+
});
|
|
522
|
+
}
|
|
429
523
|
/**
|
|
430
524
|
* Scan tmux for sessions matching this launcher's prefix and kill any
|
|
431
525
|
* we don't currently track. Intended to run once at daemon startup to
|
|
@@ -451,12 +545,28 @@ export class BaseTmuxLauncher {
|
|
|
451
545
|
if (names.length === 0)
|
|
452
546
|
return;
|
|
453
547
|
const reaped = [];
|
|
548
|
+
const skipped = [];
|
|
454
549
|
for (const name of names) {
|
|
455
550
|
if (this.tmuxSessions.has(name))
|
|
456
551
|
continue;
|
|
552
|
+
// Only reap panes THIS env owns. On a multi-env host the tmux namespace
|
|
553
|
+
// is shared, so an untracked pane is usually another daemon's LIVE
|
|
554
|
+
// session, not our leftover (2026-08-23: rolling fleet auto-update —
|
|
555
|
+
// every env boot reaped the video env's in-flight recording). A pane
|
|
556
|
+
// with no marker (pre-1.19.13 launch) is also skipped: a leaked pane is
|
|
557
|
+
// reconciled server-side within minutes, a killed live session is not
|
|
558
|
+
// recoverable at all.
|
|
559
|
+
const owner = await this.readPaneOwnerEnv(name);
|
|
560
|
+
if (!shouldReapOrphanPane(owner, this.ownerEnv())) {
|
|
561
|
+
skipped.push(`${name}(owner=${owner ?? 'unmarked'})`);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
457
564
|
reaped.push(name);
|
|
458
565
|
spawn('tmux', ['kill-session', '-t', name], { stdio: 'ignore' });
|
|
459
566
|
}
|
|
567
|
+
if (skipped.length > 0) {
|
|
568
|
+
console.log(`${this.getLogTag()} Startup reap: left ${skipped.length} pane(s) owned elsewhere/unmarked: ${skipped.join(', ')}`);
|
|
569
|
+
}
|
|
460
570
|
if (reaped.length > 0) {
|
|
461
571
|
console.log(`${this.getLogTag()} Reaped ${reaped.length} orphan tmux session(s) from prior daemon run: ${reaped.join(', ')}`);
|
|
462
572
|
}
|
|
@@ -601,10 +711,23 @@ export class BaseTmuxLauncher {
|
|
|
601
711
|
return prompt;
|
|
602
712
|
}
|
|
603
713
|
setLastResumeId(resumeId, conversationId) {
|
|
604
|
-
this.
|
|
605
|
-
|
|
714
|
+
this.restoredResumeIds.set(conversationId ?? 0, resumeId);
|
|
715
|
+
if (conversationId)
|
|
716
|
+
this.resumeIdOwner.set(resumeId, conversationId);
|
|
606
717
|
console.log(`${this.getLogTag()} Restored resume ID: ${resumeId} (conv=${conversationId ?? 'unknown'})`);
|
|
607
718
|
}
|
|
719
|
+
/** The resume id minted at launch for a conversation (Claude), if any. */
|
|
720
|
+
getMintedResumeId(conversationId) {
|
|
721
|
+
return this.mintedResumeIds.get(conversationId);
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Public entry to the capture path — the Bridge calls it right after it
|
|
725
|
+
* pre-registers a launched session so a minted id lands on the Session (and
|
|
726
|
+
* is persisted once) immediately rather than on the next liveness tick.
|
|
727
|
+
*/
|
|
728
|
+
syncResumeId(conversationId) {
|
|
729
|
+
this.captureResumeId(conversationId);
|
|
730
|
+
}
|
|
608
731
|
/**
|
|
609
732
|
* The resume id to use when launching for `conversationId`, or null to launch
|
|
610
733
|
* fresh. Only a pin captured for the SAME conversation may be resumed — a pin
|
|
@@ -615,32 +738,35 @@ export class BaseTmuxLauncher {
|
|
|
615
738
|
resumeTargetFor(conversationId) {
|
|
616
739
|
// Per-conversation pin is authoritative: each Session stores the resume id
|
|
617
740
|
// captured for ITS OWN transcript, so two conversations sharing a cwd never
|
|
618
|
-
// resume each other's CLI session. The
|
|
619
|
-
// fallback
|
|
741
|
+
// resume each other's CLI session. The per-conversation restored id is the
|
|
742
|
+
// fallback (dying session / Rails metadata at boot). Nothing else — a pin
|
|
743
|
+
// from another conversation or of unknown provenance launches fresh.
|
|
620
744
|
if (conversationId) {
|
|
621
745
|
const pinned = this.sessionManager.getByConversationId(conversationId)?.resumeId;
|
|
622
746
|
if (pinned)
|
|
623
747
|
return pinned;
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
748
|
+
const restored = this.restoredResumeIds.get(conversationId);
|
|
749
|
+
if (restored)
|
|
750
|
+
return restored;
|
|
751
|
+
const orphanPin = this.restoredResumeIds.get(0);
|
|
752
|
+
if (orphanPin) {
|
|
753
|
+
console.warn(`${this.getLogTag()} Resume id ${orphanPin} has no recorded conversation — launching conv=${conversationId} fresh instead of resuming it.`);
|
|
629
754
|
}
|
|
755
|
+
return null;
|
|
630
756
|
}
|
|
631
|
-
return this.
|
|
757
|
+
return this.restoredResumeIds.get(0) ?? null;
|
|
632
758
|
}
|
|
633
759
|
/**
|
|
634
760
|
* Forget a resume target that turned out to be unresumable so the next
|
|
635
761
|
* relaunch starts FRESH instead of re-crashing on the same `--resume <id>`.
|
|
636
|
-
* Clears
|
|
637
|
-
* the in-memory SessionInfo (which finishPaneTeardown would otherwise restore),
|
|
762
|
+
* Clears every store a resume target lives in: the per-conversation restored
|
|
763
|
+
* and minted ids, the in-memory SessionInfo (which finishPaneTeardown would otherwise restore),
|
|
638
764
|
* and the persisted rails AgentSession metadata (so a daemon restart can't
|
|
639
765
|
* resurrect it). Best-effort; never throws into the teardown path.
|
|
640
766
|
*/
|
|
641
767
|
clearResumeId(conversationId) {
|
|
642
|
-
this.
|
|
643
|
-
this.
|
|
768
|
+
this.restoredResumeIds.delete(conversationId || 0);
|
|
769
|
+
this.mintedResumeIds.delete(conversationId);
|
|
644
770
|
const session = conversationId
|
|
645
771
|
? this.sessionManager.getByConversationId(conversationId)
|
|
646
772
|
: this.sessionManager.getAll()[0];
|
|
@@ -952,6 +1078,16 @@ export class BaseTmuxLauncher {
|
|
|
952
1078
|
return { command, resolvedPath: this.resolveCommandPath(command) };
|
|
953
1079
|
}
|
|
954
1080
|
async launchInternal(reason = 'manual', conversationId) {
|
|
1081
|
+
// Record the launch origin for the resurrect idle wind-down. Respawns
|
|
1082
|
+
// ('process-exit') keep the conversation's existing origin — a respawned
|
|
1083
|
+
// deliberate session stays deliberate, a respawned resurrect stays a
|
|
1084
|
+
// resurrect.
|
|
1085
|
+
if (conversationId) {
|
|
1086
|
+
if (reason === 'inbound-message')
|
|
1087
|
+
this.autoLaunchedConversations.add(conversationId);
|
|
1088
|
+
else if (reason !== 'process-exit')
|
|
1089
|
+
this.autoLaunchedConversations.delete(conversationId);
|
|
1090
|
+
}
|
|
955
1091
|
if (!this.enabled) {
|
|
956
1092
|
console.log(`${this.getLogTag()} Not configured`);
|
|
957
1093
|
return false;
|
|
@@ -1137,6 +1273,12 @@ export class BaseTmuxLauncher {
|
|
|
1137
1273
|
});
|
|
1138
1274
|
tmuxCreate.unref();
|
|
1139
1275
|
spawn('tmux', ['set-option', '-t', tmuxSession, 'remain-on-exit', 'on'], { stdio: 'ignore' });
|
|
1276
|
+
// Ownership marker for the startup orphan reaper: on a multi-env host
|
|
1277
|
+
// every daemon shares the same tmux namespace, and an unmarked pane is
|
|
1278
|
+
// indistinguishable from a leftover of THIS env's prior run. 2026-08-23:
|
|
1279
|
+
// a fleet-wide auto-update rolled every env daemon in sequence and each
|
|
1280
|
+
// boot's reaper killed the video env's LIVE recording session — twice.
|
|
1281
|
+
spawn('tmux', ['set-environment', '-t', tmuxSession, 'SILOLINK_OWNER_ENV', this.ownerEnv()], { stdio: 'ignore' });
|
|
1140
1282
|
// Wait for tmux session to start
|
|
1141
1283
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
1142
1284
|
const sessionExists = await new Promise((resolve) => {
|
|
@@ -1151,10 +1293,16 @@ export class BaseTmuxLauncher {
|
|
|
1151
1293
|
return false;
|
|
1152
1294
|
}
|
|
1153
1295
|
console.log(`${this.getLogTag()} tmux session "${tmuxSession}" created`);
|
|
1154
|
-
|
|
1296
|
+
const mintedSessionId = conversationId ? this.mintedResumeIds.get(conversationId) : undefined;
|
|
1297
|
+
this.tmuxSessions.set(tmuxSession, { conversationId: conversationId || 0, startedAt: new Date(), cwd, mintedSessionId });
|
|
1155
1298
|
if (workspaceSessionId) {
|
|
1156
1299
|
this.tmuxSessionWorkspaces.set(tmuxSession, workspaceSessionId);
|
|
1157
1300
|
}
|
|
1301
|
+
// A minted id needs no discovery — pin it on an already-registered session
|
|
1302
|
+
// (relaunch) right away. A fresh launch's session is pre-registered by the
|
|
1303
|
+
// Bridge afterwards, which calls syncResumeId.
|
|
1304
|
+
if (mintedSessionId && conversationId)
|
|
1305
|
+
this.captureResumeId(conversationId);
|
|
1158
1306
|
// Wait for agent to be ready, then send the prompt
|
|
1159
1307
|
const sessionToPrompt = tmuxSession;
|
|
1160
1308
|
const promptToSend = this.formatPrompt(prompt);
|
|
@@ -1331,7 +1479,7 @@ export class BaseTmuxLauncher {
|
|
|
1331
1479
|
// forget that id so finishPaneTeardown relaunches FRESH instead of
|
|
1332
1480
|
// re-issuing the same doomed `--resume`/`resume` and looping forever.
|
|
1333
1481
|
// Applies to every CLI via the per-launcher isResumeFailure() override.
|
|
1334
|
-
const hadResumeTarget = this.
|
|
1482
|
+
const hadResumeTarget = this.restoredResumeIds.has(info.conversationId)
|
|
1335
1483
|
|| this.sessionManager.getByConversationId(info.conversationId)?.resumeId != null;
|
|
1336
1484
|
if (hadResumeTarget && this.isResumeFailure(paneContent)) {
|
|
1337
1485
|
console.warn(`${this.getLogTag()} Pane died on an unresumable session for conv=${info.conversationId} — clearing resume id and relaunching fresh.`);
|
|
@@ -1361,12 +1509,12 @@ export class BaseTmuxLauncher {
|
|
|
1361
1509
|
const stillRegistered = this.sessionManager.getByConversationId(conversationId);
|
|
1362
1510
|
if (!stillRegistered)
|
|
1363
1511
|
return;
|
|
1364
|
-
// RESTORE resume id from the dying SESSION's stored value
|
|
1365
|
-
//
|
|
1366
|
-
//
|
|
1367
|
-
//
|
|
1368
|
-
//
|
|
1369
|
-
//
|
|
1512
|
+
// RESTORE resume id from the dying SESSION's stored value BEFORE we
|
|
1513
|
+
// unregister it — this is what lets the auto-respawn relaunch the SAME CLI
|
|
1514
|
+
// session (`--resume <id>`) and continue an in-progress task instead of
|
|
1515
|
+
// starting fresh. Best-effort: if the worker never captured an id (capture
|
|
1516
|
+
// failed / CLI has no resume), nothing is restored and the relaunch falls
|
|
1517
|
+
// back to a fresh session — never blocked.
|
|
1370
1518
|
const resumeId = stillRegistered.resumeId || stillRegistered.claudeResumeId;
|
|
1371
1519
|
if (resumeId) {
|
|
1372
1520
|
this.setLastResumeId(resumeId, conversationId);
|
|
@@ -1447,23 +1595,27 @@ export class BaseTmuxLauncher {
|
|
|
1447
1595
|
*
|
|
1448
1596
|
* Best-effort: returns null on any failure. NEVER throws into the launch path.
|
|
1449
1597
|
*/
|
|
1450
|
-
detectResumeId(_cwd) {
|
|
1598
|
+
detectResumeId(_cwd, _notBefore) {
|
|
1451
1599
|
return null;
|
|
1452
1600
|
}
|
|
1453
1601
|
/**
|
|
1454
|
-
*
|
|
1455
|
-
*
|
|
1456
|
-
* 1.
|
|
1457
|
-
*
|
|
1458
|
-
*
|
|
1459
|
-
*
|
|
1460
|
-
*
|
|
1461
|
-
*
|
|
1462
|
-
*
|
|
1602
|
+
* Pin the running worker's CLI session id on its Session so the next respawn
|
|
1603
|
+
* relaunches the SAME session (`--resume`). Two sources, in order:
|
|
1604
|
+
* 1. the id MINTED at launch (Claude `--session-id`) — authoritative, no
|
|
1605
|
+
* discovery, never overwritten;
|
|
1606
|
+
* 2. discovery from the CLI's on-disk transcript (Gemini/Codex, and Claude
|
|
1607
|
+
* launches that resumed) — accepted only if the transcript was touched
|
|
1608
|
+
* at/after the pane's startedAt.
|
|
1609
|
+
* Either way the id is refused when another conversation owns it — a LIVE
|
|
1610
|
+
* session or any conversation seen earlier this process lifetime
|
|
1611
|
+
* (`resumeIdOwner`, which survives unregister). Stores on the Session
|
|
1612
|
+
* (`resumeId`) and persists `cli_resume_id` + legacy `claude_resume_id` to the
|
|
1613
|
+
* rails AgentSession metadata ONCE per (agent session, id) pair — that
|
|
1614
|
+
* survives a daemon restart so bridge startup / remote_load_context can
|
|
1615
|
+
* restore it.
|
|
1463
1616
|
*
|
|
1464
|
-
* Idempotent
|
|
1465
|
-
*
|
|
1466
|
-
* intact and never blocks anything.
|
|
1617
|
+
* Idempotent; runs on the liveness tick and once right after launch.
|
|
1618
|
+
* Best-effort — a capture failure leaves prior state intact and never blocks.
|
|
1467
1619
|
*/
|
|
1468
1620
|
captureResumeId(conversationId) {
|
|
1469
1621
|
const session = conversationId
|
|
@@ -1472,44 +1624,63 @@ export class BaseTmuxLauncher {
|
|
|
1472
1624
|
if (!session)
|
|
1473
1625
|
return;
|
|
1474
1626
|
// Locate by the ACTUAL launch cwd (same resolution extractTranscriptUsage uses).
|
|
1475
|
-
let
|
|
1476
|
-
for (const
|
|
1477
|
-
if (
|
|
1478
|
-
|
|
1627
|
+
let info;
|
|
1628
|
+
for (const candidate of this.tmuxSessions.values()) {
|
|
1629
|
+
if (candidate.conversationId === conversationId) {
|
|
1630
|
+
info = candidate;
|
|
1479
1631
|
break;
|
|
1480
1632
|
}
|
|
1481
1633
|
}
|
|
1482
|
-
cwd
|
|
1634
|
+
const cwd = info?.cwd || this.getWorkingDirectory();
|
|
1635
|
+
const minted = info?.mintedSessionId ?? this.mintedResumeIds.get(conversationId);
|
|
1483
1636
|
let resumeId = null;
|
|
1484
|
-
|
|
1485
|
-
|
|
1637
|
+
let source;
|
|
1638
|
+
if (minted) {
|
|
1639
|
+
resumeId = minted;
|
|
1640
|
+
source = 'minted';
|
|
1486
1641
|
}
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1642
|
+
else {
|
|
1643
|
+
try {
|
|
1644
|
+
resumeId = this.detectResumeId(cwd, info?.startedAt);
|
|
1645
|
+
}
|
|
1646
|
+
catch (err) {
|
|
1647
|
+
console.warn(`${this.getLogTag()} detectResumeId failed for conv=${conversationId}: ${err}`);
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
source = 'discovered';
|
|
1490
1651
|
}
|
|
1491
1652
|
if (!resumeId)
|
|
1492
1653
|
return;
|
|
1493
|
-
//
|
|
1494
|
-
//
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
//
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
console.warn(`${this.getLogTag()} Resume id ${resumeId} already owned by conv=${otherOwner.conversationId} — NOT capturing it for conv=${conversationId} (shared cwd). Leaving fresh.`);
|
|
1654
|
+
// Ownership guard. detectResumeId is cwd-scoped ("most recent transcript in
|
|
1655
|
+
// the launch cwd"), so when two conversations share a cwd it can return the
|
|
1656
|
+
// OTHER conversation's transcript. Never pin an id that a different
|
|
1657
|
+
// conversation owns — live (2026-07-18: conv 2170 stole conv 2158's id and
|
|
1658
|
+
// the 2170 pane ended up polling 2158) or previously seen (2026-08-24: once
|
|
1659
|
+
// 3331 stopped, its transcript was captured for 3337, then 3337's for 3369).
|
|
1660
|
+
const priorOwner = this.resumeIdOwner.get(resumeId);
|
|
1661
|
+
if (priorOwner !== undefined && priorOwner !== conversationId) {
|
|
1662
|
+
console.warn(`${this.getLogTag()} Resume id ${resumeId} is owned by conv=${priorOwner} — NOT capturing it for conv=${conversationId} (${source}). Leaving fresh.`);
|
|
1503
1663
|
return;
|
|
1504
1664
|
}
|
|
1505
|
-
|
|
1506
|
-
if (
|
|
1665
|
+
const otherLive = this.sessionManager.getAll().find((s) => s.resumeId === resumeId && s.conversationId !== conversationId);
|
|
1666
|
+
if (otherLive) {
|
|
1667
|
+
console.warn(`${this.getLogTag()} Resume id ${resumeId} already owned by live conv=${otherLive.conversationId} — NOT capturing it for conv=${conversationId} (shared cwd). Leaving fresh.`);
|
|
1507
1668
|
return;
|
|
1508
|
-
|
|
1509
|
-
this.
|
|
1510
|
-
session.resumeId
|
|
1511
|
-
|
|
1669
|
+
}
|
|
1670
|
+
this.resumeIdOwner.set(resumeId, conversationId);
|
|
1671
|
+
const changed = session.resumeId !== resumeId;
|
|
1672
|
+
if (changed) {
|
|
1673
|
+
if (session.resumeId) {
|
|
1674
|
+
console.log(`${this.getLogTag()} Resume id for conv=${conversationId}: ${session.resumeId} → ${resumeId} (${source})`);
|
|
1675
|
+
}
|
|
1676
|
+
session.resumeId = resumeId;
|
|
1677
|
+
session.claudeResumeId = resumeId; // keep legacy alias in sync
|
|
1678
|
+
}
|
|
1512
1679
|
if (this.dsiloedClient && session.agentSessionId) {
|
|
1680
|
+
const key = `${session.agentSessionId}:${resumeId}`;
|
|
1681
|
+
if (this.persistedResumeIds.has(key))
|
|
1682
|
+
return;
|
|
1683
|
+
this.persistedResumeIds.add(key);
|
|
1513
1684
|
// MERGE — only the resume keys; a full replace would wipe workspace_* and
|
|
1514
1685
|
// server-set keys. Persist BOTH a generic key and the legacy claude key the
|
|
1515
1686
|
// bridge startup / remote_load_context already read.
|
|
@@ -1517,11 +1688,14 @@ export class BaseTmuxLauncher {
|
|
|
1517
1688
|
cli_resume_id: resumeId,
|
|
1518
1689
|
claude_resume_id: resumeId,
|
|
1519
1690
|
}).then(() => {
|
|
1520
|
-
console.log(`${this.getLogTag()} Captured resume id ${resumeId} for conv=${conversationId} (agent session ${session.agentSessionId})`);
|
|
1521
|
-
}).catch(err =>
|
|
1691
|
+
console.log(`${this.getLogTag()} Captured resume id ${resumeId} for conv=${conversationId} (agent session ${session.agentSessionId}, ${source})`);
|
|
1692
|
+
}).catch(err => {
|
|
1693
|
+
this.persistedResumeIds.delete(key); // retry on the next tick
|
|
1694
|
+
console.error(`${this.getLogTag()} Failed to persist resume id:`, err);
|
|
1695
|
+
});
|
|
1522
1696
|
}
|
|
1523
|
-
else {
|
|
1524
|
-
console.log(`${this.getLogTag()} Captured resume id ${resumeId} for conv=${conversationId} (in-memory only)`);
|
|
1697
|
+
else if (changed) {
|
|
1698
|
+
console.log(`${this.getLogTag()} Captured resume id ${resumeId} for conv=${conversationId} (in-memory only, ${source})`);
|
|
1525
1699
|
}
|
|
1526
1700
|
}
|
|
1527
1701
|
// Returns true if usage was found and a report was initiated (used by the
|
|
@@ -1555,7 +1729,7 @@ export class BaseTmuxLauncher {
|
|
|
1555
1729
|
}
|
|
1556
1730
|
}
|
|
1557
1731
|
cwd ||= ledgerEntry?.cwd || this.getWorkingDirectory();
|
|
1558
|
-
const cliSessionId =
|
|
1732
|
+
const cliSessionId = session?.resumeId ?? ledgerEntry?.cliSessionId ?? this.restoredResumeIds.get(conversationId) ?? null;
|
|
1559
1733
|
let extracted;
|
|
1560
1734
|
try {
|
|
1561
1735
|
extracted = this.extractTranscriptUsage(cwd, cliSessionId);
|
|
@@ -1677,7 +1851,7 @@ export class BaseTmuxLauncher {
|
|
|
1677
1851
|
agentSessionId: session.agentSessionId,
|
|
1678
1852
|
cliType: this.getCliType(),
|
|
1679
1853
|
cwd: cwd || this.getWorkingDirectory(),
|
|
1680
|
-
cliSessionId: session.resumeId ||
|
|
1854
|
+
cliSessionId: session.resumeId || undefined,
|
|
1681
1855
|
tmuxSession,
|
|
1682
1856
|
});
|
|
1683
1857
|
}
|
|
@@ -1758,9 +1932,19 @@ export class BaseTmuxLauncher {
|
|
|
1758
1932
|
// inbound message. Skips sessions with undelivered messages so we
|
|
1759
1933
|
// never kill mid-delivery.
|
|
1760
1934
|
const killAfter = this.getIdleKillAfterMs();
|
|
1761
|
-
|
|
1935
|
+
const resurrectDefault = this.getResurrectIdleKillMs();
|
|
1936
|
+
if (killAfter > 0 || resurrectDefault > 0) {
|
|
1762
1937
|
for (const s of sessions) {
|
|
1763
|
-
|
|
1938
|
+
// A resurrect (auto-launched) session gets its own, always-on
|
|
1939
|
+
// threshold: the tightest of the configured idle-kill and the
|
|
1940
|
+
// resurrect wind-down. Deliberate sessions keep the configured
|
|
1941
|
+
// idle-kill semantics exactly (0 = never).
|
|
1942
|
+
const resurrectAfter = this.resurrectIdleKillThresholdMs(s.conversationId);
|
|
1943
|
+
const thresholds = [killAfter, resurrectAfter].filter(t => t > 0);
|
|
1944
|
+
if (thresholds.length === 0)
|
|
1945
|
+
continue;
|
|
1946
|
+
const effectiveKillAfter = Math.min(...thresholds);
|
|
1947
|
+
if (now - s.lastActivity.getTime() <= effectiveKillAfter)
|
|
1764
1948
|
continue;
|
|
1765
1949
|
if (this.messageQueue?.hasUndeliveredMessages(s.sessionId))
|
|
1766
1950
|
continue;
|
|
@@ -1775,6 +1959,18 @@ export class BaseTmuxLauncher {
|
|
|
1775
1959
|
continue;
|
|
1776
1960
|
}
|
|
1777
1961
|
const idleMin = Math.round((now - s.lastActivity.getTime()) / 60000);
|
|
1962
|
+
if (resurrectAfter > 0 && now - s.lastActivity.getTime() > resurrectAfter) {
|
|
1963
|
+
// Wind-down, not a plain kill: close the rails-side row FIRST with a
|
|
1964
|
+
// reason the backend's completion relay recognizes as
|
|
1965
|
+
// nothing-to-announce (model_api skips the dispatcher re-wake for
|
|
1966
|
+
// 'resurrect-idle:*'), THEN kill the pane. The later process-dead
|
|
1967
|
+
// completion is an idempotent no-op server-side.
|
|
1968
|
+
console.log(`${this.getLogTag()} Resurrect wind-down: conv ${s.conversationId} auto-launched session idle ${idleMin}min (threshold ${Math.round(resurrectAfter / 60000)}min) — closing session + pane`);
|
|
1969
|
+
this.completeAgentSessionRow(s, `resurrect-idle:${idleMin}min`);
|
|
1970
|
+
this.killByConversation(s.conversationId, `resurrect-idle:${idleMin}min`);
|
|
1971
|
+
this.autoLaunchedConversations.delete(s.conversationId);
|
|
1972
|
+
continue;
|
|
1973
|
+
}
|
|
1778
1974
|
console.log(`${this.getLogTag()} Idle-kill: conv ${s.conversationId} idle ${idleMin}min (threshold ${Math.round(killAfter / 60000)}min) — killing CC; auto-respawn will pick up next inbound`);
|
|
1779
1975
|
this.killByConversation(s.conversationId, `idle-kill:${idleMin}min`);
|
|
1780
1976
|
}
|
|
@@ -1985,6 +2181,30 @@ export class BaseTmuxLauncher {
|
|
|
1985
2181
|
this.killByConversation(s.conversationId, `wedged-session:${idleMin}min`);
|
|
1986
2182
|
}
|
|
1987
2183
|
}
|
|
2184
|
+
/**
|
|
2185
|
+
* Wedge verdict for one conversation, from the SAME primitives
|
|
2186
|
+
* reapWedgedSessions uses: pane present + capturable, not actively thinking,
|
|
2187
|
+
* and substantive content frozen for at least FROZEN_THRESHOLD_MS. Used by the
|
|
2188
|
+
* stale-on-inbound recycler (2026-08-23: it recycled on MCP silence alone and
|
|
2189
|
+
* killed live panes mid-work — conv 3337 six times in one evening).
|
|
2190
|
+
*/
|
|
2191
|
+
isSessionWedged(conversationId) {
|
|
2192
|
+
const paneName = this.tmuxNameForConversation(conversationId);
|
|
2193
|
+
if (!paneName)
|
|
2194
|
+
return { wedged: false, reason: 'no-pane', paneName: null };
|
|
2195
|
+
if (this.capturePaneTailForProgress(paneName) === null) {
|
|
2196
|
+
return { wedged: false, reason: 'pane-gone', paneName };
|
|
2197
|
+
}
|
|
2198
|
+
if (this.isActivelyThinking(paneName))
|
|
2199
|
+
return { wedged: false, reason: 'pane-active', paneName };
|
|
2200
|
+
const prog = this.paneProgress.get(paneName);
|
|
2201
|
+
const frozenMs = prog ? Date.now() - prog.lastChangeAt : 0;
|
|
2202
|
+
const frozenMin = Math.round(frozenMs / 60000);
|
|
2203
|
+
if (frozenMs < BaseTmuxLauncher.FROZEN_THRESHOLD_MS) {
|
|
2204
|
+
return { wedged: false, reason: `frozen-${frozenMin}min<threshold`, paneName };
|
|
2205
|
+
}
|
|
2206
|
+
return { wedged: true, reason: `frozen-${frozenMin}min`, paneName };
|
|
2207
|
+
}
|
|
1988
2208
|
isActivelyThinking(tmuxSession) {
|
|
1989
2209
|
const now = Date.now();
|
|
1990
2210
|
const prev = this.paneProgress.get(tmuxSession);
|
|
@@ -2074,4 +2294,12 @@ export class BaseTmuxLauncher {
|
|
|
2074
2294
|
}
|
|
2075
2295
|
}
|
|
2076
2296
|
}
|
|
2297
|
+
/**
|
|
2298
|
+
* Startup-reap decision: reap ONLY a pane this env provably owns. Unmarked
|
|
2299
|
+
* (legacy) and other-env panes are left alone — cross-env kills are strictly
|
|
2300
|
+
* worse than a leaked pane, which the backend reconciles anyway.
|
|
2301
|
+
*/
|
|
2302
|
+
export function shouldReapOrphanPane(ownerEnv, myEnv) {
|
|
2303
|
+
return ownerEnv !== null && ownerEnv === myEnv;
|
|
2304
|
+
}
|
|
2077
2305
|
//# sourceMappingURL=base-tmux-launcher.js.map
|