@specship/specship-win32-arm64 0.12.1 → 0.13.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 (64) hide show
  1. package/lib/dist/bin/specship.js +48 -12
  2. package/lib/dist/bin/specship.js.map +1 -1
  3. package/lib/dist/db/migrations.d.ts +1 -1
  4. package/lib/dist/db/migrations.d.ts.map +1 -1
  5. package/lib/dist/db/migrations.js +16 -1
  6. package/lib/dist/db/migrations.js.map +1 -1
  7. package/lib/dist/db/schema.sql +10 -0
  8. package/lib/dist/server/cli.js +9 -7
  9. package/lib/dist/server/product-version.js +44 -0
  10. package/lib/dist/server/routes/claude.js +153 -42
  11. package/lib/dist/server/routes/config.js +35 -0
  12. package/lib/dist/server/routes/graph.js +22 -1
  13. package/lib/dist/server/routes/mcp.js +510 -0
  14. package/lib/dist/server/routes/projects.js +31 -3
  15. package/lib/dist/server/routes/status.js +3 -0
  16. package/lib/dist/server/routes/workflow.js +29 -2
  17. package/lib/dist/server/server-config.js +38 -0
  18. package/lib/dist/server/server.js +128 -58
  19. package/lib/dist/ui/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  20. package/lib/dist/ui/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  21. package/lib/dist/ui/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  22. package/lib/dist/ui/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  23. package/lib/dist/ui/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
  24. package/lib/dist/ui/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
  25. package/lib/dist/ui/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
  26. package/lib/dist/ui/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
  27. package/lib/dist/ui/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
  28. package/lib/dist/ui/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
  29. package/lib/dist/ui/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  30. package/lib/dist/ui/assets/index-D8e4LiVb.js +51 -0
  31. package/lib/dist/ui/assets/index-ezwX2EfG.css +1 -0
  32. package/lib/dist/ui/favicon-16.png +0 -0
  33. package/lib/dist/ui/favicon-180.png +0 -0
  34. package/lib/dist/ui/favicon-32.png +0 -0
  35. package/lib/dist/ui/favicon.svg +20 -0
  36. package/lib/dist/ui/index.html +17 -0
  37. package/lib/dist/update/resolve-latest.d.ts +14 -0
  38. package/lib/dist/update/resolve-latest.d.ts.map +1 -0
  39. package/lib/dist/update/resolve-latest.js +29 -0
  40. package/lib/dist/update/resolve-latest.js.map +1 -0
  41. package/lib/dist/update/run-installer.d.ts +13 -0
  42. package/lib/dist/update/run-installer.d.ts.map +1 -0
  43. package/lib/dist/update/run-installer.js +43 -0
  44. package/lib/dist/update/run-installer.js.map +1 -0
  45. package/lib/dist/update/updater.d.ts +53 -0
  46. package/lib/dist/update/updater.d.ts.map +1 -0
  47. package/lib/dist/update/updater.js +164 -0
  48. package/lib/dist/update/updater.js.map +1 -0
  49. package/lib/dist/workflows/executor.d.ts.map +1 -1
  50. package/lib/dist/workflows/executor.js +4 -0
  51. package/lib/dist/workflows/executor.js.map +1 -1
  52. package/lib/dist/workflows/runners/prompt.d.ts +6 -0
  53. package/lib/dist/workflows/runners/prompt.d.ts.map +1 -1
  54. package/lib/dist/workflows/runners/prompt.js +10 -0
  55. package/lib/dist/workflows/runners/prompt.js.map +1 -1
  56. package/lib/dist/workflows/runners/types.d.ts +12 -0
  57. package/lib/dist/workflows/runners/types.d.ts.map +1 -1
  58. package/lib/node_modules/.package-lock.json +1 -1
  59. package/lib/package.json +3 -2
  60. package/package.json +1 -1
  61. package/lib/dist/server/ssr/md.mjs +0 -83
  62. package/lib/dist/server/ssr/public/app.css +0 -1
  63. package/lib/dist/server/ssr/render.mjs +0 -596
  64. package/lib/dist/server/ssr/routes.js +0 -73
