SLOPSHOPPER

firstmate-calm

Firstmate Calm for Claude Code: the sailboat working animation and conversation-only transcript presentation, sharing the per-home config/calm preference with…

newspinnerrowscommandtoasttimer
A shopper browsing a rack in a slop shop
README

<img alt="firstmate for Windows. Claude Code, Herdr, Git Bash and Windows 11, with three crewmates landing their changes on main" src="assets/banner.png" width="100%" />

<a href="https://github.com/EvanBatten/firstmate-for-windows/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/EvanBatten/firstmate-for-windows/actions/workflows/ci.yml/badge.svg" /></a> <img alt="Platform: Windows 11" src="https://img.shields.io/badge/platform-Windows%2011-0078d4" /> <a href="https://docs.anthropic.com/en/docs/claude-code"><img alt="Primary harness: Claude Code" src="https://img.shields.io/badge/primary-Claude%20Code-d97757" /></a> <a href="docs/herdr-backend.md"><img alt="Backend: Herdr" src="https://img.shields.io/badge/backend-Herdr-2d3748" /></a> <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue" /></a> <a href="https://github.com/EvanBatten/firstmate-for-windows/commits/main"><img alt="Last commit" src="https://img.shields.io/github/last-commit/EvanBatten/firstmate-for-windows" /></a>

<a href="#install">Install</a> · <a href="#talk-to-it">Talk to it</a> · <a href="#features">Features</a> · <a href="#how-it-works">How it works</a> · <a href="#built-for-windows">Built for Windows</a> · <a href="#proven-in-real-sessions">Proof</a> · <a href="#get-help">Help</a>

<img alt="One request to firstmate becomes three crewmates working in parallel, each change checked and landed on main" src="assets/demo.webp" width="100%" /> <sub>A real firstmate session with three crewmates, sped up.</sub>

What it is

firstmate runs a crew of coding agents for you on Windows 11. You talk to one Claude Code session, the first mate. It starts each task as a crewmate in its own Herdr tab and its own git worktree, watches the crew without spending tokens, and brings you back finished pull requests, approved local merges, and investigation reports.

There is no app to install. The cloned repo is the product: AGENTS.md is the first mate's contract, .agents/skills/ holds the procedures it loads, and bin/ holds the scripts it runs. platform/windows/ adapts those scripts to Git Bash, Windows paths, and a native Herdr.

Install

You need Windows 11 with Developer Mode on, and these tools on PATH: Git for Windows (for Git Bash), PowerShell 7, the GitHub CLI, Node.js, jq, Claude Code, Herdr, and Treehouse 2.0.1. Install on Windows has the winget and download command for each one.

Then clone with symlinks on:

gh auth login
git clone -c core.symlinks=true https://github.com/EvanBatten/firstmate-for-windows firstmate
cd firstmate

Then follow the launch steps once to load the overlay's claude function in Herdr's PowerShell. After that, every launch is herdr, then claude from the clone. On its first start, the first mate lists any tool it is still missing and asks before it installs one.

Talk to it

> ahoy! look at my github project xyz, then fix the flaky login test and add dark mode

# The first mate checks its tools, clones xyz under projects/,
# and starts two crewmates, each in its own Herdr tab and worktree.
# Minutes later:

  PR ready for review, captain: https://github.com/you/xyz/pull/42
  (fix flaky login test - risk: low - CI green)

> alright merge it

You never type into a crewmate's tab unless you want to. The first mate brings you only what needs your word: a review, a merge, a design choice, or a blocker.

Features

<img src="assets/icons/proven.svg" width="14" height="14" alt="" /> marks a feature that a recorded real session proved on Windows. <img src="assets/icons/unproven.svg" width="14" height="14" alt="" /> marks a feature the code has that no Windows session has proved yet, with the issue that tracks it.

  • <img src="assets/icons/proven.svg" width="14" height="14" alt="Proven" /> One liaison. You talk only to the first mate, which dispatches, supervises, and brings you only the decisions that are yours. Proven by the whole-session drive.
  • <img src="assets/icons/proven.svg" width="14" height="14" alt="Proven" /> A visible crew. Each crewmate works in its own Herdr tab, running Git Bash, that you can watch or type into. Proven by the ship-local drive.
  • <img src="assets/icons/proven.svg" width="14" height="14" alt="Proven" /> Disposable worktrees. Each task runs in a clean Treehouse git worktree, so parallel work on one repo never collides. Proven by the whole-session drive, three crewmates on one repo.
  • <img src="assets/icons/proven.svg" width="14" height="14" alt="Proven" /> Ship and scout tasks. A ship task delivers a change, and a scout task leaves a written report at data/<id>/report.md before its worktree goes. Proven by the scout-report drive.
  • <img src="assets/icons/unproven.svg" width="14" height="14" alt="Not yet proven" /> Project modes. Each project ships through no-mistakes, direct-PR, or local-only, and +yolo lets the first mate merge green work itself. The pr-land and ship-local drives prove direct-PR and local-only landing, and #84 tracks the rest.
  • <img src="assets/icons/unproven.svg" width="14" height="14" alt="Not yet proven" /> Second mates. Persistent second mates run from their own isolated firstmate homes. No Windows session has proved it yet, see #89.
  • <img src="assets/icons/proven.svg" width="14" height="14" alt="Proven" /> Tokenless supervision. A bash watcher sleeps on the crew and wakes the first mate only when something needs it, and Claude Code's Stop hook re-arms it at every turn end. Proven by the watcher-wake drive.
  • <img src="assets/icons/unproven.svg" width="14" height="14" alt="Not yet proven" /> Relay. Relay is an opt-in bridge that lets the first mate answer public mentions on X and Discord. The maintainer's Windows machine cannot verify it yet, see #97.
  • <img src="assets/icons/unproven.svg" width="14" height="14" alt="Not yet proven" /> A strict project boundary. The first mate reads your projects but never changes them outside its guarded paths, and crewmates make every change. #83 tracks the session-level proof.
  • <img src="assets/icons/proven.svg" width="14" height="14" alt="Proven" /> Restart-proof. All state lives on disk and in Herdr, so a new session takes the lock, finds the crew from its records, and carries on. Proven by the restart-primary drive.

How it works

You chat with the first mate in a Herdr pane. Every action it takes goes through a script in bin/, and every crewmate reports back through a status file the watcher reads.

flowchart TB
  C["You, the captain"] -- "chat" --> F["First mate<br/>Claude Code in a Herdr pane"]
  F -- "bin/fm-spawn.sh" --> T1["Crewmate 1<br/>Herdr tab, own worktree"]
  F -- "bin/fm-spawn.sh" --> T2["Crewmate 2<br/>Herdr tab, own worktree"]
  F -. "bin/fm-send.sh<br/>steering inbox" .-> T1
  T1 -- "state/#lt;id#gt;.status" --> W["bin/fm-watch.sh<br/>tokenless watcher"]
  T2 -- "state/#lt;id#gt;.status" --> W
  W -- "wakes on actionable status" --> F
  F -- "your go" --> L["Land<br/>fm-pr-merge.sh or fm-merge-local.sh"]
  L --> D["bin/fm-teardown.sh<br/>refuses unlanded work"]

A task moves through the same steps every time:

  1. bin/fm-session-start.sh takes the home's session lock, runs bootstrap, drains the wake queue, and prints one digest of fleet state.
  2. bin/fm-spawn.sh creates a Treehouse worktree, opens a Herdr tab running Git Bash in it, and starts the crewmate on a written brief.
  3. bin/fm-send.sh writes any steering message to the task's durable inbox and rings the crewmate's tab.
  4. bin/fm-watch.sh blocks until a crewmate's status needs attention, then wakes the first mate with one reason line.
  5. bin/fm-pr-merge.sh merges an approved PR and confirms GitHub reports it landed, and bin/fm-merge-local.sh fast-forwards main for a local-only task.
  6. bin/fm-teardown.sh returns the worktree and closes the tab, and it refuses while the task has uncommitted or unlanded work.

docs/architecture.md covers the supervision engine, worktree isolation, project modes, and self-update in full.

Built for Windows

The shared scripts in bin/ are written for a Unix shell. Seven files in bin/ load platform/windows/overrides.sh through one line each, and platform/windows/env.sh sets up every Git Bash the overlay reaches.

<table> <tr> <td width="33%" valign="top"> <img src="assets/icons/launch.svg" width="28" height="28" alt="" /><br /> <b>Launch through Git Bash</b><br /> <code>claude.ps1</code> wraps <code>claude</code> in your PowerShell profile, so the session starts from a Git Bash with <code>env.sh</code> sourced and can own the session lock. </td> <td width="33%" valign="top"> <img src="assets/icons/panes.svg" width="28" height="28" alt="" /><br /> <b>Git Bash in every Herdr pane</b><br /> <code>herdr.sh</code> lays out each new tab with Git Bash on <code>pane-rc.sh</code> instead of typing into PowerShell, and turns off MSYS path rewriting for every Herdr call. </td> <td width="33%" valign="top"> <img src="assets/icons/paths.svg" width="28" height="28" alt="" /><br /> <b>One spelling per path</b><br /> <code>path.sh</code> makes <code>pwd</code> answer in the same spelling <code>cygpath -u</code> gives, so <code>/tmp/x</code> and its <code>%TEMP%</code> twin compare equal. </td> </tr> <tr> <td width="33%" valign="top"> <img src="assets/icons/symlink.svg" width="28" height="28" alt="" /><br /> <b>Real symlinks</b><br /> <code>env.sh</code> sets <code>MSYS=winsymlinks:nativestrict</code>, and <code>symlinks.sh</code> repairs a clone made without <code>core.symlinks=true</code> on every launch. </td> <td width="33%" valign="top"> <img src="assets/icons/process.svg" width="28" height="28" alt="" /><br /> <b>Both process trees</b><br /> <code>proc.sh</code> walks the MSYS and Win32 process trees together, because a bash that a native program starts reports parent pid 1. </td> <td width="33%" valign="top"> <img src="assets/icons/lock.svg" width="28" height="28" alt="" /><br /> <b>Private by folder ACL</b><br /> Git Bash ignores <code>chmod</code>, so <code>env.sh</code> checks that the home's ACL names only you, SYSTEM, and Administrators, and <code>private-root.sh apply</code> fixes one that does not. </td> </tr> </table>

