SLOPSHOPPER

cc-mods

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

newpanebandspinnerrowsguard
v0.1.0MITupdated 2026-10-26keyolk/cc-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-mods
│ ┃ cockpit ✕ › fix the failing auth test and add an audit log ╭─────────────────╮ │ ┃ │ cc-mods │ │ ┃ cockpit ✓ done ⌨ Claude copy this sessi ⏺ Read(src/auth.ts) │ copied 71 chars │ │ ┃ ⎿ Read 6 lines ╰─────────────────╯ │ ┃ 1: Now 2: Chat 3: Work 4: In/Out 5: · Edit /work/app/src/auth.╭───────────────────────────────────────╮ │ ┃ ──────────────────────────────────────────── ⏺ Bash(bun test) │ cc-mods │ │ ┃ ──────────── ⎿ 3 pass, 1 fail │ fold: finished tool calls on one line │ │ ┃ ○ tweb · checking… ╰───────────────────────────────────────╯ │ ┃ ╭─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ go: URL, host or search — Enter opens a TW │ ┃ ╰─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ new pane on Electron Chrome WebKit │ ┃ No TWeb pane beside this one. Enter a URL › /flow │ ┃ to open one; links this session opens go │ ┃ there too. │ ┃ │ ┃ 1-9 tabs k search alt+w focus the pane en │ ┃ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cockpit
cockpit ✓ done ⌨ Claude copy this session's identity 1 1: Now 2: Chat 3: Work 4: In/Out 5: Policy 6: Web ──────────────────────────────────────────────────────── ○ tweb · checking… ╭─────────────────────────────────────────────────────────── │ go: URL, host or search — Enter opens a TWeb pane beside ⏎ ╰─────────────────────────────────────────────────────────── new pane on Electron Chrome WebKit No TWeb pane beside this one. Enter a URL to open one; links this session opens go there too. 1-9 tabs k search alt+w focus the pane enter open click
Tool row
· Edit /work/app/src/auth.ts
README

cc-mods

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

What it does

