zcode-acp-server 0.46.6 → 0.47.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 (80) hide show
  1. package/README.md +1 -1
  2. package/dist/backend/listener.d.ts.map +1 -1
  3. package/dist/backend/listener.js +5 -1
  4. package/dist/backend/listener.js.map +1 -1
  5. package/dist/code-fingerprint.json +2 -2
  6. package/dist/config/auto-compact.d.ts.map +1 -1
  7. package/dist/config/auto-compact.js +4 -2
  8. package/dist/config/auto-compact.js.map +1 -1
  9. package/dist/config/model-cache.js +4 -1
  10. package/dist/config/model-cache.js.map +1 -1
  11. package/dist/config/options.js +4 -1
  12. package/dist/config/options.js.map +1 -1
  13. package/dist/goal-loop/driver.d.ts +14 -3
  14. package/dist/goal-loop/driver.d.ts.map +1 -1
  15. package/dist/goal-loop/driver.js +39 -17
  16. package/dist/goal-loop/driver.js.map +1 -1
  17. package/dist/handlers/replay.d.ts +39 -0
  18. package/dist/handlers/replay.d.ts.map +1 -1
  19. package/dist/handlers/replay.js +37 -1
  20. package/dist/handlers/replay.js.map +1 -1
  21. package/dist/handlers/session.d.ts.map +1 -1
  22. package/dist/handlers/session.js +53 -13
  23. package/dist/handlers/session.js.map +1 -1
  24. package/dist/remote/endpoint.d.ts.map +1 -1
  25. package/dist/remote/endpoint.js +6 -0
  26. package/dist/remote/endpoint.js.map +1 -1
  27. package/dist/remote/hub-server.d.ts.map +1 -1
  28. package/dist/remote/hub-server.js +102 -0
  29. package/dist/remote/hub-server.js.map +1 -1
  30. package/dist/remote/settings-endpoint.d.ts +45 -0
  31. package/dist/remote/settings-endpoint.d.ts.map +1 -0
  32. package/dist/remote/settings-endpoint.js +920 -0
  33. package/dist/remote/settings-endpoint.js.map +1 -0
  34. package/dist/settings/agents-config.d.ts +158 -0
  35. package/dist/settings/agents-config.d.ts.map +1 -0
  36. package/dist/settings/agents-config.js +598 -0
  37. package/dist/settings/agents-config.js.map +1 -0
  38. package/dist/settings/app-update.d.ts +193 -0
  39. package/dist/settings/app-update.d.ts.map +1 -0
  40. package/dist/settings/app-update.js +659 -0
  41. package/dist/settings/app-update.js.map +1 -0
  42. package/dist/settings/atomic-write.d.ts +106 -0
  43. package/dist/settings/atomic-write.d.ts.map +1 -0
  44. package/dist/settings/atomic-write.js +228 -0
  45. package/dist/settings/atomic-write.js.map +1 -0
  46. package/dist/settings/cli-config.d.ts +145 -0
  47. package/dist/settings/cli-config.d.ts.map +1 -0
  48. package/dist/settings/cli-config.js +320 -0
  49. package/dist/settings/cli-config.js.map +1 -0
  50. package/dist/settings/coding-plan.d.ts +106 -0
  51. package/dist/settings/coding-plan.d.ts.map +1 -0
  52. package/dist/settings/coding-plan.js +299 -0
  53. package/dist/settings/coding-plan.js.map +1 -0
  54. package/dist/settings/file-lock.d.ts +39 -0
  55. package/dist/settings/file-lock.d.ts.map +1 -0
  56. package/dist/settings/file-lock.js +282 -0
  57. package/dist/settings/file-lock.js.map +1 -0
  58. package/dist/settings/provider-config.d.ts +123 -0
  59. package/dist/settings/provider-config.d.ts.map +1 -0
  60. package/dist/settings/provider-config.js +311 -0
  61. package/dist/settings/provider-config.js.map +1 -0
  62. package/dist/settings/skills.d.ts +72 -0
  63. package/dist/settings/skills.d.ts.map +1 -0
  64. package/dist/settings/skills.js +245 -0
  65. package/dist/settings/skills.js.map +1 -0
  66. package/dist/settings/usage-stats.d.ts +74 -0
  67. package/dist/settings/usage-stats.d.ts.map +1 -0
  68. package/dist/settings/usage-stats.js +235 -0
  69. package/dist/settings/usage-stats.js.map +1 -0
  70. package/dist/translators/projection-differ.d.ts +12 -0
  71. package/dist/translators/projection-differ.d.ts.map +1 -1
  72. package/dist/translators/projection-differ.js +17 -0
  73. package/dist/translators/projection-differ.js.map +1 -1
  74. package/dist/utils.d.ts +14 -0
  75. package/dist/utils.d.ts.map +1 -1
  76. package/dist/utils.js +22 -0
  77. package/dist/utils.js.map +1 -1
  78. package/docs/ARCHITECTURE.md +65 -0
  79. package/docs/REMOTE-CLIENTS.md +252 -0
  80. package/package.json +1 -1
