@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/core/bridge-docs.d.ts +17 -0
- package/dist/core/bridge-docs.d.ts.map +1 -0
- package/dist/core/bridge-docs.js +658 -0
- package/dist/core/bridge-docs.js.map +1 -0
- package/dist/core/command-handler.d.ts +2 -1
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +9 -3
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/quiet-mode.d.ts +14 -0
- package/dist/core/quiet-mode.d.ts.map +1 -0
- package/dist/core/quiet-mode.js +49 -0
- package/dist/core/quiet-mode.js.map +1 -0
- package/dist/core/session-manager.d.ts +50 -2
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +313 -18
- package/dist/core/session-manager.js.map +1 -1
- package/dist/index.js +347 -34
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/templates/admin/AGENTS.md +20 -0
- 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
|
|
34
|
-
|
|
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 === '
|
|
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
|
-
//
|
|
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
|
-
|
|
1291
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1562
|
-
if (
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
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
|
|
1607
|
-
if (
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1808
|
-
|
|
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
|
-
|
|
2124
|
+
exitQuietMode(channelId);
|
|
1812
2125
|
log.warn(`Failed to nudge admin session on channel ${channelId.slice(0, 8)}...:`, err);
|
|
1813
2126
|
}
|
|
1814
2127
|
}
|