@chrisromp/copilot-bridge 0.8.5 → 0.9.1

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
@@ -6,12 +6,11 @@ import { formatEvent, formatPermissionRequest, formatUserInputRequest } from './
6
6
  import { WorkspaceWatcher, initWorkspace, getWorkspacePath } from './core/workspace-manager.js';
7
7
  import { MattermostAdapter } from './channels/mattermost/adapter.js';
8
8
  import { StreamingHandler } from './channels/mattermost/streaming.js';
9
- import { getChannelPrefs, getAllChannelSessions, closeDb, listPermissionRulesForScope, removePermissionRule, clearPermissionRules } from './state/store.js';
9
+ import { getChannelPrefs, setChannelPrefs, getAllChannelSessions, closeDb, listPermissionRulesForScope, removePermissionRule, clearPermissionRules, getTaskHistory } from './state/store.js';
10
10
  import { extractThreadRequest, resolveThreadRoot } from './core/thread-utils.js';
11
11
  import { initScheduler, stopAll as stopScheduler, listJobs, removeJob, pauseJob, resumeJob, formatInTimezone, describeCron } from './core/scheduler.js';
12
12
  import { markBusy, markIdle, markIdleImmediate, isBusy, waitForChannelIdle, cancelIdleDebounce } from './core/channel-idle.js';
13
13
  import { LoopDetector, MAX_IDENTICAL_CALLS } from './core/loop-detector.js';
14
- import { getTaskHistory } from './state/store.js';
15
14
  import { checkUserAccess } from './core/access-control.js';
16
15
  import { createLogger, setLogLevel } from './logger.js';
17
16
  import fs from 'node:fs';
@@ -19,6 +18,9 @@ import path from 'node:path';
19
18
  const log = createLogger('bridge');
20
19
  // Active streaming responses, keyed by channelId
21
20
  const activeStreams = new Map(); // channelId → streamKey
21
+ // Preserve thread context across turn_end stream finalization so auto-started
22
+ // streams stay in the same thread.
23
+ const channelThreadRoots = new Map(); // channelId → threadRootId
22
24
  // Track channels where the initial "Working..." has been posted (reset on new user message)
23
25
  const initialStreamPosted = new Set();
24
26
  // Activity feed: a single edit-in-place message accumulating tool call lines per channel
@@ -558,11 +560,21 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
558
560
  return;
559
561
  }
560
562
  if (lower === '/remember' || lower === '/always approve') {
561
- sessionManager.resolvePermission(msg.channelId, true, true);
563
+ if (sessionManager.isHookPermission(msg.channelId)) {
564
+ sessionManager.resolvePermission(msg.channelId, true);
565
+ }
566
+ else {
567
+ sessionManager.resolvePermission(msg.channelId, true, true);
568
+ }
562
569
  return;
563
570
  }
564
571
  if (lower === '/always deny') {
565
- sessionManager.resolvePermission(msg.channelId, false, true);
572
+ if (sessionManager.isHookPermission(msg.channelId)) {
573
+ sessionManager.resolvePermission(msg.channelId, false);
574
+ }
575
+ else {
576
+ sessionManager.resolvePermission(msg.channelId, false, true);
577
+ }
566
578
  return;
567
579
  }
568
580
  // Unrecognized text or slash commands — auto-deny the permission and
@@ -589,6 +601,7 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
589
601
  await resolved.streaming.cancelStream(stopStreamKey);
590
602
  activeStreams.delete(msg.channelId);
591
603
  }
604
+ channelThreadRoots.delete(msg.channelId);
592
605
  await finalizeActivityFeed(msg.channelId, adapter);
593
606
  await sessionManager.abortSession(msg.channelId);
594
607
  markIdleImmediate(msg.channelId);
@@ -601,6 +614,7 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
601
614
  await resolved.streaming.cancelStream(oldStreamKey);
602
615
  activeStreams.delete(msg.channelId);
603
616
  }
617
+ channelThreadRoots.delete(msg.channelId);
604
618
  await finalizeActivityFeed(msg.channelId, adapter);
605
619
  loopDetector.reset(msg.channelId);
606
620
  await sessionManager.newSession(msg.channelId);
@@ -773,6 +787,7 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
773
787
  await streaming.cancelStream(oldStreamKey);
774
788
  activeStreams.delete(msg.channelId);
775
789
  }
790
+ channelThreadRoots.delete(msg.channelId);
776
791
  await finalizeActivityFeed(msg.channelId, adapter);
777
792
  loopDetector.reset(msg.channelId);
778
793
  await sessionManager.newSession(msg.channelId);
@@ -786,6 +801,7 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
786
801
  await streaming.cancelStream(stopStreamKey);
787
802
  activeStreams.delete(msg.channelId);
