@chrisromp/copilot-bridge 0.10.0 → 0.12.0

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 (38) hide show
  1. package/README.md +4 -0
  2. package/config.sample.json +20 -0
  3. package/dist/config.d.ts +9 -1
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +117 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/core/bridge-docs.d.ts +1 -1
  8. package/dist/core/bridge-docs.d.ts.map +1 -1
  9. package/dist/core/bridge-docs.js +133 -1
  10. package/dist/core/bridge-docs.js.map +1 -1
  11. package/dist/core/bridge.d.ts +19 -2
  12. package/dist/core/bridge.d.ts.map +1 -1
  13. package/dist/core/bridge.js +29 -5
  14. package/dist/core/bridge.js.map +1 -1
  15. package/dist/core/command-handler.d.ts +16 -4
  16. package/dist/core/command-handler.d.ts.map +1 -1
  17. package/dist/core/command-handler.js +258 -51
  18. package/dist/core/command-handler.js.map +1 -1
  19. package/dist/core/model-fallback.d.ts +2 -2
  20. package/dist/core/model-fallback.d.ts.map +1 -1
  21. package/dist/core/model-fallback.js +11 -4
  22. package/dist/core/model-fallback.js.map +1 -1
  23. package/dist/core/quiet-mode.js +1 -1
  24. package/dist/core/session-manager.d.ts +40 -1
  25. package/dist/core/session-manager.d.ts.map +1 -1
  26. package/dist/core/session-manager.js +316 -36
  27. package/dist/core/session-manager.js.map +1 -1
  28. package/dist/index.js +243 -48
  29. package/dist/index.js.map +1 -1
  30. package/dist/state/store.d.ts +1 -0
  31. package/dist/state/store.d.ts.map +1 -1
  32. package/dist/state/store.js +13 -2
  33. package/dist/state/store.js.map +1 -1
  34. package/dist/types.d.ts +20 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/package.json +2 -2
  37. package/templates/admin/AGENTS.md +38 -38
  38. package/templates/agents/AGENTS.md +0 -49
package/dist/index.js CHANGED
@@ -19,6 +19,11 @@ import path from 'node:path';
19
19
  const log = createLogger('bridge');
20
20
  // Active streaming responses, keyed by channelId
21
21
  const activeStreams = new Map(); // channelId → streamKey
22
+ // Channels where the no_reply tool was called — used to suppress the SDK's
23
+ // second agentic turn (which always fires after a tool call).
24
+ const noReplyChannels = new Set();
25
+ // Tracks whether content was emitted after no_reply (second turn succeeded)
26
+ const noReplyHadContent = new Set();
22
27
  // Preserve thread context across turn_end stream finalization so auto-started
23
28
  // streams stay in the same thread.
24
29
  const channelThreadRoots = new Map(); // channelId → threadRootId
@@ -32,7 +37,7 @@ const channelLocks = new Map();
32
37
  // Per-channel promise chain to serialize SESSION EVENT handling (prevents race on auto-start)
33
38
  const eventLocks = new Map();
34
39
  // Channels in "quiet mode" — all streaming output suppressed until we determine
35
- // whether the response is NO_REPLY. Used for startup nudges and scheduled tasks.
40
+ // whether the response is NO_REPLY. Used for scheduled tasks and silent cron jobs.
36
41
  // State managed in src/core/quiet-mode.ts
37
42
  // Bot adapters keyed by "platform:botName" for channel→adapter lookup
38
43
  const botAdapters = new Map();
@@ -482,7 +487,7 @@ async function main() {
482
487
  log.info(`Auto-registered DM channel ${dm.channelId.slice(0, 8)}... for bot "${botName}"`);
483
488
  }
484
489
  else {
485
- // Mark pre-configured DM channels so nudge logic can identify them
490
+ // Mark pre-configured DM channels so restart notice logic can identify them
486
491
  markChannelAsDM(dm.channelId);
487
492
  }
488
493
  }
@@ -547,8 +552,8 @@ async function main() {
547
552
  }
548
553
  },
549
554
  });
550
- // Nudge admin bot sessions that may have been mid-task before restart
551
- nudgeAdminSessions(sessionManager).catch(err => log.error('Admin nudge failed:', err));
555
+ // Post restart notice to admin DM channels (no session creation needed)
556
+ postRestartNotices();
552
557
  // Graceful shutdown
