Babysits a long Claude Code session so you don't have to: the context fill is watched, the recorder drafts the checkpoint, compaction happens at the right…

The babysitter for long Claude Code sessions. Session state that survives the context window.
Without it, keeping a long session on track is a watch: the context fill, the moment to ask for a checkpoint, the moment to compact, whether the work was picked back up. windvane keeps that watch. The fill is read on a timer, the checkpoint is drafted when it is needed, the compaction comes at a turn boundary once the state is banked, and one prompt of windvane's resumes the work, so the model carries on without a hand on it and without stalling near a full window.
windvane is a Claude Code plugin that keeps the state of a session and steers it. A recorder drafts the checkpoint from what the session already did. Compaction happens at a chosen point with that state banked. The project rules and the checkpoint ride inside the compacted conversation and at the head of every subagent's prompt. Tool results are trimmed and secrets in them are redacted. Project memory and rules are a tool call away, and a token ledger, a pane and a status segment show where the session stands.
The principle: never ask the model to write what the machine can record.

git clone https://github.com/20alexl/windvane.git windvane
claude plugin marketplace add ./windvane
claude plugin install windvane@windvane
Python 3.10 or later must be on PATH, or named in the python config row or in WINDVANE_PYTHON. The engine has no dependencies. Set the config rows with /plugin configure windvane@windvane, or pass --config KEY=VALUE to the install command. The first interactive session offers to install the optional embedding model for memory and session search. Details are in docs/install.md.
The mod (the status segment, band, pane, tools and commands) needs an interactive session. A headless claude -p run gets the classic hooks only, described under Headless and unattended.
Every row happens without a call from you or the model.
| Moment | What windvane does |
|---|---|
| Session start | Prints a banner with the project's rules (the ones with detectors first), the count of its own past mistakes and of the pooled ones, the restored checkpoint (whole after a resume or a compaction, a short teaser on a fresh start) ending with the repo's movement since it and the working tree now, the last session's files and activity, and recurring errors. Seeds the default rule pack once per project. Starts the background daemon. States autonomy mode when it is on. |
| You send a prompt | Captures a decision from what you typed ("let's use X", "from now on always Y"). Keeps the prompt so the destructive-command rule can see that you approved. Delivers any staged context-pressure nudge. |
| Before an edit | Warns about past mistakes tied to the file (the project's own first), an edit loop, TODO markers in a code file and the memories that match it. Checks that a proposed import resolves in the code index and lists the modules that import the file. Subagents are skipped. |
| Before a read | Once per file per session: an orientation from the code index and the file's best memories. |
| Before a shell command | Matches the command against every rule that carries a detector, records the match and shows the rule before the command runs. In autonomy mode a detector marked deny refuses the command. |
| Before any tool | In autonomy mode after a halt, denies every tool except the few that let the run leave a record. |
| After an edit | Counts the edit for the loop warning. |
| After a shell command | Tracks test runs and speaks on the first result and on each flip. Logs a recognised error as a mistake. After a search that found nothing, names the nearest symbols in the code index. |
| After a failed tool | Logs the error as a mistake unless it was a failing test run. If the error matches one seen before, shows the known fix at once. |
| After a batch of tool calls | Accounts the calls to the turn for stall detection and records detector matches on non-shell tools. |
| Plan approved or task completed | Asks for the plan to be banked as a checkpoint, or counts a finished task as a step done. |
| Turn ends | Saves an automatic handoff. If the turn edited files and nothing was banked on purpose, banks the drafted checkpoint, at most once per ten minutes. A checkpoint saved during the turn is brought up to the draft in place, with the turn's own closing reply and edits. Judges the turn for stalls. Ticks the background miner. |
| Before compaction | Banks the drafted checkpoint and indexes the transcript while the detail is still in it. |
| After compaction | Opens a new pressure cycle. The session-start banner that follows restores the state, unless the compacted conversation already carries it. |
| API failure or notification | Records the failure with its type and, for a usage limit, the reset time. In autonomy mode, sends an alert. |
| Session end | Writes the run report for a substantial session and starts the post-session miner. |
The mod adds these, in an interactive session:
| Moment | What windvane does |
|---|---|
| Every 10 seconds | Mirrors the context fill, draws the status segment and the band, and watches for the compaction point. |
| A tool result arrives | Keeps its head and tail when it passes the budget, and redacts private keys, vendor keys and literal secret values. |
| An agent is launched | Puts the project's rules, and the past mistakes for the files the prompt names, at the head of the agent's prompt. |
| A compaction finishes | Places the rules and the checkpoint right after the summary, inside the conversation. |
| A turn ends | Adds the turn's tokens and cost to the project's ledger. |
The model calls these as mcp__windvane__<name>. Each takes an optional project_path.
save, restore, list. A bare save accepts the drafted record. A field given amends that field.remember, recall, search, forget, add_rule, list_rules, modify, delete, promote, archive, restore, list_mistakes, acknowledge_mistake, set_detector.mistake, decision. Records what the hooks did not catch.search, decisions, errors, struggles, replay, timeline, run_report, run_status, status. Reads the history of past sessions on the project.map, impact. Asks the code index where a symbol is defined and what depends on a file.The commands are /windvane (a pane with the checkpoint, the rules and the mistakes for the last file touched), /windvane-cost (tokens and cost, today and over all days), /remember (stores the selected transcript text as a decision), /windvane-strict, /windvane-export and /windvane-import.
The recorder drafts the whole checkpoint. It takes the task, warnings and context needed from the last checkpoint or the first prompt, the files from the session's edits, the pending and completed steps from the task list, the git commits and the carried steps the closing reply says are done (the previous record's completed steps stay, the newest twelve), and the handoff note from the closing paragraph of the last reply. The model accepts the draft with checkpoint(save) and no other argument, or amends one field.
A checkpoint keeps the last 20 deliberate saves per project in a ring. Restore reads this session's newest deliberate checkpoint, skipping one saved on a branch of the conversation that was rewound, and falls back to the project's newest. The answer says how far the repository moved since the save.
Compaction is the model's call, made at a step end rather than at whatever state happened to be saved. The mod mirrors the engine's pressure marks and never compacts on its own: compact_now banks the draft and compacts at the next turn boundary, and a checkpoint save is only a save, inside the band or out of it. The engine tells the model where the fill stands three times per cycle, each once: at the early_compaction row when set (a fill such as 45% of the compaction point, or once one turn has cost as much as $0.40), at the heads-up, and at the last call just above the trigger. Every checkpoint save past the early mark answers with the fill and puts the compaction to the model, and a turn that ends without one is asked once more at its next start. After a compaction windvane started, the session does not wait for a person: windvane sends one prompt that resumes the work from the checkpoint, with the rules and the checkpoint arriving in the session-start brief (the continue_after_compact option turns this off). If the model never calls compact_now, Claude Code compacts at its own trigger and the before-compaction hook banks the draft as the floor.
When the compaction finishes, one message after the summary carries the rules and the checkpoint. The session-start banner then leaves them out, so they appear once.
The marks are percentages of the compaction point, the figure the status segment shows. The point is the window Claude Code is configured to compact at; auto-compaction fires 32,000 tokens under it (96% of a 750K point). The last call sits 20,000 tokens above that trigger (10,000 on a 200K window; about 93% of a 750K point), the heads-up about a tenth of the window before the point (about 87%), and the optional early mark wherever the early_compaction row puts it. The two computed marks can be set as percentages (headsup_percent, last_call_percent). Each note is said once per compaction cycle. A fallback reminder comes after 60 turns with no checkpoint and no finished step.
Entries are rules, mistakes, decisions and discoveries, stored per project. A rule is never archived or decayed. A rule written at a workspace root binds every project beneath it. Rules show in the banner, before a compaction and in every subagent's prompt. File-specific mistakes show before an edit of that file.
The pack seeds on a project's first fresh session. The default tier has 16 rules: ask before destructive commands, ask before anything leaves the machine, never kill by name, search before reading, verify before claiming, finish prerequisites first, bank the checkpoint, keep secrets out of code and logs, remember what surprised you, one purpose per function, honest names, nothing left lying around, no swallowed errors, same inputs same outputs, prefer the real thing to a stand-in, and build for performance from the start. Three of them carry detectors: the destructive command, the outbound action and the kill by name.
The strict tier has 11 rules on style and workflow and is opt-in: set strict_pack, run /windvane-strict, or run python -m windvane.rules seed --project DIR --strict. Seeding skips any rule the project or an ancestor already has in substance. To drop a rule, call memory(delete) with its id. To opt out of the pack, set default_rules to false.
A detector is hand-written: tool names, a command regex, path globs, an input regex and a note. memory(add_rule) and memory(set_detector) take one. Every match is recorded and the rule is shown before a matching shell command runs.
Nine rows are set in the plugin config: python, status_segment, result_budget, semantic, alert_command, strict_pack, autonomy, continue_after_compact and early_compaction. Every engine setting in the table below is also a key in .windvane/config.json in the project or in ~/.windvane/config.json, and an environment variable named WINDVANE_ plus the key in capitals. The first layer that names a key wins, in this order: environment, project file, plugin config, user file.
| Key | Default | What it does |
|---|---|---|
structure | false | Seed CLAUDE.md, .learnings/ and session-logs/ where missing |
default_rules | true | Seed the default rule pack once per project |
strict_pack | false | Seed the strict pack beside the default one |
compliance | true | Match rules that carry a detector against tool calls |
autonomy | false | Stall nudges and the halt brake |
alert_command | empty | Shell command that receives one alert line |
goal_turn_cap | 150 | Turns under one /goal before the halt is armed |
stall_turns | 3 | Consecutive no-effect turns per strike |
stall_decay | 5 | Consecutive good turns that remove one strike |
strike_cap | 3 | Strikes before the halt (autonomy mode only) |
output_reserve | 32000 | Tokens between the compaction point and where it fires |
headsup_percent | computed | Percent of the compaction point for the heads-up: a tenth of the window under the point, about 87 on a 750K point |
last_call_percent | computed | Percent of the compaction point for the last call (CHECKPOINT NOW): 20000 tokens under the trigger, 10000 on a 200K window, about 93 on a 750K point |
checkpoint_cadence | 60 | Turns with no checkpoint or finished step before the fallback reminder |
budget_five_hour_pct | 90 | Usage percent of the 5-hour window that nudges |
budget_seven_day_pct | 95 | Usage percent of the 7-day window that nudges |
budget_pct | 90 | Usage percent of any other rate-limit window that nudges |
live_mine | 300 | Seconds between live mining ticks at turn end, 0 disables |
non_project_dirs | empty | Comma-separated directory names that are never a project |
git_trace | empty | File that logs every git call, for debugging |
The four rows not in the table are plugin rows only. They default to: python empty (the interpreter on PATH), status_segment true, result_budget 60000 characters, semantic false. WINDVANE_PYTHON, WINDVANE_RESULT_BUDGET and WINDVANE_SEMANTIC are their environment switches. WINDVANE_DIR moves the store. The full reference is docs/configuration.md.
The store is ~/.windvane, or the folder named by WINDVANE_DIR.
manifest.json maps each project path to a hash.projects/<hash>/ holds one project's memory, its checkpoint ring and the latest handoff.checkpoints/ is the global ring and the per-task checkpoint files.sessions/ holds per-session working files, including the context mirror.config.json holds your defaults for every project.Each project may also hold .windvane/ with config.json, runs/ (run reports) and export/ (the output of /windvane-export).
To bring over a claude-engram store, run /windvane-import, or python -m windvane.doctor --import from the cloned folder. The import copies the memory files, the rings and the session index, leaves out per-session state and the runtime files of a live engram process, and never changes or deletes the source. A destination that already has a store is refused unless you pass --merge, which adds only the projects it lacks.
Nothing leaves the machine. The one download is the embedding model from Hugging Face, fetched by the daemon on the first use of the semantic tier, and only when that tier is on. In detail:
Programs it runs. The Python interpreter named by the python row or WINDVANE_PYTHON, else python on PATH, with the engine's own modules from the plugin folder: the classic hook client windvane/daemon_client.py <event> for each hook in hooks/hooks.json, which asks the daemon and otherwise runs python -m windvane.events <event> itself; the daemon python -m windvane.daemon, started by the first hook client when none is running; python -m windvane.brief for the compaction brief and for the rules placed at the head of a subagent's prompt; python -m windvane.tools when the daemon is down; python -m windvane.remember for /remember; python -m windvane.rules seed --strict for /windvane-strict; python -m windvane.export for /windvane-export; python -m windvane.migrate --import for /windvane-import. The first-run offer runs python -c "from windvane import semantic; print(int(semantic.available()))" to see whether the semantic extra is installed, and python -m pip install sentence-transformers numpy only after you answer yes in its dialog. The shell command in alert_command, if you set one, runs with one line of text when an unattended run halts, hits a usage limit or needs input.
What it reads. The session's context usage from Claude Code; the session transcript and the project's files, for the recorder and the code index; Claude Code's settings and installed_plugins.json, to list the command hooks that would fire for an event (the bridge below); the plugin's own config rows; the text you have selected in the transcript, when you run /remember; the environment variables WINDVANE_DIR, WINDVANE_PYTHON, WINDVANE_SEMANTIC, WINDVANE_RESULT_BUDGET, WINDVANE_ALERT_COMMAND, WINDVANE_AUTONOMY, WINDVANE_GOAL_TURN_CAP, WINDVANE_STRIKE_CAP, WINDVANE_LIVE_MINE, CLAUDE_CODE_AUTO_COMPACT_WINDOW, CLAUDE_CONFIG_DIR, HOME and USERPROFILE; the daemon's port and token from <store>/daemon_port and <store>/daemon_token.
What it writes. The store under your home directory, ~/.windvane or WINDVANE_DIR: the checkpoint ring, memory, rules, the ledger, the session index, and per session a context mirror (sessions/<id>.ctx.json), its marker (.mod), the engine's marks (.marks.json) and the compaction brief's marker (.briefed). In the project: nothing, unless the structure setting is on (then CLAUDE.md, .learnings/ and session-logs/ are created once) or you run /windvane-export (Markdown under .windvane/export/). Settings: the plugin's semantic row is set to true after you accept the first-run offer, and nothing else is set; no environment variable is written.
What it sends. HTTP POST to the daemon at http://127.0.0.1:<port>/hook (a hook's stdin JSON) and /tool (a tool call), with the header X-Windvane-Hook: 1 and the daemon's token in X-Windvane-Token; the port and the token come from the store's two files, and the daemon refuses a request without the token. Nothing else goes anywhere.
Prompts it submits. One, after a compaction windvane itself started, and only when you have not typed during the compaction: "The conversation was compacted with the checkpoint banked. The rules and the checkpoint are in the session-start brief beside this message. Continue from the checkpoint: its current step first, then the pending steps. End your reply with what is done and what is next. If the person has already sent a prompt since the compaction, say so in one line and stop." The continue_after_compact row turns it off.
Hooks that decide or change something. The six mcp__windvane__* tools are served by tool.call hooks; an engine error is a deny of that call with the error's text. The tool.call hook for Agent changes one input, the prompt, by placing the project's rules and the past mistakes for the files the prompt names at its head inside <windvane-brief> tags; a fork, which inherits the conversation, is passed through unchanged. The session.append hook trims a tool result to the result_budget characters, keeping its head and tail, and redacts private keys, vendor keys and literal secret values before the result is stored. The session.compact hook places the rules and the checkpoint after the summary. The prompt.submit hook notes when you typed and drops windvane's own continue prompt when it is already stale; your prompts pass unchanged. The classic.<Event> hooks, the bridge, answer a classic hook event from the daemon when every command hook that would run for it is windvane's own, so no hook process starts; when any other hook (yours, or another plugin's) would run, they pass the event on and the command hooks run as before. There is no hook on PreToolUse: the pre-edit, pre-read and shell checks and the halt run through their command hooks in every session, so a permission decision is always a command hook's deny or Claude Code's own prompt to you. In autonomy mode only, a rule with a detector marked unattended: "deny" denies the matching tool call, and a halted run denies every tool but checkpoint and the alert.
claude -p runs the classic hooks from hooks/hooks.json. Each one is a small client that makes one round trip to the daemon and falls back to running the handler in its own process. The mod's tools, band, pane and commands need an interactive session. Without the mod there is no context reading unless a status line script calls python -m windvane.pressure statusline, and the pressure nudges fall back to the turn cadence.
Autonomy mode, set with the autonomy row or WINDVANE_AUTONOMY=1 (it is also on while a /goal runs), arms the stall ladder. A turn that changes no file, test or commit counts toward a strike. At the strike cap every tool except the checkpoint tool and a few messaging tools is denied until a person runs python -m windvane.stall release <session_id>. Detectors marked deny refuse the call instead of only recording it.
alert_command is a shell command that receives one line when a run halts, hits a usage limit or waits for input. {message} in the command is replaced with the quoted line. Without that placeholder the line arrives on stdin. Nothing is sent when the row is empty.
windvane replaces claude-engram, an earlier project that is no longer public. It is the same engine rewritten as a plugin, and the import command above brings an engram store over.
hooks/register.ts 890 lines1// windvane's hooks module: the session keeps its own state.
2//
3// The mirror. windvane's hooks read the context fill from
4// sessions/<sid>.ctx.json. This module writes it every 10 s from
5// $.session.usage(), plus a sessions/<sid>.mod marker that keeps
6// statusline writers out.
7// The status line: "windvane ctx 51% · ckpt 12m", the checkpoint age read
8// from the project's ring (latest_handoff.json). The status_segment
9// option set to false hides it; the band and the pane stay.
10// Compaction at the model's call. The engine's pressure marks are mirrored
11// here (the segment and the pane name the one the fill stands at, the
12// mirror carries the early_compaction mark for the engine's notes), and
13// nothing here compacts on its own: compact_now, called by the model at a
14// step end, compacts at the next turn boundary with the state banked;
15// a checkpoint save is only a save; Claude Code's own trigger is the
16// floor. After a compaction windvane started, one
17// prompt of windvane's resumes the work from the checkpoint
18// (continue_after_compact); the SessionStart(compact) banner carries the
19// rules and the checkpoint for that one, since a plugin's own
20// session.compact hooks do not run for a compaction it starts.
21//
22// Every hook of the plugin is registered in this file, and this is the only
23// file that holds `$`. Each other piece keeps its logic in its own module
24// and takes a Host (engine.ts), a handful of closures over `$` built by
25// hostOf below: the band above the prompt (band.tsx), the /windvane pane
26// (pane.tsx), /remember (remember.ts), /windvane-strict (strict.ts),
27// /windvane-export (export.ts), /windvane-import (import.ts), tool results
28// trimmed and secrets redacted at the door (door.ts), the per-project token
29// and cost ledger with /windvane-cost (ledger.ts), the rules and file
30// mistakes at the head of every subagent's prompt (agents.ts), the
31// compacted conversation carrying the rules and the checkpoint (compact.ts),
32// windvane's command hooks answered by the daemon over loopback HTTP where
33// nothing else hooks the event (bridge.ts), and the tools the model calls
34// (tools.ts). The store reads they share live in ring.ts, the options in
35// engine.ts. The $.state values the band and the pane draw from are
36// declared here (../types/index.d.ts), read and written here, and handed to
37// the drawings as plain values.
38import { atom, read, update } from 'claude-code'
39import type { EngineInterface, Register, SessionMessage, TurnCompleteInput } from 'claude-code'
40
41import { briefFor } from './agents'
42import { BAND_HIDDEN_KEY, drawBand, parseWindvane, textOf } from './band'
43import { bridgeDecision } from './bridge'
44import { compactBrief } from './compact'
45import { readBudget, rewrite } from './door'
46import { PLUGIN, VERSION, engineEnv, pythonOf, settingsOf, type EarlyCompaction, type Host, type Settings } from './engine'
47import { EXPORT_COMMAND, exportProject } from './export'
48import { IMPORT_COMMAND, importStore } from './import'
49import { LEDGER_COMMAND, add, asEntry, costBaseline, ledgerKey, localDay, report, turnEntry } from './ledger'
50import { PANE_COMMAND, PANE_ROWS, TOUCH_TOOLS, drawPane, loadView } from './pane'
51import { REMEMBER_COMMAND, remember } from './remember'
52import {
53 CHECK_TIMEOUT_MS,
54 INSTALL_TIMEOUT_MS,
55 SEMANTIC_OFFER_KEY,
56 checkArgv,
57 extraInstalledFrom,
58 installArgv,
59 offerDue,
60 offerFor,
61 recordOf,
62 rowOnFrom,
63} from './setup'
64import { ageText, normalizePath, readLatest, readManifest, ringsFor, storePath } from './ring'
65import { STRICT_COMMAND, seedStrict } from './strict'
66import { TOOL_SPECS, serve } from './tools'
67import type { Io } from './ring'
68
69// $.state values the band and the pane draw from (../types/index.d.ts).
70const lastRead = atom({ plugin: 'windvane', key: 'read' } as const, null)
71const pressure = atom({ plugin: 'windvane', key: 'pressure' } as const, null)
72const bandHidden = atom({ plugin: 'windvane', key: 'bandHidden' } as const, false)
73const lastFile = atom({ plugin: 'windvane', key: 'lastFile' } as const, null)
74const paneView = atom({ plugin: 'windvane', key: 'pane' } as const, null)
75
76const MIRROR_EVERY_MS = 10_000
77
78// The prompt that resumes the work after a compaction windvane started
79// (continue_after_compact). The engine runs it as a turn of its own once the
80// session is idle, framed under the plugin's name. The rules and the
81// checkpoint reach the model beside it, in the SessionStart(compact) banner:
82// a compaction a plugin starts runs beneath that plugin's own hooks, so
83// compact.ts cannot place them in the conversation for this one.
84const CONTINUE_TEXT =
85 'The conversation was compacted with the checkpoint banked. The rules and the checkpoint are in the session-start brief beside this message. Continue from the checkpoint: its current step first, then the pending steps. End your reply with what is done and what is next. If the person has already sent a prompt since the compaction, say so in one line and stop.'
86
87// The engine's pressure constants (its config knobs' defaults), mirrored.
88// Keep in step with the engine.
89const OUTPUT_RESERVE = 32_000
90const CHECKPOINT_MARGIN = 20_000
91const CHECKPOINT_MARGIN_SMALL = 10_000
92const SMALL_WINDOW = 200_000
93const HEADSUP_FRACTION = 0.1
94const DEFAULT_COMPACT_1M = 967_000
95
96type Context = { percent?: number; tokens?: number; window: number }
97
98type Mirror = {
99 session_id: string
100 ts: number
101 source: 'mod'
102 plugin: string
103 total_input_tokens?: number
104 context_window_size: number
105 used_percentage?: number
106 model_id: string
107 model_name: string
108 total_cost_usd?: number
109 five_hour_pct?: number
110 five_hour_resets_at?: number
111 seven_day_pct?: number
112 seven_day_resets_at?: number
113 // Every rate-limit window the session reports, by kind (five_hour,
114 // seven_day, a model-specific weekly window, ...); the flat keys above
115 // stay for older readers.
116 rate_limits: Record<string, { pct?: number; resets_at?: number }>
117 // The early_compaction row's fill is reached, below the engine's own
118 // marks: the engine's first note comes from this, with the row's value as
119 // the reason. The compaction stays the model's call.
120 early_band?: string
121 // The compaction window the mod measured against (the session's
122 // rawMaxTokens, or the default point), so a store can be read when the band
123 // did not open where it was expected. The engine resolves its own.
124 compaction_point?: number
125 // The fill is left out: the reading still shows the pre-compaction size
126 // (see staleTokens in register).
127 stale_after_compaction?: true
128}
129
130// usage.rateLimits as the mirror's rate_limits dict, one entry per kind.
131function rateLimitsOf(limits: readonly { kind: string; percentUsed?: number; resetsAt?: string }[]): Mirror['rate_limits'] {
132 const out: Mirror['rate_limits'] = {}
133 for (const r of limits) out[r.kind] = { pct: r.percentUsed, resets_at: epochSeconds(r.resetsAt) }
134 return out
135}
136
137type Marks = { headsupAt: number; checkpointAt: number; triggerAt: number }
138
139// The computed defaults: the engine's own, with no knob set.
140function thresholds(window: number, point: number): Marks {
141 const margin = window <= SMALL_WINDOW ? CHECKPOINT_MARGIN_SMALL : CHECKPOINT_MARGIN
142 const triggerAt = Math.floor(point - OUTPUT_RESERVE)
143 const checkpointAt = Math.floor(triggerAt - margin)
144 let headsupAt = Math.floor(point - HEADSUP_FRACTION * window)
145 if (headsupAt >= checkpointAt) headsupAt = Math.floor(checkpointAt - (HEADSUP_FRACTION * window) / 2)
146 return { headsupAt, checkpointAt, triggerAt }
147}
148
149// The engine's marks for this session (sessions/<sid>.marks.json, written
150// each time it assesses the fill), read as the mod's own: the percent knobs
151// and the output reserve move them, and the mod knows only the defaults.
152// Taken while they were computed against the mod's point; else undefined.
153function marksOf(raw: string | undefined, point: number): Marks | undefined {
154 if (!raw) return undefined
155 try {
156 const v: unknown = JSON.parse(raw)
157 if (typeof v !== 'object' || v === null) return undefined
158 const r = v as Record<string, unknown>
159 if (r.point !== point) return undefined
160 const h = r.headsup_at
161 const c = r.checkpoint_at
162 const t = r.trigger_at
163 if (typeof h !== 'number' || typeof c !== 'number' || typeof t !== 'number') return undefined
164 if (!(h < c && c < t)) return undefined
165 return { headsupAt: h, checkpointAt: c, triggerAt: t }
166 } catch {
167 return undefined
168 }
169}
170
171// The marks file's text, or undefined when there is none yet (the engine
172// writes it at its first assessment, a prompt or a turn end into the session).
173async function readMarks($: EngineInterface, path: string): Promise<string | undefined> {
174 try {
175 return String(await $.fs.read(path))
176 } catch {
177 return undefined
178 }
179}
180
181function defaultPoint(window: number): number {
182 return window > SMALL_WINDOW ? Math.min(DEFAULT_COMPACT_1M, window) : window
183}
184
185// The checkpoint band's state: when it was entered ($.clock's ms; undefined
186// outside it), whether the fill is still under the engine's last call and
187// the band is open for the early_compaction row alone, what the last counted
188// main turn cost, and the compaction point the last judgement measured
189// against. The band informs (the segment, the pane, the engine's notes
190// through the mirror); it never compacts. The compaction is the model's
191// call, compact_now.
192type BandState = { enteredAt?: number; early: boolean; lastTurnCostUsd?: number; point?: number }
193
194// The band bookkeeping, against Claude Code's own compaction window: the
195// band opens at the engine's last call above the trigger, or earlier when
196// the early_compaction row's fill (a share of that window, as /context
197// counts it) or turn cost is reached. A top-level function because $ is
198// followed only into one.
199async function updateBand($: EngineInterface, usage: { context: Context }, early: EarlyCompaction | undefined, band: BandState, marksPath: string): Promise<void> {
200 const tokens = tokensOf(usage.context)
201 const point = (await $.session.usage({ breakdown: 'summary' })).context.breakdown?.rawMaxTokens
202 ?? defaultPoint(usage.context.window)
203 band.point = point
204 // The engine's marks where it has written them for this point (the knobs
205 // live on its side); the computed defaults until then.
206 const th = marksOf(await readMarks($, marksPath), point) ?? thresholds(usage.context.window, point)
207 const earlyHit = early !== undefined && tokens !== undefined && (
208 (early.percent !== undefined && tokens * 100 >= point * early.percent)
209 || (early.usd !== undefined && band.lastTurnCostUsd !== undefined && band.lastTurnCostUsd >= early.usd))
210 if (tokens !== undefined && (tokens >= th.checkpointAt || earlyHit)) {
211 if (band.enteredAt === undefined) band.enteredAt = await $.clock.now()
212 // Judged every time: the row's mark gives way to the last call once the
213 // fill reaches it.
214 band.early = tokens < th.checkpointAt
215 } else {
216 band.enteredAt = undefined
217 band.early = false
218 }
219}
220
221function epochSeconds(iso?: string): number | undefined {
222 if (!iso) return undefined
223 const ms = Date.parse(iso)
224 return Number.isFinite(ms) ? ms / 1000 : undefined
225}
226
227// The fill as a share of the compaction window (the figure /context shows),
228// when the point is known; of the model's window otherwise.
229function percentOf(context: Context, point?: number): number | undefined {
230 const tokens = tokensOf(context)
231 if (tokens !== undefined && point !== undefined && point > 0) return Math.round((100 * tokens) / point)
232 if (context.percent !== undefined) return Math.round(context.percent)
233 if (tokens !== undefined && context.window > 0) return Math.round((100 * tokens) / context.window)
234 return undefined
235}
236
237function tokensOf(context: Context): number | undefined {
238 if (context.tokens !== undefined) return context.tokens
239 if (context.percent !== undefined && context.window > 0) return Math.round((context.percent / 100) * context.window)
240 return undefined
241}
242
243// The fill as the session reports it now; undefined when it cannot be read.
244async function fillOf($: EngineInterface): Promise<number | undefined> {
245 try {
246 return tokensOf((await $.session.usage()).context)
247 } catch {
248 return undefined
249 }
250}
251
252// The engine interface as the other modules take it (Host, engine.ts):
253// closures over `$`, each call on `$` spelled here. The environment is read
254// here by name; a module asks for the store or the interpreter.
255function hostOf($: EngineInterface): Host {
256 return {
257 pluginRoot: $.plugin.root,
258 store: async () => storePath(await $.env.get('WINDVANE_DIR'), await $.env.get('USERPROFILE'), await $.env.get('HOME')),
259 python: async configured => pythonOf(await $.env.get('WINDVANE_PYTHON'), configured),
260 sessionId: () => $.session.id(),
261 cwd: () => $.session.cwd(),
262 root: () => $.session.root(),
263 now: () => $.clock.now(),
264 after: (ms, fn) => $.clock.after(ms, fn),
265 exists: path => $.fs.exists(path),
266 read: async path => String(await $.fs.read(path)),
267 write: async (path, text) => {
268 await $.fs.write(path, text)
269 },
270 run: (argv, init) => $.process.run(argv, init),
271 post: (port, token, path, body) =>
272 $.http.fetch(`http://127.0.0.1:${port}/${path}`, {
273 method: 'POST',
274 headers: { 'Content-Type': 'application/json', 'X-Windvane-Hook': '1', 'X-Windvane-Token': token },
275 body,
276 }),
277 storeGet: key => $.store.get(key),
278 storeSet: (key, value) => $.store.set(key, value),
279 storeKeys: () => $.store.keys(),
280 settings: async source => (source === undefined ? await $.settings.read() : await $.settings.read({ source })) as Record<string, unknown>,
281 // The env a command hook process inherits that windvane's handlers read
282 // per session (the daemon's per-request session env); CLAUDE_PROJECT_DIR
283 // is the session root.
284 sessionEnv: async () => ({
285 CLAUDE_PROJECT_DIR: await $.session.root(),
286 CLAUDE_CODE_AUTO_COMPACT_WINDOW: (await $.env.get('CLAUDE_CODE_AUTO_COMPACT_WINDOW')) ?? '',
287 CLAUDE_CONFIG_DIR: (await $.env.get('CLAUDE_CONFIG_DIR')) ?? '',
288 WINDVANE_AUTONOMY: (await $.env.get('WINDVANE_AUTONOMY')) ?? '',
289 WINDVANE_ALERT_COMMAND: (await $.env.get('WINDVANE_ALERT_COMMAND')) ?? '',
290 WINDVANE_STRIKE_CAP: (await $.env.get('WINDVANE_STRIKE_CAP')) ?? '',
291 WINDVANE_GOAL_TURN_CAP: (await $.env.get('WINDVANE_GOAL_TURN_CAP')) ?? '',
292 WINDVANE_LIVE_MINE: (await $.env.get('WINDVANE_LIVE_MINE')) ?? '',
293 }),
294 configDir: async () => {
295 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
296 return ((await $.env.get('CLAUDE_CONFIG_DIR')) || `${home}/.claude`).replace(/\\/g, '/')
297 },
298 resultBudgetEnv: () => $.env.get('WINDVANE_RESULT_BUDGET'),
299 log: text => $.ui.log(text),
300 debug: text => $.ui.log(text, { to: 'debug' }),
301 }
302}
303
304function ioOf($: EngineInterface): Io {
305 return { read: p => $.fs.read(p) as Promise<string>, exists: p => $.fs.exists(p) }
306}
307
308// The first-run offer of the semantic tier. setup.ts holds the decisions;
309// the $ work is here, as for the other pieces. Asked at session.start and
310// never awaited there, so the dialog never delays the start. A dismissed
311// dialog records nothing and the next session asks again.
312async function offerSemantic($: EngineInterface, e: { isInteractive: boolean }, settings: Settings): Promise<void> {
313 if (!e.isInteractive || !offerDue(await $.store.get(SEMANTIC_OFFER_KEY), Date.now())) return
314 // WINDVANE_SEMANTIC in the environment, on or off, is the person's own
315 // decision about the tier: nothing to ask (and a scripted session, the
316 // demo take among them, is never interrupted by the dialog).
317 if (((await $.env.get('WINDVANE_SEMANTIC')) ?? '').trim() !== '') return
318 const python = pythonOf(await $.env.get('WINDVANE_PYTHON'), settings.python)
319 const env = engineEnv($.plugin.root, storePath(await $.env.get('WINDVANE_DIR'), await $.env.get('USERPROFILE'), await $.env.get('HOME')))
320 const rowOn = rowOnFrom(await $.config.list())
321 let installed = false
322 try {
323 installed = extraInstalledFrom(await $.process.run(checkArgv(python), { env, timeoutMs: CHECK_TIMEOUT_MS }))
324 } catch {
325 installed = false
326 }
327 const offer = offerFor(rowOn, installed)
328 if (!offer) {
329 await $.store.set(SEMANTIC_OFFER_KEY, recordOf('done'))
330 return
331 }
332 let answer: string
333 try {
334 answer = await $.ui.ask(offer.question, { options: [offer.act, 'Not now', 'Never ask'], header: 'windvane' })
335 } catch {
336 return
337 }
338 $.ui.log(`${PLUGIN}: semantic offer answered: ${answer}`, { to: 'debug' })
339 if (answer === 'Never ask') {
340 await $.store.set(SEMANTIC_OFFER_KEY, recordOf('never'))
341 return
342 }
343 if (answer !== offer.act) {
344 await $.store.set(SEMANTIC_OFFER_KEY, recordOf('later'))
345 return
346 }
347 if (!installed) {
348 $.ui.toast(`${PLUGIN}: installing the semantic extra; this takes a few minutes`)
349 let ok = false
350 try {
351 const run = await $.process.run(installArgv(python), { env, timeoutMs: INSTALL_TIMEOUT_MS })
352 ok = run.exitCode === 0
353 if (!ok) $.ui.log(`${PLUGIN}: pip install failed (exit ${run.exitCode}): ${run.stderr.trim().slice(-400)}`)
354 } catch (err) {
355 $.ui.log(`${PLUGIN}: pip install did not run: ${String(err)}`)
356 }
357 if (!ok) {
358 $.ui.toast(`${PLUGIN}: the semantic extra did not install; see the log, or pip install it yourself`)
359 await $.store.set(SEMANTIC_OFFER_KEY, recordOf('later'))
360 return
361 }
362 }
363 if (!rowOn) {
364 // The row's key as fixed text: the directory reads the call as written.
365 const set = await $.config.set({ key: 'windvane.semantic', value: true })
366 if (set.deny) {
367 $.ui.toast(`${PLUGIN}: the semantic row stayed off: ${set.deny}`)
368 await $.store.set(SEMANTIC_OFFER_KEY, recordOf('later'))
369 return
370 }
371 }
372 $.ui.toast(`${PLUGIN}: the semantic tier is on; the daemon loads the model on its next start`)
373 await $.store.set(SEMANTIC_OFFER_KEY, recordOf('done'))
374}
375
376// The UI at session.start: the band's Hide from the last session, and the
377// commands. A failure costs that piece alone, never the mirror.
378async function startUi($: EngineInterface): Promise<void> {
379 try {
380 if ((await $.store.get(BAND_HIDDEN_KEY)) === true) await update($, bandHidden, () => true)
381 } catch (err) {
382 $.ui.log(`${PLUGIN}: band: ${String(err)}`)
383 }
384 for (const spec of [PANE_COMMAND, REMEMBER_COMMAND, LEDGER_COMMAND, STRICT_COMMAND, EXPORT_COMMAND, IMPORT_COMMAND]) {
385 try {
386 await $.command.register(spec)
387 } catch (err) {
388 $.ui.log(`${PLUGIN}: /${spec.name}: ${String(err)}`)
389 }
390 }
391 // The tools the model calls, served by tools.ts.
392 for (const spec of TOOL_SPECS) {
393 try {
394 await $.tool.register(spec)
395 } catch (err) {
396 $.ui.log(`${PLUGIN}: tool ${spec.name}: ${String(err)}`)
397 }
398 }
399}
400
401// The band's Hide and the pane's Show band: the flag in $.state, and in
402// $.store across sessions.
403async function hideBand($: EngineInterface): Promise<void> {
404 await update($, bandHidden, () => true)
405 await $.store.set(BAND_HIDDEN_KEY, true)
406}
407
408async function showBand($: EngineInterface): Promise<void> {
409 await update($, bandHidden, () => false)
410 await $.store.set(BAND_HIDDEN_KEY, false)
411}
412
413// The pane's body read from the store afresh (pane.tsx), for the file the
414// model last touched.
415async function refreshPane($: EngineInterface): Promise<void> {
416 const view = await loadView(hostOf($), (await read($, lastFile)) ?? undefined)
417 await update($, paneView, () => view)
418}
419
420// The ledger's project: the folder the session was opened in. A shell cd
421// moves $.session.cwd() for the rest of the session; the ledger stays put.
422let ledgerProject = ''
423
424// The ledger at session.start: its project and the cost it counts from.
425async function startLedger($: EngineInterface, cwd: string): Promise<void> {
426 ledgerProject = normalizePath(cwd)
427 costBaseline((await $.session.usage()).cost?.usd)
428}
429
430// The ledger at turn.complete: a main-loop turn that counted something,
431// added to the project's entry for the local day. Answers what the turn
432// cost (the session's priced cost across it), or undefined for a turn that
433// counted nothing.
434async function recordTurn($: EngineInterface, e: TurnCompleteInput): Promise<number | undefined> {
435 if (e.agentId !== undefined || e.usage === undefined) return undefined
436 const entry = turnEntry(e.usage, (await $.session.usage()).cost?.usd)
437 const key = ledgerKey(ledgerProject || (await $.session.cwd()), localDay(await $.clock.now()))
438 await $.store.set(key, add(asEntry(await $.store.get(key)), entry))
439 return entry.cost_usd
440}
441
442export const register: Register = (on, options) => {
443 // The options are fixed for this load; a change reloads the module.
444 const settings = settingsOf(options)
445
446 // Per-load state; a reload starts it over, the files on disk do not.
447 let sid = ''
448 let store = ''
449 let sessions = ''
450 let marksPath = '' // sessions/<sid>.marks.json, the engine's marks (updateBand)
451 let rings: string[] = []
452 // Times are $.clock's (ms since the epoch).
453 const bandState: BandState = { early: false } // the checkpoint band (updateBand)
454 let compactAsked = false // compact_now succeeded this turn
455 let compactRequested = false // a compaction is under way
456 // The fill as read right after a compaction. Claude Code's usage figures
457 // keep the pre-compaction size until the next request records the
458 // rewritten conversation's, so a reading equal to this one says nothing:
459 // the band stays closed and the mirror carries no fill until the reading
460 // changes or a turn completes.
461 let staleTokens: number | undefined
462 // When the turn that asked for the compaction began, and when the person
463 // last submitted a prompt of their own while no turn ran. A prompt the
464 // person types while a compaction runs is queued and runs before anything
465 // a plugin submits; the continue prompt would then arrive a turn late and
466 // stale, so it is skipped when such a prompt has landed since that turn
467 // began (its own prompt was submitted before it). A prompt typed over the
468 // running turn is another matter: the engine delivers it into that turn
469 // (the person saw it answered before the compaction), and one it did not
470 // deliver starts the next turn on its own, where the continue prompt's
471 // text tells the model to stop in one line.
472 let turnBeganAt: number | undefined
473 let personPromptAt: number | undefined
474 const continueStale = () => turnBeganAt !== undefined && personPromptAt !== undefined && personPromptAt > turnBeganAt + 100
475 const early = settings.earlyCompaction
476 let doorBudget: number | undefined // the door's budget, read once per load
477
478 // compact_now succeeded in the main conversation: it banked the draft and
479 // asked for the compaction at the turn boundary. A checkpoint save asks
480 // for nothing: the model saves at every step end and compacts when it
481 // judges the moment right (a save in the band used to compact, and the
482 // model then lost the plain save; 2026-10-06). The serving hooks below
483 // call this once the engine has answered without an error; the engine
484 // carries the arguments at the top level of the event.
485 const noteSave = (call: unknown, compactNow: boolean): void => {
486 const { agentId } = call as { agentId?: string }
487 if (compactNow && agentId === undefined) compactAsked = true
488 }
489
490 // The person's own prompts are noted for the continue prompt's sake: the
491 // ones typed at the terminal (composer) or sent from the Remote Control
492 // bridge while no turn ran (`turnId` names the turn a prompt was typed
493 // over; the compaction runs between turns, so a prompt typed during it has
494 // none). A prompt typed over a running turn was delivered into it, or
495 // starts the next turn by itself, and leaves the note alone; so does a
496 // delivery into the running turn (a peer session's message, a task
497 // notification, a subagent's prompt), which is not the person continuing
498 // the session. A continue of windvane's that is already stale is dropped
499 // (a second net under the check made before it is submitted).
500 on('prompt.submit', async ($, e, next) => {
501 const origin = e.origin as { kind?: string; name?: string } | undefined
502 const ours = origin?.kind === 'plugin' && origin.name === PLUGIN
503 if (!ours) {
504 if ((origin?.kind === 'composer' || origin?.kind === 'bridge') && e.turnId === undefined) personPromptAt = await $.clock.now()
505 return next(e)
506 }
507 if (e.text === CONTINUE_TEXT && continueStale()) return { drop: `${PLUGIN}: the session already continued, so the resume prompt was dropped` }
508 return next(e)
509 })
510
511 // The band (band.tsx). Every row windvane's hooks hand the model passes
512 // session.append with door hook-context; a subagent's rows are its own.
513 on('session.append', { door: 'hook-context' }, async ($, e, next) => {
514 if (e.agentId === undefined) {
515 const reading = parseWindvane(textOf(e.message.content), Date.now())
516 if (reading) await update($, lastRead, () => reading)
517 }
518 return next(e)
519 })
520
521 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
522 if (e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e)
523 const reading = await read($, lastRead)
524 if (reading === null || (await read($, bandHidden))) return next(e)
525 const figures = await read($, pressure)
526 return drawBand($.ui.resolve(e), reading, figures, Date.now(), () => hideBand($))
527 })
528
529 // The pane (pane.tsx). /windvane reads the store and opens it: tall enough
530 // inline for the summary, the checkpoint and the first rules; the keys go
531 // to the pane so the arrows scroll it, and Escape closes it.
532 on('command.run', { command: 'windvane' }, async $ => {
533 await refreshPane($)
534 await $.ui.open({ id: 'windvane', title: 'windvane', rows: PANE_ROWS, focus: true, closeOnEscape: true })
535 return { text: 'windvane pane opened.' }
536 })
537
538 // The file the model last touched, noted as the call is made (a denied
539 // edit still says which file the model is on); a subagent's touches are
540 // its own.
541 on('tool.call', async ($, e, next) => {
542 const path = (e as unknown as { file_path?: unknown; notebook_path?: unknown }).file_path
543 ?? (e as unknown as { notebook_path?: unknown }).notebook_path
544 if (e.agentId === undefined && TOUCH_TOOLS.has(String(e.tool)) && typeof path === 'string' && path) {
545 const file = normalizePath(path)
546 await update($, lastFile, () => file)
547 }
548 return next(e)
549 })
550
551 on('ui.render', { component: 'Pane', requestId: 'windvane' }, async ($, e) => {
552 const view = await read($, paneView)
553 const figures = await read($, pressure)
554 const hidden = await read($, bandHidden)
555 const data = { view, figures, hidden, bodyColumns: e.props.bodyColumns, nowMs: Date.now() }
556 return drawPane($.ui.resolve(e), data, { refresh: () => refreshPane($), showBand: () => showBand($) })
557 })
558
559 // /remember (remember.ts): the selected transcript text, stored as a
560 // decision for the session's project.
561 on('command.run', { command: 'remember' }, async $ => {
562 const selected = await $.ui.selection()
563 const text = selected?.text.trim() ?? ''
564 if (!text) return { text: 'Nothing is selected. Select text in the transcript, then run /remember.' }
565 return { text: await remember(hostOf($), settings.python, text, await read($, lastFile)) }
566 })
567
568 // /windvane-strict, /windvane-export, /windvane-import: the engine does
569 // the work (strict.ts, export.ts, import.ts).
570 on('command.run', { command: 'windvane-strict' }, async $ => ({ text: await seedStrict(hostOf($), settings.python) }))
571 on('command.run', { command: 'windvane-export' }, async $ => ({ text: await exportProject(hostOf($), settings.python) }))
572 on('command.run', { command: 'windvane-import' }, async $ => ({ text: await importStore(hostOf($), settings.python) }))
573
574 // The door (door.ts): a tool result's text redacted and trimmed before
575 // the row is stored and read. A row that needs no change goes on untouched.
576 on('session.append', async ($, e, next) => {
577 if (e.door !== 'tool-result') return next(e)
578 if (doorBudget === undefined) doorBudget = await readBudget(hostOf($), settings.resultBudget)
579 const content = rewrite(e.message.content, doorBudget)
580 if (content === undefined) return next(e)
581 return next({ ...e, message: { ...e.message, content } })
582 })
583
584 // /windvane-cost (ledger.ts): the ledger's project is the folder the
585 // session was opened in; before session.start the session's cwd stands in.
586 on('command.run', { command: 'windvane-cost' }, async $ => ({
587 text: await report(hostOf($), ledgerProject || normalizePath(await $.session.cwd())),
588 }))
589
590 // The subagents' brief (agents.ts): the Agent call's prompt with the
591 // project's rules and the named files' mistakes at its head.
592 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
593 const prompt = await briefFor(hostOf($), settings.python, e)
594 if (prompt === undefined) return next(e)
595 return next({ ...e, prompt })
596 })
597
598 // The compacted conversation (compact.ts): every trigger but precompute
599 // (the matcher also keeps this hook apart from the matcher-less one
600 // below). One user-role message carrying the rules and the checkpoint is
601 // placed after the summary (the first message core hands up), ahead of
602 // what it kept. A subagent's compaction and a skip pass through.
603 on('session.compact', { trigger: ['manual', 'auto', 'plugin'] }, async ($, e, next) => {
604 const out = await next(e)
605 if (out.messages === undefined || e.agentId !== undefined) return out
606 const text = await compactBrief(hostOf($), settings.python)
607 if (text === undefined) return out
608 const restore: SessionMessage = { role: 'user', text, toolUses: [] }
609 const messages = [...out.messages]
610 messages.splice(messages.length > 0 ? 1 : 0, 0, restore)
611 return { ...out, messages }
612 })
613
614 // The bridge (bridge.ts): windvane's command hooks answered by the daemon
615 // over loopback where every command hook that would fire is windvane's and
616 // served; otherwise the command hooks run exactly as before. One hook per
617 // classic event, by name, the event handed whole. PreToolUse has none: it
618 // is a permission check, whose hook may only deny, ask or pass the event
619 // on, and a bridged check with context and no decision would have to pass
620 // it on and run its handlers twice; its command hooks run as before.
621 on('classic.UserPromptSubmit', async ($, e, next) => {
622 const got = await bridgeDecision(hostOf($), e)
623 if ('answer' in got) return { ...got.answer }
624 return next(e)
625 })
626 // SessionStart's answer is the banner alone, named here so the directory
627 // reads that the session's first message is left as it is.
628 on('classic.SessionStart', async ($, e, next) => {
629 const got = await bridgeDecision(hostOf($), e)
630 if ('answer' in got) return { additionalContext: got.answer.additionalContext ?? [] }
631 return next(e)
632 })
633 on('classic.Notification', async ($, e, next) => {
634 const got = await bridgeDecision(hostOf($), e)
635 if ('answer' in got) return { ...got.answer }
636 return next(e)
637 })
638 on('classic.PostToolUse', async ($, e, next) => {
639 const got = await bridgeDecision(hostOf($), e)
640 if ('answer' in got) return { ...got.answer }
641 return next(e)
642 })
643 on('classic.PostToolUseFailure', async ($, e, next) => {
644 const got = await bridgeDecision(hostOf($), e)
645 if ('answer' in got) return { ...got.answer }
646 return next(e)
647 })
648 on('classic.PostToolBatch', async ($, e, next) => {
649 const got = await bridgeDecision(hostOf($), e)
650 if ('answer' in got) return { ...got.answer }
651 return next(e)
652 })
653 on('classic.StopFailure', async ($, e, next) => {
654 const got = await bridgeDecision(hostOf($), e)
655 if ('answer' in got) return { ...got.answer }
656 return next(e)
657 })
658 on('classic.PreCompact', async ($, e, next) => {
659 const got = await bridgeDecision(hostOf($), e)
660 if ('answer' in got) return { ...got.answer }
661 return next(e)
662 })
663 on('classic.PostCompact', async ($, e, next) => {
664 const got = await bridgeDecision(hostOf($), e)
665 if ('answer' in got) return { ...got.answer }
666 return next(e)
667 })
668 on('classic.Stop', async ($, e, next) => {
669 const got = await bridgeDecision(hostOf($), e)
670 if ('answer' in got) return { ...got.answer }
671 return next(e)
672 })
673 on('classic.SessionEnd', async ($, e, next) => {
674 const got = await bridgeDecision(hostOf($), e)
675 if ('answer' in got) return { ...got.answer }
676 return next(e)
677 })
678
679 // The tools the model calls (tools.ts): one matched hook per tool, each
680 // matcher naming its tool literally. Each answers for itself: a failure is
681 // a deny, else the reply is the result. A compact_now the engine answered
682 // asks for the compaction at the turn boundary (noteSave).
683 on('tool.call', { tool: 'mcp__windvane__checkpoint' }, async ($, e) => {
684 const got = await serve(hostOf($), 'checkpoint', e, settings)
685 if ('deny' in got) return { deny: got.deny }
686 return { result: got.result }
687 })
688 on('tool.call', { tool: 'mcp__windvane__compact_now' }, async ($, e) => {
689 const got = await serve(hostOf($), 'compact_now', e, settings)
690 if ('deny' in got) return { deny: got.deny }
691 noteSave(e, true)
692 return { result: got.result }
693 })
694 on('tool.call', { tool: 'mcp__windvane__memory' }, async ($, e) => {
695 const got = await serve(hostOf($), 'memory', e, settings)
696 if ('deny' in got) return { deny: got.deny }
697 return { result: got.result }
698 })
699 on('tool.call', { tool: 'mcp__windvane__log' }, async ($, e) => {
700 const got = await serve(hostOf($), 'log', e, settings)
701 if ('deny' in got) return { deny: got.deny }
702 return { result: got.result }
703 })
704 on('tool.call', { tool: 'mcp__windvane__mine' }, async ($, e) => {
705 const got = await serve(hostOf($), 'mine', e, settings)
706 if ('deny' in got) return { deny: got.deny }
707 return { result: got.result }
708 })
709 on('tool.call', { tool: 'mcp__windvane__deps' }, async ($, e) => {
710 const got = await serve(hostOf($), 'deps', e, settings)
711 if ('deny' in got) return { deny: got.deny }
712 return { result: got.result }
713 })
714
715 on('session.start', async ($, e, next) => {
716 // The other pieces start first, each on its own: a failure there leaves
717 // the mirror running, and the mirror's early return leaves them running.
718 await startUi($)
719 try {
720 await startLedger($, e.cwd)
721 } catch (err) {
722 $.ui.log(`${PLUGIN}: ledger off: ${String(err)}`)
723 }
724 // The first-run offer of the semantic tier (setup.ts): asked, never
725 // awaited, so the dialog never delays the start.
726 void offerSemantic($, e, settings).catch(err => $.ui.log(`${PLUGIN}: the semantic offer failed: ${String(err)}`, { to: 'debug' }))
727
728 sid = await $.session.id()
729 const model = await $.session.model()
730
731 store = storePath(await $.env.get('WINDVANE_DIR'), await $.env.get('USERPROFILE'), await $.env.get('HOME'))
732 sessions = `${store}/sessions`
733 const mirrorPath = `${sessions}/${sid}.ctx.json`
734 const markerPath = `${sessions}/${sid}.mod`
735 marksPath = `${sessions}/${sid}.marks.json`
736
737 // The rings this session can save into, through the store's manifest.
738 try {
739 const root = normalizePath(await $.session.root())
740 const cwd = normalizePath(await $.session.cwd())
741 rings = ringsFor(await readManifest(ioOf($), store), store, cwd, root)
742 } catch {
743 rings = []
744 }
745
746 // The engine creates the sessions folder (its session-start hook, within
747 // seconds of the first session after an install). Until it exists each
748 // tick is skipped and the next one looks again; the mirror is never
749 // switched off for the session.
750 let sessionsReady = false
751
752 const tick = async () => {
753 if (!sessionsReady) {
754 if (!(await $.fs.exists(sessions))) return
755 sessionsReady = true
756 }
757 const usage = await $.session.usage()
758 const stale = staleTokens !== undefined && tokensOf(usage.context) === staleTokens
759 if (stale) {
760 bandState.enteredAt = undefined
761 bandState.early = false
762 } else {
763 staleTokens = undefined
764 await updateBand($, usage, early, bandState, marksPath)
765 }
766 const five = usage.rateLimits.find(r => r.kind === 'five_hour')
767 const seven = usage.rateLimits.find(r => r.kind === 'seven_day')
768 const rec: Mirror = {
769 session_id: sid,
770 ts: Date.now() / 1000,
771 source: 'mod',
772 plugin: PLUGIN,
773 total_input_tokens: stale ? undefined : usage.context.tokens,
774 context_window_size: usage.context.window,
775 used_percentage: stale ? undefined : usage.context.percent,
776 model_id: model,
777 model_name: model,
778 total_cost_usd: usage.cost?.usd,
779 five_hour_pct: five?.percentUsed,
780 five_hour_resets_at: epochSeconds(five?.resetsAt),
781 seven_day_pct: seven?.percentUsed,
782 seven_day_resets_at: epochSeconds(seven?.resetsAt),
783 rate_limits: rateLimitsOf(usage.rateLimits),
784 }
785 if (bandState.early && early !== undefined) rec.early_band = early.label
786 if (bandState.point !== undefined) rec.compaction_point = bandState.point
787 if (stale) rec.stale_after_compaction = true
788 await $.fs.write(mirrorPath, JSON.stringify(rec))
789 await $.fs.write(markerPath, JSON.stringify({ plugin: PLUGIN, version: VERSION, ts: rec.ts }))
790
791 const latest = await readLatest(ioOf($), rings, store)
792 const pct = stale ? undefined : percentOf(usage.context, bandState.point)
793 // The mark the fill stands at, in the segment's words: the engine's
794 // last call, or the early_compaction row's mark under it.
795 const mark = bandState.enteredAt === undefined ? undefined : bandState.early ? 'early' : 'checkpoint'
796 const band = mark === 'checkpoint' ? ' · checkpoint now' : mark === 'early' ? ' · compact at step end' : ''
797 if (settings.statusSegment) $.ui.status(`${PLUGIN} ctx ${pct === undefined ? '?' : pct + '%'} · ${ageText(latest?.created)}${band}`)
798 // The same figures for the band above the prompt and the pane.
799 await update($, pressure, () => ({ percent: pct, checkpointCreated: latest?.created, mark }))
800 }
801
802 await tick()
803 $.clock.every(MIRROR_EVERY_MS, () => {
804 void tick()
805 })
806 return next(e)
807 })
808
809 // A compaction is over: the band opens again from a fresh reading, and the
810 // fill reads as it did before the compaction until the next request
811 // (staleTokens, read by the caller through fillOf).
812 const noteCompacted = (fill: number | undefined): void => {
813 staleTokens = fill
814 bandState.enteredAt = undefined
815 bandState.early = false
816 compactAsked = false
817 compactRequested = false
818 }
819
820 // The turn boundary: compact_now asked for the compaction during the turn,
821 // so it runs here, at the model's own number. Nothing else compacts: the
822 // band and the engine's notes inform, and Claude Code's own trigger is
823 // the floor. The compaction runs inside this hook, where the engine says
824 // it belongs: the conversation compacts between turns, and a compaction
825 // left to a timer lost the race against a prompt queued for the next turn
826 // ("a turn is running"). The hook's budget stops while a $ call is in
827 // flight, so the compaction costs it nothing.
828 on('turn.complete', async ($, e, next) => {
829 if ((e as unknown as { agentId?: string }).agentId !== undefined) return next(e)
830 // A turn ended: the next reading is the rewritten conversation's.
831 staleTokens = undefined
832 const compacting = compactAsked && !compactRequested
833 if (compacting) compactRequested = true
834 const done = await next(e)
835 // The ledger counts the turn once it is done. What the turn cost is the
836 // early_compaction row's dollar signal, so the band is judged again here
837 // and the mirror carries the mark before the next tick.
838 try {
839 const cost = await recordTurn($, e)
840 if (cost !== undefined) bandState.lastTurnCostUsd = cost
841 if (early?.usd !== undefined && cost !== undefined) await updateBand($, await $.session.usage(), early, bandState, marksPath)
842 } catch (err) {
843 $.ui.log(`${PLUGIN}: ledger: ${String(err)}`)
844 }
845 if (compacting) {
846 turnBeganAt = (await $.clock.now()) - Math.max(0, e.durationMs ?? 0)
847 const tokens = await fillOf($)
848 const at = tokens !== undefined && bandState.point ? ` at ${Math.round((100 * tokens) / bandState.point)}%` : ''
849 $.ui.toast(`${PLUGIN}: compact_now: draft banked, compacting${at}`)
850 let compacted = false
851 try {
852 const out = await $.session.compact()
853 compacted = !('skip' in out && out.skip)
854 // Done, or vetoed by a hook: either way this band is answered.
855 noteCompacted(compacted ? await fillOf($) : undefined)
856 } catch (err) {
857 // Refused (a turn had begun after all) or failed: the save still
858 // stands in the band, so the next turn end asks again. Nothing is
859 // lost, and nothing is said in the transcript.
860 compactRequested = false
861 $.ui.log(`${PLUGIN}: compaction not done, retried at the next turn end: ${String(err)}`, { to: 'debug' })
862 }
863 if (compacted && settings.continueAfterCompact) {
864 // The resume prompt once the hook has returned: a prompt the person
865 // queued during the compaction runs first, and that case is read
866 // where the prompt is submitted.
867 $.clock.after(0, () => {
868 if (continueStale()) {
869 $.ui.log(`${PLUGIN}: the person continued the session during the compaction; no resume prompt`)
870 return
871 }
872 void $.prompt.submit({ text: CONTINUE_TEXT }).catch(err => {
873 $.ui.log(`${PLUGIN}: the continue after the compaction failed: ${String(err)}`)
874 })
875 })
876 }
877 }
878 return done
879 })
880
881 // Any compaction that stood, the engine's or the person's, opens a new
882 // cycle once it is done (a vetoed or refused one leaves the band as it
883 // was, so the save still counts at the next turn end).
884 on('session.compact', async ($, e, next) => {
885 const out = await next(e)
886 if (e.trigger !== 'precompute' && !('skip' in out && out.skip)) noteCompacted(await fillOf($))
887 return out
888 })
889}
890hooks/agents.ts 91 lines1// windvane: the project's rules reach subagents.
2//
3// A subagent starts from its prompt alone: none of windvane's SessionStart
4// banner, and the pre-edit hook stays silent for it (subagents are skipped
5// to save their context). So the rules it should follow and the mistakes
6// already made on the files it is sent to touch never reach it. This hook
7// rewrites the Agent call's prompt with a header: the project's rules block
8// and, for each file the prompt names, the past-mistakes lines, both
9// rendered by the engine (`python -m windvane.brief`) exactly as the banner
10// and the pre-edit hook render them.
11//
12// The rules are cached per project for 60 s and each file's lines likewise,
13// so a burst of Agent calls costs one engine run. An empty brief, an absent
14// engine or a fork (it inherits the whole conversation, rules included)
15// passes the call through untouched.
16//
17// register.ts hooks the Agent call and asks briefFor for the rewritten
18// prompt; this module never holds `$`.
19import { BRIEF_TIMEOUT_MS, briefArgv, joinBlocks, parseBrief, type Brief } from './brief'
20import { PLUGIN, engineEnv, type Host } from './engine'
21
22export const BRIEF_TTL_MS = 60_000
23const MAX_FILES = 8
24const OPEN_TAG = '<windvane-brief>'
25const CLOSE_TAG = '</windvane-brief>'
26
27const FILE_EXT =
28 /\.(py|pyi|ts|tsx|js|jsx|mjs|cjs|json|md|toml|ya?ml|rs|go|java|kt|c|h|cc|cpp|hpp|cs|rb|php|sh|ps1|lua|luau|sql|html|css|txt|cfg|ini)$/i
29
30// The files a prompt names: path-like tokens with a known extension, URLs
31// left out, first mention first, at most MAX_FILES.
32export function filesNamed(text: string): string[] {
33 const out: string[] = []
34 const plain = text.replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, ' ')
35 for (const m of plain.matchAll(/(?:[A-Za-z]:)?[\w./\\-]+/g)) {
36 const tok = m[0].replace(/[.\-]+$/, '') // a sentence's closing period
37 if (!FILE_EXT.test(tok)) continue
38 if (!out.includes(tok)) out.push(tok)
39 if (out.length >= MAX_FILES) break
40 }
41 return out
42}
43
44type Cached<T> = { at: number; value: T }
45
46async function runBrief(host: Host, python: string, extra: string[]): Promise<Brief | undefined> {
47 const project = await host.cwd()
48 const sid = await host.sessionId()
49 const argv = briefArgv(await host.python(python), project, sid, extra)
50 const env = engineEnv(host.pluginRoot, await host.store(), sid)
51 try {
52 const got = parseBrief(await host.run(argv, { cwd: project, env, timeoutMs: BRIEF_TIMEOUT_MS }))
53 if (typeof got !== 'string') return got
54 host.log(`${PLUGIN}: agents: ${got}`)
55 } catch (err) {
56 host.log(`${PLUGIN}: agents: brief failed: ${String(err)}`)
57 }
58 return undefined
59}
60
61// Per-load caches; a reload starts them over.
62const rulesCache = new Map<string, Cached<string[]>>()
63const fileCache = new Map<string, Cached<string[]>>()
64
65// The Agent call's prompt with the brief at its head, or undefined when the
66// call passes through untouched (a fork, a prompt already briefed, an empty
67// brief, no engine).
68export async function briefFor(host: Host, python: string, call: { subagent_type?: string; prompt: string }): Promise<string | undefined> {
69 if (call.subagent_type === 'fork' || call.prompt.includes(OPEN_TAG)) return undefined
70
71 const project = await host.cwd()
72 const files = filesNamed(call.prompt)
73 const now = Date.now()
74 const fresh = <T>(c: Cached<T> | undefined): c is Cached<T> => c !== undefined && now - c.at < BRIEF_TTL_MS
75 const fileKey = (f: string) => `${project}\u0000${f}`
76
77 const missing = files.filter(f => !fresh(fileCache.get(fileKey(f))))
78 if (!fresh(rulesCache.get(project)) || missing.length > 0) {
79 const got = await runBrief(host, python, missing.length > 0 ? ['--files', ...missing] : [])
80 if (!got) return undefined
81 rulesCache.set(project, { at: now, value: got.rules })
82 for (const f of missing) fileCache.set(fileKey(f), { at: now, value: got.files[f] ?? [] })
83 }
84
85 const rules = rulesCache.get(project)?.value ?? []
86 const perFile = files.map(f => fileCache.get(fileKey(f))?.value ?? [])
87 const body = joinBlocks([rules, ...perFile])
88 if (!body) return undefined
89 return `${OPEN_TAG}\n${body}\n${CLOSE_TAG}\n\n${call.prompt}`
90}
91hooks/band.tsx 110 lines1// windvane: the band above the prompt says what the model last read from
2// windvane, in one line:
3//
4// windvane · 2 rules · 1 mistake · ckpt 12m · ctx 59%
5//
6// windvane's hooks inject their text as hook context (through the bridge or
7// the plugin's command hooks); every such row passes session.append with
8// door `hook-context`. The rows carrying windvane's markers are counted
9// here, deterministically, from the text the model read; nothing is
10// inferred. The checkpoint age and the context fill come from the status
11// line's tick (register.ts). Hide is kept in $.store.
12//
13// register.ts hooks session.append and ui.render, keeps the reading in
14// $.state and hands it here to draw; this module never holds `$`.
15import type { Elements, RenderElement } from 'claude-code'
16
17import type { Pressure, WindvaneRead } from '../types'
18import { ageText } from './ring'
19
20// $.store key: the Hide press, kept across sessions.
21export const BAND_HIDDEN_KEY = 'bandHidden'
22
23// The elements a drawing takes: the surface's table, as $.ui.resolve(e)
24// hands it out.
25export type Draw = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
26
27const SESSION_BANNER = /windvane session started \(([a-z]+)\)/i
28const BANNER_RULES = /^Rules \((\d+),/m
29const RULE_BLOCK = /<windvane-rule>([\s\S]*?)(?:<\/windvane-rule>|$)/g
30const RULE_LINE = /^\s*\[[^\]\s]+\] /
31const MISTAKES_HEAD = 'AUTO-CHECK: Past mistakes with this file:'
32
33// The row's text: its text blocks, joined.
34export function textOf(content: readonly { type: string; [field: string]: unknown }[]): string {
35 return content
36 .filter(b => b.type === 'text' && typeof b.text === 'string')
37 .map(b => b.text as string)
38 .join('\n')
39}
40
41// The windvane reading in one row's text, or null when the row is not
42// windvane's.
43export function parseWindvane(text: string, at: number): WindvaneRead | null {
44 const banner = SESSION_BANNER.exec(text)
45 if (!text.includes('<windvane-') && !banner) return null
46
47 const tags = [...new Set([...text.matchAll(/<windvane-([a-z-]+)/g)].map(m => m[1] ?? ''))].filter(Boolean)
48
49 let rules = 0
50 const head = banner ? BANNER_RULES.exec(text) : null
51 if (head) rules += Number(head[1])
52 for (const block of text.matchAll(RULE_BLOCK)) {
53 rules += (block[1] ?? '').split('\n').filter(l => RULE_LINE.test(l)).length
54 }
55
56 let mistakes = 0
57 const lines = text.split('\n')
58 for (let i = 0; i < lines.length; i++) {
59 if (lines[i]?.trim() !== MISTAKES_HEAD) continue
60 for (let j = i + 1; j < lines.length && (lines[j] ?? '').startsWith(' - '); j++) mistakes++
61 }
62
63 return {
64 at,
65 tags,
66 started: banner?.[1],
67 rules,
68 mistakes,
69 checkpointNow: text.includes('<windvane-context>CHECKPOINT NOW'),
70 checkpointAtStepEnd: text.includes('<windvane-context>COMPACT AT A STEP END'),
71 headsUp: text.includes('<windvane-context>Context pressure:'),
72 stall: tags.includes('stall'),
73 }
74}
75
76function plural(n: number, word: string): string {
77 return `${n} ${word}${n === 1 ? '' : 's'}`
78}
79
80// The band's line, from the reading and the status line's figures.
81export function bandLine(r: WindvaneRead, p: { percent?: number; checkpointCreated?: number } | null, nowMs: number): string {
82 const parts: string[] = ['windvane']
83 if (r.started) parts.push(`session ${r.started}`)
84 if (r.rules) parts.push(plural(r.rules, 'rule'))
85 if (r.mistakes) parts.push(plural(r.mistakes, 'mistake'))
86 if (r.checkpointNow) parts.push('CHECKPOINT NOW')
87 else if (r.checkpointAtStepEnd) parts.push('checkpoint at step end')
88 else if (r.headsUp) parts.push('heads-up')
89 if (r.stall) parts.push('stall')
90 if (parts.length === 1) parts.push(...r.tags)
91 if (p) {
92 parts.push(ageText(p.checkpointCreated, nowMs))
93 if (p.percent !== undefined) parts.push(`ctx ${p.percent}%`)
94 }
95 return parts.join(' · ')
96}
97
98// The band: the line and the Hide button, which register.ts answers.
99export function drawBand(ui: Draw, reading: WindvaneRead, figures: Pressure | null, nowMs: number, onHide: () => void): RenderElement {
100 const { Box, Button, Text } = ui
101 return (
102 <Box key="windvane-band" flexDirection="row">
103 <Text dimColor wrap="truncate-end">
104 {bandLine(reading, figures, nowMs)}{' '}
105 </Text>
106 <Button key="windvane-hide" label="Hide" onPress={onHide} />
107 </Box>
108 )
109}
110hooks/bridge.ts 490 lines1// windvane: the hook bridge.
2//
3// Each of windvane's command hooks spawns a process: Claude Code runs
4// `python .../windvane/daemon_client.py <type>`, which makes one round trip
5// to the daemon or runs the handler in-process. Here a hook per classic
6// event (register.ts, one `on('classic.<Event>')` each) answers instead: for
7// its event it finds the windvane hook types
8// the command hooks would have run (their own matchers, read from the
9// settings and the plugins' hooks.json), POSTs the event's stdin JSON to the
10// daemon's HTTP endpoint (`POST /hook`) once per type, and turns each
11// handler's stdout into the event's result, folded the way the engine folds
12// command hooks (contexts concatenate, a deny or a block wins). No process
13// is started.
14//
15// The chain is [managed settings hooks, hooks modules, the other command
16// hooks as core]: a module that answers without `next` stops EVERY command
17// hook beneath it, windvane's and anyone else's, plugins' command hooks
18// included. So the bridge answers alone only when the census says every
19// command hook that would fire for this call is windvane's and the daemon
20// serves each of their types; otherwise it calls `next(e)` and the command
21// hooks run exactly as before (windvane's among them). With the bridge
22// answering, windvane's own command hooks never run, so no double-fire
23// marker is needed.
24//
25// Any failure before a handler ran (no port or token file, a refused connection, a
26// non-200, an `error` body, a body that is not the daemon's) also takes
27// `next(e)`. A request that timed out may have run, so it does not: the
28// event gets an empty answer, as a settings hook that timed out gives, and
29// the next 30 s go to the settings hooks while the daemon recovers.
30//
31// PreToolUse is not bridged. It is a permission check, and a mod's hook on
32// one may only deny, ask, or pass the event on whole (the plugin directory
33// reads nothing else there). The pre-tool handlers mostly answer with
34// context and no decision, and passing that event on would run them a
35// second time through the command hooks. So the pre-edit, pre-read and
36// shell checks and the halt run through their command hooks in every
37// session, one client process per call, as the session-end hook does.
38//
39// The pure helpers are exported for the tests. register.ts hooks each
40// classic event by name and asks bridgeDecision whether to answer or to
41// pass; this module never holds `$`, it takes the Host register.ts builds.
42import type { ClassicResult, Timer } from 'claude-code'
43import { PLUGIN, type Host } from './engine'
44import { normalizePath } from './ring'
45
46// The hook types the bridge may ask the daemon for. The pre-tool types
47// (pre_edit_json, pre_read_json, pre_bash_json, pre_tool_json) are not here:
48// PreToolUse is never bridged (see above). session_end_json is left to its
49// settings hook (it runs while Claude Code is exiting, where a mod's request
50// may never be sent).
51export const SERVED = new Set([
52 'post_edit_json',
53 'bash_json',
54 'prompt_json',
55 'tool_failure_json',
56 'post_batch_json',
57 'post_milestone_json',
58 'session_start_json',
59 'stop_json',
60 'pre_compact_json',
61 'post_compact_json',
62 'stop_failure_json',
63 'notification_json',
64])
65
66// The handlers that may take longer than a tool-path hook (the banner, the
67// drafted checkpoint, the turn's close).
68const SLOW = new Set(['session_start_json', 'pre_compact_json', 'stop_json'])
69const TIMEOUT_MS = 2_000
70const SLOW_TIMEOUT_MS = 5_000
71const COOL_DOWN_MS = 30_000
72const CENSUS_TTL_MS = 60_000
73
74// ClassicResultFields, mirrored: the fields of a result each event reads.
75const FIELDS: Record<string, readonly string[]> = {
76 UserPromptSubmit: ['additionalContext', 'sessionTitle', 'suppressOriginalPrompt'],
77 SessionStart: ['additionalContext', 'initialUserMessage', 'sessionTitle', 'watchPaths', 'reloadSkills'],
78 PostToolUse: ['additionalContext', 'updatedToolOutput', 'updatedMCPToolOutput'],
79 PostToolUseFailure: ['additionalContext'],
80 PostToolBatch: ['additionalContext'],
81 Stop: ['additionalContext'],
82 SubagentStop: ['additionalContext'],
83}
84const COMMON = ['block', 'preventContinuation', 'stopReason']
85
86// The events whose plain (non-JSON) stdout Claude Code hands to the model as
87// context; every other event's plain stdout is shown to the person only.
88const PLAIN_TO_CONTEXT = new Set(['UserPromptSubmit', 'SessionStart'])
89
90// The env a command hook process inherits that windvane's handlers read per
91// session (the daemon's per-request session env); CLAUDE_PROJECT_DIR is the
92// session root.
93export type SessionEnv = Record<string, string>
94
95export type Json = Record<string, unknown>
96
97const isRecord = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v)
98
99// ---------------------------------------------------------------- matching
100
101// Claude Code's matcher: empty or `*` matches everything; a plain
102// `A|B` alternation matches those names exactly; anything else is a regex.
103export function matcherMatches(matcher: unknown, subject: string | undefined): boolean {
104 if (matcher === undefined || matcher === null || matcher === '' || matcher === '*') return true
105 if (subject === undefined) return true // the event takes no matcher
106 const m = String(matcher)
107 if (/^[\w|]+$/.test(m)) return m.split('|').includes(subject)
108 try {
109 return new RegExp(`^(?:${m})$`).test(subject)
110 } catch {
111 return false
112 }
113}
114
115// What an event's matcher is tested against.
116export function subjectOf(event: string, e: Json): string | undefined {
117 const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : undefined)
118 switch (event) {
119 case 'PostToolUse':
120 case 'PostToolUseFailure':
121 case 'PermissionRequest':
122 case 'PermissionDenied':
123 return s('tool_name')
124 case 'Notification':
125 return s('notification_type')
126 case 'SessionStart':
127 return s('source')
128 case 'PreCompact':
129 case 'PostCompact':
130 return s('trigger')
131 case 'SessionEnd':
132 return s('reason')
133 case 'StopFailure':
134 return s('error_type')
135 default:
136 return undefined
137 }
138}
139
140// ------------------------------------------------------------------ census
141
142// One source of command hooks: a settings file's `hooks`, or a plugin's.
143export type HookSource = { origin: string; hooks: Json }
144
145// The windvane hook type a command hook runs, or undefined when it is not
146// windvane's (`.../windvane/daemon_client.py <type>`, or
147// `-m windvane.daemon_client <type>`).
148export function windvaneType(hook: unknown): string | undefined {
149 if (!isRecord(hook) || (hook.type !== undefined && hook.type !== 'command')) return undefined
150 const args = Array.isArray(hook.args) ? hook.args.map(String) : []
151 const line = [String(hook.command ?? ''), ...args].join(' ')
152 if (!/windvane[\\/.]daemon_client(\.py)?\b/.test(line)) return undefined
153 const words = line.trim().split(/\s+/)
154 const last = (words[words.length - 1] ?? '').replace(/^["']|["']$/g, '')
155 return /^[a-z_]+_json$/.test(last) ? last : undefined
156}
157
158// What the command hooks would run for one call: windvane's hook types (in
159// settings order, once each) and whether any other hook would fire too.
160export function plan(event: string, subject: string | undefined, sources: readonly HookSource[]): { types: string[]; foreign: string[] } {
161 const types: string[] = []
162 const foreign: string[] = []
163 for (const src of sources) {
164 const groups = src.hooks[event]
165 if (!Array.isArray(groups)) continue
166 for (const g of groups) {
167 if (!isRecord(g) || !matcherMatches(g.matcher, subject)) continue
168 for (const h of Array.isArray(g.hooks) ? g.hooks : []) {
169 const t = windvaneType(h)
170 if (t === undefined) foreign.push(src.origin)
171 else if (!types.includes(t)) types.push(t)
172 }
173 }
174 }
175 return { types, foreign }
176}
177
178// ---------------------------------------------------------------- the call
179
180// A handler's stdout, read as Claude Code reads a command hook's: a JSON
181// object, else plain text.
182export type HookOutput = { json?: Json; text?: string }
183
184export function readOutput(stdout: string): HookOutput {
185 const text = stdout.trim()
186 if (!text) return {}
187 if (text.startsWith('{')) {
188 try {
189 const v: unknown = JSON.parse(text)
190 if (isRecord(v)) return { json: v }
191 } catch {
192 // plain text after all
193 }
194 }
195 return { text }
196}
197
198// One handler's output as this event's result.
199export function toClassic(event: string, out: HookOutput): ClassicResult {
200 const r: ClassicResult = {}
201 const ctx: string[] = []
202 const j = out.json
203 if (j) {
204 if (j.decision === 'block') r.block = String(j.reason ?? '') || 'Blocked by hook'
205 if (j.continue === false) {
206 r.preventContinuation = true
207 if (typeof j.stopReason === 'string') r.stopReason = j.stopReason
208 }
209 const h = isRecord(j.hookSpecificOutput) ? j.hookSpecificOutput : undefined
210 if (h) {
211 if (typeof h.additionalContext === 'string' && h.additionalContext) ctx.push(h.additionalContext)
212 if ('updatedToolOutput' in h) r.updatedToolOutput = h.updatedToolOutput
213 if ('updatedMCPToolOutput' in h) r.updatedMCPToolOutput = h.updatedMCPToolOutput
214 if (typeof h.sessionTitle === 'string') r.sessionTitle = h.sessionTitle
215 if (typeof h.initialUserMessage === 'string') r.initialUserMessage = h.initialUserMessage
216 }
217 } else if (out.text && PLAIN_TO_CONTEXT.has(event)) {
218 ctx.push(out.text)
219 }
220 if (ctx.length > 0) r.additionalContext = ctx
221 const allowed = new Set([...COMMON, ...(FIELDS[event] ?? [])])
222 const kept: Json = {}
223 for (const [k, v] of Object.entries(r)) if (allowed.has(k)) kept[k] = v
224 return kept as ClassicResult
225}
226
227// Several handlers' results folded as the engine folds settings hooks:
228// contexts concatenate in order, the first block (and stop reason) stands,
229// any preventContinuation stops, the last tool-output rewrite wins.
230export function foldClassic(results: readonly ClassicResult[]): ClassicResult {
231 const out: ClassicResult = {}
232 const ctx: string[] = []
233 for (const r of results) {
234 if (r.additionalContext) ctx.push(...r.additionalContext)
235 if (r.block !== undefined && out.block === undefined) out.block = r.block
236 if (r.preventContinuation) out.preventContinuation = true
237 if (r.stopReason !== undefined && out.stopReason === undefined) out.stopReason = r.stopReason
238 if ('updatedToolOutput' in r) out.updatedToolOutput = r.updatedToolOutput
239 if ('updatedMCPToolOutput' in r) out.updatedMCPToolOutput = r.updatedMCPToolOutput
240 if (r.sessionTitle !== undefined) out.sessionTitle = r.sessionTitle
241 if (r.initialUserMessage !== undefined) out.initialUserMessage = r.initialUserMessage
242 }
243 if (ctx.length > 0) out.additionalContext = ctx
244 return out
245}
246
247// The daemon's HTTP answer: the handler's stdout, or why there is none (in
248// which case no handler ran and the settings hooks may run instead).
249export function readAnswer(status: number, body: string): { output: string } | { reason: string } {
250 if (status !== 200) return { reason: `daemon answered ${status}` }
251 let v: unknown
252 try {
253 v = JSON.parse(body)
254 } catch {
255 return { reason: 'daemon body is not JSON' }
256 }
257 if (!isRecord(v)) return { reason: 'daemon body is not an object' }
258 if (typeof v.error === 'string') return { reason: `daemon error: ${v.error.slice(0, 120)}` }
259 if (typeof v.output !== 'string') return { reason: 'daemon body has no output' }
260 return { output: v.output }
261}
262
263// ------------------------------------------------------- per-load state
264
265type Census = { at: number; sources: HookSource[]; disabled: boolean; env: SessionEnv; store: string }
266
267export type BridgeCounts = { bridged: number; fellBack: number; partial: number; timedOut: number }
268
269const counts: BridgeCounts = { bridged: 0, fellBack: 0, partial: 0, timedOut: 0 }
270let census: Census | undefined
271let censusLoading: Promise<Census> | undefined
272let daemon: Daemon | undefined
273let downUntil = 0
274
275export function bridgeCounts(): BridgeCounts {
276 return { ...counts }
277}
278
279// --------------------------------------------------------- host helpers
280
281async function readText(host: Host, path: string): Promise<string | undefined> {
282 if (!(await host.exists(path))) return undefined
283 return host.read(path)
284}
285
286async function readJson(host: Host, path: string): Promise<unknown> {
287 const text = await readText(host, path)
288 return text === undefined ? undefined : (JSON.parse(text) as unknown)
289}
290
291// The command hooks one installed plugin declares: hooks/hooks.json and the
292// `hooks` of its manifest (a path, several, or the object inline).
293async function pluginHooks(host: Host, root: string): Promise<Json[]> {
294 const found: Json[] = []
295 const take = (v: unknown) => {
296 if (isRecord(v) && isRecord(v.hooks)) found.push(v.hooks)
297 }
298 take(await readJson(host, `${root}/hooks/hooks.json`))
299 const manifest = await readJson(host, `${root}/.claude-plugin/plugin.json`)
300 if (isRecord(manifest)) {
301 const h = manifest.hooks
302 const paths = typeof h === 'string' ? [h] : Array.isArray(h) ? h.filter(x => typeof x === 'string') : []
303 for (const p of paths) take(await readJson(host, `${root}/${String(p).replace(/^\.\//, '')}`))
304 if (isRecord(h)) found.push(isRecord(h.hooks) ? h.hooks : h)
305 }
306 return found
307}
308
309// Every command hook the session would run beneath the modules: the four
310// settings sources (policy hooks run above the modules either way), the
311// enabled plugins' hooks, and this plugin's own hooks. Another plugin loaded
312// from a session folder (--plugin-dir) is not listed anywhere a mod can
313// read, so its hooks are missing from the census; this plugin's own are read
314// from its folder however it was loaded.
315async function loadCensus(host: Host): Promise<Census> {
316 const sources: HookSource[] = []
317 for (const source of ['user', 'project', 'local', 'flag'] as const) {
318 const s = await host.settings(source)
319 if (isRecord(s.hooks)) sources.push({ origin: `${source} settings`, hooks: s.hooks })
320 }
321 const merged = await host.settings()
322 const disabled = merged.disableAllHooks === true || merged.allowManagedHooksOnly === true
323 const configDir = await host.configDir()
324 const enabled = isRecord(merged.enabledPlugins)
325 ? Object.entries(merged.enabledPlugins).filter(([, flag]) => flag === true).map(([id]) => id)
326 : []
327 const self = normalizePath(host.pluginRoot)
328 let selfListed = false
329 if (enabled.length > 0) {
330 const installed = await readJson(host, `${configDir}/plugins/installed_plugins.json`)
331 const table = isRecord(installed) && isRecord(installed.plugins) ? installed.plugins : {}
332 for (const id of enabled) {
333 const entries = table[id]
334 const first = Array.isArray(entries) ? entries[0] : undefined
335 const root = isRecord(first) && typeof first.installPath === 'string' ? first.installPath.replace(/\\/g, '/') : ''
336 if (!root) continue // enabled, not installed: nothing loads
337 if (normalizePath(root) === self) selfListed = true
338 for (const hooks of await pluginHooks(host, root)) sources.push({ origin: `plugin ${id}`, hooks })
339 }
340 }
341 if (!selfListed && self) {
342 for (const hooks of await pluginHooks(host, self)) sources.push({ origin: `plugin ${PLUGIN}`, hooks })
343 }
344 return { at: Date.now(), sources, disabled, env: await host.sessionEnv(), store: await host.store() }
345}
346
347async function currentCensus(host: Host): Promise<Census> {
348 if (census && Date.now() - census.at < CENSUS_TTL_MS) return census
349 if (!censusLoading) {
350 censusLoading = loadCensus(host).finally(() => {
351 censusLoading = undefined
352 })
353 }
354 census = await censusLoading
355 return census
356}
357
358// Where the daemon listens and the secret it checks: the store's daemon_port
359// and daemon_token files, read once and re-read after a failed fetch (a
360// restarted daemon writes a new pair). Loopback is every account on the
361// machine; the token is what makes the daemon the owner's alone.
362type Daemon = { port: number; token: string }
363
364async function daemonAt(host: Host, store: string): Promise<Daemon | undefined> {
365 if (daemon !== undefined) return daemon
366 const portText = await readText(host, `${store}/daemon_port`)
367 const n = portText === undefined ? NaN : parseInt(portText.trim(), 10)
368 const token = (await readText(host, `${store}/daemon_token`))?.trim()
369 daemon = Number.isInteger(n) && n > 0 && token ? { port: n, token } : undefined
370 return daemon
371}
372
373type Posted = { output: string } | { reason: string; ran: false } | { timedOut: true }
374
375async function post(host: Host, at: Daemon, type: string, stdin: Json, env: SessionEnv): Promise<Posted> {
376 let timer: Timer | undefined
377 const timeout = new Promise<'timeout'>(resolve => {
378 timer = host.after(SLOW.has(type) ? SLOW_TIMEOUT_MS : TIMEOUT_MS, () => resolve('timeout'))
379 })
380 try {
381 const res = await Promise.race([
382 host.post(at.port, at.token, 'hook', JSON.stringify({ hook_event: type, stdin: JSON.stringify(stdin), env })),
383 timeout,
384 ])
385 if (res === 'timeout') return { timedOut: true }
386 const got = readAnswer(res.status, res.text)
387 return 'output' in got ? got : { reason: got.reason, ran: false }
388 } catch (err) {
389 daemon = undefined // re-read: a restarted daemon writes a new port and token
390 return { reason: `fetch failed: ${String(err).slice(0, 120)}`, ran: false }
391 } finally {
392 timer?.cancel()
393 }
394}
395
396// ------------------------------------------------------------ the decision
397
398export type BridgeDecision = { pass: true } | { answer: ClassicResult }
399
400const PASS: BridgeDecision = { pass: true }
401
402// One classic event, the hook's `e` handed whole: the answer the bridge
403// gives for it, or pass, in which case register.ts calls next(e) and the
404// command hooks run as before.
405export async function bridgeDecision(host: Host, input: unknown): Promise<BridgeDecision> {
406 // The event is named by its input: every classic hook's stdin carries
407 // hook_event_name. (classic.PreToolUse's e is the tool call's envelope and
408 // carries none; no hook of windvane's is on it.)
409 if (!isRecord(input) || typeof input.hook_event_name !== 'string') return PASS
410 const event = input.hook_event_name
411
412 // The settings hooks run this one: the reason goes to the debug log.
413 const noteFallBack = (reason: string) => {
414 counts.fellBack += 1
415 host.debug(`${PLUGIN}: bridge: ${event} -> settings hooks (${reason})`)
416 }
417
418 if (event === 'SessionEnd') {
419 const c = counts
420 host.debug(`${PLUGIN}: bridge: ${c.bridged} bridged, ${c.fellBack} to the settings hooks, ${c.partial} partial, ${c.timedOut} timed out`)
421 }
422
423 let found: Census
424 try {
425 found = await currentCensus(host)
426 } catch (err) {
427 noteFallBack(`hook census failed: ${String(err).slice(0, 120)}`)
428 return PASS
429 }
430 const { types, foreign } = plan(event, subjectOf(event, input), found.sources)
431 if (types.length === 0) return PASS // windvane has no hook here
432 if (found.disabled) return PASS // hooks are off: nothing of windvane's would run
433 if (foreign.length > 0) {
434 noteFallBack(`other hooks fire here: ${[...new Set(foreign)].join(', ')}`)
435 return PASS
436 }
437 const unserved = types.filter(t => !SERVED.has(t))
438 if (unserved.length > 0) {
439 noteFallBack(`not daemon-served: ${unserved.join(', ')}`)
440 return PASS
441 }
442 if (Date.now() < downUntil) {
443 noteFallBack('cooling down after a timeout')
444 return PASS
445 }
446
447 let at: Daemon | undefined
448 try {
449 at = await daemonAt(host, found.store)
450 } catch (err) {
451 noteFallBack(`daemon files unreadable: ${String(err).slice(0, 80)}`)
452 return PASS
453 }
454 if (at === undefined) {
455 noteFallBack('no daemon port and token files')
456 return PASS
457 }
458
459 const stdin: Json = { ...input }
460
461 const outputs: HookOutput[] = []
462 let timedOut = false
463 for (const type of types) {
464 const got = await post(host, at, type, stdin, found.env)
465 if ('timedOut' in got) {
466 // It may have run: never run it again through the settings hooks.
467 timedOut = true
468 counts.timedOut += 1
469 downUntil = Date.now() + COOL_DOWN_MS
470 host.debug(`${PLUGIN}: bridge: ${event} ${type} timed out; settings hooks for ${COOL_DOWN_MS / 1000}s`)
471 break
472 }
473 if ('reason' in got) {
474 if (outputs.length === 0) {
475 noteFallBack(`${type}: ${got.reason}`)
476 return PASS
477 }
478 // An earlier type already ran here: running the settings hooks now
479 // would run it twice. Keep what ran; this type is lost this once.
480 counts.partial += 1
481 host.debug(`${PLUGIN}: bridge: ${event} ${type} lost: ${got.reason}`)
482 continue
483 }
484 outputs.push(readOutput(got.output))
485 }
486
487 if (!timedOut || outputs.length > 0) counts.bridged += 1
488 return { answer: foldClassic(outputs.map(o => toClassic(event, o))) }
489}
490hooks/compact.ts 63 lines1// windvane: the compacted conversation carries the checkpoint.
2//
3// A compaction replaces the conversation with a summary the model wrote
4// under pressure. windvane's checkpoint (the state it banked deliberately)
5// and its rules would arrive only afterwards, through the
6// SessionStart(compact) banner. Here they travel inside the compaction
7// itself: once the engine has compacted, one user-role message is placed
8// right after the summary, carrying the rules block and the checkpoint the
9// banner would restore (this session's own newest deliberate one, rewinds
10// skipped, the project's newest as the fallback), rendered whole by the
11// engine (`python -m windvane.brief --checkpoint`).
12//
13// SessionCompacted.messages is the conversation as it reads afterwards; a
14// message a hook adds without a handle is built from its role and text.
15// A precompute (kept for a later compaction) and a subagent's own
16// compaction pass through untouched, as does a skip.
17//
18// A compaction windvane itself starts ($.session.compact from register.ts)
19// runs beneath windvane's own hooks, so that hook never sees it: there the
20// SessionStart(compact) banner carries the rules and the checkpoint, and
21// register.ts's continue prompt points the model at it.
22//
23// register.ts hooks session.compact and places the message compactBrief
24// renders; this module never holds `$`.
25import { BRIEF_TIMEOUT_MS, briefArgv, joinBlocks, parseBrief, type Brief } from './brief'
26import { PLUGIN, engineEnv, type Host } from './engine'
27
28const OPEN_TAG = '<windvane-compact>'
29const CLOSE_TAG = '</windvane-compact>'
30
31async function runBrief(host: Host, python: string, extra: string[]): Promise<Brief | undefined> {
32 const project = await host.cwd()
33 const sid = await host.sessionId()
34 const argv = briefArgv(await host.python(python), project, sid, extra)
35 const env = engineEnv(host.pluginRoot, await host.store(), sid)
36 try {
37 const got = parseBrief(await host.run(argv, { cwd: project, env, timeoutMs: BRIEF_TIMEOUT_MS }))
38 if (typeof got !== 'string') return got
39 host.log(`${PLUGIN}: compact: ${got}`)
40 } catch (err) {
41 host.log(`${PLUGIN}: compact: brief failed: ${String(err)}`)
42 }
43 return undefined
44}
45
46// The text of the message that follows the summary: the rules block and the
47// checkpoint, or undefined when there is nothing to place. Once rendered, the
48// marker sessions/<sid>.briefed tells windvane's SessionStart(compact) hook
49// the rules and the checkpoint are already in the conversation: its banner
50// leaves them out while the marker is fresh.
51export async function compactBrief(host: Host, python: string): Promise<string | undefined> {
52 const got = await runBrief(host, python, ['--checkpoint'])
53 if (!got) return undefined
54 const body = joinBlocks([got.rules, got.checkpoint])
55 if (!body) return undefined
56 try {
57 await host.write(`${await host.store()}/sessions/${await host.sessionId()}.briefed`, JSON.stringify({ plugin: PLUGIN, ts: Date.now() / 1000 }))
58 } catch (err) {
59 host.log(`${PLUGIN}: compact: briefed marker not written: ${String(err)}`)
60 }
61 return `${OPEN_TAG}\n${body}\n${CLOSE_TAG}`
62}
63hooks/door.ts 117 lines1// windvane: tool results trimmed and secrets redacted at the door,
2// deterministically, before the row is stored and read.
3//
4// (a) A tool result's text longer than the budget keeps its head (75% of the
5// budget) and its tail (25%), with one line between them naming how much
6// was cut. The budget is WINDVANE_RESULT_BUDGET when set, else the
7// plugin's result_budget option, else 60_000 characters.
8// (b) Secrets in a tool result are redacted: a private key block, a vendor
9// key by its prefix, and a literal value assigned to a key, token or
10// password word (the value alone, so the line stays readable).
11//
12// The tool-result door only. The person's own prompt is theirs (a token
13// pasted there was pasted on purpose), and the model's reply holds nothing
14// the model has not already seen. The shapes are narrower than the engine's
15// storage gate for mined decisions on purpose: that gate refuses to STORE
16// a line, this door rewrites what the
17// model reads, and a false positive here breaks the next edit (the redacted
18// text no longer matches the file). So no email shape (every git author),
19// no bare-token shape (tool ids, base64 in lockfiles), and a value must be a
20// literal run of 16+ token characters, so `os.environ["DB_PASSWORD"]` and
21// `process.env.OPENAI_KEY` pass.
22//
23// Only text is touched: a text block, and a tool_result's content (a string,
24// or its text blocks). Images and documents pass as they came. A row that
25// needs no change goes on as `next(e)`, untouched.
26//
27// register.ts hooks session.append and rewrites the row with these; this
28// module never holds `$`.
29import { budgetOf, type Host } from './engine'
30
31export const DEFAULT_BUDGET = 60_000
32const HEAD_SHARE = 0.75
33
34export type Block = { type: string; [field: string]: unknown }
35
36// A PEM private key, header to footer (or to the end of the text when the
37// footer was cut off).
38const PEM_BLOCK = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g
39// Vendor keys by prefix: AWS access key id, GitHub tokens, OpenAI-style
40// `sk-`, Slack `xox?-`.
41const PREFIXED_KEY = /\b(?:AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g
42// `api_key = <literal>`, `password: "<literal>"`, `Bearer <literal>`: group 1
43// is kept, group 2 (16+ token characters, no dot) is the value.
44const ASSIGNED_VALUE = /\b((?:api[_ -]?key|secret|token|password|passwd)\b\s*[:=]\s*["']?|bearer\s+)([A-Za-z0-9_\-/+=]{16,})/gi
45
46export const REDACTED = '[redacted]'
47
48export function redact(text: string): string {
49 return text
50 .replace(PEM_BLOCK, REDACTED)
51 .replace(PREFIXED_KEY, REDACTED)
52 .replace(ASSIGNED_VALUE, (_m: string, head: string) => head + REDACTED)
53}
54
55export function trimMarker(cut: number): string {
56 return `\n[windvane: ${cut} characters trimmed at the door]\n`
57}
58
59// Head and tail of a text over budget; a cut never splits a surrogate pair.
60export function trim(text: string, budget: number): string {
61 if (text.length <= budget) return text
62 let head = Math.floor(budget * HEAD_SHARE)
63 let tailStart = text.length - (budget - head)
64 if (head > 0 && isHigh(text.charCodeAt(head - 1))) head -= 1
65 if (tailStart < text.length && isLow(text.charCodeAt(tailStart))) tailStart += 1
66 return text.slice(0, head) + trimMarker(tailStart - head) + text.slice(tailStart)
67}
68
69function isHigh(c: number): boolean {
70 return c >= 0xd800 && c <= 0xdbff
71}
72
73function isLow(c: number): boolean {
74 return c >= 0xdc00 && c <= 0xdfff
75}
76
77// One text through the door: redacted, then trimmed to the budget.
78function pass(text: string, budget: number): string {
79 return trim(redact(text), budget)
80}
81
82// The row's blocks through the door; undefined when nothing changed.
83export function rewrite(content: readonly Block[], budget: number): Block[] | undefined {
84 let changed = false
85 const out = content.map(block => {
86 if (block.type === 'text' && typeof block.text === 'string') {
87 const text = pass(block.text, budget)
88 if (text === block.text) return block
89 changed = true
90 return { ...block, text }
91 }
92 if (block.type === 'tool_result') {
93 const inner = block.content
94 if (typeof inner === 'string') {
95 const text = pass(inner, budget)
96 if (text === inner) return block
97 changed = true
98 return { ...block, content: text }
99 }
100 if (Array.isArray(inner)) {
101 const rewritten = rewrite(inner as Block[], budget)
102 if (rewritten === undefined) return block
103 changed = true
104 return { ...block, content: rewritten }
105 }
106 }
107 return block
108 })
109 return changed ? out : undefined
110}
111
112// WINDVANE_RESULT_BUDGET when it is a positive whole number of characters,
113// else the option's budget, else the default.
114export async function readBudget(host: Host, configured: number | undefined): Promise<number> {
115 return budgetOf(await host.resultBudgetEnv()) ?? configured ?? DEFAULT_BUDGET
116}
117hooks/engine.ts 161 lines1// windvane: how the mod reaches the engine, and the plugin's options.
2//
3// The engine is the Python package shipped inside the plugin
4// (`<plugin root>/windvane`). The mod runs its modules as
5// `python -m windvane.<module>` with PYTHONPATH pointing at that folder, so
6// nothing has to be installed beside the plugin. A process started by
7// `$.process.run` inherits the host's environment, not the session's: what
8// the engine needs from the session (the store, the session id) is passed
9// in `env` by the caller.
10//
11// Pure helpers, and the Host type: the engine follows `$` into functions of
12// the same file and never across an import, so register.ts alone holds `$`
13// and hands the other modules a Host, a handful of closures over it.
14import type { HttpResponse, ProcessRunInit, ProcessRunResult, SettingsSource, Timer } from 'claude-code'
15import type { PluginOptions } from 'claude-code'
16
17export const PLUGIN = 'windvane'
18export const VERSION = '1.0.14'
19
20// What a module gets instead of `$`: the calls it needs, each spelled once in
21// register.ts (hostOf there), the one file that holds the engine interface.
22// The environment variables are read there by name, so a module asks for the
23// store or the interpreter, never for a variable.
24export type Host = {
25 // The plugin's folder, absolute.
26 pluginRoot: string
27 // The store: WINDVANE_DIR, else .windvane under the home folder.
28 store(): Promise<string>
29 // The interpreter: WINDVANE_PYTHON, else the python option, else `python`.
30 python(configured: string): Promise<string>
31 sessionId(): Promise<string>
32 cwd(): Promise<string>
33 root(): Promise<string>
34 // $.clock's time, ms since the epoch.
35 now(): Promise<number>
36 after(ms: number, fn: () => void): Timer
37 exists(path: string): Promise<boolean>
38 read(path: string): Promise<string>
39 write(path: string, text: string): Promise<void>
40 run(argv: string[], init: ProcessRunInit): Promise<ProcessRunResult>
41 // One POST of a JSON body to the daemon on loopback, at /hook or /tool.
42 post(port: number, token: string, path: 'hook' | 'tool', body: string): Promise<HttpResponse>
43 storeGet(key: string): Promise<unknown>
44 storeSet(key: string, value: unknown): Promise<void>
45 storeKeys(): Promise<string[]>
46 // The settings merged over every source, or one source's as loaded.
47 settings(source?: SettingsSource): Promise<Record<string, unknown>>
48 // The session's environment the engine's handlers read (the bridge).
49 sessionEnv(): Promise<Record<string, string>>
50 // Claude Code's config folder: CLAUDE_CONFIG_DIR, else .claude under home.
51 configDir(): Promise<string>
52 // WINDVANE_RESULT_BUDGET as set, for the door.
53 resultBudgetEnv(): Promise<string | undefined>
54 // A line in the session's log, and one in the debug log alone.
55 log(text: string): void
56 debug(text: string): void
57}
58
59// The plugin's userConfig rows the mod itself reads. `semantic`,
60// `alert_command`, `strict_pack` and `autonomy` are read by the engine from
61// the settings' pluginConfigs; the mod passes nothing for them.
62export type Settings = {
63 // The interpreter the python row names; '' when it names none.
64 python: string
65 // false hides the status line segment; the band and the pane stay.
66 statusSegment: boolean
67 // The door's budget in characters; undefined leaves the door's default.
68 resultBudget: number | undefined
69 // false: a compaction windvane started ends the session's work until the
70 // person types; true: windvane's own prompt resumes it.
71 continueAfterCompact: boolean
72 // The early_compaction row: the checkpoint band also opens at this fill
73 // (a percentage of the window) or once one turn has cost this much (a
74 // dollar figure, the session's own priced cost across the turn).
75 // undefined: the band opens only at the engine's margin above the trigger.
76 earlyCompaction: EarlyCompaction | undefined
77}
78
79export type EarlyCompaction = { label: string; percent?: number; usd?: number }
80
81// A positive whole number of characters, from a string or a number; anything
82// else is undefined.
83export function budgetOf(raw: unknown): number | undefined {
84 const n = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw.trim()) : NaN
85 return Number.isInteger(n) && n > 0 ? n : undefined
86}
87
88// The early_compaction row: '40%' (a fill, 1..99 percent of the window) or
89// '$0.40' (one turn's cost in dollars, above zero). Anything else is off.
90export function earlyCompactionOf(raw: unknown): EarlyCompaction | undefined {
91 if (typeof raw !== 'string') return undefined
92 const s = raw.trim()
93 const pct = /^(\d+(?:\.\d+)?)\s*%$/.exec(s)
94 if (pct) {
95 const n = Number(pct[1])
96 return n > 0 && n < 100 ? { label: `${n}%`, percent: n } : undefined
97 }
98 const usd = /^\$\s*(\d+(?:\.\d+)?)$/.exec(s)
99 if (usd) {
100 const n = Number(usd[1])
101 return n > 0 ? { label: `$${n}`, usd: n } : undefined
102 }
103 return undefined
104}
105
106// register(on, options): the values as the modules use them.
107export function settingsOf(options: PluginOptions | undefined): Settings {
108 const o = options ?? {}
109 return {
110 python: typeof o.python === 'string' ? o.python.trim() : '',
111 statusSegment: o.status_segment !== false,
112 resultBudget: budgetOf(o.result_budget),
113 continueAfterCompact: o.continue_after_compact !== false,
114 earlyCompaction: earlyCompactionOf(o.early_compaction),
115 }
116}
117
118// The interpreter: WINDVANE_PYTHON wins, then the python option, then
119// whatever `python` resolves to on PATH.
120export function pythonOf(env: string | undefined, configured: string): string {
121 return (env && env.trim()) || configured.trim() || 'python'
122}
123
124// The environment a run of the engine gets over the host's own: the engine
125// package on PYTHONPATH, the store, UTF-8 pipes, and the session id where the
126// engine keys its state by the session.
127export function engineEnv(root: string, store: string, sessionId?: string): Record<string, string> {
128 const env: Record<string, string> = {
129 PYTHONPATH: `${root.replace(/\\/g, '/')}`,
130 WINDVANE_DIR: store,
131 PYTHONIOENCODING: 'utf-8',
132 }
133 if (sessionId) env.CLAUDE_CODE_SESSION_ID = sessionId
134 return env
135}
136
137// What to tell the person when the interpreter did not start.
138export function pythonHint(python: string, err: unknown): string {
139 return `${python} did not run (${String(err)}). Set the plugin's python option, or WINDVANE_PYTHON, to a Python 3.10+ interpreter.`
140}
141
142// The last line of a run's stdout read as a JSON object; undefined when it
143// is not one.
144export function lastJsonLine(stdout: string): Record<string, unknown> | undefined {
145 const lines = stdout.trim().split('\n')
146 const last = (lines[lines.length - 1] ?? '').trim()
147 if (!last.startsWith('{')) return undefined
148 try {
149 const v: unknown = JSON.parse(last)
150 return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined
151 } catch {
152 return undefined
153 }
154}
155
156// Text on one line, at most n characters.
157export function clip(text: string, n: number): string {
158 const one = text.replace(/\s+/g, ' ').trim()
159 return one.length > n ? one.slice(0, n - 3) + '...' : one
160}
161hooks/export.ts 59 lines1// windvane: /windvane-export writes the project's memory out as files.
2//
3// The engine does the work, `python -m windvane.export --project <cwd>`,
4// and prints one JSON line naming the files it wrote (`written`, or `paths`
5// / `files`); the command lists them. An engine that prints one path per
6// line instead is read the same way.
7//
8// register.ts registers the command and hooks its run; this module never
9// holds `$`.
10import { clip, engineEnv, lastJsonLine, pythonHint, type Host } from './engine'
11
12const EXPORT_TIMEOUT_MS = 120_000
13
14// Registered at session.start by register.ts.
15export const EXPORT_COMMAND = {
16 name: 'windvane-export',
17 description: "windvane: export this project's memory, rules and checkpoints to files",
18}
19
20// The paths a run wrote: the JSON line's list, else stdout's lines.
21export function pathsOf(stdout: string, reply: Record<string, unknown> | undefined): string[] {
22 if (reply) {
23 for (const key of ['written', 'paths', 'files']) {
24 const v = reply[key]
25 if (Array.isArray(v)) return v.filter((p): p is string => typeof p === 'string' && p !== '')
26 }
27 return []
28 }
29 return stdout
30 .split('\n')
31 .map(l => l.trim())
32 .filter(Boolean)
33}
34
35// The command's answer.
36export async function exportProject(host: Host, configured: string): Promise<string> {
37 const python = await host.python(configured)
38 const store = await host.store()
39 const project = await host.cwd()
40 let run
41 try {
42 run = await host.run([python, '-m', 'windvane.export', '--project', project], {
43 cwd: project,
44 env: engineEnv(host.pluginRoot, store),
45 timeoutMs: EXPORT_TIMEOUT_MS,
46 })
47 } catch (err) {
48 return `Not exported: ${pythonHint(python, err)}`
49 }
50 const reply = lastJsonLine(run.stdout)
51 if (run.exitCode !== 0 || typeof reply?.error === 'string') {
52 const why = (typeof reply?.error === 'string' && reply.error) || clip(run.stderr, 200) || `exit ${run.exitCode}`
53 return `Not exported: ${why}`
54 }
55 const paths = pathsOf(run.stdout, reply)
56 if (paths.length === 0) return 'Nothing exported: the project holds nothing to write.'
57 return [`Exported ${paths.length} file${paths.length === 1 ? '' : 's'}:`, ...paths.map(p => ` ${p}`)].join('\n')
58}
59hooks/import.ts 47 lines1// windvane: /windvane-import brings an existing store's records into
2// windvane's.
3//
4// The engine does the work, `python -m windvane.migrate --import`, and
5// prints one JSON line, `{"copied": N, "skipped": N, "dst": "<store>"}` or
6// `{"error": "..."}`; the command shows the counts or the error.
7//
8// register.ts registers the command and hooks its run; this module never
9// holds `$`.
10import { clip, engineEnv, lastJsonLine, pythonHint, type Host } from './engine'
11
12const IMPORT_TIMEOUT_MS = 300_000
13
14// Registered at session.start by register.ts.
15export const IMPORT_COMMAND = {
16 name: 'windvane-import',
17 description: "windvane: import an existing store's memories, rules and checkpoints",
18}
19
20// The reply in one line: "copied N, skipped N into <dst>".
21export function importLine(reply: Record<string, unknown>): string {
22 const n = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
23 const dst = typeof reply.dst === 'string' && reply.dst ? ` into ${reply.dst}` : ''
24 return `copied ${n(reply.copied)}, skipped ${n(reply.skipped)}${dst}`
25}
26
27// The command's answer.
28export async function importStore(host: Host, configured: string): Promise<string> {
29 const python = await host.python(configured)
30 const store = await host.store()
31 let run
32 try {
33 run = await host.run([python, '-m', 'windvane.migrate', '--import'], {
34 env: engineEnv(host.pluginRoot, store),
35 timeoutMs: IMPORT_TIMEOUT_MS,
36 })
37 } catch (err) {
38 return `Not imported: ${pythonHint(python, err)}`
39 }
40 const reply = lastJsonLine(run.stdout)
41 if (run.exitCode !== 0 || !reply || typeof reply.error === 'string') {
42 const why = (typeof reply?.error === 'string' && reply.error) || clip(run.stderr, 200) || `exit ${run.exitCode}`
43 return `Not imported: ${why}`
44 }
45 return `Imported: ${importLine(reply)}`
46}
47hooks/ledger.ts 150 lines1// windvane: the per-project token and cost ledger, recorded by the machine
2// so nobody has to write it down.
3//
4// Every main-loop turn.complete adds the turn's usage (TurnUsage: the four
5// token counts as the API reports them) to $.store under
6// `ledger:<the folder the session was opened in>:<YYYY-MM-DD>` (the local
7// day), with a turn count
8// and the cost: the session's own priced total ($.session.usage().cost.usd)
9// since the previous main turn, so a subagent's spend lands on the turn that
10// waited for it. Subagent turns are not counted on their own (their tokens
11// are in the session's cost, not in the token columns). A turn that counted
12// nothing (an interrupt, an API error: no usage) adds no entry, and a shell
13// cd during the session does not move the project.
14//
15// /windvane-cost prints today's totals for this project, this project's
16// totals over every recorded day, and today's over every project.
17//
18// register.ts holds every hook (session.start, turn.complete and the
19// command's run) and the store writes, built on the plain functions here and
20// the report; this module never holds `$`.
21import type { CommandSpec, TurnUsage } from 'claude-code'
22import type { Host } from './engine'
23import { normalizePath } from './ring'
24
25const PREFIX = 'ledger:'
26
27export const LEDGER_COMMAND: CommandSpec = {
28 name: 'windvane-cost',
29 description: "windvane: this project's tokens and cost, today and over every recorded day",
30}
31
32export type LedgerEntry = {
33 input: number
34 output: number
35 cache_read: number
36 cache_creation: number
37 turns: number
38 cost_usd: number
39}
40
41function empty(): LedgerEntry {
42 return { input: 0, output: 0, cache_read: 0, cache_creation: 0, turns: 0, cost_usd: 0 }
43}
44
45function num(v: unknown): number {
46 return typeof v === 'number' && Number.isFinite(v) ? v : 0
47}
48
49// A stored entry read defensively: a field missing or not a number is 0.
50export function asEntry(v: unknown): LedgerEntry {
51 const o = (v ?? {}) as Record<string, unknown>
52 return {
53 input: num(o.input),
54 output: num(o.output),
55 cache_read: num(o.cache_read),
56 cache_creation: num(o.cache_creation),
57 turns: num(o.turns),
58 cost_usd: num(o.cost_usd),
59 }
60}
61
62export function add(a: LedgerEntry, b: LedgerEntry): LedgerEntry {
63 return {
64 input: a.input + b.input,
65 output: a.output + b.output,
66 cache_read: a.cache_read + b.cache_read,
67 cache_creation: a.cache_creation + b.cache_creation,
68 turns: a.turns + b.turns,
69 cost_usd: a.cost_usd + b.cost_usd,
70 }
71}
72
73// The session's cost at the previous main turn. Module state: a reload starts
74// over from the figure at its session.start, so nothing is counted twice.
75let lastCost: number | undefined
76
77// At session.start: the figure the ledger counts from.
78export function costBaseline(cost: number | undefined): void {
79 lastCost = cost
80}
81
82// One main-loop turn as a ledger entry: its four counts, and the session's
83// cost since the previous main turn (0 when either figure is missing).
84export function turnEntry(usage: TurnUsage | undefined, cost: number | undefined): LedgerEntry {
85 const spent = cost !== undefined && lastCost !== undefined && cost >= lastCost ? cost - lastCost : 0
86 if (cost !== undefined) lastCost = cost
87 return {
88 input: num(usage?.input_tokens),
89 output: num(usage?.output_tokens),
90 cache_read: num(usage?.cache_read_input_tokens),
91 cache_creation: num(usage?.cache_creation_input_tokens),
92 turns: 1,
93 cost_usd: spent,
94 }
95}
96
97// The local day of a time in milliseconds, YYYY-MM-DD.
98export function localDay(ms: number): string {
99 const d = new Date(ms)
100 const mm = String(d.getMonth() + 1).padStart(2, '0')
101 const dd = String(d.getDate()).padStart(2, '0')
102 return `${d.getFullYear()}-${mm}-${dd}`
103}
104
105export function ledgerKey(cwd: string, day: string): string {
106 return `${PREFIX}${normalizePath(cwd)}:${day}`
107}
108
109// `ledger:<project>:<day>`: the project may hold colons (a drive letter), the
110// day is the last ten characters.
111function parseKey(key: string): { project: string; day: string } | undefined {
112 if (!key.startsWith(PREFIX) || key.length < PREFIX.length + 12) return undefined
113 const day = key.slice(-10)
114 if (key[key.length - 11] !== ':' || !/^\d{4}-\d{2}-\d{2}$/.test(day)) return undefined
115 return { project: key.slice(PREFIX.length, -11), day }
116}
117
118function grouped(n: number): string {
119 return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
120}
121
122function line(label: string, t: LedgerEntry): string {
123 return (
124 `${label}: ${grouped(t.turns)} turns · input ${grouped(t.input)} · output ${grouped(t.output)}` +
125 ` · cache read ${grouped(t.cache_read)} · cache write ${grouped(t.cache_creation)} · $${t.cost_usd.toFixed(2)}`
126 )
127}
128
129// /windvane-cost's answer for the ledger's project.
130export async function report(host: Host, project: string): Promise<string> {
131 const today = localDay(await host.now())
132 let projectToday = empty()
133 let projectAll = empty()
134 let allToday = empty()
135 for (const key of await host.storeKeys()) {
136 const k = parseKey(key)
137 if (!k || (k.project !== project && k.day !== today)) continue
138 const entry = asEntry(await host.storeGet(key))
139 if (k.project === project) projectAll = add(projectAll, entry)
140 if (k.day === today) allToday = add(allToday, entry)
141 if (k.project === project && k.day === today) projectToday = add(projectToday, entry)
142 }
143 return [
144 `windvane ledger for ${project}, ${today}`,
145 line('today, this project', projectToday),
146 line('this project, all days', projectAll),
147 line('today, all projects', allToday),
148 ].join('\n')
149}
150hooks/pane.tsx 222 lines1// windvane: /windvane opens a pane with what windvane holds for this
2// session, read from the store's own files:
3//
4// - the newest checkpoint record whole (the ring register.ts reads for the
5// status line): task, current step, completed, pending, files, warnings,
6// context needed, handoff note, goal;
7// - the project's rules (the cwd's registered project and the ancestors it
8// inherits from, as the engine's project memory loader walks them);
9// - the mistakes for the file the model last touched (Edit, Write, Read,
10// noted as the call is made), matched as the pre-edit check matches them.
11//
12// The pane reads the store when it opens and when Refresh is pressed.
13// register.ts hooks the command, the tool calls that name the file and the
14// pane's render, keeps the view in $.state and hands it here to draw; this
15// module never holds `$`.
16import type { Elements, RenderElement } from 'claude-code'
17
18import type { PaneView, Pressure } from '../types'
19import type { Host } from './engine'
20import {
21 ageText,
22 mistakesFor,
23 normalizePath,
24 projectChain,
25 readEntries,
26 readLatest,
27 readManifest,
28 ringsFor,
29 rulesOf,
30 storePath,
31} from './ring'
32import type { Io } from './ring'
33
34// The elements a drawing takes: the surface's table, as $.ui.resolve(e)
35// hands it out.
36export type Draw = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
37
38// The tools whose file_path names the file the model last touched.
39export const TOUCH_TOOLS = new Set(['Edit', 'Write', 'Read', 'MultiEdit', 'NotebookEdit'])
40// The body rows asked for while the pane sits inline above the prompt: the
41// summary, the checkpoint and the first rules without scrolling.
42export const PANE_ROWS = 24
43// The label column of the checkpoint's rows, and how many items of a list
44// are shown before "and N more".
45const LABEL = 11
46const LIST_ROWS = 6
47
48function oneLine(text: string): string {
49 return text.replace(/\s+/g, ' ').trim()
50}
51
52function clip(text: string, max: number): string {
53 return text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text
54}
55
56function basename(path: string): string {
57 const parts = path.replace(/\\/g, '/').replace(/\/+$/, '').split('/')
58 return parts[parts.length - 1] || path
59}
60
61function count(n: number, noun: string): string {
62 return `${n} ${noun}${n === 1 ? '' : 's'}`
63}
64
65// Registered at session.start by register.ts.
66export const PANE_COMMAND = {
67 name: 'windvane',
68 description: "Show windvane's checkpoint, rules and file mistakes in a pane",
69}
70
71function ioOf(host: Host): Io {
72 return { read: p => host.read(p), exists: p => host.exists(p) }
73}
74
75function list(...values: (string[] | undefined)[]): string[] {
76 for (const v of values) if (Array.isArray(v) && v.length) return v.filter(s => typeof s === 'string' && s !== '')
77 return []
78}
79
80// The pane's body, from the store as it stands; `file` is the one the model
81// last touched, when any.
82export async function loadView(host: Host, file: string | undefined): Promise<PaneView> {
83 const io = ioOf(host)
84 const store = await host.store()
85 const manifest = await readManifest(io, store)
86 const cwd = normalizePath(await host.cwd())
87 const root = normalizePath(await host.root())
88
89 const rec = await readLatest(io, ringsFor(manifest, store, cwd, root), store)
90 const chain = projectChain(manifest, cwd)
91 const rules = rulesOf(await readEntries(io, store, chain))
92
93 const fileChain = file ? projectChain(manifest, file) : []
94 const mistakes = file ? mistakesFor(await readEntries(io, store, fileChain.length ? fileChain : chain), file) : []
95
96 const view: PaneView = { loadedAt: Date.now(), store, project: chain[0]?.path, rules, file, mistakes }
97 if (rec) {
98 const task = rec.task_description ?? ''
99 const handoff = rec.handoff_summary || rec.summary || ''
100 view.checkpoint = {
101 created: rec.created ?? rec.timestamp,
102 kind: rec.kind,
103 task_id: rec.task_id,
104 project_path: rec.project_path,
105 task_description: task || undefined,
106 current_step: rec.current_step || undefined,
107 completed: list(rec.completed_steps),
108 pending: list(rec.next_steps, rec.pending_steps),
109 files: list(rec.files_in_progress, rec.files_involved),
110 warnings: list(rec.warnings, rec.handoff_warnings),
111 context_needed: list(rec.context_needed, rec.handoff_context_needed),
112 handoff: handoff && handoff !== task ? handoff : undefined,
113 goal: rec.goal || undefined,
114 }
115 }
116 return view
117}
118
119// What the pane draws from: the view as loaded (null before the first
120// read), the status line's figures, whether the band is hidden (its Show
121// button then appears), the body's width in columns and the time.
122export type PaneData = {
123 view: PaneView | null
124 figures: Pressure | null
125 hidden: boolean
126 bodyColumns: number | undefined
127 nowMs: number
128}
129
130// The buttons' work, done by register.ts: a fresh read of the store, and
131// the band shown again.
132export type PaneActions = { refresh: () => void; showBand: () => void }
133
134// The pane's tree.
135export function drawPane(ui: Draw, data: PaneData, act: PaneActions): RenderElement {
136 const { Box, Button, Text } = ui
137 const { view, figures, hidden, nowMs: now } = data
138
139 // The body's width, so prose that may take two rows is cut after them;
140 // every other row is cut at the edge by the surface, with an ellipsis.
141 const width = Math.max(24, data.bodyColumns || 80)
142 const rows: RenderElement[] = []
143 let n = 0
144 type Style = { bold?: boolean; dimColor?: boolean }
145 const row = (text: string, style: Style = {}) =>
146 rows.push(
147 <Text key={`l${n++}`} wrap="truncate-end" {...style}>
148 {text}
149 </Text>,
150 )
151 const gap = () => row(' ')
152 const pad = (label: string) => label.padEnd(LABEL)
153 // A labelled row of prose: up to two rows, then an ellipsis.
154 const prose = (label: string, text: string) =>
155 rows.push(
156 <Text key={`l${n++}`} wrap="wrap">
157 {` ${pad(label)}${clip(oneLine(text), 2 * width - LABEL - 4)}`}
158 </Text>,
159 )
160 // A labelled list: the count in the label, one item per row, the first
161 // LIST_ROWS of them.
162 const items = (label: string, list: string[]) => {
163 row(` ${pad(`${label} ${list.length}`)}${oneLine(list[0] ?? '')}`)
164 for (const s of list.slice(1, LIST_ROWS)) row(` ${pad('')}${oneLine(s)}`)
165 if (list.length > LIST_ROWS) row(` ${pad('')}and ${list.length - LIST_ROWS} more`, { dimColor: true })
166 }
167
168 // One row of figures: the fill, the checkpoint age, the band, and what
169 // the pane holds below.
170 const summary = [
171 figures ? `ctx ${figures.percent ?? '?'}%` : undefined,
172 figures ? ageText(figures.checkpointCreated, now) : undefined,
173 figures?.mark === 'checkpoint' ? 'checkpoint now' : figures?.mark === 'early' ? 'compact at step end' : undefined,
174 view ? count(view.rules.length, 'rule') : undefined,
175 view?.file ? `${count(view.mistakes.length, 'mistake')} for ${basename(view.file)}` : undefined,
176 ].filter((s): s is string => s !== undefined)
177 if (summary.length) row(summary.join(' · '), { dimColor: true })
178
179 if (!view) {
180 row('Nothing read yet: press Refresh.', { dimColor: true })
181 } else {
182 const c = view.checkpoint
183 gap()
184 if (!c) {
185 row('Checkpoint · none in the ring', { bold: true })
186 } else {
187 const meta = [c.kind ?? 'auto', `${ageText(c.created, now).replace('ckpt ', '')} ago`, c.task_id, c.project_path ? basename(c.project_path) : undefined]
188 row(`Checkpoint · ${meta.filter(Boolean).join(' · ')}`, { bold: true })
189 if (c.task_description) prose('Task', c.task_description)
190 if (c.current_step) prose('Step', c.current_step)
191 if (c.completed.length) items('Done', c.completed)
192 if (c.pending.length) items('Pending', c.pending)
193 if (c.files.length) row(` ${pad(`Files ${c.files.length}`)}${c.files.map(basename).join(', ')}`)
194 if (c.warnings.length) items('Warnings', c.warnings)
195 if (c.context_needed.length) items('Needed', c.context_needed)
196 if (c.handoff) prose('Handoff', c.handoff)
197 if (c.goal) prose('Goal', c.goal)
198 }
199
200 gap()
201 row(`Rules · ${view.rules.length}${view.project ? ` · ${basename(view.project)}` : ''}`, { bold: true })
202 if (!view.rules.length) row(' none', { dimColor: true })
203 for (const r of view.rules) row(` [${r.id}] ${oneLine(r.content)}`)
204
205 gap()
206 row(view.file ? `Mistakes · ${view.mistakes.length} · ${basename(view.file)}` : 'Mistakes', { bold: true })
207 if (!view.file) row(' no file touched yet this session', { dimColor: true })
208 else if (!view.mistakes.length) row(' none', { dimColor: true })
209 for (const m of view.mistakes) row(` [${m.id}] ${oneLine(m.content)}`)
210 }
211
212 return (
213 <Box key="windvane-pane" flexDirection="column">
214 <Box key="windvane-actions" flexDirection="row">
215 <Button key="windvane-refresh" label="Refresh" onPress={act.refresh} />
216 {hidden && <Button key="windvane-show-band" label="Show band" onPress={act.showBand} />}
217 </Box>
218 {rows}
219 </Box>
220 )
221}
222hooks/remember.ts 52 lines1// windvane: /remember stores the text the person selected in the
2// transcript as a DECISION in windvane, for the session's project.
3//
4// The store is written by the engine, never by the mod: the command runs
5// `python -m windvane.remember`, which files the entry through the same
6// writer the memory tool's remember operation and the miner's decisions use.
7// The text goes in on stdin, so no quoting rule of any shell touches it.
8// The interpreter is WINDVANE_PYTHON, else the plugin's python option, else
9// `python` on PATH; the store is the one the rest of the mod reads
10// (WINDVANE_DIR, else ~/.windvane).
11//
12// register.ts registers the command, hooks its run with the selection and
13// the file the model last touched; this module never holds `$`.
14import { clip, engineEnv, lastJsonLine, pythonHint, type Host } from './engine'
15
16const REMEMBER_TIMEOUT_MS = 30_000
17
18// Registered at session.start by register.ts.
19export const REMEMBER_COMMAND = {
20 name: 'remember',
21 description: 'Store the selected transcript text in windvane as a decision',
22}
23
24type Reply = { stored?: boolean; id?: string; project?: string; message?: string; error?: string }
25
26// The command's answer for the selected text; `file` is the one the model
27// last touched, when any.
28export async function remember(host: Host, configured: string, text: string, file: string | null | undefined): Promise<string> {
29 const python = await host.python(configured)
30 const store = await host.store()
31 const argv = [python, '-m', 'windvane.remember', '--project', await host.cwd(), '--kind', 'decision']
32 if (file) argv.push('--file', file)
33
34 let run
35 try {
36 run = await host.run(argv, {
37 stdin: text,
38 env: engineEnv(host.pluginRoot, store),
39 timeoutMs: REMEMBER_TIMEOUT_MS,
40 })
41 } catch (err) {
42 return `Not remembered: ${pythonHint(python, err)}`
43 }
44 const reply = (lastJsonLine(run.stdout) ?? {}) as Reply
45 if (run.exitCode !== 0 || reply.error) {
46 const why = reply.error || clip(run.stderr, 200) || `exit ${run.exitCode}`
47 return `Not remembered: ${why}`
48 }
49 if (!reply.stored) return `Already in windvane${reply.project ? ` for ${reply.project}` : ''}: ${reply.message ?? ''}`.trim()
50 return `Remembered as a decision${reply.project ? ` for ${reply.project}` : ''}${reply.id ? ` [${reply.id}]` : ''}: ${clip(text, 120)}`
51}
52