jeo-code 0.12.22 → 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.
@@ -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?: Array<{
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
@@ -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
- history.push(images?.length ? { role: "user", content: userInput, images } : { role: "user", content: userInput });
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;