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

<h1 align="center">firstmate</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="firstmate - talk to one agent, ship with a crew" src="assets/banner.png" width="100%" />

What it is

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 - the first mate - 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 first mate - and makes you the captain.

Features

  • One liaison - you talk only to the first mate; it dispatches, supervises, escalates only real decisions, and reports plain outcomes.
  • A visible crew - every crewmate works in its own tmux window, Herdr tab, or experimental zellij tab, cmux workspace, or Orca terminal you can watch or type into; the first mate reconciles.
  • Disposable worktrees - each task runs in a clean treehouse git worktree, or an Orca-managed worktree when backend=orca, so parallel work on one repo never collides.
  • Two task shapes - ship tasks deliver authorized changes; scout tasks leave standalone investigation reports when the intake contract warrants separate research.
  • Explicit project modes - each project ships via no-mistakes, direct-PR, or local-only, with an optional +yolo merge-autonomy flag.
  • Optional secondmates - opt in to persistent second mates that run from isolated firstmate homes with their own 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.
  • Event-driven, zero-token supervision - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is under way and supervision is not live.
  • Optional Relay - opt in with one local .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.
  • Strict project boundary - the first mate is read-only over your projects except for the narrow guarded and captain-approved operations authorized by hard rule 1, including fleet sync's guarded safe branch pruning; crewmates make every other project change behind the configured merge authority.
  • Restart-proof - all state lives on disk and in the active session backend (tmux by hard default, herdr or cmux when selected or auto-detected, zellij/orca when explicitly selected); kill the session anytime and the next one reconciles, including confirmed-dead secondmate agents, and carries on.

Full detail on every feature lives in docs/architecture.md.

Quick Start

Requirements

  • A verified primary agent harness: Claude Code, Grok, Pi, pi-signed, Oh My Pi (omp), Codex, OpenCode, or Cursor Agent CLI.
  • Git and the GitHub CLI, authenticated through gh auth login.
  • The CLI and dependencies for your selected runtime backend; tmux is the reference default.

The first mate detects and offers to install supported missing tools after you approve. Backend-specific setup is linked in Documentation.

Recommended harnesses

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 OpenCode are also verified and supported as primary harnesses; Codex uses bounded foreground checkpoints, and OpenCode uses a TUI plugin, so both carry more harness-specific supervision tradeoffs than the three co-primaries. 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.

Install and launch

gh auth login
git clone https://github.com/kunchenguid/firstmate
cd firstmate

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. Those Calm-hidden operational inputs remain ordinary user-role messages with unchanged delivery, ordering, authority, persistence, and exports. 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.

Talk to it

> ahoy! 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

More backends

Setup guides for tmux (the default) and every other supported backend (herdr, zellij, Orca, cmux) are linked in Documentation below.

How It Works

            you (the captain)
                  │  chat: requests, decisions, "merge it"
                  ▼
 ┌─────────────────────────────────────┐
 │ firstmate            (this repo)    │
 │ reads projects/ + firstmate 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 the first mate. 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.

Built-in skills

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.

SkillWhat it does
/afkEnter away-mode supervision: the sub-supervisor self-handles routine notifications in bash, escalates captain-relevant events and bounded declared-external-wait rechecks as batched digests, and actively alerts if delivery gets stuck while you step away
/quietEnter quiet supervision mode: the same token-saving sub-supervisor tradeoff as /afk, for a captain who is staying and chatting - ordinary messages do not exit it, only an explicit /quiet off does
/ahoyRecap 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 Bearings when invoked as the session's first real captain message
/bearingsGenerate a concise four-section chat digest from bounded fleet state, including registered remote-home ledgers and measured follow-up for owned contributions; use /bearings file to also replace today's dated report in data/, and add include PRs for live GitHub enrichment
/updatefirstmateGuardedly 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
/stowSweep 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
/edicionMark blocks on a replica of a real screen through the project clone's marking surface and ask for changes without touching code, then turn the accumulated marks into one task or steer them into a live task

