SLOPSHOPPER

AI-DLC Lens

Claude Code only: a live view of an AI-DLC run. The workflow pane draws each feature root of a run (its top-level step) as its own flowchart of agents…

newpanebandrowsguardtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ai-dlc-lens
│ ┃ ai-dlc-lens-edge ✕ › fix the failing auth test and add an audit log call │ ┃ – × │ ┃ Press an arrow under the flowchart. ⏺ 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 │ │ │ │ Nothing Here pinned ⤢ □ ↺ × ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Nothing Here pinned ⤢ □ ↺ × ⟨Claude Code's own drawing⟩
Pane · ai-dlc-lens-edge
– × Press an arrow under the flowchart.
Pane · ai-dlc-lens-agent
– × Press an avatar under the flowchart.
Pane · ai-dlc-lens-chart
Panel Type: ◉ Drill in ○ Tab pinned ⤢ × No workflow yet: it shows here when an agent is spawned.
Pane · ai-dlc-lens-kit
[1] Resize across the dock boundary surface=terminal placement=dock bodyColumns=56 viewport.columns=160 isFullscreen=true mode=flowchart (auto would be flowchart; override=auto) FLOWCHART [PO] -> [RA] -> [SA]+[SE] -> [SWE] -> [SQA] -> [CR] [ auto ][ compact ][ flowchart ] redraw log (last 8): #1 body=56 cols=160 dock fs=true -> flowchart [2] Svg animation (terminal: no Svg element; text fallback below) D text timer: ○ tick=false [3] Buttons (pane) [ [+] ] presses=0 [4] Transcript cards ToolUse cards drawn=0 ToolResult hooks passed through=0 [5] Gate alert classic.Stop seen by module=1 (the gate alert is now anomaly detection: /ai-dlc:lens-gate)
README

ai-dlc-lens

