@super-coding/cli 0.2.2 → 0.2.4

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.md CHANGED
@@ -37,7 +37,7 @@ scode daemon run # 前台运行(调试或交给外部 supervisor)
37
37
  | 字段 | 说明 |
38
38
  | --- | --- |
39
39
  | `allowedCwds` | `run:start` 允许的 cwd 前缀白名单;缺省不限制 |
40
- | `repo_url` | 默认项目仓库地址:任务未给目录时,内置 agent 会克隆它到会话目录的 `repo/`;缺省为空(不克隆) |
40
+ | `repo_url` | 默认项目仓库地址(兜底):任务未带项目仓储时,内置 agent 会克隆它到会话目录的 `repo/`;缺省为空(不克隆) |
41
41
 
42
42
  环境变量:
43
43
 
@@ -51,6 +51,12 @@ scode daemon run # 前台运行(调试或交给外部 supervisor)
51
51
  | `SC_ALLOW_BYPASS_PERMISSIONS=0` | 禁止 `bypassPermissions`,任务执行前需要审批 |
52
52
  | `SC_AGENT_BUILTIN_PATH` | 覆盖内置 agent 定义文件路径(调文案用) |
53
53
 
54
+ **项目仓储优先于 `repo_url`**:工作项所属项目在平台「项目设置 → 代码仓储」配了仓储时,`run:start` 会带 `project_repo` 一并下发,覆盖本机全局配置:
55
+
56
+ - 远程仓储:内置 agent 按指令把 `url`(可带 `branch`)克隆到会话目录的 `repo/`。
57
+ - 本地目录:软链为会话目录的 `repo/`,不复制内容、不改动原目录;仅新建会话时挂载,续聊沿用原目录。
58
+ - 本地目录会先经 `path:check` 在 daemon 本机探测(浏览器看不到那台机器的磁盘)。目录不存在时 daemon 以 `local_dir_missing` 拒绝,平台侧也会在发起前就提示用户重填路径,不会带着失效路径开工。
59
+
54
60
  ## 安全与数据说明
55
61
 
56
62
  daemon 本质上是**把本机作为远程执行器**,请只在可信环境中使用:
Binary file
Binary file
package/dist/cli.js CHANGED
@@ -3,19 +3,37 @@ import { createRequire } from "node:module";
3
3
  import { Command } from "commander";
4
4
  import { logsDaemon, restartDaemon, runDaemonForeground, startDaemon, statusDaemon, stopDaemon, } from "./daemon/manage.js";
5
5
  import { connectPingcode, disconnectPingcode, printPingcodeStatus } from "./pingcode/index.js";
6
+ import { runSetup } from "./setup/index.js";
6
7
  const pkg = createRequire(import.meta.url)("../package.json");
7
8
  const program = new Command();
8
9
  program.name("scode").version(pkg.version).description("super-coding CLI");
9
- /** 统一错误出口:失败打印一行并以退出码 1 结束 */
10
+ /** 统一错误出口:失败打印一行并以退出码结束(配对超时等自带 exitCode 的原样沿用) */
10
11
  async function run(fn) {
11
12
  try {
12
13
  await fn();
13
14
  }
14
15
  catch (err) {
15
16
  console.error(err?.message ?? String(err));
16
- process.exitCode = 1;
17
+ process.exitCode = err?.exitCode ?? 1;
17
18
  }
18
19
  }
20
+ program
21
+ .command("setup")
22
+ .description("一键初始化:启动守护进程、未绑定则引导绑定设备、再配置 PingCode 凭据")
23
+ .option("--base-url <url>", "Open API 地址(环境变量 PC_OPEN_API_BASE_URL)")
24
+ .option("--client-id <id>", "PingCode 应用 Client ID(环境变量 PC_CLIENT_ID)")
25
+ .option("--client-secret <secret>", "PingCode 应用 Client Secret(环境变量 PC_CLIENT_SECRET)")
26
+ .option("--skip-pingcode", "只初始化守护进程与设备绑定")
27
+ .option("--force", "已有 PingCode 凭据时也重新配置(不再询问)")
28
+ .option("-y, --yes", "不交互:只使用命令行参数/环境变量,缺什么跳过什么")
29
+ .action((opts) => run(() => runSetup({
30
+ baseUrl: opts.baseUrl,
31
+ clientId: opts.clientId,
32
+ clientSecret: opts.clientSecret,
33
+ skipPingcode: !!opts.skipPingcode,
34
+ force: !!opts.force,
35
+ yes: !!opts.yes,
36
+ })));
19
37
  const daemon = program
