Project Architect 3.2 watcher: sees every agent's steps and tool calls, serves the PA tools as inbox requests to the pa router, and writes the live feed's…

<img src="project-architect-3.0/docs/images/banner.svg" alt="Project Architect 3.0: a team with a plan, inside Claude Code" width="860">
<img alt="version 3.30.7" src="https://img.shields.io/badge/version-3.30.7-3b82f6"> <img alt="for Claude Code" src="https://img.shields.io/badge/for-Claude%20Code-d97757"> <img alt="Windows, WSL, Linux, macOS" src="https://img.shields.io/badge/runs%20on-Windows%20%7C%20WSL%20%7C%20Linux%20%7C%20macOS-64748b">
Project Architect turns Claude Code into a small team that works from a plan. It is a project management system and a token-saving system in one. As a project manager, it keeps the plan: phases with a milestone a machine can check, your approval before any work starts, and a memory in your repository that every new session starts from. As a token saver, it runs each task in a small, fresh context, and helpers do the long reading and the build-and-test loops, so your session never carries them; a local ledger measures what that saves, and the statusline shows it. What PA3 is and does explains every part in plain words.
<img src="project-architect-3.0/docs/images/statusline.png" alt="The PA3 statusline during a phase" width="860">
White is what you used; blue is what the helpers saved. Here this session used 1 % of the five-hour window and its helpers saved another 2 %, and over the project's life its work has gone 2.6 times as far as it would have without them. Every number, line by line.
You need Claude Code 2.1.292 or newer, a Claude subscription (Max 20x, Max 5x or Pro), Python 3.12 or newer, and git.
project-architect-3.0 folder. On Windows, double-click install.cmd; on WSL, Linux or macOS, run ./install.sh. It asks nothing, and it also installs the mod, the Claude Code plugin that runs a plan's tasks./exit, then claude). Or double-click setup-project.cmd and pick the folder. It makes one commit and never pushes.claude in your repository and talk with Claude, who works out where the project stands and takes it from there. The first session shows a line pointing to the Claude app's Settings → Billing page and the command that stores the day your plan renews, so the month figures count from it.Step by step, updating and removing: Install. Every switch: INSTALL.md.
flowchart LR
you((You)) <--> claude["Claude"]
claude --> thinkers["thinkers"]
claude --> retrievers["retrievers"]
mod["the mod<br/>runs the loop"] --> expert["expert<br/>one per task"]
expert --> coder["coder"]
expert --> retrievers
You talk with one partner, Claude, in one session. Claude drafts each phase's plan with you; after you approve it, the mod runs one expert per task in a fresh context while you keep talking with Claude, and the expert briefs coders and retrievers. Every change an expert wants to make to the plan goes to Claude, who settles what stays inside the milestone and asks you about the rest. A task that hits a wall comes back blocked with its evidence and a recommendation. When the milestone is met, Claude asks you before the phase closes, and the close leaves a plain-English recap of what was done. The session can stay open for as long as you like: no model reads the main session, the mod tells Claude Code so, and it trims the main session to the last finished task. Claude, every expert and every coder runs in its own context, handed to a fresh one before it grows too large, so nothing grows without bound. One session that never fills up · What PA3 is and does · The roles · A day in the life
A long Claude Code session re-sends everything it has seen with every request: the files it read, the command output, its own reasoning. The context keeps growing, and every later request pays for all of it again. Project Architect changes the shape of the work:
| Vanilla | Project Architect | |
|---|---|---|
| Average context per request | 459k to 547k tokens | 47k to 69k tokens |
| Context when the work starts | 100k to 300k tokens | about 15k tokens |
| Cost per request, at API prices | $0.35 to $0.42 | $0.08 to $0.11 |
Vanilla: real projects worked in one long Claude Code session. Project Architect: its own first phases, measured the same way. The whole story, with the measurements.
The wiki has the rest: the statusline and the savings, the bench that measures the agents themselves (results in bench/results/), external coders, why PA3 stays serial, and the FAQ. What changed in each release: CHANGES.md.
See LICENSE.
hooks/register.ts 542 lines1// PA 3.2 watcher plugin `pa-watch` (phase 3.22): activation, the debug log (T5a); the tools as inbox requests,
2// `thinking` journal entries and the per-agent stream facts (T7); the activity lines and the usage line (T8b).
3// 3.23 T5c: approach notes become `note` journal entries (experts `T<n>`, coders); the facts file carries type, description,
4// parent, the latest note and the last three steps, for the router's running-now note; the sixth tool, `steer`.
5// 3.23 T8: activity lines by role (the `colors` userConfig, PA_MOD_COLORS over it): glyphs, ANSI colors or a render rule.
6// 3.23 T13d: each visible-text segment of an agent's step is a record in the live feed's folder (feed.ts). T13d.c3: the
7// feed replaces the activity lines: the watcher draws nothing in the transcript (no `$.ui.log`), and `colors` retired.
8// The router's own hooks never see the steps and tool calls of the agents it spawned (3.21 T4); this plugin does.
9// The engine allows `$` only as `$.noun.method(...)` at the call site, so helpers are pure (activation.ts, debug.ts)
10// and every `$` call is spelled inline in a hook (design.md, "Execution model").
11// 3.24 T6: an active activation writes `.run/mod/heartbeat.json`; an activation hook that throws writes
12// `.run/mod/error.json` (pa/health.py reads both; an inert session writes neither).
13// 3.24 T8b: a successful Edit, MultiEdit, Write or NotebookEdit by Claude, an expert or a coder is an `edit` feed record
14// (edit.ts): the router draws it in the native edit look as a note; no other tool call is recorded for display.
15// 3.24 T13n: an Opus 5.5 narration block (a `thinking` block Claude Code draws white, told by its signature) feeds like
16// text; the step's records are written at its end, in block order, once the api messages hold its signatures.
17// 3.24 T13C: the usage line (`$.ui.status`) and the prices and spend it alone used are gone; the memory curator and the
18// auditor feed like the experts (retrievers still never do); a plan draft's edit record is one labelled line (edit.ts).
19// 3.24 T17b: the ladder (ladder.ts): a spawn asks T17a's resolver for its type's row (kept per session in `ladder`) and
20// a managed agent is spawned on the row's model; its requests get the row's effort (Claude's never); the spawn's model
21// and effort go to its facts file at once (the router shows them on its first feed line), and its first request's
22// to a `spawn` journal entry. A resolver failure leaves the spawn as it is.
23// 3.25 T6b: a spawn description's trailing `[effort <level>]` hint picks the row (`ladder-row --effort`, kept as
24// `<type>@<level>`); that agent's requests keep its own row's effort; the hint is never shown (facts, feed, journal).
25import { atom, read, update } from 'claude-code'
26import type { EngineInterface, Register } from 'claude-code'
27
28import { decide, ERROR_FILE, errorText, HEARTBEAT_FILE, heartbeatText, ledgerPath, paSetting, releaseOf, versionOf } from './activation'
29import { eventFile } from './debug'
30import { EDIT_TOOLS, editHeader, editOf, editsShown } from './edit'
31import { feedDirOf, feedName, feedPieces, feedRecord, feeds, lastLine, narrationIndexes, startsLine, startText } from './feed'
32import type { StepBlock } from './feed'
33import { displayWho, entryPath, entryText, noteLines, noteWho, partnerIdOf, stamp, stepFactsText, whoOf } from './journal'
34import { hintOf, keepRow, ladderArgv, PLANNER, rowKey, rowOf, spawnFields, spawnModel, stepEffort, UNSET } from './ladder'
35import type { LadderRow } from './ladder'
36import { argLine, depthOf, HANDBACK, isPingStep, planningAfter, ctxOf } from './stream'
37import type { StepUsage } from './stream'
38import { argsOf, argSummary, queuedReply, requestBody, requestName, TOOL_NAMES, TOOL_SPECS, validateInput } from './tools'
39import { trimCut } from './trim'
40
41const activeAtom = atom({ plugin: 'pa-watch', key: 'active' } as const, false)
42const ladderAtom = atom({ plugin: 'pa-watch', key: 'ladder' } as const, {}) // 3.24 T17b: rows by agent type (3.25 T6b: `<type>@<level>` for a hint)
43
44// Module variables are lost on a hot reload; the next session.start or re-arm sets them again.
45let debugDir = '' // `<root>/.run/mod/<sid>/debug` under PA_MOD_DEBUG=1 in an active session, else '' (no debug log)
46let debugWanted = false // PA_MOD_DEBUG=1; 3.24 T8: an inert session writes nothing, so debugDir opens only when active
47let counter = 0
48let reqSeq = 0 // per-load sequence of inbox requests
49let journalSeq = 0 // per-load sequence of journal entries
50let feedSeq = 0 // per-load sequence of feed records (3.23 T13d)
51
52/** Stream facts per agent (T8b draws the stream and usage lines from them); in-memory, lost on reload. */
53type Facts = {
54 model: string; effort: string; usage: unknown; tool: string; args: string; at: number
55 type: string; description: string; parentId: string; note: string; steps: string[] // 3.23 T5c
56 line: string // 3.23 T13d.c2: the last line of the latest feed segment
57}
58const facts = new Map<string, Facts>()
59const factsOf = (id: string): Facts => facts.get(id) ??
60 { model: '', effort: '', usage: null, tool: '', args: '', at: 0, type: '', description: '', parentId: '', note: '', steps: [], line: '' }
61/** The facts file text of `f` written at `now` (the last step's `at` kept). */
62const factsText = (f: Facts, ctx: number, now: number): string =>
63 stepFactsText(f.model, f.effort, ctx, f.at, { type: f.type, description: f.description, parentId: f.parentId, note: f.note, steps: f.steps, updatedAt: now, line: f.line })
64
65/** One debug event file: [path, text] for `$.fs.write`. */
66function ev(kind: string, data: Record<string, unknown>): [string, string] {
67 const ms = Date.now()
68 counter += 1
69 return [eventFile(debugDir, ms, counter, `watch.${kind}`), JSON.stringify({ ms, ev: `watch.${kind}`, ...data }, null, 1)]
70}
71
72const quiet = (): undefined => undefined
73
74/** 3.28.1 T15: background shells (Bash `run_in_background`) per agent, `.run/mod/<sid>/shells.json` (pa loop.ts
75 * SHELLS_FILE, shellsOf): `{ <agentId>: [{ id, name, at, type, exit? }] }`, from the call to its completion notice. */
76type ShellRec = { id: string; name: string; at: number; type: string; exit?: number }
77let shellsChain: Promise<unknown> = Promise.resolve() // one read-modify-write at a time
78/** A shell's name: the last script-like token's basename (`bake_rest.sh`), else the last command's first word; 3.3 T7:
79 * a `pa_ext.py run` is `external <stem>`, its last argument's basename without the extension (`external T4.c1`). */
80const shellName =(command: string): string => {
81 const words = command.split(/\s+/).map(w => w.replace(/^["']|["';]$/g, '')).filter(w => w !== '')
82 const ext = words.findIndex((w, i) => /(^|[\/\\])pa_ext\.py$/.test(w) && words[i + 1] === 'run')
83 if (ext >= 0 && words.length > ext + 2) {
84 return `external ${(words[words.length - 1] ?? '').split(/[\/\\]/).pop()?.replace(/\.[^.]*$/, '') ?? ''}`
85 }
86 const script = [...words].reverse().find(w => /\.(sh|py|ps1|js|ts|cmd|bat)$/i.test(w))
87 const last = command.split(/&&|\|\||;/).pop()?.trim().split(/\s+/)[0] ?? ''
88 return (script ?? last).split(/[\/]/).pop() ?? ''
89}
90/** The completion notice's task id and outcome: removed (`exit` undefined) on a clean end, else its exit code (-1 unknown). */
91const shellEnd = (text: string): { id: string; exit?: number } | null => {
92 const id = /<task-id>([^<]+)<\/task-id>/.exec(text)?.[1]?.trim() ?? ''
93 if (!text.includes('<task-notification>') || id === '') {
94 return null
95 }
96 const status = /<status>([^<]*)<\/status>/.exec(text)?.[1]?.trim() ?? ''
97 const code = /exit code:?\s*(-?\d+)/i.exec(text)?.[1]
98 const exit = code === undefined ? undefined : Number(code)
99 return status === 'completed' && (exit === undefined || exit === 0) ? { id } : { id, exit: exit ?? -1 }
100}
101const shellsEdit = async ($: Pick<EngineInterface, 'fs' | 'session'>, f: (s: Record<string, ShellRec[]>) => Record<string, ShellRec[]> | null): Promise<void> => {
102 const run = shellsChain.then(async () => {
103 const path = `${await $.session.root()}/.run/mod/${await $.session.id()}/shells.json`
104 let cur: Record<string, ShellRec[]> = {}
105 try {
106 const v = JSON.parse((await $.fs.read(path).catch(() => null)) ?? '{}') as unknown
107 cur = typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, ShellRec[]>) : {}
108 } catch {
109 cur = {}
110 }
111 const next = f(cur)
112 if (next !== null) {
113 await $.fs.write(path, JSON.stringify(Object.fromEntries(Object.entries(next).filter(([, l]) => l.length > 0))))
114 }
115 })
116 shellsChain = run.catch(quiet)
117 await run.catch(quiet)
118}
119
120/** Each agent seen: who it is, how deep, its last context (the facts file's `ctx`). */
121type Who = { who: string; depth: number; ctx: number; noted?: string; planning?: boolean } // noted: the turn whose approach note was taken; planning: after a pa-planning Skill call, until `plan` (3.23 T8)
122const agents = new Map<string, Who>()
123
124/** 3.24 T17b: agents spawned in this load whose first request is not journalled yet: their type and row. */
125const spawned = new Map<string, { type: string; row: LadderRow | null }>()
126/** 3.25 T6b: a hinted agent's own spawn row (`<type>@<level>`): its requests get this row's effort, not its type's. */
127const ownRows = new Map<string, LadderRow>()
128
129/** 3.24 T6: the error record pa/health.py reads, for an activation hook that threw; only where the mod would be active
130 * (pa.json mod: on), so an inert session still writes nothing. Never throws. */
131async function errorRecord($: Pick<EngineInterface, 'fs' | 'session' | 'env' | 'clock'>, sid: string, hook: string, error: string): Promise<void> {
132 try {
133 const root = await $.session.root()
134 const paText = await $.fs.read(`${root}/.claude/pa.json`).catch(() => null)
135 if (decide(paText, await $.env.get('PA_MOD_OFF'), versionOf(await $.session.version())).active) {
136 await $.fs.write(`${root}/${ERROR_FILE}`, errorText(sid === '' ? await $.session.id() : sid, 'pa-watch', hook, error, await $.clock.now()))
137 }
138 } catch {
139 // best effort: the record must never fail the handler that writes it
140 }
141}
142
143export const register: Register = on => {
144 on('session.start', async ($, e, next) => {
145 const root = await $.session.root()
146 const sid = await $.session.id()
147 debugWanted = (await $.env.get('PA_MOD_DEBUG')) === '1'
148 const paText = await $.fs.read(`${root}/.claude/pa.json`).catch(() => null)
149 const version = versionOf(await $.session.version())
150 const decision = decide(paText, await $.env.get('PA_MOD_OFF'), version)
151 await update($, activeAtom, () => decision.active)
152 debugDir = debugWanted && decision.active ? `${root}/.run/mod/${sid}/debug` : ''
153 if (decision.active) {
154 // 3.24 T6: the heartbeat pa/health.py checks (the release from the plugin's root, two up).
155 const release = releaseOf(await $.fs.read(`${$.plugin.root}/../../VERSION`).catch(() => null))
156 await $.fs.write(`${root}/${HEARTBEAT_FILE}`, heartbeatText(sid, release, version, await $.clock.now(), 'pa-watch')).catch(quiet)
157 for (const spec of TOOL_SPECS) {
158 await $.tool.register(spec)
159 }
160 }
161 if (debugDir !== '') {
162 await $.fs.write(...ev('activation', { why: 'start', sid, version, decision })).catch(quiet)
163 }
164 return next(e)
165 }).catch(async ($, e, next) => {
166 if (next.error.kind === 'throw') {
167 await errorRecord($, '', 'session.start', next.error.message ?? '')
168 }
169 return next(e)
170 })
171
172 // A /clear or a resume resets $.state (or starts a new process): decide again under the session id it goes on with.
173 on('classic.SessionStart', async ($, e, next) => {
174 const source = e.source
175 if (source === 'resume' || source === 'clear') {
176 const root = await $.session.root()
177 const paText = await $.fs.read(`${root}/.claude/pa.json`).catch(() => null)
178 const version = versionOf(await $.session.version())
179 const decision = decide(paText, await $.env.get('PA_MOD_OFF'), version)
180 await update($, activeAtom, () => decision.active)
181 debugDir = debugWanted && decision.active ? `${root}/.run/mod/${e.session_id}/debug` : ''
182 if (decision.active) { // 3.24 T6: the heartbeat under the session id it goes on with
183 const release = releaseOf(await $.fs.read(`${$.plugin.root}/../../VERSION`).catch(() => null))
184 await $.fs.write(`${root}/${HEARTBEAT_FILE}`, heartbeatText(e.session_id, release, version, await $.clock.now(), 'pa-watch')).catch(quiet)
185 }
186 if (debugDir !== '') {
187 await $.fs.write(...ev('activation', { why: `classic.SessionStart:${source}`, sid: e.session_id, version, decision })).catch(quiet)
188 }
189 }
190 return next(e)
191 }).catch(async ($, e, next) => {
192 if (next.error.kind === 'throw') {
193 await errorRecord($, e.session_id, `classic.SessionStart:${e.source}`, next.error.message ?? '')
194 }
195 return next(e)
196 })
197
198 // 3.29.9: the router's display trim. The router's `$.session.compact({ instructions: 'pa-trim …' })` skips its own
199 // hooks, so this one answers it, no model: the main conversation's rows from the validated cut point on, the very
200 // objects handed in (their handles kept). Every other compaction goes on (the router refuses those, as before).
201 on('session.compact', async ($, e, next) => {
202 if (e.agentId !== undefined || e.trigger !== 'plugin') {
203 return next(e)
204 }
205 const k = trimCut(e.instructions, e.messages)
206 if (k === null) {
207 return next(e)
208 }
209 if (debugDir !== '') {
210 await $.fs.write(...ev('trim', { k, rows: e.messages.length })).catch(quiet)
211 }
212 return k < 0 ? { skip: 'pa trim: no cut point' } : { messages: e.messages.slice(k) }
213 })
214
215 // 3.24 T17b: every spawn asks the ladder for its type's row (once per session and tier; a planner spawn re-checks the
216 // tier), sets a managed agent's model, and writes the spawn's model and effort to its facts file. Never blocks or
217 // fails a spawn: no row leaves it as it is.
218 on('agent.spawn', async ($, e, next) => {
219 const type = e.subagentType
220 if (!(await read($, activeAtom).catch(() => false)) || e.fork || UNSET.includes(type)) {
221 return next(e)
222 }
223 let row: LadderRow | null = null
224 const { level, desc } = hintOf(e.description) // 3.25 T6b: an `[effort <level>]` hint picks the row
225 const key = rowKey(type, level)
226 try {
227 row = (await read($, ladderAtom))[key] ?? null
228 if (row === null || type === PLANNER) {
229 const root = await $.session.root()
230 const paText = await $.fs.read(`${root}/.claude/pa.json`).catch(() => null)
231 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
232 const argv = ladderArgv(paSetting(paText, 'python'), ledgerPath(await $.env.get('PA_MOD_PA3_DIR'), home), type, root, level)
233 const ran = argv === null ? null : await $.process.run(argv, { cwd: root, timeoutMs: 10_000 }).catch(() => null)
234 const got = ran !== null && ran.exitCode === 0 ? rowOf(ran.stdout, key) : null
235 if (got !== null) {
236 await update($, ladderAtom, rs => keepRow(rs, got))
237 row = got
238 }
239 if (debugDir !== '') {
240 await $.fs.write(...ev('ladder', { type: key, row: got, exitCode: ran?.exitCode ?? null })).catch(quiet)
241 }
242 }
243 } catch {
244 row = null
245 }
246 const model = spawnModel(row, e)
247 const r = await next(model === null ? e : { ...e, model })
248 const id = r.agentId
249 if (id !== undefined && r.deny === undefined) {
250 // 3.29 T2: a message to a finished agent (a warming ping) resumes it through this hook under its own id. Its
251 // facts file is already written, so it is a resume: no second spawn entry and no second start line.
252 const factsPath = `${await $.session.root()}/.run/mod/${await $.session.id()}/agents/${id}.json`
253 const resumed = await $.fs.read(factsPath).then(() => true, () => false)
254 if (!resumed) {
255 spawned.set(id, { type, row })
256 }
257 if (level !== '' && row !== null) {
258 ownRows.set(id, row) // 3.25 T6b: a hinted agent keeps its own row's effort on every request
259 }
260 const effort = row?.effort ?? ''
261 const f = { ...factsOf(id), model: r.model, effort, type, description: desc, parentId: e.parentAgentId ?? '' }
262 facts.set(id, f)
263 const write = async (): Promise<void> => {
264 await $.fs.write(`${await $.session.root()}/.run/mod/${await $.session.id()}/agents/${id}.json`, factsText(f, 0, Date.now()))
265 }
266 await write().catch(quiet)
267 }
268 return r
269 }).catch(async ($, e, next) => next(e)) // a failed hook leaves the spawn as it is
270
271 // Every agent's steps: chunks pass through unchanged; the feed records, the facts file, and a step that had thinking
272 // becomes a journal `thinking` entry. A `.` ping turn writes no record. The text before a SubagentHandback call is no note.
273 // 3.24 T17b: a managed agent's request gets its row's effort (`stream` is made once its type is known).
274 on('turn.step', async function* ($, e, next) {
275 const agentId = e.agentId
276 const active = await read($, activeAtom).catch(() => false)
277 let me: Who | undefined
278 if (active && agentId !== undefined) {
279 me = agents.get(agentId)
280 if (me === undefined) {
281 const list = await $.agent.list().catch(() => [])
282 const row = list.find(a => a.id === agentId)
283 const f0 = factsOf(agentId) // 3.24 T17b: the spawn's facts when the list has no row
284 let who = displayWho(row?.type ?? f0.type)
285 facts.set(agentId, { ...f0, type: row?.type ?? f0.type, description: row === undefined ? f0.description : hintOf(row.description).desc, parentId: row?.parentId ?? f0.parentId })
286 if (who === '') {
287 const root = await $.session.root()
288 const sid = await $.session.id()
289 const partnerId = partnerIdOf(await $.fs.read(`${root}/.run/mod/${sid}/state.json`).catch(() => null))
290 who = partnerId !== '' && partnerId === agentId ? 'Claude' : 'agent'
291 }
292 me = { who, depth: depthOf(agentId, list), ctx: 0 }
293 agents.set(agentId, me)
294 }
295 }
296 const type = agentId === undefined ? '' : factsOf(agentId).type
297 const own = agentId === undefined ? undefined : ownRows.get(agentId)
298 const want = me === undefined ? null : stepEffort(own ?? (await read($, ladderAtom).catch(() => ({})) as Record<string, LadderRow | undefined>)[type], type)
299 const stream = next(want === null ? e : { ...e, effort: want })
300 const effort = String(want ?? e.effort ?? '')
301 // 3.24 T17b: an agent spawned in this load: its first request's model and effort, as a `spawn` journal entry.
302 const first = agentId === undefined ? undefined : spawned.get(agentId)
303 if (agentId !== undefined && first !== undefined) {
304 spawned.delete(agentId)
305 const ms = Date.now()
306 const [date, time] = stamp(ms)
307 journalSeq += 1
308 const fields = spawnFields({ ts: new Date(ms).toISOString(), session: await $.session.id(), agentId, type: first.type, model: e.model, effort, row: first.row })
309 const rel = entryPath(date, time, journalSeq, 'w', whoOf(first.type, agentId, '', factsOf(agentId).description), 'spawn')
310 const body = `${first.type} ran on ${e.model} at ${effort === '' ? 'no effort' : effort}.`
311 await $.fs.write(`${await $.session.root()}/${rel}`, entryText(fields, body)).catch(quiet)
312 }
313 // 3.23 T13d: the feed. Text before each tool chunk but the hand-back, and after the last tool chunk of a step that
314 // made tool calls, is one record each (`kind` "text"); the final report and the text before the hand-back are not.
315 // 3.23 T13d.c7: statements only (the developer's rule: what is white in a subagent's view); the step summaries
316 // (`thinking` chunks) and tool calls never feed. c5's `kind` "summary" records are gone; `kind` stays "text".
317 // 3.24 T13n: but a narration block does (white in the view); all the step's records are written at its end, which
318 // comes before its tools run.
319 const feedOn = me !== undefined && agentId !== undefined && feeds(factsOf(agentId).type)
320 let feedDir = ''
321 const feedOut = async (raw: string): Promise<void> => {
322 const t = raw.trim()
323 if (!feedOn || t === '' || agentId === undefined || me === undefined) {
324 return
325 }
326 if (feedDir === '') {
327 const root = await $.session.root()
328 const sid = await $.session.id()
329 feedDir = feedDirOf(await $.fs.read(`${root}/.run/mod/${sid}/state.json`).catch(() => null), root, sid)
330 }
331 const f = { ...factsOf(agentId), line: lastLine(t) }
332 const at = Date.now()
333 feedSeq += 1
334 const body = feedRecord({ agent: agentId, type: f.type, desc: f.description, planning: me.planning === true, text: t, at, kind: 'text' })
335 await $.fs.write(`${feedDir}/in/${feedName(at, feedSeq)}`, body).catch(quiet)
336 // 3.23 T13d.c2: the facts' `line` (the band's step), written with the record.
337 facts.set(agentId, f)
338 await $.fs.write(`${await $.session.root()}/.run/mod/${await $.session.id()}/agents/${agentId}.json`, factsText(f, me.ctx, at)).catch(quiet)
339 }
340 // 3.24 T17g: a shown agent's start line at its first request, its facts carrying this request's model and effort, so
341 // its first titled run names them even if it never feeds (feed.ts startsLine).
342 if (first !== undefined && agentId !== undefined && startsLine(first.type)) {
343 facts.set(agentId, { ...factsOf(agentId), model: e.model, effort })
344 await feedOut(startText(factsOf(agentId).description))
345 }
346 const blocks: StepBlock[] = [] // the step's blocks in order, for the feed (3.24 T13n)
347 let sawTool = false
348 let thinking = ''
349 let text = '' // text since the last tool chunk: an approach note when a tool chunk follows
350 let approach = '' // the text before the turn's first tool call (T5c), '' when this step has none
351 for await (const chunk of stream) {
352 if (me !== undefined) {
353 const lastBlock = blocks[blocks.length - 1]
354 if ((chunk.kind === 'thinking' || chunk.kind === 'text') && lastBlock !== undefined && lastBlock.kind === chunk.kind && lastBlock.index === chunk.index) {
355 lastBlock.text += chunk.text
356 } else if (chunk.kind === 'thinking' || chunk.kind === 'text') {
357 blocks.push({ kind: chunk.kind, index: chunk.index, text: chunk.text })
358 } else if (chunk.kind === 'tool') {
359 blocks.push({ kind: 'tool', index: chunk.index, id: chunk.id, handback: chunk.name === HANDBACK })
360 }
361 if (chunk.kind === 'thinking') {
362 thinking += chunk.text
363 } else if (chunk.kind === 'text') {
364 text += chunk.text
365 } else if (chunk.kind === 'tool' && chunk.name !== HANDBACK) {
366 sawTool = true
367 if (me.noted !== e.turnId) {
368 me.noted = e.turnId
369 approach = text.trim()
370 }
371 text = ''
372 } else if (chunk.kind === 'tool') {
373 sawTool = true // the text before the hand-back is no feed line (feedPieces)
374 }
375 }
376 yield chunk
377 }
378 const result = await stream.result
379 const usage = result.usage as StepUsage | null
380 if (agentId === undefined || me === undefined) {
381 return result
382 }
383 const ping = e.index === 0 && isPingStep(result.answer, result.toolUses)
384 // 3.24 T13n: the narration blocks by their signatures (the probe: the api messages end on this step's message here).
385 const narration = feedOn && sawTool ? narrationIndexes(await $.session.messages({ agentId, as: 'api' }).catch(() => null), blocks) : new Set<number>()
386 for (const piece of sawTool ? feedPieces(blocks, narration, !ping) : []) {
387 await feedOut(piece)
388 }
389 me.ctx = usage === null ? me.ctx : ctxOf(usage)
390 const now = Date.now()
391 const f0 = factsOf(agentId)
392 const f = { ...f0, model: e.model, effort, usage: result.usage, at: now, note: approach === '' ? f0.note : noteLines(approach) }
393 facts.set(agentId, f)
394 const root = await $.session.root()
395 const sid = await $.session.id()
396 // 3.22 T8f: the agent's last-step facts; the router stamps model and effort on its answer from them (T5c: and more).
397 await $.fs.write(`${root}/.run/mod/${sid}/agents/${agentId}.json`, factsText(f, me.ctx, now)).catch(quiet)
398 // T5c: an expert's or a coder's approach note is a `note` journal entry (the partner's text is its answer).
399 const noter = approach === '' ? '' : noteWho(f.type, f.description)
400 if (noter !== '') {
401 const [date, time] = stamp(now)
402 journalSeq += 1
403 const body = entryText({ ts: new Date(now).toISOString(), session: sid, agentId, model: e.model, turnId: e.turnId }, approach)
404 await $.fs.write(`${root}/${entryPath(date, time, journalSeq, 'w', noter, 'note')}`, body).catch(quiet)
405 }
406 if (debugDir !== '') {
407 await $.fs.write(...ev('step', { agentId, model: f.model, effort: f.effort, usage: f.usage, thinking: thinking.length, narration: narration.size })).catch(quiet)
408 }
409 if (thinking.trim() !== '') {
410 const row = (await $.agent.list().catch(() => [])).find(a => a.id === agentId)
411 const type = row?.type ?? ''
412 const partnerId = type === '' ? partnerIdOf(await $.fs.read(`${root}/.run/mod/${sid}/state.json`).catch(() => null)) : ''
413 const ms = Date.now()
414 const [date, time] = stamp(ms)
415 journalSeq += 1
416 const rel = entryPath(date, time, journalSeq, 'w', whoOf(type, agentId, partnerId, row === undefined ? f.description : hintOf(row.description).desc), 'thinking')
417 const text = entryText({ ts: new Date(ms).toISOString(), session: sid, agentId, model: e.model, effort: f.effort, turnId: e.turnId }, thinking.trim())
418 await $.fs.write(`${root}/${rel}`, text).catch(quiet)
419 }
420 return result
421 })
422
423 // Every agent's tool calls: the last one per agent goes to the stream facts (3.23 T13d.c3: no `↳` line).
424 on('tool.call', async ($, e, next) => {
425 if (e.agentId !== undefined && (await read($, activeAtom).catch(() => false))) {
426 const args = argsOf(e as unknown as Record<string, unknown>)
427 const me = agents.get(e.agentId)
428 if (me !== undefined) {
429 me.planning = planningAfter(me.planning === true, e.tool, args) // the Skill line before, the plan line in planning
430 }
431 const f0 = factsOf(e.agentId)
432 // T5c: the last three steps, `Tool short-arg`, into the facts file at once (the step's own write came before its calls).
433 const steps = e.tool === HANDBACK ? f0.steps : [...f0.steps, `${e.tool} ${argLine(args, 60)}`.trim()].slice(-3)
434 const f = { ...f0, tool: e.tool, args: argSummary(args), steps }
435 facts.set(e.agentId, f)
436 if (steps !== f0.steps) {
437 const root = await $.session.root()
438 const sid = await $.session.id()
439 await $.fs.write(`${root}/.run/mod/${sid}/agents/${e.agentId}.json`, factsText(f, me?.ctx ?? 0, Date.now())).catch(quiet)
440 }
441 if (debugDir !== '') {
442 await $.fs.write(...ev('tool', { agentId: e.agentId, tool: f.tool, args: f.args })).catch(quiet)
443 }
444 if (e.tool === 'Bash' && args['run_in_background'] === true) {
445 // 3.28.1 T15: a background shell is recorded under its agent until its completion notice (session.append).
446 const r = await next(e)
447 const res = r.result as { backgroundTaskId?: unknown } | undefined
448 const id = typeof res?.backgroundTaskId === 'string' ? res.backgroundTaskId : (/ID: (\w+)/.exec(r.text ?? '')?.[1] ?? '')
449 if (id !== '') {
450 const agent = e.agentId
451 const type = f.type !== '' ? f.type : ((await $.agent.list().catch(() => [])).find(a => a.id === agent)?.type ?? '')
452 const rec: ShellRec = { id, name: shellName(String(args['command'] ?? '')), at: Date.now(), type }
453 await shellsEdit($, s => ({ ...s, [agent]: [...(s[agent] ?? []).filter(x => x.id !== id), rec] }))
454 }
455 return r
456 }
457 if (e.tool === 'SendMessage' || e.tool === HANDBACK) {
458 // 3.28.1 T15: the agent's report: its dead shells are cleared; a contract (`STATUS:`) clears all of them.
459 const msg = String(args['message'] ?? '')
460 const agent = e.agentId
461 await shellsEdit($, s => (s[agent] === undefined ? null
462 : { ...s, [agent]: /^\s*STATUS:/.test(msg) ? [] : (s[agent] ?? []).filter(x => x.exit === undefined) }))
463 }
464 if (EDIT_TOOLS.includes(e.tool)) {
465 // 3.24 T8b: a successful file edit by Claude, an expert or a coder is one `edit` feed record; the band's line names it.
466 const r = await next(e)
467 const root = await $.session.root()
468 const sid = await $.session.id()
469 const stateText = await $.fs.read(`${root}/.run/mod/${sid}/state.json`).catch(() => null)
470 const row = f.type !== '' ? null : (await $.agent.list().catch(() => [])).find(a => a.id === e.agentId)
471 const type = row === null ? f.type : (row?.type ?? '')
472 const desc = row === null ? f.description : hintOf(row?.description ?? '').desc
473 const edit = editsShown(type, partnerIdOf(stateText) === e.agentId) ? editOf(e.tool, args, r, root) : null
474 if (edit !== null) {
475 const at = Date.now()
476 const text = editHeader(edit)
477 feedSeq += 1
478 const body = feedRecord({ agent: e.agentId, type, desc, planning: me?.planning === true, text, at, kind: 'edit', edit })
479 await $.fs.write(`${feedDirOf(stateText, root, sid)}/in/${feedName(at, feedSeq)}`, body).catch(quiet)
480 const f2 = { ...factsOf(e.agentId), line: text }
481 facts.set(e.agentId, f2)
482 await $.fs.write(`${root}/.run/mod/${sid}/agents/${e.agentId}.json`, factsText(f2, me?.ctx ?? 0, at)).catch(quiet)
483 }
484 return r
485 }
486 }
487 return next(e)
488 })
489
490 // 3.28.1 T15: a background shell's completion notice (a row in its agent's conversation): a clean end removes its
491 // record; a failed one keeps it with its exit code (shown dead) until the agent reports.
492 on('session.append', async ($, e, next) => {
493 const r = await next(e)
494 try {
495 const content = (e.message as { content?: unknown }).content
496 const text = typeof content === 'string' ? content
497 : (Array.isArray(content) ? content : []).map(b => (typeof b === 'object' && b !== null && (b as { type?: unknown }).type === 'text' ? String((b as { text?: unknown }).text ?? '') : '')).join('\n')
498 const end = text.includes('<task-notification>') ? shellEnd(text) : null
499 if (end !== null && (await read($, activeAtom).catch(() => false))) {
500 await shellsEdit($, s => {
501 if (!Object.values(s).some(l => l.some(x => x.id === end.id))) {
502 return null
503 }
504 return Object.fromEntries(Object.entries(s).map(([a, l]) => [a, end.exit === undefined
505 ? l.filter(x => x.id !== end.id) : l.map(x => (x.id === end.id ? { ...x, exit: end.exit } : x))]))
506 })
507 }
508 } catch {
509 // best effort: the row is kept whatever the record does
510 }
511 return r
512 })
513
514 // The seven tools: a good call becomes one request file in the router's inbox, which its one-second tick drains.
515 for (const name of TOOL_NAMES) {
516 on('tool.call', { tool: `mcp__pa-watch__${name}` }, async ($, e) => {
517 const input = argsOf(e as unknown as Record<string, unknown>)
518 const bad = validateInput(name, input)
519 if (bad !== null) {
520 return { result: `error: ${bad}` }
521 }
522 const ms = Date.now()
523 reqSeq += 1
524 const file = requestName(ms, reqSeq, name)
525 const id = file.slice(0, -'.json'.length)
526 const failed = await (async (): Promise<string> => {
527 const root = await $.session.root()
528 const sid = await $.session.id()
529 await $.fs.write(`${root}/.run/mod/${sid}/inbox/${file}`, requestBody(id, name, e.agentId ?? '', input, ms))
530 return ''
531 })().catch((err: unknown) => String(err).slice(0, 200))
532 if (failed !== '') {
533 return { result: `error: the request was not queued (${failed})` }
534 }
535 if (debugDir !== '') {
536 await $.fs.write(...ev('request', { id, kind: name, caller: e.agentId ?? '' })).catch(quiet)
537 }
538 return { result: queuedReply(name, id) }
539 })
540 }
541}
542hooks/activation.ts 116 lines1// Activation of the PA 3.2 mod (design.md, "Activation"): a copy of the router's mod/pa/hooks/activation.ts, kept
2// identical (plugins never import across each other); the same test vectors pin both.
3
4/** The lowest Claude Code release the mod runs on: P3b, path 0 and the file channel were verified there (3.21). */
5export const VERSION_FLOOR = '2.1.292'
6
7export type Decision = { active: boolean; reason: string }
8
9/** `[major, minor, patch]` of a version's leading `x.y.z`, or `x.y` as `[x, y, 0]` (3.3 T7: VERSION 3.30), or null
10 * when it does not start with one. */
11export function parseVersion(version: string): [number, number, number] | null {
12 const m = /^\s*(\d+)\.(\d+)(?:\.(\d+))?/.exec(version)
13 if (m === null) {
14 return null
15 }
16 return [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)]
17}
18
19/** Numeric compare of `version` against `floor`; null when either does not parse. */
20export function versionAtLeast(version: string, floor: string): boolean | null {
21 const a = parseVersion(version)
22 const b = parseVersion(floor)
23 if (a === null || b === null) {
24 return null
25 }
26 for (let i = 0; i < 3; i += 1) {
27 if (a[i] !== b[i]) {
28 return (a[i] ?? 0) > (b[i] ?? 0)
29 }
30 }
31 return true
32}
33
34/** The release a `$.session.version()` answer names: its `base` when given, else its full `version`. */
35export function versionOf(v: { version: string; base?: string }): string {
36 return v.base ?? v.version
37}
38
39/** One string field of pa.json, or '' when absent, not a string, or pa.json does not parse. */
40export function paSetting(paJsonText: string | null, key: string): string {
41 if (paJsonText === null) {
42 return ''
43 }
44 try {
45 const doc: unknown = JSON.parse(paJsonText)
46 const value = typeof doc === 'object' && doc !== null ? (doc as Record<string, unknown>)[key] : undefined
47 return typeof value === 'string' ? value : ''
48 } catch {
49 return ''
50 }
51}
52
53/**
54 * On only with `.claude/pa.json` `"mod": "on"`. Off without pa.json, with any other value, under PA_MOD_OFF (non-empty
55 * and not "0"), or below Claude Code VERSION_FLOOR.
56 */
57export function decide(paJsonText: string | null, envOff: string | undefined, version: string): Decision {
58 if (envOff !== undefined && envOff !== '' && envOff !== '0') {
59 return { active: false, reason: 'PA_MOD_OFF is set' }
60 }
61 const atLeast = versionAtLeast(version, VERSION_FLOOR)
62 if (atLeast !== true) {
63 return { active: false, reason: `Claude Code ${version} is below ${VERSION_FLOOR} or unreadable` }
64 }
65 if (paJsonText === null) {
66 return { active: false, reason: 'no .claude/pa.json' }
67 }
68 let doc: unknown
69 try {
70 doc = JSON.parse(paJsonText)
71 } catch {
72 return { active: false, reason: 'pa.json does not parse' }
73 }
74 const mod = typeof doc === 'object' && doc !== null ? (doc as Record<string, unknown>)['mod'] : undefined
75 if (mod === 'on') {
76 return { active: true, reason: 'pa.json mod: on' }
77 }
78 return { active: false, reason: `pa.json mod: ${String(mod ?? 'absent')}` }
79}
80
81/**
82 * The ledger CLI (design.md, "Execution model"): `$PA_MOD_PA3_DIR/pa_ledger.py` when PA_MOD_PA3_DIR is set (trials point
83 * it at a snapshot's project-architect-3.0), else `<home>/.claude/pa3/pa_ledger.py`; '' with neither. Forward slashes.
84 */
85export function ledgerPath(pa3Dir: string | undefined, home: string | undefined): string {
86 if (pa3Dir !== undefined && pa3Dir !== '') {
87 return `${pa3Dir.replace(/\\/g, '/').replace(/\/+$/, '')}/pa_ledger.py`
88 }
89 if (home === undefined || home === '') {
90 return ''
91 }
92 return `${home.replace(/\\/g, '/')}/.claude/pa3/pa_ledger.py`
93}
94
95// 3.24 T6: the heartbeat and the error record pa/health.py reads under `<root>/.run/mod/` (the session-start and first
96// prompt checks); written only by an active session, so an inert one still writes nothing (3.24 T8).
97export const HEARTBEAT_FILE = '.run/mod/heartbeat.json'
98export const ERROR_FILE = '.run/mod/error.json'
99
100/** The PA3 release a VERSION file names: its `version:` line, else its first line; '' when absent. */
101export function releaseOf(text: string | null): string {
102 const lines = (text ?? '').split(/\r?\n/)
103 const v = lines.find(l => /^\s*version\s*:/.test(l))
104 return (v === undefined ? lines[0] ?? '' : v.slice(v.indexOf(':') + 1)).trim()
105}
106
107/** The heartbeat `{sid, version, engine, at, plugin}`: the PA3 release, the Claude Code release, `at` in seconds. */
108export function heartbeatText(sid: string, version: string, engine: string, ms: number, plugin: string): string {
109 return JSON.stringify({ sid, version, engine, at: ms / 1000, plugin }, null, 1)
110}
111
112/** The error record `{sid, plugin, hook, error, at}` of an activation hook that threw (`at` in seconds). */
113export function errorText(sid: string, plugin: string, hook: string, error: string, ms: number): string {
114 return JSON.stringify({ sid, plugin, hook, error: error.slice(0, 500), at: ms / 1000 }, null, 1)
115}
116hooks/debug.ts 25 lines1// Pure helpers of the watcher's debug log (one JSON file per event under PA_MOD_DEBUG=1, since `$.fs` has no append):
2// copies of the router's mod/pa/hooks/debug.ts `eventFile` and `clip`, kept identical. 3.23 T13d.c11: `levelOf` and
3// the `level` option retired (since c3 it only reached the activation event).
4
5/** One file per event, sortable by time then by a per-load counter. */
6export function eventFile(dir: string, ms: number, n: number, kind: string): string {
7 const safe = kind.replace(/[^a-z0-9.]+/gi, '_')
8 return `${dir}/${String(ms)}-${String(n).padStart(5, '0')}-${safe}.json`
9}
10
11/** A value as text, cut to `n` characters, so event files stay small. */
12export function clip(value: unknown, n: number): string {
13 let text: string
14 if (typeof value === 'string') {
15 text = value
16 } else {
17 try {
18 text = JSON.stringify(value) ?? String(value)
19 } catch {
20 text = String(value)
21 }
22 }
23 return text.length > n ? `${text.slice(0, n)}…` : text
24}
25hooks/edit.ts 263 lines1// 3.24 T8b: file edits on screen. A completed Edit, MultiEdit, Write or NotebookEdit by Claude, an expert or a coder is
2// one feed record (`kind` "edit") whose `edit` holds the native look's parts: the path, created or updated, the +/-
3// counts, the unified hunks (3 lines of context, at most EDIT_CAP rows in all) and the rows left out. The router draws it
4// as a note in the agent's role color and stores it as a ```diff block for the phone. Pure helpers; register.ts makes
5// the `$` calls.
6
7/** The tools whose successful calls draw an edit card. */
8export const EDIT_TOOLS: readonly string[] = ['Edit', 'MultiEdit', 'Write', 'NotebookEdit']
9
10/** Context lines around each change (native: 3). */
11export const CONTEXT = 3
12/** The rows an edit card shows at most (hunk lines, or a new file's first lines); the rest is `… +N lines`. */
13export const EDIT_CAP = 30
14/** The middle (after the common head and tail) a line diff takes at most, in old × new lines; past it, all replaced. */
15const LCS_MAX = 4_000_000
16
17/** Whose edits draw: Claude (pa-claude, or the partner by id), experts and coders; 3.24 T17b: the planner (its draft's
18 * one labelled line). */
19export function editsShown(type: string, isPartner: boolean): boolean {
20 return type === 'pa-claude' || type === 'pa-planner' || /^(pa-)?(expert|coder)(-|$)/.test(type) || (type === '' && isPartner)
21}
22
23/** An edit card's data, the feed record's `edit` (the router's FeedEdit). `hunks`: unified hunks with `@@` headers, or
24 * for a created file one block of its first lines; `more`: rows not shown. 3.24 T13C: `label`, when set, replaces the
25 * header's `Created <path>` / `Updated <path>` (a plan draft: "Plan drafted", no hunks). */
26export type FeedEdit = { path: string; created: boolean; add: number; del: number; hunks: string[]; more: number; label?: string }
27
28/** The plan drafts (project-relative, either separator): their edit record is one header line, no hunks (3.24 T13C). */
29const PLAN_DRAFTS = ['.run/PHASE_PLAN.draft.md', '.run/GENERATION_PLAN.draft.md']
30
31/** The header line (the record's `text`, the band's line, the phone's line above the fence). */
32export function editHeader(e: FeedEdit): string {
33 const verb = e.label ?? `${e.created ? 'Created' : 'Updated'} ${e.path}`
34 return e.created ? `${verb} (${e.add} ${e.add === 1 ? 'line' : 'lines'})` : `${verb} (+${e.add} -${e.del})`
35}
36
37/** A text's lines: CRLF and CR as LF, other control characters but tab as spaces, no empty last line after a final newline. */
38export function linesOf(text: string): string[] {
39 if (text === '') {
40 return []
41 }
42 const ls = text.replace(/\r\n?/g, '\n').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ' ').split('\n')
43 if (ls[ls.length - 1] === '') {
44 ls.pop()
45 }
46 return ls
47}
48
49/** One diff row: kept, removed or added; `o`/`n` the old/new lines before it (0-based positions). */
50type Row = { sign: ' ' | '-' | '+'; o: number; n: number; text: string }
51
52/** Every row of the line diff of `a` to `b` (common head and tail kept, an LCS over the middle; removals first). */
53export function diffRows(a: readonly string[], b: readonly string[]): Row[] {
54 let head = 0
55 while (head < a.length && head < b.length && a[head] === b[head]) {
56 head += 1
57 }
58 let tail = 0
59 while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) {
60 tail += 1
61 }
62 const rows: Row[] = []
63 for (let i = 0; i < head; i += 1) {
64 rows.push({ sign: ' ', o: i, n: i, text: a[i] ?? '' })
65 }
66 const am = a.slice(head, a.length - tail)
67 const bm = b.slice(head, b.length - tail)
68 const M = am.length
69 const N = bm.length
70 let o = head
71 let n = head
72 if (M * N > 0 && M * N <= LCS_MAX) {
73 // dp[i][j]: the LCS length of am[i..] and bm[j..]
74 const w = N + 1
75 const dp = new Uint32Array((M + 1) * w)
76 for (let i = M - 1; i >= 0; i -= 1) {
77 for (let j = N - 1; j >= 0; j -= 1) {
78 dp[i * w + j] = am[i] === bm[j] ? (dp[(i + 1) * w + j + 1] ?? 0) + 1 : Math.max(dp[(i + 1) * w + j] ?? 0, dp[i * w + j + 1] ?? 0)
79 }
80 }
81 let i = 0
82 let j = 0
83 while (i < M || j < N) {
84 if (i < M && j < N && am[i] === bm[j]) {
85 rows.push({ sign: ' ', o, n, text: am[i] ?? '' })
86 i += 1; j += 1; o += 1; n += 1
87 } else if (i < M && (j >= N || (dp[(i + 1) * w + j] ?? 0) >= (dp[i * w + j + 1] ?? 0))) {
88 rows.push({ sign: '-', o, n, text: am[i] ?? '' })
89 i += 1; o += 1
90 } else {
91 rows.push({ sign: '+', o, n, text: bm[j] ?? '' })
92 j += 1; n += 1
93 }
94 }
95 } else {
96 for (const t of am) {
97 rows.push({ sign: '-', o, n, text: t })
98 o += 1
99 }
100 for (const t of bm) {
101 rows.push({ sign: '+', o, n, text: t })
102 n += 1
103 }
104 }
105 for (let k = 0; k < tail; k += 1) {
106 rows.push({ sign: ' ', o: o + k, n: n + k, text: a[a.length - tail + k] ?? '' })
107 }
108 return rows
109}
110
111/** The changed rows with CONTEXT kept rows around each change; hunks closer than 2 × CONTEXT merge. */
112export function hunksOf(rows: readonly Row[], context = CONTEXT): Row[][] {
113 const changed = rows.map((r, k) => (r.sign === ' ' ? -1 : k)).filter(k => k >= 0)
114 const out: Row[][] = []
115 let from = -1
116 let to = -1
117 for (const k of changed) {
118 if (from >= 0 && k - context <= to + context + 1) {
119 to = k
120 continue
121 }
122 if (from >= 0) {
123 out.push(rows.slice(Math.max(0, from - context), Math.min(rows.length, to + context + 1)))
124 }
125 from = k
126 to = k
127 }
128 if (from >= 0) {
129 out.push(rows.slice(Math.max(0, from - context), Math.min(rows.length, to + context + 1)))
130 }
131 return out
132}
133
134/** One hunk as unified-diff text: `@@ -a,b +c,d @@`, then each row with its sign. */
135export function hunkText(rows: readonly Row[]): string {
136 const first = rows[0]
137 if (first === undefined) {
138 return ''
139 }
140 const ol = rows.filter(r => r.sign !== '+').length
141 const nl = rows.filter(r => r.sign !== '-').length
142 const start = (pos: number, count: number): number => (count === 0 ? pos : pos + 1)
143 return [`@@ -${start(first.o, ol)},${ol} +${start(first.n, nl)},${nl} @@`, ...rows.map(r => `${r.sign}${r.text}`)].join('\n')
144}
145
146/** The hunks cut to `cap` rows in all (a hunk cut short gets its own header); the rows left out. */
147function capped(hunks: readonly Row[][], cap: number): { hunks: string[]; more: number } {
148 const out: string[] = []
149 let room = cap
150 let more = 0
151 for (const h of hunks) {
152 if (room <= 0) {
153 more += h.length
154 continue
155 }
156 const kept = h.slice(0, room)
157 room -= kept.length
158 more += h.length - kept.length
159 out.push(hunkText(kept))
160 }
161 return { hunks: out, more }
162}
163
164/** An edit card of `before` to `after` at `path`; `before` null: a created file (its first EDIT_CAP lines). */
165export function editView(path: string, before: string | null, after: string): FeedEdit {
166 const b = linesOf(after)
167 if (before === null) {
168 return { path, created: true, add: b.length, del: 0, hunks: b.length === 0 ? [] : [b.slice(0, EDIT_CAP).join('\n')], more: Math.max(0, b.length - EDIT_CAP) }
169 }
170 const rows = diffRows(linesOf(before), b)
171 const c = capped(hunksOf(rows), EDIT_CAP)
172 return { path, created: false, add: rows.filter(r => r.sign === '+').length, del: rows.filter(r => r.sign === '-').length, ...c }
173}
174
175type Patch = { oldStart: number; newStart: number; lines: string[] }
176
177/** An edit card of the tool's own structured patch (when the old text is not in the result). */
178export function patchView(path: string, patch: readonly Patch[]): FeedEdit {
179 const hunks: Row[][] = []
180 for (const p of patch) {
181 let o = p.oldStart - 1
182 let n = p.newStart - 1
183 const rows: Row[] = []
184 for (const l of p.lines) {
185 const sign = l[0] === '-' || l[0] === '+' ? l[0] : ' '
186 rows.push({ sign, o, n, text: linesOf(l.slice(1))[0] ?? '' })
187 o += sign === '+' ? 0 : 1
188 n += sign === '-' ? 0 : 1
189 }
190 hunks.push(rows)
191 }
192 const all = hunks.flat()
193 return { path, created: false, add: all.filter(r => r.sign === '+').length, del: all.filter(r => r.sign === '-').length, ...capped(hunks, EDIT_CAP) }
194}
195
196const lf = (s: string): string => s.replace(/\r\n?/g, '\n')
197
198/** `text` with one Edit applied (LF throughout); null when the old string is not found. */
199function applied(text: string, oldS: string, newS: string, all: boolean): string | null {
200 if (oldS === '') {
201 return text === '' ? newS : null
202 }
203 const i = text.indexOf(oldS)
204 if (i < 0) {
205 return null
206 }
207 return all ? text.split(oldS).join(newS) : text.slice(0, i) + newS + text.slice(i + oldS.length)
208}
209
210/** `path` shown project-relative (its own separators) when under `root`, else whole. */
211export function shownPath(path: string, root: string): string {
212 const f = (s: string): string => s.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
213 const r = f(root)
214 return r !== '' && f(path).startsWith(`${r}/`) ? path.slice(root.replace(/[\\/]+$/, '').length + 1) : path
215}
216
217type Obj = Record<string, unknown>
218const str = (x: unknown): string => (typeof x === 'string' ? x : '')
219const isObj = (x: unknown): x is Obj => typeof x === 'object' && x !== null && !Array.isArray(x)
220
221/**
222 * The edit card of one tool call, or null: not an edit tool, the call denied or failed (`deny`, `isError`, no `result`),
223 * held for review (`staged`), or nothing to show. `input` is the call's arguments, `r` what `next(e)` resolved to.
224 */
225export function editOf(tool: string, input: Obj, r: unknown, root: string): FeedEdit | null {
226 if (!EDIT_TOOLS.includes(tool) || !isObj(r) || r['deny'] !== undefined || r['isError'] === true || !isObj(r['result'])) {
227 return null
228 }
229 const res = r['result']
230 if (res['staged'] === true || str(res['error']) !== '') {
231 return null
232 }
233 const path = shownPath(str(res['filePath']) || str(res['notebook_path']) || str(input['file_path']) || str(input['notebook_path']), root)
234 if (path === '') {
235 return null
236 }
237 const patch = Array.isArray(res['structuredPatch']) ? (res['structuredPatch'] as Patch[]) : []
238 const orig = typeof res['originalFile'] === 'string' ? lf(res['originalFile']) : null
239 let view: FeedEdit | null = null
240 if (tool === 'Write') {
241 const content = str(res['content']) || str(input['content'])
242 view = res['type'] === 'create' ? editView(path, null, content) : orig !== null ? editView(path, orig, content) : patchView(path, patch)
243 } else if (tool === 'NotebookEdit') {
244 view = editView(path, str(res['old_source']), str(res['new_source']))
245 } else {
246 const edits: Obj[] = tool === 'MultiEdit' ? (Array.isArray(input['edits']) ? input['edits'].filter(isObj) : []) : [input]
247 let after: string | null = orig ?? (edits.length > 0 && str(edits[0]?.['old_string']) === '' ? '' : null)
248 for (const ed of edits) {
249 after = after === null ? null : applied(after, lf(str(ed['old_string'])), lf(str(ed['new_string'])), ed['replace_all'] === true)
250 }
251 if (after !== null && edits.length > 0) {
252 view = editView(path, orig, after) // no original: an Edit that created the file
253 } else if (patch.length > 0) {
254 view = patchView(path, patch)
255 }
256 }
257 if (view !== null && (view.created || view.hunks.length > 0) && PLAN_DRAFTS.includes(path.replace(/\\/g, '/'))) {
258 // 3.24 T13C: a plan draft is one line; the plan-mode view shows the plan itself.
259 return { ...view, hunks: [], more: 0, label: view.created ? 'Plan drafted' : 'Plan draft updated' }
260 }
261 return view === null || (view.hunks.length === 0 && !view.created) ? null : view
262}
263hooks/feed.ts 220 lines1// 3.23 T13d: the watcher's side of the live feed. Each visible-text segment of an agent's step is one record file in
2// the feed folder's `in/` (`kind` "text"; 3.23 T13d.c7: statements only, the c5 step-summary records are gone), which the router plugin's monitor (`mod/pa/feed/pa_feed.py`) numbers and prints. Pure helpers;
3// register.ts makes the `$` calls.
4import type { FeedEdit } from './edit'
5
6/** Agents whose text never feeds: retrievers. 3.24 T13C: the memory curator and the auditor feed (was: never). */
7export function feeds(type: string): boolean {
8 return !type.startsWith('retriever-')
9}
10
11/** 3.24 T17g: agents the watcher announces with a start line at their first request, so the developer sees their model
12 * and effort even if they never feed (round 4: the thinker at max thought 15 minutes and fed nothing): the thinkers, the
13 * planners, coders, the curator and the auditor. Not experts: the router writes their start lines. */
14export function startsLine(type: string): boolean {
15 return ['pa-thinker', 'thinker', 'deep-thinker', 'pa-planner', 'planner-gen', 'planner-phase', 'memory-curator', 'auditor'].includes(type) || /^(pa-)?coder/.test(type) // 3.25 T6b: pa-thinker, pa-coder
16}
17
18/** 3.24 T17g: a start line's text: `Starting: <the spawn's description>` (`Starting` when it has none). */
19export const startText = (desc: string): string => (desc.trim() === '' ? 'Starting' : `Starting: ${desc.trim()}`)
20
21/**
22 * The feed folder, absolute: state.json's `feed.dir` (project-relative, or absolute) under `root`, else
23 * `<root>/.run/mod/<sid>/feed`.
24 */
25export function feedDirOf(stateText: string | null, root: string, sid: string): string {
26 let dir = ''
27 try {
28 const doc = stateText === null ? null : (JSON.parse(stateText) as { feed?: { dir?: unknown } | null })
29 dir = typeof doc?.feed?.dir === 'string' ? doc.feed.dir : ''
30 } catch {
31 dir = ''
32 }
33 if (dir === '') {
34 return `${root}/.run/mod/${sid}/feed`
35 }
36 return /^([A-Za-z]:)?[\\/]/.test(dir) ? dir.replace(/\\/g, '/') : `${root}/${dir.replace(/\\/g, '/')}`
37}
38
39/** The band's working line of a feed segment (3.23 T13d.c2): its last non-empty line, whitespace collapsed. */
40export function lastLine(text: string): string {
41 const lines = text.split(/\r?\n/).map(l => l.replace(/\s+/g, ' ').trim()).filter(l => l !== '')
42 return lines[lines.length - 1] ?? ''
43}
44
45/** A record file's name: 13-digit ms and a zero-padded sequence, so names sort in order. */
46export function feedName(ms: number, seq: number): string {
47 return `${String(ms).padStart(13, '0')}-${String(seq).padStart(6, '0')}.json`
48}
49
50export type FeedKind = 'summary' | 'text' | 'edit'
51
52/** One record file's text (the feed contract): one JSON object and a final newline; `kind` is "text" (the agent's
53 * own text). 3.23 T13d.c5 also wrote "summary" (a step's `thinking`); c7 stopped it, the router still reads old ones.
54 * 3.24 T8b: "edit", a file edit's card (edit.ts): `text` its header line, `edit` its parts. */
55export function feedRecord(r: { agent: string; type: string; desc: string; planning: boolean; text: string; at: number; kind: FeedKind; edit?: FeedEdit }): string {
56 const edit = r.edit === undefined ? {} : { edit: r.edit }
57 return `${JSON.stringify({ agent: r.agent, type: r.type, desc: r.desc, planning: r.planning, text: r.text, at: r.at, kind: r.kind, ...edit })}\n`
58}
59
60// 3.24 T13n: Opus 5.5 "narration" blocks are `thinking` blocks Claude Code draws white, as statements; they feed like
61// text. Claude Code 2.1.296 tells them apart by the signature (`Nsn`): protobuf field 2, then 1, then string field 8 is
62// "narration". A signature that cannot be read is no narration (fail-safe: the block stays journal-only).
63
64const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
65
66/** Standard base64 to bytes; null on a character outside the alphabet. */
67function base64Bytes(s: string): number[] | null {
68 const out: number[] = []
69 let acc = 0
70 let bits = 0
71 for (const c of s.replace(/=+$/, '')) {
72 const v = B64.indexOf(c)
73 if (v < 0) {
74 return null
75 }
76 acc = ((acc << 6) | v) & 0xffffff
77 bits += 6
78 if (bits >= 8) {
79 bits -= 8
80 out.push((acc >> bits) & 0xff)
81 }
82 }
83 return out
84}
85
86/** A protobuf varint at `i`: [value, next index], or null past the end. */
87function varint(b: number[], i: number): [number, number] | null {
88 let r = 0
89 let s = 0
90 while (i < b.length && s < 64) {
91 const x = b[i] ?? 0
92 i += 1
93 r += (x & 0x7f) * 2 ** s
94 s += 7
95 if (x < 0x80) {
96 return [r, i]
97 }
98 }
99 return null
100}
101
102/** The bytes of the first length-delimited field `num` of a protobuf message, or null. */
103function protoField(b: number[], num: number): number[] | null {
104 let i = 0
105 while (i < b.length) {
106 const k = varint(b, i)
107 if (k === null) {
108 return null
109 }
110 i = k[1]
111 const f = Math.floor(k[0] / 8)
112 const w = k[0] % 8
113 if (w === 0) {
114 const v = varint(b, i)
115 if (v === null) {
116 return null
117 }
118 i = v[1]
119 } else if (w === 1) {
120 i += 8
121 } else if (w === 5) {
122 i += 4
123 } else if (w === 2) {
124 const n = varint(b, i)
125 if (n === null || n[1] + n[0] > b.length) {
126 return null
127 }
128 if (f === num) {
129 return b.slice(n[1], n[1] + n[0])
130 }
131 i = n[1] + n[0]
132 } else {
133 return null
134 }
135 }
136 return null
137}
138
139/** Whether a thinking block's signature marks it as narration (Claude Code's `Nsn`); false when it cannot be read. */
140export function isNarration(sig: unknown): boolean {
141 if (typeof sig !== 'string' || sig === '') {
142 return false
143 }
144 const b = base64Bytes(sig)
145 const s = b === null ? null : protoField(b, 2)
146 const h = s === null || s.length === 0 ? null : protoField(s, 1)
147 const t = h === null || h.length === 0 ? null : protoField(h, 8)
148 return t !== null && String.fromCharCode(...t) === 'narration'
149}
150
151/** One content block of a step as it streamed (text and thinking joined per block, a tool call by its id). */
152export type StepBlock = { kind: 'text' | 'thinking'; index: number; text: string } | { kind: 'tool'; index: number; id: string; handback: boolean }
153
154const API_TYPE = { text: 'text', thinking: 'thinking', tool: 'tool_use' } as const
155
156/**
157 * The block indexes of the step's narration blocks, from `$.session.messages({ agentId, as: 'api' })` read at the step's
158 * end (the probe: it then holds the step's own message, mostly last; a quick tool's result may follow it). The step's
159 * message is the newest assistant one that lines up with the streamed blocks (each block's type, and each tool call's
160 * id, at its index; one tool call at least); none lines up, no narration.
161 */
162export function narrationIndexes(found: unknown, blocks: readonly StepBlock[]): Set<number> {
163 const none = new Set<number>()
164 if (!Array.isArray(found) || !blocks.some(b => b.kind === 'tool')) {
165 return none
166 }
167 const linesUp = (m: { role?: unknown; content?: unknown } | null | undefined): m is { content: Record<string, unknown>[] } =>
168 m?.role === 'assistant' && Array.isArray(m.content) && blocks.every(b => {
169 const c = (m.content as Record<string, unknown>[])[b.index]
170 return c !== undefined && c !== null && c['type'] === API_TYPE[b.kind] && (b.kind !== 'tool' || c['id'] === b.id)
171 })
172 const msg = [...(found as ({ role?: unknown; content?: unknown } | null)[])].reverse().find(linesUp)
173 if (msg === undefined || !linesUp(msg)) {
174 return none
175 }
176 const content = msg.content
177 const out = new Set<number>()
178 content.forEach((c, j) => {
179 if (c['type'] === 'thinking' && isNarration(c['signature'])) {
180 out.add(j)
181 }
182 })
183 return out
184}
185
186/**
187 * A step's feed lines in block order: the text since the last tool call before each tool call but the hand-back (text
188 * blocks joined, as 3.23 T13d), each narration block on its own; `tail` adds those after the last tool call.
189 */
190export function feedPieces(blocks: readonly StepBlock[], narration: ReadonlySet<number>, tail: boolean): string[] {
191 const out: string[] = []
192 let seg: string[] = []
193 let text = ''
194 const close = (): void => {
195 seg.push(text)
196 text = ''
197 }
198 for (const b of blocks) {
199 if (b.kind === 'text') {
200 text += b.text
201 } else if (b.kind === 'thinking') {
202 if (narration.has(b.index)) {
203 close()
204 seg.push(b.text)
205 }
206 } else {
207 close()
208 if (!b.handback) {
209 out.push(...seg)
210 }
211 seg = []
212 }
213 }
214 close()
215 if (tail) {
216 out.push(...seg)
217 }
218 return out.map(t => t.trim()).filter(t => t !== '')
219}
220hooks/journal.ts 112 lines1// The watcher's journal entries (design.md, "Journal"): the watcher writes its own `thinking` entries, so reasoning
2// summaries survive a reload. `entryPath` is a copy of the router's mod/pa/hooks/journal.ts naming, kept identical
3// (plugins never import across each other); the same test vectors pin both. Pure.
4
5/** `.claude-state/journal/<YYYY-MM-DD>/<HHMMSS>-<seq><writer>-<who>-<kind>.md`, seq padded to 3 digits. */
6export function entryPath(date: string, time: string, seq: number, writer: string, who: string, kind: string): string {
7 return `.claude-state/journal/${date}/${time}-${String(seq).padStart(3, '0')}${writer}-${who}-${kind}.md`
8}
9
10const two = (n: number): string => String(n).padStart(2, '0')
11
12/** `[YYYY-MM-DD, HHMMSS]` of `ms` in local time. */
13export function stamp(ms: number): [string, string] {
14 const d = new Date(ms)
15 return [`${String(d.getFullYear())}-${two(d.getMonth() + 1)}-${two(d.getDate())}`,
16 `${two(d.getHours())}${two(d.getMinutes())}${two(d.getSeconds())}`]
17}
18
19/** An entry: one `key: value` line per field between `---` lines, then the body. */
20export function entryText(fields: Readonly<Record<string, string | number>>, body: string): string {
21 const lines = Object.entries(fields).map(([k, v]) => `${k}: ${String(v).replace(/[\r\n]+/g, ' ')}`)
22 return `---\n${lines.join('\n')}\n---\n${body}\n`
23}
24
25/**
26 * The journal's `who` for an agent: type `pa-claude` (3.22 T8g; `claude` at T8f, `partner` before) `claude`, `thinker` or `deep-thinker`
27 * `thinker` (a who is one `[a-z]+` word); 3.24 T13f, by role (the card's names): `expert-*` `expert`; `memory-curator` `curator`; `retriever-*`
28 * `retriever`; `coder*` `coder`; `auditor`, `planner-*` and `pa-planner` (3.24 T17b) `planner`, `critic`, `review` as their role; any other type
29 * `coder` (was: every type but these, so expert and curator thinking filed as `coder`); unknown type (`''`): `claude`
30 * when the agentId is the router's partner (state.json), else `coder`.
31 */
32export function whoOf(type: string, agentId: string, partnerId: string, description = ''): string {
33 if (type === 'pa-claude' || type === 'partner') { // never the built-in `claude` catch-all (3.22 T8g)
34 return 'claude'
35 }
36 if (type === 'pa-thinker' || type === 'thinker' || type === 'deep-thinker') { // 3.25 T6b: one agent per role
37 return 'thinker'
38 }
39 if (type === 'pa-expert' || type.startsWith('expert-')) {
40 return 'expert'
41 }
42 if (type === 'memory-curator') {
43 return 'curator'
44 }
45 if (type === 'pa-planner') { // 3.24 T17b
46 return 'planner'
47 }
48 if (type === 'pa-coder') { // 3.25 T6b
49 return 'coder'
50 }
51 for (const role of ['retriever', 'coder', 'planner']) {
52 if (type === role || type.startsWith(`${role}-`)) {
53 return role
54 }
55 }
56 if (type === 'auditor' || type === 'critic' || type === 'review') {
57 return type
58 }
59 if (type === '' && partnerId !== '' && agentId === partnerId) {
60 return 'claude'
61 }
62 return 'coder'
63}
64
65/** The display name of an agent type in the stream and the usage line: `pa-claude` → `Claude`, others as is (3.22 T8g). */
66export function displayWho(type: string): string {
67 return type === 'pa-claude' ? 'Claude' : type
68}
69
70/** The facts file's 3.23 T5c fields: who the agent is, its latest approach note, its last three steps, when written;
71 * 3.23 T13d.c2: `line`, the last line of its latest feed segment ('' none yet; the band shows it). */
72export type MoreFacts = { type: string; description: string; parentId: string; note: string; steps: string[]; updatedAt: number; line: string }
73
74/**
75 * The watcher's last-step facts of one agent, `.run/mod/<sid>/agents/<agentId>.json` (3.22 T8f); the router stamps them on
76 * answers and builds the running-now note from them (3.23 T5c: `more`).
77 */
78export function stepFactsText(model: string, effort: string, ctx: number, at: number, more?: MoreFacts): string {
79 return JSON.stringify({ model, effort, ctx, at, ...more })
80}
81
82/** An approach note as the facts keep it: its first two non-blank lines. */
83export function noteLines(text: string): string {
84 return text.split(/\r?\n/).map(l => l.trim()).filter(l => l !== '').slice(0, 2).join('\n')
85}
86
87/**
88 * The journal's `who` of an approach note (3.23 T5c): an expert (`expert-*`) is `T<n>` from its spawn description
89 * "T<n> <title>", a coder (`coder*`) `coder`; '' for any other agent (the partner's text is its answer), and for an
90 * expert whose description names no task.
91 */
92export function noteWho(type: string, description: string): string {
93 if (type === 'pa-expert' || type.startsWith('expert-')) { // 3.25 T6b: pa-expert, pa-coder
94 return /^(T[0-9]+)\b/.exec(description.trim())?.[1] ?? ''
95 }
96 return /^(pa-)?coder/.test(type) ? 'coder' : ''
97}
98
99/** `partner.agentId` of the router's state file text, or '' when absent or unreadable. */
100export function partnerIdOf(stateText: string | null): string {
101 if (stateText === null) {
102 return ''
103 }
104 try {
105 const doc = JSON.parse(stateText) as { partner?: { agentId?: unknown } | null }
106 const id = doc.partner?.agentId
107 return typeof id === 'string' ? id : ''
108 } catch {
109 return ''
110 }
111}
112hooks/ladder.ts 102 lines1// 3.24 T17b: each agent's model and effort from the ladder. T17a's resolver (`pa_ledger.py ladder-row`) gives an agent
2// type's row for the active account's tier; the spawn hook sets a managed agent's `model`, its `turn.step` sets the
3// effort of its requests, and the agent's first step is journalled as a `spawn` entry with what it actually got (doctor
4// compares it with the row). Claude (pa-claude) is never set: its model and effort stay the developer's. Pure;
5// register.ts makes the `$` calls.
6import type { LadderRow, LadderRows } from '../types'
7
8export type { LadderRow, LadderRows } from '../types'
9
10const EFFORTS: readonly string[] = ['low', 'medium', 'high', 'xhigh', 'max']
11
12/** The agent type whose spawn re-checks the tier (the account may have changed since the rows were read). */
13export const PLANNER = 'pa-planner'
14
15/** Types the ladder never sets: Claude, whose model and effort follow /model and /effort (until 3.25's auto effort). */
16export const UNSET: readonly string[] = ['pa-claude']
17
18/** 3.25 T6b: a spawn description's trailing `[effort <level>]` hint (level an effort or `deep`): the level, '' when none,
19 * and the description without it (what is shown). */
20const HINT = /\s*\[effort (low|medium|high|xhigh|max|deep)\]\s*$/
21export function hintOf(description: string): { level: string; desc: string } {
22 const m = HINT.exec(description)
23 return m === null ? { level: '', desc: description } : { level: m[1] ?? '', desc: description.slice(0, m.index) }
24}
25
26/** The ladder key of a spawn: `<type>@<level>` with a hint (the resolver echoes it as the row's `agent`), else the type. */
27export const rowKey = (type: string, level: string): string => (level === '' ? type : `${type}@${level}`)
28
29/** The resolver's argv, or null when pa.json names no interpreter or no ledger path is known; `effort`, a hint's level. */
30export function ladderArgv(python: string, ledger: string, agent: string, project: string, effort = ''): string[] | null {
31 if (python === '' || ledger === '' || agent === '') {
32 return null
33 }
34 return [python, ledger, 'ladder-row', '--agent', agent, ...(effort === '' ? [] : ['--effort', effort]), '--project', project, '--json']
35}
36
37/** The row in the resolver's output (its last non-blank line), or null when it is not `agent`'s well-formed row. */
38export function rowOf(stdout: string, agent: string): LadderRow | null {
39 const line = stdout.split(/\r?\n/).map(l => l.trim()).filter(l => l !== '').at(-1) ?? ''
40 let o: unknown
41 try {
42 o = JSON.parse(line)
43 } catch {
44 return null
45 }
46 if (typeof o !== 'object' || o === null || Array.isArray(o)) {
47 return null
48 }
49 const r = o as Record<string, unknown>
50 const str = (k: string): string | null => {
51 const v = r[k]
52 return typeof v === 'string' ? v : null
53 }
54 const [model, effort, tier, source] = [str('model'), str('effort'), str('tier'), str('source')]
55 const managed = r['managed']
56 if (r['agent'] !== agent || typeof managed !== 'boolean' || model === null || effort === null || tier === null || source === null) {
57 return null
58 }
59 return { agent, managed, model, effort: EFFORTS.includes(effort) ? effort as LadderRow['effort'] : '', tier, source }
60}
61
62/** The rows with `row` kept: a row of another tier is dropped (the tier changed), so no stale row outlives it. */
63export function keepRow(rows: Readonly<LadderRows>, row: LadderRow): LadderRows {
64 const out: LadderRows = {}
65 for (const [k, v] of Object.entries(rows)) {
66 if (v.tier === row.tier) {
67 out[k] = v
68 }
69 }
70 out[row.agent] = row
71 return out
72}
73
74/** The model a spawn is given: a managed row's, unless the spawn is Claude, a fork (it inherits) or a workflow's agent
75 * (its content is not rewritable); else null (the spawn is left as it is). */
76export function spawnModel(row: LadderRow | null, e: { subagentType: string; fork: boolean; workflow?: unknown }): string | null {
77 if (row === null || !row.managed || row.model === '' || e.fork || e.workflow !== undefined || UNSET.includes(e.subagentType)) {
78 return null
79 }
80 return row.model
81}
82
83/** The effort an agent's requests are given: a managed row's for its type, never Claude's; else null (left alone). */
84export function stepEffort(row: LadderRow | null | undefined, type: string): Exclude<LadderRow['effort'], ''> | null {
85 if (row === null || row === undefined || !row.managed || row.effort === '' || type === '' || UNSET.includes(type)) {
86 return null
87 }
88 return row.effort
89}
90
91/** The `spawn` journal entry's fields (T17a's doctor reads `agentType`, `model`, `effort`, newest per type): the agent's
92 * type and what its first request ran on (`model`, `effort`),
93 * and its row (`managed` yes, no or unknown when the resolver gave none; `row_model`, `row_effort`, `tier`, `source`). */
94export function spawnFields(o: { ts: string; session: string; agentId: string; type: string; model: string; effort: string; row: LadderRow | null }): Record<string, string> {
95 const r = o.row
96 return {
97 ts: o.ts, session: o.session, agentId: o.agentId, agentType: o.type, model: o.model, effort: o.effort,
98 managed: r === null ? 'unknown' : r.managed ? 'yes' : 'no',
99 row_model: r?.model ?? '', row_effort: r?.effort ?? '', tier: r?.tier ?? '', source: r?.source ?? '',
100 }
101}
102hooks/stream.ts 115 lines1// The usage line (design.md, "Activity stream (I49)") and the step helpers the watcher keeps: a tool call's argument
2// line for the facts file, ping and hand-back steps, agent depth, planning; (until 3.24 T13C) one live usage line.
3// 3.23 T13d.c3: the dim transcript lines (`streamLine`, the `level` kinds) and the `colors` option retired; the live
4// feed shows the agents' working lines. 3.24 T13C: the usage line is gone (`usageLine`, `warmOf`, `loopNote`, and the
5// step pricing it alone used: `ratesFor`, `stepUsd`); `ctxOf` stays for the facts file. Pure.
6
7/** `text` cut to `n` characters, the last one `…` when cut. */
8export function cut(text: string, n: number): string {
9 return text.length > n ? `${text.slice(0, n - 1)}…` : text
10}
11
12/** The first non-blank line of `text`, trimmed, in at most `n` characters. */
13export function firstLine(text: string, n: number): string {
14 const line = text.split(/\r?\n/).map(l => l.trim()).find(l => l !== '') ?? ''
15 return cut(line, n)
16}
17
18const PRIMARY = ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'subagent_type', 'description', 'prompt'] as const
19
20/** A tool call's arguments in one line of at most `n`: its main string argument, else the JSON. */
21export function argLine(args: unknown, n = 80): string {
22 if (typeof args === 'object' && args !== null && !Array.isArray(args)) {
23 const o = args as Record<string, unknown>
24 for (const k of PRIMARY) {
25 const v = o[k]
26 if (typeof v === 'string' && v.trim() !== '') {
27 return firstLine(v, n)
28 }
29 }
30 }
31 return cut(json(args).replace(/\s+/g, ' '), n)
32}
33
34function json(v: unknown): string {
35 try {
36 return JSON.stringify(v) ?? ''
37 } catch {
38 return String(v)
39 }
40}
41
42/** A `.` ping turn's answer: `.` alone and no tool calls (it draws no lines at all). */
43export function isPing(answer: string, toolUses: number): boolean {
44 return answer.trim() === '.' && toolUses === 0
45}
46
47/** The hand-back tool: its call is the agent's answer (3.22 T8e), never a feed segment. */
48export const HANDBACK = 'SubagentHandback'
49
50/** The `message` of a step's SubagentHandback call, or null when the step makes none. */
51export function handbackOf(toolUses: readonly { name: string; input: unknown }[]): string | null {
52 const use = toolUses.find(u => u.name === HANDBACK)
53 if (use === undefined) {
54 return null
55 }
56 const msg = typeof use.input === 'object' && use.input !== null ? (use.input as Record<string, unknown>)['message'] : undefined
57 return typeof msg === 'string' ? msg : ''
58}
59
60/** A ping step: `.` alone and no tool calls, or a `.` hand-back as its only call (it draws no lines at all). */
61export function isPingStep(answer: string, toolUses: readonly { name: string; input: unknown }[]): boolean {
62 if (isPing(answer, toolUses.length)) {
63 return true
64 }
65 const hb = handbackOf(toolUses)
66 return hb !== null && toolUses.length === 1 && maybePing(answer) && hb.trim() === '.'
67}
68
69/** Text streamed so far that may still be a ping's `.` answer. */
70export function maybePing(text: string): boolean {
71 const t = text.trim()
72 return t === '' || t === '.'
73}
74
75/** Planning after a tool call: on at a `Skill` call naming pa-planning, off at the `plan` call, else unchanged. */
76export function planningAfter(planning: boolean, tool: string, args: unknown): boolean {
77 if (tool === 'Skill' && typeof args === 'object' && args !== null) {
78 const o = args as Record<string, unknown>
79 const name = [o['skill'], o['name'], o['command']].find(v => typeof v === 'string')
80 if (typeof name === 'string' && /(^|[:/])pa-planning$/.test(name.trim())) {
81 return true
82 }
83 }
84 return tool === 'mcp__pa-watch__plan' ? false : planning
85}
86
87/** An agent's depth: the main loop 0, an agent it spawned (Claude, the partner) 1, theirs 2, by `parentId` chains. */
88export function depthOf(id: string, agents: readonly { id: string; parentId?: string }[]): number {
89 const byId = new Map(agents.map(a => [a.id, a]))
90 let depth = 1
91 let cur = byId.get(id)?.parentId
92 while (cur !== undefined && depth < 16) {
93 depth += 1
94 cur = byId.get(cur)?.parentId
95 }
96 return depth
97}
98
99/** The usage fields a step reports (`TurnUsage`; `cache_creation` is the API's TTL split, when present). */
100export type StepUsage = {
101 input_tokens?: number
102 output_tokens?: number
103 cache_read_input_tokens?: number
104 cache_creation_input_tokens?: number
105 cache_creation?: { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number }
106 model?: string
107}
108
109const n0 = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
110
111/** The context of a step: input + cache read + cache write. */
112export function ctxOf(u: StepUsage | null): number {
113 return u === null ? 0 : n0(u.input_tokens) + n0(u.cache_read_input_tokens) + n0(u.cache_creation_input_tokens)
114}
115hooks/tools.ts 183 lines1// The partner's seven tools `mcp__pa-watch__<name>` (agents/pa-claude.md, "The mod's tools"): their specs, input checks,
2// and the inbox request each good call becomes. `requestName` is a copy of the router's mod/pa/hooks/inbox.ts naming,
3// kept identical (plugins never import across each other); the same test vectors pin both. Pure.
4
5export type ToolName = 'plan' | 'ask' | 'topic' | 'release' | 'plan_edit' | 'steer' | 'cleanup'
6
7export const TOOL_NAMES: readonly ToolName[] = ['plan', 'ask', 'topic', 'release', 'plan_edit', 'steer', 'cleanup']
8
9const LATER = 'Returns at once; the answer arrives later as a message, so end your turn after calling it.'
10/** 3.23 T13d.c3: release, plan_edit and steer send no confirmation that only says done. */
11const QUIET = 'Returns at once and is taken; a message follows only when it is refused or changes the outcome, so end your turn after calling it.'
12const QUIET_KINDS: readonly string[] = ['release', 'plan_edit', 'steer']
13const NO_REPLY = 'Returns at once; no reply follows.'
14
15/** What `$.tool.register` takes for each tool. */
16export const TOOL_SPECS: readonly { name: ToolName; description: string; inputSchema: Record<string, unknown> }[] = [
17 {
18 name: 'plan',
19 description: `Presents the plan file at \`path\` to the developer in the plan-mode view. \`kind\` generation or phase: on approval the mod installs and commits it (a phase plan's \`rules\` and \`candidates\` lines are put to the developer one by one); a phase plan sent while REPLAN.md sits beside the approved plan is not shown: it is installed at the next task boundary; adhoc (the default): any other plan. ${LATER}`,
20 inputSchema: {
21 type: 'object',
22 properties: {
23 path: { type: 'string' }, kind: { type: 'string', enum: ['generation', 'phase', 'adhoc'] }, title: { type: 'string' },
24 rules: { type: 'array', items: { type: 'string' } }, candidates: { type: 'array', items: { type: 'string' } },
25 },
26 required: ['path'],
27 },
28 },
29 {
30 name: 'ask',
31 description: `Puts 2-4 short options to the developer as a picker; the choice is their option, their own words, or dismissed. ${LATER}`,
32 inputSchema: {
33 type: 'object',
34 properties: { question: { type: 'string' }, options: { type: 'array', items: { type: 'string' }, minItems: 2, maxItems: 4 } },
35 required: ['question', 'options'],
36 },
37 },
38 {
39 name: 'topic',
40 description: `The conversation has turned to a new subject; the next message goes to a fresh Claude seeded from the journal. ${NO_REPLY}`,
41 inputSchema: { type: 'object', properties: { summary: { type: 'string' } } },
42 },
43 {
44 name: 'release',
45 description: `Ends the hold you name (talk, review, question with a note for the expert, close with your phase report as the note, which the mod keeps as the phase's recap, stopped: the developer's stopped task resumed with the note, or started over with fresh: true; generation: the next generation's start, when the developer asks for it in this session; session: the loop waiting since this session began, when the developer wants it to go on; update: the PA3 update the mod found, installed after the current task, when the developer says in any words that they want it), so plan work may continue. ${QUIET}`,
46 inputSchema: {
47 type: 'object',
48 properties: { hold: { type: 'string', enum: ['talk', 'review', 'question', 'close', 'stopped', 'generation', 'session', 'update'] }, note: { type: 'string' }, fresh: { type: 'boolean' } },
49 required: ['hold'],
50 },
51 },
52 {
53 name: 'plan_edit',
54 description: `A plan change, as the exact arguments of a tools/plan_edit.py call: during a review or question stop it is a decision, applied at once; otherwise it lands at the next task boundary. Refused, or applied but not committed: a refused: or applied: line. ${QUIET}`,
55 inputSchema: { type: 'object', properties: { command: { type: 'string' }, why: { type: 'string' } }, required: ['command'] },
56 },
57 {
58 name: 'steer',
59 description: `A developer's correction for the running plan task \`task\` (T<n>), relayed to its expert, which tells its own coder what to stop, keep or undo; nothing is stopped. \`pause: true\` when the developer asks the work to pause (finish what runs, start nothing new): the pause is kept across a restart; \`pause: false\` when they want it to go on. Refused: a refused: line. ${QUIET}`,
60 inputSchema: { type: 'object', properties: { task: { type: 'string' }, text: { type: 'string' }, pause: { type: 'boolean' } }, required: ['task', 'text'] },
61 },
62 {
63 name: 'cleanup',
64 description: `3.29.8: the one-time .run/ cleanup's deletion. First write the exact items to delete as {"paths": [...]} (paths inside .run/) to .run/mod/cleanup.json; this call has the mod check them, ask the developer with its own picker, and on their yes delete exactly those items. \`summary\`: one line for the picker (count and size). ${LATER}`,
65 inputSchema: { type: 'object', properties: { summary: { type: 'string' } } },
66 },
67]
68
69type Obj = Record<string, unknown>
70
71function field(input: Obj, key: string, required: boolean): string | null {
72 const v = input[key]
73 if (v === undefined) {
74 return required ? `${key} is required` : null
75 }
76 if (typeof v !== 'string') {
77 return `${key} must be a string`
78 }
79 return required && v.trim() === '' ? `${key} is empty` : null
80}
81
82function lines(input: Obj, key: string): string | null {
83 const v = input[key]
84 return v === undefined || (Array.isArray(v) && v.every(x => typeof x === 'string')) ? null : `${key} must be an array of strings`
85}
86
87/** Why the input is bad, or null when it is good. */
88export function validateInput(name: string, input: unknown): string | null {
89 if (typeof input !== 'object' || input === null || Array.isArray(input)) {
90 return 'input must be an object'
91 }
92 const o = input as Obj
93 switch (name) {
94 case 'plan': {
95 const k = o['kind']
96 if (k !== undefined && !['generation', 'phase', 'adhoc'].includes(String(k))) {
97 return 'kind must be generation, phase or adhoc'
98 }
99 return field(o, 'path', true) ?? field(o, 'title', false) ?? lines(o, 'rules') ?? lines(o, 'candidates')
100 }
101 case 'ask': {
102 const q = field(o, 'question', true)
103 if (q !== null) {
104 return q
105 }
106 const opts = o['options']
107 if (!Array.isArray(opts) || opts.length < 2 || opts.length > 4) {
108 return 'options must be 2-4 strings'
109 }
110 return opts.every(x => typeof x === 'string' && x.trim() !== '') ? null : 'options must be non-empty strings'
111 }
112 case 'topic':
113 return field(o, 'summary', false)
114 case 'release': {
115 const h = field(o, 'hold', true)
116 if (h !== null) {
117 return h
118 }
119 if (o['fresh'] !== undefined && typeof o['fresh'] !== 'boolean') {
120 return 'fresh must be a boolean'
121 }
122 return ['talk', 'review', 'question', 'close', 'stopped', 'generation', 'session', 'update'].includes(String(o['hold'])) ? field(o, 'note', false) : 'hold must be talk, review, question, close, stopped, generation, session or update'
123 }
124 case 'plan_edit':
125 return field(o, 'command', true) ?? field(o, 'why', false)
126 case 'cleanup':
127 return field(o, 'summary', false)
128 case 'steer':
129 if (o['pause'] !== undefined && typeof o['pause'] !== 'boolean') {
130 return 'pause must be a boolean'
131 }
132 return field(o, 'task', true) ?? field(o, 'text', true)
133 default:
134 return `unknown tool ${name}`
135 }
136}
137
138/** The tool's own arguments of a `tool.call` envelope (they are spread beside `tool`, `tool_use_id`, `agentId`). */
139export function argsOf(e: Readonly<Obj>): Obj {
140 const out: Obj = {}
141 for (const [k, v] of Object.entries(e)) {
142 if (k !== 'tool' && k !== 'tool_use_id' && k !== 'agentId' && k !== 'consent') {
143 out[k] = v
144 }
145 }
146 return out
147}
148
149const KIND = /^[a-z][a-z0-9_]*$/
150
151/** The file name of a request; its id is the name without `.json` (copy of the router's). */
152export function requestName(ms: number, seq: number, kind: string): string {
153 if (!KIND.test(kind)) {
154 throw new Error(`bad request kind: ${kind}`)
155 }
156 return `${String(ms)}-${String(seq)}-${kind}.json`
157}
158
159/** The request body the router's drain parses: `{id, kind, caller, input, at}`. */
160export function requestBody(id: string, kind: string, caller: string, input: unknown, at: number): string {
161 return JSON.stringify({ id, kind, caller, input, at }, null, 1)
162}
163
164/** The tool's answer to a queued request. */
165export function queuedReply(kind: string, id: string): string {
166 return kind === 'topic'
167 ? `queued ${kind} ${id}; no reply will follow`
168 : QUIET_KINDS.includes(kind)
169 ? `queued ${kind} ${id}; a message follows only if it is refused or changes the outcome — end your turn now`
170 : `queued ${kind} ${id}; the answer arrives later as a message — end your turn now`
171}
172
173/** A tool call's arguments in at most `n` characters, for the stream facts. */
174export function argSummary(args: unknown, n = 120): string {
175 let text: string
176 try {
177 text = JSON.stringify(args) ?? ''
178 } catch {
179 text = String(args)
180 }
181 return text.length > n ? `${text.slice(0, n - 1)}…` : text
182}
183hooks/trim.ts 53 lines1// 3.29.9: the watcher's half of the router's display trim. The router (pa) calls `$.session.compact` with instructions
2// `pa-trim {"k":…,"tail":[…]}`; the call skips the calling plugin's hooks, so this plugin answers it with the rows from
3// `k` on, after checking that the rows before `k` still carry the fingerprints the router saw. `fingerprint` and
4// `tailOf` are a copy of the router's mod/pa/hooks/trim.ts, kept identical.
5
6type Row = { role: string; text: string; toolUses?: readonly unknown[]; toolResults?: readonly unknown[] }
7
8export const TRIM_PREFIX = 'pa-trim '
9const TAIL = 3
10
11function fnv(text: string): string {
12 let h = 0x811c9dc5
13 for (let i = 0; i < text.length; i += 1) {
14 h ^= text.charCodeAt(i)
15 h = Math.imul(h, 0x01000193) >>> 0
16 }
17 return h.toString(16).padStart(8, '0')
18}
19
20/** One row's fingerprint: role, text length, tool block counts and a hash of the text (copy of the router's). */
21export function fingerprint(r: Row): string {
22 return `${r.role.slice(0, 1)}${String(r.text.length)}.${String(r.toolUses?.length ?? 0)}.${String(r.toolResults?.length ?? 0)}.${fnv(r.text)}`
23}
24
25/** The fingerprints of up to TAIL rows before index `end`, oldest first (copy of the router's). */
26export function tailOf(rows: readonly Row[], end: number): string[] {
27 return rows.slice(Math.max(0, end - TAIL), Math.max(0, end)).map(fingerprint)
28}
29
30/**
31 * The cut point a `pa-trim` request names, checked against `rows`: `k` when 0 < k < rows.length, a non-empty tail
32 * matches the rows just before it and rows[k] is a user row; -1 when it does not validate; null when `instructions` is
33 * not a `pa-trim` request.
34 */
35export function trimCut(instructions: string | undefined, rows: readonly Row[]): number | null {
36 if (instructions === undefined || !instructions.startsWith(TRIM_PREFIX)) {
37 return null
38 }
39 let o: unknown
40 try {
41 o = JSON.parse(instructions.slice(TRIM_PREFIX.length))
42 } catch {
43 return -1
44 }
45 const d = typeof o === 'object' && o !== null ? (o as Record<string, unknown>) : {}
46 const k = d['k']
47 const tail = Array.isArray(d['tail']) ? (d['tail'] as unknown[]) : []
48 if (typeof k !== 'number' || !Number.isInteger(k) || k <= 0 || k >= rows.length || tail.length === 0 || rows[k]?.role !== 'user') {
49 return -1
50 }
51 return tailOf(rows, k).join('|') === tail.join('|') && tail.every(t => typeof t === 'string') ? k : -1
52}
53types/index.d.ts 25 lines1// The watcher's contract: its $.state values (they survive a hot reload, not /clear). Self-contained: validate refuses
2// imports here. No noun on $: the watcher writes requests to the router's inbox as files.
3
4/** Whether pa-watch is active this session. Also keeps this file a module (3.23 T13d.c11: `WatchLevel` retired;
5 * 3.24 T13C: `Prices` and the `prices` state retired with the usage line). */
6export type Active = boolean
7
8/** 3.24 T17b: one ladder row, as T17a's resolver prints it (`pa_ledger.py ladder-row --agent <type> --project <dir>
9 * --json`): `managed` false for an agent outside the ladder; `source` `credentials`, `preset` or `default`. */
10export type LadderRow = {
11 agent: string; managed: boolean; model: string; effort: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | ''; tier: string; source: string
12}
13
14/** 3.24 T17b: the rows resolved this session, by agent type (one tier: a planner spawn's new tier drops the others). */
15export type LadderRows = Record<string, LadderRow>
16
17declare module 'claude-code' {
18 interface PluginState {
19 'pa-watch': {
20 active: Active
21 ladder: LadderRows
22 }
23 }
24}
25