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…

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.
< 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).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.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.
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.
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.
| Command | What it does | |
|---|---|---|
/ai-dlc:lens | Opens 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-kit | Opens 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 :).
◉ 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.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.keepaliveSection), Tool calls, Background tasks (each task listed once, starting collapsed, and toggling on its own), Thinking; and the buttons View transcript and Refresh.statusInProgressColor (blinks, statusBlink), pending statusPendingColor, halted statusHaltedColor (an agent failed or was killed, or the run is blocked), waiting statusWaitingColor, completed statusCompletedColor.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.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.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-from-<run>.md record for it, at the step shifted by the merge offset (when run.md has the Offset table).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).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.docs/handoffs/<run>/ folder as well as from live spawns, so earlier steps are drawn without having been spawned in this session.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.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).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.Briefs naming records no longer on disk line (next bullet). Step numbers decide the order, not file times..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).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.lens: the <pane> could not be drawn (…), instead of going blank./ai-dlc:lens pane update while they are shown, and clear when the cause is gone./clear (no new session start follows a /clear, so stopping there would leave the lens without them).⤢ 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.
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.
| Key | Default | Values | What it does | |
|---|---|---|---|---|
chartMode | auto | auto, chart, button | Chat card: the chart when the row is wide enough, else a chip with an "Open flowchart" button; chart or button always that one. | |
breakpointColumns | 0 | 0 to 1000 | Row width from which auto draws the chart; 0: the chart width divided by cellPx. | |
cellPx | 8 | 4 to 32 | CSS px per viewport column (no API gives the cell width). | |
chartColumnPx | 120 | 60 to 400 | Width of one avatar column, px. | |
chartRowPx | 96 | 60 to 400 | Height of one chart level, px. | |
chartAvatarsPerRow | 8 | 2 to 24 | In progress. The most avatars one chart row holds; a level with more wraps onto further rows. Env: AI_DLC_LENS_CHART_AVATARS_PER_ROW. | |
avatarRadiusPx | 14 | 6 to 40 | Avatar radius, px. | |
collapsedByDefault | true | boolean | Every [+]/[-] section starts collapsed. | |
blink | true | boolean | Running avatars blink. | |
blinkMs | 1000 | 200 to 10000 | One blink cycle, ms. | |
showCost | true | boolean | Cost line in the agent panel. | |
cacheWriteRate | 5m | 5m, 1h | Cache-write price used for cost (the usage does not say the TTL). | |
windowFallback | main | main, none | A 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.json | path | Model metadata: prices, windows, limits (read only). | |
costingFallbackPath | .ai-dlc/model-costing.json | path | Read when costingPath is missing (the file's name before the rename). | |
rosterPath | .claude/agent-memory/ai-dlc-orchestrator/project_team-roster.md | path | Team roster (agent ID to persona name). | |
contextLimit | 60 | 1 to 1000 | Gathered-context calls kept for the main loop. | |
callsLimit | 80 | 1 to 1000 | Tool calls kept per agent. | |
callsShown | 40 | 1 to 1000 | Tool calls listed in the agent panel. | |
recordChars | 6000 | 200 to 200000 | Characters shown of a handoff record or hand-back. | |
thinkingChars | 4000 | 200 to 200000 | Characters of thinking kept per agent. | |
longContextThresholdTokens | 0 | 0 to 100000000 | Overrides every model's long-context threshold for cost; 0: the metadata file decides. | |
statusBlink | true | boolean | The in-progress status indicator blinks. | |
statusBlinkMs | 1000 | 200 to 10000 | One status blink cycle, ms. | |
statusInProgressColor | #2e7d32 | hex colour | In progress (blinks). | |
statusPendingColor | #cfd8dc | hex colour | Pending. | |
statusHaltedColor | #c62828 | hex colour | Halted. | |
statusWaitingColor | #ef6c00 | hex colour | Waiting. | |
statusCompletedColor | #2e7d32 | hex colour | Completed (steady). | |
avatarStyle | robot | robot, image, initials | See Avatars below. | |
avatarImageTypes | image/png | list of image/png, image/jpeg, image/gif, image/webp | Allowed image types, comma-separated; checked on the bytes. | |
avatarImageMaxBytes | 65536 | 1 to 2097152 | Largest avatar image, bytes. | |
keepaliveSection | true | boolean | The Cache keep-alive section of the agent panes (/ai-dlc:lens-keepalive toggles it). | |
panelOpenMode | drill-in | tab, drill-in | How 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. | |
chartInChat | true | boolean | Draw each workflow's chart in the card of its first spawn. | |
chartInPane | true | boolean | Draw the charts in the workflow view; off: the avatar and arrow buttons only. | |
workflowPlace | pinned | pinned, docked | Where the view starts: the band above the prompt where the surface has one (terminal, desktop app), else the pane; docked: the pane. | |
wideColumns | 70 | 0 to 1000 | View width from which the wide layout applies. | |
runListColumns | 34 | 16 to 200 | Width of the stacked run panels in the wide layout. | |
gateAlert | true | boolean | Report 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. |
anomalyRunScope | session | session, all | Which 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). | |
anomalyWaitingMinutes | 30 | 0 to 1440 | How long an agent may wait before it is an anomaly, minutes; 0: as soon as it waits. | |
anomalyRunBlocked | true | boolean | Check: a run is blocked (status: blocked in its run.md); the anomaly names its blocked steps. | |
anomalyRunBlockedColor | #c62828 | hex colour | Marker colour: blocked run (red). | |
anomalyStepBlocked | true | boolean | Check: a Stages row of run.md has a status starting with blocked, whatever the run's status. | |
anomalyStepBlockedColor | #c62828 | hex colour | Marker colour: blocked step (red). | |
anomalyGateWaiting | true | boolean | Check: 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 | #ef6c00 | hex colour | Marker colour: gate waiting (orange). | |
anomalyGateRejected | true | boolean | Check: a gate was rejected (the last row of that gate in Gates and loops says rejected, not approved, declined or no-go). | |
anomalyGateRejectedColor | #c62828 | hex colour | Marker colour: gate rejected (red). | |
anomalyLoopOutOfRounds | true | boolean | Check: 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 | #d84315 | hex colour | Marker colour: loop out of rounds (deep orange). | |
anomalyAgentFailed | true | boolean | Check: an agent failed or stopped, and no later agent of its type was spawned for the same run. | |
anomalyAgentFailedColor | #c62828 | hex colour | Marker colour: agent failed or stopped (red). | |
anomalyAgentKilled | true | boolean | Check: an agent was killed, and no later agent of its type was spawned for the same run. | |
anomalyAgentKilledColor | #c62828 | hex colour | Marker colour: agent killed (red). | |
anomalyAgentWaitingLong | true | boolean | Check: an agent has waited (held, or a teammate idle) for anomalyWaitingMinutes or more. | |
anomalyAgentWaitingLongColor | #c62828 | hex colour | Marker colour: agent waiting too long (red). | |
anomalyHandbackFailed | true | boolean | 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. | |
anomalyHandbackFailedColor | #f9a825 | hex colour | Marker colour: hand-back that failed its check (yellow). | |
probes | false | boolean | Log 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
hooks/register.tsx 3602 lines1// 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 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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 lines1// 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}
361hooks/avatar.ts 295 lines1// 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}
295hooks/anomaly.ts 426 lines1// 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}
426types/index.d.ts 247 lines1export 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