Bearings invocation examples:

  • /bearings returns the fresh four-section digest in chat only.
  • Owned-contribution follow-up comes from the cached coverage projection; include PRs remains the opt-in for repository-wide live PR enrichment.
  • /bearings include PRs keeps chat-only mode and opts into live PR enrichment.
  • /bearings file replaces today's data/status-report-<YYYY-MM-DD>.md from scratch and links it from the four-section chat digest.
  • /bearings 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.

Two-tier skill layout

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.

Documentation

  • docs/architecture.md - maintainer architecture for the crew, supervision, worktrees, secondmates, and project modes.
  • docs/configuration.md - environment variables, 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.
  • docs/extension-bindings.md - maintainer architecture for the narrow trusted external process-event-adapter/1 package, binding, handshake, and evidence boundary.
  • docs/remote-secondmates.md - current setup, routing, transfer, recovery, and safety behavior for whole-home remote second mates.
  • docs/calm.md - current /calm behavior on Pi and Claude Code and its supported presentation limits.
  • docs/voice-relay.md - the optional spoken interface: setup on both machines, measured round-trip cost, what a spoken answer may read, and what this build does not do yet.
  • docs/wedge-alarm.md - configure the active alert for an away-mode escalation delivery that gets stuck.
  • docs/tmux-backend.md - current setup and limits for the tmux reference backend.
  • docs/herdr-backend.md - current setup, CI coverage, safety boundaries, and limits for the Herdr backend.
  • docs/zellij-backend.md - current setup and limits for the experimental Zellij backend.
  • docs/orca-backend.md - current setup and limits for the experimental Orca backend.
  • docs/cmux-backend.md - current setup, socket security, and limits for the experimental cmux backend.
  • docs/codex-app-backend.md - the current blocked Codex App backend boundary and rollout contract.
  • docs/verification/runtime-backends.md - active maintainer verification for runtime backend guarantees.
  • docs/gitlab-merge-watch.md - maintainer verification for watching and merging GitLab merge requests on arbitrary instances.
  • docs/turnend-guard.md - the primary session's current "no turn ends blind" backstop, scope, loop safety, and compatibility limits.
  • docs/verification/supervision.md - active maintainer verification for session-start, guard, continuity, and wedge integrations.
  • docs/supervision-protocols/ - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi and pi-signed, omp, Grok, Cursor, and unknown harness fallback.
  • docs/scripts.md - the bin/ toolbelt reference.
  • docs/documentation-audiences.md - documentation audiences and the machine-checked placement boundary.
  • AGENTS.md - the supervisor contract, role boundary, and routing index for conditional procedures.
  • CONTRIBUTING.md - how to contribute, including the dev/test commands.

Contributing

Contributions are welcome - see CONTRIBUTING.md for the workflow, repo conventions, and how to run the tests.

License

MIT - see LICENSE.

Star History

<a href="https://www.star-history.com/?repos=kunchenguid%2Ffirstmate&type=date&legend=top-left"> <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=kunchenguid/firstmate&type=date&legend=top-left" /> </a>

