alvin-bot 4.5.0 → 4.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 (42) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +25 -2
  3. package/alvin-bot-4.5.1.tgz +0 -0
  4. package/bin/cli.js +246 -0
  5. package/dist/handlers/commands.js +461 -63
  6. package/dist/handlers/message.js +209 -14
  7. package/dist/i18n.js +470 -13
  8. package/dist/index.js +44 -5
  9. package/dist/providers/claude-sdk-provider.js +106 -14
  10. package/dist/providers/ollama-provider.js +32 -0
  11. package/dist/providers/openai-compatible.js +10 -1
  12. package/dist/providers/registry.js +112 -17
  13. package/dist/providers/types.js +25 -3
  14. package/dist/services/compaction.js +2 -0
  15. package/dist/services/cron.js +53 -42
  16. package/dist/services/heartbeat.js +41 -7
  17. package/dist/services/language-detect.js +12 -2
  18. package/dist/services/ollama-manager.js +339 -0
  19. package/dist/services/personality.js +20 -14
  20. package/dist/services/session.js +21 -3
  21. package/dist/services/subagent-delivery.js +111 -0
  22. package/dist/services/subagents.js +341 -27
  23. package/dist/services/telegram.js +28 -1
  24. package/dist/services/updater.js +158 -0
  25. package/dist/services/usage-tracker.js +11 -4
  26. package/dist/services/users.js +2 -1
  27. package/dist/tui/index.js +36 -30
  28. package/docs/HANDBOOK.md +819 -0
  29. package/package.json +7 -2
  30. package/test/claude-sdk-provider.test.ts +69 -0
  31. package/test/i18n.test.ts +108 -0
  32. package/test/registry.test.ts +201 -0
  33. package/test/subagent-delivery.test.ts +169 -0
  34. package/test/subagents-commands.test.ts +64 -0
  35. package/test/subagents-config.test.ts +108 -0
  36. package/test/subagents-depth.test.ts +58 -0
  37. package/test/subagents-inheritance.test.ts +67 -0
  38. package/test/subagents-name-resolver.test.ts +122 -0
  39. package/test/subagents-priority-reject.test.ts +60 -0
  40. package/test/subagents-shutdown.test.ts +126 -0
  41. package/test/subagents-toolset.test.ts +51 -0
  42. package/vitest.config.ts +17 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,156 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.6.0] — 2026-04-11
