openspecui 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 (34) hide show
  1. package/dist/cli.mjs +272 -23
  2. package/dist/index.mjs +1 -1
  3. package/dist/{open-DDagk2eo.mjs → open-DfwCb8mL.mjs} +1 -1
  4. package/dist/{src-Cx7GJTGT.mjs → src-Nn_MJz41.mjs} +1460 -1313
  5. package/package.json +19 -13
  6. package/web/assets/{BufferResource-Cipj3hQ5.js → BufferResource-CSbEbiLP.js} +1 -1
  7. package/web/assets/{CanvasRenderer-C151fKcy.js → CanvasRenderer-CvpAMPzA.js} +1 -1
  8. package/web/assets/{Filter-BpxymkGv.js → Filter-BqwVCa6B.js} +1 -1
  9. package/web/assets/{RenderTargetSystem-C0zk81Mh.js → RenderTargetSystem-mD_Y2dUA.js} +1 -1
  10. package/web/assets/{WebGLRenderer-De3GrxdN.js → WebGLRenderer-DsvDwXyL.js} +1 -1
  11. package/web/assets/{WebGPURenderer-BkpejvST.js → WebGPURenderer-YcBYXAol.js} +1 -1
  12. package/web/assets/{browserAll-B8KqF-Xa.js → browserAll-MbWxSnea.js} +1 -1
  13. package/web/assets/{ghostty-web-BO5ww0eG.js → ghostty-web-cM8zjOdb.js} +1 -1
  14. package/web/assets/{index-Cj_nv8ue.js → index-B0lABsCj.js} +1 -1
  15. package/web/assets/{index-CwA_uPvf.js → index-B1J3yPM2.js} +1 -1
  16. package/web/assets/{index-CDX9fioY.js → index-BCMJS8eq.js} +1 -1
  17. package/web/assets/{index-CxDip8xJ.js → index-BMKX_bGe.js} +1 -1
  18. package/web/assets/{index-a8osabOh.js → index-BY40EQxT.js} +1 -1
  19. package/web/assets/{index-B-vvVL83.js → index-Cq4njHD4.js} +198 -193
  20. package/web/assets/{index-Bxm-n0do.js → index-Cr4OFm9E.js} +1 -1
  21. package/web/assets/{index-Cv-LON1K.js → index-CskIUxS-.js} +1 -1
  22. package/web/assets/{index-Nl7iD8Yi.js → index-D0sXMPhu.js} +1 -1
  23. package/web/assets/index-DHeR2UYq.css +1 -0
  24. package/web/assets/{index-DtTSWBHJ.js → index-DijRYB99.js} +1 -1
  25. package/web/assets/{index-DzK6gT7C.js → index-DuZDcW_P.js} +1 -1
  26. package/web/assets/{index-iROJdLzk.js → index-DyZwNTgF.js} +1 -1
  27. package/web/assets/{index-Bp2dpDFJ.js → index-JHrsE4PU.js} +1 -1
  28. package/web/assets/{index-DL08rl2O.js → index-KaodXPu0.js} +1 -1
  29. package/web/assets/{index-CFAzjIVm.js → index-TGQYcfbH.js} +1 -1
  30. package/web/assets/{index-2DXouwnE.js → index-kjHuAdn9.js} +1 -1
  31. package/web/assets/{webworkerAll-DPcI1cDK.js → webworkerAll-BE_Ec3Rk.js} +1 -1
  32. package/web/index.html +2 -2
  33. package/LICENSE +0 -21
  34. package/web/assets/index-aWxXp1oO.css +0 -1
@@ -8,18 +8,18 @@ import { mkdir, readFile, rename, writeFile } from "fs/promises";
8
8
  import { dirname, join } from "path";
9
9
  import { AsyncLocalStorage } from "node:async_hooks";
10
10
  import { mkdir as mkdir$1, readFile as readFile$1, readdir, rm, stat, writeFile as writeFile$1 } from "node:fs/promises";
11
- import { dirname as dirname$1, join as join$1, matchesGlob, relative as relative$1, resolve as resolve$1, sep } from "node:path";
11
+ import { basename as basename$1, dirname as dirname$1, join as join$1, matchesGlob, relative as relative$1, resolve as resolve$1, sep } from "node:path";
12
12
  import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
13
13
  import { EventEmitter } from "events";
14
14
  import { watch } from "fs";
15
15
  import { exec, spawn } from "child_process";
16
16
  import { promisify } from "util";
17
- import * as pty from "@lydell/node-pty";
18
- import { execFile } from "node:child_process";
17
+ import { fileURLToPath } from "node:url";
19
18
  import { EventEmitter as EventEmitter$1 } from "node:events";
19
+ import { execFile } from "node:child_process";
20
20
  import { promisify as promisify$1 } from "node:util";
21
+ import * as pty from "@lydell/node-pty";
21
22
  import { Worker as Worker$1 } from "node:worker_threads";
22
- import { fileURLToPath } from "node:url";
23
23
 
24
24
  //#region rolldown:runtime
25
25
  var __create$1 = Object.create;
@@ -6308,6 +6308,7 @@ const OpenSpecUIConfigSchema = objectType({
6308
6308
  }).default({}),
6309
6309
  theme: enumType(THEME_VALUES).default("system"),
6310
6310
  codeEditor: CodeEditorConfigSchema.default(CodeEditorConfigSchema.parse({})),
6311
+ appBaseUrl: stringType().default(""),
6311
6312
  terminal: TerminalConfigSchema.default(TerminalConfigSchema.parse({})),
6312
6313
  dashboard: DashboardConfigSchema.default(DashboardConfigSchema.parse({}))
6313
6314
  });
@@ -6316,6 +6317,7 @@ const DEFAULT_CONFIG = {
6316
6317
  cli: {},
6317
6318
  theme: "system",
6318
6319
  codeEditor: CodeEditorConfigSchema.parse({}),
6320
+ appBaseUrl: "",
6319
6321
  terminal: TerminalConfigSchema.parse({}),
6320
6322
  dashboard: DashboardConfigSchema.parse({})
6321
6323
  };
@@ -6384,6 +6386,7 @@ var ConfigManager = class {
6384
6386
  ...current.codeEditor,
6385
6387
  ...config.codeEditor
6386
6388
  },
