@davvidbaker/flambe-cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # @davvidbaker/flambe-cli
2
+
3
+ Zero-dependency Node 22 CLI that streams coding-agent work into a Flambe trace.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install -g @davvidbaker/flambe-cli
9
+ ```
10
+
11
+ That puts `flambe` on `PATH`. One-off:
12
+
13
+ ```sh
14
+ npx @davvidbaker/flambe-cli --help
15
+ ```
16
+
17
+ From this checkout (no npm):
18
+
19
+ ```sh
20
+ node cli/bin/flambe.mjs --help
21
+ # or
22
+ cd cli && npm link
23
+ ```
24
+
25
+ ## Cursor Cloud and other repos
26
+
27
+ 1. Install the CLI in the environment (or at the start of an agent session):
28
+
29
+ ```sh
30
+ npm install -g @davvidbaker/flambe-cli
31
+ ```
32
+
33
+ 2. Set Cloud secrets / env: `FLAMBE_URL`, `FLAMBE_API_TOKEN`, `FLAMBE_TRACE_ID`.
34
+ Optional: `FLAMBE_THREAD` (name, unique slug, or id) is sent on `start` when
35
+ `--thread` is omitted. Do not list threads and pick one by project name.
36
+ Copy the `flambe-cli` skill from this repo (`.codex/skills/flambe-cli`) so
37
+ agents know when to `start` / `end`.
38
+
39
+ 3. Set `FLAMBE_AGENT_PLATFORM` to the product (`Cursor Cloud`) as a Cloud
40
+ secret; it is shared by every agent there. Agents do not need a name: the
41
+ reducer assigns one on first contact and the CLI remembers it
42
+ (`flambe whoami`). Set `FLAMBE_AGENT_NAME` only to override. Do not put
43
+ `FLAMBE_AGENT_ID` or `FLAMBE_AGENT_NAME` in `.env`.
44
+
45
+ CI publishes this package from `cli/` via npm trusted publishing when
46
+ `cli/package.json` on `main` changes. Local `npm publish` needs access to
47
+ `@davvidbaker`.
48
+
49
+ ## Configure
50
+
51
+ Create a `.env` in the project working directory (or set the same names in the
52
+ shell; shell wins):
53
+
54
+ ```dotenv
55
+ FLAMBE_URL=https://flambe.fly.dev
56
+ FLAMBE_API_TOKEN=flb_...
57
+ FLAMBE_TRACE_ID=1
58
+ # FLAMBE_THREAD=flambe
59
+ ```
60
+
61
+ Mint a token in Flambe Settings → API tokens. See the [repository README](https://github.com/davvidbaker/flambe#coding-agent-cli) for commands (`start`, `end`, `message`, `observe`, local `serve`).
package/package.json CHANGED
@@ -1,13 +1,19 @@
1
1
  {
2
2
  "name": "@davvidbaker/flambe-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "CLI for streaming coding-agent activity into Flambe",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "flambe": "./bin/flambe.mjs"
8
8
  },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md"
13
+ ],
9
14
  "scripts": {
10
- "test": "node --test"
15
+ "test": "node --test",
16
+ "prepublishOnly": "npm test"
11
17
  },
12
18
  "engines": {
13
19
  "node": ">=22"
@@ -15,6 +21,14 @@
15
21
  "license": "MIT",
16
22
  "repository": {
17
23
  "type": "git",
18
- "url": "git+https://github.com/davvidbaker/flambe.git"
24
+ "url": "git+https://github.com/davvidbaker/flambe.git",
25
+ "directory": "cli"
26
+ },
27
+ "homepage": "https://github.com/davvidbaker/flambe/tree/main/cli",
28
+ "bugs": {
29
+ "url": "https://github.com/davvidbaker/flambe/issues"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
19
33
  }
20
34
  }
@@ -0,0 +1,35 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ // Names the reducer assigned to agents on this machine, keyed by agent id (ADR-012).
7
+ // Read synchronously because identity is resolved while the client is constructed.
8
+
9
+ export function defaultAgentNamesPath() {
10
+ return join(homedir(), '.flambe', 'agent-names.json');
11
+ }
12
+
13
+ export function readAgentNames(path = defaultAgentNamesPath()) {
14
+ try {
15
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
16
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ export function rememberedAgentName(agentId, path = defaultAgentNamesPath()) {
23
+ if (!agentId) return undefined;
24
+ const name = readAgentNames(path)[agentId];
25
+ return typeof name === 'string' && name.trim() ? name : undefined;
26
+ }
27
+
28
+ export function rememberAgentName(agentId, name, path = defaultAgentNamesPath()) {
29
+ if (!agentId || !name) return;
30
+ const names = { ...readAgentNames(path), [agentId]: name };
31
+ mkdirSync(dirname(path), { recursive: true });
32
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
33
+ writeFileSync(tmp, `${JSON.stringify(names, null, 2)}\n`);
34
+ renameSync(tmp, path);
35
+ }
package/src/cli.mjs CHANGED
@@ -15,7 +15,7 @@ function defaultDbPath() {
15
15
  }
16
16
 
17
17
  const usage = `Usage:
18
- flambe start <activity name> [--description <text>] [--thread <id>] [--parent <id> | --root] [--category <id>...] [--started-at <ISO-8601>]
18
+ flambe start <activity name> [--description <text>] [--thread <id|name>] [--parent <id> | --root] [--category <id>...] [--started-at <ISO-8601>]
19
19
  flambe end <activity-id> [message] [--force]
20
20
  flambe suspend <activity-id> [message]
21
21
  flambe resume <activity-id> [message]
@@ -23,6 +23,7 @@ const usage = `Usage:
23
23
  flambe threads [--json]
24
24
  flambe categories [--json]
25
25
  flambe ping
26
+ flambe whoami [--json]
26
27
  flambe message <text> [--activity <id>] [--json]
27
28
  flambe observe <kind> <value> [--unit <unit>] [--on <YYYY-MM-DD>] [--payload <json>] [--at <ISO-8601>]
28
29
  flambe serve [--port <n>] [--db <path>] [--static <dir>]
@@ -41,7 +42,14 @@ Required values:
41
42
  Optional:
42
43
  FLAMBE_AGENT_ID Stable ID for this running agent; enables named flame lanes.
43
44
  Derived from Codex, Cursor, or Claude Code session env when unset.
44
- FLAMBE_AGENT_NAME Display name for this agent's lane. Do not put this in .env.
45
+ FLAMBE_AGENT_NAME Override the lane name. Unset, the reducer names the agent and the
46
+ CLI remembers it in ~/.flambe/agent-names.json. Do not put this in .env.
47
+ FLAMBE_AGENT_PLATFORM Product the agent runs on, shared by many agents ("Cursor Cloud",
48
+ "Codex"). Derived from the session type when unset.
49
+ FLAMBE_AGENT_NAMES_PATH Where remembered agent names live.
50
+ FLAMBE_THREAD Thread name, unique slug, or id for start when --thread is omitted
51
+ (overridden by --thread). 'flambe' matches 'flambé🔥'. The reducer
52
+ still keeps a child on its parent's thread.
45
53
  FLAMBE_QUEUE_PATH Local offline queue path (default: ~/.flambe/event-queue.json)
46
54
  FLAMBE_LOCAL_DB SQLite path for flambe serve / export (default: ~/.flambe/local.sqlite)
47
55
 
@@ -275,6 +283,18 @@ function formatReducerNote(note) {
275
283
  const list = note.closedDescendants.map(item => `${item.activityId} (${item.activityName})`).join(', ');
276
284
  return `reducer closed open descendants of ${note.activityId}: ${list}\n`;
277
285
  }
286
+ if (note.type === 'agent_named') {
287
+ return `reducer named this agent "${note.name}"; the CLI will use it from now on\n`;
288
+ }
289
+ if (note.type === 'start_reduced') {
290
+ const parts = [];
291
+ if (note.parentSource === 'inferred') parts.push(`nested ${note.activityId} under ${note.parentId}`);
292
+ if (note.threadSource === 'parent' && note.requestedThreadId !== undefined && note.requestedThreadId !== note.threadId) {
293
+ parts.push(`kept it on the parent's thread ${note.threadId}, not ${note.requestedThreadId}`);
294
+ }
295
+ if (note.categoriesSource === 'parent') parts.push('inherited the parent\'s categories');
296
+ return parts.length > 0 ? `reducer ${parts.join('; ')}\n` : '';
297
+ }
278
298
  return `reducer: ${JSON.stringify(note)}\n`;
