diff --git a/packages/cli/src/project-resolver.ts b/packages/cli/src/project-resolver.ts
index 044a718867..cd171d135a 100644
--- a/packages/cli/src/project-resolver.ts
+++ b/packages/cli/src/project-resolver.ts
@@ -680,14 +680,17 @@ export async function registerProjectInteractive(
// Initialize the project (create .fusion/)
const { TaskStore } = await import("@fusion/core");
const store = new TaskStore(absPath);
- await store.init();
- if (detectedSubRepos) {
- await saveWorkspaceConfig(absPath, { repos: detectedSubRepos });
- // Persist workspaceMode in config.json so it's visible/toggleable in the dashboard
- await store.updateSettings({ workspaceMode: true });
+ try {
+ await store.init();
+ if (detectedSubRepos) {
+ await saveWorkspaceConfig(absPath, { repos: detectedSubRepos });
+ // Persist workspaceMode in config.json so it's visible/toggleable in the dashboard
+ await store.updateSettings({ workspaceMode: true });
+ }
+ console.log(` ✓ Initialized fn at ${absPath}`);
+ } finally {
+ await store.close();
}
- await store.close();
- console.log(` ✓ Initialized fn at ${absPath}`);
} else {
throw new ProjectResolutionError(
"Cannot register project without .fusion/ directory. Run `fn init` first.",
@@ -747,29 +750,32 @@ export async function registerProjectInteractive(
/*
FNXC:Onboarding 2026-06-24-18:00:
- After registration, prompt the user to confirm a task prefix and default workflow.
- The prefix defaults to the first 2-4 chars of the project name so each project gets
- recognizable task IDs (e.g., "MYPR" for "my-project"). The workflow defaults to coding.
- Both are persisted to config.json via the TaskStore.
+ After registration, set a task prefix and default workflow. The prefix defaults to
+ the first 2-4 chars of the project name so each project gets recognizable task IDs
+ (e.g., "MYPR" for "my-project"). The workflow defaults to coding. Both are persisted
+ to config.json via the TaskStore.
*/
- if (interactive) {
+ {
const { TaskStore } = await import("@fusion/core");
const store = new TaskStore(absPath);
- await store.init();
-
- const suggestedPrefix = suggestTaskPrefix(name);
- const rl = createInterface({ input: process.stdin, output: process.stdout });
- const prefixInput = await rl.question(`\n Task prefix [${suggestedPrefix}]: `);
- rl.close();
- const rawPrefix = prefixInput.trim().toUpperCase().replace(/[^A-Z]/g, "");
- const prefix = rawPrefix.length >= 2 && rawPrefix.length <= 5 ? rawPrefix : suggestedPrefix;
-
- await store.updateSettings({
- taskPrefix: prefix,
- defaultWorkflowId: "builtin:coding",
- });
- await store.close();
- console.log(` ✓ Task prefix set to "${prefix}", default workflow: coding`);
+ try {
+ await store.init();
+ let prefix = suggestTaskPrefix(name);
+ if (interactive) {
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
+ const prefixInput = await rl.question(`\n Task prefix [${prefix}]: `);
+ rl.close();
+ const rawPrefix = prefixInput.trim().toUpperCase().replace(/[^A-Z]/g, "");
+ if (rawPrefix.length >= 1 && rawPrefix.length <= 5) prefix = rawPrefix;
+ }
+ await store.updateSettings({
+ taskPrefix: prefix,
+ defaultWorkflowId: "builtin:coding",
+ });
+ console.log(` ✓ Task prefix set to "${prefix}", default workflow: coding`);
+ } finally {
+ await store.close();
+ }
}
return createResolvedProject(project);
diff --git a/packages/core/src/distributed-task-id.ts b/packages/core/src/distributed-task-id.ts
index 04ba930b00..98a5aed311 100644
--- a/packages/core/src/distributed-task-id.ts
+++ b/packages/core/src/distributed-task-id.ts
@@ -77,16 +77,16 @@ function getConfiguredPrefixAndLegacyNextId(db: Database): { prefix: string; nex
.prepare("SELECT nextId, settings FROM config WHERE id = 1")
.get() as { nextId: number | null; settings: string | null } | undefined;
if (!row) {
- return { prefix: "KB", nextId: null };
+ return { prefix: "FN", nextId: null };
}
const settings = row.settings ? (JSON.parse(row.settings) as { taskPrefix?: string }) : null;
return {
- prefix: (settings?.taskPrefix ?? "KB").trim().toUpperCase(),
+ prefix: (settings?.taskPrefix ?? "FN").trim().toUpperCase(),
nextId: typeof row.nextId === "number" ? row.nextId : null,
};
} catch {
- return { prefix: "KB", nextId: null };
+ return { prefix: "FN", nextId: null };
}
}
diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts
index b4dfa9d33a..876bede201 100644
--- a/packages/dashboard/app/api/legacy.ts
+++ b/packages/dashboard/app/api/legacy.ts
@@ -6770,6 +6770,8 @@ export interface ProjectCreateInput {
isolationMode?: "in-process" | "child-process";
nodeId?: string;
cloneUrl?: string;
+ workspaceMode?: boolean;
+ taskPrefix?: string;
}
export type DockerNodeConfigInfo = DockerNodeConfig;
@@ -7236,6 +7238,13 @@ export function registerProject(input: ProjectCreateInput): Promise
body: JSON.stringify(input),
});
}
+/** Detect git sub-repos in a directory (workspace mode detection) */
+export function detectWorkspace(path: string): Promise<{ repos: string[]; isWorkspace: boolean }> {
+ return api<{ repos: string[]; isWorkspace: boolean }>("/projects/detect-workspace", {
+ method: "POST",
+ body: JSON.stringify({ path }),
+ });
+}
/** Unregister a project */
export function unregisterProject(id: string): Promise {
diff --git a/packages/dashboard/app/components/SetupWizardModal.tsx b/packages/dashboard/app/components/SetupWizardModal.tsx
index 324da042dc..89461dad2b 100644
--- a/packages/dashboard/app/components/SetupWizardModal.tsx
+++ b/packages/dashboard/app/components/SetupWizardModal.tsx
@@ -3,9 +3,22 @@ import { lazy, Suspense, useState, useCallback, useMemo, useRef, useEffect, type
import { X, Loader2, CheckCircle, ChevronRight, Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { AgentOnboardingSummary, ProjectInfo, ProjectCreateInput } from "../api";
-import { createAgent, registerProject } from "../api";
+import { createAgent, registerProject, detectWorkspace } from "../api";
import { DirectoryPicker } from "./DirectoryPicker";
import { suggestProjectName } from "../utils/projectDetection";
+
+/*
+FNXC:TaskPrefix 2026-06-24-19:00:
+Derive a task prefix from a project name in the browser. Mirrors the logic in
+@fusion/core's suggestTaskPrefix: strip non-alpha, uppercase, take 2-4 chars,
+fall back to "FN". Duplicated because @fusion/core is server-only.
+*/
+function suggestTaskPrefixFromName(name: string): string {
+ const cleaned = name.replace(/[^a-zA-Z]/g, "").toUpperCase();
+ if (cleaned.length >= 2 && cleaned.length <= 4) return cleaned;
+ if (cleaned.length > 4) return cleaned.slice(0, 4);
+ return "FN";
+}
import { useNodes } from "../hooks/useNodes";
import { AgentAvatar } from "./AgentAvatar";
import { ErrorBoundary } from "./ErrorBoundary";
@@ -44,6 +57,10 @@ interface WizardState {
manualName: string;
manualIsolationMode: "in-process" | "child-process";
manualNodeId: string;
+ manualTaskPrefix: string;
+ detectedRepos: string[];
+ workspaceMode: boolean;
+ isDetectingWorkspace: boolean;
registeredProject: ProjectInfo | null;
selectedPresetId: string;
agentDraft: AgentDraftValues;
@@ -90,6 +107,10 @@ export function SetupWizardModal({
manualName: "",
manualIsolationMode: "in-process",
manualNodeId: "",
+ manualTaskPrefix: "",
+ detectedRepos: [],
+ workspaceMode: false,
+ isDetectingWorkspace: false,
registeredProject: null,
selectedPresetId: ceoPreset.id,
agentDraft: mapPresetToAgentDraft(ceoPreset),
@@ -125,16 +146,48 @@ export function SetupWizardModal({
}
}, [state.agentError]);
+ const detectWorkspaceRequestId = useRef(0);
+
const handlePathChange = useCallback((path: string) => {
setState((prev) => {
- const updates: Partial = { manualPath: path };
+ const updates: Partial = { manualPath: path, detectedRepos: [], workspaceMode: false };
// Auto-suggest name when path changes and name is empty or was previously auto-suggested
if (path && (!prev.manualName || prev.manualName === suggestProjectName(prev.manualPath))) {
updates.manualName = suggestProjectName(path);
}
+ // Auto-suggest prefix when name changes and prefix is empty or was previously auto-suggested
+ const suggestedName = updates.manualName ?? prev.manualName;
+ if (suggestedName && (!prev.manualTaskPrefix || prev.manualTaskPrefix === suggestTaskPrefixFromName(suggestProjectName(prev.manualPath)))) {
+ updates.manualTaskPrefix = suggestTaskPrefixFromName(suggestedName);
+ }
return { ...prev, ...updates };
});
- }, []);
+
+ /*
+ FNXC:Workspace 2026-06-24-21:00:
+ Detect workspace sub-repos only in existing-directory mode (clone mode creates a fresh
+ directory with a single repo). A monotonic request ID guards against stale responses
+ overwriting state from a newer path entry (race condition on rapid typing).
+ */
+ if (state.manualMode === "existing" && path.trim() && path.trim() !== "/") {
+ const requestId = ++detectWorkspaceRequestId.current;
+ setState((prev) => ({ ...prev, isDetectingWorkspace: true }));
+ detectWorkspace(path.trim())
+ .then((result) => {
+ if (requestId !== detectWorkspaceRequestId.current) return;
+ setState((prev) => ({
+ ...prev,
+ isDetectingWorkspace: false,
+ detectedRepos: result.repos,
+ workspaceMode: result.isWorkspace,
+ }));
+ })
+ .catch(() => {
+ if (requestId !== detectWorkspaceRequestId.current) return;
+ setState((prev) => ({ ...prev, isDetectingWorkspace: false }));
+ });
+ }
+ }, [state.manualMode]);
const handleManualRegister = useCallback(async () => {
const trimmedPath = state.manualPath.trim();
@@ -153,6 +206,8 @@ export function SetupWizardModal({
isolationMode: state.manualIsolationMode,
nodeId: state.manualNodeId || undefined,
cloneUrl: state.manualMode === "clone" ? trimmedCloneUrl : undefined,
+ workspaceMode: state.workspaceMode,
+ taskPrefix: state.manualTaskPrefix.trim() || undefined,
};
const result = await registerProject(input);
@@ -179,7 +234,7 @@ export function SetupWizardModal({
error: err instanceof Error ? err.message : "Failed to register project",
}));
}
- }, [includeAgentStep, onProjectRegistered, state.manualPath, state.manualName, state.manualCloneUrl, state.manualMode, state.manualIsolationMode, state.manualNodeId]);
+ }, [includeAgentStep, onProjectRegistered, state.manualPath, state.manualName, state.manualCloneUrl, state.manualMode, state.manualIsolationMode, state.manualNodeId, state.workspaceMode, state.manualTaskPrefix]);
const handlePresetSelect = useCallback((presetId: string) => {
const preset = getPresetById(presetId);
@@ -369,6 +424,64 @@ export function SetupWizardModal({
+ {/*
+ FNXC:Workspace 2026-06-24-19:00:
+ Workspace mode detection: when the selected directory contains git sub-repos,
+ show a checkbox letting the user opt into workspace mode. In workspace mode,
+ tasks run per-sub-repo and no git repo is created at the root.
+ */}
+ {isExistingMode && state.manualPath.trim() && (
+
+ {t("setup.noSubReposDetected", "No sub-repositories detected. Enable if this is a multi-repo workspace.")}
+
+ )}
+
+ )}
+
+ {/*
+ FNXC:TaskPrefix 2026-06-24-19:00:
+ Task prefix field: auto-derived from the project name. The prefix is used
+ for task IDs (e.g. "MYPR-1"). Users can override it.
+ */}
+