SLOPSHOPPER

archon-panel

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

newpanecommandtoaststatusprocess
★ 1v0.2.0no licenseupdated 2026-10-10seanrobertwright/claude-mods/mods/archon-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · archon-panel
│ ┃ Archon ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Runs 2: Graph 3: Log 4: Archon's log │ ┃ The Archon pane needs the Archon CLI, v0.11. ⏺ Read(src/auth.ts) │ ┃ Install it from https://archon.diy, or set i ⎿ Read 6 lines │ ┃ the mod settings, then press r. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /archon │ ⎿ archon-panel: Archon pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Archon
1: Runs 2: Graph 3: Log 4: Archon's log r: ↻ 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.
README

🧩 claude-mods

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 .env or 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 Code 2.1.289+ 21 mods TypeScript strict Checks: tsc, ESLint, validate, test

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.

What is a mod?

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 asWho decides when it acts
ModTypeScript function hooks inside Claude CodeThe mod's own code
SkillInstructions the model readsThe model
Plain pluginCommands, agents or shell hooksYou, or a shell script

The mods

ModWhere it showsWhat it does
🧭 whats-nextPane in the side panelLists the next steps of your workflow, each with a prompt ready to paste
⚡ quick-replyBand above the promptOne-click replies, including the options Claude just offered, Pass, Fail and Skip for a verdict, and the next wayfinder ticket
⏳ auto-resumeBand and status lineCounts down to a rate limit's reset, then sends "continue"
🐙 github-panelPane in the side panelThe 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-panelPane in the side panelThis project's Archon workflow runs, their graphs and logs, and the approvals that wait on you, answered from the pane
📚 shelfBand above the promptNamed folders and files; one click drops a path into what you are typing
🔔 turn-chimeSound and toastTells you when a long turn ends or Claude stops to ask you something
🔒 open-file-guardQuestion dialogAsks you to close a Word, Excel or PowerPoint file before Claude uses it
🌿 branch-guardQuestion dialogAsks you before Claude commits or pushes on the default branch
🔑 env-guardQuestion dialogAsks you before Claude reads a .env or key file, and refuses when no one is there to answer
🩹 bash-quoting-rescueRefused tool callStops a shell command that does not parse, such as an unclosed quote, before any of it runs
📂 outputsPane in the side panelThe files this session made or changed, newest first; click one to open it
🔎 sourcesPane in the side panelThe files Claude read, grouped by where they came from, with a lock to the project folder
📊 hudTwo lines under the promptModel, 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-cleanupQuestion dialog and toastAfter a PR merges, /cleanup switches to the default branch, pulls and deletes the branch
🧾 pre-pr-claims-checkRefusal Claude readsRefuses 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-gateBand above the promptRuns your checks on a press and before Claude's git commit, and hands back only the failures
🔄 chrome-tab-self-healNote after a browser tool's errorWhen a Claude in Chrome tab is gone, tells Claude to fetch the current tab IDs before it tries again
🏷 session-auto-namerBand above the promptSuggests a name for the session from its first prompt; one press renames it
🧠 model-effort-presetsBand above the promptPlan and execute presets: one press switches the model and the effort together
🖥 dev-server-managerPane in the side panel, status line and toastStarts, restarts and watches the project's dev servers; a crash toasts, restarts and fills its error into the prompt
⚙ mod-settingsGear on each pane; a dialogChange 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.

🧭 whats-next

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]
  • You stay in charge. A step's prompt reaches the model only when you read it and send it yourself. Click a step to see its prompt, then paste it, paste it into a fresh session (after your prime command, if you name one), or copy it.
  • It notices when you are done. After each answered turn, a System One model is asked whether the step is finished, as your System One models setting allows, and Haiku when none answers surely enough. A finished step leaves the list. Press d to drop it yourself.
  • The run that asks is read-only. By default it gets only read commands of git and gh plus Read, Glob and Grep. git push, git config, git -c, gh api and --output are always denied.
Command or keyWhat it does
/whats-nextBring the pane to the front
/whats-next refreshAsk the skill again
rRefresh
1 to 9Show that step's prompt in the pane
p, n, cPaste the shown prompt, paste it after /clear (and the prime command, once its turn ends), or copy it
bBack to the list
dMark the active step done
SettingKeyDefaultMeaning
Skillskill/ask-seanThe skill that answers "what's next", written as you would run it: / followed by letters, digits, _, :, . or -
Most stepsmaxSteps5How many steps to ask for (1-9)
Refresh on startrefreshOnStartonAsk for a fresh list when a session starts in a git repository
Tools the headless run may useallowedToolsempty: the read-only setComma-separated permission rules for the headless run
Modelmodelempty: your defaultModel for the headless run, as an alias (haiku) or a full id
Prime commandprimeCommandempty: noneA 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 modelsmodelChoicelocal onlyWhich 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 keyjevApiKeyemptyYour 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 portlayaPort8000The 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.

  • A list you write replaces that set; it does not add to it. To widen the set, write all of it plus your additions.
  • Each rule is a tool name, optionally followed by (...). One rule that is not, such as one starting with -, discards your whole list and the read-only set is used.
  • The run can always call 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:

  • To Jev, only while System One models allows it and the folder is not marked local-only: the active step's title, reason and prompt and the last 8,000 characters of Claude's answer. Never the contents of a file the mod read itself.
  • To Laya, which keeps it on this machine: the same, fitted to Laya's small window. The step's prompt is dropped first, then the start of the answer, so the end of the answer, where Claude says whether the work is done, is kept.
  • Sure answers only. "Done" counts at a probability of 0.9 or more and "not done" at 0.1 or less. Anything between, a model that is busy or fails, or no answer within 5 s leaves the step to Haiku, as before.
  • A key it cannot use is named. While System One models allows Jev, a missing key, one that is not a key, or one TypeSafe rejects is named on a line in the pane, and the list stays. A rejected key stays off until the mod reloads.
  • A changed key takes effect once the mod reloads. Saving it in the settings dialog reloads the mod; a key changed any other way waits for the next start of Claude Code.

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.

⚡ quick-reply

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]
  • A choice's button sends its marker with its label, so the model cannot misread it.
  • A numbered report before a yes-or-no question ("Shall I commit?") is not offered as choices.
  • When the answer recommends something, the "recommend" reply is the highlighted one. Advice against something does not count.
  • The band stays out of the way while Claude is working, and after a subagent's turn.
  • A model can read the ending too. After each answered turn, a System One model is asked how the answer ends, as your System One models setting allows: whether it asks you something, whether its numbered items are choices, and which one it recommends. The band shows its own reading at once, and a sure answer that arrives within 2 s takes its place, so a numbered report closed by "push now or wait?" is not offered as choices.

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.

SettingKeyDefaultMeaning
Replies to a questionquestionReplies`Yes\Go with your recommendation\No`Shown after Claude asks something, separated by `\`; empty shows only the choices Claude offered
Replies otherwiseidleReplies`Continue\Commit and push`Shown after any other answer; empty hides the band then, except for Next ticket
Offer the next wayfinder ticketwayfinderNexttrueAfter a wayfinder turn closes a ticket or charts a map, offer Next ticket
System One modelsmodelChoicelocal onlyWhich 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 keyjevApiKeyemptyYour 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 portlayaPort8000The 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:

  • To Jev, only while System One models allows it and the folder is not marked local-only: the last 8,000 characters of Claude's answer and the labels of the choices found in it. Never the contents of a file the mod read itself.
  • To Laya, which keeps it on this machine: the answer's closing lines that fit Laya's small window, where the question is, and at most 9 labels of at most 40 characters.
  • Sure answers only. Each part of the reading counts only when the model is sure of it, at 0.9 or more (0.1 or less for "these are not choices"). Anything less, a model that is busy or fails, a reading Laya reports as cut, or no answer within 2 s leaves the band's own reading. So does a reading that arrives after your next prompt.
  • A key it cannot use is named. While System One models allows Jev, a missing key, one that is not a key, or one TypeSafe rejects is named on a dim line under the replies, until your next prompt. A rejected key stays off until the mod reloads.

Laya and a Jev key are optional: with neither, the band reads each answer as before.

⏳ auto-resume

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]
  • The countdown shows above the prompt and in the status line.
  • Send a prompt of your own and it steps aside: you took over.
  • It gives up after too many resumes in a row without a successful answer.
CommandWhat it does
/auto-resumeSay what is waiting, if anything
/auto-resume nowSend the resume now
/auto-resume cancelCancel the wait
/auto-resume in <minutes>Schedule a resume yourself (1 to 1440)
SettingKeyDefaultMeaning
Resume prompttextcontinueWhat is sent when the wait is over; empty sends continue
Grace after reset (s)graceSeconds60Extra seconds to wait past the limit's reset time (0-900)
Retry overloaded/server errorsretryOverloadedonAlso resume after an overloaded or server error, backing off from one minute
Most retries in a rowmaxRetries5Give 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

🐙 github-panel

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
  • An issue blocked by an open issue has a red line under it. Hover it to see what blocks it.
  • In a narrow pane the implement and wayfinder buttons take a line of their own.
  • The lists refresh on a timer, after a turn once they are a minute old, and on r.
  • The pull request of the branch you are on is watched: when its checks turn green or red, a toast says "Checks passed on #40" or "Checks failed on #40". The first refresh, and the first after you switch branch, only notes where the checks stand.
  • While that pull request's checks fail, its row has a 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.
  • Running /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.
  • The pinned issue's sub-issues are its tickets, each counted once: closed ones are done, open ones with an open blocker are blocked, assigned ones are claimed, and the rest are takeable. 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.
  • The pinned issue is read in the same refresh as the lists. When a refresh fails, the last section stays and the error shows.
Command or keyWhat it does
/githubBring the pane to the front and refresh it
rRefresh
allOpen the whole list on GitHub
implement, wayfinderFill the command and the issue's URL into the prompt, without sending it
fixFill a request to fix the current branch's failing checks into the prompt box
unpinDrop the pinned issue
work nextFill /wayfinder and the pinned issue's URL into the prompt, without sending it
SettingKeyDefaultMeaning
Most items per listlimit30How many open pull requests and issues to list each (1-100)
Refresh every (minutes)refreshMinutes5How often to refresh (0-120); 0 refreshes only on open, after turns and on r
Implement button fillsimplementCommand/implementThe slash command the implement button fills before the issue's URL; empty hides the button
Wayfinder button fillswayfinderCommand/wayfinderThe 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.

🏛️ archon-panel

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 
Source 19 files
hooks/register.tsx 1020 lines
1import { 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}
1020
hooks/actions.ts 200 lines
1// 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}
200
hooks/actions-view.tsx 179 lines
1// 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}
179
hooks/chrome.tsx 81 lines
1// 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}
81
hooks/cli.ts 66 lines
1// 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}
66
hooks/graph.ts 384 lines
1// 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}
384
hooks/graph-view.tsx 218 lines
1// 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}
218
hooks/log.ts 153 lines
1// 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}
153
hooks/log-view.tsx 209 lines
1// 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}
209
hooks/notify.ts 145 lines
1// 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}
145
hooks/config.ts 35 lines
1// 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}
35
hooks/runs.ts 236 lines
1// 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