@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.
Files changed (37) hide show
  1. package/README.md +70 -2
  2. package/dist/cli.js +35 -0
  3. package/dist/daemon/adapters/base.js +37 -0
  4. package/dist/daemon/adapters/claude-code.js +184 -0
  5. package/dist/daemon/adapters/cursor.js +138 -0
  6. package/dist/daemon/adapters/features.js +51 -0
  7. package/dist/daemon/adapters/registry.js +9 -0
  8. package/dist/daemon/agent/builtin/agent.md +164 -0
  9. package/dist/daemon/agent/builtin/load.js +121 -0
  10. package/dist/daemon/agent/builtin/parse.js +73 -0
  11. package/dist/daemon/agent/index.js +60 -0
  12. package/dist/daemon/agent/manifest.js +72 -0
  13. package/dist/daemon/agent/materialize.js +220 -0
  14. package/dist/daemon/agent/renderers/claude-code.js +16 -0
  15. package/dist/daemon/agent/renderers/common.js +61 -0
  16. package/dist/daemon/agent/renderers/cursor.js +13 -0
  17. package/dist/daemon/agent/renderers/generic.js +5 -0
  18. package/dist/daemon/agent/resolve.js +212 -0
  19. package/dist/daemon/agent/slug.js +27 -0
  20. package/dist/daemon/agent/types.js +8 -0
  21. package/dist/daemon/device-store.js +61 -0
  22. package/dist/daemon/index.js +280 -0
  23. package/dist/daemon/manage.js +183 -0
  24. package/dist/daemon/orchestrator.js +364 -0
  25. package/dist/daemon/pairing.js +93 -0
  26. package/dist/daemon/paths.js +47 -0
  27. package/dist/daemon/rpc.js +41 -0
  28. package/dist/daemon/run-skills.js +148 -0
  29. package/dist/daemon/services/agent-config.js +1 -0
  30. package/dist/daemon/services/claude-provider.js +126 -0
  31. package/dist/daemon/services/cursor-provider.js +94 -0
  32. package/dist/daemon/services/json-file.js +21 -0
  33. package/dist/daemon/services/log-buffer.js +43 -0
  34. package/dist/daemon/services/mcp-secrets.js +36 -0
  35. package/dist/daemon/session/store.js +137 -0
  36. package/dist/daemon/transport/ws-client.js +121 -0
  37. package/package.json +44 -3