279
299
  }
280
300
 
@@ -327,7 +347,10 @@ export async function run(argv, { env = process.env, stdout = process.stdout, st
327
347
  }
328
348
 
329
349
  // Reducer notes go to stderr so stdout stays a machine-friendly id.
330
- const onReducerNote = note => stderr.write(formatReducerNote(note));
350
+ const onReducerNote = note => {
351
+ const text = formatReducerNote(note);
352
+ if (text) stderr.write(text);
353
+ };
331
354
  const flambe = client ?? clientFromEnv(env, { onReducerNote });
332
355
  flambe.onReducerNote ??= onReducerNote;
333
356
  await flambe.flushQueue?.();
@@ -399,6 +422,14 @@ export async function run(argv, { env = process.env, stdout = process.stdout, st
399
422
  return;
400
423
  }
401
424
 
425
+ if (command === 'whoami') {
426
+ const json = parseJson(args);
427
+ const me = await flambe.whoami();
428
+ if (json) stdout.write(`${JSON.stringify(me)}\n`);
429
+ else stdout.write(`${me.agentId}\t${me.name}\t${me.platform ?? ''}\n`);
430
+ return;
431
+ }
432
+
402
433
  if (command === 'observe') {
403
434
  const observationId = await flambe.observe(parseObserve(args));
404
435
  stdout.write(`${observationId}\n`);
package/src/client.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { FlambeQueue } from './queue.mjs';
2
+ import { defaultAgentNamesPath, rememberAgentName, rememberedAgentName } from './agentNames.mjs';
2
3
 
3
4
  export function hostConfigFromEnv(env = process.env) {
4
5
  const required = ['FLAMBE_URL', 'FLAMBE_API_TOKEN'];
@@ -27,11 +28,14 @@ export function configFromEnv(env = process.env) {
27
28
  throw new Error('FLAMBE_TRACE_ID must be a positive integer');
28
29
  }
29
30
 
31
+ const defaultThread = env.FLAMBE_THREAD?.trim() || undefined;
32
+
30
33
  return {
31
34
  baseUrl: env.FLAMBE_URL.replace(/\/+$/, ''),
32
35
  ...agentIdentityFromEnv(env),
33
36
  token: env.FLAMBE_API_TOKEN,
34
37
  traceId,
38
+ ...(defaultThread ? { defaultThread } : {}),
35
39
  };
36
40
  }
37
41
 
@@ -44,14 +48,27 @@ export function agentIdentityFromEnv(env = process.env) {
44
48
  ? `cursor:${env.CURSOR_CONVERSATION_ID.trim()}`
45
49
  : undefined)
46
50
  || claudeAgentId(env);
47
- const agentName = env.FLAMBE_AGENT_NAME?.trim() || undefined;
51
+ const agentNamesPath = env.FLAMBE_AGENT_NAMES_PATH?.trim() || defaultAgentNamesPath();
52
+ // An explicit name is an override; otherwise reuse whatever the reducer named this agent.
53
+ const agentName = env.FLAMBE_AGENT_NAME?.trim() || rememberedAgentName(agentId, agentNamesPath);
54
+ // The product the agent runs on ("Cursor Cloud", "Codex"); many agents share one.
55
+ const agentPlatform = env.FLAMBE_AGENT_PLATFORM?.trim() || platformFromAgentId(agentId);
48
56
 
49
57
  return {
50
- ...(agentId ? { agentId } : {}),
58
+ ...(agentId ? { agentId, agentNamesPath } : {}),
51
59
  ...(agentName ? { agentName } : {}),
60
+ ...(agentPlatform ? { agentPlatform } : {}),
52
61
  };
53
62
  }
54
63
 
64
+ function platformFromAgentId(agentId) {
65
+ if (!agentId) return undefined;
66
+ if (agentId.startsWith('codex:')) return 'Codex';
67
+ if (agentId.startsWith('cursor:')) return 'Cursor';
68
+ if (agentId.startsWith('claude:')) return 'Claude Code';
69
+ return undefined;
70
+ }
71
+
55
72
  function firstPresent(...values) {
56
73
  for (const value of values) {
57
74
  const trimmed = value?.trim();
@@ -72,6 +89,8 @@ function claudeAgentId(env) {
72
89
  return sessionId ? `claude:${sessionId}` : undefined;
73
90
  }
74
91
 
92
+ const LIFECYCLE_PHASES = new Set(['B', 'E', 'S', 'R']);
93
+
75
94
  function errorDetail(payload) {
76
95
  if (!payload) return null;
77
96
  if (typeof payload.error === 'string') return payload.error;
@@ -80,12 +99,15 @@ function errorDetail(payload) {
80
99
  }
81
100
 
82
101
  export class FlambeClient {
83
- constructor({ baseUrl, token, traceId, agentId, agentName, fetchImpl = globalThis.fetch, now = Date.now, queuePath, queue, onReducerNote }) {
102
+ constructor({ baseUrl, token, traceId, threadId, defaultThread, agentId, agentName, agentPlatform, agentNamesPath, fetchImpl = globalThis.fetch, now = Date.now, queuePath, queue, onReducerNote }) {
84
103
  this.baseUrl = baseUrl.replace(/\/+$/, '');
85
104
  this.token = token;
86
105
  this.agentId = agentId;
87
106
  this.agentName = agentName;
107
+ this.agentPlatform = agentPlatform;
108
+ this.agentNamesPath = agentNamesPath;
88
109
  this.traceId = Number(traceId);
110
+ this.defaultThread = firstPresent(defaultThread, threadId == null ? undefined : String(threadId));
89
111
  this.fetch = fetchImpl;
90
112
  this.now = now;
91
113
  this.onReducerNote = onReducerNote;
@@ -101,6 +123,7 @@ export class FlambeClient {
101
123
  authorization: `Bearer ${this.token}`,
102
124
  ...(this.agentId ? { 'x-flambe-agent-id': this.agentId } : {}),
103
125
  ...(this.agentId && this.agentName ? { 'x-flambe-agent-name': this.agentName } : {}),
126
+ ...(this.agentId && this.agentPlatform ? { 'x-flambe-agent-platform': this.agentPlatform } : {}),
104
127
  ...(body === undefined ? {} : { 'content-type': 'application/json' }),
105
128
  },
106
129
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
@@ -129,9 +152,41 @@ export class FlambeClient {
129
152
  throw error;
130
153
  }
131
154
 
155
+ this.adoptAgentName(response.headers);
132
156
  return payload;
133
157
  }
134
158
 
159
+ /**
160
+ * The reducer names agents that arrive without a name and echoes the name on every
161
+ * response (ADR-012). Keep it for the rest of this process, remember it for future
162
+ * ones, and tell the worker the first time.
163
+ */
164
+ adoptAgentName(headers) {
165
+ if (!this.agentId || !headers?.get) return;
166
+ const name = headers.get('x-flambe-agent-name')?.trim();
167
+ if (!name) return;
168
+
169
+ const assigned = headers.get('x-flambe-agent-name-assigned') === 'true';
170
+ if (name !== this.agentName) {
171
+ this.agentName = name;
172
+ if (this.agentNamesPath) rememberAgentName(this.agentId, name, this.agentNamesPath);
173
+ }
174
+ if (assigned) this.onReducerNote?.({ type: 'agent_named', agentId: this.agentId, name });
175
+ }
176
+
177
+ async whoami() {
178
+ if (!this.agentId) {
179
+ throw new Error('No agent id for this process; set FLAMBE_AGENT_ID (Cursor, Codex, and Claude Code sessions derive one automatically)');
180
+ }
181
+ const payload = await this.request('/api/agents/me');
182
+ return {
183
+ agentId: payload.data.agent_id,
184
+ name: payload.data.name,
185
+ platform: payload.data.platform ?? null,
186
+ nameAssigned: payload.data.name_assigned === true,
187
+ };
188
+ }
189
+
135
190
  async getTrace() {
136
191
  const payload = await this.request(`/api/traces/${this.traceId}`);
137
192
  return payload.data;
@@ -139,14 +194,16 @@ export class FlambeClient {
139
194
 
140
195
  async threads() {
141
196
  const trace = await this.getTrace();
142
- const threads = [...(trace.threads ?? [])]
143
- .sort((a, b) => (a.rank - b.rank) || (a.id - b.id));
197
+ const threads = sortedThreads(trace);
198
+ const defaultId = this.defaultThread
199
+ ? matchThread(threads, this.defaultThread, this.traceId).id
200
+ : threads[0]?.id;
144
201
 
145
- return threads.map((thread, index) => ({
202
+ return threads.map(thread => ({
146
203
  id: thread.id,
147
204
  name: thread.name,
148
205
  rank: thread.rank,
149
- default: index === 0,
206
+ default: thread.id === defaultId,
150
207
  }));
151
208
  }
152
209
 
@@ -168,6 +225,8 @@ export class FlambeClient {
168
225
 
169
226
  for (const event of trace.events ?? []) {
170
227
  if (!event.activity) continue;
228
+ // Reducer annotations (reducer_incoming / reducer_decision) do not change state.
229
+ if (!LIFECYCLE_PHASES.has(event.phase)) continue;
171
230
 
172
231
  if (event.phase === 'B') {
173
232
  const startedAt = startedAtByActivity.get(event.activity.id);
@@ -218,16 +277,20 @@ export class FlambeClient {
218
277
  };
219
278
  }
220
279
 
221
- resolveThreadId(explicitThreadId, trace) {
222
- if (explicitThreadId !== undefined && explicitThreadId !== null) {
223
- const id = Number(explicitThreadId);
224
- if (!Number.isInteger(id) || id <= 0) throw new Error('--thread must be a positive integer');
280
+ async resolveThreadId(explicitThreadId) {
281
+ const hasExplicit = explicitThreadId != null && String(explicitThreadId).trim() !== '';
282
+ const selector = hasExplicit ? explicitThreadId : this.defaultThread;
283
+ if (selector == null || String(selector).trim() === '') return undefined;
284
+
285
+ const raw = String(selector).trim();
286
+ if (/^\d+$/.test(raw)) {
287
+ const id = Number(raw);
288
+ if (id <= 0) throw new Error('--thread must be a positive integer');
225
289
  return id;
226
290
  }
227
291
 
228
- const threads = [...(trace.threads ?? [])].sort((a, b) => (a.rank - b.rank) || (a.id - b.id));
229
- if (threads.length === 0) throw new Error(`Trace ${this.traceId} has no threads`);
230
- return threads[0].id;
292
+ const trace = await this.getTrace();
293
+ return matchThread(sortedThreads(trace), raw, this.traceId).id;
231
294
  }
232
295
 
233
296
  resolveCategoryIds(categoryIds = []) {
@@ -268,7 +331,7 @@ export class FlambeClient {
268
331
  const input = {
269
332
  name: name.trim(),
270
333
  description,
271
- threadId,
334
+ threadId: await this.resolveThreadId(threadId),
272
335
  parentId: this.resolveParentId(parentId),
273
336
  categoryIds: resolvedCategoryIds,
274
337
  timestamp,
@@ -282,15 +345,15 @@ export class FlambeClient {
282
345
  }
283
346
  }
284
347
 
348
+ /**
349
+ * `start` is a proposal (ADR-012): the reducer picks the parent (this agent's newest
350
+ * active activity unless `--parent`/`--root` said otherwise), puts a child on its
351
+ * parent's thread, inherits categories, and places roots asynchronously. So the CLI
352
+ * sends only what the worker actually said: `parent_id` absent = infer, `null` = root.
353
+ */
285
354
  async postStart({ name, description, threadId, parentId, categoryIds, timestamp }) {
286
- const trace = await this.getTrace();
287
- const resolvedThreadId = this.resolveThreadId(threadId, trace);
288
- const activeActivities = this.statusFromTrace(trace, { activeOnly: true }).activities;
289
- const inferredParentId = activeActivities
290
- .filter(activity => activity.threadId === resolvedThreadId)
291
- .at(-1)?.id;
292
- const resolvedParentId = parentId === undefined
293
- ? inferredParentId
355
+ const resolvedParentId = parentId === undefined || parentId === null
356
+ ? parentId
294
357
  : await this.queue.resolveActivityId(parentId);
295
358
 
296
359
  if (String(resolvedParentId).startsWith('offline-')) {
@@ -303,11 +366,11 @@ export class FlambeClient {
303
366
  method: 'POST',
304
367
  body: {
305
368
  trace_id: this.traceId,
306
- thread_id: resolvedThreadId,
369
+ ...(threadId === undefined ? {} : { thread_id: threadId }),
307
370
  activity: {
308
371
  name,
309
372
  ...(description ? { description } : {}),
310
- ...(resolvedParentId ? { parent_id: resolvedParentId } : {}),
373
+ ...(resolvedParentId === undefined ? {} : { parent_id: resolvedParentId }),
311
374
  categories: categoryIds,
312
375
  },
313
376
  event: {
@@ -317,7 +380,21 @@ export class FlambeClient {
317
380
  },
318
381
  });
319
382
 
320
- return payload.data.activity.id;
383
+ const { activity, reducer } = payload.data;
384
+ if (reducer) {
385
+ this.onReducerNote?.({
386
+ type: 'start_reduced',
387
+ activityId: activity.id,
388
+ parentId: activity.parent_id ?? null,
389
+ threadId: activity.thread_id,
390
+ requestedThreadId: threadId,
391
+ parentSource: reducer.parent_source,
392
+ threadSource: reducer.thread_source,
393
+ categoriesSource: reducer.categories_source,
394
+ });
395
+ }
396
+
397
+ return activity.id;
321
398
  }
322
399
 
323
400
  async end({ activityId, message, force = false }) {
@@ -547,3 +624,52 @@ function compareEvents(a, b) {
547
624
  const timestampDifference = Date.parse(a.timestamp) - Date.parse(b.timestamp);
548
625
  return timestampDifference || (a.id - b.id);
549
626
  }
627
+
628
+ function sortedThreads(trace) {
629
+ return [...(trace.threads ?? [])].sort((a, b) => (a.rank - b.rank) || (a.id - b.id));
630
+ }
631
+
632
+ export function threadSlug(name) {
633
+ return String(name)
634
+ .normalize('NFD')
635
+ .replace(/\p{M}/gu, '')
636
+ .toLowerCase()
637
+ .replace(/[^\p{L}\p{N}]+/gu, '');
638
+ }
639
+
640
+ function matchThread(threads, selector, traceId) {
641
+ const raw = String(selector).trim();
642
+ const available = threads.map(thread => thread.name).join(', ');
643
+ const notFound = label => new Error(
644
+ `Thread ${label} not found in trace ${traceId}${available ? ` (threads: ${available})` : ''}`,
645
+ );
646
+
647
+ if (/^\d+$/.test(raw)) {
648
+ const id = Number(raw);
649
+ const byId = threads.find(thread => thread.id === id);
650
+ if (byId) return byId;
651
+ throw notFound(raw);
652
+ }
653
+
654
+ const exact = threads.filter(thread => thread.name === raw);
655
+ if (exact.length === 1) return exact[0];
656
+ if (exact.length > 1) {
657
+ throw new Error(`Thread name "${raw}" is ambiguous in trace ${traceId}`);
658
+ }
659
+
660
+ const caseInsensitive = threads.filter(thread => thread.name.toLowerCase() === raw.toLowerCase());
661
+ if (caseInsensitive.length === 1) return caseInsensitive[0];
662
+ if (caseInsensitive.length > 1) {
663
+ throw new Error(`Thread name "${raw}" is ambiguous in trace ${traceId}`);
664
+ }
665
+
666
+ const slug = threadSlug(raw);
667
+ if (!slug) throw notFound(`"${raw}"`);
668
+ const bySlug = threads.filter(thread => threadSlug(thread.name) === slug);
669
+ if (bySlug.length === 1) return bySlug[0];
670
+ if (bySlug.length > 1) {
671
+ throw new Error(`Thread "${raw}" is ambiguous in trace ${traceId} (matches: ${bySlug.map(thread => thread.name).join(', ')})`);
672
+ }
673
+
674
+ throw notFound(`"${raw}"`);
675
+ }
@@ -1,24 +1,30 @@
1
1
  import { createHash } from 'node:crypto';
2
2
 
3
+ // Mirrors FlambeNext.Agents (ADR-012): keep the roster and picking rule in sync.
3
4
  const NAMES = [
4
- 'Steve', 'Belinda', 'Juniper', 'Marcel', 'Priya', 'Otis', 'Nia', 'Theo',
5
- 'Carmen', 'Felix', 'Imani', 'Rory', 'Greta', 'Miles', 'Suki',
5
+ 'Steve', 'Belinda', 'Juniper', 'Marcel', 'Priya', 'Otis', 'Nia', 'Theo', 'Carmen', 'Felix', 'Imani', 'Rory', 'Greta', 'Miles', 'Suki',
6
+ 'Anouk', 'Bastian', 'Cleo', 'Dashiell', 'Esme', 'Fitz', 'Hollis', 'Ines', 'Jasper', 'Kenji', 'Lucia', 'Mattias', 'Noor', 'Oona',
7
+ 'Pilar', 'Quill', 'Ravi', 'Sable', 'Tamsin', 'Ulla', 'Vera', 'Wren', 'Xiomara', 'Yusuf', 'Zadie',
6
8
  ];
7
9
 
8
- export function agentDisplayName(instanceId, providedName, tokenName) {
9
- if (usableName(providedName)) return providedName.trim();
10
- if (usableName(tokenName)) return tokenName.trim();
11
- return nameFor(instanceId);
12
- }
13
-
14
- function usableName(name) {
10
+ export function usableName(name) {
15
11
  if (typeof name !== 'string') return false;
16
12
  const trimmed = name.trim();
17
13
  return trimmed !== '' && Buffer.byteLength(trimmed) <= 100
18
14
  && !trimmed.includes('\n') && !trimmed.includes('\r');
19
15
  }
20
16
 
21
- function nameFor(instanceId) {
22
- const first = createHash('sha256').update(instanceId).digest()[0];
23
- return NAMES[first % NAMES.length];
17
+ /** A name for `agentId` that none of `taken` uses; deterministic for the id. */
18
+ export function pickName(agentId, taken = []) {
19
+ const lower = new Set(taken.map(name => name.toLowerCase()));
20
+ const offset = createHash('sha256').update(agentId).digest().readUInt16BE(0);
21
+ for (let index = 0; index < NAMES.length; index += 1) {
22
+ const candidate = NAMES[(offset + index) % NAMES.length];
23
+ if (!lower.has(candidate.toLowerCase())) return candidate;
24
+ }
25
+ const base = NAMES[offset % NAMES.length];
26
+ for (let n = 2; ; n += 1) {
27
+ const candidate = `${base} ${n}`;
28
+ if (!lower.has(candidate.toLowerCase())) return candidate;
29
+ }
24
30
  }
@@ -10,8 +10,8 @@ function sendJson(res, status, body) {
10
10
  res.end(payload);
11
11
  }
12
12
 
13
- function sendEmpty(res, status) {
14
- res.writeHead(status);
13
+ function sendEmpty(res, status, headers = {}) {
14
+ res.writeHead(status, headers);
15
15
  res.end();
16
16
  }
17
17
 
@@ -88,12 +88,32 @@ function headerBag() {
88
88
  };
89
89
  }
90
90
 
91
- async function dispatch(store, req, url, body) {
92
- const method = req.method ?? 'GET';
93
- const path = url.pathname;
94
- const auth = authenticate(store, req);
91
+ // Same contract as FlambeNextWeb.Plugs.AgentIdentity (ADR-012).
92
+ function identifyAgent(store, req, auth) {
93
+ if (!auth) return null;
95
94
  const agentId = req.headers['x-flambe-agent-id'];
96
95
  const agentName = req.headers['x-flambe-agent-name'];
96
+ const agentPlatform = req.headers['x-flambe-agent-platform'];
97
+ if (typeof agentId !== 'string') return null;
98
+ return store.identifyAgent(
99
+ auth.user.id,
100
+ agentId,
101
+ typeof agentName === 'string' ? agentName : undefined,
102
+ typeof agentPlatform === 'string' ? agentPlatform : undefined,
103
+ );
104
+ }
105
+
106
+ function agentHeaders(agent) {
107
+ if (!agent) return {};
108
+ return {
109
+ 'x-flambe-agent-name': agent.name,
110
+ ...(agent.assigned ? { 'x-flambe-agent-name-assigned': 'true' } : {}),
111
+ };
112
+ }
113
+
114
+ async function dispatch(store, req, url, body, { auth, agent }) {
115
+ const method = req.method ?? 'GET';
116
+ const path = url.pathname;
97
117
 
98
118
  if (method === 'GET' && path === '/api/health') {
99
119
  return { status: 200, json: { status: 'ok' } };
@@ -123,6 +143,13 @@ async function dispatch(store, req, url, body) {
123
143
 
124
144
  const userId = auth?.user?.id;
125
145
 
146
+ if (method === 'GET' && path === '/api/agents/me') {
147
+ if (!agent) {
148
+ return { status: 400, json: { error: 'AGENT_ID_REQUIRED', detail: 'Send x-flambe-agent-id with a bearer token' } };
149
+ }
150
+ return { status: 200, json: { data: { agent_id: agent.agent_id, name: agent.name, platform: agent.platform ?? null, name_assigned: agent.assigned } } };
151
+ }
152
+
126
153
  if (method === 'GET' && path === '/api/export') {
127
154
  return { status: 200, json: store.exportBundle() };
128
155
  }
@@ -225,9 +252,9 @@ async function dispatch(store, req, url, body) {
225
252
  threadId: body.thread_id,
226
253
  activity: body.activity ?? {},
227
254
  event: body.event ?? {},
228
- agentId: typeof agentId === 'string' ? agentId : undefined,
229
- agentName: typeof agentName === 'string' ? agentName : undefined,
230
- tokenName: auth.tokenName,
255
+ agent,
256
+ // Bearer callers are agents proposing a start; the SPA (cookie) writes what it sent.
257
+ reduce: auth.tokenName !== null,
231
258
  });
232
259
  if (result.error) return { status: 404, json: { error: 'NOT_FOUND' } };
233
260
  return { status: 201, json: { data: result } };
@@ -374,15 +401,18 @@ FLAMBE_TRACE_ID=${traceId}</pre>
374
401
  return;
375
402
  }
376
403
 
377
- const result = await dispatch(store, req, url, body);
404
+ const auth = authenticate(store, req);
405
+ const agent = identifyAgent(store, req, auth);
406
+ const result = await dispatch(store, req, url, body, { auth, agent });
407
+ const identity = agentHeaders(agent);
378
408
  if (result.status === 204) {
379
- if (result.headers) result.headers.apply(res, 204);
380
- else sendEmpty(res, 204);
409
+ if (result.headers) result.headers.apply(res, 204, identity);
410
+ else sendEmpty(res, 204, identity);
381
411
  return;
382
412
  }
383
413
 
384
414
  const payload = JSON.stringify(result.json);
385
- const extra = { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(payload) };
415
+ const extra = { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(payload), ...identity };
386
416
  if (result.headers) result.headers.apply(res, result.status, extra);
387
417
  else res.writeHead(result.status, extra);
388
418
  res.end(payload);