@davvidbaker/flambe-cli 0.1.0 → 0.1.2

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/src/client.mjs CHANGED
@@ -1,4 +1,13 @@
1
1
  import { FlambeQueue } from './queue.mjs';
2
+ import { defaultAgentNamesPath, rememberAgentName, rememberedAgentName } from './agentNames.mjs';
3
+ import { assertRuntimeUrl, runtimeConfig } from './runtime.mjs';
4
+
5
+ const REQUIRED_AGENT_COMMANDS = ['start', 'end', 'suspend', 'resume', 'status', 'message'];
6
+ const ENDED_PHASES = new Set(['E', 'J', 'V']);
7
+
8
+ function endedPhase(phase) {
9
+ return ENDED_PHASES.has(phase);
10
+ }
2
11
 
3
12
  export function hostConfigFromEnv(env = process.env) {
4
13
  const required = ['FLAMBE_URL', 'FLAMBE_API_TOKEN'];
@@ -10,6 +19,7 @@ export function hostConfigFromEnv(env = process.env) {
10
19
 
11
20
  return {
12
21
  baseUrl: env.FLAMBE_URL.replace(/\/+$/, ''),
22
+ ...runtimeConfig(env),
13
23
  token: env.FLAMBE_API_TOKEN,
14
24
  };
15
25
  }
@@ -27,11 +37,15 @@ export function configFromEnv(env = process.env) {
27
37
  throw new Error('FLAMBE_TRACE_ID must be a positive integer');
28
38
  }
29
39
 
40
+ const defaultThread = env.FLAMBE_THREAD?.trim() || undefined;
41
+
30
42
  return {
31
43
  baseUrl: env.FLAMBE_URL.replace(/\/+$/, ''),
44
+ ...runtimeConfig(env),
32
45
  ...agentIdentityFromEnv(env),
33
46
  token: env.FLAMBE_API_TOKEN,
34
47
  traceId,
48
+ ...(defaultThread ? { defaultThread } : {}),
35
49
  };
36
50
  }
37
51
 
@@ -44,14 +58,27 @@ export function agentIdentityFromEnv(env = process.env) {
44
58
  ? `cursor:${env.CURSOR_CONVERSATION_ID.trim()}`
45
59
  : undefined)
46
60
  || claudeAgentId(env);
47
- const agentName = env.FLAMBE_AGENT_NAME?.trim() || undefined;
61
+ const agentNamesPath = env.FLAMBE_AGENT_NAMES_PATH?.trim() || defaultAgentNamesPath();
62
+ // An explicit name is an override; otherwise reuse whatever the reducer named this agent.
63
+ const agentName = env.FLAMBE_AGENT_NAME?.trim() || rememberedAgentName(agentId, agentNamesPath);
64
+ // The product the agent runs on ("Cursor Cloud", "Codex"); many agents share one.
65
+ const agentPlatform = env.FLAMBE_AGENT_PLATFORM?.trim() || platformFromAgentId(agentId);
48
66
 
49
67
  return {
50
- ...(agentId ? { agentId } : {}),
68
+ ...(agentId ? { agentId, agentNamesPath } : {}),
51
69
  ...(agentName ? { agentName } : {}),
70
+ ...(agentPlatform ? { agentPlatform } : {}),
52
71
  };
53
72
  }
54
73
 