6
+
7
+ ### ✨ Sub-Agents Stufe 1 — context-aware delivery, name-first addressing, shutdown notifications
8
+
9
+ **The big one.** Stufe 1 of the SubAgents refinement spec (9 design axes, two-stage rollout) is complete. Everything here is live-validated on a remote test MacBook via `@Alvin_testbot_bot` over Telegram with Claude Agent SDK + Max OAuth.
10
+
11
+ #### A4 + I3 — Source-aware delivery router
12
+
13
+ New module `src/services/subagent-delivery.ts`. Every completed sub-agent routes through a single entry point that picks its delivery path based on `SubAgentInfo.source`:
14
+
15
+ - `implicit` (Main-Claude calling the SDK `Task` tool) → **no-op**, the parent stream already shows the result.
16
+ - `user` (explicit user spawn) → **banner + final** to `parentChatId` in the originating chat.
17
+ - `cron` (scheduled job) → **banner + final** to the `chatId` from the cron job's target.
18
+
19
+ The banner format is fixed: `{icon} *{name}* {status} · {duration} · {input_tokens} in / {output_tokens} out` followed by the agent output. Status icons: ✅ completed, ⚠️ cancelled, ⏱️ timeout, ❌ error. Duration is human-formatted (`42s`, `3m 12s`). Token counts collapse at 1000 (`4.2k`).
20
+
21
+ Output chunking:
22
+ - ≤3800 chars → single message `banner + body`
23
+ - 3800–20000 chars → banner alone, then body chunks of 3800 chars each
24
+ - \>20000 chars → banner + the body as a `.md` file upload (via `grammy`'s `InputFile`)
25
+
26
+ The bot API is attached lazily at startup via `attachBotApi()` so `subagent-delivery.ts` stays free of a circular import on `index.ts`. Test hook `__setBotApiForTest()` lets Vitest inject a fake.
27
+
28
+ #### New command: `/subagents visibility <auto|banner|silent>`
29
+
30
+ Per-install persistent visibility setting, written to `~/.alvin-bot/sub-agents.json`. `silent` suppresses the delivery entirely — the result is still stored in the `activeAgents` map and pullable via `/subagents result <name>`. `auto` is the default and falls through to the source-based routing described above.
31
+
32
+ #### B2 — Name-first addressing with automatic `#N` collision suffixes
33
+
34
+ `/subagents cancel <name|id>` and `/subagents result <name|id>` now accept names, not just UUIDs. When a new spawn collides with an existing name, the resolver appends `#2`, `#3`, … using the smallest free index. Example: three parallel `review` spawns appear as `review`, `review#2`, `review#3` in `/subagents list`.
35
+
36
+ Resolution order:
37
+ 1. Explicit `#N` suffix (e.g. `review#2`) → exact match wins, never falls through to ambiguity
38
+ 2. Base name with a single sibling → that sibling
39
+ 3. Base name with multiple siblings **and** `ambiguousAsList: true` opt-in → disambiguation reply listing all candidates
40
+ 4. Base name with multiple siblings, no opt-in → first sibling
41
+ 5. No name match → UUID prefix (back-compat)
42
+
43
+ #### C3 — Parent inheritance
44
+
45
+ Sub-agents now inherit `workingDir` (with `inheritCwd: false` opt-out), `CLAUDE.md` (via `settingSources: ["project"]`), and the registry's provider/model. Conversation history is **not** inherited — the sub-agent reads only its own prompt, which forces clean, self-describing spawn requests and keeps parallel agents from colliding on shared context.
46
+
47
+ #### D4 — Priority-aware reject messages
48
+
49
+ Pool is still strictly capped (no preemption), but the error message when it's full now depends on who holds the slots:
50
+ - User spawn + background (cron/implicit) hold slots → message points at `/subagents list` so the user knows the pool isn't stuck on another interactive task
51
+ - User spawn + other user spawns → suggests cancel-or-wait with command hints
52
+ - Cron/implicit rejects → generic "limit reached" (those callers handle retry themselves)
53
+
54
+ #### E2 — Shutdown notifications
55
+
56
+ `cancelAllSubAgents(notify: true)` is now async and fires a delivery to each still-running agent before the process exits. Each notification is a synth `cancelled` result with the body `⚠️ Agent wurde durch Bot-Restart unterbrochen. Bitte neu triggern.` and routes through the normal I3 delivery path. Total delivery phase is capped at 5s so a hanging Telegram send can't block shutdown.
57
+
58
+ The shutdown hook in `src/index.ts` now `await`s `cancelAllSubAgents(true)` before stopping the grammy bot and tearing down plugins.
59
+
60
+ #### F2 — Depth cap (hard limit = 2)
61
+
62
+ `SubAgentConfig.depth` is a new optional field (defaults to 0 = root). `spawnSubAgent` rejects any depth > 2 with a clear error. The depth shows in `/subagents list` as `d0` / `d1` / `d2` with 2-space indentation per level, so nested scatter-gather runs are visually nested.
63
+
64
+ #### G1 — Toolset preset infrastructure
65
+
66
+ New `SubAgentConfig.toolset` field with a single valid value `"full"`. Runtime validation rejects any other string. This is purely infrastructure for future `"readonly"` / `"research"` presets — no behavior change today, but adding a preset later is a one-line diff.
67
+
68
+ #### H2 — Per-run token accounting in the banner
69
+
70
+ Every completed sub-agent's banner carries the input/output token counts it actually consumed. No aggregation (H3) — that comes later with the SQLite migration. For now, you can see "this agent cost me 4.2k/2.1k" right next to the result.
71
+
72
+ #### Tests
73
+
74
+ 67 passing Vitest tests across 12 files. New test files added for this release:
75
+ - `test/claude-sdk-provider.test.ts` — auth probe + `isAuthErrorOutput` helper
76
+ - `test/subagents-depth.test.ts` — depth cap (F2)
77
+ - `test/subagents-inheritance.test.ts` — cwd inheritance (C3)
78
+ - `test/subagents-toolset.test.ts` — toolset literal (G1)
79
+ - `test/subagents-name-resolver.test.ts` — `findSubAgentByName` including regression for exact-match vs ambiguity
80
+ - `test/subagents-commands.test.ts` — `cancelSubAgentByName`/`getSubAgentResultByName` helpers
81
+ - `test/subagent-delivery.test.ts` — I3 delivery router (all 5 source/visibility paths)
82
+ - `test/subagents-shutdown.test.ts` — E2 notify=true / notify=false + regression for shutdown double-delivery
83
+ - `test/subagents-priority-reject.test.ts` — D4 priority-aware reject messages
84
+ - `test/subagents-config.test.ts` — expanded with visibility config round-trip
85
+
86
+ ### 🖥 New CLI: `alvin-bot launchd install|uninstall|status` (macOS only)
87
+
88
+ **Why this matters.** Claude Code 2.x stores the Max-subscription OAuth token in the macOS Keychain, service `"Claude Code-credentials"`. Accessing the token requires:
89
+ 1. A Keychain ACL that permits the `claude` binary (granted via the "Always Allow" dialog on first GUI invocation)
90
+ 2. An *unlocked* Keychain in the calling process's security context
91
+
92
+ Processes started via SSH, pm2, or `nohup` run in a detached launchd session that does **not** inherit the GUI user's unlocked-Keychain state. Even a manual `security unlock-keychain -p '...'` only unlocks the current SSH session — the pm2 daemon running in its own context stays locked out. Result: the Bot saw `Not logged in · Please run /login` on every sub-agent query, and the fix in 4.6.0's Phase 0 exposes that as a clean error instead of leaking it as chat text.
93
+
94
+ **The fix**: run the bot as a **launchd user agent**. LaunchAgents run inside the GUI login session and inherit the unlocked Keychain automatically. No SSH dance, no pm2 drama, no manual unlocks on every restart.
95
+
96
+ ```
97
+ alvin-bot launchd install — Write ~/Library/LaunchAgents/com.alvinbot.app.plist,
98
+ unload any existing instance, launchctl load -w.
99
+ alvin-bot launchd uninstall — Unload and rm the plist.
100
+ alvin-bot launchd status — Plist existence, PID from `launchctl list`,
101
+ tail of ~/.alvin-bot/logs/alvin-bot.{out,err}.log.
102
+ ```
103
+
104
+ Plist details:
105
+ - `KeepAlive` → auto-restart on crash, not on successful exit
106
+ - `RunAtLoad` → starts on login
107
+ - `ThrottleInterval 10` → prevents rapid restart loops
108
+ - `PATH` covers `~/.local/bin`, `/opt/homebrew/bin` (Apple Silicon), `/usr/local/bin` (Intel Homebrew)
109
+ - stdout → `~/.alvin-bot/logs/alvin-bot.out.log`
110
+ - stderr → `~/.alvin-bot/logs/alvin-bot.err.log`
111
+
112
+ macOS users should migrate from `alvin-bot start` (pm2) to `alvin-bot launchd install`. Pm2 still works and remains the Linux/Windows default.
113
+
114
+ ### 🐛 Bug fixes
115
+
116
+ - **`ClaudeSDKProvider.isAvailable()` now actually probes authentication.** The old check only ran `claude --version`, which succeeds whether or not the CLI has a valid OAuth token. A locked-out CLI would be reported as available, and the `Not logged in` response would leak into the chat as a normal assistant message. New behavior: `claude --version` for the binary check, then `claude -p "ping"` to verify auth. If the output matches the "Not logged in" pattern, the provider reports `false` and the registry falls through to the next provider.
117
+
118
+ - **`ClaudeSDKProvider.query()` surfaces `Not logged in` as an error chunk.** Even in code paths where `isAvailable()` returned stale cache, a runtime failure during the stream would emit `Not logged in · Please run /login` as text. The query loop now detects the auth pattern on the first text chunk and yields a typed `error` chunk with a clear "Run `claude login`" message, instead of pretending it's a normal response.
119
+
120
+ - **`/subagents cancel|result <name#N>` now hits the exact entry.** Regression caught during the remote test: asking for `test-ping#2` returned the "Mehrdeutig — welchen meinst du?" ambiguity reply instead of the specific `#2` entry, because `findSubAgentByName` checked base-name siblings before the exact-name match when `ambiguousAsList: true` was set. Explicit `#N` queries now always win.
121
+
122
+ - **Shutdown double-delivery race fixed.** If the bot received SIGTERM while a sub-agent was mid-stream, Telegram saw two messages: a "completed · (empty output)" banner from `runSubAgent.finally()` (because the test generator exited gracefully after the abort), followed by the "cancelled · Bot-Restart" banner from `cancelAllSubAgents`. Fixed with a `delivered: boolean` flag on each `activeAgents` entry — whoever posts first sets it, the other skips.
123
+
124
+ - **`providerKeyMap` alignment in `src/index.ts`.** The pre-flight provider-key warning used `gemini-2.5-flash` as the map key, but the registry registers Google Gemini under `google`. Users who set `PRIMARY_PROVIDER=google` never saw the "GOOGLE_API_KEY missing" warning. Fixed by canonical `google → GOOGLE_API_KEY`; legacy custom-model aliases stay for rollback safety.
125
+
126
+ - **`cron.ts` ai-query triple-notification cleanup.** A single failed ai-query cron job was sending three legacy error messages (`slow-fox: cancelled — cancelled`, `AI-Query Error (slow-fox)`, `Cron Error (slow-fox)`) because the failure path fired `notifyCallback` in the inner `if`, the inner `catch`, and the outer `catch`. The I3 delivery router already posts the cancellation banner for ai-query jobs, so all three legacy notify calls are now skipped and ai-query errors propagate via the outer catch for bookkeeping only. Other job types (reminder, shell, http, message) keep the legacy notify path.
127
+
128
+ - **`/subagents` now shows up in Telegram's command autocomplete.** The grammy handler was registered from v4.0.0 but `setMyCommands` never listed it, so users had to know the exact spelling. Added.
129
+
130
+ ### 📚 Documentation
131
+
132
+ - New English-language handbook at `docs/HANDBOOK.md` — covers installation, architecture, all providers, the sub-agents system, cron jobs, platform adapters, security audit, and the web UI. Written to be readable standalone without cross-referencing the README.
133
+ - README.md updated with a pointer to the handbook and the new `launchd` command.
134
+
135
+ ## [4.5.1] — 2026-04-09
136
+
137
+ ### 🐛 TUI Header Rendering Hotfix
138
+
139
+ **The header was appearing inline in the middle of the conversation after scrolling** — a follow-up bug to the 4.5.0 TUI fix. Reported from a live 4.5.0 Test MacBook session where the header popped up right after a long bot response.
140
+
141
+ **Root cause**: `redrawHeader()` in 4.5.0 used `\x1b[H` (move to top-left) + `\x1b[s`/`\x1b[u` (save/restore cursor) to update the header in place when cost/model/target changed. But `\x1b[H` resolves to the **current viewport top**, not the document top — and once the terminal has scrolled past the original header, the "viewport top" is somewhere in the middle of the conversation. So the header got re-rendered inline in the middle of the bot's output.
142
+
143
+ **Fix**: removed all `redrawHeader()` calls from mid-session code paths:
144
+ - `ws.on("open")` (connect): no redraw, header was already drawn at startup
145
+ - `ws.on("close")` (disconnect): no redraw, just the error message
146
+ - `case "done"` (after each bot response): no redraw (this was the primary bug site — it fired after every message)
147
+ - `case "model"` (model switch): no redraw, just a success info line
148
+ - `case "target tui|telegram"` (target switch): no redraw, just an info line
149
+ - `process.stdout.on("resize")`: no redraw, just re-renders the prompt line
150
+
151
+ The only remaining `redrawHeader()` call is inside `/clear`, which calls `console.clear()` first to wipe the whole buffer — the only context where an in-place redraw is safe.
152
+
153
+ The trade-off: the header no longer reflects live cost/model/target updates mid-session. You'll see the up-to-date values after the next `/clear` or on the next TUI start. In exchange, the conversation flow stays clean. A future release could add a proper status-line region using terminal scrolling regions if this becomes annoying.
154
+
5
155
  ## [4.5.0] — 2026-04-09
6
156
 
7
157
  ### 🐛 TUI Bug Fixes (critical — the old TUI was effectively unusable)
package/README.md CHANGED
@@ -109,13 +109,29 @@ alvin-bot start
109
109
 
110
110
  That's it. The setup wizard validates everything:
111
111
  - ✅ Tests your AI provider key
112
- - ✅ Verifies your Telegram bot token
112
+ - ✅ Verifies your Telegram bot token
113
113
  - ✅ Confirms the setup works before you start
114
114
 
115
115
  **Requires:** Node.js 18+ ([nodejs.org](https://nodejs.org)) · Telegram bot token ([@BotFather](https://t.me/BotFather)) · Your Telegram user ID ([@userinfobot](https://t.me/userinfobot))
116
116
 
117
117
  Free AI providers available — no credit card needed.
118
118
 
119
+ ### macOS: use `launchd` instead of pm2 (recommended)
120
+
121
+ If you're on macOS and using Claude Code (Max subscription) as your provider, run the bot as a **LaunchAgent** — it inherits the GUI login session so the macOS Keychain stays unlocked and the Claude OAuth token just works without any manual `security unlock-keychain` dance:
122
+
123
+ ```bash
124
+ alvin-bot launchd install # writes ~/Library/LaunchAgents/com.alvinbot.app.plist and starts the agent
125
+ alvin-bot launchd status # show PID + recent stdout/stderr logs
126
+ alvin-bot launchd uninstall # unload + remove the plist
127
+ ```
128
+
129
+ Pm2 still works and remains the default on Linux/Windows — but on macOS with Claude Code, `launchd` is the only path that reliably keeps Keychain access over restarts.
130
+
131
+ ### 📖 Handbook
132
+
133
+ For a full walkthrough of everything Alvin Bot can do — providers, sub-agents, cron jobs, plugins, MCP, security audit, web UI — read **[`docs/HANDBOOK.md`](docs/HANDBOOK.md)**.
134
+
119
135
  ### AI Providers
120
136
 
121
137
  | Provider | Cost | Best for |
@@ -436,7 +452,14 @@ alvin-bot tui # Terminal chat UI ✨
436
452
  alvin-bot chat # Alias for tui
437
453
  alvin-bot doctor # Health check
438
454
  alvin-bot update # Pull latest & rebuild
439
- alvin-bot start # Start the bot
455
+ alvin-bot start # Start the bot (background via pm2)
456
+ alvin-bot start -f # Start in foreground
457
+ alvin-bot stop # Stop the bot
458
+ alvin-bot launchd install # macOS only: install as LaunchAgent
459
+ alvin-bot launchd status # macOS only: show LaunchAgent state
460
+ alvin-bot launchd uninstall # macOS only: remove LaunchAgent
461
+ alvin-bot audit # Security health check
462
+ alvin-bot search # Search assets/memories/skills
440
463
  alvin-bot version # Show version
441
464
  ```
442
465
 
Binary file
package/bin/cli.js CHANGED
@@ -1153,6 +1153,232 @@ async function version() {
1153
1153
  }
1154
1154
  }
1155
1155
 
1156
+ // ── LaunchAgent helpers (macOS only) ────────────────────────────────────────
1157
+
1158
+ /**
1159
+ * Render the launchd plist that runs `node dist/index.js` as a per-user
1160
+ * agent. Inherits the GUI login session so the macOS Keychain is
1161
+ * automatically unlocked — which means Claude Code OAuth tokens (Max
1162
+ * subscription) work without a manual `security unlock-keychain`.
1163
+ */
1164
+ function renderLaunchdPlist({ label, nodePath, entryPoint, cwd, home, logDir }) {
1165
+ // PATH covers both Apple Silicon and Intel Homebrew plus the legacy
1166
+ // user-local claude binary path.
1167
+ const pathValue = `${home}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`;
1168
+ return `<?xml version="1.0" encoding="UTF-8"?>
1169
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1170
+ <plist version="1.0">
1171
+ <dict>
1172
+ <key>Label</key>
1173
+ <string>${label}</string>
1174
+
1175
+ <key>ProgramArguments</key>
1176
+ <array>
1177
+ <string>${nodePath}</string>
1178
+ <string>${entryPoint}</string>
1179
+ </array>
1180
+
1181
+ <key>WorkingDirectory</key>
1182
+ <string>${cwd}</string>
1183
+
1184
+ <key>RunAtLoad</key>
1185
+ <true/>
1186
+
1187
+ <key>KeepAlive</key>
1188
+ <dict>
1189
+ <key>SuccessfulExit</key>
1190
+ <false/>
1191
+ <key>Crashed</key>
1192
+ <true/>
1193
+ </dict>
1194
+
1195
+ <key>ThrottleInterval</key>
1196
+ <integer>10</integer>
1197
+
1198
+ <key>StandardOutPath</key>
1199
+ <string>${logDir}/alvin-bot.out.log</string>
1200
+
1201
+ <key>StandardErrorPath</key>
1202
+ <string>${logDir}/alvin-bot.err.log</string>
1203
+
1204
+ <key>EnvironmentVariables</key>
1205
+ <dict>
1206
+ <key>PATH</key>
1207
+ <string>${pathValue}</string>
1208
+ <key>HOME</key>
1209
+ <string>${home}</string>
1210
+ <key>NODE_ENV</key>
1211
+ <string>production</string>
1212
+ </dict>
1213
+ </dict>
1214
+ </plist>
1215
+ `;
1216
+ }
1217
+
1218
+ /**
1219
+ * Common paths + label used by all three launchd subcommands.
1220
+ */
1221
+ function launchdPaths() {
1222
+ const home = homedir();
1223
+ const label = "com.alvinbot.app";
1224
+ const plistPath = join(home, "Library", "LaunchAgents", `${label}.plist`);
1225
+ const logDir = join(home, ".alvin-bot", "logs");
1226
+ // dist/index.js lives two levels up from bin/cli.js, then dist/
1227
+ const entryPoint = resolve(join(import.meta.dirname, "..", "dist", "index.js"));
1228
+ const cwd = resolve(join(import.meta.dirname, ".."));
1229
+ const nodePath = process.execPath;
1230
+ return { home, label, plistPath, logDir, entryPoint, cwd, nodePath };
1231
+ }
1232
+
1233
+ async function launchdInstall() {
1234
+ if (process.platform !== "darwin") {
1235
+ console.log("❌ alvin-bot launchd is macOS-only.");
1236
+ console.log(" Linux users: create a systemd user unit for dist/index.js.");
1237
+ console.log(" Windows users: use Task Scheduler or NSSM.");
1238
+ process.exit(1);
1239
+ }
1240
+
1241
+ const { home, label, plistPath, logDir, entryPoint, cwd, nodePath } = launchdPaths();
1242
+
1243
+ // Sanity-check that dist/ is built
1244
+ if (!existsSync(entryPoint)) {
1245
+ console.log(`❌ Build not found at ${entryPoint}`);
1246
+ console.log(" Run 'npm run build' first.");
1247
+ process.exit(1);
1248
+ }
1249
+
1250
+ // Ensure the LaunchAgents dir and log dir exist
1251
+ mkdirSync(join(home, "Library", "LaunchAgents"), { recursive: true });
1252
+ mkdirSync(logDir, { recursive: true });
1253
+
1254
+ // Render and write the plist
1255
+ const plist = renderLaunchdPlist({ label, nodePath, entryPoint, cwd, home, logDir });
1256
+ writeFileSync(plistPath, plist, { mode: 0o644 });
1257
+ console.log(`📝 Wrote ${plistPath}`);
1258
+
1259
+ // Unload any previous instance (best-effort)
1260
+ try {
1261
+ execSync(`launchctl unload -w "${plistPath}"`, { stdio: "pipe" });
1262
+ } catch { /* not loaded yet — fine */ }
1263
+
1264
+ // Stop any nohup'd bot that might still be running
1265
+ try {
1266
+ execSync(`pkill -TERM -f 'node.*dist/index.js' || true`, { stdio: "pipe" });
1267
+ } catch { /* nothing to kill */ }
1268
+
1269
+ // Load fresh
1270
+ try {
1271
+ execSync(`launchctl load -w "${plistPath}"`, { stdio: "inherit" });
1272
+ } catch (err) {
1273
+ console.log(`❌ launchctl load failed: ${err.message}`);
1274
+ console.log(" Try manually: launchctl load -w " + plistPath);
1275
+ process.exit(1);
1276
+ }
1277
+
1278
+ console.log("");
1279
+ console.log("✅ alvin-bot is now a launchd user agent.");
1280
+ console.log(` Label: ${label}`);
1281
+ console.log(` Logs: ${logDir}/alvin-bot.out.log`);
1282
+ console.log(` Errors: ${logDir}/alvin-bot.err.log`);
1283
+ console.log("");
1284
+ console.log(" Status: alvin-bot launchd status");
1285
+ console.log(" Stop: alvin-bot launchd uninstall");
1286
+ console.log(" Restart: launchctl kickstart -k gui/$UID/" + label);
1287
+ console.log("");
1288
+ console.log(" Because launchd runs the bot inside your GUI login session,");
1289
+ console.log(" the macOS Keychain is automatically unlocked — Claude Code");
1290
+ console.log(" OAuth tokens (Max subscription) just work, no SSH keychain");
1291
+ console.log(" dance needed anymore.");
1292
+ process.exit(0);
1293
+ }
1294
+
1295
+ async function launchdUninstall() {
1296
+ if (process.platform !== "darwin") {
1297
+ console.log("❌ alvin-bot launchd is macOS-only.");
1298
+ process.exit(1);
1299
+ }
1300
+ const { plistPath, label } = launchdPaths();
1301
+ if (!existsSync(plistPath)) {
1302
+ console.log(`⚠️ No LaunchAgent plist at ${plistPath}`);
1303
+ console.log(" Nothing to uninstall.");
1304
+ process.exit(0);
1305
+ }
1306
+
1307
+ try {
1308
+ execSync(`launchctl unload -w "${plistPath}"`, { stdio: "inherit" });
1309
+ console.log(`✅ Unloaded ${label}`);
1310
+ } catch (err) {
1311
+ console.log(`⚠️ Unload reported an error (may not have been running): ${err.message}`);
1312
+ }
1313
+
1314
+ try {
1315
+ execSync(`rm -f "${plistPath}"`);
1316
+ console.log(`🗑 Removed ${plistPath}`);
1317
+ } catch (err) {
1318
+ console.log(`⚠️ Could not remove plist: ${err.message}`);
1319
+ }
1320
+
1321
+ console.log("");
1322
+ console.log("✅ alvin-bot is no longer a launchd user agent.");
1323
+ process.exit(0);
1324
+ }
1325
+
1326
+ async function launchdStatus() {
1327
+ if (process.platform !== "darwin") {
1328
+ console.log("❌ alvin-bot launchd is macOS-only.");
1329
+ process.exit(1);
1330
+ }
1331
+ const { plistPath, label, logDir } = launchdPaths();
1332
+
1333
+ console.log(`📋 alvin-bot launchd status`);
1334
+ console.log("");
1335
+ console.log(`Label: ${label}`);
1336
+ console.log(`Plist: ${plistPath}`);
1337
+ console.log(`Plist exists: ${existsSync(plistPath) ? "yes" : "no"}`);
1338
+ console.log("");
1339
+
1340
+ try {
1341
+ const out = execSync(`launchctl list | grep ${label} || true`, { encoding: "utf-8" });
1342
+ if (out.trim()) {
1343
+ // Format: <PID>\t<ExitCode>\t<Label>
1344
+ const parts = out.trim().split(/\s+/);
1345
+ const pid = parts[0];
1346
+ const exitCode = parts[1];
1347
+ const isRunning = pid !== "-" && pid !== "0";
1348
+ console.log(`Running: ${isRunning ? "✅ yes (PID " + pid + ")" : "❌ no (last exit " + exitCode + ")"}`);
1349
+ } else {
1350
+ console.log(`Running: ❌ not loaded`);
1351
+ }
1352
+ } catch {
1353
+ console.log(`Running: ❌ unknown (launchctl list failed)`);
1354
+ }
1355
+
1356
+ console.log("");
1357
+ console.log(`Log dir: ${logDir}`);
1358
+ const outLog = join(logDir, "alvin-bot.out.log");
1359
+ const errLog = join(logDir, "alvin-bot.err.log");
1360
+ if (existsSync(outLog)) {
1361
+ try {
1362
+ const tail = execSync(`tail -n 5 "${outLog}"`, { encoding: "utf-8" });
1363
+ console.log("");
1364
+ console.log("── Last 5 lines of stdout ──");
1365
+ console.log(tail.trimEnd());
1366
+ } catch { /* ignore */ }
1367
+ }
1368
+ if (existsSync(errLog)) {
1369
+ try {
1370
+ const tail = execSync(`tail -n 5 "${errLog}"`, { encoding: "utf-8" });
1371
+ const trimmed = tail.trimEnd();
1372
+ if (trimmed) {
1373
+ console.log("");
1374
+ console.log("── Last 5 lines of stderr ──");
1375
+ console.log(trimmed);
1376
+ }
1377
+ } catch { /* ignore */ }
1378
+ }
1379
+ process.exit(0);
1380
+ }
1381
+
1156
1382
  // ── CLI Router ──────────────────────────────────────────────────────────────
1157
1383
 
1158
1384
  const cmd = process.argv[2];
@@ -1211,6 +1437,25 @@ switch (cmd) {
1211
1437
  }
1212
1438
  process.exit(0);
1213
1439
  }
1440
+ case "launchd": {
1441
+ const sub = process.argv[3];
1442
+ if (sub === "install") {
1443
+ await launchdInstall();
1444
+ } else if (sub === "uninstall") {
1445
+ await launchdUninstall();
1446
+ } else if (sub === "status") {
1447
+ await launchdStatus();
1448
+ } else {
1449
+ console.log("Usage: alvin-bot launchd <install|uninstall|status>");
1450
+ console.log("");
1451
+ console.log(" install — Install as a macOS launchd user agent.");
1452
+ console.log(" Runs on login, keychain auto-unlocked.");
1453
+ console.log(" uninstall — Unload and remove the LaunchAgent plist.");
1454
+ console.log(" status — Show current launchd state + recent logs.");
1455
+ process.exit(1);
1456
+ }
1457
+ break;
1458
+ }
1214
1459
  case "tui":
1215
1460
  case "chat":
1216
1461
  import("../dist/tui/index.js").then(m => m.startTUI()).catch(console.error);
@@ -1254,6 +1499,7 @@ ${t("cli.commands")}
1254
1499
  start ${t("cli.startDesc")} (background via PM2)
1255
1500
  start -f Start in foreground (for debugging)
1256
1501
  stop Stop the bot
1502
+ launchd macOS only: install/uninstall/status as launchd user agent
1257
1503
  version ${t("cli.versionDesc")}
1258
1504
 
1259
1505
  ${t("cli.example")}