@nocoo/eagle-agent 0.3.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 ADDED
@@ -0,0 +1,52 @@
1
+ # @nocoo/eagle-agent
2
+
3
+ Read-only Herdr inventory, task evidence, machine resources and named TCP port checks for Eagle. Requires Node.js 24+ and the Herdr CLI on macOS or Linux.
4
+
5
+ ## Download and install
6
+
7
+ Check `node --version`, `npm --version` and `herdr --version` first. Install Node.js 24+ from https://nodejs.org/en/download if needed, and have Herdr installed and running. No Eagle repository checkout, TypeScript compiler or npm login is required to install this public package.
8
+
9
+ Download and install the pinned release from the official npm registry:
10
+
11
+ ```sh
12
+ npm install -g @nocoo/eagle-agent@0.3.0 --registry=https://registry.npmjs.org
13
+ ```
14
+
15
+ **If npm is unreachable or times out, use the Tencent Cloud mirror first / npm 连不上时首选腾讯云镜像:**
16
+
17
+ ```sh
18
+ npm install -g @nocoo/eagle-agent@0.3.0 --registry=https://mirrors.cloud.tencent.com/npm/
19
+ ```
20
+
21
+ `--registry` applies only to this installation; it does not change your global npm configuration. Mirrors may take time to synchronize a new release: for `404` / `ETARGET`, retry later or use the official registry once reachable. Keep the pinned version, HTTPS and certificate verification. Eagle credentials are unrelated to npm and must never be sent to a registry.
22
+
23
+ Verify the installation before configuring the agent:
24
+
25
+ ```sh
26
+ eagle-agent --version # expected: 0.3.0
27
+ eagle-agent --help
28
+ ```
29
+
30
+ If the command is missing, ensure npm's global `bin` directory is on your PATH. Use a user-owned Node/npm installation if global installation fails with `EACCES`.
31
+
32
+ ## Connect a machine
33
+
34
+ Open Eagle's **Connect** page, create a machine and copy its onboarding prompt. Give the prompt to the management agent on that machine. It contains a machine-scoped credential shown only once.
35
+
36
+ Store configuration at `~/.config/eagle/agent.json` (directory 0700, file 0600). `EAGLE_CONFIG` selects another file. `eagle-agent init` reads JSON from stdin and creates this file securely; it refuses to overwrite an existing file. Never pass a token in command arguments, commit it, print it, or include it in reports. Rotation requires updating the token in the existing secure configuration.
37
+
38
+ ```sh
39
+ eagle-agent once
40
+ eagle-agent watch
41
+ eagle-agent collect /private/path/report.json
42
+ eagle-agent upload /private/path/report.json
43
+ eagle-agent heartbeat
44
+ ```
45
+
46
+ `once` collects all running Herdr sessions and sends a complete snapshot. `watch` repeats every 30 seconds by default. Set up a user launchd or systemd service to keep it running across restarts, with the correct absolute executable paths and PATH for Node and Herdr. Do not run overlapping collectors for one configuration.
47
+
48
+ The machine's Durable Object maintains current state. Unacknowledged reports remain in a private local spool; retries preserve their IDs. Older reports cannot roll back current state. Hourly summaries and new D1 archive writes are paused.
49
+
50
+ Optional `watchPorts`: `[{ "name": "Raven", "port": 7024 }]`. Only loopback TCP checks are supported; a listening port does not prove service or task health. CPU, RAM, disk and uptime are sampled automatically. Task status combines terminal summaries, goals, Git, tests, processes and deployment evidence; pane lifecycle badges are weak hints.
51
+
52
+ Detailed configuration and manager evidence format: https://github.com/nocoo/eagle/blob/main/docs/AGENT.md
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, open, readdir, readFile, rename, stat, writeFile, } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { z } from "zod";
6
+ import { ReportSchema, WatchPortsSchema } from "../src/shared/schema.js";
7
+ import { checkUrl, collect, sendReport, } from "./collector.js";
8
+ import { drainSpool } from "./spool.js";
9
+ import { AGENT_VERSION } from "./version.js";
10
+ const ConfigSchema = z.strictObject({
11
+ url: z.string().url(),
12
+ token: z.string().min(32),
13
+ machineId: z.string().regex(/^[a-z0-9][a-z0-9_-]*$/),
14
+ machineName: z.string().min(1),
15
+ evidenceFile: z.string().optional(),
16
+ intervalSeconds: z.number().int().min(15).max(3600).default(30),
17
+ codexDir: z.string().optional(),
18
+ spoolDir: z.string().optional(),
19
+ watchPorts: WatchPortsSchema.default([]),
20
+ });
21
+ const path = process.env.EAGLE_CONFIG || join(homedir(), ".config/eagle/agent.json");
22
+ async function heartbeat(config, warning) {
23
+ const response = await fetch(`${checkUrl(config.url)}/api/v1/heartbeat`, {
24
+ method: "POST",
25
+ redirect: "error",
26
+ signal: AbortSignal.timeout(15000),
27
+ headers: {
28
+ Authorization: `Bearer ${config.token}`,
29
+ "Content-Type": "application/json",
30
+ },
31
+ body: JSON.stringify({
32
+ schemaVersion: 1,
33
+ machineId: config.machineId,
34
+ sentAt: new Date().toISOString(),
35
+ ...(warning ? { warning } : {}),
36
+ }),
37
+ });
38
+ if (!response.ok)
39
+ throw new Error(`Heartbeat rejected (${response.status})`);
40
+ }
41
+ async function cycle(config) {
42
+ const spool = config.spoolDir || join(dirname(path), "spool");
43
+ await mkdir(spool, { recursive: true, mode: 0o700 });
44
+ const files = (await readdir(spool))
45
+ .filter((f) => f.endsWith(".json"))
46
+ .sort();
47
+ if (files.length >= 1000)
48
+ await drainSpool(spool, config.machineId, (pending) => sendReport(config.url, config.token, pending));
49
+ let report;
50
+ try {
51
+ report = await collect(config);
52
+ }
53
+ catch {
54
+ await heartbeat(config, "采集失败:保留上一次完整快照,请检查本机 Agent 日志").catch(() => { });
55
+ throw new Error("Collection failed; previous complete inventory preserved");
56
+ }
57
+ const name = `${report.capturedAt.replaceAll(":", "-")}-${report.reportId}.json`;
58
+ await writeFile(join(spool, `${name}.tmp`), JSON.stringify(report), {
59
+ mode: 0o600,
60
+ });
61
+ await rename(join(spool, `${name}.tmp`), join(spool, name));
62
+ const drained = await drainSpool(spool, config.machineId, (pending) => sendReport(config.url, config.token, pending));
63
+ if (drained.rejected)
64
+ await heartbeat(config, `${drained.rejected} 份无效上报已隔离保留,请检查本机 spool/rejected`);
65
+ console.log(JSON.stringify({
66
+ event: "reported",
67
+ at: report.capturedAt,
68
+ spaces: report.spaces.length,
69
+ panes: report.spaces.flatMap((s) => s.tabs.flatMap((t) => t.panes))
70
+ .length,
71
+ warnings: report.warnings.length,
72
+ }));
73
+ }
74
+ async function main() {
75
+ const action = process.argv[2] || "once";
76
+ if (action === "--help" || action === "help") {
77
+ console.log("Eagle Agent\nCommands: init (JSON on stdin), collect <file>, upload <file>, once, watch, heartbeat\nConfig: EAGLE_CONFIG or ~/.config/eagle/agent.json (0600). Node.js 24+ and Herdr required.");
78
+ return;
79
+ }
80
+ if (action === "--version") {
81
+ console.log(AGENT_VERSION);
82
+ return;
83
+ }
84
+ if (action === "init") {
85
+ if (process.stdin.isTTY)
86
+ throw new Error("Pass configuration JSON through stdin; never pass credentials as arguments");
87
+ let input = "";
88
+ for await (const chunk of process.stdin) {
89
+ input += chunk;
90
+ if (input.length > 65536)
91
+ throw new Error("Configuration exceeds 64 KiB");
92
+ }
93
+ let value;
94
+ try {
95
+ value = JSON.parse(input);
96
+ }
97
+ catch {
98
+ throw new Error("Invalid configuration JSON");
99
+ }
100
+ const config = ConfigSchema.parse(value);
101
+ checkUrl(config.url);
102
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
103
+ const file = await open(path, "wx", 0o600);
104
+ try {
105
+ await file.writeFile(JSON.stringify(config, null, 2));
106
+ }
107
+ finally {
108
+ await file.close();
109
+ }
110
+ console.log("Secure configuration created. Run eagle-agent once to verify reporting.");
111
+ return;
112
+ }
113
+ const permissions = await stat(path);
114
+ if (process.platform !== "win32" && (permissions.mode & 0o077) !== 0)
115
+ throw new Error("Config must have mode 0600");
116
+ const config = ConfigSchema.parse(JSON.parse(await readFile(path, "utf8")));
117
+ checkUrl(config.url);
118
+ if (action === "collect") {
119
+ const output = process.argv[3];
120
+ if (!output)
121
+ throw new Error("Usage: collect <output.json>");
122
+ const report = await collect(config);
123
+ await writeFile(output, JSON.stringify(report, null, 2), { mode: 0o600 });
124
+ console.log(JSON.stringify({
125
+ event: "collected",
126
+ spaces: report.spaces.length,
127
+ warnings: report.warnings.length,
128
+ }));
129
+ }
130
+ else if (action === "upload") {
131
+ const report = ReportSchema.parse(JSON.parse(await readFile(process.argv[3], "utf8")));
132
+ if (report.machine.id !== config.machineId)
133
+ throw new Error("Machine identity mismatch");
134
+ console.log(JSON.stringify(await sendReport(config.url, config.token, report)));
135
+ }
136
+ else if (action === "heartbeat") {
137
+ await heartbeat(config);
138
+ console.log("Heartbeat accepted");
139
+ }
140
+ else if (action === "once")
141
+ await cycle(config);
142
+ else if (action === "watch") {
143
+ let running = true;
144
+ process.on("SIGTERM", () => {
145
+ running = false;
146
+ });
147
+ process.on("SIGINT", () => {
148
+ running = false;
149
+ });
150
+ while (running) {
151
+ const start = Date.now();
152
+ try {
153
+ await cycle(config);
154
+ }
155
+ catch (e) {
156
+ console.error(JSON.stringify({
157
+ event: "report_failed",
158
+ message: e instanceof z.ZodError
159
+ ? "Invalid config or report schema"
160
+ : e instanceof Error
161
+ ? e.message
162
+ : "Unknown error",
163
+ }));
164
+ }
165
+ if (running)
166
+ await new Promise((r) => setTimeout(r, Math.max(1000, config.intervalSeconds * 1000 - (Date.now() - start))));
167
+ }
168
+ }
169
+ else
170
+ throw new Error("Commands: collect <file>, upload <file>, once, watch, heartbeat");
171
+ }
172
+ void main().catch((e) => {
173
+ console.error(e instanceof z.ZodError
174
+ ? "Invalid config or report schema"
175
+ : e instanceof Error
176
+ ? e.message
177
+ : "Agent failed");
178
+ process.exitCode = 1;
179
+ });
@@ -0,0 +1,529 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { mkdir, open, readFile, rename, writeFile } from "node:fs/promises";
4
+ import { homedir, platform } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { DatabaseSync } from "node:sqlite";
7
+ import { promisify, stripVTControlCharacters } from "node:util";
8
+ import { z } from "zod";
9
+ import { EvidenceSchema, ReportSchema, } from "../src/shared/schema.js";
10
+ import { collectTelemetry } from "./machine.js";
11
+ import { AGENT_VERSION } from "./version.js";
12
+ const exec = promisify(execFile);
13
+ export function redact(text, secrets = []) {
14
+ let clean = stripVTControlCharacters(text)
15
+ .replace(/-----BEGIN [\w ]*PRIVATE KEY-----[\s\S]*?-----END [\w ]*PRIVATE KEY-----/g, "[REDACTED KEY]")
16
+ .replace(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer [REDACTED]")
17
+ .replace(/\beag1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[REDACTED]")
18
+ .replace(/((?:[A-Z_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|CREDENTIAL)[A-Z_]*)\s*[=:]\s*)(?:"[^"\n]*"|'[^'\n]*'|[^\s,;]+)/gi, "$1[REDACTED]")
19
+ .replace(/\b(?:sk-[\w-]{16,}|gh[pousr]_[\w]{20,}|github_pat_[\w]{20,})\b/g, "[REDACTED]");
20
+ for (const secret of secrets)
21
+ if (secret)
22
+ clean = clean.replaceAll(secret, "[REDACTED]");
23
+ return clean;
24
+ }
25
+ export function normalizeSnapshot(snapshot, session) {
26
+ if (!Array.isArray(snapshot.workspaces) ||
27
+ !Array.isArray(snapshot.tabs) ||
28
+ !Array.isArray(snapshot.panes) ||
29
+ !Array.isArray(snapshot.layouts))
30
+ throw new Error("Unsupported Herdr snapshot");
31
+ return snapshot.workspaces.map((w) => ({
32
+ id: `${session}:${w.workspace_id}`,
33
+ name: w.label || w.workspace_id,
34
+ session,
35
+ objective: "",
36
+ tabs: snapshot.tabs
37
+ .filter((t) => t.workspace_id === w.workspace_id)
38
+ .map((t) => {
39
+ const layout = snapshot.layouts.find((l) => l.tab_id === t.tab_id);
40
+ const rawPanes = snapshot.panes.filter((p) => p.tab_id === t.tab_id);
41
+ return {
42
+ id: t.tab_id,
43
+ name: t.label || t.tab_id,
44
+ panes: rawPanes.map((p, index) => {
45
+ const rect = layout?.panes.find((r) => r.pane_id === p.pane_id)?.rect;
46
+ const area = layout?.area;
47
+ const title = (p.title ||
48
+ p.terminal_title_stripped ||
49
+ `${p.agent || "终端"} · ${w.label}`).slice(0, 240);
50
+ return {
51
+ id: p.pane_id,
52
+ title,
53
+ agent: p.agent || "",
54
+ hint: ["working", "idle", "done", "blocked"].includes(p.agent_status ?? "")
55
+ ? p.agent_status
56
+ : "unknown",
57
+ task: {
58
+ id: createHash("sha256")
59
+ .update(`${session}:${p.pane_id}:${p.agent_session?.value || "shell"}:${p.agent === "codex" ? "" : title}`)
60
+ .digest("hex")
61
+ .slice(0, 32),
62
+ title,
63
+ requiresDeployment: true,
64
+ },
65
+ rect: rect && area?.width && area?.height
66
+ ? {
67
+ x: (rect.x - area.x) / area.width,
68
+ y: (rect.y - area.y) / area.height,
69
+ width: rect.width / area.width,
70
+ height: rect.height / area.height,
71
+ }
72
+ : {
73
+ x: index / rawPanes.length,
74
+ y: 0,
75
+ width: 1 / rawPanes.length,
76
+ height: 1,
77
+ },
78
+ evidence: [],
79
+ };
80
+ }),
81
+ };
82
+ }),
83
+ }));
84
+ }
85
+ export function transcriptContext(text) {
86
+ let current;
87
+ for (const line of text.split("\n")) {
88
+ try {
89
+ const event = JSON.parse(line);
90
+ const turnId = event.payload?.turn_id;
91
+ if (typeof turnId === "string" &&
92
+ typeof event.timestamp === "string" &&
93
+ ["event_msg", "turn_context"].includes(event.type) &&
94
+ current?.turnId !== turnId) {
95
+ current = { turnId, startedAt: event.timestamp };
96
+ }
97
+ }
98
+ catch {
99
+ /* Bounded tail can start mid-line. */
100
+ }
101
+ }
102
+ return current;
103
+ }
104
+ export function transcriptEvidence(text, taskId) {
105
+ let summary;
106
+ let process;
107
+ for (const line of text.split("\n")) {
108
+ try {
109
+ const e = JSON.parse(line);
110
+ const p = e.payload;
111
+ if (!p || !e.timestamp || Number.isNaN(Date.parse(e.timestamp)))
112
+ continue;
113
+ const observedAt = new Date(e.timestamp).toISOString();
114
+ if (e.type === "response_item" &&
115
+ ["function_call", "custom_tool_call"].includes(p.type)) {
116
+ summary = undefined;
117
+ process = {
118
+ kind: "process",
119
+ status: "running",
120
+ summary: "Agent 正在调用工具执行任务",
121
+ source: "codex:tool-event (不含参数)",
122
+ observedAt,
123
+ taskId,
124
+ };
125
+ }
126
+ if (e.type === "event_msg" &&
127
+ ["task_started", "task_complete", "task_interrupted"].includes(p.type)) {
128
+ process = {
129
+ kind: "process",
130
+ status: p.type === "task_started" ? "running" : "unknown",
131
+ summary: p.type === "task_started"
132
+ ? "Agent 回合正在执行;尚未出现回合结束事件"
133
+ : "Agent 回合已结束,任务结果仍需核对",
134
+ source: "codex:turn-event",
135
+ observedAt,
136
+ taskId,
137
+ };
138
+ if (p.type === "task_started")
139
+ summary = undefined;
140
+ }
141
+ if (e.type === "response_item" &&
142
+ p.role === "assistant" &&
143
+ ["final", "final_answer"].includes(p.phase)) {
144
+ if (process)
145
+ process = {
146
+ ...process,
147
+ status: "unknown",
148
+ observedAt,
149
+ summary: "Agent 已给出最终回复,交付结论待交叉核对",
150
+ };
151
+ const content = (p.content ?? [])
152
+ .filter((c) => typeof c.text === "string")
153
+ .map((c) => c.text)
154
+ .join("\n");
155
+ if (content)
156
+ summary = {
157
+ kind: "summary",
158
+ status: "unknown",
159
+ summary: content.slice(0, 2000),
160
+ source: "codex:final-message",
161
+ observedAt,
162
+ taskId,
163
+ };
164
+ }
165
+ }
166
+ catch {
167
+ /* A bounded tail may begin in the middle of a JSON line. */
168
+ }
169
+ }
170
+ return [process, summary].filter((e) => !!e);
171
+ }
172
+ async function tail(path) {
173
+ const f = await open(path, "r");
174
+ try {
175
+ const stat = await f.stat();
176
+ const size = Math.min(stat.size, 524288);
177
+ const bytes = Buffer.alloc(size);
178
+ await f.read(bytes, 0, size, stat.size - size);
179
+ return bytes.toString("utf8");
180
+ }
181
+ finally {
182
+ await f.close();
183
+ }
184
+ }
185
+ async function command(file, args, cwd) {
186
+ return (await exec(file, args, { cwd, timeout: 8000, maxBuffer: 4 * 1024 * 1024 })).stdout.trim();
187
+ }
188
+ async function herdr(args, session) {
189
+ return command("herdr", [
190
+ ...(session ? ["--session", session] : []),
191
+ ...args,
192
+ ]);
193
+ }
194
+ const ManagerSchema = z.record(z.string(), z.strictObject({
195
+ task: z.strictObject({
196
+ id: z.string(),
197
+ title: z.string().max(500),
198
+ requiresDeployment: z.boolean(),
199
+ }),
200
+ evidence: z.array(EvidenceSchema).max(20),
201
+ }));
202
+ export function applyManager(pane, managed) {
203
+ if (managed.task.id !== pane.task.id)
204
+ return false;
205
+ pane.task = managed.task;
206
+ pane.evidence.push(...managed.evidence);
207
+ return true;
208
+ }
209
+ export function preserveStopped(current, previous, stopped) {
210
+ return [
211
+ ...current,
212
+ ...previous
213
+ .filter((s) => stopped.includes(s.session))
214
+ .map((s) => ({ ...s, availability: "unavailable" })),
215
+ ];
216
+ }
217
+ export function conversationTaskId(text, sessionId) {
218
+ let lastUser;
219
+ for (const line of text.split("\n")) {
220
+ try {
221
+ const e = JSON.parse(line);
222
+ if ((e.type === "user" && !e.synthetic_reason) ||
223
+ (e.type === "message" && e.message?.role === "user"))
224
+ lastUser = JSON.stringify(e.type === "user" ? e.content : e.message.content);
225
+ }
226
+ catch {
227
+ /* Bounded tail can begin mid-line. */
228
+ }
229
+ }
230
+ return lastUser
231
+ ? createHash("sha256")
232
+ .update(`${sessionId}:${lastUser}`)
233
+ .digest("hex")
234
+ .slice(0, 32)
235
+ : undefined;
236
+ }
237
+ export async function collect(config) {
238
+ const capturedAt = new Date().toISOString();
239
+ const warnings = [];
240
+ const telemetry = await collectTelemetry(config.watchPorts);
241
+ if (!telemetry.resources)
242
+ warnings.push("机器资源采集失败,端口检查结果仍保留");
243
+ else if (!telemetry.resources.disk)
244
+ warnings.push("主目录磁盘信息不可读");
245
+ let spaces = [];
246
+ const cachePath = join(dirname(config.spoolDir ?? join(homedir(), ".config/eagle/spool")), "latest-report.json");
247
+ let previous;
248
+ try {
249
+ const cached = ReportSchema.parse(JSON.parse(await readFile(cachePath, "utf8")));
250
+ if (cached.machine.id === config.machineId)
251
+ previous = cached;
252
+ }
253
+ catch {
254
+ /* First collection has no cache. */
255
+ }
256
+ const sessions = JSON.parse(await herdr(["session", "list", "--json"])).sessions;
257
+ if (!sessions.some((s) => s.running) && !previous)
258
+ throw new Error("No running Herdr sessions; preserving previous inventory");
259
+ const manager = config.evidenceFile
260
+ ? ManagerSchema.parse(JSON.parse(await readFile(config.evidenceFile, "utf8")))
261
+ : {};
262
+ // ponytail: optional Codex v5/v1 adapter. Unsupported local DB schemas remain unknown; manager evidence is portable.
263
+ let state;
264
+ let goals;
265
+ const codexDir = config.codexDir ?? join(homedir(), ".codex");
266
+ try {
267
+ state = new DatabaseSync(join(codexDir, "state_5.sqlite"), {
268
+ readOnly: true,
269
+ });
270
+ }
271
+ catch {
272
+ warnings.push("Codex 会话数据库不可读;使用终端证据");
273
+ }
274
+ try {
275
+ goals = new DatabaseSync(join(codexDir, "goals_1.sqlite"), {
276
+ readOnly: true,
277
+ });
278
+ }
279
+ catch {
280
+ warnings.push("Codex Goal 数据库不可读;等待管理 Agent 补充");
281
+ }
282
+ try {
283
+ for (const session of sessions.filter((s) => s.running)) {
284
+ // Any session failure aborts the whole snapshot, so a partial inventory never closes Spaces.
285
+ const raw = JSON.parse(await herdr(["api", "snapshot"], session.name))
286
+ .result.snapshot;
287
+ const normalized = normalizeSnapshot(raw, session.name);
288
+ for (const space of normalized) {
289
+ for (const pane of space.tabs.flatMap((t) => t.panes)) {
290
+ const original = raw.panes.find((p) => p.pane_id === pane.id);
291
+ const managed = manager[`${session.name}:${pane.id}`];
292
+ const sessionId = original?.agent_session?.kind === "id"
293
+ ? original.agent_session.value
294
+ : undefined;
295
+ if (pane.agent === "codex" && sessionId) {
296
+ try {
297
+ const thread = state
298
+ ?.prepare("SELECT title, rollout_path FROM threads WHERE id = ?")
299
+ .get(sessionId);
300
+ let currentContext;
301
+ if (thread) {
302
+ const transcript = await tail(thread.rollout_path);
303
+ currentContext = transcriptContext(transcript);
304
+ if (currentContext)
305
+ pane.task.id = createHash("sha256")
306
+ .update(`${sessionId}:${currentContext.turnId}`)
307
+ .digest("hex")
308
+ .slice(0, 32);
309
+ pane.task.title = thread.title.slice(0, 500);
310
+ pane.evidence.push(...transcriptEvidence(transcript, pane.task.id));
311
+ }
312
+ const goal = goals
313
+ ?.prepare("SELECT objective, status, updated_at_ms FROM thread_goals WHERE thread_id = ?")
314
+ .get(sessionId);
315
+ if (goal &&
316
+ currentContext &&
317
+ goal.updated_at_ms >= Date.parse(currentContext.startedAt))
318
+ pane.evidence.push({
319
+ kind: "goal",
320
+ status: goal.status === "active"
321
+ ? "running"
322
+ : goal.status === "complete"
323
+ ? "success"
324
+ : "waiting",
325
+ summary: goal.objective.slice(0, 2000),
326
+ source: "codex:thread-goal",
327
+ observedAt: new Date(goal.updated_at_ms).toISOString(),
328
+ taskId: pane.task.id,
329
+ });
330
+ }
331
+ catch {
332
+ warnings.push(`${space.name}/${pane.id}:原生会话证据不可读`);
333
+ }
334
+ }
335
+ if (pane.agent === "grok" || pane.agent === "pi") {
336
+ try {
337
+ const identity = original?.agent_session?.value;
338
+ const cwd = original?.cwd;
339
+ const path = pane.agent === "pi"
340
+ ? identity
341
+ : identity && cwd
342
+ ? join(homedir(), ".grok/sessions", encodeURIComponent(cwd), identity, "chat_history.jsonl")
343
+ : undefined;
344
+ if (path && identity) {
345
+ const taskId = conversationTaskId(await tail(path), identity);
346
+ if (taskId)
347
+ pane.task.id = taskId;
348
+ }
349
+ }
350
+ catch {
351
+ /* Portable manager evidence remains available when native history is absent. */
352
+ }
353
+ }
354
+ if (managed && !applyManager(pane, managed))
355
+ warnings.push(`${space.name}/${pane.id}:管理证据属于旧任务,已忽略`);
356
+ if (!pane.evidence.some((e) => e.kind === "summary")) {
357
+ try {
358
+ const terminal = await herdr(["pane", "read", pane.id, "--source", "visible"], session.name);
359
+ const lines = terminal
360
+ .split("\n")
361
+ .map((l) => l.trim())
362
+ .filter((l) => l && !/^[─━╭╰│┃█\s]+$/.test(l));
363
+ pane.evidence.push({
364
+ kind: "summary",
365
+ status: "unknown",
366
+ summary: lines.slice(-12).join("\n").slice(0, 1500),
367
+ source: "herdr:visible (当前画面,非最终结论)",
368
+ observedAt: capturedAt,
369
+ taskId: pane.task.id,
370
+ });
371
+ }
372
+ catch {
373
+ warnings.push(`${space.name}/${pane.id}:终端证据缺失`);
374
+ }
375
+ }
376
+ const cwd = original?.foreground_cwd || original?.cwd;
377
+ if (cwd) {
378
+ try {
379
+ const revision = await command("git", ["rev-parse", "HEAD"], cwd);
380
+ const dirty = await command("git", ["status", "--porcelain", "--untracked-files=normal"], cwd);
381
+ const branch = await command("git", ["branch", "--show-current"], cwd);
382
+ pane.evidence.push({
383
+ kind: "git",
384
+ status: dirty ? "unknown" : "success",
385
+ revision,
386
+ summary: `${branch || "detached"} · ${revision.slice(0, 8)} · ${dirty ? `${dirty.split("\n").length} 项未提交变更` : "工作树干净"}`,
387
+ source: "git:HEAD+status",
388
+ observedAt: capturedAt,
389
+ taskId: pane.task.id,
390
+ });
391
+ }
392
+ catch {
393
+ /* Shells outside a repository have no Git evidence. */
394
+ }
395
+ }
396
+ try {
397
+ const info = JSON.parse(await herdr(["pane", "process-info", "--pane", pane.id], session.name)).result.process_info;
398
+ const names = (info.foreground_processes ?? [])
399
+ .map((p) => p.name)
400
+ .join(", ");
401
+ // Presence of an agent harness is deliberately not evidence that a task is running.
402
+ if (!pane.evidence.some((e) => e.kind === "process"))
403
+ pane.evidence.push({
404
+ kind: "process",
405
+ status: "unknown",
406
+ summary: names
407
+ ? `前台进程:${names};进程存在不等于任务执行中`
408
+ : "没有前台任务进程",
409
+ source: "herdr:process-info (仅进程名)",
410
+ observedAt: capturedAt,
411
+ taskId: pane.task.id,
412
+ });
413
+ }
414
+ catch {
415
+ warnings.push(`${space.name}/${pane.id}:进程证据缺失`);
416
+ }
417
+ }
418
+ space.objective =
419
+ space.tabs.flatMap((t) => t.panes).find((p) => p.agent === "codex")
420
+ ?.task.title ??
421
+ space.tabs[0]?.panes[0]?.task.title ??
422
+ "";
423
+ }
424
+ spaces.push(...normalized);
425
+ }
426
+ }
427
+ finally {
428
+ state?.close();
429
+ goals?.close();
430
+ }
431
+ const stopped = sessions.filter((s) => !s.running).map((s) => s.name);
432
+ spaces = preserveStopped(spaces, previous?.spaces ?? [], stopped);
433
+ for (const session of stopped)
434
+ warnings.push(`${session}:Session 已停止,保留最近已知拓扑`);
435
+ const value = {
436
+ schemaVersion: 1,
437
+ reportId: randomUUID(),
438
+ capturedAt,
439
+ machine: {
440
+ id: config.machineId,
441
+ name: config.machineName,
442
+ platform: platform(),
443
+ collectorVersion: AGENT_VERSION,
444
+ telemetry,
445
+ },
446
+ spaces,
447
+ warnings: warnings.slice(0, 100),
448
+ };
449
+ // Scrub every string, including task titles and manager evidence, before disk spool or transport.
450
+ function scrub(v) {
451
+ if (typeof v === "string")
452
+ return redact(v, [config.token]).slice(0, v.length);
453
+ if (Array.isArray(v))
454
+ return v.map(scrub);
455
+ if (v && typeof v === "object")
456
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, scrub(x)]));
457
+ return v;
458
+ }
459
+ const report = ReportSchema.parse(scrub(value));
460
+ await mkdir(dirname(cachePath), { recursive: true, mode: 0o700 });
461
+ await writeFile(`${cachePath}.${report.reportId}.tmp`, JSON.stringify(report), { mode: 0o600 });
462
+ await rename(`${cachePath}.${report.reportId}.tmp`, cachePath);
463
+ return report;
464
+ }
465
+ export function checkUrl(value) {
466
+ const url = new URL(value);
467
+ if (url.username ||
468
+ url.password ||
469
+ url.search ||
470
+ url.hash ||
471
+ url.pathname !== "/")
472
+ throw new Error("Use a plain Eagle origin");
473
+ if (url.protocol !== "https:" &&
474
+ !(url.protocol === "http:" &&
475
+ ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)))
476
+ throw new Error("HTTPS is required outside loopback");
477
+ return url.origin;
478
+ }
479
+ export class UploadRejectedError extends Error {
480
+ status;
481
+ constructor(status) {
482
+ super(`Upload rejected (${status}); report retained`);
483
+ this.status = status;
484
+ }
485
+ }
486
+ export async function sendReport(url, token, report, transport = fetch, delayMs = 1000) {
487
+ const origin = checkUrl(url);
488
+ const payload = JSON.stringify(ReportSchema.parse(report));
489
+ for (let attempt = 0; attempt < 3; attempt++) {
490
+ let response;
491
+ try {
492
+ response = await transport(`${origin}/api/v1/reports`, {
493
+ method: "POST",
494
+ redirect: "error",
495
+ headers: {
496
+ Authorization: `Bearer ${token}`,
497
+ "Content-Type": "application/json",
498
+ },
499
+ body: payload,
500
+ signal: AbortSignal.timeout(15000),
501
+ });
502
+ }
503
+ catch {
504
+ if (attempt === 2)
505
+ throw new Error("Upload network failure; report retained for retry");
506
+ await new Promise((r) => setTimeout(r, delayMs * 2 ** attempt));
507
+ continue;
508
+ }
509
+ if (response.ok) {
510
+ try {
511
+ return z
512
+ .object({
513
+ accepted: z.literal(true),
514
+ duplicate: z.boolean(),
515
+ seq: z.number().int().positive(),
516
+ })
517
+ .parse(await response.json());
518
+ }
519
+ catch {
520
+ throw new Error("Invalid upload acknowledgement; report retained for retry");
521
+ }
522
+ }
523
+ if (response.status < 500 && response.status !== 429)
524
+ throw new UploadRejectedError(response.status);
525
+ if (attempt === 2)
526
+ throw new Error(`Upload unavailable (${response.status}); report retained`);
527
+ await new Promise((r) => setTimeout(r, delayMs * 2 ** attempt));
528
+ }
529
+ }
@@ -0,0 +1,85 @@
1
+ import { statfs } from "node:fs/promises";
2
+ import { createConnection } from "node:net";
3
+ import { availableParallelism, cpus, freemem, homedir, loadavg, platform, totalmem, uptime, } from "node:os";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { MachineTelemetrySchema, WatchPortsSchema, } from "../src/shared/schema.js";
6
+ export function cpuUsage(before, after) {
7
+ const total = after.total - before.total;
8
+ const idle = after.idle - before.idle;
9
+ if (total <= 0 || idle < 0 || idle > total)
10
+ return null;
11
+ return Math.round((1 - idle / total) * 1000) / 10;
12
+ }
13
+ function cpuSample(values) {
14
+ return values.reduce((sum, cpu) => ({
15
+ idle: sum.idle + cpu.times.idle,
16
+ total: sum.total + Object.values(cpu.times).reduce((a, b) => a + b, 0),
17
+ }), { idle: 0, total: 0 });
18
+ }
19
+ async function resources() {
20
+ const processors = cpus();
21
+ const before = cpuSample(processors);
22
+ const started = performance.now();
23
+ // A short fresh sample also works for one-shot collectors; no process-lifetime counters.
24
+ await delay(250);
25
+ const after = cpus();
26
+ const cpuSampleMs = Math.round(performance.now() - started);
27
+ const disk = await statfs(homedir())
28
+ .then((fs) => ({
29
+ totalBytes: fs.blocks * fs.bsize,
30
+ availableBytes: fs.bavail * fs.bsize,
31
+ }))
32
+ .catch(() => null);
33
+ return {
34
+ cpuModel: processors[0]?.model || "",
35
+ cpuCores: processors.length || availableParallelism(),
36
+ cpuUsagePercent: processors.length === after.length
37
+ ? cpuUsage(before, cpuSample(after))
38
+ : null,
39
+ cpuSampleMs,
40
+ loadAverage: platform() === "win32" ? null : loadavg(),
41
+ memory: { totalBytes: totalmem(), freeBytes: freemem() },
42
+ disk,
43
+ uptimeSeconds: Math.floor(uptime()),
44
+ };
45
+ }
46
+ function checkPort(target) {
47
+ return new Promise((resolve) => {
48
+ const started = performance.now();
49
+ const socket = createConnection({ host: target.host, port: target.port });
50
+ let settled = false;
51
+ const finish = (status) => {
52
+ if (settled)
53
+ return;
54
+ settled = true;
55
+ socket.destroy();
56
+ resolve({
57
+ ...target,
58
+ status,
59
+ latencyMs: status === "open"
60
+ ? Math.round((performance.now() - started) * 10) / 10
61
+ : null,
62
+ checkedAt: new Date().toISOString(),
63
+ });
64
+ };
65
+ socket.setTimeout(1000, () => finish("timeout"));
66
+ socket.once("connect", () => finish("open"));
67
+ socket.once("error", (error) => finish(error.code === "ECONNREFUSED"
68
+ ? "closed"
69
+ : error.code === "ETIMEDOUT"
70
+ ? "timeout"
71
+ : "error"));
72
+ });
73
+ }
74
+ export async function collectTelemetry(watchPorts = []) {
75
+ const targets = WatchPortsSchema.parse(watchPorts);
76
+ const [sample, ports] = await Promise.all([
77
+ resources().catch(() => null),
78
+ Promise.all(targets.map(checkPort)),
79
+ ]);
80
+ return MachineTelemetrySchema.parse({
81
+ observedAt: new Date().toISOString(),
82
+ resources: sample,
83
+ ports,
84
+ });
85
+ }
@@ -0,0 +1,37 @@
1
+ import { mkdir, readdir, readFile, rename, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { ZodError } from "zod";
4
+ import { ReportSchema } from "../src/shared/schema.js";
5
+ import { UploadRejectedError } from "./collector.js";
6
+ export async function drainSpool(directory, machineId, send) {
7
+ let sent = 0;
8
+ const files = (await readdir(directory))
9
+ .filter((f) => f.endsWith(".json"))
10
+ .sort()
11
+ .reverse(); // Timestamp-prefixed files: restore current state before replaying backlog.
12
+ for (const file of files) {
13
+ const path = join(directory, file);
14
+ try {
15
+ const report = ReportSchema.parse(JSON.parse(await readFile(path, "utf8")));
16
+ if (report.machine.id !== machineId)
17
+ throw new UploadRejectedError(400);
18
+ await send(report);
19
+ await unlink(path);
20
+ sent++;
21
+ }
22
+ catch (error) {
23
+ if (!(error instanceof SyntaxError ||
24
+ error instanceof ZodError ||
25
+ (error instanceof UploadRejectedError &&
26
+ [400, 409, 413, 415].includes(error.status))))
27
+ throw error;
28
+ await mkdir(join(directory, "rejected"), {
29
+ recursive: true,
30
+ mode: 0o700,
31
+ });
32
+ await rename(path, join(directory, "rejected", file));
33
+ }
34
+ }
35
+ const rejected = await readdir(join(directory, "rejected")).then((f) => f.length, () => 0);
36
+ return { sent, rejected };
37
+ }
@@ -0,0 +1 @@
1
+ export const AGENT_VERSION = "0.3.0";
@@ -0,0 +1,144 @@
1
+ import { z } from "zod";
2
+ const id = z
3
+ .string()
4
+ .min(1)
5
+ .max(160)
6
+ .regex(/^[\w.:/-]+$/);
7
+ const text = z.string().max(2000);
8
+ const timestamp = z.iso
9
+ .datetime({ offset: false })
10
+ .transform((value) => new Date(value).toISOString());
11
+ const bytes = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
12
+ export const WatchPortSchema = z.strictObject({
13
+ name: z.string().trim().min(1).max(80),
14
+ host: z.enum(["127.0.0.1", "::1"]).default("127.0.0.1"),
15
+ port: z.number().int().min(1).max(65535),
16
+ });
17
+ export const WatchPortsSchema = z
18
+ .array(WatchPortSchema)
19
+ .max(32)
20
+ .refine((ports) => new Set(ports.map((p) => `${p.host}:${p.port}`)).size === ports.length, "Duplicate watched port");
21
+ export const PortCheckSchema = WatchPortSchema.extend({
22
+ status: z.enum(["open", "closed", "timeout", "error"]),
23
+ latencyMs: z.number().nonnegative().nullable(),
24
+ checkedAt: timestamp,
25
+ });
26
+ export const MachineTelemetrySchema = z.strictObject({
27
+ observedAt: timestamp,
28
+ resources: z
29
+ .strictObject({
30
+ cpuModel: z.string().max(240),
31
+ cpuCores: z.number().int().positive().max(4096),
32
+ cpuUsagePercent: z.number().min(0).max(100).nullable(),
33
+ cpuSampleMs: z.number().positive(),
34
+ loadAverage: z
35
+ .tuple([
36
+ z.number().nonnegative(),
37
+ z.number().nonnegative(),
38
+ z.number().nonnegative(),
39
+ ])
40
+ .nullable(),
41
+ memory: z
42
+ .strictObject({ totalBytes: bytes.positive(), freeBytes: bytes })
43
+ .refine((m) => m.freeBytes <= m.totalBytes, "Free memory exceeds total"),
44
+ disk: z
45
+ .strictObject({ totalBytes: bytes.positive(), availableBytes: bytes })
46
+ .refine((d) => d.availableBytes <= d.totalBytes, "Available disk exceeds total")
47
+ .nullable(),
48
+ uptimeSeconds: z.number().int().nonnegative(),
49
+ })
50
+ .nullable(),
51
+ ports: z
52
+ .array(PortCheckSchema)
53
+ .max(32)
54
+ .refine((ports) => new Set(ports.map((p) => `${p.host}:${p.port}`)).size === ports.length, "Duplicate watched port"),
55
+ });
56
+ export const EvidenceSchema = z.strictObject({
57
+ kind: z.enum(["summary", "goal", "git", "test", "process", "deployment"]),
58
+ status: z.enum(["success", "failure", "running", "waiting", "unknown"]),
59
+ summary: text,
60
+ source: z.string().min(1).max(240),
61
+ observedAt: timestamp,
62
+ taskId: id,
63
+ revision: z.string().max(100).optional(),
64
+ });
65
+ export const PaneSchema = z.strictObject({
66
+ id,
67
+ title: z.string().max(240),
68
+ agent: z.string().max(80),
69
+ hint: z.enum(["working", "idle", "done", "blocked", "unknown"]),
70
+ task: z.strictObject({
71
+ id,
72
+ title: z.string().max(500),
73
+ requiresDeployment: z.boolean(),
74
+ }),
75
+ rect: z
76
+ .strictObject({
77
+ x: z.number().min(0).max(1),
78
+ y: z.number().min(0).max(1),
79
+ width: z.number().positive().max(1),
80
+ height: z.number().positive().max(1),
81
+ })
82
+ .refine((r) => r.x + r.width <= 1.001 && r.y + r.height <= 1.001, "Pane lies outside tab"),
83
+ evidence: z.array(EvidenceSchema).max(30),
84
+ });
85
+ export const SpaceSchema = z.strictObject({
86
+ availability: z.literal("unavailable").optional(),
87
+ id,
88
+ name: z.string().min(1).max(240),
89
+ session: z.string().min(1).max(100),
90
+ objective: z.string().max(1000),
91
+ tabs: z
92
+ .array(z.strictObject({
93
+ id,
94
+ name: z.string().max(240),
95
+ panes: z.array(PaneSchema).max(100),
96
+ }))
97
+ .max(50),
98
+ });
99
+ export const ReportSchema = z
100
+ .strictObject({
101
+ schemaVersion: z.literal(1),
102
+ reportId: id,
103
+ capturedAt: timestamp,
104
+ machine: z.strictObject({
105
+ id: z
106
+ .string()
107
+ .min(1)
108
+ .max(80)
109
+ .regex(/^[a-z0-9][a-z0-9_-]*$/),
110
+ name: z.string().min(1).max(120),
111
+ platform: z.string().max(80),
112
+ collectorVersion: z.string().max(80),
113
+ telemetry: MachineTelemetrySchema.optional(),
114
+ }),
115
+ spaces: z.array(SpaceSchema).max(200),
116
+ warnings: z.array(z.string().max(500)).max(100),
117
+ })
118
+ .superRefine((report, ctx) => {
119
+ const unique = (ids) => new Set(ids).size === ids.length;
120
+ if (!unique(report.spaces.map((s) => s.id)))
121
+ ctx.addIssue({ code: "custom", message: "Duplicate space ID" });
122
+ let count = 0;
123
+ for (const space of report.spaces) {
124
+ const panes = space.tabs.flatMap((t) => t.panes);
125
+ count += panes.length;
126
+ if (!unique(space.tabs.map((t) => t.id)) ||
127
+ !unique(panes.map((p) => p.id)))
128
+ ctx.addIssue({ code: "custom", message: "Duplicate tab/pane ID" });
129
+ }
130
+ if (count > 1000)
131
+ ctx.addIssue({ code: "custom", message: "Too many panes" });
132
+ });
133
+ export const HeartbeatSchema = z.strictObject({
134
+ schemaVersion: z.literal(1),
135
+ machineId: id,
136
+ sentAt: timestamp,
137
+ warning: z.string().max(500).optional(),
138
+ });
139
+ export const STATE_LABEL = {
140
+ verified: "已验证完成",
141
+ active: "进行中",
142
+ attention: "需关注",
143
+ unverified: "待核实",
144
+ };
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@nocoo/eagle-agent",
3
+ "version": "0.3.0",
4
+ "description": "Report all local Herdr spaces and machine resources to Eagle",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/nocoo/eagle.git",
10
+ "directory": "agent"
11
+ },
12
+ "bin": {
13
+ "eagle-agent": "dist/agent/cli.js"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=24.0.0"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.build.json",
24
+ "prepack": "npm run build"
25
+ },
26
+ "dependencies": {
27
+ "zod": "^4.1.0"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ }
32
+ }