@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.
Files changed (41) hide show
  1. package/README.md +4 -0
  2. package/config.sample.json +20 -0
  3. package/dist/config.d.ts +9 -1
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +117 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/core/bridge-docs.d.ts +17 -0
  8. package/dist/core/bridge-docs.d.ts.map +1 -0
  9. package/dist/core/bridge-docs.js +790 -0
  10. package/dist/core/bridge-docs.js.map +1 -0
  11. package/dist/core/bridge.d.ts +14 -1
  12. package/dist/core/bridge.d.ts.map +1 -1
  13. package/dist/core/bridge.js +22 -2
  14. package/dist/core/bridge.js.map +1 -1
  15. package/dist/core/command-handler.d.ts +17 -4
  16. package/dist/core/command-handler.d.ts.map +1 -1
  17. package/dist/core/command-handler.js +255 -52
  18. package/dist/core/command-handler.js.map +1 -1
  19. package/dist/core/model-fallback.d.ts +2 -2
  20. package/dist/core/model-fallback.d.ts.map +1 -1
  21. package/dist/core/model-fallback.js +11 -4
  22. package/dist/core/model-fallback.js.map +1 -1
  23. package/dist/core/quiet-mode.d.ts +14 -0
  24. package/dist/core/quiet-mode.d.ts.map +1 -0
  25. package/dist/core/quiet-mode.js +49 -0
  26. package/dist/core/quiet-mode.js.map +1 -0
  27. package/dist/core/session-manager.d.ts +53 -3
  28. package/dist/core/session-manager.d.ts.map +1 -1
  29. package/dist/core/session-manager.js +430 -30
  30. package/dist/core/session-manager.js.map +1 -1
  31. package/dist/index.js +437 -41
  32. package/dist/index.js.map +1 -1
  33. package/dist/state/store.d.ts +1 -0
  34. package/dist/state/store.d.ts.map +1 -1
  35. package/dist/state/store.js +13 -2
  36. package/dist/state/store.js.map +1 -1
  37. package/dist/types.d.ts +29 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/templates/admin/AGENTS.md +56 -0
  41. 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 (they enforce workspace boundaries internally). */
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
- // Clone global servers and inject workspace .env into local ones
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 chain = buildFallbackChain(prefs.model, availableModels, configFallbacks);
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 sessionId = this.channelSessions.get(channelId);
779
- if (sessionId) {
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.bridge.switchSessionModel(sessionId, model);
902
+ await this.newSession(channelId);
782
903
  }
783
904
  catch (err) {
784
- log.warn(`RPC model switch failed:`, err);
905
+ log.warn(`wireApi switch failed, reverting prefs:`, err);
906
+ setChannelPrefs(channelId, { model: prevModel, provider: currentProvider });
907
+ throw err;
785
908
  }
786
909
  }
787
- setChannelPrefs(channelId, { model });
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
- return this.contextUsage.get(channelId) ?? null;
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
- const models = await this.bridge.listModels();
1047
- availableModels = models.map(m => m.id);
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 { result: session, usedModel, didFallback } = await tryWithFallback(prefs.model, availableModels, configFallbacks, createWithModel);
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) {