claude-flow 3.32.2 → 3.32.8

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.
Files changed (42) hide show
  1. package/.claude/helpers/statusline.cjs +38 -38
  2. package/.claude-plugin/scripts/ruflo-hook.cjs +2 -2
  3. package/.claude-plugin/scripts/ruflo-hook.sh +17 -17
  4. package/package.json +1 -1
  5. package/v3/@claude-flow/cli/catalog-manifest.json +2 -2
  6. package/v3/@claude-flow/cli/dist/src/auth/client.d.ts +89 -0
  7. package/v3/@claude-flow/cli/dist/src/auth/client.js +242 -0
  8. package/v3/@claude-flow/cli/dist/src/auth/constants.d.ts +7 -0
  9. package/v3/@claude-flow/cli/dist/src/auth/constants.js +7 -0
  10. package/v3/@claude-flow/cli/dist/src/auth/scopes.d.ts +14 -0
  11. package/v3/@claude-flow/cli/dist/src/auth/scopes.js +21 -0
  12. package/v3/@claude-flow/cli/dist/src/auth/security-bridge.d.ts +36 -0
  13. package/v3/@claude-flow/cli/dist/src/auth/security-bridge.js +42 -0
  14. package/v3/@claude-flow/cli/dist/src/auth/session.d.ts +20 -0
  15. package/v3/@claude-flow/cli/dist/src/auth/session.js +32 -0
  16. package/v3/@claude-flow/cli/dist/src/auth/state.d.ts +19 -0
  17. package/v3/@claude-flow/cli/dist/src/auth/state.js +53 -0
  18. package/v3/@claude-flow/cli/dist/src/auth/types.d.ts +27 -0
  19. package/v3/@claude-flow/cli/dist/src/auth/types.js +11 -0
  20. package/v3/@claude-flow/cli/dist/src/commands/auth.d.ts +15 -0
  21. package/v3/@claude-flow/cli/dist/src/commands/auth.js +244 -0
  22. package/v3/@claude-flow/cli/dist/src/commands/doctor.js +211 -4
  23. package/v3/@claude-flow/cli/dist/src/commands/index.js +2 -0
  24. package/v3/@claude-flow/cli/dist/src/commands/proxy-lifecycle.d.ts +20 -0
  25. package/v3/@claude-flow/cli/dist/src/commands/proxy-lifecycle.js +277 -0
  26. package/v3/@claude-flow/cli/dist/src/commands/proxy.js +97 -4
  27. package/v3/@claude-flow/cli/dist/src/mcp-tools/memory-tools.js +11 -3
  28. package/v3/@claude-flow/cli/dist/src/proxy/install.d.ts +29 -0
  29. package/v3/@claude-flow/cli/dist/src/proxy/install.js +135 -0
  30. package/v3/@claude-flow/cli/dist/src/proxy/lifecycle.d.ts +61 -0
  31. package/v3/@claude-flow/cli/dist/src/proxy/lifecycle.js +249 -0
  32. package/v3/@claude-flow/cli/dist/src/proxy/paths.d.ts +34 -0
  33. package/v3/@claude-flow/cli/dist/src/proxy/paths.js +70 -0
  34. package/v3/@claude-flow/cli/dist/src/proxy/release.d.ts +47 -0
  35. package/v3/@claude-flow/cli/dist/src/proxy/release.js +138 -0
  36. package/v3/@claude-flow/cli/dist/src/proxy/token-bridge.d.ts +5 -0
  37. package/v3/@claude-flow/cli/dist/src/proxy/token-bridge.js +61 -0
  38. package/v3/@claude-flow/cli/dist/src/proxy/verify.d.ts +44 -0
  39. package/v3/@claude-flow/cli/dist/src/proxy/verify.js +68 -0
  40. package/v3/@claude-flow/cli/package.json +3 -3
  41. package/.claude/.proven-config-version +0 -1
  42. package/.claude/proven-config.json +0 -42
@@ -23,6 +23,8 @@ import { clearRateLimitStatus, readRateLimitStatus } from '../funnel/rate-limit-
23
23
  import { clearQuotaLowStatus, readQuotaLowStatus } from '../funnel/power-saver-notifier.js';
24
24
  import { getInstalledCliVersion } from '../init/helper-refresh.js';
25
25
  import * as path from 'path';
26
+ import { proxyLifecycleSubcommands, printProxyConsoleGuidance } from './proxy-lifecycle.js';
27
+ import { getProxyStatus } from '../proxy/lifecycle.js';
26
28
  const PROXY_CONFIG_FILE = 'proxy-config.toml';
