moflo 4.10.3 → 4.10.5
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/.claude/guidance/shipped/moflo-core-guidance.md +16 -0
- package/README.md +20 -19
- package/bin/lib/daemon-recycler.mjs +203 -0
- package/bin/session-start-launcher.mjs +157 -76
- package/dist/src/cli/commands/daemon.js +21 -17
- package/dist/src/cli/commands/doctor-checks-config.js +98 -20
- package/dist/src/cli/commands/doctor-fixes.js +43 -0
- package/dist/src/cli/init/settings-generator.js +1 -1
- package/dist/src/cli/services/hook-block-hash.js +1 -1
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -6,6 +6,22 @@
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## Runtime Target: Claude Code Only
|
|
10
|
+
|
|
11
|
+
**MoFlo targets Anthropic's Claude Code exclusively** — the CLI, IDE extensions (VS Code, JetBrains), and the web app at claude.ai/code. It is not a Claude Desktop integration and is never installed into Claude Desktop's config tree.
|
|
12
|
+
|
|
13
|
+
| Tool | Status | Config paths moflo touches |
|
|
14
|
+
|------|--------|----------------------------|
|
|
15
|
+
| Claude Code (CLI, IDE extensions, web) | Sole supported target | `<project>/.mcp.json`, `<project>/.claude/settings.json`, `<project>/.moflo/`, `<project>/moflo.yaml` |
|
|
16
|
+
| Claude Desktop (the macOS/Windows app) | **Out of scope — never** | None — moflo neither reads nor writes Claude Desktop config |
|
|
17
|
+
| Other MCP-capable clients (Cline, Continue, etc.) | Unsupported, may incidentally work | None — bug reports require Claude Code reproduction |
|
|
18
|
+
|
|
19
|
+
**Never** introduce a code path, search list, fixture, or doc that reads from or writes to `~/.claude/claude_desktop_config.json`, `~/Library/Application Support/Claude/`, or `%APPDATA%/Claude/`. Those are Claude **Desktop** paths. Including them in moflo's MCP-config search list caused issue #1126: a parseable Claude Desktop preferences file outranked a malformed project `.mcp.json`, masking the real failure and routing the auto-fixer down a no-op branch.
|
|
20
|
+
|
|
21
|
+
The one ambiguous-looking path is Claude Code's user-level config at `~/.claude.json` (where `claude mcp add` writes). MoFlo doesn't author or rewrite that file either; the project-local `.mcp.json` written by `flo init` is the canonical surface moflo owns end-to-end.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
9
25
|
## Getting Started
|
|
10
26
|
|
|
11
27
|
### Installation
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
## TL;DR
|
|
10
10
|
|
|
11
|
-
MoFlo makes
|
|
11
|
+
MoFlo makes Claude Code remember what it learns, check what it knows before exploring files, and get smarter over time — all automatically. Install it, run `flo init`, restart Claude Code, and everything just works: your docs and code are indexed on session start so Claude can search them instantly, gates prevent Claude from wasting tokens on blind exploration, task outcomes feed back into routing so it picks the right agent type next time, and context depletion warnings tell you when to start a fresh session. No configuration, no API keys, no cloud services — it all runs locally on your machine.
|
|
12
12
|
|
|
13
13
|
## Quickstart
|
|
14
14
|
|
|
@@ -17,7 +17,7 @@ npm install --save-dev moflo
|
|
|
17
17
|
flo init
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
Restart Claude Code
|
|
20
|
+
Restart Claude Code. That's it — memory, indexing, gates, and routing are all active.
|
|
21
21
|
|
|
22
22
|
Or — just ask Claude to install MoFlo into your project and initialize it!
|
|
23
23
|
|
|
@@ -44,7 +44,7 @@ MoFlo makes deliberate choices so you don't have to:
|
|
|
44
44
|
- **Task registration before agents** — Sub-agents can't spawn until work is tracked. Prevents runaway agent proliferation.
|
|
45
45
|
- **Learned routing** — Task outcomes feed back into the routing system automatically. No manual configuration needed — it gets smarter with use.
|
|
46
46
|
- **Incremental indexing** — Guidance and code map indexes run on every session start but skip unchanged files. Fast after the first run.
|
|
47
|
-
- **
|
|
47
|
+
- **Claude Code is the only target** — MoFlo is built and shipped for Anthropic's Claude Code (CLI, IDE extensions, web). It is **not** a Claude Desktop integration: nothing reads from or writes to `~/.claude/claude_desktop_config.json` or `%APPDATA%/Claude/`, and adding paths there is always a bug. The MCP tools, memory system, and hooks could in principle work with any MCP-capable client, but Claude Code is the *only* surface we author for, test against, or accept bug reports on.
|
|
48
48
|
- **GitHub-oriented** — The `/flo` skill, PR automation, and issue tracking are built around GitHub. With Claude's help, you can adapt them to your own issue tracker and source control system.
|
|
49
49
|
- **Cross-platform** — Works identically on macOS, Linux, and Windows.
|
|
50
50
|
|
|
@@ -185,7 +185,7 @@ MoFlo automatically indexes three types of content on every session start, so yo
|
|
|
185
185
|
|
|
186
186
|
### How it works
|
|
187
187
|
|
|
188
|
-
1. **Session start hook** — When
|
|
188
|
+
1. **Session start hook** — When Claude Code starts a new session, MoFlo's `SessionStart` hook launches the indexers sequentially in a single background process. This runs silently — you can start working immediately.
|
|
189
189
|
2. **Incremental** — Each indexer tracks file modification times. Only files that changed since the last index run are re-processed. The first run on a large codebase may take a minute or two; subsequent runs typically finish in under a second.
|
|
190
190
|
3. **Embedding generation** — Guidance chunks are embedded using MiniLM-L6-v2 (384 dimensions, WASM). These vectors are stored in the SQLite memory database and used for semantic search.
|
|
191
191
|
4. **No blocking** — The indexers run in the background and don't block your session from starting. You can begin working immediately.
|
|
@@ -211,9 +211,9 @@ flo memory refresh # Reindex everything + rebuild embeddings + vacuum
|
|
|
211
211
|
|
|
212
212
|
### Why this matters
|
|
213
213
|
|
|
214
|
-
Without auto-indexing,
|
|
214
|
+
Without auto-indexing, Claude Code starts every session with a blank slate — it doesn't know what documentation exists, where code lives, or what tests cover which files. It resorts to Glob/Grep exploration, which burns tokens and context window on rediscovery.
|
|
215
215
|
|
|
216
|
-
With auto-indexing,
|
|
216
|
+
With auto-indexing, Claude can search semantically ("how does auth work?") and get relevant documentation chunks ranked by similarity, or ask "where is the user model defined?" and get a direct answer from the code map — all without touching the filesystem.
|
|
217
217
|
|
|
218
218
|
## The Gate System
|
|
219
219
|
|
|
@@ -261,7 +261,7 @@ You can also disable individual hooks in `.claude/settings.json` by removing the
|
|
|
261
261
|
|
|
262
262
|
## The `/flo` Skill
|
|
263
263
|
|
|
264
|
-
Inside
|
|
264
|
+
Inside Claude Code, the `/flo` (or `/fl`) slash command drives GitHub issue execution:
|
|
265
265
|
|
|
266
266
|
```
|
|
267
267
|
/flo <issue> # Full process (research → implement → test → PR)
|
|
@@ -272,7 +272,7 @@ Inside your AI client, the `/flo` (or `/fl`) slash command drives GitHub issue e
|
|
|
272
272
|
/flo -n <issue> # Normal mode (default, single agent, no swarm)
|
|
273
273
|
```
|
|
274
274
|
|
|
275
|
-
For full options and details, type `/flo` with no arguments —
|
|
275
|
+
For full options and details, type `/flo` with no arguments — Claude Code will display the complete skill documentation. Also available as `/fl`.
|
|
276
276
|
|
|
277
277
|
### Epic handling
|
|
278
278
|
|
|
@@ -313,7 +313,7 @@ flo epic reset 42 # Reset state for re-run
|
|
|
313
313
|
|
|
314
314
|
### What are spells?
|
|
315
315
|
|
|
316
|
-
Spells are declarative YAML automations composed of pluggable step commands. They exist because shell scripts drift, ad-hoc prompts aren't reproducible, and CI/CD pipelines are the wrong tool for local automation. A spell is **deterministic** (same inputs → same steps), **reviewable** (a YAML file you read like a recipe), and **replayable** (re-cast it tomorrow and it behaves the same). Spells run from the CLI (`flo spell cast`), from an MCP tool call inside
|
|
316
|
+
Spells are declarative YAML automations composed of pluggable step commands. They exist because shell scripts drift, ad-hoc prompts aren't reproducible, and CI/CD pipelines are the wrong tool for local automation. A spell is **deterministic** (same inputs → same steps), **reviewable** (a YAML file you read like a recipe), and **replayable** (re-cast it tomorrow and it behaves the same). Spells run from the CLI (`flo spell cast`), from an MCP tool call inside Claude Code, or on a schedule.
|
|
317
317
|
|
|
318
318
|
### How do they work?
|
|
319
319
|
|
|
@@ -425,7 +425,7 @@ For full configuration (`scheduler:` block in `moflo.yaml`), event types, and th
|
|
|
425
425
|
|
|
426
426
|
### Building spells with the `/spell-builder` skill
|
|
427
427
|
|
|
428
|
-
Inside
|
|
428
|
+
Inside Claude Code, use the `/spell-builder` skill to create, edit, and validate spell definitions interactively. The skill understands the full spell schema and available step commands, so you can describe what you want in natural language and it will generate the YAML:
|
|
429
429
|
|
|
430
430
|
```
|
|
431
431
|
/spell-builder # Start the spell builder
|
|
@@ -507,7 +507,7 @@ flo diagnose --json # JSON output for CI/automation
|
|
|
507
507
|
|
|
508
508
|
#### `flo healer` — Health Check
|
|
509
509
|
|
|
510
|
-
`flo healer` runs
|
|
510
|
+
`flo healer` runs 38 parallel health checks against your environment and reports pass/warn/fail for each:
|
|
511
511
|
|
|
512
512
|
| Check | What it verifies |
|
|
513
513
|
|-------|-----------------|
|
|
@@ -549,7 +549,8 @@ flo diagnose --json # JSON output for CI/automation
|
|
|
549
549
|
| Missing config file | Runs `config init` to generate defaults |
|
|
550
550
|
| Status line not wired | Adds `statusLine` config block to `.claude/settings.json` |
|
|
551
551
|
| Stale daemon lock | Removes stale `.moflo/daemon.lock` and restarts daemon |
|
|
552
|
-
|
|
|
552
|
+
| Malformed `.mcp.json` (e.g. unescaped Windows paths) | Backs up the broken file as `.mcp.json.malformed-<ts>`, regenerates a clean one, verifies it parses (#1126) |
|
|
553
|
+
| MCP server missing from a valid `.mcp.json` | Runs `claude mcp add moflo` to register the server |
|
|
553
554
|
| Claude Code CLI missing | Installs `@anthropic-ai/claude-code` globally |
|
|
554
555
|
| Zombie processes | Kills orphaned MoFlo processes (tracked + OS-level scan) |
|
|
555
556
|
|
|
@@ -658,7 +659,7 @@ These are the backend systems that hooks and commands interact with.
|
|
|
658
659
|
| **EWC++ Consolidation** | Elastic Weight Consolidation that prevents catastrophic forgetting | New learning doesn't overwrite patterns from earlier sessions | Yes |
|
|
659
660
|
| **Session Persistence** | Stop hook exports session metrics; SessionStart hook restores prior state | Patterns learned on Monday are available on Friday | Yes |
|
|
660
661
|
| **Status Line** | Live dashboard showing git branch, session state, memory stats, MCP status | At-a-glance visibility into what MoFlo is doing | Yes |
|
|
661
|
-
| **MCP Tool Server** |
|
|
662
|
+
| **MCP Tool Server** | 80+ MCP tools for memory, hooks, coordination, spells, swarm, etc. (schemas deferred by default) | Lets Claude Code call MoFlo functionality directly | Yes (deferred) |
|
|
662
663
|
|
|
663
664
|
### Systems (available but off by default)
|
|
664
665
|
|
|
@@ -670,11 +671,11 @@ These are the backend systems that hooks and commands interact with.
|
|
|
670
671
|
|
|
671
672
|
## The Two-Layer Task System
|
|
672
673
|
|
|
673
|
-
MoFlo doesn't replace
|
|
674
|
+
MoFlo doesn't replace Claude Code's task system — it wraps it. Claude Code handles spawning agents and running code. MoFlo adds a coordination layer on top that handles memory, routing, and learning.
|
|
674
675
|
|
|
675
676
|
```
|
|
676
677
|
┌──────────────────────────────────────────────────┐
|
|
677
|
-
│
|
|
678
|
+
│ CLAUDE CODE (Execution Layer) │
|
|
678
679
|
│ Spawns agents, runs code, streams output │
|
|
679
680
|
│ TaskCreate → Agent → TaskUpdate → results │
|
|
680
681
|
├──────────────────────────────────────────────────┤
|
|
@@ -688,14 +689,14 @@ Here's how a typical task flows through both layers:
|
|
|
688
689
|
|
|
689
690
|
1. **MoFlo routes** — Before work starts, MoFlo analyzes the prompt and recommends an agent type and model tier via hook or MCP tool.
|
|
690
691
|
2. **MoFlo gates** — Before an agent can spawn, MoFlo verifies that memory was searched and a task was registered. This prevents blind exploration.
|
|
691
|
-
3. **
|
|
692
|
+
3. **Claude Code executes** — The actual agent runs through Claude Code's native task system. MoFlo doesn't manage the agent — Claude Code handles execution, output, and completion.
|
|
692
693
|
4. **MoFlo learns** — After the agent finishes, MoFlo records what worked (or didn't) in its memory database. Successful patterns feed into future routing.
|
|
693
694
|
|
|
694
|
-
The key insight: **
|
|
695
|
+
The key insight: **Claude Code handles execution, MoFlo handles knowledge.** Claude Code is good at spawning agents and running code. MoFlo is good at remembering what happened, routing to the right agent, and ensuring prior knowledge is checked before exploring from scratch.
|
|
695
696
|
|
|
696
|
-
For complex work, MoFlo structures tasks into waves — a research wave discovers context, then an implementation wave acts on it — with dependencies tracked through both
|
|
697
|
+
For complex work, MoFlo structures tasks into waves — a research wave discovers context, then an implementation wave acts on it — with dependencies tracked through both Claude Code's task system and MoFlo's coordination layer. The full integration pattern is documented in `.claude/guidance/moflo-claude-swarm-cohesion.md`.
|
|
697
698
|
|
|
698
|
-
The `/flo` skill ties both systems together for GitHub issues — driving the full process (research → enhance → implement → test → simplify → PR) with
|
|
699
|
+
The `/flo` skill ties both systems together for GitHub issues — driving the full process (research → enhance → implement → test → simplify → PR) with Claude Code's agents for execution and MoFlo's memory for continuity.
|
|
699
700
|
|
|
700
701
|
### Intelligent Agent Routing
|
|
701
702
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Detached recycler for §2a of session-start-launcher.mjs.
|
|
4
|
+
*
|
|
5
|
+
* The launcher used to inline the kill-and-restart synchronously, which kept
|
|
6
|
+
* up to 500ms of liveness-polling in the foreground — fine on Linux, but on
|
|
7
|
+
* Windows under the SessionStart hook's 3000ms timeout it eroded the budget
|
|
8
|
+
* that's supposed to be spent on real work. Per the launcher's contract
|
|
9
|
+
* ("spawns background tasks via spawn(detached + unref) and exits
|
|
10
|
+
* immediately"), the daemon recycle belongs in a detached worker.
|
|
11
|
+
*
|
|
12
|
+
* Invocation (from §2a, via fireAndForget):
|
|
13
|
+
* node bin/lib/daemon-recycler.mjs <projectRoot> <pid> <installedVersion>
|
|
14
|
+
*
|
|
15
|
+
* Steps:
|
|
16
|
+
* 1. Force-kill <pid> (Windows: taskkill /F /T, Unix: SIGKILL). Skip
|
|
17
|
+
* graceful — by this point the launcher has already decided the daemon
|
|
18
|
+
* is running stale code and its shutdown handlers are stale too.
|
|
19
|
+
* 2. Poll liveness up to 5s. Unlink the lockfile only once the PID is gone,
|
|
20
|
+
* so a surviving daemon can't re-attach to the unlinked path.
|
|
21
|
+
* 3. Spawn `node node_modules/moflo/bin/cli.js daemon start --quiet`
|
|
22
|
+
* detached + unref so this recycler can exit immediately.
|
|
23
|
+
*
|
|
24
|
+
* Output is intentionally silent — there's no parent to read it. Failures are
|
|
25
|
+
* surfaced via `.moflo/daemon-recycle.last.json` for `flo doctor` to read.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
29
|
+
import { existsSync, openSync, closeSync, unlinkSync, writeFileSync, readFileSync } from 'node:fs';
|
|
30
|
+
import { resolve, join } from 'node:path';
|
|
31
|
+
|
|
32
|
+
const [, , projectRootArg, pidArg, installedVersion] = process.argv;
|
|
33
|
+
|
|
34
|
+
if (!projectRootArg || !pidArg) {
|
|
35
|
+
// No way to surface this — the launcher fire-and-forgets us, no parent
|
|
36
|
+
// captures stderr. Bail silently.
|
|
37
|
+
process.exit(2);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const projectRoot = resolve(projectRootArg);
|
|
41
|
+
const pid = Number.parseInt(pidArg, 10);
|
|
42
|
+
const lockFile = join(projectRoot, '.moflo', 'daemon.lock');
|
|
43
|
+
|
|
44
|
+
// EPERM means "exists but owned by another user" — treat as alive (matches
|
|
45
|
+
// launcher's isDaemonPidAlive contract). ESRCH means "no such process" — dead.
|
|
46
|
+
//
|
|
47
|
+
// Linux zombie handling: on Linux, `kill(pid, 0)` succeeds for zombie processes
|
|
48
|
+
// (exited but not yet reaped). A zombie can't write to the DB or hold locks, so
|
|
49
|
+
// treating it as alive exhausts the 5s kill budget polling a corpse. Read
|
|
50
|
+
// /proc/<pid>/stat and treat 'Z' as dead — same logic the launcher uses (#1083).
|
|
51
|
+
function isAlive(p) {
|
|
52
|
+
if (!p || p <= 0) return false;
|
|
53
|
+
try {
|
|
54
|
+
process.kill(p, 0);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return err && err.code === 'EPERM';
|
|
57
|
+
}
|
|
58
|
+
if (process.platform === 'linux') {
|
|
59
|
+
try {
|
|
60
|
+
const stat = readFileSync(`/proc/${p}/stat`, 'utf-8');
|
|
61
|
+
const lastParen = stat.lastIndexOf(')');
|
|
62
|
+
if (lastParen !== -1 && stat.charAt(lastParen + 2) === 'Z') return false;
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (err && err.code === 'ENOENT') return false;
|
|
65
|
+
// /proc unavailable — fall through with the kill(0) verdict.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sleepSyncMs(ms) {
|
|
72
|
+
const buf = new Int32Array(new SharedArrayBuffer(4));
|
|
73
|
+
Atomics.wait(buf, 0, 0, ms);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function writeOutcome(status, detail) {
|
|
77
|
+
try {
|
|
78
|
+
writeFileSync(
|
|
79
|
+
join(projectRoot, '.moflo', 'daemon-recycle.last.json'),
|
|
80
|
+
JSON.stringify(
|
|
81
|
+
{
|
|
82
|
+
status,
|
|
83
|
+
detail,
|
|
84
|
+
pid,
|
|
85
|
+
installedVersion: installedVersion ?? null,
|
|
86
|
+
completedAt: new Date().toISOString(),
|
|
87
|
+
},
|
|
88
|
+
null,
|
|
89
|
+
2,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
} catch { /* best-effort — doctor reads this file optionally */ }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── 0. Single-recycler advisory lock ────────────────────────────────────────
|
|
96
|
+
// Two session starts within the same second can both fire §2a, both detect
|
|
97
|
+
// behind, both spawn this recycler against the same PID. Without the lock,
|
|
98
|
+
// both call `daemon start` and race for daemon-lock acquisition — only one
|
|
99
|
+
// daemon wins but the other wastes a spawn cycle. Use O_EXCL on a sentinel
|
|
100
|
+
// file so the second invocation exits early.
|
|
101
|
+
const recycleLock = join(projectRoot, '.moflo', 'recycle.lock');
|
|
102
|
+
let lockFd;
|
|
103
|
+
let lockAcquired = false;
|
|
104
|
+
try {
|
|
105
|
+
lockFd = openSync(recycleLock, 'wx'); // O_CREAT | O_EXCL
|
|
106
|
+
lockAcquired = true;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
if (err && err.code === 'EEXIST') {
|
|
109
|
+
// Another recycler is mid-flight. Bail silently — it will handle the kill.
|
|
110
|
+
writeOutcome('already-running', `another recycler holds ${recycleLock}`);
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
// Unexpected — proceed without the lock rather than blocking the recycle.
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Release the advisory lock on every exit path, including process.exit() and
|
|
117
|
+
// crashes. Idempotent: if the lock wasn't acquired this becomes a no-op.
|
|
118
|
+
process.on('exit', () => {
|
|
119
|
+
if (!lockAcquired) return;
|
|
120
|
+
try { closeSync(lockFd); } catch { /* already closed */ }
|
|
121
|
+
try { unlinkSync(recycleLock); } catch { /* already gone */ }
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── 1. Force-kill ───────────────────────────────────────────────────────────
|
|
125
|
+
// EPERM on the kill attempt means the daemon is owned by another user. Can't
|
|
126
|
+
// kill it. Don't proceed to unlink + restart — that'd resurrect a fresh daemon
|
|
127
|
+
// alongside the foreign-owned one, double-writing the DB.
|
|
128
|
+
let killBlockedByEperm = false;
|
|
129
|
+
if (Number.isFinite(pid) && pid > 0 && isAlive(pid)) {
|
|
130
|
+
try {
|
|
131
|
+
if (process.platform === 'win32') {
|
|
132
|
+
execFileSync('taskkill', ['/F', '/T', '/PID', String(pid)], { windowsHide: true, timeout: 5000 });
|
|
133
|
+
} else {
|
|
134
|
+
process.kill(pid, 'SIGKILL');
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (err && (err.code === 'EPERM' || err.code === 'EACCES')) {
|
|
138
|
+
killBlockedByEperm = true;
|
|
139
|
+
}
|
|
140
|
+
// Other errors (ESRCH = already dead) — fall through; liveness poll confirms.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (killBlockedByEperm) {
|
|
145
|
+
writeOutcome('kill-permission-denied', `PID ${pid} owned by another user — leaving daemon alive, not spawning replacement`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── 2. Wait for death, then unlink the lockfile ─────────────────────────────
|
|
150
|
+
const deadline = Date.now() + 5000;
|
|
151
|
+
let killed = !isAlive(pid);
|
|
152
|
+
while (!killed && Date.now() < deadline) {
|
|
153
|
+
sleepSyncMs(100);
|
|
154
|
+
killed = !isAlive(pid);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!killed) {
|
|
158
|
+
writeOutcome('kill-failed', `PID ${pid} survived 5s force-kill window`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Only unlink once we know nothing's holding the lock file's old identity.
|
|
163
|
+
// A surviving daemon would re-write a lockfile with its stale PID + version
|
|
164
|
+
// and defeat the whole purpose of the recycle.
|
|
165
|
+
try {
|
|
166
|
+
if (existsSync(lockFile)) {
|
|
167
|
+
// Defensive: if the lockfile has been re-written under us (another
|
|
168
|
+
// recycler raced), only unlink if the PID still matches what we killed.
|
|
169
|
+
try {
|
|
170
|
+
const current = JSON.parse(readFileSync(lockFile, 'utf-8'));
|
|
171
|
+
if (typeof current?.pid === 'number' && current.pid !== pid) {
|
|
172
|
+
writeOutcome('lock-changed', `another daemon (PID ${current.pid}) wrote the lock; leaving it alone`);
|
|
173
|
+
process.exit(0);
|
|
174
|
+
}
|
|
175
|
+
} catch { /* unreadable / malformed — fall through and unlink */ }
|
|
176
|
+
unlinkSync(lockFile);
|
|
177
|
+
}
|
|
178
|
+
} catch { /* non-fatal */ }
|
|
179
|
+
|
|
180
|
+
// ── 3. Spawn fresh daemon, detached + unref ─────────────────────────────────
|
|
181
|
+
const cliPath = join(projectRoot, 'node_modules', 'moflo', 'bin', 'cli.js');
|
|
182
|
+
if (existsSync(cliPath)) {
|
|
183
|
+
try {
|
|
184
|
+
const child = spawn('node', [cliPath, 'daemon', 'start', '--quiet'], {
|
|
185
|
+
cwd: projectRoot,
|
|
186
|
+
stdio: 'ignore',
|
|
187
|
+
detached: true,
|
|
188
|
+
shell: false,
|
|
189
|
+
windowsHide: true,
|
|
190
|
+
});
|
|
191
|
+
child.unref();
|
|
192
|
+
writeOutcome('ok', 'fresh daemon spawn requested');
|
|
193
|
+
} catch (err) {
|
|
194
|
+
writeOutcome('spawn-failed', err && err.message ? err.message : String(err));
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
writeOutcome('cli-missing', `node_modules/moflo/bin/cli.js not present at ${cliPath}`);
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Recycler's job is done. Exit fast.
|
|
203
|
+
process.exit(0);
|
|
@@ -432,41 +432,49 @@ function stopDaemon(lockFile) {
|
|
|
432
432
|
|
|
433
433
|
let killed = false;
|
|
434
434
|
if (stalePid !== null && isDaemonPidAlive(stalePid)) {
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
const gracefulDeadline = Date.now() + 3000;
|
|
450
|
-
while (Date.now() < gracefulDeadline) {
|
|
451
|
-
if (!isDaemonPidAlive(stalePid)) { killed = true; break; }
|
|
452
|
-
sleepSyncMs(100);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Force-kill if still alive.
|
|
456
|
-
if (!killed) {
|
|
435
|
+
// Platform-split shutdown. On Linux/macOS, SIGTERM lets the daemon's
|
|
436
|
+
// shutdown handler run a final sql.js dump + lock release before we
|
|
437
|
+
// escalate.
|
|
438
|
+
//
|
|
439
|
+
// On Windows there is no SIGTERM equivalent for our headless detached
|
|
440
|
+
// Node daemon — `taskkill /PID` (no /F) sends a window-close message
|
|
441
|
+
// that a non-GUI process can't receive and always fails with the visible
|
|
442
|
+
// error 'process can only be terminated forcefully'. The prior
|
|
443
|
+
// implementation invoked it anyway, swallowed the error, then polled
|
|
444
|
+
// alive for 3s before escalating — exactly the time-waste that pushed
|
|
445
|
+
// §3's stopDaemon past the 3000ms SessionStart hook timeout. Go
|
|
446
|
+
// straight to /F /T (tree-kill, in case a worker child outlived its
|
|
447
|
+
// parent) on Win.
|
|
448
|
+
if (process.platform === 'win32') {
|
|
457
449
|
try {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
process.kill(stalePid, 'SIGKILL');
|
|
462
|
-
}
|
|
463
|
-
} catch { /* dead or unreachable */ }
|
|
464
|
-
// Short grace period for OS reap.
|
|
450
|
+
execFileSync('taskkill', ['/F', '/T', '/PID', String(stalePid)], { windowsHide: true, timeout: 5000 });
|
|
451
|
+
} catch { /* dead or unreachable — liveness poll below confirms */ }
|
|
452
|
+
// Short grace period for OS reap (typically ~ms).
|
|
465
453
|
const forceDeadline = Date.now() + 1000;
|
|
466
454
|
while (Date.now() < forceDeadline) {
|
|
467
455
|
if (!isDaemonPidAlive(stalePid)) { killed = true; break; }
|
|
468
456
|
sleepSyncMs(100);
|
|
469
457
|
}
|
|
458
|
+
} else {
|
|
459
|
+
try { process.kill(stalePid, 'SIGTERM'); } catch { /* signal failed — escalate below */ }
|
|
460
|
+
|
|
461
|
+
// Poll for death up to 3s. The daemon's shutdown handler does a final
|
|
462
|
+
// sql.js dump + lock release, which under load can take ~1s.
|
|
463
|
+
const gracefulDeadline = Date.now() + 3000;
|
|
464
|
+
while (Date.now() < gracefulDeadline) {
|
|
465
|
+
if (!isDaemonPidAlive(stalePid)) { killed = true; break; }
|
|
466
|
+
sleepSyncMs(100);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Force-kill if still alive.
|
|
470
|
+
if (!killed) {
|
|
471
|
+
try { process.kill(stalePid, 'SIGKILL'); } catch { /* dead or unreachable */ }
|
|
472
|
+
const forceDeadline = Date.now() + 1000;
|
|
473
|
+
while (Date.now() < forceDeadline) {
|
|
474
|
+
if (!isDaemonPidAlive(stalePid)) { killed = true; break; }
|
|
475
|
+
sleepSyncMs(100);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
470
478
|
}
|
|
471
479
|
|
|
472
480
|
if (!killed) {
|
|
@@ -499,6 +507,42 @@ function recycleDaemon(lockFile, label) {
|
|
|
499
507
|
return true;
|
|
500
508
|
}
|
|
501
509
|
|
|
510
|
+
// Numeric semver compare. Returns -1 / 0 / +1 for a vs b. Treats missing
|
|
511
|
+
// segments as 0 so '4.10' < '4.10.4'. Strips pre-release tags ('1.2.3-beta'
|
|
512
|
+
// compares as '1.2.3') — close enough for "is the daemon's version behind
|
|
513
|
+
// the installed package's version", which is all §2a needs.
|
|
514
|
+
function compareVersionsSemver(a, b) {
|
|
515
|
+
const norm = (v) => String(v || '').split('-')[0].split('.').map((s) => {
|
|
516
|
+
const n = parseInt(s, 10);
|
|
517
|
+
return Number.isFinite(n) ? n : 0;
|
|
518
|
+
});
|
|
519
|
+
const aa = norm(a);
|
|
520
|
+
const bb = norm(b);
|
|
521
|
+
const len = Math.max(aa.length, bb.length);
|
|
522
|
+
for (let i = 0; i < len; i++) {
|
|
523
|
+
const av = aa[i] ?? 0;
|
|
524
|
+
const bv = bb[i] ?? 0;
|
|
525
|
+
if (av < bv) return -1;
|
|
526
|
+
if (av > bv) return 1;
|
|
527
|
+
}
|
|
528
|
+
return 0;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Resolve `bin/lib/daemon-recycler.mjs` across the three places it can live:
|
|
532
|
+
// 1. node_modules/moflo/bin/lib/ (consumer install, always present)
|
|
533
|
+
// 2. .claude/scripts/lib/ (synced copy in consumer/dogfood projects)
|
|
534
|
+
// 3. bin/lib/ (dogfood source tree)
|
|
535
|
+
// Returns null when not found — §2a falls back to inline force-kill in that
|
|
536
|
+
// case, which is the pre-recycler behavior.
|
|
537
|
+
function resolveDaemonRecyclerPath() {
|
|
538
|
+
const candidates = [
|
|
539
|
+
resolve(projectRoot, 'node_modules/moflo/bin/lib/daemon-recycler.mjs'),
|
|
540
|
+
resolve(projectRoot, '.claude/scripts/lib/daemon-recycler.mjs'),
|
|
541
|
+
resolve(projectRoot, 'bin/lib/daemon-recycler.mjs'),
|
|
542
|
+
];
|
|
543
|
+
return candidates.find((p) => existsSync(p)) || null;
|
|
544
|
+
}
|
|
545
|
+
|
|
502
546
|
// ── 2. Reset workflow state for new session ──────────────────────────────────
|
|
503
547
|
const stateDir = resolve(projectRoot, '.claude');
|
|
504
548
|
const stateFile = resolve(stateDir, 'workflow-state.json');
|
|
@@ -514,6 +558,84 @@ try {
|
|
|
514
558
|
// Non-fatal - workflow gate will use defaults
|
|
515
559
|
}
|
|
516
560
|
|
|
561
|
+
// ── 2a. Recycle daemon when behind installed version (#1054 follow-up) ──────
|
|
562
|
+
// Promoted from §3a-pre to run BEFORE §3's file-sync work. The launcher has
|
|
563
|
+
// a 3000ms SessionStart hook timeout (src/cli/services/hook-block-hash.ts);
|
|
564
|
+
// §0c (DB repair) + §3 (file-sync, manifest, cherry-pick) + stopDaemon's
|
|
565
|
+
// up-to-4s graceful poll routinely exceeds it on upgrade sessions, killing
|
|
566
|
+
// the launcher mid-§3. Result: §3a-pre never ran on the very sessions that
|
|
567
|
+
// needed it, leaving a stale-version daemon alive after `npm install moflo`
|
|
568
|
+
// + Claude restart — `📊 ?` in the statusline (this bug's tell).
|
|
569
|
+
//
|
|
570
|
+
// Semver-BEHIND only — a downgrade-test daemon ahead of installed is left
|
|
571
|
+
// alone. Pre-#1054 daemons (no `version` field in the lock) are treated as
|
|
572
|
+
// behind because by construction they predate version publishing.
|
|
573
|
+
//
|
|
574
|
+
// Force-kill skips the graceful poll: a stale-code daemon's flush handlers
|
|
575
|
+
// are themselves stale, and losing one in-flight flush beats running past
|
|
576
|
+
// the hook timeout. fireAndForget the fresh `daemon start` so spawn returns
|
|
577
|
+
// immediately and the launcher can move on to §3.
|
|
578
|
+
try {
|
|
579
|
+
const mofloPkgPath = resolve(projectRoot, 'node_modules/moflo/package.json');
|
|
580
|
+
const lockFile = resolve(projectRoot, '.moflo', 'daemon.lock');
|
|
581
|
+
// Single readFileSync each (try/catch instead of existsSync + readFileSync)
|
|
582
|
+
// — halves the syscalls in the hot path and closes the TOCTOU window where
|
|
583
|
+
// the file existed for existsSync but was unlinked before readFileSync.
|
|
584
|
+
let installedVersion;
|
|
585
|
+
let daemonVersion;
|
|
586
|
+
let daemonPid;
|
|
587
|
+
try {
|
|
588
|
+
installedVersion = JSON.parse(readFileSync(mofloPkgPath, 'utf-8')).version;
|
|
589
|
+
} catch { /* node_modules/moflo absent — fresh consumer or fatal, nothing §2a can do */ }
|
|
590
|
+
let lockReadOk = false;
|
|
591
|
+
try {
|
|
592
|
+
const lock = JSON.parse(readFileSync(lockFile, 'utf-8'));
|
|
593
|
+
lockReadOk = true;
|
|
594
|
+
if (typeof lock?.version === 'string') daemonVersion = lock.version;
|
|
595
|
+
if (typeof lock?.pid === 'number' && lock.pid > 0) daemonPid = lock.pid;
|
|
596
|
+
} catch { /* no lock or corrupt — no daemon to recycle, skip the block below */ }
|
|
597
|
+
|
|
598
|
+
if (installedVersion && lockReadOk) {
|
|
599
|
+
const isBehind = !daemonVersion || compareVersionsSemver(daemonVersion, installedVersion) < 0;
|
|
600
|
+
if (isBehind) {
|
|
601
|
+
const observed = daemonVersion ?? '<pre-1054 / unknown>';
|
|
602
|
+
const recyclerPath = resolveDaemonRecyclerPath();
|
|
603
|
+
if (recyclerPath && daemonPid && daemonPid > 0) {
|
|
604
|
+
// Fire-and-forget the detached recycler. Per the launcher's contract
|
|
605
|
+
// ("spawns background tasks ... and exits immediately"), the
|
|
606
|
+
// kill+wait+restart sequence runs in a separate process so §2a's
|
|
607
|
+
// foreground cost is ~ms instead of up-to-5s. The recycler writes
|
|
608
|
+
// .moflo/daemon-recycle.last.json on completion for doctor to read.
|
|
609
|
+
fireAndForget(
|
|
610
|
+
'node',
|
|
611
|
+
[recyclerPath, projectRoot, String(daemonPid), installedVersion],
|
|
612
|
+
'daemon-behind-recycle',
|
|
613
|
+
);
|
|
614
|
+
emitMutation(
|
|
615
|
+
'recycled stale daemon',
|
|
616
|
+
`behind: daemon v${observed} → installed v${installedVersion}`,
|
|
617
|
+
);
|
|
618
|
+
} else if (!recyclerPath) {
|
|
619
|
+
// Recycler script missing — happens during the transition release
|
|
620
|
+
// where the launcher upgraded but bin/lib/daemon-recycler.mjs hasn't
|
|
621
|
+
// synced yet. Surface so /healer can flag; §3 below will sync the
|
|
622
|
+
// recycler on this session and §2a covers it on the next.
|
|
623
|
+
emitWarning(
|
|
624
|
+
`daemon-behind recycle: bin/lib/daemon-recycler.mjs not resolvable — ` +
|
|
625
|
+
`daemon v${observed} stays alive this session, will recycle on the next`,
|
|
626
|
+
);
|
|
627
|
+
} else {
|
|
628
|
+
// No PID — lockfile is corrupt or malformed. Unlink it so a fresh
|
|
629
|
+
// daemon can start cleanly on the next worker request.
|
|
630
|
+
try { unlinkSync(lockFile); } catch { /* non-fatal */ }
|
|
631
|
+
emitMutation('cleared malformed daemon lock', `version field: ${observed}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
} catch (err) {
|
|
636
|
+
emitWarning(`daemon-behind check failed: ${errMessage(err)}`);
|
|
637
|
+
}
|
|
638
|
+
|
|
517
639
|
// ── 3. Auto-sync scripts and helpers on version change ───────────────────────
|
|
518
640
|
// Controlled by `auto_update.enabled` in moflo.yaml (default: true).
|
|
519
641
|
// When moflo is upgraded (npm install), scripts and helpers may be stale.
|
|
@@ -1009,53 +1131,12 @@ try {
|
|
|
1009
1131
|
emitWarning(`upgrade section failed (${errMessage(err)})`);
|
|
1010
1132
|
}
|
|
1011
1133
|
|
|
1012
|
-
// ── 3a-pre.
|
|
1013
|
-
// The version
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
1018
|
-
// `[neural-tools] @moflo/embeddings not resolvable` spam (#639) is the
|
|
1019
|
-
// observable symptom of exactly this: a daemon running pre-#592 code that no
|
|
1020
|
-
// longer exists in source, calling a require helper that prints the warning
|
|
1021
|
-
// every time `neural_predict` / `neural_patterns` fires.
|
|
1022
|
-
//
|
|
1023
|
-
// Fix (epic #1054): compare the daemon-lock's reported moflo `version` against
|
|
1024
|
-
// the installed `node_modules/moflo/package.json` version. If they differ —
|
|
1025
|
-
// or the lock predates #1054 and has no `version` field at all — recycle the
|
|
1026
|
-
// daemon. This is exact (not a heuristic margin like the prior mtime-based
|
|
1027
|
-
// check) and named explicitly so the doctor's Daemon Version Skew check
|
|
1028
|
-
// (#1059) can share the diagnosis.
|
|
1029
|
-
//
|
|
1030
|
-
// Pre-#1054 daemons have no `version` in their lock payload — treated as a
|
|
1031
|
-
// mismatch by definition because by construction they were launched before
|
|
1032
|
-
// version publishing existed.
|
|
1033
|
-
try {
|
|
1034
|
-
const mofloPkgPathForRecycle = resolve(projectRoot, 'node_modules/moflo/package.json');
|
|
1035
|
-
const lockFile = resolve(projectRoot, '.moflo', 'daemon.lock');
|
|
1036
|
-
// Cheap stat first — if either file is gone, no skew check is possible.
|
|
1037
|
-
if (existsSync(mofloPkgPathForRecycle) && existsSync(lockFile)) {
|
|
1038
|
-
const installedVersion = JSON.parse(readFileSync(mofloPkgPathForRecycle, 'utf-8')).version;
|
|
1039
|
-
let daemonVersion;
|
|
1040
|
-
try {
|
|
1041
|
-
const lock = JSON.parse(readFileSync(lockFile, 'utf-8'));
|
|
1042
|
-
if (typeof lock?.version === 'string') daemonVersion = lock.version;
|
|
1043
|
-
} catch { /* corrupt lock — recycleDaemon will unlink it */ }
|
|
1044
|
-
if (daemonVersion !== installedVersion) {
|
|
1045
|
-
if (recycleDaemon(lockFile, 'daemon-version-skew')) {
|
|
1046
|
-
const observed = daemonVersion ?? '<pre-1054 / unknown>';
|
|
1047
|
-
emitMutation(
|
|
1048
|
-
'recycled stale daemon',
|
|
1049
|
-
`version skew: installed ${installedVersion}, daemon ${observed}`,
|
|
1050
|
-
);
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
} catch (err) {
|
|
1055
|
-
// Non-fatal; surface via emitWarning per feedback_no_layered_workarounds —
|
|
1056
|
-
// no silent catch on the upgrade path (#854).
|
|
1057
|
-
emitWarning(`daemon version-skew check failed: ${errMessage(err)}`);
|
|
1058
|
-
}
|
|
1134
|
+
// ── 3a-pre. (removed) Daemon-version-skew recycle moved to §2a. ─────────────
|
|
1135
|
+
// The previous version of this block ran AFTER §3's heavy file-sync work,
|
|
1136
|
+
// which routinely exceeded the 3000ms SessionStart hook timeout and was
|
|
1137
|
+
// killed before reaching this point. §2a now runs early and force-kills the
|
|
1138
|
+
// stale daemon before §3 can starve out. Don't restore §3a-pre — keep the
|
|
1139
|
+
// recycle in one place so the two paths can't drift.
|
|
1059
1140
|
|
|
1060
1141
|
// ── 3a. Auto-migrate settings.json (npx flo → node helpers, PATH setup) ────
|
|
1061
1142
|
// Existing users may have stale settings.json with `npx flo` hooks that break
|
|
@@ -470,33 +470,37 @@ export async function killBackgroundDaemon(projectRoot) {
|
|
|
470
470
|
return false;
|
|
471
471
|
}
|
|
472
472
|
try {
|
|
473
|
-
//
|
|
473
|
+
// Platform-split shutdown. On Linux/macOS we try SIGTERM first so the
|
|
474
|
+
// daemon's shutdown handlers (sql.js flush, lock release) can run; force-
|
|
475
|
+
// kill only if it doesn't exit within ~1s.
|
|
476
|
+
//
|
|
477
|
+
// On Windows there is no SIGTERM equivalent for our headless detached
|
|
478
|
+
// Node daemon — `taskkill /PID` (no /F) sends a window-close message
|
|
479
|
+
// that a non-GUI process can't receive, so it always fails with the
|
|
480
|
+
// visible error 'process can only be terminated forcefully'. The prior
|
|
481
|
+
// implementation invoked it anyway, ate the error in a bare catch, then
|
|
482
|
+
// slept 1s before escalating to /F. Skip the dead step: go straight to
|
|
483
|
+
// /F /T (tree-kill, in case a worker child outlived its parent) on Win.
|
|
474
484
|
if (process.platform === 'win32') {
|
|
475
|
-
// SIGTERM silently force-kills on Windows; use taskkill for clean shutdown
|
|
476
485
|
try {
|
|
477
|
-
execFileSync('taskkill', ['/PID', String(holderPid)], { windowsHide: true });
|
|
486
|
+
execFileSync('taskkill', ['/F', '/T', '/PID', String(holderPid)], { windowsHide: true });
|
|
478
487
|
}
|
|
479
488
|
catch {
|
|
480
|
-
//
|
|
489
|
+
// Already exiting / unreachable — process.kill(pid, 0) below verifies.
|
|
481
490
|
}
|
|
482
491
|
}
|
|
483
492
|
else {
|
|
484
493
|
process.kill(holderPid, 'SIGTERM');
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
// Still alive, force kill
|
|
491
|
-
if (process.platform === 'win32') {
|
|
492
|
-
execFileSync('taskkill', ['/F', '/PID', String(holderPid)], { windowsHide: true });
|
|
493
|
-
}
|
|
494
|
-
else {
|
|
494
|
+
// Wait briefly so SIGTERM has a chance to land before checking liveness.
|
|
495
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
496
|
+
try {
|
|
497
|
+
process.kill(holderPid, 0);
|
|
498
|
+
// Still alive — force kill.
|
|
495
499
|
process.kill(holderPid, 'SIGKILL');
|
|
496
500
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
501
|
+
catch {
|
|
502
|
+
// Process terminated
|
|
503
|
+
}
|
|
500
504
|
}
|
|
501
505
|
// Release lock
|
|
502
506
|
releaseDaemonLock(projectRoot, holderPid, true);
|
|
@@ -9,6 +9,7 @@ import os from 'os';
|
|
|
9
9
|
import { getDaemonLockHolder } from '../services/daemon-lock.js';
|
|
10
10
|
import { legacyMemoryDbPath, memoryDbCandidatePaths, memoryDbPath, } from '../services/moflo-paths.js';
|
|
11
11
|
import { probeDbIntegrity } from '../services/memory-db-integrity-repair.js';
|
|
12
|
+
import { findProjectRoot } from '../services/project-root.js';
|
|
12
13
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
13
14
|
export async function checkConfigFile() {
|
|
14
15
|
// JSON configs (parse-validated). LEGACY-CONFIG: `.claude-flow.json` and
|
|
@@ -195,9 +196,9 @@ export async function checkMemoryDbIntegrity(cwd = process.cwd()) {
|
|
|
195
196
|
* Standard MCP-config search paths: home (Claude Desktop on macOS/Linux),
|
|
196
197
|
* XDG config dir, project-local `.mcp.json`, and APPDATA on Windows.
|
|
197
198
|
*
|
|
198
|
-
* Shared by `checkMcpServers` (which inspects
|
|
199
|
-
*
|
|
200
|
-
*
|
|
199
|
+
* Shared by `checkMcpServers` (which inspects configs and reports on moflo
|
|
200
|
+
* presence) and `checkDaemonWriteRouting` (which COUNTS servers across all
|
|
201
|
+
* paths to detect the multi-process-clobber hazard).
|
|
201
202
|
*/
|
|
202
203
|
function mcpConfigSearchPaths(cwd) {
|
|
203
204
|
return [
|
|
@@ -226,25 +227,102 @@ function countMcpServers(cwd) {
|
|
|
226
227
|
}
|
|
227
228
|
return total;
|
|
228
229
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
230
|
+
/**
|
|
231
|
+
* Inspect the project's `.mcp.json` (the file `flo init` writes and Claude
|
|
232
|
+
* Code reads at the project level) and report whether moflo is present,
|
|
233
|
+
* absent, or the file is malformed.
|
|
234
|
+
*
|
|
235
|
+
* Scope: deliberately project-only. We previously also scanned
|
|
236
|
+
* `~/.claude/claude_desktop_config.json` and `%APPDATA%/Claude/...` — Claude
|
|
237
|
+
* **Desktop** paths — which is a separate Anthropic product that doesn't
|
|
238
|
+
* host moflo's MCP server. The old wide scan caused #1126: a Claude
|
|
239
|
+
* Desktop preferences-only APPDATA file outranked a malformed project
|
|
240
|
+
* `.mcp.json`, surfacing "0 servers (flo not found)" while the actually
|
|
241
|
+
* broken project file went unrepaired. Claude Code stores user-level MCP
|
|
242
|
+
* registrations under `~/.claude.json` `projects[<path>].mcpServers`, but
|
|
243
|
+
* that's Claude Code internal state we don't author or rewrite; the
|
|
244
|
+
* project file is the canonical surface moflo owns end-to-end.
|
|
245
|
+
*
|
|
246
|
+
* Multi-writer / cross-ecosystem MCP-process counting still uses the wider
|
|
247
|
+
* `mcpConfigSearchPaths` via `countMcpServers` in
|
|
248
|
+
* `checkDaemonWriteRouting` — that check has a legitimate reason to see
|
|
249
|
+
* Claude Desktop processes.
|
|
250
|
+
*
|
|
251
|
+
* Exported so the auto-fixer (doctor-fixes.ts) can detect a malformed
|
|
252
|
+
* `.mcp.json` and regenerate it. Project root resolves via
|
|
253
|
+
* `findProjectRoot()` so a consumer running `flo healer` from a
|
|
254
|
+
* subdirectory still discovers the project file.
|
|
255
|
+
*/
|
|
256
|
+
export function inspectMcpConfigs(cwd = process.cwd()) {
|
|
257
|
+
// Callers wanting project-root resolution pass it in (see `checkMcpServers`
|
|
258
|
+
// below). Defaulting to `process.cwd()` matches the established pattern in
|
|
259
|
+
// sibling checks (`checkMemoryDbIntegrity`, `countMcpServers`) and avoids a
|
|
260
|
+
// redundant FS walk on every doctor pass.
|
|
261
|
+
const configPath = join(cwd, '.mcp.json');
|
|
262
|
+
// Single read with the existence/parse branches handled by the catch —
|
|
263
|
+
// dropping the pre-`existsSync` guard closes a TOCTOU window where the
|
|
264
|
+
// file is deleted between the check and the read.
|
|
265
|
+
let content;
|
|
266
|
+
try {
|
|
267
|
+
content = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
if (e.code === 'ENOENT') {
|
|
271
|
+
return { status: 'not_found' };
|
|
245
272
|
}
|
|
273
|
+
return { status: 'malformed', path: configPath, parseError: errorDetail(e) };
|
|
274
|
+
}
|
|
275
|
+
const serversValue = content.mcpServers ?? content.servers;
|
|
276
|
+
const servers = (serversValue && typeof serversValue === 'object')
|
|
277
|
+
? serversValue
|
|
278
|
+
: {};
|
|
279
|
+
const count = Object.keys(servers).length;
|
|
280
|
+
const hasMoflo = 'moflo' in servers || 'claude-flow' in servers || 'claude-flow_alpha' in servers;
|
|
281
|
+
if (hasMoflo) {
|
|
282
|
+
return { status: 'valid_with_moflo', path: configPath, count };
|
|
283
|
+
}
|
|
284
|
+
return { status: 'valid_no_moflo', path: configPath, count };
|
|
285
|
+
}
|
|
286
|
+
export async function checkMcpServers(cwd = findProjectRoot()) {
|
|
287
|
+
const result = inspectMcpConfigs(cwd);
|
|
288
|
+
switch (result.status) {
|
|
289
|
+
case 'valid_with_moflo':
|
|
290
|
+
return { name: 'MCP Servers', status: 'pass', message: `${result.count} servers (moflo configured)` };
|
|
291
|
+
case 'valid_no_moflo':
|
|
292
|
+
return {
|
|
293
|
+
name: 'MCP Servers',
|
|
294
|
+
status: 'warn',
|
|
295
|
+
message: `${result.count} servers (moflo not registered)`,
|
|
296
|
+
fix: 'claude mcp add moflo -- npx -y moflo mcp start',
|
|
297
|
+
};
|
|
298
|
+
case 'malformed':
|
|
299
|
+
// #1126: distinguish "config malformed" from "moflo missing". Previously
|
|
300
|
+
// the loop silently caught the JSON.parse error and fell through,
|
|
301
|
+
// reporting "0 servers (flo not found)" — which led users to run
|
|
302
|
+
// `claude mcp add` against a still-broken file that would never parse.
|
|
303
|
+
// Now we surface the parse error and direct them at the auto-fixer
|
|
304
|
+
// that regenerates the file from `generateMCPJson`.
|
|
305
|
+
return {
|
|
306
|
+
name: 'MCP Servers',
|
|
307
|
+
status: 'warn',
|
|
308
|
+
message: `malformed JSON at ${result.path}: ${result.parseError}`,
|
|
309
|
+
fix: 'flo healer --fix -c mcp-servers',
|
|
310
|
+
};
|
|
311
|
+
case 'not_found':
|
|
312
|
+
return {
|
|
313
|
+
name: 'MCP Servers',
|
|
314
|
+
status: 'warn',
|
|
315
|
+
message: 'No MCP config found',
|
|
316
|
+
fix: 'claude mcp add moflo -- npx -y moflo mcp start',
|
|
317
|
+
};
|
|
246
318
|
}
|
|
247
|
-
|
|
319
|
+
// Compile-time exhaustiveness guard: if a future status variant is added to
|
|
320
|
+
// `inspectMcpConfigs`'s return type without a matching case above, this
|
|
321
|
+
// assignment fails to compile (`string` is not assignable to `never`),
|
|
322
|
+
// forcing a corresponding update here. Better than a silent `default:`
|
|
323
|
+
// branch swallowing the new case as if it were `not_found`.
|
|
324
|
+
const _exhaustive = result.status;
|
|
325
|
+
return _exhaustive;
|
|
248
326
|
}
|
|
249
327
|
// Catches three failure modes (#895):
|
|
250
328
|
// 1. File missing — session-start should have created it; warn user that
|
|
@@ -9,9 +9,12 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
|
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { output } from '../output.js';
|
|
11
11
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
12
|
+
import { atomicWriteFileSync } from '../shared/utils/atomic-file-write.js';
|
|
12
13
|
import { repairHookWiring } from '../services/hook-wiring.js';
|
|
13
14
|
import { getDaemonLockHolder } from '../services/daemon-lock.js';
|
|
15
|
+
import { findProjectRoot } from '../services/project-root.js';
|
|
14
16
|
import { findZombieProcesses } from './doctor-zombies.js';
|
|
17
|
+
import { inspectMcpConfigs } from './doctor-checks-config.js';
|
|
15
18
|
import { installClaudeCode, runCommand } from './doctor-checks-runtime.js';
|
|
16
19
|
/** Run a shell command as a fix action. Returns true on exit code 0. */
|
|
17
20
|
async function runFixCommand(cmd) {
|
|
@@ -200,6 +203,46 @@ export async function autoFixCheck(check) {
|
|
|
200
203
|
return runFixCommand('npx moflo embeddings init --force');
|
|
201
204
|
},
|
|
202
205
|
'MCP Servers': async () => {
|
|
206
|
+
// #1126: distinguish "malformed JSON" from "moflo missing from valid
|
|
207
|
+
// config". The previous fix always ran `claude mcp add` — a no-op when
|
|
208
|
+
// the project-local `.mcp.json` was unparseable, because the command
|
|
209
|
+
// doesn't touch malformed project files.
|
|
210
|
+
const projectRoot = findProjectRoot();
|
|
211
|
+
const inspection = inspectMcpConfigs(projectRoot);
|
|
212
|
+
if (inspection.status === 'malformed' && inspection.path) {
|
|
213
|
+
try {
|
|
214
|
+
// Filesystem-safe timestamp: Date.now() is a digit-only integer so
|
|
215
|
+
// no `:` escape needed (per dogfooding.md § 6 cross-platform primitives).
|
|
216
|
+
const backupPath = `${inspection.path}.malformed-${Date.now()}`;
|
|
217
|
+
// The backup is a brand-new file at a unique timestamped path with
|
|
218
|
+
// no concurrent readers — a plain writeFileSync is enough; the
|
|
219
|
+
// atomic ceremony is only worth its cost when replacing a file a
|
|
220
|
+
// running process might re-open mid-write.
|
|
221
|
+
writeFileSync(backupPath, readFileSync(inspection.path, 'utf8'), 'utf-8');
|
|
222
|
+
const { generateMCPJson } = await import('../init/mcp-generator.js');
|
|
223
|
+
const { DEFAULT_INIT_OPTIONS } = await import('../init/types.js');
|
|
224
|
+
const regenerated = generateMCPJson({ ...DEFAULT_INIT_OPTIONS, targetDir: projectRoot });
|
|
225
|
+
// Confirm the regenerated content parses before clobbering the
|
|
226
|
+
// (broken) original — refuse to repair if our own generator emits
|
|
227
|
+
// unparseable JSON for any reason (defense in depth against a future
|
|
228
|
+
// generator regression silently emitting bad escapes again).
|
|
229
|
+
JSON.parse(regenerated);
|
|
230
|
+
// Atomic swap so a concurrent reader (e.g. Claude Code re-scanning
|
|
231
|
+
// .mcp.json during the fix) never sees a truncated file. The
|
|
232
|
+
// Windows-AV-lock verify window inside atomicWriteFileSync (#1015)
|
|
233
|
+
// gates the rename until the new bytes are readable.
|
|
234
|
+
atomicWriteFileSync(inspection.path, regenerated);
|
|
235
|
+
output.writeln(output.dim(` Regenerated ${inspection.path}; backup at ${backupPath}.`));
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
output.writeln(output.warning(` Regeneration failed: ${errorDetail(e)}`));
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Valid config exists but moflo isn't registered — fall back to the
|
|
244
|
+
// claude-cli flow. This is the original behavior for the
|
|
245
|
+
// valid_no_moflo / not_found states.
|
|
203
246
|
return runFixCommand('claude mcp add moflo -- npx -y moflo mcp start');
|
|
204
247
|
},
|
|
205
248
|
'Claude Code CLI': async () => {
|
|
@@ -94,7 +94,7 @@ export function getReferenceHookBlock() {
|
|
|
94
94
|
],
|
|
95
95
|
SessionStart: [
|
|
96
96
|
{
|
|
97
|
-
hooks: [scriptHook('session-start-launcher.mjs',
|
|
97
|
+
hooks: [scriptHook('session-start-launcher.mjs', 5000), autoMemory('import', 8000)],
|
|
98
98
|
},
|
|
99
99
|
],
|
|
100
100
|
Stop: [
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.10.
|
|
3
|
+
"version": "4.10.5",
|
|
4
4
|
"description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
|
|
5
5
|
"main": "dist/src/cli/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
96
96
|
"@typescript-eslint/parser": "^7.18.0",
|
|
97
97
|
"eslint": "^8.0.0",
|
|
98
|
-
"moflo": "^4.10.
|
|
98
|
+
"moflo": "^4.10.4",
|
|
99
99
|
"tsx": "^4.21.0",
|
|
100
100
|
"typescript": "^5.9.3",
|
|
101
101
|
"vitest": "^4.0.0"
|