chapterhouse 0.3.0 → 0.3.1

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 CHANGED
@@ -307,6 +307,29 @@ chapterhouse daemon uninstall # stop, disable, and remove the unit file
307
307
  | macOS | `~/Library/Logs/chapterhouse.log` |
308
308
  | Linux | `journalctl --user -u chapterhouse` (no extra config needed) |
309
309
 
310
+ #### Timing contract
311
+
312
+ Chapterhouse enforces a **3-layer timing contract** so in-flight LLM streams can finish cleanly before the process is killed:
313
+
314
+ | Layer | What it controls | Config | Default |
315
+ |-------|-----------------|--------|---------|
316
+ | 1 — Orchestrator turn | How long the orchestrator waits per LLM turn | `CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS` | `1800000` (30 min) |
317
+ | 2 — Daemon shutdown grace | How long the daemon waits for in-flight work before force-exiting | `CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS` | `60000` (60 s) |
318
+ | 3 — systemd kill window | How long systemd waits after SIGTERM before sending SIGKILL | `TimeoutStopSec` in generated unit | `90 s` (fixed) |
319
+
320
+ **Rule:** each layer must exceed the one above it. Do not tighten `CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS` below `CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS`, and do not reduce `TimeoutStopSec` below `CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS`.
321
+
322
+ #### Daemon PATH
323
+
324
+ The generated systemd unit and launchd plist compose a rich `PATH` that includes:
325
+ - The installing shell's `$PATH` (captured at install time)
326
+ - The binary's own directory
327
+ - Linuxbrew (`/home/linuxbrew/.linuxbrew/bin`), Homebrew (`/opt/homebrew/bin`, `/usr/local/bin`)
328
+ - `~/.cargo/bin`, `~/.bun/bin`, `~/.volta/bin`, `~/.local/bin`
329
+ - Standard system paths
330
+
331
+ This ensures sub-agents and spawned tools can find CLI dependencies even in a headless service context.
332
+
310
333
  ## Web UI
311
334
 
312
335
  The browser app at `http://localhost:7788` is split into a few views:
@@ -315,8 +315,19 @@ export async function initOrchestrator(client) {
315
315
  log.error({ err: err instanceof Error ? err.message : err }, "Failed to create initial session (will retry on first message)");
316
316
  }
317
317
  }
318
- /** How long to wait for the orchestrator to finish a turn (10 min). */
319
- const ORCHESTRATOR_TIMEOUT_MS = 600_000;
318
+ /** How long to wait for the orchestrator to finish a turn (30 min default).
319
+ * Override with CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS env var (parsed as integer ms).
320
+ * Part of the 3-layer timing contract — see systemd unit TimeoutStopSec comment. */
321
+ const DEFAULT_ORCHESTRATOR_TIMEOUT_MS = 1_800_000;
322
+ export const ORCHESTRATOR_TIMEOUT_MS = (() => {
323
+ const env = process.env.CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS;
324
+ if (env) {
325
+ const parsed = parseInt(env, 10);
326
+ if (!isNaN(parsed) && parsed > 0)
327
+ return parsed;
328
+ }
329
+ return DEFAULT_ORCHESTRATOR_TIMEOUT_MS;
330
+ })();
320
331
  /** Send a prompt on a session identified by sessionKey, return the response. */
