@chrisromp/copilot-bridge 0.6.0-dev.2

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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -0
  3. package/bin/copilot-bridge.js +61 -0
  4. package/config.sample.json +100 -0
  5. package/dist/channels/mattermost/adapter.d.ts +55 -0
  6. package/dist/channels/mattermost/adapter.d.ts.map +1 -0
  7. package/dist/channels/mattermost/adapter.js +524 -0
  8. package/dist/channels/mattermost/adapter.js.map +1 -0
  9. package/dist/channels/mattermost/streaming.d.ts +29 -0
  10. package/dist/channels/mattermost/streaming.d.ts.map +1 -0
  11. package/dist/channels/mattermost/streaming.js +151 -0
  12. package/dist/channels/mattermost/streaming.js.map +1 -0
  13. package/dist/config.d.ts +107 -0
  14. package/dist/config.d.ts.map +1 -0
  15. package/dist/config.js +817 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/core/bridge.d.ts +73 -0
  18. package/dist/core/bridge.d.ts.map +1 -0
  19. package/dist/core/bridge.js +166 -0
  20. package/dist/core/bridge.js.map +1 -0
  21. package/dist/core/channel-idle.d.ts +40 -0
  22. package/dist/core/channel-idle.d.ts.map +1 -0
  23. package/dist/core/channel-idle.js +120 -0
  24. package/dist/core/channel-idle.js.map +1 -0
  25. package/dist/core/command-handler.d.ts +51 -0
  26. package/dist/core/command-handler.d.ts.map +1 -0
  27. package/dist/core/command-handler.js +393 -0
  28. package/dist/core/command-handler.js.map +1 -0
  29. package/dist/core/inter-agent.d.ts +52 -0
  30. package/dist/core/inter-agent.d.ts.map +1 -0
  31. package/dist/core/inter-agent.js +179 -0
  32. package/dist/core/inter-agent.js.map +1 -0
  33. package/dist/core/onboarding.d.ts +44 -0
  34. package/dist/core/onboarding.d.ts.map +1 -0
  35. package/dist/core/onboarding.js +205 -0
  36. package/dist/core/onboarding.js.map +1 -0
  37. package/dist/core/scheduler.d.ts +38 -0
  38. package/dist/core/scheduler.d.ts.map +1 -0
  39. package/dist/core/scheduler.js +253 -0
  40. package/dist/core/scheduler.js.map +1 -0
  41. package/dist/core/session-manager.d.ts +166 -0
  42. package/dist/core/session-manager.d.ts.map +1 -0
  43. package/dist/core/session-manager.js +1732 -0
  44. package/dist/core/session-manager.js.map +1 -0
  45. package/dist/core/stream-formatter.d.ts +14 -0
  46. package/dist/core/stream-formatter.d.ts.map +1 -0
  47. package/dist/core/stream-formatter.js +198 -0
  48. package/dist/core/stream-formatter.js.map +1 -0
  49. package/dist/core/thread-utils.d.ts +22 -0
  50. package/dist/core/thread-utils.d.ts.map +1 -0
  51. package/dist/core/thread-utils.js +44 -0
  52. package/dist/core/thread-utils.js.map +1 -0
  53. package/dist/core/workspace-manager.d.ts +38 -0
  54. package/dist/core/workspace-manager.d.ts.map +1 -0
  55. package/dist/core/workspace-manager.js +230 -0
  56. package/dist/core/workspace-manager.js.map +1 -0
  57. package/dist/index.d.ts +2 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +1286 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/logger.d.ts +9 -0
  62. package/dist/logger.d.ts.map +1 -0
  63. package/dist/logger.js +34 -0
  64. package/dist/logger.js.map +1 -0
  65. package/dist/state/store.d.ts +124 -0
  66. package/dist/state/store.d.ts.map +1 -0
  67. package/dist/state/store.js +523 -0
  68. package/dist/state/store.js.map +1 -0
  69. package/dist/types.d.ts +185 -0
  70. package/dist/types.d.ts.map +1 -0
  71. package/dist/types.js +2 -0
  72. package/dist/types.js.map +1 -0
  73. package/package.json +61 -0
  74. package/scripts/check.ts +267 -0
  75. package/scripts/com.copilot-bridge.plist +41 -0
  76. package/scripts/copilot-bridge.service +30 -0
  77. package/scripts/init.ts +250 -0
  78. package/scripts/install-service.ts +123 -0
  79. package/scripts/lib/config-gen.ts +129 -0
  80. package/scripts/lib/mattermost.ts +109 -0
  81. package/scripts/lib/output.ts +69 -0
  82. package/scripts/lib/prerequisites.ts +86 -0
  83. package/scripts/lib/prompts.ts +65 -0
  84. package/scripts/lib/service.ts +191 -0
  85. package/scripts/uninstall-service.ts +90 -0
  86. package/templates/admin/AGENTS.md +325 -0
  87. package/templates/admin/MEMORY.md +4 -0
  88. package/templates/agents/AGENTS.md +97 -0
  89. package/templates/agents/MEMORY.md +4 -0
