@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
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { initScheduler, stopAll as stopScheduler, listJobs, removeJob, pauseJob,
12
12
  import { markBusy, markIdle, markIdleImmediate, isBusy, waitForChannelIdle, cancelIdleDebounce } from './core/channel-idle.js';
13
13
  import { LoopDetector, MAX_IDENTICAL_CALLS } from './core/loop-detector.js';
14
14
  import { checkUserAccess } from './core/access-control.js';
15
+ import { enterQuietMode, exitQuietMode, isQuiet } from './core/quiet-mode.js';
15
16
  import { createLogger, setLogLevel } from './logger.js';
16
17
  import fs from 'node:fs';
17
18
  import path from 'node:path';
@@ -30,8 +31,9 @@ const ACTIVITY_THROTTLE_MS = 600;
30
31
  const channelLocks = new Map();
31
32
  // Per-channel promise chain to serialize SESSION EVENT handling (prevents race on auto-start)
32
33
  const eventLocks = new Map();
33
- // Channels with an active startup nudge in flight (NO_REPLY filter only applies here)
34
- const nudgePending = new Set();
34
+ // Channels in "quiet mode" all streaming output suppressed until we determine
35
+ // whether the response is NO_REPLY. Used for startup nudges and scheduled tasks.
36
+ // State managed in src/core/quiet-mode.ts
35
37
  // Bot adapters keyed by "platform:botName" for channel→adapter lookup
36
38
  const botAdapters = new Map();
37
39
  const botStreamers = new Map();
@@ -39,6 +41,8 @@ const botStreamers = new Map();
39
41
  const loopDetector = new LoopDetector();
40
42
  // Track last known sessionId per channel for implicit session change detection
41
43
  const lastSessionIds = new Map();
44
+ // Channels that have had their plan surfaced after session resume (one-time)
45
+ const planSurfacedOnResume = new Set();
42
46
  /** Format a date as a relative age string (e.g., "2h ago", "3d ago"). */
43
47
  function formatAge(date) {
44
48
  const ms = Date.now() - new Date(date).getTime();
@@ -57,6 +61,70 @@ function formatAge(date) {
57
61
  function sanitizeFilename(name) {
58
62
  return name.replace(/[/\\]/g, '_').replace(/\.\./g, '_');
59
63
  }
64
+ /** Max message size per platform. Conservative defaults — Slack blocks are 3000 but we allow some overhead. */
65
+ function getMaxMessageLength(platform) {
66
+ switch (platform) {
67
+ case 'slack': return 3500;
68
+ case 'mattermost': return 16000;
69
+ default: return 4000;
70
+ }
71
+ }
72
+ /**
73
+ * Split content into chunks that fit within a platform's message size limit.
74
+ * Splits at heading boundaries (## ) when possible, otherwise at line boundaries.
75
+ */
76
+ function chunkContent(content, maxLen) {
77
+ if (content.length <= maxLen)
78
+ return [content];
79
+ const lines = content.split('\n');
80
+ const chunks = [];
81
+ let current = [];
82
+ let currentLen = 0;
83
+ for (const line of lines) {
84
+ const lineLen = line.length + 1; // +1 for newline
85
+ // Start new chunk at ## heading if adding this line would exceed limit
86
+ if (line.startsWith('## ') && current.length > 0 && currentLen + lineLen > maxLen) {
87
+ chunks.push(current.join('\n'));
88
+ current = [line];
89
+ currentLen = lineLen;
90
+ }
91
+ else if (currentLen + lineLen > maxLen && current.length > 0) {
92
+ // Mid-section split at line boundary
93
+ chunks.push(current.join('\n'));
94
+ current = [line];
95
+ currentLen = lineLen;
96
+ }
97
+ else {
98
+ current.push(line);
99
+ currentLen += lineLen;
100
+ }
101
+ }
102
+ if (current.length > 0)
103
+ chunks.push(current.join('\n'));
104
+ // Safety: hard-truncate any chunk that still exceeds maxLen (e.g. single very long line)
105
+ return chunks.map(c => c.length > maxLen ? c.slice(0, maxLen - 3) + '...' : c);
106
+ }
107
+ /** Send content that may exceed platform message limits, chunking with part labels as needed. */
108
+ async function sendChunked(adapter, channelId, content, platform, opts) {
109
+ const maxLen = getMaxMessageLength(platform);
110
+ const header = opts?.header ? opts.header + '\n\n' : '';
111
+ const headerLen = header.length;
112
+ // Try to fit in one message
113
+ if (headerLen + content.length <= maxLen) {
114
+ await adapter.sendMessage(channelId, header + content, { threadRootId: opts?.threadRootId });
115
+ return;
116
+ }
117
+ // Chunk the content (reserve space for part label + header in first chunk)
118
+ const labelReserve = 30; // "_(Part XX of XX)_\n"
119
+ const effectiveMax = maxLen - labelReserve - headerLen;
120
+ const chunks = chunkContent(content, effectiveMax);
121
+ const total = chunks.length;
122
+ for (let i = 0; i < chunks.length; i++) {
123
+ const label = total > 1 ? `_(Part ${i + 1} of ${total})_\n` : '';
124
+ const prefix = i === 0 ? header : '';
125
+ await adapter.sendMessage(channelId, prefix + label + chunks[i].trim(), { threadRootId: opts?.threadRootId });
126
+ }
127
+ }
60
128
  /** Download message attachments to .temp/<channelId>/ in the bot's workspace, returning SDK-compatible attachment objects. */
61
129
  async function downloadAttachments(attachments, channelId, adapter) {
62
130
  if (!attachments || attachments.length === 0)
@@ -368,7 +436,7 @@ async function main() {
368
436
  handleMidTurnMessage(msg, sessionManager, platformName, botName)
369
437
  .catch(err => {
370
438
  // Expected fallbacks — debug level
371
- const expected = err?.message === 'slash-command-while-busy' || err?.message === 'file-only-while-busy';
439
+ const expected = err?.message === 'slash-command-while-busy' || err?.message === 'attachments-while-busy';
372
440
  if (expected) {
373
441
  log.debug(`Mid-turn fallback (${err.message}), routing to normal handler`);
374
442
  }
@@ -431,7 +499,8 @@ async function main() {
431
499
  const resolved = getAdapterForChannel(channelId);
432
500
  if (resolved) {
433
501
  const { streaming } = resolved;
434
- // Atomically swap streams via eventLocks to prevent event interleaving
502
+ // Finalize any existing stream, but don't create a new one —
503
+ // quiet mode defers stream creation until we know the response isn't NO_REPLY
435
504
  const evPrev = eventLocks.get(channelId) ?? Promise.resolve();
436
505
  const evTask = evPrev.then(async () => {
437
506
  const existingStream = activeStreams.get(channelId);
@@ -439,13 +508,13 @@ async function main() {
439
508
  await streaming.finalizeStream(existingStream);
440
509
  activeStreams.delete(channelId);
441
510
  }
442
- const streamKey = await streaming.startStream(channelId);
443
- activeStreams.set(channelId, streamKey);
444
511
  });
445
512
  eventLocks.set(channelId, evTask.catch(() => { }));
446
513
  await evTask;
447
514
  markBusy(channelId);
448
515
  }
516
+ // Enter quiet mode — suppresses all streaming until NO_REPLY determination
517
+ const clearQuiet = enterQuietMode(channelId);
449
518
  try {
450
519
  await sessionManager.sendMessage(channelId, prompt);
451
520
  // Hold the lock until the response is fully streamed
@@ -463,6 +532,9 @@ async function main() {
463
532
  }
464
533
  throw err;
465
534
  }
535
+ finally {
536
+ clearQuiet();
537
+ }
466
538
  });
467
539
  channelLocks.set(channelId, task.catch(() => { }));
468
540
  await task;
@@ -604,6 +676,8 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
604
676
  channelThreadRoots.delete(msg.channelId);
605
677
  await finalizeActivityFeed(msg.channelId, adapter);
606
678
  await sessionManager.abortSession(msg.channelId);
679
+ // Revert yolo if temporarily enabled for plan implementation
680
+ sessionManager.revertYoloIfNeeded(msg.channelId);
607
681
  markIdleImmediate(msg.channelId);
608
682
  await adapter.sendMessage(msg.channelId, '🛑 Task stopped.', { threadRootId: threadRoot });
609
683
  return;
@@ -617,11 +691,17 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
617
691
  channelThreadRoots.delete(msg.channelId);
618
692
  await finalizeActivityFeed(msg.channelId, adapter);
619
693
  loopDetector.reset(msg.channelId);
694
+ planSurfacedOnResume.delete(msg.channelId);
620
695
  await sessionManager.newSession(msg.channelId);
621
696
  markIdleImmediate(msg.channelId);
622
697
  await adapter.sendMessage(msg.channelId, '✅ New session created.', { threadRootId: threadRoot });
623
698
  return;
624
699
  }
700
+ // Messages with attachments can't steer — queue them for normal processing
701
+ // where downloadAttachments runs and files are passed to the SDK
702
+ if (msg.attachments?.length) {
703
+ throw new Error('attachments-while-busy');
704
+ }
625
705
  // Read-only / toggle commands — safe to handle mid-turn
626
706
  // Only commands where handleCommand returns a complete response (no separate action rendering).
627
707
  // Commands with complex action handlers (skills, schedule, rules) defer to serialized path.
@@ -645,7 +725,7 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
645
725
  }
646
726
  const mcpInfo = undefined;
647
727
  const contextUsage = sessionManager.getContextUsage(msg.channelId);
648
- const cmdResult = handleCommand(msg.channelId, commandText, sessionInfo ?? undefined, { verbose: effPrefs.verbose, permissionMode: effPrefs.permissionMode, reasoningEffort: effPrefs.reasoningEffort }, { workingDirectory: channelConfig.workingDirectory, bot: channelConfig.bot }, models, mcpInfo, contextUsage);
728
+ const cmdResult = handleCommand(msg.channelId, commandText, sessionInfo ?? undefined, { verbose: effPrefs.verbose, permissionMode: effPrefs.permissionMode, reasoningEffort: effPrefs.reasoningEffort }, { workingDirectory: channelConfig.workingDirectory, bot: channelConfig.bot }, models, mcpInfo, contextUsage, getConfig().providers);
649
729
  if (cmdResult.handled) {
650
730
  // Model/agent switch while busy — defer to serialized path
651
731
  if (cmdResult.action === 'switch_model' || cmdResult.action === 'switch_agent') {
@@ -661,10 +741,6 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
661
741
  // All other slash commands — defer to serialized path
662
742
  throw new Error('slash-command-while-busy');
663
743
  }
664
- // File-only messages can't steer — queue them for normal processing
665
- if (!text && msg.attachments?.length) {
666
- throw new Error('file-only-while-busy');
667
- }
668
744
  log.info(`Mid-turn steering for ${msg.channelId.slice(0, 8)}...: "${text.slice(0, 100)}"`);
669
745
  // Atomically swap streams via eventLocks so no residual events from the
670
746
  // previous response can sneak in between finalization and the new stream.
@@ -687,6 +763,70 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
687
763
  }
688
764
  catch { /* best-effort */ }
689
765
  }
766
+ /** Test BYOK provider connectivity by hitting its models endpoint. */
767
+ async function testProviderConnectivity(providerName) {
768
+ const providers = getConfig().providers ?? {};
769
+ const provider = providers[providerName];
770
+ if (!provider)
771
+ return `⚠️ Provider "${providerName}" not found in config.`;
772
+ const baseUrl = provider.baseUrl.replace(/\/+$/, '');
773
+ const modelsUrl = `${baseUrl}/models`;
774
+ // Resolve auth
775
+ let apiKey = provider.apiKey;
776
+ if (!apiKey && provider.apiKeyEnv)
777
+ apiKey = process.env[provider.apiKeyEnv];
778
+ let bearerToken = provider.bearerToken;
779
+ if (!bearerToken && provider.bearerTokenEnv)
780
+ bearerToken = process.env[provider.bearerTokenEnv];
781
+ const headers = { 'Accept': 'application/json' };
782
+ if (apiKey)
783
+ headers['Authorization'] = `Bearer ${apiKey}`;
784
+ else if (bearerToken)
785
+ headers['Authorization'] = `Bearer ${bearerToken}`;
786
+ const startTime = Date.now();
787
+ try {
788
+ const controller = new AbortController();
789
+ const timeout = setTimeout(() => controller.abort(), 10_000);
790
+ const response = await fetch(modelsUrl, { headers, signal: controller.signal });
791
+ clearTimeout(timeout);
792
+ const elapsed = Date.now() - startTime;
793
+ if (!response.ok) {
794
+ return `❌ Provider "${providerName}" returned HTTP ${response.status} ${response.statusText}\n URL: \`${modelsUrl}\``;
795
+ }
796
+ const data = await response.json();
797
+ const modelCount = Array.isArray(data?.data) ? data.data.length : '?';
798
+ const configuredModels = provider.models.map(m => m.id);
799
+ const lines = [
800
+ `✅ Provider "${providerName}" is reachable (${elapsed}ms)`,
801
+ ` URL: \`${modelsUrl}\``,
802
+ ` Remote models: ${modelCount}`,
803
+ ` Configured: ${configuredModels.map(m => `\`${m}\``).join(', ')}`,
804
+ ];
805
+ // Check if configured models exist on the remote
806
+ if (Array.isArray(data?.data)) {
807
+ const remoteIds = new Set(data.data.map((m) => m.id));
808
+ const missing = configuredModels.filter(id => !remoteIds.has(id));
809
+ if (missing.length > 0) {
810
+ lines.push(` ⚠️ Not found on remote: ${missing.map(m => `\`${m}\``).join(', ')}`);
811
+ }
812
+ }
813
+ return lines.join('\n');
814
+ }
815
+ catch (err) {
816
+ const elapsed = Date.now() - startTime;
817
+ if (err?.name === 'AbortError') {
818
+ return `❌ Provider "${providerName}" timed out after 10s\n URL: \`${modelsUrl}\``;
819
+ }
820
+ const msg = String(err?.message ?? err);
821
+ if (msg.includes('ECONNREFUSED')) {
822
+ return `❌ Provider "${providerName}" connection refused\n URL: \`${modelsUrl}\`\n Is the service running?`;
823
+ }
824
+ if (msg.includes('ENOTFOUND')) {
825
+ return `❌ Provider "${providerName}" hostname not found\n URL: \`${modelsUrl}\``;
826
+ }
827
+ return `❌ Provider "${providerName}" failed (${elapsed}ms): ${msg}\n URL: \`${modelsUrl}\``;
828
+ }
829
+ }
690
830
  async function handleInboundMessage(msg, sessionManager, platformName, botName) {
691
831
  // Ignore messages from any bot we manage on this platform (prevents cross-bot loops)
692
832
  for (const [key, a] of botAdapters) {
@@ -771,7 +911,7 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
771
911
  }
772
912
  // Get cached context usage for /context and /status
773
913
  const contextUsage = sessionManager.getContextUsage(msg.channelId);
774
- const cmdResult = handleCommand(msg.channelId, text, sessionInfo ?? undefined, { verbose: effPrefs.verbose, permissionMode: effPrefs.permissionMode, reasoningEffort: effPrefs.reasoningEffort }, { workingDirectory: channelConfig.workingDirectory, bot: channelConfig.bot }, models, undefined, contextUsage);
914
+ const cmdResult = handleCommand(msg.channelId, text, sessionInfo ?? undefined, { verbose: effPrefs.verbose, permissionMode: effPrefs.permissionMode, reasoningEffort: effPrefs.reasoningEffort }, { workingDirectory: channelConfig.workingDirectory, bot: channelConfig.bot }, models, undefined, contextUsage, getConfig().providers);
775
915
  if (cmdResult.handled) {
776
916
  const threadRoot = resolveThreadRoot(msg, threadRequested, channelConfig);
777
917
  // Send response before action, except for actions that send their own ack after completing
@@ -876,6 +1016,19 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
876
1016
  }
877
1017
  const resumedId = await sessionManager.resumeToSession(msg.channelId, matches[0]);
878
1018
  await adapter.updateMessage(msg.channelId, resumeAck, `✅ Resumed session \`${resumedId.slice(0, 8)}…\``);
1019
+ // Surface existing plan after resume — only when in plan mode
1020
+ try {
1021
+ const mode = await sessionManager.getSessionMode(msg.channelId);
1022
+ if (mode === 'plan') {
1023
+ const plan = await sessionManager.readPlan(msg.channelId);
1024
+ if (plan.exists && plan.content) {
1025
+ planSurfacedOnResume.add(msg.channelId);
1026
+ const summary = sessionManager.extractPlanSummary(plan.content);
1027
+ await adapter.sendMessage(msg.channelId, `📋 **Existing plan found** — ${summary}. \`/plan show\` to review, \`/plan clear\` to discard.`, { threadRootId: threadRoot });
1028
+ }
1029
+ }
1030
+ }
1031
+ catch { /* plan surfacing is best-effort */ }
879
1032
  }
880
1033
  catch (err) {
881
1034
  await adapter.updateMessage(msg.channelId, resumeAck, `❌ Failed to resume session: ${err?.message ?? 'unknown error'}`);
@@ -910,7 +1063,8 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
910
1063
  case 'switch_model': {
911
1064
  const ackId = await adapter.sendMessage(msg.channelId, '⏳ Switching model...', { threadRootId: threadRoot });
912
1065
  try {
913
- await sessionManager.switchModel(msg.channelId, cmdResult.payload);
1066
+ const { modelId, provider } = cmdResult.payload;
1067
+ await sessionManager.switchModel(msg.channelId, modelId, provider);
914
1068
  await adapter.updateMessage(msg.channelId, ackId, cmdResult.response ?? '✅ Model switched.');
915
1069
  }
916
1070
  catch (err) {
@@ -931,6 +1085,19 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
931
1085
  }
932
1086
  break;
933
1087
  }
1088
+ case 'provider_test': {
1089
+ const providerName = cmdResult.payload;
1090
+ const ackId = await adapter.sendMessage(msg.channelId, cmdResult.response ?? `🔄 Testing provider "${providerName}"...`, { threadRootId: threadRoot });
1091
+ try {
1092
+ const result = await testProviderConnectivity(providerName);
1093
+ await adapter.updateMessage(msg.channelId, ackId, result);
1094
+ }
1095
+ catch (err) {
1096
+ log.error(`Provider test failed for "${providerName}":`, err);
1097
+ await adapter.updateMessage(msg.channelId, ackId, `❌ Provider test failed: ${err?.message ?? 'unknown error'}`);
1098
+ }
1099
+ break;
1100
+ }
934
1101
  case 'set_reasoning': {
935
1102
  const reasoningSessionId = sessionManager.getSessionId(msg.channelId);
936
1103
  if (!reasoningSessionId) {
@@ -1287,21 +1454,54 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1287
1454
  await adapter.sendMessage(msg.channelId, '📋 No plan exists for this session.', { threadRootId: threadRoot });
1288
1455
  }
1289
1456
  else {
1290
- const truncated = plan.content.length > 3500 ? plan.content.slice(0, 3500) + '\n\n_…truncated_' : plan.content;
1291
- await adapter.sendMessage(msg.channelId, `📋 **Current Plan**\n\n${truncated}`, { threadRootId: threadRoot });
1457
+ await sendChunked(adapter, msg.channelId, plan.content, channelConfig.platform, {
1458
+ threadRootId: threadRoot,
1459
+ header: '📋 **Current Plan**',
1460
+ });
1292
1461
  }
1293
1462
  }
1294
1463
  else if (subcommand === 'clear' || subcommand === 'delete') {
1295
1464
  const deleted = await sessionManager.deletePlan(msg.channelId);
1296
1465
  await adapter.sendMessage(msg.channelId, deleted ? '📋 Plan cleared.' : '📋 No plan to clear.', { threadRootId: threadRoot });
1297
1466
  }
1467
+ else if (subcommand === 'summary') {
1468
+ // Ensure session is attached (handles post-restart state)
1469
+ const currentMode = await sessionManager.getSessionMode(msg.channelId);
1470
+ await sessionManager.setSessionMode(msg.channelId, currentMode ?? 'interactive');
1471
+ const plan = await sessionManager.readPlan(msg.channelId);
1472
+ if (!plan.exists || !plan.content) {
1473
+ await adapter.sendMessage(msg.channelId, '📋 No plan exists for this session.', { threadRootId: threadRoot });
1474
+ }
1475
+ else {
1476
+ // Ephemeral session summarization — doesn't pollute main conversation
1477
+ await adapter.sendMessage(msg.channelId, '📋 Summarizing plan...', { threadRootId: threadRoot });
1478
+ const summary = await sessionManager.summarizePlan(msg.channelId);
1479
+ if (summary) {
1480
+ await adapter.sendMessage(msg.channelId, `📋 **Plan summary:**\n\n${summary}\n\n\`/plan show\` to view the full plan.`, { threadRootId: threadRoot });
1481
+ }
1482
+ else {
1483
+ // Fallback to structural extraction if ephemeral session fails
1484
+ const fallback = sessionManager.extractPlanSummary(plan.content);
1485
+ await adapter.sendMessage(msg.channelId, `📋 ${fallback}\n\n\`/plan show\` to view the full plan.`, { threadRootId: threadRoot });
1486
+ }
1487
+ }
1488
+ }
1298
1489
  else if (subcommand === 'off') {
1299
1490
  await sessionManager.setSessionMode(msg.channelId, 'interactive');
1300
1491
  await adapter.sendMessage(msg.channelId, '📋 **Plan mode off** — back to interactive mode.', { threadRootId: threadRoot });
1301
1492
  }
1302
1493
  else if (subcommand === 'on') {
1494
+ // Set mode first (ensures session is attached after restart), then check for existing plan
1303
1495
  await sessionManager.setSessionMode(msg.channelId, 'plan');
1304
- await adapter.sendMessage(msg.channelId, '📋 **Plan mode on** — messages will be handled as planning requests. The agent will create and update a plan before implementing.\n\nUse `/plan show` to view the plan, `/plan` to toggle off.', { threadRootId: threadRoot });
1496
+ const existingPlan = await sessionManager.readPlan(msg.channelId);
1497
+ planSurfacedOnResume.add(msg.channelId);
1498
+ if (existingPlan.exists && existingPlan.content) {
1499
+ const summary = sessionManager.extractPlanSummary(existingPlan.content);
1500
+ await adapter.sendMessage(msg.channelId, `📋 **Existing plan found** — ${summary}\n\n\`/plan show\` to review the full plan.\n\`/plan clear\` to discard and start fresh.\n\nEntering plan mode with existing plan.`, { threadRootId: threadRoot });
1501
+ }
1502
+ else {
1503
+ await adapter.sendMessage(msg.channelId, '📋 **Plan mode on** — messages will be handled as planning requests. The agent will create and update a plan before implementing.\n\nUse `/plan show` to view the plan, `/plan` to toggle off.', { threadRootId: threadRoot });
1504
+ }
1305
1505
  }
1306
1506
  else if (!subcommand) {
1307
1507
  // Toggle: check current mode and flip
@@ -1311,12 +1511,21 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1311
1511
  await adapter.sendMessage(msg.channelId, '📋 **Plan mode off** — back to interactive mode.', { threadRootId: threadRoot });
1312
1512
  }
1313
1513
  else {
1514
+ // Set mode first (ensures session is attached after restart), then check for existing plan
1314
1515
  await sessionManager.setSessionMode(msg.channelId, 'plan');
1315
- await adapter.sendMessage(msg.channelId, '📋 **Plan mode on** — messages will be handled as planning requests. The agent will create and update a plan before implementing.\n\nUse `/plan show` to view the plan, `/plan` to toggle off.', { threadRootId: threadRoot });
1516
+ const existingPlan = await sessionManager.readPlan(msg.channelId);
1517
+ planSurfacedOnResume.add(msg.channelId);
1518
+ if (existingPlan.exists && existingPlan.content) {
1519
+ const summary = sessionManager.extractPlanSummary(existingPlan.content);
1520
+ await adapter.sendMessage(msg.channelId, `📋 **Existing plan found** — ${summary}\n\n\`/plan show\` to review the full plan.\n\`/plan clear\` to discard and start fresh.\n\nEntering plan mode with existing plan.`, { threadRootId: threadRoot });
1521
+ }
1522
+ else {
1523
+ await adapter.sendMessage(msg.channelId, '📋 **Plan mode on** — messages will be handled as planning requests. The agent will create and update a plan before implementing.\n\nUse `/plan show` to view the plan, `/plan` to toggle off.', { threadRootId: threadRoot });
1524
+ }
1316
1525
  }
1317
1526
  }
1318
1527
  else {
1319
- await adapter.sendMessage(msg.channelId, '⚠️ Usage: `/plan` (toggle), `/plan show`, `/plan clear`, `/plan on`, `/plan off`', { threadRootId: threadRoot });
1528
+ await adapter.sendMessage(msg.channelId, '⚠️ Usage: `/plan` (toggle), `/plan show`, `/plan summary`, `/plan clear`, `/plan on`, `/plan off`', { threadRootId: threadRoot });
1320
1529
  }
1321
1530
  }
