@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 +61 -0
- package/package.json +17 -3
- package/src/agentNames.mjs +35 -0
- package/src/cli.mjs +34 -3
- package/src/client.mjs +152 -26
- package/src/local/agents.mjs +18 -12
- package/src/local/http.mjs +43 -13
- package/src/local/store.mjs +158 -21
- package/test/client.test.mjs +0 -951
- package/test/local.test.mjs +0 -136
- package/test/queue.test.mjs +0 -135
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.
|
|
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
|
|
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 =>
|
|
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
|
|
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 =
|
|
143
|
-
|
|
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(
|
|
202
|
+
return threads.map(thread => ({
|
|
146
203
|
id: thread.id,
|
|
147
204
|
name: thread.name,
|
|
148
205
|
rank: thread.rank,
|
|
149
|
-
default:
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
229
|
-
|
|
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
|
|
287
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|
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
|
}
|
package/src/local/http.mjs
CHANGED
|
@@ -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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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);
|