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…

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.
┌──────────── 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 │
└─────────────────────────────────────┘ └─────────────────────────────────┘
/vitals opens it again.$.state only (dotclaude.mainView, dotclaude.sideView): a press switches views at once, runs no slash command and writes nothing to the transcript.▍Title line.hooks/ui/tabs.tsx): the shown tab is the primary button; switching is $.state only.primary; labels say what happens, with a glyph (✓ Approve, ↻ Refresh, × Close).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.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..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.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.~/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 <%); 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).<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 --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.$.session.send).git status) of the project, and the caveman flag file..claude/ledger.md.gh auth status and gh repo list, and a Haiku call drafting the commit message from the ledger while the field is empty.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).
| Feature | When | Model | What is sent |
|---|---|---|---|
| Ledger, why | After each successful edit | Haiku | Claude's latest message (up to 4,000 characters) and the change (up to 1,500 characters of old and new text) |
| Ledger, commit message | Draft commit message, or the Git panel opening with changes | Haiku | The session's ledger entries (file, lines, why) |
| Brain, scribe | + Capture this session | Sonnet (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 now | Sonnet | Up 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 catalogs | Sonnet | Plugin catalog entries (name, author, description), the same recent prompts, what you have installed |
| Command | Opens | |
|---|---|---|
/vitals | the workspace on the vitals rail (keeps the side panel) | |
| `/gate [approve\ | lock]` | Gate; approve and lock change the phase |
/plan-review | Plan review, or says there is no plan yet | |
/ledger [commit-message] | Ledger; commit-message answers a drafted message | |
/mac | Mac | |
/brain [capture [text]] | Notes; capture starts the scribe | |
/radar | Radar | |
/companion [show <absolute path>] | Companion, opening the companion if closed | |
/git-panel | Git | |
/crew | Crew (/agents is a Claude Code built-in name) |
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.
| Option | Default | What it does |
|---|---|---|
vaultPath | ~/Notes | Your notes vault; ~ is your home folder, absolute paths work too |
vaultName | Notes | The vault's name in Obsidian, for obsidian:// links |
conventionsSkill | notes-conventions | A skill under <claude dir>/skills/ whose SKILL.md holds your vault's note conventions; without it the scribe uses built-in ones |
approvalWords | approved, go ahead, proceed, lgtm, ship it, let's do it, sounds good, GO | Comma-separated; whole words, any case, except a word in capitals matches only in capitals |
lockPhrases | back to brainstorm, wait, stop | Comma-separated; lock edits again |
companionPort | 4747 | The companion's port on 127.0.0.1 |
<claude dir> is CLAUDE_CONFIG_DIR when set, else ~/.claude.
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.
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'].Load it from its folder (claude --plugin-dir <this folder>), then:
claude plugin validate .
claude plugin test .hooks/register.tsx 2636 lines1// 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 falsehooks/features/brain/notes.ts 124 lines1import 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, '<%')
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}
124hooks/features/brain/scribe.ts 122 lines1import 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}
122hooks/features/brain/vault.ts 71 lines1import 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}
71hooks/features/brain/view.tsx 164 lines1import 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}
164hooks/features/companion/logic.ts 180 lines1import { 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}
180hooks/features/companion/markdown.ts 115 lines1// 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('&', '&')
9 .replaceAll('<', '<')
10 .replaceAll('>', '>')
11 .replaceAll('"', '"')
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}
115hooks/features/companion/view.tsx 46 lines1import 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}
46hooks/features/crew/logic.ts 42 lines1import 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}
42hooks/features/crew/view.tsx 152 lines1import 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}
152hooks/features/gate/logic.ts 75 lines1import 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}
75hooks/features/gate/view.tsx 47 lines1import 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