@chrisromp/copilot-bridge 0.9.2 → 0.11.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/README.md +4 -0
- package/config.sample.json +20 -0
- package/dist/config.d.ts +9 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +117 -0
- package/dist/config.js.map +1 -1
- 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 +790 -0
- package/dist/core/bridge-docs.js.map +1 -0
- package/dist/core/bridge.d.ts +14 -1
- package/dist/core/bridge.d.ts.map +1 -1
- package/dist/core/bridge.js +22 -2
- package/dist/core/bridge.js.map +1 -1
- package/dist/core/command-handler.d.ts +17 -4
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +255 -52
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/model-fallback.d.ts +2 -2
- package/dist/core/model-fallback.d.ts.map +1 -1
- package/dist/core/model-fallback.js +11 -4
- package/dist/core/model-fallback.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 +53 -3
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +430 -30
- package/dist/core/session-manager.js.map +1 -1
- package/dist/index.js +437 -41
- package/dist/index.js.map +1 -1
- package/dist/state/store.d.ts +1 -0
- package/dist/state/store.d.ts.map +1 -1
- package/dist/state/store.js +13 -2
- package/dist/state/store.js.map +1 -1
- package/dist/types.d.ts +29 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/templates/admin/AGENTS.md +56 -0
- package/templates/agents/AGENTS.md +20 -0
|
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as os from 'node:os';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
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';
|
|
5
|
+
import { getChannelConfig, getChannelBotName, evaluateConfigPermissions, isBotAdmin, getConfig, getInterAgentConfig, isHardDeny, resolveProviderConfig } from '../config.js';
|
|
6
6
|
import { getWorkspacePath, getWorkspaceAllowPaths, ensureWorkspacesDir } from './workspace-manager.js';
|
|
7
7
|
import { onboardProject } from './onboarding.js';
|
|
8
8
|
import { addJob, removeJob, pauseJob, resumeJob, listJobs, formatInTimezone } from './scheduler.js';
|
|
@@ -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
|
|
@@ -675,11 +772,12 @@ export class SessionManager {
|
|
|
675
772
|
const configFallbacks = configChannel.fallbackModels ?? getConfig().defaults.fallbackModels;
|
|
676
773
|
let availableModels = [];
|
|
677
774
|
try {
|
|
678
|
-
const models = await this.bridge.listModels();
|
|
775
|
+
const models = await this.bridge.listModels(getConfig().providers);
|
|
679
776
|
availableModels = models.map(m => m.id);
|
|
680
777
|
}
|
|
681
778
|
catch { /* best-effort */ }
|
|
682
|
-
const
|
|
779
|
+
const byokPrefixes = Object.keys(getConfig().providers ?? {});
|
|
780
|
+
const chain = buildFallbackChain(prefs.model, availableModels, configFallbacks, byokPrefixes);
|
|
683
781
|
// Try each fallback: create session + send
|
|
684
782
|
let lastError = err;
|
|
685
783
|
for (const fallbackModel of chain) {
|
|
@@ -774,17 +872,78 @@ export class SessionManager {
|
|
|
774
872
|
}
|
|
775
873
|
}
|
|
776
874
|
/** Switch the model for a channel's session. */
|
|
777
|
-
async switchModel(channelId, model) {
|
|
778
|
-
const
|
|
779
|
-
|
|
875
|
+
async switchModel(channelId, model, provider) {
|
|
876
|
+
const currentPrefs = this.getEffectivePrefs(channelId);
|
|
877
|
+
const currentProvider = currentPrefs.provider ?? null;
|
|
878
|
+
const newProvider = provider ?? null;
|
|
879
|
+
const providerChanged = currentProvider !== newProvider;
|
|
880
|
+
if (providerChanged) {
|
|
881
|
+
// Provider change requires a fresh session (different endpoint/auth)
|
|
882
|
+
log.info(`Provider switch ${currentProvider ?? 'copilot'} → ${newProvider ?? 'copilot'} for channel ${channelId.slice(0, 8)}...`);
|
|
883
|
+
// Set prefs before newSession so createNewSession picks up the new provider,
|
|
884
|
+
// but restore on failure so the channel isn't left in a broken state.
|
|
885
|
+
const prevModel = currentPrefs.model;
|
|
886
|
+
setChannelPrefs(channelId, { model, provider: newProvider });
|
|
887
|
+
try {
|
|
888
|
+
await this.newSession(channelId);
|
|
889
|
+
}
|
|
890
|
+
catch (err) {
|
|
891
|
+
log.warn(`Provider switch failed, reverting prefs:`, err);
|
|
892
|
+
setChannelPrefs(channelId, { model: prevModel, provider: currentProvider });
|
|
893
|
+
throw err;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
else if (newProvider && this.wireApiChanged(newProvider, currentPrefs.model ?? '', model)) {
|
|
897
|
+
// Same provider but wireApi differs between models — need fresh session
|
|
898
|
+
log.info(`wireApi change for provider ${newProvider}, model ${currentPrefs.model} → ${model} — creating new session`);
|
|
899
|
+
const prevModel = currentPrefs.model;
|
|
900
|
+
setChannelPrefs(channelId, { model, provider: newProvider });
|
|
780
901
|
try {
|
|
781
|
-
await this.
|
|
902
|
+
await this.newSession(channelId);
|
|
782
903
|
}
|
|
783
904
|
catch (err) {
|
|
784
|
-
log.warn(`
|
|
905
|
+
log.warn(`wireApi switch failed, reverting prefs:`, err);
|
|
906
|
+
setChannelPrefs(channelId, { model: prevModel, provider: currentProvider });
|
|
907
|
+
throw err;
|
|
785
908
|
}
|
|
786
909
|
}
|
|
787
|
-
|
|
910
|
+
else {
|
|
911
|
+
// Same provider — use RPC model switch
|
|
912
|
+
const sessionId = this.channelSessions.get(channelId);
|
|
913
|
+
if (sessionId) {
|
|
914
|
+
try {
|
|
915
|
+
await this.bridge.switchSessionModel(sessionId, model);
|
|
916
|
+
}
|
|
917
|
+
catch (err) {
|
|
918
|
+
log.warn(`RPC model switch failed:`, err);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
setChannelPrefs(channelId, { model, provider: newProvider });
|
|
922
|
+
}
|
|
923
|
+
// Clear stale context window tokens so /context falls back to tokenLimit during transition
|
|
924
|
+
this.contextWindowTokens.delete(channelId);
|
|
925
|
+
// Update cached context window tokens for the new model (best-effort)
|
|
926
|
+
this.bridge.listModels(getConfig().providers).then(models => {
|
|
927
|
+
// Guard against rapid switches: only cache if the channel is still on this model
|
|
928
|
+
const prefs = this.getEffectivePrefs(channelId);
|
|
929
|
+
if (prefs.model === model) {
|
|
930
|
+
this.cacheContextWindowTokens(channelId, model, models);
|
|
931
|
+
}
|
|
932
|
+
}).catch(() => { });
|
|
933
|
+
}
|
|
934
|
+
/** Check if two models on the same provider have different wireApi settings. */
|
|
935
|
+
wireApiChanged(providerName, oldModel, newModel) {
|
|
936
|
+
const providers = getConfig().providers;
|
|
937
|
+
if (!providers)
|
|
938
|
+
return false;
|
|
939
|
+
const entry = providers[providerName];
|
|
940
|
+
if (!entry)
|
|
941
|
+
return false;
|
|
942
|
+
const oldEntry = entry.models.find(m => m.id === oldModel);
|
|
943
|
+
const newEntry = entry.models.find(m => m.id === newModel);
|
|
944
|
+
const oldWire = oldEntry?.wireApi ?? entry.wireApi;
|
|
945
|
+
const newWire = newEntry?.wireApi ?? entry.wireApi;
|
|
946
|
+
return oldWire !== newWire;
|
|
788
947
|
}
|
|
789
948
|
/** Switch the agent for a channel's session. */
|
|
790
949
|
async switchAgent(channelId, agent) {
|
|
@@ -808,6 +967,7 @@ export class SessionManager {
|
|
|
808
967
|
const storedPrefs = getChannelPrefs(channelId);
|
|
809
968
|
return {
|
|
810
969
|
model: storedPrefs?.model ?? configChannel.model ?? 'claude-sonnet-4.6',
|
|
970
|
+
provider: storedPrefs?.provider ?? null,
|
|
811
971
|
agent: storedPrefs?.agent !== undefined ? storedPrefs.agent : configChannel.agent,
|
|
812
972
|
verbose: storedPrefs?.verbose ?? configChannel.verbose,
|
|
813
973
|
triggerMode: configChannel.triggerMode,
|
|
@@ -820,7 +980,7 @@ export class SessionManager {
|
|
|
820
980
|
/** Get model info (for checking capabilities like reasoning effort). */
|
|
821
981
|
async getModelInfo(modelId) {
|
|
822
982
|
try {
|
|
823
|
-
const models = await this.bridge.listModels();
|
|
983
|
+
const models = await this.bridge.listModels(getConfig().providers);
|
|
824
984
|
return models.find(m => m.id === modelId) ?? null;
|
|
825
985
|
}
|
|
826
986
|
catch {
|
|
@@ -829,7 +989,7 @@ export class SessionManager {
|
|
|
829
989
|
}
|
|
830
990
|
/** List all available models. */
|
|
831
991
|
async listModels() {
|
|
832
|
-
return this.bridge.listModels();
|
|
992
|
+
return this.bridge.listModels(getConfig().providers);
|
|
833
993
|
}
|
|
834
994
|
/** Get the current session mode (interactive, plan, autopilot). Falls back to persisted prefs after restart. */
|
|
835
995
|
async getSessionMode(channelId) {
|
|
@@ -877,6 +1037,94 @@ export class SessionManager {
|
|
|
877
1037
|
return false;
|
|
878
1038
|
}
|
|
879
1039
|
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Extract a succinct summary from plan content.
|
|
1042
|
+
* Returns the first heading + first 1-2 sentences, ~100-150 chars.
|
|
1043
|
+
*/
|
|
1044
|
+
extractPlanSummary(content) {
|
|
1045
|
+
return extractPlanSummary(content);
|
|
1046
|
+
}
|
|
1047
|
+
// Preferred models for plan summarization — balanced for quality and cost.
|
|
1048
|
+
// Summarization needs accurate extraction without hallucination, so we
|
|
1049
|
+
// prefer capable mid-tier models over the cheapest options.
|
|
1050
|
+
// Matched against available models via exact-then-prefix so partial matches
|
|
1051
|
+
// work even if the catalog adds suffixes or version bumps.
|
|
1052
|
+
static SUMMARIZER_MODELS = [
|
|
1053
|
+
'claude-sonnet-4.6', // strong comprehension, likely to stay available
|
|
1054
|
+
'claude-sonnet-4.5', // strong comprehension
|
|
1055
|
+
'gpt-4.1', // fast, capable
|
|
1056
|
+
'gpt-5-mini', // smaller but still capable
|
|
1057
|
+
'claude-haiku-4.5', // fastest Claude, adequate for short summaries
|
|
1058
|
+
'gpt-5.1', // full-size fallback
|
|
1059
|
+
'gpt-5.2', // full-size fallback
|
|
1060
|
+
];
|
|
1061
|
+
/**
|
|
1062
|
+
* Summarize a plan using a lightweight ephemeral session.
|
|
1063
|
+
* Creates a throwaway session with a cheap model, sends the plan for summarization,
|
|
1064
|
+
* collects the streamed response, and destroys the session.
|
|
1065
|
+
*/
|
|
1066
|
+
async summarizePlan(channelId) {
|
|
1067
|
+
const plan = await this.readPlan(channelId);
|
|
1068
|
+
if (!plan.exists || !plan.content)
|
|
1069
|
+
return null;
|
|
1070
|
+
// Pick the best available cheap model
|
|
1071
|
+
let availableIds = [];
|
|
1072
|
+
try {
|
|
1073
|
+
const models = await this.bridge.listModels(getConfig().providers);
|
|
1074
|
+
availableIds = models.map(m => m.id);
|
|
1075
|
+
}
|
|
1076
|
+
catch { /* best-effort */ }
|
|
1077
|
+
let model;
|
|
1078
|
+
for (const candidate of SessionManager.SUMMARIZER_MODELS) {
|
|
1079
|
+
const match = availableIds.find(id => id === candidate || id.startsWith(candidate));
|
|
1080
|
+
if (match) {
|
|
1081
|
+
model = match;
|
|
1082
|
+
break;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
// If none matched, leave model undefined — SDK picks its default
|
|
1086
|
+
log.info(`Summarizing plan for ${channelId.slice(0, 8)}... using model ${model ?? '(sdk default)'}`);
|
|
1087
|
+
let session;
|
|
1088
|
+
try {
|
|
1089
|
+
session = await this.bridge.createSession({
|
|
1090
|
+
...(model ? { model } : {}),
|
|
1091
|
+
onPermissionRequest: async () => ({ kind: 'denied-interactively-by-user' }),
|
|
1092
|
+
systemMessage: { content: 'You are a concise summarizer. Respond with only the summary, no preamble.' },
|
|
1093
|
+
});
|
|
1094
|
+
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}`;
|
|
1095
|
+
const response = await this.sendAndWaitForIdle(session, prompt, 30_000);
|
|
1096
|
+
return response.trim() || null;
|
|
1097
|
+
}
|
|
1098
|
+
catch (err) {
|
|
1099
|
+
log.warn(`Plan summarization failed: ${err?.message ?? err}`);
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
finally {
|
|
1103
|
+
if (session) {
|
|
1104
|
+
try {
|
|
1105
|
+
await this.bridge.destroySession(session.sessionId);
|
|
1106
|
+
}
|
|
1107
|
+
catch { /* best-effort */ }
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Check for an existing plan and return summary if found.
|
|
1113
|
+
* Called after session resume to notify the user.
|
|
1114
|
+
*/
|
|
1115
|
+
async surfacePlanIfExists(channelId) {
|
|
1116
|
+
const plan = await this.readPlan(channelId);
|
|
1117
|
+
if (!plan.exists || !plan.content)
|
|
1118
|
+
return null;
|
|
1119
|
+
const summary = this.extractPlanSummary(plan.content);
|
|
1120
|
+
let inPlanMode = false;
|
|
1121
|
+
try {
|
|
1122
|
+
const mode = await this.getSessionMode(channelId);
|
|
1123
|
+
inPlanMode = mode === 'plan';
|
|
1124
|
+
}
|
|
1125
|
+
catch { /* best-effort */ }
|
|
1126
|
+
return { exists: true, summary, inPlanMode };
|
|
1127
|
+
}
|
|
880
1128
|
/** Check if the Copilot CLI is authenticated. */
|
|
881
1129
|
async getAuthStatus() {
|
|
882
1130
|
try {
|
|
@@ -965,6 +1213,49 @@ export class SessionManager {
|
|
|
965
1213
|
const queue = this.pendingPermissions.get(channelId);
|
|
966
1214
|
return !!queue && queue.length > 0 && !!queue[0].fromHook;
|
|
967
1215
|
}
|
|
1216
|
+
/** Store a pending plan exit request. */
|
|
1217
|
+
setPendingPlanExit(channelId, pending) {
|
|
1218
|
+
this.pendingPlanExit.set(channelId, pending);
|
|
1219
|
+
}
|
|
1220
|
+
/** Check if channel has a pending plan exit request. */
|
|
1221
|
+
hasPendingPlanExit(channelId) {
|
|
1222
|
+
return this.pendingPlanExit.has(channelId);
|
|
1223
|
+
}
|
|
1224
|
+
/** Get and clear the pending plan exit request. */
|
|
1225
|
+
consumePendingPlanExit(channelId) {
|
|
1226
|
+
const pending = this.pendingPlanExit.get(channelId);
|
|
1227
|
+
if (pending)
|
|
1228
|
+
this.pendingPlanExit.delete(channelId);
|
|
1229
|
+
return pending;
|
|
1230
|
+
}
|
|
1231
|
+
/** Save previous permission mode before auto-enabling yolo. */
|
|
1232
|
+
saveYoloPreviousState(channelId) {
|
|
1233
|
+
const prefs = getChannelPrefs(channelId);
|
|
1234
|
+
const channelConfig = getChannelConfig(channelId);
|
|
1235
|
+
const current = prefs?.permissionMode ?? channelConfig.permissionMode;
|
|
1236
|
+
this.yoloPreviousState.set(channelId, current);
|
|
1237
|
+
}
|
|
1238
|
+
/** Revert yolo to previous state (called on session.idle after plan implementation). */
|
|
1239
|
+
revertYoloIfNeeded(channelId) {
|
|
1240
|
+
const previous = this.yoloPreviousState.get(channelId);
|
|
1241
|
+
if (previous === undefined)
|
|
1242
|
+
return false;
|
|
1243
|
+
this.yoloPreviousState.delete(channelId);
|
|
1244
|
+
setChannelPrefs(channelId, { permissionMode: previous });
|
|
1245
|
+
return true;
|
|
1246
|
+
}
|
|
1247
|
+
/** Debounced plan change handler — calls callback after debounce window. */
|
|
1248
|
+
debouncePlanChanged(channelId, callback, delayMs = 3000) {
|
|
1249
|
+
const existing = this.planChangedDebounce.get(channelId);
|
|
1250
|
+
if (existing)
|
|
1251
|
+
clearTimeout(existing);
|
|
1252
|
+
this.planChangedDebounce.set(channelId, setTimeout(() => {
|
|
1253
|
+
this.planChangedDebounce.delete(channelId);
|
|
1254
|
+
void Promise.resolve(callback()).catch((err) => {
|
|
1255
|
+
log.warn(`Debounced plan callback failed for ${channelId.slice(0, 8)}...: ${err}`);
|
|
1256
|
+
});
|
|
1257
|
+
}, delayMs));
|
|
1258
|
+
}
|
|
968
1259
|
/** Get the current session ID for a channel (if any). */
|
|
969
1260
|
getSessionId(channelId) {
|
|
970
1261
|
return this.channelSessions.get(channelId) ?? getChannelSession(channelId) ?? undefined;
|
|
@@ -991,7 +1282,26 @@ export class SessionManager {
|
|
|
991
1282
|
}
|
|
992
1283
|
/** Get cached context window usage for a channel. */
|
|
993
1284
|
getContextUsage(channelId) {
|
|
994
|
-
|
|
1285
|
+
const usage = this.contextUsage.get(channelId);
|
|
1286
|
+
if (!usage)
|
|
1287
|
+
return null;
|
|
1288
|
+
const ctxWindow = this.contextWindowTokens.get(channelId);
|
|
1289
|
+
return ctxWindow ? { ...usage, contextWindowTokens: ctxWindow } : usage;
|
|
1290
|
+
}
|
|
1291
|
+
/** Cache the model's max_context_window_tokens for accurate /context display. */
|
|
1292
|
+
cacheContextWindowTokens(channelId, modelId, modelList) {
|
|
1293
|
+
let model = modelList.find((m) => m.id === modelId);
|
|
1294
|
+
// For BYOK models, the merged list has provider-prefixed IDs (e.g., "ollama-local:qwen3:8b")
|
|
1295
|
+
if (!model) {
|
|
1296
|
+
const prefs = getChannelPrefs(channelId);
|
|
1297
|
+
if (prefs?.provider) {
|
|
1298
|
+
model = modelList.find((m) => m.id === `${prefs.provider}:${modelId}`);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
const ctxTokens = model?.capabilities?.limits?.max_context_window_tokens;
|
|
1302
|
+
if (typeof ctxTokens === 'number' && ctxTokens > 0) {
|
|
1303
|
+
this.contextWindowTokens.set(channelId, ctxTokens);
|
|
1304
|
+
}
|
|
995
1305
|
}
|
|
996
1306
|
/**
|
|
997
1307
|
* Resolve a session ID prefix to matching full session IDs.
|
|
@@ -1040,11 +1350,20 @@ export class SessionManager {
|
|
|
1040
1350
|
// Resolve fallback configuration
|
|
1041
1351
|
const configChannel = getChannelConfig(channelId);
|
|
1042
1352
|
const configFallbacks = configChannel.fallbackModels ?? getConfig().defaults.fallbackModels;
|
|
1353
|
+
// Resolve BYOK provider if set in prefs
|
|
1354
|
+
const providerName = prefs.provider ?? null;
|
|
1355
|
+
const sdkProvider = providerName
|
|
1356
|
+
? resolveProviderConfig(providerName, getConfig().providers, prefs.model ?? undefined)
|
|
1357
|
+
: undefined;
|
|
1358
|
+
if (providerName && !sdkProvider) {
|
|
1359
|
+
log.warn(`Provider "${providerName}" set for channel ${channelId} but not found in config — using Copilot`);
|
|
1360
|
+
}
|
|
1043
1361
|
// Fetch available models for fallback chain (best-effort — don't block on failure)
|
|
1044
1362
|
let availableModels = [];
|
|
1363
|
+
let modelList = [];
|
|
1045
1364
|
try {
|
|
1046
|
-
|
|
1047
|
-
availableModels =
|
|
1365
|
+
modelList = await this.bridge.listModels(getConfig().providers);
|
|
1366
|
+
availableModels = modelList.map(m => m.id);
|
|
1048
1367
|
}
|
|
1049
1368
|
catch {
|
|
1050
1369
|
log.warn('Failed to fetch model list for fallback resolution');
|
|
@@ -1058,6 +1377,7 @@ export class SessionManager {
|
|
|
1058
1377
|
const createWithModel = async (model) => {
|
|
1059
1378
|
return withWorkspaceEnv(workingDirectory, () => this.bridge.createSession({
|
|
1060
1379
|
model,
|
|
1380
|
+
provider: sdkProvider || undefined,
|
|
1061
1381
|
workingDirectory,
|
|
1062
1382
|
configDir: defaultConfigDir,
|
|
1063
1383
|
reasoningEffort: reasoningEffort ?? undefined,
|
|
@@ -1071,9 +1391,37 @@ export class SessionManager {
|
|
|
1071
1391
|
infiniteSessions: getConfig().infiniteSessions,
|
|
1072
1392
|
}));
|
|
1073
1393
|
};
|
|
1074
|
-
const
|
|
1394
|
+
const byokPrefixes = Object.keys(getConfig().providers ?? {});
|
|
1395
|
+
let session;
|
|
1396
|
+
let usedModel;
|
|
1397
|
+
let didFallback;
|
|
1398
|
+
try {
|
|
1399
|
+
const result = await tryWithFallback(prefs.model, availableModels, configFallbacks, createWithModel, byokPrefixes);
|
|
1400
|
+
session = result.result;
|
|
1401
|
+
usedModel = result.usedModel;
|
|
1402
|
+
didFallback = result.didFallback;
|
|
1403
|
+
}
|
|
1404
|
+
catch (err) {
|
|
1405
|
+
// Enhance error message with BYOK context (only when provider actually resolved)
|
|
1406
|
+
if (providerName && sdkProvider) {
|
|
1407
|
+
const msg = String(err?.message ?? err);
|
|
1408
|
+
const provConfig = getConfig().providers?.[providerName];
|
|
1409
|
+
if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND') || msg.includes('fetch failed')) {
|
|
1410
|
+
throw new Error(`Provider "${providerName}" is unreachable at ${provConfig?.baseUrl ?? 'unknown URL'}. Check that the service is running.`);
|
|
1411
|
+
}
|
|
1412
|
+
if (msg.includes('401') || msg.includes('403') || msg.includes('Unauthorized') || msg.includes('Forbidden')) {
|
|
1413
|
+
throw new Error(`Provider "${providerName}" rejected authentication. Check your API key configuration.`);
|
|
1414
|
+
}
|
|
1415
|
+
if (msg.includes('404') || msg.includes('model not found') || msg.includes('does not exist')) {
|
|
1416
|
+
throw new Error(`Model "${prefs.model}" not found on provider "${providerName}". Check the model ID in your config.`);
|
|
1417
|
+
}
|
|
1418
|
+
throw new Error(`Provider "${providerName}" error: ${msg}`);
|
|
1419
|
+
}
|
|
1420
|
+
throw err;
|
|
1421
|
+
}
|
|
1075
1422
|
this.sessionMcpServers.set(channelId, new Set(Object.keys(resolvedMcpServers)));
|
|
1076
1423
|
this.sessionSkillDirs.set(channelId, new Set(skillDirectories));
|
|
1424
|
+
this.cacheContextWindowTokens(channelId, usedModel, modelList);
|
|
1077
1425
|
if (didFallback) {
|
|
1078
1426
|
log.info(`Model fallback: "${prefs.model}" → "${usedModel}" for channel ${channelId}`);
|
|
1079
1427
|
setChannelPrefs(channelId, { model: usedModel });
|
|
@@ -1107,11 +1455,20 @@ export class SessionManager {
|
|
|
1107
1455
|
if (hooks) {
|
|
1108
1456
|
log.debug(`Hooks resolved for session resume: ${Object.keys(hooks).join(', ')}`);
|
|
1109
1457
|
}
|
|
1458
|
+
// Resolve BYOK provider for resume
|
|
1459
|
+
const providerName = prefs.provider ?? null;
|
|
1460
|
+
let sdkProvider = providerName
|
|
1461
|
+
? resolveProviderConfig(providerName, getConfig().providers, prefs.model ?? undefined)
|
|
1462
|
+
: undefined;
|
|
1463
|
+
if (providerName && !sdkProvider) {
|
|
1464
|
+
log.warn(`Provider "${providerName}" set for channel ${channelId} but not found in config — using Copilot`);
|
|
1465
|
+
}
|
|
1110
1466
|
const session = await withWorkspaceEnv(workingDirectory, () => this.bridge.resumeSession(sessionId, {
|
|
1111
1467
|
onPermissionRequest: (request, invocation) => this.handlePermissionRequest(channelId, request, invocation),
|
|
1112
1468
|
onUserInputRequest: (request, invocation) => this.handleUserInputRequest(channelId, request, invocation),
|
|
1113
1469
|
configDir: defaultConfigDir,
|
|
1114
1470
|
workingDirectory,
|
|
1471
|
+
provider: sdkProvider || undefined,
|
|
1115
1472
|
reasoningEffort: reasoningEffort ?? undefined,
|
|
1116
1473
|
mcpServers,
|
|
1117
1474
|
skillDirectories: skillDirectories.length > 0 ? skillDirectories : undefined,
|
|
@@ -1125,6 +1482,15 @@ export class SessionManager {
|
|
|
1125
1482
|
this.channelSessions.set(channelId, sessionId);
|
|
1126
1483
|
this.sessionChannels.set(sessionId, channelId);
|
|
1127
1484
|
this.attachSessionEvents(session, channelId);
|
|
1485
|
+
// Cache context window tokens for /context display (best-effort, non-blocking)
|
|
1486
|
+
const resumeModel = prefs.model;
|
|
1487
|
+
this.bridge.listModels(getConfig().providers).then(models => {
|
|
1488
|
+
// Guard against model changes before this resolves
|
|
1489
|
+
const currentPrefs = this.getEffectivePrefs(channelId);
|
|
1490
|
+
if (currentPrefs.model === resumeModel) {
|
|
1491
|
+
this.cacheContextWindowTokens(channelId, resumeModel, models);
|
|
1492
|
+
}
|
|
1493
|
+
}).catch(() => { });
|
|
1128
1494
|
}
|
|
1129
1495
|
/**
|
|
1130
1496
|
* Execute an ephemeral inter-agent call: create a fresh session for the target bot,
|
|
@@ -1761,6 +2127,8 @@ export class SessionManager {
|
|
|
1761
2127
|
}
|
|
1762
2128
|
// Scheduler tool: create/list/cancel/pause/resume scheduled tasks
|
|
1763
2129
|
tools.push(this.buildScheduleToolDef(channelId));
|
|
2130
|
+
// Bridge documentation tool
|
|
2131
|
+
tools.push(this.buildBridgeDocsTool(channelId));
|
|
1764
2132
|
if (tools.length > 0) {
|
|
1765
2133
|
log.info(`Built ${tools.length} custom tool(s) for channel ${channelId.slice(0, 8)}...`);
|
|
1766
2134
|
}
|
|
@@ -1877,6 +2245,38 @@ export class SessionManager {
|
|
|
1877
2245
|
},
|
|
1878
2246
|
};
|
|
1879
2247
|
}
|
|
2248
|
+
buildBridgeDocsTool(channelId) {
|
|
2249
|
+
const channelConfig = getChannelConfig(channelId);
|
|
2250
|
+
const botName = getChannelBotName(channelId);
|
|
2251
|
+
const isAdmin = isBotAdmin(channelConfig.platform, botName);
|
|
2252
|
+
return {
|
|
2253
|
+
name: 'fetch_copilot_bridge_documentation',
|
|
2254
|
+
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.',
|
|
2255
|
+
parameters: {
|
|
2256
|
+
type: 'object',
|
|
2257
|
+
properties: {
|
|
2258
|
+
topic: {
|
|
2259
|
+
type: 'string',
|
|
2260
|
+
enum: ['overview', 'commands', 'config', 'mcp', 'permissions', 'workspaces', 'hooks', 'skills', 'inter-agent', 'scheduling', 'providers', 'troubleshooting', 'status'],
|
|
2261
|
+
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. 'providers' = BYOK provider setup and commands. 'troubleshooting' = common issues. 'status' = live system state.",
|
|
2262
|
+
},
|
|
2263
|
+
},
|
|
2264
|
+
required: [],
|
|
2265
|
+
},
|
|
2266
|
+
handler: async (args) => {
|
|
2267
|
+
const sessionInfo = this.getSessionInfo(channelId);
|
|
2268
|
+
return {
|
|
2269
|
+
content: getBridgeDocs({
|
|
2270
|
+
topic: args.topic,
|
|
2271
|
+
isAdmin,
|
|
2272
|
+
channelId,
|
|
2273
|
+
model: sessionInfo?.model,
|
|
2274
|
+
sessionId: sessionInfo?.sessionId,
|
|
2275
|
+
}),
|
|
2276
|
+
};
|
|
2277
|
+
},
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
1880
2280
|
attachSessionEvents(session, channelId) {
|
|
1881
2281
|
const unsub = session.on((event) => {
|
|
1882
2282
|
if (event.type === 'session.usage_info' && event.data) {
|