package/dist/index.js ADDED
@@ -0,0 +1,1286 @@
1
+ import { loadConfig, getConfig, isConfiguredChannel, registerDynamicChannel, markChannelAsDM, getChannelConfig, getPlatformBots, getChannelBotName, isBotAdmin, getHardcodedRules, getConfigRules, reloadConfig, ConfigWatcher } from './config.js';
2
+ import { CopilotBridge } from './core/bridge.js';
3
+ import { SessionManager, BRIDGE_CUSTOM_TOOLS } from './core/session-manager.js';
4
+ import { handleCommand, parseCommand } from './core/command-handler.js';
5
+ import { formatEvent, formatPermissionRequest, formatUserInputRequest } from './core/stream-formatter.js';
6
+ import { WorkspaceWatcher, initWorkspace, getWorkspacePath } from './core/workspace-manager.js';
7
+ import { MattermostAdapter } from './channels/mattermost/adapter.js';
8
+ import { StreamingHandler } from './channels/mattermost/streaming.js';
9
+ import { getChannelPrefs, getAllChannelSessions, closeDb, listPermissionRulesForScope, removePermissionRule, clearPermissionRules } from './state/store.js';
10
+ import { extractThreadRequest, resolveThreadRoot } from './core/thread-utils.js';
11
+ import { initScheduler, stopAll as stopScheduler, listJobs, removeJob, pauseJob, resumeJob, formatInTimezone, describeCron } from './core/scheduler.js';
12
+ import { markBusy, markIdle, markIdleImmediate, isBusy, waitForChannelIdle, cancelIdleDebounce } from './core/channel-idle.js';
13
+ import { getTaskHistory } from './state/store.js';
14
+ import { createLogger } from './logger.js';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ const log = createLogger('bridge');
18
+ // Active streaming responses, keyed by channelId
19
+ const activeStreams = new Map(); // channelId → streamKey
20
+ // Track channels where the initial "Working..." has been posted (reset on new user message)
21
+ const initialStreamPosted = new Set();
22
+ // Activity feed: a single edit-in-place message accumulating tool call lines per channel
23
+ const activityFeeds = new Map();
24
+ const ACTIVITY_THROTTLE_MS = 600;
25
+ // Per-channel promise chain to serialize message handling
26
+ const channelLocks = new Map();
27
+ // Per-channel promise chain to serialize SESSION EVENT handling (prevents race on auto-start)
28
+ const eventLocks = new Map();
29
+ // Channels with an active startup nudge in flight (NO_REPLY filter only applies here)
30
+ const nudgePending = new Set();
31
+ // Bot adapters keyed by "platform:botName" for channel→adapter lookup
32
+ const botAdapters = new Map();
33
+ const botStreamers = new Map();
34
+ /** Format a date as a relative age string (e.g., "2h ago", "3d ago"). */
35
+ function formatAge(date) {
36
+ const ms = Date.now() - new Date(date).getTime();
37
+ const mins = Math.floor(ms / 60000);
38
+ if (mins < 1)
39
+ return 'just now';
40
+ if (mins < 60)
41
+ return `${mins}m ago`;
42
+ const hours = Math.floor(mins / 60);
43
+ if (hours < 24)
44
+ return `${hours}h ago`;
45
+ const days = Math.floor(hours / 24);
46
+ return `${days}d ago`;
47
+ }
48
+ /** Sanitize a filename to prevent path traversal — strips directory separators and .. sequences. */
49
+ function sanitizeFilename(name) {
50
+ return name.replace(/[/\\]/g, '_').replace(/\.\./g, '_');
51
+ }
52
+ /** Download message attachments to .temp/<channelId>/ in the bot's workspace, returning SDK-compatible attachment objects. */
53
+ async function downloadAttachments(attachments, channelId, adapter) {
54
+ if (!attachments || attachments.length === 0)
55
+ return [];
56
+ const botName = getChannelBotName(channelId);
57
+ const workspace = getWorkspacePath(botName);
58
+ const tempDir = path.join(workspace, '.temp', channelId);
59
+ const results = [];
60
+ for (const att of attachments) {
61
+ try {
62
+ const safeName = sanitizeFilename(att.name);
63
+ const destPath = path.join(tempDir, `${att.id}-${safeName}`);
64
+ // Verify resolved path is still within tempDir
65
+ if (!path.resolve(destPath).startsWith(path.resolve(tempDir) + path.sep)) {
66
+ log.warn(`Attachment "${att.name}" resolved outside temp dir, skipping`);
67
+ continue;
68
+ }
69
+ await adapter.downloadFile(att.id, destPath);
70
+ results.push({ type: 'file', path: destPath, displayName: att.name });
71
+ log.info(`Downloaded attachment "${att.name}" (${att.type}) for channel ${channelId.slice(0, 8)}...`);
72
+ }
73
+ catch (err) {
74
+ log.warn(`Failed to download attachment "${att.name}":`, err);
75
+ }
76
+ }
77
+ return results;
78
+ }
79
+ /** Remove temp files for a specific channel's temp directory. */
80
+ function cleanupTempFiles(channelId) {
81
+ try {
82
+ const botName = getChannelBotName(channelId);
83
+ const tempDir = path.join(getWorkspacePath(botName), '.temp', channelId);
84
+ if (!fs.existsSync(tempDir))
85
+ return;
86
+ const files = fs.readdirSync(tempDir);
87
+ for (const file of files) {
88
+ try {
89
+ fs.unlinkSync(path.join(tempDir, file));
90
+ }
91
+ catch { /* best effort */ }
92
+ }
93
+ // Remove the now-empty channel temp directory
94
+ try {
95
+ fs.rmdirSync(tempDir);
96
+ }
97
+ catch { /* best effort */ }
98
+ if (files.length > 0) {
99
+ log.info(`Cleaned up ${files.length} temp file(s) for ${channelId.slice(0, 8)}...`);
100
+ }
101
+ }
102
+ catch { /* best effort */ }
103
+ }
104
+ function getAdapterForChannel(channelId) {
105
+ const channelConfig = getChannelConfig(channelId);
106
+ const botName = getChannelBotName(channelId);
107
+ const key = `${channelConfig.platform}:${botName}`;
108
+ const adapter = botAdapters.get(key);
109
+ const streaming = botStreamers.get(key);
110
+ if (!adapter || !streaming)
111
+ return null;
112
+ return { adapter, streaming };
113
+ }
114
+ async function main() {
115
+ log.info('copilot-bridge starting...');
116
+ // Load configuration
117
+ const config = loadConfig();
118
+ log.info(`Loaded ${config.channels.length} channel mapping(s)`);
119
+ // Start config file watcher for hot-reload
120
+ const configWatcher = new ConfigWatcher();
121
+ configWatcher.onReload((result) => {
122
+ if (!result.success)
123
+ return;
124
+ if (result.restartNeeded.length > 0) {
125
+ // Notify admin channels about restart-needed changes
126
+ for (const [key, adapter] of botAdapters) {
127
+ const botName = key.slice(key.indexOf(':') + 1);
128
+ if (isBotAdmin(key.slice(0, key.indexOf(':')), botName)) {
129
+ for (const ch of getConfig().channels) {
130
+ if (ch.bot === botName && !ch.isDM) {
131
+ const warnings = result.restartNeeded.map(r => ` ⚠️ ${r}`).join('\n');
132
+ adapter.sendMessage(ch.id, `**Config reloaded** with changes that need a restart:\n${warnings}`).catch(() => { });
133
+ break; // one admin channel is enough
134
+ }
135
+ }
136
+ }
137
+ }
138
+ }
139
+ });
140
+ configWatcher.start();
141
+ // Initialize Copilot SDK bridge
142
+ const bridge = new CopilotBridge();
143
+ await bridge.start();
144
+ log.info('Copilot SDK connected');
145
+ // Initialize session manager
146
+ const sessionManager = new SessionManager(bridge);
147
+ // Initialize workspaces for all configured bots (idempotent)
148
+ for (const [platformName] of Object.entries(config.platforms)) {
149
+ const bots = getPlatformBots(platformName);
150
+ for (const [botName] of bots) {
151
+ initWorkspace(botName);
152
+ }
153
+ }
154
+ // Watch for new workspace directories
155
+ const workspaceWatcher = new WorkspaceWatcher();
156
+ workspaceWatcher.onEvent((event) => {
157
+ if (event.type === 'created') {
158
+ initWorkspace(event.botName);
159
+ log.info(`Workspace ready for "${event.botName}" — channel registration will occur on first message`);
160
+ }
161
+ else if (event.type === 'removed') {
162
+ log.warn(`Workspace removed for "${event.botName}" — existing sessions will continue but workspace files are gone`);
163
+ }
164
+ });
165
+ workspaceWatcher.start();
166
+ // Adapter factories — register built-in adapters here
167
+ const adapterFactories = {
168
+ mattermost: (name, url, token) => new MattermostAdapter(name, url, token),
169
+ };
170
+ // Initialize channel adapters — one per bot identity
171
+ for (const [platformName, platformConfig] of Object.entries(config.platforms)) {
172
+ const factory = adapterFactories[platformName];
173
+ if (!factory) {
174
+ log.warn(`No adapter for platform "${platformName}" — skipping`);
175
+ continue;
176
+ }
177
+ const bots = getPlatformBots(platformName);
178
+ for (const [botName, botInfo] of bots) {
179
+ const key = `${platformName}:${botName}`;
180
+ const adapter = factory(platformName, platformConfig.url, botInfo.token);
181
+ botAdapters.set(key, adapter);
182
+ botStreamers.set(key, new StreamingHandler(adapter));
183
+ log.info(`Registered bot "${botName}" for ${platformName}`);
184
+ }
185
+ }
186
+ // Wire up session events → streaming output (serialized per channel)
187
+ sessionManager.onSessionEvent((sessionId, channelId, event) => {
188
+ const prev = eventLocks.get(channelId) ?? Promise.resolve();
189
+ const next = prev.then(() => handleSessionEvent(channelId, event)
190
+ .catch(err => log.error(`Unhandled error in event handler:`, err)));
191
+ eventLocks.set(channelId, next);
192
+ });
193
+ // Wire up send_file tool → adapter.sendFile (with thread context)
194
+ sessionManager.onSendFile(async (channelId, filePath, message) => {
195
+ const resolved = getAdapterForChannel(channelId);
196
+ if (!resolved)
197
+ throw new Error('No adapter for channel');
198
+ // Preserve thread context if threaded replies are active
199
+ const streamKey = activeStreams.get(channelId);
200
+ const threadRootId = streamKey ? resolved.streaming.getStreamThreadRootId(streamKey) : undefined;
201
+ return resolved.adapter.sendFile(channelId, filePath, message, { threadRootId });
202
+ });
203
+ // Provide adapter resolver for onboarding tools
204
+ sessionManager.onGetAdapter((channelId) => {
205
+ const resolved = getAdapterForChannel(channelId);
206
+ return resolved?.adapter ?? null;
207
+ });
208
+ // Connect all bot adapters and wire up handlers
209
+ for (const [key, adapter] of botAdapters) {
210
+ const streaming = botStreamers.get(key);
211
+ const colonIdx = key.indexOf(':');
212
+ const platformName = key.slice(0, colonIdx);
213
+ const botName = key.slice(colonIdx + 1);
214
+ adapter.onMessage((msg) => {
215
+ // If the channel is mid-turn, try steering (immediate mode) instead of serializing
216
+ if (isBusy(msg.channelId)) {
217
+ handleMidTurnMessage(msg, sessionManager, platformName, botName)
218
+ .catch(err => {
219
+ // Expected fallbacks — debug level
220
+ const expected = err?.message === 'slash-command-while-busy' || err?.message === 'file-only-while-busy';
221
+ if (expected) {
222
+ log.debug(`Mid-turn fallback (${err.message}), routing to normal handler`);
223
+ }
224
+ else {
225
+ log.warn(`Mid-turn send failed, falling back to queued handler:`, err);
226
+ }
227
+ // Fall back to normal serialized path
228
+ const prev = channelLocks.get(msg.channelId) ?? Promise.resolve();
229
+ const next = prev.then(() => handleInboundMessage(msg, sessionManager, platformName, botName)
230
+ .catch(e => log.error(`Unhandled error in message handler:`, e)));
231
+ channelLocks.set(msg.channelId, next);
232
+ });
233
+ return;
234
+ }
235
+ const prev = channelLocks.get(msg.channelId) ?? Promise.resolve();
236
+ const next = prev.then(() => handleInboundMessage(msg, sessionManager, platformName, botName)
237
+ .catch(err => log.error(`Unhandled error in message handler:`, err)));
238
+ channelLocks.set(msg.channelId, next);
239
+ });
240
+ adapter.onReaction((reaction) => handleReaction(reaction, sessionManager));
241
+ await adapter.connect();
242
+ log.info(`${key} connected`);
243
+ // Discover existing DM channels and auto-register any that aren't configured
244
+ if (typeof adapter.discoverDMChannels === 'function') {
245
+ const dmChannels = await adapter.discoverDMChannels();
246
+ let registered = 0;
247
+ for (const dm of dmChannels) {
248
+ if (!isConfiguredChannel(dm.channelId)) {
249
+ const workspacePath = getWorkspacePath(botName);
250
+ initWorkspace(botName);
251
+ registerDynamicChannel({
252
+ id: dm.channelId,
253
+ platform: platformName,
254
+ bot: botName,
255
+ name: `DM (auto-discovered @${botName})`,
256
+ workingDirectory: workspacePath,
257
+ triggerMode: 'all',
258
+ threadedReplies: false,
259
+ verbose: false,
260
+ isDM: true,
261
+ });
262
+ registered++;
263
+ log.info(`Auto-registered DM channel ${dm.channelId.slice(0, 8)}... for bot "${botName}"`);
264
+ }
265
+ else {
266
+ // Mark pre-configured DM channels so nudge logic can identify them
267
+ markChannelAsDM(dm.channelId);
268
+ }
269
+ }
270
+ log.info(`${botName}: discovered ${dmChannels.length} DM(s), ${registered} newly registered`);
271
+ }
272
+ }
273
+ log.info('copilot-bridge ready!');
274
+ // Initialize scheduler — rehydrate persisted jobs
275
+ initScheduler({
276
+ sendMessage: async (channelId, prompt) => {
277
+ // Route through channelLocks to serialize with user messages
278
+ const prev = channelLocks.get(channelId) ?? Promise.resolve();
279
+ const task = prev.then(async () => {
280
+ const resolved = getAdapterForChannel(channelId);
281
+ if (resolved) {
282
+ const { streaming } = resolved;
283
+ // Atomically swap streams via eventLocks to prevent event interleaving
284
+ const evPrev = eventLocks.get(channelId) ?? Promise.resolve();
285
+ const evTask = evPrev.then(async () => {
286
+ const existingStream = activeStreams.get(channelId);
287
+ if (existingStream) {
288
+ await streaming.finalizeStream(existingStream);
289
+ activeStreams.delete(channelId);
290
+ }
291
+ const streamKey = await streaming.startStream(channelId);
292
+ activeStreams.set(channelId, streamKey);
293
+ });
294
+ eventLocks.set(channelId, evTask.catch(() => { }));
295
+ await evTask;
296
+ markBusy(channelId);
297
+ }
298
+ try {
299
+ await sessionManager.sendMessage(channelId, prompt);
300
+ // Hold the lock until the response is fully streamed
301
+ await waitForChannelIdle(channelId);
302
+ }
303
+ catch (err) {
304
+ log.error(`Scheduled job sendMessage failed for ${channelId.slice(0, 8)}...:`, err);
305
+ markIdleImmediate(channelId);
306
+ const failedStream = activeStreams.get(channelId);
307
+ if (failedStream) {
308
+ const r = getAdapterForChannel(channelId);
309
+ if (r)
310
+ await r.streaming.cancelStream(failedStream, err?.message ?? 'Scheduled job failed').catch(() => { });
311
+ activeStreams.delete(channelId);
312
+ }
313
+ throw err;
314
+ }
315
+ });
316
+ channelLocks.set(channelId, task.catch(() => { }));
317
+ await task;
318
+ return '';
319
+ },
320
+ postMessage: async (channelId, text) => {
321
+ const resolved = getAdapterForChannel(channelId);
322
+ if (resolved) {
323
+ await resolved.adapter.sendMessage(channelId, text);
324
+ }
325
+ },
326
+ });
327
+ // Nudge admin bot sessions that may have been mid-task before restart
328
+ nudgeAdminSessions(sessionManager).catch(err => log.error('Admin nudge failed:', err));
329
+ // Graceful shutdown
330
+ const shutdown = async () => {
331
+ log.info('Shutting down...');
332
+ stopScheduler();
333
+ configWatcher.stop();
334
+ workspaceWatcher.stop();
335
+ await sessionManager.shutdown();
336
+ for (const [, adapter] of botAdapters) {
337
+ await adapter.disconnect();
338
+ }
339
+ for (const [, streaming] of botStreamers) {
340
+ await streaming.cleanup();
341
+ }
342
+ await bridge.stop();
343
+ closeDb();
344
+ log.info('Goodbye.');
345
+ process.exit(0);
346
+ };
347
+ process.on('SIGINT', shutdown);
348
+ process.on('SIGTERM', shutdown);
349
+ }
350
+ // --- Message Handling ---
351
+ /** Strip the bot's own @mention from message text, keeping other mentions intact. */
352
+ function stripBotMention(text, botName) {
353
+ if (!botName)
354
+ return text;
355
+ return text.replace(new RegExp(`@\\S+`, 'g'), (match) => {
356
+ if (match === `@${botName}`)
357
+ return '';
358
+ return match;
359
+ }).trim();
360
+ }
361
+ /** Handle a message that arrives while the session is mid-turn (steering via immediate mode). */
362
+ async function handleMidTurnMessage(msg, sessionManager, platformName, botName) {
363
+ // Ignore messages from any bot we manage on this platform
364
+ for (const [key, a] of botAdapters) {
365
+ if (key.startsWith(`${platformName}:`) && msg.userId === a.getBotUserId())
366
+ return;
367
+ }
368
+ if (!isConfiguredChannel(msg.channelId))
369
+ return;
370
+ const assignedBot = getChannelBotName(msg.channelId);
371
+ if (assignedBot && assignedBot !== botName)
372
+ return;
373
+ const resolved = getAdapterForChannel(msg.channelId);
374
+ if (!resolved)
375
+ return;
376
+ const { adapter } = resolved;
377
+ const channelConfig = getChannelConfig(msg.channelId);
378
+ // Respect trigger mode — don't steer on unmentioned messages in mention-only channels
379
+ if (channelConfig.triggerMode === 'mention' && !msg.mentionsBot && !msg.isDM)
380
+ return;
381
+ const text = stripBotMention(msg.text, channelConfig.bot);
382
+ if (!text && !msg.attachments?.length)
383
+ return;
384
+ // Pending user input — resolve directly (bypasses channelLock to avoid deadlock
385
+ // since the lock is held by waitForChannelIdle which needs this to resolve first)
386
+ if (sessionManager.hasPendingUserInput(msg.channelId)) {
387
+ sessionManager.resolveUserInput(msg.channelId, text);
388
+ return;
389
+ }
390
+ // Pending permission — resolve directly for the same reason.
391
+ // Must be checked BEFORE the general slash-command throw so /approve, /deny,
392
+ // /remember can resolve the permission instead of deadlocking on channelLocks.
393
+ if (sessionManager.hasPendingPermission(msg.channelId)) {
394
+ const lower = text.toLowerCase();
395
+ if (lower === '/approve' || lower === 'yes' || lower === 'y' || lower === 'approve') {
396
+ sessionManager.resolvePermission(msg.channelId, true);
397
+ return;
398
+ }
399
+ if (lower === '/deny' || lower === 'no' || lower === 'n' || lower === 'deny') {
400
+ sessionManager.resolvePermission(msg.channelId, false);
401
+ return;
402
+ }
403
+ if (lower === '/remember') {
404
+ sessionManager.resolvePermission(msg.channelId, true, true);
405
+ return;
406
+ }
407
+ // Other slash commands and unrecognized text while permission pending — ignore.
408
+ // They can't be queued on channelLocks (deadlock) and the permission must be resolved first.
409
+ return;
410
+ }
411
+ // Slash commands while busy: handle safe ones immediately, defer the rest
412
+ // Extract thread request first so 🧵 doesn't pollute command parsing
413
+ const threadExtract = extractThreadRequest(text);
414
+ const commandText = threadExtract.text;
415
+ if (commandText.startsWith('/')) {
416
+ const parsed = parseCommand(commandText);
417
+ if (!parsed) {
418
+ throw new Error('slash-command-while-busy');
419
+ }
420
+ const channelConfig = getChannelConfig(msg.channelId);
421
+ const threadRoot = resolveThreadRoot(msg, threadExtract.threadRequested, channelConfig);
422
+ // Commands that MUST run immediately (abort/cancel current work)
423
+ // markIdleImmediate is called AFTER cleanup to prevent queued messages from
424
+ // starting a new stream while cancel/abort is still in flight.
425
+ if (parsed.command === 'stop' || parsed.command === 'cancel') {
426
+ const stopStreamKey = activeStreams.get(msg.channelId);
427
+ if (stopStreamKey) {
428
+ await resolved.streaming.cancelStream(stopStreamKey);
429
+ activeStreams.delete(msg.channelId);
430
+ }
431
+ await finalizeActivityFeed(msg.channelId, adapter);
432
+ await sessionManager.abortSession(msg.channelId);
433
+ markIdleImmediate(msg.channelId);
434
+ await adapter.sendMessage(msg.channelId, '🛑 Task stopped.', { threadRootId: threadRoot });
435
+ return;
436
+ }
437
+ if (parsed.command === 'new') {
438
+ const oldStreamKey = activeStreams.get(msg.channelId);
439
+ if (oldStreamKey) {
440
+ await resolved.streaming.cancelStream(oldStreamKey);
441
+ activeStreams.delete(msg.channelId);
442
+ }
443
+ await finalizeActivityFeed(msg.channelId, adapter);
444
+ await sessionManager.newSession(msg.channelId);
445
+ markIdleImmediate(msg.channelId);
446
+ await adapter.sendMessage(msg.channelId, '✅ New session created.', { threadRootId: threadRoot });
447
+ return;
448
+ }
449
+ // Read-only / toggle commands — safe to handle mid-turn
450
+ // Only commands where handleCommand returns a complete response (no separate action rendering).
451
+ // Commands with complex action handlers (skills, schedule, rules) defer to serialized path.
452
+ const SAFE_MID_TURN = new Set([
453
+ 'context', 'status', 'help', 'verbose', 'autopilot', 'yolo',
454
+ 'mcp', 'model', 'models', 'reasoning',
455
+ 'streamer-mode', 'on-air',
456
+ ]);
457
+ if (SAFE_MID_TURN.has(parsed.command)) {
458
+ // Build the same inputs that handleInboundMessage would
459
+ const sessionInfo = sessionManager.getSessionInfo(msg.channelId);
460
+ const effPrefs = sessionManager.getEffectivePrefs(msg.channelId);
461
+ let models;
462
+ if (['model', 'models', 'status', 'reasoning'].includes(parsed.command)) {
463
+ try {
464
+ models = await sessionManager.listModels();
465
+ }
466
+ catch {
467
+ models = undefined;
468
+ }
469
+ }
470
+ const mcpInfo = parsed.command === 'mcp' ? sessionManager.getMcpServerInfo(msg.channelId) : undefined;
471
+ const contextUsage = sessionManager.getContextUsage(msg.channelId);
472
+ 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);
473
+ if (cmdResult.handled) {
474
+ // Model/agent switch while busy — defer to serialized path
475
+ if (cmdResult.action === 'switch_model' || cmdResult.action === 'switch_agent') {
476
+ throw new Error('slash-command-while-busy');
477
+ }
478
+ if (cmdResult.response) {
479
+ await adapter.sendMessage(msg.channelId, cmdResult.response, { threadRootId: threadRoot });
480
+ }
481
+ // handleCommand already persists prefs (verbose, autopilot, reasoning) via setChannelPrefs
482
+ return;
483
+ }
484
+ }
485
+ // All other slash commands — defer to serialized path
486
+ throw new Error('slash-command-while-busy');
487
+ }
488
+ // File-only messages can't steer — queue them for normal processing
489
+ if (!text && msg.attachments?.length) {
490
+ throw new Error('file-only-while-busy');
491
+ }
492
+ log.info(`Mid-turn steering for ${msg.channelId.slice(0, 8)}...: "${text.slice(0, 100)}"`);
493
+ // Atomically swap streams via eventLocks so no residual events from the
494
+ // previous response can sneak in between finalization and the new stream.
495
+ const evPrev = eventLocks.get(msg.channelId) ?? Promise.resolve();
496
+ const evTask = evPrev.then(async () => {
497
+ const existingStream = activeStreams.get(msg.channelId);
498
+ if (existingStream) {
499
+ await resolved.streaming.finalizeStream(existingStream);
500
+ activeStreams.delete(msg.channelId);
501
+ }
502
+ const newKey = await resolved.streaming.startStream(msg.channelId);
503
+ activeStreams.set(msg.channelId, newKey);
504
+ });
505
+ eventLocks.set(msg.channelId, evTask.catch(() => { }));
506
+ await evTask;
507
+ await sessionManager.sendMidTurn(msg.channelId, text, msg.userId);
508
+ // Acknowledge with ⚡ reaction (best-effort)
509
+ try {
510
+ adapter.addReaction?.(msg.postId, 'zap')?.catch(() => { });
511
+ }
512
+ catch { /* best-effort */ }
513
+ }
514
+ async function handleInboundMessage(msg, sessionManager, platformName, botName) {
515
+ // Ignore messages from any bot we manage on this platform (prevents cross-bot loops)
516
+ for (const [key, a] of botAdapters) {
517
+ if (key.startsWith(`${platformName}:`) && msg.userId === a.getBotUserId())
518
+ return;
519
+ }
520
+ // Auto-register DM channels for known bots
521
+ if (!isConfiguredChannel(msg.channelId) && msg.isDM) {
522
+ const workspacePath = getWorkspacePath(botName);
523
+ initWorkspace(botName);
524
+ registerDynamicChannel({
525
+ id: msg.channelId,
526
+ platform: platformName,
527
+ bot: botName,
528
+ name: `DM (auto-discovered @${botName})`,
529
+ workingDirectory: workspacePath,
530
+ triggerMode: 'all',
531
+ threadedReplies: false,
532
+ verbose: false,
533
+ isDM: true,
534
+ });
535
+ log.info(`Auto-registered DM channel ${msg.channelId.slice(0, 8)}... for bot "${botName}"`);
536
+ }
537
+ // Only handle configured channels
538
+ if (!isConfiguredChannel(msg.channelId)) {
539
+ log.debug(`Ignoring unconfigured channel ${msg.channelId}`);
540
+ return;
541
+ }
542
+ // Only the assigned bot processes messages for this channel (prevents duplicate handling)
543
+ const assignedBot = getChannelBotName(msg.channelId);
544
+ if (assignedBot && assignedBot !== botName)
545
+ return;
546
+ const resolved = getAdapterForChannel(msg.channelId);
547
+ if (!resolved) {
548
+ log.warn(`No adapter for channel ${msg.channelId}`);
549
+ return;
550
+ }
551
+ const { adapter, streaming } = resolved;
552
+ const channelConfig = getChannelConfig(msg.channelId);
553
+ // Check trigger mode
554
+ const triggerMode = channelConfig.triggerMode;
555
+ if (triggerMode === 'mention' && !msg.mentionsBot && !msg.isDM)
556
+ return;
557
+ // Strip bot mention from message text
558
+ let text = stripBotMention(msg.text, channelConfig.bot);
559
+ if (!text && !msg.attachments?.length)
560
+ return;
561
+ // Detect dynamic thread request (🧵 or "reply in thread") and strip from text
562
+ const threadExtract = extractThreadRequest(text);
563
+ text = threadExtract.text;
564
+ const threadRequested = threadExtract.threadRequested;
565
+ if (!text && !msg.attachments?.length)
566
+ return;
567
+ // Check for slash commands
568
+ const sessionInfo = sessionManager.getSessionInfo(msg.channelId);
569
+ const effPrefs = sessionManager.getEffectivePrefs(msg.channelId);
570
+ // Fetch models list for commands that need it (model, models, status, reasoning)
571
+ const parsed = parseCommand(text);
572
+ let models;
573
+ if (parsed && ['model', 'models', 'status', 'reasoning'].includes(parsed.command)) {
574
+ try {
575
+ models = await sessionManager.listModels();
576
+ }
577
+ catch {
578
+ // Check if the failure is an auth issue
579
+ const auth = await sessionManager.getAuthStatus();
580
+ if (!auth.isAuthenticated) {
581
+ const threadRoot = resolveThreadRoot(msg, threadRequested, channelConfig);
582
+ await adapter.sendMessage(msg.channelId, '🔒 **Not authenticated.** Run `copilot login` on the bridge host to sign in.', { threadRootId: threadRoot });
583
+ return;
584
+ }
585
+ models = undefined;
586
+ }
587
+ }
588
+ // Fetch MCP info for /mcp command
589
+ const mcpInfo = parsed?.command === 'mcp' ? sessionManager.getMcpServerInfo(msg.channelId) : undefined;
590
+ // Get cached context usage for /context and /status
591
+ const contextUsage = sessionManager.getContextUsage(msg.channelId);
592
+ const cmdResult = handleCommand(msg.channelId, text, sessionInfo ?? undefined, { verbose: effPrefs.verbose, permissionMode: effPrefs.permissionMode, reasoningEffort: effPrefs.reasoningEffort }, { workingDirectory: channelConfig.workingDirectory, bot: channelConfig.bot }, models, mcpInfo, contextUsage);
593
+ if (cmdResult.handled) {
594
+ const threadRoot = resolveThreadRoot(msg, threadRequested, channelConfig);
595
+ // Send response before action, except for actions that send their own ack after completing
596
+ const deferResponse = cmdResult.action === 'switch_model' || cmdResult.action === 'switch_agent';
597
+ if (cmdResult.response && !deferResponse) {
598
+ await adapter.sendMessage(msg.channelId, cmdResult.response, { threadRootId: threadRoot });
599
+ }
600
+ switch (cmdResult.action) {
601
+ case 'new_session': {
602
+ markIdleImmediate(msg.channelId);
603
+ const oldStreamKey = activeStreams.get(msg.channelId);
604
+ if (oldStreamKey) {
605
+ await streaming.cancelStream(oldStreamKey);
606
+ activeStreams.delete(msg.channelId);
607
+ }
608
+ await finalizeActivityFeed(msg.channelId, adapter);
609
+ await sessionManager.newSession(msg.channelId);
610
+ await adapter.sendMessage(msg.channelId, '✅ New session created.', { threadRootId: threadRoot });
611
+ break;
612
+ }
613
+ case 'stop_session': {
614
+ markIdleImmediate(msg.channelId);
615
+ const stopStreamKey = activeStreams.get(msg.channelId);
616
+ if (stopStreamKey) {
617
+ await streaming.cancelStream(stopStreamKey);
618
+ activeStreams.delete(msg.channelId);
619
+ }
620
+ await finalizeActivityFeed(msg.channelId, adapter);
621
+ await sessionManager.abortSession(msg.channelId);
622
+ await adapter.sendMessage(msg.channelId, '🛑 Task stopped.', { threadRootId: threadRoot });
623
+ break;
624
+ }
625
+ case 'reload_config': {
626
+ const result = reloadConfig();
627
+ let response;
628
+ if (!result.success) {
629
+ response = `❌ Config reload failed: ${result.error}\nExisting config is unchanged.`;
630
+ }
631
+ else if (result.changes.length === 0 && result.restartNeeded.length === 0) {
632
+ response = '✅ Config reloaded — no changes detected.';
633
+ }
634
+ else {
635
+ const parts = ['✅ Config reloaded.'];
636
+ if (result.changes.length > 0) {
637
+ parts.push('**Applied:**');
638
+ for (const c of result.changes)
639
+ parts.push(` ✓ ${c}`);
640
+ }
641
+ if (result.restartNeeded.length > 0) {
642
+ parts.push('**Restart needed:**');
643
+ for (const r of result.restartNeeded)
644
+ parts.push(` ⚠️ ${r}`);
645
+ }
646
+ response = parts.join('\n');
647
+ }
648
+ await adapter.sendMessage(msg.channelId, response, { threadRootId: threadRoot });
649
+ break;
650
+ }
651
+ case 'reload_session': {
652
+ const oldReloadStream = activeStreams.get(msg.channelId);
653
+ if (oldReloadStream) {
654
+ await streaming.cancelStream(oldReloadStream);
655
+ activeStreams.delete(msg.channelId);
656
+ }
657
+ await finalizeActivityFeed(msg.channelId, adapter);
658
+ const prevSessionId = sessionManager.getSessionId(msg.channelId);
659
+ const ackId = await adapter.sendMessage(msg.channelId, '⏳ Reloading session...', { threadRootId: threadRoot });
660
+ const sessionId = await sessionManager.reloadSession(msg.channelId);
661
+ const wasNew = !prevSessionId || sessionId !== prevSessionId;
662
+ const reloadMsg = wasNew
663
+ ? `⚠️ Previous session not found — created new session (\`${sessionId.slice(0, 8)}…\`).`
664
+ : `✅ Session reloaded (\`${sessionId.slice(0, 8)}…\`). Config and AGENTS.md re-read.`;
665
+ await adapter.updateMessage(msg.channelId, ackId, reloadMsg);
666
+ break;
667
+ }
668
+ case 'resume_session': {
669
+ const oldResumeStream = activeStreams.get(msg.channelId);
670
+ if (oldResumeStream) {
671
+ await streaming.cancelStream(oldResumeStream);
672
+ activeStreams.delete(msg.channelId);
673
+ }
674
+ await finalizeActivityFeed(msg.channelId, adapter);
675
+ const resumeAck = await adapter.sendMessage(msg.channelId, '⏳ Resuming session...', { threadRootId: threadRoot });
676
+ try {
677
+ const resumedId = await sessionManager.resumeToSession(msg.channelId, cmdResult.payload);
678
+ await adapter.updateMessage(msg.channelId, resumeAck, `✅ Resumed session \`${resumedId.slice(0, 8)}…\``);
679
+ }
680
+ catch (err) {
681
+ await adapter.updateMessage(msg.channelId, resumeAck, `❌ Failed to resume session: ${err?.message ?? 'unknown error'}`);
682
+ }
683
+ break;
684
+ }
685
+ case 'list_sessions': {
686
+ try {
687
+ const sessions = await sessionManager.listChannelSessions(msg.channelId);
688
+ if (sessions.length === 0) {
689
+ await adapter.sendMessage(msg.channelId, '📋 No past sessions found for this workspace.', { threadRootId: threadRoot });
690
+ }
691
+ else {
692
+ const lines = ['**Past Sessions** (use `/resume <id>` to reconnect)', ''];
693
+ for (const s of sessions.slice(0, 10)) {
694
+ const current = s.isCurrent ? ' ← current' : '';
695
+ const age = formatAge(s.modifiedTime);
696
+ const summary = s.summary ? ` — ${s.summary.slice(0, 60)}` : '';
697
+ lines.push(`• \`${s.sessionId.slice(0, 12)}\` ${age}${summary}${current}`);
698
+ }
699
+ if (sessions.length > 10) {
700
+ lines.push(`\n_…and ${sessions.length - 10} more_`);
701
+ }
702
+ await adapter.sendMessage(msg.channelId, lines.join('\n'), { threadRootId: threadRoot });
703
+ }
704
+ }
705
+ catch (err) {
706
+ await adapter.sendMessage(msg.channelId, `❌ Failed to list sessions: ${err?.message ?? 'unknown error'}`, { threadRootId: threadRoot });
707
+ }
708
+ break;
709
+ }
710
+ case 'switch_model': {
711
+ const ackId = await adapter.sendMessage(msg.channelId, '⏳ Switching model...', { threadRootId: threadRoot });
712
+ try {
713
+ await sessionManager.switchModel(msg.channelId, cmdResult.payload);
714
+ await adapter.updateMessage(msg.channelId, ackId, cmdResult.response ?? '✅ Model switched.');
715
+ }
716
+ catch (err) {
717
+ log.error(`Failed to switch model on ${msg.channelId.slice(0, 8)}...:`, err);
718
+ await adapter.updateMessage(msg.channelId, ackId, '❌ Failed to switch model. Check logs for details.');
719
+ }
720
+ break;
721
+ }
722
+ case 'switch_agent': {
723
+ const ackId = await adapter.sendMessage(msg.channelId, '⏳ Switching agent...', { threadRootId: threadRoot });
724
+ try {
725
+ await sessionManager.switchAgent(msg.channelId, cmdResult.payload);
726
+ await adapter.updateMessage(msg.channelId, ackId, cmdResult.response ?? '✅ Agent switched.');
727
+ }
728
+ catch (err) {
729
+ log.error(`Failed to switch agent on ${msg.channelId.slice(0, 8)}...:`, err);
730
+ await adapter.updateMessage(msg.channelId, ackId, '❌ Failed to switch agent. Check logs for details.');
731
+ }
732
+ break;
733
+ }
734
+ case 'approve':
735
+ if (!sessionManager.resolvePermission(msg.channelId, true)) {
736
+ await adapter.sendMessage(msg.channelId, '⚠️ No pending permission request.', { threadRootId: threadRoot });
737
+ }
738
+ break;
739
+ case 'deny':
740
+ if (!sessionManager.resolvePermission(msg.channelId, false)) {
741
+ await adapter.sendMessage(msg.channelId, '⚠️ No pending permission request.', { threadRootId: threadRoot });
742
+ }
743
+ break;
744
+ case 'remember':
745
+ if (!sessionManager.resolvePermission(msg.channelId, true, true)) {
746
+ await adapter.sendMessage(msg.channelId, '⚠️ No pending permission request.', { threadRootId: threadRoot });
747
+ }
748
+ break;
749
+ case 'remember_list': {
750
+ try {
751
+ const sections = [];
752
+ // Hardcoded safety denies
753
+ const hardcoded = getHardcodedRules();
754
+ sections.push('**🔒 Hardcoded denies (enforced in all modes including autopilot):**');
755
+ sections.push(...hardcoded.map(r => `- **${r.action}** \`${r.spec}\``));
756
+ sections.push('- **allow** `read/write in workspace + allowPaths`');
757
+ // Config-level rules
758
+ const configRules = getConfigRules();
759
+ if (configRules.length > 0) {
760
+ sections.push('\n**⚙️ Config — config.json (skipped in autopilot):**');
761
+ sections.push(...configRules.map(r => `- **${r.action}** \`${r.spec}\``));
762
+ }
763
+ else {
764
+ sections.push('\n**⚙️ Config — config.json (skipped in autopilot):** _(none)_');
765
+ }
766
+ // Stored rules (per-channel)
767
+ const stored = listPermissionRulesForScope(msg.channelId);
768
+ if (stored.length > 0) {
769
+ sections.push('\n**💾 Stored — this channel (skipped in autopilot):**');
770
+ sections.push(...stored.map(r => {
771
+ const spec = r.commandPattern === '*' ? r.tool : `${r.tool}(${r.commandPattern})`;
772
+ return `- **${r.action}** \`${spec}\``;
773
+ }));
774
+ }
775
+ else {
776
+ sections.push('\n**💾 Stored — this channel (skipped in autopilot):** _(none)_');
777
+ }
778
+ await adapter.sendMessage(msg.channelId, `📋 **Permission rules:**\n${sections.join('\n')}`, { threadRootId: threadRoot });
779
+ }
780
+ catch (err) {
781
+ log.error('Failed to list permission rules:', err);
782
+ await adapter.sendMessage(msg.channelId, '❌ Failed to list permission rules.', { threadRootId: threadRoot });
783
+ }
784
+ break;
785
+ }
786
+ case 'remember_clear': {
787
+ try {
788
+ const spec = cmdResult.payload;
789
+ if (!spec) {
790
+ clearPermissionRules(msg.channelId);
791
+ await adapter.sendMessage(msg.channelId, '🗑️ All permission rules cleared for this channel.', { threadRootId: threadRoot });
792
+ }
793
+ else {
794
+ const match = spec.match(/^([^(]+?)(?:\((.+)\))?$/);
795
+ const tool = match?.[1]?.trim() ?? spec;
796
+ const pattern = match?.[2]?.trim() ?? '*';
797
+ const removed = removePermissionRule(msg.channelId, tool, pattern);
798
+ if (removed) {
799
+ await adapter.sendMessage(msg.channelId, `🗑️ Removed rule: \`${spec}\``, { threadRootId: threadRoot });
800
+ }
801
+ else {
802
+ await adapter.sendMessage(msg.channelId, `⚠️ No matching rule found for \`${spec}\``, { threadRootId: threadRoot });
803
+ }
804
+ }
805
+ }
806
+ catch (err) {
807
+ log.error('Failed to clear permission rules:', err);
808
+ await adapter.sendMessage(msg.channelId, '❌ Failed to clear permission rules.', { threadRootId: threadRoot });
809
+ }
810
+ break;
811
+ }
812
+ case 'schedule': {
813
+ const args = cmdResult.payload;
814
+ const sub = args?.split(/\s+/)?.[0]?.toLowerCase();
815
+ const subArg = args?.slice((sub?.length ?? 0)).trim();
816
+ if (!sub || sub === 'list') {
817
+ const tasks = listJobs(msg.channelId);
818
+ if (tasks.length === 0) {
819
+ await adapter.sendMessage(msg.channelId, '📋 No scheduled tasks for this channel.', { threadRootId: threadRoot });
820
+ }
821
+ else {
822
+ const lines = tasks.map(t => {
823
+ const tz = t.timezone ?? 'UTC';
824
+ const type = t.cronExpr ? describeCron(t.cronExpr) : 'one-off';
825
+ const status = t.enabled ? '✅' : '⏸️';
826
+ const desc = t.description ?? t.prompt.slice(0, 50);
827
+ const next = t.nextRun ? formatInTimezone(t.nextRun, tz) : undefined;
828
+ const lastRan = t.lastRun ? formatInTimezone(t.lastRun, tz) : undefined;
829
+ let detail = `${status} **${desc}** — ${type}\n ID: \`${t.id}\``;
830
+ if (next)
831
+ detail += ` | Next: ${next}`;
832
+ if (lastRan)
833
+ detail += ` | Last: ${lastRan}`;
834
+ return detail;
835
+ });
836
+ await adapter.sendMessage(msg.channelId, `📋 **Scheduled Tasks**\n\n${lines.join('\n\n')}`, { threadRootId: threadRoot });
837
+ }
838
+ }
839
+ else if (sub === 'cancel' || sub === 'remove' || sub === 'delete') {
840
+ if (!subArg) {
841
+ await adapter.sendMessage(msg.channelId, '⚠️ Usage: `/schedule cancel <id>`', { threadRootId: threadRoot });
842
+ }
843
+ else {
844
+ const removed = removeJob(subArg, msg.channelId);
845
+ await adapter.sendMessage(msg.channelId, removed ? `🗑️ Task \`${subArg}\` cancelled.` : `⚠️ Task \`${subArg}\` not found.`, { threadRootId: threadRoot });
846
+ }
847
+ }
848
+ else if (sub === 'pause') {
849
+ if (!subArg) {
850
+ await adapter.sendMessage(msg.channelId, '⚠️ Usage: `/schedule pause <id>`', { threadRootId: threadRoot });
851
+ }
852
+ else {
853
+ const paused = pauseJob(subArg, msg.channelId);
854
+ await adapter.sendMessage(msg.channelId, paused ? `⏸️ Task \`${subArg}\` paused.` : `⚠️ Task \`${subArg}\` not found.`, { threadRootId: threadRoot });
855
+ }
856
+ }
857
+ else if (sub === 'resume') {
858
+ if (!subArg) {
859
+ await adapter.sendMessage(msg.channelId, '⚠️ Usage: `/schedule resume <id>`', { threadRootId: threadRoot });
860
+ }
861
+ else {
862
+ const resumed = resumeJob(subArg, msg.channelId);
863
+ await adapter.sendMessage(msg.channelId, resumed ? `▶️ Task \`${subArg}\` resumed.` : `⚠️ Task \`${subArg}\` not found.`, { threadRootId: threadRoot });
864
+ }
865
+ }
866
+ else if (sub === 'history' || sub === 'log') {
867
+ const limit = subArg ? parseInt(subArg, 10) || 10 : 10;
868
+ const entries = getTaskHistory(msg.channelId, limit);
869
+ if (entries.length === 0) {
870
+ await adapter.sendMessage(msg.channelId, '📋 No task history for this channel.', { threadRootId: threadRoot });
871
+ }
872
+ else {
873
+ const lines = entries.map(e => {
874
+ const icon = e.status === 'success' ? '✅' : '❌';
875
+ const desc = e.description ?? e.prompt.slice(0, 40);
876
+ const time = formatInTimezone(e.firedAt, e.timezone);
877
+ return `${icon} ${desc} — ${time}${e.error ? ` ⚠️ ${e.error}` : ''}`;
878
+ });
879
+ await adapter.sendMessage(msg.channelId, `📋 **Task History** (last ${entries.length})\n${lines.join('\n')}`, { threadRootId: threadRoot });
880
+ }
881
+ }
882
+ else {
883
+ await adapter.sendMessage(msg.channelId, '⚠️ Usage: `/schedule [list|cancel|pause|resume|history] [id]`', { threadRootId: threadRoot });
884
+ }
885
+ break;
886
+ }
887
+ case 'skills': {
888
+ const skills = sessionManager.getSkillInfo(msg.channelId);
889
+ const mcpInfo = sessionManager.getMcpServerInfo(msg.channelId);
890
+ const lines = ['🧰 **Skills & Tools**', ''];
891
+ if (skills.length > 0) {
892
+ lines.push('**Skills**');
893
+ for (const s of skills) {
894
+ const desc = s.description ? ` — ${s.description}` : '';
895
+ lines.push(`• \`${s.name}\`${desc} _(${s.source})_`);
896
+ }
897
+ lines.push('');
898
+ }
899
+ if (mcpInfo.length > 0) {
900
+ lines.push('**MCP Servers**');
901
+ for (const s of mcpInfo) {
902
+ lines.push(`• \`${s.name}\` _(${s.source})_`);
903
+ }
904
+ lines.push('');
905
+ }
906
+ lines.push('**Copilot Bridge Tools**');
907
+ for (const t of BRIDGE_CUSTOM_TOOLS)
908
+ lines.push(`• \`${t}\``);
909
+ if (skills.length === 0 && mcpInfo.length === 0) {
910
+ lines.push('', '_No skills or MCP servers configured. Add skills to `~/.copilot/skills/` or MCP servers to `~/.copilot/mcp-config.json`._');
911
+ }
912
+ await adapter.sendMessage(msg.channelId, lines.join('\n'), { threadRootId: threadRoot });
913
+ break;
914
+ }
915
+ }
916
+ return;
917
+ }
918
+ // Pending user input
919
+ // TODO: file-only messages (empty text + attachments) resolve input with empty string and drop files
920
+ if (sessionManager.hasPendingUserInput(msg.channelId)) {
921
+ sessionManager.resolveUserInput(msg.channelId, text);
922
+ return;
923
+ }
924
+ // Pending permission — natural language responses
925
+ if (sessionManager.hasPendingPermission(msg.channelId)) {
926
+ const lower = text.toLowerCase();
927
+ if (lower === 'yes' || lower === 'y' || lower === 'approve') {
928
+ sessionManager.resolvePermission(msg.channelId, true);
929
+ return;
930
+ }
931
+ if (lower === 'no' || lower === 'n' || lower === 'deny') {
932
+ sessionManager.resolvePermission(msg.channelId, false);
933
+ return;
934
+ }
935
+ }
936
+ // Regular message — forward to Copilot session
937
+ try {
938
+ // Check auth before starting a session (prevents hanging on "Working...")
939
+ const hasSession = sessionManager.getSessionInfo(msg.channelId);
940
+ if (!hasSession) {
941
+ const auth = await sessionManager.getAuthStatus();
942
+ if (!auth.isAuthenticated) {
943
+ const threadRoot = resolveThreadRoot(msg, threadRequested, channelConfig);
944
+ await adapter.sendMessage(msg.channelId, '🔒 **Not authenticated.** Run `copilot login` on the bridge host to sign in.', { threadRootId: threadRoot });
945
+ return;
946
+ }
947
+ }
948
+ console.log(`[bridge] Forwarding to Copilot: "${text}"`);
949
+ log.info(`Forwarding to Copilot: "${text.slice(0, 100)}"`);
950
+ adapter.setTyping(msg.channelId).catch(() => { });
951
+ // Atomically swap streams via eventLocks to prevent event interleaving
952
+ const threadRoot = resolveThreadRoot(msg, threadRequested, channelConfig);
953
+ const evPrev = eventLocks.get(msg.channelId) ?? Promise.resolve();
954
+ const evTask = evPrev.then(async () => {
955
+ const existingStreamKey = activeStreams.get(msg.channelId);
956
+ if (existingStreamKey) {
957
+ await streaming.finalizeStream(existingStreamKey);
958
+ activeStreams.delete(msg.channelId);
959
+ }
960
+ initialStreamPosted.add(msg.channelId);
961
+ const streamKey = await streaming.startStream(msg.channelId, threadRoot);
962
+ activeStreams.set(msg.channelId, streamKey);
963
+ });
964
+ eventLocks.set(msg.channelId, evTask.catch(() => { }));
965
+ await evTask;
966
+ // Mark busy before send so mid-turn messages arriving during the await are steered
967
+ markBusy(msg.channelId);
968
+ // Download any file attachments to .temp/ in the bot's workspace
969
+ const sdkAttachments = await downloadAttachments(msg.attachments, msg.channelId, adapter);
970
+ // If no text but attachments, provide a minimal prompt so the model knows to look at them
971
+ const prompt = text || (sdkAttachments.length > 0 ? 'See attached file(s).' : '');
972
+ // Guard: if both prompt and attachments are empty (all downloads failed), bail out
973
+ if (!prompt && sdkAttachments.length === 0) {
974
+ log.warn(`No text and no attachments for channel ${msg.channelId.slice(0, 8)}... — nothing to send`);
975
+ markIdleImmediate(msg.channelId);
976
+ const sk = activeStreams.get(msg.channelId);
977
+ if (sk) {
978
+ await streaming.cancelStream(sk, 'Failed to download attachment(s).');
979
+ activeStreams.delete(msg.channelId);
980
+ }
981
+ return;
982
+ }
983
+ await sessionManager.sendMessage(msg.channelId, prompt, sdkAttachments.length > 0 ? sdkAttachments : undefined, msg.userId);
984
+ // Hold the channelLock until session.idle so queued work (scheduler, etc.)
985
+ // doesn't start a new stream while this response is still being streamed.
986
+ await waitForChannelIdle(msg.channelId);
987
+ }
988
+ catch (err) {
989
+ markIdleImmediate(msg.channelId);
990
+ log.error(`Error sending message for channel ${msg.channelId}:`, err);
991
+ const streamKey = activeStreams.get(msg.channelId);
992
+ if (streamKey) {
993
+ await streaming.cancelStream(streamKey, err instanceof Error ? err.message : 'Unknown error');
994
+ activeStreams.delete(msg.channelId);
995
+ }
996
+ else {
997
+ const errorMsg = err instanceof Error ? err.message : 'Unknown error';
998
+ await adapter.sendMessage(msg.channelId, `❌ Error: ${errorMsg}`);
999
+ }
1000
+ }
1001
+ }
1002
+ // --- Reaction Handling ---
1003
+ async function handleReaction(reaction, sessionManager) {
1004
+ if (!isConfiguredChannel(reaction.channelId))
1005
+ return;
1006
+ if (reaction.action !== 'added')
1007
+ return;
1008
+ const resolved = getAdapterForChannel(reaction.channelId);
1009
+ if (!resolved)
1010
+ return;
1011
+ const { adapter } = resolved;
1012
+ if (reaction.emoji === 'thumbsup' || reaction.emoji === '+1') {
1013
+ if (sessionManager.resolvePermission(reaction.channelId, true)) {
1014
+ await adapter.sendMessage(reaction.channelId, '✅ Approved via reaction.');
1015
+ }
1016
+ }
1017
+ else if (reaction.emoji === 'thumbsdown' || reaction.emoji === '-1') {
1018
+ if (sessionManager.resolvePermission(reaction.channelId, false)) {
1019
+ await adapter.sendMessage(reaction.channelId, '❌ Denied via reaction.');
1020
+ }
1021
+ }
1022
+ else if (reaction.emoji === 'floppy_disk') {
1023
+ if (sessionManager.resolvePermission(reaction.channelId, true, true)) {
1024
+ await adapter.sendMessage(reaction.channelId, '💾 Approved + remembered via reaction.');
1025
+ }
1026
+ }
1027
+ }
1028
+ // --- Session Event Handling ---
1029
+ async function handleSessionEvent(channelId, event) {
1030
+ if (event.type === 'session.error' || event.type?.includes('error')) {
1031
+ log.error(`SDK error event: ${JSON.stringify(event).slice(0, 1000)}`);
1032
+ }
1033
+ // Verbose SDK event logging
1034
+ if (event.type === 'assistant.message_delta' || event.type === 'assistant.streaming_delta') {
1035
+ log.debug(`SDK ${event.type}: ${JSON.stringify(event.data).slice(0, 200)}`);
1036
+ }
1037
+ else if (event.type === 'assistant.message') {
1038
+ log.debug(`SDK ${event.type}: ${JSON.stringify(event.data).slice(0, 400)}`);
1039
+ }
1040
+ else if (event.type?.startsWith('tool.')) {
1041
+ log.info(`SDK ${event.type}: ${JSON.stringify(event.data).slice(0, 400)}`);
1042
+ }
1043
+ else {
1044
+ log.debug(`SDK event: ${event.type}`);
1045
+ }
1046
+ const resolved = getAdapterForChannel(channelId);
1047
+ if (!resolved)
1048
+ return;
1049
+ const { adapter, streaming } = resolved;
1050
+ const channelConfig = getChannelConfig(channelId);
1051
+ const prefs = getChannelPrefs(channelId);
1052
+ const verbose = prefs?.verbose ?? channelConfig.verbose;
1053
+ // Handle custom bridge events (permissions, user input)
1054
+ if (event.type === 'bridge.permission_request') {
1055
+ const streamKey = activeStreams.get(channelId);
1056
+ if (streamKey) {
1057
+ await streaming.finalizeStream(streamKey);
1058
+ activeStreams.delete(channelId);
1059
+ }
1060
+ await finalizeActivityFeed(channelId, adapter);
1061
+ const { toolName, serverName, input, commands } = event.data;
1062
+ const formatted = formatPermissionRequest(toolName, input, commands, serverName);
1063
+ await adapter.sendMessage(channelId, formatted);
1064
+ return;
1065
+ }
1066
+ if (event.type === 'bridge.user_input_request') {
1067
+ const streamKey = activeStreams.get(channelId);
1068
+ if (streamKey) {
1069
+ await streaming.finalizeStream(streamKey);
1070
+ activeStreams.delete(channelId);
1071
+ }
1072
+ await finalizeActivityFeed(channelId, adapter);
1073
+ const { question, choices } = event.data;
1074
+ const formatted = formatUserInputRequest(question, choices);
1075
+ await adapter.sendMessage(channelId, formatted);
1076
+ return;
1077
+ }
1078
+ // Format and route SDK events
1079
+ const formatted = formatEvent(event);
1080
+ if (!formatted)
1081
+ return;
1082
+ // Filter out NO_REPLY responses from startup nudges only
1083
+ if (nudgePending.has(channelId) && formatted.type === 'content' && event.type === 'assistant.message') {
1084
+ const content = formatted.content?.trim();
1085
+ nudgePending.delete(channelId);
1086
+ if (content === 'NO_REPLY' || content === '`NO_REPLY`') {
1087
+ log.info(`Filtered NO_REPLY from nudge on channel ${channelId.slice(0, 8)}...`);
1088
+ // Clean up any active stream without posting
1089
+ const sk = activeStreams.get(channelId);
1090
+ if (sk) {
1091
+ await streaming.deleteStream(sk);
1092
+ activeStreams.delete(channelId);
1093
+ }
1094
+ return;
1095
+ }
1096
+ }
1097
+ if (formatted.verbose && !verbose)
1098
+ return;
1099
+ const streamKey = activeStreams.get(channelId);
1100
+ switch (formatted.type) {
1101
+ case 'content': {
1102
+ // Content arriving means session is still active — cancel any idle debounce
1103
+ cancelIdleDebounce(channelId);
1104
+ if (!isBusy(channelId))
1105
+ markBusy(channelId);
1106
+ // When response content starts, finalize the activity feed
1107
+ if (activityFeeds.has(channelId)) {
1108
+ await finalizeActivityFeed(channelId, adapter);
1109
+ }
1110
+ // In verbose mode with an active "Working..." stream that hasn't received
1111
+ // content yet, delete it and start a new stream so the response posts
1112
+ // below the activity feed (no scroll-back).
1113
+ if (verbose && streamKey) {
1114
+ const streamContent = streaming.getStreamContent(streamKey);
1115
+ if (streamContent !== undefined && streamContent === '') {
1116
+ const threadRootId = streaming.getStreamThreadRootId(streamKey);
1117
+ await streaming.deleteStream(streamKey);
1118
+ activeStreams.delete(channelId);
1119
+ const initialContent = event.type === 'assistant.message'
1120
+ ? formatted.content
1121
+ : (formatted.content || undefined);
1122
+ const newKey = await streaming.startStream(channelId, threadRootId, initialContent);
1123
+ activeStreams.set(channelId, newKey);
1124
+ adapter.setTyping(channelId).catch(() => { });
1125
+ break;
1126
+ }
1127
+ }
1128
+ if (!streamKey) {
1129
+ // Suppress stream auto-start during startup nudge — avoid visible "Working..." flash
1130
+ if (nudgePending.has(channelId))
1131
+ break;
1132
+ // Auto-start stream — use actual content, never a "Working..." placeholder.
1133
+ // This happens on subsequent turns after turn_end finalized the previous stream.
1134
+ log.info(`Auto-starting stream for channel ${channelId.slice(0, 8)}...`);
1135
+ const initialContent = event.type === 'assistant.message'
1136
+ ? formatted.content
1137
+ : (formatted.content || undefined);
1138
+ const newKey = await streaming.startStream(channelId, undefined, initialContent);
1139
+ activeStreams.set(channelId, newKey);
1140
+ }
1141
+ else {
1142
+ if (event.type === 'assistant.message') {
1143
+ streaming.replaceContent(streamKey, formatted.content);
1144
+ }
1145
+ else if (formatted.content) {
1146
+ streaming.appendDelta(streamKey, formatted.content);
1147
+ }
1148
+ }
1149
+ adapter.setTyping(channelId).catch(() => { });
1150
+ break;
1151
+ }
1152
+ case 'tool_start':
1153
+ cancelIdleDebounce(channelId);
1154
+ if (!isBusy(channelId))
1155
+ markBusy(channelId);
1156
+ if (verbose && formatted.content && !nudgePending.has(channelId)) {
1157
+ await appendActivityFeed(channelId, formatted.content, adapter);
1158
+ }
1159
+ break;
1160
+ case 'tool_complete':
1161
+ // tool_complete events are folded into the activity feed via tool_start
1162
+ break;
1163
+ case 'error':
1164
+ markIdleImmediate(channelId);
1165
+ nudgePending.delete(channelId);
1166
+ if (streamKey) {
1167
+ await streaming.cancelStream(streamKey, formatted.content);
1168
+ activeStreams.delete(channelId);
1169
+ }
1170
+ else {
1171
+ await adapter.sendMessage(channelId, formatted.content);
1172
+ }
1173
+ break;
1174
+ case 'status':
1175
+ // Send subagent status messages to chat
1176
+ if (formatted.content) {
1177
+ if (streamKey) {
1178
+ await streaming.finalizeStream(streamKey);
1179
+ activeStreams.delete(channelId);
1180
+ }
1181
+ await adapter.sendMessage(channelId, formatted.content);
1182
+ }
1183
+ // Finalize stream when the session goes idle (all turns complete).
1184
+ // turn_end fires between tool cycles — DON'T finalize there or we get
1185
+ // duplicate "Working..." messages from auto-starting new streams.
1186
+ if (event.type === 'session.idle') {
1187
+ markIdle(channelId);
1188
+ nudgePending.delete(channelId);
1189
+ await finalizeActivityFeed(channelId, adapter);
1190
+ initialStreamPosted.delete(channelId);
1191
+ if (streamKey) {
1192
+ log.info(`Session idle, finalizing stream for ${channelId.slice(0, 8)}...`);
1193
+ await streaming.finalizeStream(streamKey);
1194
+ activeStreams.delete(channelId);
1195
+ }
1196
+ // Clean up temp files from downloaded attachments
1197
+ cleanupTempFiles(channelId);
1198
+ }
1199
+ break;
1200
+ }
1201
+ }
1202
+ // --- Activity Feed ---
1203
+ /** Append a tool call line to the activity feed message for a channel. */
1204
+ async function appendActivityFeed(channelId, line, adapter) {
1205
+ let feed = activityFeeds.get(channelId);
1206
+ if (!feed) {
1207
+ // Create the activity feed message
1208
+ const messageId = await adapter.sendMessage(channelId, line);
1209
+ feed = { messageId, lines: [line], updateTimer: null };
1210
+ activityFeeds.set(channelId, feed);
1211
+ return;
1212
+ }
1213
+ feed.lines.push(line);
1214
+ // Throttle updates
1215
+ if (!feed.updateTimer) {
1216
+ feed.updateTimer = setTimeout(async () => {
1217
+ const f = activityFeeds.get(channelId);
1218
+ if (!f)
1219
+ return;
1220
+ f.updateTimer = null;
1221
+ try {
1222
+ await adapter.updateMessage(channelId, f.messageId, f.lines.join('\n'));
1223
+ }
1224
+ catch (err) {
1225
+ log.error(`Failed to update activity feed:`, err);
1226
+ }
1227
+ }, ACTIVITY_THROTTLE_MS);
1228
+ }
1229
+ }
1230
+ /** Finalize the activity feed — flush any pending update and remove tracking. */
1231
+ async function finalizeActivityFeed(channelId, adapter) {
1232
+ const feed = activityFeeds.get(channelId);
1233
+ if (!feed)
1234
+ return;
1235
+ if (feed.updateTimer) {
1236
+ clearTimeout(feed.updateTimer);
1237
+ feed.updateTimer = null;
1238
+ }
1239
+ // Final update with all lines
1240
+ try {
1241
+ await adapter.updateMessage(channelId, feed.messageId, feed.lines.join('\n'));
1242
+ }
1243
+ catch (err) {
1244
+ log.error(`Failed to finalize activity feed:`, err);
1245
+ }
1246
+ activityFeeds.delete(channelId);
1247
+ }
1248
+ // --- Admin Session Nudge ---
1249
+ 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`;
1250
+ async function nudgeAdminSessions(sessionManager) {
1251
+ const allSessions = getAllChannelSessions();
1252
+ if (allSessions.length === 0)
1253
+ return;
1254
+ for (const { channelId } of allSessions) {
1255
+ // Only nudge channels belonging to admin bots
1256
+ if (!isConfiguredChannel(channelId))
1257
+ continue;
1258
+ const channelConfig = getChannelConfig(channelId);
1259
+ const botName = getChannelBotName(channelId);
1260
+ if (!isBotAdmin(channelConfig.platform, botName))
1261
+ continue;
1262
+ try {
1263
+ log.info(`Nudging admin session for bot "${botName}" on channel ${channelId.slice(0, 8)}...`);
1264
+ // Only post the visible restart notice in DM channels
1265
+ if (channelConfig.isDM) {
1266
+ const resolved = getAdapterForChannel(channelId);
1267
+ if (resolved) {
1268
+ resolved.adapter.sendMessage(channelId, '🔄 Bridge restarted.').catch(e => log.warn(`Failed to post restart notice on ${channelId.slice(0, 8)}...:`, e));
1269
+ }
1270
+ }
1271
+ nudgePending.add(channelId);
1272
+ await sessionManager.sendMessage(channelId, NUDGE_PROMPT);
1273
+ }
1274
+ catch (err) {
1275
+ nudgePending.delete(channelId);
1276
+ log.warn(`Failed to nudge admin session on channel ${channelId.slice(0, 8)}...:`, err);
1277
+ }
1278
+ }
1279
+ }
1280
+ // Start the bridge
1281
+ main().catch((err) => {
1282
+ log.error('Fatal error:', err);
1283
+ closeDb();
1284
+ process.exit(1);
1285
+ });
1286
+ //# sourceMappingURL=index.js.map