chapterhouse 0.1.1 → 0.2.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/README.md +79 -12
- package/dist/api/errors.js +5 -3
- package/dist/api/errors.test.js +12 -21
- package/dist/api/server.js +67 -17
- package/dist/cli.js +111 -18
- package/dist/copilot/agents.js +9 -7
- package/dist/copilot/classifier.js +3 -1
- package/dist/copilot/orchestrator.js +64 -31
- package/dist/copilot/orchestrator.test.js +107 -1
- package/dist/copilot/router.js +4 -2
- package/dist/copilot/tools.js +7 -5
- package/dist/daemon-install.js +368 -0
- package/dist/daemon-install.test.js +98 -0
- package/dist/daemon.js +35 -33
- package/dist/squad/discovery.test.js +61 -0
- package/dist/store/db.js +42 -0
- package/dist/store/db.test.js +88 -0
- package/dist/update.js +162 -28
- package/dist/update.test.js +84 -5
- package/dist/util/logger.js +41 -0
- package/dist/util/logger.test.js +53 -0
- package/dist/wiki/migrate.js +4 -2
- package/dist/wiki/seed-team-wiki.js +4 -2
- package/package.json +3 -2
- package/web/dist/assets/{index-DAg9IrpO.js → index-Bgs6Mze7.js} +59 -59
- package/web/dist/assets/index-Bgs6Mze7.js.map +1 -0
- package/web/dist/assets/index-CxeGtVlE.css +10 -0
- package/web/dist/chapterhouse-icon.svg +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-D-e7K-fT.css +0 -10
- package/web/dist/assets/index-DAg9IrpO.js.map +0 -1
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent user daemon management for Chapterhouse.
|
|
3
|
+
* Supports macOS (launchd) and Linux (systemd --user).
|
|
4
|
+
* Generates unit files at install time using the resolved binary path.
|
|
5
|
+
*/
|
|
6
|
+
import { execSync, execFileSync } from "child_process";
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync } from "fs";
|
|
8
|
+
import { join, dirname } from "path";
|
|
9
|
+
import { homedir, platform } from "os";
|
|
10
|
+
/** The launchd label / systemd unit name. */
|
|
11
|
+
export const DAEMON_LABEL = "com.bketelsen.chapterhouse";
|
|
12
|
+
export const DAEMON_UNIT_NAME = "chapterhouse";
|
|
13
|
+
// ─── Path helpers ────────────────────────────────────────────────────────────
|
|
14
|
+
export function getPlatform() {
|
|
15
|
+
const p = platform();
|
|
16
|
+
if (p === "darwin")
|
|
17
|
+
return "darwin";
|
|
18
|
+
if (p === "linux")
|
|
19
|
+
return "linux";
|
|
20
|
+
if (p === "win32")
|
|
21
|
+
return "windows";
|
|
22
|
+
return "unsupported";
|
|
23
|
+
}
|
|
24
|
+
/** ~/Library/LaunchAgents/com.bketelsen.chapterhouse.plist */
|
|
25
|
+
export function getPlistPath() {
|
|
26
|
+
return join(homedir(), "Library", "LaunchAgents", `${DAEMON_LABEL}.plist`);
|
|
27
|
+
}
|
|
28
|
+
/** ~/.config/systemd/user/chapterhouse.service */
|
|
29
|
+
export function getSystemdUnitPath() {
|
|
30
|
+
return join(homedir(), ".config", "systemd", "user", `${DAEMON_UNIT_NAME}.service`);
|
|
31
|
+
}
|
|
32
|
+
/** ~/Library/Logs/chapterhouse.log */
|
|
33
|
+
export function getMacOSLogPath() {
|
|
34
|
+
return join(homedir(), "Library", "Logs", "chapterhouse.log");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the absolute path of the `chapterhouse` binary.
|
|
38
|
+
* Falls back to "chapterhouse" (on $PATH) if resolution fails.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveChapterhouseBin() {
|
|
41
|
+
try {
|
|
42
|
+
const resolved = execSync("which chapterhouse", { encoding: "utf-8" }).trim();
|
|
43
|
+
if (resolved)
|
|
44
|
+
return resolved;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// not on PATH — fall through
|
|
48
|
+
}
|
|
49
|
+
// Fallback: use the path of the current Node process argv[1]
|
|
50
|
+
// (works when running as: node dist/cli.js OR /usr/bin/chapterhouse)
|
|
51
|
+
const argv1 = process.argv[1];
|
|
52
|
+
if (argv1 && !argv1.endsWith("cli.js"))
|
|
53
|
+
return argv1;
|
|
54
|
+
return "chapterhouse";
|
|
55
|
+
}
|
|
56
|
+
/** Generate the launchd plist XML string. */
|
|
57
|
+
export function generatePlist(options) {
|
|
58
|
+
const label = options.label ?? DAEMON_LABEL;
|
|
59
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
60
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
61
|
+
<plist version="1.0">
|
|
62
|
+
<dict>
|
|
63
|
+
<key>Label</key>
|
|
64
|
+
<string>${label}</string>
|
|
65
|
+
<key>ProgramArguments</key>
|
|
66
|
+
<array>
|
|
67
|
+
<string>${options.binPath}</string>
|
|
68
|
+
<string>start</string>
|
|
69
|
+
</array>
|
|
70
|
+
<key>RunAtLoad</key>
|
|
71
|
+
<true/>
|
|
72
|
+
<key>KeepAlive</key>
|
|
73
|
+
<true/>
|
|
74
|
+
<key>StandardOutPath</key>
|
|
75
|
+
<string>${options.logPath}</string>
|
|
76
|
+
<key>StandardErrorPath</key>
|
|
77
|
+
<string>${options.logPath}</string>
|
|
78
|
+
<key>EnvironmentVariables</key>
|
|
79
|
+
<dict>
|
|
80
|
+
<key>PATH</key>
|
|
81
|
+
<string>${dirname(options.binPath)}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
|
82
|
+
</dict>
|
|
83
|
+
</dict>
|
|
84
|
+
</plist>
|
|
85
|
+
`;
|
|
86
|
+
}
|
|
87
|
+
/** Generate the systemd user unit file string. */
|
|
88
|
+
export function generateSystemdUnit(options) {
|
|
89
|
+
const description = options.description ?? "Chapterhouse AI assistant daemon";
|
|
90
|
+
const pathDir = dirname(options.binPath);
|
|
91
|
+
return `[Unit]
|
|
92
|
+
Description=${description}
|
|
93
|
+
After=network.target
|
|
94
|
+
|
|
95
|
+
[Service]
|
|
96
|
+
Type=simple
|
|
97
|
+
ExecStart=${options.binPath} start
|
|
98
|
+
Restart=on-failure
|
|
99
|
+
RestartSec=5s
|
|
100
|
+
Environment="PATH=${pathDir}:/usr/local/bin:/usr/bin:/bin"
|
|
101
|
+
|
|
102
|
+
[Install]
|
|
103
|
+
WantedBy=default.target
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
// ─── Install / uninstall ──────────────────────────────────────────────────────
|
|
107
|
+
export async function install() {
|
|
108
|
+
const plat = getPlatform();
|
|
109
|
+
if (plat === "windows") {
|
|
110
|
+
console.log("⚠ Windows daemon install is not yet supported.");
|
|
111
|
+
console.log(" You can run 'chapterhouse start' manually, or use Task Scheduler.");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (plat === "unsupported") {
|
|
115
|
+
console.error("❌ Unsupported platform. Daemon install is only supported on macOS and Linux.");
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
const binPath = resolveChapterhouseBin();
|
|
119
|
+
if (plat === "darwin") {
|
|
120
|
+
const plistPath = getPlistPath();
|
|
121
|
+
const logPath = getMacOSLogPath();
|
|
122
|
+
// Ensure ~/Library/Logs/ exists (it should, but be safe)
|
|
123
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
124
|
+
mkdirSync(dirname(plistPath), { recursive: true });
|
|
125
|
+
const content = generatePlist({ binPath, logPath });
|
|
126
|
+
writeFileSync(plistPath, content, "utf-8");
|
|
127
|
+
console.log(`✅ Wrote ${plistPath}`);
|
|
128
|
+
try {
|
|
129
|
+
execFileSync("launchctl", ["load", "-w", plistPath], { stdio: "inherit" });
|
|
130
|
+
console.log("✅ launchd service loaded and enabled.");
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
console.error("⚠ Could not load the service with launchctl:", err instanceof Error ? err.message : err);
|
|
134
|
+
console.log(` Run manually: launchctl load -w ${plistPath}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
// Linux
|
|
139
|
+
const unitPath = getSystemdUnitPath();
|
|
140
|
+
mkdirSync(dirname(unitPath), { recursive: true });
|
|
141
|
+
const content = generateSystemdUnit({ binPath });
|
|
142
|
+
writeFileSync(unitPath, content, "utf-8");
|
|
143
|
+
console.log(`✅ Wrote ${unitPath}`);
|
|
144
|
+
try {
|
|
145
|
+
execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
|
|
146
|
+
execFileSync("systemctl", ["--user", "enable", "--now", DAEMON_UNIT_NAME], { stdio: "inherit" });
|
|
147
|
+
console.log("✅ systemd user service enabled and started.");
|
|
148
|
+
console.log(" Logs: journalctl --user -u chapterhouse -f");
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
console.error("⚠ Could not enable the service with systemctl:", err instanceof Error ? err.message : err);
|
|
152
|
+
console.log(` Run manually: systemctl --user daemon-reload && systemctl --user enable --now ${DAEMON_UNIT_NAME}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
export async function uninstall() {
|
|
157
|
+
const plat = getPlatform();
|
|
158
|
+
if (plat === "windows") {
|
|
159
|
+
console.log("⚠ Windows daemon uninstall is not yet supported.");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (plat === "unsupported") {
|
|
163
|
+
console.error("❌ Unsupported platform.");
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
if (plat === "darwin") {
|
|
167
|
+
const plistPath = getPlistPath();
|
|
168
|
+
if (!existsSync(plistPath)) {
|
|
169
|
+
console.log("ℹ No launchd plist found. Nothing to uninstall.");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
execFileSync("launchctl", ["unload", "-w", plistPath], { stdio: "inherit" });
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// service may already be stopped
|
|
177
|
+
}
|
|
178
|
+
rmSync(plistPath);
|
|
179
|
+
console.log(`✅ Removed ${plistPath}`);
|
|
180
|
+
console.log("✅ Chapterhouse launchd service uninstalled.");
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
// Linux
|
|
184
|
+
const unitPath = getSystemdUnitPath();
|
|
185
|
+
try {
|
|
186
|
+
execFileSync("systemctl", ["--user", "disable", "--now", DAEMON_UNIT_NAME], { stdio: "inherit" });
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// service may already be stopped
|
|
190
|
+
}
|
|
191
|
+
if (existsSync(unitPath)) {
|
|
192
|
+
rmSync(unitPath);
|
|
193
|
+
console.log(`✅ Removed ${unitPath}`);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
console.log("ℹ No systemd unit file found.");
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// best effort
|
|
203
|
+
}
|
|
204
|
+
console.log("✅ Chapterhouse systemd user service uninstalled.");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// ─── Service control ─────────────────────────────────────────────────────────
|
|
208
|
+
function launchctlServiceAction(action) {
|
|
209
|
+
// launchctl start/stop use the label, not the plist path
|
|
210
|
+
if (action === "restart") {
|
|
211
|
+
try {
|
|
212
|
+
execFileSync("launchctl", ["stop", DAEMON_LABEL], { stdio: "inherit" });
|
|
213
|
+
}
|
|
214
|
+
catch { /* ignore */ }
|
|
215
|
+
execFileSync("launchctl", ["start", DAEMON_LABEL], { stdio: "inherit" });
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
execFileSync("launchctl", [action, DAEMON_LABEL], { stdio: "inherit" });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function systemctlUserAction(action) {
|
|
222
|
+
execFileSync("systemctl", ["--user", action, DAEMON_UNIT_NAME], { stdio: "inherit" });
|
|
223
|
+
}
|
|
224
|
+
export async function start() {
|
|
225
|
+
const plat = getPlatform();
|
|
226
|
+
checkPlatformOrExit(plat);
|
|
227
|
+
if (plat === "darwin")
|
|
228
|
+
launchctlServiceAction("start");
|
|
229
|
+
else
|
|
230
|
+
systemctlUserAction("start");
|
|
231
|
+
console.log("✅ Chapterhouse daemon started.");
|
|
232
|
+
}
|
|
233
|
+
export async function stop() {
|
|
234
|
+
const plat = getPlatform();
|
|
235
|
+
checkPlatformOrExit(plat);
|
|
236
|
+
if (plat === "darwin")
|
|
237
|
+
launchctlServiceAction("stop");
|
|
238
|
+
else
|
|
239
|
+
systemctlUserAction("stop");
|
|
240
|
+
console.log("✅ Chapterhouse daemon stopped.");
|
|
241
|
+
}
|
|
242
|
+
export async function restart() {
|
|
243
|
+
const plat = getPlatform();
|
|
244
|
+
checkPlatformOrExit(plat);
|
|
245
|
+
if (plat === "darwin")
|
|
246
|
+
launchctlServiceAction("restart");
|
|
247
|
+
else
|
|
248
|
+
systemctlUserAction("restart");
|
|
249
|
+
console.log("✅ Chapterhouse daemon restarted.");
|
|
250
|
+
}
|
|
251
|
+
// ─── Status ───────────────────────────────────────────────────────────────────
|
|
252
|
+
export async function status() {
|
|
253
|
+
const plat = getPlatform();
|
|
254
|
+
if (plat === "windows") {
|
|
255
|
+
console.log("⚠ Windows daemon status is not yet supported.");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (plat === "unsupported") {
|
|
259
|
+
console.error("❌ Unsupported platform.");
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
if (plat === "darwin") {
|
|
263
|
+
const plistPath = getPlistPath();
|
|
264
|
+
const installed = existsSync(plistPath);
|
|
265
|
+
console.log(`Installed: ${installed ? "✅ yes" : "❌ no"} ${installed ? `(${plistPath})` : ""}`);
|
|
266
|
+
if (!installed)
|
|
267
|
+
return;
|
|
268
|
+
try {
|
|
269
|
+
const out = execSync(`launchctl list | grep ${DAEMON_LABEL}`, { encoding: "utf-8" }).trim();
|
|
270
|
+
if (out) {
|
|
271
|
+
const [pid, lastExit] = out.split(/\s+/);
|
|
272
|
+
const running = pid !== "-" && pid !== "0";
|
|
273
|
+
console.log(`Running: ${running ? `✅ yes (PID ${pid})` : "❌ no"}`);
|
|
274
|
+
console.log(`Last exit: ${lastExit || "—"}`);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
console.log("Running: ❌ no (not loaded)");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
console.log("Running: ❌ no (not loaded)");
|
|
282
|
+
}
|
|
283
|
+
console.log(`Log: ${getMacOSLogPath()}`);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
const unitPath = getSystemdUnitPath();
|
|
287
|
+
const installed = existsSync(unitPath);
|
|
288
|
+
console.log(`Installed: ${installed ? "✅ yes" : "❌ no"} ${installed ? `(${unitPath})` : ""}`);
|
|
289
|
+
if (!installed)
|
|
290
|
+
return;
|
|
291
|
+
try {
|
|
292
|
+
execFileSync("systemctl", ["--user", "status", DAEMON_UNIT_NAME], { stdio: "inherit" });
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
// systemctl status exits non-zero when stopped; output already printed
|
|
296
|
+
}
|
|
297
|
+
console.log("Logs: journalctl --user -u chapterhouse");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// ─── Logs ─────────────────────────────────────────────────────────────────────
|
|
301
|
+
export async function logs() {
|
|
302
|
+
const plat = getPlatform();
|
|
303
|
+
if (plat === "windows") {
|
|
304
|
+
console.log("⚠ Windows daemon logs are not yet supported.");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (plat === "unsupported") {
|
|
308
|
+
console.error("❌ Unsupported platform.");
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
if (plat === "darwin") {
|
|
312
|
+
const logPath = getMacOSLogPath();
|
|
313
|
+
if (!existsSync(logPath)) {
|
|
314
|
+
console.log(`No log file found at ${logPath}`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
execFileSync("tail", ["-f", logPath], { stdio: "inherit" });
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
// user pressed Ctrl+C — expected
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
try {
|
|
326
|
+
execFileSync("journalctl", ["--user", "-u", DAEMON_UNIT_NAME, "-f", "--no-pager"], { stdio: "inherit" });
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
// user pressed Ctrl+C — expected
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
334
|
+
function checkPlatformOrExit(plat) {
|
|
335
|
+
if (plat === "windows") {
|
|
336
|
+
console.log("⚠ Windows daemon control is not yet supported.");
|
|
337
|
+
process.exit(0);
|
|
338
|
+
}
|
|
339
|
+
if (plat === "unsupported") {
|
|
340
|
+
console.error("❌ Unsupported platform.");
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
export function printDaemonHelp() {
|
|
345
|
+
console.log(`
|
|
346
|
+
chapterhouse daemon — manage the Chapterhouse persistent background service
|
|
347
|
+
|
|
348
|
+
Usage:
|
|
349
|
+
chapterhouse daemon <subcommand>
|
|
350
|
+
|
|
351
|
+
Subcommands:
|
|
352
|
+
install Write the launchd plist (macOS) or systemd unit (Linux), enable + start
|
|
353
|
+
uninstall Stop, disable, and remove the unit file
|
|
354
|
+
start Start the daemon service (without re-enabling)
|
|
355
|
+
stop Stop the daemon service
|
|
356
|
+
restart Restart the daemon service
|
|
357
|
+
status Show installed/running state and log path
|
|
358
|
+
logs Tail live daemon logs (Ctrl+C to exit)
|
|
359
|
+
|
|
360
|
+
Platform mapping:
|
|
361
|
+
macOS ~/Library/LaunchAgents/com.bketelsen.chapterhouse.plist
|
|
362
|
+
Logs: ~/Library/Logs/chapterhouse.log
|
|
363
|
+
Linux ~/.config/systemd/user/chapterhouse.service
|
|
364
|
+
Logs: journalctl --user -u chapterhouse
|
|
365
|
+
Windows (not yet supported)
|
|
366
|
+
`.trim());
|
|
367
|
+
}
|
|
368
|
+
//# sourceMappingURL=daemon-install.js.map
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { DAEMON_LABEL, DAEMON_UNIT_NAME, getPlistPath, getSystemdUnitPath, getMacOSLogPath, generatePlist, generateSystemdUnit, } from "./daemon-install.js";
|
|
6
|
+
// ─── Path helpers ─────────────────────────────────────────────────────────────
|
|
7
|
+
test("getPlistPath returns expected LaunchAgents path", () => {
|
|
8
|
+
assert.equal(getPlistPath(), join(homedir(), "Library", "LaunchAgents", "com.bketelsen.chapterhouse.plist"));
|
|
9
|
+
});
|
|
10
|
+
test("getSystemdUnitPath returns expected systemd user path", () => {
|
|
11
|
+
assert.equal(getSystemdUnitPath(), join(homedir(), ".config", "systemd", "user", "chapterhouse.service"));
|
|
12
|
+
});
|
|
13
|
+
test("getMacOSLogPath returns expected log path", () => {
|
|
14
|
+
assert.equal(getMacOSLogPath(), join(homedir(), "Library", "Logs", "chapterhouse.log"));
|
|
15
|
+
});
|
|
16
|
+
// ─── Plist content generation ─────────────────────────────────────────────────
|
|
17
|
+
const MOCK_BIN_DARWIN = "/usr/local/bin/chapterhouse";
|
|
18
|
+
const MOCK_LOG_DARWIN = `${homedir()}/Library/Logs/chapterhouse.log`;
|
|
19
|
+
test("generatePlist contains the correct label", () => {
|
|
20
|
+
const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN });
|
|
21
|
+
assert.ok(plist.includes(`<string>${DAEMON_LABEL}</string>`), "plist should contain daemon label");
|
|
22
|
+
});
|
|
23
|
+
test("generatePlist embeds the binary path in ProgramArguments", () => {
|
|
24
|
+
const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN });
|
|
25
|
+
assert.ok(plist.includes(`<string>${MOCK_BIN_DARWIN}</string>`), "plist should embed binary path");
|
|
26
|
+
assert.ok(plist.includes("<string>start</string>"), "plist should pass 'start' argument");
|
|
27
|
+
});
|
|
28
|
+
test("generatePlist sets RunAtLoad and KeepAlive to true", () => {
|
|
29
|
+
const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN });
|
|
30
|
+
// RunAtLoad
|
|
31
|
+
assert.ok(/<key>RunAtLoad<\/key>\s*<true\/>/.test(plist), "plist should set RunAtLoad");
|
|
32
|
+
// KeepAlive
|
|
33
|
+
assert.ok(/<key>KeepAlive<\/key>\s*<true\/>/.test(plist), "plist should set KeepAlive");
|
|
34
|
+
});
|
|
35
|
+
test("generatePlist sets StandardOutPath and StandardErrorPath to log path", () => {
|
|
36
|
+
const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN });
|
|
37
|
+
const logCount = (plist.match(new RegExp(MOCK_LOG_DARWIN.replace(/\//g, "\\/"), "g")) ?? []).length;
|
|
38
|
+
assert.ok(logCount >= 2, "plist should reference log path for both stdout and stderr");
|
|
39
|
+
});
|
|
40
|
+
test("generatePlist includes bin directory in PATH environment", () => {
|
|
41
|
+
const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN });
|
|
42
|
+
assert.ok(plist.includes(dirname(MOCK_BIN_DARWIN)), "plist PATH should include bin directory");
|
|
43
|
+
});
|
|
44
|
+
test("generatePlist respects a custom label override", () => {
|
|
45
|
+
const customLabel = "com.example.test";
|
|
46
|
+
const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN, label: customLabel });
|
|
47
|
+
assert.ok(plist.includes(`<string>${customLabel}</string>`), "plist should use custom label");
|
|
48
|
+
assert.ok(!plist.includes(`<string>${DAEMON_LABEL}</string>`), "plist should not use default label");
|
|
49
|
+
});
|
|
50
|
+
test("generatePlist produces valid XML declaration", () => {
|
|
51
|
+
const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN });
|
|
52
|
+
assert.ok(plist.startsWith('<?xml version="1.0"'), "plist should start with XML declaration");
|
|
53
|
+
assert.ok(plist.includes("<!DOCTYPE plist"), "plist should include DOCTYPE");
|
|
54
|
+
});
|
|
55
|
+
// ─── Systemd unit content generation ─────────────────────────────────────────
|
|
56
|
+
const MOCK_BIN_LINUX = "/home/user/.local/bin/chapterhouse";
|
|
57
|
+
test("generateSystemdUnit contains ExecStart with binary path and 'start' argument", () => {
|
|
58
|
+
const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX });
|
|
59
|
+
assert.ok(unit.includes(`ExecStart=${MOCK_BIN_LINUX} start`), "unit should set ExecStart with bin path + start");
|
|
60
|
+
});
|
|
61
|
+
test("generateSystemdUnit sets Restart=on-failure", () => {
|
|
62
|
+
const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX });
|
|
63
|
+
assert.ok(unit.includes("Restart=on-failure"), "unit should restart on failure");
|
|
64
|
+
});
|
|
65
|
+
test("generateSystemdUnit includes WantedBy=default.target in [Install]", () => {
|
|
66
|
+
const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX });
|
|
67
|
+
assert.ok(unit.includes("WantedBy=default.target"), "unit should install for default.target");
|
|
68
|
+
});
|
|
69
|
+
test("generateSystemdUnit includes default description", () => {
|
|
70
|
+
const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX });
|
|
71
|
+
assert.ok(unit.includes("Chapterhouse AI assistant daemon"), "unit should have description");
|
|
72
|
+
});
|
|
73
|
+
test("generateSystemdUnit respects custom description", () => {
|
|
74
|
+
const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX, description: "Custom desc" });
|
|
75
|
+
assert.ok(unit.includes("Description=Custom desc"), "unit should use custom description");
|
|
76
|
+
});
|
|
77
|
+
test("generateSystemdUnit includes bin directory in PATH", () => {
|
|
78
|
+
const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX });
|
|
79
|
+
assert.ok(unit.includes(dirname(MOCK_BIN_LINUX)), "unit PATH should include bin directory");
|
|
80
|
+
});
|
|
81
|
+
test("generateSystemdUnit has all three sections", () => {
|
|
82
|
+
const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX });
|
|
83
|
+
assert.ok(unit.includes("[Unit]"), "unit should have [Unit] section");
|
|
84
|
+
assert.ok(unit.includes("[Service]"), "unit should have [Service] section");
|
|
85
|
+
assert.ok(unit.includes("[Install]"), "unit should have [Install] section");
|
|
86
|
+
});
|
|
87
|
+
test("generateSystemdUnit includes Type=simple", () => {
|
|
88
|
+
const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX });
|
|
89
|
+
assert.ok(unit.includes("Type=simple"), "unit should have Type=simple");
|
|
90
|
+
});
|
|
91
|
+
// ─── Unit name / label constants ─────────────────────────────────────────────
|
|
92
|
+
test("DAEMON_LABEL is com.bketelsen.chapterhouse", () => {
|
|
93
|
+
assert.equal(DAEMON_LABEL, "com.bketelsen.chapterhouse");
|
|
94
|
+
});
|
|
95
|
+
test("DAEMON_UNIT_NAME is chapterhouse", () => {
|
|
96
|
+
assert.equal(DAEMON_UNIT_NAME, "chapterhouse");
|
|
97
|
+
});
|
|
98
|
+
//# sourceMappingURL=daemon-install.test.js.map
|
package/dist/daemon.js
CHANGED
|
@@ -16,6 +16,8 @@ import { getDisplayHost } from "./api/server-runtime.js";
|
|
|
16
16
|
import { StandupScheduler } from "./copilot/standup.js";
|
|
17
17
|
import { DecisionsSyncScheduler } from "./squad/mirror.scheduler.js";
|
|
18
18
|
import { registerShutdownSignals } from "./shutdown-signals.js";
|
|
19
|
+
import { logger } from "./util/logger.js";
|
|
20
|
+
const log = logger.child({ module: "daemon" });
|
|
19
21
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
22
|
/** Remove orphaned session folders older than 7 days, preserving the current session. */
|
|
21
23
|
function pruneOldSessions() {
|
|
@@ -47,11 +49,11 @@ function pruneOldSessions() {
|
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
51
|
if (pruned > 0) {
|
|
50
|
-
|
|
52
|
+
log.info({ pruned }, "Pruned orphaned session folders");
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
catch (err) {
|
|
54
|
-
|
|
56
|
+
log.error({ err: err instanceof Error ? err.message : err }, "Session pruning failed (non-fatal)");
|
|
55
57
|
}
|
|
56
58
|
}
|
|
57
59
|
function truncate(text, max = 200) {
|
|
@@ -59,57 +61,57 @@ function truncate(text, max = 200) {
|
|
|
59
61
|
return oneLine.length > max ? oneLine.slice(0, max) + "…" : oneLine;
|
|
60
62
|
}
|
|
61
63
|
async function main() {
|
|
62
|
-
|
|
64
|
+
log.info("Starting Chapterhouse daemon");
|
|
63
65
|
if (config.selfEditEnabled) {
|
|
64
|
-
|
|
66
|
+
log.warn("Self-edit mode enabled — Chapterhouse can modify his own source code");
|
|
65
67
|
}
|
|
66
68
|
// Set up message logging to daemon console
|
|
67
69
|
setMessageLogger((direction, source, text) => {
|
|
68
70
|
const arrow = direction === "in" ? "⟶" : "⟵";
|
|
69
71
|
const tag = source.padEnd(8);
|
|
70
|
-
|
|
72
|
+
log.debug({ direction, source, text: truncate(text) }, "chat");
|
|
71
73
|
});
|
|
72
74
|
// Initialize SQLite
|
|
73
75
|
getDb();
|
|
74
|
-
|
|
76
|
+
log.info("Database initialized");
|
|
75
77
|
// Initialize wiki knowledge base
|
|
76
78
|
const wikiIsNew = ensureWikiStructure();
|
|
77
79
|
if (wikiIsNew) {
|
|
78
|
-
|
|
80
|
+
log.info("Created wiki");
|
|
79
81
|
}
|
|
80
82
|
if (config.chapterhouseMode === "team") {
|
|
81
83
|
const seed = seedTeamWiki();
|
|
82
84
|
if (seed.created.length > 0) {
|
|
83
|
-
|
|
85
|
+
log.info({ pages: seed.created }, "Seeded team wiki pages");
|
|
84
86
|
}
|
|
85
87
|
}
|
|
86
88
|
if (shouldMigrate()) {
|
|
87
|
-
|
|
89
|
+
log.info("Migrating SQLite memories to wiki");
|
|
88
90
|
const count = migrateMemoriesToWiki();
|
|
89
|
-
|
|
91
|
+
log.info({ count }, "Migrated memories to wiki");
|
|
90
92
|
}
|
|
91
93
|
if (shouldReorganize()) {
|
|
92
|
-
|
|
94
|
+
log.info("Reorganizing wiki pages into entity structure");
|
|
93
95
|
const count = reorganizeWiki();
|
|
94
|
-
|
|
96
|
+
log.info({ count }, "Created entity pages during reorganization");
|
|
95
97
|
}
|
|
96
98
|
// Prune orphaned session folders older than 7 days
|
|
97
99
|
pruneOldSessions();
|
|
98
100
|
// One-time deprecation note for legacy Telegram users (v1 → v2)
|
|
99
101
|
if (process.env.TELEGRAM_BOT_TOKEN) {
|
|
100
|
-
|
|
102
|
+
log.warn("TELEGRAM_BOT_TOKEN found in env — Telegram support was removed in v2. The web UI is now the only client.");
|
|
101
103
|
}
|
|
102
104
|
// Start Copilot SDK client
|
|
103
|
-
|
|
105
|
+
log.info("Starting Copilot SDK client");
|
|
104
106
|
const client = await getClient();
|
|
105
|
-
|
|
107
|
+
log.info("Copilot SDK client ready");
|
|
106
108
|
// Initialize orchestrator session
|
|
107
|
-
|
|
109
|
+
log.info("Creating orchestrator session");
|
|
108
110
|
await initOrchestrator(client);
|
|
109
|
-
|
|
111
|
+
log.info("Orchestrator session ready");
|
|
110
112
|
// Wire up proactive notifications — broadcast to all connected web clients
|
|
111
113
|
setProactiveNotify((text) => {
|
|
112
|
-
|
|
114
|
+
log.debug({ text: truncate(text) }, "bg-notify");
|
|
113
115
|
broadcastToSSE(text);
|
|
114
116
|
});
|
|
115
117
|
// Start HTTP API + serve the web UI
|
|
@@ -121,7 +123,7 @@ async function main() {
|
|
|
121
123
|
decisionsSyncScheduler = new DecisionsSyncScheduler();
|
|
122
124
|
decisionsSyncScheduler.start();
|
|
123
125
|
const url = `http://${getDisplayHost(config.apiHost)}:${config.apiPort}`;
|
|
124
|
-
|
|
126
|
+
log.info({ url }, "Chapterhouse is fully operational");
|
|
125
127
|
if (process.env.CHAPTERHOUSE_OPEN_BROWSER === "1") {
|
|
126
128
|
const opener = process.platform === "darwin" ? "open" :
|
|
127
129
|
process.platform === "win32" ? "explorer.exe" : "xdg-open";
|
|
@@ -129,14 +131,14 @@ async function main() {
|
|
|
129
131
|
spawn(opener, [url], { detached: true, stdio: "ignore" }).unref();
|
|
130
132
|
}
|
|
131
133
|
catch (err) {
|
|
132
|
-
|
|
134
|
+
log.warn({ err: err instanceof Error ? err.message : err }, "Could not open browser");
|
|
133
135
|
}
|
|
134
136
|
}
|
|
135
137
|
// Non-blocking update check
|
|
136
138
|
checkForUpdate()
|
|
137
139
|
.then(({ updateAvailable, current, latest }) => {
|
|
138
140
|
if (updateAvailable) {
|
|
139
|
-
|
|
141
|
+
log.info({ current, latest }, "Update available — run 'chapterhouse update' to install");
|
|
140
142
|
}
|
|
141
143
|
})
|
|
142
144
|
.catch(() => { }); // silent — network may be unavailable
|
|
@@ -149,23 +151,23 @@ let shutdownState = "idle";
|
|
|
149
151
|
let decisionsSyncScheduler;
|
|
150
152
|
async function shutdown() {
|
|
151
153
|
if (shutdownState === "shutting_down") {
|
|
152
|
-
|
|
154
|
+
log.warn("Forced exit");
|
|
153
155
|
process.exit(1);
|
|
154
156
|
}
|
|
155
157
|
// Check for running workers before shutting down
|
|
156
158
|
const workers = getAgentInfo();
|
|
157
159
|
if (workers.length > 0 && shutdownState === "idle") {
|
|
158
160
|
const names = workers.map(w => `@${w.slug}`).join(", ");
|
|
159
|
-
|
|
160
|
-
|
|
161
|
+
log.warn({ workerCount: workers.length, workers: names }, "Running workers will be stopped on shutdown");
|
|
162
|
+
log.warn("Press Ctrl+C again to shut down, or wait for workers to finish");
|
|
161
163
|
shutdownState = "warned";
|
|
162
164
|
return;
|
|
163
165
|
}
|
|
164
166
|
shutdownState = "shutting_down";
|
|
165
|
-
|
|
167
|
+
log.info("Shutting down (Ctrl+C again to force)");
|
|
166
168
|
// Force exit after 3 seconds no matter what
|
|
167
169
|
const forceTimer = setTimeout(() => {
|
|
168
|
-
|
|
170
|
+
log.warn("Shutdown timed out — forcing exit");
|
|
169
171
|
process.exit(1);
|
|
170
172
|
}, 3000);
|
|
171
173
|
forceTimer.unref();
|
|
@@ -184,15 +186,15 @@ async function shutdown() {
|
|
|
184
186
|
}
|
|
185
187
|
catch { /* best effort */ }
|
|
186
188
|
closeDb();
|
|
187
|
-
|
|
189
|
+
log.info("Goodbye");
|
|
188
190
|
process.exit(0);
|
|
189
191
|
}
|
|
190
192
|
/** Restart the daemon by spawning a new process and exiting. */
|
|
191
193
|
export async function restartDaemon() {
|
|
192
|
-
|
|
194
|
+
log.info("Restarting");
|
|
193
195
|
const workers = getAgentInfo();
|
|
194
196
|
if (workers.length > 0) {
|
|
195
|
-
|
|
197
|
+
log.warn({ workerCount: workers.length }, "Stopping running workers for restart");
|
|
196
198
|
}
|
|
197
199
|
// Destroy all active agent sessions
|
|
198
200
|
await shutdownAgents();
|
|
@@ -216,20 +218,20 @@ export async function restartDaemon() {
|
|
|
216
218
|
env: { ...process.env, CHAPTERHOUSE_RESTARTED: "1" },
|
|
217
219
|
});
|
|
218
220
|
child.unref();
|
|
219
|
-
|
|
221
|
+
log.info("New process spawned. Exiting old process");
|
|
220
222
|
process.exit(0);
|
|
221
223
|
}
|
|
222
224
|
registerShutdownSignals(process, shutdown);
|
|
223
225
|
// Prevent unhandled errors from crashing the daemon
|
|
224
226
|
process.on("unhandledRejection", (reason) => {
|
|
225
|
-
|
|
227
|
+
log.error({ reason: String(reason) }, "Unhandled rejection (kept alive)");
|
|
226
228
|
});
|
|
227
229
|
process.on("uncaughtException", (err) => {
|
|
228
|
-
|
|
230
|
+
log.error({ err: err instanceof Error ? err.message : err }, "Uncaught exception — shutting down");
|
|
229
231
|
process.exit(1);
|
|
230
232
|
});
|
|
231
233
|
main().catch((err) => {
|
|
232
|
-
|
|
234
|
+
log.error({ err: err instanceof Error ? err.message : err }, "Fatal error");
|
|
233
235
|
process.exit(1);
|
|
234
236
|
});
|
|
235
237
|
//# sourceMappingURL=daemon.js.map
|