Optional heads-up display beside the status line, as a mod (function hooks, Claude Code 2.1.293 or later): a Tasks pane with the open epics of kodflow-hooks'…

An optional heads-up display beside the status line. Two panes and a few toasts, nothing else: the status line keeps the context gauge, the epics' pills and everything it already draws, and this mod shows only what it does not.
waiting), a task gone silent: at most one per task and per state.It is a mod: a hooks module (hooks/mod/register.tsx) the engine loads from hooks/hooks.json → modules. It needs Claude Code 2.1.293 or later (tested on 2.1.296, the version the CI pins) and reads the task list that kodflow-hooks keeps; without kodflow-hooks the Tasks pane says there is no list and the Agents pane works on its own.
/plugin install kodflow-hud@kodflow
scripts/install.sh at the repository root installs it with the others. claude plugin disable kodflow-hud@kodflow turns it off without touching the rest.
| Command | Effect |
|---|---|
/hud | opens the Tasks pane, and the Agents pane while a subagent runs |
/hud tasks (tâches) | opens the Tasks pane |
/hud agents | opens the Agents pane while a subagent runs; with none, says so and opens it with the first |
/hud off (close) | closes both; the Agents tab no longer opens by itself until /hud |
With autoOpen on (the default) the Tasks pane opens when a session starts, alone, so it is the one shown. The Agents tab opens with the first subagent and closes with the last: the main loop alone never gets a tab. Every poll (3 s) the two tabs are retitled when their count moved. An open or a retitle may raise the pane it names; the pane shown before is opened again after, so the view does not jump to Agents. The engine seats a pane nobody asked for only where it has room — from 144 columns, docked beside the transcript in the fullscreen layout — and keeps it waiting below that; /hud seats it at any width.
Captures in text of the drawn tree, read back by the tests at the pane's minimum: 90 columns with its frame (88 of body) by 20 rows. Colour is the text's alone, and always one of the theme's own colours, which the engine shades for a light or a dark theme: merged (purple) the epic and the critical path, warning (amber) the task under way, success (green) done, planMode (teal) ready, permission the tasks waiting on the person, error (red) blocked or frozen, ide (blue) the model, claude the main loop. Each subagent has one mid-tone colour of its own for the session (eight, picked from its id, none a state's), the same in both panes. No cell paints a background, so a transparent terminal shows through.
R12, the tree (the view a pane opens on):
─ #1 Routage model/effort ──────────────────────────────────────────────────────────────
TÂCHE AGENT MODÈLE ÂGE LIENS
◆ #1 Routage model/effort 7/15 1 h 52
├▸ ✓ T0 Événements réels ◉ principal opus 2 h ⇢ C6 C7
├▸ ✓ C1–C5 Policy et routage ◉ principal opus 1 h 20 ⇢ C9
┃ ├▾ ● C6 Porte Fable ↳ mkt-routi… · general opus·xhigh 12 min ⇢ C9
│ ├─ ✓ agent.spawn ◉ principal opus 20 min
┃ │ ├▾ ● Grant de session ↳ mkt-routi… · general opus·xhigh 3 min ⇢ #10
│ │ ├─ ✓ Test resume ◉ principal opus 8 min
┃ │ │ └─ ⊘ Test parallèle ⇠ #8
│ └─ ○ Repli shell
┃ ├─ ◇ C7 Feedback agents toi 6 min ⇢ C9
┃ ├▸ ▶ C8 Spécialistes ×5 prête ⇢ C9
┃ └─ ⊘ C9 CI et PR ⇠ C6 C7 C8
┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
┃ chemin critique C6 → C9 · C7 → C9 · C8 → C9 · #8 → #10 bloqué par toi (C7)
● C6 Porte Fable mkt-routing-pr · general-purpose · opus·xhigh · 12 min · ⇢ C9 · figé
Le mod juge, il ne réécrit jamais e.model
────────────────────────────────────────────────────────────────────────────────────────
Arbre Graphe ↑↓ choisir · ⏎ plier/déplier · tab changer de vue
R4, the graph (tab, or a click on « Graphe »):
─ #1 Routage model/effort ──────────────────────────────────────────────────────────────
FAIT · PRÊT EN COURS · ATTENTE BLOQUÉ
✓ T0 Événements réels ──┬─▶ ● C6 Porte Fable ───────┐
principal · opus · 2 h │ mkt-routing-pr · 12 min │
└─▶ ◇ C7 Feedback agents ───┤
attend toi · 6 min │
✓ C1–C5 Policy et routage ────────────────────────────────┼─▶ ⊘ C9 CI et PR
principal · 1 h 20 │ attend C6 C7 C8
▶ C8 Spécialistes ×5 ──────────────────────────────────┘
prête · personne dessus
┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
prochaine action ▶ C8 prête, personne dessus t'attend ◇ C7 · 6 min
┃ chemin critique C6 → C9 · C7 → C9 · C8 → C9 · #8 → #10
livrable ⊘ C9 CI et PR · attend C6 C7 C8
────────────────────────────────────────────────────────────────────────────────────────
Arbre Graphe ↑↓ choisir · ⏎ plier/déplier · tab changer de vue
Every column is a Box of a fixed width, a gap of 2 between them; what goes in one is cut to it first, counted in terminal cells (string-width's count: wide characters and emoji two, the ambiguous glyphs ✓ ● ◆ ▶ ┃ ⇢ one), so nothing is lined up with spaces and a long title never shifts a column.
| Column | Cells | What |
|---|---|---|
| margin | 2 | ┃ for a task on the critical path |
| tree | 30 | 3 a level, the glyph 2, the ref 7 (top level), the title 17, cut with … |
| agent | 22 | ◉ principal (the main loop, theme colour claude), ↳ name · role (a subagent in its own colour, the role its type short: general, a specialist's topic; the name cut first, the role keeping up to seven cells; no role when the type is unknown), toi for the person's, prête for a ready task |
| model | 10 | opus·xhigh, sonnet·med: the family and the effort, shortened to fit |
| age | 6 | right-aligned: 45 s, 12 min, 1 h 20, 2 h, 3 j; red when silent |
| links | 10 | ⇠ C6 C7 C8 what it waits on, else ⇢ C9 what it unblocks, then +n |
An epic's line carries no agent and no model: its title runs over those columns, followed by its progress, 7/15 (green once every task is done). The tasks filed under no epic sit under a quiet ◌ Hors épopée, with no number, no progress and no age. The section's title never repeats the tab's name: the epic when one is open (#1 Routage model/effort), else how many (2 épopées ouvertes), then the tasks under no epic (1 tâche hors épopée).
Glyphs: ✓ done, ● under way, ◇ waiting on the person, ▶ ready — pending, every blocker done, nobody on it —, ⊘ blocked — pending with a blocker left —, ○ a subtask left to do under a task not done (whoever takes the task takes it). ▸/▾ a task with subtasks, folded or not. The critical path is the longest blockedBy chain of tasks not done, down to the epic's final tasks; every task on a chain of that length gets the margin, the footer names the chains and the task that waits on the person (« bloqué par toi » when it is on the path).
Rows, at 20: the title, the headers, the epic (done/total, its age), 11 tasks, a rule, the critical path, two lines on the selection (its glyph, ref, title, agent and role, model, age, links, CI, « figé » when silent, then its description) and the views' bar. One top-level task is open at a time.
Narrower than 88 cells of body the links go, then the model under 76, then the agent under 64: below that, the tree and the age. Fewer than 20 rows, the finished top-level branches fold into « ✓ fait (n) » first, then the detail drops to one line, then to none, then the list scrolls with the selection.
Three columns of 26 — FAIT · PRÊT, EN COURS · ATTENTE, BLOQUÉ — and two gutters of 5; each top-level task on two rows, its title then its agent and age. A gutter joins its links on a vertical (┬ ┐ ┤ ┼ ┘ └), links that share no lane on stretches of their own; a task feeding the right-hand column directly runs on a lane the middle one leaves free, as a solid line, so no two lines cross. Links within a column, or leftwards, are not drawn (the tree shows them). The footer gives the next action (a ready task, on the path first), what waits on the person, the critical path and the deliverable (the epic's final task, and what it waits on). Narrower than 88 cells the three columns share what is left; under 40 a node no longer holds its glyph, ref and title, and the graph says so in place of its lanes.
| Key or click | Effect |
|---|---|
↑ ↓ | choose a task |
⏎ (or space) | fold or unfold it; opening a top-level task folds the other |
tab | tree ↔ graph |
| click on a task | choose it; on a task with subtasks, or a second click, fold it |
| click on « Arbre » / « Graphe » | that view |
The tree is a Client region (hooks/mod/client.tsx, a surface module): the engine hands it keys once a click has given it the focus (Escape gives the focus back) and every pointer event over it, in cells (ClientSurface.onKey, ClientSurface.onPointer in the engine's claude-code typings). The selection, the open branches and the view live in that instance's state, across redraws. The terminal and the desktop draw a Client; VS Code and the mobile app do not, and get the same tree drawn still, at its first state.
Agents, 88 columns by 10 rows (tests/agents.test.tsx), its tab « Agents (3) »:
─ 3 sous-agents en cours ───────────────────────────────────────────────────────────────
AGENT RÔLE MODÈLE ÉTAT
◉ principal fil principal opus ● actif · 15 s
/w/repo
↳ hud-agents general-purpose ● actif · 15 s
● #2 Rendu des agents · /w/repo
↳ vault-audit devops · hashicorp ⏸ figé · 11 min
/w/repo
↳ sans-type type inconnu ◇ attend
The main loop heads the list in the theme's claude colour; each subagent follows in its own colour (the same as in the Tâches pane), the ones at work first, then for half an hour, dimmed, the ones that stopped. The role is the agent's type (subagent_type) short: the plugin prefix dropped, a specialist's <area>-specialist-<topic> read area · topic; a type nobody gave reads « type inconnu », never a guess. The state: ● actif (green, a call in the last minute), ● en cours (amber), ◇ attend (waiting), ⏸ figé (red, bold: no call for silentMinutes), ◌ au repos, ✓ terminé, ✗ échec. Under each, the task it holds and its folder, then, as rows allow, the files it last read (R, violet), wrote (W, orange) or committed (C, green), as filetree colours them. Under 76 columns the role moves under the name; under 56 the model goes.
| Shown | Source |
|---|---|
| epics and tasks | <config>/kodflow/sessions/<session>/tasks.json (version 2, kodflow-hooks' file), re-read whenever its modification time moves, looked at every 3 s and right after a task_* call |
| worker, its state | the task's worker / worker_id, matched against $.agent.list(); kept once the task moves on (tasks.json drops it) |
| subtasks, links, ref | the task's parent, blockedBy, ref (kodflow-hooks' task_create / task_update) |
| ready, blocked, critical path | derived from those links, nothing typed |
| model · effort | a subagent's: the Agent call's effort and its result's resolvedModel, the model agent.spawn resolved, and kodflow-hooks' agent-models.tsv (its PostToolUse record); the main loop's: $.session.model(), no effort (none is said to a mod). A loop no source names has an empty cell, never a guess |
| time since it moved | the later of the task's updated and the last tool call of its loop (worker, else the agent that owns it) |
| silent | in progress, and its loop stopped, gone from the list, or quiet for silentMinutes |
toi | tasks with assignee: "human", when tasks.json carries the field |
| folder of a loop | the last call that names one: a file tool's file, Grep/Glob/LS's path, a leading cd X or git -C X in a shell command (the status line's activity/dir.go), taken to the root of its git work tree |
| files of a loop | Read (read); Write, Edit, MultiEdit, NotebookEdit (write); a git commit turns the loop's writes under that repository to commit. Kept 45 s, eight per loop, five shown at most |
| CI | gh pr checks <url> for the GitHub pull request URLs an open task cites, every 5 minutes at most |
Nothing is a second source of truth: the panes draw from $.state, which holds what was last read from tasks.json, $.agent.list() and the tool calls; a reload of the module reads them again.
tool.call is observed after next: the result goes back as it came, even when the observation fails (.catch replays next). A refused call is not recorded.context; one system prompt section, the colour convention, and none with KODFLOW_COLORS=off. /hud answers one short line, as any command does.find, no fs.list; finding a work tree's root costs one exists per parent folder the first time a folder is seen, none after. The only process it runs is gh pr checks, and only for cited pull requests.The model may wrap a few words of a reply in a mark; the transcript draws them in colour, bold, and the mark is not shown:
| Mark | Colour | Meaning |
|---|---|---|
{{r:words}} | red | what blocks |
{{o:words}} | orange | what needs attention |
{{v:words}} | green | what waits on the person, their answer or their action |
{{p:words}} | pink | a decision the person already made, cited with its date |
The convention reaches the model as one system prompt section, kodflow-hud:colors, added last by a prompt.compose hook on the session side of the cache boundary. It is composed afresh for every prompt, so it holds after a compaction. It says when to mark and how: a few words, a few marks a message at most, never in code, a command, a path or a table meant to be copied, and p only for a decision of the person's whose date is cited. It is left out where nothing draws the reply: a -p run (the print trait), --bare, a render with no surface.
A ui.render hook on AssistantMessage redraws a reply that holds a mark, paragraph by paragraph (paragraphs split on blank lines): a marked paragraph becomes coloured runs, every other one, and every fenced code block, goes back to the engine's Markdown. A mark inside a code block, an unclosed fence or inline code is shown as typed. A reply with no mark is the engine's own drawing, untouched. A failure while drawing falls back to the engine's (.catch replays next(e)).
KODFLOW_COLORS=off (or 0, false, no) turns off both the drawing and the prompt section.
Limits:
prompt.compose hook does not run (claude plugin validate says so): the drawing works, but the model is not told the convention.Set them in /config or under pluginConfigs["kodflow-hud"].options.
| Option | Default | Meaning |
|---|---|---|
silentMinutes | 10 | quiet time after which a task in progress is flagged silent |
autoOpen | true | open the Tasks pane when a session starts, and the Agents tab while a subagent runs |
ci | true | ask gh pr checks about cited pull requests |
KODFLOW_HUD_TRACE=/absolute/path.json writes what the panes would draw to that file when the session ends: how a run with no pane (claude -p, a bench) shows what was collected.
claude -p: there is no screen; the hooks run (collection, trace), no pane is drawn and toasts go nowhere. Expected./hud or a wider window.The panes sit on an opaque grey block even when the terminal background is transparent. The mod is not the cause and cannot remove it: it draws no background of its own (grep backgroundColor hooks/mod finds nothing).
In Claude Code 2.1.300 the engine wraps the docked pane column in a box painted with the theme token composerSidebarBackground (rgb(38, 38, 38) in the dark preset, ansi256(235) once the terminal maps it). $.ui.open (PaneOpenArgs: id, title, focus, closeOnEscape, holdToasts, rows, columns) has no option for it, the Pane render props carry none, and a backgroundColor inside the tree is read only as rgb(), #hex, ansi256(), ansi:<name> or a theme key: nothing means "terminal default". The token is not in the documented list of custom theme overrides.
Workarounds, none of them in this plugin:
background-opacity-cells = true (available since 1.2.0). Per the Ghostty reference, the opacity then also applies to cells that carry an explicit background colour, which is the case of the grey block.~/.claude/themes/ can override composerSidebarBackground with the colour of your terminal background. The token is undocumented and read from the 2.1.300 bundle, not from the documentation, and the override is an opaque colour: it hides the grey without making the pane see-through.Until the engine offers a transparent or default-background pane surface, this stays one of its limits.
git commit written inside an echo counts, and files committed that the loop did not write through a file tool are not listed.$.agent.list() drops a subagent some time after it ends; its worker then reads absent, which counts as stopped.claude plugin validate --strict --json plugins/kodflow-hud # only the "version" warning
claude plugin test plugins/kodflow-hud # pure modules and wiring
tests/colors.test.tsx holds the coloured words: the split into marked paragraphs and runs (code blocks, inline code, bold, unknown marks), the kodflow-hud:colors section last and session, absent with KODFLOW_COLORS=off, under -p and --bare, and a marked reply drawn on the terminal through the engine, the markup gone, the code block left to the engine's markdown. tests/pure.test.ts holds the pure modules (board, silence, toasts, activity, paths, CI). tests/tree.test.tsx draws the « Tâches » pane through the engine and reads it back as text (tests/capture.ts, which cuts every Box to its width as the terminal lays it out): both views at 90 by 20, line for line, every line 88 cells and no background; the columns dropping at 86, 70 and 58; the folds, then the detail, under 20 rows; ↑ ↓ ⏎ tab and clicks on a task and on the views' bar, through the Client's own key and pointer listeners; the same tree on the surfaces with no Client; the derived states, the critical path, the cells (model·effort, links, ages, wide glyphs) and a model column filled only from what the session said. tests/wiring.test.tsx loads the module in the engine with the host in memory and a mocked clock: « Tâches » alone at start and shown, the Agents tab opened and counted with the subagents and closed with the last while « Tâches » stays in front (the host raising every pane it opens), /hud off keeping it shut, panes mounted at several widths on the terminal and desktop surfaces, each loop in its colour, results returned untouched, refused calls not recorded, toasts once per task and state, CI at most every five minutes, no process and no folder listing on a tool call. tests/agents.test.tsx draws the reported case: one epic and one loose task, the main loop alone (no Agents tab, the word « Tâches » only on the tab), then three subagents (a general-purpose one, a specialist, one of unknown type, one frozen), both panes line for line, the colours, and the Agents pane at 60 columns.
A real run, for the attribution by agent (the kit's $.tool.call carries no agentId):
KODFLOW_HUD_TRACE=$PWD/trace.json claude -p --plugin-dir plugins/kodflow-hud \
--allowedTools Read Agent "Bash(ls:*)" "Bash(cd:*)" -- \
"Read a.txt, then launch one general-purpose subagent that reads b/c.txt and runs: cd b && ls"
trace.json then holds two loops under activity: main with a.txt, and the subagent's id with b/c.txt and the folder b.
hooks/mod/register.tsx 639 lines1// register.tsx — kodflow-hud's function hooks: wiring only.
2//
3// Two panes and a few toasts, beside the status line and never in its place
4// (the context segment, the epics' pills and the rest stay there):
5// « Tâches » the open epics of tasks.json as a tree (subtasks, what each
6// task waits on, the critical path) or as a graph by state,
7// who works each task, on what model, how long since it moved,
8// the silent ones and the ones waiting on the person; the
9// view a session starts on, its tab counting the tasks left;
10// « Agents » only while a subagent runs, its tab counting them: the main
11// loop, then each subagent with its role, model, state, task,
12// folder and last files (syncPanes opens, retitles, closes).
13// And a few words of each reply in colour (colors.ts): the transcript draws
14// `{{r:words}}` red, `{{o:…}}` orange, `{{v:…}}` green, `{{p:…}}` pink.
15//
16// Everything is read, nothing is decided: tasks.json is kodflow-hooks' file,
17// re-read whenever its modification time moves; the agents come from
18// `$.agent.list()`; the activity from `tool.call`, observed after `next`
19// and handed back untouched. No tool, no context line, no refusal: this mod
20// can stop nothing, and adds one thing only to what the model reads, the
21// colour convention's system prompt section (off with KODFLOW_COLORS=off,
22// which also turns off the drawing). Every hook fails open.
23//
24// The logic lives in the pure modules beside this file (board.ts, tree.ts,
25// layout.tsx, models.ts, activity.ts, alerts.ts, ci.ts, view.tsx); the
26// « Tâches » pane's selection and folding live in its surface module,
27// client.tsx; what is here reads `$`.
28
29import { atom, read, update } from "claude-code";
30import type { Register } from "claude-code";
31import type {
32 HudActivity,
33 HudAgent,
34 HudBoard,
35 HudCi,
36 HudModel,
37 HudSeen,
38 HudTask,
39} from "../../types";
40import { MAIN, parseBoard, parseTasks, type Ctx } from "./board.ts";
41import { fold, observe, prune } from "./activity.ts";
42import { alertsFor, trimKeys } from "./alerts.ts";
43import { CI_EVERY_MS, ciState, prsToCheck } from "./ci.ts";
44import { configDir, dirname, sessionDir, tasksFile } from "./paths.ts";
45import { agentsView } from "./view.tsx";
46import { running } from "./who.ts";
47import { buildTree } from "./tree.ts";
48import { draw, initialUi, screenOf } from "./layout.tsx";
49import {
50 PROMPT_SECTION,
51 SECTION_ID,
52 blocks,
53 colorsOff,
54 drawn,
55 marked,
56} from "./colors.ts";
57import {
58 fromAgentCall,
59 keepSession,
60 merge,
61 modelKey,
62 parseModels,
63 seenFrom,
64} from "./models.ts";
65
66const boardAtom = atom(
67 { plugin: "kodflow-hud", key: "board" } as const,
68 {
69 found: false,
70 epics: [],
71 } as HudBoard,
72);
73const agentsAtom = atom(
74 { plugin: "kodflow-hud", key: "agents" } as const,
75 [] as HudAgent[],
76);
77const activityAtom = atom(
78 { plugin: "kodflow-hud", key: "activity" } as const,
79 {} as Record<string, HudActivity>,
80);
81const ciAtom = atom(
82 { plugin: "kodflow-hud", key: "ci" } as const,
83 {} as Record<string, HudCi>,
84);
85const toastedAtom = atom(
86 { plugin: "kodflow-hud", key: "toasted" } as const,
87 [] as string[],
88);
89const modelsAtom = atom(
90 { plugin: "kodflow-hud", key: "models" } as const,
91 {} as Record<string, HudModel>,
92);
93const seenAtom = atom(
94 { plugin: "kodflow-hud", key: "seen" } as const,
95 {} as Record<string, HudSeen>,
96);
97const primedAtom = atom({ plugin: "kodflow-hud", key: "primed" } as const, "");
98const tickAtom = atom({ plugin: "kodflow-hud", key: "tick" } as const, 0);
99
100export const TASKS_PANE = "hud-tasks";
101export const AGENTS_PANE = "hud-agents";
102const PANES = [
103 { id: TASKS_PANE, title: "Tâches" },
104 { id: AGENTS_PANE, title: "Agents" },
105] as const;
106
107/** « Tâches (2) »: the tasks left to do across the open epics. */
108export const tasksTitle = (board: HudBoard): string => {
109 const n = board.epics
110 .flatMap((e) => e.tasks)
111 .filter((t) => t.status !== "completed").length;
112 return n ? `Tâches (${n})` : "Tâches";
113};
114
115/** « Agents (1) »: the subagents at work, the main loop not counted. */
116export const agentsTitle = (agents: readonly HudAgent[]): string => {
117 const n = running(agents).length;
118 return n ? `Agents (${n})` : "Agents";
119};
120
121/** How often tasks.json's time, the agents and the TTLs are looked at. */
122export const POLL_MS = 3_000;
123/** The elapsed times redraw on this step, not on every poll. */
124const TICK_MS = 10_000;
125const TASK_TOOL = /^mcp__kodflow-hooks__task_(create|update|epic|focus)$/;
126
127// What the module keeps between polls. A reload starts these over, which
128// costs one re-read of tasks.json; everything drawn is in `$.state`.
129let polling = false;
130let checking = false;
131let syncing = false;
132// Whether the Agents tab opens by itself when a subagent starts: autoOpen at
133// first; off once the person closes that tab or runs /hud off, on again with
134// /hud or /hud agents
135let agentsWanted = true;
136let lastSession = "";
137let lastMtime: number | undefined = -1;
138let modelsMtime: number | undefined = -1;
139let modelsPath = "";
140let allTasks: HudTask[] = [];
141let lastCi = 0;
142let root: string | undefined;
143let home: string | undefined;
144const gitRoots = new Map<string, string>();
145
146// Atoms are written only when their value changed: every write redraws
147const same = (a: unknown, b: unknown): boolean =>
148 JSON.stringify(a) === JSON.stringify(b);
149
150/**
151 * The root of the git work tree a folder lies in, else the folder: so the
152 * view does not jump between the sub-folders of one project. One stat per
153 * level the first time a folder is seen, none after.
154 */
155async function gitRoot($: any, dir: string): Promise<string> {
156 const known = gitRoots.get(dir);
157 if (known) return known;
158 let found = dir;
159 for (let d = dir, i = 0; i < 40; d = dirname(d), i++) {
160 if (await $.fs.exists(`${d}/.git`).catch(() => false)) {
161 found = d;
162 break;
163 }
164 if (d === "/") break;
165 }
166 gitRoots.set(dir, found);
167 return found;
168}
169
170async function tasksPath($: any, session: string): Promise<string | undefined> {
171 const cfg = configDir(await $.env.get("CLAUDE_CONFIG_DIR"), home);
172 return cfg ? tasksFile(sessionDir(cfg, session)) : undefined;
173}
174
175/** Fold what a source says of some loops into the models kept. */
176async function noteModels(
177 $: any,
178 said: Record<string, HudModel>,
179): Promise<void> {
180 const session = await $.session.id();
181 const fold = (before: Record<string, HudModel>) => {
182 const after = keepSession(before, session);
183 for (const [id, m] of Object.entries(said)) {
184 const key = modelKey(session, id);
185 after[key] = merge(after[key], m);
186 }
187 return after;
188 };
189 // Folded inside the update, on the latest record: two Agent results
190 // arriving together each keep the other's entry
191 const now = await read($, modelsAtom);
192 if (!same(now, fold(now))) await update($, modelsAtom, fold);
193}
194
195/**
196 * kodflow-hooks' agent-models.tsv, when its time moved, and the main loop's
197 * model: what the tree's model column reads beside the calls seen live.
198 */
199async function pollModels($: any, tasks: string): Promise<void> {
200 const path = tasks.replace(/tasks\.json$/, "agent-models.tsv");
201 const mtime = await $.fs.stat(path).then(
202 (s: { mtimeMs: number }) => s.mtimeMs,
203 () => undefined,
204 );
205 const said: Record<string, HudModel> = {};
206 // Another session's file is read afresh whatever its time says; a read
207 // that failed is not remembered as done, so the next poll tries again
208 if (mtime !== undefined && (path !== modelsPath || mtime !== modelsMtime)) {
209 const text: string | undefined = await $.fs
210 .read(path)
211 .catch(() => undefined);
212 if (text !== undefined) {
213 Object.assign(said, parseModels(text));
214 modelsMtime = mtime;
215 modelsPath = path;
216 }
217 } else if (mtime === undefined) {
218 modelsMtime = undefined;
219 modelsPath = path;
220 }
221 const main = await $.session.model().catch(() => undefined);
222 if (typeof main === "string" && main) said[MAIN] = { model: main };
223 if (Object.keys(said).length) await noteModels($, said);
224}
225
226function ctxOf(
227 now: number,
228 agents: HudAgent[],
229 activity: Record<string, HudActivity>,
230 silentMs: number,
231): Ctx {
232 return { now, agents, activity, silentMs };
233}
234
235/** One look at the world: tasks.json, the agents, the TTLs, the toasts. */
236async function poll($: any, silentMs: number, ci: boolean): Promise<void> {
237 if (polling) return;
238 polling = true;
239 try {
240 const now = await $.clock.now();
241 const session = await $.session.id();
242 const path = await tasksPath($, session);
243 if (path) {
244 const mtime = await $.fs.stat(path).then(
245 (s: { mtimeMs: number }) => s.mtimeMs,
246 () => undefined,
247 );
248 if (session !== lastSession || mtime !== lastMtime) {
249 const text =
250 mtime === undefined
251 ? undefined
252 : await $.fs.read(path).catch(() => undefined);
253 let raw: unknown = undefined;
254 try {
255 raw = text === undefined ? undefined : JSON.parse(text);
256 } catch {
257 raw = undefined;
258 }
259 allTasks = parseTasks(raw);
260 const board = parseBoard(text);
261 if (!same(await read($, boardAtom), board))
262 await update($, boardAtom, () => board);
263 const seen = seenFrom(allTasks, await read($, seenAtom), session);
264 if (seen) await update($, seenAtom, () => seen);
265 lastSession = session;
266 lastMtime = mtime;
267 }
268 await pollModels($, path);
269 }
270 const listed = await $.agent.list().catch(() => []);
271 const agents: HudAgent[] = listed.map((a: any) => ({
272 id: a.id,
273 // Its name, else the few words of its task: its type is its role,
274 // shown beside the name, so it does not stand in for one
275 label: a.name || a.description || a.type || a.id.slice(0, 8),
276 status: a.status,
277 ...(a.name && { name: String(a.name) }),
278 ...(a.type && { type: String(a.type) }),
279 }));
280 if (!same(await read($, agentsAtom), agents))
281 await update($, agentsAtom, () => agents);
282 await syncPanes($).catch(() => undefined);
283 const pruned = prune(await read($, activityAtom), now);
284 if (pruned) await update($, activityAtom, () => pruned);
285
286 const ctx = ctxOf(now, agents, await read($, activityAtom), silentMs);
287 const baseline = (await read($, primedAtom)) !== session;
288 const toasted = await read($, toastedAtom);
289 const { alerts, keys } = alertsFor(
290 session,
291 allTasks,
292 ctx,
293 new Set(toasted),
294 baseline,
295 );
296 if (keys.length)
297 await update($, toastedAtom, (k) => trimKeys([...k, ...keys]));
298 if (baseline) await update($, primedAtom, () => session);
299 for (const a of alerts) $.ui.toast(a.text);
300
301 const tick = Math.floor(now / TICK_MS);
302 if ((await read($, tickAtom)) !== tick)
303 await update($, tickAtom, () => tick);
304 if (ci && now - lastCi >= CI_EVERY_MS) {
305 lastCi = now;
306 void checkCi($);
307 }
308 } finally {
309 polling = false;
310 }
311}
312
313/** `gh pr checks` for each pull request an open task cites, one at a time. */
314async function checkCi($: any): Promise<void> {
315 if (checking) return;
316 checking = true;
317 try {
318 const prs = prsToCheck((await read($, boardAtom)).epics);
319 for (const pr of prs) {
320 const r = await $.process
321 .run(["gh", "pr", "checks", pr], { timeoutMs: 20_000 })
322 .catch(() => undefined);
323 const state = r ? ciState(r.exitCode, r.stdout, r.stderr) : "error";
324 const at = await $.clock.now();
325 await update($, ciAtom, (m) => ({ ...m, [pr]: { state, at } }));
326 }
327 } finally {
328 checking = false;
329 }
330}
331
332/** Fold one finished tool call into its loop's activity. */
333async function record($: any, e: any, r: any): Promise<void> {
334 const loop: string = e.agentId ?? MAIN;
335 const obs = observe(String(e.tool), e, root, home);
336 // A call that failed still says the loop is alive, not that it touched files
337 if (r?.isError) {
338 obs.files = [];
339 delete obs.commit;
340 }
341 const dir = obs.dir ? await gitRoot($, obs.dir) : undefined;
342 const commit = obs.commit ? await gitRoot($, obs.commit) : undefined;
343 const now = await $.clock.now();
344 await update($, activityAtom, (m) => ({
345 ...m,
346 [loop]: fold(m[loop], loop, obs, now, dir, commit),
347 }));
348}
349
350/**
351 * The panes as the counts want them, every poll. « Tâches » carries the
352 * tasks left, « Agents » the subagents at work, retitled only when the count
353 * moved; the Agents tab opens when a subagent starts (unless the person
354 * closed it, or turned autoOpen off and never asked) and closes when none is
355 * left, so the main loop alone never gets a tab. An open or a retitle may
356 * raise the pane it names: the pane shown before is shown again after, so
357 * the « Tâches » view the session starts on stays in front.
358 */
359async function syncPanes($: any): Promise<void> {
360 if (syncing) return;
361 syncing = true;
362 try {
363 const panes: any[] = await $.ui.panes().catch(() => []);
364 if (!Array.isArray(panes)) return;
365 const find = (id: string) => panes.find((p) => p.id === id);
366 const shown = panes.find((p) => p.isShown);
367 const tTitle = tasksTitle(await read($, boardAtom));
368 const agents = await read($, agentsAtom);
369 const aTitle = agentsTitle(agents);
370 let moved = false;
371 const tasks = find(TASKS_PANE);
372 if (tasks && tasks.title !== tTitle) {
373 await $.ui.open({ id: TASKS_PANE, title: tTitle });
374 moved = true;
375 }
376 const pane = find(AGENTS_PANE);
377 if (running(agents).length === 0) {
378 if (pane) await $.ui.close({ id: AGENTS_PANE });
379 } else if (pane ? pane.title !== aTitle : agentsWanted) {
380 await $.ui.open({ id: AGENTS_PANE, title: aTitle });
381 moved = true;
382 }
383 if (!moved || !shown) return;
384 const after: any[] = await $.ui.panes().catch(() => []);
385 const front = after.find((p) => p.isShown);
386 const back = after.find((p) => p.id === shown.id);
387 if (back && front?.id !== shown.id)
388 await $.ui.open({ id: back.id, title: back.title });
389 } finally {
390 syncing = false;
391 }
392}
393
394async function openPanes(
395 $: any,
396 which: readonly { id: string; title: string }[],
397) {
398 const placed: string[] = [];
399 const board = await read($, boardAtom);
400 const agents = await read($, agentsAtom);
401 for (const p of which) {
402 const title = p.id === TASKS_PANE ? tasksTitle(board) : agentsTitle(agents);
403 // The Agents tab stands only while a subagent runs: asked for now, it
404 // opens with the first one
405 if (p.id === AGENTS_PANE && running(agents).length === 0) continue;
406 const r = await $.ui.open({ id: p.id, title });
407 if (r.isPlaced) placed.push(title);
408 }
409 return placed;
410}
411
412/** Write what the panes would draw to KODFLOW_HUD_TRACE, when it is set. */
413async function trace($: any): Promise<void> {
414 const path = await $.env.get("KODFLOW_HUD_TRACE");
415 if (!path || !path.startsWith("/")) return;
416 await poll($, settings.silentMs, false);
417 const dump = {
418 board: await read($, boardAtom),
419 agents: await read($, agentsAtom),
420 activity: await read($, activityAtom),
421 };
422 await $.fs.write(path, JSON.stringify(dump, null, 1));
423}
424
425// The options, as register received them
426const settings = { silentMs: 10 * 60_000, ci: true };
427
428/** Poll now, in the background; a failure waits for the next period. */
429function kick($: any): void {
430 void poll($, settings.silentMs, settings.ci).catch(() => undefined);
431}
432
433export const register: Register = (on, options) => {
434 settings.silentMs = Math.max(1, Number(options.silentMinutes ?? 10)) * 60_000;
435 settings.ci = options.ci !== false;
436 const autoOpen = options.autoOpen !== false;
437
438 on("session.start", async ($, e, next) => {
439 root = await $.session.root().catch(() => undefined);
440 home = await $.env.get("HOME");
441 await $.command.register({
442 name: "hud",
443 description:
444 "kodflow-hud: open the Tasks and Agents panes (/hud tasks, /hud agents, /hud off)",
445 });
446 agentsWanted = autoOpen;
447 // Unasked, the engine seats a pane only where it has room (144 columns);
448 // below that it waits, and /hud seats it at any width. Only « Tâches »
449 // opens here, so it is the view the session starts on: the Agents tab
450 // comes with the first subagent (syncPanes)
451 if (autoOpen) await $.ui.open(PANES[0]).catch(() => undefined);
452 $.clock.every(POLL_MS, () => kick($));
453 kick($);
454 return next(e);
455 }).catch(($, e, next) => next(e));
456
457 // A run with no pane (`claude -p`, a bench) can still show what was
458 // collected: KODFLOW_HUD_TRACE names a file, written at the session's end
459 on("session.end", async ($, e, next) => {
460 await trace($);
461 return next(e);
462 }).catch(($, e, next) => next(e));
463
464 on("command.run", { command: "hud" }, async ($, e) => {
465 const arg = e.args.trim().toLowerCase();
466 if (arg === "off" || arg === "close") {
467 agentsWanted = false;
468 for (const p of PANES)
469 await $.ui.close({ id: p.id }).catch(() => undefined);
470 return { text: "kodflow-hud: panes closed." };
471 }
472 const tasksOnly = arg === "tasks" || arg === "tâches" || arg === "taches";
473 const agentsOnly = arg === "agents";
474 if (!tasksOnly) agentsWanted = true;
475 const which = tasksOnly
476 ? PANES.filter((p) => p.id === TASKS_PANE)
477 : agentsOnly
478 ? PANES.filter((p) => p.id === AGENTS_PANE)
479 : PANES;
480 kick($);
481 const placed = await openPanes($, which);
482 if (agentsOnly && !placed.length)
483 return {
484 text: "kodflow-hud: no subagent is running; the Agents tab opens with the first one.",
485 };
486 return {
487 text: placed.length
488 ? `kodflow-hud: ${placed.join(", ")} open.`
489 : "kodflow-hud: no room to seat a pane here (a -p run or a host without panes).",
490 };
491 });
492
493 // The person closing the Agents tab means it: it stays closed until /hud
494 on("ui.close", async ($, e, next) => {
495 if (e.origin === "person" && e.id === AGENTS_PANE) agentsWanted = false;
496 return next(e);
497 }).catch(($, e, next) => next(e));
498
499 // Observation only: the call runs first, its result goes back as it came
500 on("tool.call", async ($, e, next) => {
501 const r: any = await next(e);
502 if (r?.deny === undefined) {
503 await record($, e, r).catch(() => undefined);
504 if (TASK_TOOL.test(String(e.tool))) kick($);
505 if (e.tool === "Agent" && !r?.isError) {
506 const said = fromAgentCall(e as { effort?: unknown }, r?.result);
507 if (said)
508 await noteModels($, { [said.id]: said.model }).catch(() => undefined);
509 }
510 }
511 return r;
512 }).catch(($, e, next) => next(e));
513
514 // The model core resolved for a subagent it started, whoever asked
515 on("agent.spawn", async ($, e, next) => {
516 const r: any = await next(e);
517 if (r?.deny === undefined && typeof r?.agentId === "string" && r.model)
518 await noteModels($, { [r.agentId]: { model: String(r.model) } }).catch(
519 () => undefined,
520 );
521 return r;
522 }).catch(($, e, next) => next(e));
523
524 on(
525 "ui.render",
526 { component: "Pane", requestId: TASKS_PANE },
527 async ($, e) => {
528 const els: any = $.ui.resolve(e);
529 await read($, tickAtom);
530 const [board, agents, activity, ciMap, models, seen] = await Promise.all([
531 read($, boardAtom),
532 read($, agentsAtom),
533 read($, activityAtom),
534 read($, ciAtom),
535 read($, modelsAtom),
536 read($, seenAtom),
537 ]);
538 const model = buildTree({
539 board,
540 ctx: ctxOf(await $.clock.now(), agents, activity, settings.silentMs),
541 ci: ciMap,
542 models,
543 seen,
544 session: await $.session.id(),
545 });
546 const width = e.props.bodyColumns;
547 const rows = e.props.scroll.bodyRows;
548 // Where the surface runs modules (terminal, desktop) the tree takes
549 // keys and clicks; elsewhere it is drawn still, at its first state
550 // (the table may name Client where the surface then drops it)
551 const { Client } = els;
552 if (Client && (e.surface === "terminal" || e.surface === "desktop"))
553 return (
554 <Client
555 key="tree"
556 module="./client.tsx"
557 props={{ model, width, rows }}
558 width={width}
559 height={rows}
560 />
561 );
562 return draw(els, screenOf(model, initialUi(model), width, rows), width);
563 },
564 );
565
566 on(
567 "ui.render",
568 { component: "Pane", requestId: AGENTS_PANE },
569 async ($, e) => {
570 const els = $.ui.resolve(e);
571 await read($, tickAtom);
572 const [agents, activity, models, board] = await Promise.all([
573 read($, agentsAtom),
574 read($, activityAtom),
575 read($, modelsAtom),
576 read($, boardAtom),
577 ]);
578 return agentsView(
579 els,
580 {
581 agents,
582 activity,
583 now: await $.clock.now(),
584 home,
585 models,
586 board,
587 session: await $.session.id(),
588 silentMs: settings.silentMs,
589 },
590 e.props.bodyColumns,
591 e.props.scroll.bodyRows,
592 );
593 },
594 );
595
596 // The colour convention, on the session's side of the cache boundary: a
597 // section composed afresh for every prompt, so a compaction keeps it. Only
598 // where a transcript will draw the reply (no -p run, no --bare)
599 on("prompt.compose", async ($, e, next) => {
600 const r = await next(e);
601 if (colorsOff(await $.env.get("KODFLOW_COLORS")) || !drawn(e)) return r;
602 const sections = r.sections.filter((s) => s.id !== SECTION_ID);
603 return {
604 ...r,
605 sections: [
606 ...sections,
607 { id: SECTION_ID, text: PROMPT_SECTION, scope: "session" as const },
608 ],
609 };
610 }).catch(($, e, next) => next(e));
611
612 // A reply with a mark is redrawn paragraph by paragraph: a marked one as
613 // coloured runs, any other as the engine's markdown. No mark: the engine's
614 // own drawing. Any failure draws the engine's
615 on("ui.render", { component: "AssistantMessage" }, async ($, e, next) => {
616 if (colorsOff(await $.env.get("KODFLOW_COLORS"))) return next(e);
617 const parts = blocks(e.props.text);
618 if (!marked(parts)) return next(e);
619 const { Box, Text, Markdown }: any = $.ui.resolve(e);
620 return (
621 <Box flexDirection="column" gap={1}>
622 {parts.map((p, i) =>
623 "runs" in p ? (
624 <Text key={`t${i}`}>
625 {p.runs.map((r, j) => (
626 <Text key={`r${i}-${j}`} color={r.color} bold={r.bold}>
627 {r.text}
628 </Text>
629 ))}
630 </Text>
631 ) : (
632 <Markdown key={`m${i}`} text={p.markdown} />
633 ),
634 )}
635 </Box>
636 );
637 }).catch(($, e, next) => next(e));
638};
639hooks/mod/board.ts 201 lines1// board.ts — the task board read from tasks.json, and what each task's
2// worker is doing. Pure: no `$`, no I/O.
3//
4// tasks.json (version 2) is kodflow-hooks' file, a contract it shares with
5// the status line and lib/epics.jq; this module only reads it. An epic is
6// open as the status line counts it: a task left to do, or the agent's active
7// epic while it has no task yet. Tasks filed under no epic (epic 0) form one
8// more group, « Hors épopée », with no number (its id is 0): the pane's tab
9// already says « Tâches », so the group does not say it again.
10//
11// A task may also carry the tree's fields (kodflow-hooks writes them only
12// once set): `parent`, `blockedBy`, `ref`. A file without them is a flat
13// list, every task at the top.
14
15import type {
16 HudActivity,
17 HudAgent,
18 HudBoard,
19 HudEpic,
20 HudTask,
21} from "../../types";
22import { safe } from "./paths.ts";
23
24export const MAIN = "main";
25export const NO_EPIC_TITLE = "Hors épopée";
26/** What `$.agent.list()` reports for an agent whose loop still stands. */
27export const LIVE = new Set(["running", "waiting", "idle"]);
28
29type Obj = Record<string, any>;
30const isObj = (v: unknown): v is Obj =>
31 typeof v === "object" && v !== null && !Array.isArray(v);
32const text = (v: unknown): string =>
33 typeof v === "string" ? v : v === undefined || v === null ? "" : String(v);
34const num = (v: unknown): number =>
35 typeof v === "number" && Number.isFinite(v) ? v : 0;
36
37const PR_URL = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/g;
38
39/** The pull request URLs a task cites, once each, in order. */
40export function prsOf(...texts: string[]): string[] {
41 const seen = new Set<string>();
42 for (const t of texts) for (const m of t.match(PR_URL) ?? []) seen.add(m);
43 return [...seen];
44}
45
46function toTask(raw: Obj): HudTask {
47 const task: HudTask = {
48 id: text(raw.id),
49 subject: text(raw.subject),
50 status: text(raw.status) || "pending",
51 epic: Number.isInteger(raw.epic) ? raw.epic : 0,
52 agent: text(raw.agent) || MAIN,
53 updated: num(raw.updated) || num(raw.created),
54 prs: prsOf(text(raw.subject), text(raw.description)),
55 };
56 if (raw.worker) task.worker = text(raw.worker);
57 if (raw.worker_id) task.workerId = text(raw.worker_id);
58 if (raw.assignee) task.assignee = text(raw.assignee);
59 if (raw.description) task.description = text(raw.description);
60 if (raw.parent) task.parent = text(raw.parent);
61 if (Array.isArray(raw.blockedBy)) {
62 const ids = raw.blockedBy.map(text).filter((id: string) => id !== "");
63 if (ids.length) task.blockedBy = ids;
64 }
65 if (raw.ref) task.ref = text(raw.ref);
66 return task;
67}
68
69/** Every task of the file, deleted ones being already gone from it. */
70export function parseTasks(raw: unknown): HudTask[] {
71 if (!isObj(raw) || !Array.isArray(raw.tasks)) return [];
72 return raw.tasks
73 .filter((t: unknown) => isObj(t) && Object.hasOwn(t, "id"))
74 .map(toTask)
75 .filter((t: HudTask) => t.status !== "deleted");
76}
77
78/**
79 * The board: the open epics, most recently touched first, then the tasks
80 * filed under no epic while one of them is left to do.
81 */
82export function parseBoard(fileText: string | undefined): HudBoard {
83 if (fileText === undefined) return { found: false, epics: [] };
84 let raw: unknown;
85 try {
86 raw = JSON.parse(fileText);
87 } catch {
88 return { found: true, epics: [] };
89 }
90 const tasks = parseTasks(raw);
91 const data: Obj = isObj(raw) ? raw : {};
92 const active: Obj = isObj(data.active) ? data.active : {};
93 const epics: HudEpic[] = [];
94 for (const e of Array.isArray(data.epics) ? data.epics : []) {
95 if (!isObj(e) || !Number.isInteger(e.id)) continue;
96 const agent = text(e.agent) || MAIN;
97 const own = tasks.filter((t) => t.epic === e.id && t.agent === agent);
98 const left = own.some((t) => t.status !== "completed");
99 const emptyActive = own.length === 0 && active[agent] === e.id;
100 if (!left && !emptyActive) continue;
101 epics.push({
102 id: e.id,
103 title: text(e.title),
104 agent,
105 created: num(e.created) || num(e.touched),
106 touched: num(e.touched),
107 tasks: own,
108 });
109 }
110 epics.sort((a, b) => b.touched - a.touched);
111 const loose = tasks.filter((t) => t.epic === 0);
112 if (loose.some((t) => t.status !== "completed"))
113 epics.push({
114 id: 0,
115 title: NO_EPIC_TITLE,
116 agent: MAIN,
117 created: Math.min(...loose.map((t) => t.updated)),
118 touched: 0,
119 tasks: loose,
120 });
121 return { found: true, epics };
122}
123
124/** What the board needs from the world to judge a task. */
125export type Ctx = {
126 /** Milliseconds since the epoch. */
127 now: number;
128 agents: readonly HudAgent[];
129 activity: Readonly<Record<string, HudActivity>>;
130 /** How long an in_progress task may stay without activity. */
131 silentMs: number;
132};
133
134export type Verdict = {
135 /** The loop working the task: its worker, else the agent that owns it. */
136 loop: string;
137 /** That loop's state as `$.agent.list()` gives it; "main" for the main loop. */
138 state: string;
139 /** The last sign of life: the task's own change or a tool call of the loop. */
140 last: number;
141 silent: boolean;
142 /** Why it is silent: the loop stopped, or nothing happened for too long. */
143 why?: "stopped" | "quiet";
144};
145
146const findAgent = (agents: readonly HudAgent[], id: string) =>
147 agents.find((a) => a.id === id) ??
148 agents.find((a) => safe(a.id) === safe(id));
149
150/** The activity recorded for a loop, by its id or its path-safe spelling. */
151export function activityOf(
152 activity: Readonly<Record<string, HudActivity>>,
153 loop: string,
154): HudActivity | undefined {
155 return (
156 activity[loop] ??
157 Object.values(activity).find((a) => safe(a.agentId) === safe(loop))
158 );
159}
160
161/**
162 * Who works a task, how that loop stands, and whether the task went silent:
163 * in progress with its loop stopped, or with nothing from it for `silentMs`.
164 */
165export function judge(task: HudTask, ctx: Ctx): Verdict {
166 const loop = task.workerId || task.agent;
167 const updated = task.updated * 1000;
168 const seen = activityOf(ctx.activity, loop)?.last ?? 0;
169 const last = Math.max(updated, seen);
170 let state = MAIN;
171 if (loop !== MAIN) state = findAgent(ctx.agents, loop)?.status ?? "absent";
172 const verdict: Verdict = { loop, state, last, silent: false };
173 if (task.status !== "in_progress") return verdict;
174 // A worker that was named and is gone is the clearest case: nobody is on it
175 if (loop !== MAIN && !LIVE.has(state)) {
176 verdict.silent = true;
177 verdict.why = "stopped";
178 } else if (ctx.now - last >= ctx.silentMs) {
179 verdict.silent = true;
180 verdict.why = "quiet";
181 }
182 return verdict;
183}
184
185/** The label of the loop a task belongs to, for the board's rows. */
186export function loopLabel(loop: string, agents: readonly HudAgent[]): string {
187 if (loop === MAIN) return "principal";
188 return findAgent(agents, loop)?.label ?? loop.slice(0, 8);
189}
190
191/** "45s", "3m", "1h02", "2j": how long ago, in the fewest cells. */
192export function ago(ms: number): string {
193 const s = Math.max(0, Math.floor(ms / 1000));
194 if (s < 60) return `${s}s`;
195 const m = Math.floor(s / 60);
196 if (m < 60) return `${m}m`;
197 const h = Math.floor(m / 60);
198 if (h < 24) return `${h}h${String(m % 60).padStart(2, "0")}`;
199 return `${Math.floor(h / 24)}j`;
200}
201hooks/mod/activity.ts 138 lines1// activity.ts — where each agent works, from the tool calls it makes. Pure.
2//
3// Nothing here touches the disk: a location is what the call names, never
4// what a scan finds. The file tools name their file; Grep, Glob and LS their
5// folder; a shell command its folder the two ways the status line reads
6// (internal/adapter/activity/dir.go): a leading `cd X`, or `git -C X`. A
7// commit turns the agent's recent writes under that repository from write
8// to commit, since the files it took are not named anywhere without asking
9// git, and asking git on every call is the cost this mod refuses.
10
11import type { HudActivity, HudFile, HudOp } from "../../types";
12import { dirname, isUnder, resolvePath } from "./paths.ts";
13
14/** How long a touched file stays listed. */
15export const FILE_TTL_MS = 45_000;
16/** How many files a loop keeps; the view shows fewer when room is short. */
17export const MAX_FILES = 8;
18
19const WORD = `("[^"]+"|'[^']+'|[^\\s;&|]+)`;
20const CD = new RegExp(`^\\s*cd\\s+${WORD}`);
21const GIT_DIR = new RegExp(`(?:^|[\\s;&|(])git\\s+-C\\s+${WORD}`);
22// git, then any of its global options, then the commit verb
23const GIT_COMMIT =
24 /(?:^|[\s;&|(])git\s+(?:(?:-C|-c)\s+(?:"[^"]+"|'[^']+'|\S+)\s+|--no-pager\s+|--git-dir=\S+\s+|--work-tree=\S+\s+)*commit\b/;
25
26const unquote = (s: string): string =>
27 s.length >= 2 && (s[0] === '"' || s[0] === "'") && s.at(-1) === s[0]
28 ? s.slice(1, -1)
29 : s;
30
31const FILE_TOOLS: Record<string, { key: string; op: HudOp }> = {
32 Read: { key: "file_path", op: "read" },
33 Write: { key: "file_path", op: "write" },
34 Edit: { key: "file_path", op: "write" },
35 MultiEdit: { key: "file_path", op: "write" },
36 NotebookEdit: { key: "notebook_path", op: "write" },
37};
38const DIR_TOOLS = new Set(["Grep", "Glob", "LS"]);
39
40/** What one call says about where its loop works. */
41export type Observation = {
42 /** The folder it points at, absolute (a file's folder for a file tool). */
43 dir?: string;
44 files: { path: string; op: HudOp }[];
45 /** A commit was made in this folder (still to be taken to its root). */
46 commit?: string;
47};
48
49/**
50 * Read one tool call. `base` is where a relative path lands (the loop's
51 * current folder, else the session's root), `home` what `~` expands to.
52 */
53export function observe(
54 tool: string,
55 input: Readonly<Record<string, unknown>>,
56 base: string | undefined,
57 home: string | undefined,
58): Observation {
59 const at = (raw: unknown): string | undefined =>
60 typeof raw === "string" ? resolvePath(raw, base, home) : undefined;
61 const file = FILE_TOOLS[tool];
62 if (file) {
63 const path = at(input[file.key]);
64 return path
65 ? { dir: dirname(path), files: [{ path, op: file.op }] }
66 : { files: [] };
67 }
68 if (DIR_TOOLS.has(tool)) {
69 const dir = at(input.path);
70 return dir ? { dir, files: [] } : { files: [] };
71 }
72 if (tool === "Bash" && typeof input.command === "string") {
73 const command = input.command;
74 const git = GIT_DIR.exec(command);
75 const cd = CD.exec(command);
76 // git -C names the tree git works on; cd the folder the rest runs in
77 const dir = at(unquote((git ?? cd)?.[1] ?? "")) ?? undefined;
78 const obs: Observation = { files: [] };
79 if (dir) obs.dir = dir;
80 if (GIT_COMMIT.test(command)) {
81 const where = dir ?? base;
82 if (where) obs.commit = where;
83 }
84 return obs;
85 }
86 return { files: [] };
87}
88
89/**
90 * Fold one observation into a loop's record. `dir` is the folder to show
91 * (already taken to its repository's root by the caller), `commitRoot` the
92 * root a commit was made in.
93 */
94export function fold(
95 prev: HudActivity | undefined,
96 agentId: string,
97 obs: Observation,
98 now: number,
99 dir: string | undefined,
100 commitRoot: string | undefined,
101): HudActivity {
102 let files: HudFile[] = prev ? [...prev.files] : [];
103 for (const f of obs.files) {
104 files = files.filter((x) => x.path !== f.path);
105 files.unshift({ path: f.path, op: f.op, at: now });
106 }
107 if (commitRoot)
108 files = files.map((f) =>
109 f.op === "write" && isUnder(f.path, commitRoot)
110 ? { ...f, op: "commit", at: now }
111 : f,
112 );
113 const next: HudActivity = {
114 agentId,
115 files: files.slice(0, MAX_FILES),
116 last: now,
117 };
118 const where = dir ?? commitRoot ?? prev?.dir;
119 if (where) next.dir = where;
120 return next;
121}
122
123/** Drop the files older than the TTL; undefined when nothing changed. */
124export function prune(
125 activity: Readonly<Record<string, HudActivity>>,
126 now: number,
127 ttl = FILE_TTL_MS,
128): Record<string, HudActivity> | undefined {
129 let changed = false;
130 const out: Record<string, HudActivity> = {};
131 for (const [id, a] of Object.entries(activity)) {
132 const files = a.files.filter((f) => now - f.at < ttl);
133 if (files.length !== a.files.length) changed = true;
134 out[id] = files.length === a.files.length ? a : { ...a, files };
135 }
136 return changed ? out : undefined;
137}
138hooks/mod/alerts.ts 63 lines1// alerts.ts — which tasks deserve a toast now. Pure.
2//
3// Three states are news: a task completed, a task blocked (waiting), a task
4// gone silent. Each is toasted at most once per task, keyed
5// `<session>:<task>:<state>`, the keys kept in `$.state` so a reload of the
6// module does not toast them again. The first board read in a session is the
7// baseline: what stands then is recorded without a toast, so opening a
8// session full of finished work does not fire a volley of them.
9
10import type { HudTask } from "../../types";
11import { judge, type Ctx } from "./board.ts";
12
13export type Alert = { key: string; text: string };
14
15/** The news state of a task, if it is in one. */
16export function newsOf(
17 task: HudTask,
18 ctx: Ctx,
19): "done" | "waiting" | "silent" | undefined {
20 if (task.status === "completed") return "done";
21 if (task.status === "waiting") return "waiting";
22 if (judge(task, ctx).silent) return "silent";
23 return undefined;
24}
25
26function line(task: HudTask, state: string, ctx: Ctx): string {
27 const head = `#${task.id} ${task.subject}`;
28 if (state === "done") return `${head} : terminée`;
29 if (state === "waiting") return `${head} : bloquée, en attente`;
30 const v = judge(task, ctx);
31 return v.why === "stopped"
32 ? `${head} : silencieuse, son worker est arrêté`
33 : `${head} : silencieuse, aucune activité depuis ${Math.round(ctx.silentMs / 60000)} min`;
34}
35
36/**
37 * The alerts to raise and the keys to record. With `baseline`, every key is
38 * recorded and nothing is raised.
39 */
40export function alertsFor(
41 session: string,
42 tasks: readonly HudTask[],
43 ctx: Ctx,
44 toasted: ReadonlySet<string>,
45 baseline: boolean,
46): { alerts: Alert[]; keys: string[] } {
47 const alerts: Alert[] = [];
48 const keys: string[] = [];
49 for (const task of tasks) {
50 const state = newsOf(task, ctx);
51 if (!state) continue;
52 const key = `${session}:${task.id}:${state}`;
53 if (toasted.has(key)) continue;
54 keys.push(key);
55 if (!baseline) alerts.push({ key, text: line(task, state, ctx) });
56 }
57 return { alerts, keys };
58}
59
60/** Keep the recorded keys bounded: the newest `max`. */
61export const trimKeys = (keys: readonly string[], max = 500): string[] =>
62 keys.length > max ? keys.slice(keys.length - max) : [...keys];
63hooks/mod/ci.ts 34 lines1// ci.ts — the CI state of the pull requests the tasks cite. Pure.
2//
3// Read by `gh pr checks <url>`, whose exit code says it: 0 every check
4// passed, 8 some still pending, 1 one failed (or gh itself failed, which its
5// output tells apart). Only the pull requests an open task cites are asked,
6// and no more often than every CI_EVERY_MS.
7
8import type { HudCiState, HudEpic } from "../../types";
9
10export const CI_EVERY_MS = 5 * 60_000;
11
12/** The pull requests to ask about: cited by a task not completed. */
13export function prsToCheck(epics: readonly HudEpic[]): string[] {
14 const seen = new Set<string>();
15 for (const e of epics)
16 for (const t of e.tasks)
17 if (t.status !== "completed") for (const pr of t.prs) seen.add(pr);
18 return [...seen];
19}
20
21/** What `gh pr checks` answered, as one state. */
22export function ciState(
23 exitCode: number,
24 stdout: string,
25 stderr: string,
26): HudCiState {
27 if (/no checks reported/i.test(stderr + stdout)) return "none";
28 if (exitCode === 0) return "pass";
29 if (exitCode === 8) return "pending";
30 // 1 is a failed check when gh printed the checks, an error otherwise
31 if (exitCode === 1 && /\bfail/i.test(stdout)) return "fail";
32 return "error";
33}
34hooks/mod/paths.ts 85 lines1// paths.ts — where a session's files are, and how a path is spelled. Pure.
2//
3// configDir, safe and sessionDir are kodflow-hooks' own (hooks/mod/paths.ts),
4// copied rather than imported: a plugin is installed alone, and a link into
5// a sibling plugin is broken for every consumer. They must agree, since this
6// mod reads the tasks.json that one writes.
7
8/** CLAUDE_CONFIG_DIR, else ~/.claude; undefined unless absolute. */
9export function configDir(
10 claudeConfigDir: string | undefined,
11 home: string | undefined,
12): string | undefined {
13 const pick =
14 claudeConfigDir || (home ? `${home.replace(/\/+$/, "")}/.claude` : "");
15 if (!pick.startsWith("/")) return undefined;
16 return pick.length > 1 ? pick.replace(/\/+$/, "") : pick;
17}
18
19/** An identifier usable as a single path component (tasks.py's safe). */
20export function safe(value: unknown): string {
21 const chars = [...String(value)].map((c) =>
22 /^[\p{L}\p{N}_-]$/u.test(c) ? c : "-",
23 );
24 return chars.slice(0, 128).join("") || "default";
25}
26
27export const sessionDir = (config: string, session: string): string =>
28 `${config}/kodflow/sessions/${safe(session)}`;
29
30export const tasksFile = (dir: string): string => `${dir}/tasks.json`;
31
32/** Collapse `.`, `..` and repeated slashes of an absolute path. */
33export function normalize(path: string): string {
34 const out: string[] = [];
35 for (const part of path.split("/")) {
36 if (part === "" || part === ".") continue;
37 if (part === "..") out.pop();
38 else out.push(part);
39 }
40 return "/" + out.join("/");
41}
42
43/**
44 * A path as written in a tool call, made absolute: `~` expanded against
45 * `home`, a relative path joined to `base`. Undefined when neither can place
46 * it (a relative path with no base, `~` with no home).
47 */
48export function resolvePath(
49 raw: string,
50 base: string | undefined,
51 home: string | undefined,
52): string | undefined {
53 let path = raw.trim();
54 if (!path) return undefined;
55 if (path === "~" || path.startsWith("~/")) {
56 if (!home) return undefined;
57 path = home + path.slice(1);
58 }
59 if (!path.startsWith("/")) {
60 if (!base || !base.startsWith("/")) return undefined;
61 path = `${base}/${path}`;
62 }
63 return normalize(path);
64}
65
66export const dirname = (path: string): string =>
67 path.lastIndexOf("/") > 0 ? path.slice(0, path.lastIndexOf("/")) : "/";
68
69/** True when `path` is `dir` or lies under it. */
70export const isUnder = (path: string, dir: string): boolean =>
71 path === dir || path.startsWith(dir === "/" ? "/" : dir + "/");
72
73/** `home` shown as `~`, the way a shell prompt spells it. */
74export function tilde(path: string, home: string | undefined): string {
75 if (!home || home === "/") return path;
76 return isUnder(path, home) ? "~" + path.slice(home.length) : path;
77}
78
79/** A file shown relative to the agent's directory when it lies under it. */
80export function shortFile(path: string, dir: string | undefined): string {
81 if (dir && dir !== path && isUnder(path, dir))
82 return path.slice(dir === "/" ? 1 : dir.length + 1);
83 return path;
84}
85hooks/mod/view.tsx 350 lines1// view.tsx — the « Agents » pane, from plain data. No `$`: the elements come
2// in from the surface's table (`$.ui.resolve(e)`) and the size from the
3// pane's own props, so a test can draw it at any width. The « Tâches » pane
4// is drawn by layout.tsx (the tree and the graph), in its surface module
5// client.tsx; this one is laid out with the same cells, so its columns line
6// up as the tree's do.
7//
8// The pane is about the subagents: register.tsx opens its tab only while one
9// runs, and its title counts them. The main loop heads the list on one line,
10// in the theme's `claude` colour, so the subagents read against it; each
11// subagent then has its line in its own colour (who.ts, the same as in the
12// tree's agent column): its name, its role (its type, short, or « type
13// inconnu » when nothing said it), its model, and its state, a frozen one
14// in red. Under it, the task it holds and the folder it works in, then the
15// files it last read (violet), wrote (orange) or committed (green).
16//
17// ─ 2 sous-agents en cours ───────────────────────────────────────────────
18// AGENT RÔLE MODÈLE ÉTAT
19// ◉ principal fil principal opus ● actif · 3 s
20// ↳ hud-agents general-purpose opus·xhigh ● actif · 15 s
21// ● #2 Rendu des agents · /w/repo
22// W src/view.tsx
23
24import type {
25 HudActivity,
26 HudAgent,
27 HudBoard,
28 HudModel,
29 HudOp,
30 HudTask,
31} from "../../types";
32import { MAIN, activityOf } from "./board.ts";
33import { cut, draw, type Cell, type Span } from "./layout.tsx";
34import { modelKey } from "./models.ts";
35import { safe, shortFile, tilde } from "./paths.ts";
36import { modelCell, span as since } from "./tree.ts";
37import { cells } from "./width.ts";
38import {
39 MAIN_COLOR,
40 MAIN_NAME,
41 RUNNING,
42 colorOf,
43 roleOf,
44 running,
45} from "./who.ts";
46
47/** The two elements every surface's table has, all these trees use. */
48export type Els = { Box: any; Text: any };
49
50export const OP_COLOR: Record<HudOp, string> = {
51 read: "#af87ff",
52 write: "#ff8700",
53 commit: "#5faf5f",
54};
55const OP_MARK: Record<HudOp, string> = { read: "R", write: "W", commit: "C" };
56
57/** Under this long since its last call, a loop is « actif ». */
58export const ACTIVE_MS = 60_000;
59/** How long an agent that stopped stays listed, dimmed, under the others. */
60export const AGENT_LINGER_MS = 30 * 60_000;
61
62/** The columns: glyph, name, role, model, then the state takes the rest. */
63export const AW = { glyph: 2, name: 18, role: 22, model: 10, gap: 2 } as const;
64/** Widths from which the role, then the model, keep their column. */
65export const AFULL = { role: 76, model: 56 } as const;
66
67export type AgentsData = {
68 agents: readonly HudAgent[];
69 activity: Readonly<Record<string, HudActivity>>;
70 now: number;
71 home: string | undefined;
72 models?: Readonly<Record<string, HudModel>>;
73 session?: string;
74 board?: HudBoard;
75 /** How long a running loop may go without a call before it is « figé ». */
76 silentMs?: number;
77};
78
79/** HH:MM:SS of a time, local. */
80export const clock = (ms: number): string => {
81 const d = new Date(ms);
82 return [d.getHours(), d.getMinutes(), d.getSeconds()]
83 .map((n) => String(n).padStart(2, "0"))
84 .join(":");
85};
86
87type Tone = Omit<Span, "text">;
88const sp = (text: string, tone: Tone = {}): Span => ({ text, ...tone });
89const dim = (text: string): Span => ({ text, dim: true });
90const cell = (w: number, spans: Span[] = []): Cell => ({
91 w,
92 spans: cut(spans, w),
93});
94
95export type AgentRow = {
96 id: string;
97 name: string;
98 /** The role, its type short; "" when nothing said it. */
99 role: string;
100 color: string;
101 status: string;
102 /** Whether its loop is at work (the tab counts these). */
103 live: boolean;
104 act?: HudActivity;
105 model: string;
106 task?: { label: string; title: string };
107 state: Span[];
108};
109
110/** How a loop stands, in a few words and one colour. */
111function stateOf(
112 status: string,
113 act: HudActivity | undefined,
114 now: number,
115 silentMs: number,
116): Span[] {
117 const age = act ? ` · ${since(now - act.last)}` : "";
118 if (status === "completed") return [dim(`✓ terminé${age}`)];
119 if (status === "failed") return [sp(`✗ échec${age}`, { color: "error" })];
120 if (status === "killed") return [sp(`✗ arrêté${age}`, { color: "error" })];
121 if (status === "idle") return [dim(`◌ au repos${age}`)];
122 if (status === "pending") return [dim("○ démarre")];
123 if (status === "absent") return [dim(`◌ parti${age}`)];
124 if (act && now - act.last >= silentMs)
125 return [sp(`⏸ figé${age}`, { color: "error", bold: true })];
126 if (status === "waiting")
127 return [sp(`◇ attend${age}`, { color: "permission" })];
128 if (act && now - act.last < ACTIVE_MS)
129 return [sp(`● actif${age}`, { color: "success" })];
130 return [sp(`● en cours${age}`, { color: "warning" })];
131}
132
133const sameLoop = (a: string | undefined, b: string) =>
134 !!a && (a === b || safe(a) === safe(b));
135
136/** The task a loop holds: in progress, handed to it, else filed by it. */
137function heldBy(
138 board: HudBoard | undefined,
139 loop: string,
140): HudTask | undefined {
141 const tasks = (board?.epics ?? []).flatMap((e) => e.tasks);
142 const doing = tasks.filter((t) => t.status === "in_progress");
143 return (
144 doing.find((t) => sameLoop(t.workerId, loop)) ??
145 doing.find((t) => !t.workerId && sameLoop(t.agent, loop))
146 );
147}
148
149/**
150 * The loops to list: the main one first, then the subagents at work, most
151 * recently busy first, then the ones that stopped within the last half hour.
152 * A loop no list names (an engine fork, compaction) is not a subagent.
153 */
154export function agentRows(data: AgentsData): AgentRow[] {
155 const silentMs = data.silentMs ?? 10 * 60_000;
156 const model = (loop: string) =>
157 modelCell(data.models?.[modelKey(data.session ?? "", loop)]);
158 const task = (loop: string) => {
159 const t = heldBy(data.board, loop);
160 return t ? { label: t.ref || `#${t.id}`, title: t.subject } : undefined;
161 };
162 const main = data.activity[MAIN];
163 const head: AgentRow = {
164 id: MAIN,
165 name: MAIN_NAME,
166 role: "fil principal",
167 color: MAIN_COLOR,
168 status: "running",
169 live: true,
170 ...(main && { act: main }),
171 model: model(MAIN),
172 state: main ? stateOf("running", main, data.now, Infinity) : [dim("—")],
173 };
174 const t0 = task(MAIN);
175 if (t0) head.task = t0;
176 const subs: AgentRow[] = [];
177 for (const a of data.agents) {
178 const act = activityOf(data.activity, a.id);
179 const live = RUNNING.has(a.status);
180 if (!live && !(act && data.now - act.last < AGENT_LINGER_MS)) continue;
181 const row: AgentRow = {
182 id: a.id,
183 name: a.name || a.label,
184 role: roleOf(a.type),
185 color: colorOf(a.id),
186 status: a.status,
187 live,
188 ...(act && { act }),
189 model: model(a.id),
190 state: stateOf(a.status, act, data.now, silentMs),
191 };
192 const held = task(a.id);
193 if (held) row.task = held;
194 subs.push(row);
195 }
196 subs.sort(
197 (a, b) =>
198 Number(b.live) - Number(a.live) ||
199 (b.act?.last ?? 0) - (a.act?.last ?? 0),
200 );
201 return [head, ...subs];
202}
203
204/** The section's title: how many subagents are at work, never « Agents ». */
205export function agentsTitle(data: AgentsData): string {
206 const n = running(data.agents).length;
207 return n === 0
208 ? "aucun sous-agent en cours"
209 : `${n} sous-agent${n > 1 ? "s" : ""} en cours`;
210}
211
212function nameLine(r: AgentRow, width: number): Cell[] {
213 const faint = r.id !== MAIN && !r.live;
214 const tone: Tone = { color: r.color, ...(faint && { dim: true }) };
215 const glyph = r.id === MAIN ? "◉" : "↳";
216 const out: Cell[] = [
217 cell(AW.glyph, [sp(glyph, tone)]),
218 cell(AW.name, [sp(r.name, { ...tone, bold: r.live })]),
219 ];
220 let used = AW.glyph + AW.name;
221 if (width >= AFULL.role) {
222 out.push(
223 cell(AW.gap),
224 cell(AW.role, [r.role ? dim(r.role) : dim("type inconnu")]),
225 );
226 used += AW.gap + AW.role;
227 }
228 if (width >= AFULL.model) {
229 out.push(
230 cell(AW.gap),
231 cell(AW.model, r.model ? [sp(r.model, { color: "ide" })] : []),
232 );
233 used += AW.gap + AW.model;
234 }
235 const rest = Math.max(0, width - used - AW.gap);
236 out.push(cell(AW.gap), cell(rest, r.state));
237 return out;
238}
239
240/** Under a loop: the task it holds, its folder, and the role when narrow. */
241function whereLine(r: AgentRow, width: number, home: string | undefined) {
242 const spans: Span[] = [];
243 if (width < AFULL.role && r.id !== MAIN)
244 spans.push(dim(r.role || "type inconnu"), dim(" · "));
245 if (r.task)
246 spans.push(
247 sp("● ", { color: "warning" }),
248 sp(`${r.task.label} `, { bold: true }),
249 sp(r.task.title),
250 );
251 const dir = r.act?.dir ? tilde(r.act.dir, home) : "";
252 if (dir) spans.push(dim(r.task ? ` · ${dir}` : dir));
253 if (!spans.length) return undefined;
254 const pad = 4;
255 return [cell(pad), cell(Math.max(0, width - pad), spans)];
256}
257
258function fileLines(
259 r: AgentRow,
260 width: number,
261 home: string | undefined,
262 count: number,
263): Cell[][] {
264 const pad = 4;
265 return (r.act?.files ?? []).slice(0, count).map((f) => [
266 cell(pad),
267 cell(2, [sp(OP_MARK[f.op], { color: OP_COLOR[f.op] })]),
268 cell(Math.max(0, width - pad - 2), [
269 sp(tilde(shortFile(f.path, r.act?.dir), home), {
270 color: OP_COLOR[f.op],
271 }),
272 ]),
273 ]);
274}
275
276function titleLine(text: string, width: number): Cell[] {
277 const w = Math.min(cells(text), Math.max(0, width - 4));
278 const rest = Math.max(0, width - 2 - w);
279 const line = [cell(2, [dim("─ ")]), cell(w, [sp(text, { bold: true })])];
280 if (rest > 1) line.push(cell(1), cell(rest - 1, [dim("─".repeat(rest - 1))]));
281 return line;
282}
283
284function headerLine(width: number): Cell[] {
285 const out: Cell[] = [cell(AW.glyph), cell(AW.name, [dim("AGENT")])];
286 let used = AW.glyph + AW.name;
287 if (width >= AFULL.role) {
288 out.push(cell(AW.gap), cell(AW.role, [dim("RÔLE")]));
289 used += AW.gap + AW.role;
290 }
291 if (width >= AFULL.model) {
292 out.push(cell(AW.gap), cell(AW.model, [dim("MODÈLE")]));
293 used += AW.gap + AW.model;
294 }
295 out.push(
296 cell(AW.gap),
297 cell(Math.max(0, width - used - AW.gap), [dim("ÉTAT")]),
298 );
299 return out;
300}
301
302/** The lines of the pane, `width` cells by at most `rows`. */
303export function agentsLines(
304 data: AgentsData,
305 width: number,
306 rows: number,
307): Cell[][] {
308 const list = agentRows(data);
309 // What the rows hold, most kept first: each loop's line, then the title,
310 // the task and folder lines, the headers, then the files shared out
311 const fixed = list.length;
312 const title = rows > fixed ? 1 : 0;
313 const where = list.map((r) => whereLine(r, width, data.home));
314 const whereAll = where.filter(Boolean).length;
315 const showWhere = rows >= fixed + title + whereAll;
316 const whereN = showWhere ? whereAll : 0;
317 const header = rows >= fixed + title + whereN + 1 ? 1 : 0;
318 const left = rows - fixed - title - whereN - header;
319 const perAgent = Math.max(0, Math.min(3, Math.floor(left / list.length)));
320 const out: Cell[][] = [];
321 if (title) out.push(titleLine(agentsTitle(data), width));
322 if (header) out.push(headerLine(width));
323 list.forEach((r, i) => {
324 out.push(nameLine(r, width));
325 if (showWhere && where[i]) out.push(where[i]!);
326 out.push(...fileLines(r, width, data.home, perAgent));
327 });
328 return out.slice(0, Math.max(0, rows));
329}
330
331/** The « Agents » pane: the main loop, then each subagent, as cells. */
332export function agentsView(
333 els: Els,
334 data: AgentsData,
335 width: number,
336 rows: number,
337) {
338 return draw(
339 els,
340 {
341 lines: agentsLines(data, width, rows),
342 rowIds: [],
343 tabsLine: -1,
344 tabs: [],
345 order: [],
346 },
347 width,
348 );
349}
350hooks/mod/who.ts 62 lines1// who.ts — how a loop is named and coloured, the same in both panes. Pure.
2//
3// The main loop is « principal », drawn in the theme's own `claude` colour
4// (the engine picks its shade for a light or a dark theme). Each subagent
5// keeps one colour for the session, picked from its id: the same agent is
6// the same colour in the « Tâches » column and in the « Agents » pane. The
7// palette stays clear of the colours the tree gives to states (amber under
8// way, teal ready, mauve the person and the epics, green done, red blocked)
9// and of the model's blue, and every shade is a mid tone, legible on a light
10// ground as on a dark one.
11//
12// An agent's role is its type, `$.agent.list()`'s `type` (the Agent call's
13// `subagent_type`), shown short: the plugin prefix dropped, a specialist's
14// « <area>-specialist-<topic> » read « area · topic ». A type nobody gave is
15// left empty, never guessed.
16
17import type { HudAgent } from "../../types";
18import { MAIN } from "./board.ts";
19import { safe } from "./paths.ts";
20
21/** The theme colour of the main loop. */
22export const MAIN_COLOR = "claude";
23export const MAIN_NAME = "principal";
24
25/** One per subagent, stable for its id: mid tones, none a state's. */
26export const AGENT_PALETTE = [
27 "#5f87d7", // steel blue
28 "#d75f87", // rose
29 "#5faf87", // sea green
30 "#af87d7", // lavender
31 "#d7875f", // terracotta
32 "#87af5f", // olive
33 "#d75fd7", // orchid
34 "#5fafd7", // sky
35] as const;
36
37/** Statuses of a subagent whose loop is at work, as `$.agent.list()` says. */
38export const RUNNING = new Set(["pending", "running", "waiting"]);
39
40/** A loop's colour: the theme's for the main one, the palette's otherwise. */
41export function colorOf(loop: string): string {
42 if (loop === MAIN) return MAIN_COLOR;
43 let h = 0;
44 for (const c of safe(loop)) h = (h * 31 + c.codePointAt(0)!) >>> 0;
45 return AGENT_PALETTE[h % AGENT_PALETTE.length]!;
46}
47
48/**
49 * « devops · hashicorp », « general-purpose », « Explore »: an agent type,
50 * short. The plugin's prefix goes, a specialist or executor keeps its area
51 * and its topic. "" when the type is unknown.
52 */
53export function roleOf(type: string | undefined): string {
54 const bare = (type ?? "").trim().replace(/^[\w.-]+:/, "");
55 const m = /^([a-z]+)-(?:specialist|executor)-(.+)$/.exec(bare);
56 return m ? `${m[1]} · ${m[2]}` : bare;
57}
58
59/** The subagents whose loop is at work: what the « Agents » tab counts. */
60export const running = (agents: readonly HudAgent[]): HudAgent[] =>
61 agents.filter((a) => RUNNING.has(a.status));
62hooks/mod/tree.ts 451 lines1// tree.ts — the task tree the « Tâches » pane draws, derived from the board.
2// Pure: no `$`, no I/O; what it returns is plain data, handed whole to the
3// pane's surface module (client.tsx) as its props.
4//
5// Nothing here is typed by anyone: the states the tree shows beyond
6// tasks.json's own statuses are read off the links kodflow-hooks keeps
7// (`parent`, `blockedBy`):
8// ready ▶ pending, every blocker completed, nobody on it: a task at the
9// top, or under a parent already completed;
10// blocked ⊘ pending, at least one blocker not completed;
11// todo ○ pending under a parent not completed: whoever takes the
12// parent takes it, so it is nobody's to pick up on its own.
13// The critical path is the longest blockedBy chain of tasks not completed,
14// down to the epic's final tasks (the ones nothing waits on); every task on
15// a chain of that length is on it. A list with no link has none.
16
17import type { HudBoard, HudCi, HudModel, HudSeen, HudTask } from "../../types";
18import { MAIN, judge, loopLabel, type Ctx } from "./board.ts";
19import { MAIN_NAME, colorOf, roleOf } from "./who.ts";
20import { cells, fit } from "./width.ts";
21import { modelKey, seenKey } from "./models.ts";
22
23export type NodeState =
24 "done" | "run" | "you" | "wait" | "ready" | "blocked" | "todo";
25
26/** One task as a row of the tree: every cell already worded. */
27export type TreeNode = {
28 id: string;
29 /** How links and footers name it: its ref, else #id. */
30 label: string;
31 title: string;
32 state: NodeState;
33 /** Who works it: a subagent's name, "principal", "toi", "prête"; "" for nobody. */
34 agent: string;
35 /** What `agent` names: the main loop, a subagent, the person, nobody yet. */
36 agentKind: "main" | "sub" | "you" | "ready" | "";
37 /** The subagent's role, its type short (who.ts); "" when unknown. */
38 role: string;
39 /** The loop's colour (who.ts), for the main loop and the subagents. */
40 agentColor?: string;
41 /** model·effort of that loop, "" when the session never said. */
42 model: string;
43 /** Since it last moved; "" for a task nobody has taken. */
44 age: string;
45 silent: boolean;
46 /** "⇠ C6 C7" what it waits on, else "⇢ C9" what it unblocks. */
47 links: string;
48 crit: boolean;
49 desc: string;
50 /** "CI ✓" and the like, for a cited pull request. */
51 ci: string;
52 kids: TreeNode[];
53};
54
55export type TreeEpic = {
56 id: number;
57 title: string;
58 done: number;
59 total: number;
60 age: string;
61 nodes: TreeNode[];
62 /** The critical path's chains, each a list of its tasks, top level first. */
63 chains: { label: string; state: NodeState }[][];
64 /** The task of the person's the epic waits on, and whether on the path. */
65 you?: { label: string; age: string; crit: boolean };
66 /** The first ready task, on the path first. */
67 next?: { label: string; title: string };
68 /** Where the epic ends: its final task on the path, else its last. */
69 goal?: { label: string; title: string; state: NodeState; note: string };
70 /** The graph's edges between top-level tasks, blocker first, by id. */
71 edges: [string, string][];
72};
73
74export type TreeModel = { found: boolean; epics: TreeEpic[] };
75
76export type TreeData = {
77 board: HudBoard;
78 ctx: Ctx;
79 ci: Readonly<Record<string, HudCi>>;
80 models: Readonly<Record<string, HudModel>>;
81 seen: Readonly<Record<string, HudSeen>>;
82 /** The session the board was read for: the `seen` keys carry it. */
83 session: string;
84};
85
86/** "45 s", "12 min", "1 h 20", "12 h", "3 j": six cells at most. */
87export function span(ms: number): string {
88 const s = Math.max(0, Math.floor(ms / 1000));
89 if (s < 60) return `${s} s`;
90 const m = Math.floor(s / 60);
91 if (m < 60) return `${m} min`;
92 const h = Math.floor(m / 60);
93 if (h < 10)
94 return m % 60 ? `${h} h ${String(m % 60).padStart(2, "0")}` : `${h} h`;
95 if (h < 24) return `${h} h`;
96 return `${Math.floor(h / 24)} j`;
97}
98
99const FAMILY = /\b(opus|sonnet|haiku|fable)\b/i;
100const EFFORT: Record<string, [string, string]> = {
101 low: ["low", "lo"],
102 medium: ["med", "med"],
103 high: ["high", "hi"],
104 xhigh: ["xhigh", "xhi"],
105 max: ["max", "max"],
106};
107export const MODEL_CELLS = 10;
108
109/** "opus·xhigh", "sonnet·hi", "haiku": a model id and an effort, in ten cells. */
110export function modelCell(m: HudModel | undefined): string {
111 if (!m?.model) return "";
112 const family = FAMILY.exec(m.model)?.[1]?.toLowerCase() ?? m.model;
113 if (!m.effort) return fit(family, MODEL_CELLS);
114 const [long, short] = EFFORT[m.effort] ?? [m.effort, m.effort];
115 const full = `${family}·${long}`;
116 if (cells(full) <= MODEL_CELLS) return full;
117 return fit(`${family}·${short}`, MODEL_CELLS);
118}
119
120const AGENT_PREFIX = /^[\w.-]+:(developer-)?/;
121export const AGENT_CELLS = 22;
122/** What the role keeps at least beside a long name, in the agent column. */
123const ROLE_MIN = 7;
124
125/** An agent's name, the plugin prefix of a type used as a name dropped. */
126export const agentName = (name: string): string =>
127 name.replace(AGENT_PREFIX, "");
128
129/** A role in the narrow agent column: a specialist's topic, a type's head. */
130export function roleCell(role: string): string {
131 if (!role) return "";
132 const topic = role.split(" · ").pop()!;
133 return topic === "general-purpose" ? "general" : topic;
134}
135
136/**
137 * The agent column's two parts, the name and the role, cut to share `room`
138 * cells around a « · » of three: the role keeps up to seven, the name the
139 * rest; a role with no room for four cells is left out.
140 */
141export function agentParts(
142 name: string,
143 role: string,
144 room: number,
145): { name: string; role: string } {
146 if (!role || cells(name) + 3 + cells(role) <= room)
147 return { name: fit(name, room), role: role && fit(role, room) };
148 const roleW = Math.min(cells(role), ROLE_MIN, room - 3 - 4);
149 if (roleW < 4) return { name: fit(name, room), role: "" };
150 const nameW = Math.min(cells(name), room - 3 - roleW);
151 return { name: fit(name, nameW), role: fit(role, room - 3 - nameW) };
152}
153
154export const LINK_CELLS = 10;
155
156/** "⇠ C6 C7 C8", three labels then "+n", in fourteen cells. */
157export function linkCell(arrow: string, labels: string[]): string {
158 if (!labels.length) return "";
159 const shown = labels.slice(0, 3);
160 const more = labels.length - shown.length;
161 let text = `${arrow} ${shown.join(" ")}${more ? ` +${more}` : ""}`;
162 while (cells(text) > LINK_CELLS && shown.length > 1) {
163 shown.pop();
164 text = `${arrow} ${shown.join(" ")} +${labels.length - shown.length}`;
165 }
166 return fit(text, LINK_CELLS);
167}
168
169const CI_TEXT: Record<string, string> = {
170 pass: "CI ✓",
171 fail: "CI ✗",
172 pending: "CI …",
173 error: "CI ?",
174};
175
176const done = (t: HudTask) => t.status === "completed";
177const labelOf = (t: HudTask) => t.ref || `#${t.id}`;
178
179/** The longest blockedBy chains of unfinished tasks, and who is on them. */
180function criticalPath(
181 tasks: readonly HudTask[],
182 byId: ReadonlyMap<string, HudTask>,
183): { onPath: Set<string>; chains: string[][] } {
184 const open = tasks.filter((t) => !done(t));
185 const openIds = new Set(open.map((t) => t.id));
186 const blockers = (t: HudTask) =>
187 (t.blockedBy ?? []).filter((id) => openIds.has(id) && id !== t.id);
188 const dependents = new Map<string, string[]>();
189 for (const t of open)
190 for (const b of blockers(t))
191 dependents.set(b, [...(dependents.get(b) ?? []), t.id]);
192 // Longest chain from a task down to a final one, and up to a first one;
193 // a cycle in a hand-edited file counts once, never forever
194 const memo = (next: (id: string) => string[]) => {
195 const got = new Map<string, number>();
196 const walk = (id: string, path: Set<string>): number => {
197 const known = got.get(id);
198 if (known !== undefined) return known;
199 if (path.has(id)) return 0;
200 path.add(id);
201 let best = 1;
202 for (const n of next(id)) best = Math.max(best, 1 + walk(n, path));
203 path.delete(id);
204 got.set(id, best);
205 return best;
206 };
207 return (id: string) => walk(id, new Set());
208 };
209 const down = memo((id) => dependents.get(id) ?? []);
210 const up = memo((id) => blockers(byId.get(id)!));
211 let longest = 0;
212 for (const t of open) longest = Math.max(longest, down(t.id));
213 const onPath = new Set<string>();
214 const chains: string[][] = [];
215 if (longest < 2) return { onPath, chains };
216 for (const t of open)
217 if (up(t.id) + down(t.id) - 1 === longest) onPath.add(t.id);
218 // The chains themselves, from each first task along the longest way down
219 const follow = (id: string, left: number, acc: string[]) => {
220 const here = [...acc, id];
221 if (left === 1) return void chains.push(here);
222 for (const n of dependents.get(id) ?? [])
223 if (onPath.has(n) && down(n) === left - 1 && chains.length < 8)
224 follow(n, left - 1, here);
225 };
226 for (const t of open)
227 if (onPath.has(t.id) && up(t.id) === 1) follow(t.id, longest, []);
228 const top = (id: string) => !byId.get(id)?.parent;
229 chains.sort((a, b) => Number(b.every(top)) - Number(a.every(top)));
230 return {
231 onPath,
232 chains,
233 };
234}
235
236/**
237 * The tree of every open epic: tasks under their parent (a parent from
238 * another epic, or none that exists, puts a task at the top), each with its
239 * derived state, its loop, its model, its age and its links.
240 */
241export function buildTree(data: TreeData): TreeModel {
242 const { board, ctx } = data;
243 const epics = board.epics.map((epic) => {
244 const tasks = epic.tasks;
245 const byId = new Map(tasks.map((t) => [t.id, t]));
246 const { onPath, chains: pathIds } = criticalPath(tasks, byId);
247 const unblocks = new Map<string, string[]>();
248 for (const t of tasks)
249 for (const b of t.blockedBy ?? [])
250 unblocks.set(b, [...(unblocks.get(b) ?? []), t.id]);
251 const waitsOn = (t: HudTask) =>
252 (t.blockedBy ?? []).filter((id) => byId.has(id) && !done(byId.get(id)!));
253 const parentOf = (t: HudTask) =>
254 t.parent && t.parent !== t.id ? byId.get(t.parent) : undefined;
255
256 const stateOf = (t: HudTask): NodeState => {
257 if (done(t)) return "done";
258 if (t.status === "in_progress") return "run";
259 // The person's task is theirs whatever its status says
260 if (t.assignee === "human" || t.status === "waiting")
261 return t.assignee === "human" ? "you" : "wait";
262 if (waitsOn(t).length) return "blocked";
263 const parent = parentOf(t);
264 return parent && !done(parent) ? "todo" : "ready";
265 };
266
267 const node = (t: HudTask, depth: number, path: Set<string>): TreeNode => {
268 const state = stateOf(t);
269 const v = judge(t, ctx);
270 const seen = data.seen[seenKey(data.session, t)];
271 const workerId = t.workerId || seen?.workerId;
272 const worker = t.worker || seen?.worker;
273 const loop = workerId || t.agent;
274 let agent = "";
275 let agentKind: TreeNode["agentKind"] = "";
276 let role = "";
277 if (state === "you") {
278 agent = "toi";
279 agentKind = "you";
280 } else if (state === "ready") {
281 agent = "prête";
282 agentKind = "ready";
283 } else if (state === "run" || state === "done" || state === "wait") {
284 // A worker named with no id is still a subagent, known by its name
285 if (loop === MAIN && (!worker || worker === MAIN)) {
286 agent = MAIN_NAME;
287 agentKind = "main";
288 } else {
289 const listed =
290 ctx.agents.find((a) => a.id === loop) ??
291 (loop === MAIN
292 ? ctx.agents.find((a) => a.name === worker)
293 : undefined);
294 agent = agentName(
295 listed?.name || worker || loopLabel(loop, ctx.agents),
296 );
297 role = roleOf(listed?.type);
298 agentKind = "sub";
299 }
300 }
301 const model =
302 state === "run" || state === "done" || state === "wait"
303 ? modelCell(data.models[modelKey(data.session, loop)])
304 : "";
305 let age = "";
306 if (state === "run") age = span(ctx.now - v.last);
307 else if (state === "done" || state === "you" || state === "wait")
308 age = span(ctx.now - t.updated * 1000);
309 const waits = waitsOn(t).map((id) => labelOf(byId.get(id)!));
310 const frees = (unblocks.get(t.id) ?? [])
311 .filter((id) => byId.has(id))
312 .map((id) => labelOf(byId.get(id)!));
313 const links = waits.length ? linkCell("⇠", waits) : linkCell("⇢", frees);
314 const ci =
315 t.prs
316 .map((pr) => CI_TEXT[data.ci[pr]?.state ?? ""] ?? "")
317 .find((c) => c) ?? "";
318 path.add(t.id);
319 const kids =
320 depth < 3
321 ? tasks
322 .filter(
323 (k) =>
324 parentOf(k) === t && !path.has(k.id) && !topIds.has(k.id),
325 )
326 .map((k) => node(k, depth + 1, path))
327 : [];
328 path.delete(t.id);
329 return {
330 id: t.id,
331 label: labelOf(t),
332 title: t.subject,
333 state,
334 agent,
335 agentKind,
336 role,
337 ...((agentKind === "main" || agentKind === "sub") && {
338 agentColor: colorOf(
339 agentKind === "sub" && loop === MAIN
340 ? (ctx.agents.find((a) => a.name === worker)?.id ??
341 `name:${worker}`)
342 : loop,
343 ),
344 }),
345 model,
346 age,
347 silent: v.silent,
348 links,
349 crit: onPath.has(t.id),
350 desc: t.description ?? "",
351 ci,
352 kids,
353 };
354 };
355
356 // A parent that is missing, in another epic or in a loop puts the task on top
357 const rooted = (t: HudTask) => {
358 const seen = new Set<string>([t.id]);
359 for (let p = parentOf(t); p; p = parentOf(p)) {
360 if (seen.has(p.id)) return false;
361 seen.add(p.id);
362 }
363 return true;
364 };
365 const tops = tasks.filter((t) => !parentOf(t) || !rooted(t));
366 const topIds = new Set(tops.map((t) => t.id));
367 const nodes = tops.map((t) => node(t, 1, new Set()));
368
369 const topOf = (t: HudTask): HudTask => {
370 const seen = new Set<string>();
371 let cur = t;
372 for (let p = parentOf(cur); p && !seen.has(p.id); p = parentOf(cur)) {
373 seen.add(cur.id);
374 cur = p;
375 }
376 return cur;
377 };
378 const edges: [string, string][] = [];
379 for (const t of tasks)
380 for (const b of t.blockedBy ?? []) {
381 const from = byId.get(b);
382 if (!from) continue;
383 const pair: [string, string] = [topOf(from).id, topOf(t).id];
384 if (
385 pair[0] !== pair[1] &&
386 !edges.some((e) => e[0] === pair[0] && e[1] === pair[1])
387 )
388 edges.push(pair);
389 }
390
391 const flat: TreeNode[] = [];
392 const walk = (list: TreeNode[]) =>
393 list.forEach((n) => (flat.push(n), walk(n.kids)));
394 walk(nodes);
395 const mine = flat.filter((n) => n.state === "you");
396 const youNode = mine.find((n) => n.crit) ?? mine[0];
397 const ready = flat.filter((n) => n.state === "ready");
398 const nextNode =
399 ready.find((n) => n.crit) ??
400 ready.find((n) => tops.some((t) => t.id === n.id)) ??
401 ready[0];
402 const finals = nodes.filter((n) => !edges.some((e) => e[0] === n.id));
403 const goalNode =
404 finals.find((n) => n.crit) ??
405 finals.find((n) => edges.some((e) => e[1] === n.id)) ??
406 nodes[nodes.length - 1];
407 let goal: TreeEpic["goal"];
408 if (goalNode) {
409 const note: Record<NodeState, string> = {
410 done: "fait",
411 run: "en cours",
412 you: "t'attend",
413 wait: "en attente",
414 ready: "prête",
415 blocked: goalNode.links.startsWith("⇠")
416 ? `attend ${goalNode.links.slice(2)}`
417 : "bloquée",
418 todo: "à faire",
419 };
420 goal = {
421 label: goalNode.label,
422 title: goalNode.title,
423 state: goalNode.state,
424 note: note[goalNode.state],
425 };
426 }
427 const doneCount = tasks.filter(done).length;
428 const out: TreeEpic = {
429 id: epic.id,
430 title: epic.title || `#${epic.id}`,
431 done: doneCount,
432 total: tasks.length,
433 age: epic.created ? span(ctx.now - epic.created * 1000) : "",
434 nodes,
435 chains: pathIds.map((c) =>
436 c.map((id) => ({
437 label: labelOf(byId.get(id)!),
438 state: stateOf(byId.get(id)!),
439 })),
440 ),
441 edges,
442 };
443 if (youNode)
444 out.you = { label: youNode.label, age: youNode.age, crit: youNode.crit };
445 if (nextNode) out.next = { label: nextNode.label, title: nextNode.title };
446 if (goal) out.goal = goal;
447 return out;
448 });
449 return { found: board.found, epics };
450}
451hooks/mod/layout.tsx 1144 lines1// layout.tsx — the « Tâches » pane's two views, laid out cell by cell.
2// Pure: no `$`. The surface module (client.tsx) draws them with its own
3// elements and keeps the selection; a surface with no `Client` gets the same
4// screen, still, from the hooks module.
5//
6// Alignment is the point. A screen is a list of lines, a line a row of cells
7// of a fixed width each, and every cell becomes a Box of exactly that width:
8// nothing is lined up with padding spaces, and what goes in a cell is cut to
9// it first, measured in terminal cells (width.ts), so a wide glyph never
10// shifts the next column. No cell paints a background: colour is the text's
11// alone, so a transparent terminal shows through everywhere.
12//
13// R12, the tree, at its full width (88 cells of body, 90 with the frame):
14// margin 2 · tree 30 (3 a level, glyph 2, ref 7, title 17) · agent 22 ·
15// model·effort 10 · age 6 (right) · links 10, a gap of 2 between them.
16// The agent column names who works the task: « ◉ principal » in the theme's
17// colour, « ↳ name · role » in the subagent's own (who.ts), « toi », « prête ».
18// Narrower, the links go first (under 88), then the model (under 76), then
19// the agent (under 64); the tree and the age stay. On 20 rows: title,
20// headers, the epic, 11 tasks, a rule, the critical path, two lines of
21// detail on the selection and the two lines of the views' bar. Shorter, the
22// finished branches fold into « ✓ fait (n) », then the detail drops to one
23// line, then to none, then the list scrolls with the selection.
24//
25// R4, the graph: three columns of 26 (FAIT · PRÊT, EN COURS · ATTENTE,
26// BLOQUÉ) and two gutters of 5, each top-level task on two rows (its title,
27// then its agent and age). A gutter routes its links on a vertical with
28// ┬ ┐ ┤ ┼ ┘ └ junctions, links sharing no lane on stretches of their own; a
29// link that skips the middle column crosses it on a lane of its own, as a
30// solid line, so no two lines ever cross.
31
32import type { NodeState, TreeEpic, TreeModel, TreeNode } from "./tree.ts";
33import {
34 AGENT_CELLS,
35 LINK_CELLS,
36 MODEL_CELLS,
37 agentParts,
38 roleCell,
39} from "./tree.ts";
40import { cells, fit } from "./width.ts";
41
42/** The elements a screen is drawn with: any surface's table has them. */
43export type Els = { Box: any; Text: any };
44
45// Theme colours, never fixed shades: the engine gives each its tone for the
46// light and the dark themes alike, so the tree reads on either ground.
47export const MAUVE = "merged"; // the epics, the critical path, the views' bar
48export const AMBER = "warning"; // the task under way
49export const TEAL = "planMode"; // ready, nobody on it yet
50export const BLUE = "ide"; // the model
51export const YOU = "permission"; // waiting on the person, as a permission is
52export const GREEN = "success";
53export const RED = "error";
54
55export type Span = {
56 text: string;
57 color?: string;
58 dim?: boolean;
59 bold?: boolean;
60 underline?: boolean;
61};
62
63/** A cell: a fixed width, and either its text or a row of cells inside. */
64export type Cell = { w: number; spans?: Span[]; right?: boolean; row?: Cell[] };
65
66export type Screen = {
67 lines: Cell[][];
68 /** For each line, the task it shows, for a click to select. */
69 rowIds: (string | undefined)[];
70 /** The line of the views' bar, and where each view's name sits. */
71 tabsLine: number;
72 tabs: { x: number; w: number; view: View }[];
73 /** The tasks ↑ and ↓ walk, in the order shown. */
74 order: string[];
75};
76
77export type View = "tree" | "graph";
78
79/** What the person moved: kept by the surface module, across redraws. */
80export type Ui = { view: View; sel?: string; open: string[] };
81
82export const FULL = { links: 88, model: 76, agent: 64 } as const;
83export const W = {
84 margin: 2,
85 tree: 30,
86 agent: AGENT_CELLS,
87 model: MODEL_CELLS,
88 age: 6,
89 links: LINK_CELLS,
90 gap: 2,
91 level: 3,
92 glyph: 2,
93 ref: 7,
94 title: 17,
95} as const;
96
97export const GLYPH: Record<
98 NodeState,
99 { glyph: string; color?: string; dim?: boolean }
100> = {
101 done: { glyph: "✓", color: GREEN },
102 run: { glyph: "●", color: AMBER },
103 you: { glyph: "◇", color: YOU },
104 wait: { glyph: "◇", color: YOU },
105 ready: { glyph: "▶", color: TEAL },
106 blocked: { glyph: "⊘", color: RED },
107 todo: { glyph: "○", dim: true },
108};
109
110const HINT = "↑↓ choisir · ⏎ plier/déplier · tab changer de vue";
111const HINT_SHORT = "↑↓ ⏎ tab";
112
113// --- cells ------------------------------------------------------------------------
114
115const span = (text: string, style: Omit<Span, "text"> = {}): Span => ({
116 text,
117 ...style,
118});
119const dim = (text: string): Span => ({ text, dim: true });
120
121/** Spans cut to `w` cells as one text: the cut falls in the span it reaches. */
122export function cut(spans: Span[], w: number): Span[] {
123 const whole = spans.map((s) => s.text).join("");
124 if (cells(whole) <= w) return spans;
125 const kept = fit(whole, w);
126 const out: Span[] = [];
127 let left = [...kept];
128 for (const s of spans) {
129 if (!left.length) break;
130 const n = [...s.text].length;
131 out.push({ ...s, text: left.slice(0, n).join("") });
132 left = left.slice(n);
133 }
134 if (left.length && out.length) out[out.length - 1]!.text += left.join("");
135 return out;
136}
137
138const cell = (w: number, spans: Span[] = [], right = false): Cell =>
139 right ? { w, spans: cut(spans, w), right } : { w, spans: cut(spans, w) };
140const row = (w: number, inner: Cell[]): Cell => ({ w, row: inner });
141const gap = (): Cell => ({ w: W.gap });
142const rule = (w: number, ch: string): Cell[] => [cell(w, [dim(ch.repeat(w))])];
143
144/** Which of the optional columns a body of `width` cells keeps. */
145export function columnsFor(width: number) {
146 return {
147 links: width >= FULL.links,
148 model: width >= FULL.model,
149 agent: width >= FULL.agent,
150 tree: Math.max(8, Math.min(W.tree, width - W.margin - W.gap - W.age)),
151 };
152}
153
154// --- the tree's rows ----------------------------------------------------------------
155
156type Visible = {
157 node: TreeNode;
158 depth: number;
159 lasts: boolean[];
160 top: TreeNode;
161};
162
163/** The rows the tree shows, an open task's subtasks under it. */
164export function visible(nodes: TreeNode[], open: readonly string[]): Visible[] {
165 const out: Visible[] = [];
166 const walk = (
167 list: TreeNode[],
168 depth: number,
169 lasts: boolean[],
170 top?: TreeNode,
171 ) =>
172 list.forEach((node, i) => {
173 const here = [...lasts, i === list.length - 1];
174 out.push({ node, depth, lasts: here, top: top ?? node });
175 if (node.kids.length && open.includes(node.id))
176 walk(node.kids, depth + 1, here, top ?? node);
177 });
178 walk(nodes, 0, []);
179 return out;
180}
181
182const findNode = (model: TreeModel, id: string | undefined) => {
183 let hit:
184 | { node: TreeNode; epic: TreeEpic; top: TreeNode; parent?: TreeNode }
185 | undefined;
186 for (const epic of model.epics) {
187 const walk = (list: TreeNode[], top?: TreeNode, parent?: TreeNode) =>
188 list.forEach((n) => {
189 if (n.id === id)
190 hit = parent
191 ? { node: n, epic, top: top!, parent }
192 : { node: n, epic, top: n };
193 walk(n.kids, top ?? n, n);
194 });
195 walk(epic.nodes);
196 }
197 return hit;
198};
199
200/**
201 * Where the person starts: the first task under way, its branch open down to
202 * the running subtask; else the first task left to do; else the first.
203 */
204export function initialUi(model: TreeModel): Ui {
205 for (const epic of model.epics)
206 for (const top of epic.nodes) {
207 if (top.state !== "run") continue;
208 const open: string[] = [];
209 for (let n: TreeNode | undefined = top; n?.kids.length;) {
210 open.push(n.id);
211 n = n.kids.find((k) => k.state === "run" && k.kids.length);
212 }
213 return { view: "tree", sel: top.id, open };
214 }
215 const tops = model.epics.flatMap((e) => e.nodes);
216 const first = tops.find((n) => n.state !== "done") ?? tops[0];
217 return first
218 ? { view: "tree", sel: first.id, open: [] }
219 : { view: "tree", open: [] };
220}
221
222/** ⏎ on a task: fold or unfold it; one top-level branch open at a time. */
223export function toggle(model: TreeModel, ui: Ui, id: string): Ui {
224 const hit = findNode(model, id);
225 if (!hit || !hit.node.kids.length) return ui;
226 if (ui.open.includes(id))
227 return { ...ui, open: ui.open.filter((o) => o !== id) };
228 const tops = new Set(model.epics.flatMap((e) => e.nodes.map((n) => n.id)));
229 const open = tops.has(id) ? ui.open.filter((o) => !tops.has(o)) : ui.open;
230 return { ...ui, open: [...open, id] };
231}
232
233/** ↑ or ↓: the previous or next task shown, at the ends it stays. */
234export function step(screen: Screen, ui: Ui, by: number): Ui {
235 const i = ui.sel === undefined ? -1 : screen.order.indexOf(ui.sel);
236 const next =
237 screen.order[Math.max(0, Math.min(screen.order.length - 1, i + by))];
238 return next === undefined ? ui : { ...ui, sel: next };
239}
240
241function treeCell(
242 v: Visible,
243 width: number,
244 selected: boolean,
245 open: boolean,
246): Cell {
247 const { node, depth } = v;
248 const inner: Cell[] = [];
249 // The ancestors' lines, then this task's own corner
250 for (const last of v.lasts.slice(0, -1))
251 inner.push(cell(W.level, [dim(last ? "" : "│")]));
252 const corner = v.lasts[v.lasts.length - 1] ? "└" : "├";
253 const mark = node.kids.length ? (open ? "▾" : "▸") : "─";
254 inner.push(
255 cell(W.level, [
256 dim(corner),
257 node.kids.length ? span(mark, { color: MAUVE }) : dim(mark),
258 ]),
259 );
260 const g = GLYPH[node.state];
261 inner.push(
262 cell(W.glyph, [
263 span(g.glyph, {
264 ...(g.color && { color: g.color }),
265 ...(g.dim && { dim: true }),
266 }),
267 ]),
268 );
269 const used = W.level * (depth + 1) + W.glyph;
270 const strong = node.state === "run";
271 const faint = node.state === "done" || node.state === "blocked";
272 if (depth === 0) {
273 inner.push(
274 cell(W.ref, [
275 span(node.label, strong ? { bold: true } : faint ? { dim: true } : {}),
276 ]),
277 );
278 }
279 const left = width - used - (depth === 0 ? W.ref : 0);
280 inner.push(
281 cell(Math.max(0, Math.min(W.title, left)), [
282 span(node.title, {
283 ...(strong && { bold: true }),
284 ...(faint && { dim: true }),
285 ...(selected && { underline: true }),
286 }),
287 ]),
288 );
289 return row(width, inner);
290}
291
292type Cols = ReturnType<typeof columnsFor>;
293
294/**
295 * Who works a task, in `w` cells: « ◉ principal » in the theme's colour,
296 * « ↳ name · role » in the subagent's own, the person « toi », « prête »
297 * when nobody took it yet. A finished task's is dimmed.
298 */
299export function agentSpans(n: TreeNode, w: number): Span[] {
300 if (!n.agent) return [];
301 const faint = n.state === "done";
302 const tone = (color: string | undefined): Omit<Span, "text"> => ({
303 ...(color && { color }),
304 ...(faint && { dim: true }),
305 });
306 switch (n.agentKind) {
307 case "you":
308 return [span(n.agent, tone(YOU))];
309 case "ready":
310 return [span(n.agent, tone(TEAL))];
311 case "main":
312 return [
313 span("◉ ", tone(n.agentColor)),
314 span(n.agent, tone(n.agentColor)),
315 ];
316 case "sub": {
317 const p = agentParts(n.agent, roleCell(n.role), Math.max(0, w - 2));
318 return [
319 span("↳ ", tone(n.agentColor)),
320 span(p.name, tone(n.agentColor)),
321 ...(p.role ? [dim(" · "), dim(p.role)] : []),
322 ];
323 }
324 default:
325 return [span(n.agent, faint ? { dim: true } : {})];
326 }
327}
328
329function taskLine(
330 v: Visible,
331 cols: Cols,
332 selected: boolean,
333 open: boolean,
334): Cell[] {
335 const n = v.node;
336 const out: Cell[] = [
337 cell(W.margin, n.crit ? [span("┃", { color: MAUVE })] : []),
338 treeCell(v, cols.tree, selected, open),
339 ];
340 if (cols.agent) out.push(gap(), cell(W.agent, agentSpans(n, W.agent)));
341 if (cols.model)
342 out.push(
343 gap(),
344 cell(
345 W.model,
346 n.model
347 ? [
348 span(
349 n.model,
350 n.state === "done" ? { dim: true } : { color: BLUE },
351 ),
352 ]
353 : [],
354 ),
355 );
356 const ageTone = n.silent
357 ? { color: RED, bold: true }
358 : n.state === "run"
359 ? { color: AMBER }
360 : n.state === "you" || n.state === "wait"
361 ? { color: YOU }
362 : { dim: true };
363 out.push(gap(), cell(W.age, n.age ? [span(n.age, ageTone)] : [], true));
364 if (cols.links)
365 out.push(
366 gap(),
367 cell(
368 W.links,
369 n.links
370 ? [
371 span(
372 n.links,
373 n.state === "blocked" ? { color: RED } : { dim: true },
374 ),
375 ]
376 : [],
377 ),
378 );
379 return out;
380}
381
382function headerLine(cols: Cols): Cell[] {
383 const hd = (t: string) => [dim(t)];
384 const out: Cell[] = [cell(W.margin), cell(cols.tree, hd("TÂCHE"))];
385 if (cols.agent) out.push(gap(), cell(W.agent, hd("AGENT")));
386 if (cols.model) out.push(gap(), cell(W.model, hd("MODÈLE")));
387 out.push(gap(), cell(W.age, hd("ÂGE"), true));
388 if (cols.links) out.push(gap(), cell(W.links, hd("LIENS")));
389 return out;
390}
391
392/** « 3/5 », done over total, in green once every task is done. */
393const progress = (epic: TreeEpic): Span =>
394 epic.total && epic.done === epic.total
395 ? span(`${epic.done}/${epic.total}`, { color: GREEN })
396 : dim(`${epic.done}/${epic.total}`);
397
398/**
399 * An epic's line: ◆, its number and title, its progress right after the
400 * title, its age. The agent and model columns stay empty: they speak of the
401 * tasks. The tasks filed under no epic sit under a quiet « Hors épopée »,
402 * with no number, no progress and no age.
403 */
404function epicLine(epic: TreeEpic, cols: Cols): Cell[] {
405 // The title runs over the agent and model columns, empty on this line
406 const span2 =
407 cols.tree +
408 (cols.agent ? W.gap + W.agent : 0) +
409 (cols.model ? W.gap + W.model : 0);
410 const titleW = Math.max(0, span2 - W.glyph - W.ref);
411 const loose = epic.id === 0;
412 const count = loose ? undefined : progress(epic);
413 const countW = count ? cells(count.text) + 2 : 0;
414 const nameW = Math.max(
415 0,
416 Math.min(cells(epic.title), titleW - (titleW > countW + 4 ? countW : 0)),
417 );
418 const out: Cell[] = [
419 cell(W.margin),
420 row(span2, [
421 cell(W.glyph, [loose ? dim("◌") : span("◆", { color: MAUVE })]),
422 cell(
423 W.ref,
424 loose ? [] : [span(`#${epic.id}`, { color: MAUVE, bold: true })],
425 ),
426 cell(nameW, [
427 loose
428 ? dim(epic.title)
429 : span(epic.title, { color: MAUVE, bold: true }),
430 ]),
431 cell(
432 Math.max(0, titleW - nameW),
433 count && titleW - nameW >= countW ? [dim(" "), count] : [],
434 ),
435 ]),
436 ];
437 out.push(gap(), cell(W.age, loose ? [] : [dim(epic.age)], true));
438 if (cols.links) out.push(gap(), cell(W.links));
439 return out;
440}
441
442function foldLine(count: number, cols: Cols): Cell[] {
443 return [
444 cell(W.margin),
445 cell(cols.tree, [
446 dim("└─ "),
447 span("✓", { color: GREEN }),
448 dim(` fait (${count})`),
449 ]),
450 ];
451}
452
453const plural = (n: number, one: string, many: string) =>
454 `${n} ${n > 1 ? many : one}`;
455
456/**
457 * What the section is about, never the tab's own name again: the one epic
458 * open (« #1 Recette kodflow »), else how many (« 2 épopées ouvertes »),
459 * then the tasks under no epic (« 1 tâche hors épopée »).
460 */
461export function sectionTitle(
462 model: TreeModel,
463 epic: TreeEpic | undefined,
464): string {
465 const real = model.epics.filter((e) => e.id !== 0);
466 const loose = model.epics.find((e) => e.id === 0);
467 const left = loose ? loose.total - loose.done : 0;
468 const parts: string[] = [];
469 if (real.length === 1 && !left) {
470 const one = epic && epic.id !== 0 ? epic : real[0]!;
471 parts.push(`#${one.id} ${one.title}`);
472 } else if (real.length)
473 parts.push(plural(real.length, "épopée ouverte", "épopées ouvertes"));
474 if (left) parts.push(plural(left, "tâche hors épopée", "tâches hors épopée"));
475 return parts.join(" · ");
476}
477
478function titleLine(
479 model: TreeModel,
480 epic: TreeEpic | undefined,
481 width: number,
482): Cell[] {
483 const what = sectionTitle(model, epic);
484 const head: Cell[] = [cell(2, [dim("─ ")])];
485 const used = 2;
486 const wWhat = Math.min(cells(what), Math.max(0, width - used - 2));
487 head.push(cell(wWhat, [span(what, { color: MAUVE })]));
488 const rest = Math.max(0, width - used - wWhat);
489 if (rest > 1) head.push(cell(1), cell(rest - 1, [dim("─".repeat(rest - 1))]));
490 return head;
491}
492
493const stateTone = (s: NodeState): Omit<Span, "text"> => {
494 const g = GLYPH[s];
495 return g.color ? { color: g.color } : { dim: true };
496};
497
498function chainSpans(epic: TreeEpic | undefined): Span[] {
499 if (!epic?.chains.length) return [dim("aucun lien")];
500 const out: Span[] = [];
501 epic.chains.forEach((chain, i) => {
502 if (i) out.push(dim(" · "));
503 chain.forEach((t, j) => {
504 if (j) out.push(dim(" → "));
505 out.push(span(t.label, stateTone(t.state)));
506 });
507 });
508 return out;
509}
510
511/** "┃ chemin critique C6 → C9 · C7 → C9", and who it waits on, right. */
512function critLine(epic: TreeEpic | undefined, width: number): Cell[] {
513 const head = [
514 span("┃ ", { color: MAUVE }),
515 span("chemin critique ", { color: MAUVE }),
516 ];
517 const you = epic?.you;
518 const tail: Span[] = you
519 ? you.crit
520 ? [dim("bloqué par "), span(`toi (${you.label})`, { color: YOU })]
521 : [
522 dim("t'attend "),
523 span(`◇ ${you.label}`, { color: YOU }),
524 dim(you.age ? ` · ${you.age}` : ""),
525 ]
526 : [];
527 const headW = Math.min(19, width);
528 // The chains keep 8 cells before who waits is given any, and the gap of 2
529 const fits = width - headW - 8 - W.gap;
530 const tailW =
531 fits > 0 ? Math.min(cells(tail.map((s) => s.text).join("")), fits) : 0;
532 const midW = Math.max(0, width - headW - tailW - (tailW ? 2 : 0));
533 const out: Cell[] = [cell(headW, head), cell(midW, chainSpans(epic))];
534 if (tailW) out.push(cell(2), cell(tailW, tail));
535 return out;
536}
537
538function detailLines(
539 model: TreeModel,
540 ui: Ui,
541 width: number,
542 count: number,
543): Cell[][] {
544 if (count <= 0) return [];
545 const hit = findNode(model, ui.sel);
546 if (!hit)
547 return [[cell(width, [dim("Aucune tâche choisie")])], [cell(width)]].slice(
548 0,
549 count,
550 );
551 const n = hit.node;
552 const g = GLYPH[n.state];
553 const name = hit.parent ? n.title : `${n.label} ${n.title}`;
554 const meta = [
555 n.role ? `${n.agent} · ${n.role}` : n.agent,
556 n.model,
557 n.age,
558 n.links,
559 n.ci,
560 n.silent ? "figé" : "",
561 ]
562 .filter(Boolean)
563 .join(" · ");
564 const first: Span[] = [
565 span(`${g.glyph} `, g.color ? { color: g.color } : { dim: true }),
566 span(name, { bold: true }),
567 ];
568 if (meta) first.push(dim(` ${meta}`));
569 if (hit.parent) first.push(dim(` sous-tâche de ${hit.top.label}`));
570 const kids = n.kids.length
571 ? `${n.kids.length} sous-tâche${n.kids.length > 1 ? "s" : ""}${
572 ui.open.includes(n.id)
573 ? ""
574 : `, repliée${n.kids.length > 1 ? "s" : ""} (⏎ ou clic)`
575 }`
576 : "";
577 const second = n.desc || kids || "Aucune description";
578 const lines = [
579 [cell(width, first)],
580 [cell(W.gap), cell(Math.max(0, width - W.gap), [dim(second)])],
581 ];
582 return lines.slice(0, count);
583}
584
585function tabsLines(
586 view: View,
587 width: number,
588): { lines: Cell[][]; tabs: Screen["tabs"] } {
589 const on = (v: View): Omit<Span, "text"> =>
590 v === view ? { color: MAUVE, bold: true, underline: true } : { dim: true };
591 const hint = width >= 64 ? HINT : HINT_SHORT;
592 const hintW = Math.min(cells(hint), Math.max(0, width - 15));
593 const line: Cell[] = [
594 cell(5, [span("Arbre", on("tree"))]),
595 cell(3),
596 cell(6, [span("Graphe", on("graph"))]),
597 ];
598 const rest = Math.max(0, width - 14 - hintW);
599 if (rest) line.push(cell(rest));
600 if (hintW) line.push(cell(hintW, [dim(hint)]));
601 return {
602 lines: [rule(width, "─"), line],
603 tabs: [
604 { x: 0, w: 5, view: "tree" },
605 { x: 8, w: 6, view: "graph" },
606 ],
607 };
608}
609
610// --- R12 ---------------------------------------------------------------------------------
611
612type Body = { line: Cell[]; id?: string; selectable?: string };
613
614/** The tree view, `width` cells by `rows` lines. */
615export function treeScreen(
616 model: TreeModel,
617 ui: Ui,
618 width: number,
619 rows: number,
620): Screen {
621 const cols = columnsFor(width);
622 const sel = findNode(model, ui.sel);
623 const epic = sel?.epic ?? model.epics[0];
624
625 const bodyOf = (foldDone: boolean): Body[] => {
626 const out: Body[] = [];
627 for (const e of model.epics) {
628 out.push({ line: epicLine(e, cols) });
629 const rows = visible(e.nodes, ui.open);
630 const finished = foldDone
631 ? e.nodes.filter((n) => n.state === "done" && n.id !== sel?.top.id)
632 : [];
633 const folded = new Set(finished.map((n) => n.id));
634 let foldShown = false;
635 for (const v of rows) {
636 if (folded.has(v.top.id)) {
637 if (!foldShown) out.push({ line: foldLine(finished.length, cols) });
638 foldShown = true;
639 continue;
640 }
641 const id = v.node.id;
642 out.push({
643 line: taskLine(v, cols, id === ui.sel, ui.open.includes(id)),
644 id,
645 selectable: id,
646 });
647 }
648 }
649 return out;
650 };
651
652 // What the rows hold, most kept first: the folds, then the detail, then
653 // the bar, the path and the headers give way, and the list scrolls
654 let detail = 2;
655 let bar = 2;
656 let foot = 2; // the rule and the critical path
657 let header = 1;
658 let title = 1;
659 const room = () => rows - title - header - foot - detail - bar;
660 let body = bodyOf(false);
661 if (body.length > room()) body = bodyOf(true);
662 while (body.length > room() && detail > 0) detail--;
663 while (room() < 1 + model.epics.length && bar > 0) bar = 0;
664 while (room() < 1 + model.epics.length && foot > 0) foot = 0;
665 if (room() < 2 && header) header = 0;
666 if (room() < 2 && title) title = 0;
667
668 const space = Math.max(0, room());
669 let shown = body;
670 if (body.length > space) {
671 const at = Math.max(
672 0,
673 body.findIndex((b) => b.id === ui.sel),
674 );
675 const start = Math.max(
676 0,
677 Math.min(body.length - space, at - Math.floor(space / 2)),
678 );
679 shown = body.slice(start, start + space);
680 }
681
682 const lines: Cell[][] = [];
683 const rowIds: (string | undefined)[] = [];
684 const push = (line: Cell[], id?: string) => {
685 lines.push(line);
686 rowIds.push(id);
687 };
688 if (title) push(titleLine(model, epic, width));
689 if (header) push(headerLine(cols));
690 for (const b of shown) push(b.line, b.id);
691 while (lines.length < title + header + space) push([cell(width)]);
692 if (foot) {
693 push(rule(width, "┄"));
694 push(critLine(epic, width));
695 }
696 for (const d of detailLines(model, ui, width, detail)) push(d);
697 let tabsLine = -1;
698 let tabs: Screen["tabs"] = [];
699 if (bar) {
700 const t = tabsLines("tree", width);
701 push(t.lines[0]!);
702 tabsLine = lines.length;
703 push(t.lines[1]!);
704 tabs = t.tabs;
705 }
706 return {
707 lines,
708 rowIds,
709 tabsLine,
710 tabs,
711 order: body.filter((b) => b.selectable).map((b) => b.selectable!),
712 };
713}
714
715// --- R4 -----------------------------------------------------------------------------------
716
717const COLUMN: Record<NodeState, 0 | 1 | 2> = {
718 done: 0,
719 ready: 0,
720 run: 1,
721 you: 1,
722 wait: 1,
723 blocked: 2,
724 todo: 2,
725};
726
727export type Lanes = {
728 /** Lane of each top-level task, by id, and its column. */
729 at: Map<string, { col: 0 | 1 | 2; lane: number }>;
730 count: number;
731 /** The edges drawn: left column to a column on its right. */
732 edges: [string, string][];
733};
734
735/**
736 * Lanes for the graph's top-level tasks. The middle column takes the first
737 * lanes; on the left, the tasks feeding only the middle come first, those
738 * feeding the right one after every lane the middle uses (so their line
739 * crosses the middle on a free lane), the ones that also feed the middle
740 * before those that do not; on the right, each task at its sources'
741 * middle lane, or the next one free.
742 */
743export function lanesOf(epic: TreeEpic): Lanes {
744 const col = new Map(epic.nodes.map((n) => [n.id, COLUMN[n.state]]));
745 const edges = epic.edges.filter(
746 ([a, b]) => (col.get(a) ?? 0) < (col.get(b) ?? 0),
747 );
748 const at = new Map<string, { col: 0 | 1 | 2; lane: number }>();
749 const inCol = (c: number) => epic.nodes.filter((n) => col.get(n.id) === c);
750 const mid = inCol(1);
751 mid.forEach((n, lane) => at.set(n.id, { col: 1, lane }));
752 const to = (id: string, c: number) =>
753 edges.some(([a, b]) => a === id && col.get(b) === c);
754 const left = inCol(0);
755 const feedsMid = left.filter((n) => !to(n.id, 2));
756 const firstTarget = (id: string) =>
757 Math.min(
758 ...edges
759 .filter(([a]) => a === id)
760 .map(([, b]) => at.get(b)?.lane ?? Infinity),
761 Infinity,
762 );
763 feedsMid.sort((a, b) => firstTarget(a.id) - firstTarget(b.id));
764 feedsMid.forEach((n, lane) => at.set(n.id, { col: 0, lane }));
765 const skips = left.filter((n) => to(n.id, 2));
766 skips.sort((a, b) => Number(to(b.id, 1)) - Number(to(a.id, 1)));
767 const base = Math.max(feedsMid.length, mid.length);
768 skips.forEach((n, i) => at.set(n.id, { col: 0, lane: base + i }));
769 const taken = new Set<number>();
770 for (const n of inCol(2)) {
771 const sources = edges
772 .filter(([, b]) => b === n.id)
773 .map(([a]) => at.get(a)?.lane ?? 0);
774 // At its sources' middle lane, so its line splits them evenly
775 sources.sort((x, y) => x - y);
776 let lane = sources.length ? sources[Math.floor(sources.length / 2)]! : 0;
777 while (taken.has(lane)) lane++;
778 taken.add(lane);
779 at.set(n.id, { col: 2, lane });
780 }
781 let count = 0;
782 for (const v of at.values()) count = Math.max(count, v.lane + 1);
783 return { at, count, edges };
784}
785
786/** The junction for a row of a gutter's vertical. */
787function junction(
788 left: boolean,
789 right: boolean,
790 up: boolean,
791 down: boolean,
792): string {
793 const key = `${+left}${+right}${+up}${+down}`;
794 const table: Record<string, string> = {
795 "1111": "┼",
796 "1101": "┬",
797 "1110": "┴",
798 "1001": "┐",
799 "1010": "┘",
800 "0101": "┌",
801 "0110": "└",
802 "1011": "┤",
803 "0111": "├",
804 "1100": "─",
805 "0011": "│",
806 "1000": "─",
807 "0100": "─",
808 };
809 return table[key] ?? "";
810}
811
812type Gutter = { cells: Cell[][] };
813
814/**
815 * The vertical spans of a gutter: the lanes its links join, grouped so that
816 * links sharing no lane get a span each; spans that would overlap are one
817 * (they share the vertical, which is a merge, never a crossing).
818 */
819function spans(links: readonly [number, number][]): [number, number][] {
820 let out: [number, number][] = links.map(([a, b]) => [
821 Math.min(a, b),
822 Math.max(a, b),
823 ]);
824 out.sort((x, y) => x[0] - y[0]);
825 const merged: [number, number][] = [];
826 for (const s of out) {
827 const last = merged[merged.length - 1];
828 if (last && s[0] <= last[1]) last[1] = Math.max(last[1], s[1]);
829 else merged.push([s[0], s[1]]);
830 }
831 return merged;
832}
833
834/**
835 * One gutter's cells, two per lane: entries from the left (a task feeding the
836 * next column, or a line crossing the middle), exits to the right (a task
837 * fed), each group of links on its own stretch of the vertical.
838 */
839function gutter(
840 lanes: number,
841 links: readonly [number, number][],
842 passes: Set<number>,
843): Gutter {
844 const enters = new Set(links.map(([a]) => a));
845 const exits = new Set(links.map(([, b]) => b));
846 const stretches = spans(links);
847 const within = (lane: number) =>
848 stretches.find(([t, b]) => lane >= t && lane <= b);
849 const out: Cell[][] = [];
850 for (let lane = 0; lane < lanes; lane++) {
851 const l = enters.has(lane);
852 const r = exits.has(lane);
853 const p = passes.has(lane);
854 const span = within(lane);
855 const up = !!span && lane > span[0];
856 const down = !!span && lane < span[1];
857 // A line crossing on to the next column leaves the junction to the right
858 const j =
859 l || r ? junction(l, r || p, up, down) : p ? "─" : up && down ? "│" : "";
860 const tail = r ? "─▶" : p ? "───" : "";
861 out.push([
862 cell(1, l || p ? [dim("─")] : []),
863 cell(1, j ? [dim(j)] : []),
864 cell(3, tail ? [dim(tail)] : []),
865 ]);
866 out.push([cell(1), cell(1, down ? [dim("│")] : []), cell(3)]);
867 }
868 return { cells: out };
869}
870
871function graphNode(n: TreeNode, w: number, fills: boolean): Cell[][] {
872 const g = GLYPH[n.state];
873 const strong = n.state === "run";
874 const idW = 6;
875 const titleW = Math.min(
876 cells(fit(n.title, Math.max(0, w - W.glyph - idW))),
877 Math.max(0, w - W.glyph - idW),
878 );
879 const fillW = Math.max(0, w - W.glyph - idW - titleW);
880 const first: Cell[] = [
881 cell(W.glyph, [
882 span(g.glyph, g.color ? { color: g.color } : { dim: true }),
883 ]),
884 cell(idW, [span(n.label, strong ? { bold: true } : {})]),
885 cell(titleW, [span(n.title, strong ? { bold: true } : {})]),
886 ];
887 if (fillW)
888 first.push(
889 cell(fillW, fills && fillW > 1 ? [dim(` ${"─".repeat(fillW - 1)}`)] : []),
890 );
891 const meta: Span[] =
892 n.state === "ready"
893 ? [span("prête", { color: TEAL }), dim(" · personne dessus")]
894 : n.state === "you"
895 ? [
896 span("attend toi", { color: YOU }),
897 ...(n.age ? [dim(" · "), span(n.age, { color: YOU })] : []),
898 ]
899 : n.state === "blocked"
900 ? [
901 span(
902 n.links.startsWith("⇠")
903 ? `attend ${n.links.slice(2)}`
904 : "bloquée",
905 { color: RED },
906 ),
907 ]
908 : [
909 ...(n.agent
910 ? [
911 span(n.agent, {
912 ...(n.agentColor && { color: n.agentColor }),
913 ...(n.state === "done" && { dim: true }),
914 }),
915 ]
916 : []),
917 // The model only where the age still fits after it
918 ...(n.model &&
919 n.state === "done" &&
920 cells(`${n.agent} · ${n.model} · ${n.age}`) <= w - W.gap
921 ? [dim(` · ${n.model}`)]
922 : []),
923 ...(n.age
924 ? [
925 dim(n.agent ? " · " : ""),
926 span(
927 n.age,
928 n.state === "run" ? { color: AMBER } : { dim: true },
929 ),
930 ]
931 : []),
932 ];
933 return [
934 [row(w, first)],
935 [row(w, [cell(W.gap), cell(Math.max(0, w - W.gap), meta)])],
936 ];
937}
938
939/** The graph view, `width` cells by `rows` lines. */
940export function graphScreen(
941 model: TreeModel,
942 ui: Ui,
943 width: number,
944 rows: number,
945): Screen {
946 const sel = findNode(model, ui.sel);
947 const epic = sel?.epic ?? model.epics[0];
948 const colW = width >= FULL.links ? 26 : Math.floor((width - 10) / 3);
949 // Under 40 cells a node no longer holds its glyph, its ref and a title
950 const fits = colW >= 10;
951 const lines: Cell[][] = [];
952 const push = (l: Cell[]) => lines.push(l);
953 push(titleLine(model, epic, width));
954 push(
955 fits
956 ? [
957 cell(colW, [dim("FAIT · PRÊT")]),
958 cell(5),
959 cell(colW, [dim("EN COURS · ATTENTE")]),
960 cell(5),
961 cell(colW, [dim("BLOQUÉ")]),
962 ]
963 : [cell(width, [dim("le graphe veut 40 colonnes")])],
964 );
965 const footer = 6; // rule, three lines, the bar's two
966 const room = Math.max(0, rows - 2 - footer);
967 if (epic && fits) {
968 const { at, count, edges } = lanesOf(epic);
969 const byLane: (TreeNode | undefined)[][] = [[], [], []];
970 for (const n of epic.nodes) {
971 const p = at.get(n.id)!;
972 byLane[p.col]![p.lane] = n;
973 }
974 const colOf = (id: string) => at.get(id)?.col;
975 const laneOf = (id: string) => at.get(id)!.lane;
976 const lanePair = ([a, b]: [string, string]): [number, number] => [
977 laneOf(a),
978 laneOf(b),
979 ];
980 const links0 = edges
981 .filter(([a, b]) => colOf(a) === 0 && colOf(b) === 1)
982 .map(lanePair);
983 // A link from the left column to the right one crosses the middle on its
984 // source's lane, and joins the second gutter there
985 const links1 = edges.filter(([, b]) => colOf(b) === 2).map(lanePair);
986 const skipping = new Set(
987 edges
988 .filter(([a, b]) => colOf(a) === 0 && colOf(b) === 2)
989 .map(([a]) => laneOf(a)),
990 );
991 const g0 = gutter(count, links0, skipping);
992 const g1 = gutter(count, links1, new Set());
993 const fills = (id: string) => edges.some(([a]) => a === id);
994 const lanesShown = Math.min(count, Math.floor(room / 2));
995 for (let lane = 0; lane < lanesShown; lane++) {
996 const parts = [0, 1, 2].map((c) => {
997 const n = byLane[c]![lane];
998 if (n) return graphNode(n, colW, fills(n.id));
999 // A line crossing the middle column on a free lane
1000 if (c === 1 && skipping.has(lane))
1001 return [[cell(colW, [dim("─".repeat(colW))])], [cell(colW)]];
1002 return [[cell(colW)], [cell(colW)]];
1003 });
1004 for (const r of [0, 1])
1005 push([
1006 ...parts[0]![r]!,
1007 row(5, g0.cells[lane * 2 + r]!),
1008 ...parts[1]![r]!,
1009 row(5, g1.cells[lane * 2 + r]!),
1010 ...parts[2]![r]!,
1011 ]);
1012 }
1013 if (count > lanesShown && lines.length > 2) {
1014 lines.pop();
1015 push([
1016 cell(width, [dim(`… ${count - lanesShown + 1} couloir(s) de plus`)]),
1017 ]);
1018 }
1019 }
1020 while (lines.length < 2 + room) push([cell(width)]);
1021 push(rule(width, "┄"));
1022 // Footer: next action and who waits on you, the path, the deliverable
1023 const next: Span[] = [dim("prochaine action ")];
1024 if (epic?.next)
1025 next.push(
1026 span(`▶ ${epic.next.label}`, { color: TEAL }),
1027 dim(" prête, personne dessus"),
1028 );
1029 else next.push(dim("rien de prêt"));
1030 const you: Span[] = epic?.you
1031 ? [
1032 dim("t'attend "),
1033 span(`◇ ${epic.you.label}`, { color: YOU }),
1034 dim(epic.you.age ? ` · ${epic.you.age}` : ""),
1035 ]
1036 : [];
1037 const youW = Math.min(
1038 cells(you.map((s) => s.text).join("")),
1039 Math.max(0, width - 30),
1040 );
1041 const first: Cell[] = [cell(width - youW - (youW ? 2 : 0), next)];
1042 if (youW) first.push(cell(2), cell(youW, you));
1043 push(first);
1044 push([
1045 cell(19, [span("┃ ", { color: MAUVE }), dim("chemin critique ")]),
1046 cell(Math.max(0, width - 19), chainSpans(epic)),
1047 ]);
1048 const goal: Span[] = [dim("livrable ")];
1049 if (epic?.goal) {
1050 const g = GLYPH[epic.goal.state];
1051 goal.push(
1052 span(`${g.glyph} `, g.color ? { color: g.color } : { dim: true }),
1053 span(`${epic.goal.label} ${epic.goal.title}`),
1054 dim(` · ${epic.goal.note}`),
1055 );
1056 } else goal.push(dim("aucun"));
1057 push([cell(width, goal)]);
1058 const t = tabsLines("graph", width);
1059 push(t.lines[0]!);
1060 const tabsLine = lines.length;
1061 push(t.lines[1]!);
1062 return {
1063 lines: lines.slice(0, Math.max(rows, 0)),
1064 rowIds: lines.map(() => undefined),
1065 tabsLine: tabsLine < rows ? tabsLine : -1,
1066 tabs: t.tabs,
1067 order: [],
1068 };
1069}
1070
1071/** The view the person chose, or the message when there is no tree. */
1072export function screenOf(
1073 model: TreeModel,
1074 ui: Ui,
1075 width: number,
1076 rows: number,
1077): Screen | string {
1078 if (!model.found)
1079 return "Aucune liste de tâches pour cette session (kodflow-hooks la tient).";
1080 if (!model.epics.length) return "Aucune épopée ouverte.";
1081 return ui.view === "graph"
1082 ? graphScreen(model, ui, width, rows)
1083 : treeScreen(model, ui, width, rows);
1084}
1085
1086// --- drawing --------------------------------------------------------------------------------
1087
1088function drawSpans({ Text }: Els, spans: Span[]) {
1089 return spans.map((s) => (
1090 <Text
1091 {...(s.color && { color: s.color })}
1092 {...(s.dim && { dimColor: true })}
1093 {...(s.bold && { bold: true })}
1094 {...(s.underline && { underline: true })}
1095 >
1096 {s.text}
1097 </Text>
1098 ));
1099}
1100
1101function drawCell(els: Els, c: Cell, key: string): any {
1102 const { Box, Text } = els;
1103 if (c.row)
1104 return (
1105 <Box key={key} width={c.w} flexShrink={0} flexDirection="row">
1106 {c.row.map((inner, i) => drawCell(els, inner, `${key}.${i}`))}
1107 </Box>
1108 );
1109 return (
1110 <Box
1111 key={key}
1112 width={c.w}
1113 flexShrink={0}
1114 {...(c.right && { justifyContent: "flex-end" })}
1115 >
1116 {c.spans?.length ? (
1117 <Text wrap="truncate-end">{drawSpans(els, c.spans)}</Text>
1118 ) : null}
1119 </Box>
1120 );
1121}
1122
1123/** A screen as Boxes: one row a line, one Box of its fixed width a cell. */
1124export function draw(els: Els, screen: Screen | string, width: number) {
1125 const { Box, Text } = els;
1126 if (typeof screen === "string")
1127 return (
1128 <Box key="tasks" flexDirection="column" width={width}>
1129 <Text dimColor wrap="truncate-end">
1130 {screen}
1131 </Text>
1132 </Box>
1133 );
1134 return (
1135 <Box key="tasks" flexDirection="column" width={width}>
1136 {screen.lines.map((line, i) => (
1137 <Box key={`l${i}`} flexDirection="row" height={1} width={width}>
1138 {line.map((c, j) => drawCell(els, c, `l${i}.${j}`))}
1139 </Box>
1140 ))}
1141 </Box>
1142 );
1143}
1144hooks/mod/colors.ts 141 lines1// colors.ts — a few words of a reply in colour, never a whole paragraph.
2//
3// The model writes `{{r:words}}`; the transcript draws "words" in red, bold,
4// and the markup is gone. A paragraph with no markup, and every fenced code
5// block, is left to the engine's own markdown. The convention reaches the
6// model as one system prompt section (PROMPT_SECTION), on the session's side
7// of the cache boundary, so it holds after a compaction.
8//
9// Pure: no `$`, nothing read from the host. register.tsx wires it.
10
11/** The four marks and their colours. */
12export const COLORS: Record<string, string> = {
13 r: "red", // blocking
14 o: "#ff8c00", // needs attention
15 v: "green", // waits on the person: their attention or their answer
16 p: "#ff69b4", // a decision the person already made
17};
18
19export type Run = { text: string; color?: string; bold?: boolean };
20export type Block = { markdown: string } | { runs: Run[] };
21
22const MARK = /\{\{([rovp]):([^{}]+?)\}\}/g;
23const HAS_MARK = /\{\{[rovp]:[^{}]+?\}\}/;
24// Inline code, `like this`: a mark inside it is shown as typed
25// (CommonMark: a run of backticks closed by a run of the same length, a
26// shorter run inside it is code too)
27const INLINE_CODE = /(?<!`)(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)/g;
28// A fence opens on three backticks or tildes or more and closes on a line
29// of the same character, at least as long, with nothing after it
30const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
31
32/** Splits on blank lines; a fenced code block stays one block. */
33export function paragraphs(text: string): { text: string; code: boolean }[] {
34 const out: { text: string; code: boolean }[] = [];
35 let buf: string[] = [];
36 let fence = "";
37 const flush = (code: boolean) => {
38 if (buf.length) out.push({ text: buf.join("\n"), code });
39 buf = [];
40 };
41 const closes = (line: string): boolean => {
42 const m = /^ {0,3}(`+|~+)\s*$/.exec(line);
43 return !!m && m[1]![0] === fence[0] && m[1]!.length >= fence.length;
44 };
45 for (const line of text.split("\n")) {
46 if (fence) {
47 buf.push(line);
48 if (closes(line)) {
49 flush(true);
50 fence = "";
51 }
52 continue;
53 }
54 const open = FENCE_OPEN.exec(line);
55 if (open) {
56 flush(false);
57 buf.push(line);
58 fence = open[1]!;
59 continue;
60 }
61 if (line.trim() === "") {
62 flush(false);
63 continue;
64 }
65 buf.push(line);
66 }
67 // An unclosed fence is still code: never interpreted
68 flush(!!fence);
69 return out;
70}
71
72/** One paragraph as runs: coloured marks, **bold**, plain text. */
73export function runs(paragraph: string): Run[] {
74 const out: Run[] = [];
75 const plain = (s: string) => {
76 for (const [i, part] of s.split("**").entries())
77 if (part) out.push(i % 2 ? { text: part, bold: true } : { text: part });
78 };
79 const marks = (s: string) => {
80 let last = 0;
81 for (const m of s.matchAll(MARK)) {
82 plain(s.slice(last, m.index));
83 out.push({ text: m[2]!, color: COLORS[m[1]!], bold: true });
84 last = m.index! + m[0].length;
85 }
86 plain(s.slice(last));
87 };
88 let last = 0;
89 for (const c of paragraph.matchAll(INLINE_CODE)) {
90 marks(paragraph.slice(last, c.index));
91 out.push({ text: c[0] });
92 last = c.index! + c[0].length;
93 }
94 marks(paragraph.slice(last));
95 return out;
96}
97
98/** A reply as blocks: runs where a paragraph is marked, markdown elsewhere. */
99export function blocks(text: string): Block[] {
100 return paragraphs(text).map((p) =>
101 !p.code && HAS_MARK.test(p.text.replace(INLINE_CODE, ""))
102 ? { runs: runs(p.text) }
103 : { markdown: p.text },
104 );
105}
106
107/** True when the reply holds a mark the transcript would colour. */
108export const marked = (parts: Block[]): boolean =>
109 parts.some((p) => "runs" in p);
110
111/** KODFLOW_COLORS=off (or 0, false, no) turns off the colours and the section. */
112export const colorsOff = (value: string | undefined): boolean =>
113 /^(off|0|false|no)$/i.test((value ?? "").trim());
114
115export const SECTION_ID = "kodflow-hud:colors";
116
117/**
118 * The convention as the model reads it. Short on purpose: it rides on every
119 * request of the session.
120 */
121export const PROMPT_SECTION = [
122 "# Coloured words",
123 "",
124 "The terminal colours a few words of your replies when you wrap them in a mark; the mark itself is never shown:",
125 "- `{{r:words}}` red: what blocks (a failure, a refusal, a stop).",
126 "- `{{o:words}}` orange: what needs attention (a risk, a warning, a limit).",
127 "- `{{v:words}}` green: what waits on the user, their answer or their action.",
128 "- `{{p:words}}` pink: a decision the user already made; use it only when you cite that decision with its date.",
129 "",
130 "Colour two to five words at a time, never a sentence or a paragraph, and put at most a few marks in one message; most messages need none. Never put a mark in code, in a command, in a path or in a table meant to be copied, since it would be copied as typed. A paragraph holding a mark loses its other markdown except bold, so keep links, lists and inline code out of it.",
131].join("\n");
132
133/** Whether the prompt being composed is one the transcript will draw. */
134export function drawn(input: {
135 surfaces: readonly string[];
136 traits: readonly string[];
137}): boolean {
138 if (!input.surfaces.length) return false;
139 return !input.traits.some((t) => t === "print" || t === "bare");
140}
141hooks/mod/models.ts 110 lines1// models.ts — what each loop runs on, and who worked each task. Pure.
2//
3// The tree's model·effort column shows only what the session said, from
4// three places, the later filling what the earlier left out:
5// agent-models.tsv kodflow-hooks' record of every Agent call (its
6// PostToolUse: agent id, resolvedModel, the call's
7// effort, the type), read for the loops started before
8// this module loaded;
9// agent.spawn the model core resolved for the subagent it started;
10// tool.call Agent the call's `effort` and the result's resolvedModel.
11// The main loop's model is `$.session.model()`; its effort is said nowhere
12// a hooks module reads, so it stays absent. Nothing is guessed: a loop no
13// source names has an empty cell.
14
15import type { HudModel, HudSeen, HudTask } from "../../types";
16
17/**
18 * The models kept are keyed `<session>:<loop>`: "main" is every session's
19 * main loop, and only the current session's entries are kept.
20 */
21export const modelKey = (session: string, loop: string) => `${session}:${loop}`;
22
23/** A record keyed `<session>:…` with only that session's entries left. */
24export function keepSession<T>(
25 record: Readonly<Record<string, T>>,
26 session: string,
27): Record<string, T> {
28 const prefix = `${session}:`;
29 return Object.fromEntries(
30 Object.entries(record).filter(([k]) => k.startsWith(prefix)),
31 );
32}
33
34/** Fields of `b` that are set win over `a`'s. */
35export function merge(a: HudModel | undefined, b: HudModel): HudModel {
36 const out: HudModel = { ...a };
37 if (b.model) out.model = b.model;
38 if (b.effort) out.effort = b.effort;
39 return out;
40}
41
42const str = (v: unknown): string =>
43 typeof v === "string" ? v.trim() : typeof v === "number" ? String(v) : "";
44
45/** agent-models.tsv: `agentId \t resolvedModel \t effort \t type`, latest last. */
46export function parseModels(
47 text: string | undefined,
48): Record<string, HudModel> {
49 const out: Record<string, HudModel> = {};
50 for (const line of (text ?? "").split("\n")) {
51 const [id, model, effort] = line.split("\t");
52 if (!id) continue;
53 out[id] = merge(out[id], {
54 ...(model && { model }),
55 ...(effort && { effort }),
56 });
57 }
58 return out;
59}
60
61/** What an Agent call says of the loop it started: its id, model, effort. */
62export function fromAgentCall(
63 input: { effort?: unknown },
64 result: unknown,
65): { id: string; model: HudModel } | undefined {
66 const r = (result ?? {}) as { agentId?: unknown; resolvedModel?: unknown };
67 const id = str(r.agentId);
68 if (!id) return undefined;
69 const model: HudModel = {};
70 const m = str(r.resolvedModel);
71 const effort = str(input.effort);
72 if (m) model.model = m;
73 if (effort) model.effort = effort;
74 return { id, model };
75}
76
77/**
78 * The worker of every task in progress, added to what was seen before:
79 * tasks.json drops the link when a task leaves in_progress, the tree still
80 * names who did it. Keyed by session too, as task ids start over in each
81 * session's file; only the current session's are kept, so the record is
82 * bounded by one list. Undefined when nothing changed.
83 */
84export const seenKey = (session: string, t: Pick<HudTask, "agent" | "id">) =>
85 `${session}:${t.agent}:${t.id}`;
86
87export function seenFrom(
88 tasks: readonly HudTask[],
89 before: Readonly<Record<string, HudSeen>>,
90 session: string,
91): Record<string, HudSeen> | undefined {
92 const prefix = `${session}:`;
93 const stale = Object.keys(before).some((k) => !k.startsWith(prefix));
94 let out: Record<string, HudSeen> | undefined = stale
95 ? keepSession(before, session)
96 : undefined;
97 for (const t of tasks) {
98 if (t.status !== "in_progress" || !(t.worker || t.workerId)) continue;
99 const key = seenKey(session, t);
100 const was = (out ?? before)[key];
101 if (was?.worker === t.worker && was?.workerId === t.workerId) continue;
102 out ??= { ...before };
103 out[key] = {
104 ...(t.worker && { worker: t.worker }),
105 ...(t.workerId && { workerId: t.workerId }),
106 };
107 }
108 return out;
109}
110