SLOPSHOPPER

plannotator

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

newguardcommandtoaststatusprompt
★ 9,259v0.28.8Apache-2.0updated 2026-10-09backnotprop/plannotator/apps/hook
A shopper browsing a rack in a slop shop
README

Plannotator Claude Code Plugin

This directory contains the Claude Code plugin configuration for Plannotator.

Prerequisites

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


Plugin Installation

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.

Updating the plugin

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.

Manual Installation (Hooks)

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
          }
        ]
      }
    ]
  }
}

How It Works

The Plannotator mod (Claude Code 2.1.287+)

In the interactive terminal on Claude Code 2.1.287 or newer, the plugin runs the Plannotator mod. It is on by default:

  • Plan review, /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.
  • A revised plan updates the same tab. After you approve, Claude calls ExitPlanMode once more and works from the exact plan text you approved.
  • Claude can open Plannotator itself with its plannotator tool.
  • Ask AI in the review is answered by this Claude session ("Ask this session").

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.

The classic hook

When Claude Code calls ExitPlanMode, this hook intercepts and waits for your decision (Claude waits too):

  1. Opens Plannotator UI in your browser
  2. Lets you annotate the plan visually
  3. Approve → Claude proceeds with implementation
  4. Request changes → Your annotations are sent back to Claude
  5. On resubmission → Plan Diff shows what changed since the last version

Environment Variables

VariableDescription
PLANNOTATOR_REMOTESet 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_PORTFixed port to use. Default: random locally, 19432 for remote sessions.
PLANNOTATOR_BROWSERCustom browser to open plans in. macOS: app name or path. Linux/Windows: executable path.
PLANNOTATOR_SHARE_URLCustom share portal URL for self-hosting. Default: https://share.plannotator.ai.
PLANNOTATOR_CLAUDE_MODThe 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_DEBUGSet to 1 before starting Claude Code to write a mod debug log to ~/.plannotator/claude-code-mod/debug.log.

Remote / Devcontainer Usage

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:

  • Use a fixed port instead of a random one (so you can set up port forwarding)
  • Use remote-friendly port/browser handling for forwarded environments
  • Print the URL to the terminal for you to access

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

Slash Commands

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:

CommandDescription
`/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-lastAnnotate the agent's last message

Obsidian Integration

Approved plans can be automatically saved to your Obsidian vault.

Setup:

  1. Open Settings (gear icon) in Plannotator
  2. Enable "Obsidian Integration"
  3. Select your vault from the dropdown (auto-detected) or enter the path manually
  4. Set folder name (default: plannotator)

What gets saved:

  • Plans saved with human-readable filenames: Title - Jan 2, 2026 2-30pm.md
  • YAML frontmatter with created, source, and tags
  • Tags extracted automatically from the plan title and code languages
  • Backlink to [[Plannotator Plans]] for graph connectivity

Example 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" />

Source 14 files
hooks/mod/register.ts 457 lines
1/**
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}
457
hooks/mod/controller.ts 1622 lines
1/**
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  }
1200
hooks/mod/enabled.ts 128 lines
1/**
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}
128
hooks/mod/host.ts 68 lines
1/**
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}
68
hooks/mod/inbox.ts 571 lines
1/**
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}
571
hooks/mod/inbox-contract.ts 342 lines
1/**
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
342
hooks/mod/launch.ts 627 lines
1/**
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}
627
hooks/mod/plan.ts 121 lines
1/**
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}
121
hooks/mod/take-over.ts 44 lines
1/**
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}
44
hooks/mod/tool.ts 769 lines
1/**
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}
769
hooks/mod/bridge.ts 334 lines
1/**
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}
334
hooks/mod/delivery.ts 263 lines
1import { 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