SLOPSHOPPER

viStack

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

newpanebandrowsguardcommand
★ 1v0.25.0no licenseupdated 2026-10-06vianch/vistack
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vistack
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /deck ⎿ vistack: viStack deck closed. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

viStack

<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


install

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.

Host manifests

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.

HostManifest
Claude Code.claude-plugin/plugin.json
Codex.codex-plugin/plugin.json
Grok Build.grok-plugin/plugin.json
OpenCodeintegrations/opencode/vistack.js (follows the checkout)

Claude Code

viStack ships .claude-plugin/marketplace.json at the repository root. From Claude Code, add the repository marketplace and install the plugin:

  1. Start Claude Code in any project.
  2. Add the marketplace once:
/plugin marketplace add https://github.com/vianch/viStack
  1. Install the plugin:
/plugin install vistack
  1. Verify the installation:
  • /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

Codex installs the same repository through .agents/plugins/marketplace.json and loads skills/vistack/SKILL.md as $vistack:vistack:

  1. Confirm the Codex CLI is available:
codex --version
  1. Add the repository as a marketplace once:
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.

  1. Install the plugin:
codex plugin add vistack@vistack
  1. Verify it is installed and enabled:
codex plugin list
  1. Start a new Codex thread and invoke $vistack:vistack. Codex runs the playbook phases in the

current 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 and OpenCode

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.


usage

Equivalent entrypoints

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:

PurposeClaude CodeCodex
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>.
PartWhat it decides
what you observed or wantwhich 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.

A bug, with a reproduction required

/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.

A groomed ticket, run unattended

/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.

An overnight run

/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.

A long run, reported at a phase boundary

/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.

A read-only investigation

/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.

A report or diagram

/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.

A QA pass with video

/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.

Sticky mode

  • Follow-up turns stay in the mode. Answering a question, adding a constraint, or asking for a change continues the open playbook at its next unchecked step. Do not re-invoke /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.
  • A finished playbook leaves the mode idle, not exited. 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.

Local decision engine

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:

BoundaryDecision typesWhat remains authoritative
Intake and groomingintake-analysis, groomingreadiness fields and FENCE 2
Route matchingplaybook-selectionthe playbook table and selected playbook
Slice planningdecomposition, tier-selectionfile ownership, conflict matrix, 500-line limit, tier rule
Pre-dispatch and monitoringdispatch-readiness, runtime-progresscoordinator state, dependencies, monitor, ledger
QAverificationartifacts mapped to acceptance criteria
Retrospective reviewskill-improvementexplicit 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.

Host integrations

  • Claude Code and Codex use the shared Python CLI and the 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 Build support is declared in .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>.
  • OpenCode support is in 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.

Workflow observer

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.

Deck: an in-session pane (Claude Code)

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.

Review watch

/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.


QA evidence: screenshots and video

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 fieldComes from
screenshot<scenario>-<step>.png, captured at the end of the step

| video | <scenario>.mp4 @ 00:04-00:09, with times

Source 31 files
hooks/register.tsx 2591 lines
1import { 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 lines
1// 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  })
218
hooks/lib/awake.ts 65 lines
1// 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}
65
hooks/lib/board.ts 106 lines
1// 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}
106
hooks/lib/crew.ts 94 lines
1// 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}
94
hooks/lib/format.ts 127 lines
1import { 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}
127
hooks/lib/jev.ts 69 lines
1// 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)
69
hooks/lib/launch.ts 69 lines
1// 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}
69
hooks/lib/ledger.ts 355 lines
1// 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}
355
hooks/lib/org.ts 425 lines
1// 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}
425
hooks/lib/monitors.ts 827 lines
1// 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}
827
hooks/lib/pricing.ts 70 lines
1// 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