jeo-code 0.12.22 → 0.12.24
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 +25 -1
- package/README.ja.md +17 -2
- package/README.ko.md +17 -2
- package/README.md +17 -2
- package/README.zh.md +17 -2
- package/package.json +1 -1
- package/src/agent/AGENTS.md +1 -1
- package/src/agent/config-schema.ts +1 -1
- package/src/agent/engine.ts +20 -3
- package/src/agent/hooks.ts +342 -32
- package/src/agent/loop-guards.ts +14 -2
- package/src/agent/sandbox/hands-source.text +528 -528
- package/src/agent/state.ts +27 -6
- package/src/commands/doctor.ts +34 -0
- package/src/commands/launch.ts +28 -3
- package/src/skills/catalog.ts +36 -11
package/src/agent/hooks.ts
CHANGED
|
@@ -22,8 +22,8 @@ export function withSandboxExecution<T>(scope: SandboxExecutionScope, run: () =>
|
|
|
22
22
|
import * as fs from "node:fs/promises";
|
|
23
23
|
import * as path from "node:path";
|
|
24
24
|
import { resolvePosixTool } from "../util/posix-tools";
|
|
25
|
-
import { readGlobalConfig } from "./state";
|
|
26
|
-
import type { HookConfig } from "./state";
|
|
25
|
+
import { readGlobalConfig, userHooksPath } from "./state";
|
|
26
|
+
import type { HookConfig, HookEntry, HookEvent } from "./state";
|
|
27
27
|
|
|
28
28
|
interface HookRunResult {
|
|
29
29
|
stdout: string;
|
|
@@ -57,6 +57,23 @@ type LocalHooks =
|
|
|
57
57
|
const localHooksCache = new Map<string, { mtimeMs: number; size: number; result: LocalHooks }>();
|
|
58
58
|
const LOCAL_HOOKS_CACHE_CAP = 32;
|
|
59
59
|
|
|
60
|
+
const HOOK_EVENTS: ReadonlySet<string> = new Set<HookEvent>(["pre-tool", "post-turn", "post-implementation", "pre-prompt", "turn-end"]);
|
|
61
|
+
|
|
62
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
63
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Hook files are written by installers as well as by hand: drop entries that cannot run
|
|
67
|
+
* (unknown event, missing command, bad timeout) instead of letting one poison the batch. */
|
|
68
|
+
function isHookEntry(value: unknown): value is HookEntry {
|
|
69
|
+
if (!isRecord(value)) return false;
|
|
70
|
+
if (typeof value.event !== "string" || !HOOK_EVENTS.has(value.event)) return false;
|
|
71
|
+
if (typeof value.run !== "string" || !value.run.trim()) return false;
|
|
72
|
+
if (value.timeoutMs !== undefined && !(typeof value.timeoutMs === "number" && value.timeoutMs > 0)) return false;
|
|
73
|
+
if (value.match !== undefined && !isRecord(value.match)) return false;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
async function readLocalHooks(localPath: string): Promise<LocalHooks> {
|
|
61
78
|
let st: { mtimeMs: number; size: number };
|
|
62
79
|
try {
|
|
@@ -72,7 +89,7 @@ async function readLocalHooks(localPath: string): Promise<LocalHooks> {
|
|
|
72
89
|
const parsed = JSON.parse(await fs.readFile(localPath, "utf-8"));
|
|
73
90
|
if (parsed && typeof parsed === "object") {
|
|
74
91
|
if (parsed.enabled === false) result = { kind: "disabled" };
|
|
75
|
-
else if (Array.isArray(parsed.hooks)) result = { kind: "hooks", hooks: parsed.hooks };
|
|
92
|
+
else if (Array.isArray(parsed.hooks)) result = { kind: "hooks", hooks: parsed.hooks.filter(isHookEntry) };
|
|
76
93
|
}
|
|
77
94
|
} catch {
|
|
78
95
|
// Missing/invalid local file → fall back to the global config hooks.
|
|
@@ -85,35 +102,126 @@ async function readLocalHooks(localPath: string): Promise<LocalHooks> {
|
|
|
85
102
|
return result;
|
|
86
103
|
}
|
|
87
104
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
105
|
+
/**
|
|
106
|
+
* A sandboxed session reads the project hook file through the supervisor so the host
|
|
107
|
+
* never trusts workspace content it did not mediate. Same outcome shape as
|
|
108
|
+
* `readLocalHooks`.
|
|
109
|
+
*/
|
|
110
|
+
async function readSandboxLocalHooks(sandbox: SandboxExecutionScope, cwd: string, signal?: AbortSignal): Promise<LocalHooks> {
|
|
111
|
+
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 });
|
|
112
|
+
if (!result.success || result.exitCode !== 0) throw new Error(`Sandbox project hook configuration read failed (exit code ${result.exitCode ?? "unavailable"}): ${result.error || result.output}`);
|
|
113
|
+
let local: unknown;
|
|
114
|
+
try { local = JSON.parse(result.stdout ?? result.output); }
|
|
115
|
+
catch { throw new Error("Malformed sandbox project hook configuration"); }
|
|
116
|
+
if (isRecord(local)) {
|
|
117
|
+
if (local.enabled === false) return { kind: "disabled" };
|
|
118
|
+
if (Array.isArray(local.hooks)) return { kind: "hooks", hooks: local.hooks.filter(isHookEntry) };
|
|
92
119
|
}
|
|
120
|
+
return { kind: "fallback" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Effective hook list, in run order: user registry (`userHooksPath()`) first, then the
|
|
125
|
+
* configured list.
|
|
126
|
+
*
|
|
127
|
+
* Precedence, strongest first:
|
|
128
|
+
* 1. `config.hooks.enabled === false` — global kill switch; nothing runs.
|
|
129
|
+
* 2. Project `.jeo/hooks.json` with `enabled: false` — project opt-out; nothing runs.
|
|
130
|
+
* 3. The user registry runs unless it opts itself out (`enabled: false`). It is
|
|
131
|
+
* user-owned and installer-managed, so it does NOT need `config.hooks.enabled: true`.
|
|
132
|
+
* 4. `config.hooks.hooks` / the project `.jeo/hooks.json` list run only when
|
|
133
|
+
* `config.hooks.enabled === true` (so an untrusted repo cannot enable its own hooks);
|
|
134
|
+
* the project list replaces the config list, as before, but never the user registry.
|
|
135
|
+
*
|
|
136
|
+
* Sandboxed sessions skip the user registry: its commands live on the host and cannot
|
|
137
|
+
* run inside the sandbox.
|
|
138
|
+
*/
|
|
139
|
+
export async function loadHooks(cwd: string, signal?: AbortSignal): Promise<HookEntry[]> {
|
|
140
|
+
const config = await readGlobalConfig();
|
|
141
|
+
if (config.hooks?.enabled === false) return [];
|
|
93
142
|
|
|
94
143
|
const sandbox = currentSandboxExecution();
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const local = await readLocalHooks(path.join(cwd, ".jeo", "hooks.json"));
|
|
144
|
+
const configHooksOn = config.hooks?.enabled === true;
|
|
145
|
+
// Sandboxed sessions read the project file only when hooks are explicitly enabled, as before.
|
|
146
|
+
if (sandbox && !configHooksOn) return [];
|
|
147
|
+
// The registry lives on the host; a sandboxed session never runs it.
|
|
148
|
+
const user = sandbox ? undefined : await readLocalHooks(userHooksPath());
|
|
149
|
+
const userHooks = user?.kind === "hooks" ? user.hooks : [];
|
|
150
|
+
// Common case (no registry, hooks not enabled): nothing can run, so skip the project file.
|
|
151
|
+
if (!configHooksOn && userHooks.length === 0) return [];
|
|
152
|
+
|
|
153
|
+
const local = sandbox
|
|
154
|
+
? await readSandboxLocalHooks(sandbox, cwd, signal)
|
|
155
|
+
: await readLocalHooks(path.join(cwd, ".jeo", "hooks.json"));
|
|
108
156
|
if (local.kind === "disabled") return [];
|
|
109
|
-
if (local.kind === "hooks") return local.hooks;
|
|
110
157
|
|
|
111
|
-
|
|
158
|
+
const configured = !configHooksOn ? [] : local.kind === "hooks" ? local.hooks : config.hooks?.hooks ?? [];
|
|
159
|
+
return userHooks.length ? [...userHooks, ...configured] : configured;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** One hook file as `describeHooks` sees it, read fresh (never from the loader cache). */
|
|
163
|
+
export interface HookFileReport {
|
|
164
|
+
path: string;
|
|
165
|
+
/** `missing`; `ok`; `disabled` (`enabled: false`); `unreadable` (not valid JSON or not an object). */
|
|
166
|
+
state: "missing" | "ok" | "disabled" | "unreadable";
|
|
167
|
+
/** Entries that can run. */
|
|
168
|
+
entries: number;
|
|
169
|
+
/** Entries dropped as unrunnable (unknown event, missing `run`, bad `timeoutMs`). */
|
|
170
|
+
invalid: number;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface HookReport {
|
|
174
|
+
/** `config.hooks.enabled`: `on` (true), `off` (false: global kill switch), `unset`. */
|
|
175
|
+
gate: "on" | "off" | "unset";
|
|
176
|
+
registry: HookFileReport;
|
|
177
|
+
project: HookFileReport;
|
|
178
|
+
/** What `loadHooks(cwd)` returns right now, counted by event. */
|
|
179
|
+
active: Partial<Record<HookEvent, number>>;
|
|
180
|
+
total: number;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function inspectHookFile(file: string): Promise<HookFileReport> {
|
|
184
|
+
let text: string;
|
|
185
|
+
try {
|
|
186
|
+
text = await fs.readFile(file, "utf-8");
|
|
187
|
+
} catch (err: unknown) {
|
|
188
|
+
const missing = isRecord(err) && err.code === "ENOENT";
|
|
189
|
+
return { path: file, state: missing ? "missing" : "unreadable", entries: 0, invalid: 0 };
|
|
190
|
+
}
|
|
191
|
+
let parsed: unknown;
|
|
192
|
+
try { parsed = JSON.parse(text); } catch { return { path: file, state: "unreadable", entries: 0, invalid: 0 }; }
|
|
193
|
+
if (!isRecord(parsed)) return { path: file, state: "unreadable", entries: 0, invalid: 0 };
|
|
194
|
+
const list = Array.isArray(parsed.hooks) ? parsed.hooks : [];
|
|
195
|
+
const entries = list.filter(isHookEntry).length;
|
|
196
|
+
return { path: file, state: parsed.enabled === false ? "disabled" : "ok", entries, invalid: list.length - entries };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Read-only account of the hook setup for `jeo doctor`: the global switch, the user
|
|
201
|
+
* registry and the project file as written (with how many entries were dropped as
|
|
202
|
+
* unrunnable), and what actually runs for `cwd`. Installers write these files, and a bad
|
|
203
|
+
* entry is otherwise dropped silently, so this is the one place to see why a hook is idle.
|
|
204
|
+
*/
|
|
205
|
+
export async function describeHooks(cwd: string): Promise<HookReport> {
|
|
206
|
+
const config = await readGlobalConfig();
|
|
207
|
+
const gate = config.hooks?.enabled === true ? "on" : config.hooks?.enabled === false ? "off" : "unset";
|
|
208
|
+
const [registry, project] = await Promise.all([inspectHookFile(userHooksPath()), inspectHookFile(path.join(cwd, ".jeo", "hooks.json"))]);
|
|
209
|
+
const active: Partial<Record<HookEvent, number>> = {};
|
|
210
|
+
let total = 0;
|
|
211
|
+
try {
|
|
212
|
+
for (const hook of await loadHooks(cwd)) {
|
|
213
|
+
active[hook.event] = (active[hook.event] ?? 0) + 1;
|
|
214
|
+
total++;
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
// An unreadable project file must not take the whole report down.
|
|
218
|
+
}
|
|
219
|
+
return { gate, registry, project, active, total };
|
|
112
220
|
}
|
|
113
221
|
|
|
114
222
|
async function runHookCommand(
|
|
115
223
|
runCmd: string,
|
|
116
|
-
payload:
|
|
224
|
+
payload: unknown,
|
|
117
225
|
cwd: string,
|
|
118
226
|
timeoutMs = 30000,
|
|
119
227
|
signal?: AbortSignal
|
|
@@ -211,13 +319,79 @@ export function hookMatchesTool(matchTool: string | undefined, tool: string): bo
|
|
|
211
319
|
return matchTool.split("|").some(t => t.trim() === tool);
|
|
212
320
|
}
|
|
213
321
|
|
|
322
|
+
let hookSessionId = "";
|
|
323
|
+
|
|
324
|
+
/** Set by the launch loop so every hook payload carries the live session id
|
|
325
|
+
* (`session_id`, Claude hook protocol). Empty string when no session is active. */
|
|
326
|
+
export function setHookSessionId(sessionId: string | undefined): void {
|
|
327
|
+
hookSessionId = sessionId ?? "";
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Fields every hook payload carries. `event` is jeo's own name (kept for existing hooks);
|
|
332
|
+
* `hook_event_name`/`session_id`/`cwd` follow the Claude hook protocol so scripts written
|
|
333
|
+
* for Claude Code / Codex (`UserPromptSubmit`, `Stop`, `PreToolUse`) run unmodified.
|
|
334
|
+
*
|
|
335
|
+
* A sandboxed hook runs in the guest, so it gets the legacy payload only: never the host
|
|
336
|
+
* `cwd` or session id.
|
|
337
|
+
*/
|
|
338
|
+
function basePayload(event: HookEvent, claudeEvent: string, cwd: string) {
|
|
339
|
+
if (currentSandboxExecution()) return { event };
|
|
340
|
+
return { event, hook_event_name: claudeEvent, session_id: hookSessionId, cwd };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Claude-protocol aliases (`tool_name`/`tool_input`) for a tool call; none in a sandbox. */
|
|
344
|
+
function toolAliases(tool: string, args: Record<string, unknown>) {
|
|
345
|
+
return currentSandboxExecution() ? {} : { tool_name: tool, tool_input: args };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const MAX_HOOK_CONTEXT_CHARS = 8_000;
|
|
349
|
+
|
|
350
|
+
function parseHookJson(stdout: string): Record<string, unknown> | undefined {
|
|
351
|
+
const text = stdout.trim();
|
|
352
|
+
if (!text.startsWith("{")) return undefined;
|
|
353
|
+
try {
|
|
354
|
+
const parsed: unknown = JSON.parse(text);
|
|
355
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
356
|
+
} catch {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function clampContext(text: string): string {
|
|
362
|
+
return text.length > MAX_HOOK_CONTEXT_CHARS ? `${text.slice(0, MAX_HOOK_CONTEXT_CHARS)}\n... (hook context truncated)` : text;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** `additionalContext` from a Claude-protocol hook output (`hookSpecificOutput.additionalContext`,
|
|
366
|
+
* top-level `additionalContext`, or `context`). Empty when the output carries none. */
|
|
367
|
+
function jsonHookContext(json: Record<string, unknown>): string {
|
|
368
|
+
const specific = isRecord(json.hookSpecificOutput) ? json.hookSpecificOutput : {};
|
|
369
|
+
for (const value of [specific.additionalContext, json.additionalContext, json.context]) {
|
|
370
|
+
if (typeof value === "string" && value.trim()) return clampContext(value.trim());
|
|
371
|
+
}
|
|
372
|
+
return "";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** A deny decision a hook returned with exit 0 (Claude `permissionDecision: deny|ask`,
|
|
376
|
+
* legacy `decision: block`, or `continue: false`). jeo has no approval prompt for hooks,
|
|
377
|
+
* so `ask` is treated as deny — the safe reading. */
|
|
378
|
+
function jsonHookDenial(json: Record<string, unknown>): { reason: string } | undefined {
|
|
379
|
+
const specific = isRecord(json.hookSpecificOutput) ? json.hookSpecificOutput : {};
|
|
380
|
+
const decision = specific.permissionDecision ?? json.decision;
|
|
381
|
+
const denied = decision === "deny" || decision === "ask" || decision === "block" || json.continue === false;
|
|
382
|
+
if (!denied) return undefined;
|
|
383
|
+
const reason = [specific.permissionDecisionReason, json.reason, json.stopReason].find(v => typeof v === "string" && v.trim());
|
|
384
|
+
return { reason: typeof reason === "string" ? reason.trim() : "no reason given" };
|
|
385
|
+
}
|
|
386
|
+
|
|
214
387
|
export async function runPreToolHooks(
|
|
215
388
|
cwd: string,
|
|
216
389
|
tool: string,
|
|
217
|
-
args: Record<string,
|
|
390
|
+
args: Record<string, unknown>,
|
|
218
391
|
signal?: AbortSignal,
|
|
219
392
|
onNotice?: (msg: string) => void
|
|
220
|
-
): Promise<{ vetoed: boolean; error?: string; output?: string }> {
|
|
393
|
+
): Promise<{ vetoed: boolean; error?: string; output?: string; context?: string }> {
|
|
394
|
+
const contexts: string[] = [];
|
|
221
395
|
try {
|
|
222
396
|
const hooks = await loadHooks(cwd, signal);
|
|
223
397
|
const preToolHooks = hooks.filter(
|
|
@@ -226,9 +400,10 @@ export async function runPreToolHooks(
|
|
|
226
400
|
|
|
227
401
|
for (const hook of preToolHooks) {
|
|
228
402
|
const payload = {
|
|
229
|
-
|
|
403
|
+
...basePayload("pre-tool", "PreToolUse", cwd),
|
|
230
404
|
tool,
|
|
231
405
|
args,
|
|
406
|
+
...toolAliases(tool, args),
|
|
232
407
|
};
|
|
233
408
|
|
|
234
409
|
const timeoutMs = hook.timeoutMs || 30000;
|
|
@@ -248,14 +423,27 @@ export async function runPreToolHooks(
|
|
|
248
423
|
onNotice?.(msg);
|
|
249
424
|
return { vetoed: true, error: msg, output: result.output };
|
|
250
425
|
}
|
|
426
|
+
// Exit 0 can still carry a structured decision (Claude-protocol hooks never exit 2
|
|
427
|
+
// to deny; they print `permissionDecision`). Plain stdout stays ignored so a
|
|
428
|
+
// hook's own logging never leaks into the model's tool result.
|
|
429
|
+
const json = parseHookJson(result.stdout);
|
|
430
|
+
if (!json) continue;
|
|
431
|
+
const denial = jsonHookDenial(json);
|
|
432
|
+
if (denial) {
|
|
433
|
+
const msg = `Pre-tool hook "${hook.run}" denied execution: ${denial.reason}`;
|
|
434
|
+
onNotice?.(msg);
|
|
435
|
+
return { vetoed: true, error: msg };
|
|
436
|
+
}
|
|
437
|
+
const context = jsonHookContext(json);
|
|
438
|
+
if (context) contexts.push(context);
|
|
251
439
|
}
|
|
252
|
-
} catch (err:
|
|
253
|
-
const msg = `Error executing pre-tool hooks: ${err.message}`;
|
|
440
|
+
} catch (err: unknown) {
|
|
441
|
+
const msg = `Error executing pre-tool hooks: ${err instanceof Error ? err.message : String(err)}`;
|
|
254
442
|
onNotice?.(msg);
|
|
255
443
|
return { vetoed: true, error: msg, output: "" };
|
|
256
444
|
}
|
|
257
445
|
|
|
258
|
-
return { vetoed: false };
|
|
446
|
+
return contexts.length ? { vetoed: false, context: contexts.join("\n") } : { vetoed: false };
|
|
259
447
|
}
|
|
260
448
|
|
|
261
449
|
/** One executed tool call fed to the post-turn hooks of a batch. */
|
|
@@ -301,10 +489,11 @@ export async function runPostTurnHooksForBatch(
|
|
|
301
489
|
// Distinct hooks are independent commands → run them concurrently. Each hook
|
|
302
490
|
// itself runs once for the whole batch (the redundancy this fix removes).
|
|
303
491
|
const settled = await Promise.all(jobs.map(async ({ hook, matched }) => {
|
|
492
|
+
const base = basePayload("post-turn", "PostToolUse", cwd);
|
|
304
493
|
const payload = matched.length === 1
|
|
305
|
-
? {
|
|
494
|
+
? { ...base, tool: matched[0].tool, args: matched[0].args, ...toolAliases(matched[0].tool, matched[0].args), success: matched[0].success, output: outputPreviewOf(matched[0].output) }
|
|
306
495
|
: {
|
|
307
|
-
|
|
496
|
+
...base,
|
|
308
497
|
tool: matched.map(c => c.tool).join(","),
|
|
309
498
|
calls: matched.map(c => ({ tool: c.tool, args: c.args, success: c.success })),
|
|
310
499
|
success: matched.every(c => c.success),
|
|
@@ -368,7 +557,7 @@ export async function runPostImplementationHooks(
|
|
|
368
557
|
|
|
369
558
|
for (const hook of postImplHooks) {
|
|
370
559
|
const payload = {
|
|
371
|
-
|
|
560
|
+
...basePayload("post-implementation", "post-implementation", cwd),
|
|
372
561
|
request,
|
|
373
562
|
};
|
|
374
563
|
|
|
@@ -389,3 +578,124 @@ export async function runPostImplementationHooks(
|
|
|
389
578
|
return { success: false, output: msg };
|
|
390
579
|
}
|
|
391
580
|
}
|
|
581
|
+
|
|
582
|
+
const PRE_PROMPT_TIMEOUT_MS = 10_000;
|
|
583
|
+
const TURN_END_TIMEOUT_MS = 30_000;
|
|
584
|
+
const MAX_PROMPT_PAYLOAD_CHARS = 20_000;
|
|
585
|
+
const MAX_RESPONSE_PAYLOAD_CHARS = 200_000;
|
|
586
|
+
|
|
587
|
+
export interface PrePromptHookResult {
|
|
588
|
+
/** Context the hooks asked to place in front of the model (empty when none). */
|
|
589
|
+
context: string;
|
|
590
|
+
/** Hooks that ran to completion (exit 0) — lets callers tell "nothing to add" from "no hook". */
|
|
591
|
+
ran: number;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Run `pre-prompt` hooks once for a real user prompt, before the model sees it
|
|
596
|
+
* (Claude `UserPromptSubmit`). Payload: `{event, hook_event_name:"UserPromptSubmit",
|
|
597
|
+
* session_id, cwd, prompt}`. A hook contributes context by printing either plain text or a
|
|
598
|
+
* Claude-protocol JSON object (`hookSpecificOutput.additionalContext` / `additionalContext`
|
|
599
|
+
* / `context`). This is a routing/enrichment point, never a gate: a hook that fails, times
|
|
600
|
+
* out or is aborted yields a notice and no context, so a broken hook can't block a prompt.
|
|
601
|
+
*/
|
|
602
|
+
export async function runPrePromptHooks(
|
|
603
|
+
cwd: string,
|
|
604
|
+
prompt: string,
|
|
605
|
+
signal?: AbortSignal,
|
|
606
|
+
onNotice?: (msg: string) => void,
|
|
607
|
+
): Promise<PrePromptHookResult> {
|
|
608
|
+
const contexts: string[] = [];
|
|
609
|
+
let ran = 0;
|
|
610
|
+
try {
|
|
611
|
+
const hooks = (await loadHooks(cwd, signal)).filter(h => h.event === "pre-prompt");
|
|
612
|
+
if (hooks.length === 0 || !prompt.trim()) return { context: "", ran: 0 };
|
|
613
|
+
const payload = { ...basePayload("pre-prompt", "UserPromptSubmit", cwd), prompt: prompt.slice(0, MAX_PROMPT_PAYLOAD_CHARS) };
|
|
614
|
+
const settled = await Promise.all(hooks.map(async hook => {
|
|
615
|
+
try {
|
|
616
|
+
return { hook, result: await runHookCommand(hook.run, payload, cwd, hook.timeoutMs || PRE_PROMPT_TIMEOUT_MS, signal) };
|
|
617
|
+
} catch (err: unknown) {
|
|
618
|
+
onNotice?.(`Pre-prompt hook "${hook.run}" failed to start (advisory): ${err instanceof Error ? err.message : String(err)}`);
|
|
619
|
+
return undefined;
|
|
620
|
+
}
|
|
621
|
+
}));
|
|
622
|
+
// Hook order, not completion order, decides how contexts are stacked.
|
|
623
|
+
for (const entry of settled) {
|
|
624
|
+
if (!entry) continue;
|
|
625
|
+
const { hook, result } = entry;
|
|
626
|
+
if (result.timedOut) onNotice?.(`Pre-prompt hook "${hook.run}" timed out (advisory).`);
|
|
627
|
+
else if (result.aborted) continue;
|
|
628
|
+
else if (result.exitCode !== 0) onNotice?.(`Pre-prompt hook "${hook.run}" exited with non-zero code ${result.exitCode} (advisory).`);
|
|
629
|
+
else {
|
|
630
|
+
ran++;
|
|
631
|
+
const json = parseHookJson(result.stdout);
|
|
632
|
+
const context = json ? jsonHookContext(json) : clampContext(result.stdout.trim());
|
|
633
|
+
if (context) contexts.push(context);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
} catch (err: unknown) {
|
|
637
|
+
onNotice?.(`Error executing pre-prompt hooks (advisory): ${err instanceof Error ? err.message : String(err)}`);
|
|
638
|
+
}
|
|
639
|
+
return { context: contexts.join("\n\n"), ran };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export interface TurnEndInfo {
|
|
643
|
+
prompt: string;
|
|
644
|
+
/** The final reply text of the turn. */
|
|
645
|
+
response: string;
|
|
646
|
+
/** True when the model signalled completion (false for a step-limit/unverified stop). */
|
|
647
|
+
done: boolean;
|
|
648
|
+
steps: number;
|
|
649
|
+
model?: string;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Run `turn-end` hooks once per finished turn (Claude `Stop`). Payload carries the turn's
|
|
654
|
+
* final reply under `response` and `last_assistant_message` (and `assistant_output`) so
|
|
655
|
+
* scripts that ingest assistant output can read it directly, alongside `prompt`, `done`,
|
|
656
|
+
* `steps`, `model`, `session_id`, `cwd`. Advisory only: failures yield a notice and never
|
|
657
|
+
* change the turn's result. Awaited so a one-shot `jeo -p` does not exit before they finish.
|
|
658
|
+
*/
|
|
659
|
+
export async function runTurnEndHooks(
|
|
660
|
+
cwd: string,
|
|
661
|
+
info: TurnEndInfo,
|
|
662
|
+
signal?: AbortSignal,
|
|
663
|
+
onNotice?: (msg: string) => void,
|
|
664
|
+
): Promise<{ ran: number }> {
|
|
665
|
+
let ran = 0;
|
|
666
|
+
try {
|
|
667
|
+
const hooks = (await loadHooks(cwd, signal)).filter(h => h.event === "turn-end");
|
|
668
|
+
if (hooks.length === 0) return { ran: 0 };
|
|
669
|
+
const response = info.response.length > MAX_RESPONSE_PAYLOAD_CHARS ? info.response.slice(0, MAX_RESPONSE_PAYLOAD_CHARS) : info.response;
|
|
670
|
+
const payload = {
|
|
671
|
+
...basePayload("turn-end", "Stop", cwd),
|
|
672
|
+
prompt: info.prompt.slice(0, MAX_PROMPT_PAYLOAD_CHARS),
|
|
673
|
+
response,
|
|
674
|
+
last_assistant_message: response,
|
|
675
|
+
assistant_output: response,
|
|
676
|
+
done: info.done,
|
|
677
|
+
steps: info.steps,
|
|
678
|
+
model: info.model ?? "",
|
|
679
|
+
stop_hook_active: false,
|
|
680
|
+
};
|
|
681
|
+
const settled = await Promise.all(hooks.map(async hook => {
|
|
682
|
+
try {
|
|
683
|
+
return { hook, result: await runHookCommand(hook.run, payload, cwd, hook.timeoutMs || TURN_END_TIMEOUT_MS, signal) };
|
|
684
|
+
} catch (err: unknown) {
|
|
685
|
+
onNotice?.(`Turn-end hook "${hook.run}" failed to start (advisory): ${err instanceof Error ? err.message : String(err)}`);
|
|
686
|
+
return undefined;
|
|
687
|
+
}
|
|
688
|
+
}));
|
|
689
|
+
for (const entry of settled) {
|
|
690
|
+
if (!entry) continue;
|
|
691
|
+
const { hook, result } = entry;
|
|
692
|
+
if (result.timedOut) onNotice?.(`Turn-end hook "${hook.run}" timed out (advisory).`);
|
|
693
|
+
else if (result.aborted) continue;
|
|
694
|
+
else if (result.exitCode !== 0) onNotice?.(`Turn-end hook "${hook.run}" exited with non-zero code ${result.exitCode} (advisory).`);
|
|
695
|
+
else ran++;
|
|
696
|
+
}
|
|
697
|
+
} catch (err: unknown) {
|
|
698
|
+
onNotice?.(`Error executing turn-end hooks (advisory): ${err instanceof Error ? err.message : String(err)}`);
|
|
699
|
+
}
|
|
700
|
+
return { ran };
|
|
701
|
+
}
|
package/src/agent/loop-guards.ts
CHANGED
|
@@ -233,6 +233,12 @@ export interface DoneGateInput {
|
|
|
233
233
|
verificationStale?: boolean;
|
|
234
234
|
/** The run-command of the most recent still-failing post-turn hook, or null. */
|
|
235
235
|
pendingHookFailure: string | null;
|
|
236
|
+
/**
|
|
237
|
+
* Why the host could not measure a shell call's effect on the workspace this turn (snapshot cap or
|
|
238
|
+
* error), when that happened. Such a call is counted as a possible mutation, so the gate says why
|
|
239
|
+
* instead of claiming files were modified.
|
|
240
|
+
*/
|
|
241
|
+
unmeasured?: string;
|
|
236
242
|
}
|
|
237
243
|
|
|
238
244
|
/** Verdict from {@link classifyDoneGate}: whether to bounce `done`, and the message. */
|
|
@@ -257,6 +263,10 @@ export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
|
|
|
257
263
|
const stale = input.sawVerification && input.verificationStale === true;
|
|
258
264
|
const block = input.sawMutation && (!input.sawVerification || stale || hookFailing);
|
|
259
265
|
if (!block) return { state: "done_ok", block: false, message: "" };
|
|
266
|
+
const unmeasuredNote = input.unmeasured
|
|
267
|
+
? ` Note: the host could not measure a shell call's effect on the workspace (${input.unmeasured}), so it is counted as a possible file change; ` +
|
|
268
|
+
"if every command you ran was read-only, say so in your reply."
|
|
269
|
+
: "";
|
|
260
270
|
if (hookFailing) {
|
|
261
271
|
return {
|
|
262
272
|
state: "done_hook_failing",
|
|
@@ -272,7 +282,8 @@ export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
|
|
|
272
282
|
block: true,
|
|
273
283
|
message:
|
|
274
284
|
"You verified earlier, but then modified files again — your last passing test/build no longer reflects the current tree. " +
|
|
275
|
-
"Re-run the narrowest verification command against the latest changes (e.g. a standalone bash call such as 'bun test', 'bun run typecheck', 'tsc --noEmit', 'npm test', 'pytest', 'cargo test', or 'go test ./...'), then call done."
|
|
285
|
+
"Re-run the narrowest verification command against the latest changes (e.g. a standalone bash call such as 'bun test', 'bun run typecheck', 'tsc --noEmit', 'npm test', 'pytest', 'cargo test', or 'go test ./...'), then call done." +
|
|
286
|
+
unmeasuredNote,
|
|
276
287
|
};
|
|
277
288
|
}
|
|
278
289
|
return {
|
|
@@ -280,6 +291,7 @@ export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
|
|
|
280
291
|
block: true,
|
|
281
292
|
message:
|
|
282
293
|
"You modified files this turn but have no passing verification (no test/build/typecheck command succeeded, or the latest recognized verification failed). " +
|
|
283
|
-
"Run the narrowest command that proves your change works (e.g. a standalone bash call such as 'bun test', 'bun run typecheck', 'tsc --noEmit', 'npm test', 'pytest', 'cargo test', or 'go test ./...'), then call done."
|
|
294
|
+
"Run the narrowest command that proves your change works (e.g. a standalone bash call such as 'bun test', 'bun run typecheck', 'tsc --noEmit', 'npm test', 'pytest', 'cargo test', or 'go test ./...'), then call done." +
|
|
295
|
+
unmeasuredNote,
|
|
284
296
|
};
|
|
285
297
|
}
|