AreaWhat you getReplaces
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
/flowThe 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.—
/graphRequests → subagents (nested by parent), background Bash, Monitors, as a tree or a timeline; tokens, tool counts, duration, prompt and result per node; Mermaid export.—
/sideQuestions 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)
/outputsPRs, 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)
/policyCapabilities 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 blockClaude's Bash running `session_policy.py allowblockinheritmodeclear 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)
bandAbove 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
narwhalWhat 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-checkAn 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 accountA 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 pollingOne 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
autocompletekubectl contexts and AWS profiles labelled with their stack (org.json), Jira keys seen, open PRs (#91…), tmux windows (@w:name).—
policy in the system promptPolicy 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.

Commands

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" } }

In/Out filter

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.

Archive folders (~/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/.

  • Linked to the session that writes into one: a Write/Edit under it, a shell command that writes there (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.
  • In/Out › Archive (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.
  • Now › Needs you shows a folder while its Open list has items, or when an attachment no longer matches 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 (/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.

  • One pod per tmux window: its sessions sit at its desks side by side, the window name on each desk's front.
  • Where a session stands follows its last tool call (its transcript): 📚 library while reading, 🛠 workshop while running a command, 📞 a phone booth during an MCP call, its desk while writing (typing) or with no tool (chin in hand, …), ? / ! over its head while it waits on you.
  • Back to its desk: a reply, a pick or a grant sent from the office gets the session up at once (❗, 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.
  • Going home: a session whose tmux pane is gone from three consecutive polls walks out through the 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 수고했습니다.
  • Idle sessions go to the ☕ lounge, which has no seat limit; after 10 minutes they lie down in a pile in front of the sofa, asleep.
  • Subagents (<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.
  • Personality from its own transcript, every tool call counted once (read incrementally): its job is the kind of tool it uses most beside Bash — 📣 communicator (Slack: headset), 📝 writer (Docs/Artifact: beret, glasses), 💻 developer (Edit/Write: headphones), 🔍 detective (Read/Grep/Web: fedora), 👀 watcher (Monitor: binocular eyes), 🧑‍💼 manager (Agent/Workflow: tie), 🛰 operator (other MCP tools: hard hat), ⌨️ hacker (nearly all Bash: hoodie). Each job walks its own way (a scurry, a sway, a look-around) with what it carries, and works with its own effect ({ } 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.
  • Walking goes round things: the floor is a grid with desks, furniture and walls blocked, A* finds a way along the aisles, the path is smoothed where the line is clear, turns bend and the last steps ease in. Depth sorts a desk by its front corner, so someone behind a desk is hidden by it and someone in front is not covered.
  • Facing follows each step: walking right or left mirrors the body (what it carries goes with it) and turns the face three-quarters that way (eyes, mouth and the near cheek shift, the far cheek narrows); walking away (up the screen) shows the back of the head with the hat, and no tie or badge. At rest it faces us.
  • Hover and a character reacts to you: one waiting on you is eager (sparkly eyes, hops, hearts), one working is nervous (sweat drop, tiny pupils, a shiver), one idle or asleep is startled awake (!), a subagent is shy (blushes, leans back). It eases in and out.
  • Quick actions: a click on a character (or a ❓/❗ chip at the bottom) opens its card: what it last said, and
  • a choice dialog on its screen (a permission prompt, AskUserQuestion): each option a button, which sends that digit to its pane while the dialog is up;
  • otherwise one-press replies (1번으로 해 주세요 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).
  • Policy on approval: when a waiting session needs a policy change to go on, its card proposes one, picked by Jev (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.
  • Links and related work: URLs in what a session said open in your browser, absolute paths show in Finder; the card lists its PRs, threads, tickets, artifacts and files (from cc-mods' ~/.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).
  • The waiting queue along the bottom: everyone who needs you, a confirmation (❗) before a question (❓), then whoever has waited longest, each with how long. 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.
  • Sessions talking: a message one session sends another (SendMessage, read from the sender's transcript; the recipient's peer name resolved through ~/.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).
  • A head as full as its context: past 50% a scribble of thoughts hangs over it, past 65% the hair stands up and it sweats, past 80% papers, ? and gears orbit it and it steams, past 90% the eyes spiral and pages fly off.
  • Compaction: while Claude Code compacts (its pane says "Compacting conversation"), the session presses its temples and the papers spiral into a summary box over its head (🗜 압축 중…); 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).
  • No double answers: a card says at once, under its title, when the session is working (the tool, for how long) or when a reply sent from the office has not been taken yet (📨), 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.
  • 미루기: one button (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.
  • Desks differ by window: wood, white, dark, pastel or standing desks; a striped, checked or round rug; in a pod with a free seat a plant, a bookshelf, a beanbag, a whiteboard, a sleeping cat, a water cooler, an arcade cabinet or a floor lamp (walkers go round it). What is on the desk follows the job: two screens for code, a laptop and a lamp for writing, a wide screen for watching, a little rack for ops, a book pile for reading.
  • Desks show the work: what a session took in and made (its cc-mods outputs) sits on its desk. A two-tier tray: the lower tier what it was handed (PRs, threads, tickets, links from you or that it read), the upper tier what it sent out (its PRs in GitHub black with the logo, Slack replies in aubergine, tickets in Jira blue). A yellow pad pile for scratch files, a printout pile for files it wrote, binders for exp archives, a framed picture for artifacts; piles grow with the count. Hover one for what and how many; a click opens the card with that shelf on top, every item in it (/detail?kind=).
  • The desk opens: a click on a desk (not on one of its things) opens its drawers in the card, a tab a kind: 📥 받은 것, 📤 보낸 것, 📝 스크래치, 📄 파일, 🗂 exp, 🖼 아티팩트, each item a badge or chip one click away; [/] switch tabs. A click on one thing opens the drawers at its tab.
  • Rooms say what they are for: a plaque sits on the back wall's top over each room and the subagents' queue (with how many are there, or out working); the meeting room has its own sign. Hovering a room's floor lights it up and says when a session comes there and who is there. The tmux sessions' names sit on plaques right against the left wall, over a band in their colour. The library has parquet, a long reading table with green lamps and a ladder; the workshop a concrete floor with a hazard stripe, a tool pegboard, a bench whose terminal shows what the people in the room are running and a machine whose gears turn and steam while someone is there; the phone booths a navy carpet and three booths (an `O
Source 55 files
hooks/register.ts 4893 lines
1// 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 lines
1// 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}
42
hooks/lib/web.ts 237 lines
1// 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}
237
hooks/views/web.ts 184 lines
1// 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}
184
hooks/views/webside.ts 219 lines
1// 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}
219
hooks/lib/webside.ts 224 lines
1// 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}
224
hooks/views/help.ts 29 lines
1// 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}
29
hooks/lib/help.ts 66 lines
1// 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}
66
hooks/views/palette.ts 159 lines
1// 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}
159
hooks/lib/palette.ts 146 lines
1// 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}
146
hooks/lib/route.ts 117 lines
1// 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}
117
hooks/lib/usage.ts 226 lines
1// 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