SLOPSHOPPER

Director

Your CLAUDE.md, actually followed: compile your instructions into linked atomic statements, plan every task by walking them, and learn from corrections through…

bandguardcommandtoaststatusprompt
★ 457v0.2.6Apache-2.0updated 2026-10-24EvolvingAgentsLabs/evolving-agents/packages/director
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · director
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /director-activity ⎿ director: director activity above the prompt: hidden ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

director

The director remembers how your team works and makes every agent session work that way. It keeps your decisions, conventions and methods as a small memory of linked rules (When / Do / Because); before an agent plans, it walks that memory and hands the agent the rules that apply, and the plan cites them; it sends each task to the right place — the frontier model, a model on your machine, another tool — and refuses to send confidential work anywhere you have not cleared; and after a task it turns your corrections into lessons that you accept or reject, each one a versioned change you can undo. It ships as a Claude Code plugin (this folder) and as a local harness with a two-lane interface.

What to expect. With today's strongest models the director does not add general good practice — the model already has it. It pays where the model cannot know: your own decisions and conventions, your privacy rules, and an auditable record of what was learned. Every measurement, the flat ones included, and the status history: how it works, and what we measured.

<img src="docs/img/results.png" alt="Three bar charts. D0, planning the team's way on 14 situations: today's session 0.589, today's session told to walk its files 0.768, the director 0.929. D2, learning from tasks on 8 held-out probes: before 0.0, after learning 0.781, and D0 re-checked afterwards 0.964. D3, learning the lane: public-facing drafts sent to the frontier before 0.0, after one move 1.0, and the internal control kept local 1.0." width="100%">

Where it comes from

Left, a blueprint of an elaborate machine drawn only in outline and never built. Dashed ochre lines carry each of its parts across to the right, where the same parts stand real on one bench: a chart table of linked index cards with a red route, a card-catalogue drawer, two brass dispatch tubes leaving for a window, a small brass box with a lever, and a ledger whose lines end in wax seals.

The Evolving Agents Toolkit (2025, legacy/eat/) had a SystemAgent, ArchitectZero, Smart Memory, a SmartLibrary and a SmartAgentBus, Firmware and an EvolutionStrategist — and three test functions. The director keeps each idea and pins it to a measurement: the SystemAgent plans by walking the memory and leaves the loop to the harness; ArchitectZero's design comes from the team's own rules; Smart Memory becomes linked When / Do / Because statements; the library and the bus become lanes; Firmware becomes rules enforced in code; evolution becomes a lesson that passes a gate and lands as an agentvcs patch. The table, piece by piece: how it works; the analysis: docs/EVOLUTION.md.

How it works, in three pictures

It plans by walking the memory. The index names the situations a task is in; each entry statement links, inside its own line, to the next one that applies; the plan's every step cites the statement it follows.

An open index book with one line underlined; a red-pencil route leaves it and steps across five index cards, leaving each from its highlighted line, to a plan of three items, each tagged back to the card it came from.

It learns from a task without breaking what it knew. After a task, the correction it received becomes a drafted statement; the statement passes a gate (it lints clean, and a person accepts it); the edit is sealed in agentvcs's ledger as a patch; the statement is filed where the next walk will find it (D2, D2b).

A finished work order with a correction pinned to it; a new index card drafted at a desk; the card checked at a small brass gate; a ledger line closed with a fresh seal; the card filed in a cabinet drawer; a dashed arc back to the start.

It learns where work runs. A plan waits; the user can move any task between the frontier and the local lane; the move goes into the next reflection, and the director plans the next similar task in that lane (D3).

A dispatch board with two columns of card slots: the left bordered in rust under a window with a distant domed building, the right in green under a small local machine. One card moves mid-air from the right column to the left; a lever below is thrown; the card beside its empty slot stays put.

Images: docs/img/ — the plates' prompts are in the repository's docs/img/IMAGES.md; results.png is drawn by docs/img/results.py from the briefs' numbers.

Where it sits

piecereuses
operational memory, atomic statements with linksthe memory design of lora-kernel (pages of verifiable statements; the weights hold the navigation, the base holds the content)
frontier laneClaude Code, driven as claude -p — the isolated, audited way agentvcs merge resolve already runs it
local lanelora-kernel's gateway on llama.cpp: local experts that abstain to the frontier
refining without breakingagentvcs: each memory edit is a gated patch; blame says which edit moved a score, bisect finds the one that broke it

The plan, in gates

