feat(FN-2962): merge fusion/fn-2962

- Add changeset for `@runfusion/fusion` minor release introducing custom provider registration support

Commits merged:
- feat(FN-2962): complete Step 8 — add changeset and documentation

Files changed:
.changeset/register-custom-providers.md | 5 +++++
 1 file changed, 5 insertions(+)

Fusion-Task-Id: FN-2962
This commit is contained in:
Fusion
2026-04-29 21:42:44 -07:00
committed by gsxdsm
parent 6c051b1851
commit 64b5f677ff
33 changed files with 1048 additions and 60 deletions

View File

@@ -0,0 +1,167 @@
import { describe, expect, it, vi } from "vitest";
import type { CustomProvider } from "@fusion/core";
import {
registerCustomProviders,
reregisterCustomProviders,
resolveApiType,
} from "../custom-provider-registry.js";
describe("custom-provider-registry", () => {
it.each([
["openai-compatible", "openai-completions"],
["anthropic-compatible", "anthropic"],
])("resolveApiType maps %s -> %s", (apiType, expectedApi) => {
expect(resolveApiType(apiType)).toBe(expectedApi);
});
it("registers providers with expected config shape", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
const logFn = vi.fn();
const providers: CustomProvider[] = [
{
id: "openai-custom",
name: "OpenAI Custom",
apiType: "openai-compatible",
baseUrl: "https://example.test/v1",
apiKey: "CUSTOM_KEY",
models: [{ id: "m1", name: "Model 1" }],
},
{
id: "anthropic-custom",
name: "Anthropic Custom",
apiType: "anthropic-compatible",
baseUrl: "https://anthropic.test",
apiKey: "ANTHROPIC_KEY",
models: [{ id: "claude-x", name: "Claude X" }],
},
];
registerCustomProviders({ registerProvider, refresh }, providers, logFn);
expect(registerProvider).toHaveBeenNthCalledWith(1, "openai-custom", expect.objectContaining({
baseUrl: "https://example.test/v1",
api: "openai-completions",
apiKey: "CUSTOM_KEY",
models: [expect.objectContaining({ id: "m1", name: "Model 1" })],
}));
expect(registerProvider).toHaveBeenNthCalledWith(2, "anthropic-custom", expect.objectContaining({
baseUrl: "https://anthropic.test",
api: "anthropic",
apiKey: "ANTHROPIC_KEY",
models: [expect.objectContaining({ id: "claude-x", name: "Claude X" })],
}));
expect(refresh).toHaveBeenCalledTimes(1);
});
it("handles empty provider list and still refreshes", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
registerCustomProviders({ registerProvider, refresh }, [], vi.fn());
expect(registerProvider).not.toHaveBeenCalled();
expect(refresh).toHaveBeenCalledTimes(1);
});
it("uses empty models when models is missing", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
registerCustomProviders(
{ registerProvider, refresh },
[{
id: "no-models",
name: "No Models",
apiType: "openai-compatible",
baseUrl: "https://nomodels.test",
}],
vi.fn(),
);
expect(registerProvider).toHaveBeenCalledWith("no-models", expect.objectContaining({ models: [] }));
expect(refresh).toHaveBeenCalledTimes(1);
});
it("continues when one provider registration fails", () => {
const registerProvider = vi
.fn()
.mockImplementationOnce(() => {
throw new Error("boom");
})
.mockImplementationOnce(() => undefined);
const refresh = vi.fn();
const logFn = vi.fn();
registerCustomProviders(
{ registerProvider, refresh },
[
{
id: "bad",
name: "Bad",
apiType: "openai-compatible",
baseUrl: "https://bad.test",
},
{
id: "good",
name: "Good",
apiType: "openai-compatible",
baseUrl: "https://good.test",
},
],
logFn,
);
expect(registerProvider).toHaveBeenCalledTimes(2);
expect(logFn).toHaveBeenCalledWith(expect.stringContaining("Failed to register custom provider bad"));
expect(refresh).toHaveBeenCalledTimes(1);
});
it("reregisters new providers", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
reregisterCustomProviders(
{ registerProvider, refresh },
[{ id: "old", name: "Old", apiType: "openai-compatible", baseUrl: "https://old.test" }],
[
{ id: "old", name: "Old", apiType: "openai-compatible", baseUrl: "https://old.test" },
{ id: "new", name: "New", apiType: "anthropic-compatible", baseUrl: "https://new.test" },
],
vi.fn(),
);
expect(registerProvider).toHaveBeenCalledTimes(1);
expect(registerProvider).toHaveBeenCalledWith("new", expect.objectContaining({ api: "anthropic" }));
expect(refresh).toHaveBeenCalledTimes(1);
});
it("reregisters changed providers", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
reregisterCustomProviders(
{ registerProvider, refresh },
[{ id: "same-id", name: "Provider", apiType: "openai-compatible", baseUrl: "https://one.test", apiKey: "A" }],
[{ id: "same-id", name: "Provider", apiType: "openai-compatible", baseUrl: "https://two.test", apiKey: "B" }],
vi.fn(),
);
expect(registerProvider).toHaveBeenCalledTimes(1);
expect(registerProvider).toHaveBeenCalledWith("same-id", expect.objectContaining({
baseUrl: "https://two.test",
apiKey: "B",
}));
expect(refresh).toHaveBeenCalledTimes(1);
});
it("handles empty previous/current arrays", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
reregisterCustomProviders({ registerProvider, refresh }, [], [], vi.fn());
expect(registerProvider).not.toHaveBeenCalled();
expect(refresh).toHaveBeenCalledTimes(1);
});
});

View File

