alvin-bot 4.25.1 → 5.0.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.
- package/CHANGELOG.md +167 -0
- package/bin/cli.js +159 -4
- package/dist/index.js +39 -0
- package/dist/services/auto-diagnostic.js +228 -0
- package/dist/services/critical-notify.js +203 -0
- package/dist/services/heartbeat-file.js +65 -0
- package/dist/services/preflight.js +292 -0
- package/dist/services/self-diagnosis.js +272 -0
- package/dist/services/trends.js +309 -0
- package/dist/services/watchdog.js +47 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,173 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [5.0.0] — 2026-05-13
|
|
6
|
+
|
|
7
|
+
### Self-Preservation Phase 2 — the bot now reasons about its own failures
|
|
8
|
+
|
|
9
|
+
The bot now uses **its own AI provider** (whichever one you have configured — claude-sdk, codex-cli, groq, gemini, openai, ollama/gemma4, openrouter, nvidia-nim) to analyze why it failed and where it's heading. Two new features, both event-driven, both opt-out.
|
|
10
|
+
|
|
11
|
+
The major-version bump reflects a conceptual shift: AI is now part of the bot's **operational loop** about itself, not just the user-facing chat. The feature surface is backwards-compatible — existing setups keep running unchanged; everything new is additive and opt-out via env vars.
|
|
12
|
+
|
|
13
|
+
#### AI-driven Self-Diagnosis on bundles (feature 3I)
|
|
14
|
+
|
|
15
|
+
When the watchdog brake fires and 2F writes a forensic bundle, 3I picks up the analysis at **the next successful bot start**:
|
|
16
|
+
|
|
17
|
+
1. Bot starts → Pre-Flight runs → 3I scans `~/.alvin-bot/diagnostics/` for unanalyzed bundles
|
|
18
|
+
2. For each bundle without a `.analysis.md` sidecar, send it (clipped to ~12 KB, head+tail) to the active AI provider via `provider.query()`
|
|
19
|
+
3. AI returns a structured 5-line response — `HYPOTHESIS / ROOT_CAUSE_CATEGORY / REMEDIATION / CONFIDENCE / EXPLANATION`
|
|
20
|
+
4. Result is written as `.analysis.md` sidecar AND delivered to the operator via 1D Telegram DM
|
|
21
|
+
|
|
22
|
+
The 5-line plain-text format was chosen over JSON because **JSON parsing reliability is uneven across providers**, especially with smaller models. The format is hard to mess up — and we parse it with a tolerant regex.
|
|
23
|
+
|
|
24
|
+
Live verified on Apple Silicon with `claude-sdk`: bundle from the actual brake earlier that day was analyzed correctly — AI identified "skills-reload triggered repeated graceful restarts that tripped the brake", suggested the documented recovery command (`rm crash-loop.alert && alvin-bot launchd install`), all within ~9 s.
|
|
25
|
+
|
|
26
|
+
**Safety policy v1**: the AI's suggested remediation is shown to the operator but **NEVER auto-applied**. This is intentional — we want a track record of accurate suggestions before granting the bot any self-modifying power.
|
|
27
|
+
|
|
28
|
+
#### Predictive Maintenance via Trends (feature 3J)
|
|
29
|
+
|
|
30
|
+
A second daily timer writes a one-line JSON snapshot of bot health to `~/.alvin-bot/state/trends.jsonl`:
|
|
31
|
+
|
|
32
|
+
```jsonl
|
|
33
|
+
{"ts":"2026-05-13T...","uptime_s":86400,"rss_mb":105,"heap_mb":33,"crashes_24h":0,"diag_24h":0,"errors_24h":3,"provider":"claude-sdk","version":"5.0.0"}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
After 7 days of data accumulate, every daily snapshot also triggers an AI **anomaly-detection pass** over the last 30 days. Output is a strict 3-line format — `ANOMALY: ... / SEVERITY: warn|critical / SUGGESTION: ...`, or just `ANOMALY: NONE` when nothing's concerning.
|
|
37
|
+
|
|
38
|
+
Live verified with synthetic 30-day memory-leak data (RSS climbing 100 → 220 MB linearly): `claude-sdk` correctly identified the leak, classified as `critical`, suggested heap-snapshot capture via `kill -USR2`. Confirmed end-to-end with file flag + Telegram DM delivered via 1D.
|
|
39
|
+
|
|
40
|
+
#### Provider-agnostic by design
|
|
41
|
+
|
|
42
|
+
Both 3I and 3J use the existing `provider.query()` abstraction — the same code path the bot uses for normal chat. Switching provider via `alvin-bot provider switch <key>` (added in 4.24.0) automatically retargets 3I + 3J as well. No provider-specific code in either feature.
|
|
43
|
+
|
|
44
|
+
**Tested provider**: `claude-sdk` (the "B1" test path). The `offline-gemma4` test path (B4) — stress-test of prompt design against a small-context local model — is deferred to a follow-up session; the deferral and its acceptance criteria are documented in the (gitignored) project `BACKLOG.md`.
|
|
45
|
+
|
|
46
|
+
#### Performance budget held
|
|
47
|
+
|
|
48
|
+
All new code runs detached, on long timers, or at startup-only. Steady-state cost: zero. The startup analyzer (3I) only runs if unanalyzed bundles exist — typically 0 on a healthy run. The trends collector (3J) runs once every 24 h with a 60 s warmup after startup.
|
|
49
|
+
|
|
50
|
+
Measured on Apple Silicon (vs. 4.26.0 baseline):
|
|
51
|
+
- RSS idle: **+0 MB** (modules loaded lazily via dynamic `import()`)
|
|
52
|
+
- Cold-start ready: **unchanged** (both modules load post-startup, fire-and-forget)
|
|
53
|
+
- 3I per-bundle latency on claude-sdk: ~9 s
|
|
54
|
+
- 3J per-analysis latency on claude-sdk: ~10 s
|
|
55
|
+
|
|
56
|
+
#### New env vars
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
ALVIN_DISABLE_SELF_DIAGNOSIS=true # disable 3I
|
|
60
|
+
ALVIN_DISABLE_TRENDS=true # disable 3J
|
|
61
|
+
ALVIN_TRENDS_INTERVAL_HOURS=24 # default
|
|
62
|
+
ALVIN_TRENDS_AI_AFTER_DAYS=7 # min history before AI kicks in
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
(All Phase-1 env vars from 4.26.0 continue to work — `ALVIN_DISABLE_SELF_PRESERVATION=true` still kills everything.)
|
|
66
|
+
|
|
67
|
+
#### Why a major version bump
|
|
68
|
+
|
|
69
|
+
Semantically, the bot is now **closing a loop on itself**: it observes its own forensics, asks an AI to interpret them, and reports back. That's a conceptual line worth marking. Nothing breaks — existing users update with `npm install -g alvin-bot@latest` and the new behaviour just appears in their next failure analysis.
|
|
70
|
+
|
|
71
|
+
#### Files added
|
|
72
|
+
|
|
73
|
+
- `src/services/self-diagnosis.ts` — 3I startup analyzer + analyzeBundle()
|
|
74
|
+
- `src/services/trends.ts` — 3J snapshot collector + analyzeTrends()
|
|
75
|
+
- `src/index.ts` — two fire-and-forget dynamic imports after Pre-Flight
|
|
76
|
+
|
|
77
|
+
## [4.26.0] — 2026-05-13
|
|
78
|
+
|
|
79
|
+
### Self-Preservation Phase 1 — four new resilience features, zero hot-path cost
|
|
80
|
+
|
|
81
|
+
Bot now **survives more failure modes** and **alerts you when it can't survive them**. All four features run event-driven or on low-frequency timers — no hot-path overhead, measured RSS +4 MB / cold-start +81 ms vs baseline on a real Apple Silicon Mac (within the +5 MB / +2000 ms tolerance budget).
|
|
82
|
+
|
|
83
|
+
#### Pre-Flight Sanity Check at startup (feature 1A)
|
|
84
|
+
|
|
85
|
+
In parallel at boot, the bot now checks: (1) Telegram `getMe`, (2) AI provider `isAvailable()` — provider-agnostic via the existing Provider interface, works equally for `claude-sdk` / `codex-cli` / `groq` / `gemini` / `offline-gemma4` / etc., (3) SQLite `PRAGMA quick_check` on the embeddings DB, (4) Disk space ≥ 1 GB. Fire-and-forget — startup is **not** delayed; results land ~1 s after `Alvin Bot started` with severity-tagged output:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
🩺 ✅ Pre-Flight: all checks ok — 986ms total
|
|
89
|
+
✓ telegram bot=@AlvinMBAM4_bot (405ms)
|
|
90
|
+
✓ ai-provider claude-sdk reachable (922ms)
|
|
91
|
+
✓ sqlite embeddings DB integrity ok (43ms)
|
|
92
|
+
✓ disk 53.28 GB free (37ms)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Per-check timeouts (3 s / 5 s / 10 s / 2 s) bound the cost. Critical findings will feed Phase 2's auto-diagnostic (already wired). Opt-out: `ALVIN_DISABLE_PREFLIGHT=true`.
|
|
96
|
+
|
|
97
|
+
#### Critical-Event Cross-Channel Notify (feature 1D)
|
|
98
|
+
|
|
99
|
+
When the bot hits a state it can't recover from on its own — watchdog crash-loop brake engaged, repeated Telegram 409s, all providers dead, disk critically low — it now alerts the operator through a **fallback chain that doesn't depend on the bot's own platform being healthy**:
|
|
100
|
+
|
|
101
|
+
1. **`~/.alvin-bot/CRITICAL.log`** — durable audit trail, always written first. Plain text, dated, machine-readable.
|
|
102
|
+
2. **macOS native notification** via `osascript` — visible immediately on the user's desktop.
|
|
103
|
+
3. **Telegram DM to admin** via `curl` — synchronous in exit-imminent contexts so the alert lands before `process.exit()` kills any pending I/O.
|
|
104
|
+
|
|
105
|
+
The synchronous-vs-detached distinction matters: detached child processes get killed by macOS+launchd before they finish their fork-and-exec when the parent exits within a few ms. The watchdog brake explicitly uses `blockTelegram: true` to spawnSync the curl POST and confirm the HTTP response code. Plain-text body (not Markdown) so shell-command `suggestedAction`s with `"`, `&&`, etc. don't trigger Telegram's `Bad Request: can't parse entities` error. Opt-out: `ALVIN_DISABLE_CRITICAL_NOTIFY=true`.
|
|
106
|
+
|
|
107
|
+
#### Zombie Dead-Man-Switch (feature 2E)
|
|
108
|
+
|
|
109
|
+
Bot writes a unix-timestamp heartbeat to `~/.alvin-bot/heartbeat.txt` every 60 s. A **separate, tiny launchd LaunchAgent** (`com.alvinbot.deadman`) wakes every 5 min and checks the heartbeat — if older than 10 min, the watcher fires `launchctl kickstart -k gui/$UID/com.alvinbot.app` to force-restart.
|
|
110
|
+
|
|
111
|
+
Catches the failure mode the in-process watchdog **cannot** see: process is alive but frozen (event-loop deadlock, blocked I/O, native-binding hang). The in-process watchdog can't detect its own death — that's a contradiction in terms — so the external observer is the only architecturally sound solution.
|
|
112
|
+
|
|
113
|
+
Threshold overridable for testing: `ALVIN_DEADMAN_THRESHOLD_SEC=60` (default 600). End-to-end verified on a real Mac: `kill -STOP` froze the bot at PID X, watcher detected stale heartbeat 700 s old, kickstart fired, fresh PID Y came up within 8 s. CPU cost of the watcher: 0.017 %.
|
|
114
|
+
|
|
115
|
+
#### Auto-Diagnostic Logs-Collector (feature 2F)
|
|
116
|
+
|
|
117
|
+
On any critical failure, the bot now writes a structured forensic Markdown bundle to `~/.alvin-bot/diagnostics/<timestamp>-<category>.md` containing:
|
|
118
|
+
|
|
119
|
+
1. Event detail + suggested action
|
|
120
|
+
2. Process state (PID, RSS, heap, uptime, node version, platform, argv)
|
|
121
|
+
3. Non-secret environment vars (PATH, PRIMARY_PROVIDER, FALLBACK_PROVIDERS, WEB_*, …)
|
|
122
|
+
4. Last 200 lines of `alvin-bot.err.log`
|
|
123
|
+
5. Last 200 lines of `alvin-bot.out.log`
|
|
124
|
+
6. Watchdog state (`~/.alvin-bot/state/watchdog.json`)
|
|
125
|
+
7. System tool inventory (`node`, `npm`, `brew`, `pm2`, `codex`, `claude`, `yt-dlp`, `ffmpeg`, `wacli`, `agent-browser`)
|
|
126
|
+
8. Disk space (`df -h ~/.alvin-bot`)
|
|
127
|
+
9. PM2 status (if PM2 installed — the same kind of state that bit us in 4.25.1)
|
|
128
|
+
|
|
129
|
+
Bundles are ~18 KB each, capped at 50 retained files (oldest pruned automatically). The Telegram DM from feature 1D now includes the bundle path so the operator can immediately `cat` or scp it.
|
|
130
|
+
|
|
131
|
+
This is also the data input the 5.0.0 AI-Self-Diagnosis (feature 3I) will feed to a sub-agent for automated analysis. As a 4.26.0 deliverable it stands on its own as "human-readable forensic dump".
|
|
132
|
+
|
|
133
|
+
Opt-out: `ALVIN_DISABLE_AUTO_DIAGNOSTIC=true`.
|
|
134
|
+
|
|
135
|
+
### Bundle wacli (WhatsApp CLI) with conditional opt-in
|
|
136
|
+
|
|
137
|
+
`wacli` (https://wacli.sh, brew tap `steipete/tap`, v0.8.1, ~25 MB Go binary) is now part of `BOOTSTRAP_TOOLS` — but with a **hybrid install condition** that avoids forcing it onto users who don't use WhatsApp:
|
|
138
|
+
|
|
139
|
+
- **If `wacli` is already installed** → bootstrap runs `brew upgrade wacli` (treated like any other bundled tool).
|
|
140
|
+
- **If `WHATSAPP_ENABLED=true` is set in `.env`** → bootstrap installs via `brew install steipete/tap/wacli`.
|
|
141
|
+
- **Otherwise** → silent skip with dimmer `·` icon: `· wacli (WhatsApp CLI) skipped (not opted in)`.
|
|
142
|
+
|
|
143
|
+
License: see https://wacli.sh — alvin-bot does not bundle wacli, only invokes the user's brew, the user remains the licensee. macOS only (no Linux build upstream; bootstrap skips on Linux automatically).
|
|
144
|
+
|
|
145
|
+
### Opt-out env vars summary
|
|
146
|
+
|
|
147
|
+
For users who want minimal footprint:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
ALVIN_DISABLE_SELF_PRESERVATION=true # skip ALL Phase-1 features
|
|
151
|
+
ALVIN_DISABLE_PREFLIGHT=true # skip Pre-Flight only
|
|
152
|
+
ALVIN_DISABLE_CRITICAL_NOTIFY=true # skip cross-channel notify
|
|
153
|
+
ALVIN_DISABLE_DEAD_MAN=true # skip heartbeat writer
|
|
154
|
+
ALVIN_DISABLE_AUTO_DIAGNOSTIC=true # skip diagnostic bundles
|
|
155
|
+
ALVIN_DEADMAN_THRESHOLD_SEC=600 # tune dead-man threshold (default 10 min)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Performance budget verified on real hardware
|
|
159
|
+
|
|
160
|
+
End-to-end measurements on Apple Silicon Mac (.75 test box):
|
|
161
|
+
|
|
162
|
+
| Metric | Baseline 4.25.1 | 4.26.0 | Δ | Tolerance |
|
|
163
|
+
|---|---|---|---|---|
|
|
164
|
+
| Cold-start ready (median, throttled) | 5023 ms | 5104 ms | +81 ms | +2000 ms |
|
|
165
|
+
| Cold-start ready (unthrottled, 1st run) | 2189 ms | 2170 ms | -19 ms | +2000 ms |
|
|
166
|
+
| RSS idle steady-state | ~102 MB | 106.4 MB | +4.4 MB | +5 MB |
|
|
167
|
+
| CPU idle | 0.0 % | 0.0 % | 0 | +0.1 % |
|
|
168
|
+
| Log dir growth | stable | stable | n/a | <1 KB/s |
|
|
169
|
+
|
|
170
|
+
All five metrics within tolerance.
|
|
171
|
+
|
|
5
172
|
## [4.25.1] — 2026-05-13
|
|
6
173
|
|
|
7
174
|
### Fixed: `alvin-bot launchd install` now persists the PM2 cleanup
|
package/bin/cli.js
CHANGED
|
@@ -272,6 +272,24 @@ const BOOTSTRAP_TOOLS = [
|
|
|
272
272
|
install: { macos: "brew install ffmpeg", linux: "sudo apt-get install -y ffmpeg" },
|
|
273
273
|
upgrade: { macos: "brew upgrade ffmpeg", linux: "sudo apt-get install --only-upgrade -y ffmpeg" },
|
|
274
274
|
},
|
|
275
|
+
{
|
|
276
|
+
// wacli — WhatsApp CLI from steipete/tap. Hybrid bootstrap: only
|
|
277
|
+
// install/upgrade if the user has already installed it (we
|
|
278
|
+
// respect their existing setup) or has explicitly opted in via
|
|
279
|
+
// WHATSAPP_ENABLED=true in .env. This avoids pulling a ~25 MB
|
|
280
|
+
// Go binary onto every public user's machine, including those
|
|
281
|
+
// who never touch WhatsApp.
|
|
282
|
+
cmd: "wacli",
|
|
283
|
+
name: "wacli (WhatsApp CLI)",
|
|
284
|
+
license: "see https://wacli.sh — installed via your own brew, you remain the licensee",
|
|
285
|
+
install: { macos: "brew install steipete/tap/wacli", linux: null },
|
|
286
|
+
upgrade: { macos: "brew upgrade wacli", linux: null },
|
|
287
|
+
// Hybrid: only bootstrap if the user has explicitly signalled
|
|
288
|
+
// interest. installCondition is checked BEFORE any install/upgrade
|
|
289
|
+
// attempt; returns false → tool silently skipped.
|
|
290
|
+
installCondition: (env) =>
|
|
291
|
+
hasCommand("wacli") || env.WHATSAPP_ENABLED === "true",
|
|
292
|
+
},
|
|
275
293
|
];
|
|
276
294
|
|
|
277
295
|
// Memoized: `brew update` is slow (5-30s) but needs to run at least once
|
|
@@ -309,6 +327,22 @@ function detectPlatformPm() {
|
|
|
309
327
|
function bootstrapOneTool(tool, platform) {
|
|
310
328
|
const cmdAvailable = hasCommand(tool.cmd);
|
|
311
329
|
|
|
330
|
+
// installCondition: optional gate that respects user intent. A tool with
|
|
331
|
+
// installCondition returning false is treated as "user hasn't opted in,
|
|
332
|
+
// silently skip". This is how wacli avoids forcing a 25 MB WhatsApp CLI
|
|
333
|
+
// onto every public user — only installs if they have it already or
|
|
334
|
+
// explicitly set WHATSAPP_ENABLED=true in .env.
|
|
335
|
+
if (typeof tool.installCondition === "function") {
|
|
336
|
+
try {
|
|
337
|
+
if (!tool.installCondition(process.env)) {
|
|
338
|
+
return { ok: true, skipped: true, message: `${tool.name} skipped (not opted in)` };
|
|
339
|
+
}
|
|
340
|
+
} catch {
|
|
341
|
+
// condition function threw — be defensive, skip
|
|
342
|
+
return { ok: true, skipped: true, message: `${tool.name} skipped (condition error)` };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
312
346
|
// Linux-only prerequisite check (e.g. pipx for yt-dlp).
|
|
313
347
|
if (platform === "linux" && tool.linuxSkipIf && !hasCommand(tool.linuxSkipIf)) {
|
|
314
348
|
return { ok: false, message: `${tool.name} skipped — needs '${tool.linuxSkipIf}' on Linux` };
|
|
@@ -376,12 +410,12 @@ async function ensureBootstrapTools(opts = {}) {
|
|
|
376
410
|
const platform = detectPlatformPm();
|
|
377
411
|
if (!platform) return;
|
|
378
412
|
|
|
379
|
-
console.log("\n🎬 Setting up
|
|
413
|
+
console.log("\n🎬 Setting up bundled tools (yt-dlp, ffmpeg, wacli on opt-in)...");
|
|
380
414
|
|
|
381
415
|
// macOS needs brew on PATH — same trick as ensureBrewOnPath() uses.
|
|
382
416
|
if (platform === "macos" && !hasCommand("brew")) {
|
|
383
417
|
if (!ensureBrewOnPath()) {
|
|
384
|
-
console.log(" ⚠️ Skipping
|
|
418
|
+
console.log(" ⚠️ Skipping tool bootstrap — Homebrew not installed.");
|
|
385
419
|
console.log(" To enable: install brew from https://brew.sh and re-run setup.");
|
|
386
420
|
return;
|
|
387
421
|
}
|
|
@@ -389,7 +423,9 @@ async function ensureBootstrapTools(opts = {}) {
|
|
|
389
423
|
|
|
390
424
|
for (const tool of BOOTSTRAP_TOOLS) {
|
|
391
425
|
const result = bootstrapOneTool(tool, platform);
|
|
392
|
-
|
|
426
|
+
// skipped (opt-in not signaled) → use dimmer icon, less attention-grabbing
|
|
427
|
+
const icon = result.skipped ? "·" : result.ok ? "✓" : "⚠";
|
|
428
|
+
console.log(` ${icon} ${result.message}`);
|
|
393
429
|
}
|
|
394
430
|
console.log("");
|
|
395
431
|
}
|
|
@@ -2688,7 +2724,80 @@ function launchdPaths() {
|
|
|
2688
2724
|
const entryPoint = resolve(join(import.meta.dirname, "..", "dist", "index.js"));
|
|
2689
2725
|
const cwd = resolve(join(import.meta.dirname, ".."));
|
|
2690
2726
|
const nodePath = process.execPath;
|
|
2691
|
-
|
|
2727
|
+
// Dead-man-switch watcher (Self-Preservation Phase 1, feature 2E).
|
|
2728
|
+
// Separate, tiny LaunchAgent that fires every 5 min and force-restarts
|
|
2729
|
+
// the main bot if its heartbeat-file is stale. The two agents are
|
|
2730
|
+
// intentionally independent: if the main bot is wedged, the dead-man
|
|
2731
|
+
// agent is still scheduling and reading the file.
|
|
2732
|
+
const deadmanLabel = "com.alvinbot.deadman";
|
|
2733
|
+
const deadmanPlistPath = join(home, "Library", "LaunchAgents", `${deadmanLabel}.plist`);
|
|
2734
|
+
return { home, label, plistPath, logDir, entryPoint, cwd, nodePath, deadmanLabel, deadmanPlistPath };
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
/**
|
|
2738
|
+
* Generate the dead-man watcher LaunchAgent plist. It runs a tiny shell
|
|
2739
|
+
* script every 5 minutes (StartInterval) that compares the bot's
|
|
2740
|
+
* heartbeat-file timestamp against now. If the heartbeat is more than
|
|
2741
|
+
* 10 minutes stale, it `launchctl kickstart -k`s the main bot.
|
|
2742
|
+
*
|
|
2743
|
+
* The threshold is overridable via ALVIN_DEADMAN_THRESHOLD_SEC for
|
|
2744
|
+
* testing; default is 600 s = 10 minutes.
|
|
2745
|
+
*
|
|
2746
|
+
* Why inline shell instead of a bundled script:
|
|
2747
|
+
* - Zero extra files to ship via npm
|
|
2748
|
+
* - Trivial to audit: 12 lines of POSIX sh
|
|
2749
|
+
* - No PATH dependency (uses absolute /bin paths)
|
|
2750
|
+
*/
|
|
2751
|
+
function renderDeadmanPlist({ deadmanLabel, mainLabel, home, logDir }) {
|
|
2752
|
+
// Inline shell — kept POSIX-clean, uses only built-ins + launchctl.
|
|
2753
|
+
// The redirect to logDir/deadman.log gives us a record of any
|
|
2754
|
+
// kickstart actions without the watcher writing more than ~50
|
|
2755
|
+
// bytes per event.
|
|
2756
|
+
const script = `
|
|
2757
|
+
HEARTBEAT="${home}/.alvin-bot/heartbeat.txt"
|
|
2758
|
+
LOG="${logDir}/deadman.log"
|
|
2759
|
+
THRESHOLD="\${ALVIN_DEADMAN_THRESHOLD_SEC:-600}"
|
|
2760
|
+
if [ ! -f "$HEARTBEAT" ]; then exit 0; fi
|
|
2761
|
+
LAST=$(cat "$HEARTBEAT" 2>/dev/null | tr -d ' \\n')
|
|
2762
|
+
NOW=$(date +%s)
|
|
2763
|
+
case "$LAST" in
|
|
2764
|
+
''|*[!0-9]*) exit 0 ;;
|
|
2765
|
+
esac
|
|
2766
|
+
DIFF=$((NOW - LAST))
|
|
2767
|
+
if [ "$DIFF" -gt "$THRESHOLD" ]; then
|
|
2768
|
+
echo "$(date -u +%FT%TZ) deadman: heartbeat $DIFF s old (> $THRESHOLD s), kickstarting ${mainLabel}" >> "$LOG"
|
|
2769
|
+
/bin/launchctl kickstart -k "gui/$(id -u)/${mainLabel}" 2>>"$LOG" || true
|
|
2770
|
+
fi
|
|
2771
|
+
`.trim();
|
|
2772
|
+
|
|
2773
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2774
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2775
|
+
<plist version="1.0">
|
|
2776
|
+
<dict>
|
|
2777
|
+
<key>Label</key>
|
|
2778
|
+
<string>${deadmanLabel}</string>
|
|
2779
|
+
|
|
2780
|
+
<key>ProgramArguments</key>
|
|
2781
|
+
<array>
|
|
2782
|
+
<string>/bin/sh</string>
|
|
2783
|
+
<string>-c</string>
|
|
2784
|
+
<string>${script.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")}</string>
|
|
2785
|
+
</array>
|
|
2786
|
+
|
|
2787
|
+
<key>StartInterval</key>
|
|
2788
|
+
<integer>300</integer>
|
|
2789
|
+
|
|
2790
|
+
<key>RunAtLoad</key>
|
|
2791
|
+
<false/>
|
|
2792
|
+
|
|
2793
|
+
<key>StandardErrorPath</key>
|
|
2794
|
+
<string>${logDir}/deadman.err.log</string>
|
|
2795
|
+
|
|
2796
|
+
<key>LimitLoadToSessionType</key>
|
|
2797
|
+
<string>Aqua</string>
|
|
2798
|
+
</dict>
|
|
2799
|
+
</plist>
|
|
2800
|
+
`;
|
|
2692
2801
|
}
|
|
2693
2802
|
|
|
2694
2803
|
async function launchdInstall() {
|
|
@@ -2830,6 +2939,38 @@ async function launchdInstall() {
|
|
|
2830
2939
|
console.log(` protected files. (Granted path: ${fda.realNodePath})`);
|
|
2831
2940
|
}
|
|
2832
2941
|
|
|
2942
|
+
// ── Dead-Man-Switch (Self-Preservation Phase 1, feature 2E) ──────────
|
|
2943
|
+
// Install a second tiny LaunchAgent that wakes every 5 min and force-
|
|
2944
|
+
// restarts the main bot if its heartbeat-file is stale. Catches "process
|
|
2945
|
+
// alive but frozen" — event-loop deadlocks, blocked I/O, etc. — that
|
|
2946
|
+
// the in-process watchdog can't see.
|
|
2947
|
+
// Opt-out: ALVIN_DISABLE_DEAD_MAN=true or ALVIN_DISABLE_SELF_PRESERVATION=true.
|
|
2948
|
+
if (
|
|
2949
|
+
process.env.ALVIN_DISABLE_DEAD_MAN !== "true" &&
|
|
2950
|
+
process.env.ALVIN_DISABLE_SELF_PRESERVATION !== "true"
|
|
2951
|
+
) {
|
|
2952
|
+
const { deadmanLabel, deadmanPlistPath } = launchdPaths();
|
|
2953
|
+
const deadmanPlist = renderDeadmanPlist({
|
|
2954
|
+
deadmanLabel,
|
|
2955
|
+
mainLabel: label,
|
|
2956
|
+
home,
|
|
2957
|
+
logDir,
|
|
2958
|
+
});
|
|
2959
|
+
writeFileSync(deadmanPlistPath, deadmanPlist, { mode: 0o644 });
|
|
2960
|
+
console.log("");
|
|
2961
|
+
console.log(`📝 Wrote ${deadmanPlistPath}`);
|
|
2962
|
+
try {
|
|
2963
|
+
execSync(`launchctl bootout gui/$(id -u)/${deadmanLabel} 2>/dev/null || true`, { stdio: "pipe" });
|
|
2964
|
+
} catch {}
|
|
2965
|
+
try {
|
|
2966
|
+
execSync(`launchctl bootstrap gui/$(id -u) "${deadmanPlistPath}"`, { stdio: "pipe" });
|
|
2967
|
+
console.log("🛡️ Dead-man watcher active — checks every 5 min, force-restarts main bot if heartbeat > 10 min stale.");
|
|
2968
|
+
} catch (err) {
|
|
2969
|
+
console.log(`⚠️ Dead-man watcher load failed (non-fatal): ${err.message?.split("\n")[0] || err}`);
|
|
2970
|
+
console.log(" The main bot still works; only zombie-detection is disabled.");
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2833
2974
|
process.exit(0);
|
|
2834
2975
|
}
|
|
2835
2976
|
|
|
@@ -2859,6 +3000,20 @@ async function launchdUninstall() {
|
|
|
2859
3000
|
console.log(`⚠️ Could not remove plist: ${err.message}`);
|
|
2860
3001
|
}
|
|
2861
3002
|
|
|
3003
|
+
// Dead-Man watcher (feature 2E) — also remove its companion plist.
|
|
3004
|
+
const { deadmanLabel, deadmanPlistPath } = launchdPaths();
|
|
3005
|
+
if (existsSync(deadmanPlistPath)) {
|
|
3006
|
+
try {
|
|
3007
|
+
execSync(`launchctl bootout gui/$(id -u)/${deadmanLabel} 2>/dev/null || true`, { stdio: "pipe" });
|
|
3008
|
+
} catch {}
|
|
3009
|
+
try {
|
|
3010
|
+
execSync(`rm -f "${deadmanPlistPath}"`);
|
|
3011
|
+
console.log(`🗑 Removed ${deadmanPlistPath} (dead-man watcher)`);
|
|
3012
|
+
} catch (err) {
|
|
3013
|
+
console.log(`⚠️ Could not remove dead-man plist: ${err.message}`);
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
|
|
2862
3017
|
console.log("");
|
|
2863
3018
|
console.log("✅ alvin-bot is no longer a launchd user agent.");
|
|
2864
3019
|
process.exit(0);
|
package/dist/index.js
CHANGED
|
@@ -204,6 +204,37 @@ if (hasProvider) {
|
|
|
204
204
|
else {
|
|
205
205
|
console.warn("⚠️ Engine not initialized — no AI provider configured.");
|
|
206
206
|
}
|
|
207
|
+
// Pre-Flight Sanity Check (Self-Preservation Phase 1, feature 1A) —
|
|
208
|
+
// runs in parallel, fire-and-forget. Does NOT block startup.
|
|
209
|
+
// Catches misconfigurations + degraded state at boot time.
|
|
210
|
+
import("./services/preflight.js")
|
|
211
|
+
.then(({ runPreFlight, formatPreFlightReport }) => runPreFlight(config.botToken, registry).then((report) => {
|
|
212
|
+
console.log(formatPreFlightReport(report));
|
|
213
|
+
}))
|
|
214
|
+
.catch((err) => {
|
|
215
|
+
// Pre-Flight itself must never crash the bot.
|
|
216
|
+
console.warn("⚠️ Pre-Flight check threw:", err?.message || err);
|
|
217
|
+
});
|
|
218
|
+
// AI Self-Diagnosis startup analyzer (Self-Preservation Phase 2, 3I).
|
|
219
|
+
// Scans ~/.alvin-bot/diagnostics/ for forensic bundles without a
|
|
220
|
+
// .analysis.md sidecar and runs AI analysis on each. Findings land on
|
|
221
|
+
// the operator's phone via 1D Telegram channel within ~30 s of the
|
|
222
|
+
// bot recovering from a brake. Fire-and-forget, never blocks startup.
|
|
223
|
+
// Provider-agnostic: uses the active Provider's query() async generator.
|
|
224
|
+
import("./services/self-diagnosis.js")
|
|
225
|
+
.then(({ runStartupAnalyzer }) => runStartupAnalyzer(registry))
|
|
226
|
+
.catch((err) => {
|
|
227
|
+
console.warn("⚠️ Self-diagnosis analyzer threw:", err?.message || err);
|
|
228
|
+
});
|
|
229
|
+
// Predictive-Maintenance Trends collector (Self-Preservation Phase 2, 3J).
|
|
230
|
+
// Snapshots health metrics every 24 h (first one after 60 s warmup).
|
|
231
|
+
// After 7 days of data, also runs AI anomaly detection daily.
|
|
232
|
+
// If a concerning trend is flagged → DM operator via 1D channel.
|
|
233
|
+
import("./services/trends.js")
|
|
234
|
+
.then(({ startTrendsCollector }) => startTrendsCollector(registry))
|
|
235
|
+
.catch((err) => {
|
|
236
|
+
console.warn("⚠️ Trends collector threw:", err?.message || err);
|
|
237
|
+
});
|
|
207
238
|
// Load plugins
|
|
208
239
|
const pluginResult = await loadPlugins();
|
|
209
240
|
if (pluginResult.loaded.length > 0) {
|
|
@@ -527,6 +558,14 @@ setNotifyCallback(async (target, text) => {
|
|
|
527
558
|
enqueue(target.platform, String(target.chatId), text);
|
|
528
559
|
});
|
|
529
560
|
startScheduler();
|
|
561
|
+
// Heartbeat-file writer (Self-Preservation Phase 1, feature 2E).
|
|
562
|
+
// Writes ~/.alvin-bot/heartbeat.txt every 60 s so an external
|
|
563
|
+
// dead-man-watch launchd agent can detect "process alive but frozen"
|
|
564
|
+
// and force-restart the bot. Catches event-loop deadlocks that the
|
|
565
|
+
// in-process watchdog cannot see.
|
|
566
|
+
import("./services/heartbeat-file.js").then(({ startHeartbeatWriter }) => {
|
|
567
|
+
startHeartbeatWriter();
|
|
568
|
+
});
|
|
530
569
|
// Start the async-agent watcher (Fix #17 Stage 2). Polls outputFiles
|
|
531
570
|
// of background sub-agents Claude launched with run_in_background and
|
|
532
571
|
// delivers their completed reports as separate Telegram messages.
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-Diagnostic Logs-Collector (Self-Preservation Phase 1, feature 2F).
|
|
3
|
+
*
|
|
4
|
+
* On critical failure, write a structured Markdown "forensic bundle" to
|
|
5
|
+
* ~/.alvin-bot/diagnostics/<timestamp>-<category>.md containing:
|
|
6
|
+
*
|
|
7
|
+
* - Bot version + boot info
|
|
8
|
+
* - Last 200 lines of out.log + err.log
|
|
9
|
+
* - Current process state (PID, RSS, uptime, node version, platform)
|
|
10
|
+
* - Non-secret environment vars (PATH, PRIMARY_PROVIDER, …)
|
|
11
|
+
* - Watchdog state (~/.alvin-bot/state/watchdog.json)
|
|
12
|
+
* - System tool inventory (which node/codex/claude/pm2/yt-dlp/…)
|
|
13
|
+
* - Disk space snapshot
|
|
14
|
+
* - The triggering event itself + suggestion
|
|
15
|
+
*
|
|
16
|
+
* The bundle is the input that the 5.0.0 AI-Diagnostic feature (3I) will
|
|
17
|
+
* later feed to a sub-agent for automated analysis. As of 4.26.0 it's a
|
|
18
|
+
* "human-readable forensic dump" — useful on its own, no AI required.
|
|
19
|
+
*
|
|
20
|
+
* Auto-prune: max 50 retained bundles, oldest deleted on next write.
|
|
21
|
+
*
|
|
22
|
+
* Performance: <100KB per bundle, ~50-200ms wall-clock per write,
|
|
23
|
+
* synchronous (we're typically called right before process.exit so
|
|
24
|
+
* blocking is the right semantic). Files are atomic — full bundle or
|
|
25
|
+
* nothing.
|
|
26
|
+
*
|
|
27
|
+
* Opt-out:
|
|
28
|
+
* ALVIN_DISABLE_AUTO_DIAGNOSTIC=true → skip bundle writes
|
|
29
|
+
* ALVIN_DISABLE_SELF_PRESERVATION=true → skip ALL Phase-1
|
|
30
|
+
*/
|
|
31
|
+
import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync, } from "fs";
|
|
32
|
+
import { join } from "path";
|
|
33
|
+
import { homedir } from "os";
|
|
34
|
+
import { execSync } from "child_process";
|
|
35
|
+
import { BOT_VERSION } from "../version.js";
|
|
36
|
+
const MAX_BUNDLES = 50;
|
|
37
|
+
function isDisabled() {
|
|
38
|
+
return (process.env.ALVIN_DISABLE_AUTO_DIAGNOSTIC === "true" ||
|
|
39
|
+
process.env.ALVIN_DISABLE_SELF_PRESERVATION === "true");
|
|
40
|
+
}
|
|
41
|
+
function safeReadTail(filename, n) {
|
|
42
|
+
try {
|
|
43
|
+
const path = join(homedir(), ".alvin-bot", "logs", filename);
|
|
44
|
+
if (!existsSync(path))
|
|
45
|
+
return "(log file not present)";
|
|
46
|
+
const content = readFileSync(path, "utf-8");
|
|
47
|
+
const lines = content.split("\n");
|
|
48
|
+
return lines.slice(Math.max(0, lines.length - n)).join("\n");
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return `(read failed: ${err instanceof Error ? err.message : String(err)})`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function safeShell(cmd, timeoutMs = 5000) {
|
|
55
|
+
try {
|
|
56
|
+
return execSync(cmd, { encoding: "utf-8", timeout: timeoutMs, stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
const e = err;
|
|
60
|
+
const out = e.stdout?.toString().trim() ?? "";
|
|
61
|
+
const stderr = e.stderr?.toString().trim() ?? "";
|
|
62
|
+
if (out)
|
|
63
|
+
return out + (stderr ? `\n[stderr]: ${stderr}` : "");
|
|
64
|
+
return `(command failed: ${e.message || "unknown"})`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function safeReadFile(path) {
|
|
68
|
+
try {
|
|
69
|
+
return readFileSync(path, "utf-8").trim();
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
return `(could not read ${path}: ${err instanceof Error ? err.message : String(err)})`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Prune diagnostic bundles older than MAX_BUNDLES (50). Oldest deleted
|
|
77
|
+
* first by mtime. Best-effort: silent on errors.
|
|
78
|
+
*/
|
|
79
|
+
export function pruneDiagnostics(maxKeep = MAX_BUNDLES) {
|
|
80
|
+
try {
|
|
81
|
+
const dir = join(homedir(), ".alvin-bot", "diagnostics");
|
|
82
|
+
if (!existsSync(dir))
|
|
83
|
+
return;
|
|
84
|
+
const files = readdirSync(dir)
|
|
85
|
+
.filter((f) => f.endsWith(".md"))
|
|
86
|
+
.map((f) => {
|
|
87
|
+
try {
|
|
88
|
+
return { name: f, mtime: statSync(join(dir, f)).mtimeMs };
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { name: f, mtime: 0 };
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
95
|
+
for (const f of files.slice(maxKeep)) {
|
|
96
|
+
try {
|
|
97
|
+
unlinkSync(join(dir, f.name));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* best-effort */
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* never fail the caller */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Write a diagnostic bundle for the given event. Returns the absolute
|
|
110
|
+
* path to the written file, or null if disabled / failed.
|
|
111
|
+
*
|
|
112
|
+
* Safe to call from any context — never throws. Side-effects:
|
|
113
|
+
* - Creates ~/.alvin-bot/diagnostics/ if absent
|
|
114
|
+
* - Writes a single ~50-100KB markdown file
|
|
115
|
+
* - Prunes to MAX_BUNDLES retained
|
|
116
|
+
*/
|
|
117
|
+
export function writeDiagnosticBundle(event) {
|
|
118
|
+
if (isDisabled())
|
|
119
|
+
return null;
|
|
120
|
+
try {
|
|
121
|
+
const dir = join(homedir(), ".alvin-bot", "diagnostics");
|
|
122
|
+
mkdirSync(dir, { recursive: true });
|
|
123
|
+
const ts = (event.ts || new Date()).toISOString().replace(/[:.]/g, "-");
|
|
124
|
+
const filename = `${ts}-${event.category}.md`;
|
|
125
|
+
const filepath = join(dir, filename);
|
|
126
|
+
const mem = process.memoryUsage();
|
|
127
|
+
const rssMB = Math.round(mem.rss / 1024 / 1024);
|
|
128
|
+
const heapMB = Math.round(mem.heapUsed / 1024 / 1024);
|
|
129
|
+
const sections = [
|
|
130
|
+
`# Alvin Bot — Diagnostic Bundle`,
|
|
131
|
+
``,
|
|
132
|
+
`**Generated:** ${new Date().toISOString()}`,
|
|
133
|
+
`**Bot version:** ${BOT_VERSION}`,
|
|
134
|
+
`**Trigger category:** ${event.category}`,
|
|
135
|
+
`**Severity:** ${event.severity}`,
|
|
136
|
+
`**Title:** ${event.title}`,
|
|
137
|
+
``,
|
|
138
|
+
`## 1. Event Detail`,
|
|
139
|
+
``,
|
|
140
|
+
"```",
|
|
141
|
+
event.detail,
|
|
142
|
+
"```",
|
|
143
|
+
``,
|
|
144
|
+
...(event.suggestedAction
|
|
145
|
+
? [`### Suggested action`, ``, "```", event.suggestedAction, "```", ``]
|
|
146
|
+
: []),
|
|
147
|
+
`## 2. Process State`,
|
|
148
|
+
``,
|
|
149
|
+
`- PID: ${process.pid}`,
|
|
150
|
+
`- RSS memory: ${rssMB} MB`,
|
|
151
|
+
`- Heap used: ${heapMB} MB`,
|
|
152
|
+
`- Uptime: ${Math.round(process.uptime())} s`,
|
|
153
|
+
`- Node.js: ${process.version}`,
|
|
154
|
+
`- Platform: ${process.platform} (${process.arch})`,
|
|
155
|
+
`- argv: ${process.argv.join(" ")}`,
|
|
156
|
+
``,
|
|
157
|
+
`## 3. Environment (non-secret only)`,
|
|
158
|
+
``,
|
|
159
|
+
...[
|
|
160
|
+
"NODE_ENV",
|
|
161
|
+
"HOME",
|
|
162
|
+
"PATH",
|
|
163
|
+
"PRIMARY_PROVIDER",
|
|
164
|
+
"FALLBACK_PROVIDERS",
|
|
165
|
+
"AUTH_MODE",
|
|
166
|
+
"SESSION_MODE",
|
|
167
|
+
"WEB_HOST",
|
|
168
|
+
"WEB_PORT",
|
|
169
|
+
"WORKING_DIR",
|
|
170
|
+
"MAX_BUDGET_USD",
|
|
171
|
+
"ALVIN_DATA_DIR",
|
|
172
|
+
"ALVIN_DEADMAN_THRESHOLD_SEC",
|
|
173
|
+
"ALVIN_DISABLE_SELF_PRESERVATION",
|
|
174
|
+
].map((key) => `- ${key}: ${process.env[key] ?? "(unset)"}`),
|
|
175
|
+
``,
|
|
176
|
+
`## 4. Recent stderr (last 200 lines)`,
|
|
177
|
+
``,
|
|
178
|
+
"```",
|
|
179
|
+
safeReadTail("alvin-bot.err.log", 200),
|
|
180
|
+
"```",
|
|
181
|
+
``,
|
|
182
|
+
`## 5. Recent stdout (last 200 lines)`,
|
|
183
|
+
``,
|
|
184
|
+
"```",
|
|
185
|
+
safeReadTail("alvin-bot.out.log", 200),
|
|
186
|
+
"```",
|
|
187
|
+
``,
|
|
188
|
+
`## 6. Watchdog state`,
|
|
189
|
+
``,
|
|
190
|
+
"```json",
|
|
191
|
+
safeReadFile(join(homedir(), ".alvin-bot", "state", "watchdog.json")),
|
|
192
|
+
"```",
|
|
193
|
+
``,
|
|
194
|
+
`## 7. System tool inventory`,
|
|
195
|
+
``,
|
|
196
|
+
"```",
|
|
197
|
+
safeShell("for t in node npm brew pm2 codex claude yt-dlp ffmpeg wacli agent-browser; do printf '%-15s %s\\n' \"$t\" \"$(command -v $t 2>/dev/null || echo NOT_FOUND)\"; done"),
|
|
198
|
+
"```",
|
|
199
|
+
``,
|
|
200
|
+
`## 8. Disk space (.alvin-bot data dir)`,
|
|
201
|
+
``,
|
|
202
|
+
"```",
|
|
203
|
+
safeShell(`df -h "${join(homedir(), ".alvin-bot")}" 2>&1 | head -2`),
|
|
204
|
+
"```",
|
|
205
|
+
``,
|
|
206
|
+
`## 9. PM2 status (if installed)`,
|
|
207
|
+
``,
|
|
208
|
+
"```",
|
|
209
|
+
safeShell("command -v pm2 >/dev/null && pm2 jlist 2>/dev/null | head -50 || echo 'pm2 not installed'", 3000),
|
|
210
|
+
"```",
|
|
211
|
+
``,
|
|
212
|
+
`---`,
|
|
213
|
+
``,
|
|
214
|
+
`*This bundle was generated automatically by the Alvin Bot auto-diagnostic system.*`,
|
|
215
|
+
`*Set \`ALVIN_DISABLE_AUTO_DIAGNOSTIC=true\` in ~/.alvin-bot/.env to opt out.*`,
|
|
216
|
+
``,
|
|
217
|
+
];
|
|
218
|
+
writeFileSync(filepath, sections.join("\n"), { mode: 0o600 });
|
|
219
|
+
pruneDiagnostics();
|
|
220
|
+
return filepath;
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
// Diagnostic writer must not be a new failure mode. Log to stderr
|
|
224
|
+
// (which the critical-notify file flag will reference) and bail.
|
|
225
|
+
console.error(`[auto-diagnostic] failed to write bundle: ${err instanceof Error ? err.message : String(err)}`);
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|