SLOPSHOPPER

agent-monitor

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…

newpanebandguardcommandtoast
★ 2v0.5.0MITupdated 2026-10-07NeriakTo/claude-code-agent-monitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-monitor
│ ┃ Agent monitor ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ AGENT MONITOR Arra ⏺ Read(src/auth.ts) │ ┃ │ inbox 0 · now 0 · agents 0 · no reply yet ⎿ Read 6 lines │ ┃ ╰─────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ │ - QUOTA ⏺ Bash(bun test) │ ┃ │ Claude 5h ■■■······· 31% ⎿ 3 pass, 1 fail │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ - INBOX │ ┃ │ no messages waiting ✻ Worked for 42s · done 4:20 PM │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── › /monitor │ ┃ │ - RUNNING ⎿ agent-monitor: Agent monitor opened. │ ┃ │ idle │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── │ ┃ │ + SESSION │ ┃ │ up 30m · woke never · compacted 0 │ ┃ ╰─────────────────────────────────────────── │ ┃ updated 08:53 · refresh 60s │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent monitor
╭──────────────────────────────────────────────────────────╮ │ AGENT MONITOR Arrange 08:53 │ │ inbox 0 · now 0 · agents 0 · no reply yet │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ - QUOTA ctx 49% │ │ Claude 5h ■■■······· 31% │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ - INBOX │ │ no messages waiting │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ - RUNNING │ │ idle │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ + SESSION │ │ up 30m · woke never · compacted 0 │ ╰──────────────────────────────────────────────────────────╯ updated 08:53 · refresh 60s
README

agent-monitor

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.

At a glance

The band

The 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
SegmentShows
INBOXChannel messages not answered yet, the oldest one's channel and wait time, and +Nch for other channels that are waiting.
NOWThe oldest tool call or background subagent still running, how long it has run, and +N for the others.
restart soon / restart nowOnly when context usage passes contextWarnPercent / contextCriticalPercent.
QUOTAThe 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 cardsA card you set to Band in arrange mode: its title and its one-line summary.
last replyno 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.

The pane

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

Requirements

  • Claude Code with function-hook plugins (mods). Tested with Claude Code 2.1.292; no older minimum version has been verified.
  • The function-hooks API is marked early access by Claude Code and may change between releases.
  • Nothing else. The mod has no dependencies and runs inside Claude Code's own hooks environment.

Install

Clone the repository anywhere, then pick one of these:

  1. One session: start Claude Code with the folder as a plugin directory.
   claude --plugin-dir /path/to/agent-monitor
  1. Every session: name the folder in 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" } }
  1. Skills folder: place the folder at ~/.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.

Configuration

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.

OptionDefaultDescription
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.
waitingAlertMinutes5A message waiting this long without a reply turns the inbox to the warning color and shows one toast. Clamped to 0 to 1440.
longActionMinutes10A tool call running this long is shown in the warning color. Clamped to 0 to 1440.
contextWarnPercent70Context usage at or above this shows restart soon and one toast each time it is crossed. Clamped to 1 to 100.
contextCriticalPercent85Context 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.
openOnStartfalseOpen 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.
customCardMaxItems5Most items an expanded custom card lists; the rest fold into one +N more line. Clamped to 1 to 100.
paneMaxRows44Rows 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.
quotaWarnPercent70A quota window at or above this turns yellow and gets !. Clamped to 1 to 100.
quotaCriticalPercent90A quota window at or above this turns red and gets !!; the band adds its reset time. Never below quotaWarnPercent.
recentRows5How many ended runs RUNNING lists under recent. Clamped to 0 to 30.
statusLinefalsePin 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"
      }
    }
  }
}

The cards

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.

CardIdDefault
headernonealways shown, cannot be hidden or moved
QUOTAquotaexpanded; only when Claude reports its rate limits or quotaCommand is set
INBOXinboxexpanded
RUNNINGrunningexpanded
DISPATCHESdispatchesexpanded; only when dispatchCommand is set
custom cardsthe title in lower case, other characters as dashes (WAITING ON YOU is waiting-on-you)expanded unless listed in collapsedCards
SESSIONsessioncollapsed

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.

Status marks

Both views take their marks and colors from one table, so they always agree.

MarkMeaningColor
●running, or a severity dot for waiting messages and actionsgreen; yellow or red past a threshold
◌stalled: no end event and no start or heartbeat for 3 minutesyellow
✓donedim
✗failed or rejectedred
–cancelleddim
·idle, waiting, or nothing to showdim
! / !!a quota past its warning / critical lineyellow / red
■·a quota gauge: used / leftused green, yellow or red past a threshold; track dim; all dim when stale
↑ ↓move a card up or down (arrange mode)

The status line

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
  • State, on the left, the parts 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.
  • Subinfo, after │, 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.
  • Why │ 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.
  • It is plain text: no colors, so every warning carries ! 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.
  • It updates when context or quota moves (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.
  • It is off by default; turning it on changes nothing else (the band and the pane stay as they are).

Arranging cards

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
ButtonEffect
↑ / ↓Move the card one place up or down among the cards not hidden. The first card has no ↑ and the last no ↓.
Band / PaneShow 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.
HideTake 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

Source 12 files
hooks/register.tsx 834 lines
1// 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}
834
hooks/config.ts 212 lines
1// 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}
212
hooks/dispatch.ts 137 lines
1// 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  )
137
hooks/logic.ts 299 lines
1// 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}
299
hooks/model.ts 173 lines
1// 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}
173
hooks/arrange.ts 62 lines
1// 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}
62
hooks/custom.ts 67 lines
1// 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})
67
hooks/recent.ts 51 lines
1// 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)
51
hooks/pane.ts 639 lines
1// 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}
639
hooks/quota.ts 139 lines
1// 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}
139
hooks/view.ts 329 lines
1// 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}
329
types/index.d.ts 146 lines
1/** 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