Starting claude from the clone runs this chain:

flowchart TB
  P["PowerShell in a Herdr pane<br/>claude function from claude.ps1"] --> B["Git Bash<br/>sources env.sh"]
  B --> E["claude.exe<br/>BASH_ENV=bash-env.sh"]
  E --> S["bin/ scripts<br/>load overrides.sh"]
  S --> H["herdr.sh<br/>talks to native Herdr"]

Proven in real sessions

Each trace below is a script of captain messages in plain English. node tools/fm-drive/drive.mjs run <trace.json> plays it against a real first mate in a throwaway home on Windows, and the run passes only when the home's own records show each claim within its time budget. All nine passed at commit 90fa7f8f, recorded in behaviors.tsv.

TraceWhat the captain asks for, and what must become true
registerAdd a project, and the session start runs once
ship-localOne change in a visible tab, checked, landed on main, cleaned up
steerA new requirement mid-task reaches the crewmate and lands in its change
watcher-wakeThe first mate ends its turn, and the crewmate's finish wakes it
pr-landA pull request opened, merged on your word, and confirmed landed
cleanup-refusalCleanup of unlanded work is refused, then the work lands
restart-primaryThe session exits mid-task, and a new one picks up the crew
scout-reportAn investigation leaves a report, then the same crewmate builds from it
whole-sessionThree changes in parallel, three landings on main, a healthy home

The verify-firstmate skill keeps the full inventory of claims and marks each one proven, unproven, or blocked on this machine.

Type these in the first mate's chat. They ship with the code, and their Windows proof is still open in the issue named beside each.

