@ethlete/agent-rules 0.1.0-next.13 → 0.1.0-next.15

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +36 -2
  3. package/THIRD-PARTY-LICENSES.md +35 -0
  4. package/content/hooks/context-warning.py +450 -112
  5. package/content/hooks/subagent-model-policy.py +185 -0
  6. package/content/rules/subagent-models.md +29 -0
  7. package/content/skills/codex-subagent/SKILL.md +90 -0
  8. package/content/skills/codex-subagent/codex-agent.mjs +229 -0
  9. package/content/skills/design-exploration/SKILL.md +203 -0
  10. package/content/skills/design-exploration/check-story.mjs +139 -0
  11. package/content/skills/design-exploration/shoot-template.mjs +61 -0
  12. package/content/skills/domain-modeling/ADR-FORMAT.md +47 -0
  13. package/content/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  14. package/content/skills/domain-modeling/SKILL.md +80 -0
  15. package/content/skills/grill-with-docs/SKILL.md +13 -0
  16. package/content/skills/grilling/SKILL.md +34 -0
  17. package/content/skills/handoff/SKILL.md +69 -4
  18. package/content/skills/sdk-update/SKILL.md +89 -0
  19. package/content/skills/timetrack/SKILL.md +190 -10
  20. package/package.json +1 -1
  21. package/src/index.js +2 -1
  22. package/src/index.js.map +1 -1
  23. package/src/lib/frontmatter.d.ts +2 -0
  24. package/src/lib/frontmatter.js +2 -1
  25. package/src/lib/frontmatter.js.map +1 -1
  26. package/src/lib/git-flow/config.js +1 -1
  27. package/src/lib/git-flow/config.js.map +1 -1
  28. package/src/lib/git.d.ts +37 -0
  29. package/src/lib/git.js +77 -1
  30. package/src/lib/git.js.map +1 -1
  31. package/src/lib/index.d.ts +1 -0
  32. package/src/lib/index.js +1 -0
  33. package/src/lib/index.js.map +1 -1
  34. package/src/lib/plain-text.d.ts +9 -0
  35. package/src/lib/plain-text.js +22 -0
  36. package/src/lib/plain-text.js.map +1 -0
  37. package/src/lib/targets/claude-hooks.js +2 -1
  38. package/src/lib/targets/claude-hooks.js.map +1 -1
  39. package/src/lib/targets/codex-hooks.js +2 -1
  40. package/src/lib/targets/codex-hooks.js.map +1 -1
  41. package/src/lib/targets/hooks-shared.d.ts +22 -1
  42. package/src/lib/targets/hooks-shared.js +42 -9
  43. package/src/lib/targets/hooks-shared.js.map +1 -1
  44. package/src/lib/targets/shared.js +1 -0
  45. package/src/lib/targets/shared.js.map +1 -1
  46. package/src/lib/timetrack-command.js +449 -20
  47. package/src/lib/timetrack-command.js.map +1 -1
  48. package/src/lib/timetrack.d.ts +241 -0
  49. package/src/lib/timetrack.js +83 -2
  50. package/src/lib/timetrack.js.map +1 -1
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: make a subagent's model an explicit choice.
3
+
4
+ A `Task` call that passes no `model` runs the subagent on the parent session's
5
+ model. A session led by an expensive model therefore spends that model on every
6
+ grep and every file read it delegates, which burns a usage limit in minutes for
7
+ work a small model does just as well.
8
+
9
+ So this hook gates the tool call rather than only advising:
10
+
11
+ * No `model` at all - denied, with the model table returned to the agent. The
12
+ agent then repeats the call with a model, so the cost of the mistake is one
13
+ tool call, not a whole subagent run.
14
+ * `model: fable` - the user is asked. Fable is the right pick for planning,
15
+ design, cross-cutting review and leading other subagents, and the wrong pick
16
+ for the twentieth mechanical lookup; only the user knows which this is.
17
+ * Any other model - allowed silently.
18
+
19
+ Two cases are left alone, because the tool's `model` would change nothing:
20
+
21
+ * `subagent_type: fork`, which always inherits the parent model.
22
+ * An agent type whose own definition under `.claude/agents/` sets a model.
23
+
24
+ Can be disabled per machine via a gitignored ethlete-agents.config.local.json at
25
+ the repo root: {"disableHooks": true} or {"disableHooks": ["subagent-model-policy"]}.
26
+
27
+ Fail-safe: any error exits 0 with no output - the hook must never block work
28
+ because it could not read its own input.
29
+ """
30
+
31
+ import json
32
+ import os
33
+ import sys
34
+
35
+ HOOK_NAME = "subagent-model-policy"
36
+ LOCAL_CONFIG_FILE = "ethlete-agents.config.local.json"
37
+
38
+ # The tool that spawns a subagent, under both names it has carried.
39
+ SUBAGENT_TOOLS = ("Task", "Agent")
40
+
41
+ POLICY = """A subagent's model is a choice, not an inheritance. This call passes no `model`, so the subagent \
42
+ would run on the model leading this session - which is how one expensive model ends up doing every small job. \
43
+ Call the tool again with `model` set:
44
+
45
+ - `haiku` - mechanical lookups: grep, find, read a file, run a command and report what it said.
46
+ - `opus` - the default for real work: code changes, tests, debugging, reviewing a diff.
47
+ - `sonnet` - a middle ground where opus is more than the task needs.
48
+ - `fable` - judgment-heavy work: planning, design, cross-cutting review, leading other subagents. The most \
49
+ expensive of the four, so choosing it asks the user first.
50
+
51
+ Effort follows the prompt, not a parameter: scope the prompt to one question, name what "done" is, and say \
52
+ "keep it brief" for a lookup. An agent type defined under `.claude/agents/` carries its own model and reasoning \
53
+ effort, so a call that names one needs no `model` of its own."""
54
+
55
+ FABLE_REASON = """This subagent would run on fable, the most expensive model. Approve it where the task is \
56
+ judgment-heavy - planning, design, cross-cutting review, or leading other subagents. Deny it for implementation \
57
+ (`model: "opus"`) or for a mechanical lookup (`model: "haiku"`), then repeat the call with that model."""
58
+
59
+
60
+ def agent_name(argv):
61
+ """The --agent value, defaulting to claude — older registrations pass no flag."""
62
+ for index, arg in enumerate(argv):
63
+ if arg == "--agent" and index + 1 < len(argv):
64
+ return argv[index + 1]
65
+ if arg.startswith("--agent="):
66
+ return arg.split("=", 1)[1]
67
+ return "claude"
68
+
69
+
70
+ def repo_root(data):
71
+ """Repo root: the env var the agent sets, else the script's own location, else the cwd.
72
+
73
+ The script always lives at <root>/.<agent>/hooks/ethlete/subagent-model-policy.py, so
74
+ walking four levels up works for any agent that has no project-dir variable of its own.
75
+ """
76
+ for variable in ("CLAUDE_PROJECT_DIR", "CODEX_PROJECT_DIR"):
77
+ value = os.environ.get(variable)
78
+ if value:
79
+ return value
80
+ here = os.path.abspath(__file__)
81
+ derived = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(here))))
82
+ if os.path.isfile(os.path.join(derived, LOCAL_CONFIG_FILE)):
83
+ return derived
84
+ cwd = data.get("cwd")
85
+ return cwd if isinstance(cwd, str) and cwd else derived
86
+
87
+
88
+ def load_local_config(root):
89
+ """Parsed ethlete-agents.config.local.json at the repo root, or {} if missing/unreadable."""
90
+ if not root:
91
+ return {}
92
+ try:
93
+ with open(os.path.join(root, LOCAL_CONFIG_FILE), encoding="utf-8") as f:
94
+ config = json.load(f)
95
+ except (OSError, ValueError, AttributeError):
96
+ return {}
97
+ return config if isinstance(config, dict) else {}
98
+
99
+
100
+ def disabled_locally(config):
101
+ """True when the local config disables this hook (or all hooks) on this machine."""
102
+ disabled = config.get("disableHooks")
103
+ return disabled is True or (isinstance(disabled, list) and HOOK_NAME in disabled)
104
+
105
+
106
+ def definition_sets_model(root, subagent_type):
107
+ """True when a project or user agent definition of that name declares its own model."""
108
+ if not subagent_type:
109
+ return False
110
+ file_name = f"{subagent_type.split(':')[-1]}.md"
111
+ candidates = [
112
+ os.path.join(root or ".", ".claude", "agents", file_name),
113
+ os.path.join(os.path.expanduser("~"), ".claude", "agents", file_name),
114
+ ]
115
+ for path in candidates:
116
+ try:
117
+ with open(path, encoding="utf-8") as f:
118
+ head = f.read(4096)
119
+ except OSError:
120
+ continue
121
+ for line in head.split("\n"):
122
+ key, separator, value = line.partition(":")
123
+ if separator and key.strip() == "model" and value.strip():
124
+ return True
125
+ return False
126
+
127
+
128
+ def decision_for(tool_input, root):
129
+ """The (decision, reason) this call needs, or None when it may run untouched."""
130
+ subagent_type = str(tool_input.get("subagent_type") or "").strip().lower()
131
+ model = str(tool_input.get("model") or "").strip().lower()
132
+
133
+ if "fable" in model:
134
+ return "ask", FABLE_REASON
135
+ if model:
136
+ return None
137
+ if subagent_type == "fork" or definition_sets_model(root, subagent_type):
138
+ return None
139
+ return "deny", POLICY
140
+
141
+
142
+ def main():
143
+ if agent_name(sys.argv[1:]) != "claude":
144
+ return
145
+
146
+ data = json.load(sys.stdin)
147
+
148
+ if data.get("tool_name") not in SUBAGENT_TOOLS:
149
+ return
150
+
151
+ tool_input = data.get("tool_input")
152
+
153
+ if not isinstance(tool_input, dict):
154
+ return
155
+
156
+ root = repo_root(data)
157
+
158
+ if disabled_locally(load_local_config(root)):
159
+ return
160
+
161
+ outcome = decision_for(tool_input, root)
162
+
163
+ if not outcome:
164
+ return
165
+
166
+ decision, reason = outcome
167
+ print(
168
+ json.dumps(
169
+ {
170
+ "hookSpecificOutput": {
171
+ "hookEventName": "PreToolUse",
172
+ "permissionDecision": decision,
173
+ "permissionDecisionReason": reason,
174
+ }
175
+ }
176
+ )
177
+ )
178
+
179
+
180
+ if __name__ == "__main__":
181
+ try:
182
+ main()
183
+ except Exception:
184
+ pass
185
+ sys.exit(0)
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: subagent-models
3
+ description: A subagent's model is an explicit choice on every call - haiku for lookups, opus for work, fable for judgment.
4
+ kind: rule
5
+ scope: both
6
+ ---
7
+
8
+ ## Delegating: name the subagent's model
9
+
10
+ A subagent spawned without a `model` runs on the model leading the session, so an expensive
11
+ model ends up doing every small job it delegates. **Set `model` on every call**, matched to the
12
+ task:
13
+
14
+ | Model | The work it fits |
15
+ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
16
+ | `haiku` | Mechanical lookups: grep, find, read a file, run a command and report what it said. |
17
+ | `sonnet` | A middle ground where opus is more than the task needs. |
18
+ | `opus` | The default for real work: code changes, tests, debugging, reviewing a diff. |
19
+ | `fable` | Judgment-heavy work: planning, design, cross-cutting review, leading other subagents. The most expensive of the four, so ask the user before picking it. |
20
+
21
+ Effort follows the prompt, not a parameter: scope the prompt to one question, name what "done"
22
+ means, and say "keep it brief" for a lookup. Two calls need no `model` of their own - a named
23
+ agent type carries the model and reasoning effort its own definition sets, and a fork always
24
+ inherits the parent's model.
25
+
26
+ A subagent does not have to be a Claude one. Delegate to a Codex agent where another model
27
+ family would answer better - a second opinion, a review of your own diff, a bug hunt your own
28
+ reading failed - and whenever the Claude usage limit is close, because a Codex run does not draw
29
+ on it. Read {%skill:codex-subagent%} first.
@@ -0,0 +1,90 @@
1
+ ---
2
+ name: codex-subagent
3
+ description: Delegate a task to a Codex agent (gpt-5.6-luna, gpt-5.6-sol, gpt-5.6-terra, gpt-6-astra) instead of a Claude subagent - a second opinion from another model family, a review, or a cheap parallel lookup. Read before spawning a subagent when another model would answer better, and whenever the user says codex, GPT, or "second opinion".
4
+ kind: skill
5
+ scope: both
6
+ ---
7
+
8
+ # Codex as a subagent
9
+
10
+ `codex exec` runs one Codex agent to completion without a terminal. It is a subagent with its
11
+ own context, its own tools and its own model family. It reads the repository's `AGENTS.md` by
12
+ itself, so it starts with the same rules you have.
13
+
14
+ Use it when the task gains from a model that is not Claude: a second opinion on a design, a
15
+ review of your own diff, a bug hunt that your reading already failed, or work you want to run
16
+ beside your own. Use it also when the Claude usage limit is close, because a Codex run does not
17
+ draw on it - move the delegatable work there and keep the limit for the work you lead. For
18
+ everything else, a Claude subagent stays the cheaper choice.
19
+
20
+ ## The call
21
+
22
+ The wrapper is {%resource:codex-agent.mjs%}. It keeps the Codex trace in a log file and prints
23
+ only the final message, so one run costs you a few hundred tokens instead of the whole trace.
24
+ Below, `<skill-dir>` is the directory this file sits in.
25
+
26
+ ```bash
27
+ node <skill-dir>/codex-agent.mjs -m gpt-5.6-terra -e medium "<prompt>"
28
+ ```
29
+
30
+ Options: `-m` model, `-e` effort (`low`, `medium`, `high`, `xhigh`), `-C` working root,
31
+ `--write`, `--resume <thread>`, `--prompt-file <file>`, `--schema <json-schema-file>`,
32
+ `--timeout <seconds>`, `--trace`. Run it with `--help` for the full list.
33
+
34
+ The run prints a footer with the model, the sandbox, the time, the output tokens, the resume
35
+ id and the log path. Read the log only when a run fails.
36
+
37
+ ## Pick the model
38
+
39
+ | Model | The work it fits |
40
+ | --------------- | ---------------------------------------------------------------------------------------------------------------- |
41
+ | `gpt-5.6-luna` | Fast and cheap: a lookup, a grep, a question about one file. |
42
+ | `gpt-5.6-terra` | Balanced agentic coding. The default for real work, and the wrapper's default. |
43
+ | `gpt-5.6-sol` | A reliable workhorse for a straight task. It costs about what `fable` costs, so ask the user before you pick it. |
44
+ | `gpt-6-astra` | Complex, demanding work. The most expensive of the four, so ask the user before you pick it. |
45
+
46
+ Effort follows both the flag and the prompt. Pass `-e low` for a lookup and `-e high` for a
47
+ hard bug. Say "keep it brief" in the prompt when you want a short answer.
48
+
49
+ ## Read-only by default
50
+
51
+ A run without `--write` gets the `read-only` sandbox. Codex can read and run commands, but it
52
+ cannot change a file. This is the right mode for a review, a second opinion and research.
53
+
54
+ Pass `--write` only when the user asked for a code change from Codex. Two rules hold then:
55
+
56
+ 1. **Codex must not commit, push, or run `git add`.** Say so in the prompt. Other sessions
57
+ share this checkout, so a broad stage takes work that is not yours.
58
+ 2. **Name the files it may touch.** An open brief in a shared checkout is how two agents edit
59
+ the same file.
60
+
61
+ `--resume` carries the sandbox and the working root of the thread it resumes. A thread that
62
+ started read-only stays read-only, so start a new run when you need write access.
63
+
64
+ ## Run several at once
65
+
66
+ The wrapper blocks until the agent is done, which takes minutes for real work. Start each run
67
+ as a background command, and collect the answers when they arrive. Give each run its own
68
+ question. Two agents on one question waste a model.
69
+
70
+ ## Write the prompt for a stranger
71
+
72
+ The Codex agent shares no context with you. It sees your prompt, the repository and
73
+ `AGENTS.md`. So:
74
+
75
+ - State the task as one question, and name what "done" means.
76
+ - Give the file paths you already know. It costs a model minutes to find what you can name.
77
+ - Ask for the shape of the answer: a verdict, a list of findings with `file:line`, a patch in
78
+ a fenced block.
79
+ - Do not paste the repository rules. It reads them itself.
80
+
81
+ Pass `--schema` with a JSON Schema file when you want the answer as JSON you can parse.
82
+
83
+ ## Before you reach for it
84
+
85
+ - `codex` must be on `PATH` and logged in. If it is not, tell the user and stop. Do not
86
+ install it, and do not write a token into the repository.
87
+ - If Codex reports that the project is not trusted, ask the user to run `codex` once in the
88
+ repository and trust it there.
89
+ - A Codex run is not free. Prefer it where a second model earns its cost, not for the
90
+ twentieth grep.
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Run one Codex agent to completion and print only its final message.
4
+ *
5
+ * `codex exec` streams its whole reasoning and command trace to stdout. That trace costs a
6
+ * calling agent more context than the answer is worth, so it stays in a log file here.
7
+ *
8
+ * Usage: node codex-agent.mjs [options] "<prompt>"
9
+ */
10
+
11
+ import { spawn } from 'node:child_process';
12
+ import { createWriteStream, mkdirSync, readFileSync, rmSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { createInterface } from 'node:readline';
16
+
17
+ const DEFAULT_MODEL = 'gpt-5.6-terra';
18
+ const DEFAULT_TIMEOUT_SECONDS = 900;
19
+
20
+ const USAGE = `Usage: node codex-agent.mjs [options] "<prompt>"
21
+
22
+ -m, --model <id> Model id (default ${DEFAULT_MODEL})
23
+ -e, --effort <level> Reasoning effort: low | medium | high | xhigh
24
+ -w, --write Let the agent edit the working tree (default: read-only)
25
+ -C, --cd <dir> Working root for the agent (default: the current directory)
26
+ --resume <thread> Continue an earlier thread instead of starting one
27
+ --prompt-file <f> Read the prompt from a file
28
+ --schema <file> JSON Schema the final message must match
29
+ --timeout <sec> Give up after this many seconds (default ${DEFAULT_TIMEOUT_SECONDS})
30
+ --trace Also print the command and file-change trace
31
+ -h, --help Print this help`;
32
+
33
+ const parseArgs = (argv) => {
34
+ const options = {
35
+ model: DEFAULT_MODEL,
36
+ effort: '',
37
+ write: false,
38
+ cd: process.cwd(),
39
+ resume: '',
40
+ promptFile: '',
41
+ schema: '',
42
+ timeout: DEFAULT_TIMEOUT_SECONDS,
43
+ trace: false,
44
+ prompt: '',
45
+ };
46
+
47
+ const rest = [];
48
+
49
+ for (let index = 0; index < argv.length; index += 1) {
50
+ const arg = argv[index];
51
+ const next = () => {
52
+ index += 1;
53
+ if (index >= argv.length) throw new Error(`${arg} needs a value.`);
54
+ return argv[index];
55
+ };
56
+
57
+ if (arg === '-m' || arg === '--model') options.model = next();
58
+ else if (arg === '-e' || arg === '--effort') options.effort = next();
59
+ else if (arg === '-w' || arg === '--write') options.write = true;
60
+ else if (arg === '-C' || arg === '--cd') options.cd = next();
61
+ else if (arg === '--resume') options.resume = next();
62
+ else if (arg === '--prompt-file') options.promptFile = next();
63
+ else if (arg === '--schema') options.schema = next();
64
+ else if (arg === '--timeout') options.timeout = Number(next());
65
+ else if (arg === '--trace') options.trace = true;
66
+ else if (arg === '-h' || arg === '--help') options.help = true;
67
+ else if (arg.startsWith('-') && arg !== '-') throw new Error(`Unknown option ${arg}.`);
68
+ else rest.push(arg);
69
+ }
70
+
71
+ options.prompt = options.promptFile ? readFileSync(options.promptFile, 'utf8') : rest.join(' ');
72
+
73
+ return options;
74
+ };
75
+
76
+ const readStdin = () =>
77
+ new Promise((resolve) => {
78
+ let buffer = '';
79
+ process.stdin.setEncoding('utf8');
80
+ process.stdin.on('data', (chunk) => (buffer += chunk));
81
+ process.stdin.on('end', () => resolve(buffer));
82
+ });
83
+
84
+ const buildArgs = (options, lastMessageFile) => {
85
+ const args = ['exec'];
86
+
87
+ if (options.resume) args.push('resume');
88
+
89
+ args.push('--json', '--skip-git-repo-check', '-o', lastMessageFile, '-m', options.model);
90
+
91
+ // `codex exec resume` carries the sandbox and the working root of the session it resumes,
92
+ // and rejects both flags, so only a fresh run may pass them.
93
+ if (!options.resume) {
94
+ args.push('-C', options.cd, '--sandbox', options.write ? 'workspace-write' : 'read-only');
95
+ }
96
+
97
+ if (options.effort) args.push('-c', `model_reasoning_effort="${options.effort}"`);
98
+ if (options.schema) args.push('--output-schema', options.schema);
99
+ if (options.resume) args.push(options.resume);
100
+
101
+ args.push('-');
102
+
103
+ return args;
104
+ };
105
+
106
+ const describeItem = (item) => {
107
+ if (item.type === 'command_execution') return `$ ${String(item.command ?? '').split('\n')[0]}`;
108
+ if (item.type === 'file_change') {
109
+ const changes = Array.isArray(item.changes) ? item.changes : [];
110
+ return `~ ${changes.map((change) => `${change.kind ?? 'edit'} ${change.path ?? ''}`).join(', ')}`;
111
+ }
112
+ if (item.type === 'error') return `! ${item.message ?? 'unknown error'}`;
113
+ return '';
114
+ };
115
+
116
+ const duration = (milliseconds) => {
117
+ const seconds = Math.round(milliseconds / 1000);
118
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`;
119
+ };
120
+
121
+ const run = async () => {
122
+ const options = parseArgs(process.argv.slice(2));
123
+
124
+ if (options.help) {
125
+ process.stdout.write(`${USAGE}\n`);
126
+ return 0;
127
+ }
128
+
129
+ if (!options.prompt.trim() && !process.stdin.isTTY) options.prompt = await readStdin();
130
+
131
+ if (!options.prompt.trim()) {
132
+ process.stderr.write(`${USAGE}\n`);
133
+ return 2;
134
+ }
135
+
136
+ const logDir = join(tmpdir(), 'codex-agent');
137
+ mkdirSync(logDir, { recursive: true });
138
+
139
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
140
+ const logFile = join(logDir, `${stamp}.jsonl`);
141
+ const lastMessageFile = join(logDir, `${stamp}.last.md`);
142
+ const log = createWriteStream(logFile);
143
+ const started = Date.now();
144
+
145
+ const child = spawn('codex', buildArgs(options, lastMessageFile), {
146
+ stdio: ['pipe', 'pipe', 'pipe'],
147
+ env: process.env,
148
+ });
149
+
150
+ child.stdin.end(options.prompt);
151
+
152
+ const state = { thread: '', usage: null, errors: [], trace: [], failed: false };
153
+
154
+ createInterface({ input: child.stdout }).on('line', (line) => {
155
+ log.write(`${line}\n`);
156
+
157
+ let event;
158
+
159
+ try {
160
+ event = JSON.parse(line);
161
+ } catch {
162
+ return;
163
+ }
164
+
165
+ if (event.type === 'thread.started') state.thread = event.thread_id ?? '';
166
+ else if (event.type === 'turn.completed') state.usage = event.usage ?? null;
167
+ else if (event.type === 'turn.failed') {
168
+ state.failed = true;
169
+ state.errors.push(event.error?.message ?? 'the turn failed');
170
+ } else if (event.type === 'item.completed') {
171
+ const description = describeItem(event.item ?? {});
172
+ if (description) state.trace.push(description);
173
+ if (event.item?.type === 'error') state.errors.push(event.item.message ?? 'unknown error');
174
+ }
175
+ });
176
+
177
+ let stderrText = '';
178
+ child.stderr.setEncoding('utf8');
179
+ child.stderr.on('data', (chunk) => (stderrText += chunk));
180
+
181
+ const timer = setTimeout(() => child.kill('SIGKILL'), Math.max(1, options.timeout) * 1000);
182
+ const code = await new Promise((resolve) => child.on('close', resolve));
183
+
184
+ clearTimeout(timer);
185
+ log.end();
186
+
187
+ let finalMessage;
188
+
189
+ try {
190
+ finalMessage = readFileSync(lastMessageFile, 'utf8').trim();
191
+ rmSync(lastMessageFile, { force: true });
192
+ } catch {
193
+ finalMessage = '';
194
+ }
195
+
196
+ const failed = code !== 0 || state.failed || (!finalMessage && state.errors.length > 0);
197
+
198
+ if (options.trace && state.trace.length > 0) process.stdout.write(`${state.trace.join('\n')}\n\n`);
199
+ if (finalMessage) process.stdout.write(`${finalMessage}\n`);
200
+
201
+ if (failed) {
202
+ const reason = state.errors.join('\n') || stderrText.trim() || `codex exited with code ${code}`;
203
+ process.stdout.write(`\ncodex agent FAILED: ${reason}\n`);
204
+ }
205
+
206
+ const parts = [
207
+ `codex ${options.model}`,
208
+ options.write ? 'workspace-write' : 'read-only',
209
+ duration(Date.now() - started),
210
+ ];
211
+
212
+ if (state.usage) parts.push(`${state.usage.output_tokens ?? 0} out tok`);
213
+
214
+ process.stdout.write(`\n--- ${parts.join(' · ')}\n`);
215
+
216
+ if (state.thread) process.stdout.write(`resume: --resume ${state.thread}\n`);
217
+
218
+ process.stdout.write(`log: ${logFile}\n`);
219
+
220
+ return failed ? 1 : 0;
221
+ };
222
+
223
+ run().then(
224
+ (code) => process.exit(code),
225
+ (error) => {
226
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
227
+ process.exit(2);
228
+ },
229
+ );