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.
- package/LICENSE +23 -0
- package/README.md +363 -0
- package/agents/chapterhouse.agent.md +40 -0
- package/agents/coder.agent.md +38 -0
- package/agents/designer.agent.md +43 -0
- package/agents/general-purpose.agent.md +30 -0
- package/dist/api/auth.js +159 -0
- package/dist/api/auth.test.js +463 -0
- package/dist/api/errors.js +95 -0
- package/dist/api/errors.test.js +89 -0
- package/dist/api/rate-limit.js +85 -0
- package/dist/api/server-runtime.js +62 -0
- package/dist/api/server.js +651 -0
- package/dist/api/server.test.js +385 -0
- package/dist/api/sse.integration.test.js +270 -0
- package/dist/api/sse.js +7 -0
- package/dist/api/team.js +196 -0
- package/dist/api/team.test.js +466 -0
- package/dist/cli.js +102 -0
- package/dist/config.js +299 -0
- package/dist/config.phase3.test.js +20 -0
- package/dist/config.test.js +148 -0
- package/dist/copilot/agents.js +447 -0
- package/dist/copilot/agents.squad.test.js +72 -0
- package/dist/copilot/classifier.js +72 -0
- package/dist/copilot/client.js +32 -0
- package/dist/copilot/client.test.js +100 -0
- package/dist/copilot/episode-writer.js +219 -0
- package/dist/copilot/episode-writer.test.js +41 -0
- package/dist/copilot/mcp-config.js +22 -0
- package/dist/copilot/okr-mapper.js +196 -0
- package/dist/copilot/okr-mapper.test.js +114 -0
- package/dist/copilot/orchestrator.js +685 -0
- package/dist/copilot/orchestrator.test.js +523 -0
- package/dist/copilot/router.js +142 -0
- package/dist/copilot/router.test.js +119 -0
- package/dist/copilot/skills.js +125 -0
- package/dist/copilot/standup.js +138 -0
- package/dist/copilot/standup.test.js +132 -0
- package/dist/copilot/system-message.js +143 -0
- package/dist/copilot/system-message.test.js +17 -0
- package/dist/copilot/tools.js +1212 -0
- package/dist/copilot/tools.okr.test.js +260 -0
- package/dist/copilot/tools.squad.test.js +168 -0
- package/dist/daemon.js +235 -0
- package/dist/home-path.js +12 -0
- package/dist/home-path.test.js +11 -0
- package/dist/integrations/ado-analytics.js +178 -0
- package/dist/integrations/ado-analytics.test.js +284 -0
- package/dist/integrations/ado-client.js +227 -0
- package/dist/integrations/ado-client.test.js +176 -0
- package/dist/integrations/ado-schema.js +25 -0
- package/dist/integrations/ado-schema.test.js +39 -0
- package/dist/integrations/ado-skill.js +55 -0
- package/dist/integrations/report-generator.js +114 -0
- package/dist/integrations/report-generator.test.js +62 -0
- package/dist/integrations/team-push.js +144 -0
- package/dist/integrations/team-push.test.js +178 -0
- package/dist/integrations/teams-notify.js +108 -0
- package/dist/integrations/teams-notify.test.js +135 -0
- package/dist/paths.js +41 -0
- package/dist/setup.js +149 -0
- package/dist/shutdown-signals.js +13 -0
- package/dist/shutdown-signals.test.js +33 -0
- package/dist/squad/charter.js +108 -0
- package/dist/squad/charter.test.js +89 -0
- package/dist/squad/context.js +48 -0
- package/dist/squad/context.test.js +59 -0
- package/dist/squad/discovery.js +280 -0
- package/dist/squad/discovery.test.js +93 -0
- package/dist/squad/index.js +7 -0
- package/dist/squad/mirror.js +81 -0
- package/dist/squad/mirror.scheduler.js +78 -0
- package/dist/squad/mirror.scheduler.test.js +197 -0
- package/dist/squad/mirror.test.js +172 -0
- package/dist/squad/registry.js +162 -0
- package/dist/squad/registry.test.js +31 -0
- package/dist/squad/squad-coordinator-system-message.test.js +190 -0
- package/dist/squad/squad-session-routing.test.js +260 -0
- package/dist/squad/types.js +4 -0
- package/dist/status.js +25 -0
- package/dist/status.test.js +22 -0
- package/dist/store/db.js +290 -0
- package/dist/store/db.test.js +126 -0
- package/dist/store/squad-sessions.test.js +341 -0
- package/dist/test/setup-env.js +3 -0
- package/dist/update.js +112 -0
- package/dist/update.test.js +25 -0
- package/dist/wiki/context.js +138 -0
- package/dist/wiki/fs.js +195 -0
- package/dist/wiki/fs.test.js +39 -0
- package/dist/wiki/index-manager.js +359 -0
- package/dist/wiki/index-manager.test.js +129 -0
- package/dist/wiki/lock.js +26 -0
- package/dist/wiki/lock.test.js +30 -0
- package/dist/wiki/log-manager.js +20 -0
- package/dist/wiki/migrate.js +306 -0
- package/dist/wiki/okr.test.js +101 -0
- package/dist/wiki/path-utils.js +4 -0
- package/dist/wiki/path-utils.test.js +8 -0
- package/dist/wiki/seed-team-wiki.js +296 -0
- package/dist/wiki/seed-team-wiki.test.js +69 -0
- package/dist/wiki/team-sync.js +212 -0
- package/dist/wiki/team-sync.test.js +185 -0
- package/dist/wiki/templates/okr.js +98 -0
- package/package.json +72 -0
- package/skills/.gitkeep +0 -0
- package/skills/find-skills/SKILL.md +161 -0
- package/skills/find-skills/_meta.json +4 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/SKILL.md +42 -0
- package/skills/squad/SKILL.md +76 -0
- package/web/dist/assets/index-D-e7K-fT.css +10 -0
- package/web/dist/assets/index-DAg9IrpO.js +142 -0
- package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
- package/web/dist/chapterhouse-icon.png +0 -0
- package/web/dist/chapterhouse-icon.svg +42 -0
- package/web/dist/chapterhouse-logo.svg +46 -0
- 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
|