chapterhouse 0.1.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.
Files changed (119) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +363 -0
  3. package/agents/chapterhouse.agent.md +40 -0
  4. package/agents/coder.agent.md +38 -0
  5. package/agents/designer.agent.md +43 -0
  6. package/agents/general-purpose.agent.md +30 -0
  7. package/dist/api/auth.js +159 -0
  8. package/dist/api/auth.test.js +463 -0
  9. package/dist/api/errors.js +95 -0
  10. package/dist/api/errors.test.js +89 -0
  11. package/dist/api/rate-limit.js +85 -0
  12. package/dist/api/server-runtime.js +62 -0
  13. package/dist/api/server.js +651 -0
  14. package/dist/api/server.test.js +385 -0
  15. package/dist/api/sse.integration.test.js +270 -0
  16. package/dist/api/sse.js +7 -0
  17. package/dist/api/team.js +196 -0
  18. package/dist/api/team.test.js +466 -0
  19. package/dist/cli.js +102 -0
  20. package/dist/config.js +299 -0
  21. package/dist/config.phase3.test.js +20 -0
  22. package/dist/config.test.js +148 -0
  23. package/dist/copilot/agents.js +447 -0
  24. package/dist/copilot/agents.squad.test.js +72 -0
  25. package/dist/copilot/classifier.js +72 -0
  26. package/dist/copilot/client.js +32 -0
  27. package/dist/copilot/client.test.js +100 -0
  28. package/dist/copilot/episode-writer.js +219 -0
  29. package/dist/copilot/episode-writer.test.js +41 -0
  30. package/dist/copilot/mcp-config.js +22 -0
  31. package/dist/copilot/okr-mapper.js +196 -0
  32. package/dist/copilot/okr-mapper.test.js +114 -0
  33. package/dist/copilot/orchestrator.js +685 -0
  34. package/dist/copilot/orchestrator.test.js +523 -0
  35. package/dist/copilot/router.js +142 -0
  36. package/dist/copilot/router.test.js +119 -0
  37. package/dist/copilot/skills.js +125 -0
  38. package/dist/copilot/standup.js +138 -0
  39. package/dist/copilot/standup.test.js +132 -0
  40. package/dist/copilot/system-message.js +143 -0
  41. package/dist/copilot/system-message.test.js +17 -0
  42. package/dist/copilot/tools.js +1212 -0
  43. package/dist/copilot/tools.okr.test.js +260 -0
  44. package/dist/copilot/tools.squad.test.js +168 -0
  45. package/dist/daemon.js +235 -0
  46. package/dist/home-path.js +12 -0
  47. package/dist/home-path.test.js +11 -0
  48. package/dist/integrations/ado-analytics.js +178 -0
  49. package/dist/integrations/ado-analytics.test.js +284 -0
  50. package/dist/integrations/ado-client.js +227 -0
  51. package/dist/integrations/ado-client.test.js +176 -0
  52. package/dist/integrations/ado-schema.js +25 -0
  53. package/dist/integrations/ado-schema.test.js +39 -0
  54. package/dist/integrations/ado-skill.js +55 -0
  55. package/dist/integrations/report-generator.js +114 -0
  56. package/dist/integrations/report-generator.test.js +62 -0
  57. package/dist/integrations/team-push.js +144 -0
  58. package/dist/integrations/team-push.test.js +178 -0
  59. package/dist/integrations/teams-notify.js +108 -0
  60. package/dist/integrations/teams-notify.test.js +135 -0
  61. package/dist/paths.js +41 -0
  62. package/dist/setup.js +149 -0
  63. package/dist/shutdown-signals.js +13 -0
  64. package/dist/shutdown-signals.test.js +33 -0
  65. package/dist/squad/charter.js +108 -0
  66. package/dist/squad/charter.test.js +89 -0
  67. package/dist/squad/context.js +48 -0
  68. package/dist/squad/context.test.js +59 -0
  69. package/dist/squad/discovery.js +280 -0
  70. package/dist/squad/discovery.test.js +93 -0
  71. package/dist/squad/index.js +7 -0
  72. package/dist/squad/mirror.js +81 -0
  73. package/dist/squad/mirror.scheduler.js +78 -0
  74. package/dist/squad/mirror.scheduler.test.js +197 -0
  75. package/dist/squad/mirror.test.js +172 -0
  76. package/dist/squad/registry.js +162 -0
  77. package/dist/squad/registry.test.js +31 -0
  78. package/dist/squad/squad-coordinator-system-message.test.js +190 -0
  79. package/dist/squad/squad-session-routing.test.js +260 -0
  80. package/dist/squad/types.js +4 -0
  81. package/dist/status.js +25 -0
  82. package/dist/status.test.js +22 -0
  83. package/dist/store/db.js +290 -0
  84. package/dist/store/db.test.js +126 -0
  85. package/dist/store/squad-sessions.test.js +341 -0
  86. package/dist/test/setup-env.js +3 -0
  87. package/dist/update.js +112 -0
  88. package/dist/update.test.js +25 -0
  89. package/dist/wiki/context.js +138 -0
  90. package/dist/wiki/fs.js +195 -0
  91. package/dist/wiki/fs.test.js +39 -0
  92. package/dist/wiki/index-manager.js +359 -0
  93. package/dist/wiki/index-manager.test.js +129 -0
  94. package/dist/wiki/lock.js +26 -0
  95. package/dist/wiki/lock.test.js +30 -0
  96. package/dist/wiki/log-manager.js +20 -0
  97. package/dist/wiki/migrate.js +306 -0
  98. package/dist/wiki/okr.test.js +101 -0
  99. package/dist/wiki/path-utils.js +4 -0
  100. package/dist/wiki/path-utils.test.js +8 -0
  101. package/dist/wiki/seed-team-wiki.js +296 -0
  102. package/dist/wiki/seed-team-wiki.test.js +69 -0
  103. package/dist/wiki/team-sync.js +212 -0
  104. package/dist/wiki/team-sync.test.js +185 -0
  105. package/dist/wiki/templates/okr.js +98 -0
  106. package/package.json +72 -0
  107. package/skills/.gitkeep +0 -0
  108. package/skills/find-skills/SKILL.md +161 -0
  109. package/skills/find-skills/_meta.json +4 -0
  110. package/skills/frontend-design/LICENSE.txt +177 -0
  111. package/skills/frontend-design/SKILL.md +42 -0
  112. package/skills/squad/SKILL.md +76 -0
  113. package/web/dist/assets/index-D-e7K-fT.css +10 -0
  114. package/web/dist/assets/index-DAg9IrpO.js +142 -0
  115. package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
  116. package/web/dist/chapterhouse-icon.png +0 -0
  117. package/web/dist/chapterhouse-icon.svg +42 -0
  118. package/web/dist/chapterhouse-logo.svg +46 -0
  119. package/web/dist/index.html +15 -0
