port gsd2 #3338: ecosystem plugin loader for .sf/extensions/

Adds support for project-local SF extension plugins dropped in
.sf/extensions/. Trust-gated (requires pi trust), symlink-escape safe.

- ecosystem/sf-extension-api.ts: SFExtensionAPI wrapper exposing
  getPhase() and getActiveUnit() to third-party handlers; updateSnapshot
  refreshes state before_agent_start so handlers see current phase/unit
- ecosystem/loader.ts: discovers .sf/extensions/*.js, loads them via
  dynamic import, dispatches factory(api) for each
- register-extension.ts: initializes ecosystemHandlers array, wires loader
- register-hooks.ts: before_agent_start refreshes snapshot then dispatches
  ecosystem handlers before returning SF system prompt
- types.ts: SFActiveUnit interface (milestoneId/sliceId/taskId + titles)
- workflow-logger.ts: "ecosystem" added to LogComponent union

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-04-25 08:27:55 +02:00
parent 6c36d62f35
commit d4cdcb582d
6 changed files with 480 additions and 3 deletions

View file

@ -2,6 +2,8 @@
import type { ExtensionAPI, ExtensionCommandContext } from "@singularity-forge/pi-coding-agent";
import type { SFEcosystemBeforeAgentStartHandler } from "../ecosystem/sf-extension-api.js";
import { loadEcosystemExtensions } from "../ecosystem/loader.js";
import { registerExitCommand } from "../exit-command.js";
import { registerWorktreeCommand } from "../worktree-command.js";
import { registerDbTools } from "./db-tools.js";
@ -72,6 +74,8 @@ export function registerSfExtension(pi: ExtensionAPI): void {
},
});
const ecosystemHandlers: SFEcosystemBeforeAgentStartHandler[] = [];
// Wrap non-critical registrations individually so one failure
// doesn't prevent the others from loading.
const nonCriticalRegistrations: Array<[string, () => void]> = [
@ -80,7 +84,15 @@ export function registerSfExtension(pi: ExtensionAPI): void {
["journal-tools", () => registerJournalTools(pi)],
["query-tools", () => registerQueryTools(pi)],
["shortcuts", () => registerShortcuts(pi)],
["hooks", () => registerHooks(pi)],
["hooks", () => registerHooks(pi, ecosystemHandlers)],
["ecosystem", () => {
void loadEcosystemExtensions(pi, ecosystemHandlers).catch((err) => {
logWarning(
"bootstrap",
`Failed to load ecosystem extensions: ${err instanceof Error ? err.message : String(err)}`,
);
});
}],
];
for (const [name, register] of nonCriticalRegistrations) {

View file

@ -3,6 +3,10 @@ import { join } from "node:path";
import type { ExtensionAPI, ExtensionContext } from "@singularity-forge/pi-coding-agent";
import { isToolCallEventType } from "@singularity-forge/pi-coding-agent";
import type { SFEcosystemBeforeAgentStartHandler } from "../ecosystem/sf-extension-api.js";
import { updateSnapshot } from "../ecosystem/sf-extension-api.js";
import { getEcosystemReadyPromise } from "../ecosystem/loader.js";
import { buildMilestoneFileName, resolveMilestonePath, resolveSliceFile, resolveSlicePath } from "../paths.js";
import { buildBeforeAgentStartResult } from "./system-context.js";
import { handleAgentEnd } from "./agent-end-recovery.js";
@ -45,7 +49,7 @@ async function syncServiceTierStatus(ctx: ExtensionContext): Promise<void> {
ctx.ui.setStatus("sf-fast", formatServiceTierFooterStatus(getEffectiveServiceTier(), ctx.model?.id));
}
export function registerHooks(pi: ExtensionAPI): void {
export function registerHooks(pi: ExtensionAPI, ecosystemHandlers: SFEcosystemBeforeAgentStartHandler[] = []): void {
pi.on("session_start", async (_event, ctx) => {
lastGeminiPreflightWarning = undefined;
resetLearningRuntime();
@ -118,6 +122,28 @@ export function registerHooks(pi: ExtensionAPI): void {
});
pi.on("before_agent_start", async (event, ctx: ExtensionContext) => {
// Refresh the ecosystem snapshot BEFORE running ecosystem handlers so they
// see current phase/unit state (#3338).
try {
const { ensureDbOpen } = await import("./dynamic-tools.js");
await ensureDbOpen();
const basePath = process.cwd();
const state = await deriveState(basePath);
updateSnapshot(state);
} catch {
updateSnapshot(null);
}
// Await ecosystem loading, then dispatch any registered handlers.
await getEcosystemReadyPromise();
for (const handler of ecosystemHandlers) {
try {
await handler(event, ctx as any);
} catch {
// Non-fatal: don't break the SF turn if a third-party handler throws.
}
}
return buildBeforeAgentStartResult(event, ctx);
});

View file

@ -0,0 +1,201 @@
// SF — Ecosystem extension loader for ./.sf/extensions/
// Discovers and registers single-file extensions that consume SFExtensionAPI.
// Trust-gated (mirrors pi's `.pi/extensions/` model) and isolated from pi's
// own loader chain — handlers run in SF's own dispatch step, not pi's.
import * as fs from "node:fs";
import * as path from "node:path";
import { pathToFileURL } from "node:url";
import type { ExtensionAPI } from "@singularity-forge/pi-coding-agent";
import { getAgentDir } from "@singularity-forge/pi-coding-agent";
import { logWarning } from "../workflow-logger.js";
import {
createSFExtensionAPI,
type SFEcosystemBeforeAgentStartHandler,
type SFExtensionAPI,
} from "./sf-extension-api.js";
// ─── Trust check (inlined; pi does not export isProjectTrusted from its
// package root, and constraint forbids modifying packages/pi-coding-agent/) ─
const TRUSTED_PROJECTS_FILE = "trusted-projects.json";
function isProjectTrusted(projectPath: string, agentDir: string): boolean {
const canonical = path.resolve(projectPath);
const trustedPath = path.join(agentDir, TRUSTED_PROJECTS_FILE);
try {
const content = fs.readFileSync(trustedPath, "utf-8");
const parsed = JSON.parse(content);
if (Array.isArray(parsed)) {
return parsed.includes(canonical);
}
} catch {
// missing or malformed — treat as untrusted
}
return false;
}
// ─── Ready-promise singleton ────────────────────────────────────────────
let _readyPromise: Promise<void> | null = null;
let _untrustedWarned = false;
/**
* Discover and register ecosystem extensions from `./.sf/extensions/`.
* Idempotent: subsequent calls with the same arguments return the same
* pending promise (no double-load).
*/
export function loadEcosystemExtensions(
pi: ExtensionAPI,
sharedHandlers: SFEcosystemBeforeAgentStartHandler[],
cwd: string = process.cwd(),
): Promise<void> {
if (_readyPromise) return _readyPromise;
_readyPromise = _loadEcosystemExtensionsImpl(pi, sharedHandlers, cwd);
return _readyPromise;
}
/**
* Returns a promise that resolves when ecosystem loading has completed.
* If loading was never kicked off this returns a resolved promise so the
* `before_agent_start` handler can `await` unconditionally.
*/
export function getEcosystemReadyPromise(): Promise<void> {
return _readyPromise ?? Promise.resolve();
}
/** Test-only: clear the singleton so tests can re-run loading. */
export function _resetEcosystemLoader(): void {
_readyPromise = null;
_untrustedWarned = false;
}
// ─── Implementation ─────────────────────────────────────────────────────
async function _loadEcosystemExtensionsImpl(
pi: ExtensionAPI,
sharedHandlers: SFEcosystemBeforeAgentStartHandler[],
cwd: string,
): Promise<void> {
const extDir = path.join(cwd, ".sf", "extensions");
if (!fs.existsSync(extDir)) return;
// Trust gate: refuse to load arbitrary code from untrusted project dirs.
if (!isProjectTrusted(cwd, getAgentDir())) {
if (!_untrustedWarned) {
_untrustedWarned = true;
logWarning(
"ecosystem",
".sf/extensions present but project is not trusted — skipping ecosystem extensions. Run `pi trust` to opt in.",
);
}
return;
}
// Resolve realpath ONCE so symlink-escape detection has a stable anchor.
let realExtDir: string;
try {
realExtDir = fs.realpathSync(extDir);
} catch (err) {
logWarning(
"ecosystem",
`failed to resolve extensions dir: ${err instanceof Error ? err.message : String(err)}`,
);
return;
}
let entries: string[];
try {
entries = fs
.readdirSync(extDir)
.filter((f) => f.endsWith(".js") || f.endsWith(".ts"))
.sort(); // deterministic load order
} catch (err) {
logWarning(
"ecosystem",
`failed to read extensions dir: ${err instanceof Error ? err.message : String(err)}`,
);
return;
}
// The wrapper api is built once per loader run and shared by all extensions
// so they all read from the same module-level snapshot.
const api: SFExtensionAPI = createSFExtensionAPI(pi, sharedHandlers);
for (const entry of entries) {
await _loadOne(extDir, realExtDir, entry, api);
}
}
async function _loadOne(
extDir: string,
realExtDir: string,
entry: string,
api: SFExtensionAPI,
): Promise<void> {
const fullPath = path.join(extDir, entry);
// Symlink-escape guard: reject entries whose realpath is not under realExtDir.
let realFullPath: string;
try {
realFullPath = fs.realpathSync(fullPath);
} catch (err) {
logWarning(
"ecosystem",
`failed to resolve ${entry}: ${err instanceof Error ? err.message : String(err)}`,
);
return;
}
const realExtDirWithSep = realExtDir.endsWith(path.sep) ? realExtDir : realExtDir + path.sep;
if (
realFullPath !== realExtDir &&
!realFullPath.startsWith(realExtDirWithSep)
) {
logWarning("ecosystem", `rejecting ${entry}: realpath escapes extensions dir`);
return;
}
// For .ts files, require a sibling compiled .js — we do not run a TS loader
// in production. Drop mtime heuristics: if .js exists, prefer it; otherwise warn.
let importPath = realFullPath;
if (entry.endsWith(".ts")) {
const jsSibling = realFullPath.slice(0, -3) + ".js";
if (fs.existsSync(jsSibling)) {
importPath = jsSibling;
} else {
logWarning(
"ecosystem",
`${entry}: TypeScript source has no compiled .js sibling — compile it first`,
);
return;
}
}
let mod: any;
try {
mod = await import(pathToFileURL(importPath).href);
} catch (err) {
logWarning(
"ecosystem",
`failed to import ${entry}: ${err instanceof Error ? err.message : String(err)}`,
);
return;
}
const factory = mod?.default;
if (typeof factory !== "function") {
logWarning("ecosystem", `${entry}: default export is not a function`);
return;
}
try {
await factory(api);
} catch (err) {
logWarning(
"ecosystem",
`factory threw for ${entry}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}

View file

@ -0,0 +1,228 @@
// SF — Ecosystem Extension API wrapper
// Wraps pi's ExtensionAPI to expose typed SF context (phase + active unit)
// to extensions loaded from `./.sf/extensions/`. The wrapper intercepts only
// `on("before_agent_start", ...)` so SF can dispatch ecosystem handlers AFTER
// refreshing state — fixing the load-order race where third-party
// `.pi/extensions/` handlers see a stale module-level snapshot (#3338).
//
// SINGLE-SESSION INVARIANT: the module-level `_snapshot` is per-process.
// Worktree or project switches do NOT reload extensions, matching pi's
// `.pi/extensions/` behavior. Only re-launching the CLI rebinds the snapshot.
import type {
BeforeAgentStartEvent,
ExtensionAPI,
ExtensionHandler,
} from "@singularity-forge/pi-coding-agent";
// Structural mirror of pi's internal BeforeAgentStartEventResult. The internal
// type is not re-exported from the package root, and constraint #3 forbids
// changes to packages/pi-coding-agent/, so we mirror the public shape here.
// `any` on inner fields keeps assignability bidirectional with pi's stricter
// `Pick<CustomMessage, ...>` shape (CustomMessage is also not re-exported).
// Source of truth: packages/pi-coding-agent/src/core/extensions/types.ts
export interface BeforeAgentStartEventResult {
message?: {
customType: string;
content?: any;
display?: any;
details?: any;
};
systemPrompt?: string;
}
import type { SFActiveUnit, SFState, Phase } from "../types.js";
import { isSFActive, getCurrentPhase } from "../../shared/sf-phase-state.js";
import { logWarning } from "../workflow-logger.js";
// ─── Public Interface ───────────────────────────────────────────────────
export interface SFExtensionAPI extends ExtensionAPI {
/** Current SF workflow phase, or null if no project state. */
getPhase(): Phase | null;
/** Currently active milestone/slice/task triple, or null if none. */
getActiveUnit(): SFActiveUnit | null;
}
export type SFEcosystemBeforeAgentStartHandler = ExtensionHandler<
BeforeAgentStartEvent,
BeforeAgentStartEventResult
>;
// ─── Auto-loop phase mapping ────────────────────────────────────────────
const AUTO_LOOP_PHASE_MAP: Record<string, Phase> = {
"plan-milestone": "planning",
"plan-slice": "planning",
"research": "researching",
"discuss": "discussing",
"execute-task": "executing",
"verify": "verifying",
"summarize-task": "summarizing",
"summarize-slice": "summarizing",
"advance": "advancing",
"validate-milestone": "validating-milestone",
"complete-milestone": "completing-milestone",
"replan-slice": "replanning-slice",
};
/** Exposed for unit tests. Returns null for unknown keys (does NOT default). */
export function mapAutoLoopPhase(raw: string): Phase | null {
return AUTO_LOOP_PHASE_MAP[raw] ?? null;
}
function resolvePhase(state: SFState | null): Phase | null {
if (!state) return null;
if (isSFActive()) {
const raw = getCurrentPhase();
if (raw != null) {
const mapped = AUTO_LOOP_PHASE_MAP[raw];
if (mapped) return mapped;
logWarning("ecosystem", `unknown auto-loop phase: ${raw}`);
// FALL THROUGH to state.phase rather than defaulting to "executing".
}
}
return state.phase;
}
function resolveActiveUnit(state: SFState | null): SFActiveUnit | null {
if (!state) return null;
const m = state.activeMilestone;
const s = state.activeSlice;
const t = state.activeTask;
if (!m || !s || !t) return null;
return {
milestoneId: m.id,
milestoneTitle: m.title,
sliceId: s.id,
sliceTitle: s.title,
taskId: t.id,
taskTitle: t.title,
};
}
// ─── Module-level snapshot ──────────────────────────────────────────────
interface Snapshot {
phase: Phase | null;
activeUnit: SFActiveUnit | null;
}
let _snapshot: Snapshot = { phase: null, activeUnit: null };
/** Refresh the snapshot from a freshly derived SFState (or null on failure). */
export function updateSnapshot(state: SFState | null): void {
_snapshot = {
phase: resolvePhase(state),
activeUnit: resolveActiveUnit(state),
};
}
export function getSnapshotPhase(): Phase | null {
return _snapshot.phase;
}
export function getSnapshotActiveUnit(): SFActiveUnit | null {
return _snapshot.activeUnit;
}
/** Test-only: reset the snapshot to its initial empty state. */
export function _resetSnapshot(): void {
_snapshot = { phase: null, activeUnit: null };
}
// ─── Wrapper factory ────────────────────────────────────────────────────
/**
* Build an SFExtensionAPI by manually delegating every ExtensionAPI method
* to the underlying pi instance, except `on("before_agent_start", ...)`
* which is captured into `sharedHandlers` for SF-owned dispatch.
*
* Uses `satisfies SFExtensionAPI` (NOT `as`) so TypeScript catches drift
* when pi adds new ExtensionAPI methods.
*/
export function createSFExtensionAPI(
pi: ExtensionAPI,
sharedHandlers: SFEcosystemBeforeAgentStartHandler[],
): SFExtensionAPI {
const wrapper = {
// ── Event subscription (single intercept point) ────────────────────
on(event: any, handler: any): void {
if (event === "before_agent_start") {
sharedHandlers.push(handler as SFEcosystemBeforeAgentStartHandler);
return;
}
(pi.on as (e: any, h: any) => void)(event, handler);
},
// ── Event emission ─────────────────────────────────────────────────
emitBeforeModelSelect: (...args: Parameters<ExtensionAPI["emitBeforeModelSelect"]>) =>
pi.emitBeforeModelSelect(...args),
emitAdjustToolSet: (...args: Parameters<ExtensionAPI["emitAdjustToolSet"]>) =>
pi.emitAdjustToolSet(...args),
// ── Tool / command / shortcut / flag registration ──────────────────
registerTool: ((tool: any) => pi.registerTool(tool)) as ExtensionAPI["registerTool"],
registerCommand: (...args: Parameters<ExtensionAPI["registerCommand"]>) =>
pi.registerCommand(...args),
registerBeforeInstall: (...args: Parameters<ExtensionAPI["registerBeforeInstall"]>) =>
pi.registerBeforeInstall(...args),
registerAfterInstall: (...args: Parameters<ExtensionAPI["registerAfterInstall"]>) =>
pi.registerAfterInstall(...args),
registerBeforeRemove: (...args: Parameters<ExtensionAPI["registerBeforeRemove"]>) =>
pi.registerBeforeRemove(...args),
registerAfterRemove: (...args: Parameters<ExtensionAPI["registerAfterRemove"]>) =>
pi.registerAfterRemove(...args),
registerShortcut: (...args: Parameters<ExtensionAPI["registerShortcut"]>) =>
pi.registerShortcut(...args),
registerFlag: (...args: Parameters<ExtensionAPI["registerFlag"]>) =>
pi.registerFlag(...args),
getFlag: (...args: Parameters<ExtensionAPI["getFlag"]>) => pi.getFlag(...args),
// ── Message rendering ──────────────────────────────────────────────
registerMessageRenderer: ((customType: string, renderer: any) =>
pi.registerMessageRenderer(customType, renderer)) as ExtensionAPI["registerMessageRenderer"],
// ── Actions ────────────────────────────────────────────────────────
sendMessage: ((message: any, options?: any) =>
pi.sendMessage(message, options)) as ExtensionAPI["sendMessage"],
sendUserMessage: (...args: Parameters<ExtensionAPI["sendUserMessage"]>) =>
pi.sendUserMessage(...args),
retryLastTurn: () => pi.retryLastTurn(),
appendEntry: ((customType: string, data?: any) =>
pi.appendEntry(customType, data)) as ExtensionAPI["appendEntry"],
// ── Session metadata ───────────────────────────────────────────────
setSessionName: (...args: Parameters<ExtensionAPI["setSessionName"]>) =>
pi.setSessionName(...args),
getSessionName: () => pi.getSessionName(),
setLabel: (...args: Parameters<ExtensionAPI["setLabel"]>) => pi.setLabel(...args),
exec: (...args: Parameters<ExtensionAPI["exec"]>) => pi.exec(...args),
getActiveTools: () => pi.getActiveTools(),
getAllTools: () => pi.getAllTools(),
setActiveTools: (...args: Parameters<ExtensionAPI["setActiveTools"]>) =>
pi.setActiveTools(...args),
getCommands: () => pi.getCommands(),
// ── Model & thinking ───────────────────────────────────────────────
setModel: (...args: Parameters<ExtensionAPI["setModel"]>) => pi.setModel(...args),
getThinkingLevel: () => pi.getThinkingLevel(),
setThinkingLevel: (...args: Parameters<ExtensionAPI["setThinkingLevel"]>) =>
pi.setThinkingLevel(...args),
// ── Provider registration ──────────────────────────────────────────
registerProvider: (...args: Parameters<ExtensionAPI["registerProvider"]>) =>
pi.registerProvider(...args),
unregisterProvider: (...args: Parameters<ExtensionAPI["unregisterProvider"]>) =>
pi.unregisterProvider(...args),
// ── Shared event bus (passthrough property) ────────────────────────
events: pi.events,
// ── SF-specific additions ──────────────────────────────────────────
getPhase: (): Phase | null => _snapshot.phase,
getActiveUnit: (): SFActiveUnit | null => _snapshot.activeUnit,
} satisfies SFExtensionAPI;
return wrapper;
}

View file

@ -217,6 +217,15 @@ export interface ActiveRef {
title: string;
}
export interface SFActiveUnit {
milestoneId: string;
milestoneTitle: string;
sliceId: string;
sliceTitle: string;
taskId: string;
taskTitle: string;
}
export interface MilestoneRegistryEntry {
id: string;
title: string;

View file

@ -53,7 +53,8 @@ export type LogComponent =
| "guided" // Guided flow (discuss, plan wizards)
| "registry" // Rule registry hook state
| "renderer" // Markdown renderer and projections
| "safety"; // LLM safety harness
| "safety" // LLM safety harness
| "ecosystem"; // Third-party .sf/extensions/ plugins
export interface LogEntry {
ts: string;