Repository navigation
feat(claude-code): mod for non-blocking plan review, annotate, review and last, with Ask this session - #1672
Merged
Merged
Conversation
… 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Plannotator Claude Code plugin gains a mod:
apps/hook/hooks/hooks.jsonnow 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 theplannotatorplugin). 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
plannotator claude-mod-planstarts detached. The denied prompt arrives as a plugin turn and Claude revises. On approval, Claude calls ExitPlanMode again, the call passes,classic.PermissionRequestallows it withsetMode, and Claude implements.updatePlan,planRevisions: true). If a decision is already being recorded, Claude is told to wait.classic.PermissionRequestwithout callingnext. Modules sit above settings hooks, so the plugin's own PermissionRequest command hook never runs./plannotator-annotate,/plannotator-review,/plannotator-last$.prompt.suggest. No turn.feedback.mdin the launch directory. Claude is told to Read it.turn.complete.$.turn.abort, and a queued question that is cancelled has its turn aborted when it starts.createPullSessionBridge--continuereattaches and delivers--continuethe mod logs "Reattached 1 open session" and delivers the decision.PLANNOTATOR_SESSION_TAG=claude-code:<id>claude plugin testharness test.moduleskey is ignored. The classic blocking review runs, approval works, and no mod files are written.-p/ SDK sessions, Windows (no/bin/sh)-p). Windows is by code 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
$.process.runruns once and returns, and a hook gets a 10 s budget. So the mod runs a small/bin/shwrapper 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 usesnohup: a closing terminal used to kill the server, and that was found live. The mod has no listener. A 1 s$.clock.everytimer reads the result file and checks the pid withkill -0. Waits inside hooks use a shell loop through$.process.run, not$.clockwaits, because$.clockwaits count against the hook budget.PLANNOTATOR_HOST_RESULT_FILE(apps/hook/server/host-result.ts). When a review, annotate, annotate-last orclaude-mod-plansession settles, the CLI writes one JSON record atomically.messageis 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.claude-mod-plan. It runs the plan server withplanRevisions: true. Revisions are read from a file and acknowledged. The decision record carriesapprovedPlan, 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.PLANNOTATOR_SESSION_BRIDGE_TOKEN, with hostclaude-codeand modesturn. The mod polls/api/ai/bridge/pollwith$.http.fetch(loopback, Bearer token, no Origin header) and postsstarted,delta,tool,doneanderrorevents.Choices on the spec's open questions (conservative)
$.tool.callpath is untested and not built.$.prompt.submitwaits for idle and does not touch the draft. There is no Hold, because there is no band.feedback.md) instead of pointing at the feedback archive, because the archive can be turned off.$.ui.status), toasts and log lines./plannotator-*names stay, and the core skills keep being installed. The mod answers the skill commands throughcommand.run. It only registers a name that nobody holds, because Claude Code refuses to register a name a user skill already holds (seen live).@claudecomment 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)
AbovePromptband (Send now / Hold / Open), the/plannotatorsessions pane (its name is taken by the knowledge skill), and theUserMessage/ToolUserender hooks.tab-closed).Risks
plannotator review.CLAUDE_CODE_ENABLE_FUNCTION_HOOKSunset or set to 0, so the mod is opt-in (see the top). Pre-mods 2.1.150 was verified by the builder.$with local minimal types, because the engine's generatedclaude-code.d.tsis not vendored.claude plugin validateand the harness tests check the event shapes.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.tsapps/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.apps/hook/tests/register.test.tsthroughscripts/test-claude-code-mod.sh. The script stages a copy of the plugin without the CLI, becauseclaude plugin testwould otherwise try to load every bun test inapps/hook. Bun skips this folder throughpathIgnorePatterns. 4 of 4 pass.bun run typecheckpasses. It now includesapps/hook/hooks/mod/tsconfig.json.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.PLANNOTATOR_SKIP_BROWSER_OPEN=1and a scratch data dir.No version bumps. Draft until the owner answers the spec's open questions.
Review fixes (follow-up commits)
session.startdoes not fire for/clearor an in-process resume.session.endnow disposes the instance, and the next hook makes a new one for the current session id. Verified live: a plan denied after/clearwas not delivered into the cleared session. It was delivered when that session was resumed with--resume.prompt.submithook 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.umask 077,chmod 700) beforestdinis written. Once a decision is delivered, the launch's files are removed, exceptfeedback.md.PLANNOTATOR_HOST_RESULT_FILEis honored only for aresult.jsonunder<data dir>/claude-code-mod/. Any other path is ignored with a warning on stderr.PermissionRequestgoes to the classic hook. The data dir follows the CLI's XDG fallback. The liveness probe uses the shell's ownkill.