autonomous-flow-daemon 1.0.0 → 1.6.0

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 (59) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.ko.md +142 -125
  3. package/README.md +119 -134
  4. package/package.json +11 -5
  5. package/src/adapters/index.ts +247 -35
  6. package/src/cli.ts +79 -1
  7. package/src/commands/benchmark.ts +187 -0
  8. package/src/commands/diagnose.ts +56 -14
  9. package/src/commands/doctor.ts +243 -0
  10. package/src/commands/evolution.ts +107 -0
  11. package/src/commands/fix.ts +22 -2
  12. package/src/commands/hooks.ts +136 -0
  13. package/src/commands/lang.ts +41 -0
  14. package/src/commands/mcp.ts +129 -0
  15. package/src/commands/restart.ts +14 -0
  16. package/src/commands/score.ts +192 -64
  17. package/src/commands/start.ts +137 -37
  18. package/src/commands/stats.ts +103 -0
  19. package/src/commands/status.ts +157 -0
  20. package/src/commands/stop.ts +42 -9
  21. package/src/commands/sync.ts +253 -20
  22. package/src/commands/vaccine.ts +177 -0
  23. package/src/constants.ts +26 -1
  24. package/src/core/boast.ts +280 -0
  25. package/src/core/config.ts +49 -0
  26. package/src/core/db.ts +74 -3
  27. package/src/core/discovery.ts +65 -0
  28. package/src/core/evolution.ts +215 -0
  29. package/src/core/hologram/engine.ts +71 -0
  30. package/src/core/hologram/fallback.ts +11 -0
  31. package/src/core/hologram/incremental.ts +227 -0
  32. package/src/core/hologram/py-extractor.ts +132 -0
  33. package/src/core/hologram/ts-extractor.ts +320 -0
  34. package/src/core/hologram/types.ts +25 -0
  35. package/src/core/hologram.ts +64 -236
  36. package/src/core/hook-manager.ts +259 -0
  37. package/src/core/i18n/messages.ts +309 -0
  38. package/src/core/immune.ts +8 -123
  39. package/src/core/locale.ts +88 -0
  40. package/src/core/log-rotate.ts +33 -0
  41. package/src/core/log-utils.ts +38 -0
  42. package/src/core/lru-map.ts +61 -0
  43. package/src/core/notify.ts +53 -14
  44. package/src/core/rule-engine.ts +287 -0
  45. package/src/core/semantic-diff.ts +432 -0
  46. package/src/core/telemetry.ts +94 -0
  47. package/src/core/vaccine-registry.ts +212 -0
  48. package/src/core/workspace.ts +28 -0
  49. package/src/core/yaml-minimal.ts +176 -0
  50. package/src/daemon/client.ts +34 -6
  51. package/src/daemon/event-batcher.ts +108 -0
  52. package/src/daemon/guards.ts +13 -0
  53. package/src/daemon/http-routes.ts +293 -0
  54. package/src/daemon/mcp-handler.ts +270 -0
  55. package/src/daemon/server.ts +492 -273
  56. package/src/daemon/types.ts +100 -0
  57. package/src/daemon/workspace-map.ts +92 -0
  58. package/src/platform.ts +60 -0
  59. package/src/version.ts +15 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Lightweight telemetry helpers for CLI-side tracking.
