@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 +21 -0
- package/README.md +100 -0
- package/lib/index.js +392 -0
- package/lib/invariant.js +23 -0
- package/lib/types/config.d.ts +32 -0
- package/lib/types/config.js +82 -0
- package/lib/types/index.d.ts +36 -0
- package/lib/types/index.js +309 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +22 -0
- package/package.json +70 -0
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 };
|
package/lib/invariant.js
ADDED
|
@@ -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
|
+
}
|