load affordances and append them to every claude session
The platform hosts the agent and never introduces itself. An agent gets a shell, a filesystem and a prompt; everything about the CLIENT on the other end has to be guessed. It writes , nothing renders, and nothing says why — because an <img> carries no bearer token and it is the user's DEVICE that fetches the URL. servers/affordances/load.ts reads capabilities/affordances/ and compiles one block; claude-manager passes it as systemPrompt.append with the claude_code preset, so the harness keeps its own prompt. Passed there rather than written to a file because it is the one place every session goes through, owner and member alike. A generated CLAUDE.md cannot do that — user-instance.ts deliberately refuses to write one for the owner, since it would pollute the personal ~/.claude/CLAUDE.md they maintain themselves. Read per session, not cached: capabilities/ is already read fresh each session, so a plugin installed an hour ago is live on the next message. Synchronous because createSession is not async, and making it so to read a few kilobytes would ripple through every caller. No filtering. Everything is loaded into every conversation, because the axis to filter on is not one dimension — for image rendering it is what the CLIENT renders, for gitea what TOOLING exists on the machine. Fine at two files, not at twenty; TODO.md carries the argument. Opencode gets nothing, and that is recorded as a gap rather than hidden: an affordance that holds on one harness is the drift this removes. Deliberate — opencode is paused pending v2 and the work would be thrown away. Nine tests, aimed at the failure that matters: a file that silently does not reach the agent looks exactly like one nobody wrote. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test, afterAll } from 'bun:test';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { loadAffordances, compileAffordances } from './load';
|
||||
|
||||
// The failure worth guarding is a file that silently does not reach the agent. An affordance that fails
|
||||
// to load looks exactly like one that was never written — and the whole point of these is that the agent
|
||||
// cannot otherwise know what it does not know.
|
||||
|
||||
const root = join(tmpdir(), `aff-test-${process.pid}`);
|
||||
const write = (rel: string, body: string) => {
|
||||
const path = join(root, rel);
|
||||
mkdirSync(join(path, '..'), { recursive: true });
|
||||
writeFileSync(path, body);
|
||||
};
|
||||
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('loading', () => {
|
||||
test('reads the nested <publisher>/<plugin>/<file> tree', () => {
|
||||
write('officer/core/image.md', '---\nname: Image rendering\nversion: 2\n---\n\nUse a reachable URL.\n');
|
||||
write('officer/gitea/gitea.md', '---\nname: Gitea\n---\n\nUse `tea` for pull requests.\n');
|
||||
const found = loadAffordances(root);
|
||||
expect(found.map((a) => a.id).sort()).toEqual(['officer/core/image', 'officer/gitea/gitea']);
|
||||
});
|
||||
|
||||
test('version is an integer and defaults to 1 — matching capabilities/agents/', () => {
|
||||
const byId = Object.fromEntries(loadAffordances(root).map((a) => [a.id, a]));
|
||||
expect(byId['officer/core/image']!.version).toBe(2);
|
||||
expect(byId['officer/gitea/gitea']!.version).toBe(1);
|
||||
});
|
||||
|
||||
test('a missing directory is the ordinary state of a fresh install, not an error', () => {
|
||||
expect(loadAffordances(join(root, 'nope'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('a broken header keeps the body — the prose matters more than the name', () => {
|
||||
write('officer/core/broken.md', '---\nname: [unclosed\n---\n\nThis still has to reach the agent.\n');
|
||||
const broken = loadAffordances(root).find((a) => a.id === 'officer/core/broken');
|
||||
expect(broken?.body).toContain('still has to reach the agent');
|
||||
});
|
||||
|
||||
test('an empty body is dropped rather than compiled as a bare heading', () => {
|
||||
write('officer/core/empty.md', '---\nname: Nothing\n---\n\n');
|
||||
expect(loadAffordances(root).some((a) => a.id === 'officer/core/empty')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compiling', () => {
|
||||
test('nothing to say produces nothing, not an empty header', () => {
|
||||
expect(compileAffordances([])).toBeNull();
|
||||
});
|
||||
|
||||
test('each body appears under its name', () => {
|
||||
const block = compileAffordances(loadAffordances(root))!;
|
||||
expect(block).toContain('## Image rendering');
|
||||
expect(block).toContain('Use a reachable URL.');
|
||||
expect(block).toContain('## Gitea');
|
||||
});
|
||||
|
||||
test('description is NOT sent — it says when to reach for one, and everything is already loaded', () => {
|
||||
const block = compileAffordances([
|
||||
{ id: 'x', name: 'X', description: 'DO-NOT-SEND-THIS', version: 1, body: 'body' },
|
||||
])!;
|
||||
expect(block).not.toContain('DO-NOT-SEND-THIS');
|
||||
});
|
||||
|
||||
test('the order is stable, so an unchanged tree produces an unchanged prompt', () => {
|
||||
expect(compileAffordances(loadAffordances(root))).toBe(compileAffordances(loadAffordances(root)));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { OFFICER_ITEMS_DIR } from '../data-path';
|
||||
|
||||
// What the platform tells an agent about the surface it is speaking through.
|
||||
//
|
||||
// ── The problem ──
|
||||
//
|
||||
// The platform hosts the agent and never introduces itself. An agent gets a shell, a filesystem and a
|
||||
// prompt; everything about the CLIENT on the other end has to be guessed. It writes
|
||||
// ``, nothing renders, and nothing tells it why — because an `<img>` carries no
|
||||
// bearer token, and it is the USER'S DEVICE that fetches the URL.
|
||||
//
|
||||
// That knowledge used to live as prose in a hand-written CLAUDE.md on one machine. This reads it from
|
||||
// `capabilities/affordances/` instead, where it is versioned, inspectable, and contributable by a plugin.
|
||||
//
|
||||
// See `plugins/CLAUDE.md` for what an affordance is and what belongs in one.
|
||||
//
|
||||
// ── Deliberately dumb ──
|
||||
//
|
||||
// No filtering. Every affordance is compiled into every conversation, because the axis to filter on is
|
||||
// not one dimension — for image rendering it is what the CLIENT renders, for gitea what TOOLING exists on
|
||||
// the machine. Picking the wrong axis now and writing files against it costs more than loading two extra
|
||||
// paragraphs. `TODO.md` carries the argument.
|
||||
|
||||
const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML;
|
||||
|
||||
export const AFFORDANCES_DIR = join(OFFICER_ITEMS_DIR, 'affordances');
|
||||
|
||||
export type Affordance = {
|
||||
/** `<publisher>/<plugin>/<file>` — provenance, and unique. */
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
/** An INTEGER, matching `capabilities/agents/`. Not the semver a plugin carries. */
|
||||
version: number;
|
||||
/** What is actually sent. */
|
||||
body: string;
|
||||
};
|
||||
|
||||
function parse(raw: string): { fm: Record<string, unknown>; body: string } {
|
||||
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) return { fm: {}, body: raw.trim() };
|
||||
try {
|
||||
return { fm: (YAML.parse(match[1]!) as Record<string, unknown>) ?? {}, body: match[2]!.trim() };
|
||||
} catch {
|
||||
// A broken header is not a reason to lose the body. The agent needs the prose far more than the name,
|
||||
// and a file that vanished over a stray colon would be very hard to notice.
|
||||
return { fm: {}, body: match[2]!.trim() };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `.md` under `affordances/`, at any depth, sorted so the compiled block is stable.
|
||||
*
|
||||
* SYNCHRONOUS on purpose. `createSession` is not async, and making it so to read a few kilobytes would
|
||||
* ripple through every caller for no benefit — this runs once per session, not per request.
|
||||
*/
|
||||
export function loadAffordances(root: string = AFFORDANCES_DIR): Affordance[] {
|
||||
let entries: string[];
|
||||
try {
|
||||
// `recursive` because the tree is `<publisher>/<plugin>/<file>.md` and neither level is known here.
|
||||
entries = (readdirSync(root, { recursive: true }) as string[]).filter((f) => f.endsWith('.md'));
|
||||
} catch {
|
||||
// No directory yet is the ordinary state of a fresh install, not an error.
|
||||
return [];
|
||||
}
|
||||
|
||||
const found = entries.sort().map((rel): Affordance | null => {
|
||||
try {
|
||||
const { fm, body } = parse(readFileSync(join(root, rel), 'utf8'));
|
||||
if (!body) return null;
|
||||
const versionRaw = fm.version;
|
||||
return {
|
||||
id: rel.replace(/\.md$/, ''),
|
||||
// Falls back to the path rather than to "Untitled": a heading naming the file beats one that
|
||||
// says nothing, and it makes a missing `name` visible without failing the load.
|
||||
name: fm.name == null ? rel.replace(/\.md$/, '') : String(fm.name),
|
||||
description: fm.description == null ? null : String(fm.description),
|
||||
version: typeof versionRaw === 'number' ? versionRaw : Number(versionRaw) || 1,
|
||||
body,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return found.filter((a): a is Affordance => a !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* One block for the system prompt, or null when there is nothing to say.
|
||||
*
|
||||
* `description` is NOT included — it answers "when should I reach for this", which only matters when an
|
||||
* agent chooses what to load. Everything here is already loaded, so it would be paid for and unread.
|
||||
*/
|
||||
export function compileAffordances(affordances: Affordance[]): string | null {
|
||||
if (!affordances.length) return null;
|
||||
return [
|
||||
'# This environment',
|
||||
'',
|
||||
'What the surface you are talking through will and will not do. Not instructions — facts about the',
|
||||
'client on the other end, which you cannot discover from the filesystem.',
|
||||
'',
|
||||
...affordances.map((a) => `## ${a.name}\n\n${a.body}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Read and compile in one step, for callers that only want the string. */
|
||||
export function affordanceBlock(root: string = AFFORDANCES_DIR): string | null {
|
||||
return compileAffordances(loadAffordances(root));
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { query, type Query, type SpawnOptions, type SpawnedProcess } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { ChatEvent, PromptImage } from '../../api/chat/types';
|
||||
import { affordanceBlock } from '../../affordances/load';
|
||||
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol';
|
||||
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
||||
import { countsAsOutput, diagnoseExit, stderrReason } from './exit-diagnosis';
|
||||
@@ -520,6 +521,10 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
`as=${params.member?.osUser ?? 'owner'} model=${params.model ?? 'default'}`,
|
||||
);
|
||||
|
||||
// Read here rather than at module load: a plugin installed an hour ago should be live on the next
|
||||
// message, and this costs a few kilobytes once per session.
|
||||
const affordances = affordanceBlock();
|
||||
|
||||
const q = query({
|
||||
prompt: input.gen as AsyncIterable<SdkUserMessage>,
|
||||
options: {
|
||||
@@ -593,6 +598,27 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
spawnClaudeCodeProcess: watchChild(spawnClaudeAsOwner, session),
|
||||
}),
|
||||
settingSources: ['user', 'project', 'local'],
|
||||
/**
|
||||
* What the platform tells the agent about the surface it is speaking through.
|
||||
*
|
||||
* `preset` keeps Claude Code's own system prompt and APPENDS to it — replacing it would throw away
|
||||
* everything the harness knows about its own tools.
|
||||
*
|
||||
* Passed here rather than written to a file because this is the one place every session goes
|
||||
* through, owner and member alike. A generated CLAUDE.md cannot do that: `user-instance.ts`
|
||||
* deliberately refuses to write one for the owner, since it would pollute the personal
|
||||
* `~/.claude/CLAUDE.md` they maintain themselves.
|
||||
*
|
||||
* Read per session, not cached: `capabilities/` is already read fresh each agent session, so a
|
||||
* plugin installed an hour ago is live on the next message without a restart.
|
||||
*
|
||||
* OPENCODE DOES NOT GET THIS, and that is a real gap rather than an oversight — an affordance that
|
||||
* holds on one harness is the drift this exists to remove. Deliberate as of 2026-08-19: opencode
|
||||
* development is paused pending its v2 migration, so the work would be thrown away. See TODO.md.
|
||||
*/
|
||||
...(affordances
|
||||
? { systemPrompt: { type: 'preset' as const, preset: 'claude_code' as const, append: affordances } }
|
||||
: {}),
|
||||
env: cleanEnv as Record<string, string>,
|
||||
stderr: (d: string) => {
|
||||
if (!d.trim()) return;
|
||||
|
||||
Reference in New Issue
Block a user