instar 0.25.1 → 0.25.3

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.
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AA0PH,UAAU,YAAY;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;2DACuD;IACvD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAsnCD,wBAAsB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAu2GtE;AAED,wBAAsB,UAAU,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAsDzE;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuD5E"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AA0PH,UAAU,YAAY;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;2DACuD;IACvD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAsnCD,wBAAsB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAikHtE;AAED,wBAAsB,UAAU,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAsDzE;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuD5E"}
@@ -1701,17 +1701,44 @@ export async function startServer(options) {
1701
1701
  }
1702
1702
  // If handle() returned false, fall through to relay
1703
1703
  }
1704
- // Relay to Telegram if adapter is available and session has a topic binding
1704
+ // Relay to messaging platform if adapter is available and session has a binding
1705
1705
  if (classification.action === 'relay' || classification.action === 'auto-approve') {
1706
+ let relayed = false;
1707
+ // Try Telegram first
1706
1708
  if (telegram) {
1707
1709
  const topicId = telegram.getTopicForSession(prompt.sessionName);
1708
1710
  if (topicId) {
1709
1711
  try {
1710
1712
  await telegram.relayPrompt(topicId, prompt);
1711
- console.log(`[PromptGate] Relayed ${prompt.type} prompt to topic ${topicId}`);
1713
+ console.log(`[PromptGate] Relayed ${prompt.type} prompt to Telegram topic ${topicId}`);
1714
+ relayed = true;
1712
1715
  }
1713
1716
  catch (relayErr) {
1714
- console.error(`[PromptGate] Relay failed: ${relayErr instanceof Error ? relayErr.message : relayErr}`);
1717
+ console.error(`[PromptGate] Telegram relay failed: ${relayErr instanceof Error ? relayErr.message : relayErr}`);
1718
+ }
1719
+ }
1720
+ }
1721
+ // Try Slack if not already relayed via Telegram
1722
+ if (!relayed && _slackAdapter) {
1723
+ const channelId = _slackAdapter.getChannelForSession(prompt.sessionName);
1724
+ if (channelId) {
1725
+ try {
1726
+ const question = prompt.summary || 'Agent needs your input';
1727
+ const options = (prompt.options || []).map((opt, i) => ({
1728
+ label: opt.label.slice(0, 75),
1729
+ value: opt.key,
1730
+ primary: i === 0,
1731
+ }));
1732
+ if (options.length > 0) {
1733
+ await _slackAdapter.relayPrompt(channelId, prompt.id, question, options);
1734
+ }
1735
+ else {
1736
+ await _slackAdapter.sendToChannel(channelId, `⏳ *Agent needs your input:*\n${question}\n\n_Reply in this channel to respond._`);
1737
+ }
1738
+ console.log(`[PromptGate] Relayed ${prompt.type} prompt to Slack channel ${channelId}`);
1739
+ }
1740
+ catch (relayErr) {
1741
+ console.error(`[PromptGate] Slack relay failed: ${relayErr instanceof Error ? relayErr.message : relayErr}`);
1715
1742
  }
1716
1743
  }
1717
1744
  }
@@ -1803,6 +1830,29 @@ export async function startServer(options) {
1803
1830
  if (telemetryHeartbeat) {
1804
1831
  telemetryHeartbeat.start();
1805
1832
  }
1833
+ // Helper: resolve pending plan prompts when a prompt response arrives.
1834
+ // Calls the internal route endpoint to mark the plan prompt as resolved,
1835
+ // which unblocks the PreToolUse hook that's polling for the response.
1836
+ const resolvePlanPromptForSession = (sessionName, key) => {
1837
+ const port = config.port ?? 4042;
1838
+ const payload = JSON.stringify({ sessionName, key });
1839
+ const http = require('node:http');
1840
+ const req = http.request({
1841
+ hostname: '127.0.0.1',
1842
+ port,
1843
+ path: '/hooks/plan-prompt/resolve',
1844
+ method: 'POST',
1845
+ headers: {
1846
+ 'Content-Type': 'application/json',
1847
+ 'Content-Length': Buffer.byteLength(payload),
1848
+ 'Authorization': `Bearer ${config.authToken}`,
1849
+ },
1850
+ timeout: 2000,
1851
+ });
1852
+ req.on('error', () => { }); // Best-effort
1853
+ req.write(payload);
1854
+ req.end();
1855
+ };
1806
1856
  // Set up Telegram if configured
1807
1857
  // When --no-telegram is set (lifeline owns polling), create adapter in send-only mode
1808
1858
  // so the server can still relay replies via /telegram/reply/:topicId
@@ -1831,6 +1881,8 @@ export async function startServer(options) {
1831
1881
  console.warn(`[PromptGate] Skipping injection — session "${sessionName}" is no longer alive`);
1832
1882
  return false;
1833
1883
  }
1884
+ // Also resolve any pending plan prompt for this session (unblocks the hook)
1885
+ resolvePlanPromptForSession(sessionName, key);
1834
1886
  return sessionManager.sendKey(sessionName, key);
1835
1887
  };
1836
1888
  telegram.onPromptTextResponse = (sessionName, text) => {
@@ -1871,6 +1923,8 @@ export async function startServer(options) {
1871
1923
  console.warn(`[PromptGate] Skipping injection — session "${sessionName}" is no longer alive`);
1872
1924
  return false;
1873
1925
  }
1926
+ // Also resolve any pending plan prompt for this session (unblocks the hook)
1927
+ resolvePlanPromptForSession(sessionName, key);
1874
1928
  return sessionManager.sendKey(sessionName, key);
1875
1929
  };
1876
1930
  telegram.onPromptTextResponse = (sessionName, text) => {
@@ -2301,14 +2355,14 @@ export async function startServer(options) {
2301
2355
  lines.push('--- End Thread History ---');
2302
2356
  lines.push('');
2303
2357
  lines.push('CRITICAL: You MUST relay your response back to Slack after responding.');
2304
- lines.push('Use the relay script:');
2358
+ lines.push('Use the relay script (write ONLY your reply text — do NOT pipe or cat this file into the script):');
2305
2359
  lines.push('');
2306
2360
  lines.push(`cat <<'EOF' | .claude/scripts/slack-reply.sh ${channelId}`);
2307
2361
  lines.push('Your response text here');
2308
2362
  lines.push('EOF');
2309
2363
  lines.push('');
2310
2364
  lines.push('Strip the [slack:] prefix before interpreting the message.');
2311
- lines.push('Only relay conversational text — not tool output or internal reasoning.');
2365
+ lines.push('Only relay conversational text — not tool output, file contents, or internal reasoning.');
2312
2366
  const contextData = lines.join('\n');
2313
2367
  fs.writeFileSync(ctxPath, contextData);
2314
2368
  // Transform [image:path] and [document:path] tags into explicit read instructions
@@ -2364,8 +2418,7 @@ export async function startServer(options) {
2364
2418
  sessionManager.injectMessage(existingSession, bootstrapMessage);
2365
2419
  // Track for stall detection
2366
2420
  slackAdapter.trackMessageInjection(channelId, existingSession, message.content);
2367
- // Delivery confirmation
2368
- slackAdapter.sendToChannel(channelId, '✓ Delivered').catch(() => { });
2421
+ // Delivery confirmation via reaction only (no text message — the ✅ reaction is sufficient)
2369
2422
  }
2370
2423
  catch (injectErr) {
2371
2424
  console.error(`[slack→session] Injection failed: ${injectErr instanceof Error ? injectErr.message : injectErr}`);
@@ -2491,6 +2544,21 @@ export async function startServer(options) {
2491
2544
  slackAdapter.onIsSessionAlive = (tmuxSession) => {
2492
2545
  return sessionManager.isSessionAlive(tmuxSession);
2493
2546
  };
2547
+ // Wire prompt response callback — inject button presses into sessions
2548
+ slackAdapter.onPromptResponse = (channelId, promptId, value) => {
2549
+ // Look up which session is bound to this channel
2550
+ const sessionName = slackAdapter.getSessionForChannel(channelId);
2551
+ if (!sessionName) {
2552
+ console.warn(`[slack] Prompt response for channel ${channelId} but no session bound`);
2553
+ return;
2554
+ }
2555
+ if (!sessionManager.isSessionAlive(sessionName)) {
2556
+ console.warn(`[slack] Prompt response for dead session "${sessionName}"`);
2557
+ return;
2558
+ }
2559
+ sessionManager.sendKey(sessionName, value);
2560
+ console.log(`[slack] Prompt response injected: session="${sessionName}" key="${value}"`);
2561
+ };
2494
2562
  // Standby commands will be wired after PresenceProxy is initialized (below)
2495
2563
  }
2496
2564
  catch (err) {
@@ -2574,19 +2642,34 @@ export async function startServer(options) {
2574
2642
  sessionManager.startMonitoring();
2575
2643
  // Proactive resume heartbeat: every 60s, update the topic→UUID mapping
2576
2644
  // for all active topic-linked sessions. Ensures crash recovery via --resume.
2577
- if (_topicResumeMap && telegram) {
2645
+ if (_topicResumeMap && (telegram || _slackAdapter)) {
2578
2646
  const resumeHeartbeatInterval = setInterval(() => {
2579
2647
  try {
2580
- const topicSessions = telegram.getAllTopicSessions();
2581
- // Enrich with authoritative Claude session IDs from SessionManager
2582
2648
  const enriched = new Map();
2583
- for (const [topicId, sessionName] of topicSessions) {
2584
- const sessions = sessionManager.listRunningSessions();
2585
- const session = sessions.find(s => s.tmuxSession === sessionName);
2586
- enriched.set(topicId, {
2587
- sessionName,
2588
- claudeSessionId: session?.claudeSessionId ?? undefined,
2589
- });
2649
+ // Telegram topic-session mappings
2650
+ if (telegram) {
2651
+ const topicSessions = telegram.getAllTopicSessions();
2652
+ for (const [topicId, sessionName] of topicSessions) {
2653
+ const sessions = sessionManager.listRunningSessions();
2654
+ const session = sessions.find(s => s.tmuxSession === sessionName);
2655
+ enriched.set(topicId, {
2656
+ sessionName,
2657
+ claudeSessionId: session?.claudeSessionId ?? undefined,
2658
+ });
2659
+ }
2660
+ }
2661
+ // Slack channel-session mappings (use synthetic IDs for compatibility)
2662
+ if (_slackAdapter) {
2663
+ const registry = _slackAdapter.getChannelRegistry();
2664
+ for (const [channelId, entry] of Object.entries(registry)) {
2665
+ const syntheticId = slackChannelToSyntheticId(channelId);
2666
+ const sessions = sessionManager.listRunningSessions();
2667
+ const session = sessions.find(s => s.tmuxSession === entry.sessionName);
2668
+ enriched.set(syntheticId, {
2669
+ sessionName: entry.sessionName,
2670
+ claudeSessionId: session?.claudeSessionId ?? undefined,
2671
+ });
2672
+ }
2590
2673
  }
2591
2674
  _topicResumeMap?.refreshResumeMappings(enriched);
2592
2675
  }
@@ -2601,19 +2684,21 @@ export async function startServer(options) {
2601
2684
  // Save Claude session UUID before any session kill so the topic can be
2602
2685
  // resumed later with --resume. This fires BEFORE the tmux session is
2603
2686
  // destroyed, so the UUID can still be discovered from the JSONL mtime.
2604
- if (_topicResumeMap && telegram) {
2687
+ if (_topicResumeMap) {
2605
2688
  sessionManager.on('beforeSessionKill', (session) => {
2606
2689
  try {
2607
- // Save Telegram topic resume UUID
2608
- const topicId = telegram.getTopicForSession(session.tmuxSession);
2609
- if (topicId) {
2610
- const uuid = _topicResumeMap.findUuidForSession(session.tmuxSession, session.claudeSessionId ?? undefined);
2611
- if (uuid) {
2612
- _topicResumeMap.save(topicId, uuid, session.tmuxSession);
2613
- console.log(`[beforeSessionKill] Saved resume UUID ${uuid} for topic ${topicId} (session: ${session.name}, source: ${session.claudeSessionId ? 'hook' : 'mtime'})`);
2690
+ // Save Telegram topic resume UUID (if Telegram is configured)
2691
+ if (telegram) {
2692
+ const topicId = telegram.getTopicForSession(session.tmuxSession);
2693
+ if (topicId) {
2694
+ const uuid = _topicResumeMap.findUuidForSession(session.tmuxSession, session.claudeSessionId ?? undefined);
2695
+ if (uuid) {
2696
+ _topicResumeMap.save(topicId, uuid, session.tmuxSession);
2697
+ console.log(`[beforeSessionKill] Saved resume UUID ${uuid} for topic ${topicId} (session: ${session.name}, source: ${session.claudeSessionId ? 'hook' : 'mtime'})`);
2698
+ }
2614
2699
  }
2615
2700
  }
2616
- // Save Slack channel resume UUID
2701
+ // Save Slack channel resume UUID (if Slack is configured)
2617
2702
  if (_slackAdapter) {
2618
2703
  const channelId = _slackAdapter.getChannelForSession(session.tmuxSession);
2619
2704
  if (channelId) {
@@ -2740,53 +2825,94 @@ export async function startServer(options) {
2740
2825
  watchdog = new SessionWatchdog(config, sessionManager, state);
2741
2826
  watchdog.intelligence = sharedIntelligence ?? null;
2742
2827
  watchdog.on('intervention', (event) => {
2828
+ const levelNames = ['Monitoring', 'Ctrl+C', 'SIGTERM', 'SIGKILL', 'Kill Session'];
2829
+ const levelName = levelNames[event.level] || `Level ${event.level}`;
2830
+ const msg = `🔧 Watchdog [${levelName}]: ${event.action}\nStuck: \`${event.stuckCommand.slice(0, 60)}\``;
2743
2831
  if (telegram) {
2744
2832
  const topicId = telegram.getTopicForSession(event.sessionName);
2745
- if (topicId) {
2746
- const levelNames = ['Monitoring', 'Ctrl+C', 'SIGTERM', 'SIGKILL', 'Kill Session'];
2747
- const levelName = levelNames[event.level] || `Level ${event.level}`;
2748
- telegram.sendToTopic(topicId, `🔧 Watchdog [${levelName}]: ${event.action}\nStuck: \`${event.stuckCommand.slice(0, 60)}\``).catch(() => { });
2749
- }
2833
+ if (topicId)
2834
+ telegram.sendToTopic(topicId, msg).catch(() => { });
2835
+ }
2836
+ if (_slackAdapter) {
2837
+ const channelId = _slackAdapter.getChannelForSession(event.sessionName);
2838
+ if (channelId)
2839
+ _slackAdapter.sendToChannel(channelId, msg).catch(() => { });
2750
2840
  }
2751
2841
  });
2752
2842
  watchdog.on('recovery', (sessionName, fromLevel) => {
2843
+ const msg = `✅ Watchdog: session recovered (was at escalation level ${fromLevel})`;
2753
2844
  if (telegram) {
2754
2845
  const topicId = telegram.getTopicForSession(sessionName);
2755
- if (topicId) {
2756
- telegram.sendToTopic(topicId, `✅ Watchdog: session recovered (was at escalation level ${fromLevel})`).catch(() => { });
2757
- }
2846
+ if (topicId)
2847
+ telegram.sendToTopic(topicId, msg).catch(() => { });
2848
+ }
2849
+ if (_slackAdapter) {
2850
+ const channelId = _slackAdapter.getChannelForSession(sessionName);
2851
+ if (channelId)
2852
+ _slackAdapter.sendToChannel(channelId, msg).catch(() => { });
2758
2853
  }
2759
2854
  });
2760
2855
  watchdog.start();
2761
2856
  console.log(pc.green(' Session Watchdog enabled'));
2762
2857
  }
2763
2858
  // StallTriageNurse — LLM-powered session recovery (uses shared intelligence)
2859
+ // Platform-aware: works with Telegram topics AND Slack channels
2764
2860
  let triageNurse;
2765
- if (config.monitoring.triage?.enabled && telegram) {
2861
+ if (config.monitoring.triage?.enabled && (telegram || _slackAdapter)) {
2766
2862
  triageNurse = new StallTriageNurse({
2767
2863
  captureSessionOutput: (name, lines) => sessionManager.captureOutput(name, lines),
2768
2864
  isSessionAlive: (name) => sessionManager.isSessionAlive(name),
2769
2865
  sendKey: (name, key) => sessionManager.sendKey(name, key),
2770
2866
  sendInput: (name, text) => sessionManager.sendInput(name, text),
2771
2867
  getTopicHistory: (topicId, limit) => {
2772
- const entries = telegram.getTopicHistory(topicId, limit);
2773
- return entries.map(e => ({
2774
- text: e.text,
2775
- fromUser: e.fromUser,
2776
- timestamp: e.timestamp,
2777
- }));
2868
+ // Check if this is a Slack synthetic ID
2869
+ const slackChId = slackProxyChannelMap.get(topicId);
2870
+ if (slackChId && _slackAdapter) {
2871
+ const msgs = _slackAdapter.getChannelMessages(slackChId, limit);
2872
+ return msgs.map(m => ({ text: m.text, fromUser: true, timestamp: new Date(parseFloat(m.ts) * 1000).toISOString() }));
2873
+ }
2874
+ if (telegram) {
2875
+ const entries = telegram.getTopicHistory(topicId, limit);
2876
+ return entries.map(e => ({ text: e.text, fromUser: e.fromUser, timestamp: e.timestamp }));
2877
+ }
2878
+ return [];
2879
+ },
2880
+ sendToTopic: async (topicId, text) => {
2881
+ const slackChId = slackProxyChannelMap.get(topicId);
2882
+ if (slackChId && _slackAdapter) {
2883
+ await _slackAdapter.sendToChannel(slackChId, text);
2884
+ return;
2885
+ }
2886
+ if (telegram)
2887
+ await telegram.sendToTopic(topicId, text);
2888
+ },
2889
+ respawnSession: (name, topicId, options) => {
2890
+ if (telegram) {
2891
+ return respawnSessionForTopic(sessionManager, telegram, name, topicId, undefined, topicMemory, undefined, undefined, options);
2892
+ }
2893
+ // Slack respawn: kill and let next message trigger fresh session
2894
+ const stuckSession = sessionManager.listRunningSessions().find(s => s.tmuxSession === name);
2895
+ if (stuckSession)
2896
+ sessionManager.killSession(stuckSession.id);
2897
+ return Promise.resolve();
2898
+ },
2899
+ clearStallForTopic: (topicId) => {
2900
+ const slackChId = slackProxyChannelMap.get(topicId);
2901
+ if (slackChId && _slackAdapter) {
2902
+ _slackAdapter.clearStallTracking(slackChId);
2903
+ return;
2904
+ }
2905
+ if (telegram)
2906
+ telegram.clearStallTracking(topicId);
2778
2907
  },
2779
- sendToTopic: (topicId, text) => telegram.sendToTopic(topicId, text),
2780
- respawnSession: (name, topicId, options) => respawnSessionForTopic(sessionManager, telegram, name, topicId, undefined, topicMemory, undefined, undefined, options),
2781
- clearStallForTopic: (topicId) => telegram.clearStallTracking(topicId),
2782
2908
  }, {
2783
2909
  config: config.monitoring.triage,
2784
2910
  state,
2785
2911
  intelligence: sharedIntelligence,
2786
2912
  });
2787
- // Wire nurse into TelegramAdapter stall detection
2913
+ // Wire nurse into stall detection — both Telegram and Slack
2788
2914
  // Note: presenceProxy may be set later — use late-binding check
2789
- telegram.onStallDetected = async (topicId, sessionName, messageText, injectedAt) => {
2915
+ const stallTriageHandler = async (topicId, sessionName, messageText, injectedAt) => {
2790
2916
  // If PresenceProxy Tier 3 is actively handling this topic, defer to it
2791
2917
  if (presenceProxy) {
2792
2918
  const proxyState = presenceProxy.getState(topicId);
@@ -2801,27 +2927,58 @@ export async function startServer(options) {
2801
2927
  const result = await triageNurse.triage(topicId, sessionName, messageText, injectedAt, 'telegram_stall');
2802
2928
  return { resolved: result.resolved };
2803
2929
  };
2930
+ if (telegram) {
2931
+ telegram.onStallDetected = stallTriageHandler;
2932
+ }
2804
2933
  console.log(pc.green(' Stall Triage Nurse enabled'));
2805
2934
  }
2806
2935
  // TriageOrchestrator — next-gen session recovery with scoped Claude Code sessions
2936
+ // Platform-aware: works with Telegram topics AND Slack channels
2807
2937
  let triageOrchestrator;
2808
- if (config.monitoring.triageOrchestrator?.enabled && telegram) {
2938
+ if (config.monitoring.triageOrchestrator?.enabled && (telegram || _slackAdapter)) {
2809
2939
  triageOrchestrator = new TriageOrchestrator({
2810
2940
  captureSessionOutput: (name, lines) => sessionManager.captureOutput(name, lines),
2811
2941
  isSessionAlive: (name) => sessionManager.isSessionAlive(name),
2812
2942
  sendKey: (name, key) => sessionManager.sendKey(name, key),
2813
2943
  sendInput: (name, text) => sessionManager.sendInput(name, text),
2814
2944
  getTopicHistory: (topicId, limit) => {
2815
- const entries = telegram.getTopicHistory(topicId, limit);
2816
- return entries.map(e => ({
2817
- text: e.text,
2818
- fromUser: e.fromUser,
2819
- timestamp: e.timestamp,
2820
- }));
2945
+ const slackChId = slackProxyChannelMap.get(topicId);
2946
+ if (slackChId && _slackAdapter) {
2947
+ const msgs = _slackAdapter.getChannelMessages(slackChId, limit);
2948
+ return msgs.map(m => ({ text: m.text, fromUser: true, timestamp: new Date(parseFloat(m.ts) * 1000).toISOString() }));
2949
+ }
2950
+ if (telegram) {
2951
+ const entries = telegram.getTopicHistory(topicId, limit);
2952
+ return entries.map(e => ({ text: e.text, fromUser: e.fromUser, timestamp: e.timestamp }));
2953
+ }
2954
+ return [];
2955
+ },
2956
+ sendToTopic: async (topicId, text) => {
2957
+ const slackChId = slackProxyChannelMap.get(topicId);
2958
+ if (slackChId && _slackAdapter) {
2959
+ await _slackAdapter.sendToChannel(slackChId, text);
2960
+ return;
2961
+ }
2962
+ if (telegram)
2963
+ await telegram.sendToTopic(topicId, text);
2964
+ },
2965
+ respawnSession: (name, topicId, options) => {
2966
+ if (telegram)
2967
+ return respawnSessionForTopic(sessionManager, telegram, name, topicId, undefined, topicMemory, undefined, undefined, options);
2968
+ const stuckSession = sessionManager.listRunningSessions().find(s => s.tmuxSession === name);
2969
+ if (stuckSession)
2970
+ sessionManager.killSession(stuckSession.id);
2971
+ return Promise.resolve();
2972
+ },
2973
+ clearStallForTopic: (topicId) => {
2974
+ const slackChId = slackProxyChannelMap.get(topicId);
2975
+ if (slackChId && _slackAdapter) {
2976
+ _slackAdapter.clearStallTracking(slackChId);
2977
+ return;
2978
+ }
2979
+ if (telegram)
2980
+ telegram.clearStallTracking(topicId);
2821
2981
  },
2822
- sendToTopic: (topicId, text) => telegram.sendToTopic(topicId, text),
2823
- respawnSession: (name, topicId, options) => respawnSessionForTopic(sessionManager, telegram, name, topicId, undefined, topicMemory, undefined, undefined, options),
2824
- clearStallForTopic: (topicId) => telegram.clearStallTracking(topicId),
2825
2982
  spawnTriageSession: (name, options) => sessionManager.spawnTriageSession(name, options),
2826
2983
  getTriageSessionUuid: (sessionName) => {
2827
2984
  return _topicResumeMap?.findUuidForSession(sessionName) ?? undefined;
@@ -2862,33 +3019,37 @@ export async function startServer(options) {
2862
3019
  state,
2863
3020
  });
2864
3021
  // TriageOrchestrator takes over stall detection from StallTriageNurse
2865
- telegram.onStallDetected = async (topicId, sessionName, messageText, injectedAt) => {
3022
+ const triageStallHandler = async (topicId, sessionName, messageText, injectedAt) => {
2866
3023
  const result = await triageOrchestrator.activate(topicId, sessionName, 'stall_detector', messageText, injectedAt);
2867
3024
  return { resolved: result.resolved };
2868
3025
  };
2869
- // Cancel triage when stall tracking clears (session responded)
2870
- const origClearStall = telegram.clearStallTracking.bind(telegram);
2871
- telegram.clearStallTracking = (topicId) => {
2872
- origClearStall(topicId);
2873
- triageOrchestrator.onTargetSessionResponded(topicId);
2874
- };
2875
- // Wire /triage command
2876
- telegram.onGetTriageStatus = (topicId) => {
2877
- const ts = triageOrchestrator.getTriageState(topicId);
2878
- if (!ts)
2879
- return null;
2880
- return {
2881
- active: true,
2882
- classification: ts.classification,
2883
- checkCount: ts.checkCount,
2884
- lastCheck: new Date(ts.lastCheckAt).toISOString(),
3026
+ if (telegram) {
3027
+ telegram.onStallDetected = triageStallHandler;
3028
+ // Cancel triage when stall tracking clears (session responded)
3029
+ const origClearStall = telegram.clearStallTracking.bind(telegram);
3030
+ telegram.clearStallTracking = (topicId) => {
3031
+ origClearStall(topicId);
3032
+ triageOrchestrator.onTargetSessionResponded(topicId);
2885
3033
  };
2886
- };
3034
+ // Wire /triage command
3035
+ telegram.onGetTriageStatus = (topicId) => {
3036
+ const ts = triageOrchestrator.getTriageState(topicId);
3037
+ if (!ts)
3038
+ return null;
3039
+ return {
3040
+ active: true,
3041
+ classification: ts.classification,
3042
+ checkCount: ts.checkCount,
3043
+ lastCheck: new Date(ts.lastCheckAt).toISOString(),
3044
+ };
3045
+ };
3046
+ }
2887
3047
  console.log(pc.green(' Triage Orchestrator enabled (replaces Stall Triage Nurse for stall detection)'));
2888
3048
  }
2889
3049
  // SessionRecovery — fast mechanical recovery (JSONL analysis, no LLM)
3050
+ // Platform-aware: works with Telegram topics AND Slack channels
2890
3051
  let sessionRecovery;
2891
- if (telegram) {
3052
+ if (telegram || _slackAdapter) {
2892
3053
  sessionRecovery = new SessionRecovery({ enabled: true, projectDir: config.projectDir }, {
2893
3054
  isSessionAlive: (name) => sessionManager.isSessionAlive(name),
2894
3055
  getPanePid: (name) => {
@@ -2904,6 +3065,13 @@ export async function startServer(options) {
2904
3065
  }
2905
3066
  },
2906
3067
  killSession: (name) => {
3068
+ // Route through SessionManager to fire beforeSessionKill hook
3069
+ const session = sessionManager.listRunningSessions().find(s => s.tmuxSession === name);
3070
+ if (session) {
3071
+ sessionManager.killSession(session.id);
3072
+ return;
3073
+ }
3074
+ // Fallback: direct tmux kill for untracked sessions
2907
3075
  try {
2908
3076
  const tmux = detectTmuxPath();
2909
3077
  if (!tmux)
@@ -2913,32 +3081,86 @@ export async function startServer(options) {
2913
3081
  catch { /* may already be dead */ }
2914
3082
  },
2915
3083
  respawnSession: async (topicId, _sessionName, recoveryPrompt) => {
2916
- const targetSession = telegram.getSessionForTopic(topicId);
2917
- if (!targetSession)
3084
+ // Check Slack first (synthetic IDs are negative)
3085
+ const slackChId = slackProxyChannelMap.get(topicId);
3086
+ if (slackChId && _slackAdapter) {
3087
+ // Slack respawn: kill existing, next message triggers fresh session
3088
+ const session = sessionManager.listRunningSessions().find(s => s.tmuxSession === _sessionName);
3089
+ if (session)
3090
+ sessionManager.killSession(session.id);
2918
3091
  return;
2919
- await respawnSessionForTopic(sessionManager, telegram, targetSession, topicId, undefined, topicMemory, undefined, recoveryPrompt, { silent: true });
3092
+ }
3093
+ if (telegram) {
3094
+ const targetSession = telegram.getSessionForTopic(topicId);
3095
+ if (!targetSession)
3096
+ return;
3097
+ await respawnSessionForTopic(sessionManager, telegram, targetSession, topicId, undefined, topicMemory, undefined, recoveryPrompt, { silent: true });
3098
+ }
3099
+ },
3100
+ sendToTopic: async (topicId, message) => {
3101
+ const slackChId = slackProxyChannelMap.get(topicId);
3102
+ if (slackChId && _slackAdapter) {
3103
+ await _slackAdapter.sendToChannel(slackChId, message);
3104
+ return;
3105
+ }
3106
+ if (telegram)
3107
+ await telegram.sendToTopic(topicId, message);
2920
3108
  },
2921
- sendToTopic: async (topicId, message) => { await telegram.sendToTopic(topicId, message); },
2922
3109
  });
2923
3110
  console.log(pc.green(' Session Recovery enabled (mechanical fast-path)'));
2924
3111
  }
2925
3112
  // SessionMonitor — proactive session health monitoring
3113
+ // Platform-aware: monitors both Telegram and Slack sessions
2926
3114
  let sessionMonitor;
2927
- if (telegram) {
3115
+ if (telegram || _slackAdapter) {
2928
3116
  sessionMonitor = new SessionMonitor({
2929
- getActiveTopicSessions: () => telegram.getActiveTopicSessions(),
3117
+ getActiveTopicSessions: () => {
3118
+ const sessions = new Map();
3119
+ // Telegram topic sessions
3120
+ if (telegram && telegram.getActiveTopicSessions) {
3121
+ const telegramSessions = telegram.getActiveTopicSessions();
3122
+ for (const [topicId, sessionName] of telegramSessions) {
3123
+ sessions.set(topicId, sessionName);
3124
+ }
3125
+ }
3126
+ // Slack channel sessions (using synthetic IDs)
3127
+ if (_slackAdapter) {
3128
+ const registry = _slackAdapter.getChannelRegistry();
3129
+ for (const [channelId, entry] of Object.entries(registry)) {
3130
+ const syntheticId = slackChannelToSyntheticId(channelId);
3131
+ sessions.set(syntheticId, entry.sessionName);
3132
+ }
3133
+ }
3134
+ return sessions;
3135
+ },
2930
3136
  captureSessionOutput: (name, lines) => sessionManager.captureOutput(name, lines),
2931
3137
  isSessionAlive: (name) => sessionManager.isSessionAlive(name),
2932
3138
  getTopicHistory: (topicId, limit) => {
2933
- const history = telegram.getMessageLog?.();
2934
- if (!history)
2935
- return [];
2936
- return history
2937
- .filter((m) => m.topicId === topicId)
2938
- .slice(-limit)
2939
- .map((m) => ({ text: m.text, fromUser: m.fromUser, timestamp: m.timestamp }));
3139
+ const slackChId = slackProxyChannelMap.get(topicId);
3140
+ if (slackChId && _slackAdapter) {
3141
+ const msgs = _slackAdapter.getChannelMessages(slackChId, limit);
3142
+ return msgs.map(m => ({ text: m.text, fromUser: true, timestamp: new Date(parseFloat(m.ts) * 1000).toISOString() }));
3143
+ }
3144
+ if (telegram) {
3145
+ const history = telegram.getMessageLog?.();
3146
+ if (!history)
3147
+ return [];
3148
+ return history
3149
+ .filter((m) => m.topicId === topicId)
3150
+ .slice(-limit)
3151
+ .map((m) => ({ text: m.text, fromUser: m.fromUser, timestamp: m.timestamp }));
3152
+ }
3153
+ return [];
3154
+ },
3155
+ sendToTopic: async (topicId, text) => {
3156
+ const slackChId = slackProxyChannelMap.get(topicId);
3157
+ if (slackChId && _slackAdapter) {
3158
+ await _slackAdapter.sendToChannel(slackChId, text);
3159
+ return;
3160
+ }
3161
+ if (telegram)
3162
+ await telegram.sendToTopic(topicId, text);
2940
3163
  },
2941
- sendToTopic: (topicId, text) => telegram.sendToTopic(topicId, text),
2942
3164
  triggerTriage: triageOrchestrator
2943
3165
  ? async (topicId, sessionName, reason) => {
2944
3166
  const result = await triageOrchestrator.activate(topicId, sessionName, 'watchdog', reason, Date.now());
@@ -4336,23 +4558,38 @@ export async function startServer(options) {
4336
4558
  // 1. Resume entries are consumed (removed) on spawn
4337
4559
  // 2. Proactive save may not have run yet
4338
4560
  // 3. beforeSessionKill doesn't fire for bulk process exit
4339
- if (_topicResumeMap && telegram) {
4561
+ if (_topicResumeMap) {
4340
4562
  try {
4341
4563
  const runningSessions = sessionManager.listRunningSessions();
4342
- const topicSessions = telegram.getAllTopicSessions?.();
4343
- if (topicSessions) {
4344
- let saved = 0;
4345
- for (const [topicId, sessionName] of topicSessions) {
4346
- const session = runningSessions.find(s => s.tmuxSession === sessionName);
4347
- const uuid = _topicResumeMap.findUuidForSession(sessionName, session?.claudeSessionId ?? undefined);
4564
+ let saved = 0;
4565
+ // Save Telegram topic resume UUIDs
4566
+ if (telegram) {
4567
+ const topicSessions = telegram.getAllTopicSessions?.();
4568
+ if (topicSessions) {
4569
+ for (const [topicId, sessionName] of topicSessions) {
4570
+ const session = runningSessions.find(s => s.tmuxSession === sessionName);
4571
+ const uuid = _topicResumeMap.findUuidForSession(sessionName, session?.claudeSessionId ?? undefined);
4572
+ if (uuid) {
4573
+ _topicResumeMap.save(topicId, uuid, sessionName);
4574
+ saved++;
4575
+ }
4576
+ }
4577
+ }
4578
+ }
4579
+ // Save Slack channel resume UUIDs
4580
+ if (_slackAdapter) {
4581
+ const registry = _slackAdapter.getChannelRegistry();
4582
+ for (const [channelId, entry] of Object.entries(registry)) {
4583
+ const session = runningSessions.find(s => s.tmuxSession === entry.sessionName);
4584
+ const uuid = _topicResumeMap.findUuidForSession(entry.sessionName, session?.claudeSessionId ?? undefined);
4348
4585
  if (uuid) {
4349
- _topicResumeMap.save(topicId, uuid, sessionName);
4586
+ _slackAdapter.saveChannelResume(channelId, uuid, entry.sessionName);
4350
4587
  saved++;
4351
4588
  }
4352
4589
  }
4353
- if (saved > 0) {
4354
- console.log(`[shutdown] Saved ${saved} resume UUID(s) for active sessions`);
4355
- }
4590
+ }
4591
+ if (saved > 0) {
4592
+ console.log(`[shutdown] Saved ${saved} resume UUID(s) for active sessions`);
4356
4593
  }
4357
4594
  }
4358
4595
  catch (err) {