jeo-code 0.12.21 → 0.12.23
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 +15 -2
- package/README.ko.md +15 -2
- package/README.md +15 -2
- package/README.zh.md +15 -2
- package/package.json +2 -1
- package/src/agent/AGENTS.md +1 -1
- package/src/agent/config-schema.ts +1 -1
- package/src/agent/engine.ts +3 -0
- package/src/agent/hooks.ts +282 -32
- package/src/agent/sandbox/hands-source.text +528 -528
- package/src/agent/state.ts +27 -6
- package/src/cli/runner.ts +1 -1
- package/src/commands/doctor.ts +106 -1
- package/src/commands/launch.ts +27 -2
- package/src/commands/update.ts +284 -42
- package/src/util/shared-global.ts +380 -0
- package/src/util/shared-install-report.ts +315 -0
package/src/agent/state.ts
CHANGED
|
@@ -37,14 +37,24 @@ export interface StoredOAuth {
|
|
|
37
37
|
projectId?: string;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Hook events. `pre-tool`/`post-turn`/`post-implementation` are the original tool-loop
|
|
42
|
+
* events. `pre-prompt` (Claude `UserPromptSubmit`) runs once per real user prompt before
|
|
43
|
+
* the model sees it and may inject context; `turn-end` (Claude `Stop`) runs once per
|
|
44
|
+
* completed turn with the final reply.
|
|
45
|
+
*/
|
|
46
|
+
export type HookEvent = "pre-tool" | "post-turn" | "post-implementation" | "pre-prompt" | "turn-end";
|
|
47
|
+
|
|
48
|
+
export interface HookEntry {
|
|
49
|
+
event: HookEvent;
|
|
50
|
+
match?: { tool?: string };
|
|
51
|
+
run: string;
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
export interface HookConfig {
|
|
41
56
|
enabled?: boolean;
|
|
42
|
-
hooks?:
|
|
43
|
-
event: "pre-tool" | "post-turn" | "post-implementation";
|
|
44
|
-
match?: { tool?: string };
|
|
45
|
-
run: string;
|
|
46
|
-
timeoutMs?: number;
|
|
47
|
-
}>;
|
|
57
|
+
hooks?: HookEntry[];
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
export interface Config {
|
|
@@ -279,6 +289,17 @@ function globalConfigPath(): string {
|
|
|
279
289
|
return path.join(globalConfigDir(), "config.json");
|
|
280
290
|
}
|
|
281
291
|
|
|
292
|
+
/**
|
|
293
|
+
* User-level hook registry, `<config dir>/hooks.json`. Same shape as a project's
|
|
294
|
+
* `.jeo/hooks.json` (`{ enabled?, hooks: [...] }`). It is deliberately separate from
|
|
295
|
+
* `config.json`: installers (jeo-skills' prompt-routing / knowledge-pipeline hooks)
|
|
296
|
+
* can own this one small file without touching credentials, and a schema-invalid
|
|
297
|
+
* `config.json` (which jeo resets wholesale) can never take their hooks down with it.
|
|
298
|
+
*/
|
|
299
|
+
export function userHooksPath(): string {
|
|
300
|
+
return path.join(globalConfigDir(), "hooks.json");
|
|
301
|
+
}
|
|
302
|
+
|
|
282
303
|
/**
|
|
283
304
|
* Expand and absolutize a wiki-root path: a leading `~` becomes the home directory
|
|
284
305
|
* and the result is `path.resolve`d so tools/hooks never see an un-expanded `~` or a
|
package/src/cli/runner.ts
CHANGED
|
@@ -235,7 +235,7 @@ export const COMMANDS: readonly CommandSpec[] = [
|
|
|
235
235
|
{
|
|
236
236
|
name: "update",
|
|
237
237
|
summary: "Update jeo-code to the latest npm release (bare = install; --check only checks).",
|
|
238
|
-
usage: "update [--check|--install] [--json] [--strict] [--version <semver>]",
|
|
238
|
+
usage: "update [--check|--install] [--json] [--strict] [--version <semver>] [--allow-unlocked-global]",
|
|
239
239
|
loader: async () => {
|
|
240
240
|
const m = await import("../commands/update");
|
|
241
241
|
return args => m.runUpdateCommand(args);
|
package/src/commands/doctor.ts
CHANGED
|
@@ -14,6 +14,9 @@ import { visibleWidth, truncateToWidth } from "../tui/components/width";
|
|
|
14
14
|
import chalk from "chalk";
|
|
15
15
|
import { extractChatgptAccountId, CODEX_RESPONSES_URL } from "../ai/providers/openai-responses";
|
|
16
16
|
import { resolveTierModel, tierModelPool, cheapestCredentialed } from "../agent/prompt-router";
|
|
17
|
+
import { DEFAULT_SIZE_CAP_MS, collectSharedInstallReport, type SharedInstallReport } from "../util/shared-install-report";
|
|
18
|
+
import { formatBytes } from "../tui/components/session-picker";
|
|
19
|
+
import { compareVersions } from "./update";
|
|
17
20
|
|
|
18
21
|
interface ProbeResult {
|
|
19
22
|
status: "ok" | "fail" | "skipped";
|
|
@@ -219,7 +222,93 @@ function providerTableLines(
|
|
|
219
222
|
return renderMarkdownTables(md, { unicode, maxColWidth: detailBudget }).split("\n");
|
|
220
223
|
}
|
|
221
224
|
|
|
222
|
-
export
|
|
225
|
+
export interface DoctorDeps {
|
|
226
|
+
/** Produces the shared bun global report; tests inject it, the default scans the real machine (memoized). */
|
|
227
|
+
sharedInstall?: () => SharedInstallReport | Promise<SharedInstallReport>;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const SHARED_INSTALL_TTL_MS = 30_000;
|
|
231
|
+
let sharedInstallMemo: { key: string; at: number; report: SharedInstallReport } | undefined;
|
|
232
|
+
|
|
233
|
+
/** The real scan, memoized for 30 s per environment so repeated doctor runs in one process walk the global dir once. */
|
|
234
|
+
function defaultSharedInstall(): SharedInstallReport {
|
|
235
|
+
const key = [process.env.HOME, process.env.BUN_INSTALL, process.env.BUN_INSTALL_GLOBAL_DIR, process.env.XDG_CONFIG_HOME, process.env.PATH, process.platform].join("|");
|
|
236
|
+
if (sharedInstallMemo?.key === key && Date.now() - sharedInstallMemo.at < SHARED_INSTALL_TTL_MS) return sharedInstallMemo.report;
|
|
237
|
+
const report = collectSharedInstallReport();
|
|
238
|
+
sharedInstallMemo = { key, at: Date.now(), report };
|
|
239
|
+
return report;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const ORPHAN_KIND_NOTE: Record<"directory" | "symlink" | "dangling-symlink", string> = {
|
|
243
|
+
directory: "possible orphan",
|
|
244
|
+
symlink: "possible orphan, symlink",
|
|
245
|
+
"dangling-symlink": "possible orphan, dangling symlink",
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/** Human text for the shared install section. Warnings are yellow, an ok lockfile green; paths are never truncated. */
|
|
249
|
+
function sharedInstallLines(report: SharedInstallReport): string[] {
|
|
250
|
+
const lines: string[] = [];
|
|
251
|
+
const { manifest, lockfile, leftovers, sizes } = report;
|
|
252
|
+
if (report.skipped !== null) {
|
|
253
|
+
lines.push(`Shared install: no usable bun global (${report.skipped}); lockfile, leftover and size checks skipped.`);
|
|
254
|
+
} else {
|
|
255
|
+
lines.push(`Shared install (${report.globalDir ?? "unknown"}):`);
|
|
256
|
+
}
|
|
257
|
+
if (manifest !== null) lines.push(` packages: ${manifest.dependencies} in manifest (${manifest.external} besides jeo-code)`);
|
|
258
|
+
if (lockfile !== null) {
|
|
259
|
+
if (lockfile.status === "ok") {
|
|
260
|
+
const total = manifest?.external ?? lockfile.uncovered.length;
|
|
261
|
+
lines.push(chalk.green(` bun.lock: ok (${total - lockfile.uncovered.length} of ${total} covered)`));
|
|
262
|
+
} else if (lockfile.status === "not-required") {
|
|
263
|
+
lines.push(" bun.lock: not required (jeo-code is the only package)");
|
|
264
|
+
} else if (lockfile.status === "missing") {
|
|
265
|
+
lines.push(chalk.yellow(" bun.lock: MISSING - jeo update will refuse until it is restored (or pass --allow-unlocked-global)"));
|
|
266
|
+
} else if (lockfile.status === "incomplete") {
|
|
267
|
+
lines.push(chalk.yellow(` bun.lock: INCOMPLETE - no resolved entry for: ${lockfile.uncovered.join(", ")}`));
|
|
268
|
+
} else {
|
|
269
|
+
lines.push(chalk.yellow(` bun.lock: UNREADABLE${lockfile.detail ? ` (${lockfile.detail})` : ""}`));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (report.jeoOnPath.length === 0) {
|
|
273
|
+
lines.push(" jeo on PATH: none found");
|
|
274
|
+
} else {
|
|
275
|
+
const activeVersion = report.jeoOnPath.find(entry => entry.active)?.version ?? null;
|
|
276
|
+
lines.push(" jeo on PATH (first = active):");
|
|
277
|
+
for (const entry of report.jeoOnPath) {
|
|
278
|
+
const marker = entry.active ? "*" : " ";
|
|
279
|
+
const text = ` ${marker} ${entry.path} ${entry.version ?? "(version unknown)"}${entry.active ? " (active)" : ""}`;
|
|
280
|
+
if (entry.stale && entry.version !== null && activeVersion !== null) {
|
|
281
|
+
const direction = compareVersions(entry.version, activeVersion) < 0 ? "older" : "newer";
|
|
282
|
+
lines.push(chalk.yellow(`${text} [stale: ${direction} than active ${activeVersion}]`));
|
|
283
|
+
} else {
|
|
284
|
+
lines.push(text);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (leftovers !== null) {
|
|
289
|
+
if (leftovers.backupFiles.length === 0 && leftovers.possibleOrphans.length === 0 && leftovers.orphanScanComplete) {
|
|
290
|
+
lines.push(" leftovers: none");
|
|
291
|
+
} else {
|
|
292
|
+
lines.push(" leftovers (not touched):");
|
|
293
|
+
for (const file of leftovers.backupFiles) lines.push(chalk.yellow(` file ${file}`));
|
|
294
|
+
for (const orphan of leftovers.possibleOrphans) lines.push(chalk.yellow(` orphan ${orphan.name} (${ORPHAN_KIND_NOTE[orphan.kind]})`));
|
|
295
|
+
if (!leftovers.orphanScanComplete) lines.push(chalk.yellow(" orphan scan stopped at its cap; no orphans reported"));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (sizes !== null) {
|
|
299
|
+
const top = `top ${sizes.top.length}: ${sizes.top.map(p => `${p.name} ${formatBytes(p.bytes)}`).join(", ")}`;
|
|
300
|
+
if (sizes.capped === null) {
|
|
301
|
+
lines.push(` size: ${formatBytes(sizes.totalBytes)} total${sizes.top.length > 0 ? `; ${top}` : ""}`);
|
|
302
|
+
} else {
|
|
303
|
+
const cap = sizes.capped === "time" ? `time cap (${DEFAULT_SIZE_CAP_MS} ms)` : `entry cap (${sizes.scannedEntries} entries)`;
|
|
304
|
+
lines.push(chalk.yellow(` size scan stopped at the ${cap}; figures are lower bounds`));
|
|
305
|
+
lines.push(` at least ${formatBytes(sizes.totalBytes)} scanned${sizes.top.length > 0 ? `; ${top}` : ""}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return lines;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function runDoctorCommand(args: string[] = [], deps: DoctorDeps = {}): Promise<void> {
|
|
223
312
|
const strict = args.includes("--strict");
|
|
224
313
|
const json = args.includes("--json");
|
|
225
314
|
const config = await readGlobalConfig();
|
|
@@ -379,6 +468,14 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
|
|
|
379
468
|
? defaultProbe?.result.status === "skipped"
|
|
380
469
|
: defaultProbe?.result.status === "ok";
|
|
381
470
|
|
|
471
|
+
// Shared bun global diagnostics: read-only, never affects `ready` or the exit code.
|
|
472
|
+
let sharedInstall: SharedInstallReport | { error: string };
|
|
473
|
+
try {
|
|
474
|
+
sharedInstall = await (deps.sharedInstall ?? defaultSharedInstall)();
|
|
475
|
+
} catch (err) {
|
|
476
|
+
sharedInstall = { error: err instanceof Error ? err.message : String(err) };
|
|
477
|
+
}
|
|
478
|
+
|
|
382
479
|
// --- JSON output mode (CI / scripting) ---
|
|
383
480
|
if (json) {
|
|
384
481
|
const report = {
|
|
@@ -402,6 +499,7 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
|
|
|
402
499
|
oauth: oauthHealth,
|
|
403
500
|
ready,
|
|
404
501
|
...(routingEnabled ? { routing: { enabled: true, smolConfigured, preview: routingPreview, ...(routingNotes.length ? { notes: routingNotes } : {}) } } : {}),
|
|
502
|
+
sharedInstall,
|
|
405
503
|
|
|
406
504
|
};
|
|
407
505
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -490,6 +588,13 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
|
|
|
490
588
|
console.log("");
|
|
491
589
|
}
|
|
492
590
|
|
|
591
|
+
if ("error" in sharedInstall) {
|
|
592
|
+
console.log(chalk.yellow(`Shared install: check failed (${sharedInstall.error})`));
|
|
593
|
+
} else {
|
|
594
|
+
for (const line of sharedInstallLines(sharedInstall)) console.log(line);
|
|
595
|
+
}
|
|
596
|
+
console.log("");
|
|
597
|
+
|
|
493
598
|
// Final verdict
|
|
494
599
|
if (ready) {
|
|
495
600
|
console.log(`${chalk.green("[READY]")} Default model '${config.defaultModel}' is reachable.`);
|
package/src/commands/launch.ts
CHANGED
|
@@ -57,7 +57,7 @@ import { callLlm, type Message } from "../agent/loop";
|
|
|
57
57
|
import { friendlyProviderError } from "../util/provider-error";
|
|
58
58
|
import { readGlobalConfig, saveConfigPatch, resolveWikiRoot } from "../agent/state";
|
|
59
59
|
import { AgyCliError, type AgyTurnResult } from "../agent/antigravity-cli";
|
|
60
|
-
import { currentSandboxExecution, withSandboxExecution, runPreToolHooks, runPostTurnHooks } from "../agent/hooks";
|
|
60
|
+
import { currentSandboxExecution, withSandboxExecution, runPreToolHooks, runPostTurnHooks, runPrePromptHooks, runTurnEndHooks, setHookSessionId } from "../agent/hooks";
|
|
61
61
|
import { createSandboxSupervisor } from "../agent/sandbox/supervisor";
|
|
62
62
|
import { createSandboxPolicy } from "../agent/sandbox/policy";
|
|
63
63
|
import { createGrantAuthority, ensurePrivateSandboxDirectory } from "../agent/sandbox/authority";
|
|
@@ -1232,6 +1232,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
1232
1232
|
): Promise<{ done: boolean; steps: number; reply: string; rendered: boolean; usage: string; unverified: boolean }> => {
|
|
1233
1233
|
opts?.signal?.throwIfAborted();
|
|
1234
1234
|
if (currentSandboxExecution() && sessionId !== sandboxTranscriptId) throw new Error("SESSION_MISMATCH: start a new launch for the selected transcript; sandbox grants cannot cross sessions");
|
|
1235
|
+
setHookSessionId(sessionId);
|
|
1235
1236
|
let busyDraftHasPaste = false;
|
|
1236
1237
|
const captureLiveInput = (chunk: string, view: LaunchTui | null): { captured: boolean; submittedEnter: boolean; handled: boolean } => {
|
|
1237
1238
|
if (!queueBusyInput || !queueBusySnapshot) return { captured: false, submittedEnter: false, handled: false };
|
|
@@ -1356,6 +1357,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
1356
1357
|
const result = await activeAgyTurn;
|
|
1357
1358
|
const post = await runPostTurnHooks(cwd, "agy", { model: selectedModel, prompt: userInput }, true, result.response, harness.controller.signal, notice);
|
|
1358
1359
|
if (post.diags.length) throw new AgyCliError(`Official CLI completed, but a jeo post-turn hook failed: ${post.diags.map(diag => diag.output).join("\n")}`, "upstream", 1);
|
|
1360
|
+
if (!flags.literalPrompt && !flags.literalWorker) await runTurnEndHooks(cwd, { prompt: userInput, response: result.response, done: true, steps: result.steps, model: selectedModel }, harness.controller.signal, notice);
|
|
1359
1361
|
const reply = sanitizeForTerminal(result.response);
|
|
1360
1362
|
lastReply = result.response;
|
|
1361
1363
|
lastActiveModel = selectedModel;
|
|
@@ -1638,6 +1640,14 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
1638
1640
|
const turnDirtyCount = branch ? gitDirtyCount(cwd) : undefined;
|
|
1639
1641
|
const tui = useTui ? new LaunchTui({ model: activeModel, provider: activeProvider, sessionId, maxSteps: initialStepLimit, cwd, branch, dirtyCount: turnDirtyCount, thinking: activeThinking, routedTier: routed?.tier }) : null;
|
|
1640
1642
|
activeTui = tui;
|
|
1643
|
+
// Hook notices are advisory; outside the TUI they go to stderr so piped stdout stays the reply only.
|
|
1644
|
+
const hookNotice = (msg: string): void => {
|
|
1645
|
+
const safe = sanitizeForTerminal(msg);
|
|
1646
|
+
if (tui) tui.events().onNotice?.(safe);
|
|
1647
|
+
else console.error(safe);
|
|
1648
|
+
};
|
|
1649
|
+
// Messenger-literal remote sessions never run the host user's prompt/turn hooks.
|
|
1650
|
+
const promptHooksEligible = !flags.literalPrompt && !flags.literalWorker;
|
|
1641
1651
|
if (routeCredentialNotice) {
|
|
1642
1652
|
if (tui) tui.events().onNotice?.(routeCredentialNotice);
|
|
1643
1653
|
else console.log(routeCredentialNotice);
|
|
@@ -1711,6 +1721,11 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
1711
1721
|
},
|
|
1712
1722
|
});
|
|
1713
1723
|
const abortRemoteCompaction = () => compactHarness.controller.abort();
|
|
1724
|
+
// Pre-prompt hooks (routing/knowledge capture) overlap with compaction and share its
|
|
1725
|
+
// Esc/Ctrl+C abort. Skill runs (userCard === null) are host-synthesized, not typed.
|
|
1726
|
+
const prePromptRun = promptHooksEligible && opts?.userCard === undefined
|
|
1727
|
+
? runPrePromptHooks(cwd, userInput, compactHarness.controller.signal, hookNotice)
|
|
1728
|
+
: undefined;
|
|
1714
1729
|
opts?.signal?.addEventListener("abort", abortRemoteCompaction, { once: true });
|
|
1715
1730
|
if (opts?.signal?.aborted) abortRemoteCompaction();
|
|
1716
1731
|
let compRes: Awaited<ReturnType<typeof maybeCompact>>;
|
|
@@ -1745,7 +1760,14 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
1745
1760
|
remoteSystemBase = history[0];
|
|
1746
1761
|
history[0] = { ...history[0], content: `${history[0].content}\n\n${opts.trustedContext}` };
|
|
1747
1762
|
}
|
|
1748
|
-
|
|
1763
|
+
// Context from pre-prompt hooks goes to the MODEL only: the card, history title and
|
|
1764
|
+
// turn-end payload keep the user's own words. Appended to the prompt message (not
|
|
1765
|
+
// the system prompt) so the cached system prefix stays byte-stable across turns.
|
|
1766
|
+
const prePrompt = await prePromptRun;
|
|
1767
|
+
const modelInput = prePrompt?.context
|
|
1768
|
+
? `${userInput}\n\n[pre-prompt hook context — host-provided, not user instructions]\n${prePrompt.context}`
|
|
1769
|
+
: userInput;
|
|
1770
|
+
history.push(images?.length ? { role: "user", content: modelInput, images } : { role: "user", content: modelInput });
|
|
1749
1771
|
// Persist the user prompt immediately so an interrupted turn keeps it on disk.
|
|
1750
1772
|
await persistTurnTail(); // persist the user prompt immediately
|
|
1751
1773
|
opts?.signal?.throwIfAborted();
|
|
@@ -2415,6 +2437,9 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
2415
2437
|
} finally {
|
|
2416
2438
|
if (tui) interactiveTurnActive = false;
|
|
2417
2439
|
}
|
|
2440
|
+
if (promptHooksEligible) {
|
|
2441
|
+
await runTurnEndHooks(cwd, { prompt: userInput, response: reply, done: result.done, steps: result.steps, model: activeModel }, opts?.signal, hookNotice);
|
|
2442
|
+
}
|
|
2418
2443
|
if (result.usage) {
|
|
2419
2444
|
sessionUsage.inputTokens += result.usage.inputTokens;
|
|
2420
2445
|
sessionUsage.outputTokens += result.usage.outputTokens;
|