Skip to content

feat(claude-code): mod for non-blocking plan review, annotate, review and last, with Ask this session - #1672

Merged
backnotprop merged 5 commits into
mainfrom
feat/claude-code-mod
Oct 3, 2026
Merged

backnotprop merged 5 commits into
mainfrom
feat/claude-code-mod

Conversation

@backnotprop

@backnotprop backnotprop commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Opt-in. On current Claude Code, hooks modules load even with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS unset, so shipping the module unconditionally would switch every 2.1.287+ user to these flows on the next release. The mod is therefore inert unless the user sets PLANNOTATOR_CLAUDE_MOD=1 or { "claudeCodeMod": true } in ~/.plannotator/config.json (env wins; resolveClaudeCodeMod in packages/shared/config.ts, mirrored in hooks/mod/enabled.ts with a parity test). Knob off: nothing is registered, no env is set, no process runs, and every hook passes through. Verified live on 2.1.288: with the knob off, plan review (deny, then approve), /plannotator-annotate, /plannotator-review and /plannotator-last all ran the classic blocking flows, and nothing was written under claude-code-mod/.

The Plannotator Claude Code plugin gains a mod: apps/hook/hooks/hooks.json now also names a hooks module ("modules": ["./mod/register.ts"]). Where Claude Code runs hooks modules (2.1.287+, CLI, interactive sessions), no Plannotator session holds a tool call open. The mod starts the CLI detached, returns at once, and sends the reviewer's decision later as a plugin turn ($.prompt.submit, from the plannotator plugin). Everyone without mods keeps today's flows.

Spec: .product/drafts/claude-code-mod/UX-SPEC.md (a draft; the choices made on its open questions are listed below).

What works

Flow Behavior Verified
Plan review: deny, revise, approve ExitPlanMode is denied with the waiting text, and plannotator claude-mod-plan starts detached. The denied prompt arrives as a plugin turn and Claude revises. On approval, Claude calls ExitPlanMode again, the call passes, classic.PermissionRequest allows it with setMode, and Claude implements. Live (2.1.288) and tests
Revision while a review is open Pushed into the same tab through a revision file the server polls (updatePlan, planRevisions: true). If a decision is already being recorded, Claude is told to wait. Tests: the CLI runs as a real process, plus controller tests. Not run live.
No double tabs The mod answers ExitPlanMode's classic.PermissionRequest without calling next. Modules sit above settings hooks, so the plugin's own PermissionRequest command hook never runs. Live: a probe plugin with a module and a command hook on the same event; the command hook never ran.
/plannotator-annotate, /plannotator-review, /plannotator-last The mod answers the user's existing skill commands, so the skill's blocking line never runs. The CLI starts detached with the same arguments, and the command returns "Opened … · url". Live, all three
Decisions with nothing to send Done with nothing to send, review LGTM, and Close only log a line; they do not start a turn. Live (annotate-last Done, review LGTM) and tests
Review posted to a PR platform Logged, and a follow-up is offered with $.prompt.suggest. No turn. Tests only
Feedback over 12 KB Written in full to feedback.md in the launch directory. Claude is told to Read it. Tests only
Ask this session (annotate) The question runs as a real turn in the session, streams back, and ends at turn.complete. Live
Ask this session (plan review) Real turns work. The session is not blocked under the mod. Live
Busy, interrupt, and cancel for Ask Busy is reported, an interrupt runs $.turn.abort, and a queued question that is cancelled has its turn aborted when it starts. Tests, against the real server half createPullSessionBridge
Review survives Claude Code exiting; --continue reattaches and delivers The server outlives the session. On --continue the mod logs "Reattached 1 open session" and delivers the decision. Live
Session tag PLANNOTATOR_SESSION_TAG=claude-code:<id> Set for every process the session starts. Live: a command hook saw it. Also covered by a claude plugin test harness test.
Old Claude Code (no hooks modules) The modules key is ignored. The classic blocking review runs, approval works, and no mod files are written. Live on 2.1.150 with this plugin
-p / SDK sessions, Windows (no /bin/sh) The mod stands down. Classic hook and skills run. Harness test (-p). Windows is by code only.
CLI older than the plugin ExitPlanMode falls back to the classic flow. Review and annotate are delivered from stdout. Tests only

One live finding changed the design. Claude Code wraps a plugin prompt as "The plannotator plugin sent a message: …", so the mod recognizes its question by the question's first and last lines. A bun test covers this.