6389
+ appBaseUrl: config.appBaseUrl ?? current.appBaseUrl,
6387
6390
  terminal: {
6388
6391
  ...current.terminal,
6389
6392
  ...config.terminal
@@ -7134,6 +7137,37 @@ const DASHBOARD_METRIC_KEYS = [
7134
7137
  "taskCompletionPercent"
7135
7138
  ];
7136
7139
 
7140
+ //#endregion
7141
+ //#region ../core/src/hosted-app.ts
7142
+ const OFFICIAL_APP_BASE_URL = "https://app.openspecui.com";
7143
+ function withHttpsProtocol(value) {
7144
+ if (/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(value)) return value;
7145
+ return `https://${value}`;
7146
+ }
7147
+ function normalizeHostedAppBaseUrl(input) {
7148
+ const trimmed = input.trim();
7149
+ if (!trimmed) throw new Error("Hosted app base URL must not be empty");
7150
+ let parsed;
7151
+ try {
7152
+ parsed = new URL(withHttpsProtocol(trimmed));
7153
+ } catch (error) {
7154
+ throw new Error(`Invalid hosted app base URL: ${error instanceof Error ? error.message : String(error)}`);
7155
+ }
7156
+ parsed.hash = "";
7157
+ parsed.search = "";
7158
+ const pathname = parsed.pathname.replace(/\/+$/, "");
7159
+ parsed.pathname = pathname.length > 0 ? pathname : "/";
7160
+ return parsed.toString().replace(/\/$/, parsed.pathname === "/" ? "" : "");
7161
+ }
7162
+ function resolveHostedAppBaseUrl(options) {
7163
+ return normalizeHostedAppBaseUrl(options.override?.trim() || options.configured?.trim() || OFFICIAL_APP_BASE_URL);
7164
+ }
7165
+ function buildHostedLaunchUrl(options) {
7166
+ const url = new URL(normalizeHostedAppBaseUrl(options.baseUrl));
7167
+ url.searchParams.set("api", options.apiBaseUrl);
7168
+ return url.toString();
7169
+ }
7170
+
7137
7171
  //#endregion
7138
7172
  //#region ../core/src/opsx-display-path.ts
7139
7173
  const VIRTUAL_PROJECT_DIRNAME = "project";
@@ -22852,1022 +22886,1390 @@ var import_websocket = /* @__PURE__ */ __toESM$1(require_websocket(), 1);
22852
22886
  var import_websocket_server = /* @__PURE__ */ __toESM$1(require_websocket_server(), 1);
22853
22887
 
22854
22888
  //#endregion
22855
- //#region ../server/src/pty-manager.ts
22856
- const DEFAULT_SCROLLBACK = 1e3;
22857
- const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
22858
- function detectPtyPlatform() {
22859
- if (process.platform === "win32") return "windows";
22860
- if (process.platform === "darwin") return "macos";
22861
- return "common";
22862
- }
22863
- function resolveDefaultShell(platform, env) {
22864
- if (platform === "windows") return env.ComSpec?.trim() || "cmd.exe";
22865
- return env.SHELL?.trim() || "/bin/sh";
22866
- }
22867
- function resolvePtyCommand(opts) {
22868
- const command = opts.command?.trim();
22869
- if (command) return {
22870
- command,
22871
- args: opts.args ?? []
22872
- };
22873
- return {
22874
- command: resolveDefaultShell(opts.platform, opts.env),
22875
- args: []
22876
- };
22877
- }
22878
- var PtySession = class extends EventEmitter {
22879
- id;
22880
- command;
22881
- args;
22882
- platform;
22883
- closeTip;
22884
- closeCallbackUrl;
22885
- createdAt;
22886
- process;
22887
- titleInterval = null;
22888
- lastTitle = "";
22889
- buffer = [];
22890
- bufferByteLength = 0;
22891
- maxBufferLines;
22892
- maxBufferBytes;
22893
- isExited = false;
22894
- exitCode = null;
22895
- constructor(id, opts) {
22896
- super();
22897
- this.id = id;
22898
- this.createdAt = Date.now();
22899
- const resolvedCommand = resolvePtyCommand({
22900
- platform: opts.platform,
22901
- command: opts.command,
22902
- args: opts.args,
22903
- env: process.env
22904
- });
22905
- this.command = resolvedCommand.command;
22906
- this.args = resolvedCommand.args;
22907
- this.platform = opts.platform;
22908
- this.closeTip = opts.closeTip;
22909
- this.closeCallbackUrl = opts.closeCallbackUrl;
22910
- this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
22911
- this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
22912
- this.process = pty.spawn(this.command, this.args, {
22913
- name: "xterm-256color",
22914
- cols: opts.cols ?? 80,
22915
- rows: opts.rows ?? 24,
22916
- cwd: opts.cwd,
22917
- env: {
22918
- ...process.env,
22919
- TERM: "xterm-256color"
22920
- }
22921
- });
22922
- this.process.onData((data) => {
22923
- this.appendBuffer(data);
22924
- this.emit("data", data);
22925
- });
22926
- this.process.onExit(({ exitCode }) => {
22927
- if (this.titleInterval) {
22928
- clearInterval(this.titleInterval);
22929
- this.titleInterval = null;
22930
- }
22931
- this.isExited = true;
22932
- this.exitCode = exitCode;
22933
- this.emit("exit", exitCode);
22889
+ //#region ../server/src/dashboard-overview-service.ts
22890
+ const REBUILD_DEBOUNCE_MS$1 = 250;
22891
+ var DashboardOverviewService = class {
22892
+ current = null;
22893
+ initialized = false;
22894
+ initPromise = null;
22895
+ refreshPromise = null;
22896
+ refreshTimer = null;
22897
+ pendingRefreshReason = null;
22898
+ emitter = new EventEmitter$1();
22899
+ constructor(loadOverview, watcher) {
22900
+ this.loadOverview = loadOverview;
22901
+ this.emitter.setMaxListeners(200);
22902
+ watcher?.on("change", () => {
22903
+ this.scheduleRefresh("watcher-change");
22934
22904
  });
22935
- this.titleInterval = setInterval(() => {
22936
- try {
22937
- const title = this.process.process;
22938
- if (title && title !== this.lastTitle) {
22939
- this.lastTitle = title;
22940
- this.emit("title", title);
22941
- }
22942
- } catch {}
22943
- }, 1e3);
22944
- }
22945
- get title() {
22946
- return this.lastTitle;
22947
- }
22948
- appendBuffer(data) {
22949
- let chunk = data;
22950
- if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
22951
- this.buffer.push(chunk);
22952
- this.bufferByteLength += chunk.length;
22953
- while (this.bufferByteLength > this.maxBufferBytes && this.buffer.length > 0) {
22954
- const removed = this.buffer.shift();
22955
- this.bufferByteLength -= removed.length;
22956
- }
22957
- while (this.buffer.length > this.maxBufferLines) {
22958
- const removed = this.buffer.shift();
22959
- this.bufferByteLength -= removed.length;
22960
- }
22961
- }
22962
- getBuffer() {
22963
- return this.buffer.join("");
22964
- }
22965
- write(data) {
22966
- if (!this.isExited) this.process.write(data);
22967
22905
  }
22968
- resize(cols, rows) {
22969
- if (!this.isExited) this.process.resize(cols, rows);
22970
- }
22971
- close() {
22972
- if (this.titleInterval) {
22973
- clearInterval(this.titleInterval);
22974
- this.titleInterval = null;
22975
- }
22906
+ async init() {
22907
+ if (this.initialized && this.current) return this.current;
22908
+ if (this.initPromise) return this.initPromise;
22909
+ this.initPromise = this.refresh("init");
22976
22910
  try {
22977
- this.process.kill();
22978
- } catch {}
22979
- this.removeAllListeners();
22980
- }
22981
- toInfo() {
22982
- return {
22983
- id: this.id,
22984
- title: this.lastTitle,
22985
- command: this.command,
22986
- args: this.args,
22987
- platform: this.platform,
22988
- isExited: this.isExited,
22989
- exitCode: this.exitCode,
22990
- closeTip: this.closeTip,
22991
- closeCallbackUrl: this.closeCallbackUrl,
22992
- createdAt: this.createdAt
22993
- };
22911
+ return await this.initPromise;
22912
+ } finally {
22913
+ this.initPromise = null;
22914
+ }
22994
22915
  }
22995
- };
22996
- var PtyManager = class {
22997
- sessions = /* @__PURE__ */ new Map();
22998
- idCounter = 0;
22999
- platform;
23000
- constructor(defaultCwd) {
23001
- this.defaultCwd = defaultCwd;
23002
- this.platform = detectPtyPlatform();
22916
+ async getCurrent() {
22917
+ if (this.current) return this.current;
22918
+ return this.init();
23003
22919
  }
23004
- create(opts) {
23005
- const id = `pty-${++this.idCounter}`;
23006
- const session = new PtySession(id, {
23007
- cols: opts.cols,
23008
- rows: opts.rows,
23009
- command: opts.command,
23010
- args: opts.args,
23011
- closeTip: opts.closeTip,
23012
- closeCallbackUrl: opts.closeCallbackUrl,
23013
- cwd: this.defaultCwd,
23014
- scrollback: opts.scrollback,
23015
- maxBufferBytes: opts.maxBufferBytes,
23016
- platform: this.platform
22920
+ subscribe(listener, options) {
22921
+ this.emitter.on("change", listener);
22922
+ if (options?.emitCurrent) if (this.current) listener(this.current);
22923
+ else this.init().catch((error) => {
22924
+ options?.onError?.(error instanceof Error ? error : new Error(String(error)));
23017
22925
  });
23018
- this.sessions.set(id, session);
23019
- return session;
23020
- }
23021
- get(id) {
23022
- return this.sessions.get(id);
23023
- }
23024
- list() {
23025
- const result = [];
23026
- for (const session of this.sessions.values()) result.push(session.toInfo());
23027
- return result;
23028
- }
23029
- write(id, data) {
23030
- this.sessions.get(id)?.write(data);
23031
- }
23032
- resize(id, cols, rows) {
23033
- this.sessions.get(id)?.resize(cols, rows);
22926
+ return () => {
22927
+ this.emitter.off("change", listener);
22928
+ };
23034
22929
  }
23035
- close(id) {
23036
- const session = this.sessions.get(id);
23037
- if (session) {
23038
- session.close();
23039
- this.sessions.delete(id);
22930
+ scheduleRefresh(reason = "scheduled-refresh") {
22931
+ this.cancelScheduledRefresh();
22932
+ this.refreshTimer = setTimeout(() => {
22933
+ this.refreshTimer = null;
22934
+ this.refresh(reason).catch(() => {});
22935
+ }, REBUILD_DEBOUNCE_MS$1);
22936
+ }
22937
+ async refresh(reason = "manual-refresh") {
22938
+ this.cancelScheduledRefresh();
22939
+ if (this.refreshPromise) {
22940
+ this.pendingRefreshReason = reason;
22941
+ return this.refreshPromise;
22942
+ }
22943
+ this.refreshPromise = (async () => {
22944
+ const next = await this.loadOverview(reason);
22945
+ this.current = next;
22946
+ this.initialized = true;
22947
+ this.emitter.emit("change", next);
22948
+ return next;
22949
+ })();
22950
+ try {
22951
+ return await this.refreshPromise;
22952
+ } finally {
22953
+ this.refreshPromise = null;
22954
+ if (this.pendingRefreshReason) {
22955
+ const pendingReason = this.pendingRefreshReason;
22956
+ this.pendingRefreshReason = null;
22957
+ this.refresh(pendingReason).catch(() => {});
22958
+ }
23040
22959
  }
23041
22960
  }
23042
- closeAll() {
23043
- for (const session of this.sessions.values()) session.close();
23044
- this.sessions.clear();
22961
+ dispose() {
22962
+ this.cancelScheduledRefresh();
22963
+ this.emitter.removeAllListeners();
22964
+ }
22965
+ cancelScheduledRefresh() {
22966
+ if (!this.refreshTimer) return;
22967
+ clearTimeout(this.refreshTimer);
22968
+ this.refreshTimer = null;
23045
22969
  }
23046
22970
  };
23047
22971
 
23048
22972
  //#endregion
23049
- //#region ../server/src/pty-websocket.ts
23050
- function createPtyWebSocketHandler(ptyManager) {
23051
- return (ws) => {
23052
- const cleanups = /* @__PURE__ */ new Map();
23053
- const send = (msg) => {
23054
- if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
22973
+ //#region ../server/src/dashboard-git-snapshot.ts
22974
+ const execFileAsync$1 = promisify$1(execFile);
22975
+ const EMPTY_DIFF = {
22976
+ files: 0,
22977
+ insertions: 0,
22978
+ deletions: 0
22979
+ };
22980
+ async function defaultRunGit(cwd, args) {
22981
+ try {
22982
+ const { stdout } = await execFileAsync$1("git", args, {
22983
+ cwd,
22984
+ encoding: "utf8",
22985
+ maxBuffer: 8 * 1024 * 1024
22986
+ });
22987
+ return {
22988
+ ok: true,
22989
+ stdout
23055
22990
  };
23056
- const sendError = (code, message, opts) => {
23057
- send({
23058
- type: "error",
23059
- code,
23060
- message,
23061
- sessionId: opts?.sessionId
23062
- });
22991
+ } catch {
22992
+ return {
22993
+ ok: false,
22994
+ stdout: ""
23063
22995
  };
23064
- const attachToSession = (session, opts) => {
23065
- const sessionId = session.id;
23066
- cleanups.get(sessionId)?.();
23067
- if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
23068
- const onData = (data) => {
23069
- send({
23070
- type: "output",
23071
- sessionId,
23072
- data
23073
- });
23074
- };
23075
- const onExit = (exitCode) => {
23076
- send({
23077
- type: "exit",
23078
- sessionId,
23079
- exitCode
23080
- });
23081
- };
23082
- const onTitle = (title) => {
23083
- send({
23084
- type: "title",
23085
- sessionId,
23086
- title
23087
- });
22996
+ }
22997
+ }
22998
+ function parseShortStat(output) {
22999
+ const files = Number(/(\d+)\s+files? changed/.exec(output)?.[1] ?? 0);
23000
+ const insertions = Number(/(\d+)\s+insertions?\(\+\)/.exec(output)?.[1] ?? 0);
23001
+ const deletions = Number(/(\d+)\s+deletions?\(-\)/.exec(output)?.[1] ?? 0);
23002
+ return {
23003
+ files: Number.isFinite(files) ? files : 0,
23004
+ insertions: Number.isFinite(insertions) ? insertions : 0,
23005
+ deletions: Number.isFinite(deletions) ? deletions : 0
23006
+ };
23007
+ }
23008
+ function parseNumStat(output) {
23009
+ let files = 0;
23010
+ let insertions = 0;
23011
+ let deletions = 0;
23012
+ for (const line of output.split("\n")) {
23013
+ const trimmed = line.trim();
23014
+ if (!trimmed) continue;
23015
+ const [addRaw, deleteRaw] = trimmed.split(" ");
23016
+ if (!addRaw || !deleteRaw) continue;
23017
+ files += 1;
23018
+ if (addRaw !== "-") insertions += Number(addRaw) || 0;
23019
+ if (deleteRaw !== "-") deletions += Number(deleteRaw) || 0;
23020
+ }
23021
+ return {
23022
+ files,
23023
+ insertions,
23024
+ deletions
23025
+ };
23026
+ }
23027
+ function normalizeGitPath(path$1) {
23028
+ return path$1.replace(/\\/g, "/").replace(/^\.\//, "");
23029
+ }
23030
+ function relativePath(fromDir, target) {
23031
+ const rel = relative$1(fromDir, target);
23032
+ if (!rel || rel.length === 0) return ".";
23033
+ return rel;
23034
+ }
23035
+ function parseBranchName(branchRef, detached) {
23036
+ if (detached) return "(detached)";
23037
+ if (!branchRef) return "(unknown)";
23038
+ return branchRef.replace(/^refs\/heads\//, "");
23039
+ }
23040
+ function parseWorktreeList(porcelain) {
23041
+ const entries = [];
23042
+ let current = null;
23043
+ const flush = () => {
23044
+ if (!current) return;
23045
+ entries.push(current);
23046
+ current = null;
23047
+ };
23048
+ for (const line of porcelain.split("\n")) {
23049
+ if (line.startsWith("worktree ")) {
23050
+ flush();
23051
+ current = {
23052
+ path: line.slice(9).trim(),
23053
+ branchRef: null,
23054
+ detached: false
23088
23055
  };
23089
- session.on("data", onData);
23090
- session.on("exit", onExit);
23091
- session.on("title", onTitle);
23092
- cleanups.set(sessionId, () => {
23093
- session.removeListener("data", onData);
23094
- session.removeListener("exit", onExit);
23095
- session.removeListener("title", onTitle);
23096
- cleanups.delete(sessionId);
23097
- });
23098
- };
23099
- ws.on("message", (raw$1) => {
23100
- let parsed;
23101
- try {
23102
- parsed = JSON.parse(String(raw$1));
23103
- } catch {
23104
- sendError("INVALID_JSON", "Invalid JSON payload");
23105
- return;
23106
- }
23107
- const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
23108
- if (!parsedMessage.success) {
23109
- const firstIssue = parsedMessage.error.issues[0]?.message;
23110
- sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
23111
- return;
23112
- }
23113
- const msg = parsedMessage.data;
23114
- switch (msg.type) {
23115
- case "create":
23116
- try {
23117
- const createMessage = msg;
23118
- const session = ptyManager.create({
23119
- cols: msg.cols,
23120
- rows: msg.rows,
23121
- command: msg.command,
23122
- args: msg.args,
23123
- closeTip: createMessage.closeTip,
23124
- closeCallbackUrl: createMessage.closeCallbackUrl
23125
- });
23126
- send({
23127
- type: "created",
23128
- requestId: msg.requestId,
23129
- sessionId: session.id,
23130
- platform: session.platform
23131
- });
23132
- attachToSession(session);
23133
- } catch (err) {
23134
- sendError("PTY_CREATE_FAILED", err instanceof Error ? err.message : String(err), { sessionId: msg.requestId });
23135
- }
23136
- break;
23137
- case "attach": {
23138
- const session = ptyManager.get(msg.sessionId);
23139
- if (!session) {
23140
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
23141
- send({
23142
- type: "exit",
23143
- sessionId: msg.sessionId,
23144
- exitCode: -1
23145
- });
23146
- break;
23147
- }
23148
- attachToSession(session, {
23149
- cols: msg.cols,
23150
- rows: msg.rows
23151
- });
23152
- const buffer = session.getBuffer();
23153
- if (buffer) send({
23154
- type: "buffer",
23155
- sessionId: session.id,
23156
- data: buffer
23157
- });
23158
- if (session.title) send({
23159
- type: "title",
23160
- sessionId: session.id,
23161
- title: session.title
23162
- });
23163
- if (session.isExited) send({
23164
- type: "exit",
23165
- sessionId: session.id,
23166
- exitCode: session.exitCode ?? -1
23167
- });
23168
- break;
23169
- }
23170
- case "list":
23171
- send({
23172
- type: "list",
23173
- sessions: ptyManager.list().map((s) => ({
23174
- id: s.id,
23175
- title: s.title,
23176
- command: s.command,
23177
- args: s.args,
23178
- platform: s.platform,
23179
- isExited: s.isExited,
23180
- exitCode: s.exitCode,
23181
- closeTip: s.closeTip,
23182
- closeCallbackUrl: s.closeCallbackUrl
23183
- }))
23184
- });
23185
- break;
23186
- case "input": {
23187
- const session = ptyManager.get(msg.sessionId);
23188
- if (!session) {
23189
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
23190
- break;
23191
- }
23192
- session.write(msg.data);
23193
- break;
23194
- }
23195
- case "resize": {
23196
- const session = ptyManager.get(msg.sessionId);
23197
- if (!session) {
23198
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
23199
- break;
23200
- }
23201
- session.resize(msg.cols, msg.rows);
23202
- break;
23203
- }
23204
- case "close": {
23205
- const session = ptyManager.get(msg.sessionId);
23206
- if (!session) {
23207
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
23208
- break;
23209
- }
23210
- cleanups.get(msg.sessionId)?.();
23211
- ptyManager.close(session.id);
23212
- break;
23213
- }
23214
- }
23215
- });
23216
- ws.on("close", () => {
23217
- for (const cleanup of cleanups.values()) cleanup();
23218
- cleanups.clear();
23056
+ continue;
23057
+ }
23058
+ if (!current) continue;
23059
+ if (line.startsWith("branch ")) {
23060
+ current.branchRef = line.slice(7).trim();
23061
+ continue;
23062
+ }
23063
+ if (line === "detached") {
23064
+ current.detached = true;
23065
+ continue;
23066
+ }
23067
+ }
23068
+ flush();
23069
+ return entries;
23070
+ }
23071
+ function parseRelatedChanges(paths) {
23072
+ const related = /* @__PURE__ */ new Set();
23073
+ for (const path$1 of paths) {
23074
+ const normalized = normalizeGitPath(path$1);
23075
+ const activeMatch = /^openspec\/changes\/([^/]+)\//.exec(normalized);
23076
+ if (activeMatch?.[1]) {
23077
+ related.add(activeMatch[1]);
23078
+ continue;
23079
+ }
23080
+ const archiveMatch = /^openspec\/changes\/archive\/([^/]+)\//.exec(normalized);
23081
+ if (archiveMatch?.[1]) {
23082
+ const fullName = archiveMatch[1];
23083
+ related.add(fullName.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
23084
+ }
23085
+ }
23086
+ return [...related].sort((a, b) => a.localeCompare(b));
23087
+ }
23088
+ async function resolveDefaultBranch(projectDir, runGit) {
23089
+ const remoteHead = await runGit(projectDir, [
23090
+ "symbolic-ref",
23091
+ "--quiet",
23092
+ "--short",
23093
+ "refs/remotes/origin/HEAD"
23094
+ ]);
23095
+ const remoteRef = remoteHead.stdout.trim();
23096
+ if (remoteHead.ok && remoteRef) return remoteRef;
23097
+ const localHead = await runGit(projectDir, [
23098
+ "rev-parse",
23099
+ "--abbrev-ref",
23100
+ "HEAD"
23101
+ ]);
23102
+ const localRef = localHead.stdout.trim();
23103
+ if (localHead.ok && localRef && localRef !== "HEAD") return localRef;
23104
+ return "main";
23105
+ }
23106
+ async function collectCommitEntries(options) {
23107
+ const { worktreePath, defaultBranch, maxCommitEntries, runGit } = options;
23108
+ const entries = [];
23109
+ const commits = await runGit(worktreePath, [
23110
+ "log",
23111
+ "--format=%H%x1f%s",
23112
+ `-n${maxCommitEntries}`,
23113
+ `${defaultBranch}..HEAD`
23114
+ ]);
23115
+ if (commits.ok) for (const line of commits.stdout.split("\n")) {
23116
+ if (!line.trim()) continue;
23117
+ const [hash, title = ""] = line.split("");
23118
+ if (!hash) continue;
23119
+ const diffResult = await runGit(worktreePath, [
23120
+ "show",
23121
+ "--numstat",
23122
+ "--format=",
23123
+ hash
23124
+ ]);
23125
+ const changedFiles = (await runGit(worktreePath, [
23126
+ "show",
23127
+ "--name-only",
23128
+ "--format=",
23129
+ hash
23130
+ ])).stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23131
+ entries.push({
23132
+ type: "commit",
23133
+ hash,
23134
+ title: title.trim() || hash.slice(0, 7),
23135
+ relatedChanges: parseRelatedChanges(changedFiles),
23136
+ diff: diffResult.ok ? parseNumStat(diffResult.stdout) : EMPTY_DIFF
23219
23137
  });
23138
+ }
23139
+ const trackedResult = await runGit(worktreePath, [
23140
+ "diff",
23141
+ "--numstat",
23142
+ "HEAD"
23143
+ ]);
23144
+ const trackedFilesResult = await runGit(worktreePath, [
23145
+ "diff",
23146
+ "--name-only",
23147
+ "HEAD"
23148
+ ]);
23149
+ const untrackedResult = await runGit(worktreePath, [
23150
+ "ls-files",
23151
+ "--others",
23152
+ "--exclude-standard"
23153
+ ]);
23154
+ const trackedFiles = trackedFilesResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23155
+ const untrackedFiles = untrackedResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23156
+ const allUncommittedFiles = new Set([...trackedFiles, ...untrackedFiles]);
23157
+ const trackedDiff = trackedResult.ok ? parseNumStat(trackedResult.stdout) : EMPTY_DIFF;
23158
+ entries.push({
23159
+ type: "uncommitted",
23160
+ title: "Uncommitted",
23161
+ relatedChanges: parseRelatedChanges([...allUncommittedFiles]),
23162
+ diff: {
23163
+ files: allUncommittedFiles.size,
23164
+ insertions: trackedDiff.insertions,
23165
+ deletions: trackedDiff.deletions
23166
+ }
23167
+ });
23168
+ return entries;
23169
+ }
23170
+ async function collectWorktree(options) {
23171
+ const { projectDir, worktree, defaultBranch, runGit, maxCommitEntries } = options;
23172
+ const worktreePath = resolve$1(worktree.path);
23173
+ const resolvedProjectDir = resolve$1(projectDir);
23174
+ const aheadBehindResult = await runGit(worktreePath, [
23175
+ "rev-list",
23176
+ "--left-right",
23177
+ "--count",
23178
+ `${defaultBranch}...HEAD`
23179
+ ]);
23180
+ let ahead = 0;
23181
+ let behind = 0;
23182
+ if (aheadBehindResult.ok) {
23183
+ const [behindRaw, aheadRaw] = aheadBehindResult.stdout.trim().split(/\s+/);
23184
+ ahead = Number(aheadRaw) || 0;
23185
+ behind = Number(behindRaw) || 0;
23186
+ }
23187
+ const diffResult = await runGit(worktreePath, [
23188
+ "diff",
23189
+ "--shortstat",
23190
+ `${defaultBranch}...HEAD`
23191
+ ]);
23192
+ const diff = diffResult.ok ? parseShortStat(diffResult.stdout) : EMPTY_DIFF;
23193
+ const entries = await collectCommitEntries({
23194
+ worktreePath,
23195
+ defaultBranch,
23196
+ maxCommitEntries,
23197
+ runGit
23198
+ });
23199
+ return {
23200
+ path: worktreePath,
23201
+ relativePath: relativePath(resolvedProjectDir, worktreePath),
23202
+ branchName: parseBranchName(worktree.branchRef, worktree.detached),
23203
+ detached: worktree.detached,
23204
+ isCurrent: resolvedProjectDir === worktreePath,
23205
+ ahead,
23206
+ behind,
23207
+ diff,
23208
+ entries
23209
+ };
23210
+ }
23211
+ async function removeDetachedDashboardGitWorktree(options) {
23212
+ const runGit = options.runGit ?? defaultRunGit;
23213
+ const resolvedProjectDir = resolve$1(options.projectDir);
23214
+ const resolvedTargetPath = resolve$1(options.targetPath);
23215
+ if (resolvedTargetPath === resolvedProjectDir) throw new Error("Cannot remove the current worktree.");
23216
+ const worktreeResult = await runGit(resolvedProjectDir, [
23217
+ "worktree",
23218
+ "list",
23219
+ "--porcelain"
23220
+ ]);
23221
+ if (!worktreeResult.ok) throw new Error("Failed to inspect git worktrees.");
23222
+ const matched = parseWorktreeList(worktreeResult.stdout).find((worktree) => resolve$1(worktree.path) === resolvedTargetPath);
23223
+ if (!matched) throw new Error("Worktree not found.");
23224
+ if (!matched.detached) throw new Error("Only detached worktrees can be removed from Dashboard.");
23225
+ if (!(await runGit(resolvedProjectDir, [
23226
+ "worktree",
23227
+ "remove",
23228
+ "--force",
23229
+ resolvedTargetPath
23230
+ ])).ok) throw new Error("Failed to remove detached worktree.");
23231
+ }
23232
+ async function buildDashboardGitSnapshot(options) {
23233
+ const runGit = options.runGit ?? defaultRunGit;
23234
+ const maxCommitEntries = options.maxCommitEntries ?? 8;
23235
+ const resolvedProjectDir = resolve$1(options.projectDir);
23236
+ const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
23237
+ const worktreeResult = await runGit(resolvedProjectDir, [
23238
+ "worktree",
23239
+ "list",
23240
+ "--porcelain"
23241
+ ]);
23242
+ const parsed = worktreeResult.ok ? parseWorktreeList(worktreeResult.stdout) : [];
23243
+ const baseWorktrees = parsed.length > 0 ? parsed : [{
23244
+ path: resolvedProjectDir,
23245
+ branchRef: null,
23246
+ detached: false
23247
+ }];
23248
+ const worktrees = await Promise.all(baseWorktrees.map((worktree) => collectWorktree({
23249
+ projectDir: resolvedProjectDir,
23250
+ worktree,
23251
+ defaultBranch,
23252
+ runGit,
23253
+ maxCommitEntries
23254
+ })));
23255
+ worktrees.sort((a, b) => {
23256
+ if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
23257
+ return a.branchName.localeCompare(b.branchName);
23258
+ });
23259
+ return {
23260
+ defaultBranch,
23261
+ worktrees
23220
23262
  };
23221
23263
  }
23222
23264
 
23223
23265
  //#endregion
23224
- //#region ../search/src/protocol.ts
23225
- const SearchDocumentKindSchema = enumType([
23226
- "spec",
23227
- "change",
23228
- "archive"
23229
- ]);
23230
- const SearchDocumentSchema = objectType({
23231
- id: stringType(),
23232
- kind: SearchDocumentKindSchema,
23233
- title: stringType(),
23234
- href: stringType(),
23235
- path: stringType(),
23236
- content: stringType(),
23237
- updatedAt: numberType()
23238
- });
23239
- const SearchQuerySchema = objectType({
23240
- query: stringType(),
23241
- limit: numberType().int().positive().optional()
23242
- });
23243
- const SearchHitSchema = objectType({
23244
- documentId: stringType(),
23245
- kind: SearchDocumentKindSchema,
23246
- title: stringType(),
23247
- href: stringType(),
23248
- path: stringType(),
23249
- score: numberType(),
23250
- snippet: stringType(),
23251
- updatedAt: numberType()
23252
- });
23253
- const SearchWorkerRequestSchema = discriminatedUnionType("type", [
23254
- objectType({
23255
- id: stringType(),
23256
- type: literalType("init"),
23257
- docs: arrayType(SearchDocumentSchema)
23258
- }),
23259
- objectType({
23260
- id: stringType(),
23261
- type: literalType("replaceAll"),
23262
- docs: arrayType(SearchDocumentSchema)
23263
- }),
23264
- objectType({
23265
- id: stringType(),
23266
- type: literalType("search"),
23267
- query: SearchQuerySchema
23268
- }),
23269
- objectType({
23270
- id: stringType(),
23271
- type: literalType("dispose")
23272
- })
23273
- ]);
23274
- const SearchWorkerResponseSchema = discriminatedUnionType("type", [
23275
- objectType({
23276
- id: stringType(),
23277
- type: literalType("ok")
23278
- }),
23279
- objectType({
23280
- id: stringType(),
23281
- type: literalType("results"),
23282
- hits: arrayType(SearchHitSchema)
23283
- }),
23284
- objectType({
23285
- id: stringType(),
23286
- type: literalType("error"),
23287
- message: stringType()
23288
- })
23289
- ]);
23266
+ //#region ../server/src/dashboard-time-trends.ts
23267
+ const MIN_TREND_POINT_LIMIT = 20;
23268
+ const MAX_TREND_POINT_LIMIT = 500;
23269
+ const DEFAULT_TREND_POINT_LIMIT = 100;
23270
+ const TARGET_TREND_BARS = 20;
23271
+ const DAY_MS = 1440 * 60 * 1e3;
23272
+ function clampPointLimit(pointLimit) {
23273
+ if (!Number.isFinite(pointLimit)) return DEFAULT_TREND_POINT_LIMIT;
23274
+ return Math.max(MIN_TREND_POINT_LIMIT, Math.min(MAX_TREND_POINT_LIMIT, Math.trunc(pointLimit)));
23275
+ }
23276
+ function createEmptyTrendSeries() {
23277
+ return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
23278
+ }
23279
+ function normalizeEvents(events, pointLimit) {
23280
+ return events.filter((event) => Number.isFinite(event.ts) && event.ts > 0 && Number.isFinite(event.value)).sort((a, b) => a.ts - b.ts).slice(-pointLimit);
23281
+ }
23282
+ function buildTimeWindow(options) {
23283
+ const { probeEvents, targetBars, rightEdgeTs } = options;
23284
+ if (probeEvents.length === 0) return null;
23285
+ const probeEnd = probeEvents[probeEvents.length - 1].ts;
23286
+ const end = typeof rightEdgeTs === "number" && Number.isFinite(rightEdgeTs) && rightEdgeTs > 0 ? Math.max(probeEnd, rightEdgeTs) : probeEnd;
23287
+ const probeStart = probeEvents[0].ts;
23288
+ const rangeMs = Math.max(1, end - probeStart);
23289
+ const bucketMs = rangeMs >= DAY_MS ? Math.max(DAY_MS, Math.ceil(rangeMs / targetBars / DAY_MS) * DAY_MS) : Math.max(1, Math.ceil(rangeMs / targetBars));
23290
+ const windowStart = end - bucketMs * targetBars;
23291
+ return {
23292
+ windowStart,
23293
+ bucketMs,
23294
+ bucketEnds: Array.from({ length: targetBars }, (_, index) => windowStart + bucketMs * (index + 1))
23295
+ };
23296
+ }
23297
+ function bucketizeTrend(events, reducer, rightEdgeTs) {
23298
+ if (events.length === 0) return [];
23299
+ const timeWindow = buildTimeWindow({
23300
+ probeEvents: events,
23301
+ targetBars: TARGET_TREND_BARS,
23302
+ rightEdgeTs
23303
+ });
23304
+ if (!timeWindow) return [];
23305
+ const { windowStart, bucketMs, bucketEnds } = timeWindow;
23306
+ const sums = Array.from({ length: bucketEnds.length }, () => 0);
23307
+ const counts = Array.from({ length: bucketEnds.length }, () => 0);
23308
+ let baseline = 0;
23309
+ for (const event of events) {
23310
+ if (event.ts <= windowStart) {
23311
+ if (reducer === "sum-cumulative") baseline += event.value;
23312
+ continue;
23313
+ }
23314
+ const offset = event.ts - windowStart;
23315
+ const index = Math.max(0, Math.min(bucketEnds.length - 1, Math.ceil(offset / bucketMs) - 1));
23316
+ sums[index] += event.value;
23317
+ counts[index] += 1;
23318
+ }
23319
+ let cumulative = baseline;
23320
+ let carry = baseline !== 0 ? baseline : events[0].value;
23321
+ return bucketEnds.map((ts, index) => {
23322
+ if (reducer === "sum") return {
23323
+ ts,
23324
+ value: sums[index]
23325
+ };
23326
+ if (reducer === "sum-cumulative") {
23327
+ cumulative += sums[index];
23328
+ return {
23329
+ ts,
23330
+ value: cumulative
23331
+ };
23332
+ }
23333
+ if (counts[index] > 0) carry = sums[index] / counts[index];
23334
+ return {
23335
+ ts,
23336
+ value: carry
23337
+ };
23338
+ });
23339
+ }
23340
+ function buildDashboardTimeTrends(options) {
23341
+ const pointLimit = clampPointLimit(options.pointLimit);
23342
+ const trends = createEmptyTrendSeries();
23343
+ for (const metric of DASHBOARD_METRIC_KEYS) {
23344
+ if (options.availability[metric].state !== "ok") continue;
23345
+ trends[metric] = bucketizeTrend(normalizeEvents(options.events[metric], pointLimit), options.reducers?.[metric] ?? "sum", options.rightEdgeTs);
23346
+ }
23347
+ return {
23348
+ trends,
23349
+ trendMeta: {
23350
+ pointLimit,
23351
+ lastUpdatedAt: options.timestamp
23352
+ }
23353
+ };
23354
+ }
23290
23355
 
23291
23356
  //#endregion
23292
- //#region ../search/src/worker-source.ts
23293
- const sharedRuntimeSource = String.raw`
23294
- const DEFAULT_LIMIT = 50;
23295
- const MAX_LIMIT = 200;
23296
- const SNIPPET_SIZE = 180;
23297
-
23298
- function normalizeText(input) {
23299
- return String(input || '')
23300
- .toLowerCase()
23301
- .replace(/\s+/g, ' ')
23302
- .trim();
23357
+ //#region ../server/src/dashboard-overview.ts
23358
+ const execFileAsync = promisify$1(execFile);
23359
+ const DASHBOARD_GIT_REFRESH_STAMP_NAME = "openspecui-dashboard-git-refresh.stamp";
23360
+ const dashboardGitTaskStatusEmitter = new EventEmitter$1();
23361
+ dashboardGitTaskStatusEmitter.setMaxListeners(200);
23362
+ const dashboardGitTaskStatus = {
23363
+ running: false,
23364
+ inFlight: 0,
23365
+ lastStartedAt: null,
23366
+ lastFinishedAt: null,
23367
+ lastReason: null,
23368
+ lastError: null
23369
+ };
23370
+ function createEmptyTriColorTrends() {
23371
+ return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
23303
23372
  }
23304
-
23305
- function splitTerms(query) {
23306
- return normalizeText(query)
23307
- .split(' ')
23308
- .map((term) => term.trim())
23309
- .filter((term) => term.length > 0);
23373
+ function resolveTrendTimestamp(primary, secondary) {
23374
+ if (typeof primary === "number" && Number.isFinite(primary) && primary > 0) return primary;
23375
+ if (typeof secondary === "number" && Number.isFinite(secondary) && secondary > 0) return secondary;
23376
+ return null;
23310
23377
  }
23311
-
23312
- function toSearchIndexDocument(doc) {
23313
- return {
23314
- id: doc.id,
23315
- kind: doc.kind,
23316
- title: doc.title,
23317
- href: doc.href,
23318
- path: doc.path,
23319
- content: doc.content,
23320
- updatedAt: doc.updatedAt,
23321
- normalizedTitle: normalizeText(doc.title),
23322
- normalizedPath: normalizeText(doc.path),
23323
- normalizedContent: normalizeText(doc.content),
23324
- };
23378
+ function parseDatedIdTimestamp(id) {
23379
+ const match$1 = /^(\d{4})-(\d{2})-(\d{2})(?:-|$)/.exec(id);
23380
+ if (!match$1) return null;
23381
+ const year = Number(match$1[1]);
23382
+ const month = Number(match$1[2]);
23383
+ const day = Number(match$1[3]);
23384
+ if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
23385
+ if (month < 1 || month > 12) return null;
23386
+ if (day < 1 || day > 31) return null;
23387
+ const ts = Date.UTC(year, month - 1, day);
23388
+ return Number.isFinite(ts) ? ts : null;
23325
23389
  }
23326
-
23327
- function buildSearchIndex(docs) {
23328
- return {
23329
- documents: Array.isArray(docs) ? docs.map(toSearchIndexDocument) : [],
23330
- };
23390
+ async function readLatestCommitTimestamp(projectDir) {
23391
+ try {
23392
+ const { stdout } = await execFileAsync("git", [
23393
+ "log",
23394
+ "-1",
23395
+ "--format=%ct"
23396
+ ], {
23397
+ cwd: projectDir,
23398
+ maxBuffer: 1024 * 1024,
23399
+ encoding: "utf8"
23400
+ });
23401
+ const seconds = Number(stdout.trim());
23402
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : null;
23403
+ } catch {
23404
+ return null;
23405
+ }
23331
23406
  }
23332
-
23333
- function resolveLimit(limit) {
23334
- if (typeof limit !== 'number' || Number.isNaN(limit)) return DEFAULT_LIMIT;
23335
- return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(limit)));
23407
+ function emitDashboardGitTaskStatus() {
23408
+ dashboardGitTaskStatusEmitter.emit("change", getDashboardGitTaskStatus());
23336
23409
  }
23337
-
23338
- function isDocumentMatch(doc, terms) {
23339
- return terms.every(
23340
- (term) =>
23341
- doc.normalizedTitle.includes(term) ||
23342
- doc.normalizedPath.includes(term) ||
23343
- doc.normalizedContent.includes(term)
23344
- );
23410
+ function beginDashboardGitTask(reason) {
23411
+ dashboardGitTaskStatus.inFlight += 1;
23412
+ dashboardGitTaskStatus.running = true;
23413
+ dashboardGitTaskStatus.lastStartedAt = Date.now();
23414
+ dashboardGitTaskStatus.lastReason = reason;
23415
+ dashboardGitTaskStatus.lastError = null;
23416
+ emitDashboardGitTaskStatus();
23345
23417
  }
23346
-
23347
- function scoreDocument(doc, terms) {
23348
- let score = 0;
23349
-
23350
- for (const term of terms) {
23351
- if (doc.normalizedTitle.includes(term)) score += 30;
23352
- if (doc.normalizedPath.includes(term)) score += 20;
23353
-
23354
- const contentIdx = doc.normalizedContent.indexOf(term);
23355
- if (contentIdx >= 0) {
23356
- score += 8;
23357
- if (contentIdx < 160) score += 4;
23358
- }
23359
- }
23360
-
23361
- return score;
23418
+ function endDashboardGitTask(error) {
23419
+ dashboardGitTaskStatus.inFlight = Math.max(0, dashboardGitTaskStatus.inFlight - 1);
23420
+ dashboardGitTaskStatus.running = dashboardGitTaskStatus.inFlight > 0;
23421
+ dashboardGitTaskStatus.lastFinishedAt = Date.now();
23422
+ if (error) dashboardGitTaskStatus.lastError = error instanceof Error ? error.message : String(error);
23423
+ emitDashboardGitTaskStatus();
23362
23424
  }
23363
-
23364
- function createSnippet(content, terms) {
23365
- const source = String(content || '').trim();
23366
- if (!source) return '';
23367
-
23368
- const normalizedSource = normalizeText(source);
23369
- let matchIndex = -1;
23370
-
23371
- for (const term of terms) {
23372
- const idx = normalizedSource.indexOf(term);
23373
- if (idx >= 0 && (matchIndex < 0 || idx < matchIndex)) {
23374
- matchIndex = idx;
23375
- }
23376
- }
23377
-
23378
- if (matchIndex < 0) {
23379
- return source.slice(0, SNIPPET_SIZE);
23380
- }
23381
-
23382
- const start = Math.max(0, matchIndex - Math.floor(SNIPPET_SIZE / 3));
23383
- const end = Math.min(source.length, start + SNIPPET_SIZE);
23384
-
23385
- const prefix = start > 0 ? '...' : '';
23386
- const suffix = end < source.length ? '...' : '';
23387
- return prefix + source.slice(start, end) + suffix;
23425
+ function getDashboardGitTaskStatus() {
23426
+ return { ...dashboardGitTaskStatus };
23427
+ }
23428
+ function subscribeDashboardGitTaskStatus(listener) {
23429
+ dashboardGitTaskStatusEmitter.on("change", listener);
23430
+ return () => {
23431
+ dashboardGitTaskStatusEmitter.off("change", listener);
23432
+ };
23433
+ }
23434
+ async function resolveGitMetadataDir(projectDir) {
23435
+ try {
23436
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--git-dir"], {
23437
+ cwd: projectDir,
23438
+ maxBuffer: 1024 * 1024,
23439
+ encoding: "utf8"
23440
+ });
23441
+ const gitDirRaw = stdout.trim();
23442
+ if (!gitDirRaw) return null;
23443
+ const gitDirPath = resolve$1(projectDir, gitDirRaw);
23444
+ if (!(await stat(gitDirPath)).isDirectory()) return null;
23445
+ return gitDirPath;
23446
+ } catch {
23447
+ return null;
23448
+ }
23449
+ }
23450
+ function getDashboardGitRefreshStampPath(gitMetadataDir) {
23451
+ return join$1(gitMetadataDir, DASHBOARD_GIT_REFRESH_STAMP_NAME);
23452
+ }
23453
+ async function touchDashboardGitRefreshStamp(projectDir, reason) {
23454
+ const gitMetadataDir = await resolveGitMetadataDir(projectDir);
23455
+ if (!gitMetadataDir) return { skipped: true };
23456
+ const stampPath = getDashboardGitRefreshStampPath(gitMetadataDir);
23457
+ await mkdir$1(dirname$1(stampPath), { recursive: true });
23458
+ await writeFile$1(stampPath, `${Date.now()} ${reason}\n`, "utf8");
23459
+ return { skipped: false };
23460
+ }
23461
+ async function loadDashboardOverview(ctx, reason = "dashboard-refresh") {
23462
+ const now = Date.now();
23463
+ const [specMetas, changeMetas, archiveMetas] = await Promise.all([
23464
+ ctx.adapter.listSpecsWithMeta(),
23465
+ ctx.adapter.listChangesWithMeta(),
23466
+ ctx.adapter.listArchivedChangesWithMeta()
23467
+ ]);
23468
+ const activeChanges = changeMetas.map((changeMeta) => ({
23469
+ id: changeMeta.id,
23470
+ name: changeMeta.name ?? changeMeta.id,
23471
+ progress: changeMeta.progress,
23472
+ updatedAt: changeMeta.updatedAt
23473
+ })).sort((a, b) => b.updatedAt - a.updatedAt);
23474
+ const archivedChanges = (await Promise.all(archiveMetas.map(async (meta) => {
23475
+ const change = await ctx.adapter.readArchivedChange(meta.id);
23476
+ if (!change) return null;
23477
+ return {
23478
+ id: meta.id,
23479
+ createdAt: meta.createdAt,
23480
+ updatedAt: meta.updatedAt,
23481
+ tasksCompleted: change.tasks.filter((task) => task.completed).length
23482
+ };
23483
+ }))).filter((item) => item !== null);
23484
+ const specifications = (await Promise.all(specMetas.map(async (meta) => {
23485
+ const spec = await ctx.adapter.readSpec(meta.id);
23486
+ if (!spec) return null;
23487
+ return {
23488
+ id: meta.id,
23489
+ name: meta.name,
23490
+ requirements: spec.requirements.length,
23491
+ updatedAt: meta.updatedAt
23492
+ };
23493
+ }))).filter((item) => item !== null).sort((a, b) => b.requirements - a.requirements || b.updatedAt - a.updatedAt);
23494
+ const requirements = specifications.reduce((sum, spec) => sum + spec.requirements, 0);
23495
+ const tasksTotal = activeChanges.reduce((sum, change) => sum + change.progress.total, 0);
23496
+ const tasksCompleted = activeChanges.reduce((sum, change) => sum + change.progress.completed, 0);
23497
+ const archivedTasksCompleted = archivedChanges.reduce((sum, change) => sum + change.tasksCompleted, 0);
23498
+ const taskCompletionPercent = tasksTotal > 0 ? Math.round(tasksCompleted / tasksTotal * 100) : null;
23499
+ const inProgressChanges = activeChanges.filter((change) => change.progress.total > 0 && change.progress.completed < change.progress.total).length;
23500
+ const specificationTrendEvents = specMetas.flatMap((spec) => {
23501
+ const ts = resolveTrendTimestamp(spec.createdAt, spec.updatedAt);
23502
+ return ts === null ? [] : [{
23503
+ ts,
23504
+ value: 1
23505
+ }];
23506
+ });
23507
+ const completedTrendEvents = archivedChanges.flatMap((archive) => {
23508
+ const ts = parseDatedIdTimestamp(archive.id) ?? resolveTrendTimestamp(archive.updatedAt, archive.createdAt);
23509
+ return ts === null ? [] : [{
23510
+ ts,
23511
+ value: archive.tasksCompleted
23512
+ }];
23513
+ });
23514
+ const specMetaById = new Map(specMetas.map((meta) => [meta.id, meta]));
23515
+ const requirementTrendEvents = specifications.flatMap((spec) => {
23516
+ const meta = specMetaById.get(spec.id);
23517
+ const ts = resolveTrendTimestamp(meta?.updatedAt, meta?.createdAt);
23518
+ return ts === null ? [] : [{
23519
+ ts,
23520
+ value: spec.requirements
23521
+ }];
23522
+ });
23523
+ const hasObjectiveSpecificationTrend = specificationTrendEvents.length > 0 || specifications.length === 0;
23524
+ const hasObjectiveRequirementTrend = requirementTrendEvents.length > 0 || requirements === 0;
23525
+ const hasObjectiveCompletedTrend = completedTrendEvents.length > 0 || archiveMetas.length === 0;
23526
+ const config = await ctx.configManager.readConfig();
23527
+ beginDashboardGitTask(reason);
23528
+ let latestCommitTs = null;
23529
+ let git;
23530
+ try {
23531
+ const gitSnapshotPromise = buildDashboardGitSnapshot({ projectDir: ctx.projectDir }).catch(() => ({
23532
+ defaultBranch: "main",
23533
+ worktrees: []
23534
+ }));
23535
+ latestCommitTs = await readLatestCommitTimestamp(ctx.projectDir);
23536
+ git = await gitSnapshotPromise;
23537
+ } catch (error) {
23538
+ endDashboardGitTask(error);
23539
+ throw error;
23540
+ }
23541
+ endDashboardGitTask(null);
23542
+ const cardAvailability = {
23543
+ specifications: hasObjectiveSpecificationTrend ? { state: "ok" } : {
23544
+ state: "invalid",
23545
+ reason: "objective-history-unavailable"
23546
+ },
23547
+ requirements: hasObjectiveRequirementTrend ? { state: "ok" } : {
23548
+ state: "invalid",
23549
+ reason: "objective-history-unavailable"
23550
+ },
23551
+ activeChanges: {
23552
+ state: "invalid",
23553
+ reason: "objective-history-unavailable"
23554
+ },
23555
+ inProgressChanges: {
23556
+ state: "invalid",
23557
+ reason: "objective-history-unavailable"
23558
+ },
23559
+ completedChanges: hasObjectiveCompletedTrend ? { state: "ok" } : {
23560
+ state: "invalid",
23561
+ reason: "objective-history-unavailable"
23562
+ },
23563
+ taskCompletionPercent: {
23564
+ state: "invalid",
23565
+ reason: taskCompletionPercent === null ? "semantic-uncomputable" : "objective-history-unavailable"
23566
+ }
23567
+ };
23568
+ const trendKinds = {
23569
+ specifications: "monotonic",
23570
+ requirements: "monotonic",
23571
+ activeChanges: "bidirectional",
23572
+ inProgressChanges: "bidirectional",
23573
+ completedChanges: "monotonic",
23574
+ taskCompletionPercent: "bidirectional"
23575
+ };
23576
+ const { trends: baselineTrends, trendMeta } = buildDashboardTimeTrends({
23577
+ pointLimit: config.dashboard.trendPointLimit,
23578
+ timestamp: now,
23579
+ rightEdgeTs: latestCommitTs,
23580
+ availability: cardAvailability,
23581
+ events: {
23582
+ specifications: specificationTrendEvents,
23583
+ requirements: requirementTrendEvents,
23584
+ activeChanges: [],
23585
+ inProgressChanges: [],
23586
+ completedChanges: completedTrendEvents,
23587
+ taskCompletionPercent: []
23588
+ },
23589
+ reducers: {
23590
+ specifications: "sum",
23591
+ requirements: "sum",
23592
+ completedChanges: "sum"
23593
+ }
23594
+ });
23595
+ return {
23596
+ summary: {
23597
+ specifications: specifications.length,
23598
+ requirements,
23599
+ activeChanges: activeChanges.length,
23600
+ inProgressChanges,
23601
+ completedChanges: archiveMetas.length,
23602
+ archivedTasksCompleted,
23603
+ tasksTotal,
23604
+ tasksCompleted,
23605
+ taskCompletionPercent
23606
+ },
23607
+ trends: baselineTrends,
23608
+ triColorTrends: createEmptyTriColorTrends(),
23609
+ trendKinds,
23610
+ cardAvailability,
23611
+ trendMeta,
23612
+ specifications,
23613
+ activeChanges,
23614
+ git
23615
+ };
23388
23616
  }
23389
23617
 
23390
- function searchIndex(index, query) {
23391
- const terms = splitTerms(query && query.query ? query.query : '');
23392
- if (terms.length === 0) return [];
23393
-
23394
- const hits = [];
23395
-
23396
- for (const doc of index.documents) {
23397
- if (!isDocumentMatch(doc, terms)) continue;
23398
-
23399
- hits.push({
23400
- documentId: doc.id,
23401
- kind: doc.kind,
23402
- title: doc.title,
23403
- href: doc.href,
23404
- path: doc.path,
23405
- score: scoreDocument(doc, terms),
23406
- snippet: createSnippet(doc.content, terms),
23407
- updatedAt: doc.updatedAt,
23408
- });
23409
- }
23410
-
23411
- hits.sort((a, b) => {
23412
- if (b.score !== a.score) return b.score - a.score;
23413
- return b.updatedAt - a.updatedAt;
23414
- });
23415
-
23416
- return hits.slice(0, resolveLimit(query ? query.limit : undefined));
23618
+ //#endregion
23619
+ //#region ../server/src/pty-manager.ts
23620
+ const DEFAULT_SCROLLBACK = 1e3;
23621
+ const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
23622
+ function detectPtyPlatform() {
23623
+ if (process.platform === "win32") return "windows";
23624
+ if (process.platform === "darwin") return "macos";
23625
+ return "common";
23417
23626
  }
23418
-
23419
- let index = buildSearchIndex([]);
23420
-
23421
- function handleMessage(payload) {
23422
- try {
23423
- if (!payload || typeof payload !== 'object') {
23424
- throw new Error('Invalid worker request payload');
23425
- }
23426
-
23427
- if (payload.type === 'init' || payload.type === 'replaceAll') {
23428
- index = buildSearchIndex(Array.isArray(payload.docs) ? payload.docs : []);
23429
- return { id: payload.id, type: 'ok' };
23430
- }
23431
-
23432
- if (payload.type === 'search') {
23433
- const hits = searchIndex(index, payload.query || { query: '' });
23434
- return { id: payload.id, type: 'results', hits };
23435
- }
23436
-
23437
- if (payload.type === 'dispose') {
23438
- index = buildSearchIndex([]);
23439
- return { id: payload.id, type: 'ok' };
23440
- }
23441
-
23442
- throw new Error('Unsupported worker request type');
23443
- } catch (error) {
23444
- const message = error instanceof Error ? error.message : String(error);
23445
- return { id: payload?.id ?? 'unknown', type: 'error', message };
23446
- }
23627
+ function resolveDefaultShell(platform, env) {
23628
+ if (platform === "windows") return env.ComSpec?.trim() || "cmd.exe";
23629
+ return env.SHELL?.trim() || "/bin/sh";
23447
23630
  }
23448
- `;
23449
- function buildNodeWorkerSource() {
23450
- return `${sharedRuntimeSource}\nconst { parentPort } = require('node:worker_threads');\nif (!parentPort) { throw new Error('Missing parentPort'); }\nparentPort.on('message', (payload) => { parentPort.postMessage(handleMessage(payload)); });`;
23631
+ function resolvePtyCommand(opts) {
23632
+ const command = opts.command?.trim();
23633
+ if (command) return {
23634
+ command,
23635
+ args: opts.args ?? []
23636
+ };
23637
+ return {
23638
+ command: resolveDefaultShell(opts.platform, opts.env),
23639
+ args: []
23640
+ };
23451
23641
  }
23642
+ var PtySession = class extends EventEmitter {
23643
+ id;
23644
+ command;
23645
+ args;
23646
+ platform;
23647
+ closeTip;
23648
+ closeCallbackUrl;
23649
+ createdAt;
23650
+ process;
23651
+ titleInterval = null;
23652
+ lastTitle = "";
23653
+ buffer = [];
23654
+ bufferByteLength = 0;
23655
+ maxBufferLines;
23656
+ maxBufferBytes;
23657
+ isExited = false;
23658
+ exitCode = null;
23659
+ constructor(id, opts) {
23660
+ super();
23661
+ this.id = id;
23662
+ this.createdAt = Date.now();
23663
+ const resolvedCommand = resolvePtyCommand({
23664
+ platform: opts.platform,
23665
+ command: opts.command,
23666
+ args: opts.args,
23667
+ env: process.env
23668
+ });
23669
+ this.command = resolvedCommand.command;
23670
+ this.args = resolvedCommand.args;
23671
+ this.platform = opts.platform;
23672
+ this.closeTip = opts.closeTip;
23673
+ this.closeCallbackUrl = opts.closeCallbackUrl;
23674
+ this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
23675
+ this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
23676
+ this.process = pty.spawn(this.command, this.args, {
23677
+ name: "xterm-256color",
23678
+ cols: opts.cols ?? 80,
23679
+ rows: opts.rows ?? 24,
23680
+ cwd: opts.cwd,
23681
+ env: {
23682
+ ...process.env,
23683
+ TERM: "xterm-256color"
23684
+ }
23685
+ });
23686
+ this.process.onData((data) => {
23687
+ this.appendBuffer(data);
23688
+ this.emit("data", data);
23689
+ });
23690
+ this.process.onExit(({ exitCode }) => {
23691
+ if (this.titleInterval) {
23692
+ clearInterval(this.titleInterval);
23693
+ this.titleInterval = null;
23694
+ }
23695
+ this.isExited = true;
23696
+ this.exitCode = exitCode;
23697
+ this.emit("exit", exitCode);
23698
+ });
23699
+ this.titleInterval = setInterval(() => {
23700
+ try {
23701
+ const title = this.process.process;
23702
+ if (title && title !== this.lastTitle) {
23703
+ this.lastTitle = title;
23704
+ this.emit("title", title);
23705
+ }
23706
+ } catch {}
23707
+ }, 1e3);
23708
+ }
23709
+ get title() {
23710
+ return this.lastTitle;
23711
+ }
23712
+ appendBuffer(data) {
23713
+ let chunk = data;
23714
+ if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
23715
+ this.buffer.push(chunk);
23716
+ this.bufferByteLength += chunk.length;
23717
+ while (this.bufferByteLength > this.maxBufferBytes && this.buffer.length > 0) {
23718
+ const removed = this.buffer.shift();
23719
+ this.bufferByteLength -= removed.length;
23720
+ }
23721
+ while (this.buffer.length > this.maxBufferLines) {
23722
+ const removed = this.buffer.shift();
23723
+ this.bufferByteLength -= removed.length;
23724
+ }
23725
+ }
23726
+ getBuffer() {
23727
+ return this.buffer.join("");
23728
+ }
23729
+ write(data) {
23730
+ if (!this.isExited) this.process.write(data);
23731
+ }
23732
+ resize(cols, rows) {
23733
+ if (!this.isExited) this.process.resize(cols, rows);
23734
+ }
23735
+ close() {
23736
+ if (this.titleInterval) {
23737
+ clearInterval(this.titleInterval);
23738
+ this.titleInterval = null;
23739
+ }
23740
+ try {
23741
+ this.process.kill();
23742
+ } catch {}
23743
+ this.removeAllListeners();
23744
+ }
23745
+ toInfo() {
23746
+ return {
23747
+ id: this.id,
23748
+ title: this.lastTitle,
23749
+ command: this.command,
23750
+ args: this.args,
23751
+ platform: this.platform,
23752
+ isExited: this.isExited,
23753
+ exitCode: this.exitCode,
23754
+ closeTip: this.closeTip,
23755
+ closeCallbackUrl: this.closeCallbackUrl,
23756
+ createdAt: this.createdAt
23757
+ };
23758
+ }
23759
+ };
23760
+ var PtyManager = class {
23761
+ sessions = /* @__PURE__ */ new Map();
23762
+ idCounter = 0;
23763
+ platform;
23764
+ constructor(defaultCwd) {
23765
+ this.defaultCwd = defaultCwd;
23766
+ this.platform = detectPtyPlatform();
23767
+ }
23768
+ create(opts) {
23769
+ const id = `pty-${++this.idCounter}`;
23770
+ const session = new PtySession(id, {
23771
+ cols: opts.cols,
23772
+ rows: opts.rows,
23773
+ command: opts.command,
23774
+ args: opts.args,
23775
+ closeTip: opts.closeTip,
23776
+ closeCallbackUrl: opts.closeCallbackUrl,
23777
+ cwd: this.defaultCwd,
23778
+ scrollback: opts.scrollback,
23779
+ maxBufferBytes: opts.maxBufferBytes,
23780
+ platform: this.platform
23781
+ });
23782
+ this.sessions.set(id, session);
23783
+ return session;
23784
+ }
23785
+ get(id) {
23786
+ return this.sessions.get(id);
23787
+ }
23788
+ list() {
23789
+ const result = [];
23790
+ for (const session of this.sessions.values()) result.push(session.toInfo());
23791
+ return result;
23792
+ }
23793
+ write(id, data) {
23794
+ this.sessions.get(id)?.write(data);
23795
+ }
23796
+ resize(id, cols, rows) {
23797
+ this.sessions.get(id)?.resize(cols, rows);
23798
+ }
23799
+ close(id) {
23800
+ const session = this.sessions.get(id);
23801
+ if (session) {
23802
+ session.close();
23803
+ this.sessions.delete(id);
23804
+ }
23805
+ }
23806
+ closeAll() {
23807
+ for (const session of this.sessions.values()) session.close();
23808
+ this.sessions.clear();
23809
+ }
23810
+ };
23452
23811
 
23453
23812
  //#endregion
23454
- //#region ../server/src/cli-stream-observable.ts
23455
- /**
23456
- * 创建安全的 CLI 流式 observable
23457
- *
23458
- * 解决的问题:
23459
- * 1. 防止在 emit.complete() 之后调用 emit.next()(会导致 "Controller is already closed" 错误)
23460
- * 2. 统一的错误处理,防止未捕获的异常导致服务器崩溃
23461
- * 3. 确保取消时正确清理资源
23462
- *
23463
- * @param startStream 启动流的函数,接收 onEvent 回调,返回取消函数的 Promise
23464
- */
23465
- function createCliStreamObservable(startStream) {
23466
- return observable((emit) => {
23467
- let cancel;
23468
- let completed = false;
23469
- /**
23470
- * 安全的事件处理器
23471
- * - 检查是否已完成,防止重复调用
23472
- * - 使用 try-catch 防止异常导致服务器崩溃
23473
- */
23474
- const safeEventHandler = (event) => {
23475
- if (completed) return;
23813
+ //#region ../server/src/pty-websocket.ts
23814
+ function createPtyWebSocketHandler(ptyManager) {
23815
+ return (ws) => {
23816
+ const cleanups = /* @__PURE__ */ new Map();
23817
+ const send = (msg) => {
23818
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
23819
+ };
23820
+ const sendError = (code, message, opts) => {
23821
+ send({
23822
+ type: "error",
23823
+ code,
23824
+ message,
23825
+ sessionId: opts?.sessionId
23826
+ });
23827
+ };
23828
+ const attachToSession = (session, opts) => {
23829
+ const sessionId = session.id;
23830
+ cleanups.get(sessionId)?.();
23831
+ if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
23832
+ const onData = (data) => {
23833
+ send({
23834
+ type: "output",
23835
+ sessionId,
23836
+ data
23837
+ });
23838
+ };
23839
+ const onExit = (exitCode) => {
23840
+ send({
23841
+ type: "exit",
23842
+ sessionId,
23843
+ exitCode
23844
+ });
23845
+ };
23846
+ const onTitle = (title) => {
23847
+ send({
23848
+ type: "title",
23849
+ sessionId,
23850
+ title
23851
+ });
23852
+ };
23853
+ session.on("data", onData);
23854
+ session.on("exit", onExit);
23855
+ session.on("title", onTitle);
23856
+ cleanups.set(sessionId, () => {
23857
+ session.removeListener("data", onData);
23858
+ session.removeListener("exit", onExit);
23859
+ session.removeListener("title", onTitle);
23860
+ cleanups.delete(sessionId);
23861
+ });
23862
+ };
23863
+ ws.on("message", (raw$1) => {
23864
+ let parsed;
23476
23865
  try {
23477
- emit.next(event);
23478
- if (event.type === "exit") {
23479
- completed = true;
23480
- emit.complete();
23866
+ parsed = JSON.parse(String(raw$1));
23867
+ } catch {
23868
+ sendError("INVALID_JSON", "Invalid JSON payload");
23869
+ return;
23870
+ }
23871
+ const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
23872
+ if (!parsedMessage.success) {
23873
+ const firstIssue = parsedMessage.error.issues[0]?.message;
23874
+ sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
23875
+ return;
23876
+ }
23877
+ const msg = parsedMessage.data;
23878
+ switch (msg.type) {
23879
+ case "create":
23880
+ try {
23881
+ const createMessage = msg;
23882
+ const session = ptyManager.create({
23883
+ cols: msg.cols,
23884
+ rows: msg.rows,
23885
+ command: msg.command,
23886
+ args: msg.args,
23887
+ closeTip: createMessage.closeTip,
23888
+ closeCallbackUrl: createMessage.closeCallbackUrl
23889
+ });
23890
+ send({
23891
+ type: "created",
23892
+ requestId: msg.requestId,
23893
+ sessionId: session.id,
23894
+ platform: session.platform
23895
+ });
23896
+ attachToSession(session);
23897
+ } catch (err) {
23898
+ sendError("PTY_CREATE_FAILED", err instanceof Error ? err.message : String(err), { sessionId: msg.requestId });
23899
+ }
23900
+ break;
23901
+ case "attach": {
23902
+ const session = ptyManager.get(msg.sessionId);
23903
+ if (!session) {
23904
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
23905
+ send({
23906
+ type: "exit",
23907
+ sessionId: msg.sessionId,
23908
+ exitCode: -1
23909
+ });
23910
+ break;
23911
+ }
23912
+ attachToSession(session, {
23913
+ cols: msg.cols,
23914
+ rows: msg.rows
23915
+ });
23916
+ const buffer = session.getBuffer();
23917
+ if (buffer) send({
23918
+ type: "buffer",
23919
+ sessionId: session.id,
23920
+ data: buffer
23921
+ });
23922
+ if (session.title) send({
23923
+ type: "title",
23924
+ sessionId: session.id,
23925
+ title: session.title
23926
+ });
23927
+ if (session.isExited) send({
23928
+ type: "exit",
23929
+ sessionId: session.id,
23930
+ exitCode: session.exitCode ?? -1
23931
+ });
23932
+ break;
23933
+ }
23934
+ case "list":
23935
+ send({
23936
+ type: "list",
23937
+ sessions: ptyManager.list().map((s) => ({
23938
+ id: s.id,
23939
+ title: s.title,
23940
+ command: s.command,
23941
+ args: s.args,
23942
+ platform: s.platform,
23943
+ isExited: s.isExited,
23944
+ exitCode: s.exitCode,
23945
+ closeTip: s.closeTip,
23946
+ closeCallbackUrl: s.closeCallbackUrl
23947
+ }))
23948
+ });
23949
+ break;
23950
+ case "input": {
23951
+ const session = ptyManager.get(msg.sessionId);
23952
+ if (!session) {
23953
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
23954
+ break;
23955
+ }
23956
+ session.write(msg.data);
23957
+ break;
23481
23958
  }
23482
- } catch (err) {
23483
- console.error("[CLI Stream] Error emitting event:", err);
23484
- if (!completed) {
23485
- completed = true;
23486
- try {
23487
- emit.error(err instanceof Error ? err : new Error(String(err)));
23488
- } catch {}
23959
+ case "resize": {
23960
+ const session = ptyManager.get(msg.sessionId);
23961
+ if (!session) {
23962
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
23963
+ break;
23964
+ }
23965
+ session.resize(msg.cols, msg.rows);
23966
+ break;
23967
+ }
23968
+ case "close": {
23969
+ const session = ptyManager.get(msg.sessionId);
23970
+ if (!session) {
23971
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
23972
+ break;
23973
+ }
23974
+ cleanups.get(msg.sessionId)?.();
23975
+ ptyManager.close(session.id);
23976
+ break;
23489
23977
  }
23490
- }
23491
- };
23492
- startStream(safeEventHandler).then((cancelFn) => {
23493
- cancel = cancelFn;
23494
- }).catch((err) => {
23495
- console.error("[CLI Stream] Error starting stream:", err);
23496
- if (!completed) {
23497
- completed = true;
23498
- try {
23499
- emit.error(err instanceof Error ? err : new Error(String(err)));
23500
- } catch {}
23501
23978
  }
23502
23979
  });
23503
- return () => {
23504
- completed = true;
23505
- cancel?.();
23506
- };
23507
- });
23508
- }
23509
-
23510
- //#endregion
23511
- //#region ../server/src/dashboard-git-snapshot.ts
23512
- const execFileAsync$1 = promisify$1(execFile);
23513
- const EMPTY_DIFF = {
23514
- files: 0,
23515
- insertions: 0,
23516
- deletions: 0
23517
- };
23518
- async function defaultRunGit(cwd, args) {
23519
- try {
23520
- const { stdout } = await execFileAsync$1("git", args, {
23521
- cwd,
23522
- encoding: "utf8",
23523
- maxBuffer: 8 * 1024 * 1024
23980
+ ws.on("close", () => {
23981
+ for (const cleanup of cleanups.values()) cleanup();
23982
+ cleanups.clear();
23524
23983
  });
23525
- return {
23526
- ok: true,
23527
- stdout
23528
- };
23529
- } catch {
23530
- return {
23531
- ok: false,
23532
- stdout: ""
23533
- };
23534
- }
23535
- }
23536
- function parseShortStat(output) {
23537
- const files = Number(/(\d+)\s+files? changed/.exec(output)?.[1] ?? 0);
23538
- const insertions = Number(/(\d+)\s+insertions?\(\+\)/.exec(output)?.[1] ?? 0);
23539
- const deletions = Number(/(\d+)\s+deletions?\(-\)/.exec(output)?.[1] ?? 0);
23540
- return {
23541
- files: Number.isFinite(files) ? files : 0,
23542
- insertions: Number.isFinite(insertions) ? insertions : 0,
23543
- deletions: Number.isFinite(deletions) ? deletions : 0
23544
- };
23545
- }
23546
- function parseNumStat(output) {
23547
- let files = 0;
23548
- let insertions = 0;
23549
- let deletions = 0;
23550
- for (const line of output.split("\n")) {
23551
- const trimmed = line.trim();
23552
- if (!trimmed) continue;
23553
- const [addRaw, deleteRaw] = trimmed.split(" ");
23554
- if (!addRaw || !deleteRaw) continue;
23555
- files += 1;
23556
- if (addRaw !== "-") insertions += Number(addRaw) || 0;
23557
- if (deleteRaw !== "-") deletions += Number(deleteRaw) || 0;
23558
- }
23559
- return {
23560
- files,
23561
- insertions,
23562
- deletions
23563
23984
  };
23564
23985
  }
23565
- function normalizeGitPath(path$1) {
23566
- return path$1.replace(/\\/g, "/").replace(/^\.\//, "");
23567
- }
23568
- function relativePath(fromDir, target) {
23569
- const rel = relative$1(fromDir, target);
23570
- if (!rel || rel.length === 0) return ".";
23571
- return rel;
23572
- }
23573
- function parseBranchName(branchRef, detached) {
23574
- if (detached) return "(detached)";
23575
- if (!branchRef) return "(unknown)";
23576
- return branchRef.replace(/^refs\/heads\//, "");
23986
+
23987
+ //#endregion
23988
+ //#region ../search/src/protocol.ts
23989
+ const SearchDocumentKindSchema = enumType([
23990
+ "spec",
23991
+ "change",
23992
+ "archive"
23993
+ ]);
23994
+ const SearchDocumentSchema = objectType({
23995
+ id: stringType(),
23996
+ kind: SearchDocumentKindSchema,
23997
+ title: stringType(),
23998
+ href: stringType(),
23999
+ path: stringType(),
24000
+ content: stringType(),
24001
+ updatedAt: numberType()
24002
+ });
24003
+ const SearchQuerySchema = objectType({
24004
+ query: stringType(),
24005
+ limit: numberType().int().positive().optional()
24006
+ });
24007
+ const SearchHitSchema = objectType({
24008
+ documentId: stringType(),
24009
+ kind: SearchDocumentKindSchema,
24010
+ title: stringType(),
24011
+ href: stringType(),
24012
+ path: stringType(),
24013
+ score: numberType(),
24014
+ snippet: stringType(),
24015
+ updatedAt: numberType()
24016
+ });
24017
+ const SearchWorkerRequestSchema = discriminatedUnionType("type", [
24018
+ objectType({
24019
+ id: stringType(),
24020
+ type: literalType("init"),
24021
+ docs: arrayType(SearchDocumentSchema)
24022
+ }),
24023
+ objectType({
24024
+ id: stringType(),
24025
+ type: literalType("replaceAll"),
24026
+ docs: arrayType(SearchDocumentSchema)
24027
+ }),
24028
+ objectType({
24029
+ id: stringType(),
24030
+ type: literalType("search"),
24031
+ query: SearchQuerySchema
24032
+ }),
24033
+ objectType({
24034
+ id: stringType(),
24035
+ type: literalType("dispose")
24036
+ })
24037
+ ]);
24038
+ const SearchWorkerResponseSchema = discriminatedUnionType("type", [
24039
+ objectType({
24040
+ id: stringType(),
24041
+ type: literalType("ok")
24042
+ }),
24043
+ objectType({
24044
+ id: stringType(),
24045
+ type: literalType("results"),
24046
+ hits: arrayType(SearchHitSchema)
24047
+ }),
24048
+ objectType({
24049
+ id: stringType(),
24050
+ type: literalType("error"),
24051
+ message: stringType()
24052
+ })
24053
+ ]);
24054
+
24055
+ //#endregion
24056
+ //#region ../search/src/worker-source.ts
24057
+ const sharedRuntimeSource = String.raw`
24058
+ const DEFAULT_LIMIT = 50;
24059
+ const MAX_LIMIT = 200;
24060
+ const SNIPPET_SIZE = 180;
24061
+
24062
+ function normalizeText(input) {
24063
+ return String(input || '')
24064
+ .toLowerCase()
24065
+ .replace(/\s+/g, ' ')
24066
+ .trim();
23577
24067
  }
23578
- function parseWorktreeList(porcelain) {
23579
- const entries = [];
23580
- let current = null;
23581
- const flush = () => {
23582
- if (!current) return;
23583
- entries.push(current);
23584
- current = null;
23585
- };
23586
- for (const line of porcelain.split("\n")) {
23587
- if (line.startsWith("worktree ")) {
23588
- flush();
23589
- current = {
23590
- path: line.slice(9).trim(),
23591
- branchRef: null,
23592
- detached: false
23593
- };
23594
- continue;
23595
- }
23596
- if (!current) continue;
23597
- if (line.startsWith("branch ")) {
23598
- current.branchRef = line.slice(7).trim();
23599
- continue;
23600
- }
23601
- if (line === "detached") {
23602
- current.detached = true;
23603
- continue;
23604
- }
23605
- }
23606
- flush();
23607
- return entries;
24068
+
24069
+ function splitTerms(query) {
24070
+ return normalizeText(query)
24071
+ .split(' ')
24072
+ .map((term) => term.trim())
24073
+ .filter((term) => term.length > 0);
23608
24074
  }
23609
- function parseRelatedChanges(paths) {
23610
- const related = /* @__PURE__ */ new Set();
23611
- for (const path$1 of paths) {
23612
- const normalized = normalizeGitPath(path$1);
23613
- const activeMatch = /^openspec\/changes\/([^/]+)\//.exec(normalized);
23614
- if (activeMatch?.[1]) {
23615
- related.add(activeMatch[1]);
23616
- continue;
23617
- }
23618
- const archiveMatch = /^openspec\/changes\/archive\/([^/]+)\//.exec(normalized);
23619
- if (archiveMatch?.[1]) {
23620
- const fullName = archiveMatch[1];
23621
- related.add(fullName.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
23622
- }
23623
- }
23624
- return [...related].sort((a, b) => a.localeCompare(b));
24075
+
24076
+ function toSearchIndexDocument(doc) {
24077
+ return {
24078
+ id: doc.id,
24079
+ kind: doc.kind,
24080
+ title: doc.title,
24081
+ href: doc.href,
24082
+ path: doc.path,
24083
+ content: doc.content,
24084
+ updatedAt: doc.updatedAt,
24085
+ normalizedTitle: normalizeText(doc.title),
24086
+ normalizedPath: normalizeText(doc.path),
24087
+ normalizedContent: normalizeText(doc.content),
24088
+ };
23625
24089
  }
23626
- async function resolveDefaultBranch(projectDir, runGit) {
23627
- const remoteHead = await runGit(projectDir, [
23628
- "symbolic-ref",
23629
- "--quiet",
23630
- "--short",
23631
- "refs/remotes/origin/HEAD"
23632
- ]);
23633
- const remoteRef = remoteHead.stdout.trim();
23634
- if (remoteHead.ok && remoteRef) return remoteRef;
23635
- const localHead = await runGit(projectDir, [
23636
- "rev-parse",
23637
- "--abbrev-ref",
23638
- "HEAD"
23639
- ]);
23640
- const localRef = localHead.stdout.trim();
23641
- if (localHead.ok && localRef && localRef !== "HEAD") return localRef;
23642
- return "main";
24090
+
24091
+ function buildSearchIndex(docs) {
24092
+ return {
24093
+ documents: Array.isArray(docs) ? docs.map(toSearchIndexDocument) : [],
24094
+ };
23643
24095
  }
23644
- async function collectCommitEntries(options) {
23645
- const { worktreePath, defaultBranch, maxCommitEntries, runGit } = options;
23646
- const entries = [];
23647
- const commits = await runGit(worktreePath, [
23648
- "log",
23649
- "--format=%H%x1f%s",
23650
- `-n${maxCommitEntries}`,
23651
- `${defaultBranch}..HEAD`
23652
- ]);
23653
- if (commits.ok) for (const line of commits.stdout.split("\n")) {
23654
- if (!line.trim()) continue;
23655
- const [hash, title = ""] = line.split("");
23656
- if (!hash) continue;
23657
- const diffResult = await runGit(worktreePath, [
23658
- "show",
23659
- "--numstat",
23660
- "--format=",
23661
- hash
23662
- ]);
23663
- const changedFiles = (await runGit(worktreePath, [
23664
- "show",
23665
- "--name-only",
23666
- "--format=",
23667
- hash
23668
- ])).stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23669
- entries.push({
23670
- type: "commit",
23671
- hash,
23672
- title: title.trim() || hash.slice(0, 7),
23673
- relatedChanges: parseRelatedChanges(changedFiles),
23674
- diff: diffResult.ok ? parseNumStat(diffResult.stdout) : EMPTY_DIFF
23675
- });
23676
- }
23677
- const trackedResult = await runGit(worktreePath, [
23678
- "diff",
23679
- "--numstat",
23680
- "HEAD"
23681
- ]);
23682
- const trackedFilesResult = await runGit(worktreePath, [
23683
- "diff",
23684
- "--name-only",
23685
- "HEAD"
23686
- ]);
23687
- const untrackedResult = await runGit(worktreePath, [
23688
- "ls-files",
23689
- "--others",
23690
- "--exclude-standard"
23691
- ]);
23692
- const trackedFiles = trackedFilesResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23693
- const untrackedFiles = untrackedResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23694
- const allUncommittedFiles = new Set([...trackedFiles, ...untrackedFiles]);
23695
- const trackedDiff = trackedResult.ok ? parseNumStat(trackedResult.stdout) : EMPTY_DIFF;
23696
- entries.push({
23697
- type: "uncommitted",
23698
- title: "Uncommitted",
23699
- relatedChanges: parseRelatedChanges([...allUncommittedFiles]),
23700
- diff: {
23701
- files: allUncommittedFiles.size,
23702
- insertions: trackedDiff.insertions,
23703
- deletions: trackedDiff.deletions
23704
- }
23705
- });
23706
- return entries;
24096
+
24097
+ function resolveLimit(limit) {
24098
+ if (typeof limit !== 'number' || Number.isNaN(limit)) return DEFAULT_LIMIT;
24099
+ return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(limit)));
23707
24100
  }
23708
- async function collectWorktree(options) {
23709
- const { projectDir, worktree, defaultBranch, runGit, maxCommitEntries } = options;
23710
- const worktreePath = resolve$1(worktree.path);
23711
- const resolvedProjectDir = resolve$1(projectDir);
23712
- const aheadBehindResult = await runGit(worktreePath, [
23713
- "rev-list",
23714
- "--left-right",
23715
- "--count",
23716
- `${defaultBranch}...HEAD`
23717
- ]);
23718
- let ahead = 0;
23719
- let behind = 0;
23720
- if (aheadBehindResult.ok) {
23721
- const [behindRaw, aheadRaw] = aheadBehindResult.stdout.trim().split(/\s+/);
23722
- ahead = Number(aheadRaw) || 0;
23723
- behind = Number(behindRaw) || 0;
23724
- }
23725
- const diffResult = await runGit(worktreePath, [
23726
- "diff",
23727
- "--shortstat",
23728
- `${defaultBranch}...HEAD`
23729
- ]);
23730
- const diff = diffResult.ok ? parseShortStat(diffResult.stdout) : EMPTY_DIFF;
23731
- const entries = await collectCommitEntries({
23732
- worktreePath,
23733
- defaultBranch,
23734
- maxCommitEntries,
23735
- runGit
23736
- });
23737
- return {
23738
- path: worktreePath,
23739
- relativePath: relativePath(resolvedProjectDir, worktreePath),
23740
- branchName: parseBranchName(worktree.branchRef, worktree.detached),
23741
- isCurrent: resolvedProjectDir === worktreePath,
23742
- ahead,
23743
- behind,
23744
- diff,
23745
- entries
23746
- };
24101
+
24102
+ function isDocumentMatch(doc, terms) {
24103
+ return terms.every(
24104
+ (term) =>
24105
+ doc.normalizedTitle.includes(term) ||
24106
+ doc.normalizedPath.includes(term) ||
24107
+ doc.normalizedContent.includes(term)
24108
+ );
23747
24109
  }
23748
- async function buildDashboardGitSnapshot(options) {
23749
- const runGit = options.runGit ?? defaultRunGit;
23750
- const maxCommitEntries = options.maxCommitEntries ?? 8;
23751
- const resolvedProjectDir = resolve$1(options.projectDir);
23752
- const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
23753
- const worktreeResult = await runGit(resolvedProjectDir, [
23754
- "worktree",
23755
- "list",
23756
- "--porcelain"
23757
- ]);
23758
- const parsed = worktreeResult.ok ? parseWorktreeList(worktreeResult.stdout) : [];
23759
- const baseWorktrees = parsed.length > 0 ? parsed : [{
23760
- path: resolvedProjectDir,
23761
- branchRef: null,
23762
- detached: false
23763
- }];
23764
- const worktrees = await Promise.all(baseWorktrees.map((worktree) => collectWorktree({
23765
- projectDir: resolvedProjectDir,
23766
- worktree,
23767
- defaultBranch,
23768
- runGit,
23769
- maxCommitEntries
23770
- })));
23771
- worktrees.sort((a, b) => {
23772
- if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
23773
- return a.branchName.localeCompare(b.branchName);
23774
- });
23775
- return {
23776
- defaultBranch,
23777
- worktrees
23778
- };
24110
+
24111
+ function scoreDocument(doc, terms) {
24112
+ let score = 0;
24113
+
24114
+ for (const term of terms) {
24115
+ if (doc.normalizedTitle.includes(term)) score += 30;
24116
+ if (doc.normalizedPath.includes(term)) score += 20;
24117
+
24118
+ const contentIdx = doc.normalizedContent.indexOf(term);
24119
+ if (contentIdx >= 0) {
24120
+ score += 8;
24121
+ if (contentIdx < 160) score += 4;
24122
+ }
24123
+ }
24124
+
24125
+ return score;
23779
24126
  }
23780
24127
 
23781
- //#endregion
23782
- //#region ../server/src/dashboard-time-trends.ts
23783
- const MIN_TREND_POINT_LIMIT = 20;
23784
- const MAX_TREND_POINT_LIMIT = 500;
23785
- const DEFAULT_TREND_POINT_LIMIT = 100;
23786
- const TARGET_TREND_BARS = 20;
23787
- const DAY_MS = 1440 * 60 * 1e3;
23788
- function clampPointLimit(pointLimit) {
23789
- if (!Number.isFinite(pointLimit)) return DEFAULT_TREND_POINT_LIMIT;
23790
- return Math.max(MIN_TREND_POINT_LIMIT, Math.min(MAX_TREND_POINT_LIMIT, Math.trunc(pointLimit)));
24128
+ function createSnippet(content, terms) {
24129
+ const source = String(content || '').trim();
24130
+ if (!source) return '';
24131
+
24132
+ const normalizedSource = normalizeText(source);
24133
+ let matchIndex = -1;
24134
+
24135
+ for (const term of terms) {
24136
+ const idx = normalizedSource.indexOf(term);
24137
+ if (idx >= 0 && (matchIndex < 0 || idx < matchIndex)) {
24138
+ matchIndex = idx;
24139
+ }
24140
+ }
24141
+
24142
+ if (matchIndex < 0) {
24143
+ return source.slice(0, SNIPPET_SIZE);
24144
+ }
24145
+
24146
+ const start = Math.max(0, matchIndex - Math.floor(SNIPPET_SIZE / 3));
24147
+ const end = Math.min(source.length, start + SNIPPET_SIZE);
24148
+
24149
+ const prefix = start > 0 ? '...' : '';
24150
+ const suffix = end < source.length ? '...' : '';
24151
+ return prefix + source.slice(start, end) + suffix;
23791
24152
  }
23792
- function createEmptyTrendSeries() {
23793
- return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
24153
+
24154
+ function searchIndex(index, query) {
24155
+ const terms = splitTerms(query && query.query ? query.query : '');
24156
+ if (terms.length === 0) return [];
24157
+
24158
+ const hits = [];
24159
+
24160
+ for (const doc of index.documents) {
24161
+ if (!isDocumentMatch(doc, terms)) continue;
24162
+
24163
+ hits.push({
24164
+ documentId: doc.id,
24165
+ kind: doc.kind,
24166
+ title: doc.title,
24167
+ href: doc.href,
24168
+ path: doc.path,
24169
+ score: scoreDocument(doc, terms),
24170
+ snippet: createSnippet(doc.content, terms),
24171
+ updatedAt: doc.updatedAt,
24172
+ });
24173
+ }
24174
+
24175
+ hits.sort((a, b) => {
24176
+ if (b.score !== a.score) return b.score - a.score;
24177
+ return b.updatedAt - a.updatedAt;
24178
+ });
24179
+
24180
+ return hits.slice(0, resolveLimit(query ? query.limit : undefined));
23794
24181
  }
23795
- function normalizeEvents(events, pointLimit) {
23796
- return events.filter((event) => Number.isFinite(event.ts) && event.ts > 0 && Number.isFinite(event.value)).sort((a, b) => a.ts - b.ts).slice(-pointLimit);
24182
+
24183
+ let index = buildSearchIndex([]);
24184
+
24185
+ function handleMessage(payload) {
24186
+ try {
24187
+ if (!payload || typeof payload !== 'object') {
24188
+ throw new Error('Invalid worker request payload');
24189
+ }
24190
+
24191
+ if (payload.type === 'init' || payload.type === 'replaceAll') {
24192
+ index = buildSearchIndex(Array.isArray(payload.docs) ? payload.docs : []);
24193
+ return { id: payload.id, type: 'ok' };
24194
+ }
24195
+
24196
+ if (payload.type === 'search') {
24197
+ const hits = searchIndex(index, payload.query || { query: '' });
24198
+ return { id: payload.id, type: 'results', hits };
24199
+ }
24200
+
24201
+ if (payload.type === 'dispose') {
24202
+ index = buildSearchIndex([]);
24203
+ return { id: payload.id, type: 'ok' };
24204
+ }
24205
+
24206
+ throw new Error('Unsupported worker request type');
24207
+ } catch (error) {
24208
+ const message = error instanceof Error ? error.message : String(error);
24209
+ return { id: payload?.id ?? 'unknown', type: 'error', message };
24210
+ }
23797
24211
  }
23798
- function buildTimeWindow(options) {
23799
- const { probeEvents, targetBars, rightEdgeTs } = options;
23800
- if (probeEvents.length === 0) return null;
23801
- const probeEnd = probeEvents[probeEvents.length - 1].ts;
23802
- const end = typeof rightEdgeTs === "number" && Number.isFinite(rightEdgeTs) && rightEdgeTs > 0 ? Math.max(probeEnd, rightEdgeTs) : probeEnd;
23803
- const probeStart = probeEvents[0].ts;
23804
- const rangeMs = Math.max(1, end - probeStart);
23805
- const bucketMs = rangeMs >= DAY_MS ? Math.max(DAY_MS, Math.ceil(rangeMs / targetBars / DAY_MS) * DAY_MS) : Math.max(1, Math.ceil(rangeMs / targetBars));
23806
- const windowStart = end - bucketMs * targetBars;
23807
- return {
23808
- windowStart,
23809
- bucketMs,
23810
- bucketEnds: Array.from({ length: targetBars }, (_, index) => windowStart + bucketMs * (index + 1))
23811
- };
24212
+ `;
24213
+ function buildNodeWorkerSource() {
24214
+ return `${sharedRuntimeSource}\nconst { parentPort } = require('node:worker_threads');\nif (!parentPort) { throw new Error('Missing parentPort'); }\nparentPort.on('message', (payload) => { parentPort.postMessage(handleMessage(payload)); });`;
23812
24215
  }
23813
- function bucketizeTrend(events, reducer, rightEdgeTs) {
23814
- if (events.length === 0) return [];
23815
- const timeWindow = buildTimeWindow({
23816
- probeEvents: events,
23817
- targetBars: TARGET_TREND_BARS,
23818
- rightEdgeTs
23819
- });
23820
- if (!timeWindow) return [];
23821
- const { windowStart, bucketMs, bucketEnds } = timeWindow;
23822
- const sums = Array.from({ length: bucketEnds.length }, () => 0);
23823
- const counts = Array.from({ length: bucketEnds.length }, () => 0);
23824
- let baseline = 0;
23825
- for (const event of events) {
23826
- if (event.ts <= windowStart) {
23827
- if (reducer === "sum-cumulative") baseline += event.value;
23828
- continue;
23829
- }
23830
- const offset = event.ts - windowStart;
23831
- const index = Math.max(0, Math.min(bucketEnds.length - 1, Math.ceil(offset / bucketMs) - 1));
23832
- sums[index] += event.value;
23833
- counts[index] += 1;
23834
- }
23835
- let cumulative = baseline;
23836
- let carry = baseline !== 0 ? baseline : events[0].value;
23837
- return bucketEnds.map((ts, index) => {
23838
- if (reducer === "sum") return {
23839
- ts,
23840
- value: sums[index]
24216
+
24217
+ //#endregion
24218
+ //#region ../server/src/cli-stream-observable.ts
24219
+ /**
24220
+ * 创建安全的 CLI 流式 observable
24221
+ *
24222
+ * 解决的问题:
24223
+ * 1. 防止在 emit.complete() 之后调用 emit.next()(会导致 "Controller is already closed" 错误)
24224
+ * 2. 统一的错误处理,防止未捕获的异常导致服务器崩溃
24225
+ * 3. 确保取消时正确清理资源
24226
+ *
24227
+ * @param startStream 启动流的函数,接收 onEvent 回调,返回取消函数的 Promise
24228
+ */
24229
+ function createCliStreamObservable(startStream) {
24230
+ return observable((emit) => {
24231
+ let cancel;
24232
+ let completed = false;
24233
+ /**
24234
+ * 安全的事件处理器
24235
+ * - 检查是否已完成,防止重复调用
24236
+ * - 使用 try-catch 防止异常导致服务器崩溃
24237
+ */
24238
+ const safeEventHandler = (event) => {
24239
+ if (completed) return;
24240
+ try {
24241
+ emit.next(event);
24242
+ if (event.type === "exit") {
24243
+ completed = true;
24244
+ emit.complete();
24245
+ }
24246
+ } catch (err) {
24247
+ console.error("[CLI Stream] Error emitting event:", err);
24248
+ if (!completed) {
24249
+ completed = true;
24250
+ try {
24251
+ emit.error(err instanceof Error ? err : new Error(String(err)));
24252
+ } catch {}
24253
+ }
24254
+ }
23841
24255
  };
23842
- if (reducer === "sum-cumulative") {
23843
- cumulative += sums[index];
23844
- return {
23845
- ts,
23846
- value: cumulative
23847
- };
23848
- }
23849
- if (counts[index] > 0) carry = sums[index] / counts[index];
23850
- return {
23851
- ts,
23852
- value: carry
24256
+ startStream(safeEventHandler).then((cancelFn) => {
24257
+ cancel = cancelFn;
24258
+ }).catch((err) => {
24259
+ console.error("[CLI Stream] Error starting stream:", err);
24260
+ if (!completed) {
24261
+ completed = true;
24262
+ try {
24263
+ emit.error(err instanceof Error ? err : new Error(String(err)));
24264
+ } catch {}
24265
+ }
24266
+ });
24267
+ return () => {
24268
+ completed = true;
24269
+ cancel?.();
23853
24270
  };
23854
24271
  });
23855
24272
  }
23856
- function buildDashboardTimeTrends(options) {
23857
- const pointLimit = clampPointLimit(options.pointLimit);
23858
- const trends = createEmptyTrendSeries();
23859
- for (const metric of DASHBOARD_METRIC_KEYS) {
23860
- if (options.availability[metric].state !== "ok") continue;
23861
- trends[metric] = bucketizeTrend(normalizeEvents(options.events[metric], pointLimit), options.reducers?.[metric] ?? "sum", options.rightEdgeTs);
23862
- }
23863
- return {
23864
- trends,
23865
- trendMeta: {
23866
- pointLimit,
23867
- lastUpdatedAt: options.timestamp
23868
- }
23869
- };
23870
- }
23871
24273
 
23872
24274
  //#endregion
23873
24275
  //#region ../server/src/reactive-kv.ts
@@ -23960,119 +24362,41 @@ function createReactiveSubscription(task) {
23960
24362
  };
23961
24363
  });
23962
24364
  }
23963
- /**
23964
- * 创建带输入参数的响应式订阅
23965
- *
23966
- * @param task 接收输入参数的异步任务
23967
- * @returns 返回一个函数,接收输入参数并返回 tRPC observable
23968
- *
23969
- * @example
23970
- * ```typescript
23971
- * // 在 router 中使用
23972
- * subscribeOne: publicProcedure
23973
- * .input(z.object({ id: z.string() }))
23974
- * .subscription(({ ctx, input }) => {
23975
- * return createReactiveSubscriptionWithInput(
23976
- * (id: string) => ctx.adapter.readSpec(id)
23977
- * )(input.id)
23978
- * })
23979
- * ```
23980
- */
23981
- function createReactiveSubscriptionWithInput(task) {
23982
- return (input) => {
23983
- return createReactiveSubscription(() => task(input));
23984
- };
23985
- }
23986
-
23987
- //#endregion
23988
- //#region ../server/src/router.ts
23989
- const t = initTRPC.context().create();
23990
- const router = t.router;
23991
- const publicProcedure = t.procedure;
23992
- const execFileAsync = promisify$1(execFile);
23993
- const OPSX_CORE_PROFILE_WORKFLOWS = [
23994
- "propose",
23995
- "explore",
23996
- "apply",
23997
- "archive"
23998
- ];
23999
- const dashboardGitTaskStatusEmitter = new EventEmitter$1();
24000
- dashboardGitTaskStatusEmitter.setMaxListeners(200);
24001
- const dashboardGitTaskStatus = {
24002
- running: false,
24003
- inFlight: 0,
24004
- lastStartedAt: null,
24005
- lastFinishedAt: null,
24006
- lastReason: null,
24007
- lastError: null
24008
- };
24009
- function getDashboardGitTaskStatus() {
24010
- return { ...dashboardGitTaskStatus };
24011
- }
24012
- function emitDashboardGitTaskStatus() {
24013
- dashboardGitTaskStatusEmitter.emit("change", getDashboardGitTaskStatus());
24014
- }
24015
- function beginDashboardGitTask(reason) {
24016
- dashboardGitTaskStatus.inFlight += 1;
24017
- dashboardGitTaskStatus.running = true;
24018
- dashboardGitTaskStatus.lastStartedAt = Date.now();
24019
- dashboardGitTaskStatus.lastReason = reason;
24020
- dashboardGitTaskStatus.lastError = null;
24021
- emitDashboardGitTaskStatus();
24022
- }
24023
- function endDashboardGitTask(error) {
24024
- dashboardGitTaskStatus.inFlight = Math.max(0, dashboardGitTaskStatus.inFlight - 1);
24025
- dashboardGitTaskStatus.running = dashboardGitTaskStatus.inFlight > 0;
24026
- dashboardGitTaskStatus.lastFinishedAt = Date.now();
24027
- if (error) dashboardGitTaskStatus.lastError = error instanceof Error ? error.message : String(error);
24028
- emitDashboardGitTaskStatus();
24029
- }
24030
- const DASHBOARD_GIT_REFRESH_STAMP_NAME = "openspecui-dashboard-git-refresh.stamp";
24031
- async function resolveGitMetadataDir(projectDir) {
24032
- try {
24033
- const { stdout } = await execFileAsync("git", ["rev-parse", "--git-dir"], {
24034
- cwd: projectDir,
24035
- maxBuffer: 1024 * 1024,
24036
- encoding: "utf8"
24037
- });
24038
- const gitDirRaw = stdout.trim();
24039
- if (!gitDirRaw) return null;
24040
- const gitDirPath = resolve$1(projectDir, gitDirRaw);
24041
- if (!(await stat(gitDirPath)).isDirectory()) return null;
24042
- return gitDirPath;
24043
- } catch {
24044
- return null;
24045
- }
24046
- }
24047
- async function resolveGitMetadataDirReactive(projectDir) {
24048
- const gitMetadataDir = await resolveGitMetadataDir(projectDir);
24049
- if (!gitMetadataDir) return null;
24050
- await reactiveReadDir(gitMetadataDir, { includeHidden: true });
24051
- return gitMetadataDir;
24052
- }
24053
- function getDashboardGitRefreshStampPath(gitMetadataDir) {
24054
- return join$1(gitMetadataDir, DASHBOARD_GIT_REFRESH_STAMP_NAME);
24055
- }
24056
- async function touchDashboardGitRefreshStamp(projectDir, reason) {
24057
- const gitMetadataDir = await resolveGitMetadataDir(projectDir);
24058
- if (!gitMetadataDir) return { skipped: true };
24059
- const stampPath = getDashboardGitRefreshStampPath(gitMetadataDir);
24060
- await mkdir$1(dirname$1(stampPath), { recursive: true });
24061
- await writeFile$1(stampPath, `${Date.now()} ${reason}\n`, "utf8");
24062
- return { skipped: false };
24063
- }
24064
- async function registerDashboardGitReactiveDeps(projectDir) {
24065
- await reactiveReadDir(projectDir, {
24066
- includeHidden: true,
24067
- exclude: ["node_modules"]
24068
- });
24069
- const gitMetadataDir = await resolveGitMetadataDirReactive(projectDir);
24070
- if (!gitMetadataDir) return;
24071
- await reactiveReadFile(getDashboardGitRefreshStampPath(gitMetadataDir));
24072
- await reactiveReadFile(join$1(gitMetadataDir, "HEAD"));
24073
- await reactiveReadFile(join$1(gitMetadataDir, "index"));
24074
- await reactiveReadFile(join$1(gitMetadataDir, "packed-refs"));
24075
- }
24365
+ /**
24366
+ * 创建带输入参数的响应式订阅
24367
+ *
24368
+ * @param task 接收输入参数的异步任务
24369
+ * @returns 返回一个函数,接收输入参数并返回 tRPC observable
24370
+ *
24371
+ * @example
24372
+ * ```typescript
24373
+ * // 在 router 中使用
24374
+ * subscribeOne: publicProcedure
24375
+ * .input(z.object({ id: z.string() }))
24376
+ * .subscription(({ ctx, input }) => {
24377
+ * return createReactiveSubscriptionWithInput(
24378
+ * (id: string) => ctx.adapter.readSpec(id)
24379
+ * )(input.id)
24380
+ * })
24381
+ * ```
24382
+ */
24383
+ function createReactiveSubscriptionWithInput(task) {
24384
+ return (input) => {
24385
+ return createReactiveSubscription(() => task(input));
24386
+ };
24387
+ }
24388
+
24389
+ //#endregion
24390
+ //#region ../server/src/router.ts
24391
+ const t = initTRPC.context().create();
24392
+ const router = t.router;
24393
+ const publicProcedure = t.procedure;
24394
+ const OPSX_CORE_PROFILE_WORKFLOWS = [
24395
+ "propose",
24396
+ "explore",
24397
+ "apply",
24398
+ "archive"
24399
+ ];
24076
24400
  function requireChangeId(changeId) {
24077
24401
  if (!changeId) throw new Error("change is required");
24078
24402
  return changeId;
@@ -24242,223 +24566,6 @@ function buildSystemStatus(ctx) {
24242
24566
  watcherLastReinitializeReason: runtime?.lastReinitializeReason ?? null
24243
24567
  };
24244
24568
  }
24245
- function resolveTrendTimestamp(primary, secondary) {
24246
- if (typeof primary === "number" && Number.isFinite(primary) && primary > 0) return primary;
24247
- if (typeof secondary === "number" && Number.isFinite(secondary) && secondary > 0) return secondary;
24248
- return null;
24249
- }
24250
- function parseDatedIdTimestamp(id) {
24251
- const match$1 = /^(\d{4})-(\d{2})-(\d{2})(?:-|$)/.exec(id);
24252
- if (!match$1) return null;
24253
- const year = Number(match$1[1]);
24254
- const month = Number(match$1[2]);
24255
- const day = Number(match$1[3]);
24256
- if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
24257
- if (month < 1 || month > 12) return null;
24258
- if (day < 1 || day > 31) return null;
24259
- const ts = Date.UTC(year, month - 1, day);
24260
- return Number.isFinite(ts) ? ts : null;
24261
- }
24262
- function createEmptyTriColorTrends() {
24263
- return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
24264
- }
24265
- async function readLatestCommitTimestamp(projectDir) {
24266
- try {
24267
- const { stdout } = await execFileAsync("git", [
24268
- "log",
24269
- "-1",
24270
- "--format=%ct"
24271
- ], {
24272
- cwd: projectDir,
24273
- maxBuffer: 1024 * 1024,
24274
- encoding: "utf8"
24275
- });
24276
- const seconds = Number(stdout.trim());
24277
- return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : null;
24278
- } catch {
24279
- return null;
24280
- }
24281
- }
24282
- async function fetchDashboardOverview(ctx, reason = "dashboard-refresh") {
24283
- if (contextStorage.getStore()) await registerDashboardGitReactiveDeps(ctx.projectDir);
24284
- const now = Date.now();
24285
- const [specMetas, changeMetas, archiveMetas] = await Promise.all([
24286
- ctx.adapter.listSpecsWithMeta(),
24287
- ctx.adapter.listChangesWithMeta(),
24288
- ctx.adapter.listArchivedChangesWithMeta()
24289
- ]);
24290
- await ctx.kernel.waitForWarmup();
24291
- await ctx.kernel.ensureStatusList();
24292
- const statusList = ctx.kernel.getStatusList();
24293
- const changeMetaMap = new Map(changeMetas.map((change) => [change.id, change]));
24294
- const activeChangeIds = new Set([...changeMetas.map((change) => change.id), ...statusList.map((status) => status.changeName)]);
24295
- const statusByChange = new Map(statusList.map((status) => [status.changeName, status]));
24296
- const activeChanges = (await Promise.all([...activeChangeIds].map(async (changeId) => {
24297
- const status = statusByChange.get(changeId);
24298
- const changeMeta = changeMetaMap.get(changeId);
24299
- const statInfo = await reactiveStat(join$1(ctx.projectDir, "openspec", "changes", changeId));
24300
- let progress = changeMeta?.progress ?? {
24301
- total: 0,
24302
- completed: 0
24303
- };
24304
- if (status) try {
24305
- await ctx.kernel.ensureApplyInstructions(changeId, status.schemaName);
24306
- const apply = ctx.kernel.getApplyInstructions(changeId, status.schemaName);
24307
- progress = {
24308
- total: apply.progress.total,
24309
- completed: apply.progress.complete
24310
- };
24311
- } catch {}
24312
- return {
24313
- id: changeId,
24314
- name: changeMeta?.name ?? changeId,
24315
- progress,
24316
- updatedAt: changeMeta?.updatedAt ?? statInfo?.mtime ?? 0
24317
- };
24318
- }))).sort((a, b) => b.updatedAt - a.updatedAt);
24319
- const archivedChanges = (await Promise.all(archiveMetas.map(async (meta) => {
24320
- const change = await ctx.adapter.readArchivedChange(meta.id);
24321
- if (!change) return null;
24322
- return {
24323
- id: meta.id,
24324
- createdAt: meta.createdAt,
24325
- updatedAt: meta.updatedAt,
24326
- tasksCompleted: change.tasks.filter((task) => task.completed).length
24327
- };
24328
- }))).filter((item) => item !== null);
24329
- const specifications = (await Promise.all(specMetas.map(async (meta) => {
24330
- const spec = await ctx.adapter.readSpec(meta.id);
24331
- if (!spec) return null;
24332
- return {
24333
- id: meta.id,
24334
- name: meta.name,
24335
- requirements: spec.requirements.length,
24336
- updatedAt: meta.updatedAt
24337
- };
24338
- }))).filter((item) => item !== null).sort((a, b) => b.requirements - a.requirements || b.updatedAt - a.updatedAt);
24339
- const requirements = specifications.reduce((sum, spec) => sum + spec.requirements, 0);
24340
- const tasksTotal = activeChanges.reduce((sum, change) => sum + change.progress.total, 0);
24341
- const tasksCompleted = activeChanges.reduce((sum, change) => sum + change.progress.completed, 0);
24342
- const archivedTasksCompleted = archivedChanges.reduce((sum, change) => sum + change.tasksCompleted, 0);
24343
- const taskCompletionPercent = tasksTotal > 0 ? Math.round(tasksCompleted / tasksTotal * 100) : null;
24344
- const inProgressChanges = activeChanges.filter((change) => change.progress.total > 0 && change.progress.completed < change.progress.total).length;
24345
- const specificationTrendEvents = specMetas.flatMap((spec) => {
24346
- const ts = resolveTrendTimestamp(spec.createdAt, spec.updatedAt);
24347
- return ts === null ? [] : [{
24348
- ts,
24349
- value: 1
24350
- }];
24351
- });
24352
- const completedTrendEvents = archivedChanges.flatMap((archive) => {
24353
- const ts = parseDatedIdTimestamp(archive.id) ?? resolveTrendTimestamp(archive.updatedAt, archive.createdAt);
24354
- return ts === null ? [] : [{
24355
- ts,
24356
- value: archive.tasksCompleted
24357
- }];
24358
- });
24359
- const specMetaById = new Map(specMetas.map((meta) => [meta.id, meta]));
24360
- const requirementTrendEvents = specifications.flatMap((spec) => {
24361
- const meta = specMetaById.get(spec.id);
24362
- const ts = resolveTrendTimestamp(meta?.updatedAt, meta?.createdAt);
24363
- return ts === null ? [] : [{
24364
- ts,
24365
- value: spec.requirements
24366
- }];
24367
- });
24368
- const hasObjectiveSpecificationTrend = specificationTrendEvents.length > 0 || specifications.length === 0;
24369
- const hasObjectiveRequirementTrend = requirementTrendEvents.length > 0 || requirements === 0;
24370
- const hasObjectiveCompletedTrend = completedTrendEvents.length > 0 || archiveMetas.length === 0;
24371
- const config = await ctx.configManager.readConfig();
24372
- beginDashboardGitTask(reason);
24373
- let latestCommitTs = null;
24374
- let git;
24375
- try {
24376
- const gitSnapshotPromise = buildDashboardGitSnapshot({ projectDir: ctx.projectDir }).catch(() => ({
24377
- defaultBranch: "main",
24378
- worktrees: []
24379
- }));
24380
- latestCommitTs = await readLatestCommitTimestamp(ctx.projectDir);
24381
- git = await gitSnapshotPromise;
24382
- } catch (error) {
24383
- endDashboardGitTask(error);
24384
- throw error;
24385
- }
24386
- endDashboardGitTask(null);
24387
- const cardAvailability = {
24388
- specifications: hasObjectiveSpecificationTrend ? { state: "ok" } : {
24389
- state: "invalid",
24390
- reason: "objective-history-unavailable"
24391
- },
24392
- requirements: hasObjectiveRequirementTrend ? { state: "ok" } : {
24393
- state: "invalid",
24394
- reason: "objective-history-unavailable"
24395
- },
24396
- activeChanges: {
24397
- state: "invalid",
24398
- reason: "objective-history-unavailable"
24399
- },
24400
- inProgressChanges: {
24401
- state: "invalid",
24402
- reason: "objective-history-unavailable"
24403
- },
24404
- completedChanges: hasObjectiveCompletedTrend ? { state: "ok" } : {
24405
- state: "invalid",
24406
- reason: "objective-history-unavailable"
24407
- },
24408
- taskCompletionPercent: {
24409
- state: "invalid",
24410
- reason: taskCompletionPercent === null ? "semantic-uncomputable" : "objective-history-unavailable"
24411
- }
24412
- };
24413
- const trendKinds = {
24414
- specifications: "monotonic",
24415
- requirements: "monotonic",
24416
- activeChanges: "bidirectional",
24417
- inProgressChanges: "bidirectional",
24418
- completedChanges: "monotonic",
24419
- taskCompletionPercent: "bidirectional"
24420
- };
24421
- const { trends: baselineTrends, trendMeta } = buildDashboardTimeTrends({
24422
- pointLimit: config.dashboard.trendPointLimit,
24423
- timestamp: now,
24424
- rightEdgeTs: latestCommitTs,
24425
- availability: cardAvailability,
24426
- events: {
24427
- specifications: specificationTrendEvents,
24428
- requirements: requirementTrendEvents,
24429
- activeChanges: [],
24430
- inProgressChanges: [],
24431
- completedChanges: completedTrendEvents,
24432
- taskCompletionPercent: []
24433
- },
24434
- reducers: {
24435
- specifications: "sum",
24436
- requirements: "sum",
24437
- completedChanges: "sum"
24438
- }
24439
- });
24440
- return {
24441
- summary: {
24442
- specifications: specifications.length,
24443
- requirements,
24444
- activeChanges: activeChanges.length,
24445
- inProgressChanges,
24446
- completedChanges: archiveMetas.length,
24447
- archivedTasksCompleted,
24448
- tasksTotal,
24449
- tasksCompleted,
24450
- taskCompletionPercent
24451
- },
24452
- trends: baselineTrends,
24453
- triColorTrends: createEmptyTriColorTrends(),
24454
- trendKinds,
24455
- cardAvailability,
24456
- trendMeta,
24457
- specifications,
24458
- activeChanges,
24459
- git
24460
- };
24461
- }
24462
24569
  /**
24463
24570
  * Spec router - spec CRUD operations
24464
24571
  */
@@ -24667,6 +24774,7 @@ const configRouter = router({
24667
24774
  "system"
24668
24775
  ]).optional(),
24669
24776
  codeEditor: objectType({ theme: CodeEditorThemeSchema.optional() }).optional(),
24777
+ appBaseUrl: stringType().optional(),
24670
24778
  terminal: TerminalConfigSchema.omit({ rendererEngine: true }).partial().extend({ rendererEngine: TerminalRendererEngineSchema.optional() }).optional(),
24671
24779
  dashboard: DashboardConfigSchema.partial().optional()
24672
24780
  })).mutation(async ({ ctx, input }) => {
@@ -24674,9 +24782,10 @@ const configRouter = router({
24674
24782
  const hasCliArgs = input.cli !== void 0 && Object.prototype.hasOwnProperty.call(input.cli, "args");
24675
24783
  if (hasCliCommand && !hasCliArgs) {
24676
24784
  await ctx.configManager.setCliCommand(input.cli?.command ?? "");
24677
- if (input.theme !== void 0 || input.codeEditor !== void 0 || input.terminal !== void 0 || input.dashboard !== void 0) await ctx.configManager.writeConfig({
24785
+ 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({
24678
24786
  theme: input.theme,
24679
24787
  codeEditor: input.codeEditor,
24788
+ appBaseUrl: input.appBaseUrl,
24680
24789
  terminal: input.terminal,
24681
24790
  dashboard: input.dashboard
24682
24791
  });
@@ -25158,30 +25267,52 @@ const systemRouter = router({
25158
25267
  */
25159
25268
  const dashboardRouter = router({
25160
25269
  get: publicProcedure.query(async ({ ctx }) => {
25161
- return fetchDashboardOverview(ctx, "dashboard.get");
25270
+ return ctx.dashboardOverviewService.getCurrent();
25162
25271
  }),
25163
25272
  subscribe: publicProcedure.subscription(({ ctx }) => {
25164
- return createReactiveSubscription(async () => {
25165
- return fetchDashboardOverview(ctx, "dashboard.subscribe");
25273
+ return observable((emit) => {
25274
+ const unsubscribe = ctx.dashboardOverviewService.subscribe((overview) => {
25275
+ emit.next(overview);
25276
+ }, {
25277
+ emitCurrent: true,
25278
+ onError: (error) => {
25279
+ emit.error(error);
25280
+ }
25281
+ });
25282
+ return () => {
25283
+ unsubscribe();
25284
+ };
25166
25285
  });
25167
25286
  }),
25168
25287
  refreshGitSnapshot: publicProcedure.input(objectType({ reason: stringType().optional() }).optional()).mutation(async ({ ctx, input }) => {
25169
25288
  const reason = input?.reason?.trim() || "manual-refresh";
25170
25289
  await touchDashboardGitRefreshStamp(ctx.projectDir, reason);
25290
+ await ctx.dashboardOverviewService.refresh(reason);
25171
25291
  return { success: true };
25172
25292
  }),
25173
- gitTaskStatus: publicProcedure.query(() => {
25293
+ removeDetachedWorktree: publicProcedure.input(objectType({ path: stringType().min(1) })).mutation(async ({ ctx, input }) => {
25294
+ await ctx.dashboardOverviewService.getCurrent();
25295
+ await removeDetachedDashboardGitWorktree({
25296
+ projectDir: ctx.projectDir,
25297
+ targetPath: input.path
25298
+ });
25299
+ await touchDashboardGitRefreshStamp(ctx.projectDir, "remove-detached-worktree");
25300
+ await ctx.dashboardOverviewService.refresh("remove-detached-worktree");
25301
+ return { success: true };
25302
+ }),
25303
+ gitTaskStatus: publicProcedure.query(async ({ ctx }) => {
25304
+ await ctx.dashboardOverviewService.getCurrent();
25174
25305
  return getDashboardGitTaskStatus();
25175
25306
  }),
25176
- subscribeGitTaskStatus: publicProcedure.subscription(() => {
25307
+ subscribeGitTaskStatus: publicProcedure.subscription(({ ctx }) => {
25177
25308
  return observable((emit) => {
25309
+ ctx.dashboardOverviewService.getCurrent().catch(() => {});
25178
25310
  emit.next(getDashboardGitTaskStatus());
25179
- const handler = (status) => {
25311
+ const unsubscribe = subscribeDashboardGitTaskStatus((status) => {
25180
25312
  emit.next(status);
25181
- };
25182
- dashboardGitTaskStatusEmitter.on("change", handler);
25313
+ });
25183
25314
  return () => {
25184
- dashboardGitTaskStatusEmitter.off("change", handler);
25315
+ unsubscribe();
25185
25316
  };
25186
25317
  });
25187
25318
  })
@@ -25438,6 +25569,17 @@ var SearchService = class {
25438
25569
  *
25439
25570
  * @module server
25440
25571
  */
25572
+ const __dirname$1 = dirname$1(fileURLToPath(import.meta.url));
25573
+ function getServerPackageVersion() {
25574
+ try {
25575
+ const packageJsonPath = join$1(__dirname$1, "..", "package.json");
25576
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
25577
+ return typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
25578
+ } catch {
25579
+ return "0.0.0";
25580
+ }
25581
+ }
25582
+ const SERVER_PACKAGE_VERSION = getServerPackageVersion();
25441
25583
  /**
25442
25584
  * Create an OpenSpecUI HTTP server with optional WebSocket support
25443
25585
  */
@@ -25448,6 +25590,11 @@ function createServer$2(config) {
25448
25590
  const kernel = config.kernel;
25449
25591
  const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
25450
25592
  const searchService = new SearchService(adapter, watcher);
25593
+ const dashboardOverviewService = new DashboardOverviewService((reason) => loadDashboardOverview({
25594
+ adapter,
25595
+ configManager,
25596
+ projectDir: config.projectDir
25597
+ }, reason), watcher);
25451
25598
  const app = new Hono();
25452
25599
  const corsOrigins = config.corsOrigins ?? ["http://localhost:5173", "http://localhost:3000"];
25453
25600
  app.use("*", cors({
@@ -25458,7 +25605,9 @@ function createServer$2(config) {
25458
25605
  return c.json({
25459
25606
  status: "ok",
25460
25607
  projectDir: config.projectDir,
25461
- watcherEnabled: !!watcher
25608
+ projectName: basename$1(config.projectDir) || config.projectDir,
25609
+ watcherEnabled: !!watcher,
25610
+ openspecuiVersion: SERVER_PACKAGE_VERSION
25462
25611
  });
25463
25612
  });
25464
25613
  app.use("/trpc/*", async (c) => {
@@ -25472,6 +25621,7 @@ function createServer$2(config) {
25472
25621
  cliExecutor,
25473
25622
  kernel,
25474
25623
  searchService,
25624
+ dashboardOverviewService,
25475
25625
  watcher,
25476
25626
  projectDir: config.projectDir
25477
25627
  })
@@ -25483,6 +25633,7 @@ function createServer$2(config) {
25483
25633
  cliExecutor,
25484
25634
  kernel,
25485
25635
  searchService,
25636
+ dashboardOverviewService,
25486
25637
  watcher,
25487
25638
  projectDir: config.projectDir
25488
25639
  });
@@ -25493,6 +25644,7 @@ function createServer$2(config) {
25493
25644
  cliExecutor,
25494
25645
  kernel,
25495
25646
  searchService,
25647
+ dashboardOverviewService,
25496
25648
  watcher,
25497
25649
  createContext,
25498
25650
  port: config.port ?? 3100
@@ -25540,6 +25692,7 @@ async function createWebSocketServer(server, httpServer, config) {
25540
25692
  wss.close();
25541
25693
  server.watcher?.stop();
25542
25694
  server.searchService.dispose().catch(() => {});
25695
+ server.dashboardOverviewService.dispose();
25543
25696
  }
25544
25697
  };
25545
25698
  }
@@ -25575,6 +25728,9 @@ async function startServer(config, setupApp) {
25575
25728
  server.searchService.init().catch((err) => {
25576
25729
  console.error("Search service warmup failed:", err);
25577
25730
  });
25731
+ server.dashboardOverviewService.init().catch((err) => {
25732
+ console.error("Dashboard overview warmup failed:", err);
25733
+ });
25578
25734
  return {
25579
25735
  url,
25580
25736
  port,
@@ -25590,9 +25746,6 @@ async function startServer(config, setupApp) {
25590
25746
  //#endregion
25591
25747
  //#region src/index.ts
25592
25748
  const __dirname = dirname$1(fileURLToPath(import.meta.url));
25593
- /**
25594
- * Get the path to the web assets directory
25595
- */
25596
25749
  function getWebAssetsDir() {
25597
25750
  const devPath = join$1(__dirname, "..", "..", "web", "dist");
25598
25751
  const prodPath = join$1(__dirname, "..", "web");
@@ -25600,9 +25753,6 @@ function getWebAssetsDir() {
25600
25753
  if (existsSync(devPath)) return devPath;
25601
25754
  throw new Error("Web assets not found. Make sure to build the web package first.");
25602
25755
  }
25603
- /**
25604
- * Setup static file serving middleware for the Hono app
25605
- */
25606
25756
  function setupStaticFiles(app) {
25607
25757
  const webDir = getWebAssetsDir();
25608
25758
  const mimeTypes = {
@@ -25639,18 +25789,15 @@ function setupStaticFiles(app) {
25639
25789
  return c.notFound();
25640
25790
  });
25641
25791
  }
25642
- /**
25643
- * Start the OpenSpec UI server with WebSocket support for realtime updates.
25644
- * Includes static file serving for the web UI.
25645
- */
25646
25792
  async function startServer$1(options = {}) {
25647
- const { projectDir = process.cwd(), port = 3100, enableWatcher = true } = options;
25793
+ const { projectDir = process.cwd(), port = 3100, enableWatcher = true, corsOrigins } = options;
25648
25794
  return await startServer({
25649
25795
  projectDir,
25650
25796
  port,
25651
- enableWatcher
25797
+ enableWatcher,
25798
+ corsOrigins
25652
25799
  }, setupStaticFiles);
25653
25800
  }
25654
25801
 
25655
25802
  //#endregion
25656
- export { SchemaInfoSchema as a, toOpsxDisplayPath as c, DEFAULT_CONFIG as d, OpenSpecAdapter as f, SchemaDetailSchema as i, CliExecutor as l, __toESM$1 as m, createServer$2 as n, SchemaResolutionSchema as o, __commonJS$1 as p, require_dist as r, TemplatesSchema as s, startServer$1 as t, ConfigManager as u };
25803
+ export { SchemaInfoSchema as a, toOpsxDisplayPath as c, CliExecutor as d, ConfigManager as f, __toESM$1 as g, __commonJS$1 as h, SchemaDetailSchema as i, buildHostedLaunchUrl as l, OpenSpecAdapter as m, createServer$2 as n, SchemaResolutionSchema as o, DEFAULT_CONFIG as p, require_dist as r, TemplatesSchema as s, startServer$1 as t, resolveHostedAppBaseUrl as u };