553
558
  const shutdown = async () => {
554
559
  log.info('Shutting down...');
@@ -725,7 +730,7 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
725
730
  }
726
731
  const mcpInfo = undefined;
727
732
  const contextUsage = sessionManager.getContextUsage(msg.channelId);
728
- const cmdResult = handleCommand(msg.channelId, commandText, sessionInfo ?? undefined, { verbose: effPrefs.verbose, permissionMode: effPrefs.permissionMode, reasoningEffort: effPrefs.reasoningEffort }, { workingDirectory: channelConfig.workingDirectory, bot: channelConfig.bot }, models, mcpInfo, contextUsage);
733
+ const cmdResult = handleCommand(msg.channelId, commandText, sessionInfo ?? undefined, { verbose: effPrefs.verbose, permissionMode: effPrefs.permissionMode, reasoningEffort: effPrefs.reasoningEffort }, { workingDirectory: channelConfig.workingDirectory, bot: channelConfig.bot }, models, mcpInfo, contextUsage, getConfig().providers);
729
734
  if (cmdResult.handled) {
730
735
  // Model/agent switch while busy — defer to serialized path
731
736
  if (cmdResult.action === 'switch_model' || cmdResult.action === 'switch_agent') {
@@ -763,12 +768,82 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
763
768
  }
764
769
  catch { /* best-effort */ }
765
770
  }
