Session cockpit: tmux state, policy, auth radar, PR band, flow/graph/side/outputs panes

A Claude Code mod for a tmux-centred setup: session state in tmux, toggled cockpit panes, a credential radar, and session policy that only a person can change.
Tested with Claude Code 2.1.296 (mods need ≥ 2.1.287; prompt.autocomplete needs ≥ 2.1.290).
| Area | What you get | Replaces | ||||
|---|---|---|---|---|---|---|
| tmux state | @cc_state / @cc_attention set from the events themselves: working on turn start, ❗action the moment a permission prompt is decided ask, ❓ask the moment a question dialog draws, working again once it is answered, then Jev's ask/action/none at turn end, 💤 after 30 idle minutes. Adds @cc_ctx (context %), @cc_agents (running subagents + background tasks), @cc_policy (anything unlocked), @cc_auth (expired/soon). Same contract twm, brd and tmux_neighbors.py already read. | tmux_window_status_{start,stop}.py | ||||
| /flow | The session as prompts and conclusions only — no tool calls. Filters: all / conclusions / decisions+findings; progress chatter folds. Select a line → ask about it in /side, or copy. Back-fills from the transcript when opened mid-session. | — | ||||
| /graph | Requests → subagents (nested by parent), background Bash, Monitors, as a tree or a timeline; tokens, tool counts, duration, prompt and result per node; Mermaid export. | — | ||||
| /side | Questions about the conversation that never enter it, answered by $.model.fork (the session's own transcript and prompt cache, no tools). Send to main / fill the prompt / copy / redo with a subagent. /side <question> asks without opening anything. | built-in /btw (ephemeral) | ||||
| /outputs | PRs, artifacts, scratch files, Jira tickets, links, background tasks the session produced, with preview (Markdown, code, PNG in the terminal, log tail, an archive's listing or first lines), open (tmux-chrome), folder (r: Finder on it, the file selected), copy, put in context, keep/delete scratch. Files an answer hands over (a path it names, or a name under a folder it names, that exists) are starred ★ and listed first under Files, wherever they live. Scope: this session / this repo / today across sessions. Exported to ~/.claude/outputs/<date>/<sid>.json at turn end and session end. | re-parsing transcripts (ccx refs, daily check-in) | ||||
| /policy | Capabilities as rows you flip, mode presets, scoped grants, 15-minute timed grants. /policy allow merge owner/repo 15m without opening anything. Footer shows the policy (merge🔒, merge(1), prod:ro). | /session-policy skill turns, session_policy.py inject | ||||
| self-grant block | Claude's Bash running `session_policy.py allow | block | inherit | mode | clear or a *_unlock.py is refused (fail-closed .catch). Only the person's own /policy (origin composer`) or a pane press changes policy. | — (was a hole: the guard's deny text told Claude the command that lifts it) |
| band | Above the prompt, only when there is something: expired/soon AWS SSO / gh / Coder / ArgoCD / daemon (press to log in), this session's PRs (checks, approvals, unresolved threads, conflict; follow-up / review-request buttons), neighbors in the same repo, and a fork offer when a prompt looks like a separate line of work. | tmux_neighbors.py context injection, flow_fork_hint.py AskUserQuestion | ||||
| narwhal | What the narwhal runs this session started need, in Now › Needs you: a question with its options as buttons (the recommended one ★) and a field for any other answer, the context or what its labels mean, a worker judged stuck, a run that finished or failed (press to bring its result into the conversation). Every item opens narwhal monitor --run beside the pane. The Work tab lists the runs this session started (state, progress, each task with its model and first line of outcome; press to open the monitor), and Outputs gets what they produced — PRs (followed by the PR poller like the session's own), files, and each run's answer. Polled every 20 s from the daemon's /control/attention and /control/runs (?session=<id>); a new question also raises @cc_attention to ask. Needs narwhal ≥ #91. | answering only in narwhal monitor | ||||
| auth pre-check | An aws --profile … / kubectl --context … call while SSO is expired stops before it fails: log in and continue / run anyway / cancel. | failing, then a turn to explain | ||||
| gh account | A gh call against a repo whose owner org.json maps to a login runs with that login's token (GH_TOKEN from gh auth token --user), without gh auth switch, so other sessions keep their account. | gh auth switch (170× in 30 days) | ||||
| PR polling | One GraphQL query per account every 90 s for every PR any session touched, cached in $.store for all sessions; toast on newly failing checks or merge. | per-PR gh pr view / gh pr checks polling | ||||
| autocomplete | kubectl contexts and AWS profiles labelled with their stack (org.json), Jira keys seen, open PRs (#91…), tmux windows (@w:name). | — | ||||
| policy in the system prompt | Policy reaches Claude once via prompt.compose (session scope), not as context on every prompt. | session_policy.py inject |
Judgment that already existed in ~/.claude/hooks is reused through bin/cc-bridge.py (Jev attention, fork judgment, neighbors), not rewritten.
The right side is one pane, the cockpit, with five tabs. Each tab groups the areas that answer the same question:
cockpit ❓ waiting ctx ▰▱▱▱ 9% 00:15
1: Now 2: Chat 4 3: Work ⏳1 4: Outputs 3 5: Policy ✗1
Now Needs you (only when actionable) · In progress (current request + answer,
running work, latest Monitor events) · This session (earlier requests,
outputs)
Chat flow of requests and conclusions, side questions under it
Work subagents and background tasks, Monitors (Watch) under them
Outputs PRs, artifacts, files, tickets, links
Policy session policy, credential radar under it
Web the TWeb pane beside this one: its tabs and controls (/web <url>);
with browserEmbed on, the page drawn in the cockpit (bin/cc-browser)
Usage ccproxy: last request's account/model/effort, accounts with quota
windows and resets, models, output/request trend (h 1h · w 6h)
Digits 1-7 switch tabs. Esc hands the keyboard back to the prompt and leaves the cockpit open. All commands are immediate and open the cockpit at their tab (/side → Chat, /watch → Work); the same command again closes it, as does ctrl+x x.
Every interactive session opens the cockpit at start, docked right of a fullscreen transcript, without taking the keyboard (cockpitOnStart, on by default; turn it off in /plugin → cc-mods → configure).
One chord flips the keyboard between the prompt and the cockpit when it is bound both ways in ~/.claude/keybindings.json (alt+tab here, alt+c kept as the same; Claude Code matches alt and meta as one modifier, and the terminal must send Option as Alt — Ghostty macos-option-as-alt = true). Tab is not touched by a Korean input source, so alt+tab needs no physical-key mapping. Claude Code warns that alt+tab "may not work" (a Windows/Linux window switcher); on macOS Option+Tab reaches it. From the prompt it borrows agents:setGroup (a Chat binding to another context's action never dispatches), which the cockpit's hidden Button carries:
{ "context": "Global", "bindings": { "alt+tab": "agents:setGroup", "alt+c": "agents:setGroup" } },
{ "context": "Pane", "bindings": { "alt+tab": "agents:setGroup", "alt+c": "abovePrompt:leave", "alt+w": "agents:switchView" } },
{ "context": "PaneField", "bindings": { "alt+tab": "agents:setGroup", "alt+w": "agents:switchView" } }
Bind abovePrompt:leave in AbovePrompt, AbovePromptInput, AbovePromptSelect and PaneField too, so it works on any element.
Esc in a text field leaves the field, not the cockpit: by default it is abovePrompt:leave there, which hands the keyboard back to the prompt. Bound to abovePrompt:previous, the ring moves to the element before the field and the cockpit keeps the keys (alt+c still leaves). In the palette's query field the same Esc closes the palette.
{ "context": "PaneField", "bindings": { "escape": "tabs:next" } },
{ "context": "AbovePromptInput", "bindings": { "escape": "abovePrompt:previous" } },
{ "context": "AbovePromptSelect", "bindings": { "escape": "abovePrompt:previous" } }
alt+← / alt+→ widen and narrow the cockpit, from the prompt too. Inside it they are the engine's pane:grow / pane:shrink (ctrl+x ←/→ by default). Those are Pane-context actions and no plugin API sets a width, so from the prompt Global binds the chords to borrowed actions; the cockpit's hidden Buttons then take the keyboard into it, replay the chord there through tmux and hand the keyboard back with alt+c. A draft in the composer refuses that focus, so with text in the prompt nothing happens (a toast says so). With the cockpit closed, alt+← opens it. The engine keeps a keyed width in ~/.claude.json (pluginPanes.dockColumns) for later sessions, over the cockpit's own 3/8 share; delete that key to go back to it. In a text field (the Web tab's go field, a filter) pane:grow does nothing, so PaneField binds the chords to the same borrowed actions: the ring moves onto the open tab's button and the chord is replayed there, keeping the keyboard in the cockpit. A TWeb page holding the keyboard (Web tab, browserEmbed on) or a TWeb pane beside takes alt+←/→ itself. Ghostty sends alt+←/→ as esc:b / esc:f by default; map them to the xterm form (keybind = alt+arrow_left=csi:1;3D, alt+arrow_right=csi:1;3C).
{ "context": "Global", "bindings": { "alt+left": "agents:previousGroup", "alt+right": "agents:nextGroup" } },
{ "context": "Pane", "bindings": { "alt+left": "pane:grow", "alt+right": "pane:shrink" } },
{ "context": "PaneField", "bindings": { "alt+left": "agents:previousGroup", "alt+right": "agents:nextGroup" } }
In a focused pane the engine hands any key no binding of the Pane context claims to the prompt, which then takes the keyboard. Left and right are among them by default; binding them as the engine's own band does keeps them in the cockpit:
{ "context": "Pane", "bindings": { "left": "abovePrompt:previous", "right": "abovePrompt:next" } }
A printable key no Button hotkey claims goes to the prompt, which then takes the keyboard, and no binding swallows a key (null unbinds, it does not claim). So the cockpit draws a hidden Button for every letter and digit first; its own hotkeys, drawn later, win their keys, and any other letter does nothing. Leaving the cockpit is alt+c (or a click); Esc is bound to abovePrompt:previous in Pane and AbovePrompt so it no longer drops to the prompt:
{ "context": "Pane", "bindings": { "escape": "abovePrompt:previous" } }
The In/Out tab (4) has a filter field above its list (g puts the keyboard in it; the palette's "Filter In/Out by words" too). Every word typed must appear somewhere in an item: its kind and that kind's words (jira for tickets, doc for artifacts, task for background tasks), title, URL, path, detail or the session window it came from; any order, case-insensitive. It narrows the kind tabs and both sides, and their counts: slack infra is Slack threads whose channel or title says infra, abc-12 tickets by key. Esc leaves the field; ✕ clears it.
~/exp/<date>_<topic>/)What a session exchanged with an outside party (a vendor ticket, a Slack Connect thread) is kept in a dated folder under ~/exp/, in the layout the exp-archive skill writes: README.md (with a ## Open list of what is still owed), correspondence.md (one row per message in or out), attachments/ with SHA256SUMS, drafts/, work/.
mkdir, mv, cp, tee, > …), or an answer that names it. Reading one (ls, cat) does not link it. The link is an exp output, so it is kept with the session and exported with it.e) lists them with what they owe and their sums; the preview shows the Open items, the attachment check, the last message and the README. o opens the folder, r reveals it in Finder.SHA256SUMS (changed or gone) or a file in attachments/ has no sum. A press opens it in In/Out.Sends to Slack Connect are often made by hand, outside Claude's tools, so the folder, not the send, is what is tracked. Sums are checked when a folder is linked, when it is opened, and every five minutes.
/office)Every Claude Code session in tmux as a chibi in a quarter-view office, in a TWeb pane beside this one, live (2 s). office/office.py serves it on 127.0.0.1:8796, shared by all sessions; /office starts it when nothing answers there. It is named after you (🏢 Gavin네 사무실): OFFICE_OWNER, else the first name of your account's full name, else your login name.
…), ? / ! over its head while it waits on you.❗, hurrying) to its desk, before its own state catches up; and a session that has just finished stays at its desk for 90 s holding its answer up (a page with a tick) before it wanders off, so a short job shows as it coming back and answering.EXIT door in the left wall near the front and fades out there. If the pane still exists, a missing pane-session option does not start the animation. A session that returns before leaving cancels it. After a long day (8 h or 2000 tool calls) it leaves slumped and slow, with bags under its eyes, a grey cloud and 휴… 퇴근; after a short one (under an hour) hopping with its arms up and 퇴근이다!; otherwise a wave and 수고했습니다.<project>/<sid>/subagents/agent-*.jsonl, live while written in the last 45 s) are small ones with an antenna from the queue in their narrow room at the far end, as deep as the other rooms (🐣 N on its door at the front, fewer while some are out): the door opens, the head of the queue walks out to its session and stands with it (a row in front of its desk, or around it in a room) on a dotted tether, shows its own work by pose and bubble, and walks back in when done.{ } rising, letters from a pen, chat bubbles, a scanning gaze, 01 rain, radio arcs, a pointing hand). Its age is how long the session has lived (its transcript's first line): grey streaks after a day, silver hair and a beard after three, a cane and a slower walk after a week; the tooltip says how long (근속). Its rank is its total calls: 🐣 new (<100), staff, senior, manager, lead (silver crown), 👑 head (10,000+, gold crown); a little taller each step, and the desk fills with papers, a plant, a trophy.!), a subagent is shy (blushes, leans back). It eases in and out.❓/❗ chip at the bottom) opens its card: what it last said, and1번으로 해 주세요 for the numbered choices its answer ends on, yes/no for a question, 계속 진행해 주세요) and a free reply. The text waits in ~/.claude/state/office/inbox/<sid>.json and that session's cc-mods submits it as your own prompt (2 s poll, dropped after 2 minutes), so vim mode or a draft in the box cannot garble it;✋ stop (Escape) while it works, and ↗ to its window (cc-focus).bin/cc-bridge.py policy) from the guards' own refusals in its transcript (/policy allow merge owner/repo, /policy allow prod-w ctx/ns 15m) or, without one, from what its last answer asks for. A (or the button) approves it: the office runs the policy CLI for that session with the grant it proposed (never one sent by the page) and puts it back after its minutes. Timers live in the office server; a grant outlives a server restart until changed by hand.~/.claude/outputs export, what it made first), each one click away. Well-known places are badges in their own colour with their logo and a short name (PR o/r#14, Jira INF-123, Slack #channel, Grafana, Google Docs/Sheets, Notion, Linear, Figma, Sentry, PagerDuty, Datadog, Argo CD, …; office/brands.js, logos from simple-icons, CC0).N/Tab opens the next one's card (P/⇧Tab the previous): the camera glides and zooms to it and a ring and arrow mark it. A reply or a dialog pick moves on to the next one by itself.~/.claude/sessions/<pid>.json) flies across as a paper plane on an arc; on landing a bubble over the recipient says who sent what. One to a session not in the office flies off past the wall. The last 15 minutes are kept: hovering a character (or opening its card) draws dashed threads to whom it talked, with a count, and its card lists them (→ sent, ← got).? and gears orbit it and it steams, past 90% the eyes spiral and pages fly off.🗜 압축 중…); right after (a compact_boundary in the transcript) the head is clear and sparkles under 🧹 정리 완료 · 96%→18%. Every compaction so far is a summary box stacked by its desk (×N; hover says how many and when the last was).📨), kept current as it changes; quick replies dim while it works. A reply then is refused once with that reason (the server checks the session's state and its inbox); pressing again sends it anyway, as a queued follow-up.Z, or ⌥Z in the reply box) opens a menu: 30 minutes, an hour, the rest of today, or 🤐 until you speak first, and, when already set aside, call it back now (digits pick). A set-aside agent leaves the Tab/N queue and stops lighting its pod up, whatever it waits on; speaking to it first lifts any of them (a reply, a pick or a grant from the office, or anything you type to it yourself: a human prompt in its transcript after you set it aside), and a timed one also ends on its time. A timed one wears 💤 (💤! with something to say); one waiting for you to speak has its mouth zipped shut and, with something to say, strains (the pull tab jiggling): cheeks puffed and reddening, a hand up and waving, a muffled 음! 음음! bubble going nowhere. Chips: 💤 name · N분, 🤐 name · 할 말 참는 중 N분. Kept in ~/.claude/state/office/mute.json./detail?kind=).[/] switch tabs. A click on one thing opens the drawers at its tab.hooks/register.ts 4893 lines1// cc-mods: a session cockpit for a tmux-centred Claude Code setup.
2//
3// tmux state @cc_state/@cc_attention from the events themselves, not
4// from Notification/Stop guesses; plus @cc_ctx/@cc_agents
5// panes /flow /graph /side /outputs /policy, toggled like /diff,
6// docked as tabs (ctrl+x tab), Esc closes
7// band credential radar, the session's PRs, neighbors, fork offer
8// policy only a person changes session policy; Claude's Bash
9// attempts to self-grant are refused
10// auth expired SSO stops an aws/kubectl call before it fails
11// gh a gh call runs as the account that owns the repo
12// autocomplete kubectl contexts, AWS profiles, Jira keys, PRs, windows
13//
14// Judgment that already exists in ~/.claude/hooks (Jev attention, fork,
15// neighbors) is reached through bin/cc-bridge.py rather than rewritten.
16//
17// Rule from `claude plugin validate`: `$` may only be passed to functions
18// declared at the top level of this file, so every helper that touches the
19// mods API lives here; hooks/lib and hooks/views are pure.
20
21import { atom, read, update } from 'claude-code'
22import type { Elements, RenderElement } from 'claude-code'
23
24import { BROWSER_LABEL, resolveBrowser, browserArgv, parseBrowser, type BrowserChoice } from './lib/browser'
25import { WEB_IDLE, engineForUrl, engineOfTarget, formatEngineRules, parseEngine, parseEngineRules, type EngineRule, urlPicks, urlsIn, type WebEngine, keyBytes, urlFromInput, sgrMouse, sgrWheel, toHex, type KeyMsg, type PointerMsg, type WebState } from './lib/web'
26import { WEB_SETTINGS_ROWS, pickRows, renderWeb, type WebSettings } from './views/web'
27import { renderWebSide } from './views/webside'
28import { LINK_RENEW_MS, SIDE_EMPTY, linkArgv, linkOf, parseConsole, parseDaemonStatus, parseNetwork, parseTwebPanes, pickSidePane, sideArgv, splitArgv, twebMcpStep, twebSteps, type AutoStep, type SidePane, type SideWeb, type TwebLink } from './lib/webside'
29import { renderHelp } from './views/help'
30import { helpSections } from './lib/help'
31import { COCKPIT_ACTION, GROW_ACTION, PALETTE_ACTION, catchAll, paletteNextKey, renderPalette } from './views/palette'
32import { PALETTE_CLOSED, filterPalette, paletteItems, type PaletteItem, type PaletteState } from './lib/palette'
33import { cacheGate, decide, resolveModel, routeLabel, type RouteLast, routeSettings, type RouteAnswer, type RouteDecision, type RouteSettings } from './lib/route'
34import { parseJev, parseQuota, parseUsage, type JevUsage, type Quota, type Usage } from './lib/usage'
35import { USAGE_WINDOWS, renderUsage, type UsageWindow } from './views/usage'
36import { argocdItems, awsItem, awsLoginReused, awsReusedItem, parseMcpList, sha1Hex, ssoProfile, ccproxyItems, coderLocalItems, credentialFor, daemonItem, ghItems, mcpItems, worst, type AuthItem, type Radar } from './lib/auth'
37import { EMPTY, parseProfiles, suggest, type Sources } from './lib/complete'
38import { BLOCK_TTL_MS, blockOf, grantLabel, nowAllowed, resumePrompt, type Blocked } from './lib/unblock'
39import { alreadyIn, authUrl, connectorUrl, isCallbackUrl, isClaudeAi, loginError, loginSplitArgv, markLoggedIn, needsTerminalLogin, serverOf, textOf, type McpLogin } from './lib/mcpauth'
40import { cockpitColumns } from './lib/layout'
41import { RCA_MS, alertOf, rcaGrants, rcaSection, restoreArgvs, type Alert, type Grant, type RcaState } from './lib/rca'
42import { identityText } from './lib/identity'
43import type { LiveTurn } from './views/flow'
44import { FORK_STALE_AFTER, forkEffect, forkPhase, forkPrompt, movedSection, setAsideMoved, type ForkOffer } from './lib/fork'
45import { parseGoal, type Goal } from './lib/goal'
46import { capAnswers, isHumanPrompt, parseStamps, rebuild, transcriptPath, type BackfillMessage } from './lib/backfill'
47import { agentFlow, flowOf } from './lib/flow'
48import { HELD_PREFIX, firstLine, heldNotification, holdDeadline, isWrite, parseExpiry, parseMonitor, shouldRearm, splitLines, startedTaskId, turnMattered, watchInput, weigh, type MonitorState, type WatchInput, type Weight } from './lib/monitor'
49import { K, PANES, PANE_TITLES, paneKey, paneOf, type PaneId } from './lib/keys'
50import { CAPS, GOD_MS, godArgvs, slackDefaultTargets, isProdContext, parseChannelSearch, parseUserSearch, slackTargetKind, badge, hintsFromBlock, hintsFromCommand, isSelfGrant, parsePolicy, parsePolicyArgs, scopeHints, shortName, type Cap, type CapValue, type Policy, type ScopeHint } from './lib/policy'
51import { ghAccountFor, gitopsCommentsArgv, gitopsFromComments, graphqlQuery, needsAttention, parseGraphql, parsePrKey, repoFromGh, repoFromRemote, setIacWindows, type PrStatus } from './lib/prs'
52import { parseWindows } from './lib/iac'
53import { answerFiles, dropUnsourcedTickets, isMade, outputKeysToDrop, prOutputs, ticketOutputs, mergeOutputs, workflowMeta, setSlackName, slackName, slackOutputs, textOutputs, toolOutputs, type AgentNode, type FlowEntry, type Output, type TurnRecord } from './lib/record'
54import { clearArgv, isPaneId, publishArgv, type Attention, type CcState } from './lib/tmux'
55import { clip, hhmm } from './lib/text'
56import { isMcpTool, looksDisconnected, mcpServerKey, normalizeServer } from './lib/mcpheal'
57import { clientItems, navItems, navTarget, skipHidden, type NavDir, type NavItem } from './lib/nav'
58import { SUMS_CHECK, expFromAnswer, expFromTool, expPreview, lastMessage, parseOpen, parseSums, type ExpCheck } from './lib/exp'
59import { focusWindowArgv, parseBrief, parseWindows as parseTmuxWindows, type Elsewhere, type Todo } from './lib/elsewhere'
60import { ORG_PATH, org, parseOrg, setOrg } from './lib/org'
61import { answerBody, freshItems, itemKey, parseAttention, parseRuns, resultPrompt, runOutputs, toastFor, type NarwhalItem, type NarwhalRun } from './lib/narwhal'
62import { RESUME_SPREAD_MS, backoff, classify, decide as resumeDecide, slept, type Pending } from './lib/resume'
63import { mayRun, parseLease, settled, spread, taken, type Lease, type LeaseTimes } from './lib/lease'
64import { mcpText, newestTs, parseChannelName, parseThread, unseenReplies, type SlackThreadState, followDue, type SlackFollow } from './lib/slack'
65import { unlockFor } from './lib/unlock'
66import { renderBand, type Neighbor } from './views/band'
67import { renderFlow, type FlowFilter } from './views/flow'
68import { mermaid, renderGraph, type GraphMode } from './views/graph'
69import { renderOutputs, type OutScope, type OutSide, type OutTab, type Preview } from './views/outputs'
70import { renderPolicy, renderRoute } from './views/policy'
71import { renderSide, type SideQA } from './views/side'
72import { renderSettings } from './views/settings'
73import { col, tabRow, type Els } from './views/common'
74import { FRAME_BOTTOM, FRAME_TOP, SPACER_ROWS, TABS, frame, renderHome, renderNarwhalRuns, renderWatch, tabOf, type HomeView, type View } from './views/home'
75import { renderAuthTab } from './views/auth'
76import { section } from './views/common'
77import { PROCEED_MAX, PROCEED_POLICY, PROCEED_REPLY, closingAsk } from './lib/proceed'
78
79const P = 'cc-mods'
80const HOME_HOOKS = '/.claude/hooks/'
81// The SSO session the Auth tab watches (org.json awsSsoStartUrl), and its
82// token cache file, which the AWS CLI names sha1(start URL).json. None
83// configured: no AWS row.
84const ssoCacheFile = (h: string) => {
85 const url = org().awsSsoStartUrl
86 return url ? h + '/.aws/sso/cache/' + sha1Hex(url) + '.json' : undefined
87}
88// The office server's port (office/office.py); 8787 is taken by cco here.
89const OFFICE_PORT = 8796
90const IDLE_MS = 30 * 60_000
91const RADAR_MS = 5 * 60_000
92const PR_POLL_MS = 90_000
93// One PR poll machine-wide per PR_POLL_MS; after failures, 90 s doubling to 15 min.
94// runMs covers a slow run (20 s per account, more for GitOps comments); a
95// success replaces it with freshMs.
96const PR_LEASE: LeaseTimes = { runMs: 5 * 60_000, freshMs: PR_POLL_MS - 10_000, baseMs: PR_POLL_MS, capMs: 15 * 60_000 }
97// Slack threads this session touched are re-read this often for new replies.
98const SLACK_POLL_MS = 3 * 60_000
99const SLACK_SERVER = 'claude.ai Slack'
100const FLOW_CAP = 600
101
102// ---- reactive state ------------------------------------------------------
103
104const flowA = atom({ plugin: 'cc-mods', key: 'flow' }, [] as FlowEntry[])
105// The main turn while it runs, for the Chat tab: what each step said and
106// which tools it called. Flow entries come only when a turn completes, so a
107// long turn showed nothing past its prompt.
108const liveA = atom({ plugin: 'cc-mods', key: 'live' }, null as LiveTurn | null)
109const answersA = atom({ plugin: 'cc-mods', key: 'answers' }, {} as Record<string, string>)
110const turnsA = atom({ plugin: 'cc-mods', key: 'turns' }, [] as TurnRecord[])
111const agentsA = atom({ plugin: 'cc-mods', key: 'agents' }, [] as AgentNode[])
112const outputsA = atom({ plugin: 'cc-mods', key: 'outputs' }, [] as Output[])
113const sideA = atom({ plugin: 'cc-mods', key: 'side' }, [] as SideQA[])
114const policyA = atom({ plugin: 'cc-mods', key: 'policy' }, null as Policy | null)
115const authA = atom({ plugin: 'cc-mods', key: 'auth' }, [] as AuthItem[])
116const prsA = atom({ plugin: 'cc-mods', key: 'prs' }, [] as PrStatus[])
117// Scopes this session's commands used or a guard blocked (lib/policy.ts
118// ScopeHint). In $.state, so a module reload keeps them; backfilled from the
119// transcript once per load.
120const hintsA = atom({ plugin: 'cc-mods', key: 'hints' }, [] as ScopeHint[])
121// Calls a policy guard refused, kept until a grant lets them through (lib/unblock.ts).
122const blockedA = atom({ plugin: 'cc-mods', key: 'blocked' }, [] as Blocked[])
123const rcaA = atom({ plugin: 'cc-mods', key: 'rca' }, null as RcaState)
124const mcpLoginA = atom({ plugin: 'cc-mods', key: 'mcpLogin' }, null as McpLogin | null)
125const slackA = atom({ plugin: 'cc-mods', key: 'slack' }, {} as Record<string, SlackThreadState>)
126// Threads followed up automatically (lib/slack.ts SlackFollow), mirrored to the store.
127const slackFollowA = atom({ plugin: 'cc-mods', key: 'slackFollow' }, {} as Record<string, SlackFollow>)
128// Archive folders' checks, by path (lib/exp.ts): recomputed, never stored.
129const expA = atom({ plugin: 'cc-mods', key: 'exp' }, {} as Record<string, ExpCheck>)
130const elsewhereA = atom({ plugin: 'cc-mods', key: 'elsewhere' }, { windows: [], todos: [] } as { windows: Elsewhere[]; todos: Todo[] })
131// What this session's narwhal runs need (lib/narwhal.ts): the items, and the
132// keys already told (toast) or dismissed, so a poll does not repeat them.
133const narwhalA = atom({ plugin: 'cc-mods', key: 'narwhal' }, { items: [], told: [], dismissed: [], runs: [] } as { items: NarwhalItem[]; told: string[]; dismissed: string[]; runs: NarwhalRun[] })
134// A main turn that died on a connection problem, waiting for the network
135// (lib/resume.ts); null when nothing is pending.
136const resumeA = atom({ plugin: 'cc-mods', key: 'resume' }, null as Pending | null)
137const neighborsA = atom({ plugin: 'cc-mods', key: 'neighbors' }, [] as Neighbor[])
138const monitorsA = atom({ plugin: 'cc-mods', key: 'monitors' }, [] as MonitorState[])
139const webA = atom({ plugin: 'cc-mods', key: 'web' }, WEB_IDLE as WebState)
140// What Jev picked last; whether it runs is the session policy (auto_effort, auto_model).
141const routeA = atom({ plugin: 'cc-mods', key: 'route' }, { last: undefined } as { last?: RouteDecision & { prompt: string; at: number } })
142const webSettingsA = atom({ plugin: 'cc-mods', key: 'webSettings' }, { open: false, engine: 'electron', device: '', rate: 30 } as WebSettings)
143const webKeyboardA = atom({ plugin: 'cc-mods', key: 'webKeyboard' }, 'controls' as 'controls' | 'page')
144// The TWeb pane beside this one that the Web tab drives (browserEmbed off).
145const sideWebA = atom({ plugin: 'cc-mods', key: 'webSide' }, SIDE_EMPTY as SideWeb)
146// Browser commands agents ran this session (Bash `tweb …`, tweb MCP tools).
147const webAutoA = atom({ plugin: 'cc-mods', key: 'webAuto' }, [] as AutoStep[])
148// How long after a browser command the Web tab says Claude is driving.
149const DRIVING_MS = 8000
150const paletteA = atom({ plugin: 'cc-mods', key: 'palette' }, PALETTE_CLOSED as PaletteState)
151// The key help (alt+/) is open; a session starts with it closed, as the palette.
152const helpA = atom({ plugin: 'cc-mods', key: 'help' }, false as boolean)
153const usageA = atom({ plugin: 'cc-mods', key: 'usage' }, { note: 'loading…', quota: [], window: 'session' } as { usage?: Usage; note?: string; quota: Quota[]; window: UsageWindow; jev?: JevUsage })
154const goalA = atom({ plugin: 'cc-mods', key: 'goal' }, null as Goal | null)
155const forkA = atom({ plugin: 'cc-mods', key: 'fork' }, null as ForkOffer | null)
156// Lines of work handed to fork panes, which this session should leave alone.
157const movedA = atom({ plugin: 'cc-mods', key: 'moved' }, [] as { prompt: string; pane: string }[])
158const uiA = atom(
159 { plugin: 'cc-mods', key: 'ui' },
160 {
161 flowFilter: 'all',
162 flowProgress: false,
163 flowSel: -1,
164 graphMode: 'tree',
165 graphSel: '',
166 graphCollapsed: true,
167 outTab: 'all',
168 outScope: 'session',
169 outSel: '',
170 policyMsg: '',
171 focus: false,
172 view: 'home',
173 open: false,
174 },
175)
176
177// ---- module-level (lost on reload, which is fine for these) ---------------
178
179let pane: string | undefined
180let sid = ''
181let cwd = ''
182let repo: string | undefined
183// Scopes seen in this session's commands and policy denials (lib/policy.ts
184// ScopeHint), offered as one-press grants on the Policy tab.
185let home = ''
186let interactive = false
187let idleTimer: { cancel: () => void } | undefined
188let sources: Sources = EMPTY
189let currentTurn = ''
190let lastPrompt = ''
191// The last prompt the person typed. turn.start carries no origin, so this is
192// how it tells a real request from a task notification or an agent hand-back.
193let pendingHuman = ''
194// auto_continue (lib/proceed.ts): proceed questions answered in a row without
195// the person typing, and the answer waiting to go out.
196let proceedStreak = 0
197let proceedTimer: { cancel: () => void } | undefined
198// A moved request waiting to be set aside in this transcript (lib/fork.ts
199// setAsideMoved), read by the session.compact hook this mod's own compaction
200// runs through.
201let setAside: { prompt: string; pane: string } | undefined
202// The turn a Monitor event started, while it runs. Its tool rows and text are
203// folded in the transcript unless, at turn.complete, it turns out to matter.
204let quietTurn: { turnId: string; taskId: string; event: string; weight: Weight; wrote: boolean; tools: string[]; texts: string[] } | undefined
205// The task a quiet re-arm replaces, while its Monitor call runs: the
206// tool.call hook carries the Watch row over to the new task id.
207let rearmingFrom: string | undefined
208// Watches the cockpit runs itself (lib/monitor.ts, holdDeadline), by task id.
209const holds = new Map<string, { stop: (why: 'stopped' | 'expired') => void }>()
210const quietTools = new Set<string>()
211const quietTexts: string[] = []
212const quietFinal = new Map<string, string>() // answer head -> one-line outcome
213// Routine Monitor events whose turn turned out to matter: their notification
214// row comes back into the transcript. Keyed by task id + event line.
215const shownEvents = new Set<string>()
216const eventKey = (taskId: string, event: string) => taskId + '\u0000' + event
217let preview: { id: string; p: Preview } | undefined
218const policyTimers = new Map<string, { expiresAt: number; revert: string[]; timer: { cancel: () => void } }>()
219let sideSeq = 0
220// Session id the transcript was last replayed for, and the transcript file
221// the classic SessionStart named (lib/backfill.ts).
222let backfilled = ''
223// The Web tab's bridge (bin/cc-browser): its socket and the engine it runs.
224// One per session.
225let webSock = ''
226let webEngine: WebEngine | undefined
227// URL pattern -> engine (lib/web.ts EngineRule): the Settings tab's list
228// (this machine) over the browserEngineRules option.
229let engineRules: EngineRule[] = []
230let engineRulesOption: EngineRule[] = []
231// The running page's engine was asked for by name (/web --chrome, the engine
232// setting, a reload putting the page back): its loads do not switch engines.
233let webExplicit = false
234let webStopping = false
235// Which bridge is current (see startWeb), and the frame rate it runs at.
236let webGen = 0
237let webRunRate = 0
238// The bridge generation whose first page load has had the device applied.
239let webDeviceGen = 0
240// Where a synthetic terminal mouse click lands inside web-region, 1-based
241// terminal cells. There is no plugin API that focuses a Client; terminal dock
242// mode can click it by its rendered coordinates (lib/nav.ts clientItems).
243let webClick: { x: number; y: number } | undefined
244let webFocusPending = false
245// What the open palette lists, from its last drawing: the arrows move the
246// selection over these (ui.focus hook) without the focus leaving the field.
247let paletteShown: PaletteItem[] = []
248// The cockpit's last drawing laid out (lib/nav.ts) and where its ring is.
249let navLast: NavItem[] = []
250let navFocus: string | undefined
251let paletteNext = ''
252// How far the cockpit body is scrolled, per tab: the header stays and only
253// the body moves (views/home.ts frame). The engine's own scroll is refused.
254const bodyScroll = new Map<string, number>()
255// The furthest each tab's body can scroll, from its last drawing.
256const bodyMax = new Map<string, number>()
257// Jev's effort pick per main turn, started at turn.start and read at the
258// turn's first step (lib/route.ts). Dropped at turn.complete.
259const routeJobs = new Map<string, Promise<RouteAnswer>>()
260const ROUTE_WAIT_MS = 1500
261// The region size last sent to the bridge (POST /region), as `${cols}x${rows}`.
262let webRegion = ''
263// userConfig.browserEmbed, read where startWeb runs (module scope, set by register).
264let embedOn = false
265// A `tweb panes` listing for the Web tab is under way: redraws do not stack them.
266let sideListing = false
267// The last `tweb link` renewal: kept off the atom, so a renewal every 20 s redraws nothing.
268let sideLinkedAt = 0
269let sideLinkedPane = ''
270let sideAskedAt = 0
271// The Settings tab's view of where links go, read when it is first drawn.
272let settingsTarget: string | undefined
273let settingsEnvBrowser = ''
274// While the main prompt edits its first slash-command token, the engine's
275// typeahead is full-terminal-width and paints over a docked Pane. Hide the
276// cockpit until the token is completed (space), deleted or submitted; restore
277// the same view without taking the keyboard. Stored per machine in Settings.
278let slashHideEnabled = true
279let slashHidCockpit = false
280let slashHideBusy = false
281let webRate = 30
282// What this session set the pane's @browser to: the userConfig default, then
283// /browser. 'inherit' means the pane holds no value of its own.
284let browser: BrowserChoice = 'inherit'
285let transcript = ''
286
287// ---- helpers that use $ ---------------------------------------------------
288
289async function tmux($: any, argvs: string[][]) {
290 if (!isPaneId(pane)) return
291 for (const argv of argvs) {
292 try {
293 await $.process.run(['tmux', ...argv], { timeoutMs: 3000 })
294 } catch {
295 // tmux socket gone (see tmux-socket-guard) — state publishing is best effort
296 }
297 }
298}
299
300// Last value written per tmux option, so a hook that fires on every redraw
301// (the question dialog renders many times) does not spawn tmux each time.
302const published = new Map<string, string>()
303
304async function publish($: any, state: CcState | undefined, attention: Attention | null, extra?: Record<string, string>) {
305 const want: Record<string, string> = { ...(extra ?? {}) }
306 if (state) want['@cc_state'] = state
307 if (attention) want['@cc_attention'] = attention
308 const changed = Object.entries(want).filter(([k, v]) => published.get(k) !== v)
309 if (changed.length === 0) return
310 for (const [k, v] of changed) published.set(k, v)
311 const keep = new Set(changed.map(([k]) => k))
312 const extras = Object.fromEntries(changed.filter(([k]) => k !== '@cc_state' && k !== '@cc_attention'))
313 await tmux(
314 $,
315 publishArgv(
316 pane!,
317 { state: keep.has('@cc_state') ? state : undefined, attention: keep.has('@cc_attention') ? attention : null, session: sid, extra: extras },
318 Math.floor(Date.now() / 1000),
319 ),
320 )
321}
322
323// Run a credential's login command from a button. A failure is shown: before,
324// a bad argv exited at once and the button looked like it did nothing.
325// An MCP connector's login, run from the cockpit: its `authenticate` tool
326// gives the OAuth URL, the system browser opens it (the person's SSO cookies
327// live there), and Claude Code's localhost callback finishes it. The needs-auth
328// cache losing the server is the sign it worked; a pasted callback URL
329// (complete_authentication) covers a callback the browser could not reach.
330let mcpPoll: { cancel: () => void } | undefined
331
332async function startMcpLogin($: any, server: string) {
333 mcpPoll?.cancel()
334 await update($, mcpLoginA, () => ({ server, status: 'starting', at: Date.now(), claudeAi: isClaudeAi(server) }))
335 if (isClaudeAi(server)) return startConnectorLogin($, server)
336 let r: any
337 try {
338 r = await $.mcp.call(server, 'authenticate', {})
339 } catch (err) {
340 if (needsTerminalLogin(err) && isPaneId(pane)) return startTerminalLogin($, server)
341 const msg = loginError(err, server)
342 await update($, mcpLoginA, (l) => l && { ...l, status: 'failed', msg: clip(msg, 200) })
343 return
344 }
345 if (alreadyIn(r)) return finishMcpLogin($, server, 'already logged in')
346 const url = authUrl(r)
347 if (!url || r?.isError) {
348 await update($, mcpLoginA, (l) => l && { ...l, status: 'failed', msg: clip(textOf(r) || 'no login URL in the answer', 200) })
349 return
350 }
351 await update($, mcpLoginA, (l) => l && { ...l, status: 'waiting', url })
352 await openLoginPage($, url)
353 $.ui.toast(server.replace(/^claude\.ai /, '') + ': log in in the browser that opened')
354 const started = Date.now()
355 const tick = async () => {
356 const l = (await read($, mcpLoginA)) as McpLogin | null
357 if (!l || l.server !== server || l.status !== 'waiting') return
358 const cache = await readText($, home + '/.claude/mcp-needs-auth-cache.json')
359 let stillNeeds = true
360 try {
361 stillNeeds = server in (cache ? JSON.parse(cache) : {})
362 } catch {}
363 if (!stillNeeds) return finishMcpLogin($, server, 'logged in')
364 if (Date.now() - started > 10 * 60_000) return void update($, mcpLoginA, (x) => x && { ...x, status: 'failed', msg: 'no login seen in 10 minutes: paste the callback URL or start again' })
365 mcpPoll = $.clock.after(3000, tick)
366 }
367 mcpPoll = $.clock.after(3000, tick)
368}
369
370// A claude.ai connector: its `authenticate` tool refuses ("run /mcp"), and its
371// authorization lives on claude.ai. `claude mcp login --no-browser` prints the
372// start-auth URL (it builds it from the signed-in org); the system browser
373// opens it; `claude mcp get` health-checks the connector until it says
374// Connected. The running session keeps the connection it made at start, so
375// the card says to restart (or reconnect in /mcp) to use it here.
376async function startConnectorLogin($: any, server: string) {
377 let out = ''
378 try {
379 const r = await $.process.run(['claude', 'mcp', 'login', '--no-browser', server], { stdin: '', timeoutMs: 30_000 })
380 out = (r.stdout || '') + (r.stderr || '')
381 } catch (err) {
382 out = String(err)
383 }
384 const url = connectorUrl(out)
385 if (!url) return void update($, mcpLoginA, (l) => l && { ...l, status: 'failed', msg: clip(out.trim() || 'claude mcp login gave no URL', 200) })
386 await update($, mcpLoginA, (l) => l && { ...l, status: 'waiting', url })
387 await openLoginPage($, url)
388 $.ui.toast(server.replace(/^claude\.ai /, '') + ': authorize it on claude.ai in the browser that opened')
389 const started = Date.now()
390 const tick = async () => {
391 const l = (await read($, mcpLoginA)) as McpLogin | null
392 if (!l || l.server !== server || l.status !== 'waiting') return
393 const g = await $.process.run(['claude', 'mcp', 'get', server], { timeoutMs: 45_000 }).catch(() => undefined)
394 if (g && /Status:\s*✔?\s*Connected/.test(g.stdout ?? '')) {
395 authorizedAt.set(server, Date.now())
396 return finishMcpLogin($, server, 'authorized on claude.ai · restart Claude Code (or /mcp → reconnect) to use it in this session')
397 }
398 if (Date.now() - started > 10 * 60_000) return void update($, mcpLoginA, (x) => x && { ...x, status: 'failed', msg: 'not authorized in 10 minutes: open the page again or start over' })
399 mcpPoll = $.clock.after(10_000, tick)
400 }
401 mcpPoll = $.clock.after(10_000, tick)
402}
403
404// A server that needs login is not connected in this session, so it offers
405// no `authenticate` tool, and `claude mcp login` gives up without a terminal
406// (its localhost callback listener dies with it). So the login runs in a
407// small tmux pane under this one: it prints the URL (opened here in the
408// person's Chrome), keeps the callback listener up, and exits when the
409// browser lands. Then `claude mcp get` says Connected, /mcp reconnect brings
410// the server into this session, and the pane goes.
411let loginPane: string | undefined
412
413async function startTerminalLogin($: any, server: string) {
414 const r = await $.process.run(['tmux', ...loginSplitArgv(pane!, server)], { timeoutMs: 5000 }).catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: String(err) }))
415 const id = String(r.stdout ?? '').trim()
416 if (r.exitCode !== 0 || !isPaneId(id)) return void update($, mcpLoginA, (l) => l && { ...l, status: 'failed', msg: clip('could not open a login pane: ' + (r.stderr || r.stdout), 200) })
417 loginPane = id
418 await update($, mcpLoginA, (l) => l && { ...l, status: 'starting', msg: 'logging in from pane ' + id })
419 const started = Date.now()
420 let opened = false
421 const tick = async () => {
422 const l = (await read($, mcpLoginA)) as McpLogin | null
423 if (!l || l.server !== server || (l.status !== 'starting' && l.status !== 'waiting')) return closeLoginPane($)
424 if (!opened) {
425 const cap = await $.process.run(['tmux', 'capture-pane', '-p', '-J', '-t', id], { timeoutMs: 3000 }).catch(() => undefined)
426 const url = connectorUrl(cap?.stdout ?? '')
427 if (url) {
428 opened = true
429 await update($, mcpLoginA, (x) => x && { ...x, status: 'waiting', url })
430 await openLoginPage($, url)
431 $.ui.toast(server + ': log in in the browser that opened')
432 }
433 }
434 const g = await $.process.run(['claude', 'mcp', 'get', server], { timeoutMs: 45_000 }).catch(() => undefined)
435 if (g && /Status:\s*✔?\s*Connected/.test(g.stdout ?? '')) {
436 await closeLoginPane($)
437 const text = await reconnectMcp($, server).catch((err: unknown) => String(err))
438 const here = !/not connected|failed|error/i.test(text)
439 return finishMcpLogin($, server, here ? 'logged in and reconnected here' : 'logged in · /mcp → reconnect ' + server + ' to use it here')
440 }
441 if (Date.now() - started > 10 * 60_000) {
442 await closeLoginPane($)
443 return void update($, mcpLoginA, (x) => x && { ...x, status: 'failed', msg: 'no login seen in 10 minutes: start again' })
444 }
445 mcpPoll = $.clock.after(opened ? 4000 : 1000, tick)
446 }
447 mcpPoll = $.clock.after(1000, tick)
448}
449
450async function closeLoginPane($: any) {
451 const id = loginPane
452 loginPane = undefined
453 if (id) await $.process.run(['tmux', 'kill-pane', '-t', id], { timeoutMs: 3000 }).catch(() => undefined)
454}
455
456let mcpList: { at: number; items: AuthItem[] } | undefined
457// Connectors logged in from this session, and when: shown ok over the list
458// for LOGGED_IN_HOLD_MS (lib/mcpauth.ts markLoggedIn). Without it the band
459// said "logged in and reconnected here" while Needs you still said expired.
460const loggedInAt = new Map<string, number>()
461const LOGGED_IN_HOLD_MS = 15 * 60_000
462let mcpListRunning = false
463
464// `claude mcp list` health-checks every MCP server (a claude process and each
465// stdio server it starts): one session runs it for the machine and shares the
466// list in $.store; on failure everyone waits 10 minutes, doubling to an hour.
467const MCP_LIST_LEASE: LeaseTimes = { runMs: 120_000, freshMs: 10 * 60_000, baseMs: 10 * 60_000, capMs: 60 * 60_000 }
468
469async function refreshMcpList($: any) {
470 mcpListRunning = true
471 try {
472 await withLease($, 'mcp-list', MCP_LIST_LEASE, async () => {
473 const r = await $.process.run(['claude', 'mcp', 'list'], { timeoutMs: 90_000 })
474 const items = parseMcpList(r.stdout ?? '')
475 if (!items.length) return false
476 mcpList = { at: Date.now(), items }
477 await $.store.set(K.mcpList, mcpList)
478 await runRadar($, true)
479 return true
480 })
481 } finally {
482 mcpListRunning = false
483 }
484}
485
486// The list another session ran, when it is newer than this one's.
487async function sharedMcpList($: any) {
488 const got = (await $.store.get(K.mcpList).catch(() => undefined)) as typeof mcpList
489 if (got && Array.isArray(got.items) && typeof got.at === 'number' && (!mcpList || got.at > mcpList.at)) mcpList = got
490}
491
492// A login page in the person's own Chrome. Not `open URL`: a headless Chrome
493// (a TWeb chrome-engine pane, a benchmark) shares Chrome's bundle id and macOS
494// hands it the URL, so the page opened where nobody could see it — the
495// "press does nothing" of the Auth tab. tmux-chrome adds the tab through the
496// extension of the Chrome the person uses; outside tmux, `open` is all there is.
497async function openLoginPage($: any, url: string) {
498 if (isPaneId(pane)) {
499 const r = await $.process.run(['tmux-chrome', 'open', url], { env: { TMUX_CHROME_BROWSER: 'chrome', TMUX_PANE: pane! }, timeoutMs: 10_000 }).catch(() => undefined)
500 if (r?.exitCode === 0) return
501 }
502 await $.process.run(['open', url], { timeoutMs: 8000 }).catch(() => undefined)
503}
504
505// Connectors authorized from the cockpit in this module's life: the needs-auth
506// cache keeps listing them until Claude Code restarts, so the radar marks them.
507const authorizedAt = new Map<string, number>()
508
509async function completeMcpLogin($: any, callbackUrl: string) {
510 const l = (await read($, mcpLoginA)) as McpLogin | null
511 if (!l) return
512 if (!isCallbackUrl(callbackUrl)) return void $.ui.toast('that is not a localhost callback URL with a code')
513 try {
514 const r = await $.mcp.call(l.server, 'complete_authentication', { callback_url: callbackUrl.trim() })
515 if (r?.isError) return void update($, mcpLoginA, (x) => x && { ...x, status: 'failed', msg: clip(textOf(r), 200) })
516 await finishMcpLogin($, l.server, 'logged in')
517 } catch (err) {
518 await update($, mcpLoginA, (x) => x && { ...x, status: 'failed', msg: clip(String(err), 200) })
519 }
520}
521
522async function mcpLoginAction($: any, action: 'paste' | 'reopen' | 'copy' | 'retry' | 'cancel', value?: string) {
523 const l = (await read($, mcpLoginA)) as McpLogin | null
524 if (action === 'paste') return completeMcpLogin($, value ?? '')
525 if (action === 'reopen' && l?.url) return openLoginPage($, l.url)
526 if (action === 'copy' && l?.url) return copyText($, l.url, 'the login link')
527 if (action === 'retry' && l) return startMcpLogin($, l.server)
528 if (action === 'cancel') {
529 mcpPoll?.cancel()
530 await closeLoginPane($)
531 await update($, mcpLoginA, () => null)
532 }
533}
534
535async function finishMcpLogin($: any, server: string, msg: string) {
536 mcpPoll?.cancel()
537 await update($, mcpLoginA, (l) => ({ server, status: 'done', msg, at: Date.now(), claudeAi: l?.claudeAi }))
538 $.ui.toast(server.replace(/^claude\.ai /, '') + ': ' + msg)
539 loggedInAt.set(server, Date.now())
540 await runRadar($, true)
541 $.clock.after(60_000, () => update($, mcpLoginA, (l) => (l?.status === 'done' && l.server === server ? null : l)))
542}
543
544async function runFix($: any, item: AuthItem): Promise<void> {
545 const server = serverOf(item.id)
546 if (server) return startMcpLogin($, server)
547 if (!item.fix) return
548 $.ui.toast(item.label + ': opening the login page…')
549 const before = item.id === 'aws-sso' ? item.expiresAt : undefined
550 try {
551 const r = await $.process.run(item.fix, { timeoutMs: 300_000 })
552 if (r.exitCode !== 0) $.ui.toast(item.label + ' login failed: ' + clip(((r.stderr || r.stdout) as string).trim().split('\n').pop() ?? '', 160))
553 } catch (err) {
554 $.ui.toast(item.label + ' login did not finish: ' + clip(String(err), 160))
555 }
556 const after = (await runRadar($, true)).find((i) => i.id === 'aws-sso')?.expiresAt
557 if (item.id === 'aws-sso' && awsLoginReused(before, after)) {
558 awsReusedUntil = after
559 $.ui.toast('AWS SSO: the login reused the running portal session; it still ends ' + hhmm(after!) + '. Sign out at the portal, then log in again, for a new one.')
560 await runRadar($, true)
561 }
562}
563
564// The AWS expiry a login left where it was (awsLoginReused): the radar notes
565// it on the row until a later login moves it.
566let awsReusedUntil: number | undefined
567
568// ---- Slack threads: channel names and replies nobody has seen ----
569
570let slackMe: { email?: string } = {}
571
572// Marks a thread seen up to `ts` (default: its newest reply known now).
573async function markSlackSeen($: any, id: string, ts?: string) {
574 const key = id.replace(/^slack:/, '')
575 const seen = ((await $.store.get(K.slackSeen)) ?? {}) as Record<string, string>
576 const st = ((await read($, slackA)) as Record<string, SlackThreadState>)[id]
577 const mark = ts ?? st?.newest
578 if (!mark || (seen[key] && Number(seen[key]) >= Number(mark))) return
579 seen[key] = mark
580 await $.store.set(K.slackSeen, seen)
581 await update($, slackA, (m) => (m[id] ? { ...m, [id]: { ...m[id]!, unseen: 0, lastFrom: undefined } } : m))
582}
583
584async function slackChannelName($: any, channel: string): Promise<string | undefined> {
585 const names = ((await $.store.get(K.slackNames)) ?? {}) as Record<string, string>
586 if (names[channel]) return names[channel]
587 try {
588 const r = await mcpTimed($, SLACK_SERVER, 'slack_read_channel', { channel_id: channel, limit: 1, response_format: 'concise' })
589 const name = parseChannelName(mcpText(r))
590 if (name) {
591 names[channel] = name
592 await $.store.set(K.slackNames, names)
593 }
594 return name
595 } catch {
596 return undefined
597 }
598}
599
600// An MCP call bounded in time: $.mcp.call itself names no timeout, and a
601// connector that hangs on a bad network must not hold the poll for ever.
602const SLACK_CALL_MS = 20_000
603function mcpTimed($: any, server: string, tool: string, args: unknown): Promise<any> {
604 return Promise.race([$.mcp.call(server, tool, args), $.clock.sleep(SLACK_CALL_MS).then(() => Promise.reject(new Error(tool + ' timed out')))])
605}
606
607// One poll at a time: a slow connector would otherwise stack one more every
608// SLACK_POLL_MS.
609let slackPolling = false
610async function pollSlack($: any) {
611 if (slackPolling) return
612 slackPolling = true
613 try {
614 // A bound on the whole poll, store reads included, so the flag cannot stay set.
615 await Promise.race([pollSlackOnce($), $.clock.sleep(2 * 60_000)])
616 } finally {
617 slackPolling = false
618 }
619}
620
621async function pollSlackOnce($: any) {
622 const outs = ((await read($, outputsA)) as Output[]).filter((o) => o.kind === 'slack' && Date.now() - o.at < 3 * 86_400_000).slice(0, 10)
623 if (!outs.length) return
624 const seen = ((await $.store.get(K.slackSeen)) ?? {}) as Record<string, string>
625 const prev = (await read($, slackA)) as Record<string, SlackThreadState>
626 const next: Record<string, SlackThreadState> = { ...prev }
627 let renamed = false
628 for (const o of outs) {
629 const [, channel, root] = o.id.split(':')
630 if (!channel || !root) continue
631 const name = await slackChannelName($, channel)
632 if (name) {
633 setSlackName(channel, name)
634 if (!o.title.startsWith(name)) renamed = true
635 }
636 let text = ''
637 try {
638 text = mcpText(await mcpTimed($, SLACK_SERVER, 'slack_read_thread', { channel_id: channel, message_ts: root, limit: 200 }))
639 } catch {
640 continue
641 }
642 const t = parseThread(text)
643 if (!t.parent && !t.replies.length) continue
644 const key = channel + ':' + root
645 const newest = newestTs(t)
646 // First sight: what is there now is what the person brought in.
647 if (!seen[key] && newest) {
648 seen[key] = newest
649 await $.store.set(K.slackSeen, seen)
650 }
651 const unseen = unseenReplies(t.replies, seen[key], slackMe)
652 next[o.id] = { channel, root, name, replies: t.replies.length, unseen: unseen.length, lastFrom: unseen.at(-1)?.name, newest, checkedAt: Date.now() }
653 const before = prev[o.id]?.unseen ?? 0
654 if (unseen.length > before && interactive) $.ui.toast('Slack ' + (name ?? channel) + ': ' + (unseen.length - before) + ' new from ' + unseen.at(-1)!.name)
655 // A followed thread with replies the follow-up has not run for yet.
656 const follow = ((await read($, slackFollowA)) as Record<string, SlackFollow>)[key]
657 if (interactive && o.url && followDue(next[o.id], follow)) {
658 await setSlackFollow($, key, { lastRun: newest })
659 void followUpSlack($, o.url, true)
660 }
661 }
662 await update($, slackA, () => next)
663 if (renamed) {
664 // Re-title the threads now that their channel names are known.
665 const all = (await read($, outputsA)) as Output[]
666 await addOutputs($, all.filter((o) => o.kind === 'slack').map((o) => ({ ...o, title: o.title.replace(/^Slack · ([A-Z0-9]+)/, (m, c) => slackName(c) ?? m) })))
667 }
668}
669
670// An arrow key in the cockpit: move the ring to the element in that direction
671// by layout. With the palette open, up/down move its selection. Nothing that
672// way: up/down scroll the body as the keys did before, left/right fall back
673// to the engine's previous/next order.
674async function navMove($: any, dir: NavDir, tab: string, bodyRows: number) {
675 const pal = (await read($, paletteA)) as PaletteState
676 if (pal.open && (dir === 'up' || dir === 'down')) {
677 const n = paletteShown.length
678 if (n) await update($, paletteA, (p) => ({ ...p, sel: (p.sel + (dir === 'down' ? 1 : -1) + n) % n }))
679 return
680 }
681 const items = navLast.filter((i) => !i.hidden && !isHiddenKey(i.key))
682 // Not knowing where the ring is (the cockpit just took the keys, or a tab
683 // switch redrew it), start from the open tab's button in the header.
684 const from = items.some((i) => i.key === navFocus) ? navFocus : 'cockpit-' + tab
685 let target = navTarget(items, from, dir)
686 if (!target && (dir === 'left' || dir === 'right')) {
687 const i = items.findIndex((x) => x.key === from)
688 target = items[i + (dir === 'right' ? 1 : -1)]
689 }
690 if (!target) {
691 if (tab !== 'web') {
692 const cur = bodyScroll.get(tab) ?? 0
693 bodyScroll.set(tab, Math.max(0, Math.min(bodyMax.get(tab) ?? 400, cur + (dir === 'down' ? 3 : -3))))
694 $.ui.invalidate('ui.render')
695 }
696 return
697 }
698 // A target off screen is not jumped to: that skipped whole answers (a long
699 // side answer has nothing focusable until its buttons). Scroll toward it a
700 // few rows instead, as the arrows did before; the focus follows once it
701 // comes into view.
702 if (tab !== 'web' && (dir === 'up' || dir === 'down')) {
703 const top = FRAME_TOP + SPACER_ROWS
704 // The header (status, tabs) above `top` never scrolls: always in view.
705 const header = target.key.startsWith('cockpit-')
706 const off = !header && (target.y < top || target.y >= top + bodyRows)
707 if (off) {
708 const cur = bodyScroll.get(tab) ?? 0
709 bodyScroll.set(tab, Math.max(0, Math.min(bodyMax.get(tab) ?? 400, cur + (dir === 'down' ? 3 : -3))))
710 $.ui.invalidate('ui.render')
711 return
712 }
713 }
714 navFocus = target.key
715 noteCockpitUse($, target.key)
716 try {
717 await $.ui.focus({ requestId: COCKPIT, key: target.key })
718 } catch {
719 // the element went away between the drawing and the move: stay put
720 }
721}
722
723// The person used the cockpit (moved its focus, typed into the page): what a
724// reload hands the keyboard back to. Throttled: focus moves come in bursts.
725let cockpitNoted = 0
726function noteCockpitUse($: any, focusKey?: string) {
727 const now = Date.now()
728 if (now - cockpitNoted < 2000 && !focusKey) return
729 cockpitNoted = now
730 void update($, uiA, (u) => ({ ...u, cockpitAt: now, ...(focusKey ? { focusKey } : {}) }))
731}
732
733// The cockpit's invisible buttons: letter catchers, alt+letter relays and
734// the palette chord.
735function isHiddenKey(key: string): boolean {
736 return key.startsWith('catch-') || key.startsWith('alt-') || key.startsWith('hk-') || key === 'palette-toggle'
737}
738
739// Letter -> what its visible hotkey Button does, from the last drawing (a
740// built Button keeps its onPress). The later drawn wins, as the engine's own
741// hotkey matching does.
742let hotkeyPress = new Map<string, () => void>()
743// The same keys with their labels, for the key help (alt+/, lib/help.ts).
744let hotkeyLabels: { key: string; label: string }[] = []
745
746function collectHotkeys(tree: any): Map<string, () => void> {
747 const out = new Map<string, () => void>()
748 const labels: { key: string; label: string }[] = []
749 const walk = (n: any) => {
750 if (!n || typeof n !== 'object') return
751 const p = n.props ?? {}
752 const key = String(p.key ?? n.key ?? '')
753 // hk- buttons are hidden hotkeys that do something (the Chat tab's `i`);
754 // catch-/alt- ones only relay, and must not relay to themselves.
755 if (n.type === 'Button' && typeof p.hotkey === 'string' && /^[a-z]$/.test(p.hotkey) && !/^(catch-|alt-)/.test(key) && typeof n.onPress === 'function') {
756 out.set(p.hotkey, n.onPress)
757 labels.push({ key: p.hotkey, label: String(p.label ?? '') })
758 }
759 const kids = n.children ?? p.children
760 for (const k of Array.isArray(kids) ? kids : kids ? [kids] : []) walk(k)
761 }
762 walk(tree)
763 hotkeyLabels = labels
764 return out
765}
766
767// A Slack target as the policy holds it: an id. A #channel or @person the
768// person typed is looked up through the Slack MCP server; the name is kept
769// so the Policy tab shows it instead of the id.
770async function resolveSlackTarget($: any, s: string): Promise<{ id: string } | { error: string }> {
771 const kind = slackTargetKind(s)
772 if (kind === 'id') return { id: s.toUpperCase() }
773 if (!kind) return { error: 'slack: give a #channel, an @person, or an id (C…, U…)' }
774 try {
775 if (kind === 'channel') {
776 const names = ((await $.store.get(K.slackNames)) ?? {}) as Record<string, string>
777 const known = Object.entries(names).find(([, n]) => n.toLowerCase() === s.toLowerCase())?.[0]
778 if (known) return { id: known }
779 const r = await $.mcp.call(SLACK_SERVER, 'slack_search_channels', { keywords: [s.slice(1)], natural_language_query: s.slice(1), channel_types: 'public_channel,private_channel', limit: 10, response_format: 'concise' })
780 const id = parseChannelSearch(mcpText(r), s)
781 if (!id) return { error: 'slack: no channel named ' + s }
782 names[id] = s.toLowerCase()
783 await $.store.set(K.slackNames, names)
784 return { id }
785 }
786 const q = s.slice(1)
787 const r = await $.mcp.call(SLACK_SERVER, 'slack_search_users', { keywords: [q], natural_language_query: q, limit: 5, response_format: 'concise' })
788 const u = parseUserSearch(mcpText(r))
789 if (!u) return { error: 'slack: nobody found for ' + s }
790 const users = ((await $.store.get(K.slackUsers)) ?? {}) as Record<string, string>
791 users[u.id] = '@' + u.name
792 await $.store.set(K.slackUsers, users)
793 return { id: u.id }
794 } catch (err) {
795 return { error: 'slack lookup failed: ' + clip(String(err), 120) }
796 }
797}
798
799// A /policy argv with its slack target resolved to an id (argv and revert).
800async function resolvePolicyArgv($: any, argv: string[], revert?: string[]): Promise<{ argv: string[]; revert?: string[] } | { error: string }> {
801 if (argv[1] !== 'slack_send') return { argv, revert }
802 const i = argv.indexOf('--scope')
803 if (i < 0 || !argv[i + 1]) return { argv, revert }
804 const r = await resolveSlackTarget($, argv[i + 1]!)
805 if ('error' in r) return r
806 const fix = (a?: string[]) => a?.map((x, j) => (j > 0 && a[j - 1] === '--scope' ? r.id : x))
807 return { argv: fix(argv)!, revert: fix(revert) }
808}
809
810// Slack ids -> what to call them on the Policy tab.
811async function slackLabels($: any): Promise<Record<string, string>> {
812 const names = ((await $.store.get(K.slackNames)) ?? {}) as Record<string, string>
813 const users = ((await $.store.get(K.slackUsers)) ?? {}) as Record<string, string>
814 return { ...slackDefaultTargets(), ...names, ...users }
815}
816
817// Candidates beyond this session: prod contexts in the kubeconfig, repos of
818// recent sessions and tracked PRs on this machine, Slack channels named so far.
819// Listed under "known", after what this session itself touched.
820async function knownHints($: any): Promise<ScopeHint[]> {
821 const out: ScopeHint[] = []
822 for (const c of sources.contexts ?? []) if (isProdContext(c)) out.push({ cap: 'prod_eks_access', scope: c, why: 'known' }, { cap: 'prod_eks_write', scope: c, why: 'known' })
823 const sess = ((await $.store.get(K.sessions)) ?? {}) as Record<string, { repo?: string; at: number }>
824 const prs = ((await $.store.get(K.prs)) ?? {}) as Record<string, PrStatus>
825 const repos = new Set<string>([...Object.values(sess).sort((a, b) => b.at - a.at).map((x) => x.repo ?? '').filter(Boolean), ...Object.keys(prs).map((k) => parsePrKey(k)?.repo ?? '').filter(Boolean)])
826 for (const r of repos) out.push({ cap: 'gh_merge', scope: r, why: 'known' })
827 const names = ((await $.store.get(K.slackNames)) ?? {}) as Record<string, string>
828 for (const id of Object.keys(names)) out.push({ cap: 'slack_send', scope: id, why: 'known' })
829 return out
830}
831
832// What a resumed or reloaded session already did: its Bash commands' prod
833// targets and merge repos, guard denials, Slack sends. Once per module load.
834let hintsBackfilled = false
835async function backfillHints($: any) {
836 if (hintsBackfilled) return
837 hintsBackfilled = true
838 let msgs: BackfillMessage[] = []
839 try {
840 msgs = await $.session.messages()
841 } catch {
842 return
843 }
844 const add: ScopeHint[] = []
845 for (const m of msgs) {
846 for (const u of m.toolUses ?? []) {
847 const input = (u.input ?? {}) as Record<string, unknown>
848 if (u.tool === 'Bash' && typeof input.command === 'string') add.push(...hintsFromCommand(input.command, repoFromGh))
849 const text = String(u.text ?? '') + (u.result ? JSON.stringify(u.result) : '')
850 if (/BLOCKED by session policy/.test(text)) add.push(...hintsFromBlock(text))
851 if (/slack_(send_message|send_message_draft|schedule_message)$/.test(u.tool) && typeof input.channel_id === 'string') add.push({ cap: 'slack_send', scope: input.channel_id.toUpperCase(), why: 'used' })
852 }
853 }
854 if (add.length) await update($, hintsA, (hs) => [...add, ...hs].slice(-200))
855}
856
857async function bridge($: any, cmd: string, req: unknown): Promise<any> {
858 try {
859 const r = await $.process.run(['python3', $.plugin.root + '/bin/cc-bridge.py', cmd], {
860 // The session goes along so Jev calls it makes are counted as this session's.
861 stdin: JSON.stringify({ ...(req as object), session: sid }),
862 timeoutMs: 8000,
863 })
864 return JSON.parse(r.stdout || '{}')
865 } catch {
866 return {}
867 }
868}
869
870// Runs `fn` in one session at a time, machine-wide, with a shared backoff after
871// a failure (lib/lease.ts). False when another session holds it or everyone is
872// backing off; `fn` answers whether it succeeded. Two sessions that read a free
873// lease in the same instant may both run: the second read narrows that to the
874// gap between a write and a read, which is all this needs.
875async function withLease($: any, name: string, t: LeaseTimes, fn: () => Promise<boolean>, opts: { overHold?: boolean } = {}): Promise<boolean> {
876 if (!home || !sid) return (await fn().catch(() => false), true)
877 const file = home + '/.claude/state/cc-mods/lease/' + name + '.json'
878 // The engine's clock (wall time in a session; tests drive it).
879 const now = await clockNow($)
880 const before = parseLease(await readText($, file))
881 if (!mayRun(before, sid, now) && !(opts.overHold && before!.failUntil <= now)) return false
882 const mine = taken(before, sid, now, t)
883 const write = (l: Lease) => $.fs.write(file, JSON.stringify(l)).catch(() => undefined)
884 await write(mine)
885 const check = parseLease(await readText($, file))
886 if (check && check.by !== sid && !opts.overHold) return false
887 const ok = await fn().catch(() => false)
888 await write(settled(mine, await clockNow($), ok, t))
889 return true
890}
891
892async function readText($: any, path: string): Promise<string | undefined> {
893 try {
894 if (!(await $.fs.exists(path))) return undefined
895 const r = await $.fs.read(path)
896 return typeof r === 'string' ? r : r?.text
897 } catch {
898 return undefined
899 }
900}
901
902async function loadPolicy($: any) {
903 const text = sid ? await readText($, home + '/.claude/state/session_policy/' + sid + '.json') : undefined
904 const p = parsePolicy(text)
905 await update($, policyA, () => p)
906 void resumeUnblocked($, p)
907 const unlocked = p.caps.gh_merge === 'allow' || p.caps.prod_eks_write === 'allow' || p.caps.tmux_window_escape === 'allow' || Object.keys(p.scopes).length > 0
908 await publish($, undefined, null, { '@cc_policy': unlocked ? '1' : '' })
909 return p
910}
911
912async function runPolicy($: any, argv: string[]): Promise<{ ok: boolean; out: string }> {
913 try {
914 const r = await $.process.run(['python3', home + HOME_HOOKS + 'session_policy.py', ...argv], {
915 env: { CLAUDE_CODE_SESSION_ID: sid },
916 timeoutMs: 8000,
917 })
918 return { ok: r.exitCode === 0, out: (r.stdout || r.stderr).trim() }
919 } catch (err) {
920 return { ok: false, out: String(err) }
921 }
922}
923
924async function applyPolicy($: any, argv: string[], ttlMs?: number, revert?: string[]) {
925 const r = await runPolicy($, argv)
926 if (r.ok && ttlMs && revert) {
927 const label = argv.slice(1).join(' ')
928 policyTimers.get(label)?.timer.cancel()
929 const timer = $.clock.after(ttlMs, async () => {
930 policyTimers.delete(label)
931 await runPolicy($, revert)
932 await loadPolicy($)
933 $.ui.toast('policy expired: ' + label)
934 })
935 policyTimers.set(label, { expiresAt: Date.now() + ttlMs, revert, timer })
936 }
937 await update($, uiA, (u) => ({ ...u, policyMsg: r.ok ? 'applied: ' + argv.join(' ') : 'failed: ' + clip(r.out, 200) }))
938 await loadPolicy($)
939 return r
940}
941
942// God mode (lib/policy.ts GOD_MS): the policy from before the press, and the
943// timer that puts it back. Kept in the store too, so a reload of the mod
944// still ends it on time.
945let god: { until: number; before: Policy; timer?: { cancel: () => void } } | undefined
946
947async function startGod($: any, minutes: number) {
948 const before = (await read($, policyA)) as Policy | null
949 if (!before) return void $.ui.toast('policy not loaded yet')
950 let answer = 'cancel'
951 try {
952 answer = await $.ui.ask('Allow every capability (merge, prod EKS write, Slack to anyone, tmux outside this window …) for ' + minutes + ' minutes?', ['allow everything', 'cancel'])
953 } catch {
954 answer = 'cancel'
955 }
956 if (answer !== 'allow everything') return
957 const prior = god?.before ?? before
958 god?.timer?.cancel()
959 for (const argv of godArgvs()) await runPolicy($, argv)
960 const until = Date.now() + Math.min(minutes * 60_000, 2 * GOD_MS)
961 god = { until, before: prior, timer: $.clock.after(until - Date.now(), () => void endGod($, 'time is up')) }
962 await $.store.set(K.god(sid), { until, before: prior })
963 await loadPolicy($)
964 await update($, uiA, (u) => ({ ...u, policyMsg: 'god mode until ' + hhmm(until) }))
965 $.ui.toast('⚡ god mode: everything allowed until ' + hhmm(until))
966}
967
968async function endGod($: any, why: string) {
969 const g = god ?? (((await $.store.get(K.god(sid))) ?? undefined) as { until: number; before: Policy } | undefined)
970 god?.timer?.cancel()
971 god = undefined
972 await $.store.set(K.god(sid), null)
973 if (!g) return
974 for (const argv of restoreArgvs(g.before)) await runPolicy($, argv)
975 await loadPolicy($)
976 await update($, uiA, (u) => ({ ...u, policyMsg: 'god mode ended (' + why + '): policy put back' }))
977 $.ui.toast('god mode ended (' + why + '): policy put back')
978}
979
980// After a reload: an unexpired god mode keeps its end time; an expired one
981// is put back now.
982async function resumeGod($: any) {
983 const g = (await $.store.get(K.god(sid))) as { until: number; before: Policy } | undefined
984 if (!g) return
985 if (g.until <= Date.now()) return endGod($, 'time is up')
986 god = { ...g, timer: $.clock.after(g.until - Date.now(), () => void endGod($, 'time is up')) }
987}
988
989async function addOutputs($: any, add: Output[]) {
990 if (add.length === 0) return
991 // An archive folder spelled ~/exp/… is kept by its absolute path, one id.
992 add = add.map((o) => (o.kind === 'exp' && o.path?.startsWith('~/') ? { ...o, path: home + o.path.slice(1), id: 'exp:' + home + o.path.slice(1) } : o))
993 if (add.some((o) => o.kind === 'exp')) void refreshExp($, add.filter((o) => o.kind === 'exp').map((o) => o.path!))
994 await update($, outputsA, (prev) => mergeOutputs(prev, add))
995 const list = await read($, outputsA)
996 await $.store.set(K.outputs(sid), list)
997 // Track PRs this session touched so the shared poller fetches them.
998 if (add.some((o) => o.kind === 'ticket' || o.kind === 'pr')) void loadSources($)
999 // A thread Claude just read or replied to has been seen as of now.
1000 const slack = add.filter((o) => o.kind === 'slack')
1001 if (slack.length && interactive) {
1002 void pollSlack($).then(async () => {
1003 for (const o of slack) if (o.detail === 'read' || o.detail === 'replied' || o.detail === 'posted') await markSlackSeen($, o.id)
1004 })
1005 }
1006 // Only PRs this session acted on or was handed: the watch list is shared
1007 // by every session, and a mentioned PR is not one anyone here follows.
1008 const prs = add.filter((o) => o.kind === 'pr' && isMade(o))
1009 if (prs.length) {
1010 const watch = ((await $.store.get(K.prWatch)) ?? {}) as Record<string, { session: string; at: number }>
1011 for (const o of prs) watch[o.title] = { session: sid, at: Date.now() }
1012 await $.store.set(K.prWatch, watch)
1013 void pollPrs($)
1014 }
1015}
1016
1017// One GraphQL query per owner account. False when any account's query failed
1018// (no answer, a non-zero exit, the rate limit): the lease backs everyone off.
1019// The shared cache is written only when a query answered, so a failed tick
1020// does not rewrite the store for nothing.
1021async function pollPrsOnce($: any, refs: NonNullable<ReturnType<typeof parsePrKey>>[], cache: Record<string, PrStatus>, now: number): Promise<boolean> {
1022 let ok = true
1023 let answered = false
1024 // One query per owner account, since a token only sees its own private repos.
1025 const byAcct = new Map<string, typeof refs>()
1026 for (const r of refs) {
1027 const a = ghAccountFor(r.repo) ?? ''
1028 byAcct.set(a, [...(byAcct.get(a) ?? []), r])
1029 }
1030 for (const [acct, group] of byAcct) {
1031 const env = acct ? await ghEnv($, acct) : {}
1032 try {
1033 const r = await $.process.run(['gh', 'api', 'graphql', '-f', 'query=' + graphqlQuery(group)], { env, timeoutMs: 20000 })
1034 const got = parseGraphql(r.stdout, group, now)
1035 if (got.length) answered = true
1036 // gh exits non-zero on a network error, with nothing on stdout.
1037 if (r.exitCode !== 0 && !got.length) ok = false
1038 for (const s of got) {
1039 // GitOps summaries come from comments, read apart and only when the
1040 // head moved or 5 minutes passed; otherwise keep the last ones.
1041 const old = cache[s.key]
1042 if (s.iac?.kind === 'gitops') {
1043 const due = !old?.iac || old.head !== s.head || now - (gitopsRead.get(s.key) ?? 0) > 300_000
1044 if (due) {
1045 try {
1046 const c = await $.process.run(['gh', ...gitopsCommentsArgv(parsePrKey(s.key)!)], { env, timeoutMs: 20000 })
1047 if (c.exitCode === 0) {
1048 Object.assign(s.iac, gitopsFromComments(c.stdout, s.head))
1049 gitopsRead.set(s.key, now)
1050 }
1051 } catch {}
1052 } else if (old?.iac) Object.assign(s.iac, { argodiff: old.iac.argodiff, codex: old.iac.codex })
1053 }
1054 cache[s.key] = s
1055 }
1056 // The account's GraphQL budget is shared with every gh call on this
1057 // machine (other sessions' PR monitors spend most of it). When it is
1058 // gone the rows would silently keep old values; say so instead.
1059 if (/RATE_LIMIT|rate limit/i.test(r.stdout + r.stderr)) {
1060 ok = false
1061 await notePrLimit($, acct, env)
1062 } else if (prLimit?.account === acct) prLimit = undefined
1063 } catch {
1064 // network blip (or the 20 s timeout): keep the cached rows, back off
1065 ok = false
1066 }
1067 }
1068 if (answered) await $.store.set(K.prs, cache)
1069 // Backed off only when nothing answered: one account with a dead token (or
1070 // one PR GitHub will not show) must not slow every other account's PRs.
1071 return ok || answered
1072}
1073
1074async function pollPrs($: any) {
1075 const watch = ((await $.store.get(K.prWatch)) ?? {}) as Record<string, { session: string; at: number }>
1076 const cache = ((await $.store.get(K.prs)) ?? {}) as Record<string, PrStatus>
1077 const now = Date.now()
1078 // Poll what is open and was touched in the last 3 days; merged/closed stay cached.
1079 // This session's PRs first, then the most recently touched: the watch list
1080 // is shared by every session and grows, and the 25-PR cap used to drop
1081 // whatever was added last (a PR just opened here never got a status).
1082 const own = new Set(((await read($, outputsA)) as Output[]).filter((o) => o.kind === 'pr' && isMade(o)).map((o) => o.title))
1083 const keys = Object.keys(watch)
1084 .filter((k) => now - watch[k]!.at < 3 * 86_400_000 && (!cache[k] || cache[k]!.state === 'OPEN'))
1085 .sort((a, b) => Number(own.has(b)) - Number(own.has(a)) || watch[b]!.at - watch[a]!.at)
1086 .slice(0, 25)
1087 const refs = keys.map(parsePrKey).filter((r): r is NonNullable<typeof r> => !!r)
1088 const fresh = refs.length && keys.every((k) => cache[k] && now - cache[k]!.at < PR_POLL_MS / 2)
1089 if (refs.length && !fresh) {
1090 // A PR not polled yet (just opened here) goes over another session's hold;
1091 // a backoff after failures still stops it.
1092 // Only this session's own, once per PR: the watch list is shared, and a key
1093 // GitHub never answers for would otherwise let every session past the hold
1094 // on every tick.
1095 const unseen = keys.filter((k) => own.has(k) && !cache[k] && parsePrKey(k) && !overHeld.has(k))
1096 for (const k of unseen) overHeld.add(k)
1097 await withLease($, 'pr-poll', PR_LEASE, () => pollPrsOnce($, refs, cache, now), { overHold: unseen.length > 0 })
1098 }
1099 const mine = [...own].map((k) => cache[k]).filter((s): s is PrStatus => !!s)
1100 const before = (await read($, prsA)) as PrStatus[]
1101 await update($, prsA, () => mine)
1102 // A newly failing PR is worth telling, without starting a turn.
1103 for (const s of mine) {
1104 const old = before.find((b) => b.key === s.key)
1105 if (old && old.checks !== 'fail' && s.checks === 'fail') $.ui.toast('PR checks failing: ' + s.key)
1106 if (old && old.policy !== 'fail' && s.policy === 'fail') $.ui.toast('PR needs atlantis approve_policies: ' + s.key)
1107 if (old && old.state === 'OPEN' && s.state === 'MERGED') $.ui.toast('PR merged: ' + s.key)
1108 }
1109}
1110
1111function workflowNode(id: string, runId: string | undefined, toolUseId: string, parentId: string | undefined, taskId: string | undefined, description: string): AgentNode {
1112 return {
1113 id,
1114 toolUseId,
1115 parentId,
1116 turnId: currentTurn,
1117 type: 'workflow',
1118 description: clip(description, 80),
1119 prompt: runId ? 'run ' + runId : '',
1120 status: 'background',
1121 startedAt: Date.now(),
1122 tools: 0,
1123 kind: 'workflow',
1124 runId,
1125 taskId,
1126 }
1127}
1128
1129// PRs this session already polled past another session's hold (pollPrs).
1130const overHeld = new Set<string>()
1131
1132// When each GitOps PR's comments were last read (pollPrs).
1133const gitopsRead = new Map<string, number>()
1134
1135// The GitHub GraphQL limit, when the last poll hit it (shown on PR rows).
1136let prLimit: { account: string; until: number } | undefined
1137
1138async function notePrLimit($: any, account: string, env: Record<string, string>) {
1139 let until = Date.now() + 3600_000
1140 try {
1141 const r = await $.process.run(['gh', 'api', 'rate_limit', '--jq', '.resources.graphql.reset'], { env, timeoutMs: 8000 })
1142 const reset = Number(r.stdout.trim())
1143 if (reset > 0) until = reset * 1000
1144 } catch {}
1145 const first = !prLimit
1146 prLimit = { account, until }
1147 if (first && interactive) $.ui.toast('GitHub API limit for ' + (account || 'gh') + ': PR status is stale until ' + new Date(until).toTimeString().slice(0, 5))
1148}
1149
1150function prLimitNote(now: number): string | undefined {
1151 return prLimit && prLimit.until > now ? 'GitHub limit · resets ' + new Date(prLimit.until).toTimeString().slice(0, 5) : undefined
1152}
1153
1154// A token read is a gh process (and through the shim, pass): kept 10 minutes,
1155// not read again on every poll.
1156const ghTokens = new Map<string, { env: Record<string, string>; at: number }>()
1157async function ghEnv($: any, account: string): Promise<Record<string, string>> {
1158 const kept = ghTokens.get(account)
1159 if (kept && Date.now() - kept.at < 10 * 60_000) return kept.env
1160 try {
1161 const r = await $.process.run(['gh', 'auth', 'token', '--user', account], { timeoutMs: 5000 })
1162 const token = r.stdout.trim()
1163 if (r.exitCode !== 0 || !token) return {}
1164 ghTokens.set(account, { env: { GH_TOKEN: token }, at: Date.now() })
1165 return { GH_TOKEN: token }
1166 } catch {
1167 return {}
1168 }
1169}
1170
1171// A login done anywhere (a terminal, another session, the browser flow a
1172// button started) rewrites one of these files. Their mtimes are checked often
1173// and cheaply, so the radar follows a login within seconds instead of waiting
1174// out RADAR_MS with a stale "expired".
1175const CRED_WATCH_MS = 10_000
1176const credFiles = (h: string) => [
1177 ...(ssoCacheFile(h) ? [ssoCacheFile(h)!] : []),
1178 h + '/.config/argocd/config',
1179 h + '/.config/gh/hosts.yml',
1180 h + '/.claude/daemon-auth-status.json',
1181 h + '/Library/Application Support/coderv2/url',
1182 h + '/Library/Application Support/coderv2/session',
1183]
1184let credStamp: string | undefined
1185const RADAR_FORCE_LEASE: LeaseTimes = { runMs: 30_000, freshMs: 30_000, baseMs: 30_000, capMs: 5 * 60_000 }
1186
1187async function watchCredentials($: any): Promise<void> {
1188 if (!home) return
1189 const stamps = await Promise.all(credFiles(home).map((f) => $.fs.stat(f).then((st: any) => String(st?.mtimeMs ?? 0)).catch(() => '-')))
1190 const stamp = stamps.join(',')
1191 const changed = credStamp !== undefined && stamp !== credStamp
1192 credStamp = stamp
1193 // Every session sees the same file change within CRED_WATCH_MS: one of them
1194 // refreshes the radar for all, the others read it a little later, each at
1195 // its own delay so a store that lags does not send them out together.
1196 if (changed && !(await withLease($, 'radar-force', RADAR_FORCE_LEASE, async () => (await runRadar($, true), true)))) {
1197 $.clock.after(5_000 + spread(sid, 20_000), () => void runRadar($))
1198 }
1199 // The policy file too: a grant made from another terminal (or this one's
1200 // Policy tab in another module load) should resume blocked work as well.hooks/lib/browser.ts 42 lines1// Where this session's links open: tmux-chrome reads the pane's @browser option
2// (then the window's, then the global one) and sends a URL to a TWeb pane on
3// Electron, on the user's Chrome or on WebKit, or the window's Chrome tab group.
4// Claude Code opens links through $BROWSER (tmux-chrome-open), and that child
5// inherits this session's TMUX_PANE, so a pane option is per session.
6
7export const BROWSERS = ['tweb', 'tweb-chrome', 'tweb-webkit', 'chrome'] as const
8export type Browser = (typeof BROWSERS)[number]
9export type BrowserChoice = Browser | 'inherit'
10
11export const BROWSER_LABEL: Record<Browser, string> = {
12 tweb: 'TWeb (Electron)',
13 'tweb-chrome': 'TWeb on your Chrome',
14 'tweb-webkit': 'TWeb on WebKit',
15 chrome: 'Chrome tab group',
16}
17
18export function parseBrowser(s: unknown): BrowserChoice | undefined {
19 const v = String(s ?? '').trim()
20 if (v === '' || v === 'inherit') return 'inherit'
21 if (v === 'tweb-tauri') return 'tweb-webkit'
22 return (BROWSERS as readonly string[]).includes(v) ? (v as Browser) : undefined
23}
24
25// tmux argv that makes `pane` open links in `choice`; inherit removes the
26// pane's own value so the window or global option applies again.
27export function browserArgv(pane: string, choice: BrowserChoice): string[] {
28 return choice === 'inherit' ? ['set-option', '-p', '-t', pane, '-u', '@browser'] : ['set-option', '-p', '-t', pane, '@browser', choice]
29}
30
31// Where links from this pane go, most specific first: TMUX_CHROME_BROWSER,
32// the pane's @browser, the window's, the global one, TMUX_CHROME_TWEB=1 as
33// tweb, else the Chrome tab group. The same order tmux-chrome's open uses;
34// answered here instead of asking `tmux-chrome browser`, which a tmux-chrome
35// build without that subcommand answers with its usage text. A value outside
36// the known ones is chrome, as there: a typo must not lose a URL.
37export function resolveBrowser(v: { env?: string; pane?: string; window?: string; global?: string; twebFlag?: string }): Browser {
38 const raw = [v.env, v.pane, v.window, v.global].map((x) => (x ?? '').trim()).find(Boolean) || (v.twebFlag && v.twebFlag !== '0' ? 'tweb' : '')
39 const c = parseBrowser(raw)
40 return c && c !== 'inherit' ? c : 'chrome'
41}
42hooks/lib/web.ts 237 lines1// The cockpit's Web tab: a TWeb page drawn in a region of this pane by
2// bin/cc-browser. This file is the pure half: the marker the region draws so
3// the bridge can find it, and the terminal bytes a click or key becomes on
4// the way to the engine (which reads them as a tmux pane's input would be).
5
6export type WebState = {
7 running: boolean
8 engine?: WebEngine
9 url?: string
10 asked?: string // the URL that was opened, before redirects or a data: page
11 title?: string
12 shown?: boolean
13 floating?: boolean // TWeb float: the page shows as an OS window
14 error?: string
15}
16
17export const WEB_IDLE: WebState = { running: false }
18
19// The Web tab's field row (views/web.ts) is what bin/cc-browser finds the page
20// by: the line holding "│ go:" right of the column the mod names (the docked
21// cockpit's left edge, so transcript text never matches). The page starts two
22// rows below it, past the field's bottom border, at the column of that border; its size comes from the mod
23// (POST /region). Change the row and the bridge's ANCHOR together. A
24// zero-width marker was tried first: Claude Code drops those characters
25// before they reach the terminal (measured), so tmux never sees them.
26export const WEB_ANCHOR = '│ go:'
27
28// What the Web tab's address field sends to the page: a URL as typed when it
29// has a scheme, https:// added when it looks like a host (http:// for a local
30// dev server), a search otherwise.
31export function urlFromInput(text: string): string | undefined {
32 const t = text.trim()
33 if (!t) return undefined
34 if (/\s/.test(t)) return 'https://www.google.com/search?q=' + encodeURIComponent(t)
35 // host:port reads like a scheme, so only `x://` or a scheme without a host counts.
36 if (/^[a-z][\w+.-]*:\/\//i.test(t) || /^(about|data|file|chrome|mailto):/i.test(t)) return t
37 // A local dev server speaks plain http.
38 if (/^(localhost|127\.0\.0\.1)(:\d+)?(\/.*)?$/.test(t)) return 'http://' + t
39 if (/^[\w-]+(\.[\w-]+)+(:\d+)?(\/.*)?$/.test(t)) return 'https://' + t
40 return 'https://www.google.com/search?q=' + encodeURIComponent(t)
41}
42
43// Raw terminal bytes sent to the hosted TWeb engine.
44export function toHex(s: string): string {
45 let out = ''
46 for (const b of new TextEncoder().encode(s)) out += b.toString(16).padStart(2, '0')
47 return out
48}
49
50export type PointerMsg = {
51 t: 'p'
52 type: 'down' | 'move' | 'up' | 'enter' | 'leave'
53 x: number
54 y: number
55 button?: 'left' | 'middle' | 'right'
56 shift?: boolean
57 alt?: boolean
58 ctrl?: boolean
59}
60
61export type KeyMsg = { t: 'k'; key: string; ctrl?: boolean; shift?: boolean; meta?: boolean }
62
63const BUTTON = { left: 0, middle: 1, right: 2 } as const
64
65// SGR mouse (CSI < b ; x ; y M/m), 1-based cells of the region. A hover move
66// without a button reports button 3 with the motion bit, as xterm's
67// any-event mode does; enter and leave have no terminal form.
68export function sgrMouse(m: PointerMsg): string | undefined {
69 if (m.type === 'enter' || m.type === 'leave') return undefined
70 let b = m.button ? BUTTON[m.button] : 3
71 if (m.type === 'move') b |= 32
72 if (m.shift) b |= 4
73 if (m.alt) b |= 8
74 if (m.ctrl) b |= 16
75 const end = m.type === 'up' ? 'm' : 'M'
76 return '\x1b[<' + b + ';' + (Math.max(0, m.x) + 1) + ';' + (Math.max(0, m.y) + 1) + end
77}
78
79export function sgrWheel(up: boolean, x: number, y: number): string {
80 return '\x1b[<' + (up ? 64 : 65) + ';' + (Math.max(0, x) + 1) + ';' + (Math.max(0, y) + 1) + 'M'
81}
82
83const SPECIAL: Record<string, string> = {
84 return: '\r',
85 enter: '\r',
86 tab: '\t',
87 backspace: '\x7f',
88 delete: '\x1b[3~',
89 up: '\x1b[A',
90 down: '\x1b[B',
91 right: '\x1b[C',
92 left: '\x1b[D',
93 home: '\x1b[H',
94 end: '\x1b[F',
95 pageup: '\x1b[5~',
96 pagedown: '\x1b[6~',
97 space: ' ',
98}
99
100// A key as a terminal sends it in legacy encoding: what the engine's tmux-mode
101// input decoder reads. Ctrl+letter is the C0 control; meta (alt) prefixes ESC.
102export function keyBytes(k: KeyMsg): string | undefined {
103 let s = SPECIAL[k.key.toLowerCase()]
104 if (s === undefined) {
105 if ([...k.key].length !== 1) return undefined
106 s = k.key
107 if (k.ctrl && /^[a-z@[\\\]^_]$/i.test(s)) s = String.fromCharCode(s.toUpperCase().charCodeAt(0) & 0x1f)
108 } else if (k.shift && k.key.toLowerCase() === 'tab') {
109 s = '\x1b[Z'
110 }
111 return k.meta ? '\x1b' + s : s
112}
113
114// TWeb's page engines: Electron (Chromium, its own profile), Chrome (the
115// user's managed Chrome over CDP: Okta device trust, extensions) and WebKit
116// (macOS WKWebView through tweb-webkit; tweb's `tauri` is the same engine).
117export const WEB_ENGINES = ['electron', 'chrome', 'webkit'] as const
118export type WebEngine = (typeof WEB_ENGINES)[number]
119export const WEB_ENGINE_LABEL: Record<WebEngine, string> = { electron: 'Electron', chrome: 'Chrome', webkit: 'WebKit' }
120
121// `tauri` is tweb's old name for webkit; anything else is not an engine.
122export function parseEngine(s: unknown): WebEngine | undefined {
123 const v = String(s ?? '').trim().toLowerCase()
124 if (v === 'tauri' || v === 'safari') return 'webkit'
125 return (WEB_ENGINES as readonly string[]).includes(v) ? (v as WebEngine) : undefined
126}
127
128// The engine a browser target draws with, and back: tweb → electron,
129// tweb-chrome → chrome, tweb-webkit → webkit.
130export function engineOfTarget(target: string): WebEngine {
131 return target === 'tweb-chrome' ? 'chrome' : target === 'tweb-webkit' ? 'webkit' : 'electron'
132}
133
134// Which engine a page draws with, by its URL: `claude.ai=chrome` puts
135// claude.ai (and its subdomains) on Chrome, where the person's login and
136// device trust live. A pattern is a host (`*` matches any run of characters)
137// with an optional path prefix (`github.com/acme`); the first rule that
138// matches wins, and no match leaves the engine the tab would use anyway.
139export type EngineRule = { pattern: string; engine: WebEngine }
140
141// `claude.ai=chrome, *.okta.com chrome`: comma or newline separated, `=` or a
142// space between the pattern and the engine. Entries naming no engine are
143// dropped, so a typo never turns into a rule.
144export function parseEngineRules(text: unknown): EngineRule[] {
145 const out: EngineRule[] = []
146 for (const part of String(text ?? '').split(/[,\n]/)) {
147 const m = /^\s*(\S+?)\s*(?:=|\s)\s*(\S+)\s*$/.exec(part)
148 const engine = m ? parseEngine(m[2]) : undefined
149 const pattern = m ? normalizePattern(m[1]!) : ''
150 if (engine && pattern) out.push({ pattern, engine })
151 }
152 return out
153}
154
155export function formatEngineRules(rules: readonly EngineRule[]): string {
156 return rules.map((r) => r.pattern + '=' + r.engine).join(', ')
157}
158
159function normalizePattern(p: string): string {
160 return p.trim().toLowerCase().replace(/^[a-z][\w+.-]*:\/\//, '').replace(/\/+$/, '')
161}
162
163export function ruleMatches(pattern: string, url: string): boolean {
164 let u: URL
165 try {
166 u = new URL(url)
167 } catch {
168 return false
169 }
170 if (!/^https?:$/.test(u.protocol)) return false
171 const p = normalizePattern(pattern)
172 const slash = p.indexOf('/')
173 const hostPat = slash < 0 ? p : p.slice(0, slash)
174 const pathPat = slash < 0 ? '' : p.slice(slash)
175 const host = u.hostname.toLowerCase()
176 const hostOk = hostPat.includes('*')
177 ? new RegExp('^' + hostPat.split('*').map((x) => x.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$').test(host)
178 : host === hostPat || host.endsWith('.' + hostPat)
179 if (!hostOk) return false
180 if (!pathPat) return true
181 const path = u.pathname.toLowerCase()
182 return path === pathPat || path.startsWith(pathPat.endsWith('/') ? pathPat : pathPat + '/')
183}
184
185export function engineForUrl(url: string, rules: readonly EngineRule[]): WebEngine | undefined {
186 return rules.find((r) => ruleMatches(r.pattern, url))?.engine
187}
188
189// URLs this session has met, for the Web tab's go field: what it produced
190// (PRs, artifacts, tickets, Slack threads, links), newest first, one per URL,
191// narrowed by what is typed (every word, in the URL or the title).
192export type UrlPick = { url: string; title: string; kind: string }
193
194// Every URL in a text (answers, prompts) as a pick candidate, titled by its
195// host and path; trailing punctuation and markdown brackets are not the URL's.
196export function urlsIn(text: string, at: number): { url: string; title: string; kind: string; at: number }[] {
197 const out: { url: string; title: string; kind: string; at: number }[] = []
198 for (const m of text.matchAll(/https?:\/\/[^\s<>()\[\]`'"|]+/g)) {
199 const url = m[0].replace(/[.,;:!?*_]+$/, '')
200 let title = url
201 try {
202 const u = new URL(url)
203 title = u.host + (u.pathname.length > 1 ? u.pathname : '')
204 } catch {
205 continue
206 }
207 out.push({ url, title, kind: 'link', at })
208 }
209 return out
210}
211
212// Tickets, Slack threads and GitHub pages have their own tabs and buttons all
213// over the cockpit; what the Web tab is for is the rest (docs, dashboards,
214// consoles, the pages a session read), so those come first.
215export function pickRank(o: { url?: string; kind: string }): number {
216 const u = o.url ?? ''
217 if (o.kind === 'ticket' || /atlassian\.net\/browse\//.test(u)) return 1
218 if (o.kind === 'slack' || /\.slack\.com\//.test(u)) return 1
219 if (o.kind === 'pr' || /^https?:\/\/(www\.)?github\.com\//.test(u)) return 1
220 return 0
221}
222
223export function urlPicks(outputs: readonly { url?: string; title: string; kind: string; at: number }[], query: string, limit = 8): UrlPick[] {
224 const words = query.toLowerCase().split(/\s+/).filter(Boolean)
225 const seen = new Set<string>()
226 const out: UrlPick[] = []
227 for (const o of [...outputs].sort((a, b) => pickRank(a) - pickRank(b) || b.at - a.at)) {
228 if (!o.url || !/^https?:\/\//.test(o.url) || seen.has(o.url)) continue
229 const hay = (o.url + ' ' + o.title).toLowerCase()
230 if (!words.every((w) => hay.includes(w))) continue
231 seen.add(o.url)
232 out.push({ url: o.url, title: o.title, kind: o.kind })
233 if (out.length >= limit) break
234 }
235 return out
236}
237hooks/views/web.ts 184 lines1// The cockpit's Web tab: one address field with keyboard-context, settings and
2// settings and close beside them, and the region the TWeb page is drawn over
3// (views/web-region.ts). TWeb draws its own title, URL and history controls,
4// so the tab does not repeat them.
5
6import { WEB_ENGINES, WEB_ENGINE_LABEL, type UrlPick, type WebEngine } from '../lib/web'
7import type { WebState } from '../lib/web'
8import { clip } from '../lib/text'
9import { C, field, type Els, type Node } from './common'
10
11export type WebView = {
12 width: number
13 rows: number // the region's height in cells
14 web: WebState
15 embed: boolean // userConfig.browserEmbed
16 target: string // what tmux-chrome resolves for this pane
17 onCmd: (argv: string[]) => void
18 floating?: boolean // the page shows as an OS window (TWeb float), not in the pane
19 onClose: () => void
20 onOpen: (text: string) => void // the address field's Enter
21 settings: WebSettings
22 onSettings: (patch: Partial<WebSettings> & { open?: boolean }) => void
23 onDevtools: () => void
24 keyboard: 'controls' | 'page' // which keyboard context owns keys now
25 onPageKeyboard: () => void // alt+w / button: synthesize a page click
26 onControlsKeyboard: () => void // alt+w / button: focus the controls
27 // The go field's picker: open while the field (or a pick) holds the ring,
28 // the session's URLs narrowed by what is typed (lib/web.ts urlPicks).
29 picks?: readonly UrlPick[]
30 pickOpen?: boolean
31 pickFolded?: boolean // the person folded the list: one line with a count
32 onPickFold?: () => void
33 onAddress?: (value: string) => void // the go field changed
34}
35
36const PICK_GLYPH: Record<string, string> = { pr: '⇄', artifact: '◰', ticket: '◇', slack: '#', link: '↗' }
37
38// Rows the picker takes when open with `n` picks: one per pick and a hint.
39export function pickRows(n: number, folded = false): number {
40 return n ? (folded ? 1 : n + 1) : 0
41}
42
43// What each engine is for, under the engine pick.
44export const ENGINE_NOTE = 'Chrome: your managed Chrome (Okta device trust, extensions) · WebKit: macOS WebKit (Safari)'
45
46export type WebSettings = {
47 open: boolean // the settings rows are shown
48 engine: WebEngine // what /web and links open in this session
49 device: string // '' for the pane's own viewport, else a tweb device name
50 rate: number // most frames a second
51}
52
53export const WEB_DEVICES = ['', 'iPhone 12', 'iPad', 'Pixel 5'] as const
54export const WEB_RATES = [15, 30, 60] as const
55// Rows the settings take while open: engine, device, frame rate, DevTools.
56export const WEB_SETTINGS_ROWS = 4
57
58export function renderWeb(els: Els, v: WebView): Node {
59 const { Box, Button, Input, Text } = els
60 // The address field: a URL, a host, or words to search; Enter opens it here.
61 const address = Input({
62 key: 'web-address',
63 label: 'go',
64 // Clipped: a placeholder longer than the half-width field wraps it onto a
65 // second line and the two fields stop lining up.
66 placeholder: v.web.running ? clip(v.web.url ?? '', Math.max(10, Math.floor(v.width / 2) - 14)) : 'URL, host or search — Enter opens it here',
67 value: '',
68 submitLabel: 'open',
69 // With no page the field is all the tab offers: the keyboard lands on it.
70 ...(v.web.running ? {} : { autoFocus: true as const }),
71 onInput: (value) => v.onAddress?.(value),
72 onSubmit: (value) => {
73 if (value.trim()) v.onOpen(value)
74 },
75 })
76 // URLs from this session, one press to open; typing in the field narrows them.
77 const picker = v.pickOpen && v.picks?.length
78 ? [
79 Box({
80 flexDirection: 'column',
81 paddingX: 1,
82 children: [
83 Button({
84 key: 'web-pick-fold',
85 label: v.pickFolded ? 'show the session URLs' : 'fold the list',
86 plain: true,
87 onPress: () => v.onPickFold?.(),
88 children: [Text({ color: C.faint, children: [(v.pickFolded ? '▸ ' + v.picks.length + ' URLs from this session' : '▾ from this session · ↓ to pick, or type to narrow / enter a URL')] })],
89 }),
90 ...(v.pickFolded ? [] : v.picks).map((p, i) =>
91 Button({
92 key: 'web-pick-' + i,
93 label: p.url,
94 plain: true,
95 onPress: () => v.onOpen(p.url),
96 children: [Text({ color: C.accent, children: [(PICK_GLYPH[p.kind] ?? '↗') + ' '] }), Text({ children: [clip(p.title, 40) + ' '] }), Text({ color: C.muted, children: [clip(p.url, Math.max(20, v.width - 50))] })],
97 }),
98 ),
99 ],
100 }),
101 ]
102 : []
103 if (!v.web.running) {
104 const why = !v.embed
105 ? 'The Web tab is off (browserEmbed). Links open where /browser says.'
106 : v.target === 'chrome'
107 ? 'Links open in the Chrome tab group here. /browser tweb (or tweb-chrome, tweb-webkit) draws them in this tab.'
108 : 'No page. /web <url> opens one here; links this session opens land here too.'
109 return Box({ flexDirection: 'column', children: [field(els, address), ...picker, Box({ paddingX: 1, children: [Text({ color: C.muted, children: [v.web.error ? 'Browser stopped: ' + v.web.error : why] })] })] })
110 }
111 // This row is the anchor bin/cc-browser finds the page by (lib/web.ts
112 // WEB_ANCHOR). The page starts below the address field's bottom border.
113 return Box({
114 flexDirection: 'column',
115 children: [
116 // Settings sit above the field row: the row is the page's anchor and
117 // must stay directly over it.
118 ...(v.settings.open ? settingsRows(els, v) : []),
119 // The picker too: below the row it would sit under the page.
120 ...picker,
121 Box({
122 flexDirection: 'row',
123 alignItems: 'center',
124 columnGap: 1,
125 children: [
126 field(els, address, true),
127 Button({
128 key: 'web-keyboard',
129 label: v.keyboard === 'page' ? 'TWeb page keyboard' : 'cockpit controls keyboard',
130 variant: v.keyboard === 'page' ? 'primary' : undefined,
131 ...(v.keyboard === 'page' ? {} : { plain: true as const }),
132 onPress: v.keyboard === 'page' ? v.onControlsKeyboard : v.onPageKeyboard,
133 children: [Text({ color: v.keyboard === 'page' ? undefined : C.muted, bold: v.keyboard === 'page', children: [v.keyboard === 'page' ? '⌨ PAGE' : '⌘ CTRL'] })],
134 }),
135 Button({
136 key: 'web-float',
137 label: v.floating ? 'pin the page back into the pane' : 'float the page as a desktop window',
138 plain: true,
139 onPress: () => v.onCmd([v.floating ? 'pin' : 'float']),
140 children: [Text({ color: v.floating ? C.accent : C.muted, children: [v.floating ? '⧉ pin' : '⧉ float'] })],
141 }),
142 Button({
143 key: 'web-settings',
144 label: 'settings',
145 plain: true,
146 onPress: () => v.onSettings({ open: !v.settings.open }),
147 children: [Text({ color: v.settings.open ? C.accent : C.muted, children: ['⚙'] })],
148 }),
149 Button({ key: 'web-close', label: '✕', plain: true, onPress: () => v.onClose(), children: [Text({ color: C.accent, children: ['✕'] })] }),
150 ],
151 }),
152 els.Client({
153 key: 'web-region',
154 module: './web-region.ts',
155 props: { hint: v.web.shown ? '' : 'drawing the page…' },
156 width: v.width,
157 height: v.rows,
158 }),
159 ],
160 })
161}
162
163// One row per setting; the picked value is marked. Engine and frame rate
164// restart the page (same URL); device switches the running page in place.
165function settingsRows(els: Els, v: WebView): Node[] {
166 const { Box, Button, Text } = els
167 const pick = (group: string, label: string, on: boolean, press: () => void) =>
168 Button({ key: 'web-' + group + '-' + label, label, plain: true, onPress: press, children: [Text({ color: on ? C.accent : C.muted, bold: on, children: [(on ? '● ' : '○ ') + label] })] })
169 const row = (name: string, items: Node[], note = '') =>
170 Box({ flexDirection: 'row', columnGap: 2, children: [Text({ color: C.muted, children: [name.padEnd(8)] }), ...items, ...(note ? [Text({ color: C.faint, children: [note] })] : [])] })
171 const s = v.settings
172 const chrome = s.engine === 'chrome'
173 return [
174 row('engine', WEB_ENGINES.map((en) => pick('engine', WEB_ENGINE_LABEL[en], s.engine === en, () => v.onSettings({ engine: en }))), ENGINE_NOTE),
175 row('device', WEB_DEVICES.map((d) => pick('device', d || 'Pane', s.device === d, () => v.onSettings({ device: d })))),
176 row('fps', WEB_RATES.map((r) => pick('fps', String(r), s.rate === r, () => v.onSettings({ rate: r }))), 'drops to 4 at rest'),
177 row('devtools', [
178 chrome
179 ? Button({ key: 'web-devtools', label: 'open DevTools', plain: true, onPress: v.onDevtools, children: [Text({ color: C.accent, children: ['open DevTools beside this pane'] })] })
180 : Text({ color: C.faint, children: ['Chrome engine only'] }),
181 ]),
182 ]
183}
184hooks/views/webside.ts 219 lines1// The Web tab while pages open in a TWeb pane beside this one (lib/webside.ts):
2// the address field, that pane's tabs and the controls that drive it. TWeb's
3// pane draws the page and takes the keys; this tab only reports and commands.
4
5import { WEB_ENGINES, WEB_ENGINE_LABEL, type WebEngine } from '../lib/web'
6import { linkOwner, type AutoStep, type SidePanel, type SideWeb, type TwebLink } from '../lib/webside'
7import { clip } from '../lib/text'
8import { C, field, type Els, type Node } from './common'
9
10export type WebSideView = {
11 width: number
12 side: SideWeb
13 engine: WebEngine // what a new pane opens on (Web settings)
14 onOpen: (text: string) => void // the address field's Enter: navigate, or open a pane
15 onCmd: (argv: string[]) => void // a tweb command for the driven pane
16 onFocus: () => void // put the keyboard on the pane
17 onClose: () => void // close the pane
18 onEngine: (engine: WebEngine) => void
19 onTab: (index: number) => void
20 onNewTab: () => void
21 onCloseTab: (index: number) => void
22 auto: readonly AutoStep[] // browser commands agents ran, newest last
23 driving: boolean // one ran in the last few seconds
24 onPanel: (panel: SidePanel | undefined) => void
25 onClearPanel: () => void
26 panelRows: number // lines the open panel may take
27 self: string // this session's tmux pane, the owner the TWeb pane shows as linked
28 now: number
29}
30
31// How long after the person acted on a driven page the tab still says so.
32const CONFLICT_SHOWN_MS = 60_000
33
34const LEVEL_COLOR: Record<string, string> = { error: C.bad, warning: C.warn, warn: C.warn }
35
36function hhmmss(at: string | number): string {
37 const d = new Date(at)
38 return isNaN(d.getTime()) ? '' : d.toTimeString().slice(0, 8)
39}
40
41// The panel under the tabs: the pane's console or network (TWeb keeps them per
42// page, read with tweb console/network), or what agents did to the browser.
43function panel(els: Els, v: WebSideView): Node[] {
44 const { Box, Button, Text } = els
45 const s = v.side
46 const open = s.panel
47 const tab = (id: SidePanel, label: string) =>
48 Button({ key: 'web-side-panel-' + id, label, plain: true, onPress: () => v.onPanel(open === id ? undefined : id), children: [Text({ color: open === id ? C.accent : C.muted, bold: open === id, children: [(open === id ? '▾ ' : '▸ ') + label] })] })
49 const errors = (s.console ?? []).filter((c) => c.level === 'error').length
50 const head = Box({
51 flexDirection: 'row',
52 columnGap: 2,
53 paddingX: 1,
54 children: [
55 tab('console', 'console' + (errors ? ' · ' + errors + ' errors' : '')),
56 tab('network', 'network'),
57 tab('auto', 'automation' + (v.auto.length ? ' · ' + v.auto.length : '')),
58 ...(open && open !== 'auto' ? [Button({ key: 'web-side-panel-clear', label: 'clear', plain: true, onPress: v.onClearPanel, children: [Text({ color: C.faint, children: ['clear'] })] })] : []),
59 ],
60 })
61 if (!open) return [head]
62 const n = Math.max(3, v.panelRows)
63 const w = Math.max(20, v.width - 4)
64 const line = (key: string, parts: Node[]) => Box({ key, flexDirection: 'row', paddingX: 2, children: parts })
65 let rows: Node[] = []
66 if (open === 'console') {
67 rows = (s.console ?? []).slice(-n).map((c, i) =>
68 line('web-side-con-' + i, [Text({ color: C.faint, children: [hhmmss(c.at) + ' '] }), Text({ color: LEVEL_COLOR[c.level] ?? C.muted, children: [c.level.slice(0, 4).padEnd(5)] }), Text({ children: [clip(c.text, w - 16)] })]),
69 )
70 } else if (open === 'network') {
71 rows = (s.network ?? []).slice(-n).map((r, i) =>
72 line('web-side-net-' + i, [
73 Text({ color: r.failed || (r.status ?? 0) >= 400 ? C.bad : C.faint, children: [String(r.status ?? '…').padEnd(4)] }),
74 Text({ color: C.muted, children: [r.method.padEnd(5) + r.type.slice(0, 8).padEnd(9)] }),
75 Text({ children: [clip(r.url, w - 20)] }),
76 ]),
77 )
78 } else {
79 rows = v.auto.slice(-n).map((a, i) =>
80 line('web-side-auto-' + i, [
81 Text({ color: C.faint, children: [hhmmss(a.at) + ' '] }),
82 Text({ color: a.state === 'failed' ? C.bad : a.state === 'running' ? C.accent : C.ok, children: [(a.state === 'running' ? '◐ ' : a.state === 'failed' ? '✗ ' : '✓ ') + a.verb.padEnd(10)] }),
83 Text({ color: C.muted, children: [clip(a.detail, w - 30) + (a.pane ? ' ' + a.pane : '')] }),
84 ]),
85 )
86 }
87 const unread = (open === 'console' && !s.console) || (open === 'network' && !s.network)
88 const empty = open === 'auto' ? 'No browser commands from agents yet (tweb … in Bash, tweb MCP tools).' : unread ? 'reading ' + open + ' from pane ' + (s.pane ?? '') + '…' : 'Nothing recorded for this page yet.'
89 if (!rows.length) rows = [line('web-side-panel-empty', [Text({ color: C.faint, children: [empty] })])]
90 // Always n rows while open: switching panels, or records arriving, must not
91 // move what is drawn below.
92 while (rows.length < n) rows.push(line('web-side-pad-' + rows.length, [Text({ children: [' '] })]))
93 return [head, ...rows]
94}
95
96function upFor(ms: number): string {
97 const m = Math.floor(ms / 60_000)
98 return m < 60 ? m + 'm' : m < 48 * 60 ? Math.floor(m / 60) + 'h' + (m % 60 ? (m % 60) + 'm' : '') : Math.floor(m / 1440) + 'd'
99}
100
101// The mod ↔ TWeb link, first line of the tab: whether tweb answered the last
102// listing, its supervisor, and how many panes it hosts. An unreachable tweb
103// otherwise looks the same as "no pane beside".
104function linkLine(els: Els, link: TwebLink | undefined): Node {
105 const { Box, Text } = els
106 if (!link) return Box({ key: 'web-side-link', paddingX: 1, children: [Text({ color: C.faint, children: ['○ tweb · checking…'] })] })
107 if (link.state !== 'ok')
108 return Box({ key: 'web-side-link', paddingX: 1, children: [Text({ color: C.bad, bold: true, children: [link.state === 'missing' ? '✗ tweb not installed' : '✗ tweb unreachable'] }), Text({ color: C.muted, children: [' ' + clip(link.detail ?? '', 100)] })] })
109 const d = link.daemon
110 const bits = [d?.pid ? 'twebd ' + d.pid : 'twebd', d?.uptimeMs ? 'up ' + upFor(d.uptimeMs) : '', d?.panes !== undefined ? d.panes + ' pane' + (d.panes === 1 ? '' : 's') + ' hosted' : '', d?.engine ? 'engine ' + d.engine.replace(/\s*\(pid \d+\)/, '') : ''].filter(Boolean)
111 return Box({ key: 'web-side-link', paddingX: 1, children: [Text({ color: C.ok, children: ['● tweb linked'] }), Text({ color: C.faint, children: [' ' + bits.join(' · ')] })] })
112}
113
114// What the TWeb pane shows its person about this cockpit: whether the ⇄ link badge is up, and
115// whether they acted on the page while an agent drove it (a race the agent should know of).
116function paneSide(els: Els, v: WebSideView): Node {
117 const { Box, Text } = els
118 const s = v.side
119 const owner = linkOwner(v.self)
120 const link = s.presence?.link
121 const parts: Node[] = []
122 if (s.linkUnsupported) parts.push(Text({ color: C.warn, children: ['⇄ this tweb cannot show the link — update tweb'] }))
123 else if (link?.owner === owner) parts.push(Text({ color: C.ok, children: ['⇄ pane ' + s.pane + ' shows it is linked here'] }))
124 else if (link) parts.push(Text({ color: C.warn, children: ['⇄ pane ' + s.pane + ' is linked to ' + link.owner] }))
125 else parts.push(Text({ color: C.faint, children: ['⇄ linking pane ' + s.pane + '…'] }))
126 const conflict = s.presence?.conflictAt
127 if (conflict && v.now - conflict < CONFLICT_SHOWN_MS) parts.push(Text({ color: C.bad, bold: true, children: [' ⚠ the person acted on the page while an agent drove it'] }))
128 return Box({ key: 'web-side-paneside', flexDirection: 'row', paddingX: 1, children: parts })
129}
130
131export function renderWebSide(els: Els, v: WebSideView): Node {
132 const { Box, Button, Input, Text } = els
133 const s = v.side
134 const btn = (key: string, label: string, text: string, press: () => void, color: string = C.accent) =>
135 Button({ key, label, plain: true, onPress: press, children: [Text({ color, children: [text] })] })
136 const address = Input({
137 key: 'web-address',
138 label: 'go',
139 placeholder: s.pane ? 'URL, host or search — Enter opens it in pane ' + s.pane : 'URL, host or search — Enter opens a TWeb pane beside',
140 value: '',
141 submitLabel: 'open',
142 ...(s.pane ? {} : { autoFocus: true as const }),
143 onSubmit: (value) => {
144 if (value.trim()) v.onOpen(value)
145 },
146 })
147 const engines = Box({
148 flexDirection: 'row',
149 columnGap: 2,
150 children: [
151 Text({ color: C.muted, children: ['new pane on'] }),
152 ...WEB_ENGINES.map((en) =>
153 Button({ key: 'web-side-engine-' + en, label: WEB_ENGINE_LABEL[en], plain: true, onPress: () => v.onEngine(en), children: [Text({ color: v.engine === en ? C.accent : C.muted, bold: v.engine === en, children: [(v.engine === en ? '● ' : '○ ') + WEB_ENGINE_LABEL[en]] })] }),
154 ),
155 ],
156 })
157 const note = (text: string, color: string | undefined = C.muted) => Box({ paddingX: 1, children: [Text({ color, children: [text] })] })
158 if (!s.pane) {
159 return Box({
160 flexDirection: 'column',
161 children: [linkLine(els, s.link), field(els, address), Box({ paddingX: 1, children: [engines] }), note('No TWeb pane beside this one. Enter a URL to open one; links this session opens go there too.'), ...(v.auto.length ? panel(els, { ...v, side: { ...s, panel: 'auto' } }) : []), ...(s.error ? [note(s.error, C.warn)] : [])],
162 })
163 }
164 const active = s.tabs.find((t) => t.active)
165 const controls = Box({
166 flexDirection: 'row',
167 columnGap: 2,
168 paddingX: 1,
169 children: [
170 Text({ color: C.muted, children: ['pane ' + s.pane + ' · ' + WEB_ENGINE_LABEL[s.engine ?? 'electron']] }),
171 btn('web-side-back', 'back', '← back', () => v.onCmd(['back'])),
172 btn('web-side-forward', 'forward', '→ fwd', () => v.onCmd(['forward'])),
173 btn('web-side-reload', 'reload', '⟳ reload', () => v.onCmd(['reload'])),
174 btn('web-side-focus', 'focus the pane', '⌨ focus', v.onFocus),
175 btn('web-side-float', s.floating ? 'pin the page back into the pane' : 'float the page as a desktop window', s.floating ? '⧉ pin' : '⧉ float', () => v.onCmd([s.floating ? 'pin' : 'float'])),
176 ...(s.engine === 'chrome' ? [btn('web-side-devtools', 'open DevTools', 'devtools', () => v.onCmd(['devtools']))] : []),
177 btn('web-side-close', 'close the pane', '✕ close', v.onClose, C.warn),
178 // Last in the row: appearing and going, it moves no button. The pane shows the same badge
179 // (◐ AGENT) to the person at the page.
180 ...(v.driving ? [Text({ color: C.claude, bold: true, inverse: true, children: [' ◐ Claude is driving' + (s.presence?.driving ? ' · ' + s.presence.driving.method : '') + ' '] })] : []),
181 ],
182 })
183 const urlWidth = Math.max(20, v.width - 50)
184 const tabs = s.tabs.map((t) =>
185 Box({
186 key: 'web-side-tabrow-' + t.index,
187 flexDirection: 'row',
188 columnGap: 1,
189 paddingX: 1,
190 children: [
191 Button({
192 key: 'web-side-tab-' + t.index,
193 label: t.url || t.title,
194 plain: true,
195 onPress: () => v.onTab(t.index),
196 children: [Text({ color: t.active ? C.accent : C.muted, bold: t.active, children: [(t.active ? '● ' : '○ ') + clip(t.title || '(untitled)', 40) + ' '] }), Text({ color: C.faint, children: [clip(t.url, urlWidth)] })],
197 }),
198 btn('web-side-tabclose-' + t.index, 'close tab ' + t.index, '✕', () => v.onCloseTab(t.index), C.faint),
199 ],
200 }),
201 )
202 return Box({
203 flexDirection: 'column',
204 children: [
205 linkLine(els, s.link),
206 paneSide(els, v),
207 field(els, address),
208 controls,
209 ...(active ? [note(clip(active.title || active.url, v.width - 4), undefined)] : []),
210 Box({ paddingX: 1, children: [Text({ color: C.muted, children: ['tabs · ' + s.tabs.length] }), Text({ children: [' '] }), btn('web-side-newtab', 'new tab', '+ new tab', v.onNewTab)] }),
211 ...tabs,
212 ...panel(els, v),
213 Box({ paddingX: 1, children: [engines] }),
214 ...(s.others ? [note(s.others + ' more TWeb pane' + (s.others > 1 ? 's' : '') + ' in this window, not driven here', C.faint)] : []),
215 ...(s.error ? [note(s.error, C.warn)] : []),
216 ],
217 })
218}
219hooks/lib/webside.ts 224 lines1// The TWeb pane beside this session (browserEmbed off, the default): pages
2// open in a tmux pane right of Claude Code, and the Web tab shows that pane's
3// state and drives it with `tweb <command> --pane`. TWeb's own pane UI does
4// the drawing and takes the keys, so none of the docked page's keyboard
5// juggling applies.
6
7import type { WebEngine } from './web'
8
9export type SideTab = { index: number; title: string; url: string; active: boolean }
10
11export type SidePane = { pane: string; engine: WebEngine; tabs: SideTab[]; presence?: SidePresence }
12
13// What the TWeb pane itself shows about who else is at it (tweb's status.presence): an agent
14// driving it (any agent, not only this session's), the controller linked to it, and when the
15// person last acted while an agent drove. Absent from a tweb that predates it.
16export type SidePresence = { driving?: { method: string; at: number }; link?: { owner: string; expiresAt: number }; conflictAt?: number }
17
18export type SideWeb = {
19 pane?: string // the TWeb pane the tab drives, in this session's tmux window
20 engine?: WebEngine
21 tabs: SideTab[]
22 others: number // more TWeb panes in the window, not driven
23 floating?: boolean // floated by this tab; TWeb's status does not say
24 error?: string // the last command that failed, for one line under the controls
25 checked?: number // when the panes were last listed
26 panel?: SidePanel // the DevTools-like panel shown under the tabs
27 console?: ConsoleLine[] // the driven pane's latest console records, newest last
28 network?: NetLine[] // its latest requests, newest last
29 link?: TwebLink // whether the last listing reached tweb at all
30 presence?: SidePresence // the driven pane's own account of who is at it
31 linkedAt?: number // when this session last renewed its link to the driven pane
32 linkUnsupported?: boolean // the installed tweb has no `tweb link`
33}
34
35// The mod's line to TWeb, as the last listing found it. `missing`: no tweb on
36// PATH; `down`: tweb ran but its supervisor (twebd) did not answer. Without
37// this an unreachable tweb draws the same as "no pane beside".
38export type TwebLink = { state: 'ok' | 'down' | 'missing'; at: number; detail?: string; daemon?: TwebDaemon }
39
40export type TwebDaemon = { pid?: number; uptimeMs?: number; panes?: number; protocol?: number; engine?: string }
41
42export type SidePanel = 'console' | 'network' | 'auto'
43
44export type ConsoleLine = { at: string; level: string; text: string }
45export type NetLine = { at: string; method: string; status?: number; type: string; url: string; failed?: boolean }
46
47// One browser command an agent ran (Bash `tweb …`, a tweb MCP tool): the Web
48// tab's automation log, and the "Claude is driving" badge while recent.
49export type AutoStep = { id: string; at: number; verb: string; detail: string; pane?: string; state: 'running' | 'ok' | 'failed' }
50
51export const SIDE_EMPTY: SideWeb = { tabs: [], others: 0 }
52
53function engineOf(browser: unknown): WebEngine {
54 return browser === 'chrome' || browser === 'webkit' ? browser : 'electron'
55}
56
57// `tweb panes --json`, narrowed to the panes in `inWindow` (this session's
58// tmux window). A pane TWeb still lists can be gone from tmux for a moment
59// after it exits, so the window's own pane list is the liveness check too.
60// The entry's own `pane` is the id: an Electron pane reports `status.pane`
61// as null. `self` (this session's pane) is skipped: a page the cockpit hosts
62// is listed under it, and it is not a pane beside.
63export function parseTwebPanes(json: string, inWindow: ReadonlySet<string>, self?: string): SidePane[] {
64 let list: any
65 try {
66 list = JSON.parse(json)
67 } catch {
68 return []
69 }
70 if (!Array.isArray(list)) return []
71 const out: SidePane[] = []
72 for (const p of list) {
73 const id = typeof p?.pane === 'string' ? p.pane : undefined
74 if (!id || id === self || !inWindow.has(id)) continue
75 const tabs = Array.isArray(p.status?.tabs?.tabs) ? p.status.tabs.tabs : []
76 out.push({
77 pane: id,
78 presence: presenceOf(p.status?.presence),
79 engine: engineOf(p.status?.browser),
80 tabs: tabs.map((t: any, i: number) => ({ index: Number(t?.index ?? i), title: String(t?.title ?? ''), url: String(t?.url ?? ''), active: !!t?.active })),
81 })
82 }
83 return out
84}
85
86// `tweb daemon status`: one `key value` per line (it has no --json).
87export function parseDaemonStatus(text: string): TwebDaemon | undefined {
88 const kv = new Map<string, string>()
89 for (const line of text.split('\n')) {
90 const m = /^([a-z_]+)\s+(.+)$/.exec(line.trim())
91 if (m) kv.set(m[1]!, m[2]!.trim())
92 }
93 if (!kv.size) return undefined
94 const num = (k: string) => (kv.has(k) && Number.isFinite(Number(kv.get(k))) ? Number(kv.get(k)) : undefined)
95 return { pid: num('pid'), uptimeMs: num('uptime_ms'), panes: num('panes'), protocol: num('protocol'), engine: kv.get('engine') }
96}
97
98// What one listing's `tweb panes --json` run says about the link. A spawn that
99// throws, or a shell's 127, is a tweb that is not installed.
100export function linkOf(run: { exitCode?: number; stdout?: string; stderr?: string } | Error, at: number): TwebLink {
101 if (run instanceof Error) return /ENOENT|not found/i.test(run.message) ? { state: 'missing', at, detail: 'tweb is not on PATH' } : { state: 'down', at, detail: run.message.split('\n')[0] }
102 if (run.exitCode === 127) return { state: 'missing', at, detail: 'tweb is not on PATH' }
103 const last = (s?: string) => String(s ?? '').trim().split('\n').pop() ?? ''
104 if (run.exitCode !== 0) return { state: 'down', at, detail: last(run.stderr) || last(run.stdout) || 'tweb panes exited ' + run.exitCode }
105 try {
106 if (Array.isArray(JSON.parse(String(run.stdout ?? '')))) return { state: 'ok', at }
107 } catch {
108 // falls through
109 }
110 return { state: 'down', at, detail: 'tweb panes printed no pane list' }
111}
112
113function presenceOf(x: any): SidePresence | undefined {
114 if (!x || typeof x !== 'object') return undefined
115 const out: SidePresence = {}
116 if (x.driving && typeof x.driving.method === 'string') out.driving = { method: x.driving.method, at: Number(x.driving.at) || 0 }
117 if (x.link && typeof x.link.owner === 'string') out.link = { owner: x.link.owner, expiresAt: Number(x.link.expiresAt) || 0 }
118 if (typeof x.conflictAt === 'number') out.conflictAt = x.conflictAt
119 return out
120}
121
122// How this session names itself to the pane (`tweb link --owner`): what the pane shows as ⇄.
123export function linkOwner(self: string): string {
124 return 'cc-mods ' + self
125}
126
127// Seconds a link lives, and how often it is renewed. The Web tab lists every few seconds, other
128// tabs once a minute, so the ttl outlives the slowest renewal: the ⇄ badge must not blink off.
129export const LINK_TTL_S = 90
130export const LINK_RENEW_MS = 20_000
131
132export function linkArgv(pane: string, self: string): string[] {
133 return sideArgv(pane, ['link', '--owner', linkOwner(self), '--ttl', String(LINK_TTL_S)])
134}
135
136// The pane to drive: the one already driven while it lives, else one on the
137// wanted engine, else the first. A URL meant for managed Chrome must not land
138// in Electron (device trust fails), so `strict` refuses another engine.
139export function pickSidePane(panes: readonly SidePane[], want?: { engine?: WebEngine; prefer?: string; strict?: boolean }): SidePane | undefined {
140 const byEngine = want?.engine ? panes.filter((p) => p.engine === want.engine) : panes
141 const pool = want?.strict ? byEngine : byEngine.length ? byEngine : panes
142 return pool.find((p) => p.pane === want?.prefer) ?? pool[0]
143}
144
145// The argv that sends one `tweb` command to the pane.
146export function sideArgv(pane: string, argv: readonly string[]): string[] {
147 return ['tweb', ...argv, '--pane', pane]
148}
149
150// The tmux argv that opens a TWeb pane right of `beside`, taking the keyboard
151// there (an open the person asked for is to use the page). -P prints its id.
152export function splitArgv(beside: string, engine: WebEngine, url: string, cwd = ''): string[] {
153 return ['split-window', '-h', '-P', '-F', '#{pane_id}', '-t', beside, ...(cwd ? ['-c', cwd] : []), 'tweb', 'open', '--engine', engine, url]
154}
155
156function parseList(json: string, key: string): any[] {
157 try {
158 const v = JSON.parse(json)
159 return Array.isArray(v?.[key]) ? v[key] : Array.isArray(v) ? v : []
160 } catch {
161 return []
162 }
163}
164
165// `tweb console --json`: one line per record, the message's first line.
166export function parseConsole(json: string): ConsoleLine[] {
167 return parseList(json, 'messages').map((m: any) => ({
168 at: String(m?.at ?? ''),
169 level: String(m?.level ?? 'log'),
170 text: String(m?.message ?? '').split('\n')[0]!.replace(/%c/g, '').trim(),
171 }))
172}
173
174// `tweb network --json`.
175export function parseNetwork(json: string): NetLine[] {
176 return parseList(json, 'requests').map((r: any) => ({
177 at: String(r?.timestamp ?? ''),
178 method: String(r?.method ?? 'GET'),
179 status: typeof r?.statusCode === 'number' ? r.statusCode : undefined,
180 type: String(r?.resourceType ?? ''),
181 url: String(r?.url ?? ''),
182 failed: !!(r?.failed || r?.errorText),
183 }))
184}
185
186// Verbs that only read TWeb's own setup, not a page: left out of the log.
187const NOT_AUTOMATION = new Set(['help', 'config', 'daemon', 'doctor', 'skill', 'profile', 'engine-paths', 'engine-log', 'extensions', 'diag', 'mcp', 'chrome', 'resource', '--help', '-h', '--version', '-V'])
188
189// The `tweb <verb> …` commands in a shell command line, with their --pane.
190// Quoted arguments stay whole so `tweb fill e3 "a b"` reads as one value.
191export function twebSteps(command: string): { verb: string; detail: string; pane?: string }[] {
192 const out: { verb: string; detail: string; pane?: string }[] = []
193 for (const seg of command.split(/&&|\|\||;|\||\n/)) {
194 const words = seg.match(/"[^"]*"|'[^']*'|\S+/g) ?? []
195 const at = words.findIndex((w) => w === 'tweb' || /\/tweb$/.test(w))
196 if (at < 0) continue
197 const rest = words.slice(at + 1).map((w) => w.replace(/^(["'])(.*)\1$/, '$2'))
198 const verb = rest[0]
199 if (!verb || NOT_AUTOMATION.has(verb)) continue
200 let pane: string | undefined
201 const args: string[] = []
202 for (let i = 1; i < rest.length; i++) {
203 if (rest[i] === '--pane') pane = rest[++i]
204 else if (rest[i] !== '--json') args.push(rest[i]!)
205 }
206 out.push({ verb, detail: args.join(' ').slice(0, 160), pane })
207 }
208 return out
209}
210
211// A tweb MCP tool (mcp__tweb__click and the like): its verb and arguments.
212export function twebMcpStep(tool: string, input: unknown): { verb: string; detail: string; pane?: string } | undefined {
213 const m = /^mcp__[^_]*tweb[^_]*__(.+)$/i.exec(tool)
214 if (!m) return undefined
215 const o = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
216 const pane = typeof o.pane === 'string' ? o.pane : undefined
217 const detail = Object.entries(o)
218 .filter(([k]) => k !== 'pane')
219 .map(([k, v]) => k + '=' + (typeof v === 'string' ? v : JSON.stringify(v)))
220 .join(' ')
221 .slice(0, 160)
222 return { verb: m[1]!.replace(/^browser_/, ''), detail, pane }
223}
224hooks/views/help.ts 29 lines1// The key help drawn over the cockpit body (lib/help.ts), opened by alt+/.
2
3import type { HelpSection } from '../lib/help'
4import { clip } from '../lib/text'
5import { C, type Els, type Node } from './common'
6
7export function renderHelp(els: Els, v: { sections: readonly HelpSection[]; width: number; onClose: () => void }): Node {
8 const { Box, Button, Text } = els
9 const keyW = Math.min(20, Math.max(...v.sections.flatMap((s) => s.rows.map(([k]) => k.length))) + 2)
10 const textW = Math.max(10, v.width - keyW - 8)
11 return Box({
12 key: 'help',
13 flexDirection: 'column',
14 borderStyle: 'round',
15 borderColor: C.accent,
16 paddingX: 1,
17 children: [
18 ...v.sections.flatMap((s, i) => [
19 Text({ bold: true, color: C.accent, children: [(i ? '\n' : '') + s.title] }),
20 ...s.rows.map(([k, what]) =>
21 Box({ flexDirection: 'row', children: [Box({ width: keyW, flexShrink: 0, children: [Text({ bold: true, children: [k] })] }), Text({ color: C.muted, children: [clip(what, textW)] })] }),
22 ),
23 ]),
24 // Focused when help opens: Esc (a move off it) or Enter closes.
25 Button({ key: 'help-close', label: 'close', plain: true, onPress: v.onClose, children: [Text({ color: C.faint, children: ['\n alt+/ / Esc / Enter close'] })] }),
26 ],
27 })
28}
29hooks/lib/help.ts 66 lines1// The cockpit's key help (alt+/): every key it answers, grouped by where it
2// works. Pure: the fixed keys are data here; the open tab's own letter keys
3// come from what register.ts collected off the last drawing, so a tab that
4// gains a hotkey shows up without touching this list.
5
6export type HelpSection = { title: string; rows: [string, string][] }
7
8// What `?` lists. `tab` names the open tab; `tabKeys` are its letter hotkeys
9// with the label each Button carries, in drawing order.
10export function helpSections(tab: string, tabKeys: readonly { key: string; label: string }[]): HelpSection[] {
11 const seen = new Set<string>()
12 const own: [string, string][] = []
13 for (const k of tabKeys) {
14 // k is the palette everywhere; a key drawn twice (a filter row and a
15 // hidden twin) is listed once.
16 if (k.key === 'k' || seen.has(k.key)) continue
17 seen.add(k.key)
18 own.push([k.key, k.label])
19 }
20 return [
21 {
22 title: 'Anywhere',
23 rows: [
24 ['alt+tab', 'Claude ⇄ cockpit; from TWeb Page → Claude (alt+c too)'],
25 ['alt+w', 'Web tab: cockpit controls ⇄ TWeb Page keyboard'],
26 ['alt+← alt+→', 'wider / narrower cockpit, from the prompt too'],
27 ['alt+s', 'search settings, actions and places (from the prompt too)'],
28 ['alt+/', 'this help (the ? key with alt)'],
29 ],
30 },
31 {
32 title: 'Cockpit',
33 rows: [
34 ['1-9', 'tabs: Now Chat Work In/Out Policy Web Usage Auth Settings'],
35 ['k', 'search (as alt+s)'],
36 ['Tab ⇧Tab', 'next / previous element'],
37 ['↑↓←→', 'move by layout; scroll when nothing is that way'],
38 ['Enter', 'press the element'],
39 ['Esc', 'leave a text field (stays in the cockpit); close search or help'],
40 ['alt+<letter>', 'that letter key under Korean input (not c s h j k l)'],
41 ['ctrl+x x', 'close the cockpit'],
42 ],
43 },
44 ...(own.length ? [{ title: 'This tab (' + tab + ')', rows: own }] : []),
45 {
46 title: 'Search',
47 rows: [
48 ['type', 'filter; several words match in any order'],
49 ['↑↓ Tab', 'pick'],
50 ['Enter', 'run; a place puts the keyboard there'],
51 ['Esc alt+s', 'close'],
52 ],
53 },
54 {
55 title: 'Commands',
56 rows: [
57 ['/cockpit', 'open or close the cockpit'],
58 ['/side [q]', 'ask a side question (Chat tab)'],
59 ['/web [--chrome] url', 'open a page in the Web tab'],
60 ['/policy', 'show or change the session policy'],
61 ['/browser', 'where links open'],
62 ],
63 },
64 ]
65}
66hooks/views/palette.ts 159 lines1// The quick-access palette drawn over the cockpit body, and the catch-all
2// hotkeys that keep stray letters in the cockpit (lib/palette.ts).
3
4import { filterPalette, type PaletteItem, type PaletteState } from '../lib/palette'
5import { clip } from '../lib/text'
6import { C, type Els, type Node } from './common'
7
8// Every key a Button hotkey can take. The engine gives a focused pane's
9// unclaimed printable keys to the prompt, which then takes the keyboard;
10// a hotkey is the one thing that claims a printable key in a pane. These are
11// drawn first so the cockpit's own hotkeys, drawn later, win their keys.
12export const CATCH_KEYS = 'abcdefghijklmnopqrstuvwxyz0123456789'.split('')
13
14// The chord that opens and closes the palette, wherever the cockpit's focus
15// is (its query field included, where a letter hotkey cannot reach). A Button
16// presses on an engine action's chord; settings:search has no handler while
17// the cockpit is up, and ~/.claude/keybindings.json binds alt+s to it in
18// Pane and PaneField.
19export const PALETTE_ACTION = 'settings:search'
20
21// alt+<letter> relays: Korean input turns a bare letter into Hangul, so each
22// cockpit hotkey letter is also reachable as alt+<letter> (Ghostty sends alt
23// by physical key, ~/.config/ghostty/config). A Button presses on an engine
24// action's chord, not on a key, so each letter borrows an action no cockpit
25// screen handles; ~/.claude/keybindings.json binds alt+<letter> to it in Pane.
26// Not c (leaves), s (palette) or h/j/k/l (tmux takes M-h..M-l). k: alt+s.
27export const ALT_ACTIONS: Record<string, string> = {
28 a: 'theme:editCustom',
29 b: 'theme:toggleSyntaxHighlighting',
30 d: 'plugin:cycleMarketplace',
31 e: 'plugin:favorite',
32 f: 'plugin:install',
33 g: 'plugin:toggle',
34 i: 'settings:periodDay',
35 m: 'settings:periodWeek',
36 n: 'settings:retry',
37 o: 'settings:sortByTokens',
38 p: 'modelPicker:decreaseEffort',
39 q: 'modelPicker:increaseEffort',
40 r: 'modelPicker:thisSessionOnly',
41 t: 'effortSlider:decreaseEffort',
42 u: 'effortSlider:increaseEffort',
43 v: 'effortSlider:thisSessionOnly',
44 w: 'effortSlider:toggleUltracode',
45 x: 'agents:rename',
46 y: 'agents:togglePin',
47 z: 'historySearch:cycleScope',
48}
49
50// alt+c from the prompt: Global binds it to this borrowed action (a Chat
51// binding to another context's action never dispatches: measured), Pane and
52// the band rebind it to abovePrompt:leave, and a Button carrying it — the cockpit's while it is
53// open, the band's while it is not — focuses the cockpit, opening it first.
54// abovePrompt:focus did nothing with no pane open, and with a band button up
55// it picked the band.
56export const COCKPIT_ACTION = 'agents:setGroup'
57
58// alt+← / alt+→ from the prompt: widen / narrow the cockpit (register.ts
59// resizeFromPrompt). Global binds them to these borrowed actions; inside the
60// cockpit Pane binds the same chords to the engine's pane:grow / pane:shrink.
61export const GROW_ACTION = 'agents:previousGroup'
62export const SHRINK_ACTION = 'agents:nextGroup'
63
64// alt+/ (the ? key with alt) opens the key help. A bare `?` cannot: a hotkey
65// takes only a-z and 0-9, and an action Button presses only on a chord or a
66// modified key, so a bare `?` falls through to the prompt and opens Claude
67// Code's own help. ~/.claude/keybindings.json binds alt+/ to this borrowed
68// action in Pane and PaneField.
69export const HELP_ACTION = 'agents:find'
70// alt+w toggles the Web tab's keyboard between cockpit controls and the TWeb
71// page. Borrowed: no cockpit screen handles agents:switchView.
72export const WEB_KEYBOARD_ACTION = 'agents:switchView'
73// Esc, on a cockpit screen. The engine handles Escape itself and raises no
74// hook event for it (measured: no ui.focus, ui.close or ui.scroll), so a
75// Client in Page mode could never see it. ~/.claude/keybindings.json binds
76// escape in Pane and PaneField to this borrowed action; the hidden Button
77// sends 1b to TWeb in Page mode and otherwise does what Esc did (move the ring
78// to the element before).
79export const ESCAPE_ACTION = 'tabs:next'
80
81export function catchAll(els: Els, onKey: (key: string) => void, onToggle: () => void, letters = true, onAlt?: (key: string) => void, onCockpit?: () => void, onHelp?: () => void, onResize?: (dir: 'grow' | 'shrink') => void, onWebKeyboard?: () => void, onEscape?: () => void): Node {
82 const { Box, Button, Text } = els
83 return Box({
84 display: 'none',
85 children: [
86 ...(letters ? CATCH_KEYS : []).map((k) => Button({ key: 'catch-' + k, label: k, hotkey: k, plain: true, onPress: () => onKey(k), children: [Text({ children: [k] })] })),
87 Button({ key: 'palette-toggle', label: 'search', action: PALETTE_ACTION, plain: true, onPress: onToggle, children: [Text({ children: ['search'] })] }),
88 ...(onHelp ? [Button({ key: 'hk-help', label: 'keys', action: HELP_ACTION, plain: true, onPress: onHelp, children: [Text({ children: ['?'] })] })] : []),
89 ...(onEscape ? [Button({ key: 'hk-escape', label: 'escape', action: ESCAPE_ACTION, plain: true, onPress: onEscape, children: [Text({ children: ['escape'] })] })] : []),
90 ...(onWebKeyboard ? [Button({ key: 'hk-web-keyboard', label: 'web keyboard', action: WEB_KEYBOARD_ACTION, plain: true, onPress: onWebKeyboard, children: [Text({ children: ['web keyboard'] })] })] : []),
91 ...(onResize
92 ? [
93 Button({ key: 'hk-grow', label: 'wider', action: GROW_ACTION, plain: true, onPress: () => onResize('grow'), children: [Text({ children: ['wider'] })] }),
94 Button({ key: 'hk-shrink', label: 'narrower', action: SHRINK_ACTION, plain: true, onPress: () => onResize('shrink'), children: [Text({ children: ['narrower'] })] }),
95 ]
96 : []),
97 ...(onCockpit ? [Button({ key: 'hk-cockpit-focus', label: 'cockpit', action: COCKPIT_ACTION, plain: true, onPress: onCockpit, children: [Text({ children: ['cockpit'] })] })] : []),
98 ...(onAlt ? Object.entries(ALT_ACTIONS).map(([k, action]) => Button({ key: 'alt-' + k, label: 'alt+' + k, action, plain: true, onPress: () => onAlt(k), children: [Text({ children: ['alt+' + k] })] })) : []),
99 ],
100 })
101}
102
103// The key of the element right after the query field: the first result, or
104// the close row when nothing matches. An arrow from the field moves the focus
105// ring there (down) or to anything else (up); register.ts reads that to move
106// the selection instead (see its ui.focus hook).
107export function paletteNextKey(items: readonly PaletteItem[], query: string): string {
108 const first = filterPalette(items, query)[0]
109 return first ? 'palette-' + first.id : 'palette-close'
110}
111
112export function renderPalette(
113 els: Els,
114 v: { state: PaletteState; items: readonly PaletteItem[]; width: number; onQuery: (q: string) => void; onRun: (item: PaletteItem) => void; onClose: () => void },
115): Node {
116 const { Box, Button, Input, Text } = els
117 const shown = filterPalette(v.items, v.state.query)
118 const sel = Math.min(v.state.sel, Math.max(0, shown.length - 1))
119 const w = Math.max(30, v.width - 6)
120 return Box({
121 key: 'palette',
122 flexDirection: 'column',
123 borderStyle: 'round',
124 borderColor: C.accent,
125 paddingX: 1,
126 children: [
127 Input({
128 key: 'palette-query',
129 label: '⌘',
130 placeholder: 'search settings and actions · ↑↓ pick · Enter runs · Esc closes',
131 value: v.state.query,
132 autoFocus: true,
133 submitLabel: 'run',
134 onInput: (q) => v.onQuery(q),
135 onSubmit: () => {
136 const item = shown[sel]
137 if (item) v.onRun(item)
138 },
139 }),
140 ...(shown.length
141 ? shown.map((item, i) =>
142 Button({
143 key: 'palette-' + item.id,
144 label: item.title,
145 plain: true,
146 onPress: () => v.onRun(item),
147 children: [
148 Text({ color: i === sel ? C.accent : C.muted, children: [(i === sel ? '› ' : ' ') + item.group.padEnd(8)] }),
149 Text({ bold: i === sel, children: [clip(item.title, Math.max(10, w - 30))] }),
150 Text({ color: C.faint, children: [item.hint ? ' ' + clip(item.hint, 18) : ''] }),
151 ],
152 }),
153 )
154 : [Text({ color: C.muted, children: [' nothing matches "' + clip(v.state.query, 40) + '"'] })]),
155 Button({ key: 'palette-close', label: 'close', plain: true, onPress: v.onClose, children: [Text({ color: C.faint, children: [' ↑↓ pick · Enter runs · Esc / alt+s / ✕ close'] })] }),
156 ],
157 })
158}
159hooks/lib/palette.ts 146 lines1// The cockpit's quick-access palette (cmd+k in other apps): every setting and
2// action in one searchable list. Pure: the items are data with an `run` id the
3// register module maps to what it does, so the list and its ranking are tested
4// without the engine.
5
6export type PaletteItem = {
7 id: string // what register.ts runs for it
8 title: string
9 group: 'Tab' | 'Go' | 'Slack' | 'Policy' | 'Routing' | 'Browser' | 'Web' | 'Command'
10 hint?: string // the current value, or what it does
11 keywords?: string // extra words it is found by
12}
13
14export type PaletteState = { open: boolean; query: string; sel: number }
15
16export const PALETTE_CLOSED: PaletteState = { open: false, query: '', sel: 0 }
17
18// Fuzzy rank: every query character in order in the title or keywords. A
19// match at a word start or a run of consecutive characters scores higher; an
20// empty query keeps the list's order. Several words match each on its own
21// (any order, any of title/group/keywords), so "side question" finds "Ask a
22// side question" and "jira out" finds the In/Out Jira list; one fuzzy pass
23// over the whole query found nothing once the space had to match too.
24export function score(item: PaletteItem, query: string): number {
25 const q = query.trim().toLowerCase()
26 if (!q) return 1
27 const words = q.split(/\s+/)
28 const whole = scoreOne(item, q)
29 if (words.length < 2) return whole
30 let sum = 0
31 for (const w of words) {
32 const s = scoreOne(item, w)
33 if (s <= 0) return whole
34 sum += s
35 }
36 return Math.max(whole, sum / words.length)
37}
38
39function scoreOne(item: PaletteItem, q: string): number {
40 let best = 0
41 for (const hay of [item.title, item.group + ' ' + item.title, item.keywords ?? '']) {
42 const h = hay.toLowerCase()
43 if (!h) continue
44 if (h.includes(q)) {
45 // A word in the title beats the same word in the keywords: "pr" is
46 // Show PRs before the prod_eks_write grant whose cap id starts with it.
47 const title = hay === item.title ? 20 : 0
48 best = Math.max(best, 100 - h.indexOf(q) + (h.startsWith(q) || h.includes(' ' + q) ? 50 : 0) + title)
49 continue
50 }
51 let i = 0
52 let s = 0
53 let run = 0
54 for (const c of q) {
55 const j = h.indexOf(c, i)
56 if (j < 0) {
57 s = 0
58 break
59 }
60 run = j === i ? run + 1 : 0
61 s += 1 + run * 2 + (j === 0 || h[j - 1] === ' ' ? 3 : 0)
62 i = j + 1
63 }
64 best = Math.max(best, s)
65 }
66 return best
67}
68
69export function filterPalette(items: readonly PaletteItem[], query: string, limit = 12): PaletteItem[] {
70 return items
71 .map((item, i) => ({ item, i, s: score(item, query) }))
72 .filter((x) => x.s > 0)
73 .sort((a, b) => b.s - a.s || a.i - b.i)
74 .slice(0, limit)
75 .map((x) => x.item)
76}
77
78// The In/Out tab's lists (views/outputs.ts TABS) as places to go to.
79const OUT_PLACES: readonly (readonly [string, string, string])[] = [
80 ['pr', 'PRs', 'pr pull request github'],
81 ['artifact', 'docs and artifacts', 'docs artifact page'],
82 ['scratch', 'files', 'files scratch edits'],
83 ['ticket', 'Jira tickets', 'jira ticket issue'],
84 ['slack', 'Slack threads', 'slack thread'],
85 ['link', 'links', 'links url'],
86 ['bg', 'background tasks', 'tasks bg background monitor'],
87]
88
89// What the palette lists, from the cockpit's state. `policy` maps a capability
90// to its value; `caps` gives each capability's label.
91export function paletteItems(v: {
92 tabs: readonly { id: string; label: string; hotkey: string }[]
93 caps: readonly { cap: string; label: string; value: string }[]
94 route: { effort: boolean; model: boolean }
95 browser: string
96 web: { running: boolean; engine: string; device: string; rate: number }
97 slack?: readonly { url: string; title: string; detail?: string }[] // threads this session touched, newest first
98}): PaletteItem[] {
99 const items: PaletteItem[] = []
100 // `slack:<url>`: register.ts opens the thread where links open.
101 for (const t of v.slack ?? []) {
102 items.push({ id: 'slack:' + t.url, title: 'Open ' + t.title, group: 'Slack', hint: t.detail, keywords: 'slack thread channel' })
103 // `slack-follow:<url>`: sb:slack-followup on that thread.
104 items.push({ id: 'slack-follow:' + t.url, title: 'Follow up ' + t.title, group: 'Slack', hint: t.detail, keywords: 'slack thread follow up reply answer followup' })
105 }
106 // Extra words a tab is found by, beyond its name.
107 const tabWords: Record<string, string> = { usage: 'ccproxy quota tokens account model trend jev', web: 'browser tweb url', policy: 'merge prod gate slack', auth: 'login sso aws gh github argocd mcp connector credential token expired', settings: 'preferences config browser links routing keys' }
108 for (const t of v.tabs) items.push({ id: 'tab:' + t.id, title: 'Go to ' + t.label, group: 'Tab', hint: t.hotkey, keywords: t.id + ' ' + (tabWords[t.id] ?? '') })
109 // Places inside a tab: register.ts opens the tab there and puts the
110 // keyboard on that place (the side question field, the list's own tab).
111 items.push({ id: 'go:side', title: 'Ask a side question', group: 'Go', hint: 'Chat', keywords: 'side question ask aside btw chat' })
112 for (const [id, label, words] of OUT_PLACES) items.push({ id: 'out:' + id, title: 'Show ' + label, group: 'Go', hint: 'In/Out', keywords: 'outputs in out ' + words })
113 items.push({ id: 'go:out-filter', title: 'Filter In/Out by words', group: 'Go', hint: 'In/Out', keywords: 'outputs inputs in out filter search find grep keyword' })
114 items.push({ id: 'out-side:in', title: 'Show inputs (brought in, read, mentioned)', group: 'Go', hint: 'In/Out', keywords: 'outputs in out inputs read mentioned' })
115 for (const [id, label] of [['conclusions', 'conclusions'], ['decisions', 'decisions'], ['agents', 'agents']] as const) items.push({ id: 'chat:' + id, title: 'Chat ' + label, group: 'Go', hint: 'Chat', keywords: 'chat flow filter ' + id })
116 for (const c of v.caps) {
117 const next = c.value === 'allow' ? 'inherit' : 'allow'
118 items.push({ id: 'policy:' + next + ':' + c.cap, title: (next === 'allow' ? 'Allow ' : 'Stop allowing ') + c.label, group: 'Policy', hint: c.value, keywords: c.cap })
119 }
120 items.push({ id: 'go:scope', title: 'Allow a target (merge repo, prod ns, slack channel)', group: 'Go', hint: 'Policy', keywords: 'policy scope grant allow target' })
121 items.push({ id: 'route:effort:' + (v.route.effort ? 'off' : 'on'), title: 'Auto effort ' + (v.route.effort ? 'off' : 'on'), group: 'Routing', hint: v.route.effort ? 'on' : 'off', keywords: 'jev effort model routing' })
122 items.push({ id: 'route:model:' + (v.route.model ? 'off' : 'on'), title: 'Auto model ' + (v.route.model ? 'off' : 'on'), group: 'Routing', hint: v.route.model ? 'on' : 'off', keywords: 'jev sonnet routing' })
123 for (const b of ['tweb', 'tweb-chrome', 'tweb-webkit', 'chrome', 'inherit']) {
124 if (b !== v.browser) items.push({ id: 'browser:' + b, title: 'Open links in ' + b, group: 'Browser', hint: v.browser, keywords: 'browser links tmux-chrome' })
125 }
126 items.push({ id: 'web:open', title: 'Open a page in the Web tab', group: 'Web', keywords: 'url browse tweb' })
127 if (v.web.running) {
128 for (const [en, label] of [['electron', 'Electron'], ['chrome', 'Chrome'], ['webkit', 'WebKit']] as const) {
129 if (en !== v.web.engine) items.push({ id: 'web:engine:' + en, title: 'Web engine ' + label, group: 'Web', hint: v.web.engine, keywords: 'tweb engine' + (en === 'webkit' ? ' tauri safari' : '') })
130 }
131 items.push({ id: 'web:float', title: 'Float the page as a desktop window (or pin it back)', group: 'Web', keywords: 'float window detach pin tweb w' })
132 items.push({ id: 'web:devtools', title: 'Open DevTools', group: 'Web', hint: v.web.engine === 'chrome' ? '' : 'Chrome engine only', keywords: 'inspect console' })
133 for (const d of ['', 'iPhone 12', 'iPad', 'Pixel 5']) {
134 if (d !== v.web.device) items.push({ id: 'web:device:' + d, title: 'Web device ' + (d || 'pane viewport'), group: 'Web', hint: v.web.device || 'pane', keywords: 'mobile emulate viewport' })
135 }
136 for (const r of [15, 30, 60]) if (r !== v.web.rate) items.push({ id: 'web:fps:' + r, title: 'Web frame rate ' + r, group: 'Web', hint: String(v.web.rate), keywords: 'fps frames' })
137 items.push({ id: 'web:close', title: 'Close the Web page', group: 'Web', keywords: 'stop quit' })
138 }
139 items.push({ id: 'cmd:auth', title: 'Refresh the credential radar', group: 'Command', keywords: 'auth login sso' })
140 items.push({ id: 'cmd:share', title: "Copy this session's identity", group: 'Command', keywords: 'share handoff session id transcript copy' })
141 items.push({ id: 'cmd:rca-end', title: 'End RCA mode (revert its grants)', group: 'Command', keywords: 'rca incident alert off' })
142 items.push({ id: 'cmd:fold', title: 'Fold finished tool calls', group: 'Command', keywords: 'transcript' })
143 items.push({ id: 'cmd:close', title: 'Close the cockpit', group: 'Command', keywords: 'hide exit' })
144 return items
145}
146hooks/lib/route.ts 117 lines1// Picking the effort (and, if asked, the model) for a turn from what the
2// person asked. The judging is Jev's (bin/cc-bridge.py route); this is the
3// pure part: when its answer is trusted, and what a step is then sent with.
4
5export type Effort = 'low' | 'medium' | 'high' | 'xhigh'
6
7export type RouteSettings = {
8 effort: boolean // rewrite each main step's effort
9 model: boolean // also pick the model (re-caches the whole context on a switch)
10}
11
12export const ROUTE_OFF: RouteSettings = { effort: false, model: false }
13
14// Routing is two session-policy capabilities, on only when allowed: inherit
15// (the default) adds nothing, block forbids it.
16export function routeSettings(caps: { auto_effort?: string; auto_model?: string } | undefined): RouteSettings {
17 return { effort: caps?.auto_effort === 'allow', model: caps?.auto_model === 'allow' }
18}
19
20export type RouteAnswer = { effort?: string | null; confidence?: number }
21
22export type RouteDecision = {
23 effort?: Effort
24 model?: string
25 confidence: number
26 // Why nothing was changed, when nothing was.
27 kept?: 'off' | 'unsure' | 'no-answer' | 'no-effort-model' | 'cache'
28}
29
30// Below this Jev's pick is a guess and the session's own setting stands.
31export const ROUTE_THRESHOLD = 0.75
32
33const EFFORTS: readonly string[] = ['low', 'medium', 'high', 'xhigh']
34
35// Light work goes to Sonnet; anything harder keeps the session's model rather
36// than reaching for one, since a switch costs a re-cache either way.
37const MODEL_FOR: Partial<Record<Effort, string>> = { low: 'sonnet', medium: 'sonnet' }
38
39// `stepEffort` is the effort the engine was about to send: absent means the
40// model takes none, and then there is nothing to rewrite.
41export function decide(settings: RouteSettings, answer: RouteAnswer | undefined, stepEffort: unknown): RouteDecision {
42 const confidence = typeof answer?.confidence === 'number' ? answer.confidence : 0
43 if (!settings.effort && !settings.model) return { confidence, kept: 'off' }
44 if (!answer || typeof answer.effort !== 'string' || !EFFORTS.includes(answer.effort)) return { confidence, kept: 'no-answer' }
45 if (confidence < ROUTE_THRESHOLD) return { confidence, kept: 'unsure' }
46 const effort = answer.effort as Effort
47 const out: RouteDecision = { confidence }
48 if (settings.effort) {
49 if (stepEffort === undefined) out.kept = 'no-effort-model'
50 else out.effort = effort
51 }
52 if (settings.model && MODEL_FOR[effort]) out.model = MODEL_FOR[effort]
53 return out
54}
55
56// The full model id for a routed family. turn.step takes the id as sent: an
57// alias ('sonnet') went out raw, and a proxy that does not map aliases (cco)
58// answered "issue with the selected model (sonnet)". The id comes from
59// ANTHROPIC_DEFAULT_<FAMILY>_MODEL when set, else the step's own id with its
60// family swapped (claude-opus-5-5 → claude-sonnet-5-5). The step's [1m] marker
61// is kept as it is: the context the session runs with must not shrink.
62export function resolveModel(family: string, stepModel: string, envModel: string | undefined): string | undefined {
63 const oneM = /\[1m\]$/.test(stepModel)
64 let base = envModel?.replace(/\[1m\]$/, '').trim()
65 if (!base) {
66 const m = /^claude-([a-z]+)-(.+?)(\[1m\])?$/.exec(stepModel)
67 if (!m) return undefined
68 base = 'claude-' + family + '-' + m[2]
69 }
70 if (!/^claude-[a-z]+-\d/.test(base)) return undefined
71 if (base.includes('-' + family + '-') === false) return undefined
72 return base + (oneM ? '[1m]' : '')
73}
74
75// What the previous main step was sent with, and when.
76export type RouteLast = { effort?: unknown; model: string; at: number }
77
78// Prompt-cache friendliness. Changing effort invalidates the messages cache
79// (docs, and measured here: medium↔high read 29k and rewrote ~600k), and a
80// model switch writes the new model's cache from scratch. On a large, warm
81// context that costs more than lower effort saves, so the pick is applied only
82// at a cache boundary: the first step, a small context, or a cache already
83// cold (idle past the TTL). Otherwise the previous step's effort and model are
84// sent again, so a held pick never flips the session back either.
85export const CACHE_SMALL_TOKENS = 30_000
86export const CACHE_TTL_MS = 60 * 60_000
87
88export function cacheGate(d: RouteDecision, s: { stepEffort: unknown; stepModel: string; last?: RouteLast; ctxTokens?: number; now: number }): RouteDecision {
89 if (d.kept && d.kept !== 'no-effort-model') return d
90 if (!s.last) return d
91 const cold = s.now - s.last.at > CACHE_TTL_MS
92 const small = typeof s.ctxTokens === 'number' && s.ctxTokens < CACHE_SMALL_TOKENS
93 if (cold || small) return d
94 const wantEffort = d.effort ?? s.stepEffort
95 const wantModel = d.model ?? s.stepModel
96 const changes = wantEffort !== s.last.effort || (d.model !== undefined && wantModel !== s.last.model)
97 if (!changes) return d
98 // Hold: send what the previous step was sent with.
99 return {
100 confidence: d.confidence,
101 kept: 'cache',
102 ...(s.last.effort !== s.stepEffort && typeof s.last.effort === 'string' ? { effort: s.last.effort as Effort } : {}),
103 ...(s.last.model !== s.stepModel ? { model: s.last.model } : {}),
104 }
105}
106
107export function routeLabel(d: RouteDecision | undefined): string {
108 if (!d) return 'no decision yet'
109 if (d.kept === 'off') return 'off'
110 if (d.kept === 'no-answer') return 'no answer from Jev; kept the session setting'
111 if (d.kept === 'cache') return 'held the previous setting to keep the prompt cache warm (' + Math.round(d.confidence * 100) + '%)'
112 if (d.kept === 'unsure') return 'unsure (' + Math.round(d.confidence * 100) + '%); kept the session setting'
113 const parts = [d.effort ? 'effort ' + d.effort : '', d.model ? 'model ' + d.model : ''].filter(Boolean)
114 const note = d.kept === 'no-effort-model' ? ' · this model takes no effort' : ''
115 return (parts.join(' · ') || 'unchanged') + ' (' + Math.round(d.confidence * 100) + '%)' + note
116}
117hooks/lib/usage.ts 226 lines1// ccproxy's view of this session (`ccproxy usage --session <sid> --json`)
2// beside each account's quota (`ccproxy status --json`): which
3// account and model served the last request, how this session splits across
4// them, and how its output has trended. Pure: parse and draw-ready strings.
5
6type Tokens = { input: number; output: number; cache_read: number; cache_create: number }
7
8export type UsageGroup = {
9 name: string
10 requests: number
11 tokens: Tokens
12 // Summed request time and requests by effort; ccproxy before PR #265 has neither.
13 ms?: number
14 efforts?: Record<string, number>
15}
16
17export type Usage = {
18 requests: number
19 last?: { at: number; account?: string; model?: string; effort?: string; status: number; reason?: string; ms: number; tokens: { output: number; cache_read: number } }
20 totals: Tokens
21 accounts: UsageGroup[]
22 models: UsageGroup[]
23 trend: { start: number; requests: number; output: number; input?: number; cache_read?: number; cache_create?: number; models?: Record<string, number>; by_model?: Record<string, UsageSlice>; by_account?: Record<string, UsageSlice> }[]
24 bucket_seconds: number
25 firstAt?: number
26}
27
28// One model's or account's share of a trend bucket (ccproxy PR #267).
29export type UsageSlice = { requests: number; output: number; input: number; cache_read: number }
30
31// One series per model or per account across the trend, busiest first: what
32// each drew in each bucket, 0 where it drew nothing. Empty from a ccproxy that
33// does not split its buckets.
34export function seriesBy(trend: Usage['trend'], by: 'by_model' | 'by_account', field: 'output' | 'requests' = 'output'): { name: string; values: number[]; total: number }[] {
35 const names = new Map<string, number>()
36 for (const b of trend) for (const [n, sl] of Object.entries(b[by] ?? {})) names.set(n, (names.get(n) ?? 0) + sl[field])
37 return [...names.entries()]
38 .sort((a, b) => b[1] - a[1])
39 .map(([name, total]) => ({ name, total, values: trend.map((b) => b[by]?.[name]?.[field] ?? 0) }))
40}
41
42// Which account served each trend bucket: one letter per account (its index
43// in `names`, a b c …), upper case when it served all the bucket's requests,
44// a space for an empty bucket; and how often the session moved between
45// accounts from one busy bucket to the next.
46export function accountStrip(trend: Usage['trend'], names: readonly string[]): { strip: string; switches: number } {
47 let prev = ''
48 let switches = 0
49 const strip = trend
50 .map((b) => {
51 const e = Object.entries(b.by_account ?? {}).sort((x, y) => y[1].requests - x[1].requests)
52 if (!e.length) return ' '
53 const top = e[0]![0]
54 if (prev && top !== prev) switches++
55 prev = top
56 const i = names.indexOf(top)
57 const ch = i >= 0 && i < 26 ? String.fromCharCode(97 + i) : '?'
58 return e.length === 1 ? ch.toUpperCase() : ch
59 })
60 .join('')
61 return { strip, switches }
62}
63
64const NO_TOKENS: Tokens = { input: 0, output: 0, cache_read: 0, cache_create: 0 }
65
66export function parseUsage(json: string | undefined): Usage | undefined {
67 let raw: any
68 try {
69 raw = json ? JSON.parse(json) : undefined
70 } catch {
71 return undefined
72 }
73 if (!raw || typeof raw.requests !== 'number' || !Array.isArray(raw.trend)) return undefined
74 const group = (g: any): UsageGroup => ({ ...g, tokens: { ...NO_TOKENS, ...(g?.tokens ?? {}) } })
75 return {
76 requests: raw.requests,
77 last: raw.last ?? undefined,
78 totals: { ...NO_TOKENS, ...(raw.totals ?? {}) },
79 accounts: Array.isArray(raw.accounts) ? raw.accounts.map(group) : [],
80 models: Array.isArray(raw.models) ? raw.models.map(group) : [],
81 trend: raw.trend,
82 bucket_seconds: Number(raw.bucket_seconds) || 300,
83 firstAt: Number(raw.first_at) || undefined,
84 }
85}
86
87// What share of the prompt was served from cache: reads over everything the
88// requests sent (fresh input, cache writes and reads). '' with nothing sent.
89export function cacheHit(t: Tokens): string {
90 const sent = t.input + t.cache_create + t.cache_read
91 return sent > 0 ? Math.round((t.cache_read / sent) * 100) + '%' : ''
92}
93
94// "medium 98% · high 2%": a group's requests by effort, most used first.
95export function effortMix(efforts: Record<string, number> | undefined): string {
96 const e = Object.entries(efforts ?? {}).sort((a, b) => b[1] - a[1])
97 const n = e.reduce((x, [, c]) => x + c, 0)
98 return e.map(([k, c]) => k + ' ' + share(c, n)).join(' · ')
99}
100
101// One letter per trend bucket: the family of the model that served most of its
102// requests (o opus, s sonnet, h haiku, f fable, ? other), upper case when that
103// model served all of them; a space for an empty bucket.
104export function modelStrip(trend: Usage['trend']): string {
105 return trend
106 .map((b) => {
107 const e = Object.entries(b.models ?? {}).sort((x, y) => y[1] - x[1])
108 if (!e.length) return ' '
109 const fam = /opus/.test(e[0]![0]) ? 'o' : /sonnet/.test(e[0]![0]) ? 's' : /haiku/.test(e[0]![0]) ? 'h' : /fable/.test(e[0]![0]) ? 'f' : '?'
110 return e.length === 1 ? fam.toUpperCase() : fam
111 })
112 .join('')
113}
114
115// A bucket width as 5m / 2h / 2h15m.
116export function span(seconds: number): string {
117 const m = Math.round(seconds / 60)
118 const h = Math.floor(m / 60)
119 return h ? h + 'h' + (m % 60 ? (m % 60) + 'm' : '') : m + 'm'
120}
121
122const BARS = '▁▂▃▄▅▆▇█'
123
124// One glyph per bucket, scaled to the window's busiest bucket; an empty bucket
125// is a space so a quiet stretch reads as a gap, not as low activity.
126export function sparkline(values: readonly number[]): string {
127 const max = Math.max(0, ...values)
128 if (max === 0) return values.map(() => ' ').join('')
129 return values.map((v) => (v <= 0 ? ' ' : BARS[Math.min(BARS.length - 1, Math.floor((v / max) * (BARS.length - 1)))]!)).join('')
130}
131
132// claude-opus-5-5 → opus-5-5; a dated id keeps its family and version.
133export function shortModel(model: string | undefined): string {
134 if (!model) return '?'
135 return model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
136}
137
138// "52%" of a group's requests, for a split line.
139export function share(n: number, total: number): string {
140 return total > 0 ? Math.round((n / total) * 100) + '%' : '0%'
141}
142
143// Each ccproxy account's quota as `ccproxy status --json` gives it, kept raw
144// enough to draw windows with their reset times.
145export type QuotaWindow = { label: string; left: number; resetAt?: number; scoped?: boolean; active?: boolean }
146export type Quota = { name: string; state: string; windows: QuotaWindow[]; note?: string; cooldownUntil?: number }
147
148export function parseQuota(json: string | undefined): Quota[] {
149 let raw: any
150 try {
151 raw = json ? JSON.parse(json) : undefined
152 } catch {
153 return []
154 }
155 if (!Array.isArray(raw?.accounts)) return []
156 return raw.accounts.map((a: any): Quota => {
157 const b = a.budget ?? {}
158 const w = (label: string, x: any): QuotaWindow[] => (typeof x?.left_pct === 'number' ? [{ label, left: x.left_pct, resetAt: x.reset_at }] : [])
159 // A model's own weekly allowance (Fable) beside the all-models windows:
160 // ccproxy status budget.scoped, from the usage endpoint.
161 const scoped: QuotaWindow[] = Array.isArray(b.scoped)
162 ? b.scoped.filter((x: any) => typeof x?.left_pct === 'number' && x.model).map((x: any) => ({ label: String(x.model), left: x.left_pct, resetAt: x.reset_at, scoped: true, active: !!x.active }))
163 : []
164 const windows = b.kind === 'windows' ? [...w('5h', b.five_hour), ...w('7d', b.seven_day), ...scoped] : b.kind === 'pool' ? w(b.pool_name ?? 'pool', b.pool) : []
165 const note = b.kind === 'spend_cap' ? (b.capped ? 'spend cap hit' : 'spend cap: not capped') : b.kind === 'requests' ? b.requests_remaining + '/' + b.requests_limit + ' requests' : windows.length ? undefined : (b.reason ?? 'no usage API')
166 return { name: String(a.name), state: String(a.state ?? 'ready'), windows, note, cooldownUntil: a.cooldown_until }
167 })
168}
169
170// A left-percentage as a bar of `cells`: filled for what is left.
171export function bar(left: number, cells = 10): string {
172 const n = Math.max(0, Math.min(cells, Math.round((left / 100) * cells)))
173 return '█'.repeat(n) + '░'.repeat(cells - n)
174}
175
176// Seconds until an epoch-seconds instant, as 4h12m / 3d4h; '' when past or unknown.
177export function until(at: number | undefined, nowMs: number): string {
178 if (!at) return ''
179 const s = Math.round(at - nowMs / 1000)
180 if (s <= 0) return ''
181 const h = Math.floor(s / 3600)
182 const m = Math.floor((s % 3600) / 60)
183 if (h >= 48) return Math.floor(h / 24) + 'd' + (h % 24 ? (h % 24) + 'h' : '')
184 return h ? h + 'h' + (m ? m + 'm' : '') : m + 'm'
185}
186
187// Jev calls these hooks made (~/.claude/hooks/jev.py records each one with
188// the gateway's billed cost), and the team-wide Jev line of the AI Gateway
189// report beside it (cc-bridge.py jev_usage).
190type JevTally = { calls: number; cost: number; in: number }
191type JevLatency = { p50?: number; p95?: number }
192export type JevUsage = {
193 today: JevTally
194 week: JevTally
195 // This session's calls (lines jev.py tagged with its id); absent from an older bridge.
196 session?: JevTally & JevLatency
197 // The last 24 hours by hour, oldest first.
198 hours: { start: number; calls: number; cost: number; ms?: number }[]
199 callers: ({ name: string; calls: number; cost: number; today?: number; kinds?: Record<string, number> } & JevLatency)[]
200 kinds: ({ name: string; calls: number; cost: number } & JevLatency)[]
201 gateway?: { requests: number; cost: number; since: string } | null
202}
203
204export function parseJev(raw: any): JevUsage | undefined {
205 if (!raw || typeof raw !== 'object' || !raw.today || !raw.week) return undefined
206 return {
207 today: raw.today,
208 week: raw.week,
209 session: raw.session ?? undefined,
210 hours: Array.isArray(raw.hours) ? raw.hours : [],
211 callers: Array.isArray(raw.callers) ? raw.callers : [],
212 kinds: Array.isArray(raw.kinds) ? raw.kinds : [],
213 gateway: raw.gateway ?? null,
214 }
215}
216
217// A hook's name as the Jev log records it: cc-bridge:route → route, flow_fork_hint.py → flow_fork_hint.
218export function hookName(caller: string): string {
219 return caller.replace(/^cc-bridge:/, 'cockpit ').replace(/\.py$/, '')
220}
221
222// $0.0123 below a dollar, $1.23 above: Jev costs are fractions of a cent.
223export function usd(n: number): string {
224 return '$' + (n < 1 ? n.toFixed(4) : n.toFixed(2))
225}
226