@chrisromp/copilot-bridge 0.11.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.
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...');
@@ -833,6 +838,12 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
833
838
  if (key.startsWith(`${platformName}:`) && msg.userId === a.getBotUserId())
834
839
  return;
835
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);
836
847
  // Check user-level access control (reads live config — hot-reloadable)
837
848
  const botInfo = getPlatformBots(platformName).get(botName);
838
849
  if (!checkUserAccess(msg.userId, msg.username, botInfo?.access, getPlatformAccess(platformName))) {
@@ -915,7 +926,7 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
915
926
  if (cmdResult.handled) {
916
927
  const threadRoot = resolveThreadRoot(msg, threadRequested, channelConfig);
917
928
  // Send response before action, except for actions that send their own ack after completing
918
- 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';
919
930
  if (cmdResult.response && !deferResponse) {
920
931
  await adapter.sendMessage(msg.channelId, cmdResult.response, { threadRootId: threadRoot });
921
932
  }
@@ -994,6 +1005,30 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
994
1005
  await adapter.updateMessage(msg.channelId, ackId, reloadMsg);
995
1006
  break;
996
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
+ }
997
1032
  case 'resume_session': {
998
1033
  const oldResumeStream = activeStreams.get(msg.channelId);
999
1034
  if (oldResumeStream) {
@@ -1107,14 +1142,12 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1107
1142
  }
1108
1143
  const ackId = await adapter.sendMessage(msg.channelId, `🧠 Setting reasoning effort to **${cmdResult.payload}**...`, { threadRootId: threadRoot });
1109
1144
  try {
1110
- const newId = await sessionManager.reloadSession(msg.channelId);
1111
- const wasNew = newId !== reasoningSessionId;
1112
- const suffix = wasNew ? ' (previous session expired — new session created)' : '';
1113
- 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}**.`);
1114
1147
  }
1115
1148
  catch (err) {
1116
- log.error(`Failed to reload session for reasoning on ${msg.channelId.slice(0, 8)}...:`, err);
1117
- 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.`);
1118
1151
  }
1119
1152
  break;
1120
1153
  }
@@ -1348,11 +1381,25 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1348
1381
  if (toggleAction === 'disable') {
1349
1382
  const allNames = [...new Set(skills.map(s => s.name))];
1350
1383
  setChannelPrefs(msg.channelId, { disabledSkills: allNames });
1351
- 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 });
1352
1392
  }
1353
1393
  else {
1394
+ const allNames = [...currentDisabled];
1354
1395
  setChannelPrefs(msg.channelId, { disabledSkills: [] });
1355
- 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 });
1356
1403
  }
1357
1404
  break;
1358
1405
  }
@@ -1388,6 +1435,13 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1388
1435
  }
1389
1436
  if (matched.length > 0) {
1390
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
+ }
1391
1445
  }
1392
1446
  const lines = [];
1393
1447
  if (matched.length > 0) {
@@ -1401,9 +1455,6 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1401
1455
  if (notFound.length > 0) {
1402
1456
  lines.push(`❌ No match: ${notFound.map(n => `"${n}"`).join(', ')}`);
1403
1457
  }
1404
- if (matched.length > 0) {
1405
- lines.push('Use `/reload` to apply.');
1406
- }
1407
1458
  await adapter.sendMessage(msg.channelId, lines.join(' '), { threadRootId: threadRoot });
1408
1459
  break;
1409
1460
  }
@@ -1918,6 +1969,14 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1918
1969
  }
1919
1970
  if (event.type === 'assistant.message') {
1920
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
+ }
1921
1980
  // Skip empty assistant.message events (tool-call signals)
1922
1981
  if (!content)
1923
1982
  return;
@@ -1944,6 +2003,15 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1944
2003
  // Suppress verbose/tool/status events during quiet — but let session.idle
1945
2004
  // and session.error pass through so channel idle tracking still works
1946
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
+ }
1947
2015
  return;
1948
2016
  }
