Просмотр исходного кода

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.
Tianyi Cui 2 месяцев назад
Родитель
Сommit
09c8e549b0

+ 6 - 1
docs/rfc/implemented/feature/2026-06-30-hook-bridges.md

@@ -39,13 +39,18 @@ Each bridge maps the neutral `MergedHookOutcome` from the shared lib onto the se
 
 The config is parsed ONCE at load; a read/parse failure logs and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run — a `prompt`/`agent`/HTTP hook (CC) or an `async: true` / non-command hook (Codex) is parsed-and-skipped with a warning. The emit-listener paths (`session-start`, `subagent/start`) run detached, with their `inject` contained in a `.catch` that logs (a throwing inject must not break session boot or the loop).
 
+### Where hooks run, and where their config comes from
+
+Two different cwds, kept distinct on purpose. The hooks **themselves** run in the agent's **session workspace**: for the agent-scoped points the bridge threads the session's `cwd` (`session/new.cwd`, on the session header) to `runHook` as the process working directory, so a hook's `pwd` / relative-file read / marker write operates in the user's project tree, not the server's launch directory. The **config path**, by contrast, is **process-level**: `configPath` is resolved and parsed once at load against the process launch cwd, so a single `hooks.json` applies to the whole process — there is no per-session config discovery that reads a project-local `hooks.json` from each `session/new.cwd` (`TODO(per-session-hook-config)`). This is an honest limitation of the current cut: the example `cordis.yml` documents that its `./hooks.json` is process-level, not per-project.
+
 ## Deferred (faithful-but-degraded)
 
 - **Tool-input rewrite.** A CC/Codex `updatedInput` is logged + warned, not honored — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)), because the pre-execution args are read by `tool/call` audit + `assistant/message` history + ACP/tool-bash presentation, so an honest rewrite is a design unit, not a field.
 - **Stop loop-guard** (`TODO(stop-loop-guard)`). CC/Codex break an infinite force-continue with `stop_hook_active` (true once a Stop hook fired this run) plus a max-consecutive cap; both are deferred. Today `stop_hook_active` is always `false`, so a Stop hook that unconditionally blocks would force-continue every step — a hook author must self-limit until the guard lands.
 - **Permission `ask`** degrades to `deny` at the `tools/pre-execute` seam (`FIXME(permissions)` in the interception-seams RFC) — there is no interactive permission prompt yet.
 - **Hook `continue:false` (hard halt).** A hook can ask to halt the whole run (CC/Codex `continue:false`); the shared merge folds it into `MergedHookOutcome.stop`/`stopReason`, but no bridge acts on it (`TODO(hook-continue-false)`) — the interception seams have no "hard-halt the agent" primitive yet (a Decision blocks/steers a single point, not the run). Deferred with the loop-guard work; the halt request is recorded in the `hook/result` log, and the hook keeps its per-point effect (decision/context) meanwhile.
-- **Config discovery.** The path is explicit in `cordis.yml`; the full multi-layer CC/Codex precedence walk and the trust/hash model are not reimplemented (`TODO`).
+- **Config discovery.** The path is explicit in `cordis.yml` and process-level (see above); the full multi-layer CC/Codex precedence walk, per-session project-local discovery, and the trust/hash model are not reimplemented (`TODO(per-session-hook-config)`).
+- **Session-start / subagent-start context is best-effort, not gated (`TODO(session-start-gating)`).** `agent/session-start` is a synchronous emit and the bridge runs its hook on a detached `.then`, so the injected `additionalContext` is not guaranteed to land before the first turn reaches the model — a slow hook can miss the first request (the context then arrives as a later injection). `subagent/start` is sharper: an in-process provider may have already queued the child's prompt before the listener runs, and a short-lived child can finish before the detached inject fires. Making startup context a gated/awaited primitive is a loop-level change deferred to the interception seams; today the contract is "injected as soon as the hook resolves", not "before the first request". The bridge tests do NOT wait on the injection where they assert the guaranteed-timing behavior, so they document the real (best-effort) timing rather than masking it.
 
 ### Multiple hooks on one point run serially, not concurrently
 

+ 8 - 5
examples/acp-agent/cordis.snapshot.yml

@@ -86,11 +86,14 @@
 - id: tool-todo
   name: '@deepseek-ai/dsh-tool-todo'
 