1322
1531
  catch (err) {
@@ -1325,6 +1534,66 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1325
1534
  }
1326
1535
  break;
1327
1536
  }
1537
+ case 'implement': {
1538
+ try {
1539
+ const arg = cmdResult.payload?.toLowerCase();
1540
+ const enableYolo = arg === 'yolo';
1541
+ const interactive = arg === 'interactive';
1542
+ // Set mode first (ensures session is attached after restart).
1543
+ // For interactive, this is a no-op on mode; for autopilot, we revert below if no plan.
1544
+ const targetMode = interactive ? 'interactive' : 'autopilot';
1545
+ await sessionManager.setSessionMode(msg.channelId, targetMode);
1546
+ // Now read plan (session is guaranteed to be attached)
1547
+ const plan = await sessionManager.readPlan(msg.channelId);
1548
+ if (!plan.exists || !plan.content) {
1549
+ // Revert mode back to interactive if we set autopilot
1550
+ if (!interactive)
1551
+ await sessionManager.setSessionMode(msg.channelId, 'interactive');
1552
+ await adapter.sendMessage(msg.channelId, '📋 No plan exists. Create one first with `/plan on`.', { threadRootId: threadRoot });
1553
+ break;
1554
+ }
1555
+ // Save yolo state before changing it
1556
+ if (enableYolo) {
1557
+ sessionManager.saveYoloPreviousState(msg.channelId);
1558
+ setChannelPrefs(msg.channelId, { permissionMode: 'autopilot' });
1559
+ }
1560
+ const modeLabel = interactive ? 'interactive' : enableYolo ? 'autopilot + yolo' : 'autopilot';
1561
+ await adapter.sendMessage(msg.channelId, `🚀 **Implementing plan** (${modeLabel})`, { threadRootId: threadRoot });
1562
+ // Clear pending plan exit if one was waiting
1563
+ sessionManager.consumePendingPlanExit(msg.channelId);
1564
+ // Set up stream and hold channel lock (matches regular message flow)
1565
+ const evPrev = eventLocks.get(msg.channelId) ?? Promise.resolve();
1566
+ const evTask = evPrev.then(async () => {
1567
+ const existingStreamKey = activeStreams.get(msg.channelId);
1568
+ if (existingStreamKey) {
1569
+ await streaming.finalizeStream(existingStreamKey);
1570
+ activeStreams.delete(msg.channelId);
1571
+ }
1572
+ initialStreamPosted.add(msg.channelId);
1573
+ const streamKey = await streaming.startStream(msg.channelId, threadRoot);
1574
+ activeStreams.set(msg.channelId, streamKey);
1575
+ });
1576
+ eventLocks.set(msg.channelId, evTask.catch(() => { }));
1577
+ await evTask;
1578
+ markBusy(msg.channelId);
1579
+ // Send plan content as a synthetic message to kick off implementation
1580
+ const kickoff = `Implement the following plan:\n\n${plan.content}`;
1581
+ await sessionManager.sendMessage(msg.channelId, kickoff);
1582
+ await waitForChannelIdle(msg.channelId);
1583
+ }
1584
+ catch (err) {
1585
+ sessionManager.revertYoloIfNeeded(msg.channelId);
1586
+ markIdleImmediate(msg.channelId);
1587
+ const sk = activeStreams.get(msg.channelId);
1588
+ if (sk) {
1589
+ await streaming.cancelStream(sk);
1590
+ activeStreams.delete(msg.channelId);
1591
+ }
1592
+ log.error(`Failed to handle /implement on ${msg.channelId.slice(0, 8)}...:`, err);
1593
+ await adapter.sendMessage(msg.channelId, `❌ Failed: ${err?.message ?? 'unknown error'}`, { threadRootId: threadRoot });
1594
+ }
1595
+ break;
1596
+ }
1328
1597
  case 'toggle_autopilot': {
1329
1598
  try {
1330
1599
  const current = await sessionManager.getSessionMode(msg.channelId);
@@ -1370,6 +1639,10 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1370
1639
  // Unrecognized text — auto-deny and fall through to process as a normal message
1371
1640
  sessionManager.resolvePermission(msg.channelId, false);
1372
1641
  }
1642
+ // Pending plan exit — auto-dismiss on unrecognized text, process message normally
1643
+ if (sessionManager.hasPendingPlanExit(msg.channelId)) {
1644
+ sessionManager.consumePendingPlanExit(msg.channelId);
1645
+ }
1373
1646
  // Regular message — forward to Copilot session
1374
1647
  try {
1375
1648
  // Check auth before starting a session (prevents hanging on "Working...")
@@ -1418,6 +1691,16 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1418
1691
  return;
1419
1692
  }
1420
1693
  await sessionManager.sendMessage(msg.channelId, prompt, sdkAttachments.length > 0 ? sdkAttachments : undefined, msg.userId);
1694
+ // One-time plan surfacing after session resume — only when in plan mode (best-effort, non-blocking)
1695
+ if (!planSurfacedOnResume.has(msg.channelId)) {
1696
+ planSurfacedOnResume.add(msg.channelId);
1697
+ sessionManager.surfacePlanIfExists(msg.channelId).then(async (result) => {
1698
+ if (result?.exists && result.inPlanMode) {
1699
+ const threadRootForPlan = channelThreadRoots.get(msg.channelId);
1700
+ await adapter.sendMessage(msg.channelId, `📋 **Existing plan found** — ${result.summary}. \`/plan show\` to review.`, { threadRootId: threadRootForPlan });
1701
+ }
1702
+ }).catch(() => { });
1703
+ }
1421
1704
  // Hold the channelLock until session.idle so queued work (scheduler, etc.)
1422
1705
  // doesn't start a new stream while this response is still being streamed.
1423
1706
  await waitForChannelIdle(msg.channelId);
@@ -1524,7 +1807,14 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1524
1807
  const prefs = getChannelPrefs(channelId);
1525
1808
  const verbose = prefs?.verbose ?? channelConfig.verbose;
1526
1809
  // Handle custom bridge events (permissions, user input)
1810
+ // During quiet mode, auto-deny permissions and suppress input requests —
1811
+ // quiet tasks should be non-interactive
1527
1812
  if (event.type === 'bridge.permission_request') {
1813
+ if (isQuiet(channelId)) {
1814
+ log.info(`Auto-denying permission during quiet mode on ${channelId.slice(0, 8)}...`);
1815
+ sessionManager.resolvePermission(channelId, false);
1816
+ return;
1817
+ }
1528
1818
  const streamKey = activeStreams.get(channelId);
1529
1819
  const threadRootId = streamKey ? streaming.getStreamThreadRootId(streamKey) : undefined;
1530
1820
  if (threadRootId)
@@ -1540,6 +1830,11 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1540
1830
  return;
1541
1831
  }
1542
1832
  if (event.type === 'bridge.user_input_request') {
1833
+ if (isQuiet(channelId)) {
1834
+ log.info(`Suppressing user input request during quiet mode on ${channelId.slice(0, 8)}...`);
1835
+ sessionManager.resolveUserInput(channelId, '');
1836
+ return;
1837
+ }
1543
1838
  const streamKey = activeStreams.get(channelId);
1544
1839
  const threadRootId = streamKey ? streaming.getStreamThreadRootId(streamKey) : undefined;
1545
1840
  if (threadRootId)
@@ -1554,24 +1849,111 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1554
1849
  await adapter.sendMessage(channelId, formatted, { threadRootId });
1555
1850
  return;
1556
1851
  }
1852
+ // Handle plan_changed events — debounced summary surfacing
1853
+ if (event.type === 'session.plan_changed') {
1854
+ const operation = event.data?.operation;
1855
+ if (operation === 'create' || operation === 'update') {
1856
+ sessionManager.debouncePlanChanged(channelId, async () => {
1857
+ try {
1858
+ const plan = await sessionManager.readPlan(channelId);
1859
+ if (!plan.exists || !plan.content)
1860
+ return;
1861
+ const summary = sessionManager.extractPlanSummary(plan.content);
1862
+ const threadRootId = channelThreadRoots.get(channelId);
1863
+ await adapter.sendMessage(channelId, `📋 **Plan updated** — ${summary}. \`/plan show\` for details.`, { threadRootId });
1864
+ }
1865
+ catch (err) {
1866
+ log.warn(`Failed to surface plan summary: ${err}`);
1867
+ }
1868
+ });
1869
+ }
1870
+ return;
1871
+ }
1872
+ // Handle exit_plan_mode.requested — present implementation options
1873
+ if (event.type === 'exit_plan_mode.requested') {
1874
+ const { requestId, summary, planContent, actions, recommendedAction } = event.data;
1875
+ const streamKey = activeStreams.get(channelId);
1876
+ const threadRootId = streamKey ? streaming.getStreamThreadRootId(streamKey) : undefined;
1877
+ if (threadRootId)
1878
+ channelThreadRoots.set(channelId, threadRootId);
1879
+ if (streamKey) {
1880
+ await streaming.finalizeStream(streamKey);
1881
+ activeStreams.delete(channelId);
1882
+ }
1883
+ await finalizeActivityFeed(channelId, adapter);
1884
+ sessionManager.setPendingPlanExit(channelId, {
1885
+ requestId,
1886
+ summary: summary ?? '',
1887
+ planContent: planContent ?? '',
1888
+ actions: actions ?? [],
1889
+ recommendedAction: recommendedAction ?? '',
1890
+ createdAt: Date.now(),
1891
+ });
1892
+ const msg = [
1893
+ '📋 **Plan ready**',
1894
+ '',
1895
+ summary || '(no summary provided)',
1896
+ '',
1897
+ 'How would you like to proceed?',
1898
+ '1. ▶️ `/implement yolo` — autopilot + yolo (fully autonomous)',
1899
+ '2. 🚀 `/implement` — autopilot (with permission prompts)',
1900
+ '3. 🔧 `/implement interactive` — interactive mode',
1901
+ '4. ❌ `/plan off` — exit plan mode without implementing',
1902
+ '',
1903
+ 'Or just keep chatting to continue refining the plan.',
1904
+ ].join('\n');
1905
+ await adapter.sendMessage(channelId, msg, { threadRootId });
1906
+ return;
1907
+ }
1557
1908
  // Format and route SDK events
1558
1909
  const formatted = formatEvent(event);
1559
1910
  if (!formatted)
1560
1911
  return;
1561
- // Filter out NO_REPLY responses from startup nudges only
1562
- if (nudgePending.has(channelId) && formatted.type === 'content' && event.type === 'assistant.message') {
1563
- const content = formatted.content?.trim();
1564
- nudgePending.delete(channelId);
1565
- if (content === 'NO_REPLY' || content === '`NO_REPLY`') {
1566
- log.info(`Filtered NO_REPLY from nudge on channel ${channelId.slice(0, 8)}...`);
1567
- // Clean up any active stream without posting
1568
- const sk = activeStreams.get(channelId);
1569
- if (sk) {
1570
- await streaming.deleteStream(sk);
1571
- activeStreams.delete(channelId);
1912
+ // ── Quiet mode: suppress all output until we know if response is NO_REPLY ──
1913
+ if (isQuiet(channelId)) {
1914
+ // Suppress content events (deltas and messages)
1915
+ if (formatted.type === 'content') {
1916
+ if (event.type === 'assistant.message_delta') {
1917
+ return;
1572
1918
  }
1919
+ if (event.type === 'assistant.message') {
1920
+ const content = formatted.content?.trim();
1921
+ // Skip empty assistant.message events (tool-call signals)
1922
+ if (!content)
1923
+ return;
1924
+ // Non-empty — check for NO_REPLY
1925
+ if (content === 'NO_REPLY' || content === '`NO_REPLY`') {
1926
+ log.info(`Filtered NO_REPLY (quiet mode) on channel ${channelId.slice(0, 8)}...`);
1927
+ exitQuietMode(channelId);
1928
+ const sk = activeStreams.get(channelId);
1929
+ if (sk) {
1930
+ await streaming.deleteStream(sk);
1931
+ activeStreams.delete(channelId);
1932
+ }
1933
+ return;
1934
+ }
1935
+ // Real content — flush: create stream with this content, exit quiet
1936
+ log.info(`Quiet mode flush on channel ${channelId.slice(0, 8)}... — real content received`);
1937
+ const savedThreadRoot = channelThreadRoots.get(channelId);
1938
+ exitQuietMode(channelId);
1939
+ const newKey = await streaming.startStream(channelId, savedThreadRoot, content);
1940
+ activeStreams.set(channelId, newKey);
1941
+ return;
1942
+ }
1943
+ }
1944
+ // Suppress verbose/tool/status events during quiet — but let session.idle
1945
+ // and session.error pass through so channel idle tracking still works
1946
+ if (formatted.type === 'tool_start' || formatted.type === 'tool_complete') {
1573
1947
  return;
1574
1948
  }
1949
+ if (formatted.type === 'status' && event.type !== 'session.idle') {
1950
+ return;
1951
+ }
1952
+ // Errors: exit quiet and fall through to normal error handling (surfaces to user)
1953
+ if (formatted.type === 'error') {
1954
+ exitQuietMode(channelId);
1955
+ // Fall through to normal error handling
1956
+ }
1575
1957
  }
1576
1958
  if (formatted.verbose && !verbose)
1577
1959
  return;
@@ -1603,8 +1985,8 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1603
1985
  }
1604
1986
  }
1605
1987
  if (!streamKey) {
1606
- // Suppress stream auto-start during startup nudge — avoid visible "Working..." flash
1607
- if (nudgePending.has(channelId))
1988
+ // Suppress stream auto-start during quiet mode — avoid visible "Working..." flash
1989
+ if (isQuiet(channelId))
1608
1990
  break;
1609
1991
  // Auto-start stream — use actual content, never a "Working..." placeholder.
1610
1992
  // This happens on subsequent turns after turn_end finalized the previous stream.
@@ -1656,7 +2038,7 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1656
2038
  `Will reset session if it continues.`);
1657
2039
  }
1658
2040
  }