788
803
  }
804
+ channelThreadRoots.delete(msg.channelId);
789
805
  await finalizeActivityFeed(msg.channelId, adapter);
790
806
  await sessionManager.abortSession(msg.channelId);
791
807
  await adapter.sendMessage(msg.channelId, '🛑 Task stopped.', { threadRootId: threadRoot });
@@ -1096,15 +1112,28 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1096
1112
  case 'skills': {
1097
1113
  const skills = sessionManager.getSkillInfo(msg.channelId);
1098
1114
  const mcpInfo = sessionManager.getMcpServerInfo(msg.channelId);
1115
+ const hooksInfo = sessionManager.getHooksInfo(msg.channelId);
1099
1116
  const lines = ['🧰 **Skills & Tools**', ''];
1100
1117
  if (skills.length > 0) {
1101
- lines.push('**Skills**');
1102
- for (const s of skills) {
1103
- const desc = s.description ? ` — ${s.description}` : '';
1104
- const flag = s.pending ? ' _reload to activate_' : '';
1105
- lines.push(`• \`${s.name}\`${desc} _(${s.source})_${flag}`);
1118
+ const active = skills.filter(s => !s.disabled);
1119
+ const disabled = skills.filter(s => s.disabled);
1120
+ if (active.length > 0) {
1121
+ lines.push('🟢 **Active Skills**');
1122
+ for (const s of active) {
1123
+ const desc = s.description ? ` — ${s.description}` : '';
1124
+ const flag = s.pending ? ' ⏳ _reload to activate_' : '';
1125
+ lines.push(`• \`${s.name}\`${desc} _(${s.source})_${flag}`);
1126
+ }
1127
+ lines.push('');
1128
+ }
1129
+ if (disabled.length > 0) {
1130
+ lines.push('🔴 **Disabled Skills**');
1131
+ for (const s of disabled) {
1132
+ const desc = s.description ? ` — ${s.description}` : '';
1133
+ lines.push(`• \`${s.name}\`${desc} _(${s.source})_`);
1134
+ }
1135
+ lines.push('');
1106
1136
  }
1107
- lines.push('');
1108
1137
  }
1109
1138
  if (mcpInfo.length > 0) {
1110
1139
  lines.push('**MCP Servers**');
@@ -1114,6 +1143,14 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1114
1143
  }
1115
1144
  lines.push('');
1116
1145
  }
1146
+ if (hooksInfo.length > 0) {
1147
+ lines.push('**Hooks**');
1148
+ for (const h of hooksInfo) {
1149
+ const count = h.commandCount > 1 ? ` (${h.commandCount} commands)` : '';
1150
+ lines.push(`• \`${h.hookType}\`${count} _(${h.source})_`);
1151
+ }
1152
+ lines.push('');
1153
+ }
1117
1154
  // Fetch built-in tools from SDK
1118
1155
  const sdkTools = await sessionManager.listSessionTools(msg.channelId);
1119
1156
  if (sdkTools.length > 0) {
@@ -1130,6 +1167,79 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1130
1167
  await adapter.sendMessage(msg.channelId, lines.join('\n'), { threadRootId: threadRoot });
1131
1168
  break;
1132
1169
  }
1170
+ case 'skill_toggle': {
1171
+ const { action: toggleAction, targets } = cmdResult.payload;
1172
+ const skills = sessionManager.getSkillInfo(msg.channelId);
1173
+ const prefs = getChannelPrefs(msg.channelId);
1174
+ const currentDisabled = new Set(prefs?.disabledSkills ?? []);
1175
+ // Handle "all" keyword (only valid as sole target)
1176
+ if (targets.some(t => t.toLowerCase() === 'all')) {
1177
+ if (targets.length > 1) {
1178
+ await adapter.sendMessage(msg.channelId, '⚠️ `all` cannot be combined with other skill names.', { threadRootId: threadRoot });
1179
+ break;
1180
+ }
1181
+ if (toggleAction === 'disable') {
1182
+ const allNames = [...new Set(skills.map(s => s.name))];
1183
+ setChannelPrefs(msg.channelId, { disabledSkills: allNames });
1184
+ await adapter.sendMessage(msg.channelId, `🔴 Disabled all ${allNames.length} skills. Use \`/reload\` to apply.`, { threadRootId: threadRoot });
1185
+ }
1186
+ else {
1187
+ setChannelPrefs(msg.channelId, { disabledSkills: [] });
1188
+ await adapter.sendMessage(msg.channelId, `🟢 Enabled all skills. Use \`/reload\` to apply.`, { threadRootId: threadRoot });
1189
+ }
1190
+ break;
1191
+ }
1192
+ const matched = [];
1193
+ const notFound = [];
1194
+ const ambiguous = [];
1195
+ for (const target of targets) {
1196
+ const lower = target.toLowerCase();
1197
+ const exact = skills.find(s => s.name.toLowerCase() === lower);
1198
+ if (exact) {
1199
+ if (toggleAction === 'disable')
1200
+ currentDisabled.add(exact.name);
1201
+ else
1202
+ currentDisabled.delete(exact.name);
1203
+ matched.push(exact.name);
1204
+ continue;
1205
+ }
1206
+ const substringMatches = skills.filter(s => s.name.toLowerCase().includes(lower));
1207
+ if (substringMatches.length === 1) {
1208
+ const skill = substringMatches[0];
1209
+ if (toggleAction === 'disable')
1210
+ currentDisabled.add(skill.name);
1211
+ else
1212
+ currentDisabled.delete(skill.name);
1213
+ matched.push(skill.name);
1214
+ }
1215
+ else if (substringMatches.length > 1) {
1216
+ ambiguous.push(`"${target}" matches: ${substringMatches.map(s => `\`${s.name}\``).join(', ')}`);
1217
+ }
1218
+ else {
1219
+ notFound.push(target);
1220
+ }
1221
+ }
1222
+ if (matched.length > 0) {
1223
+ setChannelPrefs(msg.channelId, { disabledSkills: [...currentDisabled] });
1224
+ }
1225
+ const lines = [];
1226
+ if (matched.length > 0) {
1227
+ const verb = toggleAction === 'disable' ? '🔴 Disabled' : '🟢 Enabled';
1228
+ const names = matched.map(n => `\`${n}\``).join(', ');
1229
+ lines.push(`${verb} ${names}.`);
1230
+ }
1231
+ if (ambiguous.length > 0) {
1232
+ lines.push(`⚠️ Ambiguous: ${ambiguous.join('; ')}`);
1233
+ }
1234
+ if (notFound.length > 0) {
1235
+ lines.push(`❌ No match: ${notFound.map(n => `"${n}"`).join(', ')}`);
1236
+ }
1237
+ if (matched.length > 0) {
1238
+ lines.push('Use `/reload` to apply.');
1239
+ }
1240
+ await adapter.sendMessage(msg.channelId, lines.join(' '), { threadRootId: threadRoot });
1241
+ break;
1242
+ }
1133
1243
  case 'mcp': {
1134
1244
  const mcpInfo = sessionManager.getMcpServerInfo(msg.channelId);
1135
1245
  if (mcpInfo.length === 0) {
@@ -1353,13 +1463,15 @@ async function handleReaction(reaction, sessionManager, platformName, botName) {
1353
1463
  }
1354
1464
  }
1355
1465
  else if (reaction.emoji === 'floppy_disk') {
1356
- if (sessionManager.resolvePermission(reaction.channelId, true, true)) {
1357
- await adapter.sendMessage(reaction.channelId, '💾 Approved + remembered via reaction.');
1466
+ const isHook = sessionManager.isHookPermission(reaction.channelId);
1467
+ if (sessionManager.resolvePermission(reaction.channelId, true, !isHook)) {
1468
+ await adapter.sendMessage(reaction.channelId, isHook ? '✅ Approved via reaction.' : '💾 Approved + remembered via reaction.');
1358
1469
  }
1359
1470
  }
1360
1471
  else if (reaction.emoji === 'no_entry_sign') {
1361
- if (sessionManager.resolvePermission(reaction.channelId, false, true)) {
1362
- await adapter.sendMessage(reaction.channelId, '🚫 Denied + remembered via reaction.');
1472
+ const isHook = sessionManager.isHookPermission(reaction.channelId);
1473
+ if (sessionManager.resolvePermission(reaction.channelId, false, !isHook)) {
1474
+ await adapter.sendMessage(reaction.channelId, isHook ? '❌ Denied via reaction.' : '🚫 Denied + remembered via reaction.');
1363
1475
  }
1364
1476
  }
1365
1477
  }
@@ -1398,19 +1510,23 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1398
1510
  if (event.type === 'bridge.permission_request') {
1399
1511
  const streamKey = activeStreams.get(channelId);
1400
1512
  const threadRootId = streamKey ? streaming.getStreamThreadRootId(streamKey) : undefined;
1513
+ if (threadRootId)
1514
+ channelThreadRoots.set(channelId, threadRootId);
1401
1515
  if (streamKey) {
1402
1516
  await streaming.finalizeStream(streamKey);
1403
1517
  activeStreams.delete(channelId);
1404
1518
  }
1405
1519
  await finalizeActivityFeed(channelId, adapter);
1406
- const { toolName, serverName, input, commands } = event.data;
1407
- const formatted = formatPermissionRequest(toolName, input, commands, serverName);
1520
+ const { toolName, serverName, input, commands, hookReason, fromHook } = event.data;
1521
+ const formatted = formatPermissionRequest(toolName, input, commands, serverName, hookReason, fromHook);
1408
1522
  await adapter.sendMessage(channelId, formatted, { threadRootId });
1409
1523
  return;
1410
1524
  }
1411
1525
  if (event.type === 'bridge.user_input_request') {
1412
1526
  const streamKey = activeStreams.get(channelId);
1413
1527
  const threadRootId = streamKey ? streaming.getStreamThreadRootId(streamKey) : undefined;
1528
+ if (threadRootId)
1529
+ channelThreadRoots.set(channelId, threadRootId);
1414
1530
  if (streamKey) {
1415
1531
  await streaming.finalizeStream(streamKey);
1416
1532
  activeStreams.delete(channelId);
@@ -1479,7 +1595,8 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1479
1595
  const initialContent = event.type === 'assistant.message'
1480
1596
  ? formatted.content
1481
1597
  : (formatted.content || undefined);
1482
- const newKey = await streaming.startStream(channelId, undefined, initialContent);
1598
+ const savedThreadRoot = channelThreadRoots.get(channelId);
1599
+ const newKey = await streaming.startStream(channelId, savedThreadRoot, initialContent);
1483
1600
  activeStreams.set(channelId, newKey);
1484
1601
  }
1485
1602
  else {
@@ -1532,6 +1649,7 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1532
1649
  case 'error':
1533
1650
  markIdleImmediate(channelId);
1534
1651
  nudgePending.delete(channelId);
1652
+ channelThreadRoots.delete(channelId);
1535
1653
  if (streamKey) {
1536
1654
  await streaming.cancelStream(streamKey, formatted.content);
1537
1655
  activeStreams.delete(channelId);
@@ -1541,6 +1659,21 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1541
1659
  }
1542
1660
  break;
1543
1661
  case 'status':
1662
+ // Finalize active stream on turn_start if it has content from a previous
1663
+ // turn or between-turn events (e.g., subagent results arriving after
1664
+ // turn_end). This complements turn_end finalization by catching content
1665
+ // that arrives outside turn boundaries.
1666
+ if (event.type === 'assistant.turn_start' && streamKey && streaming.hasContent(streamKey)) {
1667
+ const threadRootId = streaming.getStreamThreadRootId(streamKey);
1668
+ if (threadRootId) {
1669
+ channelThreadRoots.set(channelId, threadRootId);
1670
+ }
1671
+ else {
1672
+ channelThreadRoots.delete(channelId);
1673
+ }
1674
+ await streaming.finalizeStream(streamKey);
1675
+ activeStreams.delete(channelId);
1676
+ }
1544
1677
  // Send subagent status messages to chat
1545
1678
  if (formatted.content) {
1546
1679
  if (streamKey) {
@@ -1549,14 +1682,31 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1549
1682
  }
1550
1683
  await adapter.sendMessage(channelId, formatted.content);
1551
1684
  }
1685
+ // Finalize stream on turn_end if it has content — preserves multi-turn
1686
+ // messages so each turn's text gets its own chat message instead of being
1687
+ // overwritten by the next turn's replaceContent().
1688
+ // Only finalize when the stream has real content to avoid "Working..." churn.
1689
+ if (event.type === 'assistant.turn_end') {
1690
+ if (streamKey && streaming.hasContent(streamKey)) {
1691
+ // Preserve thread context for the next auto-started stream
1692
+ const threadRootId = streaming.getStreamThreadRootId(streamKey);
1693
+ if (threadRootId) {
1694
+ channelThreadRoots.set(channelId, threadRootId);
1695
+ }
1696
+ else {
1697
+ channelThreadRoots.delete(channelId);
1698
+ }
1699
+ await streaming.finalizeStream(streamKey);
1700
+ activeStreams.delete(channelId);
1701
+ }
1702
+ }
1552
1703
  // Finalize stream when the session goes idle (all turns complete).
1553
- // turn_end fires between tool cycles — DON'T finalize there or we get
1554
- // duplicate "Working..." messages from auto-starting new streams.
1555
1704
  if (event.type === 'session.idle') {
1556
1705
  markIdle(channelId);
1557
1706
  nudgePending.delete(channelId);
1558
1707
  await finalizeActivityFeed(channelId, adapter);
1559
1708
  initialStreamPosted.delete(channelId);
1709
+ channelThreadRoots.delete(channelId);
1560
1710
  if (streamKey) {
1561
1711
  log.info(`Session idle, finalizing stream for ${channelId.slice(0, 8)}...`);
1562
1712
  await streaming.finalizeStream(streamKey);