jeo-code 0.12.12 → 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 +47 -0
  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 +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 +39 -52
  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
@@ -3,16 +3,63 @@ import { constants } from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { Writable } from "node:stream";
6
- import { loadMessengerConfig, validateMessengerEnvironment } from "../messenger/config";
6
+ import { loadMessengerConfig, parseMessengerSandbox, validateMessengerEnvironment, validateMessengerStorage } from "../messenger/config";
7
7
  import { createDiscordAdapter } from "../messenger/discord";
8
8
  import { setupDiscordMessenger } from "../messenger/discord-setup";
9
9
  import { messengerArtifactPrompt } from "../messenger/artifacts";
10
- import { createMessengerGateway, messengerSessionRoute, messengerConversationKey } from "../messenger/gateway";
10
+ import { createMessengerGateway, messengerSessionRoute } from "../messenger/gateway";
11
11
  import { createMessengerSessionPool } from "../messenger/session-pool";
12
12
  import { createSlackAdapter } from "../messenger/slack";
13
13
  import { createTelegramAdapter } from "../messenger/telegram";
14
14
  import type { MessengerAdapter, MessengerConfig, MessengerReply } from "../messenger/types";
15
15
  import { createSession } from "../agent/session";
16
+ import { selectDiscordModel } from "../messenger/model-selection";
17
+ import type { MessengerGatewayOptions } from "../messenger/gateway";
18
+ import { notifySessionEndpointPath } from "../agent/notify/paths";
19
+ import { parseSessionEndpoint } from "../agent/notify/session-discovery";
20
+
21
+ async function sendSandboxControl(config: MessengerConfig, id: string, userId: string, text: string, signal: AbortSignal): Promise<string> {
22
+ const file = await fs.open(notifySessionEndpointPath(id), constants.O_RDONLY | constants.O_NOFOLLOW);
23
+ let raw: string;
24
+ try {
25
+ const stat = await file.stat();
26
+ if (!stat.isFile() || (process.platform !== "win32" && (stat.mode & 0o077) !== 0)) throw new Error("Sandbox endpoint discovery is not private");
27
+ raw = await file.readFile("utf8");
28
+ } finally { await file.close(); }
29
+ const endpoint = parseSessionEndpoint(raw);
30
+ if (!endpoint || endpoint.cwd !== config.cwd) throw new Error("Sandbox endpoint session mismatch");
31
+ if (signal.aborted) throw new Error("Sandbox control aborted");
32
+ const { promise, resolve, reject } = Promise.withResolvers<string>();
33
+ const reqId = crypto.randomUUID();
34
+ const ws = new WebSocket(`${endpoint.url}/?token=${encodeURIComponent(endpoint.token)}`);
35
+ let sent = false;
36
+ let settled = false;
37
+ const finish = (error?: Error, output?: string) => {
38
+ if (settled) return;
39
+ settled = true;
40
+ clearTimeout(timer);
41
+ signal.removeEventListener("abort", abort);
42
+ ws.close();
43
+ if (error) reject(error); else resolve(output ?? "Sandbox control returned no output");
44
+ };
45
+ const abort = () => finish(new Error("Sandbox control aborted"));
46
+ const timer = setTimeout(() => finish(new Error("Sandbox control timed out; outcome is unconfirmed")), 30_000);
47
+ signal.addEventListener("abort", abort, { once: true });
48
+ ws.addEventListener("error", () => finish(new Error("Sandbox control endpoint unavailable")));
49
+ ws.addEventListener("close", () => finish(new Error("Sandbox control endpoint closed")));
50
+ ws.addEventListener("message", event => {
51
+ try {
52
+ const frame = JSON.parse(String(event.data));
53
+ if (frame.type === "discord_control" && frame.sessionId === id && !sent) {
54
+ if (frame.enabled !== true || !Number.isSafeInteger(frame.generation)) return finish(new Error("Discord sandbox control is disabled"));
55
+ sent = true;
56
+ ws.send(JSON.stringify({ type: "sandbox_control", sessionId: id, reqId, text, actor: { origin: "discord", sessionKey: id, userId }, controlGeneration: frame.generation }));
57
+ } else if (frame.type === "ack" && frame.reqId === reqId && typeof frame.output === "string") finish(undefined, frame.output);
58
+ } catch { finish(new Error("Invalid sandbox control response")); }
59
+ });
60
+ if (signal.aborted) abort();
61
+ return promise;
62
+ }
16
63
 
17
64
  type Flags = { config?: string; channel: string[]; user: string[]; tools?: string; artifacts: boolean; service: boolean; noDm: boolean };
18
65
 
