@yorz/cli 0.3.7 → 0.4.2
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/dist/cli/index.js +874 -29
- package/dist/gui/assets/{abnfDiagram-VRR7QNED-P8M0mKNL.js → abnfDiagram-VRR7QNED-C1LflvIY.js} +1 -1
- package/dist/gui/assets/{arc-DlQjdRG1.js → arc-CLVcmGRI.js} +1 -1
- package/dist/gui/assets/{architectureDiagram-ZJ3FMSHR-CWjQFrBc.js → architectureDiagram-ZJ3FMSHR-CshNMQ27.js} +1 -1
- package/dist/gui/assets/{blockDiagram-677ZJIJ3-BKVLTUWU.js → blockDiagram-677ZJIJ3-D5dDqKKl.js} +1 -1
- package/dist/gui/assets/{c4Diagram-LMCZKHZV-Dc-LyufI.js → c4Diagram-LMCZKHZV-DRVpuaAe.js} +1 -1
- package/dist/gui/assets/channel-3Bz_lxm-.js +1 -0
- package/dist/gui/assets/{chunk-2Q5K7J3B-N67wx4l2.js → chunk-2Q5K7J3B-CyfpeX3b.js} +1 -1
- package/dist/gui/assets/{chunk-32BRIVSS-DTgkNatU.js → chunk-32BRIVSS-Bpb0lkX5.js} +1 -1
- package/dist/gui/assets/{chunk-5VM5RSS4-CvwJ-FXX.js → chunk-5VM5RSS4-Cxz0034S.js} +1 -1
- package/dist/gui/assets/{chunk-EX3LRPZG-B_ZbidKZ.js → chunk-EX3LRPZG-KRbOxYPK.js} +1 -1
- package/dist/gui/assets/{chunk-JWPE2WC7-CBOi6DIW.js → chunk-JWPE2WC7-Xrz2UilG.js} +1 -1
- package/dist/gui/assets/{chunk-MOJQB5TN-CW-x6_OV.js → chunk-MOJQB5TN-BNaU7zJh.js} +1 -1
- package/dist/gui/assets/{chunk-RYQCIY6F-BmdlZ9KS.js → chunk-RYQCIY6F-DWByW4ue.js} +1 -1
- package/dist/gui/assets/{chunk-V7JOEXUC-B4f5PF42.js → chunk-V7JOEXUC-wqPxL9c_.js} +1 -1
- package/dist/gui/assets/{chunk-VR4S4FIN-DKcqnznN.js → chunk-VR4S4FIN-CpNkwfNF.js} +1 -1
- package/dist/gui/assets/{chunk-XXDRQBXY--4FGYA_u.js → chunk-XXDRQBXY-DfzP0kY8.js} +1 -1
- package/dist/gui/assets/classDiagram-OUVF2IWQ-DX15YBOn.js +1 -0
- package/dist/gui/assets/classDiagram-v2-EOCWNBFH-DX15YBOn.js +1 -0
- package/dist/gui/assets/{cose-bilkent-JH36ORCC-B2f6Upao.js → cose-bilkent-JH36ORCC-DRxVn8EA.js} +1 -1
- package/dist/gui/assets/{cynefin-VYW2F7L2-CxS20C5H.js → cynefin-VYW2F7L2-Dgo2oRfE.js} +1 -1
- package/dist/gui/assets/{cynefinDiagram-TSTJHNR4-hyW_UYqU.js → cynefinDiagram-TSTJHNR4-DkOfGZKA.js} +1 -1
- package/dist/gui/assets/{dagre-VKFMJZFB-BW-oRpvd.js → dagre-VKFMJZFB-B9lbU1Y5.js} +1 -1
- package/dist/gui/assets/{diagram-FQU43EPY-B-2gzyIy.js → diagram-FQU43EPY-L4aTZZob.js} +1 -1
- package/dist/gui/assets/{diagram-G47NLZAW-DF0y134D.js → diagram-G47NLZAW-C7EjvTdr.js} +1 -1
- package/dist/gui/assets/{diagram-NH7WQ7WH-CejbGVF2.js → diagram-NH7WQ7WH-B3oafVE9.js} +1 -1
- package/dist/gui/assets/{diagram-OA4YK3LP-D5e5A6IV.js → diagram-OA4YK3LP-B5Xr4fVb.js} +1 -1
- package/dist/gui/assets/{diagram-WEI45ONY-CAISCqpz.js → diagram-WEI45ONY-CizYf4-d.js} +1 -1
- package/dist/gui/assets/{ebnfDiagram-CCIWWBDH-BYSESG5t.js → ebnfDiagram-CCIWWBDH-Cdp4Ck2s.js} +1 -1
- package/dist/gui/assets/{erDiagram-Q63AITRT-DpW8WS2S.js → erDiagram-Q63AITRT-D_HQYmPu.js} +1 -1
- package/dist/gui/assets/{flowDiagram-23GEKE2U-C_v7g6xp.js → flowDiagram-23GEKE2U-DOp2pyM-.js} +1 -1
- package/dist/gui/assets/{ganttDiagram-NO4QXBWP-CeaE4SQt.js → ganttDiagram-NO4QXBWP-jeuE6-Kq.js} +1 -1
- package/dist/gui/assets/{gitGraphDiagram-IHSO6WYX-DHiPQjRQ.js → gitGraphDiagram-IHSO6WYX-BmHbinZO.js} +1 -1
- package/dist/gui/assets/index-Ck5Q41lR.js +36 -0
- package/dist/gui/assets/index-DO4me3fw.css +1 -0
- package/dist/gui/assets/{infoDiagram-FWYZ7A6U-tbR2h7uU.js → infoDiagram-FWYZ7A6U-C1JAC0zw.js} +1 -1
- package/dist/gui/assets/{ishikawaDiagram-FXEZZL3T-TFTW7kkJ.js → ishikawaDiagram-FXEZZL3T-C7gxtQ09.js} +1 -1
- package/dist/gui/assets/{journeyDiagram-5HDEW3XC-DOV8E7Kx.js → journeyDiagram-5HDEW3XC-Bm09XlrS.js} +1 -1
- package/dist/gui/assets/{kanban-definition-HUTT4EX6-BmQnZixr.js → kanban-definition-HUTT4EX6-w-2VnSoA.js} +1 -1
- package/dist/gui/assets/{linear-BM4IZgpi.js → linear-DuGFha_A.js} +1 -1
- package/dist/gui/assets/{mermaid.core-LupX_4le.js → mermaid.core-DDfmGYIx.js} +4 -4
- package/dist/gui/assets/{mindmap-definition-LN4V7U3C-Ci25EAsL.js → mindmap-definition-LN4V7U3C-DSWPnA6W.js} +1 -1
- package/dist/gui/assets/{pegDiagram-2B236MQR-De8kn5Bk.js → pegDiagram-2B236MQR-Css8B66d.js} +1 -1
- package/dist/gui/assets/{pieDiagram-ENE6RG2P-Q1uRLnK9.js → pieDiagram-ENE6RG2P-CSyx5YUR.js} +1 -1
- package/dist/gui/assets/{quadrantDiagram-ABIIQ3AL-Dox-lxWY.js → quadrantDiagram-ABIIQ3AL-Dd0BHS30.js} +1 -1
- package/dist/gui/assets/{railroadDiagram-RFXS5EU6-5PHbvzzM.js → railroadDiagram-RFXS5EU6-6tQYC8Kx.js} +1 -1
- package/dist/gui/assets/{requirementDiagram-TGXJPOKE-BOlMALKo.js → requirementDiagram-TGXJPOKE-CIgyhQt1.js} +1 -1
- package/dist/gui/assets/{sankeyDiagram-HTMAVEWB-Dhl4t_ZC.js → sankeyDiagram-HTMAVEWB-CV8IW8dn.js} +1 -1
- package/dist/gui/assets/{sequenceDiagram-DBY2YBRQ-DWCe9xy7.js → sequenceDiagram-DBY2YBRQ-XrPzK96U.js} +1 -1
- package/dist/gui/assets/{sizeCapture-X5ZJPWSS-BUM02Me6.js → sizeCapture-X5ZJPWSS-B0RuqhJy.js} +1 -1
- package/dist/gui/assets/{stateDiagram-2N3HPSRC-ryQbw4Q_.js → stateDiagram-2N3HPSRC-BbX5u2al.js} +1 -1
- package/dist/gui/assets/stateDiagram-v2-6OUMAXLB-DPSUOLxs.js +1 -0
- package/dist/gui/assets/{swimlanes-5IMT3BWC-CrP6qLyo.js → swimlanes-5IMT3BWC-eN6NeRxH.js} +2 -2
- package/dist/gui/assets/swimlanesDiagram-G3AALYLV-BJJrQBKd.js +8 -0
- package/dist/gui/assets/{timeline-definition-FHXFAJF6-CXm-_myR.js → timeline-definition-FHXFAJF6-Cwx4rfjD.js} +1 -1
- package/dist/gui/assets/{vennDiagram-L72KCM5P-C2ONmkxz.js → vennDiagram-L72KCM5P-BfVuNGYO.js} +1 -1
- package/dist/gui/assets/{wardleyDiagram-EHGQE667-DG3WuRlC.js → wardleyDiagram-EHGQE667-tTfKUfA2.js} +1 -1
- package/dist/gui/assets/{xychartDiagram-FW5EYKEG-CChHjXHR.js → xychartDiagram-FW5EYKEG-BMDTawgV.js} +1 -1
- package/dist/gui/index.html +2 -2
- package/package.json +2 -3
- package/dist/gui/assets/channel-Donye8KV.js +0 -1
- package/dist/gui/assets/classDiagram-OUVF2IWQ-8onVfRQK.js +0 -1
- package/dist/gui/assets/classDiagram-v2-EOCWNBFH-8onVfRQK.js +0 -1
- package/dist/gui/assets/index-BESUyU3O.js +0 -673
- package/dist/gui/assets/index-CTkTlp_v.css +0 -1
- package/dist/gui/assets/stateDiagram-v2-6OUMAXLB-CkZ3MMxP.js +0 -1
- package/dist/gui/assets/swimlanesDiagram-G3AALYLV-CioGa7m6.js +0 -8
package/dist/cli/index.js
CHANGED
|
@@ -3,9 +3,9 @@ import { homedir, platform, networkInterfaces } from "node:os";
|
|
|
3
3
|
import { Command, Option } from "commander";
|
|
4
4
|
import { stat, readFile, writeFile, rm, mkdir, access, rename, readdir, appendFile, copyFile } from "node:fs/promises";
|
|
5
5
|
import { join, dirname, extname, resolve, sep, isAbsolute, basename as basename$1, relative, normalize } from "node:path";
|
|
6
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { createHash, randomUUID, randomBytes } from "node:crypto";
|
|
7
7
|
import { spawn, execFile } from "node:child_process";
|
|
8
|
-
import { existsSync, statSync, mkdirSync, watch, createReadStream
|
|
8
|
+
import { existsSync, openSync, closeSync, statSync, readSync, mkdirSync, watch, createReadStream } from "node:fs";
|
|
9
9
|
import { serve } from "@hono/node-server";
|
|
10
10
|
import { Hono } from "hono";
|
|
11
11
|
import { promisify } from "node:util";
|
|
@@ -21,7 +21,7 @@ import { Codex } from "@openai/codex-sdk";
|
|
|
21
21
|
import { createOpencode } from "@opencode-ai/sdk";
|
|
22
22
|
import { createInterface as createInterface$1 } from "node:readline/promises";
|
|
23
23
|
import MarkdownIt from "markdown-it";
|
|
24
|
-
const version = "0.
|
|
24
|
+
const version = "0.4.2";
|
|
25
25
|
const pkg = {
|
|
26
26
|
version
|
|
27
27
|
};
|
|
@@ -55,7 +55,7 @@ function getAdapter(name) {
|
|
|
55
55
|
if (name in ADAPTERS) return ADAPTERS[name];
|
|
56
56
|
throw new Error(`Unknown agent: ${name}. Supported: ${Object.keys(ADAPTERS).join(", ")}`);
|
|
57
57
|
}
|
|
58
|
-
const __vite_glob_0_0 = '---\nname: yorz-debug\ndescription: 以资深工程师的调试纪律定位并修复疑难 bug——假设→取证→验证的证据闭环,靠 debug.md 活文档与 git 快照兜住污染。\n---\n\n# YorZ Debug 深度调试 Skill\n\n指导 Agent 以**资深工程师的调试纪律**逐步逼近疑难 bug 的根因:不凭猜测改代码,坚持「假设 → 取证 → 验证 → 缩小范围 → 再假设」的证据闭环。调试过程赋予更大的自由度(临时改代码、Mock 数据、加日志、建临时验证入口),同时用 `debug.md` 活文档 + `git stash create` 快照兜住"污染性改动不进入最终提交"。\n\n> 本 skill **不复用** yorz-spec 的 plan/tasks/execute 状态机。真相以 `debug.md` 为单一载体;Agent 持续推进直至需要用户回传证据(人在环路),或根因确认并收尾。\n\n## 何时进入本 skill\n\n- **独立触发(无需 spec / UI)**:用户在 Agent 对话中直接点名「用 yorz-debug 调这个 bug」。此时可能没有任何 spec 上下文——按下方「无 spec 场景」处理 `debug.md` 落点。\n- **追加任务勾选 Debug**:用户在 SpecDetail 追加 `fix` 任务时勾选「Debug 模式」,后端 prompt 指向本 skill。\n- **重入**:spec 目录已存在 `status: debugging` 的 `debug.md`(有未收尾记录块),run/append 路由自动切到本 skill。此时**不新建记录块**,定位活跃记录块(frontmatter `active: NNN`)续跑。\n\n## 输入约定\n\n- `spec_dir`(**可选**):spec 目录路径(含 `spec.md`)。给出时 `debug.md` 建在此目录,与 `spec.md` 同级。\n- 待调试 bug 的描述:来自追加任务行 / 用户 prompt。\n\n### debug.md 落点\n\n- **有 spec 上下文**:`debug.md` 落 `spec_dir`(与 `spec.md` 同级)。\n- **无 spec 场景(独立触发)**:没有 `spec_dir` 时,`debug.md` 落**当前工作目录**;若用户在 prompt 中显式指定了路径,则以用户指定为准。其余多记录模型、快照、脚手架、收尾流程与有 spec 时**完全一致**。\n\n## debug.md:多记录活文档\n\n**一个 `debug.md` 承载同一 spec 生命周期内的多次调试**(某次收尾后又发现新 bug,就追加下一条记录,不覆盖历史)。它同时是:调试活文档、**Debug 模式的持久化标记**(存在未收尾记录 = 处于 Debug 模式)、脚手架是否清理干净的守卫。\n\n### 文件级 frontmatter\n\n```yaml\n---\nstatus: debugging # debugging(存在未收尾记录)| resolved(全部记录已收尾)\nactive: 2 # 当前活跃记录编号;resolved 时留空\nupdated_at: \'2026-07-19 15:54:37\' # 本地秒级字符串\n---\n```\n\n### 正文按「记录块」组织\n\n每次进入 Debug **在文末追加**一个记录块,编号 `NNN` 从 1 递增、**不复用**、不覆盖既有块:\n\n```markdown\n## Debug 1 · <bug 一句话简述>\n\n- 状态:debugging # debugging | resolved\n- 快照:<git stash create 得到的 SHA>\n- 进入时间:\'2026-07-19 15:54:37\'\n\n### 1. Bug 现象与复现\n\n...\n\n### 2
|
|
58
|
+
const __vite_glob_0_0 = '---\nname: yorz-debug\ndescription: 以资深工程师的调试纪律定位并修复疑难 bug——假设→取证→验证的证据闭环,靠 debug.md 活文档与 git 快照兜住污染。\n---\n\n# YorZ Debug 深度调试 Skill\n\n指导 Agent 以**资深工程师的调试纪律**逐步逼近疑难 bug 的根因:不凭猜测改代码,坚持「假设 → 取证 → 验证 → 缩小范围 → 再假设」的证据闭环。调试过程赋予更大的自由度(临时改代码、Mock 数据、加日志、建临时验证入口),同时用 `debug.md` 活文档 + `git stash create` 快照兜住"污染性改动不进入最终提交"。\n\n> 本 skill **不复用** yorz-spec 的 plan/tasks/execute 状态机。真相以 `debug.md` 为单一载体;Agent 持续推进直至需要用户回传证据(人在环路),或根因确认并收尾。\n\n## 何时进入本 skill\n\n- **独立触发(无需 spec / UI)**:用户在 Agent 对话中直接点名「用 yorz-debug 调这个 bug」。此时可能没有任何 spec 上下文——按下方「无 spec 场景」处理 `debug.md` 落点。\n- **追加任务勾选 Debug**:用户在 SpecDetail 追加 `fix` 任务时勾选「Debug 模式」,后端 prompt 指向本 skill。\n- **重入**:spec 目录已存在 `status: debugging` 的 `debug.md`(有未收尾记录块),run/append 路由自动切到本 skill。此时**不新建记录块**,定位活跃记录块(frontmatter `active: NNN`)续跑。\n\n## 输入约定\n\n- `spec_dir`(**可选**):spec 目录路径(含 `spec.md`)。给出时 `debug.md` 建在此目录,与 `spec.md` 同级。\n- 待调试 bug 的描述:来自追加任务行 / 用户 prompt。\n- 运行服务上下文(**可选**):由 YorZ Service 注入的当前项目运行中命令服务列表,包含 `runId`、`name`、`cli`、`pid`、`startedAt`、`logFile` 等字段。\n\n### debug.md 落点\n\n- **有 spec 上下文**:`debug.md` 落 `spec_dir`(与 `spec.md` 同级)。\n- **无 spec 场景(独立触发)**:没有 `spec_dir` 时,`debug.md` 落**当前工作目录**;若用户在 prompt 中显式指定了路径,则以用户指定为准。其余多记录模型、快照、脚手架、收尾流程与有 spec 时**完全一致**。\n\n## debug.md:多记录活文档\n\n**一个 `debug.md` 承载同一 spec 生命周期内的多次调试**(某次收尾后又发现新 bug,就追加下一条记录,不覆盖历史)。它同时是:调试活文档、**Debug 模式的持久化标记**(存在未收尾记录 = 处于 Debug 模式)、脚手架是否清理干净的守卫。\n\n### 文件级 frontmatter\n\n```yaml\n---\nstatus: debugging # debugging(存在未收尾记录)| resolved(全部记录已收尾)\nactive: 2 # 当前活跃记录编号;resolved 时留空\nupdated_at: \'2026-07-19 15:54:37\' # 本地秒级字符串\n---\n```\n\n### 正文按「记录块」组织\n\n每次进入 Debug **在文末追加**一个记录块,编号 `NNN` 从 1 递增、**不复用**、不覆盖既有块:\n\n```markdown\n## Debug 1 · <bug 一句话简述>\n\n- 状态:debugging # debugging | resolved\n- 快照:<git stash create 得到的 SHA>\n- 进入时间:\'2026-07-19 15:54:37\'\n\n### 1.1 Bug 现象与复现\n\n...\n\n### 1.2 关联链路分析\n\n...\n\n### 1.3 Debug 基线\n\n快照 SHA + 进入时间;`git diff <SHA>` 为退出闸门基准。\n\n### 1.4 假设看板\n\n...\n\n### 1.5 证据\n\n...\n\n### 1.6 脚手架清单\n\n...\n\n### 1.7 收尾核对\n\n...\n```\n\n### 生命周期\n\n- **创建 / 进入**:`debug.md` 不存在则创建(写文件 frontmatter + 第 1 个记录块);已存在则追加 `## Debug <max+1>`,置文件 `status: debugging`、`active: <新编号>`。进入后**立刻**打快照(见「污染防线」)。\n- **单块收尾**:把该记录块块头 `状态` 置 `resolved`,脚手架逐条核销,`git diff <SNAP>` 校验只剩合法修复。\n- **文件 status 收敛**:单块收尾后,若**所有**记录块均 `resolved`,置文件 `status: resolved`、清空 `active`;否则保持 `debugging` 且 `active` 指向仍在调试的块。\n- **归档**:收尾**保留** `debug.md` 供复盘,绝不删除。\n\n## 核心调试循环\n\n**分析 → 规划 → 假设 → 实施 → 验证 → 缩小范围 → 再假设**,直至根因被硬证据锁定。\n\n```mermaid\nflowchart TD\n Start["接手 bug"] --> Repro{"能稳定复现?"}\n Repro -->|否| GetRepro["补齐复现步骤<br/>(问用户 / 自行构造)"]\n GetRepro --> Repro\n Repro -->|是| Analyze["分析关联链路<br/>写入 debug.md"]\n Analyze --> Hypo["提出可证伪假设<br/>成立看到 X / 不成立看到 Y"]\n Hypo --> Probe["写验证性代码 / 加日志<br/>登记脚手架清单"]\n Probe --> Eval{"证据指向?"}\n Eval -->|证伪| Exclude["记入已排除假设 + 依据"]\n Exclude --> Narrow["缩小范围(二分/控制变量)"]\n Narrow --> Hypo\n Eval -->|坐实根因| Fix["写最终修复"]\n Fix --> Verify{"重跑复现步骤<br/>问题消失?"}\n Verify -->|否| Hypo\n Verify -->|是| Close["进入收尾"]\n```\n\n### 方法层\n\n- **二分法**:在可疑区间(提交历史 / 代码路径 / 数据范围)二分定位,每次砍掉一半。\n- **控制变量法**:一次只改一个变量,隔离单一因素的影响。\n- **遍历可能分支**:对有限的分支/枚举/状态逐一验证,排除法收敛。\n\n## 硬约束(资深工程师容易做、Agent 容易漏)\n\n1. **先复现,再调试**:无法稳定复现,**不得进入修改阶段**——否则无从验证修没修好。复现不了就先构造复现路径或请用户提供。\n2. **假设必须可证伪**:提出假设时,同时写出"若成立会看到 X,若不成立会看到 Y",再去取证。写进「假设看板」。\n3. **记录已排除假设 + 依据**:每否定一个假设,连同证据写入看板,**避免兜圈子反复验证同一已否定项**。\n4. **禁止无证据修复**:拿到指向根因的**硬证据**(日志 / 截图 / 可复现的最小用例)前,**只许写验证性代码,不许写最终修复**。\n5. **人机协作**:优先主动复现 Bug、获取日志进行分析判断,如果无途径复现 Bug、获取日志,也可暂停执行,让用户配合复现 Bug、回传日志。\n6. **退出双条件**:根因有硬证据 **且** 修复后重跑复现步骤、证据显示问题消失——**缺一不可**。\n\n### 日志纪律\n\n- **可读可复制**:打印结构化字符串而非整个 object(如 拼接关键字段 k1=v1&k2=v2 或 `JSON.stringify(x)`),避免直接打印对象或复杂结构变量的引用。\n- **避免刷屏**:不要在循环/高频路径里打印大量日志;聚焦怀疑环节,必要时带唯一标记便于检索。\n- **可定位**:日志带上下文(哪个函数 / 哪个分支 / 关键变量值),便于判定假设成立与否。\n\n## 更大的自由度(Debug 模式内允许)\n\n- 编写临时代码(条件短路)或注释有意义代码,控制程序进入 / 屏蔽特定分支;\n- 临时篡改变量值、Mock API 返回,让程序走到特定分支;\n- 编写临时独立测试页面 / 入口,简化复现路径、直接验证怀疑环节;\n- 联网查询第三方 SDK 文档、向用户询问必要信息;\n- 允许 lint / typecheck / build / 单测出现错误,允许**临时**跳过这些检查。\n\n> 每一处临时改动都**必须**在「脚手架清单」登记一行(文件 + 位置 + 类型:短路 / Mock / 注释 / 临时页面 / 临时日志),收尾逐条核销。**未登记 = 会漏还原**。\n\n## 污染防线(强制流程)\n\n在**当前项目与分支就地调试**,不开 worktree;靠快照 + 脚手架清单 + diff 兜住污染。\n\n```mermaid\nflowchart TD\n Enter["进入 Debug(新记录块)"] --> Snap["SNAP = git stash create<br/>零副作用快照当前脏工作区"]\n Snap --> Record["把 SNAP 写入记录块 · Debug 基线"]\n Record --> Loop["调试循环<br/>照常改代码 + 登记脚手架清单"]\n Loop --> Fixed{"根因确认 + 硬证据?"}\n Fixed -->|否| Loop\n Fixed -->|是| Revert["按脚手架清单逐条还原"]\n Revert --> Diff["git diff SNAP"]\n Diff --> Gate{"仅剩合法修复?"}\n Gate -->|否| Revert\n Gate -->|是| Done["置本块 status: resolved<br/>跑变更文件单测/build/typecheck"]\n```\n\n- **进入即打快照**:`git stash create`(**不是** `git stash` / 临时 commit)生成一个记录当前脏工作区的 commit 对象,但**不动工作区 / index / HEAD**。它天然区分"进入前的既有未提交改动"与"调试引入的改动",避免临时 commit 的还原风险。把返回的 SHA 写入记录块「Debug 基线」。\n - 若 `git stash create` 无输出(工作区干净),以当前 `HEAD`(`git rev-parse HEAD`)作为快照基准并注明。\n- **git diff 是退出闸门**:宣告完成前,`git diff <SNAP>` 必须**只剩合法修复**——所有短路 / Mock / 注释 / 临时页面 / 临时日志都已还原。diff 里还有脚手架残留 = 不许收尾。\n\n## 收尾(退出 Debug 模式)\n\n根因确认且修复通过复现验证后:\n\n1. **还原脚手架**:按「脚手架清单」逐条还原临时代码与被注释代码,确保原有逻辑正确;清理不需要的 debug 日志。\n2. **退出闸门**:`git diff <SNAP>` 只剩合法修复(脚手架清单全部核销)。\n3. **常规完整性检查**:跑变更文件关联单测、构建、类型检查。\n4. **落状态**:把该记录块块头 `状态` 置 `resolved`;按「文件 status 收敛」更新文件 frontmatter(全部 resolved → 文件 `status: resolved`、清空 `active`)。\n5. **保留归档**:`debug.md` 保留,供复盘。\n\n> 收尾后如又发现**新** bug,回到本 skill 追加下一条 `## Debug NNN` 记录块(不覆盖历史),重新走一遍循环。\n\n## 写回纪律\n\n- 每次写回 `debug.md` 都更新文件 frontmatter 的 `updated_at`。\n- `updated_at` 用本地秒级 `YYYY-MM-DD HH:mm:ss` 字符串。\n- 记录块编号连续递增、不复用、不重排既有块。\n';
|
|
59
59
|
const __vite_glob_0_1 = '{\n "$schema": "https://json-schema.org/draft-07/schema#",\n "version": 2,\n "modules": [\n {\n "module": "SKILL",\n "file": "SKILL.md",\n "relatedCases": []\n }\n ]\n}\n';
|
|
60
60
|
const __vite_glob_0_2 = '---\nname: yorz-spec\ndescription: Drive YorZ spec docs through plan / tasks / execute stages with deterministic state updates.\n---\n\n# YorZ Spec Drive Skill\n\n将单个 YorZ spec 文档作为状态机执行,围绕 `plan` / `tasks` / `execute` 三阶段推进,全部任务完成后进入终止态 `done`,把状态持续写回文档。md 是单一真相;Agent 持续推进直至阻塞(待确认项、决策、Review)或收尾为 `done` 才退出。\n\n## 如何使用本 skill\n\n本 skill 文档结构精简为 3 个文件;每次接到 spec 任务时,**按以下顺序**按需 Read:\n\n1. **本文档(SKILL.md)**:输入约定、frontmatter 规范、格式约定、自动模式判定、全局硬约束、lint 硬约束、持续推进约束。\n2. **[stages.md](./stages.md)**:plan / tasks / execute / new-spec 四阶段流程与正面示例。\n3. **[review.md](./review.md)**:Review / Git Ops 阶段(独立路径,低频使用)。\n\n[mermaid.md](./mermaid.md) 与 `references/` 目录**仅按需 Read**:只有在判断当前阶段需要输出 mermaid 图表「升维」时才加载,纯状态推进任务不必读取,以节省 context。**plan 阶段的「图形化补充」收尾子步骤**(见 stages.md)会针对 `现状分析`/`技术方案` 两节强制加载 mermaid.md 补图;补图须遵循其核心原则**「图优先、精确信息折叠」**——表层给图,精确细节折叠进 `<details>` 精确层。\n\n> 当 Agent 以 `mode=review` / `mode=git-ops` 启动时,按 [review.md](./review.md) 执行;该路径不进入 plan/tasks/execute 状态机,也不修改 spec.md 的 frontmatter。\n\n## 输入约定\n\n- `spec_path`:**可选**,目标 spec 文档路径。\n - 显式给出时:典型位置 `docs/specs/*.md` 或 `.yorz/specs/<id>/spec.md`,直接更新该 spec。\n - 缺省时按以下顺序解析:\n 1. **从 session 上下文恢复**:扫描当前会话历史中已出现/已读写过的 spec 文档路径;存在多个时优先选取最近一次被读写的;若仍有歧义,向用户确认。\n 2. **从当前 prompt 创建**:若上下文中没有任何 spec 文档,按 [新建 spec 流程](./stages.md#new-spec) 生成路径与骨架。\n- spec 目录默认为 `.yorz/specs`,可由 `<ProjectRoot>/.yorz/config.json` 的 `specsDir` 字段覆盖。\n- `mode`:可选,`plan|tasks|execute|auto`,默认 `auto`。\n- 批注前缀:`!!!`\n\n## frontmatter 规范\n\n每个 spec md 顶部必须有 YAML frontmatter:\n\n```yaml\n---\nstage: plan # plan | tasks | execute | done\nlast_action: 简述上一次动作\nupdated_at: \'2026-06-14 15:42:07\' # 本地秒级字符串\nsummary: 一句话概要,≤ 200 字符\n---\n```\n\n- 字段顺序固定为 `stage` → `last_action` → `updated_at` → `summary`,每个字段独占一行,禁止嵌套与额外字段。\n- `updated_at` 使用本地秒级 `YYYY-MM-DD HH:mm:ss` 字符串。\n- 缺失任意字段时,先补齐再继续。\n\n## Markdown 格式化约定\n\n- 每次写回 spec md 后,**应在仓库支持的条件下运行 Markdown formatter**(优先 `npx prettier --write <spec_path>`);未配置则跳过。\n- formatter 必须保留 YAML frontmatter 不变。\n- 任务清单仅使用单层 `- [ ]` / `- [x]`,避免缩进与嵌套。\n- 二、三级标题必须带层级编号,便于外部按章节号定位:\n - 每次写回前按 body 中 `## ` / `### ` 出现顺序重新编号。\n - 二级标题 `## N. 标题`(N 从 1 起);三级标题 `### N.M 标题`(M 在所属二级下从 1 起)。\n - 该规则不影响 frontmatter 与一级标题(`# `)。\n\n## 自动模式判定顺序(严格按顺序)\n\n`mode=auto` 时,**严格按顺序**判定,命中即停:\n\n1. 若 `spec_path` 缺省:从 session 上下文恢复 spec 路径;恢复失败进入 [新建 spec 流程](./stages.md#new-spec) 并以 `plan` 起步。\n2. **优先扫描 `## 追加任务` 中是否存在 `[open]` 条目**:存在即视为"新输入"信号,进入 `plan`(重开流程)。\n3. 若识别到新增/扩展需求或新增 bug,进入 `plan`(重开流程)。\n4. 若文档存在任意 `!!!` 批注,进入 [tasks](./stages.md#tasks)。\n5. 若 `## 待确认项` 下存在有效条目(非空态 `_暂无_`),停止推进并等待人工批注。\n6. 若 `stage` 已是 `done` 且上述 1–5 均未命中(无新输入/批注/待确认项),直接停止:终止态不再自动推进。\n7. 若存在未完成的**非 `[manual]`** 任务(`- [ ]` 且非 `- [ ] [manual]`),进入 [execute](./stages.md#execute)。\n8. 若任务清单已就绪且不存在未完成的非 manual 任务(走到此处已排除 `[open]`/新需求/批注/待确认项):将 `stage` 置为 `done` 并停止推进(终止态)。\n9. 若 `## 技术实现方案` 为空或明显不完整,进入 [plan](./stages.md#plan)。\n10. 进入 [tasks](./stages.md#tasks) 生成或刷新详细任务清单。\n\n> 章节名 `## 待确认项` 为新名,parser/lint 兼容旧名 `## 待确认问题`;存量 spec 保留旧名仍可识别,新建/重写一律用 `待确认项`。\n>\n> 待确认项判定:章节内存在任一 `### ` 三级标题即视为未决;仅有 `_暂无_` 或整章为空即视为无未决条目;存在未决条目时禁止进入 execute。\n>\n> `done` 判定忽略 `- [ ] [manual]` 人工确认项:即使 manual 项仍未勾选,只要其余任务全部完成、且无待确认项/批注/`[open]`,即可收尾为 `done`。\n\n## 全局硬约束\n\n### 通用\n\n- 仅更新目标 spec 文档,不修改无关文档。\n- 每次写回文档都必须更新 frontmatter 元信息。\n- 执行任务时若涉及代码改动,只改动与任务相关的最小范围。\n\n### 章节建议\n\n初始化 spec 时**建议**创建以下章节(`## 追加任务` `## 用户批注` 为可选,由用户触发追加时懒插入):\n\n- `## 现状分析` / `## 技术实现方案` / `## 待确认项` / `## 任务清单` / `## 执行记录`\n\n> lint 不再强制校验章节齐全;但 routing 按章节名查找时,章节不存在视为空态。\n\n### 变更重开流程\n\n**触发条件:**\n\n- 在已有 spec 中识别到"新增/扩展需求"或"新增 bug",将 `stage` 切回 `plan`,`last_action` 记录"变更重开流程"。\n- `## 追加任务` 存在 `- [open] [feat|refct|fix] ...` 条目时,将 `stage` 切回 `plan`。\n\n**重开后必须做的事:**\n\n- 仅针对新增内容重新走完 `plan → tasks → execute`。\n- 未受影响的既有结论/任务可保留,受影响任务必须重新评估。\n- plan 阶段消费追加任务条目时,**不修改** `[open]` 状态标记。\n\n## 输出优先级\n\n1. 先保证状态正确(frontmatter 元信息更新)。\n2. 再保证结构正确(列表格式、任务格式、候选项)。\n3. 最后保证内容质量(可执行、可验收、可追踪)。\n\n## 写回后的 lint 硬约束\n\n任何阶段完成对 `spec.md` / `review.md` 的写入后,Agent **必须**通过 Bash 运行 `yorz lint <path> --format json`,并 parse stdout。\n\n- `errorCount === 0` 时视为通过。\n- 存在 `severity: error` 时按 `ruleId` + `message` + `line` 定位并修改,然后重新运行 lint。\n- 同一文件 lint 连续失败达到 **3 次**,将偏差作为 `## 待确认项` 条目写入 spec.md 后退出,等待人工干预。\n- `warn` 级 finding 不阻断推进,但应尽力消除。\n\n## 持续推进硬约束\n\nAgent 在 plan / tasks / execute 任意阶段,**只允许**因以下"合法阻塞"退出当轮:\n\n1. `## 待确认项` 章节存在非 `暂无` 条目;\n2. spec md 内存在任何 `!!!` 批注;\n3. 识别到新增/扩展需求或新增 bug,按变更重开流程切回 `plan`;\n4. 执行外部命令 / 工具调用失败,需要用户做不可推断的决断。\n\n**显式禁止**以下"元确认"行为:\n\n- 以"任务很多 / 改动较大 / 范围广 / 时间长 / 风险高"为由询问"是否继续 / 是否执行 / 是否同意此方案"。\n- 在 execute 阶段中段汇报进度并要求用户确认下一步。\n- 反问类如"要不要我开始 / 是否继续下一项"等问句。\n\n替代约定:tasks/execute 阶段若产生新疑问,**必须**作为新条目写回 `## 待确认项` 并按变更重开流程退出。\n\n兜底:对外部世界有副作用、不可逆的命令(如 `git push`、`git reset --hard`、`rm -rf`、修改 CI 配置等破坏性操作)仍按系统默认安全准则处理。\n\n## 与 YorZ 工作流的关联\n\n- 本 skill 不依赖 YorZ Service / GUI 也能独立工作:Agent 直接读写 `spec_path` 即可。\n- 当 YorZ Service 在线时,Agent 写回 md 会被 Service 的 FS Watcher 感知并推送给 GUI。\n- 如需用户介入,Agent 把信息写回文档对应章节后立即退出,由用户人工补齐 `!!!` 批注,再由 CLI / Service 重新拉起 Agent 继续执行。\n';
|
|
61
61
|
const __vite_glob_0_3 = "---\nstage: execute\nlast_action: 主任务完成,追加 1 项 [open] 待处理\nupdated_at: 2026-06-20\nsummary: HELLO.txt 已经创建;追加任务:在 HELLO.txt 末尾追加一行 `goodbye world\\n`。\n---\n\n# HELLO 占位\n\n## 1. 背景\n\n希望仓库根有占位文件,便于演示。\n\n## 2. 需求\n\n- 在仓库根创建 `HELLO.txt`,内容 `hello world\\n`\n\n## 3. 现状分析\n\n`HELLO.txt` 已经存在,内容 `hello world\\n`。\n\n## 4. 技术实现方案\n\n- 直接写入 `HELLO.txt`,内容 `hello world\\n`。\n\n## 5. 待确认问题\n\n- 暂无\n\n## 6. 任务清单\n\n- [x] 在仓库根新建 `HELLO.txt`,内容 `hello world\\n`,验收点:文件存在且字节数 = 12。\n\n## 7. 追加任务\n\n- [open] 在 `HELLO.txt` 末尾追加一行 `goodbye world\\n`,验收点:最终文件含 2 行、共 26 字节。\n\n## 8. 执行记录\n\n- 2026-06-20 创建 `HELLO.txt`,内容 `hello world\\n`。\n";
|
|
@@ -747,7 +747,12 @@ function createSpecsRoutes(resolveProject) {
|
|
|
747
747
|
if (parsed.autoRun) {
|
|
748
748
|
const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
|
|
749
749
|
const debugActive = await readDebugMdStatus(join(p.specsDir, specId)) === "debugging";
|
|
750
|
-
const prompt = parsed.debug ? buildDebugPrompt(p.specsDirRelative, specId, "new") : debugActive ? buildDebugPrompt(
|
|
750
|
+
const prompt = parsed.debug ? buildDebugPrompt(p.specsDirRelative, specId, "new", await buildDebugRuntimeContext(p)) : debugActive ? buildDebugPrompt(
|
|
751
|
+
p.specsDirRelative,
|
|
752
|
+
specId,
|
|
753
|
+
"resume",
|
|
754
|
+
await buildDebugRuntimeContext(p)
|
|
755
|
+
) : `请使用 yorz-spec skill 处理 spec:${p.specsDirRelative}/${specId}/spec.md`;
|
|
751
756
|
const handle = await p.sessions.send(sessionId, prompt);
|
|
752
757
|
return c.json({ ok: true, runId: handle.runId, sessionId });
|
|
753
758
|
}
|
|
@@ -761,7 +766,7 @@ function createSpecsRoutes(resolveProject) {
|
|
|
761
766
|
if (!detail) return c.json({ error: "spec not found" }, 404);
|
|
762
767
|
const { sessionId } = await p.sessions.ensureSessionForSpec(specId);
|
|
763
768
|
const debugActive = await readDebugMdStatus(join(p.specsDir, specId)) === "debugging";
|
|
764
|
-
const prompt = debugActive ? buildDebugPrompt(p.specsDirRelative, specId, "resume") : `请使用 yorz-spec skill 处理 spec:${p.specsDirRelative}/${specId}/spec.md`;
|
|
769
|
+
const prompt = debugActive ? buildDebugPrompt(p.specsDirRelative, specId, "resume", await buildDebugRuntimeContext(p)) : `请使用 yorz-spec skill 处理 spec:${p.specsDirRelative}/${specId}/spec.md`;
|
|
765
770
|
const handle = await p.sessions.send(sessionId, prompt);
|
|
766
771
|
return c.json({ runId: handle.runId, sessionId });
|
|
767
772
|
});
|
|
@@ -850,13 +855,38 @@ async function readDebugMdStatus(specDir) {
|
|
|
850
855
|
const val = statusLine.slice(statusLine.indexOf(":") + 1).trim().replace(/^['"]|['"]$/g, "");
|
|
851
856
|
return val === "debugging" ? "debugging" : val === "resolved" ? "resolved" : null;
|
|
852
857
|
}
|
|
853
|
-
function buildDebugPrompt(specsDirRelative, specId, mode) {
|
|
858
|
+
function buildDebugPrompt(specsDirRelative, specId, mode, runtimeContext = "") {
|
|
854
859
|
const specPath = `${specsDirRelative}/${specId}/spec.md`;
|
|
855
860
|
const debugPath = `${specsDirRelative}/${specId}/debug.md`;
|
|
856
861
|
if (mode === "resume") {
|
|
857
|
-
return `请使用 yorz-debug skill 继续调试:spec 目录 ${specsDirRelative}/${specId}(含 ${specPath})。该目录下已存在处于 debugging 状态的 ${debugPath},请定位其活跃记录块(frontmatter active 指向的 ## Debug NNN)继续「假设 → 取证 →
|
|
862
|
+
return `请使用 yorz-debug skill 继续调试:spec 目录 ${specsDirRelative}/${specId}(含 ${specPath})。该目录下已存在处于 debugging 状态的 ${debugPath},请定位其活跃记录块(frontmatter active 指向的 ## Debug NNN)继续「假设 → 取证 → 验证」循环,勿新建记录块。` + runtimeContext;
|
|
863
|
+
}
|
|
864
|
+
return `请使用 yorz-debug skill 进入 Debug 模式:spec 目录 ${specsDirRelative}/${specId}(含 ${specPath})。若 ${debugPath} 不存在则创建,否则在文末追加新的 ## Debug 记录块;进入后立即 git stash create 打快照写入 Debug 基线,再开始「假设 → 取证 → 验证」循环。` + runtimeContext;
|
|
865
|
+
}
|
|
866
|
+
async function buildDebugRuntimeContext(project) {
|
|
867
|
+
return formatDebugRuntimeContext(await project.commands.listRuns());
|
|
868
|
+
}
|
|
869
|
+
function formatDebugRuntimeContext(runs) {
|
|
870
|
+
const running = runs.filter((r) => r.status === "running");
|
|
871
|
+
if (running.length === 0) {
|
|
872
|
+
return "\n\n当前项目运行服务上下文:暂无运行中的命令服务。若复现需要服务,请根据项目脚本自行启动,或请用户在 GUI 命令菜单启动后重试。";
|
|
873
|
+
}
|
|
874
|
+
const lines = ["\n\n当前项目运行服务上下文:"];
|
|
875
|
+
for (const run of running) {
|
|
876
|
+
lines.push(
|
|
877
|
+
`- runId: ${promptScalar(run.runId)}`,
|
|
878
|
+
` name: ${promptScalar(run.name)}`,
|
|
879
|
+
` cli: ${promptScalar(run.cli)}`,
|
|
880
|
+
` status: ${run.status}`,
|
|
881
|
+
` pid: ${run.pid}`,
|
|
882
|
+
` startedAt: ${new Date(run.startedAt).toISOString()}`,
|
|
883
|
+
` logFile: ${promptScalar(run.logFile)}`
|
|
884
|
+
);
|
|
858
885
|
}
|
|
859
|
-
return
|
|
886
|
+
return lines.join("\n");
|
|
887
|
+
}
|
|
888
|
+
function promptScalar(value) {
|
|
889
|
+
return value.replace(/\r?\n/g, "\\n");
|
|
860
890
|
}
|
|
861
891
|
function parseCreateBody(body) {
|
|
862
892
|
if (!body || typeof body !== "object") return { error: "body must be an object" };
|
|
@@ -2008,6 +2038,9 @@ class EventsHub {
|
|
|
2008
2038
|
if (!project) throw new Error("project not found");
|
|
2009
2039
|
if (rest === "specs") return this.attachSpecsList(s, topic, project);
|
|
2010
2040
|
if (rest === "sessions") return this.attachSessionsStatus(s, topic, project);
|
|
2041
|
+
if (rest === "commands") return this.attachCommandRuns(s, topic, project);
|
|
2042
|
+
const cm = /^command:(.+)$/.exec(rest);
|
|
2043
|
+
if (cm) return this.attachCommandRun(s, topic, project, cm[1]);
|
|
2011
2044
|
let sm = /^spec:([^:]+):changes$/.exec(rest);
|
|
2012
2045
|
if (sm) return this.attachSpecChanges(s, topic, project, sm[1]);
|
|
2013
2046
|
sm = /^spec:([^:]+)$/.exec(rest);
|
|
@@ -2057,6 +2090,32 @@ class EventsHub {
|
|
|
2057
2090
|
this.emit(s, topic, "session-msg", ev);
|
|
2058
2091
|
});
|
|
2059
2092
|
}
|
|
2093
|
+
// Project-level topic: the running-commands container. Emits the full run
|
|
2094
|
+
// list on every change — the list is short and the GUI renders it wholesale.
|
|
2095
|
+
async attachCommandRuns(s, topic, project) {
|
|
2096
|
+
this.emit(s, topic, "ready", {});
|
|
2097
|
+
const runs = await project.commands.listRuns().catch(() => []);
|
|
2098
|
+
this.emit(s, topic, "runs-updated", { runs });
|
|
2099
|
+
return project.commands.subscribeRuns((next) => {
|
|
2100
|
+
this.emit(s, topic, "runs-updated", { runs: next });
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
// Per-run topic: incremental stdout plus terminal status transitions.
|
|
2104
|
+
async attachCommandRun(s, topic, project, runId) {
|
|
2105
|
+
const run = await project.commands.getRun(runId);
|
|
2106
|
+
if (!run) throw new Error("command run not found");
|
|
2107
|
+
this.emit(s, topic, "ready", { run });
|
|
2108
|
+
const unsubOutput = project.commands.subscribeOutput(runId, (chunk) => {
|
|
2109
|
+
this.emit(s, topic, "output-appended", chunk);
|
|
2110
|
+
});
|
|
2111
|
+
const unsubRun = project.commands.subscribeRun(runId, (next) => {
|
|
2112
|
+
this.emit(s, topic, "run-updated", { run: next });
|
|
2113
|
+
});
|
|
2114
|
+
return () => {
|
|
2115
|
+
unsubOutput();
|
|
2116
|
+
unsubRun();
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2060
2119
|
// Project-level topic: broadcasts which sessions have an in-flight turn, so
|
|
2061
2120
|
// the Chat list can render a spinner on sessions other than the active one.
|
|
2062
2121
|
attachSessionsStatus(s, topic, project) {
|
|
@@ -2174,7 +2233,12 @@ function createProjectRoutes(registry, worktreeManager) {
|
|
|
2174
2233
|
const CURRENT_VERSION = 1;
|
|
2175
2234
|
const DEFAULT_SPECS_DIR = ".yorz/specs";
|
|
2176
2235
|
function defaultProjectConfig() {
|
|
2177
|
-
return {
|
|
2236
|
+
return {
|
|
2237
|
+
version: CURRENT_VERSION,
|
|
2238
|
+
agent: { kind: "inherit" },
|
|
2239
|
+
specsDir: DEFAULT_SPECS_DIR,
|
|
2240
|
+
commands: []
|
|
2241
|
+
};
|
|
2178
2242
|
}
|
|
2179
2243
|
function configPath(cwd) {
|
|
2180
2244
|
return join(cwd, ".yorz", "config.json");
|
|
@@ -2225,7 +2289,25 @@ function normalizeConfig(value) {
|
|
|
2225
2289
|
const obj = value;
|
|
2226
2290
|
const agent = normalizeAgent(obj.agent);
|
|
2227
2291
|
const specsDir = normalizeSpecsDir(obj.specsDir);
|
|
2228
|
-
|
|
2292
|
+
const commands = normalizeCommands(obj.commands);
|
|
2293
|
+
return { version: CURRENT_VERSION, agent, specsDir, commands };
|
|
2294
|
+
}
|
|
2295
|
+
function normalizeCommands(value) {
|
|
2296
|
+
if (!Array.isArray(value)) return [];
|
|
2297
|
+
const out = [];
|
|
2298
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2299
|
+
for (const item of value) {
|
|
2300
|
+
if (!item || typeof item !== "object") continue;
|
|
2301
|
+
const obj = item;
|
|
2302
|
+
const id = typeof obj.id === "string" ? obj.id.trim() : "";
|
|
2303
|
+
const name = typeof obj.name === "string" ? obj.name.trim() : "";
|
|
2304
|
+
const cli = typeof obj.cli === "string" ? obj.cli.trim() : "";
|
|
2305
|
+
if (!id || !name || !cli || seen.has(id)) continue;
|
|
2306
|
+
seen.add(id);
|
|
2307
|
+
const createdAt = typeof obj.createdAt === "number" && obj.createdAt > 0 ? obj.createdAt : 0;
|
|
2308
|
+
out.push({ id, name, cli, createdAt });
|
|
2309
|
+
}
|
|
2310
|
+
return out;
|
|
2229
2311
|
}
|
|
2230
2312
|
function normalizeAgent(value) {
|
|
2231
2313
|
if (typeof value === "string") {
|
|
@@ -2274,10 +2356,12 @@ function createProjectConfigRoutes(registry) {
|
|
|
2274
2356
|
}
|
|
2275
2357
|
const parsed = parseBody$1(raw);
|
|
2276
2358
|
if ("error" in parsed) return c.json({ error: parsed.error }, 400);
|
|
2359
|
+
const current = await loadProjectConfig(entry.path);
|
|
2277
2360
|
const cfg = {
|
|
2278
2361
|
version: 1,
|
|
2279
2362
|
agent: parsed.agent,
|
|
2280
|
-
specsDir: parsed.specsDir
|
|
2363
|
+
specsDir: parsed.specsDir,
|
|
2364
|
+
commands: current.commands
|
|
2281
2365
|
};
|
|
2282
2366
|
let absSpecsDir;
|
|
2283
2367
|
try {
|
|
@@ -2755,6 +2839,720 @@ function createProjectFilesRoutes(resolveProject) {
|
|
|
2755
2839
|
});
|
|
2756
2840
|
return app;
|
|
2757
2841
|
}
|
|
2842
|
+
class CommandRunStore {
|
|
2843
|
+
file;
|
|
2844
|
+
cache = null;
|
|
2845
|
+
loadPromise = null;
|
|
2846
|
+
writeChain = Promise.resolve();
|
|
2847
|
+
constructor(projectPath) {
|
|
2848
|
+
this.file = join(projectPath, ".yorz", "tmp", "commands", "index.json");
|
|
2849
|
+
}
|
|
2850
|
+
/**
|
|
2851
|
+
* The in-flight read is memoized, not just its result: concurrent callers
|
|
2852
|
+
* must end up mutating the *same* array. Racing reads that each install a
|
|
2853
|
+
* fresh cache would silently drop every upsert but the last.
|
|
2854
|
+
*/
|
|
2855
|
+
load() {
|
|
2856
|
+
if (this.cache) return Promise.resolve(this.cache);
|
|
2857
|
+
if (!this.loadPromise) {
|
|
2858
|
+
this.loadPromise = (async () => {
|
|
2859
|
+
let items = [];
|
|
2860
|
+
try {
|
|
2861
|
+
const raw = await readFile(this.file, "utf8");
|
|
2862
|
+
const parsed = JSON.parse(raw);
|
|
2863
|
+
if (Array.isArray(parsed)) items = parsed;
|
|
2864
|
+
} catch {
|
|
2865
|
+
items = [];
|
|
2866
|
+
}
|
|
2867
|
+
this.cache ??= items;
|
|
2868
|
+
return this.cache;
|
|
2869
|
+
})();
|
|
2870
|
+
}
|
|
2871
|
+
return this.loadPromise;
|
|
2872
|
+
}
|
|
2873
|
+
async persist() {
|
|
2874
|
+
this.writeChain = this.writeChain.then(async () => {
|
|
2875
|
+
const body = JSON.stringify(this.cache ?? [], null, 2);
|
|
2876
|
+
await mkdir(dirname(this.file), { recursive: true });
|
|
2877
|
+
const tmp = `${this.file}.tmp-${process.pid}-${Date.now().toString(36)}`;
|
|
2878
|
+
await writeFile(tmp, body, "utf8");
|
|
2879
|
+
await rename(tmp, this.file);
|
|
2880
|
+
});
|
|
2881
|
+
await this.writeChain;
|
|
2882
|
+
}
|
|
2883
|
+
/** Newest run first. */
|
|
2884
|
+
async list() {
|
|
2885
|
+
const items = await this.load();
|
|
2886
|
+
return [...items].sort((a, b) => b.startedAt - a.startedAt);
|
|
2887
|
+
}
|
|
2888
|
+
async get(runId) {
|
|
2889
|
+
const items = await this.load();
|
|
2890
|
+
return items.find((r) => r.runId === runId);
|
|
2891
|
+
}
|
|
2892
|
+
async upsert(run) {
|
|
2893
|
+
const items = await this.load();
|
|
2894
|
+
const idx = items.findIndex((r) => r.runId === run.runId);
|
|
2895
|
+
if (idx >= 0) items[idx] = { ...items[idx], ...run };
|
|
2896
|
+
else items.push(run);
|
|
2897
|
+
await this.persist();
|
|
2898
|
+
}
|
|
2899
|
+
async remove(runId) {
|
|
2900
|
+
const items = await this.load();
|
|
2901
|
+
const idx = items.findIndex((r) => r.runId === runId);
|
|
2902
|
+
if (idx < 0) return false;
|
|
2903
|
+
items.splice(idx, 1);
|
|
2904
|
+
await this.persist();
|
|
2905
|
+
return true;
|
|
2906
|
+
}
|
|
2907
|
+
/** Bulk replace, used by the startup reap pass. */
|
|
2908
|
+
async replaceAll(runs) {
|
|
2909
|
+
await this.load();
|
|
2910
|
+
this.cache = [...runs];
|
|
2911
|
+
this.loadPromise = Promise.resolve(this.cache);
|
|
2912
|
+
await this.persist();
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
const OUTPUT_TAIL_LIMIT = 256 * 1024;
|
|
2916
|
+
const RUN_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
2917
|
+
function runLogRelPath(runId) {
|
|
2918
|
+
return `.yorz/tmp/commands/${runId}.log`;
|
|
2919
|
+
}
|
|
2920
|
+
const log$2 = () => getLogger().child("commands");
|
|
2921
|
+
const TAIL_POLL_MS = 200;
|
|
2922
|
+
const STOP_GRACE_MS = 2e3;
|
|
2923
|
+
class CommandNotFoundError extends Error {
|
|
2924
|
+
constructor(id) {
|
|
2925
|
+
super(`command not found: ${id}`);
|
|
2926
|
+
this.name = "CommandNotFoundError";
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
class RunNotFoundError extends Error {
|
|
2930
|
+
constructor(id) {
|
|
2931
|
+
super(`command run not found: ${id}`);
|
|
2932
|
+
this.name = "RunNotFoundError";
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
class LogTailer {
|
|
2936
|
+
constructor(file) {
|
|
2937
|
+
this.file = file;
|
|
2938
|
+
}
|
|
2939
|
+
subscribers = /* @__PURE__ */ new Set();
|
|
2940
|
+
timer = null;
|
|
2941
|
+
offset = 0;
|
|
2942
|
+
get subscriberCount() {
|
|
2943
|
+
return this.subscribers.size;
|
|
2944
|
+
}
|
|
2945
|
+
get running() {
|
|
2946
|
+
return this.timer !== null;
|
|
2947
|
+
}
|
|
2948
|
+
/**
|
|
2949
|
+
* The first subscriber positions the tail at the current end of file: the GUI
|
|
2950
|
+
* has already fetched everything up to there over REST. Any gap caused by
|
|
2951
|
+
* writes landing between that fetch and this subscribe shows up as a
|
|
2952
|
+
* non-contiguous offset, which the GUI heals with a refetch.
|
|
2953
|
+
*/
|
|
2954
|
+
subscribe(cb, fromOffset) {
|
|
2955
|
+
if (this.subscribers.size === 0) this.offset = fromOffset ?? currentSize(this.file);
|
|
2956
|
+
this.subscribers.add(cb);
|
|
2957
|
+
if (!this.timer) {
|
|
2958
|
+
this.timer = setInterval(() => this.poll(), TAIL_POLL_MS);
|
|
2959
|
+
this.timer.unref?.();
|
|
2960
|
+
}
|
|
2961
|
+
return () => {
|
|
2962
|
+
this.subscribers.delete(cb);
|
|
2963
|
+
if (this.subscribers.size === 0) this.stop();
|
|
2964
|
+
};
|
|
2965
|
+
}
|
|
2966
|
+
stop() {
|
|
2967
|
+
if (this.timer) clearInterval(this.timer);
|
|
2968
|
+
this.timer = null;
|
|
2969
|
+
}
|
|
2970
|
+
poll() {
|
|
2971
|
+
let size;
|
|
2972
|
+
try {
|
|
2973
|
+
size = statSync(this.file).size;
|
|
2974
|
+
} catch {
|
|
2975
|
+
return;
|
|
2976
|
+
}
|
|
2977
|
+
if (size <= this.offset) {
|
|
2978
|
+
if (size < this.offset) this.offset = size;
|
|
2979
|
+
return;
|
|
2980
|
+
}
|
|
2981
|
+
let fd = null;
|
|
2982
|
+
try {
|
|
2983
|
+
fd = openSync(this.file, "r");
|
|
2984
|
+
const length = size - this.offset;
|
|
2985
|
+
const buf = Buffer.alloc(length);
|
|
2986
|
+
const read = readSync(fd, buf, 0, length, this.offset);
|
|
2987
|
+
if (read <= 0) return;
|
|
2988
|
+
const chunk = {
|
|
2989
|
+
offset: this.offset,
|
|
2990
|
+
chunk: buf.subarray(0, read).toString("utf8")
|
|
2991
|
+
};
|
|
2992
|
+
this.offset += read;
|
|
2993
|
+
for (const cb of this.subscribers) {
|
|
2994
|
+
try {
|
|
2995
|
+
cb(chunk);
|
|
2996
|
+
} catch {
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
} catch (err) {
|
|
3000
|
+
log$2().warn("tail read failed", { file: this.file, err });
|
|
3001
|
+
} finally {
|
|
3002
|
+
if (fd !== null) closeSync(fd);
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
class CommandManager {
|
|
3007
|
+
constructor(projectPath) {
|
|
3008
|
+
this.projectPath = projectPath;
|
|
3009
|
+
this.store = new CommandRunStore(projectPath);
|
|
3010
|
+
}
|
|
3011
|
+
store;
|
|
3012
|
+
children = /* @__PURE__ */ new Map();
|
|
3013
|
+
tailers = /* @__PURE__ */ new Map();
|
|
3014
|
+
runsSubscribers = /* @__PURE__ */ new Set();
|
|
3015
|
+
runSubscribers = /* @__PURE__ */ new Map();
|
|
3016
|
+
/**
|
|
3017
|
+
* Exits observed before `run()` finished persisting the record. A command
|
|
3018
|
+
* that dies immediately (bad shell syntax, missing binary) emits `exit`
|
|
3019
|
+
* before the first `upsert` lands; without this the terminal state would be
|
|
3020
|
+
* dropped and the record would stay `running` forever.
|
|
3021
|
+
*/
|
|
3022
|
+
pendingEnds = /* @__PURE__ */ new Map();
|
|
3023
|
+
persisted = /* @__PURE__ */ new Set();
|
|
3024
|
+
initPromise = null;
|
|
3025
|
+
/** Idempotent startup pass: reap leftovers, drop expired records. */
|
|
3026
|
+
init() {
|
|
3027
|
+
if (!this.initPromise) this.initPromise = this.reap();
|
|
3028
|
+
return this.initPromise;
|
|
3029
|
+
}
|
|
3030
|
+
// ---- definitions (persisted in .yorz/config.json) ----
|
|
3031
|
+
async listDefs() {
|
|
3032
|
+
const cfg = await loadProjectConfig(this.projectPath);
|
|
3033
|
+
return cfg.commands;
|
|
3034
|
+
}
|
|
3035
|
+
async addDef(name, cli) {
|
|
3036
|
+
const trimmedName = name.trim();
|
|
3037
|
+
const trimmedCli = cli.trim();
|
|
3038
|
+
if (!trimmedName) throw new Error("name is required");
|
|
3039
|
+
if (!trimmedCli) throw new Error("cli is required");
|
|
3040
|
+
const cfg = await loadProjectConfig(this.projectPath);
|
|
3041
|
+
const def = {
|
|
3042
|
+
id: shortId(),
|
|
3043
|
+
name: trimmedName,
|
|
3044
|
+
cli: trimmedCli,
|
|
3045
|
+
createdAt: Date.now()
|
|
3046
|
+
};
|
|
3047
|
+
cfg.commands = [...cfg.commands, def];
|
|
3048
|
+
await saveProjectConfig(this.projectPath, cfg);
|
|
3049
|
+
return def;
|
|
3050
|
+
}
|
|
3051
|
+
async removeDef(commandId) {
|
|
3052
|
+
const cfg = await loadProjectConfig(this.projectPath);
|
|
3053
|
+
const next = cfg.commands.filter((c) => c.id !== commandId);
|
|
3054
|
+
if (next.length === cfg.commands.length) return false;
|
|
3055
|
+
cfg.commands = next;
|
|
3056
|
+
await saveProjectConfig(this.projectPath, cfg);
|
|
3057
|
+
return true;
|
|
3058
|
+
}
|
|
3059
|
+
// ---- runs ----
|
|
3060
|
+
async listRuns() {
|
|
3061
|
+
await this.init();
|
|
3062
|
+
return this.store.list();
|
|
3063
|
+
}
|
|
3064
|
+
async getRun(runId) {
|
|
3065
|
+
await this.init();
|
|
3066
|
+
return this.store.get(runId);
|
|
3067
|
+
}
|
|
3068
|
+
/**
|
|
3069
|
+
* Spawn a command. Idempotent per definition: if that command already has a
|
|
3070
|
+
* live run, the existing record is returned instead of starting a second
|
|
3071
|
+
* process (two dev servers would just fight over the same port).
|
|
3072
|
+
*/
|
|
3073
|
+
async run(commandId) {
|
|
3074
|
+
await this.init();
|
|
3075
|
+
const defs = await this.listDefs();
|
|
3076
|
+
const def = defs.find((d) => d.id === commandId);
|
|
3077
|
+
if (!def) throw new CommandNotFoundError(commandId);
|
|
3078
|
+
const existing = (await this.store.list()).find(
|
|
3079
|
+
(r) => r.commandId === commandId && r.status === "running"
|
|
3080
|
+
);
|
|
3081
|
+
if (existing) return existing;
|
|
3082
|
+
const runId = shortId();
|
|
3083
|
+
const logRel = runLogRelPath(runId);
|
|
3084
|
+
const logAbs = join(this.projectPath, ".yorz", "tmp", "commands", `${runId}.log`);
|
|
3085
|
+
await mkdir(join(this.projectPath, ".yorz", "tmp", "commands"), { recursive: true });
|
|
3086
|
+
const now = Date.now();
|
|
3087
|
+
let fd;
|
|
3088
|
+
try {
|
|
3089
|
+
fd = openSync(logAbs, "a");
|
|
3090
|
+
} catch (err) {
|
|
3091
|
+
const failed = {
|
|
3092
|
+
runId,
|
|
3093
|
+
commandId,
|
|
3094
|
+
name: def.name,
|
|
3095
|
+
cli: def.cli,
|
|
3096
|
+
pid: -1,
|
|
3097
|
+
status: "failed",
|
|
3098
|
+
startedAt: now,
|
|
3099
|
+
endedAt: now,
|
|
3100
|
+
exitCode: null,
|
|
3101
|
+
signal: null,
|
|
3102
|
+
logFile: logRel
|
|
3103
|
+
};
|
|
3104
|
+
log$2().error("failed to open command log", { runId, logAbs, err });
|
|
3105
|
+
await this.store.upsert(failed);
|
|
3106
|
+
this.notifyRuns();
|
|
3107
|
+
return failed;
|
|
3108
|
+
}
|
|
3109
|
+
try {
|
|
3110
|
+
const child = spawn(def.cli, {
|
|
3111
|
+
shell: true,
|
|
3112
|
+
cwd: this.projectPath,
|
|
3113
|
+
env: process.env,
|
|
3114
|
+
detached: true,
|
|
3115
|
+
// No pipes: the child writes straight into the log fd. Keeps a chatty
|
|
3116
|
+
// dev server from buffering through this process, and lets the REST
|
|
3117
|
+
// first paint and the SSE tail read from one single source.
|
|
3118
|
+
stdio: ["ignore", fd, fd]
|
|
3119
|
+
});
|
|
3120
|
+
child.unref();
|
|
3121
|
+
const run = {
|
|
3122
|
+
runId,
|
|
3123
|
+
commandId,
|
|
3124
|
+
name: def.name,
|
|
3125
|
+
cli: def.cli,
|
|
3126
|
+
pid: child.pid ?? -1,
|
|
3127
|
+
status: "running",
|
|
3128
|
+
startedAt: now,
|
|
3129
|
+
logFile: logRel
|
|
3130
|
+
};
|
|
3131
|
+
if (!child.pid) {
|
|
3132
|
+
run.status = "failed";
|
|
3133
|
+
run.endedAt = Date.now();
|
|
3134
|
+
run.exitCode = null;
|
|
3135
|
+
run.signal = null;
|
|
3136
|
+
} else {
|
|
3137
|
+
this.children.set(runId, child);
|
|
3138
|
+
child.on("exit", (code, signal) => {
|
|
3139
|
+
this.children.delete(runId);
|
|
3140
|
+
void this.handleExit(runId, signal ? "killed" : "exited", code, signal ?? null);
|
|
3141
|
+
});
|
|
3142
|
+
child.on("error", (err) => {
|
|
3143
|
+
log$2().warn("command child error", { runId, err });
|
|
3144
|
+
this.children.delete(runId);
|
|
3145
|
+
void this.handleExit(runId, "failed", null, null);
|
|
3146
|
+
});
|
|
3147
|
+
}
|
|
3148
|
+
await this.store.upsert(run);
|
|
3149
|
+
this.persisted.add(runId);
|
|
3150
|
+
const raced = this.pendingEnds.get(runId);
|
|
3151
|
+
if (raced) {
|
|
3152
|
+
this.pendingEnds.delete(runId);
|
|
3153
|
+
await this.markRunEnded(runId, raced.status, raced.exitCode, raced.signal);
|
|
3154
|
+
const settled = await this.store.get(runId);
|
|
3155
|
+
if (settled) return settled;
|
|
3156
|
+
}
|
|
3157
|
+
this.notifyRuns();
|
|
3158
|
+
log$2().info("command started", { runId, commandId, pid: run.pid, cli: def.cli });
|
|
3159
|
+
return run;
|
|
3160
|
+
} finally {
|
|
3161
|
+
try {
|
|
3162
|
+
closeSync(fd);
|
|
3163
|
+
} catch {
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
/** Terminate the process but keep the run record and its log. */
|
|
3168
|
+
async stop(runId) {
|
|
3169
|
+
await this.init();
|
|
3170
|
+
const run = await this.store.get(runId);
|
|
3171
|
+
if (!run) throw new RunNotFoundError(runId);
|
|
3172
|
+
if (run.status !== "running") return run;
|
|
3173
|
+
await this.terminate(run);
|
|
3174
|
+
return await this.store.get(runId) ?? run;
|
|
3175
|
+
}
|
|
3176
|
+
/** Terminate if needed, then drop the record and delete the log file. */
|
|
3177
|
+
async clear(runId) {
|
|
3178
|
+
await this.init();
|
|
3179
|
+
const run = await this.store.get(runId);
|
|
3180
|
+
if (!run) return false;
|
|
3181
|
+
if (run.status === "running") await this.terminate(run);
|
|
3182
|
+
this.tailers.get(runId)?.stop();
|
|
3183
|
+
this.tailers.delete(runId);
|
|
3184
|
+
await this.store.remove(runId);
|
|
3185
|
+
await rm(join(this.projectPath, run.logFile), { force: true }).catch(() => {
|
|
3186
|
+
});
|
|
3187
|
+
this.runSubscribers.delete(runId);
|
|
3188
|
+
this.persisted.delete(runId);
|
|
3189
|
+
this.pendingEnds.delete(runId);
|
|
3190
|
+
this.notifyRuns();
|
|
3191
|
+
return true;
|
|
3192
|
+
}
|
|
3193
|
+
async readOutput(runId, offset) {
|
|
3194
|
+
await this.init();
|
|
3195
|
+
const run = await this.store.get(runId);
|
|
3196
|
+
if (!run) throw new RunNotFoundError(runId);
|
|
3197
|
+
return readLogSlice(join(this.projectPath, run.logFile), offset);
|
|
3198
|
+
}
|
|
3199
|
+
// ---- subscriptions (SSE) ----
|
|
3200
|
+
subscribeRuns(cb) {
|
|
3201
|
+
this.runsSubscribers.add(cb);
|
|
3202
|
+
return () => this.runsSubscribers.delete(cb);
|
|
3203
|
+
}
|
|
3204
|
+
subscribeRun(runId, cb) {
|
|
3205
|
+
let set = this.runSubscribers.get(runId);
|
|
3206
|
+
if (!set) {
|
|
3207
|
+
set = /* @__PURE__ */ new Set();
|
|
3208
|
+
this.runSubscribers.set(runId, set);
|
|
3209
|
+
}
|
|
3210
|
+
set.add(cb);
|
|
3211
|
+
return () => {
|
|
3212
|
+
set.delete(cb);
|
|
3213
|
+
if (set.size === 0) this.runSubscribers.delete(runId);
|
|
3214
|
+
};
|
|
3215
|
+
}
|
|
3216
|
+
subscribeOutput(runId, cb, fromOffset) {
|
|
3217
|
+
const file = join(this.projectPath, runLogRelPath(runId));
|
|
3218
|
+
let tailer = this.tailers.get(runId);
|
|
3219
|
+
if (!tailer) {
|
|
3220
|
+
tailer = new LogTailer(file);
|
|
3221
|
+
this.tailers.set(runId, tailer);
|
|
3222
|
+
}
|
|
3223
|
+
const unsub = tailer.subscribe(cb, fromOffset);
|
|
3224
|
+
return () => {
|
|
3225
|
+
unsub();
|
|
3226
|
+
const t = this.tailers.get(runId);
|
|
3227
|
+
if (t && t.subscriberCount === 0) this.tailers.delete(runId);
|
|
3228
|
+
};
|
|
3229
|
+
}
|
|
3230
|
+
// ---- lifecycle ----
|
|
3231
|
+
/**
|
|
3232
|
+
* Stop every live child. Called on service shutdown: commands are tied to the
|
|
3233
|
+
* service lifetime, so nothing outlives it.
|
|
3234
|
+
*/
|
|
3235
|
+
async stopAll() {
|
|
3236
|
+
const runs = await this.store.list().catch(() => []);
|
|
3237
|
+
const live = runs.filter((r) => r.status === "running");
|
|
3238
|
+
await Promise.all(live.map((r) => this.terminate(r).catch(() => {
|
|
3239
|
+
})));
|
|
3240
|
+
for (const t of this.tailers.values()) t.stop();
|
|
3241
|
+
this.tailers.clear();
|
|
3242
|
+
}
|
|
3243
|
+
/** Last-resort synchronous kill for `process.on('exit')`, which cannot await. */
|
|
3244
|
+
stopAllSync() {
|
|
3245
|
+
for (const [runId, child] of this.children) {
|
|
3246
|
+
const pid = child.pid;
|
|
3247
|
+
if (!pid) continue;
|
|
3248
|
+
try {
|
|
3249
|
+
process.kill(-pid, "SIGKILL");
|
|
3250
|
+
} catch {
|
|
3251
|
+
try {
|
|
3252
|
+
process.kill(pid, "SIGKILL");
|
|
3253
|
+
} catch {
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
this.children.delete(runId);
|
|
3257
|
+
}
|
|
3258
|
+
for (const t of this.tailers.values()) t.stop();
|
|
3259
|
+
}
|
|
3260
|
+
/**
|
|
3261
|
+
* Startup pass. Any record still marked `running` belongs to a previous
|
|
3262
|
+
* service process: kill it if it somehow survived (service SIGKILLed), then
|
|
3263
|
+
* mark it `killed`. Also drops records that finished long ago.
|
|
3264
|
+
*/
|
|
3265
|
+
async reap() {
|
|
3266
|
+
const runs = await this.store.list().catch(() => []);
|
|
3267
|
+
if (runs.length === 0) return;
|
|
3268
|
+
const now = Date.now();
|
|
3269
|
+
const kept = [];
|
|
3270
|
+
const dropped = [];
|
|
3271
|
+
for (const run of runs) {
|
|
3272
|
+
if (run.status === "running") {
|
|
3273
|
+
if (run.pid > 0 && isAlive(run.pid)) {
|
|
3274
|
+
killGroup(run.pid, "SIGKILL");
|
|
3275
|
+
log$2().warn("reaped orphan command process", { runId: run.runId, pid: run.pid });
|
|
3276
|
+
}
|
|
3277
|
+
kept.push({ ...run, status: "killed", endedAt: run.endedAt ?? now, exitCode: null });
|
|
3278
|
+
continue;
|
|
3279
|
+
}
|
|
3280
|
+
const endedAt = run.endedAt ?? run.startedAt;
|
|
3281
|
+
if (now - endedAt > RUN_RETENTION_MS) dropped.push(run);
|
|
3282
|
+
else kept.push(run);
|
|
3283
|
+
}
|
|
3284
|
+
await this.store.replaceAll(kept);
|
|
3285
|
+
for (const run of dropped) {
|
|
3286
|
+
await rm(join(this.projectPath, run.logFile), { force: true }).catch(() => {
|
|
3287
|
+
});
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
async terminate(run) {
|
|
3291
|
+
if (run.pid > 0) {
|
|
3292
|
+
killGroup(run.pid, "SIGTERM");
|
|
3293
|
+
const exited = await waitForExit(run.pid, STOP_GRACE_MS);
|
|
3294
|
+
if (!exited) {
|
|
3295
|
+
log$2().warn("command did not exit on SIGTERM, escalating", {
|
|
3296
|
+
runId: run.runId,
|
|
3297
|
+
pid: run.pid
|
|
3298
|
+
});
|
|
3299
|
+
killGroup(run.pid, "SIGKILL");
|
|
3300
|
+
await waitForExit(run.pid, STOP_GRACE_MS);
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
this.children.delete(run.runId);
|
|
3304
|
+
await this.markRunEnded(run.runId, "killed", null, "SIGTERM");
|
|
3305
|
+
}
|
|
3306
|
+
async handleExit(runId, status, exitCode, signal) {
|
|
3307
|
+
if (!this.persisted.has(runId)) {
|
|
3308
|
+
this.pendingEnds.set(runId, { status, exitCode, signal: signal ?? null });
|
|
3309
|
+
return;
|
|
3310
|
+
}
|
|
3311
|
+
await this.markRunEnded(runId, status, exitCode, signal);
|
|
3312
|
+
}
|
|
3313
|
+
async markRunEnded(runId, status, exitCode, signal) {
|
|
3314
|
+
const run = await this.store.get(runId);
|
|
3315
|
+
if (!run || run.status !== "running") return;
|
|
3316
|
+
const next = {
|
|
3317
|
+
...run,
|
|
3318
|
+
status,
|
|
3319
|
+
endedAt: Date.now(),
|
|
3320
|
+
exitCode,
|
|
3321
|
+
signal: signal ?? null
|
|
3322
|
+
};
|
|
3323
|
+
await this.store.upsert(next);
|
|
3324
|
+
this.notifyRuns();
|
|
3325
|
+
this.notifyRun(next);
|
|
3326
|
+
log$2().info("command ended", { runId, status, exitCode, signal });
|
|
3327
|
+
}
|
|
3328
|
+
notifyRuns() {
|
|
3329
|
+
if (this.runsSubscribers.size === 0) return;
|
|
3330
|
+
void this.store.list().then((runs) => {
|
|
3331
|
+
for (const cb of this.runsSubscribers) {
|
|
3332
|
+
try {
|
|
3333
|
+
cb(runs);
|
|
3334
|
+
} catch {
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
});
|
|
3338
|
+
}
|
|
3339
|
+
notifyRun(run) {
|
|
3340
|
+
const set = this.runSubscribers.get(run.runId);
|
|
3341
|
+
if (!set) return;
|
|
3342
|
+
for (const cb of set) {
|
|
3343
|
+
try {
|
|
3344
|
+
cb(run);
|
|
3345
|
+
} catch {
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
const managers = /* @__PURE__ */ new Map();
|
|
3351
|
+
function getCommandManager(projectPath) {
|
|
3352
|
+
let m = managers.get(projectPath);
|
|
3353
|
+
if (!m) {
|
|
3354
|
+
m = new CommandManager(projectPath);
|
|
3355
|
+
managers.set(projectPath, m);
|
|
3356
|
+
}
|
|
3357
|
+
return m;
|
|
3358
|
+
}
|
|
3359
|
+
async function stopAllCommandManagers() {
|
|
3360
|
+
const all = [...managers.values()];
|
|
3361
|
+
managers.clear();
|
|
3362
|
+
await Promise.all(all.map((m) => m.stopAll().catch(() => {
|
|
3363
|
+
})));
|
|
3364
|
+
}
|
|
3365
|
+
function stopAllCommandManagersSync() {
|
|
3366
|
+
for (const m of managers.values()) m.stopAllSync();
|
|
3367
|
+
managers.clear();
|
|
3368
|
+
}
|
|
3369
|
+
function readLogSlice(file, offset) {
|
|
3370
|
+
let size = 0;
|
|
3371
|
+
try {
|
|
3372
|
+
size = statSync(file).size;
|
|
3373
|
+
} catch {
|
|
3374
|
+
return { offset: 0, text: "", size: 0, truncated: false };
|
|
3375
|
+
}
|
|
3376
|
+
let start2 = offset ?? Math.max(0, size - OUTPUT_TAIL_LIMIT);
|
|
3377
|
+
let truncated = offset === void 0 && start2 > 0;
|
|
3378
|
+
if (start2 > size) start2 = size;
|
|
3379
|
+
if (start2 < 0) start2 = 0;
|
|
3380
|
+
if (size - start2 > OUTPUT_TAIL_LIMIT) {
|
|
3381
|
+
start2 = size - OUTPUT_TAIL_LIMIT;
|
|
3382
|
+
truncated = true;
|
|
3383
|
+
}
|
|
3384
|
+
const length = size - start2;
|
|
3385
|
+
if (length <= 0) return { offset: start2, text: "", size, truncated };
|
|
3386
|
+
let fd = null;
|
|
3387
|
+
try {
|
|
3388
|
+
fd = openSync(file, "r");
|
|
3389
|
+
const buf = Buffer.alloc(length);
|
|
3390
|
+
const read = readSync(fd, buf, 0, length, start2);
|
|
3391
|
+
return { offset: start2, text: buf.subarray(0, read).toString("utf8"), size, truncated };
|
|
3392
|
+
} catch {
|
|
3393
|
+
return { offset: start2, text: "", size, truncated };
|
|
3394
|
+
} finally {
|
|
3395
|
+
if (fd !== null) closeSync(fd);
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
function currentSize(file) {
|
|
3399
|
+
try {
|
|
3400
|
+
return statSync(file).size;
|
|
3401
|
+
} catch {
|
|
3402
|
+
return 0;
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
function shortId() {
|
|
3406
|
+
return randomBytes(8).toString("hex");
|
|
3407
|
+
}
|
|
3408
|
+
function isAlive(pid) {
|
|
3409
|
+
try {
|
|
3410
|
+
process.kill(pid, 0);
|
|
3411
|
+
return true;
|
|
3412
|
+
} catch {
|
|
3413
|
+
return false;
|
|
3414
|
+
}
|
|
3415
|
+
}
|
|
3416
|
+
function killGroup(pid, signal) {
|
|
3417
|
+
try {
|
|
3418
|
+
process.kill(-pid, signal);
|
|
3419
|
+
} catch {
|
|
3420
|
+
try {
|
|
3421
|
+
process.kill(pid, signal);
|
|
3422
|
+
} catch {
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
async function waitForExit(pid, timeoutMs) {
|
|
3427
|
+
const until = Date.now() + timeoutMs;
|
|
3428
|
+
while (Date.now() < until) {
|
|
3429
|
+
if (!isAlive(pid)) return true;
|
|
3430
|
+
await sleep$1(50);
|
|
3431
|
+
}
|
|
3432
|
+
return !isAlive(pid);
|
|
3433
|
+
}
|
|
3434
|
+
function sleep$1(ms) {
|
|
3435
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
3436
|
+
}
|
|
3437
|
+
const MAX_NAME_LEN = 120;
|
|
3438
|
+
const MAX_CLI_LEN = 2e3;
|
|
3439
|
+
function createCommandsRoutes(resolveProject) {
|
|
3440
|
+
const app = new Hono();
|
|
3441
|
+
const need = async (c) => {
|
|
3442
|
+
const id = c.req.param("projectId") ?? "";
|
|
3443
|
+
if (!id) return c.json({ error: "projectId required" }, 400);
|
|
3444
|
+
const project = await resolveProject(id);
|
|
3445
|
+
if (!project) return c.json({ error: "project not found" }, 404);
|
|
3446
|
+
return project;
|
|
3447
|
+
};
|
|
3448
|
+
app.get("/projects/:projectId/commands", async (c) => {
|
|
3449
|
+
const p = await need(c);
|
|
3450
|
+
if (p instanceof Response) return p;
|
|
3451
|
+
return c.json(await p.commands.listDefs());
|
|
3452
|
+
});
|
|
3453
|
+
app.post("/projects/:projectId/commands", async (c) => {
|
|
3454
|
+
const p = await need(c);
|
|
3455
|
+
if (p instanceof Response) return p;
|
|
3456
|
+
let body;
|
|
3457
|
+
try {
|
|
3458
|
+
body = await c.req.json();
|
|
3459
|
+
} catch {
|
|
3460
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
3461
|
+
}
|
|
3462
|
+
const parsed = parseDefBody(body);
|
|
3463
|
+
if ("error" in parsed) return c.json({ error: parsed.error }, 400);
|
|
3464
|
+
const def = await p.commands.addDef(parsed.name, parsed.cli);
|
|
3465
|
+
return c.json(def, 201);
|
|
3466
|
+
});
|
|
3467
|
+
app.delete("/projects/:projectId/commands/:commandId", async (c) => {
|
|
3468
|
+
const p = await need(c);
|
|
3469
|
+
if (p instanceof Response) return p;
|
|
3470
|
+
const ok = await p.commands.removeDef(c.req.param("commandId"));
|
|
3471
|
+
if (!ok) return c.json({ error: "command not found" }, 404);
|
|
3472
|
+
return c.json({ ok: true });
|
|
3473
|
+
});
|
|
3474
|
+
app.get("/projects/:projectId/command-runs", async (c) => {
|
|
3475
|
+
const p = await need(c);
|
|
3476
|
+
if (p instanceof Response) return p;
|
|
3477
|
+
return c.json(await p.commands.listRuns());
|
|
3478
|
+
});
|
|
3479
|
+
app.post("/projects/:projectId/command-runs", async (c) => {
|
|
3480
|
+
const p = await need(c);
|
|
3481
|
+
if (p instanceof Response) return p;
|
|
3482
|
+
let body;
|
|
3483
|
+
try {
|
|
3484
|
+
body = await c.req.json();
|
|
3485
|
+
} catch {
|
|
3486
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
3487
|
+
}
|
|
3488
|
+
const commandId = body && typeof body === "object" ? body.commandId : void 0;
|
|
3489
|
+
if (typeof commandId !== "string" || !commandId.trim()) {
|
|
3490
|
+
return c.json({ error: "commandId required" }, 400);
|
|
3491
|
+
}
|
|
3492
|
+
try {
|
|
3493
|
+
const run = await p.commands.run(commandId.trim());
|
|
3494
|
+
return c.json(run, 201);
|
|
3495
|
+
} catch (err) {
|
|
3496
|
+
if (err instanceof CommandNotFoundError) return c.json({ error: "command not found" }, 404);
|
|
3497
|
+
return c.json({ error: err.message }, 400);
|
|
3498
|
+
}
|
|
3499
|
+
});
|
|
3500
|
+
app.get("/projects/:projectId/command-runs/:runId", async (c) => {
|
|
3501
|
+
const p = await need(c);
|
|
3502
|
+
if (p instanceof Response) return p;
|
|
3503
|
+
const run = await p.commands.getRun(c.req.param("runId"));
|
|
3504
|
+
if (!run) return c.json({ error: "command run not found" }, 404);
|
|
3505
|
+
return c.json(run);
|
|
3506
|
+
});
|
|
3507
|
+
app.get("/projects/:projectId/command-runs/:runId/output", async (c) => {
|
|
3508
|
+
const p = await need(c);
|
|
3509
|
+
if (p instanceof Response) return p;
|
|
3510
|
+
const raw = c.req.query("offset");
|
|
3511
|
+
let offset;
|
|
3512
|
+
if (raw !== void 0 && raw !== "") {
|
|
3513
|
+
const n = Number(raw);
|
|
3514
|
+
if (!Number.isInteger(n) || n < 0)
|
|
3515
|
+
return c.json({ error: "offset must be a non-negative integer" }, 400);
|
|
3516
|
+
offset = n;
|
|
3517
|
+
}
|
|
3518
|
+
try {
|
|
3519
|
+
return c.json(await p.commands.readOutput(c.req.param("runId"), offset));
|
|
3520
|
+
} catch (err) {
|
|
3521
|
+
if (err instanceof RunNotFoundError) return c.json({ error: "command run not found" }, 404);
|
|
3522
|
+
throw err;
|
|
3523
|
+
}
|
|
3524
|
+
});
|
|
3525
|
+
app.post("/projects/:projectId/command-runs/:runId/stop", async (c) => {
|
|
3526
|
+
const p = await need(c);
|
|
3527
|
+
if (p instanceof Response) return p;
|
|
3528
|
+
try {
|
|
3529
|
+
const run = await p.commands.stop(c.req.param("runId"));
|
|
3530
|
+
return c.json({ ok: true, run });
|
|
3531
|
+
} catch (err) {
|
|
3532
|
+
if (err instanceof RunNotFoundError) return c.json({ error: "command run not found" }, 404);
|
|
3533
|
+
throw err;
|
|
3534
|
+
}
|
|
3535
|
+
});
|
|
3536
|
+
app.delete("/projects/:projectId/command-runs/:runId", async (c) => {
|
|
3537
|
+
const p = await need(c);
|
|
3538
|
+
if (p instanceof Response) return p;
|
|
3539
|
+
const ok = await p.commands.clear(c.req.param("runId"));
|
|
3540
|
+
if (!ok) return c.json({ error: "command run not found" }, 404);
|
|
3541
|
+
return c.json({ ok: true });
|
|
3542
|
+
});
|
|
3543
|
+
return app;
|
|
3544
|
+
}
|
|
3545
|
+
function parseDefBody(value) {
|
|
3546
|
+
if (!value || typeof value !== "object") return { error: "body must be an object" };
|
|
3547
|
+
const obj = value;
|
|
3548
|
+
const name = typeof obj.name === "string" ? obj.name.trim() : "";
|
|
3549
|
+
const cli = typeof obj.cli === "string" ? obj.cli.trim() : "";
|
|
3550
|
+
if (!name) return { error: "name required" };
|
|
3551
|
+
if (name.length > MAX_NAME_LEN) return { error: `name too long (max ${MAX_NAME_LEN})` };
|
|
3552
|
+
if (!cli) return { error: "cli required" };
|
|
3553
|
+
if (cli.length > MAX_CLI_LEN) return { error: `cli too long (max ${MAX_CLI_LEN})` };
|
|
3554
|
+
return { name, cli };
|
|
3555
|
+
}
|
|
2758
3556
|
const MIME = {
|
|
2759
3557
|
".html": "text/html; charset=utf-8",
|
|
2760
3558
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -3279,6 +4077,7 @@ function createApp(opts) {
|
|
|
3279
4077
|
api.route("/", createSpecDraftsRoutes(resolveProject));
|
|
3280
4078
|
api.route("/", createWorktreeRoutes(opts.registry, worktreeManager));
|
|
3281
4079
|
api.route("/", createProjectFilesRoutes(resolveProject));
|
|
4080
|
+
api.route("/", createCommandsRoutes(resolveProject));
|
|
3282
4081
|
api.route("/", createEventsRoutes(resolveProject, opts.registry, projectsBus));
|
|
3283
4082
|
app.route("/api", api);
|
|
3284
4083
|
app.route("/", createStaticRoutes(opts.guiRoot));
|
|
@@ -3862,6 +4661,7 @@ class SpecWatcher {
|
|
|
3862
4661
|
function blocksFrom(message) {
|
|
3863
4662
|
if (!message || typeof message !== "object") return [];
|
|
3864
4663
|
const content = message.content;
|
|
4664
|
+
if (typeof content === "string") return content ? [{ type: "text", text: content }] : [];
|
|
3865
4665
|
return Array.isArray(content) ? content : [];
|
|
3866
4666
|
}
|
|
3867
4667
|
function toolResultText(content) {
|
|
@@ -4441,6 +5241,8 @@ class SessionManager {
|
|
|
4441
5241
|
emitters = /* @__PURE__ */ new Map();
|
|
4442
5242
|
/** Session ids with an in-flight turn. Drives the list spinner. */
|
|
4443
5243
|
running = /* @__PURE__ */ new Set();
|
|
5244
|
+
/** Kind of sessions discovered via `listSessions()` but absent from the store. */
|
|
5245
|
+
discoveredKinds = /* @__PURE__ */ new Map();
|
|
4444
5246
|
statusEmitter = new EventEmitter();
|
|
4445
5247
|
emitterFor(sid) {
|
|
4446
5248
|
let e = this.emitters.get(sid);
|
|
@@ -4491,6 +5293,7 @@ class SessionManager {
|
|
|
4491
5293
|
if (!adapter.capabilities().listSessions) continue;
|
|
4492
5294
|
try {
|
|
4493
5295
|
for (const info of await adapter.listSessions()) {
|
|
5296
|
+
if (!nativeIds.has(info.id)) this.discoveredKinds.set(info.id, kind);
|
|
4494
5297
|
nativeIds.add(info.id);
|
|
4495
5298
|
const indexed2 = byId.get(info.id);
|
|
4496
5299
|
if (!indexed2) {
|
|
@@ -4538,8 +5341,15 @@ class SessionManager {
|
|
|
4538
5341
|
if (!title || isOpaqueTitle(title, sid)) return;
|
|
4539
5342
|
await this.store.updateTitle(sid, title);
|
|
4540
5343
|
}
|
|
5344
|
+
async resolveKind(sid) {
|
|
5345
|
+
const known = (await this.store.get(sid))?.kind ?? this.live.get(sid)?.kind ?? this.discoveredKinds.get(sid);
|
|
5346
|
+
if (known) return known;
|
|
5347
|
+
await this.listSessions().catch(() => {
|
|
5348
|
+
});
|
|
5349
|
+
return this.discoveredKinds.get(sid) ?? this.defaultKind;
|
|
5350
|
+
}
|
|
4541
5351
|
async getMessages(sid) {
|
|
4542
|
-
const kind =
|
|
5352
|
+
const kind = await this.resolveKind(sid);
|
|
4543
5353
|
const adapter = this.adapters.get(kind);
|
|
4544
5354
|
if (!adapter.capabilities().getMessages) return [];
|
|
4545
5355
|
return adapter.getMessages(sid);
|
|
@@ -4927,6 +5737,7 @@ class ProjectRegistry {
|
|
|
4927
5737
|
const sessions = new SessionManager(input.path, defaultKind, sessionStore, {
|
|
4928
5738
|
onSessionEnd: notifySessionEnded
|
|
4929
5739
|
});
|
|
5740
|
+
const commands = getCommandManager(input.path);
|
|
4930
5741
|
let closed = false;
|
|
4931
5742
|
const instance = {
|
|
4932
5743
|
id: input.id,
|
|
@@ -4937,6 +5748,7 @@ class ProjectRegistry {
|
|
|
4937
5748
|
watcher,
|
|
4938
5749
|
sessions,
|
|
4939
5750
|
attachments,
|
|
5751
|
+
commands,
|
|
4940
5752
|
async close() {
|
|
4941
5753
|
if (closed) return;
|
|
4942
5754
|
closed = true;
|
|
@@ -4950,6 +5762,8 @@ class ProjectRegistry {
|
|
|
4950
5762
|
await attachments.ensureRoot();
|
|
4951
5763
|
void attachments.cleanupExpired().catch(() => {
|
|
4952
5764
|
});
|
|
5765
|
+
void commands.init().catch(() => {
|
|
5766
|
+
});
|
|
4953
5767
|
await watcher.start();
|
|
4954
5768
|
})();
|
|
4955
5769
|
this.cache.set(input.id, { instance, startPromise });
|
|
@@ -4988,6 +5802,10 @@ async function maxSpecUpdatedAt(projectPath) {
|
|
|
4988
5802
|
}
|
|
4989
5803
|
const DEFAULT_PORT = 7423;
|
|
4990
5804
|
const MAX_PORT_TRIES = 10;
|
|
5805
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
5806
|
+
function isLoopbackHost(host) {
|
|
5807
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
5808
|
+
}
|
|
4991
5809
|
const log$1 = () => getLogger().child("serve");
|
|
4992
5810
|
async function start(opts = {}) {
|
|
4993
5811
|
const registry = new ProjectRegistry({ globalConfigPath: opts.globalConfigPath });
|
|
@@ -5000,11 +5818,16 @@ async function start(opts = {}) {
|
|
|
5000
5818
|
}
|
|
5001
5819
|
const projects = await registry.list();
|
|
5002
5820
|
const app = createApp({ registry, guiRoot: opts.guiRoot });
|
|
5003
|
-
const
|
|
5821
|
+
const host = opts.host?.trim() || DEFAULT_HOST;
|
|
5822
|
+
const port = await listen(app.fetch, opts.port ?? DEFAULT_PORT, host);
|
|
5004
5823
|
const url = `http://localhost:${port.port}/`;
|
|
5005
5824
|
console.log(`YorZ Service ready at ${url} (${projects.length} project${projects.length === 1 ? "" : "s"})`);
|
|
5006
|
-
|
|
5007
|
-
console.log(`
|
|
5825
|
+
if (!isLoopbackHost(host)) {
|
|
5826
|
+
console.log(` WARNING: bound to ${host} — reachable from the network.`);
|
|
5827
|
+
for (const lanUrl of listLanUrls(port.port)) {
|
|
5828
|
+
console.log(` LAN: ${lanUrl}`);
|
|
5829
|
+
}
|
|
5830
|
+
log$1().warn("service bound to a non-loopback address", { host, port: port.port });
|
|
5008
5831
|
}
|
|
5009
5832
|
for (const p of projects) {
|
|
5010
5833
|
console.log(` - ${p.name} -> ${p.path}`);
|
|
@@ -5020,12 +5843,14 @@ async function start(opts = {}) {
|
|
|
5020
5843
|
logFile: getLogger().filePath
|
|
5021
5844
|
});
|
|
5022
5845
|
installGlobalErrorHandlers();
|
|
5846
|
+
installCommandExitGuard();
|
|
5023
5847
|
if (opts.open) await tryOpenBrowser(url);
|
|
5024
5848
|
return {
|
|
5025
5849
|
url,
|
|
5026
5850
|
port: port.port,
|
|
5027
5851
|
registry,
|
|
5028
5852
|
async close() {
|
|
5853
|
+
await stopAllCommandManagers();
|
|
5029
5854
|
await registry.closeAll();
|
|
5030
5855
|
await new Promise((resolve2, reject) => {
|
|
5031
5856
|
port.server.close((err) => err ? reject(err) : resolve2());
|
|
@@ -5034,7 +5859,7 @@ async function start(opts = {}) {
|
|
|
5034
5859
|
}
|
|
5035
5860
|
};
|
|
5036
5861
|
}
|
|
5037
|
-
async function listen(fetchHandler, preferredPort) {
|
|
5862
|
+
async function listen(fetchHandler, preferredPort, hostname) {
|
|
5038
5863
|
let lastErr = null;
|
|
5039
5864
|
for (let i = 0; i < MAX_PORT_TRIES; i++) {
|
|
5040
5865
|
const tryPort = preferredPort + i;
|
|
@@ -5042,7 +5867,7 @@ async function listen(fetchHandler, preferredPort) {
|
|
|
5042
5867
|
return await new Promise(
|
|
5043
5868
|
(resolve2, reject) => {
|
|
5044
5869
|
const server = serve(
|
|
5045
|
-
{ fetch: fetchHandler, port: tryPort, hostname
|
|
5870
|
+
{ fetch: fetchHandler, port: tryPort, hostname },
|
|
5046
5871
|
(info) => {
|
|
5047
5872
|
resolve2({ port: info.port, server });
|
|
5048
5873
|
}
|
|
@@ -5097,6 +5922,14 @@ function logCrash(kind, payload) {
|
|
|
5097
5922
|
const body = payload instanceof Error ? payload.stack ?? payload.message : String(payload);
|
|
5098
5923
|
log$1().error(kind, { detail: body });
|
|
5099
5924
|
}
|
|
5925
|
+
let commandExitGuardInstalled = false;
|
|
5926
|
+
function installCommandExitGuard() {
|
|
5927
|
+
if (commandExitGuardInstalled) return;
|
|
5928
|
+
commandExitGuardInstalled = true;
|
|
5929
|
+
process.on("exit", () => {
|
|
5930
|
+
stopAllCommandManagersSync();
|
|
5931
|
+
});
|
|
5932
|
+
}
|
|
5100
5933
|
function installGlobalErrorHandlers() {
|
|
5101
5934
|
if (process.listenerCount("uncaughtException") > 0) return;
|
|
5102
5935
|
process.on("uncaughtException", (err) => {
|
|
@@ -5134,23 +5967,28 @@ async function runServe(opts) {
|
|
|
5134
5967
|
}
|
|
5135
5968
|
const handle = await start({
|
|
5136
5969
|
port: opts.port,
|
|
5970
|
+
host: opts.host,
|
|
5137
5971
|
open: opts.open,
|
|
5138
5972
|
cwd: opts.cwd ?? process.cwd(),
|
|
5139
5973
|
noRegisterCwd: opts.noRegisterCwd
|
|
5140
5974
|
});
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
|
|
5975
|
+
if (opts.recordRuntime) {
|
|
5976
|
+
await upsertProcess({
|
|
5977
|
+
pid: process.pid,
|
|
5978
|
+
port: handle.port,
|
|
5979
|
+
url: handle.url,
|
|
5980
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5981
|
+
});
|
|
5982
|
+
console.log(`Stop with: yorz serve stop`);
|
|
5983
|
+
} else {
|
|
5984
|
+
console.log(`Stop with: Ctrl-C`);
|
|
5985
|
+
}
|
|
5148
5986
|
const shutdown = async () => {
|
|
5149
5987
|
console.log("\nShutting down YorZ Service…");
|
|
5150
5988
|
try {
|
|
5151
5989
|
await handle.close();
|
|
5152
5990
|
} finally {
|
|
5153
|
-
await removeRuntimeForPid(process.pid);
|
|
5991
|
+
if (opts.recordRuntime) await removeRuntimeForPid(process.pid);
|
|
5154
5992
|
process.exit(0);
|
|
5155
5993
|
}
|
|
5156
5994
|
};
|
|
@@ -5211,10 +6049,12 @@ function startBackgroundServe(opts) {
|
|
|
5211
6049
|
function backgroundArgs(opts) {
|
|
5212
6050
|
const args = ["--foreground"];
|
|
5213
6051
|
if (opts.port !== void 0) args.push("--port", String(opts.port));
|
|
6052
|
+
if (opts.host !== void 0) args.push("--host", opts.host);
|
|
5214
6053
|
if (opts.open) args.push("--open");
|
|
5215
6054
|
if (opts.cwd) args.push("--cwd", opts.cwd);
|
|
5216
6055
|
if (opts.noRegisterCwd) args.push("--no-register-cwd");
|
|
5217
6056
|
args.push("--skip-skill-check");
|
|
6057
|
+
args.push("--record-runtime");
|
|
5218
6058
|
return args;
|
|
5219
6059
|
}
|
|
5220
6060
|
async function ensureSkillsInstalledWithLog(cwd) {
|
|
@@ -6586,12 +7426,15 @@ program.command("lint [paths...]").description("Lint spec.md / review.md files f
|
|
|
6586
7426
|
if (result.exitCode !== 0) process.exit(result.exitCode);
|
|
6587
7427
|
}
|
|
6588
7428
|
);
|
|
6589
|
-
const serveCmd = program.command("serve").description("Start the YorZ Service (HTTP + SSE + static GUI; multi-project).").option("-p, --port <port>", "port to listen on", "7423").option(
|
|
7429
|
+
const serveCmd = program.command("serve").description("Start the YorZ Service (HTTP + SSE + static GUI; multi-project).").option("-p, --port <port>", "port to listen on", "7423").option(
|
|
7430
|
+
"--host <address>",
|
|
7431
|
+
"bind address (default: 127.0.0.1; the service runs shell commands, so exposing it to the network is opt-in)"
|
|
7432
|
+
).option("--open", "open the GUI in the default browser", false).option("--foreground", "run the service in the foreground", false).option("--background", "run the service in the background (default)", false).option(
|
|
6590
7433
|
"--cwd <path>",
|
|
6591
7434
|
"directory to auto-register when it contains .yorz/ (default: process.cwd())"
|
|
6592
7435
|
).option("--no-register-cwd", "do not auto-register the current directory").addOption(
|
|
6593
7436
|
new Option("--skip-skill-check", "internal: skip the skill install/update check").hideHelp()
|
|
6594
|
-
).action(async (opts) => {
|
|
7437
|
+
).addOption(new Option("--record-runtime", "internal: record foreground process").hideHelp()).action(async (opts) => {
|
|
6595
7438
|
if (opts.foreground && opts.background) {
|
|
6596
7439
|
throw new Error("Use either --foreground or --background, not both");
|
|
6597
7440
|
}
|
|
@@ -6601,11 +7444,13 @@ const serveCmd = program.command("serve").description("Start the YorZ Service (H
|
|
|
6601
7444
|
}
|
|
6602
7445
|
await runServe({
|
|
6603
7446
|
port,
|
|
7447
|
+
host: opts.host,
|
|
6604
7448
|
open: opts.open,
|
|
6605
7449
|
cwd: opts.cwd,
|
|
6606
7450
|
noRegisterCwd: opts.registerCwd === false,
|
|
6607
7451
|
foreground: opts.foreground === true,
|
|
6608
|
-
skipSkillCheck: opts.skipSkillCheck === true
|
|
7452
|
+
skipSkillCheck: opts.skipSkillCheck === true,
|
|
7453
|
+
recordRuntime: opts.recordRuntime === true
|
|
6609
7454
|
});
|
|
6610
7455
|
});
|
|
6611
7456
|
serveCmd.command("stop").description("Stop the background YorZ Service.").action(async () => {
|