openspec-dashboard 0.1.0 → 0.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.
package/README.en.md ADDED
@@ -0,0 +1,50 @@
1
+ # OpenSpec Dashboard
2
+
3
+ [中文](./README.md) | **English**
4
+
5
+ > A zero-config visual dashboard for OpenSpec SDD — track the full spec-driven development workflow in your browser with a single command, with built-in visual skill execution.
6
+
7
+ [![npm version](https://img.shields.io/npm/v/openspec-dashboard.svg)](https://www.npmjs.com/package/openspec-dashboard)
8
+ [![node](https://img.shields.io/node/v/openspec-dashboard.svg)](https://nodejs.org)
9
+ [![license](https://img.shields.io/npm/l/openspec-dashboard.svg)](./LICENSE)
10
+
11
+ ## Quick Install
12
+
13
+ `npm i -g openspec-dashboard`.
14
+
15
+ ## Features
16
+
17
+ - **Three-column Kanban board** — In Progress / Ready / Archived, see the stage of every change at a glance
18
+ - **Artifact visualization** — Live rendering of `proposal.md` / `design.md` / `tasks.md`, with automatic task completion stats
19
+ - **One-click skill execution** — Smart recommendations of `continue` / `apply` / `archive` based on change status, click to run with real-time streaming logs
20
+ - **Hot reload** — `chokidar` + WebSocket, instant refresh on save, no manual reload needed
21
+ - **New project wizard** — Three-step form to generate `openspec/config.yaml`, supports Java / Node / Python / Go
22
+
23
+ ## CLI Options
24
+
25
+ ```bash
26
+ openspec-dashboard [options]
27
+
28
+ -p, --port <port> Server port (default 3456)
29
+ -d, --dir <dir> Specify the openspec directory (default: auto-discover up 5 levels)
30
+ --no-open Do not auto-open the browser on startup
31
+ ```
32
+
33
+ ## Directory Convention
34
+
35
+ ```text
36
+ your-project/
37
+ └── openspec/
38
+ ├── config.yaml
39
+ ├── schemas/<name>/schema.yaml
40
+ └── changes/<change-name>/
41
+ ├── .openspec.yaml
42
+ ├── proposal.md
43
+ ├── design.md
44
+ ├── tasks.md
45
+ └── specs/<capability>/spec.md
46
+ ```
47
+
48
+ ## License
49
+
50
+ MIT
package/README.md CHANGED
@@ -1,109 +1,50 @@
1
1
  # OpenSpec Dashboard
2
2
 
3
- 本地可视化看板,实时展示 OpenSpec SDD(Spec-Driven Development)流程中的变更状态。在包含 `openspec/` 目录的项目中启动,即可获得带热更新的 Web 看板界面。
3
+ **中文** | [English](./README.en.md)
4
4
 
5
- ## 功能特性
5
+ > 零配置的 OpenSpec SDD 可视化看板 —— 一个命令 即可在浏览器中追踪规格驱动开发的全流程,提供可视化操作skill功能。
6
6
 
7
- ### 📋 变更全局可视化看板
8
- - **三列 Kanban 视图**:进行中、待实现、已归档
9
- - 每张卡片展示变更名称、类型标签、产物进度条、创建时间
10
- - 支持变更类型识别:`feat-*`、`fix-*`、`hotfix-*`
11
- - 产物状态追踪:proposal → specs → design → tasks
7
+ [![npm version](https://img.shields.io/npm/v/openspec-dashboard.svg)](https://www.npmjs.com/package/openspec-dashboard)
8
+ [![node](https://img.shields.io/node/v/openspec-dashboard.svg)](https://nodejs.org)
9
+ [![license](https://img.shields.io/npm/l/openspec-dashboard.svg)](./LICENSE)
12
10
 
13
- ### 📄 变更详情查看
14
- - Markdown 产物内容实时渲染
15
- - tasks.md checkbox 完成度统计
16
- - 相关 spec 文件列表展示
11
+ ## 快速安装
17
12
 
18
- ### 🚀 新项目快速接入向导
19
- - 三步式表单引导:基础信息 → 工作流选择 → 生成确认
20
- - 支持多种技术栈:Java / Node / Python / Go
21
- - 内置流程模板:标准流程 / 轻量流程 / Hotfix 流程
22
- - 一键生成项目配置文件
13
+ `npm i -g openspec-dashboard`。
23
14
 
24
- ### ⚡ 实时更新
25
- - 文件系统监听(chokidar)
26
- - WebSocket 增量推送
27
- - 热更新无需手动刷新
15
+ ## 核心功能
28
16
 
29
- ### 🛠️ 技能执行面板
30
- - 可视化技能选择器
31
- - 命令执行终端
32
- - 实时输出展示
17
+ - **三列 Kanban 看板** — 进行中 / 待实现 / 已归档,一眼掌握所有变更的阶段分布
18
+ - **产物可视化** — 实时渲染 `proposal.md` / `design.md` / `tasks.md`,自动统计 tasks 完成度
19
+ - **一键技能执行** — 根据变更状态智能推荐 `continue` / `apply` / `archive`,点击即跑,日志实时流式回传
20
+ - **热更新** — `chokidar` + WebSocket,编辑保存即刷新,无需手动操作
21
+ - **新项目向导** — 三步表单生成 `openspec/config.yaml`,支持 Java / Node / Python / Go
33
22
 
34
- ## 快速开始
23
+ ## CLI 参数
35
24
 
36
25
  ```bash
37
- npm install
38
- npm run dev
39
- ```
40
-
41
- 开发服务启动后会自动打开浏览器,展示当前项目的变更看板。
42
-
43
- ## CLI 使用
44
-
45
- ```bash
46
- # 默认启动(自动发现 openspec/ 目录)
47
- npx openspec-dashboard
48
-
49
- # 自定义端口
50
- openspec-dashboard --port 3000
26
+ openspec-dashboard [options]
51
27
 
52
- # 指定项目路径
53
- openspec-dashboard --dir ./path
28
+ -p, --port <port> 服务端口(默认 3456)
29
+ -d, --dir <dir> 指定 openspec 目录(默认自动向上查找 5 层)
30
+ --no-open 启动后不自动打开浏览器
54
31
  ```
55
32
 
56
- ## 构建与生产运行
57
-
58
- ```bash
59
- npm run build
60
- npm start
33
+ ## 目录约定
34
+
35
+ ```text
36
+ your-project/
37
+ └── openspec/
38
+ ├── config.yaml
39
+ ├── schemas/<name>/schema.yaml
40
+ └── changes/<change-name>/
41
+ ├── .openspec.yaml
42
+ ├── proposal.md
43
+ ├── design.md
44
+ ├── tasks.md
45
+ └── specs/<capability>/spec.md
61
46
  ```
62
47
 
63
- ## 测试
64
-
65
- ```bash
66
- npm test
67
- ```
68
-
69
- ## 技术栈
70
-
71
- - **前端框架**: React 18 + Vite
72
- - **样式**: TailwindCSS 3 + shadcn/ui
73
- - **后端**: Node.js + Express
74
- - **文件监听**: chokidar
75
- - **WebSocket**: ws
76
- - **Markdown 渲染**: react-markdown + remark-gfm
77
-
78
- ## 项目结构
79
-
80
- ```
81
- openspec-dashboard/
82
- ├── src/
83
- │ ├── server/ # 后端服务
84
- │ │ ├── parser/ # OpenSpec 解析器
85
- │ │ ├── routes/ # API 路由
86
- │ │ ├── watcher.ts # 文件监听
87
- │ │ └── ws.ts # WebSocket
88
- │ └── client/ # 前端应用
89
- │ ├── components/ # UI 组件
90
- │ ├── hooks/ # 自定义 Hooks
91
- │ └── lib/ # 工具函数
92
- ├── openspec/ # 示例 OpenSpec 配置
93
- └── tests/ # 测试文件
94
- ```
95
-
96
- ## OpenSpec SDD 流程
97
-
98
- OpenSpec Dashboard 支持 Spec-Driven Development 流程,通过以下产物管理变更:
99
-
100
- 1. **proposal.md** - 变更提案
101
- 2. **specs/** - 技术规格文档
102
- 3. **design.md** - 设计文档
103
- 4. **tasks.md** - 任务清单
104
-
105
- 变更状态自动判定:
106
- - **进行中**:有产物但未全部完成
107
- - **待实现**:所有产物已就绪,等待 apply
108
- - **已归档**:位于 `archive/` 目录下的变更
48
+ ## License
109
49
 
50
+ MIT
package/dist/bin/cli.js CHANGED
@@ -1,24 +1,55 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  startServer
4
- } from "../chunk-YBLVDJ3Y.js";
4
+ } from "../chunk-JYWT57PM.js";
5
5
 
6
6
  // src/bin/cli.ts
7
7
  import { program } from "commander";
8
- import path from "path";
8
+ import path2 from "path";
9
9
  import fs from "fs";
10
+ import { fileURLToPath as fileURLToPath2 } from "url";
11
+
12
+ // src/shared/version.ts
13
+ import { readFileSync } from "fs";
14
+ import path from "path";
10
15
  import { fileURLToPath } from "url";
11
- var __filename = fileURLToPath(import.meta.url);
12
- var __dirname = path.dirname(__filename);
13
- program.name("openspec-dashboard").description("OpenSpec SDD \u672C\u5730\u53EF\u89C6\u5316\u770B\u677F").version("0.1.0").option("-p, --port <port>", "\u670D\u52A1\u7AEF\u53E3", "3456").option("-d, --dir <dir>", "\u6307\u5B9A openspec \u76EE\u5F55\u8DEF\u5F84").option("--no-open", "\u4E0D\u81EA\u52A8\u6253\u5F00\u6D4F\u89C8\u5668").action(async (opts) => {
16
+ var cachedVersion = null;
17
+ function getVersion() {
18
+ if (cachedVersion) return cachedVersion;
19
+ const __filename2 = fileURLToPath(import.meta.url);
20
+ const __dirname2 = path.dirname(__filename2);
21
+ const candidates = [
22
+ path.resolve(__dirname2, "../../package.json"),
23
+ path.resolve(__dirname2, "../package.json"),
24
+ path.resolve(__dirname2, "../../../package.json")
25
+ ];
26
+ for (const candidate of candidates) {
27
+ try {
28
+ const raw = readFileSync(candidate, "utf-8");
29
+ const pkg = JSON.parse(raw);
30
+ if (pkg.version) {
31
+ cachedVersion = pkg.version;
32
+ return cachedVersion;
33
+ }
34
+ } catch {
35
+ }
36
+ }
37
+ cachedVersion = "0.0.0";
38
+ return cachedVersion;
39
+ }
40
+
41
+ // src/bin/cli.ts
42
+ var __filename = fileURLToPath2(import.meta.url);
43
+ var __dirname = path2.dirname(__filename);
44
+ program.name("openspec-dashboard").description("OpenSpec SDD \u672C\u5730\u53EF\u89C6\u5316\u770B\u677F").version(getVersion()).option("-p, --port <port>", "\u670D\u52A1\u7AEF\u53E3", "3456").option("-d, --dir <dir>", "\u6307\u5B9A openspec \u76EE\u5F55\u8DEF\u5F84").option("--no-open", "\u4E0D\u81EA\u52A8\u6253\u5F00\u6D4F\u89C8\u5668").action(async (opts) => {
14
45
  const port = parseInt(opts.port, 10);
15
46
  const openspecDir = resolveOpenspecDir(opts.dir);
16
47
  if (!openspecDir) {
17
48
  console.error("\u9519\u8BEF\uFF1A\u672A\u627E\u5230 openspec/ \u76EE\u5F55\u3002\u8BF7\u5728\u5305\u542B openspec/ \u7684\u9879\u76EE\u4E2D\u8FD0\u884C\uFF0C\u6216\u4F7F\u7528 --dir \u6307\u5B9A\u8DEF\u5F84\u3002");
18
49
  process.exit(1);
19
50
  }
20
- const staticDir = path.resolve(__dirname, "../client");
21
- const { shutdown } = await startServer({ openspecDir, port, staticDir, projectDir: path.resolve(openspecDir, "..") });
51
+ const staticDir = path2.resolve(__dirname, "../client");
52
+ const { shutdown } = await startServer({ openspecDir, port, staticDir, projectDir: path2.resolve(openspecDir, "..") });
22
53
  if (opts.open !== false) {
23
54
  const open = await import("open");
24
55
  await open.default(`http://localhost:${port}`);
@@ -30,17 +61,17 @@ program.name("openspec-dashboard").description("OpenSpec SDD \u672C\u5730\u53EF\
30
61
  });
31
62
  function resolveOpenspecDir(dir) {
32
63
  if (dir) {
33
- const abs = path.resolve(dir);
64
+ const abs = path2.resolve(dir);
34
65
  if (fs.existsSync(abs)) return abs;
35
66
  return null;
36
67
  }
37
68
  let current = process.cwd();
38
69
  for (let i = 0; i < 5; i++) {
39
- const candidate = path.join(current, "openspec");
70
+ const candidate = path2.join(current, "openspec");
40
71
  if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
41
72
  return candidate;
42
73
  }
43
- const parent = path.dirname(current);
74
+ const parent = path2.dirname(current);
44
75
  if (parent === current) break;
45
76
  current = parent;
46
77
  }
@@ -166,6 +166,19 @@ async function parseTaskProgressFromFile(filePath) {
166
166
  }
167
167
 
168
168
  // src/server/parser/change-parser.ts
169
+ function extractWhyFromProposal(proposalContent) {
170
+ if (!proposalContent) return void 0;
171
+ const match = proposalContent.match(/##\s+Why\s*\n+([^#]+?)(?:\n+[^#]|#|$)/);
172
+ if (!match) return void 0;
173
+ let why = match[1].trim();
174
+ if (!why) return void 0;
175
+ why = why.replace(/\*\*([^*]+)\*\*/g, "$1");
176
+ why = why.replace(/\s+/g, " ").trim();
177
+ if (why.length > 100) {
178
+ why = why.slice(0, 97) + "...";
179
+ }
180
+ return why;
181
+ }
169
182
  function deriveChangeType(name) {
170
183
  if (name.startsWith("feat-")) return "feat";
171
184
  if (name.startsWith("fix-")) return "fix";
@@ -284,7 +297,7 @@ async function parseOneChange(changeDir, name, schemas, isArchived) {
284
297
  if (!createdAt) {
285
298
  try {
286
299
  const stats = await fs5.stat(changeDir);
287
- createdAt = stats.birthtime.toISOString();
300
+ createdAt = stats.mtime.toISOString();
288
301
  } catch {
289
302
  }
290
303
  }
@@ -292,6 +305,14 @@ async function parseOneChange(changeDir, name, schemas, isArchived) {
292
305
  const hasProposal = await fileExists(path4.join(changeDir, "proposal.md"));
293
306
  const hasTasks = await fileExists(path4.join(changeDir, "tasks.md"));
294
307
  const taskProgress = await parseTaskProgressFromFile(path4.join(changeDir, "tasks.md"));
308
+ let why;
309
+ if (hasProposal) {
310
+ try {
311
+ const proposalContent = await fs5.readFile(path4.join(changeDir, "proposal.md"), "utf-8");
312
+ why = extractWhyFromProposal(proposalContent);
313
+ } catch {
314
+ }
315
+ }
295
316
  const specsDir = path4.join(changeDir, "specs");
296
317
  let applied = false;
297
318
  try {
@@ -318,7 +339,8 @@ async function parseOneChange(changeDir, name, schemas, isArchived) {
318
339
  artifacts,
319
340
  taskProgress: taskProgress ?? void 0,
320
341
  createdAt,
321
- applied
342
+ applied,
343
+ why
322
344
  };
323
345
  }
324
346
  async function parseChanges(openspecDir, schemas) {
@@ -486,6 +508,38 @@ function createChangesRouter(getData, getOpenspecDir) {
486
508
  const allSuccess = results.every((r) => r.success);
487
509
  res.status(allSuccess ? 200 : 207).json({ results });
488
510
  });
511
+ router.post("/batch-delete", async (req, res) => {
512
+ const { names } = req.body;
513
+ if (!Array.isArray(names) || names.length === 0) {
514
+ res.status(400).json({ error: "names must be a non-empty array" });
515
+ return;
516
+ }
517
+ const openspecDir = getOpenspecDir();
518
+ const changesDir = path5.join(openspecDir, "changes");
519
+ const archiveDir = path5.join(changesDir, "archive");
520
+ const results = [];
521
+ for (const name of names) {
522
+ const activeChangeDir = path5.join(changesDir, name);
523
+ const archivedChangeDir = path5.join(archiveDir, name);
524
+ try {
525
+ let dirToDelete = null;
526
+ if (fs6.existsSync(activeChangeDir)) {
527
+ dirToDelete = activeChangeDir;
528
+ } else if (fs6.existsSync(archivedChangeDir)) {
529
+ dirToDelete = archivedChangeDir;
530
+ } else {
531
+ results.push({ name, success: false, error: "Change not found" });
532
+ continue;
533
+ }
534
+ fs6.rmSync(dirToDelete, { recursive: true, force: true });
535
+ results.push({ name, success: true });
536
+ } catch (err) {
537
+ results.push({ name, success: false, error: String(err) });
538
+ }
539
+ }
540
+ const allSuccess = results.every((r) => r.success);
541
+ res.status(allSuccess ? 200 : 207).json({ results });
542
+ });
489
543
  return router;
490
544
  }
491
545
 
@@ -669,8 +723,7 @@ async function getProjectName(openspecDir, fallback) {
669
723
  }
670
724
  async function validateProjectPath(projectPath) {
671
725
  try {
672
- const openspecPath = path8.join(projectPath, "openspec");
673
- const stat = await fs9.stat(openspecPath);
726
+ const stat = await fs9.stat(projectPath);
674
727
  return stat.isDirectory();
675
728
  } catch {
676
729
  return false;
@@ -702,7 +755,7 @@ function createProjectsRouter(getOpenspecDir, switchProject) {
702
755
  }
703
756
  const valid = await validateProjectPath(projectPath);
704
757
  if (!valid) {
705
- res.status(400).json({ error: "Invalid project path: no openspec/ directory found" });
758
+ res.status(400).json({ error: "Invalid project path: directory does not exist" });
706
759
  return;
707
760
  }
708
761
  try {
@@ -715,6 +768,85 @@ function createProjectsRouter(getOpenspecDir, switchProject) {
715
768
  return router;
716
769
  }
717
770
 
771
+ // src/server/routes/terminal.ts
772
+ import { Router as Router7 } from "express";
773
+ import { exec } from "child_process";
774
+ import os from "os";
775
+ function createTerminalRouter() {
776
+ const router = Router7();
777
+ function getPlatform() {
778
+ const platform = os.platform();
779
+ if (platform === "win32") return "windows";
780
+ if (platform === "darwin") return "macos";
781
+ return "linux";
782
+ }
783
+ function tryTerminalCommand(command) {
784
+ return new Promise((resolve) => {
785
+ exec(command, (error, _stdout, _stderr) => {
786
+ if (error) {
787
+ resolve(null);
788
+ } else {
789
+ resolve(command);
790
+ }
791
+ });
792
+ });
793
+ }
794
+ function buildTerminalCommand(projectPath, platform) {
795
+ const escapedPath = projectPath.replace(/"/g, '\\"');
796
+ switch (platform) {
797
+ case "windows":
798
+ return `start cmd /k "cd /d "${escapedPath}" && claude"`;
799
+ case "macos":
800
+ return `osascript -e 'tell app "Terminal"' -e 'activate' -e 'do script "cd \\"${escapedPath}\\" && claude"' -e 'end tell'`;
801
+ case "linux":
802
+ return `gnome-terminal -- bash -c 'cd "${escapedPath}" && claude; exec bash'`;
803
+ }
804
+ }
805
+ router.post("/", async (req, res) => {
806
+ const { projectPath } = req.body;
807
+ if (!projectPath || typeof projectPath !== "string") {
808
+ return res.status(400).json({
809
+ success: false,
810
+ error: "\u9879\u76EE\u8DEF\u5F84\u65E0\u6548"
811
+ });
812
+ }
813
+ const trimmedPath = projectPath.trim();
814
+ if (!trimmedPath) {
815
+ return res.status(400).json({
816
+ success: false,
817
+ error: "\u9879\u76EE\u8DEF\u5F84\u4E3A\u7A7A"
818
+ });
819
+ }
820
+ const platform = getPlatform();
821
+ const command = buildTerminalCommand(trimmedPath, platform);
822
+ return new Promise((resolve) => {
823
+ exec(command, (error, _stdout, stderr) => {
824
+ if (error) {
825
+ console.error("Failed to open terminal:", error);
826
+ console.error("stderr:", stderr);
827
+ return res.status(500).json({
828
+ success: false,
829
+ error: "\u65E0\u6CD5\u6253\u5F00\u7EC8\u7AEF",
830
+ details: process.env.NODE_ENV === "development" ? stderr : void 0
831
+ });
832
+ }
833
+ res.json({
834
+ success: true,
835
+ platform,
836
+ message: "\u7EC8\u7AEF\u5DF2\u6253\u5F00"
837
+ });
838
+ resolve();
839
+ });
840
+ });
841
+ });
842
+ router.post("/restart", (_req, res) => {
843
+ console.log("Restart requested, exiting process...");
844
+ res.json({ success: true, message: "\u9879\u76EE\u5DF2\u91CD\u542F" });
845
+ setTimeout(() => process.exit(1), 100);
846
+ });
847
+ return router;
848
+ }
849
+
718
850
  // src/server/watcher.ts
719
851
  import chokidar from "chokidar";
720
852
  import fs10 from "fs";
@@ -894,6 +1026,11 @@ var SkillExecutor = class {
894
1026
  currentStdin = null;
895
1027
  currentSkill = "";
896
1028
  currentChange = "";
1029
+ disableRealtimeProgress = process.env.DISABLE_REALTIME_PROGRESS === "true";
1030
+ // 跟踪有待处理问题且用户尚未响应
1031
+ waitingForUserResponse = false;
1032
+ // 跟踪是否有待处理问题被广播
1033
+ hasPendingQuestion = false;
897
1034
  constructor(options) {
898
1035
  this.openspecDir = options.openspecDir;
899
1036
  this.projectDir = options.projectDir;
@@ -905,6 +1042,9 @@ var SkillExecutor = class {
905
1042
  }
906
1043
  this.currentSkill = skill;
907
1044
  this.currentChange = change;
1045
+ this.waitingForUserResponse = false;
1046
+ this.hasPendingQuestion = false;
1047
+ const totalTasks = this.parseTotalTasks(skill, change);
908
1048
  const now = (/* @__PURE__ */ new Date()).toISOString();
909
1049
  const state = {
910
1050
  skill,
@@ -912,7 +1052,7 @@ var SkillExecutor = class {
912
1052
  status: "running",
913
1053
  phase: skill === "apply" ? "implementing" : "creating_artifact",
914
1054
  currentTask: 0,
915
- totalTasks: 0,
1055
+ totalTasks,
916
1056
  currentArtifact: null,
917
1057
  startedAt: now,
918
1058
  updatedAt: now
@@ -952,8 +1092,7 @@ var SkillExecutor = class {
952
1092
  for (const line of lines) {
953
1093
  if (!line.trim()) continue;
954
1094
  try {
955
- const event = JSON.parse(line);
956
- this.handleStreamEvent(event);
1095
+ this.handleStreamEvent(JSON.parse(line));
957
1096
  } catch {
958
1097
  this.appendLog(line);
959
1098
  this.broadcast({ type: "skill_log", data: { line, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
@@ -969,6 +1108,21 @@ var SkillExecutor = class {
969
1108
  this.currentProcess = null;
970
1109
  this.currentStdin = null;
971
1110
  const currentState = this.readState();
1111
+ if (this.hasPendingQuestion) {
1112
+ const completed = {
1113
+ ...currentState,
1114
+ status: "completed",
1115
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1116
+ };
1117
+ this.writeState(completed);
1118
+ this.appendLog(`\u26A0 /opsx:${skill} \u8FDB\u7A0B\u5DF2\u9000\u51FA\uFF0C\u7B49\u5F85\u7528\u6237\u54CD\u5E94`);
1119
+ this.broadcast({ type: "skill_complete", data: completed });
1120
+ this.broadcast({ type: "skill_log", data: { line: `[${this.timestamp()}] \u26A0 /opsx:${skill} \u8FDB\u7A0B\u5DF2\u9000\u51FA\uFF0C\u8BF7\u9009\u62E9\u91CD\u65B0\u6267\u884C`, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
1121
+ this.waitingForUserResponse = false;
1122
+ this.hasPendingQuestion = false;
1123
+ resolve();
1124
+ return;
1125
+ }
972
1126
  if (code === 0 && currentState.status === "running") {
973
1127
  const completed = {
974
1128
  ...currentState,
@@ -991,6 +1145,8 @@ var SkillExecutor = class {
991
1145
  this.broadcast({ type: "skill_complete", data: failed });
992
1146
  this.broadcast({ type: "skill_log", data: { line: `[${this.timestamp()}] \u2717 /opsx:${skill} failed (exit code ${code})`, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
993
1147
  }
1148
+ this.waitingForUserResponse = false;
1149
+ this.hasPendingQuestion = false;
994
1150
  resolve();
995
1151
  });
996
1152
  proc.on("error", (err) => {
@@ -1004,6 +1160,11 @@ var SkillExecutor = class {
1004
1160
  this.writeState(failed);
1005
1161
  this.appendLog(`\u2717 Failed to start: ${err.message}`);
1006
1162
  this.broadcast({ type: "skill_complete", data: failed });
1163
+ if (this.hasPendingQuestion) {
1164
+ this.broadcast({ type: "skill_question", data: null });
1165
+ }
1166
+ this.waitingForUserResponse = false;
1167
+ this.hasPendingQuestion = false;
1007
1168
  reject(err);
1008
1169
  });
1009
1170
  });
@@ -1022,6 +1183,11 @@ var SkillExecutor = class {
1022
1183
  this.writeState(cancelled);
1023
1184
  this.appendLog(`\u2717 /opsx:${this.currentSkill} cancelled by user`);
1024
1185
  this.broadcast({ type: "skill_complete", data: cancelled });
1186
+ if (this.hasPendingQuestion) {
1187
+ this.broadcast({ type: "skill_question", data: null });
1188
+ }
1189
+ this.waitingForUserResponse = false;
1190
+ this.hasPendingQuestion = false;
1025
1191
  return true;
1026
1192
  }
1027
1193
  isRunning() {
@@ -1035,6 +1201,9 @@ var SkillExecutor = class {
1035
1201
  this.currentStdin.write(text + "\n");
1036
1202
  this.appendLog(`[\u7528\u6237\u8F93\u5165] ${text}`);
1037
1203
  this.broadcast({ type: "skill_log", data: { line: `[\u7528\u6237\u8F93\u5165] ${text}`, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
1204
+ this.waitingForUserResponse = false;
1205
+ this.hasPendingQuestion = false;
1206
+ this.broadcast({ type: "skill_question", data: null });
1038
1207
  return { success: true };
1039
1208
  } catch (err) {
1040
1209
  return { success: false, error: "\u65E0\u6CD5\u53D1\u9001\u8F93\u5165" };
@@ -1054,6 +1223,43 @@ var SkillExecutor = class {
1054
1223
  this.openspecDir = openspecDir;
1055
1224
  this.projectDir = projectDir;
1056
1225
  }
1226
+ isTaskCompletionLine(line) {
1227
+ const patterns = [
1228
+ /Task \d+ completed/i,
1229
+ /✓ Task/i,
1230
+ /^\s*✓\s*\d+\./,
1231
+ /Created task/i,
1232
+ /Working on task.*complete/i,
1233
+ /^✓\s.+\(task\s*\d+\/\d+\)/i
1234
+ ];
1235
+ return patterns.some((p) => p.test(line));
1236
+ }
1237
+ parseTotalTasks(skill, change) {
1238
+ if (this.disableRealtimeProgress) return 0;
1239
+ try {
1240
+ if (skill === "apply") {
1241
+ const tasksPath = path10.join(this.openspecDir, "changes", change, "tasks.md");
1242
+ const content = fs11.readFileSync(tasksPath, "utf-8");
1243
+ const taskLines = content.split("\n").filter((line) => /^\s*-\s*\[[ x]\]\s*\d+\.?/.test(line));
1244
+ return taskLines.length;
1245
+ }
1246
+ } catch {
1247
+ }
1248
+ return 0;
1249
+ }
1250
+ updateTaskProgress() {
1251
+ if (this.disableRealtimeProgress) return;
1252
+ const state = this.readState();
1253
+ if (state.currentTask < state.totalTasks) {
1254
+ const updated = {
1255
+ ...state,
1256
+ currentTask: state.currentTask + 1,
1257
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1258
+ };
1259
+ this.writeState(updated);
1260
+ this.broadcast({ type: "skill_progress", data: updated });
1261
+ }
1262
+ }
1057
1263
  handleStreamEvent(event) {
1058
1264
  const type = event.type;
1059
1265
  if (type === "assistant") {
@@ -1066,13 +1272,40 @@ var SkillExecutor = class {
1066
1272
  for (const line of lines) {
1067
1273
  this.appendLog(line);
1068
1274
  this.broadcast({ type: "skill_log", data: { line, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
1275
+ if (this.isTaskCompletionLine(line)) {
1276
+ this.updateTaskProgress();
1277
+ }
1069
1278
  }
1070
1279
  }
1071
1280
  if (block.type === "tool_use") {
1072
- const inputStr = block.input ? JSON.stringify(block.input) : "";
1073
- const toolLine = `\u2192 ${block.name} ${inputStr}`;
1074
- this.appendLog(toolLine);
1075
- this.broadcast({ type: "skill_log", data: { line: toolLine, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
1281
+ if (block.name === "AskUserQuestion") {
1282
+ const input = block.input;
1283
+ const questions = input?.questions;
1284
+ if (Array.isArray(questions) && questions.length > 0) {
1285
+ const q = questions[0];
1286
+ const pendingQuestion = {
1287
+ question: q.question || "",
1288
+ header: q.header || "",
1289
+ options: Array.isArray(q.options) ? q.options.map((opt) => ({
1290
+ label: opt.label || "",
1291
+ description: opt.description || ""
1292
+ })) : [],
1293
+ multiSelect: q.multiSelect ?? false,
1294
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1295
+ };
1296
+ this.broadcast({ type: "skill_question", data: pendingQuestion });
1297
+ const friendlyLine = `\u23F3 \u7B49\u5F85\u9009\u62E9: ${pendingQuestion.question}`;
1298
+ this.appendLog(friendlyLine);
1299
+ this.broadcast({ type: "skill_log", data: { line: friendlyLine, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
1300
+ this.hasPendingQuestion = true;
1301
+ this.waitingForUserResponse = true;
1302
+ }
1303
+ } else {
1304
+ const inputStr = block.input ? JSON.stringify(block.input) : "";
1305
+ const toolLine = `\u2192 ${block.name} ${inputStr}`;
1306
+ this.appendLog(toolLine);
1307
+ this.broadcast({ type: "skill_log", data: { line: toolLine, timestamp: (/* @__PURE__ */ new Date()).toISOString() } });
1308
+ }
1076
1309
  }
1077
1310
  }
1078
1311
  }
@@ -1144,6 +1377,7 @@ async function createServer(options) {
1144
1377
  app.use("/api/execution", createExecutionRouter(() => openspecDir));
1145
1378
  app.use("/api/projects", createProjectsRouter(() => openspecDir, async () => {
1146
1379
  }));
1380
+ app.use("/api/terminal", createTerminalRouter());
1147
1381
  if (options.staticDir) {
1148
1382
  app.use(express.static(options.staticDir));
1149
1383
  app.get("*", (_req, res) => {
@@ -1184,6 +1418,7 @@ async function startServer(options) {
1184
1418
  broadcast({ type: "refresh" });
1185
1419
  };
1186
1420
  app.use("/api/projects", createProjectsRouter(() => currentOpenspecDir, switchProject));
1421
+ app.use("/api/terminal", createTerminalRouter());
1187
1422
  if (options.staticDir) {
1188
1423
  app.use(express.static(options.staticDir));
1189
1424
  app.get("*", (_req, res) => {