jeo-code 0.12.13 → 0.12.15

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.ja.md +32 -8
  3. package/README.ko.md +32 -8
  4. package/README.md +32 -8
  5. package/README.zh.md +32 -8
  6. package/package.json +4 -2
  7. package/src/agent/browser-tool.ts +9 -1
  8. package/src/agent/config-schema.ts +8 -0
  9. package/src/agent/context-files.ts +19 -0
  10. package/src/agent/engine.ts +25 -6
  11. package/src/agent/eval-tool.ts +6 -0
  12. package/src/agent/goal-verifier.ts +30 -6
  13. package/src/agent/hooks.ts +52 -4
  14. package/src/agent/job-registry.ts +110 -18
  15. package/src/agent/job-tool.ts +43 -9
  16. package/src/agent/loop-guards.ts +17 -2
  17. package/src/agent/memory.ts +151 -112
  18. package/src/agent/monitor-registry.ts +142 -30
  19. package/src/agent/monitor-tool.ts +34 -6
  20. package/src/agent/notify/discord-daemon.ts +135 -42
  21. package/src/agent/notify/session-endpoint.ts +27 -5
  22. package/src/agent/sandbox/assets.d.ts +4 -0
  23. package/src/agent/sandbox/authority.ts +295 -0
  24. package/src/agent/sandbox/bundle.ts +37 -0
  25. package/src/agent/sandbox/control.ts +49 -0
  26. package/src/agent/sandbox/docker.ts +283 -0
  27. package/src/agent/sandbox/hands-source.text +14548 -0
  28. package/src/agent/sandbox/hands-worker.ts +168 -0
  29. package/src/agent/sandbox/policy.ts +155 -0
  30. package/src/agent/sandbox/rpc.ts +90 -0
  31. package/src/agent/sandbox/supervisor.ts +210 -0
  32. package/src/agent/sandbox/types.ts +77 -0
  33. package/src/agent/shell-lease.ts +190 -0
  34. package/src/agent/state.ts +167 -0
  35. package/src/agent/subagents.ts +4 -3
  36. package/src/agent/task-tool.ts +8 -0
  37. package/src/agent/todo-tool.ts +30 -1
  38. package/src/agent/tool-schemas.ts +13 -0
  39. package/src/ai/model-discovery.ts +12 -0
  40. package/src/ai/providers/anthropic.ts +27 -2
  41. package/src/ai/providers/antigravity.ts +10 -2
  42. package/src/ai/providers/gemini.ts +20 -16
  43. package/src/ai/providers/ollama.ts +14 -0
  44. package/src/ai/providers/openai-responses.ts +22 -5
  45. package/src/ai/providers/openai.ts +23 -40
  46. package/src/cli/runner.ts +3 -0
  47. package/src/commands/approve.ts +37 -13
  48. package/src/commands/launch/flags.ts +2 -1
  49. package/src/commands/launch/jobs-slash.ts +1 -1
  50. package/src/commands/launch.ts +261 -35
  51. package/src/commands/messenger.ts +105 -39
  52. package/src/commands/notify.ts +2 -2
  53. package/src/commands/team.ts +44 -12
  54. package/src/commands/ultragoal.ts +6 -16
  55. package/src/messenger/activity.ts +2 -1
  56. package/src/messenger/config.ts +51 -9
  57. package/src/messenger/discord-setup.ts +122 -20
  58. package/src/messenger/discord.ts +37 -43
  59. package/src/messenger/gateway.ts +33 -10
  60. package/src/messenger/model-selection.ts +101 -0
  61. package/src/messenger/session-pool.ts +189 -35
  62. package/src/messenger/types.ts +3 -2
  63. package/src/skills/catalog.ts +207 -0
@@ -1,14 +1,16 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { loadMessengerConfig, resolveMessengerToken, validateMessengerEnvironment } from "./config";
3
+ import { loadMessengerConfig, parseMessengerSandbox, resolveMessengerToken, validateMessengerEnvironment } from "./config";
4
4
  import { DISCORD_API, DISCORD_GATEWAY_INTENTS, discordGatewayUrl } from "./discord";
5
5
  import { MessengerHttpError, messengerFetchJson } from "./http";
6
6
  import { installMessengerService, messengerServicePlan } from "./service";
7
7
  import { isEnoent, isRecord } from "./types";
8
+ import { notifyDir } from "../agent/notify/paths";
8
9
 
9
10
  export const DISCORD_TOKEN_ENV = "JEO_DISCORD_BOT_TOKEN";
10
11
  const TOKEN_FILE = "discord-token";
11
- const RUN_SCRIPT = "run-discord.sh";
12
+ const RUN_SCRIPT_POSIX = "run-discord.sh";
13
+ const RUN_SCRIPT_WINDOWS = "run-discord.cmd";
12
14
  const MESSAGE_CONTENT = 1 << 18;
13
15
  const MESSAGE_CONTENT_LIMITED = 1 << 19;
14
16
  /** The only application flags a bot token may change: the limited presence, member, and message intents. */
@@ -32,6 +34,28 @@ const INVITE_POLL_MS = 3_000;
32
34
  const GATEWAY_PROBE_MS = 20_000;
33
35
  const DM_TEST_MESSAGE = "jeo messenger is set up for this bot. Reply here to test the connection.";
34
36
 