771
+ /** Test BYOK provider connectivity by hitting its models endpoint. */
772
+ async function testProviderConnectivity(providerName) {
773
+ const providers = getConfig().providers ?? {};
774
+ const provider = providers[providerName];
775
+ if (!provider)
776
+ return `⚠️ Provider "${providerName}" not found in config.`;
777
+ const baseUrl = provider.baseUrl.replace(/\/+$/, '');
778
+ const modelsUrl = `${baseUrl}/models`;
779
+ // Resolve auth
780
+ let apiKey = provider.apiKey;
781
+ if (!apiKey && provider.apiKeyEnv)
782
+ apiKey = process.env[provider.apiKeyEnv];
783
+ let bearerToken = provider.bearerToken;
784
+ if (!bearerToken && provider.bearerTokenEnv)
785
+ bearerToken = process.env[provider.bearerTokenEnv];
786
+ const headers = { 'Accept': 'application/json' };
787
+ if (apiKey)
788
+ headers['Authorization'] = `Bearer ${apiKey}`;
789
+ else if (bearerToken)
790
+ headers['Authorization'] = `Bearer ${bearerToken}`;
791
+ const startTime = Date.now();
792
+ try {
793
+ const controller = new AbortController();
794
+ const timeout = setTimeout(() => controller.abort(), 10_000);
795
+ const response = await fetch(modelsUrl, { headers, signal: controller.signal });
796
+ clearTimeout(timeout);
797
+ const elapsed = Date.now() - startTime;
798
+ if (!response.ok) {
799
+ return `❌ Provider "${providerName}" returned HTTP ${response.status} ${response.statusText}\n URL: \`${modelsUrl}\``;
800
+ }
801
+ const data = await response.json();
802
+ const modelCount = Array.isArray(data?.data) ? data.data.length : '?';
803
+ const configuredModels = provider.models.map(m => m.id);
804
+ const lines = [
805
+ `✅ Provider "${providerName}" is reachable (${elapsed}ms)`,
806
+ ` URL: \`${modelsUrl}\``,
807
+ ` Remote models: ${modelCount}`,
808
+ ` Configured: ${configuredModels.map(m => `\`${m}\``).join(', ')}`,
809
+ ];
810
+ // Check if configured models exist on the remote
811
+ if (Array.isArray(data?.data)) {
812
+ const remoteIds = new Set(data.data.map((m) => m.id));
813
+ const missing = configuredModels.filter(id => !remoteIds.has(id));
814
+ if (missing.length > 0) {
815
+ lines.push(` ⚠️ Not found on remote: ${missing.map(m => `\`${m}\``).join(', ')}`);
816
+ }
817
+ }
818
+ return lines.join('\n');
819
+ }
820
+ catch (err) {
821
+ const elapsed = Date.now() - startTime;
822
+ if (err?.name === 'AbortError') {
823
+ return `❌ Provider "${providerName}" timed out after 10s\n URL: \`${modelsUrl}\``;
824
+ }
825
+ const msg = String(err?.message ?? err);
826
+ if (msg.includes('ECONNREFUSED')) {
827
+ return `❌ Provider "${providerName}" connection refused\n URL: \`${modelsUrl}\`\n Is the service running?`;
828
+ }
829
+ if (msg.includes('ENOTFOUND')) {
830
+ return `❌ Provider "${providerName}" hostname not found\n URL: \`${modelsUrl}\``;
831
+ }
832
+ return `❌ Provider "${providerName}" failed (${elapsed}ms): ${msg}\n URL: \`${modelsUrl}\``;
833
+ }
834
+ }
766
835
  async function handleInboundMessage(msg, sessionManager, platformName, botName) {
767
836
  // Ignore messages from any bot we manage on this platform (prevents cross-bot loops)
768
837
  for (const [key, a] of botAdapters) {
769
838
  if (key.startsWith(`${platformName}:`) && msg.userId === a.getBotUserId())
770
839
  return;
771
840
  }
841
+ // Clear stale no_reply flags from previous turn.
842
+ // Note: a late post-no_reply error could theoretically arrive after this
843
+ // clear if the user types very quickly after restart, but post-no_reply errors take
844
+ // ~30s and users rarely type during that window.
845
+ noReplyChannels.delete(msg.channelId);
846
+ noReplyHadContent.delete(msg.channelId);
772
847
  // Check user-level access control (reads live config — hot-reloadable)
773
848
  const botInfo = getPlatformBots(platformName).get(botName);
774
849
  if (!checkUserAccess(msg.userId, msg.username, botInfo?.access, getPlatformAccess(platformName))) {
@@ -847,11 +922,11 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
847
922
  }
848
923
  // Get cached context usage for /context and /status
849
924
  const contextUsage = sessionManager.getContextUsage(msg.channelId);
850
- const cmdResult = handleCommand(msg.channelId, text, sessionInfo ?? undefined, { verbose: effPrefs.verbose, permissionMode: effPrefs.permissionMode, reasoningEffort: effPrefs.reasoningEffort }, { workingDirectory: channelConfig.workingDirectory, bot: channelConfig.bot }, models, undefined, contextUsage);
925
+ const cmdResult = handleCommand(msg.channelId, text, sessionInfo ?? undefined, { verbose: effPrefs.verbose, permissionMode: effPrefs.permissionMode, reasoningEffort: effPrefs.reasoningEffort }, { workingDirectory: channelConfig.workingDirectory, bot: channelConfig.bot }, models, undefined, contextUsage, getConfig().providers);
851
926
  if (cmdResult.handled) {
852
927
  const threadRoot = resolveThreadRoot(msg, threadRequested, channelConfig);
853
928
  // Send response before action, except for actions that send their own ack after completing
854
- const deferResponse = cmdResult.action === 'switch_model' || cmdResult.action === 'switch_agent' || cmdResult.action === 'set_reasoning';
929
+ const deferResponse = cmdResult.action === 'switch_model' || cmdResult.action === 'switch_agent' || cmdResult.action === 'set_reasoning' || cmdResult.action === 'reload_mcp' || cmdResult.action === 'reload_skills';
855
930
  if (cmdResult.response && !deferResponse) {
856
931
  await adapter.sendMessage(msg.channelId, cmdResult.response, { threadRootId: threadRoot });
857
932
  }
@@ -930,6 +1005,30 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
930
1005
  await adapter.updateMessage(msg.channelId, ackId, reloadMsg);
931
1006
  break;
932
1007
  }
1008
+ case 'reload_mcp': {
1009
+ const mcpAck = await adapter.sendMessage(msg.channelId, '⏳ Reloading MCP servers...', { threadRootId: threadRoot });
1010
+ try {
1011
+ await sessionManager.reloadMcp(msg.channelId);
1012
+ await adapter.updateMessage(msg.channelId, mcpAck, '✅ MCP servers reloaded.');
1013
+ }
1014
+ catch (err) {
1015
+ log.warn(`MCP reload failed for ${msg.channelId}:`, err);
1016
+ await adapter.updateMessage(msg.channelId, mcpAck, `❌ MCP reload failed: ${err?.message ?? err}`);
1017
+ }
1018
+ break;
1019
+ }
1020
+ case 'reload_skills': {
1021
+ const skillsAck = await adapter.sendMessage(msg.channelId, '⏳ Reloading skills...', { threadRootId: threadRoot });
1022
+ try {
1023
+ await sessionManager.reloadSkills(msg.channelId);
1024
+ await adapter.updateMessage(msg.channelId, skillsAck, '✅ Skills reloaded.');
1025
+ }
1026
+ catch (err) {
1027
+ log.warn(`Skills reload failed for ${msg.channelId}:`, err);
1028
+ await adapter.updateMessage(msg.channelId, skillsAck, `❌ Skills reload failed: ${err?.message ?? err}`);
1029
+ }
1030
+ break;
1031
+ }
933
1032
  case 'resume_session': {
934
1033
  const oldResumeStream = activeStreams.get(msg.channelId);
935
1034
  if (oldResumeStream) {
@@ -999,7 +1098,8 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
999
1098
  case 'switch_model': {
1000
1099
  const ackId = await adapter.sendMessage(msg.channelId, '⏳ Switching model...', { threadRootId: threadRoot });
1001
1100
  try {
1002
- await sessionManager.switchModel(msg.channelId, cmdResult.payload);
1101
+ const { modelId, provider } = cmdResult.payload;
1102
+ await sessionManager.switchModel(msg.channelId, modelId, provider);
1003
1103
  await adapter.updateMessage(msg.channelId, ackId, cmdResult.response ?? '✅ Model switched.');
1004
1104
  }
1005
1105
  catch (err) {
@@ -1020,6 +1120,19 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1020
1120
  }
1021
1121
  break;
1022
1122
  }
1123
+ case 'provider_test': {
1124
+ const providerName = cmdResult.payload;
1125
+ const ackId = await adapter.sendMessage(msg.channelId, cmdResult.response ?? `🔄 Testing provider "${providerName}"...`, { threadRootId: threadRoot });
1126
+ try {
1127
+ const result = await testProviderConnectivity(providerName);
1128
+ await adapter.updateMessage(msg.channelId, ackId, result);
1129
+ }
1130
+ catch (err) {
1131
+ log.error(`Provider test failed for "${providerName}":`, err);
1132
+ await adapter.updateMessage(msg.channelId, ackId, `❌ Provider test failed: ${err?.message ?? 'unknown error'}`);
1133
+ }
1134
+ break;
1135
+ }
1023
1136
  case 'set_reasoning': {
1024
1137
  const reasoningSessionId = sessionManager.getSessionId(msg.channelId);
1025
1138
  if (!reasoningSessionId) {
@@ -1029,14 +1142,12 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1029
1142
  }
1030
1143
  const ackId = await adapter.sendMessage(msg.channelId, `🧠 Setting reasoning effort to **${cmdResult.payload}**...`, { threadRootId: threadRoot });
1031
1144
  try {
1032
- const newId = await sessionManager.reloadSession(msg.channelId);
1033
- const wasNew = newId !== reasoningSessionId;
1034
- const suffix = wasNew ? ' (previous session expired — new session created)' : '';
1035
- await adapter.updateMessage(msg.channelId, ackId, `🧠 Reasoning effort set to **${cmdResult.payload}**.${suffix}`);
1145
+ await sessionManager.setReasoningEffort(msg.channelId, cmdResult.payload);
1146
+ await adapter.updateMessage(msg.channelId, ackId, `🧠 Reasoning effort set to **${cmdResult.payload}**.`);
1036
1147
  }
1037
1148
  catch (err) {
1038
- log.error(`Failed to reload session for reasoning on ${msg.channelId.slice(0, 8)}...:`, err);
1039
- await adapter.updateMessage(msg.channelId, ackId, `🧠 Reasoning effort saved as **${cmdResult.payload}** but session reload failed. Use \`/reload\` to apply.`);
1149
+ log.error(`Failed to set reasoning effort on ${msg.channelId.slice(0, 8)}...:`, err);
1150
+ await adapter.updateMessage(msg.channelId, ackId, `🧠 Reasoning effort saved as **${cmdResult.payload}** but RPC failed. Use \`/reload\` to apply.`);
1040
1151
  }
1041
1152
  break;
1042
1153
  }
@@ -1270,11 +1381,25 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1270
1381
  if (toggleAction === 'disable') {
1271
1382
  const allNames = [...new Set(skills.map(s => s.name))];
1272
1383
  setChannelPrefs(msg.channelId, { disabledSkills: allNames });
1273
- await adapter.sendMessage(msg.channelId, `🔴 Disabled all ${allNames.length} skills. Use \`/reload\` to apply.`, { threadRootId: threadRoot });
1384
+ // Apply via RPC for each skill
1385
+ for (const name of allNames) {
1386
+ try {
1387
+ await sessionManager.toggleSkillRpc(msg.channelId, name, 'disable');
1388
+ }
1389
+ catch { /* best-effort */ }
1390
+ }
1391
+ await adapter.sendMessage(msg.channelId, `🔴 Disabled all ${allNames.length} skills.`, { threadRootId: threadRoot });
1274
1392
  }
1275
1393
  else {
1394
+ const allNames = [...currentDisabled];
1276
1395
  setChannelPrefs(msg.channelId, { disabledSkills: [] });
1277
- await adapter.sendMessage(msg.channelId, `🟢 Enabled all skills. Use \`/reload\` to apply.`, { threadRootId: threadRoot });
1396
+ for (const name of allNames) {
1397
+ try {
1398
+ await sessionManager.toggleSkillRpc(msg.channelId, name, 'enable');
1399
+ }
1400
+ catch { /* best-effort */ }
1401
+ }
1402
+ await adapter.sendMessage(msg.channelId, `🟢 Enabled all skills.`, { threadRootId: threadRoot });
1278
1403
  }
1279
1404
  break;
1280
1405
  }
@@ -1310,6 +1435,13 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1310
1435
  }
1311
1436
  if (matched.length > 0) {
1312
1437
  setChannelPrefs(msg.channelId, { disabledSkills: [...currentDisabled] });
1438
+ // Apply each toggle via RPC (best-effort — pref is already persisted)
1439
+ for (const name of matched) {
1440
+ try {
1441
+ await sessionManager.toggleSkillRpc(msg.channelId, name, toggleAction);
1442
+ }
1443
+ catch { /* best-effort */ }
1444
+ }
1313
1445
  }
1314
1446
  const lines = [];
1315
1447
  if (matched.length > 0) {
@@ -1323,9 +1455,6 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1323
1455
  if (notFound.length > 0) {
1324
1456
  lines.push(`❌ No match: ${notFound.map(n => `"${n}"`).join(', ')}`);
1325
1457
  }
1326
- if (matched.length > 0) {
1327
- lines.push('Use `/reload` to apply.');
1328
- }
1329
1458
  await adapter.sendMessage(msg.channelId, lines.join(' '), { threadRootId: threadRoot });
1330
1459
  break;
1331
1460
  }
@@ -1840,6 +1969,14 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1840
1969
  }
