@fuzeelogik/myflo 1.0.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # flo — local-first developer workbench
2
+
3
+ Standalone CLI + MCP server + Next.js dashboard. Zero build step on the CLI. Pure ESM, Node ≥ 20. No cloud dependencies.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ cd apps/cli && npm link # makes `flo` available globally
9
+ flo setup # creates ~/.flo/, registers MCP, runs doctor
10
+ flo notes "First note with #flo" # quick capture (BM25 search)
11
+ cd ../../web && pnpm dev --port 3030 # localhost:3030 — unified dashboard
12
+ ```
13
+
14
+ `flo setup` is idempotent — safe to re-run. It creates `~/.flo/{memory,messages,logs}/`, initializes the inbox + terminal registries, and registers `flo` as an MCP server in `~/.claude/mcp.json` so Claude Code agents can call its tools.
15
+
16
+ ## Commands
17
+
18
+ | Command | What it does |
19
+ |---|---|
20
+ | `flo guidance audit [--scope all\|user\|project] [--json] [--out file]` | Scan `~/.claude/{skills,commands,agents}/` and project `.claude/` for duplicate, undescribed, or orphan capabilities. Markdown report by default, JSON with `--json`. |
21
+ | `flo migrate [--dry-run] [--mcp-path PATH]` | Register `flo` as an MCP server in `~/.claude/mcp.json`. Idempotent. Backs up the existing file before writing. |
22
+ | `flo sessions list [--limit N] [--json]` | List Claude Code session checkpoints from `.claude/checkpoints/` in the current project. |
23
+ | `flo inbox watch <dir> [--once]` | Foreground folder watcher. `.md` drops parse frontmatter (`to:`/`from:`/`subject:`). Audio (`.m4a` `.wav` `.mp3`) is transcribed locally via whisper/mlx-whisper and a sidecar `.txt` is written next to the audio file. Processed files move to `<dir>/.processed/`. Activity logged to `<dir>/inbox.log`. |
24
+ | `flo inbox status [--dir <dir>]` | Show pending/processed/failed counts and the last few log entries. |
25
+ | `flo inbox add <dir> [--slug <name>]` | Register an inbox in `~/.flo/inboxes.json`. Idempotent. |
26
+ | `flo inbox list [--json]` | List registered inboxes with pending/processed/failed counts. |
27
+ | `flo inbox remove <slug>` | Remove from registry (does not delete files). |
28
+ | `flo inbox install <slug> [--interval N]` | **macOS**: generate `~/Library/LaunchAgents/io.myflo.inbox.<slug>.plist` that runs `flo inbox watch --once` every N seconds (default 30). Doesn't auto-load; prints `launchctl bootstrap` command. |
29
+ | `flo inbox uninstall <slug>` | **macOS**: remove the launch agent plist. |
30
+ | `flo transcribe <file> [--save] [--model base\|small\|medium\|large]` | Local audio transcription (whisper / mlx-whisper / whisper-cpp — detected at runtime, no cloud). `--save` writes sidecar `.txt`. `--detect` reports which tool would be used. |
31
+ | `flo swarm status [--json]` | Read `.swarm/state.json` + `.swarm/q-learning-model.json` and render objective/agents/q-learning stats. |
32
+ | `flo memory store --value <text> [--key <k>] [--namespace <ns>] [--tags a,b]` | Append an entry to `~/.flo/memory/<ns>.jsonl`. |
33
+ | `flo memory search <query> [--namespace <ns>] [--tags a,b] [--limit N] [--json]` | Substring + tag search across namespaces. |
34
+ | `flo memory list [--namespace <ns>] [--json]` / `get` / `delete` / `namespaces` | Inspect and tombstone memory entries. |
35
+ | `flo messages list [<recipient>] [--json]` | List inbox-bridged messages by recipient. |
36
+ | `flo messages read <recipient> <filename>` / `archive` | Read or remove a mailbox file. |
37
+ | `flo transcripts list [--json] [--limit N]` | List sidecar `.txt` transcripts produced by audio inbox drops. |
38
+ | `flo doctor [--json]` | Quick health check: Node version, git, `.claude/`, checkpoints, MCP config, flo binary. |
39
+ | `flo mcp start` | Run as a stdio MCP server. Exposes 11 tools (see below). |
40
+ | `flo help` / `flo version` | Self-explanatory. |
41
+
42
+ ## MCP usage
43
+
44
+ After `flo migrate`, the server appears in `~/.claude/mcp.json` and you can call its tools from Claude Code. **11 tools** registered:
45
+
46
+ - `flo_sessions_list({ limit? })` — Claude Code checkpoints
47
+ - `flo_guidance_audit({ scope? })` — capability dedup report
48
+ - `flo_memory_store({ value, key?, namespace?, tags?, metadata? })`
49
+ - `flo_memory_search({ query?, namespace?, tags?, limit? })`
50
+ - `flo_memory_list({ namespace?, limit? })`
51
+ - `flo_memory_namespaces({})`
52
+ - `flo_inbox_list({})` — registered inboxes with counts
53
+ - `flo_messages_list({})` — bridged messages by recipient
54
+ - `flo_swarm_status({})` — `.swarm/` state + q-learning summary
55
+ - `flo_transcribe({ file, model? })` — local audio transcription
56
+ - `flo_transcribe_detect({})` — which transcription tool is available
57
+
58
+ The repo's own `.claude/settings.json` already registers `flo` alongside the existing `claude-flow` server — both coexist.
59
+
60
+ ## Web UI
61
+
62
+ The local command center at `web/` (Next.js 16, Tailwind v4, shadcn) exposes six flo panels:
63
+
64
+ - `/swarm` — `.swarm/state.json` + q-learning model summary
65
+ - `/memory` — namespace browser + substring search across `~/.flo/memory/`
66
+ - `/sessions` — table view of `.claude/checkpoints/`
67
+ - `/capabilities` — capability audit summary with duplicate ranking
68
+ - `/inbox` — registered inboxes with pending/processed/failed counts
69
+ - `/transcripts` — sidecar transcripts from inbox audio drops
70
+
71
+ ```bash
72
+ cd web && pnpm install && pnpm dev --port 3030
73
+ ```
74
+
75
+ Then open <http://localhost:3030/sessions> or <http://localhost:3030/capabilities>.
76
+
77
+ The web pages call `flo` as a subprocess via `web/src/lib/flo.ts` (uses `execFile` with an arg array; no shell interpolation).
78
+
79
+ ## Smoke test
80
+
81
+ ```bash
82
+ bash apps/cli/tests/smoke.sh
83
+ ```
84
+
85
+ Exercises every command end-to-end against an ephemeral temp directory.
86
+
87
+ ## Roadmap
88
+
89
+ - Vector embeddings on top of the memory store (currently substring-only).
90
+ - `flo session terminal-attach` for Ghostty/iTerm window restore (port of `a-team`).
91
+ - Web `/memory`, `/inbox`, `/plugins`, `/hooks` panels.
92
+ - Full v3/@claude-flow/* → packages/@myflo/* fork (currently `@myflo/{shared,memory,hooks}` are forked; the other ~22 packages still live under `v3/`).
93
+
94
+ ## Status
95
+
96
+ - ✅ CLI: 9 commands working, 12/12 smoke tests passing.
97
+ - ✅ MCP server: stdio JSON-RPC, 2 tools registered (`flo_sessions_list`, `flo_guidance_audit`).
98
+ - ✅ Web: 5 flo panels (`/swarm`, `/memory`, `/sessions`, `/capabilities`, `/inbox`) alongside existing siege panels.
99
+ - ✅ Audio: real local transcription (auto-detects whisper / mlx-whisper / whisper-cpp).
100
+ - ✅ Inbox: registry in `~/.flo/inboxes.json` + macOS launchd installer.
101
+ - ✅ Memory: file-backed JSON store in `~/.flo/memory/` (substring + tag search, no vectors yet).
102
+ - ✅ Bridge: inbox `.md` drops with `to:` frontmatter write a mailbox file + memory entry — eagent-style cross-process comms.
103
+ - 🟡 v3 fork: 3 of ~25 packages copied with renamed manifests; rest still under `v3/@claude-flow/`.
package/bin/flo.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../lib/main.js';
3
+
4
+ run(process.argv.slice(2)).catch((err) => {
5
+ console.error(`flo: ${err.message || err}`);
6
+ if (process.env.FLO_DEBUG) console.error(err.stack);
7
+ process.exit(1);
8
+ });
@@ -0,0 +1,243 @@
1
+ // `flo activity` — cross-subsystem event timeline.
2
+ // Aggregates: task events, memory writes (all namespaces), inbox messages,
3
+ // transcripts, terminal session adds, Claude Code checkpoints.
4
+ // Returns chronologically-sorted events with type / source / snippet.
5
+
6
+ import { readFile, readdir, stat } from 'node:fs/promises';
7
+ import { existsSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join, resolve } from 'node:path';
10
+ import { listInboxes } from './inbox-registry.js';
11
+ import { listAllMailboxes } from './messages.js';
12
+ import { collectTranscripts } from './transcripts.js';
13
+ import { readCheckpoints } from './sessions.js';
14
+
15
+ // terminal-attach is optional — only available once PR #31 lands.
16
+ let loadTerminalRegistry;
17
+ try {
18
+ ({ loadRegistry: loadTerminalRegistry } = await import('./terminal-attach.js'));
19
+ } catch { /* not available; activity will skip terminal events */ }
20
+
21
+ const FLO_HOME = process.env.FLO_HOME || join(homedir(), '.flo');
22
+ const MEMORY_DIR = join(FLO_HOME, 'memory');
23
+ const TASKS_PATH = join(FLO_HOME, 'tasks.jsonl');
24
+
25
+ const TYPES = ['task', 'note', 'memory', 'inbox', 'transcript', 'terminal', 'checkpoint'];
26
+
27
+ export async function activityCommand(args) {
28
+ const [sub = 'list', ...rest] = args;
29
+ if (sub === 'help' || sub === '--help' || sub === '-h') return printHelp();
30
+ if (sub === 'list') return listCmd(rest);
31
+ console.error(`flo activity: unknown subcommand '${sub}'`);
32
+ console.error(`Available: list, help`);
33
+ process.exit(2);
34
+ }
35
+
36
+ function printHelp() {
37
+ console.log(`flo activity — cross-subsystem event timeline
38
+
39
+ Usage:
40
+ flo activity list [--since <duration>] [--type <type>] [--limit N] [--json]
41
+
42
+ --since 7d / 24h / 30m Only events newer than this
43
+ --type <t> Filter by event type:
44
+ ${TYPES.join(', ')}
45
+ --limit N Cap results (default: 100)
46
+ --json JSON output
47
+
48
+ Sources (each event has source + timestamp + snippet):
49
+ task ~/.flo/tasks.jsonl (created/updated/completed/deleted events)
50
+ note ~/.flo/memory/notes.jsonl (memory store with #note auto-tag)
51
+ memory every other ~/.flo/memory/<ns>.jsonl
52
+ inbox ~/.flo/messages/<recipient>/ mailbox files
53
+ transcript sidecar .txt files in registered inboxes' .processed/
54
+ terminal ~/.flo/terminals.json (creation timestamps)
55
+ checkpoint .claude/checkpoints/ (Claude Code session checkpoints)
56
+ `);
57
+ }
58
+
59
+ function parseFlags(args) {
60
+ const out = {};
61
+ for (let i = 0; i < args.length; i++) {
62
+ const a = args[i];
63
+ if (a === '--since') out.since = args[++i];
64
+ else if (a === '--type') out.type = args[++i];
65
+ else if (a === '--limit') out.limit = Number(args[++i]);
66
+ else if (a === '--json') out.json = true;
67
+ }
68
+ return out;
69
+ }
70
+
71
+ function parseSince(s) {
72
+ if (!s) return 0;
73
+ const m = String(s).match(/^(\d+)\s*(s|m|h|d|w)?$/i);
74
+ if (!m) return 0;
75
+ const n = Number(m[1]);
76
+ const unit = (m[2] || 'd').toLowerCase();
77
+ const mult = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 7 * 86_400_000 }[unit] || 86_400_000;
78
+ return Date.now() - n * mult;
79
+ }
80
+
81
+ export async function collectActivity({ sinceMs = 0, type } = {}) {
82
+ const events = [];
83
+ const wantAll = !type;
84
+
85
+ if (wantAll || type === 'task') {
86
+ if (existsSync(TASKS_PATH)) {
87
+ try {
88
+ const raw = await readFile(TASKS_PATH, 'utf8');
89
+ for (const line of raw.split(/\r?\n/)) {
90
+ if (!line.trim()) continue;
91
+ let row;
92
+ try { row = JSON.parse(line); } catch { continue; }
93
+ const ts = Date.parse(row.ts || row.createdAt || 0);
94
+ if (ts < sinceMs) continue;
95
+ let snippet = row.subject || '';
96
+ const op = row.op || (row.deleted ? 'delete' : 'create');
97
+ events.push({
98
+ type: 'task',
99
+ kind: op,
100
+ id: row.id,
101
+ ts,
102
+ timestamp: new Date(ts).toISOString(),
103
+ snippet: snippet || (op === 'update' ? `status → ${row.status || '?'}` : ''),
104
+ source: 'tasks.jsonl',
105
+ });
106
+ }
107
+ } catch { /* skip */ }
108
+ }
109
+ }
110
+
111
+ if ((wantAll || type === 'memory' || type === 'note') && existsSync(MEMORY_DIR)) {
112
+ try {
113
+ const files = await readdir(MEMORY_DIR);
114
+ for (const f of files) {
115
+ if (!f.endsWith('.jsonl')) continue;
116
+ const ns = f.replace(/\.jsonl$/, '');
117
+ const isNote = ns === 'notes';
118
+ if (type === 'note' && !isNote) continue;
119
+ if (type === 'memory' && isNote) continue;
120
+ let raw = '';
121
+ try { raw = await readFile(join(MEMORY_DIR, f), 'utf8'); } catch { continue; }
122
+ for (const line of raw.split(/\r?\n/)) {
123
+ if (!line.trim()) continue;
124
+ let row;
125
+ try { row = JSON.parse(line); } catch { continue; }
126
+ if (row.deleted) continue;
127
+ const ts = Date.parse(row.createdAt || 0);
128
+ if (!ts || ts < sinceMs) continue;
129
+ const snippet = (row.value || '').split('\n')[0].slice(0, 100);
130
+ events.push({
131
+ type: isNote ? 'note' : 'memory',
132
+ kind: 'store',
133
+ id: row.id,
134
+ ts,
135
+ timestamp: new Date(ts).toISOString(),
136
+ snippet,
137
+ source: `memory/${ns}`,
138
+ namespace: ns,
139
+ tags: row.tags || [],
140
+ });
141
+ }
142
+ }
143
+ } catch { /* skip */ }
144
+ }
145
+
146
+ if (wantAll || type === 'inbox') {
147
+ try {
148
+ const mailboxes = await listAllMailboxes();
149
+ for (const mb of mailboxes) {
150
+ for (const m of (mb.messages || [])) {
151
+ if (m.mtime < sinceMs) continue;
152
+ events.push({
153
+ type: 'inbox',
154
+ kind: 'message',
155
+ id: m.path,
156
+ ts: m.mtime,
157
+ timestamp: new Date(m.mtime).toISOString(),
158
+ snippet: `${mb.recipient} ← ${m.filename}`,
159
+ source: `messages/${mb.recipient}`,
160
+ });
161
+ }
162
+ }
163
+ } catch { /* skip */ }
164
+ }
165
+
166
+ if (wantAll || type === 'transcript') {
167
+ try {
168
+ const ts_list = await collectTranscripts(500);
169
+ for (const t of ts_list) {
170
+ if (t.mtime < sinceMs) continue;
171
+ events.push({
172
+ type: 'transcript',
173
+ kind: 'transcribe',
174
+ id: t.sidecarPath,
175
+ ts: t.mtime,
176
+ timestamp: new Date(t.mtime).toISOString(),
177
+ snippet: `${t.audioFilename} (${t.chars} chars): ${t.snippet.slice(0, 80)}`,
178
+ source: `inbox/${t.inboxSlug}`,
179
+ });
180
+ }
181
+ } catch { /* skip */ }
182
+ }
183
+
184
+ if (wantAll || type === 'terminal') {
185
+ try {
186
+ const reg = await loadTerminalRegistry();
187
+ for (const t of reg.terminals || []) {
188
+ const ts = Date.parse(t.createdAt || 0);
189
+ if (!ts || ts < sinceMs) continue;
190
+ events.push({
191
+ type: 'terminal',
192
+ kind: 'add',
193
+ id: t.slug,
194
+ ts,
195
+ timestamp: new Date(ts).toISOString(),
196
+ snippet: `${t.slug} (${t.app}) → ${t.cwd}`,
197
+ source: 'terminals.json',
198
+ });
199
+ }
200
+ } catch { /* skip */ }
201
+ }
202
+
203
+ if (wantAll || type === 'checkpoint') {
204
+ try {
205
+ const checkpoints = await readCheckpoints(undefined, 200);
206
+ for (const c of checkpoints) {
207
+ if (c.mtime < sinceMs) continue;
208
+ events.push({
209
+ type: 'checkpoint',
210
+ kind: c.type || 'edit',
211
+ id: c.id,
212
+ ts: c.mtime,
213
+ timestamp: c.timestamp || new Date(c.mtime).toISOString(),
214
+ snippet: c.file || c.tag || '',
215
+ source: '.claude/checkpoints',
216
+ });
217
+ }
218
+ } catch { /* skip */ }
219
+ }
220
+
221
+ events.sort((a, b) => b.ts - a.ts);
222
+ return events;
223
+ }
224
+
225
+ async function listCmd(args) {
226
+ const opts = parseFlags(args);
227
+ if (opts.type && !TYPES.includes(opts.type)) {
228
+ console.error(`flo activity: unknown --type '${opts.type}'. Valid: ${TYPES.join(', ')}`);
229
+ process.exit(2);
230
+ }
231
+ const sinceMs = parseSince(opts.since);
232
+ let events = await collectActivity({ sinceMs, type: opts.type });
233
+ events = events.slice(0, opts.limit || 100);
234
+ if (opts.json) { console.log(JSON.stringify(events, null, 2)); return; }
235
+ if (!events.length) { console.log(`flo activity: no events${opts.since ? ` since ${opts.since}` : ''}`); return; }
236
+ for (const e of events) {
237
+ const time = e.timestamp.replace('T', ' ').slice(0, 19);
238
+ const type = e.type.padEnd(10).slice(0, 10);
239
+ const kind = (e.kind || '').padEnd(9).slice(0, 9);
240
+ console.log(`${time} ${type} ${kind} ${e.snippet}`);
241
+ }
242
+ console.log(`\n${events.length} event(s).`);
243
+ }
@@ -0,0 +1,173 @@
1
+ import {
2
+ spawnAgent,
3
+ updateAgent,
4
+ stopAgent,
5
+ deleteAgent,
6
+ listAgents,
7
+ getAgent,
8
+ agentHealth,
9
+ heartbeat,
10
+ STATUSES,
11
+ } from './agents-store.js';
12
+
13
+ export async function agentsCommand(args) {
14
+ const [sub = 'help', ...rest] = args;
15
+ if (sub === 'help' || sub === '--help' || sub === '-h') return printHelp();
16
+ if (sub === 'spawn') return spawnCmd(rest);
17
+ if (sub === 'list') return listCmd(rest);
18
+ if (sub === 'get' || sub === 'show') return getCmd(rest);
19
+ if (sub === 'update') return updateCmd(rest);
20
+ if (sub === 'heartbeat' || sub === 'hb') return hbCmd(rest);
21
+ if (sub === 'stop') return stopCmd(rest);
22
+ if (sub === 'delete' || sub === 'rm') return deleteCmd(rest);
23
+ if (sub === 'health') return healthCmd(rest);
24
+ console.error(`flo agents: unknown subcommand '${sub}'`);
25
+ console.error(`Available: spawn, list, get, update, heartbeat, stop, delete, health, help`);
26
+ process.exit(2);
27
+ }
28
+
29
+ function printHelp() {
30
+ console.log(`flo agents — coordination registry for named Claude Code agents
31
+
32
+ Usage:
33
+ flo agents spawn <type> [--name <name>] [--role <s>] [--tags a,b] [--parent <id>]
34
+ flo agents list [--status idle|busy|completed|failed|stopped] [--type <t>] [--json]
35
+ flo agents get <id> [--json]
36
+ flo agents update <id> [--status <s>] [--name <n>] [--role <s>]
37
+ flo agents heartbeat <id>
38
+ flo agents stop <id>
39
+ flo agents delete <id>
40
+ flo agents health [--json] # heartbeat-age view of live agents
41
+
42
+ Stores append-only events at ~/.flo/agents.jsonl. Does NOT spawn a process —
43
+ Claude Code's Task tool does that. This is a coordination record so multiple
44
+ agents can discover each other.
45
+ `);
46
+ }
47
+
48
+ function parseFlags(args) {
49
+ const out = { positional: [] };
50
+ for (let i = 0; i < args.length; i++) {
51
+ const a = args[i];
52
+ if (a === '--json') out.json = true;
53
+ else if (a === '--name') out.name = args[++i];
54
+ else if (a === '--role') out.role = args[++i];
55
+ else if (a === '--tags') out.tags = (args[++i] || '').split(',').map((t) => t.trim()).filter(Boolean);
56
+ else if (a === '--parent') out.parent = args[++i];
57
+ else if (a === '--status') out.status = args[++i];
58
+ else if (a === '--type') out.type = args[++i];
59
+ else if (!a.startsWith('--')) out.positional.push(a);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ async function spawnCmd(args) {
65
+ const opts = parseFlags(args);
66
+ const type = opts.positional[0];
67
+ if (!type) { console.error(`flo agents spawn: missing <type>`); process.exit(2); }
68
+ const agent = await spawnAgent({
69
+ type,
70
+ name: opts.name,
71
+ role: opts.role,
72
+ tags: opts.tags,
73
+ parent: opts.parent,
74
+ });
75
+ if (opts.json) console.log(JSON.stringify(agent));
76
+ else console.log(`flo agents spawn: ${agent.id} (${agent.type})${agent.name !== agent.id ? ' name=' + agent.name : ''}`);
77
+ }
78
+
79
+ async function listCmd(args) {
80
+ const opts = parseFlags(args);
81
+ const agents = await listAgents({ status: opts.status, type: opts.type });
82
+ if (opts.json) { console.log(JSON.stringify(agents, null, 2)); return; }
83
+ if (!agents.length) { console.log(`flo agents: none registered`); return; }
84
+ console.log(`status id type name`);
85
+ console.log(`---------- ---------------------- ------------- ----`);
86
+ for (const a of agents) {
87
+ const sts = (a.status || '?').padEnd(10).slice(0, 10);
88
+ const id = a.id.padEnd(22).slice(0, 22);
89
+ const type = (a.type || '?').padEnd(13).slice(0, 13);
90
+ console.log(`${sts} ${id} ${type} ${a.name || ''}`);
91
+ }
92
+ }
93
+
94
+ async function getCmd(args) {
95
+ const opts = parseFlags(args);
96
+ const id = opts.positional[0];
97
+ if (!id) { console.error(`flo agents get: missing <id>`); process.exit(2); }
98
+ const agent = await getAgent(id);
99
+ if (!agent) { console.error(`flo agents get: no agent ${id}`); process.exit(1); }
100
+ if (opts.json) console.log(JSON.stringify(agent, null, 2));
101
+ else {
102
+ console.log(`${agent.id}`);
103
+ console.log(`type: ${agent.type}`);
104
+ console.log(`name: ${agent.name}`);
105
+ console.log(`status: ${agent.status}`);
106
+ if (agent.role) console.log(`role: ${agent.role}`);
107
+ if (agent.tags?.length) console.log(`tags: ${agent.tags.join(', ')}`);
108
+ if (agent.parent) console.log(`parent: ${agent.parent}`);
109
+ console.log(`spawnedAt: ${agent.spawnedAt}`);
110
+ console.log(`lastHeartbeat: ${agent.lastHeartbeat}`);
111
+ }
112
+ }
113
+
114
+ async function updateCmd(args) {
115
+ const opts = parseFlags(args);
116
+ const id = opts.positional[0];
117
+ if (!id) { console.error(`flo agents update: missing <id>`); process.exit(2); }
118
+ const patch = {};
119
+ if (opts.status !== undefined) {
120
+ if (!STATUSES.includes(opts.status)) {
121
+ console.error(`flo agents update: invalid status '${opts.status}'. Valid: ${STATUSES.join(', ')}`);
122
+ process.exit(2);
123
+ }
124
+ patch.status = opts.status;
125
+ }
126
+ if (opts.name !== undefined) patch.name = opts.name;
127
+ if (opts.role !== undefined) patch.role = opts.role;
128
+ if (Object.keys(patch).length === 0) {
129
+ console.error(`flo agents update: nothing to change`);
130
+ process.exit(2);
131
+ }
132
+ const agent = await updateAgent({ id, ...patch });
133
+ if (!agent) { console.error(`flo agents update: no agent ${id}`); process.exit(1); }
134
+ console.log(`flo agents update: ${agent.id} status=${agent.status}`);
135
+ }
136
+
137
+ async function hbCmd(args) {
138
+ const id = args[0];
139
+ if (!id) { console.error(`flo agents heartbeat: missing <id>`); process.exit(2); }
140
+ const agent = await heartbeat(id);
141
+ if (!agent) { console.error(`flo agents heartbeat: no agent ${id}`); process.exit(1); }
142
+ console.log(`flo agents heartbeat: ${agent.id} at ${agent.lastHeartbeat}`);
143
+ }
144
+
145
+ async function stopCmd(args) {
146
+ const id = args[0];
147
+ if (!id) { console.error(`flo agents stop: missing <id>`); process.exit(2); }
148
+ const agent = await stopAgent(id);
149
+ if (!agent) { console.error(`flo agents stop: no agent ${id}`); process.exit(1); }
150
+ console.log(`flo agents stop: ${agent.id} stopped`);
151
+ }
152
+
153
+ async function deleteCmd(args) {
154
+ const id = args[0];
155
+ if (!id) { console.error(`flo agents delete: missing <id>`); process.exit(2); }
156
+ await deleteAgent(id);
157
+ console.log(`flo agents delete: tombstoned ${id}`);
158
+ }
159
+
160
+ async function healthCmd(args) {
161
+ const opts = parseFlags(args);
162
+ const health = await agentHealth();
163
+ if (opts.json) { console.log(JSON.stringify(health, null, 2)); return; }
164
+ if (!health.length) { console.log(`flo agents health: no live agents`); return; }
165
+ console.log(`health id heartbeat age name`);
166
+ console.log(`------- ---------------------- -------------- ----`);
167
+ for (const h of health) {
168
+ const tag = (h.health || '?').padEnd(7).slice(0, 7);
169
+ const id = h.id.padEnd(22).slice(0, 22);
170
+ const ageS = Math.round(h.heartbeatAgeMs / 1000);
171
+ console.log(`${tag} ${id} ${String(ageS).padStart(8)}s ${h.name || ''}`);
172
+ }
173
+ }
@@ -0,0 +1,153 @@
1
+ // flo agents store — append-only registry of named agents at ~/.flo/agents.jsonl.
2
+ // Does NOT spawn processes — Claude Code's Task tool does the actual execution.
3
+ // This is a coordination record: agents declare themselves, others discover them,
4
+ // heartbeats track liveness.
5
+
6
+ import { mkdir, readFile, appendFile } from 'node:fs/promises';
7
+ import { existsSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { randomBytes } from 'node:crypto';
11
+
12
+ const FLO_HOME = process.env.FLO_HOME || join(homedir(), '.flo');
13
+ const AGENTS_PATH = join(FLO_HOME, 'agents.jsonl');
14
+
15
+ const HEALTH_STALE_MS = 5 * 60_000; // an agent without a heartbeat for 5min counts as 'stale'
16
+
17
+ export const STATUSES = ['idle', 'busy', 'completed', 'failed', 'stopped'];
18
+
19
+ async function ensureHome() {
20
+ if (!existsSync(FLO_HOME)) await mkdir(FLO_HOME, { recursive: true });
21
+ }
22
+
23
+ function newId() {
24
+ return `a-${Date.now()}-${randomBytes(2).toString('hex')}`;
25
+ }
26
+
27
+ async function appendEvent(event) {
28
+ await ensureHome();
29
+ await appendFile(AGENTS_PATH, JSON.stringify(event) + '\n', 'utf8');
30
+ }
31
+
32
+ async function readEvents() {
33
+ if (!existsSync(AGENTS_PATH)) return [];
34
+ let raw = '';
35
+ try { raw = await readFile(AGENTS_PATH, 'utf8'); } catch { return []; }
36
+ const out = [];
37
+ for (const line of raw.split(/\r?\n/)) {
38
+ if (!line.trim()) continue;
39
+ try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ export async function spawnAgent({ type, name, role, tags, parent, metadata }) {
45
+ if (!type) throw new Error('spawnAgent: type is required');
46
+ const id = newId();
47
+ const now = new Date().toISOString();
48
+ const event = {
49
+ id,
50
+ op: 'spawn',
51
+ ts: now,
52
+ type: String(type),
53
+ name: name || id,
54
+ role: role || null,
55
+ tags: Array.isArray(tags) ? tags.map(String) : [],
56
+ parent: parent || null,
57
+ metadata: metadata && typeof metadata === 'object' ? metadata : {},
58
+ status: 'idle',
59
+ spawnedAt: now,
60
+ lastHeartbeat: now,
61
+ };
62
+ await appendEvent(event);
63
+ return materializeOne([event]);
64
+ }
65
+
66
+ export async function updateAgent({ id, ...patch }) {
67
+ if (!id) throw new Error('updateAgent: id is required');
68
+ const ts = new Date().toISOString();
69
+ const event = { id, op: 'update', ts, ...patch, lastHeartbeat: patch.lastHeartbeat || ts };
70
+ await appendEvent(event);
71
+ const all = await listAgents();
72
+ return all.find((a) => a.id === id) || null;
73
+ }
74
+
75
+ export async function heartbeat(id) {
76
+ return updateAgent({ id, lastHeartbeat: new Date().toISOString() });
77
+ }
78
+
79
+ export async function stopAgent(id) {
80
+ return updateAgent({ id, status: 'stopped', stoppedAt: new Date().toISOString() });
81
+ }
82
+
83
+ export async function deleteAgent(id) {
84
+ if (!id) throw new Error('deleteAgent: id is required');
85
+ await appendEvent({ id, op: 'delete', ts: new Date().toISOString() });
86
+ }
87
+
88
+ function materializeOne(events) {
89
+ const map = new Map();
90
+ for (const e of events) {
91
+ if (e.op === 'delete') {
92
+ map.delete(e.id);
93
+ continue;
94
+ }
95
+ if (e.op === 'spawn') {
96
+ const { op, ts, ...record } = e;
97
+ map.set(e.id, record);
98
+ } else if (e.op === 'update') {
99
+ const existing = map.get(e.id);
100
+ if (!existing) continue;
101
+ const { op, ts, ...patch } = e;
102
+ map.set(e.id, { ...existing, ...patch });
103
+ }
104
+ }
105
+ return [...map.values()][0] || null;
106
+ }
107
+
108
+ export async function listAgents({ status, type, includeStopped = true } = {}) {
109
+ const events = await readEvents();
110
+ const map = new Map();
111
+ for (const e of events) {
112
+ if (e.op === 'delete') { map.delete(e.id); continue; }
113
+ if (e.op === 'spawn') {
114
+ const { op, ts, ...record } = e;
115
+ map.set(e.id, record);
116
+ } else if (e.op === 'update') {
117
+ const existing = map.get(e.id);
118
+ if (!existing) continue;
119
+ const { op, ts, ...patch } = e;
120
+ map.set(e.id, { ...existing, ...patch });
121
+ }
122
+ }
123
+ let agents = [...map.values()];
124
+ if (!includeStopped) agents = agents.filter((a) => a.status !== 'stopped');
125
+ if (status) agents = agents.filter((a) => a.status === status);
126
+ if (type) agents = agents.filter((a) => a.type === type);
127
+ return agents.sort((a, b) => (a.spawnedAt < b.spawnedAt ? 1 : -1));
128
+ }
129
+
130
+ export async function getAgent(id) {
131
+ const all = await listAgents();
132
+ return all.find((a) => a.id === id) || null;
133
+ }
134
+
135
+ export async function agentHealth() {
136
+ const agents = await listAgents({ includeStopped: false });
137
+ const now = Date.now();
138
+ return agents.map((a) => {
139
+ const lastHb = a.lastHeartbeat ? Date.parse(a.lastHeartbeat) : 0;
140
+ const ageMs = now - lastHb;
141
+ return {
142
+ id: a.id,
143
+ name: a.name,
144
+ type: a.type,
145
+ status: a.status,
146
+ lastHeartbeat: a.lastHeartbeat,
147
+ heartbeatAgeMs: ageMs,
148
+ health: ageMs < HEALTH_STALE_MS ? 'healthy' : 'stale',
149
+ };
150
+ });
151
+ }
152
+
153
+ export const _internal = { AGENTS_PATH, HEALTH_STALE_MS };