@dsiloed/silo-link 1.19.14 → 1.19.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/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/core/agent-launcher.d.ts +7 -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 +24 -1
- package/dist/core/base-tmux-launcher.d.ts.map +1 -1
- package/dist/core/base-tmux-launcher.js +73 -3
- 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 +208 -21
- package/dist/core/bridge.js.map +1 -1
- package/dist/core/claude-launcher.d.ts +4 -2
- package/dist/core/claude-launcher.d.ts.map +1 -1
- package/dist/core/claude-launcher.js +29 -8
- package/dist/core/claude-launcher.js.map +1 -1
- package/dist/core/codex-launcher.d.ts +2 -2
- package/dist/core/codex-launcher.d.ts.map +1 -1
- package/dist/core/codex-launcher.js +2 -2
- package/dist/core/codex-launcher.js.map +1 -1
- package/dist/core/gemini-launcher.d.ts +2 -2
- package/dist/core/gemini-launcher.d.ts.map +1 -1
- package/dist/core/gemini-launcher.js +2 -2
- package/dist/core/gemini-launcher.js.map +1 -1
- package/dist/core/health-monitor.d.ts +87 -0
- package/dist/core/health-monitor.d.ts.map +1 -0
- package/dist/core/health-monitor.js +93 -0
- package/dist/core/health-monitor.js.map +1 -0
- package/dist/core/idle-completion.d.ts +38 -0
- package/dist/core/idle-completion.d.ts.map +1 -0
- package/dist/core/idle-completion.js +52 -0
- package/dist/core/idle-completion.js.map +1 -0
- package/dist/core/launch-binding.d.ts +52 -0
- package/dist/core/launch-binding.d.ts.map +1 -0
- package/dist/core/launch-binding.js +81 -0
- package/dist/core/launch-binding.js.map +1 -0
- package/dist/core/usage-ledger.d.ts +1 -1
- package/dist/core/usage-ledger.d.ts.map +1 -1
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +15 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools/register-tools.d.ts +2 -0
- package/dist/mcp/tools/register-tools.d.ts.map +1 -1
- package/dist/mcp/tools/register-tools.js +154 -36
- package/dist/mcp/tools/register-tools.js.map +1 -1
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +3 -1
- package/dist/core/baseline-verify.d.ts +0 -54
- package/dist/core/baseline-verify.d.ts.map +0 -1
- package/dist/core/baseline-verify.js +0 -164
- package/dist/core/baseline-verify.js.map +0 -1
- package/dist/core/openai-launcher.d.ts +0 -33
- package/dist/core/openai-launcher.d.ts.map +0 -1
- package/dist/core/openai-launcher.js +0 -38
- package/dist/core/openai-launcher.js.map +0 -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;
|
|
@@ -133,12 +139,21 @@ export class Bridge {
|
|
|
133
139
|
// getLauncher(); a second Map key made launchers.values() yield the Codex
|
|
134
140
|
// launcher twice (double startIdleMonitor/destroy/kill/nudge — 2026-08-23).
|
|
135
141
|
this.launchers.set('codex', codexLauncher);
|
|
142
|
+
for (const l of this.launchers.values())
|
|
143
|
+
l.setLaunchBindings?.(this.launchBindings);
|
|
136
144
|
// Default launcher is used for auto-launch on inbound messages
|
|
137
145
|
const defaultLauncher = this.getLauncher();
|
|
138
146
|
this.subscriptionManager = new SubscriptionManager(this.cableClient, this.messageQueue, this.sessionManager, defaultLauncher, this.dsiloedClient,
|
|
139
147
|
// Lets auto-launch relaunch a conversation with its ORIGINAL coding agent
|
|
140
148
|
// (e.g. Codex stays Codex) instead of always the default launcher.
|
|
141
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
|
+
});
|
|
142
157
|
this.mcpServer = new SiloLinkMcpServer({
|
|
143
158
|
config: this.config,
|
|
144
159
|
sessionManager: this.sessionManager,
|
|
@@ -148,6 +163,7 @@ export class Bridge {
|
|
|
148
163
|
cableClient: this.cableClient,
|
|
149
164
|
workspaceManager: this.workspaceManager,
|
|
150
165
|
launcher: defaultLauncher,
|
|
166
|
+
launchBindings: this.launchBindings,
|
|
151
167
|
startedAt: () => this.startedAt,
|
|
152
168
|
onModuleLoadFailure: (err) => this.selfHealBrokenInstall(err),
|
|
153
169
|
probeStatus: () => this.probeStatus,
|
|
@@ -222,6 +238,11 @@ export class Bridge {
|
|
|
222
238
|
startupLaunchSanityCheck() {
|
|
223
239
|
const launcher = this.getLauncher();
|
|
224
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);
|
|
225
246
|
if (preflight && !preflight.resolvedPath) {
|
|
226
247
|
console.error(` ⚠️ Agent CLI "${preflight.command}" was NOT found on this daemon's PATH or in known install locations.`);
|
|
227
248
|
console.error(' Session launches WILL fail. Start the daemon from a shell where the CLI resolves,');
|
|
@@ -236,6 +257,7 @@ export class Bridge {
|
|
|
236
257
|
catch {
|
|
237
258
|
return false;
|
|
238
259
|
} })();
|
|
260
|
+
healthMonitor.setCredentialsPresent(hasEnvAuth || hasCredFile);
|
|
239
261
|
if (!hasEnvAuth && !hasCredFile) {
|
|
240
262
|
console.error(' ⚠️ No Claude Code credentials visible to this daemon (no CLAUDE_CODE_OAUTH_TOKEN /');
|
|
241
263
|
console.error(' ANTHROPIC_API_KEY in the environment, no ~/.claude/.credentials.json).');
|
|
@@ -353,6 +375,8 @@ export class Bridge {
|
|
|
353
375
|
process.on('SIGINT', shutdown);
|
|
354
376
|
// Start heartbeat broadcast so the frontend knows we're alive
|
|
355
377
|
this.startHeartbeat();
|
|
378
|
+
// One [HEALTH] line per hour in the file log (the heartbeat carries the same block).
|
|
379
|
+
healthMonitor.startHourlyLog();
|
|
356
380
|
// Start the auto-updater. Idle = no live coding-session tmux (so an update
|
|
357
381
|
// never kills work mid-flight) and not already shutting down.
|
|
358
382
|
this.autoUpdater = new AutoUpdater({
|
|
@@ -392,6 +416,7 @@ export class Bridge {
|
|
|
392
416
|
this.heartbeatTimer = setInterval(() => {
|
|
393
417
|
this.broadcastAlive();
|
|
394
418
|
this.reconcileDeadPanes();
|
|
419
|
+
this.sweepIdleCompletions();
|
|
395
420
|
}, 30_000);
|
|
396
421
|
}
|
|
397
422
|
stopHeartbeat() {
|
|
@@ -454,6 +479,10 @@ export class Bridge {
|
|
|
454
479
|
// Whether the MCP POST path (what spawned sessions register through)
|
|
455
480
|
// actually works — 'failing' means daemon-up-but-broken (install swap).
|
|
456
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(),
|
|
457
486
|
timestamp: Date.now(),
|
|
458
487
|
});
|
|
459
488
|
}
|
|
@@ -483,11 +512,57 @@ export class Bridge {
|
|
|
483
512
|
return [...new Set(ids)];
|
|
484
513
|
}
|
|
485
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. */
|
|
486
529
|
liveAgentSessions() {
|
|
487
530
|
const liveTmux = this.liveTmuxSessionNames();
|
|
531
|
+
const seen = new Set();
|
|
488
532
|
return this.sessionManager.getAll()
|
|
489
533
|
.filter(s => s.agentSessionId)
|
|
490
|
-
.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;
|
|
491
566
|
}
|
|
492
567
|
codingTmuxName(s) {
|
|
493
568
|
return `silolink-${s.agentProvider ?? this.defaultProvider}-conv${s.conversationId}`;
|
|
@@ -500,6 +575,61 @@ export class Bridge {
|
|
|
500
575
|
// auto-respawn — reconcile must not yank the session out from under it.
|
|
501
576
|
static DEAD_PANE_GRACE_MS = 10 * 60 * 1000;
|
|
502
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
|
+
}
|
|
614
|
+
try {
|
|
615
|
+
launcher.captureUsageForConversation(s.conversationId);
|
|
616
|
+
}
|
|
617
|
+
catch { /* best-effort */ }
|
|
618
|
+
const done = s.agentSessionId
|
|
619
|
+
? this.dsiloedClient.completeAgentSession(s.agentSessionId, { state: 'idle_timeout', reason })
|
|
620
|
+
: Promise.resolve();
|
|
621
|
+
done.catch(() => { }).finally(() => {
|
|
622
|
+
launcher.killByConversation(s.conversationId, reason);
|
|
623
|
+
this.messageQueue.clearSession(s.sessionId);
|
|
624
|
+
this.sessionManager.unregister(s.sessionId);
|
|
625
|
+
this.launchBindings.forgetConversation(s.conversationId);
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
catch (err) {
|
|
630
|
+
console.error('[SiloLink] sweepIdleCompletions failed:', err);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
503
633
|
reconcileDeadPanes() {
|
|
504
634
|
try {
|
|
505
635
|
const liveTmux = this.liveTmuxSessionNames();
|
|
@@ -507,7 +637,7 @@ export class Bridge {
|
|
|
507
637
|
for (const s of this.sessionManager.getAll()) {
|
|
508
638
|
if (!s.agentSessionId)
|
|
509
639
|
continue;
|
|
510
|
-
if (liveTmux.has(this.codingTmuxName(s))) {
|
|
640
|
+
if (liveTmux.has(this.codingTmuxName(s)) || this.sessionHasLiveTransport(s)) {
|
|
511
641
|
this.deadPaneSince.delete(s.sessionId);
|
|
512
642
|
continue;
|
|
513
643
|
}
|
|
@@ -545,7 +675,7 @@ export class Bridge {
|
|
|
545
675
|
* no duplicate registration, no takeover. Best-effort: on any failure we
|
|
546
676
|
* fall back to the old model-driven registration path.
|
|
547
677
|
*/
|
|
548
|
-
async preRegisterLaunchedSession(conversationId, sessionName, llmAgentId, launchAgentSessionId) {
|
|
678
|
+
async preRegisterLaunchedSession(conversationId, sessionName, llmAgentId, launchAgentSessionId, launchCorrelationId) {
|
|
549
679
|
try {
|
|
550
680
|
// Respawn/relaunch for a conversation we already track. The in-memory
|
|
551
681
|
// session stays, but the SERVER made a NEW AgentSession row for this
|
|
@@ -575,6 +705,8 @@ export class Bridge {
|
|
|
575
705
|
console.log(`[SiloLink] Relaunch on conv ${conversationId}: agent session ${tracked.agentSessionId ?? 'none'} → ${launchAgentSessionId}`);
|
|
576
706
|
tracked.agentSessionId = launchAgentSessionId;
|
|
577
707
|
}
|
|
708
|
+
if (launchCorrelationId)
|
|
709
|
+
tracked.launchCorrelationId = launchCorrelationId;
|
|
578
710
|
return;
|
|
579
711
|
}
|
|
580
712
|
const session = {
|
|
@@ -588,6 +720,7 @@ export class Bridge {
|
|
|
588
720
|
createdAt: new Date(),
|
|
589
721
|
lastActivity: new Date(),
|
|
590
722
|
llmAgentId,
|
|
723
|
+
launchCorrelationId,
|
|
591
724
|
};
|
|
592
725
|
// register() consumes the pendingProvider set earlier in the launch
|
|
593
726
|
// handler, so the session carries the right agentProvider for the
|
|
@@ -709,6 +842,7 @@ export class Bridge {
|
|
|
709
842
|
console.log('\nSiloLink shutting down...');
|
|
710
843
|
// Stop heartbeat, self-probe, auto-updater, and notify frontend
|
|
711
844
|
this.stopHeartbeat();
|
|
845
|
+
healthMonitor.stop();
|
|
712
846
|
if (this.probeTimer) {
|
|
713
847
|
clearInterval(this.probeTimer);
|
|
714
848
|
this.probeTimer = null;
|
|
@@ -957,9 +1091,9 @@ export class Bridge {
|
|
|
957
1091
|
if (outcome === 'rejected') {
|
|
958
1092
|
console.warn(` Agent session ${target.agentSessionId} (conv ${target.conversationId}) is unreportable — settling the ledger entry instead of retrying it forever`);
|
|
959
1093
|
}
|
|
960
|
-
// Anything other than 'failed' is terminal: settle.
|
|
961
|
-
// transient outage or a later capability grant can still recover the usage.
|
|
962
|
-
if (outcome !== 'failed' && entryMatches) {
|
|
1094
|
+
// Anything other than 'failed'/'blocked' is terminal: settle. Both are kept
|
|
1095
|
+
// so a transient outage or a later capability grant can still recover the usage.
|
|
1096
|
+
if (outcome !== 'failed' && outcome !== 'blocked' && entryMatches) {
|
|
963
1097
|
this.usageLedger.remove(target.conversationId);
|
|
964
1098
|
}
|
|
965
1099
|
}
|
|
@@ -1084,6 +1218,10 @@ export class Bridge {
|
|
|
1084
1218
|
if (convId) {
|
|
1085
1219
|
this.sessionManager.setPendingProvider(convId, providerName);
|
|
1086
1220
|
}
|
|
1221
|
+
// Backend linkage for the pane: rides on the launch binding so
|
|
1222
|
+
// remote_register can hand it back (the backend adopts the
|
|
1223
|
+
// pre-created row instead of creating a second one).
|
|
1224
|
+
launcher.setLaunchLinkage?.({ agentSessionId: cmd.agent_session_id, launchCorrelationId: cmd.launch_correlation_id });
|
|
1087
1225
|
const launched = await launcher.launchInternal('control-channel', convId);
|
|
1088
1226
|
console.log(`[SiloLink] Launch command result: ${launched ? 'started' : 'FAILED (or already running)'}`);
|
|
1089
1227
|
// Track the session from the moment the tmux spawns instead of waiting
|
|
@@ -1093,7 +1231,7 @@ export class Bridge {
|
|
|
1093
1231
|
// session never appeared in heartbeat active_agent_session_ids and
|
|
1094
1232
|
// server-side death detection reaped a healthy working CC mid-task.
|
|
1095
1233
|
if (launched && convId) {
|
|
1096
|
-
await this.preRegisterLaunchedSession(convId, name, cmd.llm_agent_id, cmd.agent_session_id);
|
|
1234
|
+
await this.preRegisterLaunchedSession(convId, name, cmd.llm_agent_id, cmd.agent_session_id, cmd.launch_correlation_id);
|
|
1097
1235
|
// Pin the minted resume id on the pre-registered session right away.
|
|
1098
1236
|
launcher.syncResumeId?.(convId);
|
|
1099
1237
|
}
|
|
@@ -1102,6 +1240,8 @@ export class Bridge {
|
|
|
1102
1240
|
// orchestration stage waiting on it) just went silent until a watchdog
|
|
1103
1241
|
// timed out hours later. Tell the conversation AND the control channel.
|
|
1104
1242
|
if (!launched && convId) {
|
|
1243
|
+
// Surface the failure in the health block (backend launch_failed_recent).
|
|
1244
|
+
healthMonitor.noteLaunchFailure(`session launch failed for conversation ${convId} (agent CLI missing/unlaunchable, tmux failure, or duplicate in-flight launch)`);
|
|
1105
1245
|
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));
|
|
1106
1246
|
const failPayload = {
|
|
1107
1247
|
type: 'session_launch_failed',
|
|
@@ -1278,9 +1418,26 @@ export class Bridge {
|
|
|
1278
1418
|
case 'interrupt': {
|
|
1279
1419
|
const intConvId = cmd.conversation_id;
|
|
1280
1420
|
if (intConvId) {
|
|
1281
|
-
const
|
|
1282
|
-
|
|
1283
|
-
|
|
1421
|
+
const target = this.resolvePaneForConversation(intConvId);
|
|
1422
|
+
if (target) {
|
|
1423
|
+
const interrupted = await target.launcher.interrupt(target.conversationId);
|
|
1424
|
+
console.log(`[SiloLink] Interrupt command result: ${interrupted ? `sent to ${target.paneName}` : 'no session found'}`);
|
|
1425
|
+
break;
|
|
1426
|
+
}
|
|
1427
|
+
const intSession = this.sessionManager.getByConversationId(intConvId);
|
|
1428
|
+
if (intSession) {
|
|
1429
|
+
this.messageQueue.enqueue(intSession.sessionId, {
|
|
1430
|
+
id: Date.now(),
|
|
1431
|
+
role: 'system',
|
|
1432
|
+
content: 'INTERRUPT: the user interrupted you. Stop your current task now, then check remote_poll for their latest message and follow those instructions instead.',
|
|
1433
|
+
senderName: cmd.requested_by || 'SiloLink',
|
|
1434
|
+
receivedAt: new Date(),
|
|
1435
|
+
});
|
|
1436
|
+
console.log(`[SiloLink] interrupt for conv ${intConvId}: no pane, delivered via message queue to session ${intSession.sessionId}`);
|
|
1437
|
+
}
|
|
1438
|
+
else {
|
|
1439
|
+
console.warn(`[SiloLink] interrupt for conv ${intConvId}: no pane and no registered session — nothing to interrupt`);
|
|
1440
|
+
}
|
|
1284
1441
|
}
|
|
1285
1442
|
else {
|
|
1286
1443
|
// Interrupt all sessions across all launchers
|
|
@@ -1309,13 +1466,23 @@ export class Bridge {
|
|
|
1309
1466
|
senderName: cmd.requested_by || 'SiloLink',
|
|
1310
1467
|
receivedAt: new Date(),
|
|
1311
1468
|
});
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1469
|
+
}
|
|
1470
|
+
// Also nudge via tmux in case the session isn't polling. The pane is
|
|
1471
|
+
// resolved through the session's launch binding when the conversation
|
|
1472
|
+
// has no pane of its own (registered from another pane) — previously
|
|
1473
|
+
// the keystroke was silently discarded in that case.
|
|
1474
|
+
const target = this.resolvePaneForConversation(nudgeConvId);
|
|
1475
|
+
if (target) {
|
|
1476
|
+
const nudged = target.launcher.nudgeConversation
|
|
1477
|
+
? target.launcher.nudgeConversation(target.conversationId, nudgeMessage)
|
|
1478
|
+
: target.launcher.nudge();
|
|
1479
|
+
console.log(`[SiloLink] Nudge sent to ${target.paneName}${session ? ` + queued for session ${session.sessionId}` : ''} (conversation ${nudgeConvId}, keystroke ${nudged ? 'sent' : 'not sent'})`);
|
|
1480
|
+
}
|
|
1481
|
+
else if (session) {
|
|
1482
|
+
console.log(`[SiloLink] nudge for conv ${nudgeConvId}: no pane, delivered via message queue to session ${session.sessionId}`);
|
|
1316
1483
|
}
|
|
1317
1484
|
else {
|
|
1318
|
-
console.
|
|
1485
|
+
console.warn(`[SiloLink] nudge for conv ${nudgeConvId}: no pane and no registered session — nudge dropped`);
|
|
1319
1486
|
}
|
|
1320
1487
|
}
|
|
1321
1488
|
else {
|
|
@@ -1379,14 +1546,34 @@ export class Bridge {
|
|
|
1379
1546
|
reply({ success: false, error: 'keys must be printable ASCII or a tmux key token' });
|
|
1380
1547
|
break;
|
|
1381
1548
|
}
|
|
1382
|
-
const
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1549
|
+
const sendTarget = this.resolvePaneForConversation(sendConvId);
|
|
1550
|
+
if (!sendTarget) {
|
|
1551
|
+
// No pane anywhere for this conversation. Submitted TEXT can still
|
|
1552
|
+
// reach a registered session through its poll loop; bare key tokens
|
|
1553
|
+
// cannot, and must not vanish silently.
|
|
1554
|
+
const keysSession = this.sessionManager.getByConversationId(sendConvId);
|
|
1555
|
+
if (keysSession && rawKeys && appendEnter && isPrintable && !isToken) {
|
|
1556
|
+
this.messageQueue.enqueue(keysSession.sessionId, {
|
|
1557
|
+
id: Date.now(),
|
|
1558
|
+
role: 'system',
|
|
1559
|
+
content: rawKeys,
|
|
1560
|
+
senderName: cmd.requested_by || 'SiloLink',
|
|
1561
|
+
receivedAt: new Date(),
|
|
1562
|
+
});
|
|
1563
|
+
console.log(`[SiloLink] send_keys for conv ${sendConvId}: no pane, delivered via message queue to session ${keysSession.sessionId}`);
|
|
1564
|
+
reply({ success: true, delivered_via: 'message_queue', session_id: keysSession.sessionId, keys: rawKeys, enter: true });
|
|
1565
|
+
break;
|
|
1566
|
+
}
|
|
1567
|
+
const errMsg = keysSession
|
|
1568
|
+
? `no pane for conversation ${sendConvId} (session ${keysSession.sessionId} is registered but raw keys need a pane)`
|
|
1569
|
+
: `no active session for conversation ${sendConvId}`;
|
|
1570
|
+
console.warn(`[SiloLink] send_keys: ${errMsg}`);
|
|
1387
1571
|
reply({ success: false, error: errMsg });
|
|
1388
1572
|
break;
|
|
1389
1573
|
}
|
|
1574
|
+
const sendLauncher = sendTarget.launcher;
|
|
1575
|
+
const sendPaneConvId = sendTarget.conversationId;
|
|
1576
|
+
const proc = { processId: sendTarget.paneName };
|
|
1390
1577
|
// TEXT + Enter goes through the verified path: a separate Enter plus
|
|
1391
1578
|
// submit verification/retry. Sending both in one `tmux send-keys` and
|
|
1392
1579
|
// trusting its exit code is what let a swallowed Enter strand an
|
|
@@ -1395,7 +1582,7 @@ export class Bridge {
|
|
|
1395
1582
|
// Bare key tokens (Escape, C-c, arrows) and keys-without-Enter keep the
|
|
1396
1583
|
// raw path — there is no submitted text to verify.
|
|
1397
1584
|
if (rawKeys && appendEnter && isPrintable && !isToken) {
|
|
1398
|
-
const sent = sendLauncher.sendTextAndSubmit(
|
|
1585
|
+
const sent = sendLauncher.sendTextAndSubmit(sendPaneConvId, rawKeys);
|
|
1399
1586
|
if (sent) {
|
|
1400
1587
|
console.log(`[SiloLink] send_keys: ${JSON.stringify(rawKeys)} + verified Enter → ${proc.processId} (from: ${cmd.requested_by || 'unknown'})`);
|
|
1401
1588
|
reply({ success: true, session: proc.processId, keys: rawKeys, enter: true, verified: true });
|