How it works

  • Detached launch. $.process.run runs once and returns, and a hook gets a 10 s budget. So the mod runs a small /bin/sh wrapper that starts the CLI in the background, with every stream going to a file in <data dir>/claude-code-mod/<session>/<launch>/, and returns in a few milliseconds. The wrapper ignores SIGHUP and uses nohup: a closing terminal used to kill the server, and that was found live. The mod has no listener. A 1 s $.clock.every timer reads the result file and checks the pid with kill -0. Waits inside hooks use a shell loop through $.process.run, not $.clock waits, because $.clock waits count against the hook budget.
  • New CLI side channel: PLANNOTATOR_HOST_RESULT_FILE (apps/hook/server/host-result.ts). When a review, annotate, annotate-last or claude-mod-plan session settles, the CLI writes one JSON record atomically. message is already built from the configured prompts (composePlanDeniedMessage, the answered and approved prompts, the annotate and review prompts), so the mod does not rebuild prompts. Stdout is unchanged. Like the bridge token, the path is read at startup and then removed from the environment.
  • New internal subcommand claude-mod-plan. It runs the plan server with planRevisions: true. Revisions are read from a file and acknowledged. The decision record carries approvedPlan, the text the reviewer actually decided on, which is the idea from feat(pi): non-blocking plan review, with Ask this session during review #1670. The plan is read from the plan file when it can be trusted (fix(hook): review Claude's plan file, not its stale inline snapshot #1667), both in the mod and when it hashes the approved plan for the second ExitPlanMode call.
  • Ask this session. Each server gets a 32-byte token in PLANNOTATOR_SESSION_BRIDGE_TOKEN, with host claude-code and modes turn. The mod polls /api/ai/bridge/poll with $.http.fetch (loopback, Bearer token, no Origin header) and posts started, delta, tool, done and error events.

