@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/README.md +63 -0
- package/package.json +17 -3
- package/src/agentNames.mjs +35 -0
- package/src/cli.mjs +124 -10
- package/src/client.mjs +359 -41
- package/src/local/agents.mjs +18 -12
- package/src/local/commands.mjs +291 -0
- package/src/local/http.mjs +116 -20
- package/src/local/serve.mjs +2 -1
- package/src/local/store.mjs +262 -39
- package/src/questions.mjs +48 -0
- package/src/queue.mjs +15 -3
- package/src/runtime.mjs +23 -0
- package/test/client.test.mjs +0 -951
- package/test/local.test.mjs +0 -136
- package/test/queue.test.mjs +0 -135
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
|
|
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({
|
|
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 =
|
|
143
|
-
|
|
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(
|
|
281
|
+
return threads.map(thread => ({
|
|
146
282
|
id: thread.id,
|
|
147
283
|
name: thread.name,
|
|
148
284
|
rank: thread.rank,
|
|
149
|
-
default:
|
|
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
|
|
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 ||
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
229
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
.
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
return this.lifecycleEvent({ activityId, message, phase
|
|
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 = {
|
|
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
|
-
|
|
381
|
-
|
|
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
|
|
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({
|
|
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
|
+
}
|
package/src/local/agents.mjs
CHANGED
|
@@ -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
|
-
'
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
}
|