@chrisromp/copilot-bridge 0.10.0 → 0.12.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 (38) 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 +1 -1
  8. package/dist/core/bridge-docs.d.ts.map +1 -1
  9. package/dist/core/bridge-docs.js +133 -1
  10. package/dist/core/bridge-docs.js.map +1 -1
  11. package/dist/core/bridge.d.ts +19 -2
  12. package/dist/core/bridge.d.ts.map +1 -1
  13. package/dist/core/bridge.js +29 -5
  14. package/dist/core/bridge.js.map +1 -1
  15. package/dist/core/command-handler.d.ts +16 -4
  16. package/dist/core/command-handler.d.ts.map +1 -1
  17. package/dist/core/command-handler.js +258 -51
  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.js +1 -1
  24. package/dist/core/session-manager.d.ts +40 -1
  25. package/dist/core/session-manager.d.ts.map +1 -1
  26. package/dist/core/session-manager.js +316 -36
  27. package/dist/core/session-manager.js.map +1 -1
  28. package/dist/index.js +243 -48
  29. package/dist/index.js.map +1 -1
  30. package/dist/state/store.d.ts +1 -0
  31. package/dist/state/store.d.ts.map +1 -1
  32. package/dist/state/store.js +13 -2
  33. package/dist/state/store.js.map +1 -1
  34. package/dist/types.d.ts +20 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/package.json +2 -2
  37. package/templates/admin/AGENTS.md +38 -38
  38. package/templates/agents/AGENTS.md +0 -49
@@ -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';
@@ -13,7 +13,7 @@ import { loadHooks, getHooksInfo } from './hooks-loader.js';
13
13
  import { getBridgeDocs } from './bridge-docs.js';
14
14
  const log = createLogger('session');
15
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
+ export const BRIDGE_CUSTOM_TOOLS = ['send_file', 'show_file_in_chat', 'ask_agent', 'schedule', 'fetch_copilot_bridge_documentation', 'no_reply'];
17
17
  /** Simple mutex for serializing env-sensitive session creation. */
18
18
  let envLock = Promise.resolve();
19
19
  /**
@@ -90,6 +90,11 @@ async function withWorkspaceEnv(workingDirectory, fn) {
90
90
  release();
91
91
  }
92
92
  }
93
+ /** Detect "Session not found" errors from the SDK backend. */
94
+ export function isSessionNotFoundError(err) {
95
+ const msg = String(err?.message ?? err).toLowerCase();
96
+ return msg.includes('session not found') || msg.includes('session_not_found');
97
+ }
93
98
  /**
94
99
  * Load MCP server configs from ~/.copilot/mcp-config.json and installed plugins.
95
100
  * Merges them into a single Record, with user config taking precedence over plugins.
@@ -555,7 +560,7 @@ export class SessionManager {
555
560
  if (!sessionId)
556
561
  return [];
557
562
  try {
558
- return await this.bridge.listTools();
563
+ return await this.withSessionRetry(channelId, () => this.bridge.listTools(), false);
559
564
  }
560
565
  catch (err) {
561
566
  log.warn(`Failed to list tools for channel ${channelId}:`, err);
@@ -772,11 +777,12 @@ export class SessionManager {
772
777
  const configFallbacks = configChannel.fallbackModels ?? getConfig().defaults.fallbackModels;
773
778
  let availableModels = [];
774
779
  try {
775
- const models = await this.bridge.listModels();
780
+ const models = await this.bridge.listModels(getConfig().providers);
776
781
  availableModels = models.map(m => m.id);
777
782
  }
778
783
  catch { /* best-effort */ }
779
- const chain = buildFallbackChain(prefs.model, availableModels, configFallbacks);
784
+ const byokPrefixes = Object.keys(getConfig().providers ?? {});
785
+ const chain = buildFallbackChain(prefs.model, availableModels, configFallbacks, byokPrefixes);
780
786
  // Try each fallback: create session + send
