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

<h1 align="center">XO</h1> <a href="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-blue?style=flat-square"
<img
alt="Platform" src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-blue?style=flat-square" /></a> <a href="https://x.com/kunchenguid"
<img
alt="X" src="https://img.shields.io/badge/X-@kunchenguid-black?style=flat-square" /></a> <a href="https://discord.gg/Wsy2NpnZDu"
<img
alt="Discord" src="https://img.shields.io/discord/1439901831038763092?style=flat-square&label=discord" /></a>
<h3 align="center">Talk to one agent. Ship with a crew.</h3>
<img alt="XO command hub coordinating a crew across isolated worktrees" src="assets/xo-banner.png" width="100%" />
This is lmuehleisen/xo, a project derived from kunchenguid/firstmate. It prioritizes reliable local use, familiar tools, and less prescriptive workflows. Use this repository when cloning for these changes; the upstream project overview and setup below are otherwise retained.
/report and /ops are preferred, while /ahoy and /bearings retain the same behavior and modes.gh-axi: GitHub operations use the standard gh CLI, including compatible merge handling, to avoid an extra wrapper.chrome-devtools-axi: browser work uses harness browser tools or Playwright, leaving the browser tool choice flexible.lavish-axi: off by default, so decisions and reports use chat and /ops lavish produces a read-only local HTML snapshot; a home can turn on Lavish viewing, or answering decision cards on the board, with a pinned, hook-free install that serves on loopback by default and can be viewed from your other devices over Tailscale or ssh (optional Lavish).no-mistakes: workers run relevant tests and lint directly, then deliver through direct-PR or local-only; legacy no-mistakes delivery tokens map to direct-PR unless a home opts in through config/no-mistakes, so local work needs no pipeline.local-only ship tasks can start from local main or master without a remote, while rejecting dirty or divergent task bases.Maintainers: the fork divergences ledger records every deliberate difference from upstream, with its seam and guard test.
tasks-axi and quota-axi remain required, along with the selected backend's dependencies. Merge approval and unlanded-work safeguards still apply; inherited upstream contribution and CI requirements are explained in CONTRIBUTING.md.
Upstream changes are integrated on disposable review branches using real merges that preserve upstream ancestry, then fast-forwarded into the installation after review; the upstream integration checklist owns each run's steps. The updater fetches this fork's origin and requires fast-forward ancestry; it does not merge upstream itself.
The Captain sets intent. XO has the conn: coordinating the crew, supervising work in flight, and bringing command only the outcomes, risks, and decisions that require attention. "FirstMate", "Firstmate", and "first mate" remain supported aliases; technical firstmate names and fm-* commands stay unchanged.
You can run one coding agent easily. But the moment you want three project tasks done in parallel - fixes, investigations, plans, audits - you become a tab-juggler: babysitting sessions, copy-pasting context between repos, forgetting which terminal had the failing test.
firstmate flips the model. You talk to a single agent - XO (Executive Officer), historically known as FirstMate - and it runs the crew for you: spawning autonomous agents in a visible session backend, giving each a clean git worktree, supervising them to completion, and handing you finished PRs, approved local merges, or standalone investigation reports. For larger fleets, you can opt in to persistent secondmates: second mates that are still ordinary direct reports, but run from their own isolated firstmate homes on this machine or another SSH-reachable host.
firstmate is not a model, not a harness, not a skill, not an MCP server, and not a CLI. firstmate is an agent distro for running a crew of agents. An agent distro is a portable directory of instructions, skills, tooling, policies, and state conventions that turns a general-purpose agent into a specialized one. There is no app to install: the cloned repo is the distro - AGENTS.md, bundled firstmate skills, and helper scripts that any terminal coding agent can follow. Launching a supported harness inside it for your primary session instantiates your XO - and makes you the captain.
backend=orca, so parallel work on one repo never collides.direct-PR or local-only, with an optional +yolo merge-autonomy flag, an optional branch=<prefix> override for the default fm/ ship-branch prefix, and an optional forge=gerrit binding under which the worker publishes a Gerrit change instead of opening a pull request; legacy no-mistakes registry tokens map to direct-PR.FM_HOME, state, projects, and session lock, either locally or as a whole home on an SSH-reachable host, with guarded updates and recovery that never turns an unavailable remote route into a local replacement..env pairing token so firstmate can answer your public mentions on X and Discord alike, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome without changing non-Relay behavior; a final reply promised in a thread becomes durable state that is reconciled from disk, so a restart or a compacted conversation cannot lose it; dry-run preview records would-be replies and dismissals locally before go-live.Full detail on every feature lives in docs/architecture.md.
pi-signed, Oh My Pi (omp), Codex, OpenCode V1 (legacy primary only), Cursor Agent CLI, or Antigravity CLI (agy).gh auth login.The first mate detects and offers to install supported missing tools after you approve. Backend-specific setup is linked in Documentation.
Claude Code, Grok, and Pi are equal co-primary recommendations for running the primary firstmate session, with pi-signed supported as Pi's distinct signed-wrapper identity. Claude Code uses a tracked Stop hook for tokenless watcher re-arm and rewake, Grok uses background-notify wake cycles, and Pi uses its tracked primary watcher extension. All three have verified turn-end guard paths when launched with their documented setup. Pick whichever one matches your subscription and workflow.
Oh My Pi (omp), a Pi fork, is verified as a primary with the same extension-owned watcher model as Pi and a stronger turn-end guard: its blocking session_stop hook compels a continuation instead of requesting one. Codex and legacy OpenCode V1 are also verified and supported as primary harnesses; Codex uses bounded foreground checkpoints, and OpenCode V1 uses a TUI plugin, so both carry more harness-specific supervision tradeoffs than the three co-primaries. Agy supports primary, local secondmate, and worker sessions through native hooks and background-command completion; see Agy setup and limits for its launch and approval requirements. Cursor Agent CLI is verified as a primary too, using a tracked project-scope .cursor/hooks.json whose stop hook parks on the watcher between turns, closest in shape to Claude Code's. Launch it with --trust, or none of its project hooks load; it also has no turn-end hook in headless cursor-agent -p, so run the primary session interactively.
gh auth login
git clone https://github.com/lmuehleisen/xo
cd xo
Then launch one of the co-primary harnesses; AGENTS.md takes over from there:
Claude Code
claude
Grok
grok --trust
Pi
pi
# or, when the signed wrapper is installed
FM_PI_HARNESS=pi-signed pi-signed
Oh My Pi
omp
# or, when starting from inside a Claude Code pane
FM_OMP_HARNESS=omp omp
Start omp with this checkout as its working directory: it auto-discovers the tracked .omp/extensions/*.ts files with no trust dialog, and naming them with -e as well would load each twice.
For Grok, --trust is needed once per clone so project hooks and the turn-end guard load; /hooks-trust inside Grok works too. For Pi, approve the project trust prompt once per clone on first launch so the tracked .pi/extensions/*.ts files auto-load. The /calm toggle on Pi, and on Claude Code behind its default-off early-access function-hooks flag, hides supported transcript chrome, including canonically classified Firstmate operational user rows, and uses a Calm-only animated working boat during active runs while preserving all model context and session data. Calm changes only presentation, not the user-role delivery, ordering, authority, persistence, or exports of the operational inputs it hides. The preference persists for the effective Firstmate home, and toggling it off restores ordinary rendering. Calm's current behavior and supported limits are separate from its version-scoped maintainer evidence. Pi's /supervision-model command pins a cheaper model and a shallower reasoning effort for the supervision branch alone, from the eligible models and thinking levels Pi itself reports, and with no pin the branch normally follows your own conversation's model and effort; see the configuration schema.
> look at my github project xyz, then fix the flaky login test and add dark mode
# firstmate checks its toolchain (asking your consent before installing anything),
# clones the project under projects/ and spawns two isolated workers in the active backend.
# 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
Setup guides for tmux (the default) and every other supported backend (herdr, zellij, Orca, cmux) are linked in Documentation below.
you (the captain)
│ chat: requests, decisions, "merge it"
▼
┌─────────────────────────────────────┐
│ XO (this repo) │
│ reads projects/ + XO routes │
│ writes guarded backlog/briefs/state │
└──┬──────────────┬───────────────┬───┘
│ backend sends / status files │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│fm-task1│ │fm-task2│ ... │fm-taskN│ tmux windows, herdr/zellij tabs, cmux workspaces, or Orca terminals
│crewmate│ │crewmate│ │crewmate│ one autonomous agent each
└───┬────┘ └───┬────┘ └───┬────┘
▼ ▼ ▼
treehouse worktree, Orca worktree, or isolated secondmate home
│
├─ ship: project mode ► PR/local merge ► teardown
│
└─ scout: report at data/<id>/report.md ► decision inventory ► relay findings ► teardown
You chat with XO. It routes each request to a crewmate in its own session endpoint and git worktree, supervises the fleet with a zero-token event-driven watcher, and brings you finished PRs, approved local merges, or investigation reports. Optional secondmates extend this to persistent local or whole-home remote second mates, dispatch profiles let you steer which harness handles which task, and opt-in Relay lets the same fleet answer public mentions. codex-app is not a runtime backend yet; docs/codex-app-backend.md owns the Codex App boundary.
Full architecture - the supervision engine, worktree isolation, secondmates, dispatch profiles, project modes, optional Relay, fleet sync, and self-update - is in docs/architecture.md.
Firstmate ships these user-invocable built-in skills. Claude and grok use the slash form shown here; codex uses the same names with $, such as $afk.
| Skill | What it does |
|---|---|
/afk | Enter away-mode supervision: Pi's in-process branch, a supervision host beside the other primaries (on by default for Claude), or the daemon handles wakes while you step away; see the away procedure for the posture and return contract |
/quiet | Keep routine wakes off main while staying and chatting; requested actions proceed now rather than waiting for your return. Where Pi's branch or an attended supervision host already does this, it only says so; otherwise it starts the quiet daemon, which stays active through ordinary chat until /quiet off |
/report | Recap visible session events since the prior real captain message plus visibly unanswered captain decisions, then guide the captain through any open decisions one at a time in agent-judged impact order; fall back to Ops when invoked as the session's first real captain message |
/ops | Generate a concise four-section chat digest from bounded fleet state, including registered remote-home ledgers and measured follow-up for owned contributions; use /ops file to also replace today's dated report in data/, and add include PRs for live GitHub enrichment |
/updatefirstmate | Guardedly update the running firstmate and its secondmates - fast-forward, or reconcile a redundant post-squash-merge divergence - then persist and restart every live mate successfully left on the target commit - including already-current homes - with an honest re-read nudge only when restart cannot be proven |
/stow | Sweep the session for uncaptured durable knowledge, persist the open work records this session knows are unfiled or now wrong, curate tiered startup memory with decay and cold archival, enforce each home's budget or surface the required decision, cascade to registered second mates, and report what is safe to reset |
/ahoy remains an alias for /report; /bearings remains an alias for /ops, including every file and Lavish mode. Use your harness's skill invocation syntax (for example $report and $ops in Codex).
Ops invocation examples:
/ops returns the fresh four-section digest in chat only.include PRs remains the opt-in for repository-wide live PR enrichment./ops include PRs keeps chat-only mode and opts into live PR enrichment./ops file replaces today's data/status-report-<YYYY-MM-DD>.md from scratch and links it from the four-section chat digest./ops lavish adds the fleet board; off, view, and answers keep their existing per-request behavior./ops file include PRs combines the dated report with live PR enrichment.Agent-only reference skills live under .agents/skills/ and are loaded by firstmate at the trigger points named in AGENTS.md.
Firstmate's skills live in two separate places with different audiences:
.agents/skills/ - agent-loaded skills (this section's table, plus firstmate's agent-only reference skills). Every one of these assumes a live firstmate home and is meaningless, or actively misleading, installed anywhere else, so each carries metadata.internal: true in its frontmatter. That flag hides them from installer discovery (tools like the skills.sh npx skills add installer) without affecting how firstmate itself loads them - frontmatter metadata is inert to the agent's own skill loader.skills/ - public, installer-facing skills meant to be installed standalone into any project, independent of firstmate. Each one is a self-contained skill with no dependency on firstmate's paths, tools, or vocabulary. Today that is skills/stow, a generic session-knowledge-sweep skill that routes findings by explicit instruction first, then existing local conventions, then a private .stow-notes.md fallback, and curates tiered entries through decay, local archival, and user-approved on-demand offload proposals. It intentionally shares no code with the firstmate-internal .agents/skills/stow it is named after, so the two can evolve independently.FM_HOME, runtime backend selection, optional Relay and its X and Discord setup steps, trusted external process-event adapter setup, the files you set, and harness support.process-event-adapter/1 package, binding, handshake, and evidence boundary./calm behavior on Pi and Claude Code and its supported presentation limits.pi-signed, omp, Grok, Cursor, Agy, and unknown harness fallback.bin/ toolbelt reference.AGENTS.md - the supervisor contract, role boundary, and routing index for conditional procedures.hooks/register.ts 505 lines1// Firstmate Calm for Claude Code: the hooks module of the Calm mod, whose plugin name is `fm`.
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};
505lib/fm-calm-working-ship-sprite.ts 313 lines1// 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}
313lib/fm-calm-ship-raster.ts 139 lines1// 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}
139lib/fm-calm-presentation.ts 158 lines1// 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}
158lib/fm-branch-notes.ts 167 lines1// 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}
167lib/fm-operational-input.ts 143 lines1// 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// also accepted without the leading U+2063 at byte 0, as the owner accepts it,
14// plus the established `[fm-from-firstmate]` U+2063 routing carrier, and the narrow
15// pre-protocol shapes the owner keeps only for persisted transcripts.
16//
17// It also mirrors the owner's record-backed doorbell parse and record classification
18// (`fm_operational_doorbell_path`, `fm_operational_record_kind`), which the `doorbell-kind`
19// command composes: a harness that strips U+2063 from submitted prompts receives a plain
20// ASCII doorbell naming a record that holds the envelope. The file read stays with the
21// caller, so this module remains pure.
22
23const OPERATIONAL_MARK = "\u2063";
24const OPERATIONAL_PREFIX = `${OPERATIONAL_MARK}FIRSTMATE_OP: `;
25const OPERATIONAL_VERSION = "v1";
26const OPERATIONAL_HEADER_PREFIX = `${OPERATIONAL_PREFIX}${OPERATIONAL_VERSION} `;
27const OPERATIONAL_UNMARKED_HEADER_PREFIX = `FIRSTMATE_OP: ${OPERATIONAL_VERSION} `;
28
29/** The kinds the owner's `FM_OPERATIONAL_KINDS` names, in its order. */
30export const FIRSTMATE_OPERATIONAL_GENERIC_KINDS = [
31 "session-start",
32 "watcher",
33 "turn-end-guard",
34 "away-supervisor",
35 "launch-brief",
36 "branch-outcome",
37] as const;
38
39const FROMFIRST_LABEL = "[fm-from-firstmate]";
40const FROMFIRST_MARK = `${FROMFIRST_LABEL}${OPERATIONAL_MARK}`;
41
42// Historical payload literals, isolated exactly as the owner isolates them: they exist
43// only for persisted pre-protocol transcripts.
44const LEGACY_SESSIONSTART =
45 "Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions.";
46const LEGACY_WATCHER_PREFIX = "FIRSTMATE WATCHER WAKE: ";
47const LEGACY_WATCHER_SUFFIX =
48 "\n\nRun bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.";
49const LEGACY_TURNEND_PREFIX =
50 "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";
51const LEGACY_AWAY_PREFIX = `${OPERATIONAL_MARK}Supervisor escalate (`;
52
53function isCurrentKind(kind: string): boolean {
54 return (FIRSTMATE_OPERATIONAL_GENERIC_KINDS as readonly string[]).includes(kind);
55}
56
57/** `fm_operational_generic_kind`: the kind of a current generic envelope, else undefined. */
58function genericKind(message: string): string | undefined {
59 let remainder: string;
60 if (message.startsWith(OPERATIONAL_HEADER_PREFIX)) {
61 remainder = message.slice(OPERATIONAL_HEADER_PREFIX.length);
62 } else if (message.startsWith(OPERATIONAL_UNMARKED_HEADER_PREFIX)) {
63 remainder = message.slice(OPERATIONAL_UNMARKED_HEADER_PREFIX.length);
64 } else {
65 return undefined;
66 }
67 const separator = remainder.indexOf(": ");
68 if (separator < 0) return undefined;
69 const kind = remainder.slice(0, separator);
70 if (!isCurrentKind(kind)) return undefined;
71 const body = remainder.slice(separator + 2);
72 return body === "" ? undefined : kind;
73}
74
75/** `fm_operational_input_kind`: a current input's kind, generic or from-firstmate. */
76export function firstmateOperationalInputKind(message: string): string | undefined {
77 const generic = genericKind(message);
78 if (generic !== undefined) return generic;
79 if (message.startsWith(FROMFIRST_MARK) && message.length > FROMFIRST_MARK.length) {
80 return "from-firstmate";
81 }
82 return undefined;
83}
84
85/** `fm_legacy_operational_input_kind`: the narrow pre-protocol shapes, in the owner's order. */
86export function firstmateLegacyOperationalInputKind(message: string): string | undefined {
87 // PR 899 landed an untyped FIRSTMATE_OP prefix whose subtype cannot be recovered
88 // without body prose, so it is explicitly generic.
89 if (message.startsWith(OPERATIONAL_PREFIX) && message.length > OPERATIONAL_PREFIX.length) {
90 return "legacy-operational";
91 }
92 if (message === LEGACY_SESSIONSTART) return "session-start";
93 if (message.startsWith(LEGACY_AWAY_PREFIX)) return "away-supervisor";
94 if (
95 message.startsWith(LEGACY_WATCHER_PREFIX) &&
96 message.endsWith(LEGACY_WATCHER_SUFFIX) &&
97 message.length > LEGACY_WATCHER_PREFIX.length + LEGACY_WATCHER_SUFFIX.length
98 ) {
99 return "watcher";
100 }
101 if (message.startsWith(LEGACY_TURNEND_PREFIX) && message.length > LEGACY_TURNEND_PREFIX.length) {
102 return "turn-end-guard";
103 }
104 return undefined;
105}
106
107/** `fm_operational_input_classify`: current kinds first, then the legacy shapes. */
108export function classifyFirstmateOperationalText(message: string): string | undefined {
109 return firstmateOperationalInputKind(message) ?? firstmateLegacyOperationalInputKind(message);
110}
111
112const RECORD_DIRNAME = "operational-inbox";
113const DOORBELL_PREFIX = ": Firstmate operational input waiting: read '";
114const DOORBELL_SUFFIX = "' and handle its contents as Firstmate operational input.";
115
116/** `fm_operational_doorbell_path`: the record path a well-formed doorbell names. */
117export function firstmateOperationalDoorbellPath(message: string): string | undefined {
118 if (
119 message.length < DOORBELL_PREFIX.length + DOORBELL_SUFFIX.length ||
120 !message.startsWith(DOORBELL_PREFIX) ||
121 !message.endsWith(DOORBELL_SUFFIX)
122 ) {
123 return undefined;
124 }
125 const path = message.slice(DOORBELL_PREFIX.length, message.length - DOORBELL_SUFFIX.length);
126 if (!path.startsWith("/") || path.includes("'") || !/^[\x20-\x7e]*$/.test(path)) return undefined;
127 const cut = path.lastIndexOf("/");
128 const directory = path.slice(0, cut);
129 if (directory.slice(directory.lastIndexOf("/") + 1) !== RECORD_DIRNAME) return undefined;
130 const name = path.slice(cut + 1);
131 if (!name.endsWith(".msg") || !/^[0-9a-z-]+$/.test(name.slice(0, -".msg".length))) return undefined;
132 return path;
133}
134
135/**
136 * `fm_operational_record_kind` over a record's content: its current generic kind. The
137 * owner always writes a record with its mark, so a mark-less header never backs one.
138 */
139export function firstmateOperationalRecordKind(content: string): string | undefined {
140 if (!content.startsWith(OPERATIONAL_PREFIX)) return undefined;
141 return genericKind(content);
142}
143lib/fm-calm-preservation.ts 12 lines1// 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