From d4cdcb582d12e1e7b8d61ea75c33cc7cd9840809 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 25 Apr 2026 08:27:55 +0200 Subject: [PATCH] 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 --- .../sf/bootstrap/register-extension.ts | 14 +- .../extensions/sf/bootstrap/register-hooks.ts | 28 ++- .../extensions/sf/ecosystem/loader.ts | 201 +++++++++++++++ .../sf/ecosystem/sf-extension-api.ts | 228 ++++++++++++++++++ src/resources/extensions/sf/types.ts | 9 + .../extensions/sf/workflow-logger.ts | 3 +- 6 files changed, 480 insertions(+), 3 deletions(-) create mode 100644 src/resources/extensions/sf/ecosystem/loader.ts create mode 100644 src/resources/extensions/sf/ecosystem/sf-extension-api.ts diff --git a/src/resources/extensions/sf/bootstrap/register-extension.ts b/src/resources/extensions/sf/bootstrap/register-extension.ts index c5f42904e..075cd96f8 100644 --- a/src/resources/extensions/sf/bootstrap/register-extension.ts +++ b/src/resources/extensions/sf/bootstrap/register-extension.ts @@ -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) { diff --git a/src/resources/extensions/sf/bootstrap/register-hooks.ts b/src/resources/extensions/sf/bootstrap/register-hooks.ts index 4808cd595..63d2484dc 100644 --- a/src/resources/extensions/sf/bootstrap/register-hooks.ts +++ b/src/resources/extensions/sf/bootstrap/register-hooks.ts @@ -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 { 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); }); diff --git a/src/resources/extensions/sf/ecosystem/loader.ts b/src/resources/extensions/sf/ecosystem/loader.ts new file mode 100644 index 000000000..48a21e7de --- /dev/null +++ b/src/resources/extensions/sf/ecosystem/loader.ts @@ -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 | 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 { + 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 { + 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 { + 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 { + 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)}`, + ); + } +} diff --git a/src/resources/extensions/sf/ecosystem/sf-extension-api.ts b/src/resources/extensions/sf/ecosystem/sf-extension-api.ts new file mode 100644 index 000000000..a1f80fab0 --- /dev/null +++ b/src/resources/extensions/sf/ecosystem/sf-extension-api.ts @@ -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` 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 = { + "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) => + pi.emitBeforeModelSelect(...args), + emitAdjustToolSet: (...args: Parameters) => + pi.emitAdjustToolSet(...args), + + // ── Tool / command / shortcut / flag registration ────────────────── + registerTool: ((tool: any) => pi.registerTool(tool)) as ExtensionAPI["registerTool"], + registerCommand: (...args: Parameters) => + pi.registerCommand(...args), + registerBeforeInstall: (...args: Parameters) => + pi.registerBeforeInstall(...args), + registerAfterInstall: (...args: Parameters) => + pi.registerAfterInstall(...args), + registerBeforeRemove: (...args: Parameters) => + pi.registerBeforeRemove(...args), + registerAfterRemove: (...args: Parameters) => + pi.registerAfterRemove(...args), + registerShortcut: (...args: Parameters) => + pi.registerShortcut(...args), + registerFlag: (...args: Parameters) => + pi.registerFlag(...args), + getFlag: (...args: Parameters) => 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) => + pi.sendUserMessage(...args), + retryLastTurn: () => pi.retryLastTurn(), + appendEntry: ((customType: string, data?: any) => + pi.appendEntry(customType, data)) as ExtensionAPI["appendEntry"], + + // ── Session metadata ─────────────────────────────────────────────── + setSessionName: (...args: Parameters) => + pi.setSessionName(...args), + getSessionName: () => pi.getSessionName(), + setLabel: (...args: Parameters) => pi.setLabel(...args), + exec: (...args: Parameters) => pi.exec(...args), + getActiveTools: () => pi.getActiveTools(), + getAllTools: () => pi.getAllTools(), + setActiveTools: (...args: Parameters) => + pi.setActiveTools(...args), + getCommands: () => pi.getCommands(), + + // ── Model & thinking ─────────────────────────────────────────────── + setModel: (...args: Parameters) => pi.setModel(...args), + getThinkingLevel: () => pi.getThinkingLevel(), + setThinkingLevel: (...args: Parameters) => + pi.setThinkingLevel(...args), + + // ── Provider registration ────────────────────────────────────────── + registerProvider: (...args: Parameters) => + pi.registerProvider(...args), + unregisterProvider: (...args: Parameters) => + 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; +} diff --git a/src/resources/extensions/sf/types.ts b/src/resources/extensions/sf/types.ts index 951a43443..7599c35e5 100644 --- a/src/resources/extensions/sf/types.ts +++ b/src/resources/extensions/sf/types.ts @@ -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; diff --git a/src/resources/extensions/sf/workflow-logger.ts b/src/resources/extensions/sf/workflow-logger.ts index f82beeb2e..4068fb6c0 100644 --- a/src/resources/extensions/sf/workflow-logger.ts +++ b/src/resources/extensions/sf/workflow-logger.ts @@ -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;