jeo-code 0.12.13 → 0.12.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -1
- package/README.ja.md +33 -9
- package/README.ko.md +33 -9
- package/README.md +33 -9
- package/README.zh.md +33 -9
- package/package.json +4 -2
- package/src/agent/browser-tool.ts +9 -1
- package/src/agent/config-schema.ts +8 -0
- package/src/agent/context-files.ts +19 -0
- package/src/agent/engine.ts +25 -6
- package/src/agent/eval-tool.ts +6 -0
- package/src/agent/goal-verifier.ts +30 -6
- package/src/agent/hooks.ts +52 -4
- package/src/agent/job-registry.ts +110 -18
- package/src/agent/job-tool.ts +43 -9
- package/src/agent/loop-guards.ts +17 -2
- package/src/agent/memory.ts +151 -112
- package/src/agent/monitor-registry.ts +142 -30
- package/src/agent/monitor-tool.ts +34 -6
- package/src/agent/notify/discord-daemon.ts +135 -42
- package/src/agent/notify/session-endpoint.ts +27 -5
- package/src/agent/sandbox/assets.d.ts +4 -0
- package/src/agent/sandbox/authority.ts +358 -0
- package/src/agent/sandbox/bundle.ts +40 -0
- package/src/agent/sandbox/control.ts +49 -0
- package/src/agent/sandbox/docker.ts +293 -0
- package/src/agent/sandbox/hands-source.text +14548 -0
- package/src/agent/sandbox/hands-worker.ts +168 -0
- package/src/agent/sandbox/policy.ts +172 -0
- package/src/agent/sandbox/rpc.ts +90 -0
- package/src/agent/sandbox/supervisor.ts +210 -0
- package/src/agent/sandbox/types.ts +77 -0
- package/src/agent/shell-lease.ts +190 -0
- package/src/agent/state.ts +167 -0
- package/src/agent/subagents.ts +4 -3
- package/src/agent/task-tool.ts +8 -0
- package/src/agent/todo-tool.ts +30 -1
- package/src/agent/tool-schemas.ts +13 -0
- package/src/ai/model-discovery.ts +12 -0
- package/src/ai/providers/anthropic.ts +27 -2
- package/src/ai/providers/antigravity.ts +10 -2
- package/src/ai/providers/gemini.ts +20 -16
- package/src/ai/providers/ollama.ts +14 -0
- package/src/ai/providers/openai-responses.ts +22 -5
- package/src/ai/providers/openai.ts +23 -40
- package/src/cli/runner.ts +3 -0
- package/src/commands/approve.ts +37 -13
- package/src/commands/launch/flags.ts +2 -1
- package/src/commands/launch/jobs-slash.ts +1 -1
- package/src/commands/launch.ts +275 -35
- package/src/commands/messenger.ts +107 -39
- package/src/commands/notify.ts +2 -2
- package/src/commands/team.ts +44 -12
- package/src/commands/ultragoal.ts +6 -16
- package/src/messenger/activity.ts +2 -1
- package/src/messenger/config.ts +78 -10
- package/src/messenger/discord-setup.ts +216 -34
- package/src/messenger/discord.ts +37 -43
- package/src/messenger/gateway.ts +85 -37
- package/src/messenger/model-selection.ts +101 -0
- package/src/messenger/session-pool.ts +189 -35
- package/src/messenger/types.ts +5 -2
- package/src/skills/catalog.ts +207 -0
- package/src/util/retry.ts +12 -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
|
+
}
|
package/src/agent/state.ts
CHANGED
|
@@ -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";
|
package/src/agent/subagents.ts
CHANGED
|
@@ -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;
|
package/src/agent/task-tool.ts
CHANGED
|
@@ -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,
|
package/src/agent/todo-tool.ts
CHANGED
|
@@ -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
|
|
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.",
|