@chrisromp/copilot-bridge 0.9.2 → 0.10.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/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.
@@ -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.
@@ -876,6 +952,19 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
876
952
  }
877
953
  const resumedId = await sessionManager.resumeToSession(msg.channelId, matches[0]);
878
954
  await adapter.updateMessage(msg.channelId, resumeAck, `✅ Resumed session \`${resumedId.slice(0, 8)}…\``);
955
+ // Surface existing plan after resume — only when in plan mode
956
+ try {
957
+ const mode = await sessionManager.getSessionMode(msg.channelId);
958
+ if (mode === 'plan') {
959
+ const plan = await sessionManager.readPlan(msg.channelId);
960
+ if (plan.exists && plan.content) {
961
+ planSurfacedOnResume.add(msg.channelId);
962
+ const summary = sessionManager.extractPlanSummary(plan.content);
963
+ await adapter.sendMessage(msg.channelId, `📋 **Existing plan found** — ${summary}. \`/plan show\` to review, \`/plan clear\` to discard.`, { threadRootId: threadRoot });
964
+ }
965
+ }
966
+ }
967
+ catch { /* plan surfacing is best-effort */ }
879
968
  }
880
969
  catch (err) {
881
970
  await adapter.updateMessage(msg.channelId, resumeAck, `❌ Failed to resume session: ${err?.message ?? 'unknown error'}`);
@@ -1287,21 +1376,54 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1287
1376
  await adapter.sendMessage(msg.channelId, '📋 No plan exists for this session.', { threadRootId: threadRoot });
1288
1377
  }
1289
1378
  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 });
1379
+ await sendChunked(adapter, msg.channelId, plan.content, channelConfig.platform, {
1380
+ threadRootId: threadRoot,
1381
+ header: '📋 **Current Plan**',
1382
+ });
1292
1383
  }
1293
1384
  }
1294
1385
  else if (subcommand === 'clear' || subcommand === 'delete') {
1295
1386
  const deleted = await sessionManager.deletePlan(msg.channelId);
1296
1387
  await adapter.sendMessage(msg.channelId, deleted ? '📋 Plan cleared.' : '📋 No plan to clear.', { threadRootId: threadRoot });
1297
1388
  }
1389
+ else if (subcommand === 'summary') {
1390
+ // Ensure session is attached (handles post-restart state)
1391
+ const currentMode = await sessionManager.getSessionMode(msg.channelId);
1392
+ await sessionManager.setSessionMode(msg.channelId, currentMode ?? 'interactive');
1393
+ const plan = await sessionManager.readPlan(msg.channelId);
1394
+ if (!plan.exists || !plan.content) {
1395
+ await adapter.sendMessage(msg.channelId, '📋 No plan exists for this session.', { threadRootId: threadRoot });
1396
+ }
1397
+ else {
1398
+ // Ephemeral session summarization — doesn't pollute main conversation
1399
+ await adapter.sendMessage(msg.channelId, '📋 Summarizing plan...', { threadRootId: threadRoot });
1400
+ const summary = await sessionManager.summarizePlan(msg.channelId);
1401
+ if (summary) {
1402
+ await adapter.sendMessage(msg.channelId, `📋 **Plan summary:**\n\n${summary}\n\n\`/plan show\` to view the full plan.`, { threadRootId: threadRoot });
1403
+ }
1404
+ else {
1405
+ // Fallback to structural extraction if ephemeral session fails
1406
+ const fallback = sessionManager.extractPlanSummary(plan.content);
1407
+ await adapter.sendMessage(msg.channelId, `📋 ${fallback}\n\n\`/plan show\` to view the full plan.`, { threadRootId: threadRoot });
1408
+ }
1409
+ }
1410
+ }
1298
1411
  else if (subcommand === 'off') {
1299
1412
  await sessionManager.setSessionMode(msg.channelId, 'interactive');
1300
1413
  await adapter.sendMessage(msg.channelId, '📋 **Plan mode off** — back to interactive mode.', { threadRootId: threadRoot });
1301
1414
  }
1302
1415
  else if (subcommand === 'on') {
1416
+ // Set mode first (ensures session is attached after restart), then check for existing plan
1303
1417
  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 });
1418
+ const existingPlan = await sessionManager.readPlan(msg.channelId);
1419
+ planSurfacedOnResume.add(msg.channelId);
1420
+ if (existingPlan.exists && existingPlan.content) {
1421
+ const summary = sessionManager.extractPlanSummary(existingPlan.content);
1422
+ 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 });
1423
+ }
1424
+ else {
1425
+ 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 });
1426
+ }
1305
1427
  }
