diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs
index 1597a98b8..ff480b88b 100644
--- a/scripts/run-tests.mjs
+++ b/scripts/run-tests.mjs
@@ -430,6 +430,7 @@ export const SUITES = {
"src/components/board-link-browser.test.ts",
"src/components/new-card-modal.test.ts",
"src/lib/active-familiar.test.ts",
+ "src/lib/use-project-familiars.test.ts",
"src/lib/use-projects.test.ts",
"src/lib/use-projects-race.test.ts",
"src/components/calendar-view-polish.test.ts",
diff --git a/src/app/api/board/[id]/chat/route.ts b/src/app/api/board/[id]/chat/route.ts
index 90d719512..950147b36 100644
--- a/src/app/api/board/[id]/chat/route.ts
+++ b/src/app/api/board/[id]/chat/route.ts
@@ -51,7 +51,10 @@ export async function POST(
);
}
- if (card.sessionId) {
+ // Unscoped legacy tasks have no project authorization boundary to recheck.
+ // Project-backed cards are handled below, after their current familiar has
+ // been authorized for the assigned project.
+ if (card.sessionId && !card.projectId) {
await recordSessionFamiliar(card.sessionId, familiarId);
return NextResponse.json({
ok: true,
@@ -113,6 +116,20 @@ export async function POST(
}
throw error;
}
+
+ // A persisted card/session link can outlive a permission change or a
+ // reassignment made by another Cave client. Never let that legacy link
+ // bypass the same authorization required to start a new session.
+ if (card.sessionId) {
+ await recordSessionFamiliar(card.sessionId, familiarId);
+ return NextResponse.json({
+ ok: true,
+ reused: true,
+ card,
+ sessionId: card.sessionId,
+ familiarId,
+ });
+ }
} else {
const rawProjectRoot = body.projectRoot ?? card.cwd;
if (!rawProjectRoot) {
diff --git a/src/app/api/familiars/route.test.ts b/src/app/api/familiars/route.test.ts
index 83d47a933..d4d886990 100644
--- a/src/app/api/familiars/route.test.ts
+++ b/src/app/api/familiars/route.test.ts
@@ -55,7 +55,22 @@ assert.match(
);
assert.match(
source,
- /rosterResult\.roster\.map\(/,
+ /const rostersByProject[\s\S]*?filterFamiliarsForProject\(permissions!, rosterResult\.roster, projectId, "session-launch"\)/,
+ "every project-scoped familiar request filters the roster with session-launch access",
+);
+assert.match(
+ source,
+ /searchParams\s*\.getAll\("projectId"\)/,
+ "Familiars API accepts repeated projectId scopes for dependent task pickers",
+);
+assert.match(
+ source,
+ /familiarsByProject: Object\.fromEntries/,
+ "the table can receive every project-scoped roster from one daemon/config lookup",
+);
+assert.match(
+ source,
+ /roster\.map\(/,
"daemon roster and declared-only familiars still flow through the same enrichment path",
);
assert.match(
diff --git a/src/app/api/familiars/route.ts b/src/app/api/familiars/route.ts
index db6835c99..3614087a8 100644
--- a/src/app/api/familiars/route.ts
+++ b/src/app/api/familiars/route.ts
@@ -6,6 +6,7 @@ import { bindingFor, saveConfig } from "@/lib/cave-config";
import { covenHome } from "@/lib/coven-paths";
import { resolveFamiliarAvatar } from "@/lib/server/familiar-avatar";
import { loadVisibleFamiliarRoster } from "@/lib/server/familiar-roster";
+import { filterFamiliarsForProject, loadProjectPermissions } from "@/lib/project-permissions";
import {
buildFamiliarsToml,
familiarsTomlContainsId,
@@ -30,7 +31,7 @@ export type DaemonFamiliar = {
memory_freshness?: string;
};
-export async function GET() {
+export async function GET(req: Request) {
const rosterResult = await loadVisibleFamiliarRoster();
if (!rosterResult.ok) {
// Auth failures (401/403) mean the hub/daemon rejected our access token
@@ -56,6 +57,30 @@ export async function GET() {
);
}
const { config } = rosterResult;
+ const projectIds = [...new Set(
+ new URL(req.url).searchParams
+ .getAll("projectId")
+ .map((projectId) => projectId.trim())
+ .filter(Boolean),
+ )];
+ // Board task creation selects the project first. Restrict its familiar
+ // picker using the same read-level session-launch rule enforced by
+ // /api/board/:id/chat; the route's final assertProjectAccess remains the
+ // authority for a launch request.
+ const permissions = projectIds.length > 0 ? await loadProjectPermissions() : null;
+ const rostersByProject = new Map(
+ projectIds.map((projectId) => [
+ projectId,
+ filterFamiliarsForProject(permissions!, rosterResult.roster, projectId, "session-launch"),
+ ]),
+ );
+ const roster = projectIds.length === 0
+ ? rosterResult.roster
+ : projectIds.length === 1
+ ? rostersByProject.get(projectIds[0]) ?? []
+ : [...new Map(
+ [...rostersByProject.values()].flat().map((familiar) => [familiar.id, familiar]),
+ ).values()];
// Pass `emoji` through — it's the daemon-provided default glyph the
// glyph picker uses as the starting value. The Cave-local override store
// (`cave-glyph-overrides.ts`) wins on render when the user picks something.
@@ -64,40 +89,59 @@ export async function GET() {
// when one exists, cache-busted by file mtime plus renderer format so both
// content changes and server-side encoding changes refetch in desktop
// WebViews. Familiars with no on-disk avatar omit it and render the glyph.
- const familiars = await Promise.all(
- rosterResult.roster.map(async (f) => {
- const configEntry = config.familiars[f.id] ?? {};
- const binding = bindingFor(config, f.id);
- const avatar = await resolveFamiliarAvatar(f.id);
- return {
- ...f,
- display_name: binding.display_name ?? f.display_name,
- role: binding.role ?? f.role,
- pronouns: binding.pronouns ?? f.pronouns,
- description: binding.description ?? f.description,
- color: binding.color,
- harness: binding.harness,
- defaultHarness: config.defaults.harness,
- harnessOverride: configEntry.harness ?? null,
- model: binding.model,
- note: binding.note,
- voiceProvider: binding.voiceProvider,
- voiceModel: binding.voiceModel,
- voiceName: binding.voiceName,
- imageProvider: binding.imageProvider,
- imageModel: binding.imageModel,
- imageSize: binding.imageSize,
- imageQuality: binding.imageQuality,
- autoSelfReport: configEntry.autoSelfReport ?? false,
- asanaEnabled: configEntry.asanaEnabled,
- asanaWorkspaceGid: configEntry.asanaWorkspaceGid,
- ...(binding.omnigent ? { omnigent: binding.omnigent } : {}),
- avatarUrl: avatar
- ? `/api/familiars/${encodeURIComponent(f.id)}/avatar?v=${Math.round(avatar.mtimeMs)}&format=png`
- : undefined,
- };
- }),
- );
+ const enrichFamiliar = async (f: (typeof rosterResult.roster)[number]) => {
+ const configEntry = config.familiars[f.id] ?? {};
+ const binding = bindingFor(config, f.id);
+ const avatar = await resolveFamiliarAvatar(f.id);
+ return {
+ ...f,
+ display_name: binding.display_name ?? f.display_name,
+ role: binding.role ?? f.role,
+ pronouns: binding.pronouns ?? f.pronouns,
+ description: binding.description ?? f.description,
+ color: binding.color,
+ harness: binding.harness,
+ defaultHarness: config.defaults.harness,
+ harnessOverride: configEntry.harness ?? null,
+ model: binding.model,
+ note: binding.note,
+ voiceProvider: binding.voiceProvider,
+ voiceModel: binding.voiceModel,
+ voiceName: binding.voiceName,
+ imageProvider: binding.imageProvider,
+ imageModel: binding.imageModel,
+ imageSize: binding.imageSize,
+ imageQuality: binding.imageQuality,
+ autoSelfReport: configEntry.autoSelfReport ?? false,
+ asanaEnabled: configEntry.asanaEnabled,
+ asanaWorkspaceGid: configEntry.asanaWorkspaceGid,
+ ...(binding.omnigent ? { omnigent: binding.omnigent } : {}),
+ avatarUrl: avatar
+ ? `/api/familiars/${encodeURIComponent(f.id)}/avatar?v=${Math.round(avatar.mtimeMs)}&format=png`
+ : undefined,
+ };
+ };
+ const familiars = await Promise.all(roster.map(enrichFamiliar));
+
+ // The table can show cards from many projects at once. Fetching the daemon
+ // roster once per project is especially expensive for hub and remote-host
+ // installs, so repeated projectId parameters return each filtered roster
+ // from one config/permissions/daemon snapshot.
+ if (projectIds.length > 1) {
+ const familiarById = new Map(familiars.map((familiar) => [familiar.id, familiar]));
+ return NextResponse.json({
+ ok: true,
+ familiarsByProject: Object.fromEntries(
+ projectIds.map((projectId) => [
+ projectId,
+ (rostersByProject.get(projectId) ?? []).flatMap((familiar) => {
+ const enriched = familiarById.get(familiar.id);
+ return enriched ? [enriched] : [];
+ }),
+ ]),
+ ),
+ });
+ }
return NextResponse.json({ ok: true, familiars });
}
diff --git a/src/components/board-chat-link.test.ts b/src/components/board-chat-link.test.ts
index 48a796fe0..9f0e98c7e 100644
--- a/src/components/board-chat-link.test.ts
+++ b/src/components/board-chat-link.test.ts
@@ -17,6 +17,11 @@ assert.match(
/fetch\(`\/api\/board\/\$\{id\}\/chat`, \{[\s\S]*method: "POST"/,
"Task chat action should POST to the board chat link endpoint",
);
+assert.match(
+ boardView,
+ /if \(card\?\.projectId && !card\.familiarId\) \{[\s\S]{0,260}Choose an authorized familiar before starting work in this project\.[\s\S]{0,180}return null;[\s\S]{0,260}const fallbackFamiliarId = card\?\.familiarId \?\? activeFamiliarId/,
+ "project-backed task work must not fall back to an unrelated active familiar when no authorized familiar is assigned",
+);
assert.doesNotMatch(
boardView,
/onJumpToSession\?\.\(json\.sessionId, json\.familiarId/,
@@ -32,6 +37,11 @@ assert.match(
/const openTaskWork = async \(id: string\) =>/,
"BoardView should expose one task-scoped work entry path",
);
+assert.match(
+ boardView,
+ /if \(card\.sessionId && !card\.projectId\)/,
+ "project-backed task sessions must revisit the board endpoint for current authorization before opening",
+);
assert.match(
boardView,
/if \(isMobile\)[\s\S]*onJumpToSession\?\.\(/,
@@ -87,6 +97,11 @@ assert.match(
/projectById\(card\.projectId, await loadProjects\(\)\)[\s\S]{0,900}assertProjectAccess\(\{ familiarId \}, assignedProject\.id, "session-launch"\)/,
"Board chat endpoint should resolve assigned project roots server-side and authorize the familiar",
);
+assert.match(
+ route,
+ /assertProjectAccess\(\{ familiarId \}, assignedProject\.id, "session-launch"\)[\s\S]{0,700}if \(card\.sessionId\) \{[\s\S]{0,300}reused: true/,
+ "a project-linked session is reused only after the current familiar passes project authorization",
+);
assert.match(
route,
/project root does not match assigned task project/,
diff --git a/src/components/board-inspector.tsx b/src/components/board-inspector.tsx
index 43acb84b4..03a4afa59 100644
--- a/src/components/board-inspector.tsx
+++ b/src/components/board-inspector.tsx
@@ -44,6 +44,7 @@ import { attachmentIcon, fileToAttachment, hasDraggedFiles } from "@/lib/chat-at
import type { CardPatch } from "@/lib/board-card-ops";
import { sessionStatusTone, sessionStatusWord } from "@/lib/session-status";
import { BoardInspectorDebug } from "@/components/board-inspector-debug";
+import { useProjectFamiliars } from "@/lib/use-project-familiars";
const DEFAULT_TIMEOUT_MS = 2 * 60 * 60 * 1000;
@@ -278,7 +279,9 @@ function GitHubAttachSection({
const fam = familiars.find(
(f) => f.display_name?.toLowerCase() === item.repo?.toLowerCase()
);
- if (fam) onPatch(card.id, { familiarId: fam.id });
+ // A session carries the prior familiar's harness/runtime context. Do not
+ // relabel it as this familiar merely because a GitHub assignment matched.
+ if (fam) onPatch(card.id, { familiarId: fam.id, sessionId: null });
}
const iconName = (k: string) => (KIND_ICON[k] ?? "ph:link") as IconName;
@@ -1171,6 +1174,38 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o
];
const resolvedFamiliarList = useResolvedFamiliars(currentFamiliar ? [currentFamiliar] : [], { includeArchived: true });
const resolvedFamiliar = resolvedFamiliarList[0] ?? null;
+ const {
+ familiars: eligibleFamiliars,
+ loading: eligibleFamiliarsLoading,
+ loadedSuccessfully: eligibleFamiliarsLoaded,
+ } = useProjectFamiliars({ projectId: card.projectId ?? null });
+
+ // Preserve an assignment that remains authorized, but fail closed if a
+ // project edit makes the card's familiar ineligible for its task launch.
+ useEffect(() => {
+ if (!card.projectId || !card.familiarId || !eligibleFamiliarsLoaded) return;
+ if (!eligibleFamiliars.some((familiar) => familiar.id === card.familiarId)) {
+ // A linked session belongs to the prior familiar/project context. Keep
+ // it from being reopened after the task is reassigned to an authorized
+ // familiar, which can use a different runtime or harness.
+ onPatch(card.id, { familiarId: null, sessionId: null });
+ }
+ }, [card.familiarId, card.id, card.projectId, eligibleFamiliars, eligibleFamiliarsLoaded, onPatch]);
+
+ const familiarPickerReady = !card.projectId || (eligibleFamiliarsLoaded && !eligibleFamiliarsLoading);
+ const familiarOptions = !card.projectId
+ ? [
+ { value: "", label: "Unassigned" },
+ ...familiars.map((familiar) => ({ value: familiar.id, label: familiar.display_name })),
+ ]
+ : eligibleFamiliarsLoading
+ ? [{ value: "", label: "Loading authorized familiars…", disabled: true }]
+ : !eligibleFamiliarsLoaded
+ ? [{ value: "", label: "Could not load authorized familiars", disabled: true }]
+ : [
+ { value: "", label: "Unassigned" },
+ ...eligibleFamiliars.map((familiar) => ({ value: familiar.id, label: familiar.display_name })),
+ ];
const close = () => { setClosing(true); setTimeout(onClose, 180); };
@@ -1275,6 +1310,61 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o
+
+
+ Project
+
+
+ Open Projects
+
+
+
+
+
+
+ {
+ const selectedProject = projects.find((project) => project.id === next) ?? null;
+ // A Project → Familiar assignment is only valid after the
+ // target project's authorized roster resolves. Clear an
+ // existing familiar immediately so Start work cannot race
+ // that lookup with the old project's assignment.
+ onPatch(card.id, {
+ projectId: selectedProject?.id ?? null,
+ cwd: selectedProject?.root ?? null,
+ familiarId: null,
+ sessionId: null,
+ });
+ }}
+ options={[
+ { value: "", label: "No project" },
+ ...projects.map((project) => ({ value: project.id, label: project.name })),
+ ]}
+ showCaret={false}
+ />
+
+
+ {projects.length === 0 ? (
+
+ No projects yet. Open Projects to add one, then choose it here.
+
+ ) : null}
+ {projects.length > 0 && !card.projectId && !card.cwd ? (
+
+ No project set — task work can't start, and linked sessions won't open in the
+ right project, until you pick one.
+
+ ) : null}
+
+
Familiar
@@ -1288,15 +1378,19 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o
{
setModelCustomMode(false);
- persistTaskModelPatch({ familiarId: next || null, ...taskModelPatch(null) });
+ // Rebinding changes the runtime context; its linked session and
+ // task-specific model cannot safely follow the new familiar.
+ persistTaskModelPatch({
+ familiarId: next || null,
+ sessionId: null,
+ ...taskModelPatch(null),
+ });
}}
- options={[
- { value: "", label: "Unassigned" },
- ...familiars.map((f) => ({ value: f.id, label: f.display_name })),
- ]}
+ options={familiarOptions}
+ disabled={!familiarPickerReady}
showCaret={false}
/>
@@ -1373,51 +1467,6 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o
-
-
- Project
-
-
- Open Projects
-
-
-
-
-
-
- {
- const selectedProject = projects.find((project) => project.id === next) ?? null;
- onPatch(card.id, { projectId: selectedProject?.id ?? null, cwd: selectedProject?.root ?? null });
- }}
- options={[
- { value: "", label: "No project" },
- ...projects.map((project) => ({ value: project.id, label: project.name })),
- ]}
- showCaret={false}
- />
-
-
- {projects.length === 0 ? (
-
- No projects yet. Open Projects to add one, then choose it here.
-
- ) : null}
- {projects.length > 0 && !card.projectId && !card.cwd ? (
-
- No project set — task work can't start, and linked sessions won't open in the
- right project, until you pick one.
-
- ) : null}
-
diff --git a/src/components/board-project-picker.test.ts b/src/components/board-project-picker.test.ts
index 0ef70cfe3..c4bb55648 100644
--- a/src/components/board-project-picker.test.ts
+++ b/src/components/board-project-picker.test.ts
@@ -20,8 +20,8 @@ assert.match(
);
assert.match(
inspector,
- /onPatch\(card\.id, \{ projectId: selectedProject\?\.id \?\? null, cwd: selectedProject\?\.root \?\? null \}\)/,
- "changing the inspector project picker patches projectId and its persisted cwd",
+ /onPatch\(card\.id, \{[\s\S]{0,180}projectId: selectedProject\?\.id \?\? null,[\s\S]{0,180}cwd: selectedProject\?\.root \?\? null,[\s\S]{0,180}familiarId: null,[\s\S]{0,180}sessionId: null,[\s\S]{0,180}\}\)/,
+ "changing the inspector project picker clears the old familiar and session before persisting the new project root",
);
assert.match(inspector, /\{ value: "", label: "No project" \}/, "inspector offers a No-project option");
assert.match(
@@ -34,27 +34,72 @@ assert.match(
/Open Projects/,
"inspector offers a direct route to the project creation surface",
);
+assert.ok(
+ inspector.indexOf('label="Project"') < inspector.indexOf('label="Familiar"'),
+ "inspector presents Project before Familiar",
+);
+assert.match(
+ inspector,
+ /useProjectFamiliars\(\{ projectId: card\.projectId \?\? null \}\)/,
+ "inspector requests only project-authorized familiars",
+);
+assert.match(
+ inspector,
+ /value=\{familiarPickerReady \? card\.familiarId \?\? "" : ""\}[\s\S]{0,700}disabled=\{!familiarPickerReady\}/,
+ "inspector disables the familiar choice until its project authorization result is ready",
+);
+assert.match(
+ inspector,
+ /const familiarPickerReady = !card\.projectId \|\| \(eligibleFamiliarsLoaded && !eligibleFamiliarsLoading\)/,
+ "inspector keeps the familiar picker available for unscoped cards",
+);
+assert.match(
+ inspector,
+ /const familiarOptions = !card\.projectId[\s\S]{0,360}\.{3}familiars\.map\(\(familiar\)/,
+ "inspector preserves the complete familiar roster for unscoped cards",
+);
+assert.match(
+ inspector,
+ /!eligibleFamiliars\.some\([\s\S]{0,500}onPatch\(card\.id, \{ familiarId: null, sessionId: null \}\)/,
+ "inspector clears an existing familiar and its stale session when it is ineligible for the selected project",
+);
+assert.match(
+ inspector,
+ /label="Familiar"[\s\S]{0,700}onChange=\{\(next\) => \{[\s\S]{0,500}familiarId: next \|\| null,[\s\S]{0,150}sessionId: null,[\s\S]{0,150}\.\.\.taskModelPatch\(null\)/,
+ "changing an authorized familiar unlinks its prior runtime session and model",
+);
// ── New-card modal can set a project at creation time ──────────────────────
assert.match(newCard, /projectId: string \| null;/, "NewCardDraft carries projectId");
-// The modal sources its own familiar-scoped project list (rather than taking an
-// unscoped `projects` prop) so the Project picker only offers projects the
-// assigned familiar has been granted — matching the server-side grant filter.
assert.match(
newCard,
- /useProjects\(\{ familiarId, enabled: open \}\)/,
- "new-card modal scopes its project list to the selected familiar",
+ /useProjects\(\{ enabled: open \}\)/,
+ "new-card modal loads the project list before choosing a familiar",
);
assert.match(newCard, /setProjectId\(null\)/, "new-card modal resets projectId when reopened");
assert.match(
newCard,
- /setFamiliarId\(v \|\| null\);[\s\S]{0,400}setProjectId\(null\);/,
- "switching the familiar clears the selected project so an ungranted project can't ride along the re-scope",
+ /useProjectFamiliars\(\{ projectId, enabled: open \}\)/,
+ "new-card modal fetches familiar options scoped to its selected project",
+);
+assert.match(
+ newCard,
+ /!eligibleFamiliars\.some\([\s\S]{0,180}setFamiliarId\(null\);[\s\S]{0,100}setSessionId\(null\);/,
+ "new-card modal clears an incompatible familiar and linked session after a project change",
+);
+assert.ok(
+ newCard.indexOf('') < newCard.indexOf(''),
+ "new-card modal presents Project before Familiar",
+);
+assert.match(
+ newCard,
+ /value=\{familiarPickerReady \? familiarId \?\? "" : ""\}[\s\S]{0,200}disabled=\{!familiarPickerReady\}/,
+ "new-card modal gates project-backed familiar choices on authorization",
);
assert.match(
newCard,
- /[\s\S]{0,600}label: projectsLoading \? "Loading projects…" : "No project" \}/,
- "new-card modal renders a Project field whose default reads 'Loading projects…' while the scoped list is in flight, else 'No project'",
+ /onChange=\{\(v\) => \{[\s\S]{0,700}setProjectId\(v \|\| null\);[\s\S]{0,180}setFamiliarId\(null\);[\s\S]{0,180}setSessionId\(null\);/,
+ "changing a new task's project immediately clears the prior familiar while its authorized roster loads",
);
assert.match(
newCard,
diff --git a/src/components/board-table-familiar-select.test.ts b/src/components/board-table-familiar-select.test.ts
index 32ac2f497..eb4c1853a 100644
--- a/src/components/board-table-familiar-select.test.ts
+++ b/src/components/board-table-familiar-select.test.ts
@@ -12,8 +12,23 @@ assert.match(
);
assert.match(
boardTable,
- / onPatch\(card\.id, \{ familiarId: next \|\| null \}\)\}[\s\S]*options=\{familiarOptions\}/,
- "Familiar column should render an inline StandardSelect that patches card.familiarId",
+ /useProjectFamiliarsByProject\(\{ projectIds \}\)/,
+ "Table mode fetches authorized familiar rosters for every project it displays",
+);
+assert.match(
+ boardTable,
+ /const rowFamiliarOptions = !projectId[\s\S]*?familiarOptions[\s\S]*?Loading authorized familiars…[\s\S]*?scopedFamiliars\.map/,
+ "project-backed table rows only offer their authorized roster while unscoped rows preserve every familiar",
+);
+assert.match(
+ boardTable,
+ /loadingProjectIds\.has\(projectId\)\s*\?\s*"Loading authorized familiars…"\s*:\s*"Could not load authorized familiars"/,
+ "a failed project roster load is distinguished from an in-flight request instead of displaying Loading forever",
+);
+assert.match(
+ boardTable,
+ / onPatch\(card\.id, \{ familiarId: next \|\| null, sessionId: null \}\)\}[\s\S]*options=\{rowFamiliarOptions\}[\s\S]*disabled=\{!familiarPickerReady\}/,
+ "Familiar column gates project-backed choices until authorization has loaded and unlinks the prior runtime session",
);
assert.match(
boardTable,
diff --git a/src/components/board-table.tsx b/src/components/board-table.tsx
index b24289544..e67738a43 100644
--- a/src/components/board-table.tsx
+++ b/src/components/board-table.tsx
@@ -11,6 +11,7 @@ import { useResolvedFamiliars } from "@/lib/familiar-resolve";
import { useRovingTabIndex } from "@/lib/use-roving-tabindex";
import { RelativeTime } from "@/components/ui/relative-time";
import { StandardSelect } from "@/components/ui/select";
+import { useProjectFamiliarsByProject } from "@/lib/use-project-familiars";
export type GroupBy = "status" | "familiar" | "project";
export type SortKey = "title" | "status" | "priority" | "familiar" | "lifecycle" | "startDate" | "endDate" | "updatedAt";
@@ -204,6 +205,19 @@ export function BoardTable({ cards, familiars, projects, groupBy, sortKey, sortD
],
[familiars],
);
+ // Table mode can assign a familiar inline, bypassing the inspector. Scope
+ // each project-backed row to the same server-authorized roster as the other
+ // Board pickers; cards with no project intentionally retain every installed
+ // runtime/harness choice.
+ const projectIds = useMemo(
+ () => cards.flatMap((card) => card.projectId ? [card.projectId] : []),
+ [cards],
+ );
+ const {
+ familiarsByProject,
+ loadingProjectIds,
+ loadedProjectIds,
+ } = useProjectFamiliarsByProject({ projectIds });
// Columns rendered in the user's saved order; any unknown/new key is dropped
// and any column missing from the saved order is appended so the table is
@@ -497,6 +511,29 @@ export function BoardTable({ cards, familiars, projects, groupBy, sortKey, sortD
);
break;
case "familiar":
+ {
+ const projectId = card.projectId;
+ const scopedFamiliars = projectId ? familiarsByProject.get(projectId) ?? [] : familiars;
+ const familiarPickerReady = !projectId || (
+ loadedProjectIds.has(projectId) && !loadingProjectIds.has(projectId)
+ );
+ const rowFamiliarOptions = !projectId
+ ? familiarOptions
+ : !familiarPickerReady
+ ? [{
+ value: "",
+ label: loadingProjectIds.has(projectId)
+ ? "Loading authorized familiars…"
+ : "Could not load authorized familiars",
+ disabled: true,
+ }]
+ : [
+ { value: "", label: "Unassigned" },
+ ...scopedFamiliars.map((familiar) => ({
+ value: familiar.id,
+ label: familiar.display_name,
+ })),
+ ];
content = (
@@ -506,13 +543,18 @@ export function BoardTable({ cards, familiars, projects, groupBy, sortKey, sortD
onPatch(card.id, { familiarId: next || null })}
- options={familiarOptions}
+ value={familiarPickerReady ? card.familiarId ?? "" : ""}
+ // A linked session was created with the prior familiar's
+ // harness/runtime. Reassignment must start fresh instead
+ // of relabelling that session under a different runtime.
+ onChange={(next) => onPatch(card.id, { familiarId: next || null, sessionId: null })}
+ options={rowFamiliarOptions}
+ disabled={!familiarPickerReady}
/>
);
+ }
break;
case "lifecycle":
content = ;
diff --git a/src/components/board-task-model.test.ts b/src/components/board-task-model.test.ts
index 57d5a4def..dcc6df812 100644
--- a/src/components/board-task-model.test.ts
+++ b/src/components/board-task-model.test.ts
@@ -28,7 +28,7 @@ assert.match(
"the inspector renders every runtime-provided model option, including authenticated catalogs",
);
assert.match(inspector, /const taskModelPatch = \(modelOverride: string \| null\): CardPatch => \(\{[\s\S]{0,120}modelOverrideHarness: modelOverride \? modelHarness \|\| null : null/, "the inspector tags each task model with its selected runtime");
-assert.match(inspector, /persistTaskModelPatch\(\{ familiarId: next \|\| null, \.\.\.taskModelPatch\(null\) \}\)/, "changing familiar clears the prior task model and runtime");
+assert.match(inspector, /persistTaskModelPatch\(\{[\s\S]{0,120}familiarId: next \|\| null,[\s\S]{0,120}sessionId: null,[\s\S]{0,120}\.\.\.taskModelPatch\(null\)/, "changing familiar clears the linked session, prior task model, and runtime");
assert.match(inspector, /label="Model"/, "inspector exposes a Model control");
assert.match(
inspector,
diff --git a/src/components/board-ux-polish.test.ts b/src/components/board-ux-polish.test.ts
index 0a49e6930..e200637f7 100644
--- a/src/components/board-ux-polish.test.ts
+++ b/src/components/board-ux-polish.test.ts
@@ -153,6 +153,11 @@ assert.match(
"quick-add inherits the swimlane's familiar so the card lands in the right lane",
);
assert.match(view, /onQuickAdd=\{quickAdd\}/, "BoardView wires the quick-add create path");
+assert.match(
+ view,
+ /familiarId: lane\.projectId \? null : \(lane\.familiarId !== undefined \? lane\.familiarId : \(activeFamiliarId \?\? null\)\)/,
+ "quick-add leaves project-lane tasks unassigned until their authorized familiar is selected",
+);
// ── WIP limits per column (#3) ──
assert.match(kanban, /wipLimits\?:\s*WipLimits/, "BoardKanban accepts WIP limits");
diff --git a/src/components/board-view.tsx b/src/components/board-view.tsx
index 24f1e6ae7..0cfd1de2a 100644
--- a/src/components/board-view.tsx
+++ b/src/components/board-view.tsx
@@ -516,7 +516,10 @@ export function BoardView({
// Inline quick-add from a kanban column: title-only card in that column's
// status, scoped to the swimlane it was dropped under (familiar/project) or
- // the active familiar when ungrouped.
+ // the active familiar when ungrouped. Project lanes deliberately begin
+ // unassigned: the active familiar may use a different harness/runtime and
+ // lack that project's session-launch grant, so the dependent picker must
+ // choose an authorized familiar first.
const quickAdd = async (
status: CardStatus,
title: string,
@@ -527,7 +530,7 @@ export function BoardView({
notes: "",
status,
priority: "medium",
- familiarId: lane.familiarId !== undefined ? lane.familiarId : (activeFamiliarId ?? null),
+ familiarId: lane.projectId ? null : (lane.familiarId !== undefined ? lane.familiarId : (activeFamiliarId ?? null)),
sessionId: null,
projectId: lane.projectId !== undefined ? lane.projectId : null,
cwd: null,
@@ -744,6 +747,16 @@ export function BoardView({
projectRoot?: string,
): Promise<{ sessionId: string; familiarId: string | null } | null> => {
const card = cards.find((candidate) => candidate.id === id);
+ // Project-backed tasks must retain an explicit familiar chosen from the
+ // project's authorized roster. Falling back to the active/default
+ // familiar here would bypass the Project → Familiar picker and produce a
+ // late `project access denied` for installations whose active runtime is
+ // not granted this project.
+ if (card?.projectId && !card.familiarId) {
+ setChatLinkError("Choose an authorized familiar before starting work in this project.");
+ setChatLinkErrorCardId(id);
+ return null;
+ }
const fallbackFamiliarId = card?.familiarId ?? activeFamiliarId ?? familiars[0]?.id ?? null;
setChatLinkingId(id);
setChatLinkError(null);
@@ -785,7 +798,11 @@ export function BoardView({
const openTaskWork = async (id: string) => {
const card = cards.find((candidate) => candidate.id === id);
if (!card) return;
- if (card.sessionId) {
+ // Project-backed sessions must still traverse the board-chat endpoint so
+ // a grant revoked after the session was created cannot be reopened from
+ // the Board without a fresh authorization check. Unscoped legacy cards
+ // have no project boundary to revalidate and can keep the direct path.
+ if (card.sessionId && !card.projectId) {
if (isMobile) {
const linkedSession = sessions.find((session) => session.id === card.sessionId);
onJumpToSession?.(card.sessionId, linkedSession?.familiarId ?? card.familiarId ?? null);
diff --git a/src/components/new-card-modal.test.ts b/src/components/new-card-modal.test.ts
index 0be9e78d6..eb7d5a0bc 100644
--- a/src/components/new-card-modal.test.ts
+++ b/src/components/new-card-modal.test.ts
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const modal = readFileSync(new URL("./new-card-modal.tsx", import.meta.url), "utf8");
+const projectFamiliars = readFileSync(new URL("../lib/use-project-familiars.ts", import.meta.url), "utf8");
assert.ok(modal.includes('import { Button } from "@/components/ui/button"'), "new-card modal action buttons use the shared Button primitive");
assert.ok(modal.includes('import { StandardSelect } from "@/components/ui/select"'), "new-card modal dropdowns use StandardSelect");
@@ -10,24 +11,57 @@ assert.doesNotMatch(modal, /\(null\)/,
+ "project-scoped familiar results retain the project that produced them",
+);
+assert.match(
+ projectFamiliars,
+ /loadedSuccessfully: enabled && Boolean\(projectId\) && loadedProjectId === projectId/,
+ "a familiar roster from the previous project never enables the picker during a project change",
+);
+assert.match(
+ projectFamiliars,
+ /catch \{[\s\S]{0,220}finally/,
+ "a failed familiar request leaves the dependent picker in its load-failure state without an unhandled rejection",
+);
+assert.match(
+ projectFamiliars,
+ /for \(const projectId of ids\) search\.append\("projectId", projectId\)/,
+ "table project rosters are requested together so remote/hub installs do not repeat daemon lookups per project",
);
console.log("new-card-modal.test.ts: ok");
diff --git a/src/components/new-card-modal.tsx b/src/components/new-card-modal.tsx
index 0310f6f04..9a7194011 100644
--- a/src/components/new-card-modal.tsx
+++ b/src/components/new-card-modal.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import type { Familiar, SessionRow } from "@/lib/types";
import { useProjects } from "@/lib/use-projects";
+import { useProjectFamiliars } from "@/lib/use-project-familiars";
import { Modal } from "@/components/ui/modal";
import { Button } from "@/components/ui/button";
import { PropertyPill } from "@/components/ui/property-pill";
@@ -73,11 +74,16 @@ export function NewCardModal({
const [error, setError] = useState(null);
const coarse = useIsCoarsePointer();
- // Only offer projects the assigned familiar can actually reach — the board
- // POST starts the card's chat under that familiar, which the server rejects
- // (403) for an ungranted project. Re-scopes when the Familiar picker below
- // changes; a null familiar ("Default familiar") loads the operator-wide list.
- const { projects, loading: projectsLoading } = useProjects({ familiarId, enabled: open });
+ // Project-backed familiar choices are fetched server-side with the
+ // session-launch access requirement, matching the final launch
+ // authorization boundary. Unscoped cards retain the complete roster so
+ // users can deliberately choose the installed harness/runtime they need.
+ const { projects, loading: projectsLoading } = useProjects({ enabled: open });
+ const {
+ familiars: eligibleFamiliars,
+ loading: eligibleFamiliarsLoading,
+ loadedSuccessfully: eligibleFamiliarsLoaded,
+ } = useProjectFamiliars({ projectId, enabled: open });
useEffect(() => {
if (!open) return;
@@ -95,6 +101,14 @@ export function NewCardModal({
setError(null);
}, [open, defaultStatus, defaultFamiliarId, defaultTitle, defaultLinks, defaultNotes, defaultLabels]);
+ useEffect(() => {
+ if (!projectId || !familiarId || !eligibleFamiliarsLoaded) return;
+ if (!eligibleFamiliars.some((familiar) => familiar.id === familiarId)) {
+ setFamiliarId(null);
+ setSessionId(null);
+ }
+ }, [eligibleFamiliars, eligibleFamiliarsLoaded, familiarId, projectId]);
+
const eligibleSessions = familiarId
? sessions.filter((s) => s.familiarId === familiarId)
: sessions;
@@ -102,6 +116,26 @@ export function NewCardModal({
// The selected project drives the card's working directory — there is no
// free-form cwd field, so drafts never carry machine-specific paths.
const selectedProject = projects.find((p) => p.id === projectId) ?? null;
+ const familiarPickerReady = !projectId || (eligibleFamiliarsLoaded && !eligibleFamiliarsLoading);
+ const familiarOptions = !projectId
+ ? [
+ { value: "", label: "Unassigned" },
+ ...familiars.map((familiar) => ({
+ value: familiar.id,
+ label: `${familiar.display_name} · ${familiar.harness ?? "?"}`,
+ })),
+ ]
+ : eligibleFamiliarsLoading
+ ? [{ value: "", label: "Loading authorized familiars…", disabled: true }]
+ : !eligibleFamiliarsLoaded
+ ? [{ value: "", label: "Could not load authorized familiars", disabled: true }]
+ : [
+ { value: "", label: "Unassigned" },
+ ...eligibleFamiliars.map((familiar) => ({
+ value: familiar.id,
+ label: `${familiar.display_name} · ${familiar.harness ?? "?"}`,
+ })),
+ ];
const create = async () => {
if (!title.trim() || busy) return;
@@ -224,38 +258,34 @@ export function NewCardModal({
/>
-
+
{
- setFamiliarId(v || null);
+ // A project change invalidates the prior familiar scope until
+ // the project-authorized roster has loaded. Do not let a quick
+ // submit carry the modal's default/previous familiar into an
+ // unrelated project.
+ setProjectId(v || null);
+ setFamiliarId(null);
setSessionId(null);
- // The project list re-scopes to the new familiar; a project the
- // previous familiar could reach may not be granted to this one.
- setProjectId(null);
}}
options={[
- { value: "", label: "Default familiar" },
- ...familiars.map((f) => ({
- value: f.id,
- label: `${f.display_name} · ${f.harness ?? "?"}`,
- })),
+ { value: "", label: projectsLoading ? "Loading projects…" : "No project" },
+ ...(projectsLoading ? [] : projects.map((p) => ({ value: p.id, label: p.name }))),
]}
/>
-
+
setProjectId(v || null)}
- options={[
- // While the familiar-scoped list is in flight, suppress the
- // options entirely: the retained list belongs to the *previous*
- // familiar, so offering it lets the user pick a project this
- // familiar can't reach (the board chat-launch then 403s).
- { value: "", label: projectsLoading ? "Loading projects…" : "No project" },
- ...(projectsLoading ? [] : projects.map((p) => ({ value: p.id, label: p.name }))),
- ]}
+ value={familiarPickerReady ? familiarId ?? "" : ""}
+ onChange={(v) => {
+ setFamiliarId(v || null);
+ setSessionId(null);
+ }}
+ options={familiarOptions}
+ disabled={!familiarPickerReady}
/>
@@ -343,10 +373,12 @@ function Select({
value,
onChange,
options,
+ disabled = false,
}: {
value: string;
onChange: (v: string) => void;
- options: { value: string; label: string }[];
+ options: { value: string; label: string; disabled?: boolean }[];
+ disabled?: boolean;
}) {
return (
);
diff --git a/src/components/task-chat-cwd.test.ts b/src/components/task-chat-cwd.test.ts
index 8e34a4bb1..e847263b5 100644
--- a/src/components/task-chat-cwd.test.ts
+++ b/src/components/task-chat-cwd.test.ts
@@ -129,17 +129,17 @@ assert.match(
);
assert.match(
boardInspector,
- /[\s\S]{0,120}
Project<\/span>[\s\S]{0,1100}onPatch\(card\.id, \{ projectId: selectedProject\?\.id \?\? null, cwd: selectedProject\?\.root \?\? null \}\)/,
- "The task Project field should set the runtime root for chat starts",
+ /[\s\S]{0,120}
Project<\/span>[\s\S]{0,1700}onPatch\(card\.id, \{[\s\S]{0,180}projectId: selectedProject\?\.id \?\? null,[\s\S]{0,180}cwd: selectedProject\?\.root \?\? null,[\s\S]{0,180}familiarId: null,[\s\S]{0,180}\}\)/,
+ "The task Project field should set the runtime root and clear the prior familiar",
);
assert.match(
boardInspector,
- /onPatch\(card\.id, \{ projectId: selectedProject\?\.id \?\? null, cwd: selectedProject\?\.root \?\? null \}\)/,
- "Changing the task project should persist both projectId and cwd",
+ /onPatch\(card\.id, \{[\s\S]{0,180}projectId: selectedProject\?\.id \?\? null,[\s\S]{0,180}cwd: selectedProject\?\.root \?\? null,[\s\S]{0,180}familiarId: null,[\s\S]{0,180}\}\)/,
+ "Changing the task project should persist projectId and cwd, then clear the familiar",
);
assert.match(
boardInspector,
- / \(\{ value: project\.id, label: project\.name \}\)\)/,
+ / \(\{ value: project\.id, label: project\.name \}\)\)/,
"The task project picker should render the persisted project registry through the shared select",
);
assert.doesNotMatch(
diff --git a/src/lib/board-search.test.ts b/src/lib/board-search.test.ts
index 5f7fc898c..0db5c9153 100644
--- a/src/lib/board-search.test.ts
+++ b/src/lib/board-search.test.ts
@@ -103,5 +103,9 @@ const boardInspector = await readFile(new URL("../components/board-inspector.tsx
assert.match(boardInspector, /link-item-anchor/, "Task inspector should render task links");
assert.match(boardInspector, /card\.sessionId/, "Task inspector should render task session context");
assert.match(boardInspector, /[\s\S]{0,120}
Project<\/span>/, "Task inspector should expose the task project selector");
-assert.match(boardInspector, /onPatch\(card\.id, \{ projectId: selectedProject\?\.id \?\? null, cwd: selectedProject\?\.root \?\? null \}\)/, "Task project changes should set the persisted cwd");
+assert.match(
+ boardInspector,
+ /onPatch\(card\.id, \{[\s\S]{0,160}projectId: selectedProject\?\.id \?\? null,[\s\S]{0,160}cwd: selectedProject\?\.root \?\? null,[\s\S]{0,160}familiarId: null,[\s\S]{0,160}\}\)/,
+ "Task project changes should set the persisted cwd and clear the prior familiar",
+);
assert.doesNotMatch(boardInspector, /function openCwdInExplorer|aria-label="Open CWD in directory explorer"/, "Task inspector should not expose a separate CWD open action");
diff --git a/src/lib/project-permissions.test.ts b/src/lib/project-permissions.test.ts
index 944bc34c8..50ce42176 100644
--- a/src/lib/project-permissions.test.ts
+++ b/src/lib/project-permissions.test.ts
@@ -20,6 +20,7 @@ try {
createGrantProposal,
deleteAccessGroup,
effectiveProjectAccess,
+ filterFamiliarsForProject,
filterProjectsForFamiliar,
grantProjectToFamiliar,
listAccessibleProjects,
@@ -68,6 +69,11 @@ try {
["cave"],
"project picker results are filtered server-side for normal familiars",
);
+ assert.deepEqual(
+ filterFamiliarsForProject(permissions, [{ id: "nova" }, { id: "sage" }], "cave"),
+ [{ id: "nova" }],
+ "familiar picker results use session-launch access for the selected project",
+ );
assert.deepEqual(
(await filterProjectsForFamiliar(projects, "supreme")).map((project) => project.id),
[],
diff --git a/src/lib/project-permissions.ts b/src/lib/project-permissions.ts
index 374b4389d..95882d9f2 100644
--- a/src/lib/project-permissions.ts
+++ b/src/lib/project-permissions.ts
@@ -440,6 +440,25 @@ export function canAccessProject(
return accessLevelSatisfies(effective.level, required);
}
+/**
+ * Filters a roster to the familiars that can use a project surface. Keeping
+ * this alongside the project filter makes the two sides of a Project →
+ * Familiar picker use the same effective direct and group-grant rules as the
+ * final server-side authorization check.
+ */
+export function filterFamiliarsForProject(
+ file: Pick &
+ Partial>,
+ familiars: readonly T[],
+ projectId: string,
+ surface: ProjectPermissionSurface = "session-launch",
+): T[] {
+ const required = requiredAccessLevel(surface);
+ return familiars.filter((familiar) =>
+ canAccessProject(file, { familiarId: familiar.id }, projectId, required),
+ );
+}
+
/** Every project the familiar can reach, with its effective level. */
export async function listAccessibleProjects(
projects: CaveProject[],
diff --git a/src/lib/use-project-familiars.test.ts b/src/lib/use-project-familiars.test.ts
new file mode 100644
index 000000000..81358ed5d
--- /dev/null
+++ b/src/lib/use-project-familiars.test.ts
@@ -0,0 +1,13 @@
+// @ts-nocheck
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+
+const source = readFileSync(new URL("./use-project-familiars.ts", import.meta.url), "utf8");
+
+assert.match(
+ source,
+ /ids\.length === 1 && Array\.isArray\(payload\.familiars\)[\s\S]*?\[ids\[0\]\]: payload\.familiars/,
+ "the batch project-familiar hook accepts the API's single-project response shape",
+);
+
+console.log("project familiar hook response compatibility passed");
diff --git a/src/lib/use-project-familiars.ts b/src/lib/use-project-familiars.ts
new file mode 100644
index 000000000..49f08b0c0
--- /dev/null
+++ b/src/lib/use-project-familiars.ts
@@ -0,0 +1,155 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import type { Familiar } from "@/lib/types";
+
+export type ProjectFamiliarsState = {
+ familiars: Familiar[];
+ loading: boolean;
+ loadedSuccessfully: boolean;
+};
+
+export type ProjectFamiliarsByProjectState = {
+ familiarsByProject: ReadonlyMap;
+ loadingProjectIds: ReadonlySet;
+ loadedProjectIds: ReadonlySet;
+};
+
+/**
+ * Loads only familiars that may launch a session in the selected project.
+ * Clearing the prior result before each fetch prevents a picker from briefly
+ * offering a familiar authorized for the previously selected project.
+ */
+export function useProjectFamiliars({
+ projectId,
+ enabled = true,
+}: {
+ projectId: string | null;
+ enabled?: boolean;
+}): ProjectFamiliarsState {
+ const [familiars, setFamiliars] = useState([]);
+ const [loading, setLoading] = useState(false);
+ // Keep the result tied to the project that produced it. Effects run after
+ // render, so clearing state inside the effect alone would briefly expose
+ // the previous project's roster after projectId changes.
+ const [loadedProjectId, setLoadedProjectId] = useState(null);
+ const generationRef = useRef(0);
+
+ useEffect(() => {
+ generationRef.current += 1;
+ const generation = generationRef.current;
+
+ if (!enabled || !projectId) {
+ setFamiliars([]);
+ setLoading(false);
+ setLoadedProjectId(null);
+ return;
+ }
+
+ setFamiliars([]);
+ setLoading(true);
+ setLoadedProjectId(null);
+ void (async () => {
+ try {
+ const response = await fetch(`/api/familiars?projectId=${encodeURIComponent(projectId)}`, {
+ cache: "no-store",
+ });
+ const payload = await response.json().catch(() => null) as { ok?: boolean; familiars?: Familiar[] } | null;
+ if (generationRef.current !== generation) return;
+ if (response.ok && payload?.ok) {
+ setFamiliars(Array.isArray(payload.familiars) ? payload.familiars : []);
+ setLoadedProjectId(projectId);
+ }
+ } catch {
+ // Keep the picker disabled and show its existing load-failure state.
+ // Fetch can reject when a locally hosted or remote Cave is restarting.
+ } finally {
+ if (generationRef.current === generation) setLoading(false);
+ }
+ })();
+ }, [enabled, projectId]);
+
+ return {
+ familiars,
+ loading,
+ loadedSuccessfully: enabled && Boolean(projectId) && loadedProjectId === projectId,
+ };
+}
+
+/**
+ * Fetches the authorized roster once per distinct project. Table mode exposes
+ * an inline familiar picker for many cards at once, so sharing this lookup
+ * keeps project-backed cards constrained without hiding the complete roster
+ * for intentionally unscoped tasks.
+ */
+export function useProjectFamiliarsByProject({
+ projectIds,
+ enabled = true,
+}: {
+ projectIds: readonly string[];
+ enabled?: boolean;
+}): ProjectFamiliarsByProjectState {
+ const [familiarsByProject, setFamiliarsByProject] = useState>(() => new Map());
+ const [loadingProjectIds, setLoadingProjectIds] = useState>(() => new Set());
+ const [loadedProjectIds, setLoadedProjectIds] = useState>(() => new Set());
+ const generationRef = useRef(0);
+ const projectIdsKey = [...new Set(projectIds.map((projectId) => projectId.trim()).filter(Boolean))]
+ .sort()
+ .join("\u0000");
+
+ useEffect(() => {
+ generationRef.current += 1;
+ const generation = generationRef.current;
+ const ids = projectIdsKey ? projectIdsKey.split("\u0000") : [];
+
+ if (!enabled || ids.length === 0) {
+ setFamiliarsByProject(new Map());
+ setLoadingProjectIds(new Set());
+ setLoadedProjectIds(new Set());
+ return;
+ }
+
+ setFamiliarsByProject(new Map());
+ setLoadingProjectIds(new Set(ids));
+ setLoadedProjectIds(new Set());
+ const search = new URLSearchParams();
+ for (const projectId of ids) search.append("projectId", projectId);
+ void (async () => {
+ try {
+ const response = await fetch(`/api/familiars?${search}`, { cache: "no-store" });
+ const payload = await response.json().catch(() => null) as {
+ ok?: boolean;
+ familiars?: Familiar[];
+ familiarsByProject?: Record;
+ } | null;
+ if (!response.ok || !payload?.ok) return;
+ if (generationRef.current !== generation) return;
+ // `/api/familiars?projectId=…` deliberately retains its established
+ // single-project `{ familiars }` response for inspector/modal callers.
+ // A board can currently contain only one distinct project, though, in
+ // which case this batch hook makes that same one-id request. Accept
+ // both response forms so the inline picker works for single-project
+ // boards and during client/server version-skewed desktop updates.
+ const familiarsByProject = payload.familiarsByProject
+ ?? (ids.length === 1 && Array.isArray(payload.familiars)
+ ? { [ids[0]]: payload.familiars }
+ : null);
+ if (!familiarsByProject) return;
+ const loaded = ids.map((projectId) => [
+ projectId,
+ Array.isArray(familiarsByProject[projectId])
+ ? familiarsByProject[projectId]
+ : [],
+ ] as const);
+ setFamiliarsByProject(new Map(loaded));
+ setLoadedProjectIds(new Set(ids));
+ } catch {
+ // Keep every affected picker disabled and show its existing failure state.
+ } finally {
+ if (generationRef.current === generation) setLoadingProjectIds(new Set());
+ }
+ })();
+ }, [enabled, projectIdsKey]);
+
+ return { familiarsByProject, loadingProjectIds, loadedProjectIds };
+}