jeo-code 0.12.7 → 0.12.9

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.
@@ -0,0 +1,164 @@
1
+ import { lstat, mkdir, open, realpath, rm } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
+
4
+ /** Limit enforced on presented attachment entries, before validation or deduplication. */
5
+ export const MAX_ARTIFACTS = 10;
6
+ const MAX_FILE_BYTES = 25 * 1024 * 1024;
7
+ const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
8
+ export const DISCORD_ATTACHMENT_CDN_HOSTS = ["cdn.discordapp.com", "media.discordapp.net"] as const;
9
+ export const MAX_DISCORD_ATTACHMENT_REDIRECTS = 5;
10
+
11
+ interface Attachment {
12
+ filename: string;
13
+ url: string;
14
+ size: number;
15
+ }
16
+
17
+ export interface DiscordAttachmentOptions {
18
+ directory: string;
19
+ signal?: AbortSignal;
20
+ fetchImpl?: typeof fetch;
21
+ }
22
+
23
+ export interface DiscordAttachmentStage {
24
+ files: string[];
25
+ cleanup: () => Promise<void>;
26
+ }
27
+
28
+ /**
29
+ * Parse and validate one Discord CDN URL without accepting normalization that
30
+ * could conceal an ambiguous authority. Host matching is exact after URL's
31
+ * ASCII/case normalization; Unicode, percent-encoded authorities, trailing
32
+ * dots, user-info, and explicit ports are rejected.
33
+ */
34
+ export function validateDiscordAttachmentUrl(value: string): URL {
35
+ if (typeof value !== "string") throw new Error("Untrusted Discord attachment URL");
36
+ const authority = value.match(/^https:\/\/([^/?#]*)/i)?.[1];
37
+ if (!authority || /[^\x00-\x7f]/.test(authority) || authority.includes("%") || authority.endsWith(".")) {
38
+ throw new Error("Untrusted Discord attachment URL");
39
+ }
40
+ if (authority.includes("@") || authority.includes(":")) throw new Error("Untrusted Discord attachment URL");
41
+ let url: URL;
42
+ try { url = new URL(value); } catch { throw new Error("Invalid Discord attachment URL"); }
43
+ if (url.protocol !== "https:" ||
44
+ !(DISCORD_ATTACHMENT_CDN_HOSTS as readonly string[]).includes(url.hostname) ||
45
+ url.username || url.password || url.port) {
46
+ throw new Error("Untrusted Discord attachment URL");
47
+ }
48
+ return url;
49
+ }
50
+
51
+ async function fetchTrustedAttachment(
52
+ initialUrl: string,
53
+ fetchImpl: typeof fetch,
54
+ signal: AbortSignal,
55
+ ): Promise<Response> {
56
+ let current = validateDiscordAttachmentUrl(initialUrl);
57
+ for (let redirects = 0; ; redirects++) {
58
+ const response = await fetchImpl(current.href, { redirect: "manual", signal });
59
+ if (response.status >= 300 && response.status < 400) {
60
+ const location = response.headers.get("location");
61
+ await response.body?.cancel().catch(() => {});
62
+ if (!location || redirects >= MAX_DISCORD_ATTACHMENT_REDIRECTS) throw new Error("Discord attachment redirect rejected");
63
+ // Validate an authority-bearing Location before URL resolution can erase
64
+ // percent-encoding, IDNA, case, or default-port evidence.
65
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(location)) validateDiscordAttachmentUrl(location);
66
+ else if (location.startsWith("//")) validateDiscordAttachmentUrl(`${current.protocol}${location}`);
67
+ current = validateDiscordAttachmentUrl(new URL(location, current).href);
68
+ continue;
69
+ }
70
+ // Native fetch exposes the final URL. Test doubles commonly leave it blank.
71
+ if (response.url) validateDiscordAttachmentUrl(response.url);
72
+ return response;
73
+ }
74
+ }
75
+
76
+ /** Staged files are private and must be removed with cleanup after the request finishes. */
77
+ export async function stageDiscordAttachments(
78
+ attachments: unknown[],
79
+ options: DiscordAttachmentOptions,
80
+ ): Promise<DiscordAttachmentStage> {
81
+ // Every presented entry consumes the limit, including duplicates and entries
82
+ // that would later fail metadata validation. Overflow rejects the whole input.
83
+ if (!Array.isArray(attachments) || attachments.length > MAX_ARTIFACTS) throw new Error("Invalid Discord attachment count");
84
+ const validated: Attachment[] = [];
85
+ let declaredTotal = 0;
86
+ for (const value of attachments) {
87
+ if (!value || typeof value !== "object") throw new Error("Invalid Discord attachment");
88
+ const item = value as Record<string, unknown>;
89
+ if (typeof item.filename !== "string" || !item.filename || typeof item.url !== "string" ||
90
+ !Number.isSafeInteger(item.size) || (item.size as number) < 0 || (item.size as number) > MAX_FILE_BYTES) {
91
+ throw new Error("Invalid Discord attachment metadata");
92
+ }
93
+ const url = validateDiscordAttachmentUrl(item.url);
94
+ declaredTotal += item.size as number;
95
+ if (declaredTotal > MAX_TOTAL_BYTES) throw new Error("Discord attachments exceed total size limit");
96
+ validated.push({ filename: item.filename, url: url.href, size: item.size as number });
97
+ }
98
+ if (options.signal?.aborted) throw new Error("Discord attachment download aborted");
99
+ const directory = options.directory;
100
+ if (typeof directory !== "string" || !isAbsolute(directory) || resolve(directory) !== directory || basename(directory) !== "input") {
101
+ throw new Error("Invalid Discord attachment staging directory");
102
+ }
103
+ const parent = dirname(directory);
104
+ const info = await lstat(parent);
105
+ if (!info.isDirectory() || (info.mode & 0o077) !== 0 ||
106
+ (typeof process.getuid === "function" && info.uid !== process.getuid()) || await realpath(parent) !== parent) {
107
+ throw new Error("Untrusted Discord attachment staging directory");
108
+ }
109
+ await mkdir(directory, { mode: 0o700 });
110
+ const cleanup = () => rm(directory, { recursive: true, force: true });
111
+ try {
112
+ const files: string[] = [];
113
+ let total = 0;
114
+ for (const [index, item] of validated.entries()) {
115
+ const name = item.filename.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^\.+/, "_").slice(0, 180) || "attachment";
116
+ const path = join(directory, `${index + 1}-${name}`);
117
+ const signal = AbortSignal.any([...(options.signal ? [options.signal] : []), AbortSignal.timeout(30_000)]);
118
+ let response: Response;
119
+ try {
120
+ response = await fetchTrustedAttachment(item.url, options.fetchImpl ?? fetch, signal);
121
+ } catch {
122
+ throw new Error("Discord attachment download failed or aborted");
123
+ }
124
+ if (!response.ok || !response.body) {
125
+ await response.body?.cancel().catch(() => {});
126
+ throw new Error("Discord attachment download failed");
127
+ }
128
+ const file = await open(path, "wx", 0o600).catch(async () => {
129
+ await response.body?.cancel().catch(() => {});
130
+ throw new Error("Discord attachment staging failed");
131
+ });
132
+ const reader = response.body.getReader();
133
+ let size = 0;
134
+ try {
135
+ for (;;) {
136
+ if (signal.aborted) throw new Error("Discord attachment download aborted");
137
+ const { done, value } = await reader.read().catch(() => { throw new Error("Discord attachment download failed or aborted"); });
138
+ if (signal.aborted) throw new Error("Discord attachment download aborted");
139
+ if (done) break;
140
+ size += value.byteLength;
141
+ total += value.byteLength;
142
+ if (size > MAX_FILE_BYTES || size > item.size || total > MAX_TOTAL_BYTES) throw new Error("Discord attachment exceeds size limit");
143
+ let offset = 0;
144
+ while (offset < value.byteLength) {
145
+ if (signal.aborted) throw new Error("Discord attachment download aborted");
146
+ const { bytesWritten } = await file.write(value, offset, value.byteLength - offset);
147
+ if (!bytesWritten) throw new Error("Discord attachment write failed");
148
+ offset += bytesWritten;
149
+ }
150
+ }
151
+ if (size !== item.size) throw new Error("Discord attachment size mismatch");
152
+ } finally {
153
+ await reader.cancel().catch(() => {});
154
+ await file.close();
155
+ }
156
+ files.push(path);
157
+ }
158
+ if (options.signal?.aborted) throw new Error("Discord attachment download aborted");
159
+ return { files, cleanup };
160
+ } catch (error) {
161
+ await cleanup();
162
+ throw error;
163
+ }
164
+ }
@@ -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
  }