chapterhouse 0.9.1 → 0.10.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.
Files changed (112) hide show
  1. package/README.md +1 -1
  2. package/agents/korg.agent.md +20 -0
  3. package/dist/api/auth.js +11 -1
  4. package/dist/api/auth.test.js +29 -0
  5. package/dist/api/errors.js +23 -0
  6. package/dist/api/route-coverage.test.js +61 -21
  7. package/dist/api/routes/agents.js +472 -0
  8. package/dist/api/routes/memory.js +299 -0
  9. package/dist/api/routes/projects.js +170 -0
  10. package/dist/api/routes/sessions.js +347 -0
  11. package/dist/api/routes/system.js +82 -0
  12. package/dist/api/routes/wiki.js +455 -0
  13. package/dist/api/routes/wiki.test.js +49 -0
  14. package/dist/api/send-json.js +16 -0
  15. package/dist/api/send-json.test.js +18 -0
  16. package/dist/api/server-runtime.js +45 -3
  17. package/dist/api/server.js +34 -1764
  18. package/dist/api/server.test.js +239 -8
  19. package/dist/api/sse-hub.js +37 -0
  20. package/dist/cli.js +1 -1
  21. package/dist/config.js +151 -58
  22. package/dist/config.test.js +29 -0
  23. package/dist/copilot/okr-mapper.js +2 -11
  24. package/dist/copilot/orchestrator.js +358 -352
  25. package/dist/copilot/orchestrator.test.js +139 -4
  26. package/dist/copilot/prompt-date.js +2 -1
  27. package/dist/copilot/session-manager.js +25 -23
  28. package/dist/copilot/session-manager.test.js +35 -1
  29. package/dist/copilot/standup.js +2 -2
  30. package/dist/copilot/task-event-log.js +7 -1
  31. package/dist/copilot/task-event-log.test.js +13 -0
  32. package/dist/copilot/tools/agent.js +608 -0
  33. package/dist/copilot/tools/index.js +19 -0
  34. package/dist/copilot/tools/memory.js +678 -0
  35. package/dist/copilot/tools/models.js +2 -0
  36. package/dist/copilot/tools/okr.js +171 -0
  37. package/dist/copilot/tools/wiki.js +333 -0
  38. package/dist/copilot/tools-deps.js +4 -0
  39. package/dist/copilot/tools.agent.test.js +10 -8
  40. package/dist/copilot/tools.inventory.test.js +76 -0
  41. package/dist/copilot/tools.js +1 -1725
  42. package/dist/copilot/tools.okr.test.js +31 -0
  43. package/dist/copilot/tools.wiki.test.js +358 -6
  44. package/dist/copilot/turn-event-log.js +31 -4
  45. package/dist/copilot/turn-event-log.test.js +24 -2
  46. package/dist/copilot/workiq-installer.test.js +2 -2
  47. package/dist/daemon-install.js +3 -2
  48. package/dist/daemon.js +9 -17
  49. package/dist/integrations/ado-client.js +90 -9
  50. package/dist/integrations/ado-client.test.js +56 -0
  51. package/dist/integrations/team-push.js +1 -0
  52. package/dist/integrations/team-push.test.js +6 -0
  53. package/dist/integrations/teams-notify.js +1 -0
  54. package/dist/integrations/teams-notify.test.js +5 -0
  55. package/dist/memory/active-scope.test.js +0 -1
  56. package/dist/memory/checkpoint.js +89 -72
  57. package/dist/memory/checkpoint.test.js +23 -3
  58. package/dist/memory/eot.js +194 -89
  59. package/dist/memory/eot.test.js +186 -3
  60. package/dist/memory/hooks.js +2 -4
  61. package/dist/memory/housekeeping-scheduler.js +1 -1
  62. package/dist/memory/housekeeping-scheduler.test.js +1 -2
  63. package/dist/memory/housekeeping.js +100 -3
  64. package/dist/memory/housekeeping.test.js +33 -2
  65. package/dist/memory/reflect.test.js +2 -0
  66. package/dist/memory/scope-lock.js +26 -0
  67. package/dist/memory/scope-lock.test.js +118 -0
  68. package/dist/memory/scopes.test.js +0 -1
  69. package/dist/mode-context.js +58 -5
  70. package/dist/mode-context.test.js +68 -0
  71. package/dist/paths.js +1 -0
  72. package/dist/setup.js +3 -2
  73. package/dist/shared/api-schemas.js +48 -5
  74. package/dist/store/connection.js +96 -0
  75. package/dist/store/db.js +5 -1498
  76. package/dist/store/db.test.js +182 -1
  77. package/dist/store/migrations.js +460 -0
  78. package/dist/store/repositories/memory.js +281 -0
  79. package/dist/store/repositories/okr.js +3 -0
  80. package/dist/store/repositories/projects.js +5 -0
  81. package/dist/store/repositories/sessions.js +284 -0
  82. package/dist/store/repositories/wiki.js +60 -0
  83. package/dist/store/schema.js +501 -0
  84. package/dist/util/logger.js +3 -2
  85. package/dist/wiki/consolidation.js +50 -9
  86. package/dist/wiki/consolidation.test.js +45 -0
  87. package/dist/wiki/frontmatter.js +45 -14
  88. package/dist/wiki/frontmatter.test.js +26 -1
  89. package/dist/wiki/fs.js +16 -4
  90. package/dist/wiki/fs.test.js +84 -0
  91. package/dist/wiki/index-manager.js +30 -2
  92. package/dist/wiki/index-manager.test.js +43 -12
  93. package/dist/wiki/ingest.js +17 -1
  94. package/dist/wiki/lock.js +11 -1
  95. package/dist/wiki/log-manager.js +2 -7
  96. package/dist/wiki/migrate.js +44 -17
  97. package/dist/wiki/project-registry.js +10 -5
  98. package/dist/wiki/project-registry.test.js +14 -0
  99. package/dist/wiki/scheduler.js +1 -1
  100. package/dist/wiki/seed-team-wiki.js +2 -1
  101. package/dist/wiki/team-sync.js +31 -6
  102. package/dist/wiki/team-sync.test.js +81 -0
  103. package/package.json +1 -1
  104. package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
  105. package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
  106. package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
  107. package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
  108. package/web/dist/assets/index-CUm2Wbuh.js +250 -0
  109. package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
  110. package/web/dist/index.html +1 -1
  111. package/web/dist/assets/index-iQrv3lQN.js +0 -286
  112. package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