1306
1428
  else if (!subcommand) {
1307
1429
  // Toggle: check current mode and flip
@@ -1311,12 +1433,21 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1311
1433
  await adapter.sendMessage(msg.channelId, '📋 **Plan mode off** — back to interactive mode.', { threadRootId: threadRoot });
1312
1434
  }
1313
1435
  else {
1436
+ // Set mode first (ensures session is attached after restart), then check for existing plan
1314
1437
  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 });
1438
+ const existingPlan = await sessionManager.readPlan(msg.channelId);
1439
+ planSurfacedOnResume.add(msg.channelId);
1440
+ if (existingPlan.exists && existingPlan.content) {
1441
+ const summary = sessionManager.extractPlanSummary(existingPlan.content);
1442
+ 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 });
1443
+ }
1444
+ else {
1445
+ 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 });
1446
+ }
1316
1447
  }
1317
1448
  }
1318
1449
  else {
1319
- await adapter.sendMessage(msg.channelId, '⚠️ Usage: `/plan` (toggle), `/plan show`, `/plan clear`, `/plan on`, `/plan off`', { threadRootId: threadRoot });
1450
+ await adapter.sendMessage(msg.channelId, '⚠️ Usage: `/plan` (toggle), `/plan show`, `/plan summary`, `/plan clear`, `/plan on`, `/plan off`', { threadRootId: threadRoot });
1320
1451
  }
1321
1452
  }
1322
1453
  catch (err) {
@@ -1325,6 +1456,66 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1325
1456
  }
1326
1457
  break;
1327
1458
  }