37
+ function throwIfAborted(signal?: AbortSignal): void {
38
+ if (signal?.aborted) throw new Error("Discord setup cancelled");
39
+ }
40
+
41
+ async function interruptibleSleep(sleep: (ms: number) => Promise<void>, ms: number, signal?: AbortSignal): Promise<void> {
42
+ if (!signal) return sleep(ms);
43
+ if (signal.aborted) throw new Error("Discord setup cancelled");
44
+ let cancel: (() => void) | undefined;
45
+ try {
46
+ await Promise.race([
47
+ sleep(ms),
48
+ new Promise<never>((_resolve, reject) => {
49
+ cancel = () => reject(new Error("Discord setup cancelled"));
50
+ signal.addEventListener("abort", cancel, { once: true });
51
+ if (signal.aborted) cancel();
52
+ }),
53
+ ]);
54
+ } finally {
55
+ if (cancel) signal.removeEventListener("abort", cancel);
56
+ }
57
+ }
58
+
35
59
  export interface DiscordSetupIO {
36
60
  readonly interactive: boolean;
37
61
  say(line: string): void;
@@ -60,6 +84,11 @@ export interface DiscordSetupDeps {
60
84
  /** argv that starts this jeo build; defaults to the running runtime and entry. */
61
85
  jeoCommand?: string[];
62
86
  installService?: typeof installMessengerService;
87
+ /** Cancels invite polling and the remaining setup work (the CLI wires Ctrl+C here). */
88
+ signal?: AbortSignal;
89
+ /** Injectable platform/path separator for cross-platform release tests. */
90
+ platform?: NodeJS.Platform;
91
+ pathDelimiter?: string;
63
92
  }
64
93
 
65
94
  export interface DiscordSetupResult {
@@ -89,18 +118,23 @@ export interface RequestChannel {
89
118
 
90
119
  type DiscordApiFn = (method: string, route: string, body?: unknown) => Promise<unknown>;
91
120
 
92
- function discordApi(token: string, fetchImpl: typeof fetch): DiscordApiFn {
93
- return (method, route, body) =>
94
- messengerFetchJson(
121
+ function discordApi(token: string, fetchImpl: typeof fetch, signal?: AbortSignal): DiscordApiFn {
122
+ return async (method, route, body) => {
123
+ throwIfAborted(signal);
124
+ const result = await messengerFetchJson(
95
125
  `${DISCORD_API}${route}`,
96
126
  {
97
127
  method,
128
+ signal,
98
129
  headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
99
130
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
100
131
  },
101
132
  undefined,
102
133
  fetchImpl,
103
134
  );
135
+ throwIfAborted(signal);
136
+ return result;
137
+ };
104
138
  }
105
139
 
106
140
  function str(value: unknown): string | undefined {
@@ -201,15 +235,21 @@ export async function waitForDiscordGuilds(
201
235
  io: DiscordSetupIO,
202
236
  sleep: (ms: number) => Promise<void>,
203
237
  waitMs: number,
238
+ signal?: AbortSignal,
204
239
  ): Promise<Record<string, unknown>[]> {
240
+ throwIfAborted(signal);
205
241
  let guilds = records(await api("GET", "/users/@me/guilds"));
242
+ throwIfAborted(signal);
206
243
  if (guilds.length) return guilds;
207
244
  if (!io.interactive) throw new Error(`Invite the bot to a server, then rerun: ${inviteUrl}`);
208
245
  io.say(`Invite the bot to your server (choose the server and authorize):\n ${inviteUrl}`);
209
246
  io.say("Waiting for the bot to join…");
210
247
  for (const deadline = Date.now() + waitMs; Date.now() < deadline; ) {
211
- await sleep(INVITE_POLL_MS);
248
+ throwIfAborted(signal);
249
+ await interruptibleSleep(sleep, Math.min(INVITE_POLL_MS, Math.max(0, deadline - Date.now())), signal);
250
+ throwIfAborted(signal);
212
251
  guilds = records(await api("GET", "/users/@me/guilds"));
252
+ throwIfAborted(signal);
213
253
  if (guilds.length) return guilds;
214
254
  }
215
255
  throw new Error(`The bot has not joined a server yet; invite it, then rerun: ${inviteUrl}`);
@@ -363,8 +403,11 @@ export async function probeDiscordGateway(
363
403
  api: DiscordApiFn,
364
404
  token: string,
365
405
  WebSocketImpl: typeof WebSocket,
406
+ signal?: AbortSignal,
366
407
  ): Promise<void> {
408
+ throwIfAborted(signal);
367
409
  const discovery = await api("GET", "/gateway/bot");
410
+ throwIfAborted(signal);
368
411
  const url = discordGatewayUrl(isRecord(discovery) ? discovery.url : undefined);
369
412
  const result = Promise.withResolvers<void>();
370
413
  const socket = new WebSocketImpl(url);
@@ -372,7 +415,11 @@ export async function probeDiscordGateway(
372
415
  () => result.reject(new Error("Discord Gateway did not confirm the bot session in time")),
373
416
  GATEWAY_PROBE_MS,
374
417
  );
418
+ const cancel = () => result.reject(new Error("Discord setup cancelled"));
419
+ signal?.addEventListener("abort", cancel, { once: true });
420
+ if (signal?.aborted) cancel();
375
421
  socket.addEventListener("message", (event: MessageEvent) => {
422
+ if (signal?.aborted) return;
376
423
  let packet: unknown;
377
424
  try {
378
425
  packet = typeof event.data === "string" ? JSON.parse(event.data) : undefined;
@@ -404,8 +451,10 @@ export async function probeDiscordGateway(
404
451
  socket.addEventListener("error", () => result.reject(new Error("Discord Gateway connection failed")));
405
452
  try {
406
453
  await result.promise;
454
+ throwIfAborted(signal);
407
455
  } finally {
408
456
  clearTimeout(timer);
457
+ signal?.removeEventListener("abort", cancel);
409
458
  try {
410
459
  socket.close(1000);
411
460
  } catch {
@@ -434,6 +483,18 @@ exec ${command.map(quote).join(" ")} messenger run --config "$dir/${configName}"
434
483
  `;
435
484
  }
436
485
 
486
+ /** The Windows launcher inherits PATH at launch; the token remains in the private sidecar. */
487
+ export function discordRunCommand(command: string[], configName: string, tokenEnv: string): string {
488
+ if (!/^[A-Za-z_]/u.test(tokenEnv) || /[^A-Za-z0-9_]/u.test(tokenEnv)) throw new Error("discord.tokenEnv must name an environment variable");
489
+ const literal = (value: string, label: string): string => {
490
+ if (/["\r\n]/.test(value)) throw new Error(`Windows launcher ${label} must not contain quotes or newlines`);
491
+ if (/[^\x00-\x7f]/.test(value)) throw new Error(`Windows launcher ${label} must contain only ASCII characters; use ASCII paths`);
492
+ return value.replace(/%/g, "%%");
493
+ };
494
+ const quote = (value: string): string => `"${literal(value, "command")}"`;
495
+ return `@echo off\r\nsetlocal DisableDelayedExpansion\r\nset /p "${tokenEnv}="<"%~dp0${TOKEN_FILE}"\r\n${command.map(quote).join(" ")} messenger run --config "%~dp0${literal(configName, "config name")}"\r\n`;
496
+ }
497
+
437
498
  async function writePrivate(file: string, content: string, mode: number): Promise<void> {
438
499
  const temporary = `${file}.${process.pid}.tmp`;
439
500
  await Bun.write(temporary, content);
@@ -458,9 +519,14 @@ async function readExistingConfig(configPath: string): Promise<Record<string, un
458
519
  throw new Error(`${configPath} exists but is not a JSON object; fix or move it before setup`);
459
520
  }
460
521
 
461
- function currentJeoCommand(): string[] {
462
- const entry = Bun.main;
463
- return entry === process.execPath || entry.startsWith("/$bunfs/") ? [process.execPath] : [process.execPath, entry];
522
+ export function currentJeoCommand(entry = Bun.main, execPath = process.execPath): string[] {
523
+ return entry === execPath || entry.startsWith("/$bunfs/") || entry.replaceAll("\\", "/").startsWith("B:/~BUN/") ? [execPath] : [execPath, entry];
524
+ }
525
+
526
+ export function messengerExecutablePath(execPath: string, inheritedPath: string | undefined, delimiter: string, platform: NodeJS.Platform): string {
527
+ const fallbackPath = platform === "win32" ? "" : "/usr/bin:/bin";
528
+ const runtimeDirectory = platform === "win32" ? path.win32.dirname(execPath) : path.dirname(execPath);
529
+ return [...new Set([runtimeDirectory, ...(inheritedPath ?? fallbackPath).split(delimiter).filter(Boolean)])].join(delimiter);
464
530
  }
465
531
 
466
532
  /** Create or repair a Discord messenger profile with as few manual steps as Discord allows. */
@@ -473,20 +539,47 @@ export async function setupDiscordMessenger(
473
539
  const configPath = path.resolve(options.configPath);
474
540
  const directory = path.dirname(configPath);
475
541
  const tokenPath = path.join(directory, TOKEN_FILE);
476
- const runScript = path.join(directory, RUN_SCRIPT);
542
+ const platform = deps.platform ?? process.platform;
543
+ throwIfAborted(deps.signal);
544
+ if (options.service && platform !== "darwin" && platform !== "linux") {
545
+ throw new Error("Background services are supported only on macOS and Linux");
546
+ }
547
+ const runScript = path.join(directory, platform === "win32" ? RUN_SCRIPT_WINDOWS : RUN_SCRIPT_POSIX);
548
+ const delimiter = deps.pathDelimiter ?? path.delimiter;
549
+ const envPath = messengerExecutablePath(process.execPath, env.PATH, delimiter, platform);
550
+ const command = deps.jeoCommand ?? currentJeoCommand();
551
+ const existing = await readExistingConfig(configPath);
552
+ const sandbox = existing?.sandbox !== undefined ? parseMessengerSandbox(existing.sandbox) : parseMessengerSandbox({
553
+ image: await io.ask("Existing local Linux sandbox image (no image will be pulled): "),
554
+ limits: {
555
+ memoryBytes: Number(await io.ask("Sandbox memory limit in bytes: ")),
556
+ pids: Number(await io.ask("Sandbox process limit: ")),
557
+ cpus: Number(await io.ask("Sandbox CPU limit: ")),
558
+ },
559
+ });
560
+ if (existing && Object.hasOwn(existing, "independentSessions")) {
561
+ delete existing.independentSessions;
562
+ io.say("Removed obsolete independentSessions setting. Discord threads now keep one persistent session.");
563
+ }
564
+ if (platform === "win32") discordRunCommand(command, path.basename(configPath), DISCORD_TOKEN_ENV);
477
565
 
478
566
  const token = await resolveToken(env, tokenPath, io);
479
- const api = discordApi(token, deps.fetch ?? fetch);
567
+ throwIfAborted(deps.signal);
568
+ const api = discordApi(token, deps.fetch ?? fetch, deps.signal);
480
569
  const bot = await inspectDiscordBot(api);
570
+ throwIfAborted(deps.signal);
481
571
  io.say(`Bot ${bot.username} (${bot.id}), application ${bot.applicationId}`);
482
572
  await ensureMessageContentIntent(api, bot, io);
573
+ throwIfAborted(deps.signal);
483
574
 
484
575
  await fs.mkdir(directory, { recursive: true, mode: 0o700 });
576
+ throwIfAborted(deps.signal);
485
577
  await writePrivate(tokenPath, `${token}\n`, 0o600);
578
+ throwIfAborted(deps.signal);
486
579
 
487
580
  const inviteUrl = discordInviteUrl(bot.applicationId);
488
- const guilds = await waitForDiscordGuilds(api, inviteUrl, io, deps.sleep ?? Bun.sleep, options.inviteWaitMs ?? 300_000);
489
- const existing = await readExistingConfig(configPath);
581
+ const guilds = await waitForDiscordGuilds(api, inviteUrl, io, deps.sleep ?? Bun.sleep, options.inviteWaitMs ?? 300_000, deps.signal);
582
+ throwIfAborted(deps.signal);
490
583
  const platforms = isRecord(existing?.platforms) ? existing.platforms : {};
491
584
  const previous = isRecord(platforms.discord) ? platforms.discord : {};
492
585
  const previousList = (key: string): string[] | undefined => {
@@ -499,14 +592,17 @@ export async function setupDiscordMessenger(
499
592
  let channelIds: string[];
500
593
  if (options.channelIds?.length || !previousList("allowedChannelIds")) {
501
594
  const channels = await selectDiscordChannels(api, bot, guilds, options.channelIds, inviteUrl, io);
595
+ throwIfAborted(deps.signal);
502
596
  for (const channel of channels) io.say(`Request channel: #${channel.name} in ${channel.guildName}`);
503
597
  channelIds = channels.map(channel => channel.id);
504
598
  } else {
505
599
  channelIds = previousList("allowedChannelIds") as string[];
506
600
  }
507
601
  const dmChannel = options.dmTest === false ? undefined : await openOwnerDm(api, userIds[0]!, io);
602
+ throwIfAborted(deps.signal);
508
603
  const allowedChannelIds = [...new Set([...channelIds, ...(dmChannel ? [dmChannel] : [])])];
509
604
  const tokenEnv = str(previous.tokenEnv) ?? DISCORD_TOKEN_ENV;
605
+ if (platform === "win32") discordRunCommand(command, path.basename(configPath), tokenEnv);
510
606
 
511
607
  const discord: Record<string, unknown> = { ...previous, tokenEnv, allowedUserIds: userIds, allowedChannelIds };
512
608
  if (options.artifacts) discord.artifactDir = "./artifacts";
@@ -514,24 +610,26 @@ export async function setupDiscordMessenger(
514
610
  const config = {
515
611
  ...existing,
516
612
  cwd: str(existing?.cwd) ?? "./workspace",
517
- sessionDir: str(existing?.sessionDir) ?? "./sessions",
613
+ sessionDir: str(existing?.sessionDir) ?? path.join(notifyDir(), "messenger-sessions"),
614
+ sandbox,
518
615
  toolNames: options.toolNames ?? (Array.isArray(existing?.toolNames) ? existing.toolNames : defaultTools),
519
- independentSessions: existing?.independentSessions !== undefined ? Boolean(existing.independentSessions) : true,
520
616
  platforms: { ...platforms, discord },
521
617
  };
522
618
  for (const relative of [config.cwd, config.sessionDir, discord.artifactDir]) {
523
619
  if (typeof relative === "string")
524
620
  await fs.mkdir(path.resolve(directory, relative), { recursive: true, mode: 0o700 });
621
+ throwIfAborted(deps.signal);
525
622
  }
526
623
  await writePrivate(configPath, `${JSON.stringify(config, null, 2)}\n`, 0o600);
527
- const envPath = [...new Set([path.dirname(process.execPath), ...(env.PATH ?? "/usr/bin:/bin").split(":")])].join(
528
- ":",
529
- );
624
+ throwIfAborted(deps.signal);
530
625
  await writePrivate(
531
626
  runScript,
532
- discordRunScript(deps.jeoCommand ?? currentJeoCommand(), path.basename(configPath), tokenEnv, envPath),
627
+ platform === "win32"
628
+ ? discordRunCommand(command, path.basename(configPath), tokenEnv)
629
+ : discordRunScript(command, path.basename(configPath), tokenEnv, envPath),
533
630
  0o700,
534
631
  );
632
+ throwIfAborted(deps.signal);
535
633
  // The same loader `messenger run` uses must accept what was written.
536
634
  const written = await loadMessengerConfig(configPath);
537
635
  validateMessengerEnvironment(
@@ -539,12 +637,14 @@ export async function setupDiscordMessenger(
539
637
  { [tokenEnv]: token },
540
638
  );
541
639
 
542
- await probeDiscordGateway(api, token, deps.WebSocket ?? WebSocket);
640
+ await probeDiscordGateway(api, token, deps.WebSocket ?? WebSocket, deps.signal);
641
+ throwIfAborted(deps.signal);
543
642
  io.say("Discord Gateway accepted the bot session and intents.");
544
643
 
545
644
  let service: string | undefined;
546
645
  if (options.service) {
547
646
  service = await (deps.installService ?? installMessengerService)(messengerServicePlan(directory, runScript));
647
+ throwIfAborted(deps.signal);
548
648
  io.say(`Gateway running as ${service}`);
549
649
  }
550
650
  if (dmChannel) {
@@ -555,8 +655,10 @@ export async function setupDiscordMessenger(
555
655
  io.say(
556
656
  "Could not send the test DM (the owner may block DMs from server members); server requests still work.",
557
657
  );
658
+ throwIfAborted(deps.signal);
558
659
  }
559
660
  }
661
+ throwIfAborted(deps.signal);
560
662
  return {
561
663
  configPath,
562
664
  botName: bot.username,
@@ -335,14 +335,15 @@ export function createDiscordAdapter(
335
335
  ): MessengerAdapter {
336
336
  const token = resolveMessengerToken(config.tokenEnv, env)?.trim();
337
337
  const clock = deps.clock ?? defaultClock;
338
- const createdThreads = new Map<string, { parentChannelId: string; senderId: string }>();
339
- const rememberThread = (threadId: string, parentChannelId: string, senderId: string): void => {
340
- createdThreads.set(threadId, { parentChannelId, senderId });
338
+ const createdThreads = new Map<string, { parentChannelId: string }>();
339
+ const rememberThread = (threadId: string, parentChannelId: string): void => {
340
+ createdThreads.set(threadId, { parentChannelId });
341
341
  if (createdThreads.size > 10_000) {
342
342
  const oldest = createdThreads.keys().next().value;
343
343
  if (oldest !== undefined) createdThreads.delete(oldest);
344
344
  }
345
345
  };
346
+ const normalizationTails = new Map<string, Promise<void>>();
346
347
  const activityMessages = new Map<string, string>();
347
348
  const headers = (): Record<string, string> => {
348
349
  if (!token) throw new DiscordConfigurationError("Discord bot token is missing");
@@ -386,57 +387,50 @@ export function createDiscordAdapter(
386
387
  initialUrl = discordGatewayUrl(gateway.url);
387
388
  }
388
389
  const deliver = async (message: MessengerMessage): Promise<void> => {
389
- const thread = createdThreads.get(message.channelId);
390
- if (thread && thread.senderId === message.senderId) {
391
- await onMessage({
392
- ...message,
393
- threadId: message.channelId,
394
- parentChannelId: thread.parentChannelId,
395
- });
396
- } else if (
397
- !message.direct &&
398
- config.allowedUserIds.includes(message.senderId) &&
399
- config.allowedChannelIds &&
400
- (!message.mentioned || !config.allowedChannelIds.includes(message.channelId))
401
- ) {
402
- try {
390
+ const previous = normalizationTails.get(message.channelId) ?? Promise.resolve();
391
+ const ready = Promise.withResolvers<void>();
392
+ const tail = previous.then(() => ready.promise);
393
+ normalizationTails.set(message.channelId, tail);
394
+ const forward = (destination: MessengerMessage): Promise<void> => {
395
+ ready.resolve();
396
+ return onMessage(destination);
397
+ };
398
+ await previous;
399
+ try {
400
+ if (signal.aborted) return;
401
+ const thread = createdThreads.get(message.channelId);
402
+ if (thread && config.allowedUserIds.includes(message.senderId)) {
403
+ await forward({ ...message, threadId: message.channelId, parentChannelId: thread.parentChannelId });
404
+ } else if (!message.direct && config.allowedUserIds.includes(message.senderId)) {
403
405
  const channel = await messengerFetchJson(
404
406
  `${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}`,
405
- { headers: headers() },
406
- signal,
407
- deps.fetch,
407
+ { headers: headers() }, signal, deps.fetch,
408
408
  );
409
- if (
410
- isRecord(channel) &&
411
- channel.id === message.channelId &&
412
- typeof channel.type === "number" &&
413
- [10, 11, 12].includes(channel.type) &&
414
- typeof channel.parent_id === "string" &&
415
- channel.parent_id
416
- ) {
409
+ if (!isRecord(channel) || channel.id !== message.channelId) return;
410
+ if (typeof channel.type === "number" && [10, 11, 12].includes(channel.type)) {
411
+ if (typeof channel.parent_id !== "string" || !channel.parent_id) return;
417
412
  if (!message.mentioned) {
418
413
  const originalId = typeof channel.name === "string" ? /^Request (\d{1,20})$/.exec(channel.name)?.[1] : undefined;
419
414
  if (channel.owner_id !== accountId || !originalId ||
420
- (!config.allowedChannelIds.includes(channel.parent_id) && !config.allowedChannelIds.includes(message.channelId))) return;
415
+ (config.allowedChannelIds && !config.allowedChannelIds.includes(channel.parent_id) && !config.allowedChannelIds.includes(message.channelId))) return;
421
416
  const original = await messengerFetchJson(
422
417
  `${DISCORD_API}/channels/${encodeURIComponent(channel.parent_id)}/messages/${originalId}`,
423
418
  { headers: headers() }, signal, deps.fetch,
424
419
  );
425
420
  if (!isRecord(original) || original.id !== originalId || original.channel_id !== channel.parent_id ||
426
- !isRecord(original.author) || original.author.id !== message.senderId || original.author.bot === true || original.webhook_id) return;
427
- rememberThread(message.channelId, channel.parent_id, message.senderId);
421
+ !isRecord(original.author) || typeof original.author.id !== "string" || original.author.bot === true || original.webhook_id) return;
428
422
  }
429
- await onMessage({
430
- ...message,
431
- threadId: message.channelId,
432
- parentChannelId: channel.parent_id,
433
- });
423
+ rememberThread(message.channelId, channel.parent_id);
424
+ await forward({ ...message, threadId: message.channelId, parentChannelId: channel.parent_id });
425
+ } else {
426
+ await forward(message);
434
427
  }
435
- } catch {
436
- /* ignore thread lookup failure */
428
+ } else {
429
+ await forward(message);
437
430
  }
438
- } else {
439
- await onMessage(message);
431
+ } finally {
432
+ ready.resolve();
433
+ if (normalizationTails.get(message.channelId) === tail) normalizationTails.delete(message.channelId);
440
434
  }
441
435
  };
442
436
  await connectGateway(
@@ -479,7 +473,7 @@ export function createDiscordAdapter(
479
473
  async prepare(message, signal) {
480
474
  if (message.direct) return message;
481
475
  if (message.threadId) {
482
- if (message.parentChannelId) rememberThread(message.threadId, message.parentChannelId, message.senderId);
476
+ if (message.parentChannelId) rememberThread(message.threadId, message.parentChannelId);
483
477
  return message;
484
478
  }
485
479
  const channel = await messengerFetchJson(
@@ -493,7 +487,7 @@ export function createDiscordAdapter(
493
487
  if ([10, 11, 12].includes(channel.type)) {
494
488
  if (typeof channel.parent_id !== "string" || !channel.parent_id)
495
489
  throw new Error("Discord thread parent unavailable");
496
- rememberThread(message.channelId, channel.parent_id, message.senderId);
490
+ rememberThread(message.channelId, channel.parent_id);
497
491
  return { ...message, threadId: message.channelId, parentChannelId: channel.parent_id };
498
492
  }
499
493
  if (channel.type !== 0 && channel.type !== 5) throw new Error("Discord channel cannot host a reply thread");
@@ -505,7 +499,7 @@ export function createDiscordAdapter(
505
499
  );
506
500
  if (!isRecord(thread) || typeof thread.id !== "string" || !thread.id)
507
501
  throw new Error("Discord thread creation returned no ID");
508
- rememberThread(thread.id, message.channelId, message.senderId);
502
+ rememberThread(thread.id, message.channelId);
509
503
  return { ...message, threadId: thread.id };
510
504
  },
511
505
  async status(message, state, signal) {
@@ -1,4 +1,5 @@
1
1
  import { MessengerHttpError } from "./http";
2
+ import { DiscordModelSelectionError } from "./model-selection";
2
3
  import type {
3
4
  MessengerActivityReporter,
4
5
  MessengerAdapter,
@@ -173,7 +174,7 @@ export type MessengerSessionIdentity = readonly [
173
174
  channel: string,
174
175
  thread: OptionalIdentity,
175
176
  conversation: OptionalIdentity,
176
- user: string,
177
+ user: OptionalIdentity,
177
178
  ];
178
179
 
179
180
  function optionalIdentity(name: string, value: string | undefined): OptionalIdentity {
@@ -201,7 +202,7 @@ export function messengerSessionIdentity(message: MessengerMessage): MessengerSe
201
202
  message.parentChannelId ?? message.channelId,
202
203
  optionalIdentity("thread", message.threadId),
203
204
  optionalIdentity("conversation", message.conversationId),
204
- message.senderId,
205
+ message.platform === "discord" && message.threadId !== undefined ? ["absent"] : ["present", message.senderId],
205
206
  ];
206
207
  }
207
208
 
@@ -217,7 +218,7 @@ export function messengerSessionRoute(cwd: string, message: MessengerMessage): s
217
218
 
218
219
  function schedulingKey(message: MessengerMessage, adapter: MessengerAdapter): string {
219
220
  if (adapter.prepare && !message.direct && !message.threadId && !message.parentChannelId) {
220
- return JSON.stringify([message.platform, message.accountId, message.channelId, message.messageId]);
221
+ return JSON.stringify([message.platform, message.accountId, message.guildId, message.channelId, message.platform === "discord" ? null : message.senderId]);
221
222
  }
222
223
  return messengerConversationKey(message);
223
224
  }
@@ -263,6 +264,8 @@ export interface MessengerGatewayOptions {
263
264
  * order; above 1 the reply implementation must accept concurrent turns for different conversations.
264
265
  */
265
266
  concurrency?: number;
267
+ /** Authenticated human controls bypass model queues, including busy sessions. */
268
+ sandboxControl?: (message: MessengerMessage, signal: AbortSignal) => Promise<{ handled: boolean; output: string }>;
266
269
  }
267
270
 
268
271
  export function createMessengerGateway(
@@ -285,6 +288,7 @@ export function createMessengerGateway(
285
288
  const turns = new Set<Promise<void>>();
286
289
  /** Settles when the latest request of a prepared conversation has delivered its reply. */
287
290
  const conversationTails = new Map<string, Promise<void>>();
291
+ const admissionTails = new Map<string, Promise<void>>();
288
292
  const lifetime = new AbortController();
289
293
  let running = false;
290
294
  let fatalError: MessengerFatalError | undefined;
@@ -353,7 +357,9 @@ export function createMessengerGateway(
353
357
  return;
354
358
  }
355
359
  if (signal.aborted) return;
356
- text = "The request could not be completed. Please try again.";
360
+ text = error instanceof DiscordModelSelectionError
361
+ ? "Discord could not verify a model. Ask the operator to configure an explicit Discord model, check provider credentials and live model discovery, then restart the messenger service."
362
+ : "The request could not be completed. Please try again.";
357
363
  }
358
364
  if (!signal.aborted && text.trim()) {
359
365
  // A failed send may already have delivered a chunk: never retry the entire reply.
@@ -390,21 +396,38 @@ export function createMessengerGateway(
390
396
  }
391
397
  }
392
398
 
393
- function handle(message: MessengerMessage, signal?: AbortSignal): Promise<void> {
399
+ async function handle(message: MessengerMessage, signal?: AbortSignal): Promise<void> {
394
400
  const adapter = byPlatform.get(message.platform);
395
401
  const effectiveSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
396
402
  if (effectiveSignal.aborted || !adapter || !isMessengerMessageAllowed(message, config)) return Promise.resolve();
397
403
  const id = JSON.stringify([message.platform, message.accountId, message.channelId, message.messageId]);
398
- if (seen.has(id) || queue.length >= MAX_QUEUED_MESSAGES) return Promise.resolve();
404
+ if (seen.has(id)) return;
399
405
  seen.add(id);
400
406
  if (seen.size > MAX_SEEN_MESSAGES) {
401
407
  const oldest = seen.values().next().value;
402
408
  if (oldest !== undefined) seen.delete(oldest);
403
409
  }
404
- const { promise, resolve } = Promise.withResolvers<void>();
405
- queue.push({ message, adapter, signal: effectiveSignal, resolve, key: schedulingKey(message, adapter) });
406
- schedule();
407
- return promise;
410
+ const key = schedulingKey(message, adapter);
411
+ const previous = admissionTails.get(key) ?? Promise.resolve();
412
+ const ready = Promise.withResolvers<void>();
413
+ const tail = previous.then(() => ready.promise);
414
+ admissionTails.set(key, tail);
415
+ void tail.then(() => { if (admissionTails.get(key) === tail) admissionTails.delete(key); });
416
+ try {
417
+ if (options.sandboxControl) {
418
+ const control = await options.sandboxControl(message, effectiveSignal);
419
+ if (control.handled) {
420
+ if (!effectiveSignal.aborted && control.output.trim()) await adapter.send(message, control.output, effectiveSignal);
421
+ return;
422
+ }
423
+ }
424
+ await previous;
425
+ if (effectiveSignal.aborted || queue.length >= MAX_QUEUED_MESSAGES) return;
426
+ const { promise, resolve } = Promise.withResolvers<void>();
427
+ queue.push({ message, adapter, signal: effectiveSignal, resolve, key });
428
+ schedule();
429
+ return promise;
430
+ } finally { ready.resolve(); }
408
431
  }
409
432
 
410
433
  return {
@@ -0,0 +1,101 @@
1
+ import { readGlobalConfig, type Config } from "../agent/state";
2
+ import { listProviderModels } from "../ai/model-discovery";
3
+ import { credentialForCall, effectiveCredentialForProvider, qualifyModelId, resolveProvider } from "../ai/model-manager";
4
+ import { resolveModelId } from "../ai/model-registry";
5
+ import { resolveCredential, type AuthProvider, type Credential } from "../auth";
6
+
7
+ export class DiscordModelSelectionError extends Error {
8
+ constructor(cause: unknown) {
9
+ super(cause instanceof Error ? cause.message : "Discord model selection failed", { cause });
10
+ this.name = "DiscordModelSelectionError";
11
+ }
12
+ }
13
+
14
+ /** Select only from the account's live catalog; never substitute a static model. */
15
+ export async function selectDiscordModel(
16
+ explicit: string | undefined,
17
+ opts: { config?: Config; signal?: AbortSignal; list?: typeof listProviderModels; credential?: typeof resolveCredential } = {},
18
+ ): Promise<string> {
19
+ try {
20
+ return await selectDiscordModelFromCatalog(explicit, opts);
21
+ } catch (error) {
22
+ if (opts.signal?.aborted) throw error;
23
+ throw new DiscordModelSelectionError(error);
24
+ }
25
+ }
26
+
27
+ async function selectDiscordModelFromCatalog(
28
+ explicit: string | undefined,
29
+ opts: { config?: Config; signal?: AbortSignal; list?: typeof listProviderModels; credential?: typeof resolveCredential },
30
+ ): Promise<string> {
31
+ const config = opts.config ?? await readGlobalConfig();
32
+ const requested = explicit?.trim() ? await resolveModelId(explicit.trim(), config) : undefined;
33
+ const provider = resolveProvider(requested || await resolveModelId(config.defaultModel, config));
34
+ if (!requested && provider !== "anthropic" && provider !== "openai" && provider !== "antigravity") {
35
+ throw new Error(`Discord automatic model selection does not support provider ${provider}; configure an explicit model`);
36
+ }
37
+ const result = await (opts.list ?? listProviderModels)(provider, { config, signal: opts.signal, limit: Number.POSITIVE_INFINITY, preferOAuth: true });
38
+ if (opts.signal?.aborted) throw new Error("Messenger request aborted");
39
+ if (!result.ok || result.fallback || result.source === "none" || (result.source === "keyless" && (!requested || (provider !== "ollama" && provider !== "lmstudio"))) || !result.models.length) {
40
+ throw new Error(`Discord cannot verify available models for ${provider}; check login and live model discovery`);
41
+ }
42
+ const canonical = (model: string) => qualifyModelId(model, provider);
43
+ const verified = async (model: string): Promise<string> => {
44
+ const authProvider: AuthProvider | undefined = provider === "anthropic" ? "anthropic" : provider === "openai" ? "openai" : undefined;
45
+ if (authProvider && config.providers[authProvider]) {
46
+ const credential: Credential = await (opts.credential ?? resolveCredential)(authProvider);
47
+ const effective = effectiveCredentialForProvider(authProvider, credential, config, model);
48
+ const actual = credentialForCall(provider, effective, config, provider === "openai" ? config.openaiBaseUrl : undefined);
49
+ if (actual.kind !== result.source) {
50
+ throw new Error(`Discord cannot verify ${provider} model access for the credential used at runtime`);
51
+ }
52
+ }
53
+ return canonical(model);
54
+ };
55
+ if (requested) {
56
+ const prefix = `${provider}/`;
57
+ const requestedId = requested.startsWith(prefix) ? requested.slice(prefix.length) : requested;
58
+ const match = result.models.find(model => (model.startsWith(prefix) ? model.slice(prefix.length) : model) === requestedId);
59
+ if (!match) throw new Error(`Discord configured model is not available for ${provider}: ${requested}`);
60
+ return verified(match);
61
+ }
62
+ const family = provider === "anthropic" ? /sonnet/i : provider === "openai" ? /terra/i : /gemini.*flash/i;
63
+ const variant = /(?:^|[^a-z0-9])(?:mini|lite|codex|thinking|image)(?=$|[^a-z0-9])/i;
64
+ const models = [...new Set(result.models.filter(model => {
65
+ const name = provider === "antigravity" ? (result.displayNames?.[model] ?? model) : model;
66
+ return family.test(name) && !variant.test(model) && !variant.test(name);
67
+ }))];
68
+ if (!models.length) throw new Error(`Discord found no available ${provider} model in the required family`);
69
+ if (models.length === 1) return verified(models[0]!);
70
+ // Antigravity wire aliases can advertise a different version than their IDs.
71
+ // Only the display name from the same live response establishes recency.
72
+ if (provider === "antigravity" && models.some(model => !result.displayNames?.[model])) {
73
+ throw new Error("Discord cannot determine the newest Antigravity Flash model: live catalog is missing display names; configure an explicit model from the live catalog");
74
+ }
75
+ const ranked = models.map(model => {
76
+ const name = provider === "antigravity" ? result.displayNames![model]! : model;
77
+ const snapshot = name.match(/[-_ ](\d{4}-\d{2}-\d{2}|\d{8})$/);
78
+ const versionName = snapshot ? name.slice(0, -snapshot[0].length) : name;
79
+ const tierName = provider === "antigravity" ? name.match(/\((high|medium|low)\)\s*$/i)?.[1]?.toLowerCase() : undefined;
80
+ const tier = tierName === "high" ? 3 : tierName === "medium" ? 2 : tierName === "low" ? 1 : 0;
81
+ return { model, version: (versionName.match(/\d+/g) ?? []).map(Number), snapshot: Number(snapshot?.[1]?.replaceAll("-", "") ?? 0), tier, ambiguousVersion: provider === "openai" && /terra.*\d/i.test(versionName) };
82
+ });
83
+ if (ranked.some(item => !item.version.length || item.ambiguousVersion)) {
84
+ throw new Error(`Discord cannot determine the newest ${provider} model; configure an explicit model`);
85
+ }
86
+ const compare = (a: number[], b: number[]) => {
87
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
88
+ const difference = (b[i] ?? 0) - (a[i] ?? 0);
89
+ if (difference) return difference;
90
+ }
91
+ return 0;
92
+ };
93
+ ranked.sort((a, b) => compare(a.version, b.version) || b.snapshot - a.snapshot || b.tier - a.tier);
94
+ if (provider === "antigravity" && ranked.some((item, index) => index > 0 && compare(ranked[0]!.version, item.version) === 0 && ranked[0]!.snapshot === item.snapshot && (!ranked[0]!.tier || !item.tier))) {
95
+ throw new Error("Discord cannot distinguish the newest antigravity models; configure an explicit model");
96
+ }
97
+ if (compare(ranked[0]!.version, ranked[1]!.version) === 0 && ranked[0]!.snapshot === ranked[1]!.snapshot && ranked[0]!.tier === ranked[1]!.tier) {
98
+ throw new Error(`Discord cannot distinguish the newest ${provider} models; configure an explicit model`);
99
+ }
100
+ return verified(ranked[0]!.model);
101
+ }