rescan: count affordances, and say what a rescan cannot do

the header's rescan walks ITEM_TYPES, and affordances are not one — no
directory per item, no canonical file — so they were invisible to it. write a
new affordance, press the button, and nothing in the response mentions it.

counted by LOADING them rather than counting .md files, so the number means
"affordances the agent will actually receive" and not "markdown files
present": one with no body is skipped by the loader and should not be reported
as though it were working.

counting is as far as this can honestly go, and the toast now says so.
affordanceBlock() is read when a chat session is CREATED, so a new affordance
is already live without a server restart — for the NEXT session. nothing can
retrofit one into a conversation already running, because its system prompt
was assembled when it started. the button's value here is "the file is on disk
and the platform can see it", which was the part genuinely unknowable from the
UI.

also creates the affordances directory, so it is discoverable on a machine
that has never had one, the same way the item dirs already are.
This commit is contained in:
2026-08-19 23:35:11 +00:00
parent b14b1bf678
commit ab214d1a9e
2 changed files with 29 additions and 3 deletions
@@ -33,7 +33,14 @@ export function RescanButton() {
await Promise.all(ITEM_QUERY_KEYS.map((key) => qc.invalidateQueries({ queryKey: [key], refetchType: 'all' })));
const c = res.counts ?? {};
toast.success(
`Rescanned items — ${c.tasks ?? 0} tasks, ${c.agents ?? 0} agents, ${c.tools ?? 0} tools, ${c.skills ?? 0} skills, ${c.processes ?? 0} processes`,
`Rescanned items — ${c.tasks ?? 0} tasks, ${c.agents ?? 0} agents, ${c.tools ?? 0} tools, ${c.skills ?? 0} skills, ${c.processes ?? 0} processes, ${c.affordances ?? 0} affordances`,
{
// Said here rather than left to be discovered: a rescan cannot reach a chat that is already
// open, because its system prompt was assembled when it started.
description: c.affordances
? 'Affordances apply to new chats — an open one keeps the set it started with.'
: undefined,
},
);
} catch {
toast.error('Failed to rescan items');
+21 -2
View File
@@ -1,13 +1,25 @@
import { readdirSync, existsSync } from 'node:fs';
import { readdirSync, existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { createRouter } from '../../create-router';
import { ITEM_TYPES, itemsDir, ensureItemDirs, type ItemType } from '../../data-path';
import { ensureToolLoader } from '../../ensure-tool-loader';
import { AFFORDANCES_DIR, loadAffordances } from '../../affordances/load';
// Re-applies the boot-time item setup so changes under OFFICER_ITEMS_DIR take effect without a
// server restart. Item lists are already read fresh from disk on every request; this ensures the
// type dirs exist and the tool-loader extension is current, and reports the live item counts so the
// UI can refetch.
//
// ── Affordances are counted, NOT applied ──
//
// They are not an ITEM_TYPE — no directory per item, no canonical file — so they were invisible here
// and a newly written one looked like nothing had happened.
//
// Counting them is as far as this can honestly go. `affordanceBlock()` is read when a chat session is
// CREATED (claude-manager.ts), so a new affordance is already live without a server restart — for the
// next session. Nothing can retrofit one into a conversation that is already running, because its
// system prompt was assembled when it started. Reporting the count says "the file is on disk and the
// platform can see it", which is the part that was genuinely unknowable from the UI.
const CANONICAL: Record<ItemType, string> = {
skills: 'SKILL.md',
@@ -32,9 +44,16 @@ export const rescanRouter = createRouter();
rescanRouter.post('/', async (ctx) => {
ensureItemDirs();
// So the directory is discoverable on a machine that has never had one, the same way the item dirs are.
mkdirSync(AFFORDANCES_DIR, { recursive: true });
ensureToolLoader();
const counts = Object.fromEntries(ITEM_TYPES.map((type) => [type, countItems(type)])) as Record<ItemType, number>;
return ctx.json({ ok: true, counts });
// Loaded rather than file-counted, so the number means "affordances the agent will actually receive"
// rather than "markdown files present" — one with no body is skipped by the loader and should not be
// reported as though it were working.
const affordances = loadAffordances().length;
return ctx.json({ ok: true, counts: { ...counts, affordances } });
});