A Claude Code plugin that shows an AI-DLC run while it happens. When the orchestrator spawns agents, the AI-DLC workflow view appears: one workflow per run (the work item, its docs/handoffs/<run>/ folder), each a collapsible panel holding one flowchart per top-level step of the run (in progress, see Panels and controls), with its agents drawn as avatars (persona name, model, role and a status dot). A new run never hides an earlier one, and agents of a different run never join an earlier run's workflow. Pressing an avatar opens the agent's panel (usage, tokens, context window, cost, tool calls, background tasks, thinking); pressing an arrow opens the hand-off panel (the handoff record and the hand-back, or the prompt). The chat card of a workflow's first spawn names the workflow, draws its flowchart and opens the view.

  • Drill-in or tab. An avatar or arrow opens its panel inside the card or view it was pressed in, with < Back (drill-in, panelOpenMode's default), or as a separate pane (a tab, tab). The Panel Type switch ◉ Drill in ○ Tab changes it for the session (see Panels and controls).
  • Pinned or docked. Pinned (workflowPlace, the default) draws the view in the band above the prompt on the terminal and the desktop app; VS Code has no such band, so there it stays a pane. Docked draws it in the pane, which the surface places. The view's first row also has full screen, minimise and restore (back to the size before); no mod can make a pane full screen or minimise it, so these are drawn by the lens itself, as MANUAL-CHECKS.md (Step 1.5) explains. Full screen shows one step of one run alone.
  • Wide or narrow. From wideColumns columns (70) the runs are stacked as collapsible panels on the right, with the selected run's step charts beside them; narrower, the view lists the runs, one row each, and opens the one you choose.

In progress: this plugin is being built in the run FEAT-20261020-ai-dlc-lens, and its reviews are still open. It started as spike ITEM-027 (round 6) and has since been extended (steps 8.1.2 to 8.6.1.l1 of that run: robot avatars, keep-alive figures, the anomaly detector, Panel Type, full screen, one chart per step). It still goes through that run's scope pass and gates G1, G2 and G3 before it is released. Until then the commands, settings and panes can change. This README describes only what exists now.

Claude Code only. The lens is a Claude Code mod (a hooks module). The GitHub Copilot build (tools/build-copilot.mjs) skips this plugin, and only the Claude marketplace (.claude-plugin/marketplace.json) lists it. It does not depend on the ai-dlc plugin for its panes, and ai-dlc does not depend on it; the /ai-dlc:lens* commands are skills of ai-dlc. When ai-dlc is installed and its keep-alive is pinging, the workflow pane shows one keep-alive line under its controls (keep-alive: 5m ping 1/20, next 14:05:07), read-only from the state keep-alive publishes; without it, or with no track running, the line is not drawn. The minimum Claude Code version is decided at G2; the repository README.md (Setup) and MANUAL-CHECKS.md give the version the checks name, and neither is the final answer until then.

Install

From the repository root, with the ai-dlc-agent-workflow marketplace registered (see the repository's CLAUDE.md):

claude plugin marketplace update ai-dlc-agent-workflow
claude plugin install ai-dlc-lens@ai-dlc-agent-workflow --scope local

Then run /reload-plugins, or open a new session. For one terminal session without installing: claude --plugin-dir plugins/claude/ai-dlc-lens.

Commands

The commands are named by thin skills of the ai-dlc plugin, so Claude Code lists them as /ai-dlc:<name>; this plugin's hooks module answers each run itself (a hooks module can only register a name without :). Claude Code lists the commands only when ai-dlc is installed; typed in full without it, the lens still answered them in a headless run (claude -p "/ai-dlc:lens-gate off"). The panes and the chat card work with this plugin alone.

CommandWhat it does
/ai-dlc:lensOpens the lens pane (the older single-pane view). The workflow view appears on its own at the first spawn.
/ai-dlc:lens-init [--force]Writes .ai-dlc/lens.json with every default, and its schema.
`/ai-dlc:lens-configure [<key> [<value>\--unset]]`Shows the lens settings and the level each comes from, or sets or unsets one in .ai-dlc/lens.json.
`/ai-dlc:lens-keepalive [on\off]`Shows or hides the Cache keep-alive section of the agent panes (setting keepaliveSection, on by default), then reports its figures.
`/ai-dlc:lens-gate [on\off]`Turns anomaly reporting on or off (setting gateAlert); bare, it lists the current anomalies, then the runs it read and the anomalyRunScope in force. See Anomaly alert.
/ai-dlc:lens-kitOpens the round-0 kit pane (manual checks K1 to K5).

The Cache keep-alive section, after Usage in every agent pane and collapsed like every section, shows how long keep-alive kept the prompt cache warm on the 5m and 1h tracks (from each arming to the cache's expiry, read from the state ai-dlc publishes; 0s without it) and what cache reads saved, for the agent and for the session: cache-read tokens times the input price minus the cache-read price, from the model metadata file (costingPath), — when a price is missing.

The spike's experiments E1, E2, E4, E5, E7, E9 and E10 log to PROBE-LOG.jsonl in the plugin's folder while the probes setting is on (off by default). The pane ids are ai-dlc-lens, ai-dlc-lens-chart, ai-dlc-lens-edge, ai-dlc-lens-agent and ai-dlc-lens-kit (a pane id takes no :).

Panels and controls

  • Workflow view (title "AI-DLC workflow"). One collapsible panel per run; the selected run's step charts are drawn beside the list (wide) or under the run (narrow): see Step charts below. The first row has the pinned/docked switch, full screen, minimise and restore; below it is the Panel Type switch ◉ Drill in ○ Tab (drill-in by default; ◉ marks the current choice and each choice is its own button). The switch also appears in the /ai-dlc:lens pane, changes the session only and never writes .ai-dlc/lens.json. Every [+]/[-] section starts collapsed unless collapsedByDefault is off. Below each step chart are Avatars: and Arrows: button rows. The workflow's orchestrator thinking and gathered context are sections of the view.
  • Chat card. The first spawn of a workflow leaves a card that names it and draws its flowchart (chartInChat), or a chip with an "Open flowchart" button when the row is narrower than chartMode allows. In progress: when the card's agents work on unrelated tasks (a task is a run plus a feature root), the card draws one titled chart per task, with an agent that has no record (or a gone one) joining the first task's chart; with one task the card is unchanged.
  • Agent panel ("Agent: ..."). A header with the avatar, persona name, model and role, then these sections: Usage (context, input, output, cache read and write, uncached, cost, the model it ran on, limits from the metadata file), Cache keep-alive (see above; keepaliveSection), Tool calls, Background tasks (each task listed once, starting collapsed, and toggling on its own), Thinking; and the buttons View transcript and Refresh.
  • Hand-off panel ("Hand-off: ..."). The arrow's Handoff section, Hand-back section and Prompt section (the prompt when the brief names no record), and Usage.
  • Status. A dot per run and a ring per agent. Colours (settings, hex): in progress statusInProgressColor (blinks, statusBlink), pending statusPendingColor, halted statusHaltedColor (an agent failed or was killed, or the run is blocked), waiting statusWaitingColor, completed statusCompletedColor.
  • Keep-alive line. One dim line under the controls row, keep-alive: 5m ping 1/20, next 14:05:07, only while ai-dlc's keep-alive runs a track. The minimised one-line view has none.
  • Layout. Wide (from wideColumns): runs stacked on the right, the selected run's step charts on the left, runListColumns wide. Narrow: a list of runs, one row each; choosing one opens it and < Runs goes back. Every run stays listed; finished runs are dimmed. In progress: the list (wide and narrow) and the full-screen Runs: row are ordered newest first by each run's start: its first spawn, else the date in its folder name, else started: in its run.md. A run resumed later keeps its place, and the selected run stays selected.
  • Parent runs. In progress: a run whose run.md front matter has parent: <run id> is drawn inside its parent's workflow, its step charts titled <child run>: Step N (a child keeps its own step numbers), and is not listed on its own. A chain of parents goes to the top run; an unknown parent or a cycle keeps the run as its own. The merged run is ordered by the parent's start, and a child's anomalies keep the child run's name.
  • Merged-away runs. In progress: a brief that names a run folder which no longer exists joins the run that keeps a merged-from-<run>.md record for it, at the step shifted by the merge offset (when run.md has the Offset table).
  • Chart rows wrap. In progress: a chart row holds at most chartAvatarsPerRow avatars (default 8, see Settings); a level with more wraps onto further rows. It applies inside each step chart, the chat card and full screen (full screen enlarges the sizes, not the count).
  • Run identity. A workflow is a run (its docs/handoffs/<run>/ folder). Agents of another run never join it; when a spawn names no run, the same request text joins, else it starts a new workflow.
  • Step charts. In progress: a run has one chart per feature root (top-level step), read from the record files in its docs/handoffs/<run>/ folder as well as from live spawns, so earlier steps are drawn without having been spawned in this session.
  • Each chart is headed by its status, its ▲ anomaly marker, a title Step N · <feature> and a ⤢ button (shows that step in full screen), then the chart, then its Avatars: and Arrows: button rows. The feature name comes from the Features table of the run state, falling back to its Stages table, then to Step N.
  • The chart's root is the step itself, drawn as its sender (usually the orchestrator). The step's own record is a node under it, with its loop rounds (17.l1) under that node; sub-steps (28.1) hang under the root and deeper codes under their prefix. In the current naming a loop has no record of its own: its rounds (8.2.l1.1, 8.2.l1.2, 8.2.l2.1) hang under their sub-root (8.2) joined to it by a two-way arrow, a round's own sub-numbers (8.2.l1.1.1) hang under the round, and the loop code (8.2.l1) is never a node. Review axes are sub-numbers too (8.4.1 and 8.4.2 under the review step 8.4). A loop code that does have a record (the older naming) is drawn as described above. Each node's second line starts with its step code (28.1 · sonnet).
  • A node drawn only from a record is green with a hand-back and grey without. A code with no record of its own that sits between the root and a record (1.2 above 1.2.l1) is drawn as the orchestrator, steady (not blinking). A live spawn takes the node of the record its brief names; loop rounds, resumed agents, spawns from earlier sessions or from before /clear, and absolute or worktree paths join the chart of their step.
  • Charts are ordered by step code, highest first, live or not; sub-steps inside a chart are in code order. After the run's own charts come those of child runs (by child folder, then step, highest first), then the chart of spawns that name no step, then the Briefs naming records no longer on disk line (next bullet). Step numbers decide the order, not file times.
  • Old briefs written before a run was renumbered join the node their record was renamed to. In progress: a run folder may hold rename sources, .json or .md files whose name has the word rename at its start or after a character that is not a letter or digit (renames.json, 2026_renames.json and un-renamed-notes.md are sources; prename.json and unrenamed-notes.md are not), and that are neither records nor run states: a .json source with a codeMap object (old code to new code), or a .md source with a table whose first two header cells are Old code and New code. Only pairs of step codes are kept, an unreadable file gives no map, and several sources are read oldest name first and composed (a later source that repeats an old code wins); codes inside one file are not chained, since one file is one renaming. For a run with renames (a renames prefix map in its source or a codeMap), the lens looks a brief's record up on disk under, in order: its own name; the map; the Features table's Old steps followed by the map; the Old steps alone. The first name that is listed wins. A brief whose record is not listed yet (the listing can lag behind a live spawn) is drawn at its own code when that code's step exists in the run (in its Features table or among its records). Otherwise, a brief whose record is no longer on disk gets no chart: it appears only in one collapsed line, Briefs naming records no longer on disk (n), under the charts (not drawn when there is none).
  • Run-state file names are matched in lower case only, as the handoff skill and auto-handoff write them (RUN.V1.MD and Run.v1.md are neither run states nor records). The run state is the highest versioned run.v<major>.md or run.v<major>.<minor>.md that can be read, else run.md: versions are read highest first and reading stops at the first readable one (an unreadable one is skipped), and each read is cached until the file's size or modified time changes. Versions are ordered by major, then minor, numerically (v2 > v1.2 > v1.1 > v1, v1.10 > v1.9); a part with a zero or a leading zero (run.v0.md, run.v01.md, run.v1.0.md) is not a version. An older version is deprecated by a newer .1 minor saying so, never by its own front matter, so the lens reads no deprecation field. Run-state files are never taken for records and never appear as chart nodes.
  • Idle orchestrator. In a step whose agents have all ended, the orchestrator (the step's root) is drawn idle, not running.
  • Failures stay contained. A lens error never replaces Claude Code's own Agent row: the card falls back to that row. A pane that fails to draw shows one red line, lens: the <pane> could not be drawn (…), instead of going blank.
  • Live markers. The ▲ anomaly markers on the chat card and in the /ai-dlc:lens pane update while they are shown, and clear when the cause is gone.
  • Pollers. The lens's blink and poll timers stop when the session ends, but not at /clear (no new session start follows a /clear, so stopping there would leave the lens without them).

Full screen

⤢ brings the pane to the front and shows one step of the selected run alone: a note, a Runs: row to switch runs (with several runs), a Steps: row to switch steps (with several steps), and the step's chart 1.5 times larger. ⤢ on a step chart picks that step; from the pane's own ⤢ the first step, the highest code, is shown. The run list, notices, keep-alive line and Anomalies section are hidden until ⤡ restores the view.

VS Code sizes its own panel: maximise it for more room. In the terminal, full screen leaves the band for the pane, which is at most 45% wide when docked; restoring returns to the band. No host lets a plugin take the whole window.

Settings

Every lens setting comes from the first level that holds a valid value: AI_DLC_LENS_* in the env of .claude/settings.local.json, then of .claude/settings.json, then .ai-dlc/lens.json, then the default; an invalid value is skipped. The process environment is never read. /ai-dlc:lens-configure lists them with the level each comes from, and /ai-dlc:lens-init writes the file and its schema (hooks/settings.ts is the source of this table, which lists its 61 keys and the per-agent avatarImage.<agent> pattern). An env name is the key in capitals with _ between words: chartMode is AI_DLC_LENS_CHART_MODE.

KeyDefaultValuesWhat it does
chartModeautoauto, chart, buttonChat card: the chart when the row is wide enough, else a chip with an "Open flowchart" button; chart or button always that one.
breakpointColumns00 to 1000Row width from which auto draws the chart; 0: the chart width divided by cellPx.
cellPx84 to 32CSS px per viewport column (no API gives the cell width).
chartColumnPx12060 to 400Width of one avatar column, px.
chartRowPx9660 to 400Height of one chart level, px.
chartAvatarsPerRow82 to 24In progress. The most avatars one chart row holds; a level with more wraps onto further rows. Env: AI_DLC_LENS_CHART_AVATARS_PER_ROW.
avatarRadiusPx146 to 40Avatar radius, px.
collapsedByDefaulttruebooleanEvery [+]/[-] section starts collapsed.
blinktruebooleanRunning avatars blink.
blinkMs1000200 to 10000One blink cycle, ms.
showCosttruebooleanCost line in the agent panel.
cacheWriteRate5m5m, 1hCache-write price used for cost (the usage does not say the TTL).
windowFallbackmainmain, noneA model missing from the metadata file: use the main session's window when it runs the same model, or show —.
costingPath.ai-dlc/model-metadata.jsonpathModel metadata: prices, windows, limits (read only).
costingFallbackPath.ai-dlc/model-costing.jsonpathRead when costingPath is missing (the file's name before the rename).
rosterPath.claude/agent-memory/ai-dlc-orchestrator/project_team-roster.mdpathTeam roster (agent ID to persona name).
contextLimit601 to 1000Gathered-context calls kept for the main loop.
callsLimit801 to 1000Tool calls kept per agent.
callsShown401 to 1000Tool calls listed in the agent panel.
recordChars6000200 to 200000Characters shown of a handoff record or hand-back.
thinkingChars4000200 to 200000Characters of thinking kept per agent.
longContextThresholdTokens00 to 100000000Overrides every model's long-context threshold for cost; 0: the metadata file decides.
statusBlinktruebooleanThe in-progress status indicator blinks.
statusBlinkMs1000200 to 10000One status blink cycle, ms.
statusInProgressColor#2e7d32hex colourIn progress (blinks).
statusPendingColor#cfd8dchex colourPending.
statusHaltedColor#c62828hex colourHalted.
statusWaitingColor#ef6c00hex colourWaiting.
statusCompletedColor#2e7d32hex colourCompleted (steady).
avatarStylerobotrobot, image, initialsSee Avatars below.
avatarImageTypesimage/pnglist of image/png, image/jpeg, image/gif, image/webpAllowed image types, comma-separated; checked on the bytes.
avatarImageMaxBytes655361 to 2097152Largest avatar image, bytes.
keepaliveSectiontruebooleanThe Cache keep-alive section of the agent panes (/ai-dlc:lens-keepalive toggles it).
panelOpenModedrill-intab, drill-inHow an avatar or arrow opens its details: inside the pane or card it was pressed in (drill-in), or as a separate pane (tab). The Panel Type switch overrides it for the session only and never writes lens.json.
chartInChattruebooleanDraw each workflow's chart in the card of its first spawn.
chartInPanetruebooleanDraw the charts in the workflow view; off: the avatar and arrow buttons only.
workflowPlacepinnedpinned, dockedWhere the view starts: the band above the prompt where the surface has one (terminal, desktop app), else the pane; docked: the pane.
wideColumns700 to 1000View width from which the wide layout applies.
runListColumns3416 to 200Width of the stacked run panels in the wide layout.
gateAlerttruebooleanReport anomalies: a toast when one appears, a marker, and the Anomalies section. false turns all reporting off. `/ai-dlc:lens-gate on\off` sets it.
anomalyRunScopesessionsession, allWhich runs the detector reads: those this session's agent briefs name, or (all) also every active or blocked run.md under artifacts.handoffs of .ai-dlc/workflow.json (default docs/handoffs).
anomalyWaitingMinutes300 to 1440How long an agent may wait before it is an anomaly, minutes; 0: as soon as it waits.
anomalyRunBlockedtruebooleanCheck: a run is blocked (status: blocked in its run.md); the anomaly names its blocked steps.
anomalyRunBlockedColor#c62828hex colourMarker colour: blocked run (red).
anomalyStepBlockedtruebooleanCheck: a Stages row of run.md has a status starting with blocked, whatever the run's status.
anomalyStepBlockedColor#c62828hex colourMarker colour: blocked step (red).
anomalyGateWaitingtruebooleanCheck: a gate waits for the human (a gate row with approver human and no decision, or the run's newest record addressed to: human with no decision for its gate). A decision in words with a date is not waiting (in progress, see Anomaly alert).
anomalyGateWaitingColor#ef6c00hex colourMarker colour: gate waiting (orange).
anomalyGateRejectedtruebooleanCheck: a gate was rejected (the last row of that gate in Gates and loops says rejected, not approved, declined or no-go).
anomalyGateRejectedColor#c62828hex colourMarker colour: gate rejected (red).
anomalyLoopOutOfRoundstruebooleanCheck: a loop is out of rounds (its last row in Gates and loops says its limits are used up, with no raise or acceptance recorded).
anomalyLoopOutOfRoundsColor#d84315hex colourMarker colour: loop out of rounds (deep orange).
anomalyAgentFailedtruebooleanCheck: an agent failed or stopped, and no later agent of its type was spawned for the same run.
anomalyAgentFailedColor#c62828hex colourMarker colour: agent failed or stopped (red).
anomalyAgentKilledtruebooleanCheck: an agent was killed, and no later agent of its type was spawned for the same run.
anomalyAgentKilledColor#c62828hex colourMarker colour: agent killed (red).
anomalyAgentWaitingLongtruebooleanCheck: an agent has waited (held, or a teammate idle) for anomalyWaitingMinutes or more.
anomalyAgentWaitingLongColor#c62828hex colourMarker colour: agent waiting too long (red).
anomalyHandbackFailedtruebooleanCheck: a hand-back failed its check (verification: failed in its front matter, or a first line handoff <file>: failed), in the newest round of its step.
anomalyHandbackFailedColor#f9a825hex colourMarker colour: hand-back that failed its check (yellow).
probesfalsebooleanLog the spike's experiments E1, E2, E4, E5, E7, E9 and E10 to PROBE-LOG.jsonl in the plugin folder (diagnostics).

| avatarImage.<agent> | unset | data: URI or file path | One agent's im

Source 6 files
hooks/register.tsx 3602 lines
1// ai-dlc-lens hooks module. Started from spike ITEM-027 (rounds 0-6 below, run FEAT-20261020-ai-dlc-lens);
2// the code is the round 6 code, renamed, and still goes through that run's scope pass and gates G1-G3.
3// Round 1 (step 17): the human's lens card. `/ai-dlc:lens` opens it:
4//   L1 orchestrator view: thinking and gathered context, each collapsible (pane section, step 16 Q1)
5//   L2 flowchart: an avatar per agent (Svg), a Button per avatar and per arrow under it (step 16 Q2)
6//   L4 arrow -> separate pane `lens-edge` (or drill-in): handoff record, hand-back, their tokens
7//   L5 avatar -> separate pane `lens-agent` (or drill-in): background tasks, tool calls, thinking
8//   L6 loops draw a two-way arrow; running avatars blink green, finished ones stay green
9// Round 2 (step 17.l1): the same flowchart sits at the top of the chat card. The ToolUse row of the
10// first Agent call of an assistant message draws it for every Agent call of that message (parallel
11// spawns side by side), with the avatar and arrow Buttons, collapsible thinking and gathered context,
12// and the native row last; the message's other Agent rows stay compact, native row kept.
13// Round 3 (step 17.l2): the human's findings. A smaller chart with name, model and role only (no
14// figures); a chip with an "Open flowchart" Button when the row is too narrow; persona names from
15// the roster, models from the agent definitions, readable roles; the figures, the cost (from
16// `.ai-dlc/model-costing.json` only) and the window in the avatar panel; the arrow panel falls
17// back to the orchestrator's prompt; the native row is drawn without the prompt (whole when
18// expanded); only running agents blink; every collapsible starts collapsed; every setting comes
19// from hooks/settings.ts levels. Root cause of the missing second agent: see `ensureIn` (lens.ts).
20// Rounds 4-5 (steps 17.l3, 17.l4): the workflow pane, the one-line chat card, task titles, the status dot.
21// Round 6 (step 17.l5): the pane's own minimise and close row; every workflow and every task a collapsed
22// panel; close and Esc on every lens pane; the two-colour dot and "waiting" for any agent; notices as
23// an exclamation icon with a hover card; a spawn for the same task joins its workflow; no bottom filler.
24// Step 1.4 (run AAW-20261023-lens-plugin-from-spike): the commands are /ai-dlc:lens, /ai-dlc:lens-init,
25// /ai-dlc:lens-configure, /ai-dlc:lens-kit, /ai-dlc:lens-keepalive and /ai-dlc:lens-gate. A hooks module can
26// only register a bare name (CommandSpec.name: letters, digits, _ and -, d.ts:1924), so each name comes from a
27// thin skill of the ai-dlc plugin (skills/lens*), which Claude Code lists as /ai-dlc:<skill>, and the
28// command.run hooks below answer its runs in the skill's place (the cache-keepalive precedent). The module
29// registers no command of its own. Pane ids allow no ':' (PaneOpenArgs.id, d.ts:7590), so they are ai-dlc-lens-*.
30// Step 1.5: avatar and arrow Buttons open as a tab or drill-in (`panelOpenMode`, the view's `Panel Type` switch), from
31// the chat card, the workflow view and the lens pane; the flowchart is drawn in the first spawn's chat card again
32// (`chartInChat`) and in the workflow view (`chartInPane`); the view has pinned (the AbovePrompt band) / docked
33// (the pane), full screen, minimise and restore on its first row; a workflow's identity is its run (`joinWorkflow`),
34// and the view stacks the runs on the right when wide and lists them minimised when narrow (`workflowBody`).
35// Step 1.6.1: /ai-dlc:lens-gate is an anomaly detector (hooks/anomaly.ts, `detectNow`): a toast once per anomaly, a
36// marker on its run, avatar or arrow, an Anomalies section; the GATE-ALERT.txt marker and its command Stop hook are gone.
37// Background tasks are one keyed entry per task id (`uniqueTasks`); the agent and hand-off tabs have minimise (A2);
38// a run's chart draws a later spawn by another spawner (A3, `workflowBatch`).
39// Experiments E1, E2, E4, E5, E7, E9 and E10 log to PROBE-LOG.jsonl while the `probes` setting is on (off by default).
40// The round-0 kit (checks 1-5: resize, Svg blink, buttons, transcript cards, gate alert) is `/ai-dlc:lens-kit`.
41// Step 36.2: one view. `/ai-dlc:lens` opens the workflow view, titled "AI-DLC Lens" (`VIEW_TITLE`), or raises it
42// (`openLens`). The round 1 lens card (the `ai-dlc-lens` pane, L1-L6) is gone, so "the lens pane" above is history.
43import { atom, read, update } from 'claude-code'
44import type { EngineInterface, Register, RenderElement, Timer, UiOpenResult } from 'claude-code'
45import type { LensAgent, LensAnomaly, LensBatch, LensCall, LensConfig, LensCosting, LensDef, LensDoc, LensDrill, LensNotice, LensRun, LensTask, LensUi, LensView, LensWorkflows, Tokens } from '../types'
46import {
47  AGENT_TOOLS,
48  CONTEXT_TOOLS,
49  drawOr,
50  ENDED,
51  ZERO,
52  addTokens,
53  addToBatch,
54  batchView,
55  contextUsed,
56  costOfAgent,
57  ensureIn,
58  flowchartSvg,
59  firstLine,
60  fmtClock,
61  fmtTokens,
62  fromUsage,
63  humanRole,
64  initials,
65  isAgentTask,
66  isHumanRequest,
67  isNotice,
68  isPersonOrigin,
69  isWaiting,
70  noticeWorkflow,
71  REQS_CAP,
72  runningAndWaiting,
73  statusLooks,
74  taskKeyOf,
75  runOfKey,
76  unifyRuns,
77  callRunIds,
78  workflowTasks,
79  isRespawn,
80  isRunning,
81  leadOf,
82  matchHandoff,
83  mergeStopTasks,
84  modelOf,
85  notePending,
86  parseDefinition,
87  parseRecord,
88  parseRoster,
89  parseRun,
90  personaOf,
91  pickHandback,
92  joinWorkflow,
93  limitRows,
94  lookupModel,
95  parseCosting,
96  pluginOf,
97  roleOf,
98  runDirOf,
99  stampTasks,
100  uniqueTasks,
101  statusBadge,
102  statusDotSvg,
103  summarize,
104  taskLine,
105  textChart,
106  usageBar,
107  windowOf,
108  workflowBatch,
109  workflowStatus,
110  workflowTask,
111  WF0,
112  keepaliveLine,
113  fmtSpan,
114  keptWarm,
115  noteKeepalive,
116  savingsOfAgent,
117  sessionSavings,
118  VIEW0,
119  BAND_SURFACES,
120  bandEverywhere,
121  bandLineOf,
122  dockedBandViewOf,
123  dockedFromBand,
124  layoutOf,
125  openModeOf,
126  openSizeOf,
127  paneViewOf,
128  placeOf,
129  selectedOf,
130  newestFirst,
131  runIdOf,
132  runRoots,
133  PARENT_DEPTH,
134  folderNameOf,
135  mergedHomeOf,
136  workflowRunOf,
137  paneFault,
138  showChart,
139  viewResize,
140  viewRestore,
141  parseRecordName,
142  stepCharts,
143  stepsDescending,
144  pickRunState,
145  isRunStateFile,
146  isRenameSource,
147  codeMapOf,
148  composeCodeMaps,
149  NO_STEP,
150  NOT_FOUND,
151  OPEN,
152  topStepOf,
153  cardCharts,
154  spawnPlace,
155} from './lens'
156import type { ChartLabel, ChartSize, FlowStatus, KeepaliveCycle, StepChart, StepSource } from './lens'
157import { DEFAULTS, KEYS, agentEnvName, agentEnvPrefix, agentKeyParts, envLevel, envName, fileLevel, initialFile, resolve, resolveAgent, schema, setKey } from './settings'
158import type { Key, Level, LensSettings } from './settings'
159import { SVG_CAP, avatarKey, avatarSvg, checkBytes, classify, isInside, own, trustedFolders, typesOf } from './avatar'
160import type { AvatarImage, ChartAvatars } from './avatar'
161import { anomaliesOf, anomalyLine, applySettings, colorOf, detectAgents, detectRun, diffAnomalies, edgeAgentOf, frontMatter, handoffsRoot, normDir, recordName, runsToRead, trackWaiting, worstOf } from './anomaly'
162
163// Pane ids: 1-64 of letters, digits, `_` and `-` (PaneOpenArgs.id, d.ts:7590; `$.ui.open`, d.ts:2524), so
164// `ai-dlc:lens-*` is not a valid id; `ai-dlc-lens-*` is the closest. The titles stay readable.
165const KIT = 'ai-dlc-lens-kit'
166const EDGE = 'ai-dlc-lens-edge'
167const AGENT = 'ai-dlc-lens-agent'
168const CHART = 'ai-dlc-lens-chart'
169// Step 36.2: the workflow view's title (its tab, and the minimised view with no run yet). The id stays `ai-dlc-lens-chart`:
170// with the round 1 card's `ai-dlc-lens` gone nothing clashes, and a pane left open keeps its id across the update.
171const VIEW_TITLE = 'AI-DLC Lens'
172// Step 36.2.3, amendment 7: the round 1 lens card's pane id, gone since step 36.2. A card left open from an older build
173// stays on screen after /reload-plugins (the engine's `unload` did not drop it), so it is closed explicitly (`closeOldCard`).
174const OLD_CARD = 'ai-dlc-lens'
175// The commands, served by the ai-dlc skills of the same names (skills/lens*).
176const CMD = {
177  lens: 'ai-dlc:lens',
178  init: 'ai-dlc:lens-init',
179  configure: 'ai-dlc:lens-configure',
180  kit: 'ai-dlc:lens-kit',
181  keepalive: 'ai-dlc:lens-keepalive',
182  gate: 'ai-dlc:lens-gate',
183} as const
184const LENS_JSON = '.ai-dlc/lens.json'
185const LENS_SCHEMA = '.ai-dlc/lens.schema.json'
186const SETTINGS_LOCAL = '.claude/settings.local.json'
187const SETTINGS_PROJECT = '.claude/settings.json'
188
189// ---- round-0 kit state ----
190const tick = atom({ plugin: 'ai-dlc-lens', key: 'tick' } as const, false)
191const paneExpanded = atom({ plugin: 'ai-dlc-lens', key: 'paneExpanded' } as const, false)
192const panePresses = atom({ plugin: 'ai-dlc-lens', key: 'panePresses' } as const, 0)
193const forceMode = atom({ plugin: 'ai-dlc-lens', key: 'forceMode' } as const, 'auto')
194
195// ---- round-1 lens state ----
196const UI0: LensUi = { edge: null, agent: null, edgeSnap: null, agentSnap: null }
197const DOC0: LensDoc = { agent: '', handoff: '', handoffText: '', handback: '', handbackText: '', error: '', prompt: '', hint: '' }
198const agentsA = atom({ plugin: 'ai-dlc-lens', key: 'agents' } as const, {}, { shape: 'agents-3' })
199const orderA = atom({ plugin: 'ai-dlc-lens', key: 'order' } as const, [], { shape: 'order-1' })
200const callsA = atom({ plugin: 'ai-dlc-lens', key: 'calls' } as const, {}, { shape: 'calls-1' })
201const thinkingA = atom({ plugin: 'ai-dlc-lens', key: 'thinking' } as const, {})
202const contextA = atom({ plugin: 'ai-dlc-lens', key: 'context' } as const, [], { shape: 'context-1' })
203const mainWindowA = atom({ plugin: 'ai-dlc-lens', key: 'mainWindow' } as const, 0)
204const uiA = atom({ plugin: 'ai-dlc-lens', key: 'ui' } as const, UI0, { shape: 'ui-3' })
205const docA = atom({ plugin: 'ai-dlc-lens', key: 'doc' } as const, DOC0, { shape: 'doc-2' })
206const probeLinesA = atom({ plugin: 'ai-dlc-lens', key: 'probeLines' } as const, 0)
207// ---- round-2 chat card state ----
208const batchesA = atom({ plugin: 'ai-dlc-lens', key: 'batches' } as const, {}, { shape: 'batches-2' })
209/** Every [+]/[-] section, by key; absent: the `collapsedByDefault` setting decides. */
210const cardOpenA = atom({ plugin: 'ai-dlc-lens', key: 'cardOpen' } as const, {})
211// ---- round-3 state ----
212/** tool_use_id -> agent id, written by `agent.spawn` and the Agent call's result: never overwritten by a blank record. */
213const callAgentA = atom({ plugin: 'ai-dlc-lens', key: 'callAgent' } as const, {})
214const defsA = atom({ plugin: 'ai-dlc-lens', key: 'defs' } as const, {}, { shape: 'defs-1' })
215const rosterA = atom({ plugin: 'ai-dlc-lens', key: 'roster' } as const, {})
216const costingA = atom({ plugin: 'ai-dlc-lens', key: 'costing' } as const, { path: '', text: '', error: 'not read yet' }, { shape: 'costing-1' })
217const chartLeadA = atom({ plugin: 'ai-dlc-lens', key: 'chartLead' } as const, '')
218// ---- round-4 state ----
219/** Agent call -> the request or brief that made it: a workflow's title when no run names it. */
220const callTaskA = atom({ plugin: 'ai-dlc-lens', key: 'callTask' } as const, {})
221/** The main loop's latest request (`turn.start` text, first line). */
222const mainPromptA = atom({ plugin: 'ai-dlc-lens', key: 'mainPrompt' } as const, '')
223/** Workflows across messages and turns (round 4 bug): see `joinWorkflow`. */
224const workflowsA = atom({ plugin: 'ai-dlc-lens', key: 'workflows' } as const, WF0, { shape: 'workflows-1' })
225/** The workflow pane opens by itself once a session, on the first spawn. */
226let workflowOpened = false
227/** Step 36.4 (rcm-spec): the old card is closed once per load from the band's first render (`closeOldCardOnce`). */
228let oldCardSwept = false
229const LENS0: LensConfig = { settings: { ...DEFAULTS }, sources: Object.fromEntries(KEYS.map(k => [k, 'default'])), ignored: [] }
230const lensA = atom({ plugin: 'ai-dlc-lens', key: 'lens' } as const, LENS0, { shape: 'lens-3' })
231// ---- round-5 state ----
232/** Each run folder a brief named -> its `run.md` as parsed: the titles and the status follow it (requirement 2). */
233const runsA = atom({ plugin: 'ai-dlc-lens', key: 'runs' } as const, {}, { shape: 'runs-1' })
234// ---- round-6 state ----
235/** Automatic notices fed to the main loop (A7): shown as an exclamation icon, never as a task title. */
236const noticesA = atom({ plugin: 'ai-dlc-lens', key: 'notices' } as const, [], { shape: 'notices-1' })
237/** Agent call -> its task key (`taskKeyOf`): a spawn for the same task joins its workflow (A8). */
238const callKeyA = atom({ plugin: 'ai-dlc-lens', key: 'callKey' } as const, {})
239// ---- step 1.5 state ----
240/** Drill-in views by drawing: `pane` (the workflow pane and band), `card:<call>` (a chat card). */
241const drillsA = atom({ plugin: 'ai-dlc-lens', key: 'drills' } as const, {}, { shape: 'drills-1' })
242/** The workflow view: pinned or docked, normal, minimised (A1's one-line bar) or full screen, the sizes restore returns to, the run selected. */
243const viewA = atom({ plugin: 'ai-dlc-lens', key: 'view' } as const, VIEW0, { shape: 'view-1' })
244/**
245 * Amendment 8: the band closed with its `×`, which it has only while no workflow is in progress. It stays closed until
246 * the next spawn or `/ai-dlc:lens`, and never while a workflow is in progress.
247 */
248const bandClosedA = atom({ plugin: 'ai-dlc-lens', key: 'bandClosed' } as const, false)
249/** The view's `Panel Type` switch: `tab` or `drill-in` for this session; '' follows the `panelOpenMode` setting. */
250const openModeA = atom({ plugin: 'ai-dlc-lens', key: 'openMode' } as const, '')
251/** The workflow pane's last viewport (full screen asks for its rows and columns) and the session's surfaces (pinned needs the band). */
252let lastViewport: { columns?: number; rows?: number } | undefined
253const NOTICES_KEPT = 20
254// ---- avatars (spike ITEM-029, robot addendum) ----
255/** Bumped whenever an agent's avatar image is loaded or dropped: the drawings read it to redraw. */
256const avatarRevA = atom({ plugin: 'ai-dlc-lens', key: 'avatarRev' } as const, 0)
257// ---- /ai-dlc:lens-keepalive (step 1.4) ----
258/** Each keep-alive arming the lens has read, by `<track>@<armedAt>`: the time of earlier armings survives the next one. */
259// Step 1.6.1: the anomalies /ai-dlc:lens-gate detects (hooks/anomaly.ts), and the agent and hand-off tabs' minimise (A2).
260const anomaliesA = atom({ plugin: 'ai-dlc-lens', key: 'anomalies' } as const, [], { shape: 'anomalies-1' })
261const tabsMinA = atom({ plugin: 'ai-dlc-lens', key: 'tabsMin' } as const, {})
262/** Step 8.6.1: the step chart full screen shows, per workflow; absent: the first in order, the highest code. */
263const stepSelA = atom({ plugin: 'ai-dlc-lens', key: 'stepSel' } as const, {})
264const kaCyclesA = atom({ plugin: 'ai-dlc-lens', key: 'keepaliveCycles' } as const, {}, { shape: 'ka-cycles-1' })
265/** The settings levels of the last load, highest first: per-agent keys are resolved from them. */
266let levelsNow: Level[] = []
267/** Avatar key -> its image as loaded, or why there is none, and the inputs it was loaded from. Module-local: images can be large. */
268const avatarCache: Record<string, { sig: string; img: AvatarImage | null; why: string; setting: string }> = {}
269/** Prompts `prompt.submit` saw arrive from something other than the person (A7): `turn.start` skips them. */
270const noticeTexts = new Set<string>()
271/** As many as the notices kept: the oldest goes first (a Set keeps insertion order). */
272function rememberNotice(text: string) {
273  noticeTexts.delete(text)
274  noticeTexts.add(text)
275  while (noticeTexts.size > NOTICES_KEPT) noticeTexts.delete(noticeTexts.values().next().value as string)
276}
277/** The session's pollers (`$.clock.every`): stopped at the next `session.start` and at `session.end`. */
278const timers: Timer[] = []
279function stopTimers() {
280  for (const t of timers.splice(0)) t.cancel()
281}
282
283// Tools whose rows get a transcript card. `Task` is the Agent tool's name in headless runs.
284const CARD_TOOLS = new Set(['Agent', 'Task', 'SendMessage'])
285
286// Module-local (reset on reload).
287type Redraw = { n: number; body: number; cols: number | undefined; placement: string; fs: string; mode: string }
288const redraws: Redraw[] = []
289let redrawCount = 0
290let toolResultHooks = 0
291let toolUseCards = 0
292let stopEvents = 0
293let cwd = ''
294let probeOn = false
295let probePath = ''
296const probeLog: string[] = []
297let probeWrite: Promise<void> = Promise.resolve()
298const thinkBuf: Record<string, string> = {}
299/** Thinking blocks streamed per loop (distinct block index per step), text or not. */
300const thinkBlocks: Record<string, number> = {}
301/** tool_use_id -> the output tokens of the step that asked for it and how many calls that step asked for. */
302const stepOfCall: Record<string, { out: number; n: number }> = {}
303const lastStatus: Record<string, string> = {}
304/** The settings non-drawing code uses (limits); the drawings read `lensA`. */
305let cfg: LensSettings = { ...DEFAULTS }
306
307const nowMs = () => Date.now()
308const cap = (s: string, n: number) => (s.length > n ? s.slice(0, n) + `\n... [+${s.length - n} chars]` : s)
309
310const blankAgent = (id: string, parent: string | null, type: string): LensAgent => ({
311  id,
312  parent,
313  type,
314  name: '',
315  description: '',
316  model: '',
317  callModel: '',
318  parentModel: '',
319  prompt: '',
320  status: 'running',
321  toolUseId: '',
322  steps: ZERO,
323  turns: ZERO,
324  last: null,
325  handoff: null,
326  handoffHow: 'none',
327  loop: false,
328  startedAt: nowMs(),
329  endedAt: null,
330  bg: [],
331  reqs: [],
332})
333
334const svgDot = (fill: string, animated: boolean) =>
335  `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">` +
336  `<circle cx="12" cy="12" r="9" fill="${fill}">` +
337  (animated ? `<animate attributeName="opacity" values="1;0.15;1" dur="1s" repeatCount="indefinite"/>` : '') +
338  `</circle></svg>`
339
340// ---- step 1.6.1: /ai-dlc:lens-gate, the anomaly detector (replaces the GATE-ALERT.txt marker and its command Stop hook) ----
341/** When each agent was first seen waiting (anomaly.ts `trackWaiting`). */
342let waitingSince: Record<string, number> = {}
343/** Record texts by path, kept while the file's size and time do not change: the 2 s poll reads little. */
344let recordCache: Record<string, { sig: string; text: string | null }> = {}
345/** What the last pass could not read (malformed or missing), shown by a bare /ai-dlc:lens-gate. */
346let anomalySkipped: string[] = []
347/** The anomalies as last detected: the poll's "an anomaly stands" test, and the fallback of a failed pass. Drawings read `anomaliesA`. */
348let anomaliesNow: LensAnomaly[] = []
349/** Passes run one at a time, so two triggers never toast one anomaly twice. */
350let detectChain: Promise<unknown> = Promise.resolve()
351
352/**
353 * Step 1.6.1.l1 (addendum A5): the workflows as drawn, one per run (`unifyRuns`): a run split by an
354 * earlier lens, another session, a resumed agent or a lost key is shown as one workflow (since step 8.6.1 drawn
355 * as one chart per top-level step, `flowSteps`). Read-only.
356 */
357async function readWorkflows($: EngineInterface): Promise<LensWorkflows> {
358  const wf = await read($, workflowsA)
359  const keys = await read($, callKeyA)
360  const agents = await read($, agentsA)
361  const callAgent = await read($, callAgentA)
362  return unifyRuns(wf, keys, agents, callAgent, await readRoots($))
363}
364
365/**
366 * Step 27.1: the runs drawn in another run (`runRoots`): a run.md with `parent:`, followed to the top while
367 * each parent is known (its run.md read by `refreshRuns`, or named by a call). Read-only.
368 */
369async function readRoots($: EngineInterface): Promise<Record<string, string>> {
370  const wf = await read($, workflowsA)
371  const keys = await read($, callKeyA)
372  const agents = await read($, agentsA)
373  const callAgent = await read($, callAgentA)
374  const runs = await read($, runsA)
375  return runRoots(runs, Object.values(callRunIds(wf, keys, agents, callAgent)))
376}
377
378/** The run folders the lens knows: those the briefs of its agents and pending calls name. */
379async function runDirs($: EngineInterface): Promise<string[]> {
380  const agents = await read($, agentsA)
381  const batches = await read($, batchesA)
382  const dirs = new Set<string>()
383  for (const a of Object.values(agents)) {
384    const d = runDirOf(a)
385    if (d !== null) dirs.add(d)
386  }
387  for (const b of Object.values(batches))
388    for (const p of Object.values(b.pending)) {
389      const d = runDirOf({ handoff: null, prompt: p.prompt })
390      if (d !== null) dirs.add(d)
391    }
392  return [...dirs]
393}
394
395type ListEntry = { name: string; kind?: string; size?: number; mtimeMs?: number }
396
397/**
398 * Every run folder under the handoff folder (`artifacts.handoffs` of .ai-dlc/workflow.json, default
399 * docs/handoffs, `handoffsRoot`). A handoff folder that cannot be listed is named in `skipped`.
400 */
401async function runFolders($: EngineInterface, skipped: string[]): Promise<string[]> {
402  const { root, depth } = handoffsRoot(await readText($, '.ai-dlc/workflow.json'))
403  let level = [root]
404  for (let i = 0; i < depth; i++) {
405    const next: string[] = []
406    for (const d of level) {
407      try {
408        for (const e of (await $.fs.list(d)) as ListEntry[]) if (e.kind === 'dir') next.push(`${d}/${e.name}`)
409      } catch {
410        if (i === 0) skipped.push(`${d}: not listable`)
411      }
412    }
413    level = next
414  }
415  return level
416}
417
418/**
419 * Step 1.6.1.l1 (`anomalyRunScope` all): every run folder (`runFolders`), with its run.md text (re-read only
420 * when it changed), its status and its listing. A folder with no run.md is left out; a folder that cannot be
421 * listed is named in `skipped`.
422 */
423async function scanRuns($: EngineInterface, skipped: string[]): Promise<{ dir: string; text: string | null; status: string; entries: ListEntry[] }[]> {
424  const level = await runFolders($, skipped)
425  const out: { dir: string; text: string | null; status: string; entries: ListEntry[] }[] = []
426  for (const d of level) {
427    let entries: ListEntry[] = []
428    try {
429      entries = (await $.fs.list(d)) as ListEntry[]
430    } catch {
431      skipped.push(`${d}: not listable`)
432      continue
433    }
434    // The run state is `run.md` or a versioned `run.v<major>[.<minor>].md` (`isRunStateFile`).
435    if (!entries.some(e => e.kind !== 'dir' && isRunStateFile(e.name))) continue
436    const text = await readRunState($, d, entries)
437    out.push({ dir: normDir(d), text, status: text === null ? '' : (frontMatter(text)['status'] ?? ''), entries })
438  }
439  return out
440}
441
442/** A listed file's change signature for `recordText`: size and time; '' (never cached) when the listing gives neither. */
443const sigOf = (e: ListEntry | undefined): string => (e === undefined || (e.size === undefined && e.mtimeMs === undefined) ? '' : `${e.size ?? ''}:${e.mtimeMs ?? ''}`)
444
445/** A record's text, re-read only when `fs.list` shows it changed. */
446async function recordText($: EngineInterface, path: string, sig: string): Promise<string | null> {
447  const hit = recordCache[path]
448  if (hit !== undefined && hit.sig === sig && sig !== '') return hit.text
449  const text = await readText($, path)
450  recordCache[path] = { sig, text }
451  return text
452}
453
454/**
455 * One detection pass: every known run's run.md and records, and every agent's state. A new anomaly
456 * gets one toast (`toast`), an existing one keeps its first-seen time, a gone one leaves the list.
457 * Never throws: a run that cannot be read is skipped and named in `anomalySkipped`.
458 */
459function detectNow($: EngineInterface, toast = true): Promise<LensAnomaly[]> {
460  const run = detectChain.then(() => detectPass($, toast))
461  detectChain = run.catch(() => undefined)
462  return run.catch(err => {
463    lensFail('anomaly detector', err)
464    return anomaliesNow
465  })
466}
467
468async function detectPass($: EngineInterface, toast: boolean): Promise<LensAnomaly[]> {
469  const prev = await read($, anomaliesA)
470  const now = nowMs()
471  const agents = await read($, agentsA)
472  waitingSince = trackWaiting(agents, waitingSince, now)
473  let found: LensAnomaly[] = []
474  const skipped: string[] = []
475  if (cfg.gateAlert === true) {
476    // Step 1.6.1.l1: anomalyRunScope all also reads every active or blocked run under the handoff folder.
477    const scanned = cfg.anomalyRunScope === 'all' ? await scanRuns($, skipped) : []
478    const known = new Map(scanned.map(x => [x.dir, x]))
479    for (const d of runsToRead(String(cfg.anomalyRunScope), await runDirs($), scanned)) {
480      try {
481        const hit = known.get(d)
482        const runText = hit !== undefined ? hit.text : await readRunState($, d)
483        if (runText === null) continue
484        let entries: ListEntry[] = hit?.entries ?? []
485        if (hit === undefined)
486          try {
487            entries = (await $.fs.list(d)) as ListEntry[]
488          } catch {
489            skipped.push(`${d}: not listable`)
490          }
491        const files: { name: string; text: string | null }[] = []
492        for (const f of entries) {
493          const r = f.kind === 'dir' ? null : recordName(f.name)
494          if (r === null) continue
495          // Only hand-backs and records to the human are read; the rest count by name.
496          const want = r.kind === 'handback' || r.to === 'human'
497          files.push({ name: f.name, text: want ? await recordText($, `${d}/${f.name}`, `${f.size ?? ''}:${f.mtimeMs ?? ''}`) : '' })
498        }
499        const r = detectRun(d, runText, files, now)
500        found.push(...r.list)
501        skipped.push(...r.skipped)
502      } catch (err) {
503        skipped.push(`${d}: ${String(err).slice(0, 120)}`)
504      }
505    }
506    found.push(...detectAgents(agents, waitingSince, now, cfg.anomalyWaitingMinutes))
507    found = found.map(a => (a.target === 'edge' && a.agent === '' ? { ...a, agent: edgeAgentOf(a, agents) } : a))
508  }
509  const { list, appeared } = diffAnomalies(prev, applySettings(found, cfg))
510  anomaliesNow = list
511  anomalySkipped = skipped
512  if (JSON.stringify(prev) !== JSON.stringify(list)) await update($, anomaliesA, () => list)
513  if (skipped.length > 0) await plog($, 'anomaly', { skipped })
514  if (toast && appeared.length > 0) {
515    const more = appeared.length > 1 ? ` (+${appeared.length - 1} more: /ai-dlc:lens-gate)` : ''
516    $.ui.toast(`ai-dlc-lens: ${anomalyLine(appeared[0]!)}${more}`)
517  }
518  return list
519}
520
521/** What a drawing marks with ▲: the anomalies it read (`snapshot`) and the settings that colour them. */
522type Marks = { list: readonly LensAnomaly[]; s: LensSettings }
523
524/** The anomaly marker: a triangle in the worst colour, and the count when more than one. Never the status dot. */
525function anomalyMark(t: any, list: readonly LensAnomaly[], s: LensSettings) {
526  const { Text } = t
527  const w = worstOf(list)
528  if (w === null) return null
529  return (
530    <Text color={colorOf(w.kind, s)} bold>
531      {'▲'}
532      {list.length > 1 ? String(list.length) : ''}
533    </Text>
534  )
535}
536
537async function readText($: EngineInterface, path: string): Promise<string | null> {
538  try {
539    return String(await $.fs.read(path))
540  } catch {
541    return null
542  }
543}
544
545/**
546 * A run folder's run state. A run keeps a major version per session and a minor pair per change of direction
547 * (`run.v1.md`, `run.v1.1.md`, `run.v1.2.md`, `run.v2.md`, …; the handoff skill, *Run state*): the highest
548 * version that can be read is current (`pickRunState`, which reads highest first and stops there), else
549 * `run.md`. Each file is read through `recordText`, so an unchanged one is not read again. `entries`: the
550 * folder's listing when the caller has it. Null: none.
551 */
552async function readRunState($: EngineInterface, dir: string, entries?: ListEntry[]): Promise<string | null> {
553  let list = entries
554  if (list === undefined)
555    try {
556      list = (await $.fs.list(dir)) as ListEntry[]
557    } catch {
558      list = []
559    }
560  const files = (Array.isArray(list) ? list : []).filter(e => e.kind !== 'dir')
561  const text = (name: string) => recordText($, `${dir}/${name}`, sigOf(files.find(e => e.name === name)))
562  const hit = await pickRunState(
563    files.map(e => e.name),
564    text,
565  )
566  return hit !== null ? hit.text : text('run.md')
567}
568
569// ---- settings (addendum 3): local env > project env > .ai-dlc/lens.json > default; never the process env ----
570async function loadSettings($: EngineInterface): Promise<LensConfig> {
571  const local = envLevel(SETTINGS_LOCAL, await readText($, SETTINGS_LOCAL))
572  const project = envLevel(SETTINGS_PROJECT, await readText($, SETTINGS_PROJECT))
573  const file = fileLevel(LENS_JSON, await readText($, LENS_JSON))
574  const r = resolve([local.level, project.level, file.level])
575  const problems = [local.problem, project.problem, file.problem].filter(x => x !== '')
576  const conf: LensConfig = { settings: r.settings, sources: r.sources, ignored: [...problems, ...r.ignored] }
577  cfg = r.settings
578  // Step 1.4: the experiments lost their command; the `probes` setting (off by default) turns them on.
579  probeOn = r.settings.probes === true
580  levelsNow = [local.level, project.level, file.level]
581  await update($, lensA, () => conf)
582  // A changed avatar setting reloads that agent's image; an unchanged one is kept.
583  const types = new Set(Object.keys(await read($, defsA)))
584  for (const a of Object.values(await read($, agentsA))) types.add(a.type)
585  for (const type of types) await loadAvatar($, type)
586  return conf
587}
588
589/** The user's home folder (Windows sets USERPROFILE, not HOME); only the trust check reads it. */
590async function homeDir($: EngineInterface): Promise<string | undefined> {
591  try {
592    return (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || undefined
593  } catch {
594    return undefined
595  }
596}
597
598/**
599 * An `avatarImage` value as an image, or why not (AC-13.3 to 13.7, 13.9). A data URI is checked for
600 * type and size and never fetched. A file path must resolve (every link followed, `..` folded) to a
601 * real path inside a folder Claude Code trusts (~/.claude.json), be a file within the size limit and
602 * start as an allowed image type; the real path is what is read. Anything else is refused.
603 */
604async function readImage($: EngineInterface, value: string, types: readonly string[], maxBytes: number): Promise<{ img: AvatarImage } | { why: string }> {
605  const src = classify(value)
606  if (src.kind === 'refused') return { why: src.why }
607  if (src.kind === 'data') {
608    const c = checkBytes(src.base64, src.mime, types, maxBytes)
609    if ('why' in c) return c
610    return { img: { uri: `data:${c.mime};base64,${src.base64}`, bytes: c.bytes, png: null } }
611  }
612  const home = await homeDir($)
613  const folders = trustedFolders(home !== undefined ? await readText($, `${home}/.claude.json`) : null)
614  const real: string[] = []
615  for (const f of folders) {
616    const st = await $.fs.stat(f, { resolve: true }).catch(() => undefined)
617    if (st !== undefined && typeof st.realPath === 'string') real.push(st.realPath)
618  }
619  const st = await $.fs.stat(src.path, { resolve: true }).catch(() => undefined)
620  if (st === undefined) return { why: 'the file does not exist' }
621  if (typeof st.realPath !== 'string') return { why: 'its real path cannot be resolved' }
622  if (!isInside(st.realPath, real)) return { why: 'it is outside every folder Claude Code trusts' }
623  if (st.kind !== 'file') return { why: 'it is not a file' }
624  if (st.size > maxBytes) return { why: `${st.size} bytes, over avatarImageMaxBytes (${maxBytes})` }
625  let base64 = ''
626  try {
627    const r: unknown = await $.fs.read(st.realPath, { as: 'bytes' })
628    if (r !== null && typeof r === 'object' && typeof (r as { base64?: unknown }).base64 === 'string') base64 = (r as { base64: string }).base64
629  } catch {
630    return { why: 'it could not be read' }
631  }
632  const c = checkBytes(base64, null, types, maxBytes)
633  if ('why' in c) return c
634  return { img: { uri: `data:${c.mime};base64,${base64}`, bytes: c.bytes, png: c.mime === 'image/png' ? base64 : null } }
635}
636
637/** Loads (or keeps) the avatar image of an agent type; bumps `avatarRevA` when what draws changed. */
638async function loadAvatar($: EngineInterface, type: string) {
639  const key = avatarKey(type)
640  const found = cfg.avatarStyle === 'initials' ? null : resolveAgent(levelsNow, 'avatarImage', key)
641  const sig = found === null ? '' : `${found.value}|${cfg.avatarImageTypes}|${cfg.avatarImageMaxBytes}`
642  const had = avatarCache[key]
643  if (had !== undefined && had.sig === sig) return
644  if (found === null) {
645    avatarCache[key] = { sig, img: null, why: '', setting: '' }
646  } else {
647    const setting = `avatarImage.${key} [${found.source}]`
648    const r = await readImage($, found.value, typesOf(cfg.avatarImageTypes), cfg.avatarImageMaxBytes)
649    avatarCache[key] = 'img' in r ? { sig, img: r.img, why: '', setting } : { sig, img: null, why: r.why, setting }
650  }
651  if ((had?.img ?? null) !== null || avatarCache[key]!.img !== null) await update($, avatarRevA, n => (n as number) + 1)
652}
653
654/** What the drawings need of the avatars: the style, and each agent's loaded image. */
655const chartAvatars = (s: LensSettings): ChartAvatars => ({
656  style: s.avatarStyle,
657  image: key => (s.avatarStyle === 'initials' ? null : (avatarCache[key]?.img ?? null)),
658})
659
660/** The refused avatar images, one line each, for /ai-dlc:lens-configure. */
661const avatarNotes = (): string[] =>
662  Object.values(avatarCache)
663    .filter(c => c.why !== '')
664    .map(c => `${c.setting}: refused, ${c.why}; the default avatar draws`)
665
666const chartSize = (s: LensSettings): ChartSize => ({ col: s.chartColumnPx, row: s.chartRowPx, perRow: s.chartAvatarsPerRow, r: s.avatarRadiusPx, blink: s.blink, blinkMs: s.blinkMs })
667
668/** Addendum A7: the chart settings in full screen: columns, rows and avatars 1.5 times larger, within each setting's maximum. */
669const fullscreenSettings = (s: LensSettings): LensSettings => ({
670  ...s,
671  chartColumnPx: Math.min(400, Math.round(s.chartColumnPx * 1.5)),
672  chartRowPx: Math.min(400, Math.round(s.chartRowPx * 1.5)),
673  avatarRadiusPx: Math.min(40, Math.round(s.avatarRadiusPx * 1.5)),
674})
675
676/** The model metadata file: `.ai-dlc/model-metadata.json`, else its earlier name `.ai-dlc/model-costing.json` (read only). */
677async function loadCosting($: EngineInterface) {
678  let c: LensCosting = { path: cfg.costingPath, text: '', error: `${cfg.costingPath} and ${cfg.costingFallbackPath} not found` }
679  for (const path of [cfg.costingPath, cfg.costingFallbackPath]) {
680    const text = await readText($, path)
681    if (text !== null) {
682      c = { path, text, error: '' }
683      break
684    }
685  }
686  await update($, costingA, () => c)
687}
688
689async function loadRoster($: EngineInterface) {
690  const text = await readText($, cfg.rosterPath)
691  await update($, rosterA, () => (text === null ? {} : parseRoster(text)))
692}
693
694/** Addendum L1: where each run folder without a run.md was merged (`mergedHomeOf`), by run id; a miss is looked up again after MERGED_RETRY_MS. */
695let mergedSeen: Record<string, { at: number; hit: { home: string; file: string } | null }> = {}
696const MERGED_RETRY_MS = 30_000
697
698/**
699 * Addendum L1: for each run folder whose run.md is gone, the run folder that keeps it as `merged-from-<run>.md`.
700 * Lists the run folders (`runFolders`) only when a folder is not known yet, or its last miss is older than
701 * MERGED_RETRY_MS; a found one is kept for the session.
702 */
703async function mergedHomes($: EngineInterface, missing: readonly string[]): Promise<Record<string, { home: string; file: string }>> {
704  const out: Record<string, { home: string; file: string }> = {}
705  if (missing.length === 0) return out
706  const now = nowMs()
707  const stale = missing.filter(d => {
708    const seen = own(mergedSeen, runIdOf(d))
709    return seen === undefined || (seen.hit === null && now - seen.at > MERGED_RETRY_MS)
710  })
711  if (stale.length > 0) {
712    const folders: { dir: string; names: string[] }[] = []
713    for (const f of await runFolders($, [])) {
714      try {
715        folders.push({ dir: f, names: ((await $.fs.list(f)) as ListEntry[]).filter(e => e.kind !== 'dir').map(e => e.name) })
716      } catch {
717        // not listable: no merged-from file read from it
718      }
719    }
720    for (const d of stale) mergedSeen = { ...mergedSeen, [runIdOf(d)]: { at: now, hit: mergedHomeOf(d, folders) } }
721  }
722  for (const d of missing) {
723    const hit = own(mergedSeen, runIdOf(d))?.hit
724    if (hit) out[d] = hit
725  }
726  return out
727}
728
729/**
730 * Re-reads the `run.md` of every run a brief named (round 5): the pane's titles and status follow the
731 * run as it moves. Written only when something changed, so a pane redraws only then.
732 */
733async function refreshRuns($: EngineInterface) {
734  const next: Record<string, LensRun> = {}
735  const missing: string[] = []
736  for (const d of await runDirs($)) {
737    const r = parseRun(await readRunState($, d))
738    if (r !== null) next[d] = r
739    else missing.push(d)
740  }
741  // Addendum L1: a run folder merged away (its briefs predate the merge) is read from the `merged-from-<run>.md`
742  // the run it joined keeps, and drawn in that run's chart as a child run (`parent:` set to that run).
743  const merged = new Set<string>()
744  for (const [d, m] of Object.entries(await mergedHomes($, missing))) {
745    const r = parseRun(await readText($, `${m.home}/${m.file}`))
746    if (r === null) continue
747    next[d] = { ...r, parent: folderNameOf(m.home) }
748    merged.add(d)
749    if (!Object.keys(next).some(k => runIdOf(k) === runIdOf(m.home))) {
750      const h = parseRun(await readRunState($, m.home))
751      if (h !== null) next[m.home] = h
752    }
753  }
754  // Step 27.1: a run's `parent:` names a run folder beside it; its run.md is read too, up the chain (each
755  // folder once, at most PARENT_DEPTH * 4 such reads), so the child can be drawn in its parent's chart (`runRoots`).
756  const queue = Object.keys(next)
757  let reads = 0
758  for (let i = 0; i < queue.length && reads < PARENT_DEPTH * 4; i++) {
759    const d = queue[i]!
760    const p = next[d]?.parent
761    if (p === undefined) continue
762    const base = d.replace(/[\\/]+$/, '')
763    const pd = base.includes('/') || base.includes('\\') ? `${base.replace(/[\\/][^\\/]*$/, '')}/${p}` : p
764    if (Object.keys(next).some(k => runIdOf(k) === runIdOf(pd))) continue
765    reads++
766    const r = parseRun(await readRunState($, pd))
767    if (r === null) continue
768    next[pd] = r
769    queue.push(pd)
770  }
771  // Each run folder's record files, so the workflow view draws one chart per step from them. A run
772  // merged away (its folder gone, its run state read from merged-from-<run>.md) or a folder that cannot be
773  // listed keeps none. And its per-code renames, from its rename sources (`isRenameSource`), read
774  // oldest name first (a dated name sorts by date) through `recordText`, so an unchanged file is not read again.
775  for (const d of Object.keys(next)) {
776    if (merged.has(d)) continue
777    let entries: ListEntry[]
778    try {
779      entries = ((await $.fs.list(d)) as ListEntry[]).filter(e => e.kind !== 'dir')
780    } catch {
781      continue // not listable: no records from it
782    }
783    const names = entries.filter(e => parseRecordName(e.name) !== null).map(e => e.name)
784    const maps: Record<string, string>[] = []
785    for (const e of entries.filter(x => isRenameSource(x.name)).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)))
786      maps.push(codeMapOf(e.name, await recordText($, `${d}/${e.name}`, sigOf(e))))
787    const codeMap = composeCodeMaps(maps)
788    next[d] = { ...next[d]!, records: names.sort(), ...(Object.keys(codeMap).length > 0 ? { codeMap } : {}) }
789  }
790  const prev = await read($, runsA)
791  if (JSON.stringify(prev) !== JSON.stringify(next)) await update($, runsA, () => next)
792}
793
794/** A plugin's installed copies: `<plugins cache>/<marketplace>/<plugin>/<version>/`, the newest release first. */
795async function installedRoots($: EngineInterface, plugin: string): Promise<string[]> {
796  const base = `${$.plugin.root}/../../${plugin}`
797  try {
798    const vers = ((await $.fs.list(base)) as { name: string; kind: string }[]).filter(v => v.kind === 'dir').map(v => v.name)
799    const rank = (v: string) => (v.includes('-') ? 0 : 1)
800    vers.sort((a, b) => rank(b) - rank(a) || b.localeCompare(a, undefined, { numeric: true }))
801    return vers.map(v => `${base}/${v}`)
802  } catch {
803    return []
804  }
805}
806
807/** The definition of an agent type (finding 1d): this repository's plugin source, else the installed plugin, else `.claude/agents/`. */
808async function loadDef($: EngineInterface, type: string) {
809  await loadAvatar($, type)
810  if (type === '') return
811  const have = await read($, defsA)
812  if (own(have, type)) return
813  const plugin = pluginOf(type)
814  const name = roleOf(type)
815  const first = plugin !== '' ? `plugins/claude/${plugin}/agents/${name}.md` : `.claude/agents/${name}.md`
816  let def: LensDef = { model: '', persona: '', path: '' }
817  let text = await readText($, first)
818  let path = first
819  if (text === null && plugin !== '') {
820    // The installed copy is listed only when this repository has no source for it.
821    for (const r of await installedRoots($, plugin)) {
822      path = `${r}/agents/${name}.md`
823      text = await readText($, path)
824      if (text !== null) break
825    }
826  }
827  if (text !== null) def = { ...parseDefinition(text), path: path.replace(/^.*[\\/]plugins[\\/]cache[\\/]/, '~/.claude/plugins/cache/') }
828  await update($, defsA, m => ({ ...(m as Record<string, LensDef>), [type]: def }))
829}
830
831/** What the chart and the Buttons call an agent: persona, model, role (finding 1d). */
832function labeler(roster: Record<string, string>, defs: Record<string, LensDef>, mainModel: string) {
833  return (a: LensAgent): ChartLabel => {
834    const def = own(defs, a.type)
835    const usable = def && def.path !== '' ? def : undefined
836    return {
837      name: personaOf(a, roster, usable),
838      model: modelOf(usable, a.callModel, a.parentModel || mainModel || a.model).text,
839      role: a.id === 'main' ? (a.type === '' ? 'Main loop' : humanRole(a.type)) : humanRole(a.type) || 'Agent',
840    }
841  }
842}
843
844/** Whether a [+]/[-] section is open: the person's choice, else the `collapsedByDefault` setting. */
845const isOpen = (open: Record<string, boolean>, key: string, s: LensSettings) => open[key] ?? !s.collapsedByDefault
846const toggleOpen = ($: EngineInterface, key: string, s: LensSettings) =>
847  update($, cardOpenA, m => ({ ...(m as Record<string, boolean>), [key]: !((m as Record<string, boolean>)[key] ?? !s.collapsedByDefault) }))
848
849// ---- probes (E1-E9) ----
850async function plog($: EngineInterface, exp: string, data: unknown) {
851  if (!probeOn || probePath === '') return
852  let line: string
853  try {
854    line = JSON.stringify({ t: new Date().toISOString(), exp, data })
855  } catch {
856    line = JSON.stringify({ t: new Date().toISOString(), exp, data: String(data) })
857  }
858  probeLog.push(line)
859  if (probeLog.length > 600) probeLog.shift()
860  const text = probeLog.join('\n') + '\n'
861  probeWrite = probeWrite.then(() => $.fs.write(probePath, text)).catch(() => undefined)
862  await update($, probeLinesA, n => n + 1)
863}
864
865async function ensureAgent($: EngineInterface, id: string, parent: string | null, type: string) {
866  // The existence check runs inside the write (round 3 root cause): a stale "absent" never overwrites.
867  await update($, agentsA, m => ensureIn(m as Record<string, LensAgent>, id, () => blankAgent(id, parent, type)))
868  await update($, orderA, o => (o.includes(id) ? o : [...o, id]))
869}
870
871async function patchAgent($: EngineInterface, id: string, fn: (a: LensAgent) => LensAgent) {
872  await update($, agentsA, m => {
873    const cur = (m as Record<string, LensAgent>)[id]
874    return cur ? { ...m, [id]: fn(cur) } : m
875  })
876}
877
878async function pushCall($: EngineInterface, key: string, call: LensCall) {
879  await update($, callsA, m => {
880    const list = ((m as Record<string, LensCall[]>)[key] ?? []).filter(c => c.id !== call.id)
881    return { ...m, [key]: [...list, call].slice(-cfg.callsLimit) }
882  })
883}
884
885async function refreshList($: EngineInterface, reason: string) {
886  let list: any[] = []
887  try {
888    list = (await $.agent.list()) as any[]
889  } catch (err) {
890    await plog($, 'E4', { reason, error: String(err) })
891    return
892  }
893  const changes: unknown[] = []
894  for (const a of list) {
895    if (lastStatus[a.id] !== a.status) changes.push({ id: a.id, type: a.type, status: a.status, was: lastStatus[a.id] ?? null, parentId: a.parentId ?? null, teammateId: a.teammateId ?? null, name: a.name ?? null })
896    lastStatus[a.id] = a.status
897  }
898  if (changes.length > 0) await plog($, 'E4', { reason, changes, listed: list.length })
899  // Background tasks: each agent's listed children (step 16 Q6).
900  await update($, agentsA, m => {
901    const out: Record<string, LensAgent> = { ...(m as Record<string, LensAgent>) }
902    for (const key of Object.keys(out)) {
903      const kids: LensTask[] = list
904        .filter(a => (a.parentId ?? 'main') === key)
905        .map(a => ({ id: String(a.id), type: 'subagent', agentType: String(a.type), status: String(a.status), description: String(a.description ?? ''), source: 'agent.list' }))
906      const stopTasks = out[key]!.bg.filter(t => t.source !== 'agent.list')
907      out[key] = { ...out[key]!, bg: stampTasks(out[key]!.bg, uniqueTasks([...kids, ...stopTasks]), nowMs()) }
908    }
909    for (const a of list) {
910      const cur = out[a.id]
911      // A list that still says `running` never undoes a `turn.complete` (finding 6).
912      // Round 6: nor puts `running` back over an `idle` the turn's end set (the list lags, E4); a turn's
913      // own start sets `running`. It may still start a pending agent or wake a waiting one.
914      const stale = a.status === 'running' && cur !== undefined && cur.status !== 'pending' && cur.status !== 'waiting'
915      if (cur && cur.status !== a.status && a.status !== 'completed' && !ENDED.has(cur.status) && !stale) out[a.id] = { ...cur, status: String(a.status) }
916      if (out[a.id] && typeof a.teammateId === 'string' && out[a.id]!.teammate !== true) out[a.id] = { ...out[a.id]!, teammate: true }
917    }
918    return out
919  })
920}
921
922/**
923 * A record for an agent whose brief names none, by time order and agent type (step 16 Q7): shown only as a
924 * hint since round 3, never as the record. Searched in every run folder (`runFolders`), so under the
925 * workflow's `artifacts.handoffs` too.
926 */
927async function findFallbackRecord($: EngineInterface, role: string, startedAt: number): Promise<string | null> {
928  let best: { path: string; mtime: number } | null = null
929  try {
930    for (const dir of await runFolders($, [])) {
931      for (const f of (await $.fs.list(dir)) as ListEntry[]) {
932        if (f.kind !== 'file' || !f.name.endsWith(`-to-${role}.handoff.md`)) continue
933        const mt = f.mtimeMs ?? 0
934        if (mt >= startedAt - 600_000 && mt <= startedAt + 60_000 && (best === null || mt > best.mtime)) best = { path: `${dir}/${f.name}`, mtime: mt }
935      }
936    }
937  } catch {
938    return null
939  }
940  return best?.path ?? null
941}
942
943/** The arrow panel's content (finding 2): the record the brief named and its hand-back, else the prompt. */
944async function loadDoc($: EngineInterface, a: LensAgent) {
945  const doc: LensDoc = { ...DOC0, agent: a.id, prompt: a.prompt }
946  const path = a.handoff
947  if (path === null) {
948    if (a.startedAt > 0) {
949      const hint = await findFallbackRecord($, roleOf(a.type), a.startedAt)
950      if (hint !== null) doc.hint = hint
951    }
952    await update($, docA, () => doc)
953    await plog($, 'E8', { via: 'arrow press', path: null, prompt: a.prompt.length, hint: doc.hint })
954    return
955  }
956  doc.handoff = path
957  const text = await readText($, path)
958  if (text === null) doc.error = `read ${path}: not found`
959  else doc.handoffText = cap(text, cfg.recordChars)
960  const rec = parseRecord(path)
961  if (rec) {
962    try {
963      const names = ((await $.fs.list(rec.dir)) as { name: string }[]).map(f => f.name)
964      const hb = pickHandback(names, rec)
965      if (hb !== null) {
966        doc.handback = `${rec.dir}/${hb}`
967        doc.handbackText = cap((await readText($, doc.handback)) ?? '', cfg.recordChars)
968      } else doc.handbackText = '(no hand-back yet)'
969    } catch (err) {
970      doc.error += ` list ${rec.dir}: ${String(err)}`
971    }
972  }
973  await update($, docA, () => doc)
974  await plog($, 'E8', { via: 'arrow press', path, ok: doc.handoffText !== '', handback: doc.handback, error: doc.error })
975}
976
977/** Step 1.5: how a Button opens its details now: the view's `Panel Type` switch, else the `panelOpenMode` setting. */
978async function openModeNow($: EngineInterface): Promise<'tab' | 'drill-in'> {
979  const conf = await read($, lensA)
980  return openModeOf(await read($, openModeA), String(conf.settings.panelOpenMode))
981}
982
983/**
984 * Opens an arrow's details (`snap`: the agent as the drawing drew it). Step 1.5: `tab` opens the separate
985 * pane (a tab beside the others, PaneOpenArgs, d.ts:7588-7660); `drill-in` shows it inside the drawing
986 * it was pressed in (`target`: `lens`, `pane` or `card:<call>`) with "< Back". Both work from every drawing.
987 */
988async function openEdge($: EngineInterface, snap: LensAgent, title: string, target: string) {
989  const mode = await openModeNow($)
990  await loadDoc($, snap)
991  if (mode === 'drill-in') {
992    await update($, drillsA, m => ({ ...(m as Record<string, LensDrill>), [target]: { kind: 'edge', id: snap.id, snap } }))
993    return
994  }
995  await update($, uiA, x => ({ ...x, edge: snap.id, edgeSnap: snap }))
996  await update($, tabsMinA, m => ({ ...(m as Record<string, boolean>), [EDGE]: false }))
997  // Round 6 (A4): Esc closes it while it holds the keys (PaneOpenArgs.closeOnEscape, d.ts:7614-7624); `focus` asks for them.
998  const r = await $.ui.open({ id: EDGE, title: cap(`Hand-off: ${title}`, 60), focus: true, closeOnEscape: true })
999  await plog($, 'E3', { open: EDGE, result: r })
1000}
1001
1002/** Opens an avatar's details, as `openEdge`. */
1003async function openAgent($: EngineInterface, snap: LensAgent, title: string, target: string) {
1004  const mode = await openModeNow($)
1005  await refreshList($, 'avatar press')
1006  if (mode === 'drill-in') {
1007    await update($, drillsA, m => ({ ...(m as Record<string, LensDrill>), [target]: { kind: 'agent', id: snap.id, snap } }))
1008    return
1009  }
1010  await update($, uiA, x => ({ ...x, agent: snap.id, agentSnap: snap }))
1011  await update($, tabsMinA, m => ({ ...(m as Record<string, boolean>), [AGENT]: false }))
1012  const r = await $.ui.open({ id: AGENT, title: cap(`Agent: ${title}`, 60), focus: true, closeOnEscape: true })
1013  await plog($, 'E3', { open: AGENT, result: r })
1014}
1015
1016/** Leaves a drawing's drill-in view ("< Back"). */
1017async function drillBack($: EngineInterface, target: string) {
1018  await update($, drillsA, m => {
1019    const { [target]: _gone, ...rest } = m as Record<string, LensDrill>
1020    return rest
1021  })
1022}
1023
1024/** The surfaces the session draws on (`$.session.surfaces()`, d.ts:2864-2875); [] when unknown. Never throws. */
1025async function surfacesNow($: EngineInterface): Promise<string[]> {
1026  try {
1027    return [...((await $.session.surfaces()) as readonly string[])]
1028  } catch {
1029    return []
1030  }
1031}
1032
1033/**
1034 * Step 1.5: (re)opens the workflow pane at the view's size. Full screen asks the whole viewport
1035 * (PaneOpenArgs `rows`, `columns`, d.ts:7638-7658); any other size asks nothing, so the surface's own
1036 * size applies again. Where it is seated (dock or inline) is the surface's (Pane `placement`, d.ts:10895-10902).
1037 */
1038async function openWorkflowPane($: EngineInterface, v: LensView, focus = false) {
1039  return $.ui.open({ id: CHART, title: VIEW_TITLE, closeOnEscape: true, ...(focus ? { focus: true } : {}), ...openSizeOf(v.size, lastViewport) })
1040}
1041
1042/**
1043 * Step 1.5: shows the view where its place says. Pinned: the band above the prompt (AbovePrompt, raised
1044 * on the terminal and desktop only, d.ts:10809-10819); the pane closes only when every attached surface
1045 * draws that band, else it stays a pane. Docked: the pane.
1046 */
1047async function showView($: EngineInterface, v: LensView, focus = false): Promise<void> {
1048  // Addendum A7: full screen is always the pane, raised, never the band (the band only has the rows the
1049  // bottom slot spares, AbovePrompt `maxRows`); restored, a pinned view goes back to the band.
1050  if (v.size === 'fullscreen') {
1051    await openWorkflowPane($, v, true)
1052    return
1053  }
1054  if (await onBand($, v)) {
1055    // An id that is not open is left alone (`$.ui.close`, d.ts:2538).
1056    await $.ui.close({ id: CHART })
1057    return
1058  }
1059  await openWorkflowPane($, v, focus)
1060}
1061
1062/** Step 1.5: whether the band above the prompt shows the view: pinned, not full screen, and every attached surface draws the band. */
1063async function onBand($: EngineInterface, v: LensView): Promise<boolean> {
1064  if (v.size === 'fullscreen') return false
1065  const conf = await read($, lensA)
1066  return placeOf(v, String(conf.settings.workflowPlace)) === 'pinned' && bandEverywhere(await surfacesNow($))
1067}
1068
1069/**
1070 * Step 36.4 (rcm-spec): `closeOldCard` at most once per load (`oldCardSwept`, set before the close so two renders in
1071 * flight never close twice). Not awaited by the band's render; `closeOldCard` logs a failing close and never throws.
1072 */
1073function closeOldCardOnce($: EngineInterface) {
1074  if (oldCardSwept) return
1075  oldCardSwept = true
1076  void closeOldCard($)
1077}
1078
1079/**
1080 * Step 36.2.3, amendment 7: closes the old "AI-DLC lens" card (`OLD_CARD`), at load and before `/ai-dlc:lens` shows
1081 * the view. An id that is not open is left alone (`$.ui.close`, d.ts:2538); a failing close is logged, never thrown.
1082 */
1083async function closeOldCard($: EngineInterface) {
1084  try {
1085    await $.ui.close({ id: OLD_CARD })
1086  } catch (err) {
1087    lensFail('close old card', err)
1088  }
1089}
1090
1091/** Step 36.2: whether the AI-DLC Lens pane is open (`$.ui.panes()`, d.ts:2563); false when the host cannot say. Never throws. */
1092async function viewPaneOpen($: EngineInterface): Promise<boolean> {
1093  try {
1094    return (await $.ui.panes()).some(p => p.id === CHART)
1095  } catch {
1096    return false
1097  }
1098}
1099
1100/**
1101 * Step 36.2: `/ai-dlc:lens`. Docked, or where the band is not drawn: opens the AI-DLC Lens pane when it is closed and
1102 * raises it (`focus`) when it is open. On the band: one step per run (`bandStep`). Returns the one-line reply.
1103 */
1104async function openLens($: EngineInterface): Promise<string> {
1105  await closeOldCard($)
1106  // Amendment 8: the command brings back a band closed with its `×`.
1107  await update($, bandClosedA, () => false)
1108  const v = await read($, viewA)
1109  const wasOpen = await viewPaneOpen($)
1110  if (await onBand($, v)) return bandStep($, v, wasOpen)
1111  const r = await openWorkflowPane($, v, true)
1112  if (!r.isPlaced) return `${VIEW_TITLE} is open but not drawn yet: ${r.reason}`
1113  return `${VIEW_TITLE} ${wasOpen ? 'brought forward' : 'opened'}.`
1114}
1115
1116/**
1117 * Step 36.2, amendment 4: the band starts minimised whenever it appears (load, the first spawn, docked -> pinned).
1118 * Only a normal size changes; full screen and an already minimised band are kept. Call it only when `onBand` holds.
1119 */
1120async function bandStart($: EngineInterface) {
1121  await update($, viewA, x => ((x as LensView).size === 'normal' ? viewResize(x as LensView, 'minimised') : (x as LensView)))
1122}
1123
1124/** Step 36.2: the band's list view from a minimised band: the size before when it is the list, else normal. */
1125function bandList(x: LensView): LensView {
1126  const back = viewRestore(x)
1127  return back.size === 'normal' ? back : viewResize(x, 'normal')
1128}
1129
1130type Latest = NonNullable<Awaited<ReturnType<typeof latestFlow>>>
1131
1132/** Step 36.2: how a reply names the workflow the pane goes to: the latest in progress, else the most recent. */
1133const latestWhich = (latest: Latest): string =>
1134  latest.live ? `the latest in-progress workflow, ${latest.title}` : `the most recent workflow, ${latest.title} (none is in progress)`
1135
1136/**
1137 * Step 36.2, amendment 3: the pane beside the band, opened (or raised) on `w`, focused; the band is not touched.
1138 * Amendment 8: docked (the band drawn because a workflow is in progress), the docked pane itself selects `w`.
1139 */
1140async function paneOn($: EngineInterface, w: string): Promise<UiOpenResult> {
1141  if (!(await onBand($, await read($, viewA)))) {
1142    await update($, viewA, x => ({ ...(x as LensView), selected: w }))
1143    return openWorkflowPane($, await read($, viewA), true)
1144  }
1145  await update($, viewA, x => ({ ...(x as LensView), pane: w }))
1146  return openWorkflowPane($, paneViewOf(await read($, viewA)), true)
1147}
1148
1149/**
1150 * Step 36.2, amendment 6: the band's `⤢` (and a step chart's `⤢` on the band). The band stays as it is, minimised or
1151 * its list, and is never hidden; the pane opens beside it on the latest workflow, or switches to it from an older one
1152 * (`latestFlow`: the newest in progress, else the newest), or, from a step chart, on that chart's workflow.
1153 */
1154async function expandBand($: EngineInterface, w?: string) {
1155  // A button press has no reply line, unlike `/ai-dlc:lens` (`bandStep`): what it did is logged, like the other
1156  // panes' opens (E3). With no workflow there is nothing to open, and the band already reads "Nothing Here".
1157  const target = w ?? (await latestFlow($))?.w
1158  if (target === undefined) {
1159    await plog($, 'E3', { open: null, via: 'band expand', reason: 'no workflow' })
1160    return
1161  }
1162  const r = await paneOn($, target)
1163  await plog($, 'E3', { open: CHART, via: 'band expand', workflow: target, result: r })
1164}
1165
1166/**
1167 * Step 36.2, amendments 1 and 4: `/ai-dlc:lens` while the band shows the view. Each run moves one step, the rules
1168 * checked in this order: (1) the band minimised: its list view is restored, the pane untouched; (2) the band in list
1169 * view and the pane closed: the pane opens on the latest workflow (`latestFlow`: the newest in progress, else the
1170 * newest) and the band is minimised; (3) the pane open on another workflow: it switches to the latest; (4) the pane
1171 * already on the latest: the band is minimised and the pane stays open on it. With the pane open, each run so toggles
1172 * the band between minimised and its list. Amendment 3: the pane beside the band keeps its own run
1173 * (`LensView.pane`) and is drawn at its normal size whatever the band's (`paneViewOf`), so steps 2 and 3 set `pane`,
1174 * never the band's `selected`, and minimising the band never closes the pane. Steps 1 to 4 set `viewA` directly,
1175 * never `changeView`, so nothing else opens or closes.
1176 */
1177async function bandStep($: EngineInterface, v: LensView, paneOpen: boolean): Promise<string> {
1178  if (v.size === 'minimised') {
1179    await update($, viewA, x => bandList(x as LensView))
1180    return `${VIEW_TITLE}: the band's list view is restored.`
1181  }
1182  const latest = await latestFlow($)
1183  if (latest === null) return `${VIEW_TITLE} is pinned to the band above the prompt, which draws it once a workflow starts; no pane opened.`
1184  const which = latestWhich(latest)
1185  const minimise = () => update($, viewA, x => viewResize(x as LensView, 'minimised'))
1186  const drawn = (r: UiOpenResult, text: string) => (r.isPlaced ? text : `${VIEW_TITLE}: the pane is open on ${which} but not drawn yet: ${r.reason}`)
1187  if (!paneOpen) {
1188    const r = await paneOn($, latest.w)
1189    await minimise()
1190    return drawn(r, `${VIEW_TITLE}: the pane opened on ${which}; the band is minimised.`)
1191  }
1192  if (selectedOf(paneViewOf(v), latest.ws) !== latest.w) return drawn(await paneOn($, latest.w), `${VIEW_TITLE}: the pane switched to ${which}.`)
1193  await minimise()
1194  return `${VIEW_TITLE}: the band is minimised; the pane stays open on ${which}.`
1195}
1196
1197/**
1198 * Step 1.5: a view change (place, size, selection), then the view shown where and how it now says. Step 36.2,
1199 * amendment 3: a change that leaves the view on the band (its size or selection, the pane's run) neither opens nor
1200 * closes the pane, so a pane opened beside the band stays open, on its run, while the band is minimised or restored.
hooks/lens.ts 2494 lines
1// ai-dlc-lens (from spike ITEM-027, rounds 1-6, steps 17 to 17.l5): pure helpers of the lens card and the workflow pane. No `$` here.
2import type { LensAgent, LensBatch, LensDef, LensKeepaliveCycle, LensPending, LensRun, LensTask, LensView, LensWorkflows, Tokens } from '../types'
3import { NO_IMAGES, SVG_CAP, avatarKey, imageInline, own, robotInline, robotOf } from './avatar'
4import type { AvatarImage, ChartAvatars } from './avatar'
5
6export const ZERO: Tokens = { in: 0, out: 0, cr: 0, cw: 0 }
7
8/**
9 * A drawing's guard: `draw()`, or, when it throws, `fail(err)` and then `fallback(err)`. The ToolUse
10 * card falls back to the native row as the host drew it, a pane to one line naming the fault.
11 */
12export async function drawOr<T>(draw: () => Promise<T>, fallback: (err: unknown) => T, fail: (err: unknown) => void): Promise<T> {
13  try {
14    return await draw()
15  } catch (err) {
16    fail(err)
17    return fallback(err)
18  }
19}
20
21/**
22 * A pane's fault as one red line naming the pane and the fault: drawn with the kit's Text (`viaKit`), or,
23 * when that throws too (resolving the kit was what failed), as the plain Text element the kit would build.
24 * Never throws.
25 */
26export function paneFault<T>(site: string, err: unknown, viaKit: (line: string) => T): T | { type: 'Text'; props: { color: string }; children: string[] } {
27  const line = `lens: the ${site} could not be drawn (${String(err).slice(0, 200)})`
28  try {
29    return viaKit(line)
30  } catch {
31    return { type: 'Text', props: { color: 'red' }, children: [line] }
32  }
33}
34
35export const addTokens = (a: Tokens, b: Tokens): Tokens => ({
36  in: a.in + b.in,
37  out: a.out + b.out,
38  cr: a.cr + b.cr,
39  cw: a.cw + b.cw,
40})
41
42/** `ModelUsage` (D:6605-6627) to Tokens; `input_tokens` is the uncached part. */
43export const fromUsage = (u: {
44  input_tokens: number
45  output_tokens: number
46  cache_read_input_tokens: number
47  cache_creation_input_tokens: number
48}): Tokens => ({
49  in: u.input_tokens,
50  out: u.output_tokens,
51  cr: u.cache_read_input_tokens,
52  cw: u.cache_creation_input_tokens,
53})
54
55/** 950, 12.3K, 100K, 1M, 1.2M. */
56export function fmtTokens(n: number): string {
57  if (!Number.isFinite(n) || n < 0) return '?'
58  const trim = (x: number) => (Math.round(x * 10) / 10).toString()
59  if (n < 1000) return String(Math.round(n))
60  if (n < 999_950) return trim(n / 1000) + 'K'
61  return trim(n / 1_000_000) + 'M'
62}
63
64/** `claude-opus-5-5` -> `opus/opus-5.5`; `claude-sonnet-4-5-20250929` -> `sonnet/sonnet-4.5`; an alias stays. */
65export function fmtModel(id: string): string {
66  if (id === '') return '?'
67  const oneM = /\[1m\]/i.test(id)
68  let s = id.replace(/\[1m\]/i, '').replace(/^claude-/, '').replace(/-\d{8}$/, '')
69  if (!s.includes('-')) return s + (oneM ? '[1m]' : '')
70  const family = s.split('-')[0] ?? s
71  const m = /^([a-z]+)-(\d+)(?:-(\d+))?(.*)$/.exec(s)
72  if (m) s = `${m[1]}-${m[2]}${m[3] !== undefined ? '.' + m[3] : ''}${m[4] ?? ''}`
73  return `${family}/${s}${oneM ? '[1m]' : ''}`
74}
75
76/** `ai-dlc:agent-manager` -> `agent-manager` (the agent ID). */
77export const roleOf = (type: string): string => type.replace(/^[^:]*:/, '')
78
79/** `ai-dlc:agent-manager` -> `ai-dlc`; no plugin -> ''. */
80export const pluginOf = (type: string): string => (type.includes(':') ? type.slice(0, type.indexOf(':')) : '')
81
82/** `agent-manager` -> `Agent Manager`. */
83export const titleCase = (role: string): string =>
84  role
85    .split(/[-_\s]+/)
86    .filter(w => w !== '')
87    .map(w => w[0]!.toUpperCase() + w.slice(1))
88    .join(' ')
89
90/** Words a role keeps in capitals, or spelled its own way (finding 1d.iii). */
91const ROLE_WORDS: Record<string, string> = {
92  sqa: 'SQA',
93  qa: 'QA',
94  pm: 'PM',
95  po: 'PO',
96  ui: 'UI',
97  ux: 'UX',
98  api: 'API',
99  ci: 'CI',
100  cd: 'CD',
101  ai: 'AI',
102  dlc: 'DLC',
103  devops: 'DevOps',
104}
105
106/** The role in human-readable form: `sqa-engineer` -> `SQA Engineer`, `devops-engineer` -> `DevOps Engineer`. */
107export const humanRole = (type: string): string =>
108  roleOf(type)
109    .split(/[-_\s]+/)
110    .filter(w => w !== '')
111    .map(w => own(ROLE_WORDS, w.toLowerCase()) ?? w[0]!.toUpperCase() + w.slice(1))
112    .join(' ')
113
114/** The roster table of the orchestrator's memory (`| agent-id | Name |`) as id -> name. */
115export function parseRoster(text: string): Record<string, string> {
116  const out: Record<string, string> = {}
117  for (const line of text.split(/\r?\n/)) {
118    const m = /^\|\s*([a-z0-9][a-z0-9-]*)\s*\|\s*([^|]+?)\s*\|\s*$/.exec(line)
119    if (m && m[1] !== 'agent' && !/^-+$/.test(m[2]!)) out[m[1]!] = m[2]!
120  }
121  return out
122}
123
124/** An agent definition's frontmatter `model:` and its `Persona name: **X**` line. */
125export function parseDefinition(text: string): { model: string; persona: string } {
126  const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)
127  const model = fm ? (/^model:\s*["']?([^"'\r\n#]+?)["']?\s*$/m.exec(fm[1]!)?.[1] ?? '') : ''
128  const persona = /Persona name:\s*\*\*([^*]+)\*\*/.exec(text)?.[1]?.trim() ?? ''
129  return { model: model.trim(), persona }
130}
131
132/**
133 * The model line (finding 1d.ii): the definition's `model:` as written (an alias stays an alias,
134 * a full id stays the id); `inherit` or none falls to the Agent call's `model`, then the session's.
135 */
136export function modelOf(def: LensDef | undefined, callModel: string, sessionModel: string): { text: string; source: string } {
137  const m = def?.model ?? ''
138  if (m !== '' && m !== 'inherit') return { text: m, source: `agent definition (${def!.path})` }
139  const why = m === 'inherit' ? 'definition says inherit' : def ? 'definition names no model' : 'no agent definition'
140  if (callModel !== '') return { text: callModel, source: `the orchestrator's Agent call (${why})` }
141  if (sessionModel !== '') return { text: sessionModel, source: `the session's model (${why})` }
142  return { text: '?', source: why }
143}
144
145/** The name line (finding 1d.i): the roster's persona name, else the definition's, else the role. */
146export function personaOf(a: Pick<LensAgent, 'id' | 'name' | 'type'>, roster: Record<string, string>, def: LensDef | undefined): string {
147  const id = roleOf(a.type)
148  const fromRoster = id === '' ? undefined : own(roster, id)
149  if (fromRoster) return fromRoster
150  if (def && def.persona !== '') return def.persona
151  if (a.name !== '') return a.name
152  if (a.id === 'main' && a.type === '') return 'Main'
153  return humanRole(a.type) || 'Agent'
154}
155
156/** The plain role, as before round 3: kept for the pane's dim lines. */
157export const displayName = (a: Pick<LensAgent, 'id' | 'name' | 'type'>): string => {
158  if (a.name !== '') return a.name
159  if (a.id === 'main') return a.type === '' ? 'Main' : humanRole(a.type)
160  return humanRole(a.type) || 'Agent'
161}
162
163/** `Kent Beck` -> `KB`; `Robert C. Martin (Uncle Bob)` -> `RM`: first and last word, parentheses dropped. */
164export const initials = (name: string): string => {
165  const words = name
166    .replace(/\([^)]*\)/g, ' ')
167    .split(/\s+/)
168    .filter(w => /^[A-Za-z]/.test(w))
169  if (words.length === 0) return '?'
170  const pick = words.length === 1 ? [words[0]!] : [words[0]!, words[words.length - 1]!]
171  return pick.map(w => w[0]!.toUpperCase()).join('')
172}
173
174/** Context used, as the engine counts it for main (D:8021): input + cache read + cache write + output of the last response. */
175export const contextUsed = (t: Tokens | null): number => (t === null ? 0 : t.in + t.cr + t.cw + t.out)
176
177// ---- `.ai-dlc/model-metadata.json`, earlier `.ai-dlc/model-costing.json` (finding 4, addenda): read only ----
178
179/** What the lens takes from one model's entry; `null` where the file has no figure. */
180export type ModelFacts = {
181  id: string
182  input: number | null
183  output: number | null
184  cacheRead: number | null
185  cacheWrite5m: number | null
186  cacheWrite1h: number | null
187  window: number | null
188  longWindow: number | null
189  /** Null unless a docs page or the Models API (`sources.modelsApi`) gives it (round 4: a null limit is not drawn). */
190  maxInput: number | null
191  maxOutput: number | null
192  /** Round 5: the prices of a request whose prompt is over the threshold; null for a model priced flat. */
193  long: LongPricing | null
194}
195
196/** The prices of one request, $ per million tokens; null where the file has no figure. */
197export type Prices = { input: number | null; output: number | null; cacheRead: number | null; cacheWrite5m: number | null; cacheWrite1h: number | null }
198
199/** `longContextPricing`: its prices and its `thresholdTokens` (null when the page names none). */
200export type LongPricing = Prices & { threshold: number | null }
201
202const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '')
203
204/** Every numeric leaf of an entry, keyed by its path with the separators dropped (`pricing.cacheWrite5m` -> `pricingcachewrite5m`). */
205function leaves(v: unknown, path = '', out: [string, number][] = []): [string, number][] {
206  if (typeof v === 'number' && Number.isFinite(v)) out.push([norm(path), v])
207  else if (v !== null && typeof v === 'object' && !Array.isArray(v)) for (const [k, x] of Object.entries(v as Record<string, unknown>)) leaves(x, path + '.' + k, out)
208  return out
209}
210
211const first = (ls: [string, number][], ok: (k: string) => boolean): number | null => ls.find(([k]) => ok(k))?.[1] ?? null
212
213const isTokens = (k: string) => /token|window|max|context|threshold/.test(k)
214
215/** The five prices among an entry's leaves; `long` false leaves the long-context ones out. */
216function pricesOf(ls: [string, number][], long: boolean): Prices {
217  const flat = (k: string) => long || !/long/.test(k)
218  return {
219    cacheRead: first(ls, k => /cache.*(read|hit)|(read|hit).*cache/.test(k) && !isTokens(k) && flat(k)),
220    cacheWrite5m: first(ls, k => /(cache.*(write|creation)|write.*cache).*5m|5m.*(write|creation)/.test(k) && !isTokens(k) && flat(k)),
221    cacheWrite1h: first(ls, k => /(cache.*(write|creation)|write.*cache).*1h|1h.*(write|creation)/.test(k) && !isTokens(k) && flat(k)),
222    input: first(ls, k => /input/.test(k) && !/cache|batch|fast/.test(k) && !isTokens(k) && flat(k)),
223    output: first(ls, k => /output/.test(k) && !/batch|fast/.test(k) && !isTokens(k) && flat(k)),
224  }
225}
226
227/** The entry's `longContextPricing` object (any key naming long-context prices); null when absent or null. */
228function longPricingOf(entry: unknown): LongPricing | null {
229  if (entry === null || typeof entry !== 'object') return null
230  const hit = Object.entries(entry as Record<string, unknown>).find(([k]) => /long.*pric|pric.*long/i.test(k))
231  const v = hit?.[1]
232  if (v === null || v === undefined || typeof v !== 'object' || Array.isArray(v)) return null
233  const ls = leaves(v)
234  return { ...pricesOf(ls, true), threshold: first(ls, k => /threshold/.test(k)) }
235}
236
237/**
238 * One entry's facts. The keys are matched by name, not position (the file had other names before
239 * `.ai-dlc/model-metadata.schema.json` landed): prices are $ per million tokens.
240 */
241export function factsOf(id: string, entry: unknown): ModelFacts {
242  const ls = leaves(entry)
243  return {
244    id,
245    ...pricesOf(ls, false),
246    window: first(ls, k => /window/.test(k) && !/long|1m|extended/.test(k)),
247    longWindow: first(ls, k => /window/.test(k) && /long|1m|extended/.test(k)),
248    maxInput: first(ls, k => /max.*input|input.*max/.test(k)),
249    maxOutput: first(ls, k => /max.*output|output.*max/.test(k)),
250    long: longPricingOf(entry),
251  }
252}
253
254/** The file's `longContextThresholdTokensOverride` (model-metadata.schema.json): a positive number, else null (round 6: that key only). */
255export function thresholdOverrideOf(json: unknown): number | null {
256  if (json === null || typeof json !== 'object' || Array.isArray(json)) return null
257  const v = (json as Record<string, unknown>).longContextThresholdTokensOverride
258  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null
259}
260
261/** The entries of the file: `models` as an array (`id`, `aliases`) or an object keyed by id; else the root. */
262function entries(json: unknown): { id: string; aliases: string[]; entry: unknown }[] {
263  const root = json !== null && typeof json === 'object' && 'models' in (json as object) ? (json as Record<string, unknown>).models : json
264  const out: { id: string; aliases: string[]; entry: unknown }[] = []
265  const add = (id: unknown, e: unknown) => {
266    if (e === null || typeof e !== 'object') return
267    const o = e as Record<string, unknown>
268    const name = typeof o.id === 'string' ? o.id : typeof o.model === 'string' ? o.model : typeof id === 'string' ? id : ''
269    if (name === '') return
270    const aliases = Array.isArray(o.aliases) ? o.aliases.filter((x): x is string => typeof x === 'string') : []
271    out.push({ id: name, aliases, entry: e })
272  }
273  if (Array.isArray(root)) root.forEach(e => add(undefined, e))
274  else if (root !== null && typeof root === 'object') for (const [k, e] of Object.entries(root as Record<string, unknown>)) add(k, e)
275  return out
276}
277
278/** The facts of `model` (a resolved id such as `claude-sonnet-5-5[1m]`, or an alias), or null when the file has none. */
279export function lookupModel(json: unknown, model: string): ModelFacts | null {
280  if (model === '') return null
281  const bare = (s: string) => s.toLowerCase().replace(/\[1m\]/g, '').replace(/-\d{8}$/, '')
282  const want = bare(model)
283  const all = entries(json)
284  const hit = all.find(e => bare(e.id) === want || e.aliases.some(a => bare(a) === want))
285  return hit ? factsOf(hit.id, hit.entry) : null
286}
287
288/** The costing file as the lens holds it: its raw text, or why there is none. */
289export type Costing = { path: string; text: string; error: string }
290
291export function parseCosting(c: Costing): unknown {
292  if (c.text === '') return null
293  try {
294    return JSON.parse(c.text)
295  } catch {
296    return null
297  }
298}
299
300/** The file and the model's facts a cost needs, or why there are none (`—`). Shared by every cost below (round 6). */
301function resolveCosting(c: Costing, model: string): { ok: true; json: unknown; f: ModelFacts } | { ok: false; source: string } {
302  if (c.text === '') return { ok: false, source: c.error !== '' ? c.error : `${c.path} not found` }
303  const json = parseCosting(c)
304  if (json === null) return { ok: false, source: `${c.path} is not valid JSON` }
305  const f = lookupModel(json, model)
306  if (f === null) return { ok: false, source: `${model || 'the model'} is not in ${c.path}` }
307  return { ok: true, json, f }
308}
309
310/** One request (or one total) at one set of prices, and the prices its tokens need that the file lacks. */
311function priceAt(p: Prices, t: Tokens, w: '5m' | '1h'): { usd: number; missing: string[] } {
312  const need: [number | null, number, string][] = [
313    [p.input, t.in, 'input'],
314    [p.output, t.out, 'output'],
315    [p.cacheRead, t.cr, 'cache read'],
316    [w === '1h' ? p.cacheWrite1h : p.cacheWrite5m, t.cw, `cache write ${w}`],
317  ]
318  return { usd: need.reduce((s, [x, n]) => s + (x ?? 0) * n, 0) / 1_000_000, missing: need.filter(([x, n]) => x === null && n > 0).map(([, , k]) => k) }
319}
320
321const usdText = (usd: number): string => `$${usd.toFixed(usd < 1 ? 4 : 2)}`
322const rateOf = (rate: string): '5m' | '1h' => (rate === '1h' ? '1h' : '5m')
323const rateWords = (w: '5m' | '1h'): string => (w === '1h' ? '1-hour' : '5-minute')
324
325/**
326 * Cost from the file's prices only (finding 4): never estimated, `—` when the file, the model or a
327 * needed price is missing. Cache writes are priced at the 5-minute rate: `ModelUsage` does not say
328 * the TTL (D:6605-6627), so the label says so.
329 */
330export function costOf(c: Costing, model: string, t: Tokens, rate: string = '5m'): { text: string; source: string } {
331  const r = resolveCosting(c, model)
332  if (!r.ok) return { text: '—', source: r.source }
333  const w = rateOf(rate)
334  const p = priceAt(r.f, t, w)
335  if (p.missing.length > 0) return { text: '—', source: `${c.path} has no ${p.missing.join(', ')} price for ${r.f.id}` }
336  return { text: usdText(p.usd), source: `${c.path} (${r.f.id}; cache writes at the ${rateWords(w)} rate: the usage does not say the TTL)` }
337}
338
339/** A request's prompt as the long-context rule counts it: all input tokens, cache reads and writes included. */
340export const promptTokens = (t: Tokens): number => t.in + t.cr + t.cw
341
342/** The long-context prices and the threshold they start at, and where the threshold came from. */
343type LongRule = { prices: LongPricing; threshold: number; from: string }
344
345/**
346 * Round 5: the long-context rule of a model. Null for a model priced flat; a string (why) when the
347 * model has long-context prices but no threshold is given. The threshold is the lens setting
348 * (`longContextThresholdTokens`, when given), else the file's `longContextThresholdTokensOverride`,
349 * else the model's `longContextPricing.thresholdTokens`. Round 6: the long prices travel with their
350 * threshold, so a request over it never needs `f.long!`.
351 */
352function longRuleOf(c: Costing, f: ModelFacts, json: unknown, lensThreshold: number | null): LongRule | null | string {
353  if (f.long === null) return null
354  const fileOverride = thresholdOverrideOf(json)
355  if (lensThreshold !== null && lensThreshold > 0) return { prices: f.long, threshold: lensThreshold, from: 'the lens setting longContextThresholdTokens' }
356  if (fileOverride !== null) return { prices: f.long, threshold: fileOverride, from: `${c.path} longContextThresholdTokensOverride` }
357  if (f.long.threshold !== null) return { prices: f.long, threshold: f.long.threshold, from: `${f.id} longContextPricing.thresholdTokens` }
358  return `${f.id} has long-context prices but ${c.path} gives no threshold`
359}
360
361/**
362 * Round 5: cost per request (each `turn.step` usage), with long-context pricing. For a model whose
363 * `longContextPricing` is not null the threshold is the lens setting (`longContextThresholdTokens`,
364 * when given), else the file's `longContextThresholdTokensOverride`, else the model's
365 * `longContextPricing.thresholdTokens`. A request whose prompt is over it pays the long-context prices
366 * for all its tokens; any other the flat ones. No threshold at all: `—`, never a guess.
367 */
368export function costOfRequests(
369  c: Costing,
370  model: string,
371  reqs: readonly Tokens[],
372  rate: string = '5m',
373  lensThreshold: number | null = null,
374): { text: string; source: string; long: number } {
375  const none = (source: string) => ({ text: '—', source, long: 0 })
376  const r = resolveCosting(c, model)
377  if (!r.ok) return none(r.source)
378  const f = r.f
379  const rule = longRuleOf(c, f, r.json, lensThreshold)
380  if (typeof rule === 'string') return none(rule)
381  const w = rateOf(rate)
382  let usd = 0
383  let long = 0
384  for (const t of reqs) {
385    const longPrices = rule !== null && promptTokens(t) > rule.threshold ? rule.prices : null
386    const p = priceAt(longPrices ?? f, t, w)
387    if (p.missing.length > 0) return none(`${c.path} has no ${longPrices ? 'long-context ' : ''}${p.missing.join(', ')} price for ${f.id}`)
388    if (longPrices) long += 1
389    usd += p.usd
390  }
391  const said = rule === null ? 'flat prices' : `long-context over ${rule.threshold} prompt tokens (${rule.from}): ${long} of ${reqs.length} requests`
392  return { text: usdText(usd), source: `${c.path} (${f.id}; ${said}; cache writes at the ${rateWords(w)} rate)`, long }
393}
394
395/** The requests an agent keeps for its cost (round 6: capped; past the cap a long-context model's cost is partial). */
396export const REQS_CAP = 500
397
398const sameTokens = (a: Tokens, b: Tokens): boolean => a.in === b.in && a.out === b.out && a.cr === b.cr && a.cw === b.cw
399
400/**
401 * An agent's cost (round 6). A model priced flat: from the totals, exact whatever was seen per request.
402 * A model priced by prompt length: per request, and only when the requests seen add up to the loop's
403 * totals; otherwise `—` and `partial` (a hook missed steps, a respawn, or more than REQS_CAP requests),
404 * never an undercount.
405 */
406export function costOfAgent(
407  c: Costing,
408  model: string,
409  reqs: readonly Tokens[],
410  totals: Tokens,
411  rate: string = '5m',
412  lensThreshold: number | null = null,
413): { text: string; source: string; long: number; partial: boolean } {
414  const r = resolveCosting(c, model)
415  if (r.ok && r.f.long !== null) {
416    if (reqs.length === 0 || !sameTokens(reqs.reduce(addTokens, ZERO), totals))
417      return { text: '—', source: `${r.f.id} is priced by prompt length and the ${reqs.length} requests seen do not add up to the loop's totals`, long: 0, partial: reqs.length > 0 }
418    return { ...costOfRequests(c, model, reqs, rate, lensThreshold), partial: false }
419  }
420  return { ...costOf(c, model, totals, rate), long: 0, partial: false }
421}
422
423/**
424 * A subagent's context window (addendum): the costing file by model (its long-context window for a
425 * `[1m]` model), else the main session's window when it runs main's model, else unknown (`—`).
426 */
427export function windowOf(c: Costing, model: string, mainModel: string, mainWindow: number, fallback: string = 'main'): { n: number | null; source: string } {
428  const json = parseCosting(c)
429  const f = json === null ? null : lookupModel(json, model)
430  if (f !== null) {
431    if (/\[1m\]/i.test(model) && f.longWindow !== null) return { n: f.longWindow, source: `${c.path} (${f.id}, long context)` }
432    if (f.window !== null) return { n: f.window, source: `${c.path} (${f.id})` }
433  }
434  if (fallback === 'main' && model !== '' && model === mainModel && mainWindow > 0) return { n: mainWindow, source: "the main session's window (same model)" }
435  return { n: null, source: c.text === '' ? `${c.error || c.path + ' not found'}; not main's model` : `${model || 'the model'} not in ${c.path} and not main's model` }
436}
437
438/** Kept for round-1 callers: `[1m]` says 1M, main's model takes main's window, else unknown. */
439export function windowFor(model: string, mainModel: string, mainWindow: number): { n: number | null; how: string } {
440  if (/\[1m\]/i.test(model)) return { n: 1_000_000, how: '[1m] in the model id' }
441  if (model !== '' && model === mainModel && mainWindow > 0) return { n: mainWindow, how: 'same model as main' }
442  return { n: null, how: 'unknown' }
443}
444
445/** The record path in a brief: on its first line (AGENTS.md Run Loop step 8), else anywhere in it. */
446export function matchHandoff(prompt: string): { path: string; how: string } | null {
447  const re = /[A-Za-z0-9_.:/\\-]*[A-Za-z0-9_-]\.handoff\.md/
448  const firstLine = prompt.split(/\r?\n/, 1)[0] ?? ''
449  const a = re.exec(firstLine)
450  if (a) return { path: a[0].replace(/\\/g, '/'), how: 'first line' }
451  const b = re.exec(prompt)
452  if (b) return { path: b[0].replace(/\\/g, '/'), how: 'body' }
453  return null
454}
455
456/** `docs/handoffs/R/17-orchestrator-to-agent-manager.handoff.md` -> its folder, step code, sender and receiver. */
457export function parseRecord(path: string): { dir: string; file: string; code: string; from: string; to: string } | null {
458  const slash = path.lastIndexOf('/')
459  const dir = slash < 0 ? '.' : path.slice(0, slash)
460  const file = path.slice(slash + 1)
461  // Step 1.6.1.l3: any mix of sub-steps and loop rounds, as run.md rows take them (`STEP_CODE`): the real
462  // record `1.6.1.l2.1-orchestrator-to-agent-manager.handoff.md` gave no folder before, so no run key.
463  const m = /^(\d+(?:\.(?:\d+|l\d+))*)-(.+?)-to-(.+)\.handoff\.md$/.exec(file)
464  if (!m) return null
465  return { dir, file, code: m[1]!, from: m[2]!, to: m[3]! }
466}
467
468/** The hand-back with the same step code and the agents swapped; else any hand-back of that code. */
469export function pickHandback(names: readonly string[], rec: { code: string; from: string; to: string }): string | null {
470  const exact = `${rec.code}-${rec.to}-to-${rec.from}.handback.md`
471  if (names.includes(exact)) return exact
472  return names.find(n => n.startsWith(rec.code + '-') && n.endsWith('.handback.md')) ?? null
473}
474
475const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
476const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
477
478/** What the chart says about one agent (finding 1a, 1d): name, model, role; no figures. */
479export type ChartLabel = { name: string; model: string; role: string }
480
481/** Only `running` blinks (finding 6): a call not yet started, or an agent that ended, is steady. */
482export const isRunning = (status: string): boolean => status === 'running'
483
484/** Step 8.6.1: a step drawn from its records only, whose handoff has no hand-back on disk: steady and grey, like a call not started. */
485export const OPEN = 'open'
486
487export type Placed = { id: string; x: number; y: number; depth: number }
488
489/**
490 * The chart's sizes, row cap and blink (lens settings `chartColumnPx`, `chartRowPx`, `chartAvatarsPerRow`,
491 * `avatarRadiusPx`, `blink`, `blinkMs`). `perRow`, addendum L1, a setting since step 27.2: the most avatars one
492 * row holds. A level with more (a run whose orchestrator spawned 23 agents drew a 2640 x 182 chart, shrunk to an
493 * unreadable strip in the pane) wraps onto further rows.
494 */
495export type ChartSize = { col: number; row: number; perRow: number; r: number; blink: boolean; blinkMs: number }
496/** Round 3 defaults (finding 1a: smaller than round 2's 200 x 210); `perRow` is the setting's default, 8. */
497export const SIZE0: ChartSize = { col: 120, row: 96, perRow: 8, r: 14, blink: true, blinkMs: 1000 }
498
499/**
500 * Tree layout: the top agent first, each level below its spawner's. A level of more than `size.perRow` agents
501 * wraps onto further rows, in order, so a chart stays at most `size.perRow` columns wide.
502 */
503export function layout(order: readonly string[], agents: Record<string, LensAgent>, size: ChartSize = SIZE0): { placed: Placed[]; width: number; height: number } {
504  const depthOf = (id: string): number => {
505    let d = 0
506    let cur = agents[id]
507    const seen = new Set<string>()
508    while (cur && cur.parent !== null && agents[cur.parent] && !seen.has(cur.id)) {
509      seen.add(cur.id)
510      cur = agents[cur.parent]
511      d += 1
512    }
513    return d
514  }
515  const levels: string[][] = []
516  for (const id of order) {
517    if (!agents[id]) continue
518    const d = depthOf(id)
519    ;(levels[d] ??= []).push(id)
520  }
521  const max = Math.max(1, Math.floor(size.perRow))
522  const widest = Math.min(max, Math.max(1, ...levels.map(l => (l ?? []).length)))
523  const width = widest * size.col
524  const placed: Placed[] = []
525  let row = 0
526  levels.forEach((ids, depth) => {
527    const all = ids ?? []
528    for (let start = 0; start < all.length; start += max, row++) {
529      const chunk = all.slice(start, start + max)
530      const n = chunk.length
531      chunk.forEach((id, i) => placed.push({ id, depth, x: Math.round((width / n) * (i + 0.5)), y: size.r + 8 + row * size.row }))
532    }
533    if (all.length === 0) row++
534  })
535  return { placed, width, height: Math.max(1, row) * size.row - 10 }
536}
537
538/** A chart as drawn: its Svg, the agents whose image was left out to stay under SVG_CAP, and whether it is still over. */
539export type Flowchart = { source: string; width: number; height: number; dropped: string[]; tooLarge: boolean }
540
541/**
542 * The flowchart: one avatar per agent (avatar.ts: robot, image or initials badge, by `av.style`),
543 * three lines under it (name, model, role), an arrow per spawn; a loop's arrow has a head at each
544 * end. No ctx, tokens or cache. When the Svg would pass SVG_CAP (one oversize source refuses the
545 * whole pane, spike 029 E2), images are left out, largest first, and named in `dropped`; when it is
546 * over even without them, `tooLarge` is set and the caller draws something else.
547 */
548export function flowchartSvg(
549  order: readonly string[],
550  agents: Record<string, LensAgent>,
551  label: (a: LensAgent) => ChartLabel,
552  size: ChartSize = SIZE0,
553  av: ChartAvatars = NO_IMAGES,
554): Flowchart {
555  const keys = new Map<string, AvatarImage>()
556  if (av.style !== 'initials')
557    for (const id of order) {
558      const a = agents[id]
559      if (!a) continue
560      const k = avatarKey(a.type)
561      const img = keys.has(k) ? undefined : av.image(k)
562      if (img) keys.set(k, img)
563    }
564  const dropped: string[] = []
565  for (;;) {
566    const drawn = drawChart(order, agents, label, size, av.style, keys)
567    if (drawn.source.length <= SVG_CAP || keys.size === 0) return { ...drawn, dropped, tooLarge: drawn.source.length > SVG_CAP }
568    // Largest total first: an image counts once per node that draws it.
569    const uses = (k: string) => order.filter(id => agents[id] && avatarKey(agents[id]!.type) === k).length
570    const largest = [...keys.entries()].sort((x, y) => y[1].uri.length * uses(y[0]) - x[1].uri.length * uses(x[0]))[0]![0]
571    keys.delete(largest)
572    dropped.push(largest)
573  }
574}
575
576function drawChart(
577  order: readonly string[],
578  agents: Record<string, LensAgent>,
579  label: (a: LensAgent) => ChartLabel,
580  size: ChartSize,
581  style: string,
582  images: ReadonlyMap<string, AvatarImage>,
583): { source: string; width: number; height: number } {
584  const { placed, width, height } = layout(order, agents, size)
585  const at = new Map(placed.map(p => [p.id, p] as const))
586  const R = size.r
587  const parts: string[] = []
588  // Each robot and image is drawn inline at its node: no <symbol>, <use> or <clipPath>, whose survival of
589  // a surface's Svg scrub is unproven (the chart went blank in VS Code with them, run AAW-20261023 step 1.2).
590  parts.push(
591    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet" font-family="sans-serif">`,
592    `<defs><marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#78909c"/></marker></defs>`,
593  )
594  for (const p of placed) {
595    const a = agents[p.id]!
596    const from = a.parent !== null ? at.get(a.parent) : undefined
597    if (!from) continue
598    const x1 = from.x
599    const y1 = from.y + R + 40
600    const x2 = p.x
601    const y2 = p.y - R - 3
602    const ends = a.loop ? ' marker-start="url(#a)" marker-end="url(#a)"' : ' marker-end="url(#a)"'
603    parts.push(`<path d="M${x1},${y1} C${x1},${(y1 + y2) / 2} ${x2},${(y1 + y2) / 2} ${x2},${y2}" fill="none" stroke="#78909c" stroke-width="1.5"${ends}><title>${esc(a.handoff ?? 'no handoff record: the arrow panel shows the prompt')}</title></path>`)
604  }
605  for (const p of placed) {
606    const a = agents[p.id]!
607    const l = label(a)
608    const fill = a.status === 'failed' || a.status === 'killed' ? '#c62828' : a.status === 'waiting' ? '#f9a825' : a.status === 'pending' || a.status === OPEN ? '#90a4ae' : '#2e7d32'
609    const title = `<title>${esc(l.name)}: ${esc(a.status)}</title>`
610    const blink = size.blink && isRunning(a.status) ? `<animate attributeName="opacity" values="1;0.25;1" dur="${size.blinkMs / 1000}s" repeatCount="indefinite"/>` : ''
611    const k = avatarKey(a.type)
612    const img = images.get(k)
613    if (img === undefined && style !== 'robot') {
614      // The initials badge (style `initials`, or `image` with no image): a disc in the status colour.
615      parts.push(
616        `<circle cx="${p.x}" cy="${p.y}" r="${R}" fill="${fill}">${title}${blink}</circle>`,
617        // Round 5: the initials centred in the avatar both ways (middle anchor, central baseline at the circle's centre).
618        `<text x="${p.x}" y="${p.y}" text-anchor="middle" dominant-baseline="central" font-size="11" font-weight="bold" fill="#fff">${esc(initials(l.name))}</text>`,
619      )
620    } else {
621      // A robot or an image on a light disc; the status colour is the ring, and the ring blinks.
622      const d = 2 * R - 6
623      parts.push(
624        `<circle cx="${p.x}" cy="${p.y}" r="${R}" fill="#eceff1" stroke="${fill}" stroke-width="3">${title}${blink}</circle>`,
625        img !== undefined ? imageInline(img.uri, p.x - d / 2, p.y - d / 2, d) : robotInline(robotOf(k), p.x - d / 2, p.y - d / 2, d),
626      )
627    }
628    ;[l.name, l.model, l.role].forEach((t, i) =>
629      // Round 6: the label lines keep the alphabetic baseline their offsets were set for; only the initials are centred.
630      parts.push(`<text x="${p.x}" y="${p.y + R + 13 + i * 12}" text-anchor="middle" font-size="${i === 0 ? 10.5 : 9.5}"${i === 0 ? ' font-weight="bold"' : ''} fill="#90a4ae">${esc(clip(t, 22))}</text>`),
631    )
632  }
633  parts.push('</svg>')
634  return { source: parts.join(''), width, height }
635}
636
637/**
638 * Finding 1b: the chart is drawn in full when the row is wide enough for it, else a one-line chip
639 * with an "Open flowchart" Button. `columns` is the render site's `viewport.columns` (cells,
640 * D:10957-10962); a cell is taken as 8 CSS px (an assumption: no API gives the cell width).
641 * With no viewport the chart is drawn in full: `Svg` shrinks to its slot on its own (D:13099-13105).
642 */
643export function showChart(mode: string, chartWidth: number, columns: number | undefined, breakpointColumns: number, cellPx: number): boolean {
644  if (mode === 'chart') return true
645  if (mode === 'button') return false
646  if (columns === undefined || columns <= 0) return true
647  const need = breakpointColumns > 0 ? breakpointColumns : Math.ceil(chartWidth / cellPx)
648  return columns >= need
649}
650
651/**
652 * Merge a stop-time `background_tasks` list into an agent's tasks (E7): the listed ones as listed;
653 * one started by a tool call (`backgroundTaskId`) and no longer listed is `ended`; `agent.list` rows stay.
654 */
655export function mergeStopTasks(bg: readonly LensTask[], stop: readonly LensTask[]): LensTask[] {
656  const listed = new Set(stop.map(t => t.id))
657  const kept = bg.filter(t => t.source === 'agent.list')
658  const started = bg
659    .filter(t => t.source === 'tool.call' && !listed.has(t.id))
660    .map(t => ({ ...t, status: t.status === 'running' ? 'ended' : t.status }))
661  return uniqueTasks([...kept, ...started, ...stop])
662}
663
664/**
665 * Step 1.6.1 (the background-task bug): one entry per task id. A subagent is listed live by
666 * `$.agent.list()` and again in a Stop's `background_tasks`, so the same id came twice: two entries
667 * sharing one open key and one Button key, which a surface cannot tell apart (ButtonProps.key, d.ts:1094-1101).
668 * The first entry wins (the live list comes first); a later one only fills what the first lacks.
669 * Entries without an id are kept as they are.
670 */
671export function uniqueTasks(tasks: readonly LensTask[]): LensTask[] {
672  const out: LensTask[] = []
673  const at = new Map<string, number>()
674  for (const t of tasks) {
675    const i = t.id === '' ? undefined : at.get(t.id)
676    if (i === undefined) {
677      if (t.id !== '') at.set(t.id, out.length)
678      out.push(t)
679      continue
680    }
681    const cur = out[i]!
682    out[i] = {
683      ...cur,
684      ...(cur.agentType ? {} : t.agentType ? { agentType: t.agentType } : {}),
685      ...(cur.output ? {} : t.output ? { output: t.output } : {}),
686      description: cur.description || t.description,
687    }
688  }
689  return out
690}
691
692/**
693 * One line for a tool call's input. An Agent call is named by its agent type, never by its
694 * `description` (round 4: the human saw "Agent Wait test agent one" and wants it gone).
695 */
696export function summarize(tool: string, input: Record<string, unknown>): string {
697  const pick = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
698  if (AGENT_TOOLS.has(tool)) return (pick('subagent_type') || tool).replace(/\s+/g, ' ').slice(0, 90)
699  const s =
700    pick('file_path') || pick('path') || pick('pattern') || pick('command') || pick('url') || pick('query') || pick('description') || pick('skill') || pick('to') || pick('subagent_type')
701  return (s === '' ? tool : s).replace(/\s+/g, ' ').slice(0, 90)
702}
703
704// ---- background tasks (round 4): one collapsible entry each ----
705
706/** Statuses after which a background task has ended. */
707export const TASK_ENDED = new Set(['completed', 'failed', 'killed', 'ended', 'stopped'])
708
709/** A task that is an agent's loop: its description is that Agent call's `description`, never drawn. */
710export const isAgentTask = (t: LensTask): boolean => t.source === 'agent.list' || t.type === 'subagent' || (t.agentType ?? '') !== ''
711
712/**
713 * Keeps each task's first-seen time across updates and stamps the end the first time it is seen
714 * ended: no event names the times (`AgentInfo`, `BackgroundTaskSummary`).
715 */
716export function stampTasks(prev: readonly LensTask[], next: readonly LensTask[], now: number): LensTask[] {
717  return next.map(t => {
718    const p = prev.find(x => x.id === t.id)
719    const startedAt = p?.startedAt ?? t.startedAt ?? now
720    const endedAt = TASK_ENDED.has(t.status) ? (p?.endedAt ?? t.endedAt ?? now) : null
721    const output = t.output || p?.output
722    return output ? { ...t, startedAt, endedAt, output } : { ...t, startedAt, endedAt }
723  })
724}
725
726/** A task's collapsed line: description and status; an agent task by its agent's name instead. */
727export function taskLine(t: LensTask, agentName: string): string {
728  const what = isAgentTask(t) ? agentName || humanRole(t.agentType ?? '') || 'Subagent' : t.description || t.type
729  return `${what} - ${t.status}`
730}
731
732/** A clock time for a task's details: `14:03:07`, or `—` when not known. */
733export function fmtClock(ms: number | null | undefined): string {
734  if (ms === null || ms === undefined || !Number.isFinite(ms) || ms <= 0) return '—'
735  const d = new Date(ms)
736  const two = (n: number) => String(n).padStart(2, '0')
737  return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`
738}
739
740/** Tools whose results are gathered context for the main loop (design item 1). */
741export const CONTEXT_TOOLS = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'Bash', 'Skill', 'Agent', 'LSP', 'ToolSearch'])
742
743// ---- the chat card (rounds 2-3) ----
744
745/** The tools that spawn an agent: `Agent`, and `Task` as headless runs name it. */
746export const AGENT_TOOLS = new Set(['Agent', 'Task'])
747
748/** Terminal statuses: `agent.list` may still say `running` for a while after them (E4), so they are kept. */
749export const ENDED = new Set(['completed', 'failed', 'killed'])
750
751/**
752 * Adds an agent only when it is absent, decided inside the write (round 3 root cause): a
753 * `turn.start` that read "absent" before `agent.spawn` patched the agent must not put a blank one back.
754 */
755export function ensureIn(m: Record<string, LensAgent>, id: string, make: () => LensAgent): Record<string, LensAgent> {
756  return m[id] ? m : { ...m, [id]: make() }
757}
758
759/**
760 * Two-way arrow on spawn (L6): the same parent spawned this type before, under another call.
761 * Not "the agent already exists": its own `turn.start` adds it first (headless run, step 17.l2).
762 */
763export function isRespawn(m: Record<string, LensAgent>, id: string, parent: string, type: string, toolUseId: string): boolean {
764  const self = m[id]
765  if (self && self.toolUseId !== '' && self.toolUseId !== toolUseId) return true
766  return Object.values(m).some(a => a.id !== id && a.parent === parent && a.type === type && a.toolUseId !== '' && a.toolUseId !== toolUseId)
767}
768
769/** The batch an Agent call belongs to: the batch that lists it with others first, else its own. */
770export function leadOf(batches: Record<string, LensBatch>, toolUseId: string): string | null {
771  let own: string | null = null
772  for (const [lead, b] of Object.entries(batches)) {
773    if (!b.ids.includes(toolUseId)) continue
774    if (b.ids.length > 1) return lead
775    own = lead
776  }
777  return own
778}
779
780/** Adds an Agent call to the batch of its message; a single-call batch `tool.call` made for it first is folded in. */
781export function addToBatch(batches: Record<string, LensBatch>, lead: string, parent: string, toolUseId: string): Record<string, LensBatch> {
782  const b = batches[lead] ?? { parent, ids: [], pending: {} }
783  const lone = toolUseId !== lead ? batches[toolUseId] : undefined
784  const pending = lone ? { ...b.pending, ...lone.pending } : b.pending
785  const ids = b.ids.includes(toolUseId) ? b.ids : [...b.ids, toolUseId]
786  const out = { ...batches, [lead]: { ...b, ids, pending } }
787  if (lone && lone.ids.length === 1) delete out[toolUseId]
788  return out
789}
790
791/** Notes what `tool.call` saw of an Agent call; a call no step reported becomes a batch of its own. */
792export function notePending(batches: Record<string, LensBatch>, parent: string, toolUseId: string, p: LensPending): Record<string, LensBatch> {
793  const lead = leadOf(batches, toolUseId)
794  const base = lead === null ? addToBatch(batches, toolUseId, parent, toolUseId) : batches
795  const key = lead ?? toolUseId
796  const b = base[key]!
797  return { ...base, [key]: { ...b, pending: { ...b.pending, [toolUseId]: p } } }
798}
799
800/** The avatar of an Agent call whose agent `agent.spawn` has not named yet: steady, never blinking. */
801export function pendingAgent(toolUseId: string, parent: string, p: LensPending | undefined): LensAgent {
802  return {
803    id: `call:${toolUseId}`,
804    parent,
805    type: p?.type ?? '',
806    name: p?.name ?? '',
807    description: p?.description ?? '',
808    model: '',
809    callModel: p?.model ?? '',
810    parentModel: '',
811    prompt: p?.prompt ?? '',
812    status: 'pending',
813    toolUseId,
814    steps: ZERO,
815    turns: ZERO,
816    last: null,
817    handoff: p ? (matchHandoff(p.prompt)?.path ?? null) : null,
818    handoffHow: p && matchHandoff(p.prompt) ? matchHandoff(p.prompt)!.how : 'none',
819    loop: false,
820    startedAt: 0,
821    endedAt: null,
822    bg: [],
823    reqs: [],
824  }
825}
826
827/**
828 * What the chat card draws for one batch: the loop that made the calls on top, each call's agent
829 * (by `callAgent`, then by its stored call id; else a placeholder) under it, then their children.
830 */
831export function batchView(
832  batch: LensBatch,
833  agents: Record<string, LensAgent>,
834  order: readonly string[],
835  callAgent: Record<string, string> = {},
836): { ids: string[]; agents: Record<string, LensAgent>; members: string[] } {
837  const out: Record<string, LensAgent> = {}
838  const ids: string[] = []
839  const root: LensAgent = agents[batch.parent] ?? { ...pendingAgent('', '', undefined), id: batch.parent, parent: null, status: 'running' }
840  out[root.id] = { ...root, parent: null }
841  ids.push(root.id)
842  const byCall = new Map<string, LensAgent>()
843  for (const a of Object.values(agents)) if (a.toolUseId !== '') byCall.set(a.toolUseId, a)
844  const members: string[] = []
845  // Step 1.6.1 (A3): a call made by another spawner than the root hangs under that spawner, which is
846  // drawn first with its own chain up to the root (a nested agent that started the run, say).
847  const place = (id: string, depth = 0): void => {
848    if (out[id] !== undefined || depth > 50) return
849    const a = agents[id]
850    if (a === undefined) return
851    const up = a.parent ?? root.id
852    if (up !== root.id) place(up, depth + 1)
853    out[id] = { ...a, parent: out[up] !== undefined ? up : root.id }
854    ids.push(id)
855  }
856  for (const tu of batch.ids) {
857    const mapped = callAgent[tu] !== undefined ? agents[callAgent[tu]!] : undefined
858    const p = batch.pending[tu]
859    let a = mapped ?? byCall.get(tu) ?? pendingAgent(tu, root.id, p)
860    // A record a race left blank still gets its call's type, prompt and id from the call.
861    if (p && a.type === '') a = { ...a, type: p.type, description: a.description || p.description, prompt: a.prompt || p.prompt, callModel: a.callModel || p.model }
862    if (a.toolUseId === '') a = { ...a, toolUseId: tu }
863    const sp = batch.by?.[tu] ?? root.id
864    if (sp !== root.id) place(sp)
865    if (out[a.id] !== undefined && a.id !== root.id) {
866      // Drawn already as a spawner of another call: keep it, but it is a member too.
867      members.push(a.id)
868      continue
869    }
870    out[a.id] = { ...a, parent: out[sp] !== undefined ? sp : root.id }
871    ids.push(a.id)
872    members.push(a.id)
873  }
874  for (let grew = true; grew; ) {
875    grew = false
876    for (const id of order) {
877      const a = agents[id]
878      if (!a || out[id] || a.parent === null || a.parent === root.id || !out[a.parent]) continue
879      out[id] = a
880      ids.push(id)
881      grew = true
882    }
883  }
884  out[root.id] = restingSpawner(out[root.id]!, members.map(m => out[m]!))
885  return { ids, agents: out, members }
886}
887
888/**
889 * Step 1.6.1.l3 (addendum B2): the orchestrator's node in one workflow's chart. The main loop is the
890 * spawner of every run and its status is the loop's own: `running` while it works a turn for any run (it
891 * drove three runs at once in the session that showed the bug, one turn from 05:59:05 to 06:07:04). In a
892 * workflow whose agents have all ended, the orchestrator's part in it is done (the human's rule), so its
893 * node there is drawn `idle`: steady, never blinking. Only `main`; any other spawner keeps its status.
894 */
895export function restingSpawner(root: LensAgent, members: readonly LensAgent[]): LensAgent {
896  if (root.id !== 'main' || !isRunning(root.status) || members.length === 0 || members.some(atWork)) return root
897  return { ...root, status: 'idle' }
898}
899
900/** The terminal's flowchart: one line per avatar, indented by depth, its model and role under it. */
901export function textChart(ids: readonly string[], agents: Record<string, LensAgent>, label: (a: LensAgent) => ChartLabel): { id: string; head: string; lines: string[] }[] {
902  const { placed } = layout(ids, agents, SIZE0)
903  return placed.map(p => {
904    const a = agents[p.id]!
905    const l = label(a)
906    const pad = '   '.repeat(p.depth)
907    const sub = `${pad}${a.parent !== null ? '    ' : ''}  `
908    return { id: p.id, head: `${pad}${a.parent !== null ? (a.loop ? '<-> ' : '-> ') : ''}`, lines: [l.name, `${sub}${l.model}, ${l.role}`] }
909  })
910}
911
912// ---- round 4: the workflow pane and the panels ----
913
914/** A run's title from its `run.md`: the first heading without `Run:` / `Run state:`, else the `run:` field; '' when none. */
915export function runTitle(text: string | null): string {
916  if (text === null) return ''
917  const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '')
918  const h = /^#\s+(.+?)\s*$/m.exec(body)
919  if (h) return h[1]!.replace(/^(Run state|Run):\s*/i, '').trim()
920  const f = /^run:\s*(.+?)\s*$/m.exec(text)
921  return f ? f[1]!.trim() : ''
922}
923
924/** The first line of a request or brief, as a workflow's title; '' when empty. */
925export const firstLine = (text: string, max = 100): string => (text.split(/\r?\n/).find(l => l.trim() !== '') ?? '').trim().slice(0, max)
926
927/** The workflows of the session, newest first: every batch that holds an Agent call. */
928export function workflowLeads(batches: Record<string, LensBatch>): string[] {
929  return Object.keys(batches)
930    .filter(k => batches[k]!.ids.length > 0)
931    .reverse()
932}
933
934/** The status badge: running, waiting, done or failed, and its colour. */
935export function statusBadge(status: string): { text: string; color: string } {
936  if (status === 'running') return { text: 'running', color: 'yellow' }
937  if (status === 'pending') return { text: 'waiting', color: 'gray' }
938  if (status === OPEN) return { text: 'no hand-back', color: 'gray' }
939  if (status === 'failed' || status === 'killed') return { text: 'failed', color: 'red' }
940  if (status === 'completed' || status === 'idle' || status === 'done' || status === 'ended') return { text: 'done', color: 'green' }
941  return { text: status || '—', color: 'gray' }
942}
943
944/** Context used against the window as a bar of `cells` cells; '' when the window is not known. */
945export function usageBar(used: number, window: number | null, cells = 12): string {
946  if (window === null || !(window > 0)) return ''
947  const k = Math.max(0, Math.min(cells, Math.round((used / window) * cells)))
948  return '█'.repeat(k) + '░'.repeat(cells - k)
949}
950
951// ---- round 4 bug: one workflow across messages and turns ----
952
953export const WF0: LensWorkflows = { of: {}, by: {}, parent: {}, order: [] }
954
955/** Whether a workflow still has an agent at work: a call not spawned yet, or an agent running. */
956export function workflowActive(wf: LensWorkflows, w: string, agents: Record<string, LensAgent>, callAgent: Record<string, string>): boolean {
957  const byCall = new Map<string, LensAgent>()
958  for (const a of Object.values(agents)) if (a.toolUseId !== '') byCall.set(a.toolUseId, a)
959  for (const [call, ww] of Object.entries(wf.of)) {
960    if (ww !== w) continue
961    const a = (callAgent[call] !== undefined ? agents[callAgent[call]!] : undefined) ?? byCall.get(call)
962    if (!a || a.status === 'pending' || a.status === 'running') return true
963  }
964  return false
965}
966
967/**
968 * The task an Agent call works for (round 6): the run folder its brief's handoff record is in, else the
969 * request or brief that made the call; '' when neither is known. Two calls with one key are one task.
970 */
971export function taskKeyOf(prompt: string, request: string): string {
972  const rec = matchHandoff(prompt)?.path
973  const dir = rec === undefined ? null : (parseRecord(rec)?.dir ?? null)
974  if (dir !== null && dir !== '.') return `run:${dir}`
975  return request.trim() === '' ? '' : `req:${request.trim()}`
976}
977
978/** The run folder a task key names (`run:<dir>`, from `taskKeyOf`), else null. */
979export const runOfKey = (key: string): string | null => (key.startsWith('run:') ? key.slice(4) : null)
980
981/**
982 * Step 1.6.1.l1 (addendum A5): a run's identity, the name of its run folder (`AAW-20261023-lens-plugin-from-spike`),
983 * lower case. The same run named as `docs/handoffs/R`, `./docs/handoffs/R/`, `E:\repo\docs\handoffs\R` or from a
984 * worktree is one run; '' when there is no folder.
985 */
986export function runIdOf(dir: string | null | undefined): string {
987  if (dir === null || dir === undefined) return ''
988  const segs = dir.replace(/\\/g, '/').split('/').filter(x => x !== '' && x !== '.')
989  return (segs.at(-1) ?? '').toLowerCase()
990}
991
992/**
993 * The run each call of the workflows belongs to, by identity (`runIdOf`): the run its task key names, else
994 * the run of the agent that call spawned or resumed (its brief's record, kept on the agent: a resumed agent's
995 * new call, or a call whose key was lost, still belongs to the agent's run). '' when neither names a run.
996 * Step 27.1: a run drawn in another (`roots`, from `runRoots`) gives that run's identity instead.
997 */
998export function callRunIds(
999  wf: LensWorkflows,
1000  keys: Record<string, string>,
1001  agents: Record<string, LensAgent>,
1002  callAgent: Record<string, string>,
1003  roots: Record<string, string> = {},
1004): Record<string, string> {
1005  const byCall = new Map<string, LensAgent>()
1006  for (const a of Object.values(agents)) if (a.toolUseId !== '') byCall.set(a.toolUseId, a)
1007  const out: Record<string, string> = {}
1008  for (const c of Object.keys(wf.of)) {
1009    let id = runIdOf(runOfKey(keys[c] ?? ''))
1010    if (id === '') {
1011      const a = (callAgent[c] !== undefined ? agents[callAgent[c]!] : undefined) ?? byCall.get(c)
1012      id = a ? runIdOf(runDirOf({ handoff: typeof a.handoff === 'string' ? a.handoff : null, prompt: typeof a.prompt === 'string' ? a.prompt : '' })) : ''
1013    }
1014    // Step 27.1: a run with `parent:` is its top run's (`runRoots`).
1015    out[c] = own(roots, id) ?? id
1016  }
1017  return out
1018}
1019
1020/**
1021 * Addendum A5: one workflow per run (drawn since step 8.6.1 as one chart per top-level step, `stepCharts`). Every call of a run joins the run's first workflow (the oldest in `order`
1022 * holding one of its calls), whatever session, batch, message or time made it. A workflow whose calls name
1023 * that one run (and the calls without a run its agents made) is merged into it whole and leaves `order`; a
1024 * workflow that mixes runs (an older lens joined them) gives each other run's calls back to that run's
1025 * workflow. Two runs never share one; nothing changes when every run already has one workflow. Step 27.1:
1026 * a child run (`parent:`, `roots`) is its top run here, so its calls join that run's workflow and chart.
1027 */
1028export function unifyRuns(
1029  wf: LensWorkflows,
1030  keys: Record<string, string>,
1031  agents: Record<string, LensAgent>,
1032  callAgent: Record<string, string>,
1033  roots: Record<string, string> = {},
1034): LensWorkflows {
1035  const ids = callRunIds(wf, keys, agents, callAgent, roots)
1036  const home = new Map<string, string>()
1037  for (const w of wf.order) for (const c of Object.keys(wf.of)) if (wf.of[c] === w && ids[c] !== '' && !home.has(ids[c]!)) home.set(ids[c]!, w)
1038  const of = { ...wf.of }
1039  let changed = false
1040  for (const w of wf.order) {
1041    const calls = Object.keys(wf.of).filter(c => wf.of[c] === w)
1042    const runs = new Set(calls.map(c => ids[c]!).filter(x => x !== ''))
1043    if (runs.size === 1) {
1044      const target = home.get([...runs][0]!)!
1045      if (target !== w) {
1046        for (const c of calls) of[c] = target
1047        changed = true
1048      }
1049      continue
1050    }
1051    for (const c of calls) {
1052      const target = ids[c] !== '' ? home.get(ids[c]!) : undefined
1053      if (target !== undefined && target !== w) {
1054        of[c] = target
1055        changed = true
1056      }
1057    }
1058  }
1059  if (!changed) return wf
1060  const used = new Set(Object.values(of))
1061  return { ...wf, of, order: wf.order.filter(w => used.has(w)) }
1062}
1063
1064/**
1065 * The workflow an Agent call joins. Step 1.5 (AC-1.5.1, AC-1.5.2, the human's definitions): a workflow's
1066 * identity is its run, the work item, never the spawner or the timing. A call whose brief names a record
1067 * in a run folder (`keys`, from `taskKeyOf`) joins the workflow that run already has, whatever spawned it,
1068 * whether that run is active, blocked or done, and whatever other workflows are at work; a run the lens
1069 * has not seen is a new workflow. Only a call no run can be read from falls back: an agent of a workflow
1070 * spawning stays in it; else the spawner's newest workflow for the same request (round 6, A8: a call with
1071 * that request key, or one the same request made, `requests`, whatever run it named); else, when not even
1072 * the request is known, the spawner's latest workflow while an agent of it still works (round 4).
1073 * Otherwise a new workflow, named by this call. Step 27.1: a run with `parent:` (`roots`, from `runRoots`)
1074 * is its top run's, so its calls join that run's workflow; an unknown parent leaves it its own.
1075 */
1076export function joinWorkflow(
1077  wf: LensWorkflows,
1078  spawner: string,
1079  call: string,
1080  agents: Record<string, LensAgent>,
1081  callAgent: Record<string, string>,
1082  keys: Record<string, string> = {},
1083  requests: Record<string, string> = {},
1084  roots: Record<string, string> = {},
1085): LensWorkflows {
1086  if (wf.of[call] !== undefined) return unifyRuns(wf, keys, agents, callAgent, roots)
1087  const key = keys[call] ?? ''
1088  const fresh = (): LensWorkflows => unifyRuns({ of: { ...wf.of, [call]: call }, by: { ...wf.by, [call]: spawner }, parent: { ...wf.parent, [call]: spawner }, order: [...wf.order, call] }, keys, agents, callAgent, roots)
1089  const join = (w: string): LensWorkflows => unifyRuns({ ...wf, of: { ...wf.of, [call]: w }, by: { ...wf.by, [call]: spawner } }, keys, agents, callAgent, roots)
1090  const withKey = new Set(Object.keys(wf.of).filter(c => c !== call && keys[c] === key).map(c => wf.of[c]!))
1091  const raw = runIdOf(runOfKey(key))
1092  // Step 27.1: a child run (`parent:`) joins its top run's workflow (`roots`, `runRoots`).
1093  const run = own(roots, raw) ?? raw
1094  if (run !== '') {
1095    // Addendum A5: the run's own workflow by identity (the run folder's name, `callRunIds`), the oldest
1096    // if an earlier version of the lens split it; never a newer one. `unifyRuns` merges any split.
1097    const ids = callRunIds(wf, keys, agents, callAgent, roots)
1098    const mine = new Set(Object.keys(wf.of).filter(c => c !== call && ids[c] === run).map(c => wf.of[c]!))
1099    const w = wf.order.find(x => mine.has(x))
1100    return w === undefined ? fresh() : join(w)
1101  }
1102  const self = agents[spawner]
1103  let w = spawner !== 'main' && self && self.toolUseId !== '' ? wf.of[self.toolUseId] : undefined
1104  if (w === undefined && key !== '') {
1105    const request = key.startsWith('req:') ? key.slice(4) : ''
1106    const same = new Set([...withKey, ...Object.keys(wf.of).filter(c => c !== call && request !== '' && (requests[c] ?? '').trim() === request).map(c => wf.of[c]!)])
1107    w = [...wf.order].reverse().find(x => wf.parent[x] === spawner && same.has(x))
1108  }
1109  if (w === undefined && key === '') {
1110    const latest = [...wf.order].reverse().find(x => wf.parent[x] === spawner)
1111    if (latest !== undefined && workflowActive(wf, latest, agents, callAgent)) w = latest
1112  }
1113  return w === undefined ? fresh() : join(w)
1114}
1115
1116/** One workflow as a batch for `batchView`: its spawner and the calls it made; what those agents spawned joins as descendants. */
1117export function workflowBatch(wf: LensWorkflows, w: string, batches: Record<string, LensBatch>, agents: Record<string, LensAgent> = {}): LensBatch {
1118  const first = wf.parent[w] ?? 'main'
1119  // Step 1.6.1 (addendum A3): every call of the run's workflow, whoever made it. A run started by a nested
1120  // agent and joined later by the orchestrator is drawn from the spawners' nearest common ancestor, each
1121  // call under its own spawner (`by`), so the orchestrator's later spawn shows in that run's chart too.
1122  // Calls an agent of the drawn tree made are drawn as its descendants, as before; only a spawner outside
1123  // that tree (not `first`, not under it) adds its calls here.
1124  const inTree = (id: string): boolean => {
1125    const seen = new Set<string>()
1126    for (let cur: string | null = id; cur !== null && !seen.has(cur); cur = agents[cur]?.parent ?? null) {
1127      if (cur === first) return true
1128      seen.add(cur)
1129    }
1130    return false
1131  }
1132  const ids = Object.keys(wf.of).filter(c => wf.of[c] === w && (wf.by[c] === first || !inTree(wf.by[c] ?? first)))
1133  const by: Record<string, string> = {}
1134  for (const c of ids) by[c] = wf.by[c] ?? first
1135  const parent = commonAncestor([first, ...ids.map(c => by[c]!)], agents)
1136  const pending: Record<string, LensPending> = {}
1137  for (const b of Object.values(batches)) for (const id of ids) if (b.pending[id]) pending[id] = b.pending[id]!
1138  return { parent, ids, pending, by }
1139}
1140
1141/** The nearest agent every one of `ids` is or descends from (by `parent`); `main` when they share none. */
1142export function commonAncestor(ids: readonly string[], agents: Record<string, LensAgent>): string {
1143  const chain = (id: string): string[] => {
1144    const out: string[] = []
1145    const seen = new Set<string>()
1146    for (let cur: string | null = id; cur !== null && !seen.has(cur); cur = agents[cur]?.parent ?? (cur === 'main' ? null : 'main')) {
1147      seen.add(cur)
1148      out.push(cur)
1149    }
1150    return out
1151  }
1152  const uniq = [...new Set(ids)]
1153  if (uniq.length === 1) return uniq[0]!
1154  const chains = uniq.map(chain)
1155  return chains[0]!.find(x => chains.every(c => c.includes(x))) ?? 'main'
1156}
1157
1158/**
1159 * The model's limits the avatar panel lists (round 4): only those the metadata file gives. A null
1160 * limit (max input without an API key, a long-context window the model lacks) is left out, not `—`.
1161 */
1162export function limitRows(c: Costing, model: string): [string, string][] {
1163  const json = parseCosting(c)
1164  const f = json === null ? null : lookupModel(json, model)
1165  if (f === null) return []
1166  const rows: [string, number | null][] = [
1167    ['Long-context window', f.longWindow],
1168    ['Max input', f.maxInput],
1169    ['Max output', f.maxOutput],
1170  ]
1171  return rows.filter((r): r is [string, number] => r[1] !== null).map(([k, v]) => [k, fmtTokens(v)])
1172}
1173
1174// ---- round 5 (step 17.l4) and round 6 (step 17.l5): task titles, run state, workflow status, notices ----
1175
1176const STEP_CODE = /^\d+(?:\.(?:\d+|l\d+))*$/
1177const TABLE_RULE = /^\|\s*:?-{2,}/
1178
1179/** A table row's cells, trimmed, backticks dropped. */
1180const cellsOf = (line: string): string[] =>
1181  line
1182    .split('|')
1183    .slice(1, -1)
1184    .map(x => x.trim().replace(/^`|`$/g, ''))
1185
1186/**
1187 * A run's `run.md`: its title (`runTitle`), its `status:`, the stage in progress (the last stage row
1188 * whose status starts with `in-progress`, else `current-stage:`), and every stage row's name by its
1189 * step code. Round 6: only the stages table is read, the one whose header has a `Stage` column or that
1190 * sits under a `Stages` heading; the gates, loops and clarifications tables are not. Null when there is no file.
1191 */
1192export function parseRun(text: string | null): LensRun | null {
1193  if (text === null) return null
1194  const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? ''
1195  const field = (k: string) => new RegExp(`^${k}:\\s*(.+?)\\s*$`, 'm').exec(fm)?.[1] ?? ''
1196  const stages: Record<string, string> = {}
1197  let inProgress = ''
1198  let heading = ''
1199  let inStages = false
1200  // Step 8.6.1: the merged-runs table (its second header cell `Offset`): each merged run's step offset.
hooks/settings.ts 361 lines
1// ai-dlc-lens (from spike ITEM-027, round 3, step 17.l2, addendum 3): every lens setting, its levels and its schema. No `$` here.
2// Precedence, highest first: `AI_DLC_LENS_*` in the `env` of .claude/settings.local.json, then of
3// .claude/settings.json, then .ai-dlc/lens.json, then the default. An invalid value falls through to
4// the next level. The process environment is never read (US-5, AC-5.4).
5
6export type Spec =
7  | { kind: 'int'; min: number; max: number; def: number; doc: string }
8  | { kind: 'bool'; def: boolean; doc: string }
9  | { kind: 'enum'; values: readonly string[]; def: string; doc: string }
10  | { kind: 'path'; def: string; doc: string }
11  | { kind: 'color'; def: string; doc: string }
12  | { kind: 'list'; values: readonly string[]; def: string; doc: string }
13
14export const SPEC = {
15  chartMode: { kind: 'enum', values: ['auto', 'chart', 'button'], def: 'auto', doc: 'auto: the chart when the row is wide enough, else a chip with an "Open flowchart" Button; chart or button: always that one' },
16  breakpointColumns: { kind: 'int', min: 0, max: 1000, def: 0, doc: 'The row width (viewport columns) from which the chart is drawn in auto mode; 0: the chart width divided by cellPx' },
17  cellPx: { kind: 'int', min: 4, max: 32, def: 8, doc: 'CSS px taken per viewport column (no API gives the cell width)' },
18  chartColumnPx: { kind: 'int', min: 60, max: 400, def: 120, doc: 'Width of one avatar column in the chart, px' },
19  chartRowPx: { kind: 'int', min: 60, max: 400, def: 96, doc: 'Height of one level of the chart, px' },
20  // Step 27.2: the row cap of addendum L1, now a setting.
21  chartAvatarsPerRow: { kind: 'int', min: 2, max: 24, def: 8, doc: 'The most avatars one row of a chart holds; a level with more wraps onto further rows, so a chart is at most this many avatar columns wide' },
22  avatarRadiusPx: { kind: 'int', min: 6, max: 40, def: 14, doc: 'Avatar radius, px' },
23  collapsedByDefault: { kind: 'bool', def: true, doc: 'Every [+]/[-] section starts collapsed' },
24  blink: { kind: 'bool', def: true, doc: 'Running avatars blink' },
25  blinkMs: { kind: 'int', min: 200, max: 10000, def: 1000, doc: 'One blink cycle, ms' },
26  showCost: { kind: 'bool', def: true, doc: 'Show the cost line in the avatar panel' },
27  cacheWriteRate: { kind: 'enum', values: ['5m', '1h'], def: '5m', doc: 'The cache-write price used for cost (the usage does not say the TTL)' },
28  windowFallback: { kind: 'enum', values: ['main', 'none'], def: 'main', doc: "A model not in the costing file: main: the main session's window when it runs the same model; none: show —" },
29  costingPath: { kind: 'path', def: '.ai-dlc/model-metadata.json', doc: 'The model metadata file: prices, windows, limits (read only)' },
30  costingFallbackPath: { kind: 'path', def: '.ai-dlc/model-costing.json', doc: 'Read when costingPath is missing: the name the file had before its rename to model-metadata.json' },
31  rosterPath: { kind: 'path', def: '.claude/agent-memory/ai-dlc-orchestrator/project_team-roster.md', doc: 'The team roster (agent ID -> persona name)' },
32  contextLimit: { kind: 'int', min: 1, max: 1000, def: 60, doc: "Gathered-context calls kept for the main loop's card" },
33  callsLimit: { kind: 'int', min: 1, max: 1000, def: 80, doc: 'Tool calls kept per agent' },
34  callsShown: { kind: 'int', min: 1, max: 1000, def: 40, doc: 'Tool calls listed in the avatar panel' },
35  recordChars: { kind: 'int', min: 200, max: 200000, def: 6000, doc: 'Characters shown of a handoff record or hand-back' },
36  thinkingChars: { kind: 'int', min: 200, max: 200000, def: 4000, doc: 'Characters of thinking kept per agent' },
37  // Round 5 (step 17.l4): long-context pricing and the workflow status indicator.
38  longContextThresholdTokens: {
39    kind: 'int',
40    min: 0,
41    max: 100000000,
42    def: 0,
43    doc: "Overrides every model's long-context threshold for the cost (prompt tokens: input + cache read + cache write of one request); 0: the metadata file decides (its longContextThresholdTokensOverride, else the model's longContextPricing.thresholdTokens)",
44  },
45  statusBlink: { kind: 'bool', def: true, doc: 'The in-progress status indicator of a workflow blinks' },
46  statusBlinkMs: { kind: 'int', min: 200, max: 10000, def: 1000, doc: 'One blink cycle of the status indicator, ms' },
47  statusInProgressColor: { kind: 'color', def: '#2e7d32', doc: 'Status indicator: in progress (blinks)' },
48  statusPendingColor: { kind: 'color', def: '#cfd8dc', doc: 'Status indicator: pending (spawned, not started)' },
49  statusHaltedColor: { kind: 'color', def: '#c62828', doc: 'Status indicator: halted (an agent failed or was killed, or the run is blocked)' },
50  statusWaitingColor: { kind: 'color', def: '#ef6c00', doc: 'Status indicator: an agent waiting (held, or a teammate idle between messages); beside the in-progress colour when both hold' },
51  statusCompletedColor: { kind: 'color', def: '#2e7d32', doc: 'Status indicator: completed (steady)' },
52  // Avatars (spike ITEM-029 and the robot-avatar addendum): avatar.ts draws them.
53  avatarStyle: {
54    kind: 'enum',
55    values: ['robot', 'image', 'initials'],
56    def: 'robot',
57    doc: "robot: an agent's avatarImage when it draws, else its robot (the same robot for every agent of a type); image: the avatarImage, else the initials badge; initials: always the initials badge, no image read",
58  },
59  avatarImageTypes: {
60    kind: 'list',
61    values: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
62    def: 'image/png',
63    doc: 'The image types an avatarImage may be, comma-separated (checked on the bytes, not the name)',
64  },
65  avatarImageMaxBytes: { kind: 'int', min: 1, max: 2097152, def: 65536, doc: 'The largest avatarImage, bytes; the whole chart must also stay under 131,072 Svg characters' },
66  // /ai-dlc:lens-keepalive (step 1.4): the keep-alive section of every agent pane; the command toggles it.
67  keepaliveSection: {
68    kind: 'bool',
69    def: true,
70    doc: "Show the Cache keep-alive section in every agent pane (collapsed like every section): how long keep-alive kept the cache warm per track (5m, 1h) and what cache reads saved, priced from costingPath. /ai-dlc:lens-keepalive turns it off and on",
71  },
72  // Step 1.5: how panels open, where the chart is drawn, and the workflow view's place and layout.
73  panelOpenMode: {
74    kind: 'enum',
75    values: ['tab', 'drill-in'],
76    def: 'drill-in',
77    doc: 'How an avatar or arrow Button opens its details: drill-in (the default): inside the pane or chat card it was pressed in, with "< Back"; tab: a separate pane (a tab beside the others). The "Panel Type" switch of the AI-DLC Lens view overrides it for the session',
78  },
79  chartInChat: { kind: 'bool', def: true, doc: "Draw each workflow's flowchart in the chat card of its first spawn (chartMode decides chart or chip by the row's width)" },
80  chartInPane: { kind: 'bool', def: true, doc: 'Draw the flowcharts in the workflow view (pane or band); off: the avatar and arrow Buttons only' },
81  workflowPlace: {
82    kind: 'enum',
83    values: ['pinned', 'docked'],
84    def: 'pinned',
85    doc: "Where the workflow view starts: pinned: the band above the prompt where the surface has one (terminal, desktop), else the pane; docked: the pane, placed by the surface (beside the transcript where it docks). The view's own switch changes it for the session",
86  },
87  wideColumns: {
88    kind: 'int',
89    min: 0,
90    max: 1000,
91    def: 70,
92    doc: "The workflow view's width (body columns) from which the runs stack as collapsible panels on the right with the selected run's chart beside them; narrower, the view shows only the list of runs, each minimised to one row",
93  },
94  runListColumns: { kind: 'int', min: 16, max: 200, def: 34, doc: 'Width of the stacked run panels on the right of the wide workflow view, columns' },
95  // Step 1.6.1: /ai-dlc:lens-gate, the anomaly detector (hooks/anomaly.ts). One switch, one setting per check, its colour, and the waiting threshold.
96  gateAlert: { kind: 'bool', def: true, doc: 'Detect anomalies in the runs and agents the lens sees: a toast when one appears, a marker on its run, agent or arrow, and the Anomalies section of the workflow view. /ai-dlc:lens-gate on|off sets it' },
97  anomalyRunBlocked: { kind: 'bool', def: true, doc: 'Check: a run is blocked (run.md status: blocked); the anomaly names its blocked steps' },
98  anomalyRunBlockedColor: { kind: 'color', def: '#c62828', doc: 'Marker colour: a blocked run (red)' },
99  // Step 1.6.1.l1: a blocked step of a run that is not itself blocked, and which runs are read.
100  anomalyStepBlocked: {
101    kind: 'bool',
102    def: true,
103    doc: "Check: a step is blocked (its row in run.md's Stages table has status blocked), whatever the run's status; a run reported blocked names its blocked steps instead, so none is reported twice",
104  },
105  anomalyStepBlockedColor: { kind: 'color', def: '#c62828', doc: 'Marker colour: a blocked step (red)' },
106  anomalyRunScope: {
107    kind: 'enum',
108    values: ['session', 'all'],
109    def: 'session',
110    doc: "The runs the detector reads: session: those this session's agent briefs name; all: also every run.md under the handoff folder (artifacts.handoffs of .ai-dlc/workflow.json, default docs/handoffs) whose status is active or blocked",
111  },
112  anomalyGateWaiting: { kind: 'bool', def: true, doc: 'Check: a gate waits for the human (a gate row with approver human and no decision, or the newest record of the run addressed to: human with no decision for its gate)' },
113  anomalyGateWaitingColor: { kind: 'color', def: '#ef6c00', doc: 'Marker colour: a gate waiting for the human (orange)' },
114  anomalyGateRejected: { kind: 'bool', def: true, doc: 'Check: a gate was rejected (the last row of that gate in Gates and loops says rejected, not approved, declined or no-go)' },
115  anomalyGateRejectedColor: { kind: 'color', def: '#c62828', doc: 'Marker colour: a rejected gate (red)' },
116  anomalyLoopOutOfRounds: { kind: 'bool', def: true, doc: 'Check: a loop is out of rounds (the last row of that loop in Gates and loops says its limits are used up, and no raise or acceptance is recorded)' },
117  anomalyLoopOutOfRoundsColor: { kind: 'color', def: '#d84315', doc: 'Marker colour: a loop out of rounds (deep orange)' },
118  anomalyAgentFailed: { kind: 'bool', def: true, doc: 'Check: an agent failed or stopped, and no later agent of its type was spawned for the same run' },
119  anomalyAgentFailedColor: { kind: 'color', def: '#c62828', doc: 'Marker colour: an agent failed or stopped (red)' },
120  anomalyAgentKilled: { kind: 'bool', def: true, doc: 'Check: an agent was killed, and no later agent of its type was spawned for the same run' },
121  anomalyAgentKilledColor: { kind: 'color', def: '#c62828', doc: 'Marker colour: an agent killed (red)' },
122  anomalyAgentWaitingLong: { kind: 'bool', def: true, doc: 'Check: an agent has been waiting (held, or a teammate idle) for anomalyWaitingMinutes or more' },
123  anomalyAgentWaitingLongColor: { kind: 'color', def: '#c62828', doc: 'Marker colour: an agent waiting too long (red)' },
124  anomalyWaitingMinutes: { kind: 'int', min: 0, max: 1440, def: 30, doc: 'How long an agent may wait before it is an anomaly, minutes; 0: as soon as it waits' },
125  anomalyHandbackFailed: { kind: 'bool', def: true, doc: "Check: a hand-back failed its check (verification: failed in its front matter, or a first line 'handoff <file>: failed'), in the newest round of its step" },
126  anomalyHandbackFailedColor: { kind: 'color', def: '#f9a825', doc: 'Marker colour: a hand-back that failed its check (yellow)' },
127  probes: { kind: 'bool', def: false, doc: "Log the spike's experiments E1, E2, E4, E5, E7, E9 and E10 to PROBE-LOG.jsonl in the plugin folder (diagnostics only)" },
128} as const satisfies Record<string, Spec>
129
130export type Key = keyof typeof SPEC
131export type LensSettings = { [K in Key]: (typeof SPEC)[K]['def'] extends number ? number : (typeof SPEC)[K]['def'] extends boolean ? boolean : string }
132export type Sources = Record<Key, string>
133
134export const KEYS = Object.keys(SPEC) as Key[]
135export const DEFAULTS = Object.fromEntries(KEYS.map(k => [k, SPEC[k].def])) as LensSettings
136
137/** `chartMode` -> `AI_DLC_LENS_CHART_MODE`. */
138export const envName = (k: Key): string => 'AI_DLC_LENS_' + k.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase()
139
140/** A value from a settings `env` (always a string) or from lens.json (its JSON type); undefined when invalid. */
141export function coerce(k: Key, v: unknown, fromEnv: boolean): number | boolean | string | undefined {
142  const s: Spec = SPEC[k]
143  if (s.kind === 'int') {
144    const n = fromEnv ? (typeof v === 'string' && /^-?\d+$/.test(v.trim()) ? Number(v.trim()) : NaN) : typeof v === 'number' ? v : NaN
145    return Number.isInteger(n) && n >= s.min && n <= s.max ? n : undefined
146  }
147  if (s.kind === 'bool') {
148    if (!fromEnv) return typeof v === 'boolean' ? v : undefined
149    if (typeof v !== 'string') return undefined
150    const t = v.trim().toLowerCase()
151    return t === 'true' || t === '1' ? true : t === 'false' || t === '0' ? false : undefined
152  }
153  if (s.kind === 'list' && !fromEnv && Array.isArray(v)) return v.every(x => typeof x === 'string') ? listOf(s.values, v.join(',')) : undefined
154  if (typeof v !== 'string') return undefined
155  const t = v.trim()
156  if (s.kind === 'enum') return s.values.includes(t) ? t : undefined
157  if (s.kind === 'color') return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(t) ? t : undefined
158  if (s.kind === 'list') return listOf(s.values, t)
159  return t !== '' && !t.includes('\0') ? t : undefined
160}
161
162/** A comma-separated list of allowed values, normalised (lower case, no repeats); undefined when empty or any is not allowed. */
163function listOf(allowed: readonly string[], text: string): string | undefined {
164  const items = [...new Set(text.split(',').map(x => x.trim().toLowerCase()).filter(x => x !== ''))]
165  return items.length > 0 && items.every(x => allowed.includes(x)) ? items.join(',') : undefined
166}
167
168// ---- per-agent keys (stories A21, AC-6.9): `<family>.<agent>` ----
169
170/** The per-agent settings. Each agent's value is looked up by its avatar key (avatar.ts avatarKey). */
171export const AGENT_FAMILIES = ['avatarImage'] as const
172export type AgentFamily = (typeof AGENT_FAMILIES)[number]
173
174/** An agent part: an agent ID, or `<plugin>:<agent>`; letters, digits, `-`, `_`, `.` and one `:`. */
175export const isAgentPart = (agent: string): boolean => /^[A-Za-z0-9][A-Za-z0-9_.-]*(?::[A-Za-z0-9][A-Za-z0-9_.-]*)?$/.test(agent)
176
177/** `avatarImage.software-architect` -> its family and agent part; null when not a per-agent key. */
178export function agentKeyParts(k: string): { family: AgentFamily; agent: string } | null {
179  const dot = k.indexOf('.')
180  if (dot < 0) return null
181  const family = k.slice(0, dot)
182  const agent = k.slice(dot + 1)
183  if (!(AGENT_FAMILIES as readonly string[]).includes(family) || !isAgentPart(agent)) return null
184  return { family: family as AgentFamily, agent }
185}
186
187/** `avatarImage` -> `AI_DLC_LENS_AVATAR_IMAGE__`: the prefix every per-agent env name of a family starts with. */
188export const agentEnvPrefix = (family: AgentFamily): string => 'AI_DLC_LENS_' + family.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase() + '__'
189
190/**
191 * The settings `env` name of a per-agent key: the family's prefix, then the agent part in capitals,
192 * `:` as `__` and `-` or `.` as `_` (`software-architect` -> `AI_DLC_LENS_AVATAR_IMAGE__SOFTWARE_ARCHITECT`,
193 * `my-plugin:my-agent` -> `AI_DLC_LENS_AVATAR_IMAGE__MY_PLUGIN__MY_AGENT`).
194 */
195export const agentEnvName = (family: AgentFamily, agent: string): string =>
196  agentEnvPrefix(family) + agent.replace(/:/g, '__').replace(/[-.]/g, '_').toUpperCase()
197
198/**
199 * A per-agent value: the first level, highest first, holding a non-empty string for it; `null` when
200 * none does. An invalid entry (not a string, or blank) falls through and is named in `ignored`.
201 */
202export function resolveAgent(levels: readonly Level[], family: AgentFamily, agent: string): { value: string; source: string; ignored: string[] } | null {
203  const ignored: string[] = []
204  for (const lv of levels) {
205    const name = lv.byEnv ? agentEnvName(family, agent) : `${family}.${agent}`
206    if (!(name in lv.values)) continue
207    const v = lv.values[name]
208    if (typeof v === 'string' && v.trim() !== '') return { value: v.trim(), source: lv.where, ignored }
209    ignored.push(`${lv.where}: ${name} = ${JSON.stringify(v)} is invalid, ignored`)
210  }
211  return null
212}
213
214/** One level: where it is, whether its values are `env` strings, and its raw values by setting key. */
215export type Level = { where: string; byEnv: boolean; values: Record<string, unknown> }
216
217/** The `AI_DLC_LENS_*` entries of a settings file's `env`, by setting key. */
218export function envLevel(where: string, settingsText: string | null): { level: Level; problem: string } {
219  if (settingsText === null) return { level: { where, byEnv: true, values: {} }, problem: '' }
220  let json: unknown
221  try {
222    json = JSON.parse(settingsText)
223  } catch {
224    return { level: { where, byEnv: true, values: {} }, problem: `${where}: not valid JSON, skipped` }
225  }
226  const env = json !== null && typeof json === 'object' ? (json as Record<string, unknown>).env : undefined
227  const values: Record<string, unknown> = {}
228  if (env !== null && typeof env === 'object') {
229    for (const k of KEYS) {
230      const v = (env as Record<string, unknown>)[envName(k)]
231      if (v !== undefined) values[k] = v
232    }
233    // Per-agent entries are kept under their env name; resolveAgent looks them up by it.
234    const prefixes = AGENT_FAMILIES.map(agentEnvPrefix)
235    for (const [name, v] of Object.entries(env as Record<string, unknown>)) if (prefixes.some(p => name.startsWith(p))) values[name] = v
236  }
237  return { level: { where: `${where} env`, byEnv: true, values }, problem: '' }
238}
239
240/** `.ai-dlc/lens.json`'s keys (`$schema` and unknown keys ignored). */
241export function fileLevel(where: string, text: string | null): { level: Level; problem: string } {
242  if (text === null) return { level: { where, byEnv: false, values: {} }, problem: '' }
243  try {
244    const json = JSON.parse(text)
245    if (json === null || typeof json !== 'object' || Array.isArray(json)) return { level: { where, byEnv: false, values: {} }, problem: `${where}: not an object, skipped` }
246    return { level: { where, byEnv: false, values: json as Record<string, unknown> }, problem: '' }
247  } catch {
248    return { level: { where, byEnv: false, values: {} }, problem: `${where}: not valid JSON, skipped` }
249  }
250}
251
252/** Each setting from the first level, highest first, that holds a valid value for it; else the default. */
253export function resolve(levels: readonly Level[]): { settings: LensSettings; sources: Sources; ignored: string[] } {
254  const settings = { ...DEFAULTS } as Record<Key, unknown>
255  const sources = Object.fromEntries(KEYS.map(k => [k, 'default'])) as Sources
256  const ignored: string[] = []
257  for (const k of KEYS) {
258    for (const lv of levels) {
259      if (!(k in lv.values)) continue
260      const v = coerce(k, lv.values[k], lv.byEnv)
261      if (v === undefined) {
262        ignored.push(`${lv.where}: ${lv.byEnv ? envName(k) : k} = ${JSON.stringify(lv.values[k])} is invalid, ignored`)
263        continue
264      }
265      settings[k] = v
266      sources[k] = lv.where
267      break
268    }
269  }
270  return { settings: settings as LensSettings, sources, ignored }
271}
272
273/** `.ai-dlc/lens.schema.json`, written by `/ai-dlc:lens-init`. */
274export function schema(): Record<string, unknown> {
275  const props: Record<string, unknown> = { $schema: { type: 'string' } }
276  for (const k of KEYS) {
277    const s: Spec = SPEC[k]
278    const base = { description: `${s.doc}. Env: ${envName(k)}.`, default: s.def }
279    props[k] =
280      s.kind === 'int'
281        ? { ...base, type: 'integer', minimum: s.min, maximum: s.max }
282        : s.kind === 'bool'
283          ? { ...base, type: 'boolean' }
284          : s.kind === 'enum'
285            ? { ...base, type: 'string', enum: [...s.values] }
286            : s.kind === 'color'
287              ? { ...base, type: 'string', pattern: '^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$' }
288              : s.kind === 'list'
289                ? { ...base, anyOf: [{ type: 'array', items: { enum: [...s.values] }, minItems: 1, uniqueItems: true }, { type: 'string', minLength: 1 }] }
290                : { ...base, type: 'string', minLength: 1 }
291  }
292  const patterns: Record<string, unknown> = {
293    '^avatarImage\\.[A-Za-z0-9][A-Za-z0-9_.-]*(:[A-Za-z0-9][A-Za-z0-9_.-]*)?$': {
294      type: 'string',
295      minLength: 1,
296      description:
297        "One agent's avatar: a data URI (data:image/png;base64,...) or a path to an image file inside a folder Claude Code trusts. The agent part is the agent ID for an ai-dlc agent, else the whole agent type. Env: AI_DLC_LENS_AVATAR_IMAGE__<AGENT> (capitals; ':' as '__', '-' and '.' as '_').",
298    },
299  }
300  return {
301    $schema: 'http://json-schema.org/draft-07/schema#',
302    title: 'AI-DLC lens settings',
303    description: 'Overridden by AI_DLC_LENS_* in the env of .claude/settings.json, then of .claude/settings.local.json. The process environment is not read.',
304    type: 'object',
305    additionalProperties: false,
306    properties: props,
307    patternProperties: patterns,
308  }
309}
310
311/** The text `/ai-dlc:lens-init` writes to `.ai-dlc/lens.json`: every default. */
312export const initialFile = (): string => JSON.stringify({ $schema: './lens.schema.json', ...DEFAULTS }, null, 2) + '\n'
313
314/** `/ai-dlc:lens-configure <key> <value>`: the new lens.json text, or why not. */
315export function setKey(text: string | null, k: string, raw: string): { text: string; error: string } {
316  const perAgent = agentKeyParts(k)
317  if (!(KEYS as string[]).includes(k) && perAgent === null)
318    return {
319      text: '',
320      error: k.startsWith('avatarImage.') ? `${k}: the agent part is empty or not an agent ID or type` : `unknown key ${k}; keys: ${KEYS.join(', ')}, avatarImage.<agent>`,
321    }
322  let json: Record<string, unknown> = { $schema: './lens.schema.json' }
323  if (text !== null) {
324    const f = fileLevel('lens.json', text)
325    if (f.problem !== '') return { text: '', error: f.problem }
326    json = { ...f.level.values }
327  }
328  if (perAgent !== null) {
329    // The value's own checks (trust, type, size) are the caller's: they need the file system.
330    if (raw === '--unset') delete json[k]
331    else if (raw.trim() === '') return { text: '', error: `${k} needs a value` }
332    else json[k] = raw.trim()
333    return { text: JSON.stringify(json, null, 2) + '\n', error: '' }
334  }
335  const key = k as Key
336  if (raw === '--unset') {
337    delete json[key]
338    return { text: JSON.stringify(json, null, 2) + '\n', error: '' }
339  }
340  // A typed value: the same parse as an env string, then stored as its JSON type.
341  const v = coerce(key, raw, true)
342  if (v === undefined) {
343    const s: Spec = SPEC[key]
344    const want =
345      s.kind === 'int'
346        ? `an integer ${s.min}..${s.max}`
347        : s.kind === 'bool'
348          ? 'true or false'
349          : s.kind === 'enum'
350            ? s.values.join(' | ')
351            : s.kind === 'color'
352              ? 'a hex colour such as #2e7d32'
353              : s.kind === 'list'
354                ? `a comma-separated list of ${s.values.join(', ')}`
355                : 'a non-empty path'
356    return { text: '', error: `${raw} is not valid for ${key}: ${want}` }
357  }
358  json[key] = v
359  return { text: JSON.stringify(json, null, 2) + '\n', error: '' }
360}
361
hooks/avatar.ts 295 lines
1// ai-dlc-lens: avatars (from spike ITEM-029 and the robot-avatar addendum of run AAW-20261023). Pure, no `$` here.
2//
3// An agent's avatar, by the `avatarStyle` setting:
4//   robot (default)  its configured image when one draws, else its robot
5//   image            its configured image when one draws, else its initials badge
6//   initials         always its initials badge; images are not read
7// The robot is inline Svg (no image file, no network): one of ROBOT_DESIGNS in one of ROBOT_COLORS,
8// picked per agent type (ROBOT_TABLE for the ai-dlc agents, else robotOf's hash), so every agent of a
9// type gets the same robot. The status colour is the ring around it; a running ring blinks.
10// An image is `avatarImage.<agent>`: a data URI, or a file path inside a folder Claude Code trusts.
11
12/** The longest Svg source a surface accepts; past it the whole tree is refused (spike 029, E2). */
13export const SVG_CAP = 131072
14
15export const AVATAR_STYLES = ['robot', 'image', 'initials'] as const
16export const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'] as const
17
18/**
19 * The key an agent's per-agent settings use (stories A21, AC-6.9): the agent ID for an ai-dlc agent
20 * (`software-architect`), the whole agent type for any other (`my-plugin:my-agent`, `my-agent`), and
21 * `main` for a main loop that runs no agent.
22 */
23export function avatarKey(type: string): string {
24  if (!type) return 'main'
25  // lens.ts pluginOf / roleOf, inlined: lens.ts imports this file.
26  return type.startsWith('ai-dlc:') ? type.slice('ai-dlc:'.length) : type
27}
28
29// ---- robots ----
30
31const DARK = '#263238'
32const LIGHT = '#eceff1'
33
34/** The robot body colours. */
35export const ROBOT_COLORS = ['#1e88e5', '#43a047', '#8e24aa', '#f4511e', '#00897b', '#6d4c41', '#3949ab', '#c0ca33'] as const
36
37/** The robot designs, each drawn in a 40 x 40 box with the body colour `c`. No `<circle>`: the chart's node is the only one. */
38export const ROBOT_DESIGNS: readonly { name: string; draw: (c: string) => string }[] = [
39  {
40    name: 'boxy',
41    draw: c =>
42      `<path d="M20 4v6" stroke="${DARK}" stroke-width="2"/><ellipse cx="20" cy="4" rx="2.5" ry="2.5" fill="${c}"/>` +
43      `<rect x="7" y="10" width="26" height="24" rx="4" fill="${c}"/>` +
44      `<ellipse cx="14.5" cy="20" rx="3.5" ry="3.5" fill="${LIGHT}"/><ellipse cx="25.5" cy="20" rx="3.5" ry="3.5" fill="${LIGHT}"/>` +
45      `<ellipse cx="14.5" cy="20" rx="1.5" ry="1.5" fill="${DARK}"/><ellipse cx="25.5" cy="20" rx="1.5" ry="1.5" fill="${DARK}"/>` +
46      `<rect x="13" y="27" width="14" height="3" rx="1" fill="${DARK}"/>`,
47  },
48  {
49    name: 'dome',
50    draw: c =>
51      `<rect x="3" y="20" width="4" height="8" rx="1" fill="${DARK}"/><rect x="33" y="20" width="4" height="8" rx="1" fill="${DARK}"/>` +
52      `<path d="M6 34V20a14 14 0 0 1 28 0v14z" fill="${c}"/>` +
53      `<rect x="10" y="17" width="20" height="7" rx="3.5" fill="${DARK}"/>` +
54      `<rect x="13" y="19" width="5" height="3" rx="1.5" fill="#4dd0e1"/><rect x="22" y="19" width="5" height="3" rx="1.5" fill="#4dd0e1"/>` +
55      `<path d="M14 29h12" stroke="${DARK}" stroke-width="2" stroke-linecap="round"/>`,
56  },
57  {
58    name: 'cyclops',
59    draw: c =>
60      `<path d="M13 9L10 3M27 9l3-6" stroke="${DARK}" stroke-width="2" stroke-linecap="round"/>` +
61      `<rect x="7" y="8" width="26" height="27" rx="9" fill="${c}"/>` +
62      `<ellipse cx="20" cy="19" rx="7" ry="7" fill="${LIGHT}"/><ellipse cx="20" cy="19" rx="3" ry="3" fill="${DARK}"/>` +
63      `<path d="M14 28q6 4 12 0" fill="none" stroke="${DARK}" stroke-width="2" stroke-linecap="round"/>`,
64  },
65  {
66    name: 'screen',
67    draw: c =>
68      `<path d="M14 11L10 4M26 11l4-7" stroke="${DARK}" stroke-width="2" stroke-linecap="round"/>` +
69      `<rect x="4" y="11" width="32" height="24" rx="3" fill="${c}"/><rect x="8" y="15" width="24" height="15" rx="2" fill="${DARK}"/>` +
70      `<rect x="12" y="19" width="5" height="4" fill="#76ff03"/><rect x="23" y="19" width="5" height="4" fill="#76ff03"/>` +
71      `<path d="M15 26h10" stroke="#76ff03" stroke-width="1.5"/>`,
72  },
73  {
74    name: 'octagon',
75    draw: c =>
76      `<path d="M20 2l2 5h-4z" fill="${DARK}"/><path d="M14 7h12l7 7v12l-7 7H14l-7-7V14z" fill="${c}"/>` +
77      `<rect x="12" y="15" width="6" height="6" rx="1" fill="${LIGHT}"/><rect x="22" y="15" width="6" height="6" rx="1" fill="${LIGHT}"/>` +
78      `<path d="M13 27l3-2 2 2 2-2 2 2 2-2 3 2" fill="none" stroke="${DARK}" stroke-width="1.5"/>`,
79  },
80  {
81    name: 'can',
82    draw: c =>
83      `<path d="M12 9a8 5 0 0 1 16 0z" fill="${DARK}"/><rect x="10" y="9" width="20" height="27" rx="3" fill="${c}"/>` +
84      `<rect x="12" y="14" width="16" height="5" rx="2.5" fill="${LIGHT}"/>` +
85      `<ellipse cx="16" cy="16.5" rx="1.5" ry="1.5" fill="${DARK}"/><ellipse cx="24" cy="16.5" rx="1.5" ry="1.5" fill="${DARK}"/>` +
86      `<ellipse cx="15" cy="27" rx="1.2" ry="1.2" fill="${DARK}"/><ellipse cx="20" cy="27" rx="1.2" ry="1.2" fill="${DARK}"/><ellipse cx="25" cy="27" rx="1.2" ry="1.2" fill="${DARK}"/>`,
87  },
88]
89
90export type Robot = { design: number; color: number }
91
92/**
93 * The ai-dlc agents' robots, fixed so that no two share one: agent i of this list gets design
94 * i mod 6 and colour 3i mod 8, a pair no other i below 24 repeats.
95 */
96export const ROBOT_TABLE: Record<string, Robot> = Object.fromEntries(
97  [
98    'orchestrator',
99    'product-owner',
100    'product-manager',
101    'requirement-analyst',
102    'software-architect',
103    'system-engineer',
104    'software-engineer',
105    'sqa-engineer',
106    'code-reviewer',
107    'brutal-critique',
108    'devops-engineer',
109    'documentation-writer',
110    'research-assistant',
111    'agent-manager',
112    'presentation-manager',
113    'main',
114  ].map((id, i) => [id, { design: i % ROBOT_DESIGNS.length, color: (3 * i) % ROBOT_COLORS.length }]),
115)
116
117/** 32-bit FNV-1a of the text's UTF-16 code units: the stable hash behind robotOf. */
118export function fnv1a(text: string): number {
119  let h = 0x811c9dc5
120  for (let i = 0; i < text.length; i++) {
121    h ^= text.charCodeAt(i)
122    h = Math.imul(h, 0x01000193) >>> 0
123  }
124  return h >>> 0
125}
126
127/**
128 * An agent's robot from its avatar key: ROBOT_TABLE's for an ai-dlc agent; for any other,
129 * h = fnv1a(key), design h mod 6, colour (h >>> 8) mod 8. The same key always gets the same robot.
130 */
131export function robotOf(key: string): Robot {
132  const fixed = own(ROBOT_TABLE, key)
133  if (fixed) return fixed
134  const h = fnv1a(key)
135  return { design: h % ROBOT_DESIGNS.length, color: (h >>> 8) % ROBOT_COLORS.length }
136}
137
138/** `o[k]` for the object's own keys only: `constructor` or `toString` never reach Object.prototype. */
139export const own = <T>(o: Readonly<Record<string, T>>, k: string): T | undefined => (Object.hasOwn(o, k) ? o[k] : undefined)
140
141export const robotId = (r: Robot): string => `lens-robot-${r.design}-${r.color}`
142
143/**
144 * A robot drawn inline in a `d` x `d` box at (x, y): a group moved and scaled onto its 40 x 40 drawing,
145 * its class naming the robot. Inline on purpose: no <symbol> or <use>, see lens.ts drawChart.
146 */
147export const robotInline = (r: Robot, x: number, y: number, d: number): string =>
148  `<g class="${robotId(r)}" transform="translate(${x} ${y}) scale(${d / 40})">${ROBOT_DESIGNS[r.design]!.draw(ROBOT_COLORS[r.color]!)}</g>`
149
150// ---- images ----
151
152const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
153
154/** The first bytes of a base64 text (at most `n`); stops at the first character that is not base64. */
155export function headBytes(base64: string, n = 16): number[] {
156  const out: number[] = []
157  let acc = 0
158  let bits = 0
159  for (const ch of base64) {
160    const v = B64.indexOf(ch)
161    if (v < 0) break
162    acc = (acc << 6) | v
163    bits += 6
164    if (bits >= 8) {
165      bits -= 8
166      out.push((acc >> bits) & 0xff)
167      if (out.length >= n) break
168    }
169  }
170  return out
171}
172
173/** The decoded size of standard padded base64. */
174export function decodedBytes(base64: string): number {
175  const s = base64.replace(/\s+/g, '')
176  const pad = s.endsWith('==') ? 2 : s.endsWith('=') ? 1 : 0
177  return Math.floor((s.length * 3) / 4) - pad
178}
179
180/** The image type the bytes start as (magic bytes), or '' when none of IMAGE_TYPES. */
181export function sniffType(base64: string): string {
182  const b = headBytes(base64, 12)
183  const is = (at: number, xs: number[]) => xs.every((x, i) => b[at + i] === x)
184  if (is(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png'
185  if (is(0, [0xff, 0xd8, 0xff])) return 'image/jpeg'
186  if (is(0, [0x47, 0x49, 0x46, 0x38]) && (b[4] === 0x37 || b[4] === 0x39) && b[5] === 0x61) return 'image/gif'
187  if (is(0, [0x52, 0x49, 0x46, 0x46]) && is(8, [0x57, 0x45, 0x42, 0x50])) return 'image/webp'
188  return ''
189}
190
191/** The allowed types from the `avatarImageTypes` setting (a comma-separated list). */
192export const typesOf = (setting: string): string[] =>
193  setting
194    .split(',')
195    .map(t => t.trim().toLowerCase())
196    .filter(t => t !== '')
197
198export type AvatarSource = { kind: 'data'; mime: string; base64: string } | { kind: 'path'; path: string } | { kind: 'refused'; why: string }
199
200/**
201 * What an `avatarImage.<agent>` value is (AC-13.6): a `data:<type>;base64,` URI, a file path, or refused.
202 * Any other scheme (`http:`, `https:`, `file:` ...) is refused and never fetched. A Windows drive
203 * letter (`C:\`, `C:/`) is a path, not a scheme.
204 */
205export function classify(value: string): AvatarSource {
206  const v = value.trim()
207  if (v === '') return { kind: 'refused', why: 'empty' }
208  const data = /^data:([a-z0-9.+-]+\/[a-z0-9.+-]+);base64,([A-Za-z0-9+/]+={0,2})$/i.exec(v)
209  if (data) return { kind: 'data', mime: data[1]!.toLowerCase(), base64: data[2]! }
210  if (/^data:/i.test(v)) return { kind: 'refused', why: 'a data URI that is not base64 image data' }
211  if (/^[A-Za-z]:[\\/]/.test(v)) return { kind: 'path', path: v }
212  const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(v)
213  if (scheme) return { kind: 'refused', why: `a ${scheme[1]!.toLowerCase()}: URL; only a data URI or a file path is read` }
214  if (v.includes('\0')) return { kind: 'refused', why: 'a path with a NUL character' }
215  return { kind: 'path', path: v }
216}
217
218/** Image bytes checked against the allowed types and the size limit; `declared` is a data URI's own type. */
219export function checkBytes(base64: string, declared: string | null, types: readonly string[], maxBytes: number): { mime: string; bytes: number } | { why: string } {
220  const bytes = decodedBytes(base64)
221  if (bytes > maxBytes) return { why: `${bytes} bytes, over avatarImageMaxBytes (${maxBytes})` }
222  const mime = sniffType(base64)
223  if (mime === '') return { why: 'not an image of a known type' }
224  if (declared !== null && declared !== mime) return { why: `says ${declared} but is ${mime}` }
225  if (!types.includes(mime)) return { why: `${mime} is not in avatarImageTypes (${types.join(', ')})` }
226  return { mime, bytes }
227}
228
229// ---- trust (AC-13.3, 13.4, 13.9) ----
230
231/** A path as trust compares it: `/` separators, no trailing slash; a Windows drive path in lower case. */
232export function normPath(p: string): string {
233  const s = p.replace(/\\/g, '/').replace(/\/+$/, '')
234  return /^[A-Za-z]:(\/|$)/.test(s) ? s.toLowerCase() : s
235}
236
237/**
238 * The folders Claude Code trusts, from the text of ~/.claude.json: each `projects` key whose entry
239 * has `hasTrustDialogAccepted: true` (the rule auto-handoff's config.ts trustOf uses). Fails closed:
240 * an unreadable or malformed file trusts nothing.
241 */
242export function trustedFolders(claudeJson: string | null): string[] {
243  if (claudeJson === null) return []
244  let doc: unknown
245  try {
246    doc = JSON.parse(claudeJson.replace(/^\uFEFF/, ''))
247  } catch {
248    return []
249  }
250  const projects = doc !== null && typeof doc === 'object' ? (doc as Record<string, unknown>).projects : undefined
251  if (projects === null || typeof projects !== 'object' || Array.isArray(projects)) return []
252  return Object.entries(projects as Record<string, unknown>)
253    .filter(([, e]) => e !== null && typeof e === 'object' && (e as Record<string, unknown>).hasTrustDialogAccepted === true)
254    .map(([k]) => k)
255}
256
257/** Whether a resolved real path lies inside one of the resolved trusted folders. */
258export function isInside(realPath: string, realFolders: readonly string[]): boolean {
259  const p = normPath(realPath)
260  return realFolders.some(f => {
261    const d = normPath(f)
262    return d !== '' && (p === d || p.startsWith(d + '/'))
263  })
264}
265
266// ---- drawing ----
267
268/** A loaded image, ready to draw: a data URI, and its PNG bytes for the terminal when it came from a trusted file. */
269export type AvatarImage = { uri: string; bytes: number; png: string | null }
270
271/** What the chart needs to draw avatars: the style, and each agent's image (null: none, or refused). */
272export type ChartAvatars = { style: string; image: (key: string) => AvatarImage | null }
273
274export const NO_IMAGES: ChartAvatars = { style: 'robot', image: () => null }
275
276/** An image avatar inline in a `d` x `d` box at (x, y): the `<image href="data:...">` form spike 029 checked. */
277export const imageInline = (uri: string, x: number, y: number, d: number): string =>
278  `<image href="${uri}" x="${x}" y="${y}" width="${d}" height="${d}" preserveAspectRatio="xMidYMid slice"/>`
279
280/**
281 * A small standalone avatar (panel headers): the robot, or the image when it fits under SVG_CAP;
282 * null for the initials style or an `image` style agent with no image (its initials badge stands).
283 */
284export function avatarSvg(av: ChartAvatars, key: string, size = 22): string | null {
285  const head = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 40 40">`
286  if (av.style === 'initials') return null
287  const img = av.image(key)
288  if (img) {
289    const src = head + imageInline(img.uri, 0, 0, 40) + '</svg>'
290    if (src.length <= SVG_CAP) return src
291  }
292  if (av.style === 'image') return null
293  return head + robotInline(robotOf(key), 0, 0, 40) + '</svg>'
294}
295
hooks/anomaly.ts 426 lines
1// ai-dlc-lens, step 1.6.1 (run AAW-20261023-lens-plugin-from-spike): the anomaly detector behind
2// /ai-dlc:lens-gate. Pure functions, no `$`: register.tsx reads the files and agent states and calls these.
3// The human's mapping (2026-10-24): blocked run red; gate waiting for the human orange; gate rejected red;
4// loop out of rounds deep orange; agent failed or stopped red; agent killed or waiting too long red;
5// hand-back that failed its check yellow. Every check and colour is a setting (hooks/settings.ts).
6// Step 1.6.1.l1 (human, 2026-10-23): a blocked step (a Stages row `blocked`) red even while its run is active,
7// never twice with its blocked run; and `anomalyRunScope` (this session's runs, or every active or blocked run).
8// Parsing is defensive: nothing here throws on any text; what it cannot read it skips and names in `skipped`.
9import type { LensAgent, LensAnomaly } from '../types'
10import { isWaiting, runDirOf } from './lens'
11import type { LensSettings } from './settings'
12
13export type AnomalyKind =
14  | 'runBlocked'
15  | 'stepBlocked'
16  | 'gateWaiting'
17  | 'gateRejected'
18  | 'loopOutOfRounds'
19  | 'agentFailed'
20  | 'agentKilled'
21  | 'agentWaitingLong'
22  | 'handbackFailed'
23
24type Check = { enable: keyof LensSettings; color: keyof LensSettings; label: string; rank: number }
25
26/** Each check: its enable setting, its colour setting, its words, and its rank (the worst colour wins on a run). */
27export const CHECKS: Record<AnomalyKind, Check> = {
28  runBlocked: { enable: 'anomalyRunBlocked', color: 'anomalyRunBlockedColor', label: 'Run blocked', rank: 4 },
29  stepBlocked: { enable: 'anomalyStepBlocked', color: 'anomalyStepBlockedColor', label: 'Step blocked', rank: 4 },
30  gateRejected: { enable: 'anomalyGateRejected', color: 'anomalyGateRejectedColor', label: 'Gate rejected', rank: 4 },
31  agentFailed: { enable: 'anomalyAgentFailed', color: 'anomalyAgentFailedColor', label: 'Agent failed or stopped', rank: 4 },
32  agentKilled: { enable: 'anomalyAgentKilled', color: 'anomalyAgentKilledColor', label: 'Agent killed', rank: 4 },
33  agentWaitingLong: { enable: 'anomalyAgentWaitingLong', color: 'anomalyAgentWaitingLongColor', label: 'Agent waiting too long', rank: 4 },
34  loopOutOfRounds: { enable: 'anomalyLoopOutOfRounds', color: 'anomalyLoopOutOfRoundsColor', label: 'Loop out of rounds', rank: 3 },
35  gateWaiting: { enable: 'anomalyGateWaiting', color: 'anomalyGateWaitingColor', label: 'Gate waiting for the human', rank: 2 },
36  handbackFailed: { enable: 'anomalyHandbackFailed', color: 'anomalyHandbackFailedColor', label: 'Hand-back failed its check', rank: 1 },
37}
38
39export const KINDS = Object.keys(CHECKS) as AnomalyKind[]
40
41const isKind = (k: string): k is AnomalyKind => (KINDS as string[]).includes(k)
42
43/** A check's colour from the settings. */
44export const colorOf = (kind: string, s: LensSettings): string => (isKind(kind) ? String(s[CHECKS[kind].color]) : '#c62828')
45
46/** The worst of a list (highest rank, first on a tie), or null when empty. */
47export function worstOf(list: readonly LensAnomaly[]): LensAnomaly | null {
48  let best: LensAnomaly | null = null
49  for (const a of list) if (best === null || (isKind(a.kind) ? CHECKS[a.kind].rank : 0) > (isKind(best.kind) ? CHECKS[best.kind].rank : 0)) best = a
50  return best
51}
52
53/**
54 * Only the checks the settings keep; none at all while `gateAlert` is off. One anomaly per key (a run read
55 * twice counts once). A blocked step of a run that is itself reported blocked is dropped: the run's anomaly
56 * names its blocked steps, so the step is not reported twice. With `anomalyRunBlocked` off, the steps still are.
57 */
58export function applySettings(list: readonly LensAnomaly[], s: LensSettings): LensAnomaly[] {
59  if (s.gateAlert !== true) return []
60  const kept = list.filter(a => isKind(a.kind) && s[CHECKS[a.kind].enable] === true)
61  const blockedRuns = new Set(kept.filter(a => a.kind === 'runBlocked').map(a => a.run))
62  const keys = new Set<string>()
63  return kept.filter(a => {
64    if (keys.has(a.key) || (a.kind === 'stepBlocked' && blockedRuns.has(a.run))) return false
65    keys.add(a.key)
66    return true
67  })
68}
69
70/**
71 * The detector's memory across passes: the keys reported so far keep their first-seen time; `appeared`
72 * are the keys new in this pass, the only ones a toast names. A key whose cause is gone leaves the list,
73 * so it is reported again only if it comes back.
74 */
75export function diffAnomalies(prev: readonly LensAnomaly[], next: readonly LensAnomaly[]): { list: LensAnomaly[]; appeared: LensAnomaly[] } {
76  const seen = new Map(prev.map(a => [a.key, a]))
77  const list = next.map(a => (seen.has(a.key) ? { ...a, since: seen.get(a.key)!.since } : a))
78  return { list, appeared: list.filter(a => !seen.has(a.key)) }
79}
80
81/** One line for a toast or the list: words, run, step, record. */
82export function anomalyLine(a: LensAnomaly): string {
83  const label = isKind(a.kind) ? CHECKS[a.kind].label : a.kind
84  const where = [a.run !== '' ? `run ${a.run.replace(/^.*\//, '')}` : '', a.step !== '' ? `step ${a.step}` : '', a.record !== '' ? a.record : ''].filter(x => x !== '').join(', ')
85  return `${label}${where !== '' ? ` (${where})` : ''}${a.text !== '' ? `: ${a.text}` : ''}`
86}
87
88// ---- reading run.md and the records ----
89
90/** The front matter's `key: value` lines; {} when there is none. */
91export function frontMatter(text: string): Record<string, string> {
92  const fm = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1]
93  const out: Record<string, string> = {}
94  if (fm === undefined) return out
95  for (const line of fm.split(/\r?\n/)) {
96    const m = /^([A-Za-z][\w-]*):\s*(.*?)\s*$/.exec(line)
97    if (m) out[m[1]!.toLowerCase()] = m[2]!.replace(/\s+#.*$/, '')
98  }
99  return out
100}
101
102/** The first non-blank line after the front matter. */
103export function firstBodyLine(text: string): string {
104  const body = text.replace(/^?---\r?\n[\s\S]*?\r?\n---\r?\n?/, '')
105  return (body.split(/\r?\n/).find(l => l.trim() !== '') ?? '').trim()
106}
107
108const cells = (line: string): string[] =>
109  line
110    .split('|')
111    .slice(1, -1)
112    .map(x => x.trim().replace(/^`|`$/g, ''))
113const RULE = /^\|\s*:?-{2,}/
114
115/** The rows of the run's *Gates and loops* table (under that heading, or a table whose first header names a gate). */
116export function gatesAndLoops(text: string): string[][] {
117  const lines = text.split(/\r?\n/)
118  const rows: string[][] = []
119  let heading = ''
120  let inTable = false
121  lines.forEach((line, i) => {
122    if (/^#{1,6}\s/.test(line)) heading = line
123    if (!line.startsWith('|')) {
124      inTable = false
125      return
126    }
127    if (RULE.test(line)) return
128    const c = cells(line)
129    if (RULE.test(lines[i + 1] ?? '')) {
130      inTable = /^#{1,6}\s+gates\s+and\s+loops\b/i.test(heading) || /^gate\b/i.test(c[0] ?? '')
131      return
132    }
133    if (inTable && c.length >= 3) rows.push(c)
134  })
135  return rows
136}
137
138type StageCols = { step: number; stage: number; status: number; record: number; handoff: number; handback: number }
139
140/**
141 * The rows of the run's *Stages* table whose status is `blocked` (the status cell starts with it, after any
142 * `**` or `_`): the step code, the stage, the record cell and what follows `blocked` in the status cell.
143 * The table is the one under a *Stages* heading, or one whose header has a `Stage` column; its `Step`,
144 * `Stage`, `Status` and `Record` columns are found by their headers (`Step` first and `Stage` second when
145 * unnamed). A table with no `Status` column, and a row with no step code, are skipped; a step listed twice counts once.
146 */
147export function blockedSteps(text: string): { code: string; stage: string; record: string; detail: string }[] {
148  const out: { code: string; stage: string; record: string; detail: string }[] = []
149  const lines = text.split(/\r?\n/)
150  let heading = ''
151  let cols: StageCols | null = null
152  lines.forEach((line, i) => {
153    if (/^#{1,6}\s/.test(line)) heading = line
154    if (!line.startsWith('|')) {
155      cols = null
156      return
157    }
158    if (RULE.test(line)) return
159    const c = cells(line)
160    if (RULE.test(lines[i + 1] ?? '')) {
161      const at = (re: RegExp) => c.findIndex(x => re.test(x))
162      const stage = at(/^stages?$/i)
163      const status = at(/^status$/i)
164      const step = at(/^step$/i)
165      const isStages = /^#{1,6}\s+stages?\b/i.test(heading) || stage >= 0
166      cols = isStages && status >= 0 ? { step: step >= 0 ? step : 0, stage: stage >= 0 ? stage : 1, status, record: at(/^record$/i), handoff: at(/^handoff$/i), handback: at(/^handback$/i) } : null
167      return
168    }
169    const k = cols as StageCols | null
170    if (k === null) return
171    const code = (c[k.step] ?? '').replace(/[*_]/g, '').trim()
172    const m = /^blocked\b(.*)$/i.exec((c[k.status] ?? '').replace(/^[*_\s]+/, ''))
173    if (code === '' || m === null || out.some(x => x.code === code)) return
174    out.push({
175      code,
176      stage: (c[k.stage] ?? '').replace(/[*_]/g, '').trim(),
177      // A `Record` column, else the run-state template's `Handback`, then `Handoff` (handoff skill, *Run state*).
178      record: cellOf(c, k.record) || cellOf(c, k.handback) || cellOf(c, k.handoff),
179      detail: m[1]!.replace(/^[\s*_·:—–()-]+/, '').replace(/[\s*_)]+$/, '').slice(0, 120),
180    })
181  })
182  return out
183}
184
185/** A Stages cell, '' for a missing column or a dash. */
186const cellOf = (c: string[], i: number): string => (i >= 0 ? (c[i] ?? '').replace(/^[—–-]$/, '') : '')
187
188const LOOP_ARROW = /→|->/
189const REJECTED = /\b(rejected|not approved|declined|no-go|refused)\b/i
190const APPROVED = /\b(approved|accepted)\b|^\s*go\s*$/i
191const USED_UP = /\b(used up|out of rounds|limits? reached|exhausted)\b/i
192const RESOLVED = /\b(raised|accepted|accept|approved)\b/i
193
194/**
195 * Addendum L1: whether a gate row neither approved nor rejected still waits for its decision. It does when its
196 * decision cell is empty, a dash, or says it waits (`pending`, `waiting`, `open`, `tbd`, `todo`, `not yet`), or
197 * when its Date cell is empty. A row the human decided in other words (`spike 027 rework first, then the scope
198 * pass`, `changed`, `missed: …`), dated, is decided: the live run's four such rows were reported as gates waiting.
199 */
200export function gateUndecided(decision: string, date: string): boolean {
201  const blank = (x: string) => /^[\s—–-]*$/.test(x.replace(/[*_`]/g, ''))
202  return blank(decision) || /^(pending|waiting|open|tbd|to ?do|not yet)\b/i.test(decision.replace(/[*_`]/g, '').trim()) || blank(date)
203}
204
205/** A gate decision cell: approved, rejected or pending (empty, a dash, "waiting", anything else). */
206export function gateDecision(cell: string): 'approved' | 'rejected' | 'pending' {
207  if (REJECTED.test(cell)) return 'rejected'
208  if (APPROVED.test(cell)) return 'approved'
209  return 'pending'
210}
211
212/** A record's step code, its loop round, sender and receiver, and whether it is a handoff or a hand-back. */
213export function recordName(name: string): { code: string; base: string; round: number; from: string; to: string; kind: 'handoff' | 'handback' } | null {
214  const m = /^(\d+(?:\.\d+)*)(?:\.l(\d+))?-(.+?)-to-(.+)\.(handoff|handback)\.md$/.exec(name)
215  if (!m) return null
216  return { code: m[2] !== undefined ? `${m[1]}.l${m[2]}` : m[1]!, base: m[1]!, round: m[2] !== undefined ? Number(m[2]) : 0, from: m[3]!, to: m[4]!, kind: m[5] as 'handoff' | 'handback' }
217}
218
219/** Orders two step codes (`2.1` < `2.10` < `3`; `3` < `3.l1`). */
220export function compareCodes(a: string, b: string): number {
221  const parts = (c: string) => c.split('.').map(x => (x.startsWith('l') ? [1, Number(x.slice(1))] : [0, Number(x)]))
222  const pa = parts(a)
223  const pb = parts(b)
224  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
225    const x = pa[i]
226    const y = pb[i]
227    if (x === undefined) return -1
228    if (y === undefined) return 1
229    const d = x[1]! - y[1]! || x[0]! - y[0]!
230    if (d !== 0) return d
231  }
232  return 0
233}
234
235/** A hand-back that failed its check: `verification: failed…` in its front matter, or a first line `handoff <file>: failed`. */
236export function handbackFailed(text: string): boolean {
237  const v = frontMatter(text)['verification'] ?? ''
238  return /^"?failed\b/i.test(v) || /^handoff\s+\S+?:\s*failed\b/i.test(firstBodyLine(text))
239}
240
241const make = (kind: AnomalyKind, key: string, run: string, step: string, record: string, text: string, now: number, agent = '', target = ''): LensAnomaly => ({
242  key: `${kind}:${key}`,
243  kind,
244  run,
245  step,
246  record,
247  agent,
248  target,
249  text,
250  since: now,
251})
252
253/**
254 * One run's anomalies: blocked, gates waiting or rejected, loops out of rounds, failed hand-backs.
255 * `files`: the run folder's records as read (`text` null when it could not be read). A run that is
256 * `done` or `abandoned` has none: its causes are gone. `skipped` names what could not be read.
257 */
258export function detectRun(dir: string, runText: string | null, files: readonly { name: string; text: string | null }[], now: number): { list: LensAnomaly[]; skipped: string[] } {
259  const list: LensAnomaly[] = []
260  const skipped: string[] = []
261  if (runText === null) return { list, skipped }
262  const fm = frontMatter(runText)
263  if (Object.keys(fm).length === 0) skipped.push(`${dir}/run.md: no front matter`)
264  const status = (fm['status'] ?? '').toLowerCase()
265  if (status === 'done' || status === 'abandoned') return { list, skipped }
266  const steps = blockedSteps(runText)
267  const stepsText = steps.length === 0 ? '' : `blocked step${steps.length > 1 ? 's' : ''} ${steps.map(x => x.code).join(', ')}`
268  if (status === 'blocked') list.push(make('runBlocked', dir, dir, fm['current-stage'] ?? '', fm['last-record'] ?? '', stepsText, now))
269  // Step 1.6.1.l1: each blocked step, whatever the run's status (applySettings drops it when the run is reported blocked).
270  for (const x of steps) list.push(make('stepBlocked', `${dir}:${x.code}`, dir, x.code, x.record, x.detail !== '' ? `${x.stage}: ${x.detail}` : x.stage, now))
271
272  // Gates and loops: the last row of each gate or loop decides.
273  const gates = new Map<string, { approver: string; decision: string; date: string }>()
274  const loops = new Map<string, { round: string; decision: string }>()
275  for (const r of gatesAndLoops(runText)) {
276    const [name = '', second = '', decision = '', , date = ''] = r
277    if (LOOP_ARROW.test(name)) {
278      loops.set(name.replace(/\(.*?\)/g, '').replace(/\s+/g, ' ').trim(), { round: second, decision })
279      continue
280    }
281    const id = /^(G\d+|release)\b/i.exec(name)?.[1] ?? (/^(human|orchestrator|none)$/i.test(second) ? name.split(/\s+/)[0] ?? '' : '')
282    if (id !== '') gates.set(id.toLowerCase(), { approver: second.toLowerCase(), decision, date })
283  }
284  for (const [id, g] of gates) {
285    const d = gateDecision(g.decision)
286    const gate = id.toUpperCase().replace(/^RELEASE$/, 'release')
287    if (d === 'rejected') list.push(make('gateRejected', `${dir}:${id}`, dir, gate, '', g.decision, now))
288    else if (d === 'pending' && g.approver === 'human' && gateUndecided(g.decision, g.date)) list.push(make('gateWaiting', `${dir}:${id}`, dir, gate, '', '', now))
289  }
290  for (const [name, l] of loops)
291    if (USED_UP.test(`${l.round} ${l.decision}`) && !RESOLVED.test(l.decision)) list.push(make('loopOutOfRounds', `${dir}:${name}`, dir, name, '', l.round, now))
292
293  // The newest record addressed to the human: a gate waiting, unless its gate has a decision.
294  const named = files.map(f => ({ f, r: recordName(f.name) })).filter((x): x is { f: (typeof files)[number]; r: NonNullable<ReturnType<typeof recordName>> } => x.r !== null)
295  const newest = named.reduce<(typeof named)[number] | null>((m, x) => (m === null || compareCodes(x.r.code, m.r.code) > 0 ? x : m), null)
296  if (newest !== null && newest.r.kind === 'handoff' && newest.r.to === 'human') {
297    const gate = (newest.f.text === null ? '' : (frontMatter(newest.f.text)['gate'] ?? '')).trim()
298    const id = gate !== '' && gate.toLowerCase() !== 'none' ? gate.toLowerCase() : ''
299    const decided = id !== '' && gates.has(id) && gateDecision(gates.get(id)!.decision) !== 'pending'
300    if (!decided && !list.some(a => a.key === `gateWaiting:${dir}:${id}`)) list.push(make('gateWaiting', `${dir}:${id || newest.f.name}`, dir, id !== '' ? gate : newest.r.code, newest.f.name, '', now))
301  }
302
303  // Hand-backs: only the newest round of each step and sender counts; a later round clears an earlier failure.
304  const latest = new Map<string, (typeof named)[number]>()
305  for (const x of named) {
306    if (x.r.kind !== 'handback') continue
307    const k = `${x.r.base}|${x.r.from}`
308    const cur = latest.get(k)
309    if (cur === undefined || x.r.round > cur.r.round) latest.set(k, x)
310  }
311  for (const x of latest.values()) {
312    if (x.f.text === null) {
313      skipped.push(`${dir}/${x.f.name}: not readable`)
314      continue
315    }
316    if (handbackFailed(x.f.text)) list.push(make('handbackFailed', `${dir}/${x.f.name}`, dir, x.r.code, x.f.name, '', now, '', 'edge'))
317  }
318  return { list, skipped }
319}
320
321/**
322 * The agents' anomalies: failed or stopped, killed, waiting too long (`waitingSince`: when each was first
323 * seen waiting). A failed or killed agent clears once a later agent of its type is spawned for the same
324 * run (or, with no run, by the same parent): the stage was run again.
325 */
326export function detectAgents(agents: Record<string, LensAgent>, waitingSince: Record<string, number>, now: number, waitingMinutes: number): LensAnomaly[] {
327  const list: LensAnomaly[] = []
328  const all = Object.values(agents)
329  for (const a of all) {
330    if (a.id === 'main') continue
331    const run = runDirOf(a) ?? ''
332    const rec = a.handoff ?? ''
333    const record = rec.replace(/^.*\//, '')
334    const step = recordName(record)?.code ?? ''
335    const redone = () => all.some(b => b.id !== a.id && b.type === a.type && b.startedAt > a.startedAt && (run !== '' ? runDirOf(b) === run : b.parent === a.parent))
336    if ((a.status === 'failed' || a.status === 'stopped') && !redone()) list.push(make('agentFailed', a.id, run, step, record, a.status, now, a.id, 'agent'))
337    else if (a.status === 'killed' && !redone()) list.push(make('agentKilled', a.id, run, step, record, '', now, a.id, 'agent'))
338    else if (isWaiting(a)) {
339      const since = waitingSince[a.id]
340      if (since !== undefined && now - since >= waitingMinutes * 60_000) list.push(make('agentWaitingLong', a.id, run, step, record, `waiting ${Math.floor((now - since) / 60_000)} min`, now, a.id, 'agent'))
341    }
342  }
343  return list
344}
345
346/** Updates when each agent was first seen waiting: kept while it waits, dropped once it does not. */
347export function trackWaiting(agents: Record<string, LensAgent>, prev: Record<string, number>, now: number): Record<string, number> {
348  const out: Record<string, number> = {}
349  for (const a of Object.values(agents)) if (isWaiting(a)) out[a.id] = prev[a.id] ?? now
350  return out
351}
352
353/**
354 * The agent a failed hand-back's arrow belongs to: the one whose brief named the record with the same
355 * step code and the agents swapped (`3-a-to-b.handback.md` -> `3-b-to-a.handoff.md`), in that run folder.
356 */
357export function edgeAgentOf(a: LensAnomaly, agents: Record<string, LensAgent>): string {
358  const r = recordName(a.record)
359  if (r === null) return ''
360  const want = `${a.run}/${r.code}-${r.to}-to-${r.from}.handoff.md`
361  return Object.values(agents).find(x => x.handoff === want)?.id ?? ''
362}
363
364/** The anomalies of one workflow: those of its runs and of its agents. */
365export const anomaliesOf = (list: readonly LensAnomaly[], dirs: readonly string[], ids: readonly string[]): LensAnomaly[] =>
366  list.filter(a => (a.run !== '' && dirs.includes(a.run)) || (a.agent !== '' && ids.includes(a.agent)))
367
368// ---- step 1.6.1.l1: which runs are read (`anomalyRunScope`) ----
369
370const DEFAULT_ROOT = { root: 'docs/handoffs', depth: 1 }
371
372/** A folder path as the lens compares them: forward slashes, no leading `./`, no trailing `/`. */
373export const normDir = (d: string): string => d.replace(/\\/g, '/').replace(/^(\.\/)+/, '').replace(/\/+$/, '')
374
375/**
376 * Where the runs are: the part of `artifacts.handoffs` (from `.ai-dlc/workflow.json`) before its first
377 * placeholder segment (`{...}` or `<...>`), and how many folder levels the placeholders take (default
378 * `docs/handoffs`, 1 level). A missing, invalid or unusable value (a placeholder in the first segment, a
379 * `..` segment, an absolute path, more than 3 placeholder levels) gives the default.
380 */
381export function handoffsRoot(workflowText: string | null): { root: string; depth: number } {
382  if (workflowText === null) return DEFAULT_ROOT
383  let v: unknown
384  try {
385    const json: unknown = JSON.parse(workflowText)
386    const artifacts = json !== null && typeof json === 'object' ? (json as Record<string, unknown>)['artifacts'] : undefined
387    v = artifacts !== null && typeof artifacts === 'object' ? (artifacts as Record<string, unknown>)['handoffs'] : undefined
388  } catch {
389    return DEFAULT_ROOT
390  }
391  if (typeof v !== 'string') return DEFAULT_ROOT
392  const path = normDir(v.trim())
393  if (path === '' || /^([A-Za-z]:)?\//.test(path)) return DEFAULT_ROOT
394  const segs = path.split('/').filter(x => x !== '')
395  if (segs.includes('..')) return DEFAULT_ROOT
396  const first = segs.findIndex(x => /[{<]/.test(x))
397  if (first === 0) return DEFAULT_ROOT
398  if (first < 0) return { root: segs.join('/'), depth: 0 }
399  const depth = segs.length - first
400  return depth > 3 ? DEFAULT_ROOT : { root: segs.slice(0, first).join('/'), depth }
401}
402
403/**
404 * The run folders a pass reads. `session`: the folders this session's briefs name. `all`: those, plus every
405 * scanned folder whose run.md is `active` or `blocked` (`done`, `abandoned` and the rest are not read).
406 * Each folder once: the session's as named, a scanned one only when no session folder normalises to it.
407 */
408export function runsToRead(scope: string, sessionDirs: readonly string[], scanned: readonly { dir: string; status: string }[]): string[] {
409  const out: string[] = []
410  const seen = new Set<string>()
411  for (const d of sessionDirs) {
412    const n = normDir(d)
413    if (n === '' || seen.has(n)) continue
414    seen.add(n)
415    out.push(d)
416  }
417  if (scope === 'all')
418    for (const x of scanned) {
419      const d = normDir(x.dir)
420      if (d === '' || seen.has(d) || !/^(active|blocked)$/i.test(x.status.trim())) continue
421      seen.add(d)
422      out.push(d)
423    }
424  return out
425}
426
types/index.d.ts 247 lines
1export type KitRedraw = { n: number; columns: number; placement: string; mode: string }
2
3/** Token counts as `ModelUsage` has them: `in` is uncached input (D:6605-6627). */
4export type Tokens = { in: number; out: number; cr: number; cw: number }
5
6/** One agent of the flowchart: `main` for the session's main loop, else the agent id. */
7export type LensAgent = {
8  id: string
9  parent: string | null
10  /** `agent.spawn` `subagentType` (`ai-dlc:agent-manager`), or the session agent for main. */
11  type: string
12  /** `Agent({ name })` when given. */
13  name: string
14  description: string
15  /** Resolved model id (`AgentSpawnResult.model`, then `TurnUsage.model`): the key for costs and windows. */
16  model: string
17  /** The Agent call's `model` parameter as given (`agent.spawn` `model`), '' when it named none. */
18  callModel: string
19  /** The parent's effective model (`agent.spawn` `parentModel`): what `inherit` resolves to. */
20  parentModel: string
21  /** The brief the orchestrator wrote (`agent.spawn` `prompt`): the arrow panel's fallback (finding 2). */
22  prompt: string
23  status: string
24  toolUseId: string
25  /** Sum of every `turn.step` usage of this loop. */
26  steps: Tokens
27  /** Sum of every `turn.complete` usage of this loop: the hand-off's tokens. */
28  turns: Tokens
29  /** The last step's usage: context used = in + cr + cw + out (D:8021). */
30  last: Tokens | null
31  handoff: string | null
32  /** `first line`, `body`, `fallback` or `none`: how the record path was found. */
33  handoffHow: string
34  /** Two-way arrow: a respawn of the same type by the same parent, or a SendMessage to it. */
35  loop: boolean
36  startedAt: number
37  endedAt: number | null
38  /** Background tasks: `$.agent.list()` children and stop-time `background_tasks`. */
39  bg: LensTask[]
40  /** `classic.SubagentStop` `last_assistant_message`: the result its background task shows. */
41  output?: string
42  /** Each `turn.step` usage of this loop, one per request (round 5: long-context pricing is per request; round 6: the last REQS_CAP only). */
43  reqs: Tokens[]
44  /** An agent-team teammate (`agent.spawn` `teammateId`, `$.agent.list()` `teammateId`, type `teammate`). */
45  teammate?: boolean
46}
47
48export type LensTask = {
49  id: string
50  type: string
51  status: string
52  description: string
53  /** Where the entry came from (`agent.list`, `tool.call`, `Stop`, `SubagentStop`): kept for debugging, never drawn. */
54  source: string
55  /** The agent type of a subagent task (`agent.list` `type`, `background_tasks` `agent_type`). */
56  agentType?: string
57  /** When the lens first saw it, and when it first saw it end (round 4: no event gives the times). */
58  startedAt?: number
59  endedAt?: number | null
60  /** Its output or result, when an event carries one. */
61  output?: string
62}
63
64export type LensCall = {
65  id: string
66  tool: string
67  summary: string
68  status: string
69  /** The output tokens of the step that asked for it, and how many calls that step asked for (estimate). */
70  stepOut: number
71  stepCalls: number
72}
73
74/** What the separate arrow and avatar panes show: the last arrow and avatar pressed in `tab` mode. */
75export type LensUi = {
76  edge: string | null
77  agent: string | null
78  /** The pressed arrow's / avatar's agent as the card drew it: a call not spawned yet is in no other state. */
79  edgeSnap: LensAgent | null
80  agentSnap: LensAgent | null
81}
82
83/**
84 * Step 1.5: an arrow or avatar opened in `drill-in` mode, inside the drawing it was pressed in (the
85 * workflow pane or band, one chat card), with "< Back". Kept per drawing.
86 */
87export type LensDrill = { kind: string; id: string; snap: LensAgent }
88
89/**
90 * Step 1.5: the workflow view's state. `place`: `pinned` (above the prompt) or `docked` (the pane);
91 * `size`: `normal`, `minimised` or `fullscreen`; `prev`: the sizes before, newest last, that restore
92 * returns to; `selected`: the run (workflow) shown beside the list when wide, or opened from the list when narrow.
93 * Step 36.2, amendment 3: `pane`, the run the pane shows while the band shows the view (the pane `/ai-dlc:lens` opens
94 * beside the band); absent or '': the band's `selected`. `size` and `prev` are then the band's alone: that pane is never minimised.
95 * Amendment 8: `band`, the band's own size (`normal` or `minimised`) while the view is docked, when the band is still
96 * drawn above the prompt because a workflow is in progress; `size` and `prev` are then the docked pane's.
97 */
98export type LensView = { place: string; size: string; prev: string[]; selected: string; pane?: string; band?: string }
99
100/** What `tool.call` saw of one Agent call before `agent.spawn` named its agent: the placeholder avatar. */
101export type LensPending = { type: string; description: string; name: string; prompt: string; model: string }
102
103/** An agent definition as the lens read it: `model:` and `Persona name:` (finding 1d), and where from. */
104export type LensDef = { model: string; persona: string; path: string }
105
106/** The resolved lens settings (hooks/settings.ts): each value, the level it came from, the values ignored. */
107export type LensConfig = { settings: Record<string, number | boolean | string>; sources: Record<string, string>; ignored: string[] }
108
109/** `.ai-dlc/model-costing.json` as read (never written here): its text, or why there is none. */
110export type LensCosting = { path: string; text: string; error: string }
111
112/**
113 * The Agent calls one assistant message made (one `turn.step`), in the order the model made them.
114 * The first one's ToolUse row draws the flowchart for all of them; `parent` is the loop that made them.
115 */
116export type LensBatch = { parent: string; ids: string[]; pending: Record<string, LensPending>; /** Step 1.6.1: each call's spawner when it is not `parent` (workflowBatch). */ by?: Record<string, string> }
117
118/** The arrow panel: the matched record and its hand-back, else the orchestrator's prompt (finding 2); `hint` a record found by time order only. */
119export type LensDoc = { agent: string; handoff: string; handoffText: string; handback: string; handbackText: string; error: string; prompt: string; hint: string }
120
121/**
122 * Workflows across messages (round 4 bug): `of` maps each Agent call to its workflow (named by its
123 * first call), `by` to its spawner; `parent` is each workflow's spawner; `order` oldest first.
124 */
125export type LensWorkflows = { of: Record<string, string>; by: Record<string, string>; parent: Record<string, string>; order: string[] }
126
127/**
128 * A run's `run.md` as the lens reads it (round 5): its title, `status:`, the stage in progress, and
129 * each stage row's name by its step code, so an agent's task follows its handoff record. `started`: its
130 * front matter's `started:` (else `created:`), present only when set: the workflow view's last fallback for when the run started.
131 * `parent`: step 27.1, its front matter's `parent:`, the run folder's name (as written, backticks and
132 * any path before it dropped), present only when set: the run is drawn in that run's chart (`runRoots`).
133 */
134export type LensRun = {
135  title: string
136  status: string
137  stage: string
138  stages: Record<string, string>
139  started?: string
140  parent?: string
141  /**
142   * Step 8.6.1: the record files of the run folder (`*.handoff.md`, `*.handback.md`, and the oldest form
143   * `<step>-<from>-to-<to>.md`), as `refreshRuns` listed them; absent when the folder could not be listed
144   * (a run merged away). The workflow view draws one chart per top-level step from them (`stepTrees`).
145   */
146  records?: string[]
147  /**
148   * Step 8.6.1: the step offset of each run merged into this one, by its run id, from the run.md table that
149   * has an `Offset` column (`+18`: the merged run's step k is this run's step k + 18). A brief that still
150   * names the merged run's folder joins this run's step chart k + offset.
151   */
152  offsets?: Record<string, number>
153  /**
154   * Step 8.6.1.l1: the run.md *Features* table (`Step | Feature | Old steps | Status`, written when a run's
155   * records were renumbered into feature roots): each root's feature name by its step number. The step chart
156   * of a root is titled with it.
157   */
158  features?: Record<string, string>
159  /**
160   * Step 8.6.1.l1: the same table's `Old steps` column as old code -> new code prefixes (`renamesFromFeatures`):
161   * old `30` -> `8.6`, old `10.2` -> `5.1`. A brief written before the renumbering, which names an old code,
162   * is drawn at the record it was renamed to (`locateRecord`).
163   */
164  renames?: Record<string, string>
165  /**
166   * Per-code renames, old code -> new code, from the run folder's rename sources
167   * (`isRenameSource`: a JSON file's `codeMap`, or an `Old code | New code` table), oldest file first and
168   * composed (`composeCodeMaps`). A brief written before such a renaming is drawn at the record it became
169   * (`locateRecord`, which applies this before the `renames` prefixes and composes the two).
170   */
171  codeMap?: Record<string, string>
172}
173
174/**
175 * Step 1.6.1: one anomaly /ai-dlc:lens-gate reports (hooks/anomaly.ts). `key` names it while its cause
176 * holds: it is reported once when it appears and dropped when the cause is gone. `run`: the run folder
177 * ('' for none); `step` and `record`: the step code and the record file it concerns ('' when none);
178 * `agent` and `target`: the agent whose avatar (`agent`) or arrow (`edge`) carries the marker; `since`:
179 * when the lens first saw it.
180 */
181export type LensAnomaly = { key: string; kind: string; run: string; step: string; record: string; agent: string; target: string; text: string; since: number }
182
183/** Round 6: an automatic notice the engine fed the main loop (a task notification, a reminder): never a task title. `w`: the workflow it is shown on, '' for none. */
184export type LensNotice = { text: string; at: number; w: string }
185
186/**
187 * Keep-alive's state as the ai-dlc plugin publishes it (`$.state` `{ plugin: 'ai-dlc', key: 'keepalive' }`,
188 * plugins/claude/ai-dlc/types/index.d.ts): only the fields the lens reads. A plugin's contract declares
189 * only its own state, so this is not in PluginState below; hooks/lens.ts `keepaliveLine` checks each
190 * field it uses. The lens does not list ai-dlc under `dependencies`: without it the read gives no value.
191 * Read-only here.
192 */
193export type LensKeepaliveTrack = { name: string; running: boolean; nextPingAt: number | null; warmUntil: number; pings: number; cap: number }
194export type LensKeepalive = { enabled: boolean; updatedAt: number; sessionId: string; tracks: LensKeepaliveTrack[] }
195
196/**
197 * /ai-dlc:lens-keepalive (step 1.4): one arming of one keep-alive track, as the lens last read it from
198 * ai-dlc's value (`armedAt`, `warmUntil`, `pings`). Kept per `<track>@<armedAt>` in the lens's own state,
199 * so the time of earlier armings survives the next one; hooks/lens.ts `noteKeepalive`, `keptWarm`.
200 */
201export type LensKeepaliveCycle = { track: string; armedAt: number; warmUntil: number; pings: number }
202
203declare module 'claude-code' {
204  interface PluginState {
205    'ai-dlc-lens': {
206      tick: boolean
207      paneExpanded: boolean
208      panePresses: number
209      forceMode: string
210      agents: Shaped<Record<string, LensAgent>>
211      runs: Shaped<Record<string, LensRun>>
212      order: Shaped<string[]>
213      calls: Shaped<Record<string, LensCall[]>>
214      thinking: Record<string, string>
215      context: Shaped<LensCall[]>
216      mainWindow: number
217      ui: Shaped<LensUi>
218      doc: Shaped<LensDoc>
219      probeLines: number
220      batches: Shaped<Record<string, LensBatch>>
221      cardOpen: Record<string, boolean>
222      callAgent: Record<string, string>
223      defs: Shaped<Record<string, LensDef>>
224      roster: Record<string, string>
225      costing: Shaped<LensCosting>
226      chartLead: string
227      callTask: Record<string, string>
228      mainPrompt: string
229      workflows: Shaped<LensWorkflows>
230      lens: Shaped<LensConfig>
231      notices: Shaped<LensNotice[]>
232      callKey: Record<string, string>
233      drills: Shaped<Record<string, LensDrill>>
234      view: Shaped<LensView>
235      openMode: string
236      avatarRev: number
237      keepaliveCycles: Shaped<Record<string, LensKeepaliveCycle>>
238      anomalies: Shaped<LensAnomaly[]>
239      tabsMin: Record<string, boolean>
240      /** Step 8.6.1: the step chart full screen shows, per workflow (its chart key); absent: the first in order, the highest code. */
241      stepSel: Record<string, string>
242      /** Step 36.2, amendment 8: the band closed with its close, which it has only while no workflow is in progress. */
243      bandClosed: boolean
244    }
245  }
246}
247