fix(hooks): run hooks in the session cwd; honest process-level config + best-effort session-start; surface systemMessage drop

Address review on the bridges:

- Hook cwd (blocking): the bridges never passed a workdir to runHook, so hooks
  ran in the executor default (the ACP server launch dir), not the session
  cwd — a hook doing `pwd`/relative reads/marker writes operated in the wrong
  tree. Both bridges now thread the agent's session `header.cwd` (the
  session/new.cwd) as the hook workdir for agent-scoped points. Regression per
  bridge: server cwd ≠ session cwd, a `pwd` hook proves it ran in the session
  workspace (proven red without the workdir).
- Example config honesty (blocking): `configPath: ./hooks.json` is read ONCE at
  load against the PROCESS cwd, not per-session — the comment/README now say so
  explicitly (a project-local per-session hooks.json is not discovered;
  TODO(per-session-hook-config)). The hooks-run-in-session-cwd fix above is the
  distinct, separately-documented half.
- Session-start timing (blocking): agent/session-start is a synchronous emit and
  the hook runs on a detached .then, so injected context is BEST-EFFORT — not
  guaranteed before the first request. Downgrade the contract in code comments +
  README + RFC (TODO(session-start-gating)) rather than implying "first request
  sees it", and add a no-wait regression that asserts the safe properties
  without pre-waiting for the inject.
- systemMessage (non-blocking): the merge collects merged.systemMessages but no
  bridge surfaced it. Warn per hook (like updatedInput) and document it as
  deferred in both READMEs + the RFC; tests assert the warn + non-surfacing.
This commit is contained in:
Tianyi Cui
2026-07-01 16:34:28 +08:00
parent f011699e43
commit 09c8e549b0
9 changed files with 189 additions and 16 deletions

View File

@@ -38,7 +38,12 @@ export const inject = ['bash']
/** Plugin config: where the Codex hooks.json lives + the model name for payloads. */
export interface Config {
/** Path to a Codex `hooks.json`. */
/**
* Path to a Codex `hooks.json`. PROCESS-LEVEL: read once at load, a relative
* path resolves against the process launch cwd.
* TODO(per-session-hook-config): per-session project-local discovery from each
* `session/new.cwd` is not yet implemented.
*/
configPath: string
/** The model name stamped on every payload (Codex includes `model` on each event). */
model?: string
@@ -90,6 +95,10 @@ export function apply(ctx: Context, config: Config): void {
): Promise<MergedHookOutcome> {
const groups: MatcherGroup[] = parsed[point] ?? []
const outputs: HookOutput[] = []
// Run the hook in the agent's session workspace (the `session/new` cwd), not
// the executor default (the server launch dir) — a hook reading a relative
// file or `pwd` must see the user's project tree. Absent for a no-agent run.
const workdir = opts.agent?.session.header.cwd
for (const group of groups) {
// Codex matches with PURE regex (no literal fast path).
if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue
@@ -104,6 +113,7 @@ export function apply(ctx: Context, config: Config): void {
}
const { output, durationMs } = await runHook(ctx.bash, hook, {
payload,
...workdir !== undefined ? { cwd: workdir } : {},
...opts.signal ? { signal: opts.signal } : {},
defaultTimeoutMs,
trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline.
@@ -126,6 +136,9 @@ export function apply(ctx: Context, config: Config): void {
output.additionalContext = output.stdout
}
outputs.push(output)
if (output.systemMessage !== undefined) {
ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
}
if (session && opts.turn !== undefined) {
const stderrSummary = summarize(output.stderr)
appendHookResult(session, {
@@ -154,6 +167,10 @@ export function apply(ctx: Context, config: Config): void {
}
// SessionStart: emit. Codex passes a plain-stdout hook's output as additionalContext.
// TODO(session-start-gating): a synchronous emit + detached `.then`, so the
// injected context is BEST-EFFORT — not guaranteed before the first turn reaches
// the model (a slow hook can miss the first request). Gating is a deferred
// loop-level change; the contract is "injected as soon as the hook resolves".
ctx.on('agent/session-start', (agent, source) => {
void runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true })
.then((merged) => {