jeo-code 0.12.13 → 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 +42 -0
  2. package/README.ja.md +32 -8
  3. package/README.ko.md +32 -8
  4. package/README.md +32 -8
  5. package/README.zh.md +32 -8
  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 +37 -43
  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
@@ -6,6 +6,8 @@ interface WorkerProcess {
6
6
  stderr: ReadableStream<Uint8Array>;
7
7
  exited: Promise<number>;
8
8
  kill(signal?: number | NodeJS.Signals): void;
9
+ /** Private IPC channel; present only on workers spawned with `ipc`. */
10
+ send?(message: unknown): unknown;
9
11
  }
10
12
 
11
13
  interface Pending {
@@ -18,35 +20,80 @@ interface Pending {
18
20
 
19
21
  interface Worker {
20
22
  child: WorkerProcess;
23
+ exit: Promise<number>;
24
+ policyKey?: string;
21
25
  pending?: Pending;
22
26
  lastUsed: number;
23
27
  dead: boolean;
28
+ forceSent?: boolean;
24
29
  exited?: boolean;
30
+ cleanupNonce?: string;
31
+ cleanupGone?: boolean;
32
+ cleanupError?: Error;
25
33
  idleTimer?: ReturnType<typeof setTimeout>;
26
34
  exitTimer?: ReturnType<typeof setTimeout>;
27
35
  }
28
36
 
29
- export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn, limit = 16, idleTimeoutMs = 5 * 60_000, shutdownTimeoutMs = 5_000) {
37
+ /** Resident sessions the pool keeps, and the number of turns it runs at once. */
38
+ export const MESSENGER_SESSION_LIMIT = 16;
39
+
40
+ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn, limit = MESSENGER_SESSION_LIMIT, idleTimeoutMs = 5 * 60_000, shutdownTimeoutMs = 5_000, options: { requireSandboxCleanup?: boolean } = {}) {
30
41
  const workers = new Map<string, Worker>();
31
42
  const allWorkers = new Map<Worker, string>();
32
- const exits = new Map<Promise<number>, WorkerProcess>();
43
+ const exits = new Map<Promise<number>, Worker>();
33
44
  const retiring = new Map<string, Promise<number>>();
45
+ const keyTails = new Map<string, Promise<void>>();
46
+ const requireSandboxCleanup = options.requireSandboxCleanup === true;
47
+ const incomplete = (detail: string) => new Error(`REVOKE_INCOMPLETE: ${detail}`);
34
48
 
35
49
  let closed = false;
36
50
  const closedSignal = new AbortController();
37
51
 
38
52
  let tick = 0;
39
53
 
54
+ // Admission: at most `limit` turns hold a worker at once. Later turns wait here in arrival order until a
55
+ // running turn settles, so a busy pool is backpressure on the waiting request, never a failure of it.
56
+ let admitted = 0;
57
+ const waiting: Array<() => void> = [];
58
+
59
+ function admit(signal: AbortSignal): Promise<void> {
60
+ return new Promise<void>((resolve, reject) => {
61
+ const grant = () => { cleanup(); resolve(); };
62
+ const abort = () => {
63
+ const index = waiting.indexOf(grant);
64
+ if (index >= 0) waiting.splice(index, 1);
65
+ cleanup();
66
+ reject(new Error("Messenger request aborted"));
67
+ };
68
+ const cleanup = () => { signal.removeEventListener("abort", abort); closedSignal.signal.removeEventListener("abort", abort); };
69
+ signal.addEventListener("abort", abort, { once: true });
70
+ closedSignal.signal.addEventListener("abort", abort, { once: true });
71
+ waiting.push(grant);
72
+ });
73
+ }
74
+
75
+ /** Hand the finished turn's slot straight to the oldest waiter, or free it. */
76
+ function release() {
77
+ const next = waiting.shift();
78
+ if (next) next(); else admitted--;
79
+ }
80
+
81
+ function forceKill(worker: Worker) {
82
+ if (worker.cleanupNonce) return;
83
+ if (worker.forceSent) return;
84
+ try { worker.child.kill("SIGKILL"); worker.forceSent = true; } catch { /* Retry on shutdown if termination failed. */ }
85
+ }
86
+
40
87
  function retire(key: string, worker: Worker) {
41
88
  if (worker.dead) return;
42
89
  worker.dead = true;
43
90
  if (workers.get(key) === worker) workers.delete(key);
44
- allWorkers.delete(worker);
45
- if (!worker.exited) {
46
- retiring.set(key, worker.child.exited);
47
- void worker.child.exited.then(
48
- () => { if (retiring.get(key) === worker.child.exited) retiring.delete(key); },
49
- () => { if (retiring.get(key) === worker.child.exited) retiring.delete(key); },
91
+ if (!worker.cleanupNonce) allWorkers.delete(worker);
92
+ if (!worker.exited || worker.cleanupNonce) {
93
+ retiring.set(key, worker.exit);
94
+ void worker.exit.then(
95
+ () => { if (retiring.get(key) === worker.exit) retiring.delete(key); },
96
+ () => { /* Keep the key blocked when process exit cannot be confirmed. */ },
50
97
  );
51
98
  }
52
99
  if (worker.idleTimer) clearTimeout(worker.idleTimer);
@@ -55,7 +102,29 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
55
102
  worker.pending?.reject(new Error("Messenger worker stopped"));
56
103
  worker.pending = undefined;
57
104
  if (!worker.exited) {
58
- try { worker.child.kill(); } catch { /* Shutdown will attempt forced termination. */ }
105
+ if (worker.cleanupNonce) {
106
+ // Protected workers never receive a kill signal: Windows cannot deliver a catchable SIGTERM.
107
+ // Shutdown is a private IPC message; a missing or failing channel is visible, never success.
108
+ const channel = worker.child.send;
109
+ if (typeof channel !== "function") {
110
+ worker.cleanupError = incomplete("worker shutdown channel unavailable");
111
+ } else {
112
+ try {
113
+ const sent = channel.call(worker.child, { type: "jeo_messenger_shutdown", nonce: worker.cleanupNonce });
114
+ if (sent && typeof (sent as PromiseLike<unknown>).then === "function") {
115
+ void (sent as PromiseLike<unknown>).then(undefined, error => { worker.cleanupError = incomplete(`worker shutdown request failed: ${String(error)}`); });
116
+ }
117
+ } catch (error) { worker.cleanupError = incomplete(`worker shutdown request failed: ${String(error)}`); }
118
+ }
119
+ } else {
120
+ try { worker.child.kill(); }
121
+ catch (error) { worker.cleanupError = incomplete(`worker termination failed: ${String(error)}`); }
122
+ }
123
+ if (!worker.cleanupNonce) {
124
+ const force = setTimeout(() => forceKill(worker), shutdownTimeoutMs);
125
+ force.unref();
126
+ void worker.exit.then(() => clearTimeout(force), () => clearTimeout(force));
127
+ }
59
128
  }
60
129
  }
61
130
 
@@ -89,16 +158,45 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
89
158
  } finally { reader.releaseLock(); }
90
159
  }
91
160
 
92
- function start(key: string, command: string[], cwd: string, env: Record<string, string>): Worker {
93
- if (workers.size >= limit) {
161
+ function waitForExit(exit: Promise<number>, signal: AbortSignal, capacity = false): Promise<void> {
162
+ return new Promise<void>((resolve, reject) => {
163
+ const abort = () => { cleanup(); reject(new Error("Messenger request aborted")); };
164
+ const unavailable = () => new Error("Messenger worker capacity unavailable while another session is being cleaned up");
165
+ const timer = setTimeout(() => { cleanup(); reject(capacity ? unavailable() : requireSandboxCleanup ? incomplete("previous worker cleanup is still pending") : new Error("Messenger worker did not exit")); }, shutdownTimeoutMs * 2);
166
+ const cleanup = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); closedSignal.signal.removeEventListener("abort", abort); };
167
+ signal.addEventListener("abort", abort, { once: true });
168
+ closedSignal.signal.addEventListener("abort", abort, { once: true });
169
+ if (signal.aborted || closed) { abort(); return; }
170
+ void exit.then(() => { cleanup(); resolve(); }, error => { cleanup(); reject(capacity ? unavailable() : requireSandboxCleanup ? incomplete(`previous worker cleanup failed: ${String(error)}`) : new Error("Messenger worker exit could not be confirmed")); });
171
+ });
172
+ }
173
+
174
+ async function start(key: string, command: string[], cwd: string, env: Record<string, string>, signal: AbortSignal, policyKey?: string): Promise<Worker> {
175
+ while ((requireSandboxCleanup ? allWorkers.size : workers.size) >= limit) {
94
176
  const idle = [...workers.entries()].filter(([, worker]) => !worker.pending).sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
95
- if (!idle) throw new Error("Messenger worker capacity reached");
177
+ if (!idle) {
178
+ const retiringWorker = requireSandboxCleanup ? [...allWorkers.keys()].find(worker => worker.dead) : undefined;
179
+ if (!retiringWorker) throw new Error("Messenger worker capacity reached");
180
+ await waitForExit(retiringWorker.exit, signal, true);
181
+ continue;
182
+ }
96
183
  retire(idle[0], idle[1]);
184
+ if (requireSandboxCleanup) await waitForExit(idle[1].exit, signal, true);
97
185
  }
98
- const child = spawn(command, { cwd, env, stdin: "pipe", stdout: "pipe", stderr: "pipe" }) as unknown as WorkerProcess;
99
- const worker: Worker = { child, lastUsed: ++tick, dead: false };
100
- exits.set(child.exited, child);
101
- void child.exited.then(() => exits.delete(child.exited), () => exits.delete(child.exited));
186
+ if (closed || signal.aborted) throw new Error("Messenger request aborted");
187
+ const cleanupNonce = requireSandboxCleanup ? crypto.randomUUID() : undefined;
188
+ const workerEnv = { ...env };
189
+ delete workerEnv.JEO_MESSENGER_SHUTDOWN_NONCE;
190
+ if (cleanupNonce) workerEnv.JEO_MESSENGER_SHUTDOWN_NONCE = cleanupNonce;
191
+ const spawnOptions = { cwd, env: workerEnv, stdin: "pipe", stdout: "pipe", stderr: "pipe", ...(cleanupNonce ? { ipc() {}, serialization: "json" } : {}) };
192
+ const child = spawn(command, spawnOptions as never) as unknown as WorkerProcess;
193
+ const cleanup = Promise.withResolvers<number>();
194
+ const worker: Worker = { child, exit: cleanupNonce ? cleanup.promise : child.exited, cleanupNonce, policyKey, lastUsed: ++tick, dead: false };
195
+ exits.set(worker.exit, worker);
196
+ void worker.exit.then(() => {
197
+ exits.delete(worker.exit);
198
+ if (cleanupNonce) allWorkers.delete(worker);
199
+ }, () => { if (!cleanupNonce) exits.delete(worker.exit); });
102
200
  workers.set(key, worker);
103
201
  allWorkers.set(worker, key);
104
202
  const stdoutDone = readLines(child.stdout, line => {
@@ -113,7 +211,17 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
113
211
  finishTurn(key, worker, pending);
114
212
  } catch { retire(key, worker); }
115
213
  }).catch(() => retire(key, worker));
116
- void readLines(child.stderr, line => {
214
+ const stderrDone = readLines(child.stderr, line => {
215
+ if (cleanupNonce && line.startsWith("JEO_MESSENGER_SHUTDOWN_ACK ")) {
216
+ try {
217
+ const ack: unknown = JSON.parse(line.slice("JEO_MESSENGER_SHUTDOWN_ACK ".length));
218
+ if (typeof ack === "object" && ack !== null && "nonce" in ack && ack.nonce === cleanupNonce && "gone" in ack) {
219
+ worker.cleanupGone = ack.gone === true;
220
+ worker.cleanupError = worker.cleanupGone ? undefined : incomplete("worker supervisor cleanup failed");
221
+ }
222
+ } catch { /* Unauthenticated or malformed output is never cleanup proof. */ }
223
+ return;
224
+ }
117
225
  if (line.startsWith("JEO_MESSENGER_ACTIVITY ")) {
118
226
  const activityText = line.slice("JEO_MESSENGER_ACTIVITY ".length).trim();
119
227
  if (activityText) worker.pending?.report?.(activityText);
@@ -121,9 +229,15 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
121
229
  worker.pending?.report?.("Working on your request");
122
230
  }
123
231
  }).catch(() => retire(key, worker));
124
- void child.exited.then(() => {
125
- if (worker.dead) return;
232
+ if (cleanupNonce) {
233
+ void Promise.all([child.exited, stderrDone, stdoutDone]).then(([code]) => {
234
+ if (worker.cleanupGone) cleanup.resolve(code);
235
+ else cleanup.reject(worker.cleanupError ?? incomplete("worker exited without supervisor cleanup acknowledgement"));
236
+ }, error => cleanup.reject(incomplete(`worker exit could not be verified: ${String(error)}`)));
237
+ }
238
+ void worker.exit.then(() => {
126
239
  worker.exited = true;
240
+ if (worker.dead) return;
127
241
  if (workers.get(key) === worker) workers.delete(key);
128
242
  worker.exitTimer = setTimeout(() => retire(key, worker), shutdownTimeoutMs);
129
243
  worker.exitTimer.unref();
@@ -134,24 +248,61 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
134
248
  return worker;
135
249
  }
136
250
 
137
- async function turn(key: string, command: string[], cwd: string, env: Record<string, string>, text: string, signal: AbortSignal, report?: MessengerActivityReporter): Promise<string> {
251
+ function turn(key: string, command: string[], cwd: string, env: Record<string, string>, text: string, signal: AbortSignal, report?: MessengerActivityReporter, policyKey?: string): Promise<string> {
252
+ if (closed || signal.aborted) return Promise.reject(new Error("Messenger request aborted"));
253
+ const run = () => runTurn(key, command, cwd, env, text, signal, report, policyKey).finally(release);
254
+ const admitRun = () => {
255
+ if (closed || signal.aborted) return Promise.reject(new Error("Messenger request aborted"));
256
+ if (admitted < limit && waiting.length === 0) {
257
+ admitted++;
258
+ return run();
259
+ }
260
+ return admit(signal).then(run);
261
+ };
262
+ // Same-key turns run strictly in arrival order. Queued turns hold no admission slot while they wait,
263
+ // so they never block other sessions; different keys are fully independent.
264
+ const previous = keyTails.get(key);
265
+ const result = previous ? waitForTurn(previous, signal).then(admitRun) : admitRun();
266
+ const settled = result.then(() => undefined, () => undefined);
267
+ // The tail also covers the predecessor, so an aborted queued turn never lets its successor overtake it.
268
+ const tail: Promise<void> = previous ? Promise.all([previous, settled]).then(() => undefined) : settled;
269
+ keyTails.set(key, tail);
270
+ void tail.then(() => { if (keyTails.get(key) === tail) keyTails.delete(key); });
271
+ return result;
272
+ }
273
+
274
+ function waitForTurn(previous: Promise<void>, signal: AbortSignal): Promise<void> {
275
+ return new Promise<void>((resolve, reject) => {
276
+ const abort = () => { cleanup(); reject(new Error("Messenger request aborted")); };
277
+ const cleanup = () => { signal.removeEventListener("abort", abort); closedSignal.signal.removeEventListener("abort", abort); };
278
+ signal.addEventListener("abort", abort, { once: true });
279
+ closedSignal.signal.addEventListener("abort", abort, { once: true });
280
+ if (signal.aborted || closed) { abort(); return; }
281
+ void previous.then(() => { cleanup(); resolve(); });
282
+ });
283
+ }
284
+
285
+ async function runTurn(key: string, command: string[], cwd: string, env: Record<string, string>, text: string, signal: AbortSignal, report?: MessengerActivityReporter, policyKey?: string): Promise<string> {
138
286
  if (closed || signal.aborted) throw new Error("Messenger request aborted");
139
- const previousExit = retiring.get(key);
140
- if (previousExit) {
141
- await new Promise<void>((resolve, reject) => {
142
- const abort = () => { cleanup(); reject(new Error("Messenger request aborted")); };
143
- const cleanup = () => { signal.removeEventListener("abort", abort); closedSignal.signal.removeEventListener("abort", abort); };
144
- signal.addEventListener("abort", abort, { once: true });
145
- closedSignal.signal.addEventListener("abort", abort, { once: true });
146
- if (signal.aborted || closed) { abort(); return; }
147
- void previousExit.then(() => { cleanup(); resolve(); }, () => { cleanup(); resolve(); });
148
- });
287
+ for (;;) {
288
+ const existing = workers.get(key);
289
+ if (existing && existing.policyKey !== policyKey) {
290
+ if (existing.pending) throw new Error("Messenger conversation is already active");
291
+ retire(key, existing);
292
+ }
293
+ const previousExit = retiring.get(key);
294
+ if (!previousExit) break;
295
+ await waitForExit(previousExit, signal);
149
296
  }
150
297
  if (closed || signal.aborted) throw new Error("Messenger request aborted");
151
298
  if ([...allWorkers].some(([worker, workerKey]) => workerKey === key && worker.pending)) {
152
299
  throw new Error("Messenger conversation is already active");
153
300
  }
154
- const worker = workers.get(key) ?? start(key, command, cwd, env);
301
+ const worker = workers.get(key) ?? await start(key, command, cwd, env, signal, policyKey);
302
+ if (closed || signal.aborted || worker.dead) {
303
+ retire(key, worker);
304
+ throw new Error("Messenger worker stopped");
305
+ }
155
306
  if (worker.pending) throw new Error("Messenger conversation is already active");
156
307
  if (worker.idleTimer) clearTimeout(worker.idleTimer);
157
308
  worker.idleTimer = undefined;
@@ -179,15 +330,18 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
179
330
  let timer: ReturnType<typeof setTimeout> | undefined;
180
331
  try {
181
332
  return await Promise.race([
182
- Promise.allSettled([...exits.keys()]).then(() => true),
333
+ Promise.allSettled([...exits.keys()]).then(results => {
334
+ const failure = results.find(result => result.status === "rejected");
335
+ if (failure?.status === "rejected" && requireSandboxCleanup) throw incomplete(`worker cleanup failed: ${String(failure.reason)}`);
336
+ return true;
337
+ }),
183
338
  new Promise<false>(resolve => { timer = setTimeout(() => resolve(false), shutdownTimeoutMs); }),
184
339
  ]);
185
340
  } finally { if (timer) clearTimeout(timer); }
186
341
  };
187
342
  if (await waitForExits()) return;
188
- for (const child of exits.values()) {
189
- try { child.kill("SIGKILL"); } catch { /* The process may have exited concurrently. */ }
190
- }
343
+ if (requireSandboxCleanup) throw incomplete("worker cleanup is still pending; cleanup owner retained");
344
+ for (const worker of exits.values()) forceKill(worker);
191
345
  if (!(await waitForExits())) throw new Error("Messenger workers did not exit after forced termination");
192
346
  }
193
347
 
@@ -1,3 +1,5 @@
1
+ import type { SandboxConfig } from "../agent/sandbox/types";
2
+
1
3
  export type MessengerPlatform = "discord" | "telegram" | "slack";
2
4
 
3
5
  export interface MessengerMessage {
@@ -51,9 +53,8 @@ export interface MessengerConfig {
51
53
  cwd: string;
52
54
  sessionDir: string;
53
55
  toolNames: string[];
56
+ sandbox?: SandboxConfig;
54
57
  platforms: Partial<Record<MessengerPlatform, MessengerPlatformConfig>>;
55
- /** Whether each message invocation runs in an isolated independent session. Defaults to true. */
56
- independentSessions?: boolean;
57
58
  }
58
59
 
59
60
  export interface MessengerTransportDeps {
@@ -209,6 +209,17 @@ import * as path from "node:path";
209
209
  import * as os from "node:os";
210
210
 
211
211
  import { jeoEnv } from "../util/env";
212
+ import { currentSandboxExecution } from "../agent/hooks";
213
+ import type { SandboxSupervisor } from "../agent/sandbox/types";
214
+ import { canonicalSandboxPath, nativeSandboxHome } from "../agent/sandbox/policy";
215
+
216
+ async function sandboxSkillCatalog<T>(action: string, cwd: string, args: Record<string, unknown> = {}): Promise<T> {
217
+ const sandbox = currentSandboxExecution();
218
+ if (!sandbox) throw new Error("SANDBOX_SKILLS: missing execution scope");
219
+ const result = await sandbox.supervisor.call("__workflow", { operation: "skillCatalog", action, ...args }, { cwd, signal: sandbox.signal });
220
+ if (!result.success) throw new Error(result.error || "SANDBOX_SKILLS: operation failed");
221
+ return JSON.parse(result.output) as T;
222
+ }
212
223
 
213
224
  const BUILTIN_SLASH_ALIASES = new Set([
214
225
  "/help", "/clear", "/compact", "/model", "/fast", "/provider", "/logout",
@@ -479,8 +490,204 @@ async function resolveSkillEntry(dir: string, entry: Dirent): Promise<SkillDoc |
479
490
  * batches racing across the `skillDirs` loop below. */
480
491
  const SKILL_ENTRY_LOAD_BATCH = 32;
481
492
 
493
+ interface CatalogPath { path: string; traversal: string[]; visited?: string[]; guestPath?: string }
494
+ interface CatalogEntry { location: CatalogPath; skillName: string; doc?: SkillDoc }
495
+ interface CatalogDirectory { location: CatalogPath; entries: CatalogEntry[] }
496
+ interface SandboxSkillCatalog { cwd: string; roots: string[]; readableRoots: string[]; directories: CatalogDirectory[] }
497
+ const sandboxSkillCatalogs = new WeakMap<SandboxSupervisor, SandboxSkillCatalog>();
498
+
499
+ function withinCatalogRoot(file: string, root: string): boolean {
500
+ const relative = path.relative(root, file);
501
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
502
+ }
503
+
504
+ function guestCatalogPath(location: CatalogPath, roots: string[]): string | undefined {
505
+ if (location.guestPath) return location.guestPath;
506
+ const route = location.traversal.find(file => roots.some(root => withinCatalogRoot(file, root)));
507
+ if (!route && location.visited?.some(file => roots.some(root => withinCatalogRoot(file, root)))) {
508
+ throw new Error("SANDBOX_SKILLS: catalog traversal leaves a writable root; explicit guest access required");
509
+ }
510
+ return route;
511
+ }
512
+
513
+ /** Metadata only: stop BEFORE traversing a workspace-controlled symlink. Called
514
+ * only while constructing the pre-execution snapshot, never for live host reads. */
515
+ async function traceCatalogPath(file: string, roots: string[], protectedRoots: string[]): Promise<CatalogPath> {
516
+ const absolute = path.resolve(file);
517
+ const parsed = path.parse(absolute);
518
+ let current = parsed.root;
519
+ let parts = absolute.slice(parsed.root.length).split(path.sep).filter(Boolean);
520
+ const traversal = [absolute];
521
+ if (!protectedRoots.some(root => withinCatalogRoot(absolute, root))) return { path: absolute, traversal, guestPath: absolute };
522
+ const visited: string[] = [];
523
+ let links = 0;
524
+ while (parts.length > 0) {
525
+ const part = parts.shift()!;
526
+ if (part === ".") continue;
527
+ current = part === ".." ? path.dirname(current) : path.join(current, part);
528
+ visited.push(current);
529
+ const candidate = path.join(current, ...parts);
530
+ if (!protectedRoots.some(root => withinCatalogRoot(current, root) || withinCatalogRoot(root, current))) {
531
+ return { path: candidate, traversal, visited, guestPath: candidate };
532
+ }
533
+ if (roots.some(root => withinCatalogRoot(current, root))) {
534
+ traversal.push(candidate);
535
+ const location = { path: candidate, traversal, visited };
536
+ guestCatalogPath(location, roots); // Reject '..' escape, never normalize it into a trusted host read.
537
+ return location;
538
+ }
539
+ if (part !== ".." && (await fs.lstat(current)).isSymbolicLink()) {
540
+ if (++links > 40) throw new Error("Too many skill catalog symlinks");
541
+ const target = await fs.readlink(current);
542
+ const targetRoot = path.parse(target).root;
543
+ current = targetRoot || path.dirname(current);
544
+ // Retain '..' until its preceding components and symlinks have been visited.
545
+ parts = target.slice(targetRoot.length).split(path.sep).filter(Boolean).concat(parts);
546
+ traversal.push(path.join(current, ...parts));
547
+ }
548
+ }
549
+ return { path: current, traversal, visited };
550
+ }
551
+
552
+ /** Must run before the supervisor executes any model-controlled guest code.
553
+ * Parent skill bytes are frozen in memory, not reread after tools can mutate paths. */
554
+ export async function prepareSandboxSkillCatalog(supervisor: SandboxSupervisor, cwd: string, writableRoots: readonly string[] = [], readableRoots: readonly string[] = writableRoots): Promise<void> {
555
+ if (currentSandboxExecution()) throw new Error("SANDBOX_SKILLS: snapshot requires trusted initialization");
556
+ if (sandboxSkillCatalogs.has(supervisor)) throw new Error("SANDBOX_SKILLS: catalog already initialized");
557
+ const roots = [...new Set([path.resolve(cwd), await fs.realpath(cwd), ...writableRoots.map(root => path.resolve(root))])];
558
+ // These roots (and their canonical aliases) are forbidden to EVERY sandbox
559
+ // profile, unlike runtime HOME/config directories or another session's grants.
560
+ const nativeHome = await nativeSandboxHome();
561
+ const nativeRoots = [".agents", ".claude", ".jeo", ".jeopi"].map(name => path.join(nativeHome, name));
562
+ const protectedRoots = [...new Set([...nativeRoots, ...await Promise.all(nativeRoots.map(root => canonicalSandboxPath(root, false)))])];
563
+ const authorizedRoots = [...new Set([path.resolve(cwd), await fs.realpath(cwd), ...readableRoots.map(root => path.resolve(root))])];
564
+ const directories: CatalogDirectory[] = [];
565
+ for (const dir of skillDirs(cwd)) {
566
+ let location: CatalogPath;
567
+ try { location = await traceCatalogPath(dir, roots, protectedRoots); }
568
+ catch {
569
+ directories.push({ location: { path: path.resolve(dir), traversal: [path.resolve(dir)] }, entries: [] });
570
+ continue;
571
+ }
572
+ const directory: CatalogDirectory = { location, entries: [] };
573
+ directories.push(directory);
574
+ if (guestCatalogPath(location, roots)) continue;
575
+ let entries: Dirent[];
576
+ try { entries = await fs.readdir(location.path, { withFileTypes: true }); } catch { continue; }
577
+ for (const entry of entries) {
578
+ if (entry.name.startsWith(".")) continue;
579
+ try {
580
+ let resolved = await traceCatalogPath(path.join(location.path, entry.name), roots, protectedRoots);
581
+ resolved.traversal.unshift(...location.traversal.map(root => path.join(root, entry.name)));
582
+ resolved.visited = [...(location.visited ?? []), ...(resolved.visited ?? [])];
583
+ let isDirectory = false;
584
+ if (!guestCatalogPath(resolved, roots)) {
585
+ const stat = await fs.stat(resolved.path);
586
+ if (stat.isDirectory()) {
587
+ isDirectory = true;
588
+ const skillFile = await traceCatalogPath(path.join(resolved.path, "SKILL.md"), roots, protectedRoots);
589
+ skillFile.traversal.unshift(...resolved.traversal.map(root => path.join(root, "SKILL.md")));
590
+ skillFile.visited = [...(resolved.visited ?? []), ...(skillFile.visited ?? [])];
591
+ resolved = skillFile;
592
+ } else if (!stat.isFile() || !entry.name.endsWith(".md")) continue;
593
+ }
594
+ const item: CatalogEntry = { location: resolved, skillName: entry.name.replace(/\.md$/, "") };
595
+ directory.entries.push(item);
596
+ if (guestCatalogPath(resolved, roots)) continue;
597
+ const doc = parseSkillMarkdown(entry.name.replace(/\.md$/, ""), await fs.readFile(resolved.path, "utf-8"), { preferMetaName: true });
598
+ doc.sourcePath = path.join(dir, entry.name, ...(isDirectory ? ["SKILL.md"] : []));
599
+ if (isSupportedExternalSkill(doc)) item.doc = doc;
600
+ } catch {
601
+ // Absent/unreadable global docs are not part of this session's snapshot.
602
+ }
603
+ }
604
+ }
605
+ sandboxSkillCatalogs.set(supervisor, { cwd: path.resolve(cwd), roots, readableRoots: authorizedRoots, directories });
606
+ }
607
+
608
+ /** Grant revisions may taint formerly trusted paths. Reclassify captured path
609
+ * provenance only; never refresh content from a now-running model's host paths. */
610
+ export function updateSandboxSkillCatalogRoots(supervisor: SandboxSupervisor, writableRoots: readonly string[], readableRoots: readonly string[] = writableRoots): void {
611
+ const catalog = sandboxSkillCatalogs.get(supervisor);
612
+ if (!catalog) throw new Error("SANDBOX_SKILLS: catalog not initialized");
613
+ catalog.roots = [...new Set([...catalog.roots, ...writableRoots.map(root => path.resolve(root))])];
614
+ catalog.readableRoots = [...new Set([catalog.cwd, ...readableRoots.map(root => path.resolve(root))])];
615
+ }
616
+
617
+ async function loadSandboxSkills(cwd: string): Promise<SkillDoc[]> {
618
+ const sandbox = currentSandboxExecution();
619
+ if (!sandbox) throw new Error("SANDBOX_SKILLS: missing execution scope");
620
+ const catalog = sandboxSkillCatalogs.get(sandbox.supervisor);
621
+ if (!catalog) throw new Error("SANDBOX_SKILLS: trusted catalog not initialized");
622
+ const byName = new Map<string, SkillDoc>(SKILLS.map(skill => [skill.name.toLowerCase(), skill]));
623
+ for (const name of await sandboxSkillCatalog<string[]>("lock", cwd)) ALLOWED_SKILL_NAMES.add(name.toLowerCase());
624
+ // A subagent may use another approved cwd: reuse only trusted global bytes,
625
+ // and derive its project directories without touching host filesystem state.
626
+ const directories = path.resolve(cwd) === catalog.cwd
627
+ ? catalog.directories
628
+ : catalog.directories.map((directory, index) => index >= 5 && index < 10
629
+ ? { location: { path: skillDirs(cwd)[index]!, traversal: [skillDirs(cwd)[index]!] }, entries: [] }
630
+ : directory);
631
+ for (const directory of directories) {
632
+ const guestDir = guestCatalogPath(directory.location, catalog.roots)
633
+ ?? (path.resolve(cwd) !== catalog.cwd && withinCatalogRoot(directory.location.path, path.resolve(cwd)) ? directory.location.path : undefined);
634
+ if (guestDir) {
635
+ if (!catalog.readableRoots.some(root => withinCatalogRoot(guestDir, root))) continue;
636
+ for (const doc of await sandboxSkillCatalog<SkillDoc[]>("scan", guestDir)) byName.set(doc.name.toLowerCase(), doc);
637
+ continue;
638
+ }
639
+ for (const entry of directory.entries) {
640
+ const guestFile = guestCatalogPath(entry.location, catalog.roots);
641
+ if (guestFile && !catalog.readableRoots.some(root => withinCatalogRoot(guestFile, root))) continue;
642
+ const doc = guestFile
643
+ ? await sandboxSkillCatalog<SkillDoc | null>("entry", path.dirname(guestFile), { name: path.basename(guestFile), skillName: entry.skillName })
644
+ : entry.doc;
645
+ if (doc) byName.set(doc.name.toLowerCase(), { ...doc });
646
+ }
647
+ }
648
+ return [...byName.values()];
649
+ }
650
+
651
+ async function scanSkillDirectory(dir: string): Promise<SkillDoc[]> {
652
+ let entries: Dirent[];
653
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return []; }
654
+ const skills: SkillDoc[] = [];
655
+ for (let i = 0; i < entries.length; i += SKILL_ENTRY_LOAD_BATCH) {
656
+ const resolved = await Promise.all(entries.slice(i, i + SKILL_ENTRY_LOAD_BATCH).map(entry => resolveSkillEntry(dir, entry)));
657
+ for (const skill of resolved) if (skill) skills.push(skill);
658
+ }
659
+ return skills;
660
+ }
661
+
662
+ /** Only workspace catalog effects execute in hands; trusted global docs stay in brain. */
663
+ export async function executeSkillCatalogOperation(args: Record<string, unknown>, cwd: string): Promise<unknown> {
664
+ if (currentSandboxExecution()) throw new Error("SANDBOX_SKILLS: guest handler cannot run in the brain");
665
+ if (args.action === "lock") {
666
+ try {
667
+ const lockData: unknown = JSON.parse(await fs.readFile(path.join(cwd, "skills-lock.json"), "utf-8"));
668
+ if (lockData && typeof lockData === "object" && "skills" in lockData && lockData.skills && typeof lockData.skills === "object") return Object.keys(lockData.skills);
669
+ } catch {}
670
+ return [];
671
+ }
672
+ if (args.action === "scan") return scanSkillDirectory(cwd);
673
+ if (args.action === "entry") {
674
+ if (typeof args.name !== "string" || args.name !== path.basename(args.name)) throw new Error("Invalid skill entry name");
675
+ const entryPath = path.join(cwd, args.name);
676
+ try {
677
+ const stat = await fs.stat(entryPath);
678
+ const file = stat.isDirectory() ? path.join(entryPath, "SKILL.md") : entryPath;
679
+ if (!stat.isDirectory() && (!stat.isFile() || !args.name.endsWith(".md"))) return null;
680
+ const doc = parseSkillMarkdown(typeof args.skillName === "string" ? args.skillName : args.name.replace(/\.md$/, ""), await fs.readFile(file, "utf-8"), { preferMetaName: true });
681
+ doc.sourcePath = file;
682
+ return isSupportedExternalSkill(doc) ? doc : null;
683
+ } catch { return null; }
684
+ }
685
+ throw new Error("Unknown skill catalog operation");
686
+ }
687
+
482
688
  /** Bundled skills merged with user skill docs from {@link skillDirs} (user overrides by name). */
483
689
  export async function loadSkills(cwd: string = process.cwd()): Promise<SkillDoc[]> {
690
+ if (currentSandboxExecution()) return loadSandboxSkills(cwd);
484
691
  try {
485
692
  const lockPath = path.join(cwd, "skills-lock.json");
486
693
  const lockContent = await fs.readFile(lockPath, "utf-8");