27
29
  /**
28
30
  * Minimal hand-rolled TOML writer — this config has exactly one boolean
@@ -40,12 +42,12 @@ function readProxyConfigRaw() {
40
42
  return '';
41
43
  }
42
44
  }
43
- function writeConsentMirrorLine(field, value) {
45
+ function writeConfigLine(field, rawValue) {
44
46
  const dir = funnelStateDir();
45
47
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
46
48
  const target = path.join(dir, PROXY_CONFIG_FILE);
47
49
  const raw = readProxyConfigRaw();
48
- const line = `${field} = ${value}`;
50
+ const line = `${field} = ${rawValue}`;
49
51
  const pattern = new RegExp(`^${field}\\s*=.*$`, 'm');
50
52
  let next;
51
53
  if (pattern.test(raw)) {
@@ -58,6 +60,9 @@ function writeConsentMirrorLine(field, value) {
58
60
  fs.writeFileSync(tmp, next, { encoding: 'utf-8', mode: 0o600 });
59
61
  fs.renameSync(tmp, target);
60
62
  }
63
+ function writeConsentMirrorLine(field, value) {
64
+ writeConfigLine(field, String(value));
65
+ }
61
66
  function writeSponsoredConsentMirror(granted) {
62
67
  writeConsentMirrorLine('sponsored_consent_granted', granted);
63
68
  }
@@ -67,6 +72,78 @@ function writePowerSaverConsentMirror(granted) {
67
72
  function writeTrainingShareConsentMirror(granted) {
68
73
  writeConsentMirrorLine('training_share_consent_granted', granted);
69
74
  }
75
+ /**
76
+ * `default_data_plane` — the ADR-304 cloud-routing toggle. Values confirmed
77
+ * against meta-proxy's actual `DataPlane` enum (`src/config.rs`,
78
+ * `#[serde(rename_all = "snake_case")]`): "local" | "cloud" | "sponsored" |
79
+ * "passthrough" (the last two are not written by this command — sponsored
80
+ * is ADR-313's own consent flag, passthrough is the proxy's own default).
81
+ */
82
+ function readDataPlane() {
83
+ const raw = readProxyConfigRaw();
84
+ const match = raw.match(/^default_data_plane\s*=\s*"([^"]*)"/m);
85
+ return match ? match[1] : 'passthrough'; // matches the Rust struct's own default
86
+ }
87
+ function writeDataPlane(plane) {
88
+ writeConfigLine('default_data_plane', `"${plane}"`);
89
+ }
90
+ const CLOUD_ROUTING_DISCLOSURE = [
91
+ 'Enabling cloud routing.',
92
+ '',
93
+ 'With cloud routing ON, prompts for cloud-tier requests are sent to',
94
+ 'api.cognitum.one and forwarded to the selected provider',
95
+ '(Claude / GPT / Gemini / DeepSeek / OpenRouter).',
96
+ '',
97
+ 'Requests routed to local backends never leave this machine.',
98
+ '',
99
+ 'Disable anytime: ruflo proxy config --local-only',
100
+ ].join('\n');
101
+ const configSub = {
102
+ name: 'config',
103
+ description: 'Toggle cloud routing (ADR-304) — local backends only by default',
104
+ options: [
105
+ { name: 'cloud', description: 'Enable cloud routing (requires cloud-routing consent)', type: 'boolean', default: false },
106
+ { name: 'local-only', description: 'Disable cloud routing, revert to local-only routing', type: 'boolean', default: false },
107
+ { name: 'yes', description: 'Skip the confirmation prompt', type: 'boolean', default: false },
108
+ ],
109
+ action: async (ctx) => {
110
+ const wantCloud = Boolean(ctx.flags.cloud);
111
+ const wantLocalOnly = Boolean(ctx.flags.localOnly ?? ctx.flags['local-only']);
112
+ if (wantCloud && wantLocalOnly) {
113
+ output.printError('Pass either --cloud or --local-only, not both.');
114
+ return { success: false, exitCode: 1 };
115
+ }
116
+ if (!wantCloud && !wantLocalOnly) {
117
+ const plane = readDataPlane();
118
+ output.writeln(`Current data plane: ${plane}`);
119
+ output.writeln(plane === 'cloud'
120
+ ? 'Cloud routing is ON — cloud-tier requests go to api.cognitum.one.'
121
+ : 'Cloud routing is OFF — requests never leave this machine (or use your own Claude subscription on Passthrough).');
122
+ return { success: true, data: { plane } };
123
+ }
124
+ if (wantLocalOnly) {
125
+ writeDataPlane('local');
126
+ revokeConsent('cloud-routing', 'proxy-config-local-only');
127
+ output.printSuccess('Cloud routing disabled — reverted to local-only routing.');
128
+ return { success: true, data: { plane: 'local' } };
129
+ }
130
+ // wantCloud
131
+ if (!hasConsent('cloud-routing')) {
132
+ output.writeln(CLOUD_ROUTING_DISCLOSURE);
133
+ output.writeln('');
134
+ if (!ctx.flags.yes) {
135
+ output.writeln('Re-run with --yes to confirm: ruflo proxy config --cloud --yes');
136
+ return { success: true, data: { confirmed: false } };
137
+ }
138
+ recordConsent('cloud-routing', true, 'proxy-config-cloud');
139
+ }
140
+ writeDataPlane('cloud');
141
+ output.printSuccess('Cloud routing enabled.');
142
+ output.writeln(' Requests routed to local backends still never leave this machine.');
143
+ output.writeln(' Disable anytime: ruflo proxy config --local-only');
144
+ return { success: true, data: { plane: 'cloud' } };
145
+ },
146
+ };
70
147
  const SPONSOR_DISCLOSURE = [
71
148
  'Enabling sponsored downtime mode.',
72
149
  '',
@@ -292,19 +369,35 @@ const trainingShareStatusSub = {
292
369
  };
293
370
  export const proxyCommand = {
294
371
  name: 'proxy',
295
- description: 'Meta LLM Proxy — sponsored downtime + power saver + training-data sharing (ADR-304/307/313/314/315)',
372
+ description: 'Meta LLM Proxy — install/lifecycle + sponsored downtime + power saver + training-data sharing (ADR-304/307/313/314/315)',
296
373
  subcommands: [
374
+ ...proxyLifecycleSubcommands,
375
+ configSub,
297
376
  sponsorEnableSub, sponsorDisableSub, sponsorStatusSub, sponsorClearSub,
298
377
  powerSaverEnableSub, powerSaverDisableSub, powerSaverStatusSub, powerSaverClearSub,
299
378
  trainingShareEnableSub, trainingShareDisableSub, trainingShareStatusSub,
300
379
  ],
301
380
  examples: [
381
+ { command: 'ruflo proxy install --yes', description: 'Install the signed Meta-Proxy v0.4.0 binary' },
382
+ { command: 'ruflo proxy start', description: 'Start meta-proxy in the foreground' },
383
+ { command: 'ruflo proxy status', description: 'Show install + process status' },
384
+ { command: 'ruflo proxy config --cloud --yes', description: 'Enable cloud routing (ADR-304)' },
385
+ { command: 'ruflo proxy config --local-only', description: 'Revert to local-only routing' },
302
386
  { command: 'ruflo proxy sponsor-status', description: 'Show current sponsored-mode state' },
303
387
  { command: 'ruflo proxy sponsor-enable --yes', description: 'Opt into sponsored downtime capacity' },
304
388
  { command: 'ruflo proxy power-saver-enable --yes', description: 'Opt into power saver mode' },
305
389
  { command: 'ruflo proxy training-share-enable --yes', description: 'Opt into training-data sharing (ADR-315)' },
306
390
  ],
307
- action: sponsorStatusSub.action,
391
+ action: async () => {
392
+ const status = getProxyStatus();
393
+ output.writeln('Meta Proxy');
394
+ output.writeln(` Installation: ${status.installed ? 'ready' : 'not installed'}`);
395
+ output.writeln(` Process: ${status.running ? `running (pid ${status.pid})` : 'not running'}`);
396
+ if (status.stalePidFile)
397
+ output.writeln(' (a stale PID file was found and will be cleared on next start)');
398
+ printProxyConsoleGuidance(status);
399
+ return { success: true, data: status };
400
+ },
308
401
  };
309
402
  export default proxyCommand;
310
403
  //# sourceMappingURL=proxy.js.map
@@ -389,7 +389,7 @@ export const memoryTools = [
389
389
  type: 'object',
390
390
  properties: {
391
391
  query: { type: 'string', description: 'Search query (semantic similarity)' },
392
- namespace: { type: 'string', description: 'Namespace to search (default: "default")' },
392
+ namespace: { type: 'string', description: 'Namespace to search (default: all namespaces — omit to search across every namespace)' },
393
393
  limit: { type: 'number', description: 'Maximum results (default: 10)' },
394
394
  threshold: { type: 'number', description: 'Minimum similarity threshold 0-1 (default: 0.3)' },
395
395
  smart: { type: 'boolean', description: 'Enable SmartRetrieval pipeline — query expansion, RRF fusion, recency boost, MMR diversity (default: false)' },
@@ -400,10 +400,18 @@ export const memoryTools = [
400
400
  await ensureInitialized();
401
401
  const { searchEntries } = await getMemoryFunctions();
402
402
  const query = input.query;
403
- const namespace = input.namespace || 'default';
403
+ // #2646 (3rd occurrence of #1123/#1131 shape): do NOT coerce an omitted
404
+ // namespace to the literal string 'default' here. Both searchEntries()
405
+ // and bridgeSearchEntries() already resolve an omitted/undefined
406
+ // namespace to 'all' (fan out across every namespace) — but 'default'
407
+ // is truthy, so passing it defeats that fallback and silently scopes
408
+ // the search to a namespace that is usually empty. Leave namespace
409
+ // undefined when not provided so the underlying `|| 'all'` fallback
410
+ // in the search layer actually fires.
411
+ const namespace = input.namespace;
404
412
  const limit = input.limit ?? 10;
405
413
  const threshold = input.threshold ?? 0.3;
406
- validateMemoryInput(undefined, undefined, query);
414
+ validateMemoryInput(undefined, undefined, query, namespace);
407
415
  const startTime = performance.now();
408
416
  try {
409
417
  // #1846: feature-detect smartSearch on the resolved memory package.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `ruflo proxy install`/`update` orchestration (ADR-307): download -> verify
3
+ * -> extract -> place -> record. The per-user bearer token
4
+ * (`~/.ruflo/proxy-token`) is NOT generated here — confirmed empirically
5
+ * (2026-07-16) that the meta-proxy binary itself creates it on first launch
6
+ * (`load_or_create_token()`), so this module's job ends at a verified binary
7
+ * on disk plus an install manifest doctor can check against.
8
+ *
9
+ * @module proxy/install
10
+ */
11
+ export declare class ExtractionError extends Error {
12
+ constructor(message: string);
13
+ }
14
+ export interface InstallOptions {
15
+ version: string;
16
+ log?: (line: string) => void;
17
+ }
18
+ export interface InstallResult {
19
+ version: string;
20
+ binaryPath: string;
21
+ sha256: string;
22
+ }
23
+ /**
24
+ * Full install pipeline. Refuses (throws) on any verification failure —
25
+ * never writes a partially-verified binary into the live install path.
26
+ */
27
+ export declare function installProxy(opts: InstallOptions): Promise<InstallResult>;
28
+ export declare function uninstallProxy(): Promise<boolean>;
29
+ //# sourceMappingURL=install.d.ts.map
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `ruflo proxy install`/`update` orchestration (ADR-307): download -> verify
3
+ * -> extract -> place -> record. The per-user bearer token
4
+ * (`~/.ruflo/proxy-token`) is NOT generated here — confirmed empirically
5
+ * (2026-07-16) that the meta-proxy binary itself creates it on first launch
6
+ * (`load_or_create_token()`), so this module's job ends at a verified binary
7
+ * on disk plus an install manifest doctor can check against.
8
+ *
9
+ * @module proxy/install
10
+ */
11
+ import * as fs from 'node:fs';
12
+ import * as os from 'node:os';
13
+ import * as path from 'node:path';
14
+ import { fetchReleaseAssets, detectTargetTriple, releaseArchiveExtension, releaseAssetFilename } from './release.js';
15
+ import { verifyRelease, sha256Hex, PROXY_RELEASE_PUBKEY_PEM } from './verify.js';
16
+ import { proxyBinaryPath, proxyInstallManifestPath } from './paths.js';
17
+ export class ExtractionError extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = 'ExtractionError';
21
+ }
22
+ }
23
+ function binaryNameInArchive() {
24
+ return process.platform === 'win32' ? 'meta-proxy.exe' : 'meta-proxy';
25
+ }
26
+ /**
27
+ * Extracts the archive via the OS's own tools — `tar` for `.tar.gz`
28
+ * (present on macOS/Linux/Windows 10+), PowerShell `Expand-Archive`
29
+ * specifically for `.zip` on Windows (not tar's bsdtar zip support — not
30
+ * reliable enough to lean on). Zero new archive-parsing dependency, matching
31
+ * this repo's existing taste for shelling out over adding a parser dep.
32
+ */
33
+ async function extractArchive(archivePath, extractDir, ext) {
34
+ const { SafeExecutor } = await import('@claude-flow/security');
35
+ fs.mkdirSync(extractDir, { recursive: true });
36
+ if (ext === 'tar.gz') {
37
+ const exec = new SafeExecutor({ allowedCommands: ['tar'], timeout: 60_000 });
38
+ const result = await exec.execute('tar', ['xzf', archivePath, '-C', extractDir]);
39
+ if (result.exitCode !== 0) {
40
+ throw new ExtractionError(`tar extraction failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
41
+ }
42
+ return;
43
+ }
44
+ // .zip — PowerShell Expand-Archive, single-quoted literal paths (doubling
45
+ // any embedded single quote per PowerShell string-literal escaping) passed
46
+ // as ONE argv element to -Command. shell:false means no OS shell ever
47
+ // tokenizes this string — only powershell.exe's own parser does.
48
+ const escape = (p) => p.replace(/'/g, "''");
49
+ const command = `Expand-Archive -LiteralPath '${escape(archivePath)}' -DestinationPath '${escape(extractDir)}' -Force`;
50
+ const exec = new SafeExecutor({ allowedCommands: ['powershell', 'powershell.exe'], timeout: 60_000 });
51
+ const result = await exec.execute('powershell', ['-NoProfile', '-NonInteractive', '-Command', command]);
52
+ if (result.exitCode !== 0) {
53
+ throw new ExtractionError(`Expand-Archive failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
54
+ }
55
+ }
56
+ /**
57
+ * Full install pipeline. Refuses (throws) on any verification failure —
58
+ * never writes a partially-verified binary into the live install path.
59
+ */
60
+ export async function installProxy(opts) {
61
+ const log = opts.log ?? (() => { });
62
+ const triple = detectTargetTriple();
63
+ const archiveFilename = releaseAssetFilename(opts.version, triple);
64
+ const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruflo-proxy-install-'));
65
+ try {
66
+ log(`Fetching meta-proxy ${opts.version} (${triple})...`);
67
+ const assets = await fetchReleaseAssets(opts.version, triple, workDir, log);
68
+ log('Verifying release signature and checksum...');
69
+ const { sha256 } = verifyRelease({
70
+ sumsBytes: assets.sumsBytes,
71
+ sigBase64: assets.sigBase64,
72
+ assetBytes: assets.archiveBytes,
73
+ assetFilename: assets.archiveFilename,
74
+ });
75
+ log(`Verified — sha256 ${sha256.slice(0, 16)}…`);
76
+ // fetchReleaseAssets's dev (gh) path already wrote the archive to workDir
77
+ // under archiveFilename; ensure it's there regardless of source so
78
+ // extraction always has a real file to operate on.
79
+ const archivePath = path.join(workDir, archiveFilename);
80
+ if (!fs.existsSync(archivePath)) {
81
+ fs.writeFileSync(archivePath, assets.archiveBytes);
82
+ }
83
+ const extractDir = path.join(workDir, 'extracted');
84
+ await extractArchive(archivePath, extractDir, releaseArchiveExtension(triple));
85
+ const extractedBinaryPath = path.join(extractDir, binaryNameInArchive());
86
+ if (!fs.existsSync(extractedBinaryPath)) {
87
+ throw new ExtractionError(`archive did not contain the expected binary at its root: ${binaryNameInArchive()}`);
88
+ }
89
+ // Defense in depth: confirm the extracted binary genuinely resolves
90
+ // inside extractDir (catches a symlink swap or similar), even though
91
+ // we only ever read one specific expected relative path, never an
92
+ // archive-listed one (so "zip slip" via arbitrary archive paths isn't
93
+ // reachable here in the first place).
94
+ const { PathValidator } = await import('@claude-flow/security');
95
+ const validator = new PathValidator({ allowedPrefixes: [extractDir] });
96
+ const validation = await validator.validate(extractedBinaryPath);
97
+ if (!validation.isValid) {
98
+ throw new ExtractionError(`extracted binary path failed validation: ${validation.errors.join('; ') || 'unknown'}`);
99
+ }
100
+ const finalPath = proxyBinaryPath();
101
+ fs.mkdirSync(path.dirname(finalPath), { recursive: true, mode: 0o700 });
102
+ const tmp = `${finalPath}.tmp`;
103
+ fs.copyFileSync(extractedBinaryPath, tmp);
104
+ fs.chmodSync(tmp, 0o755);
105
+ fs.renameSync(tmp, finalPath);
106
+ const liveSha = sha256Hex(fs.readFileSync(finalPath));
107
+ const manifest = {
108
+ version: opts.version,
109
+ sha256: liveSha,
110
+ verifiedAt: new Date().toISOString(),
111
+ pubkeyFingerprint: sha256Hex(Buffer.from(PROXY_RELEASE_PUBKEY_PEM)).slice(0, 16),
112
+ };
113
+ fs.mkdirSync(path.dirname(proxyInstallManifestPath()), { recursive: true });
114
+ fs.writeFileSync(proxyInstallManifestPath(), JSON.stringify(manifest, null, 2), { mode: 0o600 });
115
+ log(`meta-proxy ${opts.version} installed at ${finalPath}`);
116
+ return { version: opts.version, binaryPath: finalPath, sha256: liveSha };
117
+ }
118
+ finally {
119
+ fs.rmSync(workDir, { recursive: true, force: true });
120
+ }
121
+ }
122
+ export async function uninstallProxy() {
123
+ const binPath = proxyBinaryPath();
124
+ const existed = fs.existsSync(binPath);
125
+ if (existed)
126
+ fs.unlinkSync(binPath);
127
+ try {
128
+ fs.unlinkSync(proxyInstallManifestPath());
129
+ }
130
+ catch {
131
+ /* absent — fine */
132
+ }
133
+ return existed;
134
+ }
135
+ //# sourceMappingURL=install.js.map
@@ -0,0 +1,61 @@
1
+ /**
2
+ * meta-proxy process lifecycle (ADR-307) — start/stop/status/logs.
3
+ *
4
+ * Adapts daemon.ts's proven pattern (PID file, O_EXCL lockfile for atomic
5
+ * check-then-start, signal-0 liveness, SIGTERM->1000ms->SIGKILL) to a
6
+ * native binary instead of a forked Node process. The binary itself takes
7
+ * no CLI flags — confirmed empirically (2026-07-16): `meta-proxy.exe` has
8
+ * no `--version`/`--help`, and any invocation just starts the server reading
9
+ * its own config file — so `spawn()` here passes zero arguments, always.
10
+ *
11
+ * Foreground `start` (the ADR-307 default) uses `stdio: 'inherit'` and
12
+ * blocks directly — simplest and safest, no log-file redirection needed.
13
+ * `start --service` needs REAL file-descriptor redirection
14
+ * (`stdio: ['ignore', fd, fd]`) + `detached: true` + `unref()` via
15
+ * `child_process.spawn()` directly — `SafeExecutor.executeStreaming()`
16
+ * buffers output in-process, which is wrong for a process meant to outlive
17
+ * the `ruflo` invocation that started it.
18
+ *
19
+ * @module proxy/lifecycle
20
+ */
21
+ import * as fs from 'node:fs';
22
+ export declare class ProxyNotInstalledError extends Error {
23
+ constructor();
24
+ }
25
+ export declare class ProxyAlreadyRunningError extends Error {
26
+ readonly pid: number;
27
+ constructor(pid: number);
28
+ }
29
+ export interface ProxyStatus {
30
+ installed: boolean;
31
+ running: boolean;
32
+ pid: number | null;
33
+ stalePidFile: boolean;
34
+ }
35
+ export declare function getProxyStatus(): ProxyStatus;
36
+ /**
37
+ * Foreground start (ADR-307 default) — blocks the caller until the process
38
+ * exits or is interrupted. `stdio: 'inherit'` passes the proxy's own output
39
+ * straight through to the terminal; signals (Ctrl+C) propagate naturally to
40
+ * the child, no manual forwarding needed.
41
+ */
42
+ export declare function startForeground(supervised?: boolean): Promise<never>;
43
+ /**
44
+ * Background/`--service` start — detaches so the process outlives this
45
+ * `ruflo` invocation, redirecting stdout/stderr to a real log file (not
46
+ * buffered in-process). Returns once the child's PID is confirmed written,
47
+ * without waiting for the process to exit.
48
+ */
49
+ export declare function startBackground(): Promise<{
50
+ pid: number;
51
+ }>;
52
+ export interface StopResult {
53
+ wasRunning: boolean;
54
+ pid: number | null;
55
+ }
56
+ /** SIGTERM -> 1000ms -> SIGKILL if still alive, mirroring daemon.ts's killBackgroundDaemon. */
57
+ export declare function stopProxy(): Promise<StopResult>;
58
+ export declare function readProxyLogTail(maxBytes?: number): string;
59
+ /** Streams new log lines as they're appended, starting from the current end of file. */
60
+ export declare function watchProxyLog(onLine: (line: string) => void): fs.FSWatcher;
61
+ //# sourceMappingURL=lifecycle.d.ts.map
@@ -0,0 +1,249 @@
1
+ /**
2
+ * meta-proxy process lifecycle (ADR-307) — start/stop/status/logs.
3
+ *
4
+ * Adapts daemon.ts's proven pattern (PID file, O_EXCL lockfile for atomic
5
+ * check-then-start, signal-0 liveness, SIGTERM->1000ms->SIGKILL) to a
6
+ * native binary instead of a forked Node process. The binary itself takes
7
+ * no CLI flags — confirmed empirically (2026-07-16): `meta-proxy.exe` has
8
+ * no `--version`/`--help`, and any invocation just starts the server reading
9
+ * its own config file — so `spawn()` here passes zero arguments, always.
10
+ *
11
+ * Foreground `start` (the ADR-307 default) uses `stdio: 'inherit'` and
12
+ * blocks directly — simplest and safest, no log-file redirection needed.
13
+ * `start --service` needs REAL file-descriptor redirection
14
+ * (`stdio: ['ignore', fd, fd]`) + `detached: true` + `unref()` via
15
+ * `child_process.spawn()` directly — `SafeExecutor.executeStreaming()`
16
+ * buffers output in-process, which is wrong for a process meant to outlive
17
+ * the `ruflo` invocation that started it.
18
+ *
19
+ * @module proxy/lifecycle
20
+ */
21
+ import { spawn } from 'node:child_process';
22
+ import * as fs from 'node:fs';
23
+ import { proxyBinaryPath, proxyPidFilePath, proxyLockFilePath, proxyLogFilePath } from './paths.js';
24
+ export class ProxyNotInstalledError extends Error {
25
+ constructor() {
26
+ super('meta-proxy is not installed. Run: ruflo proxy install');
27
+ this.name = 'ProxyNotInstalledError';
28
+ }
29
+ }
30
+ export class ProxyAlreadyRunningError extends Error {
31
+ pid;
32
+ constructor(pid) {
33
+ super(`meta-proxy is already running (pid ${pid}). Stop it first with: ruflo proxy stop`);
34
+ this.pid = pid;
35
+ this.name = 'ProxyAlreadyRunningError';
36
+ }
37
+ }
38
+ function requireBinary() {
39
+ const bin = proxyBinaryPath();
40
+ if (!fs.existsSync(bin))
41
+ throw new ProxyNotInstalledError();
42
+ return bin;
43
+ }
44
+ /** Signal-0 liveness probe — cross-platform in Node, throws if the process is dead. */
45
+ function isProcessRunning(pid) {
46
+ try {
47
+ process.kill(pid, 0);
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ export function getProxyStatus() {
55
+ const installed = fs.existsSync(proxyBinaryPath());
56
+ const pidPath = proxyPidFilePath();
57
+ if (!fs.existsSync(pidPath)) {
58
+ return { installed, running: false, pid: null, stalePidFile: false };
59
+ }
60
+ const raw = fs.readFileSync(pidPath, 'utf-8').trim();
61
+ const pid = parseInt(raw, 10);
62
+ if (!Number.isFinite(pid)) {
63
+ return { installed, running: false, pid: null, stalePidFile: true };
64
+ }
65
+ const running = isProcessRunning(pid);
66
+ return { installed, running, pid: running ? pid : null, stalePidFile: !running };
67
+ }
68
+ function writePidFile(pid) {
69
+ fs.writeFileSync(proxyPidFilePath(), String(pid), 'utf-8');
70
+ }
71
+ function clearStalePidFile() {
72
+ try {
73
+ fs.unlinkSync(proxyPidFilePath());
74
+ }
75
+ catch {
76
+ // already absent
77
+ }
78
+ }
79
+ /**
80
+ * Atomic check-then-start via an O_EXCL lockfile — the same fix daemon.ts
81
+ * applies for the identical race (#2407/#2484: without this, N concurrent
82
+ * `start` calls all see "not running" and all spawn their own process).
83
+ * Returns the lock fd to release once the PID file has been written (or on
84
+ * any early-return/throw), never before.
85
+ */
86
+ function acquireStartLock() {
87
+ const lockFile = proxyLockFilePath();
88
+ try {
89
+ return fs.openSync(lockFile, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
90
+ }
91
+ catch (e) {
92
+ if (e.code === 'EEXIST')
93
+ return null; // another start is mid-spawn
94
+ throw e;
95
+ }
96
+ }
97
+ function releaseStartLock(fd) {
98
+ if (fd === null)
99
+ return;
100
+ try {
101
+ fs.closeSync(fd);
102
+ }
103
+ catch {
104
+ /* ignore */
105
+ }
106
+ try {
107
+ fs.unlinkSync(proxyLockFilePath());
108
+ }
109
+ catch {
110
+ /* ignore */
111
+ }
112
+ }
113
+ /**
114
+ * Foreground start (ADR-307 default) — blocks the caller until the process
115
+ * exits or is interrupted. `stdio: 'inherit'` passes the proxy's own output
116
+ * straight through to the terminal; signals (Ctrl+C) propagate naturally to
117
+ * the child, no manual forwarding needed.
118
+ */
119
+ export async function startForeground(supervised = false) {
120
+ const bin = requireBinary();
121
+ const status = getProxyStatus();
122
+ // In service mode startBackground has already written this supervisor's
123
+ // PID. Treating it as a competing proxy makes the supervisor immediately
124
+ // exit before it can spawn meta-proxy.
125
+ if (!supervised && status.running && status.pid)
126
+ throw new ProxyAlreadyRunningError(status.pid);
127
+ if (status.stalePidFile)
128
+ clearStalePidFile();
129
+ const child = spawn(bin, [], { stdio: 'inherit', windowsHide: false });
130
+ if (!supervised && child.pid)
131
+ writePidFile(child.pid);
132
+ const cleanup = () => clearStalePidFile();
133
+ process.on('exit', cleanup);
134
+ if (supervised) {
135
+ const forwardSignal = (signal) => {
136
+ if (!child.killed)
137
+ child.kill(signal);
138
+ };
139
+ process.once('SIGTERM', () => forwardSignal('SIGTERM'));
140
+ process.once('SIGINT', () => forwardSignal('SIGINT'));
141
+ }
142
+ await new Promise((resolve) => {
143
+ child.on('exit', () => {
144
+ cleanup();
145
+ resolve();
146
+ });
147
+ });
148
+ process.exit(0);
149
+ }
150
+ /**
151
+ * Background/`--service` start — detaches so the process outlives this
152
+ * `ruflo` invocation, redirecting stdout/stderr to a real log file (not
153
+ * buffered in-process). Returns once the child's PID is confirmed written,
154
+ * without waiting for the process to exit.
155
+ */
156
+ export async function startBackground() {
157
+ requireBinary();
158
+ const status = getProxyStatus();
159
+ if (status.running && status.pid)
160
+ throw new ProxyAlreadyRunningError(status.pid);
161
+ const lockFd = acquireStartLock();
162
+ try {
163
+ // Re-check under the lock — the dedup above raced a concurrent starter.
164
+ const rechecked = getProxyStatus();
165
+ if (rechecked.running && rechecked.pid)
166
+ throw new ProxyAlreadyRunningError(rechecked.pid);
167
+ if (rechecked.stalePidFile)
168
+ clearStalePidFile();
169
+ const logFd = fs.openSync(proxyLogFilePath(), 'a');
170
+ const cliEntry = process.argv[1];
171
+ if (!cliEntry)
172
+ throw new Error('cannot locate the ruflo CLI entrypoint for supervised service mode');
173
+ const child = spawn(process.execPath, [cliEntry, 'proxy', 'supervise'], {
174
+ stdio: ['ignore', logFd, logFd],
175
+ detached: true,
176
+ windowsHide: true,
177
+ });
178
+ fs.closeSync(logFd); // the child holds its own duplicated fd; safe to close ours
179
+ if (!child.pid)
180
+ throw new Error('ruflo proxy supervisor failed to spawn — no PID returned');
181
+ writePidFile(child.pid);
182
+ child.unref();
183
+ return { pid: child.pid };
184
+ }
185
+ finally {
186
+ releaseStartLock(lockFd);
187
+ }
188
+ }
189
+ /** SIGTERM -> 1000ms -> SIGKILL if still alive, mirroring daemon.ts's killBackgroundDaemon. */
190
+ export async function stopProxy() {
191
+ const status = getProxyStatus();
192
+ if (!status.running || !status.pid) {
193
+ if (status.stalePidFile)
194
+ clearStalePidFile();
195
+ return { wasRunning: false, pid: null };
196
+ }
197
+ const pid = status.pid;
198
+ process.kill(pid, 'SIGTERM');
199
+ await new Promise((r) => setTimeout(r, 1000));
200
+ if (isProcessRunning(pid)) {
201
+ process.kill(pid, 'SIGKILL');
202
+ }
203
+ clearStalePidFile();
204
+ return { wasRunning: true, pid };
205
+ }
206
+ const TAIL_BYTES = 64 * 1024; // bounded read — a long-running proxy's log grows over weeks
207
+ export function readProxyLogTail(maxBytes = TAIL_BYTES) {
208
+ const logPath = proxyLogFilePath();
209
+ if (!fs.existsSync(logPath))
210
+ return '';
211
+ const { size } = fs.statSync(logPath);
212
+ const start = Math.max(0, size - maxBytes);
213
+ const fd = fs.openSync(logPath, 'r');
214
+ try {
215
+ const buf = Buffer.alloc(size - start);
216
+ fs.readSync(fd, buf, 0, buf.length, start);
217
+ return buf.toString('utf-8');
218
+ }
219
+ finally {
220
+ fs.closeSync(fd);
221
+ }
222
+ }
223
+ /** Streams new log lines as they're appended, starting from the current end of file. */
224
+ export function watchProxyLog(onLine) {
225
+ const logPath = proxyLogFilePath();
226
+ if (!fs.existsSync(logPath)) {
227
+ throw new Error('no log file yet — meta-proxy has never been started in --service mode');
228
+ }
229
+ let offset = fs.statSync(logPath).size;
230
+ return fs.watch(logPath, () => {
231
+ const { size } = fs.statSync(logPath);
232
+ if (size <= offset)
233
+ return;
234
+ const fd = fs.openSync(logPath, 'r');
235
+ try {
236
+ const buf = Buffer.alloc(size - offset);
237
+ fs.readSync(fd, buf, 0, buf.length, offset);
238
+ offset = size;
239
+ for (const line of buf.toString('utf-8').split('\n')) {
240
+ if (line.length > 0)
241
+ onLine(line);
242
+ }
243
+ }
244
+ finally {
245
+ fs.closeSync(fd);
246
+ }
247
+ });
248
+ }
249
+ //# sourceMappingURL=lifecycle.js.map