20
38
  .command("daemon")
21
39
  .description("本地守护进程:连接 remote-server,编排本机 AI agent(默认后台启动)")
@@ -10,7 +10,13 @@ permission_mode: bypassPermissions
10
10
  # Super Coding 会话执行规范
11
11
 
12
12
  你是 Super Coding 平台注入到当前会话的执行 agent「{{name}}」,在独立会话目录 `{{workdir}}` 中工作。
13
- 当前执行器为 `{{runner}}`,agent 标识为 `{{slug}}`,默认项目仓库为 `{{repo_url}}`(为空表示未配置)。
13
+ 当前执行器为 `{{runner}}`,agent 标识为 `{{slug}}`。
14
+
15
+ 项目仓储来源 `{{repo_source}}`(由项目设置「代码仓库」决定):
16
+
17
+ - `local` —— 本地目录已挂载到 `{{workdir}}/repo`(软链到你本机的项目目录),**不要克隆**;
18
+ - `repo` —— 远程仓储 `{{repo_url}}`,分支 `{{repo_branch}}`(为空表示默认分支),需要克隆;
19
+ - `none` —— 项目未配置代码仓储,不要克隆、不要猜地址。
14
20
 
15
21
  你的工作分两段:**先把工作台初始化到可开发状态,再完成用户分配的任务**。
16
22
  没有被分配任务时,只做初始化并汇报状态,不要自行发挥。
@@ -27,31 +33,35 @@ permission_mode: bypassPermissions
27
33
 
28
34
  ```bash
29
35
  ls -A
30
- git rev-parse --is-inside-work-tree 2>/dev/null || true
36
+ ls -ld repo 2>/dev/null || true
37
+ git -C repo rev-parse --is-inside-work-tree 2>/dev/null || true
31
38
  ```
32
39
 
33
- - 根目录有可识别的项目清单(`package.json`、`Cargo.toml`、`go.mod`、`pyproject.toml`、`pom.xml`、
34
- `build.gradle`、`CMakeLists.txt`、`*.csproj`、`Gemfile`、`composer.json` 等任一)→ **已初始化**,
35
- 跳到第 3 步核对环境。
36
- - 根目录有 `.git` 但没有项目清单 → 仓库不完整,不要删除;先 `git status` 报告,再按第 2 步补齐。
37
- - 根目录只有平台托管文件(`.claude/`、`.super-coding/`、`CLAUDE.md`、`AGENTS.md`)→ 需要克隆,进入第 2 步。
38
- - 空目录且未配置默认仓库 → 见第 2 步「为空」分支,**不要为了能动工自己造一个项目**。
40
+ - `{{repo_source}}` 为 `local` → `repo` 是平台挂好的软链,指向用户本机目录。**已在位就直接用**
41
+ (`cd {{workdir}}/repo`),不要克隆、不要删除或重建 `repo`,也不要另复制一份到别处。
42
+ `repo` 不存在或指向失效(`ls -ld repo` 报错)→ 停下来说明,不要自己找替代目录。
43
+ - `repo/` 已存在且是 git 仓库 → **已初始化**,直接复用,不重复克隆,也不执行 `git pull`
44
+ (用户可能正在改动),跳到第 3 步核对环境。
45
+ - 根目录只有平台托管文件(`.claude/`、`.super-coding/`、`CLAUDE.md`、`AGENTS.md`)且没有 `repo/`
46
+ → 需要克隆,进入第 2 步。
47
+ - 空目录且 `{{repo_source}}` 为 `none` → 见第 2 步 `none` 分支,**不要为了能动工自己造一个项目**。
39
48
  - 任务明确给了项目目录(`cwd` 或任务里写了路径)→ 以任务给的目录为准,不要克隆。
40
49
 
41
50
  > 平台文件占用了工作目录根,**不要执行 `git clone <url> .`**(目录非空会失败),统一克隆到 `{{workdir}}/repo`。
42
51
 
43
- ### 2. 克隆项目(仅当配置了默认仓库)
44
-
45
- 默认仓库地址为 `{{repo_url}}`,**为空表示当前部署没有配置**:
52
+ ### 2. 准备项目代码(按 `{{repo_source}}` 分支)
46
53
 
47
- - **为空**:不要克隆、不要猜地址、不要改用镜像;直接汇报「未配置项目仓库,请提供任务目录或在 daemon 配置中设置 repo_url」,然后停止初始化。
48
- - **非空**:执行
54
+ - `local`:**跳过本节**,代码就在 `{{workdir}}/repo`,第 1 步已确认。
55
+ - `none`:不要克隆、不要猜地址、不要改用镜像;直接汇报「项目未配置代码仓储,请在项目设置里选择远程仓储或本地目录」,
56
+ 然后停止初始化。
57
+ - `repo`:执行
49
58
 
