@davvidbaker/flambe-cli 0.1.1 → 0.1.3

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 CHANGED
@@ -52,10 +52,12 @@ Create a `.env` in the project working directory (or set the same names in the
52
52
  shell; shell wins):
53
53
 
54
54
  ```dotenv
55
- FLAMBE_URL=https://flambe.fly.dev
55
+ FLAMBE_URL=https://your-app.fly.dev
56
56
  FLAMBE_API_TOKEN=flb_...
57
57
  FLAMBE_TRACE_ID=1
58
58
  # FLAMBE_THREAD=flambe
59
59
  ```
60
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`).
61
+ Mint a token in Flambe Settings → API tokens. See the
62
+ [repository README](https://github.com/davvidbaker/flambe#connect-coding-agents)
63
+ for commands (`start`, `end`, `message`, `observe`, local `serve`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davvidbaker/flambe-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "CLI for streaming coding-agent activity into Flambe",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -1,8 +1,9 @@
1
- import { readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join, resolve } from 'node:path';
4
4
 
5
5
  import { clientFromEnv, clientFromHostEnv } from './client.mjs';
6
+ import { clearQuestion, readOpenQuestion, rememberQuestion } from './questions.mjs';
6
7
 
7
8
  // `node:sqlite` still prints an ExperimentalWarning on import in some Node 22 releases.
8
9
  // Only serve/export need it, so load those modules on demand and keep every other
@@ -19,7 +20,8 @@ const usage = `Usage:
19
20
  flambe end <activity-id> [message] [--force]
20
21
  flambe suspend <activity-id> [message]
21
22
  flambe resume <activity-id> [message]
22
- flambe status [--active | --suspended] [--json]
23
+ flambe plan <activity name> [--start <ISO-8601>] [--end <ISO-8601>] [--description <text>] [--weight <n>]
24
+ flambe status [--active | --suspended] [--unstarted] [--json]
23
25
  flambe threads [--json]
24
26
  flambe categories [--json]
25
27
  flambe ping
@@ -52,6 +54,7 @@ Optional:
52
54
  still keeps a child on its parent's thread.
53
55
  FLAMBE_QUEUE_PATH Local offline queue path (default: ~/.flambe/event-queue.json)
54
56
  FLAMBE_LOCAL_DB SQLite path for flambe serve / export (default: ~/.flambe/local.sqlite)
57
+ FLAMBE_RUNTIME_MODE local_self_contained restricts CLI requests to numeric loopback; cloud preserves hosted access
55
58
 
