Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .agents/skills/bootstrap-diagnostics/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ When any diagnostic needs captain attention, report the plain consequence and re
- `MISSING: <tool> (install: <command>)` - list the missing tools to the captain with a one-line purpose each plus the printed install commands, wait for consent (one approval may cover the list), then run `bin/fm-bootstrap.sh install <approved tools...>`.
For `treehouse`, this also covers an installed version whose `treehouse get` lacks `--lease`; treat it as an upgrade request.
For `no-mistakes`, this also covers an installed version older than 1.31.2, because crewmate validation briefs delegate gate mechanics to no-mistakes' version-matched guidance.
For `tasks-axi`, this also covers an installed build that fails the compatibility probe (`docs/configuration.md` "Backlog backend" owns the definition); `config/backlog-backend=manual` only suppresses the verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not this missing-tool report.
For any axi-family tool - `gh-axi`, `lavish-axi`, `tasks-axi`, `quota-axi` - an installed version below its floor is a plain upgrade request; [`bin/fm-bootstrap.sh`](../../../bin/fm-bootstrap.sh) owns the floor policy, and never argue the floor down to whatever the home happens to have installed.
For `tasks-axi`, this additionally covers an installed build that fails the separate feature probe (`bin/fm-tasks-axi-lib.sh` owns the definition); `config/backlog-backend=manual` only suppresses the verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not this missing-tool report.
For `quota-axi`, bootstrap requires it because firstmate reads its current output directly before resolving every crew-dispatch profile array; without it, report the missing requirement and do not choose around an unexamined candidate.
- `MISSING_MANUAL: <tool> (instructions: <url>)` - tell the captain why the tool is required and give them the printed instructions URL, but do not pass the tool to `bin/fm-bootstrap.sh install`; wait for the captain to complete the manual installation, then rerun session start to confirm the dependency is present.
- `BACKEND_INVALID: <name> (known: <names>)` - the resolved runtime backend has no verified dependency or lifecycle contract, so do not dispatch work until the invalid `FM_BACKEND` or `config/backend` value is corrected to one of the listed backends.
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/secondmate-provisioning/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ The slot stays reserved across restarts until the lease is released.
Release happens only on explicit retirement or seed rollback, never on routine restart or recovery.

`bin/fm-home-seed.sh` copies the charter into the secondmate home as `data/charter.md`.
It also writes the required `.fm-secondmate-home` identity marker, which is gitignored and must remain in place for home validation.
It also writes the gitignored `.fm-secondmate-parent` durable binding before the required `.fm-secondmate-home` identity marker; the parser header in [`bin/fm-secondmate-parent-lib.sh`](../../../bin/fm-secondmate-parent-lib.sh) owns the record contract, and both files must remain in place.
`bin/fm-spawn.sh --secondmate` launches it through the secondmate harness path, resolving `config/secondmate-harness` -> `config/crew-harness` -> the primary's own harness unless an explicit per-spawn harness override is passed.

