// 本地守护进程:跑在用户电脑上,持有仓库、.env、工具链。 // 它不调模型、不存会话,只做一件事:领取工具调用 → 在仓库里执行 → 回传结果。 // 为了演示,每次执行后顺手拍一下 git 快照(status / diff),写进 trace。 const fs = require('node:fs'); const path = require('node:path'); const { spawn, execFileSync } = require('node:child_process'); const { tracer } = require('../shared'); const CLOUD = process.env.CLOUD_URL; const DEVICE = process.env.DEVICE_ID; const REPO = process.env.REPO_DIR; const trace = tracer('laptop-daemon'); let jobSeq = Number(process.env.JOB_SEQ_START || 1); const done = new Map(); // call_id -> 已执行结果(同一个调用被重发时不重复执行) const git = (...a) => { try { return execFileSync('git', a, { cwd: REPO, encoding: 'utf8' }); } catch (e) { return e.stdout || ''; } }; function repoSnapshot() { return { status: git('status', '--short').trim(), diff: git('diff').trim(), head: git('rev-parse', '--short', 'HEAD').trim() }; } function sh(command, timeoutMs) { return new Promise((ok) => { const child = spawn('/bin/sh', ['-c', command], { cwd: REPO, env: process.env }); let out = ''; child.stdout.on('data', (c) => (out += c)); child.stderr.on('data', (c) => (out += c)); const timer = timeoutMs ? setTimeout(() => child.kill('SIGKILL'), timeoutMs) : null; child.on('exit', (code) => { if (timer) clearTimeout(timer); ok({ code, out, pid: child.pid }); }); }); } async function runTool(cmd) { const { tool, input } = cmd; const file = input.path ? path.join(REPO, input.path) : null; if (tool === 'read_file') { const lines = fs.readFileSync(file, 'utf8').split('\n'); return { output: lines.map((l, i) => `${String(i + 1).padStart(3)}| ${l}`).join('\n') }; } if (tool === 'edit_file') { const src = fs.readFileSync(file, 'utf8'); const n = src.split(input.old_text).length - 1; if (n !== 1) return { output: `错误:old_text 出现了 ${n} 次,需要恰好 1 次` }; fs.writeFileSync(file, src.replace(input.old_text, input.new_text)); return { output: `已修改 ${input.path}` }; } if (tool === 'write_file') { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, input.content); return { output: `已写入 ${input.path}(${input.content.length} 字节)` }; } if (tool === 'run_command') { const r = await sh(input.command, 15000); trace('process_exit', { command: input.command, child_pid: r.pid, exit_code: r.code }); return { output: `退出码 ${r.code}\n${r.out.slice(-3000)}`, child_pid: r.pid }; } if (tool === 'start_background') { const jobId = `job-${jobSeq++}`; const started = Date.now(); const child = spawn('/bin/sh', ['-c', input.command], { cwd: REPO, env: process.env }); let out = ''; child.stdout.on('data', (c) => (out += c)); child.stderr.on('data', (c) => (out += c)); trace('job_start', { job_id: jobId, command: input.command, child_pid: child.pid, session: cmd.session }); child.on('exit', async (code) => { trace('job_exit', { job_id: jobId, child_pid: child.pid, exit_code: code, ms: Date.now() - started }); const tail = out.trim().split('\n').slice(-12).join('\n'); await postRetry(`/sessions/${cmd.session}/events`, { type: 'job_finished', job_id: jobId, command: input.command, exit_code: code, tail, ran_on: DEVICE, ran_pid: child.pid, by: `daemon:${process.pid}` }); }); return { output: `已在后台启动 ${jobId}(pid ${child.pid})`, child_pid: child.pid }; } return { output: `未知工具 ${tool}` }; } async function postRetry(p, b) { for (let i = 0; i < 30; i++) { try { return await (await fetch(CLOUD + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) })).json(); } catch { await new Promise((r) => setTimeout(r, 1000)); } } } async function handle(cmd) { const started = Date.now(); let result = done.get(cmd.call_id); const replay = !!result; if (!result) { try { result = await runTool(cmd); } catch (e) { result = { output: `错误:${e.message}` }; } done.set(cmd.call_id, result); } const repo_after = repoSnapshot(); trace('tool_exec', { call_id: cmd.call_id, tool: cmd.tool, input: cmd.input, cwd: REPO, ms: Date.now() - started, replay, output: result.output, child_pid: result.child_pid, repo_after }); await postRetry(`/devices/${DEVICE}/result`, { req_id: cmd.req_id, output: result.output, host: DEVICE, pid: process.pid, repo_after: { status: repo_after.status } }); } let stopping = false; async function loop() { trace('daemon_start', { device: DEVICE, repo: REPO, repo_state: repoSnapshot() }); while (!stopping) { let cmd = null; try { cmd = await (await fetch(`${CLOUD}/devices/${DEVICE}/poll`)).json(); } catch { await new Promise((r) => setTimeout(r, 1000)); continue; } if (cmd) handle(cmd); } } process.on('SIGTERM', async () => { stopping = true; trace('daemon_stop', { device: DEVICE, reason: 'laptop lid closed (SIGTERM)' }); try { await fetch(`${CLOUD}/devices/${DEVICE}/bye`, { method: 'POST' }); } catch {} process.exit(0); }); loop();