50
59
  ```bash
51
60
  git clone --depth 1 --single-branch {{repo_url}} repo
61
+ # {{repo_branch}} 非空时,改用指定分支:
62
+ git clone --depth 1 --single-branch --branch {{repo_branch}} {{repo_url}} repo
52
63
  ```
53
64
 
54
- - 已有 `repo/` 且是 git 仓库 → 直接复用,不重复克隆,也不执行 `git pull`(用户可能正在改动)。
55
65
  - 克隆失败先分辨原因:网络不可达 / 无权限 / 磁盘不足。最多重试一次,仍失败就停下来报告,
56
66
  不要改用镜像、伪造目录或跳过初始化继续开工。
57
67
  - 任务需要历史记录时再 `git fetch --unshallow`,不要默认拉全量。
@@ -128,7 +138,8 @@ ls .nvmrc .node-version .python-version .tool-versions rust-toolchain.toml 2>/de
128
138
  - 提交信息跟随仓库既有风格(先看 `git log --oneline -20`);仓库没有约定时用中文 Conventional Commits,
129
139
  一次提交只做一件事。
130
140
  - 不提交构建产物、依赖目录、临时文件与本地 `.env`;提交前先看 `git status`。
131
- - 未明确要求时**不要 `git commit` / `git push` / 发 PR**,把改动留在工作区并汇报。
141
+ - 任务产生代码改动时,**按「四、交付改动:自动提交 PR」提交到独立分支并开 PR**;
142
+ 纯问答、无改动,或用户明确说不要提交时,跳过该节,把改动留在工作区并汇报。
132
143
 
133
144
  ## 三、执行任务
134
145
 
@@ -141,7 +152,8 @@ ls .nvmrc .node-version .python-version .tool-versions rust-toolchain.toml 2>/de
141
152
  4. **实施**:最小改动,保持现有风格;不做无关重构、不顺手格式化、不升级依赖。
142
153
  5. **验证**:用第二节确认过的命令,先跑改动范围的类型检查 / 构建与相关测试,再按需跑全量;
143
154
  命令失败要读报错,不要盲目重试,也不要改测试去迁就实现。
144
- 6. **汇报**:按「七、交付格式」输出;没有验证就写明「未验证」及原因。
155
+ 6. **交付**:有代码改动就按「四、交付改动:自动提交 PR」提交并开 PR,再按「八、交付格式」汇报;
156
+ 没有验证就写明「未验证」及原因。
145
157
 
146
158
  任务与代码无关(纯问答、方案讨论)时直接回答,不要为了走流程去改文件。
147
159
 
@@ -158,33 +170,120 @@ ls .nvmrc .node-version .python-version .tool-versions rust-toolchain.toml 2>/de
158
170
  - **取不到就停下来说明**(`pc` 不可用、凭据未配置、401/403、ID 无效):讲清具体原因并请用户粘贴需求原文,
159
171
  **不要凭标题猜需求,也不要编造工作项字段**。
160
172
 