781
787
  let lastError = err;
782
788
  for (const fallbackModel of chain) {
@@ -870,51 +876,218 @@ export class SessionManager {
870
876
  this.pendingUserInput.delete(channelId);
871
877
  }
872
878
  }
879
+ /**
880
+ * Wrap an RPC call with session re-attach recovery.
881
+ * If the call fails with "Session not found", re-attaches the session and retries.
882
+ * If re-attach also fails, creates a new session and retries once more
883
+ * (unless createOnFail is false, in which case the error propagates).
884
+ */
885
+ async withSessionRetry(channelId, fn, createOnFail = true) {
886
+ const { sessionId } = await this.ensureSession(channelId);
887
+ try {
888
+ return await fn(sessionId);
889
+ }
890
+ catch (err) {
891
+ if (!isSessionNotFoundError(err))
892
+ throw err;
893
+ log.info(`Session ${sessionId} not found on RPC — attempting re-attach...`);
894
+ try {
895
+ const unsub = this.sessionUnsubscribes.get(sessionId);
896
+ if (unsub) {
897
+ unsub();
898
+ this.sessionUnsubscribes.delete(sessionId);
899
+ }
900
+ try {
901
+ await this.bridge.destroySession(sessionId);
902
+ }
903
+ catch { /* best-effort */ }
904
+ await this.attachSession(channelId, sessionId);
905
+ }
906
+ catch (attachErr) {
907
+ log.warn(`Re-attach failed for ${sessionId}:`, attachErr?.message ?? attachErr);
908
+ if (!createOnFail) {
909
+ // Clear stale session so ensureSession() creates fresh on next call
910
+ this.channelSessions.delete(channelId);
911
+ this.sessionChannels.delete(sessionId);
912
+ clearChannelSession(channelId);
913
+ throw err;
914
+ }
915
+ // Last resort: new session
916
+ log.info(`Creating new session for channel ${channelId} after RPC failure...`);
917
+ const newSessionId = await this.newSession(channelId);
918
+ return await fn(newSessionId);
919
+ }
920
+ // Re-attach succeeded — retry fn; let non-session errors propagate
921
+ return await fn(sessionId);
922
+ }
923
+ }
924
+ /**
925
+ * Reload MCP servers on the active session via RPC (no full session restart).
926
+ * Tells the SDK to re-read its MCP config (e.g., workspace mcp-config.json changes).
927
+ * Falls back to full reloadSession() if no active session exists yet or if the
928
+ * session is stale (backend no longer recognizes it).
929
+ */
930
+ async reloadMcp(channelId) {
931
+ const sessionId = this.channelSessions.get(channelId) ?? getChannelSession(channelId);
932
+ const session = sessionId ? this.bridge.getSession(sessionId) : undefined;
933
+ if (!session) {
934
+ log.info(`No active session for ${channelId.slice(0, 8)}... — falling back to full reload`);
935
+ await this.reloadSession(channelId);
936
+ return;
937
+ }
938
+ this.mcpServers = loadMcpServers();
939
+ try {
940
+ await session.rpc.mcp.reload();
941
+ }
942
+ catch (err) {
943
+ if (isSessionNotFoundError(err)) {
944
+ log.info(`Session stale during MCP reload for ${channelId.slice(0, 8)}... — falling back to full reload`);
945
+ await this.reloadSession(channelId);
946
+ return;
947
+ }
948
+ throw err;
949
+ }
950
+ log.info(`MCP servers reloaded via RPC for channel ${channelId.slice(0, 8)}...`);
951
+ }
952
+ /**
953
+ * Reload skills on the active session via RPC (no full session restart).
954
+ * Tells the SDK to re-read skill directories already configured on the session.
955
+ * Falls back to full reloadSession() if no active session exists yet or if the
956
+ * session is stale.
957
+ */
958
+ async reloadSkills(channelId) {
959
+ const sessionId = this.channelSessions.get(channelId) ?? getChannelSession(channelId);
960
+ const session = sessionId ? this.bridge.getSession(sessionId) : undefined;
961
+ if (!session) {
962
+ log.info(`No active session for ${channelId.slice(0, 8)}... — falling back to full reload`);
963
+ await this.reloadSession(channelId);
964
+ return;
965
+ }
966
+ try {
967
+ await session.rpc.skills.reload();
968
+ }
969
+ catch (err) {
970
+ if (isSessionNotFoundError(err)) {
971
+ log.info(`Session stale during skills reload for ${channelId.slice(0, 8)}... — falling back to full reload`);
972
+ await this.reloadSession(channelId);
973
+ return;
974
+ }
975
+ throw err;
976
+ }
977
+ log.info(`Skills reloaded via RPC for channel ${channelId.slice(0, 8)}...`);
978
+ }
979
+ /**
980
+ * Enable or disable a skill on the active session via RPC (instant, no reload needed).
981
+ * Silently no-ops if no active session (pref is already persisted).
982
+ */
983
+ async toggleSkillRpc(channelId, skillName, action) {
984
+ const sessionId = this.channelSessions.get(channelId) ?? getChannelSession(channelId);
985
+ const session = sessionId ? this.bridge.getSession(sessionId) : undefined;
986
+ if (!session)
987
+ return; // Pref already persisted; will apply on next session create
988
+ if (action === 'enable') {
989
+ await session.rpc.skills.enable({ name: skillName });
990
+ }
991
+ else {
992
+ await session.rpc.skills.disable({ name: skillName });
993
+ }
994
+ log.info(`Skill "${skillName}" ${action}d via RPC for channel ${channelId.slice(0, 8)}...`);
995
+ }
873
996
  /** Switch the model for a channel's session. */
