jeo-code 0.12.7 → 0.12.8
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 +4 -0
- package/README.ja.md +2 -0
- package/README.ko.md +2 -0
- package/README.md +4 -2
- package/README.zh.md +2 -0
- package/package.json +1 -1
- package/src/agent/compaction.ts +6 -7
- package/src/agent/notify/discord-api.ts +125 -28
- package/src/agent/notify/discord-artifacts.ts +126 -0
- package/src/agent/notify/discord-attachments.ts +116 -0
- package/src/agent/notify/discord-daemon.ts +213 -39
- package/src/agent/notify/session-endpoint.ts +127 -5
- package/src/agent/session.ts +7 -5
- package/src/cli/runner.ts +9 -0
- package/src/cli.ts +4 -0
- package/src/commands/launch/agy-turn.ts +20 -7
- package/src/commands/launch/confirm.ts +78 -0
- package/src/commands/launch/flags.ts +7 -0
- package/src/commands/launch/input.ts +18 -1
- package/src/commands/launch.ts +320 -40
- package/src/commands/messenger.ts +146 -0
- package/src/commands/notify.ts +3 -0
- package/src/messenger/activity.ts +30 -0
- package/src/messenger/artifacts.ts +167 -0
- package/src/messenger/config.ts +145 -0
- package/src/messenger/discord-setup.ts +568 -0
- package/src/messenger/discord.ts +707 -0
- package/src/messenger/gateway.ts +444 -0
- package/src/messenger/http.ts +88 -0
- package/src/messenger/service.ts +122 -0
- package/src/messenger/session-pool.ts +109 -0
- package/src/messenger/slack.ts +333 -0
- package/src/messenger/telegram.ts +241 -0
- package/src/messenger/types.ts +82 -0
- package/src/tui/components/slash.ts +1 -1
|
@@ -4,6 +4,8 @@ import { DiscordApi, DiscordApiError, type DiscordGatewayInfo } from "./discord-
|
|
|
4
4
|
import { notifySessionsDir } from "./paths";
|
|
5
5
|
import { isPidAlive } from "./daemon-control";
|
|
6
6
|
import { parseSessionEndpoint } from "./session-discovery";
|
|
7
|
+
import { createDiscordArtifactStage, type DiscordArtifactStage } from "./discord-artifacts";
|
|
8
|
+
import { stageDiscordAttachments, type DiscordAttachmentStage } from "./discord-attachments";
|
|
7
9
|
|
|
8
10
|
export interface DiscordDaemonConfig {
|
|
9
11
|
botToken: string;
|
|
@@ -12,11 +14,12 @@ export interface DiscordDaemonConfig {
|
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export interface DiscordDaemonDependencies {
|
|
15
|
-
api?: Pick<DiscordApi, "getMe" | "getChannel" | "getGatewayBot" | "sendMessage" | "stop">;
|
|
17
|
+
api?: Pick<DiscordApi, "getMe" | "getChannel" | "getGatewayBot" | "sendMessage" | "sendFile" | "createThread" | "addReaction" | "removeReaction" | "stop">;
|
|
16
18
|
WebSocketImpl?: typeof WebSocket;
|
|
17
19
|
GatewayWebSocketImpl?: typeof WebSocket;
|
|
18
20
|
readdir?: (dir: string) => Promise<string[]>;
|
|
19
21
|
readFile?: (file: string) => Promise<string>;
|
|
22
|
+
stageAttachments?: typeof stageDiscordAttachments;
|
|
20
23
|
isPidAlive?: (pid: number) => boolean;
|
|
21
24
|
now?: () => number;
|
|
22
25
|
random?: () => number;
|
|
@@ -36,7 +39,10 @@ interface DiscordSession {
|
|
|
36
39
|
ws: WebSocket;
|
|
37
40
|
verified: boolean;
|
|
38
41
|
records: AgentState[];
|
|
42
|
+
controlEnabled: boolean;
|
|
43
|
+
controlGeneration: number;
|
|
39
44
|
}
|
|
45
|
+
interface RemoteResult { ok: boolean; text?: string; artifactsAllowed?: boolean; controlGeneration?: number }
|
|
40
46
|
|
|
41
47
|
function object(value: unknown): value is Record<string, unknown> {
|
|
42
48
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
@@ -59,8 +65,14 @@ export class DiscordDaemon {
|
|
|
59
65
|
private readonly api: NonNullable<DiscordDaemonDependencies["api"]>;
|
|
60
66
|
private readonly timers = new Set<Timer>();
|
|
61
67
|
private readonly pending = new Map<string, { sessionId: string; finish: (ok: boolean) => void }>();
|
|
68
|
+
private readonly remote = new Map<string, { sessionId: string; acknowledged: boolean; finish: (result: RemoteResult) => void }>();
|
|
69
|
+
private readonly staging = new Map<AbortController, string>();
|
|
62
70
|
private readonly replies = new Map<string, { sessionId: string; at: number }>();
|
|
63
71
|
private readonly seen = new Set<string>();
|
|
72
|
+
private readonly threads = new Map<string, string>();
|
|
73
|
+
private readonly relayDestinations = new Map<string, string>();
|
|
74
|
+
private readonly relayVersions = new Map<string, number>();
|
|
75
|
+
private channelType = -1;
|
|
64
76
|
private readonly allowed: Set<string>;
|
|
65
77
|
private stopped = true;
|
|
66
78
|
private started = false;
|
|
@@ -121,6 +133,11 @@ export class DiscordDaemon {
|
|
|
121
133
|
const channel = await this.api.getChannel(this.config.channelId);
|
|
122
134
|
if (this.stopped) return;
|
|
123
135
|
if (![0, 1, 5, 10, 11, 12].includes(channel.type)) throw new Error("Unsupported Discord channel type");
|
|
136
|
+
this.channelType = channel.type;
|
|
137
|
+
if ([10, 11, 12].includes(channel.type)) {
|
|
138
|
+
if (typeof channel.owner_id !== "string" || !this.allowed.has(channel.owner_id)) throw new Error("Configured thread must have an allowed human owner");
|
|
139
|
+
this.threads.set(channel.id, channel.owner_id);
|
|
140
|
+
}
|
|
124
141
|
this.intents = channel.type === 1 ? 1 << 12 : (1 << 9) | (1 << 15);
|
|
125
142
|
this.gatewayInfo = await this.api.getGatewayBot();
|
|
126
143
|
if (this.stopped) return;
|
|
@@ -139,6 +156,7 @@ export class DiscordDaemon {
|
|
|
139
156
|
|
|
140
157
|
stop(): void {
|
|
141
158
|
this.stopped = true;
|
|
159
|
+
for (const controller of this.staging.keys()) controller.abort();
|
|
142
160
|
for (const timer of this.timers) (this.deps.clearTimeout ?? clearTimeout)(timer);
|
|
143
161
|
this.timers.clear();
|
|
144
162
|
const gateway = this.gateway;
|
|
@@ -148,8 +166,13 @@ export class DiscordDaemon {
|
|
|
148
166
|
this.sessions.clear();
|
|
149
167
|
for (const request of this.pending.values()) request.finish(false);
|
|
150
168
|
this.pending.clear();
|
|
169
|
+
for (const request of this.remote.values()) request.finish({ ok: false });
|
|
170
|
+
this.remote.clear();
|
|
151
171
|
this.replies.clear();
|
|
152
172
|
this.seen.clear();
|
|
173
|
+
this.threads.clear();
|
|
174
|
+
this.relayDestinations.clear();
|
|
175
|
+
this.relayVersions.clear();
|
|
153
176
|
this.api.stop();
|
|
154
177
|
}
|
|
155
178
|
|
|
@@ -293,11 +316,11 @@ export class DiscordDaemon {
|
|
|
293
316
|
try {
|
|
294
317
|
const WS = this.deps.WebSocketImpl ?? WebSocket;
|
|
295
318
|
const ws = new WS(url.href);
|
|
296
|
-
const session: DiscordSession = { sessionId: id, cwd: info.cwd, pid: info.pid, ws, verified: false, records: [] };
|
|
319
|
+
const session: DiscordSession = { sessionId: id, cwd: info.cwd, pid: info.pid, ws, verified: false, records: [], controlEnabled: false, controlGeneration: -1 };
|
|
297
320
|
this.sessions.set(id, session);
|
|
298
321
|
const timeout = this.later(() => { if (!session.verified) { this.disconnectSession(session); try { ws.close(); } catch {} } }, 10_000);
|
|
299
322
|
ws.onmessage = event => {
|
|
300
|
-
if (this.stopped || this.sessions.get(id) !== session || typeof event.data !== "string" || event.data.length > 1_048_576) return;
|
|
323
|
+
if (this.stopped || this.sessions.get(id) !== session || typeof event.data !== "string" || (!session.verified && event.data.length > 1_048_576)) return;
|
|
301
324
|
let frame: unknown;
|
|
302
325
|
try { frame = JSON.parse(event.data); } catch { return; }
|
|
303
326
|
if (!object(frame)) return;
|
|
@@ -320,7 +343,10 @@ export class DiscordDaemon {
|
|
|
320
343
|
private disconnectSession(session: DiscordSession): void {
|
|
321
344
|
if (this.sessions.get(session.sessionId) !== session) return;
|
|
322
345
|
this.sessions.delete(session.sessionId);
|
|
346
|
+
for (const [controller, id] of this.staging) if (id === session.sessionId) controller.abort();
|
|
347
|
+
this.relayDestinations.delete(session.sessionId);
|
|
323
348
|
for (const request of this.pending.values()) if (request.sessionId === session.sessionId) request.finish(false);
|
|
349
|
+
for (const request of this.remote.values()) if (request.sessionId === session.sessionId) request.finish({ ok: false });
|
|
324
350
|
}
|
|
325
351
|
|
|
326
352
|
private onSessionFrame(session: DiscordSession, frame: Record<string, unknown>): void {
|
|
@@ -330,6 +356,19 @@ export class DiscordDaemon {
|
|
|
330
356
|
return;
|
|
331
357
|
}
|
|
332
358
|
if (frame.sessionId !== session.sessionId) return;
|
|
359
|
+
if (frame.type === "discord_control" && typeof frame.enabled === "boolean" && Number.isSafeInteger(frame.generation) && (frame.generation as number) >= session.controlGeneration) {
|
|
360
|
+
if (session.controlGeneration !== frame.generation || !frame.enabled) {
|
|
361
|
+
for (const [controller, id] of this.staging) if (id === session.sessionId) controller.abort();
|
|
362
|
+
}
|
|
363
|
+
session.controlEnabled = frame.enabled;
|
|
364
|
+
session.controlGeneration = frame.generation as number;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (frame.type === "remote_final" && typeof frame.reqId === "string" && typeof frame.ok === "boolean") {
|
|
368
|
+
const request = this.remote.get(frame.reqId);
|
|
369
|
+
if (request?.sessionId === session.sessionId && request.acknowledged) request.finish({ ok: frame.ok && typeof frame.text === "string", text: typeof frame.text === "string" ? frame.text : undefined, artifactsAllowed: frame.artifactsAllowed === true, controlGeneration: typeof frame.controlGeneration === "number" && Number.isSafeInteger(frame.controlGeneration) ? frame.controlGeneration : undefined });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
333
372
|
const header = `[${session.sessionId}]`;
|
|
334
373
|
if (frame.type === "snapshot" && frame.pid === session.pid && Array.isArray(frame.subagents)) {
|
|
335
374
|
const records = frame.subagents.filter((r): r is AgentState => object(r) && typeof r.id === "string" &&
|
|
@@ -339,33 +378,47 @@ export class DiscordDaemon {
|
|
|
339
378
|
for (const record of records) {
|
|
340
379
|
const before = previous.get(record.id);
|
|
341
380
|
if ((!before && record.status === "running") || (before === "running" && record.status !== "running")) {
|
|
342
|
-
this.emit(`${header} ${record.role} '${record.id}' ${record.status}
|
|
381
|
+
this.emit(`${header} ${record.role} '${record.id}' ${record.status}`, session.sessionId);
|
|
343
382
|
}
|
|
344
383
|
}
|
|
345
384
|
session.records = records;
|
|
346
385
|
} else if (frame.type === "identity_header" && typeof frame.repo === "string") {
|
|
347
386
|
this.emit(`${header} ${frame.repo}${typeof frame.branch === "string" ? ` (${frame.branch})` : ""}`, session.sessionId);
|
|
348
|
-
} else if (frame.type === "
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
this.
|
|
387
|
+
} else if (frame.type === "local_relay_control" && typeof frame.destination === "string" && Number.isSafeInteger(frame.generation) && (frame.generation as number) >= 0) {
|
|
388
|
+
const destination = frame.destination;
|
|
389
|
+
this.relayDestinations.delete(session.sessionId);
|
|
390
|
+
const version = (this.relayVersions.get(session.sessionId) ?? 0) + 1;
|
|
391
|
+
this.relayVersions.set(session.sessionId, version);
|
|
392
|
+
const enabled = destination === this.config.channelId;
|
|
393
|
+
if (destination !== "off" && !enabled) this.deps.onError?.("Discord relay destination is not the configured channel");
|
|
394
|
+
void this.request(session, { type: "local_relay", sessionId: session.sessionId, enabled, generation: frame.generation }).then(ok => {
|
|
395
|
+
if (ok && enabled && this.relayVersions.get(session.sessionId) === version && this.sessions.get(session.sessionId) === session) this.relayDestinations.set(session.sessionId, destination);
|
|
396
|
+
});
|
|
397
|
+
} else if (frame.type === "local_final" && typeof frame.text === "string" && frame.text.trim()) {
|
|
398
|
+
const destination = this.relayDestinations.get(session.sessionId);
|
|
399
|
+
if (destination) this.emit(`${header}\n${frame.text}`, session.sessionId, destination, this.relayVersions.get(session.sessionId));
|
|
352
400
|
}
|
|
353
401
|
}
|
|
354
402
|
|
|
355
|
-
private emit(text: string, sessionId?: string):
|
|
356
|
-
if (this.stopped) return;
|
|
357
|
-
if (this.queued >= 128) { this.deps.onError?.("Discord outbound queue full"); return; }
|
|
403
|
+
private emit(text: string, sessionId?: string, destination = this.config.channelId, relayVersion?: number, guard?: () => boolean): Promise<boolean> {
|
|
404
|
+
if (this.stopped || this.queued >= 128) { this.deps.onError?.("Discord outbound queue full or stopped"); return Promise.resolve(false); }
|
|
358
405
|
this.queued++;
|
|
359
|
-
this.
|
|
360
|
-
|
|
361
|
-
|
|
406
|
+
const eligible = () => !this.stopped && (!guard || guard()) && (relayVersion === undefined || (!!sessionId && this.relayVersions.get(sessionId) === relayVersion && this.relayDestinations.get(sessionId) === destination));
|
|
407
|
+
const result = this.outgoing.then(async () => {
|
|
408
|
+
if (!eligible()) return false;
|
|
409
|
+
const messages = await this.api.sendMessage(destination, text, eligible);
|
|
362
410
|
this.rememberReplies(messages, sessionId);
|
|
411
|
+
return true;
|
|
363
412
|
}).catch(error => {
|
|
364
|
-
if (this.stopped)
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
413
|
+
if (!this.stopped) {
|
|
414
|
+
if (error instanceof DiscordApiError) this.rememberReplies(error.sentMessages, sessionId);
|
|
415
|
+
if (error instanceof DiscordApiError && error.status === 401) this.fail("Discord authentication denied");
|
|
416
|
+
else this.deps.onError?.("Discord notification delivery failed");
|
|
417
|
+
}
|
|
418
|
+
return false;
|
|
368
419
|
}).finally(() => { this.queued--; });
|
|
420
|
+
this.outgoing = result.then(() => {});
|
|
421
|
+
return result;
|
|
369
422
|
}
|
|
370
423
|
|
|
371
424
|
private rememberReplies(messages: { id: string }[], sessionId?: string): void {
|
|
@@ -400,44 +453,165 @@ export class DiscordDaemon {
|
|
|
400
453
|
return promise;
|
|
401
454
|
}
|
|
402
455
|
|
|
456
|
+
private remoteRequest(session: DiscordSession, text: string, reqId: string, controlGeneration: number): Promise<RemoteResult> {
|
|
457
|
+
if (this.remote.size >= 128 || this.pending.size >= 128) return Promise.resolve({ ok: false });
|
|
458
|
+
const { promise, resolve } = Promise.withResolvers<RemoteResult>();
|
|
459
|
+
const cancel = () => {
|
|
460
|
+
if (!this.send(session, { type: "remote_cancel", sessionId: session.sessionId, reqId })) {
|
|
461
|
+
try { session.ws.close(); } catch {}
|
|
462
|
+
this.disconnectSession(session);
|
|
463
|
+
}
|
|
464
|
+
finish({ ok: false });
|
|
465
|
+
};
|
|
466
|
+
const timer = this.later(cancel, 15 * 60_000);
|
|
467
|
+
const ackTimer = this.later(cancel, this.deps.ackTimeoutMs ?? 5_000);
|
|
468
|
+
const finish = (result: RemoteResult) => {
|
|
469
|
+
this.clear(timer); this.clear(ackTimer); this.pending.delete(reqId); this.remote.delete(reqId); resolve(result);
|
|
470
|
+
};
|
|
471
|
+
const remote = { sessionId: session.sessionId, acknowledged: false, finish };
|
|
472
|
+
this.remote.set(reqId, remote);
|
|
473
|
+
this.pending.set(reqId, { sessionId: session.sessionId, finish: ok => { this.clear(ackTimer); this.pending.delete(reqId); if (ok) remote.acknowledged = true; else finish({ ok: false }); } });
|
|
474
|
+
if (!this.send(session, { type: "remote_request", sessionId: session.sessionId, reqId, text, staged: true, controlGeneration })) finish({ ok: false });
|
|
475
|
+
return promise;
|
|
476
|
+
}
|
|
477
|
+
|
|
403
478
|
private async handleMessage(message: unknown): Promise<void> {
|
|
404
|
-
if (this.stopped || !object(message) || message.channel_id !==
|
|
479
|
+
if (this.stopped || !object(message) || typeof message.channel_id !== "string" || !/^\d{1,20}$/.test(message.channel_id) ||
|
|
405
480
|
typeof message.id !== "string" || !/^\d{1,20}$/.test(message.id) || !object(message.author) ||
|
|
406
481
|
typeof message.author.id !== "string" || !this.allowed.has(message.author.id) || message.author.id === this.botId ||
|
|
407
482
|
message.author.bot === true || (message.author.bot !== undefined && message.author.bot !== false) ||
|
|
408
483
|
"webhook_id" in message || ![0, 19].includes(message.type as number) ||
|
|
409
|
-
typeof message.content !== "string" || !message.content.trim() || message.content.length > 4_000 || this.seen.has(message.id)) return;
|
|
484
|
+
typeof message.content !== "string" || (!message.content.trim() && !(Array.isArray(message.attachments) && message.attachments.length)) || message.content.length > 4_000 || this.seen.has(message.id)) return;
|
|
410
485
|
this.seen.add(message.id);
|
|
411
486
|
if (this.seen.size > 2048) this.seen.delete(this.seen.values().next().value!);
|
|
412
|
-
const
|
|
487
|
+
const channelId = message.channel_id;
|
|
488
|
+
// Preserve the validated scalar values across the channel lookup await. TypeScript
|
|
489
|
+
// intentionally does not retain narrowing through an await on a mutable object.
|
|
490
|
+
const authorId = message.author.id;
|
|
491
|
+
const messageId = message.id;
|
|
492
|
+
if (channelId !== this.config.channelId && !this.threads.has(channelId)) {
|
|
493
|
+
const channel = await this.api.getChannel(channelId).catch(() => undefined);
|
|
494
|
+
if (!channel || ![10, 11, 12].includes(channel.type) || channel.parent_id !== this.config.channelId || channel.owner_id !== authorId) return;
|
|
495
|
+
this.threads.set(channelId, authorId);
|
|
496
|
+
}
|
|
497
|
+
if ((channelId !== this.config.channelId || [10, 11, 12].includes(this.channelType)) && this.threads.get(channelId) !== authorId) return;
|
|
498
|
+
let text = message.content.trim();
|
|
499
|
+
for (const mention of [`<@${this.botId}>`, `<@!${this.botId}>`]) {
|
|
500
|
+
if (text === mention || text.startsWith(`${mention} `) || text.startsWith(`${mention}\n`)) {
|
|
501
|
+
text = text.slice(mention.length).trimStart();
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (!text && !(Array.isArray(message.attachments) && message.attachments.length)) return;
|
|
506
|
+
let destination = channelId;
|
|
507
|
+
try {
|
|
508
|
+
if (channelId === this.config.channelId && [0, 5].includes(this.channelType)) {
|
|
509
|
+
destination = (await this.api.createThread(channelId, messageId, "jeo reply")).id;
|
|
510
|
+
this.threads.set(destination, authorId);
|
|
511
|
+
}
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (error instanceof DiscordApiError && error.status === 401) this.fail("Discord authentication denied");
|
|
514
|
+
else { this.deps.onError?.("Discord thread creation failed"); void this.emit("Unable to create a reply thread; check bot permissions.", undefined, channelId); }
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const reaction = async (emoji: string, remove = false) => {
|
|
518
|
+
try { await (remove ? this.api.removeReaction(channelId, messageId, emoji) : this.api.addReaction(channelId, messageId, emoji)); }
|
|
519
|
+
catch (error) {
|
|
520
|
+
if (error instanceof DiscordApiError && error.status === 401) this.fail("Discord authentication denied");
|
|
521
|
+
else this.deps.onError?.("Discord reaction failed");
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
const progress = reaction("⏳");
|
|
525
|
+
try {
|
|
526
|
+
const ok = await this.handleCommand(message, text, destination);
|
|
527
|
+
void progress.then(() => reaction("⏳", true)).then(() => reaction(ok ? "✅" : "❌"));
|
|
528
|
+
} catch {
|
|
529
|
+
void progress.then(() => reaction("⏳", true)).then(() => reaction("❌"));
|
|
530
|
+
this.deps.onError?.("Discord command relay failed");
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private async handleCommand(message: Record<string, unknown>, text: string, destination: string): Promise<boolean> {
|
|
535
|
+
const emit = (content: string, sessionId?: string) => this.emit(content, sessionId, destination);
|
|
536
|
+
const sendPrompt = async (session: DiscordSession, body: string): Promise<boolean> => {
|
|
537
|
+
if (/^[\/!$]/.test(body) || body.includes("\u0003")) {
|
|
538
|
+
await emit("Remote commands are disabled; send ordinary prompt text.");
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
if (!session.controlEnabled) {
|
|
542
|
+
await emit("Discord remote control is disabled in this session.", session.sessionId);
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
const controlGeneration = session.controlGeneration;
|
|
546
|
+
if (this.stopped || this.staging.size >= 8) {
|
|
547
|
+
await emit("Too many requests are being prepared; try again later.", session.sessionId);
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
const controller = new AbortController();
|
|
551
|
+
this.staging.set(controller, session.sessionId);
|
|
552
|
+
let output: DiscordArtifactStage | undefined;
|
|
553
|
+
let input: DiscordAttachmentStage | undefined;
|
|
554
|
+
try {
|
|
555
|
+
const reqId = crypto.randomUUID();
|
|
556
|
+
output = await createDiscordArtifactStage(session.sessionId, reqId);
|
|
557
|
+
if (Array.isArray(message.attachments) && message.attachments.length) input = await (this.deps.stageAttachments ?? stageDiscordAttachments)(message.attachments, { directory: path.join(output.requestDirectory, "input"), signal: controller.signal });
|
|
558
|
+
if (controller.signal.aborted || this.sessions.get(session.sessionId) !== session || !session.controlEnabled || session.controlGeneration !== controlGeneration) return false;
|
|
559
|
+
const result = await this.remoteRequest(session, body || "Please inspect the attached files.", reqId, controlGeneration);
|
|
560
|
+
if (!result.ok || result.text === undefined) {
|
|
561
|
+
await emit("Session request failed, was rejected, interrupted, or timed out.", session.sessionId);
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
const eligible = () => !this.stopped && this.sessions.get(session.sessionId) === session && session.controlEnabled && result.controlGeneration !== undefined && session.controlGeneration === result.controlGeneration;
|
|
565
|
+
if (!eligible()) return false;
|
|
566
|
+
const reply = result.text.split(output.requestDirectory).join("[Discord attachment directory]");
|
|
567
|
+
if (!await this.emit(reply, session.sessionId, destination, undefined, eligible)) return false;
|
|
568
|
+
if (result.artifactsAllowed !== true) {
|
|
569
|
+
if (eligible() && (await fs.readdir(output.directory)).length) await this.emit("File delivery was not authorized for this session response.", session.sessionId, destination, undefined, eligible);
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
const files = await output.collect();
|
|
573
|
+
for (const file of files) {
|
|
574
|
+
if (!eligible()) return false;
|
|
575
|
+
const sent = await this.api.sendFile(destination, file.name, file.data, eligible);
|
|
576
|
+
this.rememberReplies(sent, session.sessionId);
|
|
577
|
+
}
|
|
578
|
+
return true;
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (error instanceof DiscordApiError && error.status === 401) this.fail("Discord authentication denied");
|
|
581
|
+
this.deps.onError?.("Discord attachment processing or delivery failed");
|
|
582
|
+
await emit("Attachment processing or delivery failed; check file size, type, and bot permissions.", session.sessionId);
|
|
583
|
+
return false;
|
|
584
|
+
} finally {
|
|
585
|
+
await input?.cleanup().catch(() => this.deps.onError?.("Discord input cleanup failed"));
|
|
586
|
+
await output?.cleanup().catch(() => this.deps.onError?.("Discord output cleanup failed"));
|
|
587
|
+
this.staging.delete(controller);
|
|
588
|
+
}
|
|
589
|
+
};
|
|
413
590
|
if (text === "/sessions" || text === "/subagents") {
|
|
414
591
|
const sessions = [...this.sessions.values()].filter(s => s.verified && s.ws.readyState === 1);
|
|
415
|
-
|
|
416
|
-
return;
|
|
592
|
+
return emit(sessions.length ? sessions.map(s => `[${s.sessionId}] ${s.cwd}${text === "/subagents" ? s.records.map(r => `\n ${r.id}: ${r.status} ${r.role} — ${r.task}`).join("") : ""}`).join("\n") : "No connected sessions.");
|
|
417
593
|
}
|
|
418
|
-
if (text === "/help")
|
|
594
|
+
if (text === "/help") return emit(HELP);
|
|
419
595
|
const command = /^\/(send|steer|cancel)\s+(\S+)\s+([\s\S]+)$/.exec(text);
|
|
420
596
|
if (command) {
|
|
421
597
|
const session = this.findSession(command[2]!);
|
|
422
|
-
if (!session) {
|
|
598
|
+
if (!session) { await emit("Session not found or prefix ambiguous. Use /sessions and a unique ID."); return false; }
|
|
423
599
|
const body = command[3]!.trim();
|
|
424
|
-
if (command[1] === "send")
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
}
|
|
433
|
-
return;
|
|
600
|
+
if (command[1] === "send") return sendPrompt(session, body);
|
|
601
|
+
const args = command[1] === "steer" ? /^(\S+)\s+([\s\S]+)$/.exec(body) : /^(\S+)$/.exec(body);
|
|
602
|
+
if (!args) { await emit(HELP); return false; }
|
|
603
|
+
if (!session.controlEnabled) { await emit("Discord remote control is disabled in this session.", session.sessionId); return false; }
|
|
604
|
+
const control = { sessionId: session.sessionId, controlGeneration: session.controlGeneration };
|
|
605
|
+
const ok = await this.request(session, command[1] === "steer" ? { ...control, type: "discord_steer", id: args[1], message: args[2] } : { ...control, type: "discord_cancel", ids: [args[1]] });
|
|
606
|
+
const delivered = await emit(ok ? "Session acknowledged command." : "Command not acknowledged (unavailable, rejected or timed out).", session.sessionId);
|
|
607
|
+
return ok && delivered;
|
|
434
608
|
}
|
|
435
|
-
if (text.startsWith("/")) {
|
|
609
|
+
if (text.startsWith("/")) { await emit(HELP); return false; }
|
|
436
610
|
const reference = object(message.message_reference) ? message.message_reference : undefined;
|
|
437
|
-
const reply = reference && typeof reference.message_id === "string" && (reference.channel_id === undefined || reference.channel_id ===
|
|
611
|
+
const reply = reference && typeof reference.message_id === "string" && (reference.channel_id === undefined || reference.channel_id === message.channel_id)
|
|
438
612
|
? this.replies.get(reference.message_id) : undefined;
|
|
439
613
|
const session = reply && (this.deps.now ?? Date.now)() - reply.at < 86_400_000 ? this.findSession(reply.sessionId) : undefined;
|
|
440
|
-
if (!session) {
|
|
441
|
-
|
|
614
|
+
if (!session) { await emit("Reply target is unknown or expired. Use /send <unique-session> <text> after /sessions."); return false; }
|
|
615
|
+
return sendPrompt(session, text);
|
|
442
616
|
}
|
|
443
617
|
}
|
|
@@ -44,6 +44,7 @@ import * as fs from "node:fs/promises";
|
|
|
44
44
|
import type { Server, ServerWebSocket } from "bun";
|
|
45
45
|
import { readGlobalConfig } from "../state";
|
|
46
46
|
import { notifySessionEndpointPath, notifySessionsDir } from "./paths";
|
|
47
|
+
import { resolveDiscordArtifactStage, type DiscordArtifactPaths } from "./discord-artifacts";
|
|
47
48
|
import type { SubagentRecord, SubagentRegistry } from "../subagent-registry";
|
|
48
49
|
|
|
49
50
|
const SNAPSHOT_POLL_MS = 1_200;
|
|
@@ -75,6 +76,13 @@ export class SessionNotifyEndpoint {
|
|
|
75
76
|
private server: Server<undefined> | undefined;
|
|
76
77
|
|
|
77
78
|
private readonly sockets = new Set<ServerWebSocket<undefined>>();
|
|
79
|
+
private readonly relaySockets = new Set<ServerWebSocket<undefined>>();
|
|
80
|
+
private readonly pendingRelay = new Map<ServerWebSocket<undefined>, number>();
|
|
81
|
+
private relayRequestGeneration = 0;
|
|
82
|
+
private readonly remoteControllers = new Map<ServerWebSocket<undefined>, { reqId: string; controller: AbortController }>();
|
|
83
|
+
private relayGeneration = 0;
|
|
84
|
+
private discordControlEnabled = false;
|
|
85
|
+
private discordControlGeneration = 0;
|
|
78
86
|
private pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
79
87
|
private lastSnapshotJson = "";
|
|
80
88
|
/** `undefined` between turns — subagent requests report an empty registry. */
|
|
@@ -85,6 +93,8 @@ export class SessionNotifyEndpoint {
|
|
|
85
93
|
|
|
86
94
|
/** Registered by launch.ts; absent or rejected handlers return a failed ACK. */
|
|
87
95
|
onUserMessage: ((msg: RemoteUserMessage) => void | boolean | Promise<void | boolean>) | undefined;
|
|
96
|
+
onRemoteRequest: ((text: string, finish: (text?: string, artifactsAllowed?: boolean) => void, signal: AbortSignal, stage?: DiscordArtifactPaths) => boolean) | undefined;
|
|
97
|
+
onRegistryMutation: (() => void) | undefined;
|
|
88
98
|
onConfigCommand: ((cmd: RemoteConfigCommand) => void) | undefined;
|
|
89
99
|
|
|
90
100
|
constructor(
|
|
@@ -153,6 +163,61 @@ export class SessionNotifyEndpoint {
|
|
|
153
163
|
this.broadcast(JSON.stringify({ type: "turn_stream", sessionId: this.sessionId, phase: "finalized", text }));
|
|
154
164
|
}
|
|
155
165
|
|
|
166
|
+
/** Revoke current subscriptions before asking a daemon to change destination. */
|
|
167
|
+
requestLocalRelay(destination: string): number {
|
|
168
|
+
this.relaySockets.clear();
|
|
169
|
+
this.relayGeneration++;
|
|
170
|
+
this.pendingRelay.clear();
|
|
171
|
+
const generation = ++this.relayRequestGeneration;
|
|
172
|
+
const json = JSON.stringify({ type: "local_relay_control", sessionId: this.sessionId, destination, generation });
|
|
173
|
+
let sent = 0;
|
|
174
|
+
for (const ws of this.sockets) {
|
|
175
|
+
try { if (ws.send(json) > 0) { if (destination !== "off") this.pendingRelay.set(ws, generation); sent++; continue; } } catch {}
|
|
176
|
+
this.sockets.delete(ws);
|
|
177
|
+
this.remoteControllers.get(ws)?.controller.abort();
|
|
178
|
+
this.remoteControllers.delete(ws);
|
|
179
|
+
try { ws.terminate(); } catch { try { ws.close(); } catch {} }
|
|
180
|
+
}
|
|
181
|
+
return sent;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Snapshot eligibility before a turn; changes invalidate its relay. */
|
|
185
|
+
localRelayGeneration(): number | undefined {
|
|
186
|
+
return this.relaySockets.size ? this.relayGeneration : undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
sendLocalFinal(text: string, generation: number): void {
|
|
190
|
+
if (generation !== this.relayGeneration) return;
|
|
191
|
+
const json = JSON.stringify({ type: "local_final", sessionId: this.sessionId, text });
|
|
192
|
+
for (const ws of this.relaySockets) {
|
|
193
|
+
try { ws.send(json); } catch {}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private sendDiscordControl(ws: ServerWebSocket<undefined>): void {
|
|
198
|
+
try {
|
|
199
|
+
const frame = JSON.stringify({ type: "discord_control", sessionId: this.sessionId, enabled: this.discordControlEnabled, generation: this.discordControlGeneration });
|
|
200
|
+
if (ws.send(frame) > 0) return;
|
|
201
|
+
} catch {}
|
|
202
|
+
this.sockets.delete(ws);
|
|
203
|
+
this.pendingRelay.delete(ws);
|
|
204
|
+
if (this.relaySockets.delete(ws)) this.relayGeneration++;
|
|
205
|
+
this.remoteControllers.get(ws)?.controller.abort();
|
|
206
|
+
this.remoteControllers.delete(ws);
|
|
207
|
+
try { ws.terminate(); } catch { try { ws.close(); } catch {} }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
setDiscordControlEnabled(enabled: boolean): void {
|
|
211
|
+
if (enabled === this.discordControlEnabled) return;
|
|
212
|
+
this.discordControlEnabled = enabled;
|
|
213
|
+
this.discordControlGeneration++;
|
|
214
|
+
for (const ws of this.sockets) this.sendDiscordControl(ws);
|
|
215
|
+
if (!enabled) {
|
|
216
|
+
for (const remote of this.remoteControllers.values()) remote.controller.abort();
|
|
217
|
+
this.remoteControllers.clear();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
156
221
|
private async handleMessage(ws: ServerWebSocket<undefined>, raw: string | Buffer): Promise<void> {
|
|
157
222
|
if (Buffer.byteLength(raw) > MAX_INBOUND_BYTES) return;
|
|
158
223
|
let msg: Record<string, unknown>;
|
|
@@ -162,25 +227,71 @@ export class SessionNotifyEndpoint {
|
|
|
162
227
|
return;
|
|
163
228
|
}
|
|
164
229
|
if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
|
|
165
|
-
if (msg.sessionId !== undefined && msg.sessionId !== this.sessionId) return;
|
|
230
|
+
if (msg.type !== "remote_request" && msg.sessionId !== undefined && msg.sessionId !== this.sessionId) return;
|
|
231
|
+
if (msg.type === "remote_request") {
|
|
232
|
+
const reqId = msg.reqId;
|
|
233
|
+
let ok = false;
|
|
234
|
+
const onlyExpectedFields = Object.keys(msg).every(key => ["type", "sessionId", "reqId", "text", "staged", "controlGeneration"].includes(key));
|
|
235
|
+
if (onlyExpectedFields && this.discordControlEnabled && msg.controlGeneration === this.discordControlGeneration && msg.sessionId === this.sessionId && typeof reqId === "string" && reqId.length > 0 && reqId.length <= 256 &&
|
|
236
|
+
typeof msg.text === "string" && !!msg.text.trim() && !msg.text.includes("\u0003") && !/^[\/!$]/.test(msg.text.trimStart()) && (msg.staged === undefined || msg.staged === true) && this.onRemoteRequest && !this.remoteControllers.has(ws)) {
|
|
237
|
+
let finished = false;
|
|
238
|
+
const controlGeneration = this.discordControlGeneration;
|
|
239
|
+
const controller = new AbortController();
|
|
240
|
+
this.remoteControllers.set(ws, { reqId, controller });
|
|
241
|
+
let stage: DiscordArtifactPaths | undefined;
|
|
242
|
+
const finish = (text?: string, artifactsAllowed = false): void => {
|
|
243
|
+
if (finished) return;
|
|
244
|
+
finished = true;
|
|
245
|
+
if (this.remoteControllers.get(ws)?.controller === controller) this.remoteControllers.delete(ws);
|
|
246
|
+
if (!this.sockets.has(ws)) return;
|
|
247
|
+
if (!this.discordControlEnabled || controller.signal.aborted || controlGeneration !== this.discordControlGeneration) text = undefined;
|
|
248
|
+
if (!stage) artifactsAllowed = false;
|
|
249
|
+
try { ws.send(JSON.stringify({ type: "remote_final", sessionId: this.sessionId, reqId, controlGeneration, ok: text !== undefined, artifactsAllowed: text !== undefined && artifactsAllowed, ...(text !== undefined ? { text } : {}) })); } catch {}
|
|
250
|
+
};
|
|
251
|
+
try {
|
|
252
|
+
if (msg.staged === true) stage = await resolveDiscordArtifactStage(this.sessionId, reqId);
|
|
253
|
+
if (this.discordControlEnabled && controlGeneration === this.discordControlGeneration && !controller.signal.aborted && this.sockets.has(ws)) {
|
|
254
|
+
ok = this.onRemoteRequest(msg.text, finish, controller.signal, stage);
|
|
255
|
+
}
|
|
256
|
+
} catch {}
|
|
257
|
+
if (!ok && this.remoteControllers.get(ws)?.controller === controller) this.remoteControllers.delete(ws);
|
|
258
|
+
}
|
|
259
|
+
try { ws.send(JSON.stringify({ type: "ack", reqId, ok })); } catch {}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (msg.type === "remote_cancel" && msg.sessionId === this.sessionId && typeof msg.reqId === "string") {
|
|
263
|
+
const remote = this.remoteControllers.get(ws);
|
|
264
|
+
if (remote?.reqId === msg.reqId) remote.controller.abort();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (msg.type === "local_relay" && msg.sessionId === this.sessionId && typeof msg.enabled === "boolean") {
|
|
268
|
+
const allowed = !msg.enabled || (typeof msg.generation === "number" && this.pendingRelay.get(ws) === msg.generation && msg.generation === this.relayRequestGeneration);
|
|
269
|
+
if (allowed && msg.enabled) this.relaySockets.add(ws);
|
|
270
|
+
else if (!msg.enabled) this.relaySockets.delete(ws);
|
|
271
|
+
if (allowed) { this.pendingRelay.delete(ws); this.relayGeneration++; }
|
|
272
|
+
try { ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok: allowed })); } catch {}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
166
275
|
if (msg.type === "list") {
|
|
167
276
|
try {
|
|
168
277
|
ws.send(JSON.stringify(this.snapshot()));
|
|
169
278
|
} catch {}
|
|
170
279
|
return;
|
|
171
280
|
}
|
|
172
|
-
if (msg.type === "steer") {
|
|
281
|
+
if (msg.type === "steer" || msg.type === "discord_steer") {
|
|
173
282
|
const id = typeof msg.id === "string" ? msg.id : "";
|
|
174
283
|
const message = typeof msg.message === "string" ? msg.message : "";
|
|
175
|
-
const ok = !!(id && message && this.registry?.steer(id, message));
|
|
284
|
+
const ok = !!((msg.type === "steer" || (this.discordControlEnabled && msg.sessionId === this.sessionId && msg.controlGeneration === this.discordControlGeneration)) && id && message && this.registry?.steer(id, message));
|
|
285
|
+
if (ok) this.onRegistryMutation?.();
|
|
176
286
|
try {
|
|
177
287
|
ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok }));
|
|
178
288
|
} catch {}
|
|
179
289
|
return;
|
|
180
290
|
}
|
|
181
|
-
if (msg.type === "cancel") {
|
|
291
|
+
if (msg.type === "cancel" || msg.type === "discord_cancel") {
|
|
182
292
|
const ids = Array.isArray(msg.ids) ? msg.ids.filter((x): x is string => typeof x === "string") : [];
|
|
183
|
-
const cancelled = ids.length > 0 ? (this.registry?.cancel(ids) ?? []) : [];
|
|
293
|
+
const cancelled = (msg.type === "cancel" || (this.discordControlEnabled && msg.sessionId === this.sessionId && msg.controlGeneration === this.discordControlGeneration)) && ids.length > 0 ? (this.registry?.cancel(ids) ?? []) : [];
|
|
294
|
+
if (cancelled.length) this.onRegistryMutation?.();
|
|
184
295
|
try {
|
|
185
296
|
ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok: cancelled.length > 0 }));
|
|
186
297
|
ws.send(JSON.stringify(this.snapshot()));
|
|
@@ -232,10 +343,15 @@ export class SessionNotifyEndpoint {
|
|
|
232
343
|
ws.send(JSON.stringify(this.snapshot()));
|
|
233
344
|
if (this.identityFrame) ws.send(this.identityFrame);
|
|
234
345
|
} catch {}
|
|
346
|
+
this.sendDiscordControl(ws);
|
|
235
347
|
},
|
|
236
348
|
message: (ws, raw) => { void this.handleMessage(ws, raw); },
|
|
237
349
|
close: ws => {
|
|
238
350
|
this.sockets.delete(ws);
|
|
351
|
+
this.pendingRelay.delete(ws);
|
|
352
|
+
this.remoteControllers.get(ws)?.controller.abort();
|
|
353
|
+
this.remoteControllers.delete(ws);
|
|
354
|
+
if (this.relaySockets.delete(ws)) this.relayGeneration++;
|
|
239
355
|
},
|
|
240
356
|
},
|
|
241
357
|
});
|
|
@@ -279,6 +395,12 @@ export class SessionNotifyEndpoint {
|
|
|
279
395
|
clearInterval(this.pollTimer);
|
|
280
396
|
this.pollTimer = undefined;
|
|
281
397
|
}
|
|
398
|
+
this.setDiscordControlEnabled(false);
|
|
399
|
+
this.relaySockets.clear();
|
|
400
|
+
this.pendingRelay.clear();
|
|
401
|
+
this.relayGeneration++;
|
|
402
|
+
for (const remote of this.remoteControllers.values()) remote.controller.abort();
|
|
403
|
+
this.remoteControllers.clear();
|
|
282
404
|
for (const ws of this.sockets) {
|
|
283
405
|
try {
|
|
284
406
|
ws.close();
|
package/src/agent/session.ts
CHANGED
|
@@ -76,7 +76,7 @@ export function newSessionId(): string {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
export function sessionsDir(cwd = process.cwd()): string {
|
|
79
|
-
return path.join(getLocalJeoDir(cwd), "sessions");
|
|
79
|
+
return process.env.JEO_SESSION_DIR || path.join(getLocalJeoDir(cwd), "sessions");
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
export function sessionPath(id: string, cwd = process.cwd()): string {
|
|
@@ -86,9 +86,11 @@ export function sessionPath(id: string, cwd = process.cwd()): string {
|
|
|
86
86
|
export async function createSession(
|
|
87
87
|
cwd = process.cwd(),
|
|
88
88
|
id = newSessionId(),
|
|
89
|
-
model?: string
|
|
89
|
+
model?: string,
|
|
90
|
+
directory = sessionsDir(cwd),
|
|
91
|
+
exclusive = false,
|
|
90
92
|
): Promise<{ id: string; path: string }> {
|
|
91
|
-
const dir =
|
|
93
|
+
const dir = directory;
|
|
92
94
|
await fs.mkdir(dir, { recursive: true });
|
|
93
95
|
|
|
94
96
|
const header: SessionHeader = {
|
|
@@ -100,8 +102,8 @@ export async function createSession(
|
|
|
100
102
|
...(model ? { model } : {}),
|
|
101
103
|
};
|
|
102
104
|
|
|
103
|
-
const file =
|
|
104
|
-
await fs.writeFile(file, JSON.stringify(header) + "\n", "utf8");
|
|
105
|
+
const file = path.join(dir, `${id}.jsonl`);
|
|
106
|
+
await fs.writeFile(file, JSON.stringify(header) + "\n", { encoding: "utf8", ...(exclusive ? { flag: "wx", mode: 0o600 } : {}) });
|
|
105
107
|
|
|
106
108
|
return { id, path: file };
|
|
107
109
|
}
|
package/src/cli/runner.ts
CHANGED
|
@@ -119,6 +119,15 @@ export const COMMANDS: readonly CommandSpec[] = [
|
|
|
119
119
|
return args => m.runNotifyCommand(args);
|
|
120
120
|
},
|
|
121
121
|
},
|
|
122
|
+
{
|
|
123
|
+
name: "messenger",
|
|
124
|
+
summary: "Run or configure the bidirectional Discord, Telegram, and Slack agent gateway.",
|
|
125
|
+
usage: "messenger [setup|check|run] --config <file> [--channel ID] [--user ID] [--tools LIST] [--artifacts] [--service]",
|
|
126
|
+
loader: async () => {
|
|
127
|
+
const m = await import("../commands/messenger");
|
|
128
|
+
return args => m.runMessengerCommand(args);
|
|
129
|
+
},
|
|
130
|
+
},
|
|
122
131
|
{
|
|
123
132
|
name: "daemon",
|
|
124
133
|
summary: "Manage the shared Telegram/Discord/Slack notification daemon.",
|
package/src/cli.ts
CHANGED
|
@@ -44,6 +44,10 @@ const fatal = (err: unknown): never => {
|
|
|
44
44
|
};
|
|
45
45
|
process.on("uncaughtException", fatal);
|
|
46
46
|
process.on("unhandledRejection", fatal);
|
|
47
|
+
// Bun 1.4 may report a closed downstream pipe as a stream error event instead of
|
|
48
|
+
// promoting it to uncaughtException. Keep both runtime shapes on the same quiet path.
|
|
49
|
+
process.stdout.on("error", fatal);
|
|
50
|
+
process.stderr.on("error", fatal);
|
|
47
51
|
|
|
48
52
|
try {
|
|
49
53
|
const code = await dispatch(process.argv.slice(2), { appName: APP_NAME, version: VERSION });
|