@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.
Files changed (71) hide show
  1. package/dist/api/backend-health.d.ts +44 -0
  2. package/dist/api/backend-health.d.ts.map +1 -0
  3. package/dist/api/backend-health.js +80 -0
  4. package/dist/api/backend-health.js.map +1 -0
  5. package/dist/api/dsiloed-client.d.ts +7 -3
  6. package/dist/api/dsiloed-client.d.ts.map +1 -1
  7. package/dist/api/dsiloed-client.js +29 -4
  8. package/dist/api/dsiloed-client.js.map +1 -1
  9. package/dist/cli/claude-md-block.d.ts +1 -1
  10. package/dist/cli/claude-md-block.d.ts.map +1 -1
  11. package/dist/cli/claude-md-block.js +3 -1
  12. package/dist/cli/claude-md-block.js.map +1 -1
  13. package/dist/core/agent-launcher.d.ts +7 -0
  14. package/dist/core/agent-launcher.d.ts.map +1 -1
  15. package/dist/core/auto-updater.d.ts +11 -0
  16. package/dist/core/auto-updater.d.ts.map +1 -1
  17. package/dist/core/auto-updater.js +84 -15
  18. package/dist/core/auto-updater.js.map +1 -1
  19. package/dist/core/base-tmux-launcher.d.ts +24 -1
  20. package/dist/core/base-tmux-launcher.d.ts.map +1 -1
  21. package/dist/core/base-tmux-launcher.js +73 -3
  22. package/dist/core/base-tmux-launcher.js.map +1 -1
  23. package/dist/core/bridge.d.ts +28 -0
  24. package/dist/core/bridge.d.ts.map +1 -1
  25. package/dist/core/bridge.js +208 -21
  26. package/dist/core/bridge.js.map +1 -1
  27. package/dist/core/claude-launcher.d.ts +4 -2
  28. package/dist/core/claude-launcher.d.ts.map +1 -1
  29. package/dist/core/claude-launcher.js +29 -8
  30. package/dist/core/claude-launcher.js.map +1 -1
  31. package/dist/core/codex-launcher.d.ts +2 -2
  32. package/dist/core/codex-launcher.d.ts.map +1 -1
  33. package/dist/core/codex-launcher.js +2 -2
  34. package/dist/core/codex-launcher.js.map +1 -1
  35. package/dist/core/gemini-launcher.d.ts +2 -2
  36. package/dist/core/gemini-launcher.d.ts.map +1 -1
  37. package/dist/core/gemini-launcher.js +2 -2
  38. package/dist/core/gemini-launcher.js.map +1 -1
  39. package/dist/core/health-monitor.d.ts +87 -0
  40. package/dist/core/health-monitor.d.ts.map +1 -0
  41. package/dist/core/health-monitor.js +93 -0
  42. package/dist/core/health-monitor.js.map +1 -0
  43. package/dist/core/idle-completion.d.ts +38 -0
  44. package/dist/core/idle-completion.d.ts.map +1 -0
  45. package/dist/core/idle-completion.js +52 -0
  46. package/dist/core/idle-completion.js.map +1 -0
  47. package/dist/core/launch-binding.d.ts +52 -0
  48. package/dist/core/launch-binding.d.ts.map +1 -0
  49. package/dist/core/launch-binding.js +81 -0
  50. package/dist/core/launch-binding.js.map +1 -0
  51. package/dist/core/usage-ledger.d.ts +1 -1
  52. package/dist/core/usage-ledger.d.ts.map +1 -1
  53. package/dist/mcp/server.d.ts +4 -0
  54. package/dist/mcp/server.d.ts.map +1 -1
  55. package/dist/mcp/server.js +15 -0
  56. package/dist/mcp/server.js.map +1 -1
  57. package/dist/mcp/tools/register-tools.d.ts +2 -0
  58. package/dist/mcp/tools/register-tools.d.ts.map +1 -1
  59. package/dist/mcp/tools/register-tools.js +154 -36
  60. package/dist/mcp/tools/register-tools.js.map +1 -1
  61. package/dist/types/index.d.ts +5 -0
  62. package/dist/types/index.d.ts.map +1 -1
  63. package/package.json +3 -1
  64. package/dist/core/baseline-verify.d.ts +0 -54
  65. package/dist/core/baseline-verify.d.ts.map +0 -1
  66. package/dist/core/baseline-verify.js +0 -164
  67. package/dist/core/baseline-verify.js.map +0 -1
  68. package/dist/core/openai-launcher.d.ts +0 -33
  69. package/dist/core/openai-launcher.d.ts.map +0 -1
  70. package/dist/core/openai-launcher.js +0 -38
  71. package/dist/core/openai-launcher.js.map +0 -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;
@@ -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. 'failed' is kept so a
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 intLauncher = this.getLauncherForConversation(intConvId) || this.getLauncher();
1282
- const interrupted = await intLauncher.interrupt(intConvId);
1283
- 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
+ }
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
- // Also nudge via tmux in case the session isn't polling
1313
- const nudgeLauncher = this.getLauncherForConversation(nudgeConvId) || this.getLauncher();
1314
- nudgeLauncher.nudge();
1315
- 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}`);
1316
1483
  }
1317
1484
  else {
1318
- 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`);
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 sendLauncher = this.getLauncherForConversation(sendConvId) || this.getLauncher();
1383
- const proc = sendLauncher.getByConversation(sendConvId);
1384
- if (!proc) {
1385
- const errMsg = `no active session for conversation ${sendConvId}`;
1386
- 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}`);
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(sendConvId, rawKeys);
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 });