moflo 4.9.25 → 4.9.27
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/skills/healer/SKILL.md +2 -2
- package/bin/session-start-launcher.mjs +18 -0
- package/dist/src/cli/commands/daemon.js +12 -0
- package/dist/src/cli/commands/doctor-checks-config.js +83 -5
- package/dist/src/cli/commands/doctor-checks-runtime.js +25 -71
- package/dist/src/cli/commands/doctor-fixes.js +0 -22
- package/dist/src/cli/commands/doctor-registry.js +14 -7
- package/dist/src/cli/commands/doctor-version.js +6 -2
- package/dist/src/cli/commands/doctor.js +15 -1
- package/dist/src/cli/epic/runner-adapter.js +47 -3
- package/dist/src/cli/index.js +46 -8
- package/dist/src/cli/mcp-tools/aidefence-moflodb-store.js +9 -3
- package/dist/src/cli/memory/bridge-core.js +38 -3
- package/dist/src/cli/memory/bridge-entries.js +124 -22
- package/dist/src/cli/memory/daemon-write-client.js +217 -0
- package/dist/src/cli/memory/memory-initializer.js +64 -0
- package/dist/src/cli/services/daemon-dashboard.js +28 -2
- package/dist/src/cli/services/daemon-memory-rpc.js +335 -0
- package/dist/src/cli/swarm/message-bus/write-through-adapter.js +30 -2
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: healer
|
|
3
3
|
description: Run moflo's Healer (`flo healer`, alias for `flo doctor`) from inside the Claude session. Audit-only by default; pass `--fix` to apply auto-repairs, `-c <component>` for a single check. Use when something feels off (missing moflo.yaml, daemon dead, statusline empty, hooks not firing) or as a periodic health check. Distinct from Claude Code's built-in `/doctor`, which diagnoses Claude Code itself, not moflo.
|
|
4
|
-
arguments: "[
|
|
4
|
+
arguments: "[options]"
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# /healer — moflo Installation Healer
|
|
@@ -43,7 +43,7 @@ Thin wrapper around the `flo healer` CLI. All check + fix logic lives in the CLI
|
|
|
43
43
|
- **Don't** re-document checks or fixes here. The CLI's `--help` and `src/cli/commands/doctor-*` are the source of truth.
|
|
44
44
|
- **Don't** call `flo doctor` directly — use the `healer` alias for thematic consistency. They're equivalent CLI-side.
|
|
45
45
|
- **Don't** swallow non-zero exit codes silently — surface them in the summary.
|
|
46
|
-
- **Note for users:** Claude Code has its own built-in `/doctor` command that diagnoses Claude Code itself. This skill (`/healer`) diagnoses **moflo**, not Claude Code. The two are complementary, not duplicates
|
|
46
|
+
- **Note for users:** Claude Code has its own built-in `/doctor` command that diagnoses Claude Code itself. This skill (`/healer`) diagnoses **moflo**, not Claude Code. The two are complementary, not duplicates. The healer rolls in the user-actionable parts of `claude doctor` (Claude Code version freshness vs npm latest) into its own `Claude Code CLI` check; the rest of `claude doctor` is a TUI on current releases and must be run interactively if you need its full report.
|
|
47
47
|
|
|
48
48
|
## See Also
|
|
49
49
|
|
|
@@ -447,6 +447,24 @@ try {
|
|
|
447
447
|
'upgraded',
|
|
448
448
|
cachedVersion ? `${cachedVersion} → ${installedVersion}` : `installed ${installedVersion}`,
|
|
449
449
|
);
|
|
450
|
+
// #981 / #987 — one-time architecture notice. Single-writer routing
|
|
451
|
+
// means daemon must be running for safe multi-process writes. Sentinel
|
|
452
|
+
// file ensures the notice fires once per consumer (across upgrades),
|
|
453
|
+
// not on every version bump. `flo doctor` surfaces the runtime warning
|
|
454
|
+
// when the daemon is disabled with MCP configured.
|
|
455
|
+
try {
|
|
456
|
+
const noticeSentinel = join(mofloDir(projectRoot), 'single-writer-notice-shown');
|
|
457
|
+
if (cachedVersion && !existsSync(noticeSentinel)) {
|
|
458
|
+
emitMutation(
|
|
459
|
+
'single-writer write architecture active',
|
|
460
|
+
'memory writes route through the daemon (#981) — keep daemon.auto_start: true to prevent multi-process clobber',
|
|
461
|
+
);
|
|
462
|
+
try {
|
|
463
|
+
mkdirSync(mofloDir(projectRoot), { recursive: true });
|
|
464
|
+
writeFileSync(noticeSentinel, new Date().toISOString());
|
|
465
|
+
} catch { /* sentinel best-effort — re-emit next session if write fails */ }
|
|
466
|
+
}
|
|
467
|
+
} catch { /* never block the upgrade flow on the notice */ }
|
|
450
468
|
} else {
|
|
451
469
|
upgradeNoticeContext = {
|
|
452
470
|
kind: 'repair',
|
|
@@ -90,6 +90,16 @@ const startCommand = {
|
|
|
90
90
|
}
|
|
91
91
|
// Foreground mode: run in current process (blocks terminal)
|
|
92
92
|
try {
|
|
93
|
+
// #981 — mark this process as the daemon BEFORE any storeEntry /
|
|
94
|
+
// deleteEntry call runs in this process. The routing preamble in
|
|
95
|
+
// memory-initializer reads `process.env.MOFLO_IS_DAEMON` per-call (not
|
|
96
|
+
// at module-load time) and skips routing when set, breaking the loop
|
|
97
|
+
// that would otherwise recurse: storeEntry → HTTP → daemon RPC →
|
|
98
|
+
// storeEntry → HTTP. Setting it here covers both direct `flo daemon
|
|
99
|
+
// start --foreground` and the background spawn (whose daemonEnv
|
|
100
|
+
// propagates this via process inheritance — see startBackgroundDaemon
|
|
101
|
+
// below).
|
|
102
|
+
process.env.MOFLO_IS_DAEMON = '1';
|
|
93
103
|
// Acquire atomic daemon lock (prevents duplicate daemons).
|
|
94
104
|
// Always acquire here — even when spawned as a child (CLAUDE_FLOW_DAEMON=1)
|
|
95
105
|
// because on Windows the parent's child.pid is the shell PID (cmd.exe),
|
|
@@ -327,6 +337,8 @@ async function startBackgroundDaemon(projectRoot, quiet, maxCpuLoad, minFreeMemo
|
|
|
327
337
|
const daemonEnv = {
|
|
328
338
|
...process.env,
|
|
329
339
|
CLAUDE_FLOW_DAEMON: '1',
|
|
340
|
+
// #981 — daemon process must skip its own write-routing client.
|
|
341
|
+
MOFLO_IS_DAEMON: '1',
|
|
330
342
|
// Prevent macOS SIGHUP kill when terminal closes
|
|
331
343
|
...(process.platform === 'darwin' ? { NOHUP: '1' } : {}),
|
|
332
344
|
};
|
|
@@ -131,17 +131,43 @@ export async function checkMemoryDatabase() {
|
|
|
131
131
|
}
|
|
132
132
|
return { name: 'Memory Database', status: 'warn', message: 'Not initialized', fix: 'claude-flow memory configure --backend hybrid' };
|
|
133
133
|
}
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Standard MCP-config search paths: home (Claude Desktop on macOS/Linux),
|
|
136
|
+
* XDG config dir, project-local `.mcp.json`, and APPDATA on Windows.
|
|
137
|
+
*
|
|
138
|
+
* Shared by `checkMcpServers` (which inspects the FIRST config it finds and
|
|
139
|
+
* reports on flo presence) and `checkDaemonWriteRouting` (which COUNTS
|
|
140
|
+
* servers across all paths to detect the multi-process-clobber hazard).
|
|
141
|
+
*/
|
|
142
|
+
function mcpConfigSearchPaths(cwd) {
|
|
143
|
+
return [
|
|
136
144
|
join(os.homedir(), '.claude/claude_desktop_config.json'),
|
|
137
145
|
join(os.homedir(), '.config/claude/mcp.json'),
|
|
138
|
-
'.mcp.json',
|
|
139
|
-
// Windows: Claude Desktop stores config under %APPDATA%\Claude\
|
|
146
|
+
join(cwd, '.mcp.json'),
|
|
140
147
|
...(process.platform === 'win32' && process.env.APPDATA
|
|
141
148
|
? [join(process.env.APPDATA, 'Claude', 'claude_desktop_config.json')]
|
|
142
149
|
: []),
|
|
143
150
|
];
|
|
144
|
-
|
|
151
|
+
}
|
|
152
|
+
/** Sum MCP servers across every reachable config. Malformed configs counted as 0. */
|
|
153
|
+
function countMcpServers(cwd) {
|
|
154
|
+
let total = 0;
|
|
155
|
+
for (const configPath of mcpConfigSearchPaths(cwd)) {
|
|
156
|
+
if (!existsSync(configPath))
|
|
157
|
+
continue;
|
|
158
|
+
try {
|
|
159
|
+
const content = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
160
|
+
const servers = content.mcpServers || content.servers || {};
|
|
161
|
+
total += Object.keys(servers).length;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Skip unreadable / malformed config — checkMcpServers reports it.
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return total;
|
|
168
|
+
}
|
|
169
|
+
export async function checkMcpServers() {
|
|
170
|
+
for (const configPath of mcpConfigSearchPaths(process.cwd())) {
|
|
145
171
|
if (existsSync(configPath)) {
|
|
146
172
|
try {
|
|
147
173
|
const content = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
@@ -202,6 +228,58 @@ export async function checkMofloYamlCompliance(cwd = process.cwd()) {
|
|
|
202
228
|
fix: 'Restart Claude Code (yaml-upgrader auto-appends) or `npx moflo init --force`',
|
|
203
229
|
};
|
|
204
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* #981 / #987 — surfaces the single-writer-architecture safety net.
|
|
233
|
+
*
|
|
234
|
+
* When `daemon.auto_start: false` is set in moflo.yaml AND the consumer has
|
|
235
|
+
* an MCP server configured, every MCP-process write hits sql.js directly
|
|
236
|
+
* (no daemon-RPC routing). Pre-#981 multi-process clobber + reader-staleness
|
|
237
|
+
* hazards reappear in that configuration. Warn — never fail — because
|
|
238
|
+
* disabling the daemon is a legitimate consumer choice and the config
|
|
239
|
+
* itself isn't broken.
|
|
240
|
+
*
|
|
241
|
+
* Pass: daemon enabled (default) → routing protection active.
|
|
242
|
+
* Pass: daemon disabled but no MCP server detected → no multi-writer hazard.
|
|
243
|
+
* Warn: daemon disabled AND MCP server detected → hazard window open.
|
|
244
|
+
*/
|
|
245
|
+
export async function checkDaemonWriteRouting(cwd = process.cwd()) {
|
|
246
|
+
const name = 'Daemon Write Routing';
|
|
247
|
+
let daemonEnabled = true; // default-on — matches moflo.yaml default
|
|
248
|
+
try {
|
|
249
|
+
const { loadMofloConfig } = await import('../config/moflo-config.js');
|
|
250
|
+
const config = loadMofloConfig(cwd);
|
|
251
|
+
daemonEnabled = config?.daemon?.auto_start !== false;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// Unreadable config — assume daemon-enabled and let other checks flag
|
|
255
|
+
// the config error.
|
|
256
|
+
daemonEnabled = true;
|
|
257
|
+
}
|
|
258
|
+
if (daemonEnabled) {
|
|
259
|
+
return {
|
|
260
|
+
name,
|
|
261
|
+
status: 'pass',
|
|
262
|
+
message: 'Daemon enabled — multi-process writes route through single writer (#981 protection active)',
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
// Daemon disabled — count MCP servers across every reachable config.
|
|
266
|
+
const mcpServerCount = countMcpServers(cwd);
|
|
267
|
+
if (mcpServerCount === 0) {
|
|
268
|
+
return {
|
|
269
|
+
name,
|
|
270
|
+
status: 'pass',
|
|
271
|
+
message: 'Daemon disabled and no MCP server configured — no multi-writer hazard',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
name,
|
|
276
|
+
status: 'warn',
|
|
277
|
+
message: `Daemon disabled (moflo.yaml) and ${mcpServerCount} MCP server(s) configured — ` +
|
|
278
|
+
`multi-process sql.js writes can clobber each other (#981). ` +
|
|
279
|
+
`Set daemon.auto_start: true to restore single-writer protection.`,
|
|
280
|
+
fix: 'Edit moflo.yaml: daemon.auto_start: true',
|
|
281
|
+
};
|
|
282
|
+
}
|
|
205
283
|
export async function checkTestDirs() {
|
|
206
284
|
const yamlPath = join(process.cwd(), 'moflo.yaml');
|
|
207
285
|
if (!existsSync(yamlPath)) {
|
|
@@ -10,6 +10,7 @@ import { execSync, exec } from 'child_process';
|
|
|
10
10
|
import { promisify } from 'util';
|
|
11
11
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
12
12
|
import { output } from '../output.js';
|
|
13
|
+
import { fetchLatestNpmVersion, parseVersion, isOutdated } from './doctor-version.js';
|
|
13
14
|
const execAsync = promisify(exec);
|
|
14
15
|
/**
|
|
15
16
|
* Execute command asynchronously with proper environment inheritance.
|
|
@@ -132,11 +133,9 @@ export async function checkBuildTools() {
|
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
export async function checkClaudeCode() {
|
|
136
|
+
let installedRaw;
|
|
135
137
|
try {
|
|
136
|
-
|
|
137
|
-
const versionMatch = version.match(/v?(\d+\.\d+\.\d+)/);
|
|
138
|
-
const versionStr = versionMatch ? `v${versionMatch[1]}` : version;
|
|
139
|
-
return { name: 'Claude Code CLI', status: 'pass', message: versionStr };
|
|
138
|
+
installedRaw = await runCommand('claude --version');
|
|
140
139
|
}
|
|
141
140
|
catch (e) {
|
|
142
141
|
return {
|
|
@@ -146,83 +145,38 @@ export async function checkClaudeCode() {
|
|
|
146
145
|
fix: 'npm install -g @anthropic-ai/claude-code',
|
|
147
146
|
};
|
|
148
147
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
148
|
+
const versionMatch = installedRaw.match(/v?(\d+\.\d+\.\d+)/);
|
|
149
|
+
const installedClean = versionMatch ? versionMatch[1] : installedRaw.trim();
|
|
150
|
+
const installedDisplay = versionMatch ? `v${installedClean}` : installedRaw.trim();
|
|
151
|
+
// Compare against the latest published @anthropic-ai/claude-code on npm.
|
|
152
|
+
// Replaces the old `claude doctor` delegate (which became a TUI in CC 2.1.x
|
|
153
|
+
// and could not be parsed from a non-TTY child). Freshness was the only
|
|
154
|
+
// user-actionable signal that delegate produced; the auto-updater state +
|
|
155
|
+
// .mcp.json server health it also covered are already verified by the
|
|
156
|
+
// existing daemon and `MCP Servers` checks.
|
|
157
|
+
let latest;
|
|
159
158
|
try {
|
|
160
|
-
await
|
|
159
|
+
latest = await fetchLatestNpmVersion('@anthropic-ai/claude-code');
|
|
161
160
|
}
|
|
162
|
-
catch {
|
|
163
|
-
return {
|
|
164
|
-
name: 'Claude Code Doctor',
|
|
165
|
-
status: 'pass',
|
|
166
|
-
message: 'Skipped (claude CLI not installed — see Claude Code CLI check)',
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
// Capture both streams + exit code without throwing. `claude doctor` exits
|
|
170
|
-
// non-zero on findings, so a try/catch over execAsync would lose the body.
|
|
171
|
-
const result = await new Promise((resolve) => {
|
|
172
|
-
const child = exec('claude doctor', {
|
|
173
|
-
encoding: 'utf8',
|
|
174
|
-
timeout: 30000,
|
|
175
|
-
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/sh',
|
|
176
|
-
env: { ...process.env },
|
|
177
|
-
windowsHide: true,
|
|
178
|
-
}, (err, stdout, stderr) => {
|
|
179
|
-
resolve({
|
|
180
|
-
code: err && typeof err.code === 'number'
|
|
181
|
-
? (err.code)
|
|
182
|
-
: (err ? 1 : 0),
|
|
183
|
-
stdout: (stdout || '').toString().trim(),
|
|
184
|
-
stderr: (stderr || '').toString().trim(),
|
|
185
|
-
});
|
|
186
|
-
});
|
|
187
|
-
child.on('error', () => resolve({ code: 1, stdout: '', stderr: '' }));
|
|
188
|
-
});
|
|
189
|
-
// claude doctor not recognised → some Claude versions don't ship the
|
|
190
|
-
// subcommand. Surface as a pass-skip rather than a failure so older Claude
|
|
191
|
-
// installs aren't penalised.
|
|
192
|
-
const combined = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
|
193
|
-
if (/unknown command|command not found|usage:.*claude/.test(combined) &&
|
|
194
|
-
!combined.includes('check')) {
|
|
161
|
+
catch (e) {
|
|
195
162
|
return {
|
|
196
|
-
name: 'Claude Code
|
|
163
|
+
name: 'Claude Code CLI',
|
|
197
164
|
status: 'pass',
|
|
198
|
-
message:
|
|
165
|
+
message: `${installedDisplay} (registry unreachable: ${errorDetail(e, { firstLineOnly: true })})`,
|
|
199
166
|
};
|
|
200
167
|
}
|
|
201
|
-
if (
|
|
202
|
-
const firstLine = result.stdout.split(/\r?\n/).find((l) => l.trim()) || 'No issues reported';
|
|
203
|
-
return { name: 'Claude Code Doctor', status: 'pass', message: firstLine.slice(0, 120) };
|
|
204
|
-
}
|
|
205
|
-
// Non-zero with zero output → `claude doctor` is interactive in current
|
|
206
|
-
// Claude Code releases (verified on 2.1.132): it opens a TUI and produces
|
|
207
|
-
// nothing on a non-TTY child stdout, then our exec timeout kills it. Treat
|
|
208
|
-
// as a skip — the check can't observe the TUI from here, and warning would
|
|
209
|
-
// fire on every machine running the same Claude version.
|
|
210
|
-
if (!result.stdout && !result.stderr) {
|
|
168
|
+
if (versionMatch && isOutdated(parseVersion(installedClean), parseVersion(latest))) {
|
|
211
169
|
return {
|
|
212
|
-
name: 'Claude Code
|
|
213
|
-
status: '
|
|
214
|
-
message:
|
|
170
|
+
name: 'Claude Code CLI',
|
|
171
|
+
status: 'warn',
|
|
172
|
+
message: `${installedDisplay} (latest: v${latest})`,
|
|
173
|
+
fix: 'npm install -g @anthropic-ai/claude-code@latest',
|
|
215
174
|
};
|
|
216
175
|
}
|
|
217
|
-
// Non-zero — surface the tail so the user has a hint, and point to the
|
|
218
|
-
// interactive command for the full report. Don't try to fix from here:
|
|
219
|
-
// Claude-side fixes (re-auth, settings repair, IDE reload) need user gestures.
|
|
220
|
-
const tailLines = result.stdout.split(/\r?\n/).filter((l) => l.trim()).slice(-3).join(' | ');
|
|
221
176
|
return {
|
|
222
|
-
name: 'Claude Code
|
|
223
|
-
status: '
|
|
224
|
-
message:
|
|
225
|
-
fix: 'Run `claude doctor` interactively for full report and follow its instructions',
|
|
177
|
+
name: 'Claude Code CLI',
|
|
178
|
+
status: 'pass',
|
|
179
|
+
message: `${installedDisplay} (up to date)`,
|
|
226
180
|
};
|
|
227
181
|
}
|
|
228
182
|
export async function installClaudeCode() {
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* shell-out where possible). Falls back to running the check's `fix` string
|
|
6
6
|
* if it looks like an `npx`/`npm`/`claude` command.
|
|
7
7
|
*/
|
|
8
|
-
import { execSync } from 'child_process';
|
|
9
8
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
|
|
10
9
|
import { join } from 'path';
|
|
11
10
|
import { output } from '../output.js';
|
|
@@ -175,27 +174,6 @@ export async function autoFixCheck(check) {
|
|
|
175
174
|
'Claude Code CLI': async () => {
|
|
176
175
|
return installClaudeCode();
|
|
177
176
|
},
|
|
178
|
-
// Pass-through to Claude Code's own diagnostic. We don't own its CLI surface
|
|
179
|
-
// and most Claude-side findings (auth, IDE reload, settings drift) need
|
|
180
|
-
// user gestures, so the "fix" here is just to re-run with inherited stdio
|
|
181
|
-
// and let the user act on what they see.
|
|
182
|
-
'Claude Code Doctor': async () => {
|
|
183
|
-
try {
|
|
184
|
-
execSync('claude doctor', {
|
|
185
|
-
encoding: 'utf8',
|
|
186
|
-
stdio: 'inherit',
|
|
187
|
-
windowsHide: true,
|
|
188
|
-
timeout: 60000,
|
|
189
|
-
});
|
|
190
|
-
return true;
|
|
191
|
-
}
|
|
192
|
-
catch {
|
|
193
|
-
// Non-zero exit is informational here — user has seen the output and
|
|
194
|
-
// can act on it. Don't claim success, but don't claim failure of OUR
|
|
195
|
-
// healer either; flag as "needs manual action".
|
|
196
|
-
return false;
|
|
197
|
-
}
|
|
198
|
-
},
|
|
199
177
|
'Zombie Processes': async () => {
|
|
200
178
|
const result = await findZombieProcesses(true);
|
|
201
179
|
return result.killed > 0 || result.details.length === 0;
|
|
@@ -8,26 +8,32 @@ import { checkSubagentHealth, checkSpellExecution, checkMcpToolInvocation, check
|
|
|
8
8
|
import { checkEmbeddingHygiene } from './doctor-embedding-hygiene.js';
|
|
9
9
|
import { checkSwarmFunctional, checkHiveMindFunctional, } from './doctor-checks-swarm.js';
|
|
10
10
|
import { checkMemoryAccessFunctional } from './doctor-checks-memory-access.js';
|
|
11
|
-
import { checkBuildTools, checkClaudeCode,
|
|
12
|
-
import { checkConfigFile, checkDaemonStatus, checkMcpServers, checkMemoryDatabase, checkMofloYamlCompliance, checkStatusLine, checkTestDirs, } from './doctor-checks-config.js';
|
|
11
|
+
import { checkBuildTools, checkClaudeCode, checkDiskSpace, checkGit, checkGitRepo, checkNodeVersion, checkNpmVersion, } from './doctor-checks-runtime.js';
|
|
12
|
+
import { checkConfigFile, checkDaemonStatus, checkDaemonWriteRouting, checkMcpServers, checkMemoryDatabase, checkMofloYamlCompliance, checkStatusLine, checkTestDirs, } from './doctor-checks-config.js';
|
|
13
13
|
import { checkSpellEngine, checkSandboxTier } from './doctor-checks-platform.js';
|
|
14
14
|
import { checkEmbeddings, checkSemanticQuality, } from './doctor-checks-memory.js';
|
|
15
15
|
import { checkIntelligence } from './doctor-checks-intelligence.js';
|
|
16
16
|
import { checkVersionFreshness } from './doctor-version.js';
|
|
17
17
|
import { checkZombieProcesses } from './doctor-zombies.js';
|
|
18
|
-
/** Order matters — top entries surface first under the spinner.
|
|
18
|
+
/** Order matters — top entries surface first under the spinner.
|
|
19
|
+
* `checkZombieProcesses` is intentionally NOT in this list — it must run AFTER
|
|
20
|
+
* the parallel batch settles (see `zombieScanCheck` below and #992). Otherwise
|
|
21
|
+
* doctor's own subprocess probes (e.g. `checkBuildTools` running `npx tsc
|
|
22
|
+
* --version`) can be flagged as their own zombies on Windows, where the npx
|
|
23
|
+
* shim exits before its tsc child finishes.
|
|
24
|
+
*/
|
|
19
25
|
export const allChecks = [
|
|
20
26
|
checkVersionFreshness,
|
|
21
27
|
checkNodeVersion,
|
|
22
28
|
checkNpmVersion,
|
|
23
29
|
checkClaudeCode,
|
|
24
|
-
checkClaudeCodeDoctor,
|
|
25
30
|
checkGit,
|
|
26
31
|
checkGitRepo,
|
|
27
32
|
checkConfigFile,
|
|
28
33
|
checkMofloYamlCompliance,
|
|
29
34
|
checkStatusLine,
|
|
30
35
|
checkDaemonStatus,
|
|
36
|
+
checkDaemonWriteRouting,
|
|
31
37
|
checkMemoryDatabase,
|
|
32
38
|
checkEmbeddings,
|
|
33
39
|
checkEmbeddingHygiene,
|
|
@@ -38,7 +44,6 @@ export const allChecks = [
|
|
|
38
44
|
checkSemanticQuality,
|
|
39
45
|
checkIntelligence,
|
|
40
46
|
checkSpellEngine,
|
|
41
|
-
checkZombieProcesses,
|
|
42
47
|
checkSubagentHealth,
|
|
43
48
|
checkSpellExecution,
|
|
44
49
|
checkMcpToolInvocation,
|
|
@@ -57,6 +62,8 @@ export const allChecks = [
|
|
|
57
62
|
checkMemoryAccessFunctional,
|
|
58
63
|
checkSandboxTier,
|
|
59
64
|
];
|
|
65
|
+
/** Sequenced check that runs AFTER `allChecks` settles. Issue #992. */
|
|
66
|
+
export const zombieScanCheck = checkZombieProcesses;
|
|
60
67
|
/** Lookup table for `flo doctor -c <name>`. */
|
|
61
68
|
export const componentMap = {
|
|
62
69
|
'version': checkVersionFreshness,
|
|
@@ -64,14 +71,14 @@ export const componentMap = {
|
|
|
64
71
|
'node': checkNodeVersion,
|
|
65
72
|
'npm': checkNpmVersion,
|
|
66
73
|
'claude': checkClaudeCode,
|
|
67
|
-
'claude-doctor': checkClaudeCodeDoctor,
|
|
68
|
-
'claude-code-doctor': checkClaudeCodeDoctor,
|
|
69
74
|
'config': checkConfigFile,
|
|
70
75
|
'yaml': checkMofloYamlCompliance,
|
|
71
76
|
'moflo-yaml': checkMofloYamlCompliance,
|
|
72
77
|
'statusline': checkStatusLine,
|
|
73
78
|
'status-line': checkStatusLine,
|
|
74
79
|
'daemon': checkDaemonStatus,
|
|
80
|
+
'daemon-write-routing': checkDaemonWriteRouting,
|
|
81
|
+
'write-routing': checkDaemonWriteRouting,
|
|
75
82
|
'memory': checkMemoryDatabase,
|
|
76
83
|
'embeddings': checkEmbeddings,
|
|
77
84
|
'embedding-hygiene': checkEmbeddingHygiene,
|
|
@@ -66,11 +66,11 @@ function isOutdated(current, latest) {
|
|
|
66
66
|
// Manual AbortController (NOT AbortSignal.timeout): the latter leaves
|
|
67
67
|
// a libuv timer alive past process exit on Node 24 / Windows and trips
|
|
68
68
|
// an `!(handle->flags & UV_HANDLE_CLOSING)` assertion in src/win/async.c.
|
|
69
|
-
async function
|
|
69
|
+
export async function fetchLatestNpmVersion(pkg) {
|
|
70
70
|
const ac = new AbortController();
|
|
71
71
|
const timer = setTimeout(() => ac.abort(), REGISTRY_FETCH_TIMEOUT_MS);
|
|
72
72
|
try {
|
|
73
|
-
const response = await fetch(
|
|
73
|
+
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`, {
|
|
74
74
|
headers: { Accept: 'application/json' },
|
|
75
75
|
signal: ac.signal,
|
|
76
76
|
});
|
|
@@ -86,6 +86,10 @@ async function fetchLatestVersion() {
|
|
|
86
86
|
clearTimeout(timer);
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
+
export { parseVersion, isOutdated };
|
|
90
|
+
async function fetchLatestVersion() {
|
|
91
|
+
return fetchLatestNpmVersion('moflo');
|
|
92
|
+
}
|
|
89
93
|
export async function checkVersionFreshness() {
|
|
90
94
|
try {
|
|
91
95
|
const currentVersion = readCurrentVersion();
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Created with motailz.com
|
|
12
12
|
*/
|
|
13
13
|
import { output } from '../output.js';
|
|
14
|
-
import { allChecks, componentMap } from './doctor-registry.js';
|
|
14
|
+
import { allChecks, componentMap, zombieScanCheck } from './doctor-registry.js';
|
|
15
15
|
import { emitJsonOutput, finalize, formatCheck, maybeAutoInstallClaudeCode, renderSummary, runAutoFix, runKillZombiesBanner, } from './doctor-render.js';
|
|
16
16
|
import { checkEmbeddings } from './doctor-checks-memory.js';
|
|
17
17
|
import { checkMofloYamlCompliance } from './doctor-checks-config.js';
|
|
@@ -157,6 +157,20 @@ export const doctorCommand = {
|
|
|
157
157
|
let checkResults;
|
|
158
158
|
try {
|
|
159
159
|
checkResults = await Promise.allSettled(checksToRun.map(check => check()));
|
|
160
|
+
// Issue #992: zombie scan must follow the parallel batch, not race it.
|
|
161
|
+
// Several parallel checks spawn short-lived subprocesses (notably
|
|
162
|
+
// `checkBuildTools` running `npx tsc --version`); on Windows the npx
|
|
163
|
+
// shim exits before its tsc child, leaving a transient orphan that
|
|
164
|
+
// the zombie scan would otherwise flag as a real leak. Skip in
|
|
165
|
+
// single-component (`-c`) runs since those are targeted diagnostics.
|
|
166
|
+
if (!component) {
|
|
167
|
+
try {
|
|
168
|
+
checkResults.push({ status: 'fulfilled', value: await zombieScanCheck() });
|
|
169
|
+
}
|
|
170
|
+
catch (reason) {
|
|
171
|
+
checkResults.push({ status: 'rejected', reason });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
160
174
|
}
|
|
161
175
|
finally {
|
|
162
176
|
spinner?.stop();
|
|
@@ -10,6 +10,29 @@
|
|
|
10
10
|
import * as readline from 'node:readline';
|
|
11
11
|
import { loadSpellEngine, } from '../services/engine-loader.js';
|
|
12
12
|
import { createDashboardMemoryAccessor } from '../services/daemon-dashboard.js';
|
|
13
|
+
/**
|
|
14
|
+
* Wrap a MemoryAccessor with a write-failure counter so the [epic] summary
|
|
15
|
+
* can warn when spell progress didn't reach disk (#982). Without this, a
|
|
16
|
+
* persist failure surfaces only as a `[spell] storeProgress(...) failed`
|
|
17
|
+
* line buried mid-run, easily missed in shell scrollback.
|
|
18
|
+
*/
|
|
19
|
+
function trackPersistFailures(inner) {
|
|
20
|
+
const tracker = {
|
|
21
|
+
failedWrites: 0,
|
|
22
|
+
async read(ns, key) { return inner.read(ns, key); },
|
|
23
|
+
async write(ns, key, value) {
|
|
24
|
+
try {
|
|
25
|
+
await inner.write(ns, key, value);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
tracker.failedWrites++;
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
async search(ns, query) { return inner.search(ns, query); },
|
|
33
|
+
};
|
|
34
|
+
return tracker;
|
|
35
|
+
}
|
|
13
36
|
/** Cached memory accessor — created once per process. */
|
|
14
37
|
let memoryAccessor = null;
|
|
15
38
|
/** Prompt the user to accept or decline spell permissions. */
|
|
@@ -37,7 +60,8 @@ export async function runEpicSpell(yamlContent, options = {}) {
|
|
|
37
60
|
// are persisted and visible in the dashboard.
|
|
38
61
|
if (!memoryAccessor) {
|
|
39
62
|
try {
|
|
40
|
-
|
|
63
|
+
const inner = await createDashboardMemoryAccessor();
|
|
64
|
+
memoryAccessor = trackPersistFailures(inner);
|
|
41
65
|
console.log('[epic] Memory accessor ready — spell progress will be persisted');
|
|
42
66
|
}
|
|
43
67
|
catch (err) {
|
|
@@ -45,8 +69,26 @@ export async function runEpicSpell(yamlContent, options = {}) {
|
|
|
45
69
|
console.warn('[epic] ⚠ Spell executions will NOT appear in the dashboard');
|
|
46
70
|
}
|
|
47
71
|
}
|
|
72
|
+
// memoryAccessor is module-cached, so `failedWrites` is cumulative across
|
|
73
|
+
// every spell run in this process. Capturing the count BEFORE this run
|
|
74
|
+
// and computing the delta below isolates "this run's failures" from any
|
|
75
|
+
// prior run's. Spell runs are sequential per process, so no race.
|
|
76
|
+
const failuresBefore = memoryAccessor?.failedWrites ?? 0;
|
|
48
77
|
const runOpts = { ...options, projectRoot: process.cwd(), ...(memoryAccessor ? { memory: memoryAccessor } : {}) };
|
|
49
|
-
|
|
78
|
+
// Print the persist-failure summary on every return path. Without this,
|
|
79
|
+
// a #982-style failure surfaces only as scattered `[spell] storeProgress
|
|
80
|
+
// failed` lines mid-run that get lost in scrollback. The summary line is
|
|
81
|
+
// the user's signal that the dashboard / Luminarium will show empty
|
|
82
|
+
// history despite a successful-looking spell run.
|
|
83
|
+
const reportPersistFailures = () => {
|
|
84
|
+
if (!memoryAccessor)
|
|
85
|
+
return;
|
|
86
|
+
const failed = memoryAccessor.failedWrites - failuresBefore;
|
|
87
|
+
if (failed > 0) {
|
|
88
|
+
console.warn(`[epic] ⚠ Spell progress was not fully persisted (${failed} write${failed === 1 ? '' : 's'} failed) — run history may be missing from the dashboard.`);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
let result = await engine.runSpellFromContent(yamlContent, undefined, runOpts);
|
|
50
92
|
// Auto-accept permissions on first run: the spell runner already printed
|
|
51
93
|
// the full risk analysis to the console. The user initiated the epic
|
|
52
94
|
// command, so we accept on their behalf and retry immediately.
|
|
@@ -55,6 +97,7 @@ export async function runEpicSpell(yamlContent, options = {}) {
|
|
|
55
97
|
if (hasAcceptanceError) {
|
|
56
98
|
const accepted = await promptAcceptPermissions();
|
|
57
99
|
if (!accepted) {
|
|
100
|
+
reportPersistFailures();
|
|
58
101
|
return result;
|
|
59
102
|
}
|
|
60
103
|
// Use the already-loaded engine module (dynamic import) for spells internals.
|
|
@@ -71,8 +114,9 @@ export async function runEpicSpell(yamlContent, options = {}) {
|
|
|
71
114
|
const report = analyzeSpellPermissions(parsed.definition, stepRegistry);
|
|
72
115
|
await recordAcceptance(projectRoot, parsed.definition.name, report.permissionHash);
|
|
73
116
|
console.log(`[epic] Permissions accepted for "${parsed.definition.name}" — retrying...\n`);
|
|
74
|
-
|
|
117
|
+
result = await engine.runSpellFromContent(yamlContent, undefined, runOpts);
|
|
75
118
|
}
|
|
119
|
+
reportPersistFailures();
|
|
76
120
|
return result;
|
|
77
121
|
}
|
|
78
122
|
//# sourceMappingURL=runner-adapter.js.map
|
package/dist/src/cli/index.js
CHANGED
|
@@ -16,9 +16,51 @@ import { VERSION } from './version.js';
|
|
|
16
16
|
export { VERSION };
|
|
17
17
|
const LONG_RUNNING_COMMANDS = ['mcp', 'daemon'];
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
19
|
+
* Wait for a writable's userspace buffer to drain, then issue an empty write
|
|
20
|
+
* whose callback fires after libuv has committed the now-front-of-queue write.
|
|
21
|
+
*
|
|
22
|
+
* Issue #996: on Windows async pipes, the empty-write trick alone fires before
|
|
23
|
+
* prior multi-line writes (e.g. `printBox`) have left libuv's buffer, racing
|
|
24
|
+
* `process.exit` and either dropping content rows or tripping the libuv
|
|
25
|
+
* `UV_HANDLE_CLOSING` assertion in `src/win/async.c`.
|
|
26
|
+
*
|
|
27
|
+
* Two-stage wait: first await `'drain'` if the userspace buffer is full, then
|
|
28
|
+
* the empty-write callback for the libuv-level commit. A 250 ms unref'd safety
|
|
29
|
+
* timeout covers broken pipes where `'drain'` never fires.
|
|
30
|
+
*/
|
|
31
|
+
export function drainStream(stream) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
if (!stream.writable || stream.destroyed)
|
|
34
|
+
return resolve();
|
|
35
|
+
const finalize = () => {
|
|
36
|
+
try {
|
|
37
|
+
stream.write('', () => resolve());
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
resolve();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
if (stream.writableNeedDrain) {
|
|
44
|
+
const onDrain = () => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
finalize();
|
|
47
|
+
};
|
|
48
|
+
stream.once('drain', onDrain);
|
|
49
|
+
const timer = setTimeout(() => {
|
|
50
|
+
stream.removeListener('drain', onDrain);
|
|
51
|
+
resolve();
|
|
52
|
+
}, 250);
|
|
53
|
+
timer.unref();
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
finalize();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Drain stdout/stderr, shut down the memory bridge if it was initialized,
|
|
20
62
|
* then `process.exit(code)`. Prevents the libuv `uv_async_send` assertion
|
|
21
|
-
* on Windows when stdout is an async pipe (
|
|
63
|
+
* on Windows when stdout is an async pipe (issues #504, #996).
|
|
22
64
|
*/
|
|
23
65
|
async function drainAndExit(code) {
|
|
24
66
|
try {
|
|
@@ -28,12 +70,8 @@ async function drainAndExit(code) {
|
|
|
28
70
|
catch {
|
|
29
71
|
// Bridge may not have been loaded — that's fine
|
|
30
72
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return resolve();
|
|
34
|
-
stream.write('', () => resolve());
|
|
35
|
-
});
|
|
36
|
-
await Promise.all([flush(process.stdout), flush(process.stderr)]);
|
|
73
|
+
process.exitCode = code;
|
|
74
|
+
await Promise.all([drainStream(process.stdout), drainStream(process.stderr)]);
|
|
37
75
|
process.exit(code);
|
|
38
76
|
}
|
|
39
77
|
/**
|