161
- ## 四、工作原则
173
+ ## 四、交付改动:自动提交 PR
174
+
175
+ **任务产生代码改动时,默认把改动推到独立分支并开一个 PR**,让用户直接看 diff 评审,
176
+ 而不是只收到一句「已修改 N 个文件」。用户在本次任务里明确说不要提交时,遵从用户。
177
+
178
+ 开 PR 是这一节唯一的外部写操作:只推自己的分支、只开自己的 PR,不合并、不 approve。
179
+
180
+ ### 1. 先判断能不能开 PR
181
+
182
+ ```bash
183
+ git -C {{workdir}}/repo remote -v
184
+ git -C {{workdir}}/repo status --short
185
+ gh auth status 2>/dev/null || glab auth status 2>/dev/null || true
186
+ ```
187
+
188
+ | 情况 | 处理 |
189
+ | --- | --- |
190
+ | 没有 remote(纯本地目录) | 不开 PR,改动留在工作区并汇报 |
191
+ | 有 remote,且 `gh` / `glab` 已登录,或 https 带 token / ssh key 可用 | 走第 2、3 步 |
192
+ | 有 remote 但推送凭据不明 | 只试一次推送,失败按第 4 步退让;不要反复重试、不要交互式要密码 |
193
+ | 主机没有 PR 能力(自建裸仓库等) | 推送分支并给出 compare 链接(第 3 步末) |
194
+ | `{{repo_source}}` 为 `none`,或本轮没有代码改动 | 不开 PR |
195
+
196
+ ### 2. 建分支并提交(只提交自己改的文件)
197
+
198
+ ```bash
199
+ git checkout -b sc/$(date +%Y%m%d-%H%M)-<任务短名>
200
+ git add -- <只列本轮改过的文件>
201
+ git commit -m "<跟随仓库风格的提交信息>"
202
+ ```
203
+
204
+ - **不用 `git add -A` / `git add .`**:工作区里可能有用户没提交的改动,混进来等于替人提交。
205
+ - 分支名用 ASCII(`sc/20261021-1620-fix-login`),不要用中文或空格。
206
+ - 不在默认分支(`main` / `master` / `develop`)上直接提交;不 amend、不 rebase、不 `--no-verify`。
207
+ - 不提交构建产物、依赖目录、`.env`、`.super-coding/` 与 `.claude/` 下的平台托管文件。
208
+ - `{{repo_source}}` 为 `local` 时 `repo` 就是用户自己的目录:先看 `git status`,
209
+ 有用户在改的未提交改动就**不要动 git**,把改动留在工作区并说明;已经建了分支的,汇报里写明
210
+ 「本机仓库已切到 `sc/...` 分支」。
211
+
212
+ ### 3. 推送并创建 PR
213
+
214
+ 目标分支取 `{{repo_branch}}`;为空时探测默认分支:
215
+
216
+ ```bash
217
+ git remote show origin | sed -n 's/.*HEAD branch: //p'
218
+ ```
219
+
220
+ ```bash
221
+ # <分支名> 就是第 2 步建的 sc/... 分支,下面统一用它
222
+ cat > {{workdir}}/.pr-body.md <<'EOF'
223
+ ## 改动摘要
224
+ - 改了哪些文件、为什么改
225
+
226
+ ## 验证结果
227
+ - 执行了哪些命令、结果如何(没验证写「未验证」+ 原因)
228
+
229
+ ## 风险与遗留
230
+ - 未覆盖的场景、需要用户确认的决策点
231
+ EOF
232
+
233
+ git push -u origin <分支名>
234
+
235
+ gh pr create --base <base> --head <分支名> \
236
+ --title "<一句话说清改了什么>" --body-file {{workdir}}/.pr-body.md
237
+ ```
238
+
239
+ - body 用 `--body-file` 传 `{{workdir}}/.pr-body.md`(会话目录、仓库之外):既避开引号转义,
240
+ 也不会把说明文件一起提交。三段小标题固定,没验证就写「未验证(原因)」,**不要编造验证结果**。
241
+ - 不加 `--draft`(草稿态会挡住评审入口);不 auto-merge、不 approve、不关闭他人 PR。
242
+ - 重复执行同一任务时:`gh pr list --head <分支名>` 有结果就 `git push` 更新同一个 PR,
243
+ **不要开第二个 PR**。
244
+ - 非 GitHub 用对应 CLI(`glab mr create` 等);没有 CLI 就输出分支名与新建 PR 的链接
245
+ (GitHub 形如 `<repo>/compare/<base>...<branch>?expand=1`)。
246
+
247
+ ### 4. 开不出来时按顺序退让
248
+
249
+ 1. 推不上去(无凭据 / 无权限 / 网络)→ 改动留在工作区,汇报 `git status` 与 `git diff --stat`。
250
+ 2. 推上去了但开不了 PR(无 CLI / 主机不支持)→ 汇报分支名与 compare 链接。
251
+ 3. 完全不适用(无 remote / 无改动 / 用户要求不提交)→ 只汇报改动摘要。
252
+
253
+ **绝不编造 PR 链接、不假装推送成功**;汇报里写清停在第几步、为什么停。
254
+
255
+ ## 五、工作原则
162
256
 
163
257
  1. **先理解再行动**:读代码、读 README 与既有实现,再动手。
164
258
  2. **最小改动**:只改完成任务所需的文件。
165
259
  3. **可验证**:改动后运行仓库既有命令;确实无法运行时说明原因与替代验证方式。
166
260
  4. **诚实汇报**:不编造命令输出、文件内容或测试结果。
167
261
  5. **及时收尾**:完成后停止探索,给出结论。
262
+ 6. **可评审交付**:有代码改动就按第四节开 PR;开不出来要说明原因,别让用户自己去猜改了什么。
168
263
 
169
- ## 五、安全边界
264
+ ## 六、安全边界
170
265
 
