Skip to content

perf(hooks): cut what the agent waits on in hooks and pulls, and measure it - #1822

Merged
SaulMoro merged 41 commits into
Tencent:mainfrom
hz89Dev:perf/agent-wait-budget
Nov 10, 2026
Merged

SaulMoro merged 41 commits into
Tencent:mainfrom
hz89Dev:perf/agent-wait-budget

Conversation

@hz89Dev

@hz89Dev hz89Dev commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Every teamai hook and pull runs while a member's agent waits, and nothing measured that wait, so it grew unseen: on main a session start waits on 7 git processes, one gh call and, on macOS, 10 ps. This PR adds the agent wait budget, a count of the processes each hook and pull makes the agent wait on, reported in CI beside the agent context budget, and then removes the waits it found, one commit per cut.

Processes the agent waits on, second run, macOS:

main this PR
SessionStart: git / gh / ps 7 / 1 / 10 2 / 0 / 1
UserPromptSubmit: git 2 0
PostToolUse (Read): git / Node 2 / 1 0 / 0
Stop: git 2 1
git worktree add with four checkouts: git 16 10
teamai pull with nothing new: git / claude --version 25 / 0 23 / 1

The budget counts processes and files, never elapsed time: a count is the same in every run, and a time is not.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature causing existing behavior to change)
  • Documentation only
  • Refactor / internal cleanup

Test Plan

  • npx tsc --noEmit passes
  • npm run lint passes
  • The test files that cover what I changed pass (npx vitest run <test files>); the full npx vitest run passes if the change touches src/__tests__/helpers/, a vitest*.config.ts, package.json or package-lock.json. CI runs the full suite on every push to a PR.
  • Added/updated tests for the change

Typecheck, lint and each commit's own tests pass at every runtime commit and at the head. Run once locally, on macOS only: the unit suite at the head (23868 tests, 23840 passed, 0 failed, 28 skipped) and the E2E suite at 14d16b35f (the one commit after it touches only the measurer; 2116 tests, 2081 passed, 0 failed, 35 skipped). The Linux and Windows runners, and the other repo providers and tools, are CI's.

What the agent waits on, before and after

What the agent waits on main this PR Change
pull after init, first run, git processes 37 35 -2
pull after init, first run, other processes 0 1 +1
pull after init, second run, git processes 25 23 -2
pull after init, second run, other processes 0 1 +1
pull of a new team commit, first run, git processes 26 24 -2
pull of a new team commit, first run, other processes 0 1 +1
pull of a new team commit, second run, git processes 25 23 -2
pull of a new team commit, second run, other processes 0 1 +1
SessionStart, first run, git processes 10 7 -3
SessionStart, first run, gh calls 1 0 -1
SessionStart, first run, other processes 10 1 -9
SessionStart, second run, git processes 7 2 -5
SessionStart, second run, gh calls 1 0 -1
SessionStart, second run, other processes 10 1 -9
SessionStart with a stale fetch, first run, git processes 7 2 -5
SessionStart with a stale fetch, first run, gh calls 1 0 -1
SessionStart with a stale fetch, first run, other processes 10 1 -9
UserPromptSubmit, first run, git processes 2 0 -2
UserPromptSubmit, second run, git processes 2 0 -2
PostToolUse Read, first run, git processes 2 0 -2
PostToolUse Read, first run, Node processes 1 0 -1
PostToolUse Read, second run, git processes 2 0 -2
PostToolUse Read, second run, Node processes 1 0 -1
PostToolUse TodoWrite, first run, git processes 5 4 -1
PostToolUse TodoWrite, second run, git processes 2 1 -1
Stop, first run, git processes 5 4 -1
Stop, second run, git processes 2 1 -1
PostToolUse outside any scope, first run, git processes 1 0 -1
PostToolUse outside any scope, first run, Node processes 1 0 -1
PostToolUse outside any scope, second run, git processes 1 0 -1
Every row, main / this PR

Counts are processes and files, never time: Claude Code 2.1.322, workspace scope, team repo mode git, darwin-arm64, git 2.56.0 (main: git 2.56.0), from the main checkout and linked worktrees. Not measured: a process other than git, Node and gh that the hook command's own shell starts (its login shell), Windows, tools other than Claude Code, user scope, self mode, HTTP mode, teamai's git hook on post-merge and post-rewrite, git worktree add when the team repo was last fetched more than 24 hours ago (it waits on one inline pull by design), a submodule, a workspace with workspace children, a bare repo.

Row git processes git processes after Node processes Node processes after gh calls gh calls after other processes other processes after files written
pull after init, first run 37 / 35 0 1 0 0 0 0 / 1 0 52
pull after init, second run 25 / 23 0 1 0 0 0 0 / 1 0 4
pull of a new team commit, first run 26 / 24 0 1 0 0 0 0 / 1 0 8
pull of a new team commit, second run 25 / 23 0 1 0 0 0 0 / 1 0 3
SessionStart, first run 10 / 7 39 / 37 1 1 1 / 0 0 / 1 10 / 1 1 19 / 21
SessionStart, second run 7 / 2 24 / 22 1 1 1 / 0 0 / 1 10 / 1 1 5
SessionStart with a stale fetch, first run 7 / 2 26 / 24 1 1 1 / 0 0 / 1 10 / 1 1 6
UserPromptSubmit, first run 2 / 0 3 / 4 1 1 0 0 0 0 3 / 2
UserPromptSubmit, second run 2 / 0 0 / 1 1 1 0 0 0 0 2
PostToolUse Read, first run 2 / 0 3 / 4 1 / 0 1 / 2 0 0 0 0 1
PostToolUse Read, second run 2 / 0 0 / 1 1 / 0 1 / 2 0 0 0 0 1
PostToolUse TodoWrite, first run 5 / 4 0 1 1 0 0 0 0 1
PostToolUse TodoWrite, second run 2 / 1 0 1 1 0 0 0 0 1
Stop, first run 5 / 4 3 / 1 1 1 0 0 0 0 2
Stop, second run 2 / 1 3 / 1 1 1 0 0 0 0 2
PostToolUse outside any scope, first run 1 / 0 0 / 1 1 / 0 1 / 2 0 0 0 0 0 / 1
PostToolUse outside any scope, second run 1 / 0 0 / 1 1 / 0 1 / 2 0 0 0 0 0
worktree add, two checkouts, first run 14 / 12 38 1 1 0 0 0 0 / 1 25
worktree add, three checkouts, second run 14 / 10 27 1 1 0 0 0 0 / 1 18
worktree add, four checkouts, second run 16 / 10 29 1 1 0 0 0 0 / 1 16
PostToolUse in a new worktree, first run 2 / 0 23 / 24 1 / 0 1 / 2 0 0 0 0 5 / 6
PostToolUse in a new worktree, second run 2 / 0 0 / 1 1 / 0 1 / 2 0 0 0 0 1
SessionStart in a new worktree, first run 7 / 4 47 / 45 1 1 1 / 0 0 / 1 10 / 1 1 16 / 17
SessionStart in a new worktree, second run 7 / 2 32 / 30 1 1 1 / 0 0 / 1 10 / 1 1 5

Counts are processes, measured in a verify world on macOS 27 (arm64), git 2.56.0, Claude Code 2.1.322. No elapsed time is claimed. main has no measurer, so CI cannot produce this comparison for this PR: the table is the PR's before and after.

The four pull rows wait on one more process: claude --version. A full pull reads the installed Claude Code's version to choose the form of the PostToolUse entry in ~/.claude/settings.json. A hook and git worktree add never wait on it: their inline pull keeps the form the file already has, and the measurer now fails any row other than a pull that waits on the tool. git worktree add runs it once after it returns, in the full pull that follows.

For a member with Codex, a full pull also asks each Codex build it finds for its version, once per build and process, and the trust pass reuses the answer: one build 0 -> 1 codex --version; two builds 2 (or 0 when the trust pass was skipped) -> 2. The probe runs with a temporary CODEX_HOME, because codex --version writes under $CODEX_HOME/tmp before it reads its arguments. The measurer runs Claude Code, so no row shows it.

Real-CLI record

  • fix(uninstall): a world ran every measured row, then teamai uninstall; ledger CLEAN with project/.claude/rules no longer left behind.
  • MR hint: in a world, SessionStart printed nothing and stashed the hint; the first prompt printed it as additionalContext, the second printed nothing.
  • PostToolUse unwaited: the three Claude Code runs above; world ledger CLEAN after removing Claude Code's own transcripts.
  • Login-deferred PostToolUse: a world with a Claude Code answering 2.1.100. Pull wrote one entry in the deferred form and the wrapper; one hook run waited on 0 git (1 after), wrote its event; uninstall left no hook-dispatch; ledger CLEAN.
  • Login-deferred prompt: the budget world, UserPromptSubmit first and second run git waited 0, both on the fast path, because the session start before them recorded the session.
  • The inline pull and the ps cut: the budget world at the head. Every git worktree add row and PostToolUse in a new worktree wait on 0 claude (1 after), the pull rows on 1, and every SessionStart row on 1 ps; no invariant broke; ledger CLEAN.
  • teamai doctor: in a world, pull, then the wrapper's Node path replaced by hand:
    ✖ The teamai wrapper that login-deferred hooks start is current
      → <world>/home/.teamai/bin/teamai is missing, out of date, or names a Node or a teamai that is gone, so each hook in <world>/home/.claude/settings.json that starts teamai through it waits for a login shell first. Run `teamai pull` to rewrite it.
    
    teamai pull rewrote it and doctor passed again.
  • Codex hook form, on this machine's two real builds (codex on PATH, codex-cli 0.167.0, and the ChatGPT desktop app's, 0.159.2), in a world built from 6ade6477d:
    $ teamai init https://teamai.test/team/repo.git --provider git --agent codex
    ✔ Updated teamai hooks in <world>/home/.codex/hooks.json
    ✔ Trusted 6 teamai hook(s) in Codex
    $ teamai pull
    $ teamai pull
    ~/.codex/hooks.json PostToolUse, line 36:    "async": true
    sha1 after pull 1 and pull 2: 1efc45a879f1b7119ab8535051a32756242cd37e
    debug.log: Codex builds for the hook form: <PATH>/codex → "0.167.0", /Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex → "0.159.2"
    
    The real codex app-server lists the entry as post_tool_use:0:0 trustStatus=trusted. With a fake codex that answers codex-cli 0.147.0 first on PATH, teamai pull --force wrote the login-deferred command with no async, and it lists trusted. With codexTrustEnabled: false the async entry lists modified; with the switch unset, trusted at the same hash. The ledger shows two leaks, home/.codex and project/.codex; a world built from main shows the same two after the same commands (Codex's own app-server files, and the hook file a workspace uninstall keeps for other installs).
  • The four E2E files CI failed on the previous head, and the benchmark fixture's six, each run by name: green.

Not verified

  • The waited TodoWrite entry and its hint, in a real run: claude -p 2.1.322 offers no TodoWrite tool (it offers TaskCreate, TaskGet, TaskList, TaskUpdate and TaskStop). Unit and budget runs only.
  • A Codex turn that runs teamai's entry in either form: Codex is not signed in inside a world. The hook file, the build gate and the trust status are in the record above. A maintainer with a signed-in Codex on PATH can run both from this checkout:
    v() { npm run -s verify -- "$@"; }
    run=$(npm run -s verify:world)
    printenv OPENAI_API_KEY | v sh "$run" codex login --with-api-key
    v teamai "$run" init https://teamai.test/team/repo.git --provider git --agent codex
    # 1. Codex 0.148.0 or later: PostToolUse is async, trusted, and not waited for.
    v sh "$run" sh -c 'grep -n "\"async\": true" "$HOME/.codex/hooks.json"'
    v sh "$run" codex exec --skip-git-repo-check 'Run the shell command `ls` exactly once, then reply with only the word done.'
    v sh "$run" sh -c 'grep "post-tool-use/codex" "$HOME/.teamai/debug.log"'
    # 2. Codex 0.147.0: PostToolUse and UserPromptSubmit start teamai without a login shell.
    v sh "$run" sh -c 'npm install --prefix "$HOME/codex-147" @openai/codex@0.147.0 && export PATH="$HOME/codex-147/node_modules/.bin:$PATH" && teamai pull && grep -c TEAMAI_HOOK_LOGIN=deferred "$HOME/.codex/hooks.json" && codex exec --skip-git-repo-check "Run the shell command \`ls\` exactly once, then reply with only the word done." && grep -E "(post-tool-use|prompt-submit)/codex" "$HOME/.teamai/debug.log"'
    v ledger "$run"; v cleanup "$run"
    Expected: (1) the async line, codex exec ends without a hook warning, and a post-tool-use/codex line with its background pass; (2) a count of 2, then prompt-submit/codex and post-tool-use/codex lines; a ledger without LEAK.
  • claude-internal, tclaude, codex-internal and tcodex: none was run. Their login-deferred form rests on the assumption TOOLS already records, that each runs a hook as its original does.
  • The measurer's counts are stable between runs on Linux: its CI job passed on the previous head; two runs were not compared.
  • An interactive Claude Code session behaves like -p for async hooks: not run.
  • The login-deferred string and the wrapper block behave on Linux as on macOS: the lint-and-test and E2E jobs.
  • A worktree an agent tool creates still gets the team's resources with the unwaited entry on Linux: invariant 8 in CI.
  • Every Windows command string is unchanged: the windows CI job.
  • teamai update rewrites the wrapper: read in src/update.ts, not run.
  • The route reuse of the env loader is safe in zsh and dash: the env-loader suite in CI on Linux.
  • The review comment shows the wait budget: cannot run before merge (pull_request_target); the filter was run by hand.
  • The ps table on FreeBSD and the other BSDs: macOS local only.

Related Issues

Builds on #1788 (the hook fast path) and #1807 (the verify world and the agent context budget).

Notes for Reviewers

Each commit stands alone and says which budget row it moves, so the PR reads best commit by commit.

Behaviour a member can see:

  • The MR hint is shown at the session's first prompt, not at session start, for a tool that has teamai's prompt hook. A tool without one keeps it at session start.
  • ~/.claude/settings.json and ~/.codex/hooks.json change form on macOS and Linux, and so do the hook files of claude-internal, tclaude, codex-internal and tcodex (table below). A project file a repository commits keeps today's entries, and so does every file on Windows.
  • teamai pull writes ~/.teamai/bin/teamai, and teamai doctor checks it.
  • A prompt sent before the session start's background pass has stored the MR hint shows nothing; the next prompt shows the hint.
  • After git worktree add returns, an exclude line no checkout needs any more stays until the full pull that follows finishes.
  • A tool upgrade or downgrade across a version floor changes the hook file at the next full pull, not at the next git worktree add.

Door: two-way. A revert, or a member going back to an older teamai, rewrites each hook file to today's entries at the next pull: every form keeps the literal teamai hook-dispatch an older teamai recognises its entries by.

Blast radius: on macOS and Linux, the hook file of every member who uses Claude Code, Codex or one of their four internal variants, and what each session start, prompt, tool call and Stop waits on. Windows keeps today's entries.

Commits

  • chore(verify): every world gets a gh that never reaches the network.
  • test(verify): scripts/agent-wait-budget.mjs measures each hook and pull, waited and after, first and second run: git through trace2, Node and the other processes from a preload's spawn records, gh through the fake's log. It checks the invariants, among them that only a pull waits on the tool itself.
  • ci: the budget job compares against main and the review shows the table.
  • docs(dev): names the agent wait and its budget.
  • fix(uninstall): removes the rules, skills and agents directories it emptied (the verify ledger needs it to end clean).
  • perf(session): reads the team delivery once per session start. SessionStart git waited 10 -> 9 on the first run, 7 -> 6 on the others.
  • perf(git-exclude): asks git what it tracks and offers once per pull. Pull git waited -2.
  • perf(git-hook): git worktree add waits the same however many worktrees exist: 14 / 14 / 16 -> 12 / 10 / 10. A line no checkout needs any more now stays in the exclude file from the moment git worktree add returns until the full pull that follows finishes (or the next full pull, if that one does not run). Before, the inline sync dropped it at once. This is the cost of the constant wait.
  • perf(session): remembers what a delivered team revision names. SessionStart second run git waited 6 -> 4.
  • perf(hooks): reads the checkout once at Stop for both of its readers. Stop git after 3 -> 1, first and second run (not waited).
  • perf(hooks): looks for merged MRs after the session start returns, and shows the hint at the next prompt. SessionStart gh waited 1 -> 0, git waited -1.
  • perf(env): asks git once in a login bash, not once per loader pass. Every login-shell hook git waited -1.
  • perf(hooks): on macOS and the BSDs, one ps -A per process answers every lookup of the tool's pid, as Windows' one table query already did. SessionStart waited ps 10 -> 1 on every row. Linux keeps /proc.
  • refactor(hooks): each tool's hook file gets a built-in hook form (no behaviour change).
  • perf(hooks): Claude Code 2.1.119+ runs PostToolUse without waiting, with TodoWrite kept waited so its hint reaches the same turn. PostToolUse git waited 1 -> 0, Node 1 -> 0. A full pull reads the form from the version; an inline pull keeps the file's form and starts no tool. An upgrade or downgrade across 2.1.119 changes the file at the next full pull.
  • perf(hooks): Codex 0.148.0+ runs PostToolUse without waiting: the single entry takes "async": true. No budget row moves (the measurer runs Claude Code).
  • feat(hooks): a hook can start teamai through ~/.teamai/bin/teamai and run the login shell only around its background pass.
  • perf(hooks): Claude Code below 2.1.119, or with no readable version, gets the waited PostToolUse entry in that form.
  • perf(hooks): the prompt hook takes that form once a pass inside the login environment has seen that the session's directory does not sync over HTTP. UserPromptSubmit git waited 1 -> 0. The session's record adds files written on four first-run rows: SessionStart +2, PostToolUse outside any scope, PostToolUse in a new worktree and SessionStart in a new worktree +1 each; no second run writes more.
  • feat(doctor): checks the wrapper a login-deferred hook starts, and names teamai pull as the fix.
  • perf(hooks): Codex gets the login-deferred form on macOS and Linux: PostToolUse below 0.148.0 or with no readable version, and UserPromptSubmit at any version. One helper gives the dispatch text of a built-in in either form, so the deferred entry keeps today's commandWindows byte for byte. No budget row moves.

From the reviews, on top:

  • fix(hooks): a waiting MR hint that was turned off before the prompt is dropped unshown.
  • test(verify): the measure fails on a measured command that failed and on a ledger leak.
  • fix(hooks): Codex's unwaited entry is gated on the Codex builds found. Codex has no version detector, so the entry never took the form. One hook file serves every Codex build on the machine, so the version compared with 0.148.0 is the oldest of codex on PATH and each desktop app's build; no build, or one whose version cannot be read, keeps the entry waited. chore(hooks) logs those versions in debug.log.
  • fix(hooks): a Codex build is asked its version with a temporary CODEX_HOME.
  • fix(hooks): the unwaited form stays off Windows, where no tool is started to read its version.
  • feat(hooks): the four internal variants get the login-deferred form.
  • fix(uninstall): an empty folder a member made keeps its directory.
  • Revert "fix(update)…": see Tried and left out.
  • test(e2e), four commits: the MR hint is expected at the first prompt, and the fake claude and codex of the import and benchmark tests answer --version as the tools do, without logging it as a model call.
  • docs(changelog): entries under Features, Changed and Bug Fixes.
  • test(verify), two commits: each row records how many milliseconds the tool waited in its one run, for reference. It shows in the measure's log line for the row and in the last column of the full table, and nothing compares it. Until main has the budget, the job log is where this PR's times are.
  • perf(hooks): a pull that finds the wrapper unchanged keeps the file. macOS takes longer over the first run of a new executable file: on this machine about 150 ms for a script with bytes it has not seen and about 30 ms for a new file with bytes it has, against 4 ms after. Every pull wrote a new file, so the first hook through the wrapper after each pull waited those 30 ms; the first hook ever through a wrapper still waits the 150 ms, once.
  • test(hooks): the enrolment record's time is compared as a date; the exact mtimeMs comparison failed on Linux in CI.

Elapsed time of the prompt hook, for reference, since the budget claims none: 15 runs each in a world of each build on this machine, median 47 ms on this PR and 87 to 116 ms on main, with the world's temporary directory emptied or not. The measure's UserPromptSubmit, first run shows about 190 ms on macOS because it is the first run ever of that world's wrapper.

What changed in a session (the second perf(session) commit), as the stored answer for a delivered revision:

Event git today With the stored answer Direction
A later fetch brings a commit or tree with the same prefix ambiguous, ls-tree fails, listing falls back to HEAD, teamDelivery says unresolved_revision still the commit that was delivered The stored answer is the correct one. Behaviour differs from main
The clone is deleted and cloned again asks the new clone repositoryStamp changes, reads again Same as main
The commit is pruned after a force-push and gc fails still names the commit Differs from main. The commit named is the one that was delivered

Hook forms per tool

Tool Unwaited form (PostToolUse) Login-deferred form Evidence
claude yes, Claude Code 2.1.119 or later, macOS and Linux yes, POSIX, the member's user-level file: PostToolUse when it is waited, and UserPromptSubmit run on 2.1.322; 2.1.119 fixed async PostToolUse hooks with no output writing empty transcript entries
codex yes, when every Codex build teamai finds (codex on PATH and each desktop app's) is 0.148.0 or later: one entry, "async": true, no matcher yes, POSIX, the member's user-level file: PostToolUse when it is waited, and UserPromptSubmit unwaited: documented; source at 0.148.0 to 0.167.0 (0.134.0 to 0.147.0 skip an async handler); run on 0.167.0 for UserPromptSubmit and SessionStart, PostToolUse not run. Login-deferred: source at 0.134.0 to 0.167.0 (zsh, bash or sh with -c, never -lc); run on 0.167.0, macOS (/bin/zsh -c, no login flag). The hook file, the build gate and the trust status: the record above. A Codex turn running teamai's entry: not run
claude-internal, tclaude, codex-internal, tcodex no: teamai knows neither the executable that prints the version nor whether its numbers follow the original's yes, as their original assumed to run a hook as the original does, as TOOLS already records; not run. A detector and a floor are all the unwaited form needs later
qoder no in this PR no in this PR documented, not installed, no version for async
every other tool no no no such option, or its executor or version is not established

On a Claude Code at 2.1.119 or later, ~/.claude/settings.json holds the PostToolUse key as two entries: ^(?!TodoWrite$).*$ with async, and TodoWrite waited. Both keep the key Hook dispatch post-tool-use wildcard, so builtin.disabled and a timeout override cover every tool call, as today. Cursor and Copilot CLI, which also read Claude's settings, see two disjoint matchers, so one call matches one entry and nothing is recorded twice (read in the source; neither tool run). Codex gets one entry: its matchers are the Rust regex crate, which has no look-ahead, and Codex drops a group whose matcher does not compile.

Codex trusts a hook by a hash of its handler, async and the command included (hook_hash, codex-rs/hooks/src/engine/discovery.rs), so either change reads as modified until it is trusted again. teamai computes no hash: after each write it lists the hooks through codex app-server and writes back the hash Codex lists for each (trustCodexHooks), so the changed entries are trusted by Codex's own computation. That trust pass runs for codex only, on main and here. If codex-internal or tcodex keeps a hash per handler as Codex does, their changed command reads as modified until the member trusts it again, as after any release that changed an entry.

The login-deferred command keeps the literal teamai hook-dispatch, so an older teamai still recognises its entry:

if [ -x "$HOME/.teamai/bin/teamai" ]; then TEAMAI_HOOK_LOGIN=deferred "$HOME/.teamai/bin/teamai" hook-dispatch post-tool-use --tool claude 2>/dev/null; else bash -lc "teamai hook-dispatch post-tool-use --tool claude 2>/dev/null"; fi || true

Without the wrapper it runs today's command. With a wrapper whose Node or teamai is gone, the wrapper hands the hook to a login shell once, and teamai doctor reports it.

Claude Code real runs: 2.1.322, 2026-11-10, macOS arm64, claude -p --setting-sources project --model haiku from a verify world's project, with the PostToolUse block teamai wrote copied into the project's settings and each command run inside the world:

  • One Read: one tool_use event recorded, fast path then the background pass.
  • EnterWorktree then Read inside it: both events, and the team rule delivered into the new worktree.
  • A turn that ends right after the tool call: the event is still recorded after claude -p exits.

Hit every surface

  • Tools: Claude Code and Codex get both forms; claude-internal, tclaude, codex-internal and tcodex get the login-deferred form only; every other tool keeps today's entries byte for byte (existing goldens unchanged; new goldens for each new form).
  • Scopes: workspace measured; user scope not measured. Both forms go only into the member's user-level hook file.
  • Team repo modes: git measured; http and self mode not measured. A self-mode project file keeps today's command (test). A prompt in the login-deferred form goes to the login shell whenever the login environment saw HTTP enrolment (E2E: an endpoint only the profile sets).
  • Checkouts: main checkout and a linked worktree measured, through both the git hook and the tool hook; a session that moves into a worktree sends its next prompt through the login shell once (test). Submodule, workspace children and bare repo not measured.
  • Layers: a team's builtin.disabled and timeout overrides apply to both PostToolUse entries (same key).
  • Entry points: the tool hooks, teamai's git hook on post-checkout, teamai pull, teamai hooks list and teamai doctor; the git hook on post-merge and post-rewrite not measured. The instruction readiness check asks about the file's own form, so it never starts a tool; teamai hooks list compares with the version's form.
  • Reverse states: uninstall removes every form for Claude Code, Codex and the four variants, at both sides of each floor (tests); a version change across a floor moves the file at the next full pull (tests); the session's enrolment record goes with its other state (contribute-check.test.ts).
  • Runs: first and second in every row; a second pull leaves the hook file byte-identical, for Claude Code and Codex (tests).
  • OS: macOS local; Linux in CI; Windows not run here, its CI job runs the unit rows. Both forms are POSIX-only: a win32 render keeps today's Claude Code entry past the floor, and today's Codex entries in every field, and starts no tool to read a version (tests). A deferred Codex entry on POSIX keeps today's commandWindows (test). The ps cut does not touch the Windows path.
  • Docs and skill-data: docs/guide/sharing.md and its zh-CN page (the PostToolUse and UserPromptSubmit entries, Claude Code and Codex); the MR hint wording in knowledge, sharing and workflow guides and the hook's README; glossary entries for the agent wait, its budget, and the built-in hook form; AGENTS.md names the budget under scripts/; CHANGELOG. skill-data: no change needed.

Always-loaded context: unchanged. Agent wait budget: every git, Node, gh and ps count that changed goes down. The pull rows wait on one claude --version, for the reason above. Files written grow on four first-run rows, the prompt hook's per-session record (commit list above).

Tried and left out

  • The 60 s session-start cooldown does not cover the reports and learnings branches. Left: [P1 阻断] main 未变化时 pull 跳过 learnings 分支刷新,无法同步队友贡献 #704 requires a pull to refresh learnings, the stamp would get a third meaning, and no agent waits on it.
  • git --version once per process for a member whose git is a script. Left: replacing the spawn with reading the shebang must be strictly narrower than the kernel, for one process per session start.
  • The second show <rev>:./manifest/members.yaml in a cold user-scope pull. Left: that scope is not measured.
  • Skipping the env loader's second pass. Rejected: it changes which value wins.
  • Asking the registry nothing at Stop under a skip update policy. Reverted in this PR: it stopped the one request the shared update cache of [bug] Shared negative update cache hides older TeamAI installations on the same machine #1751 needs, in a pass nothing waits on, and it moved no budget row.

The session-start MR hint runs `gh pr list` for a GitHub project. A world
now puts a fake gh first on PATH: `pr list` prints fakes/gh-pr-list.json or
[], `--version` answers, anything else fails the way a gh that is not
signed in does, and every call is logged to fakes/gh.log.
`node scripts/agent-wait-budget.mjs` builds a verify world, runs the hooks
and pulls of one Claude Code session in it, from the main checkout and from
linked worktrees, and prints what the agent waits on as JSON: the git, Node
and gh processes on the waited side and after the hook returned, files
written, and the remotes reached, on a first run and a second. No elapsed
time: the same hook took twice as long in one world as in another while
every count stayed the same.

git's trace2 counts every git process, the login shell's included. A Node
preload logs each Node process and tags what runs after the hook returns
(the --bg-only pass and anything started detached), which trace2 records per
process. A row counts only when the hook added its line to debug.log, since
a hook command exits 0 when teamai is not found.

It exits non-zero when an invariant breaks: a second run costs more than the
first, a warm fast-path hook waits on git started from Node, the session
start does not reach gh exactly once, a row reaches a remote it is not known
to reach or fetches the project, a new worktree lacks the team's resources
when `git worktree add` returns or after the tool hook's background pass, or
a linked worktree costs more than the main checkout once prepared.

`--compare <main.json> [<head.json>]` prints the Markdown report CI shows.
A new job measures main with main's own measurer, when it has one, and this
PR once, and puts the comparison in the job summary. A broken invariant of
this PR's own measurement fails the job; the comparison never does, and a
main that cannot be measured is reported, not failed. When the PR changes
the measurer or the world, the report says so above its table.

When a waited count grew, the report is uploaded and the review comment
shows it beside the agent context budget. The review reads both budgets in
one loop with the same row filter, plus the wait budget's header and its two
sentences as exact matches: the report comes from the PR's own code and is
posted by a workflow with write access.
The glossary gets a Budgets section: agent wait, the agent wait budget, and
the agent context budget that CI already reports. AGENTS.md asks for work an
agent does not need to be done after the hook returns, with the saving
proved in counts, adds the first and second run to the surfaces to walk, and
says CI reports the wait budget against main.
…ptied

Uninstall deleted the files teamai delivered and left the directories that
held them, so a project kept an empty `.claude/rules` after a workspace
uninstall. It now prunes, once its rule, skill and agent removal is done,
the directories those were in when nothing is left in them: above a rule,
each folder up to the tool's rules directory. A directory that still holds
a member's file stays, and so does the tool's own root.

The verify ledger also knows what the hooks of a session leave today
(events.jsonl, session-owners.jsonl, state.json and the sessions directory,
tracked in Tencent#1808), so a world that ran hooks ends clean unless something
else leaked.
A session start read the checkout's team delivery twice before its pull:
once to observe the checkout and once more for the skills its capture
records, each resolving the delivered revision with git. It now reads it
once and hands the same answer to both.

The background reads of the record before and after the session's own
pull only need the record itself, so they take it without resolving its
revision.

Agent wait budget, git processes waited / after the hook:
- SessionStart, first run: 10 / 39 -> 9 / 37
- SessionStart, second run: 7 / 24 -> 6 / 22
- SessionStart with a stale fetch: 7 / 26 -> 6 / 24
- SessionStart in a new worktree, first run: 7 / 47 -> 6 / 45
- SessionStart in a new worktree, second run: 7 / 32 -> 6 / 30
A pull's git exclude sync and the doctor check after it ask git the same
two questions for each checkout: which delivered paths it tracks, and
which it would still offer for a commit. They share one GitContext for the
run, which now keeps both answers, keyed by the checkout root and the
paths asked about, so a linked worktree gets its own.

A failed answer is not kept. A sync that writes an exclude file forgets
what git offers, since that answer depends on the file it just wrote.

Agent wait budget, git processes waited (ls-files 6 -> 4 in each):
- pull after init: 37 -> 35 first run, 25 -> 23 second run
- pull of a new team commit: 26 -> 24 first run, 25 -> 23 second run
…trees exist

While `git worktree add` waited, the git hook's preparation of the new
checkout synced the delivered git exclude block across every checkout of
the repository, asking git what each one tracks and offers, so the wait
grew by two git processes with each worktree a member kept.

The exclude file is one per repository and a line stays while any
checkout needs it, so adding the new checkout's lines needs only the new
checkout. The inline preparation now lists that checkout's delivered
files and drops no line; the detached pull the hook already starts right
after syncs every checkout's lines, as before. The tool-use preparation
and every other pull are unchanged.

A line no checkout needs any more now stays in the exclude file from the
moment `git worktree add` returns until that detached pull finishes, or
until the next full pull if it does not run. Before, the inline sync
dropped it at once.

The agent wait budget now fails when the waited ls-files count differs
between the three `git worktree add` rows, or when the two second-run rows
wait on a different number of git processes.

Agent wait budget, git processes waited (ls-files waited -> 2 in each):
- worktree add, two checkouts (first run): 14 -> 12
- worktree add, three checkouts: 14 -> 10
- worktree add, four checkouts: 16 -> 10
With `updatePolicy: skip`, or a team's `autoUpdate: false`, the Stop
hook's automatic update still asked the npm registry for the latest
version before it read the policy that skips the update. It now reads the
policy first and returns before the check. `teamai update` run by a member
still asks and still says when it is already up to date.

The policy is now read before the registry is asked on every update, so a
local config that cannot be loaded falls back to the running scope's, as
the check for other registered copies already did: an update whose policy
read rejected before now uses the running scope's config instead.
Every session start listed the team skills at the revision its checkout
received and resolved that revision to its full commit, two git processes
the agent waited on, for an answer that does not change: a pull that
delivers something else records another revision.

Both answers are now kept with the other git answers, keyed by the team
clone and the revision, under the clone's repository stamp, so a clone
removed and cloned again is read again. A failed read keeps nothing, and
the listing still falls back to the clone's HEAD when the revision cannot
be read. A linked worktree of the project reuses what its main checkout
read, as both deliver from one team clone.

Agent wait budget, git processes waited / after the hook:
- SessionStart, second run: 6 / 22 -> 4 / 21
- SessionStart with a stale fetch: 6 / 24 -> 4 / 23
- SessionStart in a new worktree, second run: 6 / 30 -> 4 / 29
- SessionStart, first run: 9 / 37 -> 9 / 36
A Stop's background pass read the checkout three times: `git status` for
the session's end record, then `rev-parse` and `symbolic-ref` for the
dashboard's Stop event. It now runs one `git status --branch` and hands
the head, its dirtiness and the branch to both readers in the payload, as
the session start already hands its state. Each reader keeps its own read
for when nothing is handed over.

When that status fails (a bare repository) or passes its 2 s cap (a very
large checkout), the Stop event still takes the head and branch from
`rev-parse` and `symbolic-ref`, as before. The branch now comes from git
status, so a branch with a tag of the same name is recorded by its name,
not as `heads/<name>`.

Nothing the agent waits on changes: both readers run after the hook
returns.

Agent wait budget: Stop, git processes after the hook 3 -> 1 on the first
and second run; no waited count moves.
The MR hint ran `git remote get-url origin` and `gh pr list` (or a REST
call, up to 8 s) in the foreground of every session start in a GitHub or
TGit project. The session start's detached pass now looks for them and
leaves the hint in the session's state directory; the session's next
prompt shows it, once: the prompt that takes it moves the file aside
before reading it, and the fast path sends that prompt to the full
dispatch.

The cache now records MRs as hinted when the hint is shown, not when it
is computed, so a hint no prompt took leaves its MRs to the next session,
and a prompt shows nothing when another session has shown all its MRs
since. A session start that finds nothing new drops the hint its session
had waiting.

A tool teamai gives no prompt hook (Kiro, Hermes), or one whose team
turned the prompt hook off, still gets the hint at session start.

The remote is read through the async `getRemoteUrl` in place of a
`spawnSync`.
A `bash -lc` runs the env loader twice in one process: from the profile
block, then as the BASH_ENV that pass exported. Each pass asked git for
the directory's repository. The second pass must still run, since it is
what makes a team value win over a profile line after teamai's block,
so it keeps loading the files and only takes the route the first pass
found, once, while the directory, the registry stamp, PATH and every
GIT_* variable are what they were. The record is never exported, so a
child shell asks git itself, and it is taken only in a non-interactive
bash.
A session start resolves the tool's pid in two handlers, and each walk
asked ps once per lookup: ten processes the session start waited on. On
macOS and the BSDs, one `ps -A -o pid=,ppid=,comm=` now answers every
lookup in the process, as the Windows table already does, a failed read
included; its 2 s cap applies once. `comm` goes last because an
executable path may hold spaces.

Linux keeps /proc and no longer falls back to a per-pid ps when a /proc
read fails: procps reads the same /proc, so it could not answer either.
Only short-lived hook processes resolve the pid; the dashboard checks
liveness with isProcessAlive, which is unchanged.
Two optional per-tool facts in `TOOLS`: `unwaitedSince`, the first version
whose command hooks take `"async": true` and are then not waited for, and
`loginDeferrable`, a tool observed to run hook commands with a plain
`sh -c` on POSIX. `builtinHookForm` turns them into the form of one hook
file: a project file a repository may commit always keeps the waited
login-shell form, and the member's own file is unwaited only when the
installed version reads at or above the floor.

`reconcileHooks` and `getHookStatus` take the form of the file they are
given, so pull, `teamai hooks inject`, doctor and `teamai hooks list`
agree on it. The unwaited form marks the PostToolUse entry `async`, which
the Claude Code and Codex renderers write. Checks that match an entry on
disk against teamai's own recognise every form, so an older or a newer
teamai still finds its entries.

No tool has either fact yet, so every hook file is written as before.
From 2.1.119, Claude Code takes "async": true on a command hook and no
longer writes an empty transcript entry for an async PostToolUse hook
that prints nothing. In the member's own ~/.claude/settings.json, the
wildcard entry becomes two entries under the same key: an async one for
every tool but TodoWrite, and a waited one for TodoWrite, whose hint
must reach the model in the same turn. A project file, an older or
unreadable version, and every other tool keep the single waited entry.

A full pull decides the form of a hook file from the tool's version. An
inline pull, which a git hook and a new checkout wait for, keeps the form
the file already has and asks the version only when the file holds no
teamai built-in to read it from, so it starts no tool. One function,
builtinHookFormOf, owns that reading; getHookStatus takes where to read
it from: the instruction readiness check asks about the file's own form,
`teamai hooks list` about the version's. A file whose entries agree with
no single form is not installed and is repaired in the form an entry
shows. The cost: an upgrade or downgrade across 2.1.119 changes the file
at the next full pull, not at the next git hook.
From 0.148.0, Codex runs a command handler with "async": true in the
background, every event but SessionEnd; 0.134.0 to 0.147.0 skip such a
handler instead. In the member's own ~/.codex/hooks.json, the PostToolUse
entry takes "async": true beside command and commandWindows. It stays one
entry with no matcher: Codex compiles matchers with the Rust regex crate,
which has no look-ahead, and drops a group whose matcher does not compile.
codex-internal and tcodex keep the waited entry.

The fact rests on Codex's documentation and on its source at 0.148.0 to
0.167.0. An async handler was run on 0.167.0 for UserPromptSubmit and
SessionStart; PostToolUse was not run.

Codex trusts a hook by a hash of its handler, async included, so the
changed entry reads as modified until trusted again. The trust pass after
each write already trusts it: it writes back the hash Codex's own
app-server lists, never one teamai computes.

The agent wait budget measures Claude Code, so no row moves.
A hook command can now start teamai through ~/.teamai/bin with
TEAMAI_HOOK_LOGIN=deferred. teamai then answers before the CLI bundle
loads: a hook the fast path takes starts its background pass through a
detached login shell and returns, and any other hook runs through a
login shell in the foreground, as the command did before. A payload
over 16 KB goes to the background pass as a file, removed again when
the pass cannot start.

When the Node or the entry script the wrapper records is gone, a
deferred hook runs teamai as a login shell finds it, at most once.
Without the marker the wrapper does what it did. The wrapper is now
written to a temporary file and renamed, since a hook may run it at
any moment.

No hook file is written in this form yet.
…t is waited for

Where Claude Code is older than 2.1.119, or its version cannot be read,
its PostToolUse entry in ~/.claude/settings.json now starts teamai
through ~/.teamai/bin/teamai with TEAMAI_HOOK_LOGIN=deferred, and the
login shell runs around the background pass. Without the wrapper the
entry runs today's command. Pull writes the wrapper for a tool that
can take this form. A repository's project file, Windows, the TodoWrite
entry and every other event keep today's command.

The agent wait budget does not move: the Claude Code it measures is
above 2.1.119, where the entry is not waited for at all.
…sion is known not to sync over HTTP

Claude Code's UserPromptSubmit entry in ~/.claude/settings.json now starts
teamai through ~/.teamai/bin/teamai, as the waited PostToolUse entry does.

Whether a prompt must be answered in the foreground depends on an HTTP
endpoint variable that only the profile or the team env may set, which this
form's foreground cannot see. So every dispatch pass that runs inside the
login environment records, per session, the cwd it ran in and whether it saw
HTTP enrolment, writing only when that changes. A prompt takes the fast path
only when that record, for its own cwd, says not enrolled. With no record,
another cwd (a session that moved into a worktree), or enrolment, the prompt
goes through the login shell in the foreground as before, and that pass
writes the record. The record lives beside the session's other state and is
removed with it.
A login-deferred hook entry starts teamai through ~/.teamai/bin/teamai. When
that file is gone, or names a Node or a teamai that no longer exists (after a
Node or teamai reinstall), the hook still runs, but waits for a login shell
first, and nothing tells the member.

When a tool's hook file holds such an entry, doctor now compares the wrapper
with the one this build writes, its Node and entry paths aside, through the
same comparison it uses for the adapters' saved launchers, and checks that
both paths exist. The fix it names is `teamai pull`. Without such an entry it
adds no check.
… waited for

Codex runs a hook's command with the account's zsh, bash or sh and -c,
never -lc, and falls back to one of those three for any other account
shell. In the member's own ~/.codex/hooks.json on macOS and Linux, the
PostToolUse entry below 0.148.0 or with an unreadable version, and the
UserPromptSubmit entry at any version, now start teamai through
~/.teamai/bin/teamai and leave the login shell to the background pass, as
Claude Code's do. From 0.148.0 the PostToolUse entry is async and keeps
today's command. codex-internal and tcodex keep today's entries, and so
does Windows.

Codex's commandWindows was derived from a command in today's form only.
One helper now gives the hook-dispatch text of a built-in in either
form, so a deferred entry keeps today's commandWindows byte for byte.

The fact rests on Codex's source at 0.134.0 to 0.167.0 and on a run of
its hook executor on 0.167.0, macOS: /bin/zsh -c, no login flag, its
parent the codex binary. teamai's own entry in this form was not run on
Codex. The changed command changes the handler's trust hash, and the
trust pass writes back the hash Codex lists for it.

A Codex payload carries session_id and cwd, so a login pass records
whether the session syncs over HTTP, and a later prompt takes the fast
path as Claude Code's does. The agent wait budget measures Claude Code,
so no row moves.
@github-actions

Copy link
Copy Markdown
  • [P2 non-blocking] Recheck the MR-hint switch before showing a queued hint — src/hook-handlers.ts:1051. If SessionStart queues a hint and MR hints are disabled before the next prompt, this handler still injects it and marks its MRs as shown. Unlike computation, consumption checks neither the current scope’s switch nor TEAMAI_MR_HINT_DISABLED. Pass the current config and recheck before consuming.

  • [P2 non-blocking] Reject unsuccessful measured CLI commands — scripts/agent-wait-budget.ts:389. The spawnSync result is discarded. A pull that delivers the probe rule but exits nonzero for another delivery failure can still pass the process-count and file-presence checks. Record nonzero status, signals, and spawn errors as measurement failures.

  • [P2 non-blocking] Make ledger failures fail the budget job — scripts/agent-wait-budget.ts:654. When runLedger returns false, the measurer only logs the leak and proceeds with cleanup. It does not add to failures, so otherwise-passing measurements exit zero and CI stays green despite the failed ledger. Propagate that result into the exit status.

The PR description includes sufficient representative real-CLI verification records; no missing-e2e finding. Review was read-only—no PR code was run, built, or installed.

A hint the session start left for the next prompt was shown, and its MRs
recorded as shown, even when TEAMAI_MR_HINT_DISABLED or the scope's switch
turned the hint off in between. Showing it now asks the same switch as
computing it. A hint turned off is dropped unshown, so the next session
start hints the same MRs again if the hint is back on.
…dger leak

The measure kept counting when a measured command exited non-zero, was
ended by a signal or did not start, and it only printed a ledger leak.
Each is now a failure of the measure, so the budget job cannot pass on a
pull that failed or a world that leaked.

@SaulMoro SaulMoro left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during triage.

Reviewed at 9860b005 with the built CLI in verify worlds on macOS, on main and on the head. I then read the two commits in c47ea00f; the findings below hold at c47ea00f.

What checks out:

  • Counts. scripts/agent-wait-budget.mjs reproduces the body's table exactly, and its world ends with the ledger CLEAN.
  • Claude Code forms. Claude Code 2.1.322 gets the unwaited PostToolUse pair. A stub at 2.1.100 gets the login-deferred form. Both hooks took the fast path.
  • Doctor check. The wrapper check fails on a broken wrapper, and the teamai pull it names fixes it.
  • Downgrade. Main's teamai pull rewrites every new form back to today's entries, and main's uninstall removes them, with the ledger CLEAN. The door is two-way, as the body says.
  • Codex on Windows. Codex's commandWindows and timeouts are byte-identical to main.
  • Workflows. The budget job runs on pull_request with read-only permissions. The codex-review-on-assign change reads one more artifact through the same line filter the context budget uses. Neither gives code from a fork anything it could not reach before.

c47ea00f fixes the CI review's three points: a queued MR hint is dropped once hints are switched off, a ledger LEAK fails the budget job, and a measured command's exit status counts.

Blocking

  • [P1 blocking] Codex never gets the unwaited PostToolUse entry. DETECTORS in src/agent-version.ts has codex: null, so teamai reads no Codex version and the unwaitedSince: '0.148.0' gate never passes. With codex-cli 0.167.0 on PATH, init --agent codex writes ~/.codex/hooks.json with no "async" line, and debug.log shows [agent-version] codex → "". The body, both languages of the guide and the src/tools.ts comment say Codex 0.148.0+ gets this form. The unit tests pass only because they stub the version. Either add a Codex version detector, or drop that commit and its claims.

Non-blocking

  • [P2 non-blocking] The unwaited form reaches Windows too. Only the login-deferred form is limited to POSIX. Claude Code 2.1.119+ on Windows gets the new async pair, and the Codex test expects async on Windows as well. The body says "Windows keeps today's entries", and nothing has run on Windows. Either keep the unwaited form off Windows, or run it there and correct the body.

  • [P2 non-blocking] No CHANGELOG entry. A member will see several changes:

    • the MR hint moves to the first prompt;
    • ~/.claude/settings.json and ~/.codex/hooks.json change form;
    • ~/.teamai/bin/teamai is new;
    • doctor has a new check;
    • Stop skips the registry under skip;
    • uninstall removes the rules, skills and agents directories it emptied.

    The squash title is perf(hooks), which would hide the two feat commits. Entries under Features and Bug Fixes would cover it.

  • [P3 nit] A first prompt can arrive before the background pass has stored the MR hint, so the hint shows one prompt later.

  • [P3 nit] Uninstall's removal of emptied skills/agents directories also takes an empty folder a member created there.

  • [P3 nit] AGENTS.md "Where code lives" still lists only the context budget under scripts/.

Not run here: Codex itself, which is not signed in inside the worlds, and Windows. CI is pending at c47ea00f. #1775 touches hook-fast-path.ts, mr-hint.ts and pid-monitor.ts too. The conflicts are mechanical: that PR owns Windows child processes, this one the POSIX hook forms and the macOS ps table.

@github-actions

Copy link
Copy Markdown

No new findings in the specified PR diff.

All three earlier findings are resolved:

  • Queued MR hints recheck the current config and TEAMAI_MR_HINT_DISABLED before consumption.
  • Measured commands record nonzero exits, signals, and spawn errors as failures.
  • Ledger failures propagate into the budget job’s exit status.

The PR description includes sufficient representative real-CLI verification; no missing-e2e finding.

Review was read-only. No PR code was run, built, or installed.

…ds found

Codex has no version detector, so the entry never took the unwaited form.
The version compared with 0.148.0 is now the oldest of the Codex builds that
read the member's hook file: codex on PATH and each desktop app's build. A
build whose version cannot be read, or no build at all, keeps the entry
waited. Each build is asked its version once per process, so the trust pass
does not ask again.
debug.log now names each Codex build found and the version it printed, as it does for a tool's own version detector.
The hint moved from the SessionStart output to the first UserPromptSubmit
after the session start's background pass leaves it. The offer test now
waits for that pass's stash, reads the hint at the first prompt and nothing
at the second. The disabled case asserted a marker the hint never prints;
it now checks that no hint is left and none is shown at the prompt.
…e Code does

A full pull asks Claude Code its version to choose the hook form. The fake
logged that probe as an AI call, so the test that asks no AI tool saw one.
…aude Code does

A full pull asks Claude Code its version to choose the hook form. The fake
logged that probe as a model call for an unknown MR, so four cases saw an
extra entry in its log.
…date policy"

This reverts commit 82ad001.

It stopped the one registry request that the shared update cache of Tencent#1751
needs under a skip policy to refresh a legacy null. That request runs in
the Stop hook's background pass, which nothing waits on.
The unwaited form was written on Windows too, where neither Claude Code's
nor Codex's hook was run with `async`. The member's own hook file now takes
the unwaited and login-deferred forms on macOS and Linux only, and on
Windows no tool is started to read its version. The guide also says that
Codex's form follows every Codex build teamai finds.
…, as their originals'

claude-internal and tclaude behave as Claude Code, and codex-internal and
tcodex as Codex: each is assumed to run a hook as its original does. On
macOS and Linux their own hook files now take the login-deferred form for
PostToolUse and UserPromptSubmit, and the wrapper is written for them.
They get no version floor, so their PostToolUse entry stays waited. Windows,
a project file and every other event keep today's entries.
Also the wrapper and its doctor check, the MR hint at the first prompt, and the directories uninstall now removes.
… emptied

The prune of the rules, skills and agents directories uninstall emptied
also removed empty folders below them, which teamai never wrote: a
member's empty `skills/drafts` went with it. Each directory is now removed
only when nothing at all is left in it.

AGENTS.md names the agent wait budget beside the context budget in
`scripts/`.
…mber's Codex home

`codex --version` first sets up its arg0 helpers under $CODEX_HOME/tmp,
so each full pull, which now asks every Codex build its version, left a
directory in ~/.codex/tmp per build. The probe now runs with a temporary
CODEX_HOME, removed after it answers. The fake codex makes that directory
as codex does.
…Codex does

A full pull asks each Codex build its version to choose the hook form. The
fake logged that probe as a trial's launch, so five benchmark cases saw an
extra codex entry.
Each row now carries waitedMs, the milliseconds the tool waited on the row's commands in that one run; an unwaited entry adds nothing. It is not a count: no comparison, invariant or growth line reads it, and it shows only as the last column of the full table, both sides, below <details>.
@github-actions

Copy link
Copy Markdown

No new findings in the specified PR diff.

All three earlier findings are resolved:

  • Queued MR hints recheck the current config and TEAMAI_MR_HINT_DISABLED before consumption.
  • Measured commands record nonzero exits, signals, and spawn errors as failures.
  • Ledger failures propagate into the budget job’s exit status.

The PR description includes sufficient representative real-CLI verification; no missing-e2e finding.

Review was read-only. No PR code was run, built, or installed.

@SaulMoro SaulMoro left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during triage.

Re-review at cdeec62b. Everything from the last round is resolved, so I'm approving. Merge waits for CI to finish green.

Resolved

  • Codex now gets the unwaited PostToolUse entry. With codex-cli 0.167.0 on PATH, init --agent codex writes "async": true into ~/.codex/hooks.json, and debug.log lists every Codex build it found with its version. If any build's version can't be read, the hook stays waited. That is the right default.
  • The version probe leaves the member's Codex home alone. A codex wrapper on PATH logged the call: --version ran with a throwaway CODEX_HOME, and nothing was left behind. Only the trust pass, which main already runs, wrote under ~/.codex.
  • Windows keeps the waited entry (builtinHookForm returns early on win32), and the body, both guides and the glossary say so. The Windows job passed.
  • The CHANGELOG covers the wrapper, the doctor check, the earlier MR hint and the fixes, including the internal variants.
  • Uninstall keeps a member's empty folder. A drafts folder made under .claude-internal/skills survived uninstall --force, and the emptied .tcodex/skills was removed.
  • AGENTS.md names the agent wait budget.
  • The revert puts src/update.ts back exactly as on main (git diff main -- src/update.ts is empty).

Nits, not blocking

  • [P3 nit] A workspace uninstall keeps ~/.tcodex/hooks.json for other installs but removes ~/.teamai/bin/teamai, which those kept entries start. sweepCreatedLeftovers doesn't look at plan.keptGlobal. The entries fall back to bash -lc, so nothing breaks; they are only as slow as on main. A follow-up could keep the wrapper whenever keptGlobal holds a hook file.
  • [P3 nit] The internal variants get the login-deferred entry on the stated assumption that they run hooks through a POSIX shell. The waited form already assumes the same.
  • [P3 nit] From the last round: the MR hint shows one prompt later on the first prompt. The CHANGELOG wording covers it.

Verified in an isolated verify world built from cdeec62b (macOS, claude 2.1.322, codex-cli 0.167.0):

  • init --agent codex writes the unwaited entry.
  • pull --force with the logging wrapper shows where each Codex call wrote.
  • init --agent claude-internal,tcodex writes login-deferred entries. A PostToolUse run took the fast path, and doctor reports the wrapper as current.
  • uninstall --force keeps the member's empty folder.
  • verify shows the same leftovers as main's world for the same tools.

@SaulMoro

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

The Node 22 Lint & Test job at cdeec62b fails in a test this PR adds: src/__tests__/hook-fast-path.test.ts:301, "writes nothing when the record already says the same, and rewrites it for another cwd".

expected 1794345599016.999 to be 1794345599017

The code under test is fine. The assertion is what fails. utimesSync passes the Date on as fractional seconds, the filesystem stores nanoseconds, and mtimeMs comes back as a float. A time with milliseconds in it doesn't always survive that round trip, so the test fails only for some values of Date.now(). The toBe assertion on line 301 needs exact equality.

A time in whole seconds survives the round trip exactly:

const past = new Date(Math.floor(Date.now() / 1000) * 1000 - 60_000);

My approval stands. I'll merge once CI is green on the new head and the review there is clean.

The file time read back on Linux can sit a fraction of a millisecond below
the one set, so the exact mtimeMs comparison failed there now and then.
@github-actions

Copy link
Copy Markdown
  • [P2 non-blocking] Preserve measurement failures in comparison mode — scripts/agent-wait-budget.ts:843. Running --compare main.json without a head file measures the checkout but discards failures and returns zero, even when a measured command fails or the ledger reports leaks. Report those failures and return nonzero; count growth alone can remain informational.

All three earlier findings are resolved in their originally reported paths.

The PR description includes sufficient representative real-CLI verification; no missing-e2e finding.

Review was read-only. No PR code was run, built, or installed.

@SaulMoro

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

17d9ba44: comparing mtime as a Date fixes the failing assertion. I ran the same round trip 20,000 times on macOS with no mismatch. The Linux job will confirm it.

The new automated finding is real, so one change before I approve this head:

  • [P2 non-blocking] scripts/agent-wait-budget.ts:843: --compare main.json with no head file measures the checkout, then drops failures and returns 0. CI isn't affected: the workflow measures the head on its own, checks its exit status, and then passes both files. A local run without a head file, though, reports a broken invariant or a leak as a pass. Something like:
if (flag === '--compare') {
  if (!baseFile) throw new Error('--compare needs the JSON file a run on main wrote.');
  const measured = headFile ? { budget: readBudget(headFile), failures: [] } : measureOrFail(false);
  const { markdown, grew } = compareAgentWait(measured.budget, readBudget(baseFile));
  process.stdout.write(`${markdown}\n`);

  if (grew.length > 0) console.error(`Agent wait grew: ${grew.join('; ')}.`);
  for (const failure of measured.failures) console.error(`Agent wait invariant broken: ${failure}`);

  return measured.failures.length === 0 ? 0 : 1;
}

Every init and pull wrote the wrapper as a new file. macOS takes longer over
the first run of a new executable file, about 30 ms here for one with bytes
it has seen, so the first hook after each pull that starts teamai through
the wrapper waited that much more. The wrapper is now written only when its
text differs or it is not executable.
The job log and a local run now show the time beside each row's counts, for
reference; until main has the budget the comparison table is not written.
…n invariant

`--compare <main.json>` with no head file measured the checkout and dropped
what the measure found, so a failed command or a ledger leak still exited 0.
It now reports them and exits 1, as the plain measure does. Growth alone
stays a note.
@github-actions

Copy link
Copy Markdown

No new findings in the specified PR diff.

All four earlier findings are resolved:

  • Queued MR hints recheck the current scope’s switch and TEAMAI_MR_HINT_DISABLED.
  • Measured commands record nonzero exits, signals, and spawn errors as failures.
  • Ledger failures propagate into the budget job’s exit status.
  • Comparison mode preserves and reports failures when it measures the head itself.

The PR description includes sufficient representative real-CLI verification; no missing-e2e finding.

Review was read-only. No PR code was run, built, or installed.

@SaulMoro SaulMoro left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during triage.

Approving 1c6f2f9a.

  • 1c6f2f9a: when --compare measures the head itself, it now reports that run's failures and exits 1. A head read from a file keeps the exit status it got when it was measured. This resolves the last finding.
  • 58ae5c21: an init or pull leaves the wrapper alone when the file already has the same content and is executable. A change in content, or a lost exec bit, still goes through the atomic rename. The new test covers all three cases: same content, lost exec bit and a changed Node path. The .cmd on Windows is still written every time, as before.
  • 17d9ba44: comparing the time as a Date fixes the failing assertion.

I'll merge once CI is green on this head.

@SaulMoro
SaulMoro merged commit ae289a9 into Tencent:main Nov 10, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants