Standing entry point for engineering work with sticky routing, evidence-backed playbooks, unattended overnight runs, and merge-ready draft PRs without merging…

<img width="1920" height="819" alt="vistack" src="https://github.com/user-attachments/assets/9835e9ba-0687-45a1-9de8-bb932d1a90f4" />
The standing entry point for a unit of engineering work — an issue, a task, a bug, a feature, a refactor, a design implementation, an investigation. One entry point, one playbook, one role phase per slice, stopping at merge-ready.
vistack is the identifier you install and invoke. viStack is what it is called in prose. Same thing. It runs on Claude Code, Codex, Grok Build, and OpenCode.
Version: 0.25.0
Contents: install · usage · QA evidence: screenshots and video · what it gives you · reuse in another project repository · resuming · update guide · limits · uninstall · troubleshooting
viStack supports Claude Code, Codex, Grok Build, and OpenCode. The repository carries a manifest per host and keeps the same playbooks, principles, and skill names on all of them.
Each host manifest carries the version, and the version line at the top of this README repeats it. scripts/check-playbooks.mjs fails when any of them disagree.
| Host | Manifest |
|---|---|
| Claude Code | .claude-plugin/plugin.json |
| Codex | .codex-plugin/plugin.json |
| Grok Build | .grok-plugin/plugin.json |
| OpenCode | integrations/opencode/vistack.js (follows the checkout) |
viStack ships .claude-plugin/marketplace.json at the repository root. From Claude Code, add the repository marketplace and install the plugin:
/plugin marketplace add https://github.com/vianch/viStack
/plugin install vistack
/plugin lists viStack as enabled./vistack:vistack resolves — typing it offers the command rather than an unknown-command error.If either fails, see troubleshooting.
Codex installs the same repository through .agents/plugins/marketplace.json and loads skills/vistack/SKILL.md as $vistack:vistack:
codex --version
codex plugin marketplace add https://github.com/vianch/viStack
If it is already configured, check it with codex plugin marketplace list and do not add it again.
codex plugin add vistack@vistack
codex plugin list
$vistack:vistack. Codex runs the playbook phases in thecurrent thread because Claude's commands/ and agents/ directories are not Codex runtime components. Codex state uses .codex/vistack/state/ and .codex/vistack/worktrees/. See the Codex guide for local development and update instructions.
Grok Build reads .grok-plugin/plugin.json, which points at the same skills/, commands/, and agents/ directories. Its marketplace entry must pin a published commit SHA. OpenCode loads the bridge at integrations/opencode/vistack.js from .opencode/plugins/, which adds the decision and QA video tools. Both are covered step by step in docs/guide/grok-opencode.md.
vistack is the canonical, backwards-compatible skill name. The following names are equivalent entrypoints and do not select different playbooks, roles, state roots, host adapters, or merge policies:
| Purpose | Claude Code | Codex |
|---|---|---|
| Canonical | /vistack:vistack | $vistack:vistack |
| Action-oriented alias | /vistack:run | $vistack:run |
| Role-oriented alias | /vistack:orchestrator | $vistack:orchestrator |
| Coordination-oriented alias | /vistack:coordinator | $vistack:coordinator |
Claude aliases are thin command shims. Codex aliases are thin skill shims; all delegate to skills/vistack/SKILL.md, which remains the single orchestration implementation.
One command. The request shape is what makes it work.
/vistack <what you observed or want>. Done means <checkable condition>.
Keep <behaviour that must not change>.
| Part | What it decides |
|---|---|
| what you observed or want | which playbook matches |
Done means … | when the run stops, and what the health check measures the diff against |
Keep … | what counts as a regression, so the QA scenarios have something to protect |
Omit the finish condition and viStack asks for it before starting an autonomous run. It is the one question it blocks on.
/vistack The primitive Modal renders fullscreen below md when it should render as a
sheet — https://github.com/ORG/REPO/issues/789. Done means Modal renders as a
sheet below md with a failing-then-passing test covering it, and a screenshot at 375px
on the PR preview. Keep the md-and-up dialog layout and every current Modal consumer
unchanged.
Matches bug-fix. The reproduction comes before the fix — the playbook will not let a fix be written until the wrong behaviour has been observed and captured. If it does not reproduce, the run stops and reports what was tried; that is an answer, not a failure.
/vistack Run https://github.com/ORG/REPO/issues/123 unattended. Done means a
ProgressBar primitive rendering at 0/50/100% with correct aria-valuenow, both Loader
call sites using it, Storybook stories present, and the suite green. Keep every
existing Loader consumer's visual output.
Matches autopilot-stack — the default for a groomed ticket. Intake → plan → parallel implement → PR → QA → audit → bot-comment cleanup, with no human in the loop, reporting at phase boundaries only. It stops at merge-ready. It never merges.
/vistack I am going to bed. Migrate every caller to the new parser in a fresh worktree.
Done means zero old callers, all parser fixtures pass, and the old API is deleted.
Keep parser output unchanged. Commit and push branches, but do not merge.
If the blocker loop is exhausted, stop with the full dossier.
Matches overnight. It records the permissions, escape hatch, host wake mechanism, and decision trail before dispatch. Each iteration makes one evidence-backed change, checks the real artifact, and records whether the predicate moved. It stops at merge-ready drafts or a fence.
For a queue of independent items, say full autopilot and name each item and its finish check. That matches autopilot-full, which runs one owner per item and leaves every PR as a draft.
/vistack Consolidate the open PR stack for https://github.com/ORG/REPO/issues/456 into
the fewest reviewable PRs. Done means every PR sits on the one below, merges cleanly into
the newest main, and has green CI. Keep every existing screenshot. You may rebuild and
push the stack branches, close absorbed PRs with a link to their new home, request
re-reviews, mark the PRs ready for review, and post the new list in the review thread.
Do not merge.
A long run reports only at phase boundaries. This is one boundary report from a run that folded 26 open PRs into 9. Names, numbers, and endpoints are placeholders.
Done
- Pushes: 8 branches rebuilt and pushed, 3 minutes apart. All 9 PRs sit in order, each on
the one below, and all merge cleanly into the newest main, including a PR that merged
meanwhile.
- Closed: 17 absorbed PRs, each with a comment linking the PR that now holds its commits.
- Descriptions: all 9 rewritten, each with what was folded in, production vs test lines,
and a commit-by-commit review guide. All 38 existing screenshots carried over.
- Readiness: all 9 ready for review and mergeable, with the reviewer teams requested.
- Re-reviews: requested on the 5 PRs that grew after they were approved.
- Review thread: replied with the new list.
PR Approvals CI
#101 card 4 green
#102 cart and confirmation 2 (re-review asked) green
#103 pre-cart step 3 (re-review asked) green
#104 labels and prompt skip 3 (re-review asked) green
#105 detail sheet 2 (re-review asked) running
#106 quantities and add 2 (re-review asked) running
#107 scheduled time 0 running
#108 seat add-ons 0 running
#109 price categories 0 running
Waiting on the GitHub rate limit, spent until 12:45Z and shared with the other sessions on
the account. Scheduled for right after: relink the 9 PRs in the stack view, and archive the
closed PRs' board cards.
Monitor: E2E reruns for the 9 PRs → green on all 9, each on its first run. Monitor stopped.
Live check on the new top preview (<sha>):
1. The buy button creates the hold first: the cart call returns 201. Only then does the
add-on list open. The list itself creates nothing.
2. On the add-on sheet, before a time is picked, Add is disabled and no rows show.
3. The time list loads with the add-on list, not when the sheet opens: 6 times.
4. Picking a time loads its inventory: three ticket types, each starting at 0.
5. Add stays disabled until at least one unit is chosen.
Screenshot at step 4. This morning's failures were staging outages, not the code.
Next: posting the QA result on the top PR needs the screenshots attached first. Asked,
not assumed.
The owners ran as parallel lanes beside the main session, which ran Opus 5.5 with 1M context at xhigh:
◯ review-climber handles the open human review threads 4h 16m
◯ main-climber runs climb 6 of the stack 3h 01m
◯ stack-consolidator-plan plans how to consolidate the stack 2h 09m
◯ consolidator executes the chosen consolidation option 1h 44m
◯ body-writer drafts titles and descriptions for the 9 PRs 43m
The report has four parts and no narration: what changed, the evidence table, what is waiting and why, and what is next. A rate limit is a scheduled wait, not a fence. The monitor stopped as soon as its predicate held. The live check names every assertion point and the call behind it. Only side effects counted as progress: pushes, closed PRs, and green runs. For a run this long, keep the Mac awake from a side terminal with caffeinate -d -i -m -s -u.
The prompt granted rebuilding and pushing branches, closing absorbed PRs, re-review requests, ready-for-review, and the thread post. Without that grant viStack stops at draft PRs and posts nothing.
/vistack Why do Portal's error messages render inconsistently between InputField and
Alert — https://github.com/ORG/REPO/issues/101. Investigation only: write no
product code. Done means an impact map with file:line refs for every error-render path,
what is uncovered by tests, and a recommended approach posted as a comment on the issue.
Matches investigation, which is read-only by contract: no edits, no branches, no worktrees, no PRs. If the answer turns out to need a change, the run says so and ends — new task starts the right playbook.
/vistack Make an HTML timeline of the login incident from the ledger and the linked PRs.
Done means one page with every state change in order and a source link on each entry.
Matches html-report: the deliverable is a page, so no worktree and no PR. The page lands at <state-root>/reports/ as one self-contained file, with a private artifact link on Claude Code. A run with more than one slice, or any unattended run, ends with a run report the same way.
/vistack Run QA on https://github.com/ORG/REPO/pull/321. Done means every assertion point
has a screenshot on the PR head and every browser scenario has a checked video.
Matches qa-verification. Each assertion point gets its screenshot, and each browser scenario gets a video with one chapter per assertion point. See QA evidence below.
/vistack.new task forces a fresh playbook match — it discards the open playbook and starts again at the principles index.stop or pause runs pause-safely and hands back the resume command.new task is how you move on.Longer version, including the four fences: docs/guide/usage.md. The failures that cost the most: docs/guide/common-mistakes.md.
viStack includes a local decision subsystem that improves high-frequency workflow choices without turning the model into another coding agent. The router, playbooks, coordinator, worktrees, state, ledgers, verification, QA, PR creation, fences, and merge-ready boundary remain authoritative. Laya only answers bounded questions such as “is this ticket ready?”, “should these slices run in parallel?”, or “does the evidence cover the acceptance criteria?”
The data flow is deliberately one-way:
task and viStack state
-> DecisionContext
-> deterministic policy sharp -> runs in code
-> split forks only: Jev (opt-in) -> Cloudflare clef-flash (opt-in, free daily Neurons) -> Ollama clef-flash (local, opt-in)
-> safety validation sharp -> runs in code
-> advisory Decision split -> the main session decides
-> existing viStack rule and coordinator
Every Decision says fork: sharp or fork: split. Sharp forks — which playbook, which file, which tool, which tier, retry or stop — are applied without a model turn; only split forks reach the main session.
The engine is enabled by default. Default-on means the decision hook is allowed to run; it does not mean a model download or cloud request happens automatically. With no configured model, auto returns the deterministic policy.
Turn refinement off for the current consuming project:
python3 scripts/vistack-decision.py decisions off
python3 scripts/vistack-decision.py decisions status
Turn it back on:
python3 scripts/vistack-decision.py decisions on
For Claude-hosted state, use --config .claude/vistack/decisions.json. For a single request, pass --disable-laya. For an environment-wide emergency switch, set VISTACK_LAYA_ENABLED=0. All of these switches leave the deterministic viStack policy active. They only disable optional model refinement.
Evaluate a decision directly:
python3 scripts/vistack-decision.py decision grooming \
--context examples/laya/grooming.json
The JSON result is typed and machine-consumable. It includes the decision type, action, bounded confidence, rationale, evidence considered, risks, required evidence, alternatives, conditions for changing the recommendation, backend, and fallback status. Every result is advisory-only; there is no action in the schema that grants permission to merge, force-push, deploy, delete data, change secrets, or bypass a fence.
The hook is available at these decision boundaries:
| Boundary | Decision types | What remains authoritative |
|---|---|---|
| Intake and grooming | intake-analysis, grooming | readiness fields and FENCE 2 |
| Route matching | playbook-selection | the playbook table and selected playbook |
| Slice planning | decomposition, tier-selection | file ownership, conflict matrix, 500-line limit, tier rule |
| Pre-dispatch and monitoring | dispatch-readiness, runtime-progress | coordinator state, dependencies, monitor, ledger |
| QA | verification | artifacts mapped to acceptance criteria |
| Retrospective review | skill-improvement | explicit human review and an evidence-backed change |
The model cannot create evidence. A verification recommendation of accept is rejected unless captured artifacts cover every acceptance criterion. A parallelization recommendation is rejected when the conflict matrix or dependency state says the lanes must serialize. A dispatch recommendation is rejected when the brief, writable file list, dependency proof, or verification command is missing.
To reduce latency for repeated decisions, run one resident process:
python3 scripts/vistack-decision.py serve
A missing model, an unreachable server, a malformed result, a timeout, a low-confidence result, or a failed safety gate all return the deterministic fallback. No cloud LLM is required for the decision layer.
Hosted Jev settled 7 of 10 labelled split forks, none wrong, in about 350 ms. It is opt-in because it sends the redacted decision state to TypeSafe: decisions on --jev for a project or VISTACK_LAYA_JEV=1 for a shell, with the key in TYPESAFE_API_KEY or TYPESAFE_KEY. An opted-in Jev leads the ladder, and the next tier answers when Jev is refused or offline.
Cloudflare Workers AI serves the same model as @cf/cloudflare/clef-flash. It is opt-in because it sends the redacted decision state to Cloudflare: decisions on --cloudflare for a project or VISTACK_LAYA_CLOUDFLARE=1 for a shell, with CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID in the environment. Credentials alone do not opt in. It stays inside the 10,000 free Neurons a day (default cap 9,000), and at the cap split forks go to the local tier. One live decision cost about 1.8 Neurons. Its 0.85 floor is inherited from the Ollama measurements and not yet measured on Workers AI: docs/guide/decision-engine.md.
Ollama runs the local tier with no key, and the state never leaves the machine. The one supported model is clef-flash, Cloudflare's 9B System One decision model, which needs Ollama 0.35.1 or newer:
ollama pull clef-flash # about 11 GB
python3 scripts/vistack-decision.py decisions on --ollama-model clef-flash
/vistack:decisions-on asks whether to use it and pulls it when you pick it. It answers the split forks the cloud tiers left unsettled, and it accepts an answer only at 0.85 confidence or higher, its own measured threshold. Measured end to end on the 108 labelled scenarios, on an Apple M3 Pro with 36 GB, it settled 7 of 10 split forks with none wrong, at about 1 s per fork it handles. It holds 14.2 GB of memory while loaded and takes about 7 s to load, which decisions on pays up front. The guide has the table, the threshold evidence, and the confidence-scale caveat: docs/guide/decision-engine.md.
Each decision can be recorded in local JSONL history. Use override when a human changes a recommendation, outcome when the lane finishes, and feedback to find repeated overrides:
python3 scripts/vistack-decision.py override dec_123 pause \
--recommended-action continue \
--reason "The lane reached a safe stop."
python3 scripts/vistack-decision.py outcome dec_123 completed --evidence test-output.txt
python3 scripts/vistack-decision.py feedback
Feedback produces review proposals; it never edits skills/vistack/SKILL.md automatically. Use docs/guide/decision-engine.md for the full schema, protocol references, measurements, failure behavior, and extension procedure.
decisions-on, decisions-off, and decisions-status commands. The Claude commands run the plugin's script through ${CLAUDE_PLUGIN_ROOT}, so they work from any consuming project..grok-plugin/plugin.json and uses the same skills, agents, commands, and local decision switch. The xAI marketplace entry must be pinned to the published commit SHA; generate it with python3 scripts/grok-marketplace-entry.py --sha <sha>.integrations/opencode/vistack.js. Copy it to .opencode/plugins/vistack.js to expose vistack_decision, vistack_decisions_toggle, and vistack_qa_video.See docs/guide/grok-opencode.md for marketplace and plugin installation details.
Run the local HTML observer to see coordinator state, agent lanes, skills, playbooks, ledger evidence, pull requests, and addressed or open review threads:
node scripts/visualizer-server.mjs --enable
open http://127.0.0.1:47319
Use node scripts/visualizer-server.mjs --status and --disable to control the detached server. The page's LIVE SYNC toggle pauses polling without stopping it. See docs/guide/visualizer.md for data sources and offline fixtures. The lifecycle skill provides the same controls through vistack:visualizer on, off, and status.
viStack ships a Claude Code mod: a docked pane with nine tabs (Board, Agents, Cost, Session, Changes, Timeline, Flow, Recall, Settings). It shows what waits on you, each subagent with its model, tokens, and cost, cost per model and per execution, context and rate limits, the files edited, where a turn's time went, viStack run state and worktrees, and a search over the session's prompts. The Board also lists background shells, watches, agents, and /loop wakeups by state, with Stop, Cancel, and Relaunch buttons and a New loop form. /deck opens it, /deck <tab> jumps to a tab, 1 to 9 switch tabs in the pane, and g opens lazygit beside the session.
It loads with the plugin: after an install or update, run /reload-plugins. PR listing and lazygit stay off until you set your realm:
{ "pluginConfigs": { "vistack": { "options": { "realm": "github.com/your-org" } } } }
See docs/guide/deck.md for the tabs, options, model suggestions, and what the deck reads and runs.
/vistack:review-watch on starts a standing watch. Every 30 minutes until you switch it off, a pass reviews each open pull request in your realm that requests your review or your team's, and answers each comment that tags you or your team. It posts as you, in your voice: one COMMENT review per head commit and one reply per mention. It never approves, requests changes, or resolves a thread, and a question only you can answer goes to a needs-you list on the deck's Board. Drafts, your own pull requests, archived repositories, bots, and anything outside the realm are skipped. off and status do what they say. Without the deck, or on Codex, run on in each session, because nothing re-arms the loop. See docs/guide/review-watch.md for setup, scopes, limits, and the first-run checklist.
A QA pass ends with a results table, one row per assertion point. The screenshot taken at that point decides pass or fail. Each browser scenario is also recorded as a video, so a reviewer can see how the page reached that state: the transition, the order of events, the timing. A video never turns a fail into a pass.
| Row field | Comes from |
|---|---|
| screenshot | <scenario>-<step>.png, captured at the end of the step |
| video | <scenario>.mp4 @ 00:04-00:09, with times
hooks/register.tsx 2591 lines1import { atom, read, update } from 'claude-code'
2
3import { adviceHead, advisorSteps, consultsFromApi, consultsFromStep, consultsFromTranscript } from './lib/advisor'
4import { AWAKE_LINGER_MS, DEFAULT_KEEP_AWAKE, awakePlan, readKeepAwake, shouldHoldAwake } from './lib/awake'
5import { needsReply } from './lib/board'
6import { isLive, nickname, roleOf } from './lib/crew'
7import { basename, duration, usd } from './lib/format'
8import { DEFAULT_ROSTER, decisionContext, frontmatterModel, mayApply, parseDecision } from './lib/jev'
9import { launchPlan, lazygitMarker, pickLauncher } from './lib/launch'
10import {
11 addUp,
12 editDelta,
13 finishTool,
14 makeStep,
15 mergeEdit,
16 patchAgent,
17 patchTurn,
18 recordStep,
19 recordTurn,
20 startTool,
21 toolDetail,
22} from './lib/ledger'
23import {
24 ADVISOR_TOOL,
25 CONSULT_CAP,
26 consultsFrom,
27 isOnStaff,
28 isWorking,
29 mergeConsults,
30 nextGeneration,
31 partyOf,
32 recordComm,
33 reviewPostOf,
34 seatName,
35} from './lib/org'
36import {
37 applyChange,
38 fromAgents,
39 fromCronList,
40 fromRuns,
41 fromStopSnapshot,
42 fromTaskNotifications,
43 fromToolCall,
44 isOpen,
45 isSessionWorking,
46 noteFromTask,
47 resolveAbsent,
48 settle,
49 stopAllCalls,
50 stopCall,
51 stoppedBy,
52 taskNotesFrom,
53} from './lib/monitors'
54import { modelLabel } from './lib/pricing'
55import { effectiveRealm, isInRealm, normalizeRemote, realmParts } from './lib/realm'
56import { pairs, search } from './lib/recall'
57import { relaunchPlan, withLoopArgs } from './lib/relaunch'
58import { carryWatch, isWatchLoop, parseWatchState, planWatch, watchLoopArgs } from './lib/review-watch'
59import { DEFAULT_SETTINGS, iconsOf, paletteOf, readSettings } from './lib/theme'
60import { gitdirOf, headBranch, isPrChange, lastLedgerRow, parsePrSearch, parseRun, prSearchArgs } from './lib/workflow'
61import { agentsTab } from './tabs/agents'
62import { WATCH_OFF_CONFIRM, boardTab } from './tabs/board'
63import { changesTab } from './tabs/changes'
64import { costTab } from './tabs/cost'
65import { Header } from './tabs/parts'
66import { recallTab } from './tabs/recall'
67import { sessionTab } from './tabs/session'
68import { settingsTab } from './tabs/settings'
69import { timelineTab } from './tabs/timeline'
70import { workflowTab } from './tabs/workflow'
71
72import type {
73 AgentSpawnResult,
74 EngineInterface,
75 HookStream,
76 ProcessSpawnChunk,
77 ProcessSpawnResult,
78 Register,
79 RenderElement,
80 Timer,
81 TurnStepChunk,
82 TurnStepResult,
83} from 'claude-code'
84import type { Decision, Roster } from './lib/jev'
85import type { Launcher } from './lib/launch'
86import type { ReviewPost } from './lib/org'
87import type { RealmSource } from './lib/realm'
88import type { RelaunchCall } from './lib/relaunch'
89import type { IconName } from './lib/theme'
90import type { AgentAction } from './tabs/agents'
91import type { LoopDraft, MonitorAction } from './tabs/monitors'
92import type { Kit, KitElements } from './tabs/parts'
93import type {
94 CronSummary,
95 DeckActivity,
96 DeckAdvisor,
97 DeckAgent,
98 DeckAwake,
99 DeckComm,
100 DeckConsult,
101 DeckEdit,
102 DeckLazygit,
103 DeckMonitor,
104 DeckPick,
105 DeckPlacement,
106 DeckPrs,
107 DeckReviewWatch,
108 DeckRun,
109 DeckSettings,
110 DeckSkill,
111 DeckStep,
112 DeckTab,
113 DeckTodo,
114 DeckTool,
115 DeckTurn,
116 DeckUsage,
117 DeckWorkflow,
118 DeckWorktree,
119 KeepAwake,
120 MonitorChange,
121 MonitorEndNote,
122 StopCall,
123 TaskSummary,
124} from '../types'
125
126type Dollar = EngineInterface
127
128// Work the hooks hand to the session's timer, so a hook never waits on a slow process.
129type Job =
130 | { kind: 'usage'; isForced: boolean }
131 | { kind: 'workflow' }
132 | { kind: 'prs' }
133 | { kind: 'agents' }
134 | { kind: 'script' }
135 | { kind: 'about' }
136 | { kind: 'model' }
137 | { kind: 'advisor-scan' }
138 | { kind: 'lazygit-check' }
139 | { kind: 'lazygit-toggle' }
140 | { kind: 'decide-turn'; turnId: string; text: string; at: number }
141 | { kind: 'decide-agent'; agentId: string; request: string; subject: string; model: string; at: number }
142 | { kind: 'settle'; pickId: string; result: string; usd: number | null }
143 | { kind: 'kill'; agentId: string }
144 | { kind: 'fire'; agentId: string }
145 | { kind: 'hire'; agentId: string; isReload: boolean }
146 | { kind: 'ask-agent'; agentId: string; text: string }
147 | { kind: 'draft'; text: string }
148 | { kind: 'stop-turn' }
149 | { kind: 'monitor-change'; change: MonitorChange }
150 | { kind: 'monitor-notes'; notes: MonitorEndNote[] }
151 | { kind: 'monitor-scan' }
152 | { kind: 'monitor-crons' }
153 | { kind: 'monitor-snapshot'; tasks: readonly TaskSummary[] | undefined; crons: readonly CronSummary[] | undefined; isComplete: boolean }
154 | { kind: 'monitor-stop'; id: string }
155 | { kind: 'monitor-stop-all' }
156 | { kind: 'monitor-relaunch'; id: string }
157 | { kind: 'loop-create'; args: string }
158 | { kind: 'loop-tie'; args: string; at: number }
159 | { kind: 'review-watch' }
160 | { kind: 'review-watch-off' }
161
162type AwakeHold = { how: string; stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult> }
163
164type ToolRecipe = Extract<RelaunchCall, { via: 'tool' }>
165
166// The plugin's options from pluginConfigs.
167type Config = {
168 realm: string
169 jevMode: string
170 isAutoOpen: boolean
171 launcher: Launcher
172 vistackRoot: string
173}
174
175const PLUGIN = 'vistack'
176const PANE = 'vistack-deck'
177const COMMAND = 'deck'
178const PANE_COLUMNS = 64
179const STORE_KEY = 'deckSettings'
180// Kept apart from the look settings so Reset leaves them alone.
181const REALM_KEY = 'deckRealm'
182const KEEP_AWAKE_KEY = 'deckKeepAwake'
183const PR_POLL_MS = 60_000
184const LIVE_TICK_MS = 300
185const IDLE_TICK_MS = 5000
186const PROMPT_CAP = 8000
187const COMM_TEXT_CAP = 200
188const AGENT_CAP = 60
189// The main transcript's advisor rows. `$1` is the session id, the file's name in whichever project
190// directory holds it; grep reads past $.fs.read's 4 MiB cap.
191const ADVISOR_ROWS = [
192 `grep -h -s -F -e '"advisor_tool_result"' -e '"name":"advisor"'`,
193 '-- "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/projects/*/"$1".jsonl',
194].join(' ')
195const SESSION_ID = /^[\w-]+$/
196const WATCH_COMMAND = 'review-watch'
197const WATCH_SCRIPT = 'skills/review-watch/scripts/watch-state.mjs'
198const WATCH_SYNC_MS = 5 * 60_000
199const NO_WATCH_COMMAND = 'command not loaded'
200// `watch-state.mjs off` exits 1 while another writer holds the state file's lock.
201const OFF_ATTEMPTS = 3
202const OFF_RETRY_MS = 2000
203
204const TABS: readonly { id: DeckTab; label: string; hotkey: string; icon: IconName }[] = [
205 { hotkey: '1', icon: 'tab-board', id: 'board', label: 'Board' },
206 { hotkey: '2', icon: 'tab-agents', id: 'agents', label: 'Agents' },
207 { hotkey: '3', icon: 'tab-cost', id: 'cost', label: 'Cost' },
208 { hotkey: '4', icon: 'tab-session', id: 'session', label: 'Session' },
209 { hotkey: '5', icon: 'tab-changes', id: 'changes', label: 'Changes' },
210 { hotkey: '6', icon: 'tab-timeline', id: 'timeline', label: 'Timeline' },
211 { hotkey: '7', icon: 'tab-workflow', id: 'workflow', label: 'Flow' },
212 { hotkey: '8', icon: 'tab-recall', id: 'recall', label: 'Recall' },
213 { hotkey: '9', icon: 'tab-settings', id: 'settings', label: 'Settings' },
214]
215
216const LAUNCHERS: readonly Launcher[] = ['auto', 'tmux', 'ghostty', 'iterm', 'terminal']
217
218// Kill, fire, reload and stop take a second press of the same key.
219const CONFIRMED: readonly AgentAction[] = ['kill', 'fire', 'reload', 'stop']
220
221// The deck's state, held by the host for the session. The plugin name is written here and in
222// types/index.d.ts only.
223const tab = atom({ plugin: 'vistack', key: 'tab' } as const, 'board' as DeckTab)
224const steps = atom({ plugin: 'vistack', key: 'steps' } as const, [] as DeckStep[])
225const tools = atom({ plugin: 'vistack', key: 'tools' } as const, [] as DeckTool[])
226const edits = atom({ plugin: 'vistack', key: 'edits' } as const, [] as DeckEdit[])
227const turns = atom({ plugin: 'vistack', key: 'turns' } as const, [] as DeckTurn[])
228const agents = atom({ plugin: 'vistack', key: 'agents' } as const, [] as DeckAgent[])
229const picks = atom({ plugin: 'vistack', key: 'picks' } as const, [] as DeckPick[])
230const skills = atom({ plugin: 'vistack', key: 'skills' } as const, [] as DeckSkill[])
231const todos = atom({ plugin: 'vistack', key: 'todos' } as const, [] as DeckTodo[])
232const prs = atom({ plugin: 'vistack', key: 'prs' } as const, {
233 state: 'off',
234 items: [],
235 checkedAt: 0,
236} as DeckPrs)
237const workflow = atom({ plugin: 'vistack', key: 'workflow' } as const, null as DeckWorkflow | null)
238const usage = atom({ plugin: 'vistack', key: 'usage' } as const, null as DeckUsage | null)
239const lazygit = atom({ plugin: 'vistack', key: 'lazygit' } as const, { isOpen: false } as DeckLazygit)
240const frame = atom({ plugin: 'vistack', key: 'frame' } as const, 0)
241const query = atom({ plugin: 'vistack', key: 'query' } as const, '')
242const openId = atom({ plugin: 'vistack', key: 'openId' } as const, '')
243const deckSettings = atom({ plugin: 'vistack', key: 'settings' } as const, DEFAULT_SETTINGS as DeckSettings)
244const activity = atom({ plugin: 'vistack', key: 'activity' } as const, {} as Record<string, DeckActivity>)
245const comms = atom({ plugin: 'vistack', key: 'comms' } as const, [] as DeckComm[])
246const advisor = atom({ plugin: 'vistack', key: 'advisor' } as const, { consults: [], isAdvising: false } as DeckAdvisor)
247const selected = atom({ plugin: 'vistack', key: 'selected' } as const, '')
248const confirm = atom({ plugin: 'vistack', key: 'confirm' } as const, '')
249const asking = atom({ plugin: 'vistack', key: 'asking' } as const, '')
250const isBandOpen = atom({ plugin: 'vistack', key: 'isBandOpen' } as const, false)
251const model = atom({ plugin: 'vistack', key: 'model' } as const, '')
252const monitors = atom({ plugin: 'vistack', key: 'monitors' } as const, [] as DeckMonitor[])
253const keepAwake = atom({ plugin: 'vistack', key: 'keepAwake' } as const, DEFAULT_KEEP_AWAKE as KeepAwake)
254const awake = atom({ plugin: 'vistack', key: 'awake' } as const, { isHeld: false } as DeckAwake)
255const loopPrompt = atom({ plugin: 'vistack', key: 'loopPrompt' } as const, '')
256const loopInterval = atom({ plugin: 'vistack', key: 'loopInterval' } as const, '')
257const reviewWatch = atom({ plugin: 'vistack', key: 'reviewWatch' } as const, null as DeckReviewWatch | null)
258
259// Module state: rebuilt by register and session.start on every load.
260let config: Config = { isAutoOpen: true, jevMode: 'suggest', launcher: 'auto', realm: '', vistackRoot: '' }
261let cwd = ''
262let home: string | undefined
263let remote: string | null = null
264let script: string | null = null
265let roster: Roster = DEFAULT_ROSTER
266let currentTurnId: string | undefined
267let lastUsageAt = 0
268let lastSlowTick = 0
269let commCount = 0
270let sessionId = ''
271let deckVersion = ''
272let deckRealm = ''
273let isDraining = false
274// Set once an end report reaches the deck through prompt.submit or a notification row. The
275// transcript scan never sets it: reports delivered into a running turn are missing there.
276let isFeedLive = false
277// The sleep-lock child; returning its stream kills it.
278let awakeHold: AwakeHold | null = null
279// Why the lock cannot be held; no child starts again until the setting changes.
280let awakeFailure: string | null = null
281let awakePlatform: string | null = null
282let isSyncingAwake = false
283let lastWorkAt = Number.NEGATIVE_INFINITY
284// A /loop the deck ran or the person typed, claimed by the next turn; when that turn ends, its
285// args become the recipe of the row the loop made.
286let pendingLoop: { args: string; at: number; turnId?: string } | null = null
287// `e.isInteractive` at session.start: a headless session never arms the review watch.
288let isInteractive = false
289let watchScript: string | null = null
290// The first arm of a session lists the crons first, in case the loop exists but no row shows it.
291let isWatchCronListed = false
292let timers: Timer[] = []
293let jobs: Job[] = []
294
295const optionText = (value: unknown, fallback: string): string => (typeof value === 'string' ? value : fallback)
296
297const field = (input: unknown, name: string): unknown =>
298 typeof input === 'object' && input !== null ? (input as Record<string, unknown>)[name] : undefined
299
300const enqueue = (job: Job): void => {
301 jobs.push(job)
302}
303
304const realmNow = (): { realm: string; source: RealmSource } => effectiveRealm(deckRealm, config.realm)
305
306const isGitAllowed = (): boolean => {
307 const { realm } = realmNow()
308
309 return realm !== '' && isInRealm(remote, realm)
310}
311
312const tail = (text: string): string => text.trim().replace(/\s+/g, ' ').slice(-COMM_TEXT_CAP)
313
314const exists = async ($: Dollar, path: string): Promise<boolean> => {
315 try {
316 await $.fs.stat(path)
317
318 return true
319 } catch {
320 return false
321 }
322}
323
324const readText = async ($: Dollar, path: string): Promise<string | null> => {
325 try {
326 return await $.fs.read(path)
327 } catch {
328 return null
329 }
330}
331
332const listDir = async ($: Dollar, path: string) => {
333 try {
334 return await $.fs.list(path)
335 } catch {
336 return []
337 }
338}
339
340const isSameList = (before: readonly DeckMonitor[], after: readonly DeckMonitor[]): boolean =>
341 before.length === after.length && after.every((monitor, index) => monitor === before[index])
342
343// Writes the monitors only when `revise` changed a row, so a quiet tick redraws nothing.
344const reviseMonitors = async ($: Dollar, revise: (list: readonly DeckMonitor[]) => DeckMonitor[]): Promise<void> => {
345 const list = await read($, monitors)
346
347 if (!isSameList(list, revise(list))) {
348 await update($, monitors, revise)
349 }
350}
351
352const refreshUsage = async ($: Dollar, isForced: boolean): Promise<void> => {
353 const at = await $.clock.now()
354
355 if (!isForced && at - lastUsageAt < 2000) {
356 return
357 }
358 lastUsageAt = at
359
360 const figures = await $.session.usage()
361
362 await update($, usage, () => ({
363 at,
364 limits: figures.rateLimits.map(limit => ({
365 kind: limit.kind,
366 percent: limit.percentUsed,
367 ...(limit.resetsAt === undefined ? {} : { resetsAt: limit.resetsAt }),
368 })),
369 startedAt: figures.startedAt,
370 window: figures.context.window,
371 ...(figures.context.percent === undefined ? {} : { contextPercent: figures.context.percent }),
372 ...(figures.context.tokens === undefined ? {} : { contextTokens: figures.context.tokens }),
373 ...(figures.cost === undefined ? {} : { usd: figures.cost.usd }),
374 }))
375}
376
377// The person's open PRs across the realm owner's repositories, whatever the checkout's origin.
378const refreshPrs = async ($: Dollar): Promise<void> => {
379 const at = await $.clock.now()
380 const { realm } = realmNow()
381 const parts = realmParts(realm)
382
383 if (parts === null) {
384 await update($, prs, () => ({
385 checkedAt: at,
386 items: [],
387 state: 'off' as const,
388 ...(realm === '' ? {} : { reason: `${realm} names no owner: use host/owner, e.g. github.com/your-org` }),
389 }))
390
391 return
392 }
393 await update($, prs, current => ({ ...current, state: 'loading' as const }))
394 try {
395 const search = (qualifier: 'user' | 'org') => $.process.run(prSearchArgs(parts, qualifier), { cwd, timeoutMs: 20_000 })
396 const asUser = await search('user')
397 // `user:` can refuse an organization; `org:` is tried once before giving up.
398 const ran = asUser.exitCode === 0 ? asUser : await search('org')
399 const reason = ran.stderr.trim().split('\n')[0] || 'gh failed'
400
401 await update($, prs, () =>
402 ran.exitCode === 0
403 ? { checkedAt: at, items: parsePrSearch(ran.stdout), state: 'ok' as const }
404 : { checkedAt: at, items: [], reason, state: 'error' as const },
405 )
406 } catch (error) {
407 await update($, prs, () => ({ checkedAt: at, items: [], reason: String(error), state: 'error' as const }))
408 }
409}
410
411// The branch a checkout is on, read from its HEAD file: no git process runs.
412const branchOf = async ($: Dollar, dir: string): Promise<string | undefined> => {
413 const dotGit = `${dir}/.git`
414 const pointer = await readText($, dotGit)
415 const gitdir = pointer === null ? dotGit : gitdirOf(pointer)
416
417 if (gitdir === undefined) {
418 return undefined
419 }
420
421 const head = await readText($, `${gitdir.startsWith('/') ? gitdir : `${dir}/${gitdir}`}/HEAD`)
422
423 return head === null ? undefined : headBranch(head)
424}
425
426const refreshWorkflow = async ($: Dollar): Promise<void> => {
427 const at = await $.clock.now()
428 const repo = await $.session.repo().catch(() => null)
429
430 remote = repo?.remote ?? null
431
432 const repoRoot = repo?.root ?? cwd
433 const runs: DeckRun[] = []
434
435 for (const stateRoot of [`${cwd}/.claude/state`, `${cwd}/.codex/vistack/state`]) {
436 const entries = await listDir($, stateRoot)
437
438 for (const entry of entries.filter(one => one.kind === 'file' && one.name.endsWith('.json'))) {
439 const slug = entry.name.replace(/\.json$/, '')
440 const source = await readText($, `${stateRoot}/${entry.name}`)
441 const run = source === null ? null : parseRun(slug, stateRoot, source)
442
443 if (run === null || (run.slices.length === 0 && run.playbook === undefined)) {
444 continue
445 }
446
447 const ledger = await readText($, `${stateRoot}/${slug}.tsv`)
448 const last = ledger === null ? undefined : lastLedgerRow(ledger)
449
450 runs.push(last === undefined ? run : { ...run, lastLedger: last })
451 }
452 }
453
454 const worktrees: DeckWorktree[] = []
455
456 for (const treeRoot of [`${cwd}/.claude/worktrees`, `${cwd}/.codex/vistack/worktrees`]) {
457 for (const entry of (await listDir($, treeRoot)).filter(one => one.kind === 'dir')) {
458 const path = `${treeRoot}/${entry.name}`
459 const branch = await branchOf($, path)
460
461 worktrees.push({ name: entry.name, path, ...(branch === undefined ? {} : { branch }) })
462 }
463 }
464
465 const branch = await branchOf($, repoRoot)
466
467 await update($, workflow, () => ({
468 checkedAt: at,
469 cwd,
470 isInRealm: isGitAllowed(),
471 repoRoot,
472 runs,
473 worktrees,
474 ...(branch === undefined ? {} : { branch }),
475 ...(remote === null ? {} : { remote }),
476 }))
477 await reviseMonitors($, list => fromRuns(list, runs, at))
478}
479
480const pluginRoots = ($: Dollar): string[] =>
481 [config.vistackRoot, $.plugin.root, home === undefined ? '' : `${home}/.claude/plugins/marketplaces/vistack`].filter(one => one !== '')
482
483const loadDecisionScript = async ($: Dollar): Promise<void> => {
484 for (const root of pluginRoots($)) {
485 const path = `${root}/scripts/vistack-decision.py`
486
487 if (await exists($, path)) {
488 script = path
489
490 const mechanical = frontmatterModel((await readText($, `${root}/agents/implementer.md`)) ?? '')
491 const complex = frontmatterModel((await readText($, `${root}/agents/senior-implementer.md`)) ?? '')
492
493 roster = { complex: complex ?? DEFAULT_ROSTER.complex, mechanical: mechanical ?? DEFAULT_ROSTER.mechanical }
494
495 return
496 }
497 }
498 script = null
499}
500
501// The session id and the viStack version the Settings tab shows.
502const loadAbout = async ($: Dollar): Promise<void> => {
503 sessionId = await $.session.id().catch(() => '')
504
505 const manifest = await readText($, `${$.plugin.root}/.claude-plugin/plugin.json`)
506
507 try {
508 const version = field(JSON.parse(manifest ?? '{}'), 'version')
509
510 deckVersion = typeof version === 'string' ? version : ''
511 } catch {
512 deckVersion = ''
513 }
514}
515
516const loadModel = async ($: Dollar): Promise<void> => {
517 const name = await $.session.model().catch(() => '')
518
519 await update($, model, () => name)
520}
521
522// The fork layer's history and switch file live in the consuming repo when it uses viStack.
523const decisionFlags = async ($: Dollar): Promise<string[]> => {
524 const stateDir = `${cwd}/.claude/vistack`
525
526 if (!(await exists($, stateDir))) {
527 return ['--no-history']
528 }
529
530 const decisions = `${stateDir}/decisions.json`
531
532 return ['--history', `${stateDir}/decision-history.jsonl`, ...((await exists($, decisions)) ? ['--config', decisions] : [])]
533}
534
535const decide = async ($: Dollar, request: string, source: string): Promise<Decision | null> => {
536 if (script === null || request.trim() === '') {
537 return null
538 }
539 try {
540 const ran = await $.process.run(
541 ['python3', script, 'decision', 'tier-selection', '--backend', 'auto', ...(await decisionFlags($))],
542 { cwd, stdin: decisionContext(request, source), timeoutMs: 20_000 },
543 )
544
545 return ran.exitCode === 0 ? parseDecision(ran.stdout) : null
546 } catch {
547 return null
548 }
549}
550
551const recordOutcome = async ($: Dollar, pick: DeckPick): Promise<void> => {
552 if (script === null || pick.decisionId === undefined || !(await exists($, `${cwd}/.claude/vistack`))) {
553 return
554 }
555
556 const evidence = `actual=${pick.actual ?? '?'} recommended=${pick.recommended} usd=${(pick.usd ?? 0).toFixed(4)}`
557
558 await $.process
559 .run(
560 [
561 'python3',
562 script,
563 'outcome',
564 pick.decisionId,
565 pick.result ?? 'unknown',
566 '--evidence',
567 evidence,
568 '--history',
569 `${cwd}/.claude/vistack/decision-history.jsonl`,
570 ],
571 { cwd, timeoutMs: 10_000 },
572 )
573 .catch(() => undefined)
574}
575
576const addPick = async ($: Dollar, pick: DeckPick): Promise<void> => {
577 await update($, picks, list => [...list.filter(one => one.id !== pick.id), pick].slice(-80))
578}
579
580const decideTurn = async ($: Dollar, turnId: string, text: string, at: number): Promise<void> => {
581 const decision = await decide($, text, 'main-turn')
582
583 if (decision === null) {
584 return
585 }
586
587 const firstStep = (await read($, steps)).find(step => step.agentId === undefined && step.turnId === turnId)
588
589 await addPick($, {
590 at,
591 backend: decision.backend,
592 confidence: decision.confidence,
593 id: `turn-${turnId}`,
594 isApplied: false,
595 recommended: roster[decision.tier],
596 scope: 'main',
597 subject: text,
598 tier: decision.tier,
599 turnId,
600 ...(decision.decisionId === undefined ? {} : { decisionId: decision.decisionId }),
601 ...(firstStep === undefined ? {} : { actual: firstStep.model }),
602 })
603}
604
605const agentPick = (
606 decision: Decision,
607 input: { agentId: string; subject: string; model: string; at: number },
608 isApplied: boolean,
609): DeckPick => ({
610 actual: input.model,
611 agentId: input.agentId,
612 at: input.at,
613 backend: decision.backend,
614 confidence: decision.confidence,
615 id: `agent-${input.agentId}`,
616 isApplied,
617 recommended: roster[decision.tier],
618 scope: 'agent',
619 subject: input.subject,
620 tier: decision.tier,
621 ...(decision.decisionId === undefined ? {} : { decisionId: decision.decisionId }),
622})
623
624const decideAgent = async ($: Dollar, job: Extract<Job, { kind: 'decide-agent' }>): Promise<void> => {
625 const decision = await decide($, job.request, 'agent-spawn')
626
627 if (decision !== null) {
628 await addPick($, agentPick(decision, job, false))
629 }
630}
631
632const settlePick = async ($: Dollar, pickId: string, result: string, cost: number | null): Promise<void> => {
633 const found = (await read($, picks)).find(pick => pick.id === pickId)
634
635 if (found === undefined) {
636 return
637 }
638
639 const settled = { ...found, result, usd: cost }
640
641 await addPick($, settled)
642 await recordOutcome($, settled)
643}
644
645const syncAgents = async ($: Dollar): Promise<void> => {
646 const known = await read($, agents)
647
648 if (!known.some(agent => isLive(agent.status) || agent.status === 'waiting')) {
649 return
650 }
651
652 const listed = await $.agent.list()
653 const at = await $.clock.now()
654
655 await update($, agents, list => {
656 let next = list
657
658 for (const info of listed) {
659 const found = next.find(agent => agent.agentId === info.id)
660
661 if (found === undefined) {
662 const added: DeckAgent = {
663 agentId: info.id,
664 description: info.description,
665 model: '',
666 nickname: nickname(info.type, info.id, next.map(agent => agent.nickname)),
667 startedAt: at,
668 status: info.status,
669 toolUseId: '',
670 type: info.type,
671 ...(info.parentId === undefined ? {} : { parentId: info.parentId }),
672 }
673
674 next = [...next, added]
675 } else if (found.status !== info.status) {
676 next = patchAgent(next, info.id, {
677 status: info.status,
678 ...(isLive(info.status) || info.status === 'waiting' ? {} : { endedAt: found.endedAt ?? at }),
679 })
680 }
681 }
682
683 return next.slice(-AGENT_CAP)
684 })
685
686 // A fallback advisor that ended with no turn.complete would leave the seat advising.
687 const ended = new Set(listed.filter(info => !isLive(info.status) && info.status !== 'waiting').map(info => info.id))
688 const open = (await read($, advisor)).consults.filter(consult => isAgentAdvising(consult) && ended.has(consult.id))
689
690 for (const consult of open) {
691 await endConsult($, consult.id, at, agentConsult((await read($, steps)).filter(step => step.agentId === consult.id), ''))
692 }
693}
694
695const checkLazygit = async ($: Dollar): Promise<void> => {
696 const state = await read($, lazygit)
697
698 if (!state.isOpen) {
699 return
700 }
701
702 const ran = await $.process.run(['pgrep', '-f', lazygitMarker(cwd)], { timeoutMs: 5000 }).catch(() => null)
703
704 if (ran !== null && ran.exitCode === 1) {
705 await update($, lazygit, () => ({ isOpen: false }))
706 }
707}
708
709const toggleLazygit = async ($: Dollar): Promise<void> => {
710 if (!isGitAllowed()) {
711 $.ui.toast(
712 realmNow().realm === '' ? 'Set your Git realm in Settings (9) to use lazygit here.' : 'lazygit is off: origin is outside the realm.',
713 )
714
715 return
716 }
717
718 const state = await read($, lazygit)
719 const picked = pickLauncher(config.launcher, {
720 termProgram: (await $.env.get('TERM_PROGRAM')) ?? '',
721 tmux: (await $.env.get('TMUX')) ?? '',
722 })
723 const how = LAUNCHERS.find(one => one !== 'auto' && one === state.how) ?? picked
724 const plan = launchPlan(how === 'auto' ? picked : how, cwd)
725
726 try {
727 if (state.isOpen) {
728 await $.process.run(plan.close(state.handle ?? ''), { timeoutMs: 5000 })
729 await update($, lazygit, () => ({ isOpen: false }))
730
731 return
732 }
733
734 const ran = await $.process.run(plan.open, { cwd, timeoutMs: 10_000 })
735
736 await update($, lazygit, () =>
737 ran.exitCode === 0
738 ? { handle: ran.stdout.trim(), how: plan.how, isOpen: true }
739 : { error: ran.stderr.trim() || `${plan.how} exited ${ran.exitCode}`, isOpen: false },
740 )
741 } catch (error) {
742 await update($, lazygit, () => ({ error: String(error), isOpen: false }))
743 }
744}
745
746const addComm = async ($: Dollar, comm: Omit<DeckComm, 'id' | 'at'> & { at?: number }): Promise<void> => {
747 const at = comm.at ?? (await $.clock.now())
748
749 commCount += 1
750 await update($, comms, list => recordComm(list, { ...comm, at, id: `${at}-${commCount}` }))
751}
752
753const setActivity = async ($: Dollar, key: string, value: DeckActivity | null): Promise<void> => {
754 await update($, activity, map =>
755 value === null ? Object.fromEntries(Object.entries(map).filter(([name]) => name !== key)) : { ...map, [key]: value },
756 )
757}
758
759// A fallback advisor agent still at work: its consult ends with the agent's turn. A consult read
760// back from the session may never have been seen to start, so only these keep the seat advising.
761const isAgentAdvising = (consult: DeckConsult): boolean => consult.source === 'agent' && consult.endedAt === undefined
762
763const startConsult = async ($: Dollar, consult: DeckConsult): Promise<void> => {
764 await update($, advisor, value => ({
765 consults: [...value.consults.filter(one => one.id !== consult.id), consult].slice(-CONSULT_CAP),
766 isAdvising: true,
767 since: consult.at,
768 }))
769}
770
771// Posts new advice to the comms, except a fallback agent's: its report already carries it.
772const endConsult = async ($: Dollar, id: string, at: number, found: Partial<DeckConsult> = {}): Promise<void> => {
773 const known = (await read($, advisor)).consults.find(consult => consult.id === id)
774
775 await update($, advisor, value => {
776 const consults = value.consults.map(consult =>
777 consult.id === id ? { ...consult, ...found, endedAt: at, ms: Math.max(0, at - consult.at) } : consult,
778 )
779
780 return { ...value, consults, isAdvising: consults.some(isAgentAdvising) }
781 })
782 if (found.advice !== undefined && known !== undefined && known.advice === undefined && known.source !== 'agent') {
783 await addComm($, { at, from: 'advisor', kind: 'advice', text: tail(found.advice), to: 'coordinator' })
784 }
785}
786
787const agentConsult = (own: readonly DeckStep[], answer: string): Partial<DeckConsult> => {
788 const totals = addUp(own)
789 const model = own[own.length - 1]?.model
790 const advice = answer.trim()
791
792 return {
793 ...(model === undefined
794 ? {}
795 : {
796 cacheRead: totals.cacheRead,
797 cacheWrite: totals.cacheWrite,
798 input: totals.input,
799 model,
800 output: totals.output,
801 usd: totals.isPartial ? null : totals.usd,
802 }),
803 ...(advice === '' ? {} : { advice, adviceHead: adviceHead(advice) }),
804 }
805}
806
807// '' when the file or grep is missing, which leaves the consults unpriced.
808const readAdvisorRows = async ($: Dollar): Promise<string> => {
809 const id = sessionId === '' ? await $.session.id().catch(() => '') : sessionId
810
811 if (!SESSION_ID.test(id)) {
812 return ''
813 }
814
815 const ran = await $.process.run(['sh', '-c', ADVISOR_ROWS, 'sh', id], { timeoutMs: 10_000 }).catch(() => null)
816
817 return ran?.stdout ?? ''
818}
819
820const isSameStep = (known: DeckStep | undefined, step: DeckStep): boolean =>
821 known !== undefined && (Object.keys(step) as (keyof DeckStep)[]).every(key => known[key] === step[key])
822
823// One priced step per server consult, so the per-model table counts the advisor. A fallback
824// agent's own steps already price it.
825const recordAdvisorSteps = async ($: Dollar, consults: readonly DeckConsult[]): Promise<void> => {
826 const found = advisorSteps(consults.filter(consult => consult.source !== 'agent'))
827
828 await update($, steps, list =>
829 found.reduce((next, step) => (isSameStep(next.find(one => one.key === step.key), step) ? next : recordStep(next, step)), list),
830 )
831}
832
833// A server consult whose model and tokens the transcript has not given yet; an error has none.
834const isUnpriced = (consult: DeckConsult): boolean =>
835 consult.source !== undefined && consult.source !== 'agent' && consult.model === undefined && consult.error === undefined
836
837// Server consults read back from the session: the api form gives each result, the transcript the
838// advisor's model and tokens. The rows give a client advisor tool's advice.
839const scanAdvisor = async ($: Dollar): Promise<void> => {
840 const at = await $.clock.now()
841 const session = [
842 ...consultsFromApi(await $.session.messages({ as: 'api' }).catch(() => [])),
843 ...consultsFrom(await $.session.messages().catch(() => [])),
844 ]
845 const isPricing = mergeConsults((await read($, advisor)).consults, session, at).consults.some(isUnpriced)
846 const found = isPricing ? [...session, ...consultsFromTranscript(await readAdvisorRows($))] : session
847
848 if (found.length === 0) {
849 return
850 }
851
852 const { advised } = mergeConsults((await read($, advisor)).consults, found, at)
853
854 await update($, advisor, value => ({ ...value, consults: mergeConsults(value.consults, found, at).consults }))
855 await recordAdvisorSteps($, (await read($, advisor)).consults)
856 for (const consult of advised) {
857 await addComm($, { at, from: 'advisor', kind: 'advice', text: tail(consult.advice ?? ''), to: 'coordinator' })
858 }
859}
860
861// TaskStop on the agent; the reason it refused, or null once it stopped.
862const stopAgent = async ($: Dollar, agentId: string): Promise<string | null> => {
863 try {
864 const ran = await $.tool.call({ task_id: agentId, tool: 'TaskStop' })
865
866 if (ran.deny !== undefined) {
867 return ran.deny
868 }
869
870 return ran.isError === true ? (ran.text ?? 'TaskStop failed') : null
871 } catch (error) {
872 return String(error)
873 }
874}
875
876const stopFailure = (agent: DeckAgent, reason: string): string =>
877 `Could not stop ${agent.nickname}: ${reason}${
878 currentTurnId === undefined ? '' : ". If it runs in the foreground, stop the coordinator's turn instead (select it, press x)."
879 }`
880
881const findAgent = async ($: Dollar, agentId: string): Promise<DeckAgent | undefined> =>
882 (await read($, agents)).find(agent => agent.agentId === agentId)
883
884const killAgent = async ($: Dollar, agentId: string): Promise<void> => {
885 const agent = await findAgent($, agentId)
886
887 if (agent === undefined) {
888 return
889 }
890
891 const reason = await stopAgent($, agentId)
892
893 if (reason !== null) {
894 $.ui.toast(stopFailure(agent, reason))
895
896 return
897 }
898 await addComm($, { from: 'deck', kind: 'action', text: 'stop', to: agentId })
899 $.ui.toast(`Stopping ${agent.nickname}.`)
900 enqueue({ kind: 'agents' })
901}
902
903const fireAgent = async ($: Dollar, agentId: string): Promise<void> => {
904 const agent = await findAgent($, agentId)
905
906 if (agent === undefined) {
907 return
908 }
909 if (isWorking(agent)) {
910 const reason = await stopAgent($, agentId)
911
912 if (reason !== null) {
913 $.ui.toast(stopFailure(agent, reason))
914
915 return
916 }
917 }
918
919 const at = await $.clock.now()
920
921 await update($, agents, list => patchAgent(list, agentId, { leftAt: at, leftHow: 'fired' }))
922 await update($, selected, current => (current === agentId ? '' : current))
923 await addComm($, { at, from: 'deck', kind: 'action', text: 'fire', to: agentId })
924 $.ui.toast(`${agent.nickname} fired.`)
925}
926
927// Hire gives the same job to a new agent beside the old one; reload replaces the old one
928// with the seat's next generation.
929const rehire = async ($: Dollar, agentId: string, isReload: boolean): Promise<void> => {
930 const old = await findAgent($, agentId)
931 const verb = isReload ? 'reload' : 'hire for'
932
933 if (old?.prompt === undefined) {
934 $.ui.toast(`Cannot ${verb} ${old?.nickname ?? agentId}: its prompt is unknown.`)
935
936 return
937 }
938 if (isReload && isWorking(old)) {
939 const reason = await stopAgent($, agentId)
940
941 if (reason !== null) {
942 $.ui.toast(stopFailure(old, reason))
943
944 return
945 }
946 }
947
948 const started = await $.agent
949 .spawn({ description: old.description, prompt: old.prompt, subagentType: old.type })
950 .catch((error: unknown): AgentSpawnResult => ({ deny: String(error) }))
951
952 if (started.deny !== undefined || started.agentId === undefined) {
953 $.ui.toast(`Could not ${verb} ${old.nickname}: ${started.deny ?? 'no agent started'}`)
954
955 return
956 }
957
958 const hiredId = started.agentId
959 const at = await $.clock.now()
960 const list = await read($, agents)
961 const found = list.find(agent => agent.agentId === hiredId)
962 const generation = isReload ? nextGeneration(list, old) : 1
963 const name = isReload
964 ? `${seatName(old.nickname)} v${generation}`
965 : (found?.nickname ?? nickname(old.type, hiredId, list.map(agent => agent.nickname)))
966 const hired: DeckAgent = {
967 ...(found ?? {
968 agentId: hiredId,
969 description: old.description,
970 model: started.model,
971 startedAt: at,
972 status: 'running',
973 toolUseId: '',
974 type: old.type,
975 }),
976 generation,
977 hiredFrom: old.agentId,
978 nickname: name,
979 prompt: old.prompt,
980 }
981
982 await update($, agents, current => {
983 const kept = isReload ? patchAgent(current, old.agentId, { leftAt: at, leftHow: 'reloaded' }) : current
984
985 return [...kept.filter(agent => agent.agentId !== hiredId), hired].slice(-AGENT_CAP)
986 })
987 if (isReload) {
988 await update($, selected, current => (current === old.agentId ? hiredId : current))
989 }
990 await addComm($, { at, from: 'deck', kind: 'action', text: `${isReload ? 'reload' : 'hire'} → ${name}`, to: old.agentId })
991 $.ui.toast(isReload ? `${old.nickname} reloaded as ${name}.` : `${name} hired to ${old.description || old.type}.`)
992}
993
994const askAgent = async ($: Dollar, agentId: string, text: string): Promise<void> => {
995 const name = (await findAgent($, agentId))?.nickname ?? agentId
996
997 try {
998 const sent = await $.session.send({ text, to: { agentId } })
999
1000 $.ui.toast(sent.isDelivered ? `Sent to ${name}.` : `Not delivered to ${name}: ${sent.reason}`)
1001 } catch (error) {
1002 $.ui.toast(`Not delivered to ${name}: ${String(error)}`)
1003 }
1004}
1005
1006const draftPrompt = async ($: Dollar, text: string): Promise<void> => {
1007 try {
1008 const filled = await $.prompt.fill({ mode: 'replace', text })
1009
1010 $.ui.toast(filled.isFilled ? 'Draft in your prompt: press Enter to send it.' : 'The prompt box did not take the draft.')
1011 } catch (error) {
1012 $.ui.toast(`The prompt box did not take the draft: ${String(error)}`)
1013 }
1014}
1015
1016const stopTurn = async ($: Dollar): Promise<void> => {
1017 if (currentTurnId === undefined) {
1018 $.ui.toast('No coordinator turn is running.')
1019
1020 return
1021 }
1022 try {
1023 await $.turn.abort({ turnId: currentTurnId })
1024 await addComm($, { from: 'deck', kind: 'action', text: 'stop the turn', to: 'coordinator' })
1025 } catch (error) {
1026 $.ui.toast(`Could not stop the turn: ${String(error)}`)
1027 }
1028}
1029
1030const noteReview = async ($: Dollar, post: ReviewPost, from: string): Promise<void> => {
1031 const origin = remote === null ? null : normalizeRemote(remote)
1032 const repo = post.repo === '' ? (origin?.split('/').slice(1).join('/') ?? 'this repo') : post.repo
1033 const target = post.number === null ? `${repo} (current branch PR)` : `${repo}#${post.number}`
1034
1035 await addComm($, { from, kind: 'action', text: '💬 review posted', to: target })
1036 $.ui.toast(`💬 review comment posted on ${target}`)
1037 enqueue({ kind: 'prs' })
1038}
1039
1040const takeNotes = (notes: MonitorEndNote[]): void => {
1041 if (notes.length === 0) {
1042 return
1043 }
1044 isFeedLive = true
1045 enqueue({ kind: 'monitor-notes', notes })
1046}
1047
1048const applyNotes = async ($: Dollar, notes: readonly MonitorEndNote[]): Promise<void> => {
1049 const at = await $.clock.now()
1050
1051 await reviseMonitors($, list => fromTaskNotifications(list, notes, at))
1052}
1053
1054const scanMonitorNotes = async ($: Dollar): Promise<void> => {
1055 const notes = taskNotesFrom(await $.session.messages())
1056
1057 if (notes.length > 0) {
1058 await applyNotes($, notes)
1059 }
1060}
1061
1062const syncCrons = async ($: Dollar): Promise<void> => {
1063 const ran = await $.tool.call({ tool: 'CronList' })
1064 const listed = ran.deny === undefined && ran.isError !== true ? ran.result.jobs : undefined
1065
1066 if (!Array.isArray(listed)) {
1067 $.ui.log(`vistack-deck CronList: ${ran.deny ?? ran.text ?? 'no jobs listed'}`, { to: 'debug' })
1068
1069 return
1070 }
1071
1072 const at = await $.clock.now()
1073
1074 await reviseMonitors($, list => fromCronList(list, listed, at))
1075}
1076
1077const syncAgentMonitors = async ($: Dollar): Promise<void> => {
1078 const agentList = await read($, agents)
1079 const at = await $.clock.now()
1080
1081 await reviseMonitors($, list => fromAgents(list, agentList, at))
1082}
1083
1084const takeSnapshot = async ($: Dollar, job: Extract<Job, { kind: 'monitor-snapshot' }>): Promise<void> => {
1085 const at = await $.clock.now()
1086
1087 await reviseMonitors($, list => fromStopSnapshot(list, { crons: job.crons, tasks: job.tasks }, at, job.isComplete))
1088}
1089
1090// Applies the end itself, as the deck's own tool calls skip its tool.call hook. The reason the
1091// call refused, or null once it ran.
1092const runStopCall = async ($: Dollar, call: StopCall): Promise<string | null> => {
1093 try {
1094 const ran = await $.tool.call(call)
1095
1096 if (ran.deny !== undefined) {
1097 return ran.deny
1098 }
1099 if (ran.isError === true) {
1100 return ran.text ?? `${call.tool} failed`
1101 }
1102 } catch (error) {
1103 return String(error)
1104 }
1105
1106 const change = stoppedBy(call, await $.clock.now())
1107
1108 await reviseMonitors($, list => applyChange(list, change))
1109
1110 return null
1111}
1112
1113const stopMonitor = async ($: Dollar, id: string): Promise<void> => {
1114 const monitor = (await read($, monitors)).find(one => one.id === id)
1115
1116 if (monitor === undefined) {
1117 return
1118 }
1119
1120 const plan = stopCall(monitor)
1121
1122 if (!plan.isAllowed) {
1123 $.ui.toast(`Cannot stop ${monitor.label}: ${plan.reason}.`)
1124
1125 return
1126 }
1127
1128 const reason = await runStopCall($, plan.call)
1129
1130 $.ui.toast(
1131 reason === null
1132 ? `${plan.verb === 'stop' ? 'Stopped' : 'Canceled'} ${monitor.label}.`
1133 : `Could not ${plan.verb} ${monitor.label}: ${reason}`,
1134 )
1135}
1136
1137const stopAllMonitors = async ($: Dollar): Promise<void> => {
1138 const calls = stopAllCalls(await read($, monitors))
1139 const refusals: string[] = []
1140
1141 for (const call of calls) {
1142 const reason = await runStopCall($, call)
1143
1144 if (reason !== null) {
1145 refusals.push(`${call.tool}: ${reason}`)
1146 }
1147 }
1148 $.ui.toast(
1149 refusals.length === 0
1150 ? `Stopped or canceled ${calls.length}.`
1151 : `${refusals.length} of ${calls.length} refused. ${refusals.join('; ')}`,
1152 )
1153}
1154
1155const startLoop = async ($: Dollar, args: string): Promise<void> => {
1156 pendingLoop = { args, at: await $.clock.now() }
1157 $.ui.toast(`Queued /loop ${args}.`)
1158 try {
1159 await $.command.run({ args, command: 'loop' })
1160 } catch (error) {
1161 if (pendingLoop?.args === args && pendingLoop.turnId === undefined) {
1162 pendingLoop = null
1163 }
1164 $.ui.toast(`Could not start /loop ${args}: ${String(error)}`)
1165 }
1166}
1167
1168const callRecipe = ($: Dollar, call: ToolRecipe) => {
1169 switch (call.tool) {
1170 case 'Bash':
1171 return $.tool.call({ ...call.input, tool: 'Bash' })
1172 case 'Monitor':
1173 return $.tool.call({ ...call.input, tool: 'Monitor' })
1174 case 'CronCreate':
1175 return $.tool.call({ ...call.input, tool: 'CronCreate' })
1176 }
1177}
1178
1179// Queues the new row itself, as runStopCall applies its end. The reason the call refused, or null
1180// once it ran.
1181const runRecipe = async ($: Dollar, call: ToolRecipe): Promise<string | null> => {
1182 try {
1183 const ran = await callRecipe($, call)
1184
1185 if (ran.deny !== undefined) {
1186 return ran.deny
1187 }
1188 if (ran.isError === true) {
1189 return ran.text ?? `${call.tool} failed`
1190 }
1191
1192 const change = fromToolCall(call.tool, call.input, ran.result, await $.clock.now())
1193
1194 if (change !== null) {
1195 enqueue({ change, kind: 'monitor-change' })
1196 }
1197
1198 return null
1199 } catch (error) {
1200 return String(error)hooks/lib/advisor.ts 218 lines1// The server-side advisor, read three ways: a step result's timing, the api form's result
2// kind, and the transcript's usage, the only place the advisor's model and tokens appear.
3// Each reader returns ConsultFound; mergeConsults joins them by id.
4import { ADVISOR_TOOL } from './org'
5import { costOf } from './pricing'
6
7import type { ConsultFound } from './org'
8import type { DeckConsult, DeckStep } from '../../types'
9
10type Fields = Record<string, unknown>
11
12type ServerToolUse = { id: string; name: string; startedAt: number; endedAt?: number }
13
14type Row = { messageId: string; at: number | undefined; blocks: Fields[]; iterations: Fields[] | null }
15
16const isFields = (value: unknown): value is Fields => typeof value === 'object' && value !== null && !Array.isArray(value)
17
18const stringOf = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined)
19
20const countOf = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
21
22const callId = (block: Fields): string | null => {
23 const name = stringOf(block.name)
24
25 return block.type === 'server_tool_use' && name !== undefined && ADVISOR_TOOL.test(name) ? (stringOf(block.id) ?? null) : null
26}
27
28const resultId = (block: Fields): string | null =>
29 block.type === 'advisor_tool_result' ? (stringOf(block.tool_use_id) ?? null) : null
30
31export const adviceHead = (text: string): string =>
32 text
33 .split('\n')
34 .map(line => line.trim())
35 .find(line => line !== '') ?? ''
36
37const resultOf = (content: unknown): Partial<ConsultFound> => {
38 if (!isFields(content)) {
39 return {}
40 }
41
42 switch (content.type) {
43 case 'advisor_redacted_result':
44 return { isRedacted: true }
45 case 'advisor_result': {
46 const text = (stringOf(content.text) ?? '').trim()
47
48 return text === '' ? { isRedacted: false } : { advice: text, adviceHead: adviceHead(text), isRedacted: false }
49 }
50 case 'advisor_tool_result_error':
51 return { error: stringOf(content.error_code) ?? 'unknown' }
52 default:
53 return {}
54 }
55}
56
57// `turn.step`'s result. A call cut short has no `endedAt`; the stream's reader closes it.
58export const consultsFromStep = (step: { turnId: string; serverToolUses?: readonly ServerToolUse[] }): ConsultFound[] =>
59 (step.serverToolUses ?? [])
60 .filter(use => ADVISOR_TOOL.test(use.name))
61 .map(use => ({
62 at: use.startedAt,
63 id: use.id,
64 source: 'stream' as const,
65 turnId: step.turnId,
66 ...(use.endedAt === undefined ? {} : { endedAt: use.endedAt }),
67 }))
68
69// `$.session.messages({ as: 'api' })`: the call and result blocks, with no usage.
70export const consultsFromApi = (messages: readonly { role: string; content: readonly unknown[] }[]): ConsultFound[] => {
71 const found = new Map<string, ConsultFound>()
72
73 for (const message of messages) {
74 if (message.role !== 'assistant') {
75 continue
76 }
77 for (const block of message.content.filter(isFields)) {
78 const id = callId(block) ?? resultId(block)
79
80 if (id === null) {
81 continue
82 }
83 found.set(id, { ...(found.get(id) ?? { id, source: 'api' as const }), ...(resultId(block) === null ? {} : resultOf(block.content)) })
84 }
85 }
86
87 return [...found.values()]
88}
89
90const rowOf = (line: string): Row | null => {
91 let parsed: unknown
92
93 try {
94 parsed = JSON.parse(line)
95 } catch {
96 return null
97 }
98 const fields: Fields = isFields(parsed) ? parsed : {}
99 const message = fields.message
100
101 if (!isFields(message) || message.role !== 'assistant') {
102 return null
103 }
104
105 const content = message.content
106 const usage = isFields(message.usage) ? message.usage : {}
107 const iterations = usage.iterations
108 const at = Date.parse(stringOf(fields.timestamp) ?? '')
109
110 if (!Array.isArray(content)) {
111 return null
112 }
113
114 return {
115 at: Number.isNaN(at) ? undefined : at,
116 blocks: content.filter(isFields),
117 iterations: Array.isArray(iterations) ? iterations.filter(isFields) : null,
118 messageId: stringOf(message.id) ?? '',
119 }
120}
121
122const usageOf = (iteration: Fields): Partial<ConsultFound> => {
123 const model = stringOf(iteration.model)
124 const counts = {
125 cacheRead: countOf(iteration.cache_read_input_tokens),
126 cacheWrite: countOf(iteration.cache_creation_input_tokens),
127 input: countOf(iteration.input_tokens),
128 output: countOf(iteration.output_tokens),
129 }
130
131 return model === undefined ? { ...counts, usd: null } : { ...counts, model, usd: costOf(model, counts) }
132}
133
134// Transcript lines: the whole file, or only the lines a grep for "advisor_tool_result" kept.
135// Every row of a response repeats its final usage, so the usage is read once per message id.
136// The k-th call of a response that got an answer takes its k-th advisor_message iteration;
137// when the two counts differ, no call gets tokens rather than the wrong ones.
138export const consultsFromTranscript = (text: string): ConsultFound[] => {
139 const found = new Map<string, ConsultFound>()
140 const callsByMessage = new Map<string, string[]>()
141 const iterationsByMessage = new Map<string, Fields[]>()
142
143 for (const line of text.split('\n')) {
144 const row = line.trim() === '' ? null : rowOf(line)
145
146 if (row === null) {
147 continue
148 }
149 if (row.iterations !== null) {
150 iterationsByMessage.set(row.messageId, row.iterations.filter(item => item.type === 'advisor_message'))
151 }
152 for (const block of row.blocks) {
153 const call = callId(block)
154 const answer = resultId(block)
155 const id = call ?? answer
156
157 if (id === null) {
158 continue
159 }
160
161 const stamp = row.at === undefined ? {} : call === null ? { endedAt: row.at } : { at: row.at }
162 const calls = callsByMessage.get(row.messageId) ?? []
163
164 found.set(id, { ...(found.get(id) ?? { id, source: 'transcript' as const }), ...stamp, ...(answer === null ? {} : resultOf(block.content)) })
165 callsByMessage.set(row.messageId, calls.includes(id) ? calls : [...calls, id])
166 }
167 }
168
169 for (const [messageId, ids] of callsByMessage) {
170 const iterations = iterationsByMessage.get(messageId) ?? []
171 const answered = ids.filter(id => found.get(id)?.error === undefined)
172
173 if (answered.length !== iterations.length) {
174 continue
175 }
176 answered.forEach((id, index) => {
177 const iteration = iterations[index]
178 const known = found.get(id)
179
180 if (iteration !== undefined && known !== undefined) {
181 found.set(id, { ...known, ...usageOf(iteration) })
182 }
183 })
184 }
185
186 return [...found.values()]
187}
188
189// Keyed per consult so a re-read replaces its step. turnId '' when the turn is unknown:
190// per-model totals count the step, no turn row does.
191export const advisorSteps = (consults: readonly DeckConsult[]): DeckStep[] =>
192 consults.flatMap(consult => {
193 if (consult.model === undefined) {
194 return []
195 }
196
197 const counts = {
198 cacheRead: consult.cacheRead ?? 0,
199 cacheWrite: consult.cacheWrite ?? 0,
200 input: consult.input ?? 0,
201 output: consult.output ?? 0,
202 }
203
204 return [
205 {
206 ...counts,
207 index: 0,
208 isFailed: false,
209 key: `advisor-${consult.id}`,
210 model: consult.model,
211 ms: consult.ms ?? 0,
212 startedAt: consult.at,
213 turnId: consult.turnId ?? '',
214 usd: consult.usd === undefined ? costOf(consult.model, counts) : consult.usd,
215 },
216 ]
217 })
218hooks/lib/awake.ts 65 lines1// Keeping the machine awake while the session works: a child process holds the operating system's
2// sleep lock for as long as it lives. register.tsx starts and ends the child; this file decides.
3import type { DeckAwake, KeepAwake } from '../../types'
4
5export type AwakePlan = { argv: readonly string[]; how: string } | { argv: null; reason: string }
6
7export const KEEP_AWAKE_MODES: readonly KeepAwake[] = ['off', 'while-working', 'always']
8
9export const DEFAULT_KEEP_AWAKE: KeepAwake = 'while-working'
10
11// After the last work ends the lock stays this long, so it does not drop between one turn and the next.
12export const AWAKE_LINGER_MS = 2 * 60_000
13
14export const AWAKE_LIMITS =
15 'A lock prevents idle sleep and display sleep. It cannot stop a shutdown or a restart, and on macOS closing the lid on battery still sleeps.'
16
17export const KEEP_AWAKE_LABEL: Readonly<Record<KeepAwake, string>> = {
18 always: 'Always: hold the lock all session',
19 off: 'Off: the machine sleeps as usual',
20 'while-working': 'While working: agents, turns or monitors in flight',
21}
22
23export const readKeepAwake = (raw: unknown): KeepAwake => KEEP_AWAKE_MODES.find(mode => mode === raw) ?? DEFAULT_KEEP_AWAKE
24
25// `platform` is `uname -s` (Darwin, Linux) or a Node platform name (darwin, linux).
26export const awakePlan = (platform: string): AwakePlan => {
27 const name = platform.trim().toLowerCase()
28
29 if (name === 'darwin') {
30 // -d display, -i idle, -m disk, -s system sleep (the last on AC power only).
31 return { argv: ['caffeinate', '-dims'], how: 'caffeinate' }
32 }
33 if (name === 'linux') {
34 return {
35 argv: [
36 'systemd-inhibit',
37 '--what=idle:sleep:handle-lid-switch',
38 '--who=viStack deck',
39 '--why=agents or monitors running',
40 '--mode=block',
41 'sleep',
42 'infinity',
43 ],
44 how: 'systemd-inhibit',
45 }
46 }
47
48 return { argv: null, reason: `keep-awake is not supported on ${platform.trim() === '' ? 'this system' : platform.trim()}` }
49}
50
51export const shouldHoldAwake = (setting: KeepAwake, isWorking: boolean): boolean =>
52 setting === 'always' || (setting === 'while-working' && isWorking)
53
54// The status line beside the keep-awake chip, without its icon.
55export const awakeText = (awake: DeckAwake, setting: KeepAwake): string => {
56 if (awake.isHeld) {
57 return `awake (${awake.how ?? 'lock held'})`
58 }
59 if (awake.reason !== undefined && setting !== 'off') {
60 return awake.reason
61 }
62
63 return setting === 'off' ? 'normal sleep (keep-awake off)' : 'normal sleep'
64}
65hooks/lib/board.ts 106 lines1// The session board: what waits on the person, and what is still open, grouped.
2import type { DeckAgent, DeckPrs, DeckTodo, DeckTool, DeckTurn, DeckWorkflow } from '../../types'
3
4export type Reply = {
5 id: string
6 kind: 'question' | 'plan' | 'asked' | 'agent'
7 text: string
8 turnId?: string
9 since: number
10}
11
12const lastQuestion = (answer: string): string | null => {
13 const tail = answer.trim().split('\n').filter(row => row.trim() !== '').slice(-6).join(' ')
14 const sentences = tail.match(/[^.!?]*\?/g)
15 const question = sentences?.[sentences.length - 1]?.trim()
16
17 return question === undefined || question.length < 6 ? null : question
18}
19
20export const needsReply = (
21 toolList: readonly DeckTool[],
22 turnList: readonly DeckTurn[],
23 agentList: readonly DeckAgent[],
24): Reply[] => {
25 const asking: Reply[] = toolList
26 .filter(tool => tool.status === 'running' && tool.agentId === undefined)
27 .filter(tool => tool.tool === 'AskUserQuestion' || tool.tool === 'ExitPlanMode')
28 .map(tool => ({
29 id: tool.id,
30 kind: tool.tool === 'ExitPlanMode' ? ('plan' as const) : ('question' as const),
31 since: tool.startedAt,
32 text: tool.tool === 'ExitPlanMode' ? 'A plan waits for your approval' : tool.detail || 'Claude asks a question',
33 ...(tool.turnId === undefined ? {} : { turnId: tool.turnId }),
34 }))
35 const lastTurn = [...turnList].reverse().find(turn => turn.durationMs !== undefined)
36 const question = lastTurn?.answer === undefined ? null : lastQuestion(lastTurn.answer)
37 const asked: Reply[] =
38 lastTurn === undefined || question === null || asking.length > 0
39 ? []
40 : [{ id: `asked-${lastTurn.turnId}`, kind: 'asked', since: lastTurn.startedAt, text: question, turnId: lastTurn.turnId }]
41 const waiting: Reply[] = agentList
42 .filter(agent => agent.status === 'waiting')
43 .map(agent => ({ id: agent.agentId, kind: 'agent', since: agent.startedAt, text: `${agent.nickname} waits: ${agent.description}` }))
44
45 return [...asking, ...asked, ...waiting]
46}
47
48export type Pending = { title: string; rows: { id: string; text: string; tone: 'run' | 'todo' | 'fail' | 'wait' }[] }
49
50export const pending = (input: {
51 agents: readonly DeckAgent[]
52 tools: readonly DeckTool[]
53 todos: readonly DeckTodo[]
54 workflow: DeckWorkflow | null
55 prs: DeckPrs
56}): Pending[] => {
57 const sections: Pending[] = [
58 {
59 rows: input.agents
60 .filter(agent => agent.status === 'running' || agent.status === 'pending')
61 .map(agent => ({ id: agent.agentId, text: `${agent.nickname} · ${agent.description}`, tone: 'run' as const })),
62 title: 'Agents running',
63 },
64 {
65 rows: input.tools
66 .filter(tool => tool.status === 'running')
67 .map(tool => ({ id: tool.id, text: `${tool.tool} ${tool.detail}`, tone: 'run' as const })),
68 title: 'Tools running',
69 },
70 {
71 rows: input.todos
72 .filter(todo => todo.status !== 'completed')
73 .map(todo => ({
74 id: todo.id,
75 text: todo.content,
76 tone: todo.status === 'in_progress' ? ('run' as const) : ('todo' as const),
77 })),
78 title: 'Tasks open',
79 },
80 {
81 rows: (input.workflow?.runs ?? []).flatMap(run =>
82 run.slices
83 .filter(slice => slice.phase !== 'merge-ready')
84 .map(slice => ({
85 id: `${run.slug}/${slice.id}`,
86 text: `${run.slug}/${slice.id} · ${slice.phase ?? '?'}${slice.blockers > 0 ? ` · ${slice.blockers} blocker` : ''}`,
87 tone: slice.phase === 'blocked' ? ('fail' as const) : ('wait' as const),
88 })),
89 ),
90 title: 'Slices open',
91 },
92 {
93 rows: input.prs.items
94 .filter(pr => pr.checks === 'failing' || pr.review === 'CHANGES_REQUESTED')
95 .map(pr => ({
96 id: `pr-${pr.repo}-${pr.number}`,
97 text: `${pr.repo.split('/').pop() ?? pr.repo}#${pr.number} ${pr.checks === 'failing' ? 'CI failing' : 'changes requested'} · ${pr.title}`,
98 tone: 'fail' as const,
99 })),
100 title: 'PRs need action',
101 },
102 ]
103
104 return sections.filter(section => section.rows.length > 0)
105}
106hooks/lib/crew.ts 94 lines1// Nicknames and animated faces for subagents. ASCII only: wide glyphs and emoji break the
2// column math of a docked pane.
3export type Role = 'advisor' | 'scout' | 'scholar' | 'builder' | 'wizard' | 'critic' | 'captain' | 'helper'
4
5const ROLE_OF: readonly (readonly [RegExp, Role])[] = [
6 [/advis/, 'advisor'],
7 [/devops|infra|pipeline|deploy/, 'helper'],
8 [/explore|analyst|scout|search/, 'scout'],
9 [/research|docs|guide/, 'scholar'],
10 [/senior|architect|design-runner/, 'wizard'],
11 [/implement|builder|code-simplifier|engineer/, 'builder'],
12 [/review|health|verif|qa|audit|security|critic/, 'critic'],
13 [/plan|coordinat|groom|pr-author|unblock|orchestr/, 'captain'],
14]
15
16const NAMES: Readonly<Record<Role, readonly string[]>> = {
17 advisor: ['The Oracle', 'Sage', 'Athena', 'Wise Owl', 'Mentor'],
18 builder: ['Bob the Builder', 'Wrench', 'Hammerhead', 'Tinker', 'Sprocket'],
19 captain: ['Captain Hook', 'Maestro', 'Air Traffic', 'Tetris', 'Skipper'],
20 critic: ['Grumpy Cat', 'Hawkeye', 'Nitpick', 'Judge Dredd', 'Red Pen'],
21 helper: ['Gizmo', 'Noodle', 'Biscuit', 'Pixel', 'Pickles'],
22 scholar: ['Prof. Hoot', 'Doc Brown', 'Bookworm', 'Encyclo', 'Footnote'],
23 scout: ['Sherlock', 'Dora', 'Magellan', 'Columbo', 'Radar'],
24 wizard: ['Gandalf', 'Yoda', 'Merlin', 'Morpheus', 'Dumbledore'],
25}
26
27// Every frame of a role has the same width, so a row never jitters.
28const RUNNING: Readonly<Record<Role, readonly string[]>> = {
29 advisor: ['(O,O) ', '(o,O) ', '(O,o) ', '(O,O)~ '],
30 builder: ['(o_o)/ ', '(o_o)--', '(o_o)\\ ', '(o_o)--'],
31 captain: ['(^_^)> ', '(^_~)> ', '(^_^)> ', '(^o^)> '],
32 critic: ['(-_o) ', '(o_-) ', '(-_o) ', '(o_o) '],
33 helper: ['(o_o) ', '(o_o) ', '(-_-) ', '(o_o) '],
34 scholar: ['(o_O)? ', '(O_o)? ', '(o_O)! ', '(O_o)? '],
35 scout: ['(o_o ) ', '( o_o) ', '(o_o ) ', '( o_o) '],
36 wizard: ['(o_o)* ', '(o_o)+ ', '(o_o). ', '(o_o)+ '],
37}
38
39const STILL: Readonly<Record<string, string>> = {
40 completed: '(^_^) ',
41 failed: '(x_x) ',
42 idle: '(-_-)z ',
43 killed: '(x_x) ',
44 pending: '(._.) ',
45 waiting: '(o_o)? ',
46}
47
48const hash = (text: string): number => {
49 let value = 0
50
51 for (const char of text) {
52 value = (value * 31 + char.charCodeAt(0)) >>> 0
53 }
54
55 return value
56}
57
58export const roleOf = (type: string): Role => {
59 const lower = type.toLowerCase()
60 const found = ROLE_OF.find(([pattern]) => pattern.test(lower))
61
62 return found ? found[1] : 'helper'
63}
64
65export const nickname = (type: string, agentId: string, taken: readonly string[]): string => {
66 const pool = NAMES[roleOf(type)]
67 const start = hash(agentId) % pool.length
68
69 for (let offset = 0; offset < pool.length; offset += 1) {
70 const name = pool[(start + offset) % pool.length] ?? 'Gizmo'
71
72 if (!taken.includes(name)) {
73 return name
74 }
75 }
76
77 const base = pool[start] ?? 'Gizmo'
78 const count = taken.filter(name => name.startsWith(base)).length
79
80 return `${base} ${count + 1}`
81}
82
83export const isLive = (status: string): boolean => status === 'running' || status === 'pending'
84
85export const face = (type: string, status: string, tick: number): string => {
86 if (status === 'running') {
87 const frames = RUNNING[roleOf(type)]
88
89 return frames[tick % frames.length] ?? '(o_o) '
90 }
91
92 return STILL[status] ?? '(-_-) '
93}
94hooks/lib/format.ts 127 lines1import { cells, clip } from './theme'
2
3export const tokens = (count: number | undefined): string => {
4 if (count === undefined) {
5 return '-'
6 }
7 if (count >= 1_000_000) {
8 return `${(count / 1_000_000).toFixed(1)}M`
9 }
10 if (count >= 10_000) {
11 return `${Math.round(count / 1000)}k`
12 }
13 if (count >= 1000) {
14 return `${(count / 1000).toFixed(1)}k`
15 }
16
17 return String(count)
18}
19
20export const usd = (amount: number | null | undefined): string => {
21 if (amount === null || amount === undefined) {
22 return '≈?'
23 }
24 if (amount > 0 && amount < 0.01) {
25 return '<$0.01'
26 }
27
28 return `$${amount.toFixed(2)}`
29}
30
31export const duration = (ms: number | undefined): string => {
32 if (ms === undefined) {
33 return '-'
34 }
35 if (ms < 1000) {
36 return `${(ms / 1000).toFixed(1)}s`
37 }
38
39 const seconds = Math.round(ms / 1000)
40
41 if (seconds < 60) {
42 return ms < 10_000 ? `${(ms / 1000).toFixed(1)}s` : `${seconds}s`
43 }
44
45 const minutes = Math.floor(seconds / 60)
46
47 if (minutes < 60) {
48 return `${minutes}m ${String(seconds % 60).padStart(2, '0')}s`
49 }
50
51 return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`
52}
53
54export const ago = (then: number, now: number): string => {
55 const seconds = Math.max(0, Math.round((now - then) / 1000))
56
57 if (seconds < 60) {
58 return `${seconds}s ago`
59 }
60 if (seconds < 3600) {
61 return `${Math.floor(seconds / 60)}m ago`
62 }
63 if (seconds < 86_400) {
64 return `${Math.floor(seconds / 3600)}h ago`
65 }
66
67 return `${Math.floor(seconds / 86_400)}d ago`
68}
69
70export const until = (iso: string | undefined, now: number): string => {
71 if (iso === undefined) {
72 return ''
73 }
74
75 const minutes = Math.max(0, Math.round((Date.parse(iso) - now) / 60_000))
76
77 if (Number.isNaN(minutes)) {
78 return ''
79 }
80 if (minutes < 60) {
81 return `${minutes}m`
82 }
83 if (minutes < 1440) {
84 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
85 }
86
87 return `${Math.floor(minutes / 1440)}d${Math.floor((minutes % 1440) / 60)}h`
88}
89
90export const percent = (part: number, whole: number): string =>
91 whole <= 0 ? '0%' : `${Math.round((part / whole) * 100)}%`
92
93export const bar = (fraction: number, width: number): { full: string; empty: string } => {
94 const size = Math.max(1, width)
95 const filled = Math.min(size, Math.max(0, Math.round(fraction * size)))
96
97 return { full: '█'.repeat(filled), empty: '░'.repeat(size - filled) }
98}
99
100// One line of at most `width` terminal cells, an ellipsis marking a cut.
101export const fit = (text: string, width: number): string => {
102 const flat = text.replace(/\s+/g, ' ').trim()
103
104 if (width <= 1) {
105 return clip(flat, Math.max(0, width))
106 }
107
108 return cells(flat) <= width ? flat : `${clip(flat, width - 1)}…`
109}
110
111// "HH:MM:SS" in the local time zone.
112export const clockTime = (at: number): string => new Date(at).toTimeString().slice(0, 8)
113
114export const basename = (path: string): string => path.split('/').filter(Boolean).pop() ?? path
115
116// "~/…/parent/" for a path, so two files of one name stay apart.
117export const parentHint = (path: string, home: string | undefined): string => {
118 const parts = path.split('/').filter(Boolean)
119 const parent = parts.slice(-2, -1)[0]
120
121 if (parent === undefined) {
122 return '/'
123 }
124
125 return home !== undefined && path.startsWith(home) ? `~/…/${parent}/` : `…/${parent}/`
126}
127hooks/lib/jev.ts 69 lines1// Model suggestions from viStack's fork layer: a `tier-selection` decision through
2// scripts/vistack-decision.py (deterministic policy, then the tiers the person opted into
3// with /vistack:decisions-on, Jev first). The tier maps to the model the matching agent
4// file pins, so the suggestion and viStack's own dispatch agree.
5export type Tier = 'mechanical' | 'complex'
6
7export type Decision = {
8 tier: Tier
9 confidence: number
10 backend: string
11 decisionId?: string
12 rationale: string
13}
14
15export type Roster = Readonly<Record<Tier, string>>
16
17export const DEFAULT_ROSTER: Roster = { complex: 'opus', mechanical: 'sonnet' }
18
19export const BUILT_IN_TYPES: readonly string[] = ['general-purpose', 'Explore', 'Plan']
20
21export const decisionContext = (request: string, source: string): string =>
22 JSON.stringify({
23 metadata: { source },
24 task: { request: request.slice(0, 2400) },
25 })
26
27export const parseDecision = (stdout: string): Decision | null => {
28 try {
29 const value: unknown = JSON.parse(stdout)
30
31 if (typeof value !== 'object' || value === null) {
32 return null
33 }
34
35 const record = value as Record<string, unknown>
36 const outputs = (typeof record.outputs === 'object' && record.outputs !== null ? record.outputs : {}) as Record<
37 string,
38 unknown
39 >
40 const action = typeof outputs.tier === 'string' ? outputs.tier : record.action
41
42 if (action !== 'mechanical' && action !== 'complex') {
43 return null
44 }
45
46 return {
47 backend: typeof record.backend === 'string' ? record.backend : 'unknown',
48 confidence: typeof record.confidence === 'number' ? record.confidence : 0,
49 rationale: typeof record.rationale === 'string' ? record.rationale : '',
50 tier: action,
51 ...(typeof record.decision_id === 'string' ? { decisionId: record.decision_id } : {}),
52 }
53 } catch {
54 return null
55 }
56}
57
58// The `model:` line of an agent file's frontmatter.
59export const frontmatterModel = (source: string): string | null => {
60 const head = /^---\n([\s\S]*?)\n---/.exec(source)
61 const line = head?.[1]?.split('\n').find(row => /^model:\s*/.test(row))
62
63 return line === undefined ? null : line.replace(/^model:\s*/, '').replace(/["']/g, '').trim() || null
64}
65
66// Apply mode only touches built-in types that named no model of their own.
67export const mayApply = (mode: string, subagentType: string, model: string | undefined): boolean =>
68 mode === 'apply' && model === undefined && BUILT_IN_TYPES.includes(subagentType)
69hooks/lib/launch.ts 69 lines1// lazygit needs a terminal of its own: a mod cannot hand the session's terminal to a child.
2// So the deck opens it beside the session (a tmux split, or a new terminal window) and
3// closes it the same way. Pure argv builders; register.tsx runs them.
4export type Launcher = 'auto' | 'tmux' | 'ghostty' | 'terminal' | 'iterm'
5
6export type LaunchPlan = { how: Exclude<Launcher, 'auto'>; open: string[]; close: (handle: string) => string[] }
7
8const escapeRegex = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
9
10const shellQuote = (text: string): string => `'${text.replace(/'/g, `'\\''`)}'`
11
12// The pattern `lazygit -p <cwd>`, anchored, names this session's lazygit and nothing else's:
13// /a/repo never matches a lazygit open in /a/repo-2.
14export const lazygitMarker = (cwd: string): string => `lazygit -p ${escapeRegex(cwd)}$`
15
16const closeByMarker = (cwd: string) => (): string[] => ['pkill', '-f', lazygitMarker(cwd)]
17
18export const pickLauncher = (wanted: Launcher, env: { tmux?: string; termProgram?: string }): Exclude<Launcher, 'auto'> => {
19 if (wanted !== 'auto') {
20 return wanted
21 }
22 if (env.tmux !== undefined && env.tmux !== '') {
23 return 'tmux'
24 }
25 if (env.termProgram === 'ghostty') {
26 return 'ghostty'
27 }
28 if (env.termProgram === 'iTerm.app') {
29 return 'iterm'
30 }
31
32 return 'terminal'
33}
34
35export const launchPlan = (how: Exclude<Launcher, 'auto'>, cwd: string): LaunchPlan => {
36 const command = `cd ${shellQuote(cwd)} && exec lazygit -p ${shellQuote(cwd)}`
37
38 switch (how) {
39 case 'tmux':
40 return {
41 close: handle => ['tmux', 'kill-pane', '-t', handle],
42 how,
43 open: ['tmux', 'split-window', '-h', '-P', '-F', '#{pane_id}', '-c', cwd, 'lazygit', '-p', cwd],
44 }
45 case 'ghostty':
46 return {
47 close: closeByMarker(cwd),
48 how,
49 open: ['open', '-na', 'Ghostty.app', '--args', `--working-directory=${cwd}`, '-e', 'lazygit', '-p', cwd],
50 }
51 case 'iterm':
52 return {
53 close: closeByMarker(cwd),
54 how,
55 open: [
56 'osascript',
57 '-e',
58 `tell application "iTerm" to create window with default profile command ${JSON.stringify(`sh -c ${shellQuote(command)}`)}`,
59 ],
60 }
61 case 'terminal':
62 return {
63 close: closeByMarker(cwd),
64 how,
65 open: ['osascript', '-e', `tell application "Terminal" to do script ${JSON.stringify(command)}`],
66 }
67 }
68}
69hooks/lib/ledger.ts 355 lines1// Pure record keeping for the deck: every function takes the list it changes and returns
2// the next one, so the hooks stay thin and these stay testable.
3import { costOf } from './pricing'
4
5import type { DeckAgent, DeckEdit, DeckStep, DeckTool, DeckTurn } from '../../types'
6
7export const STEP_CAP = 400
8export const TOOL_CAP = 300
9export const EDIT_CAP = 200
10export const TURN_CAP = 60
11
12export const stepKey = (turnId: string, index: number, agentId: string | undefined): string =>
13 `${agentId ?? 'main'}:${turnId}:${index}`
14
15// A step is counted once: a second record under the same key replaces the first.
16export const recordStep = (list: readonly DeckStep[], step: DeckStep): DeckStep[] => {
17 const rest = list.filter(one => one.key !== step.key)
18
19 return [...rest, step].slice(-STEP_CAP)
20}
21
22export const makeStep = (input: {
23 turnId: string
24 index: number
25 agentId?: string
26 model: string
27 usage: { model: string; input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number } | null
28 startedAt: number
29 endedAt: number
30 hasStop: boolean
31}): DeckStep => {
32 const model = input.usage?.model ?? input.model
33 const counts = {
34 cacheRead: input.usage?.cache_read_input_tokens ?? 0,
35 cacheWrite: input.usage?.cache_creation_input_tokens ?? 0,
36 input: input.usage?.input_tokens ?? 0,
37 output: input.usage?.output_tokens ?? 0,
38 }
39
40 return {
41 ...counts,
42 ...(input.agentId === undefined ? {} : { agentId: input.agentId }),
43 index: input.index,
44 isFailed: !input.hasStop,
45 key: stepKey(input.turnId, input.index, input.agentId),
46 model,
47 ms: Math.max(0, input.endedAt - input.startedAt),
48 startedAt: input.startedAt,
49 turnId: input.turnId,
50 usd: input.usage === null ? 0 : costOf(model, counts),
51 }
52}
53
54export const startTool = (list: readonly DeckTool[], run: DeckTool): DeckTool[] =>
55 [...list.filter(one => one.id !== run.id), run].slice(-TOOL_CAP)
56
57export const finishTool = (list: readonly DeckTool[], id: string, endedAt: number, isError: boolean): DeckTool[] =>
58 list.map(one =>
59 one.id === id ? { ...one, ms: Math.max(0, endedAt - one.startedAt), status: isError ? 'error' : 'ok' } : one,
60 )
61
62const field = (input: unknown, name: string): unknown =>
63 typeof input === 'object' && input !== null ? (input as Record<string, unknown>)[name] : undefined
64
65const text = (input: unknown, name: string): string => {
66 const value = field(input, name)
67
68 return typeof value === 'string' ? value : ''
69}
70
71export const toolDetail = (tool: string, input: unknown): string => {
72 switch (tool) {
73 case 'Bash':
74 return text(input, 'command')
75 case 'Read':
76 case 'Edit':
77 case 'MultiEdit':
78 case 'Write':
79 return text(input, 'file_path')
80 case 'NotebookEdit':
81 return text(input, 'notebook_path')
82 case 'Grep':
83 case 'Glob':
84 return text(input, 'pattern')
85 case 'Agent':
86 case 'Task':
87 return text(input, 'description')
88 case 'Skill':
89 return text(input, 'skill')
90 case 'WebFetch':
91 return text(input, 'url')
92 case 'WebSearch':
93 return text(input, 'query')
94 case 'AskUserQuestion': {
95 const questions = field(input, 'questions')
96
97 return Array.isArray(questions) ? text(questions[0], 'question') : ''
98 }
99 default:
100 return ''
101 }
102}
103
104const lines = (value: string): string[] => (value === '' ? [] : value.split('\n'))
105
106// Lines added and removed between two texts, after their common head and tail.
107export const lineDelta = (before: string, after: string): { added: number; removed: number } => {
108 const old = lines(before)
109 const next = lines(after)
110 let head = 0
111
112 while (head < old.length && head < next.length && old[head] === next[head]) {
113 head += 1
114 }
115
116 let tail = 0
117
118 while (
119 tail < old.length - head &&
120 tail < next.length - head &&
121 old[old.length - 1 - tail] === next[next.length - 1 - tail]
122 ) {
123 tail += 1
124 }
125
126 return { added: next.length - head - tail, removed: old.length - head - tail }
127}
128
129export type EditDelta = { path: string; added: number; removed: number }
130
131export const editDelta = (tool: string, input: unknown): EditDelta | null => {
132 if (tool === 'Edit') {
133 const path = text(input, 'file_path')
134
135 return path === '' ? null : { path, ...lineDelta(text(input, 'old_string'), text(input, 'new_string')) }
136 }
137 if (tool === 'MultiEdit') {
138 const path = text(input, 'file_path')
139 const list = field(input, 'edits')
140
141 if (path === '' || !Array.isArray(list)) {
142 return null
143 }
144
145 return list.reduce<EditDelta>(
146 (sum, one) => {
147 const delta = lineDelta(text(one, 'old_string'), text(one, 'new_string'))
148
149 return { added: sum.added + delta.added, path, removed: sum.removed + delta.removed }
150 },
151 { added: 0, path, removed: 0 },
152 )
153 }
154 if (tool === 'Write') {
155 const path = text(input, 'file_path')
156
157 return path === '' ? null : { added: lines(text(input, 'content')).length, path, removed: 0 }
158 }
159 if (tool === 'NotebookEdit') {
160 const path = text(input, 'notebook_path')
161
162 return path === '' ? null : { added: lines(text(input, 'new_source')).length, path, removed: 0 }
163 }
164
165 return null
166}
167
168export const mergeEdit = (list: readonly DeckEdit[], delta: EditDelta, at: number, isNew: boolean): DeckEdit[] => {
169 const known = list.find(one => one.path === delta.path)
170 const next: DeckEdit = known
171 ? {
172 ...known,
173 added: known.added + delta.added,
174 count: known.count + 1,
175 lastAt: at,
176 removed: known.removed + delta.removed,
177 }
178 : { ...delta, count: 1, isNew, lastAt: at }
179
180 return [next, ...list.filter(one => one.path !== delta.path)].slice(0, EDIT_CAP)
181}
182
183export const recordTurn = (list: readonly DeckTurn[], turn: DeckTurn): DeckTurn[] =>
184 [...list.filter(one => one.turnId !== turn.turnId), turn].slice(-TURN_CAP)
185
186export const patchTurn = (list: readonly DeckTurn[], turnId: string, patch: Partial<DeckTurn>): DeckTurn[] =>
187 list.map(one => (one.turnId === turnId ? { ...one, ...patch } : one))
188
189export const patchAgent = (list: readonly DeckAgent[], agentId: string, patch: Partial<DeckAgent>): DeckAgent[] =>
190 list.map(one => (one.agentId === agentId ? { ...one, ...patch } : one))
191
192export type Totals = {
193 requests: number
194 input: number
195 output: number
196 cacheRead: number
197 cacheWrite: number
198 usd: number
199 isPartial: boolean
200 ms: number
201 failed: number
202}
203
204export const emptyTotals = (): Totals => ({
205 cacheRead: 0,
206 cacheWrite: 0,
207 failed: 0,
208 input: 0,
209 isPartial: false,
210 ms: 0,
211 output: 0,
212 requests: 0,
213 usd: 0,
214})
215
216export const addUp = (list: readonly DeckStep[]): Totals =>
217 list.reduce<Totals>(
218 (sum, step) => ({
219 cacheRead: sum.cacheRead + step.cacheRead,
220 cacheWrite: sum.cacheWrite + step.cacheWrite,
221 failed: sum.failed + (step.isFailed ? 1 : 0),
222 input: sum.input + step.input,
223 isPartial: sum.isPartial || step.usd === null,
224 ms: sum.ms + step.ms,
225 output: sum.output + step.output,
226 requests: sum.requests + 1,
227 usd: sum.usd + (step.usd ?? 0),
228 }),
229 emptyTotals(),
230 )
231
232// Share of input the prompt cache served.
233export const cacheShare = (totals: Totals): number => {
234 const all = totals.input + totals.cacheRead + totals.cacheWrite
235
236 return all === 0 ? 0 : totals.cacheRead / all
237}
238
239export const byModel = (list: readonly DeckStep[]): { model: string; totals: Totals }[] => {
240 const models = [...new Set(list.map(step => step.model))]
241
242 return models
243 .map(model => ({ model, totals: addUp(list.filter(step => step.model === model)) }))
244 .sort((left, right) => right.totals.usd - left.totals.usd)
245}
246
247export type Execution = {
248 id: string
249 kind: 'turn' | 'agent'
250 label: string
251 models: string[]
252 totals: Totals
253 ms: number
254 status: string
255 startedAt: number
256}
257
258// One row per main-loop turn and one per subagent run: the "cost per execution" table.
259export const executions = (
260 stepList: readonly DeckStep[],
261 turnList: readonly DeckTurn[],
262 agentList: readonly DeckAgent[],
263 now: number,
264): Execution[] => {
265 const main = turnList.map(turn => {
266 const own = stepList.filter(step => step.agentId === undefined && step.turnId === turn.turnId)
267
268 return {
269 id: turn.turnId,
270 kind: 'turn' as const,
271 label: turn.text === '' ? '(continuation)' : turn.text,
272 models: [...new Set(own.map(step => step.model))],
273 ms: turn.durationMs ?? now - turn.startedAt,
274 startedAt: turn.startedAt,
275 status: turn.reason ?? 'running',
276 totals: addUp(own),
277 }
278 })
279 const subs = agentList.map(agent => {
280 const own = stepList.filter(step => step.agentId === agent.agentId)
281
282 return {
283 id: agent.agentId,
284 kind: 'agent' as const,
285 label: `${agent.nickname} · ${agent.description}`,
286 models: [...new Set(own.map(step => step.model).concat(own.length === 0 ? [agent.model] : []))],
287 ms: (agent.endedAt ?? now) - agent.startedAt,
288 startedAt: agent.startedAt,
289 status: agent.status,
290 totals: addUp(own),
291 }
292 })
293
294 return [...main, ...subs].sort((left, right) => right.startedAt - left.startedAt)
295}
296
297export type Timeline = {
298 totalMs: number
299 modelMs: number
300 toolMs: number
301 idleMs: number
302 byTool: { tool: string; ms: number }[]
303 slowest: { kind: 'model' | 'tool'; label: string; ms: number }[]
304 segments: { kind: 'model' | 'tool' | 'failed'; startedAt: number; ms: number }[]
305}
306
307// Where one main-loop turn's wall clock went: model requests, tools, and the gaps between.
308export const timeline = (
309 turn: DeckTurn,
310 stepList: readonly DeckStep[],
311 toolList: readonly DeckTool[],
312 now: number,
313): Timeline => {
314 const own = stepList.filter(step => step.agentId === undefined && step.turnId === turn.turnId)
315 const ownTools = toolList.filter(tool => tool.agentId === undefined && tool.turnId === turn.turnId)
316 const totalMs = turn.durationMs ?? now - turn.startedAt
317 const modelMs = own.reduce((sum, step) => sum + step.ms, 0)
318 const toolMs = ownTools.reduce((sum, tool) => sum + (tool.ms ?? now - tool.startedAt), 0)
319 const names = [...new Set(ownTools.map(tool => tool.tool))]
320 const byTool = names
321 .map(tool => ({
322 ms: ownTools.filter(one => one.tool === tool).reduce((sum, one) => sum + (one.ms ?? 0), 0),
323 tool,
324 }))
325 .sort((left, right) => right.ms - left.ms)
326 const slowest = [
327 ...own.map(step => ({
328 kind: 'model' as const,
329 label: `request ${step.index + 1} of ${own.length}`,
330 ms: step.ms,
331 })),
332 ...ownTools.map(tool => ({ kind: 'tool' as const, label: `${tool.tool} ${tool.detail}`, ms: tool.ms ?? 0 })),
333 ]
334 .sort((left, right) => right.ms - left.ms)
335 .slice(0, 5)
336 const segments = [
337 ...own.map(step => ({ kind: step.isFailed ? ('failed' as const) : ('model' as const), ms: step.ms, startedAt: step.startedAt })),
338 ...ownTools.map(tool => ({
339 kind: tool.status === 'error' ? ('failed' as const) : ('tool' as const),
340 ms: tool.ms ?? now - tool.startedAt,
341 startedAt: tool.startedAt,
342 })),
343 ].sort((left, right) => left.startedAt - right.startedAt)
344
345 return {
346 byTool,
347 idleMs: Math.max(0, totalMs - modelMs - toolMs),
348 modelMs,
349 segments,
350 slowest,
351 toolMs,
352 totalMs,
353 }
354}
355hooks/lib/org.ts 425 lines1// The deck's org chart: who works under the coordinator, what the coordinator is doing, who
2// said what to whom, and the small charts the Board draws. Plain data in, plain data out.
3import { isLive, roleOf } from './crew'
4
5import type { Role } from './crew'
6import type { IconName } from './theme'
7import type { DeckActivity, DeckAgent, DeckComm, DeckConsult, DeckTool, DeckTurn } from '../../types'
8
9export type Department = { id: Role; label: string; title: string; icon: IconName }
10
11export const DEPARTMENTS: Readonly<Record<Role, Department>> = {
12 advisor: { icon: 'advisor', id: 'advisor', label: 'Advisor', title: 'Board advisor' },
13 builder: { icon: 'builder', id: 'builder', label: 'Engineering', title: 'Engineer' },
14 captain: { icon: 'captain', id: 'captain', label: 'Program', title: 'Program lead' },
15 critic: { icon: 'critic', id: 'critic', label: 'QA & Review', title: 'Reviewer' },
16 helper: { icon: 'helper', id: 'helper', label: 'Operations', title: 'Generalist' },
17 scholar: { icon: 'scholar', id: 'scholar', label: 'Library', title: 'Researcher' },
18 scout: { icon: 'scout', id: 'scout', label: 'Research', title: 'Analyst' },
19 wizard: { icon: 'wizard', id: 'wizard', label: 'Architecture', title: 'Principal engineer' },
20}
21
22// Departments under the coordinator, in drawing order. Advisor agents sit under the advisor seat.
23export const DEPARTMENT_ORDER: readonly Role[] = ['scout', 'scholar', 'wizard', 'builder', 'critic', 'captain', 'helper']
24
25export const departmentOf = (agent: DeckAgent): Department => DEPARTMENTS[roleOf(agent.type)]
26
27export type OrgNode = { agent: DeckAgent; children: OrgNode[] }
28
29export type OrgDepartment = Department & { members: OrgNode[]; count: number }
30
31export type Org = { departments: OrgDepartment[]; advisors: OrgNode[] }
32
33export const isOnStaff = (agent: DeckAgent): boolean => agent.leftAt === undefined
34
35const headcount = (nodes: readonly OrgNode[]): number =>
36 nodes.reduce((sum, node) => sum + 1 + headcount(node.children), 0)
37
38// Staff grouped by department; an agent another staff member spawned nests under that agent.
39export const orgTree = (agents: readonly DeckAgent[]): Org => {
40 const staff = agents.filter(isOnStaff)
41 const ids = new Set(staff.map(agent => agent.agentId))
42 const placed = new Set<string>()
43 const grow = (agent: DeckAgent): OrgNode => {
44 placed.add(agent.agentId)
45
46 return {
47 agent,
48 children: staff.filter(child => child.parentId === agent.agentId && !placed.has(child.agentId)).map(grow),
49 }
50 }
51 const roots = staff.filter(agent => agent.parentId === undefined || !ids.has(agent.parentId)).map(grow)
52
53 // A parent chain that loops reaches no top: those agents are drawn at the top instead of lost.
54 for (const agent of staff) {
55 if (!placed.has(agent.agentId)) {
56 roots.push(grow(agent))
57 }
58 }
59
60 const inRole = (role: Role): OrgNode[] => roots.filter(node => roleOf(node.agent.type) === role)
61
62 return {
63 advisors: inRole('advisor'),
64 departments: DEPARTMENT_ORDER.map(role => {
65 const members = inRole(role)
66
67 return { ...DEPARTMENTS[role], count: headcount(members), members }
68 }),
69 }
70}
71
72// Agents that left the chart, newest departure first.
73export const alumni = (agents: readonly DeckAgent[]): DeckAgent[] =>
74 agents.filter(agent => agent.leftAt !== undefined).sort((left, right) => (right.leftAt ?? 0) - (left.leftAt ?? 0))
75
76export const seatName = (nickname: string): string => nickname.replace(/ v\d+$/, '')
77
78// The generation a reload hands the seat to: one past the highest that sat in it.
79export const nextGeneration = (agents: readonly DeckAgent[], agent: DeckAgent): number => {
80 const seat = seatName(agent.nickname)
81 const generations = agents.filter(one => seatName(one.nickname) === seat).map(one => one.generation ?? 1)
82
83 return Math.max(1, ...generations) + 1
84}
85
86export type CoordinatorState = 'idle' | 'thinking' | 'writing' | 'tool' | 'delegating' | 'waiting-you' | 'waiting-agents'
87
88export type CoordinatorStatus = { state: CoordinatorState; label: string; since: number }
89
90export const STATE_LABELS: Readonly<Record<CoordinatorState, string>> = {
91 delegating: 'delegating',
92 idle: 'idle',
93 thinking: 'thinking',
94 tool: 'running a tool',
95 'waiting-agents': 'waiting on agents',
96 'waiting-you': 'waiting on you',
97 writing: 'writing',
98}
99
100const ASKING_TOOLS: readonly string[] = ['AskUserQuestion', 'ExitPlanMode']
101const AGENT_TOOLS: readonly string[] = ['Agent', 'Task']
102
103export const runningTurn = (turns: readonly DeckTurn[]): DeckTurn | undefined =>
104 [...turns].reverse().find(turn => turn.durationMs === undefined && turn.reason === undefined)
105
106export const isWorking = (agent: DeckAgent): boolean => isOnStaff(agent) && (isLive(agent.status) || agent.status === 'waiting')
107
108export const coordinatorStatus = (input: {
109 turns: readonly DeckTurn[]
110 tools: readonly DeckTool[]
111 agents: readonly DeckAgent[]
112 activity: Readonly<Record<string, DeckActivity>>
113}): CoordinatorStatus => {
114 const running = input.tools.filter(tool => tool.status === 'running' && tool.agentId === undefined)
115 const asking = running.find(tool => ASKING_TOOLS.includes(tool.tool))
116
117 if (asking !== undefined) {
118 const label = asking.tool === 'ExitPlanMode' ? 'waiting on you: a plan to approve' : 'waiting on you: a question'
119
120 return { label, since: asking.startedAt, state: 'waiting-you' }
121 }
122
123 const delegating = running.find(tool => AGENT_TOOLS.includes(tool.tool))
124
125 if (delegating !== undefined) {
126 return { label: `delegating ${delegating.detail}`.trim(), since: delegating.startedAt, state: 'delegating' }
127 }
128
129 const tool = running[running.length - 1]
130
131 if (tool !== undefined) {
132 return { label: `${tool.tool} ${tool.detail}`.trim(), since: tool.startedAt, state: 'tool' }
133 }
134
135 const turn = runningTurn(input.turns)
136
137 if (turn !== undefined) {
138 const main = input.activity.main
139
140 if (main?.kind === 'tool') {
141 return { label: main.detail, since: main.since, state: 'tool' }
142 }
143
144 return main?.kind === 'writing'
145 ? { label: 'writing', since: main.since, state: 'writing' }
146 : { label: 'thinking', since: main?.since ?? turn.startedAt, state: 'thinking' }
147 }
148
149 const live = input.agents.filter(isWorking)
150
151 if (live.length > 0) {
152 return {
153 label: `waiting on ${live.length} agent${live.length === 1 ? '' : 's'}`,
154 since: Math.min(...live.map(agent => agent.startedAt)),
155 state: 'waiting-agents',
156 }
157 }
158
159 const last = input.turns[input.turns.length - 1]
160
161 return { label: 'idle', since: last === undefined ? 0 : last.startedAt + (last.durationMs ?? 0), state: 'idle' }
162}
163
164// What one loop is doing now: its newest running tool, else what its stream last showed.
165export const doingNow = (
166 agentId: string | undefined,
167 activity: Readonly<Record<string, DeckActivity>>,
168 tools: readonly DeckTool[],
169): DeckActivity | null => {
170 const tool = [...tools].reverse().find(one => one.status === 'running' && one.agentId === agentId)
171
172 if (tool !== undefined) {
173 return { detail: `${tool.tool} ${tool.detail}`.trim(), kind: 'tool', since: tool.startedAt }
174 }
175
176 return activity[agentId ?? 'main'] ?? null
177}
178
179const CODE_TOOLS: readonly string[] = ['Edit', 'MultiEdit', 'Write', 'NotebookEdit']
180
181// Run state, ledgers and plans under .claude/ or .codex/ are the coordinator's own records.
182const RECORD_PATH = /(^|\/)\.(claude|codex)\//
183
184// The coordinator delegates and never writes code: every main-loop edit of a code file.
185export const codeWrites = (tools: readonly DeckTool[]): DeckTool[] =>
186 tools.filter(tool => tool.agentId === undefined && CODE_TOOLS.includes(tool.tool) && !RECORD_PATH.test(tool.detail))
187
188export const COMM_CAP = 80
189
190export const recordComm = (list: readonly DeckComm[], comm: DeckComm): DeckComm[] =>
191 [...list.filter(one => one.id !== comm.id), comm].slice(-COMM_CAP)
192
193const PARTIES: Readonly<Record<string, { name: string; icon: IconName }>> = {
194 advisor: { icon: 'advisor', name: 'Advisor' },
195 coordinator: { icon: 'coordinator', name: 'Coordinator' },
196 deck: { icon: 'org', name: 'Deck' },
197 user: { icon: 'user', name: 'You' },
198}
199
200export const commLabel = (party: string, agents: readonly DeckAgent[]): { name: string; icon: IconName } => {
201 const known = PARTIES[party]
202
203 if (known !== undefined) {
204 return known
205 }
206
207 const agent = agents.find(one => one.agentId === party)
208
209 if (agent !== undefined) {
210 return { icon: departmentOf(agent).icon, name: agent.nickname }
211 }
212
213 return { icon: /#\d+$/.test(party) ? 'pr' : 'message', name: party }
214}
215
216// A SendMessage address as a party: an agent's id when it names one by id or nickname.
217export const partyOf = (address: string, agents: readonly DeckAgent[]): string => {
218 const wanted = address.trim().toLowerCase()
219 const agent = agents.find(one => one.agentId.toLowerCase() === wanted || one.nickname.toLowerCase() === wanted)
220
221 return agent?.agentId ?? address
222}
223
224export const ADVISOR_TOOL = /^advisor$/i
225
226// What one read learned about a consult; the merge derives `ms`.
227export type ConsultFound = Omit<DeckConsult, 'at' | 'ms'> & { at?: number }
228
229type MessageLike = { role: string; toolUses: readonly { tool_use_id: string; tool: string; text?: string }[] }
230
231export const consultsFrom = (messages: readonly MessageLike[]): ConsultFound[] =>
232 messages
233 .filter(message => message.role === 'assistant')
234 .flatMap(message => message.toolUses.filter(use => ADVISOR_TOOL.test(use.tool)))
235 .map(use => (use.text === undefined || use.text.trim() === '' ? { id: use.tool_use_id } : { advice: use.text, id: use.tool_use_id }))
236
237export const CONSULT_CAP = 60
238
239// A field the read does not carry keeps its known value and the first source stays. A consult
240// first seen at its result starts there until a read measures the start; `ms` runs from the
241// start to a later result.
242const mergeConsult = (seen: DeckConsult | undefined, one: ConsultFound, at: number): DeckConsult => {
243 const carried = Object.fromEntries(Object.entries(one).filter(([, value]) => value !== undefined)) as Partial<ConsultFound>
244 const merged: DeckConsult = { ...(seen ?? { at: one.endedAt ?? at, id: one.id }), ...carried }
245 const source = seen?.source ?? one.source
246 const ms = merged.endedAt !== undefined && merged.endedAt > merged.at ? merged.endedAt - merged.at : merged.ms
247
248 return { ...merged, ...(source === undefined ? {} : { source }), ...(ms === undefined ? {} : { ms }) }
249}
250
251// Consults from every reader joined by id onto the known ones, kept in start order;
252// `advised` lists those whose advice arrived with this read.
253export const mergeConsults = (
254 known: readonly DeckConsult[],
255 found: readonly ConsultFound[],
256 at: number,
257): { consults: DeckConsult[]; advised: DeckConsult[] } => {
258 const advised: DeckConsult[] = []
259 let consults = [...known]
260
261 for (const one of found) {
262 const seen = consults.find(consult => consult.id === one.id)
263 const merged = mergeConsult(seen, one, at)
264
265 consults = seen === undefined ? [...consults, merged] : consults.map(consult => (consult.id === one.id ? merged : consult))
266 if (merged.advice !== undefined && seen?.advice === undefined) {
267 advised.push(merged)
268 }
269 }
270
271 return { advised, consults: consults.sort((left, right) => left.at - right.at).slice(-CONSULT_CAP) }
272}
273
274export type TeamStatus = { running: number; waiting: number; done: number; failed: number }
275
276export const teamStatus = (agents: readonly DeckAgent[]): TeamStatus =>
277 agents.filter(isOnStaff).reduce(
278 (sum, agent) => {
279 if (isLive(agent.status)) {
280 return { ...sum, running: sum.running + 1 }
281 }
282 if (agent.status === 'waiting' || agent.status === 'idle') {
283 return { ...sum, waiting: sum.waiting + 1 }
284 }
285
286 return agent.status === 'completed' ? { ...sum, done: sum.done + 1 } : { ...sum, failed: sum.failed + 1 }
287 },
288 { done: 0, failed: 0, running: 0, waiting: 0 },
289 )
290
291const SPARKS = '▁▂▃▄▅▆▇█'
292
293// The last `width` values as one glyph each, scaled to the largest of them.
294export const sparkline = (values: readonly number[], width: number): string => {
295 if (width <= 0) {
296 return ''
297 }
298
299 const shown = values.slice(-width)
300 const top = Math.max(0, ...shown)
301
302 return shown
303 .map(value => (top <= 0 || value <= 0 ? SPARKS[0] : SPARKS[Math.min(7, Math.max(1, Math.round((value / top) * 7)))]))
304 .join('')
305}
306
307// How many timestamps fall in each of the last `buckets` windows of `bucketMs`, oldest first.
308export const histogram = (points: readonly number[], now: number, buckets: number, bucketMs: number): number[] => {
309 const counts = Array.from({ length: Math.max(0, buckets) }, () => 0)
310
311 if (bucketMs <= 0 || counts.length === 0) {
312 return counts
313 }
314
315 const start = now - counts.length * bucketMs
316
317 for (const at of points) {
318 if (at < start || at > now) {
319 continue
320 }
321
322 const index = Math.min(counts.length - 1, Math.floor((at - start) / bucketMs))
323
324 counts[index] = (counts[index] ?? 0) + 1
325 }
326
327 return counts
328}
329
330// Bar lengths for a horizontal bar chart, the largest value filling `width`.
331export const bars = <Entry extends { value: number }>(
332 entries: readonly Entry[],
333 width: number,
334): (Entry & { full: string; empty: string })[] => {
335 const size = Math.max(1, width)
336 const top = Math.max(0, ...entries.map(entry => entry.value))
337
338 return entries.map(entry => {
339 const share = top <= 0 ? 0 : Math.round((entry.value / top) * size)
340 const filled = Math.min(size, entry.value > 0 ? Math.max(1, share) : 0)
341
342 return { ...entry, empty: '░'.repeat(size - filled), full: '█'.repeat(filled) }
343 })
344}
345
346// Cells per part of a stacked bar `width` wide: they add up to `width` unless every part is 0.
347export const stacked = (values: readonly number[], width: number): number[] => {
348 const total = values.reduce((sum, value) => sum + Math.max(0, value), 0)
349
350 if (total <= 0 || width <= 0) {
351 return values.map(() => 0)
352 }
353
354 const exact = values.map(value => (Math.max(0, value) / total) * width)
355 const sizes = exact.map(value => Math.floor(value))
356 const order = exact.map((value, index) => ({ index, rest: value - Math.floor(value) })).sort((one, two) => two.rest - one.rest)
357 let remaining = width - sizes.reduce((sum, size) => sum + size, 0)
358
359 for (const { index } of order) {
360 if (remaining <= 0) {
361 break
362 }
363 sizes[index] = (sizes[index] ?? 0) + 1
364 remaining -= 1
365 }
366
367 return sizes
368}
369
370export type ReviewPost = { repo: string; number: number | null }
371
372// The PR a command segment names: a PR URL, owner/repo#n, or --repo plus a number.
373const prTarget = (segment: string, rest: string): ReviewPost => {
374 const url = /https?:\/\/[^/\s]+\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/.exec(segment)
375
376 if (url !== null) {
377 return { number: Number(url[2]), repo: url[1] ?? '' }
378 }
379
380 const short = /(?:^|\s)([\w.-]+\/[\w.-]+)#(\d+)\b/.exec(segment)
381
382 if (short !== null) {
383 return { number: Number(short[2]), repo: short[1] ?? '' }
384 }
385
386 const repo = /(?:--repo|-R)[=\s]+([\w.-]+\/[\w.-]+)/.exec(segment)?.[1] ?? ''
387 const positional = rest.split(/\s--?[a-zA-Z]/)[0] ?? ''
388 const number = /(?:--pr|--number)[=\s]+(\d+)/.exec(segment)?.[1] ?? /(?:^|\s)(\d+)(?=\s|$)/.exec(positional)?.[1]
389
390 return { number: number === undefined ? null : Number(number), repo }
391}
392
393// `gh api` writes when a method other than GET is named or a field or input is sent.
394const isApiWrite = (segment: string): boolean =>
395 !/(?:-X|--method)[=\s]*GET\b/i.test(segment) &&
396 (/(?:-X|--method)[=\s]*(POST|PUT|PATCH)\b/i.test(segment) || /\s(?:-f|-F|--field|--raw-field|--input)[=\s]/.test(segment))
397
398// The PR a shell command posts a review or review comment on, or null when it posts none.
399// An empty repo means the checkout's own; a null number, the current branch's PR.
400export const reviewPostOf = (command: string): ReviewPost | null => {
401 for (const segment of command.split(/&&|\|\||;|\n|\|/)) {
402 const api = /\bgh\s+api\b.*?\brepos\/([^/\s]+\/[^/\s]+)\/pulls\/(\d+)\/(?:reviews|comments)\b/.exec(segment)
403
404 if (api !== null && isApiWrite(segment)) {
405 const repo = api[1] ?? ''
406
407 return { number: Number(api[2]), repo: repo.includes('{') ? '' : repo }
408 }
409
410 const verb = /\bgh\s+pr\s+(?:review|comment)\b(.*)$/.exec(segment)
411
412 if (verb !== null && !/--delete-last\b/.test(segment)) {
413 return prTarget(segment, verb[1] ?? '')
414 }
415
416 const script = /\bnode\s+\S*skills\/review-pr\/scripts\/post-review\.mjs\b(.*)$/.exec(segment)
417
418 if (script !== null && !/--dry-run\b/.test(segment)) {
419 return prTarget(segment, script[1] ?? '')
420 }
421 }
422
423 return null
424}
425hooks/lib/monitors.ts 827 lines1// Long-running work in flight: background shells, Monitor watches, background agents and
2// workflows, /loop wakeups, session crons, and viStack run monitors. Pure: every source is
3// turned into rows here and merged by id; register.tsx feeds the sources in.
4import { isWorking } from './org'
5
6import type {
7 CronJob,
8 CronSummary,
9 DeckAgent,
10 DeckMonitor,
11 DeckMonitorEnd,
12 DeckMonitorKind,
13 DeckMonitorStatus,
14 DeckRun,
15 MonitorChange,
16 MonitorEndNote,
17 MonitorRelaunch,
18 MonitorSummary,
19 StopCall,
20 StopPlan,
21 TaskSummary,
22} from '../../types'
23
24export const MONITOR_CAP = 80
25export const LONG_RUNNING_MS = 10 * 60_000
26// Work scheduled this soon still counts as work, so a /loop between wakeups keeps the machine up.
27export const SOON_MS = 30 * 60_000
28// A wakeup or a one-shot cron is taken as fired this long after its time.
29const FIRE_GRACE_MS = 2 * 60_000
30// A task that should have ended waits this long for its end report, which reaches the session on
31// its next turn.
32export const ABSENT_GRACE_MS = 2 * 60_000
33export const NO_END_REPORT = 'no end report seen'
34const NO_END_SUFFIX = ` · ${NO_END_REPORT}`
35const ENDED_WITH_AGENT = 'ended with its agent'
36// The engine caps a snapshot's strings at 1000 characters and marks the cut this way.
37const CLIPPED = /\.\.\. \[\+\d+ chars\]$/
38// CronCreate's recurring jobs auto-expire after 7 days.
39const CRON_EXPIRY_MS = 7 * 24 * 3_600_000
40const CRON_HORIZON_MS = 366 * 24 * 3_600_000
41const LOOP_DYNAMIC = '<<autonomous-loop-dynamic>>'
42const LOOP_CRON = '<<autonomous-loop>>'
43const TASK_KINDS: readonly DeckMonitorKind[] = ['monitor', 'shell', 'agent', 'workflow', 'other']
44
45const record = (value: unknown): Record<string, unknown> =>
46 typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
47
48const str = (value: unknown): string | undefined => (typeof value === 'string' && value !== '' ? value : undefined)
49
50const num = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
51
52const isOpenStatus = (status: DeckMonitorStatus): boolean => status === 'running' || status === 'active'
53
54export const isOpen = (monitor: DeckMonitor): boolean => isOpenStatus(monitor.status)
55
56export const isFinished = (monitor: DeckMonitor): boolean => !isOpen(monitor)
57
58// The end words of task notifications, the Stop snapshot and the agent list.
59const END_STATUS: Readonly<Record<string, DeckMonitorStatus>> = {
60 completed: 'done',
61 error: 'dead',
62 failed: 'dead',
63 killed: 'killed',
64 stopped: 'stopped',
65}
66
67type Fields = Omit<DeckMonitor, 'canStop' | 'canCancel'>
68
69// A notification row's `task`, the engine's UserMessageTask by shape.
70type NotifiedTask = {
71 id?: string | undefined
72 status?: string | undefined
73 type?: string | undefined
74 toolUseId?: string | undefined
75 durationMs?: number | undefined
76}
77
78const make = (fields: Fields): DeckMonitor => ({
79 ...fields,
80 canCancel: fields.status === 'active' && (fields.kind === 'cron' || (fields.kind === 'wakeup' && fields.isLoop === true)),
81 canStop: fields.status === 'running' && TASK_KINDS.includes(fields.kind),
82})
83
84const start = (fields: Fields): MonitorChange => ({ change: 'start', monitor: make(fields) })
85
86const ended = (monitor: DeckMonitor, status: DeckMonitorStatus, at: number, endedBy: DeckMonitorEnd): DeckMonitor => {
87 const { absentSince: _unused, ...rest } = monitor
88
89 return make({ ...rest, endedAt: at, endedBy, status })
90}
91
92const endedFields = (status: DeckMonitorStatus, at: number): Pick<Fields, 'endedAt' | 'endedBy'> =>
93 isOpenStatus(status) ? {} : { endedAt: at, endedBy: 'report' }
94
95const withNote = (detail: string, note: string): string => (detail === '' ? note : `${detail} · ${note}`)
96
97const isSentinel = (prompt: string): boolean => prompt === LOOP_DYNAMIC || prompt === LOOP_CRON
98
99const shellRecipe = (command: string, description: string | undefined): MonitorRelaunch => ({
100 input: { command, run_in_background: true, ...(description === undefined ? {} : { description }) },
101 tool: 'Bash',
102 via: 'tool',
103})
104
105const monitorRecipe = (args: Record<string, unknown>, timeoutMs: number): MonitorRelaunch | undefined => {
106 const command = str(args.command)
107 const ws = record(args.ws)
108 const url = str(ws.url)
109 const protocols = Array.isArray(ws.protocols) ? { protocols: ws.protocols.filter((one): one is string => typeof one === 'string') } : {}
110 const target = command !== undefined ? { command } : url !== undefined ? { ws: { url, ...protocols } } : undefined
111
112 if (target === undefined) {
113 return undefined
114 }
115
116 return { input: { description: str(args.description) ?? 'monitor', timeout_ms: num(args.timeout_ms) ?? timeoutMs, ...target }, tool: 'Monitor', via: 'tool' }
117}
118
119const promptLabel = (prompt: string): string => {
120 if (prompt === LOOP_DYNAMIC) {
121 return 'loop wakeup'
122 }
123
124 return prompt === LOOP_CRON ? 'autonomous loop' : prompt
125}
126
127// ─── Cron ───────────────────────────────────────────────────────────────────
128
129// minute, hour, day of month, month, day of week (7 is Sunday too).
130const CRON_FIELDS: readonly (readonly [number, number])[] = [
131 [0, 59],
132 [0, 23],
133 [1, 31],
134 [1, 12],
135 [0, 7],
136]
137
138const cronField = (field: string, low: number, high: number): Set<number> | null => {
139 const values = new Set<number>()
140
141 for (const part of field.split(',')) {
142 const [range = '', stepText] = part.split('/')
143 const step = stepText === undefined ? 1 : Number(stepText)
144 const [first = '', last] = range.split('-')
145 const from = range === '*' ? low : Number(first)
146 const isOpenEnded = range === '*' || (last === undefined && stepText !== undefined)
147 const to = isOpenEnded ? high : last === undefined ? from : Number(last)
148
149 if (first === '' || !Number.isInteger(step) || step < 1 || !Number.isInteger(from) || !Number.isInteger(to) || from < low || to > high || from > to) {
150 return null
151 }
152 for (let value = from; value <= to; value += step) {
153 values.add(value)
154 }
155 }
156
157 return values
158}
159
160// The first minute at or after `from` that a 5-field cron expression matches, in local time.
161export const nextCronAt = (expression: string, from: number): number | undefined => {
162 const fields = expression.trim().split(/\s+/)
163
164 if (fields.length !== CRON_FIELDS.length) {
165 return undefined
166 }
167
168 const sets = fields.map((field, index) => cronField(field, CRON_FIELDS[index]?.[0] ?? 0, CRON_FIELDS[index]?.[1] ?? 0))
169 const [minutes = null, hours = null, days = null, months = null, weekdays = null] = sets
170
171 if (minutes === null || hours === null || days === null || months === null || weekdays === null) {
172 return undefined
173 }
174 if (weekdays.has(7)) {
175 weekdays.add(0)
176 }
177
178 const isDayOpen = fields[2] === '*'
179 const isWeekdayOpen = fields[4] === '*'
180 const at = new Date(from)
181
182 at.setSeconds(0, 0)
183 if (at.getTime() < from) {
184 at.setMinutes(at.getMinutes() + 1)
185 }
186 while (at.getTime() - from <= CRON_HORIZON_MS) {
187 const dayHit = days.has(at.getDate())
188 const weekdayHit = weekdays.has(at.getDay())
189 // Cron's rule: with both day fields restricted, either one matching is enough.
190 const isDay = isDayOpen || isWeekdayOpen ? dayHit && weekdayHit : dayHit || weekdayHit
191
192 if (!months.has(at.getMonth() + 1)) {
193 at.setMonth(at.getMonth() + 1, 1)
194 at.setHours(0, 0)
195 } else if (!isDay) {
196 at.setDate(at.getDate() + 1)
197 at.setHours(0, 0)
198 } else if (!hours.has(at.getHours())) {
199 at.setHours(at.getHours() + 1, 0)
200 } else if (!minutes.has(at.getMinutes())) {
201 at.setMinutes(at.getMinutes() + 1)
202 } else {
203 return at.getTime()
204 }
205 }
206
207 return undefined
208}
209
210// ─── Sources ────────────────────────────────────────────────────────────────
211
212// What one finished tool call starts or ends; null for calls that touch no long-running work.
213// `result` is the call's structured result (`ran.result`), not the hook's whole answer, and
214// `agentId` the loop the call ran in, absent on the main loop.
215export const fromToolCall = (tool: string, input: unknown, result: unknown, at: number, agentId?: string): MonitorChange | null => {
216 const args = record(input)
217 const out = record(result)
218
219 switch (tool) {
220 case 'Monitor': {
221 const id = str(out.taskId)
222 const timeoutMs = num(out.timeoutMs) ?? 0
223
224 if (id === undefined) {
225 return null
226 }
227
228 const relaunch = monitorRecipe(args, timeoutMs)
229
230 return start({
231 detail: str(args.command) ?? str(record(args.ws).url) ?? '',
232 id,
233 kind: 'monitor',
234 label: str(args.description) ?? 'monitor',
235 source: 'tool',
236 startedAt: at,
237 status: 'running',
238 ...(timeoutMs > 0 && out.persistent !== true ? { deadlineAt: at + timeoutMs } : {}),
239 ...(relaunch === undefined ? {} : { relaunch }),
240 })
241 }
242 case 'Bash': {
243 // Set for run_in_background, and also for a command the person or a timeout backgrounded.
244 const id = str(out.backgroundTaskId)
245 const command = str(args.command)
246
247 if (id === undefined) {
248 return null
249 }
250
251 return start({
252 detail: command ?? '',
253 id,
254 kind: 'shell',
255 label: str(args.description) ?? command ?? '',
256 source: 'tool',
257 startedAt: at,
258 status: 'running',
259 ...(command === undefined ? {} : { relaunch: shellRecipe(command, str(args.description)) }),
260 ...(agentId !== undefined && out.backgroundEndsWithFinalResponse === true ? { ownerAgentId: agentId } : {}),
261 })
262 }
263 case 'Agent': {
264 const id = out.status === 'async_launched' ? str(out.agentId) : out.status === 'remote_launched' ? str(out.taskId) : undefined
265
266 if (id === undefined) {
267 return null
268 }
269
270 return start({
271 detail: str(out.sessionUrl) ?? str(args.subagent_type) ?? 'general-purpose',
272 id,
273 kind: 'agent',
274 label: str(args.name) ?? str(args.description) ?? 'agent',
275 source: 'tool',
276 startedAt: at,
277 status: 'running',
278 // A remote agent's task id is not an agent the deck can hire again.
279 ...(out.status === 'async_launched' ? { relaunch: { agentId: id, via: 'rehire' as const } } : {}),
280 })
281 }
282 case 'Workflow': {
283 const id = str(out.taskId)
284
285 if (id === undefined || str(out.error) !== undefined) {
286 return null
287 }
288
289 return start({
290 detail: str(out.summary) ?? str(out.scriptPath) ?? '',
291 id,
292 kind: 'workflow',
293 label: str(out.workflowName) ?? str(args.name) ?? 'workflow',
294 source: 'tool',
295 startedAt: at,
296 status: 'running',
297 })
298 }
299 case 'ScheduleWakeup': {
300 if (args.stop === true) {
301 return { at, change: 'end-wakeups', endedBy: 'report' }
302 }
303
304 const delay = num(args.delaySeconds)
305 const nextAt = num(out.scheduledFor) ?? (delay === undefined ? undefined : at + delay * 1000)
306 const prompt = str(args.prompt) ?? LOOP_DYNAMIC
307
308 if (nextAt === undefined) {
309 return null
310 }
311
312 // The result names no id; the fire time is unique per pending wakeup.
313 return start({
314 detail: str(args.reason) ?? str(args.prompt) ?? '',
315 id: `wakeup:${nextAt}`,
316 isLoop: true,
317 kind: 'wakeup',
318 label: promptLabel(prompt),
319 nextAt,
320 source: 'tool',
321 startedAt: at,
322 status: 'active',
323 // The prompt is the /loop input; a sentinel means the loop had none.
324 ...(isSentinel(prompt) ? {} : { relaunch: { args: prompt, via: 'loop' as const } }),
325 })
326 }
327 case 'CronCreate': {
328 const id = str(out.id)
329 const schedule = str(args.cron)
330 const prompt = str(args.prompt)
331 const isRecurring = typeof out.recurring === 'boolean' ? out.recurring : args.recurring !== false
332 const nextAt = schedule === undefined ? undefined : nextCronAt(schedule, at)
333
334 if (id === undefined) {
335 return null
336 }
337
338 return start({
339 detail: str(out.humanSchedule) ?? schedule ?? '',
340 id,
341 isRecurring,
342 kind: 'cron',
343 label: promptLabel(prompt ?? 'cron'),
344 source: 'tool',
345 startedAt: at,
346 status: 'active',
347 ...(schedule === undefined ? {} : { schedule }),
348 ...(nextAt === undefined ? {} : { nextAt }),
349 ...(isRecurring ? { deadlineAt: at + CRON_EXPIRY_MS } : {}),
350 ...(schedule === undefined || prompt === undefined
351 ? {}
352 : { relaunch: { input: { cron: schedule, prompt, recurring: isRecurring }, tool: 'CronCreate' as const, via: 'tool' as const } }),
353 })
354 }
355 case 'CronDelete':
356 case 'TaskStop': {
357 const id = str(out.task_id) ?? str(out.id) ?? str(args.task_id) ?? str(args.shell_id) ?? str(args.id)
358
359 return id === undefined ? null : { at, change: 'end', endedBy: 'report', id, status: tool === 'CronDelete' ? 'canceled' : 'stopped' }
360 }
361 default:
362 return null
363 }
364}
365
366const fromTask = (task: TaskSummary, at: number): DeckMonitor => {
367 const kinds: Readonly<Record<string, DeckMonitorKind>> = { monitor: 'monitor', shell: 'shell', subagent: 'agent', workflow: 'workflow' }
368 const tool = task.server === undefined ? task.tool : `${task.server}/${task.tool ?? ''}`
369 const status = END_STATUS[task.status] ?? 'running'
370 // A clipped command would run something else.
371 const command = task.type === 'shell' && !CLIPPED.test(task.command ?? '') ? str(task.command) : undefined
372
373 return make({
374 detail: task.command ?? task.agent_type ?? tool ?? task.name ?? task.type,
375 id: task.id,
376 kind: kinds[task.type] ?? 'other',
377 label: task.description || task.name || task.type,
378 rawStatus: task.status,
379 source: 'stop-snapshot',
380 startedAt: at,
381 status,
382 ...endedFields(status, at),
383 ...(command === undefined ? {} : { relaunch: shellRecipe(command, str(task.description)) }),
384 })
385}
386
387const isLoopPrompt = (prompt: string): boolean => prompt === LOOP_DYNAMIC || /^\/loop\b/.test(prompt)
388
389const fromCron = (cron: CronSummary, list: readonly DeckMonitor[], at: number, humanSchedule?: string): DeckMonitor[] => {
390 const nextAt = nextCronAt(cron.schedule, at)
391 const known = list.find(monitor => monitor.id === cron.id)
392
393 if (known === undefined && !cron.recurring) {
394 // A ScheduleWakeup result names no id, so its pending wakeup shows up here under a cron id
395 // of its own: fold it into the wakeup the tool call already recorded.
396 const twin = list.find(
397 monitor =>
398 monitor.kind === 'wakeup' &&
399 monitor.source === 'tool' &&
400 isOpen(monitor) &&
401 (nextAt === undefined || monitor.nextAt === undefined || Math.abs(monitor.nextAt - nextAt) <= FIRE_GRACE_MS),
402 )
403
404 if (twin !== undefined) {
405 return []
406 }
407 }
408
409 const kind = known?.kind ?? (!cron.recurring && isLoopPrompt(cron.prompt) ? 'wakeup' : 'cron')
410
411 return [
412 make({
413 detail: humanSchedule ?? cron.schedule,
414 id: cron.id,
415 isRecurring: cron.recurring,
416 kind,
417 label: promptLabel(cron.prompt || 'cron'),
418 schedule: cron.schedule,
419 source: 'stop-snapshot',
420 startedAt: at,
421 status: 'active',
422 ...(kind === 'wakeup' ? { isLoop: true } : {}),
423 ...(nextAt === undefined ? {} : { nextAt }),
424 }),
425 ]
426}
427
428// Rows `covers` names that are still open but missing from `incoming` go to `gone`.
429const reconcile = (
430 list: readonly DeckMonitor[],
431 incoming: readonly DeckMonitor[],
432 covers: (monitor: DeckMonitor) => boolean,
433 gone: (monitor: DeckMonitor) => DeckMonitor,
434 cap: number,
435): DeckMonitor[] => {
436 const seen = new Set(incoming.map(monitor => monitor.id))
437 const retired = list.map(monitor => (covers(monitor) && isOpen(monitor) && !seen.has(monitor.id) ? gone(monitor) : monitor))
438
439 return mergeMonitors(retired, incoming, cap)
440}
441
442const isTaskRow = (monitor: DeckMonitor): boolean => TASK_KINDS.includes(monitor.kind)
443
444// A tool-recorded wakeup has no engine id to look for, so it ends by time, by stop, or when superseded.
445const isCronRow = (monitor: DeckMonitor): boolean => monitor.kind === 'cron' || (monitor.kind === 'wakeup' && monitor.source !== 'tool')
446
447// No notification reports a cron, so a missing one has fired, expired or been deleted: done.
448const goneDone =
449 (at: number) =>
450 (monitor: DeckMonitor): DeckMonitor =>
451 ended(monitor, 'done', at, 'absence')
452
453// The Stop hook's view of what is in flight. A family the hook left out (undefined) says nothing;
454// a family it sent, even empty, is the whole truth. An open task missing from it is marked absent
455// and waits for its end report (resolveAbsent); a missing cron is done.
456// `isComplete: false` merges without retiring anything (SubagentStop, whose scope is unverified).
457export const fromStopSnapshot = (
458 list: readonly DeckMonitor[],
459 snapshot: { tasks?: readonly TaskSummary[] | undefined; crons?: readonly CronSummary[] | undefined },
460 at: number,
461 isComplete = true,
462 cap = MONITOR_CAP,
463): DeckMonitor[] => {
464 let next = [...list]
465
466 if (snapshot.tasks !== undefined) {
467 const incoming = snapshot.tasks.map(task => fromTask(task, at))
468 const absent = (monitor: DeckMonitor): DeckMonitor => ({ ...monitor, absentSince: monitor.absentSince ?? at })
469
470 next = isComplete ? reconcile(next, incoming, isTaskRow, absent, cap) : mergeMonitors(next, incoming, cap)
471 }
472 if (snapshot.crons !== undefined) {
473 const before = next
474 const incoming = snapshot.crons.flatMap(cron => fromCron(cron, before, at))
475
476 next = isComplete ? reconcile(next, incoming, isCronRow, goneDone(at), cap) : mergeMonitors(next, incoming, cap)
477 }
478
479 return next
480}
481
482// A CronList answer: the whole set of CronCreate jobs. Wakeups are left alone; whether CronList
483// lists them is not documented.
484export const fromCronList = (list: readonly DeckMonitor[], jobs: readonly CronJob[], at: number, cap = MONITOR_CAP): DeckMonitor[] => {
485 const incoming = jobs.flatMap(job =>
486 fromCron({ id: job.id, prompt: job.prompt, recurring: job.recurring !== false, schedule: job.cron }, list, at, job.humanSchedule),
487 )
488
489 return reconcile(list, incoming, monitor => monitor.kind === 'cron', goneDone(at), cap)
490}
491
492const RUN_STATUS: Readonly<Record<string, DeckMonitorStatus>> = {
493 active: 'active',
494 complete: 'done',
495 completed: 'done',
496 done: 'done',
497 error: 'dead',
498 failed: 'dead',
499 finished: 'done',
500 paused: 'stopped',
501 running: 'running',
502 stopped: 'stopped',
503}
504
505// viStack run monitors from the state files (`monitor.status`). The file is only a claim: the
506// live mechanism is the run's /loop wakeup or cron, listed on its own row.
507export const fromRuns = (list: readonly DeckMonitor[], runs: readonly DeckRun[], at: number, cap = MONITOR_CAP): DeckMonitor[] => {
508 const incoming = runs.flatMap(run => {
509 if (run.monitor === undefined) {
510 return []
511 }
512
513 const status = RUN_STATUS[run.monitor.toLowerCase()] ?? 'active'
514
515 return [
516 make({
517 detail: [run.playbook, run.monitor, run.objective].filter(part => part !== undefined && part !== '').join(' · '),
518 id: `run:${run.root}/${run.slug}`,
519 kind: 'run-monitor',
520 label: `${run.slug} monitor`,
521 rawStatus: run.monitor,
522 source: 'state-file',
523 startedAt: at,
524 status,
525 ...endedFields(status, at),
526 }),
527 ]
528 })
529
530 return reconcile(list, incoming, monitor => monitor.kind === 'run-monitor', goneDone(at), cap)
531}
532
533// Background agents end when the agent list says they did, and so does work a synchronous subagent
534// owned, which the engine ends with that agent's final response.
535export const fromAgents = (list: readonly DeckMonitor[], agents: readonly DeckAgent[], at: number): DeckMonitor[] =>
536 list.map(monitor => {
537 const watched = monitor.kind === 'agent' ? monitor.id : monitor.ownerAgentId
538 const agent = watched === undefined || !isOpen(monitor) ? undefined : agents.find(one => one.agentId === watched)
539
540 if (agent === undefined || isWorking(agent)) {
541 return monitor
542 }
543 if (monitor.kind === 'agent') {
544 return { ...ended(monitor, END_STATUS[agent.status] ?? 'done', agent.endedAt ?? at, 'report'), rawStatus: agent.status }
545 }
546
547 // The agent's end says nothing of how the command itself went.
548 return { ...ended(monitor, 'done', agent.endedAt ?? at, 'report'), detail: withNote(monitor.detail, ENDED_WITH_AGENT) }
549 })
550
551// ─── End reports ────────────────────────────────────────────────────────────
552
553const NOTE_BLOCK = /<task-notification>([\s\S]*?)<\/task-notification>/g
554const NOTE_TASK_ID = /<task-id>([^<]*)<\/task-id>/
555const NOTE_STATUS = /<status>([^<]*)<\/status>/
556
557// The end reports in a user message's `<task-notification>` blocks. A block with no `<status>` is a
558// Monitor's stream event, not an end; an assistant message only quotes one.
559export const taskNotesFrom = (messages: readonly { role: string; text: string }[]): MonitorEndNote[] =>
560 messages.flatMap(message =>
561 message.role !== 'user'
562 ? []
563 : [...message.text.matchAll(NOTE_BLOCK)].flatMap(match => {
564 const block = match[1] ?? ''
565 const taskId = str(NOTE_TASK_ID.exec(block)?.[1]?.trim())
566 const status = str(NOTE_STATUS.exec(block)?.[1]?.trim())
567
568 return taskId === undefined || status === undefined ? [] : [{ status, taskId }]
569 }),
570 )
571
572// The same end report from a notification row's structured `task` (a UserMessage's `props.task`).
573export const noteFromTask = (task: NotifiedTask): MonitorEndNote | null => {
574 const taskId = str(task.id)
575 const status = str(task.status)
576
577 return taskId === undefined || status === undefined ? null : { status, taskId }
578}
579
580const withoutNoEndReport = (detail: string): string => {
581 if (detail === NO_END_REPORT) {
582 return ''
583 }
584
585 return detail.endsWith(NO_END_SUFFIX) ? detail.slice(0, -NO_END_SUFFIX.length) : detail
586}
587
588// End reports settle an open row or one that ended by absence; a row something else ended keeps
589// its end. A word the deck does not know is left for absence to settle. The last report wins.
590export const fromTaskNotifications = (list: readonly DeckMonitor[], notes: readonly MonitorEndNote[], at: number): DeckMonitor[] => {
591 const byId = new Map(notes.flatMap(note => (END_STATUS[note.status] === undefined ? [] : [[note.taskId, note] as const])))
592
593 return list.map(monitor => {
594 const note = byId.get(monitor.id)
595 const status = note === undefined ? undefined : END_STATUS[note.status]
596
597 if (note === undefined || status === undefined || !(isOpen(monitor) || monitor.endedBy === 'absence')) {
598 return monitor
599 }
600
601 return {
602 ...ended(monitor, status, monitor.endedAt ?? monitor.absentSince ?? at, 'report'),
603 detail: withoutNoEndReport(monitor.detail),
604 rawStatus: note.status,
605 }
606 })
607}
608
609// Since when a running task should have reported its end: a complete snapshot left it out, or its
610// Monitor deadline passed. A task with neither runs until a report comes; no timeout is assumed.
611const unreportedSince = (monitor: DeckMonitor): number | undefined =>
612 monitor.absentSince ?? (monitor.status === 'running' ? monitor.deadlineAt : undefined)
613
614// A task unreported past the grace has ended. Dead once this session's end reports are known to
615// arrive, since its own never did; until then done, saying no report was seen.
616export const resolveAbsent = (list: readonly DeckMonitor[], now: number, isFeedLive: boolean): DeckMonitor[] =>
617 list.map(monitor => {
618 const since = unreportedSince(monitor)
619
620 if (since === undefined || !isOpen(monitor) || now - since < ABSENT_GRACE_MS) {
621 return monitor
622 }
623 if (isFeedLive) {
624 return ended(monitor, 'dead', since, 'absence')
625 }
626
627 return {
628 ...ended(monitor, 'done', since, 'absence'),
629 detail: withNote(monitor.detail, NO_END_REPORT),
630 }
631 })
632
633// ─── List upkeep ────────────────────────────────────────────────────────────
634
635// Finished rows go oldest first once the list passes the cap; open rows always stay.
636const capped = (list: readonly DeckMonitor[], cap: number): DeckMonitor[] => {
637 const finished = list.filter(isFinished)
638
639 if (list.length <= cap || finished.length === 0) {
640 return [...list]
641 }
642
643 const room = Math.max(0, cap - (list.length - finished.length))
644 const kept = new Set(
645 [...finished]
646 .sort((left, right) => (right.endedAt ?? right.startedAt) - (left.endedAt ?? left.startedAt))
647 .slice(0, room)
648 .map(monitor => monitor.id),
649 )
650
651 return list.filter(monitor => !isFinished(monitor) || kept.has(monitor.id))
652}
653
654// Upsert by id. A row that ended stays ended (a late report never revives it), except a run
655// monitor, whose state file may restart it. The first sighting's start time and the recipe
656// recorded at start are kept, and a row seen again is no longer absent.
657export const mergeMonitors = (list: readonly DeckMonitor[], incoming: readonly DeckMonitor[], cap = MONITOR_CAP): DeckMonitor[] => {
658 const byId = new Map(list.map(monitor => [monitor.id, monitor]))
659
660 for (const monitor of incoming) {
661 const before = byId.get(monitor.id)
662
663 if (before === undefined) {
664 byId.set(monitor.id, monitor)
665 } else if (!isFinished(before) || before.kind === 'run-monitor') {
666 const { absentSince: _absent, endedAt: _endedAt, endedBy: _endedBy, ...kept } = before
667
668 byId.set(
669 monitor.id,
670 make({
671 ...kept,
672 ...monitor,
673 source: before.source,
674 startedAt: Math.min(before.startedAt, monitor.startedAt),
675 ...(before.isLoop === true ? { isLoop: true } : {}),
676 ...(before.relaunch === undefined ? {} : { relaunch: before.relaunch }),
677 }),
678 )
679 }
680 }
681
682 return capped([...byId.values()], cap)
683}
684
685export const applyChange = (list: readonly DeckMonitor[], change: MonitorChange, cap = MONITOR_CAP): DeckMonitor[] => {
686 switch (change.change) {
687 case 'start': {
688 const { monitor } = change
689 // A dynamic /loop holds one pending wakeup: scheduling the next means the last one fired.
690 const before =
691 monitor.kind === 'wakeup'
692 ? list.map(one => (one.kind === 'wakeup' && one.source === 'tool' && isOpen(one) ? ended(one, 'done', monitor.startedAt, 'clock') : one))
693 : list
694
695 return mergeMonitors(before, [monitor], cap)
696 }
697 case 'end':
698 return list.map(monitor => (monitor.id === change.id && isOpen(monitor) ? ended(monitor, change.status, change.at, change.endedBy) : monitor))
699 case 'end-wakeups':
700 return list.map(monitor => (monitor.kind === 'wakeup' && isOpen(monitor) ? ended(monitor, 'canceled', change.at, change.endedBy) : monitor))
701 }
702}
703
704// Time passing: wakeups and one-shot crons past their fire time are done, and recurring crons move
705// to their next fire. A watch past its deadline is resolveAbsent's.
706export const settle = (list: readonly DeckMonitor[], now: number): DeckMonitor[] =>
707 list.map(monitor => {
708 if (monitor.status === 'active' && monitor.nextAt !== undefined && now > monitor.nextAt + FIRE_GRACE_MS) {
709 if (monitor.isRecurring === true && monitor.schedule !== undefined && (monitor.deadlineAt === undefined || now < monitor.deadlineAt)) {
710 const nextAt = nextCronAt(monitor.schedule, now)
711
712 return nextAt === undefined ? ended(monitor, 'done', now, 'clock') : { ...monitor, nextAt }
713 }
714
715 return ended(monitor, 'done', monitor.isRecurring === true ? now : monitor.nextAt, 'clock')
716 }
717
718 return monitor
719 })
720
721// ─── Reading the list ───────────────────────────────────────────────────────
722
723export const isLongRunning = (monitor: DeckMonitor, now: number, thresholdMs = LONG_RUNNING_MS): boolean =>
724 monitor.status === 'running' && now - monitor.startedAt >= thresholdMs
725
726const RANK: Readonly<Record<DeckMonitorStatus, number>> = { active: 1, canceled: 2, dead: 2, done: 2, killed: 2, running: 0, stopped: 2 }
727
728// Running work first, the longest-running on top; then active by next fire; then ended, newest first.
729export const sortMonitors = (list: readonly DeckMonitor[]): DeckMonitor[] =>
730 [...list].sort((left, right) => {
731 const byRank = RANK[left.status] - RANK[right.status]
732
733 if (byRank !== 0) {
734 return byRank
735 }
736 if (left.status === 'running') {
737 return left.startedAt - right.startedAt
738 }
739 if (left.status === 'active') {
740 return (left.nextAt ?? Number.MAX_SAFE_INTEGER) - (right.nextAt ?? Number.MAX_SAFE_INTEGER)
741 }
742
743 return (right.endedAt ?? right.startedAt) - (left.endedAt ?? left.startedAt)
744 })
745
746export const summary = (list: readonly DeckMonitor[], now: number): MonitorSummary => {
747 const counts: Record<DeckMonitorStatus, number> = { active: 0, canceled: 0, dead: 0, done: 0, killed: 0, running: 0, stopped: 0 }
748 let longest: DeckMonitor | undefined
749
750 for (const monitor of list) {
751 counts[monitor.status] += 1
752 if (monitor.status === 'running' && (longest === undefined || monitor.startedAt < longest.startedAt)) {
753 longest = monitor
754 }
755 }
756
757 return {
758 ...counts,
759 longRunning: list.filter(monitor => isLongRunning(monitor, now)).length,
760 ...(longest === undefined ? {} : { longest }),
761 }
762}
763
764// Whether the session has work in flight: a turn, a live agent, a running monitor, or something
765// scheduled within SOON_MS. Run-monitor rows are claims from a file, so they never count.
766export const isSessionWorking = (input: {
767 monitors: readonly DeckMonitor[]
768 agents: readonly DeckAgent[]
769 isTurnRunning: boolean
770 now: number
771}): boolean =>
772 input.isTurnRunning ||
773 input.agents.some(isWorking) ||
774 input.monitors.some(
775 monitor =>
776 monitor.kind !== 'run-monitor' &&
777 (monitor.status === 'running' || (monitor.status === 'active' && monitor.nextAt !== undefined && monitor.nextAt - input.now <= SOON_MS)),
778 )
779
780// The tool call that stops or cancels a row, or why there is none.
781export const stopCall = (monitor: DeckMonitor): StopPlan => {
782 if (isFinished(monitor)) {
783 return { isAllowed: false, reason: `already ${monitor.status}` }
784 }
785 if (monitor.kind === 'run-monitor') {
786 return { isAllowed: false, reason: "the run's monitor is its /loop: cancel that wakeup or cron" }
787 }
788 if (monitor.kind === 'cron') {
789 return { call: { id: monitor.id, tool: 'CronDelete' }, isAllowed: true, verb: 'cancel' }
790 }
791 if (monitor.kind === 'wakeup') {
792 return monitor.isLoop === true
793 ? { call: { stop: true, tool: 'ScheduleWakeup' }, isAllowed: true, verb: 'cancel' }
794 : { isAllowed: false, reason: 'not a /loop wakeup, so ScheduleWakeup cannot cancel it' }
795 }
796
797 return { call: { task_id: monitor.id, tool: 'TaskStop' }, isAllowed: true, verb: 'stop' }
798}
799
800// The change a successful stop call makes. The deck's own tool calls skip its tool.call hook, so
801// the stop job applies this itself.
802export const stoppedBy = (call: StopCall, at: number): MonitorChange => {
803 switch (call.tool) {
804 case 'TaskStop':
805 return { at, change: 'end', endedBy: 'deck', id: call.task_id, status: 'stopped' }
806 case 'CronDelete':
807 return { at, change: 'end', endedBy: 'deck', id: call.id, status: 'canceled' }
808 case 'ScheduleWakeup':
809 return { at, change: 'end-wakeups', endedBy: 'deck' }
810 }
811}
812
813// One call per distinct target for "stop all": every loop wakeup shares one ScheduleWakeup stop.
814export const stopAllCalls = (list: readonly DeckMonitor[]): StopCall[] => {
815 const calls = new Map<string, StopCall>()
816
817 for (const monitor of list) {
818 const plan = stopCall(monitor)
819
820 if (plan.isAllowed) {
821 calls.set(JSON.stringify(plan.call), plan.call)
822 }
823 }
824
825 return [...calls.values()]
826}
827hooks/lib/pricing.ts 70 lines1// Estimated US dollars per million tokens, first-party API list prices as of 2026-09-25.
2// Cache writes use the 5-minute rate (1.25x input). The engine's own session total
3// ($.session.usage().cost) is the authority; these estimates split it by model and task.
4type Rate = { input: number; output: number; cacheRead: number; cacheWrite: number }
5
6const RATES: readonly (readonly [RegExp, Rate])[] = [
7 [/fable-5-1|mythos-5-1/, { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 }],
8 [/fable-5|mythos-5/, { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }],
9 [/opus-5-5/, { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 }],
10 [/opus-5|opus-4-[5-8]/, { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }],
11 [/sonnet-5/, { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }],
12 [/sonnet-4/, { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }],
13 [/haiku-4/, { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }],
14]
15
16const ALIASES: Readonly<Record<string, string>> = {
17 fable: 'claude-fable-5-1',
18 haiku: 'claude-haiku-4-5',
19 opus: 'claude-opus-5-5',
20 sonnet: 'claude-sonnet-5-5',
21}
22
23export type TokenCounts = { input: number; output: number; cacheRead: number; cacheWrite: number }
24
25export const resolveModel = (model: string): string => ALIASES[model.toLowerCase()] ?? model
26
27export const rateFor = (model: string): Rate | null => {
28 const id = resolveModel(model).toLowerCase()
29 const found = RATES.find(([pattern]) => pattern.test(id))
30
31 return found ? found[1] : null
32}
33
34export const costOf = (model: string, counts: TokenCounts): number | null => {
35 const rate = rateFor(model)
36
37 if (rate === null) {
38 return null
39 }
40
41 const micro =
42 counts.input * rate.input +
43 counts.output * rate.output +
44 counts.cacheRead * rate.cacheRead +
45 counts.cacheWrite * rate.cacheWrite
46
47 return micro / 1_000_000
48}
49
50// "claude-opus-5-5[1m]" -> "Opus 5.5"; an id outside the families stays as given.
51export const modelLabel = (model: string): string => {
52 const id = resolveModel(model).toLowerCase()
53 const match = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d+))?/.exec(id)
54
55 if (match === null) {
56 return model
57 }
58
59 const family = match[1] ?? ''
60 const version = match[3] === undefined ? match[2] : `${match[2]}.${match[3]}`
61
62 return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${version}`
63}
64
65export const modelFamily = (model: string): string => {
66 const match = /(fable|mythos|opus|sonnet|haiku)/.exec(resolveModel(model).toLowerCase())
67
68 return match?.[1] ?? model
69}
70