stepwhatgate
D0today's session vs a session that walks the compiled memory, on real situationsbaseline below 0.85 (headroom), then treatment ≥ +0.15 — brief
D1the director's loop and its interface: the director on top (capabilities, plan, conversation), below it the frontier lane (left) and the local lane (right), from one event logbuilt — below
D2learning: memory edits proposed after each task, gated on D0's suite, versioned in agentvcslearns — brief
D3the user moves a task between lanes; the move becomes a routing statement the director learnslearns the lane — brief
D4–D7Accept guarded: an edit is kept only if it breaks none of the cases that relied on what it changes, and its own probes show it changes the plantargeted guard + positive probe — D4, D5, D6, D7 (below)
P0the walk inside Claude Code, as a plugin0.929, the same as the director — brief
D8–D10can a local 12B walk the memory itself?not navigable: core recall 0.32 → 0.45, precise (~0.9), but it reaches too little of what each case needs — D8, D9, D10
D11–D12do the 12B's plans hold up, on a bank no strategy was designed on?memory helps, gap remains: 0.68 against Sonnet's 0.92; a plan composed by code did not close it — D11, D12
D13–D14retrieval in code (lexical + local embeddings), the plan written by Clauderetrieval partial (0.77 / 0.74); the split planned 0.837 vs 0.892 at 17× less per plan, short of its gate by 0.005 — D13, D14
D15Claude writes meta-recipes from the memory; code runs them; the local model only picks a recipe and answers yes/norecipes work with the 12B: 0.806 vs 0.854, 3 / 3 / 6 per case; not yet with 4B (0.58) or 2B (0.26) — brief
D16the lanes' facts as one directory of data the statements cite, against the same facts in prose (EVOLUTION step 2)directory loses: routes as well (0.880 vs 0.864) and D0 holds (0.929), but 5 clearance violations vs 1 and a lane going down followed at 0.762 / 0.786 against 0.8 / 0.9; not built — brief
D17gated evolution of the recipes from D15's own runs (EVOLUTION step 3)brief written; runs on the lab's machine (Ollama) — brief
Hthe director with no person present: a verifier's verdicts in the journal, lessons consolidated and accepted by a declared policy (verified: only from sessions whose verifier passed; all: the ablation)built and tested offline, not yet run live — below
—where the director should beat other memories, and why: five hypotheses (verified gating, methods with their checks, editing in place, recall, cost), what would show each, and where it should not winwritten before the bench measures — analysis
DB0the director-bench pilot: one family, no memory / native memory / the director, 3 seeds, 90 sessionsbrief written — headroom (no-memory repeats its errors) and spread before the full run — brief
P1an anthropic-managed lane: Managed Agents behind submit / status / fetch, the memory mounted read-only, the walk as a skill, the gate over MCPplanned — D0 through it within 0.03 of 0.929; cost per plan published (EVOLUTION §5)
P2an antigravity lane: the same artifact on Google's harnessplanned — the same bar, and D0's plans judged by the other provider

As a Claude Code plugin

/plugin install director --marketplace EvolvingAgentsLabs/evolving-agents

This folder is the plugin (.claude-plugin/plugin.json). Claude Code keeps running the session; the plugin adds only what it lacks:

