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
@@ -0,0 +1,77 @@
1
+ import type { ToolHandler } from "../engine";
2
+ import type { ToolResult } from "../tools";
3
+
4
+ export interface SandboxMount {
5
+ readonly host: string;
6
+ readonly guest: string;
7
+ readonly mode: "ro" | "rw";
8
+ }
9
+
10
+ export interface SandboxLimits {
11
+ readonly memoryBytes: number;
12
+ readonly pids: number;
13
+ readonly cpus: number;
14
+ }
15
+
16
+ export interface SandboxConfig {
17
+ readonly image: string;
18
+ readonly limits: SandboxLimits;
19
+ }
20
+
21
+ export interface SandboxPolicy {
22
+ readonly sessionKey: string;
23
+ readonly revision: number;
24
+ readonly mounts: readonly SandboxMount[];
25
+ readonly privatePaths?: readonly string[];
26
+ readonly config: SandboxConfig;
27
+ readonly digest: string;
28
+ }
29
+
30
+ export interface SandboxTermination {
31
+ readonly gone: boolean;
32
+ readonly residual: readonly string[];
33
+ }
34
+
35
+ export interface SandboxProcess {
36
+ readonly name: string;
37
+ readonly stdin: { write(data: string): unknown; flush(): unknown; end(): unknown };
38
+ readonly stdout: ReadableStream<Uint8Array>;
39
+ readonly stderr: ReadableStream<Uint8Array>;
40
+ readonly exited: Promise<number>;
41
+ stop(): Promise<SandboxTermination>;
42
+ }
43
+
44
+ export interface SandboxBackend {
45
+ preflight(config: SandboxConfig): Promise<{ imageId: string; platform: string }>;
46
+ launch(policy: SandboxPolicy, bundlePath: string): Promise<SandboxProcess>;
47
+ }
48
+
49
+ export interface SandboxCallOptions {
50
+ cwd?: string;
51
+ signal?: AbortSignal;
52
+ onProgress?: (text: string) => void;
53
+ }
54
+
55
+ export interface SandboxExecOptions {
56
+ cwd?: string;
57
+ env?: Record<string, string>;
58
+ stdin?: string;
59
+ signal?: AbortSignal;
60
+ }
61
+
62
+ export interface SandboxExecResult extends ToolResult {
63
+ stdout?: string;
64
+ stderr?: string;
65
+ exitCode?: number | null;
66
+ timedOut?: boolean;
67
+ aborted?: boolean;
68
+ }
69
+
70
+ export interface SandboxSupervisor {
71
+ toolMap(names: readonly string[]): Record<string, ToolHandler>;
72
+ call(tool: string, args: Record<string, unknown>, options?: SandboxCallOptions): Promise<ToolResult>;
73
+ exec(argv: readonly string[], options?: SandboxExecOptions): Promise<SandboxExecResult>;
74
+ replacePolicy(policy: SandboxPolicy): Promise<void>;
75
+ revoke(): Promise<SandboxTermination>;
76
+ close(): Promise<void>;
77
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Process-group-safe shell spawn for background jobs and monitors.
3
+ *
4
+ * A bare `bash -c <command>` is its own process-group leader (`detached`), but the
5
+ * moment that shell is reaped its PGID becomes reusable by an unrelated process. Any
6
+ * later `kill(-pgid)` could then hit a stranger's group. So on POSIX the registries
7
+ * never signal a group whose leader has been reaped, and the user's command runs as
8
+ * a CHILD of a small "lease holder" shell that stays alive — keeping the PGID pinned
9
+ * (the kernel never reuses a pid/pgid while a group member, zombies included, exists)
10
+ * — until the registry explicitly `release()`s it:
11
+ *
12
+ * - exit status travels through a private status file (stdout/stderr EOF cannot carry
13
+ * it, and a descendant holding those pipes would delay it indefinitely; Bun's extra
14
+ * stdio pipes are unreliable on Linux, so no fd beyond 0-2 is used);
15
+ * - the holder closes its own stdout/stderr first, so only real descendants keep
16
+ * the output pipes open;
17
+ * - release is "stdin closed"; the holder only reads it after the command exits, so a
18
+ * registry that dies does NOT clean up a still-running command (same as before the lease).
19
+ *
20
+ * `killGroup()` is the only place a group signal is sent. It refuses once the holder
21
+ * is gone or released (`"unpinned"`), so callers report an unconfirmed termination
22
+ * instead of signalling a possibly reused PGID. A delivered SIGKILL reaches every
23
+ * member of the pinned group atomically; descendants that left the group (`setsid`)
24
+ * are not reachable and show up as output pipes that stay open; a descendant that left
25
+ * the group AND closed both pipes is undetectable, so EOF alone never proves it ended.
26
+ *
27
+ * Residual window (cannot be closed from JS): the holder dying — only by an external
28
+ * or in-command group/SIGKILL — and its pid wrapping around before the exit event
29
+ * reaches this process.
30
+ *
31
+ * Windows has no process groups here: `killGroup()` is `"unsupported"` and callers
32
+ * must keep the job unresolved.
33
+ */
34
+ import * as cp from "node:child_process";
35
+ import * as fs from "node:fs";
36
+ import * as os from "node:os";
37
+ import * as path from "node:path";
38
+ import { Readable } from "node:stream";
39
+
40
+ export interface ShellReader {
41
+ read(): Promise<{ done?: boolean; value?: Uint8Array }>;
42
+ cancel(): Promise<void>;
43
+ }
44
+
45
+ export type GroupKill = "delivered" | "unpinned" | "unsupported" | { error: unknown };
46
+
47
+ export interface LeasedShell {
48
+ readonly stdout: ShellReader;
49
+ readonly stderr: ShellReader;
50
+ /** The command's exit code; `null` when the lease holder ended without reporting one. */
51
+ readonly exit: Promise<number | null>;
52
+ /** Settles once the OS process owning the group (or the direct shell) is reaped. */
53
+ readonly reaped: Promise<void>;
54
+ /** SIGKILL the pinned group; never signals an unpinned or released group. */
55
+ killGroup(): GroupKill;
56
+ /** Best-effort single-process kill; proves nothing about descendants. */
57
+ killDirect(): void;
58
+ /** Let the holder exit. After this, `killGroup()` reports `"unpinned"`. */
59
+ release(): void;
60
+ /** Free pipe handles after the process is reaped. */
61
+ dispose(): void;
62
+ }
63
+
64
+ /** Resolve true if `promise` settles within `ms`, else false. */
65
+ export async function settlesWithin(promise: Promise<unknown>, ms: number): Promise<boolean> {
66
+ let timer: ReturnType<typeof setTimeout> | undefined;
67
+ try {
68
+ return await Promise.race([
69
+ promise.then(() => true),
70
+ new Promise<false>(resolve => { timer = setTimeout(() => resolve(false), ms); }),
71
+ ]);
72
+ } finally {
73
+ clearTimeout(timer);
74
+ }
75
+ }
76
+
77
+ // $1 = command, $2 = status file, $3 = caller's BASH_ENV. fd0 is the release pipe and never reaches the
78
+ // command. The holder's own stderr goes to /dev/null (its "Killed" job notices are not command output) and
79
+ // BASH_ENV is withheld from the holder itself, so a startup file runs once, inside the command's shell.
80
+ const HOLDER_SCRIPT = [
81
+ "exec 9>&2 2>/dev/null",
82
+ 'if [ -n "$3" ]; then export BASH_ENV="$3"; fi',
83
+ 'bash -c "$1" bash 0</dev/null 2>&9 9>&-',
84
+ "c=$?",
85
+ "exec 1>&- 9>&-",
86
+ "printf '%s\\n' \"$c\" > \"$2\" || exit \"$c\"", // unreportable: end the lease instead of waiting forever
87
+ "read -r _",
88
+ 'exit "$c"',
89
+ ].join("\n");
90
+
91
+ const STATUS_POLL_MIN_MS = 5;
92
+ const STATUS_POLL_MAX_MS = 50;
93
+
94
+ export function spawnLeasedShell(command: string, cwd: string): LeasedShell {
95
+ return process.platform === "win32" ? spawnDirect(command, cwd) : spawnLeased(command, cwd);
96
+ }
97
+
98
+ function spawnDirect(command: string, cwd: string): LeasedShell {
99
+ const proc = Bun.spawn(["bash", "-c", command], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
100
+ const stdout = proc.stdout.getReader();
101
+ const stderr = proc.stderr.getReader();
102
+ const reaped = proc.exited.then(() => undefined);
103
+ return {
104
+ stdout, stderr,
105
+ exit: proc.exited,
106
+ reaped,
107
+ killGroup: () => "unsupported",
108
+ killDirect: () => { try { proc.kill(9); } catch { /* best effort only */ } },
109
+ release: () => {},
110
+ dispose: () => { void stdout.cancel().catch(() => {}); void stderr.cancel().catch(() => {}); },
111
+ };
112
+ }
113
+
114
+ function spawnLeased(command: string, cwd: string): LeasedShell {
115
+ // node:child_process reports a bad cwd inconsistently across Bun versions (an 'error' event on
116
+ // some, a holder that exits 127 on others); check up front so start() fails like Bun.spawn did.
117
+ if (!fs.statSync(cwd).isDirectory()) throw new Error(`ENOTDIR: not a directory, stat '${cwd}'`);
118
+ const leaseDir = fs.mkdtempSync(path.join(os.tmpdir(), "jeo-lease-"));
119
+ const statusFile = path.join(leaseDir, "status");
120
+ const cleanup = () => { try { fs.rmSync(leaseDir, { recursive: true, force: true }); } catch { /* best effort only */ } };
121
+ const { BASH_ENV: userBashEnv, ...env } = process.env;
122
+ let child: cp.ChildProcess;
123
+ try {
124
+ child = cp.spawn("bash", ["-c", HOLDER_SCRIPT, "bash", command, statusFile, userBashEnv ?? ""], {
125
+ cwd, env, detached: true, stdio: ["pipe", "pipe", "pipe"],
126
+ });
127
+ } catch (error) { cleanup(); throw error; }
128
+ child.on("error", () => {});
129
+ if (child.pid === undefined) { cleanup(); throw new Error('Executable not found in $PATH: "bash"'); }
130
+ const pid = child.pid;
131
+ const stdin = child.stdin!;
132
+ stdin.on("error", () => {});
133
+
134
+ let gone = false;
135
+ let released = false;
136
+ let delivered = false;
137
+ const reaped = new Promise<void>(resolve => {
138
+ child.once("exit", () => { gone = true; resolve(); });
139
+ child.once("error", () => { gone = true; resolve(); });
140
+ });
141
+
142
+ const readStatus = (): number | null => {
143
+ try {
144
+ const match = /^(\d{1,3})\n$/.exec(fs.readFileSync(statusFile, "utf8"));
145
+ return match ? Number(match[1]) : null;
146
+ } catch { return null; }
147
+ };
148
+ // Poll with backoff: no fd beyond 0-2 and no fs.watch, both unreliable on Bun/Linux. The holder
149
+ // writes the file only after the command exits and stays alive until released, so a reported
150
+ // status always comes from a pinned group; none by the time the holder is reaped means a lost lease.
151
+ const exit = new Promise<number | null>(resolve => {
152
+ let delay = STATUS_POLL_MIN_MS;
153
+ let timer: ReturnType<typeof setTimeout> | undefined;
154
+ const settle = (code: number | null) => { clearTimeout(timer); resolve(code); };
155
+ const tick = () => {
156
+ const code = readStatus();
157
+ if (code !== null) return settle(code);
158
+ if (gone) return settle(null);
159
+ delay = Math.min(delay * 2, STATUS_POLL_MAX_MS);
160
+ timer = setTimeout(tick, delay);
161
+ };
162
+ timer = setTimeout(tick, delay);
163
+ void reaped.then(() => { clearTimeout(timer); tick(); });
164
+ });
165
+
166
+ const reader = (stream: Readable): ShellReader => {
167
+ const web = Readable.toWeb(stream).getReader();
168
+ return { read: () => web.read(), cancel: () => web.cancel() };
169
+ };
170
+ const stdout = reader(child.stdout!);
171
+ const stderr = reader(child.stderr!);
172
+
173
+ return {
174
+ stdout, stderr, exit, reaped,
175
+ killGroup(): GroupKill {
176
+ if (delivered) return "delivered"; // the whole group already holds SIGKILL
177
+ if (gone || released || child.exitCode !== null || child.signalCode !== null) return "unpinned";
178
+ try { process.kill(-pid, "SIGKILL"); delivered = true; return "delivered"; }
179
+ catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH" ? "unpinned" : { error }; }
180
+ },
181
+ killDirect() { try { child.kill("SIGKILL"); } catch { /* best effort only */ } },
182
+ release() { released = true; stdin.end(); },
183
+ dispose() {
184
+ void stdout.cancel().catch(() => {});
185
+ void stderr.cancel().catch(() => {});
186
+ stdin.destroy();
187
+ cleanup();
188
+ },
189
+ };
190
+ }
@@ -6,6 +6,25 @@ import type { AuthProvider } from "../auth/storage";
6
6
  import { OPENAI_COMPAT_PROVIDERS } from "../ai/providers/openai-compatible-catalog";
7
7
  import { resolveCustomApiKey, setCustomProviders, type CustomProviderConfig } from "../ai/providers/custom-providers";
8
8
  import { jeoEnv } from "../util/env";
9
+ import type { SandboxConfig } from "./sandbox/types";
10
+ import { currentSandboxExecution } from "./hooks";
11
+ import { discoverAgentGuidanceFiles, loadProjectContext } from "./context-files";
12
+ import type { ToolHandler } from "./engine";
13
+ import { createTodoTool, type TodoItem } from "./todo-tool";
14
+ import { executeMemoryOperation } from "./memory";
15
+ import { executeSkillCatalogOperation } from "../skills/catalog";
16
+ import { encodeHandsFrame, MAX_FRAME_BYTES } from "./sandbox/rpc";
17
+ import { readGoalFile, clearGoalState } from "./goal-verifier";
18
+ import { inspectApprovalPath } from "../commands/approve";
19
+
20
+ /** Only workspace workflow data crosses this boundary; global config stays in the brain. */
21
+ async function sandboxWorkflow<T>(operation: string, args: Record<string, unknown>, cwd: string): Promise<T> {
22
+ const sandbox = currentSandboxExecution();
23
+ if (!sandbox) throw new Error("SANDBOX_WORKFLOW: missing execution scope");
24
+ const result = await sandbox.supervisor.call("__workflow", { operation, ...args }, { cwd, signal: sandbox.signal });
25
+ if (!result.success) throw new Error(result.error || "SANDBOX_WORKFLOW: operation failed");
26
+ return JSON.parse(result.output) as T;
27
+ }
9
28
 
10
29
  /** Persisted OAuth credential set (access + refresh + expiry) for a provider. */
11
30
  export interface StoredOAuth {
@@ -57,6 +76,8 @@ export interface Config {
57
76
  * overrides. Resolve with `resolveWikiRoot()`. */
58
77
  wikiRoot?: string;
59
78
  theme?: string;
79
+ /** Explicit local container image and finite session resource limits; never auto-pulled. */
80
+ sandbox?: SandboxConfig;
60
81
  /** Terminal-bell notifications (gajae-code 0.7.8 parity). `bell` is the master
61
82
  * toggle; per-event flags refine it. Env `JEO_NOTIFY_BELL=1/0` force-overrides. */
62
83
  notify?: {
@@ -582,6 +603,10 @@ export function getLocalJeoDir(cwd: string = process.cwd()): string {
582
603
  * correctness requirement — a missing .gitignore degrades gracefully to "no
583
604
  * ignore", not a broken feature). */
584
605
  export async function ensureJeoGitignore(cwd: string = process.cwd()): Promise<void> {
606
+ if (currentSandboxExecution()) {
607
+ await sandboxWorkflow("ensureGitignore", {}, cwd);
608
+ return;
609
+ }
585
610
  const gitignorePath = path.join(getLocalJeoDir(cwd), ".gitignore");
586
611
  try {
587
612
  await fs.access(gitignorePath);
@@ -654,6 +679,7 @@ export async function readWorkflowState(
654
679
  skill: "deep-interview" | "ralplan" | "team" | "ultragoal",
655
680
  cwd: string = process.cwd()
656
681
  ): Promise<WorkflowState | null> {
682
+ if (currentSandboxExecution()) return sandboxWorkflow("read", { skill }, cwd);
657
683
  const statePath = path.join(getLocalJeoDir(cwd), "state", `${skill}-state.json`);
658
684
  return loadWorkflowStateFile(statePath, false);
659
685
  }
@@ -667,6 +693,7 @@ export async function readWorkflowStateStrict(
667
693
  skill: "deep-interview" | "ralplan" | "team" | "ultragoal",
668
694
  cwd: string = process.cwd()
669
695
  ): Promise<WorkflowState | null> {
696
+ if (currentSandboxExecution()) return sandboxWorkflow("readStrict", { skill }, cwd);
670
697
  const statePath = path.join(getLocalJeoDir(cwd), "state", `${skill}-state.json`);
671
698
  return loadWorkflowStateFile(statePath, true);
672
699
  }
@@ -676,6 +703,7 @@ export async function writeWorkflowState(
676
703
  state: WorkflowState,
677
704
  cwd: string = process.cwd()
678
705
  ): Promise<string> {
706
+ if (currentSandboxExecution()) return sandboxWorkflow("write", { skill, state }, cwd);
679
707
  const stateDir = path.join(getLocalJeoDir(cwd), "state");
680
708
  await fs.mkdir(stateDir, { recursive: true });
681
709
  const statePath = path.join(stateDir, `${skill}-state.json`);
@@ -705,6 +733,10 @@ export async function clearWorkflowState(
705
733
  skill: "deep-interview" | "ralplan" | "team" | "ultragoal",
706
734
  cwd: string = process.cwd()
707
735
  ): Promise<void> {
736
+ if (currentSandboxExecution()) {
737
+ await sandboxWorkflow("clear", { skill }, cwd);
738
+ return;
739
+ }
708
740
  const statePath = path.join(getLocalJeoDir(cwd), "state", `${skill}-state.json`);
709
741
  try {
710
742
  await fs.unlink(statePath);
@@ -736,6 +768,19 @@ export async function acquireWorkflowRunLock(
736
768
  skill: "team" | "ultragoal",
737
769
  cwd: string = process.cwd(),
738
770
  ): Promise<() => Promise<void>> {
771
+ if (currentSandboxExecution()) {
772
+ const sandbox = currentSandboxExecution()!;
773
+ const token = await sandboxWorkflow<unknown>("acquireLock", { skill }, cwd);
774
+ if (typeof token !== "string" || !token) throw new Error("SANDBOX_WORKFLOW: invalid lock token");
775
+ let released = false;
776
+ return async () => {
777
+ if (released) return;
778
+ // Capture the owning supervisor: release can run after its caller's ALS scope ends.
779
+ const result = await sandbox.supervisor.call("__workflow", { operation: "releaseLock", token }, { cwd });
780
+ if (!result.success) throw new Error(result.error || "SANDBOX_WORKFLOW: lock release failed");
781
+ released = true;
782
+ };
783
+ }
739
784
  const stateDir = path.join(getLocalJeoDir(cwd), "state");
740
785
  await fs.mkdir(stateDir, { recursive: true });
741
786
  const lockPath = path.join(stateDir, `${skill}.lock`);
@@ -781,6 +826,128 @@ export async function acquireWorkflowRunLock(
781
826
  throw new Error(`could not acquire ${lockPath} even after stale-lock takeover.`);
782
827
  }
783
828
 
829
+ /** Read exact UTF-8 workflow bytes; never feed a silently truncated plan into an executor. */
830
+ export async function readWorkflowFile(filePath: string, cwd: string = process.cwd()): Promise<string> {
831
+ if (currentSandboxExecution()) return sandboxWorkflow("readFile", { path: filePath }, cwd);
832
+ const file = await fs.open(path.resolve(cwd, filePath), "r");
833
+ let content: string;
834
+ try {
835
+ const stat = await file.stat();
836
+ if (!stat.isFile() || stat.size > MAX_FRAME_BYTES) throw new Error("Workflow file exceeds bounded sandbox transport or is not a regular file");
837
+ const bytes = Buffer.allocUnsafe(stat.size + 1);
838
+ let length = 0;
839
+ while (length < bytes.length) {
840
+ const chunk = await file.read(bytes, length, bytes.length - length, length);
841
+ if (chunk.bytesRead === 0) break;
842
+ length += chunk.bytesRead;
843
+ }
844
+ if (length !== stat.size) throw new Error("Workflow file changed during read");
845
+ content = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length));
846
+ } finally { await file.close(); }
847
+ // The nested JSON string must fit the final RPC envelope, including its largest permitted identity.
848
+ encodeHandsFrame({ id: "x".repeat(256), type: "result", result: { success: true, output: JSON.stringify(content) } });
849
+ return content;
850
+ }
851
+
852
+ /** Atomically publish a workflow artifact through the same guest filesystem boundary as tool writes. */
853
+ export async function writeWorkflowFile(filePath: string, content: string, cwd: string = process.cwd()): Promise<void> {
854
+ if (currentSandboxExecution()) { await sandboxWorkflow("writeFile", { path: filePath, content }, cwd); return; }
855
+ if (Buffer.byteLength(content, "utf8") > MAX_FRAME_BYTES) throw new Error("Workflow file exceeds bounded sandbox transport");
856
+ const target = path.resolve(cwd, filePath);
857
+ await fs.mkdir(path.dirname(target), { recursive: true });
858
+ const temporary = path.join(path.dirname(target), `.jeo-workflow-${crypto.randomUUID()}.tmp`);
859
+ try {
860
+ await fs.writeFile(temporary, content, { flag: "wx", mode: 0o600 });
861
+ await fs.rename(temporary, target);
862
+ } catch (error) {
863
+ try { await fs.unlink(temporary); }
864
+ catch (cleanupError) { if ((cleanupError as NodeJS.ErrnoException).code !== "ENOENT") throw new AggregateError([error, cleanupError], "Workflow artifact write and temporary cleanup failed"); }
865
+ throw error;
866
+ }
867
+ }
868
+
869
+ /** Create once per hands worker so opaque lock tokens retain their guest-held release callbacks. */
870
+ export function createWorkflowStateTool(): ToolHandler {
871
+ const locks = new Map<string, () => Promise<void>>();
872
+ let todos: TodoItem[] = [];
873
+ const todo = createTodoTool({ onChange: items => { todos = items; } });
874
+ return async (args, cwd) => {
875
+ if (currentSandboxExecution()) throw new Error("SANDBOX_WORKFLOW: guest handler cannot run in the brain");
876
+ try {
877
+ let value: unknown = null;
878
+ switch (args.operation) {
879
+ case "readGoal": value = await readGoalFile(cwd); break;
880
+ case "clearGoal": await clearGoalState(cwd); break;
881
+ case "approvalPath": {
882
+ if (typeof args.path !== "string") throw new Error("Invalid approval path");
883
+ value = await inspectApprovalPath(args.path, cwd);
884
+ break;
885
+ }
886
+ case "readFile": {
887
+ if (typeof args.path !== "string") throw new Error("Invalid workflow file path");
888
+ value = await readWorkflowFile(args.path, cwd);
889
+ break;
890
+ }
891
+ case "writeFile": {
892
+ if (typeof args.path !== "string" || typeof args.content !== "string") throw new Error("Invalid workflow file write");
893
+ await writeWorkflowFile(args.path, args.content, cwd);
894
+ break;
895
+ }
896
+ case "memory": {
897
+ if (typeof args.action !== "string") throw new Error("Invalid memory action");
898
+ value = await executeMemoryOperation(args.action, args, cwd);
899
+ break;
900
+ }
901
+ case "skillCatalog": value = await executeSkillCatalogOperation(args, cwd); break;
902
+ case "loadContext": value = await loadProjectContext(cwd); break;
903
+ case "discoverGuidance": value = await discoverAgentGuidanceFiles(cwd); break;
904
+ case "ensureGitignore": await ensureJeoGitignore(cwd); break;
905
+ case "todo": {
906
+ if (!args.args || typeof args.args !== "object" || Array.isArray(args.args)) throw new Error("Invalid todo arguments");
907
+ value = { result: await todo(args.args, cwd), todos };
908
+ break;
909
+ }
910
+ case "releaseLock": {
911
+ if (typeof args.token !== "string") throw new Error("Invalid workflow lock token");
912
+ const release = locks.get(args.token);
913
+ if (!release) throw new Error("Unknown workflow lock token");
914
+ await release();
915
+ locks.delete(args.token);
916
+ break;
917
+ }
918
+ default: {
919
+ const skill = args.skill;
920
+ if (skill !== "deep-interview" && skill !== "ralplan" && skill !== "team" && skill !== "ultragoal") {
921
+ throw new Error("Invalid workflow skill");
922
+ }
923
+ switch (args.operation) {
924
+ case "read": value = await readWorkflowState(skill, cwd); break;
925
+ case "readStrict": value = await readWorkflowStateStrict(skill, cwd); break;
926
+ case "write": {
927
+ if (!args.state || typeof args.state !== "object" || Array.isArray(args.state)) throw new Error("Invalid workflow state");
928
+ value = await writeWorkflowState(skill, args.state as WorkflowState, cwd);
929
+ break;
930
+ }
931
+ case "clear": await clearWorkflowState(skill, cwd); break;
932
+ case "acquireLock": {
933
+ if (skill !== "team" && skill !== "ultragoal") throw new Error("Invalid run-lock skill");
934
+ const release = await acquireWorkflowRunLock(skill, cwd);
935
+ const token = crypto.randomUUID();
936
+ locks.set(token, release);
937
+ value = token;
938
+ break;
939
+ }
940
+ default: throw new Error("Unknown workflow operation");
941
+ }
942
+ }
943
+ }
944
+ return { success: true, output: JSON.stringify(value) };
945
+ } catch (error) {
946
+ return { success: false, output: "", error: error instanceof Error ? error.message : String(error) };
947
+ }
948
+ };
949
+ }
950
+
784
951
  /** Returns true if the agent is running in development mode (enables self-improvement). */
785
952
  export function isDevMode(): boolean {
786
953
  return jeoEnv("DEV_MODE") === "1" || process.env.NODE_ENV === "development";
@@ -9,7 +9,7 @@
9
9
  * review/plan lane physically cannot edit the repo, mirroring gjc's read-only
10
10
  * role agents.
11
11
  */
12
- import { DEFAULT_TOOLS, TOOL_PROTOCOL, READONLY_TOOL_PROTOCOL, WORKING_DISCIPLINE, type ToolHandler } from "./engine";
12
+ import { DEFAULT_TOOLS, SANDBOX_MEDIATED_TOOLS, TOOL_PROTOCOL, READONLY_TOOL_PROTOCOL, WORKING_DISCIPLINE, type ToolHandler } from "./engine";
13
13
  import type { Config } from "./state";
14
14
  import type { CatalogModel } from "../ai/model-catalog";
15
15
  import architectPrompt from "../prompts/agents/architect.md" with { type: "text" };
@@ -304,7 +304,7 @@ export function subagentToolset(role: SubagentRole): Record<string, ToolHandler>
304
304
  if (prefixes && prefixes.length > 0) {
305
305
  const inner = DEFAULT_TOOLS.bash;
306
306
  const guarded: Record<string, ToolHandler> = { ...DEFAULT_TOOLS };
307
- guarded.bash = async (a, cwd) => {
307
+ guarded.bash = async (a, cwd, onProgress) => {
308
308
  const command = String(a.command ?? a.cmd ?? "");
309
309
  if (!bashCommandAllowed(command, prefixes)) {
310
310
  return {
@@ -315,8 +315,9 @@ export function subagentToolset(role: SubagentRole): Record<string, ToolHandler>
315
315
  `Received: ${command.trim().slice(0, 120)}`,
316
316
  };
317
317
  }
318
- return inner(a, cwd);
318
+ return inner(a, cwd, onProgress);
319
319
  };
320
+ SANDBOX_MEDIATED_TOOLS.add(guarded.bash);
320
321
  return guarded;
321
322
  }
322
323
  return DEFAULT_TOOLS;
@@ -35,6 +35,7 @@ import type { SubagentRegistry } from "./subagent-registry";
35
35
  import { ensureSessionNotifyEndpoint, type SessionNotifyEndpoint } from "./notify/session-endpoint";
36
36
  import { inferTierForModel, tierModelPool, credentialScopeFor, selectFromPool, strongestMidTierCredentialed, type RoutingConfig } from "./prompt-router";
37
37
  import { isRateLimitError } from "../util/retry";
38
+ import { currentSandboxExecution, withSandboxExecution, type SandboxExecutionScope } from "./hooks";
38
39
 
39
40
  /** `runSubagentOnce`/`createTaskTool`'s config requirement: everything
40
41
  * `resolveSubagentModel`/`resolveSubagentMaxSteps`/`resolveSubagentThinking` need
@@ -100,6 +101,7 @@ export interface TaskToolOptions {
100
101
  * one — a detached launch attaches `registry` to THIS instead of starting a
101
102
  * second, competing endpoint (see `ensureSessionNotifyEndpoint`). */
102
103
  sessionEndpoint?: SessionNotifyEndpoint;
104
+ sandbox?: SandboxExecutionScope;
103
105
  }
104
106
 
105
107
  /** Max concurrent subagents in a fan-out batch (both read-only AND the mutating
@@ -281,6 +283,7 @@ export interface RunSubagentOptions {
281
283
  detachedId?: string;
282
284
  /** SubagentRegistry to report live activity into (e.g. current tool or step). */
283
285
  registry?: SubagentRegistry;
286
+ sandbox?: SandboxExecutionScope;
284
287
  }
285
288
 
286
289
 
@@ -332,6 +335,9 @@ export async function runSubagentOnce(
332
335
  cwd: string,
333
336
  opts: RunSubagentOptions,
334
337
  ): Promise<SubagentRunResult> {
338
+ if (opts.sandbox && currentSandboxExecution() !== opts.sandbox) {
339
+ return withSandboxExecution(opts.sandbox, () => runSubagentOnce(role, taskText, context, cwd, opts));
340
+ }
335
341
  const { steer, slot, detachedId, projectContext: preloadedContext, signal } = opts;
336
342
  // Tag every live event with its fan-out slot or detached ID so a parent monitor can tell
337
343
  // task 1 from task 3 or detached tasks apart when several subagents stream concurrently.
@@ -616,6 +622,7 @@ export async function runSubagentOnce(
616
622
  * handler accepts `{ role?, task | prompt | assignment, context? }`.
617
623
  */
618
624
  export function createTaskTool(opts: TaskToolOptions): ToolHandler {
625
+ const sandbox = opts.sandbox ?? currentSandboxExecution();
619
626
  /** Run ONE subagent via the shared execution core, binding this tool instance's
620
627
  * config/signal/onEvent (the original single-task path). */
621
628
  const runOne = (
@@ -643,6 +650,7 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
643
650
  ): Promise<SubagentRunResult> =>
644
651
  runSubagentOnce(role, taskText, context, cwd, {
645
652
  config: opts.config,
653
+ sandbox,
646
654
  signal: extra.signal ?? opts.signal,
647
655
  onEvent: opts.onEvent,
648
656
  steer: extra.steer,
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import type { ToolHandler } from "./engine";
12
12
  import type { ToolResult } from "./tools";
13
+ import { currentSandboxExecution } from "./hooks";
13
14
 
14
15
  export type TodoStatus = "pending" | "in_progress" | "done";
15
16
 
@@ -23,6 +24,20 @@ export interface TodoToolOptions {
23
24
  onChange?: (items: TodoItem[]) => void;
24
25
  }
25
26
 
27
+ interface SandboxTodoReply {
28
+ result: ToolResult;
29
+ todos: TodoItem[];
30
+ }
31
+
32
+ function isSandboxTodoReply(value: unknown): value is SandboxTodoReply {
33
+ if (!value || typeof value !== "object") return false;
34
+ const { result, todos } = value as Partial<SandboxTodoReply>;
35
+ return !!result && typeof result.success === "boolean" && typeof result.output === "string" &&
36
+ (result.error === undefined || typeof result.error === "string") && Array.isArray(todos) &&
37
+ todos.every(item => !!item && typeof item.title === "string" &&
38
+ (item.status === "pending" || item.status === "in_progress" || item.status === "done"));
39
+ }
40
+
26
41
  /** One-line protocol description appended to the launch system prompt. */
27
42
  export const TODO_TOOL_PROTOCOL_LINE =
28
43
  `todo {todos:[{title,status}]} — declare/update your task plan ` +
@@ -70,7 +85,21 @@ export function renderTodoChecklist(items: TodoItem[]): string {
70
85
  /** Build a `todo` ToolHandler. Maintains the current list in a closure. */
71
86
  export function createTodoTool(opts: TodoToolOptions = {}): ToolHandler {
72
87
  let current: TodoItem[] = [];
73
- return async (args: Record<string, any>): Promise<ToolResult> => {
88
+ return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
89
+ const sandbox = currentSandboxExecution();
90
+ if (sandbox) {
91
+ const response = await sandbox.supervisor.call("__workflow", { operation: "todo", args }, { cwd, signal: sandbox.signal });
92
+ if (!response.success) return response;
93
+ const reply: unknown = JSON.parse(response.output);
94
+ if (!isSandboxTodoReply(reply)) throw new Error("SANDBOX_TODO: invalid guest response");
95
+ // Parent callback is presentation only; normalization and plan mutation ran in hands.
96
+ if (reply.result.success) opts.onChange?.(reply.todos);
97
+ return {
98
+ success: reply.result.success,
99
+ output: reply.result.output,
100
+ ...(reply.result.error !== undefined ? { error: reply.result.error } : {}),
101
+ };
102
+ }
74
103
  const items = parseTodoItems(args);
75
104
  if (!items) {
76
105
  return {
@@ -133,6 +133,19 @@ const SCHEMAS: Record<string, NativeToolSchema> = {
133
133
  required: ["action"],
134
134
  },
135
135
  },
136
+ sandbox_request: {
137
+ name: "sandbox_request",
138
+ description: "Request an additional session sandbox mount with host and mode ro|rw, or inspect grants and their guest paths with action=status. This tool cannot approve, reject, or revoke access; only authenticated human /sandbox controls can do that.",
139
+ parameters: {
140
+ type: "object",
141
+ properties: {
142
+ action: { type: "string", enum: ["request", "status"] },
143
+ host: STRING,
144
+ mode: { type: "string", enum: ["ro", "rw"] },
145
+ },
146
+ required: [],
147
+ },
148
+ },
136
149
  done: {
137
150
  name: "done",
138
151
  description: "Call when the task is fully implemented AND verified. The reason is shown to the user as your message.",