A side pane (and a band above the prompt) tracking channel messages waiting for a reply, running and recently ended tool calls and subagents, rate-limit quota…

A Claude Code mod that shows, at a glance, what your session is doing: channel messages still waiting for a reply, tool calls and subagents in flight and recently ended, how much of each rate limit is used, external dispatches, cards fed by your own commands, and the session itself.
It is a plugin of function hooks with two views: a one-line band above the prompt, and a /monitor side pane made of cards that you can reorder, move to the band or hide with buttons. It only observes; it never changes what the session does.
/monitor commandThe band sits above the prompt in every session that loads the mod.
<img src="docs/renders/band-100.svg" alt="The band at 100 columns with the pane closed: INBOX 1 with a channel message waiting 6 minutes, NOW with an agent running 35 minutes in yellow, the quota at 61% in green, and no reply yet in gray, separated by vertical bars" width="872">
Pane closed:
● INBOX 3 discord #general 18m +1ch │ ● NOW Bash "build" 12m +1 │ ● restart soon │ ● QUOTA week 61% │ no reply yet
Pane open (only what is past a threshold, and cards you placed on the band):
● INBOX 3 discord #general 18m +1ch │ ● NOW Bash "build" 12m +1 │ ● QUOTA 5h 92% !! resets 17:10
Nothing going on:
· INBOX 0 │ · NOW idle │ no reply yet
| Segment | Shows |
|---|---|
INBOX | Channel messages not answered yet, the oldest one's channel and wait time, and +Nch for other channels that are waiting. |
NOW | The oldest tool call or background subagent still running, how long it has run, and +N for the others. |
restart soon / restart now | Only when context usage passes contextWarnPercent / contextCriticalPercent. |
QUOTA | The tightest rate-limit window (the highest use among fresh readings): 5h and week are Claude's own, other names come from quotaCommand. Past quotaWarnPercent it turns yellow with !; past quotaCriticalPercent red with !! and the reset time. Only when there is a reading. |
| placed cards | A card you set to Band in arrange mode: its title and its one-line summary. |
| last reply | no reply yet, replied just now or replied 4m ago. |
With the pane closed the band shows every segment. With the pane open it keeps only segments past a threshold (a message waiting too long, an action running too long, a restart warning, a quota past its warning line) plus the cards you placed on the band, and draws nothing at all when there are none, so the two views do not repeat each other.
When the line is too narrow: the quota first shrinks to Q 61%; then the last reply goes, then cards placed on the band (last first), then the restart warning, then NOW; the quota goes last. INBOX always stays.
Type /monitor to open or close the pane. This sample is drawn by the test suite at 60 columns, with neutral sample data; PROJECTS is a custom card whose items name a group, and INBOX and SESSION are hidden. More samples, at 60 and 100 columns and in arrange mode, are in docs/renders/.
<img src="docs/renders/pane-quota-ctx-60.svg" alt="The pane at 60 columns: a header card reading AGENT MONITOR with the Arrange button and the time; a QUOTA card with context use at 44% and gauges for Claude's 5-hour and weekly windows and two other quota rows, the last one stale in gray; a RUNNING card with one agent running 35 minutes and two recent runs, one done and one failed; a PROJECTS custom card with items under the Alpha and Beta groups; and a footer listing the hidden cards" width="536">
╭──────────────────────────────────────────────────────────╮
│ AGENT MONITOR Arrange 12:47 │
│ inbox 1 · now 1 · agents 1 · no reply yet │
╰──────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────╮
│ - QUOTA ctx 44% │
│ Claude 5h ■■■■■■···· 58% resets 13:51 │
│ Claude week ■■■■■■···· 61% resets Thu 12:00 │
│ Alpha ·········· 2% resets 10/14 │
│ Beta ·········· 1% resets 10/14 1h10m old │
╰──────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────╮
│ - RUNNING 1 · 2 recent │
│ ● agent review batch 2 fixes 35m │
│ ─── recent ───────────────────────────────────────────── │
│ ✓ Bash "build web" 3m · 12:16 │
│ ✗ agent screenshot run 12m · 12:12 │
╰──────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────╮
│ - PROJECTS 5 open · 1 on you │
│ ● Alpha review │
│ ● #301 monitor arrange mode you │
│ · #208 parser second pass me │
│ · #298 release outline ext │
│ Beta 2 │
│ · #150 nightly report cleanup me │
│ · #151 export settings page me │
╰──────────────────────────────────────────────────────────╯
hidden: inbox, session
updated 12:47 · refresh 60s · 2 hidden Show
Clone the repository anywhere, then pick one of these:
claude --plugin-dir /path/to/agent-monitor
CLAUDE_CODE_PLUGIN_DIRS (absolute paths, ~ allowed, separated by the platform's path-list separator), either in the process environment or in the env block of ~/.claude/settings.json. Each folder is loaded exactly as a --plugin-dir. { "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/plugins/agent-monitor" } }
~/.claude/skills/agent-monitor/; Claude Code auto-loads it in the next session as agent-monitor@skills-dir.Then type /monitor. With the default options the mod runs no external command: you get the band, INBOX, RUNNING and SESSION, and QUOTA once Claude Code reports your plan's rate limits. Set quotaCommand, dispatchCommand or customCards to add the other sources and cards.
Options are the plugin's userConfig fields. Each one appears as a row in Claude Code's config menu, and is stored in settings under pluginConfigs, keyed by the plugin's name (agent-monitor, or agent-monitor@inline), in its options object. A change in the config menu reloads the mod with the new values.
| Option | Default | Description |
|---|---|---|
channelNames | "" | Display names for channel ids, as id=name pairs separated by commas (e.g. 123456=general,987654=ops). Empty shows <server> #<last 4 digits of the id>. |
replyTools | "" | Comma-separated tool names that count as replying to a channel. Empty means any MCP tool whose name ends in __reply or __voice_reply, matched to its own server. |
waitingAlertMinutes | 5 | A message waiting this long without a reply turns the inbox to the warning color and shows one toast. Clamped to 0 to 1440. |
longActionMinutes | 10 | A tool call running this long is shown in the warning color. Clamped to 0 to 1440. |
contextWarnPercent | 70 | Context usage at or above this shows restart soon and one toast each time it is crossed. Clamped to 1 to 100. |
contextCriticalPercent | 85 | Context usage at or above this shows restart now in the error color. Never below contextWarnPercent. |
dispatchCommand | "" | A read-only command that prints dispatch events as JSONL (see Dispatch JSONL contract). {since24h} is replaced with an RFC 3339 time 24 hours ago. Empty hides the DISPATCHES card and runs nothing. |
dispatchCommandPattern | "" | A regular expression; a Bash call whose command matches it is labelled dispatch -> <runtime> (runtime taken from --runtime <x>), and the dispatches are re-read 5 s after it starts. Empty recognises nothing. |
runtimeNames | "" | Display names for runtime ids, as id=name pairs separated by commas (e.g. codex-cli=Codex). Empty shows the runtime id. |
timeZone | "" | IANA time zone for clock times in the pane (e.g. Europe/Berlin). Empty uses UTC. |
customCards | "" | Extra cards as TITLE=command pairs separated by ;; (see Custom cards). Empty adds no cards. |
customCardRefresh | "" | Per-card intervals as card-id=seconds pairs separated by commas, e.g. board=10, builds=30. Ids follow collapsedCards; seconds are clamped to 10 through 3600. Malformed entries are ignored; unlisted cards keep 60 seconds. |
openOnStart | false | Open the monitor pane when a session starts. |
wakePattern | "" | A regular expression marking prompts that woke the session (shown in SESSION). Empty counts scheduled and loop triggers. |
collapsedCards | "session" | Comma-separated card ids collapsed until you expand them: inbox, running, dispatches, session, and each custom card's id. |
customCardMaxItems | 5 | Most items an expanded custom card lists; the rest fold into one +N more line. Clamped to 1 to 100. |
paneMaxRows | 44 | Rows the pane may use when the surface does not report its height. Clamped to 10 to 500. |
quotaCommand | "" | A read-only command that prints rate-limit rows as JSON (see Quota JSON contract), added to Claude's own windows. Runs every 60 seconds and when the pane opens, pane open or not, because the band shows the quota too. Empty runs nothing and shows Claude's windows only. |
quotaWarnPercent | 70 | A quota window at or above this turns yellow and gets !. Clamped to 1 to 100. |
quotaCriticalPercent | 90 | A quota window at or above this turns red and gets !!; the band adds its reset time. Never below quotaWarnPercent. |
recentRows | 5 | How many ended runs RUNNING lists under recent. Clamped to 0 to 30. |
statusLine | false | Pin the mod's own status line under the prompt (see The status line). |
statusLineState | "model,context,quota,mode" | The parts on the status line's left side, in order: model, context, quota, mode. Empty shows none. |
statusLineSubinfo | "schedule" | The id of the card whose summary the status line shows on the right. Empty shows none. |
Numbers outside their range are clamped rather than rejected. A regular expression that does not compile is matched as plain text instead, so a typo never stops the mod from loading.
Example ~/.claude/settings.json fragment:
{
"pluginConfigs": {
"agent-monitor": {
"options": {
"channelNames": "123456=general,987654=ops",
"timeZone": "Europe/Berlin",
"openOnStart": true,
"customCards": "TODO=python3 /path/to/todo.py;;BUILDS=/path/to/builds --json",
"quotaCommand": "python3 /path/to/quota.py",
"collapsedCards": "session,builds"
}
}
}
}
By default cards appear top to bottom in this order; arrange mode changes the order and where each card goes. The id is the name the /monitor command and collapsedCards use.
| Card | Id | Default |
|---|---|---|
| header | none | always shown, cannot be hidden or moved |
QUOTA | quota | expanded; only when Claude reports its rate limits or quotaCommand is set |
INBOX | inbox | expanded |
RUNNING | running | expanded |
DISPATCHES | dispatches | expanded; only when dispatchCommand is set |
| custom cards | the title in lower case, other characters as dashes (WAITING ON YOU is waiting-on-you) | expanded unless listed in collapsedCards |
SESSION | session | collapsed |
Every card has a title row with a toggle (- expanded, + collapsed) and a badge on the right. Expanded, it lists its items; collapsed, it keeps one summary row. Where the terminal supports it, the title row is a button that toggles the card.
Header. The Arrange button and the clock (in timeZone), an overview line (inbox N · now N · agents N and the time since the last reply) and, past the context warning line, restart soon or restart now.
QUOTA. One row per rate-limit window: Claude's own 5-hour and weekly windows (from Claude Code, the figures its status line shows), then the rows of quotaCommand. Each row has a 10-cell gauge (■■■■■■····: small squares for the used part, rounded to the nearest 10%, and a dim dotted track for the rest; neither touches the cell edges, so gauges on neighbouring rows stay apart), the percent right-aligned, ! or !! past the thresholds, and when the window resets (17:10 within a day, Thu 16:00 within a week, 10/14 after that). Gauges and percents are green below quotaWarnPercent, yellow past it and red past quotaCriticalPercent; the track stays dim. A row read longer ago than twice its source's maxAgeSeconds is drawn gray with its age (1h10m old) and is never taken as the tightest; a row with no reading says no data. The badge is context use, ctx 44%: green below contextWarnPercent, yellow and bold with ! past it, red and bold with !! past contextCriticalPercent. Until Claude Code reports context use, the badge is the tightest quota (tightest 61%). When quotaCommand fails, the card shows could not read quota: <reason> above the rows it still has; nothing else changes. QUOTA is never folded: it always lists every row, ignores /monitor rows, and is never shortened to fit the pane's height (see Fitting the pane height).
INBOX. Messages delivered by any channel server (for example a Discord channel plugin) that have not been answered, one row per channel with the count (x2) and the oldest wait time. A message waiting longer than waitingAlertMinutes turns the row to the warning color and shows one toast. A reply clears messages when a reply tool succeeds on the same server, and on the same chat when the reply names a chat_id; reactions and edits do not count. The same message delivered twice is counted once.
RUNNING. Every tool call in flight and every background subagent still running, oldest first, with elapsed time. Labels: Bash shows its description; a Bash call matching dispatchCommandPattern shows dispatch -> <runtime>; the Agent tool shows agent <description>; MCP tools show their short name. Inside a subagent only dispatches are tracked; the rest is covered by the subagent's own row. Anything past longActionMinutes turns to the warning color.
Below recent, the last recentRows Bash calls and subagents that ran at least 10 seconds, newest first, written like the dispatches' recent list: ✓ done, ✗ failed, – cancelled, then how long it ran and when it ended (3m · 15:38). A background subagent joins the list once Claude Code's subagent list says it ended. Other tools and shorter runs are left out. The list is kept in the mod's store, so a hot reload or a new session keeps it. The badge reads N · M recent.
DISPATCHES. Work you hand to other agents or tools outside this session, read from dispatchCommand. The badge reads N running · M stalled. Running dispatches come first (runtime, short id, start time, age, summary); dispatches stalled within the last hour are listed one by one, and older stalled ones fold into one ◌ N stalled since HH:MM line; below recent, the last 3 ended dispatches (change with /monitor rows dispatches <n>). A command that fails shows a one-line reason in the card.
Custom cards. One card per customCards entry, filled from your own command's JSON. The badge is the command's badge text when it gives one, else the item count, plus N failed in red when any item failed. See Custom cards.
SESSION. When the session started and how long it has been up, the last wake (time and the first 30 characters of the prompt that woke it), and how many times the conversation was compacted and when. Collapsed, it reads up 8m · woke never · compacted 0. These figures survive a hot reload.
Footer. updated HH:MM · refresh 60s says when the cards' data was last read. Configured custom intervals follow it, e.g. · board 10s · builds 30s; this list is omitted first when space is short. When cards are hidden, the line above lists their ids and the footer ends in N hidden Show; Show lists each hidden card with its own Show button that puts it back.
Both views take their marks and colors from one table, so they always agree.
| Mark | Meaning | Color |
|---|---|---|
● | running, or a severity dot for waiting messages and actions | green; yellow or red past a threshold |
◌ | stalled: no end event and no start or heartbeat for 3 minutes | yellow |
✓ | done | dim |
✗ | failed or rejected | red |
– | cancelled | dim |
· | idle, waiting, or nothing to show | dim |
! / !! | a quota past its warning / critical line | yellow / red |
■· | a quota gauge: used / left | used green, yellow or red past a threshold; track dim; all dim when stale |
↑ ↓ | move a card up or down (arrange mode) |
With statusLine on, the mod also pins one line of plain text under the prompt (Claude Code's $.ui.status, one line per plugin). It is meant to stand in for an external status line command:
Opus 5.5 · ctx 58% left · quota week 61% · bypass permissions │ SCHEDULE 3 · next 12:30 nightly-report-run · 1 failed
statusLineState names, joined by ·:model: the main model, as /model shows it.context: how much of the context window is left (100 minus the fill Claude Code reports), with ! past contextWarnPercent and !! past contextCriticalPercent.quota: the tightest quota window, the one the band and QUOTA show, with ! and !! past the quota lines.mode: the permission mode. Claude Code hands it to mods only on classic hook events, so it is read at each prompt, tool call and stop: after a change (shift+tab) it shows at the next one. Until an event has carried it the part is left out rather than guessed; default is not shown.│, the card statusLineSubinfo names, as TITLE count · summary (its collapsed summary, so a schedule card's next run and failures). While the pane is closed, a custom card named here is still read every 60 seconds so the line stays current; no other card's command runs.│ and not right alignment. $.ui.status takes plain text and is shown beside Claude Code's own notices; the terminal width reaches a mod only inside ui.render, not when it sets the status, so the line cannot be padded to push the Subinfo to the right edge. A divider is used instead.! or !!. A part with nothing known yet is left out; with nothing at all, the line is cleared. Turning statusLine off clears it at the next load.session.measure), on the 60-second refresh, when the pane's refresh reads new card data, and when a hook event carries a new permission mode.Press Arrange in the header to rearrange the pane without typing commands; press Done to go back. While arranging, each card shows only its title row, with four buttons (a sample at 60 columns, where they shrink to ↑ ↓ B H; QUOTA, the first card, holds the letter keys until the focus ring moves):
<img src="docs/renders/pane-arrange-60.svg" alt="The pane in arrange mode at 60 columns: the header shows Done in place of Arrange and explains that each change is saved at once, how to pick a card and what the shortened buttons mean; below it each card shows only its title, QUOTA holding the letter keys d, b and h, RUNNING with up, down, Band and Hide, PROJECTS as the last card without down; the footer lists the hidden cards" width="536">
╭──────────────────────────────────────────────────────────╮
│ AGENT MONITOR Done 12:47 │
│ Arrange: move, place or hide cards. Changes are kept. │
│ Each change is saved at once; Done only leaves. │
│ Pick a card: Tab or arrow keys, or click. Keys u d b h. │
│ ↑ ↓ move · B/P band or pane · H hide │
╰──────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────╮
│ QUOTA d: ↓ b: B h: H │
│ RUNNING ↑ ↓ B H │
│ PROJECTS ↑ B H │
╰──────────────────────────────────────────────────────────╯
hidden: inbox, session
updated 12:47 · refresh 60s · 2 hidden Show
| Button | Effect |
|---|---|
↑ / ↓ | Move the card one place up or down among the cards not hidden. The first card has no ↑ and the last no ↓. |
Band / Pane | Show the card as a segment of the band (its title and one-line summary) instead of in the pane, or bring it back. Cards on the band are marked (band) here and stay on the band even with the pane open. |
Hide | Take the card off the pane and the band. It is listed in the footer, where Show brings it back. Hiding QUOTA also removes the quota from the band. |
Under the header, arrange mode says how it works: each change is saved the moment you press a button (Done only leaves arrange mode, it does not save), and how to pick a card. Below 80 columns it adds a legend for the shortened buttons: ↑ ↓ move · B/P band or pane · H hide. More samples: the hidden list shown, a card moved from the band back to the pane, and the focus on another card.
The order, the placement and the hidden cards are kept in the mod's store, so the next session starts the way you left it; /monitor hide and /monitor show work on the same hidden list. Below 80 columns the buttons shrink to ↑ ↓ B H; a long card title is cut before any button is.
Keys. When the pane has the keyboard (a click on it, or Claude Code's focus key), Tab and the arrow keys move the focus ring between buttons and Enter presses the one it is on. The card the ring is on also takes four letter keys, show
hooks/register.tsx 834 lines1// agent-monitor: a band above the prompt and a /monitor pane, both drawn from one model.
2// Every hook only observes: it passes its event on with next(e) unchanged and keeps its own
3// errors to itself (a debug log line), so the session's tools and prompts never feel it.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register, RenderChildren } from 'claude-code'
6
7import type { Action, AgentRun, ContextMark, CustomView, DispatchView, Pending, Placement, QuotaView, RecentRun, SessionInfo, StatusInfo } from '../types'
8import { parseConfig, resolveCards } from './config'
9import type { Config } from './config'
10import { dispatchArgv, dispatchFromRun, oneLine } from './dispatch'
11import {
12 addPending,
13 appendChannelText,
14 clearByReply,
15 contextTransition,
16 dueAlerts,
17 isTrackedInSubagent,
18 labelFor,
19 launchOf,
20 mergeAgentStatus,
21 parseChannelMessages,
22 replyTarget,
23 waitingToast,
24} from './logic'
25import type { ChannelMessage } from './logic'
26import { buildModel } from './model'
27import type { ModelInput } from './model'
28import { cardIds, cardOfKey, monitorHint, movedOrder, parseMonitorArgs, rowsAfter, storedIds, storedPlacement } from './arrange'
29import { customFromRun, emptyCustom } from './custom'
30import { RECENT_MIN_MS, RECORDED_TOOLS, addRecent, endedAgents, storedRecent } from './recent'
31import { arrangedOrder, cardInner, cardTitle, layoutPane, paneDoc } from './pane'
32import type { PaneOptions } from './pane'
33import { claudeRows, quotaFromRun } from './quota'
34import { TOGGLE, bandExtras, bandLine, bandSegments, statusLineText, textProps } from './view'
35import type { Line } from './view'
36
37const PANE = 'agent-monitor'
38const TICK_MS = 30_000
39const PANE_REFRESH_MS = 60_000
40const QUOTA_REFRESH_MS = 60_000
41const COMMAND_TIMEOUT_MS = 20_000
42
43const pendingA = atom({ plugin: 'agent-monitor', key: 'pending' } as const, [] as Pending[])
44const seenA = atom({ plugin: 'agent-monitor', key: 'seen' } as const, [] as string[])
45const lastReplyA = atom({ plugin: 'agent-monitor', key: 'lastReplyAt' } as const, null as number | null)
46const actionsA = atom({ plugin: 'agent-monitor', key: 'actions' } as const, [] as Action[])
47const tickA = atom({ plugin: 'agent-monitor', key: 'tick' } as const, 0)
48const contextA = atom({ plugin: 'agent-monitor', key: 'context' } as const, { percent: null, isAlerted: false } as ContextMark)
49const dispatchA = atom({ plugin: 'agent-monitor', key: 'dispatch' } as const, { rows: [], error: null, fetchedAt: null } as DispatchView)
50const agentsA = atom({ plugin: 'agent-monitor', key: 'agents' } as const, [] as AgentRun[])
51const customA = atom({ plugin: 'agent-monitor', key: 'custom' } as const, [] as CustomView[])
52const sessionA = atom({ plugin: 'agent-monitor', key: 'session' } as const, {
53 startedAt: null,
54 wakeAt: null,
55 wakeText: '',
56 compactCount: 0,
57 compactAt: null,
58} as SessionInfo)
59const expandedA = atom({ plugin: 'agent-monitor', key: 'expanded' } as const, {} as Record<string, boolean>)
60const STORE_EXPANDED = 'expanded'
61const STORE_HIDDEN = 'hidden'
62const STORE_ROWS = 'rows'
63const hiddenA = atom({ plugin: 'agent-monitor', key: 'hidden' } as const, [] as string[])
64const rowsA = atom({ plugin: 'agent-monitor', key: 'rows' } as const, {} as Record<string, number>)
65const quotaA = atom({ plugin: 'agent-monitor', key: 'quota' } as const, { claude: [], external: [], error: null, fetchedAt: null } as QuotaView)
66const recentA = atom({ plugin: 'agent-monitor', key: 'recent' } as const, [] as RecentRun[])
67const orderA = atom({ plugin: 'agent-monitor', key: 'order' } as const, [] as string[])
68const placementA = atom({ plugin: 'agent-monitor', key: 'placement' } as const, {} as Record<string, Placement>)
69const arrangingA = atom({ plugin: 'agent-monitor', key: 'arranging' } as const, false)
70const selectedA = atom({ plugin: 'agent-monitor', key: 'selected' } as const, null as string | null)
71const revealA = atom({ plugin: 'agent-monitor', key: 'revealHidden' } as const, false)
72const statusA = atom({ plugin: 'agent-monitor', key: 'status' } as const, { model: null, permissionMode: null } as StatusInfo)
73const STORE_RECENT = 'recent'
74const STORE_ORDER = 'order'
75const STORE_PLACEMENT = 'placement'
76const QUOTA_TIMEOUT_MS = 10_000
77const CUSTOM_TIMEOUT_MS = 10_000
78/** How long after a dispatch command starts before its DispatchStarted event is read. */
79const DISPATCH_SETTLE_MS = 5_000
80const storeSessionKey = (startedAt: number): string => `session:${startedAt}`
81
82type $ = EngineInterface
83
84const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err))
85
86const logError = ($: $, where: string, err: unknown): void => {
87 try {
88 $.ui.log(`agent-monitor ${where}: ${oneLine(errText(err))}`, { to: 'debug' })
89 } catch {
90 // Not even the debug log: give up quietly.
91 }
92}
93
94// ---------- the inbox ----------
95
96const recordMessages = async ($: $, messages: readonly ChannelMessage[]): Promise<void> => {
97 if (messages.length === 0) return
98 const now = await $.clock.now()
99 let added: Pending[] = []
100 await update($, seenA, seen => {
101 const ledger = addPending({ pending: [], seen }, messages, now)
102 added = ledger.pending
103 return ledger.seen
104 })
105 if (added.length > 0) await update($, pendingA, list => [...list, ...added])
106}
107
108const onTick = async ($: $, cfg: Config): Promise<void> => {
109 try {
110 const now = await $.clock.now()
111 await update($, tickA, () => now)
112 let due: Pending[] = []
113 await update($, pendingA, list => {
114 const result = dueAlerts(list, now, cfg.waitingAlertMs)
115 due = result.due
116 return result.pending
117 })
118 for (const p of due) $.ui.toast(waitingToast(cfg, p), { timeoutMs: 10_000 })
119 } catch (err) {
120 logError($, 'tick', err)
121 }
122}
123
124// ---------- dispatches and subagents (pane refresh) ----------
125
126/**
127 * When the running refresh started. A refresh whose `$` call never settles (its timer dispatch
128 * abandoned) must not block every later one, so a flag older than REFRESH_STALE_MS is ignored.
129 */
130let refreshingSince: number | null = null
131const REFRESH_STALE_MS = 45_000
132
133const readDispatches = async ($: $, cfg: Config, now: number): Promise<DispatchView> => {
134 try {
135 return dispatchFromRun(cfg, await $.process.run(dispatchArgv(cfg, now), { timeoutMs: COMMAND_TIMEOUT_MS }), now)
136 } catch (err) {
137 return { rows: [], error: oneLine(errText(err)), fetchedAt: now }
138 }
139}
140
141const readCustom = async ($: $, card: Config['customCards'][number], now: number): Promise<CustomView> => {
142 const base = { ...emptyCustom(card), fetchedAt: now }
143 try {
144 return customFromRun(base, await $.process.run(card.argv, { timeoutMs: CUSTOM_TIMEOUT_MS }))
145 } catch (err) {
146 return { ...base, error: oneLine(errText(err)) }
147 }
148}
149
150// Each card owns its start time and lock; abandoned reads cannot overwrite newer data.
151const customReads = new Map<string, { startedAt: number; isRunning: boolean }>()
152const refreshCustom = async ($: $, cfg: Config, card: Config['customCards'][number]): Promise<void> => {
153 const now = await $.clock.now()
154 const previous = customReads.get(card.id)
155 const seconds = cfg.customCardRefresh.get(card.id)
156 if (previous !== undefined) {
157 if (previous.isRunning && now - previous.startedAt < REFRESH_STALE_MS) return
158 if (seconds !== undefined && now - previous.startedAt < seconds * 1000 - 1000) return
159 }
160 const token = { startedAt: now, isRunning: true }
161 customReads.set(card.id, token)
162 try {
163 const view = await readCustom($, card, now)
164 if (customReads.get(card.id) === token) {
165 await update($, customA, list => list.map(one => one.id === card.id ? view : one))
166 if (cfg.statusLine) await pushStatus($, cfg)
167 }
168 } finally {
169 token.isRunning = false
170 }
171}
172
173const onCustomTimer = async ($: $, cfg: Config, card: Config['customCards'][number]): Promise<void> => {
174 try {
175 if (await isPaneOpen($)) await refreshCustom($, cfg, card)
176 } catch (err) {
177 logError($, 'custom timer', err)
178 }
179}
180
181const hasQuota = async ($: $, cfg: Config): Promise<boolean> => cfg.quotaArgv.length > 0 || (await read($, quotaA)).claude.length > 0
182
183/** The card ids drawn now (QUOTA appears once Claude reports its windows). */
184const liveCardIds = async ($: $, cfg: Config): Promise<string[]> => cardIds(cfg, await hasQuota($, cfg))
185
186/** Expands or collapses cards (`all` for every one), kept in $.state and $.store. */
187const setExpanded = async ($: $, cfg: Config, which: string, isExpanded: boolean): Promise<string[]> => {
188 const match = resolveCards(await liveCardIds($, cfg), which)
189 if ('error' in match) return []
190 const target = match.ids
191 const next: Record<string, boolean> = { ...(await read($, expandedA)), ...Object.fromEntries(target.map(id => [id, isExpanded])) }
192 await update($, expandedA, () => next)
193 await $.store.set(STORE_EXPANDED, next)
194 return target
195}
196
197/** /monitor hide|show|rows: the answer line for the person. */
198const arrangeCards = async ($: $, cfg: Config, verb: string, which: string, value: string | undefined): Promise<string> => {
199 const match = resolveCards(await liveCardIds($, cfg), which)
200 if ('error' in match) return match.error
201 const target = match.ids
202 if (verb === 'hide' || verb === 'show') {
203 if (verb === 'hide' && which === 'all') return 'Hide cards one at a time; the header card always stays.'
204 await saveHidden($, list => (verb === 'hide' ? [...new Set([...list, ...target])] : list.filter(id => !target.includes(id))))
205 return `${verb === 'hide' ? 'Hidden' : 'Shown'}: ${target.join(', ')}.`
206 }
207 const rows = rowsAfter(await read($, rowsA), target, value)
208 if ('error' in rows) return rows.error
209 await update($, rowsA, () => rows.next)
210 await $.store.set(STORE_ROWS, rows.next)
211 return rows.text
212}
213
214// ---------- quota ----------
215
216/** Like refreshingSince, for the quota command alone: one hung read never blocks the next. */
217let quotaSince: number | null = null
218
219const readQuota = async ($: $, cfg: Config): Promise<Pick<QuotaView, 'external' | 'error'> | { error: string }> => {
220 try {
221 return quotaFromRun(await $.process.run(cfg.quotaArgv, { timeoutMs: QUOTA_TIMEOUT_MS }))
222 } catch (err) {
223 return { error: oneLine(errText(err)) }
224 }
225}
226
227/**
228 * Claude's windows from $.session.usage(), then the quotaCommand when one is set. A failing command
229 * keeps its last rows (they turn stale on their own) and only the QUOTA card shows why.
230 */
231const readClaudeQuota = async ($: $): Promise<void> => {
232 try {
233 const now = await $.clock.now()
234 const usage = await $.session.usage()
235 const rows = claudeRows(usage.rateLimits ?? [], now)
236 await update($, quotaA, q => ({ ...q, claude: rows }))
237 } catch (err) {
238 logError($, 'session.usage', err)
239 }
240}
241
242const refreshQuota = async ($: $, cfg: Config): Promise<void> => {
243 try {
244 await readClaudeQuota($)
245 const now = await $.clock.now()
246 if (cfg.quotaArgv.length > 0 && (quotaSince === null || now - quotaSince >= REFRESH_STALE_MS)) {
247 quotaSince = now
248 try {
249 const read = await readQuota($, cfg)
250 await update($, quotaA, q => ({ ...q, ...read, fetchedAt: now }))
251 } finally {
252 if (quotaSince === now) quotaSince = null
253 }
254 }
255 if (cfg.statusLine) await readStatusSources($, cfg).then(() => pushStatus($, cfg))
256 } catch (err) {
257 logError($, 'quota', err)
258 }
259}
260
261// ---------- the status line ($.ui.status) ----------
262
263/** The model's name and, until a measurement comes, context use; the Subinfo card's command while the pane is closed. */
264const readStatusSources = async ($: $, cfg: Config): Promise<void> => {
265 const model = await $.session.model()
266 if ((await read($, statusA)).model !== model) await update($, statusA, s => ({ ...s, model }))
267 const { percent } = (await $.session.usage()).context
268 if (typeof percent === 'number') await update($, contextA, c => (c.percent === null ? { percent, isAlerted: percent >= cfg.contextWarn } : c))
269 const card = cfg.customCards.find(one => one.id === cfg.statusLineSubinfo)
270 if (card === undefined || (await isPaneOpen($))) return // the pane's own refresh reads it then
271 await refreshCustom($, cfg, card)
272}
273
274/** Pins the status line, or clears it when it is off or knows nothing yet. */
275const pushStatus = async ($: $, cfg: Config): Promise<void> => {
276 try {
277 if (!cfg.statusLine) return $.ui.status(undefined)
278 const model = await readModel($, cfg)
279 const sub = paneDoc(model, 200, cfg.timeZone, await paneOptions($, cfg)).all.find(c => c.id === cfg.statusLineSubinfo) ?? null
280 $.ui.status(statusLineText(model, cfg.statusLineState, await read($, statusA), sub))
281 } catch (err) {
282 logError($, 'status line', err)
283 }
284}
285
286/** The permission mode a classic hook event carries (main conversation only); unknown until one does. */
287const notePermissionMode = async ($: $, cfg: Config, e: { agent_id?: string; permission_mode?: string }): Promise<void> => {
288 const mode = e.permission_mode
289 if (e.agent_id !== undefined || typeof mode !== 'string' || (await read($, statusA)).permissionMode === mode) return
290 await update($, statusA, s => ({ ...s, permissionMode: mode }))
291 await pushStatus($, cfg)
292}
293
294// ---------- recently ended runs ----------
295
296const saveRecent = async ($: $, run: RecentRun): Promise<void> => {
297 let next: RecentRun[] = []
298 await update($, recentA, list => {
299 next = addRecent(list, run)
300 return next
301 })
302 await $.store.set(STORE_RECENT, next)
303}
304
305/** Background subagents end after their call does: once $.agent.list() says so, they join recent. */
306const recordEndedAgents = async ($: $, cfg: Config, now: number): Promise<void> => {
307 let ended: RecentRun[] = []
308 await update($, agentsA, runs => {
309 const result = endedAgents(runs, now, run => labelFor(cfg, 'Agent', { description: run.description }))
310 ended = result.ended
311 return result.runs
312 })
313 for (const run of ended) await saveRecent($, run)
314}
315
316/** Reads what the pane shows; `isOpening` also runs the quotaCommand, which otherwise keeps its own 60 s timer. */
317const refreshPane = async ($: $, cfg: Config, isOpening = false): Promise<void> => {
318 const now = await $.clock.now()
319 if (refreshingSince !== null && now - refreshingSince < REFRESH_STALE_MS) return
320 refreshingSince = now
321 try {
322 await update($, tickA, () => now)
323 if (cfg.dispatchArgv.length > 0) {
324 const view = await readDispatches($, cfg, now)
325 await update($, dispatchA, () => view)
326 }
327 for (const card of cfg.customCards) {
328 if (isOpening || !cfg.customCardRefresh.has(card.id)) await refreshCustom($, cfg, card)
329 }
330 await (isOpening ? refreshQuota($, cfg) : readClaudeQuota($))
331 if (cfg.statusLine && !isOpening) await pushStatus($, cfg) // the cards it read may feed the Subinfo
332 try {
333 const infos = await $.agent.list()
334 await update($, agentsA, runs => mergeAgentStatus(runs, infos))
335 await recordEndedAgents($, cfg, now)
336 } catch (err) {
337 logError($, 'agent.list', err)
338 }
339 } catch (err) {
340 logError($, 'refresh', err)
341 } finally {
342 if (refreshingSince === now) refreshingSince = null
343 }
344}
345
346const isPaneOpen = async ($: $): Promise<boolean> => (await $.ui.panes()).some(pane => pane.id === PANE)
347
348const onPaneTimer = async ($: $, cfg: Config): Promise<void> => {
349 try {
350 if (await isPaneOpen($)) await refreshPane($, cfg)
351 } catch (err) {
352 logError($, 'pane timer', err)
353 }
354}
355
356// ---------- arranging cards ----------
357
358/** The pane options both views lay the cards out with: what the person set, by command or button. */
359const paneOptions = async ($: $, cfg: Config): Promise<PaneOptions> => ({
360 customCardRefresh: cfg.customCardRefresh,
361 customMaxItems: cfg.customCardMaxItems,
362 rows: await read($, rowsA),
363 hidden: await read($, hiddenA),
364 order: await read($, orderA),
365 placement: await read($, placementA),
366 recentRows: cfg.recentRows,
367 isArranging: await read($, arrangingA),
368 selected: await read($, selectedA),
369 isHiddenRevealed: await read($, revealA),
370})
371
372// Person-driven changes (a press, a command) come one at a time, so read-then-write is safe here.
373const saveHidden = async ($: $, change: (list: string[]) => string[]): Promise<void> => {
374 const next = change(await read($, hiddenA))
375 await update($, hiddenA, () => next)
376 await $.store.set(STORE_HIDDEN, next)
377}
378
379/** Moves a card one place up or down among the cards not hidden, kept in $.store. */
380const moveCard = async ($: $, cfg: Config, id: string, by: -1 | 1): Promise<void> => {
381 const full = arrangedOrder(await liveCardIds($, cfg), await read($, orderA))
382 const next = movedOrder(full, await read($, hiddenA), id, by)
383 if (next === null) return
384 await update($, orderA, () => next)
385 await $.store.set(STORE_ORDER, next)
386}
387
388const togglePlacement = async ($: $, id: string): Promise<void> => {
389 const map = await read($, placementA)
390 const next: Record<string, Placement> = { ...map, [id]: map[id] === 'band' ? 'pane' : 'band' }
391 await update($, placementA, () => next)
392 await $.store.set(STORE_PLACEMENT, next)
393}
394
395/** What a pane Button does, by its key: Arrange/Done, the four arrange buttons, and the hidden-card buttons. */
396const press = async ($: $, cfg: Config, key: string): Promise<void> => {
397 try {
398 const [verb = '', id = ''] = key.split(/:(.*)/s)
399 if (key === 'arrange') {
400 const isArranging = await read($, arrangingA)
401 await update($, arrangingA, () => !isArranging)
402 await update($, selectedA, () => null)
403 } else if (key === 'reveal-hidden') {
404 await update($, revealA, is => !is)
405 } else if (verb === 'up' || verb === 'down') {
406 await moveCard($, cfg, id, verb === 'up' ? -1 : 1)
407 await update($, selectedA, () => id)
408 } else if (verb === 'place') {
409 await togglePlacement($, id)
410 await update($, selectedA, () => id)
411 } else if (verb === 'hide') {
412 await saveHidden($, list => [...new Set([...list, id])])
413 await update($, selectedA, () => null)
414 } else if (verb === 'show') {
415 await saveHidden($, list => list.filter(one => one !== id))
416 if ((await read($, hiddenA)).length === 0) await update($, revealA, () => false)
417 }
418 } catch (err) {
419 logError($, `press ${key}`, err)
420 }
421}
422
423// ---------- the session card ----------
424
425/**
426 * The session's start comes from the engine ($.session.usage().startedAt: its launch, or its first
427 * launch when resumed), so a hot reload never resets it; wake and compaction figures are kept in
428 * $.store under that start too, and restored when the module's state comes back empty.
429 */
430const restoreSession = async ($: $): Promise<void> => {
431 const { startedAt } = await $.session.usage()
432 const saved = (await $.store.get(storeSessionKey(startedAt))) as Partial<SessionInfo> | undefined
433 await update($, sessionA, info => {
434 const isSameSession = info.startedAt === startedAt
435 const kept = isSameSession ? info : { ...info, wakeAt: null, wakeText: '', compactCount: 0, compactAt: null }
436 return { ...kept, ...(isSameSession ? {} : (saved ?? {})), startedAt }
437 })
438 for (const key of await $.store.keys()) {
439 if (key.startsWith('session:') && key !== storeSessionKey(startedAt)) await $.store.delete(key)
440 }
441}
442
443const saveSession = async ($: $, change: (info: SessionInfo) => SessionInfo): Promise<void> => {
444 let next: SessionInfo | null = null
445 await update($, sessionA, info => {
446 next = change(info)
447 return next
448 })
449 const done = next as SessionInfo | null
450 if (done !== null && done.startedAt !== null) await $.store.set(storeSessionKey(done.startedAt), done)
451}
452
453// ---------- tool calls ----------
454
455type Tracked = { action: Action | null; agentRun: AgentRun | null; startedAt: number }
456
457const beginCall = async (
458 $: $,
459 cfg: Config,
460 tool: string,
461 input: Record<string, unknown>,
462 loop: string | undefined,
463 id: string | undefined,
464): Promise<Tracked> => {
465 const startedAt = await $.clock.now()
466 const label = labelFor(cfg, tool, input)
467 const callId = id ?? `${tool}-${startedAt}-${Math.random().toString(36).slice(2)}`
468 let action: Action | null = null
469 if (loop === undefined || isTrackedInSubagent(label)) {
470 const one: Action = { id: callId, tool, label, startedAt }
471 action = one
472 await update($, actionsA, list => [...list, one].slice(-50))
473 }
474 let agentRun: AgentRun | null = null
475 if ((tool === 'Agent' || tool === 'Task') && loop === undefined) {
476 const description = typeof input['description'] === 'string' ? input['description'] : 'task'
477 const run: AgentRun = { id: callId, description, startedAt, endedAt: null, isBackground: false, agentId: null, status: null }
478 agentRun = run
479 await update($, agentsA, list => [...list, run].slice(-30))
480 }
481 return { action, agentRun, startedAt }
482}
483
484const endCall = async ($: $, tracked: Tracked, hasFailed: boolean, result: unknown): Promise<void> => {
485 const { action, agentRun } = tracked
486 if (action !== null) await update($, actionsA, list => list.filter(one => one.id !== action.id))
487 const launch = launchOf(result)
488 if (action !== null && RECORDED_TOOLS.has(action.tool) && !(agentRun !== null && launch.isBackground)) {
489 const endedAt = await $.clock.now()
490 if (endedAt - action.startedAt >= RECENT_MIN_MS) {
491 await saveRecent($, { id: action.id, label: action.label, startedAt: action.startedAt, endedAt, status: hasFailed ? 'failed' : 'done' })
492 }
493 }
494 if (agentRun === null) return
495 const endedAt = await $.clock.now()
496 await update($, agentsA, list =>
497 list.map(one =>
498 one.id !== agentRun.id
499 ? one
500 : {
501 ...one,
502 endedAt,
503 isBackground: launch.isBackground,
504 agentId: launch.agentId,
505 status: hasFailed ? 'failed' : launch.isBackground ? launch.status : 'completed',
506 },
507 ),
508 )
509}
510
511const noteReply = async ($: $, cfg: Config, tool: string, input: Record<string, unknown>, startedAt: number) => {
512 const target = replyTarget(cfg, tool, input)
513 if (target === null) return
514 await update($, pendingA, list => clearByReply(list, target, startedAt))
515 const now = await $.clock.now()
516 await update($, lastReplyA, () => now)
517}
518
519const readModel = async ($: $, cfg: Config) => {
520 await read($, tickA)
521 const now = await $.clock.now()
522 const input: ModelInput = {
523 pending: await read($, pendingA),
524 lastReplyAt: await read($, lastReplyA),
525 actions: await read($, actionsA),
526 context: await read($, contextA),
527 agents: await read($, agentsA),
528 dispatch: await read($, dispatchA),
529 custom: await read($, customA),
530 session: await read($, sessionA),
531 quota: await read($, quotaA),
532 recent: await read($, recentA),
533 }
534 return buildModel(input, now, cfg)
535}
536
537// ---------- hooks ----------
538
539export const register: Register = (on, options) => {
540 const cfg = parseConfig(options)
541
542 on('session.start', async ($, e, next) => {
543 try {
544 const now = await $.clock.now()
545 await update($, actionsA, () => [])
546 await update($, tickA, () => now)
547 await restoreSession($)
548 await update($, customA, list => cfg.customCards.map(card => list.find(one => one.id === card.id) ?? emptyCustom(card)))
549 const savedHidden = await $.store.get(STORE_HIDDEN)
550 if (Array.isArray(savedHidden)) await update($, hiddenA, () => savedHidden.filter((x): x is string => typeof x === 'string'))
551 const savedRows = await $.store.get(STORE_ROWS)
552 if (typeof savedRows === 'object' && savedRows !== null) await update($, rowsA, () => savedRows as Record<string, number>)
553 const saved = await $.store.get(STORE_EXPANDED)
554 if (typeof saved === 'object' && saved !== null) await update($, expandedA, () => saved as Record<string, boolean>)
555 const savedOrder = storedIds(await $.store.get(STORE_ORDER))
556 if (savedOrder !== null) await update($, orderA, () => savedOrder)
557 const savedPlacement = storedPlacement(await $.store.get(STORE_PLACEMENT))
558 if (savedPlacement !== null) await update($, placementA, () => savedPlacement)
559 // Ended runs survive a reload: restored from the store, never cleared at start.
560 const savedRecent = storedRecent(await $.store.get(STORE_RECENT))
561 if (savedRecent !== null) await update($, recentA, () => savedRecent)
562 } catch (err) {
563 logError($, 'session.start', err)
564 }
565 try {
566 await $.command.register({
567 name: 'monitor',
568 description: 'Toggle the agent monitor pane; expand or collapse its cards',
569 argumentHint: monitorHint(cfg),
570 })
571 } catch (err) {
572 logError($, 'command.register', err)
573 }
574 try {
575 $.clock.every(TICK_MS, () => void onTick($, cfg))
576 $.clock.every(PANE_REFRESH_MS, () => void onPaneTimer($, cfg))
577 // The quota feeds the band too, so it is read whether the pane is open or not.
578 $.clock.every(QUOTA_REFRESH_MS, () => void refreshQuota($, cfg))
579 for (const card of cfg.customCards) {
580 const seconds = cfg.customCardRefresh.get(card.id)
581 if (seconds !== undefined) $.clock.every(seconds * 1000, () => void onCustomTimer($, cfg, card))
582 }
583 // Claude's own windows are read before the first draw; the command runs in the background.
584 await readClaudeQuota($)
585 if (cfg.statusLine) await readStatusSources($, cfg)
586 await pushStatus($, cfg) // off: clears a line a previous load may have left
587 void refreshQuota($, cfg)
588 if (cfg.openOnStart && !(await isPaneOpen($))) {
589 await $.ui.open({ id: PANE, title: 'Agent monitor' })
590 void refreshPane($, cfg, true)
591 }
592 } catch (err) {
593 logError($, 'timers', err)
594 }
595 return next(e)
596 })
597
598 on('prompt.submit', async ($, e, next) => {
599 try {
600 if (e.origin.kind === 'channel') await recordMessages($, parseChannelMessages(e.text, e.origin.server))
601 const isWake = cfg.wakePattern !== null ? cfg.wakePattern.test(e.text) : e.origin.kind === 'scheduled-trigger'
602 if (isWake) {
603 const now = await $.clock.now()
604 const wakeText = e.text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
605 await saveSession($, info => ({ ...info, wakeAt: now, wakeText }))
606 }
607 } catch (err) {
608 logError($, 'prompt.submit', err)
609 }
610 return next(e)
611 })
612
613 // Channel messages delivered into a running turn arrive here; ones prompt.submit saw too are deduplicated.
614 on('session.append', async ($, e, next) => {
615 try {
616 const found = appendChannelText(e)
617 if (found !== null) await recordMessages($, parseChannelMessages(found.text, found.server))
618 } catch (err) {
619 logError($, 'session.append', err)
620 }
621 return next(e)
622 })
623
624 on('tool.call', async ($, e, next) => {
625 const tool = String(e.tool)
626 const input = e as unknown as Record<string, unknown>
627 let tracked: Tracked = { action: null, agentRun: null, startedAt: 0 }
628 try {
629 tracked = await beginCall($, cfg, tool, input, e.agentId, e.tool_use_id)
630 if (tracked.action?.label.startsWith('dispatch')) $.clock.after(DISPATCH_SETTLE_MS, () => void onPaneTimer($, cfg))
631 } catch (err) {
632 logError($, 'tool.call begin', err)
633 }
634 let ran: Awaited<ReturnType<typeof next>> | undefined
635 let hasThrown = true
636 try {
637 ran = await next(e)
638 hasThrown = false
639 } finally {
640 try {
641 await endCall($, tracked, hasThrown || ran?.isError === true || ran?.deny !== undefined, ran?.result)
642 } catch (err) {
643 logError($, 'tool.call end', err)
644 }
645 }
646 try {
647 if (tracked.action?.label.startsWith('dispatch') && (await isPaneOpen($))) void refreshPane($, cfg)
648 if (ran.deny === undefined && ran.isError !== true && tracked.startedAt > 0) {
649 await noteReply($, cfg, tool, input, tracked.startedAt)
650 }
651 } catch (err) {
652 logError($, 'tool.call reply', err)
653 }
654 return ran
655 })
656
657 // The permission mode reaches mods only on classic hook events: kept from each prompt, tool call and stop.
658 if (cfg.statusLine) {
659 on('classic.UserPromptSubmit', async ($, e, next) => (await notePermissionMode($, cfg, e).catch(err => logError($, 'mode', err)), next(e)))
660 on('classic.PostToolUse', async ($, e, next) => (await notePermissionMode($, cfg, e).catch(err => logError($, 'mode', err)), next(e)))
661 on('classic.Stop', async ($, e, next) => (await notePermissionMode($, cfg, e).catch(err => logError($, 'mode', err)), next(e)))
662 }
663
664 on('session.compact', async ($, e, next) => {
665 const result = await next(e)
666 try {
667 if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined) {
668 const now = await $.clock.now()
669 await saveSession($, info => ({ ...info, compactCount: info.compactCount + 1, compactAt: now }))
670 }
671 } catch (err) {
672 logError($, 'session.compact', err)
673 }
674 return result
675 })
676
677 on('session.measure', async ($, e, next) => {
678 try {
679 const percent = typeof e.context.percent === 'number' ? e.context.percent : null
680 let shouldAlert = false
681 await update($, contextA, prev => {
682 const t = contextTransition(prev, percent, cfg.contextWarn)
683 shouldAlert = t.shouldAlert
684 return t.mark
685 })
686 if (shouldAlert) {
687 $.ui.toast(`Context usage passed ${cfg.contextWarn}% - consider restarting the session soon`, { timeoutMs: 10_000 })
688 }
689 if (e.changed.includes('rateLimits')) {
690 const now = await $.clock.now()
691 const rows = claudeRows(e.rateLimits, now)
692 await update($, quotaA, q => ({ ...q, claude: rows }))
693 }
694 if (cfg.statusLine) await pushStatus($, cfg)
695 } catch (err) {
696 logError($, 'session.measure', err)
697 }
698 return next(e)
699 })
700
701 on('command.run', { command: 'monitor' }, async ($, e) => {
702 try {
703 const { verb, which, value } = parseMonitorArgs(e.args)
704 if (verb === 'hide' || verb === 'show' || verb === 'rows') return { text: await arrangeCards($, cfg, verb, which, value) }
705 if (verb === 'expand' || verb === 'collapse') {
706 const match = resolveCards(await liveCardIds($, cfg), which)
707 if ('error' in match) return { text: match.error }
708 const ids = await setExpanded($, cfg, which, verb === 'expand')
709 return { text: `${verb === 'expand' ? 'Expanded' : 'Collapsed'}: ${ids.join(', ')}.` }
710 }
711 if (await isPaneOpen($)) {
712 await $.ui.close({ id: PANE })
713 return { text: 'Agent monitor closed.' }
714 }
715 const opened = await $.ui.open({ id: PANE, title: 'Agent monitor' })
716 await refreshPane($, cfg, true)
717 return { text: opened.isPlaced ? 'Agent monitor opened.' : `Agent monitor opened, not shown yet: ${oneLine(opened.reason)}` }
718 } catch (err) {
719 logError($, 'monitor', err)
720 return { text: `Agent monitor could not toggle: ${oneLine(errText(err))}` }
721 }
722 })
723
724 // The band depends on whether the pane is open: redraw it when the pane closes, however it closes.
725 on('ui.close', async ($, e, next) => {
726 const closed = await next(e)
727 try {
728 const now = await $.clock.now()
729 if (e.id === PANE) await update($, tickA, () => now)
730 } catch (err) {
731 logError($, 'ui.close', err)
732 }
733 return closed
734 })
735
736 // The arrange buttons' hotkeys follow the focus ring: the card it lands on takes u/d/b/h.
737 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
738 try {
739 const id = cardOfKey(e.element)
740 if (id !== null) await update($, selectedA, () => id)
741 } catch (err) {
742 logError($, 'ui.focus', err)
743 }
744 return next(e)
745 })
746
747 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
748 if (e.props.hasSurvey) return next(e)
749 try {
750 const model = await readModel($, cfg)
751 const opts = await paneOptions($, cfg)
752 const doc = paneDoc(model, e.props.bodyColumns, cfg.timeZone, { ...opts, isArranging: false })
753 const segments = bandSegments(model, await isPaneOpen($), bandExtras(model, opts, doc.band, cfg.timeZone))
754 if (segments.length === 0) return next(e)
755 const line = bandLine(segments, e.props.bodyColumns)
756 const { Box, Text } = $.ui.resolve(e)
757 return (
758 <Box flexDirection="row">
759 {line.map(run => (
760 <Text {...textProps(run)}>{run.text}</Text>
761 ))}
762 </Box>
763 )
764 } catch (err) {
765 logError($, 'render band', err)
766 return next(e)
767 }
768 })
769
770 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
771 const { Box, Button, Text } = $.ui.resolve(e)
772 try {
773 const model = await readModel($, cfg)
774 const expanded = await read($, expandedA)
775 const doc = paneDoc(model, e.props.bodyColumns, cfg.timeZone, await paneOptions($, cfg))
776 const rows = e.props.scroll.bodyRows > 0 ? e.props.scroll.bodyRows : cfg.paneMaxRows
777 const layout = layoutPane(doc, id => expanded[id] ?? !cfg.collapsedCards.has(id), rows)
778 const inner = cardInner(e.props.bodyColumns)
779 const row = (line: Line) => (
780 <Box flexDirection="row">
781 {line.map(run =>
782 run.button === undefined ? (
783 <Text wrap="truncate" {...textProps(run)}>
784 {run.text}
785 </Text>
786 ) : (
787 <Button
788 key={run.button.key}
789 plain
790 label={run.button.label}
791 {...(run.button.hotkey === undefined ? {} : { hotkey: run.button.hotkey })}
792 onPress={() => press($, cfg, run.button?.key ?? '')}
793 />
794 ),
795 )}
796 </Box>
797 )
798 const card = (key: string, body: RenderChildren) => (
799 <Box key={key} flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
800 {body}
801 </Box>
802 )
803 return (
804 <Box flexDirection="column">
805 {card('head', layout.head.map(row))}
806 {doc.arrange !== null && card('arrange', doc.arrange.map(row))}
807 {layout.cards.map(({ card: one, isOpen, body }) => {
808 const title = (
809 <Box flexDirection="row">
810 <Button
811 key={`toggle-${one.id}`}
812 plain
813 label={isOpen ? TOGGLE.expanded : TOGGLE.collapsed}
814 onPress={() => void setExpanded($, cfg, one.id, !isOpen)}
815 />
816 {row(cardTitle(one, inner))}
817 </Box>
818 )
819 return card(one.id, [title, ...body.map(row)])
820 })}
821 {layout.showFooter && <Box flexDirection="column" paddingX={1}>{doc.footer.map(row)}</Box>}
822 </Box>
823 )
824 } catch (err) {
825 logError($, 'render pane', err)
826 return (
827 <Box>
828 <Text color="yellow">Agent monitor could not draw: {oneLine(errText(err))}</Text>
829 </Box>
830 )
831 }
832 })
833}
834hooks/config.ts 212 lines1// The plugin's options (manifest userConfig), parsed once per load into one Config.
2// Defaults work out of the box: no channel names, no dispatch command, nothing personal.
3
4export type Config = {
5 channelNames: ReadonlyMap<string, string>
6 replyTools: ReadonlySet<string>
7 waitingAlertMs: number
8 longActionMs: number
9 contextWarn: number
10 contextCritical: number
11 /** The dispatch command split into argv, `{since24h}` still in place; empty when not set. */
12 dispatchArgv: readonly string[]
13 dispatchPattern: RegExp | null
14 runtimeNames: ReadonlyMap<string, string>
15 timeZone: string
16 customCards: readonly { id: string; title: string; argv: readonly string[] }[]
17 customCardRefresh: ReadonlyMap<string, number>
18 openOnStart: boolean
19 wakePattern: RegExp | null
20 collapsedCards: ReadonlySet<string>
21 customCardMaxItems: number
22 paneMaxRows: number
23 /** The quota command split into argv; empty when not set. */
24 quotaArgv: readonly string[]
25 quotaWarn: number
26 quotaCritical: number
27 /** How many ended runs the RUNNING card lists under recent. */
28 recentRows: number
29 /** Whether the mod pins its own status line under the prompt. */
30 statusLine: boolean
31 /** What the status line's left side shows, in order: model, context, quota, mode. */
32 statusLineState: readonly StatePart[]
33 /** The card whose summary the status line's right side shows; '' for none. */
34 statusLineSubinfo: string
35}
36
37export const STATE_PARTS = ['model', 'context', 'quota', 'mode'] as const
38export type StatePart = (typeof STATE_PARTS)[number]
39
40const stateParts = (v: unknown): StatePart[] =>
41 typeof v !== 'string'
42 ? [...STATE_PARTS]
43 : v
44 .split(/[,\s]+/)
45 .map(p => p.trim().toLowerCase())
46 .filter((p): p is StatePart => (STATE_PARTS as readonly string[]).includes(p))
47
48/** A card's id: its title in lower case, runs of other characters as one dash. */
49export const cardId = (title: string): string =>
50 title
51 .toLowerCase()
52 .replace(/[^a-z0-9]+/g, '-')
53 .replace(/^-|-$/g, '')
54
55/** `TITLE=command;;TITLE=command`; a pair with no title or no command is skipped. */
56export const parseCustomCards = (raw: string): { id: string; title: string; argv: string[] }[] =>
57 raw
58 .split(';;')
59 .map(part => {
60 const at = part.indexOf('=')
61 const title = at > 0 ? part.slice(0, at).trim() : ''
62 return { id: cardId(title), title, argv: at > 0 ? splitArgv(part.slice(at + 1)) : [] }
63 })
64 .filter(card => card.title !== '' && card.id !== '' && card.argv.length > 0)
65
66/** Per-card seconds; invalid entries are ignored and valid values clamped. */
67export const parseCustomCardRefresh = (raw: string): Map<string, number> => {
68 const out = new Map<string, number>()
69 for (const part of raw.split(',')) {
70 const match = /^\s*([^=]+?)\s*=\s*(-?\d+(?:\.\d+)?)\s*$/.exec(part)
71 if (match === null) continue
72 const id = cardId(match[1] ?? '')
73 const seconds = Number(match[2])
74 if (id !== '' && Number.isFinite(seconds)) out.set(id, Math.min(3600, Math.max(10, seconds)))
75 }
76 return out
77}
78
79export type RawOptions = Readonly<Record<string, string | number | boolean | readonly string[]>>
80
81const MINUTE = 60_000
82
83const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '')
84
85const num = (v: unknown, fallback: number, min: number, max: number): number =>
86 typeof v === 'number' && Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : fallback
87
88/** `id=name,id=name` (commas, semicolons or newlines between pairs). */
89export const parsePairs = (raw: string): Map<string, string> => {
90 const out = new Map<string, string>()
91 for (const part of raw.split(/[,;\n]/)) {
92 const at = part.indexOf('=')
93 if (at <= 0) continue
94 const key = part.slice(0, at).trim()
95 const value = part.slice(at + 1).trim()
96 if (key && value) out.set(key, value)
97 }
98 return out
99}
100
101/** Splits a command line into argv the way a shell would for plain words and quotes; no expansion. */
102export const splitArgv = (line: string): string[] => {
103 const out: string[] = []
104 let current = ''
105 let quote: '"' | "'" | null = null
106 let hasWord = false
107 for (const ch of line) {
108 if (quote !== null) {
109 if (ch === quote) quote = null
110 else current += ch
111 continue
112 }
113 if (ch === '"' || ch === "'") {
114 quote = ch
115 hasWord = true
116 } else if (/\s/.test(ch)) {
117 if (hasWord) out.push(current)
118 current = ''
119 hasWord = false
120 } else {
121 current += ch
122 hasWord = true
123 }
124 }
125 if (hasWord) out.push(current)
126 return out
127}
128
129const toPattern = (raw: string): RegExp | null => {
130 if (!raw) return null
131 try {
132 return new RegExp(raw)
133 } catch {
134 return new RegExp(raw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
135 }
136}
137
138export const parseConfig = (options: RawOptions | undefined): Config => {
139 const o = options ?? {}
140 const warn = num(o['contextWarnPercent'], 70, 1, 100)
141 const quotaWarn = num(o['quotaWarnPercent'], 70, 1, 100)
142 return {
143 channelNames: parsePairs(str(o['channelNames'])),
144 replyTools: new Set(
145 str(o['replyTools'])
146 .split(/[,\s]+/)
147 .filter(Boolean),
148 ),
149 waitingAlertMs: num(o['waitingAlertMinutes'], 5, 0, 24 * 60) * MINUTE,
150 longActionMs: num(o['longActionMinutes'], 10, 0, 24 * 60) * MINUTE,
151 contextWarn: warn,
152 contextCritical: Math.max(warn, num(o['contextCriticalPercent'], 85, 1, 100)),
153 dispatchArgv: splitArgv(str(o['dispatchCommand'])),
154 dispatchPattern: toPattern(str(o['dispatchCommandPattern'])),
155 runtimeNames: parsePairs(str(o['runtimeNames'])),
156 timeZone: str(o['timeZone']),
157 customCards: parseCustomCards(str(o['customCards'])),
158 customCardRefresh: parseCustomCardRefresh(str(o['customCardRefresh'])),
159 openOnStart: o['openOnStart'] === true,
160 wakePattern: toPattern(str(o['wakePattern'])),
161 collapsedCards: new Set(
162 (typeof o['collapsedCards'] === 'string' ? o['collapsedCards'] : 'session')
163 .split(/[,\s]+/)
164 .map(cardId)
165 .filter(Boolean),
166 ),
167 customCardMaxItems: Math.round(num(o['customCardMaxItems'], 5, 1, 100)),
168 paneMaxRows: Math.round(num(o['paneMaxRows'], 44, 10, 500)),
169 quotaArgv: splitArgv(str(o['quotaCommand'])),
170 quotaWarn,
171 quotaCritical: Math.max(quotaWarn, num(o['quotaCriticalPercent'], 90, 1, 100)),
172 recentRows: Math.round(num(o['recentRows'], 5, 0, 30)),
173 statusLine: o['statusLine'] === true,
174 statusLineState: stateParts(o['statusLineState']),
175 statusLineSubinfo: cardId(typeof o['statusLineSubinfo'] === 'string' ? o['statusLineSubinfo'] : 'schedule'),
176 }
177}
178
179export const DEFAULT_CONFIG: Config = parseConfig({})
180
181/** Levenshtein distance. */
182const distance = (a: string, b: string): number => {
183 let prev = Array.from({ length: b.length + 1 }, (_, j) => j)
184 for (let i = 1; i <= a.length; i++) {
185 const cur = [i]
186 for (let j = 1; j <= b.length; j++) {
187 cur[j] = Math.min((prev[j] ?? 0) + 1, (cur[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1))
188 }
189 prev = cur
190 }
191 return prev[b.length] ?? 0
192}
193
194export type CardMatch = { ids: string[] } | { error: string }
195
196/**
197 * Finds the cards a typed name means: `all`; the name with case ignored and spaces as dashes; or a
198 * unique prefix of one. Several matches list the candidates; none lists every card and suggests the
199 * nearest by edit distance (to the whole id or its head of the same length).
200 */
201export const resolveCards = (ids: readonly string[], typed: string): CardMatch => {
202 const name = cardId(typed)
203 if (name === 'all') return { ids: [...ids] }
204 if (ids.includes(name)) return { ids: [name] }
205 const prefixed = name === '' ? [] : ids.filter(id => id.startsWith(name))
206 if (prefixed.length === 1) return { ids: prefixed }
207 if (prefixed.length > 1) return { error: `"${typed}" matches ${prefixed.join(', ')}; type more of the name.` }
208 const score = (id: string): number => Math.min(distance(name, id), distance(name, id.slice(0, name.length)))
209 const nearest = [...ids].sort((a, b) => score(a) - score(b))[0]
210 return { error: `No card named "${typed}".${nearest === undefined ? '' : ` Did you mean ${nearest}?`} Cards: ${ids.join(', ')}.` }
211}
212hooks/dispatch.ts 137 lines1// External dispatches: the configured command's argv, its JSONL events, and pairing them by dispatch_id.
2import type { DispatchRow, DispatchState, DispatchView } from '../types'
3import type { Config } from './config'
4import { MINUTE, cut, runtimeLabel } from './logic'
5
6export const WINDOW_MS = 24 * 60 * MINUTE
7/** Dispatches send a heartbeat every 30 s; this long without one and with no end event is stalled. */
8export const STALLED_AFTER_MS = 3 * MINUTE
9export const ENDED_LIMIT = 8
10export const SINCE_TOKEN = '{since24h}'
11
12/** RFC3339 without milliseconds. */
13export const toRfc3339 = (ms: number): string => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
14
15/** The configured command with {since24h} filled in; empty when no command is set. */
16export const dispatchArgv = (cfg: Config, now: number): string[] =>
17 cfg.dispatchArgv.map(arg => arg.split(SINCE_TOKEN).join(toRfc3339(now - WINDOW_MS)))
18
19export type DispatchEvent = { type: string; at: number; payload: Readonly<Record<string, unknown>> }
20
21export type Parsed = { events: DispatchEvent[]; error: string | null }
22
23const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
24
25/** One JSON object per line; any bad line fails the whole read rather than pairing half of it. */
26export const parseJsonl = (stdout: string): Parsed => {
27 const events: DispatchEvent[] = []
28 const lines = stdout.split('\n').filter(line => line.trim() !== '')
29 for (const [i, line] of lines.entries()) {
30 let row: unknown
31 try {
32 row = JSON.parse(line)
33 } catch {
34 return { events: [], error: `line ${i + 1} is not JSON` }
35 }
36 if (!isRecord(row) || typeof row['event_type'] !== 'string' || !isRecord(row['payload'])) {
37 return { events: [], error: `line ${i + 1} has no event_type or payload` }
38 }
39 const at = typeof row['timestamp'] === 'string' ? Date.parse(row['timestamp']) : NaN
40 if (Number.isNaN(at)) return { events: [], error: `line ${i + 1} has no readable timestamp` }
41 events.push({ type: row['event_type'], at, payload: row['payload'] })
42 }
43 return { events, error: null }
44}
45
46const str = (v: unknown): string => (typeof v === 'string' ? v : '')
47
48/** runtime_id through runtimeNames, else runtime_id, else runtime. */
49export const runtimeOf = (cfg: Config, payload: Readonly<Record<string, unknown>>): string => {
50 const id = str(payload['runtime_id'])
51 const name = str(payload['runtime'])
52 if (id) return runtimeLabel(cfg, id)
53 return name ? runtimeLabel(cfg, name) : 'unknown'
54}
55
56const END_STATE: Readonly<Record<string, DispatchState>> = {
57 DispatchCompleted: 'done',
58 DispatchFailed: 'failed',
59 DispatchCancelled: 'cancelled',
60 DispatchRejected: 'rejected',
61}
62
63const SUMMARY_KEYS = ['prompt_summary', 'summary', 'title', 'task_name', 'task', 'task_id'] as const
64
65export const summaryOf = (payload: Readonly<Record<string, unknown>>): string => {
66 for (const key of SUMMARY_KEYS) {
67 const v = str(payload[key]).replace(/\s+/g, ' ').trim()
68 if (v) return cut(v, 30)
69 }
70 return ''
71}
72
73/**
74 * Pairs events by payload.dispatch_id. Open ones whose last start or heartbeat is older than
75 * STALLED_AFTER_MS are stalled. Order: running (newest first), stalled (newest first), then the
76 * ENDED_LIMIT most recently ended.
77 */
78export const pairDispatches = (cfg: Config, events: readonly DispatchEvent[], now: number): DispatchRow[] => {
79 type Pair = { start?: DispatchEvent; end?: DispatchEvent; beat?: DispatchEvent }
80 const pairs = new Map<string, Pair>()
81 for (const ev of events) {
82 const id = str(ev.payload['dispatch_id'])
83 if (!id) continue
84 const pair = pairs.get(id) ?? {}
85 if (ev.type === 'DispatchStarted') pair.start = pair.start ?? ev
86 else if (ev.type === 'DispatchHeartbeat') pair.beat = pair.beat && pair.beat.at >= ev.at ? pair.beat : ev
87 else if (END_STATE[ev.type] !== undefined) pair.end = pair.end && pair.end.at >= ev.at ? pair.end : ev
88 pairs.set(id, pair)
89 }
90 const rows: DispatchRow[] = []
91 for (const [id, pair] of pairs) {
92 const source = pair.start ?? pair.end ?? pair.beat
93 if (source === undefined) continue
94 const seen = [pair.start?.at, pair.beat?.at].filter((t): t is number => t !== undefined)
95 const lastSeenAt = seen.length > 0 ? Math.max(...seen) : null
96 const open: DispatchState = lastSeenAt !== null && now - lastSeenAt <= STALLED_AFTER_MS ? 'running' : 'stalled'
97 rows.push({
98 runtime: runtimeOf(cfg, pair.start?.payload ?? source.payload),
99 id: id.slice(0, 8),
100 startedAt: pair.start?.at ?? null,
101 endedAt: pair.end?.at ?? null,
102 lastSeenAt,
103 state: pair.end ? (END_STATE[pair.end.type] ?? 'done') : open,
104 summary: summaryOf(pair.start?.payload ?? source.payload),
105 })
106 }
107 const byStart = (a: DispatchRow, b: DispatchRow): number =>
108 (b.startedAt ?? b.lastSeenAt ?? 0) - (a.startedAt ?? a.lastSeenAt ?? 0)
109 const running = rows.filter(r => r.state === 'running').sort(byStart)
110 const stalled = rows.filter(r => r.state === 'stalled').sort(byStart)
111 const ended = rows
112 .filter(r => r.state !== 'running' && r.state !== 'stalled')
113 .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
114 .slice(0, ENDED_LIMIT)
115 return [...running, ...stalled, ...ended]
116}
117
118/** What one run of the dispatch command gives: the paired rows, or the one-line reason it gave none. */
119export const dispatchFromRun = (
120 cfg: Config,
121 ran: { exitCode: number; stdout: string; stderr: string; isStdoutTruncated: boolean },
122 now: number,
123): DispatchView => {
124 if (ran.exitCode !== 0) return { rows: [], error: `exit code ${ran.exitCode}: ${oneLine(ran.stderr || ran.stdout)}`, fetchedAt: now }
125 if (ran.isStdoutTruncated) return { rows: [], error: 'output too large, cut off', fetchedAt: now }
126 const parsed = parseJsonl(ran.stdout)
127 if (parsed.error !== null) return { rows: [], error: parsed.error, fetchedAt: now }
128 return { rows: pairDispatches(cfg, parsed.events, now), error: null, fetchedAt: now }
129}
130
131/** First non-empty line of an error, kept short. */
132export const oneLine = (s: string): string =>
133 cut(
134 (s.split('\n').find(l => l.trim() !== '')?.trim() ?? 'unknown error').replace(/^(?:[\w-]+: )?\$\.[\w.]+: /, ''),
135 60,
136 )
137hooks/logic.ts 299 lines1// Pure helpers: channel tags, the reply ledger, action labels, reply-tool matching,
2// subagent results, the context line and terminal width. Nothing here touches $.
3import type { AgentRun, ContextMark, Pending } from '../types'
4import type { Config } from './config'
5
6export const MINUTE = 60_000
7export const SEEN_LIMIT = 500
8
9// ---------- channel messages ----------
10
11export type ChannelMessage = { server: string; chatId: string; key: string }
12
13const TAG = /<channel\s+([^>]*)>/g
14const ATTR = /([a-z_]+)="([^"]*)"/g
15
16const attrsOf = (raw: string): Record<string, string> => {
17 const out: Record<string, string> = {}
18 for (const m of raw.matchAll(ATTR)) {
19 if (m[1] !== undefined && m[2] !== undefined) out[m[1]] = m[2]
20 }
21 return out
22}
23
24/** FNV-1a, for messages that carry no id of their own. */
25export const hashText = (s: string): string => {
26 let h = 0x811c9dc5
27 for (let i = 0; i < s.length; i++) {
28 h ^= s.charCodeAt(i)
29 h = Math.imul(h, 0x01000193) >>> 0
30 }
31 return h.toString(16)
32}
33
34const usable = (v: string | undefined): string => (v === undefined || v === '...' ? '' : v)
35
36/**
37 * Every `<channel source=... chat_id=... message_id=...>` tag in a delivery's text, any server.
38 * A tag with no message id is keyed by a hash of its body. No tag at all: the whole delivery
39 * is one message of `fallbackServer` (when given).
40 */
41export const parseChannelMessages = (text: string, fallbackServer: string | null): ChannelMessage[] => {
42 const out: ChannelMessage[] = []
43 for (const m of text.matchAll(TAG)) {
44 const attrs = attrsOf(m[1] ?? '')
45 const server = usable(attrs['source']) || fallbackServer || ''
46 if (!server || attrs['source'] === '...') continue
47 const chatId = usable(attrs['chat_id'])
48 let messageId = usable(attrs['message_id'])
49 if (!messageId) {
50 const start = (m.index ?? 0) + m[0].length
51 const end = text.indexOf('</channel>', start)
52 messageId = `h${hashText(text.slice(start, end === -1 ? start + 400 : end))}`
53 }
54 out.push({ server, chatId, key: `${server}|${chatId}|${messageId}` })
55 }
56 if (out.length === 0 && fallbackServer) {
57 out.push({ server: fallbackServer, chatId: '', key: `${fallbackServer}||h${hashText(text)}` })
58 }
59 return out
60}
61
62export type AppendLike = {
63 agentId?: string
64 door: string
65 origin: object
66 message: { type: string; content: readonly unknown[] }
67}
68
69const originOf = (origin: object): { kind: unknown; server: unknown } => origin as { kind: unknown; server: unknown }
70
71/**
72 * A session.append row that may hold channel messages: the main conversation's user row,
73 * from a channel, or a delivery folded into a running turn that carries channel tags.
74 * Tool results and rows typed at the terminal are never read.
75 */
76export const appendChannelText = (e: AppendLike): { text: string; server: string | null } | null => {
77 if (e.agentId !== undefined || e.message.type !== 'user') return null
78 if (e.door !== 'delivery' && e.door !== 'prompt') return null
79 const origin = originOf(e.origin)
80 if (origin.kind === 'composer') return null
81 const text = e.message.content
82 .map(b => {
83 if (typeof b !== 'object' || b === null) return ''
84 const block = b as { type?: unknown; text?: unknown }
85 return block.type === 'text' && typeof block.text === 'string' ? block.text : ''
86 })
87 .join('\n')
88 if (origin.kind === 'channel' && typeof origin.server === 'string') return { text, server: origin.server }
89 return text.includes('<channel') ? { text, server: null } : null
90}
91
92// ---------- the reply ledger ----------
93
94export type Ledger = { pending: Pending[]; seen: string[] }
95
96/** New messages become pending; one seen before (even replied to) is never added again. */
97export const addPending = (ledger: Ledger, messages: readonly ChannelMessage[], now: number): Ledger => {
98 const seen = new Set(ledger.seen)
99 const added: Pending[] = []
100 for (const msg of messages) {
101 if (seen.has(msg.key)) continue
102 seen.add(msg.key)
103 added.push({ ...msg, at: now, isAlerted: false })
104 }
105 if (added.length === 0) return ledger
106 return { pending: [...ledger.pending, ...added], seen: [...seen].slice(-SEEN_LIMIT) }
107}
108
109/** A reply clears its server's messages that arrived by `before`: one chat's, or the whole server's. */
110export const clearByReply = (pending: readonly Pending[], target: ReplyTarget, before: number): Pending[] =>
111 pending.filter(
112 p => !(sameServer(p.server, target.server) && (target.chatId === null || p.chatId === target.chatId) && p.at <= before),
113 )
114
115/** Messages that just passed the alert line and were not announced yet, marked announced. */
116export const dueAlerts = (
117 pending: readonly Pending[],
118 now: number,
119 alertMs: number,
120): { pending: Pending[]; due: Pending[] } => {
121 const due: Pending[] = []
122 const next = pending.map(p => {
123 if (p.isAlerted || now - p.at < alertMs) return p
124 due.push(p)
125 return { ...p, isAlerted: true }
126 })
127 return { pending: due.length === 0 ? [...pending] : next, due }
128}
129
130/** The one toast a message gets when it has waited past the alert line. */
131export const waitingToast = (cfg: Config, p: Pending): string =>
132 `Inbox: a message has waited ${Math.round(cfg.waitingAlertMs / MINUTE)}m without a reply (${channelLabel(cfg, p.server, p.chatId)})`
133
134// ---------- servers, channel names and reply tools ----------
135
136/** How an MCP tool name spells a server name: anything but letters, digits, _ and - becomes _. */
137export const serverKey = (server: string): string => server.replace(/[^A-Za-z0-9_-]/g, '_')
138
139const sameServer = (a: string, b: string): boolean => serverKey(a) === serverKey(b)
140
141/** The server's short name: its last `:` part (plugin:discord:discord -> discord). */
142export const serverShort = (server: string): string => server.split(':').filter(Boolean).pop() ?? server
143
144export const channelLabel = (cfg: Config, server: string, chatId: string): string => {
145 const short = serverShort(server)
146 if (!chatId) return short
147 return `${short} #${cfg.channelNames.get(chatId) ?? chatId.slice(-4)}`
148}
149
150export type ReplyTarget = { server: string; chatId: string | null }
151
152/**
153 * Whether a tool call replies to a channel, and to which: an MCP tool ending in __reply or
154 * __voice_reply (or one listed in replyTools) clears its own server's messages, one chat's
155 * when the input names a chat_id.
156 */
157export const replyTarget = (cfg: Config, tool: string, input: Readonly<Record<string, unknown>>): ReplyTarget | null => {
158 if (!tool.startsWith('mcp__')) return null
159 const isListed = cfg.replyTools.size > 0 ? cfg.replyTools.has(tool) : /__(voice_)?reply$/.test(tool)
160 if (!isListed) return null
161 const server = tool.slice('mcp__'.length, tool.lastIndexOf('__'))
162 if (!server) return null
163 const chatId = typeof input['chat_id'] === 'string' && input['chat_id'] ? input['chat_id'] : null
164 return { server, chatId }
165}
166
167// ---------- action labels ----------
168
169const RUNTIME = /--runtime[\s=]+["']?([A-Za-z0-9_.-]+)/
170
171export const runtimeLabel = (cfg: Config, runtime: string): string => cfg.runtimeNames.get(runtime) ?? runtime
172
173/** The label a tool call shows under Now. */
174export const labelFor = (cfg: Config, tool: string, input: Readonly<Record<string, unknown>>): string => {
175 const description = typeof input['description'] === 'string' ? input['description'].trim() : ''
176 if (tool === 'Bash') {
177 const command = typeof input['command'] === 'string' ? input['command'] : ''
178 if (cfg.dispatchPattern !== null && cfg.dispatchPattern.test(command)) {
179 const runtime = RUNTIME.exec(command)?.[1]
180 return runtime ? `dispatch -> ${runtimeLabel(cfg, runtime)}` : 'dispatch'
181 }
182 return `Bash "${cut(description || command.trim() || 'command', 24)}"`
183 }
184 if (tool === 'Agent' || tool === 'Task') return `agent ${cut(description || 'task', 24)}`
185 if (tool.startsWith('mcp__')) return tool.split('__').pop() || tool
186 return tool
187}
188
189/** Inside a subagent only dispatches are tracked; the rest is covered by its Agent row. */
190export const isTrackedInSubagent = (label: string): boolean => label.startsWith('dispatch')
191
192// ---------- subagents ----------
193
194/** The Agent tool's result: async_launched (with agentId) runs in the background, remote_launched in the cloud. */
195export const launchOf = (result: unknown): { isBackground: boolean; agentId: string | null; status: string | null } => {
196 if (typeof result !== 'object' || result === null) return { isBackground: false, agentId: null, status: null }
197 const r = result as { status?: unknown; agentId?: unknown }
198 if (r.status === 'async_launched') {
199 return { isBackground: true, agentId: typeof r.agentId === 'string' ? r.agentId : null, status: 'running' }
200 }
201 if (r.status === 'remote_launched') return { isBackground: true, agentId: null, status: 'remote' }
202 return { isBackground: false, agentId: null, status: null }
203}
204
205/** Background agents take their status from $.agent.list(): by agentId, else the newest of the same description. */
206export const mergeAgentStatus = (
207 runs: readonly AgentRun[],
208 infos: readonly { id: string; description: string; status: string }[],
209): AgentRun[] =>
210 runs.map(run => {
211 if (!run.isBackground || run.status === 'remote') return run
212 const info =
213 infos.find(one => run.agentId !== null && one.id === run.agentId) ??
214 [...infos].reverse().find(one => one.description === run.description)
215 return info === undefined ? run : { ...run, status: info.status }
216 })
217
218// ---------- context ----------
219
220/** The next mark for a new reading, and whether this reading crossed the warning line. */
221export const contextTransition = (
222 prev: ContextMark,
223 percent: number | null,
224 warn: number,
225): { mark: ContextMark; shouldAlert: boolean } => {
226 if (percent === null) return { mark: { ...prev, percent: null }, shouldAlert: false }
227 if (percent < warn) return { mark: { percent, isAlerted: false }, shouldAlert: false }
228 return { mark: { percent, isAlerted: true }, shouldAlert: !prev.isAlerted }
229}
230
231// ---------- text and width ----------
232
233const isWide = (cp: number): boolean =>
234 (cp >= 0x1100 && cp <= 0x115f) ||
235 (cp >= 0x2e80 && cp <= 0x303e) ||
236 (cp >= 0x3041 && cp <= 0x33ff) ||
237 (cp >= 0x3400 && cp <= 0x4dbf) ||
238 (cp >= 0x4e00 && cp <= 0x9fff) ||
239 (cp >= 0xa000 && cp <= 0xa4cf) ||
240 (cp >= 0xac00 && cp <= 0xd7a3) ||
241 (cp >= 0xf900 && cp <= 0xfaff) ||
242 (cp >= 0xfe30 && cp <= 0xfe4f) ||
243 (cp >= 0xff00 && cp <= 0xff60) ||
244 (cp >= 0xffe0 && cp <= 0xffe6) ||
245 (cp >= 0x1f300 && cp <= 0x1faff) ||
246 (cp >= 0x20000 && cp <= 0x3fffd)
247
248/** Terminal cells: wide (CJK, full-width, emoji) characters take two. */
249export const displayWidth = (s: string): number => {
250 let w = 0
251 for (const ch of s) w += isWide(ch.codePointAt(0) ?? 0) ? 2 : 1
252 return w
253}
254
255/** Cut to `width` cells, ending in `~` when cut (ASCII, so every terminal font has it). */
256export const truncateWidth = (s: string, width: number): string => {
257 if (width <= 0) return ''
258 if (displayWidth(s) <= width) return s
259 let out = ''
260 let w = 0
261 for (const ch of s) {
262 const cw = isWide(ch.codePointAt(0) ?? 0) ? 2 : 1
263 if (w + cw > width - 1) break
264 out += ch
265 w += cw
266 }
267 return `${out.trimEnd()}~`
268}
269
270/** Cut to `n` characters, ending in `~` when cut. */
271export const cut = (s: string, n: number): string => {
272 const chars = [...s]
273 return chars.length <= n ? s : `${chars.slice(0, n).join('')}~`
274}
275
276/** 0m -> "<1m", 12m -> "12m", 125m -> "2h05m". */
277export const duration = (ms: number): string => {
278 const m = Math.floor(Math.max(0, ms) / MINUTE)
279 if (m < 1) return '<1m'
280 if (m < 60) return `${m}m`
281 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
282}
283
284/** HH:MM in the configured time zone (UTC when none, or when the zone is unknown). */
285export const clockTime = (ms: number | null, timeZone: string): string => {
286 if (ms === null) return '--:--'
287 try {
288 return new Intl.DateTimeFormat('en-GB', {
289 hour: '2-digit',
290 minute: '2-digit',
291 hour12: false,
292 timeZone: timeZone || 'UTC',
293 }).format(new Date(ms))
294 } catch {
295 const d = new Date(ms)
296 return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`
297 }
298}
299hooks/model.ts 173 lines1// One model, two views: buildModel computes every figure and threshold once; the band and the
2// pane (view.ts) only lay it out, so the two always agree.
3import type {
4 Action,
5 AgentRun,
6 ContextMark,
7 CustomView,
8 DispatchRow,
9 DispatchView,
10 Pending,
11 QuotaRow,
12 QuotaView,
13 RecentRun,
14 SessionInfo,
15} from '../types'
16import type { Config } from './config'
17import { channelLabel } from './logic'
18
19export type Level = 'normal' | 'warning' | 'error'
20
21/** The status words both views show; their symbol and color come from one table (view.ts statusMark). */
22export type Status = 'running' | 'stalled' | 'done' | 'failed' | 'cancelled' | 'rejected' | 'idle'
23
24export type InboxGroup = { label: string; count: number; waitedMs: number; level: Level }
25
26/** A quota row with its level (from the quota thresholds) and whether its reading is stale. */
27export type QuotaLine = QuotaRow & { level: Level; isStale: boolean; ageMs: number | null }
28
29export type QuotaModel = {
30 /** Whether there is a QUOTA card: a quotaCommand is set, or Claude reports its windows. */
31 isEnabled: boolean
32 rows: QuotaLine[]
33 /** The fresh row with the highest use; null when no fresh row has a reading. */
34 tightest: QuotaLine | null
35 error: string | null
36}
37
38export type Model = {
39 now: number
40 inbox: { total: number; groups: InboxGroup[]; level: Level }
41 lastReplyAgoMs: number | null
42 /** Tool calls in flight plus background subagents still running, oldest first. */
43 actions: { label: string; elapsedMs: number; level: Level }[]
44 context: { percent: number; level: Level; warn: number; critical: number } | null
45 subagents: { status: Status; startedAt: number; elapsedMs: number; description: string }[]
46 dispatches: {
47 isEnabled: boolean
48 error: string | null
49 fetchedAt: number | null
50 rows: (DispatchRow & { status: Status; ageMs: number })[]
51 }
52 custom: readonly CustomView[]
53 session: SessionInfo
54 quota: QuotaModel
55 /** Ended runs, newest first, with how long they took. */
56 recent: (RecentRun & { elapsedMs: number })[]
57}
58
59export type ModelInput = {
60 pending: readonly Pending[]
61 lastReplyAt: number | null
62 actions: readonly Action[]
63 context: ContextMark
64 agents: readonly AgentRun[]
65 dispatch: DispatchView
66 custom: readonly CustomView[]
67 session: SessionInfo
68 quota?: QuotaView
69 recent?: readonly RecentRun[]
70}
71
72const worst = (levels: readonly Level[]): Level =>
73 levels.includes('error') ? 'error' : levels.includes('warning') ? 'warning' : 'normal'
74
75const AGENT_STATUS: Readonly<Record<string, Status>> = {
76 pending: 'idle',
77 running: 'running',
78 waiting: 'idle',
79 idle: 'idle',
80 completed: 'done',
81 failed: 'failed',
82 killed: 'cancelled',
83 remote: 'running',
84}
85
86const agentStatus = (run: AgentRun): Status => {
87 if (run.status !== null) return AGENT_STATUS[run.status] ?? 'running'
88 return run.endedAt === null || run.isBackground ? 'running' : 'done'
89}
90
91const EMPTY_QUOTA: QuotaView = { claude: [], external: [], error: null, fetchedAt: null }
92
93/** Levels by the quota thresholds; a row past twice its source's polling period is stale. */
94export const quotaModel = (view: QuotaView, now: number, cfg: Config): QuotaModel => {
95 const rows: QuotaLine[] = [...view.claude, ...view.external].map(row => {
96 const ageMs = row.fetchedAt === null ? null : Math.max(0, now - row.fetchedAt)
97 const isStale = row.maxAgeMs !== null && (ageMs === null || ageMs > 2 * row.maxAgeMs)
98 const p = row.usedPercent
99 const level: Level = p === null ? 'normal' : p >= cfg.quotaCritical ? 'error' : p >= cfg.quotaWarn ? 'warning' : 'normal'
100 return { ...row, level, isStale, ageMs }
101 })
102 const fresh = rows.filter(r => r.usedPercent !== null && !r.isStale)
103 const tightest = fresh.reduce<QuotaLine | null>((top, r) => (top === null || (r.usedPercent ?? 0) > (top.usedPercent ?? 0) ? r : top), null)
104 return { isEnabled: cfg.quotaArgv.length > 0 || view.claude.length > 0, rows, tightest, error: view.error }
105}
106
107export const buildModel = (input: ModelInput, now: number, cfg: Config): Model => {
108 const groups = new Map<string, InboxGroup>()
109 for (const p of input.pending) {
110 const label = channelLabel(cfg, p.server, p.chatId)
111 const waitedMs = Math.max(0, now - p.at)
112 const level: Level = waitedMs >= cfg.waitingAlertMs ? 'warning' : 'normal'
113 const prev = groups.get(label)
114 groups.set(
115 label,
116 prev === undefined
117 ? { label, count: 1, waitedMs, level }
118 : { label, count: prev.count + 1, waitedMs: Math.max(prev.waitedMs, waitedMs), level: worst([prev.level, level]) },
119 )
120 }
121 const inboxGroups = [...groups.values()].sort((a, b) => b.waitedMs - a.waitedMs)
122
123 const background = input.agents
124 .filter(run => run.isBackground && run.endedAt !== null && run.status === 'running')
125 .map(run => ({ label: `agent ${run.description}`, startedAt: run.startedAt }))
126 const actions = [...input.actions, ...background]
127 .sort((a, b) => a.startedAt - b.startedAt)
128 .map(a => {
129 const elapsedMs = Math.max(0, now - a.startedAt)
130 return { label: a.label, elapsedMs, level: (elapsedMs >= cfg.longActionMs ? 'warning' : 'normal') as Level }
131 })
132
133 const percent = input.context.percent
134 const context =
135 percent === null
136 ? null
137 : {
138 percent: Math.round(percent),
139 level: (percent >= cfg.contextCritical ? 'error' : percent >= cfg.contextWarn ? 'warning' : 'normal') as Level,
140 warn: cfg.contextWarn,
141 critical: cfg.contextCritical,
142 }
143
144 const subagents = input.agents.slice(-10).map(run => {
145 const status = agentStatus(run)
146 const end = status === 'running' || status === 'idle' ? now : (run.endedAt ?? now)
147 return { status, startedAt: run.startedAt, elapsedMs: Math.max(0, end - run.startedAt), description: run.description }
148 })
149
150 return {
151 now,
152 inbox: { total: input.pending.length, groups: inboxGroups, level: worst(inboxGroups.map(g => g.level)) },
153 lastReplyAgoMs: input.lastReplyAt === null ? null : Math.max(0, now - input.lastReplyAt),
154 actions,
155 context,
156 subagents,
157 dispatches: {
158 isEnabled: cfg.dispatchArgv.length > 0,
159 error: input.dispatch.error,
160 fetchedAt: input.dispatch.fetchedAt,
161 rows: input.dispatch.rows.map(row => {
162 const start = row.startedAt ?? row.lastSeenAt ?? now
163 const end = row.state === 'running' || row.state === 'stalled' ? now : (row.endedAt ?? now)
164 return { ...row, status: row.state, ageMs: Math.max(0, end - start) }
165 }),
166 },
167 custom: input.custom,
168 session: input.session,
169 quota: quotaModel(input.quota ?? EMPTY_QUOTA, now, cfg),
170 recent: (input.recent ?? []).map(r => ({ ...r, elapsedMs: Math.max(0, r.endedAt - r.startedAt) })),
171 }
172}
173hooks/arrange.ts 62 lines1// Arranging cards, the pure part: which cards exist, how a move reorders them, which card an
2// arrange button belongs to, and reading saved values back from the store. Nothing here touches $.
3import type { Placement } from '../types'
4import type { Config } from './config'
5
6/** Every card id this configuration draws, in default pane order; QUOTA only when there is quota data. */
7export const cardIds = (cfg: Config, hasQuota = cfg.quotaArgv.length > 0): string[] => [
8 ...(hasQuota ? ['quota'] : []),
9 'inbox',
10 'running',
11 ...(cfg.dispatchArgv.length > 0 ? ['dispatches'] : []),
12 ...cfg.customCards.map(c => c.id),
13 'session',
14]
15
16/** The full order after moving `id` one place up (-1) or down (1) among the cards not hidden; null when it cannot move. */
17export const movedOrder = (full: readonly string[], hidden: readonly string[], id: string, by: -1 | 1): string[] | null => {
18 const visible = full.filter(one => !hidden.includes(one))
19 const at = visible.indexOf(id)
20 const other = visible[at + by]
21 if (at === -1 || other === undefined) return null
22 return full.map(one => (one === id ? other : one === other ? id : one))
23}
24
25/** The card an arrange button belongs to, from its key (`up:quota`); null for any other element. */
26export const cardOfKey = (key: string | undefined): string | null => /^(?:up|down|place|hide):(.+)$/s.exec(key ?? '')?.[1] ?? null
27
28/** A stored list of ids, or null when the store holds something else. */
29export const storedIds = (v: unknown): string[] | null => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : null)
30
31/** A stored placement map, keeping only pane and band entries; null when the store holds something else. */
32export const storedPlacement = (v: unknown): Record<string, Placement> | null =>
33 typeof v === 'object' && v !== null && !Array.isArray(v)
34 ? Object.fromEntries(Object.entries(v).filter((kv): kv is [string, Placement] => kv[1] === 'band' || kv[1] === 'pane'))
35 : null
36
37/** /monitor's argument hint: every subcommand and every card id this configuration has. */
38export const monitorHint = (cfg: Config): string =>
39 `[expand|collapse <card|all> · hide <card> · show <card|all> · rows <card> <1-30|default>] cards: ${cardIds(cfg).join(', ')}${
40 cfg.quotaArgv.length > 0 ? '' : ' (quota once Claude reports its limits)'
41 }`
42
43/** /monitor's words: the verb, the card name (it may hold spaces) and, for rows, the number (the last word). */
44export const parseMonitorArgs = (args: string): { verb: string; which: string; value: string | undefined } => {
45 const [verb = '', ...rest] = args.trim().split(/\s+/)
46 const value = verb === 'rows' && rest.length > 1 ? rest.pop() : undefined
47 return { verb, which: rest.join(' ') || 'all', value }
48}
49
50/** /monitor rows: the new per-card limits and the answer line, or why the value is refused. */
51export const rowsAfter = (
52 map: Readonly<Record<string, number>>,
53 target: readonly string[],
54 value: string | undefined,
55): { next: Record<string, number>; text: string } | { error: string } => {
56 const n = Number(value)
57 if (value !== 'default' && !(Number.isInteger(n) && n >= 1 && n <= 30)) return { error: 'Rows takes a whole number from 1 to 30, or default.' }
58 const merged = { ...map, ...Object.fromEntries(target.map(id => [id, n])) }
59 const next = Object.fromEntries(Object.entries(merged).filter(([id]) => !(value === 'default' && target.includes(id))))
60 return { next, text: value === 'default' ? `Rows back to default: ${target.join(', ')}.` : `Rows set to ${n}: ${target.join(', ')}.` }
61}
62hooks/custom.ts 67 lines1// Custom cards: the JSON a configured command prints, checked before it is drawn.
2import type { CustomMark, CustomView } from '../types'
3import { oneLine } from './dispatch'
4import { cut } from './logic'
5
6const MARKS: readonly CustomMark[] = ['running', 'stalled', 'done', 'failed', 'idle', 'waiting', 'warn']
7export const CUSTOM_ITEM_LIMIT = 30
8
9const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
10const text = (v: unknown, n: number): string => (typeof v === 'string' ? cut(v.replace(/\s+/g, ' ').trim(), n) : '')
11
12/** The card a command's stdout describes, or the one-line reason it does not describe one. */
13export const parseCustomOutput = (stdout: string): Pick<CustomView, 'summary' | 'badge' | 'items' | 'empty' | 'groups'> | string => {
14 let raw: unknown
15 try {
16 raw = JSON.parse(stdout)
17 } catch {
18 return 'output is not JSON'
19 }
20 if (!isRecord(raw)) return 'output is not a JSON object'
21 if (raw['items'] !== undefined && !Array.isArray(raw['items'])) return 'items is not a list'
22 const items = (Array.isArray(raw['items']) ? raw['items'] : []).filter(isRecord).slice(0, CUSTOM_ITEM_LIMIT)
23 const groups: NonNullable<CustomView['groups']> = Object.create(null)
24 if (isRecord(raw['groups'])) {
25 for (const [name, decoration] of Object.entries(raw['groups'])) {
26 const group = text(name, 40)
27 if (group === '' || !isRecord(decoration)) continue
28 const mark = MARKS.includes(decoration['mark'] as CustomMark) ? decoration['mark'] as CustomMark : undefined
29 const right = typeof decoration['right'] === 'string' ? [...decoration['right'].replace(/\s+/g, ' ').trim()].slice(0, 12).join('') : undefined
30 if (mark !== undefined || right !== undefined) groups[group] = { ...(mark === undefined ? {} : { mark }), ...(right === undefined ? {} : { right }) }
31 }
32 }
33 return {
34 ...(Object.keys(groups).length === 0 ? {} : { groups }),
35 summary: text(raw['summary'], 80),
36 badge: text(raw['badge'], 30),
37 empty: text(raw['empty'], 60) || 'nothing to show',
38 items: items.map(item => {
39 const group = text(item['group'], 40)
40 return {
41 mark: MARKS.includes(item['mark'] as CustomMark) ? (item['mark'] as CustomMark) : 'idle',
42 text: text(item['text'], 80),
43 right: text(item['right'], 12),
44 // Optional: items that name a group are listed under a heading per group.
45 ...(group === '' ? {} : { group }),
46 }
47 }),
48 }
49}
50
51/** A custom card after one run of its command: its JSON, or the one-line reason it gave none. */
52export const customFromRun = (base: CustomView, ran: { exitCode: number; stdout: string; stderr: string }): CustomView => {
53 if (ran.exitCode !== 0) return { ...base, error: `exit code ${ran.exitCode}: ${oneLine(ran.stderr || ran.stdout)}` }
54 const parsed = parseCustomOutput(ran.stdout)
55 return typeof parsed === 'string' ? { ...base, error: parsed } : { ...base, ...parsed }
56}
57
58export const emptyCustom = (card: { id: string; title: string }): CustomView => ({
59 ...card,
60 summary: '',
61 badge: '',
62 items: [],
63 empty: '',
64 error: null,
65 fetchedAt: null,
66})
67hooks/recent.ts 51 lines1// RUNNING's recent list, the pure part: what is worth keeping, reading it back from the store, and
2// which background subagents just ended. Nothing here touches $.
3import type { AgentRun, RecentRun } from '../types'
4
5/** A run shorter than this is not worth keeping under recent. */
6export const RECENT_MIN_MS = 10_000
7/** Ended runs kept in the store; the card lists recentRows of them. */
8export const RECENT_KEEP = 30
9/** The tools whose ended calls are kept: Bash commands and subagents. */
10export const RECORDED_TOOLS: ReadonlySet<string> = new Set(['Agent', 'Task', 'Bash'])
11
12export const isRecentRun = (v: unknown): v is RecentRun => {
13 if (typeof v !== 'object' || v === null) return false
14 const r = v as Record<string, unknown>
15 return (
16 typeof r['id'] === 'string' &&
17 typeof r['label'] === 'string' &&
18 typeof r['startedAt'] === 'number' &&
19 typeof r['endedAt'] === 'number' &&
20 (r['status'] === 'done' || r['status'] === 'failed' || r['status'] === 'cancelled')
21 )
22}
23
24/** The list with `run` first (replacing an older entry of the same id), capped at RECENT_KEEP. */
25export const addRecent = (list: readonly RecentRun[], run: RecentRun): RecentRun[] =>
26 [run, ...list.filter(one => one.id !== run.id)].slice(0, RECENT_KEEP)
27
28const END_STATUS: Readonly<Record<string, RecentRun['status']>> = { completed: 'done', failed: 'failed', killed: 'cancelled' }
29
30/**
31 * Background subagents end after their call does: those $.agent.list() now reports ended are marked
32 * recorded, and the ones that ran at least RECENT_MIN_MS are returned to join recent, ended at `now`.
33 */
34export const endedAgents = (
35 runs: readonly AgentRun[],
36 now: number,
37 label: (run: AgentRun) => string,
38): { runs: AgentRun[]; ended: RecentRun[] } => {
39 const ended: RecentRun[] = []
40 const next = runs.map(run => {
41 const status = run.status === null ? undefined : END_STATUS[run.status]
42 if (!run.isBackground || run.isRecorded === true || status === undefined) return run
43 if (now - run.startedAt >= RECENT_MIN_MS) ended.push({ id: run.id, label: label(run), startedAt: run.startedAt, endedAt: now, status })
44 return { ...run, isRecorded: true }
45 })
46 return { runs: next, ended }
47}
48
49/** The stored recent list, keeping only well-formed runs; null when the store holds something else. */
50export const storedRecent = (v: unknown): RecentRun[] | null => (Array.isArray(v) ? v.filter(isRecentRun).slice(0, RECENT_KEEP) : null)
51hooks/pane.ts 639 lines1// The /monitor pane: a header card and one round card per section, each with a collapsed
2// summary and expanded detail. Built from the same Model and symbol table as the band.
3import type { CustomMark, Placement } from '../types'
4import { clockTime, cut, displayWidth, duration, truncateWidth } from './logic'
5import type { Model, QuotaLine } from './model'
6import { gauge, resetText } from './quota'
7import {
8 buttonRun,
9 count,
10 fitLine,
11 levelMark,
12 levelTone,
13 percentText,
14 quotaFlag,
15 quotaTone,
16 replyText,
17 restartRun,
18 spread,
19 statusMark,
20} from './view'
21import type { Line, Run, Tone } from './view'
22
23/**
24 * `maxLines`: most detail lines shown when expanded; the rest fold into one `+N more` line.
25 * `isPinned`: every line is always shown (QUOTA): no `+N more`, and never shortened to fit the height.
26 */
27export type Card = { id: string; title: string; badge: Line; summary: Line; lines: Line[]; maxLines?: number; isPinned?: boolean }
28/**
29 * `cards`: the pane's cards in the person's order. `footer`: the hidden-cards line (when some are
30 * hidden), the updated line, and the hidden cards one by one when the person asked to see them.
31 * `band`: cards placed on the band, as the band shows them. `arrange`: in arrange mode, one title
32 * row per card with its move, place and hide buttons (the pane then shows those instead of cards).
33 */
34export type PaneDoc = {
35 head: Line[]
36 cards: Card[]
37 footer: Line[]
38 band: { id: string; title: string; summary: Line }[]
39 arrange: Line[] | null
40 /** Every card this configuration draws, hidden or placed anywhere (the status line's Subinfo reads one). */
41 all: Card[]
42}
43
44/**
45 * What the person set with /monitor and the arrange buttons: hidden cards, per-card item limits,
46 * the card order and placement, and the pane's mode. All but the first three are optional.
47 */
48export type PaneOptions = {
49 customCardRefresh?: ReadonlyMap<string, number>
50 customMaxItems: number
51 rows: Readonly<Record<string, number>>
52 hidden: readonly string[]
53 order?: readonly string[]
54 placement?: Readonly<Record<string, Placement>>
55 recentRows?: number
56 isArranging?: boolean
57 /** The card whose arrange buttons carry the u/d/b/h hotkeys. */
58 selected?: string | null
59 isHiddenRevealed?: boolean
60}
61
62/** The badge: a gray count, and a red `· N failed` when the card holds failed items. */
63export const badgeOf = (n: number, failed: number, tone: Tone = 'muted'): Line =>
64 n === 0 && failed === 0
65 ? []
66 : [
67 { text: String(n), tone: n === 0 ? 'muted' : tone, bold: tone !== 'muted' },
68 ...(failed > 0 ? [SEP, { text: `${failed} failed`, tone: 'critical' as Tone, bold: true }] : []),
69 ]
70
71/** Cells inside a card: the pane body less the round border (2) and padding (2). */
72export const cardInner = (bodyColumns: number): number => Math.max(16, bodyColumns - 4)
73
74const muted = (text: string): Line => [{ text, tone: 'muted' }]
75
76const DISPATCH_COLS = { runtime: 11, id: 8, start: 5, age: 5 } as const
77const RECENT_DEFAULT = 3
78/** Stalled dispatches silent longer than this fold into one line. */
79const STALE_FOLD_MS = 60 * 60_000
80
81/** Fits the 5-cell AGE column: 1h43m, then whole hours (10h), then days (2d). */
82export const ageText = (ms: number): string => {
83 const m = Math.floor(Math.max(0, ms) / 60_000)
84 if (m < 600) return duration(ms)
85 return m < 24 * 60 ? `${Math.floor(m / 60)}h` : `${Math.floor(m / (24 * 60))}d`
86}
87
88const padTo = (s: string, n: number): string => {
89 const t = truncateWidth(s, n)
90 return t + ' '.repeat(Math.max(0, n - displayWidth(t)))
91}
92
93const SEP: Run = { text: ' · ', tone: 'muted' }
94
95/** A custom item's mark: the status table, plus waiting (gray middle dot, like idle) and warn (yellow dot). */
96export const customMark = (mark: CustomMark): Run =>
97 mark === 'waiting' ? { text: '·', tone: 'muted' } : mark === 'warn' ? { text: '●', tone: 'warn' } : statusMark(mark)
98
99/** The header card: title, the Arrange (or Done) button and clock, the overview, and the restart warning when there is one. */
100/**
101 * Arrange mode's help under the header: how changes are kept, how to pick a card, and, where the
102 * buttons are shrunk to letters, what the letters mean.
103 */
104export const ARRANGE_HELP = {
105 intro: 'Arrange: move, place or hide cards. Changes are kept.',
106 saving: 'Each change is saved at once; Done only leaves.',
107 picking: 'Pick a card: Tab or arrow keys, or click. Keys u d b h.',
108 legend: '↑ ↓ move · B/P band or pane · H hide',
109} as const
110
111const headLines = (m: Model, inner: number, timeZone: string, isArranging: boolean, isCompact = false): Line[] => {
112 const title = spread(
113 [{ text: 'AGENT MONITOR', tone: 'accent', bold: true }],
114 [buttonRun('arrange', isArranging ? 'Done' : 'Arrange'), { text: ' ', tone: 'plain' }, { text: clockTime(m.now, timeZone), tone: 'muted' }],
115 inner,
116 )
117 if (isArranging) {
118 const help = [ARRANGE_HELP.intro, ARRANGE_HELP.saving, ARRANGE_HELP.picking, ...(isCompact ? [ARRANGE_HELP.legend] : [])]
119 return [title, ...help.map(text => fitLine(muted(text), inner))]
120 }
121 const runningAgents = m.subagents.filter(s => s.status === 'running').length
122 const overview: Line = [
123 { text: 'inbox ', tone: 'plain' },
124 count(m.inbox.total, levelTone(m.inbox.level)),
125 SEP,
126 { text: 'now ', tone: 'plain' },
127 count(m.actions.length, m.actions.some(a => a.level !== 'normal') ? 'warn' : 'ok'),
128 SEP,
129 { text: 'agents ', tone: 'plain' },
130 count(runningAgents, 'ok'),
131 SEP,
132 { text: replyText(m.lastReplyAgoMs), tone: 'muted' },
133 ]
134 const restart = restartRun(m)
135 return [
136 title,
137 fitLine(overview, inner),
138 ...(restart === null ? [] : [[restart]]),
139 ]
140}
141
142const timed = (mark: Run, label: string, right: Run, inner: number): Line =>
143 spread([mark, { text: ` ${label}`, tone: 'plain' }], [right], inner)
144
145const inboxCard = (m: Model, inner: number): Card => {
146 const [oldest] = m.inbox.groups
147 return {
148 id: 'inbox',
149 title: 'INBOX',
150 badge: badgeOf(m.inbox.total, 0),
151 summary:
152 oldest === undefined
153 ? muted('no messages waiting')
154 : fitLine(
155 [
156 { text: `${m.inbox.total} waiting, oldest ${oldest.label} `, tone: 'plain' },
157 { text: duration(oldest.waitedMs), tone: oldest.level === 'normal' ? 'plain' : levelTone(oldest.level) },
158 ],
159 inner,
160 ),
161 lines:
162 oldest === undefined
163 ? [muted('no messages waiting')]
164 : m.inbox.groups.map(g =>
165 timed(
166 levelMark(g.level, false),
167 `${g.label}${g.count > 1 ? ` x${g.count}` : ''}`,
168 { text: duration(g.waitedMs), tone: g.level === 'normal' ? 'plain' : levelTone(g.level) },
169 inner,
170 ),
171 ),
172 }
173}
174
175/** The `─── recent ───` rule the RUNNING and DISPATCHES cards both draw above ended rows. */
176const recentRule = (inner: number): Line => muted(`─── recent ${'─'.repeat(Math.max(0, inner - 11))}`)
177
178const runningCard = (m: Model, inner: number, timeZone: string, recentRows: number): Card => {
179 const [first] = m.actions
180 const recent = m.recent.slice(0, recentRows)
181 const active =
182 first === undefined
183 ? [muted('idle')]
184 : m.actions.map(a =>
185 timed(levelMark(a.level, false), a.label, { text: duration(a.elapsedMs), tone: a.level === 'normal' ? 'plain' : levelTone(a.level) }, inner),
186 )
187 const ended = recent.map(r =>
188 timed(statusMark(r.status), r.label, { text: `${duration(r.elapsedMs)} · ${clockTime(r.endedAt, timeZone)}`, tone: 'muted' }, inner),
189 )
190 return {
191 id: 'running',
192 title: 'RUNNING',
193 badge: [
194 ...badgeOf(m.actions.length, 0),
195 ...(recent.length === 0 ? [] : [...(m.actions.length > 0 ? [SEP] : []), { text: `${recent.length} recent`, tone: 'muted' as Tone }]),
196 ],
197 summary:
198 first === undefined
199 ? muted('idle')
200 : fitLine(
201 [
202 { text: `${m.actions.length} running, longest ${first.label} `, tone: 'plain' },
203 { text: duration(first.elapsedMs), tone: first.level === 'normal' ? 'plain' : levelTone(first.level) },
204 ],
205 inner,
206 ),
207 lines: [...active, ...(ended.length === 0 ? [] : [recentRule(inner), ...ended])],
208 }
209}
210
211/** One quota row: name, a 10-cell gauge, the percent and its flag, the reset time, and how old a stale reading is. */
212const quotaLine = (row: QuotaLine, inner: number, now: number, timeZone: string): Line => {
213 // Below 60 cells the name column and the gaps narrow, so a stale row's age still fits.
214 const isRoomy = inner >= 60
215 const gap = isRoomy ? ' ' : ' '
216 const name: Run = { text: `${padTo(row.name, isRoomy ? 14 : 12)} `, tone: row.isStale ? 'muted' : 'plain' }
217 const old: Run[] = row.isStale ? [{ text: `${gap}${row.ageMs === null ? 'age unknown' : `${duration(row.ageMs)} old`}`, tone: 'muted' }] : []
218 if (row.usedPercent === null) return fitLine([name, { text: 'no data', tone: 'muted' }, ...old], inner)
219 const tone = quotaTone(row)
220 const g = gauge(row.usedPercent)
221 const reset = resetText(row.resetsAt, now, timeZone)
222 return fitLine(
223 [
224 name,
225 { text: g.filled, tone },
226 { text: g.empty, tone: 'muted' },
227 { text: percentText(row).padStart(6), tone, bold: row.level !== 'normal' && !row.isStale },
228 { text: quotaFlag(row).padEnd(2), tone, bold: true },
229 ...(reset === '' ? [] : [{ text: `${gap}resets ${reset}`, tone: 'muted' as Tone }]),
230 ...old,
231 ],
232 inner,
233 )
234}
235
236const quotaCard = (m: Model, inner: number, timeZone: string): Card => {
237 const q = m.quota
238 const top = q.tightest
239 const lines: Line[] = [
240 ...(q.error === null ? [] : [fitLine([{ text: `could not read quota: ${q.error}`, tone: 'critical' }], inner)]),
241 ...q.rows.map(row => quotaLine(row, inner, m.now, timeZone)),
242 ]
243 if (lines.length === 0) lines.push(muted('no quota readings yet'))
244 const topRuns = (row: QuotaLine): Run[] => [{ text: `${percentText(row)}${quotaFlag(row)}`, tone: quotaTone(row), bold: row.level !== 'normal' }]
245 // The badge is context use, colored by its level with `!`/`!!` past the context lines; the tightest quota until a context reading comes.
246 const c = m.context
247 const ctxFlag = c === null ? '' : c.level === 'error' ? '!!' : c.level === 'warning' ? '!' : ''
248 const badge: Line =
249 c !== null
250 ? [{ text: 'ctx ', tone: 'muted' }, { text: `${c.percent}%${ctxFlag}`, tone: levelTone(c.level), bold: c.level !== 'normal' }]
251 : top === null
252 ? []
253 : [{ text: 'tightest ', tone: 'muted' }, ...topRuns(top)]
254 return {
255 id: 'quota',
256 title: 'QUOTA',
257 isPinned: true,
258 badge,
259 summary:
260 top === null
261 ? (lines[0] ?? [])
262 : fitLine([{ text: `tightest ${top.name} `, tone: 'plain' }, ...topRuns(top), ...(q.error === null ? [] : [SEP, { text: '1 source failed', tone: 'critical' as Tone }])], inner),
263 lines,
264 }
265}
266
267const dispatchCard = (m: Model, inner: number, timeZone: string, recent: number): Card => {
268 const d = m.dispatches
269 const c = DISPATCH_COLS
270 const taskWidth = Math.max(0, inner - 2 - c.runtime - c.id - c.start - c.age - 4)
271 const running = d.rows.filter(r => r.status === 'running')
272 const stalled = d.rows.filter(r => r.status === 'stalled')
273 const fresh = stalled.filter(r => r.lastSeenAt !== null && m.now - r.lastSeenAt <= STALE_FOLD_MS)
274 const stale = stalled.filter(r => !fresh.includes(r))
275 const ended = d.rows.filter(r => r.status !== 'running' && r.status !== 'stalled').slice(0, recent)
276 const row = (r: (typeof d.rows)[number]): Line => {
277 const isOpen = r.status === 'running' || r.status === 'stalled'
278 const tone: Tone = isOpen ? 'plain' : 'muted'
279 return fitLine(
280 [
281 statusMark(r.status),
282 { text: ` ${padTo(r.runtime, c.runtime)} ${padTo(r.id, c.id)} ${padTo(clockTime(r.startedAt, timeZone), c.start)} `, tone },
283 { text: padTo(ageText(r.ageMs), c.age), tone: r.status === 'stalled' ? 'warn' : tone },
284 { text: ` ${truncateWidth(r.summary || (r.status === 'stalled' ? 'no end event' : ''), taskWidth)}`.trimEnd(), tone: 'muted' },
285 ],
286 inner,
287 )
288 }
289 const lines: Line[] = []
290 let summary: Line
291 if (d.error !== null) {
292 lines.push(fitLine([{ text: `could not read dispatches: ${d.error}`, tone: 'critical' }], inner))
293 summary = lines[0] ?? []
294 } else if (d.fetchedAt === null) {
295 lines.push(muted('loading...'))
296 summary = muted('loading...')
297 } else {
298 if (d.rows.length === 0) lines.push(muted('none in the last 24h'))
299 if (running.length + fresh.length > 0) {
300 lines.push(muted(` ${padTo('RUNTIME', c.runtime)} ${padTo('ID', c.id)} ${padTo('START', c.start)} ${padTo('AGE', c.age)} TASK`))
301 }
302 lines.push(...running.map(row), ...fresh.map(row))
303 if (stale.length > 0) {
304 const since = Math.min(...stale.map(r => r.startedAt ?? r.lastSeenAt ?? m.now))
305 lines.push([statusMark('stalled'), { text: ` ${stale.length} stalled since ${clockTime(since, timeZone)}`, tone: 'muted' }])
306 }
307 if (ended.length > 0) {
308 lines.push(recentRule(inner))
309 lines.push(...ended.map(row))
310 }
311 const okCount = ended.filter(r => r.status === 'done').length
312 const badCount = ended.filter(r => r.status === 'failed' || r.status === 'rejected').length
313 summary =
314 ended.length === 0
315 ? muted('nothing ended in the last 24h')
316 : [
317 { text: 'recent ', tone: 'muted' },
318 { text: `✓ ${okCount}`, tone: 'muted' },
319 ...(badCount > 0 ? [{ text: ' ', tone: 'plain' as Tone }, { text: `✗ ${badCount}`, tone: 'critical' as Tone }] : []),
320 ]
321 }
322 const badge: Line = [
323 { text: `${running.length} running`, tone: running.length === 0 ? 'muted' : 'ok', bold: running.length > 0 },
324 ...(stalled.length > 0 ? [SEP, { text: `${stalled.length} stalled`, tone: 'warn' as Tone, bold: true }] : []),
325 ]
326 return { id: 'dispatches', title: 'DISPATCHES', badge, summary, lines }
327}
328
329const URGENT: readonly CustomMark[] = ['failed', 'warn', 'stalled']
330
331/**
332 * Orders items so the ones a capped card shows come first: failed, warn and stalled items always
333 * make the cut (up to `cap`), the rest of the cut goes to the first other items; each group keeps
334 * the command's own order.
335 */
336export const visibleFirst = <T extends { mark: CustomMark }>(items: readonly T[], cap: number): T[] => {
337 if (items.length <= cap) return [...items]
338 const urgent = items.filter(i => URGENT.includes(i.mark)).slice(0, cap)
339 const rest = items.filter(i => !urgent.includes(i))
340 const chosen = new Set<T>([...urgent, ...rest.slice(0, cap - urgent.length)])
341 return [...items.filter(i => chosen.has(i)), ...items.filter(i => !chosen.has(i))]
342}
343
344type CustomItem = Model['custom'][number]['items'][number]
345
346const itemLine = (i: CustomItem, inner: number): Line =>
347 timed(customMark(i.mark), i.text, { text: i.right, tone: i.mark === 'failed' ? 'critical' : 'muted' }, inner)
348
349/**
350 * A card whose items carry `group`: a gray heading per group with its item count on the right, the
351 * group's items under it in the command's order. The `maxItems` cap picks items as an ungrouped card
352 * does (failed, warn and stalled first) and the rest fold into one `+N more` row; headings do not
353 * count against the cap.
354 */
355/**
356 * How many items a line stands for when the height fit folds it into `+N more`: a group heading
357 * stands for none, a grouped card's own `+N more` for its N; any other line for one.
358 */
359const ITEM_COUNT = new WeakMap<Line, number>()
360const counted = (line: Line, n: number): Line => (ITEM_COUNT.set(line, n), line)
361
362const groupedLines = (items: readonly CustomItem[], inner: number, maxItems: number, decorations: Model['custom'][number]['groups']): Line[] => {
363 const chosen = new Set(visibleFirst(items, maxItems).slice(0, maxItems))
364 const groups = [...new Set(items.map(i => i.group ?? ''))]
365 const lines: Line[] = []
366 for (const group of groups) {
367 const all = items.filter(i => (i.group ?? '') === group)
368 const shownItems = all.filter(i => chosen.has(i))
369 if (shownItems.length === 0) continue
370 const decoration = group !== '' && decorations !== undefined && Object.hasOwn(decorations, group) ? decorations[group] : undefined
371 if (group !== '') {
372 const heading = decoration === undefined
373 ? spread([{ text: group, tone: 'muted' }], [{ text: String(all.length), tone: 'muted' }], inner)
374 : timed(decoration.mark === undefined ? { text: ' ', tone: 'plain' } : customMark(decoration.mark), group,
375 { text: decoration.right ?? String(all.length), tone: decoration.mark === 'failed' ? 'critical' : 'muted' }, inner)
376 lines.push(counted(heading, 0))
377 }
378 lines.push(...shownItems.map(i => decoration === undefined ? itemLine(i, inner) : [{ text: ' ', tone: 'plain' as Tone }, ...itemLine(i, inner - 2)]))
379 }
380 const rest = items.length - chosen.size
381 return rest > 0 ? [...lines, counted(moreLine(rest), rest)] : lines
382}
383
384const customCard = (m: Model, view: Model['custom'][number], inner: number, maxItems: number): Card => {
385 const loading = view.fetchedAt === null
386 const failed = view.items.filter(i => i.mark === 'failed').length
387 const waiting = view.items.some(i => i.mark === 'waiting')
388 const isGrouped = view.items.some(i => i.group !== undefined)
389 const lines: Line[] =
390 view.error !== null
391 ? [fitLine([{ text: `could not read: ${view.error}`, tone: 'critical' }], inner)]
392 : loading
393 ? [muted('loading...')]
394 : view.items.length === 0
395 ? [fitLine(muted(view.empty), inner)]
396 : isGrouped
397 ? groupedLines(view.items, inner, maxItems, view.groups)
398 : visibleFirst(view.items, maxItems).map(i => itemLine(i, inner))
399 return {
400 id: view.id,
401 title: view.title,
402 // The command's own badge text when it gives one (e.g. `14 open · 3 on you`), else the item count.
403 badge:
404 view.badge !== '' && view.error === null
405 ? [
406 { text: view.badge, tone: waiting || view.items.some(i => i.mark === 'warn') ? 'warn' : 'muted', bold: true },
407 ...(failed > 0 ? [SEP, { text: `${failed} failed`, tone: 'critical' as Tone, bold: true }] : []),
408 ]
409 : badgeOf(view.items.length, failed, waiting ? 'warn' : 'muted'),
410 summary: view.error !== null || loading || view.summary === ''
411 ? (isGrouped && view.error === null && !loading && view.groups !== undefined
412 ? (groupedLines(view.items, inner, maxItems, undefined)[0] ?? []) : (lines[0] ?? []))
413 : fitLine([{ text: view.summary, tone: 'plain' }], inner),
414 lines,
415 // A grouped card already applied the cap and drew its own +N more row.
416 ...(isGrouped && view.error === null && !loading ? {} : { maxLines: maxItems }),
417 }
418}
419
420const sessionCard = (m: Model, inner: number, timeZone: string): Card => {
421 const s = m.session
422 const up = s.startedAt === null ? '--' : duration(m.now - s.startedAt)
423 const woke = s.wakeAt === null ? 'never' : clockTime(s.wakeAt, timeZone)
424 return {
425 id: 'session',
426 title: 'SESSION',
427 badge: [],
428 summary: fitLine([{ text: `up ${up} · woke ${woke} · compacted ${s.compactCount}`, tone: 'plain' }], inner),
429 lines: [
430 fitLine([{ text: 'started ', tone: 'muted' }, { text: `${clockTime(s.startedAt, timeZone)} (up ${up})`, tone: 'plain' }], inner),
431 fitLine(
432 s.wakeAt === null
433 ? muted('no wake yet')
434 : [{ text: 'last wake ', tone: 'muted' }, { text: `${woke} ${cut(s.wakeText, 30)}`, tone: 'plain' }],
435 inner,
436 ),
437 fitLine(
438 [
439 { text: 'compacted ', tone: 'muted' },
440 { text: `${s.compactCount}${s.compactAt === null ? '' : `, last ${clockTime(s.compactAt, timeZone)}`}`, tone: 'plain' },
441 ],
442 inner,
443 ),
444 ],
445 }
446}
447
448/** Data older than this is called stale: two refreshes missed. */
449const STALE_DATA_MS = 150_000
450
451/** When the cards' data was last read (not when the pane was drawn), flagged once it is stale. */
452const footer = (m: Model, timeZone: string): Line => {
453 const reads = [m.dispatches.isEnabled ? m.dispatches.fetchedAt : null, ...m.custom.map(c => c.fetchedAt)].filter(
454 (t): t is number => t !== null,
455 )
456 const at = reads.length > 0 ? Math.max(...reads) : null
457 const isStale = at !== null && m.now - at > STALE_DATA_MS
458 return [
459 { text: `updated ${at === null ? clockTime(m.now, timeZone) : clockTime(at, timeZone)}`, tone: isStale ? 'warn' : 'muted' },
460 ...(isStale ? [{ text: ` (stale, ${duration(m.now - at)} old)`, tone: 'warn' as Tone }] : []),
461 { text: ' · refresh 60s', tone: 'muted' },
462 ]
463}
464
465/**
466 * The person's order over the cards this configuration draws: saved ids first, as saved; an id the
467 * saved order lacks goes right after the card that precedes it by default.
468 */
469export const arrangedOrder = (defaults: readonly string[], saved: readonly string[]): string[] => {
470 const out = saved.filter((id, i) => defaults.includes(id) && saved.indexOf(id) === i)
471 defaults.forEach((id, i) => {
472 if (out.includes(id)) return
473 const before = defaults.slice(0, i).reverse().find(prev => out.includes(prev))
474 out.splice(before === undefined ? 0 : out.indexOf(before) + 1, 0, id)
475 })
476 return out
477}
478
479/** Below this body width the arrange buttons shrink to `↑ ↓ B H`. */
480export const WIDE_ARRANGE_COLUMNS = 80
481
482const ARRANGE_KEYS = { up: 'u', down: 'd', place: 'b', hide: 'h' } as const
483
484/**
485 * A card's row in arrange mode: its title on the left (cut first), then up, down, place and hide.
486 * The first card has no up button and the last no down button: blank, so the columns line up.
487 * The selected card's buttons carry the u/d/b/h hotkeys.
488 */
489const arrangeRow = (
490 card: Card,
491 i: number,
492 n: number,
493 placement: Placement,
494 inner: number,
495 isWide: boolean,
496 isSelected: boolean,
497): Line => {
498 const hot = (k: keyof typeof ARRANGE_KEYS): string | undefined => (isSelected ? ARRANGE_KEYS[k] : undefined)
499 const place = placement === 'band' ? 'Pane' : 'Band'
500 const label = { up: '↑', down: '↓', place: isWide ? place : place.slice(0, 1), hide: isWide ? 'Hide' : 'H' }
501 const gap: Run = { text: isWide ? ' ' : ' ', tone: 'plain' }
502 const slot = (k: 'up' | 'down', isShown: boolean): Run => {
503 const run = buttonRun(`${k}:${card.id}`, label[k], hot(k))
504 return isShown ? run : { text: ' '.repeat(displayWidth(run.text)), tone: 'plain' }
505 }
506 const right: Run[] = [
507 slot('up', i > 0),
508 gap,
509 slot('down', i < n - 1),
510 gap,
511 buttonRun(`place:${card.id}`, label.place, hot('place')),
512 gap,
513 buttonRun(`hide:${card.id}`, label.hide, hot('hide')),
514 ]
515 return spread([{ text: ` ${card.title}`, tone: 'accent', bold: true }, ...(placement === 'band' ? [{ text: ' (band)', tone: 'muted' as Tone }] : [])], right, inner)
516}
517
518/**
519 * Every card, in the person's order (by default QUOTA when there is one, INBOX, RUNNING, DISPATCHES
520 * when configured, the custom cards, SESSION). Hidden cards are left out; cards placed on the band
521 * go to `band` instead of the pane, except in arrange mode, which lists them all.
522 */
523export const paneDoc = (m: Model, bodyColumns: number, timeZone: string, opts: PaneOptions): PaneDoc => {
524 const inner = cardInner(bodyColumns)
525 const width = Math.max(20, bodyColumns)
526 const footWidth = Math.max(18, bodyColumns - 2)
527 const isArranging = opts.isArranging === true
528 const limit = (card: Card): Card => (opts.rows[card.id] === undefined ? card : { ...card, maxLines: opts.rows[card.id] })
529 const byDefault = [
530 ...(m.quota.isEnabled ? [quotaCard(m, inner, timeZone)] : []),
531 limit(inboxCard(m, inner)),
532 runningCard(m, inner, timeZone, opts.rows['running'] ?? opts.recentRows ?? 5),
533 ...(m.dispatches.isEnabled ? [dispatchCard(m, inner, timeZone, opts.rows['dispatches'] ?? RECENT_DEFAULT)] : []),
534 ...m.custom.map(view => limit(customCard(m, view, inner, opts.customMaxItems))),
535 limit(sessionCard(m, inner, timeZone)),
536 ]
537 const order = arrangedOrder(
538 byDefault.map(card => card.id),
539 opts.order ?? [],
540 )
541 const all = order.map(id => byDefault.find(card => card.id === id)).filter((card): card is Card => card !== undefined)
542 const hidden = all.filter(card => opts.hidden.includes(card.id))
543 const visible = all.filter(card => !opts.hidden.includes(card.id))
544 const placeOf = (id: string): Placement => opts.placement?.[id] ?? 'pane'
545 const isWide = bodyColumns >= WIDE_ARRANGE_COLUMNS
546 const selected = visible.some(card => card.id === opts.selected) ? opts.selected : visible[0]?.id
547 const updated = footer(m, timeZone)
548 const intervals = m.custom.filter(c => opts.customCardRefresh?.has(c.id)).map(c => ` · ${c.id} ${opts.customCardRefresh?.get(c.id)}s`).join('')
549 const footerRoom = hidden.length === 0 ? width - 2 : footWidth - displayWidth(` · ${hidden.length} hidden`) - 5
550 if (intervals !== '' && updated.reduce((n, run) => n + displayWidth(run.text), 0) + displayWidth(intervals) <= footerRoom) {
551 updated.push({ text: intervals, tone: 'muted' })
552 }
553 return {
554 all,
555 head: headLines(m, inner, timeZone, isArranging, !isWide),
556 cards: isArranging ? [] : visible.filter(card => placeOf(card.id) === 'pane'),
557 band: visible.filter(card => placeOf(card.id) === 'band').map(card => ({ id: card.id, title: card.title, summary: card.summary })),
558 arrange: isArranging
559 ? visible.map((card, i) => arrangeRow(card, i, visible.length, placeOf(card.id), inner, isWide, card.id === selected))
560 : null,
561 footer: [
562 ...(hidden.length > 0 ? [fitLine(muted(`hidden: ${hidden.map(card => card.id).join(', ')}`), width)] : []),
563 hidden.length === 0
564 ? fitLine(updated, width)
565 : spread(
566 [...updated, { text: ` · ${hidden.length} hidden`, tone: 'muted' }],
567 [buttonRun('reveal-hidden', opts.isHiddenRevealed === true ? 'Close' : 'Show')],
568 footWidth,
569 ),
570 ...(opts.isHiddenRevealed === true
571 ? hidden.map(card => spread([{ text: ` ${card.title}`, tone: 'plain' }], [buttonRun(`show:${card.id}`, 'Show')], footWidth))
572 : []),
573 ],
574 }
575}
576
577/** A card's title row after its toggle: the title on the left, its badge on the right. */
578export const cardTitle = (card: Card, inner: number): Line =>
579 spread([{ text: ` ${card.title}`, tone: 'accent', bold: true }], card.badge, inner - 1)
580
581// ---------- fitting the pane's height ----------
582
583export type Placed = { card: Card; isOpen: boolean; body: Line[] }
584export type Layout = { head: Line[]; cards: Placed[]; showFooter: boolean }
585
586const moreLine = (hidden: number): Line => muted(`+${hidden} more`)
587
588/** The first `keep` rows of `lines`, the last of them a `+N more` line when some are hidden. */
589const shown = (lines: readonly Line[], keep: number): Line[] => {
590 if (keep >= lines.length) return [...lines]
591 const hidden = lines.slice(Math.max(0, keep - 1)).reduce((n, line) => n + (ITEM_COUNT.get(line) ?? 1), 0)
592 return [...lines.slice(0, Math.max(0, keep - 1)), moreLine(hidden)]
593}
594
595/** Rows a round card takes: its border (2), its title row and its body. */
596const CARD_FRAME = 3
597
598/**
599 * Lays the cards into `maxRows`: collapsed cards keep their one summary row; expanded ones show up
600 * to their maxLines, then the longest is shortened a row at a time (down to one `+N more` row) until
601 * everything fits, so every card's title row stays on screen. The footer goes last of all. A pinned
602 * card (QUOTA) is never shortened: when nothing else can give way, the pane runs past `maxRows`
603 * and scrolls rather than hide a quota row.
604 */
605export const layoutPane = (doc: PaneDoc, isOpen: (id: string) => boolean, maxRows: number): Layout => {
606 const open = doc.cards.map(card => isOpen(card.id))
607 const keep = doc.cards.map((card, i) =>
608 !open[i]
609 ? 1
610 : card.isPinned !== true && card.maxLines !== undefined && card.lines.length > card.maxLines
611 ? card.maxLines + 1
612 : card.lines.length,
613 )
614 let showFooter = true
615 const total = (): number =>
616 2 + doc.head.length + keep.reduce((sum, k) => sum + CARD_FRAME + Math.max(1, k), 0) + (showFooter ? doc.footer.length : 0)
617 while (total() > maxRows) {
618 let longest = -1
619 keep.forEach((k, i) => {
620 if (open[i] && doc.cards[i]?.isPinned !== true && k > 1 && (longest === -1 || k > (keep[longest] ?? 0))) longest = i
621 })
622 if (longest === -1) {
623 if (!showFooter) break
624 showFooter = false
625 continue
626 }
627 keep[longest] = (keep[longest] ?? 1) - 1
628 }
629 return {
630 head: doc.head,
631 cards: doc.cards.map((card, i) => ({
632 card,
633 isOpen: open[i] === true,
634 body: open[i] ? shown(card.lines, keep[i] ?? 1) : [card.summary],
635 })),
636 showFooter,
637 }
638}
639hooks/quota.ts 139 lines1// Quota rows: Claude's own rate-limit windows ($.session.usage) and the quotaCommand's JSON,
2// checked before they are drawn. Nothing here touches $.
3import type { QuotaRow, QuotaView } from '../types'
4import { oneLine } from './dispatch'
5import { cut } from './logic'
6
7/** The windows Claude Code reports, by kind; any other kind is shown as `Claude <kind>`. */
8const CLAUDE_NAMES: Readonly<Record<string, string>> = {
9 five_hour: 'Claude 5h',
10 seven_day: 'Claude week',
11}
12
13export type RateLimitLike = { kind: string; percentUsed: number; resetsAt?: string }
14
15/** Claude's rows from `$.session.usage().rateLimits` (or a session.measure), read at `now`. */
16export const claudeRows = (limits: readonly RateLimitLike[], now: number): QuotaRow[] =>
17 limits
18 .filter(l => typeof l.kind === 'string' && typeof l.percentUsed === 'number' && Number.isFinite(l.percentUsed))
19 .map(l => {
20 const reset = typeof l.resetsAt === 'string' ? Date.parse(l.resetsAt) : NaN
21 return {
22 name: CLAUDE_NAMES[l.kind] ?? cut(`Claude ${l.kind.replace(/_/g, ' ')}`, 20),
23 usedPercent: l.percentUsed,
24 resetsAt: Number.isNaN(reset) ? null : reset,
25 fetchedAt: now,
26 // Pushed by the engine whenever a window moves, so never called stale.
27 maxAgeMs: null,
28 }
29 })
30
31export const QUOTA_ROW_LIMIT = 12
32
33const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
34
35const time = (v: unknown): number | null | 'bad' => {
36 if (v === undefined || v === null || v === '') return null
37 if (typeof v !== 'string') return 'bad'
38 const t = Date.parse(v)
39 return Number.isNaN(t) ? 'bad' : t
40}
41
42/**
43 * The rows a quotaCommand prints: a JSON object `{ "rows": [...] }` or a bare list. Each row needs a
44 * `name`; `usedPercent` is a number or null (no reading); `resetsAt` and `fetchedAt` are ISO times or
45 * empty; `maxAgeSeconds` is optional. One bad row fails the read with its reason, rather than
46 * showing half of what the command meant.
47 */
48export const parseQuotaOutput = (stdout: string): QuotaRow[] | string => {
49 let raw: unknown
50 try {
51 raw = JSON.parse(stdout)
52 } catch {
53 return 'output is not JSON'
54 }
55 const list = Array.isArray(raw) ? raw : isRecord(raw) ? raw['rows'] : undefined
56 if (!Array.isArray(list)) return 'output has no rows list'
57 const rows: QuotaRow[] = []
58 for (const [i, row] of list.slice(0, QUOTA_ROW_LIMIT).entries()) {
59 if (!isRecord(row) || typeof row['name'] !== 'string' || row['name'].trim() === '') return `row ${i + 1} has no name`
60 const used = row['usedPercent']
61 if (used !== null && used !== undefined && (typeof used !== 'number' || !Number.isFinite(used))) {
62 return `row ${i + 1} usedPercent is not a number`
63 }
64 const resetsAt = time(row['resetsAt'])
65 const fetchedAt = time(row['fetchedAt'])
66 if (resetsAt === 'bad') return `row ${i + 1} resetsAt is not an ISO time`
67 if (fetchedAt === 'bad') return `row ${i + 1} fetchedAt is not an ISO time`
68 const maxAge = row['maxAgeSeconds']
69 rows.push({
70 name: cut(row['name'].replace(/\s+/g, ' ').trim(), 20),
71 usedPercent: typeof used === 'number' ? Math.max(0, used) : null,
72 resetsAt,
73 fetchedAt,
74 maxAgeMs: typeof maxAge === 'number' && Number.isFinite(maxAge) && maxAge > 0 ? maxAge * 1000 : null,
75 })
76 }
77 return rows
78}
79
80export type CommandRun = { exitCode: number; stdout: string; stderr: string; isStdoutTruncated: boolean }
81
82/** What one run of the quotaCommand gives: its rows, or the one-line reason it gave none. */
83export const quotaFromRun = (ran: CommandRun): Pick<QuotaView, 'external' | 'error'> | { error: string } => {
84 if (ran.exitCode !== 0) return { error: `exit code ${ran.exitCode}: ${oneLine(ran.stderr || ran.stdout)}` }
85 if (ran.isStdoutTruncated) return { error: 'output too large, cut off' }
86 const parsed = parseQuotaOutput(ran.stdout)
87 return typeof parsed === 'string' ? { error: parsed } : { external: parsed, error: null }
88}
89
90/**
91 * A 10-cell gauge: filled cells rounded to the nearest tenth, clamped to the gauge. Filled cells are
92 * small squares and the track a dim middle dot: both stay clear of the cell edges, so two gauges on
93 * neighbouring rows never merge into one block, and no shading pattern turns to noise in a terminal.
94 */
95export const GAUGE_CELLS = 10
96export const GAUGE = { filled: '■', empty: '·' } as const
97export const gauge = (percent: number): { filled: string; empty: string } => {
98 const n = Math.min(GAUGE_CELLS, Math.max(0, Math.round(percent / 10)))
99 return { filled: GAUGE.filled.repeat(n), empty: GAUGE.empty.repeat(GAUGE_CELLS - n) }
100}
101
102const DAY = 24 * 60 * 60_000
103const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
104
105const parts = (ms: number, timeZone: string): { hm: string; md: string; weekday: string } => {
106 try {
107 const f = new Intl.DateTimeFormat('en-US', {
108 hour: '2-digit',
109 minute: '2-digit',
110 hour12: false,
111 month: '2-digit',
112 day: '2-digit',
113 weekday: 'short',
114 timeZone: timeZone || 'UTC',
115 }).formatToParts(new Date(ms))
116 const get = (type: string): string => f.find(p => p.type === type)?.value ?? ''
117 const hour = get('hour') === '24' ? '00' : get('hour')
118 return { hm: `${hour}:${get('minute')}`, md: `${get('month')}/${get('day')}`, weekday: get('weekday') }
119 } catch {
120 const d = new Date(ms)
121 const pad = (n: number): string => String(n).padStart(2, '0')
122 return {
123 hm: `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`,
124 md: `${pad(d.getUTCMonth() + 1)}/${pad(d.getUTCDate())}`,
125 weekday: WEEKDAYS[d.getUTCDay()] ?? '',
126 }
127 }
128}
129
130/** When a window resets: `17:10` within a day, `Thu 16:00` within a week, `10/14` after that. */
131export const resetText = (resetsAt: number | null, now: number, timeZone: string): string => {
132 if (resetsAt === null) return ''
133 const p = parts(resetsAt, timeZone)
134 const ahead = resetsAt - now
135 if (ahead < DAY) return p.hm
136 if (ahead < 7 * DAY) return `${p.weekday} ${p.hm}`
137 return p.md
138}
139hooks/view.ts 329 lines1// The band above the prompt, and the pieces both views share (see pane.ts for the pane).
2// Neither computes a figure or a threshold: both read the Model, and both take their status
3// symbols and colors from statusMark/levelMark below, so the two always agree.
4import { cut, displayWidth, duration, truncateWidth } from './logic'
5import { resetText } from './quota'
6import type { Level, Model, QuotaLine, Status } from './model'
7
8/**
9 * Every non-ASCII character either view may draw. Terminal fonts (PuTTY's included) have these;
10 * anything else risks a box glyph. The round card borders are drawn by the surface, listed too.
11 * The gauge square and the move arrows (■↑↓) are in the Windows console's code page 437 as well.
12 * Shading blocks (░▒▓) and the full block (█) are left out: shading turns to noise in some
13 * terminals, and full blocks on neighbouring rows merge into one.
14 */
15export const SYMBOLS = '●✓✗◌–·│─┊╭╮╰╯■↑↓'
16
17/** The card toggles: ASCII, so every font has them. */
18export const TOGGLE = { expanded: '-', collapsed: '+' } as const
19
20/** Color roles; textProps maps them to named terminal colors only. */
21export type Tone = 'accent' | 'ok' | 'warn' | 'critical' | 'muted' | 'plain'
22/**
23 * One styled piece of a line. With `button`, the piece is drawn as a plain Button whose label is
24 * `button.label`; `text` is what the terminal shows for it (`h: Hide` when it has a hotkey), so
25 * widths stay right.
26 */
27export type Run = { text: string; tone: Tone; bold?: boolean; button?: { key: string; label: string; hotkey?: string } }
28
29/** A plain Button as a run: the terminal draws `hotkey: label`, or the label alone. */
30export const buttonRun = (key: string, label: string, hotkey?: string): Run => ({
31 text: hotkey === undefined ? label : `${hotkey}: ${label}`,
32 tone: 'plain',
33 button: { key, label, ...(hotkey === undefined ? {} : { hotkey }) },
34})
35export type Line = Run[]
36
37export const textProps = (run: Run): { color?: string; dimColor?: boolean; bold?: boolean } => {
38 const bold = run.bold === true ? { bold: true } : {}
39 switch (run.tone) {
40 case 'accent':
41 return { color: 'cyan', ...bold }
42 case 'ok':
43 return { color: 'green', ...bold }
44 case 'warn':
45 return { color: 'yellow', ...bold }
46 case 'critical':
47 return { color: 'red', ...bold }
48 case 'muted':
49 return { dimColor: true, ...bold }
50 default:
51 return bold
52 }
53}
54
55// ---------- the one symbol and color table ----------
56
57export const statusMark = (status: Status): Run => {
58 switch (status) {
59 case 'running':
60 return { text: '●', tone: 'ok' }
61 case 'stalled':
62 return { text: '◌', tone: 'warn' }
63 case 'done':
64 return { text: '✓', tone: 'muted' }
65 case 'failed':
66 case 'rejected':
67 return { text: '✗', tone: 'critical' }
68 case 'cancelled':
69 return { text: '–', tone: 'muted' }
70 default:
71 return { text: '·', tone: 'muted' }
72 }
73}
74
75export const levelTone = (level: Level): Tone => (level === 'error' ? 'critical' : level === 'warning' ? 'warn' : 'ok')
76
77/** A dot colored by severity; a gray middle dot when there is nothing to show. */
78export const levelMark = (level: Level, isEmpty: boolean): Run =>
79 isEmpty ? { text: '·', tone: 'muted' } : { text: '●', tone: levelTone(level) }
80
81/** A count colored by state: gray at zero. */
82export const count = (n: number, tone: Tone): Run => ({ text: String(n), tone: n === 0 ? 'muted' : tone, bold: n > 0 })
83
84export const lineWidth = (line: Line): number => line.reduce((w, r) => w + displayWidth(r.text), 0)
85
86/** Cuts a line to `width` cells, the last run that does not fit ending in `~`. */
87export const fitLine = (line: Line, width: number): Line => {
88 const out: Line = []
89 let left = width
90 for (const run of line) {
91 const w = displayWidth(run.text)
92 if (w <= left) {
93 out.push(run)
94 left -= w
95 continue
96 }
97 // A button is never cut: it is dropped whole when it does not fit.
98 if (left > 0 && run.button === undefined) out.push({ ...run, text: truncateWidth(run.text, left) })
99 break
100 }
101 return out
102}
103
104/** `left` then `right` pushed to the far edge; the left side gives way when they do not both fit. */
105export const spread = (left: Line, right: Line, width: number): Line => {
106 const rw = lineWidth(right)
107 const fittedLeft = fitLine(left, Math.max(0, width - rw - 1))
108 const gap = Math.max(1, width - lineWidth(fittedLeft) - rw)
109 return fitLine([...fittedLeft, { text: ' '.repeat(gap), tone: 'plain' }, ...right], width)
110}
111
112export const replyText = (ms: number | null): string =>
113 ms === null ? 'no reply yet' : ms < 60_000 ? 'replied just now' : `replied ${duration(ms)} ago`
114
115/** The restart warning shared by the band and the pane's overview; none under the warning line. */
116export const restartRun = (m: Model): Run | null =>
117 m.context === null || m.context.level === 'normal'
118 ? null
119 : { text: m.context.level === 'error' ? 'restart now' : 'restart soon', tone: levelTone(m.context.level), bold: true }
120
121// ---------- quota figures, shared by the band and the pane ----------
122
123/** The tone a quota figure takes: green below the warning line, yellow past it, red past the critical one, gray when stale. */
124export const quotaTone = (row: QuotaLine): Tone =>
125 row.isStale ? 'muted' : row.level === 'error' ? 'critical' : row.level === 'warning' ? 'warn' : 'ok'
126
127/** `!` past the warning line, `!!` past the critical one, so the state never rests on color alone. */
128export const quotaFlag = (row: QuotaLine): string => (row.isStale ? '' : row.level === 'error' ? '!!' : row.level === 'warning' ? '!' : '')
129
130export const percentText = (row: QuotaLine): string => (row.usedPercent === null ? '--' : `${Math.round(row.usedPercent)}%`)
131
132/** The band's short name: `Claude 5h` is `5h`, other sources keep their name. */
133export const shortQuotaName = (name: string): string => cut(name.replace(/^Claude /, ''), 12)
134
135// ---------- the band ----------
136
137export type Segment = {
138 key: 'inbox' | 'now' | 'context' | 'quota' | 'card' | 'reply'
139 runs: Line
140 /** A tighter form tried before the segment is dropped (the quota's `Q 61%`). */
141 compact?: Line
142}
143
144export const SEPARATOR: Run = { text: ' │ ', tone: 'muted' }
145
146/** What else the band shows: the quota (unless hidden), and the cards placed on the band. */
147export type BandExtras = {
148 isQuotaHidden?: boolean
149 isQuotaOnBand?: boolean
150 /** The pane's own render of a card placed on the band: its title and collapsed summary. */
151 cards?: readonly { title: string; summary: Line }[]
152 /** Clock times for the reset, in this zone. */
153 resetLabel?: (row: QuotaLine) => string
154}
155
156/** The band's extras from the person's arrangement: QUOTA hidden or on the band, the cards placed there, reset times. */
157export const bandExtras = (
158 m: Model,
159 opts: { hidden: readonly string[]; placement?: Readonly<Record<string, string>> },
160 band: readonly { id: string; title: string; summary: Line }[],
161 timeZone: string,
162): BandExtras => ({
163 isQuotaHidden: opts.hidden.includes('quota'),
164 isQuotaOnBand: opts.placement?.['quota'] === 'band',
165 cards: band.filter(card => card.id !== 'quota'),
166 resetLabel: row => resetText(row.resetsAt, m.now, timeZone),
167})
168
169const quotaSegment = (m: Model, extras: BandExtras): Segment | null => {
170 const top = m.quota.tightest
171 if (top === null || extras.isQuotaHidden === true) return null
172 const tone = quotaTone(top)
173 const flag = quotaFlag(top)
174 const reset = top.level === 'normal' ? '' : (extras.resetLabel?.(top) ?? '')
175 return {
176 key: 'quota',
177 runs: [
178 levelMark(top.level, false),
179 { text: ' QUOTA ', tone: 'plain', bold: true },
180 { text: `${shortQuotaName(top.name)} `, tone: 'plain' },
181 { text: percentText(top), tone, bold: top.level !== 'normal' },
182 ...(flag === '' ? [] : [{ text: ` ${flag}`, tone, bold: true }]),
183 ...(reset === '' ? [] : [{ text: ` resets ${reset}`, tone: 'muted' as Tone }]),
184 ],
185 compact: [{ text: 'Q ', tone: 'plain', bold: true }, { text: `${percentText(top)}${flag}`, tone, bold: top.level !== 'normal' }],
186 }
187}
188
189/** A card placed on the band: its title, then its collapsed summary cut to 40 cells. */
190const cardSegment = (card: { title: string; summary: Line }): Segment => ({
191 key: 'card',
192 runs: [{ text: `${cut(card.title, 16)} `, tone: 'plain', bold: true }, ...fitLine(card.summary, 40)],
193})
194
195/**
196 * The band's segments. With the pane closed: INBOX, NOW, the restart warning (only past the
197 * warning line), the tightest quota, cards placed on the band, and the last reply. With the pane
198 * open the pane says the rest, so the band keeps only what is past a threshold (and the cards the
199 * person put there); nothing left means no band at all (an empty list).
200 */
201export const bandSegments = (m: Model, isPaneOpen: boolean, extras: BandExtras = {}): Segment[] => {
202 const [oldest] = m.inbox.groups
203 const inbox: Segment = {
204 key: 'inbox',
205 runs: [
206 levelMark(m.inbox.level, m.inbox.total === 0),
207 { text: ' INBOX ', tone: 'plain', bold: true },
208 count(m.inbox.total, levelTone(m.inbox.level)),
209 ...(oldest === undefined
210 ? []
211 : [
212 { text: ` ${oldest.label} `, tone: 'plain' as Tone },
213 { text: duration(oldest.waitedMs), tone: levelTone(oldest.level) },
214 ...(m.inbox.groups.length > 1 ? [{ text: ` +${m.inbox.groups.length - 1}ch`, tone: 'muted' as Tone }] : []),
215 ]),
216 ],
217 }
218 const [first] = m.actions
219 const now: Segment = {
220 key: 'now',
221 runs:
222 first === undefined
223 ? [statusMark('idle'), { text: ' NOW ', tone: 'plain', bold: true }, { text: 'idle', tone: 'muted' }]
224 : [
225 levelMark(first.level, false),
226 { text: ' NOW ', tone: 'plain', bold: true },
227 { text: `${cut(first.label, 28)} `, tone: 'plain' },
228 { text: duration(first.elapsedMs), tone: levelTone(first.level) },
229 ...(m.actions.length > 1 ? [{ text: ` +${m.actions.length - 1}`, tone: 'muted' as Tone }] : []),
230 ],
231 }
232 const restart = restartRun(m)
233 const context: Segment | null =
234 restart === null ? null : { key: 'context', runs: [{ text: '●', tone: restart.tone }, { text: ' ', tone: 'plain' }, restart] }
235 const quota = quotaSegment(m, extras)
236 const cards = (extras.cards ?? []).map(cardSegment)
237 if (isPaneOpen) {
238 const isQuotaUrgent = quota !== null && (m.quota.tightest?.level !== 'normal' || extras.isQuotaOnBand === true)
239 return [
240 ...(m.inbox.level !== 'normal' ? [inbox] : []),
241 ...(first !== undefined && first.level !== 'normal' ? [now] : []),
242 ...(context === null ? [] : [context]),
243 ...(isQuotaUrgent && quota !== null ? [quota] : []),
244 ...cards,
245 ]
246 }
247 return [
248 inbox,
249 now,
250 ...(context === null ? [] : [context]),
251 ...(quota === null ? [] : [quota]),
252 ...cards,
253 { key: 'reply', runs: [{ text: replyText(m.lastReplyAgoMs), tone: 'muted' }] },
254 ]
255}
256
257/**
258 * The band as one line within `width`. When it does not fit: the quota shrinks to `Q 61%`; then the
259 * last reply goes, then cards placed on the band (last first), then the restart warning and NOW as
260 * before; the quota goes last of all. The first segment always stays.
261 */
262export const bandLine = (segments: readonly Segment[], width: number): Line => {
263 let kept = [...segments]
264 const join = (list: readonly Segment[]): Line => [
265 { text: ' ', tone: 'plain' },
266 ...list.flatMap((s, i) => (i > 0 ? [SEPARATOR, ...s.runs] : s.runs)),
267 ]
268 const fits = (): boolean => lineWidth(join(kept)) <= width
269 const drop = (key: Segment['key']): boolean => {
270 const at = kept.map(s => s.key).lastIndexOf(key)
271 if (at <= 0) return false
272 kept = kept.filter((_, i) => i !== at)
273 return true
274 }
275 if (!fits()) kept = kept.map(s => (s.compact === undefined ? s : { ...s, runs: s.compact }))
276 if (!fits()) drop('reply')
277 while (!fits() && drop('card')) {
278 // one card at a time, from the right
279 }
280 for (const key of ['context', 'now', 'quota'] as const) if (!fits()) drop(key)
281 while (kept.length > 1 && !fits()) kept.pop()
282 return fitLine(join(kept), width)
283}
284
285// ---------- the status line ($.ui.status: one line of plain text) ----------
286
287/** Permission modes as the status line names them; `default` is not shown, as Claude Code does not. */
288const MODE_NAMES: Readonly<Record<string, string>> = {
289 acceptEdits: 'accept edits',
290 bypassPermissions: 'bypass permissions',
291 plan: 'plan mode',
292 auto: 'auto mode',
293 dontAsk: "don't ask",
294}
295
296/** Between the State (left) and the Subinfo (right): the line has no width to pad to. */
297export const STATUS_DIVIDER = ' │ '
298
299const plainText = (line: Line): string => line.map(run => run.text).join('').trim()
300
301/**
302 * The status line: the State parts asked for, then, after STATUS_DIVIDER, the Subinfo card as
303 * `TITLE count · summary`. Plain text, so a past-threshold figure carries `!` or `!!`. A part with
304 * nothing known yet is left out, never guessed; undefined when nothing at all is known.
305 */
306export const statusLineText = (
307 m: Model,
308 parts: readonly ('model' | 'context' | 'quota' | 'mode')[],
309 info: { model: string | null; permissionMode: string | null },
310 sub: { title: string; badge: Line; summary: Line } | null,
311): string | undefined => {
312 const flag = (level: Level): string => (level === 'error' ? ' !!' : level === 'warning' ? ' !' : '')
313 const c = m.context
314 const top = m.quota.tightest
315 const mode = info.permissionMode === null ? '' : info.permissionMode === 'default' ? '' : (MODE_NAMES[info.permissionMode] ?? info.permissionMode)
316 const text: Record<(typeof parts)[number], string> = {
317 model: info.model === null ? '' : cut(info.model, 24),
318 context: c === null ? '' : `ctx ${Math.max(0, 100 - c.percent)}% left${flag(c.level)}`,
319 quota: top === null ? '' : `quota ${shortQuotaName(top.name)} ${percentText(top)}${flag(top.level)}`,
320 mode,
321 }
322 const left = parts.map(p => text[p]).filter(t => t !== '').join(' · ')
323 const count = plainText(sub?.badge.slice(0, 1) ?? [])
324 const summary = sub === null ? '' : plainText(sub.summary)
325 const right = sub === null ? '' : [`${sub.title}${count === '' ? '' : ` ${count}`}`, summary].filter(t => t !== '').join(' · ')
326 if (left === '' && right === '') return undefined
327 return left === '' ? right : right === '' ? left : `${left}${STATUS_DIVIDER}${right}`
328}
329types/index.d.ts 146 lines1/** One channel message waiting for a reply. */
2export type Pending = {
3 key: string
4 /** The channel server's name, as the delivery names it (e.g. plugin:discord:discord). */
5 server: string
6 /** The chat inside that server; '' when the message carries none. */
7 chatId: string
8 at: number
9 isAlerted: boolean
10}
11
12/** One tool call in flight. */
13export type Action = { id: string; tool: string; label: string; startedAt: number }
14
15/** The last context measurement, and whether this crossing of the warning line was announced. */
16export type ContextMark = { percent: number | null; isAlerted: boolean }
17
18export type DispatchState = 'running' | 'stalled' | 'done' | 'failed' | 'cancelled' | 'rejected'
19
20/** One external dispatch, paired from its events. */
21export type DispatchRow = {
22 runtime: string
23 id: string
24 startedAt: number | null
25 endedAt: number | null
26 /** The last start or heartbeat event. */
27 lastSeenAt: number | null
28 state: DispatchState
29 summary: string
30}
31
32export type DispatchView = { rows: DispatchRow[]; error: string | null; fetchedAt: number | null }
33
34/** One subagent of this session. */
35export type AgentRun = {
36 id: string
37 description: string
38 startedAt: number
39 endedAt: number | null
40 isBackground: boolean
41 /** A background agent's id (from an async_launched result), matched against $.agent.list(). */
42 agentId: string | null
43 status: string | null
44 /** Already added to the recent list (background agents end after their call does). */
45 isRecorded?: boolean
46}
47
48export type CustomMark = 'running' | 'stalled' | 'done' | 'failed' | 'idle' | 'waiting' | 'warn'
49
50/** One custom card's last read: the command's JSON, or why it could not be read. */
51export type CustomView = {
52 id: string
53 title: string
54 summary: string
55 /** Optional text for the title's right side in place of the item count ('' when not given). */
56 badge: string
57 /** `group`: optional heading the item is listed under; items without one are not grouped. */
58 items: { mark: CustomMark; text: string; right: string; group?: string }[]
59 groups?: Record<string, { mark?: CustomMark; right?: string }>
60 empty: string
61 error: string | null
62 fetchedAt: number | null
63}
64
65/** One rate-limit window: Claude's own, or a row of the quotaCommand's JSON. */
66export type QuotaRow = {
67 name: string
68 /** 0 to 100 (more past an exceeded limit); null when the source has no reading. */
69 usedPercent: number | null
70 resetsAt: number | null
71 /** When the source read the figure; null when unknown. */
72 fetchedAt: number | null
73 /** The source's own polling period; the row is called stale past twice this. Null: never stale. */
74 maxAgeMs: number | null
75}
76
77/** Claude's windows ($.session.usage) and the quotaCommand's rows, kept apart so one failing never hides the other. */
78export type QuotaView = {
79 claude: QuotaRow[]
80 external: QuotaRow[]
81 /** Why the quotaCommand could not be read; null when it was (or is not set). */
82 error: string | null
83 fetchedAt: number | null
84}
85
86/** A tool call or subagent that ended, kept for the RUNNING card's recent list. */
87export type RecentRun = {
88 id: string
89 label: string
90 startedAt: number
91 endedAt: number
92 status: 'done' | 'failed' | 'cancelled'
93}
94
95/** Where a card is shown: in the pane, as a segment of the band. Hidden cards are listed in `hidden`. */
96export type Placement = 'pane' | 'band'
97
98/** The status line's facts not in the model: the main model's name, and the permission mode once a hook event carried it. */
99export type StatusInfo = { model: string | null; permissionMode: string | null }
100
101/** What the SESSION card shows. */
102export type SessionInfo = {
103 startedAt: number | null
104 wakeAt: number | null
105 wakeText: string
106 compactCount: number
107 compactAt: number | null
108}
109
110declare module 'claude-code' {
111 interface PluginState {
112 'agent-monitor': {
113 pending: Pending[]
114 seen: string[]
115 lastReplyAt: number | null
116 actions: Action[]
117 tick: number
118 context: ContextMark
119 dispatch: DispatchView
120 agents: AgentRun[]
121 custom: CustomView[]
122 session: SessionInfo
123 /** Card id -> expanded; mirrored to $.store so it survives sessions. */
124 expanded: Record<string, boolean>
125 /** Card ids hidden with /monitor hide; mirrored to $.store. */
126 hidden: string[]
127 /** Card id -> most items listed when expanded (/monitor rows); mirrored to $.store. */
128 rows: Record<string, number>
129 quota: QuotaView
130 /** Ended tool calls and subagents, newest first; mirrored to $.store so a reload keeps them. */
131 recent: RecentRun[]
132 /** Card ids in the order the person arranged them; mirrored to $.store. */
133 order: string[]
134 /** Card id -> pane or band; mirrored to $.store. */
135 placement: Record<string, Placement>
136 /** Whether the pane is in arrange mode (session only). */
137 arranging: boolean
138 /** The card whose arrange buttons take the u/d/b/h keys: the one the focus ring is on. */
139 selected: string | null
140 /** Whether the footer lists the hidden cards, each with its own Show button. */
141 revealHidden: boolean
142 status: StatusInfo
143 }
144 }
145}
146