Interactive Plan Review: Mark up and refine your plans using a UI, easily share for team collaboration, automatically integrates with plan mode hooks.

This directory contains the Claude Code plugin configuration for Plannotator.
Install the plannotator command so Claude Code can use it:
macOS / Linux / WSL:
curl -fsSL https://plannotator.ai/install.sh | bash
Windows PowerShell:
irm https://plannotator.ai/install.ps1 | iex
Windows CMD:
curl -fsSL https://plannotator.ai/install.cmd -o install.cmd && install.cmd && del install.cmd
Released binaries ship with SHA256 sidecars and SLSA build provenance attestations from v0.17.2 onwards. See the installation docs for version pinning and the verification docs for verification commands.
Plugin Installation · Manual Installation (Hooks) · Obsidian Integration
In Claude Code:
/plugin marketplace add backnotprop/plannotator
/plugin install plannotator@plannotator
Important: Restart Claude Code after installing the plugin for the hooks to take effect.
Refreshing the marketplace alone does not update an installed plugin. From a terminal:
claude plugin marketplace update plannotator
claude plugin update plannotator@plannotator
Or inside Claude Code: run /plugin marketplace update plannotator, then open /plugin, go to Installed, select plannotator and choose Update now. Then restart Claude Code. Run the install script again to update the plannotator binary.
If you prefer not to use the plugin system, add this to your ~/.claude/settings.json:
{
"hooks": {
"PermissionRequest": [
{
"matcher": "ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "plannotator",
"timeout": 345600
}
]
}
]
}
}
In the interactive terminal on Claude Code 2.1.287 or newer, the plugin runs the Plannotator mod. It is on by default:
/plannotator-review, /plannotator-annotate and /plannotator-last don't make Claude wait. Claude ends its turn and your decision arrives later as a message. You can keep chatting meanwhile.ExitPlanMode once more and works from the exact plan text you approved.plannotator tool.While a plan review is open Claude is not blocked, so if you leave plan mode yourself before you approve, it can start editing.
Turn the mod off with PLANNOTATOR_CLAUDE_MOD=0 or { "claudeCodeMod": false } in ~/.plannotator/config.json (read when Claude Code starts). Older Claude Code, claude -p and SDK runs, and Windows always use the classic hook below.
When Claude Code calls ExitPlanMode, this hook intercepts and waits for your decision (Claude waits too):
| Variable | Description |
|---|---|
PLANNOTATOR_REMOTE | Set to 1 / true for remote mode, 0 / false for local mode, or leave unset for SSH auto-detection. Uses a fixed port in remote mode; browser-opening behavior depends on the environment. |
PLANNOTATOR_PORT | Fixed port to use. Default: random locally, 19432 for remote sessions. |
PLANNOTATOR_BROWSER | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. |
PLANNOTATOR_SHARE_URL | Custom share portal URL for self-hosting. Default: https://share.plannotator.ai. |
PLANNOTATOR_CLAUDE_MOD | The Plannotator mod is on by default. Set to 0 / false / off / disabled to turn it off and use the classic hook. Read when Claude Code starts. |
PLANNOTATOR_MOD_DEBUG | Set to 1 before starting Claude Code to write a mod debug log to ~/.plannotator/claude-code-mod/debug.log. |
When running Claude Code in a remote environment (SSH, devcontainer, WSL), set PLANNOTATOR_REMOTE=1 (or true) and these environment variables:
export PLANNOTATOR_REMOTE=1
export PLANNOTATOR_PORT=9999 # Choose a port you'll forward
This tells Plannotator to:
Port forwarding in VS Code devcontainers: The port should be automatically forwarded. Check the "Ports" tab.
SSH port forwarding: Add to your ~/.ssh/config:
Host your-server
LocalForward 9999 localhost:9999
Plannotator's slash commands are installed as Claude Code skills in ~/.claude/skills by the install script (the canonical source is apps/skills/core/). Claude Code skills are user-invocable by directory name, so these three work like slash commands inside your session:
| Command | Description | |||
|---|---|---|---|---|
| `/plannotator-review [--git \ | --gitbutler] [DIRECTORY \ | PR_URL]` | Open code review UI for current changes, another repository/worktree, or a PR; optionally force the Git or GitButler provider | |
| `/plannotator-annotate <file.md \ | file.html \ | https://... \ | folder/>` | Annotate a file, URL, or folder |
/plannotator-last | Annotate the agent's last message |
Approved plans can be automatically saved to your Obsidian vault.
Setup:
plannotator)What gets saved:
Title - Jan 2, 2026 2-30pm.mdcreated, source, and tags[[Plannotator Plans]] for graph connectivityExample saved file:
---
created: 2026-01-02T14:30:00.000Z
source: plannotator
tags: [plan, authentication, typescript, sql]
---
[[Plannotator Plans]]
# Implementation Plan: User Authentication
...
<img width="1190" height="730" alt="image" src="https://github.com/user-attachments/assets/1f0876a0-8ace-4bcf-b0d6-4bbb07613b25" />
hooks/mod/register.ts 457 lines1/**
2 * The Plannotator mod: non-blocking plan review, annotate, code review and
3 * annotate-last for Claude Code, plus "Ask this session".
4 *
5 * ON BY DEFAULT (enabled.ts). With `PLANNOTATOR_CLAUDE_MOD=0` (or
6 * `{ "claudeCodeMod": false }` in config.json) every hook below passes straight
7 * through and nothing is registered or set, so the classic hook and skills
8 * run exactly as they do without mods.
9 *
10 * Loaded from `hooks/hooks.json` ("modules") only where Claude Code runs
11 * hooks modules (function hooks, 2.1.287+, CLI). Elsewhere the same plugin's
12 * classic command hooks and the `/plannotator-*` skills behave exactly as
13 * before; the mod also stands down in `-p`/SDK sessions and where there is no
14 * `/bin/sh` (Windows), leaving those classic paths in place.
15 *
16 * What it does:
17 * - ExitPlanMode (`tool.call`): answers `deny` with "waiting for review" text
18 * at once and starts `plannotator claude-mod-plan` detached. A revision
19 * while the review is open goes into the same tab. The decision arrives
20 * later as a plugin turn (`$.prompt.submit`, origin `plannotator`). On
21 * approval Claude calls ExitPlanMode again; the call whose plan matches the
22 * approved text passes, and `classic.PermissionRequest` allows it with the
23 * reviewer's permission mode. Because modules sit above the settings hooks
24 * in that chain, the plugin's classic PermissionRequest command hook is
25 * never reached for it: no second review.
26 * - `/plannotator-review`, `/plannotator-annotate`, `/plannotator-last`
27 * (`command.run`): the user's skills keep their names; the mod answers the
28 * command itself, starting the CLI detached with the same arguments, and
29 * returns the "Opened …" line. It registers a name only where no skill holds it.
30 * - The `plannotator` tool (`$.tool.register`, tool.ts): when Claude itself is
31 * asked to open something in Plannotator it calls this tool instead of the
32 * CLI; the call goes through the same detached launch and returns at once.
33 * Not registered when the user turned the agent tool off
34 * (`PLANNOTATOR_AGENT_TOOL=0` / `{ "agentTool": false }`, read once with the
35 * mod switch). A main-loop Bash call that runs the CLI in a form the tool
36 * can represent (`plannotator annotate|review|last ...`, take-over.ts) is
37 * answered the same way instead of running, with or without the tool: the
38 * take-over adds nothing to Claude's context, it only changes what a
39 * command Claude already chose to run does.
40 * - "Ask this session": each launched server gets a pull-bridge token; the
41 * mod polls it and runs the reviewer's questions as turns (bridge.ts).
42 * - The Plannotator Inbox (inbox.ts): where `inbox/inbox.json` exists at
43 * session start and the inbox tool switch allows it (on by default,
44 * `PLANNOTATOR_INBOX_TOOL=0` / `{ "inboxTool": false }`), the
45 * `plannotator_inbox` tool, whose actions are the Inbox's own MCP tools read
46 * once from its `/mcp`, and the reply wake: the person's Send in the Inbox
47 * reaches the session that asked as a turn once it is idle. No registry:
48 * nothing is registered and nothing polls.
49 * - `PLANNOTATOR_SESSION_TAG=claude-code:<session id>` in the environment
50 * every process the session starts inherits, so a Plannotator server can be
51 * matched to the session that started it.
52 *
53 * Every `$` call is in this file (`claude plugin validate` follows `$`); the
54 * logic lives behind `Host` in controller.ts.
55 */
56
57import { PlannotatorMod } from './controller'
58import { resolveAgentToolEnabled, resolveClaudeModEnabled, resolveInboxToolEnabled } from './enabled'
59import type { Host } from './host'
60import { discoverInboxTools } from './inbox'
61import { inboxAgentTool, type InboxToolInfo } from './inbox-contract'
62import { COMMANDS, dataDirOf, debugAppendArgv, isModCommand, waitArgv } from './launch'
63import { PLAN_TOOL } from './plan'
64import { answerShellCall, SHELL_TOOL, shellTakeOver } from './take-over'
65import { PLANNOTATOR_TOOL_DESCRIPTION, PLANNOTATOR_TOOL_INPUT_SCHEMA, PLANNOTATOR_TOOL_NAME } from './tool'
66
67// Minimal local types: the engine's declarations are written by `/plugin-types`
68// and not vendored here.
69type Engine = any
70type Next = any
71type On = (event: string, ...args: unknown[]) => void
72
73function hexOf(bytes: Uint8Array): string {
74 let out = ''
75 for (const byte of bytes) out += byte.toString(16).padStart(2, '0')
76 return out
77}
78
79/** Debug lines waiting to be appended; at most this many are kept while an append runs. */
80const DEBUG_PENDING_MAX = 500
81let debugPending: string[] = []
82let debugAppending = false
83/** Tells this process's lines apart from another's in the shared log. */
84let debugTag: string | null = null
85
86/** This plugin's name: what `$.prompt.submit` stamps as the origin of our prompts. */
87const PLUGIN_NAME = 'plannotator'
88
89/** Every closure over `$` the logic uses. `debugPath` set: lines go to that file. */
90function hostOf($: Engine, debugPath: string | null): Host {
91 return {
92 now: () => $.clock.now(),
93 sleep: (ms, signal) => $.clock.sleep(ms, signal ? { signal } : undefined),
94 waitForAny: async (paths, timeoutMs) => {
95 await $.process.run(waitArgv(paths, timeoutMs), { timeoutMs: timeoutMs + 5_000 }).catch(() => undefined)
96 },
97 every: (ms, fn) => $.clock.every(ms, fn),
98 run: (argv, init) => $.process.run(argv, init),
99 readFile: (path) => $.fs.read(path),
100 writeFile: (path, text) => $.fs.write(path, text),
101 exists: (path) => $.fs.exists(path),
102 fileSize: async (path) => {
103 const stat = await $.fs.stat(path)
104 return stat && stat.kind === 'file' ? Number(stat.size) : null
105 },
106 storeGet: (key) => $.store.get(key),
107 storeSet: (key, value) => $.store.set(key, value),
108 submit: async (text) => {
109 await $.prompt.submit({ text })
110 },
111 suggest: async (text) => {
112 await $.prompt.suggest({ text })
113 },
114 status: (text) => $.ui.status(text),
115 log: (text) => $.ui.log(text),
116 toast: (text) => $.ui.toast(text),
117 messages: async () => {
118 const messages = await $.session.messages()
119 return (Array.isArray(messages) ? messages : []).map((message: { role: 'user' | 'assistant'; text: string }) => ({
120 role: message.role,
121 text: typeof message.text === 'string' ? message.text : '',
122 }))
123 },
124 fetch: (url, init) => $.http.fetch(url, init),
125 abortTurn: (turnId) => $.turn.abort({ turnId }),
126 randomHex: (bytes) => hexOf(crypto.getRandomValues(new Uint8Array(bytes))),
127 debug: (text) => {
128 if (!debugPath) return
129 debugTag ??= hexOf(crypto.getRandomValues(new Uint8Array(3)))
130 debugPending.push(`${new Date().toISOString()} [${debugTag}] ${text}`)
131 if (debugPending.length > DEBUG_PENDING_MAX) debugPending.splice(0, debugPending.length - DEBUG_PENDING_MAX)
132 if (debugAppending) return
133 // Appended (O_APPEND, launch.ts DEBUG_APPEND_SCRIPT), one batch at a
134 // time: several Claude Code processes share this log, and rewriting the
135 // whole file from each one's buffer clobbered the other's lines.
136 debugAppending = true
137 void (async () => {
138 try {
139 while (debugPending.length > 0) {
140 const batch = debugPending
141 debugPending = []
142 await $.process.run(debugAppendArgv(debugPath), { stdin: `${batch.join('\n')}\n`, timeoutMs: 5_000 }).catch(() => undefined)
143 }
144 } finally {
145 debugAppending = false
146 }
147 })()
148 },
149 sha256: async (text) => hexOf(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))),
150 }
151}
152
153/** What session.start found: the mod may run in this process. Null: inert. */
154interface Allowed {
155 dataDir: string
156 debugPath: string | null
157 /** Register Claude's `plannotator` tool (the agent tool switch, on by default). */
158 agentTool: boolean
159 /** Connect to the Plannotator Inbox when one is found (the inbox tool switch, on by default). */
160 inboxTool: boolean
161}
162
163// One plugin instance per Claude Code process.
164let allowed: Allowed | null = null
165/**
166 * The Inbox tools registered as `plannotator_inbox` at the first session.start
167 * (fixed for the process: the tool list is part of Claude's prompt); null: no
168 * Inbox connection.
169 */
170let inboxTools: readonly InboxToolInfo[] | null = null
171/** `mcp__<plugin server>__plannotator_inbox` as the engine registered it; null until then. */
172let inboxToolName: string | null = null
173let mod: PlannotatorMod | null = null
174let switching: Promise<PlannotatorMod | null> | null = null
175
176/**
177 * The instance for the session the process is in NOW. `session.start` does
178 * not fire for `/clear` or an in-process resume (the process goes on under
179 * another session id), so the id is checked here and a new instance is made
180 * (restoring that session's open reviews) when it changed. The old one was
181 * disposed at `session.end`, so its decisions never land in another session.
182 */
183async function currentMod($: Engine): Promise<PlannotatorMod | null> {
184 const settings = allowed
185 if (!settings) return null
186 const sessionId = await $.session.id()
187 if (mod && !mod.isDisposed && mod.session.sessionId === sessionId) return mod
188 if (switching) return switching
189 switching = (async () => {
190 mod?.dispose()
191 await $.env.set('PLANNOTATOR_SESSION_TAG', `claude-code:${sessionId}`)
192 const instance = new PlannotatorMod(hostOf($, settings.debugPath), {
193 sessionId,
194 dataDir: settings.dataDir,
195 interactive: true,
196 ...(inboxTools ? { inboxTools, cwd: async () => String(await $.session.cwd()) } : {}),
197 })
198 mod = instance
199 await instance.restore().catch(() => undefined)
200 return instance
201 })()
202 try {
203 return await switching
204 } finally {
205 switching = null
206 }
207}
208
209/** Whether the mod runs here (on unless the user turned it off) and where its data dir is; null: stay inert. */
210async function resolveAllowed($: Engine, e: { isInteractive?: unknown }): Promise<Allowed | null> {
211 // A person at the prompt is what makes a later plugin turn mean anything;
212 // `-p` and SDK runs keep the classic, blocking flows.
213 if (!e.isInteractive) return null
214 if (!(await $.fs.exists('/bin/sh'))) return null
215 const home = await $.env.get('HOME')
216 const dataDir = dataDirOf({
217 home,
218 dataDir: await $.env.get('PLANNOTATOR_DATA_DIR'),
219 xdgDataHome: await $.env.get('XDG_DATA_HOME'),
220 legacyExists: home ? await $.fs.exists(`${String(home).replace(/\/+$/, '')}/.plannotator`) : false,
221 })
222 if (!dataDir) return null
223 // On by default; nothing happens when the user turned the mod off.
224 const read = await $.fs.read(`${dataDir}/config.json`).catch(() => null)
225 const configText = typeof read === 'string' ? read : null
226 if (!resolveClaudeModEnabled(await $.env.get('PLANNOTATOR_CLAUDE_MOD'), configText)) {
227 return null
228 }
229 const debug = await $.env.get('PLANNOTATOR_MOD_DEBUG')
230 return {
231 dataDir,
232 debugPath: debug && debug !== '0' ? `${dataDir}/claude-code-mod/debug.log` : null,
233 agentTool: resolveAgentToolEnabled(await $.env.get('PLANNOTATOR_AGENT_TOOL'), configText),
234 inboxTool: resolveInboxToolEnabled(await $.env.get('PLANNOTATOR_INBOX_TOOL'), configText),
235 }
236}
237
238/** Register the slash commands no one holds (the user's own skills keep theirs; command.run answers them). */
239async function registerCommands($: Engine): Promise<void> {
240 const listed = await $.command.list().catch(() => [])
241 const taken = new Set((Array.isArray(listed) ? listed : []).map((command: { name: string }) => command.name))
242 for (const [name, spec] of Object.entries(COMMANDS)) {
243 if (taken.has(name)) continue
244 await $.command
245 .register({ name, description: spec.description, ...(spec.argumentHint ? { argumentHint: spec.argumentHint } : {}), immediate: true })
246 .catch(() => undefined)
247 }
248}
249
250/**
251 * The `plannotator` tool's full name as the engine registered it
252 * (`mcp__<plugin server>__plannotator`); null until registered, and never set
253 * while the mod is off.
254 */
255let toolName: string | null = null
256
257/** Register Claude's `plannotator` tool (tool.ts): agent-initiated opens get the slash commands' launch. */
258async function registerTool($: Engine): Promise<void> {
259 try {
260 const registered = await $.tool.register({
261 name: PLANNOTATOR_TOOL_NAME,
262 description: PLANNOTATOR_TOOL_DESCRIPTION,
263 inputSchema: PLANNOTATOR_TOOL_INPUT_SCHEMA,
264 })
265 toolName = registered && typeof registered.tool === 'string' ? registered.tool : null
266 } catch (error) {
267 // A session with no built-in tools, --bare, or a host without a tool
268 // registrar: Claude keeps the CLI through the plannotator skill.
269 toolName = null
270 $.ui.log(`Plannotator: the plannotator tool is not available in this session (${error instanceof Error ? error.message : String(error)}).`)
271 }
272}
273
274/**
275 * Register `plannotator_inbox` when an Inbox is found (inbox.ts): its actions
276 * are the tools the Inbox offers, so an older Inbox gives fewer actions and no
277 * registry gives no tool at all.
278 */
279async function registerInboxTool($: Engine, settings: Allowed): Promise<void> {
280 const tools = await discoverInboxTools(hostOf($, settings.debugPath), settings.dataDir).catch(() => null)
281 const spec = tools ? inboxAgentTool(tools) : null
282 if (!tools || !spec) return
283 try {
284 const registered = await $.tool.register(spec)
285 inboxToolName = registered && typeof registered.tool === 'string' ? registered.tool : null
286 if (inboxToolName) inboxTools = tools
287 } catch (error) {
288 inboxToolName = null
289 $.ui.log(`Plannotator: the plannotator_inbox tool is not available in this session (${error instanceof Error ? error.message : String(error)}).`)
290 }
291}
292
293/** A tool call's arguments: the event minus the keys the engine reserves. */
294function toolArgsOf(e: Record<string, unknown>): Record<string, unknown> {
295 const { tool: _tool, tool_use_id: _id, agentId: _agent, consent: _consent, ...args } = e
296 return args
297}
298
299export function register(on: On) {
300 on('session.start', async ($: Engine, e: any, next: Next) => {
301 const result = await next(e)
302 if (allowed) return result
303 allowed = await resolveAllowed($, e)
304 if (!allowed) return result
305 // Before the instance exists: it owns the Inbox link when the tool is registered.
306 if (allowed.inboxTool) await registerInboxTool($, allowed)
307 const instance = await currentMod($)
308 if (!instance) return result
309 await registerCommands($)
310 // Decided once per process, here: the tool list is part of Claude's
311 // prompt, so it never changes under a running session.
312 if (allowed.agentTool) await registerTool($)
313 return result
314 })
315
316 on('session.end', async ($: Engine, e: any, next: Next) => {
317 // Stop delivering for the session that ended; its open reviews stay in
318 // the store and reattach if it is resumed.
319 mod?.dispose()
320 return next(e)
321 })
322
323 // Matched by name: an unmatched hook makes the engine credit Plannotator on every plugin's command answer.
324 on('command.run', { command: Object.keys(COMMANDS) }, async ($: Engine, e: any, next: Next) => {
325 const name: string = typeof e.command === 'string' ? e.command : ''
326 if (!allowed || !isModCommand(name)) return next(e)
327 const instance = await currentMod($)
328 if (!instance) return next(e)
329 const spec = COMMANDS[name]
330 const text = await instance.runCommand(spec.kind, typeof e.args === 'string' ? e.args : '')
331 return { text }
332 })
333
334 on('tool.call', async ($: Engine, e: any, next: Next) => {
335 // Claude's `plannotator` tool: answered here, with the same detached
336 // launch the slash commands use. No other hook or core runs for it.
337 if (allowed && toolName && e.tool === toolName) {
338 const instance = await currentMod($)
339 if (!instance) return { deny: 'Plannotator is not available in this session; run the plannotator CLI instead.' }
340 // `last` reads the main session's transcript, so from a subagent it
341 // would annotate a message the subagent never wrote.
342 if (e.agentId && e.action === 'last') {
343 return { deny: 'Invalid plannotator call: action "last" annotates the main session\'s last message and is not available to a subagent.' }
344 }
345 const answer = await instance.runTool(toolArgsOf(e))
346 return 'deny' in answer ? { deny: answer.deny } : { result: answer.text }
347 }
348 // `plannotator_inbox`: the Inbox tool the call names, through the Inbox's
349 // /mcp, with this session's working folder and id filled in (inbox.ts).
350 if (allowed && inboxToolName && e.tool === inboxToolName) {
351 const instance = await currentMod($)
352 if (!instance?.inbox) return { deny: 'The Plannotator Inbox is not connected in this session; use the plannotator inbox mcp server instead.' }
353 const answer = await instance.inbox.callTool(toolArgsOf(e), String(await $.session.cwd()))
354 return 'deny' in answer ? { deny: answer.deny } : { result: answer.text }
355 }
356 // Claude running `plannotator annotate|review|last` in Bash on the main
357 // loop: the same launch and result as the tool (take-over.ts); anything
358 // the tool cannot represent, and every subagent's command, runs as written.
359 if (allowed && e.tool === SHELL_TOOL) {
360 const input = shellTakeOver(e.command, !!e.agentId)
361 if (!input) return next(e)
362 const instance = await currentMod($)
363 if (!instance) return next(e)
364 return answerShellCall(instance, input)
365 }
366 // Only the main loop's ExitPlanMode: a subagent's keeps the classic flow.
367 if (!allowed || e.tool !== PLAN_TOOL || e.agentId) return next(e)
368 const instance = await currentMod($)
369 if (!instance) return next(e)
370 const answer = await instance.onPlanCall({ tool_use_id: e.tool_use_id, plan: e.plan, planFilePath: e.planFilePath })
371 if ('deny' in answer) return { deny: answer.deny }
372 try {
373 return await next(e)
374 } finally {
375 instance.onPlanCallSettled(e.tool_use_id)
376 }
377 })
378
379 on('classic.PermissionRequest', async ($: Engine, e: any, next: Next) => {
380 const instance = mod
381 // A subagent's ExitPlanMode keeps the classic hook (its tool.call was not ours).
382 if (!instance || instance.isDisposed || e.tool_name !== PLAN_TOOL || e.agent_id) return next(e)
383 const decision = instance.onPlanPermission(typeof e.tool_use_id === 'string' ? e.tool_use_id : undefined, e.tool_input)
384 // Answered here, without next(e): the plugin's own classic command hook
385 // below never runs for this call, so it cannot open a second review.
386 return decision ? { decision } : next(e)
387 })
388
389 on('prompt.submit', async ($: Engine, e: any, next: Next) => {
390 const instance = allowed ? mod : null
391 const live = !!instance && !instance.isDisposed
392 // A prompt typed (or delivered) while a turn ran carries that turn's id:
393 // when the turn is a question's and a person sent it, the rest of the turn
394 // answers this prompt instead (turns.ts, take-over). Streaming stops HERE,
395 // before the hooks beneath run, so a slow one cannot let the next step
396 // through; it resumes if one of them drops the prompt.
397 const origin = e.origin
398 const ref = {
399 fromUs: !!origin && origin.kind === 'plugin' && origin.name === PLUGIN_NAME,
400 ...(typeof e.turnId === 'string' ? { turnId: e.turnId } : {}),
401 ...(origin && typeof origin.kind === 'string' ? { originKind: origin.kind } : {}),
402 }
403 if (live) instance.onPromptSubmitting(ref)
404 let result
405 try {
406 result = await next(e)
407 } catch (error) {
408 if (live) instance.onPromptDropped(ref)
409 throw error
410 }
411 if (!live || instance.isDisposed) return result
412 if (result && typeof result.text === 'string') {
413 // Every prompt seen here is someone else's (the engine skips our hooks
414 // for prompts our own code submitted): its turn is never a question's.
415 instance.onPromptEntered({ ...ref, text: result.text })
416 } else {
417 instance.onPromptDropped(ref)
418 }
419 return result
420 })
421
422 on('turn.start', async ($: Engine, e: any, next: Next) => {
423 if (allowed && typeof e.turnId === 'string') {
424 const instance = await currentMod($).catch(() => null)
425 if (instance) await instance.onTurnStart(e.turnId, typeof e.text === 'string' ? e.text : '')
426 }
427 return next(e)
428 })
429
430 on('turn.step', async function* ($: Engine, e: any, next: Next) {
431 const instance = allowed ? mod : null
432 if (!instance || !instance.turns.ownsTurn(e.turnId)) return yield* next(e)
433 // A question's turn that someone else's prompt entered settles here: this
434 // request carries their prompt, so nothing it says is the question's answer.
435 instance.onTurnStep(e.turnId)
436 if (!instance.turns.ownsTurn(e.turnId)) return yield* next(e)
437 const stream = next(e)
438 let step = await stream.next()
439 while (!step.done) {
440 const chunk = step.value
441 if (chunk && chunk.kind === 'text' && typeof chunk.text === 'string') instance.turns.onText(e.turnId, chunk.text)
442 else if (chunk && chunk.kind === 'tool' && typeof chunk.name === 'string') instance.turns.onTool(e.turnId, chunk.name)
443 else if (chunk && chunk.kind === 'stop') instance.onTurnStepStop(e.turnId, typeof chunk.stopReason === 'string' ? chunk.stopReason : null)
444 yield chunk
445 step = await stream.next()
446 }
447 return step.value
448 })
449
450 on('turn.complete', async ($: Engine, e: any, next: Next) => {
451 if (allowed && mod && !mod.isDisposed && !e.agentId && typeof e.turnId === 'string') {
452 mod.onTurnComplete(e.turnId, typeof e.answer === 'string' ? e.answer : '', e.isAborted === true)
453 }
454 return next(e)
455 })
456}
457hooks/mod/controller.ts 1622 lines1/**
2 * The Plannotator mod's state for one Claude Code session: the reviews it has
3 * open, the plan approval waiting for Claude's next ExitPlanMode, delivery of
4 * decisions as plugin turns, and the "Ask this session" bridges.
5 *
6 * Every engine call goes through `Host` (built from `$` in register.ts), so
7 * bun tests drive this class with a host made of memory.
8 */
9
10import { BRIDGE_HOST, BRIDGE_MODES, bridgeBaseUrl, createBridge, type BridgeEnd, type BridgeHandle } from './bridge'
11import { deliveryFor, legacyResult, parseHostResult, type HostResultRecord, type SessionKind } from './delivery'
12import type { Host, HttpResult } from './host'
13import { InboxLink } from './inbox'
14import type { InboxToolInfo } from './inbox-contract'
15import {
16 aliveArgv,
17 CLAIM_EXIT,
18 claimArgv,
19 cleanupArgv,
20 stopArgv,
21 STOP_EXIT,
22 cliArgvFor,
23 failedText,
24 fileIn,
25 isSeveralFilePaths,
26 launchArgv,
27 launchDirOf,
28 modTargetFor,
29 openedText,
30 parseReadyFile,
31 pickerFile,
32 privateDirArgv,
33 pruneArgv,
34 RECENT_MESSAGES_SUBJECT,
35 recentAssistantTexts,
36 SETTLED_BY,
37 SETTLED_DIR,
38 subjectFor,
39 subjectFromServerTarget,
40 wordsOf,
41} from './launch'
42import {
43 approvedPermissionDecision,
44 CLASSIC_PLAN_RETRY_TEXT,
45 CLASSIC_PLAN_REVIEW_TEXT,
46 cliLacksModPlan,
47 decidingDenyText,
48 isTrustablePlanPath,
49 MAX_PLAN_FILE_BYTES,
50 normalizePlanForHash,
51 planCallAction,
52 planWaitingStatus,
53 revisedDenyText,
54 revisionPendingDenyText,
55 unchangedDenyText,
56 waitingDenyText,
57 type OpenPlanReview,
58 type PendingApproval,
59} from './plan'
60import {
61 isOlderCliBundleRefusal,
62 parsePlannotatorToolInput,
63 plannotatorDecisionSubject,
64 plannotatorDistinctSubjects,
65 plannotatorSameTarget,
66 plannotatorBundleSubject,
67 plannotatorSessionId,
68 plannotatorToolArgs,
69 plannotatorToolCloseText,
70 plannotatorToolListText,
71 plannotatorToolOpenedText,
72 plannotatorToolTargets,
73 plannotatorUnknownSessionText,
74 PLANNOTATOR_TOOL_BUNDLE_UNAVAILABLE_TEXT,
75 scriptOnlyAnnotateFlag,
76 scriptOnlyAnnotateFlagText,
77 type PlannotatorCloseOutcome,
78 type PlannotatorSessionSummary,
79 type PlannotatorTarget,
80} from './tool'
81import { TurnTracker, type EnteredPrompt } from './turns'
82
83/** Persisted in `$.store` so open reviews reattach after a restart or `--resume`. */
84export interface LaunchRecord {
85 id: string
86 sessionId: string
87 kind: SessionKind
88 dir: string
89 /** How the launch is named now: `baseSubject`, told apart from same-named open launches. */
90 subject: string
91 /** The subject before same-named launches were told apart (`plannotatorDistinctSubjects`). */
92 baseSubject?: string
93 /**
94 * What the launch shows, in full. From the ready file when the CLI names it
95 * (`targetFromServer`), else the mod's own resolution of the words: the
96 * fallback a decision from an older CLI (no `target` in its record) is named by.
97 */
98 target?: string | string[]
99 /** `target` came from the server's ready file, not the mod's guess. */
100 targetFromServer?: boolean
101 startedAt: number
102 url?: string
103 port?: number
104 /** Plan: the version shown in the copy. */
105 version?: number
106 /** Plan: the last revision sequence written to revision.json. */
107 revisionSeq?: number
108 /** The pull-bridge token this launch's server was started with. */
109 bridgeToken?: string
110 /**
111 * Opened by Claude's `plannotator` tool with `gate: true`: Claude was told
112 * to wait for the sign-off, so a bare approval is delivered as a turn (the
113 * slash command's bare approval only logs).
114 */
115 deliverApproval?: boolean
116 /**
117 * Claude closed this review (the `plannotator` tool's `close`): nothing is
118 * delivered for it, and it is gone from `list` and the status line while
119 * the server shuts down.
120 */
121 closedByAgent?: boolean
122}
123
124/** The `pn-` session id of a launch: the six hex digits that end its launch id. */
125export function sessionIdOf(launch: { id: string }): string {
126 const hex = /([0-9a-f]{6})$/i.exec(launch.id)?.[1] ?? '000000'
127 return plannotatorSessionId(hex)
128}
129
130/** The host-only endpoints of the CLI's server (packages/shared/host-control.ts). */
131export const HOST_STATUS_PATH = '/api/host/status'
132export const HOST_CLOSE_PATH = '/api/host/close'
133/** The code a current CLI's 404 carries while host control is off (packages/shared/host-control.ts). */
134export const HOST_CONTROL_DISABLED_CODE = 'host_control_disabled'
135
136function jsonObjectOf(text: string): Record<string, unknown> | null {
137 try {
138 const value = JSON.parse(text) as unknown
139 return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
140 } catch {
141 return null
142 }
143}
144
145/** What `POST /api/host/close` told the mod. */
146export type HostCloseAnswer =
147 | { kind: 'closed'; unsent: number }
148 | { kind: 'decided' }
149 /** A Plannotator without the endpoint answered: a JSON 404 (0.24+) or its app page (0.19.24–0.23.x). */
150 | { kind: 'older' }
151 /** A Plannotator WITH the endpoint, turned off (remote mode): `404 { code: "host_control_disabled" }`. */
152 | { kind: 'disabled' }
153 | { kind: 'refused'; status: number }
154 /** Nothing answered on the port. */
155 | { kind: 'unreachable' }
156
157/**
158 * Reads the close answer. Only a JSON body with a numeric `unsentAnnotations`
159 * is a close: a CLI before the `/api/*` 404 guard (#748) serves its app page
160 * with 200 for any path, which must not read as closed (the reviewer's later
161 * decision would be swallowed as the agent's close).
162 */
163export function classifyHostCloseAnswer(response: HttpResult | null): HostCloseAnswer {
164 if (!response) return { kind: 'unreachable' }
165 const body = jsonObjectOf(response.text)
166 if (body) {
167 if (response.ok && typeof body.unsentAnnotations === 'number') return { kind: 'closed', unsent: body.unsentAnnotations }
168 if (response.status === 409 && body.code === 'already_decided') return { kind: 'decided' }
169 if (response.status === 404 && body.code === HOST_CONTROL_DISABLED_CODE) return { kind: 'disabled' }
170 if (response.status === 404 && typeof body.error === 'string') return { kind: 'older' }
171 return { kind: 'refused', status: response.status }
172 }
173 if (response.status === 200 && /<html|<!doctype html/i.test(response.text)) return { kind: 'older' }
174 return { kind: 'refused', status: response.status }
175}
176
177export const STORE_LAUNCHES = 'launches'
178export const STORE_APPROVALS = 'approvals'
179
180const TICK_MS = 1_000
181/** Liveness check of a launch whose server has not decided yet. */
182const PID_CHECK_EVERY_TICKS = 15
183const PID_MISSES_BEFORE_STOPPED = 3
184const READY_WAIT_MS = { review: 45_000, other: 15_000 }
185const REVISION_ACK_WAIT_MS = 4_000
186/**
187 * A bridge loop that ended because the server stopped answering is started
188 * again after `first`, doubling up to `max` while it keeps failing. A loop
189 * that got through at least once starts the next wait at `first` again.
190 */
191export const BRIDGE_RETRY_MS = { first: 5_000, max: 60_000 } as const
192/**
193 * The watcher lease (`watcher.json` in the launch directory): when two Claude
194 * Code processes hold the same session (`claude --continue` while the first
195 * still runs), one of them watches a launch, runs its bridge and delivers its
196 * decision. The process the person last acted in wins it (`touchedAt`:
197 * restore, a typed prompt, a command, the tool, ExitPlanMode); the holder
198 * renews it every `LEASE_EVERY_TICKS`, and anyone takes it over once it is
199 * `LEASE_STALE_MS` old (the holder exited, slept, or hung). Delivery is also
200 * claimed once per launch (`claimArgv`), so a lease race can never deliver
201 * twice.
202 */
203const LEASE_EVERY_TICKS = 5
204export const LEASE_STALE_MS = 20_000
205/**
206 * The minimum age of a stored record `pruneStoredLaunches` looks at: a
207 * younger one's wrapper may not have written its pid yet.
208 */
209export const LAUNCH_SETTLED_AGE_MS = 60_000
210/** Another session's launch this old whose server is gone is pruned even with a decision waiting. */
211export const LAUNCH_EXPIRED_MS = 14 * 24 * 60 * 60_000
212/**
213 * A claimed decision whose claimant has not renewed its watcher lease for
214 * this long (or released it at `session.end`) and never marked it delivered
215 * is reported as undelivered: the claimant quit (or crashed) while
216 * `$.prompt.submit` waited for Claude to go idle.
217 */
218export const UNDELIVERED_AFTER_MS = 60_000
219export const SETTLED_DELIVERED = `${SETTLED_DIR}/delivered`
220export const SETTLED_REPORTED = `${SETTLED_DIR}/reported`
221
222/** A stored record minus the in-memory watch state. */
223function recordOf(launch: LiveLaunch): LaunchRecord {
224 const {
225 pidMisses: _misses,
226 ticks: _ticks,
227 settling: _settling,
228 starting: _starting,
229 bridge: _bridge,
230 bridgeRetryAt: _retryAt,
231 bridgeBackoffMs: _backoff,
232 bridgeOff: _off,
233 leader: _leader,
234 leaseTick: _leaseTick,
235 touchedAt: _touchedAt,
236 checking: _checking,
237 ...record
238 } = launch
239 return record
240}
241
242interface LiveLaunch extends LaunchRecord {
243 pidMisses: number
244 ticks: number
245 settling: boolean
246 /**
247 * A hook is still waiting in `awaitReady` for this launch to come up or
248 * fail: that hook reports the outcome, so the timer leaves the launch alone.
249 */
250 starting: boolean
251 /** A tick is checking this launch now: the next tick skips it (ticks are not awaited). */
252 checking: boolean
253 bridge: BridgeHandle | null
254 /** No new bridge before this time (after a loop gave up on a silent server). */
255 bridgeRetryAt: number
256 /** The last retry wait, doubled while loops keep failing. */
257 bridgeBackoffMs: number
258 /** The server refused the bridge (an older CLI, a wrong token, AI off) or is closing: never again. */
259 bridgeOff: boolean
260 /** This instance holds the watcher lease: it runs the bridge and delivers. */
261 leader: boolean
262 /** The tick the lease was last looked at; null: never. */
263 leaseTick: number | null
264 /**
265 * When the person last acted on this launch from this process (restored it,
266 * typed here, ran a command or tool, ExitPlanMode); 0: never. The more
267 * recent touch wins the watcher lease.
268 */
269 touchedAt: number
270}
271
272interface WatcherLease {
273 owner: string | null
274 /** Heartbeat. */
275 at: number
276 /** The holder's `touchedAt` for this launch. */
277 touchedAt: number
278}
279
280function parseWatcherLease(text: string): WatcherLease | null {
281 const body = jsonObjectOf(text)
282 if (!body || typeof body.at !== 'number') return null
283 return {
284 owner: typeof body.owner === 'string' && body.owner ? body.owner : null,
285 at: body.at,
286 touchedAt: typeof body.touchedAt === 'number' ? body.touchedAt : 0,
287 }
288}
289
290export interface SessionInfo {
291 sessionId: string
292 dataDir: string
293 interactive: boolean
294 /**
295 * The Plannotator Inbox tools this process registered as `plannotator_inbox`
296 * at its first session start (inbox.ts); absent: no Inbox connection.
297 */
298 inboxTools?: readonly InboxToolInfo[]
299 /** The session's working folder, read when asked (the Inbox link's polls say where the session works). */
300 cwd?: () => Promise<string>
301}
302
303export class PlannotatorMod {
304 readonly turns = new TurnTracker()
305 private launches = new Map<string, LiveLaunch>()
306 private approval: PendingApproval | null = null
307 /** ExitPlanMode calls passed through as the approved plan, by tool_use_id. */
308 private passing = new Map<string, PendingApproval>()
309 private planVersion = 0
310 /**
311 * The CLI has no `claude-mod-plan` (it is older than the plugin): every
312 * ExitPlanMode of this session takes Claude Code's own flow, and the
313 * plugin's classic hook reviews it, blocking, as before the mod.
314 */
315 private classicPlanReview = false
316 private timer: { cancel: () => void } | null = null
317 private delivering: Promise<void> = Promise.resolve()
318 private sequence = 0
319 private disposed = false
320 /** Launch ids this instance settled or dropped: kept out of the store even if another process re-adds them. */
321 private forgotten = new Set<string>()
322 /** Names this instance in a launch's watcher lease and settlement claim. */
323 private readonly instanceId: string
324 /** The store housekeeping started at restore (awaited by tests only). */
325 pruning: Promise<void> = Promise.resolve()
326 /**
327 * Launches this instance claimed whose decision waits for Claude to go idle
328 * (`$.prompt.submit`): their records stay in the store and their lease is
329 * renewed until `settled/delivered` is written, so another process can tell
330 * a claimant still waiting from one that quit.
331 */
332 private awaitingIdle = new Map<string, LaunchRecord>()
333 /** Launches another process claimed: watched until delivered, or reported once its claimant is gone. */
334 private claimedElsewhere = new Map<string, LaunchRecord>()
335 private tickCount = 0
336 /** This session's connection to the Plannotator Inbox (the tool and the reply wake); null without one. */
337 readonly inbox: InboxLink | null
338
339 constructor(
340 private readonly host: Host,
341 readonly session: SessionInfo,
342 ) {
343 this.instanceId = host.randomHex(8)
344 this.inbox = session.inboxTools
345 ? new InboxLink({
346 host,
347 dataDir: session.dataDir,
348 sessionId: session.sessionId,
349 tools: session.inboxTools,
350 isBusy: () => this.turns.busy,
351 ...(session.cwd ? { cwd: session.cwd } : {}),
352 instanceId: this.instanceId,
353 })
354 : null
355 this.inbox?.start()
356 }
357
358 // --- Lifecycle -----------------------------------------------------------
359
360 /**
361 * Reattach the reviews this session left open (restart, `--resume`,
362 * `--continue`). Restoring is the strongest sign the person now works in
363 * THIS process, so its launches are touched: when another process still
364 * runs on the same session, this one takes over watching them
365 * (`holdLease`), and their decisions land in the conversation in use.
366 */
367 async restore(): Promise<void> {
368 const stored = await this.host.storeGet(STORE_LAUNCHES)
369 const records = (Array.isArray(stored) ? (stored as LaunchRecord[]) : []).filter(
370 (record) => record && record.sessionId === this.session.sessionId && typeof record.dir === 'string',
371 )
372 const now = await this.host.now()
373 const mine: LaunchRecord[] = []
374 for (const record of records) {
375 // Already settled: delivered, still on its way, or stranded (reported).
376 if (await this.host.exists(`${record.dir}/${SETTLED_DIR}`)) {
377 await this.watchClaimedElsewhere(record)
378 continue
379 }
380 // Cleaned up: nothing to reattach.
381 if (!(await this.host.exists(fileIn(record.dir, 'stdin')))) continue
382 mine.push(record)
383 }
384 // Watched from here from now on (the lease is written at once, so a
385 // decision arriving before the first tick lands here too).
386 for (const record of mine) await this.holdLease(this.adopt(record, now), true).catch(() => false)
387 this.refreshSubjects()
388 await this.loadApproval()
389 for (const launch of this.launches.values()) {
390 if (launch.kind === 'plan') this.planVersion = Math.max(this.planVersion, launch.version ?? 0)
391 }
392 if (mine.length > 0) {
393 const names = mine.map((record) => record.subject).join(', ')
394 this.host.log(`Reattached ${mine.length} open ${mine.length === 1 ? 'session' : 'sessions'} (${names}).`)
395 this.refreshStatus()
396 }
397 this.ensureTimer()
398 // Housekeeping off the session-start path.
399 this.pruning = this.pruneStoredLaunches().catch(() => undefined)
400 }
401
402 /**
403 * The approval waiting for this session's next ExitPlanMode, from the store:
404 * another Claude Code process on the session may have received it, or
405 * already used it.
406 */
407 private async loadApproval(): Promise<void> {
408 const approvals = await this.host.storeGet(STORE_APPROVALS)
409 const approval = approvals && typeof approvals === 'object' ? (approvals as Record<string, PendingApproval>)[this.session.sessionId] : undefined
410 this.approval = approval && typeof approval.hash === 'string' ? approval : null
411 }
412
413 /**
414 * Adopt this session's launches another Claude Code process on the same
415 * session started after this one restored, so a plan review, `list` and
416 * `close` see them here too.
417 */
418 private async adoptNewStoredLaunches(): Promise<void> {
419 const stored = await this.host.storeGet(STORE_LAUNCHES)
420 const records = Array.isArray(stored) ? (stored as LaunchRecord[]) : []
421 const now = await this.host.now()
422 let adopted = 0
423 for (const record of records) {
424 if (!record || record.sessionId !== this.session.sessionId || typeof record.dir !== 'string') continue
425 if (this.launches.has(record.id) || this.forgotten.has(record.id) || this.claimedElsewhere.has(record.id)) continue
426 if (await this.host.exists(`${record.dir}/${SETTLED_DIR}`)) {
427 await this.watchClaimedElsewhere(record)
428 continue
429 }
430 if (!(await this.host.exists(fileIn(record.dir, 'stdin')))) continue
431 const live = this.adopt(record, now)
432 if (live.kind === 'plan') this.planVersion = Math.max(this.planVersion, live.version ?? 0)
433 adopted += 1
434 }
435 if (adopted > 0) this.refreshStatus()
436 this.ensureTimer()
437 }
438
439 /**
440 * Where a claimed launch's decision stands: delivered (or nothing to
441 * deliver), on its way (its claimant renews its lease, or it is this
442 * instance's own), or stranded (its claimant quit while waiting for Claude to
443 * go idle), with the file holding the decision.
444 */
445 private async claimedState(record: LaunchRecord): Promise<'done' | 'waiting' | { stranded: string }> {
446 const dir = record.dir
447 if (!(await this.host.exists(`${dir}/${SETTLED_DIR}`))) return 'done'
448 if (await this.host.exists(`${dir}/${SETTLED_DELIVERED}`)) return 'done'
449 if (await this.host.exists(`${dir}/${SETTLED_REPORTED}`)) return 'done'
450 if (!(await this.host.exists(fileIn(dir, 'stdin')))) return 'done'
451 let decision: string | null = null
452 if (await this.host.exists(fileIn(dir, 'result'))) decision = fileIn(dir, 'result')
453 else if ((await this.host.readFile(fileIn(dir, 'exit')).catch(() => '')).trim() === '0') decision = fileIn(dir, 'stdout')
454 // A claim on a server that stopped without a decision: nothing was lost.
455 if (!decision) return 'done'
456 const by = (await this.host.readFile(`${dir}/${SETTLED_BY}`).catch(() => '')).trim()
457 if (by === this.instanceId) return this.awaitingIdle.has(record.id) ? 'waiting' : 'done'
458 const lease = parseWatcherLease(await this.host.readFile(fileIn(dir, 'watcher')).catch(() => ''))
459 const now = await this.host.now()
460 if (lease?.owner && lease.owner === by && now - lease.at < UNDELIVERED_AFTER_MS) return 'waiting'
461 return { stranded: decision }
462 }
463
464 /**
465 * A launch another process claimed: report it now if its decision was
466 * stranded, else watch it until it is delivered or stranded. Never
467 * delivered from here: the claimant may still be waiting to deliver it, so
468 * only a report is strictly at most once.
469 */
470 private async watchClaimedElsewhere(record: LaunchRecord): Promise<void> {
471 const state = await this.claimedState(record)
472 if (state === 'done') {
473 this.claimedElsewhere.delete(record.id)
474 return
475 }
476 if (state === 'waiting') {
477 this.claimedElsewhere.set(record.id, record)
478 this.ensureTimer()
479 return
480 }
481 this.claimedElsewhere.delete(record.id)
482 await this.host.writeFile(`${record.dir}/${SETTLED_REPORTED}`, String(await this.host.now())).catch(() => undefined)
483 this.host.log(`A decision for ${record.subject} arrived but wasn't delivered — it's saved in ${state.stranded}.`)
484 this.host.toast(`${record.subject}: a decision wasn't delivered; it is saved on disk`)
485 }
486
487 /**
488 * The person acts in this process (a prompt typed here, a slash command,
489 * Claude's tool, ExitPlanMode): its launches should be watched from here,
490 * so the lease is taken at once.
491 */
492 private async touchLaunches(): Promise<void> {
493 if (this.launches.size === 0) return
494 const now = await this.host.now()
495 for (const launch of [...this.launches.values()]) {
496 launch.touchedAt = now
497 if (!launch.settling) await this.holdLease(launch, true).catch(() => false)
498 }
499 }
500
501 /**
502 * The session this instance serves ended (`/clear`, an in-process resume,
503 * exit). Stop watching and polling so nothing is delivered into whatever
504 * session the process goes on with; open reviews stay in the store under
505 * this session id and reattach when it is resumed.
506 */
507 dispose(): void {
508 this.disposed = true
509 this.timer?.cancel()
510 this.timer = null
511 this.inbox?.dispose()
512 this.host.status(undefined)
513 // Hand the launches this instance watched to any other process on the session at once.
514 for (const launch of this.launches.values()) {
515 if (!launch.leader) continue
516 launch.leader = false
517 void this.host.writeFile(fileIn(launch.dir, 'watcher'), JSON.stringify({ owner: null, at: 0, touchedAt: 0 })).catch(() => undefined)
518 }
519 // A decision still waiting for Claude to go idle: say at once that nobody waits for it any more.
520 for (const record of this.awaitingIdle.values()) {
521 void this.host.writeFile(fileIn(record.dir, 'watcher'), JSON.stringify({ owner: null, at: 0, touchedAt: 0 })).catch(() => undefined)
522 }
523 }
524
525 get isDisposed(): boolean {
526 return this.disposed
527 }
528
529 private adopt(record: LaunchRecord, touchedAt = 0): LiveLaunch {
530 const live: LiveLaunch = {
531 ...record,
532 pidMisses: 0,
533 ticks: 0,
534 settling: false,
535 starting: false,
536 checking: false,
537 bridge: null,
538 bridgeRetryAt: 0,
539 bridgeBackoffMs: 0,
540 bridgeOff: false,
541 leader: false,
542 leaseTick: null,
543 touchedAt,
544 }
545 this.launches.set(record.id, live)
546 return live
547 }
548
549 /**
550 * Drops stored records nothing is left of (all sessions older than a
551 * minute, whose wrapper has surely written its pid):
552 * - settled or cleaned-up launches (a `settled/` claim, no `stdin`);
553 * - other sessions' servers that died without a decision;
554 * - other sessions' launches older than `LAUNCH_EXPIRED_MS` whose server is
555 * gone, decision or not (a session nobody resumed).
556 * A live server always keeps its record; this session's dead servers are
557 * left to the timer, which reports them. The store is read again before the
558 * write, so a record another process added meanwhile is kept.
559 */
560 private async pruneStoredLaunches(): Promise<void> {
561 const stored = await this.host.storeGet(STORE_LAUNCHES)
562 const records = (Array.isArray(stored) ? (stored as LaunchRecord[]) : []).filter(
563 (record) => record && typeof record.dir === 'string' && typeof record.sessionId === 'string',
564 )
565 const now = await this.host.now()
566 const age = (record: LaunchRecord) => now - (Number(record.startedAt) || 0)
567 const old = records.filter((record) => age(record) > LAUNCH_SETTLED_AGE_MS)
568 if (old.length === 0) return
569 const mine = (record: LaunchRecord) => record.sessionId === this.session.sessionId
570 const groups = {
571 cleaned: old.filter(mine).map((record) => record.dir),
572 dead: old.filter((record) => !mine(record) && age(record) <= LAUNCH_EXPIRED_MS).map((record) => record.dir),
573 expired: old.filter((record) => !mine(record) && age(record) > LAUNCH_EXPIRED_MS).map((record) => record.dir),
574 }
575 const probe = await this.host.run(pruneArgv(groups), { timeoutMs: 5_000 }).catch(() => null)
576 if (!probe || probe.exitCode !== 0) return
577 const gone = new Set(probe.stdout.split('\n').map((line) => line.trim()).filter(Boolean))
578 if (gone.size === 0) return
579 const current = await this.host.storeGet(STORE_LAUNCHES)
580 const all = Array.isArray(current) ? (current as LaunchRecord[]) : []
581 const kept = all.filter((record) => !record || typeof record.dir !== 'string' || !gone.has(record.dir) || this.launches.has(record.id))
582 this.host.debug(`pruned ${all.length - kept.length} stored launch record(s)`)
583 await this.host.storeSet(STORE_LAUNCHES, kept)
584 }
585
586 /**
587 * Writes this instance's launches for this session. Records of this session
588 * this instance does not know (another Claude Code process on the same
589 * session launched them) are kept, unless this instance settled them.
590 */
591 private async persist(): Promise<void> {
592 const stored = await this.host.storeGet(STORE_LAUNCHES)
593 const all = (Array.isArray(stored) ? (stored as LaunchRecord[]) : []).filter((record) => !!record)
594 const others = all.filter(
595 (record) =>
596 record.sessionId !== this.session.sessionId ||
597 (!this.launches.has(record.id) && !this.awaitingIdle.has(record.id) && !this.forgotten.has(record.id)),
598 )
599 // A decision waiting for Claude to go idle keeps its record: if this
600 // process quits first, the next restore finds and reports it.
601 const mine: LaunchRecord[] = [...[...this.launches.values()].map(recordOf), ...this.awaitingIdle.values()]
602 await this.host.storeSet(STORE_LAUNCHES, [...others, ...mine])
603 }
604
605 private async persistApproval(): Promise<void> {
606 const stored = await this.host.storeGet(STORE_APPROVALS)
607 const all = stored && typeof stored === 'object' ? { ...(stored as Record<string, PendingApproval>) } : {}
608 if (this.approval) all[this.session.sessionId] = this.approval
609 else delete all[this.session.sessionId]
610 await this.host.storeSet(STORE_APPROVALS, all)
611 }
612
613 private hasWork(): boolean {
614 return this.launches.size > 0 || this.awaitingIdle.size > 0 || this.claimedElsewhere.size > 0
615 }
616
617 private ensureTimer(): void {
618 if (this.disposed || this.timer || !this.hasWork()) return
619 this.timer = this.host.every(TICK_MS, () => {
620 void this.tick()
621 })
622 }
623
624 private stopTimerIfIdle(): void {
625 if (!this.hasWork() && this.timer) {
626 this.timer.cancel()
627 this.timer = null
628 }
629 }
630
631 /** A launch id whose last six hex digits (its `pn-` session id) no open launch of this session uses. */
632 private async newLaunchId(): Promise<string> {
633 this.sequence += 1
634 const used = new Set([...this.launches.values()].map((launch) => sessionIdOf(launch)))
635 let value = Number.parseInt(this.host.randomHex(3), 16) || 0
636 let hex = value.toString(16).padStart(6, '0')
637 while (used.has(plannotatorSessionId(hex))) {
638 value = (value + 1) % 0x1000000
639 hex = value.toString(16).padStart(6, '0')
640 }
641 return `${await this.host.now()}-${this.sequence}-${hex}`
642 }
643
644 // --- Launch --------------------------------------------------------------
645
646 private async launch(
647 kind: SessionKind,
648 cliArgv: string[],
649 subject: string,
650 stdin: string | ((dir: string) => string),
651 extra: Partial<LaunchRecord> = {},
652 side: { messages?: string } = {},
653 ): Promise<LiveLaunch | { error: string }> {
654 const id = await this.newLaunchId()
655 const dir = launchDirOf(this.session.dataDir, this.session.sessionId, id)
656 const bridgeToken = this.host.randomHex(32)
657 let cwd: string | undefined
658 try {
659 // Owner-only before anything lands in it (stdin holds the plan or message).
660 const made = await this.host.run(privateDirArgv(dir), { timeoutMs: 5_000 })
661 if (made.exitCode !== 0) return { error: made.stderr.trim() || `could not create ${dir}` }
662 // The session's working directory (the CLI runs there too): what the
663 // mod's fallback target resolves relative words against.
664 cwd = made.stdout.trim().split('\n').pop()?.trim() || undefined
665 await this.host.writeFile(fileIn(dir, 'stdin'), typeof stdin === 'function' ? stdin(dir) : stdin)
666 // `last`'s picker list. A CLI that predates the variable ignores it and
667 // opens the newest message from stdin, as before.
668 if (side.messages !== undefined) await this.host.writeFile(fileIn(dir, 'messages'), side.messages)
669 const result = await this.host.run(launchArgv(dir, cliArgv), {
670 env: {
671 PLANNOTATOR_READY_FILE: fileIn(dir, 'ready'),
672 PLANNOTATOR_HOST_RESULT_FILE: fileIn(dir, 'result'),
673 ...(side.messages !== undefined ? { PLANNOTATOR_HOST_MESSAGES_FILE: fileIn(dir, 'messages') } : {}),
674 PLANNOTATOR_SESSION_BRIDGE_TOKEN: bridgeToken,
675 PLANNOTATOR_SESSION_BRIDGE_HOST: BRIDGE_HOST,
676 PLANNOTATOR_SESSION_BRIDGE_MODES: BRIDGE_MODES,
677 // The review's pn- id, for the `sessions/` registry (`plannotator sessions`).
678 PLANNOTATOR_HOST_REVIEW_ID: sessionIdOf({ id }),
679 },
680 timeoutMs: 15_000,
681 })
682 if (result.exitCode !== 0) return { error: result.stderr.trim() || `launcher exited ${result.exitCode}` }
683 } catch (error) {
684 return { error: error instanceof Error ? error.message : String(error) }
685 }
686 const target = extra.target ?? modTargetFor(kind, cliArgv.slice(2), cwd)
687 const record: LaunchRecord = {
688 id,
689 sessionId: this.session.sessionId,
690 kind,
691 dir,
692 subject,
693 baseSubject: subject,
694 startedAt: await this.host.now(),
695 bridgeToken,
696 ...extra,
697 ...(target !== undefined ? { target } : {}),
698 }
699 // Launched from here: the person is in this process.
700 const live = this.adopt(record, record.startedAt)
701 this.refreshSubjects()
702 this.host.debug(`launched ${kind} ${id}: ${cliArgv.join(' ')}`)
703 await this.persist()
704 this.ensureTimer()
705 return live
706 }
707
708 /**
709 * Wait until the server is listening or the CLI exited, up to `ms`. Called
710 * from hooks, so the waiting happens in `waitForAny` (a process call), never
711 * in a `$.clock` wait that would spend the hook's budget.
712 */
713 private async awaitReady(launch: LiveLaunch, ms: number): Promise<'ready' | 'exited' | 'timeout'> {
714 launch.starting = true
715 try {
716 return await this.waitReadyOrExit(launch, ms)
717 } finally {
718 launch.starting = false
719 }
720 }
721
722 private async waitReadyOrExit(launch: LiveLaunch, ms: number): Promise<'ready' | 'exited' | 'timeout'> {
723 const ready = fileIn(launch.dir, 'ready')
724 const exit = fileIn(launch.dir, 'exit')
725 const deadline = (await this.host.now()) + ms
726 for (;;) {
727 if (await this.readReady(launch)) return 'ready'
728 if (await this.host.exists(exit)) {
729 // A last look: the CLI may have become ready and exited at once.
730 return (await this.readReady(launch)) ? 'ready' : 'exited'
731 }
732 const left = deadline - (await this.host.now())
733 if (left <= 0) return 'timeout'
734 // The ready file appears before its JSON line is complete; re-check shortly.
735 await this.host.waitForAny([ready, exit], (await this.host.exists(ready)) ? 200 : left)
736 }
737 }
738
739 private async readReady(launch: LiveLaunch): Promise<boolean> {
740 if (launch.url) return true
741 const path = fileIn(launch.dir, 'ready')
742 if (!(await this.host.exists(path))) return false
743 const ready = parseReadyFile(await this.host.readFile(path).catch(() => ''))
744 if (!ready) return false
745 launch.url = ready.url
746 launch.port = ready.port
747 if (ready.target !== undefined) {
748 // The server's own answer: it may have found a bare name elsewhere in the project.
749 if (launch.target !== undefined && !plannotatorSameTarget(launch.target, ready.target)) {
750 this.host.debug(`ready ${launch.id}: the server opened ${JSON.stringify(ready.target)}, not ${JSON.stringify(launch.target)}`)
751 }
752 launch.target = ready.target
753 launch.targetFromServer = true
754 // Named by what the CLI opened, not the typed words: it drops a stray
755 // `.` or prose beside a file (`annotate . a.md` opens a.md alone).
756 const named = subjectFromServerTarget(launch.kind, launch.baseSubject ?? launch.subject, ready.target)
757 if (named !== null) launch.baseSubject = named
758 this.refreshSubjects()
759 }
760 await this.persist()
761 this.refreshStatus()
762 return true
763 }
764
765 private async startupFailure(launch: LiveLaunch): Promise<string> {
766 const read = (name: 'stderr' | 'stdout' | 'exit') => this.host.readFile(fileIn(launch.dir, name)).catch(() => '')
767 const code = Number.parseInt((await read('exit')).trim(), 10)
768 const text = failedText(launch.subject, await read('stderr'), await read('stdout'), Number.isFinite(code) ? code : null)
769 await this.forget(launch)
770 return text
771 }
772
773 /**
774 * Not a failure to report each time: an older CLI. Say so once, and leave
775 * this instance's plans to the classic review (a resumed session probes
776 * once more, so a CLI updated in between is picked up).
777 */
778 private async fallBackToClassicPlans(launch: LiveLaunch): Promise<void> {
779 this.classicPlanReview = true
780 await this.forget(launch)
781 await this.host.run(cleanupArgv(launch.dir), { timeoutMs: 5_000 }).catch(() => undefined)
782 this.host.log(CLASSIC_PLAN_REVIEW_TEXT)
783 }
784
785 /** The plan launch exited because the CLI has no `claude-mod-plan`. */
786 private async lacksModPlan(launch: LiveLaunch): Promise<boolean> {
787 const stderr = await this.host.readFile(fileIn(launch.dir, 'stderr')).catch(() => '')
788 return cliLacksModPlan(stderr)
789 }
790
791 // --- Commands ------------------------------------------------------------
792
793 /** `/plannotator-review`, `/plannotator-annotate`, `/plannotator-last`: open and return at once. */
794 async runCommand(kind: Exclude<SessionKind, 'plan'>, rawArgs: string): Promise<string> {
795 await this.touchLaunches()
796 const opened = await this.open(kind, rawArgs, subjectFor(kind, rawArgs))
797 switch (opened.state) {
798 case 'error':
799 // Several file paths given to a CLI that predates reviews of several
800 // files: say to update instead of showing its "pick one" error.
801 if (kind === 'annotate' && isOlderCliBundleRefusal(opened.text) && isSeveralFilePaths(wordsOf(rawArgs))) {
802 return PLANNOTATOR_TOOL_BUNDLE_UNAVAILABLE_TEXT
803 }
804 return opened.text
805 case 'starting':
806 return `Starting Plannotator for ${opened.subject}… it opens in your browser when ready, and your feedback comes back here as a message.`
807 case 'ready':
808 return openedText(kind, opened.subject, opened.url, opened.extra)
809 }
810 }
811
812 /**
813 * Claude's `plannotator` tool: the same launch as the slash command, with
814 * the call's validated arguments (never re-split). `{ deny }` is an error
815 * result for Claude (a bad call, or the CLI's startup error); `{ text }`
816 * tells Claude the page is open and to end its turn and wait.
817 */
818 async runTool(input: unknown): Promise<{ text: string } | { deny: string }> {
819 await this.adoptNewStoredLaunches()
820 await this.touchLaunches()
821 const parsed = parsePlannotatorToolInput(input)
822 if (!parsed.ok) return { deny: parsed.error }
823 const call = parsed.input
824 switch (call.action) {
825 case 'list':
826 return { text: await this.listText() }
827 case 'close':
828 return this.closeSessions(call.session as string)
829 case 'annotate':
830 case 'review':
831 case 'last':
832 break
833 }
834 const action = call.action
835 const gate = call.gate === true
836 const targets = plannotatorToolTargets(call)
837 // A list of files is one review of all of them (a bundle), named as such.
838 const bundle = Array.isArray(call.target)
839 const subject = bundle ? plannotatorBundleSubject(targets) : subjectFor(action, targets)
840 const opened = await this.open(action, plannotatorToolArgs(call), subject, gate ? { deliverApproval: true } : {})
841 switch (opened.state) {
842 case 'error':
843 // An older CLI answers several paths with its ambiguity error.
844 if (bundle && isOlderCliBundleRefusal(opened.text)) return { deny: PLANNOTATOR_TOOL_BUNDLE_UNAVAILABLE_TEXT }
845 return { deny: opened.text }
846 case 'starting':
847 return { text: plannotatorToolOpenedText(opened.subject, undefined, gate, opened.sessionId, opened.target) }
848 case 'ready':
849 return { text: plannotatorToolOpenedText(opened.subject, opened.url, gate, opened.sessionId, opened.target) }
850 }
851 }
852
853 // --- The agent's own sessions (list, close) ------------------------------
854
855 /** The reviews this Claude session opened that are still open (not settling, not closed by Claude). */
856 private openLaunches(): LiveLaunch[] {
857 return [...this.launches.values()].filter((launch) => !launch.settling && !launch.closedByAgent)
858 }
859
860 private hostHeaders(launch: LiveLaunch): Record<string, string> {
861 return { authorization: `Bearer ${launch.bridgeToken ?? ''}` }
862 }
863
864 /** `GET /api/host/status`, or null when the server cannot say (not up yet, an older CLI). */
865 private async hostStatus(launch: LiveLaunch): Promise<{ unsent: number; decided: boolean } | null> {
866 if (!launch.port || !launch.bridgeToken) return null
867 try {
868 const response = await this.host.fetch(`${bridgeBaseUrl(launch.port)}${HOST_STATUS_PATH}`, {
869 method: 'GET',
870 headers: this.hostHeaders(launch),
871 })
872 if (!response.ok) return null
873 // An older CLI answers its app page (200 text/html) or a JSON 404: no count.
874 const body = jsonObjectOf(response.text)
875 if (!body || typeof body.unsentAnnotations !== 'number') return null
876 return { unsent: body.unsentAnnotations, decided: body.decided === true }
877 } catch {
878 return null
879 }
880 }
881
882 /** The tool's `list`: every open review of THIS Claude session (the launch store is per session). */
883 async listText(): Promise<string> {
884 const now = await this.host.now()
885 const sessions: PlannotatorSessionSummary[] = []
886 for (const launch of this.openLaunches()) {
887 if (!launch.url) await this.readReady(launch).catch(() => false)
888 const status = launch.url ? await this.hostStatus(launch) : null
889 sessions.push({
890 id: sessionIdOf(launch),
891 kind: launch.kind,
892 subject: launch.subject,
893 ...(launch.url ? { url: launch.url } : {}),
894 ageMs: now - launch.startedAt,
895 state: !launch.url ? 'starting' : status?.decided ? 'decided' : 'open',
896 unsent: status ? status.unsent : null,
897 })
898 }
899 return plannotatorToolListText(sessions)
900 }
901
902 /** The tool's `close`: one id or "all", only among this Claude session's reviews. */
903 private async closeSessions(session: string): Promise<{ text: string } | { deny: string }> {
904 if (session === 'all') {
905 const outcomes: PlannotatorCloseOutcome[] = []
906 for (const launch of this.openLaunches()) outcomes.push(await this.closeLaunch(launch))
907 return { text: plannotatorToolCloseText(outcomes) }
908 }
909 const launch = this.openLaunches().find((candidate) => sessionIdOf(candidate) === session)
910 if (!launch) return { deny: plannotatorUnknownSessionText(session) }
911 const outcome = await this.closeLaunch(launch)
912 const text = plannotatorToolCloseText([outcome])
913 return outcome.closed ? { text } : { deny: text }
914 }
915
916 /**
917 * Close one review: the server's host close (the reviewer's Close, draft
918 * kept, the tab told), or for a CLI without it a TERM to the process (which
919 * never deletes a draft). Plan reviews end only with a decision.
920 */
921 private async closeLaunch(launch: LiveLaunch): Promise<PlannotatorCloseOutcome> {
922 const id = sessionIdOf(launch)
923 const subject = launch.subject
924 if (launch.kind === 'plan') return { id, subject, closed: false, reason: 'plan' }
925 if (!launch.port) {
926 return { id, subject, closed: false, reason: 'failed', detail: 'its server has not started yet; try again in a moment' }
927 }
928 const response = await this.host
929 .fetch(`${bridgeBaseUrl(launch.port)}${HOST_CLOSE_PATH}`, {
930 method: 'POST',
931 headers: { ...this.hostHeaders(launch), 'content-type': 'application/json' },
932 body: '{}',
933 })
934 .catch(() => null)
935 const answer = classifyHostCloseAnswer(response)
936 switch (answer.kind) {
937 case 'closed':
938 await this.markClosedByAgent(launch)
939 return { id, subject, closed: true, unsent: answer.unsent }
940 case 'decided':
941 // The reviewer decided first: that decision is on its way.
942 return { id, subject, closed: false, reason: 'decided' }
943 case 'unreachable':
944 // Nothing answers on its port: the server is gone (the timer reports
945 // that) or the pid is stale. Never signal a pid on a guess.
946 return { id, subject, closed: false, reason: 'failed', detail: 'its server is not answering' }
947 case 'refused':
948 return { id, subject, closed: false, reason: 'failed', detail: `its server refused the close (HTTP ${answer.status})` }
949 case 'disabled':
950 // A current CLI that turned host control off (remote mode): its
951 // process is not ours to signal.
952 return {
953 id,
954 subject,
955 closed: false,
956 reason: 'failed',
957 detail: 'it runs in remote mode, where Plannotator turns host close off; close it from the tab',
958 }
959 case 'older':
960 return this.stopOlderCli(launch, id, subject)
961 }
962 }
963
964 /**
965 * An older Plannotator (no host close) answered on the launch's port: TERM
966 * its process, unless the reviewer's decision is already on disk or the pid
967 * no longer names a plannotator process (`STOP_SCRIPT`). A decision such a
968 * CLI is still publishing (it waits 1.5 s after the reviewer decides) cannot
969 * be seen and is lost; see "Version skew" in AGENTS.md.
970 */
971 private async stopOlderCli(launch: LiveLaunch, id: string, subject: string): Promise<PlannotatorCloseOutcome> {
972 const pid = (await this.host.readFile(fileIn(launch.dir, 'pid')).catch(() => '')).trim()
973 if (!/^\d+$/.test(pid)) return { id, subject, closed: false, reason: 'failed', detail: 'its server has not started yet; try again in a moment' }
974 const stopped = await this.host
975 .run(stopArgv(pid, [fileIn(launch.dir, 'result'), fileIn(launch.dir, 'exit')]), { timeoutMs: 5_000 })
976 .catch(() => null)
977 switch (stopped?.exitCode) {
978 case STOP_EXIT.stopped:
979 await this.markClosedByAgent(launch)
980 return { id, subject, closed: true, unsent: null }
981 case STOP_EXIT.decided:
982 return { id, subject, closed: false, reason: 'decided' }
983 case STOP_EXIT.notPlannotator:
984 return { id, subject, closed: false, reason: 'failed', detail: 'its server process is gone' }
985 case STOP_EXIT.cannotVerify:
986 return {
987 id,
988 subject,
989 closed: false,
990 reason: 'failed',
991 detail: "this system's ps could not verify the review's process, so it was left running; close it from the tab",
992 }
993 default:
994 return { id, subject, closed: false, reason: 'failed', detail: 'its server could not be stopped' }
995 }
996 }
997
998 private async markClosedByAgent(launch: LiveLaunch): Promise<void> {
999 launch.closedByAgent = true
1000 await this.persist()
1001 this.refreshStatus()
1002 this.ensureTimer()
1003 }
1004
1005 /** A review Claude closed has exited (or published its dismissal): forget it, log one line, deliver nothing. */
1006 private async finishAgentClose(launch: LiveLaunch, record: HostResultRecord | null): Promise<void> {
1007 launch.settling = true
1008 await this.forget(launch)
1009 const unsent = record?.unsentAnnotations
1010 const saved = typeof unsent === 'number' && unsent > 0 ? ` ${unsent} unsent ${unsent === 1 ? 'comment' : 'comments'} kept in the draft.` : ''
1011 this.host.log(`Claude closed ${launch.subject} (${sessionIdOf(launch)}).${saved} Nothing was sent to Claude.`)
1012 await this.markDelivered(launch.dir)
1013 await this.host.run(cleanupArgv(launch.dir), { timeoutMs: 5_000 }).catch(() => undefined)
1014 }
1015
1016 /** The launch both entry points share: detached CLI, result later as a plugin turn, bridge, cleanup. */
1017 private async open(
1018 kind: Exclude<SessionKind, 'plan'>,
1019 args: string | readonly string[],
1020 subject: string,
1021 record: Partial<LaunchRecord> = {},
1022 ): Promise<
1023 | { state: 'error'; text: string }
1024 | { state: 'starting'; subject: string; sessionId: string; target?: PlannotatorTarget }
1025 | { state: 'ready'; subject: string; url: string; sessionId: string; extra?: string; target?: PlannotatorTarget }
1026 > {
1027 if (kind === 'annotate') {
1028 // Strict gates and --hook answer on the CLI's exit code, stdout or result
1029 // file, which nothing reads under a detached launch: refuse up front.
1030 const flag = scriptOnlyAnnotateFlag(wordsOf(args))
1031 if (flag) return { state: 'error', text: scriptOnlyAnnotateFlagText(flag) }
1032 }
1033 let stdin = ''
1034 let extra: string | undefined
1035 const side: { messages?: string } = {}
1036 if (kind === 'last') {
1037 const texts = recentAssistantTexts(await this.host.messages())
1038 const text = texts[0]
1039 if (!text) return { state: 'error', text: 'There is no assistant message to annotate yet.' }
1040 // stdin always carries the newest text: all an older CLI reads.
1041 stdin = text
1042 const picker = await pickerFile(texts, (value) => this.host.sha256(value))
1043 if (picker.messages.length > 1) {
1044 side.messages = picker.json
1045 subject = RECENT_MESSAGES_SUBJECT
1046 extra = `${picker.messages.length} messages, newest first`
1047 } else {
1048 const words = text.trim().split(/\s+/).length
1049 extra = `${words} ${words === 1 ? 'word' : 'words'}`
1050 }
1051 }
1052 const started = await this.launch(kind, cliArgvFor(kind, args), subject, stdin, record, side)
1053 if ('error' in started) return { state: 'error', text: `Plannotator could not start: ${started.error}` }
1054
1055 const outcome = await this.awaitReady(started, kind === 'review' ? READY_WAIT_MS.review : READY_WAIT_MS.other)
1056 if (outcome === 'exited') return { state: 'error', text: await this.startupFailure(started) }
1057 const sessionId = sessionIdOf(started)
1058 // The launch's subject, told apart from a same-named open review, and its full target.
1059 const named = { subject: started.subject, ...(started.target !== undefined ? { target: started.target } : {}) }
1060 if (outcome === 'timeout') return { state: 'starting', sessionId, ...named }
1061 return { state: 'ready', url: started.url as string, sessionId, ...named, ...(extra ? { extra } : {}) }
1062 }
1063
1064 // --- Plan review -------------------------------------------------------------
1065
1066 /** Resolve the plan an ExitPlanMode call carries: the plan file when trustworthy (#1667), else the inline plan. */
1067 async resolvePlan(input: { plan?: unknown; planFilePath?: unknown }): Promise<string> {
1068 const inline = typeof input.plan === 'string' ? input.plan : ''
1069 const path = input.planFilePath
1070 if (!isTrustablePlanPath(path)) return inline
1071 try {
1072 const size = await this.host.fileSize(path)
1073 if (size === null || size > MAX_PLAN_FILE_BYTES) return inline
1074 return (await this.host.readFile(path)) || inline
1075 } catch {
1076 return inline
1077 }
1078 }
1079
1080 private openPlanReview(): (LiveLaunch & { version: number }) | null {
1081 for (const launch of this.launches.values()) {
1082 if (launch.kind === 'plan' && !launch.settling) return launch as LiveLaunch & { version: number }
1083 }
1084 return null
1085 }
1086
1087 /**
1088 * The `tool.call` hook on ExitPlanMode. `pass` lets the call through (the
1089 * approved plan, or a fallback to Claude Code's own flow); otherwise the
1090 * call is answered with `deny` text Claude reads.
1091 */
1092 async onPlanCall(input: { tool_use_id: string; plan?: unknown; planFilePath?: unknown }): Promise<{ pass: true } | { deny: string }> {
1093 // Another Claude Code process on this session may have received the
1094 // approval (or used it), or opened the plan review this call revises.
1095 await this.loadApproval()
1096 await this.adoptNewStoredLaunches()
1097 await this.touchLaunches()
1098 const plan = await this.resolvePlan(input)
1099 if (!plan.trim()) return { pass: true }
1100 const hash = await this.host.sha256(normalizePlanForHash(plan))
1101 const open = this.openPlanReview()
1102 const openState: OpenPlanReview | null = open
1103 ? { launchId: open.id, dir: open.dir, version: open.version, revisionSeq: open.revisionSeq ?? 0, url: open.url }
1104 : null
1105 const action = planCallAction(hash, { approval: this.approval, open: openState })
1106
1107 if (action.kind === 'pass-approved') {
1108 this.passing.set(input.tool_use_id, action.approval)
1109 this.approval = null
1110 await this.persistApproval()
1111 return { pass: true }
1112 }
1113
1114 // A different plan than the approved one needs its own review.
1115 if (this.approval) {
1116 this.approval = null
1117 await this.persistApproval()
1118 }
1119
1120 if (action.kind === 'revise' && open) return this.revisePlan(open, plan)
1121 return this.startPlanReview(plan, typeof input.planFilePath === 'string' ? input.planFilePath : undefined)
1122 }
1123
1124 private async startPlanReview(plan: string, planFilePath?: string): Promise<{ pass: true } | { deny: string }> {
1125 if (this.classicPlanReview) return { pass: true }
1126 const version = this.planVersion + 1
1127 const subject = subjectFor('plan', '', version)
1128 const stdin = (dir: string) => JSON.stringify({ plan, planFilePath, revisionFile: fileIn(dir, 'revision') })
1129 const started = await this.launch('plan', ['plannotator', 'claude-mod-plan'], subject, stdin, {
1130 version,
1131 revisionSeq: 0,
1132 ...(planFilePath ? { target: planFilePath } : {}),
1133 })
1134 if ('error' in started) {
1135 // Fall back to Claude Code's own flow (and the classic hook) rather than strand the plan.
1136 this.host.log(`Could not open the plan review (${started.error}).`)
1137 return { pass: true }
1138 }
1139 this.planVersion = version
1140 const outcome = await this.awaitReady(started, READY_WAIT_MS.other)
1141 if (outcome === 'exited') {
1142 if (await this.lacksModPlan(started)) {
1143 this.planVersion = version - 1
1144 await this.fallBackToClassicPlans(started)
1145 return { pass: true }
1146 }
1147 this.host.log(await this.startupFailure(started))
1148 return { pass: true }
1149 }
1150 this.host.toast(planWaitingStatus(version))
1151 return { deny: waitingDenyText(version, started.url) }
1152 }
1153
1154 private async revisePlan(open: LiveLaunch & { version: number }, plan: string): Promise<{ deny: string }> {
1155 const seq = (open.revisionSeq ?? 0) + 1
1156 open.revisionSeq = seq
1157 await this.host.writeFile(fileIn(open.dir, 'revision'), JSON.stringify({ seq, plan }))
1158 await this.persist()
1159 const ackPath = `${fileIn(open.dir, 'revision')}.ack`
1160 const deadline = (await this.host.now()) + REVISION_ACK_WAIT_MS
1161 while ((await this.host.now()) < deadline) {
1162 await this.host.waitForAny([ackPath], 250)
1163 if (await this.host.exists(ackPath)) {
1164 try {
1165 const ack = JSON.parse(await this.host.readFile(ackPath)) as { seq?: number; accepted?: boolean; unchanged?: boolean }
1166 if (ack.seq === seq) {
1167 if (!ack.accepted) return { deny: decidingDenyText() }
1168 if (ack.unchanged) return { deny: unchangedDenyText(open.version) }
1169 const version = this.planVersion + 1
1170 this.planVersion = version
1171 open.version = version
1172 open.subject = subjectFor('plan', '', version)
1173 open.baseSubject = open.subject
1174 this.refreshSubjects()
1175 await this.persist()
1176 this.refreshStatus()
1177 this.host.toast(`Plan v${version} replaced v${version - 1} in the open tab`)
1178 return { deny: revisedDenyText(version) }
1179 }
1180 } catch {
1181 // An older ack or one being written; look again.
1182 }
1183 }
1184 }
1185 return { deny: revisionPendingDenyText(open.version) }
1186 }
1187
1188 /** `classic.PermissionRequest` for ExitPlanMode: the decision, or null to defer. */
1189 onPlanPermission(toolUseId: string | undefined, toolInput: unknown): ReturnType<typeof approvedPermissionDecision> | null {
1190 const approval = toolUseId ? this.passing.get(toolUseId) : undefined
1191 if (!approval) {
1192 // The PermissionRequest input may not carry tool_use_id: take the one
1193 // approved call in flight, if exactly one.
1194 if (this.passing.size !== 1) return null
1195 const [only] = this.passing.values()
1196 return only ? approvedPermissionDecision(toolInput, only) : null
1197 }
1198 return approvedPermissionDecision(toolInput, approval)
1199 }
1200hooks/mod/enabled.ts 128 lines1/**
2 * Whether the mod is switched on. It is ON BY DEFAULT wherever Claude Code
3 * runs hooks modules (register.ts still stands down in `-p` / SDK sessions
4 * and where there is no `/bin/sh`). The user turns it off with:
5 *
6 * PLANNOTATOR_CLAUDE_MOD=0 (env; also false/off/disabled; wins over the config file)
7 * { "claudeCodeMod": false } (config.json in the data dir)
8 *
9 * Off, the mod is inert: every hook passes straight through, nothing is
10 * registered, no environment is set, and the classic PermissionRequest hook
11 * and `/plannotator-*` skills run exactly as they do without mods.
12 *
13 * Mirrors `resolveClaudeCodeMod` in packages/shared/config.ts (a hooks module
14 * may import only its own files); `enabled.test.ts` keeps the two in step.
15 */
16
17/** The env override: true/false, or undefined when it does not decide (unset, empty, unrecognized). */
18export function parseClaudeModEnv(value: string | undefined): boolean | undefined {
19 const v = value?.trim().toLowerCase()
20 if (v === '1' || v === 'true' || v === 'on') return true
21 if (v === '0' || v === 'false' || v === 'off' || v === 'disabled') return false
22 return undefined
23}
24
25/**
26 * config.json's `claudeCodeMod`, coerced like the CLI's other boolean keys
27 * (`coerceConfigBoolean`): a boolean, or the strings true/1 and false/0.
28 * Anything else, a missing key, or an unreadable file is the default: on.
29 */
30export function parseClaudeModConfig(configText: string | null | undefined): boolean {
31 if (!configText) return true
32 let value: unknown
33 try {
34 value = (JSON.parse(configText) as Record<string, unknown> | null)?.claudeCodeMod
35 } catch {
36 return true
37 }
38 if (typeof value === 'boolean') return value
39 if (typeof value === 'string') {
40 const v = value.trim().toLowerCase()
41 if (v === 'true' || v === '1') return true
42 if (v === 'false' || v === '0') return false
43 }
44 return true
45}
46
47export function resolveClaudeModEnabled(envValue: string | undefined, configText: string | null | undefined): boolean {
48 return parseClaudeModEnv(envValue) ?? parseClaudeModConfig(configText)
49}
50
51/**
52 * Whether the mod registers Claude's `plannotator` tool when nothing is set.
53 * Mirrors the claude-code entry of `AGENT_TOOL_DEFAULTS` in
54 * packages/shared/config.ts (enabled.test.ts keeps them equal): on here, where
55 * the tool is deferred behind tool search and costs only its name until used.
56 */
57export const AGENT_TOOL_DEFAULT = true
58
59/**
60 * Whether the mod registers the `plannotator` tool (the rest of the mod is
61 * unaffected). Off with:
62 *
63 * PLANNOTATOR_AGENT_TOOL=0 (env; also false/off/disabled; wins over the config file)
64 * { "agentTool": false } (config.json in the data dir)
65 *
66 * Mirrors `resolveAgentTool(config, env, 'claude-code')` in packages/shared/config.ts. Read once, at the
67 * first session.start of the Claude Code process: the tool list is part of
68 * the prompt, so it never changes under a running session.
69 */
70export function resolveAgentToolEnabled(envValue: string | undefined, configText: string | null | undefined): boolean {
71 const fromEnv = parseClaudeModEnv(envValue)
72 if (fromEnv !== undefined) return fromEnv
73 if (!configText) return AGENT_TOOL_DEFAULT
74 let value: unknown
75 try {
76 value = (JSON.parse(configText) as Record<string, unknown> | null)?.agentTool
77 } catch {
78 return AGENT_TOOL_DEFAULT
79 }
80 if (typeof value === 'boolean') return value
81 if (typeof value === 'string') {
82 const v = value.trim().toLowerCase()
83 if (v === 'true' || v === '1') return true
84 if (v === 'false' || v === '0') return false
85 }
86 return AGENT_TOOL_DEFAULT
87}
88
89/**
90 * Whether the mod registers the `plannotator_inbox` tool and runs the reply
91 * wake when nothing is set (and an Inbox is found). Mirrors the claude-code
92 * entry of `INBOX_TOOL_DEFAULTS` in packages/shared/config.ts.
93 */
94export const INBOX_TOOL_DEFAULT = true
95
96/**
97 * Whether the mod connects this session to the Plannotator Inbox (the
98 * `plannotator_inbox` tool and the reply wake), when `inbox/inbox.json`
99 * exists. Off with:
100 *
101 * PLANNOTATOR_INBOX_TOOL=0 (env; also false/off/disabled; wins over the config file)
102 * { "inboxTool": false } (config.json in the data dir, every host)
103 * { "inboxTool": { "claude-code": false } } (this host only; the Inbox's Settings writes this)
104 *
105 * Mirrors `resolveInboxTool(config, env, 'claude-code')` in
106 * packages/shared/config.ts. Read once, at the first session.start of the
107 * Claude Code process, like the agent tool switch.
108 */
109export function resolveInboxToolEnabled(envValue: string | undefined, configText: string | null | undefined): boolean {
110 const fromEnv = parseClaudeModEnv(envValue)
111 if (fromEnv !== undefined) return fromEnv
112 if (!configText) return INBOX_TOOL_DEFAULT
113 let value: unknown
114 try {
115 value = (JSON.parse(configText) as Record<string, unknown> | null)?.inboxTool
116 } catch {
117 return INBOX_TOOL_DEFAULT
118 }
119 if (value && typeof value === 'object' && !Array.isArray(value)) value = (value as Record<string, unknown>)['claude-code']
120 if (typeof value === 'boolean') return value
121 if (typeof value === 'string') {
122 const v = value.trim().toLowerCase()
123 if (v === 'true' || v === '1') return true
124 if (v === 'false' || v === '0') return false
125 }
126 return INBOX_TOOL_DEFAULT
127}
128hooks/mod/host.ts 68 lines1/**
2 * What the mod's logic needs from Claude Code, as plain closures over `$`.
3 *
4 * `register.ts` builds one from the engine's `$` at `session.start`; unit
5 * tests (bun) build one from memory. Keeping every `$` call in register.ts is
6 * also what `claude plugin validate` needs to follow `$`.
7 */
8
9export interface ProcessResult {
10 exitCode: number
11 stdout: string
12 stderr: string
13}
14
15export interface HttpResult {
16 status: number
17 ok: boolean
18 text: string
19}
20
21export interface TranscriptMessage {
22 role: 'user' | 'assistant'
23 text: string
24}
25
26export interface TimerHandle {
27 cancel: () => void
28}
29
30export interface Host {
31 now(): Promise<number>
32 /**
33 * `$.clock.sleep`: counts against a hook's 10 s budget while it waits, so
34 * never used inside one, except as a short bound raced against a `$` call
35 * (inbox.ts `within`), aborted through `signal` the moment that call settles.
36 */
37 sleep(ms: number, signal?: AbortSignal): Promise<void>
38 /**
39 * Resolve once any of `paths` exists, or after `timeoutMs`. Waits inside a
40 * `$.process.run` call, which (unlike a `$.clock` wait) does not count
41 * against a hook's budget.
42 */
43 waitForAny(paths: readonly string[], timeoutMs: number): Promise<void>
44 every(ms: number, fn: () => void): TimerHandle
45 run(argv: readonly string[], init?: { cwd?: string; env?: Record<string, string>; stdin?: string; timeoutMs?: number }): Promise<ProcessResult>
46 readFile(path: string): Promise<string>
47 writeFile(path: string, text: string): Promise<void>
48 exists(path: string): Promise<boolean>
49 /** Size in bytes, or null when the path is not a regular file. */
50 fileSize(path: string): Promise<number | null>
51 storeGet(key: string): Promise<unknown>
52 storeSet(key: string, value: unknown): Promise<void>
53 /** `$.prompt.submit`: runs once the session is idle, read under the plugin's name. */
54 submit(text: string): Promise<void>
55 suggest(text: string): Promise<void>
56 status(text: string | undefined): void
57 log(text: string): void
58 toast(text: string): void
59 messages(): Promise<TranscriptMessage[]>
60 /** `body` absent for a GET. */
61 fetch(url: string, init: { method: string; headers: Record<string, string>; body?: string }): Promise<HttpResult>
62 abortTurn(turnId: string): Promise<void>
63 randomHex(bytes: number): string
64 /** A line in the mod's debug log (PLANNOTATOR_MOD_DEBUG=1); a no-op otherwise. */
65 debug(text: string): void
66 sha256(text: string): Promise<string>
67}
68hooks/mod/inbox.ts 571 lines1/**
2 * The Claude Code connection to the Plannotator Inbox (plan step 6): the
3 * `plannotator_inbox` tool and the reply wake. One `InboxLink` per session,
4 * owned by that session's `PlannotatorMod`.
5 *
6 * Found or not: only where `inbox/inbox.json` exists at session start (the
7 * person ran the Inbox once) and the inbox tool switch allows it
8 * (`resolveInboxToolEnabled`); otherwise nothing is registered and nothing
9 * polls. The tool's actions are the Inbox's own MCP tools, read from its
10 * `/mcp` once at session start (`discoverInboxTools`); an older Inbox offers
11 * fewer, and the tool simply has fewer actions. The tool list never changes
12 * under a running session.
13 *
14 * The tool: each call re-reads the registry (never caching the port or
15 * token), starts a stopped Inbox with `plannotator inbox --background` (it
16 * detaches into its own session and never opens a browser), and proxies the
17 * call to the Inbox's `/mcp` with `project_path` the session's working folder
18 * and `agent_session` the host's real session id.
19 *
20 * The wake: a 1 s tick long-polls `POST /api/inbox/bridge/poll` (bearer token,
21 * no Origin) for the person's replies to this session's messages, with a 5 to
22 * 60 s backoff that never gives up for good. A reply waits HERE until the
23 * session has been idle for a tick (a pending `$.prompt.submit` cannot be
24 * withdrawn, and a prompt the person typed, or a `/clear`, queued during a
25 * turn goes first), is checked once more with the Inbox, claimed once across
26 * processes, submitted as a turn (`inboxWakeText`), and acknowledged
27 * (`delivered`), which the thread shows as "Delivered to Claude Code, <time>".
28 * A New message the person addressed to this session (plan step 8) arrives
29 * on the same poll and takes the same path. Each poll also tells the Inbox
30 * where the session works and whether a turn runs, and a `state` event tells
31 * it when a turn starts or ends: the Inbox lists live sessions from these.
32 *
33 * Two Claude Code processes on one session (`claude --continue` while the
34 * first still runs): one of them polls and delivers, the holder of the
35 * session's inbox lease (`watcher.json`, the person's last process wins, as
36 * for reviews); a delivery is also claimed once per reply (mkdir), so a lease
37 * race can never deliver twice. A reply another process claimed and never
38 * delivered (it quit while the turn waited) is said once, with a toast.
39 */
40
41import type { Host, HttpResult } from './host'
42import {
43 INBOX_BRIDGE_EVENT_PATH,
44 INBOX_BRIDGE_POLL_PATH,
45 inboxToolCall,
46 inboxToolCallParams,
47 inboxToolResultText,
48 inboxWakeText,
49 mcpAnswerOfResponse,
50 parseInboxBridgeCommands,
51 parseInboxRegistry,
52 parseInboxToolList,
53 type InboxRegistryView,
54 type InboxReplyCommand,
55 type InboxToolInfo,
56} from './inbox-contract'
57import { privateDirArgv } from './launch'
58
59/** The host this connection names on its messages and deliveries. */
60export const INBOX_HOST = 'claude-code'
61/** How the person sees this agent in the Inbox. */
62export const INBOX_AGENT_NAME = 'Claude Code'
63/** `$.store` key: the Inbox tool list last read live, used when the Inbox is stopped at session start. */
64export const STORE_INBOX_TOOLS = 'inboxTools'
65
66/** Poll wait we ask for: under the server's 25 s hold and `$.http.fetch`'s 30 s. */
67export const INBOX_POLL_WAIT_MS = 20_000
68/** After a failed poll: 5 s, doubling to 60 s while the Inbox stays away, never giving up. */
69export const INBOX_RETRY_MS = { first: 5_000, max: 60_000 } as const
70/**
71 * wait_for_reply's longest hold through this tool: `$.http.fetch` gives up at
72 * 30 s, so the call asks the Inbox for at most 25 s (the Inbox's own default
73 * is 50 s, for MCP hosts with a 60 s tool timeout).
74 */
75export const INBOX_WAIT_MAX_SECONDS = 25
76/** The tick: one per second, as the mod's review watcher. */
77export const INBOX_TICK_MS = 1_000
78/** How often the lease is renewed or re-read. */
79const LEASE_EVERY_MS = 5_000
80/** A lease not renewed for this long belongs to a process that exited, slept or hung. */
81export const INBOX_LEASE_STALE_MS = 20_000
82/** A claimed reply whose claimant stopped saying it is alive for this long is reported as undelivered. */
83export const INBOX_UNDELIVERED_AFTER_MS = 60_000
84/**
85 * The longest session start waits for a running Inbox's `tools/list`. The
86 * person's first prompt waits for session.start, and `$.http.fetch` gives up
87 * only after 30 s, so an Inbox that accepts and never answers (stopped with
88 * Ctrl-Z, or a port another server reused and holds) would hold that prompt
89 * for 30 s; past this bound the remembered list stands.
90 */
91export const INBOX_DISCOVER_TIMEOUT_MS = 2_000
92/** `plannotator inbox --background` waits up to 20 s for the Inbox to answer. */
93const START_TIMEOUT_MS = 30_000
94
95export function inboxRegistryPathOf(dataDir: string): string {
96 return `${dataDir}/inbox/inbox.json`
97}
98
99/** This session's inbox folder in the mod's data: the lease and the delivery claims. */
100export function inboxSessionDirOf(dataDir: string, sessionId: string): string {
101 return `${dataDir}/claude-code-mod/${sessionId}/inbox`
102}
103
104/** Exit codes of `INBOX_CLAIM_SCRIPT`. */
105export const INBOX_CLAIM_EXIT = { won: 0, lost: 3, failed: 4 } as const
106
107/**
108 * Claims one reply's delivery across processes: mkdir is atomic, so exactly
109 * one process makes `$1`; the winner writes its id (`$2`) to `by`, so the same
110 * instance whose process call lost its answer wins again on retry.
111 */
112export const INBOX_CLAIM_SCRIPT = [
113 'd=$1; me=$2',
114 'umask 077',
115 'mkdir -p "$(dirname "$d")" || exit 4',
116 `if mkdir "$d" 2>/dev/null; then printf '%s' "$me" > "$d/by"; exit 0; fi`,
117 '[ "$(cat "$d/by" 2>/dev/null)" = "$me" ] && exit 0',
118 '[ -d "$d" ] && exit 3',
119 'exit 4',
120].join('\n')
121
122export function inboxClaimArgv(dir: string, claimant: string): string[] {
123 return ['/bin/sh', '-c', INBOX_CLAIM_SCRIPT, 'plannotator-inbox-claim', dir, claimant]
124}
125
126async function mcpCall(host: Host, port: number, method: string, params: Record<string, unknown>): Promise<{ result?: unknown; error?: { message?: string } } | null> {
127 const response = await host.fetch(`http://127.0.0.1:${port}/mcp`, {
128 method: 'POST',
129 headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
130 body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
131 })
132 return mcpAnswerOfResponse(response.status, response.text)
133}
134
135/** `work`'s answer, or null once `ms` passed first; the bounding sleep is aborted as soon as `work` settles. */
136async function within<T>(host: Host, ms: number, work: Promise<T>): Promise<T | null> {
137 const stop = new AbortController()
138 const late = host.sleep(ms, stop.signal).then(
139 () => null,
140 () => null,
141 )
142 try {
143 return await Promise.race([work, late])
144 } finally {
145 stop.abort()
146 }
147}
148
149/**
150 * The Inbox tools this session's `plannotator_inbox` tool carries, decided
151 * once at session start; null: no tool (no registry, or nothing known about
152 * the Inbox's tools). A running Inbox is asked (`tools/list`) and its answer
153 * remembered; a stopped one is described by the list it last answered with,
154 * and is started by the first call.
155 */
156export async function discoverInboxTools(host: Host, dataDir: string): Promise<InboxToolInfo[] | null> {
157 const path = inboxRegistryPathOf(dataDir)
158 if (!(await host.exists(path))) return null
159 const registry = parseInboxRegistry(await host.readFile(path).catch(() => null))
160 if (registry) {
161 const answer = await within(host, INBOX_DISCOVER_TIMEOUT_MS, mcpCall(host, registry.port, 'tools/list', {})).catch(() => null)
162 const tools = answer && 'result' in answer ? parseInboxToolList(answer.result) : []
163 if (tools.length > 0) {
164 await host.storeSet(STORE_INBOX_TOOLS, { tools }).catch(() => undefined)
165 return tools
166 }
167 }
168 const remembered = parseInboxToolList(await host.storeGet(STORE_INBOX_TOOLS).catch(() => null))
169 return remembered.length > 0 ? remembered : null
170}
171
172interface InboxLease {
173 owner: string | null
174 at: number
175 touchedAt: number
176}
177
178function parseLease(text: string): InboxLease | null {
179 try {
180 const value = JSON.parse(text) as Record<string, unknown>
181 if (typeof value.at !== 'number') return null
182 return { owner: typeof value.owner === 'string' && value.owner ? value.owner : null, at: value.at, touchedAt: typeof value.touchedAt === 'number' ? value.touchedAt : 0 }
183 } catch {
184 return null
185 }
186}
187
188export interface InboxLinkOptions {
189 host: Host
190 dataDir: string
191 sessionId: string
192 tools: readonly InboxToolInfo[]
193 /** A turn is running (or a question of Ask this session is in flight). */
194 isBusy: () => boolean
195 /** The session's working folder, sent with each poll so New message can find the session by project. */
196 cwd?: () => Promise<string>
197 /** Names this process in the lease and the claims. */
198 instanceId: string
199}
200
201export class InboxLink {
202 readonly tools: readonly InboxToolInfo[]
203 private readonly host: Host
204 private readonly dir: string
205 private timer: { cancel: () => void } | null = null
206 private disposed = false
207 private ticking = false
208 private polling = false
209 private retryAt = 0
210 private backoffMs = 0
211 private madeDir = false
212 private leader = false
213 private leaseCheckedAt = Number.NEGATIVE_INFINITY
214 /** When the person last acted in this process (created, typed, called the tool); the most recent touch wins the lease. */
215 private touchedAt = 0
216 /** Ticks in a row the session was idle; a reply goes in once it is idle for a whole tick. */
217 private idleTicks = 0
218 /** Replies handed out by the Inbox and not yet settled here, in arrival order. */
219 private pending = new Map<string, InboxReplyCommand>()
220 /** Replies settled here (delivered, dropped, claimed elsewhere, reported). */
221 private settled = new Set<string>()
222 /** Delivered here (or by a claimant that quit), not yet acknowledged to the Inbox. */
223 private unacked = new Set<string>()
224 /** Claimed by another process, watched until delivered or reported. */
225 private elsewhere = new Map<string, { command: InboxReplyCommand; since: number }>()
226 /** The reply whose turn this process is submitting, kept alive in its claim. */
227 private submitting: string | null = null
228 private delivering = false
229 /** When this link started (the session, near enough), and the turn state last told the Inbox (step 8). */
230 private startedAt = 0
231 private busy: boolean | null = null
232 private idleSince = 0
233
234 constructor(private readonly options: InboxLinkOptions) {
235 this.host = options.host
236 this.tools = options.tools
237 this.dir = inboxSessionDirOf(options.dataDir, options.sessionId)
238 }
239
240 get isDisposed(): boolean {
241 return this.disposed
242 }
243
244 /** Start the tick. The person is in this process now. */
245 start(): void {
246 if (this.timer || this.disposed) return
247 void this.host.now().then((now) => {
248 this.startedAt ||= now
249 this.idleSince ||= now
250 })
251 void this.touch()
252 this.timer = this.host.every(INBOX_TICK_MS, () => {
253 if (this.ticking) return
254 this.ticking = true
255 void this.tick()
256 .catch((error: unknown) => this.host.debug(`inbox tick: ${error instanceof Error ? error.message : String(error)}`))
257 .finally(() => {
258 this.ticking = false
259 })
260 })
261 }
262
263 /** The session ended (`session.end`, `/clear`): stop, and let another process take the lease at once. */
264 dispose(): void {
265 if (this.disposed) return
266 this.disposed = true
267 this.timer?.cancel()
268 this.timer = null
269 if (this.leader) void this.host.writeFile(this.leasePath, JSON.stringify({ owner: null, at: 0, touchedAt: 0 })).catch(() => undefined)
270 this.leader = false
271 }
272
273 /** The person acted in this process: replies should land here, from now on. */
274 async touch(): Promise<void> {
275 this.touchedAt = await this.host.now()
276 if (this.disposed) return
277 if (await this.ensureDir()) await this.holdLease(this.touchedAt)
278 else this.leaseCheckedAt = Number.NEGATIVE_INFINITY
279 }
280
281 /** This session's inbox folder, owner-only, made once. */
282 private async ensureDir(): Promise<boolean> {
283 if (this.madeDir) return true
284 const made = await this.host.run(privateDirArgv(this.dir), { timeoutMs: 5_000 }).catch(() => null)
285 this.madeDir = made?.exitCode === 0
286 return this.madeDir
287 }
288
289 /**
290 * Someone else's prompt entered the session. At idle (no turn id) its turn
291 * is about to start, so the idle count starts over; the person typing here
292 * also pulls the lease to this process.
293 */
294 onForeignPrompt(prompt: { turnId?: string; originKind?: string }): void {
295 if (!prompt.turnId) this.idleTicks = 0
296 if (prompt.originKind === 'composer') void this.touch()
297 }
298
299 // --- The tool --------------------------------------------------------------
300
301 /** A `plannotator_inbox` call: the Inbox tool it stands for, through the Inbox's `/mcp`. */
302 async callTool(input: unknown, cwd: string): Promise<{ text: string } | { deny: string }> {
303 await this.touch()
304 const call = inboxToolCall(input, this.tools, {
305 project_path: cwd,
306 agent_session: this.options.sessionId,
307 agent_host: INBOX_HOST,
308 agent_name: INBOX_AGENT_NAME,
309 })
310 if ('error' in call) return { deny: call.error }
311 if (call.name === 'wait_for_reply') {
312 const asked = typeof call.arguments.timeout_seconds === 'number' ? call.arguments.timeout_seconds : INBOX_WAIT_MAX_SECONDS
313 call.arguments.timeout_seconds = Math.max(1, Math.min(asked, INBOX_WAIT_MAX_SECONDS))
314 }
315 const running = await this.ensureRunning()
316 if ('error' in running) return { deny: running.error }
317 let answer: Awaited<ReturnType<typeof mcpCall>>
318 try {
319 // This session's wake delivers the reply as a turn: the send tools say to end the turn.
320 answer = await mcpCall(this.host, running.port, 'tools/call', inboxToolCallParams(call, { wakes: true }))
321 } catch (error) {
322 return { deny: `The Plannotator Inbox did not answer (${error instanceof Error ? error.message : String(error)}).` }
323 }
324 if (!answer) return { deny: 'The Plannotator Inbox gave an answer this session could not read.' }
325 if (answer.error) {
326 const message = answer.error.message ?? 'unknown error'
327 // An action this Inbox lost (a downgrade since session start).
328 if (/not found|unknown tool/i.test(message)) return { deny: `This Plannotator Inbox has no ${call.name}; update Plannotator. (${message})` }
329 return { deny: `The Plannotator Inbox refused the call: ${message}` }
330 }
331 const result = inboxToolResultText(answer.result)
332 return result.isError ? { deny: result.text } : { text: result.text }
333 }
334
335 private get registryPath(): string {
336 return inboxRegistryPathOf(this.options.dataDir)
337 }
338
339 private async registry(): Promise<InboxRegistryView | null> {
340 return parseInboxRegistry(await this.host.readFile(this.registryPath).catch(() => null))
341 }
342
343 /** Running: the registry's port answers health with the registry's serverSession. */
344 private async isRunning(registry: InboxRegistryView): Promise<boolean> {
345 const response = await this.host
346 .fetch(`http://127.0.0.1:${registry.port}/api/inbox/health`, { method: 'GET', headers: {} })
347 .catch(() => null)
348 if (!response || response.status !== 200) return false
349 try {
350 return (JSON.parse(response.text) as { serverSession?: unknown }).serverSession === registry.serverSession
351 } catch {
352 return false
353 }
354 }
355
356 /** The running Inbox, starting a stopped one detached (no browser) first. */
357 private async ensureRunning(): Promise<InboxRegistryView | { error: string }> {
358 const known = await this.registry()
359 if (known && (await this.isRunning(known))) return known
360 const started = await this.host
361 .run(['plannotator', 'inbox', '--background'], { env: { PLANNOTATOR_DATA_DIR: this.options.dataDir }, timeoutMs: START_TIMEOUT_MS })
362 .catch((error: unknown) => ({ exitCode: -1, stdout: '', stderr: error instanceof Error ? error.message : String(error) }))
363 if (started.exitCode !== 0) {
364 const why = started.stderr.trim() || started.stdout.trim() || `exit ${started.exitCode}`
365 if (/unknown command/i.test(why)) return { error: 'The plannotator on PATH has no Inbox (an older version); update Plannotator.' }
366 return { error: `The Plannotator Inbox did not start: ${why}` }
367 }
368 this.host.debug(`inbox: started with --background (${started.stdout.trim()})`)
369 const now = await this.registry()
370 return now ?? { error: 'The Plannotator Inbox started but wrote no registry.' }
371 }
372
373 // --- The wake --------------------------------------------------------------
374
375 private get leasePath(): string {
376 return `${this.dir}/watcher.json`
377 }
378
379 private claimDir(replyId: string): string {
380 return `${this.dir}/claims/${replyId}`
381 }
382
383 private async tick(): Promise<void> {
384 if (this.disposed || !(await this.ensureDir())) return
385 const busy = this.options.isBusy()
386 this.idleTicks = busy ? 0 : this.idleTicks + 1
387 const now = await this.host.now()
388 if (busy !== this.busy) {
389 const known = this.busy !== null
390 this.busy = busy
391 if (!busy) this.idleSince = now
392 // A turn started or ended: the Inbox's live sessions say so (step 8).
393 if (known && this.leader) void this.bridge(INBOX_BRIDGE_EVENT_PATH, { type: 'state', busy, idle_since: this.idleSince })
394 }
395 if (now - this.leaseCheckedAt >= LEASE_EVERY_MS) {
396 await this.holdLease(now)
397 if (this.submitting) await this.host.writeFile(`${this.claimDir(this.submitting)}/alive`, String(now)).catch(() => undefined)
398 for (const id of [...this.elsewhere.keys()]) await this.watchElsewhere(id, now)
399 }
400 for (const id of [...this.unacked]) await this.acknowledge(id)
401 if (!this.leader) return
402 if (!this.polling && now >= this.retryAt) void this.poll()
403 if (!this.delivering && this.pending.size > 0 && this.idleTicks >= 2) {
404 this.delivering = true
405 void this.deliverNext().finally(() => {
406 this.delivering = false
407 })
408 }
409 }
410
411 /**
412 * Whether this process polls and delivers: it holds the session's inbox
413 * lease unless another live process does and the person touched that one
414 * at least as recently.
415 */
416 private async holdLease(now: number): Promise<boolean> {
417 this.leaseCheckedAt = now
418 const lease = parseLease(await this.host.readFile(this.leasePath).catch(() => ''))
419 const foreign = !!lease?.owner && lease.owner !== this.options.instanceId && Math.abs(now - lease.at) < INBOX_LEASE_STALE_MS
420 if (foreign && lease && lease.touchedAt >= this.touchedAt) {
421 if (this.leader) this.host.debug('inbox: another Claude Code process on this session delivers replies now')
422 this.leader = false
423 return false
424 }
425 await this.host
426 .writeFile(this.leasePath, JSON.stringify({ owner: this.options.instanceId, at: now, touchedAt: this.touchedAt }))
427 .catch(() => undefined)
428 this.leader = true
429 return true
430 }
431
432 private async bridge(path: string, body: Record<string, unknown>): Promise<HttpResult | null> {
433 const registry = await this.registry()
434 if (!registry) return null
435 return this.host
436 .fetch(`http://127.0.0.1:${registry.port}${path}`, {
437 method: 'POST',
438 headers: { 'content-type': 'application/json', authorization: `Bearer ${registry.token}` },
439 body: JSON.stringify({ session: this.options.sessionId, host: INBOX_HOST, ...body }),
440 })
441 .catch(() => null)
442 }
443
444 /**
445 * The replies and messages the Inbox has for this session now (waitMs 0),
446 * or null when it did not answer. The poll says where the session works and
447 * whether a turn runs, for New message's live sessions (step 8).
448 */
449 private async pendingNow(waitMs: number): Promise<InboxReplyCommand[] | null> {
450 const cwd = this.options.cwd ? await this.options.cwd().catch(() => '') : ''
451 const response = await this.bridge(INBOX_BRIDGE_POLL_PATH, {
452 waitMs,
453 ...(cwd ? { project_path: cwd } : {}),
454 ...(this.startedAt ? { started_at: this.startedAt } : {}),
455 ...(this.busy === null ? {} : { busy: this.busy, idle_since: this.idleSince }),
456 })
457 return response && response.status === 200 ? parseInboxBridgeCommands(response.text) : null
458 }
459
460 private async poll(): Promise<void> {
461 this.polling = true
462 try {
463 const commands = await this.pendingNow(INBOX_POLL_WAIT_MS)
464 if (this.disposed) return
465 if (commands === null) {
466 // No registry, a stopped Inbox, an older one without the route, a
467 // rotated token: wait and try again, longer each time, never for good.
468 this.backoffMs = this.backoffMs ? Math.min(this.backoffMs * 2, INBOX_RETRY_MS.max) : INBOX_RETRY_MS.first
469 this.retryAt = (await this.host.now()) + this.backoffMs
470 return
471 }
472 this.backoffMs = 0
473 this.retryAt = 0
474 for (const command of commands) {
475 if (this.settled.has(command.id) || this.pending.has(command.id) || this.elsewhere.has(command.id)) continue
476 this.host.debug(`inbox: ${command.type} ${command.id} for this session`)
477 this.pending.set(command.id, command)
478 }
479 } finally {
480 this.polling = false
481 }
482 }
483
484 private settle(id: string): void {
485 this.pending.delete(id)
486 this.settled.add(id)
487 }
488
489 /** Deliver the oldest waiting reply as a turn, once. */
490 private async deliverNext(): Promise<void> {
491 const command = this.pending.values().next().value as InboxReplyCommand | undefined
492 if (!command) return
493 // Still waiting? An agent may have read it through the tool meanwhile, or
494 // another process delivered it.
495 const fresh = await this.pendingNow(0)
496 if (fresh === null) return
497 if (!fresh.some((candidate) => candidate.id === command.id)) {
498 this.settle(command.id)
499 return
500 }
501 if (this.disposed || !(await this.holdLease(await this.host.now()))) return
502 const dir = this.claimDir(command.id)
503 const claim = await this.host.run(inboxClaimArgv(dir, this.options.instanceId), { timeoutMs: 5_000 }).catch(() => null)
504 if (claim?.exitCode === INBOX_CLAIM_EXIT.lost) {
505 this.pending.delete(command.id)
506 this.elsewhere.set(command.id, { command, since: await this.host.now() })
507 return
508 }
509 if (claim?.exitCode !== INBOX_CLAIM_EXIT.won) return
510 this.submitting = command.id
511 await this.host.writeFile(`${dir}/alive`, String(await this.host.now())).catch(() => undefined)
512 this.host.debug(`inbox: delivering ${command.type} ${command.id}`)
513 try {
514 // Resolves once Claude is idle and took the turn.
515 await this.host.submit(inboxWakeText(command))
516 } catch (error) {
517 this.submitting = null
518 this.settle(command.id)
519 await this.host.writeFile(`${dir}/reported`, '1').catch(() => undefined)
520 this.host.toast(
521 `${command.type === 'message' ? 'A message' : 'A reply'} in the Plannotator Inbox (${command.subject ?? 'a thread'}) could not be delivered to this session (${error instanceof Error ? error.message : String(error)}). Read it in the Inbox: ${command.url}`,
522 )
523 return
524 }
525 this.submitting = null
526 await this.host.writeFile(`${dir}/delivered`, String(await this.host.now())).catch(() => undefined)
527 this.settle(command.id)
528 this.unacked.add(command.id)
529 await this.acknowledge(command.id)
530 }
531
532 /** Tell the Inbox a reply was delivered (retried every tick until it answers). */
533 private async acknowledge(id: string): Promise<void> {
534 const response = await this.bridge(INBOX_BRIDGE_EVENT_PATH, { type: 'delivered', id })
535 if (!response) return
536 // 200, or a refusal that will not change (the reply is gone or not ours): done.
537 if (response.status === 200 || (response.status >= 400 && response.status < 500 && response.status !== 401)) this.unacked.delete(id)
538 }
539
540 /**
541 * A reply another process claimed: acknowledged on its behalf once it says
542 * delivered (its own acknowledgement may have been lost), and reported once,
543 * with a toast, when its claimant stopped saying it is alive before it did.
544 */
545 private async watchElsewhere(id: string, now: number): Promise<void> {
546 const entry = this.elsewhere.get(id)
547 if (!entry) return
548 const dir = this.claimDir(id)
549 if (await this.host.exists(`${dir}/delivered`)) {
550 this.elsewhere.delete(id)
551 this.settled.add(id)
552 this.unacked.add(id)
553 return
554 }
555 if (await this.host.exists(`${dir}/reported`)) {
556 this.elsewhere.delete(id)
557 this.settled.add(id)
558 return
559 }
560 const alive = Number((await this.host.readFile(`${dir}/alive`).catch(() => '')).trim())
561 const last = Number.isFinite(alive) && alive > 0 ? alive : entry.since
562 if (now - last < INBOX_UNDELIVERED_AFTER_MS || !this.leader) return
563 this.elsewhere.delete(id)
564 this.settled.add(id)
565 await this.host.writeFile(`${dir}/reported`, '1').catch(() => undefined)
566 this.host.toast(
567 `${entry.command.type === 'message' ? 'A message' : 'A reply'} in the Plannotator Inbox (${entry.command.subject ?? 'a thread'}) arrived for this session but was not delivered (the Claude Code process that took it quit). Read it in the Inbox: ${entry.command.url}`,
568 )
569 }
570}
571hooks/mod/inbox-contract.ts 342 lines1/**
2 * The Plannotator Inbox connection contract, a byte-for-byte copy of the
3 * CONTRACT section of packages/shared/inbox/connection.ts (a hooks module may
4 * import only its own folder). `inbox-contract.test.ts` fails when the two
5 * differ: edit the shared file, then paste.
6 */
7
8// --- CONTRACT (copied byte for byte into apps/hook/hooks/mod/inbox-contract.ts; edit packages/shared/inbox/connection.ts, then paste) ---
9
10/** The agent tool every connection registers. */
11export const INBOX_TOOL_NAME = 'plannotator_inbox'
12
13/**
14 * The Inbox MCP tools the agent tool may carry, in the order it lists them.
15 * Only those the running Inbox offers become actions; an Inbox without one
16 * (an older binary) simply has no such action.
17 */
18export const INBOX_TOOL_ACTIONS = [
19 'send_message',
20 'read_thread',
21 'wait_for_reply',
22 'resolve_message',
23 'list_decisions',
24 'record_decision',
25 'submit_guide',
26 'get_guide_brief',
27] as const
28
29export type InboxToolAction = (typeof INBOX_TOOL_ACTIONS)[number]
30
31/**
32 * Arguments the connection fills itself, on the Inbox tools that take them:
33 * the session's working folder, the host's real session id, and who the
34 * person sees. They never appear in the agent's schema.
35 */
36export const INBOX_FILLED_ARGUMENTS = ['project_path', 'agent_session', 'agent_host', 'agent_name'] as const
37
38export type InboxFilledArguments = Record<(typeof INBOX_FILLED_ARGUMENTS)[number], string>
39
40/**
41 * The `_meta` key a connection sets to `true` on its `tools/call` when the
42 * person's reply reaches this session as a turn by itself (the session's wake
43 * runs). The send tools then say to end the turn instead of waiting with
44 * wait_for_reply. Without it (the stdio shim, a raw MCP client, OpenCode 1,
45 * an older connection) they keep the wait_for_reply advice. An older Inbox
46 * ignores it.
47 */
48export const INBOX_WAKES_META_KEY = 'ai.plannotator/inbox-wakes'
49
50/** The `tools/call` params for an Inbox tool call: `wakes` when this session's replies arrive as turns. */
51export function inboxToolCallParams(call: { name: string; arguments: Record<string, unknown> }, options: { wakes: boolean }): Record<string, unknown> {
52 return { name: call.name, arguments: call.arguments, ...(options.wakes ? { _meta: { [INBOX_WAKES_META_KEY]: true } } : {}) }
53}
54
55/** The bridge routes on the Inbox server (bearer token, loopback Host, no Origin). */
56export const INBOX_BRIDGE_POLL_PATH = '/api/inbox/bridge/poll'
57export const INBOX_BRIDGE_EVENT_PATH = '/api/inbox/bridge/event'
58/** The longest the server holds a poll open. */
59export const INBOX_BRIDGE_POLL_MAX_MS = 25_000
60
61/** One tool as the Inbox's `tools/list` describes it. */
62export interface InboxToolInfo {
63 name: string
64 description: string
65 inputSchema: Record<string, unknown>
66}
67
68/** The tools in a `tools/list` result that the agent tool can carry, in INBOX_TOOL_ACTIONS order. */
69export function parseInboxToolList(result: unknown): InboxToolInfo[] {
70 const listed = result && typeof result === 'object' ? (result as { tools?: unknown }).tools : undefined
71 if (!Array.isArray(listed)) return []
72 const byName = new Map<string, InboxToolInfo>()
73 for (const item of listed) {
74 if (!item || typeof item !== 'object') continue
75 const tool = item as { name?: unknown; description?: unknown; inputSchema?: unknown }
76 if (typeof tool.name !== 'string' || !(INBOX_TOOL_ACTIONS as readonly string[]).includes(tool.name)) continue
77 const schema = tool.inputSchema && typeof tool.inputSchema === 'object' ? (tool.inputSchema as Record<string, unknown>) : { type: 'object' }
78 byName.set(tool.name, { name: tool.name, description: typeof tool.description === 'string' ? tool.description : '', inputSchema: schema })
79 }
80 return INBOX_TOOL_ACTIONS.flatMap((name) => {
81 const tool = byName.get(name)
82 return tool ? [tool] : []
83 })
84}
85
86function propertiesOf(tool: InboxToolInfo): Record<string, Record<string, unknown>> {
87 const properties = tool.inputSchema.properties
88 return properties && typeof properties === 'object' ? (properties as Record<string, Record<string, unknown>>) : {}
89}
90
91function isFilled(name: string): boolean {
92 return (INBOX_FILLED_ARGUMENTS as readonly string[]).includes(name)
93}
94
95/** The first sentence of a description (up to the first ". " or line break). */
96function leadOf(description: string): string {
97 const line = description.split('\n', 1)[0] ?? ''
98 const stop = line.search(/\.\s/)
99 return (stop >= 0 ? line.slice(0, stop + 1) : line).trim()
100}
101
102/** What the agent tool says, before the per-action lines. Fixed text. */
103export const INBOX_TOOL_LEAD =
104 "The Plannotator Inbox on this machine: message the person and get their answer without holding this session open. Set `action` to one of the Inbox's tools below and pass that tool's fields; the project and this session are filled in for you."
105
106/**
107 * Fixed text: how a reply comes back. The connection delivers the person's
108 * reply to a message this session sent as a new turn once the session is idle.
109 * Host-neutral: Claude Code frames the turn as the plugin's message, Pi and
110 * OpenCode 2 as a user message, and all three carry the wake's first line.
111 */
112export const INBOX_TOOL_WAKE_NOTE =
113 "When the person replies to a message you sent, their reply arrives in this session by itself once the session is idle, as a message with the line `Plannotator Inbox: <subject> (<reply id>)`: you can end your turn instead of waiting with wait_for_reply."
114
115/**
116 * The agent tool: name, description and input schema, built from the Inbox's
117 * own tools. Null when it offers none. `wakes: false` for a host that cannot
118 * deliver a reply as a turn (OpenCode 1): the description then says nothing
119 * about replies arriving by themselves.
120 */
121export function inboxAgentTool(
122 tools: readonly InboxToolInfo[],
123 options: { wakes?: boolean } = {},
124): { name: string; description: string; inputSchema: Record<string, unknown> } | null {
125 if (tools.length === 0) return null
126 const description = [
127 INBOX_TOOL_LEAD,
128 '',
129 ...tools.map((tool) => `- ${tool.name}: ${leadOf(tool.description)}`),
130 ...(options.wakes === false ? [] : ['', INBOX_TOOL_WAKE_NOTE]),
131 ].join('\n')
132 const properties: Record<string, Record<string, unknown>> = {
133 action: { type: 'string', enum: tools.map((tool) => tool.name), description: 'Which Inbox tool to call.' },
134 }
135 const usedBy = new Map<string, string[]>()
136 for (const tool of tools) {
137 for (const [name, schema] of Object.entries(propertiesOf(tool))) {
138 if (isFilled(name) || name === 'action') continue
139 const users = usedBy.get(name)
140 if (users) {
141 users.push(tool.name)
142 continue
143 }
144 usedBy.set(name, [tool.name])
145 // send_message's description carries the question-block guide: it rides
146 // the body field, so the tool's own description stays short.
147 const extra = tool.name === 'send_message' && name === 'body' ? `\n\n${tool.description}` : ''
148 properties[name] = { ...schema, description: `${typeof schema.description === 'string' ? schema.description : ''}${extra}`.trim() }
149 }
150 }
151 for (const [name, users] of usedBy) {
152 const schema = properties[name]!
153 properties[name] = { ...schema, description: `(${users.join(', ')}) ${schema.description}`.trim() }
154 }
155 return { name: INBOX_TOOL_NAME, description, inputSchema: { type: 'object', properties, required: ['action'], additionalProperties: false } }
156}
157
158/**
159 * The Inbox tool call an agent tool call stands for: the action's own fields
160 * (any other field is refused, naming the action's fields) plus the filled
161 * arguments that tool takes.
162 */
163export function inboxToolCall(
164 input: unknown,
165 tools: readonly InboxToolInfo[],
166 filled: InboxFilledArguments,
167): { name: string; arguments: Record<string, unknown> } | { error: string } {
168 if (!input || typeof input !== 'object' || Array.isArray(input)) return { error: 'Invalid plannotator_inbox call: expected an object with an action.' }
169 const { action, ...rest } = input as Record<string, unknown>
170 const tool = tools.find((candidate) => candidate.name === action)
171 if (!tool) {
172 return {
173 error: `Invalid plannotator_inbox call: action must be one of ${tools.map((candidate) => candidate.name).join(', ')}${typeof action === 'string' && (INBOX_TOOL_ACTIONS as readonly string[]).includes(action) ? ` (this Plannotator Inbox has no ${action}; update Plannotator for it)` : ''}.`,
174 }
175 }
176 const own = propertiesOf(tool)
177 const args: Record<string, unknown> = {}
178 for (const [name, value] of Object.entries(rest)) {
179 if (value === undefined) continue
180 if (!(name in own) || isFilled(name)) {
181 const fields = Object.keys(own).filter((field) => !isFilled(field))
182 return { error: `Invalid plannotator_inbox call: ${tool.name} takes no "${name}" (its fields: ${fields.join(', ') || 'none'}).` }
183 }
184 args[name] = value
185 }
186 for (const name of INBOX_FILLED_ARGUMENTS) if (name in own) args[name] = filled[name]
187 return { name: tool.name, arguments: args }
188}
189
190/** An MCP `tools/call` result as the text the agent tool answers with: the text, then any structured content as JSON. */
191export function inboxToolResultText(result: unknown): { text: string; isError: boolean } {
192 const value = result && typeof result === 'object' ? (result as { content?: unknown; structuredContent?: unknown; isError?: unknown }) : {}
193 const text = Array.isArray(value.content)
194 ? value.content
195 .map((part) => (part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string' ? (part as { text: string }).text : ''))
196 .filter(Boolean)
197 .join('\n')
198 : ''
199 const structured = value.structuredContent && typeof value.structuredContent === 'object' ? `\n\n${JSON.stringify(value.structuredContent, null, 2)}` : ''
200 return { text: `${text}${value.isError === true ? '' : structured}`.trim(), isError: value.isError === true }
201}
202
203/** The JSON-RPC message in an MCP answer: a JSON body, or the `data:` lines of an SSE body. */
204export function mcpAnswerOf(text: string): { result?: unknown; error?: { message?: string } } | null {
205 const candidates = /^\s*(event:|data:|:)/m.test(text)
206 ? text
207 .split(/\r?\n\r?\n/)
208 .map((block) =>
209 block
210 .split(/\r?\n/)
211 .filter((line) => line.startsWith('data:'))
212 .map((line) => line.slice(5).replace(/^ /, ''))
213 .join('\n'),
214 )
215 .filter((data) => data.trim())
216 : [text]
217 for (const candidate of candidates) {
218 try {
219 const value = JSON.parse(candidate) as { result?: unknown; error?: { message?: string } }
220 if (value && typeof value === 'object' && ('result' in value || 'error' in value)) return value
221 } catch {
222 // The next block.
223 }
224 }
225 return null
226}
227
228/**
229 * The one bound on a request body the Inbox reads, `/mcp` included: Bun's own
230 * default for `Bun.serve`, made explicit, and handed to the MCP SDK, whose
231 * default (4 MiB) refused ordinary large messages. A body is read whole and
232 * parsed in memory, so the long-lived Inbox that holds every thread keeps a
233 * bound; under it a message works, over it the request is refused at once
234 * with {@link inboxRequestTooLargeMessage}, never left to time out.
235 */
236export const INBOX_MAX_REQUEST_BYTES = 128 * 1024 * 1024
237
238/** The refusal for a request over {@link INBOX_MAX_REQUEST_BYTES}, naming the bound. */
239export function inboxRequestTooLargeMessage(bytes?: number): string {
240 const size = bytes === undefined ? '' : ` (${bytes} bytes)`
241 return `The message is too large for the Plannotator Inbox${size}: one request may carry at most ${INBOX_MAX_REQUEST_BYTES} bytes (128 MiB). Send a smaller message, or attach the content as a file.`
242}
243
244/** {@link mcpAnswerOf} with the HTTP status: an unreadable 413 (the server's own refusal, no body) is the request bound. */
245export function mcpAnswerOfResponse(status: number, text: string): { result?: unknown; error?: { message?: string } } | null {
246 return mcpAnswerOf(text) ?? (status === 413 ? { error: { message: inboxRequestTooLargeMessage() } } : null)
247}
248
249/** The registry fields a connection reads (`inbox/inbox.json`), re-read on every call. */
250export interface InboxRegistryView {
251 pid: number
252 port: number
253 token: string
254 serverSession: string
255 url: string
256}
257
258export function parseInboxRegistry(text: string | null | undefined): InboxRegistryView | null {
259 if (!text) return null
260 try {
261 const value = JSON.parse(text) as Record<string, unknown>
262 if (
263 value.v === 1 &&
264 typeof value.pid === 'number' &&
265 typeof value.port === 'number' &&
266 Number.isInteger(value.port) &&
267 value.port > 0 &&
268 value.port < 65536 &&
269 typeof value.token === 'string' &&
270 value.token.length >= 32 &&
271 typeof value.serverSession === 'string' &&
272 typeof value.url === 'string'
273 ) {
274 return { pid: value.pid, port: value.port, token: value.token, serverSession: value.serverSession, url: value.url }
275 }
276 } catch {
277 // Unreadable: the same as none.
278 }
279 return null
280}
281
282/**
283 * One thing the person sent the polling session: a `reply` to a message it
284 * sent, or a `message` the person wrote to it with New message (plan step 8),
285 * which answers nothing (`reply_to` null). Both are delivered the same way.
286 */
287export interface InboxReplyCommand {
288 type: 'reply' | 'message'
289 /** The person's reply or message (a `msg_` id). */
290 id: string
291 thread_id: string
292 /** The agent message a reply answers; null for a message. */
293 reply_to: string | null
294 /** The thread's subject. */
295 subject: string | null
296 /** The reply or message, markdown, verbatim. */
297 body: string
298 /** The thread in the Inbox page. */
299 url: string
300}
301
302export function parseInboxBridgeCommands(text: string): InboxReplyCommand[] {
303 try {
304 const body = JSON.parse(text) as { commands?: unknown }
305 if (!Array.isArray(body.commands)) return []
306 return body.commands.filter((command): command is InboxReplyCommand => {
307 if (!command || typeof command !== 'object') return false
308 const c = command as Record<string, unknown>
309 return (c.type === 'reply' || c.type === 'message') && typeof c.id === 'string' && typeof c.thread_id === 'string' && typeof c.body === 'string'
310 })
311 } catch {
312 return []
313 }
314}
315
316/** The fixed second line of every wake: who is speaking, and how to answer. */
317export const INBOX_WAKE_INSTRUCTION =
318 "The person replied to you in the Plannotator Inbox. Their reply follows as they wrote it: it is their answer to you. When they need to hear back, answer in the same thread: plannotator_inbox send_message with reply_to set to the id in parentheses on the line above."
319
320/** The fixed second line of a New message wake: the person wrote first, and how to answer. */
321export const INBOX_MESSAGE_INSTRUCTION =
322 "The person wrote to you from the Plannotator Inbox. Their message follows as they wrote it: it is from them, not from the Inbox. When they need to hear back, answer in the same thread: plannotator_inbox send_message with reply_to set to the id in parentheses on the line above."
323
324/**
325 * The turn a reply or a message becomes: `Plannotator Inbox: <subject> (<id>)`,
326 * the fixed instruction line for its type, then the words verbatim. Never the
327 * thread re-sent.
328 */
329export function inboxWakeText(command: Pick<InboxReplyCommand, 'id' | 'subject' | 'body'> & { type?: InboxReplyCommand['type'] }): string {
330 const subject = (command.subject ?? '').replace(/\s+/g, ' ').trim() || (command.type === 'message' ? 'a message' : 'a reply')
331 const instruction = command.type === 'message' ? INBOX_MESSAGE_INSTRUCTION : INBOX_WAKE_INSTRUCTION
332 return `Plannotator Inbox: ${subject} (${command.id})\n${instruction}\n\n${command.body}`
333}
334
335/**
336 * A session is live while its connection polled within this long (plan step
337 * 8): New message is addressed only to a live session. A connection polls
338 * again at once after each held poll (at most 25 s), so a running session is
339 * never this long without one.
340 */
341export const INBOX_SESSION_LIVE_MS = 30_000
342hooks/mod/launch.ts 627 lines1/**
2 * Starting the `plannotator` CLI detached, and reading what it leaves behind.
3 *
4 * `$.process.run` is one shot and ends at ten minutes, so it cannot hold a
5 * review open. It runs a tiny `/bin/sh` wrapper instead that starts the CLI in
6 * the background with every stream on a file and returns at once. The CLI
7 * itself does the rest through files in the launch directory:
8 *
9 * stdin what the CLI reads on stdin (plan JSON, the last message)
10 * ready PLANNOTATOR_READY_FILE: one JSON line { url, isRemote, port, target? } once listening
11 * result.json PLANNOTATOR_HOST_RESULT_FILE: the decision record, written atomically
12 * stdout/stderr the CLI's own output (startup errors land in stderr)
13 * pid the CLI's pid, for the liveness check
14 * exit the CLI's exit code, written after it exits
15 * revision.json / revision.json.ack plan revisions pushed into an open review
16 * messages.json PLANNOTATOR_HOST_MESSAGES_FILE: `last`'s recent assistant messages, for the picker
17 * watcher.json which Claude Code process watches this launch (`{ owner, at, touchedAt }`, a heartbeat)
18 * settled/by the claim: made (mkdir) by the one process that settles the launch, naming it
19 *
20 * No listener in the mod: it looks at these files on a timer.
21 */
22
23import type { SessionKind } from './delivery'
24import { splitShellWords } from './shell-words'
25import { looksLikeFilePath, plannotatorBundleSubject, plannotatorTargetSubject, type PlannotatorTarget } from './tool'
26
27/** The wrapper. `$1` is the launch directory; the CLI argv follows. */
28export const LAUNCH_SCRIPT = [
29 'dir=$1; shift',
30 '(',
31 " trap '' HUP",
32 ' nohup "$@" < "$dir/stdin" > "$dir/stdout" 2> "$dir/stderr" &',
33 ' child=$!',
34 ' echo "$child" > "$dir/pid"',
35 ' wait "$child"',
36 ' code=$?',
37 ' echo "$code" > "$dir/exit.tmp" && mv "$dir/exit.tmp" "$dir/exit"',
38 ') < /dev/null > /dev/null 2>&1 &',
39].join('\n')
40
41export const LAUNCH_FILES = {
42 stdin: 'stdin',
43 ready: 'ready',
44 result: 'result.json',
45 stdout: 'stdout',
46 stderr: 'stderr',
47 pid: 'pid',
48 exit: 'exit',
49 revision: 'revision.json',
50 messages: 'messages.json',
51 watcher: 'watcher.json',
52 overflow: 'feedback.md',
53} as const
54
55/** The claim directory `claimArgv` makes, and the file in it naming the claimant. */
56export const SETTLED_DIR = 'settled'
57export const SETTLED_BY = 'settled/by'
58
59export function fileIn(dir: string, name: keyof typeof LAUNCH_FILES): string {
60 return `${dir}/${LAUNCH_FILES[name]}`
61}
62
63/** Polls for any of its arguments to exist, 100 ms apart, `$1` times. */
64export const WAIT_SCRIPT = [
65 'n=$1; shift',
66 'i=0',
67 'while [ "$i" -lt "$n" ]; do',
68 ' for f in "$@"; do [ -e "$f" ] && exit 0; done',
69 ' sleep 0.1',
70 ' i=$((i+1))',
71 'done',
72 'exit 1',
73].join('\n')
74
75/** `$.process.run` argv that waits for any of `paths` for up to `timeoutMs`. */
76export function waitArgv(paths: readonly string[], timeoutMs: number): string[] {
77 return ['/bin/sh', '-c', WAIT_SCRIPT, 'plannotator-wait', String(Math.max(1, Math.ceil(timeoutMs / 100))), ...paths]
78}
79
80/** `$.process.run` argv for a detached launch of `cliArgv` in `dir`. */
81export function launchArgv(dir: string, cliArgv: readonly string[]): string[] {
82 return ['/bin/sh', '-c', LAUNCH_SCRIPT, 'plannotator-launch', dir, ...cliArgv]
83}
84
85/**
86 * The data directory, as the CLI resolves it (`getPlannotatorDataDir`):
87 * `PLANNOTATOR_DATA_DIR` (with `~` expanded); else `~/.plannotator` when it
88 * exists; else `$XDG_DATA_HOME/plannotator` when that is absolute; else
89 * `~/.plannotator`. A relative `PLANNOTATOR_DATA_DIR` (resolved against the
90 * CLI's cwd) is refused: the mod could not name the same directory.
91 */
92export function dataDirOf(env: { home?: string; dataDir?: string; xdgDataHome?: string; legacyExists?: boolean }): string | null {
93 const home = env.home?.replace(/\/+$/, '')
94 const custom = env.dataDir?.trim()
95 if (custom) {
96 if (custom === '~') return home ?? null
97 if (custom.startsWith('~/')) return home ? `${home}/${custom.slice(2)}` : null
98 if (custom.startsWith('/')) return custom.replace(/\/+$/, '') || '/'
99 return null
100 }
101 if (!home) return null
102 const legacy = `${home}/.plannotator`
103 if (env.legacyExists) return legacy
104 const xdg = env.xdgDataHome?.trim()
105 if (xdg && xdg.startsWith('/')) return `${xdg.replace(/\/+$/, '')}/plannotator`
106 return legacy
107}
108
109/**
110 * Creates a launch directory owner-only (0700, and any parent it has to
111 * create) before anything is written into it: it holds the plan or message on
112 * stdin and the reviewer's feedback in stdout and result.json. Prints the
113 * working directory the CLI will run in (the session's), which the mod
114 * resolves relative targets against.
115 */
116export function privateDirArgv(dir: string): string[] {
117 return ['/bin/sh', '-c', 'umask 077 && mkdir -p "$1" && chmod 700 "$1" && pwd', 'plannotator-mkdir', dir]
118}
119
120/** `/a/b/../c/./d` → `/a/c/d` (no symlinks resolved: nothing is looked up). */
121function normalizeAbsolute(path: string): string {
122 const out: string[] = []
123 for (const segment of path.split('/')) {
124 if (segment === '' || segment === '.') continue
125 if (segment === '..') out.pop()
126 else out.push(segment)
127 }
128 return `/${out.join('/')}`
129}
130
131/** An absolute path word (`/a/b`, `@/a/b`), normalized; anything else (relative, `~`) is undefined. */
132function absoluteWord(word: string): string | undefined {
133 const path = word.replace(/^@/, '')
134 return path.startsWith('/') ? normalizeAbsolute(path) : undefined
135}
136
137/**
138 * The mod's own idea of what a launch shows, only for a CLI that predates
139 * naming its target in the ready file and result record. It never guesses:
140 * the CLI may resolve a bare or relative name somewhere else (it searches the
141 * project) and reads prose around a review directory as words to ignore, so a
142 * confidently wrong path would be worse than none. Only what the words state
143 * exactly counts: a URL or PR URL, an absolute path, and a review with no
144 * words at all (the session's directory, which the CLI reviews then). In
145 * every other case there is no fallback and the message carries no Target
146 * line from the mod.
147 */
148export function modTargetFor(
149 kind: SessionKind,
150 args: string | readonly string[],
151 cwd: string | undefined,
152): string | string[] | undefined {
153 const all = wordsOf(args)
154 const words: string[] = []
155 for (let index = 0; index < all.length; index += 1) {
156 const word = all[index] as string
157 // `--base <ref>` / `--diff-type <id>` take a value that is not a target.
158 if (word === '--base' || word === '--diff-type') {
159 index += 1
160 continue
161 }
162 if (!word.startsWith('-')) words.push(word)
163 }
164 switch (kind) {
165 case 'plan':
166 case 'last':
167 return undefined
168 case 'review': {
169 const pr = words.find((word) => PR_URL.test(word))
170 if (pr) return pr
171 if (words.length === 0) return cwd && cwd.startsWith('/') ? normalizeAbsolute(cwd) : undefined
172 return words.length === 1 ? absoluteWord(words[0] as string) : undefined
173 }
174 case 'annotate': {
175 if (isSeveralFilePaths(words)) {
176 const paths = [...new Set(words)].map(absoluteWord)
177 return paths.every((path): path is string => path !== undefined) ? paths : undefined
178 }
179 if (words.length !== 1) return undefined
180 const target = words[0] as string
181 if (/^https?:\/\//i.test(target)) return target
182 return absoluteWord(target)
183 }
184 }
185}
186
187/**
188 * Removes a settled launch's files, keeping `feedback.md` (Claude reads it
189 * later) and the directory when that file is there. Only names this module
190 * wrote; never a glob. The claim (`settled/`) goes only with the directory:
191 * while `feedback.md` keeps the directory, the claim keeps saying the launch
192 * was settled. Files go first, `stdin` among them, so a claim made after this
193 * started finds `stdin` gone and loses (`CLAIM_SCRIPT`).
194 */
195export function cleanupArgv(dir: string): string[] {
196 const names = Object.entries(LAUNCH_FILES)
197 .filter(([key]) => key !== 'overflow')
198 .map(([, name]) => name)
199 return [
200 '/bin/sh',
201 '-c',
202 [
203 'dir=$1; shift',
204 'for f in "$@"; do rm -f "$dir/$f"; done',
205 `if [ ! -e "$dir/${LAUNCH_FILES.overflow}" ]; then rm -f "$dir/${SETTLED_BY}" "$dir/${SETTLED_DIR}/delivered" "$dir/${SETTLED_DIR}/reported"; rmdir "$dir/${SETTLED_DIR}" 2>/dev/null; rmdir "$dir" 2>/dev/null; fi`,
206 'exit 0',
207 ].join('\n'),
208 'plannotator-cleanup',
209 dir,
210 ...names,
211 'revision.json.ack',
212 'exit.tmp',
213 ]
214}
215
216/** Exit codes of `CLAIM_SCRIPT`. */
217export const CLAIM_EXIT = { won: 0, lost: 3, failed: 4 } as const
218
219/**
220 * Claims one launch's settlement across Claude Code processes: two processes
221 * on one session id (`claude --continue` while the first still runs) both
222 * watch the launch, and only the one that makes `$1/settled` (mkdir is atomic
223 * and fails when it exists: exactly one winner) settles it, whichever file
224 * says it is settled (the result record, the exit code, or a dead pid). The
225 * winner writes its id (`$2`) to `settled/by`, so the same instance that
226 * claimed but lost the answer (a timed-out process call) wins again on retry
227 * (exit 0). A claim made once cleanup removed `stdin` is too late (exit 3).
228 * Neither made nor found: try again later (exit 4).
229 */
230export const CLAIM_SCRIPT = [
231 'd=$1; me=$2',
232 `if mkdir "$d/${SETTLED_DIR}" 2>/dev/null; then`,
233 ` printf '%s' "$me" > "$d/${SETTLED_BY}"`,
234 ' [ -e "$d/stdin" ] && exit 0',
235 ' exit 3',
236 'fi',
237 `[ "$(cat "$d/${SETTLED_BY}" 2>/dev/null)" = "$me" ] && exit 0`,
238 `[ -d "$d/${SETTLED_DIR}" ] && exit 3`,
239 '[ -d "$d" ] || exit 3',
240 'exit 4',
241].join('\n')
242
243export function claimArgv(dir: string, claimant: string): string[] {
244 return ['/bin/sh', '-c', CLAIM_SCRIPT, 'plannotator-claim', dir, claimant]
245}
246
247/**
248 * Prints each launch directory whose stored record can go. Arguments are
249 * directories in three groups, each started by a marker:
250 * - `--cleaned`: only when settled or cleaned up (no `stdin` — which the mod
251 * writes before launching and only `cleanupArgv` removes — or no directory
252 * at all; or a `settled/` claim marked delivered or reported, or with no
253 * decision on disk). A claimed decision never marked delivered stays, so
254 * its session can report it when resumed (`UNDELIVERED_AFTER_MS`);
255 * - `--dead`: also when no decision waits (`result.json`, `exit`) and the
256 * server's pid no longer answers `kill -0` (or there is none);
257 * - `--expired`: also with a decision waiting, unless the server still runs
258 * (a session nobody resumed for a long time).
259 * A live server always keeps its record.
260 */
261export const PRUNE_SCRIPT = [
262 'mode=cleaned',
263 'for d in "$@"; do',
264 ' case "$d" in --cleaned|--dead|--expired) mode=${d#--}; continue ;; esac',
265 ' if [ ! -e "$d/stdin" ]; then echo "$d"; continue; fi',
266 ` if [ -e "$d/${SETTLED_DIR}" ]; then`,
267 // Delivered, reported, or a claim on a server that stopped without a decision.
268 ` if [ -e "$d/${SETTLED_DIR}/delivered" ] || [ -e "$d/${SETTLED_DIR}/reported" ] || { [ ! -e "$d/result.json" ] && [ ! -e "$d/exit" ]; }; then echo "$d"; continue; fi`,
269 // Claimed and maybe never delivered: kept until its session reports it, or it expires.
270 ' [ "$mode" = expired ] || continue',
271 ' fi',
272 ' [ "$mode" = cleaned ] && continue',
273 ' pid=$(cat "$d/pid" 2>/dev/null)',
274 ' case "$pid" in',
275 ' "") alive=0 ;;',
276 ' *[!0-9]*) alive=1 ;;',
277 ' *) if kill -0 "$pid" 2>/dev/null; then alive=1; else alive=0; fi ;;',
278 ' esac',
279 ' [ "$alive" = 1 ] && continue',
280 ' if [ "$mode" = dead ]; then',
281 ' for f in result.json exit; do [ -e "$d/$f" ] && continue 2; done',
282 ' fi',
283 ' echo "$d"',
284 'done',
285].join('\n')
286
287export function pruneArgv(groups: { cleaned: readonly string[]; dead: readonly string[]; expired: readonly string[] }): string[] {
288 return [
289 '/bin/sh',
290 '-c',
291 PRUNE_SCRIPT,
292 'plannotator-prune',
293 '--cleaned',
294 ...groups.cleaned,
295 '--dead',
296 ...groups.dead,
297 '--expired',
298 ...groups.expired,
299 ]
300}
301
302/** Above this the debug log is moved to `<file>.1` before the next append. */
303export const DEBUG_LOG_MAX_BYTES = 1_048_576
304
305/**
306 * Appends stdin to the debug log, rotating it once it passes
307 * `DEBUG_LOG_MAX_BYTES`. Appending (O_APPEND) is what lets several Claude Code
308 * processes share one log: rewriting the whole file from each process's own
309 * buffer clobbered the other's lines and could leave NUL bytes behind. The
310 * rotation runs under a lock (`<file>.rotating`, mkdir; a lock older than a
311 * minute is a dead writer's and is removed) and re-checks the size inside it,
312 * so two writers past the limit at once rotate once instead of the second
313 * moving the fresh log over the first one's `<file>.1`.
314 */
315export const DEBUG_APPEND_SCRIPT = [
316 'f=$1; lock="$f.rotating"',
317 'mkdir -p "$(dirname "$f")" 2>/dev/null',
318 `size() { s=$(wc -c < "$f" 2>/dev/null | tr -d ' '); echo "\${s:-0}"; }`,
319 `if [ "$(size)" -gt ${DEBUG_LOG_MAX_BYTES} ]; then`,
320 ' [ -n "$(find "$lock" -maxdepth 0 -mmin +1 2>/dev/null)" ] && rmdir "$lock" 2>/dev/null',
321 ' if mkdir "$lock" 2>/dev/null; then',
322 ` [ "$(size)" -gt ${DEBUG_LOG_MAX_BYTES} ] && mv -f "$f" "$f.1" 2>/dev/null`,
323 ' rmdir "$lock" 2>/dev/null',
324 ' fi',
325 'fi',
326 'cat >> "$f"',
327].join('\n')
328
329export function debugAppendArgv(path: string): string[] {
330 return ['/bin/sh', '-c', DEBUG_APPEND_SCRIPT, 'plannotator-debug', path]
331}
332
333/** Liveness probe through the shell's own `kill` (no /bin/kill on every system). */
334export function aliveArgv(pid: string): string[] {
335 return ['/bin/sh', '-c', 'kill -0 "$1" 2>/dev/null', 'plannotator-alive', pid]
336}
337
338/** Exit codes of `STOP_SCRIPT`. */
339export const STOP_EXIT = { stopped: 0, decided: 3, notPlannotator: 4, failed: 5, cannotVerify: 6 } as const
340
341/**
342 * Stops a CLI that has no host close endpoint (an older Plannotator) with
343 * TERM, which ends the server without a decision and never deletes its draft.
344 * `$1` is the pid, the rest are files whose presence means the reviewer
345 * already decided (the result record, the exit code): then nothing is sent
346 * (exit 3), so that decision is still delivered. The pid must still name a
347 * `plannotator` process (exit 4 otherwise), so a pid reused after the CLI
348 * died (a reboot, a crash) is never signalled. Where `ps` cannot say (missing,
349 * as on Debian slim without procps, or without `-p`, as BusyBox's) nothing is
350 * signalled either (exit 6): the check is made on the script's own pid first.
351 */
352export const STOP_SCRIPT = [
353 'pid=$1; shift',
354 'for f in "$@"; do [ -e "$f" ] && exit 3; done',
355 '[ -n "$(ps -o args= -p $$ 2>/dev/null)" ] || exit 6',
356 'ps -o args= -p "$pid" 2>/dev/null | grep -q plannotator || exit 4',
357 'kill -TERM "$pid" 2>/dev/null || exit 5',
358].join('\n')
359
360export function stopArgv(pid: string, decidedFiles: readonly string[]): string[] {
361 return ['/bin/sh', '-c', STOP_SCRIPT, 'plannotator-stop', pid, ...decidedFiles]
362}
363
364export function launchDirOf(dataDir: string, sessionId: string, launchId: string): string {
365 const safe = (value: string) => value.replace(/[^A-Za-z0-9._-]/g, '_')
366 return `${dataDir}/claude-code-mod/${safe(sessionId)}/${safe(launchId)}`
367}
368
369export interface ReadyInfo {
370 url: string
371 port: number
372 isRemote: boolean
373 /** What the server shows, in full, as the CLI resolved it (absent from an older CLI). */
374 target?: string | string[]
375}
376
377function readyTargetOf(value: unknown): string | string[] | undefined {
378 if (typeof value === 'string') return value.trim() ? value : undefined
379 if (Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string' && item.trim() !== '')) {
380 return value as string[]
381 }
382 return undefined
383}
384
385/** The first well-formed line of the ready file. */
386export function parseReadyFile(text: string): ReadyInfo | null {
387 for (const line of text.split('\n')) {
388 if (!line.trim()) continue
389 try {
390 const value = JSON.parse(line) as Record<string, unknown>
391 if (typeof value.url === 'string' && typeof value.port === 'number') {
392 const target = readyTargetOf(value.target)
393 return { url: value.url, port: value.port, isRemote: value.isRemote === true, ...(target !== undefined ? { target } : {}) }
394 }
395 } catch {
396 // A partial line: not ready yet.
397 }
398 }
399 return null
400}
401
402// --- Commands ---------------------------------------------------------------
403
404/** The slash commands the mod runs non-blocking, by the name the user types. */
405export const COMMANDS = {
406 'plannotator-review': {
407 kind: 'review',
408 description: "Open Plannotator's code review UI; your feedback comes back as a message when you send it.",
409 argumentHint: '[directory | PR URL] [--base <ref>] [--diff-type <type>]',
410 },
411 'plannotator-annotate': {
412 kind: 'annotate',
413 description: 'Annotate a file, URL or folder in Plannotator; your annotations come back as a message when you send them.',
414 argumentHint: '<file | URL | folder>',
415 },
416 'plannotator-last': {
417 kind: 'last',
418 description: "Annotate Claude's last message in Plannotator; your annotations come back as a message.",
419 argumentHint: '',
420 },
421} as const satisfies Record<string, { kind: SessionKind; description: string; argumentHint: string }>
422
423export type CommandName = keyof typeof COMMANDS
424
425export function isModCommand(name: string): name is CommandName {
426 return Object.prototype.hasOwnProperty.call(COMMANDS, name)
427}
428
429/** A slash command's typed arguments split like its shell line, or words that are already split (a tool call). */
430export function wordsOf(args: string | readonly string[]): string[] {
431 return typeof args === 'string' ? splitShellWords(args) : [...args]
432}
433
434/** The CLI argv for a command, with the user's words passed through unchanged. */
435export function cliArgvFor(kind: Exclude<SessionKind, 'plan'>, args: string | readonly string[]): string[] {
436 const words = wordsOf(args)
437 switch (kind) {
438 case 'review':
439 return ['plannotator', 'review', ...words]
440 case 'annotate':
441 return ['plannotator', 'annotate', ...words]
442 case 'last':
443 return ['plannotator', 'annotate-last', '--stdin']
444 }
445}
446
447const PR_URL = /^https?:\/\/[^\s/]+\/.+\/(?:pull|pull-requests|merge_requests)\/(\d+)\b/i
448
449function baseName(path: string): string {
450 const trimmed = path.replace(/\/+$/, '')
451 return trimmed.slice(trimmed.lastIndexOf('/') + 1) || trimmed
452}
453
454/**
455 * Whether annotate's words (flags ignored) are several file paths, i.e. a
456 * review of several files (the CLI checks they exist; this only reads their
457 * shape, for naming the session and for reading an older CLI's refusal).
458 */
459export function isSeveralFilePaths(words: readonly string[]): boolean {
460 const targets = [...new Set(words.filter((word) => !word.startsWith('-')))]
461 return targets.length > 1 && targets.every(looksLikeFilePath)
462}
463
464/** A review of the session's own working tree, opened with no target words. */
465export const LOCAL_CHANGES_SUBJECT = 'local changes'
466
467/**
468 * The subject a launch takes once its CLI reports the target it opened (the
469 * ready line), or null to keep `typed` (the subject from the typed words).
470 * Annotate and review are named from the target, since the CLI may have
471 * dropped words (a stray `.`, prose beside a file); plan and last keep theirs.
472 * A review typed with no target stays "local changes".
473 */
474export function subjectFromServerTarget(kind: SessionKind, typed: string, target: PlannotatorTarget | undefined): string | null {
475 if (kind === 'plan' || kind === 'last') return null
476 if (kind === 'review' && typed === LOCAL_CHANGES_SUBJECT) return null
477 return plannotatorTargetSubject(kind, target)
478}
479
480/** How the status line, the command output and the plugin turn name a session. */
481export function subjectFor(kind: SessionKind, args: string | readonly string[], version?: number): string {
482 const words = wordsOf(args).filter((word) => !word.startsWith('-'))
483 switch (kind) {
484 case 'plan':
485 return `Plan v${version ?? 1}`
486 case 'last':
487 return "Claude's last message"
488 case 'review': {
489 for (const word of words) {
490 const match = PR_URL.exec(word)
491 if (match) return /merge_requests/i.test(word) ? `MR !${match[1]}` : `PR #${match[1]}`
492 }
493 const directory = words[words.length - 1]
494 return directory ? `changes in ${baseName(directory)}` : LOCAL_CHANGES_SUBJECT
495 }
496 case 'annotate': {
497 // Several file paths open as one review: name it as a bundle.
498 if (isSeveralFilePaths(words)) return plannotatorBundleSubject([...new Set(words)])
499 const target = words.find((word) => /^https?:\/\//i.test(word) || /[./]/.test(word)) ?? words[0]
500 if (!target) return 'document'
501 if (/^https?:\/\//i.test(target)) {
502 try {
503 return new URL(target).host
504 } catch {
505 return target
506 }
507 }
508 return baseName(target)
509 }
510 }
511}
512
513/**
514 * `last`'s subject when the reviewer can pick among several messages: the
515 * feedback may be about an older one (its excerpt rides in the feedback).
516 */
517export const RECENT_MESSAGES_SUBJECT = "Claude's recent messages"
518
519/** The classic `annotate-last` picker's limit (RECENT_MESSAGES_LIMIT in the CLI). */
520export const RECENT_MESSAGES_LIMIT = 25
521/** The CLI's limits on the messages file (`apps/hook/server/host-messages.ts`): raw text per message. */
522export const MAX_PICKER_MESSAGE_BYTES = 2 * 1024 * 1024
523/** The CLI's cap on the whole SERIALIZED file. */
524export const MAX_PICKER_FILE_BYTES = 8 * 1024 * 1024
525/**
526 * What the mod lets the serialized file reach: under the CLI's cap with a
527 * margin. Measured on the JSON as written, since escaping inflates text
528 * (quotes and newlines double, a control character such as ESC becomes
529 * `\u001b`, six bytes).
530 */
531export const PICKER_FILE_BUDGET_BYTES = MAX_PICKER_FILE_BYTES - 256 * 1024
532
533/**
534 * The assistant messages that have text, newest first, at most `limit`.
535 * Consecutive assistant rows (no user row between them) are one response, so
536 * their texts are joined, as the transcript path groups chunks by message id.
537 */
538export function recentAssistantTexts(
539 messages: readonly { role: string; text: string }[],
540 limit: number = RECENT_MESSAGES_LIMIT,
541): string[] {
542 const texts: string[] = []
543 let run: string[] = []
544 const flush = () => {
545 const text = run.join('\n')
546 run = []
547 if (text.trim()) texts.push(text)
548 }
549 for (let index = messages.length - 1; index >= 0 && texts.length < limit; index -= 1) {
550 const message = messages[index]
551 if (!message) continue
552 if (message.role === 'assistant') {
553 if (message.text.trim()) run.unshift(message.text)
554 } else {
555 flush()
556 }
557 }
558 if (texts.length < limit) flush()
559 return texts
560}
561
562export interface PickerMessage {
563 messageId: string
564 text: string
565}
566
567export interface PickerFile {
568 messages: PickerMessage[]
569 /** Exactly what is written to `messages.json`, within PICKER_FILE_BUDGET_BYTES. */
570 json: string
571}
572
573/**
574 * The picker list the CLI reads from `messages.json`: `texts` newest first,
575 * each with an id derived from its content (stable across launches, so a
576 * message keeps its id as newer ones arrive), and the file text itself. The
577 * budget is the serialized size: older messages that would push the file past
578 * it (or are over the CLI's per-message limit) are left out. When the newest
579 * alone does not fit, the list is empty and the launch hands over stdin alone.
580 */
581export async function pickerFile(
582 texts: readonly string[],
583 sha256: (text: string) => Promise<string>,
584 budgetBytes: number = PICKER_FILE_BUDGET_BYTES,
585): Promise<PickerFile> {
586 const encoder = new TextEncoder()
587 const size = (text: string) => encoder.encode(text).length
588 const empty = { messages: [], json: JSON.stringify({ v: 1, messages: [] }) }
589 const picked: PickerMessage[] = []
590 const used = new Map<string, number>()
591 // `{"v":1,"messages":[]}`, then each entry's JSON plus a comma between entries.
592 let total = size(empty.json)
593 for (const [index, text] of texts.entries()) {
594 const fits = size(text) <= MAX_PICKER_MESSAGE_BYTES
595 const base = fits ? `cc-${(await sha256(text)).slice(0, 16)}` : ''
596 const seen = used.get(base) ?? 0
597 const message = { messageId: seen === 0 ? base : `${base}-${seen + 1}`, text }
598 const cost = size(JSON.stringify(message)) + (picked.length > 0 ? 1 : 0)
599 if (!fits || total + cost > budgetBytes) {
600 if (index === 0) return empty
601 continue
602 }
603 total += cost
604 used.set(base, seen + 1)
605 picked.push(message)
606 }
607 return { messages: picked, json: JSON.stringify({ v: 1, messages: picked }) }
608}
609
610/** The command's own output once the server is up. */
611export function openedText(kind: SessionKind, subject: string, url: string, extra?: string): string {
612 const detail = extra ? ` · ${extra}` : ''
613 const second = kind === 'review'
614 ? 'Take your time. Feedback comes back here when you send it.'
615 : 'Your annotations come back here as a message when you send them.'
616 return `Opened ${subject} in Plannotator${detail} · ${url}\n${second}`
617}
618
619/** Startup failure: what the CLI printed, trimmed for the transcript. */
620export function failedText(subject: string, stderr: string, stdout: string, exitCode: number | null): string {
621 const output = (stderr.trim() || stdout.trim()).slice(0, 4000)
622 const code = exitCode === null ? '' : ` (exit ${exitCode})`
623 return output
624 ? `Plannotator could not open ${subject}${code}:\n${output}`
625 : `Plannotator could not open ${subject}${code}.`
626}
627hooks/mod/plan.ts 121 lines1/**
2 * Non-blocking plan review: the decisions behind the `tool.call` hook on
3 * ExitPlanMode and the `classic.PermissionRequest` answer. Pure.
4 */
5
6/** Claude Code's plan-mode tool. */
7export const PLAN_TOOL = 'ExitPlanMode'
8
9/** The annotate cap the CLI applies to a plan file it trusts (MAX_ANNOTATABLE_FILE_BYTES). */
10export const MAX_PLAN_FILE_BYTES = 2 * 1024 * 1024
11
12/**
13 * Whether the plan file named by ExitPlanMode may be read instead of its
14 * inline `plan` (the #1667 stale-snapshot fix, mirrored from
15 * `apps/hook/server/claude-plan.ts`): an absolute `.md` path.
16 * The caller also requires a regular file within MAX_PLAN_FILE_BYTES.
17 */
18export function isTrustablePlanPath(path: unknown): path is string {
19 return typeof path === 'string' && path.startsWith('/') && /\.md$/i.test(path)
20}
21
22/** Hash input: trailing whitespace is not a different plan. */
23export function normalizePlanForHash(plan: string): string {
24 return plan.replace(/\s+$/, '')
25}
26
27/** The approval the reviewer gave, waiting for Claude's next ExitPlanMode. */
28export interface PendingApproval {
29 hash: string
30 plan: string
31 permissionMode?: string
32 version: number
33}
34
35/** The open review of this session, if any. */
36export interface OpenPlanReview {
37 launchId: string
38 dir: string
39 version: number
40 revisionSeq: number
41 url?: string
42}
43
44export type PlanCallAction =
45 | { kind: 'pass-approved'; approval: PendingApproval }
46 | { kind: 'revise'; review: OpenPlanReview }
47 | { kind: 'start' }
48
49/** What an ExitPlanMode call does, given the session's plan review state. */
50export function planCallAction(
51 planHash: string,
52 state: { approval: PendingApproval | null; open: OpenPlanReview | null },
53): PlanCallAction {
54 if (state.approval && state.approval.hash === planHash) return { kind: 'pass-approved', approval: state.approval }
55 if (state.open) return { kind: 'revise', review: state.open }
56 return { kind: 'start' }
57}
58
59/** The `classic.PermissionRequest` decision that lets the approved plan through. */
60export function approvedPermissionDecision(toolInput: unknown, approval: PendingApproval) {
61 const input = toolInput && typeof toolInput === 'object' ? (toolInput as Record<string, unknown>) : {}
62 return {
63 behavior: 'allow' as const,
64 // Claude Code drops an ExitPlanMode allow without updatedInput (>= 2.1.199);
65 // carry the approved text so execution starts from what the reviewer saw.
66 updatedInput: { ...input, plan: approval.plan },
67 ...(approval.permissionMode
68 ? { updatedPermissions: [{ type: 'setMode' as const, mode: approval.permissionMode, destination: 'session' as const }] }
69 : {}),
70 }
71}
72
73// --- Copy (what Claude reads in the denied ExitPlanMode result) -----------
74
75export function waitingDenyText(version: number, url?: string): string {
76 const where = url ? ` (${url})` : ''
77 return `Plan v${version} is open in Plannotator for the user's review${where}. It is NOT approved. Stay in plan mode and do not implement. End your turn. The user's decision will arrive as a message from the plannotator plugin. Until then you may answer questions, and you may revise the plan by calling ExitPlanMode again.`
78}
79
80export function revisedDenyText(version: number): string {
81 return `Plan v${version} replaced v${version - 1} in the open review. Still not approved. End your turn and wait for the decision.`
82}
83
84export function unchangedDenyText(version: number): string {
85 return `Plan v${version} is unchanged and still in review. It is NOT approved. End your turn and wait for the decision.`
86}
87
88export function decidingDenyText(): string {
89 return 'The user is recording a decision on the plan in Plannotator right now, so this revision was not added. It is NOT approved. End your turn and wait for the decision message from the plannotator plugin.'
90}
91
92export function revisionPendingDenyText(version: number): string {
93 return `Plan v${version} was sent to the open Plannotator review. It is NOT approved. End your turn and wait for the decision.`
94}
95
96/**
97 * Whether a `plannotator claude-mod-plan` launch failed because the CLI is
98 * older than the plugin (the two update separately) and has no such
99 * subcommand: 0.27.11+ answers "Unknown command", and older CLIs read any
100 * unknown subcommand as the classic hook, which finds no hook event on stdin.
101 */
102export function cliLacksModPlan(stderr: string): boolean {
103 return /Unknown command: claude-mod-plan\b/.test(stderr) || /No plan content in hook event/.test(stderr)
104}
105
106/** Logged once per session when the CLI has no non-blocking plan review. */
107export const CLASSIC_PLAN_REVIEW_TEXT =
108 'Your plannotator CLI is older than the Plannotator plugin and has no non-blocking plan review, so plans open in the classic review, which holds this session until you decide. Update the CLI (curl -fsSL https://plannotator.ai/install.sh | bash) and start or resume a session to get non-blocking plan review.'
109
110/**
111 * Sent to Claude when the old CLI refused only after the ExitPlanMode call had
112 * already been told a review was open: nothing is open, so ask for the call again.
113 */
114export const CLASSIC_PLAN_RETRY_TEXT =
115 "The plan review did not open: the user's plannotator CLI is older than the Plannotator plugin and has no non-blocking plan review. The plan is NOT approved. Call ExitPlanMode again with the same plan; it will open in the classic review."
116
117/** Shown in the status line and toasts. */
118export function planWaitingStatus(version: number): string {
119 return `Plan v${version} · waiting for your review`
120}
121hooks/mod/take-over.ts 44 lines1/**
2 * Claude running the `plannotator` CLI through Bash, answered by the mod.
3 *
4 * The skill and the tool steer Claude to the `plannotator` tool, but a model
5 * that reaches for `plannotator annotate x.md --gate --json` in Bash would
6 * otherwise block the session on the CLI and give Ask AI a separate AI. When
7 * the command is one the tool can represent (`plannotatorCommandToToolInput`
8 * in tool.ts decides, host-neutral), the `tool.call` hook answers the Bash call
9 * with the tool's own launch and result text; the command never runs. Anything
10 * else (strict gates, pipelines, unknown flags, a dev build run by path) runs
11 * as written.
12 *
13 * Main loop only: a subagent may run in its own cwd or worktree, while the
14 * mod launches in the session's cwd, so its `review` or `annotate notes.md`
15 * would open the wrong diff or file. A subagent's command runs for real.
16 */
17
18import { plannotatorCommandToToolInput, type PlannotatorToolInput } from './tool'
19
20/** Claude Code's shell tool. */
21export const SHELL_TOOL = 'Bash'
22
23/** The tool input a main-loop Bash call's command stands for; null runs it as written. */
24export function shellTakeOver(command: unknown, fromSubagent: boolean): PlannotatorToolInput | null {
25 if (fromSubagent || typeof command !== 'string') return null
26 return plannotatorCommandToToolInput(command)
27}
28
29/** The Bash tool's result record, carrying the tool's text as the command's output. */
30export interface ShellResult {
31 stdout: string
32 stderr: string
33 interrupted: boolean
34}
35
36/** Open through the tool's launch; the tool's text becomes the Bash result, its error a deny. */
37export async function answerShellCall(
38 mod: { runTool(input: unknown): Promise<{ text: string } | { deny: string }> },
39 input: PlannotatorToolInput,
40): Promise<{ result: ShellResult } | { deny: string }> {
41 const answer = await mod.runTool(input)
42 return 'deny' in answer ? { deny: answer.deny } : { result: { stdout: answer.text, stderr: '', interrupted: false } }
43}
44hooks/mod/tool.ts 769 lines1/**
2 * The `plannotator` tool's contract, as the Claude Code mod registers it
3 * (`$.tool.register` in register.ts).
4 *
5 * A COPY of packages/shared/plannotator-tool.ts: a hooks module may import
6 * only its own folder. Everything below the CONTRACT marker must stay byte
7 * for byte the same as there; `tool.test.ts` fails when they differ. Edit the
8 * shared file, then paste its contract section here.
9 */
10
11// --- CONTRACT (copied verbatim into apps/hook/hooks/mod/tool.ts) ---
12
13export const PLANNOTATOR_TOOL_NAME = 'plannotator'
14
15export type PlannotatorToolAction = 'annotate' | 'review' | 'last' | 'list' | 'close'
16
17/** The actions that open a review page. */
18export type PlannotatorToolOpenAction = 'annotate' | 'review' | 'last'
19
20export interface PlannotatorToolInput {
21 action: PlannotatorToolAction
22 /**
23 * annotate: one file, folder or URL, or (contract v2) several files as a
24 * list, in the order they should be read. A one-item list is returned as a
25 * plain string and exact duplicates are dropped, so a list here always has
26 * two or more entries. review: a directory or PR URL (string only).
27 */
28 target?: string | string[]
29 gate?: boolean
30 options?: { base?: string; markdown?: boolean }
31 /** close: a session id (`pn-` + 6 hex, as the results name it) or "all". */
32 session?: string
33}
34
35export const PLANNOTATOR_TOOL_DESCRIPTION = [
36 'Open Plannotator, the browser review UI, for the user, and return at once. Also lists and closes the reviews opened in this conversation (by this tool or the user\'s /plannotator-* commands).',
37 '- action "annotate": annotate a file (markdown, text, config, HTML), a folder, or a URL; `target` is required. Pass a list as `target` to review several files together, in the order you want them read. `gate: true` adds an Approve button for an explicit sign-off. `options.markdown: true` converts HTML or a URL to markdown first.',
38 '- action "review": review code changes; `target` is an optional repository directory or a GitHub/GitLab/Bitbucket pull request URL (default: the current repository). `options.base` sets the compare branch or ref (git only).',
39 '- action "last": annotate your own last assistant message; no target.',
40 '- action "list": the reviews opened in this conversation that are still open, one line each: session id, what it shows, url, age, state, and how many comments the reviewer has not sent yet.',
41 '- action "close": close a review opened in this conversation that is no longer needed; `session` is its id (pn-...) or "all". Nothing is sent to you, and the reviewer\'s unsent comments stay saved as a draft. Plan reviews are not closed this way: they end with the reviewer\'s decision.',
42 'Opening only opens the page and names its session id (pn-...). The reviewer\'s feedback arrives later as a message in this conversation that names the same id, so end your turn after opening and wait for it. Use this tool instead of running the `plannotator` CLI. Plan review is not done with this tool: it opens by itself when you exit plan mode.',
43].join('\n')
44
45export const PLANNOTATOR_TOOL_INPUT_SCHEMA = {
46 type: 'object',
47 properties: {
48 action: {
49 type: 'string',
50 enum: ['annotate', 'review', 'last', 'list', 'close'],
51 description: 'What to do: open a file/folder/URL to annotate, code changes or a PR to review, or your last message; list your open reviews; close one.',
52 },
53 target: {
54 anyOf: [
55 { type: 'string' },
56 { type: 'array', items: { type: 'string' }, minItems: 1 },
57 ],
58 description: 'annotate: the file, folder or URL (required), or a list of file paths to review together in that order. review: a repository directory or PR URL (optional). Other actions: not used.',
59 },
60 gate: {
61 type: 'boolean',
62 description: 'annotate only: show an Approve button so the reviewer can sign off explicitly.',
63 },
64 options: {
65 type: 'object',
66 properties: {
67 base: { type: 'string', description: 'review only: the branch or ref to compare against (git).' },
68 markdown: { type: 'boolean', description: 'annotate only: convert an HTML file or URL to markdown before annotating.' },
69 },
70 additionalProperties: false,
71 },
72 session: {
73 type: 'string',
74 description: 'close: the session id (pn-...) of a review opened in this conversation, or "all".',
75 },
76 },
77 required: ['action'],
78 additionalProperties: false,
79} as const
80
81/** Longest target or base accepted; a real path or URL is far shorter. */
82export const PLANNOTATOR_TOOL_MAX_TEXT = 4096
83
84/**
85 * A session id as every host names it: `pn-` and six lowercase hex digits, a
86 * short alias of the host's own launch id. Hosts resolve an id only among the
87 * reviews their own agent session opened.
88 */
89export const PLANNOTATOR_SESSION_ID_PREFIX = 'pn-'
90
91const SESSION_ID = /^(?:pn-)?([0-9a-f]{6})$/i
92
93/** `pn-3f2a9c` from an id the agent typed (`pn-3F2A9C`, `3f2a9c`), or null when it is not one. */
94export function normalizePlannotatorSessionId(value: string): string | null {
95 const match = SESSION_ID.exec(value.trim())
96 return match ? `${PLANNOTATOR_SESSION_ID_PREFIX}${(match[1] as string).toLowerCase()}` : null
97}
98
99/** The session id for six hex digits a host drew for its launch. */
100export function plannotatorSessionId(hex6: string): string {
101 return `${PLANNOTATOR_SESSION_ID_PREFIX}${hex6.toLowerCase()}`
102}
103
104const TOOL_KEYS = ['action', 'target', 'gate', 'options', 'session']
105const OPTION_KEYS = ['base', 'markdown']
106const ACTIONS: readonly PlannotatorToolAction[] = ['annotate', 'review', 'last', 'list', 'close']
107
108export type PlannotatorToolParse = { ok: true; input: PlannotatorToolInput } | { ok: false; error: string }
109
110function isRecord(value: unknown): value is Record<string, unknown> {
111 return typeof value === 'object' && value !== null && !Array.isArray(value)
112}
113
114/** A string the CLI can take as one argument: no control characters, never read as a flag. */
115function checkWord(name: string, value: unknown): string | null {
116 if (typeof value !== 'string') return `${name} must be a string`
117 if (value.trim() === '') return `${name} must not be empty`
118 if (value.length > PLANNOTATOR_TOOL_MAX_TEXT) return `${name} is longer than ${PLANNOTATOR_TOOL_MAX_TEXT} characters`
119 if (/[\u0000-\u001f\u007f]/.test(value)) return `${name} must not contain control characters or line breaks`
120 if (value.trim().startsWith('-')) return `${name} must not start with "-"`
121 return null
122}
123
124/** Whether the action opens a page (and so takes target, gate, options). */
125export function isPlannotatorToolOpenAction(action: PlannotatorToolAction): action is PlannotatorToolOpenAction {
126 return action === 'annotate' || action === 'review' || action === 'last'
127}
128
129/**
130 * Validates a tool call strictly: unknown keys, wrong types, and a field the
131 * action does not take are errors (a `false` the action ignores is allowed,
132 * since models often fill defaults). The error text is for the model.
133 */
134export function parsePlannotatorToolInput(value: unknown): PlannotatorToolParse {
135 const fail = (error: string): PlannotatorToolParse => ({ ok: false, error: `Invalid plannotator call: ${error}.` })
136 if (!isRecord(value)) return fail('the input must be an object')
137 for (const key of Object.keys(value)) {
138 if (!TOOL_KEYS.includes(key)) return fail(`unknown field "${key}"`)
139 }
140 const action = value.action
141 if (typeof action !== 'string' || !ACTIONS.includes(action as PlannotatorToolAction)) {
142 return fail('action must be "annotate", "review", "last", "list" or "close"')
143 }
144 const input: PlannotatorToolInput = { action: action as PlannotatorToolAction }
145 const opens = isPlannotatorToolOpenAction(input.action)
146
147 if (value.target !== undefined) {
148 if (!opens || action === 'last') return fail(`action "${action}" takes no target`)
149 if (Array.isArray(value.target)) {
150 if (action !== 'annotate') return fail('a list of targets is for action "annotate" only')
151 if (value.target.length === 0) return fail('target must not be an empty list')
152 const targets: string[] = []
153 for (const [index, item] of value.target.entries()) {
154 const problem = checkWord(`target[${index}]`, item)
155 if (problem) return fail(problem)
156 const trimmed = (item as string).trim()
157 if (!targets.includes(trimmed)) targets.push(trimmed)
158 }
159 input.target = targets.length === 1 ? targets[0] : targets
160 } else {
161 const problem = checkWord('target', value.target)
162 if (problem) return fail(problem)
163 input.target = (value.target as string).trim()
164 }
165 } else if (action === 'annotate') {
166 return fail('action "annotate" needs a target (a file, folder or URL)')
167 }
168
169 if (value.gate !== undefined) {
170 if (typeof value.gate !== 'boolean') return fail('gate must be true or false')
171 if (value.gate && action !== 'annotate') return fail('gate is for action "annotate" only')
172 if (value.gate) input.gate = true
173 }
174
175 if (value.options !== undefined) {
176 const options = value.options
177 if (!isRecord(options)) return fail('options must be an object')
178 for (const key of Object.keys(options)) {
179 if (!OPTION_KEYS.includes(key)) return fail(`unknown option "${key}"`)
180 }
181 const parsed: NonNullable<PlannotatorToolInput['options']> = {}
182 if (options.base !== undefined) {
183 if (action !== 'review') return fail('options.base is for action "review" only')
184 const problem = checkWord('options.base', options.base)
185 if (problem) return fail(problem)
186 if (/\s/.test((options.base as string).trim())) return fail('options.base must not contain spaces')
187 parsed.base = (options.base as string).trim()
188 }
189 if (options.markdown !== undefined) {
190 if (typeof options.markdown !== 'boolean') return fail('options.markdown must be true or false')
191 if (options.markdown && action !== 'annotate') return fail('options.markdown is for action "annotate" only')
192 if (options.markdown) parsed.markdown = true
193 }
194 if (Object.keys(parsed).length > 0) input.options = parsed
195 }
196
197 if (value.session !== undefined) {
198 if (action !== 'close') return fail('session is for action "close" only')
199 if (typeof value.session !== 'string') return fail('session must be a string')
200 if (action === 'close' && value.session.trim().toLowerCase() === 'all') {
201 input.session = 'all'
202 } else {
203 const id = normalizePlannotatorSessionId(value.session)
204 if (!id) return fail('session must be a session id such as "pn-3f2a9c" or "all"')
205 input.session = id
206 }
207 } else if (action === 'close') {
208 return fail('action "close" needs a session (the pn-... id a result named, or "all")')
209 }
210
211 return { ok: true, input }
212}
213
214/** The files of an annotate call, in order (one or several). */
215export function plannotatorToolTargets(input: PlannotatorToolInput): string[] {
216 if (input.target === undefined) return []
217 return Array.isArray(input.target) ? [...input.target] : [input.target]
218}
219
220/**
221 * The arguments the matching slash command would carry (`/plannotator-annotate
222 * <these>`), one argument per element, never re-split. A list of annotate
223 * targets passes a bare word as `./word`, so the CLI reads every entry as a
224 * path. `last` has none, and neither do the actions that open nothing (list,
225 * close).
226 */
227export function plannotatorToolArgs(input: PlannotatorToolInput): string[] {
228 switch (input.action) {
229 case 'annotate':
230 return [
231 // A list is files named by their paths, so every entry is passed as
232 // one: a bare word becomes `./word`. The CLI then refuses a list with a
233 // missing file instead of reading the bare word as prose.
234 ...(Array.isArray(input.target)
235 ? input.target.map((target) => (looksLikeFilePath(target) || /^https?:\/\//i.test(target) ? target : `./${target}`))
236 : plannotatorToolTargets(input)),
237 ...(input.gate ? ['--gate'] : []),
238 ...(input.options?.markdown ? ['--markdown'] : []),
239 ]
240 case 'review':
241 return [
242 ...(input.options?.base ? ['--base', input.options.base] : []),
243 ...plannotatorToolTargets(input),
244 ]
245 case 'last':
246 case 'list':
247 case 'close':
248 return []
249 }
250}
251
252/**
253 * What a review is OF, in full: the absolute file or folder path, the URL, the
254 * files of a bundle (in review order), the reviewed directory or the PR URL.
255 * Taken from the server that shows (and later submits) the review, never
256 * guessed from the words the agent typed: two files can share a name.
257 */
258export type PlannotatorTarget = string | readonly string[]
259
260/**
261 * The line(s) naming a review's full target: `Target: /abs/path/notes.md`, or
262 * for several files `Targets:` and one `- path` line each. Empty for no target
263 * (the last-message surface has none). Every decision message and the tool's
264 * opened text carry it, so an agent never has to guess which of two
265 * same-named files a decision is about.
266 */
267export function plannotatorTargetLines(target: PlannotatorTarget | undefined): string {
268 if (target === undefined) return ''
269 if (typeof target === 'string') return target.trim() ? `Target: ${target}` : ''
270 const paths = target.filter((path) => path.trim() !== '')
271 if (paths.length === 0) return ''
272 if (paths.length === 1) return `Target: ${paths[0]}`
273 return ['Targets:', ...paths.map((path) => `- ${path}`)].join('\n')
274}
275
276/**
277 * The tool's result once the session is open (`url`) or still starting (no
278 * url). `sessionId` leads it when given; `target` (the full path or URL the
279 * server opened, when known) follows it.
280 */
281export function plannotatorToolOpenedText(
282 subject: string,
283 url: string | undefined,
284 gate: boolean,
285 sessionId?: string,
286 target?: PlannotatorTarget,
287): string {
288 const where = url ? `Opened ${subject} in Plannotator: ${url}` : `Plannotator is starting for ${subject}; it opens in the browser when ready.`
289 const outcome = gate
290 ? 'If they approve, an approval message arrives; if they send annotations, the feedback arrives. Closing it sends nothing.'
291 : 'When they send annotations, the feedback arrives. Closing it with nothing to send sends nothing.'
292 const targetLines = plannotatorTargetLines(target)
293 return [
294 ...(sessionId ? [`Session: ${sessionId}`] : []),
295 ...(targetLines ? [targetLines] : []),
296 where,
297 'The reviewer is looking at it now. End your turn now and wait: their decision arrives later as a message in this conversation that starts with "Plannotator:".',
298 outcome,
299 'Do not poll, reopen it, or run the plannotator CLI for this session.',
300 ].join('\n')
301}
302
303/**
304 * The outcome a decision heading names for a code review the reviewer posted
305 * straight to the PR platform (GitHub, GitLab, Bitbucket): the same words on
306 * every host that delivers it as a message.
307 */
308export const PLANNOTATOR_OUTCOME_REVIEW_POSTED = 'Review posted'
309
310/**
311 * The first line of every decision message a host delivers: what was
312 * reviewed, its session id, and the outcome (`Feedback · 3 comments`). With a
313 * `target` (the full path, URL or files the SUBMITTING server reviewed) the
314 * heading is followed by its `Target:` line(s), so even a bare approval names
315 * exactly which file it approves.
316 */
317export function plannotatorDecisionHeading(
318 subject: string,
319 sessionId: string | undefined,
320 outcome: string,
321 target?: PlannotatorTarget,
322): string {
323 const heading = `Plannotator: ${subject}${sessionId ? ` (${sessionId})` : ''} — ${outcome}.`
324 const targetLines = plannotatorTargetLines(target)
325 return targetLines ? `${heading}\n${targetLines}` : heading
326}
327
328const PR_URL_PATTERN = /^https?:\/\/[^\s/]+\/.+\/(?:pull|pull-requests|merge_requests)\/(\d+)\b/i
329
330/** How a pull request URL is named (`PR #12`, `MR !12`), or null when the target is not one. */
331export function plannotatorPrSubject(target: PlannotatorTarget | undefined): string | null {
332 if (typeof target !== 'string') return null
333 const match = PR_URL_PATTERN.exec(target)
334 if (!match) return null
335 return /merge_requests/i.test(target) ? `MR !${match[1]}` : `PR #${match[1]}`
336}
337
338/**
339 * The subject a decision is headed with: the launch's own, unless the
340 * decision is about a DIFFERENT pull request than the launch opened (the
341 * reviewer switched PRs in place), which is then named itself.
342 */
343export function plannotatorDecisionSubject(
344 subject: string,
345 launchTarget: PlannotatorTarget | undefined,
346 decisionTarget: PlannotatorTarget | undefined,
347): string {
348 if (decisionTarget === undefined || plannotatorSameTarget(launchTarget, decisionTarget)) return subject
349 return plannotatorPrSubject(decisionTarget) ?? subject
350}
351
352/** A target as a list of trimmed entries, trailing separators dropped. */
353function targetEntries(target: PlannotatorTarget): string[] {
354 return (typeof target === 'string' ? [target] : [...target]).map((value) => value.trim().replace(/[\\/]+$/, ''))
355}
356
357/** Whether two targets name the same thing (a list compares entry by entry, in order). */
358export function plannotatorSameTarget(a: PlannotatorTarget | undefined, b: PlannotatorTarget | undefined): boolean {
359 if (a === undefined || b === undefined) return a === b
360 const left = targetEntries(a)
361 const right = targetEntries(b)
362 return left.length === right.length && left.every((value, index) => value === right[index])
363}
364
365function pathSegments(path: string): string[] {
366 return path.split(/[\\/]+/).filter((segment) => segment !== '')
367}
368
369/**
370 * Subjects that tell open reviews apart. Reviews whose subjects are equal and
371 * whose targets are different paths (two `QUESTIONS.md` in different folders)
372 * are each named by the shortest trailing part of their path, two segments at
373 * least, that none of the others shares (`releases-2026-10-04/QUESTIONS.md`).
374 * A subject that ends in the path's last segment keeps its other words
375 * (`changes in app/web`). Every other subject is returned as is, in order.
376 */
377export function plannotatorDistinctSubjects(
378 reviews: readonly { subject: string; target?: PlannotatorTarget }[],
379): string[] {
380 const subjects = reviews.map((review) => review.subject)
381 const groups = new Map<string, number[]>()
382 reviews.forEach((review, index) => {
383 if (typeof review.target !== 'string' || /^https?:\/\//i.test(review.target)) return
384 const group = groups.get(review.subject) ?? []
385 group.push(index)
386 groups.set(review.subject, group)
387 })
388 for (const [subject, members] of groups) {
389 const paths = members.map((index) => pathSegments(targetEntries(reviews[index]?.target as string)[0] as string))
390 const joined = paths.map((segments) => segments.join('/'))
391 if (new Set(joined).size < 2) continue
392 const tail = (segments: readonly string[], depth: number) => segments.slice(-depth).join('/')
393 members.forEach((index, position) => {
394 const segments = paths[position] as string[]
395 const last = segments[segments.length - 1]
396 if (!last || !subject.endsWith(last)) return
397 let depth = 2
398 while (
399 depth < segments.length &&
400 paths.some((other, at) => joined[at] !== joined[position] && tail(other, depth) === tail(segments, depth))
401 ) {
402 depth += 1
403 }
404 subjects[index] = `${subject.slice(0, subject.length - last.length)}${tail(segments, depth)}`
405 })
406 }
407 return subjects
408}
409
410/** What a host answers a list of several files with when its Plannotator CLI is too old to open them as one review. */
411export const PLANNOTATOR_TOOL_BUNDLE_UNAVAILABLE_TEXT =
412 'Plannotator did not open: the installed Plannotator is too old to open several files at once. Ask the user to update Plannotator to open several files at once, or open them one at a time for now.'
413
414/**
415 * The line a CLI that opens bundles adds to its "Ambiguous annotate
416 * arguments" error (`ANNOTATE_BUNDLE_HINT` in annotate-target.ts; copied here
417 * because this section must stay dependency-free).
418 */
419export const PLANNOTATOR_BUNDLE_HINT_LINE =
420 'To review several files together, pass only their paths: plannotator annotate a.md b.html'
421
422/**
423 * Whether a CLI's startup error for several file paths is an OLDER CLI's
424 * refusal: its ambiguity error without the bundle hint a current CLI adds. A
425 * host that sent a list of paths answers `PLANNOTATOR_TOOL_BUNDLE_UNAVAILABLE_TEXT`
426 * then, instead of showing an error that tells the agent to pick one file.
427 */
428export function isOlderCliBundleRefusal(errorText: string): boolean {
429 return errorText.includes('Ambiguous annotate arguments:') && !errorText.includes(PLANNOTATOR_BUNDLE_HINT_LINE)
430}
431
432/** The file name of a path, for subjects (either separator). */
433function fileNameOf(path: string): string {
434 const trimmed = path.replace(/[\\/]+$/, '')
435 return trimmed.slice(Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')) + 1) || trimmed
436}
437
438/**
439 * What a review of several files is called wherever one line names it: the
440 * tool's result, the decision heading, the session list. Up to three file
441 * names, then a count of the rest: "3 files: spec.md, mock.html, notes.md",
442 * "5 files: a.md, b.md, c.md +2 more".
443 */
444export function plannotatorBundleSubject(paths: readonly string[]): string {
445 const names = paths.map(fileNameOf)
446 const rest = names.length - 3
447 return `${names.length} files: ${names.slice(0, 3).join(', ')}${rest > 0 ? ` +${rest} more` : ''}`
448}
449
450/**
451 * A subject from the target the CLI reports it opened (the ready line, the
452 * result record), for the kinds whose subject names what was opened:
453 * annotate (the file name, a URL's host, or a bundle's file names) and
454 * review (`PR #12` / `MR !12`, or `changes in <directory>`). Null when the
455 * target names nothing usable; the host then keeps the subject it built from
456 * the typed words, as it must for an older CLI that reports no target. Typed
457 * words are not trusted for this when a target is known: the CLI ignores a
458 * stray `.` or prose words beside a file, so `annotate . a.md` opens a.md.
459 */
460export function plannotatorTargetSubject(kind: 'annotate' | 'review', target: PlannotatorTarget | undefined): string | null {
461 if (target === undefined) return null
462 const entries = (typeof target === 'string' ? [target] : [...target]).filter((entry) => entry.trim() !== '')
463 if (entries.length === 0) return null
464 if (kind === 'annotate' && entries.length > 1) return plannotatorBundleSubject(entries)
465 if (entries.length !== 1) return null
466 const only = entries[0] as string
467 if (kind === 'review') {
468 const pr = plannotatorPrSubject(only)
469 if (pr) return pr
470 if (/^https?:\/\//i.test(only)) return null
471 const name = fileNameOf(only)
472 return name ? `changes in ${name}` : null
473 }
474 if (/^https?:\/\//i.test(only)) {
475 try {
476 return new URL(only).host || only
477 } catch {
478 return only
479 }
480 }
481 return fileNameOf(only) || null
482}
483
484/**
485 * Whether a shell word reads as a file path rather than prose: it has a path
486 * separator, starts with `~`, `.` or `@`, or ends in a file extension. A URL
487 * is not a path. Pure: nothing is looked up on disk.
488 */
489export function looksLikeFilePath(word: string): boolean {
490 if (/^https?:\/\//i.test(word)) return false
491 if (/[\\/]/.test(word) || /^[~.@]/.test(word)) return true
492 return /\.[A-Za-z0-9]{1,12}$/.test(word)
493}
494
495/** One open review, as `list` reports it. */
496export interface PlannotatorSessionSummary {
497 id: string
498 kind: 'plan' | 'annotate' | 'review' | 'last'
499 /** What it shows: the file(s), folder, URL, PR, or "local changes". */
500 subject: string
501 url?: string
502 ageMs: number
503 /** starting: the server is not up yet. open: waiting for the reviewer. decided: the reviewer decided and it is closing. */
504 state: 'starting' | 'open' | 'decided'
505 /** Comments the reviewer wrote and has not sent; null when the server cannot say (an older Plannotator). */
506 unsent: number | null
507}
508
509function ageText(ms: number): string {
510 const minutes = Math.floor(Math.max(0, ms) / 60_000)
511 if (minutes < 1) return 'under a minute'
512 if (minutes < 60) return `${minutes} min`
513 const hours = Math.floor(minutes / 60)
514 if (hours < 48) return `${hours} h`
515 return `${Math.floor(hours / 24)} days`
516}
517
518/** `list`'s result: one line per open review, or a sentence when there is none. */
519export function plannotatorToolListText(sessions: readonly PlannotatorSessionSummary[]): string {
520 if (sessions.length === 0) return 'No open Plannotator reviews from this conversation.'
521 const lines = sessions.map((session) =>
522 [
523 session.id,
524 session.kind,
525 session.subject,
526 session.url ?? 'no url yet',
527 ageText(session.ageMs),
528 session.state,
529 `unsent: ${session.unsent === null ? 'unknown' : session.unsent}`,
530 ].join(' · '),
531 )
532 const plans = sessions.some((session) => session.kind === 'plan')
533 return [
534 `${sessions.length} open Plannotator ${sessions.length === 1 ? 'review' : 'reviews'} from this conversation:`,
535 ...lines,
536 plans
537 ? 'Close one you no longer need with action "close" and its session id. Plan reviews close only with the reviewer\'s decision.'
538 : 'Close one you no longer need with action "close" and its session id.',
539 ].join('\n')
540}
541
542/** How one `close` went, for `plannotatorToolCloseText`. */
543export type PlannotatorCloseOutcome =
544 | { id: string; subject: string; closed: true; unsent: number | null }
545 | { id: string; subject: string; closed: false; reason: 'plan' | 'decided' | 'failed'; detail?: string }
546
547function savedText(unsent: number | null): string {
548 if (unsent === null) return 'any unsent comments stay saved as a draft'
549 if (unsent === 0) return 'no unsent comments'
550 return `${unsent} unsent ${unsent === 1 ? 'comment' : 'comments'} saved as a draft`
551}
552
553/** `close`'s result. Nothing is sent to the agent later for a review it closed. */
554export function plannotatorToolCloseText(outcomes: readonly PlannotatorCloseOutcome[]): string {
555 if (outcomes.length === 0) return 'No open Plannotator reviews from this conversation to close.'
556 const lines = outcomes.map((outcome) => {
557 if (outcome.closed) return `Closed ${outcome.subject} (${outcome.id}): ${savedText(outcome.unsent)}.`
558 switch (outcome.reason) {
559 case 'plan':
560 return `Not closed: ${outcome.subject} (${outcome.id}) is a plan review; it ends with the reviewer's decision.`
561 case 'decided':
562 return `Not closed: ${outcome.subject} (${outcome.id}) was already decided; its decision arrives as a message.`
563 case 'failed':
564 return `Could not close ${outcome.subject} (${outcome.id})${outcome.detail ? `: ${outcome.detail}` : ''}.`
565 }
566 })
567 const closedAny = outcomes.some((outcome) => outcome.closed)
568 return closedAny ? [...lines, 'Nothing more arrives for a review you closed.'].join('\n') : lines.join('\n')
569}
570
571/** `close` naming a session this conversation did not open (or that already ended). */
572export function plannotatorUnknownSessionText(id: string): string {
573 return `No open Plannotator review ${id} from this conversation. Call the plannotator tool with action "list" to see yours.`
574}
575
576/**
577 * The words of `command` when it is ONE simple command a shell would run
578 * without interpreting anything; null otherwise.
579 *
580 * Quoting follows the slash commands' splitter (`splitShellWords`): whitespace
581 * separates, single quotes are literal, double quotes group (a backslash
582 * escapes `"`, `\`, `$` and a backtick inside them), a backslash outside quotes
583 * escapes the next character. Where that splitter tolerates, this refuses:
584 * anything the shell would expand or treat as syntax makes the result null, so
585 * a word here is exactly the argument the program would have received. That
586 * is: an unquoted operator or redirect (`; & | < > ( )`), a line break, `$`
587 * or a backtick outside single quotes, an unquoted glob or brace (`* ? [ { }`),
588 * a `#` that starts a word (a comment), a `~` that starts a word other than
589 * `~` or `~/...` (the CLIs expand those two themselves), and an unterminated
590 * quote.
591 */
592export function simpleShellCommandWords(command: string): string[] | null {
593 const input = command.trim()
594 const words: string[] = []
595 let word = ''
596 let inWord = false
597 let quote: '"' | "'" | null = null
598
599 for (let index = 0; index < input.length; index += 1) {
600 const char = input[index] as string
601
602 if (quote === "'") {
603 if (char === "'") quote = null
604 else word += char
605 continue
606 }
607
608 if (quote === '"') {
609 if (char === '"') {
610 quote = null
611 } else if (char === '\\' && index + 1 < input.length && '"\\$`'.includes(input[index + 1] as string)) {
612 word += input[index + 1]
613 index += 1
614 } else if (char === '$' || char === '`') {
615 return null
616 } else {
617 word += char
618 }
619 continue
620 }
621
622 if (char === "'" || char === '"') {
623 quote = char
624 inWord = true
625 continue
626 }
627
628 if (char === '\\' && index + 1 < input.length) {
629 // A backslash before a line break joins lines: not one simple word.
630 if (input[index + 1] === '\n' || input[index + 1] === '\r') return null
631 word += input[index + 1]
632 inWord = true
633 index += 1
634 continue
635 }
636
637 if (char === '\n' || char === '\r') return null
638
639 if (/\s/.test(char)) {
640 if (inWord) {
641 words.push(word)
642 word = ''
643 inWord = false
644 }
645 continue
646 }
647
648 if (';&|<>()$`*?[{}'.includes(char)) return null
649 if (!inWord && char === '#') return null
650 if (!inWord && char === '~') {
651 const after = input[index + 1]
652 if (after !== undefined && after !== '/' && !/\s/.test(after)) return null
653 }
654
655 word += char
656 inWord = true
657 }
658
659 if (quote) return null
660 if (inWord) words.push(word)
661 return words
662}
663
664/**
665 * Annotate flags for a script that reads the CLI's own channels: the exit
666 * code and stdout record of a strict gate (`--require-approval`,
667 * `--result-file`) or the hook-shaped stdout (`--hook`). A host that starts
668 * the CLI detached and delivers the decision later as a message has no caller
669 * reading any of them, so the reviewer's decision would be lost. Such a host
670 * refuses annotate words carrying one (`scriptOnlyAnnotateFlag`), and the
671 * shell take-over below leaves a command carrying one to run as written.
672 */
673export const SCRIPT_ONLY_ANNOTATE_FLAGS: readonly string[] = ['--require-approval', '--result-file', '--hook']
674
675/** The first script-only annotate flag among the words, or null. */
676export function scriptOnlyAnnotateFlag(words: readonly string[]): string | null {
677 return words.find((word) => SCRIPT_ONLY_ANNOTATE_FLAGS.includes(word)) ?? null
678}
679
680/** What a detached host answers when the user's annotate words carry a script-only flag. */
681export function scriptOnlyAnnotateFlagText(flag: string): string {
682 return (
683 `Plannotator did not open: ${flag} is for scripts that read the CLI's exit code or result file, ` +
684 'and here your decision comes back as a message instead, so nothing would read it. ' +
685 `Run \`plannotator annotate <file> --gate --json ${flag === '--result-file' ? '--result-file <path>' : flag}\` in a terminal, ` +
686 `or drop ${SCRIPT_ONLY_ANNOTATE_FLAGS.join(' / ')} to annotate here.`
687 )
688}
689
690const COMMAND_ACTIONS: Record<string, PlannotatorToolAction> = {
691 annotate: 'annotate',
692 review: 'review',
693 'annotate-last': 'last',
694 last: 'last',
695}
696
697/**
698 * An agent's shell command (a Bash tool call) as the `plannotator` tool call
699 * that opens the same thing, or null when the command is not one to take over.
700 *
701 * A host that can deliver decisions later answers such a command itself,
702 * through the same launch as the tool, instead of running the blocking CLI:
703 * the agent gets the tool's experience (the page opens, the turn ends, the
704 * decision arrives as a message, Ask AI asks this session) even when it reached
705 * for the CLI.
706 *
707 * Taken over: one simple command (see `simpleShellCommandWords`) whose first
708 * word is exactly `plannotator` (the installed binary on PATH; a path such as
709 * `./plannotator` is a dev build and runs for real), with subcommand
710 * `annotate`, `review`, `annotate-last` or `last`, carrying only what the tool
711 * represents: annotate `<target>` (or several targets that all read as file
712 * paths, `looksLikeFilePath`, which become `target: [...]`, a review of
713 * several files) plus `--gate` and `--markdown`; review `[target]` plus
714 * `--base <ref>`; last with no arguments. `--json` is accepted and dropped
715 * (the decision arrives as a message, not on stdout). The result passes
716 * `parsePlannotatorToolInput`.
717 *
718 * Everything else is null and runs as written: other subcommands, any other
719 * flag (`--require-approval`, `--result-file`, `--hook`, `--tailscale`,
720 * `--static`, `--app`, `--no-jina`, `--help`, ...), a repeated flag, several
721 * targets that are not all file paths (or for review), an environment prefix,
722 * and any shell syntax (`cd x && ...`,
723 * pipes, redirects, substitutions), so scripted strict gates keep the CLI.
724 */
725export function plannotatorCommandToToolInput(command: string): PlannotatorToolInput | null {
726 const words = simpleShellCommandWords(command)
727 if (!words || words.length < 2) return null
728 const program = words[0] as string
729 if (program !== 'plannotator') return null
730 const action = COMMAND_ACTIONS[words[1] as string]
731 if (!action) return null
732
733 const seen = new Set<string>()
734 const targets: string[] = []
735 const call: Record<string, unknown> = { action }
736 const options: Record<string, unknown> = {}
737 const rest = words.slice(2)
738 if (scriptOnlyAnnotateFlag(rest)) return null
739 for (let index = 0; index < rest.length; index += 1) {
740 const word = rest[index] as string
741 if (!word.startsWith('-')) {
742 targets.push(word)
743 continue
744 }
745 if (seen.has(word)) return null
746 seen.add(word)
747 if (word === '--json') continue
748 if (word === '--gate' && action === 'annotate') call.gate = true
749 else if (word === '--markdown' && action === 'annotate') options.markdown = true
750 else if (word === '--base' && action === 'review') {
751 const value = rest[index + 1]
752 if (value === undefined || value.startsWith('-')) return null
753 options.base = value
754 index += 1
755 } else return null
756 }
757
758 if (targets.length > 1) {
759 // Several targets are taken over only as a review of several files:
760 // annotate, and every word a file path. Anything else (prose around a
761 // path, which the CLI's tolerant resolution reads) runs as written.
762 if (action !== 'annotate' || !targets.every(looksLikeFilePath)) return null
763 call.target = targets
764 } else if (targets.length === 1) call.target = targets[0]
765 if (Object.keys(options).length > 0) call.options = options
766 const parsed = parsePlannotatorToolInput(call)
767 return parsed.ok ? parsed.input : null
768}
769hooks/mod/bridge.ts 334 lines1/**
2 * "Ask this session" for Claude Code: the host half of the pull bridge
3 * (protocol: packages/ai/session-bridge-pull.ts; the OpenCode plugin's client
4 * is packages/ai/session-bridge-pull-client.ts, which this mirrors over
5 * `$.http.fetch` because a mod has no Node `fetch`, timers or AbortSignal).
6 *
7 * The mod generates a token per launched server and hands it to the detached
8 * CLI in `PLANNOTATOR_SESSION_BRIDGE_TOKEN` (the CLI takes it out of its env at
9 * startup). Once the server is listening, this loop long-polls
10 * `POST /api/ai/bridge/poll` and posts progress to `/api/ai/bridge/event`, both
11 * to the loopback port with the bearer token and no Origin header.
12 *
13 * A question runs as a real turn: `$.prompt.submit` puts it in the session
14 * (it waits for idle), the turn's streamed text goes back as deltas, and
15 * `turn.complete`'s answer as `done`. Busy = Claude is mid-turn: reported as
16 * `busy`, so the reviewer chooses wait or interrupt; an interrupt aborts the
17 * running turn with `$.turn.abort`, except a question's turn the person typed
18 * into (turns.ts, take-over), which is theirs. Plan review does not block the session
19 * under the mod, so the status is never `blocked`.
20 */
21
22import type { Host } from './host'
23import type { AskSink, TurnTracker } from './turns'
24
25export const BRIDGE_POLL_PATH = '/api/ai/bridge/poll'
26export const BRIDGE_EVENT_PATH = '/api/ai/bridge/event'
27export const BRIDGE_HOST = 'claude-code'
28export const BRIDGE_MODES = 'turn'
29/** Long-poll wait we ask for; below the server's 25 s cap and any fetch timeout. */
30export const BRIDGE_POLL_WAIT_MS = 15_000
31/**
32 * How long to stay away after the server answered `superseded` (another
33 * client, e.g. a second Claude Code process on the same session, polled
34 * after us): 10 s plus up to 5 s of jitter. Polling again at once made the
35 * two clients supersede each other in a busy loop.
36 */
37export const BRIDGE_SUPERSEDED_WAIT_MS = { min: 10_000, jitter: 5_000 } as const
38/** Statuses that mean the server will never take this client: stop for good. */
39export const BRIDGE_REFUSED_STATUSES: readonly number[] = [401, 403, 404, 405, 503]
40/**
41 * Why "Interrupt and ask now" refuses a turn another message took over. Same
42 * text as `SESSION_ASK_TAKEN_OVER_INTERRUPT_TEXT` (packages/ai/session-bridge.ts).
43 */
44export const TAKEN_OVER_INTERRUPT_TEXT =
45 'The session is now answering another message, so Plannotator will not stop it. Ask when it finishes instead.'
46
47/** Used when a `taken_over` comes without a message. */
48const TAKEN_OVER_FALLBACK_NOTE =
49 'Another message entered this session while it was answering, so the rest of the reply went to that message.'
50
51/**
52 * A server that does not advertise `taken_over` (poll `features`) reads it as
53 * `failed`, and its UI then replaces the partial answer with the error. Settle
54 * as an answer instead: what streamed, plus the note as its last paragraph.
55 * Mirrors `takenOverFallback` in packages/ai/session-bridge-pull-client.ts.
56 */
57export function takenOverFallback(streamed: string, message: string | undefined): { delta: string; answer: string } {
58 const note = `_${(message || TAKEN_OVER_FALLBACK_NOTE).trim()}_`
59 const delta = streamed ? `\n\n${note}` : note
60 return { delta, answer: `${streamed}${delta}` }
61}
62
63type BridgeCommand =
64 | { type: 'ask'; askId: string; text: string; mode: string }
65 | { type: 'cancel'; askId: string }
66 | { type: 'interrupt'; interruptId: string }
67
68type BridgeEvent =
69 | { type: 'status'; status: 'ready' | 'busy' | 'blocked' | 'gone' }
70 | { type: 'started'; askId: string }
71 | { type: 'delta'; askId: string; text: string }
72 | { type: 'tool'; askId: string; name: string }
73 | { type: 'done'; askId: string; answer: string }
74 | { type: 'error'; askId: string; code: string; message?: string }
75 | { type: 'interrupted'; interruptId: string; ok: boolean; message?: string }
76
77export interface BridgeOptions {
78 host: Host
79 /** e.g. `http://127.0.0.1:4321`; always the loopback literal. */
80 baseUrl: string
81 token: string
82 turns: TurnTracker
83 /** False once the review settled or the session ended: the loop stops. */
84 isLive: () => boolean
85 maxFailures?: number
86}
87
88export function bridgeBaseUrl(port: number): string {
89 return `http://127.0.0.1:${port}`
90}
91
92export function parseBridgeCommands(text: string): { commands: BridgeCommand[]; closing: boolean; superseded: boolean; features: string[] } {
93 try {
94 const body = JSON.parse(text) as { commands?: unknown; closing?: unknown; superseded?: unknown; features?: unknown }
95 const commands = Array.isArray(body.commands)
96 ? body.commands.filter((command): command is BridgeCommand =>
97 !!command && typeof command === 'object' && typeof (command as { type?: unknown }).type === 'string')
98 : []
99 const features = Array.isArray(body.features) ? body.features.filter((feature): feature is string => typeof feature === 'string') : []
100 return { commands, closing: body.closing === true, superseded: body.superseded === true, features }
101 } catch {
102 return { commands: [], closing: false, superseded: false, features: [] }
103 }
104}
105
106/**
107 * Why a bridge loop ended, so the controller knows whether to start another:
108 * - `failures`: the server stopped answering (`maxFailures` in a row; each
109 * `$.http.fetch` gives up after 30 s, so a sleeping laptop or a paused
110 * server gets here in about three minutes). Worth a new loop later.
111 * - `refused`: 401/403 (token, not loopback), 404/405 (a CLI without the
112 * bridge), 503 (AI off). Never retried.
113 * - `closing`: the server is shutting down. Not retried.
114 * - `ended`: the launch is no longer live for this client (settled, closed,
115 * the session ended, or another Claude Code process watches it now).
116 */
117export interface BridgeEnd {
118 reason: 'failures' | 'refused' | 'closing' | 'ended'
119 status?: number
120 /** At least one poll was answered with 200 during this loop. */
121 connected: boolean
122}
123
124export interface BridgeHandle {
125 /** Runs until the server closes, refuses, stops answering, or the review is no longer live. Never throws. */
126 run(): Promise<BridgeEnd>
127 /** Push a busy/ready change now (from `turn.start` / `turn.complete`), not at the next poll. */
128 pushStatus(): void
129}
130
131export function createBridge(options: BridgeOptions): BridgeHandle {
132 const { host, turns, token } = options
133 const base = options.baseUrl.replace(/\/+$/, '')
134 const headers = { 'content-type': 'application/json', authorization: `Bearer ${token}` }
135 const maxFailures = options.maxFailures ?? 6
136 const seenAsks = new Set<string>()
137 const seenInterrupts = new Set<string>()
138 let outbox: BridgeEvent[] = []
139 let sending: Promise<void> = Promise.resolve()
140 let lastStatus: 'ready' | 'busy' = turns.busy ? 'busy' : 'ready'
141 /** The server knows the `taken_over` code (poll `features`). */
142 let serverTakesTakenOver = false
143
144 const post = async (events: BridgeEvent[]): Promise<void> => {
145 if (events.length === 0) return
146 try {
147 const response = await host.fetch(`${base}${BRIDGE_EVENT_PATH}`, {
148 method: 'POST',
149 headers,
150 body: JSON.stringify(events.length === 1 ? events[0] : { events }),
151 })
152 if (response.status === 409) {
153 // The server no longer runs a question we are answering: stop ours.
154 for (const event of events) {
155 if ('askId' in event) void stopAsk(event.askId)
156 }
157 }
158 } catch {
159 // Best effort; the server re-sends what it needs.
160 }
161 }
162
163 const flush = (): Promise<void> => {
164 const batch = outbox
165 outbox = []
166 sending = sending.then(() => post(batch))
167 return sending
168 }
169
170 const emit = (event: BridgeEvent, immediate = true) => {
171 const last = outbox[outbox.length - 1]
172 if (event.type === 'delta' && last?.type === 'delta' && last.askId === event.askId) {
173 last.text += event.text
174 } else {
175 outbox.push(event.type === 'delta' ? { ...event } : event)
176 }
177 if (immediate) void flush()
178 }
179
180 const stopAsk = async (askId: string) => {
181 const turnId = turns.cancelAsk(askId)
182 if (turnId) await host.abortTurn(turnId).catch(() => undefined)
183 }
184
185 const runAsk = (command: Extract<BridgeCommand, { type: 'ask' }>) => {
186 if (seenAsks.has(command.askId)) return
187 seenAsks.add(command.askId)
188 const askId = command.askId
189 let streamed = ''
190 const sink: AskSink = {
191 delta: (text) => {
192 streamed += text
193 emit({ type: 'delta', askId, text }, false)
194 },
195 tool: (name) => emit({ type: 'tool', askId, name }),
196 done: (answer) => emit({ type: 'done', askId, answer }),
197 error: (code, message) => {
198 if (code === 'taken_over' && !serverTakesTakenOver) {
199 const fallback = takenOverFallback(streamed, message)
200 emit({ type: 'delta', askId, text: fallback.delta }, false)
201 emit({ type: 'done', askId, answer: fallback.answer })
202 return
203 }
204 emit({ type: 'error', askId, code, ...(message ? { message } : {}) })
205 },
206 }
207 if (!turns.beginAsk(askId, command.text, sink)) {
208 emit({ type: 'error', askId, code: 'busy', message: 'Another question is already running in this session.' })
209 return
210 }
211 emit({ type: 'started', askId })
212 host.submit(command.text).catch((error: unknown) => {
213 turns.failAsk(askId, error instanceof Error ? error.message : String(error))
214 })
215 }
216
217 const runInterrupt = async (interruptId: string) => {
218 if (seenInterrupts.has(interruptId)) return
219 seenInterrupts.add(interruptId)
220 const running = turns.runningTurnId
221 if (!running) {
222 emit({ type: 'interrupted', interruptId, ok: true })
223 return
224 }
225 // A question's turn that the person typed into is theirs now: never stopped from Plannotator.
226 if (turns.isTakenOver(running)) {
227 emit({ type: 'interrupted', interruptId, ok: false, message: TAKEN_OVER_INTERRUPT_TEXT })
228 return
229 }
230 try {
231 await host.abortTurn(running)
232 emit({ type: 'interrupted', interruptId, ok: true })
233 } catch (error) {
234 emit({ type: 'interrupted', interruptId, ok: false, message: error instanceof Error ? error.message : String(error) })
235 }
236 }
237
238 const handle = (command: BridgeCommand) => {
239 switch (command.type) {
240 case 'ask':
241 runAsk(command)
242 break
243 case 'cancel':
244 if (turns.isActiveAsk(command.askId)) void stopAsk(command.askId)
245 else emit({ type: 'error', askId: command.askId, code: 'aborted' })
246 break
247 case 'interrupt':
248 void runInterrupt(command.interruptId)
249 break
250 }
251 }
252
253 const pushStatus = () => {
254 const status: 'ready' | 'busy' = turns.busy ? 'busy' : 'ready'
255 if (status === lastStatus) return
256 lastStatus = status
257 emit({ type: 'status', status })
258 }
259
260 const supersededWait = () => {
261 const jitter = Number.parseInt(host.randomHex(1), 16) / 255
262 return BRIDGE_SUPERSEDED_WAIT_MS.min + Math.round(jitter * BRIDGE_SUPERSEDED_WAIT_MS.jitter)
263 }
264
265 const loop = async (): Promise<BridgeEnd> => {
266 let failures = 0
267 let connected = false
268 while (options.isLive()) {
269 // Deltas are batched per poll round.
270 if (outbox.length > 0) await flush()
271 const status: 'ready' | 'busy' = turns.busy ? 'busy' : 'ready'
272 lastStatus = status
273 let response
274 try {
275 response = await host.fetch(`${base}${BRIDGE_POLL_PATH}`, {
276 method: 'POST',
277 headers,
278 body: JSON.stringify({ status, modes: { turn: true, transient: false }, waitMs: pollWaitFor(turns) }),
279 })
280 } catch {
281 failures += 1
282 if (failures >= maxFailures) return { reason: 'failures', connected }
283 await host.sleep(Math.min(4_000, 500 * 2 ** (failures - 1)))
284 continue
285 }
286 if (BRIDGE_REFUSED_STATUSES.includes(response.status)) return { reason: 'refused', status: response.status, connected }
287 if (!response.ok) {
288 failures += 1
289 if (failures >= maxFailures) return { reason: 'failures', status: response.status, connected }
290 await host.sleep(Math.min(4_000, 500 * 2 ** (failures - 1)))
291 continue
292 }
293 failures = 0
294 connected = true
295 const { commands, closing, superseded, features } = parseBridgeCommands(response.text)
296 serverTakesTakenOver = features.includes('taken_over')
297 for (const command of commands) {
298 host.debug(`bridge ${base}: ${command.type}`)
299 handle(command)
300 }
301 if (closing) return { reason: 'closing', connected }
302 if (superseded) {
303 // Someone else polls this server now. Stay away a while instead of
304 // taking it back at once, which only starts a ping-pong.
305 const wait = supersededWait()
306 host.debug(`bridge ${base}: superseded, polling again in ${wait} ms`)
307 await host.sleep(wait)
308 }
309 }
310 return { reason: 'ended', connected }
311 }
312
313 const run = async (): Promise<BridgeEnd> => {
314 try {
315 return await loop()
316 } catch {
317 return { reason: 'failures', connected: false }
318 } finally {
319 await flush()
320 }
321 }
322
323 return { run, pushStatus }
324}
325
326/**
327 * While our question streams, poll briefly so deltas and status go out
328 * promptly (a mod has no timer that can interrupt a pending fetch); otherwise
329 * wait long.
330 */
331function pollWaitFor(turns: TurnTracker): number {
332 return turns.askInFlight ? 750 : BRIDGE_POLL_WAIT_MS
333}
334hooks/mod/delivery.ts 263 lines1import { plannotatorDecisionHeading, type PlannotatorTarget } from './tool'
2
3/**
4 * What to do with a decision the CLI published (the host result record,
5 * `apps/hook/server/host-result.ts`): submit it to Claude as a plugin turn, or
6 * only log a line because there is nothing for Claude to act on.
7 *
8 * Pure: the controller does the I/O.
9 */
10
11export type SessionKind = 'plan' | 'review' | 'annotate' | 'last'
12
13/** The CLI's host result record, version 1 (fields only ever added). */
14export interface HostResultRecord {
15 v: number
16 surface: 'plan' | 'review' | 'annotate' | 'annotate-last'
17 decision: 'approved' | 'annotated' | 'dismissed' | 'denied' | 'answered'
18 message: string
19 noop: boolean
20 annotationCount?: number
21 platform?: boolean
22 withNotes?: boolean
23 approvedPlan?: string
24 permissionMode?: string
25 /** A dismissal the host asked for (the tool's `close`), not the reviewer's. */
26 closedBy?: 'agent'
27 unsentAnnotations?: number
28 /**
29 * What the decision is about, in full, as the server that took it resolved
30 * it (absolute path, URL, a bundle's files, reviewed directory or PR URL).
31 * Absent from a CLI older than the field and for annotate-last.
32 */
33 target?: string | string[]
34}
35
36/** A well-formed `target`, or undefined (a malformed one is dropped, never trusted). */
37function targetOf(value: unknown): string | string[] | undefined {
38 if (typeof value === 'string') return value.trim() ? value : undefined
39 if (Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string' && item.trim() !== '')) {
40 return value as string[]
41 }
42 return undefined
43}
44
45/** Feedback longer than this goes to a file Claude reads, never truncated. */
46export const INLINE_LIMIT_BYTES = 12 * 1024
47
48/** Added to a plan approval: the approval is completed by Claude's next ExitPlanMode. */
49export const PLAN_APPROVAL_NEXT_STEP =
50 'Call ExitPlanMode once more with the approved plan, without editing the plan file first; it will be allowed.'
51
52export function parseHostResult(text: string): HostResultRecord | null {
53 let value: unknown
54 try {
55 value = JSON.parse(text)
56 } catch {
57 return null
58 }
59 if (!value || typeof value !== 'object') return null
60 const record = value as Record<string, unknown>
61 const surfaces = ['plan', 'review', 'annotate', 'annotate-last']
62 const decisions = ['approved', 'annotated', 'dismissed', 'denied', 'answered']
63 if (typeof record.v !== 'number' || record.v < 1) return null
64 if (typeof record.surface !== 'string' || !surfaces.includes(record.surface)) return null
65 if (typeof record.decision !== 'string' || !decisions.includes(record.decision)) return null
66 if (typeof record.message !== 'string' || typeof record.noop !== 'boolean') return null
67 const { target: rawTarget, ...rest } = record
68 const target = targetOf(rawTarget)
69 return { ...(rest as unknown as HostResultRecord), ...(target !== undefined ? { target } : {}) }
70}
71
72function plural(count: number, one: string, many: string): string {
73 return `${count} ${count === 1 ? one : many}`
74}
75
76/** The outcome as the plugin turn's first line names it. */
77export function outcomeOf(record: HostResultRecord): string {
78 const count = record.annotationCount
79 const comments = typeof count === 'number' && count > 0 ? ` · ${plural(count, 'comment', 'comments')}` : ''
80 switch (record.decision) {
81 case 'approved':
82 if (record.surface === 'plan') return record.withNotes ? 'Approved with notes' : 'Approved'
83 return record.noop ? 'Approved' : `Approved with notes${comments}`
84 case 'answered':
85 return 'Questions answered'
86 case 'denied':
87 return 'Changes requested'
88 case 'dismissed':
89 return 'Closed. No decision.'
90 case 'annotated':
91 return record.surface === 'review' ? `Changes requested${comments}` : `Feedback${comments}`
92 }
93}
94
95export type Delivery =
96 | { action: 'submit'; text: string; overflow?: { path: string; text: string } }
97 | { action: 'log'; text: string; suggest?: string }
98
99export interface DeliveryContext {
100 subject: string
101 /** The launch's `pn-` id, named in the turn's first line. */
102 sessionId?: string
103 /** Where the full text is written when it is over the inline limit. */
104 overflowPath: string
105 inlineLimitBytes?: number
106 /** Deliver an approval even when it carries nothing (a gate the `plannotator` tool opened). */
107 deliverApproval?: boolean
108 /**
109 * The launch's own idea of the target (from the ready file, else the mod's
110 * resolution of the words): named when the record carries none (an older CLI).
111 */
112 target?: PlannotatorTarget
113}
114
115function byteLength(text: string): number {
116 return new TextEncoder().encode(text).length
117}
118
119/**
120 * The status lines the review editor posts after a review goes to the PR
121 * platform (`statusMessage` in packages/review-editor/App.tsx; delivery.test.ts
122 * builds them the same way): "Pull request|Merge request approved|reviewed on
123 * <platform>…" and "Changes requested on <platform>…".
124 */
125const PLATFORM_STATUS_LINE = /^(?:(?:Pull request|Merge request) (?:approved|reviewed) on |Changes requested on )/
126
127export function isPlatformStatusLine(message: string): boolean {
128 return PLATFORM_STATUS_LINE.test(message.trim())
129}
130
131/**
132 * OLD-CLI GUARD (0.28.0 to 0.28.3). Those CLIs read any review with zero code
133 * annotations as the PR-platform status post and wrote `platform: true,
134 * noop: true`, so feedback made only of PR description, PR comment or editor
135 * comments (which ride only in the feedback text) reached Claude as nothing.
136 * The mod updates from main independently of the binary, so it still meets
137 * those records. A `platform` record whose message is not one of the editor's
138 * status lines is that misread feedback: deliver it. A CLI with the fix sets
139 * `platform` only on the real status post, which always matches, so it never
140 * takes this path.
141 */
142function misreadAsPlatformPost(record: HostResultRecord): boolean {
143 return record.surface === 'review' && record.platform === true && record.message.trim() !== '' && !isPlatformStatusLine(record.message)
144}
145
146/**
147 * Decide the delivery. Done / LGTM / Close never start a turn (a log line
148 * instead); a review posted straight to a PR platform logs and suggests the
149 * follow-up; everything else is submitted, prefixed with one line naming the
150 * subject and outcome, and moved to a file Claude reads when it is too long.
151 */
152export function deliveryFor(record: HostResultRecord, context: DeliveryContext): Delivery {
153 const { subject } = context
154
155 // Claude closed it itself: nothing for Claude, whatever the record says.
156 if (record.closedBy === 'agent') {
157 const unsent = record.unsentAnnotations
158 const saved = typeof unsent === 'number' && unsent > 0 ? ` ${unsent} unsent ${unsent === 1 ? 'comment' : 'comments'} kept in the draft.` : ''
159 return { action: 'log', text: `Claude closed ${subject}.${saved} Nothing was sent to Claude.` }
160 }
161
162 const platformPost = record.surface === 'review' && record.platform === true && isPlatformStatusLine(record.message)
163 if (platformPost) {
164 const posted = record.message.trim() || 'review posted'
165 return {
166 action: 'log',
167 text: `${posted[0]?.toUpperCase() ?? ''}${posted.slice(1)}. Nothing was sent to Claude.`,
168 suggest: `address the review comments on ${subject}`,
169 }
170 }
171
172 // A gated session Claude opened itself (the `plannotator` tool): Claude was
173 // told to wait for the sign-off, so a bare approval still starts a turn.
174 const approvalAwaited = context.deliverApproval === true && record.decision === 'approved'
175 if (record.noop && !approvalAwaited && !misreadAsPlatformPost(record)) {
176 const what = record.decision === 'approved' ? 'approved with no notes' : 'closed with no annotations'
177 return { action: 'log', text: `${subject} ${what}. Nothing was sent to Claude.` }
178 }
179
180 // The record's target is the submitting server's own: it wins over the launch's.
181 const prefix = plannotatorDecisionHeading(subject, context.sessionId, outcomeOf(record), record.target ?? context.target)
182 const nextStep = record.surface === 'plan' && record.decision === 'approved' ? `\n\n${PLAN_APPROVAL_NEXT_STEP}` : ''
183 const body = record.message.trim()
184 const inline = body ? `${prefix}\n\n${body}${nextStep}` : `${prefix}${nextStep}`
185 const limit = context.inlineLimitBytes ?? INLINE_LIMIT_BYTES
186
187 if (byteLength(body) <= limit) return { action: 'submit', text: inline }
188
189 const kb = Math.ceil(byteLength(body) / 1024)
190 const counts = typeof record.annotationCount === 'number' && record.annotationCount > 0
191 ? `, ${plural(record.annotationCount, 'annotation', 'annotations')}`
192 : ''
193 return {
194 action: 'submit',
195 text: `${prefix}\n\nThe full feedback (${kb} KB${counts}) is too long to include here. Read all of it with the Read tool before you continue: ${context.overflowPath}${nextStep}`,
196 overflow: { path: context.overflowPath, text: `${body}\n` },
197 }
198}
199
200/**
201 * The CLI's default review approval prompts (`DEFAULT_REVIEW_APPROVED_PROMPT`
202 * and the first line of `DEFAULT_REVIEW_APPROVED_WITH_NOTES_PROMPT` in
203 * packages/shared/prompts.ts; delivery.test.ts keeps them equal). An older
204 * CLI prints them on stdout for an approval, and only stdout says what the
205 * decision was.
206 */
207export const LEGACY_REVIEW_APPROVED_TEXT = '# Code Review\n\nCode review completed — no changes requested.'
208export const LEGACY_REVIEW_APPROVED_WITH_NOTES_HEADING = '# Code Review — Approved with Notes'
209
210/**
211 * The first line of the CLI's default annotate approved-with-notes prompt
212 * (`DEFAULT_ANNOTATE_APPROVED_WITH_NOTES_PROMPT`; delivery.test.ts keeps them
213 * equal), which plaintext `--gate` prints for an approval that carries a note.
214 * A launch whose result file is missing is then still delivered as "Approved
215 * with notes" rather than as feedback. A customized prompt cannot be told
216 * apart and arrives as feedback, as for review.
217 */
218export const LEGACY_ANNOTATE_APPROVED_WITH_NOTES_HEADING = '# Approved with Notes'
219
220/**
221 * What the editor posts, and an annotate CLI prints, for a Done with nothing
222 * to send (`ANNOTATE_NO_FEEDBACK_SENTENCE` in packages/editor/annotateSubmission.ts
223 * and the multi-message variant in packages/ui/utils/parser.ts; delivery.test.ts
224 * keeps them equal). A newer CLI marks that decision `noop` in its result
225 * record; an older one only prints the sentence.
226 */
227export const LEGACY_ANNOTATE_NO_FEEDBACK_TEXTS: readonly string[] = [
228 'User reviewed the document and has no feedback.',
229 'User reviewed the messages and has no feedback.',
230]
231
232/**
233 * A CLI that predates the host result file (the plugin and the binary update
234 * separately): what it printed on stdout, the text the skill would have shown
235 * Claude. Empty output, or the legacy close/approve lines, carry nothing; a
236 * review approval (the default approved prompt) is an LGTM, as the newer CLI
237 * reports it, rather than "Changes requested". A user-customized approved
238 * prompt cannot be told apart from feedback and is delivered as feedback.
239 * Stdout does not say whether a review was the PR-platform status post, so
240 * that line also arrives as feedback; nothing here infers it from the text.
241 */
242export function legacyResult(kind: SessionKind, printed: string): HostResultRecord {
243 const surface = kind === 'review' ? 'review' : kind === 'last' ? 'annotate-last' : 'annotate'
244 const text = printed.trim()
245 if (!text || text === 'Review session closed without feedback.') {
246 return { v: 1, surface, decision: 'dismissed', message: '', noop: true }
247 }
248 if (text === 'The user approved.') return { v: 1, surface, decision: 'approved', message: '', noop: true }
249 if (surface !== 'review' && LEGACY_ANNOTATE_NO_FEEDBACK_TEXTS.includes(text)) {
250 return { v: 1, surface, decision: 'annotated', message: '', noop: true }
251 }
252 if (surface === 'review' && text === LEGACY_REVIEW_APPROVED_TEXT) {
253 return { v: 1, surface, decision: 'approved', message: '', noop: true }
254 }
255 if (surface === 'review' && text.startsWith(LEGACY_REVIEW_APPROVED_WITH_NOTES_HEADING)) {
256 return { v: 1, surface, decision: 'approved', message: text, noop: false, withNotes: true }
257 }
258 if (surface !== 'review' && text.startsWith(`${LEGACY_ANNOTATE_APPROVED_WITH_NOTES_HEADING}\n`)) {
259 return { v: 1, surface, decision: 'approved', message: text, noop: false, withNotes: true }
260 }
261 return { v: 1, surface, decision: 'annotated', message: text, noop: false }
262}
263