74
+ function platformFromAgentId(agentId) {
75
+ if (!agentId) return undefined;
76
+ if (agentId.startsWith('codex:')) return 'Codex';
77
+ if (agentId.startsWith('cursor:')) return 'Cursor';
78
+ if (agentId.startsWith('claude:')) return 'Claude Code';
79
+ return undefined;
80
+ }
81
+
55
82
  function firstPresent(...values) {
56
83
  for (const value of values) {
57
84
  const trimmed = value?.trim();
@@ -72,35 +99,103 @@ function claudeAgentId(env) {
72
99
  return sessionId ? `claude:${sessionId}` : undefined;
73
100
  }
74
101
 
102
+ const LIFECYCLE_PHASES = new Set(['B', 'E', 'S', 'R', 'X', 'J', 'V']);
103
+
75
104
  function errorDetail(payload) {
76
105
  if (!payload) return null;
77
106
  if (typeof payload.error === 'string') return payload.error;
107
+ if (payload.error && typeof payload.error === 'object') {
108
+ const message = payload.error.message ?? payload.error.code;
109
+ const openChildren = payload.error.open_children;
110
+ if (!Array.isArray(openChildren) || openChildren.length === 0) return message ?? JSON.stringify(payload.error);
111
+
112
+ const children = openChildren
113
+ .map(child => `${child.activity_id} (${child.activity_name ?? child.name ?? 'unnamed'})`)
114
+ .join(', ');
115
+ const ids = openChildren.map(child => child.activity_id).join(', ');
116
+ return `${message ?? 'Open children remain'}: ${children}. End ${ids} first (or pass --force).`;
117
+ }
78
118
  if (payload.errors) return JSON.stringify(payload.errors);
79
119
  return null;
80
120
  }
81
121
 
82
122
  export class FlambeClient {
83
- constructor({ baseUrl, token, traceId, agentId, agentName, fetchImpl = globalThis.fetch, now = Date.now, queuePath, queue, onReducerNote }) {
123
+ constructor({ baseUrl, token, traceId, threadId, defaultThread, agentId, agentName, agentPlatform, agentNamesPath, runtimeMode, fetchImpl = globalThis.fetch, now = Date.now, queuePath, queue, onReducerNote }) {
124
+ runtimeConfig({ FLAMBE_RUNTIME_MODE: runtimeMode });
125
+ assertRuntimeUrl(baseUrl, runtimeMode);
126
+ this.runtimeMode = runtimeMode;
84
127
  this.baseUrl = baseUrl.replace(/\/+$/, '');
85
128
  this.token = token;
86
129
  this.agentId = agentId;
87
130
  this.agentName = agentName;
131
+ this.agentPlatform = agentPlatform;
132
+ this.agentNamesPath = agentNamesPath;
88
133
  this.traceId = Number(traceId);
134
+ this.defaultThread = firstPresent(defaultThread, threadId == null ? undefined : String(threadId));
89
135
  this.fetch = fetchImpl;
90
136
  this.now = now;
91
137
  this.onReducerNote = onReducerNote;
92
- this.queue = queue ?? new FlambeQueue({ baseUrl: this.baseUrl, traceId: this.traceId, path: queuePath });
138
+ this.queue = queue ?? new FlambeQueue({
139
+ baseUrl: this.baseUrl,
140
+ traceId: this.traceId,
141
+ agentId: this.agentId,
142
+ path: queuePath,
143
+ });
144
+ this.agentCommandsCapability = null;
145
+ }
146
+
147
+ async supportsAgentCommands() {
148
+ if (!this.agentCommandsCapability) {
149
+ const probe = this.request('/api/agent-commands', { responseMetadata: true })
150
+ .then(({ payload, contentType }) => {
151
+ if (contentType.toLowerCase().startsWith('text/html')) return false;
152
+ const commands = payload?.data?.commands;
153
+ if (payload?.data?.version !== 1
154
+ || !Array.isArray(commands)
155
+ || REQUIRED_AGENT_COMMANDS.some(command => !commands.includes(command))) {
156
+ throw new Error('Flambe API returned an invalid agent-command capability document');
157
+ }
158
+ return true;
159
+ })
160
+ .catch(error => {
161
+ if (error?.status === 404 || error?.status === 405) return false;
162
+ this.agentCommandsCapability = null;
163
+ throw error;
164
+ });
165
+ this.agentCommandsCapability = probe;
166
+ }
167
+ return this.agentCommandsCapability;
168
+ }
169
+
170
+ async agentCommand(command, arguments_) {
171
+ const payload = await this.request('/api/agent-commands', {
172
+ method: 'POST',
173
+ body: { command, arguments: arguments_ },
174
+ });
175
+ return payload.data;
176
+ }
177
+
178
+ commandIdentity() {
179
+ return {
180
+ trace_id: this.traceId,
181
+ ...(this.agentId ? { agent_id: this.agentId } : {}),
182
+ ...(this.agentName ? { agent_name: this.agentName } : {}),
183
+ ...(this.agentPlatform ? { agent_platform: this.agentPlatform } : {}),
184
+ };
93
185
  }
94
186
 
95
- async request(path, { method = 'GET', body } = {}) {
187
+ async request(path, { method = 'GET', body, responseMetadata = false } = {}) {
188
+ assertRuntimeUrl(`${this.baseUrl}${path}`, this.runtimeMode);
96
189
  let response;
97
190
  try {
98
191
  response = await this.fetch(`${this.baseUrl}${path}`, {
99
192
  method,
193
+ ...(this.runtimeMode === 'local_self_contained' ? { redirect: 'manual' } : {}),
100
194
  headers: {
101
195
  authorization: `Bearer ${this.token}`,
102
196
  ...(this.agentId ? { 'x-flambe-agent-id': this.agentId } : {}),
103
197
  ...(this.agentId && this.agentName ? { 'x-flambe-agent-name': this.agentName } : {}),
198
+ ...(this.agentId && this.agentPlatform ? { 'x-flambe-agent-platform': this.agentPlatform } : {}),
104
199
  ...(body === undefined ? {} : { 'content-type': 'application/json' }),
105
200
  },
106
201
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
@@ -129,9 +224,48 @@ export class FlambeClient {
129
224
  throw error;
130
225
  }
131
226
 
227
+ this.adoptAgentName(response.headers);
228
+ if (responseMetadata) {
229
+ return {
230
+ payload,
231
+ contentType: response.headers.get('content-type') ?? '',
232
+ status: response.status,
233
+ };
234
+ }
132
235
  return payload;
133
236
  }
134
237
 
238
+ /**
239
+ * The reducer names agents that arrive without a name and echoes the name on every
240
+ * response (ADR-012). Keep it for the rest of this process, remember it for future
241
+ * ones, and tell the worker the first time.
242
+ */
243
+ adoptAgentName(headers) {
244
+ if (!this.agentId || !headers?.get) return;
245
+ const name = headers.get('x-flambe-agent-name')?.trim();
246
+ if (!name) return;
247
+
248
+ const assigned = headers.get('x-flambe-agent-name-assigned') === 'true';
249
+ if (name !== this.agentName) {
250
+ this.agentName = name;
251
+ if (this.agentNamesPath) rememberAgentName(this.agentId, name, this.agentNamesPath);
252
+ }
253
+ if (assigned) this.onReducerNote?.({ type: 'agent_named', agentId: this.agentId, name });
254
+ }
255
+
256
+ async whoami() {
257
+ if (!this.agentId) {
258
+ throw new Error('No agent id for this process; set FLAMBE_AGENT_ID (Cursor, Codex, and Claude Code sessions derive one automatically)');
259
+ }
260
+ const payload = await this.request('/api/agents/me');
261
+ return {
262
+ agentId: payload.data.agent_id,
263
+ name: payload.data.name,
264
+ platform: payload.data.platform ?? null,
265
+ nameAssigned: payload.data.name_assigned === true,
266
+ };
267
+ }
268
+
135
269
  async getTrace() {
136
270
  const payload = await this.request(`/api/traces/${this.traceId}`);
137
271
  return payload.data;
@@ -139,14 +273,16 @@ export class FlambeClient {
139
273
 
140
274
  async threads() {
141
275
  const trace = await this.getTrace();
142
- const threads = [...(trace.threads ?? [])]
143
- .sort((a, b) => (a.rank - b.rank) || (a.id - b.id));
276
+ const threads = sortedThreads(trace);
277
+ const defaultId = this.defaultThread
278
+ ? matchThread(threads, this.defaultThread, this.traceId).id
279
+ : threads[0]?.id;
144
280
 
145
- return threads.map((thread, index) => ({
281
+ return threads.map(thread => ({
146
282
  id: thread.id,
147
283
  name: thread.name,
148
284
  rank: thread.rank,
149
- default: index === 0,
285
+ default: thread.id === defaultId,
150
286
  }));
151
287
  }
152
288
 
@@ -155,8 +291,31 @@ export class FlambeClient {
155
291
  return payload.data;
156
292
  }
157
293
 
158
- async status({ activeOnly = false, suspendedOnly = false } = {}) {
294
+ async plan({ name, description, weight, scheduledStart, scheduledEnd }) {
295
+ if (!name?.trim()) throw new Error('Activity name is required');
296
+ const result = await this.agentCommand('plan', {
297
+ ...this.commandIdentity(),
298
+ name: name.trim(),
299
+ ...(description ? { description } : {}),
300
+ ...(weight == null ? {} : { weight }),
301
+ ...(scheduledStart == null ? {} : { scheduled_start: scheduledStart }),
302
+ ...(scheduledEnd == null ? {} : { scheduled_end: scheduledEnd }),
303
+ });
304
+ this.reportCommandResult(result);
305
+ return result.activity_id;
306
+ }
307
+
308
+ async status({ activeOnly = false, suspendedOnly = false, includeUnstarted = false } = {}) {
159
309
  if (activeOnly && suspendedOnly) throw new Error('--active and --suspended cannot be used together');
310
+ if (await this.supportsAgentCommands()) {
311
+ const result = await this.agentCommand('status', {
312
+ ...this.commandIdentity(),
313
+ active_only: activeOnly,
314
+ suspended_only: suspendedOnly,
315
+ ...(includeUnstarted ? { include_unstarted: true } : {}),
316
+ });
317
+ return result.state;
318
+ }
160
319
  const trace = await this.getTrace();
161
320
  return this.statusFromTrace(trace, { activeOnly, suspendedOnly });
162
321
  }
@@ -168,6 +327,8 @@ export class FlambeClient {
168
327
 
169
328
  for (const event of trace.events ?? []) {
170
329
  if (!event.activity) continue;
330
+ // Reducer annotations (reducer_incoming / reducer_decision) do not change state.
331
+ if (!LIFECYCLE_PHASES.has(event.phase)) continue;
171
332
 
172
333
  if (event.phase === 'B') {
173
334
  const startedAt = startedAtByActivity.get(event.activity.id);
@@ -191,7 +352,7 @@ export class FlambeClient {
191
352
  };
192
353
 
193
354
  const activities = [...latestByActivity.values()]
194
- .filter(event => !activeOnly || event.phase === 'B' || event.phase === 'R')
355
+ .filter(event => !activeOnly || ['B', 'R', 'X'].includes(event.phase))
195
356
  .filter(event => !suspendedOnly || event.phase === 'S')
196
357
  .sort(compareEvents)
197
358
  .map(event => ({
@@ -218,16 +379,20 @@ export class FlambeClient {
218
379
  };
219
380
  }
220
381
 
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');
382
+ async resolveThreadId(explicitThreadId) {
383
+ const hasExplicit = explicitThreadId != null && String(explicitThreadId).trim() !== '';
384
+ const selector = hasExplicit ? explicitThreadId : this.defaultThread;
385
+ if (selector == null || String(selector).trim() === '') return undefined;
386
+
387
+ const raw = String(selector).trim();
388
+ if (/^\d+$/.test(raw)) {
389
+ const id = Number(raw);
390
+ if (id <= 0) throw new Error('--thread must be a positive integer');
225
391
  return id;
226
392
  }
227
393
 
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;
394
+ const trace = await this.getTrace();
395
+ return matchThread(sortedThreads(trace), raw, this.traceId).id;
231
396
  }
232
397
 
233
398
  resolveCategoryIds(categoryIds = []) {
@@ -268,7 +433,7 @@ export class FlambeClient {
268
433
  const input = {
269
434
  name: name.trim(),
270
435
  description,
271
- threadId,
436
+ threadId: await this.resolveThreadId(threadId),
272
437
  parentId: this.resolveParentId(parentId),
273
438
  categoryIds: resolvedCategoryIds,
274
439
  timestamp,
@@ -282,15 +447,37 @@ export class FlambeClient {
282
447
  }
283
448
  }
284
449
 
450
+ /**
451
+ * `start` is a proposal (ADR-012): the reducer picks the parent (this agent's newest
452
+ * active activity unless `--parent`/`--root` said otherwise), puts a child on its
453
+ * parent's thread, inherits categories, and places roots asynchronously. So the CLI
454
+ * sends only what the worker actually said: `parent_id` absent = infer, `null` = root.
455
+ */
285
456
  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
457
+ if (await this.supportsAgentCommands()) {
458
+ const resolvedParentId = parentId === undefined || parentId === null
459
+ ? parentId
460
+ : await this.queue.resolveActivityId(parentId);
461
+ if (String(resolvedParentId).startsWith('offline-')) {
462
+ const error = new Error('Parent activity is queued for offline delivery');
463
+ error.retryable = true;
464
+ throw error;
465
+ }
466
+ const result = await this.agentCommand('start', {
467
+ ...this.commandIdentity(),
468
+ name,
469
+ ...(description ? { description } : {}),
470
+ ...(threadId === undefined ? {} : { thread_id: Number(threadId) }),
471
+ ...(resolvedParentId === undefined ? {} : { parent_id: resolvedParentId }),
472
+ category_ids: categoryIds,
473
+ timestamp,
474
+ });
475
+ this.reportCommandResult(result);
476
+ return result.activity_id;
477
+ }
478
+
479
+ const resolvedParentId = parentId === undefined || parentId === null
480
+ ? parentId
294
481
  : await this.queue.resolveActivityId(parentId);
295
482
 
296
483
  if (String(resolvedParentId).startsWith('offline-')) {
@@ -303,11 +490,11 @@ export class FlambeClient {
303
490
  method: 'POST',
304
491
  body: {
305
492
  trace_id: this.traceId,
306
- thread_id: resolvedThreadId,
493
+ ...(threadId === undefined ? {} : { thread_id: threadId }),
307
494
  activity: {
308
495
  name,
309
496
  ...(description ? { description } : {}),
310
- ...(resolvedParentId ? { parent_id: resolvedParentId } : {}),
497
+ ...(resolvedParentId === undefined ? {} : { parent_id: resolvedParentId }),
311
498
  categories: categoryIds,
312
499
  },
313
500
  event: {
@@ -317,12 +504,26 @@ export class FlambeClient {
317
504
  },
318
505
  });
319
506
 
320
- return payload.data.activity.id;
507
+ const { activity, reducer } = payload.data;
508
+ if (reducer) {
509
+ this.onReducerNote?.({
510
+ type: 'start_reduced',
511
+ activityId: activity.id,
512
+ parentId: activity.parent_id ?? null,
513
+ threadId: activity.thread_id,
514
+ requestedThreadId: threadId,
515
+ parentSource: reducer.parent_source,
516
+ threadSource: reducer.thread_source,
517
+ categoriesSource: reducer.categories_source,
518
+ });
519
+ }
520
+
521
+ return activity.id;
321
522
  }
322
523
 
323
524
  async end({ activityId, message, force = false }) {
324
- if (!force) await this.assertNoOpenChildren(activityId);
325
- return this.lifecycleEvent({ activityId, message, phase: 'E', queueType: 'end' });
525
+ const phase = message ? 'V' : 'E';
526
+ return this.lifecycleEvent({ activityId, message, phase, queueType: 'end', force, guardChildren: !force });
326
527
  }
327
528
 
328
529
  /**
@@ -362,14 +563,20 @@ export class FlambeClient {
362
563
  return this.lifecycleEvent({ activityId, message, phase: 'R', queueType: 'resume' });
363
564
  }
364
565
 
365
- async lifecycleEvent({ activityId, message, phase, queueType }) {
566
+ async lifecycleEvent({ activityId, message, phase, queueType, force = false, guardChildren = false }) {
366
567
  if (!String(activityId).startsWith('offline-')) {
367
568
  const id = Number(activityId);
368
569
  if (!Number.isInteger(id) || id <= 0) throw new Error('Activity id must be a positive integer');
369
570
  }
370
571
 
371
572
  const timestamp = this.now();
372
- const input = { activityId, message, timestamp };
573
+ const input = {
574
+ activityId,
575
+ message,
576
+ timestamp,
577
+ phase,
578
+ ...(queueType === 'end' ? { force } : {}),
579
+ };
373
580
 
374
581
  try {
375
582
  const id = await this.queue.resolveActivityId(activityId);
@@ -377,8 +584,11 @@ export class FlambeClient {
377
584
  await this.queue.enqueue(queueType, input);
378
585
  return 'queued';
379
586
  }
380
- const eventId = await this.postLifecycleEvent({ ...input, activityId: id, phase });
381
- if (phase === 'E') await this.queue.removeAlias(activityId);
587
+ if (guardChildren && !await this.supportsAgentCommands()) {
588
+ await this.assertNoOpenChildren(id);
589
+ }
590
+ const eventId = await this.postLifecycleEvent({ ...input, activityId: id, phase, force });
591
+ if (endedPhase(phase)) await this.queue.removeAlias(activityId);
382
592
  return eventId;
383
593
  } catch (error) {
384
594
  if (!error?.retryable) throw error;
@@ -387,7 +597,21 @@ export class FlambeClient {
387
597
  }
388
598
  }
389
599
 
390
- async postLifecycleEvent({ activityId, message, timestamp, phase }) {
600
+ async postLifecycleEvent({ activityId, message, timestamp, phase, force = false }) {
601
+ if (await this.supportsAgentCommands()) {
602
+ const command = { E: 'end', J: 'end', V: 'end', S: 'suspend', R: 'resume' }[phase];
603
+ const result = await this.agentCommand(command, {
604
+ ...this.commandIdentity(),
605
+ activity_id: Number(activityId),
606
+ ...(message ? { message } : {}),
607
+ timestamp,
608
+ ...(command === 'end' ? { force, ...(phase ? { phase } : {}) } : {}),
609
+ });
610
+ this.reportClosedDescendants(Number(activityId), result.closed_descendants ?? []);
611
+ this.reportCommandResult(result);
612
+ return result.event_id;
613
+ }
614
+
391
615
  const payload = await this.request('/api/events', {
392
616
  method: 'POST',
393
617
  body: {
@@ -402,10 +626,32 @@ export class FlambeClient {
402
626
  });
403
627
 
404
628
  const closed = payload.data.reducer?.closed_descendants ?? [];
629
+ this.reportClosedDescendants(Number(activityId), closed);
630
+
631
+ return payload.data.id;
632
+ }
633
+
634
+ reportCommandResult(result) {
635
+ if (!result) return;
636
+ const actions = result.actions_applied ?? [];
637
+ const direction = result.direction ?? null;
638
+ const reply = result.reply ?? null;
639
+ if (!direction && !reply && actions.length === 0) return;
640
+ this.onReducerNote?.({
641
+ type: 'command_result',
642
+ activityId: result.activity_id,
643
+ direction,
644
+ reply,
645
+ actionsApplied: actions,
646
+ asked: actions.some(action => action.type === 'ask'),
647
+ });
648
+ }
649
+
650
+ reportClosedDescendants(activityId, closed) {
405
651
  if (closed.length > 0) {
406
652
  this.onReducerNote?.({
407
653
  type: 'closed_descendants',
408
- activityId: Number(activityId),
654
+ activityId,
409
655
  closedDescendants: closed.map(item => ({
410
656
  activityId: item.activity_id,
411
657
  activityName: item.activity_name,
@@ -413,14 +659,15 @@ export class FlambeClient {
413
659
  })),
414
660
  });
415
661
  }
416
-
417
- return payload.data.id;
418
662
  }
419
663
 
420
664
  async flushQueue() {
421
665
  return this.queue.flush({
422
666
  start: input => this.postStart(input),
423
- end: input => this.postLifecycleEvent({ ...input, phase: 'E' }),
667
+ end: input => this.postLifecycleEvent({
668
+ ...input,
669
+ phase: input.phase ?? (input.message ? 'V' : 'E'),
670
+ }),
424
671
  suspend: input => this.postLifecycleEvent({ ...input, phase: 'S' }),
425
672
  resume: input => this.postLifecycleEvent({ ...input, phase: 'R' }),
426
673
  observe: input => this.postObservation(input),
@@ -461,6 +708,28 @@ export class FlambeClient {
461
708
  async message({ activityId, text }) {
462
709
  if (!text?.trim()) throw new Error('Message text is required');
463
710
 
711
+ if (await this.supportsAgentCommands()) {
712
+ let resolvedActivityId = activityId;
713
+ if (activityId !== undefined) {
714
+ resolvedActivityId = await this.queue.resolveActivityId(activityId);
715
+ if (String(resolvedActivityId).startsWith('offline-')) {
716
+ throw new Error('Activity is still queued for offline delivery; retry once Flambe is reachable');
717
+ }
718
+ const id = Number(resolvedActivityId);
719
+ if (!Number.isInteger(id) || id <= 0) throw new Error('--activity must be a positive integer');
720
+ resolvedActivityId = id;
721
+ }
722
+
723
+ const result = await this.agentCommand('message', {
724
+ ...this.commandIdentity(),
725
+ ...(resolvedActivityId === undefined ? {} : { activity_id: resolvedActivityId }),
726
+ message: text.trim(),
727
+ });
728
+ const decision = { activityId: result.activity_id ?? resolvedActivityId, ...result };
729
+ this.reportCommandResult(result);
730
+ return decision;
731
+ }
732
+
464
733
  const resolvedActivityId = activityId === undefined
465
734
  ? await this.currentActivityId()
466
735
  : await this.queue.resolveActivityId(activityId);
@@ -547,3 +816,52 @@ function compareEvents(a, b) {
547
816
  const timestampDifference = Date.parse(a.timestamp) - Date.parse(b.timestamp);
548
817
  return timestampDifference || (a.id - b.id);
549
818
  }
819
+
820
+ function sortedThreads(trace) {
821
+ return [...(trace.threads ?? [])].sort((a, b) => (a.rank - b.rank) || (a.id - b.id));
822
+ }
823
+
824
+ export function threadSlug(name) {
825
+ return String(name)
826
+ .normalize('NFD')
827
+ .replace(/\p{M}/gu, '')
828
+ .toLowerCase()
829
+ .replace(/[^\p{L}\p{N}]+/gu, '');
830
+ }
831
+
832
+ function matchThread(threads, selector, traceId) {
833
+ const raw = String(selector).trim();
834
+ const available = threads.map(thread => thread.name).join(', ');
835
+ const notFound = label => new Error(
836
+ `Thread ${label} not found in trace ${traceId}${available ? ` (threads: ${available})` : ''}`,
837
+ );
838
+
839
+ if (/^\d+$/.test(raw)) {
840
+ const id = Number(raw);
841
+ const byId = threads.find(thread => thread.id === id);
842
+ if (byId) return byId;
843
+ throw notFound(raw);
844
+ }
845
+
846
+ const exact = threads.filter(thread => thread.name === raw);
847
+ if (exact.length === 1) return exact[0];
848
+ if (exact.length > 1) {
849
+ throw new Error(`Thread name "${raw}" is ambiguous in trace ${traceId}`);
850
+ }
851
+
852
+ const caseInsensitive = threads.filter(thread => thread.name.toLowerCase() === raw.toLowerCase());
853
+ if (caseInsensitive.length === 1) return caseInsensitive[0];
854
+ if (caseInsensitive.length > 1) {
855
+ throw new Error(`Thread name "${raw}" is ambiguous in trace ${traceId}`);
856
+ }
857
+
858
+ const slug = threadSlug(raw);
859
+ if (!slug) throw notFound(`"${raw}"`);
860
+ const bySlug = threads.filter(thread => threadSlug(thread.name) === slug);
861
+ if (bySlug.length === 1) return bySlug[0];
862
+ if (bySlug.length > 1) {
863
+ throw new Error(`Thread "${raw}" is ambiguous in trace ${traceId} (matches: ${bySlug.map(thread => thread.name).join(', ')})`);
864
+ }
865
+
866
+ throw notFound(`"${raw}"`);
867
+ }
@@ -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
  }