@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.
- package/README.md +64 -3
- 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 +95 -21
- package/dist/core/base-tmux-launcher.d.ts.map +1 -1
- package/dist/core/base-tmux-launcher.js +330 -85
- 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 +219 -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 +64 -17
- 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 +57 -0
- package/dist/core/idle-completion.d.ts.map +1 -0
- package/dist/core/idle-completion.js +71 -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/mcp-config-dir.d.ts +16 -0
- package/dist/core/mcp-config-dir.d.ts.map +1 -0
- package/dist/core/mcp-config-dir.js +37 -0
- package/dist/core/mcp-config-dir.js.map +1 -0
- package/dist/core/stray-processes.d.ts +47 -0
- package/dist/core/stray-processes.d.ts.map +1 -0
- package/dist/core/stray-processes.js +141 -0
- package/dist/core/stray-processes.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/core/workspace-manager.d.ts.map +1 -1
- package/dist/core/workspace-manager.js +6 -2
- package/dist/core/workspace-manager.js.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,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
|
-
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
451
|
-
for (const [name] of this.tmuxSessions) {
|
|
452
|
-
this.
|
|
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.
|
|
697
|
-
|
|
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
|
|
711
|
-
// fallback
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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.
|
|
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
|
|
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.
|
|
735
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
1473
|
-
//
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
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
|
-
*
|
|
1563
|
-
*
|
|
1564
|
-
* 1.
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1567
|
-
*
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
1570
|
-
*
|
|
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
|
|
1573
|
-
*
|
|
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
|
|
1584
|
-
for (const
|
|
1585
|
-
if (
|
|
1586
|
-
|
|
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
|
|
1774
|
+
const cwd = info?.cwd || this.getWorkingDirectory();
|
|
1775
|
+
const minted = info?.mintedSessionId ?? this.mintedResumeIds.get(conversationId);
|
|
1591
1776
|
let resumeId = null;
|
|
1592
|
-
|
|
1593
|
-
|
|
1777
|
+
let source;
|
|
1778
|
+
if (minted) {
|
|
1779
|
+
resumeId = minted;
|
|
1780
|
+
source = 'minted';
|
|
1594
1781
|
}
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
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
|
-
//
|
|
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.`);
|
|
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
|
-
|
|
1614
|
-
if (
|
|
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
|
-
|
|
1617
|
-
this.
|
|
1618
|
-
session.resumeId
|
|
1619
|
-
|
|
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 =>
|
|
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 =
|
|
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
|
-
|
|
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 ||
|
|
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);
|