@@ -0,0 +1,651 @@
1
+ import cors from "cors";
2
+ import express from "express";
3
+ import helmet from "helmet";
4
+ import { existsSync, statSync } from "fs";
5
+ import { join, dirname } from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { z } from "zod";
8
+ import { sendToOrchestrator, getAgentInfo, cancelCurrentMessage, getLastRouteResult, getCurrentSessionKey } from "../copilot/orchestrator.js";
9
+ import { getAgentRegistry } from "../copilot/agents.js";
10
+ import { config, persistModel } from "../config.js";
11
+ import { getRouterConfig, updateRouterConfig } from "../copilot/router.js";
12
+ import { searchIndex, parseIndex } from "../wiki/index-manager.js";
13
+ import { createAuthMiddleware, getBootstrapAuthResponse } from "./auth.js";
14
+ import { createConcurrentConnectionLimiter, createFixedWindowRateLimiter } from "./rate-limit.js";
15
+ import { createTeamRouter } from "./team.js";
16
+ import { writePage, deletePage, pageExists, listPages, ensureWikiStructure, assertPagePath, } from "../wiki/fs.js";
17
+ import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
18
+ import { withWikiWrite } from "../wiki/lock.js";
19
+ import { listSkills, removeSkill } from "../copilot/skills.js";
20
+ import { restartDaemon } from "../daemon.js";
21
+ import { API_TOKEN_PATH, resolveWikiRelativePath } from "../paths.js";
22
+ import { getDb } from "../store/db.js";
23
+ import { getStatus, onStatusChange } from "../status.js";
24
+ import { formatSseData, formatSseEvent } from "./sse.js";
25
+ import { syncDecisionsFileToWiki } from "../squad/mirror.js";
26
+ import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, buildHistoryEntries, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
27
+ import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
28
+ void searchIndex; // re-exported by index-manager; reference here documents the dep
29
+ const __dirname = dirname(fileURLToPath(import.meta.url));
30
+ // Built SPA bundle (web/dist/), shipped alongside dist/
31
+ const WEB_DIST_DIR = join(__dirname, "..", "..", "web", "dist");
32
+ const WEB_INDEX_HTML = join(WEB_DIST_DIR, "index.html");
33
+ const requiredString = (message) => z.string({ error: message }).trim().min(1, message);
34
+ const messageRequestSchema = z.object({
35
+ prompt: requiredString("Missing 'prompt' in request body"),
36
+ connectionId: requiredString("Missing or invalid 'connectionId'. Connect to /stream first."),
37
+ projectPath: z.string().optional(),
38
+ sessionKey: z.string().optional(),
39
+ });
40
+ const modelRequestSchema = z.object({
41
+ model: requiredString("Missing 'model' in request body"),
42
+ }).strict();
43
+ const autoRequestSchema = z.object({
44
+ enabled: z.boolean().optional(),
45
+ tierModels: z.object({
46
+ fast: requiredString("tierModels.fast must be a non-empty string").optional(),
47
+ standard: requiredString("tierModels.standard must be a non-empty string").optional(),
48
+ premium: requiredString("tierModels.premium must be a non-empty string").optional(),
49
+ }).strict().optional(),
50
+ cooldownMessages: z.number({ error: "cooldownMessages must be a number" })
51
+ .int("cooldownMessages must be an integer")
52
+ .min(0, "cooldownMessages must be a non-negative integer")
53
+ .optional(),
54
+ }).strict();
55
+ const wikiWriteSchema = z.object({
56
+ content: z.string({ error: "Missing 'content' string in request body" }),
57
+ }).strict();
58
+ // Load a configured API token when present; startup validation below enforces auth.
59
+ let apiToken = null;
60
+ try {
61
+ apiToken = resolveApiToken({
62
+ envToken: process.env.API_TOKEN,
63
+ tokenPath: API_TOKEN_PATH,
64
+ });
65
+ assertAuthenticationConfigured({
66
+ entraAuthEnabled: config.entraAuthEnabled,
67
+ apiToken,
68
+ });
69
+ }
70
+ catch (err) {
71
+ console.error(err instanceof Error ? err.message : String(err));
72
+ process.exit(1);
73
+ }
74
+ if (config.standaloneMode) {
75
+ console.log("[standalone] Running without authentication — team features disabled");
76
+ }
77
+ function isLoopbackHostname(hostname) {
78
+ return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
79
+ }
80
+ function isAllowedCorsOrigin(origin) {
81
+ if (config.corsAllowedOrigins.includes(origin)) {
82
+ return true;
83
+ }
84
+ if (!config.isProduction) {
85
+ try {
86
+ return isLoopbackHostname(new URL(origin).hostname);
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ }
92
+ return false;
93
+ }
94
+ const app = express();
95
+ app.disable("x-powered-by");
96
+ app.use(helmet({
97
+ contentSecurityPolicy: false,
98
+ crossOriginEmbedderPolicy: false,
99
+ }));
100
+ app.use(cors({
101
+ origin(origin, callback) {
102
+ if (!origin || isAllowedCorsOrigin(origin)) {
103
+ callback(null, true);
104
+ return;
105
+ }
106
+ callback(null, false);
107
+ },
108
+ methods: ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"],
109
+ allowedHeaders: ["Authorization", "Content-Type"],
110
+ maxAge: 600,
111
+ optionsSuccessStatus: 204,
112
+ }));
113
+ app.use(express.json({ limit: "2mb" }));
114
+ function sendRateLimitResponse(res, retryAfterSeconds, message) {
115
+ res.setHeader("Retry-After", String(retryAfterSeconds));
116
+ res.status(429).json({ error: `${message} Retry after ${retryAfterSeconds} seconds.` });
117
+ }
118
+ const apiRateLimit = createFixedWindowRateLimiter({
119
+ windowMs: config.apiRateLimitWindowMs,
120
+ maxRequests: config.apiRateLimitGeneralMax,
121
+ skip: (req) => req.path === "/bootstrap",
122
+ onLimit: (_req, res, retryAfterSeconds) => {
123
+ sendRateLimitResponse(res, retryAfterSeconds, "Too many API requests.");
124
+ },
125
+ });
126
+ const authRateLimit = createFixedWindowRateLimiter({
127
+ windowMs: config.apiRateLimitWindowMs,
128
+ maxRequests: config.apiRateLimitAuthMax,
129
+ onLimit: (_req, res, retryAfterSeconds) => {
130
+ sendRateLimitResponse(res, retryAfterSeconds, "Too many authentication attempts.");
131
+ },
132
+ });
133
+ const sseConcurrentLimit = createConcurrentConnectionLimiter({
134
+ maxConnections: config.apiRateLimitSseMaxConnections,
135
+ onLimit: (_req, res, retryAfterSeconds) => {
136
+ sendRateLimitResponse(res, retryAfterSeconds, "Too many concurrent stream connections.");
137
+ },
138
+ });
139
+ const authMiddleware = createAuthMiddleware({
140
+ apiToken,
141
+ config: {
142
+ entraAuthEnabled: config.entraAuthEnabled,
143
+ standaloneMode: config.standaloneMode,
144
+ entraTenantId: config.entraTenantId,
145
+ entraClientId: config.entraClientId,
146
+ entraRequiredRole: config.entraRequiredRole,
147
+ entraTeamLeadId: config.entraTeamLeadId,
148
+ },
149
+ });
150
+ app.use("/api/team", apiRateLimit, createTeamRouter({ authMiddleware }));
151
+ app.use("/api", apiRateLimit);
152
+ app.use("/api/bootstrap", authRateLimit);
153
+ app.use("/stream", authRateLimit, sseConcurrentLimit);
154
+ app.use(authMiddleware);
155
+ // Loopback-only origin gate for the bootstrap endpoint that hands the token to the SPA.
156
+ function isLoopbackOrigin(req) {
157
+ const origin = req.headers.origin || req.headers.referer;
158
+ if (!origin) {
159
+ // Same-origin fetches from the served SPA omit Origin; permit when the request comes
160
+ // from the loopback socket itself.
161
+ const remote = req.socket.remoteAddress || "";
162
+ return remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
163
+ }
164
+ try {
165
+ return isLoopbackHostname(new URL(origin).hostname);
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ }
171
+ function readPathParam(req) {
172
+ const raw = req.query.path;
173
+ if (typeof raw !== "string" || !raw) {
174
+ throw new BadRequestError("Missing 'path' query param");
175
+ }
176
+ return raw;
177
+ }
178
+ function assertValidPagePath(path) {
179
+ try {
180
+ assertPagePath(path);
181
+ return path;
182
+ }
183
+ catch (error) {
184
+ asBadRequest(error);
185
+ }
186
+ }
187
+ function getWikiPageScope(path) {
188
+ return teamWikiSync.isTeamPath(path) ? "team" : "personal";
189
+ }
190
+ // Active SSE connections
191
+ const sseClients = new Map();
192
+ const pendingSseMessages = [];
193
+ let connectionCounter = 0;
194
+ // ---------------------------------------------------------------------------
195
+ // Bootstrap — hands the API token to the same-origin SPA on first load.
196
+ // Loopback-only by IP / Origin check.
197
+ // ---------------------------------------------------------------------------
198
+ app.get("/api/bootstrap", (req, res) => {
199
+ if (!isLoopbackOrigin(req)) {
200
+ throw new ForbiddenError("Bootstrap is loopback-only");
201
+ }
202
+ res.json(getBootstrapAuthResponse(apiToken, {
203
+ entraAuthEnabled: config.entraAuthEnabled,
204
+ standaloneMode: config.standaloneMode,
205
+ entraTenantId: config.entraTenantId,
206
+ entraClientId: config.entraClientId,
207
+ entraRequiredRole: config.entraRequiredRole,
208
+ entraTeamLeadId: config.entraTeamLeadId,
209
+ }));
210
+ });
211
+ app.get("/api/config/public", (_req, res) => {
212
+ res.json(createPublicConfigPayload({
213
+ entraAuthEnabled: config.entraAuthEnabled,
214
+ standaloneMode: config.standaloneMode,
215
+ entraClientId: config.entraClientId,
216
+ entraTenantId: config.entraTenantId,
217
+ }));
218
+ });
219
+ // Health check — intentionally unauthenticated, returns no sensitive data
220
+ const handleHealth = (_req, res) => {
221
+ res.json(createHealthPayload());
222
+ };
223
+ app.get("/status", handleHealth);
224
+ app.get("/health", handleHealth);
225
+ // ---------------------------------------------------------------------------
226
+ // Workers / agents
227
+ // ---------------------------------------------------------------------------
228
+ app.get("/api/agents", (_req, res) => {
229
+ res.json(getAgentInfo());
230
+ });
231
+ // List all workers: reads from SQLite agent_tasks (last 24 hours) so completed
232
+ // squad-dispatched tasks remain visible after they finish, not just in-flight ones.
233
+ app.get("/api/workers", (_req, res) => {
234
+ const rows = getDb()
235
+ .prepare(`SELECT task_id, agent_slug, description, status, started_at, completed_at
236
+ FROM agent_tasks
237
+ WHERE started_at >= datetime('now', '-24 hours')
238
+ ORDER BY started_at DESC
239
+ LIMIT 100`)
240
+ .all();
241
+ const registry = getAgentRegistry();
242
+ res.json(rows.map((row) => {
243
+ const agent = registry.find((a) => a.slug === row.agent_slug);
244
+ return {
245
+ taskId: row.task_id,
246
+ slug: row.agent_slug,
247
+ name: agent?.name || row.agent_slug,
248
+ model: agent?.model || "unknown",
249
+ description: row.description,
250
+ status: row.status,
251
+ startedAt: row.started_at,
252
+ completedAt: row.completed_at,
253
+ };
254
+ }));
255
+ });
256
+ // Detailed worker row: include task status, description, and any captured result/output.
257
+ app.get("/api/workers/:taskId", (req, res) => {
258
+ const taskId = req.params.taskId;
259
+ const row = getDb()
260
+ .prepare(`SELECT task_id, agent_slug, description, status, result, started_at, completed_at
261
+ FROM agent_tasks WHERE task_id = ?`)
262
+ .get(taskId);
263
+ if (!row) {
264
+ throw new NotFoundError("Task not found");
265
+ }
266
+ res.json({
267
+ taskId: row.task_id,
268
+ agentSlug: row.agent_slug,
269
+ description: row.description,
270
+ status: row.status,
271
+ result: row.result,
272
+ startedAt: row.started_at,
273
+ completedAt: row.completed_at,
274
+ });
275
+ });
276
+ // ---------------------------------------------------------------------------
277
+ // SSE stream for real-time chat
278
+ // ---------------------------------------------------------------------------
279
+ app.get("/stream", (req, res) => {
280
+ const connectionId = `web-${++connectionCounter}`;
281
+ res.writeHead(200, {
282
+ "Content-Type": "text/event-stream",
283
+ "Cache-Control": "no-cache",
284
+ Connection: "keep-alive",
285
+ });
286
+ res.write(formatSseData({ type: "connected", connectionId }));
287
+ while (pendingSseMessages.length > 0) {
288
+ const queued = pendingSseMessages.shift();
289
+ if (!queued) {
290
+ continue;
291
+ }
292
+ res.write(formatSseData({ type: "message", content: queued }));
293
+ }
294
+ sseClients.set(connectionId, res);
295
+ const unsubscribeStatus = onStatusChange((status, message) => {
296
+ res.write(formatSseEvent("status", { status, message }));
297
+ });
298
+ const currentStatus = getStatus();
299
+ if (currentStatus.status !== "idle") {
300
+ res.write(formatSseEvent("status", currentStatus));
301
+ }
302
+ const heartbeat = setInterval(() => {
303
+ res.write(`:ping\n\n`);
304
+ }, 20_000);
305
+ req.on("close", () => {
306
+ clearInterval(heartbeat);
307
+ unsubscribeStatus();
308
+ sseClients.delete(connectionId);
309
+ });
310
+ });
311
+ // ---------------------------------------------------------------------------
312
+ // Send a message to the orchestrator
313
+ // ---------------------------------------------------------------------------
314
+ app.post("/api/message", (req, res) => {
315
+ const { prompt, connectionId, projectPath, sessionKey: requestedSessionKey } = parseRequest(messageRequestSchema, req.body);
316
+ const effectiveSessionKey = requestedSessionKey || "default";
317
+ if (!sseClients.has(connectionId)) {
318
+ throw new BadRequestError("Missing or invalid 'connectionId'. Connect to /stream first.");
319
+ }
320
+ sendToOrchestrator(prompt, {
321
+ type: "web",
322
+ connectionId,
323
+ user: req.user,
324
+ authorizationHeader: typeof req.headers.authorization === "string" ? req.headers.authorization : undefined,
325
+ projectPath: projectPath || undefined,
326
+ }, (text, done) => {
327
+ const sseRes = sseClients.get(connectionId);
328
+ if (sseRes) {
329
+ const event = {
330
+ type: done ? "message" : "delta",
331
+ content: text,
332
+ sessionKey: effectiveSessionKey,
333
+ };
334
+ if (done) {
335
+ const routeResult = getLastRouteResult();
336
+ if (routeResult) {
337
+ event.route = {
338
+ model: routeResult.model,
339
+ routerMode: routeResult.routerMode,
340
+ tier: routeResult.tier,
341
+ ...(routeResult.overrideName ? { overrideName: routeResult.overrideName } : {}),
342
+ };
343
+ }
344
+ }
345
+ sseRes.write(formatSseData(event));
346
+ }
347
+ }, undefined, (activity) => {
348
+ const sseRes = sseClients.get(connectionId);
349
+ if (sseRes) {
350
+ sseRes.write(formatSseData({ type: "activity", ...activity, sessionKey: effectiveSessionKey }));
351
+ }
352
+ });
353
+ res.json({ status: "queued" });
354
+ });
355
+ // Cancel the current in-flight message
356
+ app.post("/api/cancel", async (_req, res) => {
357
+ const sessionKey = getCurrentSessionKey();
358
+ const cancelled = await cancelCurrentMessage();
359
+ for (const [, sseRes] of sseClients) {
360
+ sseRes.write(formatSseData({ type: "cancelled", sessionKey }));
361
+ }
362
+ res.json({ status: "ok", cancelled });
363
+ });
364
+ // ---------------------------------------------------------------------------
365
+ // Model & router
366
+ // ---------------------------------------------------------------------------
367
+ app.get("/api/model", (_req, res) => {
368
+ res.json({ model: config.copilotModel });
369
+ });
370
+ app.post("/api/model", async (req, res) => {
371
+ const { model } = parseRequest(modelRequestSchema, req.body);
372
+ try {
373
+ const { getClient } = await import("../copilot/client.js");
374
+ const client = await getClient();
375
+ const models = await client.listModels();
376
+ const match = models.find((m) => m.id === model);
377
+ if (!match) {
378
+ const suggestions = models
379
+ .filter((m) => m.id.includes(model) || m.id.toLowerCase().includes(model.toLowerCase()))
380
+ .map((m) => m.id);
381
+ const hint = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : "";
382
+ throw new BadRequestError(`Model '${model}' not found.${hint}`);
383
+ }
384
+ }
385
+ catch (error) {
386
+ if (error instanceof BadRequestError) {
387
+ throw error;
388
+ }
389
+ // If we can't validate (client not ready), allow the switch — it'll fail on next message if wrong
390
+ }
391
+ const previous = config.copilotModel;
392
+ config.copilotModel = model;
393
+ persistModel(model);
394
+ res.json({ previous, current: model });
395
+ });
396
+ app.get("/api/models", async (_req, res) => {
397
+ try {
398
+ const { getClient } = await import("../copilot/client.js");
399
+ const client = await getClient();
400
+ const models = await client.listModels();
401
+ res.json({ models: models.map((m) => m.id), current: config.copilotModel });
402
+ }
403
+ catch (error) {
404
+ console.error("[api] Failed to list models:", error);
405
+ throw new InternalServerError();
406
+ }
407
+ });
408
+ app.get("/api/auto", (_req, res) => {
409
+ const routerConfig = getRouterConfig();
410
+ const lastRoute = getLastRouteResult();
411
+ res.json({
412
+ ...routerConfig,
413
+ currentModel: config.copilotModel,
414
+ lastRoute: lastRoute || null,
415
+ });
416
+ });
417
+ app.post("/api/auto", (req, res) => {
418
+ const body = parseRequest(autoRequestSchema, req.body ?? {});
419
+ const updated = updateRouterConfig(body);
420
+ console.log(`[chapterhouse] Auto-routing ${updated.enabled ? "enabled" : "disabled"}`);
421
+ res.json(updated);
422
+ });
423
+ // ---------------------------------------------------------------------------
424
+ // Wiki: list, read, write, delete
425
+ // ---------------------------------------------------------------------------
426
+ app.get("/api/wiki/pages", async (req, res) => {
427
+ ensureWikiStructure();
428
+ // Sync team wiki pages if connected, using the caller's auth token
429
+ if (teamWikiSync.isEnabled()) {
430
+ const authorizationHeader = typeof req.headers.authorization === "string"
431
+ ? req.headers.authorization
432
+ : undefined;
433
+ try {
434
+ await teamWikiSync.syncAll({ authorizationHeader });
435
+ }
436
+ catch {
437
+ // Non-fatal: list local pages even if team sync fails
438
+ }
439
+ }
440
+ const entries = parseIndex();
441
+ // Index entries first (rich metadata), then any pages on disk that aren't yet indexed.
442
+ const indexed = new Set(entries.map((e) => e.path));
443
+ const indexedResults = entries.map((e) => ({
444
+ path: e.path,
445
+ title: e.title,
446
+ summary: e.summary,
447
+ section: e.section,
448
+ tags: e.tags || [],
449
+ updated: e.updated || "",
450
+ scope: getWikiPageScope(e.path),
451
+ }));
452
+ const orphanResults = listPages()
453
+ .filter((p) => !indexed.has(p))
454
+ .map((p) => ({
455
+ path: p,
456
+ title: p,
457
+ summary: "",
458
+ section: "Unindexed",
459
+ tags: [],
460
+ updated: "",
461
+ scope: getWikiPageScope(p),
462
+ }));
463
+ res.json([...indexedResults, ...orphanResults]);
464
+ });
465
+ app.get("/api/wiki/page", async (req, res) => {
466
+ const path = assertValidPagePath(readPathParam(req));
467
+ const authorizationHeader = typeof req.headers.authorization === "string"
468
+ ? req.headers.authorization
469
+ : undefined;
470
+ const content = await readWikiPage(path, { authorizationHeader });
471
+ if (content === undefined) {
472
+ throw new NotFoundError("Page not found");
473
+ }
474
+ res.json({ path, content });
475
+ });
476
+ app.put("/api/wiki/page", async (req, res) => {
477
+ const path = assertValidPagePath(readPathParam(req));
478
+ const { content } = parseRequest(wikiWriteSchema, req.body);
479
+ const created = await withWikiWrite(() => {
480
+ const isCreated = !pageExists(path);
481
+ writePage(path, content);
482
+ return isCreated;
483
+ });
484
+ res.json({ ok: true, created, path });
485
+ });
486
+ app.delete("/api/wiki/page", async (req, res) => {
487
+ const path = assertValidPagePath(readPathParam(req));
488
+ const removed = await withWikiWrite(() => deletePage(path));
489
+ res.json({ ok: removed, path });
490
+ });
491
+ // ---------------------------------------------------------------------------
492
+ // History — past conversation summaries auto-written to pages/conversations/
493
+ // ---------------------------------------------------------------------------
494
+ app.get("/api/history", (_req, res) => {
495
+ ensureWikiStructure();
496
+ const entries = buildHistoryEntries(listPages().filter((p) => p.startsWith("pages/conversations/")), {
497
+ resolveWikiPath: resolveWikiRelativePath,
498
+ stat: statSync,
499
+ });
500
+ res.json(entries);
501
+ });
502
+ // ---------------------------------------------------------------------------
503
+ // Skills
504
+ // ---------------------------------------------------------------------------
505
+ app.get("/api/skills", (_req, res) => {
506
+ res.json(listSkills());
507
+ });
508
+ app.delete("/api/skills/:slug", (req, res) => {
509
+ const slug = Array.isArray(req.params.slug) ? req.params.slug[0] : req.params.slug;
510
+ const result = removeSkill(slug);
511
+ if (!result.ok) {
512
+ throw new BadRequestError(result.message);
513
+ }
514
+ res.json({ ok: true, message: result.message });
515
+ });
516
+ // Restart daemon
517
+ app.post("/api/restart", (_req, res) => {
518
+ res.json({ status: "restarting" });
519
+ setTimeout(() => {
520
+ restartDaemon().catch((err) => {
521
+ console.error("[chapterhouse] Restart failed:", err);
522
+ });
523
+ }, 500);
524
+ });
525
+ // ---------------------------------------------------------------------------
526
+ // Projects (Squad integration)
527
+ // ---------------------------------------------------------------------------
528
+ const projectRegisterSchema = z.object({
529
+ projectRoot: requiredString("projectRoot must be a non-empty string"),
530
+ }).strict();
531
+ app.get("/api/projects", (_req, res) => {
532
+ if (!config.squadEnabled) {
533
+ res.status(503).json({ error: "Squad integration is disabled. Set ENABLE_SQUAD=1 to enable." });
534
+ return;
535
+ }
536
+ const db = getDb();
537
+ const rows = db.prepare(`
538
+ SELECT project_squads.project_root, project_squads.squad_dir,
539
+ COUNT(squad_agents.slug) as agent_count, project_squads.loaded_at
540
+ FROM project_squads
541
+ LEFT JOIN squad_agents ON project_squads.project_root = squad_agents.project_root
542
+ WHERE project_squads.registered = 1
543
+ GROUP BY project_squads.project_root
544
+ ORDER BY project_squads.loaded_at DESC
545
+ `).all();
546
+ res.json(rows.map((r) => ({
547
+ projectRoot: r.project_root,
548
+ squadDir: r.squad_dir,
549
+ agentCount: r.agent_count,
550
+ loadedAt: r.loaded_at,
551
+ })));
552
+ });
553
+ app.post("/api/projects", async (req, res) => {
554
+ if (!config.squadEnabled) {
555
+ res.status(503).json({ error: "Squad integration is disabled. Set ENABLE_SQUAD=1 to enable." });
556
+ return;
557
+ }
558
+ const { projectRoot } = parseRequest(projectRegisterSchema, req.body);
559
+ if (!existsSync(projectRoot)) {
560
+ throw new BadRequestError(`Directory not found: ${projectRoot}`);
561
+ }
562
+ const squadDir = join(projectRoot, ".squad");
563
+ if (!existsSync(squadDir)) {
564
+ res.status(400).json({ error: "No .squad directory found at this path" });
565
+ return;
566
+ }
567
+ const db = getDb();
568
+ db.prepare(`INSERT OR REPLACE INTO project_squads (project_root, squad_dir, team_dir, mode, registered) VALUES (?, ?, ?, 'local', 1)`)
569
+ .run(projectRoot, squadDir, squadDir);
570
+ // Fire-and-forget: sync decisions.md to the wiki. Non-fatal if it fails.
571
+ syncDecisionsFileToWiki(projectRoot).then(result => {
572
+ if (result) {
573
+ console.log(`[squad] Synced ${result.entriesSynced} decision entries to wiki for ${projectRoot}`);
574
+ }
575
+ }).catch(err => {
576
+ console.warn('[squad] syncDecisionsFileToWiki failed during registration (non-fatal):', err instanceof Error ? err.message : err);
577
+ });
578
+ res.status(201).json({ projectRoot, message: "Project registered successfully" });
579
+ });
580
+ app.delete("/api/projects/:projectRoot", (req, res) => {
581
+ if (!config.squadEnabled) {
582
+ res.status(503).json({ error: "Squad integration is disabled. Set ENABLE_SQUAD=1 to enable." });
583
+ return;
584
+ }
585
+ const raw = Array.isArray(req.params.projectRoot) ? req.params.projectRoot[0] : req.params.projectRoot;
586
+ const projectRoot = decodeURIComponent(raw);
587
+ const db = getDb();
588
+ const existing = db.prepare(`SELECT project_root FROM project_squads WHERE project_root = ?`).get(projectRoot);
589
+ if (!existing) {
590
+ throw new NotFoundError("Project not found");
591
+ }
592
+ db.prepare(`DELETE FROM project_squads WHERE project_root = ?`).run(projectRoot);
593
+ db.prepare(`DELETE FROM squad_agents WHERE project_root = ?`).run(projectRoot);
594
+ res.json({ message: "Project removed" });
595
+ });
596
+ app.use(apiNotFoundHandler);
597
+ // ---------------------------------------------------------------------------
598
+ // Static SPA + fallback. Mounted last so API routes win.
599
+ // ---------------------------------------------------------------------------
600
+ if (existsSync(WEB_DIST_DIR)) {
601
+ app.use(express.static(WEB_DIST_DIR, { index: false, maxAge: "1h" }));
602
+ // SPA fallback for client-side routing. Everything not under /api/ or the
603
+ // public transport endpoints gets index.html.
604
+ app.use((req, res, next) => {
605
+ if (req.method !== "GET" || !shouldServeSpaPath(req.path)) {
606
+ next();
607
+ return;
608
+ }
609
+ if (existsSync(WEB_INDEX_HTML)) {
610
+ res.sendFile("index.html", { root: WEB_DIST_DIR });
611
+ }
612
+ else {
613
+ res.status(404).send("Web UI not built. Run `npm run build` first.");
614
+ }
615
+ });
616
+ }
617
+ else {
618
+ app.get("/", (_req, res) => {
619
+ res
620
+ .status(503)
621
+ .send("Web UI not built. Run `npm run build` (or `npm --prefix web run build`) first.");
622
+ });
623
+ }
624
+ app.use(createApiErrorHandler());
625
+ export function startApiServer() {
626
+ return new Promise((resolve, reject) => {
627
+ const server = app.listen(config.apiPort, config.apiHost, () => {
628
+ console.log(`[chapterhouse] HTTP API + web UI listening on http://${getDisplayHost(config.apiHost)}:${config.apiPort}`);
629
+ resolve();
630
+ });
631
+ server.on("error", (err) => {
632
+ if (err.code === "EADDRINUSE") {
633
+ reject(new Error(`Port ${config.apiPort} is already in use. Is another Chapterhouse instance running?`));
634
+ }
635
+ else {
636
+ reject(err);
637
+ }
638
+ });
639
+ });
640
+ }
641
+ /** Broadcast a proactive message to all connected SSE clients (for background task completions). */
642
+ export function broadcastToSSE(text) {
643
+ if (sseClients.size === 0) {
644
+ pendingSseMessages.push(text);
645
+ return;
646
+ }
647
+ for (const [, res] of sseClients) {
648
+ res.write(formatSseData({ type: "message", content: text }));
649
+ }
650
+ }
651
+ //# sourceMappingURL=server.js.map