diff --git a/bin/codex-watch b/bin/codex-watch new file mode 100755 index 0000000..8e65319 --- /dev/null +++ b/bin/codex-watch @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""codex-watch: stream a codex-wt JSONL log as one line per action, flagging +anything a moderator should look at. + + codex-watch replay a finished run + codex-watch -f follow a run in progress + +Emits FLAG lines for actions that warrant steering. Silence means the agent only +did ordinary things, so this is safe to attach a notifier to: only flags and the +final message are worth waking someone for. +""" + +import json +import re +import sys +import time + +# Each pattern is a thing worth interrupting a run for, not merely unusual. +SUSPECT = [ + (re.compile(r"\bgit\s+push\b"), "push attempt"), + (re.compile(r"\bgh\s+(pr|issue|release|api)\b"), "github write via gh"), + (re.compile(r"\bgit\s+(checkout|switch)\s+(?!-)"), "leaving the branch"), + (re.compile(r"\bgit\s+(reset|rebase|cherry-pick|revert)\b"), "history surgery"), + (re.compile(r"\bgit\s+commit\b.*--amend"), "amending an existing commit"), + (re.compile(r"\bgit\s+config\b"), "changing git config"), + (re.compile(r"\brm\s+-[rf]"), "recursive delete"), + (re.compile(r"\b(sudo|launchctl|systemctl)\b"), "privileged command"), + (re.compile(r"\.worktrees/"), "naming a worktree path"), + # Any parent traversal in a write-shaped command: the workdir is the scope, + # and ../ is how an agent leaves it without saying so. + (re.compile(r"(?:rm|mv|cp|tee|>|>>|git\s+-C)\s[^|;]*\.\./"), "writing outside the workdir"), + (re.compile(r"\bcurl\b|\bwget\b"), "outbound fetch"), + (re.compile(r"\bbiome\b.*--write|--unsafe"), "bulk autofix"), + (re.compile(r"\bprisma\s+(migrate\s+(reset|dev)|db\s+push)\b"), "destructive prisma"), +] + + +def flags_for(command: str) -> list[str]: + return [label for pattern, label in SUSPECT if pattern.search(command)] + + +def render(event: dict) -> list[str]: + kind = event.get("type") + + if kind == "thread.started": + return [f"thread {event.get('thread_id')}"] + + if kind == "turn.completed": + usage = event.get("usage", {}) + return [ + "turn complete " + f"(in {usage.get('input_tokens', 0)}, out {usage.get('output_tokens', 0)})" + ] + + if kind != "item.completed": + return [] + + item = event.get("item", {}) + item_type = item.get("type") + + if item_type == "command_execution": + command = " ".join(item.get("command", "").split()) + exit_code = item.get("exit_code") + marks = flags_for(command) + prefix = "FLAG" if marks else ("fail" if exit_code not in (0, None) else "ran ") + note = f" <- {', '.join(marks)}" if marks else "" + return [f"{prefix} [{exit_code}] {command[:150]}{note}"] + + if item_type == "file_change": + changes = item.get("changes") or item.get("paths") or [] + return [f"edit {len(changes)} path(s): {str(changes)[:150]}"] + + if item_type == "agent_message": + text = " ".join((item.get("text") or "").split()) + return [f"say {text[:300]}"] + + return [] + + +def replay(handle, follow: bool) -> None: + while True: + line = handle.readline() + if not line: + if not follow: + return + time.sleep(0.5) + continue + try: + event = json.loads(line) + except ValueError: + continue + for out in render(event): + print(out, flush=True) + + +def main() -> None: + args = sys.argv[1:] + follow = "-f" in args + paths = [a for a in args if a != "-f"] + if not paths: + print(__doc__, file=sys.stderr) + raise SystemExit(2) + with open(paths[0]) as handle: + replay(handle, follow) + + +if __name__ == "__main__": + main() diff --git a/bin/codex-wt b/bin/codex-wt new file mode 100755 index 0000000..a9381cf --- /dev/null +++ b/bin/codex-wt @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# codex-wt: run Codex against one git worktree, sandboxed and non-interactive. +# +# codex-wt start a run +# codex-wt steer an existing run +# +# Isolation: the worktree plus the repo's common git dir are writable, so the +# agent can commit; sibling worktrees are unreachable. Pushing is denied by +# environment rather than by asking: ssh is /usr/bin/false and gh has no config. +# Sockets stay allowed because tsx/tsc need local IPC to build (network_access +# false denies unix pipes too, not just the internet). +set -uo pipefail + +worktree=${1:?usage: codex-wt [thread-id]} +brief=${2:?usage: codex-wt [thread-id]} +thread=${3:-} + +worktree=$(cd "$worktree" && pwd) +[[ -r $brief ]] || { echo "codex-wt: unreadable brief: $brief" >&2; exit 2; } + +gitdir=$(git -C "$worktree" rev-parse --path-format=absolute --git-common-dir) +logdir=${CODEX_WT_LOGS:-$HOME/.local/state/codex-wt} +mkdir -p "$logdir" +stamp=$(git -C "$worktree" rev-parse --short HEAD) +log="$logdir/$(basename "$worktree")-$stamp-$$.jsonl" + +# Codex's own AGENTS.md mandates plan-then-approval. In a non-interactive run +# nobody can answer, so it proposes and exits having done nothing. The operator +# pre-approves implementation scope per run; the boundary it draws still holds +# and is enforced above by the sandbox, not by this paragraph. +preamble=$(cat <<'EOF' +## Run contract (read first) + +You are running non-interactively. Nobody can answer a question mid-run, so do +not propose a plan and stop: plan internally, then implement, verify, and commit +locally. The operator has pre-approved implementation scope for the task below. + +Still forbidden, and enforced by the sandbox rather than trust: pushing, opening +or editing PRs, touching any branch or worktree other than this one, and editing +tickets. If you believe the task requires one of those, say so in your final +message and stop. + +Report at the end: what changed, which checks you ran with their real output, +what you could not verify, and anything you found that is out of scope. + +--- + +EOF +) + +set -- \ + -c sandbox_mode=workspace-write \ + -c approval_policy=never \ + -c model_reasoning_effort=high \ + -c "sandbox_workspace_write.writable_roots=[\"$gitdir\"]" \ + -c 'sandbox_workspace_write.network_access=true' \ + -c 'shell_environment_policy.set={GIT_SSH_COMMAND="/usr/bin/false",GH_TOKEN="",GITHUB_TOKEN="",GH_CONFIG_DIR="/nonexistent-codex-gh"}' \ + -C "$worktree" + +if [[ -n $thread ]]; then + printf 'codex-wt: steering %s in %s\n' "$thread" "$worktree" >&2 + codex "$@" exec resume "$thread" --json "$preamble$(cat "$brief")" &2 + codex "$@" exec --json "$preamble$(cat "$brief")" &2 +python3 - "$log" <<'PY' >&2 +import json, sys +thread, last = None, None +for line in open(sys.argv[1]): + try: + event = json.loads(line) + except ValueError: + continue + if event.get("type") == "thread.started": + thread = event.get("thread_id") + if event.get("type") == "item.completed" and event.get("item", {}).get("type") == "agent_message": + last = event["item"].get("text") +print(f"codex-wt: thread {thread}" if thread else "codex-wt: no thread id in stream") +if thread: + print(f"codex-wt: steer with codex-wt {thread}") +PY diff --git a/install.sh b/install.sh index 52610db..2953816 100755 --- a/install.sh +++ b/install.sh @@ -123,6 +123,8 @@ ln -sf "$root/bin/statmon" ~/.local/bin/statmon ln -sf "$root/bin/throb" ~/.local/bin/throb ln -sf "$root/bin/train" ~/.local/bin/train ln -sf "$root/bin/glab-merge" ~/.local/bin/glab-merge +ln -sf "$root/bin/codex-wt" ~/.local/bin/codex-wt +ln -sf "$root/bin/codex-watch" ~/.local/bin/codex-watch # Configure git echo "Configuring git..."