1459
+ case 'implement': {
1460
+ try {
1461
+ const arg = cmdResult.payload?.toLowerCase();
1462
+ const enableYolo = arg === 'yolo';
1463
+ const interactive = arg === 'interactive';
1464
+ // Set mode first (ensures session is attached after restart).
1465
+ // For interactive, this is a no-op on mode; for autopilot, we revert below if no plan.
1466
+ const targetMode = interactive ? 'interactive' : 'autopilot';
1467
+ await sessionManager.setSessionMode(msg.channelId, targetMode);
1468
+ // Now read plan (session is guaranteed to be attached)
1469
+ const plan = await sessionManager.readPlan(msg.channelId);
1470
+ if (!plan.exists || !plan.content) {
1471
+ // Revert mode back to interactive if we set autopilot
1472
+ if (!interactive)
1473
+ await sessionManager.setSessionMode(msg.channelId, 'interactive');
1474
+ await adapter.sendMessage(msg.channelId, '📋 No plan exists. Create one first with `/plan on`.', { threadRootId: threadRoot });
1475
+ break;
1476
+ }
1477
+ // Save yolo state before changing it
1478
+ if (enableYolo) {
1479
+ sessionManager.saveYoloPreviousState(msg.channelId);
1480
+ setChannelPrefs(msg.channelId, { permissionMode: 'autopilot' });
1481
+ }
1482
+ const modeLabel = interactive ? 'interactive' : enableYolo ? 'autopilot + yolo' : 'autopilot';
1483
+ await adapter.sendMessage(msg.channelId, `🚀 **Implementing plan** (${modeLabel})`, { threadRootId: threadRoot });
1484
+ // Clear pending plan exit if one was waiting
1485
+ sessionManager.consumePendingPlanExit(msg.channelId);
1486
+ // Set up stream and hold channel lock (matches regular message flow)
1487
+ const evPrev = eventLocks.get(msg.channelId) ?? Promise.resolve();
1488
+ const evTask = evPrev.then(async () => {
1489
+ const existingStreamKey = activeStreams.get(msg.channelId);
1490
+ if (existingStreamKey) {
1491
+ await streaming.finalizeStream(existingStreamKey);
1492
+ activeStreams.delete(msg.channelId);
1493
+ }
1494
+ initialStreamPosted.add(msg.channelId);
1495
+ const streamKey = await streaming.startStream(msg.channelId, threadRoot);
1496
+ activeStreams.set(msg.channelId, streamKey);
1497
+ });
1498
+ eventLocks.set(msg.channelId, evTask.catch(() => { }));
1499
+ await evTask;
1500
+ markBusy(msg.channelId);
1501
+ // Send plan content as a synthetic message to kick off implementation
1502
+ const kickoff = `Implement the following plan:\n\n${plan.content}`;
1503
+ await sessionManager.sendMessage(msg.channelId, kickoff);
1504
+ await waitForChannelIdle(msg.channelId);
1505
+ }
1506
+ catch (err) {
1507
+ sessionManager.revertYoloIfNeeded(msg.channelId);
1508
+ markIdleImmediate(msg.channelId);
1509
+ const sk = activeStreams.get(msg.channelId);
1510
+ if (sk) {
1511
+ await streaming.cancelStream(sk);
1512
+ activeStreams.delete(msg.channelId);
1513
+ }
1514
+ log.error(`Failed to handle /implement on ${msg.channelId.slice(0, 8)}...:`, err);
1515
+ await adapter.sendMessage(msg.channelId, `❌ Failed: ${err?.message ?? 'unknown error'}`, { threadRootId: threadRoot });
1516
+ }
1517
+ break;
1518
+ }
1328
1519
  case 'toggle_autopilot': {
1329
1520
  try {
1330
1521
  const current = await sessionManager.getSessionMode(msg.channelId);
@@ -1370,6 +1561,10 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1370
1561
  // Unrecognized text — auto-deny and fall through to process as a normal message
1371
1562
  sessionManager.resolvePermission(msg.channelId, false);
1372
1563
  }
1564
+ // Pending plan exit — auto-dismiss on unrecognized text, process message normally
1565
+ if (sessionManager.hasPendingPlanExit(msg.channelId)) {
1566
+ sessionManager.consumePendingPlanExit(msg.channelId);
1567
+ }
1373
1568
  // Regular message — forward to Copilot session
1374
1569
  try {
1375
1570
  // Check auth before starting a session (prevents hanging on "Working...")
@@ -1418,6 +1613,16 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1418
1613
  return;
1419
1614
  }
1420
1615
  await sessionManager.sendMessage(msg.channelId, prompt, sdkAttachments.length > 0 ? sdkAttachments : undefined, msg.userId);
1616
+ // One-time plan surfacing after session resume — only when in plan mode (best-effort, non-blocking)
1617
+ if (!planSurfacedOnResume.has(msg.channelId)) {
1618
+ planSurfacedOnResume.add(msg.channelId);
1619
+ sessionManager.surfacePlanIfExists(msg.channelId).then(async (result) => {
1620
+ if (result?.exists && result.inPlanMode) {
1621
+ const threadRootForPlan = channelThreadRoots.get(msg.channelId);
1622
+ await adapter.sendMessage(msg.channelId, `📋 **Existing plan found** — ${result.summary}. \`/plan show\` to review.`, { threadRootId: threadRootForPlan });
1623
+ }
1624
+ }).catch(() => { });
1625
+ }
1421
1626
  // Hold the channelLock until session.idle so queued work (scheduler, etc.)
1422
1627
  // doesn't start a new stream while this response is still being streamed.
1423
1628
  await waitForChannelIdle(msg.channelId);
@@ -1524,7 +1729,14 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1524
1729
  const prefs = getChannelPrefs(channelId);
1525
1730
  const verbose = prefs?.verbose ?? channelConfig.verbose;
1526
1731
  // Handle custom bridge events (permissions, user input)
1732
+ // During quiet mode, auto-deny permissions and suppress input requests —
1733
+ // quiet tasks should be non-interactive
1527
1734
  if (event.type === 'bridge.permission_request') {
1735
+ if (isQuiet(channelId)) {
1736
+ log.info(`Auto-denying permission during quiet mode on ${channelId.slice(0, 8)}...`);
1737
+ sessionManager.resolvePermission(channelId, false);
1738
+ return;
1739
+ }
1528
1740
  const streamKey = activeStreams.get(channelId);
1529
1741
  const threadRootId = streamKey ? streaming.getStreamThreadRootId(streamKey) : undefined;
1530
1742
  if (threadRootId)
@@ -1540,6 +1752,11 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1540
1752
  return;
1541
1753
  }
1542
1754
  if (event.type === 'bridge.user_input_request') {
1755
+ if (isQuiet(channelId)) {
1756
+ log.info(`Suppressing user input request during quiet mode on ${channelId.slice(0, 8)}...`);
1757
+ sessionManager.resolveUserInput(channelId, '');
1758
+ return;
1759
+ }
1543
1760
  const streamKey = activeStreams.get(channelId);
1544
1761
  const threadRootId = streamKey ? streaming.getStreamThreadRootId(streamKey) : undefined;
1545
1762
  if (threadRootId)
@@ -1554,24 +1771,111 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1554
1771
  await adapter.sendMessage(channelId, formatted, { threadRootId });
1555
1772
  return;
1556
1773
  }
1774
+ // Handle plan_changed events — debounced summary surfacing
1775
+ if (event.type === 'session.plan_changed') {
1776
+ const operation = event.data?.operation;
1777
+ if (operation === 'create' || operation === 'update') {
1778
+ sessionManager.debouncePlanChanged(channelId, async () => {
1779
+ try {
1780
+ const plan = await sessionManager.readPlan(channelId);
1781
+ if (!plan.exists || !plan.content)
1782
+ return;
1783
+ const summary = sessionManager.extractPlanSummary(plan.content);
1784
+ const threadRootId = channelThreadRoots.get(channelId);
1785
+ await adapter.sendMessage(channelId, `📋 **Plan updated** — ${summary}. \`/plan show\` for details.`, { threadRootId });
1786
+ }
1787
+ catch (err) {
1788
+ log.warn(`Failed to surface plan summary: ${err}`);
1789
+ }
1790
+ });
1791
+ }
1792
+ return;
1793
+ }
1794
+ // Handle exit_plan_mode.requested — present implementation options
1795
+ if (event.type === 'exit_plan_mode.requested') {
1796
+ const { requestId, summary, planContent, actions, recommendedAction } = event.data;
1797
+ const streamKey = activeStreams.get(channelId);
1798
+ const threadRootId = streamKey ? streaming.getStreamThreadRootId(streamKey) : undefined;
1799
+ if (threadRootId)
1800
+ channelThreadRoots.set(channelId, threadRootId);
1801
+ if (streamKey) {
1802
+ await streaming.finalizeStream(streamKey);
1803
+ activeStreams.delete(channelId);
1804
+ }
1805
+ await finalizeActivityFeed(channelId, adapter);
1806
+ sessionManager.setPendingPlanExit(channelId, {
1807
+ requestId,
1808
+ summary: summary ?? '',
1809
+ planContent: planContent ?? '',
1810
+ actions: actions ?? [],
1811
+ recommendedAction: recommendedAction ?? '',
1812
+ createdAt: Date.now(),
1813
+ });
1814
+ const msg = [
1815
+ '📋 **Plan ready**',
1816
+ '',
1817
+ summary || '(no summary provided)',
1818
+ '',
1819
+ 'How would you like to proceed?',
1820
+ '1. ▶️ `/implement yolo` — autopilot + yolo (fully autonomous)',
1821
+ '2. 🚀 `/implement` — autopilot (with permission prompts)',
1822
+ '3. 🔧 `/implement interactive` — interactive mode',
1823
+ '4. ❌ `/plan off` — exit plan mode without implementing',
1824
+ '',
1825
+ 'Or just keep chatting to continue refining the plan.',
1826
+ ].join('\n');
1827
+ await adapter.sendMessage(channelId, msg, { threadRootId });
1828
+ return;
1829
+ }
1557
1830
  // Format and route SDK events
1558
1831
  const formatted = formatEvent(event);
1559
1832
  if (!formatted)
1560
1833
  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);
