SLOPSHOPPER

windvane

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…

newpanebandguardcommandtoast
★ 1v1.0.14MITupdated 2026-10-0920alexl/windvane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · windvane
│ ┃ windvane ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ [ Refresh ] │ windvane │ │ ┃ 0 rules · 0 mistakes for cache.ts ● windvane: windvane: │ windvane: installing the semantic extra; │ │ ┃ ⏺ Read(src/auth.ts) │ this takes a few minutes │ │ ┃ Checkpoint · none in the ring ⎿ Read 6 lines ╰────────────────────────────────────────────╯ │ ┃ ⏺ Update(src/auth.ts) ╭────────────────────────────────────────────╮ │ ┃ Rules · 0 ⎿ Added 2 lines, re│ windvane │ │ ┃ none ⏺ Bash(bun test) │ windvane: the semantic tier is on; the │ │ ┃ ⎿ 3 pass, 1 fail │ daemon loads the model on its next start │ │ ┃ Mistakes · 0 · cache.ts ╰────────────────────────────────────────────╯ │ ┃ none ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /windvane │ ⎿ windvane: windvane pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · windvane
[ Refresh ] 0 rules · 0 mistakes for cache.ts Checkpoint · none in the ring Rules · 0 none Mistakes · 0 · cache.ts none
README

windvane

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.

windvane demo

Install

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.

What runs on its own

The mod in the session, the daemon, the engine and the store

Every row happens without a call from you or the model.

MomentWhat windvane does
Session startPrints 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 promptCaptures 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 editWarns 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 readOnce per file per session: an orientation from the code index and the file's best memories.
Before a shell commandMatches 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 toolIn autonomy mode after a halt, denies every tool except the few that let the run leave a record.
After an editCounts the edit for the loop warning.
After a shell commandTracks 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 toolLogs 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 callsAccounts the calls to the turn for stall detection and records detector matches on non-shell tools.
Plan approved or task completedAsks for the plan to be banked as a checkpoint, or counts a finished task as a step done.
Turn endsSaves 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 compactionBanks the drafted checkpoint and indexes the transcript while the detail is still in it.
After compactionOpens a new pressure cycle. The session-start banner that follows restores the state, unless the compacted conversation already carries it.
API failure or notificationRecords the failure with its type and, for a usage limit, the reset time. In autonomy mode, sends an alert.
Session endWrites the run report for a substantial session and starts the post-session miner.

The mod adds these, in an interactive session:

MomentWhat windvane does
Every 10 secondsMirrors the context fill, draws the status segment and the band, and watches for the compaction point.
A tool result arrivesKeeps its head and tail when it passes the budget, and redacts private keys, vendor keys and literal secret values.
An agent is launchedPuts the project's rules, and the past mistakes for the files the prompt names, at the head of the agent's prompt.
A compaction finishesPlaces the rules and the checkpoint right after the summary, inside the conversation.
A turn endsAdds the turn's tokens and cost to the project's ledger.

The tools

The model calls these as mcp__windvane__<name>. Each takes an optional project_path.

  • checkpoint: save, restore, list. A bare save accepts the drafted record. A field given amends that field.
  • compact_now: banks the drafted checkpoint, compacts as soon as the turn ends, and resumes the work with one prompt of windvane's.
  • memory: remember, recall, search, forget, add_rule, list_rules, modify, delete, promote, archive, restore, list_mistakes, acknowledge_mistake, set_detector.
  • log: mistake, decision. Records what the hooks did not catch.
  • mine: search, decisions, errors, struggles, replay, timeline, run_report, run_status, status. Reads the history of past sessions on the project.
  • deps: 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.

Checkpoints and compaction

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.

One compaction cycle: the brief, the recorded work, the model's save and compact_now at a step end, the compaction, the next brief

Context pressure

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.

Memory and rules

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.

Configuration

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.

KeyDefaultWhat it does
structurefalseSeed CLAUDE.md, .learnings/ and session-logs/ where missing
default_rulestrueSeed the default rule pack once per project
strict_packfalseSeed the strict pack beside the default one
compliancetrueMatch rules that carry a detector against tool calls
autonomyfalseStall nudges and the halt brake
alert_commandemptyShell command that receives one alert line
goal_turn_cap150Turns under one /goal before the halt is armed
stall_turns3Consecutive no-effect turns per strike
stall_decay5Consecutive good turns that remove one strike
strike_cap3Strikes before the halt (autonomy mode only)
output_reserve32000Tokens between the compaction point and where it fires
headsup_percentcomputedPercent of the compaction point for the heads-up: a tenth of the window under the point, about 87 on a 750K point
last_call_percentcomputedPercent 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_cadence60Turns with no checkpoint or finished step before the fallback reminder
budget_five_hour_pct90Usage percent of the 5-hour window that nudges
budget_seven_day_pct95Usage percent of the 7-day window that nudges
budget_pct90Usage percent of any other rate-limit window that nudges
live_mine300Seconds between live mining ticks at turn end, 0 disables
non_project_dirsemptyComma-separated directory names that are never a project
git_traceemptyFile 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.

Storage

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.
  • The daemon's port, process id and lock files sit in the root.

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.

What it runs, reads, writes and sends

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.

Headless and unattended

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.

Lineage

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.

Source 18 files
hooks/register.ts 890 lines
1// 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}
890
hooks/agents.ts 91 lines
1// 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}
91
hooks/band.tsx 110 lines
1// 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}
110
hooks/bridge.ts 490 lines
1// 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}
490
hooks/compact.ts 63 lines
1// 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}
63
hooks/door.ts 117 lines
1// 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}
117
hooks/engine.ts 161 lines
1// 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}
161
hooks/export.ts 59 lines
1// 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}
59
hooks/import.ts 47 lines
1// 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}
47
hooks/ledger.ts 150 lines
1// 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}
150
hooks/pane.tsx 222 lines
1// 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}
222
hooks/remember.ts 52 lines
1// 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