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
|
@@ -93,6 +93,14 @@ export const ConfigSchema = z
|
|
|
93
93
|
lmstudioBaseUrl: z.string().optional(),
|
|
94
94
|
defaultModel: z.string().min(1),
|
|
95
95
|
theme: z.string().optional(),
|
|
96
|
+
sandbox: z.object({
|
|
97
|
+
image: z.string().min(1),
|
|
98
|
+
limits: z.object({
|
|
99
|
+
memoryBytes: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
|
100
|
+
pids: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
|
101
|
+
cpus: z.number().finite().positive(),
|
|
102
|
+
}).strict(),
|
|
103
|
+
}).strict().optional(),
|
|
96
104
|
/** Terminal-bell notifications (gajae-code 0.7.8 parity): emit an ASCII BEL
|
|
97
105
|
* (\x07) at notable interaction points so a backgrounded session pings the
|
|
98
106
|
* user. Off by default; `bell` is the master toggle, per-event flags refine it.
|
|
@@ -3,6 +3,23 @@ import * as os from "node:os";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
|
|
5
5
|
import { IGNORED_DIRS } from "./tools";
|
|
6
|
+
import { currentSandboxExecution } from "./hooks";
|
|
7
|
+
|
|
8
|
+
async function sandboxContext(operation: "loadContext", cwd: string): Promise<ProjectContextFile[]>;
|
|
9
|
+
async function sandboxContext(operation: "discoverGuidance", cwd: string): Promise<Array<{ filePath: string; displayPath: string }>>;
|
|
10
|
+
async function sandboxContext(operation: "loadContext" | "discoverGuidance", cwd: string): Promise<ProjectContextFile[] | Array<{ filePath: string; displayPath: string }>> {
|
|
11
|
+
const sandbox = currentSandboxExecution();
|
|
12
|
+
if (!sandbox) throw new Error("SANDBOX_CONTEXT: missing execution scope");
|
|
13
|
+
const result = await sandbox.supervisor.call("__workflow", { operation }, { cwd, signal: sandbox.signal });
|
|
14
|
+
if (!result.success) throw new Error(result.error || "SANDBOX_CONTEXT: context read failed");
|
|
15
|
+
const value: unknown = JSON.parse(result.output);
|
|
16
|
+
const keys = operation === "loadContext" ? ["path", "content"] : ["filePath", "displayPath"];
|
|
17
|
+
if (!Array.isArray(value) || !value.every(item => item !== null && typeof item === "object" &&
|
|
18
|
+
keys.every(key => typeof item[key] === "string"))) {
|
|
19
|
+
throw new Error("SANDBOX_CONTEXT: invalid guest response");
|
|
20
|
+
}
|
|
21
|
+
return value as ProjectContextFile[] | Array<{ filePath: string; displayPath: string }>;
|
|
22
|
+
}
|
|
6
23
|
|
|
7
24
|
/**
|
|
8
25
|
* The user's home directory, or `undefined` when it genuinely cannot be determined.
|
|
@@ -293,6 +310,7 @@ export function invalidateWorkspaceScan(cwd?: string): void {
|
|
|
293
310
|
}
|
|
294
311
|
|
|
295
312
|
export async function discoverAgentGuidanceFiles(cwd = process.cwd()): Promise<Array<{ filePath: string; displayPath: string }>> {
|
|
313
|
+
if (currentSandboxExecution()) return sandboxContext("discoverGuidance", cwd);
|
|
296
314
|
const scan = await getWorkspaceScan(cwd);
|
|
297
315
|
const out: Array<{ filePath: string; displayPath: string }> = [...scan.localGuidance];
|
|
298
316
|
// No resolvable home → NO home roots. Previously this fell back to `path.join("", …)`,
|
|
@@ -325,6 +343,7 @@ export async function discoverAgentGuidanceFiles(cwd = process.cwd()): Promise<A
|
|
|
325
343
|
// cache. This keeps the result disk-truthful — a guidance/AGENTS.md edit is reflected on the
|
|
326
344
|
// next spawn — while still avoiding the dominant per-spawn cost (re-reading every file).
|
|
327
345
|
export async function loadProjectContext(cwd = process.cwd()): Promise<ProjectContextFile[]> {
|
|
346
|
+
if (currentSandboxExecution()) return sandboxContext("loadContext", cwd);
|
|
328
347
|
const result: ProjectContextFile[] = [];
|
|
329
348
|
let baseChars = 0;
|
|
330
349
|
let guidanceChars = 0;
|
package/src/agent/engine.ts
CHANGED
|
@@ -25,14 +25,14 @@ import { createDebugTool } from "./debug-tool";
|
|
|
25
25
|
import { createBrowserTool } from "./browser-tool";
|
|
26
26
|
import { isRateLimitError, isTransientStreamDropError, isProviderStreamError, waitAbortable } from "../util/retry";
|
|
27
27
|
import { isContextOverflowError, isRefusalError, friendlyProviderError } from "../util/provider-error";
|
|
28
|
-
import { runPreToolHooks, runPostTurnHooksForBatch } from "./hooks";
|
|
28
|
+
import { currentSandboxExecution, withSandboxExecution, runPreToolHooks, runPostTurnHooksForBatch } from "./hooks";
|
|
29
29
|
import { truncateToolOutput, formatToolResultBody } from "./tool-output";
|
|
30
30
|
export { TOOL_OUTPUT_MAX, READ_OUTPUT_MAX, TOOL_SPILL_THRESHOLD, MAX_TOOL_ARTIFACTS, truncateToolOutput, spillToolResult } from "./tool-output";
|
|
31
31
|
import { StepBudget, dynamicStepBudgetConfig, resolveStepBudgetConfig, hashSignature, toolTarget, type StepBudgetConfig } from "./step-budget";
|
|
32
32
|
|
|
33
33
|
import { historyTokens, trimToolResultsInPlace, stripReasoningArtifactsInPlace } from "./compaction";
|
|
34
34
|
import { jeoEnv } from "../util/env";
|
|
35
|
-
import { GUARD_LIMITS, isVerificationSignal, repeatHint, classifyDoneGate } from "./loop-guards";
|
|
35
|
+
import { GUARD_LIMITS, isVerificationSignal, verificationProducedEvidence, repeatHint, classifyDoneGate } from "./loop-guards";
|
|
36
36
|
import { stripLeakedReasoningTags } from "../ai/think-tags";
|
|
37
37
|
|
|
38
38
|
|
|
@@ -111,6 +111,17 @@ export const DEFAULT_TOOLS: Record<string, ToolHandler> = {
|
|
|
111
111
|
calc: a => calcTool(Array.isArray(a.calculations) ? a.calculations : []),
|
|
112
112
|
};
|
|
113
113
|
|
|
114
|
+
/** Trusted wrappers that preserve role checks before forwarding to hands. */
|
|
115
|
+
export const SANDBOX_MEDIATED_TOOLS = new WeakSet<ToolHandler>();
|
|
116
|
+
// Engine and workflow callers share these handlers; the async scope is session-local.
|
|
117
|
+
for (const [name, handler] of Object.entries(DEFAULT_TOOLS)) {
|
|
118
|
+
DEFAULT_TOOLS[name] = (args, cwd, onProgress) => {
|
|
119
|
+
const sandbox = currentSandboxExecution();
|
|
120
|
+
return sandbox ? sandbox.supervisor.call(name, args, { cwd, signal: sandbox.signal, onProgress }) : handler(args, cwd, onProgress);
|
|
121
|
+
};
|
|
122
|
+
SANDBOX_MEDIATED_TOOLS.add(DEFAULT_TOOLS[name]);
|
|
123
|
+
}
|
|
124
|
+
|
|
114
125
|
/** Tool names whose calls have no workspace side effects. Shared by execution,
|
|
115
126
|
* failure accounting, and the refusal-recovery replay exemption. */
|
|
116
127
|
const READONLY_TOOL_NAMES: Record<string, true> = {
|
|
@@ -520,6 +531,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
520
531
|
history.unshift({ role: "system", content: opts.systemPrompt });
|
|
521
532
|
}
|
|
522
533
|
const tools = opts.tools ?? DEFAULT_TOOLS;
|
|
534
|
+
const sandbox = currentSandboxExecution();
|
|
535
|
+
const parentTools: Record<string, true> = { task: true, subagent: true, irc: true, todo: true, sandbox_request: true };
|
|
523
536
|
const maxSteps = opts.maxSteps ?? 15;
|
|
524
537
|
// gjc-style retry flow: the step limit is a flexible BUDGET, not a bare counter.
|
|
525
538
|
// While the recent window shows real progress the budget extends itself; a stalled
|
|
@@ -1277,7 +1290,12 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1277
1290
|
} else {
|
|
1278
1291
|
try {
|
|
1279
1292
|
const onProgress = ev.onToolProgress ? (partial: string) => ev.onToolProgress!(tool, partial) : undefined;
|
|
1280
|
-
const
|
|
1293
|
+
const sandboxedHandler = sandbox && !Object.hasOwn(parentTools, tool) && !SANDBOX_MEDIATED_TOOLS.has(handler)
|
|
1294
|
+
? (input: Record<string, any>, _cwd: string, progress?: (text: string) => void) => sandbox.supervisor.call(tool, input, { cwd, signal: opts.signal, onProgress: progress })
|
|
1295
|
+
: handler;
|
|
1296
|
+
const res = await (sandbox
|
|
1297
|
+
? withSandboxExecution({ ...sandbox, signal: opts.signal }, () => sandboxedHandler(args ?? {}, cwd, onProgress))
|
|
1298
|
+
: sandboxedHandler(args ?? {}, cwd, onProgress));
|
|
1281
1299
|
success = res.success;
|
|
1282
1300
|
images = res.images;
|
|
1283
1301
|
output = res.success ? res.output : (res.error ? (res.output ? `${res.error}\n${res.output}` : res.error) : res.output);
|
|
@@ -1463,9 +1481,10 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1463
1481
|
for (let i = 0; i < toolCalls.length; i++) {
|
|
1464
1482
|
if (!results[i].executed) continue;
|
|
1465
1483
|
const t = toolCalls[i].tool;
|
|
1466
|
-
const
|
|
1484
|
+
const command = String(toolCalls[i].arguments?.command ?? toolCalls[i].arguments?.cmd ?? "");
|
|
1485
|
+
const verificationCall = t === "bash" && isVerificationSignal(command);
|
|
1467
1486
|
if (verificationCall) {
|
|
1468
|
-
if (results[i].success) {
|
|
1487
|
+
if (results[i].success && verificationProducedEvidence(command, String(results[i].output ?? ""))) {
|
|
1469
1488
|
sawVerification = true;
|
|
1470
1489
|
lastVerificationSeq = ++mutVerSeq;
|
|
1471
1490
|
} else {
|
|
@@ -1514,7 +1533,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1514
1533
|
sawMutation = true;
|
|
1515
1534
|
lastMutationSeq = ++mutVerSeq;
|
|
1516
1535
|
} else if (t === "bash") {
|
|
1517
|
-
//
|
|
1536
|
+
// Even simple commands can run shell startup hooks or inherited functions.
|
|
1518
1537
|
sawMutation = true;
|
|
1519
1538
|
lastMutationSeq = ++mutVerSeq;
|
|
1520
1539
|
}
|
package/src/agent/eval-tool.ts
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
} from "./task-tool";
|
|
50
50
|
import { getSubagentRole, defaultSubagentRole, subagentRoleIds } from "./subagents";
|
|
51
51
|
import { loadProjectContext, type ProjectContextFile } from "./context-files";
|
|
52
|
+
import { currentSandboxExecution } from "./hooks";
|
|
52
53
|
|
|
53
54
|
export interface EvalToolOptions {
|
|
54
55
|
config: SubagentTaskConfig;
|
|
@@ -101,6 +102,9 @@ const DEFAULT_EVAL_TIMEOUT_MS = 300_000; // 5 minutes
|
|
|
101
102
|
const MAX_EVAL_TIMEOUT_MS = 1_800_000; // 30 minutes — same outer ceiling as JEO_CALL_TIMEOUT_MS's default
|
|
102
103
|
|
|
103
104
|
export function evalToolProtocolLine(config?: Pick<import("./state").Config, "subagents">): string {
|
|
105
|
+
if (currentSandboxExecution()) {
|
|
106
|
+
return "eval {code, timeoutMs?} executes JavaScript in the persistent session hands kernel at the mapped working directory. Filesystem/process access stays inside the container. task(), parallel(), and pipeline() orchestration are not available there; use the parent task tool to delegate model work.";
|
|
107
|
+
}
|
|
104
108
|
return (
|
|
105
109
|
`eval {code, timeoutMs?} — Dynamic Workflows: write an async JS function BODY (return value or throw) ` +
|
|
106
110
|
`composing subagent dispatch with REAL control flow (loops/conditionals/sequencing) instead of one static tool call. ` +
|
|
@@ -224,6 +228,8 @@ type WorkerMsg = TaskRequestMsg | LogMsg | DoneMsg;
|
|
|
224
228
|
|
|
225
229
|
export function createEvalTool(opts: EvalToolOptions): ToolHandler {
|
|
226
230
|
return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
|
|
231
|
+
const sandbox = currentSandboxExecution();
|
|
232
|
+
if (sandbox) return sandbox.supervisor.call("eval", args, { cwd, signal: opts.signal ?? sandbox.signal });
|
|
227
233
|
const code = typeof args.code === "string" ? args.code : "";
|
|
228
234
|
if (!code.trim()) {
|
|
229
235
|
return { success: false, output: "", error: `eval requires a non-empty 'code' body (an async function BODY — the last expression or an explicit 'return' becomes the result).` };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
|
|
3
|
-
import { callLlm
|
|
3
|
+
import { callLlm } from "./loop";
|
|
4
|
+
import type { Message } from "./loop";
|
|
4
5
|
|
|
5
6
|
export interface GoalVerdict {
|
|
6
7
|
verdict: "MET" | "NOT_MET" | "IMPOSSIBLE";
|
|
@@ -204,25 +205,42 @@ export function applyPendingEvidence(state: GoalState, evidence: TurnEvidence):
|
|
|
204
205
|
|
|
205
206
|
import * as path from "node:path";
|
|
206
207
|
import * as fs from "node:fs/promises";
|
|
207
|
-
import { getLocalJeoDir } from "./state";
|
|
208
|
+
import { getLocalJeoDir, readWorkflowFile, writeWorkflowFile } from "./state";
|
|
209
|
+
import { currentSandboxExecution } from "./hooks";
|
|
208
210
|
|
|
209
211
|
export function getGoalPath(cwd: string = process.cwd()): string {
|
|
210
212
|
return path.join(getLocalJeoDir(cwd), "state", "goal.json");
|
|
211
213
|
}
|
|
212
214
|
|
|
213
|
-
|
|
215
|
+
/** Return exact persisted bytes (or absence) so corruption is classified in the brain. */
|
|
216
|
+
export async function readGoalFile(cwd: string = process.cwd()): Promise<string | null> {
|
|
217
|
+
const sandbox = currentSandboxExecution();
|
|
218
|
+
if (sandbox) {
|
|
219
|
+
const result = await sandbox.supervisor.call("__workflow", { operation: "readGoal" }, { cwd, signal: sandbox.signal });
|
|
220
|
+
if (!result.success) throw new Error(result.error || "SANDBOX_WORKFLOW: goal read failed");
|
|
221
|
+
const value: unknown = JSON.parse(result.output);
|
|
222
|
+
if (value !== null && typeof value !== "string") throw new Error("SANDBOX_WORKFLOW: invalid goal file response");
|
|
223
|
+
return value;
|
|
224
|
+
}
|
|
214
225
|
const p = getGoalPath(cwd);
|
|
215
|
-
let data: string;
|
|
216
226
|
try {
|
|
217
|
-
|
|
227
|
+
return await readWorkflowFile(p, cwd);
|
|
218
228
|
} catch (error) {
|
|
219
229
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
220
230
|
throw error;
|
|
221
231
|
}
|
|
222
|
-
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function readGoalState(cwd: string = process.cwd()): Promise<GoalState | null> {
|
|
235
|
+
const data = await readGoalFile(cwd);
|
|
236
|
+
return data === null ? null : parseGoalState(data, getGoalPath(cwd));
|
|
223
237
|
}
|
|
224
238
|
|
|
225
239
|
export async function writeGoalState(state: GoalState, cwd: string = process.cwd()): Promise<void> {
|
|
240
|
+
if (currentSandboxExecution()) {
|
|
241
|
+
await writeWorkflowFile(getGoalPath(cwd), JSON.stringify(state, null, 2), cwd);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
226
244
|
const p = getGoalPath(cwd);
|
|
227
245
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
228
246
|
const tempPath = `${p}.${randomUUID()}.tmp`;
|
|
@@ -235,6 +253,12 @@ export async function writeGoalState(state: GoalState, cwd: string = process.cwd
|
|
|
235
253
|
}
|
|
236
254
|
|
|
237
255
|
export async function clearGoalState(cwd: string = process.cwd()): Promise<void> {
|
|
256
|
+
const sandbox = currentSandboxExecution();
|
|
257
|
+
if (sandbox) {
|
|
258
|
+
const result = await sandbox.supervisor.call("__workflow", { operation: "clearGoal" }, { cwd, signal: sandbox.signal });
|
|
259
|
+
if (!result.success) throw new Error(result.error || "SANDBOX_WORKFLOW: goal clear failed");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
238
262
|
const p = getGoalPath(cwd);
|
|
239
263
|
await fs.unlink(p).catch(() => {});
|
|
240
264
|
}
|
package/src/agent/hooks.ts
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import type { SandboxSupervisor } from "./sandbox/types";
|
|
3
|
+
import type { GrantAuthority } from "./sandbox/authority";
|
|
4
|
+
|
|
5
|
+
export interface SandboxExecutionScope {
|
|
6
|
+
supervisor: SandboxSupervisor;
|
|
7
|
+
authority?: GrantAuthority;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
requestAccess?: (host: string, mode: "ro" | "rw") => Promise<unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const sandboxExecution = new AsyncLocalStorage<SandboxExecutionScope>();
|
|
13
|
+
|
|
14
|
+
export function currentSandboxExecution(): SandboxExecutionScope | undefined {
|
|
15
|
+
return sandboxExecution.getStore();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function withSandboxExecution<T>(scope: SandboxExecutionScope, run: () => T): T {
|
|
19
|
+
return sandboxExecution.run(scope, run);
|
|
20
|
+
}
|
|
21
|
+
|
|
1
22
|
import * as fs from "node:fs/promises";
|
|
2
23
|
import * as path from "node:path";
|
|
3
24
|
import { readGlobalConfig } from "./state";
|
|
@@ -63,12 +84,25 @@ async function readLocalHooks(localPath: string): Promise<LocalHooks> {
|
|
|
63
84
|
return result;
|
|
64
85
|
}
|
|
65
86
|
|
|
66
|
-
export async function loadHooks(cwd: string): Promise<NonNullable<HookConfig["hooks"]>> {
|
|
87
|
+
export async function loadHooks(cwd: string, signal?: AbortSignal): Promise<NonNullable<HookConfig["hooks"]>> {
|
|
67
88
|
const config = await readGlobalConfig();
|
|
68
89
|
if (!config.hooks?.enabled) {
|
|
69
90
|
return [];
|
|
70
91
|
}
|
|
71
92
|
|
|
93
|
+
const sandbox = currentSandboxExecution();
|
|
94
|
+
if (sandbox) {
|
|
95
|
+
const result = await sandbox.supervisor.exec(["bun", "-e", "const file=Bun.file('.jeo/hooks.json');let value={};try{if(await file.exists())value=await file.json()}catch{}process.stdout.write(JSON.stringify(value));"], { cwd, signal: signal ?? sandbox.signal });
|
|
96
|
+
if (!result.success || result.exitCode !== 0) throw new Error(`Sandbox project hook configuration read failed (exit code ${result.exitCode ?? "unavailable"}): ${result.error || result.output}`);
|
|
97
|
+
let local: unknown;
|
|
98
|
+
try { local = JSON.parse(result.stdout ?? result.output); }
|
|
99
|
+
catch { throw new Error("Malformed sandbox project hook configuration"); }
|
|
100
|
+
if (local && typeof local === "object") {
|
|
101
|
+
if ("enabled" in local && local.enabled === false) return [];
|
|
102
|
+
if ("hooks" in local && Array.isArray(local.hooks)) return local.hooks;
|
|
103
|
+
}
|
|
104
|
+
return config.hooks.hooks || [];
|
|
105
|
+
}
|
|
72
106
|
const local = await readLocalHooks(path.join(cwd, ".jeo", "hooks.json"));
|
|
73
107
|
if (local.kind === "disabled") return [];
|
|
74
108
|
if (local.kind === "hooks") return local.hooks;
|
|
@@ -86,6 +120,19 @@ async function runHookCommand(
|
|
|
86
120
|
if (signal?.aborted) {
|
|
87
121
|
return { stdout: "", stderr: "", output: "", exitCode: null, timedOut: false, aborted: true };
|
|
88
122
|
}
|
|
123
|
+
const sandbox = currentSandboxExecution();
|
|
124
|
+
if (sandbox) {
|
|
125
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
126
|
+
const result = await sandbox.supervisor.exec(["/bin/sh", "-c", runCmd], {
|
|
127
|
+
cwd, stdin: JSON.stringify(payload), signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
128
|
+
});
|
|
129
|
+
if (result.exitCode === undefined) throw new Error(result.error || "Malformed sandbox hook result: missing exitCode");
|
|
130
|
+
return {
|
|
131
|
+
stdout: result.stdout ?? result.output, stderr: result.stderr ?? "", output: result.output,
|
|
132
|
+
exitCode: result.exitCode, timedOut: result.timedOut === true || timeout.aborted,
|
|
133
|
+
aborted: result.aborted === true || signal?.aborted === true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
89
136
|
|
|
90
137
|
const proc = Bun.spawn(["bash", "-c", runCmd], {
|
|
91
138
|
cwd,
|
|
@@ -154,6 +201,7 @@ async function runHookCommand(
|
|
|
154
201
|
};
|
|
155
202
|
}
|
|
156
203
|
|
|
204
|
+
|
|
157
205
|
/** True when a hook's `match.tool` selects `tool`. Accepts a single name or a
|
|
158
206
|
* `|`-separated list (`"edit|write"`) — so one post-edit tsc hook can cover
|
|
159
207
|
* every mutating tool without duplicate entries. No match = all tools. */
|
|
@@ -170,7 +218,7 @@ export async function runPreToolHooks(
|
|
|
170
218
|
onNotice?: (msg: string) => void
|
|
171
219
|
): Promise<{ vetoed: boolean; error?: string; output?: string }> {
|
|
172
220
|
try {
|
|
173
|
-
const hooks = await loadHooks(cwd);
|
|
221
|
+
const hooks = await loadHooks(cwd, signal);
|
|
174
222
|
const preToolHooks = hooks.filter(
|
|
175
223
|
h => h.event === "pre-tool" && hookMatchesTool(h.match?.tool, tool)
|
|
176
224
|
);
|
|
@@ -243,7 +291,7 @@ export async function runPostTurnHooksForBatch(
|
|
|
243
291
|
// "hooks ran clean" (clear a pending failure) differently from "no hook ran".
|
|
244
292
|
let ran = 0;
|
|
245
293
|
try {
|
|
246
|
-
const hooks = await loadHooks(cwd);
|
|
294
|
+
const hooks = await loadHooks(cwd, signal);
|
|
247
295
|
const jobs = hooks
|
|
248
296
|
.filter(h => h.event === "post-turn")
|
|
249
297
|
.map(hook => ({ hook, matched: calls.filter(c => hookMatchesTool(hook.match?.tool, c.tool)) }))
|
|
@@ -311,7 +359,7 @@ export async function runPostImplementationHooks(
|
|
|
311
359
|
onNotice?: (msg: string) => void
|
|
312
360
|
): Promise<{ success: boolean; output: string }> {
|
|
313
361
|
try {
|
|
314
|
-
const hooks = await loadHooks(cwd);
|
|
362
|
+
const hooks = await loadHooks(cwd, signal);
|
|
315
363
|
const postImplHooks = hooks.filter(h => h.event === "post-implementation");
|
|
316
364
|
|
|
317
365
|
let combinedOutput = "";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* In-process registry for BACKGROUND shell processes (gjc `job`/async-bash parity,
|
|
3
3
|
* scoped down to one runtime). Unlike the synchronous `bash` tool, `start()` spawns
|
|
4
|
-
* a real parallel OS process
|
|
4
|
+
* a real parallel OS process (see `shell-lease.ts`) and returns immediately — the parent
|
|
5
5
|
* can keep working and later list / tail / await / cancel it via the `job` control
|
|
6
6
|
* tool while stdout/stderr drain into a bounded buffer in the background.
|
|
7
7
|
*
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
*
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { settlesWithin, spawnLeasedShell, type LeasedShell } from "./shell-lease";
|
|
16
|
+
|
|
15
17
|
export type JobStatus = "running" | "exited" | "killed" | "failed";
|
|
16
18
|
|
|
17
19
|
export interface JobRecord {
|
|
@@ -27,17 +29,25 @@ export interface JobRecord {
|
|
|
27
29
|
|
|
28
30
|
interface Entry {
|
|
29
31
|
record: JobRecord;
|
|
30
|
-
proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
31
32
|
promise: Promise<void>;
|
|
33
|
+
cancel?: () => boolean;
|
|
34
|
+
/** SIGKILL reached the pinned process group. */
|
|
35
|
+
killDelivered?: boolean;
|
|
36
|
+
/** Termination of every process holding the job's group or pipes cannot be confirmed. */
|
|
37
|
+
incomplete?: boolean;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
const OUTPUT_CAP = 20000;
|
|
35
41
|
const TRUNCATION_MARKER = "[…output truncated…]\n";
|
|
42
|
+
const PIPE_LINGER_MS = 500;
|
|
36
43
|
|
|
37
44
|
export class JobRegistry {
|
|
38
45
|
private readonly entries = new Map<string, Entry>();
|
|
39
46
|
private readonly buffers = new Map<string, string>();
|
|
40
47
|
private readonly truncated = new Set<string>();
|
|
48
|
+
private readonly reported = new Set<string>();
|
|
49
|
+
private readonly pendingMutations = new Set<string>();
|
|
50
|
+
private readonly active = new Set<string>();
|
|
41
51
|
private seq = 0;
|
|
42
52
|
|
|
43
53
|
private append(id: string, chunk: string): void {
|
|
@@ -66,42 +76,91 @@ export class JobRegistry {
|
|
|
66
76
|
};
|
|
67
77
|
this.buffers.set(id, "");
|
|
68
78
|
|
|
69
|
-
let
|
|
79
|
+
let shell: LeasedShell;
|
|
70
80
|
try {
|
|
71
|
-
|
|
81
|
+
shell = spawnLeasedShell(command, cwd);
|
|
72
82
|
} catch (err) {
|
|
73
83
|
record.status = "failed";
|
|
74
84
|
record.finishedAt = Date.now();
|
|
75
85
|
this.append(id, err instanceof Error ? err.message : String(err));
|
|
86
|
+
this.pendingMutations.add(id);
|
|
76
87
|
// No process handle to store — nothing further to drain or cancel.
|
|
77
|
-
this.entries.set(id, { record,
|
|
88
|
+
this.entries.set(id, { record, promise: Promise.resolve() });
|
|
78
89
|
return record;
|
|
79
90
|
}
|
|
91
|
+
this.active.add(id);
|
|
80
92
|
|
|
81
|
-
const
|
|
93
|
+
const entry: Entry = { record, promise: Promise.resolve() };
|
|
94
|
+
entry.cancel = (): boolean => {
|
|
95
|
+
const outcome = shell.killGroup();
|
|
96
|
+
if (outcome === "delivered") { entry.killDelivered = true; return true; }
|
|
97
|
+
entry.incomplete = true;
|
|
98
|
+
this.append(id, outcome === "unpinned"
|
|
99
|
+
? "\n[job process group is no longer pinned; termination cannot be confirmed]\n"
|
|
100
|
+
: outcome === "unsupported"
|
|
101
|
+
? "\n[job process tree termination cannot be confirmed on Windows]\n"
|
|
102
|
+
: `\n[job process group termination attempt failed: ${String(outcome.error)}]\n`);
|
|
103
|
+
shell.killDirect();
|
|
104
|
+
return false;
|
|
105
|
+
};
|
|
106
|
+
const drain = async (reader: { read: () => Promise<{ done?: boolean; value?: Uint8Array }> }) => {
|
|
82
107
|
const decoder = new TextDecoder();
|
|
83
|
-
|
|
84
|
-
|
|
108
|
+
while (true) {
|
|
109
|
+
const { done, value } = await reader.read();
|
|
110
|
+
if (done) break;
|
|
111
|
+
if (value) this.append(id, decoder.decode(value, { stream: true }));
|
|
85
112
|
}
|
|
113
|
+
this.append(id, decoder.decode());
|
|
86
114
|
};
|
|
115
|
+
const drains = Promise.all([drain(shell.stdout), drain(shell.stderr)]);
|
|
116
|
+
void drains.catch(() => {});
|
|
87
117
|
|
|
88
118
|
const promise = (async () => {
|
|
89
119
|
try {
|
|
90
|
-
await
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
|
|
120
|
+
const exitCode = await shell.exit;
|
|
121
|
+
let lingered = false;
|
|
122
|
+
if (!(await settlesWithin(drains, PIPE_LINGER_MS))) {
|
|
123
|
+
lingered = true;
|
|
124
|
+
// A descendant still holds stdout/stderr. Only the pinned group may be signalled; if EOF does
|
|
125
|
+
// not follow the delivered SIGKILL, the holder left the group and stays unresolved.
|
|
126
|
+
if ((entry.killDelivered || entry.cancel!()) && !(await settlesWithin(drains, PIPE_LINGER_MS))) {
|
|
127
|
+
entry.incomplete = true;
|
|
128
|
+
this.append(id, "\n[job pipe remains open outside the process group; termination cannot be confirmed]\n");
|
|
129
|
+
}
|
|
130
|
+
shell.dispose();
|
|
131
|
+
await drains;
|
|
132
|
+
}
|
|
133
|
+
if (exitCode === null && !entry.killDelivered && !entry.incomplete) {
|
|
134
|
+
entry.incomplete = true;
|
|
135
|
+
this.append(id, "\n[job process group lease ended without an exit report; termination cannot be confirmed]\n");
|
|
136
|
+
}
|
|
137
|
+
if (record.status === "killed" && !entry.incomplete) return;
|
|
138
|
+
if (!lingered && !entry.incomplete) {
|
|
139
|
+
record.status = "exited";
|
|
140
|
+
record.exitCode = exitCode;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
record.status = "failed";
|
|
94
144
|
record.exitCode = exitCode;
|
|
145
|
+
if (lingered) this.append(id, "\n[job output incomplete: a descendant kept stdout or stderr open after the shell exited]\n");
|
|
95
146
|
} catch (err) {
|
|
147
|
+
// Output can no longer be observed: stop the group now so the holder can be reaped.
|
|
148
|
+
if (!entry.killDelivered) entry.cancel!();
|
|
96
149
|
if (record.status === "killed") return;
|
|
97
150
|
record.status = "failed";
|
|
98
151
|
this.append(id, err instanceof Error ? err.message : String(err));
|
|
99
152
|
} finally {
|
|
153
|
+
shell.release();
|
|
154
|
+
await shell.reaped;
|
|
155
|
+
shell.dispose();
|
|
156
|
+
if (!entry.incomplete) this.active.delete(id);
|
|
157
|
+
this.pendingMutations.add(id);
|
|
100
158
|
if (record.finishedAt === undefined) record.finishedAt = Date.now();
|
|
101
159
|
}
|
|
102
160
|
})();
|
|
103
161
|
|
|
104
|
-
|
|
162
|
+
entry.promise = promise;
|
|
163
|
+
this.entries.set(id, entry);
|
|
105
164
|
return record;
|
|
106
165
|
}
|
|
107
166
|
|
|
@@ -117,6 +176,34 @@ export class JobRegistry {
|
|
|
117
176
|
return this.list().filter(r => r.status === "running");
|
|
118
177
|
}
|
|
119
178
|
|
|
179
|
+
/** Include running jobs and terminal jobs not yet reported by await. */
|
|
180
|
+
awaiting(): JobRecord[] {
|
|
181
|
+
return this.list().filter(r => r.status === "running" || !this.reported.has(r.id));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
markReported(records: JobRecord[]): void {
|
|
185
|
+
for (const record of records) {
|
|
186
|
+
if (record.status !== "running") this.reported.add(record.id);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Report each process completion once to invalidate earlier verification. */
|
|
191
|
+
consumeMutations(ids?: string[]): boolean {
|
|
192
|
+
if (ids === undefined) {
|
|
193
|
+
const pending = this.pendingMutations.size > 0;
|
|
194
|
+
this.pendingMutations.clear();
|
|
195
|
+
return pending;
|
|
196
|
+
}
|
|
197
|
+
let pending = false;
|
|
198
|
+
for (const id of ids) pending = this.pendingMutations.delete(id) || pending;
|
|
199
|
+
return pending;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Processes not yet settled or whose completion has not reached the engine. */
|
|
203
|
+
unobserved(): JobRecord[] {
|
|
204
|
+
return this.list().filter(record => this.active.has(record.id) || this.pendingMutations.has(record.id));
|
|
205
|
+
}
|
|
206
|
+
|
|
120
207
|
/** Current buffered output for one job (empty string if the id is unknown). */
|
|
121
208
|
tail(id: string): string {
|
|
122
209
|
return this.buffers.get(id) ?? "";
|
|
@@ -142,25 +229,30 @@ export class JobRegistry {
|
|
|
142
229
|
return targets.map(e => e.record);
|
|
143
230
|
}
|
|
144
231
|
|
|
145
|
-
/** Cancel the
|
|
146
|
-
* records are returned unchanged. */
|
|
232
|
+
/** Cancel the process group on POSIX, or the direct process on Windows. */
|
|
147
233
|
cancel(ids: string[]): JobRecord[] {
|
|
148
234
|
const out: JobRecord[] = [];
|
|
149
235
|
for (const id of ids) {
|
|
150
236
|
const e = this.entries.get(id);
|
|
151
237
|
if (!e) continue;
|
|
238
|
+
if (e.incomplete) { out.push(e.record); continue; }
|
|
152
239
|
if (e.record.status === "running") {
|
|
153
|
-
|
|
240
|
+
const killed = e.cancel?.();
|
|
241
|
+
e.record.status = killed ? "killed" : "failed";
|
|
154
242
|
e.record.finishedAt = Date.now();
|
|
155
|
-
e.proc?.kill();
|
|
156
243
|
}
|
|
157
244
|
out.push(e.record);
|
|
158
245
|
}
|
|
159
246
|
return out;
|
|
160
247
|
}
|
|
161
248
|
|
|
249
|
+
/** Include unresolved process groups even if their shell has exited. */
|
|
250
|
+
cancellable(): JobRecord[] {
|
|
251
|
+
return this.list().filter(r => r.status === "running" || this.entries.get(r.id)?.incomplete);
|
|
252
|
+
}
|
|
253
|
+
|
|
162
254
|
/** Kill every still-running job (session teardown / Ctrl-C). */
|
|
163
255
|
cancelAll(): JobRecord[] {
|
|
164
|
-
return this.cancel(this.
|
|
256
|
+
return this.cancel(this.cancellable().map(r => r.id));
|
|
165
257
|
}
|
|
166
258
|
}
|
package/src/agent/job-tool.ts
CHANGED
|
@@ -6,13 +6,17 @@
|
|
|
6
6
|
import type { ToolHandler } from "./engine";
|
|
7
7
|
import type { ToolResult } from "./tools";
|
|
8
8
|
import type { JobRegistry, JobRecord } from "./job-registry";
|
|
9
|
+
import { settlesWithin } from "./shell-lease";
|
|
10
|
+
|
|
11
|
+
/** Upper bound on pipe-linger, escape detection and notice-queue drain after a cancel. */
|
|
12
|
+
const CANCEL_SETTLE_MS = 3000;
|
|
9
13
|
|
|
10
14
|
/** One-line protocol description appended to the launch system prompt. */
|
|
11
15
|
export const JOB_TOOL_PROTOCOL_LINE =
|
|
12
16
|
`job {action:"start", command, cwd?} runs a shell command in the BACKGROUND as a real ` +
|
|
13
17
|
`parallel OS process and returns a job id immediately. job {action:"list"|"tail"|"await"|"cancel", ` +
|
|
14
18
|
`ids?, timeoutMs?} — 'tail' shows buffered output so far; 'await' blocks (optionally up to timeoutMs ` +
|
|
15
|
-
`ms) until it exits; 'cancel' kills it; omit ids to target all running.`;
|
|
19
|
+
`ms) until it exits; 'cancel' kills it; omit ids to target all running, plus unreported completions for 'await'.`;
|
|
16
20
|
|
|
17
21
|
function truncate(s: string, n: number): string {
|
|
18
22
|
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
@@ -24,7 +28,8 @@ function elapsed(rec: JobRecord): string {
|
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
function rowLine(rec: JobRecord): string {
|
|
27
|
-
|
|
31
|
+
const exit = rec.exitCode != null ? ` (exit code ${rec.exitCode})` : "";
|
|
32
|
+
return `- ${rec.id} [${rec.status.toUpperCase()}]${exit} ${elapsed(rec)} · ${truncate(rec.command, 100)}`;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
function idsOf(args: Record<string, any>): string[] {
|
|
@@ -33,8 +38,8 @@ function idsOf(args: Record<string, any>): string[] {
|
|
|
33
38
|
return [];
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
export function createJobTool(registry: JobRegistry): ToolHandler {
|
|
37
|
-
|
|
41
|
+
export function createJobTool(registry: JobRegistry, reportMutations = true): ToolHandler {
|
|
42
|
+
const handle: ToolHandler = async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
|
|
38
43
|
const action = String(args.action ?? "list").trim().toLowerCase();
|
|
39
44
|
const ids = idsOf(args);
|
|
40
45
|
|
|
@@ -79,28 +84,57 @@ export function createJobTool(registry: JobRegistry): ToolHandler {
|
|
|
79
84
|
}
|
|
80
85
|
|
|
81
86
|
if (action === "await") {
|
|
82
|
-
const targets = ids.length ? ids : registry.
|
|
87
|
+
const targets = ids.length ? ids : registry.awaiting().map(r => r.id);
|
|
83
88
|
if (targets.length === 0) {
|
|
84
|
-
return { success: true, output: "No
|
|
89
|
+
return { success: true, output: "No jobs to await." };
|
|
90
|
+
}
|
|
91
|
+
const unknown = targets.filter(id => !registry.get(id));
|
|
92
|
+
if (unknown.length > 0) {
|
|
93
|
+
return { success: false, output: "", error: `No job matches ${unknown.join(", ")}.` };
|
|
85
94
|
}
|
|
86
95
|
const timeoutMs = typeof args.timeoutMs === "number" && args.timeoutMs > 0 ? args.timeoutMs : undefined;
|
|
87
96
|
const recs = await registry.awaitIds(targets, timeoutMs);
|
|
97
|
+
if (reportMutations) registry.markReported(recs);
|
|
88
98
|
const stillRunning = recs.filter(r => r.status === "running").length;
|
|
89
99
|
const head = stillRunning > 0
|
|
90
100
|
? `Awaited ${recs.length} job(s); ${stillRunning} still running after the ${timeoutMs}ms timeout — await again or cancel.`
|
|
91
101
|
: `Awaited ${recs.length} job(s); all settled.`;
|
|
92
|
-
|
|
102
|
+
const success = recs.every(r => r.status === "exited" && r.exitCode === 0);
|
|
103
|
+
return { success, output: `${head}\n\n${recs.map(rowLine).join("\n")}\n\nUse job {action:"tail", ids:[...]} to inspect output.` };
|
|
93
104
|
}
|
|
94
105
|
|
|
95
106
|
if (action === "cancel") {
|
|
96
|
-
const
|
|
107
|
+
const cancellable = new Set(registry.cancellable().map(r => r.id));
|
|
108
|
+
const targets = ids.length ? [...new Set(ids)] : [...cancellable];
|
|
97
109
|
if (targets.length === 0) {
|
|
98
110
|
return { success: true, output: "No running jobs to cancel." };
|
|
99
111
|
}
|
|
112
|
+
const unknown = targets.filter(id => !registry.get(id));
|
|
113
|
+
if (unknown.length > 0) {
|
|
114
|
+
return { success: false, output: "", error: `No job matches ${unknown.join(", ")}.` };
|
|
115
|
+
}
|
|
116
|
+
const attempted = targets.filter(id => cancellable.has(id));
|
|
100
117
|
const recs = registry.cancel(targets);
|
|
101
|
-
|
|
118
|
+
// A delivered SIGKILL is only verified once the output pipes close; settlement can still
|
|
119
|
+
// downgrade `killed` to `failed` (a holder that left the group), so wait before judging.
|
|
120
|
+
const settled = attempted.length === 0 || await settlesWithin(registry.awaitIds(attempted), CANCEL_SETTLE_MS);
|
|
121
|
+
const failed = recs.filter(r => cancellable.has(r.id) && (!settled || r.status !== "killed")).length;
|
|
122
|
+
const head = attempted.length ? `Cancelled ${attempted.length - failed} job(s); ${failed} cancellation(s) failed; ${recs.length - attempted.length} already settled.` : "No running jobs to cancel; requested jobs are already settled.";
|
|
123
|
+
return { success: failed === 0, output: `${head}\n${recs.map(rowLine).join("\n")}`, mutationMayHaveOccurred: attempted.length > 0 };
|
|
102
124
|
}
|
|
103
125
|
|
|
104
126
|
return { success: false, output: "", error: `Unknown job action '${action}'. Use start | list | tail | await | cancel.` };
|
|
105
127
|
};
|
|
128
|
+
return async (args, cwd) => {
|
|
129
|
+
const action = String(args.action ?? "list").trim().toLowerCase();
|
|
130
|
+
const ids = idsOf(args);
|
|
131
|
+
const targets = action === "list" ? registry.list().map(r => r.id)
|
|
132
|
+
: action === "await" ? (ids.length ? ids : registry.awaiting().map(r => r.id))
|
|
133
|
+
: action === "tail" ? (ids.length ? ids : registry.running().map(r => r.id))
|
|
134
|
+
: action === "cancel" ? (ids.length ? ids : registry.cancellable().map(r => r.id))
|
|
135
|
+
: [];
|
|
136
|
+
const result = await handle(args, cwd);
|
|
137
|
+
if (reportMutations && !result.error && registry.consumeMutations(targets)) result.mutationMayHaveOccurred = true;
|
|
138
|
+
return result;
|
|
139
|
+
};
|
|
106
140
|
}
|