jeo-code 0.12.15 → 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.
@@ -29,6 +29,17 @@ export function sandboxPathContains(parent: string, child: string): boolean {
29
29
  return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
30
30
  }
31
31
 
32
+ export function windowsSystemRoot(): string {
33
+ const configured = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? process.env.windir;
34
+ if (configured && path.isAbsolute(configured)) return configured;
35
+ for (const candidate of ["powershell.exe", "cmd.exe"]) {
36
+ const resolved = Bun.which(candidate);
37
+ const match = resolved ? /^(.*)[\\/]System32(?:[\\/]|$)/i.exec(resolved) : null;
38
+ if (match?.[1] && path.isAbsolute(match[1])) return match[1];
39
+ }
40
+ throw new Error("SANDBOX_STATE: Windows system directory is unavailable");
41
+ }
42
+
32
43
  let nativeHome: Promise<string> | undefined;
33
44
 
34
45
  /** Bun userInfo() can inherit startup HOME; query the native account database instead. */
@@ -36,9 +47,9 @@ export function nativeSandboxHome(): Promise<string> {
36
47
  nativeHome ??= (async () => {
37
48
  let home: string | undefined;
38
49
  let argv: string[] | undefined;
50
+ let systemRoot = "";
39
51
  if (process.platform === "win32") {
40
- const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT;
41
- if (!systemRoot || !path.isAbsolute(systemRoot)) throw new Error("SANDBOX_STATE: native Windows system directory unavailable");
52
+ systemRoot = windowsSystemRoot();
42
53
  const script = "$ErrorActionPreference='Stop';[Console]::Out.Write([Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile))";
43
54
  argv = [path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")];
44
55
  } else {
@@ -52,7 +63,7 @@ export function nativeSandboxHome(): Promise<string> {
52
63
  }
53
64
  }
54
65
  if (argv) {
55
- const child = Bun.spawn(argv, { env: { SystemRoot: process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "" }, stdin: "ignore", stdout: "pipe", stderr: "ignore" });
66
+ const child = Bun.spawn(argv, { env: { SystemRoot: systemRoot }, stdin: "ignore", stdout: "pipe", stderr: "ignore" });
56
67
  const timer = setTimeout(() => child.kill(), 30_000);
57
68
  try {
58
69
  const [code, output] = await Promise.all([child.exited, new Response(child.stdout).text()]);
@@ -73,7 +84,10 @@ export async function canonicalSandboxPath(value: string, mustExist = true, cwd?
73
84
  if (process.platform === "win32" && /^(?:[\\/]{2}|[\\/]\?\?[\\/])/.test(value)) throw new Error("SANDBOX_PATH: Windows network and device paths are forbidden");
74
85
  const absolute = cwd === undefined ? path.resolve(value) : path.resolve(cwd, value);
75
86
  if (process.platform === "win32" && /^[\\/]{2}/.test(absolute)) throw new Error("SANDBOX_PATH: Windows network and device paths are forbidden");
76
- try { return await fs.realpath(absolute); }
87
+ try {
88
+ const resolved = await fs.realpath(absolute);
89
+ return process.platform === "win32" && /^[A-Za-z]:$/.test(resolved) ? `${resolved}\\` : resolved;
90
+ }
77
91
  catch (error) {
78
92
  const code = (error as NodeJS.ErrnoException).code;
79
93
  // macOS realpath can reject launchd sockets even though their parent is resolvable.
@@ -81,14 +95,17 @@ export async function canonicalSandboxPath(value: string, mustExist = true, cwd?
81
95
  if (!socket && (mustExist || code !== "ENOENT")) throw error;
82
96
  const parent = path.dirname(absolute);
83
97
  if (parent === absolute) throw error;
84
- return path.join(await canonicalSandboxPath(parent, false), path.basename(absolute));
98
+ const resolvedParent = await canonicalSandboxPath(parent, false);
99
+ const result = path.join(resolvedParent, path.basename(absolute));
100
+ return process.platform === "win32" && /^[A-Za-z]:$/.test(result) ? `${result}\\` : result;
85
101
  }
86
102
  }
87
103
 
88
104
  /** Deny endpoint ancestors even when their socket has not been created yet. */
89
105
  export async function validateSandboxMountSource(host: string, privatePaths: readonly string[], controlPaths: readonly string[] = []): Promise<void> {
90
106
  const home = await canonicalSandboxPath(os.homedir());
91
- if (host === path.parse(host).root || host === home) throw new Error("SANDBOX_POLICY: host root and whole-home mounts are forbidden");
107
+ const isRoot = host === path.parse(host).root || (process.platform === "win32" && /^[A-Za-z]:[\\/]?$/.test(host));
108
+ if (isRoot || host === home) throw new Error("SANDBOX_POLICY: host root and whole-home mounts are forbidden");
92
109
  if (privatePaths.some(privatePath => sandboxPathContains(host, privatePath) || sandboxPathContains(privatePath, host))) {
93
110
  throw new Error("SANDBOX_POLICY: mount overlaps private control state");
94
111
  }
@@ -60,7 +60,7 @@ import { AgyCliError, type AgyTurnResult } from "../agent/antigravity-cli";
60
60
  import { currentSandboxExecution, withSandboxExecution, runPreToolHooks, runPostTurnHooks } from "../agent/hooks";
61
61
  import { createSandboxSupervisor } from "../agent/sandbox/supervisor";
62
62
  import { createSandboxPolicy } from "../agent/sandbox/policy";
63
- import { createGrantAuthority } from "../agent/sandbox/authority";
63
+ import { createGrantAuthority, ensurePrivateSandboxDirectory } from "../agent/sandbox/authority";
64
64
  import { handleSandboxControl } from "../agent/sandbox/control";
65
65
  import type { SandboxConfig, SandboxSupervisor } from "../agent/sandbox/types";
66
66
  import { runAgySessionTurn } from "./launch/agy-turn";
@@ -75,6 +75,7 @@ import { RouteHistory } from "../agent/route-history";
75
75
 
76
76
  import { isRateLimitError, isUsageLimitError, isConnectionError } from "../util/retry";
77
77
  import { messengerUsageFallback } from "../messenger/model-policy";
78
+ import { messengerProfilePrivatePaths } from "../messenger/config";
78
79
 
79
80
 
80
81
  import { listAliases } from "../ai/model-registry";
@@ -400,6 +401,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
400
401
  let supervisor: SandboxSupervisor | undefined;
401
402
  let stateDirectory: string | undefined;
402
403
  let stopEndpoint: (() => Promise<void>) | undefined;
404
+ const setup = Promise.withResolvers<void>();
403
405
  let endpointStartup: Promise<void> | undefined;
404
406
  let closing: Promise<void> | undefined;
405
407
  const acknowledge = async (gone: boolean): Promise<void> => {
@@ -411,6 +413,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
411
413
  const close = (): Promise<void> => closing ??= (async () => {
412
414
  for (;;) {
413
415
  try {
416
+ await setup.promise;
414
417
  await endpointStartup;
415
418
  await stopEndpoint?.();
416
419
  // No supervisor means startup never had authority to create an environment.
@@ -445,6 +448,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
445
448
  const runSession = (): Promise<void> => {
446
449
  const ready = Promise.withResolvers<void>();
447
450
  endpointStartup = ready.promise;
451
+ setup.resolve();
448
452
  return runLaunchSession(args, cleanup => { stopEndpoint = cleanup; ready.resolve(); }).finally(ready.resolve);
449
453
  };
450
454
  try {
@@ -460,9 +464,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
460
464
  for (const legacy of [path.join(cwd, ".jeo", "sessions"), path.join(cwd, ".jeo", "messenger-sessions"), path.join(cwd, ".jeo", "memory", "raw")]) {
461
465
  if (fs.existsSync(legacy)) throw new Error("SANDBOX_PRIVATE_STATE: legacy transcripts are inside the workspace; relocate them before granting workspace access");
462
466
  }
463
- stateDirectory = fs.mkdtempSync(path.join(tmpdir(), "jeo-sandbox-authority-"));
467
+ // The messenger profile (config, token file, run script) is private control state wherever it lives, not only under ~/.jeo.
468
+ const profileDirectory = process.env.JEO_MESSENGER_PROFILE_DIR;
469
+ if (profileDirectory !== undefined && !path.isAbsolute(profileDirectory)) throw new Error("SANDBOX_PRIVATE_STATE: invalid messenger profile directory");
470
+ const profilePaths = profileDirectory === undefined ? [] : await messengerProfilePrivatePaths(profileDirectory, cwd);
471
+ if (closing) throw new Error("Messenger worker stopping");
472
+ // Publish the candidate before creating it so close() removes it even if owner-only creation or ACL verification fails midway.
473
+ const candidateStateDirectory = path.join(tmpdir(), `jeo-sandbox-authority-${crypto.randomUUID()}`);
474
+ stateDirectory = candidateStateDirectory;
475
+ stateDirectory = await ensurePrivateSandboxDirectory(candidateStateDirectory);
476
+ if (closing) throw new Error("Messenger worker stopping");
464
477
  const sessionDirectory = previousSessionDirectory || path.join(jeoHomeDir(), "sandbox-sessions", createHash("sha256").update(path.resolve(cwd)).digest("hex"));
465
- const privateRoots = [stateDirectory, sessionDirectory, jeoHomeDir(), notifySessionsDir()];
478
+ const privateRoots = [stateDirectory, sessionDirectory, jeoHomeDir(), notifySessionsDir(), ...profilePaths];
466
479
  const policy = await createSandboxPolicy({ sessionKey, revision: 0, cwd, config: sandboxConfig, privatePaths: privateRoots });
467
480
  if (closing) throw new Error("Messenger worker stopping");
468
481
  const sessionSupervisor = createSandboxSupervisor({ policy });
@@ -481,6 +494,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
481
494
  if (closing) throw new Error("Messenger worker stopping");
482
495
  await withSandboxExecution({ supervisor: sessionSupervisor, authority, requestAccess: (host, mode) => authority.requestAccess(host, mode) }, runSession);
483
496
  } finally {
497
+ setup.resolve();
484
498
  await close();
485
499
  process.removeListener("SIGTERM", handleTermination);
486
500
  process.removeListener("message", handleControlMessage);
@@ -159,6 +159,8 @@ export function createMessengerCliReply(config: MessengerConfig, spawn: typeof B
159
159
  delete env.JEO_MESSENGER_SANDBOX;
160
160
  delete env.JEO_MESSENGER_ALLOWED_USER_IDS;
161
161
  delete env.JEO_MESSENGER_SESSION_KEY;
162
+ delete env.JEO_MESSENGER_PROFILE_DIR;
163
+ if (config.profileDir !== undefined) env.JEO_MESSENGER_PROFILE_DIR = config.profileDir;
162
164
  if (sandbox) {
163
165
  env.JEO_MESSENGER_SANDBOX = JSON.stringify(sandbox);
164
166
  env.JEO_MESSENGER_ALLOWED_USER_IDS = JSON.stringify(platformConfig?.allowedUserIds ?? []);
@@ -147,6 +147,12 @@ export async function validateMessengerStorage(config: MessengerConfig): Promise
147
147
  if (sandboxPathContains(cwd, sessionDir) || sandboxPathContains(sessionDir, cwd)) {
148
148
  throw new Error("sessionDir must be outside and disjoint from the sandbox workspace");
149
149
  }
150
+ if (config.profileDir !== undefined) {
151
+ const profile = await canonicalSandboxPath(config.profileDir);
152
+ if (sandboxPathContains(cwd, profile) || sandboxPathContains(profile, cwd)) {
153
+ throw new Error("The sandbox workspace must be disjoint from the messenger profile directory (configuration and credentials); set cwd to a directory outside it");
154
+ }
155
+ }
150
156
  const pending = [path.join(cwd, ".jeo", "sessions"), path.join(cwd, ".jeo", "messenger-sessions"), path.join(cwd, ".jeo", "memory", "raw")];
151
157
  while (pending.length) {
152
158
  const directory = pending.pop()!;
@@ -162,6 +168,25 @@ export async function validateMessengerStorage(config: MessengerConfig): Promise
162
168
  }
163
169
  }
164
170
 
171
+ /**
172
+ * Host paths a sandbox mount or grant must never touch: the whole profile directory (messenger.json, token file,
173
+ * run script, anything created later) and every direct entry that is a symlink leaving it. Entries are canonicalized,
174
+ * so a symlink cannot move a secret outside the list. The workspace must be disjoint from the profile in both
175
+ * directions; any failure to inspect the profile throws instead of returning a shorter list.
176
+ */
177
+ export async function messengerProfilePrivatePaths(profileDir: string, cwd: string): Promise<string[]> {
178
+ const [profile, workspace] = await Promise.all([canonicalSandboxPath(profileDir), canonicalSandboxPath(cwd)]);
179
+ if (sandboxPathContains(workspace, profile) || sandboxPathContains(profile, workspace)) {
180
+ throw new Error("SANDBOX_PRIVATE_STATE: the sandbox workspace must be disjoint from the messenger profile directory (configuration and credentials)");
181
+ }
182
+ const privatePaths = [profile];
183
+ for (const name of await fs.readdir(profile)) {
184
+ const entry = await canonicalSandboxPath(path.join(profile, name), false);
185
+ if (!sandboxPathContains(profile, entry)) privatePaths.push(entry);
186
+ }
187
+ return privatePaths;
188
+ }
189
+
165
190
  export async function loadMessengerConfig(file: string): Promise<MessengerConfig> {
166
191
  let value: unknown;
167
192
  try {
@@ -169,7 +194,8 @@ export async function loadMessengerConfig(file: string): Promise<MessengerConfig
169
194
  } catch {
170
195
  throw new Error("Cannot read messenger configuration as JSON");
171
196
  }
172
- const config = parseMessengerConfig(value, path.dirname(path.resolve(file)));
197
+ const profileDir = path.dirname(path.resolve(file));
198
+ const config: MessengerConfig = { ...parseMessengerConfig(value, profileDir), profileDir };
173
199
  await validateMessengerStorage(config);
174
200
  const artifactDir = config.platforms.discord?.artifactDir;
175
201
  if (artifactDir) {
@@ -1,14 +1,20 @@
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, parseMessengerSandbox, 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";
8
- import { notifyDir } from "../agent/notify/paths";
9
+ import { jeoHomeDir, notifyDir, notifySessionsDir } from "../agent/notify/paths";
10
+ import { createSandboxPolicy } from "../agent/sandbox/policy";
11
+ import type { SandboxConfig } from "../agent/sandbox/types";
9
12
 
10
13
  export const DISCORD_TOKEN_ENV = "JEO_DISCORD_BOT_TOKEN";
11
14
  const TOKEN_FILE = "discord-token";
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";
12
18
  const RUN_SCRIPT_POSIX = "run-discord.sh";
13
19
  const RUN_SCRIPT_WINDOWS = "run-discord.cmd";
14
20
  const MESSAGE_CONTENT = 1 << 18;
@@ -495,11 +501,18 @@ export function discordRunCommand(command: string[], configName: string, tokenEn
495
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`;
496
502
  }
497
503
 
498
- async function writePrivate(file: string, content: string, mode: number): Promise<void> {
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> {
499
506
  const temporary = `${file}.${process.pid}.tmp`;
500
- await Bun.write(temporary, content);
501
- await fs.chmod(temporary, mode);
502
- 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
+ }
503
516
  }
504
517
 
505
518
  async function readExistingConfig(configPath: string): Promise<Record<string, unknown> | undefined> {
@@ -519,6 +532,61 @@ async function readExistingConfig(configPath: string): Promise<Record<string, un
519
532
  throw new Error(`${configPath} exists but is not a JSON object; fix or move it before setup`);
520
533
  }
521
534
 
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
+
522
590
  export function currentJeoCommand(entry = Bun.main, execPath = process.execPath): string[] {
523
591
  return entry === execPath || entry.startsWith("/$bunfs/") || entry.replaceAll("\\", "/").startsWith("B:/~BUN/") ? [execPath] : [execPath, entry];
524
592
  }
@@ -606,13 +674,22 @@ export async function setupDiscordMessenger(
606
674
 
607
675
  const discord: Record<string, unknown> = { ...previous, tokenEnv, allowedUserIds: userIds, allowedChannelIds };
608
676
  if (options.artifacts) discord.artifactDir = "./artifacts";
609
- 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);
610
687
  const config = {
611
688
  ...existing,
612
- cwd: str(existing?.cwd) ?? "./workspace",
613
- sessionDir: str(existing?.sessionDir) ?? path.join(notifyDir(), "messenger-sessions"),
689
+ cwd,
690
+ sessionDir,
614
691
  sandbox,
615
- toolNames: options.toolNames ?? (Array.isArray(existing?.toolNames) ? existing.toolNames : defaultTools),
692
+ toolNames: options.toolNames ?? (Array.isArray(existing?.toolNames) ? existing.toolNames : [...DEFAULT_MESSENGER_TOOLS]),
616
693
  platforms: { ...platforms, discord },
617
694
  };
618
695
  for (const relative of [config.cwd, config.sessionDir, discord.artifactDir]) {
@@ -620,7 +697,16 @@ export async function setupDiscordMessenger(
620
697
  await fs.mkdir(path.resolve(directory, relative), { recursive: true, mode: 0o700 });
621
698
  throwIfAborted(deps.signal);
622
699
  }
623
- await writePrivate(configPath, `${JSON.stringify(config, null, 2)}\n`, 0o600);
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
+ });
624
710
  throwIfAborted(deps.signal);
625
711
  await writePrivate(
626
712
  runScript,
@@ -630,12 +716,6 @@ export async function setupDiscordMessenger(
630
716
  0o700,
631
717
  );
632
718
  throwIfAborted(deps.signal);
633
- // The same loader `messenger run` uses must accept what was written.
634
- const written = await loadMessengerConfig(configPath);
635
- validateMessengerEnvironment(
636
- { ...written, platforms: { discord: written.platforms.discord } },
637
- { [tokenEnv]: token },
638
- );
639
719
 
640
720
  await probeDiscordGateway(api, token, deps.WebSocket ?? WebSocket, deps.signal);
641
721
  throwIfAborted(deps.signal);
@@ -216,7 +216,12 @@ export function messengerSessionRoute(cwd: string, message: MessengerMessage): s
216
216
  return new Bun.CryptoHasher("sha256").update(JSON.stringify([cwd, messengerConversationKey(message)])).digest("hex");
217
217
  }
218
218
 
219
- function schedulingKey(message: MessengerMessage, adapter: MessengerAdapter): string {
219
+ /**
220
+ * Admission lane. A top-level message has no thread yet, so only its raw channel can order it against its
221
+ * neighbours; anything already resolved is ordered by its own conversation. A lane is held only until the request
222
+ * is queued, never while it runs, so it cannot serialize requests that end up in different threads.
223
+ */
224
+ function admissionKey(message: MessengerMessage, adapter: MessengerAdapter): string {
220
225
  if (adapter.prepare && !message.direct && !message.threadId && !message.parentChannelId) {
221
226
  return JSON.stringify([message.platform, message.accountId, message.guildId, message.channelId, message.platform === "discord" ? null : message.senderId]);
222
227
  }
@@ -243,12 +248,15 @@ export function isMessengerMessageAllowed(message: MessengerMessage, config: Mes
243
248
  }
244
249
 
245
250
  interface PendingMessage {
251
+ /** Prepared destination: replies, deliveries and activity target it. */
246
252
  message: MessengerMessage;
247
253
  adapter: MessengerAdapter;
248
254
  signal: AbortSignal;
249
255
  resolve: () => void;
250
- /** Requests sharing a key never run at the same time. */
256
+ /** Destination conversation key; requests sharing it never run at the same time. */
251
257
  key: string;
258
+ /** Status reactions on the user's original message. */
259
+ status: (state: "processing" | "success" | "failure") => void;
252
260
  }
253
261
 
254
262
  export interface MessengerGateway {
@@ -260,8 +268,9 @@ export interface MessengerGatewayOptions {
260
268
  /** Minimum spacing between in-place activity edits for one request. */
261
269
  activityIntervalMs?: number;
262
270
  /**
263
- * Requests processed at once (default 1). Requests of one prepared conversation still answer and deliver in
264
- * order; above 1 the reply implementation must accept concurrent turns for different conversations.
271
+ * Requests processed at once (default 1). Requests of one conversation answer and deliver in arrival order and
272
+ * wait in the queue, not in a slot, behind their predecessor; above 1 the reply implementation must accept
273
+ * concurrent turns for different conversations.
265
274
  */
266
275
  concurrency?: number;
267
276
  /** Authenticated human controls bypass model queues, including busy sessions. */
@@ -286,23 +295,22 @@ export function createMessengerGateway(
286
295
  const queue: PendingMessage[] = [];
287
296
  const activeKeys = new Set<string>();
288
297
  const turns = new Set<Promise<void>>();
289
- /** Settles when the latest request of a prepared conversation has delivered its reply. */
290
- const conversationTails = new Map<string, Promise<void>>();
298
+ /** Orders admission (sandbox control, capacity check, destination preparation) per raw lane. */
291
299
  const admissionTails = new Map<string, Promise<void>>();
300
+ /** Requests holding a queue slot while the adapter prepares their destination; counted against the queue cap. */
301
+ let preparing = 0;
292
302
  const lifetime = new AbortController();
293
303
  let running = false;
294
304
  let fatalError: MessengerFatalError | undefined;
295
305
  let statusInFlight = 0;
296
306
 
297
- async function processRequest(pending: PendingMessage): Promise<void> {
298
- const { message, adapter, signal, resolve } = pending;
299
- let failed = false;
300
- let activity = NO_ACTIVITY;
301
- let release = (): void => {};
302
- let tail: Promise<void> | undefined;
303
- let conversation = "";
307
+ function createStatusReporter(
308
+ adapter: MessengerAdapter,
309
+ message: MessengerMessage,
310
+ signal: AbortSignal,
311
+ ): (state: "processing" | "success" | "failure") => void {
304
312
  let statusChain = Promise.resolve();
305
- const status = (state: "processing" | "success" | "failure"): void => {
313
+ return state => {
306
314
  if (!adapter.status) return;
307
315
  statusChain = statusChain.then(async () => {
308
316
  if (signal.aborted || statusInFlight >= MAX_STATUS_IN_FLIGHT) return;
@@ -332,22 +340,20 @@ export function createMessengerGateway(
332
340
  }
333
341
  });
334
342
  };
343
+ }
344
+
345
+ async function processRequest(pending: PendingMessage): Promise<void> {
346
+ const { message, adapter, signal, resolve, status } = pending;
347
+ let failed = false;
348
+ let activity = NO_ACTIVITY;
335
349
  try {
336
350
  if (signal.aborted) return;
337
351
  status("processing");
338
- const destination = (await adapter.prepare?.(message, signal)) ?? message;
339
- conversation = messengerConversationKey(destination);
340
- const previous = conversationTails.get(conversation);
341
- const done = Promise.withResolvers<void>();
342
- release = done.resolve;
343
- tail = done.promise;
344
- conversationTails.set(conversation, tail);
345
- await previous;
346
- activity = createActivityReporter(adapter, destination, signal, activityIntervalMs);
352
+ activity = createActivityReporter(adapter, message, signal, activityIntervalMs);
347
353
  activity.report("Starting request");
348
354
  let text: string;
349
355
  try {
350
- text = await reply(destination, signal, activity.report);
356
+ text = await reply(message, signal, activity.report);
351
357
  } catch (error) {
352
358
  failed = true;
353
359
  if (error instanceof MessengerFatalError) {
@@ -363,7 +369,7 @@ export function createMessengerGateway(
363
369
  }
364
370
  if (!signal.aborted && text.trim()) {
365
371
  // A failed send may already have delivered a chunk: never retry the entire reply.
366
- await adapter.send(destination, text, signal);
372
+ await adapter.send(message, text, signal);
367
373
  }
368
374
  if (!signal.aborted) status(failed ? "failure" : "success");
369
375
  } catch {
@@ -371,8 +377,6 @@ export function createMessengerGateway(
371
377
  if (!signal.aborted) status("failure");
372
378
  } finally {
373
379
  await activity.close(failed ? "failure" : signal.aborted ? "cancelled" : "success");
374
- release();
375
- if (tail && conversationTails.get(conversation) === tail) conversationTails.delete(conversation);
376
380
  resolve();
377
381
  }
378
382
  }
@@ -407,12 +411,12 @@ export function createMessengerGateway(
407
411
  const oldest = seen.values().next().value;
408
412
  if (oldest !== undefined) seen.delete(oldest);
409
413
  }
410
- const key = schedulingKey(message, adapter);
411
- const previous = admissionTails.get(key) ?? Promise.resolve();
414
+ const lane = admissionKey(message, adapter);
415
+ const previous = admissionTails.get(lane) ?? Promise.resolve();
412
416
  const ready = Promise.withResolvers<void>();
413
417
  const tail = previous.then(() => ready.promise);
414
- admissionTails.set(key, tail);
415
- void tail.then(() => { if (admissionTails.get(key) === tail) admissionTails.delete(key); });
418
+ admissionTails.set(lane, tail);
419
+ void tail.then(() => { if (admissionTails.get(lane) === tail) admissionTails.delete(lane); });
416
420
  try {
417
421
  if (options.sandboxControl) {
418
422
  const control = await options.sandboxControl(message, effectiveSignal);
@@ -422,9 +426,30 @@ export function createMessengerGateway(
422
426
  }
423
427
  }
424
428
  await previous;
425
- if (effectiveSignal.aborted || queue.length >= MAX_QUEUED_MESSAGES) return;
429
+ if (effectiveSignal.aborted) return;
430
+ const status = createStatusReporter(adapter, message, effectiveSignal);
431
+ // Capacity is claimed before preparing: a request that cannot be queued must never create a thread.
432
+ if (queue.length + preparing >= MAX_QUEUED_MESSAGES) {
433
+ // Never executed, so forget the id: a redelivery is accepted once capacity returns, replaying nothing.
434
+ seen.delete(id);
435
+ status("failure");
436
+ return;
437
+ }
438
+ let destination: MessengerMessage;
439
+ let key: string;
440
+ preparing++;
441
+ try {
442
+ destination = (await adapter.prepare?.(message, effectiveSignal)) ?? message;
443
+ key = messengerConversationKey(destination);
444
+ } catch {
445
+ if (!effectiveSignal.aborted) status("failure");
446
+ return;
447
+ } finally {
448
+ preparing--;
449
+ }
450
+ if (effectiveSignal.aborted) return;
426
451
  const { promise, resolve } = Promise.withResolvers<void>();
427
- queue.push({ message, adapter, signal: effectiveSignal, resolve, key });
452
+ queue.push({ message: destination, adapter, signal: effectiveSignal, resolve, key, status });
428
453
  schedule();
429
454
  return promise;
430
455
  } finally { ready.resolve(); }
@@ -50,6 +50,8 @@ export interface MessengerPlatformConfig {
50
50
  }
51
51
 
52
52
  export interface MessengerConfig {
53
+ /** Directory holding the loaded messenger.json, token file and run script; set by `loadMessengerConfig`. */
54
+ profileDir?: string;
53
55
  cwd: string;
54
56
  sessionDir: string;
55
57
  toolNames: string[];
package/src/util/retry.ts CHANGED
@@ -64,6 +64,18 @@ export function defaultRetryable(err: unknown): boolean {
64
64
  if (isRefusalError(err)) {
65
65
  return false;
66
66
  }
67
+ // An Antigravity/Gemini 3.x `finishReason=MALFORMED_FUNCTION_CALL` on an otherwise-200
68
+ // response that already carried partial text/functionCall parts is rejected by
69
+ // antigravity.ts as "…response incomplete (finishReason=MALFORMED_FUNCTION_CALL)."
70
+ // (call AND stream). It is an intermittent model fault, not a deterministic classifier
71
+ // verdict: the adapter throws BEFORE serializing the buffered functionCalls, so a resend
72
+ // executes no unvalidated call. Retry it within the normal attempt budget. Scoped to this
73
+ // exact shape — other `response incomplete (…)` reasons (MAX_TOKENS, missing finishReason)
74
+ // stay non-retryable; the empty-candidate variant ("returned no content (…)") is already
75
+ // retried below. A stream that has yielded a chunk is never re-run by retryableStream.
76
+ if (/Antigravity Cloud Code Assist response incomplete \(finishReason=MALFORMED_FUNCTION_CALL\)/i.test(errorMessageOf(err))) {
77
+ return true;
78
+ }
67
79
  // A pre-response connection failure (refused / unresolved host) — retryable: a
68
80
  // local provider that is mid-restart, or a transient DNS blip, can clear within
69
81
  // the backoff budget. A provider that is simply never running exhausts the