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.
- package/CHANGELOG.md +42 -0
- package/README.ja.md +32 -8
- package/README.ko.md +32 -8
- package/README.md +32 -8
- package/README.zh.md +32 -8
- 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 +295 -0
- package/src/agent/sandbox/bundle.ts +37 -0
- package/src/agent/sandbox/control.ts +49 -0
- package/src/agent/sandbox/docker.ts +283 -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 +155 -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 +261 -35
- package/src/commands/messenger.ts +105 -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 +51 -9
- package/src/messenger/discord-setup.ts +122 -20
- package/src/messenger/discord.ts +37 -43
- package/src/messenger/gateway.ts +33 -10
- package/src/messenger/model-selection.ts +101 -0
- package/src/messenger/session-pool.ts +189 -35
- package/src/messenger/types.ts +3 -2
- 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
|
-
|
|
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>,
|
|
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.
|
|
47
|
-
void worker.
|
|
48
|
-
() => { if (retiring.get(key) === worker.
|
|
49
|
-
() => {
|
|
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
|
-
|
|
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
|
|
93
|
-
|
|
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)
|
|
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
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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(
|
|
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
|
-
|
|
189
|
-
|
|
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
|
|
package/src/messenger/types.ts
CHANGED
|
@@ -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 {
|
package/src/skills/catalog.ts
CHANGED
|
@@ -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");
|