56
59
  serve / export do not need FLAMBE_URL. import needs FLAMBE_URL and FLAMBE_API_TOKEN (the production instance).`;
57
60
 
@@ -125,20 +128,55 @@ function parseEnd(args) {
125
128
  };
126
129
  }
127
130
 
131
+ function parsePlan(args) {
132
+ const nameParts = [];
133
+ let description;
134
+ let weight;
135
+ let start;
136
+ let end;
137
+ for (let index = 0; index < args.length; index += 1) {
138
+ const arg = args[index];
139
+ if (arg === '--description') {
140
+ description = args[index + 1];
141
+ if (description === undefined) throw new Error('--description requires a value');
142
+ index += 1;
143
+ } else if (arg === '--weight') {
144
+ weight = Number(args[index + 1]);
145
+ if (!Number.isInteger(weight)) throw new Error('--weight requires an integer');
146
+ index += 1;
147
+ } else if (arg === '--start' || arg === '--end') {
148
+ const raw = args[index + 1];
149
+ if (raw === undefined) throw new Error(`${arg} requires an ISO-8601 timestamp`);
150
+ const parsed = Date.parse(raw);
151
+ if (!Number.isFinite(parsed)) throw new Error(`${arg} must be a valid ISO-8601 timestamp`);
152
+ if (arg === '--start') start = parsed;
153
+ else end = parsed;
154
+ index += 1;
155
+ } else if (arg.startsWith('--')) {
156
+ throw new Error(`Unknown option: ${arg}`);
157
+ } else nameParts.push(arg);
158
+ }
159
+ const name = nameParts.join(' ').trim();
160
+ if (!name) throw new Error('Usage: flambe plan <activity name> [--start <ISO-8601>] [--end <ISO-8601>]');
161
+ return { name, description, weight, scheduledStart: start, scheduledEnd: end };
162
+ }
163
+
128
164
  function parseStatus(args) {
129
165
  let activeOnly = false;
130
166
  let suspendedOnly = false;
167
+ let includeUnstarted = false;
131
168
  let json = false;
132
169
 
133
170
  for (const arg of args) {
134
171
  if (arg === '--active') activeOnly = true;
135
172
  else if (arg === '--suspended') suspendedOnly = true;
173
+ else if (arg === '--unstarted') includeUnstarted = true;
136
174
  else if (arg === '--json') json = true;
137
175
  else throw new Error(`Unknown option: ${arg}`);
138
176
  }
139
177
 
140
178
  if (activeOnly && suspendedOnly) throw new Error('--active and --suspended cannot be used together');
141
- return { activeOnly, suspendedOnly, json };
179
+ return { activeOnly, suspendedOnly, includeUnstarted, json };
142
180
  }
143
181
 
144
182
  function parseObserve(args) {
@@ -275,6 +313,14 @@ function parseImport(args) {
275
313
  function formatAction(action) {
276
314
  if (action.type === 'create_child') return `created child ${action.activity_id} under ${action.parent_activity_id}`;
277
315
  if (action.type === 'update_activity') return `updated activity ${action.activity_id}`;
316
+ if (action.type === 'reparent') return `reparented ${action.activity_id} under ${action.parent_activity_id}`;
317
+ if (action.type === 'rename') return `renamed ${action.activity_id} to ${action.name}`;
318
+ if (action.type === 'ask') return `asked about ${action.activity_id}`;
319
+ if (action.type === 'resume_existing') return `resumed existing ${action.activity_id}`;
320
+ if (action.type === 'resume_ancestor') return `resumed ancestor ${action.activity_id}`;
321
+ if (action.type === 'no_op') return `left ${action.activity_id} as it was`;
322
+ if (action.type === 'keep') return 'kept the recorded structure';
323
+ if (action.type === 'skipped') return 'recorded the proposal; model review skipped';
278
324
  return JSON.stringify(action);
279
325
  }
280
326
 
@@ -286,6 +332,15 @@ function formatReducerNote(note) {
286
332
  if (note.type === 'agent_named') {
287
333
  return `reducer named this agent "${note.name}"; the CLI will use it from now on\n`;
288
334
  }
335
+ if (note.type === 'command_result') {
336
+ const lines = [];
337
+ if (note.direction) lines.push(`direction\t${note.direction}\n`);
338
+ if (note.reply) lines.push(`reply\t${note.reply}\n`);
339
+ if (note.actionsApplied?.length) {
340
+ lines.push(`actions\t${note.actionsApplied.map(formatAction).join('; ')}\n`);
341
+ }
342
+ return lines.join('');
343
+ }
289
344
  if (note.type === 'start_reduced') {
290
345
  const parts = [];
291
346
  if (note.parentSource === 'inferred') parts.push(`nested ${note.activityId} under ${note.parentId}`);
@@ -298,7 +353,7 @@ function formatReducerNote(note) {
298
353
  return `reducer: ${JSON.stringify(note)}\n`;
299
354
  }
300
355
 
301
- export async function run(argv, { env = process.env, stdout = process.stdout, stderr = process.stderr, client } = {}) {
356
+ export async function run(argv, { env = process.env, stdout = process.stdout, stderr = process.stderr, client, questionsPath } = {}) {
302
357
  const [command, ...args] = argv;
303
358
 
304
359
  if (!command || command === 'help' || command === '--help' || command === '-h') {
@@ -320,6 +375,9 @@ export async function run(argv, { env = process.env, stdout = process.stdout, st
320
375
 
321
376
  if (command === 'export') {
322
377
  const { file, dbPath } = parseExport(args, env);
378
+ if (!existsSync(dbPath)) {
379
+ throw new Error(`Local database does not exist: ${dbPath}`);
380
+ }
323
381
  const { LocalStore } = await loadStore();
324
382
  const store = new LocalStore(dbPath);
325
383
  try {
@@ -347,14 +405,32 @@ export async function run(argv, { env = process.env, stdout = process.stdout, st
347
405
  }
348
406
 
349
407
  // Reducer notes go to stderr so stdout stays a machine-friendly id.
408
+ const questionFile = questionsPath ?? (client ? undefined : env.FLAMBE_QUESTIONS_PATH);
409
+ const persistQuestions = Boolean(questionsPath) || !client;
410
+ const traceId = env.FLAMBE_TRACE_ID;
411
+ const pendingQuestion = !persistQuestions || command === 'message'
412
+ ? undefined
413
+ : readOpenQuestion(traceId, questionFile);
414
+ if (pendingQuestion) {
415
+ stderr.write(`reducer still waiting on activity ${pendingQuestion.activityId}: ${pendingQuestion.reply}\n`);
416
+ }
350
417
  const onReducerNote = note => {
351
418
  const text = formatReducerNote(note);
352
419
  if (text) stderr.write(text);
420
+ if (persistQuestions && note.type === 'command_result' && note.asked) {
421
+ rememberQuestion(traceId, { activityId: note.activityId, reply: note.reply }, questionFile);
422
+ }
353
423
  };
354
424
  const flambe = client ?? clientFromEnv(env, { onReducerNote });
355
425
  flambe.onReducerNote ??= onReducerNote;
356
426
  await flambe.flushQueue?.();
357
427
 
428
+ if (command === 'plan') {
429
+ const activityId = await flambe.plan(parsePlan(args));
430
+ stdout.write(`${activityId}\n`);
431
+ return;
432
+ }
433
+
358
434
  if (command === 'start') {
359
435
  const activityId = await flambe.start(parseStart(args));
360
436
  stdout.write(`${activityId}\n`);
@@ -378,14 +454,20 @@ export async function run(argv, { env = process.env, stdout = process.stdout, st
378
454
  }
379
455
 
380
456
  if (command === 'status') {
381
- const { activeOnly, suspendedOnly, json } = parseStatus(args);
382
- const status = await flambe.status({ activeOnly, suspendedOnly });
457
+ const { activeOnly, suspendedOnly, includeUnstarted, json } = parseStatus(args);
458
+ const status = await flambe.status({
459
+ activeOnly,
460
+ suspendedOnly,
461
+ ...(includeUnstarted ? { includeUnstarted } : {}),
462
+ });
383
463
 
384
464
  if (json) {
385
465
  stdout.write(`${JSON.stringify(status)}\n`);
386
466
  } else {
387
467
  for (const activity of status.activities) {
388
- stdout.write(`${activity.id}\t${activity.path.join(' > ')}\t${activity.threadId}\t${activity.threadName ?? ''}\t${activity.categoryIds.join(',')}\t${activity.latestEvent.phase}\t${activity.latestEvent.timestamp}\n`);
468
+ const phase = activity.latestEvent?.phase ?? activity.status;
469
+ const when = activity.latestEvent?.timestamp ?? '';
470
+ stdout.write(`${activity.id}\t${activity.path.join(' > ')}\t${activity.threadId}\t${activity.threadName ?? ''}\t${activity.categoryIds.join(',')}\t${phase}\t${when}\n`);
389
471
  }
390
472
  }
391
473
 
@@ -439,6 +521,7 @@ export async function run(argv, { env = process.env, stdout = process.stdout, st
439
521
  if (command === 'message') {
440
522
  const { text, activityId, json } = parseMessage(args);
441
523
  const decision = await flambe.message({ activityId, text });
524
+ if (persistQuestions) clearQuestion(traceId, questionFile);
442
525
  if (json) {
443
526
  stdout.write(`${JSON.stringify(decision)}\n`);
444
527
  } else {
package/src/client.mjs CHANGED
@@ -1,5 +1,13 @@
1
1
  import { FlambeQueue } from './queue.mjs';
2
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
+ }
3
11
 
4
12
  export function hostConfigFromEnv(env = process.env) {
5
13
  const required = ['FLAMBE_URL', 'FLAMBE_API_TOKEN'];
@@ -11,6 +19,7 @@ export function hostConfigFromEnv(env = process.env) {
11
19
 
12
20
  return {
13
21
  baseUrl: env.FLAMBE_URL.replace(/\/+$/, ''),
22
+ ...runtimeConfig(env),
14
23
  token: env.FLAMBE_API_TOKEN,
15
24
  };
16
25
  }
@@ -32,6 +41,7 @@ export function configFromEnv(env = process.env) {
32
41
 
33
42
  return {
34
43
  baseUrl: env.FLAMBE_URL.replace(/\/+$/, ''),
44
+ ...runtimeConfig(env),
35
45
  ...agentIdentityFromEnv(env),
36
46
  token: env.FLAMBE_API_TOKEN,
37
47
  traceId,
@@ -89,17 +99,31 @@ function claudeAgentId(env) {
89
99
  return sessionId ? `claude:${sessionId}` : undefined;
90
100
  }
91
101
 
92
- const LIFECYCLE_PHASES = new Set(['B', 'E', 'S', 'R']);
102
+ const LIFECYCLE_PHASES = new Set(['B', 'E', 'S', 'R', 'X', 'J', 'V']);
93
103
 
94
104
  function errorDetail(payload) {
95
105
  if (!payload) return null;
96
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
+ }
97
118
  if (payload.errors) return JSON.stringify(payload.errors);
98
119
  return null;
99
120
  }
100
121
 
101
122
  export class FlambeClient {
102
- constructor({ baseUrl, token, traceId, threadId, defaultThread, agentId, agentName, agentPlatform, agentNamesPath, 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;
103
127
  this.baseUrl = baseUrl.replace(/\/+$/, '');
104
128
  this.token = token;
105
129
  this.agentId = agentId;
@@ -111,14 +135,62 @@ export class FlambeClient {
111
135
  this.fetch = fetchImpl;
112
136
  this.now = now;
113
137
  this.onReducerNote = onReducerNote;
114
- 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;
115
176
  }
116
177
 
117
- async request(path, { method = 'GET', body } = {}) {
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
+ };
185
+ }
186
+
187
+ async request(path, { method = 'GET', body, responseMetadata = false } = {}) {
188
+ assertRuntimeUrl(`${this.baseUrl}${path}`, this.runtimeMode);
118
189
  let response;
119
190
  try {
120
191
  response = await this.fetch(`${this.baseUrl}${path}`, {
121
192
  method,
193
+ ...(this.runtimeMode === 'local_self_contained' ? { redirect: 'manual' } : {}),
122
194
  headers: {
123
195
  authorization: `Bearer ${this.token}`,
124
196
  ...(this.agentId ? { 'x-flambe-agent-id': this.agentId } : {}),
@@ -153,6 +225,13 @@ export class FlambeClient {
153
225
  }
154
226
 
155
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
+ }
156
235
  return payload;
157
236
  }
158
237
 
@@ -212,8 +291,31 @@ export class FlambeClient {
212
291
  return payload.data;
213
292
  }
214
293
 
215
- 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 } = {}) {
216
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
+ }
217
319
  const trace = await this.getTrace();
218
320
  return this.statusFromTrace(trace, { activeOnly, suspendedOnly });
219
321
  }
@@ -250,7 +352,7 @@ export class FlambeClient {
250
352
  };
251
353
 
252
354
  const activities = [...latestByActivity.values()]
253
- .filter(event => !activeOnly || event.phase === 'B' || event.phase === 'R')
355
+ .filter(event => !activeOnly || ['B', 'R', 'X'].includes(event.phase))
254
356
  .filter(event => !suspendedOnly || event.phase === 'S')
255
357
  .sort(compareEvents)
256
358
  .map(event => ({
@@ -352,6 +454,28 @@ export class FlambeClient {
352
454
  * sends only what the worker actually said: `parent_id` absent = infer, `null` = root.
353
455
  */
354
456
  async postStart({ name, description, threadId, parentId, categoryIds, timestamp }) {
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
+
355
479
  const resolvedParentId = parentId === undefined || parentId === null
356
480
  ? parentId
357
481
  : await this.queue.resolveActivityId(parentId);
@@ -398,8 +522,8 @@ export class FlambeClient {
398
522
  }
399
523
 
400
524
  async end({ activityId, message, force = false }) {
401
- if (!force) await this.assertNoOpenChildren(activityId);
402
- 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 });
403
527
  }
404
528
 
405
529
  /**
@@ -439,14 +563,20 @@ export class FlambeClient {
439
563
  return this.lifecycleEvent({ activityId, message, phase: 'R', queueType: 'resume' });
440
564
  }
441
565
 
442
- async lifecycleEvent({ activityId, message, phase, queueType }) {
566
+ async lifecycleEvent({ activityId, message, phase, queueType, force = false, guardChildren = false }) {
443
567
  if (!String(activityId).startsWith('offline-')) {
444
568
  const id = Number(activityId);
445
569
  if (!Number.isInteger(id) || id <= 0) throw new Error('Activity id must be a positive integer');
446
570
  }
447
571
 
448
572
  const timestamp = this.now();
449
- const input = { activityId, message, timestamp };
573
+ const input = {
574
+ activityId,
575
+ message,
576
+ timestamp,
577
+ phase,
578
+ ...(queueType === 'end' ? { force } : {}),
579
+ };
450
580
 
451
581
  try {
452
582
  const id = await this.queue.resolveActivityId(activityId);
@@ -454,8 +584,11 @@ export class FlambeClient {
454
584
  await this.queue.enqueue(queueType, input);
455
585
  return 'queued';
456
586
  }
457
- const eventId = await this.postLifecycleEvent({ ...input, activityId: id, phase });
458
- 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);
459
592
  return eventId;
460
593
  } catch (error) {
461
594
  if (!error?.retryable) throw error;
@@ -464,7 +597,21 @@ export class FlambeClient {
464
597
  }
465
598
  }
466
599
 
467
- 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
+
468
615
  const payload = await this.request('/api/events', {
469
616
  method: 'POST',
470
617
  body: {
@@ -479,10 +626,32 @@ export class FlambeClient {
479
626
  });
480
627
 
481
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) {
482
651
  if (closed.length > 0) {
483
652
  this.onReducerNote?.({
484
653
  type: 'closed_descendants',
485
- activityId: Number(activityId),
654
+ activityId,
486
655
  closedDescendants: closed.map(item => ({
487
656
  activityId: item.activity_id,
488
657
  activityName: item.activity_name,
@@ -490,14 +659,15 @@ export class FlambeClient {
490
659
  })),
491
660
  });
492
661
  }
493
-
494
- return payload.data.id;
495
662
  }
496
663
 
497
664
  async flushQueue() {
498
665
  return this.queue.flush({
499
666
  start: input => this.postStart(input),
500
- end: input => this.postLifecycleEvent({ ...input, phase: 'E' }),
667
+ end: input => this.postLifecycleEvent({
668
+ ...input,
669
+ phase: input.phase ?? (input.message ? 'V' : 'E'),
670
+ }),
501
671
  suspend: input => this.postLifecycleEvent({ ...input, phase: 'S' }),
502
672
  resume: input => this.postLifecycleEvent({ ...input, phase: 'R' }),
503
673
  observe: input => this.postObservation(input),
@@ -538,6 +708,28 @@ export class FlambeClient {
538
708
  async message({ activityId, text }) {
539
709
  if (!text?.trim()) throw new Error('Message text is required');
540
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
+
541
733
  const resolvedActivityId = activityId === undefined
542
734
  ? await this.currentActivityId()
543
735
  : await this.queue.resolveActivityId(activityId);