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.
@@ -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}\n${record.status === "running" ? record.task : record.result ?? ""}`, session.sessionId);
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 === "context_update" && ["turn_start", "turn_end"].includes(String(frame.phase)) && typeof frame.summary === "string") {
349
- this.emit(`${header} ${frame.phase}\n${frame.summary}`, session.sessionId);
350
- } else if (frame.type === "turn_stream" && frame.phase === "finalized" && typeof frame.text === "string" && frame.text.trim()) {
351
- this.emit(`${header}\n${frame.text}`, session.sessionId);
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): void {
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.outgoing = this.outgoing.then(async () => {
360
- if (this.stopped) return;
361
- const messages = await this.api.sendMessage(this.config.channelId, text);
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) return;
365
- if (error instanceof DiscordApiError) this.rememberReplies(error.sentMessages, sessionId);
366
- if (error instanceof DiscordApiError && (error.status === 401 || error.status === 403)) this.fail("Discord authentication or permission denied");
367
- else this.deps.onError?.("Discord notification delivery failed");
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 !== this.config.channelId ||
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 text = message.content.trim();
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
- this.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.");
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") { this.emit(HELP); return; }
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) { this.emit("Session not found or prefix ambiguous. Use /sessions and a unique ID."); return; }
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
- if (body.startsWith("/")) { this.emit("Remote slash commands are disabled; send ordinary prompt text."); return; }
426
- this.emit(this.send(session, { type: "user_message", text: body }) ? "Forwarded to session (not an execution acknowledgement)." : "Session unavailable.", session.sessionId);
427
- } else {
428
- const args = command[1] === "steer" ? /^(\S+)\s+([\s\S]+)$/.exec(body) : /^(\S+)$/.exec(body);
429
- if (!args) { this.emit(HELP); return; }
430
- const ok = await this.request(session, command[1] === "steer" ? { type: "steer", id: args[1], message: args[2] } : { type: "cancel", ids: [args[1]] });
431
- this.emit(ok ? "Session acknowledged command." : "Command not acknowledged (unavailable, rejected or timed out).", session.sessionId);
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("/")) { this.emit(HELP); return; }
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 === this.config.channelId)
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) { this.emit("Reply target is unknown or expired. Use /send <unique-session> <text> after /sessions."); return; }
441
- this.emit(this.send(session, { type: "user_message", text }) ? "Forwarded to session (not an execution acknowledgement)." : "Session unavailable.", session.sessionId);
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();
@@ -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 = sessionsDir(cwd);
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 = sessionPath(id, cwd);
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 });