@iamdevlinph/codex-kit 1.0.3 → 1.0.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/README.md +29 -8
- package/assets/SUBAGENT_ROUTING.md +35 -28
- package/bin/codex-kit.js +152 -15
- package/bin/routing-hook.js +166 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Portable Codex setup for new devices and multiple projects. It provides:
|
|
4
4
|
|
|
5
5
|
- four custom subagents: explorer, quick implementer, implementer, and reviewer
|
|
6
|
-
- global
|
|
6
|
+
- automatic global role routing
|
|
7
7
|
- a reusable, stack-neutral `AGENTS.md` template
|
|
8
8
|
- safe commands for global setup, project synchronization, and reconciliation
|
|
9
9
|
|
|
@@ -20,12 +20,30 @@ codex-kit global install
|
|
|
20
20
|
codex-kit global configure
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
Start a new Codex task or restart the client after installation. Codex may ask
|
|
24
|
+
you to review and trust the new command hooks once; approve them after confirming
|
|
25
|
+
that they run the installed `${CODEX_HOME:-~/.codex}/codex-kit/routing-hook.js`.
|
|
26
|
+
|
|
23
27
|
`npm install --global @iamdevlinph/codex-kit@latest` works as an alternative.
|
|
24
28
|
For one-off use without a global installation, prefix a command with
|
|
25
29
|
`pnpm dlx @iamdevlinph/codex-kit@latest`.
|
|
26
30
|
|
|
27
|
-
`global install` copies reusable agents to `${CODEX_HOME:-~/.codex}
|
|
28
|
-
|
|
31
|
+
`global install` copies reusable agents to `${CODEX_HOME:-~/.codex}`, maintains
|
|
32
|
+
a marked routing section in the global `AGENTS.md`, and adds package-owned
|
|
33
|
+
handlers to the global `hooks.json` without replacing existing hooks.
|
|
34
|
+
|
|
35
|
+
The Sol root plans, routes, coordinates, and validates. On every prompt, the
|
|
36
|
+
routing hook supplies the current `SUBAGENT_ROUTING.md`; the root classifies the
|
|
37
|
+
task and delegates to the exact matching role. The role's agent TOML—not the
|
|
38
|
+
routing policy—selects its model and reasoning effort. Planning, conversation,
|
|
39
|
+
status, and small read-only checks may stay on the root.
|
|
40
|
+
|
|
41
|
+
The hook rejects direct root writes through Codex edit tools and obvious
|
|
42
|
+
write-shaped shell commands. `SubagentStart` opens a 15-minute write allowance
|
|
43
|
+
for that worker's `session_id + turn_id`; `SubagentStop` closes it. This identifies
|
|
44
|
+
the delegated lane without hard-coding a model. Codex hooks do not intercept
|
|
45
|
+
every possible indirect shell-write path, so this is a workflow guardrail rather
|
|
46
|
+
than a security boundary.
|
|
29
47
|
|
|
30
48
|
`global configure` sets these defaults while preserving unrelated settings:
|
|
31
49
|
|
|
@@ -55,8 +73,8 @@ pnpm dlx @iamdevlinph/codex-kit@latest global list
|
|
|
55
73
|
```
|
|
56
74
|
|
|
57
75
|
The summary shows the Codex home, orchestrator, normal and Plan-mode reasoning
|
|
58
|
-
effort, routing status, and installed custom agents without
|
|
59
|
-
configuration.
|
|
76
|
+
effort, routing-file and routing-hook status, and installed custom agents without
|
|
77
|
+
dumping unrelated configuration.
|
|
60
78
|
|
|
61
79
|
Uninstall package-managed global files:
|
|
62
80
|
|
|
@@ -64,8 +82,8 @@ Uninstall package-managed global files:
|
|
|
64
82
|
pnpm dlx @iamdevlinph/codex-kit@latest global uninstall
|
|
65
83
|
```
|
|
66
84
|
|
|
67
|
-
|
|
68
|
-
|
|
85
|
+
Uninstall removes only codex-kit's handlers from `hooks.json` and preserves other
|
|
86
|
+
hooks. Modified managed files are preserved unless `--force` is supplied.
|
|
69
87
|
|
|
70
88
|
## Commands
|
|
71
89
|
|
|
@@ -175,7 +193,7 @@ commands do not add network latency or depend on registry availability.
|
|
|
175
193
|
## Requirements
|
|
176
194
|
|
|
177
195
|
- Node.js 20 or newer
|
|
178
|
-
- Codex with custom subagent support
|
|
196
|
+
- Codex with custom subagent and lifecycle-hook support
|
|
179
197
|
|
|
180
198
|
The published package has no runtime dependencies and uses only Node.js
|
|
181
199
|
standard-library modules.
|
|
@@ -189,3 +207,6 @@ redistribute the package beyond applicable law and npm's service terms.
|
|
|
189
207
|
|
|
190
208
|
- [Codex custom subagents](https://learn.chatgpt.com/docs/agent-configuration/subagents)
|
|
191
209
|
- [Codex `AGENTS.md` discovery](https://learn.chatgpt.com/docs/agent-configuration/agents-md)
|
|
210
|
+
- [Codex hooks](https://learn.chatgpt.com/docs/hooks)
|
|
211
|
+
- [Delegate Mode](https://github.com/Raylinkh/delegate-mode), whose short-lived
|
|
212
|
+
subagent allowance pattern informed codex-kit's write guard
|
|
@@ -1,36 +1,43 @@
|
|
|
1
1
|
# Subagent Routing
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
The root parent is the planner and orchestrator. Before acting on a substantive
|
|
4
|
+
task, classify the work using this file. Route by role and task shape, never by a
|
|
5
|
+
model name; each agent definition owns its model and reasoning effort.
|
|
6
6
|
|
|
7
|
-
The parent owns requirements, architecture, integration, and final
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Do not delegate trivial conversation, known-target edits limited to one or two
|
|
12
|
-
files, or straightforward commands. A slow command alone is not a reason to
|
|
13
|
-
delegate.
|
|
14
|
-
|
|
15
|
-
When delegation is worthwhile:
|
|
16
|
-
|
|
17
|
-
- Prefer one subagent. Add more only for independent, non-overlapping work.
|
|
18
|
-
- Give each agent a bounded task and request a concise, decision-ready report.
|
|
19
|
-
- For parallel implementation, assign explicit file or module ownership.
|
|
20
|
-
- Wait for relevant agents and synthesize their results in the parent task.
|
|
21
|
-
- Reuse prior exploration and evidence instead of repeating it.
|
|
7
|
+
The parent owns requirements, architecture, sequencing, integration, and final
|
|
8
|
+
validation. It may handle planning, conversation, status reporting, and small
|
|
9
|
+
read-only checks directly. When a route below matches, spawn that exact role
|
|
10
|
+
before performing the role's work. The user does not need to request delegation.
|
|
22
11
|
|
|
23
12
|
Select custom agents by exact name:
|
|
24
13
|
|
|
25
|
-
- Broad repository discovery or
|
|
26
|
-
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
14
|
+
- Broad repository discovery, contract tracing, or search across many files:
|
|
15
|
+
`code-explorer`
|
|
16
|
+
- Mechanical, well-specified implementation localized to one or two files:
|
|
17
|
+
`quick-implementer`
|
|
18
|
+
- Multi-file behavior changes, debugging, migrations, or substantial tests:
|
|
19
|
+
`implementer`
|
|
20
|
+
- Independent review of security-sensitive, architectural, public-API,
|
|
21
|
+
concurrency, migration, or otherwise difficult-to-validate changes:
|
|
22
|
+
`code-reviewer`
|
|
23
|
+
|
|
24
|
+
For tasks with multiple phases, sequence only the roles that add value. For
|
|
25
|
+
example, use `code-explorer` before implementation only when broad discovery is
|
|
26
|
+
actually needed, and use `code-reviewer` after implementation only when the
|
|
27
|
+
change meets its risk threshold. Avoid parallel write-heavy work and never assign
|
|
28
|
+
overlapping files to multiple agents.
|
|
29
|
+
|
|
30
|
+
An assigned implementation agent performs its edits directly and must not
|
|
31
|
+
delegate them again. Use either `quick-implementer` or `implementer` for one
|
|
32
|
+
implementation slice, not both. Read-only agents never modify files.
|
|
33
|
+
|
|
34
|
+
For every delegation:
|
|
35
|
+
|
|
36
|
+
- Give the agent a bounded task and the relevant requirements.
|
|
37
|
+
- Request a concise, decision-ready result.
|
|
38
|
+
- Wait for the result before integrating or validating dependent work.
|
|
39
|
+
- Reuse returned evidence instead of repeating the same exploration.
|
|
33
40
|
|
|
34
41
|
There is no commit-pusher role. Never delegate Git publishing. Do not stage,
|
|
35
|
-
commit, or push unless the user explicitly requests that operation in the
|
|
36
|
-
task; if requested, the parent handles it after review.
|
|
42
|
+
commit, tag, or push unless the user explicitly requests that operation in the
|
|
43
|
+
current task; if requested, the parent handles it after review.
|
package/bin/codex-kit.js
CHANGED
|
@@ -10,6 +10,7 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
10
10
|
const ASSETS = join(ROOT, "assets");
|
|
11
11
|
const AGENTS_DIR = join(ASSETS, "agents");
|
|
12
12
|
const ROUTING_FILE = join(ASSETS, "SUBAGENT_ROUTING.md");
|
|
13
|
+
const ROUTING_HOOK_FILE = join(ROOT, "bin", "routing-hook.js");
|
|
13
14
|
const TEMPLATE_FILE = join(ASSETS, "TEMPLATE_AGENTS.md");
|
|
14
15
|
const PACKAGE = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
|
|
15
16
|
const GLOBAL_BEGIN = "<!-- BEGIN codex-kit:subagent-routing -->";
|
|
@@ -44,6 +45,116 @@ function write(file, data) {
|
|
|
44
45
|
writeFileSync(temporary, data);
|
|
45
46
|
renameSync(temporary, file);
|
|
46
47
|
}
|
|
48
|
+
function readJsonObject(file) {
|
|
49
|
+
if (!existsSync(file))
|
|
50
|
+
return {};
|
|
51
|
+
try {
|
|
52
|
+
const value = JSON.parse(readText(file));
|
|
53
|
+
if (isRecord(value))
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Use the actionable error below for invalid JSON and non-object roots.
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`${file} must contain a JSON object; fix or move it before installing.`);
|
|
60
|
+
}
|
|
61
|
+
function shellQuote(value) {
|
|
62
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
63
|
+
}
|
|
64
|
+
function hookCommands(file) {
|
|
65
|
+
return {
|
|
66
|
+
command: `/usr/bin/env node ${shellQuote(file)}`,
|
|
67
|
+
commandWindows: `node ${JSON.stringify(file)}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function removeHookHandlers(root, state) {
|
|
71
|
+
const hooks = root.hooks;
|
|
72
|
+
if (!isRecord(hooks))
|
|
73
|
+
return;
|
|
74
|
+
for (const [event, groupsValue] of Object.entries(hooks)) {
|
|
75
|
+
if (!Array.isArray(groupsValue))
|
|
76
|
+
continue;
|
|
77
|
+
const groups = groupsValue.flatMap((groupValue) => {
|
|
78
|
+
if (!isRecord(groupValue) || !Array.isArray(groupValue.hooks))
|
|
79
|
+
return [groupValue];
|
|
80
|
+
const handlers = groupValue.hooks.filter((handler) => {
|
|
81
|
+
if (!isRecord(handler))
|
|
82
|
+
return true;
|
|
83
|
+
return handler.command !== state.command && handler.commandWindows !== state.commandWindows;
|
|
84
|
+
});
|
|
85
|
+
return handlers.length ? [{ ...groupValue, hooks: handlers }] : [];
|
|
86
|
+
});
|
|
87
|
+
if (groups.length)
|
|
88
|
+
hooks[event] = groups;
|
|
89
|
+
else
|
|
90
|
+
delete hooks[event];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function installRoutingHooks(home, previous) {
|
|
94
|
+
const target = join(home, "hooks.json");
|
|
95
|
+
const hookFile = join(home, "codex-kit", "routing-hook.js");
|
|
96
|
+
const commands = hookCommands(hookFile);
|
|
97
|
+
const created = previous?.created ?? !existsSync(target);
|
|
98
|
+
const root = readJsonObject(target);
|
|
99
|
+
if (previous)
|
|
100
|
+
removeHookHandlers(root, previous);
|
|
101
|
+
const hooks = isRecord(root.hooks) ? root.hooks : {};
|
|
102
|
+
root.hooks = hooks;
|
|
103
|
+
const handler = {
|
|
104
|
+
type: "command",
|
|
105
|
+
command: commands.command,
|
|
106
|
+
commandWindows: commands.commandWindows,
|
|
107
|
+
timeout: 5,
|
|
108
|
+
};
|
|
109
|
+
const promptGroups = Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : [];
|
|
110
|
+
const toolGroups = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
111
|
+
hooks.UserPromptSubmit = [
|
|
112
|
+
...promptGroups,
|
|
113
|
+
{ hooks: [{ ...handler, statusMessage: "Loading subagent routing" }] },
|
|
114
|
+
];
|
|
115
|
+
const startGroups = Array.isArray(hooks.SubagentStart) ? hooks.SubagentStart : [];
|
|
116
|
+
hooks.SubagentStart = [
|
|
117
|
+
...startGroups,
|
|
118
|
+
{ hooks: [{ ...handler, statusMessage: "Opening delegated write lane" }] },
|
|
119
|
+
];
|
|
120
|
+
const stopGroups = Array.isArray(hooks.SubagentStop) ? hooks.SubagentStop : [];
|
|
121
|
+
hooks.SubagentStop = [
|
|
122
|
+
...stopGroups,
|
|
123
|
+
{ hooks: [{ ...handler, statusMessage: "Closing delegated write lane" }] },
|
|
124
|
+
];
|
|
125
|
+
hooks.PreToolUse = [
|
|
126
|
+
...toolGroups,
|
|
127
|
+
{
|
|
128
|
+
matcher: "Bash|Edit|Write|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions.apply_patch",
|
|
129
|
+
hooks: [{ ...handler, statusMessage: "Checking orchestrator routing" }],
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
const updated = `${JSON.stringify(root, null, 2)}\n`;
|
|
133
|
+
const original = existsSync(target) ? readText(target) : "";
|
|
134
|
+
if (updated !== original) {
|
|
135
|
+
backup(target);
|
|
136
|
+
write(target, updated);
|
|
137
|
+
console.log(`updated: ${target}`);
|
|
138
|
+
}
|
|
139
|
+
return { target, ...commands, created };
|
|
140
|
+
}
|
|
141
|
+
function uninstallRoutingHooks(state) {
|
|
142
|
+
if (!existsSync(state.target)) {
|
|
143
|
+
console.warn(`preserved missing hooks file: ${state.target}`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const root = readJsonObject(state.target);
|
|
147
|
+
removeHookHandlers(root, state);
|
|
148
|
+
const hooks = root.hooks;
|
|
149
|
+
if (isRecord(hooks) && !Object.keys(hooks).length)
|
|
150
|
+
delete root.hooks;
|
|
151
|
+
backup(state.target);
|
|
152
|
+
if (state.created && !Object.keys(root).length)
|
|
153
|
+
rmSync(state.target);
|
|
154
|
+
else
|
|
155
|
+
write(state.target, `${JSON.stringify(root, null, 2)}\n`);
|
|
156
|
+
console.log(`removed managed routing hooks from: ${state.target}`);
|
|
157
|
+
}
|
|
47
158
|
function loadState(home) {
|
|
48
159
|
const file = join(home, STATE_FILE);
|
|
49
160
|
if (!existsSync(file))
|
|
@@ -234,6 +345,7 @@ function installGlobal(options) {
|
|
|
234
345
|
version: PACKAGE.version,
|
|
235
346
|
files: {},
|
|
236
347
|
globalAgents: null,
|
|
348
|
+
hooks: null,
|
|
237
349
|
config: prior.config ?? null,
|
|
238
350
|
};
|
|
239
351
|
for (const name of readdirSync(AGENTS_DIR).filter((name) => name.endsWith(".toml")).sort()) {
|
|
@@ -245,6 +357,9 @@ function installGlobal(options) {
|
|
|
245
357
|
const routingRecord = installFile(ROUTING_FILE, join(home, "SUBAGENT_ROUTING.md"), "routing", prior, options.force);
|
|
246
358
|
if (routingRecord)
|
|
247
359
|
next.files.routing = routingRecord;
|
|
360
|
+
const hookRecord = installFile(ROUTING_HOOK_FILE, join(home, "codex-kit", "routing-hook.js"), "routing-hook", prior, options.force);
|
|
361
|
+
if (hookRecord)
|
|
362
|
+
next.files["routing-hook"] = hookRecord;
|
|
248
363
|
for (const [key, record] of Object.entries(prior.files)) {
|
|
249
364
|
if (key in next.files)
|
|
250
365
|
continue;
|
|
@@ -272,6 +387,7 @@ function installGlobal(options) {
|
|
|
272
387
|
console.log(`updated: ${globalAgents}`);
|
|
273
388
|
}
|
|
274
389
|
next.globalAgents = { target: globalAgents };
|
|
390
|
+
next.hooks = installRoutingHooks(home, prior.hooks);
|
|
275
391
|
saveState(home, next);
|
|
276
392
|
const commitPusher = join(home, "agents", "commit-pusher.toml");
|
|
277
393
|
if (existsSync(commitPusher)) {
|
|
@@ -360,6 +476,11 @@ function listGlobal(options) {
|
|
|
360
476
|
const hasRoutingBlock = existsSync(globalAgents) && readText(globalAgents).includes(GLOBAL_BEGIN);
|
|
361
477
|
console.log(`Global routing: ${hasRoutingBlock ? "installed" : "not installed"}`);
|
|
362
478
|
console.log(`Routing file: ${existsSync(routingFile) ? routingFile : "missing"}`);
|
|
479
|
+
const routingHook = state.hooks;
|
|
480
|
+
const hooksInstalled = Boolean(routingHook &&
|
|
481
|
+
existsSync(routingHook.target) &&
|
|
482
|
+
readText(routingHook.target).includes(routingHook.command));
|
|
483
|
+
console.log(`Routing hook: ${hooksInstalled ? "installed" : "not installed"}`);
|
|
363
484
|
console.log("Custom agents:");
|
|
364
485
|
const agents = existsSync(agentsDir)
|
|
365
486
|
? readdirSync(agentsDir).filter((name) => name.endsWith(".toml")).sort()
|
|
@@ -441,6 +562,14 @@ function uninstallGlobal(options) {
|
|
|
441
562
|
}
|
|
442
563
|
}
|
|
443
564
|
}
|
|
565
|
+
if (state.hooks)
|
|
566
|
+
uninstallRoutingHooks(state.hooks);
|
|
567
|
+
const allowancesDir = join(home, "codex-kit", "allowances");
|
|
568
|
+
if (existsSync(allowancesDir))
|
|
569
|
+
rmSync(allowancesDir, { recursive: true, force: true });
|
|
570
|
+
const kitDir = join(home, "codex-kit");
|
|
571
|
+
if (existsSync(kitDir) && !readdirSync(kitDir).length)
|
|
572
|
+
rmSync(kitDir, { recursive: true });
|
|
444
573
|
rmSync(statePath);
|
|
445
574
|
console.log(`Codex kit uninstalled from ${home}`);
|
|
446
575
|
}
|
|
@@ -647,22 +776,30 @@ function help() {
|
|
|
647
776
|
console.log(`codex-kit ${PACKAGE.version}
|
|
648
777
|
|
|
649
778
|
Usage:
|
|
650
|
-
codex-kit
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
779
|
+
codex-kit <command> [options]
|
|
780
|
+
|
|
781
|
+
Commands:
|
|
782
|
+
global install Install or update package-owned agents and routing guidance.
|
|
783
|
+
global configure Set the orchestrator and normal/Plan reasoning defaults.
|
|
784
|
+
global list Show model settings, routing status, and custom agents.
|
|
785
|
+
global uninstall Restore managed config values and remove package-owned files.
|
|
786
|
+
project init Initialize AGENTS.md, TEMPLATE_AGENTS.md, and project state.
|
|
787
|
+
project sync Refresh TEMPLATE_AGENTS.md without editing AGENTS.md.
|
|
788
|
+
project status Show whether template changes still need reconciliation.
|
|
789
|
+
project mark-applied Record the current template as reconciled with AGENTS.md.
|
|
790
|
+
version check Compare the installed version with the latest npm release.
|
|
791
|
+
--help Show this help.
|
|
792
|
+
--version Print the installed version.
|
|
793
|
+
|
|
794
|
+
Options:
|
|
795
|
+
--codex-home PATH Use a Codex home other than CODEX_HOME or ~/.codex.
|
|
796
|
+
--cwd PATH Use a project directory other than the current directory.
|
|
797
|
+
--force Replace modified files or config managed by codex-kit.
|
|
798
|
+
--orchestrator MODEL Set the root/orchestrator model (default: gpt-5.6-sol).
|
|
799
|
+
--model MODEL Alias for --orchestrator.
|
|
800
|
+
--reasoning-effort LEVEL Set normal and Plan-mode effort (default: high).
|
|
661
801
|
|
|
662
|
-
|
|
663
|
-
and edits only those top-level model settings in CODEX_HOME/config.toml.
|
|
664
|
-
Global install manages custom agents and a marked routing block under CODEX_HOME.
|
|
665
|
-
Project sync refreshes TEMPLATE_AGENTS.md and never merges it into AGENTS.md.`);
|
|
802
|
+
Run a command with only its applicable options.`);
|
|
666
803
|
}
|
|
667
804
|
export function main(argv = process.argv.slice(2)) {
|
|
668
805
|
const options = parse(argv);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const home = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
|
+
const routingFile = join(home, "SUBAGENT_ROUTING.md");
|
|
8
|
+
const allowancesDir = join(home, "codex-kit", "allowances");
|
|
9
|
+
const allowanceTtlMs = 15 * 60 * 1000;
|
|
10
|
+
const writeTools = new Set([
|
|
11
|
+
"Edit",
|
|
12
|
+
"Write",
|
|
13
|
+
"MultiEdit",
|
|
14
|
+
"NotebookEdit",
|
|
15
|
+
"apply_patch",
|
|
16
|
+
"ApplyPatch",
|
|
17
|
+
"functions.apply_patch",
|
|
18
|
+
]);
|
|
19
|
+
const writeCommands = new Set([
|
|
20
|
+
"chmod",
|
|
21
|
+
"chown",
|
|
22
|
+
"cp",
|
|
23
|
+
"dd",
|
|
24
|
+
"install",
|
|
25
|
+
"ln",
|
|
26
|
+
"mkdir",
|
|
27
|
+
"mv",
|
|
28
|
+
"rm",
|
|
29
|
+
"rmdir",
|
|
30
|
+
"rsync",
|
|
31
|
+
"tee",
|
|
32
|
+
"touch",
|
|
33
|
+
"truncate",
|
|
34
|
+
]);
|
|
35
|
+
const gitWriteCommands = new Set([
|
|
36
|
+
"add",
|
|
37
|
+
"am",
|
|
38
|
+
"apply",
|
|
39
|
+
"checkout",
|
|
40
|
+
"cherry-pick",
|
|
41
|
+
"clean",
|
|
42
|
+
"commit",
|
|
43
|
+
"merge",
|
|
44
|
+
"mv",
|
|
45
|
+
"rebase",
|
|
46
|
+
"reset",
|
|
47
|
+
"restore",
|
|
48
|
+
"revert",
|
|
49
|
+
"rm",
|
|
50
|
+
"stash",
|
|
51
|
+
"switch",
|
|
52
|
+
]);
|
|
53
|
+
const input = JSON.parse(readFileSync(0, "utf8"));
|
|
54
|
+
function allowanceFile(payload) {
|
|
55
|
+
if (!payload.session_id || !payload.turn_id)
|
|
56
|
+
return null;
|
|
57
|
+
const key = createHash("sha256")
|
|
58
|
+
.update(`${payload.session_id}\0${payload.turn_id}`)
|
|
59
|
+
.digest("hex");
|
|
60
|
+
return join(allowancesDir, `${key}.json`);
|
|
61
|
+
}
|
|
62
|
+
function writeAllowance(payload) {
|
|
63
|
+
const file = allowanceFile(payload);
|
|
64
|
+
if (!file)
|
|
65
|
+
return false;
|
|
66
|
+
mkdirSync(allowancesDir, { recursive: true });
|
|
67
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
68
|
+
writeFileSync(temporary, JSON.stringify({
|
|
69
|
+
agentId: payload.agent_id,
|
|
70
|
+
agentType: payload.agent_type,
|
|
71
|
+
expiresAt: Date.now() + allowanceTtlMs,
|
|
72
|
+
}));
|
|
73
|
+
renameSync(temporary, file);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
function removeAllowance(payload) {
|
|
77
|
+
const file = allowanceFile(payload);
|
|
78
|
+
if (file)
|
|
79
|
+
rmSync(file, { force: true });
|
|
80
|
+
}
|
|
81
|
+
function hasAllowance(payload) {
|
|
82
|
+
const file = allowanceFile(payload);
|
|
83
|
+
if (!file || !existsSync(file))
|
|
84
|
+
return false;
|
|
85
|
+
try {
|
|
86
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
87
|
+
if (typeof value.expiresAt === "number" && value.expiresAt > Date.now())
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Invalid allowance files fail closed.
|
|
92
|
+
}
|
|
93
|
+
rmSync(file, { force: true });
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
function pruneAllowances() {
|
|
97
|
+
if (!existsSync(allowancesDir))
|
|
98
|
+
return;
|
|
99
|
+
for (const name of readdirSync(allowancesDir)) {
|
|
100
|
+
if (!name.endsWith(".json"))
|
|
101
|
+
continue;
|
|
102
|
+
const file = join(allowancesDir, name);
|
|
103
|
+
try {
|
|
104
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
105
|
+
if (typeof value.expiresAt === "number" && value.expiresAt > Date.now())
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Invalid allowance files are stale.
|
|
110
|
+
}
|
|
111
|
+
rmSync(file, { force: true });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function isWriteShapedBash(command) {
|
|
115
|
+
if (/(^|[^<])(?:>>?|&>>?|\d>>?)\s*[^&]/.test(command))
|
|
116
|
+
return true;
|
|
117
|
+
const commands = command.split(/(?:&&|\|\||;|\|)/).map((part) => part.trim()).filter(Boolean);
|
|
118
|
+
return commands.some((segment) => {
|
|
119
|
+
const tokens = segment.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
|
|
120
|
+
const executable = tokens[0]?.split("/").at(-1);
|
|
121
|
+
if (!executable)
|
|
122
|
+
return false;
|
|
123
|
+
if (writeCommands.has(executable))
|
|
124
|
+
return true;
|
|
125
|
+
if ((executable === "sed" || executable === "perl") && tokens.slice(1, 5).some((token) => /^-[^-]*i/.test(token)))
|
|
126
|
+
return true;
|
|
127
|
+
return executable === "git" && Boolean(tokens[1] && gitWriteCommands.has(tokens[1]));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function hookContext(event, context) {
|
|
131
|
+
return JSON.stringify({
|
|
132
|
+
hookSpecificOutput: {
|
|
133
|
+
hookEventName: event,
|
|
134
|
+
additionalContext: context,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (input.hook_event_name === "UserPromptSubmit" && existsSync(routingFile)) {
|
|
139
|
+
const routing = readFileSync(routingFile, "utf8").trim();
|
|
140
|
+
process.stdout.write(`Codex-kit routing policy for this turn:\n\n${routing}\n\n` +
|
|
141
|
+
"Classify the task using this policy before acting. When it requires delegation, " +
|
|
142
|
+
"spawn the exact named role before doing that role's work. Agent definitions, not " +
|
|
143
|
+
"this policy, determine each role's model and reasoning effort.");
|
|
144
|
+
}
|
|
145
|
+
if (input.hook_event_name === "SubagentStart") {
|
|
146
|
+
pruneAllowances();
|
|
147
|
+
const recorded = writeAllowance(input);
|
|
148
|
+
process.stdout.write(hookContext("SubagentStart", `You are the delegated ${input.agent_type ?? "worker"}. ` +
|
|
149
|
+
`${recorded ? "A temporary write lane is active." : "No write lane could be recorded."} ` +
|
|
150
|
+
"Follow the assigned scope, perform the role's work directly without further delegation, validate it, and return concise evidence."));
|
|
151
|
+
}
|
|
152
|
+
if (input.hook_event_name === "SubagentStop")
|
|
153
|
+
removeAllowance(input);
|
|
154
|
+
if (input.hook_event_name === "PreToolUse") {
|
|
155
|
+
const writeAttempt = writeTools.has(input.tool_name ?? "") ||
|
|
156
|
+
(input.tool_name === "Bash" && isWriteShapedBash(input.tool_input?.command ?? ""));
|
|
157
|
+
if (writeAttempt && !input.agent_id && !hasAllowance(input)) {
|
|
158
|
+
process.stdout.write(JSON.stringify({
|
|
159
|
+
hookSpecificOutput: {
|
|
160
|
+
hookEventName: "PreToolUse",
|
|
161
|
+
permissionDecision: "deny",
|
|
162
|
+
permissionDecisionReason: "The root orchestrator may not write directly. Consult SUBAGENT_ROUTING.md and delegate to the exact role selected there.",
|
|
163
|
+
},
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
}
|