@openspecui/server 2.0.2 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.mjs +999 -875
  2. package/package.json +15 -11
  3. package/LICENSE +0 -21
package/dist/index.mjs CHANGED
@@ -1,21 +1,23 @@
1
1
  import { createServer as createServer$1 } from "node:net";
2
2
  import { serve } from "@hono/node-server";
3
- import { CliExecutor, CodeEditorThemeSchema, ConfigManager, DASHBOARD_METRIC_KEYS, DashboardConfigSchema, OpenSpecAdapter, OpenSpecWatcher, OpsxKernel, PtyClientMessageSchema, ReactiveContext, TerminalConfigSchema, TerminalRendererEngineSchema, contextStorage, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, getWatcherRuntimeStatus, initWatcherPool, isWatcherPoolInitialized, reactiveReadDir, reactiveReadFile, reactiveStat, sniffGlobalCli } from "@openspecui/core";
3
+ import { CliExecutor, CodeEditorThemeSchema, ConfigManager, DASHBOARD_METRIC_KEYS, DashboardConfigSchema, OpenSpecAdapter, OpenSpecWatcher, OpsxKernel, PtyClientMessageSchema, ReactiveContext, TerminalConfigSchema, TerminalRendererEngineSchema, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, getWatcherRuntimeStatus, initWatcherPool, isWatcherPoolInitialized, sniffGlobalCli } from "@openspecui/core";
4
4
  import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
5
5
  import { applyWSSHandler } from "@trpc/server/adapters/ws";
6
6
  import { Hono } from "hono";
7
7
  import { cors } from "hono/cors";
8
+ import { readFileSync } from "node:fs";
9
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
10
+ import { fileURLToPath } from "node:url";
8
11
  import { WebSocketServer } from "ws";
12
+ import { EventEmitter } from "node:events";
13
+ import { execFile } from "node:child_process";
14
+ import { mkdir, rm, stat, writeFile } from "node:fs/promises";
15
+ import { promisify } from "node:util";
9
16
  import * as pty from "@lydell/node-pty";
10
- import { EventEmitter } from "events";
17
+ import { EventEmitter as EventEmitter$1 } from "events";
11
18
  import { SearchQuerySchema } from "@openspecui/search";
12
19
  import { initTRPC } from "@trpc/server";
13
20
  import { observable } from "@trpc/server/observable";
14
- import { execFile } from "node:child_process";
15
- import { EventEmitter as EventEmitter$1 } from "node:events";
16
- import { mkdir, rm, stat, writeFile } from "node:fs/promises";
17
- import { dirname, join, relative, resolve, sep } from "node:path";
18
- import { promisify } from "node:util";
19
21
  import { z } from "zod";
20
22
  import { NodeWorkerSearchProvider } from "@openspecui/search/node";
21
23
 
@@ -54,454 +56,112 @@ async function findAvailablePort(startPort, maxAttempts = 10) {
54
56
  }
55
57
 
56
58
  //#endregion