1659
- if (verbose && formatted.content && !nudgePending.has(channelId)) {
2041
+ if (verbose && formatted.content && !isQuiet(channelId)) {
1660
2042
  await appendActivityFeed(channelId, formatted.content, adapter);
1661
2043
  }
1662
2044
  break;
@@ -1665,7 +2047,7 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1665
2047
  break;
1666
2048
  case 'error':
1667
2049
  markIdleImmediate(channelId);
1668
- nudgePending.delete(channelId);
2050
+ exitQuietMode(channelId);
1669
2051
  channelThreadRoots.delete(channelId);
1670
2052
  if (streamKey) {
1671
2053
  await streaming.cancelStream(streamKey, formatted.content);
@@ -1720,7 +2102,7 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1720
2102
  // Finalize stream when the session goes idle (all turns complete).
1721
2103
  if (event.type === 'session.idle') {
1722
2104
  markIdle(channelId);
1723
- nudgePending.delete(channelId);
2105
+ exitQuietMode(channelId);
1724
2106
  await finalizeActivityFeed(channelId, adapter);
1725
2107
  initialStreamPosted.delete(channelId);
1726
2108
  channelThreadRoots.delete(channelId);
@@ -1729,6 +2111,10 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1729
2111
  await streaming.finalizeStream(streamKey);
1730
2112
  activeStreams.delete(channelId);
1731
2113
  }
2114
+ // Revert yolo if it was temporarily enabled for plan implementation
2115
+ if (sessionManager.revertYoloIfNeeded(channelId)) {
2116
+ log.info(`Reverted yolo state on idle for ${channelId.slice(0, 8)}...`);
2117
+ }
1732
2118
  // Clean up temp files from downloaded attachments
1733
2119
  cleanupTempFiles(channelId);
1734
2120
  }
@@ -1797,18 +2183,28 @@ async function nudgeAdminSessions(sessionManager) {
1797
2183
  continue;
1798
2184
  try {
1799
2185
  log.info(`Nudging admin session for bot "${botName}" on channel ${channelId.slice(0, 8)}...`);
2186
+ const resolved = getAdapterForChannel(channelId);
2187
+ if (!resolved) {
2188
+ log.warn(`No adapter for channel ${channelId.slice(0, 8)}... — skipping nudge`);
2189
+ continue;
2190
+ }
1800
2191
  // Only post the visible restart notice in DM channels
1801
2192
  if (channelConfig.isDM) {
1802
- const resolved = getAdapterForChannel(channelId);
1803
- if (resolved) {
1804
- resolved.adapter.sendMessage(channelId, '🔄 Bridge restarted.').catch(e => log.warn(`Failed to post restart notice on ${channelId.slice(0, 8)}...:`, e));
1805
- }
2193
+ resolved.adapter.sendMessage(channelId, '🔄 Bridge restarted.').catch(e => log.warn(`Failed to post restart notice on ${channelId.slice(0, 8)}...:`, e));
2194
+ }
2195
+ const clearQuiet = enterQuietMode(channelId);
2196
+ try {
2197
+ markBusy(channelId);
2198
+ await sessionManager.sendMessage(channelId, NUDGE_PROMPT);
2199
+ await waitForChannelIdle(channelId);
2200
+ }
2201
+ finally {
2202
+ clearQuiet();
1806
2203
  }
1807
- nudgePending.add(channelId);
1808
- await sessionManager.sendMessage(channelId, NUDGE_PROMPT);
1809
2204
  }
1810
2205
  catch (err) {
1811
- nudgePending.delete(channelId);
2206
+ exitQuietMode(channelId);
2207
+ markIdleImmediate(channelId);
1812
2208
  log.warn(`Failed to nudge admin session on channel ${channelId.slice(0, 8)}...:`, err);
1813
2209
  }
1814
2210
  }