321
332
  async function executeOnSession(sessionKey, prompt, callback, attachments, onActivity) {
322
333
  const projectRoot = sessionKey.startsWith("project:") ? sessionKey.slice("project:".length) : undefined;
@@ -619,7 +619,7 @@ test("S5-01: subagent.failed event updates agent_tasks status to error", async (
619
619
  toolCallId: "subagent-call-003",
620
620
  agentName: "Zoe",
621
621
  agentDisplayName: "Zoe — QA",
622
- error: "Timeout after 600s",
622
+ error: "Timeout after 1800s",
623
623
  });
624
624
  const errorWrite = state.dbWrites.find((w) => w.sql.includes("UPDATE") && w.sql.includes("agent_tasks") && w.sql.includes("error"));
625
625
  assert.ok(errorWrite, "subagent.failed must UPDATE agent_tasks to error status");
@@ -53,9 +53,35 @@ export function resolveChapterhouseBin() {
53
53
  return argv1;
54
54
  return "chapterhouse";
55
55
  }
56
+ /**
57
+ * Compose a rich PATH for launchd-spawned children (macOS).
58
+ * Prepends the installing shell's $PATH so any tool already on the user's PATH
59
+ * is available to sub-agents, then adds Homebrew and language-toolchain dirs.
60
+ */
61
+ function composeMacOSPath(binDir, shellPath) {
62
+ const home = homedir();
63
+ return [
64
+ shellPath,
65
+ binDir,
66
+ "/opt/homebrew/bin",
67
+ "/usr/local/bin",
68
+ `${home}/.cargo/bin`,
69
+ `${home}/.bun/bin`,
70
+ `${home}/.volta/bin`,
71
+ `${home}/.local/bin`,
72
+ "/usr/bin",
73
+ "/bin",
74
+ "/usr/sbin",
75
+ "/sbin",
76
+ ]
77
+ .filter(Boolean)
78
+ .join(":");
79
+ }
56
80
  /** Generate the launchd plist XML string. */
57
81
  export function generatePlist(options) {
58
82
  const label = options.label ?? DAEMON_LABEL;
83
+ const shellPath = options.shellPath ?? process.env.PATH ?? "";
84
+ const richPath = composeMacOSPath(dirname(options.binPath), shellPath);
59
85
  return `<?xml version="1.0" encoding="UTF-8"?>
60
86
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
61
87
  <plist version="1.0">
@@ -78,16 +104,39 @@ export function generatePlist(options) {
78
104
  <key>EnvironmentVariables</key>
79
105
  <dict>
80
106
  <key>PATH</key>
81
- <string>${dirname(options.binPath)}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
107
+ <string>${richPath}</string>
82
108
  </dict>
83
109
  </dict>
84
110
  </plist>
85
111
  `;
86
112
  }
113
+ /**
114
+ * Compose a rich PATH for systemd-spawned children (Linux).
115
+ * Uses systemd's %h specifier for the home directory so the unit stays portable
116
+ * across uid changes. Prepends the installing shell's $PATH for robustness.
117
+ */
118
+ function composeSystemdPath(binDir, shellPath) {
119
+ return [
120
+ shellPath,
121
+ binDir,
122
+ "/home/linuxbrew/.linuxbrew/bin",
123
+ "%h/.cargo/bin",
124
+ "%h/.bun/bin",
125
+ "%h/.volta/bin",
126
+ "%h/.local/bin",
127
+ "/opt/homebrew/bin",
128
+ "/usr/local/bin",
129
+ "/usr/bin",
130
+ "/bin",
131
+ ]
132
+ .filter(Boolean)
133
+ .join(":");
134
+ }
87
135
  /** Generate the systemd user unit file string. */
88
136
  export function generateSystemdUnit(options) {
89
137
  const description = options.description ?? "Chapterhouse AI assistant daemon";
90
- const pathDir = dirname(options.binPath);
138
+ const shellPath = options.shellPath ?? process.env.PATH ?? "";
139
+ const richPath = composeSystemdPath(dirname(options.binPath), shellPath);
91
140
  return `[Unit]
92
141
  Description=${description}
93
142
  After=network.target
@@ -97,7 +146,17 @@ Type=simple
97
146
  ExecStart=${options.binPath} start
98
147
  Restart=on-failure
99
148
  RestartSec=5s
100
- Environment="PATH=${pathDir}:/usr/local/bin:/usr/bin:/bin"
149
+ # Rich PATH ensures spawned sub-agents and tools can find CLI dependencies
150
+ # (Linuxbrew, Homebrew, Cargo, Bun, Volta). %h is expanded to $HOME by systemd.
151
+ Environment="PATH=${richPath}"
152
+ # ── Timing contract (three layers) ─────────────────────────────────────────────
153
+ # Layer 1 │ Orchestrator turn timeout │ CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS (default 1800 s)
154
+ # Layer 2 │ Daemon graceful shutdown │ CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS (default 60 s)
155
+ # Layer 3 │ systemd force-kill window │ TimeoutStopSec = 90 s (must exceed layer 2)
156
+ # Each layer must exceed the one above it. Do NOT tighten TimeoutStopSec without
157
+ # first reducing CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS, or in-flight LLM streams will
158
+ # be killed mid-response.
159
+ TimeoutStopSec=90
101
160
 
102
161
  [Install]
103
162
  WantedBy=default.target
@@ -41,6 +41,23 @@ test("generatePlist includes bin directory in PATH environment", () => {
41
41
  const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN });
42
42
  assert.ok(plist.includes(dirname(MOCK_BIN_DARWIN)), "plist PATH should include bin directory");
43
43
  });
44
+ test("generatePlist PATH includes Homebrew and toolchain directories", () => {
45
+ const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN, shellPath: "" });
46
+ const home = homedir();
47
+ assert.ok(plist.includes("/opt/homebrew/bin"), "plist PATH should include /opt/homebrew/bin");
48
+ assert.ok(plist.includes(`${home}/.cargo/bin`), "plist PATH should include ~/.cargo/bin");
49
+ assert.ok(plist.includes(`${home}/.bun/bin`), "plist PATH should include ~/.bun/bin");
50
+ assert.ok(plist.includes(`${home}/.volta/bin`), "plist PATH should include ~/.volta/bin");
51
+ assert.ok(plist.includes(`${home}/.local/bin`), "plist PATH should include ~/.local/bin");
52
+ });
53
+ test("generatePlist PATH prepends installing shell PATH", () => {
54
+ const shellPath = "/custom/shell/bin:/another/dir";
55
+ const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN, shellPath });
56
+ assert.ok(plist.includes(shellPath), "plist PATH should include the installing shell PATH");
57
+ const pathStart = plist.indexOf(shellPath);
58
+ const binDirStart = plist.indexOf(dirname(MOCK_BIN_DARWIN), plist.indexOf("<key>PATH</key>"));
59
+ assert.ok(pathStart < binDirStart, "shell PATH should appear before bin dir in composed PATH");
60
+ });
44
61
  test("generatePlist respects a custom label override", () => {
45
62
  const customLabel = "com.example.test";
46
63
  const plist = generatePlist({ binPath: MOCK_BIN_DARWIN, logPath: MOCK_LOG_DARWIN, label: customLabel });
@@ -78,6 +95,24 @@ test("generateSystemdUnit includes bin directory in PATH", () => {
78
95
  const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX });
79
96
  assert.ok(unit.includes(dirname(MOCK_BIN_LINUX)), "unit PATH should include bin directory");
80
97
  });
98
+ test("generateSystemdUnit PATH includes Linuxbrew and toolchain directories", () => {
99
+ const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX, shellPath: "" });
100
+ assert.ok(unit.includes("/home/linuxbrew/.linuxbrew/bin"), "unit PATH should include Linuxbrew");
101
+ assert.ok(unit.includes("%h/.cargo/bin"), "unit PATH should include %h/.cargo/bin");
102
+ assert.ok(unit.includes("%h/.bun/bin"), "unit PATH should include %h/.bun/bin");
103
+ assert.ok(unit.includes("%h/.volta/bin"), "unit PATH should include %h/.volta/bin");
104
+ assert.ok(unit.includes("%h/.local/bin"), "unit PATH should include %h/.local/bin");
105
+ assert.ok(unit.includes("/opt/homebrew/bin"), "unit PATH should include /opt/homebrew/bin");
106
+ });
107
+ test("generateSystemdUnit PATH prepends installing shell PATH", () => {
108
+ const shellPath = "/custom/shell/bin";
109
+ const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX, shellPath });
110
+ assert.ok(unit.includes(shellPath), "unit PATH should include the installing shell PATH");
111
+ });
112
+ test("generateSystemdUnit includes TimeoutStopSec=90", () => {
113
+ const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX });
114
+ assert.ok(unit.includes("TimeoutStopSec=90"), "unit should set TimeoutStopSec=90 for timing contract");
115
+ });
81
116
  test("generateSystemdUnit has all three sections", () => {
82
117
  const unit = generateSystemdUnit({ binPath: MOCK_BIN_LINUX });
83
118
  assert.ok(unit.includes("[Unit]"), "unit should have [Unit] section");
package/dist/daemon.js CHANGED
@@ -19,6 +19,23 @@ import { registerShutdownSignals } from "./shutdown-signals.js";
19
19
  import { logger } from "./util/logger.js";
20
20
  const log = logger.child({ module: "daemon" });
21
21
  const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
22
+ /**
23
+ * How long the daemon waits for in-flight work to finish before forcing an exit.
24
+ * Layer 2 of the 3-layer timing contract:
25
+ * Layer 1 — orchestrator turn: CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS (default 1800 s)
26
+ * Layer 2 — daemon shutdown: CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS (default 60 s) ← this
27
+ * Layer 3 — systemd kill: TimeoutStopSec=90 s (must exceed layer 2)
28
+ * Allows in-flight LLM streams to complete before the process is torn down.
29
+ */
30
+ const SHUTDOWN_TIMEOUT_MS = (() => {
31
+ const env = process.env.CHAPTERHOUSE_SHUTDOWN_TIMEOUT_MS;
32
+ if (env) {
33
+ const parsed = parseInt(env, 10);
34
+ if (!isNaN(parsed) && parsed > 0)
35
+ return parsed;
36
+ }
37
+ return 60_000;
38
+ })();
22
39
  /** Remove orphaned session folders older than 7 days, preserving the current session. */
23
40
  function pruneOldSessions() {
24
41
  try {
@@ -165,11 +182,11 @@ async function shutdown() {
165
182
  }
166
183
  shutdownState = "shutting_down";
167
184
  log.info("Shutting down (Ctrl+C again to force)");
168
- // Force exit after 3 seconds no matter what
185
+ // Force exit after the configured grace period no matter what
169
186
  const forceTimer = setTimeout(() => {
170
187
  log.warn("Shutdown timed out — forcing exit");
171
188
  process.exit(1);
172
- }, 3000);
189
+ }, SHUTDOWN_TIMEOUT_MS);
173
190
  forceTimer.unref();
174
191
  // Destroy all active agent sessions
175
192
  await shutdownAgents();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Chapterhouse — a team-level AI assistant for engineering teams, built on the GitHub Copilot SDK. Web UI only.",
5
5
  "bin": {
6
6
  "chapterhouse": "dist/cli.js"