bound-codex-tokens 0.1.0__tar.gz

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.
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: bound-codex-tokens
3
+ Version: 0.1.0
4
+ Summary: Local token and v2 delegation guard for interactive Codex sessions
5
+ Requires-Python: >=3.11
@@ -0,0 +1,101 @@
1
+ # bound-codex-tokens
2
+
3
+ Local, terminal-first protection for long-running Codex workflows. It lets
4
+ Codex keep working overnight, while placing a finite boundary around one
5
+ workflow and its delegated work.
6
+
7
+ ## The three controls
8
+
9
+ 1. **Bounded rollovers.** `--total` bounds all TUI segments together, and
10
+ `--compactions` is the finite number of automatic fresh-TUI resumes. By
11
+ default, the total is divided across the permitted segments. `--segment`
12
+ optionally chooses a smaller per-segment cap. At a segment cap, the wrapper
13
+ writes a small handoff and starts a fresh normal Codex TUI. At the total cap,
14
+ or after the allowed rollovers, it writes one final handoff and stops.
15
+ 2. **Configurable handoffs.** The handoff uses Luna by default, but its model,
16
+ reasoning effort, and prompt are independent of the interactive TUI. The
17
+ prompt is a top-level option so the continuation format is predictable.
18
+ 3. **Guarded v2 delegation.** `--no-fork` turns on
19
+ `--enable multi_agent_v2` and adds a temporary per-launch hook. It requires
20
+ `fork_turns: none`, blocks Sol by default, can allow a finite number of Sol
21
+ children, and can restrict children to an explicit model list.
22
+
23
+ This is intentionally a safety wrapper, not a substitute for Codex: the TUI
24
+ stays attached, so a long-running workflow can still make progress while the
25
+ wrapper bounds its lifecycle.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ uv tool install bound-codex-tokens
31
+ bound-codex-tokens --help
32
+ ```
33
+
34
+ From a checkout, use `uv tool install .`.
35
+
36
+ ## Examples
37
+
38
+ Run the normal TUI with a 10M workflow cap and two automatic rollovers (`K`,
39
+ `M`, and `B` are accepted). This permits three segments of about 3.34M each:
40
+
41
+ ```bash
42
+ bound-codex-tokens --total 10M --compactions 2 -- --yolo -m gpt-5.6-terra
43
+ ```
44
+
45
+ `--yolo` is passed straight through to Codex and gives it broad authority to
46
+ act without asking. Use it only in a workspace and environment you are willing
47
+ to let the unattended TUI change.
48
+
49
+ Customize the bounded handoff (also called a compaction here) independently:
50
+
51
+ ```bash
52
+ bound-codex-tokens --total 10M --compactions 2 \
53
+ --compaction-model gpt-5.6-terra --compaction-effort high \
54
+ --compaction-prompt-file ./my-handoff-prompt.md \
55
+ -- --yolo
56
+ ```
57
+
58
+ The default prompt is the versioned
59
+ [`compaction.md`](bound_codex_tokens_assets/compaction.md) shipped with the
60
+ release; the current upstream copy is also available at
61
+ `https://raw.githubusercontent.com/garylvov/bound-codex-tokens/main/bound_codex_tokens_assets/compaction.md`.
62
+ Use `--compaction-prompt-file` to pin a project-specific prompt. The wrapper
63
+ does not use a copied native Codex compaction prompt: native compaction output
64
+ is opaque, so this tool supplies only selected user/assistant messages plus a
65
+ small manifest to the handoff model.
66
+
67
+ Enable guarded multi-agent v2, permit only Terra or Luna children, and permit
68
+ no Sol children:
69
+
70
+ ```bash
71
+ bound-codex-tokens --total 10M --compactions 2 \
72
+ --no-fork --max-sol-subagents 0 \
73
+ --allowed-subagent-models gpt-5.6-terra gpt-5.6-luna \
74
+ -- --enable multi_agent_v2 --disable auto_review --yolo -m gpt-5.6-terra \
75
+ -c 'model_reasoning_effort="medium"'
76
+ ```
77
+
78
+ `--no-fork` enables `multi_agent_v2` when it is absent and is
79
+ idempotent when the normal Codex flag `--enable multi_agent_v2` is already
80
+ present. `fork_turns: none` means a child starts without a copy of the parent
81
+ conversation history. It prevents a large main context from being charged again
82
+ to every delegated child. Use `--max-sol-subagents 2` to allow exactly two Sol
83
+ children. Without a v2 policy, Sol is still denied by default; use
84
+ `--allow-sol-subagents` only when that is intentional.
85
+
86
+ `--disable auto_review` is passed to Codex and makes that choice explicit for
87
+ these long-running sessions. It is separate from `--yolo`, which controls
88
+ approval and sandbox bypass.
89
+
90
+ The v2 hook also blocks nested `codex exec` calls, because their separate
91
+ sessions would evade the wrapper's root-lineage accounting.
92
+
93
+ ## Notes
94
+
95
+ * `--total` counts reported tokens from every supervised TUI segment, including
96
+ those before a rollover. The final handoff is a separate Luna/Terra call and
97
+ is not part of this total. This is a local guardrail, not a claim about final
98
+ provider billing categories.
99
+ * The wrapper uses the regular Codex TUI, not a replacement client. It does not
100
+ change `~/.codex/config.toml`.
101
+ * Run the no-cost checks with `bound-codex-tokens --self-test`.
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: bound-codex-tokens
3
+ Version: 0.1.0
4
+ Summary: Local token and v2 delegation guard for interactive Codex sessions
5
+ Requires-Python: >=3.11
@@ -0,0 +1,12 @@
1
+ README.md
2
+ bound_codex_tokens.py
3
+ pyproject.toml
4
+ bound_codex_tokens.egg-info/PKG-INFO
5
+ bound_codex_tokens.egg-info/SOURCES.txt
6
+ bound_codex_tokens.egg-info/dependency_links.txt
7
+ bound_codex_tokens.egg-info/entry_points.txt
8
+ bound_codex_tokens.egg-info/top_level.txt
9
+ bound_codex_tokens_assets/__init__.py
10
+ bound_codex_tokens_assets/compaction.md
11
+ bound_codex_tokens_hooks/__init__.py
12
+ bound_codex_tokens_hooks/v2_spawn_policy.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bound-codex-tokens = bound_codex_tokens:main
@@ -0,0 +1,3 @@
1
+ bound_codex_tokens
2
+ bound_codex_tokens_assets
3
+ bound_codex_tokens_hooks
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env python3
2
+ """A small, local session-budget supervisor for the interactive Codex TUI."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import re
10
+ import shlex
11
+ import signal
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from collections import defaultdict
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+
22
+ def default_summary_prompt() -> str:
23
+ """Read the versioned prompt distributed with this release."""
24
+ from importlib.resources import files
25
+
26
+ return files("bound_codex_tokens_assets").joinpath("compaction.md").read_text(encoding="utf-8").strip()
27
+
28
+
29
+ def token_limit(value: str) -> int:
30
+ match = re.fullmatch(r"([0-9][0-9_]*(?:\.[0-9]+)?)([KkMmBb]?)", value.strip())
31
+ if not match:
32
+ raise argparse.ArgumentTypeError("use an integer or 10K, 10M, 1B")
33
+ multiplier = {"": 1, "k": 1_000, "m": 1_000_000, "b": 1_000_000_000}[match.group(2).lower()]
34
+ result = int(float(match.group(1).replace("_", "")) * multiplier)
35
+ if result <= 0:
36
+ raise argparse.ArgumentTypeError("must be a positive token count")
37
+ return result
38
+
39
+
40
+ def model_list(values: list[str]) -> list[str]:
41
+ """Normalize one CLI model list, accepting commas for shell convenience."""
42
+ return [model.strip() for value in values for model in value.split(",") if model.strip()]
43
+
44
+
45
+ def default_sessions_dir() -> Path:
46
+ codex_home = Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex")))
47
+ return codex_home / "sessions"
48
+
49
+
50
+ def discover_logs(directory: Path) -> set[Path]:
51
+ return set(directory.glob("*/*/*/*.jsonl")) if directory.exists() else set()
52
+
53
+
54
+ def content_text(value: Any) -> str:
55
+ if isinstance(value, str):
56
+ return value
57
+ if isinstance(value, list):
58
+ return "\n".join(content_text(item.get("text", "")) if isinstance(item, dict) else "" for item in value)
59
+ return ""
60
+
61
+
62
+ @dataclass
63
+ class FileState:
64
+ offset: int = 0
65
+ session_id: str | None = None
66
+ parent_id: str | None = None
67
+ source: str | None = None
68
+ model: str | None = None
69
+ total_tokens: int = 0
70
+ last_cumulative: int | None = None
71
+ excerpts: list[str] = field(default_factory=list)
72
+
73
+
74
+ class SessionWatch:
75
+ def __init__(self, sessions_dir: Path, baseline: set[Path], deny_sol: bool, require_none: bool):
76
+ self.sessions_dir = sessions_dir
77
+ self.baseline = baseline
78
+ self.states: dict[Path, FileState] = {}
79
+ self.root_id: str | None = None
80
+ self.related_ids: set[str] = set()
81
+ self.deny_sol = deny_sol
82
+ self.require_none = require_none
83
+ self.violation: str | None = None
84
+
85
+ def poll(self) -> None:
86
+ for path in discover_logs(self.sessions_dir) - self.baseline:
87
+ state = self.states.setdefault(path, FileState())
88
+ try:
89
+ with path.open("r", encoding="utf-8") as handle:
90
+ handle.seek(state.offset)
91
+ lines = handle.readlines()
92
+ state.offset = handle.tell()
93
+ except (OSError, UnicodeDecodeError):
94
+ continue
95
+ for line in lines:
96
+ try:
97
+ self._record(path, state, json.loads(line))
98
+ except json.JSONDecodeError:
99
+ continue
100
+ self._refresh_lineage()
101
+
102
+ def _record(self, path: Path, state: FileState, record: dict[str, Any]) -> None:
103
+ payload = record.get("payload", {})
104
+ if record.get("type") == "session_meta":
105
+ state.session_id = payload.get("session_id") or payload.get("id")
106
+ state.parent_id = payload.get("parent_thread_id") or payload.get("forked_from_id")
107
+ state.source = str(payload.get("thread_source") or "")
108
+ state.model = str(payload.get("model") or payload.get("model_slug") or "")
109
+ if self.root_id is None and state.source == "user":
110
+ self.root_id = state.session_id
111
+ if self.deny_sol and state.source == "subagent" and "sol" in state.model.lower():
112
+ self.violation = f"Sol child recorded in {path.name}"
113
+
114
+ if record.get("type") == "event_msg" and payload.get("type") == "token_count":
115
+ usage = payload.get("info", {}).get("last_token_usage", {})
116
+ cumulative = payload.get("info", {}).get("total_token_usage", {}).get("total_tokens")
117
+ spent = usage.get("total_tokens")
118
+ if isinstance(cumulative, int) and cumulative != state.last_cumulative and isinstance(spent, int):
119
+ state.total_tokens += spent
120
+ state.last_cumulative = cumulative
121
+
122
+ if record.get("type") == "response_item" and payload.get("type") == "message":
123
+ role = payload.get("role")
124
+ text = content_text(payload.get("content"))
125
+ if role in {"user", "assistant"} and text:
126
+ state.excerpts.append(f"{role}: {text[:1800]}")
127
+
128
+ # v2 spawn requests are persisted as function calls in the root transcript.
129
+ if record.get("type") == "response_item" and payload.get("type") == "function_call":
130
+ if payload.get("name") != "spawn_agent":
131
+ return
132
+ try:
133
+ args = json.loads(payload.get("arguments", "{}"))
134
+ except (TypeError, json.JSONDecodeError):
135
+ return
136
+ if self.deny_sol and "sol" in str(args.get("model", "")).lower():
137
+ self.violation = "spawn_agent requested a Sol subagent"
138
+ if self.require_none and args.get("fork_turns") != "none":
139
+ self.violation = "spawn_agent did not explicitly request fork_turns: none"
140
+
141
+ def _refresh_lineage(self) -> None:
142
+ if not self.root_id:
143
+ return
144
+ related = {self.root_id}
145
+ changed = True
146
+ while changed:
147
+ changed = False
148
+ for state in self.states.values():
149
+ if state.session_id and state.parent_id in related and state.session_id not in related:
150
+ related.add(state.session_id)
151
+ changed = True
152
+ self.related_ids = related
153
+
154
+ @property
155
+ def tokens(self) -> int:
156
+ return sum(state.total_tokens for state in self.states.values() if state.session_id in self.related_ids)
157
+
158
+ def related_paths(self) -> list[Path]:
159
+ return [path for path, state in self.states.items() if state.session_id in self.related_ids]
160
+
161
+
162
+ def terminate(process: subprocess.Popen[bytes], grace: float = 8.0) -> None:
163
+ if process.poll() is not None:
164
+ return
165
+ try:
166
+ os.killpg(process.pid, signal.SIGTERM)
167
+ except ProcessLookupError:
168
+ return
169
+ deadline = time.monotonic() + grace
170
+ while process.poll() is None and time.monotonic() < deadline:
171
+ time.sleep(0.2)
172
+ if process.poll() is None:
173
+ os.killpg(process.pid, signal.SIGKILL)
174
+
175
+
176
+ def write_bundle(output_dir: Path, watch: SessionWatch, reason: str, tokens: int) -> Path:
177
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
178
+ bundle = output_dir / f"handoff-{stamp}"
179
+ bundle.mkdir(parents=True, exist_ok=False)
180
+ excerpts: list[str] = []
181
+ for path in watch.related_paths():
182
+ excerpts.extend(watch.states[path].excerpts[-30:])
183
+ excerpts = excerpts[-100:]
184
+ source = bundle / "selected-transcript.md"
185
+ source.write_text(
186
+ "# Bounded transcript selection\n\n"
187
+ f"Reason: {reason}\n\nReported lineage tokens: {tokens:,}\n\n"
188
+ "Only user/assistant messages are included; raw tool output and full history are excluded.\n\n"
189
+ + "\n\n".join(excerpts),
190
+ encoding="utf-8",
191
+ )
192
+ (bundle / "manifest.json").write_text(json.dumps({
193
+ "reason": reason, "reported_tokens": tokens,
194
+ "root_session_id": watch.root_id,
195
+ "session_logs": [str(path) for path in watch.related_paths()],
196
+ }, indent=2) + "\n", encoding="utf-8")
197
+ return bundle
198
+
199
+
200
+ def run_summary(bundle: Path, model: str, effort: str, summary_prompt: str, cwd: Path) -> Path:
201
+ handoff = bundle / "HANDOFF.md"
202
+ prompt = (
203
+ f"Read only {bundle / 'selected-transcript.md'} and {bundle / 'manifest.json'}. "
204
+ f"{summary_prompt} Write the result to the output file. "
205
+ "Do not read any session JSONL files."
206
+ )
207
+ command = ["codex", "exec", "--skip-git-repo-check", "-C", str(cwd), "-s", "read-only",
208
+ "-m", model, "-c", f"model_reasoning_effort={json.dumps(effort)}", "-o", str(handoff), prompt]
209
+ print(f"[bound] generating bounded handoff with {model} ({effort})...", flush=True)
210
+ subprocess.run(command, check=True)
211
+ return handoff
212
+
213
+
214
+ def without_initial_prompt(arguments: list[str]) -> list[str]:
215
+ """Keep common Codex flags; drop positional prompt for the fresh handoff TUI."""
216
+ takes_value = {"-c", "--config", "-i", "--image", "-m", "--model", "-p", "--profile", "-s", "--sandbox", "-C", "--cd", "--add-dir", "-a", "--ask-for-approval", "--enable", "--disable"}
217
+ result: list[str] = []
218
+ index = 0
219
+ while index < len(arguments):
220
+ item = arguments[index]
221
+ if item in takes_value:
222
+ result.append(item)
223
+ if index + 1 < len(arguments):
224
+ result.append(arguments[index + 1])
225
+ index += 2
226
+ elif item.startswith("-"):
227
+ result.append(item)
228
+ index += 1
229
+ else:
230
+ break
231
+ return result
232
+
233
+
234
+ def v2_already_enabled(arguments: list[str]) -> bool:
235
+ """Recognize the normal CLI spelling so the v2 flag is never duplicated."""
236
+ return any(
237
+ item == "--enable=multi_agent_v2"
238
+ or item == "features.multi_agent_v2=true"
239
+ or (item == "--enable" and index + 1 < len(arguments) and arguments[index + 1] == "multi_agent_v2")
240
+ for index, item in enumerate(arguments)
241
+ )
242
+
243
+
244
+ def v2_policy_config_args(max_sol_subagents: int, allowed_models: list[str], state_file: Path,
245
+ already_enabled: bool) -> list[str]:
246
+ """Return a process-local PreToolUse hook config, without touching config.toml."""
247
+ from bound_codex_tokens_hooks import v2_spawn_policy
248
+
249
+ command_args = [
250
+ sys.executable, str(Path(v2_spawn_policy.__file__).resolve()),
251
+ "--max-sol-subagents", str(max_sol_subagents), "--state-file", str(state_file),
252
+ ]
253
+ for model in allowed_models:
254
+ command_args.extend(["--allowed-model", model])
255
+ command = shlex.join(command_args)
256
+ # TOML inline tables use `=` rather than JSON's `:`. Build it explicitly so
257
+ # the hook command remains safely quoted even when its path contains spaces.
258
+ value = ('[{ matcher = ".*", hooks = '
259
+ '[{ type = "command", command = ' + json.dumps(command) +
260
+ ', timeout = 5, statusMessage = "checking protected Codex policy" }] }]')
261
+ enable_args = [] if already_enabled else ["--enable", "multi_agent_v2"]
262
+ return [*enable_args, "-c", f"hooks.PreToolUse={value}"]
263
+
264
+
265
+ def self_test() -> int:
266
+ assert token_limit("500") == 500
267
+ assert token_limit("10M") == 10_000_000
268
+ assert without_initial_prompt(["--yolo", "-m", "gpt-5.6-luna", "hello"]) == ["--yolo", "-m", "gpt-5.6-luna"]
269
+ assert v2_already_enabled(["--enable", "multi_agent_v2"])
270
+ assert v2_already_enabled(["--enable=multi_agent_v2"])
271
+ assert not v2_already_enabled(["--enable", "multi_agent"])
272
+ print("self-test passed: limits, flag preservation, and idempotent v2 activation")
273
+ return 0
274
+
275
+
276
+ def main() -> int:
277
+ parser = argparse.ArgumentParser(description=__doc__)
278
+ parser.add_argument("--total", "--session", dest="total", type=token_limit, default=10_000_000,
279
+ help="reported-token cap across all TUI segments; e.g. 10M")
280
+ parser.add_argument("--segment", type=token_limit,
281
+ help="optional reported-token cap before a handoff and fresh TUI")
282
+ parser.add_argument("--compactions", type=int, default=2, help="maximum automatic fresh-TUI resumes")
283
+ parser.add_argument("--sessions-dir", type=Path, default=default_sessions_dir())
284
+ parser.add_argument("--output-dir", type=Path, default=Path.cwd() / ".bound-codex-tokens")
285
+ parser.add_argument("--summary-model", "--compaction-model", dest="summary_model", default="gpt-5.6-luna",
286
+ help="model used for the bounded handoff")
287
+ parser.add_argument("--summary-effort", "--compaction-effort", dest="summary_effort", default="medium",
288
+ help="reasoning effort used for the bounded handoff")
289
+ parser.add_argument("--summary-prompt", "--compaction-prompt", dest="summary_prompt",
290
+ help="top-level instructions for the bounded handoff")
291
+ parser.add_argument("--summary-prompt-file", "--compaction-prompt-file", dest="summary_prompt_file", type=Path,
292
+ help="Markdown/text file containing handoff instructions")
293
+ parser.add_argument("--poll-seconds", type=float, default=2.0)
294
+ parser.add_argument("--deny-sol-subagents", action="store_true", default=True,
295
+ help="stop when a Sol subagent is requested (default)")
296
+ parser.add_argument("--allow-sol-subagents", action="store_false", dest="deny_sol_subagents",
297
+ help="permit Sol subagents outside the v2 policy hook")
298
+ parser.add_argument("--require-fork-none", action="store_true")
299
+ parser.add_argument("--no-fork", "--v2-spawn-policy", dest="v2_spawn_policy", action="store_true",
300
+ help="enable v2 and require fork_turns: none for subagents")
301
+ parser.add_argument("--max-sol-subagents", type=int, default=0, help="Sol subagent allowance under v2 policy")
302
+ parser.add_argument("--allowed-subagent-models", nargs="+", default=[], metavar="MODEL",
303
+ help="space- or comma-separated allowed v2 subagent models")
304
+ parser.add_argument("--allowed-subagent-model", action="append", dest="allowed_subagent_model_legacy", default=[],
305
+ help=argparse.SUPPRESS)
306
+ parser.add_argument("--self-test", action="store_true")
307
+ parser.add_argument("codex_args", nargs=argparse.REMAINDER, help="pass Codex TUI flags after --")
308
+ args = parser.parse_args()
309
+ if args.self_test:
310
+ return self_test()
311
+ if args.compactions < 0:
312
+ parser.error("--compactions must be zero or greater")
313
+ if args.max_sol_subagents < 0:
314
+ parser.error("--max-sol-subagents must be zero or greater")
315
+ if args.summary_prompt and args.summary_prompt_file:
316
+ parser.error("use only one of --compaction-prompt and --compaction-prompt-file")
317
+ if args.summary_prompt_file:
318
+ try:
319
+ summary_prompt = args.summary_prompt_file.read_text(encoding="utf-8").strip()
320
+ except OSError as exc:
321
+ parser.error(f"could not read compaction prompt file: {exc}")
322
+ else:
323
+ summary_prompt = args.summary_prompt or default_summary_prompt()
324
+ if not summary_prompt:
325
+ parser.error("compaction prompt must not be empty")
326
+ codex_args = args.codex_args[1:] if args.codex_args[:1] == ["--"] else args.codex_args
327
+ if not args.sessions_dir.exists():
328
+ parser.error(f"sessions directory not found: {args.sessions_dir}")
329
+
330
+ # A compaction count is a count of fresh-TUI resumes, so it permits one
331
+ # more TUI segment than its value. Split the total by default so the total
332
+ # budget remains useful rather than silently becoming an unreachable cap.
333
+ segment_limit = args.segment or (args.total + args.compactions) // (args.compactions + 1)
334
+
335
+ rollovers = 0
336
+ workflow_tokens = 0
337
+ first_launch = True
338
+ cwd = Path.cwd()
339
+ state_file = args.output_dir / f"v2-spawn-policy-{os.getpid()}.json"
340
+ allowed_models = model_list(args.allowed_subagent_models) + args.allowed_subagent_model_legacy
341
+ policy_args = (
342
+ v2_policy_config_args(
343
+ args.max_sol_subagents, allowed_models, state_file, v2_already_enabled(codex_args)
344
+ )
345
+ if args.v2_spawn_policy else []
346
+ )
347
+ while True:
348
+ baseline = discover_logs(args.sessions_dir)
349
+ launch_args = (codex_args if first_launch else without_initial_prompt(codex_args) + [
350
+ f"Read the protected handoff at {handoff}; continue from it. Do not use native resume."
351
+ ]) + policy_args
352
+ print(
353
+ f"[bound] starting TUI segment {rollovers + 1}; "
354
+ f"segment cap {segment_limit:,}, total cap {args.total:,} reported tokens",
355
+ flush=True,
356
+ )
357
+ process = subprocess.Popen(["codex", *launch_args], start_new_session=True)
358
+ watch = SessionWatch(args.sessions_dir, baseline, args.deny_sol_subagents, args.require_fork_none)
359
+ reason: str | None = None
360
+ while process.poll() is None:
361
+ time.sleep(args.poll_seconds)
362
+ watch.poll()
363
+ if watch.violation:
364
+ reason = f"policy violation: {watch.violation}"
365
+ elif watch.root_id and workflow_tokens + watch.tokens >= args.total:
366
+ reason = (
367
+ f"workflow total cap reached: {workflow_tokens + watch.tokens:,} "
368
+ f">= {args.total:,}"
369
+ )
370
+ elif watch.root_id and watch.tokens >= segment_limit:
371
+ reason = f"segment cap reached: {watch.tokens:,} >= {segment_limit:,}"
372
+ if reason:
373
+ print(f"[bound] {reason}; stopping TUI", flush=True)
374
+ terminate(process)
375
+ break
376
+ if reason is None:
377
+ return process.wait()
378
+ workflow_tokens += watch.tokens
379
+ bundle = write_bundle(args.output_dir, watch, reason, watch.tokens)
380
+ try:
381
+ handoff = run_summary(bundle, args.summary_model, args.summary_effort, summary_prompt, cwd)
382
+ except subprocess.CalledProcessError as exc:
383
+ print(f"[bound] handoff failed ({exc.returncode}); bundle retained at {bundle}", file=sys.stderr)
384
+ return exc.returncode
385
+ if "workflow total cap reached" in reason or rollovers >= args.compactions:
386
+ print(
387
+ f"[bound] automatic resume limit reached; final handoff: {handoff}\n"
388
+ "[bound] No new TUI was started. Resume manually from this handoff when ready.",
389
+ flush=True,
390
+ )
391
+ return 75
392
+ print(f"[bound] handoff written to {handoff}; reopening TUI", flush=True)
393
+ rollovers += 1
394
+ first_launch = False
395
+
396
+
397
+ if __name__ == "__main__":
398
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Versioned assets distributed with bound-codex-tokens."""
@@ -0,0 +1,3 @@
1
+ Create a concise continuation handoff. Preserve completed actions, current
2
+ state, important assumptions, relevant tool outcomes, unresolved blockers, and
3
+ the next concrete step.
@@ -0,0 +1 @@
1
+ """Bundled Codex hook assets for bound-codex-tokens."""
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse policy for Codex v2 `spawn_agent` calls."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import fcntl
8
+ import json
9
+ from pathlib import Path
10
+ import re
11
+ import sys
12
+ from typing import Any
13
+
14
+
15
+ def deny(reason: str, max_sol: int) -> dict[str, Any]:
16
+ message = (
17
+ f"bound-codex-tokens blocked this operation: {reason}. "
18
+ "For multi_agent_v2, use fork_turns: none and a permitted subagent model "
19
+ f"(Sol allowance: {max_sol})."
20
+ )
21
+ return {
22
+ "decision": "block",
23
+ "reason": message,
24
+ "systemMessage": message,
25
+ "hookSpecificOutput": {
26
+ "hookEventName": "PreToolUse",
27
+ "additionalContext": message,
28
+ },
29
+ }
30
+
31
+
32
+ def sol_slot(state_file: Path, session_id: str, maximum: int) -> tuple[bool, int]:
33
+ """Atomically reserve one Sol slot for this root session."""
34
+ state_file.parent.mkdir(parents=True, exist_ok=True)
35
+ with state_file.open("a+", encoding="utf-8") as handle:
36
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
37
+ handle.seek(0)
38
+ try:
39
+ state = json.load(handle)
40
+ except json.JSONDecodeError:
41
+ state = {}
42
+ count = int(state.get(session_id, 0))
43
+ if count >= maximum:
44
+ return False, count
45
+ state[session_id] = count + 1
46
+ handle.seek(0)
47
+ handle.truncate()
48
+ json.dump(state, handle)
49
+ handle.flush()
50
+ return True, count + 1
51
+
52
+
53
+ def nested_codex_exec(tool_input: Any) -> bool:
54
+ """Detect a Codex CLI exec command in a tool payload without shell parsing."""
55
+ if isinstance(tool_input, dict):
56
+ return any(nested_codex_exec(value) for key, value in tool_input.items()
57
+ if key in {"cmd", "command", "shell_command"})
58
+ if not isinstance(tool_input, str):
59
+ return False
60
+ return bool(re.search(r"\bcodex\s+(?:exec|e)\b", tool_input, re.IGNORECASE))
61
+
62
+
63
+ def main() -> int:
64
+ parser = argparse.ArgumentParser(add_help=False)
65
+ parser.add_argument("--max-sol-subagents", type=int, default=0)
66
+ parser.add_argument("--allowed-model", action="append", default=[])
67
+ parser.add_argument("--state-file", type=Path, required=True)
68
+ args = parser.parse_args()
69
+ if args.max_sol_subagents < 0:
70
+ return 2
71
+ try:
72
+ event = json.load(sys.stdin)
73
+ except json.JSONDecodeError:
74
+ return 0
75
+ if event.get("hook_event_name") != "PreToolUse":
76
+ print("{}")
77
+ return 0
78
+ tool_input = event.get("tool_input") or {}
79
+ if nested_codex_exec(tool_input):
80
+ print(json.dumps(deny("nested `codex exec` sessions are disabled because they bypass lineage accounting", args.max_sol_subagents)))
81
+ return 0
82
+ if event.get("tool_name") != "spawn_agent":
83
+ print("{}")
84
+ return 0
85
+ if tool_input.get("fork_turns") != "none":
86
+ print(json.dumps(deny("fork_turns must be explicitly set to none", args.max_sol_subagents)))
87
+ return 0
88
+ model = str(tool_input.get("model") or "").lower()
89
+ allowed_models = {item.lower() for item in args.allowed_model}
90
+ if allowed_models and model not in allowed_models:
91
+ print(json.dumps(deny("subagent model is not in the allowed-model list", args.max_sol_subagents)))
92
+ return 0
93
+ if "sol" in model:
94
+ allowed, used = sol_slot(args.state_file, str(event.get("session_id")), args.max_sol_subagents)
95
+ if not allowed:
96
+ print(json.dumps(deny(f"Sol-subagent allowance exhausted ({used}/{args.max_sol_subagents})", args.max_sol_subagents)))
97
+ return 0
98
+ message = f"bound-codex-tokens: Sol subagent {used}/{args.max_sol_subagents} allowed."
99
+ print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": message}}))
100
+ return 0
101
+ print("{}")
102
+ return 0
103
+
104
+
105
+ if __name__ == "__main__":
106
+ raise SystemExit(main())
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bound-codex-tokens"
7
+ version = "0.1.0"
8
+ description = "Local token and v2 delegation guard for interactive Codex sessions"
9
+ requires-python = ">=3.11"
10
+ dependencies = []
11
+
12
+ [project.scripts]
13
+ bound-codex-tokens = "bound_codex_tokens:main"
14
+
15
+ [tool.setuptools]
16
+ py-modules = ["bound_codex_tokens"]
17
+ packages = ["bound_codex_tokens_assets", "bound_codex_tokens_hooks"]
18
+
19
+ [tool.setuptools.package-data]
20
+ bound_codex_tokens_assets = ["*.md"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+