-# The Claude Code hook bridge, pointed at a `hooks.json` in the session cwd. A
-# scenario that ships `workspace/hooks.json` (copied into the cwd before the run)
-# exercises the hooks path end-to-end; every other scenario has no such file, so
-# the bridge's parse fails-soft and it registers nothing (a silent no-op — the
-# ACP app loads no logger exporter, so the warning never reaches stdout).
+# The Claude Code hook bridge. `configPath` is read ONCE at load and resolves
+# `./hooks.json` against the PROCESS cwd (not per-session) — in these snapshot
+# runs the harness launches the subprocess with process cwd = the scenario's temp
+# workspace, so a scenario that ships `workspace/hooks.json` (copied into that cwd
+# before the run) exercises the hooks path end-to-end; every other scenario has no
+# such file, so the parse fails-soft and the bridge registers nothing (a silent
+# no-op — the ACP app loads no logger exporter, so the warning never reaches
+# stdout). Hooks themselves run in the session cwd (the bridge passes it as workdir).
 - id: hooks-claude
   name: '@deepseek-ai/dsh-hooks-claude'
   config:

+ 10 - 5
examples/acp-agent/cordis.yml

@@ -96,11 +96,16 @@
 - id: tool-todo
   name: '@deepseek-ai/dsh-tool-todo'
 
-# The Claude Code hook bridge, pointed at a `hooks.json` in the session cwd. With
-# no such file present the parse fails-soft and the bridge registers nothing (a
-# silent no-op); a session whose cwd holds a `hooks.json` runs those hooks on the
-# interception seams. stdout is the ACP JSON-RPC channel — the bridge's warnings
-# go through ctx.logger (no exporter here), never to stdout.
+# The Claude Code hook bridge. `configPath` is PROCESS-LEVEL: it is read ONCE at
+# load and the relative `./hooks.json` resolves against the ACP server's launch
+# cwd, NOT each `session/new.cwd`. So a single `hooks.json` next to where the
+# server starts applies to every session; a project-local, per-session hooks.json
+# is NOT discovered (per-session config resolution is a TODO — see the bridge
+# README). With no file present the parse fails-soft and the bridge registers
+# nothing (a silent no-op). Hooks THEMSELVES run in the session cwd (the bridge
+# passes it as the workdir); only WHERE the config is read from is process-level.
+# stdout is the ACP JSON-RPC channel — the bridge's warnings go through ctx.logger
+# (no exporter here), never to stdout.
 - id: hooks-claude
   name: '@deepseek-ai/dsh-hooks-claude'
   config:

+ 4 - 1
packages/hooks/hooks-claude/README.md

@@ -25,7 +25,9 @@ In a `cordis.yml`:
     projectDir: .
 ```
 
-The config is parsed **once** at load. A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning.
+The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning.
+
+The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir.
 
 ## Hook points → seam Decisions
 
@@ -48,4 +50,5 @@ Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }
 ## Deferred (faithful-but-degraded)
 
 - **`updatedInput` (tool-input rewrite)** is logged + warned, **not honored** — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)).
+- **`systemMessage`** (a hook's user-facing warning) is logged + warned, **not surfaced** — there is no user-message channel on these seams yet (only model-facing `additionalContext`). The shared merge collects it; the bridge does not yet render it.
 - **Stop loop-guard.** CC breaks an infinite force-continue with `stop_hook_active` (true once a Stop hook has fired this run) plus a max-consecutive cap; both are deferred (`TODO(stop-loop-guard)`). Today `stop_hook_active` is always `false`, so a Stop hook that unconditionally blocks would force-continue every step — a hook author must self-limit until the guard lands.

+ 25 - 2
packages/hooks/hooks-claude/src/index.ts

@@ -51,7 +51,13 @@ export const inject = ['bash']
 
 /** Plugin config: where the CC hook config lives + substitution roots. */
 export interface Config {
-  /** Path to a `hooks.json` or a settings file whose `hooks` key holds the config. */
+  /**
+   * Path to a `hooks.json` or a settings file whose `hooks` key holds the config.
+   * PROCESS-LEVEL: read once at load, a relative path resolves against the process
+   * launch cwd, so one config applies to the whole process.
+   * TODO(per-session-hook-config): per-session discovery of a project-local
+   * `hooks.json` from each `session/new.cwd` is not yet implemented.
+   */
   configPath: string
   /** Replaces `${CLAUDE_PLUGIN_ROOT}` in command strings (the plugin's root dir). */
   pluginRoot?: string
@@ -124,6 +130,12 @@ 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 on the
+    // session header), not the executor default (the ACP server's launch dir).
+    // A hook that does `pwd`, reads a relative file, or writes a marker must
+    // operate in the user's project tree. Absent for a no-agent run (falls back
+    // to the executor default).
+    const workdir = opts.agent?.session.header.cwd
     for (const group of groups) {
       if (!matchesMatcher(group.matcher, matchQuery, 'claude')) continue
       for (const hook of group.hooks) {
@@ -138,6 +150,7 @@ export function apply(ctx: Context, config: Config): void {
         const { output, durationMs } = await runHook(ctx.bash, hook, {
           payload,
           ...hookEnv ? { env: hookEnv } : {},
+          ...workdir !== undefined ? { cwd: workdir } : {},
           ...opts.signal ? { signal: opts.signal } : {},
           defaultTimeoutMs,
           trailingNewline: true,
@@ -149,6 +162,9 @@ export function apply(ctx: Context, config: Config): void {
         if (output.updatedInput !== undefined) {
           ctx.logger.warn(`hooks-claude: ${point} hook requested updatedInput, which is not yet honored (ignored)`)
         }
+        if (output.systemMessage !== undefined) {
+          ctx.logger.warn(`hooks-claude: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
+        }
         if (session && opts.turn !== undefined) {
           const stderrSummary = summarize(output.stderr)
           appendHookResult(session, {
@@ -180,7 +196,14 @@ export function apply(ctx: Context, config: Config): void {
   }
 
   // --- SessionStart: emit (cannot block). Inject any additionalContext into the
-  // agent so the first request sees it. The matcher subject is the source. ---
+  // agent. The matcher subject is the source.
+  // TODO(session-start-gating): `agent/session-start` is a SYNCHRONOUS emit and
+  // this hook runs on a detached `.then`, so the injected context is BEST-EFFORT
+  // — it is not guaranteed to land before the first turn reaches the model. A
+  // slow hook can miss the first request (the context then arrives as a later
+  // injection turn). Gating startup on the hook is a loop-level change deferred
+  // to the interception seams; today the contract is "injected as soon as the
+  // hook resolves", not "before the first request". ---
   ctx.on('agent/session-start', (agent, source) => {
     void runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent })
       .then((merged) => {

+ 76 - 0
packages/hooks/hooks-claude/tests/coverage.spec.ts

@@ -454,3 +454,79 @@ describe('hooks-claude coverage — detached-listener catch handlers', () => {
     expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject
   })
 })
+
+describe('hooks-claude coverage — hook runs in the session cwd, not the server cwd', () => {
+  it('runs an agent-scoped hook in the session workspace even when the executor default differs', async () => {
+    // The bug: the bridge passed no workdir, so hooks ran in the executor default
+    // (the server launch dir), not session/new.cwd. Here the executor default and
+    // the session cwd are DIFFERENT temp dirs; a PreToolUse hook writes `pwd` to a
+    // marker and we assert it ran in the SESSION cwd.
+    const serverDir = dir()
+    const sessionDir = dir()
+    const marker = join(sessionDir, 'where')
+    // The hook is invoked with cwd = session dir, so a relative marker path lands there.
+    hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] })
+    const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
+    const ctx = new Context()
+    await ctx.plugin(LlmService)
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(AgentLoop, { agents: [] })
+    // Executor default cwd = serverDir (deliberately NOT the session cwd).
+    await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
+    await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
+    ctx.llm.registerAdapter(['mock'], adapter)
+    ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
+
+    const { SessionId } = await import('@deepseek-ai/dsh-session')
+    const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
+    handle.agent.send([{ type: 'text', text: 'go' }])
+    await waitForIdle(ctx, handle.agent as ReactLoopAgent)
+
+    expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir
+    const { readFileSync } = await import('node:fs')
+    const where = readFileSync(marker, 'utf8').trim()
+    // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames.
+    expect(where.endsWith(sessionDir.split('/').pop()!)).toBe(true)
+    await handle.dispose()
+  })
+})
+
+describe('hooks-claude coverage — systemMessage is warned, not surfaced', () => {
+  it('a hook emitting a systemMessage is logged as not-yet-surfaced', async () => {
+    const d = dir()
+    const s = sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n')
+    const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
+    const adapter = new MockAdapter([textResponse('ok')])
+    const ctx = await harness(path, adapter)
+    const warn = vi.fn(); ctx.logger.warn = warn as never
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+    agent.send([{ type: 'text', text: 'go' }])
+    await waitForIdle(ctx, agent)
+    expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
+    // Not surfaced: the systemMessage text never reaches the model request.
+    expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up')
+  })
+})
+
+describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait)', () => {
+  it('does NOT crash or block when the prompt is sent immediately (context is best-effort, may miss the first request)', async () => {
+    // Regression for the documented downgrade: session-start injection is
+    // detached, so a prompt sent immediately need not observe it. This asserts
+    // the SAFE properties (no crash, the turn still runs) WITHOUT waiting for the
+    // inject first — it documents the best-effort timing rather than masking it
+    // by pre-waiting for context/message (which the guaranteed-timing tests do).
+    const d = dir()
+    const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"late ctx"}}\'\n')
+    const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
+    const adapter = new MockAdapter([textResponse('ok')])
+    const ctx = await harness(path, adapter)
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+    // Send immediately — do NOT wait for the session-start inject.
+    agent.send([{ type: 'text', text: 'go' }])
+    await waitForIdle(ctx, agent)
+    expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing
+  })
+})

