@chrisromp/copilot-bridge 0.11.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.
@@ -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);
@@ -871,6 +876,123 @@ export class SessionManager {
871
876
  this.pendingUserInput.delete(channelId);
872
877
  }
873
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
+ }
874
996
  /** Switch the model for a channel's session. */
875
997
  async switchModel(channelId, model, provider) {
876
998
  const currentPrefs = this.getEffectivePrefs(channelId);
@@ -908,16 +1030,8 @@ export class SessionManager {
908
1030
  }
909
1031
  }
910
1032
  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
- }
1033
+ // Same provider — use RPC model switch (with session retry)
1034
+ await this.withSessionRetry(channelId, (sid) => this.bridge.switchSessionModel(sid, model));
921
1035
  setChannelPrefs(channelId, { model, provider: newProvider });
922
1036
  }
923
1037
  // Clear stale context window tokens so /context falls back to tokenLimit during transition
@@ -947,20 +1061,26 @@ export class SessionManager {
947
1061
  }
948
1062
  /** Switch the agent for a channel's session. */
949
1063
  async switchAgent(channelId, agent) {
950
- const { sessionId } = await this.ensureSession(channelId);
951
- try {
1064
+ await this.withSessionRetry(channelId, async (sid) => {
952
1065
  if (agent) {
953
- await this.bridge.selectAgent(sessionId, agent);
1066
+ await this.bridge.selectAgent(sid, agent);
954
1067
  }
955
1068
  else {
956
- await this.bridge.deselectAgent(sessionId);
1069
+ await this.bridge.deselectAgent(sid);
957
1070
  }
958
- }
959
- catch (err) {
960
- log.warn(`RPC agent switch failed:`, err);
961
- }
1071
+ });
962
1072
  setChannelPrefs(channelId, { agent });
963
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
+ }
964
1084
  /** Get effective preferences for a channel (config merged with runtime overrides). */
965
1085
  getEffectivePrefs(channelId) {
966
1086
  const configChannel = getChannelConfig(channelId);
@@ -996,7 +1116,7 @@ export class SessionManager {
996
1116
  const sessionId = this.channelSessions.get(channelId);
997
1117
  if (sessionId) {
998
1118
  try {
999
- const result = await this.bridge.getSessionMode(sessionId);
1119
+ const result = await this.withSessionRetry(channelId, (sid) => this.bridge.getSessionMode(sid), false);
1000
1120
  return result.mode;
1001
1121
  }
1002
1122
  catch { /* fall through to prefs */ }
@@ -1006,8 +1126,7 @@ export class SessionManager {
1006
1126
  }
1007
1127
  /** Set the session mode (interactive, plan, autopilot). Persists to channel prefs. Does not change yolo/permission state. */
1008
1128
  async setSessionMode(channelId, mode) {
1009
- const { sessionId } = await this.ensureSession(channelId);
1010
- const result = await this.bridge.setSessionMode(sessionId, mode);
1129
+ const result = await this.withSessionRetry(channelId, (sid) => this.bridge.setSessionMode(sid, mode));
1011
1130
  setChannelPrefs(channelId, { sessionMode: result.mode });
1012
1131
  return result.mode;
1013
1132
  }
@@ -1017,7 +1136,7 @@ export class SessionManager {
1017
1136
  if (!sessionId)
1018
1137
  return { exists: false, content: null };
1019
1138
  try {
1020
- const result = await this.bridge.readPlan(sessionId);
1139
+ const result = await this.withSessionRetry(channelId, (sid) => this.bridge.readPlan(sid), false);
1021
1140
  return { exists: result.exists, content: result.content };
1022
1141
  }
1023
1142
  catch {
@@ -1030,7 +1149,7 @@ export class SessionManager {
1030
1149
  if (!sessionId)
1031
1150
  return false;
1032
1151
  try {
1033
- await this.bridge.deletePlan(sessionId);
1152
+ await this.withSessionRetry(channelId, (sid) => this.bridge.deletePlan(sid), false);
1034
1153
  return true;
1035
1154
  }
1036
1155
  catch {
@@ -1339,6 +1458,38 @@ export class SessionManager {
1339
1458
  return config.workingDirectory;
1340
1459
  return getWorkspacePath(botName);
1341
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
+ }
1342
1493
  async createNewSession(channelId) {
1343
1494
  const prefs = this.getEffectivePrefs(channelId);
1344
1495
  const workingDirectory = this.resolveWorkingDirectory(channelId);
@@ -1381,6 +1532,7 @@ export class SessionManager {
1381
1532
  workingDirectory,
1382
1533
  configDir: defaultConfigDir,
1383
1534
  reasoningEffort: reasoningEffort ?? undefined,
1535
+ agent: prefs.agent ?? undefined,
1384
1536
  mcpServers: resolvedMcpServers,
1385
1537
  skillDirectories: skillDirectories.length > 0 ? skillDirectories : undefined,
1386
1538
  disabledSkills,
@@ -1389,6 +1541,7 @@ export class SessionManager {
1389
1541
  tools: customTools.length > 0 ? customTools : undefined,
1390
1542
  hooks,
1391
1543
  infiniteSessions: getConfig().infiniteSessions,
1544
+ systemMessage: this.buildSystemMessage(),
1392
1545
  }));
1393
1546
  };
1394
1547
  const byokPrefixes = Object.keys(getConfig().providers ?? {});
@@ -1470,12 +1623,14 @@ export class SessionManager {
1470
1623
  workingDirectory,
1471
1624
  provider: sdkProvider || undefined,
1472
1625
  reasoningEffort: reasoningEffort ?? undefined,
1626
+ agent: prefs.agent ?? undefined,
1473
1627
  mcpServers,
1474
1628
  skillDirectories: skillDirectories.length > 0 ? skillDirectories : undefined,
1475
1629
  disabledSkills,
1476
1630
  tools: customTools.length > 0 ? customTools : undefined,
1477
1631
  hooks,
1478
1632
  infiniteSessions: getConfig().infiniteSessions,
1633
+ systemMessage: this.buildSystemMessage(),
1479
1634
  }));
1480
1635
  this.sessionMcpServers.set(channelId, new Set(Object.keys(mcpServers)));
1481
1636
  this.sessionSkillDirs.set(channelId, new Set(skillDirectories));
@@ -2129,6 +2284,26 @@ export class SessionManager {
2129
2284
  tools.push(this.buildScheduleToolDef(channelId));
2130
2285
  // Bridge documentation tool
2131
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
+ }
2132
2307
  if (tools.length > 0) {
2133
2308
  log.info(`Built ${tools.length} custom tool(s) for channel ${channelId.slice(0, 8)}...`);
2134
2309
  }