@@ -0,0 +1,510 @@
1
+ /**
2
+ * MCP servers routes — inventory, live status, and control (REQ-DESKTOP-026).
3
+ *
4
+ * Three real sources, merged (this fork is Claude Code only, so Claude
5
+ * Code's config surfaces ARE the inventory — no other clients are read):
6
+ * 1. Claude Code's own config — `~/.claude.json` (global `mcpServers` +
7
+ * `projects[<root>].mcpServers`) and `<projectRoot>/.mcp.json`, the
8
+ * same files `specship install` writes. Disabled servers come from
9
+ * `disabledMcpjsonServers` (Claude Code's native mechanism for
10
+ * `.mcp.json` entries) and `mcpServersDisabled` (our parking map for
11
+ * `~/.claude.json`-owned entries).
12
+ * 2. The transcript analytics DB — `claude_tool_calls` rows whose
13
+ * `tool_name` is `mcp__<server>__<tool>` give real per-server/per-tool
14
+ * call counts, result sizes, last-used timestamps, and the example
15
+ * call (`input_json`, schema v7).
16
+ * 3. A liveness probe — stdio entries are spawned and asked to answer an
17
+ * MCP `initialize`; `url` entries get a fetch. Results cache ~30 s.
18
+ *
19
+ * States use the spec vocabulary (deriveState): `disabled` (parked in
20
+ * config) → `failed` (probe refused) → `active` (calls in the last 24 h) →
21
+ * `connected` (probe answered) → `idle`.
22
+ *
23
+ * Writes (PATCH enable/disable, POST add) touch the user's LIVE Claude Code
24
+ * configs, so every write is atomic (write-temp + rename) and preserves
25
+ * unrelated keys byte-for-byte: key order is kept, and the file's existing
26
+ * indentation unit + trailing newline are detected and reused. A file that
27
+ * fails to parse is never overwritten — the edit errors instead.
28
+ */
29
+ import { spawn } from 'node:child_process';
30
+ import * as fs from 'node:fs';
31
+ import * as os from 'node:os';
32
+ import * as path from 'node:path';
33
+ function getDb(cg) {
34
+ const anyCg = cg;
35
+ if (anyCg.db?.getDb)
36
+ return anyCg.db.getDb();
37
+ return anyCg.queries?.db;
38
+ }
39
+ /** Config-write failures the routes translate to HTTP statuses. */
40
+ export class McpConfigError extends Error {
41
+ statusCode;
42
+ constructor(message, statusCode) {
43
+ super(message);
44
+ this.statusCode = statusCode;
45
+ }
46
+ }
47
+ function readJson(p) {
48
+ try {
49
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /**
56
+ * Collect configured servers from Claude Code's three config surfaces.
57
+ * Precedence on name collision follows Claude Code's own resolution:
58
+ * user (`~/.claude.json` global) < project-local (`projects[<root>]` entry)
59
+ * < `.mcp.json` — later wins, matching what the agent actually loads.
60
+ */
61
+ export function configuredServers(paths) {
62
+ const out = new Map();
63
+ const claudeJson = readJson(paths.claudeJsonPath);
64
+ const add = (name, cfg) => out.set(name, { name, ...cfg });
65
+ for (const [name, entry] of Object.entries((claudeJson?.['mcpServers'] ?? {}))) {
66
+ add(name, { scope: 'user', entry, configFile: paths.claudeJsonPath, disabled: false, container: 'user' });
67
+ }
68
+ for (const [name, entry] of Object.entries((claudeJson?.['mcpServersDisabled'] ?? {}))) {
69
+ add(name, { scope: 'user', entry, configFile: paths.claudeJsonPath, disabled: true, container: 'user' });
70
+ }
71
+ if (paths.projectRoot) {
72
+ const proj = claudeJson?.['projects']?.[paths.projectRoot];
73
+ for (const [name, entry] of Object.entries((proj?.['mcpServers'] ?? {}))) {
74
+ add(name, { scope: 'project', entry, configFile: paths.claudeJsonPath, disabled: false, container: 'project-local' });
75
+ }
76
+ for (const [name, entry] of Object.entries((proj?.['mcpServersDisabled'] ?? {}))) {
77
+ add(name, { scope: 'project', entry, configFile: paths.claudeJsonPath, disabled: true, container: 'project-local' });
78
+ }
79
+ const disabledMcpjson = new Set((proj?.['disabledMcpjsonServers'] ?? []));
80
+ const mcpJsonPath = path.join(paths.projectRoot, '.mcp.json');
81
+ const mcpJson = readJson(mcpJsonPath);
82
+ for (const [name, entry] of Object.entries((mcpJson?.['mcpServers'] ?? {}))) {
83
+ add(name, { scope: 'project', entry, configFile: mcpJsonPath, disabled: disabledMcpjson.has(name), container: 'mcpjson' });
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+ /**
89
+ * Spec vocabulary, in precedence order: a parked entry is `disabled` no
90
+ * matter what; a refused probe is `failed` even with recent calls (the
91
+ * config broke since); recent usage is `active`; a probe answer without
92
+ * recent usage is `connected`; otherwise `idle`.
93
+ */
94
+ export function deriveState(cfg, usage, probe, nowMs = Date.now()) {
95
+ if (cfg?.disabled)
96
+ return 'disabled';
97
+ if (probe === 'failed')
98
+ return 'failed';
99
+ if (usage && nowMs - usage.lastMs < 24 * 3600_000)
100
+ return 'active';
101
+ if (probe === 'ok')
102
+ return 'connected';
103
+ return 'idle';
104
+ }
105
+ const PROBE_CACHE_MS = 30_000;
106
+ const probeCache = new Map();
107
+ /** The MCP initialize request the stdio probe sends (newline-delimited JSON-RPC). */
108
+ const INITIALIZE_REQUEST = JSON.stringify({
109
+ jsonrpc: '2.0',
110
+ id: 1,
111
+ method: 'initialize',
112
+ params: {
113
+ protocolVersion: '2025-06-18',
114
+ capabilities: {},
115
+ clientInfo: { name: 'specship-desktop', version: '0.0.0' },
116
+ },
117
+ }) + '\n';
118
+ function probeStdio(entry, timeoutMs) {
119
+ return new Promise((resolve) => {
120
+ let settled = false;
121
+ let child;
122
+ const done = (result) => {
123
+ if (settled)
124
+ return;
125
+ settled = true;
126
+ clearTimeout(timer);
127
+ try {
128
+ child?.kill();
129
+ }
130
+ catch { /* already gone */ }
131
+ resolve(result);
132
+ };
133
+ const timer = setTimeout(() => done('failed'), timeoutMs);
134
+ try {
135
+ child = spawn(entry.command, entry.args ?? [], {
136
+ stdio: ['pipe', 'pipe', 'ignore'],
137
+ env: { ...process.env, ...(entry.env ?? {}) },
138
+ shell: process.platform === 'win32',
139
+ windowsHide: true,
140
+ });
141
+ }
142
+ catch {
143
+ done('failed');
144
+ return;
145
+ }
146
+ child.on('error', () => done('failed'));
147
+ child.on('exit', () => done('failed'));
148
+ let buf = '';
149
+ child.stdout?.on('data', (chunk) => {
150
+ buf += chunk.toString('utf8');
151
+ for (const line of buf.split('\n')) {
152
+ try {
153
+ const msg = JSON.parse(line);
154
+ if (msg && msg.id === 1 && msg.result) {
155
+ done('ok');
156
+ return;
157
+ }
158
+ }
159
+ catch { /* partial line */ }
160
+ }
161
+ });
162
+ try {
163
+ child.stdin?.write(INITIALIZE_REQUEST);
164
+ }
165
+ catch {
166
+ done('failed');
167
+ }
168
+ });
169
+ }
170
+ async function probeUrl(url, timeoutMs) {
171
+ try {
172
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
173
+ // Any 4xx/5xx (expired OAuth = 401, dead route = 404) is the failed
174
+ // treatment — the design's sentry example shows 401 as Failed.
175
+ return res.ok ? 'ok' : 'failed';
176
+ }
177
+ catch {
178
+ return 'failed';
179
+ }
180
+ }
181
+ /**
182
+ * Liveness probe. Never throws and never hangs past `timeoutMs`; a timeout
183
+ * counts as `failed` (the agent couldn't have used it either). Results are
184
+ * cached ~30 s per config shape so a page refresh doesn't respawn servers.
185
+ */
186
+ export async function probeServer(entry, timeoutMs = 2500) {
187
+ const key = entry.url ?? [entry.command, ...(entry.args ?? [])].join(' ');
188
+ if (!key)
189
+ return 'failed';
190
+ const cached = probeCache.get(key);
191
+ if (cached && Date.now() - cached.at < PROBE_CACHE_MS)
192
+ return cached.result;
193
+ const result = entry.url
194
+ ? await probeUrl(entry.url, timeoutMs)
195
+ : entry.command
196
+ ? await probeStdio(entry, timeoutMs)
197
+ : 'failed';
198
+ probeCache.set(key, { at: Date.now(), result });
199
+ return result;
200
+ }
201
+ // ---- Config writes ----------------------------------------------------------
202
+ /** Write-temp + rename so a crash mid-write can't corrupt a live config. */
203
+ function atomicWriteFileSync(filePath, content) {
204
+ const dir = path.dirname(filePath);
205
+ if (!fs.existsSync(dir))
206
+ fs.mkdirSync(dir, { recursive: true });
207
+ const tmpPath = filePath + '.tmp.' + process.pid;
208
+ try {
209
+ fs.writeFileSync(tmpPath, content);
210
+ fs.renameSync(tmpPath, filePath);
211
+ }
212
+ catch (err) {
213
+ try {
214
+ fs.unlinkSync(tmpPath);
215
+ }
216
+ catch { /* ignore */ }
217
+ throw err;
218
+ }
219
+ }
220
+ /** First indented line's leading whitespace = the file's indent unit. */
221
+ function detectJsonStyle(text) {
222
+ const m = text.match(/\n([ \t]+)\S/);
223
+ return {
224
+ indent: m?.[1] ?? ' ',
225
+ trailingNewline: text.length === 0 || text.endsWith('\n'),
226
+ };
227
+ }
228
+ /**
229
+ * Parse → mutate → re-serialize preserving key order, the detected indent
230
+ * unit, and the trailing newline — so untouched keys keep their bytes. An
231
+ * unparseable file throws (500) instead of being clobbered.
232
+ */
233
+ function editJsonFile(filePath, mutate) {
234
+ let text = '';
235
+ try {
236
+ text = fs.readFileSync(filePath, 'utf8');
237
+ }
238
+ catch { /* missing → create */ }
239
+ let data = {};
240
+ if (text.trim()) {
241
+ try {
242
+ data = JSON.parse(text);
243
+ }
244
+ catch (err) {
245
+ const msg = err instanceof Error ? err.message : String(err);
246
+ throw new McpConfigError(`refusing to edit unparseable ${filePath}: ${msg}`, 500);
247
+ }
248
+ }
249
+ mutate(data);
250
+ const { indent, trailingNewline } = detectJsonStyle(text);
251
+ atomicWriteFileSync(filePath, JSON.stringify(data, null, indent) + (trailingNewline ? '\n' : ''));
252
+ }
253
+ /**
254
+ * Move `data[from][name]` → `data[to][name]`. An emptied `mcpServersDisabled`
255
+ * (our parking map) is removed; an emptied `mcpServers` is kept in place —
256
+ * deleting it would re-create it at the END of the object on re-enable,
257
+ * breaking the byte-for-byte round-trip.
258
+ */
259
+ function moveEntry(data, from, to, name) {
260
+ const entry = data[from]?.[name];
261
+ if (entry === undefined)
262
+ throw new McpConfigError(`server "${name}" not found under ${from}`, 404);
263
+ delete data[from][name];
264
+ if (from === 'mcpServersDisabled' && Object.keys(data[from]).length === 0)
265
+ delete data[from];
266
+ data[to] = { ...(data[to] ?? {}), [name]: entry };
267
+ }
268
+ /**
269
+ * Enable/disable a configured server by editing its owning file.
270
+ * - `.mcp.json`-owned → toggle the name in `disabledMcpjsonServers` under
271
+ * the project's entry in `~/.claude.json` (Claude Code's own mechanism —
272
+ * `.mcp.json` itself is team-shared and never touched).
273
+ * - `~/.claude.json`-owned (user or project-local) → move the entry
274
+ * between `mcpServers` and the `mcpServersDisabled` parking map.
275
+ * Idempotent: a no-op toggle returns without writing. Throws McpConfigError
276
+ * 404 when the name isn't configured anywhere.
277
+ */
278
+ export function setServerEnabled(paths, name, enabled) {
279
+ const cfg = configuredServers(paths).get(name);
280
+ if (!cfg)
281
+ throw new McpConfigError(`unknown MCP server "${name}"`, 404);
282
+ if (cfg.disabled === !enabled)
283
+ return cfg;
284
+ if (cfg.container === 'mcpjson') {
285
+ editJsonFile(paths.claudeJsonPath, (data) => {
286
+ const projects = (data['projects'] = data['projects'] ?? {});
287
+ const proj = (projects[paths.projectRoot] = projects[paths.projectRoot] ?? {});
288
+ const list = proj['disabledMcpjsonServers'] ?? [];
289
+ const next = enabled ? list.filter((n) => n !== name) : [...list, name];
290
+ if (next.length)
291
+ proj['disabledMcpjsonServers'] = next;
292
+ else
293
+ delete proj['disabledMcpjsonServers'];
294
+ });
295
+ }
296
+ else if (cfg.container === 'user') {
297
+ editJsonFile(paths.claudeJsonPath, (data) => {
298
+ if (enabled)
299
+ moveEntry(data, 'mcpServersDisabled', 'mcpServers', name);
300
+ else
301
+ moveEntry(data, 'mcpServers', 'mcpServersDisabled', name);
302
+ });
303
+ }
304
+ else {
305
+ editJsonFile(paths.claudeJsonPath, (data) => {
306
+ const proj = data['projects']?.[paths.projectRoot];
307
+ if (!proj)
308
+ throw new McpConfigError(`project entry for ${paths.projectRoot} missing`, 404);
309
+ if (enabled)
310
+ moveEntry(proj, 'mcpServersDisabled', 'mcpServers', name);
311
+ else
312
+ moveEntry(proj, 'mcpServers', 'mcpServersDisabled', name);
313
+ });
314
+ }
315
+ return { ...cfg, disabled: !enabled };
316
+ }
317
+ /**
318
+ * Add a server: user scope → `~/.claude.json` `mcpServers`; project scope →
319
+ * `<projectRoot>/.mcp.json`. 409 when the name exists on any surface, 400
320
+ * on invalid input. Entry shape matches the installer's (`type` explicit).
321
+ */
322
+ export function addServer(paths, input) {
323
+ const name = (input.name ?? '').trim();
324
+ if (!name || /\s/.test(name))
325
+ throw new McpConfigError('server name must be a non-empty token', 400);
326
+ if (input.scope !== 'user' && input.scope !== 'project')
327
+ throw new McpConfigError('scope must be "user" or "project"', 400);
328
+ if (!input.url && !(input.command ?? '').trim())
329
+ throw new McpConfigError('either a command or a url is required', 400);
330
+ if (configuredServers(paths).has(name))
331
+ throw new McpConfigError(`MCP server "${name}" is already configured`, 409);
332
+ const entry = input.url
333
+ ? { type: 'http', url: input.url }
334
+ : { type: 'stdio', command: input.command.trim(), ...(input.args?.length ? { args: input.args } : {}) };
335
+ if (input.scope === 'project') {
336
+ if (!paths.projectRoot)
337
+ throw new McpConfigError('no project open — cannot write .mcp.json', 400);
338
+ const mcpJsonPath = path.join(paths.projectRoot, '.mcp.json');
339
+ editJsonFile(mcpJsonPath, (data) => {
340
+ data['mcpServers'] = { ...(data['mcpServers'] ?? {}), [name]: entry };
341
+ });
342
+ return { name, scope: 'project', entry, configFile: mcpJsonPath, disabled: false, container: 'mcpjson' };
343
+ }
344
+ editJsonFile(paths.claudeJsonPath, (data) => {
345
+ data['mcpServers'] = { ...(data['mcpServers'] ?? {}), [name]: entry };
346
+ });
347
+ return { name, scope: 'user', entry, configFile: paths.claudeJsonPath, disabled: false, container: 'user' };
348
+ }
349
+ /** Aggregate claude_tool_calls into per-server usage + the latest example call. */
350
+ function collectUsage(cg) {
351
+ const byServer = new Map();
352
+ let usage = [];
353
+ let examples = [];
354
+ try {
355
+ const db = getDb(cg);
356
+ usage = db.prepare(`
357
+ SELECT tool_name, COUNT(*) as calls,
358
+ COALESCE(SUM(result_length), 0) as bytes,
359
+ MAX(ts) as last
360
+ FROM claude_tool_calls
361
+ WHERE tool_name LIKE 'mcp__%'
362
+ GROUP BY tool_name
363
+ `).all();
364
+ // Latest verbatim inputs (schema v7) — first row per server wins below.
365
+ examples = db.prepare(`
366
+ SELECT tool_name, input_json
367
+ FROM claude_tool_calls
368
+ WHERE tool_name LIKE 'mcp__%' AND input_json IS NOT NULL
369
+ ORDER BY ts DESC
370
+ LIMIT 500
371
+ `).all();
372
+ }
373
+ catch { /* analytics tables absent — config-only response */ }
374
+ const parse = (t) => t.match(/^mcp__(.+?)__(.+)$/);
375
+ for (const u of usage) {
376
+ const m = parse(u.tool_name);
377
+ if (!m)
378
+ continue;
379
+ const [, server, tool] = m;
380
+ if (!byServer.has(server))
381
+ byServer.set(server, { calls: 0, bytes: 0, last: 0, tools: new Map(), example: null });
382
+ const s = byServer.get(server);
383
+ s.calls += u.calls;
384
+ s.bytes += u.bytes;
385
+ // `ts` is epoch milliseconds (integer) in the ingest schema.
386
+ const t = typeof u.last === 'number' ? u.last : u.last ? Date.parse(u.last) : 0;
387
+ if (Number.isFinite(t) && t > s.last)
388
+ s.last = t;
389
+ const tt = s.tools.get(tool) ?? { calls: 0, bytes: 0 };
390
+ tt.calls += u.calls;
391
+ tt.bytes += u.bytes;
392
+ s.tools.set(tool, tt);
393
+ }
394
+ for (const row of examples) {
395
+ const m = parse(row.tool_name);
396
+ if (!m)
397
+ continue;
398
+ const [, server, tool] = m;
399
+ const s = byServer.get(server);
400
+ if (!s || s.example)
401
+ continue;
402
+ try {
403
+ s.example = { tool, args: JSON.parse(row.input_json) };
404
+ }
405
+ catch { /* skip bad row */ }
406
+ }
407
+ // Fallback example: the server's top tool with empty args.
408
+ for (const s of byServer.values()) {
409
+ if (s.example)
410
+ continue;
411
+ const top = [...s.tools.entries()].sort((a, b) => b[1].calls - a[1].calls)[0];
412
+ if (top)
413
+ s.example = { tool: top[0], args: {} };
414
+ }
415
+ return byServer;
416
+ }
417
+ export async function registerMcpRoutes(app) {
418
+ const paths = () => ({
419
+ claudeJsonPath: path.join(os.homedir(), '.claude.json'),
420
+ projectRoot: app.primaryCg?.getProjectRoot?.() ?? null,
421
+ });
422
+ function requirePrimary(reply) {
423
+ if (!app.primaryCg) {
424
+ reply.code(409).send({ error: 'analytics unavailable: no primary project configured', code: 'no_primary' });
425
+ return null;
426
+ }
427
+ return app.primaryCg;
428
+ }
429
+ app.get('/api/mcp/servers', async (_req, reply) => {
430
+ const cg = requirePrimary(reply);
431
+ if (!cg)
432
+ return;
433
+ const p = paths();
434
+ const config = configuredServers(p);
435
+ const byServer = collectUsage(cg);
436
+ // Probe every enabled configured server concurrently; disabled and
437
+ // usage-only entries have nothing to probe.
438
+ const probeTargets = [...config.values()].filter((c) => !c.disabled);
439
+ const probes = new Map(await Promise.all(probeTargets.map(async (c) => [c.name, await probeServer(c.entry)])));
440
+ const names = new Set([...config.keys(), ...byServer.keys()]);
441
+ const servers = [...names].map((name) => {
442
+ const cfg = config.get(name);
443
+ const use = byServer.get(name);
444
+ const entry = cfg?.entry ?? {};
445
+ const transport = entry.url ? 'http' : 'stdio';
446
+ const command = entry.url ?? [entry.command, ...(entry.args ?? [])].filter(Boolean).join(' ');
447
+ const state = deriveState(cfg, use?.last ? { lastMs: use.last } : null, probes.get(name) ?? null);
448
+ return {
449
+ id: name, name,
450
+ scope: cfg?.scope ?? 'unknown',
451
+ state, transport, command,
452
+ configFile: cfg?.configFile ?? null,
453
+ disabled: cfg?.disabled ?? false,
454
+ entry: cfg?.entry ?? null,
455
+ calls: use?.calls ?? 0,
456
+ resultBytes: use?.bytes ?? 0,
457
+ lastUsed: use?.last ? new Date(use.last).toISOString() : null,
458
+ tools: use ? [...use.tools.entries()]
459
+ .map(([tool, t]) => ({ name: tool, calls: t.calls, resultBytes: t.bytes }))
460
+ .sort((a, b) => b.calls - a.calls) : [],
461
+ exampleCall: use?.example ?? null,
462
+ };
463
+ }).sort((a, b) => b.calls - a.calls);
464
+ reply.send({ servers });
465
+ });
466
+ app.patch('/api/mcp/servers/:name', async (req, reply) => {
467
+ const enabled = req.body?.enabled;
468
+ if (typeof enabled !== 'boolean') {
469
+ return reply.code(400).send({ error: 'body must be { enabled: boolean }' });
470
+ }
471
+ const p = paths();
472
+ try {
473
+ const server = setServerEnabled(p, req.params.name, enabled);
474
+ return reply.send({ ok: true, name: server.name, disabled: server.disabled });
475
+ }
476
+ catch (err) {
477
+ if (err instanceof McpConfigError) {
478
+ // Usage-only servers show up in transcripts but have no config to
479
+ // edit — that's a 400 (bad target), not a 404 (unknown name).
480
+ if (err.statusCode === 404 && app.primaryCg && hasUsage(app.primaryCg, req.params.name)) {
481
+ return reply.code(400).send({ error: `"${req.params.name}" appears in transcripts but has no config entry to toggle` });
482
+ }
483
+ return reply.code(err.statusCode).send({ error: err.message });
484
+ }
485
+ throw err;
486
+ }
487
+ });
488
+ app.post('/api/mcp/servers', async (req, reply) => {
489
+ try {
490
+ const server = addServer(paths(), req.body ?? {});
491
+ return reply.code(201).send({ ok: true, name: server.name, scope: server.scope, configFile: server.configFile });
492
+ }
493
+ catch (err) {
494
+ if (err instanceof McpConfigError)
495
+ return reply.code(err.statusCode).send({ error: err.message });
496
+ throw err;
497
+ }
498
+ });
499
+ }
500
+ /** True when transcripts contain any mcp__<name>__* call. */
501
+ function hasUsage(cg, name) {
502
+ try {
503
+ const db = getDb(cg);
504
+ const rows = db.prepare(`SELECT 1 FROM claude_tool_calls WHERE tool_name LIKE 'mcp__' || ? || '__%' LIMIT 1`).all(name);
505
+ return rows.length > 0;
506
+ }
507
+ catch {
508
+ return false;
509
+ }
510
+ }
@@ -68,7 +68,7 @@ export async function enumerate(claudeRoot, resolverOpts) {
68
68
  }
69
69
  }
70
70
  catch { /* path missing — OK, still surface it so UI can flag it */ }
71
- out.push({ slug, path: decoded, exists, initialized, sessionCount, lastModifiedMs });
71
+ out.push({ slug, path: decoded, exists, initialized, sessionCount, lastModifiedMs, driftCount: null });
72
72
  }
73
73
  // Recent activity first; ties broken by path.
74
74
  out.sort((a, b) => {
@@ -78,6 +78,32 @@ export async function enumerate(claudeRoot, resolverOpts) {
78
78
  });
79
79
  return out;
80
80
  }
81
+ /**
82
+ * Bound on how many projects get a live driftCount per request. Mirrors the
83
+ * /api/events sweep: every lookup opens the project's graph through the
84
+ * registry LRU (maxOpen 16), so an unbounded pass over a large project list
85
+ * churns open/close cycles on every GET. enumerate() sorts by recency, so
86
+ * the bounded prefix is exactly what the switcher surfaces first; the rest
87
+ * stay `null` (unknown), which the UI renders as no pill.
88
+ */
89
+ const DRIFT_SWEEP_LIMIT = 10;
90
+ /** Fill `driftCount` in place for the most-recent initialized projects. */
91
+ export async function attachDriftCounts(list, source, limit = DRIFT_SWEEP_LIMIT) {
92
+ let budget = limit;
93
+ for (const p of list) {
94
+ if (!p.initialized || budget <= 0)
95
+ continue;
96
+ budget--;
97
+ try {
98
+ const cg = await source(p.slug);
99
+ if (!cg)
100
+ continue; // open failed — leave null
101
+ p.driftCount = cg.getSpecQueries().getLinksByState(['drifted', 'broken', 'orphaned']).length;
102
+ }
103
+ catch { /* graph unreadable — leave null */ }
104
+ }
105
+ return list;
106
+ }
81
107
  // --- Watcher (singleton across the process) ---------------------------------
82
108
  let watcher = null;
83
109
  let lastSlugs = new Set();
@@ -144,8 +170,10 @@ export async function registerProjectsRoutes(app) {
144
170
  const claudeRoot = path.join(os.homedir(), '.claude', 'projects');
145
171
  startWatcher(claudeRoot, false);
146
172
  app.addHook('onClose', async () => { stopWatcher(); });
173
+ const driftSource = (slug) => app.projects.getBySlug(slug);
147
174
  app.get('/api/projects', async () => {
148
- return { claudeRoot, projects: await enumerate(claudeRoot) };
175
+ const projects = await attachDriftCounts(await enumerate(claudeRoot), driftSource);
176
+ return { claudeRoot, projects };
149
177
  });
150
178
  app.get('/api/projects/events', async (req, reply) => {
151
179
  writeSseHead(req, reply);
@@ -169,7 +197,7 @@ export async function registerProjectsRoutes(app) {
169
197
  closed = true;
170
198
  }
171
199
  };
172
- const initial = await enumerate(claudeRoot);
200
+ const initial = await attachDriftCounts(await enumerate(claudeRoot), driftSource);
173
201
  lastSlugs = new Set(initial.map((p) => p.slug));
174
202
  writeEvent('snapshot', { claudeRoot, projects: initial });
175
203
  const onChange = (payload) => {
@@ -13,6 +13,7 @@
13
13
  * (no `?project=`, no primary), returns a 409 with `code: 'no_project'`
14
14
  * so the UI can show the picker without blowing up.
15
15
  */
16
+ import { productVersion } from '../product-version.js';
16
17
  export async function registerStatusRoutes(app) {
17
18
  app.get('/api/status', async (req, reply) => {
18
19
  const cg = await app.activeCg(req);
@@ -28,6 +29,7 @@ export async function registerStatusRoutes(app) {
28
29
  projectPath: cg.getProjectRoot ? cg.getProjectRoot() : '',
29
30
  backend: cg.getBackend(),
30
31
  journalMode: cg.getJournalMode(),
32
+ version: productVersion(),
31
33
  nodeCount: stats.nodeCount,
32
34
  edgeCount: stats.edgeCount,
33
35
  fileCount: stats.fileCount,
@@ -98,6 +100,7 @@ export async function registerStatusRoutes(app) {
98
100
  projectPath: cg.getProjectRoot ? cg.getProjectRoot() : '',
99
101
  backend: cg.getBackend(),
100
102
  journalMode: cg.getJournalMode(),
103
+ version: productVersion(),
101
104
  nodeCount: stats.nodeCount,
102
105
  edgeCount: stats.edgeCount,
103
106
  fileCount: stats.fileCount,
@@ -36,7 +36,7 @@ export async function registerWorkflowRoutes(app) {
36
36
  // the bundled tarball stages the specship core without a resolvable
37
37
  // `@specship/specship` package on Node's module graph (offline
38
38
  // installs hit this — see scripts/offline-install.sh).
39
- // 2. **Workspace / dev** — the `file:../..` workspace dep resolves the
39
+ // 2. **Workspace / dev** — the `file:..` workspace dep resolves the
40
40
  // named import via Node's normal package resolution.
41
41
  let discovery;
42
42
  let executorMod;
@@ -111,13 +111,40 @@ export async function registerWorkflowRoutes(app) {
111
111
  return undefined;
112
112
  }
113
113
  }
114
+ /**
115
+ * Roll a run's per-node stats (from `step_completed` events) into
116
+ * list-level `totalCostUsd` / `model` fields so the Runs list can render
117
+ * its cost/model columns without N+1 detail fetches (REQ-DESKTOP-023).
118
+ * Additive + best-effort: runs recorded before stats existed simply get
119
+ * nulls.
120
+ */
121
+ function statsRollup(sq, runId) {
122
+ let totalCostUsd = null;
123
+ let model = null;
124
+ try {
125
+ for (const e of sq.getEventsByRun(runId, 1000)) {
126
+ if (e.eventType !== 'step_completed')
127
+ continue;
128
+ const stats = e.data?.stats;
129
+ if (typeof stats?.costUsd === 'number')
130
+ totalCostUsd = (totalCostUsd ?? 0) + stats.costUsd;
131
+ if (!model && typeof stats?.model === 'string')
132
+ model = stats.model;
133
+ }
134
+ }
135
+ catch { /* stats are decorative — never fail the list */ }
136
+ return { totalCostUsd, model };
137
+ }
114
138
  app.get('/api/workflows/runs', async (req, reply) => {
115
139
  const cg = await resolveCg(app, req, reply);
116
140
  if (!cg)
117
141
  return;
118
142
  const { sq } = executorFor(cg);
119
143
  const limit = Math.min(parseInt(req.query.limit ?? '50', 10) || 50, 500);
120
- const runs = sq.getAllWorkflowRuns(limit).map((r) => r.status === 'running' || r.status === 'paused' ? { ...r, eta: etaFor(cg, r.id) } : r);
144
+ const runs = sq.getAllWorkflowRuns(limit).map((r) => ({
145
+ ...(r.status === 'running' || r.status === 'paused' ? { ...r, eta: etaFor(cg, r.id) } : r),
146
+ ...statsRollup(sq, r.id),
147
+ }));
121
148
  return { runs };
122
149
  });
123
150
  app.get('/api/workflows/runs/:id', async (req, reply) => {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Persisted server runtime config — the settings the dashboard can change
3
+ * at runtime and that must survive a restart (REQ-DESKTOP-028.A2). Lives at
4
+ * `~/.specship/server-config.json` (user-level, not per-project: the ingest
5
+ * watcher reads every project's transcripts). `SPECSHIP_SERVER_CONFIG`
6
+ * overrides the path so tests never touch the real home directory.
7
+ */
8
+ import * as fs from 'node:fs';
9
+ import * as os from 'node:os';
10
+ import * as path from 'node:path';
11
+ export function serverConfigPath() {
12
+ return process.env.SPECSHIP_SERVER_CONFIG
13
+ ?? path.join(os.homedir(), '.specship', 'server-config.json');
14
+ }
15
+ export function readServerConfig() {
16
+ try {
17
+ const raw = fs.readFileSync(serverConfigPath(), 'utf-8');
18
+ const parsed = JSON.parse(raw);
19
+ if (parsed && typeof parsed === 'object')
20
+ return parsed;
21
+ }
22
+ catch { /* missing or corrupt → defaults */ }
23
+ return {};
24
+ }
25
+ /**
26
+ * Merge `patch` into the stored config and write it back atomically:
27
+ * write to a sibling temp file, then rename over the target. A crash
28
+ * mid-write can never leave a truncated JSON file behind.
29
+ */
30
+ export function writeServerConfig(patch) {
31
+ const merged = { ...readServerConfig(), ...patch };
32
+ const file = serverConfigPath();
33
+ fs.mkdirSync(path.dirname(file), { recursive: true });
34
+ const tmp = `${file}.${process.pid}.tmp`;
35
+ fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
36
+ fs.renameSync(tmp, file);
37
+ return merged;
38
+ }