1834
+ // ── Quiet mode: suppress all output until we know if response is NO_REPLY ──
1835
+ if (isQuiet(channelId)) {
1836
+ // Suppress content events (deltas and messages)
1837
+ if (formatted.type === 'content') {
1838
+ if (event.type === 'assistant.message_delta') {
1839
+ return;
1840
+ }
1841
+ if (event.type === 'assistant.message') {
1842
+ const content = formatted.content?.trim();
1843
+ // Skip empty assistant.message events (tool-call signals)
1844
+ if (!content)
1845
+ return;
1846
+ // Non-empty — check for NO_REPLY
1847
+ if (content === 'NO_REPLY' || content === '`NO_REPLY`') {
1848
+ log.info(`Filtered NO_REPLY (quiet mode) on channel ${channelId.slice(0, 8)}...`);
1849
+ exitQuietMode(channelId);
1850
+ const sk = activeStreams.get(channelId);
1851
+ if (sk) {
1852
+ await streaming.deleteStream(sk);
1853
+ activeStreams.delete(channelId);
1854
+ }
1855
+ return;
1856
+ }
1857
+ // Real content — flush: create stream with this content, exit quiet
1858
+ log.info(`Quiet mode flush on channel ${channelId.slice(0, 8)}... — real content received`);
1859
+ const savedThreadRoot = channelThreadRoots.get(channelId);
1860
+ exitQuietMode(channelId);
1861
+ const newKey = await streaming.startStream(channelId, savedThreadRoot, content);
1862
+ activeStreams.set(channelId, newKey);
1863
+ return;
1572
1864
  }
1865
+ }
1866
+ // Suppress verbose/tool/status events during quiet — but let session.idle
1867
+ // and session.error pass through so channel idle tracking still works
1868
+ if (formatted.type === 'tool_start' || formatted.type === 'tool_complete') {
1869
+ return;
1870
+ }
1871
+ if (formatted.type === 'status' && event.type !== 'session.idle') {
1573
1872
  return;
1574
1873
  }
1874
+ // Errors: exit quiet and fall through to normal error handling (surfaces to user)
1875
+ if (formatted.type === 'error') {
1876
+ exitQuietMode(channelId);
1877
+ // Fall through to normal error handling
1878
+ }
1575
1879
  }