1949
2017
  if (formatted.type === 'status' && event.type !== 'session.idle') {
@@ -1952,7 +2020,20 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1952
2020
  // Errors: exit quiet and fall through to normal error handling (surfaces to user)
1953
2021
  if (formatted.type === 'error') {
1954
2022
  exitQuietMode(channelId);
1955
- // 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;
1956
2037
  }
1957
2038
  }
1958
2039
  if (formatted.verbose && !verbose)
@@ -1964,6 +2045,31 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1964
2045
  cancelIdleDebounce(channelId);
1965
2046
  if (!isBusy(channelId))
1966
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
+ }
1967
2073
  // When response content starts, finalize the activity feed
1968
2074
  if (activityFeeds.has(channelId)) {
1969
2075
  await finalizeActivityFeed(channelId, adapter);
@@ -2017,6 +2123,12 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
2017
2123
  if (event.type === 'tool.execution_start') {
2018
2124
  const toolName = event.data?.toolName ?? event.data?.name ?? 'unknown';
2019
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
+ }
2020
2132
  const loop = loopDetector.recordToolCall(channelId, toolName, args);
2021
2133
  if (loop.isCritical) {
2022
2134
  // Critical loop — warn and force a new session
@@ -2106,7 +2218,30 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
2106
2218
  await finalizeActivityFeed(channelId, adapter);
2107
2219
  initialStreamPosted.delete(channelId);
2108
2220
  channelThreadRoots.delete(channelId);
2109
- 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) {
2110
2245
  log.info(`Session idle, finalizing stream for ${channelId.slice(0, 8)}...`);
2111
2246
  await streaming.finalizeStream(streamKey);
2112
2247
  activeStreams.delete(channelId);
@@ -2167,46 +2302,23 @@ async function finalizeActivityFeed(channelId, adapter) {
2167
2302
  }
2168
2303
  activityFeeds.delete(channelId);
2169
2304
  }
2170
- // --- Admin Session Nudge ---
2171
- 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`;
2172
- 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() {
2173
2308
  const allSessions = getAllChannelSessions();
2174
- if (allSessions.length === 0)
2175
- return;
2176
2309
  for (const { channelId } of allSessions) {
2177
- // Only nudge channels belonging to admin bots
2178
2310
  if (!isConfiguredChannel(channelId))
2179
2311
  continue;
2180
2312
  const channelConfig = getChannelConfig(channelId);
2181
2313
  const botName = getChannelBotName(channelId);
2182
2314
  if (!isBotAdmin(channelConfig.platform, botName))
2183
2315
  continue;
2184
- try {
2185
- log.info(`Nudging admin session for bot "${botName}" on channel ${channelId.slice(0, 8)}...`);
2186
- const resolved = getAdapterForChannel(channelId);
2187
- if (!resolved) {
2188
- log.warn(`No adapter for channel ${channelId.slice(0, 8)}... — skipping nudge`);
2189
- continue;
2190
- }
2191
- // Only post the visible restart notice in DM channels
2192
- if (channelConfig.isDM) {
2193
- resolved.adapter.sendMessage(channelId, '🔄 Bridge restarted.').catch(e => log.warn(`Failed to post restart notice on ${channelId.slice(0, 8)}...:`, e));
2194
- }
2195
- const clearQuiet = enterQuietMode(channelId);
2196
- try {
2197
- markBusy(channelId);
2198
- await sessionManager.sendMessage(channelId, NUDGE_PROMPT);
2199
- await waitForChannelIdle(channelId);
2200
- }
2201
- finally {
2202
- clearQuiet();
2203
- }
2204
- }
2205
- catch (err) {
2206
- exitQuietMode(channelId);
2207
- markIdleImmediate(channelId);
2208
- log.warn(`Failed to nudge admin session on channel ${channelId.slice(0, 8)}...:`, err);
2209
- }
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));
2210
2322
  }
2211
2323
  }
2212
2324
  // Start the bridge