3
+ * Uses a short-lived DB connection — no daemon required.
4
+ */
5
+
6
+ import { Database } from "bun:sqlite";
7
+ import { resolveWorkspacePaths } from "../constants";
8
+ import { existsSync } from "fs";
9
+
10
+ function openDb(): Database | null {
11
+ try {
12
+ const paths = resolveWorkspacePaths();
13
+ if (!existsSync(paths.dbFile)) return null;
14
+ const db = new Database(paths.dbFile);
15
+ db.exec("PRAGMA journal_mode = WAL");
16
+ return db;
17
+ } catch { return null; }
18
+ }
19
+
20
+ /** Track a CLI command invocation (fire-and-forget). */
21
+ export function trackCliCommand(command: string) {
22
+ const db = openDb();
23
+ if (!db) return;
24
+ try {
25
+ db.prepare("INSERT INTO telemetry (category, action, timestamp) VALUES (?, ?, ?)").run("cli", command, Date.now());
26
+ } catch { /* table may not exist yet — ignore */ }
27
+ finally { db.close(); }
28
+ }
29
+
30
+ export interface TelemetryRow {
31
+ category: string;
32
+ action: string;
33
+ detail: string | null;
34
+ duration_ms: number | null;
35
+ timestamp: number;
36
+ }
37
+
38
+ export interface TelemetrySummary {
39
+ cli: Record<string, number>;
40
+ mcp: Record<string, number>;
41
+ seam: { counts: Record<string, number>; avgDurationMs: Record<string, number> };
42
+ immune: Record<string, number>;
43
+ validator: Record<string, number>;
44
+ totalEvents: number;
45
+ periodDays: number;
46
+ }
47
+
48
+ /** Query aggregated telemetry for the last N days. */
49
+ export function queryTelemetry(days: number): TelemetrySummary {
50
+ const db = openDb();
51
+ const empty: TelemetrySummary = { cli: {}, mcp: {}, seam: { counts: {}, avgDurationMs: {} }, immune: {}, validator: {}, totalEvents: 0, periodDays: days };
52
+ if (!db) return empty;
53
+
54
+ try {
55
+ const since = Date.now() - days * 86_400_000;
56
+ const rows = db.prepare(
57
+ "SELECT category, action, duration_ms FROM telemetry WHERE timestamp >= ?"
58
+ ).all(since) as { category: string; action: string; duration_ms: number | null }[];
59
+
60
+ const result = { ...empty };
61
+ const seamDurations: Record<string, number[]> = {};
62
+
63
+ for (const row of rows) {
64
+ result.totalEvents++;
65
+ switch (row.category) {
66
+ case "cli":
67
+ result.cli[row.action] = (result.cli[row.action] ?? 0) + 1;
68
+ break;
69
+ case "mcp":
70
+ result.mcp[row.action] = (result.mcp[row.action] ?? 0) + 1;
71
+ break;
72
+ case "seam":
73
+ result.seam.counts[row.action] = (result.seam.counts[row.action] ?? 0) + 1;
74
+ if (row.duration_ms != null) {
75
+ (seamDurations[row.action] ??= []).push(row.duration_ms);
76
+ }
77
+ break;
78
+ case "immune":
79
+ result.immune[row.action] = (result.immune[row.action] ?? 0) + 1;
80
+ break;
81
+ case "validator":
82
+ result.validator[row.action] = (result.validator[row.action] ?? 0) + 1;
83
+ break;
84
+ }
85
+ }
86
+
87
+ for (const [action, durations] of Object.entries(seamDurations)) {
88
+ result.seam.avgDurationMs[action] = Math.round(durations.reduce((a, b) => a + b, 0) / durations.length);
89
+ }
90
+
91
+ return result;
92
+ } catch { return empty; }
93
+ finally { db.close(); }
94
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Vaccine Registry — central antibody package system
3
+ *
4
+ * Manages vaccine packages that can be published, searched, and installed.
5
+ * Registry index is stored at .afd/registry/index.json
6
+ * Each package is a directory under .afd/registry/packages/<name>/
7
+ */
8
+
9
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
10
+ import { join } from "path";
11
+ import { AFD_DIR } from "../constants";
12
+
13
+ export interface VaccinePackage {
14
+ name: string;
15
+ version: string;
16
+ description: string;
17
+ author: string;
18
+ ecosystem: string;
19
+ antibodies: VaccineAntibodyDef[];
20
+ createdAt: string;
21
+ signature?: string; // future: cryptographic signature
22
+ }
23
+
24
+ export interface VaccineAntibodyDef {
25
+ id: string;
26
+ title: string;
27
+ description: string;
28
+ severity: "critical" | "warning" | "info";
29
+ condition: {
30
+ type: string;
31
+ path: string;
32
+ pattern?: string;
33
+ minLength?: number;
34
+ };
35
+ patches: { op: string; path: string; value?: string }[];
36
+ }
37
+
38
+ export interface RegistryIndex {
39
+ version: string;
40
+ updatedAt: string;
41
+ packages: RegistryEntry[];
42
+ }
43
+
44
+ export interface RegistryEntry {
45
+ name: string;
46
+ version: string;
47
+ description: string;
48
+ author: string;
49
+ ecosystem: string;
50
+ antibodyCount: number;
51
+ }
52
+
53
+ const REGISTRY_DIR = join(AFD_DIR, "registry");
54
+ const PACKAGES_DIR = join(REGISTRY_DIR, "packages");
55
+ const INDEX_FILE = join(REGISTRY_DIR, "index.json");
56
+
57
+ function ensureDirs() {
58
+ mkdirSync(PACKAGES_DIR, { recursive: true });
59
+ }
60
+
61
+ function loadIndex(): RegistryIndex {
62
+ if (!existsSync(INDEX_FILE)) {
63
+ return { version: "1.0.0", updatedAt: new Date().toISOString(), packages: [] };
64
+ }
65
+ try {
66
+ return JSON.parse(readFileSync(INDEX_FILE, "utf-8"));
67
+ } catch {
68
+ return { version: "1.0.0", updatedAt: new Date().toISOString(), packages: [] };
69
+ }
70
+ }
71
+
72
+ function saveIndex(index: RegistryIndex) {
73
+ ensureDirs();
74
+ index.updatedAt = new Date().toISOString();
75
+ writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), "utf-8");
76
+ }
77
+
78
+ /** Publish a vaccine package to the local registry */
79
+ export function publishPackage(pkg: VaccinePackage): { success: boolean; message: string } {
80
+ ensureDirs();
81
+
82
+ const pkgDir = join(PACKAGES_DIR, pkg.name);
83
+ mkdirSync(pkgDir, { recursive: true });
84
+ writeFileSync(join(pkgDir, "vaccine.json"), JSON.stringify(pkg, null, 2), "utf-8");
85
+
86
+ // Update index
87
+ const index = loadIndex();
88
+ const existing = index.packages.findIndex(p => p.name === pkg.name);
89
+ const entry: RegistryEntry = {
90
+ name: pkg.name,
91
+ version: pkg.version,
92
+ description: pkg.description,
93
+ author: pkg.author,
94
+ ecosystem: pkg.ecosystem,
95
+ antibodyCount: pkg.antibodies.length,
96
+ };
97
+
98
+ if (existing >= 0) {
99
+ index.packages[existing] = entry;
100
+ } else {
101
+ index.packages.push(entry);
102
+ }
103
+ saveIndex(index);
104
+
105
+ return { success: true, message: `Published ${pkg.name}@${pkg.version} (${pkg.antibodies.length} antibodies)` };
106
+ }
107
+
108
+ /** Search packages in the registry */
109
+ export function searchPackages(query?: string): RegistryEntry[] {
110
+ const index = loadIndex();
111
+ if (!query) return index.packages;
112
+ const q = query.toLowerCase();
113
+ return index.packages.filter(p =>
114
+ p.name.toLowerCase().includes(q) ||
115
+ p.description.toLowerCase().includes(q) ||
116
+ p.ecosystem.toLowerCase().includes(q)
117
+ );
118
+ }
119
+
120
+ /** Get full package details */
121
+ export function getPackage(name: string): VaccinePackage | null {
122
+ const pkgFile = join(PACKAGES_DIR, name, "vaccine.json");
123
+ if (!existsSync(pkgFile)) return null;
124
+ try {
125
+ return JSON.parse(readFileSync(pkgFile, "utf-8"));
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /** Install a package — writes antibody rules to .afd/rules/ */
132
+ export function installPackage(name: string): { success: boolean; installed: number; message: string } {
133
+ const pkg = getPackage(name);
134
+ if (!pkg) return { success: false, installed: 0, message: `Package '${name}' not found` };
135
+
136
+ const rulesDir = join(AFD_DIR, "rules");
137
+ mkdirSync(rulesDir, { recursive: true });
138
+
139
+ let installed = 0;
140
+ for (const ab of pkg.antibodies) {
141
+ const ruleContent = buildYamlRule(ab);
142
+ const ruleFile = join(rulesDir, `${pkg.name}-${ab.id}.yml`);
143
+ writeFileSync(ruleFile, ruleContent, "utf-8");
144
+ installed++;
145
+ }
146
+
147
+ return {
148
+ success: true,
149
+ installed,
150
+ message: `Installed ${pkg.name}@${pkg.version}: ${installed} rules added to .afd/rules/`,
151
+ };
152
+ }
153
+
154
+ /** List installed packages (by checking .afd/rules/ for package-prefixed files) */
155
+ export function listInstalled(): string[] {
156
+ const rulesDir = join(AFD_DIR, "rules");
157
+ if (!existsSync(rulesDir)) return [];
158
+ if (!existsSync(PACKAGES_DIR)) return [];
159
+
160
+ // Get all known package names from registry
161
+ let knownPackages: string[];
162
+ try {
163
+ knownPackages = readdirSync(PACKAGES_DIR);
164
+ } catch {
165
+ return [];
166
+ }
167
+
168
+ const files = readdirSync(rulesDir).filter(f => f.endsWith(".yml") || f.endsWith(".yaml"));
169
+ const installed = new Set<string>();
170
+
171
+ for (const pkgName of knownPackages) {
172
+ // Check if any rule file starts with this package name
173
+ if (files.some(f => f.startsWith(`${pkgName}-`))) {
174
+ installed.add(pkgName);
175
+ }
176
+ }
177
+
178
+ return [...installed];
179
+ }
180
+
181
+ function buildYamlRule(ab: VaccineAntibodyDef): string {
182
+ const lines: string[] = [
183
+ `# Auto-generated by afd vaccine registry`,
184
+ `id: ${ab.id}`,
185
+ `title: "${ab.title}"`,
186
+ `description: "${ab.description}"`,
187
+ `severity: ${ab.severity}`,
188
+ `condition:`,
189
+ ` type: ${ab.condition.type}`,
190
+ ` path: ${ab.condition.path}`,
191
+ ];
192
+
193
+ if (ab.condition.pattern) {
194
+ lines.push(` pattern: "${ab.condition.pattern}"`);
195
+ }
196
+ if (ab.condition.minLength !== undefined) {
197
+ lines.push(` minLength: ${ab.condition.minLength}`);
198
+ }
199
+
200
+ if (ab.patches.length > 0) {
201
+ lines.push(`patches:`);
202
+ for (const p of ab.patches) {
203
+ lines.push(` - op: ${p.op}`);
204
+ lines.push(` path: ${p.path}`);
205
+ if (p.value !== undefined) {
206
+ lines.push(` value: "${p.value.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`);
207
+ }
208
+ }
209
+ }
210
+
211
+ return lines.join("\n") + "\n";
212
+ }
@@ -0,0 +1,28 @@
1
+ import { existsSync } from "fs";
2
+ import { join, dirname, resolve } from "path";
3
+
4
+ /**
5
+ * Walk upward from `from` to find the nearest directory containing `.afd/` or `.git/`.
6
+ * Returns the absolute workspace root path, or falls back to `from` itself.
7
+ */
8
+ export function findWorkspaceRoot(from: string = process.cwd()): string {
9
+ let dir = resolve(from);
10
+ const root = dirname(dir) === dir ? dir : undefined; // filesystem root guard
11
+
12
+ while (true) {
13
+ if (existsSync(join(dir, ".afd")) || existsSync(join(dir, ".git"))) {
14
+ return dir;
15
+ }
16
+ const parent = dirname(dir);
17
+ if (parent === dir) break; // reached filesystem root
18
+ dir = parent;
19
+ }
20
+
21
+ // Fallback: return original directory
22
+ return resolve(from);
23
+ }
24
+
25
+ /** Resolve an `.afd/`-relative path against the workspace root */
26
+ export function resolveAfdPath(relativePath: string, from?: string): string {
27
+ return join(findWorkspaceRoot(from), relativePath);
28
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Minimal YAML parser — handles flat keys, nested objects, and simple arrays.
3
+ * No external dependencies. Sufficient for .afd/rules/*.yml diagnostic rule files.
4
+ *
5
+ * Supports:
6
+ * key: value
7
+ * key:
8
+ * nested: value
9
+ * items:
10
+ * - value
11
+ * items:
12
+ * - op: add
13
+ * path: /file
14
+ *
15
+ * Does NOT support: anchors, aliases, flow syntax, multi-line strings, tags.
16
+ */
17
+
18
+ type YamlValue = string | number | boolean | null | YamlObject | YamlValue[];
19
+ interface YamlObject {
20
+ [key: string]: YamlValue;
21
+ }
22
+
23
+ export function parse(text: string): YamlObject {
24
+ const lines = text.split("\n");
25
+ return parseBlock(lines, 0, 0).value as YamlObject;
26
+ }
27
+
28
+ interface ParseResult {
29
+ value: YamlValue;
30
+ nextLine: number;
31
+ }
32
+
33
+ function getIndent(line: string): number {
34
+ const match = line.match(/^(\s*)/);
35
+ return match ? match[1].length : 0;
36
+ }
37
+
38
+ function isComment(line: string): boolean {
39
+ return line.trimStart().startsWith("#") || line.trim() === "";
40
+ }
41
+
42
+ function parseScalar(raw: string): YamlValue {
43
+ const trimmed = raw.trim();
44
+ if (trimmed === "" || trimmed === "null" || trimmed === "~") return null;
45
+ if (trimmed === "true") return true;
46
+ if (trimmed === "false") return false;
47
+ // Remove quotes
48
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
49
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
50
+ return trimmed.slice(1, -1);
51
+ }
52
+ const num = Number(trimmed);
53
+ if (!isNaN(num) && trimmed !== "") return num;
54
+ return trimmed;
55
+ }
56
+
57
+ function parseBlock(lines: string[], startLine: number, baseIndent: number): ParseResult {
58
+ const obj: YamlObject = {};
59
+ let i = startLine;
60
+
61
+ while (i < lines.length) {
62
+ const line = lines[i];
63
+
64
+ if (isComment(line)) { i++; continue; }
65
+
66
+ const indent = getIndent(line);
67
+ if (indent < baseIndent) break; // dedent → end of block
68
+
69
+ const content = line.trim();
70
+
71
+ // Array item at this level
72
+ if (content.startsWith("- ")) {
73
+ // This block is actually an array — delegate to array parser
74
+ const arr = parseArray(lines, i, indent);
75
+ return { value: arr.value, nextLine: arr.nextLine };
76
+ }
77
+
78
+ // Key-value pair
79
+ const colonIdx = content.indexOf(":");
80
+ if (colonIdx === -1) { i++; continue; }
81
+
82
+ const key = content.slice(0, colonIdx).trim();
83
+ const rest = content.slice(colonIdx + 1).trim();
84
+
85
+ if (rest === "" || rest === "|" || rest === ">") {
86
+ // Check next line indent to determine nested type
87
+ const nextNonEmpty = findNextNonEmpty(lines, i + 1);
88
+ if (nextNonEmpty < lines.length) {
89
+ const nextIndent = getIndent(lines[nextNonEmpty]);
90
+ if (nextIndent > indent) {
91
+ const nextContent = lines[nextNonEmpty].trim();
92
+ if (nextContent.startsWith("- ")) {
93
+ const arr = parseArray(lines, nextNonEmpty, nextIndent);
94
+ obj[key] = arr.value;
95
+ i = arr.nextLine;
96
+ } else {
97
+ const nested = parseBlock(lines, nextNonEmpty, nextIndent);
98
+ obj[key] = nested.value;
99
+ i = nested.nextLine;
100
+ }
101
+ } else {
102
+ obj[key] = null;
103
+ i++;
104
+ }
105
+ } else {
106
+ obj[key] = null;
107
+ i++;
108
+ }
109
+ } else {
110
+ obj[key] = parseScalar(rest);
111
+ i++;
112
+ }
113
+ }
114
+
115
+ return { value: obj, nextLine: i };
116
+ }
117
+
118
+ function parseArray(lines: string[], startLine: number, baseIndent: number): ParseResult {
119
+ const arr: YamlValue[] = [];
120
+ let i = startLine;
121
+
122
+ while (i < lines.length) {
123
+ const line = lines[i];
124
+ if (isComment(line)) { i++; continue; }
125
+
126
+ const indent = getIndent(line);
127
+ if (indent < baseIndent) break;
128
+
129
+ const content = line.trim();
130
+ if (!content.startsWith("- ")) break;
131
+
132
+ const itemContent = content.slice(2).trim();
133
+
134
+ // Check if this is a simple value or an inline key-value
135
+ const colonIdx = itemContent.indexOf(":");
136
+ if (colonIdx > 0 && !itemContent.startsWith('"') && !itemContent.startsWith("'")) {
137
+ // Inline object start: - key: value
138
+ const itemObj: YamlObject = {};
139
+ const key = itemContent.slice(0, colonIdx).trim();
140
+ const val = itemContent.slice(colonIdx + 1).trim();
141
+ itemObj[key] = parseScalar(val);
142
+ i++;
143
+
144
+ // Continuation lines at deeper indent
145
+ const itemIndent = indent + 2;
146
+ while (i < lines.length) {
147
+ const nextLine = lines[i];
148
+ if (isComment(nextLine)) { i++; continue; }
149
+ const nextIndent = getIndent(nextLine);
150
+ if (nextIndent < itemIndent) break;
151
+ const nc = nextLine.trim();
152
+ if (nc.startsWith("- ")) break; // next array item
153
+ const ci = nc.indexOf(":");
154
+ if (ci > 0) {
155
+ itemObj[nc.slice(0, ci).trim()] = parseScalar(nc.slice(ci + 1).trim());
156
+ }
157
+ i++;
158
+ }
159
+
160
+ arr.push(itemObj);
161
+ } else {
162
+ // Simple value
163
+ arr.push(parseScalar(itemContent));
164
+ i++;
165
+ }
166
+ }
167
+
168
+ return { value: arr, nextLine: i };
169
+ }
170
+
171
+ function findNextNonEmpty(lines: string[], start: number): number {
172
+ for (let i = start; i < lines.length; i++) {
173
+ if (!isComment(lines[i])) return i;
174
+ }
175
+ return lines.length;
176
+ }
@@ -1,17 +1,45 @@
1
- import { readFileSync, existsSync } from "fs";
2
- import { PID_FILE, PORT_FILE } from "../constants";
1
+ import { readFileSync, existsSync, unlinkSync } from "fs";
2
+ import { resolveWorkspacePaths } from "../constants";
3
3
 
4
4
  export interface DaemonInfo {
5
5
  pid: number;
6
6
  port: number;
7
+ workspace: string;
7
8
  }
8
9
 
10
+ /**
11
+ * Read daemon PID/port from the workspace-local `.afd/` directory.
12
+ * Walks up from cwd to find the workspace root, so CLI commands
13
+ * work correctly even when invoked from subdirectories.
14
+ *
15
+ * If PID file exists but process is dead, cleans up stale files.
16
+ */
9
17
  export function getDaemonInfo(): DaemonInfo | null {
10
- if (!existsSync(PID_FILE) || !existsSync(PORT_FILE)) return null;
11
- const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
12
- const port = parseInt(readFileSync(PORT_FILE, "utf-8").trim(), 10);
18
+ const paths = resolveWorkspacePaths();
19
+ if (!existsSync(paths.pidFile) || !existsSync(paths.portFile)) return null;
20
+
21
+ const pid = parseInt(readFileSync(paths.pidFile, "utf-8").trim(), 10);
22
+ const port = parseInt(readFileSync(paths.portFile, "utf-8").trim(), 10);
13
23
  if (isNaN(pid) || isNaN(port)) return null;
14
- return { pid, port };
24
+
25
+ // Stale PID detection: check if process is alive at OS level
26
+ if (!isProcessAlive(pid)) {
27
+ try { unlinkSync(paths.pidFile); } catch {}
28
+ try { unlinkSync(paths.portFile); } catch {}
29
+ return null;
30
+ }
31
+
32
+ return { pid, port, workspace: paths.root };
33
+ }
34
+
35
+ /** Check if a process exists at OS level (does not verify it's afd) */
36
+ function isProcessAlive(pid: number): boolean {
37
+ try {
38
+ process.kill(pid, 0); // signal 0 = existence check
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
15
43
  }
16
44
 
17
45
  export async function isDaemonAlive(info: DaemonInfo): Promise<boolean> {
@@ -0,0 +1,108 @@
1
+ /**
2
+ * EventBatcher — Adaptive debounce for file watcher events.
3
+ *
4
+ * Strategy:
5
+ * - Immune file changes → fast-path (immediate, no debounce)
6
+ * - All other events → 300ms debounce batch
7
+ * - Deduplicates: same file multiple events → last event wins
8
+ * - Cancels out: add + unlink on same file → removed from batch
9
+ */
10
+
11
+ export interface BatchedEvent {
12
+ event: string;
13
+ path: string;
14
+ timestamp: number;
15
+ }
16
+
17
+ export interface EventBatcherOptions {
18
+ /** Debounce window in ms (default: 300) */
19
+ debounceMs?: number;
20
+ /** Check if a path is an immune-protected file (fast-path) */
21
+ isImmunePath?: (path: string) => boolean;
22
+ /** Handler for fast-path (immediate) events */
23
+ onImmediate: (event: string, path: string) => void;
24
+ /** Handler for batched events (fired after debounce window) */
25
+ onBatch: (events: BatchedEvent[]) => void;
26
+ }
27
+
28
+ export class EventBatcher {
29
+ private readonly debounceMs: number;
30
+ private readonly isImmunePath: (path: string) => boolean;
31
+ private readonly onImmediate: (event: string, path: string) => void;
32
+ private readonly onBatch: (events: BatchedEvent[]) => void;
33
+
34
+ private pendingEvents = new Map<string, BatchedEvent>();
35
+ private timer: ReturnType<typeof setTimeout> | null = null;
36
+ private batchCount = 0;
37
+
38
+ constructor(options: EventBatcherOptions) {
39
+ this.debounceMs = options.debounceMs ?? 300;
40
+ this.isImmunePath = options.isImmunePath ?? (() => false);
41
+ this.onImmediate = options.onImmediate;
42
+ this.onBatch = options.onBatch;
43
+ }
44
+
45
+ /** Push a new file event. Returns true if handled immediately (fast-path). */
46
+ push(event: string, path: string): boolean {
47
+ // Fast-path: immune file change → immediate processing for auto-heal responsiveness
48
+ if (event === "change" && this.isImmunePath(path)) {
49
+ this.onImmediate(event, path);
50
+ return true;
51
+ }
52
+
53
+ const now = Date.now();
54
+ const existing = this.pendingEvents.get(path);
55
+
56
+ // Cancel out: add + unlink on same file
57
+ if (existing) {
58
+ if ((existing.event === "add" && event === "unlink") ||
59
+ (existing.event === "unlink" && event === "add")) {
60
+ this.pendingEvents.delete(path);
61
+ return false;
62
+ }
63
+ }
64
+
65
+ // Last event wins for same file
66
+ this.pendingEvents.set(path, { event, path, timestamp: now });
67
+
68
+ // Start/reset debounce timer
69
+ if (this.timer) clearTimeout(this.timer);
70
+ this.timer = setTimeout(() => this.flush(), this.debounceMs);
71
+
72
+ return false;
73
+ }
74
+
75
+ /** Flush all pending events immediately */
76
+ flush(): void {
77
+ if (this.timer) {
78
+ clearTimeout(this.timer);
79
+ this.timer = null;
80
+ }
81
+
82
+ if (this.pendingEvents.size === 0) return;
83
+
84
+ const events = [...this.pendingEvents.values()];
85
+ this.pendingEvents.clear();
86
+ this.batchCount++;
87
+ this.onBatch(events);
88
+ }
89
+
90
+ /** Get the number of batches processed */
91
+ get totalBatches(): number {
92
+ return this.batchCount;
93
+ }
94
+
95
+ /** Get the number of pending events */
96
+ get pendingCount(): number {
97
+ return this.pendingEvents.size;
98
+ }
99
+
100
+ /** Destroy the batcher, clearing any pending timers */
101
+ destroy(): void {
102
+ if (this.timer) {
103
+ clearTimeout(this.timer);
104
+ this.timer = null;
105
+ }
106
+ this.pendingEvents.clear();
107
+ }
108
+ }
@@ -0,0 +1,13 @@
1
+ import { resolve } from "path";
2
+
3
+ /**
4
+ * Guard: reject resolved paths outside the workspace root.
5
+ * Throws if absPath is not under wsRoot.
6
+ */
7
+ export function assertInsideWorkspace(absPath: string, wsRoot: string): void {
8
+ const normalizedPath = absPath.replace(/\\/g, "/").toLowerCase();
9
+ const normalizedRoot = resolve(wsRoot).replace(/\\/g, "/").toLowerCase();
10
+ if (!normalizedPath.startsWith(normalizedRoot + "/") && normalizedPath !== normalizedRoot) {
11
+ throw new Error("Access denied: path outside workspace");
12
+ }
13
+ }