Skip to content

Commit cad8441

Browse files
committed
feat(hooks): fire remaining lifecycle events; harden session save + child env
Wires the four declared-but-not-firing hook events through HookManager so user-defined hooks see the full lifecycle: UserPromptSubmit via bundle.submitUserPrompt, called from App.tsx / headless run / plan-mode approve / /redo. Exit-code-2 veto bubbles back as { submitted: false, reason }. SessionStart once per createAgent — lets hooks pre-seed context. Stop fires on agent_end with the final assistant text — "ping my phone when this finishes" style hooks. PostEdit fires from afterToolCall for write-family tools so formatters / linters / commit-on-save can target edits specifically. Also fixes two audit-flagged silent-failure / mutation bugs: - Session save errors now surface to stderr instead of vanishing, so a full disk no longer loses work without warning. Adds a workDir- exists check on load so a deleted/moved project refuses to resume instead of re-anchoring the session to a bogus path. - child_process spawns in hooks/runner.ts + tools/shell.ts now clone process.env instead of passing the reference, so a tool / hook that does `export FOO=bar` can't leak into every subsequent spawn.
1 parent 5ecf848 commit cad8441

9 files changed

Lines changed: 143 additions & 12 deletions

File tree

src/agent/agent.ts

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@ export interface AgentBundle {
9898
hooks: HookManager;
9999
diagnostics: DiagnosticsEngine;
100100
subscribe: (listener: (event: AgentEvent) => void) => () => void;
101+
/**
102+
* User-initiated prompt — fires UserPromptSubmit hooks; honors exit-code-2
103+
* veto. Callers that originate prompts from real user input should use
104+
* this instead of `agent.prompt()` directly.
105+
*/
106+
submitUserPrompt: (text: string) => Promise<{ submitted: boolean; reason?: string }>;
101107
/**
102108
* Set when `--resume` actually loaded a prior session, with its
103109
* timestamp + message count so the welcome banner can say
@@ -279,6 +285,18 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
279285
signal,
280286
);
281287

288+
// PostEdit fires for write-family tools so hooks can run formatters
289+
// / linters / commit-on-save scripts targeted specifically at file
290+
// mutations (instead of having to filter inside a generic
291+
// PostToolUse handler).
292+
if (filePath && WRITE_TOOL_NAMES.has(ctx.toolCall.name)) {
293+
await hooks.dispatch(
294+
"PostEdit",
295+
{ event: "PostEdit", toolName: ctx.toolCall.name, toolArgs: ctx.args, filePath, workingDir: cwd },
296+
signal,
297+
);
298+
}
299+
282300
// After a write/edit tool, run language checkers on the affected file
283301
// and steer the result into the next turn. Fire-and-forget so the
284302
// tool result return isn't blocked by a 15s checker run.
@@ -340,11 +358,52 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
340358
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
341359
},
342360
});
343-
} catch {
344-
// Persistence is best-effort — don't crash the agent over a write failure.
361+
} catch (err) {
362+
// Persistence is best-effort — never crash the agent over a write
363+
// failure. But silent failure used to mean the user lost work to
364+
// a full disk with no warning, so surface to stderr so support is
365+
// possible. Visible to anyone watching the terminal; the agent
366+
// keeps running.
367+
const msg = err instanceof Error ? err.message : String(err);
368+
process.stderr.write(`[session] save failed (${sessions.filePath}): ${msg}\n`);
345369
}
370+
371+
// Stop fires once the agent settles after a turn — useful for "ping
372+
// my phone when the long task finishes" style hooks. Fire-and-forget
373+
// so a misconfigured hook can't gate the agent_end notification.
374+
const finalMessage = lastAssistantText(event.messages);
375+
void hooks.dispatch("Stop", { event: "Stop", workingDir: cwd, finalMessage }).catch(() => undefined);
346376
});
347377

378+
// SessionStart fires once per createAgent. Lets hooks pre-seed context
379+
// (e.g. "add project status to memory") before any user prompt lands.
380+
// Fire-and-forget because nothing else is waiting on it.
381+
void hooks.dispatch("SessionStart", { event: "SessionStart", workingDir: cwd }).catch(() => undefined);
382+
383+
/**
384+
* Submit a user-initiated prompt — fires UserPromptSubmit through the
385+
* hook chain first so audit / lint hooks can veto with exit code 2.
386+
* Subagent prompts (dispatch-agent) skip this and call agent.prompt
387+
* directly because they aren't user-initiated.
388+
*
389+
* Returns `{ submitted: false, reason }` when a sync hook blocked the
390+
* submit, otherwise resolves to `{ submitted: true }` after the agent
391+
* accepted the prompt. The agent's own turn lifecycle still emits
392+
* events on bundle.subscribe.
393+
*/
394+
const submitUserPrompt = async (text: string): Promise<{ submitted: boolean; reason?: string }> => {
395+
const outcome = await hooks.dispatch("UserPromptSubmit", {
396+
event: "UserPromptSubmit",
397+
workingDir: cwd,
398+
userPrompt: text,
399+
});
400+
if (outcome.blocked) {
401+
return { submitted: false, reason: outcome.reason };
402+
}
403+
void agent.prompt(text).catch(() => undefined);
404+
return { submitted: true };
405+
};
406+
348407
void agentRef;
349408
return {
350409
agent,
@@ -362,8 +421,27 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
362421
hooks,
363422
diagnostics,
364423
subscribe,
424+
submitUserPrompt,
365425
resumedFrom: resumed ? { updatedAt: resumed.updatedAt, messageCount: resumed.messages.length } : undefined,
366426
resumedMessages: opts.initialMessages ?? resumed?.messages ?? [],
367427
backgroundShells: toolContext.backgroundShells,
368428
};
369429
}
430+
431+
/** Extract the trailing assistant text content from an array of messages. */
432+
function lastAssistantText(messages: AgentMessage[]): string | undefined {
433+
for (let i = messages.length - 1; i >= 0; i--) {
434+
const m = messages[i];
435+
if (m.role !== "assistant") continue;
436+
if (typeof m.content === "string") return m.content || undefined;
437+
if (Array.isArray(m.content)) {
438+
const text = m.content
439+
.filter((b): b is { type: "text"; text: string } => (b as { type: string }).type === "text")
440+
.map((b) => b.text)
441+
.join("");
442+
return text || undefined;
443+
}
444+
return undefined;
445+
}
446+
return undefined;
447+
}

