@dsiloed/silo-link 1.19.15 → 1.19.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +8 -0
  2. package/dist/api/backend-health.d.ts +44 -0
  3. package/dist/api/backend-health.d.ts.map +1 -0
  4. package/dist/api/backend-health.js +80 -0
  5. package/dist/api/backend-health.js.map +1 -0
  6. package/dist/api/dsiloed-client.d.ts +7 -3
  7. package/dist/api/dsiloed-client.d.ts.map +1 -1
  8. package/dist/api/dsiloed-client.js +29 -4
  9. package/dist/api/dsiloed-client.js.map +1 -1
  10. package/dist/cable/subscription-manager.d.ts +26 -1
  11. package/dist/cable/subscription-manager.d.ts.map +1 -1
  12. package/dist/cable/subscription-manager.js +72 -13
  13. package/dist/cable/subscription-manager.js.map +1 -1
  14. package/dist/cli/claude-md-block.d.ts +1 -1
  15. package/dist/cli/claude-md-block.d.ts.map +1 -1
  16. package/dist/cli/claude-md-block.js +3 -1
  17. package/dist/cli/claude-md-block.js.map +1 -1
  18. package/dist/cli/file-log.d.ts +58 -3
  19. package/dist/cli/file-log.d.ts.map +1 -1
  20. package/dist/cli/file-log.js +110 -7
  21. package/dist/cli/file-log.js.map +1 -1
  22. package/dist/core/agent-launcher.d.ts +26 -0
  23. package/dist/core/agent-launcher.d.ts.map +1 -1
  24. package/dist/core/auto-updater.d.ts +11 -0
  25. package/dist/core/auto-updater.d.ts.map +1 -1
  26. package/dist/core/auto-updater.js +84 -15
  27. package/dist/core/auto-updater.js.map +1 -1
  28. package/dist/core/base-tmux-launcher.d.ts +66 -20
  29. package/dist/core/base-tmux-launcher.d.ts.map +1 -1
  30. package/dist/core/base-tmux-launcher.js +236 -76
  31. package/dist/core/base-tmux-launcher.js.map +1 -1
  32. package/dist/core/bridge.d.ts +28 -0
  33. package/dist/core/bridge.d.ts.map +1 -1
  34. package/dist/core/bridge.js +217 -25
  35. package/dist/core/bridge.js.map +1 -1
  36. package/dist/core/claude-launcher.d.ts +11 -4
  37. package/dist/core/claude-launcher.d.ts.map +1 -1
  38. package/dist/core/claude-launcher.js +59 -16
  39. package/dist/core/claude-launcher.js.map +1 -1
  40. package/dist/core/codex-launcher.d.ts +3 -3
  41. package/dist/core/codex-launcher.d.ts.map +1 -1
  42. package/dist/core/codex-launcher.js +9 -5
  43. package/dist/core/codex-launcher.js.map +1 -1
  44. package/dist/core/gemini-launcher.d.ts +3 -3
  45. package/dist/core/gemini-launcher.d.ts.map +1 -1
  46. package/dist/core/gemini-launcher.js +9 -5
  47. package/dist/core/gemini-launcher.js.map +1 -1
  48. package/dist/core/health-monitor.d.ts +87 -0
  49. package/dist/core/health-monitor.d.ts.map +1 -0
  50. package/dist/core/health-monitor.js +93 -0
  51. package/dist/core/health-monitor.js.map +1 -0
  52. package/dist/core/idle-completion.d.ts +38 -0
  53. package/dist/core/idle-completion.d.ts.map +1 -0
  54. package/dist/core/idle-completion.js +52 -0
  55. package/dist/core/idle-completion.js.map +1 -0
  56. package/dist/core/launch-binding.d.ts +52 -0
  57. package/dist/core/launch-binding.d.ts.map +1 -0
  58. package/dist/core/launch-binding.js +81 -0
  59. package/dist/core/launch-binding.js.map +1 -0
  60. package/dist/core/transcript-usage.d.ts +9 -0
  61. package/dist/core/transcript-usage.d.ts.map +1 -1
  62. package/dist/core/transcript-usage.js +19 -0
  63. package/dist/core/transcript-usage.js.map +1 -1
  64. package/dist/core/usage-ledger.d.ts +1 -1
  65. package/dist/core/usage-ledger.d.ts.map +1 -1
  66. package/dist/mcp/server.d.ts +4 -0
  67. package/dist/mcp/server.d.ts.map +1 -1
  68. package/dist/mcp/server.js +15 -0
  69. package/dist/mcp/server.js.map +1 -1
  70. package/dist/mcp/tools/register-tools.d.ts +2 -0
  71. package/dist/mcp/tools/register-tools.d.ts.map +1 -1
  72. package/dist/mcp/tools/register-tools.js +154 -36
  73. package/dist/mcp/tools/register-tools.js.map +1 -1
  74. package/dist/types/index.d.ts +6 -0
  75. package/dist/types/index.d.ts.map +1 -1
  76. package/package.json +3 -1