@@ -86,6 +86,10 @@ function makeMockStore() {
logEntry: vi.fn().mockResolvedValue(undefined),
updateTask: vi.fn().mockResolvedValue({}),
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
getGlobalSettingsStore: vi.fn(() => ({
getSettings: mockGlobalSettingsGetSettings,
updateSettings: mockGlobalSettingsUpdateSettings,
})),
getActiveMergingTask: vi.fn().mockReturnValue(undefined),
getMissionStore: vi.fn().mockReturnValue(mockMissionStore),
close: vi.fn(),

View File

@@ -73,6 +73,9 @@ const mocks = vi.hoisted(() => {
watch: vi.fn().mockResolvedValue(undefined),
close: vi.fn(),
getFusionDir: vi.fn().mockReturnValue(`/repo${projectId ? `/${projectId}` : ""}/.fusion`),
getGlobalSettingsStore: vi.fn(() => ({
getSettings: vi.fn().mockResolvedValue({}),
})),
getMissionStore: vi.fn().mockReturnValue(missionStore),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,

View File

@@ -0,0 +1,96 @@
import type { CustomProvider } from "@fusion/core";
interface ModelRegistryLike {
registerProvider: (name: string, config: {
baseUrl: string;
api: string;
apiKey?: string;
models: Array<{
id: string;
name: string;
reasoning: boolean;
input: ("text" | "image")[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
maxTokens: number;
}>;
}) => void;
refresh: () => void;
}
export function resolveApiType(apiType: string): string {
if (apiType === "anthropic-compatible") {
return "anthropic";
}
return "openai-completions";
}
function toProviderConfig(provider: CustomProvider) {
return {
baseUrl: provider.baseUrl,
api: resolveApiType(provider.apiType),
apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({
id: model.id,
name: model.name,
reasoning: false,
input: ["text" as const],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 16384,
})),
};
}
function providersDiffer(previous: CustomProvider, current: CustomProvider): boolean {
return JSON.stringify(toProviderConfig(previous)) !== JSON.stringify(toProviderConfig(current));
}
export function registerCustomProviders(
modelRegistry: ModelRegistryLike,
customProviders: CustomProvider[] | undefined,
logFn: (message: string) => void,
): void {
for (const provider of customProviders ?? []) {
try {
modelRegistry.registerProvider(provider.id, toProviderConfig(provider));
logFn(`Registered custom provider ${provider.id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logFn(`Failed to register custom provider ${provider.id}: ${message}`);
}
}
modelRegistry.refresh();
}
export function reregisterCustomProviders(
modelRegistry: ModelRegistryLike,
previousProviders: CustomProvider[] | undefined,
currentProviders: CustomProvider[] | undefined,
logFn: (message: string) => void,
): void {
const previousById = new Map((previousProviders ?? []).map((provider) => [provider.id, provider]));
for (const provider of currentProviders ?? []) {
const previous = previousById.get(provider.id);
if (previous && !providersDiffer(previous, provider)) {
continue;
}
try {
modelRegistry.registerProvider(provider.id, toProviderConfig(provider));
logFn(`${previous ? "Updated" : "Registered"} custom provider ${provider.id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logFn(`Failed to register custom provider ${provider.id}: ${message}`);
}
}
modelRegistry.refresh();
}

View File

@@ -49,6 +49,7 @@ import {
} from "./claude-cli-extension.js";
import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js";
import { resolveSelfExtension } from "./self-extension.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js";
// Re-export for backward compatibility with tests
@@ -1244,6 +1245,18 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
extensionsResult.runtime.pendingProviderRegistrations = [];
modelRegistry.refresh();
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
registerCustomProviders(
modelRegistry,
globalSettings.customProviders,
(message) => logSink.log(message, "custom-providers"),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logSink.warn(`Failed to load custom providers from global settings: ${message}`, "custom-providers");
}
// Eagerly sync OpenRouter models — the pi-openrouter-realtime extension
// only registers providers on session_start (TUI-only event), so kick off
// a fetch here so the dashboard model list is populated. Respects the
@@ -1293,6 +1306,21 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
modelRegistry.refresh();
}
registerHandler(store, "settings:updated", ({ settings, previous }) => {
const currentProviders = settings.customProviders;
const previousProviders = previous.customProviders;
if (JSON.stringify(currentProviders ?? []) === JSON.stringify(previousProviders ?? [])) {
return;
}
reregisterCustomProviders(
modelRegistry,
previousProviders,
currentProviders,
(message) => logSink.log(message, "custom-providers"),
);
});
// ── Skills adapter for skills discovery and execution toggling ─────────────
//
// Create the skills adapter using the same DefaultPackageManager instance

View File

@@ -53,6 +53,7 @@ import {
setCachedClaudeCliResolution,
} from "./claude-cli-extension.js";
import { resolveSelfExtension } from "./self-extension.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
@@ -522,6 +523,18 @@ export async function runServe(
extensionsResult.runtime.pendingProviderRegistrations = [];
modelRegistry.refresh();
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
registerCustomProviders(
modelRegistry,
globalSettings.customProviders,
(message) => console.log(`[custom-providers] ${message}`),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[custom-providers] Failed to load custom providers from global settings: ${message}`);
}
(async () => {
try {
const settings = await store.getSettings();
@@ -606,6 +619,21 @@ export async function runServe(
modelRegistry.refresh();
}
store.on("settings:updated", ({ settings, previous }) => {
const currentProviders = settings.customProviders;
const previousProviders = previous.customProviders;
if (JSON.stringify(currentProviders ?? []) === JSON.stringify(previousProviders ?? [])) {
return;
}
reregisterCustomProviders(
modelRegistry,
previousProviders,
currentProviders,
(message) => console.log(`[custom-providers] ${message}`),
);
});
// ── Daemon token resolution ─────────────────────────────────────────────
//
// When --daemon flag is set, resolve the daemon token using the same

View File

@@ -131,7 +131,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(53);
expect(db.getSchemaVersion()).toBe(54);
});
it("seeds lastModified", () => {
@@ -154,7 +154,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(53);
expect(db.getSchemaVersion()).toBe(54);
});
it("does not overwrite existing config on re-init", () => {
@@ -761,7 +761,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(53);
expect(db.getSchemaVersion()).toBe(54);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -786,11 +786,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(53);
expect(db.getSchemaVersion()).toBe(54);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(53);
expect(db.getSchemaVersion()).toBe(54);
db.close();
});
@@ -825,7 +825,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(53);
expect(db.getSchemaVersion()).toBe(54);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -866,7 +866,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(53);
expect(db.getSchemaVersion()).toBe(54);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -935,7 +935,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(53);
expect(db.getSchemaVersion()).toBe(54);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -994,7 +994,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(53);
expect(db.getSchemaVersion()).toBe(54);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1068,7 +1068,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(53);
expect(db.getSchemaVersion()).toBe(54);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1092,7 +1092,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(53);
expect(db.getSchemaVersion()).toBe(54);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -1196,7 +1196,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(53);
expect(db.getSchemaVersion()).toBe(54);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1647,7 +1647,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(53);
expect(db.getSchemaVersion()).toBe(54);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -779,7 +779,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(53);
expect(db1.getSchemaVersion()).toBe(54);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -814,7 +814,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(53);
expect(db3.getSchemaVersion()).toBe(54);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -845,12 +845,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(53);
expect(db1.getSchemaVersion()).toBe(54);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(53);
expect(db2.getSchemaVersion()).toBe(54);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });

View File

@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(53);
expect(db.getSchemaVersion()).toBe(54);
});
it("mission_features table has loop state columns", () => {

View File

@@ -742,7 +742,7 @@ describe("RoadmapStore", () => {
describe("schema version", () => {
it("schema version is 40 after init", () => {
expect(db.getSchemaVersion()).toBe(53);
expect(db.getSchemaVersion()).toBe(54);
});
});

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(53);
expect(db.getSchemaVersion()).toBe(54);
});
});
});

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(53);
expect(db.getSchemaVersion()).toBe(54);
const index = db
.prepare(

View File

@@ -36,7 +36,7 @@ import type {
AgentRatingInput,
Task,
} from "./types.js";
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, CheckoutConflictError } from "./types.js";
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH } from "./types.js";
import type { RunMutationContext } from "./types.js";
import type { TaskStore } from "./store.js";
import { computeAccessState } from "./agent-permissions.js";
@@ -115,6 +115,7 @@ interface AgentData {
soul?: string;
memory?: string;
bundleConfig?: InstructionsBundleConfig;
heartbeatProcedurePath?: string;
}
interface AgentRow {
id: string;
@@ -374,6 +375,13 @@ export class AgentStore extends EventEmitter {
const metadata = input.metadata ?? {};
const runtimeConfig = resolveCreationRuntimeConfig(input.runtimeConfig, metadata);
// Default heartbeatProcedurePath for new non-ephemeral agents so operators
// get an editable HEARTBEAT.md file from day one. Ephemeral task workers
// skip this — they're short-lived and don't need persistent procedure files.
const ephemeral = isEphemeralAgent({ metadata, name: input.name, role: input.role, reportsTo: input.reportsTo });
const resolvedHeartbeatProcedurePath = input.heartbeatProcedurePath
?? (ephemeral ? undefined : DEFAULT_HEARTBEAT_PROCEDURE_PATH);
const agent: Agent = {
id: agentId,
name: input.name.trim(),
@@ -392,6 +400,7 @@ export class AgentStore extends EventEmitter {
...(input.soul && { soul: input.soul }),
...(input.memory && { memory: input.memory }),
...(input.bundleConfig && { bundleConfig: input.bundleConfig }),
...(resolvedHeartbeatProcedurePath && { heartbeatProcedurePath: resolvedHeartbeatProcedurePath }),
};
await this.writeAgent(agent);
@@ -841,6 +850,7 @@ export class AgentStore extends EventEmitter {
...(updates.soul !== undefined && { soul: updates.soul }),
...(updates.memory !== undefined && { memory: updates.memory }),
...("bundleConfig" in updates && { bundleConfig: updates.bundleConfig }),
...("heartbeatProcedurePath" in updates && { heartbeatProcedurePath: updates.heartbeatProcedurePath }),
};
await this.writeAgent(updated);
@@ -1807,6 +1817,7 @@ export class AgentStore extends EventEmitter {
| "soul"
| "memory"
| "bundleConfig"
| "heartbeatProcedurePath"
| "metadata"
> {
return {
@@ -1827,6 +1838,7 @@ export class AgentStore extends EventEmitter {
files: [...snapshot.bundleConfig.files],
}
: undefined,
heartbeatProcedurePath: snapshot.heartbeatProcedurePath,
metadata: { ...snapshot.metadata },
};
}
@@ -2007,6 +2019,7 @@ export class AgentStore extends EventEmitter {
soul: data.soul,
memory: data.memory,
bundleConfig: data.bundleConfig,
heartbeatProcedurePath: data.heartbeatProcedurePath,
};
}
@@ -2035,6 +2048,7 @@ export class AgentStore extends EventEmitter {
soul: agent.soul,
memory: agent.memory,
bundleConfig: agent.bundleConfig,
heartbeatProcedurePath: agent.heartbeatProcedurePath,
};
this.db.prepare(`

View File

@@ -86,7 +86,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 53;
const SCHEMA_VERSION = 54;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -189,6 +189,8 @@ CREATE TABLE IF NOT EXISTS tasks (
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
columnMovedAt TEXT,
executionStartedAt TEXT,
executionCompletedAt TEXT,
-- JSON columns for nested arrays/objects
dependencies TEXT DEFAULT '[]',
steps TEXT DEFAULT '[]',
@@ -1988,6 +1990,15 @@ export class Database {
});
}
// Wall-clock end-to-end execution timestamps for card runtime display.
// Set on first in-progress / done transitions, cleared only on retry.
if (version < 54) {
this.applyMigration(54, () => {
this.addColumnIfMissing("tasks", "executionStartedAt", "TEXT");
this.addColumnIfMissing("tasks", "executionCompletedAt", "TEXT");
});
}
}
/**

View File

@@ -1,4 +1,4 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy } from "./types.js";
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {

View File

@@ -69,6 +69,8 @@ interface TaskRow {
createdAt: string;
updatedAt: string;
columnMovedAt: string | null;
executionStartedAt: string | null;
executionCompletedAt: string | null;
dependencies: string | null;
steps: string | null;
log: string | null;
@@ -554,6 +556,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
createdAt: row.createdAt,
updatedAt: row.updatedAt,
columnMovedAt: row.columnMovedAt || undefined,
executionStartedAt: row.executionStartedAt || undefined,
executionCompletedAt: row.executionCompletedAt || undefined,
dependencies: fromJson<string[]>(row.dependencies) || [],
steps: fromJson<import("./types.js").TaskStep[]>(row.steps) || [],
log: fromJson<import("./types.js").TaskLogEntry[]>(row.log) || [],
@@ -662,6 +666,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
createdAt: entry.createdAt,
updatedAt: entry.updatedAt,
columnMovedAt: entry.columnMovedAt,
executionStartedAt: entry.executionStartedAt,
executionCompletedAt: entry.executionCompletedAt,
modelPresetId: entry.modelPresetId,
modelProvider: entry.modelProvider,
modelId: entry.modelId,
@@ -784,6 +790,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
createdAt: task.createdAt,
updatedAt: task.updatedAt,
columnMovedAt: task.columnMovedAt,
executionStartedAt: task.executionStartedAt,
executionCompletedAt: task.executionCompletedAt,
archivedAt,
modelPresetId: task.modelPresetId,
modelProvider: task.modelProvider,
@@ -853,7 +861,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"mergeRetries", "workflowStepRetries", "stuckKillCount", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "verificationFailureCount", "mergeConflictBounceCount", "nextRecoveryAt",
"error", "summary", "thinkingLevel", "executionMode",
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
"createdAt", "updatedAt", "columnMovedAt",
"createdAt", "updatedAt", "columnMovedAt", "executionStartedAt", "executionCompletedAt",
"dependencies", "steps", "comments", "workflowStepResults", "steeringComments",
"attachments", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
@@ -902,7 +910,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"mergeRetries", "workflowStepRetries", "stuckKillCount", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "verificationFailureCount", "mergeConflictBounceCount", "nextRecoveryAt",
"error", "summary", "thinkingLevel", "executionMode",
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
"createdAt", "updatedAt", "columnMovedAt",
"createdAt", "updatedAt", "columnMovedAt", "executionStartedAt", "executionCompletedAt",
"dependencies", "steps", "attachments", "steeringComments",
"comments", "workflowStepResults", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
@@ -945,12 +953,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, verificationFailureCount, mergeConflictBounceCount, nextRecoveryAt, error,
summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, createdAt, updatedAt, columnMovedAt,
executionStartedAt, executionCompletedAt,
dependencies, steps, log, attachments, steeringComments,
comments, workflowStepResults, prInfo, issueInfo,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
@@ -996,6 +1005,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
createdAt = excluded.createdAt,
updatedAt = excluded.updatedAt,
columnMovedAt = excluded.columnMovedAt,
executionStartedAt = excluded.executionStartedAt,
executionCompletedAt = excluded.executionCompletedAt,
dependencies = excluded.dependencies,
steps = excluded.steps,
log = excluded.log,
@@ -1075,6 +1086,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.createdAt,
task.updatedAt,
task.columnMovedAt ?? null,
task.executionStartedAt ?? null,
task.executionCompletedAt ?? null,
toJson(task.dependencies || []),
toJson(task.steps || []),
toJson(task.log || []),
@@ -2661,6 +2674,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.columnMovedAt = new Date().toISOString();
task.updatedAt = task.columnMovedAt;
// Wall-clock end-to-end runtime: set on first transition into in-progress
// and first transition into done. Never overwritten — see retry-clear
// logic below for the path that resets these for a fresh run.
if (toColumn === "in-progress" && !task.executionStartedAt) {
task.executionStartedAt = task.columnMovedAt;
}
if (toColumn === "done" && !task.executionCompletedAt) {
task.executionCompletedAt = task.columnMovedAt;
}
// Clear transient fields when moving to done (matches moveToDone behavior)
if (toColumn === "done") {
this.clearDoneTransientFields(task);
@@ -2680,6 +2703,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.error = undefined;
task.worktree = undefined;
task.blockedBy = undefined;
// Reset wall-clock runtime so the next run gets a fresh timer.
task.executionStartedAt = undefined;
task.executionCompletedAt = undefined;
this.resetAllStepsToPending(task);
await this.resetPromptCheckboxes(dir);
}
@@ -4068,6 +4094,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.clearDoneTransientFields(task);
task.columnMovedAt = new Date().toISOString();
task.updatedAt = task.columnMovedAt;
if (!task.executionCompletedAt) {
task.executionCompletedAt = task.columnMovedAt;
}
await this.atomicWriteTaskJson(dir, task);

View File

@@ -1005,6 +1005,17 @@ export interface Task {
/** ISO-8601 timestamp of when the task last entered its current column.
* Used to sort cards within a column so that recently-moved cards appear at the top. */
columnMovedAt?: string;
/** ISO-8601 wall-clock timestamp when the task first entered `in-progress`.
* Set on first transition into in-progress and never overwritten on retry,
* so cards in in-progress / in-review / done can show end-to-end runtime
* rather than just instrumented (`[timing]`) execution slices. Cleared
* alongside `executionCompletedAt` when a task is reopened from
* done/in-review back to todo/triage so a fresh run gets a fresh timer. */
executionStartedAt?: string;
/** ISO-8601 wall-clock timestamp when the task first entered `done`.
* Set on first transition into done and never overwritten. Cleared
* alongside `executionStartedAt` on reopen-for-retry. */
executionCompletedAt?: string;
createdAt: string;
updatedAt: string;
}
@@ -2005,6 +2016,10 @@ export interface ArchivedTaskEntry {
createdAt: string;
updatedAt: string;
columnMovedAt?: string;
/** Wall-clock timestamps for end-to-end runtime — preserved through archive
* so unarchived tasks still show their original execution duration. */
executionStartedAt?: string;
executionCompletedAt?: string;
/** Timestamp when the task was archived to the log */
archivedAt: string;
/** Optional: model preset and override fields for executor and validator */
@@ -2871,6 +2886,10 @@ export interface Agent {
memory?: string;
/** Structured instruction bundle configuration for managed/external markdown files. */
bundleConfig?: InstructionsBundleConfig;
/** Optional path to a markdown file containing this agent's per-tick heartbeat procedure
* (overrides the default HEARTBEAT_PROCEDURE constant). Resolved relative to project root.
* Must end in `.md`, no `..` traversal. Max 500 chars. */
heartbeatProcedurePath?: string;
}
/** Recursive node in the agent org tree. */
@@ -2973,6 +2992,7 @@ export interface AgentCreateInput {
soul?: string;
memory?: string;
bundleConfig?: InstructionsBundleConfig;
heartbeatProcedurePath?: string;
}
/** Input for updating an existing agent */
@@ -2994,6 +3014,7 @@ export interface AgentUpdateInput {
soul?: string;
memory?: string;
bundleConfig?: InstructionsBundleConfig;
heartbeatProcedurePath?: string;
}
/** An API key associated with an agent for bearer token authentication. */
@@ -3086,6 +3107,7 @@ export interface AgentConfigSnapshot {
soul?: string;
memory?: string;
bundleConfig?: InstructionsBundleConfig;
heartbeatProcedurePath?: string;
metadata: Record<string, unknown>;
}
@@ -3118,6 +3140,15 @@ export interface AgentConfigRevision {
rollbackToRevisionId?: string;
}
/**
* Project-relative default path for the per-tick heartbeat procedure markdown
* file. New non-ephemeral agents get this as their `heartbeatProcedurePath`,
* and the engine seeds the file with the built-in HEARTBEAT_PROCEDURE constant
* on first use so operators can edit it freely. Existing agents can be
* upgraded onto this path via the dashboard's "Upgrade Heartbeat" action.
*/
export const DEFAULT_HEARTBEAT_PROCEDURE_PATH = ".fusion/HEARTBEAT.md";
/** Extract trackable config fields from an Agent into a snapshot */
export function agentToConfigSnapshot(agent: Agent): AgentConfigSnapshot {
return {
@@ -3138,6 +3169,7 @@ export function agentToConfigSnapshot(agent: Agent): AgentConfigSnapshot {
files: [...agent.bundleConfig.files],
}
: undefined,
heartbeatProcedurePath: agent.heartbeatProcedurePath,
metadata: { ...agent.metadata },
};
}
@@ -3160,6 +3192,7 @@ export function diffConfigSnapshots(
"soul",
"memory",
"bundleConfig",
"heartbeatProcedurePath",
"metadata",
];

View File

@@ -4229,6 +4229,17 @@ export function updateAgent(agentId: string, updates: AgentUpdateInput, projectI
});
}
/** Backfill an existing agent onto the default heartbeat procedure file. */
export function upgradeAgentHeartbeatProcedure(
agentId: string,
projectId?: string,
): Promise<{ agent: Agent; heartbeatProcedurePath: string; procedureFileSeeded: boolean }> {
return api(
withProjectId(`/agents/${encodeURIComponent(agentId)}/upgrade-heartbeat-procedure`, projectId),
{ method: "POST" },
);
}
/** Update agent custom instructions */
export function updateAgentInstructions(
agentId: string,

View File

@@ -9,7 +9,7 @@ import {
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, upgradeAgentHeartbeatProcedure } from "../api";
import type { Agent } from "../api";
import type { AgentLogEntry, Task } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
@@ -2509,14 +2509,93 @@ function deriveBudgetValues(runtimeConfig: AgentDetail["runtimeConfig"] | undefi
return nextValues;
}
function ConfigTab({
function HeartbeatProcedureSection({
agent,
projectId,
addToast,
onSaved,
}: {
agent: AgentDetail;
projectId?: string;
addToast: (message: string, type?: "success" | "error") => void;
onSaved: () => Promise<void>;
}) {
const [isUpgrading, setIsUpgrading] = useState(false);
const currentPath = agent.heartbeatProcedurePath?.trim();
const onDefault = currentPath === ".fusion/HEARTBEAT.md";
const handleUpgrade = async () => {
setIsUpgrading(true);
try {
const result = await upgradeAgentHeartbeatProcedure(agent.id, projectId);
addToast(
result.procedureFileSeeded
? `Heartbeat procedure file ready at ${result.heartbeatProcedurePath}`
: `Heartbeat procedure path set to ${result.heartbeatProcedurePath}`,
"success",
);
await onSaved();
} catch (err) {
addToast(`Failed to upgrade heartbeat procedure: ${getErrorMessage(err)}`, "error");
} finally {
setIsUpgrading(false);
}
};
return (
<div className="config-section">
<h3>Heartbeat Procedure</h3>
<p className="config-description">
The per-tick procedure this agent runs every wake. Defaults to a project-level
markdown file you can edit. Resets on every tick — no need to restart the agent
after editing.
</p>
<div className="config-fields">
<div className="config-field">
<span className="config-hint">
Current path: <code>{currentPath || "(none — using built-in default)"}</code>
</span>
</div>
<div className="config-field">
<button
className="btn"
disabled={isUpgrading || onDefault}
onClick={() => void handleUpgrade()}
aria-label="Upgrade agent to default heartbeat procedure file"
>
{isUpgrading ? (
<>
<Loader2 size={16} className="animate-spin" />
Upgrading…
</>
) : onDefault ? (
<>
<CheckCircle size={16} />
Already on default
</>
) : (
"Upgrade to Default Heartbeat Procedure"
)}
</button>
<span className="config-hint">
Sets <code>heartbeatProcedurePath</code> to <code>.fusion/HEARTBEAT.md</code>
{" "}and seeds the file from the built-in template if it doesn't exist.
Operator edits to the file are preserved.
</span>
</div>
</div>
</div>
);
}
function ConfigTab({
agent,
projectId,
addToast,
onSaved,
onHasChangesChange,
onDelete,
}: {
}: {
agent: AgentDetail;
projectId?: string;
addToast: (message: string, type?: "success" | "error") => void;
@@ -3603,6 +3682,13 @@ function ConfigTab({
</div>
</div>
<HeartbeatProcedureSection
agent={agent}
projectId={projectId}
addToast={addToast}
onSaved={onSaved}
/>
<div className="config-section config-section--danger">
<h3>Danger Zone</h3>
<p className="config-description">

View File

@@ -746,7 +746,14 @@ export function ModelOnboardingModal({
const loadCustomProviders = useCallback(async () => {
try {
const data = await fetchCustomProviders();
setCustomProviders(data.providers ?? []);
setCustomProviders((data.providers ?? []).map((provider) => ({
id: provider.id,
name: provider.name,
baseUrl: provider.baseUrl,
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-completions",
apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
})));
} catch {
// best effort
}

View File

@@ -786,7 +786,14 @@ export function SettingsModal({
if (activeSection === "authentication") {
setAuthLoading(true);
loadAuthStatus().finally(() => setAuthLoading(false));
void fetchCustomProviders().then((data) => setCustomProviders(data.providers ?? [])).catch(() => undefined);
void fetchCustomProviders().then((data) => setCustomProviders((data.providers ?? []).map((provider) => ({
id: provider.id,
name: provider.name,
baseUrl: provider.baseUrl,
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-completions",
apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
})))).catch(() => undefined);
}
// Clean up polling when leaving auth section
return () => {
@@ -799,7 +806,14 @@ export function SettingsModal({
const loadCustomProviders = useCallback(async () => {
const data = await fetchCustomProviders();
setCustomProviders(data.providers ?? []);
setCustomProviders((data.providers ?? []).map((provider) => ({
id: provider.id,
name: provider.name,
baseUrl: provider.baseUrl,
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-completions",
apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
})));
}, []);
const handleSaveCustomProvider = useCallback(async (config: CustomProviderConfig) => {

View File

@@ -125,6 +125,21 @@ function getInProgressElapsedMs(task: Task, nowMs: number): number | null {
return Math.max(0, nowMs - startedMs);
}
// Wall-clock end-to-end runtime: from when the task first entered in-progress
// to when it first entered done (or `now` if not yet done). Preferred over the
// instrumented `[timing]` sum on cards in in-progress / in-review / done so the
// timer reflects how long the task actually took, not just the time spent
// inside instrumented code paths. Returns null on legacy tasks that completed
// before `executionStartedAt` was tracked, so callers can fall back.
function getEndToEndDurationMs(task: Task, nowMs: number): number | null {
const startedMs = parseTimestampToMs(task.executionStartedAt);
if (startedMs == null) return null;
const completedMs = parseTimestampToMs(task.executionCompletedAt);
const endMs = completedMs != null && completedMs >= startedMs ? completedMs : nowMs;
return Math.max(0, endMs - startedMs);
}
// Mirrors summarizeWorkflowTiming in TaskTokenStatsPanel: completed steps use
// completedAt-startedAt; in-progress steps contribute live elapsed (now-startedAt).
function getWorkflowRuntimeMs(task: Task, nowMs: number): number | null {
@@ -697,16 +712,18 @@ function TaskCardComponent({
const merging = task.status != null && ACTIVE_MERGE_STATUSES.has(task.status);
if (!merging && task.column === "in-progress") {
const endToEndMs = getEndToEndDurationMs(task, Date.now());
const elapsedMs = getInProgressElapsedMs(task, Date.now());
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
if (elapsedMs == null && instrumentedMs == null) {
if (endToEndMs == null && elapsedMs == null && instrumentedMs == null) {
return;
}
}
if (!merging && task.column === "in-review") {
const endToEndMs = getEndToEndDurationMs(task, Date.now());
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
if (instrumentedMs == null) {
if (endToEndMs == null && instrumentedMs == null) {
return;
}
}
@@ -717,7 +734,7 @@ function TaskCardComponent({
}, LIVE_TIME_INDICATOR_POLL_MS);
return () => window.clearInterval(interval);
}, [task.column, task.status, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs]);
}, [task.column, task.status, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs, task.executionStartedAt, task.executionCompletedAt]);
const timeIndicator = useMemo(() => {
if (!TIME_INDICATOR_COLUMNS.has(task.column)) {
@@ -744,8 +761,12 @@ function TaskCardComponent({
}
if (task.column === "in-progress") {
// Prefer the persistent execution start (set on first transition to
// in-progress, never reset on retry-loop bounces). Fall back to the
// columnMovedAt heuristic for legacy tasks predating the new field.
const elapsedMs =
getInProgressElapsedMs(task, timeIndicatorNowMs)
getEndToEndDurationMs(task, timeIndicatorNowMs)
?? getInProgressElapsedMs(task, timeIndicatorNowMs)
?? getInstrumentedDurationMs(task, timeIndicatorNowMs);
if (elapsedMs == null) {
return null;
@@ -756,20 +777,23 @@ function TaskCardComponent({
return null;
}
const hasColumnElapsed = getInProgressElapsedMs(task, timeIndicatorNowMs) != null;
return {
label: elapsedLabel,
title: hasColumnElapsed ? `In progress ${elapsedLabel}` : `Execution time ${elapsedLabel}`,
ariaLabel: hasColumnElapsed ? `In progress ${elapsedLabel}` : `Execution time ${elapsedLabel}`,
title: `In progress ${elapsedLabel}`,
ariaLabel: `In progress ${elapsedLabel}`,
};
}
const instrumentedMs = getInstrumentedDurationMs(task, timeIndicatorNowMs);
if (instrumentedMs == null) {
// in-review and done: show wall-clock end-to-end runtime. Falls back to
// the instrumented `[timing]` aggregate for tasks completed before
// `executionStartedAt`/`executionCompletedAt` were tracked.
const endToEndMs = getEndToEndDurationMs(task, timeIndicatorNowMs);
const totalMs = endToEndMs ?? getInstrumentedDurationMs(task, timeIndicatorNowMs);
if (totalMs == null) {
return null;
}
const elapsedLabel = formatElapsedDurationDone(instrumentedMs);
const elapsedLabel = formatElapsedDurationDone(totalMs);
if (!elapsedLabel) {
return null;
}
@@ -789,7 +813,7 @@ function TaskCardComponent({
title: `Execution time ${elapsedLabel}. Completed ${completedAt}`,
ariaLabel: `Execution time ${elapsedLabel}. Completed ${completedAt}`,
};
}, [task.column, task.status, task.columnMovedAt, task.timedExecutionMs, task.updatedAt, task.workflowStepResults, task.log, timeIndicatorNowMs]);
}, [task.column, task.status, task.columnMovedAt, task.timedExecutionMs, task.updatedAt, task.workflowStepResults, task.log, task.executionStartedAt, task.executionCompletedAt, timeIndicatorNowMs]);
useEffect(() => {
if (!hasGitHubBadge || !isInViewport) {

View File

@@ -8546,6 +8546,40 @@ describe("Git Management endpoints", () => {
});
});
describe("GET /git/changes", () => {
const resetGitRepo = () => {
const { headSha } = getSharedGitTestRepo();
execFileSync("git", ["-C", gitRepoDir, "reset", "--hard", headSha], { stdio: "pipe" });
execFileSync("git", ["-C", gitRepoDir, "clean", "-fd"], { stdio: "pipe" });
};
beforeEach(() => {
resetGitRepo();
});
afterEach(() => {
resetGitRepo();
});
it("preserves the first unstaged entry instead of misclassifying it as staged", async () => {
const readmePath = join(gitRepoDir, "README.md");
const original = readFileSync(readmePath, "utf-8");
const marker = `\nchanges-first-line-${Date.now()}\n`;
writeFileSync(readmePath, `${original}${marker}`);
const res = await GET(buildApp(), "/api/git/changes");
expect(res.status).toBe(200);
expect(res.body).toEqual([
{
file: "README.md",
status: "modified",
staged: false,
},
]);
});
});
describe("GET /git/diff/file", () => {
const resetGitRepo = () => {
const { headSha } = getSharedGitTestRepo();

View File

@@ -1,5 +1,6 @@
import type { Request, Response } from "express";
import type { Agent, AgentCapability, AgentUpdateInput, TaskStore } from "@fusion/core";
import { DEFAULT_HEARTBEAT_PROCEDURE_PATH } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
@@ -66,6 +67,7 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
soul,
memory,
bundleConfig,
heartbeatProcedurePath,
} = req.body ?? {};
if (!name || typeof name !== "string") {
@@ -107,6 +109,12 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
if (typeof memory === "string" && memory.length > 50000) {
throw badRequest("memory must be at most 50,000 characters");
}
if (heartbeatProcedurePath !== undefined && heartbeatProcedurePath !== null && typeof heartbeatProcedurePath !== "string") {
throw badRequest("heartbeatProcedurePath must be a string");
}
if (typeof heartbeatProcedurePath === "string" && heartbeatProcedurePath.length > 500) {
throw badRequest("heartbeatProcedurePath must be at most 500 characters");
}
if (bundleConfig !== undefined && bundleConfig !== null) {
if (typeof bundleConfig !== "object" || Array.isArray(bundleConfig)) {
throw badRequest("bundleConfig must be an object");
@@ -144,7 +152,21 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
soul: soul ?? undefined,
memory: memory ?? undefined,
bundleConfig: bundleConfig ?? undefined,
heartbeatProcedurePath: heartbeatProcedurePath ?? undefined,
});
// Seed the default heartbeat procedure file if the new agent landed on
// the default path (which createAgent fills in for non-ephemeral agents
// when no override is provided). Idempotent — operator edits are kept.
if (agent.heartbeatProcedurePath === DEFAULT_HEARTBEAT_PROCEDURE_PATH) {
try {
const { ensureDefaultHeartbeatProcedureFile, HEARTBEAT_PROCEDURE } = await import("@fusion/engine");
await ensureDefaultHeartbeatProcedureFile(scopedStore.getRootDir(), DEFAULT_HEARTBEAT_PROCEDURE_PATH, HEARTBEAT_PROCEDURE);
} catch {
// Non-fatal — the heartbeat resolver falls back to the in-memory constant.
}
}
res.status(201).json(agent);
} catch (err: unknown) {
if (err instanceof ApiError) {
@@ -394,6 +416,16 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
updates.memory = body.memory ?? undefined;
}
if ("heartbeatProcedurePath" in body) {
if (body.heartbeatProcedurePath !== null && typeof body.heartbeatProcedurePath !== "string") {
throw badRequest("heartbeatProcedurePath must be a string");
}
if (typeof body.heartbeatProcedurePath === "string" && body.heartbeatProcedurePath.length > 500) {
throw badRequest("heartbeatProcedurePath must be at most 500 characters");
}
updates.heartbeatProcedurePath = body.heartbeatProcedurePath ?? undefined;
}
if ("bundleConfig" in body) {
if (body.bundleConfig !== null) {
if (typeof body.bundleConfig !== "object" || Array.isArray(body.bundleConfig)) {
@@ -436,6 +468,52 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
}
});
/**
* POST /api/agents/:id/upgrade-heartbeat-procedure
* Backfill an existing agent onto the default heartbeat procedure file.
* Sets `heartbeatProcedurePath` to DEFAULT_HEARTBEAT_PROCEDURE_PATH and
* seeds the file with the built-in HEARTBEAT_PROCEDURE if it doesn't exist.
* Idempotent: existing operator edits to the file are preserved.
*/
router.post("/agents/:id/upgrade-heartbeat-procedure", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const existing = await agentStore.getAgent(req.params.id);
if (!existing) {
throw notFound(`agent ${req.params.id} not found`);
}
const { ensureDefaultHeartbeatProcedureFile, HEARTBEAT_PROCEDURE } = await import("@fusion/engine");
const filePath = await ensureDefaultHeartbeatProcedureFile(
scopedStore.getRootDir(),
DEFAULT_HEARTBEAT_PROCEDURE_PATH,
HEARTBEAT_PROCEDURE,
);
const updated = await agentStore.updateAgent(req.params.id, {
heartbeatProcedurePath: DEFAULT_HEARTBEAT_PROCEDURE_PATH,
});
res.json({
agent: updated,
heartbeatProcedurePath: DEFAULT_HEARTBEAT_PROCEDURE_PATH,
procedureFileSeeded: filePath !== null,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
throw notFound(err instanceof Error ? err.message : String(err));
}
rethrowAsApiError(err);
}
});
/**
* DELETE /api/agents/:id
* Delete an agent.

View File

@@ -717,15 +717,19 @@ export async function dropGitStash(index: number, cwd?: string): Promise<string>
export async function getGitFileChanges(cwd?: string): Promise<GitFileChange[]> {
try {
const output = (await runGitCommand(["status", "--porcelain=v1"], cwd, 5000)).trim();
if (!output) return [];
const output = await runGitCommand(["status", "--porcelain=v1"], cwd, 5000);
if (!output.trim()) return [];
const changes: GitFileChange[] = [];
for (const line of output.split("\n")) {
if (line.length < 3) continue;
const indexStatus = line[0];
const workTreeStatus = line[1];
const filePath = line.slice(3).trim();
// Preserve leading status spaces from porcelain output. Trimming the
// whole command output corrupts the first unstaged entry (`" M foo"` →
// `"M foo"`), which misclassifies it as staged and truncates the path.
const normalizedLine = line.replace(/\r$/, "");
if (normalizedLine.length < 3) continue;
const indexStatus = normalizedLine[0];
const workTreeStatus = normalizedLine[1];
const filePath = normalizedLine.slice(3).trim();
const mapStatus = (code: string): GitFileChange["status"] => {
switch (code) {

View File

@@ -13,6 +13,7 @@ import {
type HeartbeatExecutionOptions,
HEARTBEAT_SYSTEM_PROMPT,
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
HEARTBEAT_PROCEDURE,
} from "../agent-heartbeat.js";
import { AgentLogger } from "../agent-logger.js";
import * as agentTools from "../agent-tools.js";
@@ -1655,6 +1656,10 @@ describe("HeartbeatMonitor", () => {
// Should NOT include task-specific content
expect(executionPrompt).not.toContain("Assigned task:");
expect(executionPrompt).not.toContain("Task description:");
// Should include Wake Delta + Heartbeat Procedure (paperclip-style per-tick anchoring)
expect(executionPrompt).toContain("## Wake Delta");
expect(executionPrompt).toContain("wake reason:");
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
});
it("task-scoped run receives HEARTBEAT_SYSTEM_PROMPT as system prompt", async () => {
@@ -2123,6 +2128,60 @@ describe("HeartbeatMonitor", () => {
expect(executionPrompt).toContain("Pending Messages:");
expect(executionPrompt).toContain("[id: msg-1] [from: agent:agent-2] Hello from agent-2");
expect(executionPrompt).toContain("[id: msg-2] [from: user:user-1] Hello from user");
// Task-scoped prompts must include Wake Delta + Heartbeat Procedure so
// the agent re-runs its procedure each tick instead of grinding on the
// assigned task (paperclip-parity).
expect(executionPrompt).toContain("## Wake Delta");
expect(executionPrompt).toContain("wake reason: message_received");
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
});
it("substitutes per-agent heartbeatProcedurePath content for the default procedure", async () => {
const tmpRoot = mkdtempSync(join(tmpdir(), "fn-hb-procedure-"));
try {
const customProcedure = "## Custom CEO Procedure\n\n1. Review reports\n2. Update strategy\n3. Exit";
writeFileSync(join(tmpRoot, "MY-PROCEDURE.md"), customProcedure, "utf-8");
const store = createStoreWithAgentForExec({
heartbeatProcedurePath: "MY-PROCEDURE.md",
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: tmpRoot });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.status).toBe("completed");
const promptCalls = mockSession.prompt.mock.calls;
expect(promptCalls.length).toBeGreaterThan(0);
const executionPrompt = promptCalls[promptCalls.length - 1][0];
// Custom procedure should appear; default constant should not.
expect(executionPrompt).toContain("## Custom CEO Procedure");
expect(executionPrompt).toContain("1. Review reports");
expect(executionPrompt).not.toContain(HEARTBEAT_PROCEDURE);
// Wake Delta still rendered.
expect(executionPrompt).toContain("## Wake Delta");
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("falls back to default procedure when heartbeatProcedurePath is invalid (traversal)", async () => {
const store = createStoreWithAgentForExec({
heartbeatProcedurePath: "../escape.md",
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.status).toBe("completed");
const promptCalls = mockSession.prompt.mock.calls;
const executionPrompt = promptCalls[promptCalls.length - 1][0];
// Invalid path → fall back to the default constant.
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
});
it("does not include message section when no unread messages", async () => {

View File

@@ -21,6 +21,7 @@ const reloadMock = vi.fn(async () => {});
const execSyncMock = vi.fn((_cmd?: any, _opts?: any) => "");
const existsSyncMock = vi.fn((_path: PathLike) => false);
const readFileSyncMock = vi.fn((_path?: any) => "{}");
const readCustomProvidersMock = vi.fn(() => []);
// Route async `exec` through the `execSync` mock so the promisify bridge works.
// Use Symbol.for("nodejs.util.promisify.custom") directly to avoid async imports
@@ -68,6 +69,10 @@ vi.mock("node:fs", async () => {
};
});
vi.mock("../custom-providers.js", () => ({
readCustomProviders: readCustomProvidersMock,
}));
vi.mock("@mariozechner/pi-coding-agent", () => ({
AuthStorage: {
create: () => ({
@@ -382,6 +387,7 @@ describe("createFnAgent", () => {
execSyncMock.mockReturnValue("");
existsSyncMock.mockReturnValue(false);
readFileSyncMock.mockReturnValue("{}");
readCustomProvidersMock.mockReturnValue([]);
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
createAgentSessionMock.mockResolvedValue({
session: {
@@ -484,6 +490,36 @@ describe("createFnAgent", () => {
expect(refreshMock).toHaveBeenCalled();
});
it("registers custom providers from global settings", async () => {
readCustomProvidersMock.mockReturnValue([
{
id: "custom-openai",
name: "Custom OpenAI",
apiType: "openai-compatible",
baseUrl: "https://custom.example/v1",
apiKey: "CUSTOM_API_KEY",
models: [{ id: "custom-model", name: "Custom Model" }],
},
] as any);
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
tools: "readonly",
defaultProvider: "openai-codex",
defaultModelId: "gpt-5.4",
});
expect(registerProviderMock).toHaveBeenCalledWith("custom-openai", expect.objectContaining({
baseUrl: "https://custom.example/v1",
api: "openai-completions",
apiKey: "CUSTOM_API_KEY",
models: [expect.objectContaining({ id: "custom-model", name: "Custom Model" })],
}));
});
it("avoids lock-based SettingsManager.create when loading extension providers", async () => {
const { createFnAgent } = await import("../pi.js");

View File

@@ -23,7 +23,7 @@ import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, taskCreateParams } from "./agent-tools.js";
import { AgentLogger } from "./agent-logger.js";
import { resolveAgentInstructionsWithRatings, buildSystemPromptWithInstructions } from "./agent-instructions.js";
import { resolveAgentInstructionsWithRatings, buildSystemPromptWithInstructions, resolveAgentHeartbeatProcedure } from "./agent-instructions.js";
import { heartbeatLog, formatError } from "./logger.js";
import { createRunAuditor, type EngineRunContext } from "./run-audit.js";
import { promptWithFallback } from "./pi.js";
@@ -285,6 +285,37 @@ When sending messages:
// Backward-compatible alias; prefer HEARTBEAT_NO_TASK_SYSTEM_PROMPT.
export const HEARTBEAT_SYSTEM_PROMPT_NO_TASK = HEARTBEAT_NO_TASK_SYSTEM_PROMPT;
/**
* Per-tick heartbeat procedure appended to every execution prompt. Forces the
* agent to re-anchor on its own operating procedure each wake instead of
* silently grinding on a previously assigned task.
*/
export const HEARTBEAT_PROCEDURE = `## Heartbeat Procedure (run every tick, in order)
1. **Identity & context** — review your soul, instructions, and memory (already
loaded in the system prompt). Confirm who you are and what you're responsible
for before continuing prior work.
2. **Inbox** — when fn_read_messages is available, call it. Process any pending
messages first; reply with reply_to_message_id when answering.
3. **Wake delta** — read the Wake Delta block above. The wake reason is the
highest-priority change for this heartbeat. If you were woken by a comment
or a message, acknowledge it before doing anything else.
4. **Assignment review** — if you have an assigned task, re-read its current
description, latest comments, and any task documents. Decide whether the
prior plan is still valid given the wake delta. Do not assume yesterday's
plan is still correct.
5. **Pick the next concrete action** — exactly ONE useful action this heartbeat:
advance the task, create a follow-up, log findings, delegate, or update
memory. Don't stop at planning unless the task is a planning task.
6. **Persist progress** — fn_task_log for observations, fn_task_document_write
for durable findings, status updates only when the work warrants it.
7. **Exit** — call fn_heartbeat_done with a one-line summary of what changed
this tick. If you took no action, say so and explain why.
Critical: a heartbeat without observable progress (a log, a document write, a
status change, a comment, a delegation, or an explicit "no-op with reason") is
a bug. Do not loop on the same plan across heartbeats without recording why.`;
/** Parameter schema for the fn_heartbeat_done tool */
const heartbeatDoneParams = Type.Object({
summary: Type.Optional(Type.String({ description: "Summary of what was accomplished this heartbeat" })),
@@ -1337,6 +1368,30 @@ export class HeartbeatMonitor {
let pendingMessages: Message[] = [];
let executionPrompt: string;
// Derive a stable wake reason from source, triggerDetail, and trigger
// type so the agent can change its strategy based on *why* it woke up.
// Mirrors paperclip's PAPERCLIP_WAKE_REASON (see plan: wake delta).
const deriveWakeReason = (): string => {
if (effectiveTriggeringCommentType) return `comment_${effectiveTriggeringCommentType}`;
if (triggerDetail === "wake-on-message") return "message_received";
if (triggerDetail === "wake-on-comment") return "comment_mention";
if (triggerDetail === "task-assigned") return "task_assigned";
if (source === "timer") return "timer";
if (source === "assignment") return "task_assigned";
if (source === "automation") return "automation";
if (source === "routine") return "routine";
return triggerDetail || source;
};
const wakeReason = deriveWakeReason();
// Per-agent override of the default HEARTBEAT_PROCEDURE: if the agent
// configured a heartbeatProcedurePath pointing to a markdown file in
// the project, use that instead. Reloaded fresh each tick (matches the
// existing instructionsPath/instructionsText reload contract) so an
// operator can iterate on procedure text without restarting agents.
const customProcedure = await resolveAgentHeartbeatProcedure(agent, rootDir);
const heartbeatProcedureText = customProcedure ?? HEARTBEAT_PROCEDURE;
if (isNoTaskRun) {
// No-task heartbeat: agent has identity but no assigned task
// Fetch unread messages when messageStore is available (for all trigger types)
@@ -1365,6 +1420,16 @@ export class HeartbeatMonitor {
`Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`,
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
"",
"## Wake Delta",
`- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
`- wake reason: ${wakeReason}`,
`- assigned task: none`,
`- pending messages: ${pendingMessages.length}`,
"",
"Treat this wake delta as the highest-priority change for this heartbeat.",
"Run the Heartbeat Procedure (below) before doing anything else — even a",
"timer-only wake should re-check messages, memory, and project state.",
"",
"**No assigned task** — This heartbeat run has no task assignment.",
"",
"You have identity (soul, instructions, and/or memory) loaded, which means you can perform",
@@ -1388,6 +1453,9 @@ export class HeartbeatMonitor {
"",
"Your soul, instructions, and memory are already loaded in the system prompt.",
"Focus on work that benefits the project without requiring a specific task context.",
"",
heartbeatProcedureText,
"",
"Call fn_heartbeat_done when finished.",
].join("\n");
} else {
@@ -1450,6 +1518,18 @@ export class HeartbeatMonitor {
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
`Assigned task: ${taskId} — ${taskTitle}`,
"",
"## Wake Delta",
`- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
`- wake reason: ${wakeReason}`,
`- assigned task: ${taskId}`,
`- pending messages: ${pendingMessages.length}`,
`- triggering comments: ${effectiveTriggeringCommentIds?.length ?? 0}`,
"",
"Treat this wake delta as the highest-priority change for this heartbeat.",
"Before resuming prior task work, run the Heartbeat Procedure (below) and",
"decide what action this delta requires. Your assigned task is one input",
"to the procedure — not the only thing to consider.",
"",
"Task description:",
taskDetail!.description,
"",
@@ -1457,7 +1537,9 @@ export class HeartbeatMonitor {
...triggeringCommentLines,
...pendingMessagesLines,
"",
"Review the task status and take appropriate action. Call fn_heartbeat_done when finished.",
heartbeatProcedureText,
"",
"Run the Heartbeat Procedure above. Call fn_heartbeat_done when finished.",
].join("\n");
}

View File

@@ -1,5 +1,6 @@
import { readFile } from "node:fs/promises";
import { isAbsolute, resolve, relative, normalize, sep } from "node:path";
import { readFile, writeFile, mkdir, access } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { isAbsolute, resolve, relative, normalize, sep, dirname } from "node:path";
import {
readProjectMemory,
type Agent,
@@ -32,7 +33,12 @@ function isPathTraversal(path: string): boolean {
return path.split(/[\\/]+/).includes("..");
}
function resolveValidatedInstructionsPath(rawPath: string, rootDir: string, agentId: string): string | null {
function resolveValidatedMarkdownPath(
rawPath: string,
rootDir: string,
agentId: string,
fieldLabel: string,
): string | null {
const trimmed = rawPath.trim();
if (!trimmed) {
return null;
@@ -40,37 +46,118 @@ function resolveValidatedInstructionsPath(rawPath: string, rootDir: string, agen
if (trimmed.length > MAX_INSTRUCTIONS_PATH_LENGTH) {
log.warn(
`instructionsPath too long for agent ${agentId} (${trimmed.length} > ${MAX_INSTRUCTIONS_PATH_LENGTH})`,
`${fieldLabel} too long for agent ${agentId} (${trimmed.length} > ${MAX_INSTRUCTIONS_PATH_LENGTH})`,
);
return null;
}
if (!trimmed.toLowerCase().endsWith(".md")) {
log.warn(`instructionsPath must end in .md for agent ${agentId}: ${trimmed}`);
log.warn(`${fieldLabel} must end in .md for agent ${agentId}: ${trimmed}`);
return null;
}
if (isAbsolute(trimmed)) {
log.warn(`instructionsPath must be project-relative for agent ${agentId}: ${trimmed}`);
log.warn(`${fieldLabel} must be project-relative for agent ${agentId}: ${trimmed}`);
return null;
}
const normalized = normalize(trimmed);
if (isPathTraversal(normalized)) {
log.warn(`instructionsPath traversal is not allowed for agent ${agentId}: ${trimmed}`);
log.warn(`${fieldLabel} traversal is not allowed for agent ${agentId}: ${trimmed}`);
return null;
}
const resolvedPath = resolve(rootDir, normalized);
const rel = relative(rootDir, resolvedPath);
if (!rel || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
log.warn(`instructionsPath escapes project root for agent ${agentId}: ${trimmed}`);
log.warn(`${fieldLabel} escapes project root for agent ${agentId}: ${trimmed}`);
return null;
}
return resolvedPath;
}
function resolveValidatedInstructionsPath(rawPath: string, rootDir: string, agentId: string): string | null {
return resolveValidatedMarkdownPath(rawPath, rootDir, agentId, "instructionsPath");
}
/**
* Load a per-agent heartbeat procedure markdown file. Returns the file
* contents (trimmed/clamped to MAX_INSTRUCTIONS_TEXT_LENGTH), or null if no
* path is configured, the path is invalid, or the file is unreadable. Caller
* substitutes a default constant on null.
*/
export async function resolveAgentHeartbeatProcedure(
agent: Agent | null | undefined,
rootDir: string,
): Promise<string | null> {
const rawPath = agent?.heartbeatProcedurePath?.trim();
if (!agent || !rawPath) {
return null;
}
const filePath = resolveValidatedMarkdownPath(rawPath, rootDir, agent.id, "heartbeatProcedurePath");
if (!filePath) {
return null;
}
try {
const content = await readFile(filePath, "utf-8");
const normalized = trimAndClamp(
content,
MAX_INSTRUCTIONS_TEXT_LENGTH,
"heartbeat procedure file content",
agent.id,
);
return normalized || null;
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
log.warn(`Heartbeat procedure file not found for agent ${agent.id}: ${filePath}`);
} else {
log.warn(`Failed to read heartbeat procedure file for agent ${agent.id}: ${filePath} (${code})`);
}
return null;
}
}
/**
* Write the default heartbeat procedure file at `pathRel` (project-relative)
* if it doesn't already exist. Idempotent: leaves an existing file untouched
* so operators can edit the procedure without losing their changes on the
* next upgrade run. Returns the absolute path that was ensured (or null if
* the path is invalid).
*
* The default content is supplied by the caller — kept as a parameter rather
* than imported from agent-heartbeat.ts to avoid a circular dependency
* (agent-heartbeat.ts already imports from this module).
*/
export async function ensureDefaultHeartbeatProcedureFile(
rootDir: string,
procedurePathRel: string,
defaultContent: string,
): Promise<string | null> {
// Reuse the same path validation as the per-agent loader so we never write
// outside the project root.
const filePath = resolveValidatedMarkdownPath(procedurePathRel, rootDir, "system", "heartbeatProcedurePath");
if (!filePath) {
return null;
}
try {
await access(filePath, fsConstants.F_OK);
return filePath; // Already exists — preserve operator edits.
} catch {
// Falls through to write below.
}
try {
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, defaultContent, "utf-8");
log.log(`Seeded default heartbeat procedure file at ${filePath}`);
return filePath;
} catch (err: unknown) {
log.warn(`Failed to seed default heartbeat procedure file at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
}
function getTrendLabel(trend: AgentRatingSummary["trend"]): string {
switch (trend) {
case "improving":

View File

@@ -0,0 +1,15 @@
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { CustomProvider } from "@fusion/core";
export function readCustomProviders(): CustomProvider[] {
try {
const settingsPath = join(homedir(), ".fusion", "settings.json");
const raw = readFileSync(settingsPath, "utf-8");
const parsed = JSON.parse(raw) as { customProviders?: CustomProvider[] };
return Array.isArray(parsed.customProviders) ? parsed.customProviders : [];
} catch {
return [];
}
}

View File

@@ -45,7 +45,10 @@ export {
resolveAgentInstructionsWithRatings,
resolveAgentInstructions,
buildSystemPromptWithInstructions,
resolveAgentHeartbeatProcedure,
ensureDefaultHeartbeatProcedureFile,
} from "./agent-instructions.js";
export { HEARTBEAT_PROCEDURE, HEARTBEAT_SYSTEM_PROMPT, HEARTBEAT_NO_TASK_SYSTEM_PROMPT } from "./agent-heartbeat.js";
export { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees, reapOrphanWorktrees } from "./worktree-pool.js";
export { createLogger, type Logger } from "./logger.js";
export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js";

View File

@@ -43,6 +43,7 @@ import {
import { isContextLimitError } from "./context-limit-detector.js";
import { createFusionAuthStorage, getModelRegistryModelsPath } from "./auth-storage.js";
import { piLog, extensionsLog } from "./logger.js";
import { readCustomProviders } from "./custom-providers.js";
export interface AgentResult {
session: AgentSession;
@@ -998,6 +999,35 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath());
await registerExtensionProviders(options.cwd, modelRegistry);
for (const provider of readCustomProviders()) {
try {
modelRegistry.registerProvider(provider.id, {
baseUrl: provider.baseUrl,
api: provider.apiType === "anthropic-compatible" ? "anthropic" : "openai-completions",
apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({
id: model.id,
name: model.name,
reasoning: false,
input: ["text" as const],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 16384,
})),
});
piLog.log(`Registered custom provider ${provider.id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
piLog.warn(`Failed to register custom provider ${provider.id}: ${message}`);
}
}
modelRegistry.refresh();
// Build the pi built-in tool set. We deliberately do NOT use the bundled
// `createCodingTools` / `createReadOnlyTools` presets — they're missing
// tools that pi-claude-cli's Claude→pi name mapping depends on (Glob→find,