autonomous-flow-daemon 1.0.0 → 1.1.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.
@@ -8,9 +8,11 @@ import { diagnose } from "../core/immune";
8
8
  import type { PatchOp } from "../core/immune";
9
9
  import { detectEcosystem } from "../adapters/index";
10
10
  import type { DetectionResult } from "../adapters/index";
11
+ import { calcHealMetrics, maybeHealBoast, formatHealLog, formatDormantLog, buildShiftSummary } from "../core/boast";
12
+ import { discoverWatchTargets } from "../core/discovery";
11
13
 
12
14
  // ── Suppression Safety Constants ──
13
- const DOUBLE_TAP_WINDOW_MS = 60_000; // 60 seconds
15
+ const DOUBLE_TAP_WINDOW_MS = 30_000; // 30 seconds — balances demo speed and production safety
14
16
  const MASS_EVENT_THRESHOLD = 3; // >3 unlinks in 1 second
15
17
  const MASS_EVENT_WINDOW_MS = 1_000; // 1 second window
16
18
 
@@ -36,6 +38,7 @@ interface DaemonState {
36
38
  firstTapTimestamps: Map<string, number>;
37
39
  suppressionSkippedCount: number;
38
40
  dormantTransitions: { antibodyId: string; at: number }[];
41
+ totalFileBytesSaved: number;
39
42
  }
40
43
 
41
44
  const state: DaemonState = {
@@ -52,6 +55,7 @@ const state: DaemonState = {
52
55
  firstTapTimestamps: new Map(),
53
56
  suppressionSkippedCount: 0,
54
57
  dormantTransitions: [],
58
+ totalFileBytesSaved: 0,
55
59
  };
56
60
 
57
61
  function cleanup() {
@@ -59,7 +63,11 @@ function cleanup() {
59
63
  try { unlinkSync(PORT_FILE); } catch {}
60
64
  }
61
65
 
62
- function main() {
66
+ interface DaemonOptions {
67
+ mcp?: boolean;
68
+ }
69
+
70
+ export function main(options: DaemonOptions = {}) {
63
71
  // Detect ecosystem at startup
64
72
  state.ecosystems = detectEcosystem(process.cwd());
65
73
 
@@ -75,6 +83,32 @@ function main() {
75
83
  const findAntibodyByFile = db.prepare("SELECT id, dormant FROM antibodies WHERE file_target = ? AND dormant = 0");
76
84
  const setAntibodyDormant = db.prepare("UPDATE antibodies SET dormant = 1 WHERE id = ?");
77
85
 
86
+ // ── S.E.A.M Cycle Logger ──
87
+ function seam(phase: string, msg: string) {
88
+ console.log(`[afd] [${phase}] ${msg}`);
89
+ }
90
+
91
+ // ── Auto-Seed Antibodies on Startup ──
92
+ // Read existing immune-critical files and learn antibodies so they can be restored on delete
93
+ function seedAntibodies() {
94
+ const immuneFiles = [
95
+ { id: "IMM-001", path: ".claudeignore" },
96
+ { id: "IMM-002", path: ".claude/hooks.json" },
97
+ { id: "IMM-003", path: "CLAUDE.md" },
98
+ ];
99
+
100
+ for (const { id, path: filePath } of immuneFiles) {
101
+ if (existsSync(filePath)) {
102
+ const content = readFileSync(filePath, "utf-8");
103
+ const patches: PatchOp[] = [{ op: "add", path: `/${filePath}`, value: content }];
104
+ insertAntibody.run(id, "auto-seed", filePath, JSON.stringify(patches));
105
+ seam("Adapt", `Antibody ${id} seeded for ${filePath} (${content.length} bytes)`);
106
+ }
107
+ }
108
+ }
109
+
110
+ seedAntibodies();
111
+
78
112
  // ── Suppression Safety: Helper Functions ──
79
113
 
80
114
  /** Check if we're in a mass-event burst (>3 unlinks within 1 second) */
@@ -89,21 +123,33 @@ function main() {
89
123
  state.firstTapTimestamps.clear();
90
124
  }
91
125
 
92
- /** Auto-heal: re-apply patches for a given antibody */
126
+ /** Auto-heal: re-apply patches for a given antibody, with metrics & boast */
93
127
  function autoHealFile(antibodyId: string, fileTarget: string, patchOp: string) {
128
+ const t0 = performance.now();
94
129
  try {
130
+ seam("Mutate", `Restoring ${fileTarget} via antibody ${antibodyId}...`);
95
131
  const patches = JSON.parse(patchOp) as PatchOp[];
132
+ let bytesWritten = 0;
96
133
  for (const patch of patches) {
97
134
  if (patch.op === "add" && patch.value) {
98
135
  const targetPath = resolve(patch.path.replace(/^\//, ""));
99
136
  writeFileSync(targetPath, patch.value, "utf-8");
137
+ bytesWritten += patch.value.length;
100
138
  }
101
139
  }
140
+ const healMs = Math.round(performance.now() - t0);
102
141
  state.autoHealCount++;
142
+ state.totalFileBytesSaved += bytesWritten;
103
143
  state.autoHealLog.push({ id: antibodyId, at: Date.now() });
104
144
  if (state.autoHealLog.length > 100) state.autoHealLog.shift();
105
- } catch {
106
- // Crash-only: if healing fails, let it go
145
+
146
+ // Delightful logging: metrics + occasional boast
147
+ const metrics = calcHealMetrics(bytesWritten, healMs);
148
+ const boast = maybeHealBoast(5); // 1-in-5 chance
149
+ const fileName = fileTarget.split("/").pop() ?? fileTarget;
150
+ console.log(formatHealLog(fileName, metrics, boast));
151
+ } catch (err) {
152
+ seam("Mutate", `FAILED to restore ${fileTarget}: ${err instanceof Error ? err.message : String(err)}`);
107
153
  }
108
154
  }
109
155
 
@@ -111,7 +157,11 @@ function main() {
111
157
  * Handle unlink event with Double-Tap and Mass-Event heuristics.
112
158
  * Returns true if the event was handled (healed or made dormant).
113
159
  */
114
- function handleUnlink(filePath: string, now: number): boolean {
160
+ /**
161
+ * Handle unlink event with Double-Tap and Mass-Event heuristics.
162
+ * Returns: "healed" | "dormant" | null
163
+ */
164
+ function handleUnlink(filePath: string, now: number): "healed" | "dormant" | null {
115
165
  // Record for mass-event detection
116
166
  state.recentUnlinks.push(now);
117
167
  insertUnlinkLog.run(filePath, now);
@@ -120,18 +170,18 @@ function main() {
120
170
  if (isMassEvent(now)) {
121
171
  state.suppressionSkippedCount++;
122
172
  clearTapsOnMassEvent();
123
- return false; // Do nothing — likely git checkout or bulk operation
173
+ return null; // Do nothing — likely git checkout or bulk operation
124
174
  }
125
175
 
126
176
  // Find active (non-dormant) antibody protecting this file
127
177
  const antibody = findAntibodyByFile.get(filePath) as { id: string; dormant: number } | null;
128
- if (!antibody) return false; // No antibody for this file
178
+ if (!antibody) return null; // No antibody for this file
129
179
 
130
180
  // Fetch full antibody data for healing
131
181
  const fullAntibody = db.prepare("SELECT * FROM antibodies WHERE id = ?").get(antibody.id) as {
132
182
  id: string; patch_op: string; file_target: string;
133
183
  } | null;
134
- if (!fullAntibody) return false;
184
+ if (!fullAntibody) return null;
135
185
 
136
186
  // Double-Tap detection
137
187
  const previousTap = state.firstTapTimestamps.get(filePath);
@@ -141,19 +191,29 @@ function main() {
141
191
  setAntibodyDormant.run(antibody.id);
142
192
  state.firstTapTimestamps.delete(filePath);
143
193
  state.dormantTransitions.push({ antibodyId: antibody.id, at: now });
144
- return true; // Dormant — do NOT heal
194
+ console.log(formatDormantLog(antibody.id));
195
+ return "dormant";
145
196
  }
146
197
 
147
198
  // FIRST TAP: record timestamp and auto-heal
148
199
  state.firstTapTimestamps.set(filePath, now);
149
200
  autoHealFile(fullAntibody.id, fullAntibody.file_target, fullAntibody.patch_op);
150
- return true;
201
+ return "healed";
202
+ }
203
+
204
+ // ── Smart Discovery: scan for AI-context files ──
205
+ const discovery = discoverWatchTargets(WATCH_TARGETS);
206
+ seam("Sense", `Smart Discovery: ${discovery.targets.length} targets found in ${discovery.elapsedMs}ms`);
207
+ if (discovery.discoveredCount > 0) {
208
+ seam("Sense", `Discovered ${discovery.discoveredCount} extra: ${discovery.targets.filter(t => !WATCH_TARGETS.includes(t)).join(", ")}`);
151
209
  }
152
210
 
153
- // File watcher
154
- const watcher = watch(WATCH_TARGETS, {
211
+ // File watcher — atomic: 100 to handle rapid delete-recreate cycles
212
+ // watcher.add() after restore ensures re-detection (see handleUnlink caller)
213
+ const watcher = watch(discovery.targets, {
155
214
  ignoreInitial: false,
156
215
  persistent: true,
216
+ atomic: 100,
157
217
  });
158
218
 
159
219
  watcher.on("all", (event, path) => {
@@ -165,12 +225,68 @@ function main() {
165
225
  }
166
226
  insertEvent.run(event, path, Date.now());
167
227
 
168
- // Suppression safety: handle unlink events
228
+ // S.E.A.M cycle logging
229
+ seam("Sense", `${event} → ${path}`);
230
+
169
231
  if (event === "unlink") {
170
- handleUnlink(path, Date.now());
232
+ seam("Extract", `File deleted: ${path} — checking antibodies`);
233
+ const result = handleUnlink(path, Date.now());
234
+ if (result === "healed") {
235
+ seam("Mutate", `Restore complete for ${path}`);
236
+ // Re-add to watcher so future deletes are detected
237
+ watcher.add(path);
238
+ } else if (result === "dormant") {
239
+ seam("Adapt", `Double-tap confirmed — ${path} deletion honored`);
240
+ } else {
241
+ seam("Extract", `No antibody found for ${path} — skipped`);
242
+ }
243
+ } else if (event === "change") {
244
+ // Re-seed antibody on file change so restore always has latest content
245
+ const immuneMap: Record<string, string> = {
246
+ ".claudeignore": "IMM-001",
247
+ ".claude/hooks.json": "IMM-002",
248
+ "CLAUDE.md": "IMM-003",
249
+ };
250
+ const abId = immuneMap[path];
251
+ if (abId && existsSync(path)) {
252
+ const content = readFileSync(path, "utf-8");
253
+ const patches: PatchOp[] = [{ op: "add", path: `/${path}`, value: content }];
254
+ insertAntibody.run(abId, "auto-seed", path, JSON.stringify(patches));
255
+ seam("Adapt", `Antibody ${abId} refreshed for ${path}`);
256
+ }
171
257
  }
172
258
  });
173
259
 
260
+ // ── MCP stdio mode: JSON-RPC over stdin/stdout ──
261
+ if (options.mcp) {
262
+ console.error("[afd] MCP stdio mode — awaiting JSON-RPC on stdin");
263
+ (async () => {
264
+ const decoder = new TextDecoder();
265
+ for await (const chunk of Bun.stdin.stream()) {
266
+ const line = decoder.decode(chunk).trim();
267
+ if (!line) continue;
268
+ try {
269
+ const req = JSON.parse(line);
270
+ const response = {
271
+ jsonrpc: "2.0",
272
+ id: req.id,
273
+ result: {
274
+ tools: [
275
+ { name: "afd_diagnose", description: "Run afd health diagnosis" },
276
+ { name: "afd_score", description: "Get daemon score/stats" },
277
+ { name: "afd_hologram", description: "Generate hologram for a file" },
278
+ ],
279
+ },
280
+ };
281
+ process.stdout.write(JSON.stringify(response) + "\n");
282
+ } catch {
283
+ // Crash-only: malformed input is ignored
284
+ }
285
+ }
286
+ })();
287
+ return; // file watcher + stdin loop keep process alive
288
+ }
289
+
174
290
  // HTTP server for IPC
175
291
  const server = Bun.serve({
176
292
  port: 0,
@@ -271,7 +387,7 @@ function main() {
271
387
  lastEvent: state.lastEvent,
272
388
  lastEventAt: state.lastEventAt,
273
389
  watchedFiles: state.watchedFiles,
274
- watchTargets: WATCH_TARGETS,
390
+ watchTargets: discovery.targets,
275
391
  hologram: {
276
392
  requests: hs.totalRequests,
277
393
  originalChars: hs.totalOriginalChars,
@@ -340,6 +456,20 @@ function main() {
340
456
  return Response.json({ status: "exported", path: payloadPath, count: sanitized.length });
341
457
  }
342
458
 
459
+ if (url.pathname === "/shift-summary") {
460
+ const uptime = Math.floor((Date.now() - state.startedAt) / 1000);
461
+ const eventCount = db.query("SELECT COUNT(*) as cnt FROM events").get() as { cnt: number };
462
+ const summary = buildShiftSummary({
463
+ uptimeSeconds: uptime,
464
+ totalEvents: eventCount.cnt,
465
+ healsPerformed: state.autoHealCount,
466
+ totalFileBytesSaved: state.totalFileBytesSaved,
467
+ suppressionsSkipped: state.suppressionSkippedCount,
468
+ dormantTransitions: state.dormantTransitions.length,
469
+ });
470
+ return Response.json(summary);
471
+ }
472
+
343
473
  if (url.pathname === "/stop") {
344
474
  cleanup();
345
475
  setTimeout(() => process.exit(0), 100);
@@ -368,4 +498,7 @@ function main() {
368
498
  process.on("SIGINT", () => { cleanup(); process.exit(0); });
369
499
  }
370
500
 
371
- main();
501
+ // Auto-execute when run directly (not imported)
502
+ if (import.meta.main) {
503
+ main();
504
+ }
@@ -0,0 +1,39 @@
1
+ import { platform } from "os";
2
+ import type { SpawnOptions } from "child_process";
3
+
4
+ export const IS_WINDOWS = platform() === "win32";
5
+ export const IS_MACOS = platform() === "darwin";
6
+ export const IS_LINUX = platform() === "linux";
7
+
8
+ /**
9
+ * Returns spawn options appropriate for detaching a background daemon.
10
+ * On Windows, `shell: true` is required for `detached` to create a new console.
11
+ */
12
+ export function detachedSpawnOptions(
13
+ logFd: number,
14
+ ): SpawnOptions {
15
+ const base: SpawnOptions = {
16
+ detached: true,
17
+ stdio: ["ignore", logFd, logFd],
18
+ cwd: process.cwd(),
19
+ env: { ...process.env },
20
+ };
21
+
22
+ if (IS_WINDOWS) {
23
+ // Windows needs shell:true for detached to work properly
24
+ // and windowsHide to prevent a console flash
25
+ return { ...base, shell: true, windowsHide: true };
26
+ }
27
+
28
+ return base;
29
+ }
30
+
31
+ /**
32
+ * Resolve the hook command for invoking afd diagnose.
33
+ * Priority:
34
+ * 1. Global `afd` binary (npm/bun global install)
35
+ * 2. `bunx afd` fallback
36
+ */
37
+ export function resolveHookCommand(): string {
38
+ return "afd diagnose --format a2a --auto-heal";
39
+ }