SkillWhat it does
/afkHands supervision to a background daemon while you step away, and briefs you when you return (#94)
/quietKeeps routine wakes out of the chat while you stay, until /quiet off (#94)
/ahoyRecaps what happened since your last message and walks you through open decisions one at a time (#95)
/bearingsPrints a four-section digest of the fleet, and /bearings file also writes it to data/ (#95)
/updatefirstmateFast-forwards this clone from origin and restarts every live mate on the new commit (#93)
/stowSaves what the session learned to disk and trims startup memory before a reset (#95)

.agents/skills/ also holds the agent-only skills the first mate loads at the triggers AGENTS.md names. skills/ holds stow, a public skill you can install into any project on its own.

The same checkout runs on Linux and macOS, where tmux is the default backend, as docs/tmux-backend.md describes. CI runs the behavior suites on Linux, the Herdr suite on Linux, and a Bash compatibility check on macOS. The code also supports Codex, Grok, Pi, OpenCode, Cursor Agent CLI, and Oh My Pi as the primary harness, and Zellij, Orca, and cmux as backends. No Windows session has verified those yet, see #97. docs/configuration.md lists what each one needs.

Get help

Open an issue at EvanBatten/firstmate-for-windows. Include the first mate's session-start digest and the output of herdr --version and bash --version.

Contributing

CONTRIBUTING.md has the workflow, the repo conventions, and the test commands.

Credits

firstmate for Windows is a Windows port of firstmate by Kun Chen. The design, the first mate's contract, and the shared scripts in bin/ come from his project.

License

MIT, see LICENSE.

Source 7 files
hooks/register.ts 505 lines
1// Firstmate Calm for Claude Code: the hooks module of the `firstmate-calm` mod.
2//
3// A Claude Code "mod" is a plugin whose behavior lives in one hooks module. Claude Code
4// may load this module through its rollout flag or `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS`,
5// but every handler requires that environment variable to equal `1`, so rollout-only
6// loading remains a complete no-op.
7// The plugin carries no command, skill, agent, or classic hook of its own; the `/calm`
8// command below exists only once this module has registered it. docs/calm.md owns the
9// captain-facing contract and docs/calm-mode-feasibility.md the version-scoped evidence.
10//
11// This file is the only place the engine interface `$` is touched: the geometry lives
12// in ../lib/fm-calm-working-ship-sprite.ts (shared with the Pi extension), the Raster
13// packing in ../lib/fm-calm-ship-raster.ts, and every visibility decision in
14// ../lib/fm-calm-presentation.ts, so the policy is testable under Node and the engine
15// glue under `claude plugin test`. Nothing here rewrites a message: `ui.render` changes
16// drawings and leaves the stored transcript, model context, and session storage alone.
17//
18// Presentation while Calm is on, sharing Pi Calm's goals where the mods API allows:
19// the stock working row (`Spinner`) becomes the two-row sailboat, repainted through
20// `$.ui.blit` on the sprite's own tick; `ToolUse`, `ToolResult`, and `ToolGroup` rows
21// draw as zero-height boxes; a `UserMessage` whose text the canonical operational-input
22// classifier recognizes, or a record-backed doorbell whose record holds a current
23// envelope (read through `$.fs.read`, cached until Calm next invalidates its drawings),
24// draws as zero height; an `AssistantMessage` block recorded as a mid-turn working note
25// draws as zero height. Calm off returns every drawing to the
26// engine. A toggle invalidates every hooked drawing, so rows already on screen redraw.
27// The boat is painted in Claude Code's own theme colors: the family is read from the
28// `theme` setting at load and re-read when a `config.set` changes it.
29//
30// Supervision notes, whether Calm is on or off, as Pi shows them regardless of Calm: a
31// slow timer follows the outcome store's display tail copy and the supervision host's
32// latch, and `$.ui.log` appends one dim line per new outcome or latch change, never
33// sent to the model. The first tail copy a session sees, at `session.start` or later,
34// replays the outcomes unread or unprocessed at `session.start` that this session has
35// not already shown. The mod only reads the Firstmate home: the drain remains the one
36// presenter that marks outcomes read.
37// ../lib/fm-branch-notes.ts owns every line and which rows are due.
38//
39// Loading is lazy and cached within a session: a resumed transcript or a hot reload can
40// draw restored rows before `session.start`, so every hook awaits that session's load of
41// the per-home preference and restored working notes rather than trusting a stale "off".
42// Each `session.start` clears presentation classifications and reloads the new session.
43import type { EngineInterface, Register, RenderElement, RenderInput } from "claude-code";
44import {
45  CALM_WORKING_SHIP_TICK_MS,
46  createCalmWorkingShipSprite,
47} from "../lib/fm-calm-working-ship-sprite.ts";
48import {
49  CALM_SHIP_RASTER_KEY,
50  CALM_SHIP_RASTER_PALETTES,
51  calmShipPaletteFamily,
52  calmShipRasterColumns,
53  packCalmShipRasterCells,
54  type CalmShipRasterPalette,
55} from "../lib/fm-calm-ship-raster.ts";
56import {
57  calmPreferencePath,
58  parseCalmPreference,
59  classifyRestoredTranscript,
60  recordIsOperational,
61  serializeCalmPreference,
62  stepTextIsWorkingNote,
63  userTextIsOperational,
64  userTextOperationalRecord,
65  workingNoteKey,
66} from "../lib/fm-calm-presentation.ts";
67import {
68  firstmateStateDirectory,
69  hostHealthNote,
70  newOutcomeNotes,
71  parseHostHealth,
72  parseOutcomeMarker,
73  parseOutcomeTail,
74  recordSessionShownThrough,
75  replayOutcomeNotes,
76  sessionShownThrough,
77  type HostHealth,
78} from "../lib/fm-branch-notes.ts";
79
80/** The slash command the mod serves, the same name as Pi's `/calm`. */
81const CALM_COMMAND = "calm";
82
83// One module environment holds one Calm state; a hot reload starts a fresh one, the
84// same as a new Pi extension lifetime.
85let calm = false;
86let preferencePath: string | undefined;
87let activation: Promise<boolean> | undefined;
88let loading: Promise<void> | undefined;
89let ticker: { cancel(): void } | undefined;
90const workingNotes = new Set<string>();
91const finalReplies = new Set<string>();
92// Each doorbell's record verdict, by record path. Records are immutable once published
93// but pruned after seven days, so every invalidation drops the cache and rechecks.
94const doorbellVerdicts = new Map<string, Promise<boolean>>();
95const sprite = createCalmWorkingShipSprite();
96let palette: CalmShipRasterPalette = CALM_SHIP_RASTER_PALETTES.light;
97// Every Spinner site currently drawing the boat, by its requestId, with the mounted
98// Raster size a blit must repeat exactly.
99const sites = new Map<string, { columns: number; rows: number }>();
100/** How often the supervision notes check the store's tail copy and the host's latch. */
101const BRANCH_NOTES_POLL_MS = 3000;
102/**
103 * A file changed this recently may be replaced again within its timestamp's resolution
104 * at the same size, so its size and time do not yet prove a later read unchanged.
105 */
106const SETTLED_MS = 5000;
107/**
108 * The mod's store key for the sequence each session has followed the store through:
109 * Claude Code 2.1.283 keeps `$.ui.log` lines in the session and restores them on
110 * `--continue`, so a resumed session replays only what it has not already shown.
111 */
112const BRANCH_NOTES_SHOWN_KEY = "supervision-notes-shown-through";
113// What the notes have shown in this session; each `session.start` replaces it.
114type NotesState = {
115  state: string;
116  tailStamp: string | undefined;
117  healthStamp: string | undefined;
118  lastSeen: number | undefined;
119  cursor: number;
120  processed: number;
121  shown: number;
122  health: HostHealth | undefined;
123  sessionId: string | undefined;
124  remembered: number | undefined;
125};
126let notes: NotesState | undefined;
127let notesTimer: { cancel(): void } | undefined;
128let notesPolling = false;
129
130function isActivated($: EngineInterface): Promise<boolean> {
131  if (activation === undefined) {
132    activation = $.env.get("CLAUDE_CODE_ENABLE_FUNCTION_HOOKS").then(
133      (value) => value === "1",
134      () => false,
135    );
136  }
137  return activation;
138}
139
140// A missing file is checked first because every rejected read or stat is an error in
141// Claude Code's debug log, and the supervision notes look for absent files every tick.
142async function readText($: EngineInterface, path: string): Promise<string | undefined> {
143  try {
144    if (!(await $.fs.exists(path))) return undefined;
145    return await $.fs.read(path);
146  } catch {
147    return undefined;
148  }
149}
150
151/** The `theme` setting's current value, or undefined when the menu cannot be read. */
152async function readTheme($: EngineInterface): Promise<unknown> {
153  try {
154    return (await $.config.list()).find((row) => row.key === "theme")?.value;
155  } catch {
156    return undefined;
157  }
158}
159
160async function load($: EngineInterface): Promise<void> {
161  preferencePath = calmPreferencePath(
162    {
163      FM_HOME: await $.env.get("FM_HOME"),
164      FM_ROOT_OVERRIDE: await $.env.get("FM_ROOT_OVERRIDE"),
165      FM_CONFIG_OVERRIDE: await $.env.get("FM_CONFIG_OVERRIDE"),
166    },
167    $.plugin.root,
168  );
169  calm = parseCalmPreference(await readText($, preferencePath));
170  palette = CALM_SHIP_RASTER_PALETTES[calmShipPaletteFamily(await readTheme($))];
171  try {
172    const restored = classifyRestoredTranscript(await $.session.messages());
173    for (const note of restored.workingNotes) workingNotes.add(note);
174    for (const reply of restored.finalReplies) finalReplies.add(reply);
175  } catch {
176    // A transcript that cannot be read leaves restored narration visible; nothing else changes.
177  }
178  if (ticker === undefined) {
179    ticker = $.clock.every(CALM_WORKING_SHIP_TICK_MS, () => {
180      void repaintShip($);
181    });
182  }
183  invalidateDrawings($);
184}
185
186function ensureLoaded($: EngineInterface): Promise<void> {
187  if (loading === undefined) loading = load($);
188  return loading;
189}
190
191async function resetSession($: EngineInterface): Promise<void> {
192  if (loading !== undefined) await loading.catch(() => undefined);
193  calm = false;
194  preferencePath = undefined;
195  loading = undefined;
196  workingNotes.clear();
197  finalReplies.clear();
198  doorbellVerdicts.clear();
199  sites.clear();
200  sprite.reset();
201  palette = CALM_SHIP_RASTER_PALETTES.light;
202  await ensureLoaded($);
203}
204
205/** Redraw every hooked drawing, rechecking each doorbell's record on its next drawing. */
206function invalidateDrawings($: EngineInterface): void {
207  doorbellVerdicts.clear();
208  $.ui.invalidate("ui.render");
209}
210
211/** One scheduler tick: advance the sprite, then repaint every mounted boat in place. */
212async function repaintShip($: EngineInterface): Promise<void> {
213  if (!calm || sites.size === 0) return;
214  sprite.tick();
215  for (const [requestId, site] of sites) {
216    const packed = packCalmShipRasterCells(sprite.frame(site.columns), site.columns, palette);
217    const result = await $.ui.blit({
218      requestId,
219      key: CALM_SHIP_RASTER_KEY,
220      cells: packed.cells,
221      columns: site.columns,
222      rows: site.rows,
223    });
224    // A denied blit means the site no longer shows this plugin's Raster (the turn
225    // settled, or a resize redrew it); forget it until the next Spinner drawing.
226    if (result.deny !== undefined && sites.get(requestId) === site) sites.delete(requestId);
227  }
228}
229
230/** Whether a user row is a record-backed doorbell whose record holds a current envelope. */
231function doorbellIsOperational($: EngineInterface, text: string): Promise<boolean> {
232  const record = userTextOperationalRecord(text);
233  if (record === undefined) return Promise.resolve(false);
234  let verdict = doorbellVerdicts.get(record);
235  if (verdict === undefined) {
236    verdict = readText($, record).then(recordIsOperational);
237    doorbellVerdicts.set(record, verdict);
238  }
239  return verdict;
240}
241
242/**
243 * A file's text with the size and time it was read at, or undefined when it is missing or
244 * unchanged. A file too recently changed has no stamp, so the next check reads it again.
245 */
246async function readIfChanged(
247  $: EngineInterface,
248  path: string,
249  stamp: string | undefined,
250): Promise<{ stamp: string | undefined; text: string } | undefined> {
251  let current: string;
252  let settled: boolean;
253  try {
254    if (!(await $.fs.exists(path))) return undefined;
255    const stat = await $.fs.stat(path);
256    current = `${stat.size}:${stat.mtimeMs}`;
257    settled = (await $.clock.now()) - stat.mtimeMs >= SETTLED_MS;
258  } catch {
259    return undefined;
260  }
261  if (current === stamp) return undefined;
262  const text = await readText($, path);
263  return text === undefined ? undefined : { stamp: settled ? current : undefined, text };
264}
265
266/** Replay the due outcomes, then follow the store from its current tail. */
267async function startNotes($: EngineInterface): Promise<void> {
268  const state = firstmateStateDirectory(
269    {
270      FM_HOME: await $.env.get("FM_HOME"),
271      FM_ROOT_OVERRIDE: await $.env.get("FM_ROOT_OVERRIDE"),
272      FM_STATE_OVERRIDE: await $.env.get("FM_STATE_OVERRIDE"),
273    },
274    $.plugin.root,
275  );
276  const sessionId = await $.session.id().catch(() => undefined);
277  const health = await readIfChanged($, `${state}/.supervision-host-health`, undefined);
278  const current: NotesState = {
279    state,
280    tailStamp: undefined,
281    healthStamp: health?.stamp,
282    lastSeen: undefined,
283    cursor: parseOutcomeMarker(await readText($, `${state}/.branch-outcomes-cursor`)),
284    processed: parseOutcomeMarker(await readText($, `${state}/.branch-outcomes-processed`)),
285    shown: sessionId === undefined ? 0 : sessionShownThrough(await readStored($), sessionId),
286    health: parseHostHealth(health?.text),
287    sessionId,
288    remembered: undefined,
289  };
290  await followTail($, current);
291  notes = current;
292  if (notesTimer === undefined) {
293    notesTimer = $.clock.every(BRANCH_NOTES_POLL_MS, () => {
294      void pollNotes($);
295    });
296  }
297}
298
299/**
300 * A line per outcome the tail copy gained. The first tail this session sees is the
301 * startup replay, whether it existed at session start or appeared later, judged against
302 * the read cursor and processed marker as they were at session start: a row read or
303 * processed before then is never shown, and one the drain read since still is.
304 */
305async function followTail($: EngineInterface, current: NotesState): Promise<void> {
306  const tail = await readIfChanged($, `${current.state}/.branch-outcomes-tail.jsonl`, current.tailStamp);
307  if (tail === undefined) return;
308  current.tailStamp = tail.stamp;
309  const rows = parseOutcomeTail(tail.text);
310  let lines: string[];
311  if (current.lastSeen === undefined) {
312    lines = replayOutcomeNotes(rows, current.cursor, current.processed, current.shown);
313    current.lastSeen = rows[rows.length - 1]?.seq;
314  } else {
315    const fresh = newOutcomeNotes(rows, current.lastSeen);
316    lines = fresh.lines;
317    current.lastSeen = fresh.lastSeen;
318  }
319  for (const line of lines) $.ui.log(line);
320  await rememberShown($, current);
321}
322
323async function readStored($: EngineInterface): Promise<unknown> {
324  try {
325    return await $.store.get(BRANCH_NOTES_SHOWN_KEY);
326  } catch {
327    return undefined;
328  }
329}
330
331/** Record how far this session has followed the store, when that moved. */
332async function rememberShown($: EngineInterface, current: NotesState): Promise<void> {
333  if (current.sessionId === undefined || current.lastSeen === undefined || current.lastSeen === current.remembered) return;
334  try {
335    await $.store.set(
336      BRANCH_NOTES_SHOWN_KEY,
337      recordSessionShownThrough(await readStored($), current.sessionId, current.lastSeen),
338    );
339    current.remembered = current.lastSeen;
340  } catch {
341    // An unwritable store only means a later resume may replay a line again.
342  }
343}
344
345/** One slow tick: a line per outcome appended since the last, and a latch change's note. */
346async function pollNotes($: EngineInterface): Promise<void> {
347  const current = notes;
348  if (current === undefined || notesPolling) return;
349  notesPolling = true;
350  try {
351    await followTail($, current);
352    const health = await readIfChanged($, `${current.state}/.supervision-host-health`, current.healthStamp);
353    if (health !== undefined) {
354      current.healthStamp = health.stamp;
355      const next = parseHostHealth(health.text);
356      const note = hostHealthNote(current.health, next);
357      if (next !== undefined) current.health = next;
358      if (note !== undefined) $.ui.log(note);
359    }
360  } finally {
361    notesPolling = false;
362  }
363}
364
365/** A zero-height drawing: the row contributes nothing to the transcript's layout. */
366function hiddenRow($: EngineInterface, e: RenderInput): RenderElement {
367  const { Box } = $.ui.resolve(e);
368  return Box({ display: "none" });
369}
370
371export const register: Register = (on) => {
372  on("session.start", async ($, e, next) => {
373    if (!(await isActivated($))) return next(e);
374    await resetSession($);
375    // Notes that cannot start leave Calm and the transcript exactly as they were.
376    await startNotes($).catch(() => undefined);
377    await $.command.register({
378      name: CALM_COMMAND,
379      description: "Toggle Firstmate's Calm transcript presentation and working ship.",
380    });
381    return next(e);
382  });
383
384  on("command.run", { command: CALM_COMMAND }, async ($, e, next) => {
385    if (!(await isActivated($))) return next(e);
386    await ensureLoaded($);
387    const active = !calm;
388    // Persist before changing live presentation, so a failed write leaves the current
389    // choice unchanged rather than claiming persistence.
390    try {
391      await $.fs.write(preferencePath ?? "", serializeCalmPreference(active));
392    } catch (error) {
393      const reason = error instanceof Error ? error.message : String(error);
394      $.ui.toast(`Calm unchanged: could not save ${preferencePath ?? "the preference"} (${reason})`);
395      return {};
396    }
397    calm = active;
398    if (!calm) sites.clear();
399    invalidateDrawings($);
400    $.ui.toast(active ? "Calm on" : "Calm off");
401    // No `text`: the toggle leaves no output row in the transcript, as on Pi.
402    return {};
403  });
404
405  // Follow a theme change: the next drawing and every later blit use the new family.
406  on("config.set", { key: "theme" }, async ($, e, next) => {
407    if (!(await isActivated($))) return next(e);
408    const result = await next(e);
409    if (result.deny === undefined) {
410      const chosen = CALM_SHIP_RASTER_PALETTES[calmShipPaletteFamily(result.value)];
411      if (chosen !== palette) {
412        palette = chosen;
413        if (calm) invalidateDrawings($);
414      }
415    }
416    return result;
417  });
418
419  // Record mid-turn narration as it streams: the text blocks of a model step that
420  // stopped to call tools. Subagent steps never draw in the main transcript.
421  on("turn.step", async function* ($, e, next) {
422    if (!(await isActivated($))) {
423      const untouched = next(e);
424      for await (const chunk of untouched) yield chunk;
425      return await untouched.result;
426    }
427    const stream = next(e);
428    const blocks = new Map<number, string>();
429    for await (const chunk of stream) {
430      if (chunk.kind === "text") blocks.set(chunk.index, (blocks.get(chunk.index) ?? "") + chunk.text);
431      yield chunk;
432    }
433    const result = await stream.result;
434    if (e.agentId === undefined) {
435      let changed = false;
436      for (const text of [...blocks.values(), result.answer]) {
437        const key = workingNoteKey(text);
438        if (key === "") continue;
439        if (stepTextIsWorkingNote(result, text)) {
440          if (finalReplies.has(key) || workingNotes.has(key)) continue;
441          workingNotes.add(key);
442          changed = true;
443        } else {
444          if (!finalReplies.has(key)) {
445            finalReplies.add(key);
446            changed = true;
447          }
448          if (workingNotes.delete(key)) changed = true;
449        }
450      }
451      if (changed && calm) invalidateDrawings($);
452    }
453    return result;
454  });
455
456  on("ui.render", { component: "Spinner" }, async ($, e, next) => {
457    if (!(await isActivated($))) return next(e);
458    await ensureLoaded($);
459    if (!calm || e.surface !== "terminal") {
460      sites.delete(e.requestId);
461      return next(e);
462    }
463    const columns = calmShipRasterColumns(e.viewport?.columns);
464    const packed = packCalmShipRasterCells(sprite.frame(columns), columns, palette);
465    sites.set(e.requestId, { columns, rows: packed.rows });
466    const { Box, Raster } = $.ui.resolve(e);
467    return Box({
468      flexDirection: "column",
469      children: Raster({ key: CALM_SHIP_RASTER_KEY, columns, rows: packed.rows, cells: packed.cells }),
470    });
471  });
472
473  on("ui.render", { component: "ToolUse" }, async ($, e, next) => {
474    if (!(await isActivated($))) return next(e);
475    await ensureLoaded($);
476    return calm ? hiddenRow($, e) : next(e);
477  });
478  on("ui.render", { component: "ToolResult" }, async ($, e, next) => {
479    if (!(await isActivated($))) return next(e);
480    await ensureLoaded($);
481    return calm ? hiddenRow($, e) : next(e);
482  });
483  on("ui.render", { component: "ToolGroup" }, async ($, e, next) => {
484    if (!(await isActivated($))) return next(e);
485    await ensureLoaded($);
486    return calm ? hiddenRow($, e) : next(e);
487  });
488
489  on("ui.render", { component: "UserMessage" }, async ($, e, next) => {
490    if (!(await isActivated($))) return next(e);
491    await ensureLoaded($);
492    if (!calm) return next(e);
493    const operational =
494      userTextIsOperational(e.props.text) || (await doorbellIsOperational($, e.props.text));
495    return operational ? hiddenRow($, e) : next(e);
496  });
497
498  on("ui.render", { component: "AssistantMessage" }, async ($, e, next) => {
499    if (!(await isActivated($))) return next(e);
500    await ensureLoaded($);
501    const key = workingNoteKey(e.props.text);
502    return calm && workingNotes.has(key) && !finalReplies.has(key) ? hiddenRow($, e) : next(e);
503  });
504};
505
lib/fm-calm-working-ship-sprite.ts 313 lines
1// Firstmate's harness-neutral Calm working-ship sprite.
2//
3// This module owns the sprite geometry, the bounce track, the two linked animation
4// cadences, and the freeze/resume state that every Calm working presentation shares.
5// It paints each frame as rows of color-tagged runs and never as bytes, so each harness
6// renders the same picture its own way: `.pi/extensions/lib/fm-calm-working-ship.ts`
7// paints the runs as standard ANSI escapes for Pi's widget, and `./fm-calm-ship-raster.ts`
8// packs them as Claude Code Raster cells. docs/calm.md owns the captain-facing contract
9// and docs/calm-mode-feasibility.md the geometry rationale.
10//
11// It lives inside the Claude Code plugin folder because Claude Code 2.1.272 refuses a
12// hooks-module import from outside that folder, symlinks included; the Pi extension
13// reaches it through the tracked `.pi/extensions/lib/fm-calm-working-ship-sprite.ts`
14// symlink. Nothing here imports a harness: every glyph is one terminal column under
15// both harnesses' width rules, so widths are plain character counts.
16//
17// Cadence: one scheduler drives two linked cadences. Every tick advances the wave by
18// one quarter-cell, and every CALM_WORKING_SHIP_TICKS_PER_MOVE-th tick moves the boat
19// one whole cell, so the trough stays phase-locked to a deliberately calm boat.
20// Ticks, not wall-clock timestamps, drive every state change, so tests can seek time exactly.
21//
22// Continuity: one caller-owned sprite instance survives hide/show within one harness
23// process and extension lifetime. restoreLastRendered() freezes column, direction, water
24// phase, and tick cadence at the last painted frame without advancing them for hidden
25// wall time, and the next working period resumes from that exact logical state. A fresh
26// session or new extension lifetime calls reset() and starts at the normal initial
27// position. State is never a module-level or process-global singleton.
28
29// The asymmetric three-cell sail is centered over a five-cell hull. The one-cell
30// quarter triangle keeps the left sail lighter than the full right sail, and the whole
31// boat (both sail halves, mast, and hull) is one color so the sprite reads as one shape.
32// The hull's inner cells retain zero-height water glyphs instead of interrupting the trough.
33const LEFT_SAIL = "◿";
34const MAST = "│";
35const RIGHT_SAIL = "◣";
36const HULL_LEFT = "╲";
37const HULL_WATER = "▁▁▁";
38const HULL_RIGHT = "╱";
39const SAIL_OFFSET = 1;
40
41/** The complete sail as drawn, left to right. */
42export const CALM_WORKING_SHIP_SAIL = `${LEFT_SAIL}${MAST}${RIGHT_SAIL}`;
43/** The complete hull as drawn, left to right. */
44export const CALM_WORKING_SHIP_HULL = `${HULL_LEFT}${HULL_WATER}${HULL_RIGHT}`;
45
46/** Terminal columns a string of one-column glyphs occupies. */
47function cellCount(text: string): number {
48  return Array.from(text).length;
49}
50
51const HULL_WIDTH = cellCount(CALM_WORKING_SHIP_HULL);
52const SAIL_WIDTH = cellCount(CALM_WORKING_SHIP_SAIL);
53
54// Pi Dictation uses these bottom-aligned one-cell bars for truthful level history.
55// Calm deliberately keeps only its lower half: a long, low ocean swell rather than an
56// audio-sized waveform. Every glyph is one terminal column under both harnesses.
57export const CALM_WORKING_SHIP_WAVE_BARS = ["▁", "▂", "▃", "▄"] as const;
58const WAVE_MAX_LEVEL = CALM_WORKING_SHIP_WAVE_BARS.length - 1;
59const WAVE_HALF_LENGTH_MIN = 9;
60const WAVE_HALF_LENGTH_SPAN = 5;
61const WAVE_TROUGH_RADIUS = 5;
62
63/** Scheduler period. One tick advances the water by one phase. */
64export const CALM_WORKING_SHIP_TICK_MS = 220;
65/** Boat moves one column every Nth tick, so it travels at 220 * 4 = 880ms per column. */
66export const CALM_WORKING_SHIP_TICKS_PER_MOVE = 4;
67
68/**
69 * The color classes a frame uses. `plain` is uncolored padding; `water` is every water
70 * cell whatever its height, so the swell reads through glyph height alone; `boat` is
71 * the whole boat, both sail halves, the mast, and the complete hull including its
72 * zero-height interior. Each harness maps a class to its own color: Pi paints them as
73 * standard ANSI blue and yellow, the Claude Code mod as Claude Code's theme colors.
74 */
75export type CalmWorkingShipColor = "plain" | "water" | "boat";
76
77/** One same-colored run of cells inside a frame row. */
78export type CalmWorkingShipRun = {
79  readonly text: string;
80  readonly color: CalmWorkingShipColor;
81};
82
83/** One painted frame: one or two rows of runs, each row exactly the requested width. */
84export type CalmWorkingShipFrame = readonly (readonly CalmWorkingShipRun[])[];
85
86export type CalmWorkingShipSprite = {
87  /** Paint one frame that exactly fits `width`, clamping the track to it first. */
88  frame(width: number): CalmWorkingShipFrame;
89  /** Advance one scheduler tick: water every tick, boat on its slower cadence. */
90  tick(): void;
91  /** Return to the state of the last painted frame, discarding later ticks. */
92  restoreLastRendered(): void;
93  /** Restore the normal initial column, direction, water phase, and cadence. */
94  reset(): void;
95  /**
96   * Clamp the frozen column and direction to `width` without advancing time.
97   * Used when a terminal resize lands while the working presentation is hidden.
98   */
99  clampToWidth(width: number): void;
100  /** Current hull column, exposed for deterministic motion assertions. */
101  position(): number;
102  /** Current travel direction: 1 travelling right, -1 travelling left. */
103  direction(): number;
104  /** Current quarter-cell wave phase, exposed for deterministic swell assertions. */
105  waterPhase(): number;
106};
107
108/** Longest hull start column that still fits the sprite in `width` usable cells. */
109function trackSpan(width: number): number {
110  if (width >= HULL_WIDTH) return width - HULL_WIDTH;
111  if (width >= SAIL_WIDTH) return width - SAIL_WIDTH;
112  return 0;
113}
114
115/** Stable bounded variation for successive half-waves on either side of the trough. */
116function halfWaveLength(index: number, negative: boolean): number {
117  let value =
118    ((negative ? 0xc411 : 0x5ea1) + Math.imul(index + 1, 0x9e3779b1)) >>> 0;
119  value ^= value >>> 16;
120  value = Math.imul(value, 0x7feb352d) >>> 0;
121  value ^= value >>> 15;
122  value >>>= 0;
123  return WAVE_HALF_LENGTH_MIN + (value % WAVE_HALF_LENGTH_SPAN);
124}
125
126function smoothstep(value: number): number {
127  const bounded = Math.max(0, Math.min(1, value));
128  return bounded * bounded * (3 - 2 * bounded);
129}
130
131/** Smooth amplitude at one fractional cell in the deterministic variable wave field. */
132function waveAmplitude(coordinate: number): number {
133  const negative = coordinate < 0;
134  let distance = Math.abs(coordinate);
135  let rising = true;
136  for (let index = 0; ; index += 1) {
137    const length = halfWaveLength(index, negative);
138    if (distance <= length) {
139      const eased = smoothstep(distance / length);
140      return (rising ? eased : 1 - eased) * WAVE_MAX_LEVEL;
141    }
142    distance -= length;
143    rising = !rising;
144  }
145}
146
147/**
148 * One bottom-aligned bar level at an absolute column.
149 *
150 * The wave advances one quarter-cell on every water tick and exactly one cell on the
151 * boat's slower movement tick. Anchoring that displacement to the hull center keeps
152 * the boat inside the same broad trough without per-frame randomness or jitter.
153 */
154function waveLevel(
155  column: number,
156  hullCenter: number,
157  direction: number,
158  phase: number,
159): number {
160  const displacement =
161    hullCenter + (direction * phase) / CALM_WORKING_SHIP_TICKS_PER_MOVE;
162  const coordinate = column - displacement;
163  if (Math.abs(coordinate) <= WAVE_TROUGH_RADIUS) return 0;
164  const beyondTrough = coordinate - Math.sign(coordinate) * WAVE_TROUGH_RADIUS;
165  return Math.max(
166    0,
167    Math.min(WAVE_MAX_LEVEL, Math.round(waveAmplitude(beyondTrough))),
168  );
169}
170
171export function createCalmWorkingShipSprite(): CalmWorkingShipSprite {
172  let position = 0;
173  let direction = 1;
174  let span = 0;
175  let phase = 0;
176  let ticks = 0;
177  let renderedPosition = position;
178  let renderedDirection = direction;
179  let renderedSpan = span;
180  let renderedPhase = phase;
181  let renderedTicks = ticks;
182
183  // Reversing the moment the boat lands on an endpoint means the endpoint frame already
184  // carries the new wave direction, so the trough follows the next boat movement.
185  const settleDirectionAtEdges = (): void => {
186    if (span <= 0) return;
187    if (position >= span) direction = -1;
188    else if (position <= 0) direction = 1;
189  };
190
191  const applyWidth = (width: number): void => {
192    if (width <= 0) {
193      span = 0;
194      position = 0;
195      return;
196    }
197    span = trackSpan(width);
198    position = Math.min(position, span);
199    settleDirectionAtEdges();
200  };
201
202  const commitRenderedState = (): void => {
203    renderedPosition = position;
204    renderedDirection = direction;
205    renderedSpan = span;
206    renderedPhase = phase;
207    renderedTicks = ticks;
208  };
209
210  const restoreLastRenderedState = (): void => {
211    position = renderedPosition;
212    direction = renderedDirection;
213    span = renderedSpan;
214    phase = renderedPhase;
215    ticks = renderedTicks;
216  };
217
218  /** One water-colored run per cell of low water covering absolute columns [from, from + count). */
219  const water = (
220    from: number,
221    count: number,
222    hullCenter: number,
223  ): CalmWorkingShipRun[] => {
224    const runs: CalmWorkingShipRun[] = [];
225    for (let column = from; column < from + count; column += 1) {
226      const level = waveLevel(column, hullCenter, direction, phase);
227      runs.push({
228        text: CALM_WORKING_SHIP_WAVE_BARS[level] ?? CALM_WORKING_SHIP_WAVE_BARS[0],
229        color: "water",
230      });
231    }
232    return runs;
233  };
234
235  // The boat is one boat-colored run per row, so its halves never split into mismatched colors.
236  const sail = (): CalmWorkingShipRun[] => [{ text: CALM_WORKING_SHIP_SAIL, color: "boat" }];
237  const hull = (): CalmWorkingShipRun[] => [{ text: CALM_WORKING_SHIP_HULL, color: "boat" }];
238
239  return {
240    position: () => position,
241    direction: () => direction,
242    waterPhase: () => phase,
243
244    restoreLastRendered: restoreLastRenderedState,
245
246    reset(): void {
247      position = 0;
248      direction = 1;
249      span = 0;
250      phase = 0;
251      ticks = 0;
252      commitRenderedState();
253    },
254
255    clampToWidth(width: number): void {
256      applyWidth(width);
257    },
258
259    tick(): void {
260      ticks += 1;
261      phase = (phase + 1) % CALM_WORKING_SHIP_TICKS_PER_MOVE;
262      if (ticks % CALM_WORKING_SHIP_TICKS_PER_MOVE !== 0) return;
263      if (span <= 0) {
264        position = 0;
265        return;
266      }
267      position = Math.min(span, Math.max(0, position + direction));
268      settleDirectionAtEdges();
269    },
270
271    frame(width: number): CalmWorkingShipFrame {
272      if (width <= 0) return [];
273
274      // A resize lands here before the next frame, so recompute and clamp the track
275      // immediately rather than trusting a position measured against the old width.
276      applyWidth(width);
277
278      const hullCenter =
279        position +
280        (width >= HULL_WIDTH
281          ? Math.floor(HULL_WIDTH / 2)
282          : Math.floor(SAIL_WIDTH / 2));
283
284      let frame: CalmWorkingShipFrame;
285      if (width < SAIL_WIDTH) {
286        // Too narrow for even the sail: a deterministic single row of low water.
287        frame = [water(0, width, hullCenter)];
288      } else if (width < HULL_WIDTH) {
289        // Too narrow for the hull: the sail alone rides inside the water row.
290        frame = [
291          [
292            ...water(0, position, hullCenter),
293            ...sail(),
294            ...water(position + SAIL_WIDTH, width - position - SAIL_WIDTH, hullCenter),
295          ],
296        ];
297      } else {
298        frame = [
299          [{ text: " ".repeat(position + SAIL_OFFSET), color: "plain" }, ...sail()],
300          [
301            ...water(0, position, hullCenter),
302            ...hull(),
303            ...water(position + HULL_WIDTH, width - position - HULL_WIDTH, hullCenter),
304          ],
305        ];
306      }
307
308      commitRenderedState();
309      return frame;
310    },
311  };
312}
313
lib/fm-calm-ship-raster.ts 139 lines
1// Packs one Calm working-ship frame as Claude Code Raster cells.
2//
3// The Claude Code mods API draws a grid of colored cells as one `Raster` element whose
4// `cells` prop is base64 of `columns * rows` little-endian u32 triplets
5// `[codePoint, foreground, background]`; `$.ui.blit` repaints a mounted Raster with a
6// new `cells` string without a render pass. This module owns that packing and the
7// sprite's palette on that surface; ../hooks/register.ts owns when it is drawn.
8//
9// Raster colors are RGB, and the terminal paints them through a quantized 256-color
10// palette rather than the standard 16-color ANSI codes Pi's widget emits, which
11// docs/calm-mode-feasibility.md records as a bounded gap. The palette is Claude Code's
12// own: the water takes the theme's spinner blue and the whole boat takes the Claude
13// orange of the stock spinner, one set per theme family. The family follows the
14// `theme` setting's prefix (`dark*` or `light*`); `auto`, custom, missing, and
15// unreadable values use the light set as the both-readable fallback. The Pi extension
16// keeps its standard ANSI colors and is unaffected.
17import type {
18  CalmWorkingShipColor,
19  CalmWorkingShipFrame,
20} from "./fm-calm-working-ship-sprite.ts";
21
22/** The Raster's `key` inside the Spinner drawing, what `$.ui.blit` names to repaint it. */
23export const CALM_SHIP_RASTER_KEY = "firstmate-calm-working-ship";
24
25/** Claude Code's Raster width limit, per RasterProps. */
26export const CALM_SHIP_RASTER_MAX_COLUMNS = 512;
27
28/** The transcript's side margin the stock working row also sits inside. */
29export const CALM_SHIP_RASTER_MARGIN = 2;
30
31/** The viewport width assumed before the surface has measured. */
32export const CALM_SHIP_RASTER_DEFAULT_VIEWPORT_COLUMNS = 80;
33
34/** `0x01000000` (bit 24 alone) asks for the terminal's default color. */
35export const CALM_SHIP_RASTER_DEFAULT_COLOR = 0x01000000;
36
37/** Foreground per sprite color class, as `0x00RRGGBB`, or the terminal default. */
38export type CalmShipRasterPalette = Readonly<Record<CalmWorkingShipColor, number>>;
39
40/** The two theme families Claude Code's built-in themes fall into. */
41export type CalmShipPaletteFamily = "dark" | "light";
42
43/**
44 * Claude Code's own colors per theme family: the dark and light spinner blues for the
45 * water and the Claude orange of the stock spinner for the boat, from the app's
46 * built-in theme tables.
47 */
48export const CALM_SHIP_RASTER_PALETTES: Readonly<Record<CalmShipPaletteFamily, CalmShipRasterPalette>> = {
49  dark: { plain: CALM_SHIP_RASTER_DEFAULT_COLOR, water: 0x93a5ff, boat: 0xd77757 },
50  light: { plain: CALM_SHIP_RASTER_DEFAULT_COLOR, water: 0x5769f7, boat: 0xd77757 },
51};
52
53/**
54 * The palette family for a `theme` setting value: values starting with `dark` select
55 * the dark set, values starting with `light` select the light set, and every other,
56 * missing, or non-string value selects the both-readable light fallback.
57 */
58export function calmShipPaletteFamily(theme: unknown): CalmShipPaletteFamily {
59  return typeof theme === "string" && theme.startsWith("dark") ? "dark" : "light";
60}
61
62/** How many Raster columns a Spinner site of `viewportColumns` gets: the row minus its margin, within the Raster's limits. */
63export function calmShipRasterColumns(viewportColumns: number | undefined): number {
64  const measured = viewportColumns ?? CALM_SHIP_RASTER_DEFAULT_VIEWPORT_COLUMNS;
65  return Math.max(1, Math.min(CALM_SHIP_RASTER_MAX_COLUMNS, measured - CALM_SHIP_RASTER_MARGIN));
66}
67
68const BASE64_ALPHABET =
69  "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
70
71/** Standard padded base64, written here because the hooks environment and Node differ on native helpers. */
72export function encodeBase64(bytes: Uint8Array): string {
73  let out = "";
74  let index = 0;
75  for (; index + 2 < bytes.length; index += 3) {
76    const word = ((bytes[index] ?? 0) << 16) | ((bytes[index + 1] ?? 0) << 8) | (bytes[index + 2] ?? 0);
77    out +=
78      BASE64_ALPHABET[(word >> 18) & 63]! +
79      BASE64_ALPHABET[(word >> 12) & 63]! +
80      BASE64_ALPHABET[(word >> 6) & 63]! +
81      BASE64_ALPHABET[word & 63]!;
82  }
83  const rest = bytes.length - index;
84  if (rest === 1) {
85    const word = (bytes[index] ?? 0) << 16;
86    out += BASE64_ALPHABET[(word >> 18) & 63]! + BASE64_ALPHABET[(word >> 12) & 63]! + "==";
87  } else if (rest === 2) {
88    const word = ((bytes[index] ?? 0) << 16) | ((bytes[index + 1] ?? 0) << 8);
89    out +=
90      BASE64_ALPHABET[(word >> 18) & 63]! +
91      BASE64_ALPHABET[(word >> 12) & 63]! +
92      BASE64_ALPHABET[(word >> 6) & 63]! +
93      "=";
94  }
95  return out;
96}
97
98export type CalmShipRasterCells = {
99  /** How many rows the packed grid has: the frame's, one or two. */
100  rows: number;
101  /** The packed `cells` string for a Raster of `columns` by `rows`. */
102  cells: string;
103};
104
105/**
106 * Pack a frame painted for exactly `columns` cells. Every row is padded with plain
107 * spaces to the full width, so the sail row's short run still fills its Raster row,
108 * and a row wider than the grid is clipped rather than wrapped.
109 */
110export function packCalmShipRasterCells(
111  frame: CalmWorkingShipFrame,
112  columns: number,
113  palette: CalmShipRasterPalette = CALM_SHIP_RASTER_PALETTES.light,
114): CalmShipRasterCells {
115  const rows = Math.max(1, frame.length);
116  const words = new Uint32Array(columns * rows * 3);
117  const put = (row: number, column: number, codePoint: number, foreground: number): void => {
118    if (column < 0 || column >= columns) return;
119    const offset = (row * columns + column) * 3;
120    words[offset] = codePoint;
121    words[offset + 1] = foreground;
122    words[offset + 2] = CALM_SHIP_RASTER_DEFAULT_COLOR;
123  };
124  for (let row = 0; row < rows; row += 1) {
125    for (let column = 0; column < columns; column += 1) {
126      put(row, column, 0x20, CALM_SHIP_RASTER_DEFAULT_COLOR);
127    }
128    let column = 0;
129    for (const run of frame[row] ?? []) {
130      const foreground = palette[run.color];
131      for (const glyph of Array.from(run.text)) {
132        put(row, column, glyph.codePointAt(0) ?? 0x20, foreground);
133        column += 1;
134      }
135    }
136  }
137  return { rows, cells: encodeBase64(new Uint8Array(words.buffer)) };
138}
139
lib/fm-calm-presentation.ts 158 lines
1// Firstmate Calm presentation policy for the Claude Code mod, kept free of the engine.
2//
3// This module owns the decisions ../hooks/register.ts applies through `$`: where the
4// shared per-home Calm preference lives and how its value reads, which assistant text is
5// a mid-turn working note, and which transcript rows Calm hides. It shares Pi Calm's
6// broad presentation boundary: genuine user prompts, genuine agent responses, and
7// working activity stay visible; tool rows, tool groups, classified working notes, and
8// canonically classified operational user rows hide, including a record-backed doorbell
9// once the caller has read the record it names. docs/calm.md owns the exact
10// captain-facing contract and docs/configuration.md
11// the persisted preference schema. Everything here is pure so tests run it under Node.
12import {
13  classifyFirstmateOperationalText,
14  firstmateOperationalDoorbellPath,
15  firstmateOperationalRecordKind,
16} from "./fm-operational-input.ts";
17import {
18  CALM_PRESERVE_MIN_CHARS,
19  calmTextIsSubstantive,
20} from "./fm-calm-preservation.ts";
21
22export { CALM_PRESERVE_MIN_CHARS } from "./fm-calm-preservation.ts";
23
24/** The environment variables that select the effective Firstmate home, as the mod reads them. */
25export type CalmHomeEnvironment = {
26  readonly FM_HOME?: string | undefined;
27  readonly FM_ROOT_OVERRIDE?: string | undefined;
28  readonly FM_CONFIG_OVERRIDE?: string | undefined;
29};
30
31/** The parent of a path, with either separator; a bare name resolves to itself. */
32function parentDirectory(path: string): string {
33  const trimmed = path.replace(/[\\/]+$/, "");
34  const cut = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
35  return cut > 0 ? trimmed.slice(0, cut) : trimmed;
36}
37
38/**
39 * The tracked Firstmate code root the mod belongs to: three levels above the plugin
40 * folder, whether Claude Code names it through `.claude/skills/<name>`,
41 * `.agents/skills/<name>`, or its physical `.claude/mods/<name>` home, which all sit
42 * at that same depth.
43 */
44export function calmCodeRootFromPluginRoot(pluginRoot: string): string {
45  return parentDirectory(parentDirectory(parentDirectory(pluginRoot)));
46}
47
48/**
49 * The per-home `config/calm` path, resolved exactly as the Pi extension resolves it:
50 * `FM_HOME`, then `FM_ROOT_OVERRIDE`, then the tracked code root, with
51 * `FM_CONFIG_OVERRIDE` naming the config directory outright when present.
52 */
53export function calmPreferencePath(env: CalmHomeEnvironment, pluginRoot: string): string {
54  const configDirectory =
55    env.FM_CONFIG_OVERRIDE ||
56    `${env.FM_HOME || env.FM_ROOT_OVERRIDE || calmCodeRootFromPluginRoot(pluginRoot)}/config`;
57  return `${configDirectory}/calm`;
58}
59
60/**
61 * Whether a stored preference reads as Calm on. `max` is the legacy value of a removed
62 * third level whose behavior is now ordinary Calm; absent or unrecognized reads as off.
63 */
64export function parseCalmPreference(stored: string | undefined): boolean {
65  if (stored === undefined) return false;
66  const value = stored.trim();
67  return value === "on" || value === "max";
68}
69
70/** The exact file content the Pi extension writes for the same choice. */
71export function serializeCalmPreference(active: boolean): string {
72  return active ? "on\n" : "off\n";
73}
74
75/** The shape of one `turn.step` result this policy reads. */
76export type CalmStepOutcome = {
77  readonly stopReason: string | null;
78  readonly toolUses: readonly unknown[];
79};
80
81
82/**
83 * Whether text from a model step is a mid-turn working note: the model did not end
84 * its response there, because it stopped to call tools, or ran out of tokens while
85 * calling them. Short single-line narration stays a note; substantive text is a final
86 * reply even when the step also called tools.
87 */
88export function stepTextIsWorkingNote(step: CalmStepOutcome, text: string): boolean {
89  const midTurn = step.stopReason === "tool_use" || (step.stopReason === "max_tokens" && step.toolUses.length > 0);
90  return midTurn && !calmTextIsSubstantive(text);
91}
92
93/** A trimmed text key that retains whether the raw row contained a newline. */
94export function workingNoteKey(text: string): string {
95  const trimmedText = text.trim();
96  if (trimmedText === "") return "";
97  return text.includes("\n") ? `${trimmedText}\n` : trimmedText;
98}
99
100/** The shape of one `$.session.messages()` row this policy reads. */
101export type CalmSessionRow = {
102  readonly role: "user" | "assistant";
103  readonly text: string;
104  readonly toolUses: readonly unknown[];
105};
106
107/**
108 * The structurally identified working notes and final replies in a restored transcript.
109 * The stored transcript keeps each content block as its own row, so assistant text is a
110 * working note when its own row called tools, or when a tool-calling assistant row
111 * follows it before the next user row. Substantive text in either position is preserved
112 * as a final reply, matching the live classifier.
113 */
114export function classifyRestoredTranscript(rows: readonly CalmSessionRow[]): {
115  workingNotes: string[];
116  finalReplies: string[];
117} {
118  const notes = new Set<string>();
119  const finalReplies = new Set<string>();
120  for (let index = 0; index < rows.length; index += 1) {
121    const row = rows[index]!;
122    if (row.role !== "assistant") continue;
123    const key = workingNoteKey(row.text);
124    if (key === "") continue;
125    let followedByToolCall = row.toolUses.length > 0;
126    for (let later = index + 1; later < rows.length && rows[later]!.role === "assistant"; later += 1) {
127      if (rows[later]!.toolUses.length > 0) {
128        followedByToolCall = true;
129        break;
130      }
131    }
132    if (followedByToolCall && calmTextIsSubstantive(row.text)) finalReplies.add(key);
133    else if (followedByToolCall) notes.add(key);
134    else finalReplies.add(key);
135  }
136  for (const key of finalReplies) notes.delete(key);
137  return { workingNotes: [...notes], finalReplies: [...finalReplies] };
138}
139
140/** Whether a user row's text is a canonically classified Firstmate operational input. */
141export function userTextIsOperational(text: string): boolean {
142  return classifyFirstmateOperationalText(text) !== undefined;
143}
144
145/**
146 * The record a user row names when its text is a record-backed operational doorbell,
147 * the carrier for harnesses that strip U+2063 from submitted prompts. The doorbell text
148 * alone proves nothing; `recordIsOperational` decides from the record's content.
149 */
150export function userTextOperationalRecord(text: string): string | undefined {
151  return firstmateOperationalDoorbellPath(text);
152}
153
154/** Whether a doorbell's record, as read (undefined when unreadable), holds a current envelope. */
155export function recordIsOperational(content: string | undefined): boolean {
156  return content !== undefined && firstmateOperationalRecordKind(content) !== undefined;
157}
158
lib/fm-branch-notes.ts 167 lines
1// Firstmate supervision notes for the Claude Code mod, kept free of the engine.
2//
3// Pi renders each supervision outcome in the transcript: a sailboat note for a visible
4// routine outcome and a sequence-keyed anchor entry for a captain outcome
5// (.pi/extensions/fm-branch-supervision.ts). This module owns the same lines for
6// Claude Code, read from the display tail copy of the one outcome store
7// (bin/fm-branch-outcome.sh owns every file format read here) and from the supervision
8// host's latch (bin/fm-supervision-host.sh). It only renders: nothing here marks an
9// outcome read or processed. Everything is pure so tests run it under Node.
10import { calmCodeRootFromPluginRoot } from "./fm-calm-presentation.ts";
11
12export const BRANCH_NOTE_BOAT = "⛵";
13export const BRANCH_NOTE_ANCHOR = "⚓";
14/** At most this many lines replay at session start, newest kept. */
15export const BRANCH_NOTES_REPLAY_LIMIT = 20;
16/** How many sessions' last shown sequence the mod's store keeps, newest kept. */
17export const BRANCH_NOTES_SESSIONS_KEPT = 20;
18
19export type FirstmateStateEnvironment = {
20  readonly FM_HOME?: string | undefined;
21  readonly FM_ROOT_OVERRIDE?: string | undefined;
22  readonly FM_STATE_OVERRIDE?: string | undefined;
23};
24
25export type OutcomeRow = {
26  readonly seq: number;
27  readonly epoch: number;
28  readonly task: string;
29  readonly verdict: "routine" | "captain";
30  readonly summary: string;
31  readonly silent: boolean;
32};
33
34/** The home's state directory, resolved as the Pi extension resolves it. */
35export function firstmateStateDirectory(env: FirstmateStateEnvironment, pluginRoot: string): string {
36  return env.FM_STATE_OVERRIDE || `${env.FM_HOME || env.FM_ROOT_OVERRIDE || calmCodeRootFromPluginRoot(pluginRoot)}/state`;
37}
38
39function parseOutcomeRow(value: unknown): OutcomeRow | undefined {
40  if (value === null || typeof value !== "object") return undefined;
41  const row = value as Record<string, unknown>;
42  if (typeof row.seq !== "number" || !Number.isSafeInteger(row.seq) || row.seq < 1) return undefined;
43  if (typeof row.epoch !== "number" || !Number.isSafeInteger(row.epoch) || row.epoch < 0) return undefined;
44  if (typeof row.task !== "string" || row.task === "") return undefined;
45  if (row.verdict !== "routine" && row.verdict !== "captain") return undefined;
46  if (typeof row.summary !== "string" || row.summary === "") return undefined;
47  if (row.silent !== undefined && typeof row.silent !== "boolean") return undefined;
48  const silent = row.silent === true;
49  if (silent && row.verdict !== "routine") return undefined;
50  return { seq: row.seq, epoch: row.epoch, task: row.task, verdict: row.verdict, summary: row.summary, silent };
51}
52
53/** The valid rows of the tail copy in ascending sequence; a line that breaks the contract is skipped. */
54export function parseOutcomeTail(text: string | undefined): OutcomeRow[] {
55  const rows: OutcomeRow[] = [];
56  for (const line of (text ?? "").split("\n")) {
57    if (line.trim() === "") continue;
58    let row: OutcomeRow | undefined;
59    try {
60      row = parseOutcomeRow(JSON.parse(line));
61    } catch {
62      row = undefined;
63    }
64    if (row !== undefined && (rows.length === 0 || row.seq > rows[rows.length - 1]!.seq)) rows.push(row);
65  }
66  return rows;
67}
68
69/** A sidecar marker's sequence: absent or unreadable reads as 0, as the store owner reads it. */
70export function parseOutcomeMarker(text: string | undefined): number {
71  const value = (text ?? "").trim();
72  return /^(0|[1-9][0-9]*)$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : 0;
73}
74
75/** Pi's transcript line for one row, on one line; a silent row has none. */
76export function outcomeNoteLine(row: OutcomeRow): string | undefined {
77  if (row.silent) return undefined;
78  const summary = row.summary.replace(/\s*\n\s*/g, " ");
79  return row.verdict === "captain"
80    ? `${BRANCH_NOTE_ANCHOR} [seq ${row.seq}] ${row.task}: ${summary}`
81    : `${BRANCH_NOTE_BOAT} ${row.task}: ${summary}`;
82}
83
84/**
85 * The session-start replay, as Pi's startup replay presents the store: every captain row
86 * main has not acknowledged as processed and every unread visible routine row, bounded
87 * to the newest few with one line counting any that were left out. Rows through
88 * `shownThrough` are already in this session's restored transcript and are skipped,
89 * unless the tail ends below it (a replaced store).
90 */
91export function replayOutcomeNotes(
92  rows: readonly OutcomeRow[],
93  cursor: number,
94  processed: number,
95  shownThrough = 0,
96): string[] {
97  const shown = shownThrough > (rows[rows.length - 1]?.seq ?? 0) ? 0 : shownThrough;
98  const due = rows.filter(
99    (row) => row.seq > shown && (row.verdict === "captain" ? row.seq > processed : row.seq > cursor),
100  );
101  const lines = due.map(outcomeNoteLine).filter((line): line is string => line !== undefined);
102  if (lines.length <= BRANCH_NOTES_REPLAY_LIMIT) return lines;
103  const omitted = lines.length - BRANCH_NOTES_REPLAY_LIMIT;
104  return [
105    `${BRANCH_NOTE_BOAT} ${omitted} earlier supervision ${omitted === 1 ? "note" : "notes"} not replayed; bin/fm-branch-outcome.sh list shows them`,
106    ...lines.slice(-BRANCH_NOTES_REPLAY_LIMIT),
107  ];
108}
109
110/**
111 * The lines for rows appended since `lastSeen`, and the new last seen sequence. Rows that
112 * arrived faster than the tail copy holds are counted in one line rather than dropped
113 * silently. A tail that ends below the anchor is a replaced store: re-anchor there
114 * without replaying it.
115 */
116export function newOutcomeNotes(rows: readonly OutcomeRow[], lastSeen: number): { lines: string[]; lastSeen: number } {
117  const last = rows.length === 0 ? lastSeen : rows[rows.length - 1]!.seq;
118  if (last < lastSeen) return { lines: [], lastSeen: last };
119  const fresh = rows.filter((row) => row.seq > lastSeen);
120  const lines = fresh.map(outcomeNoteLine).filter((line): line is string => line !== undefined);
121  const missed = (fresh[0]?.seq ?? lastSeen + 1) - lastSeen - 1;
122  if (missed > 0) {
123    lines.unshift(
124      `${BRANCH_NOTE_BOAT} ${missed} earlier supervision ${missed === 1 ? "outcome" : "outcomes"} not shown; bin/fm-branch-outcome.sh list shows them`,
125    );
126  }
127  return { lines, lastSeen: last };
128}
129
130/** The last sequence a session has followed the store through, from the mod's store value; 0 when unknown. */
131export function sessionShownThrough(stored: unknown, sessionId: string): number {
132  if (!Array.isArray(stored)) return 0;
133  const entry = stored.find((item) => Array.isArray(item) && item[0] === sessionId);
134  return entry !== undefined && Number.isSafeInteger(entry[1]) && entry[1] > 0 ? entry[1] : 0;
135}
136
137/** The store value with this session's last followed sequence recorded as its newest entry. */
138export function recordSessionShownThrough(stored: unknown, sessionId: string, seq: number): [string, number][] {
139  const others = (Array.isArray(stored) ? stored : []).filter(
140    (item): item is [string, number] =>
141      Array.isArray(item) && typeof item[0] === "string" && item[0] !== sessionId && Number.isSafeInteger(item[1]),
142  );
143  return [...others, [sessionId, seq] as [string, number]].slice(-BRANCH_NOTES_SESSIONS_KEPT);
144}
145
146export type HostHealth = { readonly key: string; readonly cooling: boolean };
147
148/** The supervision host's latch, or undefined when the file is absent or has no key. */
149export function parseHostHealth(text: string | undefined): HostHealth | undefined {
150  const field = (name: string) => new RegExp(`^${name}=(.*)$`, "m").exec(text ?? "")?.[1];
151  const key = field("key");
152  if (key === undefined || key === "") return undefined;
153  const cooldown = field("cooldown") ?? "";
154  return { key, cooling: /^[0-9]+$/.test(cooldown) && Number(cooldown) > 0 };
155}
156
157/** The note a latch change owes, as Pi's two health notes: a trip, or a recovery under the same key. */
158export function hostHealthNote(previous: HostHealth | undefined, next: HostHealth | undefined): string | undefined {
159  if (next === undefined) return undefined;
160  const wasCooling = previous !== undefined && previous.key === next.key && previous.cooling;
161  if (next.cooling && !wasCooling) {
162    return `${BRANCH_NOTE_BOAT} Supervision session paused after repeated engine errors; main will handle wakes while it cools down.`;
163  }
164  if (!next.cooling && wasCooling) return `${BRANCH_NOTE_BOAT} Supervision session recovered after a successful cooldown probe.`;
165  return undefined;
166}
167
lib/fm-operational-input.ts 131 lines
1// A faithful port of bin/fm-operational-input.sh's `classify` command.
2//
3// bin/fm-operational-input.sh is the single owner of the Firstmate operational-input
4// protocol; this module mirrors only its classification so the Claude Code mod can
5// recognize operational user rows inside a render hook, where no host process may be
6// spawned per row. tests/fm-calm-claude-mod.test.sh deterministically runs both over
7// the full envelope and near-miss contract and is this port's drift guard, so a change
8// to the canonical shell owner must land here in the same change. Never widen this
9// beyond what the owner recognizes.
10//
11// Current generic wire form:
12//   U+2063 FIRSTMATE_OP: v1 <kind>: <body>
13// plus the established `[fm-from-firstmate]` U+2063 routing carrier, and the narrow
14// pre-protocol shapes the owner keeps only for persisted transcripts.
15//
16// It also mirrors the owner's record-backed doorbell parse and record classification
17// (`fm_operational_doorbell_path`, `fm_operational_record_kind`), which the `doorbell-kind`
18// command composes: a harness that strips U+2063 from submitted prompts receives a plain
19// ASCII doorbell naming a record that holds the envelope. The file read stays with the
20// caller, so this module remains pure.
21
22const OPERATIONAL_MARK = "\u2063";
23const OPERATIONAL_PREFIX = `${OPERATIONAL_MARK}FIRSTMATE_OP: `;
24const OPERATIONAL_VERSION = "v1";
25const OPERATIONAL_HEADER_PREFIX = `${OPERATIONAL_PREFIX}${OPERATIONAL_VERSION} `;
26
27/** The kinds the owner's `FM_OPERATIONAL_KINDS` names, in its order. */
28export const FIRSTMATE_OPERATIONAL_GENERIC_KINDS = [
29  "session-start",
30  "watcher",
31  "turn-end-guard",
32  "away-supervisor",
33  "launch-brief",
34  "branch-outcome",
35] as const;
36
37const FROMFIRST_LABEL = "[fm-from-firstmate]";
38const FROMFIRST_MARK = `${FROMFIRST_LABEL}${OPERATIONAL_MARK}`;
39
40// Historical payload literals, isolated exactly as the owner isolates them: they exist
41// only for persisted pre-protocol transcripts.
42const LEGACY_SESSIONSTART =
43  "Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions.";
44const LEGACY_WATCHER_PREFIX = "FIRSTMATE WATCHER WAKE: ";
45const LEGACY_WATCHER_SUFFIX =
46  "\n\nRun bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.";
47const LEGACY_TURNEND_PREFIX =
48  "TURN WOULD END BLIND - supervision is off. The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n";
49const LEGACY_AWAY_PREFIX = `${OPERATIONAL_MARK}Supervisor escalate (`;
50
51function isCurrentKind(kind: string): boolean {
52  return (FIRSTMATE_OPERATIONAL_GENERIC_KINDS as readonly string[]).includes(kind);
53}
54
55/** `fm_operational_generic_kind`: the kind of a current generic envelope, else undefined. */
56function genericKind(message: string): string | undefined {
57  if (!message.startsWith(OPERATIONAL_HEADER_PREFIX)) return undefined;
58  const remainder = message.slice(OPERATIONAL_HEADER_PREFIX.length);
59  const separator = remainder.indexOf(": ");
60  if (separator < 0) return undefined;
61  const kind = remainder.slice(0, separator);
62  if (!isCurrentKind(kind)) return undefined;
63  const body = remainder.slice(separator + 2);
64  return body === "" ? undefined : kind;
65}
66
67/** `fm_operational_input_kind`: a current input's kind, generic or from-firstmate. */
68export function firstmateOperationalInputKind(message: string): string | undefined {
69  const generic = genericKind(message);
70  if (generic !== undefined) return generic;
71  if (message.startsWith(FROMFIRST_MARK) && message.length > FROMFIRST_MARK.length) {
72    return "from-firstmate";
73  }
74  return undefined;
75}
76
77/** `fm_legacy_operational_input_kind`: the narrow pre-protocol shapes, in the owner's order. */
78export function firstmateLegacyOperationalInputKind(message: string): string | undefined {
79  // PR 899 landed an untyped FIRSTMATE_OP prefix whose subtype cannot be recovered
80  // without body prose, so it is explicitly generic.
81  if (message.startsWith(OPERATIONAL_PREFIX) && message.length > OPERATIONAL_PREFIX.length) {
82    return "legacy-operational";
83  }
84  if (message === LEGACY_SESSIONSTART) return "session-start";
85  if (message.startsWith(LEGACY_AWAY_PREFIX)) return "away-supervisor";
86  if (
87    message.startsWith(LEGACY_WATCHER_PREFIX) &&
88    message.endsWith(LEGACY_WATCHER_SUFFIX) &&
89    message.length > LEGACY_WATCHER_PREFIX.length + LEGACY_WATCHER_SUFFIX.length
90  ) {
91    return "watcher";
92  }
93  if (message.startsWith(LEGACY_TURNEND_PREFIX) && message.length > LEGACY_TURNEND_PREFIX.length) {
94    return "turn-end-guard";
95  }
96  return undefined;
97}
98
99/** `fm_operational_input_classify`: current kinds first, then the legacy shapes. */
100export function classifyFirstmateOperationalText(message: string): string | undefined {
101  return firstmateOperationalInputKind(message) ?? firstmateLegacyOperationalInputKind(message);
102}
103
104const RECORD_DIRNAME = "operational-inbox";
105const DOORBELL_PREFIX = ": Firstmate operational input waiting: read '";
106const DOORBELL_SUFFIX = "' and handle its contents as Firstmate operational input.";
107
108/** `fm_operational_doorbell_path`: the record path a well-formed doorbell names. */
109export function firstmateOperationalDoorbellPath(message: string): string | undefined {
110  if (
111    message.length < DOORBELL_PREFIX.length + DOORBELL_SUFFIX.length ||
112    !message.startsWith(DOORBELL_PREFIX) ||
113    !message.endsWith(DOORBELL_SUFFIX)
114  ) {
115    return undefined;
116  }
117  const path = message.slice(DOORBELL_PREFIX.length, message.length - DOORBELL_SUFFIX.length);
118  if (!path.startsWith("/") || path.includes("'") || !/^[\x20-\x7e]*$/.test(path)) return undefined;
119  const cut = path.lastIndexOf("/");
120  const directory = path.slice(0, cut);
121  if (directory.slice(directory.lastIndexOf("/") + 1) !== RECORD_DIRNAME) return undefined;
122  const name = path.slice(cut + 1);
123  if (!name.endsWith(".msg") || !/^[0-9a-z-]+$/.test(name.slice(0, -".msg".length))) return undefined;
124  return path;
125}
126
127/** `fm_operational_record_kind` over a record's content: its current generic kind. */
128export function firstmateOperationalRecordKind(content: string): string | undefined {
129  return genericKind(content);
130}
131
lib/fm-calm-preservation.ts 12 lines
1// Shared Calm policy for deciding whether mid-turn assistant text is substantive.
2// Claude Code imports this file directly, while the Pi extension reaches the same
3// implementation through its tracked symlink so both harnesses keep one threshold and rule.
4
5/** The minimum trimmed text length preserved from a mid-turn assistant message. */
6export const CALM_PRESERVE_MIN_CHARS = 240;
7
8/** Whether mid-turn assistant text is substantive enough to remain visible. */
9export function calmTextIsSubstantive(text: string): boolean {
10  return text.includes("\n") || text.trim().length >= CALM_PRESERVE_MIN_CHARS;
11}
12