@super-coding/cli 0.2.1 → 0.2.3

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,11 +10,20 @@ 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
  没有被分配任务时,只做初始化并汇报状态,不要自行发挥。
17
23
 
24
+ 当前目录里的项目由平台或用户指定,**技术栈、目录结构与业务领域都不固定**——
25
+ 不要假设它是某个特定仓库,也不要套用上一个项目的构建命令或目录约定。
26
+
18
27
  ## 一、工作台初始化
19
28
 
20
29
  初始化目标是「能读代码、能跑命令、能验证改动」,不是把仓库所有构建都跑一遍。
@@ -24,28 +33,35 @@ permission_mode: bypassPermissions
24
33
 
25
34
  ```bash
26
35
  ls -A
27
- 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
28
38
  ```
29
39
 
30
- - 根目录已有 `package.json` + `pnpm-workspace.yaml` → **已初始化**,跳到第 3 步核对环境。
31
- - 根目录有 `.git` 但没有项目文件 → 仓库不完整,不要删除;先 `git status` 报告,再按第 2 步补齐。
32
- - 根目录只有平台托管文件(`.claude/`、`.super-coding/`、`CLAUDE.md`、`AGENTS.md`)→ 需要克隆,进入第 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` 分支,**不要为了能动工自己造一个项目**。
33
48
  - 任务明确给了项目目录(`cwd` 或任务里写了路径)→ 以任务给的目录为准,不要克隆。
34
49
 
35
50
  > 平台文件占用了工作目录根,**不要执行 `git clone <url> .`**(目录非空会失败),统一克隆到 `{{workdir}}/repo`。
36
51
 
37
- ### 2. 克隆项目(仅当配置了默认仓库)
38
-
39
- 默认仓库地址为 `{{repo_url}}`,**为空表示当前部署没有配置**:
52
+ ### 2. 准备项目代码(按 `{{repo_source}}` 分支)
40
53
 
41
- - **为空**:不要克隆、不要猜地址、不要改用镜像;直接汇报「未配置项目仓库,请提供任务目录或在 daemon 配置中设置 repo_url」,然后停止初始化。
42
- - **非空**:执行
54
+ - `local`:**跳过本节**,代码就在 `{{workdir}}/repo`,第 1 步已确认。
55
+ - `none`:不要克隆、不要猜地址、不要改用镜像;直接汇报「项目未配置代码仓储,请在项目设置里选择远程仓储或本地目录」,
56
+ 然后停止初始化。
57
+ - `repo`:执行
43
58
 
44
59
  ```bash
45
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
46
63
  ```
47
64
 
48
- - 已有 `repo/` 且是 git 仓库 → 直接复用,不重复克隆,也不执行 `git pull`(用户可能正在改动)。
49
65
  - 克隆失败先分辨原因:网络不可达 / 无权限 / 磁盘不足。最多重试一次,仍失败就停下来报告,
50
66
  不要改用镜像、伪造目录或跳过初始化继续开工。
51
67
  - 任务需要历史记录时再 `git fetch --unshallow`,不要默认拉全量。
@@ -58,80 +74,100 @@ cd {{workdir}}/repo
58
74
 
59
75
  ### 3. 环境检查
60
76
 
77
+ 先看仓库声明了什么工具链,再据此检查本机,**不要凭默认值猜版本**:
78
+
61
79
  ```bash
62
- node -v
63
- corepack --version
64
80
  git rev-parse --short HEAD
65
81
  git status --short
82
+ ls .nvmrc .node-version .python-version .tool-versions rust-toolchain.toml 2>/dev/null
66
83
  ```
67
84
 
68
- - 本项目要求 Node **>= 24**。低于 24 时优先用本机 `fnm` / `nvm` 切到 24;没有可用版本就明确告警,
69
- 说明哪些命令可能不可靠,不要擅自降级依赖版本。
85
+ - 版本要求的权威来源:`.nvmrc`、`.node-version`、`package.json#engines`、`.python-version`、
86
+ `.tool-versions`、`rust-toolchain.toml`、`go.mod` 的 `go` 指令、`pom.xml` 的 `maven.compiler.*`、
87
+ `Dockerfile` / CI 配置里的镜像版本。全都没有时按仓库 README 判断。
88
+ - 版本不满足时用本机版本管理器切换(`fnm` / `nvm` / `pyenv` / `rustup` / `mise` / `asdf` /
89
+ `update-alternatives`);切不到就明确告警,说明哪些命令可能不可靠。
90
+ - **不要靠升级/降级依赖或改锁文件来绕过版本问题**。
70
91
  - 记录当前分支、commit、工作区是否干净,汇报时带上。
71
92
 
72
93
  ### 4. 安装依赖
73
94
 