@@ -0,0 +1,472 @@
1
+ import { Router } from "express";
2
+ import { existsSync, readFileSync, statSync, writeFileSync } from "fs";
3
+ import { join, resolve, sep } from "path";
4
+ import { z } from "zod";
5
+ import { agentEventBus } from "../../copilot/agent-event-bus.js";
6
+ import { ensureDefaultAgents, getAgent, getAgentRegistry, isBuiltinAgent, loadAgents, notifyAgentSaved, parseAgentMd, parseAgentMdOrThrow, serializeAgentMd, SLUG_REGEX, } from "../../copilot/agents.js";
7
+ import { getLastRouteResult, interruptSessionTurn, reloadPersistentAgent } from "../../copilot/orchestrator.js";
8
+ import { getTaskLogEvents, subscribeTaskLog } from "../../copilot/task-event-log.js";
9
+ import { getRouterConfig, updateRouterConfig } from "../../copilot/router.js";
10
+ import { listSkills, removeSkill } from "../../copilot/skills.js";
11
+ import { config, persistModel } from "../../config.js";
12
+ import { AGENTS_DIR } from "../../paths.js";
13
+ import { AgentDetailSchema, AgentListSchema, AutoConfigSchema, ChannelListSchema, ListModelsResponseSchema, ModelResponseSchema, RemoveSkillResponseSchema, SetModelResponseSchema, SkillListSchema, StatusResponseSchema, WorkerDetailSchema, WorkerListSchema, } from "../../shared/api-schemas.js";
14
+ import { getDb, getTaskEvents } from "../../store/db.js";
15
+ import { childLogger } from "../../util/logger.js";
16
+ import { assertAgentEditAccess } from "../agent-edit-access.js";
17
+ import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, parseRequest } from "../errors.js";
18
+ import { sendJson } from "../send-json.js";
19
+ import { formatSseData } from "../sse.js";
20
+ import { setupSseCleanup } from "../server-runtime.js";
21
+ import { broadcastSsePayloadToSession } from "../sse-hub.js";
22
+ const log = childLogger("server");
23
+ const requiredString = (message) => z.string({ error: message }).trim().min(1, message);
24
+ const modelRequestSchema = z.object({
25
+ model: requiredString("Missing 'model' in request body"),
26
+ }).strict();
27
+ const autoRequestSchema = z.object({
28
+ enabled: z.boolean().optional(),
29
+ tierModels: z.object({
30
+ fast: requiredString("tierModels.fast must be a non-empty string").optional(),
31
+ standard: requiredString("tierModels.standard must be a non-empty string").optional(),
32
+ premium: requiredString("tierModels.premium must be a non-empty string").optional(),
33
+ }).strict().optional(),
34
+ cooldownMessages: z.number({ error: "cooldownMessages must be a number" })
35
+ .int("cooldownMessages must be an integer")
36
+ .min(0, "cooldownMessages must be a non-negative integer")
37
+ .optional(),
38
+ }).strict();
39
+ const agentPatchSchema = z.object({
40
+ name: requiredString("name must be a non-empty string").optional(),
41
+ description: requiredString("description must be a non-empty string").optional(),
42
+ model: requiredString("model must be a non-empty string").optional(),
43
+ systemPrompt: z.string().optional(),
44
+ }).strict();
45
+ function getLoadedAgents() {
46
+ let agents = getAgentRegistry();
47
+ if (agents.length === 0) {
48
+ ensureDefaultAgents();
49
+ agents = loadAgents();
50
+ }
51
+ return agents;
52
+ }
53
+ function getAgentLastEdited(slug) {
54
+ try {
55
+ return statSync(join(AGENTS_DIR, `${slug}.agent.md`)).mtime.toISOString();
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ export function createAgentsRouter(options) {
62
+ const { modeContext } = options;
63
+ const router = Router();
64
+ router.get("/api/agents", (_req, res) => {
65
+ const agents = getLoadedAgents()
66
+ .map((agent) => ({
67
+ name: agent.name,
68
+ slug: agent.slug,
69
+ description: agent.description,
70
+ model: agent.model,
71
+ scope: agent.scope ?? null,
72
+ type: isBuiltinAgent(agent.slug) ? "builtin" : "custom",
73
+ lastEdited: getAgentLastEdited(agent.slug),
74
+ }))
75
+ .sort((left, right) => {
76
+ if (left.type !== right.type) {
77
+ return left.type === "builtin" ? -1 : 1;
78
+ }
79
+ return left.name.localeCompare(right.name);
80
+ });
81
+ sendJson(res, AgentListSchema, agents);
82
+ });
83
+ router.get("/api/agents/:slug", (req, res, next) => {
84
+ const slugParam = req.params.slug;
85
+ const slug = Array.isArray(slugParam) ? slugParam[0] : slugParam;
86
+ if (slug === "stream") {
87
+ next();
88
+ return;
89
+ }
90
+ if (!SLUG_REGEX.test(slug)) {
91
+ res.status(400).json({ error: "Invalid slug" });
92
+ return;
93
+ }
94
+ getLoadedAgents();
95
+ const filePath = join(AGENTS_DIR, `${slug}.agent.md`);
96
+ const resolvedAgentsDir = resolve(AGENTS_DIR);
97
+ const resolvedFilePath = resolve(filePath);
98
+ if (!(resolvedFilePath === resolvedAgentsDir || resolvedFilePath.startsWith(`${resolvedAgentsDir}${sep}`))) {
99
+ res.status(403).json({ error: "Access denied" });
100
+ return;
101
+ }
102
+ let content;
103
+ try {
104
+ content = readFileSync(filePath, "utf-8");
105
+ }
106
+ catch {
107
+ res.status(404).json({ error: `Agent '${slug}' not found` });
108
+ return;
109
+ }
110
+ const agent = parseAgentMd(content, slug);
111
+ if (!agent) {
112
+ res.status(500).json({ error: `Agent '${slug}' could not be parsed` });
113
+ return;
114
+ }
115
+ const builtin = isBuiltinAgent(slug);
116
+ sendJson(res, AgentDetailSchema, {
117
+ name: agent.name,
118
+ slug: agent.slug,
119
+ description: agent.description,
120
+ model: agent.model,
121
+ scope: agent.scope ?? null,
122
+ persistent: agent.persistent ?? false,
123
+ skills: agent.skills ?? [],
124
+ type: builtin ? "builtin" : "custom",
125
+ editable: !builtin,
126
+ systemPrompt: agent.systemMessage,
127
+ });
128
+ });
129
+ router.patch("/api/agents/:slug", async (req, res) => {
130
+ const slugParam = req.params.slug;
131
+ const slug = Array.isArray(slugParam) ? slugParam[0] : slugParam;
132
+ if (!SLUG_REGEX.test(slug)) {
133
+ throw new BadRequestError("Invalid slug");
134
+ }
135
+ if (modeContext.isTeam() && req.user?.role !== "team-lead") {
136
+ throw new ForbiddenError("Forbidden");
137
+ }
138
+ const patch = parseRequest(agentPatchSchema, req.body ?? {});
139
+ const filePath = join(AGENTS_DIR, `${slug}.agent.md`);
140
+ const resolvedAgentsDir = resolve(AGENTS_DIR);
141
+ const resolvedFilePath = resolve(filePath);
142
+ if (!(resolvedFilePath === resolvedAgentsDir || resolvedFilePath.startsWith(`${resolvedAgentsDir}${sep}`))) {
143
+ throw new ForbiddenError("Access denied");
144
+ }
145
+ assertAgentEditAccess({ entraAuthEnabled: config.entraAuthEnabled }, req.user);
146
+ if (isBuiltinAgent(slug)) {
147
+ throw new ForbiddenError("Built-in agents are read-only");
148
+ }
149
+ if (!existsSync(filePath)) {
150
+ throw new NotFoundError("Agent not found");
151
+ }
152
+ try {
153
+ const current = parseAgentMdOrThrow(readFileSync(filePath, "utf-8"), slug);
154
+ const updated = {
155
+ ...current,
156
+ name: patch.name ?? current.name,
157
+ description: patch.description ?? current.description,
158
+ model: patch.model ?? current.model,
159
+ systemMessage: patch.systemPrompt ?? current.systemMessage,
160
+ };
161
+ const nextContent = serializeAgentMd(updated);
162
+ writeFileSync(filePath, nextContent, "utf-8");
163
+ await notifyAgentSaved(slug, updated);
164
+ sendJson(res, AgentDetailSchema, {
165
+ name: updated.name,
166
+ slug: updated.slug,
167
+ description: updated.description,
168
+ model: updated.model,
169
+ scope: updated.scope ?? null,
170
+ persistent: updated.persistent ?? false,
171
+ skills: updated.skills ?? [],
172
+ type: "custom",
173
+ editable: true,
174
+ systemPrompt: updated.systemMessage,
175
+ });
176
+ }
177
+ catch (error) {
178
+ const message = error instanceof Error ? error.message : String(error);
179
+ throw new BadRequestError(`Invalid content: ${message}`);
180
+ }
181
+ });
182
+ router.get("/api/channels", (_req, res) => {
183
+ const agents = getLoadedAgents();
184
+ const persistentAgentChannels = agents
185
+ .filter((agent) => agent.persistent)
186
+ .map((agent) => ({
187
+ key: `agent:${agent.slug}`,
188
+ label: `# ${agent.slug}`,
189
+ slug: agent.slug,
190
+ name: agent.name,
191
+ description: agent.description,
192
+ ...(agent.scope ? { scope: agent.scope } : {}),
193
+ }))
194
+ .sort((a, b) => a.label.localeCompare(b.label));
195
+ sendJson(res, ChannelListSchema, [
196
+ {
197
+ key: "default",
198
+ label: "# chapterhouse",
199
+ name: "Chapterhouse",
200
+ description: "Orchestrator",
201
+ },
202
+ ...persistentAgentChannels,
203
+ ]);
204
+ });
205
+ // List all workers: reads from SQLite agent_tasks (last 24 hours) so completed
206
+ // dispatched subagents remain visible after they finish, not just in-flight ones.
207
+ router.get("/api/workers", (_req, res) => {
208
+ const rows = getDb()
209
+ .prepare(`SELECT task_id, agent_slug, description, status, started_at, completed_at
210
+ FROM agent_tasks
211
+ WHERE started_at >= datetime('now', '-24 hours')
212
+ ORDER BY started_at DESC
213
+ LIMIT 100`)
214
+ .all();
215
+ const registry = getAgentRegistry();
216
+ sendJson(res, WorkerListSchema, rows.map((row) => {
217
+ const agent = registry.find((a) => a.slug === row.agent_slug);
218
+ return {
219
+ taskId: row.task_id,
220
+ slug: row.agent_slug,
221
+ name: agent?.name || row.agent_slug,
222
+ model: agent?.model || "unknown",
223
+ description: row.description,
224
+ status: row.status,
225
+ startedAt: row.started_at,
226
+ completedAt: row.completed_at,
227
+ };
228
+ }));
229
+ });
230
+ // Detailed worker row: include task status, description, and any captured result/output.
231
+ router.get("/api/workers/:taskId", (req, res) => {
232
+ const taskId = req.params.taskId;
233
+ const row = getDb()
234
+ .prepare(`SELECT task_id, agent_slug, description, prompt, status, result, started_at, completed_at
235
+ FROM agent_tasks WHERE task_id = ?`)
236
+ .get(taskId);
237
+ if (!row) {
238
+ throw new NotFoundError("Task not found");
239
+ }
240
+ const registry = getAgentRegistry();
241
+ const agent = registry.find((a) => a.slug === row.agent_slug);
242
+ sendJson(res, WorkerDetailSchema, {
243
+ taskId: row.task_id,
244
+ agentSlug: row.agent_slug,
245
+ name: agent?.name || row.agent_slug,
246
+ description: row.description,
247
+ prompt: row.prompt,
248
+ status: row.status,
249
+ result: row.result,
250
+ startedAt: row.started_at,
251
+ completedAt: row.completed_at,
252
+ });
253
+ });
254
+ const TERMINAL_TASK_STATUSES = new Set(["completed", "failed", "cancelled", "error"]);
255
+ // SSE stream for per-task tool-call activity.
256
+ // Replays buffered/persisted backlog on connect, then streams live events until
257
+ // the task reaches a terminal state.
258
+ router.get("/api/workers/:taskId/events", (req, res) => {
259
+ const taskId = req.params.taskId;
260
+ const taskRow = getDb()
261
+ .prepare(`SELECT task_id FROM agent_tasks WHERE task_id = ?`)
262
+ .get(taskId);
263
+ if (!taskRow) {
264
+ throw new NotFoundError("Task not found");
265
+ }
266
+ res.setHeader("Content-Type", "text/event-stream");
267
+ res.setHeader("Cache-Control", "no-cache");
268
+ res.setHeader("Connection", "keep-alive");
269
+ res.setHeader("X-Accel-Buffering", "no");
270
+ res.flushHeaders();
271
+ const rawLastId = req.headers["last-event-id"];
272
+ const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
273
+ ? parseInt(rawLastId.trim(), 10)
274
+ : undefined;
275
+ const sendEvent = (event) => {
276
+ let payload;
277
+ if (event.kind === "output_delta") {
278
+ payload = {
279
+ type: "output_delta",
280
+ taskId: event.taskId,
281
+ seq: event.seq,
282
+ text: event.text ?? "",
283
+ };
284
+ }
285
+ else if (event.kind === "task_status") {
286
+ payload = {
287
+ type: "task_status",
288
+ taskId: event.taskId,
289
+ seq: event.seq,
290
+ status: event.status ?? "running",
291
+ summary: event.summary,
292
+ };
293
+ }
294
+ else {
295
+ payload = {
296
+ taskId: event.taskId,
297
+ seq: event.seq,
298
+ ts: event.ts,
299
+ kind: event.kind,
300
+ toolName: event.toolName,
301
+ summary: event.summary,
302
+ };
303
+ }
304
+ res.write(`id: ${event.seq}\ndata: ${JSON.stringify(payload)}\n\n`);
305
+ };
306
+ let replayHighSeq = lastSeq;
307
+ if (lastSeq !== undefined) {
308
+ const bufferedEvents = getTaskLogEvents(taskId);
309
+ const oldestBufferedSeq = bufferedEvents[0]?.seq;
310
+ const bufferMissesRange = oldestBufferedSeq === undefined || oldestBufferedSeq > lastSeq + 1;
311
+ if (bufferMissesRange) {
312
+ const dbEvents = getTaskEvents(taskId, lastSeq);
313
+ for (const event of dbEvents) {
314
+ sendEvent(event);
315
+ if (replayHighSeq === undefined || event.seq > replayHighSeq) {
316
+ replayHighSeq = event.seq;
317
+ }
318
+ }
319
+ }
320
+ }
321
+ const replayEvents = getTaskLogEvents(taskId, replayHighSeq ?? 0);
322
+ const backlog = replayEvents.length > 0 ? replayEvents : getTaskEvents(taskId, replayHighSeq ?? 0);
323
+ for (const event of backlog) {
324
+ sendEvent(event);
325
+ }
326
+ const isTerminal = () => {
327
+ const row = getDb()
328
+ .prepare(`SELECT status FROM agent_tasks WHERE task_id = ?`)
329
+ .get(taskId);
330
+ return row ? TERMINAL_TASK_STATUSES.has(row.status) : true;
331
+ };
332
+ res.write(`: connected task=${taskId}\n\n`);
333
+ if (isTerminal()) {
334
+ res.end();
335
+ return;
336
+ }
337
+ const heartbeat = setInterval(() => {
338
+ res.write(`: keep-alive\n\n`);
339
+ }, 15_000);
340
+ setupSseCleanup((registerCleanup, cleanupNow) => {
341
+ registerCleanup(() => clearInterval(heartbeat));
342
+ registerCleanup(subscribeTaskLog(taskId, (event) => {
343
+ sendEvent(event);
344
+ // Close SSE when a terminal task_status event arrives
345
+ if (event.kind === "task_status" && event.status && TERMINAL_TASK_STATUSES.has(event.status)) {
346
+ cleanupNow();
347
+ res.end();
348
+ }
349
+ }));
350
+ registerCleanup(agentEventBus.subscribe("session:destroyed", (event) => {
351
+ if (event.sessionId === taskId && isTerminal()) {
352
+ cleanupNow();
353
+ res.end();
354
+ }
355
+ }));
356
+ registerCleanup(agentEventBus.subscribe("session:error", (event) => {
357
+ if (event.sessionId === taskId && isTerminal()) {
358
+ cleanupNow();
359
+ res.end();
360
+ }
361
+ }));
362
+ req.on("close", cleanupNow);
363
+ });
364
+ });
365
+ // ---------------------------------------------------------------------------
366
+ // Global agent EventBus SSE stream — thin pass-through of AgentEvents.
367
+ // Replaces the 4-second poll in the Workers frontend: clients subscribe once
368
+ // and receive push notifications on session:created / session:destroyed /
369
+ // session:error so the worker list updates in real time.
370
+ // Chat-specific events (delta, message, queued) are NOT emitted here.
371
+ // ---------------------------------------------------------------------------
372
+ router.get("/api/agents/stream", (req, res) => {
373
+ res.writeHead(200, {
374
+ "Content-Type": "text/event-stream",
375
+ "Cache-Control": "no-cache",
376
+ Connection: "keep-alive",
377
+ });
378
+ res.write(formatSseData({ type: "connected" }));
379
+ const heartbeat = setInterval(() => { res.write(`:ping\n\n`); }, 20_000);
380
+ setupSseCleanup((registerCleanup, cleanupNow) => {
381
+ registerCleanup(() => clearInterval(heartbeat));
382
+ registerCleanup(agentEventBus.subscribeAll((event) => {
383
+ res.write(formatSseData({ type: "agent_event", agentEvent: event }));
384
+ }));
385
+ req.on("close", cleanupNow);
386
+ });
387
+ });
388
+ router.post("/api/agents/:slug/reload-confirm", async (req, res) => {
389
+ const slugParam = Array.isArray(req.params.slug) ? req.params.slug[0] : req.params.slug;
390
+ const slug = slugParam?.trim() || "";
391
+ const agent = getAgent(slug);
392
+ if (!agent?.persistent) {
393
+ throw new NotFoundError("Agent not found");
394
+ }
395
+ const sessionKey = `agent:${agent.slug}`;
396
+ await interruptSessionTurn(sessionKey);
397
+ const reloadResult = await reloadPersistentAgent(agent.slug);
398
+ if (reloadResult === "reloaded") {
399
+ broadcastSsePayloadToSession(sessionKey, { type: "agent_reloaded", slug: agent.slug, reason: "confirmed_restart" });
400
+ }
401
+ sendJson(res, StatusResponseSchema, { status: "ok" });
402
+ });
403
+ router.get("/api/model", (_req, res) => {
404
+ sendJson(res, ModelResponseSchema, { model: config.copilotModel });
405
+ });
406
+ router.post("/api/model", async (req, res) => {
407
+ const { model } = parseRequest(modelRequestSchema, req.body);
408
+ try {
409
+ const { getClient } = await import("../../copilot/client.js");
410
+ const client = await getClient();
411
+ const models = await client.listModels();
412
+ const match = models.find((m) => m.id === model);
413
+ if (!match) {
414
+ const suggestions = models
415
+ .filter((m) => m.id.includes(model) || m.id.toLowerCase().includes(model.toLowerCase()))
416
+ .map((m) => m.id);
417
+ const hint = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : "";
418
+ throw new BadRequestError(`Model '${model}' not found.${hint}`);
419
+ }
420
+ }
421
+ catch (error) {
422
+ if (error instanceof BadRequestError) {
423
+ throw error;
424
+ }
425
+ // If we can't validate (client not ready), allow the switch — it'll fail on next message if wrong
426
+ }
427
+ const previous = config.copilotModel;
428
+ config.copilotModel = model;
429
+ persistModel(model);
430
+ sendJson(res, SetModelResponseSchema, { previous, current: model });
431
+ });
432
+ router.get("/api/models", async (_req, res) => {
433
+ try {
434
+ const { getClient } = await import("../../copilot/client.js");
435
+ const client = await getClient();
436
+ const models = await client.listModels();
437
+ sendJson(res, ListModelsResponseSchema, { models: models.map((m) => m.id), current: config.copilotModel });
438
+ }
439
+ catch (error) {
440
+ log.error({ err: error instanceof Error ? error.message : error }, "Failed to list models");
441
+ throw new InternalServerError();
442
+ }
443
+ });
444
+ router.get("/api/auto", (_req, res) => {
445
+ const routerConfig = getRouterConfig();
446
+ const lastRoute = getLastRouteResult();
447
+ sendJson(res, AutoConfigSchema, {
448
+ ...routerConfig,
449
+ currentModel: config.copilotModel,
450
+ lastRoute: lastRoute || null,
451
+ });
452
+ });
453
+ router.post("/api/auto", (req, res) => {
454
+ const body = parseRequest(autoRequestSchema, req.body ?? {});
455
+ const updated = updateRouterConfig(body);
456
+ log.info({ enabled: updated.enabled }, "Auto-routing updated");
457
+ sendJson(res, AutoConfigSchema, updated);
458
+ });
459
+ router.get("/api/skills", (_req, res) => {
460
+ sendJson(res, SkillListSchema, listSkills());
461
+ });
462
+ router.delete("/api/skills/:slug", (req, res) => {
463
+ const slug = Array.isArray(req.params.slug) ? req.params.slug[0] : req.params.slug;
464
+ const result = removeSkill(slug);
465
+ if (!result.ok) {
466
+ throw new BadRequestError(result.message);
467
+ }
468
+ sendJson(res, RemoveSkillResponseSchema, { ok: true, message: result.message });
469
+ });
470
+ return router;
471
+ }
472
+ //# sourceMappingURL=agents.js.map