@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.
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/bin/copilot-bridge.js +61 -0
- package/config.sample.json +100 -0
- package/dist/channels/mattermost/adapter.d.ts +55 -0
- package/dist/channels/mattermost/adapter.d.ts.map +1 -0
- package/dist/channels/mattermost/adapter.js +524 -0
- package/dist/channels/mattermost/adapter.js.map +1 -0
- package/dist/channels/mattermost/streaming.d.ts +29 -0
- package/dist/channels/mattermost/streaming.d.ts.map +1 -0
- package/dist/channels/mattermost/streaming.js +151 -0
- package/dist/channels/mattermost/streaming.js.map +1 -0
- package/dist/config.d.ts +107 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +817 -0
- package/dist/config.js.map +1 -0
- package/dist/core/bridge.d.ts +73 -0
- package/dist/core/bridge.d.ts.map +1 -0
- package/dist/core/bridge.js +166 -0
- package/dist/core/bridge.js.map +1 -0
- package/dist/core/channel-idle.d.ts +40 -0
- package/dist/core/channel-idle.d.ts.map +1 -0
- package/dist/core/channel-idle.js +120 -0
- package/dist/core/channel-idle.js.map +1 -0
- package/dist/core/command-handler.d.ts +51 -0
- package/dist/core/command-handler.d.ts.map +1 -0
- package/dist/core/command-handler.js +393 -0
- package/dist/core/command-handler.js.map +1 -0
- package/dist/core/inter-agent.d.ts +52 -0
- package/dist/core/inter-agent.d.ts.map +1 -0
- package/dist/core/inter-agent.js +179 -0
- package/dist/core/inter-agent.js.map +1 -0
- package/dist/core/onboarding.d.ts +44 -0
- package/dist/core/onboarding.d.ts.map +1 -0
- package/dist/core/onboarding.js +205 -0
- package/dist/core/onboarding.js.map +1 -0
- package/dist/core/scheduler.d.ts +38 -0
- package/dist/core/scheduler.d.ts.map +1 -0
- package/dist/core/scheduler.js +253 -0
- package/dist/core/scheduler.js.map +1 -0
- package/dist/core/session-manager.d.ts +166 -0
- package/dist/core/session-manager.d.ts.map +1 -0
- package/dist/core/session-manager.js +1732 -0
- package/dist/core/session-manager.js.map +1 -0
- package/dist/core/stream-formatter.d.ts +14 -0
- package/dist/core/stream-formatter.d.ts.map +1 -0
- package/dist/core/stream-formatter.js +198 -0
- package/dist/core/stream-formatter.js.map +1 -0
- package/dist/core/thread-utils.d.ts +22 -0
- package/dist/core/thread-utils.d.ts.map +1 -0
- package/dist/core/thread-utils.js +44 -0
- package/dist/core/thread-utils.js.map +1 -0
- package/dist/core/workspace-manager.d.ts +38 -0
- package/dist/core/workspace-manager.d.ts.map +1 -0
- package/dist/core/workspace-manager.js +230 -0
- package/dist/core/workspace-manager.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1286 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +34 -0
- package/dist/logger.js.map +1 -0
- package/dist/state/store.d.ts +124 -0
- package/dist/state/store.d.ts.map +1 -0
- package/dist/state/store.js +523 -0
- package/dist/state/store.js.map +1 -0
- package/dist/types.d.ts +185 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +61 -0
- package/scripts/check.ts +267 -0
- package/scripts/com.copilot-bridge.plist +41 -0
- package/scripts/copilot-bridge.service +30 -0
- package/scripts/init.ts +250 -0
- package/scripts/install-service.ts +123 -0
- package/scripts/lib/config-gen.ts +129 -0
- package/scripts/lib/mattermost.ts +109 -0
- package/scripts/lib/output.ts +69 -0
- package/scripts/lib/prerequisites.ts +86 -0
- package/scripts/lib/prompts.ts +65 -0
- package/scripts/lib/service.ts +191 -0
- package/scripts/uninstall-service.ts +90 -0
- package/templates/admin/AGENTS.md +325 -0
- package/templates/admin/MEMORY.md +4 -0
- package/templates/agents/AGENTS.md +97 -0
- 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
|