@@ -0,0 +1,280 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import { loadBuiltinAgent } from "./agent/index.js";
4
+ import { detectRunnerFeatures } from "./adapters/features.js";
5
+ import { adapters, agentBinaries } from "./adapters/registry.js";
6
+ import { loadDaemonConfig, loadDevice, removeDevice, restoreDeviceBackup } from "./device-store.js";
7
+ import { logFile } from "./paths.js";
8
+ import { Orchestrator } from "./orchestrator.js";
9
+ import { runPairing, PairingTimeoutError } from "./pairing.js";
10
+ import { RpcException, RpcRouter } from "./rpc.js";
11
+ import { ClaudeConfigProvider } from "./services/claude-provider.js";
12
+ import { CursorConfigProvider } from "./services/cursor-provider.js";
13
+ import { LogBuffer } from "./services/log-buffer.js";
14
+ import { RunStore } from "./session/store.js";
15
+ import { DaemonWsClient } from "./transport/ws-client.js";
16
+ const pkg = createRequire(import.meta.url)("../../package.json");
17
+ // 默认连公共中转(集中化 remote);DAEMON_SERVER_URL 可覆盖(本地调试指 ws://127.0.0.1:3000/daemon)
18
+ const WS_URL = process.env.DAEMON_SERVER_URL ?? "wss://super-coding-api.icezeros.com/daemon";
19
+ const HTTP_URL = process.env.DAEMON_SERVER_HTTP_URL ?? WS_URL.replace(/^ws/, "http").replace(/\/daemon$/, "");
20
+ // agent 注入总开关(SC_AGENT_INJECTION=off 保留旧行为:不写 agent 文件、不声明能力)
21
+ const AGENT_INJECTION_ENABLED = process.env["SC_AGENT_INJECTION"] !== "off";
22
+ /** 与 packages/protocol 的 DAEMON_FEATURE_BUSINESS_AGENT 一致;内联避免依赖 dist 新鲜度 */
23
+ const DAEMON_FEATURES = AGENT_INJECTION_ENABLED ? ["business_agent_v1"] : [];
24
+ /** 对每个已注册 agent 跑 `<bin> --version`,失败(未安装/超时 3s)则不上报 */
25
+ async function detectAgents() {
26
+ const probes = [...adapters.keys()].map((id) => new Promise((resolve) => {
27
+ const bin = agentBinaries[id];
28
+ if (!bin)
29
+ return resolve(null);
30
+ execFile(bin, ["--version"], { timeout: 3_000 }, (err, stdout) => {
31
+ if (err)
32
+ return resolve(null);
33
+ resolve({ id, version: stdout.trim().split("\n")[0] || "unknown" });
34
+ });
35
+ }));
36
+ const results = await Promise.all(probes);
37
+ return results.filter((r) => r !== null);
38
+ }
39
+ export async function runDaemon() {
40
+ const logBuffer = new LogBuffer();
41
+ logBuffer.instrumentConsole(logFile()); // 前台模式同时落盘 daemon.log(后台由 stdio 重定向承载)
42
+ // 信号处理提前注册:配对/探测/reattach 期间收到 stop 也要优雅收尾(否则默认信号直接杀进程)
43
+ let orchRef;
44
+ let wsClient;
45
+ let presenceTimer;
46
+ let shuttingDown = false;
47
+ const shutdown = async (signal) => {
48
+ if (shuttingDown)
49
+ return;
50
+ shuttingDown = true;
51
+ console.log(`[daemon] received ${signal}, shutting down...`);
52
+ if (presenceTimer)
53
+ clearInterval(presenceTimer);
54
+ await orchRef?.shutdown();
55
+ wsClient?.close();
56
+ process.exit(0);
57
+ };
58
+ process.on("SIGINT", () => void shutdown("SIGINT"));
59
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
60
+ const daemonConfig = loadDaemonConfig();
61
+ // 4401 误删自愈:daemon.json 缺失时从备份恢复(真解绑走 device:unpair 会连备份清除)
62
+ let device = loadDevice() ?? restoreDeviceBackup();
63
+ if (device) {
64
+ console.log("[daemon] 已从备份恢复设备凭证");
65
+ }
66
+ if (!device) {
67
+ try {
68
+ device = await runPairing(HTTP_URL);
69
+ }
70
+ catch (err) {
71
+ if (err instanceof PairingTimeoutError) {
72
+ console.error(`[daemon] ${err.message}`);
73
+ process.exit(err.exitCode);
74
+ }
75
+ console.error(`[daemon] 配对失败:${err instanceof Error ? err.message : String(err)}`);
76
+ process.exit(2);
77
+ }
78
+ }
79
+ const agents = await detectAgents();
80
+ console.log(`[daemon] detected agents: ${agents.map((a) => `${a.id}@${a.version}`).join(", ") || "(none)"}`);
81
+ // 启动即自检内置 agent 定义:打包漏文件/语法错误在日志里暴露,而不是等用户任务失败
82
+ if (AGENT_INJECTION_ENABLED) {
83
+ const builtin = loadBuiltinAgent();
84
+ const level = builtin.source === "file" ? "[daemon]" : "[daemon][warn]";
85
+ console.log(`${level} builtin agent: ${builtin.name} (${builtin.source}) ${builtin.path}`);
86
+ }
87
+ const store = new RunStore();
88
+ const router = new RpcRouter();
89
+ // 配置面 provider:每 agent 一个,直接读写各自配置文件
90
+ const providers = new Map([new ClaudeConfigProvider(), new CursorConfigProvider()].map((p) => [p.agent_id, p]));
91
+ const providerOf = (agent) => {
92
+ const p = providers.get(agent);
93
+ if (!p)
94
+ throw new RpcException("agent_unavailable", `unknown agent: ${agent}`);
95
+ return p;
96
+ };
97
+ // agent:list:已注册 adapter 全集;installed/version 来自启动时探测
98
+ router.register("agent:list", async () => {
99
+ const detected = new Map(agents.map((a) => [a.id, a.version]));
100
+ const list = [];
101
+ for (const adapter of adapters.values()) {
102
+ const provider = providers.get(adapter.id);
103
+ const config = await provider?.getConfig();
104
+ list.push({
105
+ id: adapter.id,
106
+ version: detected.get(adapter.id) ?? "",
107
+ installed: detected.has(adapter.id),
108
+ capabilities: adapter.capabilities,
109
+ default_model: config?.model,
110
+ available_models: provider?.available_models ?? [],
111
+ });
112
+ }
113
+ return { agents: list };
114
+ });
115
+ router
116
+ .register("config:get", (p) => providerOf(p.agent).getConfig().then((config) => ({ config })))
117
+ .register("config:set", (p) => {
118
+ const { agent, patch } = p;
119
+ return providerOf(agent).setConfig(patch).then((config) => ({ config }));
120
+ })
121
+ .register("skill:list", async (p) => {
122
+ const provider = providerOf(p.agent);
123
+ try {
124
+ return { supported: true, skills: await provider.listSkills() };
125
+ }
126
+ catch (err) {
127
+ // unsupported 也结构化返回,便于前端灰化
128
+ if (err instanceof RpcException && err.code === "unsupported")
129
+ return { supported: false, skills: [] };
130
+ throw err;
131
+ }
132
+ })
133
+ .register("skill:install", async (p) => {
134
+ const { agent, name, content } = p;
135
+ await providerOf(agent).installSkill(name, content);
136
+ return { ok: true };
137
+ })
138
+ .register("skill:remove", async (p) => {
139
+ const { agent, name } = p;
140
+ await providerOf(agent).removeSkill(name);
141
+ return { ok: true };
142
+ })
143
+ .register("mcp:list", async (p) => ({ servers: await providerOf(p.agent).listMcp() }))
144
+ .register("mcp:add", async (p) => {
145
+ const { agent, name, spec } = p;
146
+ await providerOf(agent).addMcp(name, spec);
147
+ return { ok: true };
148
+ })
149
+ .register("mcp:remove", async (p) => {
150
+ const { agent, name } = p;
151
+ await providerOf(agent).removeMcp(name);
152
+ return { ok: true };
153
+ })
154
+ .register("memory:get", (p) => {
155
+ const { agent, scope, cwd } = p;
156
+ return providerOf(agent).getMemory(scope, cwd);
157
+ })
158
+ .register("memory:put", async (p) => {
159
+ const { agent, scope, cwd, content } = p;
160
+ await providerOf(agent).putMemory(scope, content, cwd);
161
+ return { ok: true };
162
+ })
163
+ .register("run:list", () => ({
164
+ // RunRecord(SQLite 行,run_id/createdAt)→ RunListItem 契约(id/created_at)
165
+ runs: store.listRuns().map((t) => ({
166
+ id: t.run_id,
167
+ agent: t.agent,
168
+ cwd: t.cwd,
169
+ status: t.status,
170
+ ...(t.native_session_id !== undefined ? { native_session_id: t.native_session_id } : {}),
171
+ created_at: t.createdAt,
172
+ })),
173
+ }))
174
+ .register("log:tail", (p) => ({ lines: logBuffer.tail(p.limit) }))
175
+ .register("device:unpair", () => {
176
+ // 先回包再退出:延迟触发,让 rpc:result 有机会发出
177
+ setTimeout(() => {
178
+ removeDevice(undefined, { keepBackup: false });
179
+ console.log("[daemon] unpaired, exiting");
180
+ process.exit(0);
181
+ }, 100).unref();
182
+ return { ok: true };
183
+ });
184
+ // orch 与 ws 相互依赖:orch 用转发 sender,连接建立后消息才会真正发出
185
+ let wsRef;
186
+ const orch = new Orchestrator({ send: (m) => wsRef.send(m) }, store, adapters, router, daemonConfig.allowedCwds, (runner) => detectRunnerFeatures(runner, agentBinaries[runner] ?? runner));
187
+ orchRef = orch;
188
+ // 4401 双确认:平台瞬时空读也可能让 auth 回源报「查无此设备」——首次只告警并
189
+ // 自动退避重连(保留凭证),连续第二次才认定确定性解绑(凭证删前备份见 removeDevice)。
190
+ // 连接健康(收到任意消息)即清零。
191
+ let unauth4401Streak = 0;
192
+ /**
193
+ * 删凭证前的旁证:服务端/webhook 当前是否健康。
194
+ * webhook 抖动期可能出现「假 404」,此时删凭证会陷入「配对也走 webhook 所以配不上」的死锁,
195
+ * 用户只能手动重启——这正是「设备莫名离线、重启就好」的由来。
196
+ */
197
+ const probeBackendHealthy = async () => {
198
+ try {
199
+ const res = await fetch(`${HTTP_URL}/health/webhook`, { signal: AbortSignal.timeout(3_000) });
200
+ if (!res.ok)
201
+ return false;
202
+ const body = (await res.json());
203
+ return body.ok !== false;
204
+ }
205
+ catch {
206
+ return false; // 探不到就当不健康:宁可多保留凭证,也不要误删
207
+ }
208
+ };
209
+ const connectWith = (cfg) => {
210
+ const client = new DaemonWsClient({
211
+ url: WS_URL,
212
+ device_token: cfg.device_token,
213
+ daemon_version: pkg.version,
214
+ agents,
215
+ ...(DAEMON_FEATURES.length ? { features: DAEMON_FEATURES } : {}),
216
+ onMessage: (msg) => {
217
+ unauth4401Streak = 0;
218
+ // 必须兜住拒绝:handleMessage 是 async,任一步抛出都会变成 unhandledRejection,
219
+ // Node 15+ 默认终止进程 → 表现成「daemon 莫名消失」
220
+ void orch.handleMessage(msg).catch((err) => {
221
+ console.error(`[daemon] 处理 ${msg.type} 失败: ${err instanceof Error ? err.message : String(err)}`);
222
+ });
223
+ },
224
+ onClose: (code, reason) => {
225
+ // 确定性解绑(CES 查无此设备 → 4401 unauthorized):清凭证就地重新配对。
226
+ // 覆盖离线解绑/反向通知 RPC 未送达的场景(否则旧凭证残留导致永不出配对码)。
227
+ // 其余 close(如 4402 auth_unavailable)保留凭证,走自动退避重连。
228
+ if (code === 4401 && reason === "unauthorized") {
229
+ unauth4401Streak += 1;
230
+ if (unauth4401Streak < 2) {
231
+ console.warn(`[daemon] 鉴权 4401(第 ${unauth4401Streak} 次),保留凭证自动重连;连续两次才判定解绑`);
232
+ return; // ws-client 自动退避重连
233
+ }
234
+ void (async () => {
235
+ if (!(await probeBackendHealthy())) {
236
+ console.warn("[daemon] 连续 4401,但服务端/webhook 当前不健康,暂不判定解绑(保留凭证继续重连)");
237
+ unauth4401Streak = 0;
238
+ return; // ws-client 已调度重连
239
+ }
240
+ client.close(); // 取消自动重连
241
+ removeDevice();
242
+ unauth4401Streak = 0;
243
+ console.log("[daemon] 设备已被解绑(4401 连续确认),重新配对...");
244
+ void rePair();
245
+ })();
246
+ }
247
+ },
248
+ });
249
+ wsRef = client;
250
+ wsClient = client;
251
+ return client;
252
+ };
253
+ const rePair = async () => {
254
+ try {
255
+ const cfg = await runPairing(HTTP_URL);
256
+ const client = connectWith(cfg);
257
+ client.connect();
258
+ console.log(`[daemon] connecting to ${WS_URL} ...`);
259
+ }
260
+ catch (err) {
261
+ if (err instanceof PairingTimeoutError) {
262
+ console.error(`[daemon] ${err.message}`);
263
+ process.exit(err.exitCode);
264
+ }
265
+ console.error(`[daemon] 配对失败:${err instanceof Error ? err.message : String(err)}`);
266
+ process.exit(2);
267
+ }
268
+ };
269
+ // 先建 client 赋值 wsRef,再 reattach(reattach 的 presence 上报经 wsRef.send)
270
+ const ws = connectWith(device);
271
+ // 跨重启重挂:恢复本地 running 任务的会话控制面(--resume native_session_id)
272
+ await orch.reattach();
273
+ ws.connect();
274
+ console.log(`[daemon] connecting to ${WS_URL} ...`);
275
+ // 周期性 presence(agents/负载心跳);会话变化由 orchestrator 即时上报
276
+ presenceTimer = setInterval(() => {
277
+ wsRef.send({ type: "presence", running: orch.activeRuns(), agents });
278
+ }, 60_000);
279
+ presenceTimer.unref();
280
+ }
@@ -0,0 +1,183 @@
1
+ // daemon 守护进程管理:start/stop/restart/status/logs + 前台 run。
2
+ // start 以 detached + stdio 重定向方式后台派生 `daemon run`;run(前台)同样写 pidfile,
3
+ // 前后台均可被 stop/status 管理。文件约定见 paths.ts(~/.super-coding/daemon.pid、daemon.log)。
4
+ // pidfile 双写语义:start 先写派生 pid 保证立即可管,run 自注册为自身 pid(dev 下 tsx 包装
5
+ // 进程会派生孙进程,最终 pidfile 以 run 写入的为准)。
6
+ // 日志轮转:超 10MB 时 rename 为 daemon.log.1(一代)。
7
+ import { spawn } from "node:child_process";
8
+ import { closeSync, openSync, readFileSync, readSync, renameSync, statSync, } from "node:fs";
9
+ import { createRequire } from "node:module";
10
+ import { logFile, isAlive, manageDir, readPid, removePid, writePid } from "./paths.js";
11
+ import { mkdirSync } from "node:fs";
12
+ import { runDaemon } from "./index.js";
13
+ const require = createRequire(import.meta.url);
14
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
15
+ const DEFAULT_SERVER = "wss://super-coding-api.icezeros.com/daemon";
16
+ function childArgs(entry) {
17
+ // dev(tsx 跑 src/cli.ts)与产物(node dist/cli.js)双模式派生
18
+ // tsx 的 exports 不开放 ./dist/cli.mjs,须经官方子路径 tsx/cli 解析 CLI 入口
19
+ return entry.endsWith(".ts")
20
+ ? [require.resolve("tsx/cli"), entry, "daemon", "run"]
21
+ : [entry, "daemon", "run"];
22
+ }
23
+ const MAX_LOG_BYTES = 10 * 1024 * 1024;
24
+ function rotateLogIfNeeded() {
25
+ try {
26
+ if (statSync(logFile()).size > MAX_LOG_BYTES)
27
+ renameSync(logFile(), `${logFile()}.1`);
28
+ }
29
+ catch {
30
+ /* 无日志文件,跳过 */
31
+ }
32
+ }
33
+ export async function startDaemon(opts = {}) {
34
+ const existing = readPid();
35
+ if (existing && isAlive(existing.pid)) {
36
+ console.log(`daemon 已在运行(pid ${existing.pid})`);
37
+ return;
38
+ }
39
+ rotateLogIfNeeded();
40
+ mkdirSync(manageDir(), { recursive: true });
41
+ const logFd = openSync(logFile(), "a");
42
+ const entry = opts.entry ?? process.argv[1];
43
+ const spawnImpl = opts.spawnImpl ?? spawn;
44
+ let child;
45
+ try {
46
+ child = spawnImpl(process.execPath, childArgs(entry), {
47
+ detached: true,
48
+ stdio: ["ignore", logFd, logFd],
49
+ // SC_DAEMON_LOG_VIA_STDIO:stdio 已重定向到日志文件,子进程跳过文件 sink 防双写
50
+ env: { ...process.env, SC_DAEMON_LOG_VIA_STDIO: "1" },
51
+ });
52
+ child.unref();
53
+ }
54
+ finally {
55
+ closeSync(logFd);
56
+ }
57
+ if (!child.pid)
58
+ throw new Error(`daemon 派生失败(日志:${logFile()})`);
59
+ writePid(child.pid, { server: process.env.DAEMON_SERVER_URL ?? DEFAULT_SERVER });
60
+ // 秒退检测:入口即崩/tsx 缺失等场景不能误报"已启动"
61
+ const died = await new Promise((resolve) => {
62
+ const timer = setTimeout(() => resolve(false), 400);
63
+ child.once("exit", () => {
64
+ clearTimeout(timer);
65
+ resolve(true);
66
+ });
67
+ child.once("error", () => {
68
+ clearTimeout(timer);
69
+ resolve(true);
70
+ });
71
+ });
72
+ if (died) {
73
+ removePid(child.pid);
74
+ throw new Error(`daemon 启动失败,进程立即退出(日志:${logFile()})`);
75
+ }
76
+ console.log(`daemon 已后台启动(pid ${child.pid})`);
77
+ console.log(`日志:${logFile()}`);
78
+ }
79
+ export async function stopDaemon() {
80
+ const state = readPid();
81
+ if (!state || !isAlive(state.pid)) {
82
+ if (state)
83
+ removePid(state.pid);
84
+ console.log("daemon 未在运行");
85
+ return;
86
+ }
87
+ process.kill(state.pid, "SIGTERM");
88
+ for (let i = 0; i < 50; i += 1) {
89
+ if (!isAlive(state.pid))
90
+ break;
91
+ await sleep(100);
92
+ }
93
+ if (isAlive(state.pid)) {
94
+ process.kill(state.pid, "SIGKILL");
95
+ await sleep(200);
96
+ }
97
+ removePid(state.pid);
98
+ console.log(`daemon 已停止(pid ${state.pid})`);
99
+ }
100
+ export async function restartDaemon(opts = {}) {
101
+ await stopDaemon();
102
+ await startDaemon(opts);
103
+ }
104
+ export function statusDaemon() {
105
+ const state = readPid();
106
+ if (state && isAlive(state.pid)) {
107
+ const upS = Math.floor((Date.now() - state.started_at) / 1000);
108
+ console.log(`daemon 运行中(pid ${state.pid})`);
109
+ console.log(` 运行时长:${upS}s`);
110
+ console.log(` 服务地址:${state.server ?? process.env.DAEMON_SERVER_URL ?? DEFAULT_SERVER}`);
111
+ console.log(` 日志:${logFile()}`);
112
+ }
113
+ else {
114
+ console.log("daemon 未在运行");
115
+ }
116
+ }
117
+ /** 尾部 n 行(纯函数,便于测试) */
118
+ export function readLogTail(text, n) {
119
+ const lines = text.split("\n");
120
+ // 文件常以换行结尾:丢弃末尾空串
121
+ if (lines.length && lines[lines.length - 1] === "")
122
+ lines.pop();
123
+ return lines.slice(Math.max(0, lines.length - n));
124
+ }
125
+ /** 按字节读取 [offset, EOF) 追加内容;stat size 是字节,不能用字符串下标切多字节日志 */
126
+ export function readAppend(path, offset) {
127
+ const size = statSync(path).size;
128
+ const start = size < offset ? 0 : offset; // 截断/轮转重建后从头读
129
+ if (size === start)
130
+ return null;
131
+ const buf = Buffer.alloc(size - start);
132
+ const fd = openSync(path, "r");
133
+ try {
134
+ let read = 0;
135
+ while (read < buf.length) {
136
+ const r = readSync(fd, buf, read, buf.length - read, start + read);
137
+ if (r <= 0)
138
+ break;
139
+ read += r;
140
+ }
141
+ return { buf: read === buf.length ? buf : buf.subarray(0, read), offset: start + read };
142
+ }
143
+ finally {
144
+ closeSync(fd);
145
+ }
146
+ }
147
+ export async function logsDaemon(opts = {}) {
148
+ const path = logFile();
149
+ const n = opts.lines ?? 50;
150
+ let offset = 0;
151
+ try {
152
+ const text = readFileSync(path, "utf8");
153
+ for (const line of readLogTail(text, n))
154
+ console.log(line);
155
+ offset = statSync(path).size;
156
+ }
157
+ catch {
158
+ console.log(`(暂无日志:${path})`);
159
+ }
160
+ if (!opts.follow)
161
+ return;
162
+ // 轮询追加(--follow 语义同 tail -f);按字节 range 读,多字节内容不错位
163
+ for (;;) {
164
+ await sleep(500);
165
+ try {
166
+ const next = readAppend(path, offset);
167
+ if (next) {
168
+ offset = next.offset;
169
+ process.stdout.write(next.buf);
170
+ }
171
+ }
172
+ catch {
173
+ /* 文件暂缺时静默等待 */
174
+ }
175
+ }
176
+ }
177
+ /** 前台运行(调试/被外部 supervisor 托管时用):写 pidfile 使 stop/status 可管 */
178
+ export function runDaemonForeground() {
179
+ rotateLogIfNeeded();
180
+ writePid(process.pid, { server: process.env.DAEMON_SERVER_URL ?? DEFAULT_SERVER });
181
+ process.on("exit", () => removePid(process.pid));
182
+ void runDaemon();
183
+ }