Files
fusion/packages/cli/src/extension.ts
gsxdsm 901d61fa85 fix(cli): validate agentId in fn_task_create/update and unblock bundle tests
fn_task_create and fn_task_update accepted any string as `agentId` and
wrote it verbatim onto `task.assignedAgentId`, letting hallucinated IDs
(e.g. `agent-executor-001`) appear as agent badges in the dashboard.
Mirror the validation already used by fn_delegate: look the agent up via
AgentStore and reject unknown or ephemeral/runtime-managed agents. Null
still clears the field on update.

Also clean up two stale failures in bundle-output.test that predated this
change:
- pi-claude-cli no longer imports cross-spawn, so drop the dependency and
  its orphan type-decl file.
- Loosen the spawn-import regex to match `spawn` anywhere in the
  destructured import (the source has additional named imports).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:34:34 -07:00

2017 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type, type TSchema } from "typebox";
import { StringEnum } from "@mariozechner/pi-ai";
import {
TaskStore,
COLUMNS,
COLUMN_LABELS,
validateNodeOverrideChange,
type Task,
} from "@fusion/core";
import {
getGhErrorMessage,
isGhAuthenticated,
isGhAvailable,
runGhJsonAsync,
} from "@fusion/core/gh-cli";
import { resolve, basename, extname, join } from "node:path";
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { spawn, type ChildProcess } from "node:child_process";
// ── Helpers ────────────────────────────────────────────────────────
const MIME_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".txt": "text/plain",
".log": "text/plain",
".json": "application/json",
".yaml": "text/yaml",
".yml": "text/yaml",
".toml": "text/x-toml",
".csv": "text/csv",
".xml": "application/xml",
};
function resolveProjectRoot(cwd: string): string {
let current = resolve(cwd);
while (true) {
if (existsSync(join(current, ".fusion"))) {
return current;
}
const parent = resolve(current, "..");
if (parent === current) {
return resolve(cwd);
}
current = parent;
}
}
/** Cache stores per project root to avoid re-init on every tool call. */
const storeCache = new Map<string, TaskStore>();
async function getStore(cwd: string): Promise<TaskStore> {
const projectRoot = resolveProjectRoot(cwd);
const existing = storeCache.get(projectRoot);
if (existing) return existing;
const store = new TaskStore(projectRoot);
await store.init();
storeCache.set(projectRoot, store);
return store;
}
function getFusionDir(cwd: string): string {
return join(resolveProjectRoot(cwd), ".fusion");
}
/**
* Validate an agent id supplied to task create/update tools.
* Returns null on success, or an error message describing why the id was rejected.
*
* Rejects unknown agents and ephemeral/runtime-managed agents — mirrors fn_delegate
* so callers can't park hallucinated or task-worker IDs in `task.assignedAgentId`.
*/
async function validateAssignableAgentId(
cwd: string,
agentId: string,
): Promise<string | null> {
const { AgentStore, isEphemeralAgent } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: getFusionDir(cwd) });
await agentStore.init();
const agent = await agentStore.getAgent(agentId);
if (!agent) {
return `Agent ${agentId} not found`;
}
if (isEphemeralAgent(agent)) {
return `Cannot assign task to ephemeral/runtime agent ${agentId}`;
}
return null;
}
function formatTaskLine(t: Task): string {
const label =
t.title || t.description.slice(0, 60) + (t.description.length > 60 ? "…" : "");
const deps = t.dependencies.length ? ` [deps: ${t.dependencies.join(", ")}]` : "";
const paused = t.paused ? " (paused)" : "";
return `${t.id} ${label}${deps}${paused}`;
}
interface GitHubIssueApiResult {
number: number;
title: string;
body: string | null;
html_url: string;
labels?: Array<{ name: string }>;
pull_request?: unknown;
}
function ensureGhCliAuth(): void {
if (!isGhAvailable() || !isGhAuthenticated()) {
throw new Error("GitHub CLI (gh) is not available or not authenticated. Run 'gh auth login'.");
}
}
async function fetchGitHubIssuesViaGh(
owner: string,
repo: string,
options: { limit?: number; labels?: string[]; signal?: AbortSignal } = {},
): Promise<GitHubIssueApiResult[]> {
ensureGhCliAuth();
const queryParams = new URLSearchParams();
queryParams.append("state", "open");
queryParams.append("per_page", String(Math.min(options.limit ?? 30, 100)));
if (options.labels && options.labels.length > 0) {
queryParams.append("labels", options.labels.join(","));
}
const path = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${queryParams.toString()}`;
try {
const issues = await runGhJsonAsync<GitHubIssueApiResult[]>(["api", path], { signal: options.signal });
return issues.filter((issue) => !issue.pull_request);
} catch (error) {
throw new Error(getGhErrorMessage(error));
}
}
async function fetchGitHubIssueViaGh(
owner: string,
repo: string,
issueNumber: number,
options: { signal?: AbortSignal } = {},
): Promise<GitHubIssueApiResult> {
ensureGhCliAuth();
const path = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`;
try {
return await runGhJsonAsync<GitHubIssueApiResult>(["api", path], { signal: options.signal });
} catch (error) {
throw new Error(getGhErrorMessage(error));
}
}
// ── Extension entry point ──────────────────────────────────────────
export default function kbExtension(pi: ExtensionAPI) {
// ── fn_task_create ───────────────────────────────────────────────
pi.registerTool({
name: "fn_task_create",
label: "fn: Create Task",
description:
"Create a new task on the Fusion task board. The task enters the planning column " +
"where the AI planning agent will plan it into a full prompt with steps, " +
"file scope, and acceptance criteria.",
promptSnippet: "Create a task on the Fusion AI-orchestrated task board",
promptGuidelines: [
"Use fn_task_create for task tracking — be descriptive so the planning agent can write a good plan.",
"Include the problem AND desired outcome. For bugs, describe current vs expected behavior.",
],
parameters: Type.Object({
description: Type.String({ description: "What needs to be done — be descriptive" }),
depends: Type.Optional(
Type.Array(Type.String(), {
description: "Task IDs this depends on (e.g. ['FN-001', 'FN-002'])",
}),
),
agentId: Type.Optional(
Type.String({
description: "Agent ID to assign this task to (e.g. 'agent-abc123')",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
if (params.agentId !== undefined) {
const error = await validateAssignableAgentId(ctx.cwd, params.agentId);
if (error) {
return {
content: [{ type: "text", text: error }],
isError: true,
details: { error },
};
}
}
const task = await store.createTask({
description: params.description.trim(),
dependencies: params.depends,
assignedAgentId: params.agentId,
});
const label =
task.description.length > 80
? task.description.slice(0, 80) + "…"
: task.description;
return {
content: [
{
type: "text",
text:
`Created ${task.id}: ${label}\n` +
`Column: triage\n` +
(task.dependencies.length
? `Dependencies: ${task.dependencies.join(", ")}\n`
: "") +
(task.assignedAgentId
? `Assigned to: ${task.assignedAgentId}\n`
: "") +
`Path: .fusion/tasks/${task.id}/`,
},
],
details: {
taskId: task.id,
column: task.column,
dependencies: task.dependencies,
assignedAgentId: task.assignedAgentId,
},
};
},
});
// ── fn_task_update ────────────────────────────────────────────────
pi.registerTool({
name: "fn_task_update",
label: "fn: Update Task",
description:
"Update fields on an existing task. Supports modifying the title, " +
"description, dependencies, and assigned agent after task creation.",
promptSnippet: "Update fields on an existing Fusion task",
promptGuidelines: [
"Use fn_task_update to modify task title, description, dependencies, or assigned agent after creation.",
"At least one field must be provided to update.",
],
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
title: Type.Optional(Type.String({ description: "New task title" })),
description: Type.Optional(Type.String({ description: "New task description" })),
depends: Type.Optional(
Type.Array(Type.String(), {
description: "New dependency list — replaces existing dependencies (e.g. ['FN-001', 'FN-002'])",
}),
),
agentId: Type.Optional(
Type.Union([
Type.String(),
Type.Null(),
], {
description: "Agent ID to assign this task to, or null to clear (e.g. 'agent-abc123')",
}),
),
nodeId: Type.Optional(
Type.Union([Type.String(), Type.Null()], {
description: "Node ID override for this task, or null to clear",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
// Validate task exists
let task: Task;
try {
task = await store.getTask(params.id);
} catch {
return {
content: [{ type: "text", text: `Task ${params.id} not found` }],
isError: true,
details: { error: "Task not found" },
};
}
// Build update payload
const updates: Record<string, unknown> = {};
const updatedFields: string[] = [];
if (params.title !== undefined) {
updates.title = params.title.trim();
updatedFields.push("title");
}
if (params.description !== undefined) {
updates.description = params.description.trim();
updatedFields.push("description");
}
if (params.depends !== undefined) {
updates.dependencies = params.depends;
updatedFields.push("dependencies");
}
if (params.agentId !== undefined) {
if (params.agentId !== null) {
const error = await validateAssignableAgentId(ctx.cwd, params.agentId);
if (error) {
return {
content: [{ type: "text", text: error }],
isError: true,
details: { error },
};
}
}
updates.assignedAgentId = params.agentId;
updatedFields.push("agentId");
}
if (params.nodeId !== undefined) {
const validation = validateNodeOverrideChange(task, params.nodeId ?? null);
if (!validation.allowed) {
return {
content: [{ type: "text", text: validation.message ?? "Node override change blocked" }],
isError: true,
details: { error: validation.reason },
};
}
updates.nodeId = params.nodeId;
updatedFields.push("nodeId");
}
if (updatedFields.length === 0) {
return {
content: [{ type: "text", text: "No fields to update. Provide at least one of: title, description, depends, agentId, nodeId." }],
isError: true,
details: { error: "No fields provided" },
};
}
await store.updateTask(params.id, updates);
return {
content: [
{
type: "text",
text: `Updated ${params.id}: ${updatedFields.join(", ")}`,
},
],
details: { taskId: params.id, updatedFields },
};
},
});
// ── fn_task_list ─────────────────────────────────────────────────
pi.registerTool({
name: "fn_task_list",
label: "fn: List Tasks",
description: "List all tasks on the Fusion board, grouped by column.",
promptSnippet: "List all tasks on the Fusion board grouped by column",
parameters: Type.Object({
column: Type.Optional(
StringEnum([...COLUMNS] as unknown as string[], {
description: "Filter to a specific column",
}) as unknown as TSchema,
),
limit: Type.Optional(
Type.Number({
description: "Max tasks to show per column (default: 10)",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const tasks = await store.listTasks({ slim: true });
if (tasks.length === 0) {
return {
content: [{ type: "text", text: "No tasks yet." }],
details: { count: 0 },
};
}
const perColumn = params.limit ?? 10;
const lines: string[] = [];
for (const col of COLUMNS) {
if (params.column && params.column !== col) continue;
const colTasks = tasks.filter((t) => t.column === col);
if (colTasks.length === 0) continue;
lines.push(`${COLUMN_LABELS[col]} (${colTasks.length}):`);
const shown = colTasks.slice(0, perColumn);
for (const t of shown) {
lines.push(` ${formatTaskLine(t)}`);
}
const hidden = colTasks.length - shown.length;
if (hidden > 0) {
lines.push(` ... and ${hidden} more`);
}
lines.push("");
}
return {
content: [{ type: "text", text: lines.join("\n").trimEnd() }],
details: { count: tasks.length },
};
},
});
// ── fn_task_show ─────────────────────────────────────────────────
pi.registerTool({
name: "fn_task_show",
label: "fn: Show Task",
description: "Show full details for a task including steps, progress, and log entries.",
promptSnippet: "Show full details for a Fusion task",
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.getTask(params.id);
const lines: string[] = [];
lines.push(`${task.id}: ${task.title || task.description}`);
lines.push(
`Column: ${COLUMN_LABELS[task.column]}` +
(task.size ? ` · Size: ${task.size}` : "") +
(task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""),
);
if (task.dependencies.length) {
lines.push(`Dependencies: ${task.dependencies.join(", ")}`);
}
if (task.paused) lines.push("Status: PAUSED");
lines.push("");
// Steps
if (task.steps.length > 0) {
const done = task.steps.filter((s) => s.status === "done").length;
lines.push(`Steps (${done}/${task.steps.length}):`);
for (let i = 0; i < task.steps.length; i++) {
const s = task.steps[i];
const icon =
s.status === "done"
? "✓"
: s.status === "in-progress"
? "▸"
: s.status === "skipped"
? ""
: " ";
const marker =
i === task.currentStep && s.status !== "done" ? " ◀" : "";
lines.push(` [${icon}] ${i}: ${s.name}${marker}`);
}
lines.push("");
}
// Prompt (truncated)
if (task.prompt) {
const promptPreview =
task.prompt.length > 500
? task.prompt.slice(0, 500) + "\n... (truncated)"
: task.prompt;
lines.push("Prompt:");
lines.push(promptPreview);
lines.push("");
}
// Recent log
if (task.log.length > 0) {
const recent = task.log.slice(-5);
lines.push(`Log (last ${recent.length}):`);
for (const l of recent) {
const ts = new Date(l.timestamp).toLocaleTimeString();
lines.push(
` ${ts} ${l.action}${l.outcome ? `${l.outcome}` : ""}`,
);
}
}
return {
content: [{ type: "text", text: lines.join("\n").trimEnd() }],
details: { task },
};
},
});
// ── fn_task_attach ───────────────────────────────────────────────
pi.registerTool({
name: "fn_task_attach",
label: "fn: Attach File",
description:
"Attach a file to a task. Supports images (png, jpg, gif, webp) and " +
"text files (txt, log, json, yaml, yml, toml, csv, xml).",
promptSnippet: "Attach a file to a Fusion task",
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
path: Type.String({ description: "Path to the file to attach" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const filePath = resolve(ctx.cwd, params.path.replace(/^@/, ""));
const filename = basename(filePath);
const ext = extname(filename).toLowerCase();
const mimeType = MIME_TYPES[ext];
if (!mimeType) {
throw new Error(
`Unsupported file type: ${ext}. Supported: ${Object.keys(MIME_TYPES).join(", ")}`,
);
}
let content: Buffer;
try {
content = await readFile(filePath);
} catch {
throw new Error(`Cannot read file: ${params.path}`);
}
const store = await getStore(ctx.cwd);
const attachment = await store.addAttachment(params.id, filename, content, mimeType);
const sizeKB = (attachment.size / 1024).toFixed(1);
return {
content: [
{
type: "text",
text:
`Attached to ${params.id}: ${attachment.originalName} (${sizeKB} KB)\n` +
`Path: .fusion/tasks/${params.id}/attachments/${attachment.filename}`,
},
],
details: { taskId: params.id, attachment },
};
},
});
// ── fn_task_pause ────────────────────────────────────────────────
pi.registerTool({
name: "fn_task_pause",
label: "fn: Pause Task",
description:
"Pause a task — stops all automated agent and scheduler interaction for this task.",
promptSnippet: "Pause a Fusion task (stops automation)",
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.pauseTask(params.id, true);
return {
content: [{ type: "text", text: `Paused ${task.id}` }],
details: { taskId: task.id },
};
},
});
// ── fn_task_unpause ──────────────────────────────────────────────
pi.registerTool({
name: "fn_task_unpause",
label: "fn: Unpause Task",
description:
"Unpause a task — resumes automated agent and scheduler interaction.",
promptSnippet: "Unpause a Fusion task (resumes automation)",
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.pauseTask(params.id, false);
return {
content: [{ type: "text", text: `Unpaused ${task.id}` }],
details: { taskId: task.id },
};
},
});
// ── fn_task_retry ────────────────────────────────────────────────
pi.registerTool({
name: "fn_task_retry",
label: "fn: Retry Task",
description:
"Retry a failed task — clears the error state and moves it back to the todo column for re-execution.",
promptSnippet: "Retry a failed Fusion task (clears error, moves to todo)",
promptGuidelines: [
"Use when a task has failed and needs to be retried from the beginning",
"Only tasks in 'failed' state can be retried",
"The task will be moved to the todo column with error state cleared",
],
parameters: Type.Object({
id: Type.String({ description: "Task ID to retry (e.g. FN-001). Must be in 'failed' state." }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
// Validate task exists
let task;
try {
task = await store.getTask(params.id);
} catch {
return {
content: [{ type: "text", text: `Task ${params.id} not found` }],
isError: true,
details: { error: "Task not found" },
};
}
// Validate task is in a retryable state
if (task.status !== 'failed' && task.status !== 'stuck-killed') {
return {
content: [{ type: "text", text: `Task ${params.id} is not in a retryable state (status: ${task.status || 'none'})` }],
isError: true,
details: { taskId: params.id, currentStatus: task.status },
};
}
// Clear failure state
await store.updateTask(params.id, { status: null, error: null });
// Move to todo column
await store.moveTask(params.id, 'todo');
// Log the retry action
await store.logEntry(params.id, "Retry requested via Pi extension", "Task reset to todo for retry");
return {
content: [{ type: "text", text: `Retried ${params.id} → todo (failure state cleared)` }],
details: { taskId: params.id, newColumn: 'todo' },
};
},
});
// ── fn_task_duplicate ─────────────────────────────────────────────
pi.registerTool({
name: "fn_task_duplicate",
label: "fn: Duplicate Task",
description:
"Duplicate an existing task, creating a fresh copy in planning. " +
"Copies the title and description but resets all execution state. " +
"The AI planning agent will replan the new task.",
promptSnippet: "Duplicate a Fusion task (creates copy in planning)",
promptGuidelines: [
"Use when a task needs to be re-done, split, or used as a template",
"The duplicated task will be placed in planning for replanning",
"Dependencies, attachments, and execution state are NOT copied",
],
parameters: Type.Object({
id: Type.String({ description: "Source task ID to duplicate (e.g. FN-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const newTask = await store.duplicateTask(params.id);
return {
content: [{ type: "text", text: `Duplicated ${params.id}${newTask.id}` }],
details: { sourceId: params.id, newTaskId: newTask.id },
};
},
});
// ── fn_task_refine ──────────────────────────────────────────────
pi.registerTool({
name: "fn_task_refine",
label: "fn: Refine Task",
description:
"Request a refinement of a completed or in-review task. " +
"Creates a new follow-up task in planning that references the original task as a dependency. " +
"Use this when a done or in-review task needs additional work, improvements, or follow-up changes.",
promptSnippet: "Create a refinement task for follow-up work on a completed task",
promptGuidelines: [
"Use when a completed or in-review task needs follow-up work or improvements",
"The original task must be in 'done' or 'in-review' column",
"The refinement task will be created in planning and depend on the original task",
"Provide clear feedback about what needs to be refined or improved",
],
parameters: Type.Object({
id: Type.String({ description: "Task ID to refine (e.g. FN-001). Must be in 'done' or 'in-review' column." }),
feedback: Type.String({
description: "Description of what needs to be refined or improved",
minLength: 1,
maxLength: 2000,
}),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const newTask = await store.refineTask(params.id, params.feedback);
return {
content: [
{ type: "text", text: `Created refinement ${newTask.id} for ${params.id}` },
],
details: { sourceId: params.id, newTaskId: newTask.id, feedback: params.feedback },
};
},
});
// ── fn_task_archive ───────────────────────────────────────────────
pi.registerTool({
name: "fn_task_archive",
label: "fn: Archive Task",
description:
"Archive a done task (move from done → archived). " +
"Archived tasks are preserved for historical reference but moved out of the main board view.",
promptSnippet: "Archive a done Fusion task (moves to archived column)",
promptGuidelines: [
"Use to clean up old completed tasks from the done column",
"Only tasks in the 'done' column can be archived",
"Archived tasks can be unarchived later if needed",
],
parameters: Type.Object({
id: Type.String({ description: "Task ID to archive (e.g. FN-001). Must be in 'done' column." }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.archiveTask(params.id);
return {
content: [{ type: "text", text: `Archived ${task.id}${COLUMN_LABELS[task.column]}` }],
details: { taskId: task.id, column: task.column },
};
},
});
// ── fn_task_unarchive ─────────────────────────────────────────────
pi.registerTool({
name: "fn_task_unarchive",
label: "fn: Unarchive Task",
description:
"Unarchive an archived task (move from archived → done). " +
"Restores the task to the done column.",
promptSnippet: "Unarchive a Fusion task (restores to done column)",
promptGuidelines: [
"Use to restore an archived task back to the done column",
"Only tasks in the 'archived' column can be unarchived",
],
parameters: Type.Object({
id: Type.String({ description: "Task ID to unarchive (e.g. FN-001). Must be in 'archived' column." }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.unarchiveTask(params.id);
return {
content: [{ type: "text", text: `Unarchived ${task.id}${COLUMN_LABELS[task.column]}` }],
details: { taskId: task.id, column: task.column },
};
},
});
// ── fn_task_delete ─────────────────────────────────────────────────
pi.registerTool({
name: "fn_task_delete",
label: "fn: Delete Task",
description:
"Permanently delete a task from the Fusion board. " +
"Tasks are deleted immediately and cannot be recovered.",
promptSnippet: "Delete a Fusion task",
promptGuidelines: [
"Use for cleaning up test tasks or tasks created in error",
"Tasks are permanently deleted and cannot be recovered",
"Consider archiving instead of deleting for completed work you may need to reference later",
],
parameters: Type.Object({
id: Type.String({ description: "Task ID to delete (e.g. FN-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.deleteTask(params.id);
return {
content: [{ type: "text", text: `Deleted ${task.id}` }],
details: { taskId: task.id },
};
},
});
// ── fn_task_import_github ─────────────────────────────────────────
pi.registerTool({
name: "fn_task_import_github",
label: "fn: Import GitHub Issues",
description:
"Import GitHub issues as Fusion tasks. Fetches open issues from a repository " +
"and creates tasks in the planning column. Each task includes the issue title " +
"and body with a link to the source issue.",
promptSnippet: "Import GitHub issues as Fusion tasks",
promptGuidelines: [
"Use for syncing GitHub issue backlog to Fusion board",
"Uses gh CLI authentication (run 'gh auth login')",
"Use --limit to control how many issues to import (default: 30)",
"Use --labels to filter by specific labels",
],
parameters: Type.Object({
ownerRepo: Type.String({
description: "Repository in owner/repo format (e.g., 'dustinbyrne/fusion')",
pattern: "^[^/]+/[^/]+$",
}),
limit: Type.Optional(
Type.Number({
description: "Max issues to import (default: 30, max: 100)",
minimum: 1,
maximum: 100,
})
),
labels: Type.Optional(
Type.Array(Type.String(), {
description: "Label names to filter by",
})
),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const [owner, repo] = params.ownerRepo.split("/");
const limit = params.limit ?? 30;
const labels = params.labels;
const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels, signal });
if (issues.length === 0) {
return {
content: [{ type: "text", text: `No open issues found in ${owner}/${repo}.` }],
details: { createdTasks: [], summary: `Imported 0 tasks from ${owner}/${repo}` },
};
}
const store = await getStore(ctx.cwd);
const existingTasks = await store.listTasks({ slim: true });
const createdTasks: Array<{ id: string; title: string }> = [];
for (const issue of issues) {
const sourceUrl = issue.html_url;
const alreadyImported = existingTasks.some((task) => task.description.includes(sourceUrl));
if (alreadyImported) {
continue;
}
const title = issue.title.slice(0, 200);
const body = issue.body?.trim() || "(no description)";
const description = `${body}\n\nSource: ${sourceUrl}`;
const task = await store.createTask({
title: title || undefined,
description,
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",
repository: `${owner}/${repo}`,
externalIssueId: String(issue.number),
issueNumber: issue.number,
url: issue.html_url,
},
});
await store.logEntry(task.id, "Imported from GitHub", sourceUrl);
createdTasks.push({ id: task.id, title: task.title || issue.title });
existingTasks.push({ ...task, description });
}
const summary = `✓ Imported ${createdTasks.length} tasks from ${owner}/${repo}`;
return {
content: [
{
type: "text",
text: `${summary}\n\nCreated tasks:\n${createdTasks.map((task) => ` ${task.id}: ${task.title}`).join("\n") || " None"}`,
},
],
details: { createdTasks, summary },
};
},
});
// ── fn_task_import_github_issue ───────────────────────────────────
// Import a single GitHub issue by its issue number
pi.registerTool({
name: "fn_task_import_github_issue",
label: "fn: Import GitHub Issue",
description:
"Import a specific GitHub issue as a Fusion task. Fetches the issue by number " +
"and creates a single task in the planning column with the issue title and body.",
promptSnippet: "Import a specific GitHub issue as a Fusion task",
promptGuidelines: [
"Use for importing a single known issue by its number",
"Uses gh CLI authentication (run 'gh auth login')",
"Skips import if the issue is already imported (checks for existing Source URL)",
],
parameters: Type.Object({
owner: Type.String({
description: "Repository owner (e.g., 'dustinbyrne')",
}),
repo: Type.String({
description: "Repository name (e.g., 'fusion')",
}),
issueNumber: Type.Number({
description: "GitHub issue number to import",
minimum: 1,
}),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const { owner, repo, issueNumber } = params;
const issue = await fetchGitHubIssueViaGh(owner, repo, issueNumber, { signal });
if (issue.pull_request) {
throw new Error(`#${issueNumber} is a pull request, not an issue`);
}
// Check if already imported
const store = await getStore(ctx.cwd);
const existingTasks = await store.listTasks({ slim: true });
const sourceUrl = issue.html_url;
for (const task of existingTasks) {
if (task.description.includes(sourceUrl)) {
return {
content: [
{
type: "text",
text: `Issue #${issueNumber} already imported as ${task.id}\nSource: ${sourceUrl}`,
},
],
details: { skipped: true, existingTaskId: task.id, sourceUrl },
};
}
}
// Create the task
const title = issue.title.slice(0, 200);
const body = issue.body?.trim() || "(no description)";
const description = `${body}\n\nSource: ${sourceUrl}`;
const task = await store.createTask({
title: title || undefined,
description,
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",
repository: `${owner}/${repo}`,
externalIssueId: String(issue.number),
issueNumber: issue.number,
url: issue.html_url,
},
});
await store.logEntry(task.id, "Imported from GitHub", sourceUrl);
return {
content: [
{
type: "text",
text: `Imported ${task.id} from GitHub\n${sourceUrl}`,
},
],
details: { taskId: task.id, sourceUrl },
};
},
});
// ── fn_task_browse_github_issues ──────────────────────────────────
// Browse available GitHub issues before importing
pi.registerTool({
name: "fn_task_browse_github_issues",
label: "fn: Browse GitHub Issues",
description:
"List open GitHub issues from a repository to browse before importing. " +
"Returns issue numbers, titles, and URLs for selection. Use with fn_task_import_github_issue " +
"to import specific issues by number.",
promptSnippet: "Browse open GitHub issues in a repository",
promptGuidelines: [
"Use to preview available issues before importing",
"Returns a list you can reference when importing specific issues",
"Use --limit to control how many issues to show (default: 30)",
"Use --labels to filter by specific labels",
"Uses gh CLI authentication (run 'gh auth login')",
],
parameters: Type.Object({
owner: Type.String({
description: "Repository owner (e.g., 'dustinbyrne')",
}),
repo: Type.String({
description: "Repository name (e.g., 'fusion')",
}),
limit: Type.Optional(
Type.Number({
description: "Max issues to show (default: 30, max: 100)",
minimum: 1,
maximum: 100,
})
),
labels: Type.Optional(
Type.Array(Type.String(), {
description: "Label names to filter by",
})
),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const { owner, repo, limit = 30, labels } = params;
const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels, signal });
if (issues.length === 0) {
return {
content: [{ type: "text", text: `No open issues found in ${owner}/${repo}.` }],
details: { count: 0, issues: [] },
};
}
// Check which issues are already imported
const store = await getStore(ctx.cwd);
const existingTasks = await store.listTasks({ slim: true });
const importedUrls = new Set<string>();
for (const task of existingTasks) {
const match = task.description.match(/Source: (https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+)/);
if (match) {
importedUrls.add(match[1]);
}
}
const lines: string[] = [];
lines.push(`Found ${issues.length} open issues in ${owner}/${repo}:\n`);
for (const issue of issues) {
const isImported = importedUrls.has(issue.html_url);
const issueLabels = issue.labels ?? [];
const labelStr = issueLabels.length > 0 ? ` [${issueLabels.map((label) => label.name).join(", ")}]` : "";
const importedStr = isImported ? " ✓ Imported" : "";
lines.push(` #${issue.number}: ${issue.title.slice(0, 80)}${issue.title.length > 80 ? "…" : ""}${labelStr}${importedStr}`);
lines.push(` ${issue.html_url}`);
}
lines.push("\nUse fn_task_import_github_issue to import a specific issue by number.");
return {
content: [{ type: "text", text: lines.join("\n") }],
details: {
count: issues.length,
issues: issues.map((issue) => ({
number: issue.number,
title: issue.title,
url: issue.html_url,
labels: (issue.labels ?? []).map((label) => label.name),
imported: importedUrls.has(issue.html_url),
})),
},
};
},
});
// ── fn_task_plan ────────────────────────────────────────────────
// Create a task via AI-guided planning mode
pi.registerTool({
name: "fn_task_plan",
label: "fn: Plan Task",
description:
"Create a task via AI-guided planning mode — interactive conversation to refine your idea into a well-specified task.",
promptSnippet: "Create a task via AI-guided planning mode",
promptGuidelines: [
"Use for breaking down vague ideas into actionable tasks",
"The AI will ask clarifying questions before creating the task",
],
parameters: Type.Object({
description: Type.Optional(
Type.String({
description: "Initial plan description (optional) — the AI will ask clarifying questions if not provided",
})
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
// Import the planning function dynamically to avoid circular dependencies
const { runTaskPlan } = await import("./commands/task.js");
// Capture console output
const originalLog = console.log;
const originalError = console.error;
const logs: string[] = [];
console.log = (...args: unknown[]) => {
const line = args.map(String).join(" ");
logs.push(line);
originalLog.apply(console, args);
};
console.error = (...args: unknown[]) => {
const line = args.map(String).join(" ");
logs.push(line);
originalError.apply(console, args);
};
let taskId: string | undefined;
try {
taskId = await runTaskPlan(params.description, true); // Use --yes flag for non-interactive
} catch (err) {
console.error = originalError;
console.log = originalLog;
throw new Error(`Planning mode failed: ${err instanceof Error ? err.message : String(err)}`);
} finally {
console.error = originalError;
console.log = originalLog;
}
// Get summary line
const summaryLine = logs.find((l) => l.includes("✓ Created")) || "Task created";
return {
content: [
{
type: "text",
text: summaryLine + (taskId ? `\n\nPlanning session completed. Task ${taskId} is now in planning and will be auto-planned by the AI planning agent.` : ""),
},
],
details: { taskId, logs },
};
},
});
// ── Mission Tools ───────────────────────────────────────────────
// Mission hierarchy management for multi-phase project planning
// ── fn_mission_create ───────────────────────────────────────────
pi.registerTool({
name: "fn_mission_create",
label: "fn: Create Mission",
description:
"Create a new mission — a high-level objective that can span multiple milestones. " +
"Missions contain milestones that break down work into phases.",
promptSnippet: "Create a new mission for high-level project planning",
promptGuidelines: [
"Use for high-level project objectives that span multiple work phases",
"Missions are broken down into milestones → slices → features → tasks",
"Be descriptive so the mission purpose is clear",
],
parameters: Type.Object({
title: Type.String({ description: "Mission title — brief but descriptive" }),
description: Type.Optional(
Type.String({ description: "Detailed mission objectives and context" })
),
autoAdvance: Type.Optional(
Type.Boolean({ description: "Automatically activate the next pending slice when the current slice completes" })
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const mission = missionStore.createMission({
title: params.title.trim(),
description: params.description?.trim(),
});
if (params.autoAdvance !== undefined) {
missionStore.updateMission(mission.id, { autoAdvance: params.autoAdvance });
}
const createdMission = missionStore.getMission(mission.id)!;
return {
content: [
{
type: "text",
text: `Created ${createdMission.id}: ${createdMission.title}\nStatus: ${createdMission.status}${createdMission.autoAdvance ? "\nAuto-advance: enabled" : ""}`,
},
],
details: {
missionId: createdMission.id,
title: createdMission.title,
status: createdMission.status,
autoAdvance: createdMission.autoAdvance ?? false,
},
};
},
});
// ── fn_mission_list ──────────────────────────────────────────────
pi.registerTool({
name: "fn_mission_list",
label: "fn: List Missions",
description: "List all missions with their current status.",
promptSnippet: "List all missions",
promptGuidelines: [
"Use to see all missions and their current status",
"Missions are grouped by status (active, planning, complete, etc.)",
"Use before fn_mission_show to find a specific mission ID",
],
parameters: Type.Object({}),
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const missions = missionStore.listMissions();
if (missions.length === 0) {
return {
content: [{ type: "text", text: "No missions yet." }],
details: { count: 0 },
};
}
const summary = {
planning: missions.filter((mission) => mission.status === "planning").length,
active: missions.filter((mission) => mission.status === "active").length,
blocked: missions.filter((mission) => mission.status === "blocked").length,
complete: missions.filter((mission) => mission.status === "complete").length,
archived: missions.filter((mission) => mission.status === "archived").length,
};
const lines: string[] = [];
lines.push(`Missions (${missions.length})`);
lines.push(
`Summary: active ${summary.active}, planning ${summary.planning}, blocked ${summary.blocked}, complete ${summary.complete}, archived ${summary.archived}\n`,
);
for (const mission of missions) {
const statusIcon = mission.status === "complete" ? "✓" : mission.status === "active" ? "●" : mission.status === "blocked" ? "⚠" : "○";
const autoAdvance = mission.autoAdvance ? " · auto-advance" : "";
lines.push(` ${statusIcon} ${mission.id}: ${mission.title} (${mission.status}${autoAdvance})`);
}
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { count: missions.length, missions: missions.map((m) => ({ id: m.id, title: m.title, status: m.status })) },
};
},
});
// ── fn_mission_show ──────────────────────────────────────────────
pi.registerTool({
name: "fn_mission_show",
label: "fn: Show Mission",
description: "Show mission details with full hierarchy: milestones → slices → features.",
promptSnippet: "Show mission details with hierarchy",
promptGuidelines: [
"Use to see the full mission structure before planning work",
"Shows milestones, slices, and features in hierarchical order",
"Check slice status to see if features can be linked to tasks",
],
parameters: Type.Object({
id: Type.String({ description: "Mission ID (e.g., M-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const mission = missionStore.getMissionWithHierarchy(params.id);
if (!mission) {
return {
content: [{ type: "text", text: `Mission ${params.id} not found` }],
isError: true,
details: { error: "Mission not found" },
};
}
const lines: string[] = [];
lines.push(`${mission.id}: ${mission.title}`);
lines.push(`Status: ${mission.status}`);
if (mission.description) {
lines.push(`Description: ${mission.description}`);
}
lines.push("");
if (mission.milestones.length === 0) {
lines.push("No milestones yet.");
} else {
lines.push("Milestones:");
for (const milestone of mission.milestones) {
const mIcon = milestone.status === "complete" ? "✓" : milestone.status === "active" ? "●" : "○";
lines.push(` ${mIcon} ${milestone.id}: ${milestone.title} (${milestone.status})`);
for (const slice of milestone.slices) {
const sIcon = slice.status === "complete" ? "✓" : slice.status === "active" ? "●" : "○";
lines.push(` ${sIcon} ${slice.id}: ${slice.title} (${slice.status})`);
for (const feature of slice.features) {
const fIcon = feature.status === "done" ? "✓" : feature.status === "in-progress" ? "▸" : feature.status === "triaged" ? "●" : "○";
const taskLink = feature.taskId ? `${feature.taskId}` : "";
lines.push(` ${fIcon} ${feature.id}: ${feature.title} (${feature.status})${taskLink}`);
}
}
}
}
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { mission },
};
},
});
// ── fn_mission_delete ───────────────────────────────────────────
pi.registerTool({
name: "fn_mission_delete",
label: "fn: Delete Mission",
description: "Delete a mission and all its milestones, slices, and features. Cannot be undone.",
promptSnippet: "Delete a mission and all its contents",
promptGuidelines: [
"Use for cleaning up test missions or mistakenly created missions",
"Permanently deletes all milestones, slices, and features within the mission",
"Tasks linked to features are NOT deleted — only the feature links are removed",
],
parameters: Type.Object({
id: Type.String({ description: "Mission ID to delete (e.g., M-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const mission = missionStore.getMission(params.id);
if (!mission) {
return {
content: [{ type: "text", text: `Mission ${params.id} not found` }],
isError: true,
details: { error: "Mission not found" },
};
}
missionStore.deleteMission(params.id);
return {
content: [{ type: "text", text: `Deleted ${params.id}: "${mission.title}"` }],
details: { missionId: params.id, title: mission.title },
};
},
});
// ── fn_milestone_add ────────────────────────────────────────────
pi.registerTool({
name: "fn_milestone_add",
label: "fn: Add Milestone",
description: "Add a milestone to a mission. Milestones represent phases of work.",
promptSnippet: "Add a milestone to a mission",
promptGuidelines: [
"Use to break down a mission into manageable phases",
"Milestones are ordered and contain slices (work units)",
],
parameters: Type.Object({
missionId: Type.String({ description: "Parent mission ID (e.g., M-001)" }),
title: Type.String({ description: "Milestone title" }),
description: Type.Optional(Type.String({ description: "Milestone description" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const mission = missionStore.getMission(params.missionId);
if (!mission) {
return {
content: [{ type: "text", text: `Mission ${params.missionId} not found` }],
isError: true,
details: { error: "Mission not found" },
};
}
const milestone = missionStore.addMilestone(params.missionId, {
title: params.title.trim(),
description: params.description?.trim(),
});
return {
content: [
{ type: "text", text: `Added ${milestone.id}: "${milestone.title}" to ${params.missionId}` },
],
details: { milestoneId: milestone.id, missionId: params.missionId, title: milestone.title },
};
},
});
// ── fn_slice_add ─────────────────────────────────────────────────
pi.registerTool({
name: "fn_slice_add",
label: "fn: Add Slice",
description: "Add a slice to a milestone. Slices are work units that can be activated for implementation.",
promptSnippet: "Add a work slice to a milestone",
promptGuidelines: [
"Slices represent work units within a milestone",
"Slices are activated for implementation, linking features to tasks",
"Order slices by priority — they execute in sequence",
],
parameters: Type.Object({
milestoneId: Type.String({ description: "Parent milestone ID (e.g., MS-001)" }),
title: Type.String({ description: "Slice title" }),
description: Type.Optional(Type.String({ description: "Slice description" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const milestone = missionStore.getMilestone(params.milestoneId);
if (!milestone) {
return {
content: [{ type: "text", text: `Milestone ${params.milestoneId} not found` }],
isError: true,
details: { error: "Milestone not found" },
};
}
const slice = missionStore.addSlice(params.milestoneId, {
title: params.title.trim(),
description: params.description?.trim(),
});
return {
content: [
{ type: "text", text: `Added ${slice.id}: "${slice.title}" to ${params.milestoneId}` },
],
details: { sliceId: slice.id, milestoneId: params.milestoneId, title: slice.title },
};
},
});
// ── fn_feature_add ────────────────────────────────────────────────
pi.registerTool({
name: "fn_feature_add",
label: "fn: Add Feature",
description: "Add a feature to a slice. Features are deliverables that can be linked to tasks.",
promptSnippet: "Add a feature to a slice",
promptGuidelines: [
"Features represent deliverables within a slice",
"Features start as 'defined' and progress through 'triaged' → 'in-progress' → 'done'",
"Link features to tasks using fn_feature_link_task",
],
parameters: Type.Object({
sliceId: Type.String({ description: "Parent slice ID (e.g., SL-001)" }),
title: Type.String({ description: "Feature title" }),
description: Type.Optional(Type.String({ description: "Feature description" })),
acceptanceCriteria: Type.Optional(
Type.String({ description: "Acceptance criteria for completing the feature" })
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const slice = missionStore.getSlice(params.sliceId);
if (!slice) {
return {
content: [{ type: "text", text: `Slice ${params.sliceId} not found` }],
isError: true,
details: { error: "Slice not found" },
};
}
const feature = missionStore.addFeature(params.sliceId, {
title: params.title.trim(),
description: params.description?.trim(),
acceptanceCriteria: params.acceptanceCriteria?.trim(),
});
return {
content: [
{ type: "text", text: `Added ${feature.id}: "${feature.title}" to ${params.sliceId}` },
],
details: { featureId: feature.id, sliceId: params.sliceId, title: feature.title },
};
},
});
// ── fn_slice_activate ────────────────────────────────────────────
pi.registerTool({
name: "fn_slice_activate",
label: "fn: Activate Slice",
description:
"Activate a pending slice for implementation. " +
"Sets status to 'active' and enables task linking for its features.",
promptSnippet: "Activate a slice for implementation",
promptGuidelines: [
"Activating a slice allows its features to be linked to tasks",
"Only pending slices can be activated",
"Slice activation triggers auto-advance when linked tasks complete",
],
parameters: Type.Object({
id: Type.String({ description: "Slice ID to activate (e.g., SL-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const slice = missionStore.getSlice(params.id);
if (!slice) {
return {
content: [{ type: "text", text: `Slice ${params.id} not found` }],
isError: true,
details: { error: "Slice not found" },
};
}
if (slice.status !== "pending") {
return {
content: [{ type: "text", text: `Slice ${params.id} is not pending (status: ${slice.status})` }],
isError: true,
details: { error: "Slice not pending", currentStatus: slice.status },
};
}
const activated = await missionStore.activateSlice(params.id);
return {
content: [
{
type: "text",
text: `Activated ${activated.id}: "${activated.title}"\nStatus: ${activated.status}`,
},
],
details: { sliceId: activated.id, title: activated.title, status: activated.status },
};
},
});
// ── fn_feature_link_task ──────────────────────────────────────────
pi.registerTool({
name: "fn_feature_link_task",
label: "fn: Link Feature to Task",
description:
"Link a feature to a fn task for implementation. " +
"Updates the feature status to 'triaged' and associates it with the task.",
promptSnippet: "Link a feature to a task",
promptGuidelines: [
"Use when a feature is ready for implementation and has a corresponding task",
"The feature's slice must be active to link tasks",
"Linking updates the feature status to 'triaged'",
"When the linked task moves to 'done', the feature status becomes 'done'",
],
parameters: Type.Object({
featureId: Type.String({ description: "Feature ID to link (e.g., F-001)" }),
taskId: Type.String({ description: "Task ID to link to (e.g., FN-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const feature = missionStore.getFeature(params.featureId);
if (!feature) {
return {
content: [{ type: "text", text: `Feature ${params.featureId} not found` }],
isError: true,
details: { error: "Feature not found" },
};
}
// Check if task exists
try {
await store.getTask(params.taskId);
} catch {
return {
content: [{ type: "text", text: `Task ${params.taskId} not found` }],
isError: true,
details: { error: "Task not found" },
};
}
const updated = missionStore.linkFeatureToTask(params.featureId, params.taskId);
await store.updateTask(params.taskId, { sliceId: feature.sliceId });
return {
content: [
{
type: "text",
text: `Linked ${updated.id}: "${updated.title}" → ${params.taskId}\nStatus: ${updated.status}`,
},
],
details: { featureId: updated.id, taskId: params.taskId, title: updated.title, status: updated.status },
};
},
});
// ── fn_agent_stop ─────────────────────────────────────────────────
pi.registerTool({
name: "fn_agent_stop",
label: "fn: Stop Agent",
description:
"Stop a running agent — pauses its execution. " +
"Transitions the agent from running/active to paused state.",
promptSnippet: "Stop (pause) a running Fusion agent",
promptGuidelines: [
"Use to pause an agent that is currently running or active",
"Stopped agents can be resumed with fn_agent_start",
"Agents in 'idle', 'error', or 'terminated' state cannot be stopped",
],
parameters: Type.Object({
id: Type.String({ description: "Agent ID to stop (e.g., agent-abc123)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { AgentStore, AGENT_VALID_TRANSITIONS } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
await agentStore.init();
const agent = await agentStore.getAgent(params.id);
if (!agent) {
return {
content: [{ type: "text", text: `Agent ${params.id} not found` }],
isError: true,
details: { error: "Agent not found" },
};
}
if (agent.state === "paused") {
return {
content: [{ type: "text", text: `Agent ${params.id} is already paused` }],
details: { agentId: params.id, state: agent.state },
};
}
const validTargets = AGENT_VALID_TRANSITIONS[agent.state];
if (!validTargets.includes("paused")) {
return {
content: [
{
type: "text",
text: `Cannot stop agent ${params.id} — current state '${agent.state}' cannot transition to 'paused'. Valid transitions: ${validTargets.join(", ")}`,
},
],
isError: true,
details: { agentId: params.id, currentState: agent.state, validTargets },
};
}
await agentStore.updateAgentState(params.id, "paused");
return {
content: [{ type: "text", text: `Stopped ${params.id}` }],
details: { agentId: params.id, previousState: agent.state, newState: "paused" },
};
},
});
// ── fn_agent_start ────────────────────────────────────────────────
pi.registerTool({
name: "fn_agent_start",
label: "fn: Start Agent",
description:
"Start a stopped agent — resumes its execution. " +
"Transitions the agent from paused to active state.",
promptSnippet: "Start (resume) a stopped Fusion agent",
promptGuidelines: [
"Use to resume an agent that has been paused",
"Only agents in 'paused' state can be started",
"Agents in 'idle' or 'error' state cannot be started — use reset instead",
],
parameters: Type.Object({
id: Type.String({ description: "Agent ID to start (e.g., agent-abc123)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { AgentStore, AGENT_VALID_TRANSITIONS } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
await agentStore.init();
const agent = await agentStore.getAgent(params.id);
if (!agent) {
return {
content: [{ type: "text", text: `Agent ${params.id} not found` }],
isError: true,
details: { error: "Agent not found" },
};
}
if (agent.state === "active" || agent.state === "running") {
return {
content: [{ type: "text", text: `Agent ${params.id} is already running (${agent.state})` }],
details: { agentId: params.id, state: agent.state },
};
}
const validTargets = AGENT_VALID_TRANSITIONS[agent.state];
if (!validTargets.includes("active")) {
return {
content: [
{
type: "text",
text: `Cannot start agent ${params.id} — current state '${agent.state}' cannot transition to 'active'. Valid transitions: ${validTargets.join(", ")}`,
},
],
isError: true,
details: { agentId: params.id, currentState: agent.state, validTargets },
};
}
await agentStore.updateAgentState(params.id, "active");
return {
content: [{ type: "text", text: `Started ${params.id}` }],
details: { agentId: params.id, previousState: agent.state, newState: "active" },
};
},
});
// ── fn_skills_search ─────────────────────────────────────────────
pi.registerTool({
name: "fn_skills_search",
label: "FN: Search Skills",
description:
"Search the skills.sh directory for agent skills. Returns matching skills with names, " +
"sources (owner/repo), install counts, and install commands. " +
"Use fn_skills_install to install a selected skill.",
promptSnippet: "Search skills.sh for agent skills",
promptGuidelines: [
"Use fn_skills_search to discover skills before installing",
"Returns skills sorted by popularity (install count)",
],
parameters: Type.Object({
query: Type.String({
description:
"Search query — framework name, technology, or capability (e.g., 'react', 'firebase', 'testing', 'docker')",
}),
limit: Type.Optional(
Type.Number({
description: "Max results to return (default: 10, max: 50)",
minimum: 1,
maximum: 50,
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
// Dynamic import to match existing extension patterns
const { searchSkills, formatInstalls } = await import("./commands/skills.js");
const skills = await searchSkills(params.query, params.limit ?? 10);
if (skills.length === 0) {
return {
content: [{ type: "text", text: `No skills found for '${params.query}'` }],
details: { count: 0, skills: [] },
};
}
const lines: string[] = [];
lines.push(`Found ${skills.length} skills matching '${params.query}':\n`);
for (let i = 0; i < skills.length; i++) {
const skill = skills[i]!;
const installs = formatInstalls(skill.installs);
lines.push(`${i + 1}. ${skill.name} (${skill.source})${installs ? `${installs}` : ""}`);
}
lines.push("\nInstall a skill with: fn_skills_install({ source: \"<owner/repo>\", skill: \"<name>\" })");
return {
content: [{ type: "text", text: lines.join("\n") }],
details: {
count: skills.length,
skills: skills.map((s) => ({
name: s.name,
source: s.source,
installs: s.installs,
installCommand: `fn skills install ${s.source} --skill ${s.name}`,
})),
},
};
},
});
// ── fn_skills_install ─────────────────────────────────────────────
pi.registerTool({
name: "fn_skills_install",
label: "FN: Install Skill",
description:
"Install an agent skill from skills.sh into the current project. " +
"Downloads skill files into the project's skill directories (.fusion/skills/, legacy .pi/skills/, .agents/skills/). " +
"The skill becomes available to AI agents in subsequent sessions.",
promptSnippet: "Install a skill from skills.sh into the current project",
promptGuidelines: [
"Use fn_skills_install after fn_skills_search to install a discovered skill",
"The source is in owner/repo format (e.g., 'firebase/agent-skills')",
"Specify the skill name to install a specific skill, or omit to install all from the source",
],
parameters: Type.Object({
source: Type.String({
description: "GitHub source in owner/repo format (e.g., 'firebase/agent-skills')",
}),
skill: Type.Optional(
Type.String({
description: "Specific skill name to install (e.g., 'firebase-basics'). Omit to install all skills from the source.",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
// Validate source format
if (!/^[^/]+\/[^/]+$/.test(params.source)) {
return {
content: [
{
type: "text",
text: `Invalid source format: '${params.source}'. Use owner/repo format (e.g., 'firebase/agent-skills').`,
},
],
isError: true,
details: { error: "Invalid source format" },
};
}
// Build npx skills add arguments
const npxArgs = ["skills", "add", params.source];
if (params.skill) {
npxArgs.push("--skill", params.skill);
}
// Non-interactive mode (-y) targeting pi agent (-a pi)
npxArgs.push("-y", "-a", "pi");
// Execute via spawn
const child = spawn("npx", npxArgs, {
cwd: resolveProjectRoot(ctx.cwd),
stdio: "pipe",
shell: true,
});
let stderr = "";
child.stdout?.on("data", () => {});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
const exitCode = await new Promise<number>((resolve) => {
child.on("exit", (code) => {
resolve(code ?? 1);
});
child.on("error", () => {
resolve(1);
});
});
try {
// Always dispose the child process
child.kill();
} catch {
// Ignore errors during cleanup
}
if (exitCode !== 0) {
return {
content: [
{
type: "text",
text: `Failed to install skill: ${stderr || "npx skills add exited with code " + exitCode}`,
},
],
isError: true,
details: { exitCode, stderr },
};
}
return {
content: [
{
type: "text",
text: `Installed skill from ${params.source}. Skills are discovered from .fusion/skills/, legacy .pi/skills/, and .agents/skills/. The skill will be available in future agent sessions.`,
},
],
details: { source: params.source, skill: params.skill ?? "all" },
};
},
});
// ── /fn command — start the dashboard + engine ───────────────────
let dashboardProcess: ChildProcess | null = null;
let dashboardPort: number | null = null;
pi.registerCommand("fn", {
description: "Start (or stop) the Fusion dashboard and AI engine",
handler: async (args, ctx) => {
const trimmed = (args ?? "").trim();
// /fn stop — kill the dashboard
if (trimmed === "stop") {
if (dashboardProcess) {
dashboardProcess.kill("SIGINT");
dashboardProcess = null;
dashboardPort = null;
ctx.ui.setStatus("fn", "");
ctx.ui.notify("Fusion dashboard stopped", "info");
} else {
ctx.ui.notify("Fusion dashboard is not running", "warning");
}
return;
}
// /fn status
if (trimmed === "status") {
if (dashboardProcess && !dashboardProcess.killed) {
ctx.ui.notify(`Fusion dashboard running on http://localhost:${dashboardPort}`, "info");
} else {
dashboardProcess = null;
dashboardPort = null;
ctx.ui.notify("Fusion dashboard is not running", "info");
}
return;
}
// /fn [port] — start the dashboard
if (dashboardProcess && !dashboardProcess.killed) {
ctx.ui.notify(
`Fusion dashboard already running on http://localhost:${dashboardPort}. Use /fn stop first.`,
"warning",
);
return;
}
const port = trimmed ? parseInt(trimmed, 10) || 4040 : 4040;
// Find the fn binary: prefer local node_modules, then global
const child = spawn("fn", ["dashboard", "--port", String(port)], {
cwd: resolveProjectRoot(ctx.cwd),
stdio: ["ignore", "pipe", "pipe"],
detached: false,
env: { ...process.env },
shell: true,
});
dashboardProcess = child;
dashboardPort = port;
// Watch for early exit (e.g. fn not found)
child.on("error", (err) => {
dashboardProcess = null;
dashboardPort = null;
ctx.ui.setStatus("fn", "");
ctx.ui.notify(`Failed to start Fusion dashboard: ${err.message}`, "error");
});
child.on("exit", (code) => {
if (dashboardProcess === child) {
dashboardProcess = null;
dashboardPort = null;
ctx.ui.setStatus("fn", "");
if (code !== 0 && code !== null) {
ctx.ui.notify(`Fusion dashboard exited with code ${code}`, "warning");
}
}
});
// Wait briefly to see if it crashes immediately
await new Promise((r) => setTimeout(r, 500));
if (dashboardProcess && !dashboardProcess.killed) {
const url = `http://localhost:${port}`;
ctx.ui.notify(`Fusion dashboard started on ${url} (AI engine active)`, "info");
const link = `\x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\`;
ctx.ui.setStatus("fn", `Fusion ● ${link}`);
}
},
});
// ── Cleanup on session end ───────────────────────────────────────
pi.on("session_shutdown", async () => {
if (dashboardProcess) {
dashboardProcess.kill("SIGINT");
dashboardProcess = null;
dashboardPort = null;
}
storeCache.clear();
});
}