alvin-bot 4.5.1 → 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.
- package/CHANGELOG.md +130 -0
- package/README.md +25 -2
- package/alvin-bot-4.5.1.tgz +0 -0
- package/bin/cli.js +246 -0
- package/dist/handlers/commands.js +461 -63
- package/dist/handlers/message.js +209 -14
- package/dist/i18n.js +470 -13
- package/dist/index.js +44 -5
- package/dist/providers/claude-sdk-provider.js +106 -14
- package/dist/providers/ollama-provider.js +32 -0
- package/dist/providers/openai-compatible.js +10 -1
- package/dist/providers/registry.js +112 -17
- package/dist/providers/types.js +25 -3
- package/dist/services/compaction.js +2 -0
- package/dist/services/cron.js +53 -42
- package/dist/services/heartbeat.js +41 -7
- package/dist/services/language-detect.js +12 -2
- package/dist/services/ollama-manager.js +339 -0
- package/dist/services/personality.js +20 -14
- package/dist/services/session.js +21 -3
- package/dist/services/subagent-delivery.js +111 -0
- package/dist/services/subagents.js +341 -27
- package/dist/services/telegram.js +28 -1
- package/dist/services/updater.js +158 -0
- package/dist/services/usage-tracker.js +11 -4
- package/dist/services/users.js +2 -1
- package/docs/HANDBOOK.md +819 -0
- package/package.json +7 -2
- package/test/claude-sdk-provider.test.ts +69 -0
- package/test/i18n.test.ts +108 -0
- package/test/registry.test.ts +201 -0
- package/test/subagent-delivery.test.ts +169 -0
- package/test/subagents-commands.test.ts +64 -0
- package/test/subagents-config.test.ts +108 -0
- package/test/subagents-depth.test.ts +58 -0
- package/test/subagents-inheritance.test.ts +67 -0
- package/test/subagents-name-resolver.test.ts +122 -0
- package/test/subagents-priority-reject.test.ts +60 -0
- package/test/subagents-shutdown.test.ts +126 -0
- package/test/subagents-toolset.test.ts +51 -0
- package/vitest.config.ts +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,136 @@
|
|
|
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
|
+
|
|
5
135
|
## [4.5.1] — 2026-04-09
|
|
6
136
|
|
|
7
137
|
### 🐛 TUI Header Rendering Hotfix
|
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")}
|