An Archon pane in the side panel: this project's workflow runs, their graphs and logs, and the approvals that wait on you.

Small TypeScript mods that live inside Claude Code: a pane that knows your next step, one-click replies, a rate-limit countdown that resumes for you, your repo's pull requests and issues beside the conversation, a shelf of paths you use every day, presets that switch the model and the effort with one press, a chime when a long turn ends, a guard for the Office file you left open, a question before a commit or push on the default branch, a question before Claude reads a
.envor key file, a list of the files this session made, a list of the ones it read, a check that keeps banned claims out of a pull request, a gate that runs your checks and hands back only the failures, a nudge to fetch fresh tab IDs when a browser tab is gone, a name for the session from its first prompt, your project's dev servers in a pane that fills a crash's error into the prompt, and one dialog for every mod's settings.
claude-mods is one developer's personal toolbox of Claude Code mods, shared as a plugin marketplace so anyone can install them. The mods are built for the author's own workflow first, and you are a welcome guest: install one, install all twenty-one, or read the source and write your own.
A mod is a plugin of function hooks: TypeScript that runs inside Claude Code and changes what it shows (a pane in the side panel, a band of buttons above the prompt, the status line) or what it does between turns. The mod's own code decides when to act, even when what it does is send the model a prompt.
| Runs as | Who decides when it acts | |
|---|---|---|
| Mod | TypeScript function hooks inside Claude Code | The mod's own code |
| Skill | Instructions the model reads | The model |
| Plain plugin | Commands, agents or shell hooks | You, or a shell script |
| Mod | Where it shows | What it does |
|---|---|---|
| 🧭 whats-next | Pane in the side panel | Lists the next steps of your workflow, each with a prompt ready to paste |
| ⚡ quick-reply | Band above the prompt | One-click replies, including the options Claude just offered, Pass, Fail and Skip for a verdict, and the next wayfinder ticket |
| ⏳ auto-resume | Band and status line | Counts down to a rate limit's reset, then sends "continue" |
| 🐙 github-panel | Pane in the side panel | The repo's open pull requests and issues, one click from the browser or from /implement and /wayfinder in the prompt; the issue a /wayfinder run works on, pinned with its next ticket; a toast when your branch's checks turn green or red |
| 🏛️ archon-panel | Pane in the side panel | This project's Archon workflow runs, their graphs and logs, and the approvals that wait on you, answered from the pane |
| 📚 shelf | Band above the prompt | Named folders and files; one click drops a path into what you are typing |
| 🔔 turn-chime | Sound and toast | Tells you when a long turn ends or Claude stops to ask you something |
| 🔒 open-file-guard | Question dialog | Asks you to close a Word, Excel or PowerPoint file before Claude uses it |
| 🌿 branch-guard | Question dialog | Asks you before Claude commits or pushes on the default branch |
| 🔑 env-guard | Question dialog | Asks you before Claude reads a .env or key file, and refuses when no one is there to answer |
| 🩹 bash-quoting-rescue | Refused tool call | Stops a shell command that does not parse, such as an unclosed quote, before any of it runs |
| 📂 outputs | Pane in the side panel | The files this session made or changed, newest first; click one to open it |
| 🔎 sources | Pane in the side panel | The files Claude read, grouped by where they came from, with a lock to the project folder |
| 📊 hud | Two lines under the prompt | Model, effort, context window, rate limits, turn timer, tool calls, agents, git state, worktree, cost, session length and folder, each named and in colour |
| 🧹 post-merge-cleanup | Question dialog and toast | After a PR merges, /cleanup switches to the default branch, pulls and deletes the branch |
| 🧾 pre-pr-claims-check | Refusal Claude reads | Refuses gh pr create and gh pr edit while the pull request cites a file and line, holds a placeholder, or spells out a count |
| 🚦 lint-test-gate | Band above the prompt | Runs your checks on a press and before Claude's git commit, and hands back only the failures |
| 🔄 chrome-tab-self-heal | Note after a browser tool's error | When a Claude in Chrome tab is gone, tells Claude to fetch the current tab IDs before it tries again |
| 🏷 session-auto-namer | Band above the prompt | Suggests a name for the session from its first prompt; one press renames it |
| 🧠 model-effort-presets | Band above the prompt | Plan and execute presets: one press switches the model and the effort together |
| 🖥 dev-server-manager | Pane in the side panel, status line and toast | Starts, restarts and watches the project's dev servers; a crash toasts, restarts and fills its error into the prompt |
| ⚙ mod-settings | Gear on each pane; a dialog | Change and save any mod's settings without leaving the session |
When more than one mod has a pane open, Claude Code shows them as tabs in the side panel.
A pane listing the next steps of your workflow for the project folder, kept between sessions. A skill of your choosing answers "what's next" in a headless run beside your session, and the mod turns the answer into steps.
What's next refresh
updated 3 min ago
Fix the failing parse test
working on it done
The check is red, so nothing else can merge.
Open a pull request for the fix
Review comes before the next feature starts.
Triage the two new issues
They arrived while you were heads-down.
flowchart LR
A[Skill answers<br/>what's next] --> B[Steps in the pane]
B --> C[You read a step's prompt<br/>and send it yourself]
C --> D[Step glows:<br/>working on it]
D --> E{Laya, Jev or Haiku:<br/>is the step finished?}
E -- not yet --> D
E -- yes --> F[Step leaves the list]
d to drop it yourself.git push, git config, git -c, gh api and --output are always denied.| Command or key | What it does |
|---|---|
/whats-next | Bring the pane to the front |
/whats-next refresh | Ask the skill again |
r | Refresh |
1 to 9 | Show that step's prompt in the pane |
p, n, c | Paste the shown prompt, paste it after /clear (and the prime command, once its turn ends), or copy it |
b | Back to the list |
d | Mark the active step done |
| Setting | Key | Default | Meaning |
|---|---|---|---|
| Skill | skill | /ask-sean | The skill that answers "what's next", written as you would run it: / followed by letters, digits, _, :, . or - |
| Most steps | maxSteps | 5 | How many steps to ask for (1-9) |
| Refresh on start | refreshOnStart | on | Ask for a fresh list when a session starts in a git repository |
| Tools the headless run may use | allowedTools | empty: the read-only set | Comma-separated permission rules for the headless run |
| Model | model | empty: your default | Model for the headless run, as an alias (haiku) or a full id |
| Prime command | primeCommand | empty: none | A slash command, with any arguments, run after /clear and before the paste (/lril:prime); the prompt is filled once the turn it starts ends, finished or not. A value that is not one slash command on one line is never run, and the step view says so |
| System One models | modelChoice | local only | Which System One model judges whether a turn finished the active step: local only (Laya on this machine, or Haiku as before), local first (Laya, and TypeSafe's hosted Jev while Laya is unavailable) or hosted first (Jev, and Laya while Jev is unavailable) |
| Jev API key | jevApiKey | empty | Your key for Jev, from console.typesafe.ai, kept in secure storage. Sent only to https://api.typesafe.ai, and only while System One models allows Jev |
| Laya port | layaPort | 8000 | The port your laya-serve listens on at 127.0.0.1 (1-65535) |
/ask-sean is the author's own skill, so point the Skill setting at a skill of yours that answers "what should I do next?" (how to set it):
echo '{"skill": "/next-steps", "maxSteps": "3", "model": "haiku"}' | claude plugin configure whats-next@claude-mods --values-stdin
The read-only set, used while Tools is empty, is Bash(git status:*), Bash(git log:*), Bash(git diff:*), Bash(git show:*), Bash(git rev-parse:*), Bash(git branch --show-current), Bash(git branch -vv), Bash(git remote -v), Bash(gh issue list:*), Bash(gh issue view:*), Bash(gh pr list:*), Bash(gh pr view:*), Bash(gh pr checks:*), Bash(gh run list:*), Read, Glob and Grep.
(...). One rule that is not, such as one starting with -, discards your whole list and the read-only set is used.Skill, so a skill that calls another still works. MCP servers load only when a rule names an mcp__ tool.What the judge sends to a System One model, once after each answered turn while a step is active:
Needs: the claude CLI on the PATH, and the skill named in the Skill setting. Laya and a Jev key are optional: with neither, the judge is Haiku, as before.
One row of buttons above the prompt after each answer. When Claude ends on a question, the band offers the choices Claude asked you to pick from, then your replies to a question. After any other answer it offers your other replies. You set both lists in the settings below.
Reply: [a: Keep the copies] [b: Add a sync script] [Yes] [Go with your recommendation] [No]
Verdicts. When Claude asks for a pass/fail verdict on a test or a check, as a UAT or /gsd:verify-work step does ("Pass or fail?", "Did it pass?", "Type pass or describe what's wrong"), the band offers a verdict in place of your replies:
Reply: [Pass] [Fail…] [Skip]
Pass sends "pass" and Skip sends "skip", as your own message. Fail… sends nothing: it puts "Fail: " in the prompt, where you say what went wrong and can paste a screenshot. A test's numbered steps are not offered as choices, and a question that only mentions passing ("Shall I make the tests pass?") gets your usual replies.
Next ticket. When you work a map with the wayfinder skill, the band leads with the next ticket after each turn that closes one or charts the map:
Reply: [Next ticket: /wayfinder 135] [Continue] [Commit and push]
One press runs /clear and then /wayfinder 135, the loop you would otherwise type. It is offered only in a session that ran the wayfinder skill, after a turn whose gh issue close worked; after charting it names the map the turn created. A close made some other way (gh api, the web) is not seen, so no button shows, and once a turn closes the map itself the loop ends.
| Setting | Key | Default | Meaning | |||
|---|---|---|---|---|---|---|
| Replies to a question | questionReplies | `Yes\ | Go with your recommendation\ | No` | Shown after Claude asks something, separated by `\ | `; empty shows only the choices Claude offered |
| Replies otherwise | idleReplies | `Continue\ | Commit and push` | Shown after any other answer; empty hides the band then, except for Next ticket | ||
| Offer the next wayfinder ticket | wayfinderNext | true | After a wayfinder turn closes a ticket or charts a map, offer Next ticket | |||
| System One models | modelChoice | local only | Which System One model reads how each answer ends: local only (Laya on this machine, or the band's own reading as before), local first (Laya, and TypeSafe's hosted Jev while Laya is unavailable) or hosted first (Jev, and Laya while Jev is unavailable) | |||
| Jev API key | jevApiKey | empty | Your key for Jev, from console.typesafe.ai, kept in secure storage. Sent only to https://api.typesafe.ai, and only while System One models allows Jev | |||
| Laya port | layaPort | 8000 | The port your laya-serve listens on at 127.0.0.1 (1-65535) |
Each list holds up to six replies. A reply longer than 120 characters is cut short, and a repeat is dropped. For example, to answer questions with your own three replies and hide the band after other answers (how to set it):
echo '{"questionReplies": "Yes|No|Explain that first", "idleReplies": ""}' | claude plugin configure quick-reply@claude-mods --values-stdin
What the band sends to a System One model, once after each answered turn:
Laya and a Jev key are optional: with neither, the band reads each answer as before.
When a turn dies on a rate limit or an overloaded API, auto-resume counts down to the reset and sends "continue" for you. Go to lunch, and come back to finished work.
⏳ rate limited: sending "continue" in 1 h 12 min [Resume now] [Cancel]
| Command | What it does |
|---|---|
/auto-resume | Say what is waiting, if anything |
/auto-resume now | Send the resume now |
/auto-resume cancel | Cancel the wait |
/auto-resume in <minutes> | Schedule a resume yourself (1 to 1440) |
| Setting | Key | Default | Meaning |
|---|---|---|---|
| Resume prompt | text | continue | What is sent when the wait is over; empty sends continue |
| Grace after reset (s) | graceSeconds | 60 | Extra seconds to wait past the limit's reset time (0-900) |
| Retry overloaded/server errors | retryOverloaded | on | Also resume after an overloaded or server error, backing off from one minute |
| Most retries in a row | maxRetries | 5 | Give up after this many resumes without a successful answer (1-20) |
For example, to send a longer prompt and wait two minutes past the reset (how to set it):
echo '{"text": "continue where you left off", "graceSeconds": "120"}' | claude plugin configure auto-resume@claude-mods --values-stdin
A GitHub pane beside What's next listing the repo's open pull requests and issues. Click one to open it in the browser. Under each issue, implement and wayfinder put /implement or /wayfinder and the issue's URL in the prompt. Nothing is sent. While a /wayfinder effort is under way, its issue is pinned at the top, with the next ticket to take.
octocat/hello-world refresh
updated just now
Decide how shared code is copied unpin
3 done · 1 takeable · 1 claimed · 2 blocked
next: Pick the drift check decision
work next
Pull requests 2 all
#41 Add a drift check for copied guards
draft · @octocat
#40 Bring the asked pane to the front
✗ checks failing · @hubot fix
Issues 2 all
#39 Share the headless-session check
blocked by #12 · ready… implement wayfinder
#12 Decide how shared code is copied
needs-triage · @hubot implement wayfinder
implement and wayfinder buttons take a line of their own.r.fix button. It fills the prompt box with the failed checks' names, the last 40 lines of the failed run's log, and "Fix it." Nothing is sent: you read it and send it yourself. The log is fetched only when you press fix; when gh cannot fetch it, such as while the run is still going, the prompt holds the names alone./wayfinder on an issue of this repo (/wayfinder 12, /wayfinder #12 or the issue's URL) pins that issue and brings the pane to the front. Anything else, such as prose or another repo's issue, pins nothing. The pin is kept for the repo across sessions, one at a time: a run on another issue replaces it. It goes when you press unpin, or once the issue is closed.next is the first takeable ticket in the order the issue lists its sub-issues, with its wayfinder: type label; click it to open it on GitHub. With nothing takeable it says so; an issue with no sub-issues yet says no tickets yet.work next fills /wayfinder and the pinned issue's URL into the prompt, without sending it, so the skill takes the frontier ticket fresh when it runs.| Command or key | What it does |
|---|---|
/github | Bring the pane to the front and refresh it |
r | Refresh |
all | Open the whole list on GitHub |
implement, wayfinder | Fill the command and the issue's URL into the prompt, without sending it |
fix | Fill a request to fix the current branch's failing checks into the prompt box |
unpin | Drop the pinned issue |
work next | Fill /wayfinder and the pinned issue's URL into the prompt, without sending it |
| Setting | Key | Default | Meaning |
|---|---|---|---|
| Most items per list | limit | 30 | How many open pull requests and issues to list each (1-100) |
| Refresh every (minutes) | refreshMinutes | 5 | How often to refresh (0-120); 0 refreshes only on open, after turns and on r |
| Implement button fills | implementCommand | /implement | The slash command the implement button fills before the issue's URL; empty hides the button |
| Wayfinder button fills | wayfinderCommand | /wayfinder | The slash command the wayfinder and work next buttons fill before the issue's URL, and the skill whose runs pin an issue; empty hides the button and turns pinning off |
A command must start with / and hold no spaces. Any other value falls back to the default, and a message says so when the session starts. A button is labelled with its command, without the /.
For example, to list fifty of each and stop the timer (how to set it):
echo '{"limit": "50", "refreshMinutes": "0"}' | claude plugin configure github-panel@claude-mods --values-stdin
Needs: the GitHub CLI, logged in, and a folder with a GitHub remote.
An Archon pane in the side panel: this project's Archon workflow runs, their graphs and logs, and the approvals that wait on you. A run that needs you comes first, and the pane answers its approval, or resumes or abandons it, without leaving the session.
1: Runs 3 ⏸1 2: Graph 3: Log 4: Archon's log ↻ ⚙️
+2 live in other projects
⏸ archon-interactive-prd needs your approval 4m
Draft the PRD for the widget pane
● archon-deliver running 37m
Ship the widget pane · continues archon-plan hooks/register.tsx 1020 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code'
3
4import type { ArchonActions, ArchonData, ArchonView, Detail, LogWindow, Notice, Pending, Project, Run, Tab } from '../types'
5import { deliver, hasMoved, movedElsewhere, recordLine, REPLY_MS } from './actions'
6import { actionBody, offersResume } from './actions-view'
7import { bodyWindow, lastPosition, pinnedRow, SETTINGS } from './chrome'
8import { candidates, findArchon, requirementLine } from './cli'
9import { readWorkflow } from './graph'
10import { drawNodes, graphBody } from './graph-view'
11import { EMPTY_WINDOW, isUnder, splitChunk, take, toolSteps } from './log'
12import { logBody } from './log-view'
13import type { OpenedFile } from './log-view'
14import { claimKey, collapse, isClaimed, isMine, statusText, toastEvents } from './notify'
15import type { Runner } from './cli'
16import { parseConfig } from './config'
17import type { Config } from './config'
18import { hasEnded, liveCount, needsYou, needsYouCount, sortRuns, topRuns, waitNode } from './runs'
19import { runsBody } from './runs-view'
20import { capLines, expandHome, serveLine } from './serve-log'
21import type { Platform } from './scope'
22import { listRuns, readDetail, serverText, signature } from './source'
23import { clockTime, dollars, duration, firstLine } from './text'
24import type { DiskIo } from './source'
25
26const PANE = 'archon'
27const TITLE = 'Archon'
28/** After a main-thread turn, load only when the runs are older than this. */
29const AFTER_TURN_MS = 10_000
30
31const EMPTY_DATA: ArchonData = {
32 status: 'idle',
33 loadId: 0,
34 requirement: { state: 'unchecked', path: '', version: '' },
35 project: null,
36 source: '',
37 loadedAt: 0,
38 error: '',
39 runs: [],
40 others: { live: 0, needsYou: 0 },
41 listed: [],
42 seen: {},
43 details: {},
44 graphs: {},
45}
46
47const EMPTY_VIEW: ArchonView = {
48 tab: 'runs',
49 at: { 'runs': 0, 'graph': 0, 'log': 0, 'archon-log': 0 },
50 run: '',
51 node: '',
52 fold: '',
53 file: '',
54 isFilesOpen: false,
55 fanouts: [],
56 includes: [],
57 loop: '',
58 round: 0,
59}
60
61const data = atom({ plugin: 'archon-panel', key: 'data' } as const, EMPTY_DATA)
62const view = atom({ plugin: 'archon-panel', key: 'view' } as const, EMPTY_VIEW)
63const hasStartedUp = atom({ plugin: 'archon-panel', key: 'hasStartedUp' } as const, false)
64const startedAt = atom({ plugin: 'archon-panel', key: 'startedAt' } as const, 0)
65const logWindow = atom({ plugin: 'archon-panel', key: 'log' } as const, EMPTY_WINDOW)
66const EMPTY_ACTIONS: ArchonActions = { pending: null, text: {}, sending: '', notices: {}, records: {}, checks: {} }
67const actions = atom({ plugin: 'archon-panel', key: 'actions' } as const, EMPTY_ACTIONS)
68const serveLog = atom({ plugin: 'archon-panel', key: 'serveLog' } as const, { lines: [] as string[], size: 0, isMissing: false })
69
70// Dies with the module on a reload; session.start or the next attach starts it again.
71let timer: Timer | undefined
72// Counts attaches, so a surfaces check answered before an attach cannot stop
73// the polling that attach kept going.
74let attaches = 0
75// The run log follower: one spawned `archon workflow logs <id> --follow`, for the one live run whose log is in front.
76let follower: { runId: string; stream: AsyncGenerator<unknown, unknown> } | undefined
77// The file open in Log's read view, fetched only when opened.
78let opened: OpenedFile | undefined
79// A line the read view shows under its buttons, such as the prompt box refusing.
80let fileNotice = ''
81// The furthest each sub-tab can scroll, as last drawn; a scroll never goes past it.
82const furthest: Record<Tab, number> = { 'runs': 0, 'graph': 0, 'log': 0, 'archon-log': 0 }
83// Whether each log sits at its end, and so follows its tail as rows arrive.
84const atEnd: Record<'log' | 'archon-log', boolean> = { 'log': true, 'archon-log': true }
85// Whether the pane was in front when a tick or a draw last looked; undefined until one has.
86let wasInFront: boolean | undefined
87
88/**
89 * Whether any surface shows the session right now. Asked before each action
90 * the mod starts on its own and never kept: a reload or a missed attach would
91 * leave a kept flag wrong. Each mod carries its own copy (ADR-0001).
92 */
93async function isShown($: EngineInterface): Promise<boolean> {
94 return (await $.session.surfaces()).length > 0
95}
96
97/** Whether the Archon pane is the one shown, and placed: what "in front" means for polling. */
98async function isInFront($: EngineInterface): Promise<boolean> {
99 return (await $.ui.panes().catch(() => [])).some(pane => pane.id === PANE && pane.isShown && pane.isPlaced)
100}
101
102function report($: EngineInterface): (error: unknown) => void {
103 return error => $.ui.toast(`${error instanceof Error ? error.message : String(error)}`)
104}
105
106function runner($: EngineInterface): Runner {
107 return (argv, timeoutMs) => $.process.run(argv, timeoutMs === undefined ? undefined : { timeoutMs })
108}
109
110function io($: EngineInterface): DiskIo {
111 return {
112 run: runner($),
113 fetch: (url, init) => $.http.fetch(url, init),
114 sleep: ms => $.clock.sleep(ms),
115 exists: path => $.fs.exists(path),
116 list: path => $.fs.list(path),
117 }
118}
119
120async function isWindows($: EngineInterface): Promise<boolean> {
121 return (await $.env.get('OS')) === 'Windows_NT'
122}
123
124async function home($: EngineInterface): Promise<string> {
125 return ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
126}
127
128async function platformOf($: EngineInterface): Promise<Platform> {
129 if (await isWindows($)) return 'windows'
130 return ((await $.env.get('HOME')) ?? '').startsWith('/Users/') ? 'mac' : 'linux'
131}
132
133/** The output of a git command in the session's folder; '' when git cannot say. */
134async function git($: EngineInterface, args: readonly string[]): Promise<string> {
135 const run = await $.process.run(['git', ...args]).catch(() => undefined)
136 return run?.exitCode === 0 ? run.stdout.trim() : ''
137}
138
139/**
140 * The session's primary checkout (the parent of Git's common directory) and
141 * top level; the session's folder outside Git. Worked out once per session and
142 * again on r.
143 */
144async function findProject($: EngineInterface): Promise<Project> {
145 const common = await git($, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
146 const top = await git($, ['rev-parse', '--show-toplevel'])
147 if (common === '' || top === '') {
148 const cwd = await $.session.cwd()
149 return { primary: cwd, top: cwd, branch: '', ids: [], from: '' }
150 }
151 const primary = common.replace(/\\/g, '/').replace(/\/+$/, '').replace(/\/[^/]+$/, '')
152 return { primary, top, branch: await git($, ['branch', '--show-current']), ids: [], from: '' }
153}
154
155/** Checks the Requirement and keeps the result in `$.state`. */
156async function checkCli($: EngineInterface, config: Config): Promise<boolean> {
157 const places = candidates(config.archonPath, await home($), await isWindows($))
158 const requirement = await findArchon(runner($), places)
159 await update($, data, current => ({ ...current, requirement }))
160 return requirement.state === 'ok'
161}
162
163/**
164 * How long until the next poll, by what the person can see and which source
165 * answered: in front with a live run here, in front with nothing live, or
166 * behind another tab or closed (the status line and toasts only).
167 */
168function interval(source: ArchonData['source'], isFront: boolean, hasLive: boolean): number {
169 const isServer = source === 'server'
170 if (!isFront) return isServer ? 15_000 : 60_000
171 if (hasLive) return isServer ? 2_000 : 10_000
172 return isServer ? 10_000 : 30_000
173}
174
175function hasLiveRun(current: ArchonData): boolean {
176 return current.runs.some(run => !hasEnded(run))
177}
178
179function stopPolling(): void {
180 timer?.cancel()
181 timer = undefined
182}
183
184/** Polls again after `ms`; a tick that finds no surface stops it until the next attach. */
185function schedule($: EngineInterface, config: Config, ms: number): void {
186 stopPolling()
187 const own = $.clock.after(ms, () => {
188 void (async () => {
189 const seen = attaches
190 if (!(await isShown($))) {
191 if (timer === own && attaches === seen) stopPolling()
192 return
193 }
194 if (timer !== own) return
195 await load($, config)
196 })().catch(report($))
197 })
198 timer = own
199}
200
201/** Arms the next poll at the interval what the person can see now asks for. */
202async function scheduleNext($: EngineInterface, config: Config): Promise<void> {
203 const current = await read($, data)
204 if (current.requirement.state !== 'ok') return stopPolling()
205 const isFront = await isInFront($)
206 wasInFront = isFront
207 schedule($, config, interval(current.source, isFront, hasLiveRun(current)))
208}
209
210/**
211 * Loads at once when a draw finds the pane come forward since a tick or a
212 * draw last looked: no event fires when its tab is brought forward, and the
213 * tick it set while behind could be a minute off. A draw writes nothing, so
214 * the load starts from a timer of its own.
215 */
216async function loadIfForward($: EngineInterface, config: Config): Promise<void> {
217 const isFront = await isInFront($)
218 const was = wasInFront
219 wasInFront = isFront
220 if (was === false && isFront) $.clock.after(0, () => void load($, config).catch(report($)))
221}
222
223/** Whether a changed row's detail is worth reading: it is live, ended while this session watched, or picked. */
224function wantsDetail(run: Run, since: number, picked: string): boolean {
225 return !hasEnded(run) || run.completedAt >= since || run.id === picked
226}
227
228/**
229 * Loads the runs. One load at a time; a load superseded by a reload of the
230 * module is dropped by its load id. With the Requirement not met nothing is
231 * read, and polling stops until r, an attach or a reload.
232 */
233async function load($: EngineInterface, config: Config): Promise<void> {
234 let loadId = 0
235 await update($, data, (current): ArchonData => {
236 if (current.status === 'loading') return current
237 loadId = current.loadId + 1
238 return { ...current, status: 'loading', loadId }
239 })
240 if (loadId === 0) return
241 const finish = (change: (current: ArchonData) => ArchonData) =>
242 update($, data, current => (current.loadId === loadId ? change(current) : current))
243
244 const before = await read($, data)
245 if (before.requirement.state !== 'ok') {
246 stopPolling()
247 await finish(current => ({ ...current, status: 'idle' }))
248 return
249 }
250 try {
251 const project = before.project ?? (await findProject($))
252 const reader = io($)
253 const archon = before.requirement.path
254 const listing = await listRuns(reader, config.port, archon, project, await platformOf($))
255 const isServer = listing.source === 'server'
256 const since = await read($, startedAt)
257 const picked = (await read($, view)).run
258 const runs = new Map(listing.mine.map(run => [run.id, run]))
259 const details: Record<string, Detail> = {}
260 const seen: Record<string, string> = {}
261
262 // A sub-run whose parent is not listed brings the parent in by id, again only when the sub-run's row changes.
263 for (const run of listing.mine) {
264 if (run.parentId === '' || runs.has(run.parentId)) continue
265 const kept = before.runs.find(known => known.id === run.parentId)
266 if (kept !== undefined && before.seen[run.id] === signature(run)) {
267 runs.set(kept.id, kept)
268 continue
269 }
270 const parent = await readDetail(reader, config.port, archon, run.parentId, isServer, kept)
271 if (parent?.run !== undefined) {
272 runs.set(parent.run.id, parent.run)
273 details[parent.run.id] = parent.detail
274 seen[parent.run.id] = signature(parent.run)
275 }
276 }
277
278 for (const run of [...runs.values()]) {
279 const sig = seen[run.id] ?? signature(run)
280 seen[run.id] = sig
281 if (details[run.id] !== undefined) continue
282 const known = before.details[run.id]
283 if (before.seen[run.id] === sig && known !== undefined) {
284 details[run.id] = known
285 continue
286 }
287 if (before.seen[run.id] === sig || !wantsDetail(run, since, picked)) continue
288 const read = await readDetail(reader, config.port, archon, run.id, isServer, run)
289 if (read !== undefined) details[run.id] = read.detail
290 }
291
292 const mine = new Set(runs.keys())
293 const others = listing.all.filter(run => !mine.has(run.id))
294 const loadedAt = await $.clock.now()
295 await finish(current => ({
296 ...current,
297 status: 'idle',
298 error: '',
299 source: listing.source,
300 project: { ...project, ids: listing.ids, from: listing.from },
301 runs: [...runs.values()],
302 others: { live: liveCount(others), needsYou: needsYouCount(others, {}) },
303 listed: listing.all.map(run => run.id),
304 seen,
305 details,
306 loadedAt,
307 }))
308 } catch (error) {
309 const reason = error instanceof Error ? error.message : String(error)
310 await finish(current => ({ ...current, status: 'idle', error: reason }))
311 }
312 if ((await read($, data)).loadId !== loadId) return
313 await scheduleNext($, config)
314 void notify($, config).catch(report($))
315 void syncLog($, config).catch(report($))
316 await settleActions($)
317}
318
319/** A moment for another session's claim on the same toast to land before this one reads its own back. */
320const CLAIM_WAIT_MS = 300
321
322/**
323 * Sets the status line and raises this poll's toasts. Each toast is claimed
324 * in the shared store first, so one event toasts once across sessions, best
325 * effort: a session that is headless or has toasts off never claims.
326 */
327async function notify($: EngineInterface, config: Config): Promise<void> {
328 if (!(await isShown($))) return
329 const current = await read($, data)
330 $.ui.status(config.isStatusLine ? statusText(current.runs, current.details, current.others.needsYou) : undefined)
331 await prune($, current.listed)
332 if (config.toasts === 'off') return
333 const events = toastEvents(current.runs, current.details, await read($, startedAt))
334 .filter(event => config.toasts === 'all' || event.event === 'needs-you')
335 const session = await $.session.id()
336 const claimed = []
337 for (const event of events) {
338 if (isClaimed(event, await $.store.get(claimKey(event)))) continue
339 await $.store.set(claimKey(event), { session, gate: event.gate })
340 claimed.push(event)
341 }
342 if (claimed.length === 0) return
343 await $.clock.sleep(CLAIM_WAIT_MS)
344 const won = []
345 for (const event of claimed) if (isMine(await $.store.get(claimKey(event)), session)) won.push(event)
346 const toast = collapse(won)
347 if (toast !== undefined) $.ui.toast(toast.text, { timeoutMs: toast.timeoutMs })
348}
349
350/** Drops the toast claims of runs no longer among the listed rows. */
351async function prune($: EngineInterface, listed: readonly string[]): Promise<void> {
352 if (listed.length === 0) return
353 const ids = new Set(listed)
354 for (const key of await $.store.keys()) {
355 const [kind, runId] = key.split('/')
356 if (kind === 'toasted' && runId !== undefined && !ids.has(runId)) await $.store.delete(key)
357 }
358}
359
360/** Picks a run: one that needs you opens Log on its gate or wait node; any other opens its Graph. */
361async function pick($: EngineInterface, config: Config, run: Run): Promise<void> {
362 // Picking a run, even the one in front, starts its window afresh, unless it is being followed.
363 if (follower?.runId !== run.id) {
364 stopFollower()
365 await update($, logWindow, () => EMPTY_WINDOW)
366 atEnd.log = true
367 }
368 await update($, actions, (a): ArchonActions => ({ ...a, pending: null, records: Object.fromEntries(Object.entries(a.records).filter(([id]) => id === run.id)) }))
369 const current = await read($, data)
370 const need = needsYou(run, current.runs, current.details)
371 if (need === undefined) {
372 await openGraph($, run.id)
373 return
374 }
375 await changeView($, config, (v): ArchonView => ({ ...v, tab: 'log', run: need.holder.id, node: waitNode(need.standing), file: '' }))
376}
377
378/** Reads a run's graph once from its frozen workflow source, which never changes. */
379async function ensureGraph($: EngineInterface, runId: string): Promise<void> {
380 const current = await read($, data)
381 const run = current.runs.find(r => r.id === runId)
382 if (run === undefined || current.graphs[runId] !== undefined) return
383 const nodes = await readWorkflow({ list: path => $.fs.list(path), read: path => $.fs.read(path) }, run.sourceRoot, run.workflow)
384 await update($, data, (d): ArchonData => ({ ...d, graphs: { ...d.graphs, [runId]: nodes } }))
385}
386
387/** Picks a box in the Graph: a node opens Log cut to it; a block, loop or sub-run box opens what it folds. */
388async function pickNode($: EngineInterface, config: Config, runId: string, id: string): Promise<void> {
389 const current = await read($, data)
390 const shown = await read($, view)
391 const run = current.runs.find(r => r.id === runId)
392 const nodes = current.graphs[runId]
393 if (run === undefined) return
394 if (id.includes('.')) {
395 await openNode($, config, run, id)
396 return
397 }
398 const pickOf = nodes === undefined || nodes === null ? undefined : drawNodes(nodes, run, current.runs, current.details, shown).picks.get(id)
399 if (pickOf?.kind === 'block') {
400 const key = `${runId}:${pickOf.id}`
401 await update($, view, (v): ArchonView => ({ ...v, includes: v.includes.includes(key) ? v.includes : [...v.includes, key] }))
402 } else if (pickOf?.kind === 'loop') {
403 const key = `${runId}:${pickOf.id}`
404 await update($, view, (v): ArchonView => ({ ...v, loop: v.loop === key ? '' : key, round: 0 }))
405 } else if (pickOf?.kind === 'workflow' && pickOf.subRuns.length > 0) {
406 const waiting = pickOf.subRuns.find(subRun => needsYou(subRun, current.runs, current.details) !== undefined)
407 if (waiting !== undefined) await pick($, config, waiting)
408 else if (pickOf.subRuns.length > 1) {
409 const key = `${runId}:${pickOf.id}`
410 await update($, view, (v): ArchonView => ({ ...v, fanouts: v.fanouts.includes(key) ? v.fanouts.filter(f => f !== key) : [...v.fanouts, key] }))
411 } else await openGraph($, pickOf.subRuns[0]!.id)
412 } else {
413 await openNode($, config, run, id)
414 }
415}
416
417/**
418 * Opens Log cut to a node. A finished run whose window lost that node's rows
419 * off its top refills it with one replay of that node's rows.
420 */
421async function openNode($: EngineInterface, config: Config, run: Run, node: string): Promise<void> {
422 await changeView($, config, (v): ArchonView => ({ ...v, tab: 'log', run: run.id, node, file: '' }))
423 const win = await read($, logWindow)
424 if (hasEnded(run) && win.runId === run.id && win.dropped > 0 && !win.rows.some(row => row.nodes.includes(node))) await replay($, config, run, node)
425}
426
427/** `a`: Log widened to all nodes, back to the run's newest rows. */
428async function showAll($: EngineInterface, config: Config): Promise<void> {
429 await update($, view, (v): ArchonView => ({ ...v, node: '' }))
430 const current = await read($, data)
431 const picked = (await read($, view)).run
432 const run = current.runs.find(r => r.id === picked)
433 const win = await read($, logWindow)
434 if (run !== undefined && win.runId === run.id && win.node !== '') {
435 if (hasEnded(run)) await replay($, config, run)
436 else {
437 stopFollower()
438 await update($, logWindow, () => EMPTY_WINDOW)
439 await syncLog($, config)
440 }
441 }
442}
443
444/** Shows a run's Graph, reading its source the first time. */
445async function openGraph($: EngineInterface, runId: string): Promise<void> {
446 await update($, view, (v): ArchonView => ({ ...v, tab: 'graph', run: runId, node: '' }))
447 await ensureGraph($, runId)
448}
449
450/** Reads one run again by id, keeping its row and detail: before an action, and when its follower ends. */
451async function refreshRun($: EngineInterface, config: Config, runId: string): Promise<{ run: Run; detail: Detail } | undefined> {
452 const current = await read($, data)
453 const known = current.runs.find(r => r.id === runId)
454 const got = await readDetail(io($), config.port, current.requirement.path, runId, current.source !== 'cli', known).catch(() => undefined)
455 if (got?.run === undefined) return undefined
456 const run = got.run
457 await update($, data, (d): ArchonData => ({
458 ...d,
459 runs: d.runs.some(r => r.id === run.id) ? d.runs.map(r => (r.id === run.id ? run : r)) : [...d.runs, run],
460 details: { ...d.details, [run.id]: got.detail },
461 }))
462 return { run, detail: got.detail }
463}
464
465/** A finished run's end line: `✓ completed 37m $4.12`, or how it failed. */
466function endLine(run: Run, detail: Detail | undefined): string {
467 const spent = duration(run.completedAt - run.startedAt)
468 const cost = run.costUsd > 0 ? ` ${dollars(run.costUsd)}` : ''
469 if (run.status === 'completed') return `✓ completed ${spent}${cost}`
470 if (run.status === 'cancelled') return `✗ cancelled ${spent}${cost}`
471 const failed = [...(detail?.events ?? [])].reverse().find(e => e.type === 'node_failed')
472 const error = firstLine(failed?.error ?? '')
473 return `✗ failed ${spent}${cost}${failed === undefined ? '' : ` at ${failed.step}`}${error === '' ? '' : `: ${error}`}`
474}
475
476function stopFollower(): void {
477 const own = follower
478 follower = undefined
479 void own?.stream.return(undefined).catch(() => {})
480}
481
482/**
483 * Follows a live run's log with `archon workflow logs <id> --follow`, which
484 * starts at line 1 and exits once the run reaches a final status. A spawn
485 * delivers chunks, not lines, so a partial last line waits for the next. A
486 * restart after a reload replays from line 1, skipping the lines already taken.
487 */
488async function follow($: EngineInterface, config: Config, runId: string): Promise<void> {
489 const archon = (await read($, data)).requirement.path
490 let win = await read($, logWindow)
491 if (win.runId !== runId || win.node !== '') {
492 win = { ...EMPTY_WINDOW, runId }
493 await update($, logWindow, () => win)
494 }
495 const skip = win.taken
496 const stream = $.process.spawn({ argv: [archon, 'workflow', 'logs', runId, '--follow'] })
497 const own = { runId, stream }
498 follower = own
499 let held = ''
500 let lineNo = 0
501 let isOver: boolean
502 let code: number | null = null
503 try {
504 for await (const chunk of stream) {
505 if (follower !== own) break
506 if (chunk.stream !== 'stdout') continue
507 const split = splitChunk(held, chunk.text)
508 held = split.held
509 const from = lineNo
510 lineNo += split.lines.length
511 if (split.lines.length > 0) await update($, logWindow, (w): LogWindow => (w.runId === runId ? take(w, split.lines, from, skip) : w))
512 }
513 isOver = follower === own
514 if (isOver) code = (await stream.result).code
515 } catch {
516 isOver = follower === own
517 } finally {
518 if (follower === own) follower = undefined
519 }
520 if (!isOver) return
521 if (code !== 0 && lineNo === 0) await update($, logWindow, (w): LogWindow => (w.runId === runId ? { ...w, isMissing: true } : w))
522 const got = await refreshRun($, config, runId)
523 if (got !== undefined && hasEnded(got.run)) {
524 await update($, logWindow, (w): LogWindow => (w.runId === runId ? { ...w, end: endLine(got.run, got.detail) } : w))
525 }
526}
527
528/** A finished run's log, in one `archon workflow logs <id>`; with `node`, only that node's rows are kept. */
529async function replay($: EngineInterface, config: Config, run: Run, node = ''): Promise<void> {
530 const archon = (await read($, data)).requirement.path
531 const out = await runner($)([archon, 'workflow', 'logs', run.id]).catch(() => undefined)
532 if ((await read($, data)).details[run.id] === undefined) await refreshRun($, config, run.id)
533 const detail = (await read($, data)).details[run.id]
534 const steps = toolSteps(detail?.events ?? [])
535 let win: LogWindow = { ...EMPTY_WINDOW, runId: run.id, node }
536 if (out?.exitCode === 0) win = take(win, out.stdout.replace(/\n$/, '').split('\n'), 0, 0, row => isUnder(row, node, steps))
537 else win = { ...win, isMissing: true }
538 const latest = (await read($, data)).runs.find(r => r.id === run.id) ?? run
539 if (hasEnded(latest)) win = { ...win, end: endLine(latest, detail) }
540 await update($, logWindow, () => win)
541}
542
543/**
544 * Keeps the run log in step with what the person sees: the follower runs only
545 * while a live run's log is in front, one run at a time; a finished run is
546 * replayed once, and its window kept until another run is picked.
547 */
548async function syncLog($: EngineInterface, config: Config): Promise<void> {
549 const current = await read($, data)
550 const shown = await read($, view)
551 if (shown.tab === 'archon-log' && (await isShown($)) && (await isInFront($))) await readServeLog($, config)
552 const run = current.runs.find(r => r.id === shown.run)
553 const isWanted = run !== undefined && shown.tab === 'log' && current.requirement.state === 'ok' && (await isShown($)) && (await isInFront($))
554 if (!isWanted || run === undefined) return stopFollower()
555 if (!hasEnded(run)) {
556 if (follower?.runId !== run.id) {
557 stopFollower()
558 void follow($, config, run.id).catch(report($))
559 }
560 return
561 }
562 if (follower?.runId === run.id) return
563 stopFollower()
564 const win = await read($, logWindow)
565 if (win.runId !== run.id) await replay($, config, run)
566}
567
568/** Reads Archon's log while its sub-tab is in front, keeping the newest lines under 60,000 characters. */
569async function readServeLog($: EngineInterface, config: Config): Promise<void> {
570 const path = expandHome(config.archonLog, await home($))
571 const text = await $.fs.read(path).catch(() => undefined)
572 await update($, serveLog, () => (text === undefined
573 ? { lines: [], size: 0, isMissing: true }
574 : { lines: capLines(text.split(/\r?\n/).filter(line => line !== '')), size: text.length, isMissing: false }))
575}
576
577/** Changes what the person sees, then brings the run log in step. */
578async function changeView($: EngineInterface, config: Config, change: (v: ArchonView) => ArchonView): Promise<void> {
579 const was = await read($, view)
580 await update($, view, change)
581 const now = await read($, view)
582 // A change of run or sub-tab drops the pending action; any typed text stays.
583 if (now.tab !== was.tab || now.run !== was.run) await update($, actions, (a): ArchonActions => (a.pending === null ? a : { ...a, pending: null }))
584 await syncLog($, config)
585}
586
587/** Where a run's file is on disk. */
588function filePath(run: Run, path: string): string {
589 return `${run.outputRoot.replace(/\\/g, '/').replace(/\/+$/, '')}/artifacts/runs/${run.id}/${path}`
590}
591
592/** Opens one of the run's files in Log's read view: from the server while it answers, else from disk. */
593async function openFile($: EngineInterface, config: Config, path: string): Promise<void> {
594 const current = await read($, data)
595 const shown = await read($, view)
596 const run = current.runs.find(r => r.id === shown.run)
597 await update($, view, (v): ArchonView => ({ ...v, tab: 'log', file: path }))
598 fileNotice = ''
599 if (run === undefined) return
600 const listed = current.details[run.id]?.files.find(file => file.path === path)
601 let text: string | undefined
602 if (current.source === 'server') {
603 const got = await serverText(io($), `http://localhost:${config.port}/api/artifacts/${encodeURIComponent(run.id)}/${path.split('/').map(encodeURIComponent).join('/')}`)
604 text = got?.text
605 }
606 if (text === undefined) text = await $.fs.read(filePath(run, path)).catch(() => undefined)
607 opened = { path, text: text ?? '', isBinary: text !== undefined && text.includes('\u0000'), size: listed?.size ?? text?.length ?? 0 }
608 if (text === undefined) fileNotice = "This file can't be read."
609 $.ui.invalidate('ui.render')
610}
611
612/** Opens the file with the platform's opener, in the terminal only; elsewhere, or when no opener starts, its path is copied. */
613async function openOutside($: EngineInterface, surface: string): Promise<void> {
614 const current = await read($, data)
615 const shown = await read($, view)
616 const run = current.runs.find(r => r.id === shown.run)
617 if (run === undefined || shown.file === '') return
618 const path = filePath(run, shown.file)
619 const windows = await isWindows($)
620 const native = windows ? path.replace(/\//g, '\\') : path
621 if (surface === 'terminal') {
622 const root = ((await $.env.get('SystemRoot')) ?? 'C:\\Windows').replace(/\\+$/, '')
623 const openers = windows ? [[`${root}\\explorer.exe`, native]] : [['open', path], ['xdg-open', path]]
624 for (const argv of openers) {
625 const ran = await $.process.run(argv).catch(() => undefined)
626 // explorer.exe can exit 1 when it opened the file, so its exit code is not trusted.
627 if (ran !== undefined && (windows || ran.exitCode === 0)) return
628 }
629 }
630 await $.ui.copy({ text: native }).catch(() => undefined)
631}
632
633/** Adds `@<absolute path>` to the prompt box, wherever a surface has one. */
634async function mention($: EngineInterface): Promise<void> {
635 const current = await read($, data)
636 const shown = await read($, view)
637 const run = current.runs.find(r => r.id === shown.run)
638 if (run === undefined || shown.file === '') return
639 const filled = await $.prompt.fill({ text: `@${filePath(run, shown.file)} `, mode: 'append' }).catch(() => undefined)
640 if (filled?.isFilled !== true) {
641 fileNotice = "can't reach the prompt here"
642 $.ui.invalidate('ui.render')
643 }
644}
645
646function setNotices($: EngineInterface, runId: string, notices: Notice[]): Promise<unknown> {
647 return update($, actions, (a): ArchonActions => ({ ...a, notices: { ...a.notices, [runId]: notices } }))
648}
649
650/**
651 * Sends the confirmed action. Just before, the run is fetched again: one that
652 * no longer needs you gets nothing. The call goes where the run lives, with 30 s
653 * to reply; a refusal shows Archon's own words. Nothing here ever toasts.
654 */
655async function send($: EngineInterface, config: Config): Promise<void> {
656 const before = await read($, actions)
657 const pending = before.pending
658 if (pending === null || before.sending !== '') return
659 const runId = pending.runId
660 const text = before.text[runId] ?? ''
661 const word = pending.kind === 'answer' ? `Sending ${pending.decision}…` : pending.kind === 'resume' ? '… resuming' : '… abandoning'
662 await update($, actions, (a): ArchonActions => ({ ...a, pending: null, sending: runId, notices: { ...a.notices, [runId]: [{ text: word, tone: 'dim' }] } }))
663 try {
664 const got = await refreshRun($, config, runId)
665 const current = await read($, data)
666 const race = movedElsewhere(pending, got?.run, current.runs, got?.detail)
667 if (race !== undefined) {
668 await setNotices($, runId, [{ text: race, tone: 'dim' }])
669 return
670 }
671 const run = got?.run ?? current.runs.find(r => r.id === runId)
672 if (run === undefined) return
673 const reply = await deliver(io($), config.port, current.requirement.path, pending, run, text, current.source === 'server')
674 if (reply.kind === 'silent') {
675 await setNotices($, runId, [{ text: 'No reply from Archon in 30 s; it may still have gone through', tone: 'error' }])
676 return
677 }
678 if (reply.kind === 'refused') {
679 await setNotices($, runId, [{ text: reply.message, tone: 'error' }])
680 return
681 }
682 const now = await $.clock.now()
683 const isChatResume = pending.kind === 'resume' && reply.via === 'server'
684 const record = pending.kind === 'answer'
685 ? recordLine(pending.decision, pending.label, text, now)
686 : pending.kind === 'abandon' ? `✗ abandoned by you ${clockTime(now)}` : isChatResume ? `▶ sent to its chat ${clockTime(now)}` : `▶ resumed by you ${clockTime(now)}`
687 const isChecked = pending.kind !== 'abandon' && (reply.via === 'cli' || isChatResume)
688 await update($, actions, (a): ArchonActions => ({
689 ...a,
690 text: pending.kind === 'answer' ? { ...a.text, [runId]: '' } : a.text,
691 records: { ...a.records, [run.id]: [...(a.records[run.id] ?? []), record] },
692 notices: { ...a.notices, [runId]: reply.message === '' || reply.via === 'cli' ? [] : [{ text: reply.message, tone: 'dim' }] },
693 checks: isChecked ? { ...a.checks, [run.id]: { kind: pending.kind === 'answer' ? 'answer' : 'resume', at: now, polls: 0, log: reply.log, text, isChat: isChatResume } } : a.checks,
694 }))
695 // A --detach answer or resume is checked on once 30 s have passed; a chat resume over the next two polls.
696 if (isChecked && !isChatResume) $.clock.after(REPLY_MS, () => void checkMoved($, config, run.id).catch(report($)))
697 } finally {
698 await update($, actions, (a): ArchonActions => ({ ...a, sending: a.sending === runId ? '' : a.sending }))
699 await refreshRun($, config, runId)
700 }
701}
702
703/** 30 s after a `--detach` answer or resume: a gate still unanswered, or a run still paused, says so, and the buttons come back with the text. */
704async function checkMoved($: EngineInterface, config: Config, runId: string): Promise<void> {
705 const check = (await read($, actions)).checks[runId]
706 if (check === undefined) return
707 const got = await refreshRun($, config, runId)
708 const runs = (await read($, data)).runs
709 const moved = hasMoved(got?.run, runs, got?.detail, check.kind)
710 await update($, actions, (a): ArchonActions => {
711 const { [runId]: _done, ...checks } = a.checks
712 void _done
713 if (moved) return { ...a, checks }
714 const said = check.kind === 'answer' ? "Archon accepted the answer but hasn't recorded it" : "Archon accepted the resume but the run hasn't moved"
715 return {
716 ...a,
717 checks,
718 text: check.text === '' ? a.text : { ...a.text, [runId]: check.text },
719 notices: { ...a.notices, [runId]: [{ text: `${said}${check.log === '' ? '.' : `; its log is ${check.log}`}`, tone: 'error' }] },
720 }
721 })
722}
723
724/**
725 * After each poll: a pending confirmation for a run that no longer needs you
726 * is dropped, its typed text left on screen, dim, to copy; a resume sent to a
727 * chat that is still paused two polls later says to check the chat.
728 */
729async function settleActions($: EngineInterface): Promise<void> {
730 const current = await read($, data)
731 await update($, actions, (a): ArchonActions => {
732 let next = a
733 const pending = a.pending
734 if (pending !== null) {
735 const run = current.runs.find(r => r.id === pending.runId)
736 const race = movedElsewhere(pending, run, current.runs, current.details[pending.runId])
737 if (race !== undefined) {
738 const typed = a.text[pending.runId] ?? ''
739 next = { ...next, pending: null, notices: { ...next.notices, [pending.runId]: [{ text: race, tone: 'dim' }, ...(typed === '' ? [] : [{ text: typed, tone: 'dim' as const }])] } }
740 }
741 }
742 for (const [runId, check] of Object.entries(a.checks)) {
743 if (!check.isChat) continue
744 const run = current.runs.find(r => r.id === runId)
745 const polls = check.polls + 1
746 const { [runId]: _done, ...rest } = next.checks
747 void _done
748 if (run === undefined || run.status !== 'paused') next = { ...next, checks: rest }
749 else if (polls >= 2) next = { ...next, checks: rest, notices: { ...next.notices, [runId]: [{ text: 'still paused: check its chat', tone: 'dim' }] } }
750 else next = { ...next, checks: { ...next.checks, [runId]: { ...check, polls } } }
751 }
752 return next
753 })
754}
755
756/** The run that has needed you longest, a sub-run's gate counted like any other; undefined with none. */
757function longestWaiting(current: ArchonData): Run | undefined {
758 return sortRuns(topRuns(current.runs), current.runs, current.details).find(run => needsYou(run, current.runs, current.details) !== undefined)
759}
760
761/** Whether mod-settings is installed: the gear shows only then. A command list that cannot be read shows none. */
762async function isSettingsInstalled($: EngineInterface): Promise<boolean> {
763 return (await $.command.list().catch(() => [])).some(command => command.name === SETTINGS)
764}
765
766/**
767 * Rechecks the CLI and the project, then loads: at start, on attach, on reload
768 * and on r. A missing Requirement stops polling and takes down a status line
769 * set while the CLI was there.
770 */
771async function reload($: EngineInterface, config: Config): Promise<void> {
772 const project = await findProject($)
773 await update($, data, current => ({ ...current, project }))
774 if (await checkCli($, config)) await load($, config)
775 else {
776 stopPolling()
777 $.ui.status(undefined)
778 }
779}
780
781/** The start-up work: loads, then opens the pane unasked when `isPaneWanted` and this project has a live run. */
782async function startUp($: EngineInterface, config: Config, isPaneWanted: boolean): Promise<void> {
783 await reload($, config)
784 const current = await read($, data)
785 if (isPaneWanted && current.requirement.state === 'ok' && hasLiveRun(current)) await $.ui.open({ id: PANE, title: TITLE })
786}
787
788export const register: Register = (on, options) => {
789 const config = parseConfig(options)
790
791 on('session.start', async ($, e, next) => {
792 await $.command.register({ name: 'archon', description: "Open the Archon pane: this project's workflow runs, their graphs and logs, and the approvals that wait on you" })
793 const now = await $.clock.now()
794 await update($, startedAt, was => (was === 0 ? now : was))
795 // A reload killed any load in flight: drop its loading state and its result.
796 await update($, data, (current): ArchonData => ({
797 ...current,
798 status: 'idle',
799 loadId: current.loadId + 1,
800 }))
801 stopPolling()
802 // The follower died with the old module; the load below starts it again from line 1 if a live run's log is in front.
803 stopFollower()
804 for (const tab of Object.keys(furthest) as Tab[]) furthest[tab] = 0
805 // A headless session does nothing until a surface attaches (session.attach).
806 if (await isShown($)) {
807 const isFirst = !(await read($, hasStartedUp))
808 await update($, hasStartedUp, () => true)
809 void (isFirst ? startUp($, config, true) : reload($, config)).catch(report($))
810 }
811 return next(e)
812 })
813
814 on('session.attach', async ($, e, next) => {
815 attaches += 1
816 const done = await next(e)
817 let isFirst = false
818 await update($, hasStartedUp, was => {
819 isFirst = !was
820 return true
821 })
822 // A session that started headless catches up once. The pane opens unasked
823 // only on a surface that docks it beside the conversation; elsewhere, such as
824 // on a phone, it waits for /archon.
825 if (isFirst) void startUp($, config, e.viewport?.isFullscreen === true).catch(report($))
826 else if (timer === undefined) void reload($, config).catch(report($))
827 return done
828 })
829
830 on('session.detach', async ($, e, next) => {
831 const seen = attaches
832 const done = await next(e)
833 if (!(await isShown($)) && attaches === seen) {
834 stopPolling()
835 stopFollower()
836 }
837 return done
838 })
839
840 on('turn.complete', async ($, e, next) => {
841 const done = await next(e)
842 if (e.agentId !== undefined || !(await isShown($))) return done
843 const current = await read($, data)
844 if (current.requirement.state === 'ok' && (await $.clock.now()) - current.loadedAt > AFTER_TURN_MS) void load($, config).catch(report($))
845 return done
846 })
847
848 on('command.run', { command: 'archon' }, async $ => {
849 // Focus brings the pane in front of another mod's tab and gives it the keys.
850 await $.ui.open({ id: PANE, title: TITLE, focus: true })
851 void (async () => {
852 await reload($, config)
853 const longest = longestWaiting(await read($, data))
854 if (longest !== undefined) await pick($, config, longest)
855 else await changeView($, config, (v): ArchonView => ({ ...v, tab: 'runs' }))
856 })().catch(report($))
857 return { text: 'Archon pane opened.' }
858 })
859
860 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
861 // The engine's window stays at 0 so the pinned row never scrolls away; the shown sub-tab moves itself.
862 await update($, view, (v): ArchonView => {
863 const isLog = v.tab === 'log' || v.tab === 'archon-log'
864 const from = isLog && atEnd[v.tab as 'log'] ? furthest[v.tab] : v.at[v.tab]
865 const at = Math.min(Math.max(0, from + e.by), furthest[v.tab])
866 if (isLog) atEnd[v.tab as 'log'] = at >= furthest[v.tab]
867 return at === v.at[v.tab] ? v : { ...v, at: { ...v.at, [v.tab]: at } }
868 })
869 return next({ ...e, offset: 0 })
870 })
871
872 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
873 const ui = $.ui.resolve(e)
874 const { Box, Text } = ui
875 void loadIfForward($, config).catch(report($))
876 const current = await read($, data)
877 const shown = await read($, view)
878 const line = requirementLine(current.requirement)
879 const width = Math.max(16, e.props.bodyColumns)
880 const bodyRows = e.props.scroll.bodyRows
881 const serverLine = `Archon's server isn't answering on port ${config.port}, so runs come from the CLI every ${interval('cli', true, hasLiveRun(current)) / 1000} s. archon serve makes them faster.`
882 let lines: RenderElement[]
883 let isResumeShown = false
884 if (line !== undefined) lines = [<Text wrap="wrap">{line}</Text>]
885 else if (shown.tab === 'runs') {
886 lines = runsBody({
887 ui,
888 data: current,
889 view: shown,
890 now: await $.clock.now(),
891 width,
892 platform: await platformOf($),
893 serverLine,
894 linkBase: current.source === 'server' && e.surface !== 'mobile' ? `http://localhost:${config.port}/console/r/` : '',
895 onPick: run => void pick($, config, run).catch(report($)),
896 onFanout: id => void update($, view, (v): ArchonView => ({ ...v, fanouts: v.fanouts.includes(id) ? v.fanouts.filter(f => f !== id) : [...v.fanouts, id] })).catch(report($)),
897 })
898 } else if (shown.tab === 'graph') {
899 const run = current.runs.find(r => r.id === shown.run)
900 if (run !== undefined && current.graphs[run.id] === undefined) void ensureGraph($, run.id).catch(report($))
901 lines = graphBody({
902 ui,
903 run,
904 runs: current.runs,
905 details: current.details,
906 nodes: run === undefined ? undefined : current.graphs[run.id],
907 view: shown,
908 now: await $.clock.now(),
909 width,
910 link: run !== undefined && current.source === 'server' && e.surface !== 'mobile' ? `http://localhost:${config.port}/console/r/${run.id}` : '',
911 onPick: id => void pickNode($, config, shown.run, id).catch(report($)),
912 onFold: block => void update($, view, (v): ArchonView => ({ ...v, includes: v.includes.filter(entry => entry !== `${shown.run}:${block}`) })).catch(report($)),
913 onRound: round => void update($, view, (v): ArchonView => ({ ...v, round })).catch(report($)),
914 onParent: parent => void openGraph($, parent.id).catch(report($)),
915 onSubRun: subRun => void openGraph($, subRun.id).catch(report($)),
916 })
917 } else if (shown.tab === 'archon-log') {
918 const served = await read($, serveLog)
919 lines = served.isMissing
920 ? [<Text dimColor wrap="wrap">{`No Archon's log at ${config.archonLog}. Point Archon's log in the mod settings at the file archon serve is redirected to.`}</Text>]
921 : served.lines.map(text => {
922 const drawn = serveLine(text)
923 return <Text wrap="wrap" {...(drawn.tone === undefined ? {} : { color: drawn.tone })}>{drawn.text}</Text>
924 })
925 }
926 else {
927 const run = current.runs.find(r => r.id === shown.run)
928 const acts = await read($, actions)
929 const top = run === undefined ? undefined : topRuns(current.runs).find(t => needsYou(t, current.runs, current.details)?.holder.id === run.id)
930 const need = top === undefined ? undefined : needsYou(top, current.runs, current.details)
931 const link = run !== undefined && current.source === 'server' && e.surface !== 'mobile' ? `http://localhost:${config.port}/console/r/${run.id}` : ''
932 if (run !== undefined && need !== undefined && current.graphs[run.id] === undefined) void ensureGraph($, run.id).catch(report($))
933 const logLines = logBody({
934 ui,
935 run,
936 runs: current.runs,
937 detail: run === undefined ? undefined : current.details[run.id],
938 window: await read($, logWindow),
939 view: shown,
940 width,
941 link: run !== undefined && current.source === 'server' && e.surface !== 'mobile' ? `http://localhost:${config.port}/console/r/${run.id}` : '',
942 filesFolder: run === undefined ? '' : filePath(run, '').replace(/\/$/, ''),
943 file: opened,
944 notice: fileNotice,
945 onFold: key => void update($, view, (v): ArchonView => ({ ...v, fold: v.fold === key ? '' : key })).catch(report($)),
946 onAll: () => void showAll($, config).catch(report($)),
947 onFiles: () => void update($, view, (v): ArchonView => ({ ...v, isFilesOpen: !v.isFilesOpen })).catch(report($)),
948 onFile: path => void openFile($, config, path).catch(report($)),
949 onBack: () => void update($, view, (v): ArchonView => ({ ...v, file: '' })).catch(report($)),
950 onOpen: () => void openOutside($, e.surface).catch(report($)),
951 onMention: () => void mention($).catch(report($)),
952 })
953 const holderId = run?.id ?? ''
954 const notes: RenderElement[] = [
955 ...(acts.records[holderId] ?? []).map(record => <Text {...(record.startsWith('✗') ? { color: 'error' as const } : {})}>{record}</Text>),
956 ...(acts.notices[holderId] ?? []).map(notice => (notice.tone === 'error'
957 ? <Text color="error" wrap="wrap">{notice.text}</Text>
958 : notice.tone === 'dim' ? <Text dimColor wrap="wrap">{notice.text}</Text> : <Text wrap="wrap">{notice.text}</Text>)),
959 ]
960 if (run !== undefined && need !== undefined && shown.file === '') {
961 const set = (change: (a: ArchonActions) => ArchonActions) => void update($, actions, change).catch(report($))
962 const ask = (kind: Pending['kind'], decision = '', label = '') => set((a): ArchonActions => ({ ...a, pending: { runId: run.id, kind, decision, label } }))
963 isResumeShown = offersResume({ run: top!, need, detail: current.details[run.id], actions: acts })
964 const files = (current.details[run.id]?.files ?? []).map(file => (
965 <ui.Button key={`file-${file.path}`} plain label={`${file.path} ${Math.round(file.size / 1024) === 0 ? `${file.size} B` : `${Math.round(file.size / 1024)} KB`}`} onPress={() => void openFile($, config, file.path).catch(report($))} />
966 ))
967 lines = [
968 ...actionBody({
969 ui,
970 surface: e.surface,
971 run: top!,
972 need,
973 parent: current.runs.find(r => r.id === run.parentId),
974 detail: current.details[run.id],
975 graph: current.graphs[run.id],
976 actions: acts,
977 now: await $.clock.now(),
978 width,
979 isServer: current.source === 'server',
980 link,
981 isCut: shown.node !== '',
982 log: logLines.slice(1),
983 files,
984 onDecide: (decision, label) => ask('answer', decision, label),
985 onResume: () => ask('resume'),
986 onAbandon: () => ask('abandon'),
987 onConfirm: () => void send($, config).catch(report($)),
988 onBack: () => set((a): ArchonActions => ({ ...a, pending: null })),
989 onText: text => set((a): ArchonActions => ({ ...a, text: { ...a.text, [run.id]: text } })),
990 onAll: () => void showAll($, config).catch(report($)),
991 }),
992 ...notes,
993 ]
994 } else lines = [...logLines, ...notes]
995 }
996 // The logs follow their tail while at their end.
997 const last = lastPosition(lines.length, bodyRows)
998 const isLog = shown.tab === 'log' || shown.tab === 'archon-log'
999 const at = isLog && atEnd[shown.tab as 'log'] ? last : shown.at[shown.tab]
1000 furthest[shown.tab] = last
1001 return (
1002 <Box flexDirection="column" width={width}>
1003 {pinnedRow({
1004 ui,
1005 width,
1006 shown: shown.tab,
1007 live: liveCount(current.runs),
1008 needsYou: needsYouCount(current.runs, current.details),
1009 hasSettings: await isSettingsInstalled($),
1010 isReloadKey: !isResumeShown,
1011 onTab: tab => void changeView($, config, (v): ArchonView => ({ ...v, tab })).catch(report($)),
1012 onReload: () => void reload($, config).catch(report($)),
1013 onSettings: () => void $.command.run({ command: SETTINGS }).catch(report($)),
1014 })}
1015 {bodyWindow(ui, lines, at, bodyRows)}
1016 </Box>
1017 )
1018 })
1019}
1020hooks/actions.ts 200 lines1// Acting on a run that needs you: answering an approval, and resuming or
2// abandoning a run on action needed or left paused by a sub-run that ended.
3// Nothing else is ever done to a run. Each action goes where the run lives
4// (ADR-0006).
5
6import type { Detail, Gate, Pending, Run } from '../types'
7import { hasEnded, standing } from './runs'
8import { isTimedOut, within } from './source'
9import type { Io } from './source'
10import { clockTime, firstLine, lastLine } from './text'
11
12/** Letters an answer never takes: reload, all nodes, back, open, abandon, and approve's and reject's own. */
13const RESERVED = new Set(['r', 'a', 'b', 'o', 'x', 'y', 'n'])
14
15export type AnswerButton = {
16 decision: string
17 letter: string
18 label: string
19 isPrimary: boolean
20}
21
22/**
23 * One button per decision the gate declares, in the workflow's order and
24 * words: approve is `y`, reject is `n`, any other the first free letter of
25 * its label. The labels carry their consequences: what a reject cancels, and
26 * what a bare approve does at a loop gate whose round said it is done.
27 */
28export function answerButtons(gate: Gate, run: Run, parent: Run | undefined, text: string): AnswerButton[] {
29 const used = new Set<string>()
30 const letterOf = (id: string, label: string) => {
31 if (id === 'approve') return 'y'
32 if (id === 'reject') return 'n'
33 const free = Array.from(label.toLowerCase()).find(ch => /[a-z]/.test(ch) && !RESERVED.has(ch) && !used.has(ch))
34 return free ?? ''
35 }
36 return gate.decisions.map(decision => {
37 const letter = letterOf(decision.id, decision.label)
38 used.add(letter)
39 let label = decision.label
40 if (decision.id === 'reject') {
41 if (parent !== undefined) label = `${label} (cancels this sub-run; ${parent.workflow} stays paused)`
42 else if (!gate.hasRework) label = `${label} (cancels the run)`
43 }
44 if (decision.id === 'approve' && gate.type === 'interactive_loop' && gate.isRoundDone) label = text.trim() === '' ? 'Approve and finish' : 'Another round'
45 return { decision: decision.id, letter, label: letter === '' ? label : `${letter}: ${label}`, isPrimary: decision.id === 'approve' }
46 })
47}
48
49/** Where an action goes: the CLI, the server, or nowhere (a chat run's resume). */
50export type Route =
51 | { via: 'cli'; argv: string[] }
52 | { via: 'server'; path: string; body: string }
53 | { via: 'none' }
54
55/** A chat platform's name as the pane says it: `Slack`, `Telegram`, `GitHub`. */
56export function platformName(platform: string): string {
57 return platform === 'github' ? 'GitHub' : platform === '' ? '' : `${platform[0]!.toUpperCase()}${platform.slice(1)}`
58}
59
60/**
61 * Routes an action where the run lives (ADR-0006). A CLI-started run (no
62 * parent conversation) always goes through the CLI, answers and resumes
63 * `--detach`. A run from the web UI or a chat goes through the server while it
64 * answers, else the same CLI call. A chat run is never resumed from the pane,
65 * and abandon, with nothing to route back, goes through the CLI for it.
66 */
67export function route(pending: Pending, run: Run, text: string, isServer: boolean, archon: string): Route {
68 const viaServer = run.hasConversation && isServer
69 const id = run.id
70 if (pending.kind === 'answer') {
71 if (viaServer) {
72 if (pending.decision === 'approve') return { via: 'server', path: `/api/workflows/runs/${id}/approve`, body: JSON.stringify(text === '' ? {} : { comment: text }) }
73 if (pending.decision === 'reject') return { via: 'server', path: `/api/workflows/runs/${id}/reject`, body: JSON.stringify(text === '' ? {} : { reason: text }) }
74 return { via: 'server', path: `/api/workflows/runs/${id}/respond`, body: JSON.stringify(text === '' ? { decision: pending.decision } : { decision: pending.decision, text }) }
75 }
76 const verb = pending.decision === 'approve' || pending.decision === 'reject' ? [pending.decision, id] : ['respond', id, pending.decision]
77 return { via: 'cli', argv: [archon, 'workflow', ...verb, ...(text === '' ? [] : [text]), '--detach', '--json'] }
78 }
79 if (pending.kind === 'resume') {
80 if (run.platform !== '') return { via: 'none' }
81 if (viaServer) return { via: 'server', path: `/api/workflows/runs/${id}/resume`, body: '' }
82 return { via: 'cli', argv: [archon, 'workflow', 'resume', id, '--detach', '--json'] }
83 }
84 if (viaServer && run.platform === '') return { via: 'server', path: `/api/workflows/runs/${id}/abandon`, body: '' }
85 return { via: 'cli', argv: [archon, 'workflow', 'abandon', id, '--json'] }
86}
87
88/** How a sub-run ended, as a stranded parent's view says it. */
89export function strandHead(subRun: Run | undefined): string {
90 const name = subRun?.workflow ?? 'sub-run'
91 if (subRun?.status === 'cancelled') return `! Sub-run ${name} was cancelled${subRun.completedAt > 0 ? ` (rejected ${clockTime(subRun.completedAt)})` : ''}`
92 if (subRun?.status === 'failed') return `! Sub-run ${name} failed`
93 return `! Sub-run ${name} finished`
94}
95
96/** The resume button of a stranded parent, by how its sub-run ended. */
97export function strandResume(subRun: Run | undefined): string {
98 const name = subRun?.workflow ?? 'the sub-run'
99 if (subRun?.status === 'cancelled') return 'r Resume without it'
100 if (subRun?.status === 'failed') return `r Resume: run ${name} again, once`
101 return `r Resume: go on with ${name}'s output`
102}
103
104/** The confirming line of a stranded parent's resume. */
105export function strandConfirm(node: string, subRun: Run | undefined): string {
106 const name = subRun?.workflow ?? 'the sub-run'
107 if (subRun?.status === 'cancelled') return `Resume: node ${node} fails ("Sub-run '${name}' was cancelled") and the run goes on by its rules, which usually fail it.`
108 if (subRun?.status === 'failed') return `Resume: node ${node} runs ${name} again, once.`
109 return `Resume: node ${node} goes on with ${name}'s output.`
110}
111
112export const ABANDON_LINE = "Abandon: ends this run and anything it started. It can't be resumed."
113export const SERVER_DOWN_NOTE = "The server isn't answering, so the rest of this run won't show in its chat."
114
115/** What an answer that landed looks like: `✓ Approved 14:32 · "…"`, `✗ Rejected …`, other decisions by label. */
116export function recordLine(decision: string, label: string, text: string, at: number): string {
117 const said = text === '' ? '' : ` · "${text}"`
118 if (decision === 'reject') return `✗ Rejected ${clockTime(at)}${said}`
119 if (decision === 'approve') return `✓ Approved ${clockTime(at)}${said}`
120 return `✓ ${label.replace(/^[a-z]: /, '')} ${clockTime(at)}${said}`
121}
122
123/** The last answer the run's events record: the decision, when and the text. */
124export function lastAnswer(detail: Detail | undefined): { decision: string; at: number; text: string } | undefined {
125 const received = [...(detail?.events ?? [])].reverse().find(e => e.type === 'approval_received')
126 return received === undefined ? undefined : { decision: received.decision || 'approve', at: received.at, text: received.text }
127}
128
129/** Archon's own words from a CLI reply or a REST body: `{ok:false, error}`, `{error}`, `{message}`, else the last line; a refusal's `childRunId` names the sub-run to answer instead. */
130export function archonMessage(text: string): { ok: boolean | undefined; message: string; subRunId: string; logPath: string } {
131 try {
132 const parsed = JSON.parse(text) as Record<string, unknown>
133 const message = typeof parsed.error === 'string' ? parsed.error : typeof parsed.message === 'string' ? parsed.message : ''
134 return {
135 ok: typeof parsed.ok === 'boolean' ? parsed.ok : typeof parsed.success === 'boolean' ? parsed.success : undefined,
136 message,
137 subRunId: typeof parsed.childRunId === 'string' ? parsed.childRunId : '',
138 logPath: typeof parsed.logPath === 'string' ? parsed.logPath : '',
139 }
140 } catch {
141 return { ok: undefined, message: firstLine(text.split(/\r?\n/).reverse().join('\n')), subRunId: '', logPath: '' }
142 }
143}
144
145/** How long Archon has to reply to an action, and how long after a `--detach` answer or resume the run is checked on. */
146export const REPLY_MS = 30_000
147
148/** Whether a run, as fetched again, still waits on the pending action; else what happened elsewhere. */
149export function movedElsewhere(pending: Pending, run: Run | undefined, runs: readonly Run[], detail: Detail | undefined): string | undefined {
150 if (run === undefined) return undefined
151 if (hasEnded(run)) return 'Ended elsewhere'
152 const found = standing(run, runs, detail)
153 if (pending.kind === 'answer') {
154 if (found.kind === 'approval') return undefined
155 const answer = lastAnswer(detail)
156 return answer === undefined
157 ? 'Answered elsewhere'
158 : `Answered elsewhere · ${recordLine(answer.decision, answer.decision, answer.text, answer.at)}`
159 }
160 if (found.kind === 'action' || found.kind === 'stranded') return undefined
161 return run.status === 'paused' ? 'Answered elsewhere' : 'Resumed elsewhere'
162}
163
164export type Delivered =
165 | { kind: 'done'; via: 'cli' | 'server'; message: string; log: string }
166 | { kind: 'refused'; message: string }
167 | { kind: 'silent' }
168
169/** Makes the call the action routes to, raced against 30 s; a refusal naming a sub-run's id is followed to it once. */
170export async function deliver(io: Io, port: number, archon: string, pending: Pending, run: Run, text: string, isServer: boolean, isFollowed = false): Promise<Delivered> {
171 const routed = route(pending, run, text, isServer, archon)
172 if (routed.via === 'none') return { kind: 'refused', message: `resume it from ${platformName(run.platform)}` }
173 if (routed.via === 'cli') {
174 const out = await within(io, REPLY_MS, io.run(routed.argv, 2 * REPLY_MS)).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))))
175 if (isTimedOut(out)) return { kind: 'silent' }
176 if (out instanceof Error) return { kind: 'refused', message: out.message }
177 const said = archonMessage(out.stdout)
178 if (out.exitCode !== 0 || said.ok === false) return { kind: 'refused', message: said.message || lastLine(out.stderr) || `archon exited with ${out.exitCode}` }
179 return { kind: 'done', via: 'cli', message: said.message, log: said.logPath }
180 }
181 const reply = await within(io, REPLY_MS, io.fetch(`http://localhost:${port}${routed.path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: routed.body })).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))))
182 if (isTimedOut(reply)) return { kind: 'silent' }
183 if (reply instanceof Error) return { kind: 'refused', message: reply.message }
184 const said = archonMessage(reply.text)
185 if (!reply.ok) {
186 if (pending.kind === 'answer' && said.subRunId !== '' && !isFollowed) {
187 return deliver(io, port, archon, { ...pending, runId: said.subRunId }, { ...run, id: said.subRunId }, text, isServer, true)
188 }
189 return { kind: 'refused', message: said.message || `Archon answered ${reply.status}` }
190 }
191 return { kind: 'done', via: 'server', message: pending.kind === 'answer' ? said.message : '', log: '' }
192}
193
194/** Whether a run moved on from what a sent action left it waiting at. */
195export function hasMoved(run: Run | undefined, runs: readonly Run[], detail: Detail | undefined, kind: 'answer' | 'resume'): boolean {
196 if (run === undefined || hasEnded(run)) return true
197 const found = standing(run, runs, detail)
198 return kind === 'answer' ? found.kind !== 'approval' : run.status !== 'paused'
199}
200hooks/actions-view.tsx 179 lines1// What Log shows for a run that needs you: the approval view and its answer
2// area, or the resume and abandon view, each action through a confirming step.
3
4import type { Elements, RenderElement } from 'claude-code'
5
6import type { ArchonActions, Detail, GraphNode, Run } from '../types'
7import { ABANDON_LINE, answerButtons, platformName, SERVER_DOWN_NOTE, strandConfirm, strandHead, strandResume } from './actions'
8import { waitNode } from './runs'
9import type { NeedsYou } from './runs'
10import { shortId } from './runs-view'
11import { duration } from './text'
12
13/** The most of a node's output "what it asks about" shows: its last lines. */
14const ASKS_LINES = 10
15
16export type ActionProps = {
17 ui: Elements[keyof Elements]
18 surface: string
19 /** The run that needs you, and the run holding the gate (itself, or its sub-run). */
20 run: Run
21 need: NeedsYou
22 parent: Run | undefined
23 detail: Detail | undefined
24 graph: GraphNode[] | null | undefined
25 actions: ArchonActions
26 now: number
27 width: number
28 isServer: boolean
29 /** The run's page in Archon's web UI, or '' when it is not linked. */
30 link: string
31 /** Whether Log is cut to the gate's node; `a` widens what it asks about to the whole run log. */
32 isCut: boolean
33 /** The whole run log, drawn when `a` widened it. */
34 log: RenderElement[]
35 files: RenderElement[]
36 onDecide: (decision: string, label: string) => void
37 onResume: () => void
38 onAbandon: () => void
39 onConfirm: () => void
40 onBack: () => void
41 onText: (text: string) => void
42 onAll: () => void
43}
44
45/** The area under a run that needs you: notices, then the buttons, the confirming step, or what is being sent. */
46function answerArea(props: ActionProps): RenderElement[] {
47 const { ui, run, need, actions } = props
48 const { Box, Text, Button } = ui
49 // The phone draws no Input.
50 const Input = 'Input' in ui ? ui.Input : undefined
51 const holder = need.holder
52 const text = actions.text[holder.id] ?? ''
53 const lines: RenderElement[] = []
54 const pending = actions.pending?.runId === holder.id ? actions.pending : null
55 if (actions.sending === holder.id) return lines
56 const found = need.standing
57
58 if (pending !== null) {
59 let line: string
60 if (pending.kind === 'answer') {
61 line = `${pending.label.replace(/^[a-z]: /, '').replace(/ \(.*\)$/, '')} ${holder.workflow} ${shortId(holder.id)}${text === '' ? '' : ` · "${text}"`}`
62 if (holder.hasConversation && !props.isServer) line = `${line} ${SERVER_DOWN_NOTE}`
63 } else if (pending.kind === 'resume') {
64 line = found.kind === 'stranded' ? strandConfirm(found.gate.nodeId, found.subRun) : `Resume: the run carries on from ${waitNode(found)}.`
65 } else line = ABANDON_LINE
66 const name = pending.kind === 'answer' ? 'Send' : `${pending.kind === 'resume' ? 'Resume' : 'Abandon'} ${holder.workflow}`
67 lines.push(<Text wrap="wrap">{line}</Text>)
68 lines.push(
69 <Box key="confirming" flexDirection="row" columnGap={2}>
70 <Button key="confirm" variant="primary" autoFocus label={name} onPress={props.onConfirm} />
71 <Button key="back" hotkey="b" label="b: Back" onPress={props.onBack} />
72 </Box>,
73 )
74 return lines
75 }
76
77 if (found.kind === 'approval') {
78 const buttons = answerButtons(found.gate, holder, props.parent, text)
79 lines.push(
80 <Box key="answers" flexDirection="row" flexWrap="wrap" columnGap={2}>
81 {buttons.map(button => (button.letter === ''
82 ? <Button key={`decide-${button.decision}`} {...(button.isPrimary ? { variant: 'primary' as const } : {})} label={button.label} onPress={() => props.onDecide(button.decision, button.label)} />
83 : <Button key={`decide-${button.decision}`} hotkey={button.letter} {...(button.isPrimary ? { variant: 'primary' as const } : {})} label={button.label} onPress={() => props.onDecide(button.decision, button.label)} />))}
84 </Box>,
85 )
86 if (Input !== undefined && props.surface !== 'mobile') {
87 lines.push(<Input key="comment" label="Comment or reason (optional)" placeholder="Comment or reason (optional)" value={text} onInput={(value: string) => props.onText(value)} onSubmit={(value: string) => props.onText(value)} />)
88 } else lines.push(<Text dimColor>A comment needs the terminal or the desktop app.</Text>)
89 if (found.gate.type === 'container_writeback' && props.link !== '' && ui.Link !== undefined) {
90 const { Link } = ui
91 lines.push(<Link key="diff-link" href={props.link} label="↗ Archon, for the full diff" />)
92 }
93 return lines
94 }
95
96 if (found.kind === 'action' || found.kind === 'stranded') {
97 const row: RenderElement[] = []
98 if (run.platform !== '' || holder.platform !== '') row.push(<Text dimColor>{`resume it from ${platformName(holder.platform || run.platform)}`}</Text>)
99 else if (!isPathThere(props.detail)) row.push(<Text dimColor>{`can't resume: ${holder.workingPath} is gone`}</Text>)
100 else {
101 const label = found.kind === 'stranded' ? strandResume(found.subRun) : "r Resume: I've done it"
102 row.push(<Button key="resume" hotkey="r" variant="primary" label={label} onPress={props.onResume} />)
103 }
104 row.push(<Button key="abandon" hotkey="x" label="x Abandon run" onPress={props.onAbandon} />)
105 lines.push(<Box key="resume-row" flexDirection="row" flexWrap="wrap" columnGap={2}>{row}</Box>)
106 }
107 return lines
108}
109
110/** The node outputs a gate asks about: each node it waits on, its last part, under an `a` that widens it to the whole run log. */
111function asksAbout(props: ActionProps): RenderElement[] {
112 const { Box, Text, Button } = props.ui
113 const node = waitNode(props.need.standing)
114 const deps = props.graph?.find(n => n.id === node)?.deps ?? []
115 const events = props.detail?.events ?? []
116 const lines: RenderElement[] = [
117 <Box key="asks" flexDirection="row" columnGap={1}>
118 <Text dimColor>{`cut to ${node} ·`}</Text>
119 <Button key="asks-all" plain dimColor hotkey="a" label="whole run log" onPress={props.onAll} />
120 </Box>,
121 ]
122 for (const dep of deps) {
123 const output = [...events].reverse().find(e => e.step === dep && e.type === 'node_completed')?.output ?? ''
124 if (output === '') continue
125 lines.push(<Text bold>{dep}</Text>)
126 for (const line of output.replace(/\n+$/, '').split('\n').slice(-ASKS_LINES)) lines.push(<Text dimColor wrap="wrap">{line}</Text>)
127 }
128 const found = props.need.standing
129 if (found.kind === 'approval' && found.gate.type === 'interactive_loop') {
130 lines.push(<Text dimColor wrap="wrap">{found.gate.isRoundDone ? 'A bare approve finishes the loop; with a comment it runs another round.' : 'An approve runs another round, with your comment if you give one.'}</Text>)
131 }
132 return lines
133}
134
135/** The approval view, or the resume and abandon view, top to bottom. */
136const isPathThere = (detail: ActionProps['detail']): boolean => detail?.isWorkingPathThere !== false
137
138/**
139 * Whether the answer area offers Resume, which then holds the `r` key: the
140 * pinned row's reload gives it up (an answer's letter never takes `r`, but
141 * Resume is `r` by name).
142 */
143export function offersResume(props: Pick<ActionProps, 'run' | 'need' | 'detail' | 'actions'>): boolean {
144 const { standing, holder } = props.need
145 if (standing.kind !== 'action' && standing.kind !== 'stranded') return false
146 if (props.actions.pending?.runId === holder.id) return false
147 return props.run.platform === '' && holder.platform === '' && isPathThere(props.detail)
148}
149
150export function actionBody(props: ActionProps): RenderElement[] {
151 const { ui, need, now } = props
152 const { Box, Text, Markdown, Link } = ui
153 const holder = need.holder
154 const found = need.standing
155 const lines: RenderElement[] = []
156 const waited = duration(now - found.since)
157 if (found.kind === 'approval' || found.kind === 'unreadable') {
158 const name = `${props.parent === undefined ? '' : `${props.parent.workflow} › sub-run `}${holder.workflow}`
159 lines.push(
160 <Box key="loghead" flexDirection="row" columnGap={1}>
161 <Text bold wrap="truncate-end">{`${name} · ${shortId(holder.id)} · waited ${waited}`}</Text>
162 {props.link !== '' && <Link key="archon-link" href={props.link} label="↗ Archon" />}
163 </Box>,
164 )
165 if (found.kind === 'approval' && found.gate.message !== '') lines.push(<Markdown key="gate-message" text={found.gate.message} />)
166 if (found.kind === 'unreadable') lines.push(<Text color="warning" wrap="wrap">This gate is one Archon can't read; answer it in Archon.</Text>)
167 } else if (found.kind === 'action') {
168 lines.push(<Text bold color="warning">{`⏸ Action needed · waiting ${waited}`}</Text>)
169 lines.push(<Text wrap="wrap">{found.wait.message}</Text>)
170 } else {
171 lines.push(<Text bold color="warning">{strandHead(found.subRun)}</Text>)
172 lines.push(<Text>{`Node ${found.gate.nodeId} can't go on.`}</Text>)
173 }
174 lines.push(...(props.isCut ? asksAbout(props) : props.log))
175 lines.push(...props.files)
176 lines.push(...answerArea(props))
177 return lines
178}
179hooks/chrome.tsx 81 lines1// The pane's chrome: the pinned row of sub-tabs, and the body window under
2// it that the mod scrolls itself, so the row never scrolls away.
3
4import type { Elements, RenderElement } from 'claude-code'
5
6import type { Tab } from '../types'
7
8/** The settings dialog's command and its gear's key (ADR-0007). mod-settings takes the press; the gear's onPress is the fallback. */
9export const SETTINGS = 'mod-settings'
10/** Below about this many columns the sub-tab labels shorten. */
11const NARROW = 40
12
13export const TABS: readonly { tab: Tab; hotkey: string; label: string; short: string }[] = [
14 { tab: 'runs', hotkey: '1', label: 'Runs', short: 'Runs' },
15 { tab: 'graph', hotkey: '2', label: 'Graph', short: 'Graph' },
16 { tab: 'log', hotkey: '3', label: 'Log', short: 'Log' },
17 { tab: 'archon-log', hotkey: '4', label: "Archon's log", short: 'Arch' },
18]
19
20export type ChromeProps = {
21 ui: Elements[keyof Elements]
22 width: number
23 shown: Tab
24 /** This project's live runs, and how many need you. */
25 live: number
26 needsYou: number
27 hasSettings: boolean
28 /** False while a Resume button holds `r`. */
29 isReloadKey: boolean
30 onTab: (tab: Tab) => void
31 onReload: () => void
32 onSettings: () => void
33}
34
35/** The pinned row: exactly one line, drawn truncated, the gear at its right end. */
36export function pinnedRow(props: ChromeProps): RenderElement {
37 const { Box, Button } = props.ui
38 const isNarrow = props.width < NARROW
39 return (
40 <Box key="pinned" flexDirection="row" justifyContent="space-between" height={1} overflow="hidden">
41 <Box flexDirection="row" columnGap={isNarrow ? 1 : 2} flexShrink={1} overflow="hidden">
42 {TABS.map(({ tab, hotkey, label, short }) => {
43 const counted = tab === 'runs' ? `${props.live > 0 ? ` ${props.live}` : ''}${props.needsYou > 0 ? ` ⏸${props.needsYou}` : ''}` : ''
44 return (
45 <Button
46 key={`tab-${tab}`}
47 plain
48 hotkey={hotkey}
49 dimColor={tab !== props.shown}
50 label={`${isNarrow ? short : label}${counted}`}
51 onPress={() => props.onTab(tab)}
52 />
53 )
54 })}
55 </Box>
56 <Box flexDirection="row" columnGap={1} flexShrink={0}>
57 <Button key="reload" plain dimColor {...(props.isReloadKey ? { hotkey: 'r' } : {})} label="↻" onPress={props.onReload} />
58 {props.hasSettings && <Button key={SETTINGS} plain dimColor label="⚙️" onPress={props.onSettings} />}
59 </Box>
60 </Box>
61 )
62}
63
64/** The furthest a sub-tab can scroll: its last rows fill the window. */
65export function lastPosition(count: number, bodyRows: number): number {
66 return Math.max(0, count - Math.max(1, bodyRows - 1))
67}
68
69/**
70 * The rows the window shows: from `at`, sliced to the body's height under the
71 * pinned row, with a `… N more` line counted in that height.
72 */
73export function bodyWindow(ui: ChromeProps['ui'], lines: readonly RenderElement[], at: number, bodyRows: number): RenderElement[] {
74 const { Text } = ui
75 const room = Math.max(1, bodyRows - 1)
76 const from = Math.min(Math.max(0, at), lastPosition(lines.length, bodyRows))
77 if (lines.length - from <= room) return lines.slice(from)
78 const shown = lines.slice(from, from + room - 1)
79 return [...shown, <Text key="more" dimColor>… {lines.length - from - shown.length} more</Text>]
80}
81hooks/cli.ts 66 lines1// Finding and calling the Archon CLI, the pane's one Requirement.
2
3import type { Requirement } from '../types'
4
5/** What the CLI calls need from the engine: a run that rejects when the command cannot start. */
6export type Runner = (argv: readonly string[], timeoutMs?: number) => Promise<{ exitCode: number; stdout: string; stderr: string }>
7
8/** The oldest CLI with `workflow logs --follow`. */
9const MINIMUM = [0, 11, 0] as const
10
11export const NEEDS_CLI = 'The Archon pane needs the Archon CLI, v0.11.0 or later. Install it from https://archon.diy, or set its path in the mod settings, then press r.'
12
13export function needsNewer(version: string): string {
14 return `The Archon pane needs Archon v0.11.0 or later; this is ${version}. Update it, then press r.`
15}
16
17/** The line the pane shows for a Requirement not met; undefined when it is met or not yet checked. */
18export function requirementLine(requirement: Requirement): string | undefined {
19 if (requirement.state === 'missing') return NEEDS_CLI
20 if (requirement.state === 'old') return needsNewer(requirement.version)
21 return undefined
22}
23
24/** `v0.11.1` from what `archon --version` prints; '' when it names no version. */
25export function parseVersion(output: string): string {
26 const found = /v?(\d+)\.(\d+)\.(\d+)/.exec(output)
27 return found === null ? '' : `v${found[1]}.${found[2]}.${found[3]}`
28}
29
30function isNewEnough(version: string): boolean {
31 const parts = version.slice(1).split('.').map(Number)
32 for (let i = 0; i < MINIMUM.length; i++) {
33 const have = parts[i] ?? 0
34 if (have !== MINIMUM[i]) return have > MINIMUM[i]!
35 }
36 return true
37}
38
39/**
40 * Where to look for the CLI, in order: the setting, `archon` on Claude Code's
41 * PATH, then `<home>/.archon/bin/archon(.exe)`. The engine expands no `~` and
42 * sees no PATH set in `init.env`, so the last is an absolute path.
43 */
44export function candidates(setting: string, home: string, isWindows: boolean): string[] {
45 if (setting !== '') return [setting]
46 const found = ['archon']
47 if (home !== '') found.push(`${home.replace(/[\\/]+$/, '')}/.archon/bin/archon${isWindows ? '.exe' : ''}`)
48 return found
49}
50
51/** Checks the CLI: the first candidate that starts names the version; older than v0.11.0 counts as missing. */
52export async function findArchon(run: Runner, places: readonly string[]): Promise<Requirement> {
53 for (const path of places) {
54 let output
55 try {
56 output = await run([path, '--version'])
57 } catch {
58 continue
59 }
60 const version = parseVersion(`${output.stdout}\n${output.stderr}`)
61 if (output.exitCode !== 0 || version === '') continue
62 return { state: isNewEnough(version) ? 'ok' : 'old', path, version }
63 }
64 return { state: 'missing', path: '', version: '' }
65}
66hooks/graph.ts 384 lines1// A run's graph: its nodes read from its frozen workflow source, and their
2// layout as box-drawing text that gives way one step at a time when it is
3// wider than the pane (#165; the prototype on `prototype/165-graph-width` is
4// the reference for the layering, the lanes and the give-way steps).
5
6import type { GraphNode } from '../types'
7import { fit, glyph } from './text'
8import type { Look } from './text'
9import { parseYaml } from './yaml'
10
11const KINDS = ['command', 'prompt', 'bash', 'script', 'loop', 'loop_group', 'approval', 'cancel', 'wait', 'workflow', 'include']
12
13function isObject(value: unknown): value is Record<string, unknown> {
14 return typeof value === 'object' && value !== null && !Array.isArray(value)
15}
16
17function depsOf(value: unknown): string[] {
18 if (typeof value === 'string' && value !== '') return [value]
19 return Array.isArray(value) ? value.filter((dep): dep is string => typeof dep === 'string' && dep !== '') : []
20}
21
22function rawNodes(source: unknown): Record<string, unknown>[] {
23 return isObject(source) && Array.isArray(source.nodes) ? source.nodes.filter(isObject).filter(node => typeof node.id === 'string') : []
24}
25
26function readNode(node: Record<string, unknown>): GraphNode {
27 const kind = KINDS.find(k => node[k] !== undefined) ?? 'command'
28 const group = isObject(node.loop_group) ? node.loop_group : {}
29 return {
30 id: String(node.id),
31 deps: depsOf(node.depends_on),
32 kind,
33 block: '',
34 workflow: kind === 'workflow' && typeof node.workflow === 'string' ? node.workflow : '',
35 body: rawNodes(group).map(readNode),
36 }
37}
38
39/**
40 * The nodes of a workflow, with every plain `include:` expanded at load time
41 * as Archon does: `<include>__<node>`, the include's own waits attached to the
42 * block's entry nodes, and a node waiting on the include waiting on the
43 * block's end nodes. `find` reads another workflow of the same frozen source
44 * by name; an include it cannot find stays one node.
45 */
46export function workflowNodes(yaml: string, find: (name: string) => string | undefined, depth = 0): GraphNode[] {
47 const nodes = rawNodes(parseYaml(yaml))
48 const out: GraphNode[] = []
49 const ends = new Map<string, string[]>()
50 for (const raw of nodes) {
51 const node = readNode(raw)
52 const target = typeof raw.include === 'string' && raw.fan_out === undefined && depth < 3 ? find(raw.include) : undefined
53 if (target === undefined) {
54 out.push(node)
55 continue
56 }
57 const inner = workflowNodes(target, find, depth + 1)
58 const ids = new Set(inner.map(n => n.id))
59 const waitedOn = new Set(inner.flatMap(n => n.deps))
60 for (const n of inner) {
61 const own = n.deps.filter(dep => ids.has(dep)).map(dep => `${node.id}__${dep}`)
62 out.push({ ...n, id: `${node.id}__${n.id}`, deps: own.length === 0 ? node.deps : own, block: n.block === '' ? node.id : `${node.id}__${n.block}` })
63 }
64 ends.set(node.id, inner.filter(n => !waitedOn.has(n.id)).map(n => `${node.id}__${n.id}`))
65 }
66 return out.map(node => ({ ...node, deps: node.deps.flatMap(dep => ends.get(dep) ?? [dep]) }))
67}
68
69/** What reading a frozen workflow source needs: a folder's entries and a file's text, each of which may throw. */
70export type SourceIo = {
71 list: (path: string) => Promise<readonly { name: string; kind: string }[]>
72 read: (path: string) => Promise<string>
73}
74
75/** The files under `root` whose name ends `.yaml` or `.yml`, a few folders deep. */
76async function yamlFiles(io: SourceIo, root: string, depth = 0): Promise<string[]> {
77 const entries = await io.list(root).catch(() => [])
78 const found: string[] = []
79 for (const entry of entries) {
80 const path = `${root}/${entry.name}`
81 if (entry.kind === 'dir' && depth < 5) found.push(...(await yamlFiles(io, path, depth + 1)))
82 else if (entry.kind === 'file' && /\.ya?ml$/i.test(entry.name)) found.push(path)
83 }
84 return found
85}
86
87/** How a workflow of a frozen source ranks when two share a name: a project's over a global one over a bundled one. */
88function rankOf(path: string): number {
89 return /\/bundled\//.test(path) ? 0 : /\/global\//.test(path) ? 1 : 2
90}
91
92/**
93 * A run's nodes, read from its frozen workflow source
94 * (`metadata.workflow_source.root`), which never changes: the workflow
95 * `manifest.json` names (else `workflow`), and any workflow it includes.
96 * Null when the source has no such workflow or can't be read.
97 */
98export async function readWorkflow(io: SourceIo, sourceRoot: string, workflow: string): Promise<GraphNode[] | null> {
99 const root = sourceRoot.replace(/\\/g, '/').replace(/\/+$/, '')
100 if (root === '') return null
101 const manifest = await io.read(`${root}/manifest.json`).then(text => JSON.parse(text) as { workflow_name?: unknown }).catch(() => undefined)
102 const name = typeof manifest?.workflow_name === 'string' ? manifest.workflow_name : workflow
103 const files = await yamlFiles(io, root)
104 const texts = new Map<string, string>()
105 const textOf = async (path: string) => {
106 if (!texts.has(path)) texts.set(path, await io.read(path).catch(() => ''))
107 return texts.get(path)!
108 }
109 const nameIn = (text: string) => /^name:\s*["']?([^"'\n#]+?)["']?\s*$/m.exec(text)?.[1] ?? ''
110 // A workflow's file is usually named for it; any other file is read only when that misses.
111 const byName = new Map<string, { text: string; rank: number }>()
112 const consider = (path: string, text: string) => {
113 const found = nameIn(text)
114 const known = byName.get(found)
115 if (found !== '' && (known === undefined || rankOf(path) > known.rank)) byName.set(found, { text, rank: rankOf(path) })
116 }
117 const base = (path: string) => path.slice(path.lastIndexOf('/') + 1).replace(/\.ya?ml$/i, '')
118 for (const path of files) if (base(path) === name || /^archon-/.test(base(path))) consider(path, await textOf(path))
119 if (!byName.has(name)) for (const path of files) consider(path, await textOf(path))
120 const main = byName.get(name)?.text
121 if (main === undefined) return null
122 const sync = new Map([...byName].map(([key, value]) => [key, value.text]))
123 return workflowNodes(main, wanted => sync.get(wanted))
124}
125
126/** One box of the drawn graph. */
127export type DrawNode = {
128 id: string
129 /** What the box names: the node, or a folded block's, loop's or sub-run's summary. */
130 label: string
131 look: Look
132 deps: string[]
133 /** A gate waiting on the person: drawn with a bold border in `warning`. */
134 isGate: boolean
135 /** The include block it came from, whose prefix step 2 leaves out. */
136 block: string
137}
138
139/** A run of text in one drawn line, with how it looks and what a press on it picks. */
140export type Seg = {
141 text: string
142 tone?: 'success' | 'error' | 'warning'
143 dim?: boolean
144 bold?: boolean
145 /** The node a press on this segment picks. */
146 pick?: string
147}
148
149export type Drawn = {
150 lines: Seg[][]
151 /** The give-way step that fit: 1 whole names, 2 no include prefix, 3 names cut to 10, 4 skip edges as notes, 5 a list. */
152 step: 1 | 2 | 3 | 4 | 5
153}
154
155type Item = { kind: 'node'; id: string; node: DrawNode; name: string } | { kind: 'lane'; id: string }
156
157function toneOf(look: Look): Seg['tone'] {
158 if (look === 'running' || look === 'completed') return 'success'
159 if (look === 'failed') return 'error'
160 if (look === 'approval' || look === 'action' || look === 'unreadable') return 'warning'
161 return undefined
162}
163
164function depthsOf(nodes: readonly DrawNode[]): Map<string, number> {
165 const byId = new Map(nodes.map(node => [node.id, node]))
166 const depth = new Map<string, number>()
167 const of = (id: string, seen: Set<string>): number => {
168 const known = depth.get(id)
169 if (known !== undefined) return known
170 const node = byId.get(id)
171 if (node === undefined || seen.has(id)) return 0
172 seen.add(id)
173 const deps = node.deps.filter(dep => byId.has(dep))
174 const value = deps.length === 0 ? 0 : 1 + Math.max(...deps.map(dep => of(dep, seen)))
175 depth.set(id, value)
176 return value
177 }
178 for (const node of nodes) of(node.id, new Set())
179 return depth
180}
181
182const width = (name: string) => Array.from(name).length
183
184function itemWidth(item: Item): number {
185 return item.kind === 'lane' ? 1 : width(item.name) + 6
186}
187
188function layerWidth(items: readonly Item[]): number {
189 return items.reduce((sum, item, i) => sum + itemWidth(item) + (i === 0 ? 0 : 1), 0)
190}
191
192/** The name a box shows at a give-way step. */
193function nameAt(node: DrawNode, step: number): string {
194 const name = step >= 2 && node.block !== '' && node.label.startsWith(`${node.block}__`) ? node.label.slice(node.block.length + 2) : node.label
195 return step >= 3 ? fit(name, 10) : name
196}
197
198type Plan = { layers: Item[][]; edges: [string, string][]; notes: Map<number, string[]> }
199
200function plan(nodes: readonly DrawNode[], step: number): Plan {
201 const depth = depthsOf(nodes)
202 const ids = new Set(nodes.map(node => node.id))
203 const deepest = Math.max(0, ...depth.values())
204 const layers: Item[][] = Array.from({ length: deepest + 1 }, () => [])
205 for (const node of nodes) layers[depth.get(node.id) ?? 0]!.push({ kind: 'node', id: node.id, node, name: nameAt(node, step) })
206 const edges: [string, string][] = []
207 const notes = new Map<number, string[]>()
208 for (const node of nodes) {
209 for (const dep of node.deps.filter(d => ids.has(d))) {
210 const from = depth.get(dep)!
211 const to = depth.get(node.id)!
212 if (to - from === 1) edges.push([dep, node.id])
213 else if (step <= 3) {
214 // A lane: a one-column stand-in in each layer the edge passes.
215 let previous = dep
216 for (let layer = from + 1; layer < to; layer++) {
217 const id = `~${dep}>${node.id}@${layer}`
218 layers[layer]!.push({ kind: 'lane', id })
219 edges.push([previous, id])
220 previous = id
221 }
222 edges.push([previous, node.id])
223 } else {
224 notes.set(to, [...(notes.get(to) ?? []), `↑ ${node.id} also waits on ${dep}`])
225 }
226 }
227 }
228 // Each layer in the mean order of what it waits on, so edges cross least.
229 for (let layer = 1; layer < layers.length; layer++) {
230 const above = new Map(layers[layer - 1]!.map((item, i) => [item.id, i]))
231 const mean = (item: Item) => {
232 const at = edges.filter(([, to]) => to === item.id).map(([from]) => above.get(from) ?? 0)
233 return at.length === 0 ? 0 : at.reduce((a, b) => a + b, 0) / at.length
234 }
235 layers[layer] = layers[layer]!.map((item, i) => ({ item, m: mean(item), i })).sort((a, b) => a.m - b.m || a.i - b.i).map(entry => entry.item)
236 }
237 return { layers, edges, notes }
238}
239
240const CORNERS: Record<string, string> = {
241 udlr: '┼', udl: '┤', udr: '├', ulr: '┴', dlr: '┬', ud: '│', lr: '─',
242 ul: '┘', ur: '└', dl: '┐', dr: '┌', u: '│', d: '│', l: '─', r: '─', '': ' ',
243}
244
245function graphLines({ layers, edges, notes }: Plan): Seg[][] {
246 const total = Math.max(...layers.map(layerWidth))
247 const centre = new Map<string, number>()
248 const out: Seg[][] = []
249 const hasOut = new Set(edges.map(([from]) => from))
250 const hasIn = new Set(edges.map(([, to]) => to))
251
252 layers.forEach((items, l) => {
253 const own = layerWidth(items)
254 let x = Math.floor((total - own) / 2)
255 if (l > 0) {
256 // Line the layer up under its parents' mean centre.
257 const ids = new Set(items.map(item => item.id))
258 const parents = edges.filter(([from, to]) => ids.has(to) && centre.has(from)).map(([from]) => centre.get(from)!)
259 if (parents.length > 0) {
260 let rx = 0
261 const centres = items.map(item => {
262 const w = itemWidth(item)
263 const c = rx + Math.floor(w / 2)
264 rx += w + 1
265 return c
266 })
267 const mean = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length
268 x = Math.max(0, Math.min(total - own, Math.round(mean(parents) - mean(centres))))
269 }
270 }
271 const placed = items.map(item => {
272 const w = itemWidth(item)
273 const at = { item, x, w }
274 centre.set(item.id, x + Math.floor(w / 2))
275 x += w + 1
276 return at
277 })
278
279 if (l > 0) {
280 // The connector row: unrelated edge groups get their own spans, and crossings draw ┼.
281 const arms = Array.from({ length: total }, () => ({ u: false, d: false, l: false, r: false }))
282 const above = new Set(layers[l - 1]!.map(item => item.id))
283 const mine = new Set(items.map(item => item.id))
284 const here = edges.filter(([from, to]) => above.has(from) && mine.has(to))
285 const parent = new Map<string, string>()
286 const find = (a: string): string => {
287 const p = parent.get(a) ?? a
288 return p === a ? a : find(p)
289 }
290 for (const [from, to] of here) parent.set(find(to), find(from))
291 const groups = new Map<string, [string, string][]>()
292 for (const edge of here) groups.set(find(edge[0]), [...(groups.get(find(edge[0])) ?? []), edge])
293 for (const group of groups.values()) {
294 const ups = new Set(group.map(([from]) => centre.get(from)!))
295 const downs = new Set(group.map(([, to]) => centre.get(to)!))
296 const xs = [...ups, ...downs]
297 const lo = Math.min(...xs)
298 const hi = Math.max(...xs)
299 for (let i = lo; i <= hi; i++) {
300 const arm = arms[i]!
301 if (ups.has(i)) arm.u = true
302 if (downs.has(i)) arm.d = true
303 if (i > lo) arm.l = true
304 if (i < hi) arm.r = true
305 }
306 }
307 const key = (a: { u: boolean; d: boolean; l: boolean; r: boolean }) => (a.u ? 'u' : '') + (a.d ? 'd' : '') + (a.l ? 'l' : '') + (a.r ? 'r' : '')
308 out.push([{ text: arms.map(arm => CORNERS[key(arm)]!).join('').trimEnd() }])
309 }
310
311 // Three rows of boxes, each row cut into segments so a box takes its state's colour.
312 for (let r = 0; r < 3; r++) {
313 const segs: Seg[] = []
314 let cursor = 0
315 for (const { item, x: bx, w } of placed) {
316 if (bx > cursor) segs.push({ text: ' '.repeat(bx - cursor) })
317 if (item.kind === 'lane') {
318 segs.push({ text: '│' })
319 cursor = bx + 1
320 continue
321 }
322 const node = item.node
323 const mid = Math.floor(w / 2)
324 const [h, v, tl, tr, bl, br] = node.isGate ? ['━', '┃', '┏', '┓', '┗', '┛'] : ['─', '│', '┌', '┐', '└', '┘']
325 const border: Seg = { text: '', ...(node.isGate ? { tone: 'warning', bold: true } : {}) }
326 if (r === 0) {
327 const top = [...`${tl}${h.repeat(w - 2)}${tr}`]
328 if (hasIn.has(item.id)) top[mid] = node.isGate ? '┻' : '┴'
329 segs.push({ ...border, text: top.join('') })
330 } else if (r === 2) {
331 const bottom = [...`${bl}${h.repeat(w - 2)}${br}`]
332 if (hasOut.has(item.id)) bottom[mid] = node.isGate ? '┳' : '┬'
333 segs.push({ ...border, text: bottom.join('') })
334 } else {
335 const name = item.name + ' '.repeat(Math.max(0, w - 6 - width(item.name)))
336 const tone = toneOf(node.look)
337 segs.push({ ...border, text: `${v} ` })
338 segs.push({ text: glyph(node.look), ...(tone === undefined ? {} : { tone }), dim: node.look === 'cancelled' || node.look === 'skipped' })
339 segs.push({ text: ` ${name}`, pick: node.id })
340 segs.push({ ...border, text: ` ${v}` })
341 }
342 cursor = bx + w
343 }
344 out.push(segs)
345 }
346 for (const note of notes.get(l) ?? []) out.push([{ text: ` ${note}`, dim: true }])
347 })
348 return out
349}
350
351function listLines({ layers, notes }: Plan): Seg[][] {
352 const out: Seg[][] = []
353 layers.forEach((items, l) => {
354 const nodes = items.flatMap(item => (item.kind === 'node' ? [item.node] : []))
355 nodes.forEach((node, i) => {
356 const mark = nodes.length === 1 ? '' : i === nodes.length - 1 ? '└ ' : '├ '
357 const tone = toneOf(node.look)
358 out.push([
359 { text: `${mark}` },
360 { text: glyph(node.look), ...(tone === undefined ? {} : { tone }), ...(node.isGate ? { bold: true } : {}) },
361 { text: ` ${nameAt(node, 2)}`, pick: node.id },
362 ].filter(seg => seg.text !== ''))
363 })
364 for (const note of notes.get(l) ?? []) out.push([{ text: ` ${note}`, dim: true }])
365 })
366 return out
367}
368
369/**
370 * Lays the nodes out in layers by depth, giving way one step at a time while
371 * the widest layer is wider than `columns`: whole names, names without their
372 * include prefix, names cut to 10 characters, skip edges as notes instead of
373 * lanes, then the whole run as a list. A wide layer is never stacked into one
374 * box, and a skip edge is never dropped.
375 */
376export function layout(nodes: readonly DrawNode[], columns: number): Drawn {
377 if (nodes.length === 0) return { lines: [], step: 1 }
378 for (const step of [1, 2, 3, 4] as const) {
379 const planned = plan(nodes, step)
380 if (Math.max(...planned.layers.map(layerWidth)) <= columns) return { lines: graphLines(planned), step }
381 }
382 return { lines: listLines(plan(nodes, 4)), step: 5 }
383}
384hooks/graph-view.tsx 218 lines1// The Graph sub-tab: the picked run's nodes as box-drawing text, include
2// blocks, loop groups and sub-runs folded, each box a press that opens Log.
3
4import type { Elements, RenderElement } from 'claude-code'
5
6import type { ArchonView, Detail, GraphNode, Run, RunEvent } from '../types'
7import { layout } from './graph'
8import type { DrawNode, Seg } from './graph'
9import { hasEnded, needsYou, standing, subRunsOf, waitNode } from './runs'
10import type { Look } from './text'
11import { duration, fit } from './text'
12
13/** A node's look from the events naming it: the last of started, completed, failed, skipped. */
14function lookFrom(events: readonly RunEvent[], step: string, iteration?: number): Look {
15 let look: Look = 'pending'
16 for (const e of events) {
17 if (e.step !== step || (iteration !== undefined && e.iteration !== iteration)) continue
18 if (e.type === 'node_started') look = 'running'
19 else if (e.type === 'node_completed') look = 'completed'
20 else if (e.type === 'node_failed') look = 'failed'
21 else if (e.type === 'node_skipped') look = 'skipped'
22 else if (e.type === 'node_suspended') look = 'paused'
23 }
24 return look
25}
26
27/** How a set of looks reads as one: any failure, then any work, then all done, else waiting. */
28function foldLook(looks: readonly Look[]): Look {
29 if (looks.some(look => look === 'failed')) return 'failed'
30 if (looks.some(look => look === 'running' || look === 'paused' || look === 'approval' || look === 'action')) return 'running'
31 if (looks.length > 0 && looks.every(look => look === 'completed' || look === 'skipped')) return 'completed'
32 return 'pending'
33}
34
35const isDone = (look: Look) => look === 'completed' || look === 'skipped'
36
37/** The rounds a loop group has started, from its body's events. */
38export function roundsOf(events: readonly RunEvent[], group: string): number {
39 return Math.max(0, ...events.filter(e => e.step.startsWith(`${group}.`)).map(e => e.iteration))
40}
41
42function subRunLook(subRun: Run, runs: readonly Run[], details: Readonly<Record<string, Detail>>): { look: Look; isGate: boolean } {
43 if (needsYou(subRun, runs, details) !== undefined) return { look: 'approval', isGate: true }
44 return { look: subRun.status === 'paused' ? 'paused' : subRun.status, isGate: false }
45}
46
47/** The sub-runs a `workflow:` node started. */
48export function nodeSubRuns(run: Run, node: GraphNode, runs: readonly Run[], workflowNodes: number): Run[] {
49 return subRunsOf(run, runs).filter(subRun => subRun.parentNodeId === node.id || (subRun.parentNodeId === '' && workflowNodes === 1))
50}
51
52export type Graphed = {
53 draw: DrawNode[]
54 /** What a press on each box picks. */
55 picks: Map<string, { kind: 'node' | 'block' | 'loop' | 'workflow'; id: string; subRuns: Run[] }>
56}
57
58/** The boxes of a run's graph: node states from its events, gates in `warning`, blocks and loops folded unless opened. */
59export function drawNodes(nodes: readonly GraphNode[], run: Run, runs: readonly Run[], details: Readonly<Record<string, Detail>>, view: ArchonView): Graphed {
60 const events = details[run.id]?.events ?? []
61 const found = standing(run, runs, details[run.id])
62 const gateNode = waitNode(found)
63 const picks: Graphed['picks'] = new Map()
64 const workflows = nodes.filter(node => node.kind === 'workflow').length
65 const looks = new Map<string, DrawNode>()
66 for (const node of nodes) {
67 let look = lookFrom(events, node.id)
68 let label = node.id
69 let isGate = node.id === gateNode
70 if (isGate) look = found.kind === 'approval' ? 'approval' : 'action'
71 picks.set(node.id, { kind: 'node', id: node.id, subRuns: [] })
72 if (node.kind === 'loop_group') {
73 const round = roundsOf(events, node.id)
74 if (round > 0) look = foldLook(node.body.map(body => lookFrom(events, `${node.id}.${body.id}`, round)))
75 label = round > 0 ? `${node.id} ⟳${round}` : node.id
76 picks.set(node.id, { kind: 'loop', id: node.id, subRuns: [] })
77 } else if (node.kind === 'workflow') {
78 const subRuns = nodeSubRuns(run, node, runs, workflows)
79 picks.set(node.id, { kind: 'workflow', id: node.id, subRuns })
80 if (subRuns.length === 1) {
81 const shown = subRunLook(subRuns[0]!, runs, details)
82 look = shown.look
83 isGate = isGate || shown.isGate
84 label = `↳ ${subRuns[0]!.workflow}`
85 } else if (subRuns.length > 1) {
86 const subLooks = subRuns.map(subRun => subRunLook(subRun, runs, details))
87 look = foldLook(subLooks.map(s => s.look))
88 isGate = isGate || subLooks.some(s => s.isGate)
89 label = `↳ ${subRuns.filter(subRun => hasEnded(subRun)).length}/${subRuns.length}`
90 } else label = `↳ ${node.workflow || node.id}`
91 }
92 looks.set(node.id, { id: node.id, label, look, deps: node.deps, isGate, block: node.block })
93 }
94
95 // Include blocks fold to one box unless opened: `deliver 3/5`.
96 const blockOf = (node: DrawNode) => node.block.split('__')[0] ?? ''
97 const folded = new Set(nodes.map(node => blockOf(looks.get(node.id)!)).filter(block => block !== '' && !view.includes.includes(`${run.id}:${block}`)))
98 const out: DrawNode[] = []
99 const placed = new Set<string>()
100 for (const node of nodes) {
101 const drawn = looks.get(node.id)!
102 const block = blockOf(drawn)
103 if (!folded.has(block)) {
104 out.push(drawn)
105 continue
106 }
107 if (placed.has(block)) continue
108 placed.add(block)
109 const members = [...looks.values()].filter(n => blockOf(n) === block)
110 const ids = new Set(members.map(n => n.id))
111 const done = members.filter(n => isDone(n.look)).length
112 out.push({
113 id: `block:${block}`,
114 label: `${block} ${done}/${members.length}`,
115 look: foldLook(members.map(n => n.look)),
116 deps: [...new Set(members.flatMap(n => n.deps).filter(dep => !ids.has(dep)))],
117 isGate: members.some(n => n.isGate),
118 block: '',
119 })
120 picks.set(`block:${block}`, { kind: 'block', id: block, subRuns: [] })
121 }
122 // Waits on a folded block's nodes wait on the block.
123 const boxOf = (id: string) => {
124 const drawn = looks.get(id)
125 const block = drawn === undefined ? '' : blockOf(drawn)
126 return folded.has(block) ? `block:${block}` : id
127 }
128 return {
129 draw: out.map(node => ({ ...node, deps: [...new Set(node.deps.map(boxOf).filter(dep => dep !== node.id))] })),
130 picks,
131 }
132}
133
134/** A loop group's body as one round drew it: the current one, or an earlier one picked with ‹ ›. */
135export function roundNodes(group: GraphNode, events: readonly RunEvent[], round: number): DrawNode[] {
136 return group.body.map(body => ({ id: `${group.id}.${body.id}`, label: body.id, look: lookFrom(events, `${group.id}.${body.id}`, round), deps: body.deps.map(dep => `${group.id}.${dep}`), isGate: false, block: '' }))
137}
138
139export type GraphProps = {
140 ui: Elements[keyof Elements]
141 run: Run | undefined
142 runs: readonly Run[]
143 details: Readonly<Record<string, Detail>>
144 nodes: GraphNode[] | null | undefined
145 view: ArchonView
146 now: number
147 width: number
148 /** The run's page in Archon's web UI, or '' when it is not linked. */
149 link: string
150 onPick: (id: string) => void
151 onFold: (block: string) => void
152 onRound: (round: number) => void
153 onParent: (parent: Run) => void
154 onSubRun: (subRun: Run) => void
155}
156
157/** The drawn lines of a graph as elements: a box's name is a press that picks it. */
158export function segLines(ui: GraphProps['ui'], lines: readonly Seg[][], onPick: (id: string) => void, prefix = 'node'): RenderElement[] {
159 const { Box, Text, Button } = ui
160 return lines.map(line => (
161 <Box flexDirection="row">
162 {line.map(seg => (seg.pick !== undefined
163 ? <Button key={`${prefix}-${seg.pick}`} plain label={seg.text} onPress={() => onPick(seg.pick!)} />
164 : <Text {...(seg.tone === undefined ? {} : { color: seg.tone })} {...(seg.bold === true ? { bold: true } : {})} {...(seg.dim === true ? { dimColor: true } : {})}>{seg.text}</Text>))}
165 </Box>
166 ))
167}
168
169export function graphBody(props: GraphProps): RenderElement[] {
170 const { ui, run, view, width } = props
171 const { Box, Text, Button, Link } = ui
172 if (run === undefined) return [<Text dimColor>Pick a run in Runs to see its graph.</Text>]
173 const parent = props.runs.find(other => other.id === run.parentId)
174 const lines: RenderElement[] = []
175 const status = `${run.status} ${duration((hasEnded(run) ? run.completedAt : props.now) - run.startedAt)}`
176 lines.push(
177 <Box flexDirection="row" columnGap={1}>
178 {parent !== undefined && <Button key="graph-parent" plain dimColor hotkey="b" label={`‹ ${parent.workflow}`} onPress={() => props.onParent(parent)} />}
179 <Text bold wrap="truncate-end">{fit(`${parent === undefined ? '' : `${parent.workflow} › `}${run.workflow} ${status}`, Math.max(8, width - 12))}</Text>
180 {props.link !== '' && <Link key="archon-link" href={props.link} label="↗ Archon" />}
181 </Box>,
182 )
183 if (props.nodes === undefined) return [...lines, <Text dimColor>Reading the run's workflow…</Text>]
184 if (props.nodes === null || props.nodes.length === 0) return [...lines, <Text dimColor>This run's workflow source can't be read.</Text>]
185
186 const graphed = drawNodes(props.nodes, run, props.runs, props.details, view)
187 for (const block of view.includes.filter(entry => entry.startsWith(`${run.id}:`)).map(entry => entry.slice(run.id.length + 1))) {
188 lines.push(<Button key={`fold-${block}`} plain dimColor label={`▾ ${block}`} onPress={() => props.onFold(block)} />)
189 }
190 lines.push(...segLines(ui, layout(graphed.draw, width).lines, props.onPick))
191
192 // A picked loop group: its round's body as a small graph, stepping through earlier rounds.
193 const group = view.loop.startsWith(`${run.id}:`) ? props.nodes.find(node => node.id === view.loop.slice(run.id.length + 1)) : undefined
194 if (group !== undefined) {
195 const events = props.details[run.id]?.events ?? []
196 const current = roundsOf(events, group.id)
197 const round = view.round > 0 && view.round <= current ? view.round : current
198 lines.push(
199 <Box flexDirection="row">
200 <Text>{`round ${round} · `}</Text>
201 <Button key="round-back" plain label="‹" onPress={() => props.onRound(Math.max(1, round - 1))} />
202 <Text> </Text>
203 <Button key="round-on" plain label="›" onPress={() => props.onRound(Math.min(current, round + 1))} />
204 </Box>,
205 )
206 lines.push(...segLines(ui, layout(roundNodes(group, events, round), width).lines, props.onPick, 'body'))
207 }
208
209 // A fan-out's sub-runs, listed once its box is picked.
210 for (const node of props.nodes.filter(n => n.kind === 'workflow' && view.fanouts.includes(`${run.id}:${n.id}`))) {
211 const pick = graphed.picks.get(node.id)
212 for (const subRun of pick?.subRuns ?? []) {
213 lines.push(<Button key={`sub-run-${subRun.id}`} plain label={fit(`↳ ${subRun.workflow} ${subRun.status}`, width - 2)} onPress={() => props.onSubRun(subRun)} />)
214 }
215 }
216 return lines
217}
218hooks/log.ts 153 lines1// A run's run log: transcript lines as rows, each row's node, and the window
2// of the newest 60,000 drawn characters the pane keeps.
3
4import type { LogRow, LogWindow, RunEvent } from '../types'
5import { dollars, duration, fit, size } from './text'
6
7/** The most drawn characters the window keeps, and a file's read view shows. */
8export const WINDOW_CHARS = 60_000
9/** The most an opened fold draws. */
10export const FOLD_CHARS = 4_000
11/** The lines of a bash node's printout shown. */
12const EXEC_LINES = 20
13
14export const EMPTY_WINDOW: LogWindow = { runId: '', rows: [], dropped: 0, taken: 0, open: [], tools: 0, end: '', isMissing: false, node: '' }
15
16function isObject(value: unknown): value is Record<string, unknown> {
17 return typeof value === 'object' && value !== null && !Array.isArray(value)
18}
19
20function str(value: unknown): string {
21 return typeof value === 'string' ? value : ''
22}
23
24/** Whole lines out of a spawn's chunks: what came before plus this chunk, with a partial last line held for the next. */
25export function splitChunk(held: string, chunk: string): { lines: string[]; held: string } {
26 const text = held + chunk
27 const at = text.lastIndexOf('\n')
28 if (at < 0) return { lines: [], held: text }
29 return { lines: text.slice(0, at).split('\n').map(line => line.replace(/\r$/, '')), held: text.slice(at + 1) }
30}
31
32/** The argument a tool row names: a command, a path, a pattern, or the input's first text. */
33function mainArgument(input: Record<string, unknown>): { key: string; text: string } {
34 for (const key of ['command', 'pattern', 'file_path', 'path', 'url', 'query', 'description', 'prompt']) {
35 if (str(input[key]) !== '') return { key, text: str(input[key]) }
36 }
37 const first = Object.entries(input).find(([, value]) => typeof value === 'string')
38 return first === undefined ? { key: '', text: '' } : { key: first[0], text: first[1] as string }
39}
40
41/** A tool row: the tool and its main argument on one line, the full input behind `▸` when there is more of it. */
42function toolRow(name: string, input: Record<string, unknown>): { text: string; full: string } {
43 const main = mainArgument(input)
44 const content = str(input.content)
45 const sized = name === 'Write' && content !== '' ? ` (${size(content.length)})` : ''
46 const line = `${name} ${fit(main.text.split('\n')[0] ?? '', 200)}${sized}`.trim()
47 const whole = Object.entries(input).map(([key, value]) => `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`).join('\n')
48 // The full input is folded behind ▸ only when it says much more than the line does.
49 const isMore = whole.length > line.length + 40 || (name === 'Write' && content !== '') || Object.values(input).some(value => typeof value === 'string' && value.includes('\n'))
50 return { text: isMore ? `${line} ▸` : line, full: isMore ? whole : '' }
51}
52
53/** The tail of a printout: its last `count` lines. */
54function tail(text: string, count: number): string[] {
55 const lines = text.replace(/\n+$/, '').split('\n').filter(line => line !== '')
56 return lines.slice(-count)
57}
58
59/**
60 * The rows one transcript line makes. Transcript rows do not name their node:
61 * a tool row is counted so the events can name it, and any other row belongs
62 * to the nodes open when it was written.
63 */
64export function rowsOf(text: string, lineNo: number, window: LogWindow): { rows: LogRow[]; window: LogWindow } {
65 let entry: unknown
66 try {
67 entry = JSON.parse(text)
68 } catch {
69 return { rows: [], window }
70 }
71 if (!isObject(entry)) return { rows: [], window }
72 const execution = isObject(entry.execution) && isObject(entry.execution.node) ? entry.execution.node : {}
73 const node = str(entry.step) || str(execution.id)
74 const key = `l${lineNo}`
75 const at = (row: Omit<LogRow, 'key' | 'nodes' | 'tool' | 'full'> & Partial<LogRow>): LogRow => ({ key, nodes: window.open, tool: -1, full: '', ...row })
76 switch (entry.type) {
77 case 'node_start':
78 return { rows: [at({ kind: 'start', text: `▶ ${node}`, nodes: [node] })], window: { ...window, open: window.open.includes(node) ? window.open : [...window.open, node] } }
79 case 'node_complete': {
80 const spent = [typeof entry.duration_ms === 'number' ? duration(entry.duration_ms) : '', typeof entry.cost_usd === 'number' ? dollars(entry.cost_usd) : ''].filter(part => part !== '')
81 return { rows: [at({ kind: 'end', text: `✓ ${node}${spent.length === 0 ? '' : ` ${spent.join(' ')}`}`, nodes: [node] })], window: { ...window, open: window.open.filter(open => open !== node) } }
82 }
83 case 'node_error':
84 case 'node_failed':
85 return { rows: [at({ kind: 'error', text: `✗ ${node}: ${str(entry.error) || str(entry.message)}`, nodes: [node] })], window: { ...window, open: window.open.filter(open => open !== node) } }
86 case 'node_skipped':
87 return { rows: [at({ kind: 'skip', text: `– ${node} skipped${str(entry.reason) === '' ? '' : `: ${str(entry.reason)}`}`, nodes: [node] })], window }
88 case 'assistant':
89 return { rows: [at({ kind: 'text', text: str(entry.content) })], window }
90 case 'tool': {
91 const drawn = toolRow(str(entry.tool_name) || 'tool', isObject(entry.tool_input) ? entry.tool_input : {})
92 return { rows: [at({ kind: 'tool', text: drawn.text, full: drawn.full, tool: window.tools })], window: { ...window, tools: window.tools + 1 } }
93 }
94 case 'exec_output': {
95 const out = [...tail(str(entry.stdout_tail), EXEC_LINES), ...tail(str(entry.stderr_tail), EXEC_LINES)]
96 return { rows: out.length === 0 ? [] : [at({ kind: 'exec', text: out.join('\n') })], window }
97 }
98 default:
99 // Heartbeats (watchdog_reset), workflow_start and workflow_complete draw nothing.
100 return { rows: [], window }
101 }
102}
103
104/** The characters a row draws in the window. */
105export function rowChars(row: LogRow): number {
106 return row.text.length
107}
108
109/** Keeps the newest rows that fit in 60,000 drawn characters; older rows drop first. */
110export function capWindow(window: LogWindow): LogWindow {
111 let total = window.rows.reduce((sum, row) => sum + rowChars(row), 0)
112 let cut = 0
113 while (total > WINDOW_CHARS && cut < window.rows.length) {
114 total -= rowChars(window.rows[cut]!)
115 cut++
116 }
117 return cut === 0 ? window : { ...window, rows: window.rows.slice(cut), dropped: window.dropped + cut }
118}
119
120/** Takes whole transcript lines into the window, numbering them from `from + 1`; lines up to `skip` were taken already, and only rows `keep` passes stay. */
121export function take(window: LogWindow, lines: readonly string[], from: number, skip: number, keep: (row: LogRow) => boolean = () => true): LogWindow {
122 let next = window
123 let lineNo = from
124 const rows: LogRow[] = []
125 for (const text of lines) {
126 lineNo++
127 if (lineNo <= skip) continue
128 const made = rowsOf(text, lineNo, next)
129 next = made.window
130 rows.push(...made.rows.filter(keep))
131 }
132 return capWindow({ ...next, rows: [...next.rows, ...rows], taken: Math.max(next.taken, lineNo) })
133}
134
135/** The steps of the run's tool calls in order: the nth names the node of the transcript's nth tool row. */
136export function toolSteps(events: readonly RunEvent[]): string[] {
137 return events.filter(e => e.type === 'tool_called').map(e => e.step)
138}
139
140/** The nodes a row belongs to: a tool row's from the events, any other row's from the nodes open when it was written. */
141export function nodesOf(row: LogRow, steps: readonly string[]): string[] {
142 if (row.kind === 'tool') {
143 const step = steps[row.tool]
144 if (step !== undefined && step !== '') return [step]
145 }
146 return row.nodes
147}
148
149/** Whether a row shows under `node`: all rows with no node picked. */
150export function isUnder(row: LogRow, node: string, steps: readonly string[]): boolean {
151 return node === '' || nodesOf(row, steps).includes(node)
152}
153hooks/log-view.tsx 209 lines1// The Log sub-tab: the picked run's run log, cut to the picked node, with the
2// files the run kept and a read view for one of them.
3
4import type { Elements, RenderElement } from 'claude-code'
5
6import type { ArchonView, Detail, LogRow, LogWindow, Run, RunFile } from '../types'
7import { FOLD_CHARS, isUnder, nodesOf, toolSteps, WINDOW_CHARS } from './log'
8import { shortId } from './runs-view'
9import { size } from './text'
10
11/** The most wrapped lines a node's output or error shows before the rest folds. */
12const OUTPUT_LINES = 10
13
14/** A file of the run, as opened in the read view. */
15export type OpenedFile = {
16 path: string
17 text: string
18 isBinary: boolean
19 size: number
20}
21
22export type LogProps = {
23 ui: Elements[keyof Elements]
24 run: Run | undefined
25 runs: readonly Run[]
26 detail: Detail | undefined
27 window: LogWindow
28 view: ArchonView
29 width: number
30 /** The run's page in Archon's web UI, or '' when it is not linked. */
31 link: string
32 /** The run's files folder on disk, to tell a Write row of a file it kept. */
33 filesFolder: string
34 file: OpenedFile | undefined
35 notice: string
36 onFold: (key: string) => void
37 onAll: () => void
38 onFiles: () => void
39 onFile: (path: string) => void
40 onBack: () => void
41 onOpen: () => void
42 onMention: () => void
43}
44
45/** A run log's file path relative to the run's files folder; '' when it is outside it. */
46export function keptPath(path: string, folder: string): string {
47 const slashed = path.replace(/\\/g, '/')
48 const base = folder.replace(/\\/g, '/').replace(/\/+$/, '')
49 if (base === '') return ''
50 return slashed.toLowerCase().startsWith(`${base.toLowerCase()}/`) ? slashed.slice(base.length + 1) : ''
51}
52
53/** Lines of `text` cut at `count` wrapped lines at `width`, and how many lines were left out. */
54function cutLines(text: string, count: number, width: number): { shown: string[]; more: number } {
55 const lines = text.replace(/\n+$/, '').split('\n')
56 const shown: string[] = []
57 let used = 0
58 for (const line of lines) {
59 const rows = Math.max(1, Math.ceil(Array.from(line).length / Math.max(8, width)))
60 if (used + rows > count) break
61 shown.push(line)
62 used += rows
63 }
64 return { shown, more: lines.length - shown.length }
65}
66
67function header(props: LogProps, run: Run): RenderElement {
68 const { Box, Text, Link } = props.ui
69 const parent = props.runs.find(other => other.id === run.parentId)
70 const name = `${parent === undefined ? '' : `${parent.workflow} › `}${run.workflow}`
71 return (
72 <Box key="loghead" flexDirection="row" columnGap={1}>
73 <Text bold wrap="truncate-end">{`${name} · ${shortId(run.id)} · ${run.status}`}</Text>
74 {props.link !== '' && <Link key="archon-link" href={props.link} label="↗ Archon" />}
75 </Box>
76 )
77}
78
79function filesLines(props: LogProps, files: readonly RunFile[]): RenderElement[] {
80 const { Box, Button } = props.ui
81 if (files.length === 0) return []
82 const count = `${files.length} file${files.length === 1 ? '' : 's'}`
83 const names = files.map(file => file.path.slice(file.path.lastIndexOf('/') + 1)).join(' · ')
84 if (!props.view.isFilesOpen) return [<Button key="files" plain label={`▸ ${count}: ${names}`} onPress={props.onFiles} />]
85 return [
86 <Button key="files" plain label={`▾ ${count}`} onPress={props.onFiles} />,
87 ...files.map(file => (
88 <Box key={`filerow-${file.path}`} flexDirection="row">
89 <Box flexShrink={0}><Button key={`file-${file.path}`} plain label={`${file.path} ${size(file.size)}`} onPress={() => props.onFile(file.path)} /></Box>
90 </Box>
91 )),
92 ]
93}
94
95/** The read view of one file the run kept: Markdown for `.md`, Code for anything else, capped at 60,000 characters. */
96function readView(props: LogProps): RenderElement[] {
97 const { Box, Text, Button, Markdown, Code } = props.ui
98 const lines: RenderElement[] = [
99 <Box key="filehead" flexDirection="row" columnGap={2}>
100 <Button key="file-back" plain hotkey="b" label="back" onPress={props.onBack} />
101 <Button key="file-open" plain hotkey="o" label="open" onPress={props.onOpen} />
102 <Button key="file-mention" plain dimColor label="@ prompt" onPress={props.onMention} />
103 </Box>,
104 <Text bold wrap="truncate-end">{props.view.file}</Text>,
105 ]
106 if (props.notice !== '') lines.push(<Text dimColor>{props.notice}</Text>)
107 const file = props.file
108 if (file === undefined || file.path !== props.view.file) return [...lines, <Text dimColor>Reading…</Text>]
109 if (file.isBinary) return [...lines, <Text dimColor>{`A binary file, ${size(file.size)}.`}</Text>]
110 const shown = file.text.slice(0, WINDOW_CHARS)
111 lines.push(file.path.toLowerCase().endsWith('.md') ? <Markdown key="filetext" text={shown} /> : <Code key="filetext" source={shown} />)
112 if (file.text.length > WINDOW_CHARS) lines.push(<Text dimColor>{`… ${size(file.text.length - WINDOW_CHARS)} more: open it outside`}</Text>)
113 return lines
114}
115
116export function logBody(props: LogProps): RenderElement[] {
117 const { ui, run, view, width } = props
118 const { Box, Text, Button } = ui
119 if (run === undefined) return [<Text dimColor>Pick a run in Runs to see its log.</Text>]
120 if (view.file !== '') return [header(props, run), ...readView(props)]
121 const events = props.detail?.events ?? []
122 const steps = toolSteps(events)
123 const window = props.window.runId === run.id ? props.window : undefined
124 const lines: RenderElement[] = [header(props, run)]
125 if (view.node !== '') {
126 lines.push(
127 <Box key="logcut" flexDirection="row" columnGap={1}>
128 <Text dimColor>{`cut to ${view.node} ·`}</Text>
129 <Button key="log-all" plain dimColor hotkey="a" label="all nodes" onPress={props.onAll} />
130 </Box>,
131 )
132 }
133 lines.push(...filesLines(props, props.detail?.files ?? []))
134 if (window === undefined) return [...lines, <Text dimColor>Reading the run log…</Text>]
135
136 if (window.dropped > 0) lines.push(<Box key="log-dropped"><Text dimColor>{`… ${window.dropped} earlier rows not kept`}</Text></Box>)
137 if (window.isMissing) lines.push(<Box key="log-missing"><Text dimColor>This run's transcript is missing; node output and errors still show from its events.</Text></Box>)
138
139 const outputOf = (node: string, key: string) => {
140 for (const e of events.filter(ev => ev.step === node && ((ev.type === 'node_completed' && ev.output !== '') || (ev.type === 'node_failed' && ev.error !== '')))) {
141 const isError = e.type === 'node_failed'
142 const foldKey = `out-${key}-${isError ? 'error' : 'output'}`
143 const isOpen = view.fold === foldKey
144 const full = isError ? e.error : e.output
145 const cut = cutLines(isOpen ? full.slice(0, FOLD_CHARS) : full, isOpen ? Number.MAX_SAFE_INTEGER : OUTPUT_LINES, width)
146 cut.shown.forEach((text, i) => lines.push(
147 <Box key={`log-${foldKey}-${i}`}>{isError ? <Text color="error" wrap="wrap">{text}</Text> : <Text dimColor wrap="wrap">{text}</Text>}</Box>,
148 ))
149 if (isOpen && full.length > FOLD_CHARS) lines.push(<Box key={`log-${foldKey}-rest`}><Text dimColor>{`… ${size(full.length - FOLD_CHARS)} more not shown`}</Text></Box>)
150 if (cut.more > 0) lines.push(<Box key={`log-${foldKey}-more`}><Button key={foldKey} plain dimColor label={`▸ ${cut.more} more lines`} onPress={() => props.onFold(foldKey)} /></Box>)
151 }
152 }
153
154 const ended = new Set<string>()
155 for (const row of window.rows) {
156 if (!isUnder(row, view.node, steps)) continue
157 const nodes = nodesOf(row, steps)
158 const isBody = row.kind === 'text' || row.kind === 'tool' || row.kind === 'exec'
159 const tag = !isBody ? '' : view.node === '' ? (nodes.length === 0 ? '' : `${nodes.join(' ∥ ')} · `) : nodes.length > 1 ? '∥ ' : ''
160 const tagText = tag === '' ? null : <Text dimColor>{tag}</Text>
161 if (row.kind === 'tool' && row.full !== '') {
162 lines.push(
163 <Box key={`log-${row.key}`} flexDirection="row">
164 {tagText}
165 <Button key={`fold-${row.key}`} plain label={row.text} onPress={() => props.onFold(row.key)} />
166 </Box>,
167 )
168 if (view.fold === row.key) lines.push(...foldLines(props, row))
169 continue
170 }
171 const tone = row.kind === 'error' ? { color: 'error' as const } : row.kind === 'skip' ? { dimColor: true } : {}
172 row.text.split('\n').forEach((text, i) => lines.push(
173 <Box key={`log-${row.key}${i === 0 ? '' : `-${i}`}`} flexDirection="row">
174 {i === 0 ? tagText : null}
175 <Text {...tone} wrap="wrap">{text}</Text>
176 </Box>,
177 ))
178 if (row.kind === 'end' || row.kind === 'error') {
179 const node = nodes[0] ?? ''
180 if (!ended.has(node)) {
181 ended.add(node)
182 outputOf(node, row.key)
183 }
184 }
185 }
186 // A skipped node never starts, so the transcript has no row for it: the events give it, with its cause.
187 for (const skipped of events.filter(e => e.type === 'node_skipped' && (view.node === '' || view.node === e.step))) {
188 lines.push(<Box key={`log-skip-${skipped.step}`}><Text dimColor wrap="wrap">{`– ${skipped.step} skipped${skipped.reason === '' ? '' : `: ${skipped.reason}`}`}</Text></Box>)
189 }
190 // Output and errors of nodes whose end marker is not in the window, as with a missing transcript.
191 const seen = new Set(events.filter(e => e.type === 'node_completed' || e.type === 'node_failed').map(e => e.step))
192 for (const node of seen) if (!ended.has(node) && (view.node === '' || view.node === node)) outputOf(node, `ev-${node}`)
193 if (window.end !== '') lines.push(<Box key="log-end"><Text {...(window.end.startsWith('✗') ? { color: 'error' as const } : {})}>{window.end}</Text></Box>)
194 return lines
195}
196
197function foldLines(props: LogProps, row: LogRow): RenderElement[] {
198 const { Box, Text, Button } = props.ui
199 const shown = row.full.slice(0, FOLD_CHARS)
200 const lines: RenderElement[] = shown.split('\n').map((text, i) => (
201 <Box key={`log-${row.key}-fold-${i}`}><Text dimColor wrap="wrap">{text}</Text></Box>
202 ))
203 if (row.full.length > FOLD_CHARS) lines.push(<Box key={`log-${row.key}-fold-more`}><Text dimColor>{`… ${size(row.full.length - FOLD_CHARS)} more not shown`}</Text></Box>)
204 const path = /^file_path: (.*)$/m.exec(row.full)?.[1] ?? ''
205 const kept = row.text.startsWith('Write ') ? keptPath(path, props.filesFolder) : ''
206 if (kept !== '') lines.push(<Box key={`log-${row.key}-fold-open`}><Button key={`write-open-${row.key}`} plain label={`open ${kept}`} onPress={() => props.onFile(kept)} /></Box>)
207 return lines
208}
209hooks/notify.ts 145 lines1// What reaches the person outside the pane: the status line, and toasts when
2// this project's runs need you, fail or finish, one per event across sessions.
3
4import type { Detail, Run } from '../types'
5import { hasEnded, needsYou, subRunsOf, topRuns, waitNode } from './runs'
6import type { NeedsYou } from './runs'
7import { duration, firstLine } from './text'
8
9/** The status line's length, about; parts are dropped to fit it. */
10const STATUS_ROOM = 60
11
12/** How a run is named outside the pane: its workflow, and the start of its id when another run here has the same workflow. */
13export function nameOf(run: Run, runs: readonly Run[]): string {
14 const isShared = topRuns(runs).some(other => other.id !== run.id && other.workflow === run.workflow)
15 return isShared ? `${run.workflow} ${run.id.slice(0, 6)}` : run.workflow
16}
17
18function needing(runs: readonly Run[], details: Readonly<Record<string, Detail>>): { run: Run; need: NeedsYou }[] {
19 return topRuns(runs).flatMap(run => {
20 const need = needsYou(run, runs, details)
21 return need === undefined ? [] : [{ run, need }]
22 })
23}
24
25/**
26 * The status line: `Archon`, then what needs you, what runs here, and what
27 * needs you elsewhere, each left out at zero; undefined with none of them.
28 * Cut to about 60 characters by dropping elsewhere, then running, then
29 * naming no run. A parent and its sub-runs count as one.
30 */
31export function statusText(runs: readonly Run[], details: Readonly<Record<string, Detail>>, elsewhere: number): string | undefined {
32 const needs = needing(runs, details)
33 const needIds = new Set(needs.map(entry => entry.run.id))
34 const running = topRuns(runs).filter(run => !needIds.has(run.id) && isFamilyLive(run, runs)).length
35 if (needs.length === 0 && running === 0 && elsewhere === 0) return undefined
36 if (needs.length === 0 && running === 0) return `Archon · ⏸ ${elsewhere} elsewhere`
37
38 const first = needs[0]
39 const counted = needs.length === 1 ? '⏸ 1 needs you' : `⏸ ${needs.length} need you`
40 const named = first === undefined || needs.length > 1
41 ? counted
42 : `⏸ ${nameOf(first.run, runs)} ${first.need.standing.kind === 'approval' ? 'needs approval' : 'needs you'}`
43 const parts = (need: string, isRunning: boolean, isElsewhere: boolean) =>
44 ['Archon', ...(needs.length > 0 ? [need] : []), ...(isRunning && running > 0 ? [`${running} running`] : []), ...(isElsewhere && elsewhere > 0 ? [`⏸ ${elsewhere} elsewhere`] : [])].join(' · ')
45 for (const text of [parts(named, true, true), parts(named, true, false), parts(named, false, false)]) {
46 if (Array.from(text).length <= STATUS_ROOM) return text
47 }
48 return parts(counted, false, false)
49}
50
51function isFamilyLive(run: Run, runs: readonly Run[], depth = 0): boolean {
52 if (!hasEnded(run)) return true
53 return depth < 10 && subRunsOf(run, runs).some(subRun => isFamilyLive(subRun, runs, depth + 1))
54}
55
56export type ToastEvent = {
57 runId: string
58 event: 'needs-you' | 'failed' | 'completed'
59 /** Which gate a needs-you toast was for, so a later gate on the same run toasts again. */
60 gate: string
61 text: string
62 timeoutMs: number
63}
64
65const STAYS = { 'needs-you': 15_000, 'failed': 8_000, 'completed': 4_000 } as const
66
67/** What a run that needs you is waiting on, as its toast says it. */
68function needText(run: Run, need: NeedsYou, runs: readonly Run[]): string {
69 const name = nameOf(run, runs)
70 const found = need.standing
71 if (found.kind === 'approval') {
72 const inSub = need.holder.id === run.id ? '' : ` (in sub-run ${need.holder.workflow})`
73 return `⏸ ${name} needs approval: ${firstLine(found.gate.message)}${inSub}`
74 }
75 if (found.kind === 'action') return `⏸ ${name} needs you: ${firstLine(found.wait.message)}`
76 if (found.kind === 'stranded') {
77 const subRun = found.subRun
78 const ending = subRun === undefined ? 'ended' : subRun.status === 'completed' ? 'done' : subRun.status
79 return `⏸ ${name} needs you: sub-run ${subRun?.workflow ?? 'sub-run'} was ${ending}`
80 }
81 return `⏸ ${name} needs you`
82}
83
84/**
85 * The toast-worthy events among this project's runs as they stand: each run
86 * on an approval, action needed or stranded (an unreadable gate is counted but
87 * never toasts), and each failure and finish after `since`. Sub-runs never
88 * toast for themselves: a sub-run's approval is raised for its parent.
89 */
90export function toastEvents(runs: readonly Run[], details: Readonly<Record<string, Detail>>, since: number): ToastEvent[] {
91 const events: ToastEvent[] = []
92 for (const { run, need } of needing(runs, details)) {
93 if (need.standing.kind === 'unreadable') continue
94 const gate = `${need.holder.id}:${need.standing.kind}:${waitNode(need.standing)}:${need.standing.since}`
95 events.push({ runId: run.id, event: 'needs-you', gate, text: needText(run, need, runs), timeoutMs: STAYS['needs-you'] })
96 }
97 for (const run of topRuns(runs)) {
98 if (run.completedAt <= since) continue
99 if (run.status === 'failed') {
100 const failed = [...(details[run.id]?.events ?? [])].reverse().find(e => e.type === 'node_failed' || (e.type === 'workflow_failed' && e.error !== ''))
101 const node = failed?.type === 'node_failed' ? failed.step : ''
102 const error = firstLine(failed?.error ?? '')
103 const text = `✗ ${nameOf(run, runs)} failed${node === '' ? '' : ` at ${node}`}${error === '' ? '' : `: ${error}`}`
104 events.push({ runId: run.id, event: 'failed', gate: '', text, timeoutMs: STAYS.failed })
105 } else if (run.status === 'completed') {
106 const subFailed = subRunsOf(run, runs).filter(subRun => subRun.status === 'failed').length
107 const tail = subFailed === 0 ? '' : `, ${subFailed} sub-run${subFailed === 1 ? '' : 's'} failed`
108 events.push({ runId: run.id, event: 'completed', gate: '', text: `✓ ${nameOf(run, runs)} finished in ${duration(run.completedAt - run.startedAt)}${tail}`, timeoutMs: STAYS.completed })
109 }
110 }
111 return events
112}
113
114/** One toast for the events of one poll: the one event's own, or the counts in order, staying the longest part's time. */
115export function collapse(events: readonly ToastEvent[]): { text: string; timeoutMs: number } | undefined {
116 if (events.length === 0) return undefined
117 if (events.length === 1) return { text: events[0]!.text, timeoutMs: events[0]!.timeoutMs }
118 const count = (event: ToastEvent['event']) => events.filter(e => e.event === event).length
119 const needs = count('needs-you')
120 const parts = [
121 needs > 0 ? `⏸ ${needs} ${needs === 1 ? 'needs' : 'need'} you` : '',
122 count('failed') > 0 ? `✗ ${count('failed')} failed` : '',
123 count('completed') > 0 ? `✓ ${count('completed')} finished` : '',
124 ].filter(part => part !== '')
125 return { text: parts.join(' · '), timeoutMs: Math.max(...events.map(e => e.timeoutMs)) }
126}
127
128/** The store key of a toast claim. */
129export function claimKey(event: ToastEvent): string {
130 return `toasted/${event.runId}/${event.event}`
131}
132
133/** Whether a claim already in the store covers this event: the same gate for a needs-you, any claim for a failure or finish. */
134export function isClaimed(event: ToastEvent, value: unknown): boolean {
135 if (value === undefined || value === null) return false
136 if (event.event !== 'needs-you') return true
137 return typeof value === 'object' && (value as { gate?: unknown }).gate === event.gate
138}
139
140/** Whether a claim read back is this session's. */
141export function isMine(value: unknown, session: string): boolean {
142 if (typeof value === 'string') return value === session
143 return typeof value === 'object' && value !== null && (value as { session?: unknown }).session === session
144}
145hooks/config.ts 35 lines1// The mod's settings, parsed once at the boundary.
2
3export type Toasts = 'all' | 'needs you' | 'off'
4
5export type Config = {
6 /** The Archon CLI the person named; '' finds it on the PATH or in `~/.archon/bin`. */
7 archonPath: string
8 /** Where `archon serve` answers. */
9 port: number
10 /** Archon's log file, `~` not yet expanded. */
11 archonLog: string
12 toasts: Toasts
13 isStatusLine: boolean
14}
15
16export const DEFAULT_PORT = 3090
17export const DEFAULT_LOG = '~/.archon/logs/serve.log'
18
19const TOASTS: readonly Toasts[] = ['all', 'needs you', 'off']
20
21/** Reads the settings: a blank path means find it, a port outside 1-65535 or not whole reads 3090, an unknown `toasts` reads `all`. */
22export function parseConfig(options: Readonly<Record<string, unknown>>): Config {
23 const path = typeof options.archonPath === 'string' ? options.archonPath.trim() : ''
24 const port = options.archonPort
25 const log = typeof options.archonLog === 'string' && options.archonLog.trim() !== '' ? options.archonLog.trim() : DEFAULT_LOG
26 const toasts = TOASTS.find(value => value === options.toasts) ?? 'all'
27 return {
28 archonPath: path,
29 port: typeof port === 'number' && Number.isInteger(port) && port >= 1 && port <= 65535 ? port : DEFAULT_PORT,
30 archonLog: log,
31 toasts,
32 isStatusLine: options.statusLine !== false,
33 }
34}
35hooks/runs.ts 236 lines1// Reading Archon's run rows at the boundary, and what state each run is in.
2
3import type { Decision, Detail, Gate, Run, Status, Wait } from '../types'
4import { oneLine } from './text'
5
6const STATUSES: readonly Status[] = ['pending', 'running', 'paused', 'completed', 'failed', 'cancelled']
7const CHAT_PLATFORMS = ['slack', 'telegram', 'github', 'discord']
8
9function isObject(value: unknown): value is Record<string, unknown> {
10 return typeof value === 'object' && value !== null && !Array.isArray(value)
11}
12
13function text(value: unknown): string {
14 return typeof value === 'string' ? value : ''
15}
16
17function time(value: unknown): number {
18 if (typeof value === 'number' && Number.isFinite(value)) return value
19 const parsed = typeof value === 'string' ? Date.parse(value) : NaN
20 return Number.isNaN(parsed) ? 0 : parsed
21}
22
23function label(id: string): string {
24 return id === '' ? id : `${id[0]!.toUpperCase()}${id.slice(1).replace(/[-_]/g, ' ')}`
25}
26
27function decisions(value: unknown): Decision[] {
28 if (!Array.isArray(value)) return []
29 return value.flatMap((entry): Decision[] => {
30 if (typeof entry === 'string' && entry !== '') return [{ id: entry, label: label(entry) }]
31 if (!isObject(entry)) return []
32 const id = text(entry.id) || text(entry.decision) || text(entry.value)
33 return id === '' ? [] : [{ id, label: text(entry.label) || label(id) }]
34 })
35}
36
37function gate(value: unknown): Gate | null {
38 if (!isObject(value)) return null
39 const nodeId = text(value.nodeId) || text(value.node_id)
40 const type = text(value.type)
41 const declared = decisions(value.decisions)
42 return {
43 nodeId,
44 message: text(value.message),
45 type,
46 decisions: declared.length > 0 ? declared : [{ id: 'approve', label: 'Approve' }, { id: 'reject', label: 'Reject' }],
47 subRunId: text(value.childRunId) || text(value.child_run_id),
48 hasRework: value.onReject !== undefined || value.on_reject !== undefined,
49 isRoundDone: value.roundDone === true || value.isComplete === true || value.complete === true,
50 isReadable: nodeId !== '' && type !== '',
51 since: time(value.waitingSince ?? value.requestedAt ?? value.since),
52 }
53}
54
55function wait(value: unknown): Wait | null {
56 if (!isObject(value)) return null
57 return {
58 kind: text(value.kind),
59 nodeId: text(value.nodeId) || text(value.node_id),
60 message: text(value.message),
61 until: text(value.until),
62 event: text(value.event),
63 since: time(value.waitingSince ?? value.since),
64 }
65}
66
67/** One run row from either source; undefined when it has no id. Unknown statuses read as `pending`. */
68export function parseRun(value: unknown): Run | undefined {
69 if (!isObject(value) || text(value.id) === '') return undefined
70 const metadata = isObject(value.metadata) ? value.metadata : {}
71 const source = isObject(metadata.workflow_source) ? metadata.workflow_source : {}
72 const status = STATUSES.find(s => s === value.status) ?? 'pending'
73 const platform = [value.platform_type, value.parent_platform_type, metadata.platform_type, metadata.platform]
74 .map(text)
75 .map(name => name.toLowerCase())
76 .find(name => CHAT_PLATFORMS.includes(name)) ?? ''
77 return {
78 id: text(value.id),
79 workflow: text(value.workflow_name) || 'workflow',
80 status,
81 message: oneLine(text(value.user_message)),
82 startedAt: time(value.started_at),
83 completedAt: time(value.completed_at),
84 lastActivityAt: time(value.last_activity_at) || time(value.started_at),
85 codebaseId: text(value.codebase_id),
86 workingPath: text(value.working_path),
87 outputRoot: text(value.output_root),
88 parentId: text(value.parent_run_id),
89 parentNodeId: text(metadata.parent_node_id),
90 adoptedFromId: text(value.adopted_from_run_id),
91 origin: text(source.origin),
92 sourceRoot: text(source.root),
93 hasConversation: text(value.parent_conversation_id) !== '',
94 platform,
95 isContainer: metadata.isolation === 'container' || (isObject(metadata.isolation) && metadata.isolation.kind === 'container'),
96 approval: status === 'paused' ? gate(metadata.approval) : null,
97 wait: status === 'paused' ? wait(metadata.wait) : null,
98 costUsd: typeof metadata.total_cost_usd === 'number' ? metadata.total_cost_usd : 0,
99 }
100}
101
102/** The rows of a runs list, either source's body; throws when it is not one. */
103export function parseRuns(body: string): Run[] {
104 const parsed: unknown = JSON.parse(body)
105 if (!isObject(parsed) || !Array.isArray(parsed.runs)) throw new Error('Archon answered without a runs list')
106 return parsed.runs.flatMap(entry => parseRun(entry) ?? [])
107}
108
109/** Whether a run has ended: completed, failed or cancelled. */
110export function hasEnded(run: Run): boolean {
111 return run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled'
112}
113
114/** The run at the top of a run's chain of parents, as far as the rows go; a missing parent stands for itself. */
115export function rootId(run: Run, byId: ReadonlyMap<string, Run>): string {
116 let at = run
117 const seen = new Set<string>()
118 while (at.parentId !== '' && !seen.has(at.id)) {
119 seen.add(at.id)
120 const parent = byId.get(at.parentId)
121 if (parent === undefined) return at.parentId
122 at = parent
123 }
124 return at.id
125}
126
127/** How many live runs: a parent and its sub-runs count as one while any of them has not ended. */
128export function liveCount(runs: readonly Run[]): number {
129 const byId = new Map(runs.map(run => [run.id, run]))
130 return new Set(runs.filter(run => !hasEnded(run)).map(run => rootId(run, byId))).size
131}
132
133/**
134 * Why a run stands where it does, mirroring Archon's `runAttention`
135 * (`packages/workflows/src/schemas/workflow-run.ts` at v0.11.1) from
136 * `metadata.approval` and `metadata.wait`, plus the mod's own check of a
137 * `child_workflow` gate's sub-run, which Archon never reads.
138 */
139export type Standing =
140 | { kind: 'none' }
141 | { kind: 'approval'; gate: Gate; since: number }
142 | { kind: 'action'; wait: Wait; since: number }
143 | { kind: 'stranded'; gate: Gate; subRun: Run | undefined; since: number }
144 | { kind: 'unreadable'; since: number }
145 | { kind: 'blocked'; gate: Gate; subRun: Run | undefined }
146 | { kind: 'waiting'; wait: Wait }
147 | { kind: 'resuming'; gate: Gate }
148
149/** Whether a gate was answered: an `approval_received` after the last `approval_requested`. */
150function isAnswered(detail: Detail | undefined): boolean {
151 const events = detail?.events ?? []
152 const asked = events.map(e => e.type).lastIndexOf('approval_requested')
153 const answered = events.map(e => e.type).lastIndexOf('approval_received')
154 return answered > asked && answered >= 0
155}
156
157/** Where a run stands, its sub-run looked up among `runs`. */
158export function standing(run: Run, runs: readonly Run[], detail: Detail | undefined): Standing {
159 if (run.status !== 'paused') return { kind: 'none' }
160 const since = run.approval?.since || run.wait?.since || run.lastActivityAt
161 if (run.wait !== null) {
162 return run.wait.kind === 'attention' ? { kind: 'action', wait: run.wait, since } : { kind: 'waiting', wait: run.wait }
163 }
164 const gate = run.approval
165 if (gate === null) return { kind: 'unreadable', since }
166 if (gate.type === 'child_workflow') {
167 const subRun = runs.find(other => other.id === gate.subRunId)
168 return subRun !== undefined && hasEnded(subRun) ? { kind: 'stranded', gate, subRun, since } : { kind: 'blocked', gate, subRun }
169 }
170 if (!gate.isReadable) return { kind: 'unreadable', since }
171 return isAnswered(detail) ? { kind: 'resuming', gate } : { kind: 'approval', gate, since }
172}
173
174/** The node a run waits at: an approval's or a stranded parent's gate, or an action-needed wait; '' for any other standing. */
175export function waitNode(found: Standing): string {
176 if (found.kind === 'approval' || found.kind === 'stranded') return found.gate.nodeId
177 if (found.kind === 'action') return found.wait.nodeId
178 return ''
179}
180
181/** What a run that needs you is waiting on: the run that holds it (the parent, or the deepest sub-run on a gate) and why. */
182export type NeedsYou = {
183 holder: Run
184 standing: Extract<Standing, { since: number }>
185}
186
187/**
188 * Whether a run needs you, following a parent blocked on a live sub-run down
189 * the chain to the deepest run on a gate. Waits, answered gates and a sub-run
190 * still working do not.
191 */
192export function needsYou(run: Run, runs: readonly Run[], details: Readonly<Record<string, Detail>>): NeedsYou | undefined {
193 let at = run
194 for (let depth = 0; depth < 10; depth++) {
195 const found = standing(at, runs, details[at.id])
196 if (found.kind === 'approval' || found.kind === 'action' || found.kind === 'stranded' || found.kind === 'unreadable') return { holder: at, standing: found }
197 if (found.kind !== 'blocked' || found.subRun === undefined) return undefined
198 at = found.subRun
199 }
200 return undefined
201}
202
203/** The sub-runs of a run among the rows, oldest first. */
204export function subRunsOf(run: Run, runs: readonly Run[]): Run[] {
205 return runs.filter(other => other.parentId === run.id).sort((a, b) => a.startedAt - b.startedAt)
206}
207
208/** The latest activity of a run or any run below it. */
209export function latestActivity(run: Run, runs: readonly Run[], depth = 0): number {
210 if (depth > 10) return run.lastActivityAt
211 return Math.max(run.lastActivityAt, ...subRunsOf(run, runs).map(subRun => latestActivity(subRun, runs, depth + 1)))
212}
213
214/** The rows the Runs sub-tab lists: runs that are not sub-runs of a listed run. */
215export function topRuns(runs: readonly Run[]): Run[] {
216 const ids = new Set(runs.map(run => run.id))
217 return runs.filter(run => run.parentId === '' || !ids.has(run.parentId))
218}
219
220/** Needs-you runs first, the one that has needed you longest at the top; then the rest by latest activity. */
221export function sortRuns(runs: readonly Run[], all: readonly Run[], details: Readonly<Record<string, Detail>>): Run[] {
222 const keyed = runs.map(run => ({ run, need: needsYou(run, all, details), latest: latestActivity(run, all) }))
223 keyed.sort((a, b) => {
224 if (a.need !== undefined && b.need !== undefined) return a.need.standing.since - b.need.standing.since
225 if (a.need !== undefined) return -1
226 if (b.need !== undefined) return 1
227 return b.latest - a.latest
228 })
229 return keyed.map(entry => entry.run)
230}
231
232/** How many of a run family need you: a parent and its sub-runs count once. */
233export function needsYouCount(runs: readonly Run[], details: Readonly<Record<string, Detail>>): number {
234 return topRuns(runs).filter(run => needsYou(run, runs, details) !== undefined).length
235}
236