SLOPSHOPPER

pa-watch

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…

newguardtoolprocessagents
★ 98v3.30.7NOASSERTIONupdated 2026-10-22Druthulu/ProjectArchitect/project-architect-3.0/mod/pa-watch
A shopper browsing a rack in a slop shop
README

<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.

What you get

  • A plan that outlives every session. Phases with a milestone a machine can check, task summaries, decisions, rules and techniques, all kept as files in your repository. A new session starts from them, not from a transcript.
  • Work that runs on its own between two gates. You approve the plan and confirm the close; in between, nobody stops to ask unless a decision is genuinely yours.
  • Savings you can see. A local ledger prices every request, and the statusline shows, live, what the helpers kept out of your context.
  • Models that fit your plan. Max 20x, Max 5x or Pro: PA3 reads which from your login and picks its models to match.
  • Cheaper coders when you want them. A task's coding can run on DeepSeek or GLM through OpenRouter, on your own key, while planning and judgment stay on Claude.

See it

<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.

Install

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.

  1. Once per machine. Download this repository (Code → Download ZIP, then extract) and open its 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.
  2. Once per repository. Open the repository in Claude Code: PA3 shows one line that sets it up, with the path already filled in. Type it, then restart Claude Code (/exit, then claude). Or double-click setup-project.cmd and pick the folder. It makes one commit and never pushes.
  3. Start. Open a bare 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.

How it works

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

Why Project Architect

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:

  • Memory that carries forward. Every session, and every expert inside one, starts from the files the work left behind (the plan, the task summaries, the decisions, the rules and techniques) instead of a long transcript. Nothing has to stay in context to be remembered.
  • Helpers that keep the weight off. Retrievers read the long files and return one answer, coders run the edit, build and test loops, and each expert starts its task with a small, fresh context. What they read never lands in your session, and the statusline counts what that kept out.
VanillaProject Architect
Average context per request459k to 547k tokens47k to 69k tokens
Context when the work starts100k to 300k tokensabout 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.

Learn more

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.

License

See LICENSE.

Source 11 files
hooks/register.ts 542 lines
1// 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}
542
hooks/activation.ts 116 lines
1// 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}
116
hooks/debug.ts 25 lines
1// 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}
25
hooks/edit.ts 263 lines
1// 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}
263
hooks/feed.ts 220 lines
1// 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}
220
hooks/journal.ts 112 lines
1// 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}
112
hooks/ladder.ts 102 lines
1// 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}
102
hooks/stream.ts 115 lines
1// 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}
115
hooks/tools.ts 183 lines
1// 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}
183
hooks/trim.ts 53 lines
1// 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}
53
types/index.d.ts 25 lines
1// 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