Merge upstream/main — v0.28.1 → v0.29.0 (721 commits)

This commit is contained in:
semih
2026-05-15 11:17:48 +03:00
687 changed files with 45453 additions and 3741 deletions

View File

@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { writeFile, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const previewPlan = vi.fn();
const finalize = vi.fn();
const init = vi.fn();
const getExperimentSessionStore = vi.fn(() => ({}));
const mockErrors = vi.hoisted(() => ({
CherryPickConflictError: class extends Error {
code = "cherry_pick_conflict" as const;
groupId = "g1";
commit = "abc";
stderr = "conflict";
},
}));
vi.mock("@fusion/core", () => ({
TaskStore: vi.fn(() => ({ init, getExperimentSessionStore })),
}));
vi.mock("@fusion/engine", () => ({
defaultGitOps: vi.fn(() => ({})),
ExperimentFinalizeService: vi.fn(() => ({ previewPlan, finalize })),
ExperimentFinalizeStateError: class extends Error { code = "state_error" as const; },
ExperimentFinalizeNoKeptRunsError: class extends Error { code = "no_kept_runs" as const; },
ExperimentFinalizePlanError: class extends Error { code = "plan_error" as const; },
ExperimentFinalizeMergeBaseError: class extends Error { code = "merge_base_error" as const; },
ExperimentFinalizeBranchExistsError: class extends Error { code = "branch_exists" as const; },
ExperimentFinalizeCherryPickConflictError: mockErrors.CherryPickConflictError,
}));
import { runExperimentFinalize } from "../commands/experiment-finalize.js";
describe("runExperimentFinalize", () => {
beforeEach(() => {
vi.clearAllMocks();
init.mockResolvedValue(undefined);
getExperimentSessionStore.mockReturnValue({});
});
it("dry-run calls previewPlan and not finalize", async () => {
previewPlan.mockResolvedValue({ sessionId: "EXP-1", mergeBaseCommit: "mb", groups: [] });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runExperimentFinalize({ sessionId: "EXP-1", dryRun: true });
expect(getExperimentSessionStore).toHaveBeenCalled();
expect(previewPlan).toHaveBeenCalledWith({ sessionId: "EXP-1", integrationBranch: undefined });
expect(finalize).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalled();
});
it("plan-file loads override and passes to finalize", async () => {
finalize.mockResolvedValue({ sessionId: "EXP-1", branches: [] });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const tempDir = await mkdtemp(join(tmpdir(), "fn-4222-"));
const planPath = join(tempDir, "plan.json");
await writeFile(planPath, JSON.stringify({ groups: [{ runRecordIds: ["RUN-1"] }] }), "utf8");
await runExperimentFinalize({ sessionId: "EXP-1", planFile: planPath });
expect(finalize).toHaveBeenCalledWith(expect.objectContaining({ planOverride: { groups: [{ runRecordIds: ["RUN-1"] }] } }));
logSpy.mockRestore();
});
it("cherry-pick conflict exits with code 6", async () => {
finalize.mockRejectedValue(new mockErrors.CherryPickConflictError("conflict"));
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`exit:${code}`); }) as never);
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(runExperimentFinalize({ sessionId: "EXP-1" })).rejects.toThrow("exit:6");
expect(errSpy).toHaveBeenCalled();
exitSpy.mockRestore();
errSpy.mockRestore();
});
it("json output is parseable", async () => {
previewPlan.mockResolvedValue({ sessionId: "EXP-1", mergeBaseCommit: "mb", groups: [] });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runExperimentFinalize({ sessionId: "EXP-1", dryRun: true, json: true });
expect(() => JSON.parse((logSpy.mock.calls[0] ?? ["{}"])[0] as string)).not.toThrow();
logSpy.mockRestore();
});
it("unexpected errors exit with code 1", async () => {
finalize.mockRejectedValue(new Error("boom"));
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`exit:${code}`); }) as never);
await expect(runExperimentFinalize({ sessionId: "EXP-1" })).rejects.toThrow("exit:1");
exitSpy.mockRestore();
});
});

View File

@@ -0,0 +1,120 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const previewPlanMock = vi.hoisted(() => vi.fn());
const finalizeMock = vi.hoisted(() => vi.fn());
const mockErrors = vi.hoisted(() => ({
StateError: class extends Error { code = "state_error" as const; },
NoKeptError: class extends Error { code = "no_kept_runs" as const; },
PlanError: class extends Error { code = "plan_error" as const; },
MergeBaseError: class extends Error { code = "merge_base_error" as const; },
BranchExistsError: class extends Error { code = "branch_exists" as const; },
CherryPickError: class extends Error {
code = "cherry_pick_conflict" as const;
groupId = "g-1";
commit = "abc";
stderr = "conflict";
},
}));
vi.mock("@fusion/core", () => ({
TaskStore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
getExperimentSessionStore: vi.fn(() => ({})),
})),
COLUMNS: [],
COLUMN_LABELS: {},
validateNodeOverrideChange: vi.fn(),
RESEARCH_RUN_STATUSES: [],
isResearchExperimentalEnabled: vi.fn(() => true),
resolveResearchSettings: vi.fn(() => ({})),
canAgentTakeImplementationTaskForExplicitRouting: vi.fn(() => true),
formatRoleMismatchReason: vi.fn(() => ""),
resolveAgentProvisioningPolicy: vi.fn(() => ({ approvalMode: "auto" })),
TASK_PRIORITIES: ["low", "normal", "high", "urgent"],
}));
vi.mock("@fusion/engine", () => ({
fetchWebContent: vi.fn(),
defaultGitOps: vi.fn(() => ({})),
ExperimentFinalizeService: vi.fn(() => ({ previewPlan: previewPlanMock, finalize: finalizeMock })),
ExperimentFinalizeStateError: mockErrors.StateError,
ExperimentFinalizeNoKeptRunsError: mockErrors.NoKeptError,
ExperimentFinalizePlanError: mockErrors.PlanError,
ExperimentFinalizeMergeBaseError: mockErrors.MergeBaseError,
ExperimentFinalizeBranchExistsError: mockErrors.BranchExistsError,
ExperimentFinalizeCherryPickConflictError: mockErrors.CherryPickError,
}));
import kbExtension from "../extension.js";
describe("extension fn_experiment_finalize", () => {
beforeEach(() => {
vi.clearAllMocks();
});
function getTool() {
const tools = new Map<string, any>();
kbExtension({
registerTool(def: any) {
tools.set(def.name, def);
},
registerCommand: vi.fn(),
registerShortcut: vi.fn(),
registerFlag: vi.fn(),
on: vi.fn(),
} as any);
return tools.get("fn_experiment_finalize");
}
it("supports dry-run preview", async () => {
const tool = getTool();
previewPlanMock.mockResolvedValue({ sessionId: "EXP-1", groups: [], mergeBaseCommit: "abc" });
const result = await tool.execute("id", { sessionId: "EXP-1", dryRun: true }, undefined, undefined, { cwd: process.cwd() });
expect(previewPlanMock).toHaveBeenCalledWith({ sessionId: "EXP-1", integrationBranch: undefined });
expect(result.isError).toBeUndefined();
expect(result.details.plan.sessionId).toBe("EXP-1");
});
it("supports finalize success", async () => {
const tool = getTool();
finalizeMock.mockResolvedValue({ sessionId: "EXP-2", branches: [{ name: "b1" }] });
const result = await tool.execute("id", { sessionId: "EXP-2", summary: "done" }, undefined, undefined, { cwd: process.cwd() });
expect(finalizeMock).toHaveBeenCalled();
expect(result.content[0].text).toContain("Finalized EXP-2");
});
it("surfaces no kept runs error", async () => {
const tool = getTool();
finalizeMock.mockRejectedValue(new mockErrors.NoKeptError("no kept"));
const result = await tool.execute("id", { sessionId: "EXP-3" }, undefined, undefined, { cwd: process.cwd() });
expect(result.isError).toBe(true);
expect(result.details.code).toBe("no_kept_runs");
});
it("surfaces cherry-pick conflict details", async () => {
const tool = getTool();
finalizeMock.mockRejectedValue(new mockErrors.CherryPickError("conflict"));
const result = await tool.execute("id", { sessionId: "EXP-4" }, undefined, undefined, { cwd: process.cwd() });
expect(result.isError).toBe(true);
expect(result.details).toMatchObject({ code: "cherry_pick_conflict", groupId: "g-1", commit: "abc", stderr: "conflict" });
});
it("returns tool result contract", async () => {
const tool = getTool();
previewPlanMock.mockResolvedValue({ sessionId: "EXP-5", groups: [], mergeBaseCommit: "abc" });
const result = await tool.execute("id", { sessionId: "EXP-5", dryRun: true }, undefined, undefined, { cwd: process.cwd() });
expect(Array.isArray(result.content)).toBe(true);
expect(typeof result.content[0].text).toBe("string");
});
});