+ 5 - 1
packages/hooks/hooks-codex/README.md

@@ -31,7 +31,9 @@ In a `cordis.yml`:
     model: deepseek-v4
 ```
 
-The config is parsed **once** at load; a read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias. Events outside the five Codex points are dropped at parse.
+The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias. Events outside the five Codex points are dropped at parse.
+
+The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir.
 
 ## Hook points → seam Decisions
 
@@ -52,3 +54,5 @@ Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }`
 ## Deferred
 
 **Stop loop-guard** (`TODO(stop-loop-guard)`): as in CC, a Stop hook that unconditionally blocks would force-continue every step (`stop_hook_active` is always `false` here); the loop-guard is deferred. A hook author must self-limit until it lands.
+
+**`systemMessage`**: a hook's user-facing warning is logged + warned, not surfaced — there is no user-message channel on these seams yet (only model-facing `additionalContext`).

+ 18 - 1
packages/hooks/hooks-codex/src/index.ts

@@ -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) => {

+ 37 - 0
packages/hooks/hooks-codex/tests/coverage.spec.ts

@@ -436,4 +436,41 @@ describe('hooks-codex coverage — decision mapping paths', () => {
     expect(ran).toBe(false) // the matcher fired → the hook denied the tool
     expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true)
   })
+
+  it('a hook emitting a systemMessage is warned as not-yet-surfaced', async () => {
+    const d = dir()
+    hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n') }] }] })
+    const adapter = new MockAdapter([textResponse('ok')])
+    const ctx = await harness(join(d, 'hooks.json'), adapter)
+    const warn = vi.fn(); ctx.logger.warn = warn as never
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+    agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+    expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
+    expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up')
+  })
+
+  it('runs an agent-scoped hook in the session cwd, not the executor default', async () => {
+    // Same regression as the CC bridge: the Codex bridge must thread the session
+    // cwd as the hook workdir. Executor default = serverDir; session cwd =
+    // sessionDir; the PreToolUse hook's `pwd` marker must land in sessionDir.
+    const serverDir = dir()
+    const sessionDir = dir()
+    const marker = join(sessionDir, 'where')
+    hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] })
+    const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
+    const ctx = new Context()
+    await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] })
+    await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
+    await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' })
+    ctx.llm.registerAdapter(['mock'], adapter)
+    ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
+    const { SessionId } = await import('@deepseek-ai/dsh-session')
+    const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
+    handle.agent.send([{ type: 'text', text: 'go' }])
+    await waitForIdle(ctx, handle.agent as ReactLoopAgent)
+    expect(existsSync(marker)).toBe(true)
+    expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true)
+    await handle.dispose()
+  })
 })