874
- async switchModel(channelId, model) {
875
- const sessionId = this.channelSessions.get(channelId);
876
- if (sessionId) {
997
+ async switchModel(channelId, model, provider) {
998
+ const currentPrefs = this.getEffectivePrefs(channelId);
999
+ const currentProvider = currentPrefs.provider ?? null;
1000
+ const newProvider = provider ?? null;
1001
+ const providerChanged = currentProvider !== newProvider;
1002
+ if (providerChanged) {
1003
+ // Provider change requires a fresh session (different endpoint/auth)
1004
+ log.info(`Provider switch ${currentProvider ?? 'copilot'} → ${newProvider ?? 'copilot'} for channel ${channelId.slice(0, 8)}...`);
1005
+ // Set prefs before newSession so createNewSession picks up the new provider,
1006
+ // but restore on failure so the channel isn't left in a broken state.
1007
+ const prevModel = currentPrefs.model;
1008
+ setChannelPrefs(channelId, { model, provider: newProvider });
1009
+ try {
1010
+ await this.newSession(channelId);
1011
+ }
1012
+ catch (err) {
1013
+ log.warn(`Provider switch failed, reverting prefs:`, err);
1014
+ setChannelPrefs(channelId, { model: prevModel, provider: currentProvider });
1015
+ throw err;
1016
+ }
1017
+ }
1018
+ else if (newProvider && this.wireApiChanged(newProvider, currentPrefs.model ?? '', model)) {
1019
+ // Same provider but wireApi differs between models — need fresh session
1020
+ log.info(`wireApi change for provider ${newProvider}, model ${currentPrefs.model} → ${model} — creating new session`);
1021
+ const prevModel = currentPrefs.model;
1022
+ setChannelPrefs(channelId, { model, provider: newProvider });
877
1023
  try {
878
- await this.bridge.switchSessionModel(sessionId, model);
1024
+ await this.newSession(channelId);
879
1025
  }
880
1026
  catch (err) {
881
- log.warn(`RPC model switch failed:`, err);
1027
+ log.warn(`wireApi switch failed, reverting prefs:`, err);
1028
+ setChannelPrefs(channelId, { model: prevModel, provider: currentProvider });
1029
+ throw err;
882
1030
  }
883
1031
  }
884
- setChannelPrefs(channelId, { model });
1032
+ else {
1033
+ // Same provider — use RPC model switch (with session retry)
1034
+ await this.withSessionRetry(channelId, (sid) => this.bridge.switchSessionModel(sid, model));
1035
+ setChannelPrefs(channelId, { model, provider: newProvider });
1036
+ }
885
1037
  // Clear stale context window tokens so /context falls back to tokenLimit during transition
886
1038
  this.contextWindowTokens.delete(channelId);
887
1039
  // Update cached context window tokens for the new model (best-effort)
888
- this.bridge.listModels().then(models => {
1040
+ this.bridge.listModels(getConfig().providers).then(models => {
889
1041
  // 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) {
1042
+ const prefs = this.getEffectivePrefs(channelId);
1043
+ if (prefs.model === model) {
892
1044
  this.cacheContextWindowTokens(channelId, model, models);
893
1045
  }
894
1046
  }).catch(() => { });
895
1047
  }
1048
+ /** Check if two models on the same provider have different wireApi settings. */
1049
+ wireApiChanged(providerName, oldModel, newModel) {
1050
+ const providers = getConfig().providers;
1051
+ if (!providers)
1052
+ return false;
1053
+ const entry = providers[providerName];
1054
+ if (!entry)
1055
+ return false;
1056
+ const oldEntry = entry.models.find(m => m.id === oldModel);
1057
+ const newEntry = entry.models.find(m => m.id === newModel);
1058
+ const oldWire = oldEntry?.wireApi ?? entry.wireApi;
1059
+ const newWire = newEntry?.wireApi ?? entry.wireApi;
1060
+ return oldWire !== newWire;
1061
+ }
896
1062
  /** Switch the agent for a channel's session. */
897
1063
  async switchAgent(channelId, agent) {
898
- const { sessionId } = await this.ensureSession(channelId);
899
- try {
1064
+ await this.withSessionRetry(channelId, async (sid) => {
900
1065
  if (agent) {
901
- await this.bridge.selectAgent(sessionId, agent);
1066
+ await this.bridge.selectAgent(sid, agent);
902
1067
  }
903
1068
  else {
904
- await this.bridge.deselectAgent(sessionId);
1069
+ await this.bridge.deselectAgent(sid);
905
1070
  }
906
- }
907
- catch (err) {
908
- log.warn(`RPC agent switch failed:`, err);
909
- }
1071
+ });
910
1072
  setChannelPrefs(channelId, { agent });
911
1073
  }
1074
+ /**
1075
+ * Set reasoning effort on the active session via setModel() RPC.
1076
+ * Uses the current model so only the reasoning effort changes — no full session reload needed.
1077
+ */
1078
+ async setReasoningEffort(channelId, effort) {
1079
+ const prefs = this.getEffectivePrefs(channelId);
1080
+ const model = prefs.model;
1081
+ await this.withSessionRetry(channelId, (sid) => this.bridge.switchSessionModel(sid, model, { reasoningEffort: effort }));
1082
+ setChannelPrefs(channelId, { reasoningEffort: effort });
1083
+ }
912
1084
  /** Get effective preferences for a channel (config merged with runtime overrides). */
913
1085
  getEffectivePrefs(channelId) {
914
1086
  const configChannel = getChannelConfig(channelId);
915
1087
  const storedPrefs = getChannelPrefs(channelId);
916
1088
  return {
917
1089
  model: storedPrefs?.model ?? configChannel.model ?? 'claude-sonnet-4.6',
1090
+ provider: storedPrefs?.provider ?? null,
918
1091
  agent: storedPrefs?.agent !== undefined ? storedPrefs.agent : configChannel.agent,
919
1092
  verbose: storedPrefs?.verbose ?? configChannel.verbose,
920
1093
  triggerMode: configChannel.triggerMode,
@@ -927,7 +1100,7 @@ export class SessionManager {
927
1100
  /** Get model info (for checking capabilities like reasoning effort). */
928
1101
  async getModelInfo(modelId) {
929
1102
  try {
930
- const models = await this.bridge.listModels();
1103
+ const models = await this.bridge.listModels(getConfig().providers);
931
1104
  return models.find(m => m.id === modelId) ?? null;
932
1105
  }
933
1106
  catch {
@@ -936,14 +1109,14 @@ export class SessionManager {
936
1109
  }
937
1110
  /** List all available models. */
938
1111
  async listModels() {
939
- return this.bridge.listModels();
1112
+ return this.bridge.listModels(getConfig().providers);
940
1113
  }
941
1114
  /** Get the current session mode (interactive, plan, autopilot). Falls back to persisted prefs after restart. */
942
1115
  async getSessionMode(channelId) {
943
1116
  const sessionId = this.channelSessions.get(channelId);
944
1117
  if (sessionId) {
945
1118
  try {
946
- const result = await this.bridge.getSessionMode(sessionId);
1119
+ const result = await this.withSessionRetry(channelId, (sid) => this.bridge.getSessionMode(sid), false);
947
1120
  return result.mode;
948
1121
  }
949
1122
  catch { /* fall through to prefs */ }
@@ -953,8 +1126,7 @@ export class SessionManager {
953
1126
  }
954
1127
  /** Set the session mode (interactive, plan, autopilot). Persists to channel prefs. Does not change yolo/permission state. */
955
1128
  async setSessionMode(channelId, mode) {
956
- const { sessionId } = await this.ensureSession(channelId);
957
- const result = await this.bridge.setSessionMode(sessionId, mode);
1129
+ const result = await this.withSessionRetry(channelId, (sid) => this.bridge.setSessionMode(sid, mode));
958
1130
  setChannelPrefs(channelId, { sessionMode: result.mode });
959
1131
  return result.mode;
960
1132
  }
@@ -964,7 +1136,7 @@ export class SessionManager {
964
1136
  if (!sessionId)
965
1137
  return { exists: false, content: null };
966
1138
  try {
967
- const result = await this.bridge.readPlan(sessionId);
1139
+ const result = await this.withSessionRetry(channelId, (sid) => this.bridge.readPlan(sid), false);
968
1140
  return { exists: result.exists, content: result.content };
969
1141
  }
970
1142
  catch {
@@ -977,7 +1149,7 @@ export class SessionManager {
977
1149
  if (!sessionId)
978
1150
  return false;
979
1151
  try {
980
- await this.bridge.deletePlan(sessionId);
1152
+ await this.withSessionRetry(channelId, (sid) => this.bridge.deletePlan(sid), false);
981
1153
  return true;
982
1154
  }
983
1155
  catch {
@@ -1017,7 +1189,7 @@ export class SessionManager {
1017
1189
  // Pick the best available cheap model
1018
1190
  let availableIds = [];
1019
1191
  try {
1020
- const models = await this.bridge.listModels();
1192
+ const models = await this.bridge.listModels(getConfig().providers);
1021
1193
  availableIds = models.map(m => m.id);
1022
1194
  }
1023
1195
  catch { /* best-effort */ }
@@ -1237,7 +1409,14 @@ export class SessionManager {
1237
1409
  }
1238
1410
  /** Cache the model's max_context_window_tokens for accurate /context display. */
1239
1411
  cacheContextWindowTokens(channelId, modelId, modelList) {
1240
- const model = modelList.find((m) => m.id === modelId);
1412
+ let model = modelList.find((m) => m.id === modelId);
1413
+ // For BYOK models, the merged list has provider-prefixed IDs (e.g., "ollama-local:qwen3:8b")
1414
+ if (!model) {
1415
+ const prefs = getChannelPrefs(channelId);
1416
+ if (prefs?.provider) {
1417
+ model = modelList.find((m) => m.id === `${prefs.provider}:${modelId}`);
1418
+ }
1419
+ }
1241
1420
  const ctxTokens = model?.capabilities?.limits?.max_context_window_tokens;
1242
1421
  if (typeof ctxTokens === 'number' && ctxTokens > 0) {
1243
1422
  this.contextWindowTokens.set(channelId, ctxTokens);
@@ -1279,6 +1458,38 @@ export class SessionManager {
1279
1458
  return config.workingDirectory;
1280
1459
  return getWorkspacePath(botName);
1281
1460
  }
1461
+ /** Build the system message config for session create/resume.
1462
+ * Appends bridge-specific instructions to the SDK's custom_instructions section
1463
+ * so agents get channel communication context without polluting AGENTS.md. */
1464
+ buildSystemMessage() {
1465
+ const content = [
1466
+ '<bridge_instructions>',
1467
+ 'You are communicating through copilot-bridge, a messaging bridge to a chat platform (e.g., Mattermost, Slack).',
1468
+ '',
1469
+ '## Channel Communication',
1470
+ '- Your responses are streamed back to the chat channel in real time',
1471
+ '- Slash commands (e.g., /new, /model, /verbose, /plan) are intercepted by the bridge — you will never see them',
1472
+ '- The user may be on mobile — keep responses concise when possible',
1473
+ '',
1474
+ '## Environment Secrets',
1475
+ '- A `.env` file in your workspace is loaded into your shell environment at session start',
1476
+ '- **Never read, cat, or display `.env` contents** — secret values must stay out of chat',
1477
+ '- Reference secrets by variable name only (e.g., `$APP_TOKEN`)',
1478
+ '- To check if a key exists: `grep -q \'^KEY=\' .env 2>/dev/null`',
1479
+ '- Never use cat, grep -v, sed, or any command that would output .env values',
1480
+ '',
1481
+ '## No-Reply Convention',
1482
+ '- When you have nothing meaningful to add to a conversation, call the `no_reply` tool instead of sending text',
1483
+ '- This is preferred over typing "NO_REPLY" or similar text responses',
1484
+ '</bridge_instructions>',
1485
+ ].join('\n');
1486
+ return {
1487
+ mode: 'customize',
1488
+ sections: {
1489
+ custom_instructions: { action: 'append', content },
1490
+ },
1491
+ };
1492
+ }
1282
1493
  async createNewSession(channelId) {
1283
1494
  const prefs = this.getEffectivePrefs(channelId);
1284
1495
  const workingDirectory = this.resolveWorkingDirectory(channelId);
@@ -1290,11 +1501,19 @@ export class SessionManager {
1290
1501
  // Resolve fallback configuration
1291
1502
  const configChannel = getChannelConfig(channelId);
1292
1503
  const configFallbacks = configChannel.fallbackModels ?? getConfig().defaults.fallbackModels;
1504
+ // Resolve BYOK provider if set in prefs
1505
+ const providerName = prefs.provider ?? null;
1506
+ const sdkProvider = providerName
1507
+ ? resolveProviderConfig(providerName, getConfig().providers, prefs.model ?? undefined)
1508
+ : undefined;
1509
+ if (providerName && !sdkProvider) {
1510
+ log.warn(`Provider "${providerName}" set for channel ${channelId} but not found in config — using Copilot`);
1511
+ }
1293
1512
  // Fetch available models for fallback chain (best-effort — don't block on failure)
1294
1513
  let availableModels = [];
1295
1514
  let modelList = [];
1296
1515
  try {
1297
- modelList = await this.bridge.listModels();
1516
+ modelList = await this.bridge.listModels(getConfig().providers);
1298
1517
  availableModels = modelList.map(m => m.id);
1299
1518
  }
1300
1519
  catch {
@@ -1309,9 +1528,11 @@ export class SessionManager {
1309
1528
  const createWithModel = async (model) => {
1310
1529
  return withWorkspaceEnv(workingDirectory, () => this.bridge.createSession({
1311
1530
  model,
1531
+ provider: sdkProvider || undefined,
1312
1532
  workingDirectory,
1313
1533
  configDir: defaultConfigDir,
1314
1534
  reasoningEffort: reasoningEffort ?? undefined,
1535
+ agent: prefs.agent ?? undefined,
1315
1536
  mcpServers: resolvedMcpServers,
1316
1537
  skillDirectories: skillDirectories.length > 0 ? skillDirectories : undefined,
1317
1538
  disabledSkills,
@@ -1320,9 +1541,37 @@ export class SessionManager {
1320
1541
  tools: customTools.length > 0 ? customTools : undefined,
1321
1542
  hooks,
1322
1543
  infiniteSessions: getConfig().infiniteSessions,
1544
+ systemMessage: this.buildSystemMessage(),
1323
1545
  }));
1324
1546
  };
1325
- const { result: session, usedModel, didFallback } = await tryWithFallback(prefs.model, availableModels, configFallbacks, createWithModel);
1547
+ const byokPrefixes = Object.keys(getConfig().providers ?? {});
1548
+ let session;
1549
+ let usedModel;
1550
+ let didFallback;
1551
+ try {
1552
+ const result = await tryWithFallback(prefs.model, availableModels, configFallbacks, createWithModel, byokPrefixes);
1553
+ session = result.result;
1554
+ usedModel = result.usedModel;
1555
+ didFallback = result.didFallback;
1556
+ }
1557
+ catch (err) {
1558
+ // Enhance error message with BYOK context (only when provider actually resolved)
1559
+ if (providerName && sdkProvider) {
1560
+ const msg = String(err?.message ?? err);
1561
+ const provConfig = getConfig().providers?.[providerName];
1562
+ if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND') || msg.includes('fetch failed')) {
1563
+ throw new Error(`Provider "${providerName}" is unreachable at ${provConfig?.baseUrl ?? 'unknown URL'}. Check that the service is running.`);
1564
+ }
1565
+ if (msg.includes('401') || msg.includes('403') || msg.includes('Unauthorized') || msg.includes('Forbidden')) {
1566
+ throw new Error(`Provider "${providerName}" rejected authentication. Check your API key configuration.`);
1567
+ }
1568
+ if (msg.includes('404') || msg.includes('model not found') || msg.includes('does not exist')) {
1569
+ throw new Error(`Model "${prefs.model}" not found on provider "${providerName}". Check the model ID in your config.`);
1570
+ }
1571
+ throw new Error(`Provider "${providerName}" error: ${msg}`);
1572
+ }
1573
+ throw err;
1574
+ }
1326
1575
  this.sessionMcpServers.set(channelId, new Set(Object.keys(resolvedMcpServers)));
1327
1576
  this.sessionSkillDirs.set(channelId, new Set(skillDirectories));
1328
1577
  this.cacheContextWindowTokens(channelId, usedModel, modelList);
@@ -1359,18 +1608,29 @@ export class SessionManager {
1359
1608
  if (hooks) {
1360
1609
  log.debug(`Hooks resolved for session resume: ${Object.keys(hooks).join(', ')}`);
1361
1610
  }
1611
+ // Resolve BYOK provider for resume
1612
+ const providerName = prefs.provider ?? null;
1613
+ let sdkProvider = providerName
1614
+ ? resolveProviderConfig(providerName, getConfig().providers, prefs.model ?? undefined)
1615
+ : undefined;
1616
+ if (providerName && !sdkProvider) {
1617
+ log.warn(`Provider "${providerName}" set for channel ${channelId} but not found in config — using Copilot`);
1618
+ }
1362
1619
  const session = await withWorkspaceEnv(workingDirectory, () => this.bridge.resumeSession(sessionId, {
1363
1620
  onPermissionRequest: (request, invocation) => this.handlePermissionRequest(channelId, request, invocation),
1364
1621
  onUserInputRequest: (request, invocation) => this.handleUserInputRequest(channelId, request, invocation),
1365
1622
  configDir: defaultConfigDir,
1366
1623
  workingDirectory,
1624
+ provider: sdkProvider || undefined,
1367
1625
  reasoningEffort: reasoningEffort ?? undefined,
1626
+ agent: prefs.agent ?? undefined,
1368
1627
  mcpServers,
1369
1628
  skillDirectories: skillDirectories.length > 0 ? skillDirectories : undefined,
1370
1629
  disabledSkills,
1371
1630
  tools: customTools.length > 0 ? customTools : undefined,
1372
1631
  hooks,
1373
1632
  infiniteSessions: getConfig().infiniteSessions,
1633
+ systemMessage: this.buildSystemMessage(),
1374
1634
  }));
1375
1635
  this.sessionMcpServers.set(channelId, new Set(Object.keys(mcpServers)));
1376
1636
  this.sessionSkillDirs.set(channelId, new Set(skillDirectories));
@@ -1379,7 +1639,7 @@ export class SessionManager {
1379
1639
  this.attachSessionEvents(session, channelId);
1380
1640
  // Cache context window tokens for /context display (best-effort, non-blocking)
1381
1641
  const resumeModel = prefs.model;
1382
- this.bridge.listModels().then(models => {
1642
+ this.bridge.listModels(getConfig().providers).then(models => {
1383
1643
  // Guard against model changes before this resolves
1384
1644
  const currentPrefs = this.getEffectivePrefs(channelId);
1385
1645
  if (currentPrefs.model === resumeModel) {
@@ -2024,6 +2284,26 @@ export class SessionManager {
2024
2284
  tools.push(this.buildScheduleToolDef(channelId));
2025
2285
  // Bridge documentation tool
2026
2286
  tools.push(this.buildBridgeDocsTool(channelId));
2287
+ // No-reply tool: agent calls this instead of emitting "NO_REPLY" text
2288
+ tools.push({
2289
+ name: 'no_reply',
2290
+ description: 'Signal that no response is needed for the current message. Call this instead of sending text when you have nothing meaningful to add to the conversation.',
2291
+ parameters: { type: 'object', properties: {} },
2292
+ skipPermission: true,
2293
+ handler: async () => {
2294
+ log.info(`no_reply tool called for channel ${channelId.slice(0, 8)}...`);
2295
+ return { content: 'Acknowledged. No response sent.' };
2296
+ },
2297
+ });
2298
+ // Mark safe bridge tools as skip-permission (they enforce their own boundaries).
2299
+ // Admin-only tools (create_project, grant_path_access, revoke_path_access) are
2300
+ // intentionally left on the normal permission path.
2301
+ const skipSet = new Set(BRIDGE_CUSTOM_TOOLS);
2302
+ for (const tool of tools) {
2303
+ if (skipSet.has(tool.name) && !('skipPermission' in tool)) {
2304
+ tool.skipPermission = true;
2305
+ }
2306
+ }
2027
2307
  if (tools.length > 0) {
2028
2308
  log.info(`Built ${tools.length} custom tool(s) for channel ${channelId.slice(0, 8)}...`);
2029
2309
  }
@@ -2152,8 +2432,8 @@ export class SessionManager {
2152
2432
  properties: {
2153
2433
  topic: {
2154
2434
  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.",
2435
+ enum: ['overview', 'commands', 'config', 'mcp', 'permissions', 'workspaces', 'hooks', 'skills', 'inter-agent', 'scheduling', 'providers', 'troubleshooting', 'status'],
2436
+ 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.",
2157
2437
  },
2158
2438
  },
2159
2439
  required: [],