@hydraharness/harness-hooks-codex 0.1.1-rc.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeepSeek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # @hydraharness/harness-hooks-codex
2
+
3
+ A cordis plugin that runs the supported subset of a user's existing **Codex** hook config on the harness's canonical interception points. The **Codex dialect** half of the hooks subsystem. The dialect-agnostic primitives come from [`@hydraharness/harness-hook-protocol`](../hook-protocol/README.md); this bridge owns the Codex-shaped payloads, matcher mode, and decision mapping.
4
+
5
+ This bridge implements a deliberate subset of Codex's current hook protocol:
6
+
7
+ - **Seven hook points with Hydra seams:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop`, `SubagentStart`, and `SubagentStop`.
8
+ - **Regex-only matchers** (no literal fast path; the matcher is always an unanchored regex).
9
+ - **snake_case stdin payloads** with `turn_id`/`model` extras, written **without** a trailing newline.
10
+ - **No config-time placeholder substitution** (the command still receives the executor's environment and runs through its shell; a host may add trusted plugin variables).
11
+ - **No pre-tool approval or rewrite path** — a hook can block, but the bridge does not pre-approve or replace tool input.
12
+
13
+ A native cordis plugin could do everything this bridge does, more powerfully; the bridge exists only as a compatibility path for the mapped Codex subset (see [the interception extension-points Agent Note](../../../.agents/notes/implemented/feature/2026-06-30-interception-extension-points.md)).
14
+
15
+ ## Config
16
+
17
+ ```ts
18
+ import type { Config } from '@hydraharness/harness-hooks-codex'
19
+ const config: Config = {
20
+ configPath: '/path/to/.codex/hooks.json', // required
21
+ model: 'deepseek-v4', // optional: stamped on every payload (Codex includes `model`)
22
+ defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none
23
+ stderrSummaryMaxChars: 500, // optional: char cap on the hook/result event's persisted stderr summary
24
+ }
25
+ ```
26
+
27
+ In a `cordis.yml`:
28
+
29
+ ```yaml
30
+ - hydra-hooks-codex:
31
+ configPath: ./.codex/hooks.json
32
+ model: deepseek-v4
33
+ ```
34
+
35
+ 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); an invalid regex matcher on an event that consumes matchers is one such failure and reports its pattern and event. 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; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `@hydraharness/harness-hook-protocol`, 10 minutes). Events outside the seven bridge-supported points are dropped at parse.
36
+
37
+ 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.
38
+
39
+ ## Hook points → typed Decisions
40
+
41
+ | Codex hook | Harness point | Mapping |
42
+ |---|---|---|
43
+ | `SessionStart` | `agent/session-start` (emit) | a plain-stdout hook's output → additionalContext → `agent.inject()` |
44
+ | `UserPromptSubmit` | `agent/pre-step` (waterfall) | `block` (exit 2) → `PreStepDecision.reject`; additionalContext-only → delegate via `next()` then append a separately sourced message to a downstream `enter` decision |
45
+ | `PreToolUse` | `tools/pre-execute` (waterfall) | `block` → `PreToolDecision.deny` (no `allow`/`ask`) |
46
+ | `PostToolUse` | `tools/post-execute` (waterfall) | `block` → `block` with feedback; additionalContext-only → delegate via `next()` then prepend a separately sourced context to the downstream decision; Code Mode defers sub-call contexts until the outer `run_code` result |
47
+ | `Stop` | `agent/turn-stopping` (serial) | a blocking Stop hook feeds its reason through `steer()`, forcing another step |
48
+ | `SubagentStart` | `subagent/start` (detached) | additionalContext → `agent.inject()` when the local child is available |
49
+ | `SubagentStop` | `subagent/end` (detached) | observe-only |
50
+
51
+ A tool call's payload carries the real `tool_name` (the same value the matcher tests) and Codex's `tool_input: { command }` shape (the `command` arg when present, else `''`). The matcher subject is the tool name (`PreToolUse`/`PostToolUse`), the session source (`SessionStart`), or the subagent type (`SubagentStart`/`SubagentStop`); `UserPromptSubmit`/`Stop` ignore matchers.
52
+
53
+ Every agent-scoped stdin payload carries `session_id` and `transcript_path`. The bridge resolves the latter through `ctx.sessionPersistence.locate(session.header)` when available and otherwise sends `null`, preserving the Codex `string | null` shape. Lookup does not create or flush the artifact, so a path can be absent before the first turn-end checkpoint or omit the current open turn.
54
+
55
+ `SessionStart` — the one emit point — runs detached; each run chain is tracked, and disposing the bridge aborts a still-running hook process, then drains the continuation before the dispose resolves (`createDetachedRuns` in `@hydraharness/harness-hook-protocol`).
56
+
57
+ ## Context source
58
+
59
+ Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }` source so the durable message is never mistaken for a user prompt.
60
+
61
+ ## Model Experience
62
+
63
+ ### Hook-provided context
64
+
65
+ #### What the model sees
66
+
67
+ `SessionStart`, accepted prompt, and post-tool hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering.
68
+
69
+ #### Token effect
70
+
71
+ No cost when hooks return no context. Hook text is data-dependent, logged, and resent until compaction.
72
+
73
+ #### KV Cache effect
74
+
75
+ Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
76
+
77
+ ### Blocked prompt or tool outcome
78
+
79
+ #### What the model sees
80
+
81
+ Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. Codex `systemMessage` is not surfaced.
82
+
83
+ #### Token effect
84
+
85
+ Blocking a prompt removes its request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request.
86
+
87
+ #### KV Cache effect
88
+
89
+ A blocked prompt sends no request and invalidates nothing. Denial, feedback, and forced-continuation context append after the reusable prefix without rewriting it.
90
+
91
+ ## Known Limitations and Deferred Work
92
+
93
+ - **Unsupported hook events:** `PermissionRequest`, `PreCompact`, `PostCompact`, `Interrupt`, and `SessionEnd` have no matching Hydra extension point and are dropped during parsing. `SubagentStart` and `SubagentStop` are supported through the shared subagent lifecycle seam. The comparison baseline is Codex's [official hook reference](https://learn.chatgpt.com/docs/hooks).
94
+ - **`SessionStart` is partial:** plain stdout and JSON `additionalContext` work, but the hook runs detached, so context can miss the first request (`TODO(session-start-gating)`).
95
+ - **`UserPromptSubmit` is partial:** blocking plus plain-stdout or JSON context work, but the common `systemMessage` and `{"continue": false}` controls are not enforced.
96
+ - **`PreToolUse` is partial:** blocking works, but `additionalContext`, `permissionDecision: "allow"`, and `updatedInput` are ignored. Every tool is represented as `tool_input: { command }`, so non-shell tool arguments are not faithfully exposed to the hook.
97
+ - **`PostToolUse` is partial:** blocking feedback and JSON `additionalContext` work, but `{"continue": false}` is not enforced, non-shell tool arguments are reduced to `{ command }`, and structured tool output is flattened to text in `tool_response`.
98
+ - **`Stop` is partial:** blocking forces another model turn, but `stop_hook_active` is always `false`, `last_assistant_message` is always `null`, and `{"continue": false}` is not enforced. An unconditionally blocking hook therefore force-continues every step unless it self-limits (`TODO(stop-loop-guard)`).
99
+ - **Common payload and output fields are partial:** every mapped event reports the statically configured `model` and `permission_mode: "default"` instead of current Codex runtime values. `systemMessage` is logged + warned but not surfaced, and `{"continue": false}` is recorded but does not apply Codex's event-specific stop behavior (`TODO(hook-continue-false)`).
100
+ - **Config loading and execution are partial:** one process-level `configPath` is parsed at load; Codex's active user, project, session, system/managed, and plugin layers, trust controls, and inline `config.toml` hook form are not implemented (`TODO(per-session-hook-config)`). Only synchronous `command` handlers run, current metadata such as `statusMessage` and `commandWindows` is ignored, and matching handlers run serially rather than with Codex's concurrent launch semantics. Plugin-bundled commands run with the session cwd; use `PLUGIN_ROOT` or `CLAUDE_PLUGIN_ROOT` for paths inside the installed bundle.
package/lib/index.js ADDED
@@ -0,0 +1,392 @@
1
+ import { readFileSync } from "node:fs";
2
+ import z from "@hydraharness/schemastery";
3
+ import { createUserMessage } from "@hydraharness/harness-llm";
4
+ import { DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_STDERR_SUMMARY_MAX_CHARS, appendHookInvoked, appendHookResult, createDetachedRuns, matcherDiagnostic, matchesMatcher, mergeHookOutputs, runHook } from "@hydraharness/harness-hook-protocol";
5
+ //#region lib/types/config.js
6
+ /**
7
+ * Parse Codex's seven-event hook subset into shared {@link MatcherGroup}s. Only synchronous command
8
+ * hooks run; other types and `async: true` commands are recorded as skipped. Codex performs no
9
+ * command substitution.
10
+ * @module @hydraharness/harness-hooks-codex/config
11
+ */
12
+ /** The seven Codex hook points that have matching Hydra extension points. */
13
+ const CODEX_EVENTS = [
14
+ "PreToolUse",
15
+ "PostToolUse",
16
+ "SessionStart",
17
+ "UserPromptSubmit",
18
+ "Stop",
19
+ "SubagentStart",
20
+ "SubagentStop"
21
+ ];
22
+ function asObject(value) {
23
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
24
+ }
25
+ /**
26
+ * Parse a wrapped or bare Codex event map. Unknown events and malformed entries are ignored rather
27
+ * than failing boot; unsupported or asynchronous hooks are returned in `skipped`. Matcher fields on
28
+ * UserPromptSubmit and Stop are discarded because those events have no matcher subject in the bridge. A
29
+ * matcher-bearing runnable group with an invalid regex throws a `SyntaxError`, allowing the bridge
30
+ * to reject the complete config before listener registration.
31
+ * @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map.
32
+ * @returns the runnable per-event groups plus the skipped hooks with their reasons.
33
+ */
34
+ function parseCodexConfig(raw) {
35
+ const config = {};
36
+ const skipped = [];
37
+ const root = asObject(raw);
38
+ const hooksMap = root ? asObject(root.hooks) ?? root : void 0;
39
+ if (!hooksMap) return {
40
+ config,
41
+ skipped
42
+ };
43
+ for (const event of CODEX_EVENTS) {
44
+ const rawGroups = hooksMap[event];
45
+ if (!Array.isArray(rawGroups)) continue;
46
+ const groups = [];
47
+ for (const rawGroup of rawGroups) {
48
+ const group = asObject(rawGroup);
49
+ if (!group || !Array.isArray(group.hooks)) continue;
50
+ const commands = [];
51
+ for (const rawHook of group.hooks) {
52
+ const hook = asObject(rawHook);
53
+ if (!hook) continue;
54
+ const type = typeof hook.type === "string" ? hook.type : "command";
55
+ if (type !== "command") {
56
+ skipped.push({
57
+ event,
58
+ reason: `unsupported "${type}" hook`
59
+ });
60
+ continue;
61
+ }
62
+ if (hook.async === true) {
63
+ skipped.push({
64
+ event,
65
+ reason: "async hook"
66
+ });
67
+ continue;
68
+ }
69
+ if (typeof hook.command !== "string") continue;
70
+ const timeout = typeof hook.timeout === "number" ? hook.timeout : typeof hook.timeoutSec === "number" ? hook.timeoutSec : void 0;
71
+ commands.push({
72
+ command: hook.command,
73
+ ...timeout !== void 0 ? { timeoutSec: timeout } : {}
74
+ });
75
+ }
76
+ if (commands.length === 0) continue;
77
+ const matcher = event === "UserPromptSubmit" || event === "Stop" ? void 0 : typeof group.matcher === "string" ? group.matcher : void 0;
78
+ const diagnostic = matcherDiagnostic(matcher, "codex");
79
+ if (diagnostic !== void 0) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`);
80
+ groups.push({
81
+ ...matcher !== void 0 ? { matcher } : {},
82
+ hooks: commands
83
+ });
84
+ }
85
+ if (groups.length > 0) config[event] = groups;
86
+ }
87
+ return {
88
+ config,
89
+ skipped
90
+ };
91
+ }
92
+ //#endregion
93
+ //#region lib/types/index.js
94
+ /**
95
+ * Bridge for unmodified Codex command hooks on harness interception points. It
96
+ * supports seven points (SessionStart, prompt/tool pre/post, Stop, and
97
+ * subagent start/stop), regex-only
98
+ * matchers, snake_case payloads without a trailing newline, no config-time
99
+ * command substitution, and no pre-tool approval or rewrite path; only
100
+ * blocking decisions are honored. Shared execution and parsing live in
101
+ * `@hydraharness/harness-hook-protocol`; see the
102
+ * [hook-bridges Agent Note](../../../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md).
103
+ * @module @hydraharness/harness-hooks-codex
104
+ */
105
+ const name = "hooks-codex";
106
+ const inject = ["shell"];
107
+ const Config = z.object({
108
+ configPath: z.string().required(),
109
+ model: z.string().default(""),
110
+ defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS),
111
+ stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS),
112
+ env: z.dict(String).default({})
113
+ });
114
+ let handlerCounter = 0;
115
+ function nextHandlerId(point) {
116
+ return `codex:${point}:${++handlerCounter}`;
117
+ }
118
+ const PLUGIN_SOURCE = {
119
+ kind: "plugin",
120
+ plugin: "hooks-codex"
121
+ };
122
+ /** The summary cap bounds a persisted event field — a positive integer or the slice misbehaves silently. */
123
+ function assertPositiveInteger(name, value) {
124
+ if (!Number.isInteger(value) || value < 1) throw new Error(`hooks-codex: ${name} must be a positive integer`);
125
+ }
126
+ function apply(ctx, config) {
127
+ const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS;
128
+ assertPositiveInteger("stderrSummaryMaxChars", stderrSummaryMaxChars);
129
+ const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
130
+ let parsed = {};
131
+ try {
132
+ const result = parseCodexConfig(JSON.parse(readFileSync(config.configPath, "utf8")));
133
+ parsed = result.config;
134
+ for (const s of result.skipped) ctx.logger.warn(`hooks-codex: skipping ${s.reason} on ${s.event} (only sync command hooks run)`);
135
+ } catch (error) {
136
+ ctx.logger.warn(`hooks-codex: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`);
137
+ return;
138
+ }
139
+ const model = config.model ?? "";
140
+ const lifecycle = new AbortController();
141
+ ctx.effect(() => () => {
142
+ lifecycle.abort(/* @__PURE__ */ new Error("hooks-codex unloaded"));
143
+ }, "hooks-codex lifecycle");
144
+ const detached = createDetachedRuns();
145
+ ctx.effect(() => () => detached.drain(), "hooks-codex: drain detached hook runs");
146
+ const subagentChildren = /* @__PURE__ */ new Map();
147
+ /**
148
+ * Run and fold one configured Codex hook point.
149
+ *
150
+ * A supplied turn records the hook invocation/result pair inside that open turn.
151
+ * Detached lifecycle points omit it.
152
+ */
153
+ async function runPoint(point, matchQuery, payload, opts) {
154
+ const groups = parsed[point] ?? [];
155
+ const outputs = [];
156
+ const workdir = opts.agent?.session.header.cwd;
157
+ for (const group of groups) {
158
+ if (!matchesMatcher(group.matcher, matchQuery, "codex")) continue;
159
+ for (const hook of group.hooks) {
160
+ const handlerId = nextHandlerId(point);
161
+ const session = opts.agent?.session;
162
+ if (session && opts.turn !== void 0) appendHookInvoked(session, {
163
+ turn: opts.turn,
164
+ point,
165
+ dialect: "codex",
166
+ handlerId,
167
+ ...group.matcher !== void 0 ? { matcher: group.matcher } : {}
168
+ });
169
+ const { output, durationMs } = await runHook(ctx.shell, hook, {
170
+ payload,
171
+ defaultTimeoutMs,
172
+ ...workdir !== void 0 ? { cwd: workdir } : {},
173
+ signal: AbortSignal.any([opts.signal, lifecycle.signal]),
174
+ trailingNewline: false,
175
+ ...config.env === void 0 ? {} : { env: config.env },
176
+ expectedEventName: point
177
+ }, () => performance.now());
178
+ if (opts.plainStdoutAsContext === true && output.exitCode === 0 && output.additionalContext === void 0 && output.stdout.length > 0 && !output.stdout.startsWith("{")) output.additionalContext = output.stdout;
179
+ outputs.push(output);
180
+ if (output.systemMessage !== void 0) ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`);
181
+ if (session && opts.turn !== void 0) appendHookResult(session, {
182
+ turn: opts.turn,
183
+ point,
184
+ handlerId,
185
+ output,
186
+ stderrSummaryMaxChars,
187
+ durationMs
188
+ });
189
+ }
190
+ }
191
+ return mergeHookOutputs(outputs);
192
+ }
193
+ function contextFrom(merged) {
194
+ if (merged.additionalContext.length === 0) return void 0;
195
+ return createUserMessage({
196
+ content: merged.additionalContext.map((text) => ({
197
+ type: "text",
198
+ text
199
+ })),
200
+ source: PLUGIN_SOURCE
201
+ });
202
+ }
203
+ /** Prepend one context without flattening source fields or other downstream metadata. */
204
+ function prependContext(ours, theirs) {
205
+ return [ours, ...theirs ?? []];
206
+ }
207
+ ctx.on("agent/session-start", ({ agent, source }) => {
208
+ detached.track(runPoint("SessionStart", source, {
209
+ ...base(ctx, agent, "SessionStart", model),
210
+ source
211
+ }, {
212
+ agent,
213
+ plainStdoutAsContext: true,
214
+ signal: detached.signal
215
+ }).then((merged) => {
216
+ const context = contextFrom(merged);
217
+ if (context) agent.inject(context);
218
+ }).catch((error) => {
219
+ ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`);
220
+ }));
221
+ });
222
+ ctx.on("agent/pre-step", async ({ agent, messages, turn, signal }, next) => {
223
+ if (messages.length === 0) return next();
224
+ const merged = await runPoint("UserPromptSubmit", "", {
225
+ ...base(ctx, agent, "UserPromptSubmit", model),
226
+ turn_id: String(turn),
227
+ prompt: blocksToText(messages.flatMap((message) => message.content))
228
+ }, {
229
+ agent,
230
+ turn,
231
+ plainStdoutAsContext: true,
232
+ signal
233
+ });
234
+ if (merged.decision === "deny") return { kind: "reject" };
235
+ const downstream = await next();
236
+ const ours = contextFrom(merged);
237
+ if (!ours || downstream.kind !== "enter") return downstream;
238
+ return {
239
+ kind: "enter",
240
+ messages: [...downstream.messages, ours]
241
+ };
242
+ });
243
+ ctx.on("tools/pre-execute", async (exec, next) => {
244
+ const turn = lastTurn(exec.agent);
245
+ const merged = await runPoint("PreToolUse", exec.name, preToolPayload(ctx, exec, model), {
246
+ ...exec.agent ? { agent: exec.agent } : {},
247
+ turn,
248
+ signal: exec.signal
249
+ });
250
+ if (merged.decision === "deny") return {
251
+ kind: "deny",
252
+ reason: merged.reason ?? "blocked by PreToolUse hook"
253
+ };
254
+ return next();
255
+ });
256
+ ctx.on("tools/post-execute", async (exec, result, next) => {
257
+ const turn = lastTurn(exec.agent);
258
+ const merged = await runPoint("PostToolUse", exec.name, postToolPayload(ctx, exec, result, model), {
259
+ ...exec.agent ? { agent: exec.agent } : {},
260
+ turn,
261
+ signal: exec.signal
262
+ });
263
+ const context = contextFrom(merged);
264
+ if (merged.decision === "deny") return {
265
+ kind: "block",
266
+ feedback: [{
267
+ type: "text",
268
+ text: merged.reason ?? "blocked by PostToolUse hook"
269
+ }],
270
+ ...context ? { additionalContexts: [context] } : {}
271
+ };
272
+ const downstream = await next();
273
+ if (!context) return downstream;
274
+ if (downstream.kind === "block") return {
275
+ ...downstream,
276
+ additionalContexts: prependContext(context, downstream.additionalContexts)
277
+ };
278
+ return {
279
+ ...downstream,
280
+ additionalContexts: prependContext(context, downstream.additionalContexts)
281
+ };
282
+ });
283
+ ctx.on("agent/turn-stopping", async ({ agent, turn, signal }) => {
284
+ const merged = await runPoint("Stop", "", {
285
+ ...turnBase(ctx, agent, "Stop", model),
286
+ stop_hook_active: false,
287
+ last_assistant_message: null
288
+ }, {
289
+ agent,
290
+ turn,
291
+ signal
292
+ });
293
+ if (merged.decision === "deny") {
294
+ const text = merged.reason ?? "continue: blocked by Stop hook";
295
+ agent.steer(createUserMessage({
296
+ content: [{
297
+ type: "text",
298
+ text
299
+ }],
300
+ source: PLUGIN_SOURCE
301
+ }));
302
+ }
303
+ });
304
+ ctx.on("subagent/start", (info) => {
305
+ const child = ctx.get("agents")?.get(info.id);
306
+ if (child !== void 0) subagentChildren.set(info.runId, child);
307
+ detached.track(runPoint("SubagentStart", SUBAGENT_TYPE, subagentPayload(ctx, "SubagentStart", info, child, model), {
308
+ ...child ? { agent: child } : {},
309
+ signal: detached.signal
310
+ }).then((merged) => {
311
+ const context = contextFrom(merged);
312
+ if (context && child) child.inject(context);
313
+ }).catch((error) => {
314
+ ctx.logger.warn(`hooks-codex: SubagentStart hook failed: ${String(error)}`);
315
+ }));
316
+ });
317
+ ctx.on("subagent/end", (info) => {
318
+ const child = subagentChildren.get(info.runId) ?? ctx.get("agents")?.get(info.id);
319
+ subagentChildren.delete(info.runId);
320
+ detached.track(runPoint("SubagentStop", SUBAGENT_TYPE, subagentPayload(ctx, "SubagentStop", info, child, model), {
321
+ ...child ? { agent: child } : {},
322
+ signal: detached.signal
323
+ }).catch((error) => {
324
+ ctx.logger.warn(`hooks-codex: SubagentStop hook failed: ${String(error)}`);
325
+ }));
326
+ });
327
+ }
328
+ /** The default matcher subject used for Codex subagent lifecycle hooks. */
329
+ const SUBAGENT_TYPE = "general-purpose";
330
+ function lastTurn(agent) {
331
+ if (!agent) return 0;
332
+ const last = [...agent.session.events].findLast((e) => e.type === "turn/start");
333
+ /* v8 ignore next -- agent-present turnBase callers are tool/stop extension points inside an open turn. */
334
+ return last?.type === "turn/start" ? last.data.turn : 0;
335
+ }
336
+ function blocksToText(content) {
337
+ return content.filter((b) => b.type === "text").map((b) => b.text).join("");
338
+ }
339
+ /** Base fields on every Codex payload (no turn_id). */
340
+ function base(ctx, agent, event, model) {
341
+ return {
342
+ session_id: agent?.session.header.id ?? "",
343
+ transcript_path: agent === void 0 ? null : ctx.get("sessionPersistence")?.locate(agent.session.header)?.path ?? null,
344
+ cwd: agent?.session.header.cwd ?? process.cwd(),
345
+ hook_event_name: event,
346
+ model,
347
+ permission_mode: "default"
348
+ };
349
+ }
350
+ /** Base + turn_id, for the turn-scoped events (PreToolUse/PostToolUse/UserPromptSubmit/Stop). */
351
+ function turnBase(ctx, agent, event, model) {
352
+ return {
353
+ ...base(ctx, agent, event, model),
354
+ turn_id: String(lastTurn(agent))
355
+ };
356
+ }
357
+ /** Extract a `command` string from a tool call's parsed arguments, else ''. */
358
+ function commandOf(args) {
359
+ if (typeof args === "object" && args !== null && "command" in args) {
360
+ const command = args.command;
361
+ if (typeof command === "string") return command;
362
+ }
363
+ return "";
364
+ }
365
+ function preToolPayload(ctx, exec, model) {
366
+ return {
367
+ ...turnBase(ctx, exec.agent, "PreToolUse", model),
368
+ tool_name: exec.name,
369
+ tool_input: { command: commandOf(exec.arguments) },
370
+ tool_use_id: exec.callId
371
+ };
372
+ }
373
+ function postToolPayload(ctx, exec, result, model) {
374
+ return {
375
+ ...turnBase(ctx, exec.agent, "PostToolUse", model),
376
+ tool_name: exec.name,
377
+ tool_input: { command: commandOf(exec.arguments) },
378
+ tool_use_id: exec.callId,
379
+ tool_response: blocksToText(result.content)
380
+ };
381
+ }
382
+ /** Build the Codex-shaped lifecycle payload for a subagent event. */
383
+ function subagentPayload(ctx, event, info, child, model) {
384
+ return {
385
+ ...base(ctx, child, event, model),
386
+ agent_id: info.id,
387
+ agent_type: SUBAGENT_TYPE,
388
+ ...event === "SubagentStop" ? { stop_hook_active: false } : {}
389
+ };
390
+ }
391
+ //#endregion
392
+ export { Config, apply, inject, name };
@@ -0,0 +1,23 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@hydraharness/harness-hooks-codex`.
4
+ * @module @hydraharness/harness-hooks-codex/invariant
5
+ */
6
+ const PACKAGE_NAME = "@hydraharness/harness-hooks-codex";
7
+ /** Cordis companion plugin name. */
8
+ const name = "hooks-codex-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * No runtime invariant: this bridge publishes hook-protocol session events, whose companion owns
13
+ * which invocation event each result cites.
14
+ */
15
+ const install = () => {};
16
+ /**
17
+ * Register this package's invariant companion.
18
+ * @param ctx - Cordis context carrying the invariant service.
19
+ * @returns the installed registration's disposer after setup succeeds.
20
+ */
21
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
22
+ //#endregion
23
+ export { apply, inject, name };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Parse Codex's seven-event hook subset into shared {@link MatcherGroup}s. Only synchronous command
3
+ * hooks run; other types and `async: true` commands are recorded as skipped. Codex performs no
4
+ * command substitution.
5
+ * @module @hydraharness/harness-hooks-codex/config
6
+ */
7
+ import { type MatcherGroup } from '@hydraharness/harness-hook-protocol';
8
+ /** The seven Codex hook points that have matching Hydra extension points. */
9
+ export declare const CODEX_EVENTS: readonly ["PreToolUse", "PostToolUse", "SessionStart", "UserPromptSubmit", "Stop", "SubagentStart", "SubagentStop"];
10
+ /** A parsed Codex config: event name → its matcher groups (command hooks only). */
11
+ export type CodexHookConfig = Record<string, MatcherGroup[]>;
12
+ /** A skipped non-command (or async) hook, surfaced so the bridge can warn. */
13
+ export interface SkippedHook {
14
+ event: string;
15
+ reason: string;
16
+ }
17
+ /** The outcome of parsing one Codex config file. */
18
+ export interface ParsedCodexConfig {
19
+ config: CodexHookConfig;
20
+ skipped: SkippedHook[];
21
+ }
22
+ /**
23
+ * Parse a wrapped or bare Codex event map. Unknown events and malformed entries are ignored rather
24
+ * than failing boot; unsupported or asynchronous hooks are returned in `skipped`. Matcher fields on
25
+ * UserPromptSubmit and Stop are discarded because those events have no matcher subject in the bridge. A
26
+ * matcher-bearing runnable group with an invalid regex throws a `SyntaxError`, allowing the bridge
27
+ * to reject the complete config before listener registration.
28
+ * @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map.
29
+ * @returns the runnable per-event groups plus the skipped hooks with their reasons.
30
+ */
31
+ export declare function parseCodexConfig(raw: unknown): ParsedCodexConfig;
32
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Parse Codex's seven-event hook subset into shared {@link MatcherGroup}s. Only synchronous command
3
+ * hooks run; other types and `async: true` commands are recorded as skipped. Codex performs no
4
+ * command substitution.
5
+ * @module @hydraharness/harness-hooks-codex/config
6
+ */
7
+ import { matcherDiagnostic } from '@hydraharness/harness-hook-protocol';
8
+ /** The seven Codex hook points that have matching Hydra extension points. */
9
+ export const CODEX_EVENTS = [
10
+ 'PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop', 'SubagentStart', 'SubagentStop',
11
+ ];
12
+ function asObject(value) {
13
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
14
+ ? value
15
+ : undefined;
16
+ }
17
+ /**
18
+ * Parse a wrapped or bare Codex event map. Unknown events and malformed entries are ignored rather
19
+ * than failing boot; unsupported or asynchronous hooks are returned in `skipped`. Matcher fields on
20
+ * UserPromptSubmit and Stop are discarded because those events have no matcher subject in the bridge. A
21
+ * matcher-bearing runnable group with an invalid regex throws a `SyntaxError`, allowing the bridge
22
+ * to reject the complete config before listener registration.
23
+ * @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map.
24
+ * @returns the runnable per-event groups plus the skipped hooks with their reasons.
25
+ */
26
+ export function parseCodexConfig(raw) {
27
+ const config = {};
28
+ const skipped = [];
29
+ const root = asObject(raw);
30
+ const hooksMap = root ? asObject(root.hooks) ?? root : undefined;
31
+ if (!hooksMap)
32
+ return { config, skipped };
33
+ for (const event of CODEX_EVENTS) {
34
+ const rawGroups = hooksMap[event];
35
+ // Matcher-group parsing remains dialect-local because the supported hook
36
+ // shapes and skip reasons differ from Claude Code's.
37
+ /* jscpd:ignore-start */
38
+ if (!Array.isArray(rawGroups))
39
+ continue;
40
+ const groups = [];
41
+ for (const rawGroup of rawGroups) {
42
+ const group = asObject(rawGroup);
43
+ if (!group || !Array.isArray(group.hooks))
44
+ continue;
45
+ const commands = [];
46
+ for (const rawHook of group.hooks) {
47
+ const hook = asObject(rawHook);
48
+ if (!hook)
49
+ continue;
50
+ const type = typeof hook.type === 'string' ? hook.type : 'command';
51
+ if (type !== 'command') {
52
+ skipped.push({ event, reason: `unsupported "${type}" hook` });
53
+ continue;
54
+ }
55
+ /* jscpd:ignore-end */
56
+ if (hook.async === true) {
57
+ skipped.push({ event, reason: 'async hook' });
58
+ continue;
59
+ }
60
+ if (typeof hook.command !== 'string')
61
+ continue;
62
+ // Codex accepts `timeout` or the `timeoutSec` alias.
63
+ const timeout = typeof hook.timeout === 'number' ? hook.timeout
64
+ : typeof hook.timeoutSec === 'number' ? hook.timeoutSec : undefined;
65
+ commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} });
66
+ }
67
+ if (commands.length === 0)
68
+ continue;
69
+ const matcher = event === 'UserPromptSubmit' || event === 'Stop'
70
+ ? undefined
71
+ : typeof group.matcher === 'string' ? group.matcher : undefined;
72
+ const diagnostic = matcherDiagnostic(matcher, 'codex');
73
+ if (diagnostic !== undefined)
74
+ throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`);
75
+ groups.push({ ...matcher !== undefined ? { matcher } : {}, hooks: commands });
76
+ }
77
+ if (groups.length > 0)
78
+ config[event] = groups;
79
+ }
80
+ return { config, skipped };
81
+ }
82
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Bridge for unmodified Codex command hooks on harness interception points. It
3
+ * supports seven points (SessionStart, prompt/tool pre/post, Stop, and
4
+ * subagent start/stop), regex-only
5
+ * matchers, snake_case payloads without a trailing newline, no config-time
6
+ * command substitution, and no pre-tool approval or rewrite path; only
7
+ * blocking decisions are honored. Shared execution and parsing live in
8
+ * `@hydraharness/harness-hook-protocol`; see the
9
+ * [hook-bridges Agent Note](../../../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md).
10
+ * @module @hydraharness/harness-hooks-codex
11
+ */
12
+ import type { Context } from '@hydraharness/cordis';
13
+ import z from '@hydraharness/schemastery';
14
+ export declare const name = "hooks-codex";
15
+ export declare const inject: string[];
16
+ /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */
17
+ export interface Config {
18
+ /**
19
+ * Path to a Codex `hooks.json`. Process-level: read once at load, a relative
20
+ * path resolves against the process launch cwd.
21
+ * TODO(per-session-hook-config): per-session project-local discovery from each
22
+ * `session/new.cwd`.
23
+ */
24
+ configPath: string;
25
+ /** The model name stamped on every payload (Codex includes `model` on each event). */
26
+ model?: string;
27
+ /** Default per-hook timeout in ms when a hook sets none (Codex default: 600000). */
28
+ defaultTimeoutMs?: number;
29
+ /** Character cap for the `hook/result` event's persisted stderr summary. */
30
+ stderrSummaryMaxChars?: number;
31
+ /** Extra trusted environment values supplied by a host-managed plugin runtime. */
32
+ env?: Record<string, string>;
33
+ }
34
+ export declare const Config: z<Config>;
35
+ export declare function apply(ctx: Context, config: Config): void;
36
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Bridge for unmodified Codex command hooks on harness interception points. It
3
+ * supports seven points (SessionStart, prompt/tool pre/post, Stop, and
4
+ * subagent start/stop), regex-only
5
+ * matchers, snake_case payloads without a trailing newline, no config-time
6
+ * command substitution, and no pre-tool approval or rewrite path; only
7
+ * blocking decisions are honored. Shared execution and parsing live in
8
+ * `@hydraharness/harness-hook-protocol`; see the
9
+ * [hook-bridges Agent Note](../../../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md).
10
+ * @module @hydraharness/harness-hooks-codex
11
+ */
12
+ // Each dialect bridge keeps its complete dependency list visible at the entry
13
+ // point; a cross-package facade for imports alone would add indirection.
14
+ /* jscpd:ignore-start */
15
+ import { readFileSync } from 'node:fs';
16
+ import z from '@hydraharness/schemastery';
17
+ import { createUserMessage } from '@hydraharness/harness-llm';
18
+ import { appendHookInvoked, appendHookResult, createDetachedRuns, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_STDERR_SUMMARY_MAX_CHARS, matchesMatcher, mergeHookOutputs, runHook, } from '@hydraharness/harness-hook-protocol';
19
+ import { parseCodexConfig } from "./config.js";
20
+ /* jscpd:ignore-end */
21
+ export const name = 'hooks-codex';
22
+ export const inject = ['shell'];
23
+ export const Config = z.object({
24
+ configPath: z.string().required(),
25
+ model: z.string().default(''),
26
+ defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS),
27
+ stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS),
28
+ env: z.dict(String).default({}),
29
+ });
30
+ let handlerCounter = 0;
31
+ function nextHandlerId(point) {
32
+ return `codex:${point}:${++handlerCounter}`;
33
+ }
34
+ const PLUGIN_SOURCE = { kind: 'plugin', plugin: 'hooks-codex' };
35
+ /** The summary cap bounds a persisted event field — a positive integer or the slice misbehaves silently. */
36
+ function assertPositiveInteger(name, value) {
37
+ if (!Number.isInteger(value) || value < 1) {
38
+ throw new Error(`hooks-codex: ${name} must be a positive integer`);
39
+ }
40
+ }
41
+ export function apply(ctx, config) {
42
+ // Validate before config parsing so a bad value cannot be hidden by its early return.
43
+ const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS;
44
+ assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars);
45
+ const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
46
+ let parsed = {};
47
+ try {
48
+ const raw = JSON.parse(readFileSync(config.configPath, 'utf8'));
49
+ const result = parseCodexConfig(raw);
50
+ parsed = result.config;
51
+ for (const s of result.skipped) {
52
+ ctx.logger.warn(`hooks-codex: skipping ${s.reason} on ${s.event} (only sync command hooks run)`);
53
+ }
54
+ }
55
+ catch (error) {
56
+ ctx.logger.warn(`hooks-codex: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`);
57
+ return;
58
+ }
59
+ const model = config.model ?? '';
60
+ const lifecycle = new AbortController();
61
+ ctx.effect(() => () => {
62
+ lifecycle.abort(new Error('hooks-codex unloaded'));
63
+ }, 'hooks-codex lifecycle');
64
+ // SessionStart is the one emit-shaped (detached) point Codex has: track its
65
+ // run chains so disposal aborts a still-running hook process and drains the
66
+ // continuation (docs/defensive-patterns.md: dispose must reach quiescence).
67
+ const detached = createDetachedRuns();
68
+ ctx.effect(() => () => detached.drain(), 'hooks-codex: drain detached hook runs');
69
+ const subagentChildren = new Map();
70
+ /**
71
+ * Run and fold one configured Codex hook point.
72
+ *
73
+ * A supplied turn records the hook invocation/result pair inside that open turn.
74
+ * Detached lifecycle points omit it.
75
+ */
76
+ async function runPoint(point, matchQuery, payload, opts) {
77
+ const groups = parsed[point] ?? [];
78
+ const outputs = [];
79
+ // Run hooks in the agent's session workspace so relative paths address the
80
+ // user's project rather than the server launch directory.
81
+ const workdir = opts.agent?.session.header.cwd;
82
+ for (const group of groups) {
83
+ // Codex always interprets matchers as regexes; it has no literal fast path.
84
+ if (!matchesMatcher(group.matcher, matchQuery, 'codex'))
85
+ continue;
86
+ for (const hook of group.hooks) {
87
+ const handlerId = nextHandlerId(point);
88
+ const session = opts.agent?.session;
89
+ if (session && opts.turn !== undefined) {
90
+ appendHookInvoked(session, {
91
+ turn: opts.turn, point, dialect: 'codex', handlerId,
92
+ ...group.matcher !== undefined ? { matcher: group.matcher } : {},
93
+ });
94
+ }
95
+ const { output, durationMs } = await runHook(ctx.shell, hook, {
96
+ payload,
97
+ defaultTimeoutMs,
98
+ ...workdir !== undefined ? { cwd: workdir } : {},
99
+ signal: AbortSignal.any([opts.signal, lifecycle.signal]),
100
+ trailingNewline: false, // Codex writes stdin without a trailing newline.
101
+ ...config.env === undefined ? {} : { env: config.env },
102
+ // Discard a `hookSpecificOutput` block naming a different event.
103
+ expectedEventName: point,
104
+ }, () => performance.now());
105
+ // Clean plain stdout becomes context only when no structured context
106
+ // exists; nonzero output and raw JSON never leak as prose.
107
+ if (opts.plainStdoutAsContext === true && output.exitCode === 0
108
+ && output.additionalContext === undefined
109
+ && output.stdout.length > 0 && !output.stdout.startsWith('{')) {
110
+ output.additionalContext = output.stdout;
111
+ }
112
+ outputs.push(output);
113
+ // Execution and decision mapping remain in each bridge so dialect
114
+ // differences stay explicit at their owning extension point.
115
+ /* jscpd:ignore-start */
116
+ if (output.systemMessage !== undefined) {
117
+ ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`);
118
+ }
119
+ if (session && opts.turn !== undefined) {
120
+ appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs });
121
+ }
122
+ }
123
+ }
124
+ return mergeHookOutputs(outputs);
125
+ }
126
+ // TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt mechanism.
127
+ function contextFrom(merged) {
128
+ if (merged.additionalContext.length === 0)
129
+ return undefined;
130
+ const content = merged.additionalContext.map(text => ({ type: 'text', text }));
131
+ return createUserMessage({ content, source: PLUGIN_SOURCE });
132
+ }
133
+ /** Prepend one context without flattening source fields or other downstream metadata. */
134
+ function prependContext(ours, theirs) {
135
+ return [ours, ...theirs ?? []];
136
+ }
137
+ // SessionStart injects plain stdout when its detached hook resolves; a slow
138
+ // hook may miss the first request.
139
+ // TODO(session-start-gating): add a startup gate before promising first-turn delivery.
140
+ ctx.on('agent/session-start', ({ agent, source }) => {
141
+ detached.track(runPoint('SessionStart', source, { ...base(ctx, agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal })
142
+ .then((merged) => {
143
+ const context = contextFrom(merged);
144
+ if (context)
145
+ agent.inject(context);
146
+ })
147
+ .catch((error) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`); }));
148
+ /* jscpd:ignore-end */
149
+ });
150
+ // UserPromptSubmit → PreStepDecision. Codex supports reject, not rewrite or ask.
151
+ ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next) => {
152
+ if (messages.length === 0)
153
+ return next();
154
+ const payload = {
155
+ ...base(ctx, agent, 'UserPromptSubmit', model),
156
+ turn_id: String(turn),
157
+ prompt: blocksToText(messages.flatMap(message => message.content)),
158
+ };
159
+ const merged = await runPoint('UserPromptSubmit', '', payload, {
160
+ agent, turn, plainStdoutAsContext: true, signal,
161
+ });
162
+ /* jscpd:ignore-start */
163
+ if (merged.decision === 'deny') {
164
+ return { kind: 'reject' };
165
+ }
166
+ // Context alone is not a veto: DELEGATE so a later pre-step listener can
167
+ // still reject/rewrite, then fold our context onto its decision.
168
+ const downstream = await next();
169
+ const ours = contextFrom(merged);
170
+ if (!ours || downstream.kind !== 'enter')
171
+ return downstream;
172
+ return {
173
+ kind: 'enter',
174
+ messages: [...downstream.messages, ours],
175
+ };
176
+ });
177
+ // PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored).
178
+ ctx.on('tools/pre-execute', async (exec, next) => {
179
+ const turn = lastTurn(exec.agent);
180
+ const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal });
181
+ /* jscpd:ignore-end */
182
+ if (merged.decision === 'deny')
183
+ return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' };
184
+ return next();
185
+ });
186
+ // PostToolUse → PostToolDecision (block with feedback, or attach context).
187
+ ctx.on('tools/post-execute', async (exec, result, next) => {
188
+ const turn = lastTurn(exec.agent);
189
+ /* jscpd:ignore-start */
190
+ const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal });
191
+ const context = contextFrom(merged);
192
+ if (merged.decision === 'deny') {
193
+ return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} };
194
+ }
195
+ // Context alone is not a veto: DELEGATE, then fold our context onto the
196
+ // downstream decision (a downstream block carries it too).
197
+ const downstream = await next();
198
+ if (!context)
199
+ return downstream;
200
+ if (downstream.kind === 'block') {
201
+ return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) };
202
+ }
203
+ return {
204
+ ...downstream,
205
+ additionalContexts: prependContext(context, downstream.additionalContexts),
206
+ };
207
+ });
208
+ // A blocking Stop hook steers at the stopping boundary, which makes the
209
+ // machine observe pending input and run another step.
210
+ // TODO(stop-loop-guard): Codex supplies `stop_hook_active` so a Stop hook can
211
+ // avoid continuing the same turn indefinitely. It is always false here, so an
212
+ // unconditionally blocking hook force-continues every step until it self-limits.
213
+ ctx.on('agent/turn-stopping', async ({ agent, turn, signal }) => {
214
+ const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal });
215
+ /* jscpd:ignore-end */
216
+ if (merged.decision === 'deny') {
217
+ // A blocking Stop hook forces continuation; a block with no reason (exit 2,
218
+ // empty stderr) still forces it — fall back to a generic steering line
219
+ // rather than letting the turn stop.
220
+ const text = merged.reason ?? 'continue: blocked by Stop hook';
221
+ agent.steer(createUserMessage({ content: [{ type: 'text', text }], source: PLUGIN_SOURCE }));
222
+ }
223
+ });
224
+ // SubagentStart may inject child context; SubagentStop is observe-only. The
225
+ // generic subagent seam carries no agent-type label, so both use a stable
226
+ // default matcher subject.
227
+ ctx.on('subagent/start', (info) => {
228
+ const child = ctx.get('agents')?.get(info.id);
229
+ if (child !== undefined)
230
+ subagentChildren.set(info.runId, child);
231
+ detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStart', info, child, model), { ...child ? { agent: child } : {}, signal: detached.signal })
232
+ .then((merged) => {
233
+ const context = contextFrom(merged);
234
+ if (context && child)
235
+ child.inject(context);
236
+ })
237
+ .catch((error) => { ctx.logger.warn(`hooks-codex: SubagentStart hook failed: ${String(error)}`); }));
238
+ });
239
+ ctx.on('subagent/end', (info) => {
240
+ const child = subagentChildren.get(info.runId) ?? ctx.get('agents')?.get(info.id);
241
+ subagentChildren.delete(info.runId);
242
+ detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStop', info, child, model), { ...child ? { agent: child } : {}, signal: detached.signal })
243
+ .catch((error) => { ctx.logger.warn(`hooks-codex: SubagentStop hook failed: ${String(error)}`); }));
244
+ });
245
+ }
246
+ /** The default matcher subject used for Codex subagent lifecycle hooks. */
247
+ const SUBAGENT_TYPE = 'general-purpose';
248
+ // --- Codex DIALECT payloads: snake_case, model on every event, turn_id on
249
+ // turn-scoped events. ---
250
+ // These small payload helpers intentionally remain next to the dialect shape;
251
+ // sharing them would pull bridge-only agent/LLM dependencies into hook-protocol.
252
+ /* jscpd:ignore-start */
253
+ function lastTurn(agent) {
254
+ if (!agent)
255
+ return 0;
256
+ const last = [...agent.session.events].findLast(e => e.type === 'turn/start');
257
+ /* v8 ignore next -- agent-present turnBase callers are tool/stop extension points inside an open turn. */
258
+ return last?.type === 'turn/start' ? last.data.turn : 0;
259
+ }
260
+ function blocksToText(content) {
261
+ return content.filter((b) => b.type === 'text').map(b => b.text).join('');
262
+ }
263
+ /* jscpd:ignore-end */
264
+ /** Base fields on every Codex payload (no turn_id). */
265
+ function base(ctx, agent, event, model) {
266
+ return {
267
+ session_id: agent?.session.header.id ?? '',
268
+ transcript_path: agent === undefined
269
+ ? null
270
+ : ctx.get('sessionPersistence')?.locate(agent.session.header)?.path ?? null,
271
+ cwd: agent?.session.header.cwd ?? process.cwd(),
272
+ hook_event_name: event,
273
+ model,
274
+ permission_mode: 'default',
275
+ };
276
+ }
277
+ /** Base + turn_id, for the turn-scoped events (PreToolUse/PostToolUse/UserPromptSubmit/Stop). */
278
+ function turnBase(ctx, agent, event, model) {
279
+ return { ...base(ctx, agent, event, model), turn_id: String(lastTurn(agent)) };
280
+ }
281
+ /** Extract a `command` string from a tool call's parsed arguments, else ''. */
282
+ function commandOf(args) {
283
+ if (typeof args === 'object' && args !== null && 'command' in args) {
284
+ const command = args.command;
285
+ if (typeof command === 'string')
286
+ return command;
287
+ }
288
+ return '';
289
+ }
290
+ function preToolPayload(ctx, exec, model) {
291
+ // `tool_name` is the REAL tool name (matching the `exec.name` matcher subject);
292
+ // a hardcoded constant would disagree with what the matcher tests and make a
293
+ // config's tool matcher never fire. `tool_input` keeps Codex's `{ command }`
294
+ // shape (its shell payload), derived from the call's `command` arg when present.
295
+ return { ...turnBase(ctx, exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId };
296
+ }
297
+ function postToolPayload(ctx, exec, result, model) {
298
+ return { ...turnBase(ctx, exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) };
299
+ }
300
+ /** Build the Codex-shaped lifecycle payload for a subagent event. */
301
+ function subagentPayload(ctx, event, info, child, model) {
302
+ return {
303
+ ...base(ctx, child, event, model),
304
+ agent_id: info.id,
305
+ agent_type: SUBAGENT_TYPE,
306
+ ...event === 'SubagentStop' ? { stop_hook_active: false } : {},
307
+ };
308
+ }
309
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `@hydraharness/harness-hooks-codex`.
3
+ * @module @hydraharness/harness-hooks-codex/invariant
4
+ */
5
+ import type { Context } from '@hydraharness/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "hooks-codex-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant service.
13
+ * @returns the installed registration's disposer after setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Package-owned invariant companion for `@hydraharness/harness-hooks-codex`.
3
+ * @module @hydraharness/harness-hooks-codex/invariant
4
+ */
5
+ const PACKAGE_NAME = '@hydraharness/harness-hooks-codex';
6
+ /** Cordis companion plugin name. */
7
+ export const name = 'hooks-codex-invariant';
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export const inject = ['invariants'];
10
+ /**
11
+ * No runtime invariant: this bridge publishes hook-protocol session events, whose companion owns
12
+ * which invocation event each result cites.
13
+ */
14
+ const install = () => { };
15
+ /**
16
+ * Register this package's invariant companion.
17
+ * @param ctx - Cordis context carrying the invariant service.
18
+ * @returns the installed registration's disposer after setup succeeds.
19
+ */
20
+ export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
21
+ /* jscpd:ignore-end */
22
+ //# sourceMappingURL=invariant.js.map
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@hydraharness/harness-hooks-codex",
3
+ "description": "Bridge plugin: run a Codex hooks.json hook config on the Hydra harness interception seams",
4
+ "version": "0.1.1-rc.5",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/MaiHongPhong1902/Hydra-Harness.git",
11
+ "directory": "packages/hooks/hooks-codex"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./invariant": {
22
+ "types": "./lib/types/invariant.d.ts",
23
+ "default": "./lib/invariant.js"
24
+ },
25
+ "./config": {
26
+ "types": "./lib/types/config.d.ts",
27
+ "default": "./lib/types/config.js"
28
+ },
29
+ "./src/*": "./src/*",
30
+ "./package.json": "./package.json"
31
+ },
32
+ "files": [
33
+ "lib/index.js",
34
+ "lib/invariant.js",
35
+ "lib/types/**/*.js",
36
+ "lib/types/**/*.d.ts"
37
+ ],
38
+ "license": "MIT",
39
+ "dependencies": {
40
+ "@hydraharness/schemastery": "^3.18.2"
41
+ },
42
+ "peerDependencies": {
43
+ "@hydraharness/harness-agent": "^0.1.1-rc.5",
44
+ "@hydraharness/harness-hook-protocol": "^0.1.1-rc.5",
45
+ "@hydraharness/harness-invariants": "^0.1.1-rc.5",
46
+ "@hydraharness/harness-session-persistence": "^0.1.1-rc.5",
47
+ "@hydraharness/harness-session": "^0.1.1-rc.5",
48
+ "@hydraharness/harness-llm": "^0.1.1-rc.5",
49
+ "@hydraharness/harness-subagent": "^0.1.1-rc.5",
50
+ "@hydraharness/harness-tools": "^0.1.1-rc.5",
51
+ "@hydraharness/cordis": "^4.0.2"
52
+ },
53
+ "devDependencies": {
54
+ "@hydraharness/harness-agent": "^0.1.1-rc.5",
55
+ "@hydraharness/harness-shell": "^0.1.1-rc.5",
56
+ "@hydraharness/harness-bash-local": "^0.1.1-rc.5",
57
+ "@hydraharness/harness-agent-loop-testkit": "^0.1.1-rc.5",
58
+ "@hydraharness/harness-agent-loop": "^0.1.1-rc.5",
59
+ "@hydraharness/harness-subprocess-local": "^0.1.1-rc.5",
60
+ "@hydraharness/harness-hook-protocol": "^0.1.1-rc.5",
61
+ "@hydraharness/harness-invariants": "^0.1.1-rc.5",
62
+ "@hydraharness/harness-session": "^0.1.1-rc.5",
63
+ "@hydraharness/harness-llm": "^0.1.1-rc.5",
64
+ "@hydraharness/harness-session-persistence": "^0.1.1-rc.5",
65
+ "@hydraharness/harness-session-persistence-jsonl": "^0.1.1-rc.5",
66
+ "@hydraharness/harness-subagent": "^0.1.1-rc.5",
67
+ "@hydraharness/harness-tools": "^0.1.1-rc.5",
68
+ "@hydraharness/cordis": "^4.0.2"
69
+ }
70
+ }