jeo-code 0.12.13 → 0.12.16

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 (64) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.ja.md +33 -9
  3. package/README.ko.md +33 -9
  4. package/README.md +33 -9
  5. package/README.zh.md +33 -9
  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 +358 -0
  24. package/src/agent/sandbox/bundle.ts +40 -0
  25. package/src/agent/sandbox/control.ts +49 -0
  26. package/src/agent/sandbox/docker.ts +293 -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 +172 -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 +275 -35
  51. package/src/commands/messenger.ts +107 -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 +78 -10
  57. package/src/messenger/discord-setup.ts +216 -34
  58. package/src/messenger/discord.ts +37 -43
  59. package/src/messenger/gateway.ts +85 -37
  60. package/src/messenger/model-selection.ts +101 -0
  61. package/src/messenger/session-pool.ts +189 -35
  62. package/src/messenger/types.ts +5 -2
  63. package/src/skills/catalog.ts +207 -0
  64. package/src/util/retry.ts +12 -0
@@ -1,14 +1,22 @@
1
1
  import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
2
3
  import * as path from "node:path";
3
- import { loadMessengerConfig, resolveMessengerToken, validateMessengerEnvironment } from "./config";
4
+ import { DEFAULT_MESSENGER_TOOLS, loadMessengerConfig, messengerProfilePrivatePaths, parseMessengerSandbox, resolveMessengerToken, validateMessengerEnvironment } from "./config";
4
5
  import { DISCORD_API, DISCORD_GATEWAY_INTENTS, discordGatewayUrl } from "./discord";
5
6
  import { MessengerHttpError, messengerFetchJson } from "./http";
6
7
  import { installMessengerService, messengerServicePlan } from "./service";
7
8
  import { isEnoent, isRecord } from "./types";
9
+ import { jeoHomeDir, notifyDir, notifySessionsDir } from "../agent/notify/paths";
10
+ import { createSandboxPolicy } from "../agent/sandbox/policy";
11
+ import type { SandboxConfig } from "../agent/sandbox/types";
8
12
 
9
13
  export const DISCORD_TOKEN_ENV = "JEO_DISCORD_BOT_TOKEN";
10
14
  const TOKEN_FILE = "discord-token";
11
- const RUN_SCRIPT = "run-discord.sh";
15
+ /** Fresh-setup workspace: a jeo-owned directory in the user's home, outside every sandbox-private root. */
16
+ const WORKSPACE_DIR = "jeo-discord-workspace";
17
+ const WORKSPACE_MARKER = ".jeo-workspace";
18
+ const RUN_SCRIPT_POSIX = "run-discord.sh";
19
+ const RUN_SCRIPT_WINDOWS = "run-discord.cmd";
12
20
  const MESSAGE_CONTENT = 1 << 18;
13
21
  const MESSAGE_CONTENT_LIMITED = 1 << 19;
14
22
  /** The only application flags a bot token may change: the limited presence, member, and message intents. */
@@ -32,6 +40,28 @@ const INVITE_POLL_MS = 3_000;
32
40
  const GATEWAY_PROBE_MS = 20_000;
33
41
  const DM_TEST_MESSAGE = "jeo messenger is set up for this bot. Reply here to test the connection.";
34
42
 
