jeopi-cli 16.6.9 → 16.6.11
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/CHANGELOG.md +16 -0
- package/dist/cli.js +3369 -3368
- package/dist/types/messenger/live-session.d.ts +28 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/session-teardown.d.ts +13 -8
- package/dist/types/modes/types.d.ts +2 -0
- package/package.json +12 -12
- package/src/config/model-registry.ts +50 -17
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/messenger/live-session.ts +321 -0
- package/src/modes/interactive-mode.ts +31 -7
- package/src/modes/session-teardown.test.ts +61 -0
- package/src/modes/session-teardown.ts +20 -8
- package/src/modes/types.ts +2 -0
- package/src/sdk.ts +12 -5
- package/src/slash-commands/builtin-registry.ts +104 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import type { AssistantMessage, UserMessage } from "jeopi-ai";
|
|
2
|
+
import type { InteractiveModeContext } from "../modes/types";
|
|
3
|
+
import { loadMessengerConfig, validateMessengerEnvironment } from "./config";
|
|
4
|
+
import { createDiscordAdapter, type DiscordTransportDeps } from "./discord";
|
|
5
|
+
import { createMessengerGateway, isMessengerMessageAllowed, type MessengerGateway } from "./gateway";
|
|
6
|
+
import type { MessengerAdapter, MessengerConfig, MessengerReply } from "./types";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_POLL_INTERVAL_MS = 250;
|
|
9
|
+
const LIVE_HOST_ABORT_TIMEOUT_MS = 1000;
|
|
10
|
+
const LIVE_BUSY_MESSAGE = "The live session is busy. Please try again when it is idle.";
|
|
11
|
+
const LIVE_UNAVAILABLE_MESSAGE = "The live Discord bridge is no longer attached to this session.";
|
|
12
|
+
|
|
13
|
+
export interface LiveDiscordBridgeDependencies {
|
|
14
|
+
createAdapter?: typeof createDiscordAdapter;
|
|
15
|
+
createGateway?: typeof createMessengerGateway;
|
|
16
|
+
env?: Record<string, string | undefined>;
|
|
17
|
+
transport?: DiscordTransportDeps;
|
|
18
|
+
pollIntervalMs?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LiveDiscordBridge {
|
|
22
|
+
readonly gateway: MessengerGateway;
|
|
23
|
+
readonly active: boolean;
|
|
24
|
+
run(signal?: AbortSignal): Promise<void>;
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface TurnCapture {
|
|
29
|
+
readonly promptText: string;
|
|
30
|
+
promptSeen: boolean;
|
|
31
|
+
dispatched: boolean;
|
|
32
|
+
hostSettled: boolean;
|
|
33
|
+
conflicted: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LiveDiscordBridgeOptions {
|
|
37
|
+
/** Explicit acknowledgement that Discord requests may use this host session's tools. */
|
|
38
|
+
acknowledgeFullControl?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function userMessageText(message: UserMessage): string {
|
|
42
|
+
if (typeof message.content === "string") return message.content;
|
|
43
|
+
return message.content
|
|
44
|
+
.filter(block => block.type === "text")
|
|
45
|
+
.map(block => block.text)
|
|
46
|
+
.join("");
|
|
47
|
+
}
|
|
48
|
+
function assistantMessageText(message: AssistantMessage): string {
|
|
49
|
+
return message.content
|
|
50
|
+
.filter(block => block.type === "text")
|
|
51
|
+
.map(block => block.text)
|
|
52
|
+
.join("\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sameSession(
|
|
56
|
+
ctx: InteractiveModeContext,
|
|
57
|
+
attachedSession: InteractiveModeContext["session"],
|
|
58
|
+
sessionId: string,
|
|
59
|
+
sessionFile: string | undefined,
|
|
60
|
+
): boolean {
|
|
61
|
+
return (
|
|
62
|
+
ctx.session === attachedSession &&
|
|
63
|
+
!attachedSession.isDisposed &&
|
|
64
|
+
ctx.sessionManager.getSessionId() === sessionId &&
|
|
65
|
+
ctx.sessionManager.getSessionFile() === sessionFile &&
|
|
66
|
+
!ctx.shutdownRequested &&
|
|
67
|
+
!ctx.isShuttingDown
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isCompetingMessage(message: { role: string; attribution?: string }): boolean {
|
|
72
|
+
if (message.role === "user") return true;
|
|
73
|
+
const attribution = message.attribution ?? "agent";
|
|
74
|
+
return (
|
|
75
|
+
(message.role === "custom" || message.role === "developer" || message.role === "hookMessage") &&
|
|
76
|
+
attribution !== "agent"
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function awaitWithAbort<T>(operation: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
|
81
|
+
if (!signal) return operation;
|
|
82
|
+
if (signal.aborted) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
83
|
+
const cancellation = Promise.withResolvers<never>();
|
|
84
|
+
const onAbort = () => cancellation.reject(new Error(LIVE_UNAVAILABLE_MESSAGE));
|
|
85
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
86
|
+
if (signal.aborted) onAbort();
|
|
87
|
+
try {
|
|
88
|
+
return await Promise.race([operation, cancellation.promise]);
|
|
89
|
+
} finally {
|
|
90
|
+
signal.removeEventListener("abort", onAbort);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function awaitHostAbort(operation: Promise<void>): Promise<void> {
|
|
95
|
+
const timeout = Promise.withResolvers<never>();
|
|
96
|
+
const timer = setTimeout(
|
|
97
|
+
() => timeout.reject(new Error("Live Discord host abort timed out")),
|
|
98
|
+
LIVE_HOST_ABORT_TIMEOUT_MS,
|
|
99
|
+
);
|
|
100
|
+
try {
|
|
101
|
+
await Promise.race([operation, timeout.promise]);
|
|
102
|
+
} finally {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function liveConfig(config: MessengerConfig): MessengerConfig {
|
|
108
|
+
const discord = config.platforms.discord;
|
|
109
|
+
if (!discord) throw new Error("Live Discord requires a discord platform configuration");
|
|
110
|
+
if (discord.allowedChannelIds === undefined || discord.allowedChannelIds.length === 0) {
|
|
111
|
+
throw new Error("Live Discord requires an explicit nonempty allowedChannelIds list");
|
|
112
|
+
}
|
|
113
|
+
return { ...config, platforms: { discord } };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Attach an authorized Discord transport to the already-running interactive session.
|
|
118
|
+
* This never creates, switches, or disposes an AgentSession. The caller owns activation
|
|
119
|
+
* and must retain the returned bridge to close it when the TUI shuts down.
|
|
120
|
+
*/
|
|
121
|
+
export function createLiveDiscordBridge(
|
|
122
|
+
ctx: InteractiveModeContext,
|
|
123
|
+
config: MessengerConfig,
|
|
124
|
+
dependencies: LiveDiscordBridgeDependencies = {},
|
|
125
|
+
options: LiveDiscordBridgeOptions = {},
|
|
126
|
+
): LiveDiscordBridge {
|
|
127
|
+
if (!options.acknowledgeFullControl) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"Live Discord requires explicit full-control acknowledgement (--allow-full-control); " +
|
|
130
|
+
"Discord requests run in the active session with its tools and approval policy",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const scopedConfig = liveConfig(config);
|
|
134
|
+
validateMessengerEnvironment(scopedConfig, dependencies.env);
|
|
135
|
+
const attachedSession = ctx.session;
|
|
136
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
137
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
138
|
+
if (!sessionId) throw new Error("Live Discord requires a persisted session identity");
|
|
139
|
+
|
|
140
|
+
const lifetime = new AbortController();
|
|
141
|
+
let closed = false;
|
|
142
|
+
let runPromise: Promise<void> | undefined;
|
|
143
|
+
let currentTurn: TurnCapture | undefined;
|
|
144
|
+
let pollTimer: NodeJS.Timeout;
|
|
145
|
+
let bridgeRef: LiveDiscordBridge | undefined;
|
|
146
|
+
let close: () => Promise<void>;
|
|
147
|
+
const reply: MessengerReply = async (message, signal) => {
|
|
148
|
+
if (closed || signal?.aborted) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
149
|
+
if (!sameSession(ctx, attachedSession, sessionId, sessionFile)) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
150
|
+
if (!isMessengerMessageAllowed(message, scopedConfig)) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
151
|
+
if (
|
|
152
|
+
attachedSession.isStreaming ||
|
|
153
|
+
attachedSession.isCompacting ||
|
|
154
|
+
attachedSession.isGeneratingHandoff ||
|
|
155
|
+
attachedSession.isRetrying ||
|
|
156
|
+
attachedSession.isBashRunning ||
|
|
157
|
+
attachedSession.isEvalRunning ||
|
|
158
|
+
attachedSession.hasPostPromptWork ||
|
|
159
|
+
currentTurn
|
|
160
|
+
)
|
|
161
|
+
return LIVE_BUSY_MESSAGE;
|
|
162
|
+
const promptText = `[Discord:${message.messageId} sender:${message.senderId} channel:${message.channelId}] ${message.text}`;
|
|
163
|
+
const capture: TurnCapture = {
|
|
164
|
+
promptText,
|
|
165
|
+
promptSeen: false,
|
|
166
|
+
dispatched: false,
|
|
167
|
+
hostSettled: false,
|
|
168
|
+
conflicted: false,
|
|
169
|
+
};
|
|
170
|
+
currentTurn = capture;
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
ctx.showStatus(`Discord request from ${message.senderId}`);
|
|
174
|
+
} catch {}
|
|
175
|
+
const operationSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
|
|
176
|
+
const unsubscribe = attachedSession.subscribe(event => {
|
|
177
|
+
if (event.type !== "message_end") return;
|
|
178
|
+
if (event.message.role === "user") {
|
|
179
|
+
const text = userMessageText(event.message);
|
|
180
|
+
if (!capture.promptSeen && text === capture.promptText) {
|
|
181
|
+
capture.promptSeen = true;
|
|
182
|
+
} else if (capture.promptSeen) {
|
|
183
|
+
capture.conflicted = true;
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (capture.promptSeen && isCompetingMessage(event.message)) capture.conflicted = true;
|
|
188
|
+
});
|
|
189
|
+
try {
|
|
190
|
+
if (closed || operationSignal.aborted || !sameSession(ctx, attachedSession, sessionId, sessionFile))
|
|
191
|
+
throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
192
|
+
const promptPromise = attachedSession.prompt(promptText, {
|
|
193
|
+
expandPromptTemplates: false,
|
|
194
|
+
attribution: "user",
|
|
195
|
+
});
|
|
196
|
+
capture.dispatched = true;
|
|
197
|
+
const dispatched = await awaitWithAbort(promptPromise, operationSignal);
|
|
198
|
+
capture.dispatched = dispatched;
|
|
199
|
+
await awaitWithAbort(attachedSession.waitForIdle(), operationSignal);
|
|
200
|
+
capture.hostSettled = true;
|
|
201
|
+
const turnMessages = attachedSession.messages;
|
|
202
|
+
const promptIndices = turnMessages.flatMap((turnMessage, index) =>
|
|
203
|
+
turnMessage.role === "user" && userMessageText(turnMessage) === promptText ? [index] : [],
|
|
204
|
+
);
|
|
205
|
+
capture.promptSeen = capture.promptSeen || promptIndices.length === 1;
|
|
206
|
+
if (
|
|
207
|
+
closed ||
|
|
208
|
+
!dispatched ||
|
|
209
|
+
!sameSession(ctx, attachedSession, sessionId, sessionFile) ||
|
|
210
|
+
capture.conflicted ||
|
|
211
|
+
promptIndices.length !== 1
|
|
212
|
+
)
|
|
213
|
+
throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
214
|
+
const promptIndex = promptIndices[0]!;
|
|
215
|
+
const messagesAfterPrompt = turnMessages.slice(promptIndex + 1);
|
|
216
|
+
if (messagesAfterPrompt.some(isCompetingMessage)) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
217
|
+
const finalAssistant = messagesAfterPrompt.findLast(
|
|
218
|
+
(turnMessage): turnMessage is AssistantMessage => turnMessage.role === "assistant",
|
|
219
|
+
);
|
|
220
|
+
if (finalAssistant?.stopReason !== "stop") throw new Error("Live Discord response unavailable");
|
|
221
|
+
const response = assistantMessageText(finalAssistant);
|
|
222
|
+
if (!response.trim()) throw new Error("Live Discord response unavailable");
|
|
223
|
+
return response;
|
|
224
|
+
} finally {
|
|
225
|
+
unsubscribe();
|
|
226
|
+
if (currentTurn === capture) currentTurn = undefined;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const discordConfig = scopedConfig.platforms.discord;
|
|
231
|
+
if (!discordConfig) throw new Error("Live Discord configuration unavailable");
|
|
232
|
+
const adapterFactory = dependencies.createAdapter ?? createDiscordAdapter;
|
|
233
|
+
const gatewayFactory = dependencies.createGateway ?? createMessengerGateway;
|
|
234
|
+
const adapter = adapterFactory(discordConfig, dependencies.env, dependencies.transport);
|
|
235
|
+
const guardedAdapter: MessengerAdapter = {
|
|
236
|
+
...adapter,
|
|
237
|
+
send: async (message, text, signal) => {
|
|
238
|
+
if (closed || !sameSession(ctx, attachedSession, sessionId, sessionFile)) {
|
|
239
|
+
throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
240
|
+
}
|
|
241
|
+
const sendSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
|
|
242
|
+
if (sendSignal.aborted || !sameSession(ctx, attachedSession, sessionId, sessionFile)) {
|
|
243
|
+
throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
244
|
+
}
|
|
245
|
+
await adapter.send(message, text, sendSignal);
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
const gateway = gatewayFactory(scopedConfig, [guardedAdapter], reply);
|
|
249
|
+
close = async (): Promise<void> => {
|
|
250
|
+
if (closed) {
|
|
251
|
+
await runPromise?.catch(() => {});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
closed = true;
|
|
255
|
+
clearInterval(pollTimer);
|
|
256
|
+
lifetime.abort();
|
|
257
|
+
const turn = currentTurn;
|
|
258
|
+
if (turn && sameSession(ctx, attachedSession, sessionId, sessionFile)) {
|
|
259
|
+
if (turn.dispatched && !turn.hostSettled && !turn.conflicted) {
|
|
260
|
+
try {
|
|
261
|
+
await awaitHostAbort(attachedSession.abort({ reason: "Discord live bridge revoked" }));
|
|
262
|
+
} catch {
|
|
263
|
+
try {
|
|
264
|
+
ctx.showWarning("Discord live turn could not be interrupted; press Esc to interrupt it.");
|
|
265
|
+
} catch {}
|
|
266
|
+
}
|
|
267
|
+
} else if (!turn.hostSettled) {
|
|
268
|
+
try {
|
|
269
|
+
ctx.showWarning("A Discord-initiated turn is still running; press Esc to interrupt.");
|
|
270
|
+
} catch {}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
await runPromise?.catch(() => {});
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const run = (signal?: AbortSignal): Promise<void> => {
|
|
277
|
+
if (runPromise) return runPromise;
|
|
278
|
+
const effectiveSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
|
|
279
|
+
runPromise = gateway
|
|
280
|
+
.run(effectiveSignal)
|
|
281
|
+
.then(() => {
|
|
282
|
+
if (ctx.liveDiscordBridge !== bridgeRef) return;
|
|
283
|
+
ctx.liveDiscordBridge = undefined;
|
|
284
|
+
if (!sameSession(ctx, attachedSession, sessionId, sessionFile)) {
|
|
285
|
+
ctx.showStatus("Discord live bridge detached: session changed");
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
.finally(() => {
|
|
289
|
+
closed = true;
|
|
290
|
+
clearInterval(pollTimer);
|
|
291
|
+
});
|
|
292
|
+
return runPromise;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const pollInterval = Math.max(50, Math.trunc(dependencies.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS));
|
|
296
|
+
pollTimer = setInterval(() => {
|
|
297
|
+
if (!closed && !sameSession(ctx, attachedSession, sessionId, sessionFile)) void close().catch(() => {});
|
|
298
|
+
}, pollInterval);
|
|
299
|
+
pollTimer.unref?.();
|
|
300
|
+
|
|
301
|
+
const bridge: LiveDiscordBridge = {
|
|
302
|
+
gateway,
|
|
303
|
+
get active() {
|
|
304
|
+
return !closed;
|
|
305
|
+
},
|
|
306
|
+
run,
|
|
307
|
+
close,
|
|
308
|
+
};
|
|
309
|
+
bridgeRef = bridge;
|
|
310
|
+
return bridge;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function loadAndCreateLiveDiscordBridge(
|
|
314
|
+
ctx: InteractiveModeContext,
|
|
315
|
+
configPath: string,
|
|
316
|
+
dependencies: LiveDiscordBridgeDependencies = {},
|
|
317
|
+
options: LiveDiscordBridgeOptions = {},
|
|
318
|
+
): Promise<LiveDiscordBridge> {
|
|
319
|
+
const config = await loadMessengerConfig(configPath);
|
|
320
|
+
return createLiveDiscordBridge(ctx, config, dependencies, options);
|
|
321
|
+
}
|
|
@@ -80,6 +80,7 @@ import {
|
|
|
80
80
|
MCP_CONNECTION_STATUS_EVENT_CHANNEL,
|
|
81
81
|
type McpConnectionStatusEvent,
|
|
82
82
|
} from "../mcp/startup-events";
|
|
83
|
+
import type { LiveDiscordBridge } from "../messenger/live-session";
|
|
83
84
|
import {
|
|
84
85
|
humanizePlanTitle,
|
|
85
86
|
type PlanApprovalDetails,
|
|
@@ -491,6 +492,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
491
492
|
oauthManualInput: OAuthManualInputManager = new OAuthManualInputManager();
|
|
492
493
|
collabHost?: CollabHost;
|
|
493
494
|
collabGuest?: CollabGuestLink;
|
|
495
|
+
liveDiscordBridge?: LiveDiscordBridge;
|
|
494
496
|
|
|
495
497
|
#pendingSlashCommands: SlashCommand[] = [];
|
|
496
498
|
#cleanupUnsubscribe?: () => void;
|
|
@@ -796,10 +798,27 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
796
798
|
// signal arriving mid-Ctrl+C no-ops instead of racing a second dispose.
|
|
797
799
|
this.#signalTeardown = createSessionTeardown({
|
|
798
800
|
getDraftText: () => this.editor.getText(),
|
|
801
|
+
beforeDispose: async () => {
|
|
802
|
+
const liveDiscordBridge = this.liveDiscordBridge;
|
|
803
|
+
this.liveDiscordBridge = undefined;
|
|
804
|
+
try {
|
|
805
|
+
await liveDiscordBridge?.close();
|
|
806
|
+
} catch (error) {
|
|
807
|
+
logger.warn("Failed to close live Discord bridge before teardown", { error: String(error) });
|
|
808
|
+
}
|
|
809
|
+
},
|
|
799
810
|
beginDispose: () => this.session.beginDispose(),
|
|
800
811
|
saveDraft: text => this.sessionManager.saveDraft(text),
|
|
801
|
-
disposeSession: reason =>
|
|
802
|
-
|
|
812
|
+
disposeSession: async reason => {
|
|
813
|
+
const liveDiscordBridge = this.liveDiscordBridge;
|
|
814
|
+
this.liveDiscordBridge = undefined;
|
|
815
|
+
try {
|
|
816
|
+
await liveDiscordBridge?.close();
|
|
817
|
+
} catch (error) {
|
|
818
|
+
logger.warn("Failed to close live Discord bridge during teardown", { error: String(error) });
|
|
819
|
+
}
|
|
820
|
+
await this.session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS, reason });
|
|
821
|
+
},
|
|
803
822
|
});
|
|
804
823
|
// Forward the postmortem reason (SIGTERM/SIGHUP/uncaughtException/…) so the
|
|
805
824
|
// persisted `session_exit` diagnostic carries the real trigger. Postmortem
|
|
@@ -3349,14 +3368,19 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
3349
3368
|
this.#isShuttingDown = true;
|
|
3350
3369
|
|
|
3351
3370
|
this.#btwController.dispose();
|
|
3352
|
-
this.#omfgController.dispose();
|
|
3353
3371
|
this.#focusController.dispose();
|
|
3354
|
-
|
|
3372
|
+
this.#omfgController.dispose();
|
|
3355
3373
|
// Surface an explicit "Closing session…" line so the user sees a reason
|
|
3356
3374
|
// for the pause while `session.dispose()` flushes memory consolidate and
|
|
3357
|
-
// other cleanups (issue #3641). The
|
|
3358
|
-
//
|
|
3359
|
-
|
|
3375
|
+
// other cleanups (issue #3641). The bridge is closed before disposal so
|
|
3376
|
+
// revoked Discord work cannot race session teardown.
|
|
3377
|
+
const liveDiscordBridge = this.liveDiscordBridge;
|
|
3378
|
+
this.liveDiscordBridge = undefined;
|
|
3379
|
+
try {
|
|
3380
|
+
await liveDiscordBridge?.close();
|
|
3381
|
+
} catch (error) {
|
|
3382
|
+
logger.warn("Failed to close live Discord bridge during shutdown", { error: String(error) });
|
|
3383
|
+
}
|
|
3360
3384
|
this.showStatus("Closing session…");
|
|
3361
3385
|
|
|
3362
3386
|
// Persist the draft and dispose the session through the shared teardown
|
|
@@ -66,6 +66,67 @@ describe("createSessionTeardown", () => {
|
|
|
66
66
|
expect(order).toEqual(["snapshot", "beginDispose", "saveDraft:start", "saveDraft:done", "disposeSession"]);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
it("awaits beforeDispose before marking the session disposing", async () => {
|
|
70
|
+
const order: string[] = [];
|
|
71
|
+
const release = Promise.withResolvers<void>();
|
|
72
|
+
|
|
73
|
+
const teardown = createSessionTeardown({
|
|
74
|
+
getDraftText: () => "draft",
|
|
75
|
+
beforeDispose: async () => {
|
|
76
|
+
order.push("beforeDispose:start");
|
|
77
|
+
await release.promise;
|
|
78
|
+
order.push("beforeDispose:done");
|
|
79
|
+
},
|
|
80
|
+
beginDispose: () => {
|
|
81
|
+
order.push("beginDispose");
|
|
82
|
+
},
|
|
83
|
+
saveDraft: async () => {
|
|
84
|
+
order.push("saveDraft");
|
|
85
|
+
},
|
|
86
|
+
disposeSession: async () => {
|
|
87
|
+
order.push("disposeSession");
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const running = teardown();
|
|
92
|
+
expect(order).toEqual(["beforeDispose:start"]);
|
|
93
|
+
release.resolve();
|
|
94
|
+
await running;
|
|
95
|
+
|
|
96
|
+
expect(order).toEqual([
|
|
97
|
+
"beforeDispose:start",
|
|
98
|
+
"beforeDispose:done",
|
|
99
|
+
"beginDispose",
|
|
100
|
+
"saveDraft",
|
|
101
|
+
"disposeSession",
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("continues teardown when beforeDispose rejects", async () => {
|
|
106
|
+
const order: string[] = [];
|
|
107
|
+
|
|
108
|
+
const teardown = createSessionTeardown({
|
|
109
|
+
getDraftText: () => "draft",
|
|
110
|
+
beforeDispose: async () => {
|
|
111
|
+
order.push("beforeDispose");
|
|
112
|
+
throw new Error("revocation failed");
|
|
113
|
+
},
|
|
114
|
+
beginDispose: () => {
|
|
115
|
+
order.push("beginDispose");
|
|
116
|
+
},
|
|
117
|
+
saveDraft: async () => {
|
|
118
|
+
order.push("saveDraft");
|
|
119
|
+
},
|
|
120
|
+
disposeSession: async () => {
|
|
121
|
+
order.push("disposeSession");
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await teardown();
|
|
126
|
+
|
|
127
|
+
expect(order).toEqual(["beforeDispose", "beginDispose", "saveDraft", "disposeSession"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
69
130
|
it("still disposes when saveDraft rejects — never leaves session_shutdown unemitted", async () => {
|
|
70
131
|
let disposed = false;
|
|
71
132
|
|
|
@@ -16,11 +16,16 @@ export interface SessionTeardownDeps {
|
|
|
16
16
|
/** Snapshot the current editor text; called once, before disposal touches session state. */
|
|
17
17
|
getDraftText: () => string;
|
|
18
18
|
/**
|
|
19
|
-
* Synchronously mark the session as disposing
|
|
20
|
-
*
|
|
21
|
-
* after a signal requested shutdown
|
|
19
|
+
* Synchronously mark the session as disposing after asynchronous revocation
|
|
20
|
+
* completes and before any further awaited teardown work. This closes the async
|
|
21
|
+
* gap where deferred jobs could otherwise start after a signal requested shutdown.
|
|
22
22
|
*/
|
|
23
23
|
beginDispose: () => void;
|
|
24
|
+
/**
|
|
25
|
+
* Complete asynchronous revocation before the session is marked disposed.
|
|
26
|
+
* Failures are logged and never skip the disposal chain.
|
|
27
|
+
*/
|
|
28
|
+
beforeDispose?: () => Promise<void>;
|
|
24
29
|
/**
|
|
25
30
|
* Persist the snapshotted draft. Called even for an empty string so a
|
|
26
31
|
* previously-persisted draft sidecar is cleared on a clean exit.
|
|
@@ -44,11 +49,11 @@ export interface SessionTeardownDeps {
|
|
|
44
49
|
export type SessionTeardown = (reason?: postmortem.Reason) => Promise<void>;
|
|
45
50
|
|
|
46
51
|
/**
|
|
47
|
-
* Build a promise-memoized teardown
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
+
* Build a promise-memoized teardown: snapshot the draft, complete asynchronous
|
|
53
|
+
* revocation, mark the session disposing synchronously, run `saveDraft` (draft-loss
|
|
54
|
+
* protection for `--resume`), then `disposeSession`; subsequent calls await the same
|
|
55
|
+
* settled promise so the keypress `InteractiveMode.shutdown()` path and the postmortem
|
|
56
|
+
* signal callback cannot
|
|
52
57
|
* double-emit `session_shutdown`, double-dispose the session's async-job
|
|
53
58
|
* manager, or race each other.
|
|
54
59
|
*
|
|
@@ -67,6 +72,13 @@ export function createSessionTeardown(deps: SessionTeardownDeps): SessionTeardow
|
|
|
67
72
|
let pending: Promise<void> | undefined;
|
|
68
73
|
const run = async (reason?: postmortem.Reason): Promise<void> => {
|
|
69
74
|
const draftText = deps.getDraftText();
|
|
75
|
+
if (deps.beforeDispose) {
|
|
76
|
+
try {
|
|
77
|
+
await deps.beforeDispose();
|
|
78
|
+
} catch (err) {
|
|
79
|
+
logger.warn("Failed pre-dispose cleanup during teardown", { error: String(err) });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
70
82
|
deps.beginDispose();
|
|
71
83
|
try {
|
|
72
84
|
await deps.saveDraft(draftText);
|
package/src/modes/types.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
import type { CompactOptions } from "../extensibility/extensions/types";
|
|
17
17
|
import type { Skill } from "../extensibility/skills";
|
|
18
18
|
import type { MCPManager } from "../mcp";
|
|
19
|
+
import type { LiveDiscordBridge } from "../messenger/live-session";
|
|
19
20
|
import type { PlanApprovalDetails } from "../plan-mode/approved-plan";
|
|
20
21
|
import type { AgentSession } from "../session/agent-session";
|
|
21
22
|
import type { CompactMode } from "../session/compact-modes";
|
|
@@ -134,6 +135,7 @@ export interface InteractiveModeContext {
|
|
|
134
135
|
collabGuest?: CollabGuestLink;
|
|
135
136
|
eventController: EventController;
|
|
136
137
|
eventBus?: EventBus;
|
|
138
|
+
liveDiscordBridge?: LiveDiscordBridge;
|
|
137
139
|
|
|
138
140
|
// State
|
|
139
141
|
isInitialized: boolean;
|
package/src/sdk.ts
CHANGED
|
@@ -1921,11 +1921,18 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1921
1921
|
const refreshedProviders = new Set<string>();
|
|
1922
1922
|
for (const selector of discoverySelectors) {
|
|
1923
1923
|
if (!selector) continue;
|
|
1924
|
-
const parsed =
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1924
|
+
const parsed =
|
|
1925
|
+
parseModelString(selector, {
|
|
1926
|
+
allowMaxAlias: true,
|
|
1927
|
+
allowAutoAlias: true,
|
|
1928
|
+
isLiteralModelId: (provider, id) => modelRegistry.find(provider, id) !== undefined,
|
|
1929
|
+
}) ??
|
|
1930
|
+
(!selector.includes("/") &&
|
|
1931
|
+
!/[?*[\]]/.test(selector) &&
|
|
1932
|
+
/argon|gemini-(?:3\.8|4)/i.test(selector) &&
|
|
1933
|
+
!parseModelPattern(selector, modelRegistry.getAll(), modelMatchPreferences).model
|
|
1934
|
+
? { provider: "google-antigravity", id: selector.split(":")[0] }
|
|
1935
|
+
: undefined);
|
|
1929
1936
|
if (!parsed || /[?*[\]]/.test(parsed.id)) continue;
|
|
1930
1937
|
const existing = modelRegistry.find(parsed.provider, parsed.id);
|
|
1931
1938
|
if (existing && hasModelAuth(existing)) break;
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
MarketplaceManager,
|
|
25
25
|
} from "../extensibility/plugins/marketplace";
|
|
26
26
|
import { resolveMemoryBackend } from "../memory-backend";
|
|
27
|
+
import { loadAndCreateLiveDiscordBridge } from "../messenger/live-session";
|
|
27
28
|
import { describeLoopLimitRuntime } from "../modes/loop-limit";
|
|
28
29
|
import { theme } from "../modes/theme/theme";
|
|
29
30
|
import type { InteractiveModeContext } from "../modes/types";
|
|
@@ -66,6 +67,13 @@ import type {
|
|
|
66
67
|
|
|
67
68
|
export type { BuiltinSlashCommand, SubcommandDef } from "./types";
|
|
68
69
|
|
|
70
|
+
interface LiveDiscordStart {
|
|
71
|
+
cancelled: boolean;
|
|
72
|
+
promise: Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const liveDiscordStarts = new WeakMap<InteractiveModeContext, LiveDiscordStart>();
|
|
76
|
+
|
|
69
77
|
/** TUI-specific runtime accepted by `executeBuiltinSlashCommand`. */
|
|
70
78
|
export type BuiltinSlashCommandRuntime = TuiSlashCommandRuntime;
|
|
71
79
|
|
|
@@ -650,6 +658,102 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
650
658
|
runtime.ctx.editor.setText("");
|
|
651
659
|
},
|
|
652
660
|
},
|
|
661
|
+
{
|
|
662
|
+
name: "discord",
|
|
663
|
+
description: "Opt in to this session from an authorized Discord channel",
|
|
664
|
+
inlineHint: "[live|stop|status] [configPath] [--allow-full-control]",
|
|
665
|
+
subcommands: [
|
|
666
|
+
{ name: "live", description: "Start the live Discord bridge with a private config file" },
|
|
667
|
+
{ name: "status", description: "Show live Discord bridge status" },
|
|
668
|
+
{ name: "stop", description: "Stop the live Discord bridge" },
|
|
669
|
+
],
|
|
670
|
+
allowArgs: true,
|
|
671
|
+
getTuiAutocompleteDescription: runtime =>
|
|
672
|
+
runtime.ctx.liveDiscordBridge?.active ? "Discord live bridge: active" : "Discord live bridge: off",
|
|
673
|
+
handleTui: async (command, runtime) => {
|
|
674
|
+
const ctx = runtime.ctx;
|
|
675
|
+
ctx.editor.setText("");
|
|
676
|
+
const { verb, rest } = parseSubcommand(command.args.trim());
|
|
677
|
+
if (verb === "status") {
|
|
678
|
+
if (!ctx.liveDiscordBridge?.active) {
|
|
679
|
+
ctx.showStatus("Discord live bridge is off");
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const approvalMode = String(ctx.session.settings.get("tools.approvalMode"));
|
|
683
|
+
const activeTools = ctx.session.getActiveToolNames();
|
|
684
|
+
ctx.showStatus(
|
|
685
|
+
`Discord live bridge is active with full host control acknowledged. Approval mode: ${approvalMode}; active tools: ${activeTools.length > 0 ? activeTools.join(", ") : "none"}.`,
|
|
686
|
+
);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (verb === "stop") {
|
|
690
|
+
const bridge = ctx.liveDiscordBridge;
|
|
691
|
+
const start = liveDiscordStarts.get(ctx);
|
|
692
|
+
if (!bridge && start) {
|
|
693
|
+
start.cancelled = true;
|
|
694
|
+
ctx.showStatus("Discord live bridge startup cancellation requested");
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (!bridge) {
|
|
698
|
+
ctx.showStatus("Discord live bridge is not active");
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
ctx.liveDiscordBridge = undefined;
|
|
702
|
+
try {
|
|
703
|
+
await bridge.close();
|
|
704
|
+
ctx.showStatus("Discord live bridge stopped; access revoked");
|
|
705
|
+
} catch (error) {
|
|
706
|
+
ctx.showError(`Failed to stop Discord live bridge: ${errorMessage(error)}`);
|
|
707
|
+
}
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const acknowledgment = /^(.+?)\s+--allow-full-control$/.exec(rest);
|
|
711
|
+
if (verb !== "live" || !acknowledgment) {
|
|
712
|
+
ctx.showError("Usage: /discord live <private messenger.json> --allow-full-control");
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (ctx.liveDiscordBridge?.active || liveDiscordStarts.has(ctx)) {
|
|
716
|
+
ctx.showError("Discord live bridge is already active or starting (/discord stop first)");
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
let resolveStart!: () => void;
|
|
720
|
+
const startPromise = new Promise<void>(resolve => {
|
|
721
|
+
resolveStart = resolve;
|
|
722
|
+
});
|
|
723
|
+
const startState: LiveDiscordStart = { cancelled: false, promise: startPromise };
|
|
724
|
+
liveDiscordStarts.set(ctx, startState);
|
|
725
|
+
try {
|
|
726
|
+
const configPath = resolveToCwd(acknowledgment[1]!, ctx.sessionManager.getCwd());
|
|
727
|
+
const bridge = await loadAndCreateLiveDiscordBridge(ctx, configPath, {}, { acknowledgeFullControl: true });
|
|
728
|
+
if (startState.cancelled || ctx.shutdownRequested || ctx.isShuttingDown) {
|
|
729
|
+
await bridge.close().catch(() => {});
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (ctx.liveDiscordBridge?.active) {
|
|
733
|
+
await bridge.close().catch(() => {});
|
|
734
|
+
ctx.showError("Discord live bridge is already active (/discord stop first)");
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
ctx.liveDiscordBridge = bridge;
|
|
738
|
+
void bridge.run().catch(error => {
|
|
739
|
+
if (ctx.liveDiscordBridge !== bridge || ctx.isShuttingDown) return;
|
|
740
|
+
ctx.liveDiscordBridge = undefined;
|
|
741
|
+
ctx.showError(`Discord live bridge stopped: ${errorMessage(error)}`);
|
|
742
|
+
});
|
|
743
|
+
const approvalMode = String(ctx.session.settings.get("tools.approvalMode"));
|
|
744
|
+
const activeTools = ctx.session.getActiveToolNames();
|
|
745
|
+
ctx.showStatus(
|
|
746
|
+
`Discord live bridge active with full host control acknowledged. Approval mode: ${approvalMode}; active tools: ${activeTools.length > 0 ? activeTools.join(", ") : "none"}. /discord stop revokes access.`,
|
|
747
|
+
);
|
|
748
|
+
} catch (error) {
|
|
749
|
+
ctx.showError(`Failed to start Discord live bridge: ${errorMessage(error)}`);
|
|
750
|
+
} finally {
|
|
751
|
+
resolveStart();
|
|
752
|
+
if (liveDiscordStarts.get(ctx)?.promise === startPromise) liveDiscordStarts.delete(ctx);
|
|
753
|
+
}
|
|
754
|
+
},
|
|
755
|
+
},
|
|
756
|
+
|
|
653
757
|
{
|
|
654
758
|
name: "collab",
|
|
655
759
|
description: "Share this session live via a relay",
|