@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
@@ -0,0 +1,1732 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { getChannelSession, setChannelSession, clearChannelSession, getChannelPrefs, setChannelPrefs, checkPermission, addPermissionRule, getWorkspaceOverride, setWorkspaceOverride, listWorkspaceOverrides, recordAgentCall, } from '../state/store.js';
5
+ import { getChannelConfig, getChannelBotName, evaluateConfigPermissions, isBotAdmin, getConfig, getInterAgentConfig, isHardDeny } from '../config.js';
6
+ import { getWorkspacePath, getWorkspaceAllowPaths, ensureWorkspacesDir } from './workspace-manager.js';
7
+ import { onboardProject } from './onboarding.js';
8
+ import { addJob, removeJob, pauseJob, resumeJob, listJobs, formatInTimezone } from './scheduler.js';
9
+ import { canCall, createContext, extendContext, getBotWorkspaceMap, buildWorkspacePrompt, buildCallerPrompt, resolveAgentDefinition, } from './inter-agent.js';
10
+ import { createLogger } from '../logger.js';
11
+ const log = createLogger('session');
12
+ /** Custom tools auto-approved without interactive prompt (they enforce workspace boundaries internally). */
13
+ export const BRIDGE_CUSTOM_TOOLS = ['send_file', 'show_file_in_chat', 'ask_agent', 'schedule'];
14
+ /** Simple mutex for serializing env-sensitive session creation. */
15
+ let envLock = Promise.resolve();
16
+ /**
17
+ * Parse a .env file into a key-value map.
18
+ * Handles KEY=VALUE, KEY="VALUE", KEY='VALUE', comments, and blank lines.
19
+ */
20
+ function parseEnvFile(filePath) {
21
+ try {
22
+ const content = fs.readFileSync(filePath, 'utf-8');
23
+ const vars = {};
24
+ for (const line of content.split('\n')) {
25
+ const trimmed = line.trim();
26
+ if (!trimmed || trimmed.startsWith('#'))
27
+ continue;
28
+ const eqIdx = trimmed.indexOf('=');
29
+ if (eqIdx === -1)
30
+ continue;
31
+ const key = trimmed.slice(0, eqIdx).trim();
32
+ let value = trimmed.slice(eqIdx + 1).trim();
33
+ // Strip matching quotes
34
+ if ((value.startsWith('"') && value.endsWith('"')) ||
35
+ (value.startsWith("'") && value.endsWith("'"))) {
36
+ value = value.slice(1, -1);
37
+ }
38
+ if (key)
39
+ vars[key] = value;
40
+ }
41
+ return vars;
42
+ }
43
+ catch {
44
+ return {};
45
+ }
46
+ }
47
+ /**
48
+ * Run an async function with workspace env vars temporarily injected into process.env.
49
+ * Uses a mutex to prevent concurrent sessions from seeing each other's env vars.
50
+ */
51
+ async function withWorkspaceEnv(workingDirectory, fn) {
52
+ const envPath = path.join(workingDirectory, '.env');
53
+ const vars = parseEnvFile(envPath);
54
+ // Always hold the lock for the full duration of fn() so we never run
55
+ // while another workspace's secrets are injected into process.env.
56
+ const prev = envLock;
57
+ let release;
58
+ envLock = new Promise(resolve => { release = resolve; });
59
+ await prev;
60
+ if (Object.keys(vars).length === 0) {
61
+ try {
62
+ return await fn();
63
+ }
64
+ finally {
65
+ release();
66
+ }
67
+ }
68
+ // Save originals, inject workspace vars
69
+ const saved = {};
70
+ for (const [key, value] of Object.entries(vars)) {
71
+ saved[key] = process.env[key];
72
+ process.env[key] = value;
73
+ }
74
+ try {
75
+ return await fn();
76
+ }
77
+ finally {
78
+ // Restore originals
79
+ for (const [key] of Object.entries(vars)) {
80
+ if (saved[key] === undefined) {
81
+ delete process.env[key];
82
+ }
83
+ else {
84
+ process.env[key] = saved[key];
85
+ }
86
+ }
87
+ release();
88
+ }
89
+ }
90
+ /**
91
+ * Load MCP server configs from ~/.copilot/mcp-config.json and installed plugins.
92
+ * Merges them into a single Record, with user config taking precedence over plugins.
93
+ */
94
+ function loadMcpServers() {
95
+ const home = process.env.HOME;
96
+ if (!home)
97
+ return {};
98
+ const servers = {};
99
+ // 1. Load from installed plugins (.mcp.json files)
100
+ const pluginsDir = path.join(home, '.copilot', 'installed-plugins');
101
+ if (fs.existsSync(pluginsDir)) {
102
+ const walk = (dir) => {
103
+ try {
104
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
105
+ const full = path.join(dir, entry.name);
106
+ if (entry.isDirectory()) {
107
+ // Check for .mcp.json in this directory
108
+ const mcpFile = path.join(full, '.mcp.json');
109
+ if (fs.existsSync(mcpFile)) {
110
+ try {
111
+ const cfg = JSON.parse(fs.readFileSync(mcpFile, 'utf8'));
112
+ if (cfg.mcpServers) {
113
+ for (const [name, config] of Object.entries(cfg.mcpServers)) {
114
+ if (!servers[name]) {
115
+ servers[name] = config;
116
+ log.debug(`Loaded MCP "${name}" from plugin ${path.relative(pluginsDir, full)}`);
117
+ }
118
+ }
119
+ }
120
+ }
121
+ catch (err) {
122
+ log.warn(`Failed to parse ${mcpFile}: ${err}`);
123
+ }
124
+ }
125
+ walk(full);
126
+ }
127
+ }
128
+ }
129
+ catch { /* permission errors etc */ }
130
+ };
131
+ walk(pluginsDir);
132
+ }
133
+ // 2. Load from user mcp-config.json (overrides plugins)
134
+ const userConfig = path.join(home, '.copilot', 'mcp-config.json');
135
+ if (fs.existsSync(userConfig)) {
136
+ try {
137
+ const cfg = JSON.parse(fs.readFileSync(userConfig, 'utf8'));
138
+ if (cfg.mcpServers) {
139
+ for (const [name, config] of Object.entries(cfg.mcpServers)) {
140
+ servers[name] = config;
141
+ log.debug(`Loaded MCP "${name}" from mcp-config.json`);
142
+ }
143
+ }
144
+ }
145
+ catch (err) {
146
+ log.warn(`Failed to parse ${userConfig}: ${err}`);
147
+ }
148
+ }
149
+ normalizeMcpServers(servers);
150
+ const count = Object.keys(servers).length;
151
+ if (count > 0) {
152
+ log.info(`Loaded ${count} MCP server(s): ${Object.keys(servers).join(', ')}`);
153
+ }
154
+ return servers;
155
+ }
156
+ /** Ensure all MCP server entries have a tools field (SDK requires it). */
157
+ function normalizeMcpServers(servers) {
158
+ for (const config of Object.values(servers)) {
159
+ if (!config.tools) {
160
+ config.tools = ['*'];
161
+ }
162
+ }
163
+ }
164
+ /**
165
+ * Load workspace-specific MCP servers from <workspacePath>/mcp-config.json.
166
+ * Injects workspace .env vars into each local server's env field so the CLI
167
+ * subprocess passes them through to MCP server processes (the CLI subprocess
168
+ * is long-lived and does not inherit bridge process.env changes).
169
+ * Also expands ${VAR} references in env values from .env or process.env.
170
+ */
171
+ function loadWorkspaceMcpServers(workspacePath) {
172
+ const workspaceEnv = parseEnvFile(path.join(workspacePath, '.env'));
173
+ const configFile = path.join(workspacePath, 'mcp-config.json');
174
+ if (!fs.existsSync(configFile))
175
+ return { servers: {}, env: workspaceEnv };
176
+ try {
177
+ const cfg = JSON.parse(fs.readFileSync(configFile, 'utf8'));
178
+ if (!cfg.mcpServers || typeof cfg.mcpServers !== 'object')
179
+ return { servers: {}, env: workspaceEnv };
180
+ const servers = {};
181
+ for (const [name, config] of Object.entries(cfg.mcpServers)) {
182
+ const serverConfig = config;
183
+ // Expand ${VAR} references in config-defined env values BEFORE merging .env
184
+ // (only config-authored keys get expansion; .env values are always literal)
185
+ const configEnv = serverConfig.env ? { ...serverConfig.env } : {};
186
+ for (const [key, value] of Object.entries(configEnv)) {
187
+ if (typeof value === 'string' && value.includes('${')) {
188
+ configEnv[key] = value.replace(/\$\{(\w+)\}/g, (_, varName) => workspaceEnv[varName] ?? process.env[varName] ?? '');
189
+ }
190
+ }
191
+ // Inject workspace .env vars into local MCP servers
192
+ const isLocal = !serverConfig.type || serverConfig.type === 'local' || serverConfig.type === 'stdio';
193
+ if (isLocal && Object.keys(workspaceEnv).length > 0) {
194
+ // Workspace .env as base, expanded config env overrides
195
+ serverConfig.env = { ...workspaceEnv, ...configEnv };
196
+ }
197
+ else {
198
+ serverConfig.env = configEnv;
199
+ }
200
+ servers[name] = serverConfig;
201
+ log.debug(`Loaded workspace MCP "${name}" from ${configFile}`);
202
+ }
203
+ normalizeMcpServers(servers);
204
+ return { servers, env: workspaceEnv };
205
+ }
206
+ catch (err) {
207
+ log.warn(`Failed to parse workspace MCP config ${configFile}: ${err}`);
208
+ return { servers: {}, env: workspaceEnv };
209
+ }
210
+ }
211
+ /**
212
+ * Extract individual command names from a shell command string.
213
+ * Handles chained commands: "ls -la && grep -r foo . | head" → ["ls", "grep", "head"]
214
+ */
215
+ const SHELL_WRAPPERS = new Set(['bash', 'sh', 'zsh', 'dash', 'fish', 'env', 'sudo', 'nohup', 'xargs', 'exec', 'eval']);
216
+ export function extractCommandPatterns(input) {
217
+ if (!input || typeof input !== 'object')
218
+ return [];
219
+ const obj = input;
220
+ const cmd = obj.fullCommandText || obj.command || obj.description || obj.path;
221
+ if (typeof cmd !== 'string')
222
+ return [];
223
+ const segments = cmd.split(/\s*(?:&&|\|\||[|;])\s*/);
224
+ const names = segments
225
+ .map((seg) => seg.trim().split(/\s+/)[0])
226
+ .filter(Boolean);
227
+ return [...new Set(names)];
228
+ }
229
+ /**
230
+ * Discover skill directories following Copilot CLI conventions:
231
+ * - ~/.copilot/skills/ (user-level)
232
+ * - <workspace>/.github/skills/ (project-level)
233
+ * - <workspace>/.agents/skills/ (project-level, legacy)
234
+ */
235
+ function discoverSkillDirectories(workingDirectory) {
236
+ const home = process.env.HOME;
237
+ const roots = [];
238
+ // User-level skills
239
+ if (home)
240
+ roots.push(path.join(home, '.copilot', 'skills'));
241
+ // Project-level skills (standard)
242
+ roots.push(path.join(workingDirectory, '.github', 'skills'));
243
+ // Project-level skills (legacy)
244
+ roots.push(path.join(workingDirectory, '.agents', 'skills'));
245
+ const dirs = [];
246
+ for (const skillsRoot of roots) {
247
+ if (!fs.existsSync(skillsRoot))
248
+ continue;
249
+ try {
250
+ for (const entry of fs.readdirSync(skillsRoot, { withFileTypes: true })) {
251
+ if (entry.isDirectory()) {
252
+ dirs.push(path.join(skillsRoot, entry.name));
253
+ }
254
+ }
255
+ }
256
+ catch { /* permission errors etc */ }
257
+ }
258
+ if (dirs.length > 0) {
259
+ log.info(`Discovered ${dirs.length} skill(s): ${dirs.map(d => path.basename(d)).join(', ')}`);
260
+ }
261
+ return dirs;
262
+ }
263
+ export class SessionManager {
264
+ bridge;
265
+ channelSessions = new Map(); // channelId → sessionId
266
+ sessionChannels = new Map(); // sessionId → channelId (reverse)
267
+ sessionUnsubscribes = new Map(); // sessionId → unsubscribe fn
268
+ eventHandler = null;
269
+ mcpServers; // global (plugin + user) MCP servers
270
+ // Pending permission requests (queue per channel to avoid overwrites)
271
+ pendingPermissions = new Map();
272
+ // Pending user input requests (queue per channel to avoid overwrites)
273
+ pendingUserInput = new Map();
274
+ // Cached context usage from session.usage_info events
275
+ contextUsage = new Map();
276
+ lastMessageUserIds = new Map(); // channelId → userId of last message sender
277
+ // Handler for send_file tool (set by index.ts, calls adapter.sendFile)
278
+ sendFileHandler = null;
279
+ getAdapterForChannel = null;
280
+ constructor(bridge) {
281
+ this.bridge = bridge;
282
+ this.mcpServers = loadMcpServers();
283
+ ensureWorkspacesDir();
284
+ }
285
+ /** Register a handler for session events (streaming, tool calls, etc.) */
286
+ onSessionEvent(handler) {
287
+ this.eventHandler = handler;
288
+ }
289
+ /** Register handler for the send_file custom tool. */
290
+ onSendFile(handler) {
291
+ this.sendFileHandler = handler;
292
+ }
293
+ /** Register adapter resolver for onboarding tools. */
294
+ onGetAdapter(resolver) {
295
+ this.getAdapterForChannel = resolver;
296
+ }
297
+ /**
298
+ * Resolve MCP servers for a workspace: workspace config (highest priority)
299
+ * merged on top of global servers (plugin + user config).
300
+ */
301
+ resolveMcpServers(workingDirectory) {
302
+ const { servers: workspaceServers, env: workspaceEnv } = loadWorkspaceMcpServers(workingDirectory);
303
+ // Clone global servers and inject workspace .env into local ones
304
+ const merged = {};
305
+ for (const [name, config] of Object.entries(this.mcpServers)) {
306
+ const serverConfig = { ...config };
307
+ const isLocal = !serverConfig.type || serverConfig.type === 'local' || serverConfig.type === 'stdio';
308
+ if (isLocal && Object.keys(workspaceEnv).length > 0) {
309
+ serverConfig.env = { ...workspaceEnv, ...(serverConfig.env || {}) };
310
+ }
311
+ merged[name] = serverConfig;
312
+ }
313
+ if (Object.keys(workspaceServers).length === 0)
314
+ return merged;
315
+ return { ...merged, ...workspaceServers };
316
+ }
317
+ /** Get annotated MCP server info for a channel, showing which layer each server came from. */
318
+ getMcpServerInfo(channelId) {
319
+ const workingDirectory = this.resolveWorkingDirectory(channelId);
320
+ const { servers: workspaceServers } = loadWorkspaceMcpServers(workingDirectory);
321
+ const globalNames = new Set(Object.keys(this.mcpServers));
322
+ const result = [];
323
+ // All user-level servers — mark project overrides accordingly
324
+ for (const name of globalNames) {
325
+ if (name in workspaceServers) {
326
+ result.push({ name, source: 'workspace (override)' });
327
+ }
328
+ else {
329
+ result.push({ name, source: 'user' });
330
+ }
331
+ }
332
+ // Project-only servers (not in user-level)
333
+ for (const name of Object.keys(workspaceServers)) {
334
+ if (!globalNames.has(name)) {
335
+ result.push({ name, source: 'workspace' });
336
+ }
337
+ }
338
+ return result.sort((a, b) => a.name.localeCompare(b.name));
339
+ }
340
+ /** Get skill info for a channel — discovers skills and reads their descriptions from SKILL.md frontmatter. */
341
+ getSkillInfo(channelId) {
342
+ const workingDirectory = this.resolveWorkingDirectory(channelId);
343
+ const dirs = discoverSkillDirectories(workingDirectory);
344
+ const skills = [];
345
+ for (const dir of dirs) {
346
+ const name = path.basename(dir);
347
+ const skillFile = path.join(dir, 'SKILL.md');
348
+ let description = '';
349
+ let source = 'user';
350
+ // Determine source from path (normalize separators for cross-platform)
351
+ const normalized = dir.split(path.sep).join('/');
352
+ if (normalized.includes('.copilot/skills'))
353
+ source = 'user';
354
+ else if (normalized.includes('.github/skills'))
355
+ source = 'workspace';
356
+ else if (normalized.includes('.agents/skills'))
357
+ source = 'workspace';
358
+ // Try to read description from SKILL.md (matches first description: line)
359
+ if (fs.existsSync(skillFile)) {
360
+ try {
361
+ const content = fs.readFileSync(skillFile, 'utf8');
362
+ const descMatch = content.match(/^description:\s*["']?(.+?)["']?\s*$/m);
363
+ if (descMatch)
364
+ description = descMatch[1];
365
+ }
366
+ catch { /* skip */ }
367
+ }
368
+ skills.push({ name, description, source });
369
+ }
370
+ return skills.sort((a, b) => a.name.localeCompare(b.name));
371
+ }
372
+ /** Get or create a session for a channel. */
373
+ async ensureSession(channelId) {
374
+ // Check in-memory cache first
375
+ const cachedSessionId = this.channelSessions.get(channelId);
376
+ if (cachedSessionId && this.bridge.getSession(cachedSessionId)) {
377
+ return { sessionId: cachedSessionId, isNew: false };
378
+ }
379
+ // Check SQLite for persisted session
380
+ const storedSessionId = getChannelSession(channelId);
381
+ if (storedSessionId) {
382
+ try {
383
+ await this.attachSession(channelId, storedSessionId);
384
+ return { sessionId: storedSessionId, isNew: false };
385
+ }
386
+ catch (err) {
387
+ log.warn(`Failed to resume session ${storedSessionId} for channel ${channelId}, creating new:`, err);
388
+ clearChannelSession(channelId);
389
+ }
390
+ }
391
+ // Create new session
392
+ const sessionId = await this.createNewSession(channelId);
393
+ return { sessionId, isNew: true };
394
+ }
395
+ /** Create a brand new session for a channel (used by /new command). */
396
+ async newSession(channelId) {
397
+ // Clean up existing session
398
+ const existingId = this.channelSessions.get(channelId);
399
+ if (existingId) {
400
+ const unsub = this.sessionUnsubscribes.get(existingId);
401
+ if (unsub) {
402
+ unsub();
403
+ this.sessionUnsubscribes.delete(existingId);
404
+ }
405
+ try {
406
+ this.bridge.destroySession(existingId);
407
+ }
408
+ catch { /* best-effort */ }
409
+ this.channelSessions.delete(channelId);
410
+ this.sessionChannels.delete(existingId);
411
+ this.contextUsage.delete(channelId);
412
+ this.lastMessageUserIds.delete(channelId);
413
+ }
414
+ clearChannelSession(channelId);
415
+ return this.createNewSession(channelId);
416
+ }
417
+ /** Reload the current session — detach and re-attach to pick up AGENTS.md / config changes. */
418
+ async reloadSession(channelId) {
419
+ const existingId = this.channelSessions.get(channelId) ?? getChannelSession(channelId) ?? undefined;
420
+ if (!existingId) {
421
+ // No session to reload — just create one
422
+ return this.createNewSession(channelId);
423
+ }
424
+ // Detach event listeners and release the bridge handle
425
+ const unsub = this.sessionUnsubscribes.get(existingId);
426
+ if (unsub) {
427
+ unsub();
428
+ this.sessionUnsubscribes.delete(existingId);
429
+ }
430
+ this.bridge.releaseSession(existingId);
431
+ // Re-attach the same session (re-reads workspace config, AGENTS.md, MCP, etc.)
432
+ this.contextUsage.delete(channelId);
433
+ this.lastMessageUserIds.delete(channelId);
434
+ try {
435
+ await this.attachSession(channelId, existingId);
436
+ log.info(`Reloaded session ${existingId} for channel ${channelId}`);
437
+ return existingId;
438
+ }
439
+ catch (err) {
440
+ // Session no longer exists server-side (e.g., workspace was deleted and re-created)
441
+ log.warn(`Stale session ${existingId} for channel ${channelId}: ${err?.message ?? err}. Creating new session.`);
442
+ this.channelSessions.delete(channelId);
443
+ this.sessionChannels.delete(existingId);
444
+ this.contextUsage.delete(channelId);
445
+ this.lastMessageUserIds.delete(channelId);
446
+ clearChannelSession(channelId);
447
+ return this.createNewSession(channelId);
448
+ }
449
+ }
450
+ /** Resume a specific past session by ID. */
451
+ async resumeToSession(channelId, targetSessionId) {
452
+ // If already attached to this session, just reload it
453
+ const existingId = this.channelSessions.get(channelId);
454
+ if (existingId === targetSessionId) {
455
+ return this.reloadSession(channelId);
456
+ }
457
+ // Clean up current session for this channel
458
+ if (existingId) {
459
+ const unsub = this.sessionUnsubscribes.get(existingId);
460
+ if (unsub) {
461
+ unsub();
462
+ this.sessionUnsubscribes.delete(existingId);
463
+ }
464
+ this.bridge.releaseSession(existingId);
465
+ this.channelSessions.delete(channelId);
466
+ this.sessionChannels.delete(existingId);
467
+ this.contextUsage.delete(channelId);
468
+ this.lastMessageUserIds.delete(channelId);
469
+ }
470
+ // If target session is active on another channel, release it first
471
+ const otherChannel = this.sessionChannels.get(targetSessionId);
472
+ if (otherChannel) {
473
+ const unsub = this.sessionUnsubscribes.get(targetSessionId);
474
+ if (unsub) {
475
+ unsub();
476
+ this.sessionUnsubscribes.delete(targetSessionId);
477
+ }
478
+ this.bridge.releaseSession(targetSessionId);
479
+ this.channelSessions.delete(otherChannel);
480
+ this.sessionChannels.delete(targetSessionId);
481
+ this.contextUsage.delete(otherChannel);
482
+ clearChannelSession(otherChannel);
483
+ }
484
+ // Attach to the target session — fail hard if it doesn't exist
485
+ // (user explicitly asked for this session, don't silently replace it)
486
+ await this.attachSession(channelId, targetSessionId);
487
+ setChannelSession(channelId, targetSessionId);
488
+ log.info(`Resumed session ${targetSessionId} for channel ${channelId}`);
489
+ return targetSessionId;
490
+ }
491
+ /** Send a message to a channel's session. Returns immediately; responses come via events. */
492
+ async sendMessage(channelId, text, attachments, userId) {
493
+ if (userId)
494
+ this.lastMessageUserIds.set(channelId, userId);
495
+ // Auto-deny any pending permissions so the session unblocks
496
+ this.clearPendingPermissions(channelId);
497
+ const { sessionId } = await this.ensureSession(channelId);
498
+ const session = this.bridge.getSession(sessionId);
499
+ if (!session)
500
+ throw new Error(`Session ${sessionId} not found after ensure`);
501
+ const sendOpts = { prompt: text, attachments };
502
+ try {
503
+ const messageId = await session.send(sendOpts);
504
+ return messageId;
505
+ }
506
+ catch (err) {
507
+ const msg = String(err?.message ?? err);
508
+ log.error(`Send failed for session ${sessionId}:`, msg);
509
+ // Try to reconnect to the same session (CLI subprocess may have restarted)
510
+ try {
511
+ log.info(`Attempting to re-attach session ${sessionId}...`);
512
+ this.bridge.releaseSession(sessionId);
513
+ const unsub = this.sessionUnsubscribes.get(sessionId);
514
+ if (unsub) {
515
+ unsub();
516
+ this.sessionUnsubscribes.delete(sessionId);
517
+ }
518
+ await this.attachSession(channelId, sessionId);
519
+ const reconnected = this.bridge.getSession(sessionId);
520
+ if (reconnected) {
521
+ log.info(`Re-attached session ${sessionId} successfully`);
522
+ return reconnected.send(sendOpts);
523
+ }
524
+ }
525
+ catch (retryErr) {
526
+ log.warn(`Re-attach failed:`, retryErr?.message ?? retryErr);
527
+ }
528
+ // Last resort: create a new session
529
+ log.info(`Creating new session for channel ${channelId}...`);
530
+ const newSessionId = await this.newSession(channelId);
531
+ const newSession = this.bridge.getSession(newSessionId);
532
+ if (!newSession)
533
+ throw new Error(`New session ${newSessionId} not found`);
534
+ return newSession.send(sendOpts);
535
+ }
536
+ }
537
+ /** Send a mid-turn message to an active session using immediate mode (steering).
538
+ * Throws if no active session exists or if send fails. */
539
+ async sendMidTurn(channelId, text, userId) {
540
+ if (userId)
541
+ this.lastMessageUserIds.set(channelId, userId);
542
+ const sessionId = this.channelSessions.get(channelId);
543
+ if (!sessionId)
544
+ throw new Error(`No active session for channel ${channelId}`);
545
+ const session = this.bridge.getSession(sessionId);
546
+ if (!session)
547
+ throw new Error(`Session ${sessionId} not found`);
548
+ log.info(`Mid-turn send (immediate) for channel ${channelId.slice(0, 8)}...: "${text.slice(0, 100)}"`);
549
+ return session.send({ prompt: text, mode: 'immediate' });
550
+ }
551
+ /** Deny all pending permissions for a channel (e.g., when user sends a new message instead). */
552
+ clearPendingPermissions(channelId) {
553
+ const queue = this.pendingPermissions.get(channelId);
554
+ if (queue && queue.length > 0) {
555
+ log.info(`Auto-denying ${queue.length} pending permission(s) for channel ${channelId}`);
556
+ for (const entry of queue) {
557
+ entry.resolve({ kind: 'denied-interactively-by-user' });
558
+ }
559
+ this.pendingPermissions.delete(channelId);
560
+ }
561
+ const inputQueue = this.pendingUserInput.get(channelId);
562
+ if (inputQueue && inputQueue.length > 0) {
563
+ log.info(`Cancelling ${inputQueue.length} pending input request(s) for channel ${channelId}`);
564
+ for (const entry of inputQueue) {
565
+ entry.resolve({ answer: '', wasFreeform: true });
566
+ }
567
+ this.pendingUserInput.delete(channelId);
568
+ }
569
+ }
570
+ /** Switch the model for a channel's session. */
571
+ async switchModel(channelId, model) {
572
+ const sessionId = this.channelSessions.get(channelId);
573
+ if (sessionId) {
574
+ try {
575
+ await this.bridge.switchSessionModel(sessionId, model);
576
+ }
577
+ catch (err) {
578
+ log.warn(`RPC model switch failed:`, err);
579
+ }
580
+ }
581
+ setChannelPrefs(channelId, { model });
582
+ }
583
+ /** Switch the agent for a channel's session. */
584
+ async switchAgent(channelId, agent) {
585
+ const sessionId = this.channelSessions.get(channelId);
586
+ if (sessionId) {
587
+ try {
588
+ if (agent) {
589
+ await this.bridge.selectAgent(sessionId, agent);
590
+ }
591
+ else {
592
+ await this.bridge.deselectAgent(sessionId);
593
+ }
594
+ }
595
+ catch (err) {
596
+ log.warn(`RPC agent switch failed:`, err);
597
+ }
598
+ }
599
+ setChannelPrefs(channelId, { agent });
600
+ }
601
+ /** Get effective preferences for a channel (config merged with runtime overrides). */
602
+ getEffectivePrefs(channelId) {
603
+ const configChannel = getChannelConfig(channelId);
604
+ const storedPrefs = getChannelPrefs(channelId);
605
+ return {
606
+ model: storedPrefs?.model ?? configChannel.model ?? 'claude-sonnet-4.6',
607
+ agent: storedPrefs?.agent !== undefined ? storedPrefs.agent : configChannel.agent,
608
+ verbose: storedPrefs?.verbose ?? configChannel.verbose,
609
+ triggerMode: configChannel.triggerMode,
610
+ threadedReplies: storedPrefs?.threadedReplies ?? configChannel.threadedReplies,
611
+ permissionMode: storedPrefs?.permissionMode ?? configChannel.permissionMode,
612
+ reasoningEffort: storedPrefs?.reasoningEffort ?? configChannel.reasoningEffort ?? null,
613
+ };
614
+ }
615
+ /** Get model info (for checking capabilities like reasoning effort). */
616
+ async getModelInfo(modelId) {
617
+ try {
618
+ const models = await this.bridge.listModels();
619
+ return models.find(m => m.id === modelId) ?? null;
620
+ }
621
+ catch {
622
+ return null;
623
+ }
624
+ }
625
+ /** List all available models. */
626
+ async listModels() {
627
+ return this.bridge.listModels();
628
+ }
629
+ /** Check if the Copilot CLI is authenticated. */
630
+ async getAuthStatus() {
631
+ try {
632
+ return await this.bridge.getAuthStatus();
633
+ }
634
+ catch {
635
+ return { isAuthenticated: false, statusMessage: 'Unable to check auth status' };
636
+ }
637
+ }
638
+ /** Resolve a pending permission request (first in queue). */
639
+ resolvePermission(channelId, allow, remember) {
640
+ const queue = this.pendingPermissions.get(channelId);
641
+ if (!queue || queue.length === 0)
642
+ return false;
643
+ const pending = queue.shift();
644
+ if (remember) {
645
+ const action = allow ? 'allow' : 'deny';
646
+ if (pending.serverName) {
647
+ // MCP tool: save at server level so all tools on this server are covered
648
+ addPermissionRule(channelId, `mcp:${pending.serverName}`, '*', action);
649
+ log.info(`Saved ${action} rule for MCP server "${pending.serverName}" in channel ${channelId}`);
650
+ }
651
+ else if (pending.commands.length > 0) {
652
+ for (const cmd of pending.commands) {
653
+ addPermissionRule(channelId, pending.toolName, cmd, action);
654
+ }
655
+ }
656
+ else {
657
+ addPermissionRule(channelId, pending.toolName, '*', action);
658
+ }
659
+ }
660
+ pending.resolve(allow
661
+ ? { kind: 'approved' }
662
+ : { kind: 'denied-interactively-by-user' });
663
+ if (queue.length === 0) {
664
+ this.pendingPermissions.delete(channelId);
665
+ }
666
+ else {
667
+ // Surface the next queued permission request
668
+ const next = queue[0];
669
+ this.eventHandler?.(next.sessionId, channelId, {
670
+ type: 'bridge.permission_request',
671
+ data: {
672
+ toolName: next.toolName,
673
+ serverName: next.serverName,
674
+ input: next.toolInput,
675
+ commands: next.commands,
676
+ },
677
+ });
678
+ }
679
+ return true;
680
+ }
681
+ /** Resolve a pending user input request (first in queue). */
682
+ resolveUserInput(channelId, answer) {
683
+ const queue = this.pendingUserInput.get(channelId);
684
+ if (!queue || queue.length === 0)
685
+ return false;
686
+ const pending = queue.shift();
687
+ pending.resolve({ answer, wasFreeform: true });
688
+ if (queue.length === 0) {
689
+ this.pendingUserInput.delete(channelId);
690
+ }
691
+ else {
692
+ // Surface the next queued user input request
693
+ const next = queue[0];
694
+ this.eventHandler?.(next.sessionId, channelId, {
695
+ type: 'bridge.user_input_request',
696
+ data: {
697
+ question: next.question,
698
+ choices: next.choices,
699
+ allowFreeform: next.allowFreeform,
700
+ },
701
+ });
702
+ }
703
+ return true;
704
+ }
705
+ /** Check if channel has a pending permission request. */
706
+ hasPendingPermission(channelId) {
707
+ const queue = this.pendingPermissions.get(channelId);
708
+ return !!queue && queue.length > 0;
709
+ }
710
+ /** Get the current session ID for a channel (if any). */
711
+ getSessionId(channelId) {
712
+ return this.channelSessions.get(channelId) ?? getChannelSession(channelId) ?? undefined;
713
+ }
714
+ /** Abort the current turn for a channel's session. */
715
+ async abortSession(channelId) {
716
+ const sessionId = this.channelSessions.get(channelId);
717
+ if (sessionId) {
718
+ await this.bridge.abortSession(sessionId);
719
+ }
720
+ }
721
+ /** Check if channel has a pending user input request. */
722
+ hasPendingUserInput(channelId) {
723
+ const queue = this.pendingUserInput.get(channelId);
724
+ return !!queue && queue.length > 0;
725
+ }
726
+ /** Get info about the current session for a channel. */
727
+ getSessionInfo(channelId) {
728
+ const sessionId = this.channelSessions.get(channelId);
729
+ if (!sessionId)
730
+ return null;
731
+ const prefs = this.getEffectivePrefs(channelId);
732
+ return { sessionId, model: prefs.model, agent: prefs.agent ?? null };
733
+ }
734
+ /** Get cached context window usage for a channel. */
735
+ getContextUsage(channelId) {
736
+ return this.contextUsage.get(channelId) ?? null;
737
+ }
738
+ /** List past sessions for this channel's working directory. */
739
+ async listChannelSessions(channelId) {
740
+ const workingDirectory = this.resolveWorkingDirectory(channelId);
741
+ const sessions = await this.bridge.listSessions({ cwd: workingDirectory });
742
+ const currentId = this.channelSessions.get(channelId);
743
+ return sessions.map(s => ({
744
+ sessionId: s.sessionId,
745
+ startTime: s.startTime,
746
+ modifiedTime: s.modifiedTime,
747
+ summary: s.summary,
748
+ isCurrent: s.sessionId === currentId,
749
+ }));
750
+ }
751
+ // --- Private helpers ---
752
+ /** Resolve working directory: SQLite workspace override → channel config → default workspace path. */
753
+ resolveWorkingDirectory(channelId) {
754
+ const botName = getChannelBotName(channelId);
755
+ const override = getWorkspaceOverride(botName);
756
+ if (override)
757
+ return override.workingDirectory;
758
+ const config = getChannelConfig(channelId);
759
+ if (config.workingDirectory)
760
+ return config.workingDirectory;
761
+ return getWorkspacePath(botName);
762
+ }
763
+ async createNewSession(channelId) {
764
+ const prefs = this.getEffectivePrefs(channelId);
765
+ const workingDirectory = this.resolveWorkingDirectory(channelId);
766
+ const defaultConfigDir = process.env.HOME ? `${process.env.HOME}/.copilot` : undefined;
767
+ const reasoningEffort = prefs.reasoningEffort;
768
+ const skillDirectories = discoverSkillDirectories(workingDirectory);
769
+ const customTools = this.buildCustomTools(channelId);
770
+ const session = await withWorkspaceEnv(workingDirectory, () => this.bridge.createSession({
771
+ model: prefs.model,
772
+ workingDirectory,
773
+ configDir: defaultConfigDir,
774
+ reasoningEffort: reasoningEffort ?? undefined,
775
+ mcpServers: this.resolveMcpServers(workingDirectory),
776
+ skillDirectories: skillDirectories.length > 0 ? skillDirectories : undefined,
777
+ onPermissionRequest: (request, invocation) => this.handlePermissionRequest(channelId, request, invocation),
778
+ onUserInputRequest: (request, invocation) => this.handleUserInputRequest(channelId, request, invocation),
779
+ tools: customTools.length > 0 ? customTools : undefined,
780
+ }));
781
+ const sessionId = session.sessionId;
782
+ this.channelSessions.set(channelId, sessionId);
783
+ this.sessionChannels.set(sessionId, channelId);
784
+ setChannelSession(channelId, sessionId);
785
+ this.attachSessionEvents(session, channelId);
786
+ log.info(`Created session ${sessionId} for channel ${channelId}`);
787
+ return sessionId;
788
+ }
789
+ async attachSession(channelId, sessionId) {
790
+ const prefs = this.getEffectivePrefs(channelId);
791
+ const workingDirectory = this.resolveWorkingDirectory(channelId);
792
+ const defaultConfigDir = process.env.HOME ? `${process.env.HOME}/.copilot` : undefined;
793
+ const reasoningEffort = prefs.reasoningEffort;
794
+ const skillDirectories = discoverSkillDirectories(workingDirectory);
795
+ const customTools = this.buildCustomTools(channelId);
796
+ const session = await withWorkspaceEnv(workingDirectory, () => this.bridge.resumeSession(sessionId, {
797
+ onPermissionRequest: (request, invocation) => this.handlePermissionRequest(channelId, request, invocation),
798
+ onUserInputRequest: (request, invocation) => this.handleUserInputRequest(channelId, request, invocation),
799
+ configDir: defaultConfigDir,
800
+ workingDirectory,
801
+ reasoningEffort: reasoningEffort ?? undefined,
802
+ mcpServers: this.resolveMcpServers(workingDirectory),
803
+ skillDirectories: skillDirectories.length > 0 ? skillDirectories : undefined,
804
+ tools: customTools.length > 0 ? customTools : undefined,
805
+ }));
806
+ this.channelSessions.set(channelId, sessionId);
807
+ this.sessionChannels.set(sessionId, channelId);
808
+ this.attachSessionEvents(session, channelId);
809
+ }
810
+ /**
811
+ * Execute an ephemeral inter-agent call: create a fresh session for the target bot,
812
+ * send the message, collect the response, and tear down.
813
+ */
814
+ async executeEphemeralCall(opts) {
815
+ const iaConfig = getInterAgentConfig();
816
+ const timeout = Math.min(opts.timeout ?? iaConfig.defaultTimeout ?? 60, iaConfig.maxTimeout ?? 300) * 1000; // convert to ms
817
+ const startTime = Date.now();
818
+ const nextContext = extendContext(opts.context, opts.targetBot);
819
+ // Resolve target bot's workspace
820
+ const targetWorkspace = getWorkspacePath(opts.targetBot);
821
+ const targetBotConfig = this.getTargetBotConfig(opts.targetBot);
822
+ // Resolve agent definition
823
+ const agentDef = resolveAgentDefinition(targetWorkspace, opts.agent, targetBotConfig?.agent);
824
+ // Build workspace awareness
825
+ const workspaceMap = getBotWorkspaceMap(opts.targetBot);
826
+ const workspacePrompt = buildWorkspacePrompt(workspaceMap);
827
+ const callerPrompt = buildCallerPrompt(opts.context);
828
+ // Build system message with inter-agent context
829
+ const systemParts = [callerPrompt, workspacePrompt];
830
+ if (agentDef) {
831
+ systemParts.push(`\n--- Agent Definition: ${agentDef.name} ---\n${agentDef.content}`);
832
+ }
833
+ // If the target has an ask_agent tool available, inject the chain context
834
+ if (nextContext.depth < (iaConfig.maxDepth ?? 3)) {
835
+ systemParts.push(`\nYou have the ask_agent tool available for calling other agents. Current call chain: ${nextContext.visited.join(' → ')}. Remaining depth: ${(iaConfig.maxDepth ?? 3) - nextContext.depth}.`);
836
+ }
837
+ const defaultConfigDir = process.env.HOME ? `${process.env.HOME}/.copilot` : undefined;
838
+ const skillDirectories = discoverSkillDirectories(targetWorkspace);
839
+ // Build ephemeral permission handler
840
+ const ephemeralPermissionHandler = this.buildEphemeralPermissionHandler(opts);
841
+ // Build custom tools for ephemeral session (ask_agent with propagated context)
842
+ // Pass target bot name so chained calls use B's identity (not A's channel)
843
+ const ephemeralTools = this.buildEphemeralTools(opts.targetBot, nextContext);
844
+ let session;
845
+ try {
846
+ session = await withWorkspaceEnv(targetWorkspace, () => this.bridge.createSession({
847
+ workingDirectory: targetWorkspace,
848
+ configDir: defaultConfigDir,
849
+ mcpServers: this.resolveMcpServers(targetWorkspace),
850
+ skillDirectories: skillDirectories.length > 0 ? skillDirectories : undefined,
851
+ onPermissionRequest: ephemeralPermissionHandler,
852
+ systemMessage: { content: systemParts.filter(Boolean).join('\n\n') },
853
+ tools: ephemeralTools.length > 0 ? ephemeralTools : undefined,
854
+ }));
855
+ // Send message and wait for idle
856
+ const response = await this.sendAndWaitForIdle(session, opts.message, timeout);
857
+ const durationMs = Date.now() - startTime;
858
+ recordAgentCall({
859
+ callerBot: opts.callerBot,
860
+ targetBot: opts.targetBot,
861
+ targetAgent: opts.agent,
862
+ messageSummary: opts.message.slice(0, 500),
863
+ responseSummary: response.slice(0, 500),
864
+ durationMs,
865
+ success: true,
866
+ chainId: opts.context.chainId,
867
+ depth: nextContext.depth,
868
+ });
869
+ log.info(`Ephemeral call ${opts.callerBot}→${opts.targetBot}: ${durationMs}ms, ${response.length} chars`);
870
+ return { success: true, response };
871
+ }
872
+ catch (err) {
873
+ const durationMs = Date.now() - startTime;
874
+ const errorMsg = err?.message ?? 'unknown error';
875
+ recordAgentCall({
876
+ callerBot: opts.callerBot,
877
+ targetBot: opts.targetBot,
878
+ targetAgent: opts.agent,
879
+ messageSummary: opts.message.slice(0, 500),
880
+ durationMs,
881
+ success: false,
882
+ error: errorMsg,
883
+ chainId: opts.context.chainId,
884
+ depth: nextContext.depth,
885
+ });
886
+ log.error(`Ephemeral call ${opts.callerBot}→${opts.targetBot} failed: ${errorMsg}`);
887
+ return { success: false, error: 'ephemeral_session_error', detail: errorMsg };
888
+ }
889
+ finally {
890
+ if (session) {
891
+ try {
892
+ await this.bridge.destroySession(session.sessionId);
893
+ }
894
+ catch { /* best-effort */ }
895
+ }
896
+ }
897
+ }
898
+ /** Send a message to a session and wait for session.idle, collecting streamed response text. */
899
+ sendAndWaitForIdle(session, message, timeoutMs) {
900
+ return new Promise((resolve, reject) => {
901
+ const chunks = [];
902
+ let settled = false;
903
+ const timer = setTimeout(() => {
904
+ if (!settled) {
905
+ settled = true;
906
+ unsub();
907
+ reject(new Error(`Ephemeral session timed out after ${timeoutMs / 1000}s`));
908
+ }
909
+ }, timeoutMs);
910
+ const unsub = session.on((event) => {
911
+ if (settled)
912
+ return;
913
+ if (event.type === 'assistant.message_delta') {
914
+ const text = event.data?.deltaContent ?? event.data?.text ?? event.deltaContent ?? '';
915
+ if (text)
916
+ chunks.push(text);
917
+ }
918
+ if (event.type === 'assistant.message' && event.data?.content && chunks.length === 0) {
919
+ // Full message event — only use as fallback when no deltas were received
920
+ chunks.push(event.data.content);
921
+ }
922
+ if (event.type === 'session.idle') {
923
+ settled = true;
924
+ clearTimeout(timer);
925
+ unsub();
926
+ resolve(chunks.join(''));
927
+ }
928
+ if (event.type === 'session.error') {
929
+ settled = true;
930
+ clearTimeout(timer);
931
+ unsub();
932
+ reject(new Error(event.data?.message ?? 'Session error'));
933
+ }
934
+ });
935
+ session.send({ prompt: message }).catch((err) => {
936
+ if (!settled) {
937
+ settled = true;
938
+ clearTimeout(timer);
939
+ unsub();
940
+ reject(err);
941
+ }
942
+ });
943
+ });
944
+ }
945
+ /** Build a permission handler for ephemeral sessions with merged caller+target rules. */
946
+ buildEphemeralPermissionHandler(opts) {
947
+ return async (request, _invocation) => {
948
+ const reqKind = request.kind;
949
+ const reqCommand = typeof request.fullCommandText === 'string'
950
+ ? request.fullCommandText
951
+ : typeof request.command === 'string' ? request.command : undefined;
952
+ // 1. Hardcoded safety denies — always enforced
953
+ if (isHardDeny(reqKind, reqCommand)) {
954
+ return { kind: 'denied-by-rules' };
955
+ }
956
+ // 2. Caller's explicit denies — checked before auto-approve
957
+ if (opts.denyTools && opts.denyTools.length > 0) {
958
+ const toolName = request.toolName ?? request.tool_name ?? request.name ?? reqKind;
959
+ if (opts.denyTools.includes(toolName)) {
960
+ return { kind: 'denied-by-rules' };
961
+ }
962
+ }
963
+ // 3. Auto-approve bridge custom tools
964
+ if (reqKind === 'custom-tool') {
965
+ const reqToolName = request.toolName;
966
+ if (BRIDGE_CUSTOM_TOOLS.includes(reqToolName)) {
967
+ return { kind: 'approved' };
968
+ }
969
+ }
970
+ // 4. Caller's explicit grants (only if caller has them)
971
+ if (opts.grantTools && opts.grantTools.length > 0) {
972
+ const toolName = request.toolName ?? request.tool_name ?? request.name ?? reqKind;
973
+ if (opts.grantTools.includes(toolName)) {
974
+ // Verify caller has this permission
975
+ const callerResult = checkPermission(opts.callerChannelId, toolName, '*');
976
+ if (callerResult === 'allow') {
977
+ return { kind: 'approved' };
978
+ }
979
+ }
980
+ }
981
+ // 5. Target bot's own stored permission rules
982
+ // Use a synthetic scope for the target bot
983
+ const targetScope = `bot:${opts.targetBot}`;
984
+ const toolName = request.toolName ?? request.tool_name ?? request.name ?? reqKind;
985
+ const storedResult = checkPermission(targetScope, toolName, '*');
986
+ if (storedResult === 'allow')
987
+ return { kind: 'approved' };
988
+ if (storedResult === 'deny')
989
+ return { kind: 'denied-by-rules' };
990
+ // 6. Caller channel's stored rules (merged — supplement target)
991
+ const callerResult = checkPermission(opts.callerChannelId, toolName, '*');
992
+ if (callerResult === 'allow')
993
+ return { kind: 'approved' };
994
+ // 7. Autopilot: approve remaining if enabled
995
+ if (opts.autopilot) {
996
+ return { kind: 'approved' };
997
+ }
998
+ // 8. No rule matched — deny with detail (no human to ask in ephemeral sessions)
999
+ log.warn(`Ephemeral permission denied (no rule): ${toolName} for ${opts.targetBot}`);
1000
+ return { kind: 'denied-no-approval-rule-and-could-not-request-from-user' };
1001
+ };
1002
+ }
1003
+ /** Build custom tools for an ephemeral inter-agent session. */
1004
+ buildEphemeralTools(currentBotName, context) {
1005
+ const tools = [];
1006
+ const iaConfig = getInterAgentConfig();
1007
+ // Only register ask_agent if there's remaining depth
1008
+ if (iaConfig.enabled && context.depth < (iaConfig.maxDepth ?? 3)) {
1009
+ tools.push(this.buildAskAgentToolDef(currentBotName, context, true));
1010
+ }
1011
+ return tools;
1012
+ }
1013
+ /** Get target bot config across all platforms. */
1014
+ getTargetBotConfig(botName) {
1015
+ const config = getConfig();
1016
+ for (const platform of Object.values(config.platforms)) {
1017
+ if (platform.bots?.[botName]) {
1018
+ return platform.bots[botName];
1019
+ }
1020
+ }
1021
+ return null;
1022
+ }
1023
+ /** Build the ask_agent tool definition (shared by normal and ephemeral sessions).
1024
+ * When callerBotDirect is true, channelIdOrBot is the bot name directly (for ephemeral sessions). */
1025
+ buildAskAgentToolDef(channelIdOrBot, parentContext, callerBotDirect = false) {
1026
+ const callerBot = callerBotDirect ? channelIdOrBot : getChannelBotName(channelIdOrBot);
1027
+ const channelId = callerBotDirect ? undefined : channelIdOrBot;
1028
+ return {
1029
+ name: 'ask_agent',
1030
+ description: 'Ask another agent a question. Creates a fresh session for the target agent with its own workspace, tools, and knowledge. Use this when you need information or capabilities from a different bot identity (e.g., asking Alice about home automation, asking a specialist about their domain). IMPORTANT: The user cannot see the inter-agent exchange. After receiving the response, communicate the relevant information back to the user.',
1031
+ parameters: {
1032
+ type: 'object',
1033
+ properties: {
1034
+ target: {
1035
+ type: 'string',
1036
+ description: 'The bot name to ask (e.g., "alice", "copilot"). Must be configured in the inter-agent allowlist.',
1037
+ },
1038
+ message: {
1039
+ type: 'string',
1040
+ description: 'The question or request to send to the target agent.',
1041
+ },
1042
+ agent: {
1043
+ type: 'string',
1044
+ description: 'Optional: specific agent persona to use (matches *.agent.md file in the target\'s workspace/agents/ directory).',
1045
+ },
1046
+ timeout: {
1047
+ type: 'number',
1048
+ description: 'Optional: timeout in seconds (default from config, capped at maxTimeout).',
1049
+ },
1050
+ autopilot: {
1051
+ type: 'boolean',
1052
+ description: 'Optional: auto-approve tool permissions in the target session (default: false). Enable for trusted queries that may require tool use.',
1053
+ },
1054
+ denyTools: {
1055
+ type: 'array',
1056
+ items: { type: 'string' },
1057
+ description: 'Optional: tool names to deny in the target session (e.g., ["bash"] for read-only queries).',
1058
+ },
1059
+ grantTools: {
1060
+ type: 'array',
1061
+ items: { type: 'string' },
1062
+ description: 'Optional: tool names to pre-approve in the target session. Only effective if you (the caller) also have those tools approved.',
1063
+ },
1064
+ },
1065
+ required: ['target', 'message'],
1066
+ },
1067
+ handler: async (args) => {
1068
+ try {
1069
+ // Build or extend context
1070
+ const context = parentContext
1071
+ ? parentContext
1072
+ : createContext(callerBot, channelId);
1073
+ // Pre-flight: check if the call is allowed
1074
+ const blocked = canCall(callerBot, args.target, context);
1075
+ if (blocked) {
1076
+ return { content: JSON.stringify({ success: false, error: 'not_allowed', detail: blocked }) };
1077
+ }
1078
+ const result = await this.executeEphemeralCall({
1079
+ callerBot,
1080
+ targetBot: args.target,
1081
+ message: args.message,
1082
+ context,
1083
+ agent: args.agent,
1084
+ timeout: args.timeout,
1085
+ autopilot: args.autopilot,
1086
+ denyTools: args.denyTools,
1087
+ grantTools: args.grantTools,
1088
+ callerChannelId: channelId ?? `bot:${callerBot}`,
1089
+ });
1090
+ return { content: JSON.stringify(result) };
1091
+ }
1092
+ catch (err) {
1093
+ return { content: JSON.stringify({ success: false, error: 'tool_error', detail: err?.message ?? 'unknown error' }) };
1094
+ }
1095
+ },
1096
+ };
1097
+ }
1098
+ /** Build custom tool definitions to pass to SDK session creation. */
1099
+ buildCustomTools(channelId) {
1100
+ const tools = [];
1101
+ if (this.sendFileHandler) {
1102
+ const handler = this.sendFileHandler;
1103
+ const config = getChannelConfig(channelId);
1104
+ const botName = getChannelBotName(channelId);
1105
+ const workDir = this.resolveWorkingDirectory(channelId);
1106
+ const allowPaths = getWorkspaceAllowPaths(botName, config.platform);
1107
+ tools.push({
1108
+ name: 'send_file',
1109
+ description: 'Send a file or image from the workspace to the user in their chat channel. The file will appear as an inline image (for image types) or a downloadable attachment.',
1110
+ parameters: {
1111
+ type: 'object',
1112
+ properties: {
1113
+ path: { type: 'string', description: 'Path to the file to send (absolute or relative to workspace)' },
1114
+ message: { type: 'string', description: 'Optional message to accompany the file' },
1115
+ },
1116
+ required: ['path'],
1117
+ },
1118
+ handler: async (args) => {
1119
+ try {
1120
+ // Resolve relative paths against workspace
1121
+ const resolved = path.isAbsolute(args.path) ? path.resolve(args.path) : path.resolve(workDir, args.path);
1122
+ // Resolve symlinks to prevent traversal via symlink targets
1123
+ let realPath;
1124
+ try {
1125
+ realPath = fs.realpathSync(resolved);
1126
+ }
1127
+ catch {
1128
+ return { content: 'File not found.' };
1129
+ }
1130
+ // Validate the real file path is within workspace or allowed paths
1131
+ const allowed = [workDir, ...allowPaths];
1132
+ const isAllowed = allowed.some(dir => realPath.startsWith(path.resolve(dir) + path.sep) || realPath === path.resolve(dir));
1133
+ if (!isAllowed) {
1134
+ log.warn(`send_file blocked: "${realPath}" is outside workspace for channel ${channelId.slice(0, 8)}...`);
1135
+ return { content: 'File path is outside the allowed workspace. Only files within your workspace can be sent.' };
1136
+ }
1137
+ await handler(channelId, realPath, args.message);
1138
+ return { content: `File sent: ${path.basename(realPath)}` };
1139
+ }
1140
+ catch (err) {
1141
+ log.error(`send_file failed for channel ${channelId.slice(0, 8)}...:`, err);
1142
+ return { content: `Failed to send file: ${err?.message ?? 'unknown error'}` };
1143
+ }
1144
+ },
1145
+ });
1146
+ }
1147
+ // Show file contents in chat (renamed from show_file — CLI doesn't support overridesBuiltInTool yet)
1148
+ if (this.getAdapterForChannel) {
1149
+ const adapterResolver = this.getAdapterForChannel;
1150
+ const showWorkDir = this.resolveWorkingDirectory(channelId);
1151
+ const showBotName = getChannelBotName(channelId);
1152
+ const showConfig = getChannelConfig(channelId);
1153
+ const showAllowPaths = getWorkspaceAllowPaths(showBotName, showConfig.platform);
1154
+ tools.push({
1155
+ name: 'show_file_in_chat',
1156
+ description: 'Show file contents to the user in their chat channel as a formatted code block. Prefer this over the built-in show_file which only works in terminal. Use when the user asks to see a file, code snippet, or diff. Supports optional line range. For diffs, set diff: true to show pending git changes.',
1157
+ parameters: {
1158
+ type: 'object',
1159
+ properties: {
1160
+ path: { type: 'string', description: 'Full absolute path to the file to show.' },
1161
+ view_range: {
1162
+ type: 'array', items: { type: 'integer' },
1163
+ description: 'Optional [start, end] line range. [start, -1] shows from start to end of file.',
1164
+ },
1165
+ diff: { type: 'boolean', description: 'When true, show pending git diff instead of file contents.' },
1166
+ },
1167
+ required: ['path'],
1168
+ },
1169
+ handler: async (args) => {
1170
+ try {
1171
+ const resolved = path.isAbsolute(args.path) ? path.resolve(args.path) : path.resolve(showWorkDir, args.path);
1172
+ let realPath;
1173
+ try {
1174
+ realPath = fs.realpathSync(resolved);
1175
+ }
1176
+ catch {
1177
+ return { content: 'File not found.' };
1178
+ }
1179
+ const allowed = [showWorkDir, ...showAllowPaths];
1180
+ const isAllowed = allowed.some(dir => realPath.startsWith(path.resolve(dir) + path.sep) || realPath === path.resolve(dir));
1181
+ if (!isAllowed) {
1182
+ log.warn(`show_file blocked: "${realPath}" is outside workspace for channel ${channelId.slice(0, 8)}...`);
1183
+ return { content: 'File path is outside the allowed workspace.' };
1184
+ }
1185
+ const adapter = adapterResolver(channelId);
1186
+ if (!adapter)
1187
+ return { content: 'No adapter available for this channel.' };
1188
+ const ext = path.extname(realPath).slice(1) || 'txt';
1189
+ const fileName = path.basename(realPath);
1190
+ let content;
1191
+ if (args.diff) {
1192
+ const { execFileSync } = await import('node:child_process');
1193
+ const dir = path.dirname(realPath);
1194
+ try {
1195
+ content = execFileSync('git', ['diff', '--', realPath], { cwd: dir, encoding: 'utf-8', timeout: 5000 });
1196
+ if (!content.trim())
1197
+ content = '(no pending changes)';
1198
+ }
1199
+ catch {
1200
+ content = '(not a git repository or git diff failed)';
1201
+ }
1202
+ await adapter.sendMessage(channelId, `**${fileName}** (diff)\n\`\`\`\`diff\n${content}\n\`\`\`\``);
1203
+ }
1204
+ else {
1205
+ const fullContent = fs.readFileSync(realPath, 'utf-8');
1206
+ let lines = fullContent.split('\n');
1207
+ if (args.view_range && args.view_range.length === 2) {
1208
+ const [start, end] = args.view_range;
1209
+ const startIdx = Math.max(0, start - 1);
1210
+ const endIdx = end === -1 ? lines.length : Math.min(end, lines.length);
1211
+ lines = lines.slice(startIdx, endIdx);
1212
+ }
1213
+ content = lines.join('\n');
1214
+ const MAX_CHARS = 8000;
1215
+ let truncated = false;
1216
+ if (content.length > MAX_CHARS) {
1217
+ content = content.slice(0, MAX_CHARS);
1218
+ truncated = true;
1219
+ }
1220
+ // Use 4-backtick fence to avoid breaking if content contains ```
1221
+ const rangeLabel = args.view_range ? ` (lines ${args.view_range[0]}–${args.view_range[1] === -1 ? 'end' : args.view_range[1]})` : '';
1222
+ let msg = `**${fileName}**${rangeLabel}\n\`\`\`\`${ext}\n${content}\n\`\`\`\``;
1223
+ if (truncated)
1224
+ msg += '\n*(truncated — file too large for chat)*';
1225
+ await adapter.sendMessage(channelId, msg);
1226
+ }
1227
+ return { content: `Showed ${fileName} to user in chat.` };
1228
+ }
1229
+ catch (err) {
1230
+ log.error(`show_file failed for channel ${channelId.slice(0, 8)}...:`, err);
1231
+ return { content: `Failed to show file: ${err?.message ?? 'unknown error'}` };
1232
+ }
1233
+ },
1234
+ });
1235
+ }
1236
+ // Admin-only onboarding tools
1237
+ const config = getChannelConfig(channelId);
1238
+ const botName = getChannelBotName(channelId);
1239
+ const isAdmin = isBotAdmin(config.platform, botName);
1240
+ if (isAdmin && this.getAdapterForChannel) {
1241
+ const adapterResolver = this.getAdapterForChannel;
1242
+ // Tool: get_platform_info — returns available teams, bots, and defaults
1243
+ tools.push({
1244
+ name: 'get_platform_info',
1245
+ description: 'Get information about the bridge platform: available teams, bot names, and defaults. Use this when onboarding a new project to present options to the user.',
1246
+ parameters: { type: 'object', properties: {} },
1247
+ handler: async () => {
1248
+ try {
1249
+ const adapter = adapterResolver(channelId);
1250
+ if (!adapter?.getTeams)
1251
+ return { content: 'Platform does not support team listing.' };
1252
+ const teams = await adapter.getTeams();
1253
+ const appConfig = getConfig();
1254
+ const platformConfig = appConfig.platforms[config.platform];
1255
+ const botNames = platformConfig.bots ? Object.keys(platformConfig.bots) : ['default'];
1256
+ return {
1257
+ content: JSON.stringify({
1258
+ teams: teams.map(t => ({ id: t.id, name: t.name, displayName: t.displayName })),
1259
+ bots: botNames,
1260
+ defaults: {
1261
+ model: appConfig.defaults.model,
1262
+ triggerMode: appConfig.defaults.triggerMode,
1263
+ threadedReplies: appConfig.defaults.threadedReplies,
1264
+ },
1265
+ }, null, 2),
1266
+ };
1267
+ }
1268
+ catch (err) {
1269
+ return { content: `Error: ${err?.message ?? 'unknown'}` };
1270
+ }
1271
+ },
1272
+ });
1273
+ // Tool: create_project — full onboarding orchestration
1274
+ tools.push({
1275
+ name: 'create_project',
1276
+ description: 'Create a new project: set up a Mattermost channel, assign a bot, initialize the workspace, and optionally clone a git repo. The channel is immediately active after creation. Use get_platform_info first to get team IDs and available bots.',
1277
+ parameters: {
1278
+ type: 'object',
1279
+ properties: {
1280
+ project_name: { type: 'string', description: 'Human-readable project name (e.g., "Widget API"). Will be slugified for the channel name.' },
1281
+ bot_name: { type: 'string', description: 'Bot to assign (e.g., "copilot", "bob"). Must be a configured bot name.' },
1282
+ team_id: { type: 'string', description: 'Mattermost team ID (from get_platform_info).' },
1283
+ private: { type: 'boolean', description: 'Create a private channel. Ask the user: private or public?' },
1284
+ workspace_path: { type: 'string', description: 'Workspace directory path. Ask the user — default is ~/.copilot-bridge/workspaces/<project-slug>/.' },
1285
+ repo_url: { type: 'string', description: 'Git repository URL to clone into the workspace. Optional — skip for new projects.' },
1286
+ user_id: { type: 'string', description: 'Mattermost user ID of the requesting user, to add them to the channel.' },
1287
+ trigger_mode: { type: 'string', enum: ['all', 'mention'], description: 'How the bot responds. Ask the user: "all" (every message) or "mention" (only when @mentioned).' },
1288
+ threaded_replies: { type: 'boolean', description: 'Whether the bot replies in threads. Ask the user: yes or no.' },
1289
+ },
1290
+ required: ['project_name', 'bot_name', 'team_id', 'private', 'workspace_path', 'trigger_mode', 'threaded_replies'],
1291
+ },
1292
+ handler: async (args) => {
1293
+ try {
1294
+ const adapter = adapterResolver(channelId);
1295
+ if (!adapter)
1296
+ return { content: 'Error: No adapter available for this channel.' };
1297
+ const result = await onboardProject(adapter, {
1298
+ projectName: args.project_name,
1299
+ botName: args.bot_name,
1300
+ platform: config.platform,
1301
+ teamId: args.team_id,
1302
+ private: args.private,
1303
+ workspacePath: args.workspace_path,
1304
+ repoUrl: args.repo_url,
1305
+ userId: args.user_id ?? this.lastMessageUserIds.get(channelId),
1306
+ triggerMode: args.trigger_mode,
1307
+ threadedReplies: args.threaded_replies,
1308
+ });
1309
+ return {
1310
+ content: [
1311
+ `✅ Project "${args.project_name}" created:`,
1312
+ ...result.steps.map(s => ` - ${s}`),
1313
+ '',
1314
+ `Channel: #${result.channelName}`,
1315
+ `Workspace: ${result.workspacePath}`,
1316
+ result.cloned ? `Repo cloned: ${args.repo_url}` : '',
1317
+ ].filter(Boolean).join('\n'),
1318
+ };
1319
+ }
1320
+ catch (err) {
1321
+ log.error(`create_project failed:`, err);
1322
+ return { content: `Failed to create project: ${err?.message ?? 'unknown error'}` };
1323
+ }
1324
+ },
1325
+ });
1326
+ // Tool: grant_path_access — add an extra allowed path for an agent
1327
+ tools.push({
1328
+ name: 'grant_path_access',
1329
+ description: 'Grant an agent read/write access to an additional folder beyond its workspace. Updates the workspace_overrides table in SQLite.',
1330
+ parameters: {
1331
+ type: 'object',
1332
+ properties: {
1333
+ bot_name: { type: 'string', description: 'The bot/agent name (e.g., "inbox", "bob").' },
1334
+ path: { type: 'string', description: 'Absolute path to the folder to grant access to.' },
1335
+ },
1336
+ required: ['bot_name', 'path'],
1337
+ },
1338
+ handler: async (args) => {
1339
+ try {
1340
+ const existing = getWorkspaceOverride(args.bot_name);
1341
+ const workDir = existing?.workingDirectory ?? getWorkspacePath(args.bot_name);
1342
+ const currentPaths = existing?.allowPaths ?? [];
1343
+ const resolvedPath = path.resolve(args.path);
1344
+ // Block sensitive paths (bidirectional: parent-of or child-of blocked)
1345
+ const home = os.homedir();
1346
+ const blocked = [home, path.join(home, '.ssh'), path.join(home, '.aws'), path.join(home, '.gnupg'),
1347
+ path.join(home, '.copilot-bridge'), '/etc', '/var', '/usr', '/System', '/private'];
1348
+ if (resolvedPath === '/') {
1349
+ return { content: '❌ Refused: cannot grant access to filesystem root.' };
1350
+ }
1351
+ for (const b of blocked) {
1352
+ if (resolvedPath === b || b.startsWith(resolvedPath + path.sep) || resolvedPath.startsWith(b + path.sep)) {
1353
+ return { content: `❌ Refused: "${resolvedPath}" overlaps with sensitive directory "${b}". Grant a more specific, non-sensitive subdirectory instead.` };
1354
+ }
1355
+ }
1356
+ if (currentPaths.includes(resolvedPath)) {
1357
+ return { content: `"${args.bot_name}" already has access to ${resolvedPath}.` };
1358
+ }
1359
+ const newPaths = [...currentPaths, resolvedPath];
1360
+ setWorkspaceOverride(args.bot_name, workDir, newPaths);
1361
+ return {
1362
+ content: `✅ Granted "${args.bot_name}" access to ${resolvedPath}.\nCurrent allowed paths: ${JSON.stringify(newPaths)}\n\nTo apply: delete the agent's AGENTS.md and run /new in its channel (or restart the bridge).`,
1363
+ };
1364
+ }
1365
+ catch (err) {
1366
+ return { content: `Failed: ${err?.message ?? 'unknown error'}` };
1367
+ }
1368
+ },
1369
+ });
1370
+ // Tool: revoke_path_access — remove an allowed path from an agent
1371
+ tools.push({
1372
+ name: 'revoke_path_access',
1373
+ description: 'Remove an extra allowed folder from an agent. Does not affect its workspace directory.',
1374
+ parameters: {
1375
+ type: 'object',
1376
+ properties: {
1377
+ bot_name: { type: 'string', description: 'The bot/agent name.' },
1378
+ path: { type: 'string', description: 'Absolute path to revoke access from.' },
1379
+ },
1380
+ required: ['bot_name', 'path'],
1381
+ },
1382
+ handler: async (args) => {
1383
+ try {
1384
+ const existing = getWorkspaceOverride(args.bot_name);
1385
+ if (!existing)
1386
+ return { content: `No workspace override found for "${args.bot_name}".` };
1387
+ const resolvedPath = path.resolve(args.path);
1388
+ const newPaths = existing.allowPaths.filter(p => path.resolve(p) !== resolvedPath);
1389
+ setWorkspaceOverride(args.bot_name, existing.workingDirectory, newPaths);
1390
+ return {
1391
+ content: `✅ Revoked "${args.bot_name}" access to ${resolvedPath}.\nRemaining allowed paths: ${JSON.stringify(newPaths)}\n\nTo apply: delete the agent's AGENTS.md and run /new in its channel (or restart the bridge).`,
1392
+ };
1393
+ }
1394
+ catch (err) {
1395
+ return { content: `Failed: ${err?.message ?? 'unknown error'}` };
1396
+ }
1397
+ },
1398
+ });
1399
+ // Tool: list_agent_access — show workspace info for all agents
1400
+ tools.push({
1401
+ name: 'list_agent_access',
1402
+ description: 'List all agents and their workspace paths and extra allowed folders.',
1403
+ parameters: { type: 'object', properties: {} },
1404
+ handler: async () => {
1405
+ try {
1406
+ const overrides = listWorkspaceOverrides();
1407
+ const overrideMap = new Map(overrides.map(o => [o.botName, o]));
1408
+ // Enumerate all configured bots across platforms
1409
+ const config = getConfig();
1410
+ const botNames = new Set();
1411
+ for (const platform of Object.values(config.platforms)) {
1412
+ if (platform.bots) {
1413
+ for (const name of Object.keys(platform.bots))
1414
+ botNames.add(name);
1415
+ }
1416
+ }
1417
+ // Include any bots that have overrides but aren't in config
1418
+ for (const o of overrides)
1419
+ botNames.add(o.botName);
1420
+ if (botNames.size === 0)
1421
+ return { content: 'No agents configured.' };
1422
+ const lines = [...botNames].sort().map(name => {
1423
+ const override = overrideMap.get(name);
1424
+ const workspace = override?.workingDirectory ?? getWorkspacePath(name);
1425
+ const extra = override?.allowPaths ?? [];
1426
+ return `**${name}**\n Workspace: ${workspace}\n Extra paths: ${extra.length > 0 ? extra.join(', ') : '(none)'}`;
1427
+ });
1428
+ return { content: lines.join('\n\n') };
1429
+ }
1430
+ catch (err) {
1431
+ return { content: `Failed: ${err?.message ?? 'unknown error'}` };
1432
+ }
1433
+ },
1434
+ });
1435
+ }
1436
+ // Inter-agent tool: ask_agent (only when enabled in config)
1437
+ const iaConfig = getInterAgentConfig();
1438
+ if (iaConfig.enabled) {
1439
+ tools.push(this.buildAskAgentToolDef(channelId));
1440
+ }
1441
+ // Scheduler tool: create/list/cancel/pause/resume scheduled tasks
1442
+ tools.push(this.buildScheduleToolDef(channelId));
1443
+ if (tools.length > 0) {
1444
+ log.info(`Built ${tools.length} custom tool(s) for channel ${channelId.slice(0, 8)}...`);
1445
+ }
1446
+ return tools;
1447
+ }
1448
+ /** Build the schedule tool definition for creating/managing scheduled tasks. */
1449
+ buildScheduleToolDef(channelId) {
1450
+ const botName = getChannelBotName(channelId);
1451
+ return {
1452
+ name: 'schedule',
1453
+ description: 'Create, list, cancel, pause, or resume scheduled tasks. Tasks fire at the specified time and send a prompt to the LLM for processing. Supports cron expressions for recurring tasks and ISO datetimes for one-off tasks.',
1454
+ parameters: {
1455
+ type: 'object',
1456
+ properties: {
1457
+ action: {
1458
+ type: 'string',
1459
+ enum: ['create', 'list', 'cancel', 'pause', 'resume'],
1460
+ description: 'The action to perform.',
1461
+ },
1462
+ prompt: {
1463
+ type: 'string',
1464
+ description: 'The prompt to send when the job fires. Required for "create".',
1465
+ },
1466
+ cron: {
1467
+ type: 'string',
1468
+ description: 'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" for weekdays at 9am). Use standard 5-field cron syntax.',
1469
+ },
1470
+ run_at: {
1471
+ type: 'string',
1472
+ description: 'ISO 8601 datetime for one-off tasks. IMPORTANT: current_datetime is UTC — use a Z suffix (e.g., "2026-03-09T22:31:00Z") or properly convert to local time before adding an offset. Do NOT take the UTC hour and attach a non-UTC offset. Mutually exclusive with cron.',
1473
+ },
1474
+ timezone: {
1475
+ type: 'string',
1476
+ description: 'IANA timezone for display and cron scheduling (e.g., "America/Los_Angeles"). Defaults to UTC.',
1477
+ },
1478
+ description: {
1479
+ type: 'string',
1480
+ description: 'Human-readable label for the task.',
1481
+ },
1482
+ id: {
1483
+ type: 'string',
1484
+ description: 'Task ID. Required for cancel/pause/resume.',
1485
+ },
1486
+ },
1487
+ required: ['action'],
1488
+ },
1489
+ handler: async (args) => {
1490
+ try {
1491
+ switch (args.action) {
1492
+ case 'create': {
1493
+ if (!args.prompt)
1494
+ return { content: 'Error: prompt is required for create.' };
1495
+ if (!args.cron && !args.run_at)
1496
+ return { content: 'Error: either cron or run_at is required.' };
1497
+ const task = addJob({
1498
+ channelId,
1499
+ botName,
1500
+ prompt: args.prompt,
1501
+ cronExpr: args.cron,
1502
+ runAt: args.run_at,
1503
+ timezone: args.timezone,
1504
+ description: args.description,
1505
+ createdBy: this.lastMessageUserIds.get(channelId),
1506
+ });
1507
+ const type = task.cronExpr ? `recurring (${task.cronExpr})` : `one-off (${task.runAt})`;
1508
+ const tz = task.timezone ?? 'UTC';
1509
+ const nextRunLocal = task.nextRun ? formatInTimezone(task.nextRun, tz) : undefined;
1510
+ return { content: JSON.stringify({ success: true, id: task.id, type, nextRun: nextRunLocal, timezone: tz, description: task.description }) };
1511
+ }
1512
+ case 'list': {
1513
+ const tasks = listJobs(channelId);
1514
+ if (tasks.length === 0)
1515
+ return { content: 'No scheduled tasks for this channel.' };
1516
+ const summary = tasks.map(t => {
1517
+ const tz = t.timezone ?? 'UTC';
1518
+ return {
1519
+ id: t.id,
1520
+ description: t.description ?? t.prompt.slice(0, 60),
1521
+ type: t.cronExpr ? 'recurring' : 'one-off',
1522
+ schedule: t.cronExpr ?? t.runAt,
1523
+ timezone: tz,
1524
+ enabled: t.enabled,
1525
+ lastRun: t.lastRun ? formatInTimezone(t.lastRun, tz) : undefined,
1526
+ nextRun: t.nextRun ? formatInTimezone(t.nextRun, tz) : undefined,
1527
+ };
1528
+ });
1529
+ return { content: JSON.stringify(summary) };
1530
+ }
1531
+ case 'cancel': {
1532
+ if (!args.id)
1533
+ return { content: 'Error: id is required for cancel.' };
1534
+ const removed = removeJob(args.id, channelId);
1535
+ return { content: removed ? `Task ${args.id} cancelled and removed.` : `Task ${args.id} not found.` };
1536
+ }
1537
+ case 'pause': {
1538
+ if (!args.id)
1539
+ return { content: 'Error: id is required for pause.' };
1540
+ const paused = pauseJob(args.id, channelId);
1541
+ return { content: paused ? `Task ${args.id} paused.` : `Task ${args.id} not found.` };
1542
+ }
1543
+ case 'resume': {
1544
+ if (!args.id)
1545
+ return { content: 'Error: id is required for resume.' };
1546
+ const resumed = resumeJob(args.id, channelId);
1547
+ return { content: resumed ? `Task ${args.id} resumed.` : `Task ${args.id} not found or failed to resume.` };
1548
+ }
1549
+ default:
1550
+ return { content: `Unknown action: ${args.action}. Use create, list, cancel, pause, or resume.` };
1551
+ }
1552
+ }
1553
+ catch (err) {
1554
+ return { content: `Schedule error: ${err?.message ?? 'unknown error'}` };
1555
+ }
1556
+ },
1557
+ };
1558
+ }
1559
+ attachSessionEvents(session, channelId) {
1560
+ const unsub = session.on((event) => {
1561
+ if (event.type === 'session.usage_info' && event.data) {
1562
+ this.contextUsage.set(channelId, {
1563
+ currentTokens: event.data.currentTokens,
1564
+ tokenLimit: event.data.tokenLimit,
1565
+ });
1566
+ }
1567
+ this.eventHandler?.(session.sessionId, channelId, event);
1568
+ });
1569
+ this.sessionUnsubscribes.set(session.sessionId, unsub);
1570
+ }
1571
+ handlePermissionRequest(channelId, request, invocation) {
1572
+ const prefs = this.getEffectivePrefs(channelId);
1573
+ // Hardcoded safety denies — checked before autopilot, cannot be overridden
1574
+ const reqKind = request.kind;
1575
+ const reqCommand = typeof request.fullCommandText === 'string' ? request.fullCommandText
1576
+ : typeof request.command === 'string' ? request.command : undefined;
1577
+ if (isHardDeny(reqKind, reqCommand)) {
1578
+ return Promise.resolve({ kind: 'denied-by-rules' });
1579
+ }
1580
+ // Auto-approve bridge custom tools (they enforce their own workspace boundaries)
1581
+ if (reqKind === 'custom-tool') {
1582
+ const reqToolName = request.toolName;
1583
+ if (BRIDGE_CUSTOM_TOOLS.includes(reqToolName)) {
1584
+ return Promise.resolve({ kind: 'approved' });
1585
+ }
1586
+ }
1587
+ // Autopilot mode: allow everything (after safety checks)
1588
+ if (prefs.permissionMode === 'autopilot') {
1589
+ return Promise.resolve({ kind: 'approved' });
1590
+ }
1591
+ // Check config-level permission rules first (CLI-compatible syntax)
1592
+ const config = getChannelConfig(channelId);
1593
+ const botName = getChannelBotName(channelId);
1594
+ const resolvedDir = this.resolveWorkingDirectory(channelId);
1595
+ const workspaceAllowPaths = getWorkspaceAllowPaths(botName, config.platform);
1596
+ const configResult = evaluateConfigPermissions(request, resolvedDir, workspaceAllowPaths, isBotAdmin(config.platform, botName));
1597
+ if (configResult === 'allow') {
1598
+ return Promise.resolve({ kind: 'approved' });
1599
+ }
1600
+ if (configResult === 'deny') {
1601
+ return Promise.resolve({ kind: 'denied-by-rules' });
1602
+ }
1603
+ // Check stored permission rules (SQLite, from /remember)
1604
+ log.debug(`Permission request:`, JSON.stringify(request).slice(0, 500));
1605
+ const kind = request.kind ?? 'unknown';
1606
+ const serverName = request.serverName;
1607
+ // Build a descriptive tool name from kind + available fields
1608
+ const toolName = request.toolName ?? request.tool_name ?? request.name ?? kind;
1609
+ const toolInput = request.input ?? request.arguments ?? request.parameters ?? request;
1610
+ const commands = extractCommandPatterns(toolInput);
1611
+ // For MCP tools, check server-level rules first (covers all tools on that server)
1612
+ if (kind === 'mcp' && serverName) {
1613
+ const serverResult = checkPermission(channelId, `mcp:${serverName}`, '*');
1614
+ if (serverResult === 'allow') {
1615
+ log.debug(`MCP "${serverName}" auto-approved by stored rule`);
1616
+ return Promise.resolve({ kind: 'approved' });
1617
+ }
1618
+ if (serverResult === 'deny') {
1619
+ log.debug(`MCP "${serverName}" denied by stored rule`);
1620
+ return Promise.resolve({ kind: 'denied-by-rules' });
1621
+ }
1622
+ }
1623
+ if (commands.length > 0) {
1624
+ const results = commands.map(cmd => checkPermission(channelId, toolName, cmd));
1625
+ if (results.every(r => r === 'allow')) {
1626
+ const hasWrapper = commands.some(cmd => SHELL_WRAPPERS.has(cmd));
1627
+ if (!hasWrapper) {
1628
+ return Promise.resolve({ kind: 'approved' });
1629
+ }
1630
+ }
1631
+ if (results.some(r => r === 'deny')) {
1632
+ return Promise.resolve({ kind: 'denied-by-rules' });
1633
+ }
1634
+ }
1635
+ else {
1636
+ const result = checkPermission(channelId, toolName, '*');
1637
+ if (result === 'allow')
1638
+ return Promise.resolve({ kind: 'approved' });
1639
+ if (result === 'deny')
1640
+ return Promise.resolve({ kind: 'denied-by-rules' });
1641
+ }
1642
+ // No rule matched — need to ask the user via chat
1643
+ return new Promise((resolve) => {
1644
+ const entry = {
1645
+ sessionId: invocation.sessionId,
1646
+ channelId,
1647
+ toolName,
1648
+ serverName,
1649
+ toolInput: toolInput,
1650
+ commands,
1651
+ resolve,
1652
+ createdAt: Date.now(),
1653
+ };
1654
+ let queue = this.pendingPermissions.get(channelId);
1655
+ if (!queue) {
1656
+ queue = [];
1657
+ this.pendingPermissions.set(channelId, queue);
1658
+ }
1659
+ queue.push(entry);
1660
+ // Only emit the event if this is the first (active) item in the queue
1661
+ if (queue.length === 1) {
1662
+ this.eventHandler?.(invocation.sessionId, channelId, {
1663
+ type: 'bridge.permission_request',
1664
+ data: {
1665
+ toolName,
1666
+ serverName,
1667
+ input: toolInput,
1668
+ commands,
1669
+ },
1670
+ });
1671
+ }
1672
+ });
1673
+ }
1674
+ handleUserInputRequest(channelId, request, invocation) {
1675
+ return new Promise((resolve) => {
1676
+ const entry = {
1677
+ sessionId: invocation.sessionId,
1678
+ channelId,
1679
+ question: request.question,
1680
+ choices: request.choices,
1681
+ allowFreeform: request.allowFreeform,
1682
+ resolve,
1683
+ createdAt: Date.now(),
1684
+ };
1685
+ let queue = this.pendingUserInput.get(channelId);
1686
+ if (!queue) {
1687
+ queue = [];
1688
+ this.pendingUserInput.set(channelId, queue);
1689
+ }
1690
+ queue.push(entry);
1691
+ // Only emit the event if this is the first (active) item in the queue
1692
+ if (queue.length === 1) {
1693
+ this.eventHandler?.(invocation.sessionId, channelId, {
1694
+ type: 'bridge.user_input_request',
1695
+ data: {
1696
+ question: request.question,
1697
+ choices: request.choices,
1698
+ allowFreeform: request.allowFreeform,
1699
+ },
1700
+ });
1701
+ }
1702
+ });
1703
+ }
1704
+ async shutdown() {
1705
+ // Resolve all pending permissions (deny them on shutdown)
1706
+ for (const [, queue] of this.pendingPermissions) {
1707
+ for (const pending of queue) {
1708
+ pending.resolve({ kind: 'denied-interactively-by-user' });
1709
+ }
1710
+ }
1711
+ this.pendingPermissions.clear();
1712
+ // Resolve all pending user inputs (empty answer on shutdown)
1713
+ for (const [, queue] of this.pendingUserInput) {
1714
+ for (const pending of queue) {
1715
+ pending.resolve({ answer: '', wasFreeform: false });
1716
+ }
1717
+ }
1718
+ this.pendingUserInput.clear();
1719
+ // Unsubscribe all session event listeners
1720
+ for (const [, unsub] of this.sessionUnsubscribes) {
1721
+ unsub();
1722
+ }
1723
+ this.sessionUnsubscribes.clear();
1724
+ // Release all sessions (don't destroy — they persist in CLI)
1725
+ for (const [channelId, sessionId] of this.channelSessions) {
1726
+ this.bridge.releaseSession(sessionId);
1727
+ }
1728
+ this.channelSessions.clear();
1729
+ this.sessionChannels.clear();
1730
+ }
1731
+ }
1732
+ //# sourceMappingURL=session-manager.js.map