Choices on the spec's open questions (conservative)

  1. Approval: two hops. Claude calls ExitPlanMode again and the mod lets it through. The one-hop $.tool.call path is untested and not built.
  2. Revisions while open: they replace the plan in the same tab as vN+1, as on Pi (feat(pi): non-blocking plan review, with Ask this session during review #1670).
  3. Auto-send while the user is typing: decisions are sent anyway. $.prompt.submit waits for idle and does not touch the draft. There is no Hold, because there is no band.
  4. No-op decisions: Done, LGTM and Close never wake Claude. A review posted to a platform logs and suggests a follow-up. Plan approval always wakes Claude.
  5. Inline limit: 12 KB. Above it, the mod writes its own copy of the feedback (feedback.md) instead of pointing at the feedback archive, because the archive can be turned off.
  6. Band: not built in this phase. There is a status line ($.ui.status), toasts and log lines.
  7. Name collisions: the /plannotator-* names stay, and the core skills keep being installed. The mod answers the skill commands through command.run. It only registers a name that nobody holds, because Claude Code refuses to register a name a user skill already holds (seen live).
  8. and 9. @claude comment tags; Ask AI under the mod: tags are not built. Ask AI keeps both options. "Ask this session · Claude Code" appears through the existing client default, and the separate SDK AI is still available.

Not built (follow-ups)

  • The AbovePrompt band (Send now / Hold / Open), the /plannotator sessions pane (its name is taken by the knowledge skill), and the UserMessage / ToolUse render hooks.
  • The edit guard (deny Edit/Write while a plan is in review after the user leaves plan mode). It was left out so users can't get stuck. If the user leaves plan mode during review, the decision still arrives later.
  • Detecting a closed tab (tab-closed).
  • A subagent's ExitPlanMode keeps the classic flow.

Risks

  • With mods on, a review server outlives Claude Code by design, so it can be reattached. If a session is never resumed, its review server keeps running until the reviewer decides or closes it. This is the same as a backgrounded plannotator review.
  • Modules load even with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS unset or set to 0, so the mod is opt-in (see the top). Pre-mods 2.1.150 was verified by the builder.
  • The mod types its use of $ with local minimal types, because the engine's generated claude-code.d.ts is not vendored. claude plugin validate and the harness tests check the event shapes.

Tests

  • Bun tests:
    • apps/hook/hooks/mod/*.test.ts: controller flows over an in-memory host, delivery, shell-word splitting, and the bridge against the real server half.
    • apps/hook/server/host-result.test.ts
    • apps/hook/server/claude-mod-plan.test.ts: the subcommand runs as a process. A revision reaches the tab, the approval carries the revised text, and answers-only works.
  • Engine harness: apps/hook/tests/register.test.ts through scripts/test-claude-code-mod.sh. The script stages a copy of the plugin without the CLI, because claude plugin test would otherwise try to load every bun test in apps/hook. Bun skips this folder through pathIgnorePatterns. 4 of 4 pass.
  • bun run typecheck passes. It now includes apps/hook/hooks/mod/tsconfig.json.
  • Full bun test: 5889 pass, 0 fail. That run was before the last small commit; the affected suites were re-run after it.
  • bun run --cwd apps/review build && bun run build:hook: pass.
  • claude plugin validate apps/hook: passes.
  • Live smoke: done on Claude Code 2.1.288 and 2.1.150, driven through a pty, with the source-run CLI on PATH. A script made the browser decisions through the HTTP API, using PLANNOTATOR_SKIP_BROWSER_OPEN=1 and a scratch data dir.

No version bumps. Draft until the owner answers the spec's open questions.

Review fixes (follow-up commits)

  • Opt-in knob (above).
  • Session boundaries. session.start does not fire for /clear or an in-process resume. session.end now disposes the instance, and the next hook makes a new one for the current session id. Verified live: a plan denied after /clear was not delivered into the cleared session. It was delivered when that session was resumed with --resume.
  • Ask this session. The engine never runs a plugin's own prompt.submit hook for a prompt that plugin submitted. So the hook records every prompt it does see (the user's, a notification's, another plugin's), and a turn whose text is exactly one of those is never claimed as the question's turn. A prompt the user typed can't be streamed to Plannotator or aborted by a cancel. Verified live: an Ask streamed and completed.
  • Launch directories are created owner-only (umask 077, chmod 700) before stdin is written. Once a decision is delivered, the launch's files are removed, except feedback.md.
  • PLANNOTATOR_HOST_RESULT_FILE is honored only for a result.json under <data dir>/claude-code-mod/. Any other path is ignored with a warning on stderr.
  • A subagent's ExitPlanMode PermissionRequest goes to the classic hook. The data dir follows the CLI's XDG fallback. The liveness probe uses the shell's own kill.

… and last, with Ask this session

The Claude Code plugin's hooks.json now also names a hooks module
(apps/hook/hooks/mod/register.ts). Where Claude Code runs hooks modules
(2.1.287+, CLI, interactive), no Plannotator session holds a tool call open:

- ExitPlanMode is answered with a deny at once and plannotator claude-mod-plan
  starts detached; revisions while open go into the same tab; the decision
  arrives later as a plugin turn. An approval asks Claude to call ExitPlanMode
  again; the call whose plan matches the approved text passes and
  classic.PermissionRequest allows it with updatedInput = the approved text and
  the reviewer's permission mode. Modules sit above the settings hooks, so the
  classic command hook never opens a second review.
- /plannotator-review, -annotate and -last keep their names: the mod answers
  the user's skills' commands itself and starts the CLI detached with the same
  arguments; Done / LGTM / Close / platform posts never start a turn.
- Ask this session over the pull bridge: each launched server gets a token,
  questions run as real turns (plan review included), busy/interrupt/cancel
  follow turn.start / turn.complete.
- PLANNOTATOR_SESSION_TAG tags the session for everything it starts.

CLI: PLANNOTATOR_HOST_RESULT_FILE side channel (one composed decision record
per settled session; stdout unchanged), the internal claude-mod-plan
subcommand, hostSession in the sessions registry. Older Claude Code ignores the
modules key (verified live on 2.1.150); -p/SDK sessions and Windows keep the
classic flows.
…odeMod

Hooks modules load on current Claude Code even with
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS unset, so shipping the module would switch
every 2.1.287+ user to the non-blocking flows on the next release. Until the
owner makes it the default, the mod is inert unless PLANNOTATOR_CLAUDE_MOD=1
or { "claudeCodeMod": true } in config.json (env wins; resolveClaudeCodeMod
in packages/shared/config.ts, mirrored in hooks/mod/enabled.ts with a parity
test). Knob off: no command registered, no env set, no store, no process;
every hook passes through, so the classic hook and skills run unchanged.

Also in register.ts: $ only reaches top-level functions (validator rule),
the data dir follows the CLI's XDG fallback, session.end disposes the
instance and the next hook re-creates it for the current session id
(session.start does not fire for /clear or an in-process resume), a subagent's
ExitPlanMode PermissionRequest goes to the classic hook, and an Ask turn is
claimed only after a prompt.submit with origin plugin plannotator.
…inment, session boundaries

- Launch dir created owner-only (umask 077, chmod 700) before stdin (plan or
  last message) is written; settled launches are cleaned up except
  feedback.md, which Claude reads afterwards.
- PLANNOTATOR_HOST_RESULT_FILE is honored only for a result.json under
  <data dir>/claude-code-mod/, so it cannot make the CLI create or replace an
  arbitrary file.
- A disposed instance (session.end: /clear, in-process resume, exit) stops
  its timer and bridges: no decision is delivered into another session; the
  reviews reattach when that session is resumed.
- Ask this session claims a turn only after our own plugin-origin prompt
  entered; a user prompt containing the question text is never streamed to
  Plannotator or aborted by a cancel.
- Liveness probe uses the shell's kill (no /bin/kill on some systems).
…submitted

Live on 2.1.288 the engine never raises a plugin's own prompt.submit hook for
a prompt that plugin submitted ("skipped: re-entry"), so arming on our own
origin never fired and Ask this session hung. The hook now records every
prompt it does see (the user's, a notification's, another plugin's); a turn
whose text is exactly one of those is never claimed as the question's.
Verified live: Ask streams and completes; a user prompt's turn text equals its
prompt.submit text.
@backnotprop
backnotprop marked this pull request as ready for review October 3, 2026 17:26
@backnotprop
backnotprop merged commit 772c620 into main Oct 3, 2026
28 checks passed
@backnotprop
backnotprop deleted the feat/claude-code-mod branch October 3, 2026 17:39
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.

1 participant