1576
1880
  if (formatted.verbose && !verbose)
1577
1881
  return;
@@ -1603,8 +1907,8 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1603
1907
  }
1604
1908
  }
1605
1909
  if (!streamKey) {
1606
- // Suppress stream auto-start during startup nudge — avoid visible "Working..." flash
1607
- if (nudgePending.has(channelId))
1910
+ // Suppress stream auto-start during quiet mode — avoid visible "Working..." flash
1911
+ if (isQuiet(channelId))
1608
1912
  break;
1609
1913
  // Auto-start stream — use actual content, never a "Working..." placeholder.
1610
1914
  // This happens on subsequent turns after turn_end finalized the previous stream.
@@ -1656,7 +1960,7 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1656
1960
  `Will reset session if it continues.`);
1657
1961
  }
1658
1962
  }
1659
- if (verbose && formatted.content && !nudgePending.has(channelId)) {
1963
+ if (verbose && formatted.content && !isQuiet(channelId)) {
1660
1964
  await appendActivityFeed(channelId, formatted.content, adapter);
1661
1965
  }
1662
1966
  break;
@@ -1665,7 +1969,7 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1665
1969
  break;
1666
1970
  case 'error':
1667
1971
  markIdleImmediate(channelId);
1668
- nudgePending.delete(channelId);
1972
+ exitQuietMode(channelId);
1669
1973
  channelThreadRoots.delete(channelId);
1670
1974
  if (streamKey) {
1671
1975
  await streaming.cancelStream(streamKey, formatted.content);
@@ -1720,7 +2024,7 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1720
2024
  // Finalize stream when the session goes idle (all turns complete).
1721
2025
  if (event.type === 'session.idle') {
1722
2026
  markIdle(channelId);
1723
- nudgePending.delete(channelId);
2027
+ exitQuietMode(channelId);
1724
2028
  await finalizeActivityFeed(channelId, adapter);
1725
2029
  initialStreamPosted.delete(channelId);
1726
2030
  channelThreadRoots.delete(channelId);
@@ -1729,6 +2033,10 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1729
2033
  await streaming.finalizeStream(streamKey);
1730
2034
  activeStreams.delete(channelId);
1731
2035
  }
2036
+ // Revert yolo if it was temporarily enabled for plan implementation
2037
+ if (sessionManager.revertYoloIfNeeded(channelId)) {
2038
+ log.info(`Reverted yolo state on idle for ${channelId.slice(0, 8)}...`);
2039
+ }
1732
2040
  // Clean up temp files from downloaded attachments
1733
2041
  cleanupTempFiles(channelId);
1734
2042
  }
@@ -1804,11 +2112,16 @@ async function nudgeAdminSessions(sessionManager) {
1804
2112
  resolved.adapter.sendMessage(channelId, '🔄 Bridge restarted.').catch(e => log.warn(`Failed to post restart notice on ${channelId.slice(0, 8)}...:`, e));
1805
2113
  }
1806
2114
  }
1807
- nudgePending.add(channelId);
1808
- await sessionManager.sendMessage(channelId, NUDGE_PROMPT);
2115
+ const clearQuiet = enterQuietMode(channelId);
2116
+ try {
2117
+ await sessionManager.sendMessage(channelId, NUDGE_PROMPT);
2118
+ }
2119
+ finally {
2120
+ clearQuiet();
2121
+ }
1809
2122
  }
1810
2123
  catch (err) {
1811
- nudgePending.delete(channelId);
2124
+ exitQuietMode(channelId);
1812
2125
  log.warn(`Failed to nudge admin session on channel ${channelId.slice(0, 8)}...:`, err);
1813
2126
  }
1814
2127
  }