Source 6 files
hooks/register.ts 298 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 draws as zero height; an `AssistantMessage` block recorded as a
23// mid-turn working note draws as zero height. Calm off returns every drawing to the
24// engine. A toggle invalidates every hooked drawing, so rows already on screen redraw.
25// The boat is painted in Claude Code's own theme colors: the family is read from the
26// `theme` setting at load and re-read when a `config.set` changes it.
27//
28// Loading is lazy and cached within a session: a resumed transcript or a hot reload can
29// draw restored rows before `session.start`, so every hook awaits that session's load of
30// the per-home preference and restored working notes rather than trusting a stale "off".
31// Each `session.start` clears presentation classifications and reloads the new session.
32import type { EngineInterface, Register, RenderElement, RenderInput } from "claude-code";
33import {
34  CALM_WORKING_SHIP_TICK_MS,
35  createCalmWorkingShipSprite,
36} from "../lib/fm-calm-working-ship-sprite.ts";
37import {
38  CALM_SHIP_RASTER_KEY,
39  CALM_SHIP_RASTER_PALETTES,
40  calmShipPaletteFamily,
41  calmShipRasterColumns,
42  packCalmShipRasterCells,
43  type CalmShipRasterPalette,
44} from "../lib/fm-calm-ship-raster.ts";
45import {
46  calmPreferencePath,
47  parseCalmPreference,
48  classifyRestoredTranscript,
49  serializeCalmPreference,
50  stepTextIsWorkingNote,
51  userTextIsOperational,
52  workingNoteKey,
53} from "../lib/fm-calm-presentation.ts";
54
55/** The slash command the mod serves, the same name as Pi's `/calm`. */
56const CALM_COMMAND = "calm";
57
58// One module environment holds one Calm state; a hot reload starts a fresh one, the
59// same as a new Pi extension lifetime.
60let calm = false;
61let preferencePath: string | undefined;
62let activation: Promise<boolean> | undefined;
63let loading: Promise<void> | undefined;
64let ticker: { cancel(): void } | undefined;
65const workingNotes = new Set<string>();
66const finalReplies = new Set<string>();
67const sprite = createCalmWorkingShipSprite();
68let palette: CalmShipRasterPalette = CALM_SHIP_RASTER_PALETTES.light;
69// Every Spinner site currently drawing the boat, by its requestId, with the mounted
70// Raster size a blit must repeat exactly.
71const sites = new Map<string, { columns: number; rows: number }>();
72
73function isActivated($: EngineInterface): Promise<boolean> {
74  if (activation === undefined) {
75    activation = $.env.get("CLAUDE_CODE_ENABLE_FUNCTION_HOOKS").then(
76      (value) => value === "1",
77      () => false,
78    );
79  }
80  return activation;
81}
82
83async function readPreference($: EngineInterface, path: string): Promise<string | undefined> {
84  try {
85    return await $.fs.read(path);
86  } catch {
87    return undefined;
88  }
89}
90
91/** The `theme` setting's current value, or undefined when the menu cannot be read. */
92async function readTheme($: EngineInterface): Promise<unknown> {
93  try {
94    return (await $.config.list()).find((row) => row.key === "theme")?.value;
95  } catch {
96    return undefined;
97  }
98}
99
100async function load($: EngineInterface): Promise<void> {
101  preferencePath = calmPreferencePath(
102    {
103      FM_HOME: await $.env.get("FM_HOME"),
104      FM_ROOT_OVERRIDE: await $.env.get("FM_ROOT_OVERRIDE"),
105      FM_CONFIG_OVERRIDE: await $.env.get("FM_CONFIG_OVERRIDE"),
106    },
107    $.plugin.root,
108  );
109  calm = parseCalmPreference(await readPreference($, preferencePath));
110  palette = CALM_SHIP_RASTER_PALETTES[calmShipPaletteFamily(await readTheme($))];
111  try {
112    const restored = classifyRestoredTranscript(await $.session.messages());
113    for (const note of restored.workingNotes) workingNotes.add(note);
114    for (const reply of restored.finalReplies) finalReplies.add(reply);
115  } catch {
116    // A transcript that cannot be read leaves restored narration visible; nothing else changes.
117  }
118  if (ticker === undefined) {
119    ticker = $.clock.every(CALM_WORKING_SHIP_TICK_MS, () => {
120      void repaintShip($);
121    });
122  }
123  $.ui.invalidate("ui.render");
124}
125
126function ensureLoaded($: EngineInterface): Promise<void> {
127  if (loading === undefined) loading = load($);
128  return loading;
129}
130
131async function resetSession($: EngineInterface): Promise<void> {
132  if (loading !== undefined) await loading.catch(() => undefined);
133  calm = false;
134  preferencePath = undefined;
135  loading = undefined;
136  workingNotes.clear();
137  finalReplies.clear();
138  sites.clear();
139  sprite.reset();
140  palette = CALM_SHIP_RASTER_PALETTES.light;
141  await ensureLoaded($);
142}
143
144/** One scheduler tick: advance the sprite, then repaint every mounted boat in place. */
145async function repaintShip($: EngineInterface): Promise<void> {
146  if (!calm || sites.size === 0) return;
147  sprite.tick();
148  for (const [requestId, site] of sites) {
149    const packed = packCalmShipRasterCells(sprite.frame(site.columns), site.columns, palette);
150    const result = await $.ui.blit({
151      requestId,
152      key: CALM_SHIP_RASTER_KEY,
153      cells: packed.cells,
154      columns: site.columns,
155      rows: site.rows,
156    });
157    // A denied blit means the site no longer shows this plugin's Raster (the turn
158    // settled, or a resize redrew it); forget it until the next Spinner drawing.
159    if (result.deny !== undefined && sites.get(requestId) === site) sites.delete(requestId);
160  }
161}
162
163/** A zero-height drawing: the row contributes nothing to the transcript's layout. */
164function hiddenRow($: EngineInterface, e: RenderInput): RenderElement {
165  const { Box } = $.ui.resolve(e);
166  return Box({ display: "none" });
167}
168
169export const register: Register = (on) => {
170  on("session.start", async ($, e, next) => {
171    if (!(await isActivated($))) return next(e);
172    await resetSession($);
173    await $.command.register({
174      name: CALM_COMMAND,
175      description: "Toggle Firstmate's Calm transcript presentation and working ship.",
176    });
177    return next(e);
178  });
179
180  on("command.run", { command: CALM_COMMAND }, async ($, e, next) => {
181    if (!(await isActivated($))) return next(e);
182    await ensureLoaded($);
183    const active = !calm;
184    // Persist before changing live presentation, so a failed write leaves the current
185    // choice unchanged rather than claiming persistence.
186    try {
187      await $.fs.write(preferencePath ?? "", serializeCalmPreference(active));
188    } catch (error) {
189      const reason = error instanceof Error ? error.message : String(error);
190      $.ui.toast(`Calm unchanged: could not save ${preferencePath ?? "the preference"} (${reason})`);
191      return {};
192    }
193    calm = active;
194    if (!calm) sites.clear();
195    $.ui.invalidate("ui.render");
196    $.ui.toast(active ? "Calm on" : "Calm off");
197    // No `text`: the toggle leaves no output row in the transcript, as on Pi.
198    return {};
199  });
200
201  // Follow a theme change: the next drawing and every later blit use the new family.
202  on("config.set", { key: "theme" }, async ($, e, next) => {
203    if (!(await isActivated($))) return next(e);
204    const result = await next(e);
205    if (result.deny === undefined) {
206      const chosen = CALM_SHIP_RASTER_PALETTES[calmShipPaletteFamily(result.value)];
207      if (chosen !== palette) {
208        palette = chosen;
209        if (calm) $.ui.invalidate("ui.render");
210      }
211    }
212    return result;
213  });
214
215  // Record mid-turn narration as it streams: the text blocks of a model step that
216  // stopped to call tools. Subagent steps never draw in the main transcript.
217  on("turn.step", async function* ($, e, next) {
218    if (!(await isActivated($))) {
219      const untouched = next(e);
220      for await (const chunk of untouched) yield chunk;
221      return await untouched.result;
222    }
223    const stream = next(e);
224    const blocks = new Map<number, string>();
225    for await (const chunk of stream) {
226      if (chunk.kind === "text") blocks.set(chunk.index, (blocks.get(chunk.index) ?? "") + chunk.text);
227      yield chunk;
228    }
229    const result = await stream.result;
230    if (e.agentId === undefined) {
231      let changed = false;
232      for (const text of [...blocks.values(), result.answer]) {
233        const key = workingNoteKey(text);
234        if (key === "") continue;
235        if (stepTextIsWorkingNote(result, text)) {
236          if (finalReplies.has(key) || workingNotes.has(key)) continue;
237          workingNotes.add(key);
238          changed = true;
239        } else {
240          if (!finalReplies.has(key)) {
241            finalReplies.add(key);
242            changed = true;
243          }
244          if (workingNotes.delete(key)) changed = true;
245        }
246      }
247      if (changed && calm) $.ui.invalidate("ui.render");
248    }
249    return result;
250  });
251
252  on("ui.render", { component: "Spinner" }, async ($, e, next) => {
253    if (!(await isActivated($))) return next(e);
254    await ensureLoaded($);
255    if (!calm || e.surface !== "terminal") {
256      sites.delete(e.requestId);
257      return next(e);
258    }
259    const columns = calmShipRasterColumns(e.viewport?.columns);
260    const packed = packCalmShipRasterCells(sprite.frame(columns), columns, palette);
261    sites.set(e.requestId, { columns, rows: packed.rows });
262    const { Box, Raster } = $.ui.resolve(e);
263    return Box({
264      flexDirection: "column",
265      children: Raster({ key: CALM_SHIP_RASTER_KEY, columns, rows: packed.rows, cells: packed.cells }),
266    });
267  });
268
269  on("ui.render", { component: "ToolUse" }, async ($, e, next) => {
270    if (!(await isActivated($))) return next(e);
271    await ensureLoaded($);
272    return calm ? hiddenRow($, e) : next(e);
273  });
274  on("ui.render", { component: "ToolResult" }, async ($, e, next) => {
275    if (!(await isActivated($))) return next(e);
276    await ensureLoaded($);
277    return calm ? hiddenRow($, e) : next(e);
278  });
279  on("ui.render", { component: "ToolGroup" }, async ($, e, next) => {
280    if (!(await isActivated($))) return next(e);
281    await ensureLoaded($);
282    return calm ? hiddenRow($, e) : next(e);
283  });
284
285  on("ui.render", { component: "UserMessage" }, async ($, e, next) => {
286    if (!(await isActivated($))) return next(e);
287    await ensureLoaded($);
288    return calm && userTextIsOperational(e.props.text) ? hiddenRow($, e) : next(e);
289  });
290
291  on("ui.render", { component: "AssistantMessage" }, async ($, e, next) => {
292    if (!(await isActivated($))) return next(e);
293    await ensureLoaded($);
294    const key = workingNoteKey(e.props.text);
295    return calm && workingNotes.has(key) && !finalReplies.has(key) ? hiddenRow($, e) : next(e);
296  });
297};
298
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 139 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. docs/calm.md owns the exact
9// captain-facing contract and docs/configuration.md
10// the persisted preference schema. Everything here is pure so tests run it under Node.
11import { classifyFirstmateOperationalText } from "./fm-operational-input.ts";
12import {
13  CALM_PRESERVE_MIN_CHARS,
14  calmTextIsSubstantive,
15} from "./fm-calm-preservation.ts";
16
17export { CALM_PRESERVE_MIN_CHARS } from "./fm-calm-preservation.ts";
18
19/** The environment variables that select the effective Firstmate home, as the mod reads them. */
20export type CalmHomeEnvironment = {
21  readonly FM_HOME?: string | undefined;
22  readonly FM_ROOT_OVERRIDE?: string | undefined;
23  readonly FM_CONFIG_OVERRIDE?: string | undefined;
24};
25
26/** The parent of a path, with either separator; a bare name resolves to itself. */
27function parentDirectory(path: string): string {
28  const trimmed = path.replace(/[\\/]+$/, "");
29  const cut = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
30  return cut > 0 ? trimmed.slice(0, cut) : trimmed;
31}
32
33/**
34 * The tracked Firstmate code root the mod belongs to: three levels above the plugin
35 * folder, whether Claude Code names it through `.claude/skills/<name>`,
36 * `.agents/skills/<name>`, or its physical `.claude/mods/<name>` home, which all sit
37 * at that same depth.
38 */
39export function calmCodeRootFromPluginRoot(pluginRoot: string): string {
40  return parentDirectory(parentDirectory(parentDirectory(pluginRoot)));
41}
42
43/**
44 * The per-home `config/calm` path, resolved exactly as the Pi extension resolves it:
45 * `FM_HOME`, then `FM_ROOT_OVERRIDE`, then the tracked code root, with
46 * `FM_CONFIG_OVERRIDE` naming the config directory outright when present.
47 */
48export function calmPreferencePath(env: CalmHomeEnvironment, pluginRoot: string): string {
49  const configDirectory =
50    env.FM_CONFIG_OVERRIDE ||
51    `${env.FM_HOME || env.FM_ROOT_OVERRIDE || calmCodeRootFromPluginRoot(pluginRoot)}/config`;
52  return `${configDirectory}/calm`;
53}
54
55/**
56 * Whether a stored preference reads as Calm on. `max` is the legacy value of a removed
57 * third level whose behavior is now ordinary Calm; absent or unrecognized reads as off.
58 */
59export function parseCalmPreference(stored: string | undefined): boolean {
60  if (stored === undefined) return false;
61  const value = stored.trim();
62  return value === "on" || value === "max";
63}
64
65/** The exact file content the Pi extension writes for the same choice. */
66export function serializeCalmPreference(active: boolean): string {
67  return active ? "on\n" : "off\n";
68}
69
70/** The shape of one `turn.step` result this policy reads. */
71export type CalmStepOutcome = {
72  readonly stopReason: string | null;
73  readonly toolUses: readonly unknown[];
74};
75
76
77/**
78 * Whether text from a model step is a mid-turn working note: the model did not end
79 * its response there, because it stopped to call tools, or ran out of tokens while
80 * calling them. Short single-line narration stays a note; substantive text is a final
81 * reply even when the step also called tools.
82 */
83export function stepTextIsWorkingNote(step: CalmStepOutcome, text: string): boolean {
84  const midTurn = step.stopReason === "tool_use" || (step.stopReason === "max_tokens" && step.toolUses.length > 0);
85  return midTurn && !calmTextIsSubstantive(text);
86}
87
88/** A trimmed text key that retains whether the raw row contained a newline. */
89export function workingNoteKey(text: string): string {
90  const trimmedText = text.trim();
91  if (trimmedText === "") return "";
92  return text.includes("\n") ? `${trimmedText}\n` : trimmedText;
93}
94
95/** The shape of one `$.session.messages()` row this policy reads. */
96export type CalmSessionRow = {
97  readonly role: "user" | "assistant";
98  readonly text: string;
99  readonly toolUses: readonly unknown[];
100};
101
102/**
103 * The structurally identified working notes and final replies in a restored transcript.
104 * The stored transcript keeps each content block as its own row, so assistant text is a
105 * working note when its own row called tools, or when a tool-calling assistant row
106 * follows it before the next user row. Substantive text in either position is preserved
107 * as a final reply, matching the live classifier.
108 */
109export function classifyRestoredTranscript(rows: readonly CalmSessionRow[]): {
110  workingNotes: string[];
111  finalReplies: string[];
112} {
113  const notes = new Set<string>();
114  const finalReplies = new Set<string>();
115  for (let index = 0; index < rows.length; index += 1) {
116    const row = rows[index]!;
117    if (row.role !== "assistant") continue;
118    const key = workingNoteKey(row.text);
119    if (key === "") continue;
120    let followedByToolCall = row.toolUses.length > 0;
121    for (let later = index + 1; later < rows.length && rows[later]!.role === "assistant"; later += 1) {
122      if (rows[later]!.toolUses.length > 0) {
123        followedByToolCall = true;
124        break;
125      }
126    }
127    if (followedByToolCall && calmTextIsSubstantive(row.text)) finalReplies.add(key);
128    else if (followedByToolCall) notes.add(key);
129    else finalReplies.add(key);
130  }
131  for (const key of finalReplies) notes.delete(key);
132  return { workingNotes: [...notes], finalReplies: [...finalReplies] };
133}
134
135/** Whether a user row's text is a canonically classified Firstmate operational input. */
136export function userTextIsOperational(text: string): boolean {
137  return classifyFirstmateOperationalText(text) !== undefined;
138}
139
lib/fm-operational-input.ts 97 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
16const OPERATIONAL_MARK = "\u2063";
17const OPERATIONAL_PREFIX = `${OPERATIONAL_MARK}FIRSTMATE_OP: `;
18const OPERATIONAL_VERSION = "v1";
19const OPERATIONAL_HEADER_PREFIX = `${OPERATIONAL_PREFIX}${OPERATIONAL_VERSION} `;
20
21/** The kinds the owner's `FM_OPERATIONAL_KINDS` names, in its order. */
22export const FIRSTMATE_OPERATIONAL_GENERIC_KINDS = [
23  "session-start",
24  "watcher",
25  "turn-end-guard",
26  "away-supervisor",
27  "launch-brief",
28  "branch-outcome",
29] as const;
30
31const FROMFIRST_LABEL = "[fm-from-firstmate]";
32const FROMFIRST_MARK = `${FROMFIRST_LABEL}${OPERATIONAL_MARK}`;
33
34// Historical payload literals, isolated exactly as the owner isolates them: they exist
35// only for persisted pre-protocol transcripts.
36const LEGACY_SESSIONSTART =
37  "Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions.";
38const LEGACY_WATCHER_PREFIX = "FIRSTMATE WATCHER WAKE: ";
39const LEGACY_WATCHER_SUFFIX =
40  "\n\nRun bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.";
41const LEGACY_TURNEND_PREFIX =
42  "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";
43const LEGACY_AWAY_PREFIX = `${OPERATIONAL_MARK}Supervisor escalate (`;
44
45function isCurrentKind(kind: string): boolean {
46  return (FIRSTMATE_OPERATIONAL_GENERIC_KINDS as readonly string[]).includes(kind);
47}
48
49/** `fm_operational_generic_kind`: the kind of a current generic envelope, else undefined. */
50function genericKind(message: string): string | undefined {
51  if (!message.startsWith(OPERATIONAL_HEADER_PREFIX)) return undefined;
52  const remainder = message.slice(OPERATIONAL_HEADER_PREFIX.length);
53  const separator = remainder.indexOf(": ");
54  if (separator < 0) return undefined;
55  const kind = remainder.slice(0, separator);
56  if (!isCurrentKind(kind)) return undefined;
57  const body = remainder.slice(separator + 2);
58  return body === "" ? undefined : kind;
59}
60
61/** `fm_operational_input_kind`: a current input's kind, generic or from-firstmate. */
62export function firstmateOperationalInputKind(message: string): string | undefined {
63  const generic = genericKind(message);
64  if (generic !== undefined) return generic;
65  if (message.startsWith(FROMFIRST_MARK) && message.length > FROMFIRST_MARK.length) {
66    return "from-firstmate";
67  }
68  return undefined;
69}
70
71/** `fm_legacy_operational_input_kind`: the narrow pre-protocol shapes, in the owner's order. */
72export function firstmateLegacyOperationalInputKind(message: string): string | undefined {
73  // PR 899 landed an untyped FIRSTMATE_OP prefix whose subtype cannot be recovered
74  // without body prose, so it is explicitly generic.
75  if (message.startsWith(OPERATIONAL_PREFIX) && message.length > OPERATIONAL_PREFIX.length) {
76    return "legacy-operational";
77  }
78  if (message === LEGACY_SESSIONSTART) return "session-start";
79  if (message.startsWith(LEGACY_AWAY_PREFIX)) return "away-supervisor";
80  if (
81    message.startsWith(LEGACY_WATCHER_PREFIX) &&
82    message.endsWith(LEGACY_WATCHER_SUFFIX) &&
83    message.length > LEGACY_WATCHER_PREFIX.length + LEGACY_WATCHER_SUFFIX.length
84  ) {
85    return "watcher";
86  }
87  if (message.startsWith(LEGACY_TURNEND_PREFIX) && message.length > LEGACY_TURNEND_PREFIX.length) {
88    return "turn-end-guard";
89  }
90  return undefined;
91}
92
93/** `fm_operational_input_classify`: current kinds first, then the legacy shapes. */
94export function classifyFirstmateOperationalText(message: string): string | undefined {
95  return firstmateOperationalInputKind(message) ?? firstmateLegacyOperationalInputKind(message);
96}
97
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