SLOPSHOPPER

dotclaude

A workspace pane for Claude Code: vitals rail, edit gate (blocks Edit, Write, NotebookEdit and MultiEdit until you approve; not Bash or MCP tools), plan…

newpanerowsguardcommandprompt
v0.2.0MITupdated 2026-10-08CarloArnone/dotclaude/mods/dotclaude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dotclaude
│ ┃ Workspace ✕ › fix the failing auth test and add an audit log call │ ┃ ▍Radar [ × Close ] ▍Crew │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Ideas ] [ Advisor ] No agents are r ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Scans your Claude Code ⎿ Added 2 lines, removed 1 line │ ┃ sessions and vault for ⏺ Edit(/work/app/src/auth.ts) │ ┃ repeated work, then ⎿ Denied by dotclaude: Edit blocked: the session is still b │ ┃ suggests skills, mods and │ ┃ agents you don't have yet. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ [ ⌕ Scan now ] ✻ Worked for 42s · done 4:20 PM │ │ › /vitals │ ⎿ dotclaude: Workspace opened on the vitals. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Workspace
▍Radar [ × Close ] ▍Crew [ × Close ] [ Ideas ] [ Advisor ] No agents are running. Scans your Claude Code sessions and vault for repeated work, then suggests skills, mods and agents you don't have yet. [ ⌕ Scan now ]
README

dotclaude

A workspace for Claude Code in one plugin and one pane, Workspace. Plugin panes share a single dock as tabs and a plugin cannot place them, so every feature lives inside this pane, in columns.

Layout