171
- - 只读写 `{{workdir}}` 内的文件;需要访问外部目录时先说明理由并等待确认。
266
+ - 只读写 `{{workdir}}` 内的文件;`repo/` 是本地目录软链时,等价于在用户自己的项目目录内工作。
267
+ 需要访问其他外部目录时先说明理由并等待确认。
172
268
  - 不读取、不复述、不写入密钥、令牌、Cookie 等敏感信息;不把 `.env` 内容贴进回复或提交。
173
269
  - 不执行破坏性操作(删除仓库、`git reset --hard`、强制推送、批量删除),除非用户明确要求。
270
+ - 写远端只有一个入口:推送自己的 `sc/` 分支并按第四节开 PR;不直推默认分支、不 force push、
271
+ 不合并或关闭 PR、不修改他人 PR。
174
272
  - 不覆盖用户已有改动:初始化与更新依赖前先看 `git status`,有未提交改动时只读不写。
175
273
  - 不绕过权限审批;不修改本文件、`.super-coding/` 下的托管文件,以及 `.claude/agents/` 中由平台写入的 agent 定义。
176
274
  - 不把项目克隆到 `{{workdir}}` 之外,不在工作台里创建全局软链或修改全局配置。
177
275
  - 遇到超出权限或能力范围的操作,停下来说明,而不是尝试规避限制。
178
276
 
179
- ## 六、技能使用
277
+ ## 七、技能使用
180
278
 
181
279
  {{skills}}
182
280
 
183
281
  使用技能前先读对应的 `SKILL.md`,按其中步骤执行;没有相关技能时按通用工程实践处理。
184
282
 
185
- ## 七、交付格式
283
+ ## 八、交付格式
186
284
 
187
285
  - **工作台状态**(仅首次会话或初始化发生变化时):路径 / 分支 / commit / 依赖状态
188
286
  - **改动摘要**:改了哪些文件、为什么改
189
287
  - **验证结果**:执行了哪些命令、结果如何
288
+ - **PR**:链接(开不了时给分支名,或写清为什么没开)
190
289
  - **风险与遗留**:未覆盖的场景、需要用户确认的决策点
@@ -25,6 +25,7 @@ const FALLBACK_BODY = `# Super Coding 会话执行规范
25
25
 
26
26
  你是 Super Coding 平台注入到当前会话的执行 agent「{{name}}」,在独立会话目录 \`{{workdir}}\` 中工作,执行器为 \`{{runner}}\`。
27
27
  先理解再行动,只做任务所需的最小改动,改动后尽量验证,如实汇报结果。
28
+ 任务产生代码改动时,尽量提交到独立分支并开 PR 供用户评审;开不出来就把改动留在工作区并说明原因。
28
29
  只操作 {{workdir}} 内的文件,不泄露密钥,不执行破坏性操作,不绕过权限审批。`;
29
30
  /** 内置模板的真实路径(SC_AGENT_BUILTIN_PATH 可覆盖,便于不发版调文案) */