View File

@@ -202,6 +202,7 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
"fn_feature_add",
"fn_slice_activate",
"fn_feature_link_task",
"fn_feature_update",
"fn_agent_stop",
"fn_agent_start",
"fn_agent_create",
@@ -1373,6 +1374,292 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
});
});
describe("fn_feature_update", () => {
it("patches title, description, and acceptanceCriteria", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const updateTool = api.tools.get("fn_feature_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature", description: "Original", acceptanceCriteria: "AC old" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await updateTool.execute(
"fu1",
{
id: feature.details.featureId,
title: "Updated Feature",
description: "Updated description",
acceptanceCriteria: "AC new",
},
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getFeature(feature.details.featureId);
expect(result.content[0].text).toContain("Updated");
expect(persisted?.title).toBe("Updated Feature");
expect(persisted?.description).toBe("Updated description");
expect(persisted?.acceptanceCriteria).toBe("AC new");
});
it("partial patch preserves untouched fields", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const updateTool = api.tools.get("fn_feature_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature", description: "Original", acceptanceCriteria: "AC old" },
undefined,
undefined,
makeCtx(tmpDir),
);
await updateTool.execute(
"fu2",
{ id: feature.details.featureId, acceptanceCriteria: "AC patched" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getFeature(feature.details.featureId);
expect(persisted?.title).toBe("Feature");
expect(persisted?.description).toBe("Original");
expect(persisted?.acceptanceCriteria).toBe("AC patched");
});
it("preserves slice ordering", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const updateTool = api.tools.get("fn_feature_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const first = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "F1" },
undefined,
undefined,
makeCtx(tmpDir),
);
const second = await featureTool.execute(
"f2",
{ sliceId: slice.details.sliceId, title: "F2" },
undefined,
undefined,
makeCtx(tmpDir),
);
const third = await featureTool.execute(
"f3",
{ sliceId: slice.details.sliceId, title: "F3" },
undefined,
undefined,
makeCtx(tmpDir),
);
await updateTool.execute(
"fu3",
{ id: second.details.featureId, title: "F2 Updated" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const features = store.getMissionStore().listFeatures(slice.details.sliceId);
expect(features.map((featureItem) => featureItem.id)).toEqual([
first.details.featureId,
second.details.featureId,
third.details.featureId,
]);
});
it("preserves linked task association", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const createTaskTool = api.tools.get("fn_task_create")!;
const linkTool = api.tools.get("fn_feature_link_task")!;
const updateTool = api.tools.get("fn_feature_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
const taskResult = await createTaskTool.execute(
"t1",
{ description: "Task for feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
await linkTool.execute(
"l1",
{ featureId: feature.details.featureId, taskId: taskResult.details.taskId },
undefined,
undefined,
makeCtx(tmpDir),
);
await updateTool.execute(
"fu4",
{ id: feature.details.featureId, title: "Updated Feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getFeature(feature.details.featureId);
expect(persisted?.taskId).toBe(taskResult.details.taskId);
expect(persisted?.status).toBe("triaged");
});
it("returns error when feature not found", async () => {
const updateTool = api.tools.get("fn_feature_update")!;
const result = await updateTool.execute(
"fu5",
{ id: "F-999", title: "Updated" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Feature F-999 not found");
});
it("returns error when no fields supplied", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const updateTool = api.tools.get("fn_feature_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await updateTool.execute(
"fu6",
{ id: feature.details.featureId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("No fields to update");
});
});
describe("GitHub import tools", () => {
it("fn_task_import_github requires gh auth", async () => {
const tool = api.tools.get("fn_task_import_github")!;

View File

@@ -16,6 +16,7 @@ import { createRequire } from "node:module";
import { join, dirname, resolve } from "node:path";
import { tmpdir } from "node:os";
import { performance } from "node:perf_hooks";
import { Readable } from "node:stream";
import { fileURLToPath } from "node:url";
// @ts-expect-error -- Bun-only global; undefined in Node
@@ -133,10 +134,12 @@ async function loadCommandHandlers() {
const { runAgentImport } = await import("./commands/agent-import.js");
const { runAgentExport } = await import("./commands/agent-export.js");
const { runMessageInbox, runMessageOutbox, runMessageSend, runMessageRead, runMessageDelete, runAgentMailbox } = await import("./commands/message.js");
const { runChatInteractive } = await import("./commands/chat.js");
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable, runPluginSetupStatus, runPluginSetup, runPluginAvailable, runPluginSettings, runPluginRescan } = await import("./commands/plugin.js");
const { runPluginCreate } = await import("./commands/plugin-scaffold.js");
const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js");
const { runExperimentFinalize } = await import("./commands/experiment-finalize.js");
const { runUpdate } = await import("./commands/update.js");
return {
@@ -233,7 +236,9 @@ async function loadCommandHandlers() {
runResearchExport,
runResearchCancel,
runResearchRetry,
runExperimentFinalize,
runUpdate,
runChatInteractive,
};
}
@@ -247,10 +252,10 @@ Usage:
fn dashboard --paused Start with automation paused
fn dashboard --dev Start web UI only (no AI engine)
fn dashboard --interactive Start with interactive port selection
fn serve [--port <port>] [--host <host>] [--paused] [--daemon] [--no-auto-register]
fn serve [--port <port>] [--host <host>] [--paused] [--daemon] [--project <id|name>] [--no-auto-register]
Start Fusion as a headless node (API + engine, no UI)
Auto-registers cwd project on first run (use --no-auto-register to disable)
fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--token-only] [--no-auto-register]
fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--token-only] [--project <id|name>] [--no-auto-register]
Start Fusion daemon (API + engine, auth required)
fn desktop Launch the Fusion desktop app (Electron)
fn desktop --dev Launch with hot-reload (connects to Vite dev server)
@@ -342,6 +347,8 @@ Usage:
fn message send <agent-id> <msg> Send a message to an agent
fn message read <id> Read a specific message
fn message delete <id> Delete a message
fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
Interactive or one-shot chat with an agent
fn backup --create Create a database backup immediately
fn backup --list List all database backups
fn backup --restore <file> Restore database from a backup file
@@ -394,6 +401,11 @@ Supported file types: png, jpg, gif, webp, txt, log, json, yaml, yml, toml, csv,
`.trim();
function extractGlobalProjectFlag(argv: string[]): { cleanedArgs: string[]; projectName?: string } {
const command = argv[0];
if (command === "serve" || command === "daemon") {
return { cleanedArgs: [...argv] };
}
const cleanedArgs: string[] = [];
let projectName: string | undefined;
@@ -595,7 +607,9 @@ async function main() {
runResearchExport,
runResearchCancel,
runResearchRetry,
runExperimentFinalize,
runUpdate,
runChatInteractive,
} = await loadCommandHandlers();
try {
@@ -646,8 +660,9 @@ async function main() {
const hostIdx = args.indexOf("--host");
const host = hostIdx !== -1 && hostIdx + 1 < args.length ? args[hostIdx + 1] : undefined;
const daemon = args.includes("--daemon");
const project = getFlagValue(args, "--project");
const noAutoRegister = args.includes("--no-auto-register");
await runServe(port, { paused, interactive, host, daemon, noAutoRegister });
await runServe(port, { paused, interactive, host, daemon, project, noAutoRegister });
break;
}
@@ -663,8 +678,9 @@ async function main() {
const tokenIdx = args.indexOf("--token");
const token = tokenIdx !== -1 && tokenIdx + 1 < args.length ? args[tokenIdx + 1] : undefined;
const tokenOnly = args.includes("--token-only");
const project = getFlagValue(args, "--project");
const noAutoRegister = args.includes("--no-auto-register");
await runDaemon({ port, paused, interactive, host, token, tokenOnly, noAutoRegister });
await runDaemon({ port, paused, interactive, host, token, tokenOnly, project, noAutoRegister });
break;
}
@@ -903,6 +919,34 @@ async function main() {
break;
}
case "experiment": {
const subcommand = args[1];
switch (subcommand) {
case "finalize": {
const sessionId = args[2];
if (!sessionId) {
console.error("Usage: fn experiment finalize <sessionId> [--integration-branch <name>] [--dry-run] [--json] [--summary <text>] [--plan-file <path>]");
process.exit(1);
}
await runExperimentFinalize({
sessionId,
integrationBranch: getFlagValue(args, "--integration-branch") ?? "main",
dryRun: args.includes("--dry-run"),
json: args.includes("--json"),
summary: getFlagValue(args, "--summary"),
planFile: getFlagValue(args, "--plan-file"),
projectName,
});
break;
}
default:
console.error(`Unknown subcommand: experiment ${subcommand || ""}`);
console.log("Try: fn experiment finalize <session-id>");
process.exit(1);
}
break;
}
case "task": {
const subcommand = args[1];
switch (subcommand) {
@@ -1483,6 +1527,45 @@ async function main() {
break;
}
case "chat": {
const usage = "Usage: fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]";
const agentId = args[1];
if (!agentId) {
console.error(usage);
process.exit(1);
}
const pollIdx = args.indexOf("--poll-ms");
const pollMs = pollIdx !== -1 && pollIdx + 1 < args.length
? Number.parseInt(args[pollIdx + 1] ?? "", 10)
: undefined;
if (pollIdx !== -1 && (!Number.isFinite(pollMs) || (pollMs ?? 0) <= 0)) {
console.error(usage);
process.exit(1);
}
const filteredArgs = args.slice(2).filter((arg, idx, arr) => {
if (arg === "--once" || arg === "--non-interactive" || arg === "--poll-ms") return false;
if (idx > 0 && arr[idx - 1] === "--poll-ms") return false;
return true;
});
const contentArg = filteredArgs.join(" ").trim();
const once = args.includes("--once") || contentArg.length > 0;
const nonInteractive = args.includes("--non-interactive") || contentArg.length > 0;
const input = contentArg ? Readable.from(contentArg) : process.stdin;
const code = await runChatInteractive(agentId, {
project: projectName,
once,
nonInteractive,
pollIntervalMs: pollMs,
input,
});
process.exit(code);
break;
}
case "plugin": {
const sub = args[1];
switch (sub) {

View File

@@ -0,0 +1,259 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough, Readable } from "node:stream";
import { AgentStore, MessageStore, createDatabase } from "@fusion/core";
const mockResolveProject = vi.fn();
vi.mock("../../project-context.js", () => ({
resolveProject: (...args: unknown[]) => mockResolveProject(...args),
}));
import { runChatInteractive } from "../chat.js";
function streamToString(stream: PassThrough): Promise<string> {
return new Promise((resolve) => {
let text = "";
stream.on("data", (chunk) => {
text += chunk.toString();
});
stream.on("end", () => resolve(text));
});
}
describe("runChatInteractive", () => {
let projectDir: string;
let agentId: string;
beforeEach(async () => {
projectDir = mkdtempSync(join(tmpdir(), "fn-chat-"));
mockResolveProject.mockResolvedValue({
projectId: "proj-1",
projectPath: projectDir,
projectName: "proj-1",
isRegistered: true,
store: {},
});
const agentStore = new AgentStore({ rootDir: join(projectDir, ".fusion") });
await agentStore.init();
const agent = await agentStore.createAgent({
name: "Chat Agent",
role: "executor",
reportsTo: undefined,
});
agentId = agent.id;
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
rmSync(projectDir, { recursive: true, force: true });
});
async function sendAgentReply(content: string, toId = "cli"): Promise<void> {
const db = createDatabase(join(projectDir, ".fusion"));
db.init();
const messageStore = new MessageStore(db);
messageStore.sendMessage({
fromId: agentId,
fromType: "agent",
toId,
toType: "user",
content,
type: "agent-to-user",
});
db.close();
}
it("sends a line as a user-to-agent message with wakeRecipient metadata", async () => {
const input = new PassThrough();
const output = new PassThrough();
const outputPromise = streamToString(output);
const runPromise = runChatInteractive(agentId, { input, output, pollIntervalMs: 10 });
input.write("hello\n");
input.write("/exit\n");
input.end();
const code = await runPromise;
output.end();
await outputPromise;
const db = createDatabase(join(projectDir, ".fusion"));
db.init();
const store = new MessageStore(db);
const outbox = store.getOutbox("cli", "user", { limit: 20 });
db.close();
expect(code).toBe(0);
expect(outbox[0]).toMatchObject({
fromId: "cli",
toId: agentId,
type: "user-to-agent",
content: "hello",
metadata: { wakeRecipient: true },
});
});
it("returns 1 for unknown agent and writes no message", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const code = await runChatInteractive("agent-does-not-exist", {
once: true,
nonInteractive: true,
input: Readable.from("hi"),
});
const db = createDatabase(join(projectDir, ".fusion"));
db.init();
const store = new MessageStore(db);
const outbox = store.getOutbox("cli", "user", { limit: 20 });
db.close();
expect(code).toBe(1);
expect(errorSpy).toHaveBeenCalledWith("Agent agent-does-not-exist not found");
expect(outbox).toHaveLength(0);
});
it("prints existing conversation tail on start", async () => {
const db = createDatabase(join(projectDir, ".fusion"));
db.init();
const store = new MessageStore(db);
store.sendMessage({
fromId: "cli",
fromType: "user",
toId: agentId,
toType: "agent",
content: "first",
type: "user-to-agent",
});
store.sendMessage({
fromId: agentId,
fromType: "agent",
toId: "cli",
toType: "user",
content: "second",
type: "agent-to-user",
});
db.close();
const input = new PassThrough();
const output = new PassThrough();
const outputPromise = streamToString(output);
const runPromise = runChatInteractive(agentId, { input, output, pollIntervalMs: 10 });
input.write("/exit\n");
input.end();
await runPromise;
output.end();
const outputText = await outputPromise;
expect(outputText).toContain("first");
expect(outputText).toContain("second");
});
it("/exit ends loop cleanly", async () => {
const input = new PassThrough();
const output = new PassThrough();
const runPromise = runChatInteractive(agentId, { input, output, pollIntervalMs: 10 });
input.write("/exit\n");
input.end();
await expect(runPromise).resolves.toBe(0);
});
it("poll loop prints new replies and marks them read", async () => {
const input = new PassThrough();
const output = new PassThrough();
const outputPromise = streamToString(output);
const runPromise = runChatInteractive(agentId, { input, output, pollIntervalMs: 10 });
await new Promise((resolve) => setTimeout(resolve, 30));
await sendAgentReply("async reply");
await new Promise((resolve) => setTimeout(resolve, 60));
input.write("/exit\n");
input.end();
await runPromise;
output.end();
const outputText = await outputPromise;
expect(outputText).toContain("async reply");
const db = createDatabase(join(projectDir, ".fusion"));
db.init();
const store = new MessageStore(db);
const inbox = store.getInbox("cli", "user", { limit: 20 });
const reply = inbox.find((msg) => msg.content === "async reply");
db.close();
expect(reply?.read).toBe(true);
});
it("--once sends and waits for one reply", async () => {
const output = new PassThrough();
const outputPromise = streamToString(output);
setTimeout(() => {
void sendAgentReply("reply once");
}, 50);
const code = await runChatInteractive(agentId, {
once: true,
nonInteractive: true,
input: Readable.from("one-shot"),
output,
pollIntervalMs: 10,
});
output.end();
const outputText = await outputPromise;
expect(code).toBe(0);
expect(outputText).toContain(`you → ${agentId}: one-shot`);
expect(outputText).toContain("reply once");
});
it("--once exits with timeout note when no reply arrives", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const input = new PassThrough();
input.end("ping");
const code = await runChatInteractive(agentId, {
once: true,
nonInteractive: true,
input,
output: new PassThrough(),
pollIntervalMs: 1000,
});
expect(code).toBe(0);
expect(errorSpy).toHaveBeenCalledWith("No reply within 30s");
}, 40_000);
it("refuses oversized messages", async () => {
const oversized = "x".repeat(8193);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const code = await runChatInteractive(agentId, {
once: true,
nonInteractive: true,
input: Readable.from(oversized),
output: new PassThrough(),
pollIntervalMs: 5,
});
const db = createDatabase(join(projectDir, ".fusion"));
db.init();
const store = new MessageStore(db);
const outbox = store.getOutbox("cli", "user", { limit: 20 });
db.close();
expect(code).toBe(0);
expect(errorSpy).toHaveBeenCalledWith("Message too long; max 8192 chars");
expect(outbox).toHaveLength(0);
});
});

View File

@@ -165,6 +165,7 @@ const mocks = vi.hoisted(() => {
Promise.resolve(projects.find((project) => project.id === id) ?? null),
),
listProjects: vi.fn().mockImplementation(() => Promise.resolve([...projects])),
getDefaultProjectId: vi.fn().mockResolvedValue(undefined),
listNodes: vi.fn().mockResolvedValue([
{ id: "node-local", name: "local", type: "local", status: "offline" },
]),
@@ -412,6 +413,8 @@ const mocks = vi.hoisted(() => {
heartbeatTriggerScheduler.stop();
}),
getTaskStore: vi.fn(() => store),
getProjectId: vi.fn(() => runtimeConfig.projectId),
getWorkingDirectory: vi.fn(() => runtimeConfig.workingDirectory),
getAutomationStore: vi.fn(() => automationStore),
getRuntime: vi.fn(() => ({
getHeartbeatMonitor: () => heartbeatMonitor,
@@ -847,7 +850,7 @@ describe("runDaemon", () => {
}
});
it("--no-auto-register preserves legacy exit behavior", async () => {
it("--no-auto-register falls back to existing started engines", async () => {
const freshCwd = mkdtempSync(join(tmpdir(), "daemon-no-auto-register-"));
cwdSpy.mockReturnValue(freshCwd);
@@ -858,8 +861,12 @@ describe("runDaemon", () => {
instance.registerProject.mock.calls,
);
expect(registrationCalls).toHaveLength(0);
expect(errorSpy).toHaveBeenCalledWith("[daemon] No engine started for the current project — exiting");
expect(process.exit).toHaveBeenCalledWith(1);
expect(process.exit).not.toHaveBeenCalledWith(1);
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining("[daemon] HTTP layer bound to project")
);
await triggerSignal("SIGINT");
} finally {
rmSync(freshCwd, { recursive: true, force: true });
}

View File

@@ -193,6 +193,7 @@ const mocks = vi.hoisted(() => {
Promise.resolve(projects.find((project) => project.id === id) ?? null),
),
listProjects: vi.fn().mockImplementation(() => Promise.resolve([...projects])),
getDefaultProjectId: vi.fn().mockResolvedValue(undefined),
listNodes: vi.fn().mockResolvedValue([
{ id: "node-local", name: "local", type: "local", status: "offline" },
]),
@@ -461,6 +462,8 @@ const mocks = vi.hoisted(() => {
heartbeatTriggerScheduler.stop();
}),
getTaskStore: vi.fn(() => store),
getProjectId: vi.fn(() => runtimeConfig.projectId),
getWorkingDirectory: vi.fn(() => runtimeConfig.workingDirectory),
getAutomationStore: vi.fn(() => automationStore),
getRuntime: vi.fn(() => ({
getHeartbeatMonitor: () => heartbeatMonitor,
@@ -1964,7 +1967,7 @@ describe("runServe — multi-project cwd/default engine resolution", () => {
}
});
it("--no-auto-register preserves legacy exit behavior", async () => {
it("--no-auto-register falls back to existing started engines", async () => {
const freshCwd = mkdtempSync(join(tmpdir(), "serve-no-auto-register-"));
cwdSpy.mockReturnValue(freshCwd);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -1979,10 +1982,15 @@ describe("runServe — multi-project cwd/default engine resolution", () => {
logPrefix: "serve",
autoRegister: false,
}));
expect(process.exit).toHaveBeenCalledWith(1);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("[serve] No engine started for the current project")
expect(process.exit).not.toHaveBeenCalledWith(1);
expect(errorSpy).not.toHaveBeenCalledWith(
expect.stringContaining("[serve] No engines started")
);
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining("[serve] HTTP layer bound to project")
);
await triggerSignal("SIGINT");
} finally {
ensureSpy.mockRestore();
errorSpy.mockRestore();

View File

@@ -0,0 +1,251 @@
import { AgentStore } from "@fusion/core";
import type { Message } from "@fusion/core";
import { createMessageStore, formatParticipant, formatTime, CLI_USER_ID } from "./message.js";
import { resolveProject } from "../project-context.js";
import { createInterface } from "node:readline/promises";
const MAX_MESSAGE_LENGTH = 8192;
const DEFAULT_POLL_MS = 1000;
const HISTORY_LIMIT = 20;
export interface ChatInteractiveOptions {
project?: string;
pollIntervalMs?: number;
once?: boolean;
nonInteractive?: boolean;
input?: NodeJS.ReadableStream;
output?: NodeJS.WritableStream;
}
async function getProjectPath(projectName?: string): Promise<string> {
if (projectName) {
const context = await resolveProject(projectName);
return context.projectPath;
}
try {
const context = await resolveProject(undefined);
return context.projectPath;
} catch {
return process.cwd();
}
}
async function createAgentStore(projectName?: string): Promise<AgentStore> {
const projectPath = await getProjectPath(projectName);
const store = new AgentStore({ rootDir: `${projectPath}/.fusion` });
await store.init();
return store;
}
function parsePollMs(options: ChatInteractiveOptions): number {
const envValue = process.env.FUSION_CHAT_POLL_MS;
const envPollMs = envValue ? Number.parseInt(envValue, 10) : Number.NaN;
const candidate = options.pollIntervalMs ?? (Number.isFinite(envPollMs) ? envPollMs : DEFAULT_POLL_MS);
return Number.isFinite(candidate) && candidate > 0 ? candidate : DEFAULT_POLL_MS;
}
function printMessage(output: NodeJS.WritableStream, message: Message): void {
const fromLabel = formatParticipant(message.fromId, message.fromType);
const time = formatTime(message.createdAt);
output.write(`${fromLabel}${time}\n`);
output.write(`${message.content}\n\n`);
}
function printConversationTail(output: NodeJS.WritableStream, messages: Message[]): void {
if (messages.length === 0) {
output.write("\nNo messages yet.\n\n");
return;
}
output.write("\nRecent conversation:\n\n");
for (const message of messages) {
printMessage(output, message);
}
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms);
const onAbort = () => {
clearTimeout(timer);
reject(new Error("aborted"));
};
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
});
}
async function waitForReply(
messageStore: Awaited<ReturnType<typeof createMessageStore>>["store"],
agentId: string,
printedIds: Set<string>,
output: NodeJS.WritableStream,
pollIntervalMs: number,
timeoutMs: number,
): Promise<boolean> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const inbox = messageStore.getInbox(CLI_USER_ID, "user", { limit: 50 });
for (const message of inbox.slice().reverse()) {
if (message.fromId !== agentId || message.fromType !== "agent") continue;
if (printedIds.has(message.id)) continue;
printedIds.add(message.id);
printMessage(output, message);
messageStore.markAsRead(message.id);
return true;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
return false;
}
export async function runChatInteractive(agentId: string, options: ChatInteractiveOptions = {}): Promise<number> {
const output = options.output ?? process.stdout;
const input = options.input ?? process.stdin;
const pollIntervalMs = parsePollMs(options);
const agentStore = await createAgentStore(options.project);
const agent = await agentStore.getAgent(agentId);
if (!agent) {
console.error(`Agent ${agentId} not found`);
return 1;
}
const { store: messageStore, db } = await createMessageStore(options.project);
const printedIds = new Set<string>();
const conversation = messageStore.getConversation(
{ id: CLI_USER_ID, type: "user" },
{ id: agentId, type: "agent" },
);
const tail = conversation.slice(-HISTORY_LIMIT);
for (const message of tail) printedIds.add(message.id);
output.write(`Chat with Agent ${agentId} — type /exit or Ctrl-C to quit, /help for commands\n`);
output.write("Replies appear when this project's engine is running (fn dashboard or fn serve).\n");
printConversationTail(output, tail);
const runOnce = options.once === true;
try {
if (runOnce) {
const content = await readSingleMessage(input, output, options.nonInteractive);
if (!content.trim()) return 0;
if (content.length > MAX_MESSAGE_LENGTH) {
console.error(`Message too long; max ${MAX_MESSAGE_LENGTH} chars`);
return 0;
}
messageStore.sendMessage({
fromId: CLI_USER_ID,
fromType: "user",
toId: agentId,
toType: "agent",
content,
type: "user-to-agent",
metadata: { wakeRecipient: true },
});
output.write(`you → ${agentId}: ${content}\n`);
const timeoutMs = Math.max(pollIntervalMs * 10, 30_000);
const replied = await waitForReply(messageStore, agentId, printedIds, output, pollIntervalMs, timeoutMs);
if (!replied) {
console.error(`No reply within ${Math.ceil(timeoutMs / 1000)}s`);
}
return 0;
}
const abortController = new AbortController();
const poller = (async () => {
while (!abortController.signal.aborted) {
const inbox = messageStore.getInbox(CLI_USER_ID, "user", { limit: 50 });
for (const message of inbox.slice().reverse()) {
if (message.fromId !== agentId || message.fromType !== "agent") continue;
if (printedIds.has(message.id)) continue;
printedIds.add(message.id);
printMessage(output, message);
messageStore.markAsRead(message.id);
}
await sleep(pollIntervalMs, abortController.signal);
}
})().catch(() => undefined);
const rl = createInterface({ input, output });
rl.on("close", () => abortController.abort());
while (true) {
let line: string;
try {
line = (await rl.question("> ")).trim();
} catch {
break;
}
if (!line) continue;
if (line === "/exit" || line === "/quit") break;
if (line === "/help") {
output.write("Commands: /help, /history, /clear, /exit, /quit\n");
continue;
}
if (line === "/history") {
const history = messageStore.getConversation(
{ id: CLI_USER_ID, type: "user" },
{ id: agentId, type: "agent" },
).slice(-HISTORY_LIMIT);
for (const message of history) printedIds.add(message.id);
printConversationTail(output, history);
continue;
}
if (line === "/clear") {
output.write("\x1b[2J\x1b[H");
continue;
}
if (line.length > MAX_MESSAGE_LENGTH) {
console.error(`Message too long; max ${MAX_MESSAGE_LENGTH} chars`);
continue;
}
messageStore.sendMessage({
fromId: CLI_USER_ID,
fromType: "user",
toId: agentId,
toType: "agent",
content: line,
type: "user-to-agent",
metadata: { wakeRecipient: true },
});
output.write(`you → ${agentId}: ${line}\n`);
}
abortController.abort();
rl.close();
await poller;
return 0;
} finally {
db.close();
}
}
async function readSingleMessage(
input: NodeJS.ReadableStream,
output: NodeJS.WritableStream,
nonInteractive?: boolean,
): Promise<string> {
if (nonInteractive) {
const chunks: Buffer[] = [];
for await (const chunk of input) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
}
return Buffer.concat(chunks).toString("utf8").trimEnd();
}
const rl = createInterface({ input, output });
try {
return await rl.question("");
} finally {
rl.close();
}
}

View File

@@ -168,8 +168,10 @@ export interface DaemonOptions {
interactive?: boolean;
/** Just print/generate token without starting server */
tokenOnly?: boolean;
/** Disable cwd auto-registration and preserve legacy strict behavior */
/** Disable cwd auto-registration */
noAutoRegister?: boolean;
/** Preferred primary project (id or name). */
project?: string;
}
export async function runDaemon(opts: DaemonOptions = {}) {
@@ -355,14 +357,72 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}
}
// Get the cwd project's engine and store for the HTTP layer
const cwdEngine = ntfyProjectId ? engineManager.getEngine(ntfyProjectId) : undefined;
if (!cwdEngine) {
console.error("[daemon] No engine started for the current project — exiting");
const startedEngines = [...engineManager.getAllEngines().values()];
const projects = sharedCentralCore ? await sharedCentralCore.listProjects() : [];
const resolvePrimaryEngine = async (): Promise<{
engine: (typeof startedEngines)[number];
source: "cli-flag" | "default-setting" | "cwd" | "fallback";
} | null> => {
if (opts.project) {
const byId = startedEngines.find((engine) => engine.getProjectId() === opts.project);
if (byId) {
return { engine: byId, source: "cli-flag" };
}
const projectMatch = projects.find((project) => project.name === opts.project);
if (projectMatch) {
const byName = engineManager.getEngine(projectMatch.id);
if (byName) {
return { engine: byName, source: "cli-flag" };
}
}
console.error(`[daemon] --project "${opts.project}" did not match any started engine`);
process.exit(1);
return null;
}
const defaultProjectId = await sharedCentralCore?.getDefaultProjectId?.();
if (defaultProjectId) {
const defaultEngine = engineManager.getEngine(defaultProjectId);
if (defaultEngine) {
return { engine: defaultEngine, source: "default-setting" };
}
console.warn(`[daemon] defaultProjectId ${defaultProjectId} is set but no engine started for it — falling through`);
}
const cwdEngine = ntfyProjectId ? engineManager.getEngine(ntfyProjectId) : undefined;
if (cwdEngine) {
return { engine: cwdEngine, source: "cwd" };
}
const fallback = startedEngines[0];
if (!fallback) {
return null;
}
return { engine: fallback, source: "fallback" };
};
const primarySelection = await resolvePrimaryEngine();
if (!primarySelection) {
console.error("[daemon] No engines started — registry empty or all engines failed to start. Exiting.");
process.exit(1);
return;
}
const store = cwdEngine.getTaskStore();
const primaryEngine = primarySelection.engine;
const primaryProjectId = primaryEngine.getProjectId();
ntfyProjectId = primaryProjectId;
const primaryProject = projects.find((project) => project.id === primaryProjectId);
const primaryProjectName = primaryProject?.name ?? primaryProjectId;
const primaryCwd = primaryEngine.getWorkingDirectory();
console.log(
`[daemon] HTTP layer bound to project ${primaryProjectName} (${primaryProjectId}) [source: ${primarySelection.source}]`,
);
const store = primaryEngine.getTaskStore();
await store.watch();
@@ -417,11 +477,11 @@ export async function runDaemon(opts: DaemonOptions = {}) {
);
}
// Get subsystems from the cwd engine for the HTTP layer
const heartbeatMonitor = cwdEngine.getRuntime().getHeartbeatMonitor();
const missionAutopilot = cwdEngine.getRuntime().getMissionAutopilot();
const missionExecutionLoop = cwdEngine.getRuntime().getMissionExecutionLoop();
const automationStore = cwdEngine.getAutomationStore();
// Get subsystems from the primary engine for the HTTP layer
const heartbeatMonitor = primaryEngine.getRuntime().getHeartbeatMonitor();
const missionAutopilot = primaryEngine.getRuntime().getMissionAutopilot();
const missionExecutionLoop = primaryEngine.getRuntime().getMissionExecutionLoop();
const automationStore = primaryEngine.getAutomationStore();
const authStorage = AuthStorage.create(getFusionAuthPath());
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
@@ -438,9 +498,9 @@ export async function runDaemon(opts: DaemonOptions = {}) {
try {
const agentDir = getPackageManagerAgentDir();
packageManager = new DefaultPackageManager({
cwd,
cwd: primaryCwd,
agentDir,
settingsManager: createReadOnlyProviderSettingsView(cwd, agentDir) as unknown as SettingsManager,
settingsManager: createReadOnlyProviderSettingsView(primaryCwd, agentDir) as unknown as SettingsManager,
});
const resolvedPaths = await packageManager.resolve();
const packageExtensionPaths = resolvedPaths.extensions
@@ -515,14 +575,14 @@ export async function runDaemon(opts: DaemonOptions = {}) {
setHostExtensionPaths(selfExtensionPaths);
const reconciledExtensionPaths = reconcileClaudeCliPaths(
[...selfExtensionPaths, ...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths, ...claudeCliPaths],
[...selfExtensionPaths, ...getEnabledPiExtensionPaths(primaryCwd), ...packageExtensionPaths, ...claudeCliPaths],
claudeCliPaths[0] ?? null,
);
const extensionsResult = await discoverAndLoadExtensions(
[...reconciledExtensionPaths, ...droidCliPaths, ...llamaCppPaths],
cwd,
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
primaryCwd,
join(primaryCwd, ".fusion", "disabled-auto-extension-discovery"),
);
for (const { path, error } of extensionsResult.errors) {
@@ -575,10 +635,10 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}, DIAGNOSTIC_INTERVAL_MS).unref?.();
const app = createServer(store, {
engine: cwdEngine,
engine: primaryEngine,
engineManager,
centralCore: sharedCentralCore ?? undefined,
onMerge: (taskId) => cwdEngine.onMerge(taskId),
onMerge: (taskId) => primaryEngine.onMerge(taskId),
authStorage: dashboardAuthStorage,
modelRegistry,
automationStore,
@@ -586,7 +646,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
missionExecutionLoop,
heartbeatMonitor: heartbeatMonitor
? {
rootDir: cwd,
rootDir: primaryCwd,
startRun: heartbeatMonitor.startRun.bind(heartbeatMonitor),
executeHeartbeat: heartbeatMonitor.executeHeartbeat.bind(heartbeatMonitor),
stopRun: heartbeatMonitor.stopRun.bind(heartbeatMonitor),

View File

@@ -0,0 +1,124 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { TaskStore } from "@fusion/core";
import {
defaultGitOps,
ExperimentFinalizeBranchExistsError,
ExperimentFinalizeCherryPickConflictError,
ExperimentFinalizeMergeBaseError,
ExperimentFinalizeNoKeptRunsError,
ExperimentFinalizePlanError,
ExperimentFinalizeService,
ExperimentFinalizeStateError,
type FinalizePlanOverride,
} from "@fusion/engine";
import { resolveProject } from "../project-context.js";
interface ExperimentFinalizeOptions {
sessionId: string;
integrationBranch?: string;
dryRun?: boolean;
json?: boolean;
summary?: string;
planFile?: string;
projectName?: string;
}
const EXIT_CODES = new Map<string, number>([
["state_error", 2],
["no_kept_runs", 3],
["plan_error", 4],
["merge_base_error", 5],
["cherry_pick_conflict", 6],
["branch_exists", 7],
]);
function printJson(payload: unknown): void {
console.log(JSON.stringify(payload, null, 2));
}
function printPlan(plan: Awaited<ReturnType<ExperimentFinalizeService["previewPlan"]>>): void {
console.log(`Session: ${plan.sessionId}`);
console.log(`Merge base: ${plan.mergeBaseCommit}`);
for (const group of plan.groups) {
console.log(`- ${group.title} -> ${group.suggestedBranchName} (${group.commits.length} commits)`);
}
}
async function parsePlanOverride(path: string): Promise<FinalizePlanOverride> {
const content = await readFile(resolve(path), "utf8");
return JSON.parse(content) as FinalizePlanOverride;
}
function exitWithError(error: unknown): never {
if (error instanceof ExperimentFinalizeCherryPickConflictError) {
console.error(`Error: ${error.message}`);
console.error(JSON.stringify({ groupId: error.groupId, commit: error.commit, stderr: error.stderr }));
process.exit(6);
}
const code = (error as { code?: string })?.code;
if (code && EXIT_CODES.has(code)) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(EXIT_CODES.get(code)!);
}
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
export async function runExperimentFinalize(options: ExperimentFinalizeOptions): Promise<void> {
try {
const project = options.projectName ? await resolveProject(options.projectName) : undefined;
const projectRoot = project?.projectPath ?? process.cwd();
const taskStore = new TaskStore(projectRoot);
await taskStore.init();
const sessionStore = taskStore.getExperimentSessionStore();
const service = new ExperimentFinalizeService({
store: sessionStore,
git: defaultGitOps(projectRoot),
});
const planOverride = options.planFile ? await parsePlanOverride(options.planFile) : undefined;
if (options.dryRun) {
const plan = await service.previewPlan({
sessionId: options.sessionId,
integrationBranch: options.integrationBranch,
});
if (options.json) {
printJson({ plan });
} else {
printPlan(plan);
}
return;
}
const result = await service.finalize({
sessionId: options.sessionId,
integrationBranch: options.integrationBranch,
planOverride,
summary: options.summary,
});
if (options.json) {
printJson({ result });
return;
}
console.log(`Finalized session ${result.sessionId}`);
for (const branch of result.branches) {
console.log(`- ${branch.name} (${branch.tipCommit})`);
}
} catch (error) {
if (
error instanceof ExperimentFinalizeStateError
|| error instanceof ExperimentFinalizeNoKeptRunsError
|| error instanceof ExperimentFinalizePlanError
|| error instanceof ExperimentFinalizeMergeBaseError
|| error instanceof ExperimentFinalizeBranchExistsError
|| error instanceof ExperimentFinalizeCherryPickConflictError
) {
exitWithError(error);
}
exitWithError(error);
}
}

View File

@@ -24,7 +24,7 @@ async function getProjectPath(projectName?: string): Promise<string> {
* Create a MessageStore for the given project.
* Returns both the store and database for proper cleanup.
*/
async function createMessageStore(projectName?: string): Promise<{ store: MessageStore; db: Database }> {
export async function createMessageStore(projectName?: string): Promise<{ store: MessageStore; db: Database }> {
const projectPath = await getProjectPath(projectName);
const fusionDir = projectPath + "/.fusion";
const db = createDatabase(fusionDir);
@@ -34,7 +34,7 @@ async function createMessageStore(projectName?: string): Promise<{ store: Messag
}
/** User ID for CLI-originated messages */
const CLI_USER_ID = "cli";
export const CLI_USER_ID = "cli";
/**
* List inbox messages.
@@ -211,7 +211,7 @@ export async function runAgentMailbox(agentId: string, projectName?: string): Pr
// ── Helpers ───────────────────────────────────────────────────────────────
function formatParticipant(id: string, type: ParticipantType): string {
export function formatParticipant(id: string, type: ParticipantType): string {
switch (type) {
case "agent": return `Agent ${id}`;
case "user": return id === "cli" ? "You (CLI)" : id === "dashboard" ? "You (Dashboard)" : `User ${id}`;
@@ -219,7 +219,7 @@ function formatParticipant(id: string, type: ParticipantType): string {
}
}
function formatTime(ts: string): string {
export function formatTime(ts: string): string {
const date = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - date.getTime();

View File

@@ -224,7 +224,7 @@ function ensureProcessDiagnostics(): void {
export async function runServe(
port: number,
opts: { interactive?: boolean; paused?: boolean; host?: string; daemon?: boolean; noAutoRegister?: boolean } = {},
opts: { interactive?: boolean; paused?: boolean; host?: string; daemon?: boolean; noAutoRegister?: boolean; project?: string } = {},
) {
serveStartTime = Date.now();
ensureProcessDiagnostics();
@@ -399,15 +399,72 @@ export async function runServe(
}
}
// Get the cwd project's engine and store for the HTTP layer.
// serve.ts needs a store for plugin setup, diagnostics, and the server.
const cwdEngine = ntfyProjectId ? engineManager.getEngine(ntfyProjectId) : undefined;
if (!cwdEngine) {
console.error("[serve] No engine started for the current project — exiting");
const startedEngines = [...engineManager.getAllEngines().values()];
const projects = sharedCentralCore ? await sharedCentralCore.listProjects() : [];
const resolvePrimaryEngine = async (): Promise<{
engine: (typeof startedEngines)[number];
source: "cli-flag" | "default-setting" | "cwd" | "fallback";
} | null> => {
if (opts.project) {
const byId = startedEngines.find((engine) => engine.getProjectId() === opts.project);
if (byId) {
return { engine: byId, source: "cli-flag" };
}
const projectMatch = projects.find((project) => project.name === opts.project);
if (projectMatch) {
const byName = engineManager.getEngine(projectMatch.id);
if (byName) {
return { engine: byName, source: "cli-flag" };
}
}
console.error(`[serve] --project "${opts.project}" did not match any started engine`);
process.exit(1);
return null;
}
const defaultProjectId = await sharedCentralCore?.getDefaultProjectId?.();
if (defaultProjectId) {
const defaultEngine = engineManager.getEngine(defaultProjectId);
if (defaultEngine) {
return { engine: defaultEngine, source: "default-setting" };
}
console.warn(`[serve] defaultProjectId ${defaultProjectId} is set but no engine started for it — falling through`);
}
const cwdEngine = ntfyProjectId ? engineManager.getEngine(ntfyProjectId) : undefined;
if (cwdEngine) {
return { engine: cwdEngine, source: "cwd" };
}
const fallback = startedEngines[0];
if (!fallback) {
return null;
}
return { engine: fallback, source: "fallback" };
};
const primarySelection = await resolvePrimaryEngine();
if (!primarySelection) {
console.error("[serve] No engines started — registry empty or all engines failed to start. Exiting.");
process.exit(1);
return; // unreachable in production, but needed for test mocks
return;
}
const store = cwdEngine.getTaskStore();
const primaryEngine = primarySelection.engine;
const primaryProjectId = primaryEngine.getProjectId();
ntfyProjectId = primaryProjectId;
const primaryProject = projects.find((project) => project.id === primaryProjectId);
const primaryProjectName = primaryProject?.name ?? primaryProjectId;
const primaryCwd = primaryEngine.getWorkingDirectory();
console.log(
`[serve] HTTP layer bound to project ${primaryProjectName} (${primaryProjectId}) [source: ${primarySelection.source}]`,
);
const store = primaryEngine.getTaskStore();
// InProcessRuntime does not call store.watch() — do it here so SSE events
// and file-watcher triggers are active for the HTTP layer.
@@ -503,11 +560,11 @@ export async function runServe(
);
}
// Get subsystems from the cwd engine for the HTTP layer
const heartbeatMonitor = cwdEngine.getRuntime().getHeartbeatMonitor();
const missionAutopilot = cwdEngine.getRuntime().getMissionAutopilot();
const missionExecutionLoop = cwdEngine.getRuntime().getMissionExecutionLoop();
const automationStore = cwdEngine.getAutomationStore();
// Get subsystems from the primary engine for the HTTP layer
const heartbeatMonitor = primaryEngine.getRuntime().getHeartbeatMonitor();
const missionAutopilot = primaryEngine.getRuntime().getMissionAutopilot();
const missionExecutionLoop = primaryEngine.getRuntime().getMissionExecutionLoop();
const automationStore = primaryEngine.getAutomationStore();
const authStorage = AuthStorage.create(getFusionAuthPath());
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
@@ -524,9 +581,9 @@ export async function runServe(
try {
const agentDir = getPackageManagerAgentDir();
packageManager = new DefaultPackageManager({
cwd,
cwd: primaryCwd,
agentDir,
settingsManager: createReadOnlyProviderSettingsView(cwd, agentDir) as unknown as SettingsManager,
settingsManager: createReadOnlyProviderSettingsView(primaryCwd, agentDir) as unknown as SettingsManager,
});
const resolvedPaths = await packageManager.resolve();
const packageExtensionPaths = resolvedPaths.extensions
@@ -602,14 +659,14 @@ export async function runServe(
const extensionsResult = await discoverAndLoadExtensions(
[
...selfExtensionPaths,
...getEnabledPiExtensionPaths(cwd),
...getEnabledPiExtensionPaths(primaryCwd),
...packageExtensionPaths,
...claudeCliPaths,
...droidCliPaths,
...llamaCppPaths,
],
cwd,
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
primaryCwd,
join(primaryCwd, ".fusion", "disabled-auto-extension-discovery"),
);
for (const { path, error } of extensionsResult.errors) {
@@ -715,10 +772,10 @@ export async function runServe(
: undefined;
const app = createServer(store, {
engine: cwdEngine,
engine: primaryEngine,
engineManager,
centralCore: sharedCentralCore ?? undefined,
onMerge: (taskId) => cwdEngine.onMerge(taskId),
onMerge: (taskId) => primaryEngine.onMerge(taskId),
authStorage: dashboardAuthStorage,
modelRegistry,
automationStore,
@@ -726,7 +783,7 @@ export async function runServe(
missionExecutionLoop,
heartbeatMonitor: heartbeatMonitor
? {
rootDir: cwd,
rootDir: primaryCwd,
startRun: heartbeatMonitor.startRun.bind(heartbeatMonitor),
executeHeartbeat: heartbeatMonitor.executeHeartbeat.bind(heartbeatMonitor),
stopRun: heartbeatMonitor.stopRun.bind(heartbeatMonitor),

View File

@@ -28,7 +28,18 @@ import {
isGhAvailable,
runGhJsonAsync,
} from "@fusion/core/gh-cli";
import { fetchWebContent } from "@fusion/engine";
import {
defaultGitOps,
ExperimentFinalizeBranchExistsError,
ExperimentFinalizeCherryPickConflictError,
ExperimentFinalizeMergeBaseError,
ExperimentFinalizeNoKeptRunsError,
ExperimentFinalizePlanError,
ExperimentFinalizeService,
ExperimentFinalizeStateError,
type FinalizePlanOverride,
fetchWebContent,
} from "@fusion/engine";
import { resolve, basename, extname, join } from "node:path";
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
@@ -1725,6 +1736,76 @@ export default function kbExtension(pi: ExtensionAPI) {
},
});
pi.registerTool({
name: "fn_experiment_finalize",
label: "fn: Finalize Experiment Session",
description: "Group kept experiment runs into reviewable branches and finalize the session. Use dryRun=true to preview the plan without touching git.",
parameters: Type.Object({
sessionId: Type.String({ description: "Experiment session ID" }),
integrationBranch: Type.Optional(Type.String({ description: "Integration branch to compute merge-base against (default: main)" })),
dryRun: Type.Optional(Type.Boolean({ description: "Preview plan only; do not create branches" })),
planOverride: Type.Optional(Type.Any({ description: "Optional plan override payload" })),
summary: Type.Optional(Type.String({ description: "Optional finalize summary" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
try {
const store = await getStore(ctx.cwd);
const sessionStore = store.getExperimentSessionStore();
const service = new ExperimentFinalizeService({
store: sessionStore,
git: defaultGitOps(resolveProjectRoot(ctx.cwd)),
});
if (params.dryRun) {
const plan = await service.previewPlan({
sessionId: params.sessionId,
integrationBranch: params.integrationBranch,
});
return {
content: [{ type: "text", text: `Finalize plan for ${plan.sessionId}: ${plan.groups.length} group(s), merge-base ${plan.mergeBaseCommit}.` }],
details: { plan },
};
}
const result = await service.finalize({
sessionId: params.sessionId,
integrationBranch: params.integrationBranch,
planOverride: params.planOverride as FinalizePlanOverride | undefined,
summary: params.summary,
});
return {
content: [{ type: "text", text: `Finalized ${result.sessionId}. Created ${result.branches.length} branch(es).` }],
details: { result },
};
} catch (error) {
if (
error instanceof ExperimentFinalizeStateError
|| error instanceof ExperimentFinalizeNoKeptRunsError
|| error instanceof ExperimentFinalizePlanError
|| error instanceof ExperimentFinalizeMergeBaseError
|| error instanceof ExperimentFinalizeBranchExistsError
|| error instanceof ExperimentFinalizeCherryPickConflictError
) {
return {
content: [{ type: "text", text: error.message }],
isError: true,
details: {
code: error.code,
...(error instanceof ExperimentFinalizeCherryPickConflictError
? { groupId: error.groupId, commit: error.commit, stderr: error.stderr }
: {}),
},
};
}
return {
content: [{ type: "text", text: error instanceof Error ? error.message : "Unknown experiment finalize error" }],
isError: true,
details: { code: "INTERNAL_ERROR" },
};
}
},
});
// ── Insights Tools ──────────────────────────────────────────────
pi.registerTool({
@@ -2446,6 +2527,83 @@ export default function kbExtension(pi: ExtensionAPI) {
},
});
// ── fn_feature_update ─────────────────────────────────────────────
pi.registerTool({
name: "fn_feature_update",
label: "fn: Update Feature",
description:
"Update an existing feature's title, description, or acceptance criteria. " +
"Partial patches leave untouched fields intact.",
promptSnippet: "Update an existing mission feature",
promptGuidelines: [
"Use to revise acceptance criteria after reconciliation without re-creating the feature",
"Slice ordering and linked tasks are preserved",
"Provide only the fields you want to change",
],
parameters: Type.Object({
id: Type.String({ description: "Feature ID to update (e.g., F-001)" }),
title: Type.Optional(Type.String({ description: "Updated feature title" })),
description: Type.Optional(Type.String({ description: "Updated feature description" })),
acceptanceCriteria: Type.Optional(
Type.String({ description: "Updated acceptance criteria for completing the feature" })
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const existingFeature = missionStore.getFeature(params.id);
if (!existingFeature) {
return {
content: [{ type: "text", text: `Feature ${params.id} not found` }],
isError: true,
details: { error: "Feature not found" },
};
}
const updates: { title?: string; description?: string; acceptanceCriteria?: string } = {};
if ("title" in params) {
updates.title = params.title?.trim();
}
if ("description" in params) {
updates.description = params.description?.trim();
}
if ("acceptanceCriteria" in params) {
updates.acceptanceCriteria = params.acceptanceCriteria?.trim();
}
if (Object.keys(updates).length === 0) {
return {
content: [
{
type: "text",
text: "No fields to update (provide at least one of: title, description, acceptanceCriteria)",
},
],
isError: true,
details: { error: "No fields to update" },
};
}
const feature = missionStore.updateFeature(params.id, updates);
return {
content: [{ type: "text", text: `Updated ${feature.id}: "${feature.title}"` }],
details: {
featureId: feature.id,
sliceId: feature.sliceId,
title: feature.title,
description: feature.description,
acceptanceCriteria: feature.acceptanceCriteria,
status: feature.status,
},
};
},
});
// ── fn_agent_stop ─────────────────────────────────────────────────
pi.registerTool({