┌──────────── main column ────────────┐  ┌──── side column (when open) ────┐
│ Vitals rail (default)               │  │ Gate | Plan review | Mac |      │
│   or Ledger | Companion | Git | Crew│  │ Notes | Radar                   │
│   in its place, with × Close        │  │ with × Close                    │
└─────────────────────────────────────┘  └─────────────────────────────────┘
  • The pane opens when every session starts: always on the desktop, and in a terminal from 144 columns (the engine's rule for a pane opened unasked). /vitals opens it again.
  • Main column: the vitals rail. Ledger, Companion, Git and Crew open in its place; their × Close brings the vitals back.
  • Side column: Gate, Plan review, Mac, Notes (the brain) and Radar open beside the main column, one at a time (opening another replaces it), each with its own × Close.
  • Navigation is $.state only (dotclaude.mainView, dotclaude.sideView): a press switches views at once, runs no slash command and writes nothing to the transcript.
  • Every panel starts with a banner: on the desktop an SVG (glyph, title, faint artwork in the panel's colour); in the terminal a coloured ▍Title line.
  • A panel with more than one view has a tab strip under its banner (hooks/ui/tabs.tsx): the shown tab is the primary button; switching is $.state only.
  • Only the main action of a button row is primary; labels say what happens, with a glyph (✓ Approve, ↻ Refresh, × Close).
  • The dock: in the terminal, each panel's coloured glyph before its button. On the desktop, each native button with its panel's badge tile beside it (the desktop's native buttons cannot be coloured, and it draws Markdown images as text, so icon-only buttons are not possible).
  • VS Code and mobile get each open view as plain text, without controls.

Features

  • Vitals rail. statusline.sh's figures as a bedside monitor: Activity (one heartbeat per tool call), Context (share, tokens, burn per message), Rate limit 5h (share, countdown, 7-day share; hidden without a reading) and Cost (from $.session.usage(); hidden without a ledger). The header shows project, branch and dirty dot, model, session age, clock, and chips: Edits locked or Edits open, Companion open, caveman mode, Context critical. At 80% context the Context channel turns red and the rail offers Compact now (asks first) and Silence (10 min). The channels share every spare row of the pane. The dock opens each feature.
  • Gate. Every session starts brainstorming: Edit, Write, NotebookEdit and MultiEdit are refused (the guard denies when it fails, .catch pattern). It blocks those four tools only: Bash, MCP tools and every other tool are not gated, so it keeps Claude from editing files by mistake, not from acting. Plans and specs stay writable: .md files under docs/plans/, specs/, docs/superpowers/ or .claude/plans/, .md, .html and .svg files under mockups/, and any *.plan.md or *.spec.md, matched on the path relative to the session's project root after ., .. and every symbolic link are resolved, so folders above the project never count. A target that is itself a symbolic link is refused, even one that leads nowhere yet. Your own prompt unlocks when it starts with an approval word or phrase (by default approved, go ahead, proceed, lgtm, ship it, let's do it, sounds good, and GO in capitals), alone or followed by a space or one of . , ! : (a word in capitals such as GO: alone or followed by one of those marks only): "approved.", "GO" and "GO, do it" unlock; "is it approved?", "approved?", "approved-ish" and "GO away" do not. A lock phrase anywhere locks again (by default back to brainstorm, wait, stop; locking wins). Both lists are options, and the refusal names the words.
  • Plan review. A successful Write or Edit to a plan path, or ExitPlanMode, records the plan and replaces its tool row with a card: ↗ Review plan, ◫ Open in companion. The panel renders the plan (in parts of 30,000 characters), takes an optional comment, and offers ✓ Approve (unlocks the gate directly) and ✎ Request changes (sends the comment as your next message).
  • Ledger (tabs Edits and Requests). Edits: every successful Edit, Write and NotebookEdit: file, lines added and removed, time, and a one-sentence why from Haiku (after the tool result, never slowing it). Appended to .claude/ledger.md. Files the approved plan does not mention are flagged. ✎ Draft commit message (with ⧉ Copy), ⊕ Save to brain (opens the brain beside and starts its scribe), ⇢ Timeline (an HTML timeline shown in the companion). Requests: one row per model request of the main loop (time, model, tokens in and out, real cost, tool calls, duration). The cost is the rise in the session's own cost total ($.session.usage().cost) read just before and just after the request, never a price table; it reads — where the host keeps no cost ledger. Tool calls go to the request that asked for them (the engine runs a response's calls before the next request). Details › opens a request's tools (✓/✗, duration) and its token breakdown; ← Requests goes back. Kept for the session only, not written to ledger.md.
  • Companion. The plugin's own small server (assets/server.py, Python standard library) serves <claude dir>/companion/ on 127.0.0.1 (port 4747 by default). It answers only requests addressed to 127.0.0.1:<port> or localhost:<port> (403 otherwise, against DNS rebinding), serves regular files inside that folder only (no traversal, no listing, links resolved), and sends X-Content-Type-Options: nosniff and a Content-Security-Policy (default-src 'self' 'unsafe-inline' data:; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'self'; sandbox allow-scripts for views; the shell page alone may connect to the server, to poll its manifest and fetch the views it shows). A path that names no file (a NUL byte, an over-long name) answers 404. The server's token is kept in <claude dir>/companion.token (mode 0600, outside the served folder): a server on the port is trusted only when it answers that token at /__token; one an earlier session left running is stopped (POST /__shutdown with the token in a header) and replaced; an older companion with another token, or another program, gets a message naming how to free the port. Views are shown in a sandboxed frame (sandbox="allow-scripts", no same origin) through srcdoc, under a policy that adds connect-src 'none' (embedding browsers such as the desktop's preview pane block framed page loads): /companion show file.html runs that page's scripts inside the sandbox, where they cannot read the shell or make background network requests (a link the person clicks still navigates the frame). /companion show takes .html and .md files inside the project only, and refuses a file that is itself a symbolic link or resolves outside the project. The page opens in the desktop's browser pane ($.mcp.call to Claude_Browser's preview_start; elsewhere Claude is asked to open it and the URL is toasted). While open, a system prompt section tells Claude to put visual answers there, and two tool.call hooks add files to it: every file passed to SendUserFile (any folder; .html as is, .svg, .md, .csv, .json drawn, other kinds as a card with the path), and every new .html, .svg, .md, .csv or .json file written by Write inside the project or the session's scratchpad (/tmp/claude-*/<project>/<session id>/scratchpad/, matched because the engine does not name it; symbolic links refused). Edits to existing files are not shown.
  • Mac. CPU, memory, disk and battery sparklines (every 5 s, only while the panel shows), the heaviest processes with ■ End process… (asks, then SIGTERM), Docker containers with ■ Stop… (asks) and ≡ Logs.
  • Notes (brain). Live view of your notes vault (~/Notes by default: search, inbox, today's daily note, recent notes; every 10 s while shown; a plain message when the folder does not exist). + Capture this session runs the dotclaude:scribe agent (hidden from the main model); its questions are asked in dialogs; before anything is written each proposed note is shown in full (path and body, a long body cut at 40 lines with "+N more lines") with the daily line, and saved only on Save; a note holding code blocks is flagged, and Obsidian's runnable parts are made inert before writing (a dataviewjs block becomes a text block, an inline ` $= ` query is escaped, a Templater <% becomes &lt;%); titles, tags and the daily line are held to one line; paths stay in the note folders, never in a hidden folder (.obsidian), and no folder or file on the way may be a symbolic link; notes are never overwritten, an existing one gets a dated section. The scribe follows <claude dir>/skills/<conventionsSkill>/SKILL.md` when that skill is installed, else built-in conventions (frontmatter, wikilinks, a daily note).
  • Radar (tabs Ideas and Advisor). Ideas: ⌕ Scan now reads your recent prompts (<claude dir>/projects) and vault titles and asks Sonnet for skills, mods and agents you lack; each card has ✎ Draft (a brainstorm prompt that asks you where to create it), ◷ Later, × Dismiss. Kept in the plugin's store. Advisor: ⌕ Scan catalogs reads the catalogs of your marketplaces (<claude dir>/plugins/marketplaces/*, fetched fresh from GitHub when they live there) plus well-known ones not added yet (anthropics/claude-plugins-official, anthropics/skills), leaves out what is installed, enabled or hidden, and asks Sonnet for 5 to 8 picks for your recent prompts; picks the catalogs do not list are dropped, as are catalog entries whose plugin, marketplace or repository name is not a plain name (URLs and install lines are built from checked parts only). Cards show an initial tile, name, author, description and why, with ⧉ Copy install (the /plugin marketplace add line too when needed) and × Hide (for good). Offline, the local catalogs are used and the panel says so. The last results are kept in the plugin's store.
  • Git. Status, changes (every file, inside untracked folders too: git status --porcelain -uall), repository and branch pickers, and one primary button (↑ Push, ↑ Commit and push, …) that asks first in one confirmation listing the files a commit takes (the first 15), warning about names that look like secrets (.env*, *.pem, *.key, id_rsa*, id_ed25519*, *.p12, credentials*) and about a public repository, and showing the commit message; the panel never builds a force push, runs git with core.fsmonitor off, puts -- before names where git and gh take it, accepts only plain repository and remote names, adds the Co-Authored-By line, and undoes what it can when a step fails. The commit message is drafted by the ledger while the field is empty.
  • Crew. The session's agents: why each runs, what it does now, ■ Stop… (asks, then TaskStop) and ✉ Message (sent with $.session.send).

What runs automatically

  • Every 5 s, from session start: the vitals read the git branch and dirty state (git status) of the project, and the caveman flag file.
  • After each successful Edit, Write or NotebookEdit: one Haiku call writes the ledger's why, then a line is appended to the project's .claude/ledger.md.
  • When the Git panel opens: gh auth status and gh repo list, and a Haiku call drafting the commit message from the ledger while the field is empty.
  • Companion server: started only when you open the companion (or a session reload finds it was open).
  • Only on a click: Radar's scan, the Advisor's scan, the brain's capture, and every Git push.

What is sent to the model

Every text below goes through redactSecrets first (hooks/redact.ts): API keys (sk-…, sk-ant-…, Stripe sk_live_…/rk_test_…, Google AIza…), AWS key ids and secret access keys (after aws_secret_access_key), GitHub, Slack, npm (npm_…) and Hugging Face (hf_…) tokens, Bearer tokens and Authorization: header values, the password of a user:password@ URL, the value of environment lines whose name holds KEY, SECRET, TOKEN or PASSWORD (API_KEY=…), private key blocks and JSON Web Tokens are replaced by [redacted secret] (a label such as API_KEY= stays).

FeatureWhenModelWhat is sent
Ledger, whyAfter each successful editHaikuClaude's latest message (up to 4,000 characters) and the change (up to 1,500 characters of old and new text)
Ledger, commit messageDraft commit message, or the Git panel opening with changesHaikuThe session's ledger entries (file, lines, why)
Brain, scribe+ Capture this sessionSonnet (scribe agent, or one model call when agents are off)The session's messages (the last 60,000 characters), your note conventions, the vault's note list, what you asked to capture, and your answers to its questions
Radar, Ideas⌕ Scan nowSonnetUp to 4 of your prompts from each of your 40 newest sessions (300 characters each), your vault's note titles, what you have installed, dismissed ideas
Radar, Advisor⌕ Scan catalogsSonnetPlugin catalog entries (name, author, description), the same recent prompts, what you have installed

Commands

CommandOpens
/vitalsthe workspace on the vitals rail (keeps the side panel)
`/gate [approve\lock]`Gate; approve and lock change the phase
/plan-reviewPlan review, or says there is no plan yet
/ledger [commit-message]Ledger; commit-message answers a drafted message
/macMac
/brain [capture [text]]Notes; capture starts the scribe
/radarRadar
/companion [show <absolute path>]Companion, opening the companion if closed
/git-panelGit
/crewCrew (/agents is a Claude Code built-in name)

Options

Set them in /config, or in settings.json under pluginConfigs["dotclaude@dotclaude"].options when installed from the marketplace (pluginConfigs.dotclaude under --plugin-dir or a dev folder). An unset option uses its default; installing with --config key=value for each saves the defaults as visible values.

OptionDefaultWhat it does
vaultPath~/NotesYour notes vault; ~ is your home folder, absolute paths work too
vaultNameNotesThe vault's name in Obsidian, for obsidian:// links
conventionsSkillnotes-conventionsA skill under <claude dir>/skills/ whose SKILL.md holds your vault's note conventions; without it the scribe uses built-in ones
approvalWordsapproved, go ahead, proceed, lgtm, ship it, let's do it, sounds good, GOComma-separated; whole words, any case, except a word in capitals matches only in capitals
lockPhrasesback to brainstorm, wait, stopComma-separated; lock edits again
companionPort4747The companion's port on 127.0.0.1

<claude dir> is CLAUDE_CONFIG_DIR when set, else ~/.claude.

Desktop traces: one switch

hooks/features/vitals/svg.ts starts with VITALS_TRACE_MODE:

  • 'smil' (default): each channel's SVG animates its own sweep with SMIL (the head glides, the newest ink is revealed behind it, the erase gap moves) for 8 s, and is rebuilt only when its data changes or every 5 s. Chromium runs SMIL in an SVG drawn as an image, so no interactive frame is used (that one reloads and flashes on each redraw).
  • 'redraw': a static, lighter SVG (half the samples, no glow) redrawn every 250 ms.

The desktop's cell size in pixels is not given by any API; DESKTOP_CELL in hooks/ui/tokens.ts holds the estimate used to size the SVGs.

Code layout

  • hooks/register.tsx: every hook, every $ call and every $.state atom (the validator follows $ and atoms within this file only).
  • hooks/features/<feature>/: pure code: view.tsx (values and callbacks in, tree out) and the feature's logic modules.
  • hooks/ui/: shared design primitives: tokens, the banner, panel and button-row chrome.
  • hooks/config.ts: the options, parsed with their defaults.
  • types/index.d.ts: every state value, under PluginState['dotclaude'].

Develop

Load it from its folder (claude --plugin-dir <this folder>), then:

claude plugin validate .
claude plugin test .
Source 46 files
hooks/register.tsx 2636 lines
1// The plugin's only module that touches the engine: every hook, every `$` call and every
2// `$.state` atom lives here, because the engine follows `$` and the atoms within this file
3// alone. Everything under ./features and ./ui is pure: values and callbacks in, trees out.
4
5import { atom, read, update } from 'claude-code'
6import type {
7  AgentInfo,
8  EngineInterface,
9  HookStream,
10  ProcessSpawnChunk,
11  ProcessSpawnResult,
12  Register,
13  RenderElement,
14  RenderSurface,
15  Timer,
16  ToolCallInput,
17  ToolCallResult,
18  TurnStepInput,
19  TurnStepResult,
20  UiOpenResult,
21} from 'claude-code'
22
23import type {
24  AdvisorPick,
25  AdvisorResult,
26  CaptureState,
27  CompanionView,
28  ContainerLogs,
29  GatePhase,
30  GitPanelForm,
31  GitPanelGitHub,
32  GitPanelGitHubRepo,
33  GitPanelRepo,
34  GitPanelSnapshot,
35  GitPanelStep,
36  HudPoint,
37  HudRateLimit,
38  LedgerDraft,
39  LedgerEntry,
40  LedgerTab,
41  MainView,
42  NoteRef,
43  PlanReviewPlan,
44  PlanReviewStatus,
45  Process,
46  RadarIdea,
47  RadarScan,
48  RadarTab,
49  RequestRecord,
50  ScribeAnswer,
51  ScribeNote,
52  ScribeQuestion,
53  SearchResult,
54  SideView,
55  VaultView,
56} from '../types'
57import { addCaptureLine, appendDatedSection, captureLine, capturePreview, cleanAnswer, isAllowedNotePath, newDailyText, newNoteText } from './features/brain/notes'
58import {
59  GENERIC_CONVENTIONS,
60  SCRIBE_MODEL,
61  SCRIBE_NAME,
62  SCRIBE_PROMPT,
63  SCRIBE_TYPE,
64  buildTranscript,
65  draftPrompt as scribeDraftPrompt,
66  finalPrompt,
67  parseScribeAnswer,
68} from './features/brain/scribe'
69import { NOTE_FOLDERS, countCaptures, localDate, localTime, mergeHits, newestNotes } from './features/brain/vault'
70import { brainPanel, brainPlain } from './features/brain/view'
71import {
72  absolutePath,
73  BROWSER_SERVER,
74  companionSection,
75  companionUrl,
76  USAGE as COMPANION_USAGE,
77  csvTable,
78  fileCard,
79  htmlTitle,
80  isScratchpadPath,
81  isShownOnWrite,
82  jsonBlock,
83  markdownPage,
84  outputKind,
85  outputPage,
86  serverArgv,
87  showableExtension,
88  slugify,
89} from './features/companion/logic'
90import { markdownTitle, renderMarkdown } from './features/companion/markdown'
91import { companionPanel, companionPlain } from './features/companion/view'
92import { describeCall, isLive, keepListed, nameOf } from './features/crew/logic'
93import { crewPanel, crewPlain } from './features/crew/view'
94import {
95  EDIT_TOOLS,
96  GATE_TOAST,
97  GUARD_FAILED_REASON,
98  denyReason,
99  editedPath,
100  isInside,
101  isWritableWhileBrainstorming,
102  phaseAskedBy,
103  resolvePath,
104} from './features/gate/logic'
105import { gatePanel, gatePlain } from './features/gate/view'
106import { GH_TIMEOUT_MS, LOCAL_TIMEOUT_MS, failureText, folderName, lines, parseAheadBehind, parseRemotes, stepArgv } from './features/git/git'
107import type { RunOutcome, Step } from './features/git/git'
108import { confirmationText, isForcePush, planFrom } from './features/git/plan'
109import type { Plan } from './features/git/plan'
110import { buildSteps, remoteDecision } from './features/git/steps'
111import type { RemoteSetup } from './features/git/steps'
112import { gitPanel, gitPlain } from './features/git/view'
113import {
114  REASON_UNAVAILABLE,
115  appendToLedger,
116  baseName,
117  calendarDay,
118  clip,
119  clockTime,
120  countLineChanges,
121  countOccurrences,
122  entrySummary,
123  formatChange,
124  isMentionedIn,
125  ledgerLine,
126  relativeTo,
127  tidyCommitMessage,
128  timelineHtml,
129  toOneSentence,
130} from './features/ledger/logic'
131import type { LineChanges } from './features/ledger/logic'
132import { REQUESTS_KEPT, costDelta, requestForTool, requestTokens } from './features/ledger/requests'
133import { ledgerPanel, ledgerPlain } from './features/ledger/view'
134import { EMPTY_SNAPSHOT, takeSample } from './features/mac/sample'
135import { macPanel, macPlain } from './features/mac/view'
136import { isSideView, splitColumns } from './features/nav/logic'
137import { changesMessage, countSections, fileName, isPlanPath } from './features/plan/logic'
138import { NO_PLAN, cardSummary, planCard, planPanel, planPlain } from './features/plan/view'
139import {
140  STORE_KEYS,
141  buildScanPrompt,
142  draftPrompt as radarDraftPrompt,
143  extractSession,
144  filterIdeas,
145  parseIdeas,
146  sortByKind,
147  storedList,
148  whyUnanswered,
149} from './features/radar/logic'
150import type { SessionDigest } from './features/radar/logic'
151import {
152  ADVISOR_STORE_KEYS,
153  CATALOG_PATHS,
154  WELL_KNOWN_REPOS,
155  buildAdvisorPrompt,
156  candidates,
157  catalogName,
158  catalogUrl,
159  entryKey,
160  githubRepoOf,
161  installCommand,
162  installedKeys,
163  parseCatalog,
164  parsePicks,
165} from './features/radar/advisor'
166import type { CatalogEntry } from './features/radar/advisor'
167import { radarPanel, radarPlain } from './features/radar/view'
168import { buildChannels } from './features/vitals/channels'
169import { findLimit, isAlarmOn, parseCavemanMode, SILENCE_MS } from './features/vitals/readings'
170import { TICKS_PER_DESKTOP_REDRAW, VITALS_TICK_MS, VITALS_TRACE_MODE } from './features/vitals/svg'
171import { WINDOW_MS } from './features/vitals/trace'
172import { vitalsPanel, vitalsPlain } from './features/vitals/view'
173import type { Room, Vitals } from './features/vitals/view'
174import { DEFAULT_CONFIG, expandHome, readConfig } from './config'
175import { redactSecrets } from './redact'
176import type { Config } from './config'
177import { columns } from './ui/chrome'
178import type { Kit } from './ui/chrome'
179
180type Engine = EngineInterface
181type DockView = Exclude<MainView, 'vitals'> | SideView
182
183// ---- State --------------------------------------------------------------------
184// Declared in ../types under PluginState['dotclaude'].
185
186const mainViewState = atom({ plugin: 'dotclaude', key: 'mainView' } as const, 'vitals' as MainView)
187const sideViewState = atom({ plugin: 'dotclaude', key: 'sideView' } as const, null as SideView | null)
188
189const nowState = atom({ plugin: 'dotclaude', key: 'now' } as const, 0)
190const desktopTickState = atom({ plugin: 'dotclaude', key: 'desktopTick' } as const, 0)
191const headerState = atom({ plugin: 'dotclaude', key: 'header' } as const, {
192  project: '',
193  branch: null,
194  isDirty: false,
195  model: '',
196  startedAt: 0,
197})
198const contextState = atom({ plugin: 'dotclaude', key: 'context' } as const, { percent: 0, tokens: 0, window: 0 })
199const contextHistoryState = atom({ plugin: 'dotclaude', key: 'contextHistory' } as const, [])
200const rateLimitsState = atom({ plugin: 'dotclaude', key: 'rateLimits' } as const, [])
201const costUsdState = atom({ plugin: 'dotclaude', key: 'costUsd' } as const, null)
202const lastCallState = atom({ plugin: 'dotclaude', key: 'lastCall' } as const, null)
203const seriesState = atom({ plugin: 'dotclaude', key: 'series' } as const, { context: [], rate: [], cost: [] })
204const callsState = atom({ plugin: 'dotclaude', key: 'calls' } as const, [])
205const callCountState = atom({ plugin: 'dotclaude', key: 'callCount' } as const, 0)
206const cavemanState = atom({ plugin: 'dotclaude', key: 'caveman' } as const, null)
207const silencedUntilState = atom({ plugin: 'dotclaude', key: 'silencedUntil' } as const, 0)
208
209const gatePhaseState = atom({ plugin: 'dotclaude', key: 'gatePhase' } as const, 'brainstorm' as GatePhase)
210
211const planState = atom({ plugin: 'dotclaude', key: 'plan' } as const, null as PlanReviewPlan | null)
212const planCommentState = atom({ plugin: 'dotclaude', key: 'planComment' } as const, '')
213const planPartState = atom({ plugin: 'dotclaude', key: 'planPart' } as const, 0)
214
215const ledgerEntriesState = atom({ plugin: 'dotclaude', key: 'ledgerEntries' } as const, [] as LedgerEntry[])
216const ledgerDraftState = atom({ plugin: 'dotclaude', key: 'ledgerDraft' } as const, { status: 'idle' } as LedgerDraft)
217const ledgerTabState = atom({ plugin: 'dotclaude', key: 'ledgerTab' } as const, 'edits' as LedgerTab)
218const ledgerRequestsState = atom({ plugin: 'dotclaude', key: 'ledgerRequests' } as const, [] as RequestRecord[])
219const ledgerRequestOpenState = atom({ plugin: 'dotclaude', key: 'ledgerRequestOpen' } as const, null as string | null)
220
221const companionIsOpenState = atom({ plugin: 'dotclaude', key: 'companionIsOpen' } as const, false)
222const companionTokenState = atom({ plugin: 'dotclaude', key: 'companionToken' } as const, '')
223
224const macSnapshotState = atom({ plugin: 'dotclaude', key: 'macSnapshot' } as const, EMPTY_SNAPSHOT)
225const macLogsState = atom({ plugin: 'dotclaude', key: 'macLogs' } as const, null as ContainerLogs | null)
226
227const brainViewState = atom({ plugin: 'dotclaude', key: 'brainView' } as const, null as VaultView | null)
228const brainSearchState = atom({ plugin: 'dotclaude', key: 'brainSearch' } as const, { query: '', hits: [] } as SearchResult)
229const brainCaptureState = atom({ plugin: 'dotclaude', key: 'brainCapture' } as const, { phase: 'idle' } as CaptureState)
230
231const radarStatusState = atom({ plugin: 'dotclaude', key: 'radarStatus' } as const, { isScanning: false, error: null })
232const radarLastScanState = atom({ plugin: 'dotclaude', key: 'radarLastScan' } as const, null as RadarScan | null)
233const radarLaterState = atom({ plugin: 'dotclaude', key: 'radarLater' } as const, [] as RadarIdea[])
234const radarTabState = atom({ plugin: 'dotclaude', key: 'radarTab' } as const, 'ideas' as RadarTab)
235const advisorStatusState = atom({ plugin: 'dotclaude', key: 'advisorStatus' } as const, { isScanning: false, error: null })
236const advisorResultState = atom({ plugin: 'dotclaude', key: 'advisorResult' } as const, null as AdvisorResult | null)
237
238const gitSnapshotState = atom({ plugin: 'dotclaude', key: 'gitSnapshot' } as const, { kind: 'loading' } as GitPanelSnapshot)
239const gitFormState = atom({ plugin: 'dotclaude', key: 'gitForm' } as const, { message: '' } as GitPanelForm)
240const gitLogState = atom({ plugin: 'dotclaude', key: 'gitLog' } as const, [] as GitPanelStep[])
241const gitIsBusyState = atom({ plugin: 'dotclaude', key: 'gitIsBusy' } as const, false)
242
243const crewSnapshotState = atom({ plugin: 'dotclaude', key: 'crewSnapshot' } as const, { agents: [] as AgentInfo[], polledAt: 0 })
244const crewActivityState = atom({ plugin: 'dotclaude', key: 'crewActivity' } as const, {} as Record<string, string>)
245const crewStartedAtState = atom({ plugin: 'dotclaude', key: 'crewStartedAt' } as const, {} as Record<string, number>)
246const crewComposerState = atom({ plugin: 'dotclaude', key: 'crewComposer' } as const, null as { agentId: string; draft: string } | null)
247
248// Timers are closures, so they cannot live in $.state; a reload drops them with the module
249// and session.start starts the ones the open views need again.
250let macSampler: Timer | undefined
251let brainTicker: Timer | undefined
252let crewPoller: Timer | undefined
253// The companion server this session started; undefined when none runs or when another
254// session's server is reused (that one is not ours to stop). The child dies with the
255// module on a reload, and so does this handle.
256let companionServer: ServerStream | undefined
257// The ledger file has no append call, only read and write: chaining the writes
258// keeps two whys that land together from overwriting each other's line.
259let ledgerWrites: Promise<void> = Promise.resolve()
260
261type ServerStream = HookStream<ProcessSpawnChunk, ProcessSpawnResult>
262
263// ---- Hooks --------------------------------------------------------------------
264
265// The plugin's options, read again on every load: a change in /config reloads the module.
266let config: Config = DEFAULT_CONFIG
267
268export const register: Register = (on, options) => {
269  config = readConfig(options)
270
271  // Each feature starts on its own: one that fails is logged and the others still start.
272  on('session.start', async ($, e, next) => {
273    await startFeature($, 'workspace', () => startWorkspace($))
274    await startFeature($, 'vitals', () => startVitals($))
275    await startFeature($, 'gate', () => startGate($))
276    await startFeature($, 'plan review', () => startPlanReview($))
277    await startFeature($, 'ledger', () => startLedger($))
278    await startFeature($, 'companion', () => startCompanion($))
279    await startFeature($, 'mac', () => startMac($))
280    await startFeature($, 'brain', () => startBrain($))
281    await startFeature($, 'radar', () => startRadar($))
282    await startFeature($, 'git', () => startGit($))
283    await startFeature($, 'crew', () => startCrew($))
284    return next(e)
285  })
286
287  // Each feature starts on its own: one that fails is logged and the others still start.
288  // -- Workspace
289
290  on('command.run', { command: 'vitals' }, async $ => {
291    const opened = await openWorkspace($)
292    await showMain($, 'vitals')
293    await resumeViews($)
294    return { text: placed(opened, 'Workspace opened on the vitals.') }
295  })
296
297  on('ui.close', { id: WORKSPACE }, async ($, e, next) => {
298    stopTimers(await read($, mainViewState))
299    stopTimers(await read($, sideViewState))
300    return next(e)
301  })
302
303  on('ui.render', { component: 'Pane', requestId: WORKSPACE }, async ($, e) => {
304    const main = await read($, mainViewState)
305    const side = await read($, sideViewState)
306    if (e.surface === 'vscode' || e.surface === 'mobile') {
307      const { Box, Text } = $.ui.resolve(e)
308      const shown = [...(await plainLines($, main)), ...(side === null ? [] : await plainLines($, side))]
309      return (
310        <Box flexDirection="column">
311          {shown.map(line => (
312            <Text>{line}</Text>
313          ))}
314        </Box>
315      )
316    }
317    const kit: Kit = e.surface === 'terminal' ? { surface: 'terminal', t: $.ui.resolve(e) } : { surface: 'desktop', t: $.ui.resolve(e) }
318    const split = splitColumns(e.props.bodyColumns, side !== null)
319    const mainTree = await drawMain($, kit, main, { columns: split.main, rows: e.props.scroll.bodyRows })
320    const sideTree = side === null ? null : await drawSide($, kit, side, split.side)
321    return columns(kit.t, split, mainTree, sideTree)
322  })
323
324  // Every tool call, registered ahead of the gate so a refused call still counts: the
325  // vitals' heartbeat and the crew's "Now" before it runs, plan detection after. A failure
326  // here must never block a call: the handler replays the call's own answer.
327  on('tool.call', async ($, e, next) => {
328    await observeCall($, e).catch(error => $.ui.log(`dotclaude: could not record the call: ${errorText(error)}`, { to: 'debug' }))
329    const started = await $.clock.now()
330    const requestId = await attachToRequest($, e).catch(error => {
331      $.ui.log(`dotclaude: could not attach the call to its request: ${errorText(error)}`, { to: 'debug' })
332      return undefined
333    })
334    const ran = await next(e)
335    if (requestId !== undefined) {
336      await finishRequestTool($, requestId, e.tool_use_id, ran, started).catch(error =>
337        $.ui.log(`dotclaude: could not finish the call's record: ${errorText(error)}`, { to: 'debug' }),
338      )
339    }
340    if (ran.deny !== undefined || ran.isError === true) return ran
341    try {
342      const found = await findPlan($, e, ran.result)
343      if (found !== null) await recordPlan($, found)
344    } catch (error) {
345      $.ui.log(`Reading the plan failed: ${errorText(error)}`)
346    }
347    return ran
348  }).catch(($, e, next) => next(e))
349
350  // -- Vitals
351
352  on('session.measure', async ($, e, next) => {
353    await recordMeasurement($, e, await $.clock.now())
354    return next(e)
355  })
356
357  // Main loop only. The cost a request adds is read around it: the session's real total
358  // just before and just after, never a price table.
359  on('turn.step', async function* ($, e, next) {
360    const isMain = e.agentId === undefined
361    const before = isMain ? await requestMark($) : null
362    const result = yield* next(e)
363    if (before !== null && result.usage !== null) {
364      const usage = result.usage
365      await update($, lastCallState, () => ({
366        inputTokens: usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
367        outputTokens: usage.output_tokens,
368      }))
369      await recordRequest($, e, result, before).catch(error =>
370        $.ui.log(`dotclaude: could not record the request: ${errorText(error)}`, { to: 'debug' }),
371      )
372    }
373    return result
374  })
375
376  // -- Gate
377
378  on('command.run', { command: 'gate' }, async ($, e) => ({ text: await runGateCommand($, e.args) }))
379
380  on('tool.call', { tool: EDIT_TOOLS }, async ($, e, next) => {
381    if ((await read($, gatePhaseState)) === 'approved') return next(e)
382    const path = editedPath(e)
383    if (path !== undefined && (await isPlanInsideProject($, path))) return next(e)
384    return { deny: denyReason(config.approvalWords) }
385  }).catch(($, e, next) => (next.called ? next(e) : { deny: GUARD_FAILED_REASON }))
386
387  on('prompt.submit', async ($, e, next) => {
388    const asked = PERSON_ORIGINS.has(e.origin.kind) ? phaseAskedBy(e.text, config) : undefined
389    if (asked !== undefined) await changePhase($, asked)
390    return next(e)
391  })
392
393  // -- Plan review
394
395  on('command.run', { command: 'plan-review' }, async $ => {
396    const current = await read($, planState)
397    if (current === null) return { text: NO_PLAN }
398    const opened = await openView($, 'plan')
399    return { text: placed(opened, `Plan review opened on ${current.name}.`) }
400  })
401
402  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
403    const current = await read($, planState)
404    const isPlanRow = current !== null && current.toolUseId === e.props.tool_use_id
405    if (!isPlanRow || e.props.isRunning || e.props.isErrored) return next(e)
406    if (e.surface !== 'terminal' && e.surface !== 'desktop') {
407      const { Text } = $.ui.resolve(e)
408      return <Text>Plan ready for your review: {cardSummary(current)}. Run /plan-review to open it.</Text>
409    }
410    const planPath = current.planPath
411    return planCard($.ui.resolve(e), {
412      plan: current,
413      review: act($, 'Opening the plan', () => openView($, 'plan')),
414      openInCompanion: act($, 'Opening in the companion', async () => {
415        if (planPath !== null) $.ui.toast(await showFile($, planPath))
416      }),
417    })
418  })
419
420  // -- Ledger
421
422  on('command.run', { command: 'ledger' }, async ($, e) => {
423    if (e.args.trim() === 'commit-message') {
424      const result = await draftCommitMessage($)
425      return { text: result.status === 'ready' ? result.text : result.status === 'failed' ? result.reason : '' }
426    }
427    const opened = await openView($, 'ledger')
428    return { text: placed(opened, 'Ledger opened.') }
429  })
430
431  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
432    // replace_all changes every occurrence, so the file is read before it changes.
433    const occurrences = e.replace_all ? Math.max(1, countOccurrences(await readIfPresent($, e.file_path), e.old_string)) : 1
434    const ran = await next(e)
435    if (!hasSucceeded(ran)) return ran
436    const one = countLineChanges(e.old_string, e.new_string)
437    const changed = { added: one.added * occurrences, removed: one.removed * occurrences }
438    const snippet = `Replaced:\n${clip(e.old_string, SNIPPET_MAX)}\nWith:\n${clip(e.new_string, SNIPPET_MAX)}`
439    await recordChange($, { id: e.tool_use_id, tool: 'Edit', path: e.file_path, lines: changed, snippet })
440    return ran
441  }).catch(($, e, next) => next(e))
442
443  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
444    const before = await readIfPresent($, e.file_path)
445    const ran = await next(e)
446    if (!hasSucceeded(ran) || (ran.result as { staged?: boolean } | undefined)?.staged) return ran
447    const changed = countLineChanges(before, e.content)
448    const snippet = `New content:\n${clip(e.content, SNIPPET_MAX)}`
449    await recordChange($, { id: e.tool_use_id, tool: 'Write', path: e.file_path, lines: changed, snippet })
450    return ran
451  }).catch(($, e, next) => next(e))
452
453  on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
454    const ran = await next(e)
455    if (!hasSucceeded(ran)) return ran
456    const result = ran.result as { old_source?: string } | undefined
457    const after = e.edit_mode === 'delete' ? '' : e.new_source
458    const changed = countLineChanges(result?.old_source ?? '', after)
459    const snippet = `Cell source:\n${clip(after, SNIPPET_MAX)}`
460    await recordChange($, { id: e.tool_use_id, tool: 'NotebookEdit', path: e.notebook_path, lines: changed, snippet })
461    return ran
462  }).catch(($, e, next) => next(e))
463
464  // -- Companion
465
466  on('command.run', { command: 'companion' }, async ($, e) => {
467    try {
468      return { text: await runCompanionCommand($, e.args) }
469    } catch (error) {
470      return { text: `Companion: ${errorText(error)}` }
471    }
472  })
473
474  // While the companion is open, what Claude hands the user and the new pages it writes
475  // land there too, so nobody has to look for them in Finder. Never blocks the call.
476  on('tool.call', { tool: 'SendUserFile' }, async ($, e, next) => {
477    const ran = await next(e)
478    if (!hasSucceeded(ran) || !(await read($, companionIsOpenState))) return ran
479    const cwd = await $.session.cwd()
480    for (const file of e.files) {
481      const path = absolutePath(file, cwd)
482      // Outside the project and scratchpad only the path is shown: the views folder is
483      // served locally, so a file from elsewhere (or a link) is never copied into it.
484      if (await isOutputLocation($, path)) await showOutput($, path)
485      else await showPathOnly($, path)
486    }
487    return ran
488  }).catch(($, e, next) => next(e))
489
490  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
491    const isNew = !(await $.fs.exists(e.file_path))
492    const ran = await next(e)
493    if (!hasSucceeded(ran) || !isNew || !isShownOnWrite(e.file_path)) return ran
494    if (!(await read($, companionIsOpenState)) || !(await isOutputLocation($, e.file_path))) return ran
495    await showOutput($, e.file_path)
496    return ran
497  }).catch(($, e, next) => next(e))
498
499  on('prompt.compose', async ($, e, next) => {
500    const composed = await next(e)
501    if (!(await read($, companionIsOpenState))) return composed
502    return { sections: [...composed.sections, companionSection(companionUrl(config.companionPort), await companionDir($))] }
503  })
504
505  on('session.end', async ($, e, next) => {
506    if (await read($, companionIsOpenState)) {
507      await stopServer()
508      await update($, companionIsOpenState, () => false)
509    }
510    return next(e)
511  })
512
513  // -- Mac
514
515  on('command.run', { command: 'mac' }, async $ => {
516    const opened = await openView($, 'mac')
517    return { text: placed(opened, 'Mac opened.') }
518  })
519
520  // -- Brain
521
522  // The scribe is the plugin's own worker, never one the main model should delegate to.
523  on('agent.offer', { agent: 'dotclaude:scribe' }, () => ({ isOffered: false }))
524
525  on('turn.complete', async ($, e, next) => {
526    if (e.agentId !== undefined) void onScribeTurn($, e.agentId, e.reason, e.answer)
527    return next(e)
528  })
529
530  on('command.run', { command: 'brain' }, async ($, e) => {
531    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
532    if (verb !== '' && verb !== 'capture') return { text: BRAIN_USAGE }
533    let opened: UiOpenResult
534    try {
535      opened = await openView($, 'brain')
536    } catch (error) {
537      return { text: `Could not read the vault: ${errorText(error)}` }
538    }
539    if (verb === '') return { text: placed(opened, 'Second brain opened.') }
540    void startCapture($, rest.join(' '))
541    return { text: 'The scribe is reading the session.' }
542  })
543
544  // -- Radar
545
546  on('command.run', { command: 'radar' }, async $ => {
547    const opened = await openView($, 'radar')
548    return { text: placed(opened, 'Radar opened.') }
549  })
550
551  // -- Git
552
553  on('command.run', { command: 'git-panel' }, async $ => {
554    const opened = await openView($, 'git')
555    return { text: placed(opened, 'Git opened.') }
556  })
557
558  // -- Crew
559
560  on('command.run', { command: 'crew' }, async $ => {
561    const opened = await openView($, 'crew')
562    return { text: placed(opened, 'Crew opened.') }
563  })
564
565  // Spawn time makes "elapsed" true even when the view opens long after the agent started.
566  on('agent.spawn', async ($, e, next) => {
567    const spawned = await next(e)
568    if (spawned.deny === undefined && spawned.agentId !== undefined) {
569      const agentId = spawned.agentId
570      const now = await $.clock.now()
571      await update($, crewStartedAtState, known => ({ ...known, [agentId]: now }))
572    }
573    return spawned
574  })
575}
576
577// ---- Session start ------------------------------------------------------------
578
579async function startFeature($: Engine, feature: string, start: () => Promise<void>): Promise<void> {
580  try {
581    await start()
582  } catch (error) {
583    $.ui.log(`dotclaude: ${feature} did not start: ${errorText(error)}`)
584  }
585}
586
587async function startWorkspace($: Engine): Promise<void> {
588  await $.command.register({ name: 'vitals', description: 'Open the workspace on the vitals rail' })
589  // After a hot reload the pane may still be up while the timers its views need are gone.
590  if (await isWorkspaceOpen($)) await resumeViews($)
591  void $.ui.open({ id: WORKSPACE, title: WORKSPACE_TITLE })
592}
593
594async function startVitals($: Engine): Promise<void> {
595  vitalsPaths = await readPaths($)
596  const usage = await $.session.usage()
597  await update($, headerState, header => ({ ...header, startedAt: usage.startedAt }))
598  await recordMeasurement($, usage, await $.clock.now())
599  await refreshVitals($, true, true)
600  startTicking($)
601}
602
603async function startGate($: Engine): Promise<void> {
604  await $.command.register({
605    name: 'gate',
606    description: 'Show the brainstorm gate, or change it with approve or lock',
607    argumentHint: '[approve|lock]',
608  })
609}
610
611async function startPlanReview($: Engine): Promise<void> {
612  await $.command.register({ name: 'plan-review', description: 'Open the latest plan for review' })
613}
614
615async function startLedger($: Engine): Promise<void> {
616  await $.command.register({
617    name: 'ledger',
618    description: 'Show what was changed this session and why',
619    argumentHint: '[commit-message]',
620  })
621}
622
623async function startCompanion($: Engine): Promise<void> {
624  await $.command.register({
625    name: 'companion',
626    description: 'Open the companion view in the browser pane',
627    argumentHint: '[show <absolute path>]',
628  })
629  await reviveIfStale($).catch(error => $.ui.toast(`Companion: ${errorText(error)}`))
630}
631
632async function startMac($: Engine): Promise<void> {
633  await $.command.register({ name: 'mac', description: 'Show CPU, memory, disk, battery, processes and Docker' })
634}
635
636async function startBrain($: Engine): Promise<void> {
637  await $.command.register({
638    name: 'brain',
639    description: 'Open your notes vault, or capture this session into it',
640    argumentHint: '[capture [text]]',
641  })
642  await $.agent.register({
643    name: SCRIBE_NAME,
644    description: 'Extracts what is worth keeping from a session for your notes vault',
645    prompt: SCRIBE_PROMPT,
646    tools: [],
647    model: SCRIBE_MODEL,
648    omitClaudeMd: true,
649  })
650}
651
652async function startRadar($: Engine): Promise<void> {
653  await $.command.register({ name: 'radar', description: 'Suggests skills, mods and agents from your recent work' })
654}
655
656async function startGit($: Engine): Promise<void> {
657  await $.command.register({ name: 'git-panel', description: 'Open the Git panel: status, commit and push' })
658}
659
660async function startCrew($: Engine): Promise<void> {
661  await $.command.register({ name: 'crew', description: "Show the session's agents: why each runs, what it does now" })
662}
663
664/** The vitals' heartbeat (main loop only, as statusline.sh counts) and the crew's "Now" (subagents). */
665async function observeCall($: Engine, e: ToolCallInput): Promise<void> {
666  const agentId = e.agentId
667  if (agentId === undefined) {
668    await recordCall($, await $.clock.now())
669    return
670  }
671  const line = describeCall(String(e.tool), e as Readonly<Record<string, unknown>>, await $.session.cwd())
672  await update($, crewActivityState, known => ({ ...known, [agentId]: line }))
673}
674
675// ---- Shared helpers -----------------------------------------------------------
676
677function errorText(error: unknown): string {
678  return error instanceof Error ? error.message : String(error)
679}
680
681/** A press handler: runs the work unawaited, and turns a failure into a toast instead of a silent drop. */
682function act($: Engine, what: string, work: () => Promise<unknown>): () => void {
683  return () => void work().catch(error => $.ui.toast(`${what} failed: ${errorText(error)}`))
684}
685
686function placed(opened: UiOpenResult, text: string): string {
687  return opened.isPlaced ? text : `The workspace waits: ${opened.reason}`
688}
689
690/** True only when the person picks `action`; another answer or a dismissed dialog says no. */
691async function confirm($: Engine, question: string, action: string, header: string): Promise<boolean> {
692  try {
693    return (await $.ui.ask(question, { options: [action, 'Cancel'], header })) === action
694  } catch {
695    // ask rejects when the dialog is dismissed or nobody can answer: that is a no.
696    return false
697  }
698}
699
700async function readIfPresent($: Engine, path: string): Promise<string> {
701  return (await $.fs.exists(path)) ? $.fs.read(path) : ''
702}
703
704async function homeDir($: Engine): Promise<string> {
705  const home = await $.env.get('HOME')
706  if (!home) throw new Error('HOME is not set, so the plugin cannot find your files')
707  return home
708}
709
710/** Claude Code's own folder: CLAUDE_CONFIG_DIR when set, else ~/.claude. */
711async function claudeDir($: Engine): Promise<string> {
712  return (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await homeDir($)}/.claude`
713}
714
715// ---- Workspace and navigation -------------------------------------------------
716
717const WORKSPACE = 'workspace'
718const WORKSPACE_TITLE = 'Workspace'
719
720function openWorkspace($: Engine): Promise<UiOpenResult> {
721  return $.ui.open({ id: WORKSPACE, title: WORKSPACE_TITLE })
722}
723
724async function isWorkspaceOpen($: Engine): Promise<boolean> {
725  return (await $.ui.panes()).some(pane => pane.id === WORKSPACE)
726}
727
728/** A command's shortcut: the workspace, open on that view. */
729async function openView($: Engine, view: DockView): Promise<UiOpenResult> {
730  const opened = await openWorkspace($)
731  await showView($, view)
732  await resumeViews($)
733  return opened
734}
735
736function showView($: Engine, view: DockView): Promise<void> {
737  return isSideView(view) ? showSide($, view) : showMain($, view)
738}
739
740/** Puts a view in the main column; the vitals rail is the main column's resting view. */
741async function showMain($: Engine, view: MainView): Promise<void> {
742  const previous = await read($, mainViewState)
743  if (previous === view) return
744  stopTimers(previous)
745  await update($, mainViewState, () => view)
746  await loadView($, view)
747  startTimers($, view)
748}
749
750/** Opens a side panel beside the main column, in place of the one open; null closes it. */
751async function showSide($: Engine, view: SideView | null): Promise<void> {
752  const previous = await read($, sideViewState)
753  if (previous === view) return
754  stopTimers(previous)
755  await update($, sideViewState, () => view)
756  await loadView($, view)
757  startTimers($, view)
758}
759
760/** What a view reads once as it opens. */
761async function loadView($: Engine, view: MainView | SideView | null): Promise<void> {
762  if (view === 'brain') await refreshVault($)
763  if (view === 'radar') await loadRadarFromStore($)
764  if (view === 'radar') await loadAdvisorFromStore($)
765  if (view === 'crew') await refreshCrew($)
766  // A timer runs outside the press's or command's dispatch, so the slow git reads never hold it up.
767  if (view === 'git') $.clock.after(0, () => void refreshGit($))
768}
769
770/** The polling a view does while it shows; each start is a no-op when already running. */
771function startTimers($: Engine, view: MainView | SideView | null): void {
772  if (view === 'mac') startSampling($)
773  if (view === 'brain') startBrainTicker($)
774  if (view === 'crew') startCrewPolling($)
775}
776
777function stopTimers(view: MainView | SideView | null): void {
778  if (view === 'mac') stopSampling()
779  if (view === 'brain') stopBrainTicker()
780  if (view === 'crew') stopCrewPolling()
781}
782
783async function resumeViews($: Engine): Promise<void> {
784  startTimers($, await read($, mainViewState))
785  startTimers($, await read($, sideViewState))
786}
787
788async function drawMain($: Engine, kit: Kit, view: MainView, room: Room): Promise<RenderElement> {
789  const close = act($, 'Closing', () => showMain($, 'vitals'))
790  switch (view) {
791    case 'vitals':
792      return vitalsPanel(kit, await readVitals($, kit.surface), room)
793    case 'ledger': {
794      // Only the shown tab's values are read, so a busy turn does not redraw the Edits tab.
795      const tab = await read($, ledgerTabState)
796      const isEdits = tab === 'edits'
797      return ledgerPanel(
798        kit,
799        {
800          tab,
801          selectTab: next => void update($, ledgerTabState, () => next),
802          edits: {
803            entries: isEdits ? await read($, ledgerEntriesState) : [],
804            draft: isEdits ? await read($, ledgerDraftState) : { status: 'idle' },
805            startDraft: act($, 'Drafting', () => startDraft($)),
806            copyDraft: press => void copyDraft($, press.surface).catch(error => $.ui.toast(`Copy failed: ${errorText(error)}`)),
807            saveToBrain: act($, 'Saving to brain', () => saveToBrain($)),
808            showTimeline: act($, 'Timeline', () => showTimeline($)),
809          },
810          requests: {
811            requests: isEdits ? [] : await read($, ledgerRequestsState),
812            openId: isEdits ? null : await read($, ledgerRequestOpenState),
813            sessionCostUsd: isEdits ? null : await read($, costUsdState),
814            open: id => void update($, ledgerRequestOpenState, () => id),
815            back: () => void update($, ledgerRequestOpenState, () => null),
816          },
817          close,
818        },
819        room.columns,
820      )
821    }
822    case 'companion':
823      return companionPanel(
824        kit,
825        {
826          isOpen: await read($, companionIsOpenState),
827          url: companionUrl(config.companionPort),
828          open: act($, 'Opening the companion', async () => $.ui.toast(await ensureCompanion($))),
829          stop: act($, 'Closing the companion', () => closeCompanion($)),
830          close,
831        },
832        room.columns,
833      )
834    case 'git':
835      return gitPanel(
836        kit,
837        {
838          snapshot: await read($, gitSnapshotState),
839          form: await read($, gitFormState),
840          log: await read($, gitLogState),
841          isBusy: await read($, gitIsBusyState),
842          setForm: change => void setGitForm($, change),
843          checkNewBranch: name => void checkNewBranch($, name),
844          push: act($, 'Pushing', () => pushFromPane($)),
845          refresh: act($, 'Refreshing', () => refreshGit($)),
846          init: cwd => act($, 'Initializing', () => initRepository($, cwd))(),
847          close,
848        },
849        room.columns,
850      )
851    case 'crew': {
852      const { agents, polledAt } = await read($, crewSnapshotState)
853      return crewPanel(
854        kit,
855        {
856          agents,
857          polledAt,
858          activity: await read($, crewActivityState),
859          startedAt: await read($, crewStartedAtState),
860          composer: await read($, crewComposerState),
861          stop: agent => act($, 'Stopping', () => stopAgent($, agent))(),
862          toggleComposer: agentId => void toggleComposer($, agentId),
863          keepDraft: (agentId, draft) => void keepDraft($, agentId, draft),
864          send: (agent, text) => act($, 'Sending', () => sendMessage($, agent, text))(),
865          sendDraft: agent => act($, 'Sending', () => sendDraft($, agent))(),
866          close,
867        },
868        room.columns,
869      )
870    }
871  }
872}
873
874async function drawSide($: Engine, kit: Kit, view: SideView, columnCount: number): Promise<RenderElement> {
875  const close = act($, 'Closing', () => showSide($, null))
876  switch (view) {
877    case 'gate':
878      return gatePanel(
879        kit,
880        {
881          phase: await read($, gatePhaseState),
882          approve: act($, 'Approving', () => changePhase($, 'approved')),
883          relock: act($, 'Locking', () => changePhase($, 'brainstorm')),
884          close,
885        },
886        columnCount,
887      )
888    case 'plan':
889      return planPanel(
890        kit,
891        {
892          plan: await read($, planState),
893          comment: await read($, planCommentState),
894          part: await read($, planPartState),
895          approve: act($, 'Approving the plan', () => approvePlan($)),
896          requestChanges: act($, 'Requesting changes', () => requestChanges($)),
897          keepComment: text => void update($, planCommentState, () => text),
898          showPart: part => void update($, planPartState, () => part),
899          close,
900        },
901        columnCount,
902      )
903    case 'mac':
904      return macPanel(
905        kit,
906        {
907          snapshot: await read($, macSnapshotState),
908          logs: await read($, macLogsState),
909          endProcess: process => void endProcess($, process),
910          stopContainer: name => void stopContainer($, name),
911          showLogs: name => void showLogs($, name),
912          hideLogs: () => void update($, macLogsState, () => null),
913          close,
914        },
915        columnCount,
916      )
917    case 'brain':
918      return brainPanel(
919        kit,
920        {
921          view: await read($, brainViewState),
922          search: await read($, brainSearchState),
923          capture: await read($, brainCaptureState),
924          nowMs: await $.clock.now(),
925          runSearch: query => act($, 'Search', () => runSearch($, query))(),
926          openNote: url => act($, 'Opening the note', () => openNote($, url))(),
927          startCapture: act($, 'Capture', () => startCapture($, '')),
928          close,
929        },
930        columnCount,
931      )
932    case 'radar':
933      return radarPanel(
934        kit,
935        {
936          tab: await read($, radarTabState),
937          selectTab: next => void update($, radarTabState, () => next),
938          ideas: {
939            status: await read($, radarStatusState),
940            lastScan: await read($, radarLastScanState),
941            later: await read($, radarLaterState),
942            scan: act($, 'Scan', () => scanNow($)),
943            draft: idea => act($, 'Draft', () => draftIdea($, idea))(),
944            keepForLater: idea => act($, 'Later', () => keepForLater($, idea))(),
945            dismiss: idea => act($, 'Dismiss', () => dismissIdea($, idea))(),
946          },
947          advisor: {
948            status: await read($, advisorStatusState),
949            result: await read($, advisorResultState),
950            scan: act($, 'Scan', () => scanAdvisor($)),
951            copyInstall: (pick, press) => act($, 'Copy', () => copyInstall($, pick, press.surface))(),
952            hide: pick => act($, 'Hide', () => hideAdvisorPick($, pick))(),
953          },
954          close,
955        },
956        columnCount,
957      )
958  }
959}
960
961/** vscode and mobile: each view's facts as plain lines, no controls. */
962async function plainLines($: Engine, view: MainView | SideView): Promise<string[]> {
963  switch (view) {
964    case 'vitals':
965      return vitalsPlain(await readVitals($, 'plain'))
966    case 'ledger':
967      return ledgerPlain(await read($, ledgerEntriesState))
968    case 'companion':
969      return companionPlain(await read($, companionIsOpenState), companionUrl(config.companionPort))
970    case 'git':
971      return gitPlain(await read($, gitSnapshotState))
972    case 'crew': {
973      const { agents, polledAt } = await read($, crewSnapshotState)
974      return crewPlain({
975        agents,
976        polledAt,
977        activity: await read($, crewActivityState),
978        startedAt: await read($, crewStartedAtState),
979        composer: await read($, crewComposerState),
980      })
981    }
982    case 'gate':
983      return gatePlain(await read($, gatePhaseState))
984    case 'plan':
985      return planPlain(await read($, planState))
986    case 'mac':
987      return macPlain(await read($, macSnapshotState))
988    case 'brain':
989      return brainPlain(await read($, brainViewState), await read($, brainCaptureState))
990    case 'radar':
991      return radarPlain(await read($, radarLastScanState))
992  }
993}
994
995// ---- Vitals -------------------------------------------------------------------
996
997type Measurement = {
998  context: { percent?: number; tokens?: number; window: number }
999  rateLimits: readonly HudRateLimit[]
1000  cost?: { usd: number }
1001}
1002
1003type Paths = { root: string; home: string; configDir: string }
1004
1005// git, the model and the project change rarely; files are cheap enough to read every tick.
1006const SLOW_REFRESH_MS = 5000
1007// A beat lasts a second, so a call just outside the window still draws its tail.
1008const KEEP_CALLS_MS = WINDOW_MS + 1000
1009
1010let vitalsPaths: Paths = { root: '', home: '', configDir: '' }
1011
1012async function readPaths($: Engine): Promise<Paths> {
1013  return {
1014    root: await $.session.root(),
1015    home: await homeDir($),
1016    // the caveman flag lives in Claude Code's own folder, as statusline.sh reads it
1017    configDir: await claudeDir($),
1018  }
1019}
1020
1021function startTicking($: Engine): void {
1022  let ticks = 0
1023  const slowEvery = Math.round(SLOW_REFRESH_MS / VITALS_TICK_MS)
1024  $.clock.every(VITALS_TICK_MS, () => {
1025    ticks += 1
1026    void tick($, ticks % slowEvery === 0, ticks % TICKS_PER_DESKTOP_REDRAW === 0)
1027  })
1028}
1029
1030async function tick($: Engine, isSlow: boolean, isDesktopTick: boolean): Promise<void> {
1031  try {
1032    await refreshVitals($, isSlow, isDesktopTick)
1033  } catch (error) {
1034    $.ui.log(`dotclaude: vitals tick failed: ${errorText(error)}`, { to: 'debug' })
1035  }
1036}
1037
1038// Each drawing redraws when a value it read is written, so a write that changes nothing is skipped.
1039async function refreshVitals($: Engine, isSlow: boolean, isDesktopTick: boolean): Promise<void> {
1040  const now = await $.clock.now()
1041  await update($, nowState, () => now)
1042  if (isDesktopTick) await update($, desktopTickState, () => now)
1043  const caveman = await readCaveman($)
1044  if (caveman !== (await read($, cavemanState))) await update($, cavemanState, () => caveman)
1045  if (!isSlow) return
1046  const git = await readGitHead($, vitalsPaths.root)
1047  const model = await $.session.model()
1048  const project = vitalsPaths.root.split('/').filter(part => part !== '').pop() ?? vitalsPaths.root
1049  const header = await read($, headerState)
1050  const isSame = header.branch === git.branch && header.isDirty === git.isDirty && header.model === model && header.project === project
1051  if (!isSame) await update($, headerState, current => ({ ...current, ...git, model, project }))
1052}
1053
1054async function recordMeasurement($: Engine, measured: Measurement, at: number): Promise<void> {
1055  const percent = measured.context.percent ?? 0
1056  const rateLimits = measured.rateLimits.map(limit => ({ ...limit }))
1057  const fiveHour = findLimit(rateLimits, 'five_hour')
1058  const costUsd = measured.cost?.usd ?? null
1059  await update($, contextState, () => ({
1060    percent,
1061    tokens: measured.context.tokens ?? 0,
1062    window: measured.context.window,
1063  }))
1064  await update($, contextHistoryState, history => [...history, percent].slice(-6))
1065  await update($, rateLimitsState, () => rateLimits)
1066  await update($, costUsdState, () => costUsd)
1067  await update($, seriesState, series => ({
1068    context: addPoint(series.context, at, percent / 100),
1069    rate: fiveHour === undefined ? series.rate : addPoint(series.rate, at, fiveHour.percentUsed / 100),
1070    cost: costUsd === null ? series.cost : addPoint(series.cost, at, costUsd),
1071  }))
1072}
1073
1074/** Appends a reading, keeping the last one before the window: it sets the trace's left edge. */
1075function addPoint(points: readonly HudPoint[], at: number, value: number): HudPoint[] {
1076  const all = [...points, { at, value }]
1077  const firstInside = all.findIndex(point => point.at >= at - WINDOW_MS)
1078  return all.slice(Math.max(0, firstInside - 1))
1079}
1080
1081async function recordCall($: Engine, at: number): Promise<void> {
1082  await update($, callsState, calls => [...calls.filter(call => call >= at - KEEP_CALLS_MS), at])
1083  await update($, callCountState, count => count + 1)
1084}
1085
1086/**
1087 * The terminal's braille sweep moves with each 1 s tick, so it reads that tick. The desktop's
1088 * SVG sweeps itself, so its drawing reads only the slow tick (and the data) and takes the
1089 * time from the clock: every redraw starts the sweep exactly where it stands.
1090 */
1091async function readVitals($: Engine, surface: RenderSurface | 'plain'): Promise<Vitals> {
1092  const isSelfAnimated = surface === 'desktop' && VITALS_TRACE_MODE === 'smil'
1093  const now = isSelfAnimated ? await readDesktopClock($) : await read($, nowState)
1094  const context = await read($, contextState)
1095  const silencedUntil = await read($, silencedUntilState)
1096  return {
1097    now,
1098    header: await read($, headerState),
1099    contextPercent: context.percent,
1100    gatePhase: await read($, gatePhaseState),
1101    isCompanionOpen: await read($, companionIsOpenState),
1102    caveman: await read($, cavemanState),
1103    isAlarm: isAlarmOn(context.percent, now, silencedUntil),
1104    channels: buildChannels({
1105      now,
1106      context,
1107      contextHistory: await read($, contextHistoryState),
1108      rateLimits: await read($, rateLimitsState),
1109      costUsd: await read($, costUsdState),
1110      lastCall: await read($, lastCallState),
1111      series: await read($, seriesState),
1112      calls: await read($, callsState),
1113      callCount: await read($, callCountState),
1114    }),
1115    actions: {
1116      open: view => act($, 'Opening', () => showView($, view))(),
1117      compact: act($, 'Compacting', () => compactAfterAsking($)),
1118      silence: act($, 'Silencing', () => silence($)),
1119    },
1120  }
1121}
1122
1123/** The clock, read by a drawing that redraws on the slow tick only. */
1124async function readDesktopClock($: Engine): Promise<number> {
1125  await read($, desktopTickState)
1126  return $.clock.now()
1127}
1128
1129// Compaction rewrites the conversation for good, so it asks first, as the plugin's rules say.
1130async function compactAfterAsking($: Engine): Promise<void> {
1131  const answer = await $.ui.ask('Compact the conversation now?', ['Compact', 'Cancel']).catch(() => 'Cancel')
1132  if (answer !== 'Compact') return
1133  try {
1134    const { skip } = await $.session.compact()
1135    if (skip) $.ui.toast('Another plugin kept the conversation from compacting.')
1136  } catch (error) {
1137    $.ui.toast(`Compaction waits for the turn to end: ${errorText(error)}`)
1138  }
1139}
1140
1141async function silence($: Engine): Promise<void> {
1142  const now = await $.clock.now()
1143  await update($, silencedUntilState, () => now + SILENCE_MS)
1144  $.ui.toast('Alarm silenced for 10 min')
1145}
1146
1147/** Branch and dirty state as statusline.sh reads them; no branch outside a repository. */
1148async function readGitHead($: Engine, cwd: string): Promise<{ branch: string | null; isDirty: boolean }> {
1149  const head = await $.process
1150    .run(withoutFsmonitor(['git', '-C', cwd, '--no-optional-locks', 'symbolic-ref', '--short', 'HEAD']))
1151    .catch(() => undefined)
1152  if (head === undefined || head.exitCode !== 0) return { branch: null, isDirty: false }
1153  const status = await $.process
1154    .run(withoutFsmonitor(['git', '-C', cwd, '--no-optional-locks', 'status', '--porcelain']))
1155    .catch(() => undefined)
1156  return { branch: head.stdout.trim(), isDirty: (status?.stdout.trim() ?? '') !== '' }
1157}
1158
1159async function readCaveman($: Engine): Promise<string | null> {
1160  const text = await readPlainFile($, `${vitalsPaths.configDir}/.caveman-active`)
1161  return text === null ? null : parseCavemanMode(text)
1162}
1163
1164/** A regular file's text; null when missing or a symbolic link, as statusline.sh skips those. */
1165async function readPlainFile($: Engine, path: string): Promise<string | null> {
1166  const stat = await $.fs.stat(path).catch(() => undefined)
1167  if (stat === undefined || stat.isLink || stat.kind !== 'file') return null
1168  return await $.fs.read(path)
1169}
1170
1171// ---- Gate ---------------------------------------------------------------------
1172
1173// Approval must come from the person: their prompt, Remote Control, or the SDK host (desktop).
1174const PERSON_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
1175
1176/**
1177 * A path's real location, every link resolved: its own real path when it exists, else its
1178 * nearest existing parent's real path with the rest appended (a file about to be created).
1179 */
1180async function realPathOf($: Engine, path: string): Promise<string> {
1181  const stat = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
1182  if (stat?.realPath !== undefined) return stat.realPath
1183  const cut = path.lastIndexOf('/')
1184  if (cut <= 0) return path
1185  const parent = await realPathOf($, path.slice(0, cut))
1186  return `${parent === '/' ? '' : parent}/${path.slice(cut + 1)}`
1187}
1188
1189/**
1190 * Whether an edit may go ahead while brainstorming: a plan or spec path, resolved, inside the
1191 * project. A target that is itself a symbolic link, even one leading nowhere yet, is refused:
1192 * the write would land wherever it points.
1193 */
1194async function isPlanInsideProject($: Engine, path: string): Promise<boolean> {
1195  const absolute = resolvePath(path, await $.session.cwd())
1196  const target = await $.fs.stat(absolute).catch(() => undefined)
1197  if (target?.isLink) return false
1198  const root = await realPathOf($, await $.session.root())
1199  const resolved = await realPathOf($, absolute)
1200  if (resolved === root || !isInside(resolved, root)) return false
hooks/features/brain/notes.ts 124 lines
1import type { ScribeAnswer, ScribeNote } from '../../../types'
2import { noteName } from './vault'
3
4const ALLOWED_PATH = /^(inbox|notes|projects|references)\/[^/].*\.md$/
5
6/**
7 * Keeps the scribe inside the vault's note folders: no absolute paths, no daily/ rewrites,
8 * and no segment starting with a dot (`..`, or a hidden folder such as `.obsidian`).
9 */
10export function isAllowedNotePath(path: string): boolean {
11  return ALLOWED_PATH.test(path) && !path.split('/').some(segment => segment.startsWith('.'))
12}
13
14/** One line of plain text: newlines and control characters become spaces, runs of them one. */
15export function oneLine(text: string): string {
16  return text.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim()
17}
18
19/**
20 * The scribe's answer with every field that lands in frontmatter, a heading or a list item held
21 * to one line and made inert like a body.
22 */
23export function cleanAnswer(answer: ScribeAnswer): ScribeAnswer {
24  const line = (text: string) => neutraliseBody(oneLine(text))
25  return {
26    ...answer,
27    notes: answer.notes.map(note => ({ ...note, title: line(note.title), tags: note.tags.map(line).filter(Boolean) })),
28    dailyLine: line(answer.dailyLine),
29  }
30}
31
32const PREVIEW_LINES = 40
33
34// A fence line: indent, three or more backticks or tildes, then the block's language.
35const FENCE = /^(\s*(?:`{3,}|~{3,})\s*)([^\s`]*)/
36// Obsidian runs these when a note is opened: Dataview's JavaScript blocks and inline `$= …`
37// queries, and Templater's <% … %> tags.
38const RUNNABLE_FENCE = /^dataviewjs$/i
39const SCRIPT_FENCE = /^(js|javascript|dataviewjs)$/i
40const INLINE_DATAVIEW_JS = /`\$=/g
41const TEMPLATER_TAG = /<%/g
42
43function fenceLanguages(body: string): string[] {
44  return body.split('\n').flatMap(line => {
45    const fence = FENCE.exec(line)
46    return fence ? [fence[2] ?? ''] : []
47  })
48}
49
50function hasRunnableCode(body: string): boolean {
51  return fenceLanguages(body).some(language => RUNNABLE_FENCE.test(language)) || /`\$=|<%/.test(body)
52}
53
54/**
55 * The body with what Obsidian would run made inert: a dataviewjs block becomes a text block,
56 * an inline `$= query loses its trigger and a Templater tag is escaped. The text stays readable.
57 */
58export function neutraliseBody(body: string): string {
59  return body
60    .split('\n')
61    .map(line => line.replace(FENCE, (fence, opening: string, language: string) => (RUNNABLE_FENCE.test(language) ? `${opening}text` : fence)))
62    .join('\n')
63    .replace(INLINE_DATAVIEW_JS, '`\\$=')
64    .replace(TEMPLATER_TAG, '&lt;%')
65}
66
67/** The warnings shown above a note's body before it is saved, if any. */
68export function codeWarnings(body: string): string[] {
69  const languages = fenceLanguages(body)
70  const warnings: string[] = []
71  if (hasRunnableCode(body)) warnings.push('⚠ Holds dataviewjs or Templater code: it is saved as plain text, so it cannot run.')
72  if (languages.some(language => SCRIPT_FENCE.test(language))) warnings.push('⚠ Holds JavaScript code blocks: read them before saving.')
73  else if (languages.length > 0) warnings.push('⚠ Holds code blocks: read them before saving.')
74  return warnings
75}
76
77/** What the scribe proposes, each note in full as it will be written, for the question asked first. */
78export function capturePreview(answer: ScribeAnswer): string {
79  const notes = answer.notes.map(note => {
80    const lines = neutraliseBody(note.body.trim()).split('\n')
81    const shown = lines.slice(0, PREVIEW_LINES).map(line => `    ${line}`.trimEnd())
82    const more = lines.length > PREVIEW_LINES ? [`    +${lines.length - PREVIEW_LINES} more lines`] : []
83    const warnings = codeWarnings(note.body).map(warning => `  ${warning}`)
84    return [`- ${note.path}`, ...warnings, ...shown, ...more].join('\n')
85  })
86  return [...(notes.length === 0 ? ['(no notes)'] : notes), '', `Daily note: ${answer.dailyLine}`].join('\n')
87}
88
89export function newNoteText(note: ScribeNote, date: string, source: string): string {
90  const frontmatter = ['---', `created: ${date}`, `tags: [${note.tags.join(', ')}]`, `source: ${source}`, '---']
91
92  return `${frontmatter.join('\n')}\n\n# ${note.title}\n\n${neutraliseBody(note.body.trim())}\n`
93}
94
95/** Never overwrites: the new content lands under a dated heading after what is there. */
96export function appendDatedSection(existing: string, body: string, date: string): string {
97  return `${existing.trimEnd()}\n\n## ${date}\n\n${neutraliseBody(body.trim())}\n`
98}
99
100export function newDailyText(date: string): string {
101  return `---\ncreated: ${date}\ntags: [daily]\n---\n\n# ${date}\n`
102}
103
104/** Appends the bullet at the end of "## Captures", creating the section when the note has none. */
105export function addCaptureLine(daily: string, line: string): string {
106  const lines = daily.trimEnd().split('\n')
107  const start = lines.findIndex(text => text.trim() === '## Captures')
108  if (start === -1) return `${lines.join('\n')}\n\n## Captures\n\n${line}\n`
109
110  const next = lines.findIndex((text, i) => i > start && text.startsWith('## '))
111  let insertAt = next === -1 ? lines.length : next
112  while (insertAt > start + 1 && lines[insertAt - 1].trim() === '') insertAt -= 1
113  lines.splice(insertAt, 0, line)
114
115  return `${lines.join('\n')}\n`
116}
117
118/** One bullet for the daily note, linking every captured note so none is an orphan. */
119export function captureLine(answer: ScribeAnswer, time: string): string {
120  const links = answer.notes.map(note => `[[${noteName(note.path)}]]`).join(' ')
121
122  return `- ${time} — ${answer.dailyLine.trim()}${links ? ` ${links}` : ''}`
123}
124
hooks/features/brain/scribe.ts 122 lines
1import type { SessionMessage } from 'claude-code'
2
3import type { ScribeAnswer, ScribeNote, ScribeQuestion } from '../../../types'
4
5export const SCRIBE_NAME = 'scribe'
6export const SCRIBE_TYPE = `dotclaude:${SCRIBE_NAME}`
7export const SCRIBE_MODEL = 'sonnet'
8// Enough for a long session's substance while keeping one request well inside the context window.
9const TRANSCRIPT_LIMIT = 60_000
10
11export const SCRIBE_PROMPT = `You are the scribe of the user's notes vault (Markdown, Obsidian style).
12You read one Claude Code session transcript and keep only what is durably worth remembering:
13decisions, stated preferences, durable facts, learnings, useful links or code snippets, and project status changes.
14Skip chatter, one-off steps, and anything secret (tokens, passwords, API keys, private data).
15
16Follow the note conventions you are given exactly. Notes go in inbox/, notes/, projects/ or references/;
17[[wikilinks]] for internal links, never Markdown links; no orphan notes.
18Prefer appending to an existing note listed in the vault index over creating a near-duplicate:
19reuse its exact path. A note body must not start with a "# " title heading, the writer adds it.
20
21Ask a question whenever you are unsure where something belongs, whether it is general or project-specific,
22or whether it is worth keeping at all. Never guess: ask.
23
24Answer ONLY with one JSON object, no prose and no code fence:
25{ "notes": [{ "path": "notes/Example.md", "title": "Example", "tags": ["area/topic"], "body": "markdown" }],
26  "dailyLine": "one short line saying what was captured",
27  "questions": [{ "id": "q1", "question": "Ends with a question mark?", "options": ["2 to 4 short labels"] }] }
28When there is nothing worth keeping, answer with empty "notes" and say so in "dailyLine".`
29
30/** The conventions the scribe follows when no conventions skill is installed. */
31export const GENERIC_CONVENTIONS = `- Every note starts with YAML frontmatter: created (YYYY-MM-DD), tags (a list), source.
32- One idea per note, titled by what it is about; prefer adding to an existing note over a near-duplicate.
33- Link related notes with [[wikilinks]]; every new note links to, or is linked from, at least one other note.
34- Fleeting or unsorted items go in inbox/; durable knowledge in notes/; work on a project in projects/; sources in references/.
35- The daily note daily/YYYY-MM-DD.md logs what was captured that day, linking each note.`
36
37type DraftInput = {
38  date: string
39  /** The vault's own note conventions (the conventions skill's SKILL.md), or GENERIC_CONVENTIONS. */
40  conventions: string
41  noteIndex: string[]
42  transcript: string
43  extraText: string
44}
45
46/** Text only, oldest first; long sessions keep their most recent part. */
47export function buildTranscript(messages: readonly SessionMessage[]): string {
48  const text = messages
49    .filter(message => message.text.trim() !== '')
50    .map(message => `${message.role}: ${message.text.trim()}`)
51    .join('\n\n')
52  if (text.length <= TRANSCRIPT_LIMIT) return text
53
54  return `[earlier messages trimmed]\n\n${text.slice(-TRANSCRIPT_LIMIT)}`
55}
56
57export function draftPrompt(input: DraftInput): string {
58  const extra = input.extraText.trim()
59
60  return [
61    `Today is ${input.date}.`,
62    `## Note conventions\n\n${input.conventions}`,
63    `## Vault index (existing notes)\n\n${input.noteIndex.join('\n') || '(none)'}`,
64    extra ? `## The user asked to capture this in particular\n\n${extra}` : '',
65    `## Session transcript\n\n${input.transcript}`,
66  ]
67    .filter(Boolean)
68    .join('\n\n')
69}
70
71export function finalPrompt(draft: string, answer: ScribeAnswer, replies: Record<string, string>): string {
72  const answered = answer.questions
73    .map(question => `- ${question.question} The user answered: ${replies[question.id]}`)
74    .join('\n')
75
76  return [
77    draft,
78    `## Your first draft\n\n${JSON.stringify({ notes: answer.notes, dailyLine: answer.dailyLine })}`,
79    `## The user's answers to your questions\n\n${answered}`,
80    'Apply the answers and return the final JSON. "questions" must now be empty.',
81  ].join('\n\n')
82}
83
84const isString = (value: unknown): value is string => typeof value === 'string'
85const isStringList = (value: unknown): value is string[] => Array.isArray(value) && value.every(isString)
86
87function toNote(raw: unknown): ScribeNote {
88  const note = raw as Partial<ScribeNote>
89  if (!isString(note.path) || !isString(note.title) || !isString(note.body)) {
90    throw new Error('The scribe returned a note without path, title or body')
91  }
92
93  return { path: note.path, title: note.title, body: note.body, tags: isStringList(note.tags) ? note.tags : [] }
94}
95
96function toQuestion(raw: unknown): ScribeQuestion {
97  const question = raw as Partial<ScribeQuestion>
98  if (!isString(question.id) || !isString(question.question) || !isStringList(question.options)) {
99    throw new Error('The scribe returned a malformed question')
100  }
101
102  return { id: question.id, question: question.question, options: question.options }
103}
104
105/** Reads the outermost JSON object, tolerating a stray fence or sentence around it. */
106export function parseScribeAnswer(text: string): ScribeAnswer {
107  const start = text.indexOf('{')
108  const end = text.lastIndexOf('}')
109  if (start === -1 || end <= start) throw new Error('The scribe did not answer with JSON')
110
111  const raw = JSON.parse(text.slice(start, end + 1)) as Record<string, unknown>
112  if (!Array.isArray(raw.notes) || !isString(raw.dailyLine)) {
113    throw new Error('The scribe answer lacks "notes" or "dailyLine"')
114  }
115
116  return {
117    notes: raw.notes.map(toNote),
118    dailyLine: raw.dailyLine,
119    questions: Array.isArray(raw.questions) ? raw.questions.map(toQuestion) : [],
120  }
121}
122
hooks/features/brain/vault.ts 71 lines
1import type { FsEntry } from 'claude-code'
2
3import type { NoteRef } from '../../../types'
4
5const LIST_LIMIT = 5
6const SEARCH_LIMIT = 10
7const MINUTE = 60_000
8const HOUR = 60 * MINUTE
9const DAY = 24 * HOUR
10
11export const NOTE_FOLDERS = ['inbox', 'notes', 'projects', 'references'] as const
12
13const pad = (n: number) => String(n).padStart(2, '0')
14
15export function localDate(ms: number): string {
16  const d = new Date(ms)
17  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
18}
19
20export function localTime(ms: number): string {
21  const d = new Date(ms)
22  return `${pad(d.getHours())}:${pad(d.getMinutes())}`
23}
24
25export function relativeTime(thenMs: number, nowMs: number): string {
26  const ago = Math.max(0, nowMs - thenMs)
27  if (ago < MINUTE) return 'just now'
28  if (ago < HOUR) return `${Math.floor(ago / MINUTE)} min ago`
29  if (ago < DAY) return `${Math.floor(ago / HOUR)} h ago`
30  const days = Math.floor(ago / DAY)
31  return days === 1 ? 'yesterday' : `${days} days ago`
32}
33
34export function obsidianUrl(vault: string, path: string): string {
35  const file = path.replace(/\.md$/, '')
36  return `obsidian://open?vault=${encodeURIComponent(vault)}&file=${encodeURIComponent(file)}`
37}
38
39export function noteName(path: string): string {
40  return (path.split('/').pop() ?? path).replace(/\.md$/, '')
41}
42
43/** Counts the bullets of the daily note's "## Captures" section, where this mod logs each capture. */
44export function countCaptures(daily: string): number {
45  const lines = daily.split('\n')
46  const start = lines.findIndex(line => line.trim() === '## Captures')
47  if (start === -1) return 0
48  const rest = lines.slice(start + 1)
49  const end = rest.findIndex(line => line.startsWith('## '))
50  const section = end === -1 ? rest : rest.slice(0, end)
51
52  return section.filter(line => line.startsWith('- ')).length
53}
54
55export function newestNotes(entries: readonly FsEntry[], folder: string): NoteRef[] {
56  return entries
57    .filter(entry => entry.kind === 'file' && entry.name.endsWith('.md'))
58    .sort((a, b) => b.mtimeMs - a.mtimeMs)
59    .slice(0, LIST_LIMIT)
60    .map(entry => ({ path: `${folder}/${entry.name}`, mtimeMs: entry.mtimeMs }))
61}
62
63/** File-name matches first, then content matches, as paths relative to the vault. */
64export function mergeHits(root: string, query: string, allNotes: string[], contentHits: string[]): string[] {
65  const needle = query.toLowerCase()
66  const byName = allNotes.filter(file => noteName(file).toLowerCase().includes(needle))
67  const relative = [...byName, ...contentHits].map(file => file.slice(root.length + 1))
68
69  return [...new Set(relative)].slice(0, SEARCH_LIMIT)
70}
71
hooks/features/brain/view.tsx 164 lines
1import type { RenderElement } from 'claude-code'
2
3import type { CaptureState, NoteRef, SearchResult, VaultView } from '../../../types'
4import { buttonRow, panel } from '../../ui/chrome'
5import type { Kit, Rich } from '../../ui/chrome'
6import { COLORS } from '../../ui/tokens'
7import { noteName, obsidianUrl, relativeTime } from './vault'
8
9export type BrainModel = {
10  view: VaultView | null
11  search: SearchResult
12  capture: CaptureState
13  nowMs: number
14  runSearch: (query: string) => void
15  openNote: (url: string) => void
16  startCapture: () => void
17  close: () => void
18}
19
20/** What every drawing helper needs: the elements, the vault, and how a note opens on this surface. */
21type Draw = {
22  t: Rich
23  vault: string
24  /** The terminal opens an `obsidian:` Link itself; the desktop draws only https links, so a Button opens it. */
25  linksOpen: boolean
26  open: (url: string) => void
27}
28
29export function brainPanel(kit: Kit, model: BrainModel, columns: number): RenderElement {
30  const { Input, Text } = kit.t
31  const draw: Draw = {
32    t: kit.t,
33    vault: model.view?.vaultName ?? '',
34    linksOpen: kit.surface === 'terminal',
35    open: model.openNote,
36  }
37  return panel(
38    kit,
39    'brain',
40    columns,
41    [
42      <Input key="search" placeholder="Search the vault" value={model.search.query} onSubmit={model.runSearch} />,
43      model.search.query !== '' ? searchSection(draw, model.search) : null,
44      model.view === null ? <Text dimColor>Reading the vault…</Text> : null,
45      model.view?.missingPath ? <Text color={COLORS.resp}>{missingVault(model.view.missingPath)}</Text> : null,
46      model.view !== null && model.view.missingPath === null ? vaultSections(draw, model.view, model.nowMs) : null,
47      model.view?.missingPath ? null : captureBlock(draw, model.capture, model.startCapture),
48    ],
49    model.close,
50  )
51}
52
53export function brainPlain(view: VaultView | null, capture: CaptureState): string[] {
54  if (view === null) return ['Second brain: reading the vault.']
55  if (view.missingPath !== null) return [missingVault(view.missingPath)]
56  const today = view.today.exists ? `${view.today.captures} captures today` : 'no daily note yet'
57  return [`Second brain: ${view.inbox.length} in inbox, ${today}, capture ${capture.phase}.`]
58}
59
60function missingVault(path: string): string {
61  return `No notes vault at ${path}. Set vaultPath in the plugin's options (/config) to your notes folder.`
62}
63
64function section(d: Draw, title: string, rows: RenderElement[], empty: string): RenderElement {
65  const { Box, Text } = d.t
66  return (
67    <Box key={`section-${title}`} flexDirection="column">
68      <Text bold color={COLORS.spo2}>
69        {title}
70      </Text>
71      {rows.length === 0 ? <Text dimColor>{empty}</Text> : rows}
72    </Box>
73  )
74}
75
76/** `where` keeps keys unique when one note shows in several sections. */
77function noteLink(d: Draw, where: string, path: string): RenderElement {
78  const { Button, Link } = d.t
79  const url = obsidianUrl(d.vault, path)
80  if (d.linksOpen) return <Link href={url} label={noteName(path)} />
81
82  return <Button key={`${where}:${path}`} plain label={noteName(path)} onPress={() => d.open(url)} />
83}
84
85function noteRow(d: Draw, where: string, note: NoteRef, nowMs: number): RenderElement {
86  const { Box, Text } = d.t
87  return (
88    <Box key={`row-${where}:${note.path}`} flexDirection="row" gap={1}>
89      {noteLink(d, where, note.path)}
90      <Text color={COLORS.muted}>{relativeTime(note.mtimeMs, nowMs)}</Text>
91    </Box>
92  )
93}
94
95function searchSection(d: Draw, search: SearchResult): RenderElement {
96  return section(d, 'Results', search.hits.map(path => noteLink(d, 'result', path)), `No notes match "${search.query}".`)
97}
98
99function vaultSections(d: Draw, view: VaultView, nowMs: number): RenderElement {
100  const { Box, Text } = d.t
101  const { today } = view
102  const todayRow = today.exists ? (
103    <Box key="today-row" flexDirection="row" gap={1}>
104      {noteLink(d, 'today', today.path)}
105      <Text color={COLORS.muted}>{today.captures === 1 ? '1 capture' : `${today.captures} captures`}</Text>
106    </Box>
107  ) : (
108    <Text dimColor>No daily note yet.</Text>
109  )
110
111  return (
112    <Box key="vault-sections" flexDirection="column" gap={1}>
113      {section(d, 'Inbox', view.inbox.map(note => noteRow(d, 'inbox', note, nowMs)), 'Inbox is empty.')}
114      {section(d, 'Today', [todayRow], '')}
115      {section(d, 'Recent notes', view.recent.map(note => noteRow(d, 'recent', note, nowMs)), 'No notes yet.')}
116    </Box>
117  )
118}
119
120function captureBlock(d: Draw, capture: CaptureState, onCapture: () => void): RenderElement {
121  const { Box, Button, Text } = d.t
122  switch (capture.phase) {
123    case 'idle':
124      return buttonRow(d.t, 'capture-actions', [<Button key="capture" variant="primary" label="+ Capture this session" onPress={onCapture} />])
125    case 'working':
126      return (
127        <Box key="capture-working" flexDirection="column">
128          <Text bold>{capture.stage === 'draft' ? 'Scribe is reading the session…' : 'Scribe is writing the notes…'}</Text>
129          <Text color={COLORS.muted}>Extracting decisions, facts and open questions.</Text>
130        </Box>
131      )
132    case 'question':
133      return (
134        <Box key="capture-question" flexDirection="column" borderStyle="round" borderColor={COLORS.lock} paddingX={1}>
135          <Text bold>
136            {capture.total > 1 ? `The scribe has a question (${capture.position} of ${capture.total})` : 'The scribe has a question'}
137          </Text>
138          <Text>{capture.question.question}</Text>
139          <Text color={COLORS.muted}>Answer in the dialog: {capture.question.options.join(', ')}.</Text>
140        </Box>
141      )
142    case 'saved':
143      return (
144        <Box key="capture-saved" flexDirection="column">
145          <Text bold color={COLORS.ecg}>
146            Saved
147          </Text>
148          {capture.paths.map(path => noteLink(d, 'saved', path))}
149          {buttonRow(d.t, 'capture-actions', [<Button key="capture" label="↻ Capture again" onPress={onCapture} />])}
150        </Box>
151      )
152    case 'failed':
153      return (
154        <Box key="capture-failed" flexDirection="column">
155          <Text bold color={COLORS.alarm}>
156            Capture failed
157          </Text>
158          <Text color={COLORS.muted}>{capture.reason}</Text>
159          {buttonRow(d.t, 'capture-actions', [<Button key="capture" label="↻ Try again" onPress={onCapture} />])}
160        </Box>
161      )
162  }
163}
164
hooks/features/companion/logic.ts 180 lines
1import { escapeHtml } from './markdown'
2
3/** Where the companion is served: 127.0.0.1 only, on the configured port. */
4export function companionUrl(port: number): string {
5  return `http://127.0.0.1:${port}`
6}
7
8// The desktop app's browser pane server, in the tool-name spelling `$.mcp.call` accepts.
9export const BROWSER_SERVER = 'Claude_Browser'
10
11/** The system prompt section that tells Claude the companion is open and how to draw in it. */
12export function companionSection(url: string, dir: string) {
13  return {
14    id: 'companion:view',
15    scope: 'session',
16    text:
17      `A companion view is open at ${url}, served from ${dir}/. ` +
18      'Every file you send to the user, and every new .html, .svg, .md, .csv or .json file you write ' +
19      'in the project or the scratchpad, is shown there automatically. ' +
20      'When an answer is clearer as a picture (diagram, timeline, plan, chart, table), ' +
21      'write it as a self-contained HTML or SVG file and send it; keep the chat answer short and say it is in the companion.',
22  } as const
23}
24
25export const USAGE = 'Usage: /companion, or /companion show <absolute path to .html or .md>'
26
27/**
28 * The plugin's own server (assets/server.py) on 127.0.0.1 only, serving `dir`. It answers
29 * `token` at /__token, so the plugin can tell its own server from another program on the port.
30 */
31export function serverArgv(script: string, dir: string, port: number, token: string): string[] {
32  // -u: the "Serving HTTP" line it prints is the readiness signal awaited, so no buffering.
33  return ['python3', '-u', script, String(port), dir, token]
34}
35
36export function slugify(name: string): string {
37  const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
38  return slug === '' ? 'view' : slug
39}
40
41export function htmlTitle(source: string): string | undefined {
42  return /<title[^>]*>([^<]*)<\/title>/i.exec(source)?.[1]?.trim() || undefined
43}
44
45/** What `/companion show` accepts: an absolute path to an .html or .md file. */
46export function showableExtension(path: string): 'html' | 'md' {
47  if (!path.startsWith('/')) throw new Error(`give an absolute path, not "${path}"`)
48  const extension = /\.([^./]+)$/.exec(path)?.[1]?.toLowerCase()
49  if (extension !== 'html' && extension !== 'md') throw new Error('only .html and .md files can be shown')
50  return extension
51}
52
53export function markdownPage(title: string, body: string): string {
54  return `<!doctype html>
55<html lang="en">
56<head>
57<meta charset="utf-8">
58<meta name="viewport" content="width=device-width, initial-scale=1">
59<title>${escapeHtml(title)}</title>
60<style>
61body{margin:0;padding:22px;background:#eef1f5;color:#1a2433;font:14px/1.6 'Manrope',-apple-system,BlinkMacSystemFont,sans-serif;max-width:72ch}
62h1,h2,h3,h4,h5,h6{font-family:'Barlow Condensed','Avenir Next Condensed','Arial Narrow',sans-serif;font-weight:600;line-height:1.2;margin:1.2em 0 .4em}
63h1{font-size:28px;margin-top:0}h2{font-size:22px}h3{font-size:18px}
64a{color:#1d8fb8}
65code{font:12.5px 'JetBrains Mono',ui-monospace,Menlo,monospace;background:#e1e6ed;border-radius:4px;padding:1px 4px}
66pre{background:#e1e6ed;border:1px solid #cfd6df;border-radius:8px;padding:12px;overflow:auto}
67pre code{background:none;padding:0}
68</style>
69</head>
70<body>
71${body}
72</body>
73</html>
74`
75}
76
77/** How a file sent to or written for the user is shown in the companion. */
78export type OutputKind = 'html' | 'svg' | 'md' | 'csv' | 'json' | 'other'
79
80export function outputKind(path: string): OutputKind {
81  const extension = /\.([^./]+)$/.exec(path)?.[1]?.toLowerCase()
82  return extension === 'html' || extension === 'htm'
83    ? 'html'
84    : extension === 'svg' || extension === 'md' || extension === 'csv' || extension === 'json'
85      ? extension
86      : 'other'
87}
88
89/** The kinds a newly written file is shown for; edits and other kinds would flood the view. */
90export function isShownOnWrite(path: string): boolean {
91  return outputKind(path) !== 'other'
92}
93
94/**
95 * The session's scratchpad: /tmp/claude-<uid>/<project>/<session id>/scratchpad/, also under
96 * /private on macOS. The engine does not name it to plugins, so the layout is matched.
97 */
98export function isScratchpadPath(path: string, sessionId: string): boolean {
99  const escaped = sessionId.replace(/[^A-Za-z0-9-]/g, '')
100  // A dot segment could climb out of the scratchpad while the prefix still matches.
101  if (escaped === '' || /(^|\/)\.\.?(\/|$)/.test(path)) return false
102  return new RegExp(`^(/private)?/tmp/claude-[^/]+/[^/]+/${escaped}/scratchpad/`).test(path)
103}
104
105/** Resolves a path the way the tool did: relative ones against the session's working folder. */
106export function absolutePath(path: string, cwd: string): string {
107  return path.startsWith('/') ? path : `${cwd.replace(/\/+$/, '')}/${path}`
108}
109
110/** Splits one CSV line, honouring double-quoted fields with "" escapes. */
111export function csvCells(line: string): string[] {
112  const cells: string[] = []
113  let cell = ''
114  let quoted = false
115  for (let i = 0; i < line.length; i++) {
116    const char = line[i]
117    if (quoted && char === '"' && line[i + 1] === '"') {
118      cell += '"'
119      i++
120    } else if (char === '"') {
121      quoted = !quoted
122    } else if (char === ',' && !quoted) {
123      cells.push(cell)
124      cell = ''
125    } else {
126      cell += char
127    }
128  }
129  return [...cells, cell]
130}
131
132export function csvTable(source: string): string {
133  const rows = source.split(/\r?\n/).filter(line => line.trim() !== '').map(csvCells)
134  const [head, ...body] = rows
135  if (head === undefined) return '<p>The file is empty.</p>'
136  const cells = (row: string[], tag: string) => row.map(cell => `<${tag}>${escapeHtml(cell)}</${tag}>`).join('')
137  return `<table><thead><tr>${cells(head, 'th')}</tr></thead><tbody>${body
138    .map(row => `<tr>${cells(row, 'td')}</tr>`)
139    .join('')}</tbody></table>`
140}
141
142export function jsonBlock(source: string): string {
143  try {
144    return `<pre><code>${escapeHtml(JSON.stringify(JSON.parse(source), null, 2))}</code></pre>`
145  } catch {
146    return `<p>Not valid JSON; shown as written.</p><pre><code>${escapeHtml(source)}</code></pre>`
147  }
148}
149
150/** A page for a file kind that is not drawn: where it is, so it can be opened without searching. */
151export function fileCard(path: string): string {
152  return `<p>This file type is not drawn here. It is at:</p><pre><code>${escapeHtml(path)}</code></pre>`
153}
154
155export function outputPage(title: string, body: string, path: string): string {
156  return `<!doctype html>
157<html lang="en">
158<head>
159<meta charset="utf-8">
160<meta name="viewport" content="width=device-width, initial-scale=1">
161<title>${escapeHtml(title)}</title>
162<style>
163body{margin:0;padding:22px;background:#eef1f5;color:#1a2433;font:14px/1.6 -apple-system,BlinkMacSystemFont,sans-serif}
164.path{color:#5b6b80;font:12px ui-monospace,Menlo,monospace;margin-bottom:14px;word-break:break-all}
165table{border-collapse:collapse;background:#fff}
166th,td{border:1px solid #cfd6df;padding:4px 10px;text-align:left;font-variant-numeric:tabular-nums}
167th{background:#e1e6ed}
168pre{background:#e1e6ed;border:1px solid #cfd6df;border-radius:8px;padding:12px;overflow:auto}
169code{font:12.5px ui-monospace,Menlo,monospace}
170svg{max-width:100%;height:auto}
171</style>
172</head>
173<body>
174<div class="path">${escapeHtml(path)}</div>
175${body}
176</body>
177</html>
178`
179}
180
hooks/features/companion/markdown.ts 115 lines
1// A small Markdown renderer for `/companion show <file.md>`: headings, flat
2// lists, fenced and inline code, emphasis and links. No dependency, by design.
3
4const SAFE_HREF = /^(https?:|mailto:|#|\/|\.{0,2}\/|[\w-]+(\.[\w-]+)*(\/|$))/i
5
6export function escapeHtml(text: string): string {
7  return text
8    .replaceAll('&', '&amp;')
9    .replaceAll('<', '&lt;')
10    .replaceAll('>', '&gt;')
11    .replaceAll('"', '&quot;')
12}
13
14function renderLink(label: string, href: string): string {
15  // A `javascript:` or `data:` link in a rendered note must not run in the view.
16  if (!SAFE_HREF.test(href)) return label
17  return `<a href="${href}" target="_blank" rel="noopener">${label}</a>`
18}
19
20function renderInline(text: string): string {
21  // Code spans are cut out first so emphasis and links never apply inside them.
22  return escapeHtml(text)
23    .split(/(`[^`]+`)/)
24    .map(part =>
25      part.startsWith('`') && part.endsWith('`') && part.length > 1
26        ? `<code>${part.slice(1, -1)}</code>`
27        : part
28            .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, href) => renderLink(label, href))
29            .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
30            .replace(/(^|[^\w*])\*([^*]+)\*/g, '$1<em>$2</em>')
31            .replace(/(^|[^\w_])_([^_]+)_/g, '$1<em>$2</em>'),
32    )
33    .join('')
34}
35
36type ListKind = 'ul' | 'ol'
37
38function listKindOf(line: string): ListKind | undefined {
39  if (/^\s*[-*+]\s+/.test(line)) return 'ul'
40  if (/^\s*\d+[.)]\s+/.test(line)) return 'ol'
41  return undefined
42}
43
44function listItemText(line: string): string {
45  return line.replace(/^\s*([-*+]|\d+[.)])\s+/, '')
46}
47
48export function renderMarkdown(source: string): string {
49  const lines = source.replace(/\r\n?/g, '\n').split('\n')
50  const html: string[] = []
51  let index = 0
52
53  while (index < lines.length) {
54    const line = lines[index] ?? ''
55
56    if (line.startsWith('```')) {
57      const code: string[] = []
58      index += 1
59      while (index < lines.length && !(lines[index] ?? '').startsWith('```')) {
60        code.push(lines[index] ?? '')
61        index += 1
62      }
63      html.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`)
64      index += 1
65      continue
66    }
67
68    const heading = /^(#{1,6})\s+(.*)$/.exec(line)
69    if (heading) {
70      const level = heading[1]?.length ?? 1
71      html.push(`<h${level}>${renderInline(heading[2] ?? '')}</h${level}>`)
72      index += 1
73      continue
74    }
75
76    const kind = listKindOf(line)
77    if (kind) {
78      const items: string[] = []
79      while (index < lines.length && listKindOf(lines[index] ?? '') === kind) {
80        items.push(`<li>${renderInline(listItemText(lines[index] ?? ''))}</li>`)
81        index += 1
82      }
83      html.push(`<${kind}>${items.join('')}</${kind}>`)
84      continue
85    }
86
87    if (line.trim() === '') {
88      index += 1
89      continue
90    }
91
92    const paragraph: string[] = []
93    while (index < lines.length && isParagraphLine(lines[index] ?? '')) {
94      paragraph.push((lines[index] ?? '').trim())
95      index += 1
96    }
97    html.push(`<p>${renderInline(paragraph.join(' '))}</p>`)
98  }
99
100  return html.join('\n')
101}
102
103function isParagraphLine(line: string): boolean {
104  return (
105    line.trim() !== '' &&
106    !line.startsWith('```') &&
107    !/^#{1,6}\s/.test(line) &&
108    listKindOf(line) === undefined
109  )
110}
111
112export function markdownTitle(source: string): string | undefined {
113  return /^#{1,6}\s+(.+)$/m.exec(source)?.[1]?.trim()
114}
115
hooks/features/companion/view.tsx 46 lines
1import type { RenderElement } from 'claude-code'
2
3import { buttonRow, panel } from '../../ui/chrome'
4import type { Kit } from '../../ui/chrome'
5import { COLORS } from '../../ui/tokens'
6
7export type CompanionModel = {
8  isOpen: boolean
9  url: string
10  open: () => void
11  stop: () => void
12  close: () => void
13}
14
15const ABOUT =
16  'A local page in the built-in browser pane where Claude draws answers that read better as a picture.'
17const SHOW_HINT = '/companion show <absolute path> adds an .html or .md file as a view.'
18
19export function companionPanel(kit: Kit, model: CompanionModel, columns: number): RenderElement {
20  const { Button, Text } = kit.t
21  return panel(
22    kit,
23    'companion',
24    columns,
25    [
26      <Text bold color={model.isOpen ? COLORS.spo2 : COLORS.muted}>
27        {model.isOpen ? `● Companion open at ${model.url}` : '● Companion closed'}
28      </Text>,
29      <Text dimColor>{ABOUT}</Text>,
30      <Text dimColor>{SHOW_HINT}</Text>,
31      buttonRow(kit.t, 'companion-actions', [
32        model.isOpen ? (
33          <Button key="stop" label="■ Close companion" onPress={model.stop} />
34        ) : (
35          <Button key="open" variant="primary" label="▶ Open companion" onPress={model.open} />
36        ),
37      ]),
38    ],
39    model.close,
40  )
41}
42
43export function companionPlain(isOpen: boolean, url: string): string[] {
44  return [isOpen ? `Companion open at ${url}.` : 'Companion closed.', SHOW_HINT]
45}
46
hooks/features/crew/logic.ts 42 lines
1import type { AgentInfo, AgentStatus } from 'claude-code'
2
3export const FINISHED_SHOWN = 5
4const ACTIVITY_MAX_CHARS = 200
5const LIVE_STATUSES: ReadonlySet<AgentStatus> = new Set(['pending', 'running', 'waiting', 'idle'])
6// The argument that best names what a call works on, most specific first.
7const TARGET_KEYS = ['file_path', 'notebook_path', 'path', 'command', 'pattern', 'url', 'query', 'description']
8const PATH_KEYS: ReadonlySet<string> = new Set(['file_path', 'notebook_path', 'path'])
9
10export function nameOf(agent: AgentInfo): string {
11  return agent.name ?? agent.type
12}
13
14export function isLive(agent: AgentInfo): boolean {
15  return LIVE_STATUSES.has(agent.status)
16}
17
18export function formatElapsed(ms: number): string {
19  const seconds = Math.max(0, Math.floor(ms / 1000))
20  const hours = Math.floor(seconds / 3600)
21  const minutes = Math.floor((seconds % 3600) / 60)
22  const rest = String(seconds % 60).padStart(2, '0')
23  return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}:${rest}` : `${minutes}:${rest}`
24}
25
26function relativeTo(cwd: string, path: string): string {
27  return path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
28}
29
30/** A call as one line: the tool and what it works on, e.g. `Write hooks/register.tsx`. */
31export function describeCall(tool: string, args: Readonly<Record<string, unknown>>, cwd: string): string {
32  const key = TARGET_KEYS.find(candidate => typeof args[candidate] === 'string' && args[candidate] !== '')
33  if (key === undefined) return tool
34  const value = String(args[key])
35  const target = PATH_KEYS.has(key) ? relativeTo(cwd, value) : value.replace(/\s+/g, ' ').trim()
36  return `${tool} ${target}`.slice(0, ACTIVITY_MAX_CHARS)
37}
38
39export function keepListed<T>(byId: Record<string, T>, ids: ReadonlySet<string>): Record<string, T> {
40  return Object.fromEntries(Object.entries(byId).filter(([id]) => ids.has(id)))
41}
42
hooks/features/crew/view.tsx 152 lines
1import type { AgentInfo, AgentStatus, RenderElement } from 'claude-code'
2
3import type { CrewComposer } from '../../../types'
4import { buttonRow, panel } from '../../ui/chrome'
5import type { Kit, Rich } from '../../ui/chrome'
6import { COLORS } from '../../ui/tokens'
7import { FINISHED_SHOWN, formatElapsed, isLive, nameOf } from './logic'
8
9const NO_ACTIVITY = 'No tool calls yet'
10const EMPTY = 'No agents are running.'
11
12const CHIP_COLOR: Partial<Record<AgentStatus, string>> = {
13  running: COLORS.ecg,
14  waiting: COLORS.resp,
15  failed: COLORS.alarm,
16}
17
18export type CrewModel = {
19  agents: AgentInfo[]
20  polledAt: number
21  activity: Record<string, string>
22  startedAt: Record<string, number>
23  composer: CrewComposer | null
24  stop: (agent: AgentInfo) => void
25  toggleComposer: (agentId: string) => void
26  keepDraft: (agentId: string, draft: string) => void
27  send: (agent: AgentInfo, text: string) => void
28  sendDraft: (agent: AgentInfo) => void
29  close: () => void
30}
31
32type CardView = { elapsed: string; now: string; draft: string | undefined }
33
34export function crewPanel(kit: Kit, model: CrewModel, columns: number): RenderElement {
35  const { Box, Text } = kit.t
36  const { live, finished } = split(model.agents)
37  return panel(
38    kit,
39    'crew',
40    columns,
41    [
42      live.length === 0 ? <Text color={COLORS.muted}>{EMPTY}</Text> : null,
43      ...live.map(agent => agentCard(kit.t, model, agent, viewOf(model, agent))),
44      finished.length > 0 ? (
45        <Box key="crew-finished" flexDirection="column">
46          <Text bold color={COLORS.muted}>
47            Finished
48          </Text>
49          {finished.map(agent => finishedRow(kit.t, agent))}
50        </Box>
51      ) : null,
52    ],
53    model.close,
54  )
55}
56
57export function crewPlain(model: Pick<CrewModel, 'agents' | 'polledAt' | 'activity' | 'startedAt' | 'composer'>): string[] {
58  const { live, finished } = split(model.agents)
59  const cards = live.map(agent => {
60    const view = viewOf(model, agent)
61    return `${nameOf(agent)} (${agent.status}, ${view.elapsed})\nWhy: ${agent.description}\nNow: ${view.now}`
62  })
63  const lines = cards.length > 0 ? cards : [EMPTY]
64  if (finished.length > 0) {
65    lines.push(`Finished: ${finished.map(agent => `${nameOf(agent)} (${agent.status})`).join(', ')}`)
66  }
67  return [lines.join('\n\n')]
68}
69
70function split(agents: AgentInfo[]): { live: AgentInfo[]; finished: AgentInfo[] } {
71  return {
72    live: agents.filter(isLive),
73    finished: agents.filter(agent => !isLive(agent)).slice(-FINISHED_SHOWN).reverse(),
74  }
75}
76
77function viewOf(model: Pick<CrewModel, 'polledAt' | 'activity' | 'startedAt' | 'composer'>, agent: AgentInfo): CardView {
78  return {
79    elapsed: formatElapsed(model.polledAt - (model.startedAt[agent.id] ?? model.polledAt)),
80    now: model.activity[agent.id] ?? NO_ACTIVITY,
81    draft: model.composer?.agentId === agent.id ? model.composer.draft : undefined,
82  }
83}
84
85function labelled(t: Rich, agentId: string, label: string, value: string): RenderElement {
86  const { Box, Text } = t
87  return (
88    <Box key={`${label.toLowerCase()}-${agentId}`} gap={1}>
89      <Text color={COLORS.muted}>{label}</Text>
90      <Box key={`${label.toLowerCase()}-value-${agentId}`} flexShrink={1} minWidth={0}>
91        <Text color={COLORS.text} wrap="truncate-end">
92          {value}
93        </Text>
94      </Box>
95    </Box>
96  )
97}
98
99function composerRow(t: Rich, model: CrewModel, agent: AgentInfo, draft: string): RenderElement {
100  const { Box, Button, Input } = t
101  return (
102    <Box key={`composer-${agent.id}`} gap={1}>
103      <Input
104        key={`draft-${agent.id}`}
105        placeholder={`Message for ${nameOf(agent)}`}
106        value={draft}
107        submitLabel="send"
108        onInput={value => model.keepDraft(agent.id, value)}
109        onSubmit={value => model.send(agent, value)}
110      />
111      {buttonRow(t, `send-row-${agent.id}`, [<Button key={`send-${agent.id}`} variant="primary" label="➤ Send" onPress={() => model.sendDraft(agent)} />])}
112    </Box>
113  )
114}
115
116function agentCard(t: Rich, model: CrewModel, agent: AgentInfo, view: CardView): RenderElement {
117  const { Box, Button, Text } = t
118  return (
119    <Box key={`card-${agent.id}`} flexDirection="column" borderStyle="round" borderColor={COLORS.plate} paddingX={1}>
120      <Box key={`card-head-${agent.id}`} justifyContent="space-between" gap={1}>
121        <Text bold color={COLORS.text} wrap="truncate-end">
122          {nameOf(agent)}
123        </Text>
124        <Box key={`card-status-${agent.id}`} gap={1} flexShrink={0}>
125          <Text color={CHIP_COLOR[agent.status] ?? COLORS.muted}>{`● ${agent.status}`}</Text>
126          <Text color={COLORS.muted}>{view.elapsed}</Text>
127        </Box>
128      </Box>
129      {labelled(t, agent.id, 'Why', agent.description)}
130      {labelled(t, agent.id, 'Now', view.now)}
131      {buttonRow(t, `card-actions-${agent.id}`, [
132        <Button key={`stop-${agent.id}`} label="■ Stop…" onPress={() => model.stop(agent)} />,
133        <Button key={`message-${agent.id}`} label="✉ Message" onPress={() => model.toggleComposer(agent.id)} />,
134      ])}
135      {view.draft !== undefined && composerRow(t, model, agent, view.draft)}
136    </Box>
137  )
138}
139
140function finishedRow(t: Rich, agent: AgentInfo): RenderElement {
141  const { Box, Text } = t
142  return (
143    <Box key={`done-${agent.id}`} gap={1}>
144      <Text color={CHIP_COLOR[agent.status] ?? COLORS.muted}>●</Text>
145      <Text color={COLORS.text} wrap="truncate-end">
146        {nameOf(agent)}
147      </Text>
148      <Text color={COLORS.muted}>{agent.status}</Text>
149    </Box>
150  )
151}
152
hooks/features/gate/logic.ts 75 lines
1import type { GatePhase } from '../../../types'
2import { mentionsAny, startsWithAny } from '../../config'
3
4export const GUARD_FAILED_REASON = 'Edit blocked: the gate could not check the phase, so it refused.'
5
6// MultiEdit is absent from this build's tool table, so the matcher compares names as strings.
7export const EDIT_TOOLS = /^(Edit|Write|NotebookEdit|MultiEdit)$/
8
9// Matched inside the path relative to the project root. `/specs/` already covers
10// `/docs/specs/`. Plans are Markdown; mockups are pages and pictures too.
11const PLAN_DIRS: readonly { dir: string; extensions: readonly string[] }[] = [
12  { dir: '/docs/plans/', extensions: ['.md'] },
13  { dir: '/specs/', extensions: ['.md'] },
14  { dir: '/docs/superpowers/', extensions: ['.md'] },
15  { dir: '/.claude/plans/', extensions: ['.md'] },
16  { dir: '/mockups/', extensions: ['.md', '.html', '.svg'] },
17]
18const PLAN_SUFFIXES = ['.plan.md', '.spec.md']
19
20export const GATE_TOAST: Record<GatePhase, string> = {
21  brainstorm: 'Back to brainstorm, edits locked',
22  approved: 'Approved, edits unlocked',
23}
24
25/** Why an edit is refused while brainstorming, naming the words that unlock it. */
26export function denyReason(approvalWords: readonly string[]): string {
27  const words = approvalWords.map(word => `"${word}"`)
28  const said = words.length > 1 ? `${words.slice(0, -1).join(', ')} or ${words.at(-1)}` : (words[0] ?? 'an approval')
29  return `Edit blocked: the session is still brainstorming. Start your message with ${said}, or press Approve.`
30}
31
32/**
33 * The phase a prompt asks for. A lock phrase anywhere locks; an approval counts only when
34 * the prompt starts with it, so a question that mentions it ("is it approved?") does not.
35 */
36export function phaseAskedBy(text: string, words: { approvalWords: readonly string[]; lockPhrases: readonly string[] }): GatePhase | undefined {
37  // Locking wins when a prompt holds both: refusing an edit is the safe mistake.
38  if (mentionsAny(text, words.lockPhrases)) return 'brainstorm'
39  if (startsWithAny(text, words.approvalWords)) return 'approved'
40  return undefined
41}
42
43/** Whether `path` is `root` or lies under it; both already resolved. */
44export function isInside(path: string, root: string): boolean {
45  const base = root.endsWith('/') ? root : `${root}/`
46  return path === root || path.startsWith(base)
47}
48
49export function resolvePath(path: string, cwd: string): string {
50  const absolute = path.startsWith('/') ? path : `${cwd}/${path}`
51  const segments: string[] = []
52  for (const segment of absolute.split('/')) {
53    if (segment === '' || segment === '.') continue
54    if (segment === '..') segments.pop()
55    else segments.push(segment)
56  }
57  return `/${segments.join('/')}`
58}
59
60/**
61 * Plans and specs stay writable while brainstorming. `relative` is the resolved path relative
62 * to the project root, so folders above the project never count.
63 */
64export function isWritableWhileBrainstorming(relative: string): boolean {
65  const path = `/${relative}`
66  const lower = path.toLowerCase()
67  if (PLAN_SUFFIXES.some(suffix => lower.endsWith(suffix))) return true
68  return PLAN_DIRS.some(({ dir, extensions }) => path.includes(dir) && extensions.some(extension => lower.endsWith(extension)))
69}
70
71export function editedPath(e: { file_path?: unknown; notebook_path?: unknown }): string | undefined {
72  const path = e.notebook_path ?? e.file_path
73  return typeof path === 'string' ? path : undefined
74}
75
hooks/features/gate/view.tsx 47 lines
1import type { RenderElement } from 'claude-code'
2
3import type { GatePhase } from '../../../types'
4import { buttonRow, panel } from '../../ui/chrome'
5import type { Kit } from '../../ui/chrome'
6import { COLORS } from '../../ui/tokens'
7
8export type GateModel = {
9  phase: GatePhase
10  approve: () => void
11  relock: () => void
12  close: () => void
13}
14
15const HINT = 'Phase follows your words: approval unlocks, "back to brainstorm" locks again.'
16
17const CHIP: Record<GatePhase, { label: string; color: string }> = {
18  brainstorm: { label: 'Brainstorming, edits locked', color: COLORS.lock },
19  approved: { label: 'Approved, edits open', color: COLORS.ecg },
20}
21
22export function gatePanel(kit: Kit, gate: GateModel, columns: number): RenderElement {
23  const { Button, Text } = kit.t
24  const chip = CHIP[gate.phase]
25  const action =
26    gate.phase === 'brainstorm' ? (
27      <Button key="approve" variant="primary" label="✓ Approve" onPress={gate.approve} />
28    ) : (
29      <Button key="relock" label="↺ Back to brainstorm" onPress={gate.relock} />
30    )
31  return panel(
32    kit,
33    'gate',
34    columns,
35    [
36      <Text color={chip.color} bold>{`● ${chip.label}`}</Text>,
37      <Text dimColor>{HINT}</Text>,
38      buttonRow(kit.t, 'gate-actions', [action]),
39    ],
40    gate.close,
41  )
42}
43
44export function gatePlain(phase: GatePhase): string[] {
45  return [`${CHIP[phase].label}. ${HINT}`]
46}
47