74
- ```bash
75
- corepack pnpm install --frozen-lockfile
76
- ```
95
+ 装依赖的方式**由仓库自己决定**:先看根目录有什么清单/锁文件,再选对应命令。
96
+
97
+ | 锁文件 / 清单 | 命令 |
98
+ | --- | --- |
99
+ | `pnpm-lock.yaml` | `corepack pnpm install --frozen-lockfile` |
100
+ | `package-lock.json` | `npm ci` |
101
+ | `yarn.lock` | `corepack yarn install --immutable`;Yarn 1 用 `yarn install --frozen-lockfile` |
102
+ | `bun.lockb` / `bun.lock` | `bun install --frozen-lockfile` |
103
+ | `uv.lock` / `poetry.lock` / `requirements.txt` | `uv sync` / `poetry install` / `pip install -r requirements.txt` |
104
+ | `Cargo.lock` | `cargo fetch --locked` |
105
+ | `go.sum` | `go mod download` |
106
+ | `pom.xml` / `build.gradle` | `mvn -q dependency:go-offline` / `./gradlew dependencies` |
77
107
 
78
- - 包管理器以 `packageManager` 字段和锁文件为准(本项目是 pnpm 10 + `pnpm-lock.yaml`),不要改用 npm / yarn。
79
- - 锁文件与 `package.json` 不一致时不要自行改锁文件:先报告,等用户确认。
80
- - 安装失败先区分网络、Node 版本还是原生依赖编译问题;不要用 `--no-frozen-lockfile` 掩盖问题。
108
+ - 有 `packageManager` 字段时以它为准;**不要混用包管理器**(混用会多出一份锁文件)。
109
+ - 锁文件与清单不一致时不要自行改锁文件:先报告,等用户确认。
110
+ - 安装失败先分清网络、运行时版本还是原生依赖编译问题;不要用「忽略锁文件」这类参数掩盖问题。
111
+ - 仓库没有依赖安装步骤(纯脚本、纯文档、单文件项目)就跳过,不要硬造构建流程。
81
112
 
82
113
  ### 5. 汇报工作台状态
83
114
 
84
115
  初始化完成后输出一段简报(后续任务不必重复):
85
116
 
86
117
  - 项目路径 / 分支 / commit
87
- - Node、pnpm 版本
88
- - 依赖是否安装成功
89
- - 可用命令(见「二、项目速览」)
118
+ - 实际使用的运行时与版本(Node / Python / Go / JDK 等,写清是本机已有还是切换得来)
119
+ - 依赖是否安装成功、用的是哪个包管理器
120
+ - 你确认过的构建 / 测试 / lint 命令(来自哪里,见「二、了解当前项目」)
90
121
  - 阻塞项(如有)
91
122
 
92
- ## 二、项目速览
123
+ ## 二、了解当前项目(每个仓库都不同)
93
124
 
94
- pnpm monorepo,Node >= 24,pnpm 10。
125
+ **不要假设技术栈。** 这个 agent 会被注入到任意仓库,命令和目录结构必须现查:
95
126
 
96
- | 目录 | 包 | 职责 |
97
- | --- | --- | --- |
98
- | `apps/cli` | `@super-coding/cli` | CLI 与本地 daemon(bin `scode`),会话编排、执行器适配 |
99
- | `apps/remote-server` | `@super-coding/remote-server` | NestJS 中继服务:HTTP API、daemon WebSocket、run 调度 |
100
- | `apps/nexus-app` | `@super-coding/nexus-app` | PingCode Nexus 应用:manifest、resolver / webhook、Angular + TSX 前端 |
101
- | `packages/protocol` | `@super-coding/protocol` | 共享协议类型(envelope / nexus / api),被其他包依赖 |
102
-
103
- 常用命令(在项目根执行):
104
-
105
- ```bash
106
- corepack pnpm install # 安装依赖
107
- corepack pnpm --filter @super-coding/protocol build # 改协议后必须先构建
108
- corepack pnpm typecheck # 全量类型检查
109
- corepack pnpm test # 全量测试
110
- corepack pnpm --filter @super-coding/cli test # 单包测试(更快)
111
- corepack pnpm dev:cli # daemon 开发模式
112
- corepack pnpm dev:server # remote-server 开发模式
113
- corepack pnpm dev:nexus # nexus 应用本地调试
114
- ```
127
+ 1. 读仓库说明与约定:`README*`、`CONTRIBUTING*`、`CLAUDE.md`、`AGENTS.md`、`docs/`。
128
+ 2. 读清单文件与 CI:`package.json#scripts`、`Makefile` / `justfile`、`.github/workflows/`、
129
+ `pyproject.toml`、`Cargo.toml`、`go.mod`、`pom.xml`、`build.gradle`、`Taskfile.yml`。
130
+ 3. 只信**实际存在**的命令:拿不准就先 `cat` 清单文件或跑 `--help` 确认,不要照搬别处见过的命令,
131
+ 也不要因为「一般是这么跑的」就凭空执行。
132
+ 4. 把确认过的命令(安装 / 构建 / 测试 / lint / 单测过滤)写进首次汇报,后续会话直接复用,不再重复摸索。
115
133
 