1841
1970
  if (event.type === 'assistant.message') {
1842
1971
  const content = formatted.content?.trim();
1972
+ // Check if this message includes a no_reply tool request — the SDK may
1973
+ // bundle tool requests with content and then skip tool execution entirely,
1974
+ // so we must detect no_reply here (not just in tool.execution_start)
1975
+ const toolRequests = event.data?.toolRequests;
1976
+ const hasNoReply = toolRequests?.some(t => t.name === 'no_reply');
1977
+ if (hasNoReply) {
1978
+ noReplyChannels.add(channelId);
1979
+ }
1843
1980
  // Skip empty assistant.message events (tool-call signals)
1844
1981
  if (!content)
1845
1982
  return;
@@ -1866,6 +2003,15 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1866
2003
  // Suppress verbose/tool/status events during quiet — but let session.idle
1867
2004
  // and session.error pass through so channel idle tracking still works
1868
2005
  if (formatted.type === 'tool_start' || formatted.type === 'tool_complete') {
2006
+ // Detect no_reply tool even during quiet mode so we can suppress the
2007
+ // second-turn error that the SDK fires after the tool completes
2008
+ if (formatted.type === 'tool_start' && event.type === 'tool.execution_start') {
2009
+ const toolName = event.data?.toolName ?? event.data?.name;
2010
+ if (toolName === 'no_reply') {
2011
+ log.info(`no_reply tool invoked (quiet) on channel ${channelId.slice(0, 8)}...`);
2012
+ noReplyChannels.add(channelId);
2013
+ }
2014
+ }
1869
2015
  return;
1870
2016
  }
1871
2017
  if (formatted.type === 'status' && event.type !== 'session.idle') {
@@ -1874,7 +2020,20 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1874
2020
  // Errors: exit quiet and fall through to normal error handling (surfaces to user)
1875
2021
  if (formatted.type === 'error') {
1876
2022
  exitQuietMode(channelId);
1877
- // Fall through to normal error handling
2023
+ // Fall through to post-no_reply suppression or normal error handling below
2024
+ }
2025
+ }
2026
+ // Suppress errors from the second agentic turn after no_reply tool.
2027
+ // The SDK always starts another turn after a tool call; that turn's model
2028
+ // call may fail but the session remains functional.
2029
+ // Scoped to the specific SDK error to avoid masking unrelated failures.
2030
+ if (formatted.type === 'error' && noReplyChannels.has(channelId)) {
2031
+ const msg = event.data?.message ?? '';
2032
+ if (typeof msg === 'string' && msg.includes('Failed to get response from the AI model')) {
2033
+ log.info(`Suppressing post-no_reply model error on ${channelId.slice(0, 8)}...`);
2034
+ noReplyChannels.delete(channelId);
2035
+ noReplyHadContent.delete(channelId);
2036
+ return;
1878
2037
  }
1879
2038
  }
1880
2039
  if (formatted.verbose && !verbose)
@@ -1886,6 +2045,31 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1886
2045
  cancelIdleDebounce(channelId);
1887
2046
  if (!isBusy(channelId))
1888
2047
  markBusy(channelId);
2048
+ // Track if content arrives after no_reply (second turn succeeded)
2049
+ if (noReplyChannels.has(channelId) && formatted.content?.trim()) {
2050
+ noReplyHadContent.add(channelId);
2051
+ }
2052
+ // Suppress NO_REPLY responses — agent decided no response was needed.
2053
+ // This can happen outside quiet mode when the agent determines a user
2054
+ // message doesn't require a reply.
2055
+ if (event.type === 'assistant.message') {
2056
+ // Detect no_reply in tool requests bundled with the message — the SDK
2057
+ // may skip tool execution when content is present alongside tool calls
2058
+ const toolReqs = event.data?.toolRequests;
2059
+ if (toolReqs?.some(t => t.name === 'no_reply')) {
2060
+ noReplyChannels.add(channelId);
2061
+ }
2062
+ const trimmed = formatted.content?.trim();
2063
+ if (trimmed === 'NO_REPLY' || trimmed === '`NO_REPLY`') {
2064
+ log.info(`Filtered NO_REPLY on channel ${channelId.slice(0, 8)}...`);
2065
+ const sk = activeStreams.get(channelId);
2066
+ if (sk) {
2067
+ await streaming.deleteStream(sk);
2068
+ activeStreams.delete(channelId);
2069
+ }
2070
+ break;
2071
+ }
2072
+ }
1889
2073
  // When response content starts, finalize the activity feed
1890
2074
  if (activityFeeds.has(channelId)) {
1891
2075
  await finalizeActivityFeed(channelId, adapter);
@@ -1939,6 +2123,12 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1939
2123
  if (event.type === 'tool.execution_start') {
1940
2124
  const toolName = event.data?.toolName ?? event.data?.name ?? 'unknown';
1941
2125
  const args = event.data?.arguments ?? {};
2126
+ // Detect no_reply tool — mark channel for stream suppression
2127
+ if (toolName === 'no_reply') {
2128
+ log.info(`no_reply tool invoked on channel ${channelId.slice(0, 8)}...`);
2129
+ noReplyChannels.add(channelId);
2130
+ break;
2131
+ }
1942
2132
  const loop = loopDetector.recordToolCall(channelId, toolName, args);
1943
2133
  if (loop.isCritical) {
1944
2134
  // Critical loop — warn and force a new session
@@ -2028,7 +2218,30 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
2028
2218
  await finalizeActivityFeed(channelId, adapter);
2029
2219
  initialStreamPosted.delete(channelId);
2030
2220
  channelThreadRoots.delete(channelId);
2031
- if (streamKey) {
2221
+ // If no_reply tool was called, handle stream based on whether
2222
+ // the SDK's second turn produced any content.
2223
+ if (noReplyChannels.has(channelId)) {
2224
+ if (noReplyHadContent.has(channelId)) {
2225
+ // Second turn succeeded and produced content — finalize normally
2226
+ // (content will already have been suppressed by quiet mode or
2227
+ // the text NO_REPLY fallback in most cases)
2228
+ log.info(`no_reply: second turn had content, finalizing stream for ${channelId.slice(0, 8)}...`);
2229
+ if (streamKey) {
2230
+ await streaming.finalizeStream(streamKey);
2231
+ activeStreams.delete(channelId);
2232
+ }
2233
+ }
2234
+ else if (streamKey) {
2235
+ // No content from second turn — delete the stream silently
2236
+ log.info(`no_reply: deleting stream for ${channelId.slice(0, 8)}...`);
2237
+ await streaming.deleteStream(streamKey);
2238
+ activeStreams.delete(channelId);
2239
+ }
2240
+ noReplyHadContent.delete(channelId);
2241
+ // Don't clear noReplyChannels here — the SDK may start a second
2242
+ // agentic turn that errors. We clear it on the error or next message.
2243
+ }
2244
+ else if (streamKey) {
2032
2245
  log.info(`Session idle, finalizing stream for ${channelId.slice(0, 8)}...`);
2033
2246
  await streaming.finalizeStream(streamKey);
2034
2247
  activeStreams.delete(channelId);
@@ -2089,41 +2302,23 @@ async function finalizeActivityFeed(channelId, adapter) {
2089
2302
  }
2090
2303
  activityFeeds.delete(channelId);
2091
2304
  }
2092
- // --- Admin Session Nudge ---
2093
- const NUDGE_PROMPT = `The bridge service was just restarted. If you were in the middle of a task, review your conversation history and continue where you left off. If you were not mid-task, respond with exactly: NO_REPLY`;
2094
- async function nudgeAdminSessions(sessionManager) {
2305
+ // --- Startup Restart Notice ---
2306
+ /** Post a restart notice to admin DM channels (no session creation or LLM interaction). */
2307
+ function postRestartNotices() {
2095
2308
  const allSessions = getAllChannelSessions();
2096
- if (allSessions.length === 0)
2097
- return;
2098
2309
  for (const { channelId } of allSessions) {
2099
- // Only nudge channels belonging to admin bots
2100
2310
  if (!isConfiguredChannel(channelId))
2101
2311
  continue;
2102
2312
  const channelConfig = getChannelConfig(channelId);
2103
2313
  const botName = getChannelBotName(channelId);
2104
2314
  if (!isBotAdmin(channelConfig.platform, botName))
2105
2315
  continue;
2106
- try {
2107
- log.info(`Nudging admin session for bot "${botName}" on channel ${channelId.slice(0, 8)}...`);
2108
- // Only post the visible restart notice in DM channels
2109
- if (channelConfig.isDM) {
2110
- const resolved = getAdapterForChannel(channelId);
2111
- if (resolved) {
2112
- resolved.adapter.sendMessage(channelId, '🔄 Bridge restarted.').catch(e => log.warn(`Failed to post restart notice on ${channelId.slice(0, 8)}...:`, e));
2113
- }
2114
- }
2115
- const clearQuiet = enterQuietMode(channelId);
2116
- try {
2117
- await sessionManager.sendMessage(channelId, NUDGE_PROMPT);
2118
- }
2119
- finally {
2120
- clearQuiet();
2121
- }
2122
- }
2123
- catch (err) {
2124
- exitQuietMode(channelId);
2125
- log.warn(`Failed to nudge admin session on channel ${channelId.slice(0, 8)}...:`, err);
2126
- }
2316
+ if (!channelConfig.isDM)
2317
+ continue;
2318
+ const resolved = getAdapterForChannel(channelId);
2319
+ if (!resolved)
2320
+ continue;
2321
+ resolved.adapter.sendMessage(channelId, '🔄 Bridge restarted.').catch(e => log.warn(`Failed to post restart notice on ${channelId.slice(0, 8)}...:`, e));
2127
2322
  }
2128
2323
  }
2129
2324
  // Start the bridge