chapterhouse 0.1.5 → 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.
@@ -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
- console.log(`[chapterhouse] Pruned ${pruned} orphaned session folder(s)`);
52
+ log.info({ pruned }, "Pruned orphaned session folders");
51
53
  }
52
54
  }
53
55
  catch (err) {
54
- console.error("[chapterhouse] Session pruning failed (non-fatal):", err instanceof Error ? err.message : err);
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
- console.log("[chapterhouse] Starting Chapterhouse daemon...");
64
+ log.info("Starting Chapterhouse daemon");
63
65
  if (config.selfEditEnabled) {
64
- console.log("[chapterhouse] ⚠ Self-edit mode enabled — Chapterhouse can modify his own source code");
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
- console.log(`[chapterhouse] ${tag} ${arrow} ${truncate(text)}`);
72
+ log.debug({ direction, source, text: truncate(text) }, "chat");
71
73
  });
72
74
  // Initialize SQLite
73
75
  getDb();
74
- console.log("[chapterhouse] Database initialized");
76
+ log.info("Database initialized");
75
77
  // Initialize wiki knowledge base
76
78
  const wikiIsNew = ensureWikiStructure();
77
79
  if (wikiIsNew) {
78
- console.log("[chapterhouse] Created wiki at ~/.chapterhouse/wiki/");
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
- console.log(`[chapterhouse] Seeded ${seed.created.length} team wiki page(s): ${seed.created.join(", ")}`);
85
+ log.info({ pages: seed.created }, "Seeded team wiki pages");
84
86
  }
85
87
  }
86
88
  if (shouldMigrate()) {
87
- console.log("[chapterhouse] Migrating SQLite memories to wiki...");
89
+ log.info("Migrating SQLite memories to wiki");
88
90
  const count = migrateMemoriesToWiki();
89
- console.log(`[chapterhouse] Migrated ${count} memories to wiki`);
91
+ log.info({ count }, "Migrated memories to wiki");
90
92
  }
91
93
  if (shouldReorganize()) {
92
- console.log("[chapterhouse] Reorganizing wiki pages into entity structure...");
94
+ log.info("Reorganizing wiki pages into entity structure");
93
95
  const count = reorganizeWiki();
94
- console.log(`[chapterhouse] Created ${count} entity pages`);
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
- console.log("[chapterhouse] ℹ TELEGRAM_BOT_TOKEN found in env — Telegram support was removed in v2. The web UI is now the only client. You can delete this line from ~/.chapterhouse/.env.");
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
- console.log("[chapterhouse] Starting Copilot SDK client...");
105
+ log.info("Starting Copilot SDK client");
104
106
  const client = await getClient();
105
- console.log("[chapterhouse] Copilot SDK client ready");
107
+ log.info("Copilot SDK client ready");
106
108
  // Initialize orchestrator session
107
- console.log("[chapterhouse] Creating orchestrator session...");
109
+ log.info("Creating orchestrator session");
108
110
  await initOrchestrator(client);
109
- console.log("[chapterhouse] Orchestrator session ready");
111
+ log.info("Orchestrator session ready");
110
112
  // Wire up proactive notifications — broadcast to all connected web clients
111
113
  setProactiveNotify((text) => {
112
- console.log(`[chapterhouse] bg-notify ⟵ ${truncate(text)}`);
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
- console.log(`[chapterhouse] Chapterhouse is fully operational — open ${url} in your browser`);
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
- console.error(`[chapterhouse] Could not open browser:`, err instanceof Error ? err.message : err);
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
- console.log(`[chapterhouse] Update available: v${current} → v${latest} — run 'chapterhouse update' to install`);
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
- console.log("\n[chapterhouse] Forced exit.");
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
- console.log(`\n[chapterhouse] ${workers.length} running worker(s) will be stopped: ${names}`);
160
- console.log("[chapterhouse] Press Ctrl+C again to shut down, or wait for workers to finish.");
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
- console.log("\n[chapterhouse] Shutting down... (Ctrl+C again to force)");
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
- console.log("[chapterhouse] Shutdown timed out — forcing exit.");
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
- console.log("[chapterhouse] Goodbye.");
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
- console.log("[chapterhouse] Restarting...");
194
+ log.info("Restarting");
193
195
  const workers = getAgentInfo();
194
196
  if (workers.length > 0) {
195
- console.log(`[chapterhouse] Stopping ${workers.length} running worker(s) for restart`);
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
- console.log("[chapterhouse] New process spawned. Exiting old process.");
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
- console.error("[chapterhouse] Unhandled rejection (kept alive):", reason);
227
+ log.error({ reason: String(reason) }, "Unhandled rejection (kept alive)");
226
228
  });
227
229
  process.on("uncaughtException", (err) => {
228
- console.error("[chapterhouse] Uncaught exception — shutting down:", err);
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
- console.error("[chapterhouse] Fatal error:", err);
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