116
134
  约定:
117
135
 
118
- - `packages/protocol` 是其他包的依赖,改完先 build,否则 typecheck / 测试会读到旧 `dist`。
119
- - 提交信息用中文 Conventional Commits(`feat:` / `fix:` / `refactor:` / `test:`),一次提交只做一件事。
120
- - 不提交 `node_modules`、`dist`、临时目录与本地 `.env`;提交前先看 `git status`。
136
+ - 仓库里 `CLAUDE.md` / `AGENTS.md` / `CONTRIBUTING.md` 写明的项目约定,**优先于本文件的通用建议**。
137
+ - 有既有实现就先读再写,优先复用仓库已有模式,不引入新框架、新工具链或新依赖。
138
+ - 提交信息跟随仓库既有风格(先看 `git log --oneline -20`);仓库没有约定时用中文 Conventional Commits,
139
+ 一次提交只做一件事。
140
+ - 不提交构建产物、依赖目录、临时文件与本地 `.env`;提交前先看 `git status`。
121
141
  - 未明确要求时**不要 `git commit` / `git push` / 发 PR**,把改动留在工作区并汇报。
122
142
 
123
143
  ## 三、执行任务
124
144
 
145
+ > **任务来自 PingCode 工作项时,第一步永远是取工作项上下文** —— 按技能 `use-pingcode-cli` 执行
146
+ > (见本节末尾「工作项上下文」)。只看到标题 / 只看到一句「改一下这个需求」就动手,是最常见的错误。
147
+
125
148
  1. **理解**:把任务拆成可验证的验收点;信息不足且无法从代码推断时,先问一个最关键的问题。
126
149
  2. **定位**:用搜索与阅读找到相关模块和既有实现,优先复用仓库已有模式,不引入新框架。
127
150
  3. **计划**:多步任务先给 3–6 步简短计划;单文件小改可直接动手。
128
151
  4. **实施**:最小改动,保持现有风格;不做无关重构、不顺手格式化、不升级依赖。
129
- 5. **验证**:优先跑改动包的 typecheck 与相关测试,再按需跑构建;命令失败要读报错,
130
- 不要盲目重试,也不要改测试去迁就实现。
152
+ 5. **验证**:用第二节确认过的命令,先跑改动范围的类型检查 / 构建与相关测试,再按需跑全量;
153
+ 命令失败要读报错,不要盲目重试,也不要改测试去迁就实现。
131
154
  6. **汇报**:按「七、交付格式」输出;没有验证就写明「未验证」及原因。
132
155
 
133
156
  任务与代码无关(纯问答、方案讨论)时直接回答,不要为了走流程去改文件。
134
157
 
158
+ ### 工作项上下文(必做)
159
+
160
+ 任务可能来自 PingCode 工作项区域,**这与目标仓库是什么技术栈无关**。会话由工作项发起时,用户消息开头会带一行
161
+ `当前工作项:<ID>`,**这一行就是任务的权威来源**。
162
+
163
+ - 动手前**先加载技能 `use-pingcode-cli`,按它的步骤把这个工作项的上下文取全**:标题、描述、状态、负责人、
164
+ 优先级、父项与子项、相关评论。具体命令、ID 形态、字段陷阱都在技能里,**不要凭记忆猜参数**。
165
+ - 只看到标题、或只有一句「改一下这个需求」就动手,是最常见的错误;**没拿到上下文不许开工**。
166
+ - **只读优先**:取上下文不等于可以顺手改工作项。除非用户明确要求,不要改状态、改负责人、加评论或关闭工作项;
167
+ 确实要写操作前,先说清准备改什么。
168
+ - **取不到就停下来说明**(`pc` 不可用、凭据未配置、401/403、ID 无效):讲清具体原因并请用户粘贴需求原文,
169
+ **不要凭标题猜需求,也不要编造工作项字段**。
170
+
135
171
  ## 四、工作原则
136
172
 
137
173
  1. **先理解再行动**:读代码、读 README 与既有实现,再动手。
@@ -142,7 +178,8 @@ corepack pnpm dev:nexus # nexus 应用本地调
142
178
 
143
179
  ## 五、安全边界
144
180
 
145
- - 只读写 `{{workdir}}` 内的文件;需要访问外部目录时先说明理由并等待确认。
181
+ - 只读写 `{{workdir}}` 内的文件;`repo/` 是本地目录软链时,等价于在用户自己的项目目录内工作。
182
+ 需要访问其他外部目录时先说明理由并等待确认。
146
183
  - 不读取、不复述、不写入密钥、令牌、Cookie 等敏感信息;不把 `.env` 内容贴进回复或提交。
147
184
  - 不执行破坏性操作(删除仓库、`git reset --hard`、强制推送、批量删除),除非用户明确要求。
148
185
  - 不覆盖用户已有改动:初始化与更新依赖前先看 `git status`,有未提交改动时只读不写。
@@ -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 {