piecewhat it does
/director:compilecompiles your CLAUDE.md / AGENTS.md, auto-memory, agents and skills into .director/memory/ (asks first; ~US$5 for ~60 files)
SessionStart hookwhere that memory exists, puts the walk (DIRECTOR.md) into the session: every plan walks the memory and cites what it follows; since 0.2.6 the walk does not end with the plan — at each decision point of the work (a definition, a convention, a value someone will score or reuse) the session searches the memory again and follows it, and what it hands over cites the statements it followed and lists the choices the memory does not hold; it also says how many of the person's messages and corrections no reflection has read yet, and when the session is running an older director than the one installed (restart Claude Code to load it; claude --resume keeps the conversation); silent elsewhere
UserPromptSubmit hookevery message the person sends goes to the episodic journal as a person entry (keys scrubbed), so a decision made in passing is not lost before /director:reflect reads it; nothing reaches the memory except through the gate. If the installed director changes under a running session, the next prompt says so (restart; claude --resume), once per installed version per session
PreToolUse hook (Bash)the public guard: a git commit, git push or gh pr/gh issue text bound for a repository not listed as private is refused when what it publishes carries a term from a private list or something shaped like a key — enforced in code (director/public_guard.py; silent without its private config)
/director:reflectreads the journal since the last reflection and turns the person's corrections and decisions (a model, a cap, a tool, "from now on …"), the methods a task reached only after failed attempts (steps with the check that caught each failure, without that task's values) and, since 0.2.6, the scored failures of the agents that did the work (items failing in more than one run: a reference to check, or a definition the team never fixed, proposed as a convention for the person to decide) into edits plus two probes; a lesson the plain runs already follow is reported as redundant, not proposed; nothing touches the memory until you say yes
/director:recipeswrites (or rewrites) the meta-recipes a local model follows to plan from this memory (recipes.json beside the pages), then lints them and reports how much of the memory they cite (D15); stamps them with the memory version they were written from, so recipes_lint, plan_local and the status line say when an accepted lesson has made them stale
MCP server directormemory_propose (a candidate copy, linted, diffed), memory_check (D7's positive probe, in the background: TEACHES / REDUNDANT / NO EFFECT), memory_accept (your yes, a clean lint and — if checked — TEACHES; versioned as a gated agentvcs patch when agentvcs is installed; a proposal made before another lesson was accepted is rebased onto the live memory, same id, when none of the statements or Index lines it touches changed — memory_status reports rebased_from and the new diff — and refused with conflict naming them when they did, since accepting would undo what came since; an edit whose situations hold a whole Index line (→ or [[) is refused), memory_reject (your no, or a conflicting proposal closed), memory_lint, memory_status; recipes_lint and plan_local (a plan made by following the recipes with a local model on Ollama, DIRECTOR_LOCAL_MODEL, default gemma4:12b-mlx; free, and abstain when no recipe fits); lanes_list, plan_propose, plan_status, delegate, lane_status, lane_fetch (lanes, below); journal_read and journal_note (the episodic journal, below)
status line and band (hooks/ui.tsx)where that memory exists, the status line reads director · 286 statements · 3 learned · 1 to decide · 4 to reflect; when a plan waits, a band above the prompt shows each task with its lane (Run, Move, Cancel); when a lesson waits for a decision, it shows its edits and its check with Diff, Accept and Reject (Accept is your yes, through the same gate as memory_accept; it is offered only when the proposal lints clean and its check, if one ran, says TEACHES), and each delegation not yet brought back with Status and Fetch. What you press reaches the session with your next prompt. Needs a Claude Code with plugin UI modules; on older versions everything else works as before
live activity (hooks/ui.tsx)every tool call the session makes is shown as it runs, on its lane: the status line adds frontier ▶ <what runs now> (12s…) │ local ▶ <plan_local or a delegation>, and the band above the prompt shows Director · activity — the walk through the memory (walk → Headroom), the director's own tools, the journal's latest events and each lane's last step. /director-activity hides or shows the band; the status line always keeps the lanes

What you see in Claude Code. No other window: the director lives in the session. The status line names the memory and what waits for you; a band appears above the prompt only when something does. An illustration, not a capture:

╭─ Director · a plan to run ───────────────────────────────────────────╮
│ Draft the 0.7 release note and summarise this week's internal log    │
│ frontier  Draft the release note (public)                            │
│ local     Summarise the week's log                          [Move]   │
│ [Run]  [Cancel]                                                      │
├─ Director · a lesson to decide ──────────────────────────────────────┤
│ Screenshots are cropped before publishing                            │
│ add Confidentiality#crop_screens · check: TEACHES                    │
│ [Diff]  [Accept]  [Reject]                                           │
╰──────────────────────────────────────────────────────────────────────╯
> _
  director · 291 statements · 3 learned · 1 to decide · 1 plan waiting

Nothing is delegated before Run; Move offers only a lane the task is cleared for; Accept appears only when the lesson lints clean and its check, if one ran, says TEACHES. What you press reaches the session with your next prompt ("On the director's band, the person: moved task t2 from local to frontier"). The memory is found at $DIRECTOR_MEMORY, else .director/memory/ in the project, else memory/; with none, the plugin stays silent. To use a memory kept elsewhere: DIRECTOR_MEMORY=~/path/to/memory claude.

Measured [ran] 2026-10-17 (P0): the walk through the plugin's hook plans D0's 14 situations at 0.929, the same as the director driving Claude Code (T − P = 0.0; 27 of 28 plans cite the statements they follow), against 0.589 for a session without it.

Lanes. The session is the frontier lane. Other lanes are commands named in a private config ($DIRECTOR_LANES, default ~/.config/director/lanes.json: submit, status and fetch per lane), so the public code knows nothing about what they run. With a person at the session, the plan waits for them: plan_propose puts each task with its lane on the band, where they press Run, Move (the next lane allowed for that task) or Cancel; nothing is delegated before Run, each move is logged in .director/lane-moves.jsonl for the next /director:reflect (D3 learned the lane from such moves), and plan_status tells the session what they did. delegate still sends one task straight to a lane; lane_status and lane_fetch follow it, and the band shows each delegation until it is fetched. A confidential task — flagged, or with an input under a confidential_paths folder — is refused by every lane without "confidential_ok": true, the frontier included: enforced in code (director/lanes.py), not only as a learned statement.

Other harnesses, next. Claude Code is one harness; Managed Agents and the Antigravity agent are the same kind, hosted. All three load SKILL.md and call MCP servers and the memory is plain files, so a managed lane is an adapter behind the same three commands — the memory mounted read-only, lessons returned through the gate, and no cloud lane cleared for confidential work unless you clear it (P1, P2 above).

The chart table, a brass gate and a sealed ledger. Three glass-fronted, locked card drawers travel from it: to a writing desk close by, joined by a solid green cord, and to two distant buildings on hills joined only by dashed ochre lines. From each place a single index card returns to the brass gate.

Not in the plugin: the targeted guard (its cases are this lab's; a project has none until it has run some), lora-kernel's local lane and the two-lane web interface — those stay in director.server below; the band carries the interface's Accept / Reject into the session. Tests: tests/test_plugin.py, tests/test_ui_state.py, tests/test_recipes.py, tests/test_lanes.py, tests/test_plans.py, tests/test_journal.py, tests/test_versions.py, tests/test_headless.py; the band: claude plugin test ..

Without a person: director.headless

A benchmark run or a nightly job has no one to say yes. director.headless gives it the same gate with the decision declared instead of asked, and keeps everything in one store directory (<store>/.director/memory, the journal and the proposals), so a caller that keeps only that directory between sessions keeps the whole memory:

python -m director.headless init --memory STORE                       # an empty memory, once
claude -p "<task>" --plugin-dir packages/director ...                 # the session, with DIRECTOR_MEMORY=STORE/.director/memory
python -m director.headless outcome --memory STORE --task 03 --passed true --trace session.jsonl
python -m director.headless consolidate --memory STORE --policy verified   # or --policy all; --check to require TEACHES
  • outcome writes the verifier's verdict to the journal with a digest of the session's trace: its tool calls in order, the tool errors it met, its final words, its turns.
  • consolidate runs the reflection with Claude Code on the sessions since the last one — under verified only those whose verifier passed (the model never sees the others), under all every one — and each proposal it makes is accepted by gate.accept_by_policy: clean lint and, with --check, TEACHES. The journal records decided_by: policy:verified (or all); the last line printed carries the reflection's tokens and cost.
  • The person's gate is untouched: memory_accept still needs the user's yes, and accept_by_policy is reachable from no MCP tool and no band (a test holds that line).

Built and tested offline with a fake Claude Code (tests/test_headless.py); not yet run live — DB0 runs it first by hand on one real session.

Running it (D1)

Your own memory first. director.compile reads only what you name — your CLAUDE.md / AGENTS.md, Claude Code's auto-memory directory, any folder of agents or skills — compiles it with Claude Code (Opus by default; one compile of ~60 files cost about US$5) into pages of atomic statements and an index of situations, lints them, and writes a config if you have none:

Long paper scrolls and notebooks feed a brass binding press; out of it come index cards, filed in a small card-catalogue drawer, a few of them linked by ink lines.

python -m director.compile --claude-md ~/your-project/CLAUDE.md \
    --memory-dir ~/.claude/projects/<your-project>/memory --workdir ~/your-project
python -m director.server --config ~/.config/director/director.json      # http://127.0.0.1:8740

The config is private JSON: memory_dir (the compiled pages, Index.md among them), workdir (where frontier tasks run), log (the event log), local_base_url/local_model (an OpenAI-compatible server), director_model, frontier_model, frontier_tools, plan_budget_usd, task_budget_usd; optionally guard (D0 behind Accept, below), local_kind: "lora-kernel" with lora_kernel (the local lane through lora-kernel's specialists, below) and agentvcs (the executable that versions the memory).

  • Top: the director — its capabilities (pages, statements, the situations it knows how to enter), the memory's current agentvcs version, its plan (each task with its lane and the statements it follows), the conversation and its walk through the memory.
  • The plan waits for Run plan. Before that, any task can be moved to the other lane (→ local / → frontier); the move is logged (lane-moves.jsonl) and becomes part of what the director reflects on.
  • After a task, Reflect — with an optional correction. The director proposes memory edits; each proposal shows its edits and lint result, with Accept / Reject. Accept runs the accept gate (the candidate lints clean and a person accepted it), records the edit as an applied agentvcs patch (<log dir>/vcs) and makes it the live memory. Reject leaves the memory as it was.
  • Bottom left: what it delegated to Claude Code, streamed (claude -p --output-format stream-json). Read-only tools (Read, Grep, Glob) unless the config grants more.
  • Bottom right: what it delegated to local models. The director never starts a local model: running one on the user's machine is the user's decision. With no server answering, the planner is told the lane is offline, and a local task that still reaches it is logged offline.

[ran] 2026-10-14, one real request ("check that agentvcs's two READMEs state the same number of golden cases; draft a changelog note"): the director walked 6 pages, planned 2 tasks citing 6 statements, put both on the frontier because the local lane was offline, and the frontier verified 117 = 117 = the cases on disk. Tests: python -m pytest tests -q (a fake claude and a fake local server; in CI).

Accept is guarded by D0, targeted ("guard": {"enabled": true, "mode": "targeted", "k": 3, "config": "<private d0 config>"}, D6): the guard re-plans only the D0 cases whose plans relied on a statement the edit removes or changes in

Source 2 files
hooks/ui.tsx 369 lines
1// The director's band and status line. The status line names the memory and what waits for a decision; the band
2// above the prompt shows a plan waiting to run (each task's lane; Run / Move / Cancel), a proposed lesson (its edits,
3// its check, Diff / Accept / Reject) and the delegations not yet brought back. Both read director/ui_state.py and stay silent where the project has no director memory.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { Snapshot, Step } from '../types'
8
9const snap = atom({ plugin: 'director', key: 'snap' } as const, null)
10const diff = atom({ plugin: 'director', key: 'diff' } as const, null)
11const busy = atom({ plugin: 'director', key: 'busy' } as const, null)
12const message = atom({ plugin: 'director', key: 'message' } as const, null)
13const notes = atom({ plugin: 'director', key: 'notes' } as const, [] as string[])
14const steps = atom({ plugin: 'director', key: 'steps' } as const, [] as Step[])
15const verbose = atom({ plugin: 'director', key: 'verbose' } as const, true)
16
17const TOOL_PREFIX = 'mcp__plugin_director_director__'
18
19export function statusText(s: Snapshot | null, live: Step[] = [], now = Date.now()): string | undefined {
20  if (!s || !s.memory) return undefined
21  if (s.error) return `director · ${s.error.slice(0, 60)}`
22  const n = s.statements ?? 0
23  const parts = [`director · ${n} statement${n === 1 ? '' : 's'}`, `${s.accepted ?? 0} learned`]
24  const waiting = s.proposals?.length ?? 0
25  if (waiting) parts.push(`${waiting} to decide`)
26  const plans = s.plans?.length ?? 0
27  if (plans) parts.push(`${plans} plan${plans === 1 ? '' : 's'} waiting`)
28  const out = s.delegations?.length ?? 0
29  if (out) parts.push(`${out} delegated`)
30  if (s.recipes_stale) parts.push('recipes stale')
31  if (s.to_reflect) parts.push(`${s.to_reflect} to reflect`)
32  const lanes = laneText(live, s, now)
33  return lanes ? `${parts.join(' · ')} │ ${lanes}` : parts.join(' · ')
34}
35
36const MEMORY_PATH = /\/\.director\/memory\/([^/]+?)\.md$/
37
38// One tool call, seen live: which lane it belongs to and a short label. The director's own steps are its MCP tools
39// and the walk (a read of one of its memory's pages); everything else the session does is the frontier lane, and a
40// plan made on the local model (plan_local) or a task sent to a lane other than the frontier is the local lane.
41export function classify(tool: string, args: Record<string, unknown>): { lane: Step['lane']; label: string } {
42  const str = (k: string) => (typeof args[k] === 'string' ? (args[k] as string) : '')
43  const path = str('file_path') || str('path')
44  const page = path.match(MEMORY_PATH)
45  if (page) return { lane: 'director', label: `walk → ${page[1]}` }
46  if ((tool === 'Grep' || tool === 'Glob') && path.includes('/.director/memory'))
47    return { lane: 'director', label: `search memory: ${str('pattern')}`.slice(0, 80) }
48  if (tool.startsWith(TOOL_PREFIX)) {
49    const name = tool.slice(TOOL_PREFIX.length)
50    if (name === 'plan_local') return { lane: 'local', label: `plan_local: ${str('request')}`.slice(0, 80) }
51    if (name === 'delegate') return { lane: 'local', label: `→ ${str('lane')}: ${str('task')}`.slice(0, 80) }
52    const detail = str('rationale') || str('kind') || str('id') || str('request')
53    return { lane: 'director', label: `${name}${detail ? `: ${detail}` : ''}`.slice(0, 80) }
54  }
55  const base = (f: string) => f.split('/').pop() || f
56  const label =
57    tool === 'Bash'
58      ? str('description') || str('command').split('\n')[0]
59      : tool === 'Read' || tool === 'Edit' || tool === 'Write'
60        ? `${tool} ${base(path)}`
61        : tool === 'Grep' || tool === 'Glob'
62          ? `${tool} ${str('pattern')}`
63          : tool === 'Agent'
64            ? `subagent: ${str('description')}`
65            : tool.startsWith('mcp__')
66              ? tool.split('__').slice(-1)[0]
67              : tool
68  return { lane: 'frontier', label: `${tool === 'Bash' ? '$ ' : ''}${label}`.slice(0, 80) }
69}
70
71function ago(step: Step, now: number): string {
72  const secs = Math.round(((step.ms ?? now - step.t) as number) / 1000)
73  return step.state === 'running' ? `${secs}s…` : step.state === 'error' ? 'failed' : `${secs}s`
74}
75
76// The status line's live half: what each lane is doing now, or did last.
77export function laneText(live: Step[], s: Snapshot | null, now = Date.now()): string {
78  const last = (lane: Step['lane']) => [...live].reverse().find(x => x.lane === lane)
79  const f = last('frontier')
80  const l = last('local')
81  const out = (s?.delegations ?? []).find(d => !d.done)
82  const local = l ? `${l.label} (${ago(l, now)})` : out ? `${out.lane}: ${out.id} (${out.status})` : 'idle'
83  if (!f && !l && !out) return ''
84  return `frontier ▶ ${f ? `${f.label} (${ago(f, now)})` : 'idle'} │ local ▶ ${local}`
85}
86
87type Result = { error?: string } & Record<string, unknown>
88
89let poll: { cancel: () => void } | null = null
90let tick: { cancel: () => void } | null = null
91
92// The status line again from what is already known (no subprocess): called on every step and, while a step runs,
93// every second, so the elapsed time moves.
94async function redraw($: EngineInterface): Promise<void> {
95  const live = await read($, steps)
96  $.ui.status(statusText(await read($, snap), live))
97  const running = live.some(x => x.state === 'running')
98  if (running && !tick) tick = $.clock.every(1_000, () => void redraw($))
99  else if (!running && tick) {
100    tick.cancel()
101    tick = null
102  }
103}
104
105async function record($: EngineInterface, step: Step): Promise<void> {
106  await update($, steps, list => [...list, step].slice(-40))
107  await redraw($)
108}
109
110async function finish($: EngineInterface, t: number, state: Step['state']): Promise<void> {
111  await update($, steps, list => list.map(x => (x.t === t ? { ...x, state, ms: Date.now() - x.t } : x)))
112  await redraw($)
113}
114
115async function run($: EngineInterface, args: string[]): Promise<Result> {
116  const root = await $.session.root()
117  try {
118    const r = await $.process.run(['python3', `${$.plugin.root}/director/ui_state.py`, ...args], {
119      cwd: root,
120      env: { CLAUDE_PROJECT_DIR: root },
121      timeoutMs: args[0] === 'fetch' ? 600_000 : 150_000,
122    })
123    return JSON.parse(r.stdout.trim().split('\n').pop() || '{}') as Result
124  } catch (err) {
125    return { error: `ui_state.py failed: ${String(err).slice(0, 200)}` }
126  }
127}
128
129async function refresh($: EngineInterface): Promise<void> {
130  const s = (await run($, ['status'])) as unknown as Snapshot
131  await update($, snap, () => s)
132  $.ui.status(statusText(s, await read($, steps)))
133  const checking = (s.proposals ?? []).some(p => p.check === 'RUNNING')
134  if (checking && !poll) {
135    poll = $.clock.every(20_000, () => void refresh($))
136  } else if (!checking && poll) {
137    poll.cancel()
138    poll = null
139  }
140}
141
142// An action from a button: run it, say how it went, tell the session on the next prompt, and re-read the state.
143async function act($: EngineInterface, args: string[], note: string | ((r: Result) => string)): Promise<void> {
144  const what = args.join(' ')
145  await update($, busy, () => what)
146  const r = await run($, args)
147  const text = r.error ? `${what}: ${r.error}` : `${what}: done`
148  await update($, message, () => text)
149  if (!r.error) await update($, notes, xs => [...xs, typeof note === 'string' ? note : note(r)])
150  await update($, busy, () => null)
151  $.ui.toast(`director: ${text}`)
152  await refresh($)
153}
154
155// What the session needs to hear after Run: where each task went, and which are its own to do now.
156export function runSummary(r: Result): string {
157  const results = (r.results ?? []) as { id: string; lane: string; status?: string; error?: string }[]
158  return results
159    .map(x =>
160      x.error
161        ? `${x.id} failed on ${x.lane} (${x.error})`
162        : x.lane === 'frontier'
163          ? `${x.id} is yours to do now`
164          : `${x.id} delegated to ${x.lane}`,
165    )
166    .join('; ')
167}
168
169async function showDiff($: EngineInterface, id: string): Promise<void> {
170  const r = await run($, ['diff', id])
171  await update($, diff, () => ({ id, text: String(r.diff ?? r.error ?? '') }))
172}
173
174export const register: Register = on => {
175  on('session.start', async ($, e, next) => {
176    const started = await next(e)
177    // A surface without slash commands still gets the status line and the band.
178    await $.command
179      .register({
180        name: 'director-activity',
181        description: "Show or hide the director's live activity above the prompt (the status line always shows the lanes)",
182      })
183      .catch(() => undefined)
184    await refresh($)
185    return started
186  })
187
188  on('command.run', { command: 'director-activity' }, async $ => {
189    const on_ = !(await read($, verbose))
190    await update($, verbose, () => on_)
191    return { text: `director activity above the prompt: ${on_ ? 'shown' : 'hidden'}` }
192  })
193
194  on('turn.complete', async ($, e, next) => {
195    const done = await next(e)
196    await refresh($)
197    return done
198  })
199
200  // Every tool call, live: recorded on its lane when it starts and closed when it ends, so the status line and the band
201  // show what each lane is doing at every moment. The director's own tools also re-read its state.
202  on('tool.call', async ($, e, next) => {
203    const { lane, label } = classify(e.tool, e as unknown as Record<string, unknown>)
204    const t = Date.now()
205    await record($, { t, lane: e.agentId && lane === 'frontier' ? 'frontier' : lane, label: e.agentId ? `[subagent] ${label}` : label, state: 'running' })
206    try {
207      const result = await next(e)
208      await finish($, t, 'deny' in result && result.deny ? 'error' : result.isError ? 'error' : 'done')
209      if (e.tool.startsWith(TOOL_PREFIX)) void refresh($)
210      return result
211    } catch (err) {
212      await finish($, t, 'error')
213      throw err
214    }
215  }).catch(($, e, next) => next(e))
216
217  // What the person did on the band reaches the model with their next prompt, so the session's picture stays true.
218  on('prompt.submit', async ($, e, next) => {
219    const pending = await read($, notes)
220    if (!pending.length || e.command) return next(e)
221    await update($, notes, () => [])
222    return next({ ...e, context: [...(e.context ?? []), `On the director's band, the person: ${pending.join('; ')}.`] })
223  }).catch(($, e, next) => next(e))
224
225  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
226    const s = await read($, snap)
227    const proposals = s?.proposals ?? []
228    const delegations = s?.delegations ?? []
229    const plans = s?.plans ?? []
230    const live = await read($, steps)
231    const showActivity = (await read($, verbose)) && (live.length > 0 || (s?.recent ?? []).length > 0)
232    if (e.props.hasSurvey || !s?.memory || (!proposals.length && !delegations.length && !plans.length && !showActivity))
233      return next(e)
234
235    const { Box, Button, Text, Code } = $.ui.resolve(e)
236    const shown = await read($, diff)
237    const working = await read($, busy)
238    const said = await read($, message)
239    const p = proposals[0]
240    const plan = plans[0]
241
242    const now = Date.now()
243    const directorSteps = live.filter(x => x.lane === 'director').slice(-5)
244    const journal = (s?.recent ?? []).slice(-3)
245    const laneLine = (lane: Step['lane']) => {
246      const x = [...live].reverse().find(y => y.lane === lane)
247      return x ? `${x.label} · ${ago(x, now)}` : 'idle'
248    }
249
250    return (
251      <Box flexDirection="column">
252        {showActivity && (
253          <Box flexDirection="column">
254            <Text bold>Director · activity</Text>
255            {directorSteps.map(x => (
256              <Text key={`step-${x.t}`} dimColor={x.state !== 'running'}>
257                {x.state === 'running' ? '▶ ' : x.state === 'error' ? '✗ ' : '✓ '}
258                {x.label}
259              </Text>
260            ))}
261            {journal.map(j => (
262              <Text key={`journal-${j.t}-${j.kind}`} dimColor>
263                · {j.kind}: {j.what}
264              </Text>
265            ))}
266            <Text>
267              <Text bold>frontier</Text> ▶ {laneLine('frontier')}
268            </Text>
269            <Text>
270              <Text bold>local</Text> ▶ {laneLine('local')}
271            </Text>
272          </Box>
273        )}
274        {plan && (
275          <Box flexDirection="column">
276            <Text bold>Director · a plan to run{plans.length > 1 ? ` (1 of ${plans.length})` : ''}</Text>
277            {plan.request ? <Text>{plan.request}</Text> : null}
278            {plan.tasks.map(t => (
279              <Box key={`task-${plan.id}-${t.id}`} flexDirection="row" gap={1}>
280                <Text>
281                  <Text bold>{t.lane}</Text> {t.task}
282                  {t.confidential ? <Text dimColor> (confidential)</Text> : null}
283                </Text>
284                {t.movable ? (
285                  <Button
286                    key={`move-${plan.id}-${t.id}`}
287                    label="Move"
288                    onPress={() =>
289                      act($, ['plan-move', plan.id, t.id], r => `moved task ${t.id} from ${t.lane} to ${String(r.to)}`)
290                    }
291                  />
292                ) : null}
293              </Box>
294            ))}
295            <Box flexDirection="row" gap={1}>
296              <Button
297                key={`run-${plan.id}`}
298                label="Run"
299                variant="primary"
300                onPress={() => act($, ['plan-run', plan.id], r => `ran plan ${plan.id}: ${runSummary(r)}`)}
301              />
302              <Button
303                key={`cancel-${plan.id}`}
304                label="Cancel"
305                onPress={() => act($, ['plan-cancel', plan.id], `cancelled plan ${plan.id} (nothing was delegated)`)}
306              />
307            </Box>
308          </Box>
309        )}
310        {p && (
311          <Box flexDirection="column">
312            <Text bold>Director · a lesson to decide{proposals.length > 1 ? ` (1 of ${proposals.length})` : ''}</Text>
313            {p.rationale ? <Text>{p.rationale}</Text> : null}
314            <Text dimColor>{p.edits.join(', ')}</Text>
315            <Text dimColor>
316              {p.check === null ? 'not checked' : `check: ${p.check}`}
317              {p.lint ? ` · ${p.lint} lint problems` : ''}
318            </Text>
319            {!p.acceptable ? <Text color="warning">Cannot be accepted: {p.why_not}</Text> : null}
320            {shown && shown.id === p.id ? <Code source={shown.text || '(no changes)'} format="diff" /> : null}
321            <Box flexDirection="row" gap={1}>
322              <Button
323                key={`diff-${p.id}`}
324                label={shown && shown.id === p.id ? 'Hide diff' : 'Diff'}
325                onPress={() =>
326                  shown && shown.id === p.id ? update($, diff, () => null) : showDiff($, p.id)
327                }
328              />
329              {p.acceptable ? (
330                <Button
331                  key={`accept-${p.id}`}
332                  label="Accept"
333                  variant="primary"
334                  onPress={() => act($, ['accept', p.id], `accepted proposal ${p.id} (now the live memory)`)}
335                />
336              ) : null}
337              <Button
338                key={`reject-${p.id}`}
339                label="Reject"
340                onPress={() => act($, ['reject', p.id], `rejected proposal ${p.id} (the memory is unchanged)`)}
341              />
342            </Box>
343          </Box>
344        )}
345        {delegations.map(d => (
346          <Box key={`lane-${d.id}`} flexDirection="row" gap={1}>
347            <Text>
348              {d.lane}: {d.id} <Text dimColor>{d.status}</Text>
349            </Text>
350            <Button
351              key={`refresh-${d.id}`}
352              label="Status"
353              onPress={() => act($, ['refresh', d.id], `asked lane ${d.lane} for the status of ${d.id}`)}
354            />
355            {d.done ? (
356              <Button
357                key={`fetch-${d.id}`}
358                label="Fetch"
359                onPress={() => act($, ['fetch', d.id], `fetched delegation ${d.id} from lane ${d.lane}`)}
360              />
361            ) : null}
362          </Box>
363        ))}
364        {working ? <Text dimColor>working: {working}…</Text> : said ? <Text dimColor>{said}</Text> : null}
365      </Box>
366    )
367  })
368}
369
types/index.d.ts 47 lines
1export type Proposal = {
2  id: string
3  rationale: string
4  edits: string[]
5  lint: number
6  check: string | null
7  probes: number
8  acceptable: boolean
9  why_not: string
10}
11
12export type Delegation = { id: string; lane: string; status: string; done: boolean }
13
14export type PlanTask = { id: string; task: string; lane: string; confidential: boolean; movable: boolean }
15
16export type Plan = { id: string; request: string; tasks: PlanTask[] }
17
18export type Snapshot = {
19  memory: string | null
20  statements?: number
21  accepted?: number
22  proposals?: Proposal[]
23  delegations?: Delegation[]
24  plans?: Plan[]
25  recipes_stale?: boolean
26  to_reflect?: number
27  recent?: { t: string; kind: string; what: string }[]
28  error?: string
29}
30
31/** One step seen live in this session: a tool call on a lane, or a step of the director's own. */
32export type Step = { t: number; lane: 'frontier' | 'local' | 'director'; label: string; state: 'running' | 'done' | 'error'; ms?: number }
33
34declare module 'claude-code' {
35  interface PluginState {
36    director: {
37      snap: Snapshot | null
38      diff: { id: string; text: string } | null
39      busy: string | null
40      message: string | null
41      notes: string[]
42      steps: Step[]
43      verbose: boolean
44    }
45  }
46}
47