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.
@@ -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: "[--fix] [-c <component>]"
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 and the healer also runs `claude doctor` internally as a delegated check (`Claude Code Doctor`) so Claude-side issues (auth, settings drift, IDE/extension state) surface in the same report. With `--fix`, the healer re-runs `claude doctor` interactively so you can see and act on its findings; Claude-side fixes typically need user gestures (re-auth, IDE reload) and aren't auto-applied.
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
- export async function checkMcpServers() {
135
- const mcpConfigPaths = [
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
- for (const configPath of mcpConfigPaths) {
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
- const version = await runCommand('claude --version');
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
- * Delegate diagnostics to Claude Code's own `claude doctor` command and surface
152
- * the result. Catches Claude-side issues (settings drift, MCP/auth, IDE/extension
153
- * state, update channel) that moflo's own checks can't see since `claude` is
154
- * not a moflo-owned binary we don't try to parse its output structurally; we
155
- * just report exit code + a short tail. Skip silently when `claude` isn't
156
- * installed `checkClaudeCode` already covers that condition.
157
- */
158
- export async function checkClaudeCodeDoctor() {
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 runCommand('claude --version', 3000);
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 Doctor',
163
+ name: 'Claude Code CLI',
197
164
  status: 'pass',
198
- message: 'Skipped (this Claude version does not expose `claude doctor`)',
165
+ message: `${installedDisplay} (registry unreachable: ${errorDetail(e, { firstLineOnly: true })})`,
199
166
  };
200
167
  }
201
- if (result.code === 0) {
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 Doctor',
213
- status: 'pass',
214
- message: 'Skipped (claude doctor is interactive — run manually to see findings)',
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 Doctor',
223
- status: 'warn',
224
- message: `claude doctor reported issues: ${tailLines.slice(0, 200)}`,
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, checkClaudeCodeDoctor, checkDiskSpace, checkGit, checkGitRepo, checkNodeVersion, checkNpmVersion, } from './doctor-checks-runtime.js';
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 fetchLatestVersion() {
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('https://registry.npmjs.org/moflo/latest', {
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
- memoryAccessor = await createDashboardMemoryAccessor();
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
- const result = await engine.runSpellFromContent(yamlContent, undefined, runOpts);
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
- return engine.runSpellFromContent(yamlContent, undefined, runOpts);
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
@@ -16,9 +16,51 @@ import { VERSION } from './version.js';
16
16
  export { VERSION };
17
17
  const LONG_RUNNING_COMMANDS = ['mcp', 'daemon'];
18
18
  /**
19
- * Flush stdout/stderr, shut down the memory bridge if it was initialized,
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 (issue #504).
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
- const flush = (stream) => new Promise((resolve) => {
32
- if (!stream.writable)
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
  /**