@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
package/dist/core/bridge.js
CHANGED
|
@@ -12,6 +12,9 @@ import { SubscriptionManager } from '../cable/subscription-manager.js';
|
|
|
12
12
|
import { SessionManager } from '../core/session-manager.js';
|
|
13
13
|
import { MessageQueue } from '../core/message-queue.js';
|
|
14
14
|
import { SiloLinkMcpServer } from '../mcp/server.js';
|
|
15
|
+
import { LaunchBindingRegistry } from '../core/launch-binding.js';
|
|
16
|
+
import { healthMonitor } from '../core/health-monitor.js';
|
|
17
|
+
import { idleCompletionConfig, idleCompletionVerdict } from '../core/idle-completion.js';
|
|
15
18
|
import { WorkspaceManager } from '../core/workspace-manager.js';
|
|
16
19
|
import { UsageLedger } from '../core/usage-ledger.js';
|
|
17
20
|
import { createLauncher } from '../core/launcher-factory.js';
|
|
@@ -42,6 +45,9 @@ export class Bridge {
|
|
|
42
45
|
sessionManager;
|
|
43
46
|
messageQueue;
|
|
44
47
|
launchers = new Map();
|
|
48
|
+
// Launch → MCP transport bindings shared by the launchers (writers) and the
|
|
49
|
+
// MCP server / remote_register (readers). See launch-binding.ts.
|
|
50
|
+
launchBindings = new LaunchBindingRegistry();
|
|
45
51
|
defaultProvider;
|
|
46
52
|
workspaceManager;
|
|
47
53
|
usageLedger;
|
|
@@ -129,14 +135,25 @@ export class Bridge {
|
|
|
129
135
|
const geminiLauncher = createLauncher('gemini', this.config, this.sessionManager, this.dsiloedClient, this.messageQueue, this.workspaceManager, this.usageLedger);
|
|
130
136
|
this.launchers.set('gemini', geminiLauncher);
|
|
131
137
|
const codexLauncher = createLauncher('codex', this.config, this.sessionManager, this.dsiloedClient, this.messageQueue, this.workspaceManager, this.usageLedger);
|
|
138
|
+
// Stored under 'codex' ONLY. The legacy 'openai' alias is resolved in
|
|
139
|
+
// getLauncher(); a second Map key made launchers.values() yield the Codex
|
|
140
|
+
// launcher twice (double startIdleMonitor/destroy/kill/nudge — 2026-08-23).
|
|
132
141
|
this.launchers.set('codex', codexLauncher);
|
|
133
|
-
this.launchers.
|
|
142
|
+
for (const l of this.launchers.values())
|
|
143
|
+
l.setLaunchBindings?.(this.launchBindings);
|
|
134
144
|
// Default launcher is used for auto-launch on inbound messages
|
|
135
145
|
const defaultLauncher = this.getLauncher();
|
|
136
146
|
this.subscriptionManager = new SubscriptionManager(this.cableClient, this.messageQueue, this.sessionManager, defaultLauncher, this.dsiloedClient,
|
|
137
147
|
// Lets auto-launch relaunch a conversation with its ORIGINAL coding agent
|
|
138
148
|
// (e.g. Codex stays Codex) instead of always the default launcher.
|
|
139
|
-
(provider) => this.getLauncher(provider));
|
|
149
|
+
(provider) => this.getLauncher(provider), { staleInboundGraceMs: this.config.stale_inbound_grace_ms });
|
|
150
|
+
// Health block feeds (see health-monitor.ts): live readers, no copies.
|
|
151
|
+
healthMonitor.attach({
|
|
152
|
+
backend: () => this.dsiloedClient.backendHealth.snapshot(),
|
|
153
|
+
usageLedger: () => this.usageLedger.getAll(),
|
|
154
|
+
recycles24h: () => this.subscriptionManager.recyclesWithin(24 * 60 * 60 * 1000),
|
|
155
|
+
jwt: () => this.config.jwt,
|
|
156
|
+
});
|
|
140
157
|
this.mcpServer = new SiloLinkMcpServer({
|
|
141
158
|
config: this.config,
|
|
142
159
|
sessionManager: this.sessionManager,
|
|
@@ -146,6 +163,7 @@ export class Bridge {
|
|
|
146
163
|
cableClient: this.cableClient,
|
|
147
164
|
workspaceManager: this.workspaceManager,
|
|
148
165
|
launcher: defaultLauncher,
|
|
166
|
+
launchBindings: this.launchBindings,
|
|
149
167
|
startedAt: () => this.startedAt,
|
|
150
168
|
onModuleLoadFailure: (err) => this.selfHealBrokenInstall(err),
|
|
151
169
|
probeStatus: () => this.probeStatus,
|
|
@@ -220,6 +238,11 @@ export class Bridge {
|
|
|
220
238
|
startupLaunchSanityCheck() {
|
|
221
239
|
const launcher = this.getLauncher();
|
|
222
240
|
const preflight = launcher.preflightLaunchCheck?.();
|
|
241
|
+
// Boolean-or-null for the backend claude_unresolved detector (fires on
|
|
242
|
+
// == false): null before preflight runs, false ONLY when a preflight ran
|
|
243
|
+
// and could not resolve the CLI, true when it did. The resolved path stays
|
|
244
|
+
// in the log lines below.
|
|
245
|
+
healthMonitor.setClaudeResolved(preflight ? (preflight.resolvedPath != null) : null);
|
|
223
246
|
if (preflight && !preflight.resolvedPath) {
|
|
224
247
|
console.error(` ⚠️ Agent CLI "${preflight.command}" was NOT found on this daemon's PATH or in known install locations.`);
|
|
225
248
|
console.error(' Session launches WILL fail. Start the daemon from a shell where the CLI resolves,');
|
|
@@ -234,6 +257,7 @@ export class Bridge {
|
|
|
234
257
|
catch {
|
|
235
258
|
return false;
|
|
236
259
|
} })();
|
|
260
|
+
healthMonitor.setCredentialsPresent(hasEnvAuth || hasCredFile);
|
|
237
261
|
if (!hasEnvAuth && !hasCredFile) {
|
|
238
262
|
console.error(' ⚠️ No Claude Code credentials visible to this daemon (no CLAUDE_CODE_OAUTH_TOKEN /');
|
|
239
263
|
console.error(' ANTHROPIC_API_KEY in the environment, no ~/.claude/.credentials.json).');
|
|
@@ -246,8 +270,9 @@ export class Bridge {
|
|
|
246
270
|
* Get a launcher by provider, falling back to the default.
|
|
247
271
|
*/
|
|
248
272
|
getLauncher(provider) {
|
|
249
|
-
|
|
250
|
-
|
|
273
|
+
const key = provider === 'openai' ? 'codex' : provider; // legacy alias
|
|
274
|
+
if (key && this.launchers.has(key)) {
|
|
275
|
+
return this.launchers.get(key);
|
|
251
276
|
}
|
|
252
277
|
return this.launchers.get(this.defaultProvider);
|
|
253
278
|
}
|
|
@@ -350,6 +375,8 @@ export class Bridge {
|
|
|
350
375
|
process.on('SIGINT', shutdown);
|
|
351
376
|
// Start heartbeat broadcast so the frontend knows we're alive
|
|
352
377
|
this.startHeartbeat();
|
|
378
|
+
// One [HEALTH] line per hour in the file log (the heartbeat carries the same block).
|
|
379
|
+
healthMonitor.startHourlyLog();
|
|
353
380
|
// Start the auto-updater. Idle = no live coding-session tmux (so an update
|
|
354
381
|
// never kills work mid-flight) and not already shutting down.
|
|
355
382
|
this.autoUpdater = new AutoUpdater({
|
|
@@ -389,6 +416,7 @@ export class Bridge {
|
|
|
389
416
|
this.heartbeatTimer = setInterval(() => {
|
|
390
417
|
this.broadcastAlive();
|
|
391
418
|
this.reconcileDeadPanes();
|
|
419
|
+
this.sweepIdleCompletions();
|
|
392
420
|
}, 30_000);
|
|
393
421
|
}
|
|
394
422
|
stopHeartbeat() {
|
|
@@ -451,6 +479,10 @@ export class Bridge {
|
|
|
451
479
|
// Whether the MCP POST path (what spawned sessions register through)
|
|
452
480
|
// actually works — 'failing' means daemon-up-but-broken (install swap).
|
|
453
481
|
mcp_post: this.probeStatus,
|
|
482
|
+
// Daemon health block — see health-monitor.ts. The backend stores it on
|
|
483
|
+
// the daemon row and derives coded issues (jwt_expiring_soon,
|
|
484
|
+
// http_403_streak, credentials_missing, updater_unpinned, ...).
|
|
485
|
+
health: healthMonitor.snapshot(),
|
|
454
486
|
timestamp: Date.now(),
|
|
455
487
|
});
|
|
456
488
|
}
|
|
@@ -480,11 +512,57 @@ export class Bridge {
|
|
|
480
512
|
return [...new Set(ids)];
|
|
481
513
|
}
|
|
482
514
|
/** Tracked sessions (with a server AgentSession row) whose coding-session tmux is alive. */
|
|
515
|
+
/**
|
|
516
|
+
* A registered session whose MCP transport is still open has a live CLI
|
|
517
|
+
* behind it even when no pane carries its conversation's name — the CC
|
|
518
|
+
* registered by conversation from another pane (the 3337-in-the-3369-pane
|
|
519
|
+
* case, 2026-08-24). Reporting only pane-named sessions made the backend
|
|
520
|
+
* reconciler complete such rows every ~10 min, the daemon drop the
|
|
521
|
+
* registration, the CC re-register, and a new row appear — forever.
|
|
522
|
+
*/
|
|
523
|
+
sessionHasLiveTransport(s) {
|
|
524
|
+
if (!s.mcpSessionId || s.mcpSessionId.startsWith('launch-pending-'))
|
|
525
|
+
return false;
|
|
526
|
+
return this.mcpServer?.hasTransport?.(s.mcpSessionId) ?? false;
|
|
527
|
+
}
|
|
528
|
+
/** Every registered session that is provably alive: pane present OR MCP transport open. Deduped by agent session id. */
|
|
483
529
|
liveAgentSessions() {
|
|
484
530
|
const liveTmux = this.liveTmuxSessionNames();
|
|
531
|
+
const seen = new Set();
|
|
485
532
|
return this.sessionManager.getAll()
|
|
486
533
|
.filter(s => s.agentSessionId)
|
|
487
|
-
.filter(s => liveTmux.has(this.codingTmuxName(s)))
|
|
534
|
+
.filter(s => liveTmux.has(this.codingTmuxName(s)) || this.sessionHasLiveTransport(s))
|
|
535
|
+
.filter(s => {
|
|
536
|
+
if (seen.has(s.agentSessionId))
|
|
537
|
+
return false;
|
|
538
|
+
seen.add(s.agentSessionId);
|
|
539
|
+
return true;
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Which pane should a control command for `conversationId` land on?
|
|
544
|
+
* 1. the pane named for the conversation itself;
|
|
545
|
+
* 2. the pane this conversation's REGISTERED session was launched in (its
|
|
546
|
+
* MCP transport is bound to a launch for another conversation — the
|
|
547
|
+
* wrong-conversation register case);
|
|
548
|
+
* 3. none.
|
|
549
|
+
*/
|
|
550
|
+
resolvePaneForConversation(conversationId) {
|
|
551
|
+
const direct = this.getLauncherForConversation(conversationId);
|
|
552
|
+
const directProc = direct?.getByConversation(conversationId);
|
|
553
|
+
if (direct && directProc)
|
|
554
|
+
return { launcher: direct, conversationId, paneName: directProc.processId };
|
|
555
|
+
const session = this.sessionManager.getByConversationId(conversationId);
|
|
556
|
+
const binding = session ? this.launchBindings?.forTransport(session.mcpSessionId) : undefined;
|
|
557
|
+
if (binding && binding.conversationId !== conversationId) {
|
|
558
|
+
const bound = this.getLauncherForConversation(binding.conversationId);
|
|
559
|
+
const boundProc = bound?.getByConversation(binding.conversationId);
|
|
560
|
+
if (bound && boundProc) {
|
|
561
|
+
console.log(`[SiloLink] conv ${conversationId} has no pane of its own — its session was launched in ${boundProc.processId} (conv ${binding.conversationId}); routing there`);
|
|
562
|
+
return { launcher: bound, conversationId: binding.conversationId, paneName: boundProc.processId };
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return null;
|
|
488
566
|
}
|
|
489
567
|
codingTmuxName(s) {
|
|
490
568
|
return `silolink-${s.agentProvider ?? this.defaultProvider}-conv${s.conversationId}`;
|
|
@@ -497,6 +575,63 @@ export class Bridge {
|
|
|
497
575
|
// auto-respawn — reconcile must not yank the session out from under it.
|
|
498
576
|
static DEAD_PANE_GRACE_MS = 10 * 60 * 1000;
|
|
499
577
|
deadPaneSince = new Map();
|
|
578
|
+
/**
|
|
579
|
+
* Worker report-back (issue 4): end a session that posted its final report
|
|
580
|
+
* and got no further work, or that has sat in the poll loop with zero work
|
|
581
|
+
* for poll_idle_complete_ms. Backend complete (state idle_timeout) FIRST so
|
|
582
|
+
* the completion relay fires, then the pane is killed and the session
|
|
583
|
+
* untracked. See idle-completion.ts for the verdict rules.
|
|
584
|
+
*/
|
|
585
|
+
sweepIdleCompletions() {
|
|
586
|
+
try {
|
|
587
|
+
const cfg = idleCompletionConfig(this.config);
|
|
588
|
+
if (cfg.reportBackIdleMs <= 0 && cfg.pollIdleCompleteMs <= 0)
|
|
589
|
+
return;
|
|
590
|
+
const now = Date.now();
|
|
591
|
+
for (const s of this.sessionManager.getAll()) {
|
|
592
|
+
const verdict = idleCompletionVerdict(s, cfg, this.messageQueue.hasUndeliveredMessages(s.sessionId), now);
|
|
593
|
+
if (!verdict)
|
|
594
|
+
continue;
|
|
595
|
+
const idleMin = Math.round(verdict.idleMs / 60000);
|
|
596
|
+
const reason = `${verdict.reason}:${idleMin}min`;
|
|
597
|
+
console.log(`[SiloLink] Idle completion: conv ${s.conversationId} session ${s.sessionId} — ${reason} (agent session ${s.agentSessionId ?? 'none'}); completing then killing the pane`);
|
|
598
|
+
const launcher = this.getLauncherForConversation(s.conversationId) || this.getLauncher(s.agentProvider);
|
|
599
|
+
// Pane-activity tiebreaker (the lesson of the stale-on-inbound recycler):
|
|
600
|
+
// MCP silence alone is never proof of idleness. A verdict — poll_idle OR
|
|
601
|
+
// the default-ON final_report_idle — is only acted on when the pane shows
|
|
602
|
+
// no progress. A worker that posted final:true and then kept working
|
|
603
|
+
// (a commit, a summary write, an Agent subprocess) is silent on the MCP
|
|
604
|
+
// side but visibly active in its pane; killing it there is the recycler
|
|
605
|
+
// bug (conv 3337, six times). A truly parked pane is not "actively
|
|
606
|
+
// thinking", so this still completes it.
|
|
607
|
+
if (typeof launcher.isSessionWedged === 'function') {
|
|
608
|
+
const pane = launcher.isSessionWedged(s.conversationId);
|
|
609
|
+
if (pane.reason === 'pane-active') {
|
|
610
|
+
console.log(`[SiloLink] Idle completion deferred: conv ${s.conversationId} is ${verdict.reason} ${idleMin}min but pane ${pane.paneName} is active`);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
// 'polling' is the one pane verdict that is BOTH alive and idle: the
|
|
614
|
+
// CLI is parked in remote_poll, so its pane churn is not work. Act.
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
launcher.captureUsageForConversation(s.conversationId);
|
|
618
|
+
}
|
|
619
|
+
catch { /* best-effort */ }
|
|
620
|
+
const done = s.agentSessionId
|
|
621
|
+
? this.dsiloedClient.completeAgentSession(s.agentSessionId, { state: 'idle_timeout', reason })
|
|
622
|
+
: Promise.resolve();
|
|
623
|
+
done.catch(() => { }).finally(() => {
|
|
624
|
+
launcher.killByConversation(s.conversationId, reason);
|
|
625
|
+
this.messageQueue.clearSession(s.sessionId);
|
|
626
|
+
this.sessionManager.unregister(s.sessionId);
|
|
627
|
+
this.launchBindings.forgetConversation(s.conversationId);
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
console.error('[SiloLink] sweepIdleCompletions failed:', err);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
500
635
|
reconcileDeadPanes() {
|
|
501
636
|
try {
|
|
502
637
|
const liveTmux = this.liveTmuxSessionNames();
|
|
@@ -504,7 +639,7 @@ export class Bridge {
|
|
|
504
639
|
for (const s of this.sessionManager.getAll()) {
|
|
505
640
|
if (!s.agentSessionId)
|
|
506
641
|
continue;
|
|
507
|
-
if (liveTmux.has(this.codingTmuxName(s))) {
|
|
642
|
+
if (liveTmux.has(this.codingTmuxName(s)) || this.sessionHasLiveTransport(s)) {
|
|
508
643
|
this.deadPaneSince.delete(s.sessionId);
|
|
509
644
|
continue;
|
|
510
645
|
}
|
|
@@ -542,7 +677,7 @@ export class Bridge {
|
|
|
542
677
|
* no duplicate registration, no takeover. Best-effort: on any failure we
|
|
543
678
|
* fall back to the old model-driven registration path.
|
|
544
679
|
*/
|
|
545
|
-
async preRegisterLaunchedSession(conversationId, sessionName, llmAgentId, launchAgentSessionId) {
|
|
680
|
+
async preRegisterLaunchedSession(conversationId, sessionName, llmAgentId, launchAgentSessionId, launchCorrelationId) {
|
|
546
681
|
try {
|
|
547
682
|
// Respawn/relaunch for a conversation we already track. The in-memory
|
|
548
683
|
// session stays, but the SERVER made a NEW AgentSession row for this
|
|
@@ -572,6 +707,8 @@ export class Bridge {
|
|
|
572
707
|
console.log(`[SiloLink] Relaunch on conv ${conversationId}: agent session ${tracked.agentSessionId ?? 'none'} → ${launchAgentSessionId}`);
|
|
573
708
|
tracked.agentSessionId = launchAgentSessionId;
|
|
574
709
|
}
|
|
710
|
+
if (launchCorrelationId)
|
|
711
|
+
tracked.launchCorrelationId = launchCorrelationId;
|
|
575
712
|
return;
|
|
576
713
|
}
|
|
577
714
|
const session = {
|
|
@@ -585,6 +722,7 @@ export class Bridge {
|
|
|
585
722
|
createdAt: new Date(),
|
|
586
723
|
lastActivity: new Date(),
|
|
587
724
|
llmAgentId,
|
|
725
|
+
launchCorrelationId,
|
|
588
726
|
};
|
|
589
727
|
// register() consumes the pendingProvider set earlier in the launch
|
|
590
728
|
// handler, so the session carries the right agentProvider for the
|
|
@@ -706,6 +844,7 @@ export class Bridge {
|
|
|
706
844
|
console.log('\nSiloLink shutting down...');
|
|
707
845
|
// Stop heartbeat, self-probe, auto-updater, and notify frontend
|
|
708
846
|
this.stopHeartbeat();
|
|
847
|
+
healthMonitor.stop();
|
|
709
848
|
if (this.probeTimer) {
|
|
710
849
|
clearInterval(this.probeTimer);
|
|
711
850
|
this.probeTimer = null;
|
|
@@ -954,9 +1093,9 @@ export class Bridge {
|
|
|
954
1093
|
if (outcome === 'rejected') {
|
|
955
1094
|
console.warn(` Agent session ${target.agentSessionId} (conv ${target.conversationId}) is unreportable — settling the ledger entry instead of retrying it forever`);
|
|
956
1095
|
}
|
|
957
|
-
// Anything other than 'failed' is terminal: settle.
|
|
958
|
-
// transient outage or a later capability grant can still recover the usage.
|
|
959
|
-
if (outcome !== 'failed' && entryMatches) {
|
|
1096
|
+
// Anything other than 'failed'/'blocked' is terminal: settle. Both are kept
|
|
1097
|
+
// so a transient outage or a later capability grant can still recover the usage.
|
|
1098
|
+
if (outcome !== 'failed' && outcome !== 'blocked' && entryMatches) {
|
|
960
1099
|
this.usageLedger.remove(target.conversationId);
|
|
961
1100
|
}
|
|
962
1101
|
}
|
|
@@ -1081,6 +1220,10 @@ export class Bridge {
|
|
|
1081
1220
|
if (convId) {
|
|
1082
1221
|
this.sessionManager.setPendingProvider(convId, providerName);
|
|
1083
1222
|
}
|
|
1223
|
+
// Backend linkage for the pane: rides on the launch binding so
|
|
1224
|
+
// remote_register can hand it back (the backend adopts the
|
|
1225
|
+
// pre-created row instead of creating a second one).
|
|
1226
|
+
launcher.setLaunchLinkage?.({ agentSessionId: cmd.agent_session_id, launchCorrelationId: cmd.launch_correlation_id });
|
|
1084
1227
|
const launched = await launcher.launchInternal('control-channel', convId);
|
|
1085
1228
|
console.log(`[SiloLink] Launch command result: ${launched ? 'started' : 'FAILED (or already running)'}`);
|
|
1086
1229
|
// Track the session from the moment the tmux spawns instead of waiting
|
|
@@ -1090,13 +1233,17 @@ export class Bridge {
|
|
|
1090
1233
|
// session never appeared in heartbeat active_agent_session_ids and
|
|
1091
1234
|
// server-side death detection reaped a healthy working CC mid-task.
|
|
1092
1235
|
if (launched && convId) {
|
|
1093
|
-
await this.preRegisterLaunchedSession(convId, name, cmd.llm_agent_id, cmd.agent_session_id);
|
|
1236
|
+
await this.preRegisterLaunchedSession(convId, name, cmd.llm_agent_id, cmd.agent_session_id, cmd.launch_correlation_id);
|
|
1237
|
+
// Pin the minted resume id on the pre-registered session right away.
|
|
1238
|
+
launcher.syncResumeId?.(convId);
|
|
1094
1239
|
}
|
|
1095
1240
|
// A failed launch used to be invisible to the platform: the daemon had
|
|
1096
1241
|
// already posted "Starting session...", so the conversation (and any
|
|
1097
1242
|
// orchestration stage waiting on it) just went silent until a watchdog
|
|
1098
1243
|
// timed out hours later. Tell the conversation AND the control channel.
|
|
1099
1244
|
if (!launched && convId) {
|
|
1245
|
+
// Surface the failure in the health block (backend launch_failed_recent).
|
|
1246
|
+
healthMonitor.noteLaunchFailure(`session launch failed for conversation ${convId} (agent CLI missing/unlaunchable, tmux failure, or duplicate in-flight launch)`);
|
|
1100
1247
|
this.dsiloedClient.postMessage(convId, '**[SiloLink]** ⚠️ Session launch FAILED on this machine (agent CLI missing/unlaunchable, tmux failure, or a duplicate in-flight launch). Check the daemon log. The daemon is healthy; retry by sending another message.').catch(err => console.error('[SiloLink] Failed to post launch-failure message:', err));
|
|
1101
1248
|
const failPayload = {
|
|
1102
1249
|
type: 'session_launch_failed',
|
|
@@ -1273,9 +1420,26 @@ export class Bridge {
|
|
|
1273
1420
|
case 'interrupt': {
|
|
1274
1421
|
const intConvId = cmd.conversation_id;
|
|
1275
1422
|
if (intConvId) {
|
|
1276
|
-
const
|
|
1277
|
-
|
|
1278
|
-
|
|
1423
|
+
const target = this.resolvePaneForConversation(intConvId);
|
|
1424
|
+
if (target) {
|
|
1425
|
+
const interrupted = await target.launcher.interrupt(target.conversationId);
|
|
1426
|
+
console.log(`[SiloLink] Interrupt command result: ${interrupted ? `sent to ${target.paneName}` : 'no session found'}`);
|
|
1427
|
+
break;
|
|
1428
|
+
}
|
|
1429
|
+
const intSession = this.sessionManager.getByConversationId(intConvId);
|
|
1430
|
+
if (intSession) {
|
|
1431
|
+
this.messageQueue.enqueue(intSession.sessionId, {
|
|
1432
|
+
id: Date.now(),
|
|
1433
|
+
role: 'system',
|
|
1434
|
+
content: 'INTERRUPT: the user interrupted you. Stop your current task now, then check remote_poll for their latest message and follow those instructions instead.',
|
|
1435
|
+
senderName: cmd.requested_by || 'SiloLink',
|
|
1436
|
+
receivedAt: new Date(),
|
|
1437
|
+
});
|
|
1438
|
+
console.log(`[SiloLink] interrupt for conv ${intConvId}: no pane, delivered via message queue to session ${intSession.sessionId}`);
|
|
1439
|
+
}
|
|
1440
|
+
else {
|
|
1441
|
+
console.warn(`[SiloLink] interrupt for conv ${intConvId}: no pane and no registered session — nothing to interrupt`);
|
|
1442
|
+
}
|
|
1279
1443
|
}
|
|
1280
1444
|
else {
|
|
1281
1445
|
// Interrupt all sessions across all launchers
|
|
@@ -1304,13 +1468,23 @@ export class Bridge {
|
|
|
1304
1468
|
senderName: cmd.requested_by || 'SiloLink',
|
|
1305
1469
|
receivedAt: new Date(),
|
|
1306
1470
|
});
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1471
|
+
}
|
|
1472
|
+
// Also nudge via tmux in case the session isn't polling. The pane is
|
|
1473
|
+
// resolved through the session's launch binding when the conversation
|
|
1474
|
+
// has no pane of its own (registered from another pane) — previously
|
|
1475
|
+
// the keystroke was silently discarded in that case.
|
|
1476
|
+
const target = this.resolvePaneForConversation(nudgeConvId);
|
|
1477
|
+
if (target) {
|
|
1478
|
+
const nudged = target.launcher.nudgeConversation
|
|
1479
|
+
? target.launcher.nudgeConversation(target.conversationId, nudgeMessage)
|
|
1480
|
+
: target.launcher.nudge();
|
|
1481
|
+
console.log(`[SiloLink] Nudge sent to ${target.paneName}${session ? ` + queued for session ${session.sessionId}` : ''} (conversation ${nudgeConvId}, keystroke ${nudged ? 'sent' : 'not sent'})`);
|
|
1482
|
+
}
|
|
1483
|
+
else if (session) {
|
|
1484
|
+
console.log(`[SiloLink] nudge for conv ${nudgeConvId}: no pane, delivered via message queue to session ${session.sessionId}`);
|
|
1311
1485
|
}
|
|
1312
1486
|
else {
|
|
1313
|
-
console.
|
|
1487
|
+
console.warn(`[SiloLink] nudge for conv ${nudgeConvId}: no pane and no registered session — nudge dropped`);
|
|
1314
1488
|
}
|
|
1315
1489
|
}
|
|
1316
1490
|
else {
|
|
@@ -1374,14 +1548,34 @@ export class Bridge {
|
|
|
1374
1548
|
reply({ success: false, error: 'keys must be printable ASCII or a tmux key token' });
|
|
1375
1549
|
break;
|
|
1376
1550
|
}
|
|
1377
|
-
const
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1551
|
+
const sendTarget = this.resolvePaneForConversation(sendConvId);
|
|
1552
|
+
if (!sendTarget) {
|
|
1553
|
+
// No pane anywhere for this conversation. Submitted TEXT can still
|
|
1554
|
+
// reach a registered session through its poll loop; bare key tokens
|
|
1555
|
+
// cannot, and must not vanish silently.
|
|
1556
|
+
const keysSession = this.sessionManager.getByConversationId(sendConvId);
|
|
1557
|
+
if (keysSession && rawKeys && appendEnter && isPrintable && !isToken) {
|
|
1558
|
+
this.messageQueue.enqueue(keysSession.sessionId, {
|
|
1559
|
+
id: Date.now(),
|
|
1560
|
+
role: 'system',
|
|
1561
|
+
content: rawKeys,
|
|
1562
|
+
senderName: cmd.requested_by || 'SiloLink',
|
|
1563
|
+
receivedAt: new Date(),
|
|
1564
|
+
});
|
|
1565
|
+
console.log(`[SiloLink] send_keys for conv ${sendConvId}: no pane, delivered via message queue to session ${keysSession.sessionId}`);
|
|
1566
|
+
reply({ success: true, delivered_via: 'message_queue', session_id: keysSession.sessionId, keys: rawKeys, enter: true });
|
|
1567
|
+
break;
|
|
1568
|
+
}
|
|
1569
|
+
const errMsg = keysSession
|
|
1570
|
+
? `no pane for conversation ${sendConvId} (session ${keysSession.sessionId} is registered but raw keys need a pane)`
|
|
1571
|
+
: `no active session for conversation ${sendConvId}`;
|
|
1572
|
+
console.warn(`[SiloLink] send_keys: ${errMsg}`);
|
|
1382
1573
|
reply({ success: false, error: errMsg });
|
|
1383
1574
|
break;
|
|
1384
1575
|
}
|
|
1576
|
+
const sendLauncher = sendTarget.launcher;
|
|
1577
|
+
const sendPaneConvId = sendTarget.conversationId;
|
|
1578
|
+
const proc = { processId: sendTarget.paneName };
|
|
1385
1579
|
// TEXT + Enter goes through the verified path: a separate Enter plus
|
|
1386
1580
|
// submit verification/retry. Sending both in one `tmux send-keys` and
|
|
1387
1581
|
// trusting its exit code is what let a swallowed Enter strand an
|
|
@@ -1390,7 +1584,7 @@ export class Bridge {
|
|
|
1390
1584
|
// Bare key tokens (Escape, C-c, arrows) and keys-without-Enter keep the
|
|
1391
1585
|
// raw path — there is no submitted text to verify.
|
|
1392
1586
|
if (rawKeys && appendEnter && isPrintable && !isToken) {
|
|
1393
|
-
const sent = sendLauncher.sendTextAndSubmit(
|
|
1587
|
+
const sent = sendLauncher.sendTextAndSubmit(sendPaneConvId, rawKeys);
|
|
1394
1588
|
if (sent) {
|
|
1395
1589
|
console.log(`[SiloLink] send_keys: ${JSON.stringify(rawKeys)} + verified Enter → ${proc.processId} (from: ${cmd.requested_by || 'unknown'})`);
|
|
1396
1590
|
reply({ success: true, session: proc.processId, keys: rawKeys, enter: true, verified: true });
|