@chrisromp/copilot-bridge 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/bridge-docs.d.ts +17 -0
- package/dist/core/bridge-docs.d.ts.map +1 -0
- package/dist/core/bridge-docs.js +658 -0
- package/dist/core/bridge-docs.js.map +1 -0
- package/dist/core/command-handler.d.ts +2 -1
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +9 -3
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/quiet-mode.d.ts +14 -0
- package/dist/core/quiet-mode.d.ts.map +1 -0
- package/dist/core/quiet-mode.js +49 -0
- package/dist/core/quiet-mode.js.map +1 -0
- package/dist/core/session-manager.d.ts +50 -2
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +313 -18
- package/dist/core/session-manager.js.map +1 -1
- package/dist/index.js +347 -34
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/templates/admin/AGENTS.md +20 -0
- package/templates/agents/AGENTS.md +20 -0
|
@@ -10,9 +10,10 @@ import { canCall, createContext, extendContext, getBotWorkspaceMap, buildWorkspa
|
|
|
10
10
|
import { createLogger } from '../logger.js';
|
|
11
11
|
import { tryWithFallback, isModelError, buildFallbackChain } from './model-fallback.js';
|
|
12
12
|
import { loadHooks, getHooksInfo } from './hooks-loader.js';
|
|
13
|
+
import { getBridgeDocs } from './bridge-docs.js';
|
|
13
14
|
const log = createLogger('session');
|
|
14
|
-
/** Custom tools auto-approved without interactive prompt (
|
|
15
|
-
export const BRIDGE_CUSTOM_TOOLS = ['send_file', 'show_file_in_chat', 'ask_agent', 'schedule'];
|
|
15
|
+
/** Custom tools auto-approved without interactive prompt (read-only or enforce workspace boundaries internally). */
|
|
16
|
+
export const BRIDGE_CUSTOM_TOOLS = ['send_file', 'show_file_in_chat', 'ask_agent', 'schedule', 'fetch_copilot_bridge_documentation'];
|
|
16
17
|
/** Simple mutex for serializing env-sensitive session creation. */
|
|
17
18
|
let envLock = Promise.resolve();
|
|
18
19
|
/**
|
|
@@ -210,6 +211,35 @@ function loadWorkspaceMcpServers(workspacePath) {
|
|
|
210
211
|
return { servers: {}, env: workspaceEnv };
|
|
211
212
|
}
|
|
212
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Merge global and workspace MCP server configs, injecting cwd and env into local servers.
|
|
216
|
+
* Exported for testing — used internally by SessionManager.resolveMcpServers().
|
|
217
|
+
*/
|
|
218
|
+
export function mergeMcpServers(globalServers, workspaceServers, workspaceEnv, workingDirectory) {
|
|
219
|
+
const merged = {};
|
|
220
|
+
for (const [name, config] of Object.entries(globalServers)) {
|
|
221
|
+
const serverConfig = { ...config };
|
|
222
|
+
const isLocal = !serverConfig.type || serverConfig.type === 'local' || serverConfig.type === 'stdio';
|
|
223
|
+
if (isLocal) {
|
|
224
|
+
if (!serverConfig.cwd) {
|
|
225
|
+
serverConfig.cwd = workingDirectory;
|
|
226
|
+
}
|
|
227
|
+
if (Object.keys(workspaceEnv).length > 0) {
|
|
228
|
+
serverConfig.env = { ...workspaceEnv, ...(serverConfig.env || {}) };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
merged[name] = serverConfig;
|
|
232
|
+
}
|
|
233
|
+
for (const [name, config] of Object.entries(workspaceServers)) {
|
|
234
|
+
const serverConfig = { ...config };
|
|
235
|
+
const isLocal = !serverConfig.type || serverConfig.type === 'local' || serverConfig.type === 'stdio';
|
|
236
|
+
if (isLocal && !serverConfig.cwd) {
|
|
237
|
+
serverConfig.cwd = workingDirectory;
|
|
238
|
+
}
|
|
239
|
+
merged[name] = serverConfig;
|
|
240
|
+
}
|
|
241
|
+
return merged;
|
|
242
|
+
}
|
|
213
243
|
/**
|
|
214
244
|
* Extract individual command names from a shell command string.
|
|
215
245
|
* Handles chained commands: "ls -la && grep -r foo . | head" → ["ls", "grep", "head"]
|
|
@@ -294,6 +324,36 @@ function discoverSkillDirectories(workingDirectory) {
|
|
|
294
324
|
}
|
|
295
325
|
return dirs;
|
|
296
326
|
}
|
|
327
|
+
/** Extract a succinct summary from plan content (first heading + first body line, ~150 chars max). */
|
|
328
|
+
export function extractPlanSummary(content) {
|
|
329
|
+
if (!content?.trim())
|
|
330
|
+
return '(empty plan)';
|
|
331
|
+
const lines = content.split('\n');
|
|
332
|
+
let heading = '';
|
|
333
|
+
let body = '';
|
|
334
|
+
for (const line of lines) {
|
|
335
|
+
const trimmed = line.trim();
|
|
336
|
+
if (!trimmed)
|
|
337
|
+
continue;
|
|
338
|
+
if (!heading && trimmed.startsWith('#')) {
|
|
339
|
+
heading = trimmed.replace(/^#+\s*/, '');
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (!trimmed.startsWith('#') && !body) {
|
|
343
|
+
body = trimmed;
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (heading && body) {
|
|
348
|
+
const full = `${heading}: ${body}`;
|
|
349
|
+
return full.length > 150 ? full.slice(0, 147) + '...' : full;
|
|
350
|
+
}
|
|
351
|
+
if (heading)
|
|
352
|
+
return heading.length > 150 ? heading.slice(0, 147) + '...' : heading;
|
|
353
|
+
if (body)
|
|
354
|
+
return body.length > 150 ? body.slice(0, 147) + '...' : body;
|
|
355
|
+
return '(empty plan)';
|
|
356
|
+
}
|
|
297
357
|
export class SessionManager {
|
|
298
358
|
bridge;
|
|
299
359
|
channelSessions = new Map(); // channelId → sessionId
|
|
@@ -307,6 +367,8 @@ export class SessionManager {
|
|
|
307
367
|
pendingUserInput = new Map();
|
|
308
368
|
// Cached context usage from session.usage_info events
|
|
309
369
|
contextUsage = new Map();
|
|
370
|
+
// Cached max_context_window_tokens from listModels() (separate from usage_info to avoid ordering issues)
|
|
371
|
+
contextWindowTokens = new Map();
|
|
310
372
|
lastMessageUserIds = new Map(); // channelId → userId of last message sender
|
|
311
373
|
// MCP server names that were passed to the session at creation/resume time
|
|
312
374
|
sessionMcpServers = new Map(); // channelId → server names
|
|
@@ -314,6 +376,12 @@ export class SessionManager {
|
|
|
314
376
|
sessionSkillDirs = new Map(); // channelId → skill dir paths
|
|
315
377
|
// Loaded session hooks per workspace (cached after first load)
|
|
316
378
|
workspaceHooks = new Map();
|
|
379
|
+
// Pending plan exit requests (one per channel)
|
|
380
|
+
pendingPlanExit = new Map();
|
|
381
|
+
// Debounce timers for plan_changed events
|
|
382
|
+
planChangedDebounce = new Map();
|
|
383
|
+
// Previous permissionMode before yolo was auto-enabled by plan exit
|
|
384
|
+
yoloPreviousState = new Map();
|
|
317
385
|
// Handler for send_file tool (set by index.ts, calls adapter.sendFile)
|
|
318
386
|
sendFileHandler = null;
|
|
319
387
|
getAdapterForChannel = null;
|
|
@@ -408,19 +476,7 @@ export class SessionManager {
|
|
|
408
476
|
*/
|
|
409
477
|
resolveMcpServers(workingDirectory) {
|
|
410
478
|
const { servers: workspaceServers, env: workspaceEnv } = loadWorkspaceMcpServers(workingDirectory);
|
|
411
|
-
|
|
412
|
-
const merged = {};
|
|
413
|
-
for (const [name, config] of Object.entries(this.mcpServers)) {
|
|
414
|
-
const serverConfig = { ...config };
|
|
415
|
-
const isLocal = !serverConfig.type || serverConfig.type === 'local' || serverConfig.type === 'stdio';
|
|
416
|
-
if (isLocal && Object.keys(workspaceEnv).length > 0) {
|
|
417
|
-
serverConfig.env = { ...workspaceEnv, ...(serverConfig.env || {}) };
|
|
418
|
-
}
|
|
419
|
-
merged[name] = serverConfig;
|
|
420
|
-
}
|
|
421
|
-
if (Object.keys(workspaceServers).length === 0)
|
|
422
|
-
return merged;
|
|
423
|
-
return { ...merged, ...workspaceServers };
|
|
479
|
+
return mergeMcpServers(this.mcpServers, workspaceServers, workspaceEnv, workingDirectory);
|
|
424
480
|
}
|
|
425
481
|
/** Get annotated MCP server info for a channel, showing which layer each server came from. */
|
|
426
482
|
getMcpServerInfo(channelId) {
|
|
@@ -546,9 +602,17 @@ export class SessionManager {
|
|
|
546
602
|
this.channelSessions.delete(channelId);
|
|
547
603
|
this.sessionChannels.delete(existingId);
|
|
548
604
|
this.contextUsage.delete(channelId);
|
|
605
|
+
this.contextWindowTokens.delete(channelId);
|
|
549
606
|
this.lastMessageUserIds.delete(channelId);
|
|
550
607
|
this.sessionMcpServers.delete(channelId);
|
|
551
608
|
this.sessionSkillDirs.delete(channelId);
|
|
609
|
+
this.pendingPlanExit.delete(channelId);
|
|
610
|
+
this.revertYoloIfNeeded(channelId);
|
|
611
|
+
const debounceTimer = this.planChangedDebounce.get(channelId);
|
|
612
|
+
if (debounceTimer) {
|
|
613
|
+
clearTimeout(debounceTimer);
|
|
614
|
+
this.planChangedDebounce.delete(channelId);
|
|
615
|
+
}
|
|
552
616
|
}
|
|
553
617
|
clearChannelSession(channelId);
|
|
554
618
|
return this.createNewSession(channelId);
|
|
@@ -575,9 +639,19 @@ export class SessionManager {
|
|
|
575
639
|
this.mcpServers = loadMcpServers();
|
|
576
640
|
// Re-attach the same session (re-reads workspace config, AGENTS.md, MCP, etc.)
|
|
577
641
|
this.contextUsage.delete(channelId);
|
|
642
|
+
this.contextWindowTokens.delete(channelId);
|
|
578
643
|
this.lastMessageUserIds.delete(channelId);
|
|
579
644
|
this.sessionMcpServers.delete(channelId);
|
|
580
645
|
this.sessionSkillDirs.delete(channelId);
|
|
646
|
+
this.pendingPlanExit.delete(channelId);
|
|
647
|
+
this.revertYoloIfNeeded(channelId);
|
|
648
|
+
{
|
|
649
|
+
const dt = this.planChangedDebounce.get(channelId);
|
|
650
|
+
if (dt) {
|
|
651
|
+
clearTimeout(dt);
|
|
652
|
+
this.planChangedDebounce.delete(channelId);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
581
655
|
try {
|
|
582
656
|
await this.attachSession(channelId, existingId);
|
|
583
657
|
log.info(`Reloaded session ${existingId} for channel ${channelId}`);
|
|
@@ -589,9 +663,12 @@ export class SessionManager {
|
|
|
589
663
|
this.channelSessions.delete(channelId);
|
|
590
664
|
this.sessionChannels.delete(existingId);
|
|
591
665
|
this.contextUsage.delete(channelId);
|
|
666
|
+
this.contextWindowTokens.delete(channelId);
|
|
592
667
|
this.lastMessageUserIds.delete(channelId);
|
|
593
668
|
this.sessionMcpServers.delete(channelId);
|
|
594
669
|
this.sessionSkillDirs.delete(channelId);
|
|
670
|
+
this.pendingPlanExit.delete(channelId);
|
|
671
|
+
// yoloPreviousState already reverted above
|
|
595
672
|
clearChannelSession(channelId);
|
|
596
673
|
return this.createNewSession(channelId);
|
|
597
674
|
}
|
|
@@ -617,9 +694,19 @@ export class SessionManager {
|
|
|
617
694
|
this.channelSessions.delete(channelId);
|
|
618
695
|
this.sessionChannels.delete(existingId);
|
|
619
696
|
this.contextUsage.delete(channelId);
|
|
697
|
+
this.contextWindowTokens.delete(channelId);
|
|
620
698
|
this.lastMessageUserIds.delete(channelId);
|
|
621
699
|
this.sessionMcpServers.delete(channelId);
|
|
622
700
|
this.sessionSkillDirs.delete(channelId);
|
|
701
|
+
this.pendingPlanExit.delete(channelId);
|
|
702
|
+
this.revertYoloIfNeeded(channelId);
|
|
703
|
+
{
|
|
704
|
+
const dt = this.planChangedDebounce.get(channelId);
|
|
705
|
+
if (dt) {
|
|
706
|
+
clearTimeout(dt);
|
|
707
|
+
this.planChangedDebounce.delete(channelId);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
623
710
|
}
|
|
624
711
|
// If target session is active on another channel, disconnect it first
|
|
625
712
|
const otherChannel = this.sessionChannels.get(targetSessionId);
|
|
@@ -636,9 +723,19 @@ export class SessionManager {
|
|
|
636
723
|
this.channelSessions.delete(otherChannel);
|
|
637
724
|
this.sessionChannels.delete(targetSessionId);
|
|
638
725
|
this.contextUsage.delete(otherChannel);
|
|
726
|
+
this.contextWindowTokens.delete(otherChannel);
|
|
639
727
|
this.lastMessageUserIds.delete(otherChannel);
|
|
640
728
|
this.sessionMcpServers.delete(otherChannel);
|
|
641
729
|
this.sessionSkillDirs.delete(otherChannel);
|
|
730
|
+
this.pendingPlanExit.delete(otherChannel);
|
|
731
|
+
this.revertYoloIfNeeded(otherChannel);
|
|
732
|
+
{
|
|
733
|
+
const dt = this.planChangedDebounce.get(otherChannel);
|
|
734
|
+
if (dt) {
|
|
735
|
+
clearTimeout(dt);
|
|
736
|
+
this.planChangedDebounce.delete(otherChannel);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
642
739
|
clearChannelSession(otherChannel);
|
|
643
740
|
}
|
|
644
741
|
// Attach to the target session — fail hard if it doesn't exist
|
|
@@ -785,6 +882,16 @@ export class SessionManager {
|
|
|
785
882
|
}
|
|
786
883
|
}
|
|
787
884
|
setChannelPrefs(channelId, { model });
|
|
885
|
+
// Clear stale context window tokens so /context falls back to tokenLimit during transition
|
|
886
|
+
this.contextWindowTokens.delete(channelId);
|
|
887
|
+
// Update cached context window tokens for the new model (best-effort)
|
|
888
|
+
this.bridge.listModels().then(models => {
|
|
889
|
+
// Guard against rapid switches: only cache if the channel is still on this model
|
|
890
|
+
const currentPrefs = this.getEffectivePrefs(channelId);
|
|
891
|
+
if (currentPrefs.model === model) {
|
|
892
|
+
this.cacheContextWindowTokens(channelId, model, models);
|
|
893
|
+
}
|
|
894
|
+
}).catch(() => { });
|
|
788
895
|
}
|
|
789
896
|
/** Switch the agent for a channel's session. */
|
|
790
897
|
async switchAgent(channelId, agent) {
|
|
@@ -877,6 +984,94 @@ export class SessionManager {
|
|
|
877
984
|
return false;
|
|
878
985
|
}
|
|
879
986
|
}
|
|
987
|
+
/**
|
|
988
|
+
* Extract a succinct summary from plan content.
|
|
989
|
+
* Returns the first heading + first 1-2 sentences, ~100-150 chars.
|
|
990
|
+
*/
|
|
991
|
+
extractPlanSummary(content) {
|
|
992
|
+
return extractPlanSummary(content);
|
|
993
|
+
}
|
|
994
|
+
// Preferred models for plan summarization — balanced for quality and cost.
|
|
995
|
+
// Summarization needs accurate extraction without hallucination, so we
|
|
996
|
+
// prefer capable mid-tier models over the cheapest options.
|
|
997
|
+
// Matched against available models via exact-then-prefix so partial matches
|
|
998
|
+
// work even if the catalog adds suffixes or version bumps.
|
|
999
|
+
static SUMMARIZER_MODELS = [
|
|
1000
|
+
'claude-sonnet-4.6', // strong comprehension, likely to stay available
|
|
1001
|
+
'claude-sonnet-4.5', // strong comprehension
|
|
1002
|
+
'gpt-4.1', // fast, capable
|
|
1003
|
+
'gpt-5-mini', // smaller but still capable
|
|
1004
|
+
'claude-haiku-4.5', // fastest Claude, adequate for short summaries
|
|
1005
|
+
'gpt-5.1', // full-size fallback
|
|
1006
|
+
'gpt-5.2', // full-size fallback
|
|
1007
|
+
];
|
|
1008
|
+
/**
|
|
1009
|
+
* Summarize a plan using a lightweight ephemeral session.
|
|
1010
|
+
* Creates a throwaway session with a cheap model, sends the plan for summarization,
|
|
1011
|
+
* collects the streamed response, and destroys the session.
|
|
1012
|
+
*/
|
|
1013
|
+
async summarizePlan(channelId) {
|
|
1014
|
+
const plan = await this.readPlan(channelId);
|
|
1015
|
+
if (!plan.exists || !plan.content)
|
|
1016
|
+
return null;
|
|
1017
|
+
// Pick the best available cheap model
|
|
1018
|
+
let availableIds = [];
|
|
1019
|
+
try {
|
|
1020
|
+
const models = await this.bridge.listModels();
|
|
1021
|
+
availableIds = models.map(m => m.id);
|
|
1022
|
+
}
|
|
1023
|
+
catch { /* best-effort */ }
|
|
1024
|
+
let model;
|
|
1025
|
+
for (const candidate of SessionManager.SUMMARIZER_MODELS) {
|
|
1026
|
+
const match = availableIds.find(id => id === candidate || id.startsWith(candidate));
|
|
1027
|
+
if (match) {
|
|
1028
|
+
model = match;
|
|
1029
|
+
break;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
// If none matched, leave model undefined — SDK picks its default
|
|
1033
|
+
log.info(`Summarizing plan for ${channelId.slice(0, 8)}... using model ${model ?? '(sdk default)'}`);
|
|
1034
|
+
let session;
|
|
1035
|
+
try {
|
|
1036
|
+
session = await this.bridge.createSession({
|
|
1037
|
+
...(model ? { model } : {}),
|
|
1038
|
+
onPermissionRequest: async () => ({ kind: 'denied-interactively-by-user' }),
|
|
1039
|
+
systemMessage: { content: 'You are a concise summarizer. Respond with only the summary, no preamble.' },
|
|
1040
|
+
});
|
|
1041
|
+
const prompt = `Condense this plan into a short summary that preserves the main section headings (## level). Under each heading, write one sentence capturing the key point. Include any unresolved questions or TBDs. Omit code blocks, type definitions, and resolved questions. Keep the total under 500 characters.\n\n${plan.content}`;
|
|
1042
|
+
const response = await this.sendAndWaitForIdle(session, prompt, 30_000);
|
|
1043
|
+
return response.trim() || null;
|
|
1044
|
+
}
|
|
1045
|
+
catch (err) {
|
|
1046
|
+
log.warn(`Plan summarization failed: ${err?.message ?? err}`);
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
1049
|
+
finally {
|
|
1050
|
+
if (session) {
|
|
1051
|
+
try {
|
|
1052
|
+
await this.bridge.destroySession(session.sessionId);
|
|
1053
|
+
}
|
|
1054
|
+
catch { /* best-effort */ }
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Check for an existing plan and return summary if found.
|
|
1060
|
+
* Called after session resume to notify the user.
|
|
1061
|
+
*/
|
|
1062
|
+
async surfacePlanIfExists(channelId) {
|
|
1063
|
+
const plan = await this.readPlan(channelId);
|
|
1064
|
+
if (!plan.exists || !plan.content)
|
|
1065
|
+
return null;
|
|
1066
|
+
const summary = this.extractPlanSummary(plan.content);
|
|
1067
|
+
let inPlanMode = false;
|
|
1068
|
+
try {
|
|
1069
|
+
const mode = await this.getSessionMode(channelId);
|
|
1070
|
+
inPlanMode = mode === 'plan';
|
|
1071
|
+
}
|
|
1072
|
+
catch { /* best-effort */ }
|
|
1073
|
+
return { exists: true, summary, inPlanMode };
|
|
1074
|
+
}
|
|
880
1075
|
/** Check if the Copilot CLI is authenticated. */
|
|
881
1076
|
async getAuthStatus() {
|
|
882
1077
|
try {
|
|
@@ -965,6 +1160,49 @@ export class SessionManager {
|
|
|
965
1160
|
const queue = this.pendingPermissions.get(channelId);
|
|
966
1161
|
return !!queue && queue.length > 0 && !!queue[0].fromHook;
|
|
967
1162
|
}
|
|
1163
|
+
/** Store a pending plan exit request. */
|
|
1164
|
+
setPendingPlanExit(channelId, pending) {
|
|
1165
|
+
this.pendingPlanExit.set(channelId, pending);
|
|
1166
|
+
}
|
|
1167
|
+
/** Check if channel has a pending plan exit request. */
|
|
1168
|
+
hasPendingPlanExit(channelId) {
|
|
1169
|
+
return this.pendingPlanExit.has(channelId);
|
|
1170
|
+
}
|
|
1171
|
+
/** Get and clear the pending plan exit request. */
|
|
1172
|
+
consumePendingPlanExit(channelId) {
|
|
1173
|
+
const pending = this.pendingPlanExit.get(channelId);
|
|
1174
|
+
if (pending)
|
|
1175
|
+
this.pendingPlanExit.delete(channelId);
|
|
1176
|
+
return pending;
|
|
1177
|
+
}
|
|
1178
|
+
/** Save previous permission mode before auto-enabling yolo. */
|
|
1179
|
+
saveYoloPreviousState(channelId) {
|
|
1180
|
+
const prefs = getChannelPrefs(channelId);
|
|
1181
|
+
const channelConfig = getChannelConfig(channelId);
|
|
1182
|
+
const current = prefs?.permissionMode ?? channelConfig.permissionMode;
|
|
1183
|
+
this.yoloPreviousState.set(channelId, current);
|
|
1184
|
+
}
|
|
1185
|
+
/** Revert yolo to previous state (called on session.idle after plan implementation). */
|
|
1186
|
+
revertYoloIfNeeded(channelId) {
|
|
1187
|
+
const previous = this.yoloPreviousState.get(channelId);
|
|
1188
|
+
if (previous === undefined)
|
|
1189
|
+
return false;
|
|
1190
|
+
this.yoloPreviousState.delete(channelId);
|
|
1191
|
+
setChannelPrefs(channelId, { permissionMode: previous });
|
|
1192
|
+
return true;
|
|
1193
|
+
}
|
|
1194
|
+
/** Debounced plan change handler — calls callback after debounce window. */
|
|
1195
|
+
debouncePlanChanged(channelId, callback, delayMs = 3000) {
|
|
1196
|
+
const existing = this.planChangedDebounce.get(channelId);
|
|
1197
|
+
if (existing)
|
|
1198
|
+
clearTimeout(existing);
|
|
1199
|
+
this.planChangedDebounce.set(channelId, setTimeout(() => {
|
|
1200
|
+
this.planChangedDebounce.delete(channelId);
|
|
1201
|
+
void Promise.resolve(callback()).catch((err) => {
|
|
1202
|
+
log.warn(`Debounced plan callback failed for ${channelId.slice(0, 8)}...: ${err}`);
|
|
1203
|
+
});
|
|
1204
|
+
}, delayMs));
|
|
1205
|
+
}
|
|
968
1206
|
/** Get the current session ID for a channel (if any). */
|
|
969
1207
|
getSessionId(channelId) {
|
|
970
1208
|
return this.channelSessions.get(channelId) ?? getChannelSession(channelId) ?? undefined;
|
|
@@ -991,7 +1229,19 @@ export class SessionManager {
|
|
|
991
1229
|
}
|
|
992
1230
|
/** Get cached context window usage for a channel. */
|
|
993
1231
|
getContextUsage(channelId) {
|
|
994
|
-
|
|
1232
|
+
const usage = this.contextUsage.get(channelId);
|
|
1233
|
+
if (!usage)
|
|
1234
|
+
return null;
|
|
1235
|
+
const ctxWindow = this.contextWindowTokens.get(channelId);
|
|
1236
|
+
return ctxWindow ? { ...usage, contextWindowTokens: ctxWindow } : usage;
|
|
1237
|
+
}
|
|
1238
|
+
/** Cache the model's max_context_window_tokens for accurate /context display. */
|
|
1239
|
+
cacheContextWindowTokens(channelId, modelId, modelList) {
|
|
1240
|
+
const model = modelList.find((m) => m.id === modelId);
|
|
1241
|
+
const ctxTokens = model?.capabilities?.limits?.max_context_window_tokens;
|
|
1242
|
+
if (typeof ctxTokens === 'number' && ctxTokens > 0) {
|
|
1243
|
+
this.contextWindowTokens.set(channelId, ctxTokens);
|
|
1244
|
+
}
|
|
995
1245
|
}
|
|
996
1246
|
/**
|
|
997
1247
|
* Resolve a session ID prefix to matching full session IDs.
|
|
@@ -1042,9 +1292,10 @@ export class SessionManager {
|
|
|
1042
1292
|
const configFallbacks = configChannel.fallbackModels ?? getConfig().defaults.fallbackModels;
|
|
1043
1293
|
// Fetch available models for fallback chain (best-effort — don't block on failure)
|
|
1044
1294
|
let availableModels = [];
|
|
1295
|
+
let modelList = [];
|
|
1045
1296
|
try {
|
|
1046
|
-
|
|
1047
|
-
availableModels =
|
|
1297
|
+
modelList = await this.bridge.listModels();
|
|
1298
|
+
availableModels = modelList.map(m => m.id);
|
|
1048
1299
|
}
|
|
1049
1300
|
catch {
|
|
1050
1301
|
log.warn('Failed to fetch model list for fallback resolution');
|
|
@@ -1074,6 +1325,7 @@ export class SessionManager {
|
|
|
1074
1325
|
const { result: session, usedModel, didFallback } = await tryWithFallback(prefs.model, availableModels, configFallbacks, createWithModel);
|
|
1075
1326
|
this.sessionMcpServers.set(channelId, new Set(Object.keys(resolvedMcpServers)));
|
|
1076
1327
|
this.sessionSkillDirs.set(channelId, new Set(skillDirectories));
|
|
1328
|
+
this.cacheContextWindowTokens(channelId, usedModel, modelList);
|
|
1077
1329
|
if (didFallback) {
|
|
1078
1330
|
log.info(`Model fallback: "${prefs.model}" → "${usedModel}" for channel ${channelId}`);
|
|
1079
1331
|
setChannelPrefs(channelId, { model: usedModel });
|
|
@@ -1125,6 +1377,15 @@ export class SessionManager {
|
|
|
1125
1377
|
this.channelSessions.set(channelId, sessionId);
|
|
1126
1378
|
this.sessionChannels.set(sessionId, channelId);
|
|
1127
1379
|
this.attachSessionEvents(session, channelId);
|
|
1380
|
+
// Cache context window tokens for /context display (best-effort, non-blocking)
|
|
1381
|
+
const resumeModel = prefs.model;
|
|
1382
|
+
this.bridge.listModels().then(models => {
|
|
1383
|
+
// Guard against model changes before this resolves
|
|
1384
|
+
const currentPrefs = this.getEffectivePrefs(channelId);
|
|
1385
|
+
if (currentPrefs.model === resumeModel) {
|
|
1386
|
+
this.cacheContextWindowTokens(channelId, resumeModel, models);
|
|
1387
|
+
}
|
|
1388
|
+
}).catch(() => { });
|
|
1128
1389
|
}
|
|
1129
1390
|
/**
|
|
1130
1391
|
* Execute an ephemeral inter-agent call: create a fresh session for the target bot,
|
|
@@ -1761,6 +2022,8 @@ export class SessionManager {
|
|
|
1761
2022
|
}
|
|
1762
2023
|
// Scheduler tool: create/list/cancel/pause/resume scheduled tasks
|
|
1763
2024
|
tools.push(this.buildScheduleToolDef(channelId));
|
|
2025
|
+
// Bridge documentation tool
|
|
2026
|
+
tools.push(this.buildBridgeDocsTool(channelId));
|
|
1764
2027
|
if (tools.length > 0) {
|
|
1765
2028
|
log.info(`Built ${tools.length} custom tool(s) for channel ${channelId.slice(0, 8)}...`);
|
|
1766
2029
|
}
|
|
@@ -1877,6 +2140,38 @@ export class SessionManager {
|
|
|
1877
2140
|
},
|
|
1878
2141
|
};
|
|
1879
2142
|
}
|
|
2143
|
+
buildBridgeDocsTool(channelId) {
|
|
2144
|
+
const channelConfig = getChannelConfig(channelId);
|
|
2145
|
+
const botName = getChannelBotName(channelId);
|
|
2146
|
+
const isAdmin = isBotAdmin(channelConfig.platform, botName);
|
|
2147
|
+
return {
|
|
2148
|
+
name: 'fetch_copilot_bridge_documentation',
|
|
2149
|
+
description: 'Fetch documentation about copilot-bridge capabilities, commands, configuration, and system status. Use this when you need to understand bridge features, help users with commands, troubleshoot issues, or check system status. Call with a specific topic to get focused information. If the documentation doesn\'t fully answer your question, each topic includes source code pointers for deeper investigation.',
|
|
2150
|
+
parameters: {
|
|
2151
|
+
type: 'object',
|
|
2152
|
+
properties: {
|
|
2153
|
+
topic: {
|
|
2154
|
+
type: 'string',
|
|
2155
|
+
enum: ['overview', 'commands', 'config', 'mcp', 'permissions', 'workspaces', 'hooks', 'skills', 'inter-agent', 'scheduling', 'troubleshooting', 'status'],
|
|
2156
|
+
description: "Topic to query. 'overview' = what the bridge is and key features. 'commands' = common slash commands. 'config' = configuration options. 'mcp' = MCP server setup. 'permissions' = permission system. 'workspaces' = workspace structure. 'hooks' = tool hooks. 'skills' = skill discovery. 'inter-agent' = bot-to-bot communication. 'scheduling' = task scheduling. 'troubleshooting' = common issues. 'status' = live system state.",
|
|
2157
|
+
},
|
|
2158
|
+
},
|
|
2159
|
+
required: [],
|
|
2160
|
+
},
|
|
2161
|
+
handler: async (args) => {
|
|
2162
|
+
const sessionInfo = this.getSessionInfo(channelId);
|
|
2163
|
+
return {
|
|
2164
|
+
content: getBridgeDocs({
|
|
2165
|
+
topic: args.topic,
|
|
2166
|
+
isAdmin,
|
|
2167
|
+
channelId,
|
|
2168
|
+
model: sessionInfo?.model,
|
|
2169
|
+
sessionId: sessionInfo?.sessionId,
|
|
2170
|
+
}),
|
|
2171
|
+
};
|
|
2172
|
+
},
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
1880
2175
|
attachSessionEvents(session, channelId) {
|
|
1881
2176
|
const unsub = session.on((event) => {
|
|
1882
2177
|
if (event.type === 'session.usage_info' && event.data) {
|