57
- //#region src/pty-manager.ts
58
- const DEFAULT_SCROLLBACK = 1e3;
59
- const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
60
- function detectPtyPlatform() {
61
- if (process.platform === "win32") return "windows";
62
- if (process.platform === "darwin") return "macos";
63
- return "common";
64
- }
65
- function resolveDefaultShell(platform, env) {
66
- if (platform === "windows") return env.ComSpec?.trim() || "cmd.exe";
67
- return env.SHELL?.trim() || "/bin/sh";
68
- }
69
- function resolvePtyCommand(opts) {
70
- const command = opts.command?.trim();
71
- if (command) return {
72
- command,
73
- args: opts.args ?? []
74
- };
75
- return {
76
- command: resolveDefaultShell(opts.platform, opts.env),
77
- args: []
78
- };
79
- }
80
- var PtySession = class extends EventEmitter {
81
- id;
82
- command;
83
- args;
84
- platform;
85
- closeTip;
86
- closeCallbackUrl;
87
- createdAt;
88
- process;
89
- titleInterval = null;
90
- lastTitle = "";
91
- buffer = [];
92
- bufferByteLength = 0;
93
- maxBufferLines;
94
- maxBufferBytes;
95
- isExited = false;
96
- exitCode = null;
97
- constructor(id, opts) {
98
- super();
99
- this.id = id;
100
- this.createdAt = Date.now();
101
- const resolvedCommand = resolvePtyCommand({
102
- platform: opts.platform,
103
- command: opts.command,
104
- args: opts.args,
105
- env: process.env
106
- });
107
- this.command = resolvedCommand.command;
108
- this.args = resolvedCommand.args;
109
- this.platform = opts.platform;
110
- this.closeTip = opts.closeTip;
111
- this.closeCallbackUrl = opts.closeCallbackUrl;
112
- this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
113
- this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
114
- this.process = pty.spawn(this.command, this.args, {
115
- name: "xterm-256color",
116
- cols: opts.cols ?? 80,
117
- rows: opts.rows ?? 24,
118
- cwd: opts.cwd,
119
- env: {
120
- ...process.env,
121
- TERM: "xterm-256color"
122
- }
123
- });
124
- this.process.onData((data) => {
125
- this.appendBuffer(data);
126
- this.emit("data", data);
127
- });
128
- this.process.onExit(({ exitCode }) => {
129
- if (this.titleInterval) {
130
- clearInterval(this.titleInterval);
131
- this.titleInterval = null;
132
- }
133
- this.isExited = true;
134
- this.exitCode = exitCode;
135
- this.emit("exit", exitCode);
59
+ //#region src/dashboard-overview-service.ts
60
+ const REBUILD_DEBOUNCE_MS$1 = 250;
61
+ var DashboardOverviewService = class {
62
+ current = null;
63
+ initialized = false;
64
+ initPromise = null;
65
+ refreshPromise = null;
66
+ refreshTimer = null;
67
+ pendingRefreshReason = null;
68
+ emitter = new EventEmitter();
69
+ constructor(loadOverview, watcher) {
70
+ this.loadOverview = loadOverview;
71
+ this.emitter.setMaxListeners(200);
72
+ watcher?.on("change", () => {
73
+ this.scheduleRefresh("watcher-change");
136
74
  });
137
- this.titleInterval = setInterval(() => {
138
- try {
139
- const title = this.process.process;
140
- if (title && title !== this.lastTitle) {
141
- this.lastTitle = title;
142
- this.emit("title", title);
143
- }
144
- } catch {}
145
- }, 1e3);
146
- }
147
- get title() {
148
- return this.lastTitle;
149
75
  }
150
- appendBuffer(data) {
151
- let chunk = data;
152
- if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
153
- this.buffer.push(chunk);
154
- this.bufferByteLength += chunk.length;
155
- while (this.bufferByteLength > this.maxBufferBytes && this.buffer.length > 0) {
156
- const removed = this.buffer.shift();
157
- this.bufferByteLength -= removed.length;
158
- }
159
- while (this.buffer.length > this.maxBufferLines) {
160
- const removed = this.buffer.shift();
161
- this.bufferByteLength -= removed.length;
76
+ async init() {
77
+ if (this.initialized && this.current) return this.current;
78
+ if (this.initPromise) return this.initPromise;
79
+ this.initPromise = this.refresh("init");
80
+ try {
81
+ return await this.initPromise;
82
+ } finally {
83
+ this.initPromise = null;
162
84
  }
163
85
  }
164
- getBuffer() {
165
- return this.buffer.join("");
86
+ async getCurrent() {
87
+ if (this.current) return this.current;
88
+ return this.init();
166
89
  }
167
- write(data) {
168
- if (!this.isExited) this.process.write(data);
90
+ subscribe(listener, options) {
91
+ this.emitter.on("change", listener);
92
+ if (options?.emitCurrent) if (this.current) listener(this.current);
93
+ else this.init().catch((error) => {
94
+ options?.onError?.(error instanceof Error ? error : new Error(String(error)));
95
+ });
96
+ return () => {
97
+ this.emitter.off("change", listener);
98
+ };
169
99
  }
170
- resize(cols, rows) {
171
- if (!this.isExited) this.process.resize(cols, rows);
100
+ scheduleRefresh(reason = "scheduled-refresh") {
101
+ this.cancelScheduledRefresh();
102
+ this.refreshTimer = setTimeout(() => {
103
+ this.refreshTimer = null;
104
+ this.refresh(reason).catch(() => {});
105
+ }, REBUILD_DEBOUNCE_MS$1);
172
106
  }
173
- close() {
174
- if (this.titleInterval) {
175
- clearInterval(this.titleInterval);
176
- this.titleInterval = null;
107
+ async refresh(reason = "manual-refresh") {
108
+ this.cancelScheduledRefresh();
109
+ if (this.refreshPromise) {
110
+ this.pendingRefreshReason = reason;
111
+ return this.refreshPromise;
177
112
  }
113
+ this.refreshPromise = (async () => {
114
+ const next = await this.loadOverview(reason);
115
+ this.current = next;
116
+ this.initialized = true;
117
+ this.emitter.emit("change", next);
118
+ return next;
119
+ })();
178
120
  try {
179
- this.process.kill();
180
- } catch {}
181
- this.removeAllListeners();
182
- }
183
- toInfo() {
184
- return {
185
- id: this.id,
186
- title: this.lastTitle,
187
- command: this.command,
188
- args: this.args,
189
- platform: this.platform,
190
- isExited: this.isExited,
191
- exitCode: this.exitCode,
192
- closeTip: this.closeTip,
193
- closeCallbackUrl: this.closeCallbackUrl,
194
- createdAt: this.createdAt
195
- };
196
- }
197
- };
198
- var PtyManager = class {
199
- sessions = /* @__PURE__ */ new Map();
200
- idCounter = 0;
201
- platform;
202
- constructor(defaultCwd) {
203
- this.defaultCwd = defaultCwd;
204
- this.platform = detectPtyPlatform();
205
- }
206
- create(opts) {
207
- const id = `pty-${++this.idCounter}`;
208
- const session = new PtySession(id, {
209
- cols: opts.cols,
210
- rows: opts.rows,
211
- command: opts.command,
212
- args: opts.args,
213
- closeTip: opts.closeTip,
214
- closeCallbackUrl: opts.closeCallbackUrl,
215
- cwd: this.defaultCwd,
216
- scrollback: opts.scrollback,
217
- maxBufferBytes: opts.maxBufferBytes,
218
- platform: this.platform
219
- });
220
- this.sessions.set(id, session);
221
- return session;
222
- }
223
- get(id) {
224
- return this.sessions.get(id);
225
- }
226
- list() {
227
- const result = [];
228
- for (const session of this.sessions.values()) result.push(session.toInfo());
229
- return result;
230
- }
231
- write(id, data) {
232
- this.sessions.get(id)?.write(data);
233
- }
234
- resize(id, cols, rows) {
235
- this.sessions.get(id)?.resize(cols, rows);
236
- }
237
- close(id) {
238
- const session = this.sessions.get(id);
239
- if (session) {
240
- session.close();
241
- this.sessions.delete(id);
121
+ return await this.refreshPromise;
122
+ } finally {
123
+ this.refreshPromise = null;
124
+ if (this.pendingRefreshReason) {
125
+ const pendingReason = this.pendingRefreshReason;
126
+ this.pendingRefreshReason = null;
127
+ this.refresh(pendingReason).catch(() => {});
128
+ }
242
129
  }
243
130
  }
244
- closeAll() {
245
- for (const session of this.sessions.values()) session.close();
246
- this.sessions.clear();
131
+ dispose() {
132
+ this.cancelScheduledRefresh();
133
+ this.emitter.removeAllListeners();
134
+ }
135
+ cancelScheduledRefresh() {
136
+ if (!this.refreshTimer) return;
137
+ clearTimeout(this.refreshTimer);
138
+ this.refreshTimer = null;
247
139
  }
248
140
  };
249
141
 
250
142
  //#endregion
251
- //#region src/pty-websocket.ts
252
- function createPtyWebSocketHandler(ptyManager) {
253
- return (ws) => {
254
- const cleanups = /* @__PURE__ */ new Map();
255
- const send = (msg) => {
256
- if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
143
+ //#region src/dashboard-git-snapshot.ts
144
+ const execFileAsync$1 = promisify(execFile);
145
+ const EMPTY_DIFF = {
146
+ files: 0,
147
+ insertions: 0,
148
+ deletions: 0
149
+ };
150
+ async function defaultRunGit(cwd, args) {
151
+ try {
152
+ const { stdout } = await execFileAsync$1("git", args, {
153
+ cwd,
154
+ encoding: "utf8",
155
+ maxBuffer: 8 * 1024 * 1024
156
+ });
157
+ return {
158
+ ok: true,
159
+ stdout
257
160
  };
258
- const sendError = (code, message, opts) => {
259
- send({
260
- type: "error",
261
- code,
262
- message,
263
- sessionId: opts?.sessionId
264
- });
265
- };
266
- const attachToSession = (session, opts) => {
267
- const sessionId = session.id;
268
- cleanups.get(sessionId)?.();
269
- if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
270
- const onData = (data) => {
271
- send({
272
- type: "output",
273
- sessionId,
274
- data
275
- });
276
- };
277
- const onExit = (exitCode) => {
278
- send({
279
- type: "exit",
280
- sessionId,
281
- exitCode
282
- });
283
- };
284
- const onTitle = (title) => {
285
- send({
286
- type: "title",
287
- sessionId,
288
- title
289
- });
290
- };
291
- session.on("data", onData);
292
- session.on("exit", onExit);
293
- session.on("title", onTitle);
294
- cleanups.set(sessionId, () => {
295
- session.removeListener("data", onData);
296
- session.removeListener("exit", onExit);
297
- session.removeListener("title", onTitle);
298
- cleanups.delete(sessionId);
299
- });
300
- };
301
- ws.on("message", (raw) => {
302
- let parsed;
303
- try {
304
- parsed = JSON.parse(String(raw));
305
- } catch {
306
- sendError("INVALID_JSON", "Invalid JSON payload");
307
- return;
308
- }
309
- const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
310
- if (!parsedMessage.success) {
311
- const firstIssue = parsedMessage.error.issues[0]?.message;
312
- sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
313
- return;
314
- }
315
- const msg = parsedMessage.data;
316
- switch (msg.type) {
317
- case "create":
318
- try {
319
- const createMessage = msg;
320
- const session = ptyManager.create({
321
- cols: msg.cols,
322
- rows: msg.rows,
323
- command: msg.command,
324
- args: msg.args,
325
- closeTip: createMessage.closeTip,
326
- closeCallbackUrl: createMessage.closeCallbackUrl
327
- });
328
- send({
329
- type: "created",
330
- requestId: msg.requestId,
331
- sessionId: session.id,
332
- platform: session.platform
333
- });
334
- attachToSession(session);
335
- } catch (err) {
336
- sendError("PTY_CREATE_FAILED", err instanceof Error ? err.message : String(err), { sessionId: msg.requestId });
337
- }
338
- break;
339
- case "attach": {
340
- const session = ptyManager.get(msg.sessionId);
341
- if (!session) {
342
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
343
- send({
344
- type: "exit",
345
- sessionId: msg.sessionId,
346
- exitCode: -1
347
- });
348
- break;
349
- }
350
- attachToSession(session, {
351
- cols: msg.cols,
352
- rows: msg.rows
353
- });
354
- const buffer = session.getBuffer();
355
- if (buffer) send({
356
- type: "buffer",
357
- sessionId: session.id,
358
- data: buffer
359
- });
360
- if (session.title) send({
361
- type: "title",
362
- sessionId: session.id,
363
- title: session.title
364
- });
365
- if (session.isExited) send({
366
- type: "exit",
367
- sessionId: session.id,
368
- exitCode: session.exitCode ?? -1
369
- });
370
- break;
371
- }
372
- case "list":
373
- send({
374
- type: "list",
375
- sessions: ptyManager.list().map((s) => ({
376
- id: s.id,
377
- title: s.title,
378
- command: s.command,
379
- args: s.args,
380
- platform: s.platform,
381
- isExited: s.isExited,
382
- exitCode: s.exitCode,
383
- closeTip: s.closeTip,
384
- closeCallbackUrl: s.closeCallbackUrl
385
- }))
386
- });
387
- break;
388
- case "input": {
389
- const session = ptyManager.get(msg.sessionId);
390
- if (!session) {
391
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
392
- break;
393
- }
394
- session.write(msg.data);
395
- break;
396
- }
397
- case "resize": {
398
- const session = ptyManager.get(msg.sessionId);
399
- if (!session) {
400
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
401
- break;
402
- }
403
- session.resize(msg.cols, msg.rows);
404
- break;
405
- }
406
- case "close": {
407
- const session = ptyManager.get(msg.sessionId);
408
- if (!session) {
409
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
410
- break;
411
- }
412
- cleanups.get(msg.sessionId)?.();
413
- ptyManager.close(session.id);
414
- break;
415
- }
416
- }
417
- });
418
- ws.on("close", () => {
419
- for (const cleanup of cleanups.values()) cleanup();
420
- cleanups.clear();
421
- });
422
- };
423
- }
424
-
425
- //#endregion
426
- //#region src/cli-stream-observable.ts
427
- /**
428
- * 创建安全的 CLI 流式 observable
429
- *
430
- * 解决的问题:
431
- * 1. 防止在 emit.complete() 之后调用 emit.next()(会导致 "Controller is already closed" 错误)
432
- * 2. 统一的错误处理,防止未捕获的异常导致服务器崩溃
433
- * 3. 确保取消时正确清理资源
434
- *
435
- * @param startStream 启动流的函数,接收 onEvent 回调,返回取消函数的 Promise
436
- */
437
- function createCliStreamObservable(startStream) {
438
- return observable((emit) => {
439
- let cancel;
440
- let completed = false;
441
- /**
442
- * 安全的事件处理器
443
- * - 检查是否已完成,防止重复调用
444
- * - 使用 try-catch 防止异常导致服务器崩溃
445
- */
446
- const safeEventHandler = (event) => {
447
- if (completed) return;
448
- try {
449
- emit.next(event);
450
- if (event.type === "exit") {
451
- completed = true;
452
- emit.complete();
453
- }
454
- } catch (err) {
455
- console.error("[CLI Stream] Error emitting event:", err);
456
- if (!completed) {
457
- completed = true;
458
- try {
459
- emit.error(err instanceof Error ? err : new Error(String(err)));
460
- } catch {}
461
- }
462
- }
463
- };
464
- startStream(safeEventHandler).then((cancelFn) => {
465
- cancel = cancelFn;
466
- }).catch((err) => {
467
- console.error("[CLI Stream] Error starting stream:", err);
468
- if (!completed) {
469
- completed = true;
470
- try {
471
- emit.error(err instanceof Error ? err : new Error(String(err)));
472
- } catch {}
473
- }
474
- });
475
- return () => {
476
- completed = true;
477
- cancel?.();
478
- };
479
- });
480
- }
481
-
482
- //#endregion
483
- //#region src/dashboard-git-snapshot.ts
484
- const execFileAsync$1 = promisify(execFile);
485
- const EMPTY_DIFF = {
486
- files: 0,
487
- insertions: 0,
488
- deletions: 0
489
- };
490
- async function defaultRunGit(cwd, args) {
491
- try {
492
- const { stdout } = await execFileAsync$1("git", args, {
493
- cwd,
494
- encoding: "utf8",
495
- maxBuffer: 8 * 1024 * 1024
496
- });
497
- return {
498
- ok: true,
499
- stdout
500
- };
501
- } catch {
502
- return {
503
- ok: false,
504
- stdout: ""
161
+ } catch {
162
+ return {
163
+ ok: false,
164
+ stdout: ""
505
165
  };
506
166
  }
507
167
  }
@@ -710,6 +370,7 @@ async function collectWorktree(options) {
710
370
  path: worktreePath,
711
371
  relativePath: relativePath(resolvedProjectDir, worktreePath),
712
372
  branchName: parseBranchName(worktree.branchRef, worktree.detached),
373
+ detached: worktree.detached,
713
374
  isCurrent: resolvedProjectDir === worktreePath,
714
375
  ahead,
715
376
  behind,
@@ -717,6 +378,27 @@ async function collectWorktree(options) {
717
378
  entries
718
379
  };
719
380
  }
381
+ async function removeDetachedDashboardGitWorktree(options) {
382
+ const runGit = options.runGit ?? defaultRunGit;
383
+ const resolvedProjectDir = resolve(options.projectDir);
384
+ const resolvedTargetPath = resolve(options.targetPath);
385
+ if (resolvedTargetPath === resolvedProjectDir) throw new Error("Cannot remove the current worktree.");
386
+ const worktreeResult = await runGit(resolvedProjectDir, [
387
+ "worktree",
388
+ "list",
389
+ "--porcelain"
390
+ ]);
391
+ if (!worktreeResult.ok) throw new Error("Failed to inspect git worktrees.");
392
+ const matched = parseWorktreeList(worktreeResult.stdout).find((worktree) => resolve(worktree.path) === resolvedTargetPath);
393
+ if (!matched) throw new Error("Worktree not found.");
394
+ if (!matched.detached) throw new Error("Only detached worktrees can be removed from Dashboard.");
395
+ if (!(await runGit(resolvedProjectDir, [
396
+ "worktree",
397
+ "remove",
398
+ "--force",
399
+ resolvedTargetPath
400
+ ])).ok) throw new Error("Failed to remove detached worktree.");
401
+ }
720
402
  async function buildDashboardGitSnapshot(options) {
721
403
  const runGit = options.runGit ?? defaultRunGit;
722
404
  const maxCommitEntries = options.maxCommitEntries ?? 8;
@@ -751,95 +433,783 @@ async function buildDashboardGitSnapshot(options) {
751
433
  }
752
434
 
753
435
  //#endregion
754
- //#region src/dashboard-time-trends.ts
755
- const MIN_TREND_POINT_LIMIT = 20;
756
- const MAX_TREND_POINT_LIMIT = 500;
757
- const DEFAULT_TREND_POINT_LIMIT = 100;
758
- const TARGET_TREND_BARS = 20;
759
- const DAY_MS = 1440 * 60 * 1e3;
760
- function clampPointLimit(pointLimit) {
761
- if (!Number.isFinite(pointLimit)) return DEFAULT_TREND_POINT_LIMIT;
762
- return Math.max(MIN_TREND_POINT_LIMIT, Math.min(MAX_TREND_POINT_LIMIT, Math.trunc(pointLimit)));
763
- }
764
- function createEmptyTrendSeries() {
765
- return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
766
- }
767
- function normalizeEvents(events, pointLimit) {
768
- return events.filter((event) => Number.isFinite(event.ts) && event.ts > 0 && Number.isFinite(event.value)).sort((a, b) => a.ts - b.ts).slice(-pointLimit);
769
- }
770
- function buildTimeWindow(options) {
771
- const { probeEvents, targetBars, rightEdgeTs } = options;
772
- if (probeEvents.length === 0) return null;
773
- const probeEnd = probeEvents[probeEvents.length - 1].ts;
774
- const end = typeof rightEdgeTs === "number" && Number.isFinite(rightEdgeTs) && rightEdgeTs > 0 ? Math.max(probeEnd, rightEdgeTs) : probeEnd;
775
- const probeStart = probeEvents[0].ts;
776
- const rangeMs = Math.max(1, end - probeStart);
777
- const bucketMs = rangeMs >= DAY_MS ? Math.max(DAY_MS, Math.ceil(rangeMs / targetBars / DAY_MS) * DAY_MS) : Math.max(1, Math.ceil(rangeMs / targetBars));
778
- const windowStart = end - bucketMs * targetBars;
779
- return {
780
- windowStart,
781
- bucketMs,
782
- bucketEnds: Array.from({ length: targetBars }, (_, index) => windowStart + bucketMs * (index + 1))
783
- };
784
- }
785
- function bucketizeTrend(events, reducer, rightEdgeTs) {
786
- if (events.length === 0) return [];
787
- const timeWindow = buildTimeWindow({
788
- probeEvents: events,
789
- targetBars: TARGET_TREND_BARS,
790
- rightEdgeTs
791
- });
792
- if (!timeWindow) return [];
793
- const { windowStart, bucketMs, bucketEnds } = timeWindow;
794
- const sums = Array.from({ length: bucketEnds.length }, () => 0);
795
- const counts = Array.from({ length: bucketEnds.length }, () => 0);
796
- let baseline = 0;
797
- for (const event of events) {
798
- if (event.ts <= windowStart) {
799
- if (reducer === "sum-cumulative") baseline += event.value;
800
- continue;
801
- }
802
- const offset = event.ts - windowStart;
803
- const index = Math.max(0, Math.min(bucketEnds.length - 1, Math.ceil(offset / bucketMs) - 1));
804
- sums[index] += event.value;
805
- counts[index] += 1;
806
- }
807
- let cumulative = baseline;
808
- let carry = baseline !== 0 ? baseline : events[0].value;
809
- return bucketEnds.map((ts, index) => {
810
- if (reducer === "sum") return {
811
- ts,
812
- value: sums[index]
436
+ //#region src/dashboard-time-trends.ts
437
+ const MIN_TREND_POINT_LIMIT = 20;
438
+ const MAX_TREND_POINT_LIMIT = 500;
439
+ const DEFAULT_TREND_POINT_LIMIT = 100;
440
+ const TARGET_TREND_BARS = 20;
441
+ const DAY_MS = 1440 * 60 * 1e3;
442
+ function clampPointLimit(pointLimit) {
443
+ if (!Number.isFinite(pointLimit)) return DEFAULT_TREND_POINT_LIMIT;
444
+ return Math.max(MIN_TREND_POINT_LIMIT, Math.min(MAX_TREND_POINT_LIMIT, Math.trunc(pointLimit)));
445
+ }
446
+ function createEmptyTrendSeries() {
447
+ return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
448
+ }
449
+ function normalizeEvents(events, pointLimit) {
450
+ return events.filter((event) => Number.isFinite(event.ts) && event.ts > 0 && Number.isFinite(event.value)).sort((a, b) => a.ts - b.ts).slice(-pointLimit);
451
+ }
452
+ function buildTimeWindow(options) {
453
+ const { probeEvents, targetBars, rightEdgeTs } = options;
454
+ if (probeEvents.length === 0) return null;
455
+ const probeEnd = probeEvents[probeEvents.length - 1].ts;
456
+ const end = typeof rightEdgeTs === "number" && Number.isFinite(rightEdgeTs) && rightEdgeTs > 0 ? Math.max(probeEnd, rightEdgeTs) : probeEnd;
457
+ const probeStart = probeEvents[0].ts;
458
+ const rangeMs = Math.max(1, end - probeStart);
459
+ const bucketMs = rangeMs >= DAY_MS ? Math.max(DAY_MS, Math.ceil(rangeMs / targetBars / DAY_MS) * DAY_MS) : Math.max(1, Math.ceil(rangeMs / targetBars));
460
+ const windowStart = end - bucketMs * targetBars;
461
+ return {
462
+ windowStart,
463
+ bucketMs,
464
+ bucketEnds: Array.from({ length: targetBars }, (_, index) => windowStart + bucketMs * (index + 1))
465
+ };
466
+ }
467
+ function bucketizeTrend(events, reducer, rightEdgeTs) {
468
+ if (events.length === 0) return [];
469
+ const timeWindow = buildTimeWindow({
470
+ probeEvents: events,
471
+ targetBars: TARGET_TREND_BARS,
472
+ rightEdgeTs
473
+ });
474
+ if (!timeWindow) return [];
475
+ const { windowStart, bucketMs, bucketEnds } = timeWindow;
476
+ const sums = Array.from({ length: bucketEnds.length }, () => 0);
477
+ const counts = Array.from({ length: bucketEnds.length }, () => 0);
478
+ let baseline = 0;
479
+ for (const event of events) {
480
+ if (event.ts <= windowStart) {
481
+ if (reducer === "sum-cumulative") baseline += event.value;
482
+ continue;
483
+ }
484
+ const offset = event.ts - windowStart;
485
+ const index = Math.max(0, Math.min(bucketEnds.length - 1, Math.ceil(offset / bucketMs) - 1));
486
+ sums[index] += event.value;
487
+ counts[index] += 1;
488
+ }
489
+ let cumulative = baseline;
490
+ let carry = baseline !== 0 ? baseline : events[0].value;
491
+ return bucketEnds.map((ts, index) => {
492
+ if (reducer === "sum") return {
493
+ ts,
494
+ value: sums[index]
495
+ };
496
+ if (reducer === "sum-cumulative") {
497
+ cumulative += sums[index];
498
+ return {
499
+ ts,
500
+ value: cumulative
501
+ };
502
+ }
503
+ if (counts[index] > 0) carry = sums[index] / counts[index];
504
+ return {
505
+ ts,
506
+ value: carry
507
+ };
508
+ });
509
+ }
510
+ function buildDashboardTimeTrends(options) {
511
+ const pointLimit = clampPointLimit(options.pointLimit);
512
+ const trends = createEmptyTrendSeries();
513
+ for (const metric of DASHBOARD_METRIC_KEYS) {
514
+ if (options.availability[metric].state !== "ok") continue;
515
+ trends[metric] = bucketizeTrend(normalizeEvents(options.events[metric], pointLimit), options.reducers?.[metric] ?? "sum", options.rightEdgeTs);
516
+ }
517
+ return {
518
+ trends,
519
+ trendMeta: {
520
+ pointLimit,
521
+ lastUpdatedAt: options.timestamp
522
+ }
523
+ };
524
+ }
525
+
526
+ //#endregion
527
+ //#region src/dashboard-overview.ts
528
+ const execFileAsync = promisify(execFile);
529
+ const DASHBOARD_GIT_REFRESH_STAMP_NAME = "openspecui-dashboard-git-refresh.stamp";
530
+ const dashboardGitTaskStatusEmitter = new EventEmitter();
531
+ dashboardGitTaskStatusEmitter.setMaxListeners(200);
532
+ const dashboardGitTaskStatus = {
533
+ running: false,
534
+ inFlight: 0,
535
+ lastStartedAt: null,
536
+ lastFinishedAt: null,
537
+ lastReason: null,
538
+ lastError: null
539
+ };
540
+ function createEmptyTriColorTrends() {
541
+ return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
542
+ }
543
+ function resolveTrendTimestamp(primary, secondary) {
544
+ if (typeof primary === "number" && Number.isFinite(primary) && primary > 0) return primary;
545
+ if (typeof secondary === "number" && Number.isFinite(secondary) && secondary > 0) return secondary;
546
+ return null;
547
+ }
548
+ function parseDatedIdTimestamp(id) {
549
+ const match = /^(\d{4})-(\d{2})-(\d{2})(?:-|$)/.exec(id);
550
+ if (!match) return null;
551
+ const year = Number(match[1]);
552
+ const month = Number(match[2]);
553
+ const day = Number(match[3]);
554
+ if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
555
+ if (month < 1 || month > 12) return null;
556
+ if (day < 1 || day > 31) return null;
557
+ const ts = Date.UTC(year, month - 1, day);
558
+ return Number.isFinite(ts) ? ts : null;
559
+ }
560
+ async function readLatestCommitTimestamp(projectDir) {
561
+ try {
562
+ const { stdout } = await execFileAsync("git", [
563
+ "log",
564
+ "-1",
565
+ "--format=%ct"
566
+ ], {
567
+ cwd: projectDir,
568
+ maxBuffer: 1024 * 1024,
569
+ encoding: "utf8"
570
+ });
571
+ const seconds = Number(stdout.trim());
572
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : null;
573
+ } catch {
574
+ return null;
575
+ }
576
+ }
577
+ function emitDashboardGitTaskStatus() {
578
+ dashboardGitTaskStatusEmitter.emit("change", getDashboardGitTaskStatus());
579
+ }
580
+ function beginDashboardGitTask(reason) {
581
+ dashboardGitTaskStatus.inFlight += 1;
582
+ dashboardGitTaskStatus.running = true;
583
+ dashboardGitTaskStatus.lastStartedAt = Date.now();
584
+ dashboardGitTaskStatus.lastReason = reason;
585
+ dashboardGitTaskStatus.lastError = null;
586
+ emitDashboardGitTaskStatus();
587
+ }
588
+ function endDashboardGitTask(error) {
589
+ dashboardGitTaskStatus.inFlight = Math.max(0, dashboardGitTaskStatus.inFlight - 1);
590
+ dashboardGitTaskStatus.running = dashboardGitTaskStatus.inFlight > 0;
591
+ dashboardGitTaskStatus.lastFinishedAt = Date.now();
592
+ if (error) dashboardGitTaskStatus.lastError = error instanceof Error ? error.message : String(error);
593
+ emitDashboardGitTaskStatus();
594
+ }
595
+ function getDashboardGitTaskStatus() {
596
+ return { ...dashboardGitTaskStatus };
597
+ }
598
+ function subscribeDashboardGitTaskStatus(listener) {
599
+ dashboardGitTaskStatusEmitter.on("change", listener);
600
+ return () => {
601
+ dashboardGitTaskStatusEmitter.off("change", listener);
602
+ };
603
+ }
604
+ async function resolveGitMetadataDir(projectDir) {
605
+ try {
606
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--git-dir"], {
607
+ cwd: projectDir,
608
+ maxBuffer: 1024 * 1024,
609
+ encoding: "utf8"
610
+ });
611
+ const gitDirRaw = stdout.trim();
612
+ if (!gitDirRaw) return null;
613
+ const gitDirPath = resolve(projectDir, gitDirRaw);
614
+ if (!(await stat(gitDirPath)).isDirectory()) return null;
615
+ return gitDirPath;
616
+ } catch {
617
+ return null;
618
+ }
619
+ }
620
+ function getDashboardGitRefreshStampPath(gitMetadataDir) {
621
+ return join(gitMetadataDir, DASHBOARD_GIT_REFRESH_STAMP_NAME);
622
+ }
623
+ async function touchDashboardGitRefreshStamp(projectDir, reason) {
624
+ const gitMetadataDir = await resolveGitMetadataDir(projectDir);
625
+ if (!gitMetadataDir) return { skipped: true };
626
+ const stampPath = getDashboardGitRefreshStampPath(gitMetadataDir);
627
+ await mkdir(dirname(stampPath), { recursive: true });
628
+ await writeFile(stampPath, `${Date.now()} ${reason}\n`, "utf8");
629
+ return { skipped: false };
630
+ }
631
+ async function loadDashboardOverview(ctx, reason = "dashboard-refresh") {
632
+ const now = Date.now();
633
+ const [specMetas, changeMetas, archiveMetas] = await Promise.all([
634
+ ctx.adapter.listSpecsWithMeta(),
635
+ ctx.adapter.listChangesWithMeta(),
636
+ ctx.adapter.listArchivedChangesWithMeta()
637
+ ]);
638
+ const activeChanges = changeMetas.map((changeMeta) => ({
639
+ id: changeMeta.id,
640
+ name: changeMeta.name ?? changeMeta.id,
641
+ progress: changeMeta.progress,
642
+ updatedAt: changeMeta.updatedAt
643
+ })).sort((a, b) => b.updatedAt - a.updatedAt);
644
+ const archivedChanges = (await Promise.all(archiveMetas.map(async (meta) => {
645
+ const change = await ctx.adapter.readArchivedChange(meta.id);
646
+ if (!change) return null;
647
+ return {
648
+ id: meta.id,
649
+ createdAt: meta.createdAt,
650
+ updatedAt: meta.updatedAt,
651
+ tasksCompleted: change.tasks.filter((task) => task.completed).length
652
+ };
653
+ }))).filter((item) => item !== null);
654
+ const specifications = (await Promise.all(specMetas.map(async (meta) => {
655
+ const spec = await ctx.adapter.readSpec(meta.id);
656
+ if (!spec) return null;
657
+ return {
658
+ id: meta.id,
659
+ name: meta.name,
660
+ requirements: spec.requirements.length,
661
+ updatedAt: meta.updatedAt
662
+ };
663
+ }))).filter((item) => item !== null).sort((a, b) => b.requirements - a.requirements || b.updatedAt - a.updatedAt);
664
+ const requirements = specifications.reduce((sum, spec) => sum + spec.requirements, 0);
665
+ const tasksTotal = activeChanges.reduce((sum, change) => sum + change.progress.total, 0);
666
+ const tasksCompleted = activeChanges.reduce((sum, change) => sum + change.progress.completed, 0);
667
+ const archivedTasksCompleted = archivedChanges.reduce((sum, change) => sum + change.tasksCompleted, 0);
668
+ const taskCompletionPercent = tasksTotal > 0 ? Math.round(tasksCompleted / tasksTotal * 100) : null;
669
+ const inProgressChanges = activeChanges.filter((change) => change.progress.total > 0 && change.progress.completed < change.progress.total).length;
670
+ const specificationTrendEvents = specMetas.flatMap((spec) => {
671
+ const ts = resolveTrendTimestamp(spec.createdAt, spec.updatedAt);
672
+ return ts === null ? [] : [{
673
+ ts,
674
+ value: 1
675
+ }];
676
+ });
677
+ const completedTrendEvents = archivedChanges.flatMap((archive) => {
678
+ const ts = parseDatedIdTimestamp(archive.id) ?? resolveTrendTimestamp(archive.updatedAt, archive.createdAt);
679
+ return ts === null ? [] : [{
680
+ ts,
681
+ value: archive.tasksCompleted
682
+ }];
683
+ });
684
+ const specMetaById = new Map(specMetas.map((meta) => [meta.id, meta]));
685
+ const requirementTrendEvents = specifications.flatMap((spec) => {
686
+ const meta = specMetaById.get(spec.id);
687
+ const ts = resolveTrendTimestamp(meta?.updatedAt, meta?.createdAt);
688
+ return ts === null ? [] : [{
689
+ ts,
690
+ value: spec.requirements
691
+ }];
692
+ });
693
+ const hasObjectiveSpecificationTrend = specificationTrendEvents.length > 0 || specifications.length === 0;
694
+ const hasObjectiveRequirementTrend = requirementTrendEvents.length > 0 || requirements === 0;
695
+ const hasObjectiveCompletedTrend = completedTrendEvents.length > 0 || archiveMetas.length === 0;
696
+ const config = await ctx.configManager.readConfig();
697
+ beginDashboardGitTask(reason);
698
+ let latestCommitTs = null;
699
+ let git;
700
+ try {
701
+ const gitSnapshotPromise = buildDashboardGitSnapshot({ projectDir: ctx.projectDir }).catch(() => ({
702
+ defaultBranch: "main",
703
+ worktrees: []
704
+ }));
705
+ latestCommitTs = await readLatestCommitTimestamp(ctx.projectDir);
706
+ git = await gitSnapshotPromise;
707
+ } catch (error) {
708
+ endDashboardGitTask(error);
709
+ throw error;
710
+ }
711
+ endDashboardGitTask(null);
712
+ const cardAvailability = {
713
+ specifications: hasObjectiveSpecificationTrend ? { state: "ok" } : {
714
+ state: "invalid",
715
+ reason: "objective-history-unavailable"
716
+ },
717
+ requirements: hasObjectiveRequirementTrend ? { state: "ok" } : {
718
+ state: "invalid",
719
+ reason: "objective-history-unavailable"
720
+ },
721
+ activeChanges: {
722
+ state: "invalid",
723
+ reason: "objective-history-unavailable"
724
+ },
725
+ inProgressChanges: {
726
+ state: "invalid",
727
+ reason: "objective-history-unavailable"
728
+ },
729
+ completedChanges: hasObjectiveCompletedTrend ? { state: "ok" } : {
730
+ state: "invalid",
731
+ reason: "objective-history-unavailable"
732
+ },
733
+ taskCompletionPercent: {
734
+ state: "invalid",
735
+ reason: taskCompletionPercent === null ? "semantic-uncomputable" : "objective-history-unavailable"
736
+ }
737
+ };
738
+ const trendKinds = {
739
+ specifications: "monotonic",
740
+ requirements: "monotonic",
741
+ activeChanges: "bidirectional",
742
+ inProgressChanges: "bidirectional",
743
+ completedChanges: "monotonic",
744
+ taskCompletionPercent: "bidirectional"
745
+ };
746
+ const { trends: baselineTrends, trendMeta } = buildDashboardTimeTrends({
747
+ pointLimit: config.dashboard.trendPointLimit,
748
+ timestamp: now,
749
+ rightEdgeTs: latestCommitTs,
750
+ availability: cardAvailability,
751
+ events: {
752
+ specifications: specificationTrendEvents,
753
+ requirements: requirementTrendEvents,
754
+ activeChanges: [],
755
+ inProgressChanges: [],
756
+ completedChanges: completedTrendEvents,
757
+ taskCompletionPercent: []
758
+ },
759
+ reducers: {
760
+ specifications: "sum",
761
+ requirements: "sum",
762
+ completedChanges: "sum"
763
+ }
764
+ });
765
+ return {
766
+ summary: {
767
+ specifications: specifications.length,
768
+ requirements,
769
+ activeChanges: activeChanges.length,
770
+ inProgressChanges,
771
+ completedChanges: archiveMetas.length,
772
+ archivedTasksCompleted,
773
+ tasksTotal,
774
+ tasksCompleted,
775
+ taskCompletionPercent
776
+ },
777
+ trends: baselineTrends,
778
+ triColorTrends: createEmptyTriColorTrends(),
779
+ trendKinds,
780
+ cardAvailability,
781
+ trendMeta,
782
+ specifications,
783
+ activeChanges,
784
+ git
785
+ };
786
+ }
787
+
788
+ //#endregion
789
+ //#region src/pty-manager.ts
790
+ const DEFAULT_SCROLLBACK = 1e3;
791
+ const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
792
+ function detectPtyPlatform() {
793
+ if (process.platform === "win32") return "windows";
794
+ if (process.platform === "darwin") return "macos";
795
+ return "common";
796
+ }
797
+ function resolveDefaultShell(platform, env) {
798
+ if (platform === "windows") return env.ComSpec?.trim() || "cmd.exe";
799
+ return env.SHELL?.trim() || "/bin/sh";
800
+ }
801
+ function resolvePtyCommand(opts) {
802
+ const command = opts.command?.trim();
803
+ if (command) return {
804
+ command,
805
+ args: opts.args ?? []
806
+ };
807
+ return {
808
+ command: resolveDefaultShell(opts.platform, opts.env),
809
+ args: []
810
+ };
811
+ }
812
+ var PtySession = class extends EventEmitter$1 {
813
+ id;
814
+ command;
815
+ args;
816
+ platform;
817
+ closeTip;
818
+ closeCallbackUrl;
819
+ createdAt;
820
+ process;
821
+ titleInterval = null;
822
+ lastTitle = "";
823
+ buffer = [];
824
+ bufferByteLength = 0;
825
+ maxBufferLines;
826
+ maxBufferBytes;
827
+ isExited = false;
828
+ exitCode = null;
829
+ constructor(id, opts) {
830
+ super();
831
+ this.id = id;
832
+ this.createdAt = Date.now();
833
+ const resolvedCommand = resolvePtyCommand({
834
+ platform: opts.platform,
835
+ command: opts.command,
836
+ args: opts.args,
837
+ env: process.env
838
+ });
839
+ this.command = resolvedCommand.command;
840
+ this.args = resolvedCommand.args;
841
+ this.platform = opts.platform;
842
+ this.closeTip = opts.closeTip;
843
+ this.closeCallbackUrl = opts.closeCallbackUrl;
844
+ this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
845
+ this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
846
+ this.process = pty.spawn(this.command, this.args, {
847
+ name: "xterm-256color",
848
+ cols: opts.cols ?? 80,
849
+ rows: opts.rows ?? 24,
850
+ cwd: opts.cwd,
851
+ env: {
852
+ ...process.env,
853
+ TERM: "xterm-256color"
854
+ }
855
+ });
856
+ this.process.onData((data) => {
857
+ this.appendBuffer(data);
858
+ this.emit("data", data);
859
+ });
860
+ this.process.onExit(({ exitCode }) => {
861
+ if (this.titleInterval) {
862
+ clearInterval(this.titleInterval);
863
+ this.titleInterval = null;
864
+ }
865
+ this.isExited = true;
866
+ this.exitCode = exitCode;
867
+ this.emit("exit", exitCode);
868
+ });
869
+ this.titleInterval = setInterval(() => {
870
+ try {
871
+ const title = this.process.process;
872
+ if (title && title !== this.lastTitle) {
873
+ this.lastTitle = title;
874
+ this.emit("title", title);
875
+ }
876
+ } catch {}
877
+ }, 1e3);
878
+ }
879
+ get title() {
880
+ return this.lastTitle;
881
+ }
882
+ appendBuffer(data) {
883
+ let chunk = data;
884
+ if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
885
+ this.buffer.push(chunk);
886
+ this.bufferByteLength += chunk.length;
887
+ while (this.bufferByteLength > this.maxBufferBytes && this.buffer.length > 0) {
888
+ const removed = this.buffer.shift();
889
+ this.bufferByteLength -= removed.length;
890
+ }
891
+ while (this.buffer.length > this.maxBufferLines) {
892
+ const removed = this.buffer.shift();
893
+ this.bufferByteLength -= removed.length;
894
+ }
895
+ }
896
+ getBuffer() {
897
+ return this.buffer.join("");
898
+ }
899
+ write(data) {
900
+ if (!this.isExited) this.process.write(data);
901
+ }
902
+ resize(cols, rows) {
903
+ if (!this.isExited) this.process.resize(cols, rows);
904
+ }
905
+ close() {
906
+ if (this.titleInterval) {
907
+ clearInterval(this.titleInterval);
908
+ this.titleInterval = null;
909
+ }
910
+ try {
911
+ this.process.kill();
912
+ } catch {}
913
+ this.removeAllListeners();
914
+ }
915
+ toInfo() {
916
+ return {
917
+ id: this.id,
918
+ title: this.lastTitle,
919
+ command: this.command,
920
+ args: this.args,
921
+ platform: this.platform,
922
+ isExited: this.isExited,
923
+ exitCode: this.exitCode,
924
+ closeTip: this.closeTip,
925
+ closeCallbackUrl: this.closeCallbackUrl,
926
+ createdAt: this.createdAt
927
+ };
928
+ }
929
+ };
930
+ var PtyManager = class {
931
+ sessions = /* @__PURE__ */ new Map();
932
+ idCounter = 0;
933
+ platform;
934
+ constructor(defaultCwd) {
935
+ this.defaultCwd = defaultCwd;
936
+ this.platform = detectPtyPlatform();
937
+ }
938
+ create(opts) {
939
+ const id = `pty-${++this.idCounter}`;
940
+ const session = new PtySession(id, {
941
+ cols: opts.cols,
942
+ rows: opts.rows,
943
+ command: opts.command,
944
+ args: opts.args,
945
+ closeTip: opts.closeTip,
946
+ closeCallbackUrl: opts.closeCallbackUrl,
947
+ cwd: this.defaultCwd,
948
+ scrollback: opts.scrollback,
949
+ maxBufferBytes: opts.maxBufferBytes,
950
+ platform: this.platform
951
+ });
952
+ this.sessions.set(id, session);
953
+ return session;
954
+ }
955
+ get(id) {
956
+ return this.sessions.get(id);
957
+ }
958
+ list() {
959
+ const result = [];
960
+ for (const session of this.sessions.values()) result.push(session.toInfo());
961
+ return result;
962
+ }
963
+ write(id, data) {
964
+ this.sessions.get(id)?.write(data);
965
+ }
966
+ resize(id, cols, rows) {
967
+ this.sessions.get(id)?.resize(cols, rows);
968
+ }
969
+ close(id) {
970
+ const session = this.sessions.get(id);
971
+ if (session) {
972
+ session.close();
973
+ this.sessions.delete(id);
974
+ }
975
+ }
976
+ closeAll() {
977
+ for (const session of this.sessions.values()) session.close();
978
+ this.sessions.clear();
979
+ }
980
+ };
981
+
982
+ //#endregion
983
+ //#region src/pty-websocket.ts
984
+ function createPtyWebSocketHandler(ptyManager) {
985
+ return (ws) => {
986
+ const cleanups = /* @__PURE__ */ new Map();
987
+ const send = (msg) => {
988
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
989
+ };
990
+ const sendError = (code, message, opts) => {
991
+ send({
992
+ type: "error",
993
+ code,
994
+ message,
995
+ sessionId: opts?.sessionId
996
+ });
997
+ };
998
+ const attachToSession = (session, opts) => {
999
+ const sessionId = session.id;
1000
+ cleanups.get(sessionId)?.();
1001
+ if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
1002
+ const onData = (data) => {
1003
+ send({
1004
+ type: "output",
1005
+ sessionId,
1006
+ data
1007
+ });
1008
+ };
1009
+ const onExit = (exitCode) => {
1010
+ send({
1011
+ type: "exit",
1012
+ sessionId,
1013
+ exitCode
1014
+ });
1015
+ };
1016
+ const onTitle = (title) => {
1017
+ send({
1018
+ type: "title",
1019
+ sessionId,
1020
+ title
1021
+ });
1022
+ };
1023
+ session.on("data", onData);
1024
+ session.on("exit", onExit);
1025
+ session.on("title", onTitle);
1026
+ cleanups.set(sessionId, () => {
1027
+ session.removeListener("data", onData);
1028
+ session.removeListener("exit", onExit);
1029
+ session.removeListener("title", onTitle);
1030
+ cleanups.delete(sessionId);
1031
+ });
1032
+ };
1033
+ ws.on("message", (raw) => {
1034
+ let parsed;
1035
+ try {
1036
+ parsed = JSON.parse(String(raw));
1037
+ } catch {
1038
+ sendError("INVALID_JSON", "Invalid JSON payload");
1039
+ return;
1040
+ }
1041
+ const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
1042
+ if (!parsedMessage.success) {
1043
+ const firstIssue = parsedMessage.error.issues[0]?.message;
1044
+ sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
1045
+ return;
1046
+ }
1047
+ const msg = parsedMessage.data;
1048
+ switch (msg.type) {
1049
+ case "create":
1050
+ try {
1051
+ const createMessage = msg;
1052
+ const session = ptyManager.create({
1053
+ cols: msg.cols,
1054
+ rows: msg.rows,
1055
+ command: msg.command,
1056
+ args: msg.args,
1057
+ closeTip: createMessage.closeTip,
1058
+ closeCallbackUrl: createMessage.closeCallbackUrl
1059
+ });
1060
+ send({
1061
+ type: "created",
1062
+ requestId: msg.requestId,
1063
+ sessionId: session.id,
1064
+ platform: session.platform
1065
+ });
1066
+ attachToSession(session);
1067
+ } catch (err) {
1068
+ sendError("PTY_CREATE_FAILED", err instanceof Error ? err.message : String(err), { sessionId: msg.requestId });
1069
+ }
1070
+ break;
1071
+ case "attach": {
1072
+ const session = ptyManager.get(msg.sessionId);
1073
+ if (!session) {
1074
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1075
+ send({
1076
+ type: "exit",
1077
+ sessionId: msg.sessionId,
1078
+ exitCode: -1
1079
+ });
1080
+ break;
1081
+ }
1082
+ attachToSession(session, {
1083
+ cols: msg.cols,
1084
+ rows: msg.rows
1085
+ });
1086
+ const buffer = session.getBuffer();
1087
+ if (buffer) send({
1088
+ type: "buffer",
1089
+ sessionId: session.id,
1090
+ data: buffer
1091
+ });
1092
+ if (session.title) send({
1093
+ type: "title",
1094
+ sessionId: session.id,
1095
+ title: session.title
1096
+ });
1097
+ if (session.isExited) send({
1098
+ type: "exit",
1099
+ sessionId: session.id,
1100
+ exitCode: session.exitCode ?? -1
1101
+ });
1102
+ break;
1103
+ }
1104
+ case "list":
1105
+ send({
1106
+ type: "list",
1107
+ sessions: ptyManager.list().map((s) => ({
1108
+ id: s.id,
1109
+ title: s.title,
1110
+ command: s.command,
1111
+ args: s.args,
1112
+ platform: s.platform,
1113
+ isExited: s.isExited,
1114
+ exitCode: s.exitCode,
1115
+ closeTip: s.closeTip,
1116
+ closeCallbackUrl: s.closeCallbackUrl
1117
+ }))
1118
+ });
1119
+ break;
1120
+ case "input": {
1121
+ const session = ptyManager.get(msg.sessionId);
1122
+ if (!session) {
1123
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1124
+ break;
1125
+ }
1126
+ session.write(msg.data);
1127
+ break;
1128
+ }
1129
+ case "resize": {
1130
+ const session = ptyManager.get(msg.sessionId);
1131
+ if (!session) {
1132
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1133
+ break;
1134
+ }
1135
+ session.resize(msg.cols, msg.rows);
1136
+ break;
1137
+ }
1138
+ case "close": {
1139
+ const session = ptyManager.get(msg.sessionId);
1140
+ if (!session) {
1141
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1142
+ break;
1143
+ }
1144
+ cleanups.get(msg.sessionId)?.();
1145
+ ptyManager.close(session.id);
1146
+ break;
1147
+ }
1148
+ }
1149
+ });
1150
+ ws.on("close", () => {
1151
+ for (const cleanup of cleanups.values()) cleanup();
1152
+ cleanups.clear();
1153
+ });
1154
+ };
1155
+ }
1156
+
1157
+ //#endregion
1158
+ //#region src/cli-stream-observable.ts
1159
+ /**
1160
+ * 创建安全的 CLI 流式 observable
1161
+ *
1162
+ * 解决的问题:
1163
+ * 1. 防止在 emit.complete() 之后调用 emit.next()(会导致 "Controller is already closed" 错误)
1164
+ * 2. 统一的错误处理,防止未捕获的异常导致服务器崩溃
1165
+ * 3. 确保取消时正确清理资源
1166
+ *
1167
+ * @param startStream 启动流的函数,接收 onEvent 回调,返回取消函数的 Promise
1168
+ */
1169
+ function createCliStreamObservable(startStream) {
1170
+ return observable((emit) => {
1171
+ let cancel;
1172
+ let completed = false;
1173
+ /**
1174
+ * 安全的事件处理器
1175
+ * - 检查是否已完成,防止重复调用
1176
+ * - 使用 try-catch 防止异常导致服务器崩溃
1177
+ */
1178
+ const safeEventHandler = (event) => {
1179
+ if (completed) return;
1180
+ try {
1181
+ emit.next(event);
1182
+ if (event.type === "exit") {
1183
+ completed = true;
1184
+ emit.complete();
1185
+ }
1186
+ } catch (err) {
1187
+ console.error("[CLI Stream] Error emitting event:", err);
1188
+ if (!completed) {
1189
+ completed = true;
1190
+ try {
1191
+ emit.error(err instanceof Error ? err : new Error(String(err)));
1192
+ } catch {}
1193
+ }
1194
+ }
813
1195
  };
814
- if (reducer === "sum-cumulative") {
815
- cumulative += sums[index];
816
- return {
817
- ts,
818
- value: cumulative
819
- };
820
- }
821
- if (counts[index] > 0) carry = sums[index] / counts[index];
822
- return {
823
- ts,
824
- value: carry
1196
+ startStream(safeEventHandler).then((cancelFn) => {
1197
+ cancel = cancelFn;
1198
+ }).catch((err) => {
1199
+ console.error("[CLI Stream] Error starting stream:", err);
1200
+ if (!completed) {
1201
+ completed = true;
1202
+ try {
1203
+ emit.error(err instanceof Error ? err : new Error(String(err)));
1204
+ } catch {}
1205
+ }
1206
+ });
1207
+ return () => {
1208
+ completed = true;
1209
+ cancel?.();
825
1210
  };
826
1211
  });
827
1212
  }
828
- function buildDashboardTimeTrends(options) {
829
- const pointLimit = clampPointLimit(options.pointLimit);
830
- const trends = createEmptyTrendSeries();
831
- for (const metric of DASHBOARD_METRIC_KEYS) {
832
- if (options.availability[metric].state !== "ok") continue;
833
- trends[metric] = bucketizeTrend(normalizeEvents(options.events[metric], pointLimit), options.reducers?.[metric] ?? "sum", options.rightEdgeTs);
834
- }
835
- return {
836
- trends,
837
- trendMeta: {
838
- pointLimit,
839
- lastUpdatedAt: options.timestamp
840
- }
841
- };
842
- }
843
1213
 
844
1214
  //#endregion
845
1215
  //#region src/reactive-kv.ts
@@ -853,7 +1223,7 @@ function buildDashboardTimeTrends(options) {
853
1223
  */
854
1224
  var ReactiveKV = class {
855
1225
  store = /* @__PURE__ */ new Map();
856
- emitter = new EventEmitter$1();
1226
+ emitter = new EventEmitter();
857
1227
  constructor() {
858
1228
  this.emitter.setMaxListeners(200);
859
1229
  }
@@ -921,130 +1291,52 @@ function createReactiveSubscription(task) {
921
1291
  const context = new ReactiveContext();
922
1292
  const controller = new AbortController();
923
1293
  (async () => {
924
- try {
925
- for await (const data of context.stream(task, controller.signal)) emit.next(data);
926
- } catch (err) {
927
- if (!controller.signal.aborted) emit.error(err);
928
- }
929
- })();
930
- return () => {
931
- controller.abort();
932
- };
933
- });
934
- }
935
- /**
936
- * 创建带输入参数的响应式订阅
937
- *
938
- * @param task 接收输入参数的异步任务
939
- * @returns 返回一个函数,接收输入参数并返回 tRPC observable
940
- *
941
- * @example
942
- * ```typescript
943
- * // 在 router 中使用
944
- * subscribeOne: publicProcedure
945
- * .input(z.object({ id: z.string() }))
946
- * .subscription(({ ctx, input }) => {
947
- * return createReactiveSubscriptionWithInput(
948
- * (id: string) => ctx.adapter.readSpec(id)
949
- * )(input.id)
950
- * })
951
- * ```
952
- */
953
- function createReactiveSubscriptionWithInput(task) {
954
- return (input) => {
955
- return createReactiveSubscription(() => task(input));
956
- };
957
- }
958
-
959
- //#endregion
960
- //#region src/router.ts
961
- const t = initTRPC.context().create();
962
- const router = t.router;
963
- const publicProcedure = t.procedure;
964
- const execFileAsync = promisify(execFile);
965
- const OPSX_CORE_PROFILE_WORKFLOWS = [
966
- "propose",
967
- "explore",
968
- "apply",
969
- "archive"
970
- ];
971
- const dashboardGitTaskStatusEmitter = new EventEmitter$1();
972
- dashboardGitTaskStatusEmitter.setMaxListeners(200);
973
- const dashboardGitTaskStatus = {
974
- running: false,
975
- inFlight: 0,
976
- lastStartedAt: null,
977
- lastFinishedAt: null,
978
- lastReason: null,
979
- lastError: null
980
- };
981
- function getDashboardGitTaskStatus() {
982
- return { ...dashboardGitTaskStatus };
983
- }
984
- function emitDashboardGitTaskStatus() {
985
- dashboardGitTaskStatusEmitter.emit("change", getDashboardGitTaskStatus());
986
- }
987
- function beginDashboardGitTask(reason) {
988
- dashboardGitTaskStatus.inFlight += 1;
989
- dashboardGitTaskStatus.running = true;
990
- dashboardGitTaskStatus.lastStartedAt = Date.now();
991
- dashboardGitTaskStatus.lastReason = reason;
992
- dashboardGitTaskStatus.lastError = null;
993
- emitDashboardGitTaskStatus();
994
- }
995
- function endDashboardGitTask(error) {
996
- dashboardGitTaskStatus.inFlight = Math.max(0, dashboardGitTaskStatus.inFlight - 1);
997
- dashboardGitTaskStatus.running = dashboardGitTaskStatus.inFlight > 0;
998
- dashboardGitTaskStatus.lastFinishedAt = Date.now();
999
- if (error) dashboardGitTaskStatus.lastError = error instanceof Error ? error.message : String(error);
1000
- emitDashboardGitTaskStatus();
1001
- }
1002
- const DASHBOARD_GIT_REFRESH_STAMP_NAME = "openspecui-dashboard-git-refresh.stamp";
1003
- async function resolveGitMetadataDir(projectDir) {
1004
- try {
1005
- const { stdout } = await execFileAsync("git", ["rev-parse", "--git-dir"], {
1006
- cwd: projectDir,
1007
- maxBuffer: 1024 * 1024,
1008
- encoding: "utf8"
1009
- });
1010
- const gitDirRaw = stdout.trim();
1011
- if (!gitDirRaw) return null;
1012
- const gitDirPath = resolve(projectDir, gitDirRaw);
1013
- if (!(await stat(gitDirPath)).isDirectory()) return null;
1014
- return gitDirPath;
1015
- } catch {
1016
- return null;
1017
- }
1018
- }
1019
- async function resolveGitMetadataDirReactive(projectDir) {
1020
- const gitMetadataDir = await resolveGitMetadataDir(projectDir);
1021
- if (!gitMetadataDir) return null;
1022
- await reactiveReadDir(gitMetadataDir, { includeHidden: true });
1023
- return gitMetadataDir;
1024
- }
1025
- function getDashboardGitRefreshStampPath(gitMetadataDir) {
1026
- return join(gitMetadataDir, DASHBOARD_GIT_REFRESH_STAMP_NAME);
1027
- }
1028
- async function touchDashboardGitRefreshStamp(projectDir, reason) {
1029
- const gitMetadataDir = await resolveGitMetadataDir(projectDir);
1030
- if (!gitMetadataDir) return { skipped: true };
1031
- const stampPath = getDashboardGitRefreshStampPath(gitMetadataDir);
1032
- await mkdir(dirname(stampPath), { recursive: true });
1033
- await writeFile(stampPath, `${Date.now()} ${reason}\n`, "utf8");
1034
- return { skipped: false };
1035
- }
1036
- async function registerDashboardGitReactiveDeps(projectDir) {
1037
- await reactiveReadDir(projectDir, {
1038
- includeHidden: true,
1039
- exclude: ["node_modules"]
1294
+ try {
1295
+ for await (const data of context.stream(task, controller.signal)) emit.next(data);
1296
+ } catch (err) {
1297
+ if (!controller.signal.aborted) emit.error(err);
1298
+ }
1299
+ })();
1300
+ return () => {
1301
+ controller.abort();
1302
+ };
1040
1303
  });
1041
- const gitMetadataDir = await resolveGitMetadataDirReactive(projectDir);
1042
- if (!gitMetadataDir) return;
1043
- await reactiveReadFile(getDashboardGitRefreshStampPath(gitMetadataDir));
1044
- await reactiveReadFile(join(gitMetadataDir, "HEAD"));
1045
- await reactiveReadFile(join(gitMetadataDir, "index"));
1046
- await reactiveReadFile(join(gitMetadataDir, "packed-refs"));
1047
1304
  }
1305
+ /**
1306
+ * 创建带输入参数的响应式订阅
1307
+ *
1308
+ * @param task 接收输入参数的异步任务
1309
+ * @returns 返回一个函数,接收输入参数并返回 tRPC observable
1310
+ *
1311
+ * @example
1312
+ * ```typescript
1313
+ * // 在 router 中使用
1314
+ * subscribeOne: publicProcedure
1315
+ * .input(z.object({ id: z.string() }))
1316
+ * .subscription(({ ctx, input }) => {
1317
+ * return createReactiveSubscriptionWithInput(
1318
+ * (id: string) => ctx.adapter.readSpec(id)
1319
+ * )(input.id)
1320
+ * })
1321
+ * ```
1322
+ */
1323
+ function createReactiveSubscriptionWithInput(task) {
1324
+ return (input) => {
1325
+ return createReactiveSubscription(() => task(input));
1326
+ };
1327
+ }
1328
+
1329
+ //#endregion
1330
+ //#region src/router.ts
1331
+ const t = initTRPC.context().create();
1332
+ const router = t.router;
1333
+ const publicProcedure = t.procedure;
1334
+ const OPSX_CORE_PROFILE_WORKFLOWS = [
1335
+ "propose",
1336
+ "explore",
1337
+ "apply",
1338
+ "archive"
1339
+ ];
1048
1340
  function requireChangeId(changeId) {
1049
1341
  if (!changeId) throw new Error("change is required");
1050
1342
  return changeId;
@@ -1214,223 +1506,6 @@ function buildSystemStatus(ctx) {
1214
1506
  watcherLastReinitializeReason: runtime?.lastReinitializeReason ?? null
1215
1507
  };
1216
1508
  }
1217
- function resolveTrendTimestamp(primary, secondary) {
1218
- if (typeof primary === "number" && Number.isFinite(primary) && primary > 0) return primary;
1219
- if (typeof secondary === "number" && Number.isFinite(secondary) && secondary > 0) return secondary;
1220
- return null;
1221
- }
1222
- function parseDatedIdTimestamp(id) {
1223
- const match = /^(\d{4})-(\d{2})-(\d{2})(?:-|$)/.exec(id);
1224
- if (!match) return null;
1225
- const year = Number(match[1]);
1226
- const month = Number(match[2]);
1227
- const day = Number(match[3]);
1228
- if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
1229
- if (month < 1 || month > 12) return null;
1230
- if (day < 1 || day > 31) return null;
1231
- const ts = Date.UTC(year, month - 1, day);
1232
- return Number.isFinite(ts) ? ts : null;
1233
- }
1234
- function createEmptyTriColorTrends() {
1235
- return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
1236
- }
1237
- async function readLatestCommitTimestamp(projectDir) {
1238
- try {
1239
- const { stdout } = await execFileAsync("git", [
1240
- "log",
1241
- "-1",
1242
- "--format=%ct"
1243
- ], {
1244
- cwd: projectDir,
1245
- maxBuffer: 1024 * 1024,
1246
- encoding: "utf8"
1247
- });
1248
- const seconds = Number(stdout.trim());
1249
- return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : null;
1250
- } catch {
1251
- return null;
1252
- }
1253
- }
1254
- async function fetchDashboardOverview(ctx, reason = "dashboard-refresh") {
1255
- if (contextStorage.getStore()) await registerDashboardGitReactiveDeps(ctx.projectDir);
1256
- const now = Date.now();
1257
- const [specMetas, changeMetas, archiveMetas] = await Promise.all([
1258
- ctx.adapter.listSpecsWithMeta(),
1259
- ctx.adapter.listChangesWithMeta(),
1260
- ctx.adapter.listArchivedChangesWithMeta()
1261
- ]);
1262
- await ctx.kernel.waitForWarmup();
1263
- await ctx.kernel.ensureStatusList();
1264
- const statusList = ctx.kernel.getStatusList();
1265
- const changeMetaMap = new Map(changeMetas.map((change) => [change.id, change]));
1266
- const activeChangeIds = new Set([...changeMetas.map((change) => change.id), ...statusList.map((status) => status.changeName)]);
1267
- const statusByChange = new Map(statusList.map((status) => [status.changeName, status]));
1268
- const activeChanges = (await Promise.all([...activeChangeIds].map(async (changeId) => {
1269
- const status = statusByChange.get(changeId);
1270
- const changeMeta = changeMetaMap.get(changeId);
1271
- const statInfo = await reactiveStat(join(ctx.projectDir, "openspec", "changes", changeId));
1272
- let progress = changeMeta?.progress ?? {
1273
- total: 0,
1274
- completed: 0
1275
- };
1276
- if (status) try {
1277
- await ctx.kernel.ensureApplyInstructions(changeId, status.schemaName);
1278
- const apply = ctx.kernel.getApplyInstructions(changeId, status.schemaName);
1279
- progress = {
1280
- total: apply.progress.total,
1281
- completed: apply.progress.complete
1282
- };
1283
- } catch {}
1284
- return {
1285
- id: changeId,
1286
- name: changeMeta?.name ?? changeId,
1287
- progress,
1288
- updatedAt: changeMeta?.updatedAt ?? statInfo?.mtime ?? 0
1289
- };
1290
- }))).sort((a, b) => b.updatedAt - a.updatedAt);
1291
- const archivedChanges = (await Promise.all(archiveMetas.map(async (meta) => {
1292
- const change = await ctx.adapter.readArchivedChange(meta.id);
1293
- if (!change) return null;
1294
- return {
1295
- id: meta.id,
1296
- createdAt: meta.createdAt,
1297
- updatedAt: meta.updatedAt,
1298
- tasksCompleted: change.tasks.filter((task) => task.completed).length
1299
- };
1300
- }))).filter((item) => item !== null);
1301
- const specifications = (await Promise.all(specMetas.map(async (meta) => {
1302
- const spec = await ctx.adapter.readSpec(meta.id);
1303
- if (!spec) return null;
1304
- return {
1305
- id: meta.id,
1306
- name: meta.name,
1307
- requirements: spec.requirements.length,
1308
- updatedAt: meta.updatedAt
1309
- };
1310
- }))).filter((item) => item !== null).sort((a, b) => b.requirements - a.requirements || b.updatedAt - a.updatedAt);
1311
- const requirements = specifications.reduce((sum, spec) => sum + spec.requirements, 0);
1312
- const tasksTotal = activeChanges.reduce((sum, change) => sum + change.progress.total, 0);
1313
- const tasksCompleted = activeChanges.reduce((sum, change) => sum + change.progress.completed, 0);
1314
- const archivedTasksCompleted = archivedChanges.reduce((sum, change) => sum + change.tasksCompleted, 0);
1315
- const taskCompletionPercent = tasksTotal > 0 ? Math.round(tasksCompleted / tasksTotal * 100) : null;
1316
- const inProgressChanges = activeChanges.filter((change) => change.progress.total > 0 && change.progress.completed < change.progress.total).length;
1317
- const specificationTrendEvents = specMetas.flatMap((spec) => {
1318
- const ts = resolveTrendTimestamp(spec.createdAt, spec.updatedAt);
1319
- return ts === null ? [] : [{
1320
- ts,
1321
- value: 1
1322
- }];
1323
- });
1324
- const completedTrendEvents = archivedChanges.flatMap((archive) => {
1325
- const ts = parseDatedIdTimestamp(archive.id) ?? resolveTrendTimestamp(archive.updatedAt, archive.createdAt);
1326
- return ts === null ? [] : [{
1327
- ts,
1328
- value: archive.tasksCompleted
1329
- }];
1330
- });
1331
- const specMetaById = new Map(specMetas.map((meta) => [meta.id, meta]));
1332
- const requirementTrendEvents = specifications.flatMap((spec) => {
1333
- const meta = specMetaById.get(spec.id);
1334
- const ts = resolveTrendTimestamp(meta?.updatedAt, meta?.createdAt);
1335
- return ts === null ? [] : [{
1336
- ts,
1337
- value: spec.requirements
1338
- }];
1339
- });
1340
- const hasObjectiveSpecificationTrend = specificationTrendEvents.length > 0 || specifications.length === 0;
1341
- const hasObjectiveRequirementTrend = requirementTrendEvents.length > 0 || requirements === 0;
1342
- const hasObjectiveCompletedTrend = completedTrendEvents.length > 0 || archiveMetas.length === 0;
1343
- const config = await ctx.configManager.readConfig();
1344
- beginDashboardGitTask(reason);
1345
- let latestCommitTs = null;
1346
- let git;
1347
- try {
1348
- const gitSnapshotPromise = buildDashboardGitSnapshot({ projectDir: ctx.projectDir }).catch(() => ({
1349
- defaultBranch: "main",
1350
- worktrees: []
1351
- }));
1352
- latestCommitTs = await readLatestCommitTimestamp(ctx.projectDir);
1353
- git = await gitSnapshotPromise;
1354
- } catch (error) {
1355
- endDashboardGitTask(error);
1356
- throw error;
1357
- }
1358
- endDashboardGitTask(null);
1359
- const cardAvailability = {
1360
- specifications: hasObjectiveSpecificationTrend ? { state: "ok" } : {
1361
- state: "invalid",
1362
- reason: "objective-history-unavailable"
1363
- },
1364
- requirements: hasObjectiveRequirementTrend ? { state: "ok" } : {
1365
- state: "invalid",
1366
- reason: "objective-history-unavailable"
1367
- },
1368
- activeChanges: {
1369
- state: "invalid",
1370
- reason: "objective-history-unavailable"
1371
- },
1372
- inProgressChanges: {
1373
- state: "invalid",
1374
- reason: "objective-history-unavailable"
1375
- },
1376
- completedChanges: hasObjectiveCompletedTrend ? { state: "ok" } : {
1377
- state: "invalid",
1378
- reason: "objective-history-unavailable"
1379
- },
1380
- taskCompletionPercent: {
1381
- state: "invalid",
1382
- reason: taskCompletionPercent === null ? "semantic-uncomputable" : "objective-history-unavailable"
1383
- }
1384
- };
1385
- const trendKinds = {
1386
- specifications: "monotonic",
1387
- requirements: "monotonic",
1388
- activeChanges: "bidirectional",
1389
- inProgressChanges: "bidirectional",
1390
- completedChanges: "monotonic",
1391
- taskCompletionPercent: "bidirectional"
1392
- };
1393
- const { trends: baselineTrends, trendMeta } = buildDashboardTimeTrends({
1394
- pointLimit: config.dashboard.trendPointLimit,
1395
- timestamp: now,
1396
- rightEdgeTs: latestCommitTs,
1397
- availability: cardAvailability,
1398
- events: {
1399
- specifications: specificationTrendEvents,
1400
- requirements: requirementTrendEvents,
1401
- activeChanges: [],
1402
- inProgressChanges: [],
1403
- completedChanges: completedTrendEvents,
1404
- taskCompletionPercent: []
1405
- },
1406
- reducers: {
1407
- specifications: "sum",
1408
- requirements: "sum",
1409
- completedChanges: "sum"
1410
- }
1411
- });
1412
- return {
1413
- summary: {
1414
- specifications: specifications.length,
1415
- requirements,
1416
- activeChanges: activeChanges.length,
1417
- inProgressChanges,
1418
- completedChanges: archiveMetas.length,
1419
- archivedTasksCompleted,
1420
- tasksTotal,
1421
- tasksCompleted,
1422
- taskCompletionPercent
1423
- },
1424
- trends: baselineTrends,
1425
- triColorTrends: createEmptyTriColorTrends(),
1426
- trendKinds,
1427
- cardAvailability,
1428
- trendMeta,
1429
- specifications,
1430
- activeChanges,
1431
- git
1432
- };
1433
- }
1434
1509
  /**
1435
1510
  * Spec router - spec CRUD operations
1436
1511
  */
@@ -1639,6 +1714,7 @@ const configRouter = router({
1639
1714
  "system"
1640
1715
  ]).optional(),
1641
1716
  codeEditor: z.object({ theme: CodeEditorThemeSchema.optional() }).optional(),
1717
+ appBaseUrl: z.string().optional(),
1642
1718
  terminal: TerminalConfigSchema.omit({ rendererEngine: true }).partial().extend({ rendererEngine: TerminalRendererEngineSchema.optional() }).optional(),
1643
1719
  dashboard: DashboardConfigSchema.partial().optional()
1644
1720
  })).mutation(async ({ ctx, input }) => {
@@ -1646,9 +1722,10 @@ const configRouter = router({
1646
1722
  const hasCliArgs = input.cli !== void 0 && Object.prototype.hasOwnProperty.call(input.cli, "args");
1647
1723
  if (hasCliCommand && !hasCliArgs) {
1648
1724
  await ctx.configManager.setCliCommand(input.cli?.command ?? "");
1649
- if (input.theme !== void 0 || input.codeEditor !== void 0 || input.terminal !== void 0 || input.dashboard !== void 0) await ctx.configManager.writeConfig({
1725
+ if (input.theme !== void 0 || input.codeEditor !== void 0 || input.appBaseUrl !== void 0 || input.terminal !== void 0 || input.dashboard !== void 0) await ctx.configManager.writeConfig({
1650
1726
  theme: input.theme,
1651
1727
  codeEditor: input.codeEditor,
1728
+ appBaseUrl: input.appBaseUrl,
1652
1729
  terminal: input.terminal,
1653
1730
  dashboard: input.dashboard
1654
1731
  });
@@ -2130,30 +2207,52 @@ const systemRouter = router({
2130
2207
  */
2131
2208
  const dashboardRouter = router({
2132
2209
  get: publicProcedure.query(async ({ ctx }) => {
2133
- return fetchDashboardOverview(ctx, "dashboard.get");
2210
+ return ctx.dashboardOverviewService.getCurrent();
2134
2211
  }),
2135
2212
  subscribe: publicProcedure.subscription(({ ctx }) => {
2136
- return createReactiveSubscription(async () => {
2137
- return fetchDashboardOverview(ctx, "dashboard.subscribe");
2213
+ return observable((emit) => {
2214
+ const unsubscribe = ctx.dashboardOverviewService.subscribe((overview) => {
2215
+ emit.next(overview);
2216
+ }, {
2217
+ emitCurrent: true,
2218
+ onError: (error) => {
2219
+ emit.error(error);
2220
+ }
2221
+ });
2222
+ return () => {
2223
+ unsubscribe();
2224
+ };
2138
2225
  });
2139
2226
  }),
2140
2227
  refreshGitSnapshot: publicProcedure.input(z.object({ reason: z.string().optional() }).optional()).mutation(async ({ ctx, input }) => {
2141
2228
  const reason = input?.reason?.trim() || "manual-refresh";
2142
2229
  await touchDashboardGitRefreshStamp(ctx.projectDir, reason);
2230
+ await ctx.dashboardOverviewService.refresh(reason);
2231
+ return { success: true };
2232
+ }),
2233
+ removeDetachedWorktree: publicProcedure.input(z.object({ path: z.string().min(1) })).mutation(async ({ ctx, input }) => {
2234
+ await ctx.dashboardOverviewService.getCurrent();
2235
+ await removeDetachedDashboardGitWorktree({
2236
+ projectDir: ctx.projectDir,
2237
+ targetPath: input.path
2238
+ });
2239
+ await touchDashboardGitRefreshStamp(ctx.projectDir, "remove-detached-worktree");
2240
+ await ctx.dashboardOverviewService.refresh("remove-detached-worktree");
2143
2241
  return { success: true };
2144
2242
  }),
2145
- gitTaskStatus: publicProcedure.query(() => {
2243
+ gitTaskStatus: publicProcedure.query(async ({ ctx }) => {
2244
+ await ctx.dashboardOverviewService.getCurrent();
2146
2245
  return getDashboardGitTaskStatus();
2147
2246
  }),
2148
- subscribeGitTaskStatus: publicProcedure.subscription(() => {
2247
+ subscribeGitTaskStatus: publicProcedure.subscription(({ ctx }) => {
2149
2248
  return observable((emit) => {
2249
+ ctx.dashboardOverviewService.getCurrent().catch(() => {});
2150
2250
  emit.next(getDashboardGitTaskStatus());
2151
- const handler = (status) => {
2251
+ const unsubscribe = subscribeDashboardGitTaskStatus((status) => {
2152
2252
  emit.next(status);
2153
- };
2154
- dashboardGitTaskStatusEmitter.on("change", handler);
2253
+ });
2155
2254
  return () => {
2156
- dashboardGitTaskStatusEmitter.off("change", handler);
2255
+ unsubscribe();
2157
2256
  };
2158
2257
  });
2159
2258
  })
@@ -2323,6 +2422,17 @@ var SearchService = class {
2323
2422
  *
2324
2423
  * @module server
2325
2424
  */
2425
+ const __dirname = dirname(fileURLToPath(import.meta.url));
2426
+ function getServerPackageVersion() {
2427
+ try {
2428
+ const packageJsonPath = join(__dirname, "..", "package.json");
2429
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
2430
+ return typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
2431
+ } catch {
2432
+ return "0.0.0";
2433
+ }
2434
+ }
2435
+ const SERVER_PACKAGE_VERSION = getServerPackageVersion();
2326
2436
  /**
2327
2437
  * Create an OpenSpecUI HTTP server with optional WebSocket support
2328
2438
  */
@@ -2333,6 +2443,11 @@ function createServer(config) {
2333
2443
  const kernel = config.kernel;
2334
2444
  const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
2335
2445
  const searchService = new SearchService(adapter, watcher);
2446
+ const dashboardOverviewService = new DashboardOverviewService((reason) => loadDashboardOverview({
2447
+ adapter,
2448
+ configManager,
2449
+ projectDir: config.projectDir
2450
+ }, reason), watcher);
2336
2451
  const app = new Hono();
2337
2452
  const corsOrigins = config.corsOrigins ?? ["http://localhost:5173", "http://localhost:3000"];
2338
2453
  app.use("*", cors({
@@ -2343,7 +2458,9 @@ function createServer(config) {
2343
2458
  return c.json({
2344
2459
  status: "ok",
2345
2460
  projectDir: config.projectDir,
2346
- watcherEnabled: !!watcher
2461
+ projectName: basename(config.projectDir) || config.projectDir,
2462
+ watcherEnabled: !!watcher,
2463
+ openspecuiVersion: SERVER_PACKAGE_VERSION
2347
2464
  });
2348
2465
  });
2349
2466
  app.use("/trpc/*", async (c) => {
@@ -2357,6 +2474,7 @@ function createServer(config) {
2357
2474
  cliExecutor,
2358
2475
  kernel,
2359
2476
  searchService,
2477
+ dashboardOverviewService,
2360
2478
  watcher,
2361
2479
  projectDir: config.projectDir
2362
2480
  })
@@ -2368,6 +2486,7 @@ function createServer(config) {
2368
2486
  cliExecutor,
2369
2487
  kernel,
2370
2488
  searchService,
2489
+ dashboardOverviewService,
2371
2490
  watcher,
2372
2491
  projectDir: config.projectDir
2373
2492
  });
@@ -2378,6 +2497,7 @@ function createServer(config) {
2378
2497
  cliExecutor,
2379
2498
  kernel,
2380
2499
  searchService,
2500
+ dashboardOverviewService,
2381
2501
  watcher,
2382
2502
  createContext,
2383
2503
  port: config.port ?? 3100
@@ -2425,6 +2545,7 @@ async function createWebSocketServer(server, httpServer, config) {
2425
2545
  wss.close();
2426
2546
  server.watcher?.stop();
2427
2547
  server.searchService.dispose().catch(() => {});
2548
+ server.dashboardOverviewService.dispose();
2428
2549
  }
2429
2550
  };
2430
2551
  }
@@ -2460,6 +2581,9 @@ async function startServer(config, setupApp) {
2460
2581
  server.searchService.init().catch((err) => {
2461
2582
  console.error("Search service warmup failed:", err);
2462
2583
  });
2584
+ server.dashboardOverviewService.init().catch((err) => {
2585
+ console.error("Dashboard overview warmup failed:", err);
2586
+ });
2463
2587
  return {
2464
2588
  url,
2465
2589
  port,