43
+ function throwIfAborted(signal?: AbortSignal): void {
44
+ if (signal?.aborted) throw new Error("Discord setup cancelled");
45
+ }
46
+
47
+ async function interruptibleSleep(sleep: (ms: number) => Promise<void>, ms: number, signal?: AbortSignal): Promise<void> {
48
+ if (!signal) return sleep(ms);
49
+ if (signal.aborted) throw new Error("Discord setup cancelled");
50
+ let cancel: (() => void) | undefined;
51
+ try {
52
+ await Promise.race([
53
+ sleep(ms),
54
+ new Promise<never>((_resolve, reject) => {
55
+ cancel = () => reject(new Error("Discord setup cancelled"));
56
+ signal.addEventListener("abort", cancel, { once: true });
57
+ if (signal.aborted) cancel();
58
+ }),
59
+ ]);
60
+ } finally {
61
+ if (cancel) signal.removeEventListener("abort", cancel);
62
+ }
63
+ }
64
+
35
65
  export interface DiscordSetupIO {
36
66
  readonly interactive: boolean;
37
67
  say(line: string): void;
@@ -60,6 +90,11 @@ export interface DiscordSetupDeps {
60
90
  /** argv that starts this jeo build; defaults to the running runtime and entry. */
61
91
  jeoCommand?: string[];
62
92
  installService?: typeof installMessengerService;
93
+ /** Cancels invite polling and the remaining setup work (the CLI wires Ctrl+C here). */
94
+ signal?: AbortSignal;
95
+ /** Injectable platform/path separator for cross-platform release tests. */
96
+ platform?: NodeJS.Platform;
97
+ pathDelimiter?: string;
63
98
  }
64
99
 
65
100
  export interface DiscordSetupResult {
@@ -89,18 +124,23 @@ export interface RequestChannel {
89
124
 
90
125
  type DiscordApiFn = (method: string, route: string, body?: unknown) => Promise<unknown>;
91
126
 
92
- function discordApi(token: string, fetchImpl: typeof fetch): DiscordApiFn {
93
- return (method, route, body) =>
94
- messengerFetchJson(
127
+ function discordApi(token: string, fetchImpl: typeof fetch, signal?: AbortSignal): DiscordApiFn {
128
+ return async (method, route, body) => {
129
+ throwIfAborted(signal);
130
+ const result = await messengerFetchJson(
95
131
  `${DISCORD_API}${route}`,
96
132
  {
97
133
  method,
134
+ signal,
98
135
  headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
99
136
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
100
137
  },
101
138
  undefined,
102
139
  fetchImpl,
103
140
  );
141
+ throwIfAborted(signal);
142
+ return result;
143
+ };
104
144
  }
105
145
 
106
146
  function str(value: unknown): string | undefined {
@@ -201,15 +241,21 @@ export async function waitForDiscordGuilds(
201
241
  io: DiscordSetupIO,
202
242
  sleep: (ms: number) => Promise<void>,
203
243
  waitMs: number,
244
+ signal?: AbortSignal,
204
245
  ): Promise<Record<string, unknown>[]> {
246
+ throwIfAborted(signal);
205
247
  let guilds = records(await api("GET", "/users/@me/guilds"));
248
+ throwIfAborted(signal);
206
249
  if (guilds.length) return guilds;
207
250
  if (!io.interactive) throw new Error(`Invite the bot to a server, then rerun: ${inviteUrl}`);
208
251
  io.say(`Invite the bot to your server (choose the server and authorize):\n ${inviteUrl}`);
209
252
  io.say("Waiting for the bot to join…");
210
253
  for (const deadline = Date.now() + waitMs; Date.now() < deadline; ) {
211
- await sleep(INVITE_POLL_MS);
254
+ throwIfAborted(signal);
255
+ await interruptibleSleep(sleep, Math.min(INVITE_POLL_MS, Math.max(0, deadline - Date.now())), signal);
256
+ throwIfAborted(signal);
212
257
  guilds = records(await api("GET", "/users/@me/guilds"));
258
+ throwIfAborted(signal);
213
259
  if (guilds.length) return guilds;
214
260
  }
215
261
  throw new Error(`The bot has not joined a server yet; invite it, then rerun: ${inviteUrl}`);
@@ -363,8 +409,11 @@ export async function probeDiscordGateway(
363
409
  api: DiscordApiFn,
364
410
  token: string,
365
411
  WebSocketImpl: typeof WebSocket,
412
+ signal?: AbortSignal,
366
413
  ): Promise<void> {
414
+ throwIfAborted(signal);
367
415
  const discovery = await api("GET", "/gateway/bot");
416
+ throwIfAborted(signal);
368
417
  const url = discordGatewayUrl(isRecord(discovery) ? discovery.url : undefined);
369
418
  const result = Promise.withResolvers<void>();
370
419
  const socket = new WebSocketImpl(url);
@@ -372,7 +421,11 @@ export async function probeDiscordGateway(
372
421
  () => result.reject(new Error("Discord Gateway did not confirm the bot session in time")),
373
422
  GATEWAY_PROBE_MS,
374
423
  );
424
+ const cancel = () => result.reject(new Error("Discord setup cancelled"));
425
+ signal?.addEventListener("abort", cancel, { once: true });
426
+ if (signal?.aborted) cancel();
375
427
  socket.addEventListener("message", (event: MessageEvent) => {
428
+ if (signal?.aborted) return;
376
429
  let packet: unknown;
377
430
  try {
378
431
  packet = typeof event.data === "string" ? JSON.parse(event.data) : undefined;
@@ -404,8 +457,10 @@ export async function probeDiscordGateway(
404
457
  socket.addEventListener("error", () => result.reject(new Error("Discord Gateway connection failed")));
405
458
  try {
406
459
  await result.promise;
460
+ throwIfAborted(signal);
407
461
  } finally {
408
462
  clearTimeout(timer);
463
+ signal?.removeEventListener("abort", cancel);
409
464
  try {
410
465
  socket.close(1000);
411
466
  } catch {
@@ -434,11 +489,30 @@ exec ${command.map(quote).join(" ")} messenger run --config "$dir/${configName}"
434
489
  `;
435
490
  }
436
491
 
437
- async function writePrivate(file: string, content: string, mode: number): Promise<void> {
492
+ /** The Windows launcher inherits PATH at launch; the token remains in the private sidecar. */
493
+ export function discordRunCommand(command: string[], configName: string, tokenEnv: string): string {
494
+ if (!/^[A-Za-z_]/u.test(tokenEnv) || /[^A-Za-z0-9_]/u.test(tokenEnv)) throw new Error("discord.tokenEnv must name an environment variable");
495
+ const literal = (value: string, label: string): string => {
496
+ if (/["\r\n]/.test(value)) throw new Error(`Windows launcher ${label} must not contain quotes or newlines`);
497
+ if (/[^\x00-\x7f]/.test(value)) throw new Error(`Windows launcher ${label} must contain only ASCII characters; use ASCII paths`);
498
+ return value.replace(/%/g, "%%");
499
+ };
500
+ const quote = (value: string): string => `"${literal(value, "command")}"`;
501
+ 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`;
502
+ }
503
+
504
+ /** Stage `content` beside `file`, run `validate` on the staged copy, and only then replace `file`; failure leaves `file` untouched. */
505
+ async function writePrivate(file: string, content: string, mode: number, validate?: (staged: string) => Promise<void>): Promise<void> {
438
506
  const temporary = `${file}.${process.pid}.tmp`;
439
- await Bun.write(temporary, content);
440
- await fs.chmod(temporary, mode);
441
- await fs.rename(temporary, file);
507
+ try {
508
+ await Bun.write(temporary, content);
509
+ await fs.chmod(temporary, mode);
510
+ await validate?.(temporary);
511
+ await fs.rename(temporary, file);
512
+ } catch (error) {
513
+ await fs.unlink(temporary).catch(() => undefined);
514
+ throw error;
515
+ }
442
516
  }
443
517
 
444
518
  async function readExistingConfig(configPath: string): Promise<Record<string, unknown> | undefined> {
@@ -458,9 +532,69 @@ async function readExistingConfig(configPath: string): Promise<Record<string, un
458
532
  throw new Error(`${configPath} exists but is not a JSON object; fix or move it before setup`);
459
533
  }
460
534
 
461
- function currentJeoCommand(): string[] {
462
- const entry = Bun.main;
463
- return entry === process.execPath || entry.startsWith("/$bunfs/") ? [process.execPath] : [process.execPath, entry];
535
+ /**
536
+ * The policy admission `messenger run` performs before starting the sandbox worker (commands/launch.ts), with the same
537
+ * private roots: the authority state directory, the session directory, the jeo home, the notify sessions directory and
538
+ * the profile paths from messengerProfilePrivatePaths, which also refuses a workspace that overlaps the profile. The authority state
539
+ * directory is a fresh random temp directory at launch, so a representative path under os.tmpdir() stands in for it.
540
+ * Throws on any refusal, so setup cannot succeed for a workspace that launch would reject.
541
+ */
542
+ async function admitSandboxWorkspace(sandbox: SandboxConfig, profileDir: string, sessionDir: string, cwd: string): Promise<void> {
543
+ const profilePaths = await messengerProfilePrivatePaths(profileDir, cwd);
544
+ await createSandboxPolicy({
545
+ sessionKey: "setup",
546
+ revision: 0,
547
+ cwd,
548
+ config: sandbox,
549
+ privatePaths: [path.join(os.tmpdir(), "jeo-sandbox-authority-setup"), sessionDir, jeoHomeDir(), notifySessionsDir(), ...profilePaths],
550
+ });
551
+ }
552
+
553
+ /**
554
+ * Create the default workspace (or reuse one that carries the marker an earlier setup run left), then prove `admit`
555
+ * accepts it. The marker is a re-run convention, not proof of ownership; reuse is still gated by `admit`, and an
556
+ * unmarked existing directory is never adopted. Failure cleanup is non-recursive and best-effort: it can only remove
557
+ * the marker this call wrote and the then-empty directory, so concurrently added data survives.
558
+ */
559
+ async function createDefaultWorkspace(admit: (workspace: string) => Promise<void>): Promise<string> {
560
+ const workspace = path.join(os.homedir(), WORKSPACE_DIR);
561
+ const marker = path.join(workspace, WORKSPACE_MARKER);
562
+ let created = false;
563
+ let wroteMarker = false;
564
+ try {
565
+ await fs.mkdir(workspace, { mode: 0o700 });
566
+ created = true;
567
+ } catch (error) {
568
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
569
+ }
570
+ try {
571
+ if (created) {
572
+ await fs.writeFile(marker, "", { flag: "wx", mode: 0o600 });
573
+ wroteMarker = true;
574
+ } else {
575
+ const [stat, tag] = await Promise.all([fs.lstat(workspace), fs.lstat(marker).catch(() => undefined)]);
576
+ if (!stat.isDirectory() || !tag?.isFile()) {
577
+ throw new Error(`${workspace} already exists without the jeo setup marker`);
578
+ }
579
+ }
580
+ await admit(workspace);
581
+ } catch (error) {
582
+ if (wroteMarker) await fs.unlink(marker).catch(() => undefined);
583
+ // rmdir refuses a non-empty directory, so anything added meanwhile is preserved.
584
+ if (created) await fs.rmdir(workspace).catch(() => undefined);
585
+ throw new Error(`Default workspace ${workspace} is unusable (${error instanceof Error ? error.message : String(error)}); set "cwd" in the config to a directory outside private jeo and config roots`, { cause: error });
586
+ }
587
+ return workspace;
588
+ }
589
+
590
+ export function currentJeoCommand(entry = Bun.main, execPath = process.execPath): string[] {
591
+ return entry === execPath || entry.startsWith("/$bunfs/") || entry.replaceAll("\\", "/").startsWith("B:/~BUN/") ? [execPath] : [execPath, entry];
592
+ }
593
+
594
+ export function messengerExecutablePath(execPath: string, inheritedPath: string | undefined, delimiter: string, platform: NodeJS.Platform): string {
595
+ const fallbackPath = platform === "win32" ? "" : "/usr/bin:/bin";
596
+ const runtimeDirectory = platform === "win32" ? path.win32.dirname(execPath) : path.dirname(execPath);
597
+ return [...new Set([runtimeDirectory, ...(inheritedPath ?? fallbackPath).split(delimiter).filter(Boolean)])].join(delimiter);
464
598
  }
465
599
 
466
600
  /** Create or repair a Discord messenger profile with as few manual steps as Discord allows. */
@@ -473,20 +607,47 @@ export async function setupDiscordMessenger(
473
607
  const configPath = path.resolve(options.configPath);
474
608
  const directory = path.dirname(configPath);
475
609
  const tokenPath = path.join(directory, TOKEN_FILE);
476
- const runScript = path.join(directory, RUN_SCRIPT);
610
+ const platform = deps.platform ?? process.platform;
611
+ throwIfAborted(deps.signal);
612
+ if (options.service && platform !== "darwin" && platform !== "linux") {
613
+ throw new Error("Background services are supported only on macOS and Linux");
614
+ }
615
+ const runScript = path.join(directory, platform === "win32" ? RUN_SCRIPT_WINDOWS : RUN_SCRIPT_POSIX);
616
+ const delimiter = deps.pathDelimiter ?? path.delimiter;
617
+ const envPath = messengerExecutablePath(process.execPath, env.PATH, delimiter, platform);
618
+ const command = deps.jeoCommand ?? currentJeoCommand();
619
+ const existing = await readExistingConfig(configPath);
620
+ const sandbox = existing?.sandbox !== undefined ? parseMessengerSandbox(existing.sandbox) : parseMessengerSandbox({
621
+ image: await io.ask("Existing local Linux sandbox image (no image will be pulled): "),
622
+ limits: {
623
+ memoryBytes: Number(await io.ask("Sandbox memory limit in bytes: ")),
624
+ pids: Number(await io.ask("Sandbox process limit: ")),
625
+ cpus: Number(await io.ask("Sandbox CPU limit: ")),
626
+ },
627
+ });
628
+ if (existing && Object.hasOwn(existing, "independentSessions")) {
629
+ delete existing.independentSessions;
630
+ io.say("Removed obsolete independentSessions setting. Discord threads now keep one persistent session.");
631
+ }
632
+ if (platform === "win32") discordRunCommand(command, path.basename(configPath), DISCORD_TOKEN_ENV);
477
633
 
478
634
  const token = await resolveToken(env, tokenPath, io);
479
- const api = discordApi(token, deps.fetch ?? fetch);
635
+ throwIfAborted(deps.signal);
636
+ const api = discordApi(token, deps.fetch ?? fetch, deps.signal);
480
637
  const bot = await inspectDiscordBot(api);
638
+ throwIfAborted(deps.signal);
481
639
  io.say(`Bot ${bot.username} (${bot.id}), application ${bot.applicationId}`);
482
640
  await ensureMessageContentIntent(api, bot, io);
641
+ throwIfAborted(deps.signal);
483
642
 
484
643
  await fs.mkdir(directory, { recursive: true, mode: 0o700 });
644
+ throwIfAborted(deps.signal);
485
645
  await writePrivate(tokenPath, `${token}\n`, 0o600);
646
+ throwIfAborted(deps.signal);
486
647
 
487
648
  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);
649
+ const guilds = await waitForDiscordGuilds(api, inviteUrl, io, deps.sleep ?? Bun.sleep, options.inviteWaitMs ?? 300_000, deps.signal);
650
+ throwIfAborted(deps.signal);
490
651
  const platforms = isRecord(existing?.platforms) ? existing.platforms : {};
491
652
  const previous = isRecord(platforms.discord) ? platforms.discord : {};
492
653
  const previousList = (key: string): string[] | undefined => {
@@ -499,52 +660,71 @@ export async function setupDiscordMessenger(
499
660
  let channelIds: string[];
500
661
  if (options.channelIds?.length || !previousList("allowedChannelIds")) {
501
662
  const channels = await selectDiscordChannels(api, bot, guilds, options.channelIds, inviteUrl, io);
663
+ throwIfAborted(deps.signal);
502
664
  for (const channel of channels) io.say(`Request channel: #${channel.name} in ${channel.guildName}`);
503
665
  channelIds = channels.map(channel => channel.id);
504
666
  } else {
505
667
  channelIds = previousList("allowedChannelIds") as string[];
506
668
  }
507
669
  const dmChannel = options.dmTest === false ? undefined : await openOwnerDm(api, userIds[0]!, io);
670
+ throwIfAborted(deps.signal);
508
671
  const allowedChannelIds = [...new Set([...channelIds, ...(dmChannel ? [dmChannel] : [])])];
509
672
  const tokenEnv = str(previous.tokenEnv) ?? DISCORD_TOKEN_ENV;
673
+ if (platform === "win32") discordRunCommand(command, path.basename(configPath), tokenEnv);
510
674
 
511
675
  const discord: Record<string, unknown> = { ...previous, tokenEnv, allowedUserIds: userIds, allowedChannelIds };
512
676
  if (options.artifacts) discord.artifactDir = "./artifacts";
513
- const defaultTools = ["read", "find", "search", "write", "edit", "bash", "computer"];
677
+ const sessionDir = str(existing?.sessionDir) ?? path.join(notifyDir(), "messenger-sessions");
678
+ let cwd: unknown;
679
+ if (existing && Object.hasOwn(existing, "cwd")) {
680
+ // An explicit value is never replaced; an unusable one fails the loader below before the config is touched.
681
+ cwd = existing.cwd;
682
+ } else {
683
+ cwd = await createDefaultWorkspace(workspace => admitSandboxWorkspace(sandbox, directory, path.resolve(directory, sessionDir), workspace));
684
+ if (existing) io.say(`The existing config had no "cwd"; its sandbox workspace is now ${cwd}. No existing files were moved or deleted.`);
685
+ }
686
+ throwIfAborted(deps.signal);
514
687
  const config = {
515
688
  ...existing,
516
- cwd: str(existing?.cwd) ?? "./workspace",
517
- sessionDir: str(existing?.sessionDir) ?? "./sessions",
518
- toolNames: options.toolNames ?? (Array.isArray(existing?.toolNames) ? existing.toolNames : defaultTools),
519
- independentSessions: existing?.independentSessions !== undefined ? Boolean(existing.independentSessions) : true,
689
+ cwd,
690
+ sessionDir,
691
+ sandbox,
692
+ toolNames: options.toolNames ?? (Array.isArray(existing?.toolNames) ? existing.toolNames : [...DEFAULT_MESSENGER_TOOLS]),
520
693
  platforms: { ...platforms, discord },
521
694
  };
522
695
  for (const relative of [config.cwd, config.sessionDir, discord.artifactDir]) {
523
696
  if (typeof relative === "string")
524
697
  await fs.mkdir(path.resolve(directory, relative), { recursive: true, mode: 0o700 });
698
+ throwIfAborted(deps.signal);
525
699
  }
526
- 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
- );
700
+ // The loader `messenger run` uses, then the policy admission it performs, must accept the staged file before it replaces a working config.
701
+ await writePrivate(configPath, `${JSON.stringify(config, null, 2)}\n`, 0o600, async staged => {
702
+ const loaded = await loadMessengerConfig(staged);
703
+ validateMessengerEnvironment({ ...loaded, platforms: { discord: loaded.platforms.discord } }, { [tokenEnv]: token });
704
+ try {
705
+ await admitSandboxWorkspace(sandbox, loaded.profileDir ?? directory, loaded.sessionDir, loaded.cwd);
706
+ } catch (error) {
707
+ throw new Error(`Sandbox workspace ${loaded.cwd} would be refused at launch (${error instanceof Error ? error.message : String(error)}); set "cwd" in the config to a directory outside private jeo and config roots`, { cause: error });
708
+ }
709
+ });
710
+ throwIfAborted(deps.signal);
530
711
  await writePrivate(
531
712
  runScript,
532
- discordRunScript(deps.jeoCommand ?? currentJeoCommand(), path.basename(configPath), tokenEnv, envPath),
713
+ platform === "win32"
714
+ ? discordRunCommand(command, path.basename(configPath), tokenEnv)
715
+ : discordRunScript(command, path.basename(configPath), tokenEnv, envPath),
533
716
  0o700,
534
717
  );
535
- // The same loader `messenger run` uses must accept what was written.
536
- const written = await loadMessengerConfig(configPath);
537
- validateMessengerEnvironment(
538
- { ...written, platforms: { discord: written.platforms.discord } },
539
- { [tokenEnv]: token },
540
- );
718
+ throwIfAborted(deps.signal);
541
719
 
542
- await probeDiscordGateway(api, token, deps.WebSocket ?? WebSocket);
720
+ await probeDiscordGateway(api, token, deps.WebSocket ?? WebSocket, deps.signal);
721
+ throwIfAborted(deps.signal);
543
722
  io.say("Discord Gateway accepted the bot session and intents.");
544
723
 
545
724
  let service: string | undefined;
546
725
  if (options.service) {
547
726
  service = await (deps.installService ?? installMessengerService)(messengerServicePlan(directory, runScript));
727
+ throwIfAborted(deps.signal);
548
728
  io.say(`Gateway running as ${service}`);
549
729
  }
550
730
  if (dmChannel) {
@@ -555,8 +735,10 @@ export async function setupDiscordMessenger(
555
735
  io.say(
556
736
  "Could not send the test DM (the owner may block DMs from server members); server requests still work.",
557
737
  );
738
+ throwIfAborted(deps.signal);
558
739
  }
559
740
  }
741
+ throwIfAborted(deps.signal);
560
742
  return {
561
743
  configPath,
562
744
  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) {