@super-coding/cli 0.0.0-stage → 0.1.0
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 +70 -2
- package/dist/cli.js +35 -0
- package/dist/daemon/adapters/base.js +37 -0
- package/dist/daemon/adapters/claude-code.js +184 -0
- package/dist/daemon/adapters/cursor.js +138 -0
- package/dist/daemon/adapters/features.js +51 -0
- package/dist/daemon/adapters/registry.js +9 -0
- package/dist/daemon/agent/builtin/agent.md +164 -0
- package/dist/daemon/agent/builtin/load.js +121 -0
- package/dist/daemon/agent/builtin/parse.js +73 -0
- package/dist/daemon/agent/index.js +60 -0
- package/dist/daemon/agent/manifest.js +72 -0
- package/dist/daemon/agent/materialize.js +220 -0
- package/dist/daemon/agent/renderers/claude-code.js +16 -0
- package/dist/daemon/agent/renderers/common.js +61 -0
- package/dist/daemon/agent/renderers/cursor.js +13 -0
- package/dist/daemon/agent/renderers/generic.js +5 -0
- package/dist/daemon/agent/resolve.js +212 -0
- package/dist/daemon/agent/slug.js +27 -0
- package/dist/daemon/agent/types.js +8 -0
- package/dist/daemon/device-store.js +61 -0
- package/dist/daemon/index.js +280 -0
- package/dist/daemon/manage.js +183 -0
- package/dist/daemon/orchestrator.js +364 -0
- package/dist/daemon/pairing.js +93 -0
- package/dist/daemon/paths.js +47 -0
- package/dist/daemon/rpc.js +41 -0
- package/dist/daemon/run-skills.js +148 -0
- package/dist/daemon/services/agent-config.js +1 -0
- package/dist/daemon/services/claude-provider.js +126 -0
- package/dist/daemon/services/cursor-provider.js +94 -0
- package/dist/daemon/services/json-file.js +21 -0
- package/dist/daemon/services/log-buffer.js +43 -0
- package/dist/daemon/services/mcp-secrets.js +36 -0
- package/dist/daemon/session/store.js +137 -0
- package/dist/daemon/transport/ws-client.js +121 -0
- package/package.json +44 -3
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { AgentError, prepareAgent, restoreAgentLaunch, validateBusinessAgent } from "./agent/index.js";
|
|
6
|
+
import { promptWithSkills, syncRunSkills } from "./run-skills.js";
|
|
7
|
+
/** 会话目录默认基底(持久位置:目录保留不删,不用 /tmp) */
|
|
8
|
+
const DEFAULT_WORKDIR_BASE = join(homedir(), ".super-coding", "workspaces");
|
|
9
|
+
/**
|
|
10
|
+
* 任务编排:transport ⇄ adapter ⇄ store 接线。
|
|
11
|
+
* 事件一律先落盘(分配 seq)再发 server——断线后 server 按 last_seq 请求补发。
|
|
12
|
+
*/
|
|
13
|
+
export class Orchestrator {
|
|
14
|
+
ws;
|
|
15
|
+
store;
|
|
16
|
+
adapters;
|
|
17
|
+
rpc;
|
|
18
|
+
allowedCwds;
|
|
19
|
+
featuresFor;
|
|
20
|
+
sessions = new Map();
|
|
21
|
+
/** 本连接已经请求过水位的运行,避免同一批在确认前被反复重放 */
|
|
22
|
+
syncRequested = new Set();
|
|
23
|
+
syncInFlight;
|
|
24
|
+
constructor(ws, store, adapters, rpc,
|
|
25
|
+
/** run:start 的 cwd 白名单(前缀匹配);缺省不限制 */
|
|
26
|
+
allowedCwds,
|
|
27
|
+
/** runner 能力探测(默认按不支持 --agent 处理,测试可注入) */
|
|
28
|
+
featuresFor) {
|
|
29
|
+
this.ws = ws;
|
|
30
|
+
this.store = store;
|
|
31
|
+
this.adapters = adapters;
|
|
32
|
+
this.rpc = rpc;
|
|
33
|
+
this.allowedCwds = allowedCwds;
|
|
34
|
+
this.featuresFor = featuresFor;
|
|
35
|
+
}
|
|
36
|
+
activeRuns() {
|
|
37
|
+
return [...this.sessions.keys()];
|
|
38
|
+
}
|
|
39
|
+
/** 会话集合变化时主动上报 presence(running 列表) */
|
|
40
|
+
reportPresence() {
|
|
41
|
+
this.ws.send({ type: "presence", running: this.activeRuns() });
|
|
42
|
+
}
|
|
43
|
+
async handleMessage(msg) {
|
|
44
|
+
switch (msg.type) {
|
|
45
|
+
case "run:start":
|
|
46
|
+
await this.startRun(msg);
|
|
47
|
+
break;
|
|
48
|
+
case "run:input":
|
|
49
|
+
await this.sessions.get(msg.run_id)?.sendMessage(msg.text);
|
|
50
|
+
break;
|
|
51
|
+
case "run:permission":
|
|
52
|
+
await this.sessions.get(msg.run_id)?.respondPermission(msg.request_id, msg.decision);
|
|
53
|
+
break;
|
|
54
|
+
case "run:interrupt":
|
|
55
|
+
await this.sessions.get(msg.run_id)?.interrupt();
|
|
56
|
+
break;
|
|
57
|
+
case "debug:echo":
|
|
58
|
+
console.log("[debug]", msg.text);
|
|
59
|
+
break;
|
|
60
|
+
case "rpc":
|
|
61
|
+
await (this.rpc
|
|
62
|
+
? this.rpc.dispatch(msg, (m) => this.ws.send(m))
|
|
63
|
+
: Promise.resolve(this.ws.send({
|
|
64
|
+
type: "rpc:result",
|
|
65
|
+
id: msg.id,
|
|
66
|
+
error: { code: "unknown_method", message: "rpc not available" },
|
|
67
|
+
})));
|
|
68
|
+
break;
|
|
69
|
+
case "resume":
|
|
70
|
+
this.handleResume(msg);
|
|
71
|
+
break;
|
|
72
|
+
default:
|
|
73
|
+
break; // ping 由 transport 层处理
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** hello 先复位本连接同步状态;后续每个 sync 响应驱动下一批,覆盖终态运行 */
|
|
77
|
+
handleResume(msg) {
|
|
78
|
+
if (!msg.sync_id) {
|
|
79
|
+
this.syncRequested.clear();
|
|
80
|
+
this.syncInFlight = undefined;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
const inFlight = this.syncInFlight;
|
|
84
|
+
if (inFlight?.id !== msg.sync_id)
|
|
85
|
+
return; // 迟到/跨连接响应
|
|
86
|
+
for (const runId of inFlight.runIds)
|
|
87
|
+
this.syncRequested.add(runId);
|
|
88
|
+
this.syncInFlight = undefined;
|
|
89
|
+
}
|
|
90
|
+
for (const [run_id, last_seq] of Object.entries(msg.runs ?? {})) {
|
|
91
|
+
this.syncRequested.add(run_id);
|
|
92
|
+
this.store.acknowledge(run_id, last_seq);
|
|
93
|
+
for (const { seq, event } of this.store.eventsAfter(run_id, last_seq)) {
|
|
94
|
+
this.ws.send({ type: "event", run_id, seq, event });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
this.sendNextSync();
|
|
98
|
+
}
|
|
99
|
+
/** daemon 驱动补发:remote 无需枚举或持久化运行集合,CES 仍是唯一水位真相源 */
|
|
100
|
+
sendNextSync() {
|
|
101
|
+
if (this.syncInFlight)
|
|
102
|
+
return;
|
|
103
|
+
const runIds = this.store.pendingSync()
|
|
104
|
+
.map((r) => r.run_id)
|
|
105
|
+
.filter((id) => !this.syncRequested.has(id))
|
|
106
|
+
.slice(0, 50);
|
|
107
|
+
if (runIds.length === 0)
|
|
108
|
+
return;
|
|
109
|
+
const id = randomUUID();
|
|
110
|
+
this.syncInFlight = { id, runIds };
|
|
111
|
+
this.ws.send({ type: "sync", sync_id: id, run_ids: runIds });
|
|
112
|
+
}
|
|
113
|
+
reject(run_id, reason, code) {
|
|
114
|
+
this.ws.send({ type: "run:ack", run_id, status: "rejected", reason, ...(code ? { code } : {}) });
|
|
115
|
+
}
|
|
116
|
+
rejectAgent(run_id, err) {
|
|
117
|
+
if (err instanceof AgentError) {
|
|
118
|
+
this.reject(run_id, err.message, err.code);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
this.reject(run_id, `agent 注入失败: ${err?.message ?? String(err)}`, "agent_materialize_failed");
|
|
122
|
+
}
|
|
123
|
+
async startRun(msg) {
|
|
124
|
+
const adapter = this.adapters.get(msg.agent);
|
|
125
|
+
if (!adapter) {
|
|
126
|
+
this.reject(msg.run_id, `unknown agent: ${msg.agent}`, "unknown_agent");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// 安全边界:白名单只校验用户显式传入的基底(不传 = 内部默认基底,跳过)
|
|
130
|
+
const base = msg.cwd?.trim() ? msg.cwd.trim() : undefined;
|
|
131
|
+
if (base && this.allowedCwds && !this.allowedCwds.some((dir) => base === dir || base.startsWith(dir.endsWith("/") ? dir : `${dir}/`))) {
|
|
132
|
+
this.reject(msg.run_id, `cwd not allowed: ${base}`, "cwd_not_allowed");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// 同一 run_id 重复下发:进程已在跑就幂等回执,不重复建目录/起进程
|
|
136
|
+
const existing = this.store.getRun(msg.run_id);
|
|
137
|
+
if (existing && this.sessions.has(msg.run_id)) {
|
|
138
|
+
this.ws.send({ type: "run:ack", run_id: msg.run_id, status: "accepted", cwd: existing.cwd });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// 纯校验放在建目录之前:禁用/runner 不符不该留下空 session-* 目录
|
|
142
|
+
const businessAgent = msg.business_agent;
|
|
143
|
+
const injection = process.env["SC_AGENT_INJECTION"] !== "off";
|
|
144
|
+
if (injection) {
|
|
145
|
+
try {
|
|
146
|
+
validateBusinessAgent(msg.agent, businessAgent);
|
|
147
|
+
// 续聊沿用原生会话时,绑定关系要回溯到最早的运行(本轮 run_id 还没有记录)
|
|
148
|
+
const resumeId = msg.options?.resume_session_id;
|
|
149
|
+
const boundAgentId = existing?.business_agent_id
|
|
150
|
+
?? (resumeId ? this.store.findRunByNativeSession(resumeId, msg.agent)?.business_agent_id : undefined);
|
|
151
|
+
if (boundAgentId && businessAgent && boundAgentId !== businessAgent.id) {
|
|
152
|
+
throw new AgentError("agent_switch_not_allowed", `会话已绑定 agent ${boundAgentId},不能切换为 ${businessAgent.id}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
this.rejectAgent(msg.run_id, err);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const resolved = this.resolveWorkdir(msg, base);
|
|
161
|
+
if (!resolved.ok) {
|
|
162
|
+
this.reject(msg.run_id, resolved.reason, "cwd_not_allowed");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const workdir = resolved.cwd;
|
|
166
|
+
// 防御性观测(「续聊分叉」定位):新建 session 目录时若同 runner 已有活跃 run,
|
|
167
|
+
// 多半是上游 resume 链路断了(native 未就绪被静默放行/客户端没带 session_id)。
|
|
168
|
+
// 并行多会话是合法场景,只告警不拒绝。
|
|
169
|
+
if (!msg.options?.resume_session_id) {
|
|
170
|
+
for (const activeId of this.sessions.keys()) {
|
|
171
|
+
if (this.store.getRun(activeId)?.agent === msg.agent) {
|
|
172
|
+
console.warn(`[daemon] run ${msg.run_id} 新建 session 目录(同 agent ${msg.agent} 的 run ${activeId} 仍在执行)`
|
|
173
|
+
+ `——若非预期请检查续聊 resume 链路`);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
let visible;
|
|
179
|
+
try {
|
|
180
|
+
visible = await syncRunSkills(workdir, msg.agent, msg.skills ?? []);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
this.reject(msg.run_id, `skill materialize failed: ${err.message}`, "skill_materialize_failed");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// 内置 agent 定义 + 用户配置 → 原生 agent 文件 / 主指令 / MCP(失败一律在 ack 前拒绝)
|
|
187
|
+
let prepared;
|
|
188
|
+
if (injection) {
|
|
189
|
+
try {
|
|
190
|
+
const features = this.featuresFor
|
|
191
|
+
? await this.featuresFor(msg.agent)
|
|
192
|
+
: { agentFlag: false, strictMcp: false };
|
|
193
|
+
prepared = await prepareAgent({
|
|
194
|
+
workdir,
|
|
195
|
+
runner: msg.agent,
|
|
196
|
+
...(businessAgent ? { businessAgent } : {}),
|
|
197
|
+
...(msg.options ? { options: msg.options } : {}),
|
|
198
|
+
skills: visible,
|
|
199
|
+
agentFlag: features.agentFlag,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
this.rejectAgent(msg.run_id, err);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
let prompt = promptWithSkills(msg.prompt, visible);
|
|
208
|
+
if (prepared?.launch.promptPrefix) {
|
|
209
|
+
prompt = prompt ? `${prepared.launch.promptPrefix}\n\n${prompt}` : prepared.launch.promptPrefix;
|
|
210
|
+
}
|
|
211
|
+
// 先落库再 ack:失败还能显式 rejected(accepted 之后 remote 会立刻把消息置 running)
|
|
212
|
+
try {
|
|
213
|
+
if (!existing) {
|
|
214
|
+
this.store.createRun({
|
|
215
|
+
run_id: msg.run_id,
|
|
216
|
+
agent: msg.agent,
|
|
217
|
+
cwd: workdir,
|
|
218
|
+
...(businessAgent ? { business_agent_id: businessAgent.id } : {}),
|
|
219
|
+
...(prepared ? { agent_slug: prepared.slug } : {}),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
this.reject(msg.run_id, `record run failed: ${err.message}`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
this.ws.send({ type: "run:ack", run_id: msg.run_id, status: "accepted", cwd: workdir });
|
|
228
|
+
let session;
|
|
229
|
+
try {
|
|
230
|
+
session = await adapter.start({
|
|
231
|
+
cwd: workdir,
|
|
232
|
+
prompt,
|
|
233
|
+
options: { ...(msg.options ?? {}), ...(prepared?.runOptions ?? {}) },
|
|
234
|
+
...(prepared?.env ? { env: prepared.env } : {}),
|
|
235
|
+
...(prepared?.launch.agentName ? { agentName: prepared.launch.agentName } : {}),
|
|
236
|
+
...(prepared?.launch.mcpConfigPath ? { mcpConfigPath: prepared.launch.mcpConfigPath } : {}),
|
|
237
|
+
...(prepared?.launch.disallowedTools?.length ? { disallowedTools: prepared.launch.disallowedTools } : {}),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
this.emit(msg.run_id, { kind: "done", status: "failed", final_text: String(err) });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
this.sessions.set(msg.run_id, session);
|
|
245
|
+
this.reportPresence();
|
|
246
|
+
this.consume(msg.run_id, session);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* 会话执行目录:resume 续会话复用同目录(按 (agent, native_session_id) 回查;
|
|
250
|
+
* 查不到则拒绝——换目录 --resume 会错乱/失败,不静默新建);新会话在基底下建唯一
|
|
251
|
+
* 子目录 session-<uuid>(保留不删)。基底 resolve 归一化(相对路径不失义)。
|
|
252
|
+
*/
|
|
253
|
+
resolveWorkdir(msg, base) {
|
|
254
|
+
const resumeId = msg.options?.resume_session_id;
|
|
255
|
+
if (resumeId) {
|
|
256
|
+
const prior = this.store.findCwdByNativeSession(resumeId, msg.agent);
|
|
257
|
+
if (!prior)
|
|
258
|
+
return { ok: false, reason: `resume session not found: ${resumeId}` };
|
|
259
|
+
// 目录可能被外部删除——复用前补建(上下文在 agent 原生会话里,不丢)
|
|
260
|
+
mkdirSync(prior, { recursive: true });
|
|
261
|
+
return { ok: true, cwd: prior };
|
|
262
|
+
}
|
|
263
|
+
const root = process.env.SC_WORKDIR_BASE ?? DEFAULT_WORKDIR_BASE;
|
|
264
|
+
const dir = join(resolve(base ?? root), `session-${randomUUID()}`);
|
|
265
|
+
try {
|
|
266
|
+
mkdirSync(dir, { recursive: true });
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
return { ok: false, reason: `workdir create failed: ${err.message}` };
|
|
270
|
+
}
|
|
271
|
+
return { ok: true, cwd: dir };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* 跨重启重挂:daemon 重启后 agent 进程已死,但 claude/cursor 都支持 --resume <native_session_id>
|
|
275
|
+
* 重建会话控制面(可继续 input/interrupt);无 native_session_id 的标记 failed。
|
|
276
|
+
* agent 启动参数从会话目录的 managed-agent.json 恢复(不重新拉 agents 表,配置变更从下一轮生效)。
|
|
277
|
+
*/
|
|
278
|
+
async reattach() {
|
|
279
|
+
const injection = process.env["SC_AGENT_INJECTION"] !== "off";
|
|
280
|
+
for (const run of this.store.listRuns("running")) {
|
|
281
|
+
const adapter = this.adapters.get(run.agent);
|
|
282
|
+
if (!run.native_session_id || !adapter) {
|
|
283
|
+
this.store.setStatus(run.run_id, "failed");
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
let restored;
|
|
287
|
+
if (injection) {
|
|
288
|
+
restored = await restoreAgentLaunch(run.cwd);
|
|
289
|
+
if (!restored) {
|
|
290
|
+
console.error(`[daemon] reattach ${run.run_id} 失败:会话目录缺少 agent 快照(${run.cwd})`);
|
|
291
|
+
this.store.setStatus(run.run_id, "failed");
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const features = this.featuresFor
|
|
297
|
+
? await this.featuresFor(run.agent)
|
|
298
|
+
: { agentFlag: false, strictMcp: false };
|
|
299
|
+
const manifest = restored?.manifest;
|
|
300
|
+
const session = await adapter.start({
|
|
301
|
+
cwd: run.cwd,
|
|
302
|
+
prompt: "",
|
|
303
|
+
options: {
|
|
304
|
+
resume_session_id: run.native_session_id,
|
|
305
|
+
...(manifest?.resolved.model ? { model: manifest.resolved.model } : {}),
|
|
306
|
+
...(manifest?.resolved.permission_mode
|
|
307
|
+
? { permission_mode: manifest.resolved.permission_mode }
|
|
308
|
+
: {}),
|
|
309
|
+
},
|
|
310
|
+
...(manifest?.resolved.env ? { env: manifest.resolved.env } : {}),
|
|
311
|
+
...(features.agentFlag && manifest ? { agentName: manifest.resolved.slug } : {}),
|
|
312
|
+
...(restored?.launch.mcpConfigPath ? { mcpConfigPath: restored.launch.mcpConfigPath } : {}),
|
|
313
|
+
...(restored?.launch.disallowedTools?.length
|
|
314
|
+
? { disallowedTools: restored.launch.disallowedTools }
|
|
315
|
+
: {}),
|
|
316
|
+
});
|
|
317
|
+
this.sessions.set(run.run_id, session);
|
|
318
|
+
this.consume(run.run_id, session);
|
|
319
|
+
console.log(`[daemon] reattached run ${run.run_id} (session ${run.native_session_id})`);
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
console.error(`[daemon] reattach ${run.run_id} failed:`, err);
|
|
323
|
+
this.store.setStatus(run.run_id, "failed");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
this.reportPresence();
|
|
327
|
+
}
|
|
328
|
+
/** 后台消费事件流:落盘(分配 seq)→ 上行;done 更新状态 */
|
|
329
|
+
consume(run_id, session) {
|
|
330
|
+
void (async () => {
|
|
331
|
+
try {
|
|
332
|
+
for await (const event of session) {
|
|
333
|
+
if (event.kind === "init") {
|
|
334
|
+
this.store.setNativeSessionId(run_id, event.session_id);
|
|
335
|
+
// 回显会话执行目录(前端「会话开始」时展示)
|
|
336
|
+
const rec = this.store.getRun(run_id);
|
|
337
|
+
this.emit(run_id, rec ? { ...event, workdir: rec.cwd } : event);
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
this.emit(run_id, event);
|
|
341
|
+
}
|
|
342
|
+
if (event.kind === "done") {
|
|
343
|
+
this.store.setStatus(run_id, event.status);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
this.emit(run_id, { kind: "error", message: String(err), retryable: false });
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
this.sessions.delete(run_id);
|
|
352
|
+
this.reportPresence();
|
|
353
|
+
}
|
|
354
|
+
})();
|
|
355
|
+
}
|
|
356
|
+
/** 先落盘(拿到 seq)再发 */
|
|
357
|
+
emit(run_id, event) {
|
|
358
|
+
const seq = this.store.appendEvent(run_id, event);
|
|
359
|
+
this.ws.send({ type: "event", run_id, seq, event });
|
|
360
|
+
}
|
|
361
|
+
async shutdown() {
|
|
362
|
+
await Promise.all([...this.sessions.values()].map((s) => s.close()));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { randomInt } from "node:crypto";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { saveDevice } from "./device-store.js";
|
|
4
|
+
/** 字母表去除易混淆字符(0/O、1/I/L) */
|
|
5
|
+
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
6
|
+
const CODE_LENGTH = 6;
|
|
7
|
+
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1_000; // 10 分钟
|
|
9
|
+
/** 配对超时:CLI 入口应以此 exitCode 退出 */
|
|
10
|
+
export class PairingTimeoutError extends Error {
|
|
11
|
+
exitCode = 2;
|
|
12
|
+
constructor(message = "pairing timed out") {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "PairingTimeoutError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function generateCode() {
|
|
18
|
+
let code = "";
|
|
19
|
+
for (let i = 0; i < CODE_LENGTH; i += 1) {
|
|
20
|
+
code += ALPHABET[randomInt(ALPHABET.length)];
|
|
21
|
+
}
|
|
22
|
+
return code;
|
|
23
|
+
}
|
|
24
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
25
|
+
/**
|
|
26
|
+
* 配对流程:注册配对码 → 提示用户在应用中输入 → 轮询直到被认领 → 保存设备凭证。
|
|
27
|
+
* device_token 明文只在 poll 响应中出现一次,立即写入 ~/.super-coding/daemon.json(0600)。
|
|
28
|
+
* 超时抛出 PairingTimeoutError(exitCode = 2)。
|
|
29
|
+
*/
|
|
30
|
+
export async function runPairing(serverUrl, opts = {}) {
|
|
31
|
+
const { fetchImpl = fetch, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, timeoutMs = DEFAULT_TIMEOUT_MS, machine_name = hostname(), sleep = defaultSleep, log = console.log, configDir, } = opts;
|
|
32
|
+
const base = serverUrl.replace(/\/+$/, "");
|
|
33
|
+
const code = generateCode();
|
|
34
|
+
// register:remote 重启窗口的瞬时 5xx/网络错误重试,不因一次抖动杀死 daemon
|
|
35
|
+
let registerRes = null;
|
|
36
|
+
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
|
37
|
+
try {
|
|
38
|
+
registerRes = await fetchImpl(`${base}/pairing/register`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "content-type": "application/json" },
|
|
41
|
+
body: JSON.stringify({ code, machine_name }),
|
|
42
|
+
});
|
|
43
|
+
if (registerRes.ok)
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
registerRes = null; // 网络错误
|
|
48
|
+
}
|
|
49
|
+
log(`pairing register 暂时失败(第 ${attempt} 次),2s 后重试...`);
|
|
50
|
+
await sleep(2_000);
|
|
51
|
+
}
|
|
52
|
+
if (!registerRes?.ok) {
|
|
53
|
+
throw new Error(`pairing register failed: HTTP ${registerRes?.status ?? "network"}`);
|
|
54
|
+
}
|
|
55
|
+
log(`请在 Super Coding 应用中输入配对码: ${code.slice(0, 3)} ${code.slice(3)}`);
|
|
56
|
+
const deadline = Date.now() + timeoutMs;
|
|
57
|
+
for (;;) {
|
|
58
|
+
if (Date.now() >= deadline) {
|
|
59
|
+
throw new PairingTimeoutError();
|
|
60
|
+
}
|
|
61
|
+
await sleep(pollIntervalMs);
|
|
62
|
+
let pollRes;
|
|
63
|
+
try {
|
|
64
|
+
pollRes = await fetchImpl(`${base}/pairing/poll?code=${encodeURIComponent(code)}`);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// 网络抖动:继续轮询(deadline 兜底)
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (pollRes.status === 404) {
|
|
71
|
+
// code 已过期/被消费
|
|
72
|
+
throw new PairingTimeoutError("pairing code expired or consumed");
|
|
73
|
+
}
|
|
74
|
+
if (!pollRes.ok) {
|
|
75
|
+
// 瞬时 5xx(如 remote 重启窗口):继续轮询,不因一次抖动退出
|
|
76
|
+
log(`pairing poll 暂时失败(HTTP ${pollRes.status}),继续重试...`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const body = (await pollRes.json());
|
|
80
|
+
if (body.status === "claimed") {
|
|
81
|
+
const cfg = {
|
|
82
|
+
id: body.id,
|
|
83
|
+
device_token: body.device_token,
|
|
84
|
+
machine_name,
|
|
85
|
+
paired_at: Date.now(),
|
|
86
|
+
};
|
|
87
|
+
saveDevice(cfg, configDir);
|
|
88
|
+
log(`配对成功,设备已绑定: ${machine_name} (${body.id})`);
|
|
89
|
+
return cfg;
|
|
90
|
+
}
|
|
91
|
+
// status === "pending" → 继续轮询
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// daemon 管理面的文件约定与 pidfile:~/.super-coding/daemon.pid、daemon.log。
|
|
2
|
+
// 独立小模块(manage.ts 与 index.ts 都依赖它,避免循环 import)。
|
|
3
|
+
// SC_HOME 可覆盖目录(测试注入)。
|
|
4
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
export function manageDir() {
|
|
8
|
+
return process.env.SC_HOME ?? join(homedir(), ".super-coding");
|
|
9
|
+
}
|
|
10
|
+
export function pidFile() {
|
|
11
|
+
return join(manageDir(), "daemon.pid");
|
|
12
|
+
}
|
|
13
|
+
export function logFile() {
|
|
14
|
+
return join(manageDir(), "daemon.log");
|
|
15
|
+
}
|
|
16
|
+
export function readPid() {
|
|
17
|
+
try {
|
|
18
|
+
const state = JSON.parse(readFileSync(pidFile(), "utf8"));
|
|
19
|
+
return Number.isFinite(state.pid) && state.pid > 0 ? state : null;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function writePid(pid, extra = {}) {
|
|
26
|
+
mkdirSync(manageDir(), { recursive: true });
|
|
27
|
+
// tmp + rename 原子写,杜绝半截 JSON
|
|
28
|
+
const tmp = `${pidFile()}.${process.pid}.tmp`;
|
|
29
|
+
writeFileSync(tmp, JSON.stringify({ pid, started_at: Date.now(), ...extra }));
|
|
30
|
+
renameSync(tmp, pidFile());
|
|
31
|
+
}
|
|
32
|
+
/** expectedPid 给定时仅删除匹配的 pidfile(后到的实例不得误删他人的) */
|
|
33
|
+
export function removePid(expectedPid) {
|
|
34
|
+
if (expectedPid !== undefined && readPid()?.pid !== expectedPid)
|
|
35
|
+
return;
|
|
36
|
+
rmSync(pidFile(), { force: true });
|
|
37
|
+
}
|
|
38
|
+
/** 信号 0 探活(无权限/不存在都视为不活) */
|
|
39
|
+
export function isAlive(pid) {
|
|
40
|
+
try {
|
|
41
|
+
process.kill(pid, 0);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** 带 code 的 RPC 错误:handler 抛出时按 code 回包 */
|
|
2
|
+
export class RpcException extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "RpcException";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* daemon 侧 RPC dispatcher:server 经 WS 下发 {type:"rpc", id, method, params},
|
|
12
|
+
* 这里按 method 分发到注册的 handler,回 {type:"rpc:result", id, result|error}。
|
|
13
|
+
*/
|
|
14
|
+
export class RpcRouter {
|
|
15
|
+
handlers = new Map();
|
|
16
|
+
register(method, handler) {
|
|
17
|
+
this.handlers.set(method, handler);
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
async dispatch(msg, send) {
|
|
21
|
+
const handler = this.handlers.get(msg.method);
|
|
22
|
+
if (!handler) {
|
|
23
|
+
send({
|
|
24
|
+
type: "rpc:result",
|
|
25
|
+
id: msg.id,
|
|
26
|
+
error: { code: "unknown_method", message: `unknown rpc method: ${msg.method}` },
|
|
27
|
+
});
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const result = await handler(msg.params ?? {});
|
|
32
|
+
send({ type: "rpc:result", id: msg.id, result: result ?? { ok: true } });
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
const error = err instanceof RpcException
|
|
36
|
+
? { code: err.code, message: err.message }
|
|
37
|
+
: { code: "internal", message: err instanceof Error ? err.message : String(err) };
|
|
38
|
+
send({ type: "rpc:result", id: msg.id, error });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|