src/commands/builtins.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -686,7 +686,12 @@ const redo: Command = {
686686
return { handled: true };
687687
}
688688
ctx.emit(`(redo) ${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`);
689-
void ctx.bundle.agent.prompt(text).catch(() => undefined);
689+
// Route through the bundle helper so UserPromptSubmit hooks fire on
690+
// /redo too — a hook that rejects secrets shouldn't be bypassed just
691+
// because the same prompt is being re-issued.
692+
void ctx.bundle.submitUserPrompt(text).then((result) => {
693+
if (!result.submitted && result.reason) ctx.emit(`Prompt blocked by hook: ${result.reason}`);
694+
});
690695
return { handled: true };
691696
},
692697
};

src/headless/run.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,15 @@ export async function runHeadless(opts: HeadlessOptions): Promise<number> {
9797
}
9898

9999
try {
100-
await bundle.agent.prompt(opts.prompt);
100+
// Route through the bundle helper so UserPromptSubmit hooks fire on
101+
// headless runs too (CI scripts, scheduled jobs). A hook veto exits
102+
// with code 1 and the reason printed to stderr.
103+
const submitResult = await bundle.submitUserPrompt(opts.prompt);
104+
if (!submitResult.submitted) {
105+
errored = true;
106+
errorMessage = submitResult.reason ?? "Prompt blocked by hook.";
107+
err(`prompt blocked: ${errorMessage}\n`);
108+
}
101109
} catch (e) {
102110
errored = true;
103111
errorMessage = e instanceof Error ? e.message : String(e);

src/hooks/runner.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ export function runHook(config: HookConfig, context: HookEventContext, signal?:
2424
child = spawn(config.command, {
2525
shell: true,
2626
cwd: context.workingDir,
27-
env: process.env,
27+
// Clone instead of passing process.env directly — a hook that
28+
// does `export FOO=bar` would otherwise mutate the agent's
29+
// own environment for every subsequent spawn.
30+
env: { ...process.env },
2831
stdio: ["pipe", "pipe", "pipe"],
2932
});
3033
} catch (err) {

src/hooks/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ export interface HookEventContext {
5454
subagentPrompt?: string;
5555
/** SubagentStop only — was the subagent run successful? */
5656
subagentSuccess?: boolean;
57+
58+
// UserPromptSubmit-specific:
59+
/** UserPromptSubmit — the raw text the user submitted. Hooks can reject with exit 2. */
60+
userPrompt?: string;
61+
62+
// Stop-specific:
63+
/** Stop — the agent's final message text (last assistant message), if any. */
64+
finalMessage?: string;
5765
}
5866

5967
export interface HookResult {

src/plan/run-flow.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,15 @@ export async function runPlanFlow(
6262
if (choice === "Yes — run it") {
6363
const finalPrompt = buildAgentPrompt(originalPrompt, plan, qaHistory);
6464
const withEnv = envReminderForFirstTurn ? `${envReminderForFirstTurn}\n\n${finalPrompt}` : finalPrompt;
65-
bundle.agent.prompt(withEnv).catch((err: unknown) => {
66-
onError(err instanceof Error ? err.message : String(err));
67-
});
65+
// Plan-mode "approve" is a user decision, so the final prompt
66+
// goes through the UserPromptSubmit hook surface like any
67+
// other user-initiated submit.
68+
bundle
69+
.submitUserPrompt(withEnv)
70+
.then((result) => {
71+
if (!result.submitted && result.reason) onError(`Hook blocked plan: ${result.reason}`);
72+
})
73+
.catch((err: unknown) => onError(err instanceof Error ? err.message : String(err)));
6874
return;
6975
}
7076
if (choice === "Cancel") {

src/sessions/store.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ export class SessionStore {
7171

7272
if (parsed.formatVersion !== SESSION_FORMAT_VERSION) return null;
7373
if (parsed.workDir !== this.cwd) return null;
74+
// If the project directory the session belongs to has been deleted /
75+
// renamed since the save, refuse to resume rather than re-anchoring
76+
// the session to a now-invalid path. The file stays on disk so a
77+
// future fix can migrate it intentionally.
78+
try {
79+
if (!statSync(parsed.workDir).isDirectory()) return null;
80+
} catch {
81+
return null;
82+
}
7483
if (parsed.modelId !== modelId) return null;
7584
if (Date.now() - parsed.updatedAt > this.maxAgeMs) {
7685
this.clear();

src/tools/shell.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,10 @@ export function createShell(ctx: ToolContext): AgentTool<typeof Params, ShellDet
111111
const child: ChildProcess = spawn(params.command, {
112112
shell: true,
113113
cwd,
114-
env: process.env,
114+
// Clone instead of passing process.env directly — a shell
115+
// command that does `export FOO=bar` would otherwise leak
116+
// into the agent's own environment for every subsequent spawn.
117+
env: { ...process.env },
115118
stdio: ["ignore", "pipe", "pipe"],
116119
detached: process.platform !== "win32",
117120
});

src/ui/App.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -311,9 +311,20 @@ function ChatApp({ initialBundle, onExit }: ChatAppProps) {
311311
const promptText = shouldInjectEnv(state.messages)
312312
? `${buildEnvironmentReminder(bundle.toolContext.cwd)}\n\n${augmentedText}`
313313
: augmentedText;
314-
bundle.agent.prompt(promptText).catch((err: unknown) => {
315-
dispatch({ type: "error", message: err instanceof Error ? err.message : String(err) });
316-
});
314+
// Use the bundle's user-prompt helper so a UserPromptSubmit hook
315+
// can veto the submit (e.g. project policy blocking secrets in
316+
// prompts). A blocked submit surfaces the hook's stderr as a
317+
// status line; we never silently swallow it.
318+
bundle
319+
.submitUserPrompt(promptText)
320+
.then((result) => {
321+
if (!result.submitted && result.reason) {
322+
appendStatus(`Prompt blocked by hook: ${result.reason}`);
323+
}
324+
})
325+
.catch((err: unknown) => {
326+
dispatch({ type: "error", message: err instanceof Error ? err.message : String(err) });
327+
});
317328
};
318329

319330
handleSubmitRef.current = handleSubmit;

0 commit comments

Comments
 (0)