Switch firstmate to OpenCode 2 and refresh nix-darwin pins

Point fm at the isolated oc2 wrapper, upgrade Homebrew packages on rebuild, and declare ffmpeg, docker-desktop, puremac, utm, node, and tea. Update nixpkgs, home-manager, and nix-homebrew locks. Track Pi agent config, crew-dispatch, and the Gitea PR helper that home.nix already expected.
This commit is contained in:
Lap Tran
2026-09-18 13:58:49 -04:00
parent b7a956ebc0
commit 4c6e85c97c
21 changed files with 1399 additions and 19 deletions
@@ -0,0 +1,117 @@
// Pi Calm - gapless built-in tool-shell presentation adapter.
//
// Adapted from the Firstmate project's Calm implementation.
// Copyright (c) 2026 Kun Chen. MIT License - see the LICENSE file in this directory.
//
// Verified against Pi 0.82.0, which exports AgentSession and
// ToolExecutionComponent. The source-aware lookup returns Pi's active definition
// unchanged and the adapter changes only its final TUI row layout. Execution,
// settings, SDK overrides, extension collisions, and stored results remain
// owned by Pi. Image results remain visible without their call/result shell,
// and custom tools or tools outside Pi's seven built-ins render unchanged.
import {
AgentSession,
ToolExecutionComponent,
type ToolDefinition,
} from "@earendil-works/pi-coding-agent";
import type { Component } from "@earendil-works/pi-tui";
import { calmHidesTranscriptChrome } from "./visibility.ts";
const CALM_BUILT_IN_TOOL_NAMES = new Set([
"read",
"bash",
"edit",
"write",
"grep",
"find",
"ls",
]);
type ToolRowPresentationState = {
toolName: string;
toolDefinition?: ToolDefinition;
imageComponents: Component[];
imageSpacers: Component[];
};
type AgentSessionPresentationState = {
_baseToolsOverride?: Record<string, unknown>;
};
type CalmBuiltInToolShellPatch = {
hidesShell: () => boolean;
builtInDefinitions: WeakSet<ToolDefinition>;
};
const CALM_BUILT_IN_TOOL_SHELL_PATCH = Symbol.for(
"pi-calm:built-in-tool-shell-layout:pi-0.82.0",
);
export function installCalmBuiltInToolShellLayout(): void {
const registry = globalThis as typeof globalThis & {
[key: symbol]: CalmBuiltInToolShellPatch | undefined;
};
const hidesShell = (): boolean => calmHidesTranscriptChrome();
const installed = registry[CALM_BUILT_IN_TOOL_SHELL_PATCH];
if (installed?.builtInDefinitions) {
installed.hidesShell = hidesShell;
return;
}
const originalGetToolDefinition = AgentSession.prototype.getToolDefinition;
if (typeof originalGetToolDefinition !== "function") {
throw new Error("Pi Calm requires Pi AgentSession.getToolDefinition");
}
if (typeof ToolExecutionComponent !== "function") {
throw new Error("Pi Calm requires Pi ToolExecutionComponent");
}
const originalRender = ToolExecutionComponent.prototype.render;
if (typeof originalRender !== "function") {
throw new Error("Pi Calm requires Pi ToolExecutionComponent.render");
}
if (installed) installed.hidesShell = () => false;
const patch: CalmBuiltInToolShellPatch = {
hidesShell,
builtInDefinitions: new WeakSet(),
};
AgentSession.prototype.getToolDefinition = function (
name: string,
): ToolDefinition | undefined {
const definition = originalGetToolDefinition.call(this, name);
const source = this.getAllTools().find((tool) => tool.name === name)?.sourceInfo.source;
if (definition) {
const session = this as unknown as AgentSessionPresentationState;
const isSdkBaseOverride = Object.hasOwn(session._baseToolsOverride ?? {}, name);
if (source === "builtin" && !isSdkBaseOverride) {
patch.builtInDefinitions.add(definition);
} else {
patch.builtInDefinitions.delete(definition);
}
}
return definition;
};
ToolExecutionComponent.prototype.render = function (width: number): string[] {
const state = this as unknown as ToolRowPresentationState;
const isKnownBuiltIn =
CALM_BUILT_IN_TOOL_NAMES.has(state.toolName) &&
state.toolDefinition !== undefined &&
patch.builtInDefinitions.has(state.toolDefinition);
if (!isKnownBuiltIn || !patch.hidesShell()) {
return originalRender.call(this, width);
}
const lines: string[] = [];
for (let index = 0; index < state.imageComponents.length; index += 1) {
const spacer = state.imageSpacers[index];
if (spacer) lines.push(...spacer.render(width));
const image = state.imageComponents[index];
if (image) lines.push(...image.render(width));
}
return lines;
};
registry[CALM_BUILT_IN_TOOL_SHELL_PATCH] = patch;
}
@@ -0,0 +1,82 @@
// Pi Calm - gapless collapsed-thinking presentation adapter.
//
// Adapted from the Firstmate project's Calm implementation.
// Copyright (c) 2026 Kun Chen. MIT License - see the LICENSE file in this directory.
//
// Verified against Pi 0.82.0, which exports AssistantMessageComponent with an
// updateContent method. installCalmCollapsedThinkingLayout() probes that exact
// public seam and throws if it is missing; index.ts catches that and skips only
// this adapter with one clear diagnostic instead of blocking Calm or Pi.
//
// How it works: Pi renders a hidden thinking block as one static label row.
// Calm sets that label to the empty string and this adapter filters thinking
// blocks out of the message handed to the stock renderer, so a collapsed
// thinking block occupies zero rows instead of one blank one. The unfiltered
// message is kept on lastMessage so expanding thinking (Ctrl+T) and turning
// Calm off both restore the original reasoning content byte-for-byte. Only
// collapsed thinking is affected: expanded reasoning, assistant text, and tool
// calls render exactly as Pi renders them.
import type { AssistantMessageComponent as PiAssistantMessageComponent } from "@earendil-works/pi-coding-agent";
import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
import { calmHidesTranscriptChrome } from "./visibility.ts";
type AssistantMessage = Parameters<PiAssistantMessageComponent["updateContent"]>[0];
type AssistantMessagePresentationState = {
hiddenThinkingLabel: string;
hideThinkingBlock: boolean;
lastMessage?: AssistantMessage;
};
type CalmCollapsedThinkingPatch = {
hidesThinking: () => boolean;
};
// Keep the introduction-version symbol stable so a compatible upgrade cannot
// double-patch a live process.
const CALM_COLLAPSED_THINKING_PATCH = Symbol.for(
"pi-calm:collapsed-thinking-layout:pi-0.82.0",
);
export function installCalmCollapsedThinkingLayout(): void {
const registry = globalThis as typeof globalThis & {
[key: symbol]: CalmCollapsedThinkingPatch | undefined;
};
const hidesThinking = (): boolean => calmHidesTranscriptChrome();
const installed = registry[CALM_COLLAPSED_THINKING_PATCH];
if (installed) {
installed.hidesThinking = hidesThinking;
return;
}
const patch: CalmCollapsedThinkingPatch = { hidesThinking };
const AssistantMessageComponent = PiCodingAgent.AssistantMessageComponent;
if (typeof AssistantMessageComponent !== "function") {
throw new Error("Pi Calm requires Pi AssistantMessageComponent");
}
const originalUpdateContent = AssistantMessageComponent.prototype.updateContent;
if (typeof originalUpdateContent !== "function") {
throw new Error("Pi Calm requires Pi AssistantMessageComponent.updateContent");
}
AssistantMessageComponent.prototype.updateContent = function (
message: AssistantMessage,
): void {
const state = this as unknown as AssistantMessagePresentationState;
const hideThinking =
state.hiddenThinkingLabel === "" &&
state.hideThinkingBlock &&
patch.hidesThinking();
const presentationMessage = hideThinking
? {
...message,
content: message.content.filter((block) => block.type !== "thinking"),
}
: message;
originalUpdateContent.call(this, presentationMessage);
if (presentationMessage !== message) state.lastMessage = message;
};
registry[CALM_COLLAPSED_THINKING_PATCH] = patch;
}
@@ -0,0 +1,77 @@
// Pi Calm - persisted on/off preference.
//
// Copyright (c) 2026 Kun Chen. MIT License - see the LICENSE file in this directory.
//
// The preference lives in a plain local state file named "calm" directly under
// Pi's agent directory (~/.pi/agent by default, PI_CODING_AGENT_DIR when set).
// That directory is Pi runtime territory: this repository never tracks the
// state file and Home Manager never manages it. The file contains exactly
// "on\n" or "off\n"; anything else, including a missing or unreadable file,
// means off.
import { randomUUID } from "node:crypto";
import {
mkdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
export const CALM_PREFERENCE_FILE_NAME = "calm";
/**
* Resolve Pi's agent directory through Pi's exported getAgentDir(), which
* honors PI_CODING_AGENT_DIR and tilde expansion. If a future Pi stops
* exporting it, fall back to the documented environment variable and default
* path instead of failing.
*/
export function calmAgentDir(): string {
if (typeof PiCodingAgent.getAgentDir === "function") return PiCodingAgent.getAgentDir();
const envDir = process.env.PI_CODING_AGENT_DIR?.trim();
if (envDir) return envDir;
return join(homedir(), ".pi", "agent");
}
export function calmPreferencePath(): string {
return join(calmAgentDir(), CALM_PREFERENCE_FILE_NAME);
}
/** Load the persisted preference. Calm is off by default and on any read error. */
export function loadCalmPreference(): boolean {
try {
return readFileSync(calmPreferencePath(), "utf8").trim() === "on";
} catch {
return false;
}
}
/**
* Persist the preference atomically (unique temp file plus rename) so a
* crashed write never leaves a truncated state file. A failure throws a clear
* error naming the path so /calm can surface it instead of silently applying
* a toggle that would not survive a restart.
*/
export function persistCalmPreference(active: boolean): void {
const path = calmPreferencePath();
try {
mkdirSync(dirname(path), { recursive: true });
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
try {
writeFileSync(temporaryPath, active ? "on\n" : "off\n", {
encoding: "utf8",
flag: "wx",
mode: 0o600,
});
renameSync(temporaryPath, path);
} finally {
rmSync(temporaryPath, { force: true });
}
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`Pi Calm could not persist its preference to ${path}: ${reason}`);
}
}
@@ -0,0 +1,39 @@
// Pi Calm - shared presentation state for the standalone Calm extension.
//
// Adapted from the Firstmate project's Calm implementation.
// Copyright (c) 2026 Kun Chen. MIT License - see the LICENSE file in this directory.
//
// This module owns only the in-memory presentation flags. Presentation filtering
// must never delete or alter semantic, session, or export data, so the export
// path forces stock rendering for the duration of an /export or /share command.
let active = false;
let stockExportRendering = false;
/** True while Calm presentation filtering is enabled. */
export function calmPresentationIsActive(): boolean {
return active;
}
export function setCalmPresentation(next: boolean): void {
active = next;
}
/** True while an /export or /share render is in flight and stock output is required. */
export function calmStockExportRenderingIsActive(): boolean {
return stockExportRendering;
}
export function setCalmStockExportRendering(next: boolean): void {
stockExportRendering = next;
}
/**
* True while Calm should hide the supported transcript chrome: collapsed
* thinking labels and the known Pi built-in tool call/result shells. Genuine
* user prompts, assistant text, custom tools, and every other transcript row
* class are never filtered by this flag.
*/
export function calmHidesTranscriptChrome(): boolean {
return active && !stockExportRendering;
}
@@ -0,0 +1,247 @@
// Pi Calm - animated working presentation.
//
// Adapted from the Firstmate project's Calm implementation.
// Copyright (c) 2026 Kun Chen. MIT License - see the LICENSE file in this directory.
//
// Calm replaces Pi's stock working row with a tiny two-row ASCII boat while one
// logical agent run is active. This module owns only the sprite geometry, the
// bounce track, the two animation cadences, the session-scoped freeze/resume
// state, and the temporary TUI widget; ../index.ts owns when the presentation
// is installed and removed, and stays the sole caller of setWorkingVisible().
//
// Cadence: one scheduler drives two logically independent clocks. Every tick
// advances the water phase, and only every CALM_WORKING_SHIP_TICKS_PER_MOVE-th
// tick moves the boat, so the water visibly ripples several times between boat
// steps and the boat itself reads as calm. Both clocks stop together when the
// widget is disposed. Ticks, not wall-clock timestamps, drive every state
// change, so tests can seek time exactly.
//
// Continuity: one extension-owned animation instance survives hide/show within
// the same Pi process and Calm extension lifetime. Disposing the widget freezes
// column, direction, water phase, and tick cadence without advancing them for
// hidden wall time. The next working period resumes from that exact logical
// state. A fresh session or new extension lifetime calls reset() and starts at
// the normal initial position. State is never a module-level or process-global
// singleton.
//
// Verified against Pi 0.82.0, which exposes ExtensionUIContext.setWidget() with
// a component factory, per-widget dispose(), and TUI.requestRender(). Pi renders
// a widget through Component.render(width), so this module recomputes its track
// from that width on every frame instead of caching a terminal size that a
// resize would invalidate. A resize while the boat is hidden is applied on the
// first resumed frame through the same clamp path.
import type { Component, TUI } from "@earendil-works/pi-tui";
// The hull is symmetric and replaces waves on its row rather than adding a third row.
const HULL = "\\__/";
// A mainsail extends aft of the mast, so it trails behind the bow relative to travel.
const SAIL_RIGHT = "<|";
const SAIL_LEFT = "|>";
// Centers the two-cell sail over the four-cell hull.
const SAIL_OFFSET = 1;
const HULL_WIDTH = HULL.length;
const SAIL_WIDTH = SAIL_RIGHT.length;
// Bounded deterministic fixed-cell water phases. Every entry is exactly one column, so
// advancing the phase ripples the surface without changing visible width or row count.
const WAVE_CYCLE = ["~", "~", "-", "~"] as const;
// Standard ANSI foreground codes only: no theme lookup, bright variant, or 256/RGB.
const BLUE = "\u001b[34m";
const YELLOW = "\u001b[33m";
// Restores the default foreground so color never bleeds into padding or later frames.
const RESET = "\u001b[39m";
export const CALM_WORKING_SHIP_WIDGET_KEY = "calm-working-ship";
/** Scheduler period. One tick advances the water by one phase. */
export const CALM_WORKING_SHIP_TICK_MS = 220;
/** Boat moves one column every Nth tick, so it travels at 220 * 4 = 880ms per column. */
export const CALM_WORKING_SHIP_TICKS_PER_MOVE = 4;
export type CalmWorkingShipAnimation = {
/** Render one frame that exactly fits `width`, clamping the track to it first. */
render(width: number): string[];
/** Advance one scheduler tick: water every tick, boat on its slower cadence. */
tick(): void;
restoreLastRendered(): void;
/** Restore the normal initial column, direction, water phase, and cadence. */
reset(): void;
/**
* Clamp the frozen column and direction to `width` without advancing time.
* Used when a terminal resize lands while the working presentation is hidden.
*/
clampToWidth(width: number): void;
/** Current hull column, exposed for deterministic motion assertions. */
position(): number;
/** Current travel direction: 1 travelling right, -1 travelling left. */
direction(): number;
/** Current water phase, exposed for deterministic ripple assertions. */
waterPhase(): number;
};
/** Longest hull start column that still fits the sprite in `width` usable cells. */
function trackSpan(width: number): number {
if (width >= HULL_WIDTH) return width - HULL_WIDTH;
if (width >= SAIL_WIDTH) return width - SAIL_WIDTH;
return 0;
}
export function createCalmWorkingShipAnimation(): CalmWorkingShipAnimation {
let position = 0;
let direction = 1;
let span = 0;
let phase = 0;
let ticks = 0;
let renderedPosition = position;
let renderedDirection = direction;
let renderedSpan = span;
let renderedPhase = phase;
let renderedTicks = ticks;
// Reversing the moment the boat lands on an endpoint means the endpoint frame itself
// already shows the new heading, so no frame at or after a bounce shows the old sail.
const settleDirectionAtEdges = (): void => {
if (span <= 0) return;
if (position >= span) direction = -1;
else if (position <= 0) direction = 1;
};
const applyWidth = (width: number): void => {
if (width <= 0) {
span = 0;
position = 0;
return;
}
span = trackSpan(width);
position = Math.min(position, span);
settleDirectionAtEdges();
};
const commitRenderedState = (): void => {
renderedPosition = position;
renderedDirection = direction;
renderedSpan = span;
renderedPhase = phase;
renderedTicks = ticks;
};
const restoreLastRenderedState = (): void => {
position = renderedPosition;
direction = renderedDirection;
span = renderedSpan;
phase = renderedPhase;
ticks = renderedTicks;
};
/** One colored run of water covering absolute columns [from, from + count). */
const water = (from: number, count: number): string => {
if (count <= 0) return "";
let cells = "";
for (let column = from; column < from + count; column += 1) {
cells += WAVE_CYCLE[(column + phase) % WAVE_CYCLE.length];
}
return `${BLUE}${cells}${RESET}`;
};
const boat = (text: string): string => `${YELLOW}${text}${RESET}`;
return {
position: () => position,
direction: () => direction,
waterPhase: () => phase,
restoreLastRendered: restoreLastRenderedState,
reset(): void {
position = 0;
direction = 1;
span = 0;
phase = 0;
ticks = 0;
commitRenderedState();
},
clampToWidth(width: number): void {
applyWidth(width);
},
tick(): void {
ticks += 1;
phase = (phase + 1) % WAVE_CYCLE.length;
if (ticks % CALM_WORKING_SHIP_TICKS_PER_MOVE !== 0) return;
if (span <= 0) {
position = 0;
return;
}
position = Math.min(span, Math.max(0, position + direction));
settleDirectionAtEdges();
},
render(width: number): string[] {
if (width <= 0) return [];
// A resize lands here before the next frame, so recompute and clamp the track
// immediately rather than trusting a position measured against the old width.
applyWidth(width);
const sail = direction >= 0 ? SAIL_RIGHT : SAIL_LEFT;
let frame: string[];
if (width < SAIL_WIDTH) {
// Too narrow for even the sail: a deterministic single row of water.
frame = [water(0, width)];
} else if (width < HULL_WIDTH) {
// Too narrow for the hull: the sail alone rides the water row.
frame = [
water(0, position) +
boat(sail) +
water(position + SAIL_WIDTH, width - position - SAIL_WIDTH),
];
} else {
frame = [
" ".repeat(position + SAIL_OFFSET) + boat(sail),
water(0, position) +
boat(HULL) +
water(position + HULL_WIDTH, width - position - HULL_WIDTH),
];
}
commitRenderedState();
return frame;
},
};
}
/**
* Build the temporary Calm working widget bound to one caller-owned animation.
* Pi disposes the previous component before installing a replacement under the same
* key and when it clears extension widgets, so the single scheduler driving both
* cadences cannot outlive the widget or duplicate. Disposing freezes the shared
* animation in place; the next widget bound to the same animation resumes without
* applying hidden wall time.
*/
export function createCalmWorkingShipWidget(
tui: TUI,
animation: CalmWorkingShipAnimation = createCalmWorkingShipAnimation(),
): Component & { dispose(): void } {
let disposed = false;
const timer = setInterval(() => {
if (disposed) return;
animation.tick();
tui.requestRender();
}, CALM_WORKING_SHIP_TICK_MS);
// The animation must never keep Pi's process alive on its own.
timer.unref?.();
return {
render: (width) => (disposed ? [] : animation.render(width)),
// Every frame is rebuilt from fixed standard ANSI codes, so there is no cache.
invalidate: () => {},
dispose: () => {
if (disposed) return;
disposed = true;
clearInterval(timer);
animation.restoreLastRendered();
},
};
}