@super-coding/cli 0.2.4 → 0.2.6
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/daemon/app-config-pull.js +174 -0
- package/dist/daemon/index.js +19 -1
- package/package.json +2 -2
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { inspectPingcodeConfig } from "../pingcode/config.js";
|
|
2
|
+
const DEFAULT_FIRST_DELAY_MS = 2_000;
|
|
3
|
+
const DEFAULT_DELAYS_MS = [30_000, 120_000, 300_000];
|
|
4
|
+
const DEFAULT_MAX_ATTEMPTS = 24;
|
|
5
|
+
/**
|
|
6
|
+
* 是否需要向平台要凭据。
|
|
7
|
+
*
|
|
8
|
+
* 刻意用配置文件状态而不是 `bridge.status().reason`:
|
|
9
|
+
* - `PingcodeBridge.refresh()` 是 private、`status()` 是启动快照,运行期 connect 后会失真;
|
|
10
|
+
* - `binary_missing` / `skill_missing` 拉凭据也救不了,拉了纯浪费;
|
|
11
|
+
* - **配置损坏(invalid)必须排除**:remote-config 对坏文件永远 `skipped_invalid` 不覆盖,拉取 100% 空转。
|
|
12
|
+
*/
|
|
13
|
+
function checkCredentials(env) {
|
|
14
|
+
if (env["SC_PINGCODE"] === "off" || env["SC_PINGCODE_REMOTE_CONFIG"] === "off") {
|
|
15
|
+
return { pull: false, reason: "disabled" };
|
|
16
|
+
}
|
|
17
|
+
// 环境里已有显式令牌:本地就能用,不需要平台下发
|
|
18
|
+
if ((env["PC_TOKEN"] ?? "").trim())
|
|
19
|
+
return { pull: false, reason: "ready" };
|
|
20
|
+
const state = inspectPingcodeConfig().state;
|
|
21
|
+
if (state === "ok")
|
|
22
|
+
return { pull: false, reason: "ready" };
|
|
23
|
+
if (state === "invalid")
|
|
24
|
+
return { pull: false, reason: "invalid" };
|
|
25
|
+
return { pull: true };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 无凭据时的退避拉取器。
|
|
29
|
+
* 生命周期:`onSessionReady()`(收到 hello 的 resume)→ 首发 → 退避重试;
|
|
30
|
+
* `onDisconnected()` 停表但保留计数语义(重连会重置);`stop()` 是进程退出用的硬停。
|
|
31
|
+
*/
|
|
32
|
+
export class AppConfigPuller {
|
|
33
|
+
opts;
|
|
34
|
+
env;
|
|
35
|
+
firstDelayMs;
|
|
36
|
+
delaysMs;
|
|
37
|
+
maxAttempts;
|
|
38
|
+
setTimer;
|
|
39
|
+
clearTimer;
|
|
40
|
+
log;
|
|
41
|
+
timer;
|
|
42
|
+
connected = false;
|
|
43
|
+
disposed = false;
|
|
44
|
+
halted;
|
|
45
|
+
attempts = 0;
|
|
46
|
+
/** 「无凭据,开始拉取」只提示一次,避免每次重连都刷日志 */
|
|
47
|
+
announced = false;
|
|
48
|
+
constructor(opts) {
|
|
49
|
+
this.opts = opts;
|
|
50
|
+
this.env = opts.env ?? process.env;
|
|
51
|
+
this.firstDelayMs = opts.firstDelayMs ?? DEFAULT_FIRST_DELAY_MS;
|
|
52
|
+
this.delaysMs = opts.delaysMs ?? DEFAULT_DELAYS_MS;
|
|
53
|
+
this.maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
54
|
+
this.setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
55
|
+
this.clearTimer = opts.clearTimer ?? ((timer) => clearTimeout(timer));
|
|
56
|
+
this.log = opts.log ?? ((level, msg) => (level === "warn" ? console.warn(msg) : console.log(msg)));
|
|
57
|
+
}
|
|
58
|
+
/** 收到 hello 的 resume(唯一可靠的「已鉴权」信号):重新评估并安排首次拉取 */
|
|
59
|
+
onSessionReady() {
|
|
60
|
+
if (this.disposed)
|
|
61
|
+
return;
|
|
62
|
+
this.connected = true;
|
|
63
|
+
this.attempts = 0;
|
|
64
|
+
this.halted = undefined;
|
|
65
|
+
const check = checkCredentials(this.env);
|
|
66
|
+
if (!check.pull) {
|
|
67
|
+
this.noteUnavailable(check.reason);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.schedule(this.firstDelayMs);
|
|
71
|
+
}
|
|
72
|
+
/** 收到并应用了 app:config:本机配置已可用就立刻停(省掉后续重试) */
|
|
73
|
+
onAppConfigApplied() {
|
|
74
|
+
if (this.disposed || this.halted === "ready")
|
|
75
|
+
return;
|
|
76
|
+
if (inspectPingcodeConfig().state === "ok")
|
|
77
|
+
this.halt("ready");
|
|
78
|
+
}
|
|
79
|
+
/** 连接断开:停表、不消耗尝试次数(重连后会从首发间隔重来) */
|
|
80
|
+
onDisconnected() {
|
|
81
|
+
this.connected = false;
|
|
82
|
+
this.clearScheduled();
|
|
83
|
+
}
|
|
84
|
+
/** 进程退出 / 关闸:硬停,之后不再调度 */
|
|
85
|
+
stop() {
|
|
86
|
+
if (this.disposed)
|
|
87
|
+
return;
|
|
88
|
+
this.disposed = true;
|
|
89
|
+
this.connected = false;
|
|
90
|
+
this.clearScheduled();
|
|
91
|
+
}
|
|
92
|
+
state() {
|
|
93
|
+
return {
|
|
94
|
+
attempts: this.attempts,
|
|
95
|
+
scheduled: this.timer !== undefined,
|
|
96
|
+
...(this.halted ? { stopReason: this.halted } : {}),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
schedule(ms) {
|
|
100
|
+
this.clearScheduled();
|
|
101
|
+
this.timer = this.setTimer(() => {
|
|
102
|
+
this.timer = undefined;
|
|
103
|
+
this.attempt();
|
|
104
|
+
}, ms);
|
|
105
|
+
this.timer.unref?.();
|
|
106
|
+
}
|
|
107
|
+
clearScheduled() {
|
|
108
|
+
if (this.timer === undefined)
|
|
109
|
+
return;
|
|
110
|
+
this.clearTimer(this.timer);
|
|
111
|
+
this.timer = undefined;
|
|
112
|
+
}
|
|
113
|
+
attempt() {
|
|
114
|
+
if (this.disposed || !this.connected || this.halted)
|
|
115
|
+
return;
|
|
116
|
+
// 每次请求前自检:配置可能已被 connect / 平台下发写好了
|
|
117
|
+
const check = checkCredentials(this.env);
|
|
118
|
+
if (!check.pull) {
|
|
119
|
+
this.noteUnavailable(check.reason);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (this.maxAttempts > 0 && this.attempts >= this.maxAttempts) {
|
|
123
|
+
this.halt("max_attempts");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
this.opts.send({ type: "app:config:pull", reason: "no_credentials" });
|
|
127
|
+
this.attempts += 1;
|
|
128
|
+
if (!this.announced) {
|
|
129
|
+
this.announced = true;
|
|
130
|
+
this.log("info", `[daemon] PingCode 无凭据,已向平台请求(退避 ${this.describeDelays()},最多 ${this.maxAttempts || "不限"} 次)`);
|
|
131
|
+
}
|
|
132
|
+
this.schedule(this.delayFor(this.attempts));
|
|
133
|
+
}
|
|
134
|
+
delayFor(attempt) {
|
|
135
|
+
const index = Math.min(attempt - 1, this.delaysMs.length - 1);
|
|
136
|
+
return this.delaysMs[index] ?? DEFAULT_DELAYS_MS[DEFAULT_DELAYS_MS.length - 1];
|
|
137
|
+
}
|
|
138
|
+
describeDelays() {
|
|
139
|
+
const first = `${Math.round(this.firstDelayMs / 1000)}s`;
|
|
140
|
+
const rest = this.delaysMs.map((ms) => (ms >= 60_000 ? `${Math.round(ms / 60_000)}m` : `${Math.round(ms / 1000)}s`));
|
|
141
|
+
return [first, ...rest].join("→");
|
|
142
|
+
}
|
|
143
|
+
noteUnavailable(reason) {
|
|
144
|
+
const target = reason ?? "ready";
|
|
145
|
+
// 已经因为同一原因停过就不再重复记录
|
|
146
|
+
if (this.halted === target)
|
|
147
|
+
return;
|
|
148
|
+
this.halt(target);
|
|
149
|
+
}
|
|
150
|
+
halt(reason) {
|
|
151
|
+
this.clearScheduled();
|
|
152
|
+
this.halted = reason;
|
|
153
|
+
const attempted = this.attempts;
|
|
154
|
+
if (reason === "ready") {
|
|
155
|
+
if (attempted > 0)
|
|
156
|
+
this.log("info", `[daemon] 已取得 PingCode 凭据,停止拉取(已请求 ${attempted} 次)`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (reason === "disabled") {
|
|
160
|
+
this.log("info", "[daemon] PingCode 平台拉取已关闭(SC_PINGCODE=off / SC_PINGCODE_REMOTE_CONFIG=off)");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (reason === "invalid") {
|
|
164
|
+
this.log("warn", "[daemon][warn] 本机 pingcode.json 内容损坏,平台下发不会覆盖,跳过拉取(请运行 scode pingcode connect)");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
this.log("warn", `[daemon][warn] 已请求 ${attempted} 次仍无 PingCode 凭据,停止拉取;重连后会重试,或运行 scode pingcode status 查看原因`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
export const appConfigPullerDefaults = {
|
|
171
|
+
DEFAULT_FIRST_DELAY_MS,
|
|
172
|
+
DEFAULT_DELAYS_MS,
|
|
173
|
+
DEFAULT_MAX_ATTEMPTS,
|
|
174
|
+
};
|
package/dist/daemon/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { loadBuiltinAgent } from "./agent/index.js";
|
|
4
|
+
import { AppConfigPuller } from "./app-config-pull.js";
|
|
4
5
|
import { detectRunnerFeatures } from "./adapters/features.js";
|
|
5
6
|
import { adapters, agentBinaries } from "./adapters/registry.js";
|
|
6
7
|
import { loadDaemonConfig, loadDevice, removeDevice, restoreDeviceBackup } from "./device-store.js";
|
|
@@ -57,6 +58,7 @@ export async function runDaemon() {
|
|
|
57
58
|
// 信号处理提前注册:配对/探测/reattach 期间收到 stop 也要优雅收尾(否则默认信号直接杀进程)
|
|
58
59
|
let orchRef;
|
|
59
60
|
let wsClient;
|
|
61
|
+
let pullerRef;
|
|
60
62
|
let presenceTimer;
|
|
61
63
|
let pidTimer;
|
|
62
64
|
let shuttingDown = false;
|
|
@@ -69,6 +71,7 @@ export async function runDaemon() {
|
|
|
69
71
|
clearInterval(presenceTimer);
|
|
70
72
|
if (pidTimer)
|
|
71
73
|
clearInterval(pidTimer);
|
|
74
|
+
pullerRef?.stop();
|
|
72
75
|
await orchRef?.shutdown();
|
|
73
76
|
wsClient?.close();
|
|
74
77
|
process.exit(0);
|
|
@@ -215,6 +218,11 @@ export async function runDaemon() {
|
|
|
215
218
|
// 配置可能在运行期才写入(scode pingcode connect),bridge 每轮任务会自行重探。
|
|
216
219
|
const pingcode = await createPingcodeBridge();
|
|
217
220
|
console.log(`[daemon] pingcode: ${describePingcode(pingcode.status())}`);
|
|
221
|
+
// 平台凭据主动拉取:本机没有凭据时按退避发 app:config:pull(下发原本只在 hello 那一刻,
|
|
222
|
+
// 已在线却错过那次的机器永远拿不到)。send 经 wsRef 转发,断线时 ws-client 会静默丢弃,
|
|
223
|
+
// puller 自己也会在 onDisconnected 停表。
|
|
224
|
+
const appConfigPuller = new AppConfigPuller({ send: (m) => wsRef.send(m), env: process.env });
|
|
225
|
+
pullerRef = appConfigPuller;
|
|
218
226
|
const orch = new Orchestrator({ send: (m) => wsRef.send(m) }, store, adapters, router, daemonConfig.allowedCwds, (runner) => detectRunnerFeatures(runner, agentBinaries[runner] ?? runner), pingcode);
|
|
219
227
|
orchRef = orch;
|
|
220
228
|
// 4401 双确认:平台瞬时空读也可能让 auth 回源报「查无此设备」——首次只告警并
|
|
@@ -249,11 +257,21 @@ export async function runDaemon() {
|
|
|
249
257
|
unauth4401Streak = 0;
|
|
250
258
|
// 必须兜住拒绝:handleMessage 是 async,任一步抛出都会变成 unhandledRejection,
|
|
251
259
|
// Node 15+ 默认终止进程 → 表现成「daemon 莫名消失」
|
|
252
|
-
|
|
260
|
+
const handled = orch.handleMessage(msg);
|
|
261
|
+
void handled.catch((err) => {
|
|
253
262
|
console.error(`[daemon] 处理 ${msg.type} 失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
254
263
|
});
|
|
264
|
+
// resume(无 sync_id)是「hello 已鉴权」的唯一可靠信号:onOpen 早于鉴权,
|
|
265
|
+
// 那时发非 hello 消息会被服务端 close(4401)。
|
|
266
|
+
if (msg.type === "resume" && !msg.sync_id)
|
|
267
|
+
appConfigPuller.onSessionReady();
|
|
268
|
+
if (msg.type === "app:config") {
|
|
269
|
+
// 两参数 then:只写 onFulfilled 会在 reject 时产生新的 unhandledRejection
|
|
270
|
+
void handled.then(() => appConfigPuller.onAppConfigApplied(), () => undefined);
|
|
271
|
+
}
|
|
255
272
|
},
|
|
256
273
|
onClose: (code, reason) => {
|
|
274
|
+
appConfigPuller.onDisconnected();
|
|
257
275
|
// 确定性解绑(CES 查无此设备 → 4401 unauthorized):清凭证就地重新配对。
|
|
258
276
|
// 覆盖离线解绑/反向通知 RPC 未送达的场景(否则旧凭证残留导致永不出配对码)。
|
|
259
277
|
// 其余 close(如 4402 auth_unavailable)保留凭证,走自动退避重连。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@super-coding/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Super Coding CLI:把本机 Claude Code / Cursor 接入远程控制台(scode 命令与后台 daemon)",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"commander": "^14.0.2",
|
|
37
37
|
"ws": "^8.21.3",
|
|
38
|
-
"@super-coding/protocol": "0.
|
|
38
|
+
"@super-coding/protocol": "0.2.1"
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
41
|
"dev": "tsx watch src/cli.ts",
|