moflo 4.9.13 → 4.9.14
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/helpers/gate.cjs +21 -5
- package/.claude/skills/fl/phases.md +18 -2
- package/.claude/skills/simplify/SKILL.md +35 -48
- package/bin/gate.cjs +21 -5
- package/bin/session-start-launcher.mjs +1 -1
- package/bin/simplify-classify.cjs +211 -0
- package/dist/src/cli/commands/doctor-checks-config.js +246 -0
- package/dist/src/cli/commands/doctor-checks-intelligence.js +197 -0
- package/dist/src/cli/commands/doctor-checks-memory.js +207 -0
- package/dist/src/cli/commands/doctor-checks-platform.js +138 -0
- package/dist/src/cli/commands/doctor-checks-runtime.js +170 -0
- package/dist/src/cli/commands/doctor-fixes.js +165 -0
- package/dist/src/cli/commands/doctor-registry.js +109 -0
- package/dist/src/cli/commands/doctor-render.js +203 -0
- package/dist/src/cli/commands/doctor-types.js +9 -0
- package/dist/src/cli/commands/doctor-version.js +134 -0
- package/dist/src/cli/commands/doctor-zombies.js +201 -0
- package/dist/src/cli/commands/doctor.js +35 -1706
- package/dist/src/cli/init/helpers-generator.js +21 -5
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
- package/scripts/post-install-bootstrap.mjs +1 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output rendering helpers for `flo doctor`:
|
|
3
|
+
* formatted summary, JSON output, auto-fix loop, kill-zombies banner.
|
|
4
|
+
*/
|
|
5
|
+
import { output } from '../output.js';
|
|
6
|
+
import { autoFixCheck } from './doctor-fixes.js';
|
|
7
|
+
import { checkClaudeCode, installClaudeCode } from './doctor-checks-runtime.js';
|
|
8
|
+
import { findZombieProcesses, formatZombieDetail, killTrackedProcesses, } from './doctor-zombies.js';
|
|
9
|
+
export function formatCheck(check) {
|
|
10
|
+
const icon = check.status === 'pass' ? output.success('✓') :
|
|
11
|
+
check.status === 'warn' ? output.warning('⚠') :
|
|
12
|
+
output.error('✗');
|
|
13
|
+
return `${icon} ${check.name}: ${check.message}`;
|
|
14
|
+
}
|
|
15
|
+
function tally(results) {
|
|
16
|
+
return {
|
|
17
|
+
passed: results.filter(r => r.status === 'pass').length,
|
|
18
|
+
warnings: results.filter(r => r.status === 'warn').length,
|
|
19
|
+
failed: results.filter(r => r.status === 'fail').length,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export async function runKillZombiesBanner() {
|
|
23
|
+
output.writeln(output.bold('Zombie Process Scan'));
|
|
24
|
+
output.writeln();
|
|
25
|
+
const registryKilled = killTrackedProcesses();
|
|
26
|
+
if (registryKilled > 0) {
|
|
27
|
+
output.writeln(output.success(` Killed ${registryKilled} tracked background process(es) from registry`));
|
|
28
|
+
}
|
|
29
|
+
// Single OS-level scan + kill — the previous flow scanned twice.
|
|
30
|
+
const result = await findZombieProcesses(true);
|
|
31
|
+
const found = result.details.length;
|
|
32
|
+
if (found === 0) {
|
|
33
|
+
if (registryKilled === 0) {
|
|
34
|
+
output.writeln(output.success(' No orphaned moflo processes found'));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
output.writeln(output.warning(` Found ${found} additional orphaned process(es):`));
|
|
39
|
+
for (const d of result.details) {
|
|
40
|
+
output.writeln(output.dim(` ${formatZombieDetail(d)}`));
|
|
41
|
+
}
|
|
42
|
+
if (result.killed > 0) {
|
|
43
|
+
output.writeln(output.success(` Killed ${result.killed} zombie process(es)`));
|
|
44
|
+
}
|
|
45
|
+
if (result.killed < found) {
|
|
46
|
+
output.writeln(output.warning(` ${found - result.killed} process(es) could not be killed`));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
output.writeln();
|
|
50
|
+
output.writeln(output.dim('─'.repeat(50)));
|
|
51
|
+
output.writeln();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Issue #818: machine-readable output. Emits a single JSON document with
|
|
55
|
+
* per-check fields (and any FunctionalCheckDetail entries from the swarm/
|
|
56
|
+
* hive checks) and exits with the right code. Skips auto-fix entirely —
|
|
57
|
+
* --json is read-only by intent so CI gates can consume it without
|
|
58
|
+
* mutating the working tree.
|
|
59
|
+
*/
|
|
60
|
+
export function emitJsonOutput({ results, strict, allowWarnList }) {
|
|
61
|
+
const { passed, warnings, failed } = tally(results);
|
|
62
|
+
const allowSet = new Set(allowWarnList);
|
|
63
|
+
const strictWarningFailures = strict
|
|
64
|
+
? results.filter(r => r.status === 'warn' && !allowSet.has(r.name)).map(r => r.name)
|
|
65
|
+
: [];
|
|
66
|
+
const exitCode = failed > 0 || strictWarningFailures.length > 0 ? 1 : 0;
|
|
67
|
+
process.stdout.write(JSON.stringify({
|
|
68
|
+
summary: { passed, warnings, failed },
|
|
69
|
+
strict: strict ? { strictMode: true, warningsTriggeringFail: strictWarningFailures } : { strictMode: false },
|
|
70
|
+
results,
|
|
71
|
+
}, null, 2) + '\n');
|
|
72
|
+
return { success: exitCode === 0, exitCode, data: { passed, warnings, failed, results } };
|
|
73
|
+
}
|
|
74
|
+
/** Re-runs Claude Code CLI install + check if --install was passed and the prior result wasn't pass. */
|
|
75
|
+
export async function maybeAutoInstallClaudeCode(results, fixes) {
|
|
76
|
+
const claudeCodeResult = results.find(r => r.name === 'Claude Code CLI');
|
|
77
|
+
if (!claudeCodeResult || claudeCodeResult.status === 'pass')
|
|
78
|
+
return;
|
|
79
|
+
const installed = await installClaudeCode();
|
|
80
|
+
if (!installed)
|
|
81
|
+
return;
|
|
82
|
+
const newCheck = await checkClaudeCode();
|
|
83
|
+
const idx = results.findIndex(r => r.name === 'Claude Code CLI');
|
|
84
|
+
if (idx !== -1) {
|
|
85
|
+
results[idx] = newCheck;
|
|
86
|
+
const fixIdx = fixes.findIndex(f => f.startsWith('Claude Code CLI:'));
|
|
87
|
+
if (fixIdx !== -1 && newCheck.status === 'pass') {
|
|
88
|
+
fixes.splice(fixIdx, 1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
output.writeln(formatCheck(newCheck));
|
|
92
|
+
}
|
|
93
|
+
export function renderSummary(results) {
|
|
94
|
+
const counts = tally(results);
|
|
95
|
+
output.writeln();
|
|
96
|
+
output.writeln(output.dim('─'.repeat(50)));
|
|
97
|
+
output.writeln();
|
|
98
|
+
const summaryParts = [
|
|
99
|
+
output.success(`${counts.passed} passed`),
|
|
100
|
+
counts.warnings > 0 ? output.warning(`${counts.warnings} warnings`) : null,
|
|
101
|
+
counts.failed > 0 ? output.error(`${counts.failed} failed`) : null,
|
|
102
|
+
].filter(Boolean);
|
|
103
|
+
output.writeln(`Summary: ${summaryParts.join(', ')}`);
|
|
104
|
+
return counts;
|
|
105
|
+
}
|
|
106
|
+
/** Auto-fix loop, including the post-fix re-run. Mutates `results` and `fixes` in place when fixes succeed. */
|
|
107
|
+
export async function runAutoFix(results, fixes, checksToRun) {
|
|
108
|
+
if (fixes.length === 0)
|
|
109
|
+
return;
|
|
110
|
+
output.writeln();
|
|
111
|
+
output.writeln(output.bold('Auto-fixing issues...'));
|
|
112
|
+
output.writeln();
|
|
113
|
+
const fixableResults = results.filter(r => r.fix && (r.status === 'fail' || r.status === 'warn'));
|
|
114
|
+
let fixed = 0;
|
|
115
|
+
const unfixed = [];
|
|
116
|
+
for (const check of fixableResults) {
|
|
117
|
+
const success = await autoFixCheck(check);
|
|
118
|
+
if (success) {
|
|
119
|
+
fixed++;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
unfixed.push(`${check.name}: ${check.fix}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (fixed > 0) {
|
|
126
|
+
output.writeln();
|
|
127
|
+
output.writeln(output.success(`Auto-fixed ${fixed} issue${fixed > 1 ? 's' : ''}`));
|
|
128
|
+
}
|
|
129
|
+
if (unfixed.length > 0) {
|
|
130
|
+
output.writeln();
|
|
131
|
+
output.writeln(output.bold('Manual fixes needed:'));
|
|
132
|
+
for (const fix of unfixed) {
|
|
133
|
+
output.writeln(output.dim(` ${fix}`));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (fixed === 0)
|
|
137
|
+
return;
|
|
138
|
+
output.writeln();
|
|
139
|
+
output.writeln(output.dim('Re-checking...'));
|
|
140
|
+
output.writeln();
|
|
141
|
+
const reResults = await Promise.allSettled(checksToRun.map(check => check()));
|
|
142
|
+
let rePassed = 0, reWarnings = 0, reFailed = 0;
|
|
143
|
+
for (const sr of reResults) {
|
|
144
|
+
if (sr.status === 'fulfilled') {
|
|
145
|
+
output.writeln(formatCheck(sr.value));
|
|
146
|
+
if (sr.value.status === 'pass')
|
|
147
|
+
rePassed++;
|
|
148
|
+
else if (sr.value.status === 'warn')
|
|
149
|
+
reWarnings++;
|
|
150
|
+
else
|
|
151
|
+
reFailed++;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
output.writeln();
|
|
155
|
+
output.writeln(output.dim('─'.repeat(50)));
|
|
156
|
+
const reSummary = [
|
|
157
|
+
output.success(`${rePassed} passed`),
|
|
158
|
+
reWarnings > 0 ? output.warning(`${reWarnings} warnings`) : null,
|
|
159
|
+
reFailed > 0 ? output.error(`${reFailed} failed`) : null,
|
|
160
|
+
].filter(Boolean);
|
|
161
|
+
output.writeln(`After fix: ${reSummary.join(', ')}`);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Build the final CommandResult based on pass/warn/fail counts and --strict
|
|
165
|
+
* mode. Issue #784: in strict mode any non-allowlisted warning fails the run.
|
|
166
|
+
* Equality (not substring) match — an allowlist entry tolerates exactly that
|
|
167
|
+
* check, never accidentally suppresses neighboring checks like "Git"
|
|
168
|
+
* allowlisting "Git Repository".
|
|
169
|
+
*/
|
|
170
|
+
export function finalize({ results, strict, allowWarnList }) {
|
|
171
|
+
const { passed, warnings, failed } = tally(results);
|
|
172
|
+
if (failed > 0) {
|
|
173
|
+
output.writeln();
|
|
174
|
+
output.writeln(output.error('Some checks failed. Please address the issues above.'));
|
|
175
|
+
return { success: false, exitCode: 1, data: { passed, warnings, failed, results } };
|
|
176
|
+
}
|
|
177
|
+
if (warnings > 0) {
|
|
178
|
+
if (strict) {
|
|
179
|
+
const warnResults = results.filter((r) => r.status === 'warn');
|
|
180
|
+
const allowSet = new Set(allowWarnList);
|
|
181
|
+
const offending = warnResults.filter((r) => !r.name || !allowSet.has(r.name));
|
|
182
|
+
if (offending.length > 0) {
|
|
183
|
+
output.writeln();
|
|
184
|
+
output.writeln(output.error(`--strict: ${offending.length} warning${offending.length > 1 ? 's' : ''} not allowlisted ` +
|
|
185
|
+
`(use --allow-warn "<name>,<name>" to tolerate intentional warnings):`));
|
|
186
|
+
for (const r of offending) {
|
|
187
|
+
output.writeln(output.error(` ✗ ${r.name}: ${r.message ?? ''}`));
|
|
188
|
+
}
|
|
189
|
+
return { success: false, exitCode: 1, data: { passed, warnings, failed, results } };
|
|
190
|
+
}
|
|
191
|
+
output.writeln();
|
|
192
|
+
output.writeln(output.success(`--strict: ${warnResults.length} warning${warnResults.length > 1 ? 's' : ''} all allowlisted (--allow-warn).`));
|
|
193
|
+
return { success: true, data: { passed, warnings, failed, results } };
|
|
194
|
+
}
|
|
195
|
+
output.writeln();
|
|
196
|
+
output.writeln(output.warning('All checks passed with some warnings.'));
|
|
197
|
+
return { success: true, data: { passed, warnings, failed, results } };
|
|
198
|
+
}
|
|
199
|
+
output.writeln();
|
|
200
|
+
output.writeln(output.success('All checks passed! System is healthy.'));
|
|
201
|
+
return { success: true, data: { passed, warnings, failed, results } };
|
|
202
|
+
}
|
|
203
|
+
//# sourceMappingURL=doctor-render.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared type declarations for the doctor command tree.
|
|
3
|
+
*
|
|
4
|
+
* Imported as `import type { HealthCheck } from './doctor-types.js'` so the
|
|
5
|
+
* import is erased at runtime — keeps the doctor-registry → doctor-checks-*
|
|
6
|
+
* module graph free of value-level cycles.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
9
|
+
//# sourceMappingURL=doctor-types.js.map
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version freshness check for `flo doctor`.
|
|
3
|
+
*
|
|
4
|
+
* Uses fetch() against the npm registry rather than `npm view` because the
|
|
5
|
+
* latter shells out to npm-cli.js, which is briefly orphaned on Windows after
|
|
6
|
+
* its parent chain reaps and gets flagged by findZombieProcesses' "moflo"
|
|
7
|
+
* regex.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync } from 'fs';
|
|
10
|
+
import { dirname, join } from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
13
|
+
// Cold-connect TLS+DNS can eat most of npm's old 5s budget when doctor's
|
|
14
|
+
// parallel checks saturate the event loop, hence 10s.
|
|
15
|
+
const REGISTRY_FETCH_TIMEOUT_MS = 10_000;
|
|
16
|
+
function readCurrentVersion() {
|
|
17
|
+
// Walk up from the current file's directory until we find the moflo
|
|
18
|
+
// package.json (or a tolerated legacy upstream name during migration).
|
|
19
|
+
// Walk until dirname(dir) === dir (filesystem root on any platform).
|
|
20
|
+
try {
|
|
21
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
22
|
+
let dir = dirname(thisFile);
|
|
23
|
+
for (;;) {
|
|
24
|
+
const candidate = join(dir, 'package.json');
|
|
25
|
+
try {
|
|
26
|
+
if (existsSync(candidate)) {
|
|
27
|
+
const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
28
|
+
if (pkg.version &&
|
|
29
|
+
typeof pkg.name === 'string' &&
|
|
30
|
+
(pkg.name === 'moflo' || pkg.name === 'claude-flow' || pkg.name === 'ruflo')) {
|
|
31
|
+
return pkg.version;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Unreadable/invalid JSON -- skip and keep walking up
|
|
37
|
+
}
|
|
38
|
+
const parent = dirname(dir);
|
|
39
|
+
if (parent === dir)
|
|
40
|
+
break; // reached root
|
|
41
|
+
dir = parent;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Fall back to a default
|
|
46
|
+
}
|
|
47
|
+
return '0.0.0';
|
|
48
|
+
}
|
|
49
|
+
function parseVersion(v) {
|
|
50
|
+
const match = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-[a-zA-Z]+\.(\d+))?/);
|
|
51
|
+
if (!match)
|
|
52
|
+
return { major: 0, minor: 0, patch: 0, prerelease: 0 };
|
|
53
|
+
return {
|
|
54
|
+
major: parseInt(match[1], 10) || 0,
|
|
55
|
+
minor: parseInt(match[2], 10) || 0,
|
|
56
|
+
patch: parseInt(match[3], 10) || 0,
|
|
57
|
+
prerelease: parseInt(match[4], 10) || 0,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function isOutdated(current, latest) {
|
|
61
|
+
return (latest.major > current.major ||
|
|
62
|
+
(latest.major === current.major && latest.minor > current.minor) ||
|
|
63
|
+
(latest.major === current.major && latest.minor === current.minor && latest.patch > current.patch) ||
|
|
64
|
+
(latest.major === current.major && latest.minor === current.minor && latest.patch === current.patch && latest.prerelease > current.prerelease));
|
|
65
|
+
}
|
|
66
|
+
// Manual AbortController (NOT AbortSignal.timeout): the latter leaves
|
|
67
|
+
// a libuv timer alive past process exit on Node 24 / Windows and trips
|
|
68
|
+
// an `!(handle->flags & UV_HANDLE_CLOSING)` assertion in src/win/async.c.
|
|
69
|
+
async function fetchLatestVersion() {
|
|
70
|
+
const ac = new AbortController();
|
|
71
|
+
const timer = setTimeout(() => ac.abort(), REGISTRY_FETCH_TIMEOUT_MS);
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch('https://registry.npmjs.org/moflo/latest', {
|
|
74
|
+
headers: { Accept: 'application/json' },
|
|
75
|
+
signal: ac.signal,
|
|
76
|
+
});
|
|
77
|
+
if (!response.ok)
|
|
78
|
+
throw new Error(`registry HTTP ${response.status}`);
|
|
79
|
+
const info = (await response.json());
|
|
80
|
+
if (typeof info.version !== 'string' || !info.version) {
|
|
81
|
+
throw new Error('registry response missing version');
|
|
82
|
+
}
|
|
83
|
+
return info.version;
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export async function checkVersionFreshness() {
|
|
90
|
+
try {
|
|
91
|
+
const currentVersion = readCurrentVersion();
|
|
92
|
+
// Check if running via npx (look for _npx in process path or argv)
|
|
93
|
+
const isNpx = process.argv[1]?.includes('_npx') ||
|
|
94
|
+
process.env.npm_execpath?.includes('npx') ||
|
|
95
|
+
process.cwd().includes('_npx');
|
|
96
|
+
let latestVersion;
|
|
97
|
+
try {
|
|
98
|
+
latestVersion = await fetchLatestVersion();
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
return {
|
|
102
|
+
name: 'Version Freshness',
|
|
103
|
+
status: 'warn',
|
|
104
|
+
message: `v${currentVersion} (cannot check registry: ${errorDetail(e, { firstLineOnly: true })})`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (isOutdated(parseVersion(currentVersion), parseVersion(latestVersion))) {
|
|
108
|
+
const fix = isNpx
|
|
109
|
+
? (process.platform === 'win32'
|
|
110
|
+
? 'npx -y moflo (or clear %LocalAppData%\\npm-cache\\_npx manually)'
|
|
111
|
+
: 'rm -rf ~/.npm/_npx/* && npx -y moflo')
|
|
112
|
+
: 'npm update moflo';
|
|
113
|
+
return {
|
|
114
|
+
name: 'Version Freshness',
|
|
115
|
+
status: 'warn',
|
|
116
|
+
message: `v${currentVersion} (latest: v${latestVersion})${isNpx ? ' [npx cache stale]' : ''}`,
|
|
117
|
+
fix,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
name: 'Version Freshness',
|
|
122
|
+
status: 'pass',
|
|
123
|
+
message: `v${currentVersion} (up to date)`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
return {
|
|
128
|
+
name: 'Version Freshness',
|
|
129
|
+
status: 'warn',
|
|
130
|
+
message: `Unable to check version freshness: ${errorDetail(error)}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=doctor-version.js.map
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zombie process detection + cleanup for `flo doctor`.
|
|
3
|
+
*
|
|
4
|
+
* "Orphaned" means the parent is no longer alive — nothing will clean it up.
|
|
5
|
+
* MCP servers spawned by a live Claude Code session have a live parent
|
|
6
|
+
* (claude.exe) and must NOT be flagged. The shared ProcessManager registry
|
|
7
|
+
* (.moflo/background-pids.json) is treated as an allowlist for legitimate
|
|
8
|
+
* detached background tasks (sequential indexer chain, daemon, MCP servers).
|
|
9
|
+
*/
|
|
10
|
+
import { execSync } from 'child_process';
|
|
11
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { getDaemonLockHolder, isDaemonProcess, releaseDaemonLock } from '../services/daemon-lock.js';
|
|
14
|
+
// Cmdline capture/display caps + scan timeouts. Hoisted so the values
|
|
15
|
+
// used to size buffers and format messages are visible in one place.
|
|
16
|
+
const ZOMBIE_CMDLINE_CAPTURE_LEN = 200;
|
|
17
|
+
const ZOMBIE_CMDLINE_DISPLAY_LEN = 100;
|
|
18
|
+
const ZOMBIE_SCAN_TIMEOUT_MS_WIN = 10_000;
|
|
19
|
+
const ZOMBIE_SCAN_TIMEOUT_MS_POSIX = 5_000;
|
|
20
|
+
const ZOMBIE_KILL_TIMEOUT_MS = 5_000;
|
|
21
|
+
const NODE_PREFIX_RE = /^"?[^"\s]*node(?:\.exe)?"?\s+/i;
|
|
22
|
+
export function formatCmdline(raw) {
|
|
23
|
+
const cleaned = raw.replace(NODE_PREFIX_RE, '').trim();
|
|
24
|
+
return cleaned.length > ZOMBIE_CMDLINE_DISPLAY_LEN
|
|
25
|
+
? cleaned.slice(0, ZOMBIE_CMDLINE_DISPLAY_LEN - 1) + '…'
|
|
26
|
+
: cleaned;
|
|
27
|
+
}
|
|
28
|
+
export function formatZombieDetail(d) {
|
|
29
|
+
return `pid=${d.pid} ppid=${d.ppid} cmd=${formatCmdline(d.cmdline)}`;
|
|
30
|
+
}
|
|
31
|
+
// Cross-platform liveness probe via signal 0 — Node abstracts the platform.
|
|
32
|
+
function isProcessAlive(pid) {
|
|
33
|
+
try {
|
|
34
|
+
process.kill(pid, 0);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Fast path: kill processes tracked in the shared ProcessManager registry.
|
|
42
|
+
// This avoids the expensive OS-level process scan for known background tasks.
|
|
43
|
+
export function killTrackedProcesses() {
|
|
44
|
+
const registryFile = join(process.cwd(), '.moflo', 'background-pids.json');
|
|
45
|
+
const lockFile = join(process.cwd(), '.moflo', 'spawn.lock');
|
|
46
|
+
let killed = 0;
|
|
47
|
+
try {
|
|
48
|
+
if (existsSync(registryFile)) {
|
|
49
|
+
const entries = JSON.parse(readFileSync(registryFile, 'utf-8'));
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (!isProcessAlive(entry.pid))
|
|
52
|
+
continue;
|
|
53
|
+
try {
|
|
54
|
+
if (process.platform === 'win32') {
|
|
55
|
+
execSync(`taskkill /F /PID ${entry.pid}`, { timeout: 5000, windowsHide: true });
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
process.kill(entry.pid, 'SIGKILL');
|
|
59
|
+
}
|
|
60
|
+
killed++;
|
|
61
|
+
}
|
|
62
|
+
catch { /* already gone */ }
|
|
63
|
+
}
|
|
64
|
+
writeFileSync(registryFile, '[]');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch { /* non-fatal */ }
|
|
68
|
+
try {
|
|
69
|
+
if (existsSync(lockFile))
|
|
70
|
+
unlinkSync(lockFile);
|
|
71
|
+
}
|
|
72
|
+
catch { /* ok */ }
|
|
73
|
+
return killed;
|
|
74
|
+
}
|
|
75
|
+
// Read the set of moflo background PIDs registered with the shared
|
|
76
|
+
// ProcessManager (.moflo/background-pids.json). These are legitimate tracked
|
|
77
|
+
// background tasks (sequential indexer chain, daemon, MCP servers spawned by
|
|
78
|
+
// session-start) — they are detached:true by design so their parents have
|
|
79
|
+
// already exited, but they are NOT orphans. Without this allow-set,
|
|
80
|
+
// findZombieProcesses() flags every running indexer step as a zombie.
|
|
81
|
+
function readTrackedBackgroundPids() {
|
|
82
|
+
const result = new Set();
|
|
83
|
+
const registryFile = join(process.cwd(), '.moflo', 'background-pids.json');
|
|
84
|
+
try {
|
|
85
|
+
if (!existsSync(registryFile))
|
|
86
|
+
return result;
|
|
87
|
+
const entries = JSON.parse(readFileSync(registryFile, 'utf-8'));
|
|
88
|
+
if (!Array.isArray(entries))
|
|
89
|
+
return result;
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (entry && typeof entry.pid === 'number' && entry.pid > 0) {
|
|
92
|
+
result.add(entry.pid);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch { /* malformed or unreadable — treat as empty */ }
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
// Find and optionally kill orphaned moflo/claude-flow node processes.
|
|
100
|
+
export async function findZombieProcesses(kill = false) {
|
|
101
|
+
const legitimatePid = getDaemonLockHolder(process.cwd());
|
|
102
|
+
const trackedPids = readTrackedBackgroundPids();
|
|
103
|
+
const currentPid = process.pid;
|
|
104
|
+
const parentPid = process.ppid;
|
|
105
|
+
const details = [];
|
|
106
|
+
let killed = 0;
|
|
107
|
+
const candidates = [];
|
|
108
|
+
try {
|
|
109
|
+
if (process.platform === 'win32') {
|
|
110
|
+
// CSV output preserves full CommandLine; Format-Table truncates to console width.
|
|
111
|
+
const result = execSync('powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"Name=\'node.exe\'\\" | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"', { encoding: 'utf-8', timeout: ZOMBIE_SCAN_TIMEOUT_MS_WIN, windowsHide: true });
|
|
112
|
+
const lines = result.split(/\r?\n/);
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
if (!/moflo|claude-flow|flo\s+(hooks|gate|mcp|daemon)/i.test(line))
|
|
115
|
+
continue;
|
|
116
|
+
const m = line.match(/^"(\d+)","(\d+)","?(.*?)"?$/);
|
|
117
|
+
if (m) {
|
|
118
|
+
candidates.push({
|
|
119
|
+
pid: parseInt(m[1], 10),
|
|
120
|
+
ppid: parseInt(m[2], 10),
|
|
121
|
+
cmdline: m[3].replace(/""/g, '"').slice(0, ZOMBIE_CMDLINE_CAPTURE_LEN),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// ps -ww disables width truncation so cmdline is captured intact.
|
|
128
|
+
const result = execSync('ps -ww -eo pid,ppid,command | grep -E "node.*(moflo|claude-flow)" | grep -v grep', { encoding: 'utf-8', timeout: ZOMBIE_SCAN_TIMEOUT_MS_POSIX });
|
|
129
|
+
const lines = result.trim().split(/\r?\n/);
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
|
132
|
+
if (m) {
|
|
133
|
+
candidates.push({
|
|
134
|
+
pid: parseInt(m[1], 10),
|
|
135
|
+
ppid: parseInt(m[2], 10),
|
|
136
|
+
cmdline: m[3].slice(0, ZOMBIE_CMDLINE_CAPTURE_LEN),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// No matches (grep exits non-zero) or scan command failed.
|
|
144
|
+
}
|
|
145
|
+
for (const cand of candidates) {
|
|
146
|
+
const { pid, ppid } = cand;
|
|
147
|
+
if (pid === currentPid || pid === parentPid || pid === legitimatePid)
|
|
148
|
+
continue;
|
|
149
|
+
if (trackedPids.has(pid))
|
|
150
|
+
continue;
|
|
151
|
+
if (isProcessAlive(ppid))
|
|
152
|
+
continue;
|
|
153
|
+
// Defense-in-depth: detached daemons have dead parents by design even
|
|
154
|
+
// when the lock file is missing/corrupted.
|
|
155
|
+
if (isDaemonProcess(pid))
|
|
156
|
+
continue;
|
|
157
|
+
details.push(cand);
|
|
158
|
+
}
|
|
159
|
+
if (kill && details.length > 0) {
|
|
160
|
+
for (const { pid } of details) {
|
|
161
|
+
try {
|
|
162
|
+
if (process.platform === 'win32') {
|
|
163
|
+
execSync(`taskkill /F /PID ${pid}`, { timeout: ZOMBIE_KILL_TIMEOUT_MS, windowsHide: true });
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
process.kill(pid, 'SIGKILL');
|
|
167
|
+
}
|
|
168
|
+
killed++;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Already exited.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (legitimatePid && details.some(d => d.pid === legitimatePid)) {
|
|
175
|
+
releaseDaemonLock(process.cwd(), legitimatePid, true);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { killed, details };
|
|
179
|
+
}
|
|
180
|
+
// HealthCheck wrapper around findZombieProcesses for the orchestrated check list.
|
|
181
|
+
// Surfaces each orphan's cmdline so spawn-discipline regressions are diagnosable
|
|
182
|
+
// from a single doctor run.
|
|
183
|
+
export async function checkZombieProcesses() {
|
|
184
|
+
try {
|
|
185
|
+
const scan = await findZombieProcesses(false);
|
|
186
|
+
if (scan.details.length === 0) {
|
|
187
|
+
return { name: 'Zombie Processes', status: 'pass', message: 'No orphaned processes' };
|
|
188
|
+
}
|
|
189
|
+
const detail = scan.details.map(formatZombieDetail).join(' | ');
|
|
190
|
+
return {
|
|
191
|
+
name: 'Zombie Processes',
|
|
192
|
+
status: 'warn',
|
|
193
|
+
message: `${scan.details.length} orphaned process(es): ${detail}`,
|
|
194
|
+
fix: 'moflo doctor --kill-zombies',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return { name: 'Zombie Processes', status: 'pass', message: 'Check skipped' };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=doctor-zombies.js.map
|