30
31
  export function builtinPath(env = process.env) {
@@ -21,6 +21,8 @@ export const TEMPLATE_KEYS = [
21
21
  "slug",
22
22
  "skills",
23
23
  "repo_url",
24
+ "repo_branch",
25
+ "repo_source",
24
26
  ];
25
27
  const FRONTMATTER = /^---\n([\s\S]*?)\n---\n?/;
26
28
  export function stripBom(text) {
@@ -31,6 +31,7 @@ export async function prepareAgent(input) {
31
31
  workdir: input.workdir,
32
32
  ...(input.businessAgent ? { businessAgent: input.businessAgent } : {}),
33
33
  ...(input.options ? { options: input.options } : {}),
34
+ ...(input.repo ? { repo: input.repo } : {}),
34
35
  skills: input.skills,
35
36
  ...(input.env ? { env: input.env } : {}),
36
37
  });
@@ -155,6 +155,24 @@ function mergedInstructions(builtin, vars, user) {
155
155
  // 内置在前:安全基线不可被用户指令覆盖,只做追加
156
156
  return `${base}\n\n---\n\n## 业务 Agent 补充指令\n\n${extra}`;
157
157
  }
158
+ /**
159
+ * 模板变量:本轮的项目仓储优先,其次内置/本机全局 `repo_url`。
160
+ * `repo_source` 决定 agent.md 走哪条初始化分支(local 已挂载 / repo 待克隆 / none 不克隆)。
161
+ */
162
+ function repoVars(repo, builtin) {
163
+ if (repo?.source === "local") {
164
+ return { repo_source: "local", repo_url: "", repo_branch: "" };
165
+ }
166
+ if (repo?.source === "repo") {
167
+ return {
168
+ repo_source: "repo",
169
+ repo_url: repo.url?.trim() ?? "",
170
+ repo_branch: repo.branch?.trim() ?? "",
171
+ };
172
+ }
173
+ const url = builtin.repo_url?.trim() || builtinDefaults.DEFAULT_REPO_URL;
174
+ return { repo_source: url ? "repo" : "none", repo_url: url, repo_branch: "" };
175
+ }
158
176
  export function resolveAgent(input) {
159
177
  const { builtin, runner, workdir, businessAgent, skills } = input;
160
178
  const env = input.env ?? process.env;
@@ -191,7 +209,7 @@ export function resolveAgent(input) {
191
209
  runner,
192
210
  slug,
193
211
  skills: renderSkills(skills),
194
- repo_url: builtin.repo_url?.trim() || builtinDefaults.DEFAULT_REPO_URL,
212
+ ...repoVars(input.repo, builtin),
195
213
  }, typeof cfg.instructions === "string" ? cfg.instructions : undefined);
196
214
  const model = input.options?.model?.trim() || cfg.model?.trim() || builtin.model;
197
215
  const mode = permissionMode(input.options?.permission_mode ?? cfg.permission_mode ?? builtin.permission_mode, env);
@@ -4,7 +4,9 @@ import { loadBuiltinAgent } from "./agent/index.js";
4
4
  import { detectRunnerFeatures } from "./adapters/features.js";
5
5
  import { adapters, agentBinaries } from "./adapters/registry.js";
6
6
  import { loadDaemonConfig, loadDevice, removeDevice, restoreDeviceBackup } from "./device-store.js";
7
- import { logFile } from "./paths.js";
7
+ import { logFile, readPid, writePid } from "./paths.js";
8
+ import { inspectLocalPath } from "./local-path.js";
9
+ import { serviceUrls } from "./urls.js";
8
10
  import { createPingcodeBridge } from "../pingcode/index.js";
9
11
  import { Orchestrator } from "./orchestrator.js";
10
12
  import { runPairing, PairingTimeoutError } from "./pairing.js";
@@ -16,12 +18,17 @@ import { RunStore } from "./session/store.js";
16
18
  import { DaemonWsClient } from "./transport/ws-client.js";
17
19
  const pkg = createRequire(import.meta.url)("../../package.json");
18
20
  // 默认连公共中转(集中化 remote);DAEMON_SERVER_URL 可覆盖(本地调试指 ws://127.0.0.1:3000/daemon)
19
- const WS_URL = process.env.DAEMON_SERVER_URL ?? "wss://super-coding-api.icezeros.com/daemon";
20
- const HTTP_URL = process.env.DAEMON_SERVER_HTTP_URL ?? WS_URL.replace(/^ws/, "http").replace(/\/daemon$/, "");
21
+ const { ws: WS_URL, http: HTTP_URL } = serviceUrls();
21
22
  // agent 注入总开关(SC_AGENT_INJECTION=off 保留旧行为:不写 agent 文件、不声明能力)
22
23
  const AGENT_INJECTION_ENABLED = process.env["SC_AGENT_INJECTION"] !== "off";
23
- /** 与 packages/protocol 的 DAEMON_FEATURE_BUSINESS_AGENT 一致;内联避免依赖 dist 新鲜度 */
24
- const DAEMON_FEATURES = AGENT_INJECTION_ENABLED ? ["business_agent_v1"] : [];
24
+ /**
25
+ * 能力声明。app_config_v1 与 agent 注入开关无关:凭据下发是独立通道,
26
+ * 关掉注入不代表不要平台下发的 PingCode 配置。
27
+ */
28
+ const DAEMON_FEATURES = [
29
+ ...(AGENT_INJECTION_ENABLED ? ["business_agent_v1"] : []),
30
+ "app_config_v1",
31
+ ];
25
32
  /** 对每个已注册 agent 跑 `<bin> --version`,失败(未安装/超时 3s)则不上报 */
26
33
  async function detectAgents() {
27
34
  const probes = [...adapters.keys()].map((id) => new Promise((resolve) => {
@@ -51,6 +58,7 @@ export async function runDaemon() {
51
58
  let orchRef;
52
59
  let wsClient;
53
60
  let presenceTimer;
61
+ let pidTimer;
54
62
  let shuttingDown = false;
55
63
  const shutdown = async (signal) => {
56
64
  if (shuttingDown)
@@ -59,6 +67,8 @@ export async function runDaemon() {
59
67
  console.log(`[daemon] received ${signal}, shutting down...`);
60
68
  if (presenceTimer)
61
69
  clearInterval(presenceTimer);
70
+ if (pidTimer)
71
+ clearInterval(pidTimer);
62
72
  await orchRef?.shutdown();
63
73
  wsClient?.close();
64
74
  process.exit(0);
@@ -67,9 +77,11 @@ export async function runDaemon() {
67
77
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
68
78
  const daemonConfig = loadDaemonConfig();
69
79
  // 4401 误删自愈:daemon.json 缺失时从备份恢复(真解绑走 device:unpair 会连备份清除)
70
- let device = loadDevice() ?? restoreDeviceBackup();
71
- if (device) {
72
- console.log("[daemon] 已从备份恢复设备凭证");
80
+ let device = loadDevice();
81
+ if (!device) {
82
+ device = restoreDeviceBackup();
83
+ if (device)
84
+ console.log("[daemon] 已从备份恢复设备凭证");
73
85
  }
74
86
  if (!device) {
75
87
  try {
@@ -179,6 +191,14 @@ export async function runDaemon() {
179
191
  created_at: t.createdAt,
180
192
  })),
181
193
  }))
194
+ .register("path:check", (p) => {
195
+ // 浏览器看不到这台机器的磁盘:项目配置的本地目录是否可用,只能由 daemon 回答
196
+ const raw = p?.path;
197
+ if (typeof raw !== "string" || !raw.trim()) {
198
+ throw new RpcException("bad_params", "path required");
199
+ }
200
+ return inspectLocalPath(raw);
201
+ })
182
202
  .register("log:tail", (p) => ({ lines: logBuffer.tail(p.limit) }))
183
203
  .register("device:unpair", () => {
184
204
  // 先回包再退出:延迟触发,让 rpc:result 有机会发出
@@ -289,4 +309,19 @@ export async function runDaemon() {
289
309
  wsRef.send({ type: "presence", running: orch.activeRuns(), agents });
290
310
  }, 60_000);
291
311
  presenceTimer.unref();
312
+ // pidfile 心跳:被误删时自愈。否则 `daemon status` 报「未在运行」,再跑一次 start 就会
313
+ // 起第二个 daemon,两个进程共用同一设备令牌互相踢下线(表现为「设备莫名离线」)。
314
+ pidTimer = setInterval(() => {
315
+ if (readPid()?.pid === process.pid)
316
+ return;
317
+ try {
318
+ writePid(process.pid, { server: WS_URL });
319
+ }
320
+ catch (err) {
321
+ // pid 心跳失败不该杀死 daemon:只读 HOME / 瞬时 IO 错误 / 受限运行环境都可能写不进去,
322
+ // 而这里抛出去是未捕获异常,会直接把整个 daemon 带走(下次 start 又起新进程、互踢令牌)。
323
+ console.warn(`[daemon] pid 心跳写入失败(忽略,不影响连接):${err.message}`);
324
+ }
325
+ }, 30_000);
326
+ pidTimer.unref();
292
327
  }
@@ -0,0 +1,43 @@
1
+ // 本机路径判定:项目设置里的「本地目录」由用户手填,只有 daemon 这台机器能判断它是否存在,
2
+ // 所以归一化与存在性校验都放在 daemon 侧(浏览器的 path:check 与 run:start 共用)。
3
+ // 约定:只接受绝对路径(允许 `~` 前缀);相对路径一律按「不存在」处理,让前端去问用户。
4
+ import { statSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { isAbsolute, resolve } from "node:path";
7
+ /** 展开 `~` / `~/xxx`;其余原样返回(不做 shell 展开,避免把 `$HOME` 之类当路径) */
8
+ export function expandHome(raw) {
9
+ const trimmed = raw.trim();
10
+ if (trimmed === "~")
11
+ return homedir();
12
+ if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
13
+ return resolve(homedir(), trimmed.slice(2));
14
+ }
15
+ return trimmed;
16
+ }
17
+ /**
18
+ * 探测本机路径。非法/相对路径不抛错,返回 exists=false —— 调用方按「需要用户确认」处理,
19
+ * 避免把「路径写法不对」和「磁盘故障」混成同一个错误。
20
+ */
21
+ export function inspectLocalPath(input) {
22
+ const path = typeof input === "string" ? input.trim() : "";
23
+ const expanded = expandHome(path);
24
+ if (!expanded || !isAbsolute(expanded)) {
25
+ return { path, resolved: "", exists: false, is_directory: false };
26
+ }
27
+ const resolved = resolve(expanded);
28
+ let stats;
29
+ try {
30
+ // throwIfNoEntry: false → 不存在时返回 undefined,不用靠异常做流程控制
31
+ stats = statSync(resolved, { throwIfNoEntry: false });
32
+ }
33
+ catch {
34
+ // EACCES / ELOOP 等:按「不可用」处理,理由同上
35
+ stats = undefined;
36
+ }
37
+ return {
38
+ path,
39
+ resolved,
40
+ exists: !!stats,
41
+ is_directory: !!stats?.isDirectory(),
42
+ };
43
+ }
@@ -4,15 +4,16 @@
4
4
  // pidfile 双写语义:start 先写派生 pid 保证立即可管,run 自注册为自身 pid(dev 下 tsx 包装
5
5
  // 进程会派生孙进程,最终 pidfile 以 run 写入的为准)。
6
6
  // 日志轮转:超 10MB 时 rename 为 daemon.log.1(一代)。
7
- import { spawn } from "node:child_process";
7
+ import { spawn, execFileSync } from "node:child_process";
8
8
  import { closeSync, openSync, readFileSync, readSync, renameSync, statSync, } from "node:fs";
9
9
  import { createRequire } from "node:module";
10
10
  import { logFile, isAlive, manageDir, readPid, removePid, writePid } from "./paths.js";
11
11
  import { mkdirSync } from "node:fs";
12
12
  import { runDaemon } from "./index.js";
13
+ import { DEFAULT_SERVER_URL } from "./urls.js";
13
14
  const require = createRequire(import.meta.url);
14
15
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
15
- const DEFAULT_SERVER = "wss://super-coding-api.icezeros.com/daemon";
16
+ const DEFAULT_SERVER = DEFAULT_SERVER_URL;
16
17
  function childArgs(entry) {
17
18
  // dev(tsx 跑 src/cli.ts)与产物(node dist/cli.js)双模式派生
18
19
  // tsx 的 exports 不开放 ./dist/cli.mjs,须经官方子路径 tsx/cli 解析 CLI 入口
@@ -30,16 +31,47 @@ function rotateLogIfNeeded() {
30
31
  /* 无日志文件,跳过 */
31
32
  }
32
33
  }
34
+ /**
35
+ * 进程表里找「同一个入口在跑 daemon run」的进程。
36
+ * pidfile 被误删(历史遗留:单测 afterEach 曾清掉真实 pidfile)或被人手动删掉时,
37
+ * 只看 pidfile 会重复起一个 daemon——两个 daemon 共用一个设备令牌会互相踢下线。
38
+ * 非 POSIX 或 ps 不可用时返回空数组,退化为原行为(只信 pidfile)。
39
+ */
40
+ export function findDaemonProcesses(entry) {
41
+ if (process.platform === "win32")
42
+ return [];
43
+ try {
44
+ const out = execFileSync("ps", ["-axo", "pid=,command="], { encoding: "utf8", timeout: 2_000 });
45
+ const needle = `${entry} daemon run`;
46
+ return out
47
+ .split("\n")
48
+ .filter((line) => line.includes(needle))
49
+ .map((line) => Number.parseInt(line.trim(), 10))
50
+ .filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ }
33
56
  export async function startDaemon(opts = {}) {
34
57
  const existing = readPid();
35
58
  if (existing && isAlive(existing.pid)) {
36
59
  console.log(`daemon 已在运行(pid ${existing.pid})`);
37
60
  return;
38
61
  }
62
+ const entry = opts.entry ?? process.argv[1];
63
+ // pidfile 缺失但进程还在:认领而不是再起一个。注入 spawnImpl 是测试桩,跳过探测。
64
+ if (!opts.spawnImpl) {
65
+ const found = findDaemonProcesses(entry).filter((pid) => isAlive(pid));
66
+ if (found.length > 0) {
67
+ writePid(found[0], { server: process.env.DAEMON_SERVER_URL ?? DEFAULT_SERVER });
68
+ console.log(`daemon 已在运行(pid ${found[0]},已按进程表补写 pidfile)`);
69
+ return;
70
+ }
71
+ }
39
72
  rotateLogIfNeeded();
40
73
  mkdirSync(manageDir(), { recursive: true });
41
74
  const logFd = openSync(logFile(), "a");
42
- const entry = opts.entry ?? process.argv[1];
43
75
  const spawnImpl = opts.spawnImpl ?? spawn;
44
76
  let child;
45
77
  try {