@@ -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.set('openai', codexLauncher); // alias
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
- if (provider && this.launchers.has(provider)) {
250
- return this.launchers.get(provider);
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,61 @@ 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
+ }
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
+ }
500
633
  reconcileDeadPanes() {
501
634
  try {
502
635
  const liveTmux = this.liveTmuxSessionNames();
@@ -504,7 +637,7 @@ export class Bridge {
504
637
  for (const s of this.sessionManager.getAll()) {
505
638
  if (!s.agentSessionId)
506
639
  continue;
507
- if (liveTmux.has(this.codingTmuxName(s))) {
640
+ if (liveTmux.has(this.codingTmuxName(s)) || this.sessionHasLiveTransport(s)) {
508
641
  this.deadPaneSince.delete(s.sessionId);
509
642
  continue;
510
643
  }
@@ -542,7 +675,7 @@ export class Bridge {
542
675
  * no duplicate registration, no takeover. Best-effort: on any failure we
543
676
  * fall back to the old model-driven registration path.
544
677
  */
545
- async preRegisterLaunchedSession(conversationId, sessionName, llmAgentId, launchAgentSessionId) {
678
+ async preRegisterLaunchedSession(conversationId, sessionName, llmAgentId, launchAgentSessionId, launchCorrelationId) {
546
679
  try {
547
680
  // Respawn/relaunch for a conversation we already track. The in-memory
548
681
  // session stays, but the SERVER made a NEW AgentSession row for this
@@ -572,6 +705,8 @@ export class Bridge {
572
705
  console.log(`[SiloLink] Relaunch on conv ${conversationId}: agent session ${tracked.agentSessionId ?? 'none'} → ${launchAgentSessionId}`);
573
706
  tracked.agentSessionId = launchAgentSessionId;
574
707
  }
708
+ if (launchCorrelationId)
709
+ tracked.launchCorrelationId = launchCorrelationId;
575
710
  return;
576
711
  }
577
712
  const session = {
@@ -585,6 +720,7 @@ export class Bridge {
585
720
  createdAt: new Date(),
586
721
  lastActivity: new Date(),
587
722
  llmAgentId,
723
+ launchCorrelationId,
588
724
  };
589
725
  // register() consumes the pendingProvider set earlier in the launch
590
726
  // handler, so the session carries the right agentProvider for the
@@ -706,6 +842,7 @@ export class Bridge {
706
842
  console.log('\nSiloLink shutting down...');
707
843
  // Stop heartbeat, self-probe, auto-updater, and notify frontend
708
844
  this.stopHeartbeat();
845
+ healthMonitor.stop();
709
846
  if (this.probeTimer) {
710
847
  clearInterval(this.probeTimer);
711
848
  this.probeTimer = null;
@@ -954,9 +1091,9 @@ export class Bridge {
954
1091
  if (outcome === 'rejected') {
955
1092
  console.warn(` Agent session ${target.agentSessionId} (conv ${target.conversationId}) is unreportable — settling the ledger entry instead of retrying it forever`);
956
1093
  }
957
- // Anything other than 'failed' is terminal: settle. 'failed' is kept so a
958
- // transient outage or a later capability grant can still recover the usage.
959
- 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) {
960
1097
  this.usageLedger.remove(target.conversationId);
961
1098
  }
962
1099
  }
@@ -1081,6 +1218,10 @@ export class Bridge {
1081
1218
  if (convId) {
1082
1219
  this.sessionManager.setPendingProvider(convId, providerName);
1083
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 });
1084
1225
  const launched = await launcher.launchInternal('control-channel', convId);
1085
1226
  console.log(`[SiloLink] Launch command result: ${launched ? 'started' : 'FAILED (or already running)'}`);
1086
1227
  // Track the session from the moment the tmux spawns instead of waiting
@@ -1090,13 +1231,17 @@ export class Bridge {
1090
1231
  // session never appeared in heartbeat active_agent_session_ids and
1091
1232
  // server-side death detection reaped a healthy working CC mid-task.
1092
1233
  if (launched && convId) {
1093
- 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);
1235
+ // Pin the minted resume id on the pre-registered session right away.
1236
+ launcher.syncResumeId?.(convId);
1094
1237
  }
1095
1238
  // A failed launch used to be invisible to the platform: the daemon had
1096
1239
  // already posted "Starting session...", so the conversation (and any
1097
1240
  // orchestration stage waiting on it) just went silent until a watchdog
1098
1241
  // timed out hours later. Tell the conversation AND the control channel.
1099
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)`);
1100
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));
1101
1246
  const failPayload = {
1102
1247
  type: 'session_launch_failed',
@@ -1273,9 +1418,26 @@ export class Bridge {
1273
1418
  case 'interrupt': {
1274
1419
  const intConvId = cmd.conversation_id;
1275
1420
  if (intConvId) {
1276
- const intLauncher = this.getLauncherForConversation(intConvId) || this.getLauncher();
1277
- const interrupted = await intLauncher.interrupt(intConvId);
1278
- console.log(`[SiloLink] Interrupt command result: ${interrupted ? 'sent' : 'no session found'}`);
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
+ }
1279
1441
  }
1280
1442
  else {
1281
1443
  // Interrupt all sessions across all launchers
@@ -1304,13 +1466,23 @@ export class Bridge {
1304
1466
  senderName: cmd.requested_by || 'SiloLink',
1305
1467
  receivedAt: new Date(),
1306
1468
  });
1307
- // Also nudge via tmux in case the session isn't polling
1308
- const nudgeLauncher = this.getLauncherForConversation(nudgeConvId) || this.getLauncher();
1309
- nudgeLauncher.nudge();
1310
- console.log(`[SiloLink] Nudge sent to session ${session.sessionId} (conversation ${nudgeConvId})`);
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}`);
1311
1483
  }
1312
1484
  else {
1313
- console.log(`[SiloLink] Nudge: no session found for conversation ${nudgeConvId}`);
1485
+ console.warn(`[SiloLink] nudge for conv ${nudgeConvId}: no pane and no registered session — nudge dropped`);
1314
1486
  }
1315
1487
  }
1316
1488
  else {
@@ -1374,14 +1546,34 @@ export class Bridge {
1374
1546
  reply({ success: false, error: 'keys must be printable ASCII or a tmux key token' });
1375
1547
  break;
1376
1548
  }
1377
- const sendLauncher = this.getLauncherForConversation(sendConvId) || this.getLauncher();
1378
- const proc = sendLauncher.getByConversation(sendConvId);
1379
- if (!proc) {
1380
- const errMsg = `no active session for conversation ${sendConvId}`;
1381
- console.log(`[SiloLink] send_keys: ${errMsg}`);
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}`);
1382
1571
  reply({ success: false, error: errMsg });
1383
1572
  break;
1384
1573
  }
1574
+ const sendLauncher = sendTarget.launcher;
1575
+ const sendPaneConvId = sendTarget.conversationId;
1576
+ const proc = { processId: sendTarget.paneName };
1385
1577
  // TEXT + Enter goes through the verified path: a separate Enter plus
1386
1578
  // submit verification/retry. Sending both in one `tmux send-keys` and
1387
1579
  // trusting its exit code is what let a swallowed Enter strand an
@@ -1390,7 +1582,7 @@ export class Bridge {
1390
1582
  // Bare key tokens (Escape, C-c, arrows) and keys-without-Enter keep the
1391
1583
  // raw path — there is no submitted text to verify.
1392
1584
  if (rawKeys && appendEnter && isPrintable && !isToken) {
1393
- const sent = sendLauncher.sendTextAndSubmit(sendConvId, rawKeys);
1585
+ const sent = sendLauncher.sendTextAndSubmit(sendPaneConvId, rawKeys);
1394
1586
  if (sent) {
1395
1587
  console.log(`[SiloLink] send_keys: ${JSON.stringify(rawKeys)} + verified Enter → ${proc.processId} (from: ${cmd.requested_by || 'unknown'})`);
1396
1588
  reply({ success: true, session: proc.processId, keys: rawKeys, enter: true, verified: true });