@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.
- package/README.md +4 -0
- package/config.sample.json +20 -0
- package/dist/config.d.ts +9 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +117 -0
- package/dist/config.js.map +1 -1
- package/dist/core/bridge-docs.d.ts +17 -0
- package/dist/core/bridge-docs.d.ts.map +1 -0
- package/dist/core/bridge-docs.js +790 -0
- package/dist/core/bridge-docs.js.map +1 -0
- package/dist/core/bridge.d.ts +14 -1
- package/dist/core/bridge.d.ts.map +1 -1
- package/dist/core/bridge.js +22 -2
- package/dist/core/bridge.js.map +1 -1
- package/dist/core/command-handler.d.ts +17 -4
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +255 -52
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/model-fallback.d.ts +2 -2
- package/dist/core/model-fallback.d.ts.map +1 -1
- package/dist/core/model-fallback.js +11 -4
- package/dist/core/model-fallback.js.map +1 -1
- package/dist/core/quiet-mode.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 +53 -3
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +430 -30
- package/dist/core/session-manager.js.map +1 -1
- package/dist/index.js +437 -41
- package/dist/index.js.map +1 -1
- package/dist/state/store.d.ts +1 -0
- package/dist/state/store.d.ts.map +1 -1
- package/dist/state/store.js +13 -2
- package/dist/state/store.js.map +1 -1
- package/dist/types.d.ts +29 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/templates/admin/AGENTS.md +56 -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.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
1291
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1562
|
-
if (
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
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
|
|
1607
|
-
if (
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
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
|
-
|
|
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
|
}
|