@@ -40,15 +87,20 @@ function parse(args: string[]): { action: "setup" | "check" | "run"; flags: Flag
40
87
  function terminalIO() {
41
88
  let muted = false;
42
89
  const output = new Writable({ write(chunk, _encoding, callback) { if (!muted) process.stdout.write(chunk); callback(); } });
43
- const rl = createInterface({ input: process.stdin, output, terminal: Boolean(process.stdin.isTTY && process.stdout.isTTY) });
90
+ const rl = createInterface({ input: process.stdin, output, terminal: Boolean(process.stdin.isTTY && process.stdout.isTTY), historySize: 0 });
91
+ const controller = new AbortController();
92
+ const interrupted = new AbortController();
93
+ rl.on("SIGINT", () => { interrupted.abort(); rl.close(); });
94
+ rl.on("close", () => controller.abort());
44
95
  const ask = async (question: string, secret = false) => {
45
96
  process.stdout.write(question);
46
97
  muted = secret;
47
- try { return (await rl.question("")).trim(); }
98
+ try { return (await rl.question("", { signal: controller.signal })).trim(); }
48
99
  finally { muted = false; if (secret) process.stdout.write("\n"); }
49
100
  };
50
101
  return {
51
102
  interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
103
+ signal: interrupted.signal,
52
104
  say: (line: string) => process.stdout.write(`${line}\n`),
53
105
  ask: (question: string) => ask(question),
54
106
  askSecret: (question: string) => ask(question, true),
@@ -57,53 +109,39 @@ function terminalIO() {
57
109
  }
58
110
 
59
111
  function routeUuid(config: MessengerConfig, message: Parameters<MessengerReply>[0]): string {
60
- if (config.independentSessions) {
61
- const hex = new Bun.CryptoHasher("sha256").update(JSON.stringify([config.cwd, messengerConversationKey(message), message.messageId])).digest("hex");
62
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
63
- }
64
112
  const hex = messengerSessionRoute(config.cwd, message);
65
113
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
66
114
  }
67
115
 
68
116
  /** Run each conversation in its own reusable agent process. */
69
- export function createMessengerCliReply(config: MessengerConfig, spawn: typeof Bun.spawn = Bun.spawn): MessengerReply & { close(): Promise<void> } {
70
- const pool = createMessengerSessionPool(spawn);
117
+ export function createMessengerCliReply(config: MessengerConfig, spawn: typeof Bun.spawn = Bun.spawn, selectModel: typeof selectDiscordModel = selectDiscordModel): MessengerReply & { close(): Promise<void>; sandboxControl: NonNullable<MessengerGatewayOptions["sandboxControl"]> } {
118
+ const sandbox = config.sandbox !== undefined || config.platforms.discord ? parseMessengerSandbox(config.sandbox) : undefined;
119
+ // Launch may inherit a global sandbox even when this platform has no local override.
120
+ const pool = createMessengerSessionPool(spawn, undefined, undefined, undefined, { requireSandboxCleanup: true });
71
121
  const reply: MessengerReply = async (message, signal, onActivity) => {
72
122
  if (signal.aborted) throw new Error("Messenger request aborted");
123
+ await validateMessengerStorage(config);
73
124
  const id = routeUuid(config, message);
74
125
  await fs.mkdir(config.sessionDir, { recursive: true, mode: 0o700 });
126
+ if (signal.aborted) throw new Error("Messenger request aborted");
75
127
  const target = path.join(config.sessionDir, `${id}.jsonl`);
76
- const legacy = path.join(config.cwd, ".jeo", "sessions", `${id}.jsonl`);
77
128
  try {
78
129
  await fs.access(target);
130
+ if (signal.aborted) throw new Error("Messenger request aborted");
79
131
  } catch (error) {
80
132
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
81
- let old;
82
- try { old = await fs.open(legacy, constants.O_RDONLY | constants.O_NOFOLLOW); }
83
- catch (openError) { if ((openError as NodeJS.ErrnoException).code !== "ENOENT") throw openError; }
84
- try {
85
- if (old) {
86
- if (!(await old.stat()).isFile()) throw new Error("Invalid legacy messenger session");
87
- const contents = await old.readFile();
88
- const temporary = `${target}.${crypto.randomUUID()}.tmp`;
89
- try {
90
- await fs.writeFile(temporary, contents, { flag: "wx", mode: 0o600 });
91
- await fs.link(temporary, target);
92
- } finally { await fs.unlink(temporary).catch(() => {}); }
93
- } else {
94
- await createSession(config.cwd, id, undefined, config.sessionDir, true);
95
- }
96
- } catch (createError) {
97
- if ((createError as NodeJS.ErrnoException).code !== "EEXIST") throw createError;
98
- } finally { await old?.close(); }
133
+ try { await createSession(config.cwd, id, undefined, config.sessionDir, true); }
134
+ catch (createError) { if ((createError as NodeJS.ErrnoException).code !== "EEXIST") throw createError; }
99
135
  }
100
136
  const artifactPrompt = await messengerArtifactPrompt(config.platforms[message.platform]?.artifactDir, message, signal);
137
+ if (signal.aborted) throw new Error("Messenger request aborted");
101
138
  const executable = process.execPath;
102
139
  const sourceEntry = process.argv[1]?.endsWith(".ts") ? process.argv[1] : undefined;
103
140
  const tools = [...new Set(config.toolNames.map(name => name === "grep" ? "search" : name === "glob" ? "find" : name))];
104
141
  const toolFlags = tools.length ? ["--tools", tools.join(",")] : ["--no-tools"];
105
142
  const platformConfig = config.platforms[message.platform];
106
- const modelFlags = platformConfig?.model ? ["--model", platformConfig.model] : [];
143
+ const model = message.platform === "discord" ? await selectModel(platformConfig?.model, { signal }) : platformConfig?.model;
144
+ const modelFlags = model ? ["--model", model] : [];
107
145
  const command = [executable, ...(sourceEntry ? ["--no-env-file", sourceEntry] : []), "launch", "--print", "--no-tui", "--no-skills", "--literal-worker", ...toolFlags, ...modelFlags, "--resume", id, "--", "worker"];
108
146
  const secretNames = new Set<string>();
109
147
  for (const platform of Object.values(config.platforms)) {
@@ -118,34 +156,62 @@ export function createMessengerCliReply(config: MessengerConfig, spawn: typeof B
118
156
  const env = Object.fromEntries(Object.entries(process.env).filter(([name, value]) => value !== undefined && !secretNames.has(name))) as Record<string, string>;
119
157
  for (const name of secretNames) env[name] = "";
120
158
  env.JEO_SESSION_DIR = config.sessionDir;
159
+ delete env.JEO_MESSENGER_SANDBOX;
160
+ delete env.JEO_MESSENGER_ALLOWED_USER_IDS;
161
+ delete env.JEO_MESSENGER_SESSION_KEY;
162
+ if (sandbox) {
163
+ env.JEO_MESSENGER_SANDBOX = JSON.stringify(sandbox);
164
+ env.JEO_MESSENGER_ALLOWED_USER_IDS = JSON.stringify(platformConfig?.allowedUserIds ?? []);
165
+ env.JEO_MESSENGER_SESSION_KEY = id;
166
+ }
121
167
  // The explicit tool allowlist is the opt-in for this worker only.
122
168
  env.JEO_MESSENGER_COMPUTER_ENABLED = tools.includes("computer") ? "1" : "0";
123
169
  // Never inherit another platform's fallback policy from the gateway environment.
124
170
  delete env.JEO_MESSENGER_USAGE_FALLBACK_MODEL;
125
- if (platformConfig?.usageFallbackModel) env.JEO_MESSENGER_USAGE_FALLBACK_MODEL = platformConfig.usageFallbackModel;
171
+ if (platformConfig?.usageFallbackModel) {
172
+ if (message.platform !== "discord") env.JEO_MESSENGER_USAGE_FALLBACK_MODEL = platformConfig.usageFallbackModel;
173
+ else {
174
+ try { env.JEO_MESSENGER_USAGE_FALLBACK_MODEL = await selectModel(platformConfig.usageFallbackModel, { signal }); }
175
+ catch {
176
+ if (signal.aborted) throw new Error("Messenger request aborted");
177
+ console.warn("Discord usage fallback model could not be verified; fallback disabled for this request.");
178
+ }
179
+ }
180
+ }
126
181
  delete env.JEO_MESSENGER_FIXED_MODEL;
127
- if (platformConfig?.model) env.JEO_MESSENGER_FIXED_MODEL = "1";
128
- return pool.turn(id, command, config.cwd, env, [message.text, artifactPrompt].filter(Boolean).join("\n\n"), signal, onActivity);
182
+ if (model) env.JEO_MESSENGER_FIXED_MODEL = "1";
183
+ const policyKey = message.platform === "discord" ? JSON.stringify([model, env.JEO_MESSENGER_USAGE_FALLBACK_MODEL]) : undefined;
184
+ return pool.turn(id, command, config.cwd, env, [message.text, artifactPrompt].filter(Boolean).join("\n\n"), signal, onActivity, policyKey);
185
+ };
186
+ const sandboxControl: NonNullable<MessengerGatewayOptions["sandboxControl"]> = async (message, signal) => {
187
+ if (!/^\/sandbox(?:\s|$)/.test(message.text)) return { handled: false, output: "" };
188
+ if (message.platform !== "discord" || !config.platforms.discord?.allowedUserIds.includes(message.senderId)) return { handled: true, output: "UNAUTHORIZED: Discord sandbox control required" };
189
+ try { return { handled: true, output: await sendSandboxControl(config, routeUuid(config, message), message.senderId, message.text, signal) }; }
190
+ catch (error) { return { handled: true, output: error instanceof Error ? error.message : "Sandbox control failed" }; }
129
191
  };
130
- return Object.assign(reply, { close: pool.close });
192
+ return Object.assign(reply, { close: pool.close, sandboxControl });
131
193
  }
132
194
 
133
195
  export async function runMessengerCommand(args: string[]): Promise<void> {
134
196
  const { action, flags } = parse([...args]);
135
197
  if (action === "setup") {
136
198
  const io = terminalIO();
199
+ const controller = new AbortController();
200
+ const stop = () => { controller.abort(); io.close(); };
201
+ process.once("SIGINT", stop);
202
+ process.once("SIGTERM", stop);
137
203
  try {
138
- const result = await setupDiscordMessenger({ configPath: path.resolve(flags.config!), channelIds: flags.channel.length ? flags.channel : undefined, userIds: flags.user.length ? flags.user : undefined, toolNames: flags.tools?.split(",").filter(Boolean), artifacts: flags.artifacts, service: flags.service, dmTest: !flags.noDm }, io);
204
+ const result = await setupDiscordMessenger({ configPath: path.resolve(flags.config!), channelIds: flags.channel.length ? flags.channel : undefined, userIds: flags.user.length ? flags.user : undefined, toolNames: flags.tools?.split(",").filter(Boolean), artifacts: flags.artifacts, service: flags.service, dmTest: !flags.noDm }, io, { signal: AbortSignal.any([controller.signal, io.signal]) });
139
205
  process.stdout.write(`Discord messenger ready: ${result.configPath}\nStart: ${result.runScript}\n`);
140
- } finally { io.close(); }
206
+ } finally { process.off("SIGINT", stop); process.off("SIGTERM", stop); io.close(); }
141
207
  return;
142
208
  }
143
209
  let config: MessengerConfig;
144
210
  try { config = await loadMessengerConfig(flags.config!); validateMessengerEnvironment(config); }
145
- catch { throw new Error("Messenger configuration or required environment is invalid"); }
211
+ catch (error) { throw new Error(error instanceof Error ? error.message : "Messenger configuration or required environment is invalid"); }
146
212
  const platforms = Object.keys(config.platforms);
147
213
  if (action === "check") { process.stdout.write(`Messenger configuration valid: ${platforms.join(", ")}; ${config.toolNames.length} tools enabled. No network checks performed.\n`); return; }
148
- if (config.toolNames.length) process.stderr.write("Warning: messenger tools grant authorized remote users local machine authority; this is not a sandbox.\n");
214
+ if (config.sandbox) process.stderr.write("Messenger tools execute in session-specific containers with approved workspace mounts and no network. Sandbox failures do not fall back to host execution.\n");
149
215
  const adapters: MessengerAdapter[] = [];
150
216
  if (config.platforms.discord) adapters.push(createDiscordAdapter(config.platforms.discord));
151
217
  if (config.platforms.telegram) adapters.push(createTelegramAdapter(config.platforms.telegram));
@@ -154,6 +220,6 @@ export async function runMessengerCommand(args: string[]): Promise<void> {
154
220
  const stop = () => controller.abort();
155
221
  process.once("SIGINT", stop); process.once("SIGTERM", stop);
156
222
  const reply = createMessengerCliReply(config);
157
- try { process.stdout.write(`Starting messenger: ${platforms.join(", ")}. Press Ctrl+C to stop.\n`); await createMessengerGateway(config, adapters, reply, { concurrency: 4 }).run(controller.signal); }
223
+ try { process.stdout.write(`Starting messenger: ${platforms.join(", ")}. Press Ctrl+C to stop.\n`); await createMessengerGateway(config, adapters, reply, { concurrency: 4, sandboxControl: reply.sandboxControl }).run(controller.signal); }
158
224
  finally { controller.abort(); await reply.close(); process.off("SIGINT", stop); process.off("SIGTERM", stop); }
159
225
  }
@@ -175,7 +175,7 @@ async function runSetup(flags: Flags, opts: NotifyOptions): Promise<void> {
175
175
  });
176
176
  console.log(`Telegram bot verified; chatId=${chatId}. Notifications enabled. Setup did not send a message.`);
177
177
  }
178
- console.log("Run 'jeo daemon start' (or reload if already running) to apply. Use 'jeo notify health --provider " + flags.provider + "' for read-only API checks; 'jeo notify test --provider " + flags.provider + "' explicitly sends a test message.");
178
+ console.log("Setup only saves and verifies configuration; it does not start the notification daemon. Run 'jeo daemon start' (or reload if already running) to apply. Use 'jeo notify health --provider " + flags.provider + "' for read-only API checks; 'jeo notify test --provider " + flags.provider + "' explicitly sends a test message.");
179
179
  }
180
180
 
181
181
  async function runStatus(provider: Provider): Promise<void> {
@@ -188,7 +188,7 @@ async function runStatus(provider: Provider): Promise<void> {
188
188
  if (provider === "slack") console.log(`appToken=${n?.slack?.appToken ? "(set)" : "(not set)"}`);
189
189
  console.log(provider === "telegram" ? `chatId=${n?.telegram?.chatId ?? "(not set)"}` : `channelId=${n?.[provider]?.channelId ?? "(not set)"}`);
190
190
  console.log(`allowedUserIds=${channel?.allowedUserIds?.join(",") ?? (provider === "telegram" ? "private chat owner only" : "(not set; inbound disabled)")}`);
191
- console.log(`daemon=${status.pairing ? "pairing owns polling (daemon not running)" : status.running ? `process ${status.ready ? "initialized" : "initializing"} (pid ${status.pid}; transport connectivity not checked)` : status.stale ? "stale" : "stopped"}`);
191
+ console.log(`daemon=${status.pairing ? "pairing owns polling (daemon not running)" : status.running ? `process ${status.ready ? "initialized" : "initializing"} (pid ${status.pid}; transport connectivity not checked)` : status.stale ? "stale" : status.configured ? "stopped (configuration is saved; run 'jeo daemon start')" : "stopped (not configured)"}`);
192
192
  console.log("Channel health not checked; use 'jeo notify health --provider " + provider + "'.");
193
193
  }
194
194
 
@@ -1,10 +1,10 @@
1
- import * as fs from "node:fs/promises";
2
1
  import * as path from "node:path";
3
2
  import { createHash } from "node:crypto";
4
3
  import chalk from "chalk";
5
4
  import { PlanSchema, normalizePlanShape, parseYaml } from "../agent/plan";
6
5
  import {
7
6
  readWorkflowState,
7
+ readWorkflowFile,
8
8
  readWorkflowStateStrict,
9
9
  writeWorkflowState,
10
10
  acquireWorkflowRunLock,
@@ -25,6 +25,7 @@ import {
25
25
  } from "../agent/subagents";
26
26
  import { loadProjectContext } from "../agent/context-files";
27
27
  import { categoryBadge } from "../tui/components/category-index";
28
+ import { currentSandboxExecution } from "../agent/hooks";
28
29
 
29
30
  export type RalphStreamKind = "step" | "complete" | "error" | "warn";
30
31
 
@@ -220,7 +221,7 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<TeamE
220
221
 
221
222
  let planContent = "";
222
223
  try {
223
- planContent = await fs.readFile(planPath, "utf-8");
224
+ planContent = await readWorkflowFile(planPath, cwd);
224
225
  } catch (err: any) {
225
226
  log(`[ERROR] Failed to read plan file: ${err.message}`);
226
227
  return { ok: false, reason: err.message };
@@ -332,7 +333,7 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<TeamE
332
333
  // with `git status --porcelain` and report the CONCRETE uncommitted count so
333
334
  // the user knows whether real partial work is present before re-running on it.
334
335
  if (teamState.current_phase === "failed" && teamState.failed_task) {
335
- const dirty = gitDirtyCount(cwd);
336
+ const dirty = await workflowDirtyCount(cwd);
336
337
  const treeNote = dirty === undefined
337
338
  ? `The working tree could not be inspected (not a git repo or git unavailable) — review it manually before trusting this re-run.`
338
339
  : dirty > 0
@@ -597,11 +598,40 @@ interface ParallelWorktree {
597
598
  branch: string;
598
599
  }
599
600
 
600
- function runGitSync(cwd: string, args: string[]): { ok: boolean; stdout: string; stderr: string } {
601
+ async function runGit(cwd: string, args: string[]): Promise<{ ok: boolean; stdout: string; stderr: string }> {
602
+ const sandbox = currentSandboxExecution();
603
+ if (sandbox) {
604
+ const result = await sandbox.supervisor.exec(["git", ...args], { cwd, signal: sandbox.signal });
605
+ return { ok: result.success && result.exitCode === 0, stdout: result.stdout ?? result.output, stderr: result.stderr ?? result.error ?? "" };
606
+ }
601
607
  const res = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
602
608
  return { ok: res.exitCode === 0, stdout: res.stdout.toString(), stderr: res.stderr.toString() };
603
609
  }
604
610
 
611
+ async function workflowDirtyCount(cwd: string): Promise<number | undefined> {
612
+ if (!currentSandboxExecution()) return gitDirtyCount(cwd);
613
+ const result = await runGit(cwd, ["status", "--porcelain"]);
614
+ if (!result.ok) throw new Error(result.stderr || "Sandbox git status failed");
615
+ return result.stdout.split("\n").filter(line => line.trim().length > 0).length;
616
+ }
617
+
618
+ async function workflowWorktree(cwd: string, worktreePath: string): Promise<string> {
619
+ const sandbox = currentSandboxExecution();
620
+ if (!sandbox) return resolveWorktree(cwd, worktreePath);
621
+ // Only cwd is translated by exec; keep git paths relative to that guest cwd.
622
+ const relativePath = path.relative(cwd, worktreePath).split(path.sep).join("/");
623
+ const exists = await sandbox.supervisor.exec(["test", "-d", relativePath], { cwd, signal: sandbox.signal });
624
+ if (exists.success && exists.exitCode === 0) return worktreePath;
625
+ if (exists.exitCode !== 1) throw new Error(exists.error || exists.stderr || "Sandbox worktree inspection failed");
626
+ const branch = path.basename(worktreePath).replace(/[^a-zA-Z0-9_-]/g, "-") || "jeo-wt";
627
+ const created = await runGit(cwd, ["worktree", "add", "-b", branch, "--", relativePath]);
628
+ if (!created.ok) {
629
+ const attached = await runGit(cwd, ["worktree", "add", "--", relativePath]);
630
+ if (!attached.ok) throw new Error(attached.stderr || created.stderr || "Sandbox worktree creation failed");
631
+ }
632
+ return worktreePath;
633
+ }
634
+
605
635
  /**
606
636
  * Execute a CONTIGUOUS run of `parallel_group`-marked plan steps concurrently,
607
637
  * each in its own git worktree via `resolveWorktree` + the SAME
@@ -637,8 +667,9 @@ async function runParallelGroup(
637
667
 
638
668
  for (const step of group) {
639
669
  const worktreePath = path.join(cwd, ".jeo", "team-worktrees", `${slug}-${step.index}`);
640
- const resolvedPath = resolveWorktree(cwd, worktreePath);
641
- const branchRes = runGitSync(resolvedPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
670
+ const resolvedPath = await workflowWorktree(cwd, worktreePath);
671
+ const branchRes = await runGit(resolvedPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
672
+ if (!branchRes.ok) throw new Error(branchRes.stderr || "Failed to resolve worktree branch");
642
673
  const branch = branchRes.stdout.trim();
643
674
  worktreeByIndex.set(step.index, { path: resolvedPath, branch });
644
675
 
@@ -702,13 +733,13 @@ async function runParallelGroup(
702
733
  }
703
734
  // An empty diff (read-only role, or a no-op) needs no commit — skip it for
704
735
  // the merge step below without treating it as an error.
705
- if (gitDirtyCount(wt.path)) {
706
- const add = runGitSync(wt.path, ["add", "-A"]);
736
+ if (await workflowDirtyCount(wt.path)) {
737
+ const add = await runGit(wt.path, ["add", "-A"]);
707
738
  if (!add.ok) {
708
739
  log(`${categoryBadge("error")} Failed to stage changes in worktree ${wt.path} for step "${step.name}": ${add.stderr.trim()}`);
709
740
  return { ok: false, failedTaskName: step.name, ...(registryMutation ? { mutationMayHaveOccurred: true } : {}) };
710
741
  }
711
- const commit = runGitSync(wt.path, ["commit", "-m", step.name]);
742
+ const commit = await runGit(wt.path, ["commit", "-m", step.name]);
712
743
  if (!commit.ok) {
713
744
  log(`${categoryBadge("error")} Failed to commit changes in worktree ${wt.path} for step "${step.name}": ${commit.stderr.trim()}`);
714
745
  return { ok: false, failedTaskName: step.name, ...(registryMutation ? { mutationMayHaveOccurred: true } : {}) };
@@ -721,9 +752,9 @@ async function runParallelGroup(
721
752
  // rest of the group's merges.
722
753
  for (const step of group) {
723
754
  const wt = worktreeByIndex.get(step.index)!;
724
- const merge = runGitSync(cwd, ["merge", "--no-ff", wt.branch, "-m", `merge: ${step.name}`]);
755
+ const merge = await runGit(cwd, ["merge", "--no-ff", wt.branch, "-m", `merge: ${step.name}`]);
725
756
  if (!merge.ok) {
726
- runGitSync(cwd, ["merge", "--abort"]);
757
+ await runGit(cwd, ["merge", "--abort"]);
727
758
  log(
728
759
  `${categoryBadge("error")} Merge conflict merging step "${step.name}"'s branch "${wt.branch}" into the current branch — ` +
729
760
  `the merge was aborted automatically (no changes were auto-resolved). Resolve manually: ` +
@@ -738,7 +769,8 @@ async function runParallelGroup(
738
769
  // e.g. stray untracked files, is a warning, never a hard failure of the run).
739
770
  for (const step of group) {
740
771
  const wt = worktreeByIndex.get(step.index)!;
741
- const removal = runGitSync(cwd, ["worktree", "remove", wt.path]);
772
+ const worktreePath = currentSandboxExecution() ? path.relative(cwd, wt.path).split(path.sep).join("/") : wt.path;
773
+ const removal = await runGit(cwd, ["worktree", "remove", worktreePath]);
742
774
  if (!removal.ok) {
743
775
  log(`[WARN] Could not remove worktree ${wt.path} for step "${step.name}": ${removal.stderr.trim()}`);
744
776
  }
@@ -1,7 +1,6 @@
1
- import * as fs from "node:fs/promises";
2
1
  import * as path from "node:path";
3
- import { readWorkflowState, writeWorkflowState, getLocalJeoDir, type WorkflowState } from "../agent/state";
4
- import { bashTool } from "../agent/tools";
2
+ import { readWorkflowState, writeWorkflowState, readWorkflowFile, writeWorkflowFile, getLocalJeoDir, type WorkflowState } from "../agent/state";
3
+ import { DEFAULT_TOOLS } from "../agent/engine";
5
4
  import { parseSeedAcceptanceCriteria, parseCriterion } from "../agent/seed";
6
5
 
7
6
  export interface UltragoalEngineOptions {
@@ -62,7 +61,7 @@ export async function runUltragoalEngine(opts: UltragoalEngineOptions = {}): Pro
62
61
 
63
62
  let seedContent = "";
64
63
  try {
65
- seedContent = await fs.readFile(seedPath, "utf-8");
64
+ seedContent = await readWorkflowFile(seedPath, cwd);
66
65
  } catch (err: any) {
67
66
  log(`[ERROR] Failed to read seed file: ${err.message}`);
68
67
  return { ok: false, reason: err.message };
@@ -94,7 +93,7 @@ export async function runUltragoalEngine(opts: UltragoalEngineOptions = {}): Pro
94
93
  if (opts.onProgress) {
95
94
  opts.onProgress({ skill: "ultragoal", phase: "verifying", detail: "Running verification suite" });
96
95
  }
97
- const suite = await bashTool("bun test", cwd);
96
+ const suite = await DEFAULT_TOOLS.bash!({ command: "bun test" }, cwd);
98
97
  log(` └─ Suite: ${suite.success ? "GREEN" : "FAILED"}`);
99
98
 
100
99
  if (opts.signal?.aborted) {
@@ -111,7 +110,7 @@ export async function runUltragoalEngine(opts: UltragoalEngineOptions = {}): Pro
111
110
  opts.onProgress({ skill: "ultragoal", phase: "verifying", detail: `Verifying: ${c.text}` });
112
111
  }
113
112
  log(`[CHECK] ${c.text}\n └─ $ ${c.verify}`);
114
- const check = await bashTool(c.verify, cwd);
113
+ const check = await DEFAULT_TOOLS.bash!({ command: c.verify }, cwd);
115
114
  log(` ${check.success ? "PASS" : "FAIL"}`);
116
115
  results.push(
117
116
  check.success
@@ -131,7 +130,6 @@ export async function runUltragoalEngine(opts: UltragoalEngineOptions = {}): Pro
131
130
 
132
131
  // Write verification report
133
132
  const reportDir = path.join(getLocalJeoDir(cwd), "state");
134
- await fs.mkdir(reportDir, { recursive: true });
135
133
  const reportPath = path.join(reportDir, "ultragoal-report.md");
136
134
 
137
135
  const totalCount = results.length;
@@ -168,15 +166,7 @@ export async function runUltragoalEngine(opts: UltragoalEngineOptions = {}): Pro
168
166
  results.map(r => `| ${r.criterion} | ${r.status === "passed" ? "✅ PASSED" : r.status === "failed" ? "❌ FAILED" : "⚠️ UNVERIFIED"} | ${r.note} |`).join("\n") +
169
167
  `\n`;
170
168
 
171
- // Atomic temp+rename (zeroclaw): a torn report must not disagree with the state JSON.
172
- const tmpReport = `${reportPath}.${Math.random().toString(36).slice(2)}.tmp`;
173
- try {
174
- await fs.writeFile(tmpReport, reportContent, "utf-8");
175
- await fs.rename(tmpReport, reportPath);
176
- } catch (err) {
177
- await fs.unlink(tmpReport).catch(() => {});
178
- throw err;
179
- }
169
+ await writeWorkflowFile(reportPath, reportContent, cwd);
180
170
 
181
171
  // Persist a machine-readable terminal phase so the chain is queryable end-to-end.
182
172
  const ultragoalState: WorkflowState = {
@@ -1,7 +1,8 @@
1
1
  import type { AgentLoopEvents, ToolInvocation } from "../agent/engine";
2
2
  import type { MessengerActivityReporter } from "./types";
3
3
 
4
- /** Derive bounded progress without exposing tool arguments, results, or provider errors. */
4
+
5
+ /** Report only fixed status text and validated tool names, never model-authored content. */
5
6
  export function createMessengerActivityEvents(report: MessengerActivityReporter): AgentLoopEvents {
6
7
  let step = 0;
7
8
  return {
@@ -1,6 +1,9 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import type { MessengerConfig, MessengerPlatform, MessengerPlatformConfig } from "./types";
4
+ import { canonicalSandboxPath, sandboxPathContains, validateSandboxConfig } from "../agent/sandbox/policy";
5
+ import type { SandboxConfig } from "../agent/sandbox/types";
6
+ import { notifyDir } from "../agent/notify/paths";
4
7
 
5
8
  const PLATFORMS: MessengerPlatform[] = ["discord", "telegram", "slack"];
6
9
  const TOOLS: Record<string, true> = {
@@ -13,6 +16,7 @@ const TOOLS: Record<string, true> = {
13
16
  edit: true,
14
17
  write: true,
15
18
  computer: true,
19
+ sandbox_request: true,
16
20
  };
17
21
 
18
22
  function object(value: unknown, label: string): Record<string, unknown> {
@@ -61,11 +65,25 @@ export function resolveMessengerToken(
61
65
  return undefined;
62
66
  }
63
67
 
64
- export const DEFAULT_MESSENGER_TOOLS = ["read", "find", "search", "write", "edit", "bash", "computer"];
68
+ export const DEFAULT_MESSENGER_TOOLS = ["read", "find", "search", "write", "edit", "bash", "computer", "sandbox_request"];
69
+
70
+ export function parseMessengerSandbox(value: unknown): SandboxConfig {
71
+ const input = object(value, "sandbox");
72
+ keys(input, ["image", "limits"], "sandbox");
73
+ const limits = object(input.limits, "sandbox.limits");
74
+ keys(limits, ["memoryBytes", "pids", "cpus"], "sandbox.limits");
75
+ const config: SandboxConfig = {
76
+ image: string(input.image, "sandbox.image"),
77
+ limits: { memoryBytes: limits.memoryBytes as number, pids: limits.pids as number, cpus: limits.cpus as number },
78
+ };
79
+ validateSandboxConfig(config);
80
+ return config;
81
+ }
65
82
 
66
83
  export function parseMessengerConfig(value: unknown, baseDir: string): MessengerConfig {
67
84
  const input = object(value, "Messenger config");
68
- keys(input, ["cwd", "sessionDir", "toolNames", "platforms", "independentSessions"], "Messenger config");
85
+ if (Object.hasOwn(input, "independentSessions")) throw new Error("independentSessions is obsolete; run messenger setup to migrate to persistent conversation sessions");
86
+ keys(input, ["cwd", "sessionDir", "sandbox", "toolNames", "platforms"], "Messenger config");
69
87
  const platformsInput = object(input.platforms, "platforms");
70
88
  keys(platformsInput, PLATFORMS, "platforms");
71
89
  if (Object.keys(platformsInput).length === 0) throw new Error("At least one messenger platform is required");
@@ -108,19 +126,42 @@ export function parseMessengerConfig(value: unknown, baseDir: string): Messenger
108
126
  ) {
109
127
  throw new Error("toolNames must be a unique list drawn from read, grep, glob, find, search, bash, edit, write, computer");
110
128
  }
111
- const independentSessions = input.independentSessions !== undefined ? Boolean(input.independentSessions) : true;
129
+ const sandbox = input.sandbox !== undefined || platforms.discord ? parseMessengerSandbox(input.sandbox) : undefined;
130
+ const cwd = path.resolve(baseDir, input.cwd === undefined ? "." : string(input.cwd, "cwd"));
131
+ const sessionDir = input.sessionDir === undefined ? path.join(notifyDir(), "messenger-sessions") : path.resolve(baseDir, string(input.sessionDir, "sessionDir"));
132
+ if (sandbox && (sandboxPathContains(cwd, sessionDir) || sandboxPathContains(sessionDir, cwd))) {
133
+ throw new Error("sessionDir must be outside and disjoint from the sandbox workspace");
134
+ }
112
135
  return {
113
- cwd: path.resolve(baseDir, input.cwd === undefined ? "." : string(input.cwd, "cwd")),
114
- sessionDir: path.resolve(
115
- baseDir,
116
- input.sessionDir === undefined ? ".jeo/messenger-sessions" : string(input.sessionDir, "sessionDir"),
117
- ),
136
+ cwd,
137
+ sessionDir,
118
138
  toolNames: [...toolNames] as string[],
139
+ ...(sandbox ? { sandbox } : {}),
119
140
  platforms,
120
- independentSessions,
121
141
  };
122
142
  }
123
143
 
144
+ export async function validateMessengerStorage(config: MessengerConfig): Promise<void> {
145
+ if (!config.sandbox && !config.platforms.discord) return;
146
+ const [cwd, sessionDir] = await Promise.all([canonicalSandboxPath(config.cwd), canonicalSandboxPath(config.sessionDir, false)]);
147
+ if (sandboxPathContains(cwd, sessionDir) || sandboxPathContains(sessionDir, cwd)) {
148
+ throw new Error("sessionDir must be outside and disjoint from the sandbox workspace");
149
+ }
150
+ const pending = [path.join(cwd, ".jeo", "sessions"), path.join(cwd, ".jeo", "messenger-sessions"), path.join(cwd, ".jeo", "memory", "raw")];
151
+ while (pending.length) {
152
+ const directory = pending.pop()!;
153
+ let entries;
154
+ try { entries = await fs.readdir(directory, { withFileTypes: true }); }
155
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
156
+ for (const entry of entries) {
157
+ if (/\.(?:jsonl|json)$/i.test(entry.name) || entry.isSymbolicLink()) {
158
+ throw new Error("Legacy session transcripts or raw history may be exposed in the sandbox workspace; move existing private history outside the workspace before starting the sandbox");
159
+ }
160
+ if (entry.isDirectory()) pending.push(path.join(directory, entry.name));
161
+ }
162
+ }
163
+ }
164
+
124
165
  export async function loadMessengerConfig(file: string): Promise<MessengerConfig> {
125
166
  let value: unknown;
126
167
  try {
@@ -129,6 +170,7 @@ export async function loadMessengerConfig(file: string): Promise<MessengerConfig
129
170
  throw new Error("Cannot read messenger configuration as JSON");
130
171
  }
131
172
  const config = parseMessengerConfig(value, path.dirname(path.resolve(file)));
173
+ await validateMessengerStorage(config);
132
174
  const artifactDir = config.platforms.discord?.artifactDir;
133
175
  if (artifactDir) {
134
176
  const stat = await fs.lstat(artifactDir).catch(() => undefined);