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:
parent
6c36d62f35
commit
d4cdcb582d
6 changed files with 480 additions and 3 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
201
src/resources/extensions/sf/ecosystem/loader.ts
Normal file
201
src/resources/extensions/sf/ecosystem/loader.ts
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
228
src/resources/extensions/sf/ecosystem/sf-extension-api.ts
Normal file
228
src/resources/extensions/sf/ecosystem/sf-extension-api.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue