@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.
- 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 +1 -1
- package/dist/core/bridge-docs.d.ts.map +1 -1
- package/dist/core/bridge-docs.js +133 -1
- package/dist/core/bridge-docs.js.map +1 -1
- package/dist/core/bridge.d.ts +19 -2
- package/dist/core/bridge.d.ts.map +1 -1
- package/dist/core/bridge.js +29 -5
- package/dist/core/bridge.js.map +1 -1
- package/dist/core/command-handler.d.ts +16 -4
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +258 -51
- 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.js +1 -1
- package/dist/core/session-manager.d.ts +40 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +316 -36
- package/dist/core/session-manager.js.map +1 -1
- package/dist/index.js +243 -48
- 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 +20 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/templates/admin/AGENTS.md +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
|
|
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
|
|
876
|
-
|
|
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.
|
|
1024
|
+
await this.newSession(channelId);
|
|
879
1025
|
}
|
|
880
1026
|
catch (err) {
|
|
881
|
-
log.warn(`
|
|
1027
|
+
log.warn(`wireApi switch failed, reverting prefs:`, err);
|
|
1028
|
+
setChannelPrefs(channelId, { model: prevModel, provider: currentProvider });
|
|
1029
|
+
throw err;
|
|
882
1030
|
}
|
|
883
1031
|
}
|
|
884
|
-
|
|
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
|
|
891
|
-
if (
|
|
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
|
-
|
|
899
|
-
try {
|
|
1064
|
+
await this.withSessionRetry(channelId, async (sid) => {
|
|
900
1065
|
if (agent) {
|
|
901
|
-
await this.bridge.selectAgent(
|
|
1066
|
+
await this.bridge.selectAgent(sid, agent);
|
|
902
1067
|
}
|
|
903
1068
|
else {
|
|
904
|
-
await this.bridge.deselectAgent(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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: [],
|