@super-coding/cli 0.2.2 → 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 +7 -1
- package/dist/bin/pc-darwin-arm64 +0 -0
- package/dist/bin/pc-linux-x64 +0 -0
- package/dist/cli.js +20 -2
- package/dist/daemon/agent/builtin/agent.md +26 -15
- package/dist/daemon/agent/builtin/parse.js +2 -0
- package/dist/daemon/agent/index.js +1 -0
- package/dist/daemon/agent/resolve.js +19 -1
- package/dist/daemon/index.js +43 -8
- package/dist/daemon/local-path.js +43 -0
- package/dist/daemon/manage.js +35 -3
- package/dist/daemon/orchestrator.js +116 -1
- package/dist/daemon/session/store.js +12 -0
- package/dist/daemon/urls.js +9 -0
- package/dist/pingcode/builtin/SKILL.md +41 -14
- package/dist/pingcode/builtin/reference-cross.md +172 -0
- package/dist/pingcode/builtin/reference-org-wiki.md +127 -0
- package/dist/pingcode/builtin/reference-ship.md +282 -0
- package/dist/pingcode/builtin/reference-testhub.md +172 -0
- package/dist/pingcode/builtin/reference.md +351 -50
- package/dist/pingcode/cli.js +14 -3
- package/dist/pingcode/detect.js +71 -11
- package/dist/pingcode/index.js +2 -1
- package/dist/pingcode/remote-config.js +69 -0
- package/dist/pingcode/skill.js +30 -7
- package/dist/setup/index.js +329 -0
- package/dist/setup/prompt.js +129 -0
- package/dist/setup/ui.js +78 -0
- package/package.json +3 -2
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` |
|
|
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
|
-
/**
|
|
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}}
|
|
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
|
-
|
|
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
|
-
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
-
|
|
37
|
-
|
|
38
|
-
-
|
|
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
|
-
-
|
|
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`,不要默认拉全量。
|
|
@@ -168,7 +178,8 @@ ls .nvmrc .node-version .python-version .tool-versions rust-toolchain.toml 2>/de
|
|
|
168
178
|
|
|
169
179
|
## 五、安全边界
|
|
170
180
|
|
|
171
|
-
- 只读写 `{{workdir}}`
|
|
181
|
+
- 只读写 `{{workdir}}` 内的文件;`repo/` 是本地目录软链时,等价于在用户自己的项目目录内工作。
|
|
182
|
+
需要访问其他外部目录时先说明理由并等待确认。
|
|
172
183
|
- 不读取、不复述、不写入密钥、令牌、Cookie 等敏感信息;不把 `.env` 内容贴进回复或提交。
|
|
173
184
|
- 不执行破坏性操作(删除仓库、`git reset --hard`、强制推送、批量删除),除非用户明确要求。
|
|
174
185
|
- 不覆盖用户已有改动:初始化与更新依赖前先看 `git status`,有未提交改动时只读不写。
|
|
@@ -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
|
-
|
|
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);
|
package/dist/daemon/index.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
24
|
-
|
|
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()
|
|
71
|
-
if (device) {
|
|
72
|
-
|
|
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
|
+
}
|
package/dist/daemon/manage.js
CHANGED
|
@@ -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 =
|
|
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 {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, symlinkSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { AgentError, prepareAgent, restoreAgentLaunch, validateBusinessAgent } from "./agent/index.js";
|
|
6
|
+
import { applyRemotePingcodeConfig, maskSecret } from "../pingcode/index.js";
|
|
7
|
+
import { inspectLocalPath } from "./local-path.js";
|
|
6
8
|
import { promptWithSkills, syncRunSkills } from "./run-skills.js";
|
|
7
9
|
/** 合并多份子进程环境变量;后者覆盖前者(PingCode 注入的 PC_* 必须压过用户配置) */
|
|
8
10
|
function mergeEnv(...parts) {
|
|
@@ -15,6 +17,43 @@ function mergeEnv(...parts) {
|
|
|
15
17
|
}
|
|
16
18
|
/** 会话目录默认基底(持久位置:目录保留不删,不用 /tmp) */
|
|
17
19
|
const DEFAULT_WORKDIR_BASE = join(homedir(), ".super-coding", "workspaces");
|
|
20
|
+
/**
|
|
21
|
+
* project_repo 由浏览器按项目设置解析后下发,不能全信:
|
|
22
|
+
* local 必须在本机真实存在且是目录(否则让前端去问用户),repo 至少要有地址。
|
|
23
|
+
*/
|
|
24
|
+
function resolveProjectRepo(repo) {
|
|
25
|
+
if (!repo)
|
|
26
|
+
return { ok: true };
|
|
27
|
+
if (repo.type === "local") {
|
|
28
|
+
const dir = typeof repo.dir === "string" ? repo.dir.trim() : "";
|
|
29
|
+
if (!dir)
|
|
30
|
+
return { ok: false, code: "project_repo_invalid", reason: "项目配置的本地目录为空" };
|
|
31
|
+
const state = inspectLocalPath(dir);
|
|
32
|
+
if (!state.exists) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
code: "local_dir_missing",
|
|
36
|
+
reason: `本地目录不存在:${dir}(请确认本机路径,或在项目设置里改用远程仓储)`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (!state.is_directory) {
|
|
40
|
+
return { ok: false, code: "local_dir_missing", reason: `本地目录不是文件夹:${dir}` };
|
|
41
|
+
}
|
|
42
|
+
return { ok: true, repo: { type: "local", dir: state.resolved } };
|
|
43
|
+
}
|
|
44
|
+
if (repo.type === "repo") {
|
|
45
|
+
const url = typeof repo.url === "string" ? repo.url.trim() : "";
|
|
46
|
+
if (!url)
|
|
47
|
+
return { ok: false, code: "project_repo_invalid", reason: "项目配置的远程仓储地址为空" };
|
|
48
|
+
const branch = typeof repo.branch === "string" ? repo.branch.trim() : "";
|
|
49
|
+
return { ok: true, repo: { type: "repo", url, ...(branch ? { branch } : {}) } };
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
code: "project_repo_invalid",
|
|
54
|
+
reason: `不支持的项目仓储类型:${String(repo.type)}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
18
57
|
/**
|
|
19
58
|
* 任务编排:transport ⇄ adapter ⇄ store 接线。
|
|
20
59
|
* 事件一律先落盘(分配 seq)再发 server——断线后 server 按 last_seq 请求补发。
|
|
@@ -82,10 +121,53 @@ export class Orchestrator {
|
|
|
82
121
|
case "resume":
|
|
83
122
|
this.handleResume(msg);
|
|
84
123
|
break;
|
|
124
|
+
case "app:config":
|
|
125
|
+
this.applyAppConfig(msg);
|
|
126
|
+
break;
|
|
85
127
|
default:
|
|
86
128
|
break; // ping 由 transport 层处理
|
|
87
129
|
}
|
|
88
130
|
}
|
|
131
|
+
/** 同一种「跳过」结果只打一次日志:重连很频繁,否则 daemon.log 会被刷满 */
|
|
132
|
+
lastAppConfigOutcome;
|
|
133
|
+
/**
|
|
134
|
+
* 平台下发的应用级凭据(manifest environment → webhook → remote → 这里)。
|
|
135
|
+
* 写入策略见 pingcode/remote-config.ts:只在本机缺失时落盘,绝不覆盖用户配置。
|
|
136
|
+
* 写入后无需通知 bridge——它每轮 prepareRun 都会重读 pingcode.json。
|
|
137
|
+
*/
|
|
138
|
+
applyAppConfig(msg) {
|
|
139
|
+
const pingcode = msg.pingcode;
|
|
140
|
+
const result = applyRemotePingcodeConfig(pingcode);
|
|
141
|
+
if (result.applied !== "written" && result.applied === this.lastAppConfigOutcome)
|
|
142
|
+
return;
|
|
143
|
+
this.lastAppConfigOutcome = result.applied;
|
|
144
|
+
switch (result.applied) {
|
|
145
|
+
case "written":
|
|
146
|
+
console.log(`[daemon] 已写入平台下发的 PingCode 凭据(client_id=${pingcode?.client_id ?? "-"}, ` +
|
|
147
|
+
`secret=${maskSecret(pingcode?.client_secret)}, ` +
|
|
148
|
+
`base_url=${pingcode?.base_url ?? "默认"})→ ${result.path};下一轮任务生效`);
|
|
149
|
+
return;
|
|
150
|
+
case "exists":
|
|
151
|
+
console.log(`[daemon] 本机已有 PingCode 配置,忽略平台下发(${result.path})`);
|
|
152
|
+
return;
|
|
153
|
+
case "skipped_invalid":
|
|
154
|
+
console.warn(`[daemon][warn] 本机 PingCode 配置无法解析,未覆盖平台下发值(${result.path}:${result.error});` +
|
|
155
|
+
"可运行 scode pingcode connect 修复");
|
|
156
|
+
return;
|
|
157
|
+
case "skipped_bad_base_url":
|
|
158
|
+
console.warn(`[daemon][warn] 平台下发的 PC_OPEN_API_BASE_URL 不是 https,已忽略:${result.base_url}`);
|
|
159
|
+
return;
|
|
160
|
+
case "skipped_incomplete":
|
|
161
|
+
console.warn(`[daemon][warn] 平台只下发了半个 PingCode 凭据(缺 ${result.missing}),已忽略`);
|
|
162
|
+
return;
|
|
163
|
+
case "failed":
|
|
164
|
+
console.warn(`[daemon][warn] 写入平台下发的 PingCode 凭据失败:${result.error}`);
|
|
165
|
+
return;
|
|
166
|
+
default:
|
|
167
|
+
// skipped_empty(平台未配置)/ skipped_disabled(开关关闭):保持静默
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
89
171
|
/** hello 先复位本连接同步状态;后续每个 sync 响应驱动下一批,覆盖终态运行 */
|
|
90
172
|
handleResume(msg) {
|
|
91
173
|
if (!msg.sync_id) {
|
|
@@ -170,12 +252,34 @@ export class Orchestrator {
|
|
|
170
252
|
return;
|
|
171
253
|
}
|
|
172
254
|
}
|
|
255
|
+
// 项目仓储(项目设置里的「代码仓库」):local 必须本机真实存在,否则明确拒绝,
|
|
256
|
+
// 让前端去问用户——绝不静默退化成「空目录 + 不克隆」让 agent 在错误前提下开工。
|
|
257
|
+
const repoCheck = resolveProjectRepo(msg.project_repo);
|
|
258
|
+
if (!repoCheck.ok) {
|
|
259
|
+
this.reject(msg.run_id, repoCheck.reason, repoCheck.code);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const projectRepo = repoCheck.repo;
|
|
173
263
|
const resolved = this.resolveWorkdir(msg, base);
|
|
174
264
|
if (!resolved.ok) {
|
|
175
265
|
this.reject(msg.run_id, resolved.reason, "cwd_not_allowed");
|
|
176
266
|
return;
|
|
177
267
|
}
|
|
178
268
|
const workdir = resolved.cwd;
|
|
269
|
+
// 本地目录仓储:软链成 workdir/repo —— 平台文件仍写在 workdir 根,用户仓库不被污染,
|
|
270
|
+
// 但 agent 的读写落在用户真实目录上。续聊复用原目录(链接第一轮已建),不重复建。
|
|
271
|
+
if (projectRepo?.type === "local" && !msg.options?.resume_session_id) {
|
|
272
|
+
try {
|
|
273
|
+
symlinkSync(projectRepo.dir, join(workdir, "repo"), "dir");
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
// EEXIST:目录/link 已存在(重复下发或手工建过)→ 沿用,不覆盖
|
|
277
|
+
if (err.code !== "EEXIST") {
|
|
278
|
+
this.reject(msg.run_id, `挂载本地目录失败:${err.message}`, "local_dir_missing");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
179
283
|
// 防御性观测(「续聊分叉」定位):新建 session 目录时若同 runner 已有活跃 run,
|
|
180
284
|
// 多半是上游 resume 链路断了(native 未就绪被静默放行/客户端没带 session_id)。
|
|
181
285
|
// 并行多会话是合法场景,只告警不拒绝。
|
|
@@ -220,6 +324,17 @@ export class Orchestrator {
|
|
|
220
324
|
runner: msg.agent,
|
|
221
325
|
...(businessAgent ? { businessAgent } : {}),
|
|
222
326
|
...(msg.options ? { options: msg.options } : {}),
|
|
327
|
+
...(projectRepo
|
|
328
|
+
? {
|
|
329
|
+
repo: projectRepo.type === "local"
|
|
330
|
+
? { source: "local" }
|
|
331
|
+
: {
|
|
332
|
+
source: "repo",
|
|
333
|
+
url: projectRepo.url,
|
|
334
|
+
...(projectRepo.branch ? { branch: projectRepo.branch } : {}),
|
|
335
|
+
},
|
|
336
|
+
}
|
|
337
|
+
: {}),
|
|
223
338
|
skills: visible,
|
|
224
339
|
agentFlag: features.agentFlag,
|
|
225
340
|
});
|
|
@@ -15,6 +15,14 @@ export class RunStore {
|
|
|
15
15
|
mkdirSync(join(path, ".."), { recursive: true });
|
|
16
16
|
}
|
|
17
17
|
this.db = new DatabaseSync(path);
|
|
18
|
+
// 并发健壮性:默认的 journal_mode=delete + busy_timeout=0 在「进程被 SIGKILL 留下热日志」
|
|
19
|
+
// 或「另一个连接短暂持锁」时会直接抛 SQLITE_IOERR/SQLITE_BUSY(现象是 record run failed:
|
|
20
|
+
// disk I/O error,重启 daemon 才恢复)。WAL 不再产生热回滚日志,busy_timeout 让写等锁而不是立刻失败。
|
|
21
|
+
if (path !== ":memory:") {
|
|
22
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
23
|
+
this.db.exec("PRAGMA synchronous = NORMAL");
|
|
24
|
+
}
|
|
25
|
+
this.db.exec("PRAGMA busy_timeout = 5000");
|
|
18
26
|
this.db.exec(`
|
|
19
27
|
CREATE TABLE IF NOT EXISTS runs(
|
|
20
28
|
run_id TEXT PRIMARY KEY,
|
|
@@ -45,6 +53,10 @@ export class RunStore {
|
|
|
45
53
|
this.db.exec("ALTER TABLE runs ADD COLUMN agent_slug TEXT");
|
|
46
54
|
}
|
|
47
55
|
}
|
|
56
|
+
/** 诊断用:读当前连接的 PRAGMA(journal_mode / busy_timeout),排障与单测用 */
|
|
57
|
+
pragma(name) {
|
|
58
|
+
return this.db.prepare(`PRAGMA ${name}`).get();
|
|
59
|
+
}
|
|
48
60
|
createRun(t) {
|
|
49
61
|
this.db
|
|
50
62
|
.prepare("INSERT INTO runs(run_id, agent, cwd, native_session_id, business_agent_id, agent_slug, createdAt)"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// remote-server 地址解析:daemon 与 setup 都按同一套规则推导,避免两处漂移。
|
|
2
|
+
// WS 地址可整体覆盖;HTTP 地址默认由 WS 地址派生(ws→http,去掉 /daemon),
|
|
3
|
+
// 也可用 DAEMON_SERVER_HTTP_URL 单独指定(本地调试常把两者分开)。
|
|
4
|
+
export const DEFAULT_SERVER_URL = "wss://super-coding-api.icezeros.com/daemon";
|
|
5
|
+
export function serviceUrls(env = process.env) {
|
|
6
|
+
const ws = env.DAEMON_SERVER_URL ?? DEFAULT_SERVER_URL;
|
|
7
|
+
const http = env.DAEMON_SERVER_HTTP_URL ?? ws.replace(/^ws/, "http").replace(/\/daemon$/, "");
|
|
8
|
+
return { ws, http };
|
|
9
|
+
}
|