`config/secondmate-harness` may also pin a concrete model and effort for the secondmate agent, in the SAME file rather than a new one: the format is a single whitespace-separated line `<harness> [<model>] [<effort>]`, with only the first non-empty, non-comment line parsed.
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ data/
.no-mistakes/
.lavish/
.fm-secondmate-home
.fm-secondmate-parent
.DS_Store
__pycache__/
*.pyc
Expand Down
133 changes: 123 additions & 10 deletions .pi/extensions/fm-calm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,18 @@
// diagnostic (see installCalmPresentationAdapter below) if a future Pi removes it; Pi
// still exposes no global renderer for arbitrary built-in or custom rows.
// docs/configuration.md owns the home-local Calm preference contract.
//
// Pi has one first-registration-wins ToolDefinition per tool name, with no merge or
// unregister operation. Keep Calm-off registration empty; keep Calm-on load-time
// registration synchronous because restored rows capture the registry before
// session_start; and collision-check only the later first-activation path, when
// getAllTools() is reliable. docs/calm-mode-feasibility.md owns the Pi-source evidence
// and docs/calm.md owns the user-facing behavior and non-retroactive first-toggle bound.
import { randomUUID } from "node:crypto";
import {
mkdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
writeFileSync,
Expand All @@ -25,6 +33,7 @@ import type {
ExtensionAPI,
ExtensionUIContext,
ToolDefinition,
ToolInfo,
ToolRenderResultOptions,
} from "@earendil-works/pi-coding-agent";
import {
Expand Down Expand Up @@ -84,6 +93,21 @@ const extensionFile = fileURLToPath(import.meta.url);
const extensionDir = dirname(extensionFile);
const root = resolve(extensionDir, "../..");

// Resolves symlinks before comparing tool-ownership identity below: sourceInfo.path
// values come from independent path-resolution code paths (this module's own
// import.meta.url vs. Pi's extension loader), and macOS alone symlinks /tmp and /var
// to /private/..., so lexical string comparison alone spuriously reads a symlinked
// self-path as a foreign one. Falls back to the raw path for synthetic, non-file
// sourceInfo paths such as "<builtin:read>" or "<inline>", which realpathSync rejects.
const realpathOrSelf = (path: string): string => {
try {
return realpathSync(path);
} catch {
return path;
}
};
const extensionRealFile = realpathOrSelf(extensionFile);

// Each presentation adapter probes the exact Pi API it patches. If a future Pi removes
// that API, only the affected adapter degrades; the rest of Calm keeps working.
function installCalmPresentationAdapter(name: string, install: () => void): void {
Expand Down Expand Up @@ -166,9 +190,9 @@ export default function (pi: ExtensionAPI) {

registerFirstmateSyntheticPresentation(pi);

function registerBuiltIn<TParams extends TSchema, TDetails, TState>(
function wrapBuiltIn<TParams extends TSchema, TDetails, TState>(
factory: DefinitionFactory<TParams, TDetails, TState>,
): void {
): ToolDefinition<TParams, TDetails, TState> {
const definitions = new Map<string, ToolDefinition<TParams, TDetails, TState>>();
const definitionFor = (cwd: string): ToolDefinition<TParams, TDetails, TState> => {
let definition = definitions.get(cwd);
Expand Down Expand Up @@ -220,7 +244,7 @@ export default function (pi: ExtensionAPI) {
return shell;
};

pi.registerTool({
return {
...original,
renderShell: "self",

Expand Down Expand Up @@ -263,18 +287,106 @@ export default function (pi: ExtensionAPI) {
refreshStandardShell(state, theme, context);
return new Container();
},
};
}

// Each wrapBuiltIn() call below has its own concrete TParams/TDetails/TState; the
// array holding all seven has no single sound instantiation, so it is typed the same
// way Pi's own ToolDefinition consumers erase this (any, any, any).
const wrappedBuiltIns: ToolDefinition<any, any, any>[] = [
wrapBuiltIn(createReadToolDefinition),
wrapBuiltIn(createBashToolDefinition),
wrapBuiltIn(createEditToolDefinition),
wrapBuiltIn(createWriteToolDefinition),
wrapBuiltIn(createGrepToolDefinition),
wrapBuiltIn(createFindToolDefinition),
wrapBuiltIn(createLsToolDefinition),
];

// True once this extension has handled built-in registration for its lifetime:
// either all seven synchronously at load, or only the uncontested subset during
// first activation.
let builtInsRegistered = false;

// Gate on Calm already being on at load time. This must stay synchronous and
// unconditional here (see file header): a foreign-claim check is not reachable at
// this point, while deferral would make restored rows capture the wrong definition.
// A Calm-off session or reload registers nothing and creates no collision exposure.
if (loadCalmPreference()) {
for (const tool of wrappedBuiltIns) pi.registerTool(tool);
builtInsRegistered = true;
}

// Which of the 7 built-ins are currently owned by a different, non-builtin
// extension. Only safe to call once every extension has finished loading (see file
// header); never call this during the factory's own synchronous execution above.
function contestedBuiltIns(): ToolDefinition<any, any, any>[] {
let registered: ToolInfo[];
try {
registered = pi.getAllTools();
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
console.error(`Firstmate Calm: built-in ownership check unavailable, claiming every built-in unconditionally. ${reason}`);
return [];
}
return wrappedBuiltIns.filter((tool) => {
const owner = registered.find((info) => info.name === tool.name)?.sourceInfo;
return owner !== undefined && owner.source !== "builtin" && realpathOrSelf(owner.path) !== extensionRealFile;
});
}

registerBuiltIn(createReadToolDefinition);
registerBuiltIn(createBashToolDefinition);
registerBuiltIn(createEditToolDefinition);
registerBuiltIn(createWriteToolDefinition);
registerBuiltIn(createGrepToolDefinition);
registerBuiltIn(createFindToolDefinition);
registerBuiltIn(createLsToolDefinition);
// The first time Calm turns on in a session that started off, claim every
// uncontested built-in and leave each contested tool and its owning extension
// untouched. Tell the user which built-in Calm could not take over, since Calm's
// presentation does not apply to it.
function activateBuiltInsIfNeeded(ui: ExtensionUIContext): void {
if (builtInsRegistered) return;
const contested = contestedBuiltIns();
const contestedNames = new Set(contested.map((tool) => tool.name));
for (const tool of wrappedBuiltIns) {
if (!contestedNames.has(tool.name)) pi.registerTool(tool);
}
builtInsRegistered = true;
if (contested.length === 0) return;
const names = contested.map((tool) => `"${tool.name}"`).join(", ");
const plural = contested.length > 1;
ui.notify(
`Firstmate Calm: the ${names} built-in tool${plural ? "s are" : " is"} already provided by another extension, so Calm may not fully function for ${plural ? "them" : "it"} this session.`,
"warning",
);
for (const tool of contested) {
console.error(`Firstmate Calm: skipped claiming built-in "${tool.name}" because another extension already owns it.`);
}
}

// Backstop for the one case activateBuiltInsIfNeeded cannot reach: Calm registered
// unconditionally at load time because it was already on, without any chance to
// check for a foreign claim first, so it can still silently lose a name to an
// earlier-loaded extension. Runs on every session_start reason because a reload
// rebuilds every extension's registrations from scratch, so last session's clean
// bill of health does not carry over.
function reportBuiltInLosses(): void {
if (!builtInsRegistered) return;
let registered: ToolInfo[];
try {
registered = pi.getAllTools();
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
console.error(`Firstmate Calm: built-in ownership check unavailable. ${reason}`);
return;
}
for (const tool of wrappedBuiltIns) {
const owner = registered.find((info) => info.name === tool.name)?.sourceInfo;
if (owner && owner.source !== "builtin" && realpathOrSelf(owner.path) !== extensionRealFile) {
console.error(
`Firstmate Calm: another extension (${owner.path}) also claimed the built-in "${tool.name}" tool and won; Calm's presentation for it is unavailable this session.`,
);
}
}
}

pi.on("session_start", (_event, ctx) => {
reportBuiltInLosses();
exportRendering = false;
setCalmPresentation(loadCalmPreference());
setCalmStockExportRendering(false);
Expand Down Expand Up @@ -335,6 +447,7 @@ export default function (pi: ExtensionAPI) {
const active = !calmPresentationIsActive();
persistCalmPreference(active);
setCalmPresentation(active);
if (active) activateBuiltInsIfNeeded(ctx.ui);
publishPresentationState();
applyWorkingPresentation(ctx.ui, true);
ctx.ui.setHiddenThinkingLabel(active ? "" : undefined);
Expand Down
Loading
Loading