@@ -0,0 +1,920 @@
1
+ /**
2
+ * Settings API routes (ADR-0025), served on the bridge's loopback server and
3
+ * re-served by the hub behind the token.
4
+ *
5
+ * The handler is a single factory mounted in both places, so the two routes
6
+ * cannot drift. Everything here is a thin translation layer over
7
+ * `src/settings/*`: parse the request, call the module, map the outcome to a
8
+ * status code and an **effect class** the client can act on.
9
+ *
10
+ * Effect classes are the contract's most important field. A write answers
11
+ * `immediate` when the running backend picks it up by itself (the provider
12
+ * table is polled every ~1s; skills enablement is read live) and
13
+ * `needs-restart` when the agent read it once at startup (MCP servers, hooks,
14
+ * subagent markdown). A client that ignores this shows the user a toggle that
15
+ * appears to do nothing.
16
+ *
17
+ * Error mapping is deliberately coarse — the module's messages are already
18
+ * actionable, so they are passed through as `error` rather than re-coded. Only
19
+ * the validation failures that indicate a malformed request get a 400;
20
+ * everything else is a 500, because it means the environment is wrong.
21
+ */
22
+ import { loadAllModels } from "../config/options.js";
23
+ import { warn } from "../utils.js";
24
+ import { removeModel, readProviderConfig, updateProvider, upsertModel, } from "../settings/provider-config.js";
25
+ import { readCliConfig, readHooks, removeMcpServer, setHooksEnabled, setMcpServerEnabled, updateHookEntry, upsertMcpServer, } from "../settings/cli-config.js";
26
+ import { BUILT_IN_AGENTS, createAgent, deleteAgent, listAgents, setAgentEnabled, setBuiltInAgentModel, updateAgent, } from "../settings/agents-config.js";
27
+ import { copySkillToUser, deleteSkill, listSkills, setSkillEnabledByPath, } from "../settings/skills.js";
28
+ import { listBackups, readJsonDocument, writeJsonAtomic } from "../settings/atomic-write.js";
29
+ import { appBundlePath, appUpdateState, checkForAppUpdate, fetchReleaseManifest, installedAppVersion, isNewerVersion, resetAppUpdateStateForTest as resetAppUpdateForTest, startAppUpdate, } from "../settings/app-update.js";
30
+ import { readUsageStats } from "../settings/usage-stats.js";
31
+ import { codingPlanProviderIds, isCodingPlanProvider, markResetHistoryRead, readResetStatus, requestResetOpportunity, resolveResetAuthorization, useResetCard, } from "../settings/coding-plan.js";
32
+ import { queryQuota } from "../quota/index.js";
33
+ import { zcodeCliConfigPath, zcodePersonalProviderPath } from "../utils.js";
34
+ /** Body size cap. Settings payloads are small; hooks are the largest. */
35
+ const MAX_BODY_BYTES = 256 * 1024;
36
+ function sendJson(res, code, body) {
37
+ if (res.writableEnded)
38
+ return;
39
+ const payload = JSON.stringify(body);
40
+ res.writeHead(code, {
41
+ "Content-Type": "application/json",
42
+ "Content-Length": Buffer.byteLength(payload),
43
+ "Cache-Control": "no-store",
44
+ });
45
+ res.end(payload);
46
+ }
47
+ function sendError(res, code, error) {
48
+ sendJson(res, code, { ok: false, error });
49
+ }
50
+ async function readBody(req) {
51
+ const chunks = [];
52
+ let size = 0;
53
+ for await (const chunk of req) {
54
+ size += chunk.length;
55
+ if (size > MAX_BODY_BYTES)
56
+ throw new Error("request body too large");
57
+ chunks.push(chunk);
58
+ }
59
+ if (chunks.length === 0)
60
+ return {};
61
+ const text = Buffer.concat(chunks).toString("utf8");
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(text);
65
+ }
66
+ catch {
67
+ throw new Error("request body is not valid JSON");
68
+ }
69
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
70
+ throw new Error("request body must be a JSON object");
71
+ }
72
+ return parsed;
73
+ }
74
+ function str(body, key) {
75
+ const value = body[key];
76
+ return typeof value === "string" ? value : undefined;
77
+ }
78
+ function bool(body, key) {
79
+ const value = body[key];
80
+ return typeof value === "boolean" ? value : undefined;
81
+ }
82
+ function num(body, key) {
83
+ const value = body[key];
84
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
85
+ }
86
+ function messageOf(error) {
87
+ return error instanceof Error ? error.message : String(error);
88
+ }
89
+ /** Map a module error to a status code. */
90
+ function statusFor(error) {
91
+ const message = error instanceof Error ? error.message : String(error);
92
+ if (/is not valid JSON|not a JSON object|refusing to write|could not be read/u.test(message)) {
93
+ // The environment is broken (a damaged config file) — not the client's fault.
94
+ return 500;
95
+ }
96
+ return 400;
97
+ }
98
+ /**
99
+ * Build the settings router.
100
+ *
101
+ * `server` is the bridge to operate on. It is only needed by
102
+ * `/settings/backend/restart`, which has a backend to restart; the hub's
103
+ * machine-level mount passes none and answers 501 on that route. Passing it
104
+ * through the closure (rather than a module singleton) keeps two bridges in one
105
+ * process — a test, or a future multi-workspace mode — from disturbing each
106
+ * other's backend.
107
+ *
108
+ * Routes are matched on `url.pathname` after an optional `/api` prefix is
109
+ * stripped, so one factory serves `/settings/…` on the loopback server and
110
+ * `/api/settings/…` on the hub.
111
+ */
112
+ export function createSettingsHandler(server) {
113
+ return (req, res) => {
114
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
115
+ const path = url.pathname.replace(/^\/api/u, "").replace(/\/+$/u, "") || "/";
116
+ void route(req, res, url, path, server ?? null).catch((error) => {
117
+ warn(`settings: unhandled route error: ${error instanceof Error ? error.message : String(error)}`);
118
+ if (!res.writableEnded)
119
+ sendError(res, 500, "internal error");
120
+ });
121
+ };
122
+ }
123
+ async function route(req, res, url, path, server) {
124
+ const method = req.method ?? "GET";
125
+ // ---- reads ----
126
+ if (method === "GET") {
127
+ if (path === "/settings/all")
128
+ return void (await handleAll(res));
129
+ if (path === "/settings/models")
130
+ return void (await handleModels(res));
131
+ if (path === "/settings/skills")
132
+ return void (await handleSkills(res));
133
+ if (path === "/settings/mcp")
134
+ return void (await handleMcp(res));
135
+ if (path === "/settings/hooks")
136
+ return void (await handleHooks(res));
137
+ if (path === "/settings/agents")
138
+ return void (await handleAgents(res));
139
+ if (path === "/settings/backups")
140
+ return void (await handleBackups(res));
141
+ if (path === "/settings/usage")
142
+ return void (await handleUsage(res, url));
143
+ if (path === "/settings/quota")
144
+ return void (await handleQuota(res));
145
+ if (path === "/settings/reset-cards")
146
+ return void (await handleResetCards(res, url));
147
+ if (path === "/settings/pending-restart")
148
+ return void (await handlePendingRestart(res));
149
+ if (path === "/settings/app-update")
150
+ return void (await handleAppUpdate(res, url));
151
+ return sendError(res, 404, "not found");
152
+ }
153
+ // ---- writes ----
154
+ if (method === "PUT" || method === "POST" || method === "DELETE") {
155
+ // A body that cannot be parsed is the CLIENT's fault — 400, not the 500
156
+ // the outer catch would produce. Everything downstream goes through
157
+ // `guard`, which maps module errors to their own status.
158
+ let body = {};
159
+ if (method !== "DELETE") {
160
+ try {
161
+ body = await readBody(req);
162
+ }
163
+ catch (error) {
164
+ sendError(res, 400, error instanceof Error ? error.message : String(error));
165
+ return;
166
+ }
167
+ }
168
+ if (method === "PUT" && path.startsWith("/settings/providers/")) {
169
+ const id = decodeURIComponent(path.slice("/settings/providers/".length));
170
+ return void (await handleUpdateProvider(res, id, body));
171
+ }
172
+ if (method === "POST" && path === "/settings/models") {
173
+ return void (await handleUpsertModel(res, body));
174
+ }
175
+ if (method === "DELETE" && path.startsWith("/settings/models/")) {
176
+ const rest = path.slice("/settings/models/".length);
177
+ const sep = rest.indexOf("/");
178
+ if (sep <= 0)
179
+ return sendError(res, 400, "expected /settings/models/{providerId}/{modelId}");
180
+ const providerId = decodeURIComponent(rest.slice(0, sep));
181
+ const modelId = decodeURIComponent(rest.slice(sep + 1));
182
+ return void (await handleRemoveModel(res, providerId, modelId));
183
+ }
184
+ if (method === "POST" && path === "/settings/skills/enable") {
185
+ return void (await handleSkillEnable(res, body));
186
+ }
187
+ if (method === "POST" && path === "/settings/skills/copy-to-user") {
188
+ return void (await handleSkillCopy(res, body));
189
+ }
190
+ if (method === "DELETE" && path.startsWith("/settings/skills/")) {
191
+ const skillPath = decodeURIComponent(path.slice("/settings/skills/".length));
192
+ return void (await handleSkillDelete(res, skillPath));
193
+ }
194
+ if (method === "PUT" && path.startsWith("/settings/mcp/")) {
195
+ const name = decodeURIComponent(path.slice("/settings/mcp/".length));
196
+ return void (await handleMcpUpsert(res, name, body));
197
+ }
198
+ if (method === "DELETE" && path.startsWith("/settings/mcp/")) {
199
+ const name = decodeURIComponent(path.slice("/settings/mcp/".length));
200
+ return void (await guard(res, async () => {
201
+ await removeMcpServer(name);
202
+ return { ok: true, effect: "needs-restart" };
203
+ }));
204
+ }
205
+ if (method === "POST" && path === "/settings/mcp/enable") {
206
+ return void (await handleMcpEnable(res, body));
207
+ }
208
+ if (method === "PUT" && path.startsWith("/settings/hooks/")) {
209
+ return void (await handleHookUpdate(res, path.slice("/settings/hooks/".length), body));
210
+ }
211
+ if (method === "POST" && path === "/settings/hooks/enabled") {
212
+ const enabled = bool(body, "enabled");
213
+ if (enabled === undefined)
214
+ return sendError(res, 400, "enabled (boolean) is required");
215
+ return void (await guard(res, async () => {
216
+ await setHooksEnabled(enabled);
217
+ return { ok: true, effect: "needs-restart" };
218
+ }));
219
+ }
220
+ if ((method === "POST" || method === "PUT") &&
221
+ path.startsWith("/settings/agents/") &&
222
+ path.endsWith("/enable")) {
223
+ const name = decodeURIComponent(path.slice("/settings/agents/".length, -"/enable".length));
224
+ return void (await handleAgentEnable(res, name, body));
225
+ }
226
+ if (method === "PUT" && path.startsWith("/settings/agents/")) {
227
+ const name = decodeURIComponent(path.slice("/settings/agents/".length));
228
+ return void (await handleAgentUpsert(res, name, body));
229
+ }
230
+ if (method === "DELETE" && path.startsWith("/settings/agents/")) {
231
+ const name = decodeURIComponent(path.slice("/settings/agents/".length));
232
+ return void (await guard(res, async () => {
233
+ await deleteAgent(name);
234
+ return { ok: true, effect: "needs-restart" };
235
+ }));
236
+ }
237
+ if (method === "POST" && path === "/settings/backups/restore") {
238
+ return void (await handleBackupRestore(res, body));
239
+ }
240
+ if (method === "POST" && path === "/settings/reset-cards/use") {
241
+ return void (await handleResetUse(res, body));
242
+ }
243
+ if (method === "POST" && path === "/settings/reset-cards/opportunity") {
244
+ return void (await handleResetOpportunity(res, body));
245
+ }
246
+ if (method === "POST" && path === "/settings/reset-cards/history-read") {
247
+ return void (await handleResetHistoryRead(res, body));
248
+ }
249
+ if (method === "POST" && path === "/settings/backend/restart") {
250
+ return void (await handleBackendRestart(res, server));
251
+ }
252
+ if (method === "POST" && path === "/settings/app-update/install") {
253
+ return void (await handleAppUpdateInstall(res, body));
254
+ }
255
+ return sendError(res, 404, "not found");
256
+ }
257
+ sendError(res, 405, "method not allowed");
258
+ }
259
+ /**
260
+ * Run a write and translate its failure into a status code.
261
+ *
262
+ * A `needs-restart` effect also arms the pending flag, which is what
263
+ * `GET /settings/pending-restart` reports and what the backend restart clears.
264
+ */
265
+ async function guard(res, fn) {
266
+ try {
267
+ const body = await fn();
268
+ if (body.effect === "needs-restart")
269
+ pendingRestartWrites += 1;
270
+ sendJson(res, 200, body);
271
+ }
272
+ catch (error) {
273
+ sendError(res, statusFor(error), error instanceof Error ? error.message : String(error));
274
+ }
275
+ }
276
+ /**
277
+ * How many `needs-restart` writes this process has recorded since the last
278
+ * backend restart. Process-scoped on purpose: it answers "does THIS bridge need
279
+ * a restart", and a restart of one bridge says nothing about another's.
280
+ */
281
+ let pendingRestartWrites = 0;
282
+ /** Test seam: drop the counter so a suite starts from a known state. */
283
+ export function resetPendingRestartForTest() {
284
+ pendingRestartWrites = 0;
285
+ }
286
+ async function handlePendingRestart(res) {
287
+ await guard(res, async () => ({
288
+ ok: true,
289
+ pendingRestart: pendingRestartWrites > 0,
290
+ writes: pendingRestartWrites,
291
+ }));
292
+ }
293
+ // ---------- reads ----------
294
+ async function handleAll(res) {
295
+ // Local config files are the environment: a failure here means the machine's
296
+ // ZCode install is broken, so the whole request fails rather than returning a
297
+ // half-populated screen.
298
+ let models;
299
+ let skills;
300
+ let mcp;
301
+ let hooks;
302
+ let agents;
303
+ try {
304
+ [models, skills, mcp, hooks, agents] = await Promise.all([
305
+ handleModelsPayload(),
306
+ listSkills(),
307
+ readMcpView(),
308
+ readHooks(),
309
+ listAgents(),
310
+ ]);
311
+ }
312
+ catch (error) {
313
+ sendError(res, 500, error instanceof Error ? error.message : String(error));
314
+ return;
315
+ }
316
+ // The two optional sections degrade independently: no local agent database and
317
+ // no decryptable credential store are normal states on a machine that never
318
+ // ran an agent or never linked a coding plan, and a first-screen render must
319
+ // not fail because one of them is unavailable.
320
+ const usage = await readUsageStats("7d").catch(() => ({
321
+ available: false,
322
+ range: "7d",
323
+ summary: { totalTokens: 0, requestCount: 0, models: 0 },
324
+ models: [],
325
+ daily: [],
326
+ }));
327
+ // Reset cards are NOT fetched here: `readResetStatus` needs a providerId and
328
+ // makes a network call per provider, which a first-screen snapshot must not
329
+ // do. The client gets eligibility only, and calls
330
+ // `GET /settings/reset-cards?providerId=…` for the cards themselves.
331
+ const resetCards = await resetCardEligibility();
332
+ sendJson(res, 200, { ok: true, models, skills, mcp, hooks, agents, usage, resetCards });
333
+ }
334
+ /**
335
+ * Which providers could spend a reset card, and whether the machine has a
336
+ * decryptable credential store to try with.
337
+ *
338
+ * Deliberately offline and cheap: eligibility is a provider-id shape check, and
339
+ * credential presence is the decrypt step. A provider listed here with
340
+ * `credentials: false` means the section should render disabled rather than
341
+ * error, because the store is encrypted per machine and its absence is normal.
342
+ *
343
+ * Only `credentials_unavailable` (the store is missing or unreadable) counts as
344
+ * "no credentials" — a store that decrypts but lacks a JWT is a linked-account
345
+ * problem the status call will report precisely, so the snapshot says
346
+ * `credentials: true` and lets that call answer.
347
+ */
348
+ async function resetCardEligibility() {
349
+ const providers = codingPlanProviderIds();
350
+ try {
351
+ await resolveResetAuthorization(providers[0]);
352
+ return { providers, credentials: true };
353
+ }
354
+ catch (error) {
355
+ const reason = messageOf(error);
356
+ return { providers, credentials: reason !== "credentials_unavailable", reason };
357
+ }
358
+ }
359
+ async function handleModelsPayload() {
360
+ const available = loadAllModels();
361
+ const personal = await readProviderConfig();
362
+ const rules = personal.config.providerConfigRules.providerRules;
363
+ return {
364
+ available,
365
+ providers: rules.map((rule) => ({
366
+ providerId: rule.providerId,
367
+ providerName: rule.providerName,
368
+ enabled: rule.enabled !== false,
369
+ modelIds: rule.config?.personalModelIds ?? [],
370
+ baseUrl: rule.config?.api?.baseUrl,
371
+ apiType: rule.config?.api?.type,
372
+ })),
373
+ modelRules: [
374
+ ...personal.config.modelConfigRules.providerModelRules,
375
+ ...personal.config.modelConfigRules.manualProviderModelRules,
376
+ ].map((rule) => ({
377
+ providerId: rule.providerId,
378
+ modelId: rule.modelId,
379
+ enabled: rule.config.enabled !== false,
380
+ contextWindow: rule.config.properties?.contextWindow,
381
+ reasoningLevels: rule.config.optionSpecs?.reasoningLevel?.values,
382
+ })),
383
+ };
384
+ }
385
+ async function handleModels(res) {
386
+ await guard(res, async () => ({ ok: true, models: await handleModelsPayload() }));
387
+ }
388
+ async function handleSkills(res) {
389
+ await guard(res, async () => ({ ok: true, skills: await listSkills() }));
390
+ }
391
+ async function readMcpView() {
392
+ const doc = await readCliConfig();
393
+ const servers = doc.mcp?.servers ?? {};
394
+ return {
395
+ servers: Object.entries(servers).map(([name, config]) => ({
396
+ name,
397
+ type: config.type ?? "stdio",
398
+ command: config.command,
399
+ args: config.args,
400
+ url: config.url,
401
+ env: config.env,
402
+ headers: config.headers,
403
+ enabled: config.enabled !== false,
404
+ })),
405
+ };
406
+ }
407
+ async function handleMcp(res) {
408
+ await guard(res, async () => ({ ok: true, mcp: await readMcpView() }));
409
+ }
410
+ async function handleHooks(res) {
411
+ await guard(res, async () => {
412
+ const hooks = await readHooks();
413
+ return {
414
+ ok: true,
415
+ hooks,
416
+ // Surface the gate explicitly: hooks are inert without it, and a client
417
+ // that only shows the tree makes a working config look broken.
418
+ enabled: hooks.enabled === true,
419
+ };
420
+ });
421
+ }
422
+ async function handleAgents(res) {
423
+ await guard(res, async () => ({
424
+ ok: true,
425
+ agents: await listAgents(),
426
+ builtIn: [...BUILT_IN_AGENTS],
427
+ }));
428
+ }
429
+ async function handleBackups(res) {
430
+ await guard(res, async () => {
431
+ const [provider, cli] = await Promise.all([
432
+ listBackups(zcodePersonalProviderPath()),
433
+ listBackups(zcodeCliConfigPath()),
434
+ ]);
435
+ return { ok: true, backups: { providerConfig: provider, cliConfig: cli } };
436
+ });
437
+ }
438
+ // ---------- writes ----------
439
+ async function handleUpdateProvider(res, providerId, body) {
440
+ const enabled = bool(body, "enabled");
441
+ const providerName = str(body, "providerName");
442
+ if (enabled === undefined && providerName === undefined) {
443
+ sendError(res, 400, "nothing to update — send enabled and/or providerName");
444
+ return;
445
+ }
446
+ await guard(res, async () => {
447
+ await updateProvider(providerId, {
448
+ ...(enabled !== undefined ? { enabled } : {}),
449
+ ...(providerName !== undefined ? { providerName } : {}),
450
+ });
451
+ return { ok: true, effect: "immediate" };
452
+ });
453
+ }
454
+ async function handleUpsertModel(res, body) {
455
+ const providerId = str(body, "providerId");
456
+ const modelId = str(body, "modelId");
457
+ if (!providerId || !modelId) {
458
+ sendError(res, 400, "providerId and modelId are required");
459
+ return;
460
+ }
461
+ await guard(res, async () => {
462
+ await upsertModel(providerId, modelId, {
463
+ ...(bool(body, "enabled") !== undefined ? { enabled: bool(body, "enabled") } : {}),
464
+ ...(num(body, "contextWindow") !== undefined
465
+ ? { properties: { contextWindow: num(body, "contextWindow") } }
466
+ : {}),
467
+ ...(Array.isArray(body["reasoningLevels"])
468
+ ? { optionSpecs: { reasoningLevel: { values: body["reasoningLevels"] } } }
469
+ : {}),
470
+ });
471
+ return { ok: true, effect: "immediate" };
472
+ });
473
+ }
474
+ async function handleRemoveModel(res, providerId, modelId) {
475
+ await guard(res, async () => {
476
+ await removeModel(providerId, modelId);
477
+ return { ok: true, effect: "immediate" };
478
+ });
479
+ }
480
+ async function handleSkillEnable(res, body) {
481
+ const skillPath = str(body, "path");
482
+ const enable = bool(body, "enable");
483
+ if (!skillPath || enable === undefined) {
484
+ sendError(res, 400, "path and enable are required");
485
+ return;
486
+ }
487
+ await guard(res, async () => {
488
+ await setSkillEnabledByPath(skillPath, enable);
489
+ return { ok: true, effect: "immediate" };
490
+ });
491
+ }
492
+ async function handleSkillCopy(res, body) {
493
+ const skillPath = str(body, "path");
494
+ if (!skillPath) {
495
+ sendError(res, 400, "path is required");
496
+ return;
497
+ }
498
+ await guard(res, async () => {
499
+ const target = await copySkillToUser(skillPath);
500
+ return { ok: true, effect: "immediate", path: target };
501
+ });
502
+ }
503
+ async function handleSkillDelete(res, skillPath) {
504
+ await guard(res, async () => {
505
+ await deleteSkill(skillPath);
506
+ return { ok: true, effect: "immediate" };
507
+ });
508
+ }
509
+ async function handleMcpUpsert(res, name, body) {
510
+ await guard(res, async () => {
511
+ await upsertMcpServer(name, body);
512
+ return { ok: true, effect: "needs-restart" };
513
+ });
514
+ }
515
+ async function handleMcpEnable(res, body) {
516
+ const name = str(body, "name");
517
+ const enabled = bool(body, "enabled");
518
+ if (!name || enabled === undefined) {
519
+ sendError(res, 400, "name and enabled are required");
520
+ return;
521
+ }
522
+ await guard(res, async () => {
523
+ await setMcpServerEnabled(name, enabled);
524
+ return { ok: true, effect: "needs-restart" };
525
+ });
526
+ }
527
+ async function handleHookUpdate(res, rest, body) {
528
+ const parts = rest.split("/");
529
+ if (parts.length !== 2) {
530
+ sendError(res, 400, "expected /settings/hooks/{event}/{matcherIndex}/{hookIndex}");
531
+ return;
532
+ }
533
+ const event = decodeURIComponent(parts[0]);
534
+ const matcherIndex = Number(parts[1]);
535
+ const hookIndex = num(body, "hookIndex");
536
+ if (!Number.isInteger(matcherIndex) || hookIndex === undefined || !Number.isInteger(hookIndex)) {
537
+ sendError(res, 400, "matcherIndex (path) and hookIndex (body) must be integers");
538
+ return;
539
+ }
540
+ await guard(res, async () => {
541
+ await updateHookEntry(event, matcherIndex, hookIndex, {
542
+ ...(str(body, "command") !== undefined ? { command: str(body, "command") } : {}),
543
+ ...(bool(body, "enabled") !== undefined ? { enabled: bool(body, "enabled") } : {}),
544
+ ...(num(body, "timeoutMs") !== undefined ? { timeoutMs: num(body, "timeoutMs") } : {}),
545
+ ...(num(body, "timeout") !== undefined ? { timeout: num(body, "timeout") } : {}),
546
+ });
547
+ return { ok: true, effect: "needs-restart" };
548
+ });
549
+ }
550
+ async function handleAgentUpsert(res, name, body) {
551
+ await guard(res, async () => {
552
+ // A built-in agent has no file, so "PUT" on one means a model override.
553
+ if (BUILT_IN_AGENTS.includes(name)) {
554
+ // Presence, not truthiness: `null` is the documented way to CLEAR an
555
+ // override, and reading through str() would map it to `undefined` and
556
+ // make the clear path unreachable.
557
+ const hasProvider = "providerId" in body;
558
+ const hasModel = "modelId" in body;
559
+ const providerId = str(body, "providerId");
560
+ const modelId = str(body, "modelId");
561
+ const clearing = hasProvider && hasModel && providerId === undefined && modelId === undefined;
562
+ if (!clearing && (providerId === undefined || modelId === undefined)) {
563
+ throw new Error("a built-in agent override needs providerId and modelId; send nulls to clear");
564
+ }
565
+ if (clearing) {
566
+ await setBuiltInAgentModel(name, undefined);
567
+ return { ok: true, effect: "needs-restart" };
568
+ }
569
+ const reasoningLevel = str(body, "reasoningLevel");
570
+ await setBuiltInAgentModel(name, {
571
+ providerId: providerId,
572
+ modelId: modelId,
573
+ ...(reasoningLevel !== undefined ? { thoughtLevel: reasoningLevel } : {}),
574
+ });
575
+ return { ok: true, effect: "needs-restart" };
576
+ }
577
+ const description = str(body, "description");
578
+ const patch = {
579
+ ...(description !== undefined ? { description } : {}),
580
+ ...(str(body, "color") !== undefined ? { color: str(body, "color") } : {}),
581
+ ...("model" in body ? { model: body["model"] ?? null } : {}),
582
+ ...("thoughtLevel" in body
583
+ ? { thoughtLevel: body["thoughtLevel"] ?? null }
584
+ : {}),
585
+ };
586
+ let agent;
587
+ try {
588
+ agent = await updateAgent(name, patch);
589
+ }
590
+ catch (error) {
591
+ const message = error instanceof Error ? error.message : String(error);
592
+ if (!/does not exist/u.test(message))
593
+ throw error;
594
+ // Creating is the natural reading of a PUT on a missing agent.
595
+ if (description === undefined)
596
+ throw new Error("description is required to create an agent");
597
+ agent = await createAgent({
598
+ name,
599
+ description,
600
+ ...(str(body, "color") !== undefined ? { color: str(body, "color") } : {}),
601
+ ...(typeof body["model"] === "string" ? { model: body["model"] } : {}),
602
+ ...(typeof body["thoughtLevel"] === "string" ? { thoughtLevel: body["thoughtLevel"] } : {}),
603
+ });
604
+ }
605
+ return { ok: true, effect: "needs-restart", agent };
606
+ });
607
+ }
608
+ async function handleAgentEnable(res, name, body) {
609
+ const enable = bool(body, "enable");
610
+ if (enable === undefined) {
611
+ sendError(res, 400, "enable (boolean) is required");
612
+ return;
613
+ }
614
+ await guard(res, async () => {
615
+ await setAgentEnabled(name, enable);
616
+ return { ok: true, effect: "needs-restart" };
617
+ });
618
+ }
619
+ async function handleBackupRestore(res, body) {
620
+ const which = str(body, "file");
621
+ const backupPath = str(body, "path");
622
+ if (which !== "providerConfig" && which !== "cliConfig") {
623
+ sendError(res, 400, "file must be 'providerConfig' or 'cliConfig'");
624
+ return;
625
+ }
626
+ if (!backupPath) {
627
+ sendError(res, 400, "path is required");
628
+ return;
629
+ }
630
+ const target = which === "providerConfig" ? zcodePersonalProviderPath() : zcodeCliConfigPath();
631
+ // The backup must be one of THIS file's backups — refusing an arbitrary path
632
+ // keeps the restore endpoint from becoming a general file-write primitive.
633
+ if (!backupPath.startsWith(`${target}.bak-`)) {
634
+ sendError(res, 400, "path is not a backup of the requested file");
635
+ return;
636
+ }
637
+ await guard(res, async () => {
638
+ const doc = await readJsonDocument(backupPath);
639
+ if (doc === null)
640
+ throw new Error("backup is not a readable JSON object");
641
+ await writeJsonAtomic(target, () => doc, { skipBackup: false });
642
+ // The effect class follows the FILE, not the operation: provider_config is
643
+ // absorbed by the backend's ~1s poll, while cli/config.json carries MCP
644
+ // servers, hooks and plugin enablement, which the agent reads once at start.
645
+ // Reporting `immediate` for a cli-config restore would leave the client
646
+ // never prompting for the restart that change actually needs.
647
+ const effect = which === "providerConfig" ? "immediate" : "needs-restart";
648
+ return { ok: true, effect, restored: backupPath };
649
+ });
650
+ }
651
+ // ---------- usage / quota / reset cards ----------
652
+ const USAGE_RANGES = new Set(["7d", "30d", "all"]);
653
+ async function handleUsage(res, url) {
654
+ const requested = url.searchParams.get("range") ?? "7d";
655
+ if (!USAGE_RANGES.has(requested)) {
656
+ sendError(res, 400, "range must be one of 7d, 30d, all");
657
+ return;
658
+ }
659
+ await guard(res, async () => ({
660
+ ok: true,
661
+ usage: await readUsageStats(requested),
662
+ }));
663
+ }
664
+ async function handleQuota(res) {
665
+ // Quota is account-level and already has a full result model; it is passed
666
+ // through so a settings screen and the CLI card render the same data.
667
+ await guard(res, async () => ({ ok: true, quota: await queryQuota() }));
668
+ }
669
+ /**
670
+ * Read the reset-card inventory.
671
+ *
672
+ * The `nonce` in the response is what `use` requires. It is generated here, per
673
+ * read, so a client cannot spend a card from a screen it loaded an hour ago
674
+ * without first refreshing.
675
+ */
676
+ async function handleResetCards(res, url) {
677
+ const providerId = url.searchParams.get("providerId") ?? "";
678
+ if (!isCodingPlanProvider(providerId)) {
679
+ sendError(res, 403, "reset cards require an account coding-plan provider id");
680
+ return;
681
+ }
682
+ await guard(res, async () => {
683
+ const resetCards = await readResetStatus(providerId);
684
+ // Register the nonce so the matching `use` call can validate against it.
685
+ rememberResetNonce(providerId, resetCards.nonce);
686
+ return { ok: true, resetCards };
687
+ });
688
+ }
689
+ /**
690
+ * Spend one reset card.
691
+ *
692
+ * The nonce is checked against the one issued by the most recent status read
693
+ * for this provider, and the idempotency key makes a retry safe. Both are
694
+ * required: without the nonce a stale screen could burn a card, and without the
695
+ * key a dropped response would let a retry burn a second one.
696
+ */
697
+ async function handleResetUse(res, body) {
698
+ const providerId = str(body, "providerId");
699
+ const resetType = str(body, "resetType");
700
+ const nonce = str(body, "nonce");
701
+ const idempotencyKey = str(body, "idempotencyKey");
702
+ if (!providerId || !isCodingPlanProvider(providerId)) {
703
+ sendError(res, 403, "reset cards require an account coding-plan provider id");
704
+ return;
705
+ }
706
+ if (resetType !== "FIVE_HOUR" && resetType !== "WEEK") {
707
+ sendError(res, 400, "resetType must be FIVE_HOUR or WEEK");
708
+ return;
709
+ }
710
+ if (!nonce || !idempotencyKey) {
711
+ sendError(res, 400, "nonce and idempotencyKey are required");
712
+ return;
713
+ }
714
+ if (!consumeNonce(providerId, nonce)) {
715
+ sendError(res, 409, "nonce is stale — refresh the reset card status and try again");
716
+ return;
717
+ }
718
+ await guard(res, async () => {
719
+ const result = await useResetCard(providerId, resetType, idempotencyKey);
720
+ return { ok: true, ...result };
721
+ });
722
+ }
723
+ async function handleResetOpportunity(res, body) {
724
+ const providerId = str(body, "providerId");
725
+ const idempotencyKey = str(body, "idempotencyKey");
726
+ if (!providerId || !isCodingPlanProvider(providerId)) {
727
+ sendError(res, 403, "reset cards require an account coding-plan provider id");
728
+ return;
729
+ }
730
+ if (!idempotencyKey) {
731
+ sendError(res, 400, "idempotencyKey is required");
732
+ return;
733
+ }
734
+ await guard(res, async () => ({
735
+ ok: true,
736
+ opportunity: await requestResetOpportunity(providerId, idempotencyKey),
737
+ }));
738
+ }
739
+ async function handleResetHistoryRead(res, body) {
740
+ const providerId = str(body, "providerId");
741
+ if (!providerId || !isCodingPlanProvider(providerId)) {
742
+ sendError(res, 403, "reset cards require an account coding-plan provider id");
743
+ return;
744
+ }
745
+ await guard(res, async () => {
746
+ await markResetHistoryRead(providerId);
747
+ return { ok: true };
748
+ });
749
+ }
750
+ /**
751
+ * Nonces issued by the most recent status read, one per provider.
752
+ *
753
+ * In-memory and single-use: a restart clears them, which only costs the user a
754
+ * refresh. There is deliberately no persistence — a nonce that survived a
755
+ * restart would outlive the status it vouched for.
756
+ */
757
+ const issuedNonces = new Map();
758
+ /** Record the nonce a status read just issued. */
759
+ export function rememberResetNonce(providerId, nonce) {
760
+ issuedNonces.set(providerId, nonce);
761
+ }
762
+ /** Validate and burn a nonce. False when it is missing, stale, or reused. */
763
+ function consumeNonce(providerId, nonce) {
764
+ if (issuedNonces.get(providerId) !== nonce)
765
+ return false;
766
+ issuedNonces.delete(providerId);
767
+ return true;
768
+ }
769
+ /**
770
+ * Restart the bridge's backend so a `needs-restart` write takes effect.
771
+ *
772
+ * The shape is copied from `applySandboxFlip()`: cancel in-flight turns, close
773
+ * the subprocess, and let the next `ensureBackend()` respawn it. The response
774
+ * reports how many conversations were interrupted so the client can warn
775
+ * BEFORE the user commits to it.
776
+ *
777
+ * Only the loopback mount can do this — the hub proxies it per instance, so a
778
+ * client must name the bridge it means to disturb. The hub's own
779
+ * `/api/settings/*` mount has no backend to restart.
780
+ */
781
+ async function handleBackendRestart(res, server) {
782
+ if (!server) {
783
+ sendError(res, 501, "this mount has no backend to restart");
784
+ return;
785
+ }
786
+ const cancelled = server.pendingTurns.size;
787
+ server.cancelAllPendingTurns();
788
+ try {
789
+ await server.backend?.close();
790
+ }
791
+ catch (error) {
792
+ warn(`settings: backend restart close failed: ${error instanceof Error ? error.message : String(error)}`);
793
+ }
794
+ // The respawned backend reads MCP servers, hooks and subagent markdown at
795
+ // startup, so everything this process was waiting on is now applied.
796
+ pendingRestartWrites = 0;
797
+ sendJson(res, 200, { ok: true, cancelledTurns: cancelled });
798
+ }
799
+ // ---------- app update (ZCode desktop) ----------
800
+ const APP_UPDATE_CHANNELS = new Set(["stable", "preview"]);
801
+ /**
802
+ * Check the ZCode desktop app for a newer version.
803
+ *
804
+ * `channel=preview` opts into the preview stream (the app's own setting); the
805
+ * default is stable. A machine with no app installed is not an error — the
806
+ * response says so and `updateAvailable` stays false, so the client hides the
807
+ * row instead of showing a failure.
808
+ */
809
+ async function handleAppUpdate(res, url) {
810
+ const requested = url.searchParams.get("channel") ?? "stable";
811
+ if (!APP_UPDATE_CHANNELS.has(requested)) {
812
+ sendError(res, 400, "channel must be one of stable, preview");
813
+ return;
814
+ }
815
+ await guard(res, async () => {
816
+ const check = await checkForAppUpdate({ channel: requested });
817
+ return {
818
+ ok: true,
819
+ appUpdate: {
820
+ updateAvailable: check.updateAvailable,
821
+ currentVersion: check.currentVersion,
822
+ latestVersion: check.latestVersion,
823
+ channel: check.channel,
824
+ platform: check.platform,
825
+ appPath: appBundlePath(),
826
+ releaseName: check.release?.releaseName ?? null,
827
+ releaseNotes: check.release?.releaseNotes ?? null,
828
+ files: check.release?.files ?? [],
829
+ install: appUpdateState(),
830
+ },
831
+ };
832
+ });
833
+ }
834
+ /**
835
+ * Download and install a newer ZCode app build.
836
+ *
837
+ * The version and artifact URL are re-fetched from the release manifest here
838
+ * rather than trusted from the request, so the client cannot install a build the
839
+ * feed does not currently offer. That closes two holes a client-supplied URL
840
+ * would leave open: installing an OLD version (a silent downgrade that the
841
+ * manifest's `sha512` would happily verify), and skipping the checksum entirely.
842
+ *
843
+ * The work runs in the background and the response is the immediate state; the
844
+ * client polls `GET /settings/app-update` (or re-reads this route's `install`
845
+ * field) for progress.
846
+ */
847
+ async function handleAppUpdateInstall(res, body) {
848
+ const version = str(body, "version");
849
+ const url = str(body, "url");
850
+ const channel = str(body, "channel") ?? "stable";
851
+ if (!version) {
852
+ sendError(res, 400, "version is required");
853
+ return;
854
+ }
855
+ if (!url) {
856
+ sendError(res, 400, "url is required");
857
+ return;
858
+ }
859
+ if (!APP_UPDATE_CHANNELS.has(channel)) {
860
+ sendError(res, 400, "channel must be one of stable, preview");
861
+ return;
862
+ }
863
+ // Only the official CDN may serve the artifact. Accepting an arbitrary URL
864
+ // would turn this endpoint into a fetch-and-execute primitive.
865
+ if (!/^https:\/\/cdn-zcode\.z\.ai\//u.test(url)) {
866
+ sendError(res, 400, "url must be an official ZCode CDN artifact");
867
+ return;
868
+ }
869
+ if (appUpdateState().stage === "downloading" || appUpdateState().stage === "installing") {
870
+ sendJson(res, 409, { ok: false, error: "an update is already in progress" });
871
+ return;
872
+ }
873
+ let file;
874
+ let latest = null;
875
+ let current = null;
876
+ try {
877
+ // Read the manifest DIRECTLY rather than through `checkForAppUpdate`: that
878
+ // helper omits the file list when the installed version is already current,
879
+ // which would make an already-up-to-date machine report "no such artifact"
880
+ // instead of the real reason.
881
+ const manifest = await fetchReleaseManifest(channel);
882
+ latest = manifest.version;
883
+ const appPath = appBundlePath();
884
+ current = appPath ? await installedAppVersion(appPath) : null;
885
+ const match = (manifest.files ?? []).find((f) => f.url === url);
886
+ if (!match) {
887
+ sendError(res, 409, "that artifact is not in the current release manifest");
888
+ return;
889
+ }
890
+ if (!match.sha512) {
891
+ // A manifest entry with no checksum cannot be verified; refuse rather than
892
+ // downloading an unverifiable binary into the install path.
893
+ sendError(res, 409, "the release manifest lists no checksum for that artifact");
894
+ return;
895
+ }
896
+ file = match;
897
+ }
898
+ catch (error) {
899
+ sendError(res, 502, `could not read the release manifest: ${messageOf(error)}`);
900
+ return;
901
+ }
902
+ // The manifest is the authority on what is current, so a request for anything
903
+ // else is refused instead of silently downgrading the installed app.
904
+ if (file === undefined || latest === null || version !== latest) {
905
+ sendError(res, 409, `latest ${channel} version is ${latest ?? "unknown"}, not ${version}`);
906
+ return;
907
+ }
908
+ if (current !== null && !isNewerVersion(latest, current)) {
909
+ sendJson(res, 409, { ok: false, error: `version ${latest} is not newer than ${current}` });
910
+ return;
911
+ }
912
+ // Do not await: a 250 MB download must not hold the request open.
913
+ void startAppUpdate(file, version, { channel: channel });
914
+ sendJson(res, 202, { ok: true, install: appUpdateState() });
915
+ }
916
+ /** Test seam: drop the install state so a suite starts from a known one. */
917
+ export function resetAppUpdateStateForTest() {
918
+ resetAppUpdateForTest();
919
+ }
920
+ //# sourceMappingURL=settings-endpoint.js.map