tryharness 0.7.4 → 0.7.7

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/cli.mjs CHANGED
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
2
3
  import fs from "node:fs";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { configureAgents, AGENTS, MCP_CLIENTS, detectMcpClients, mcpClientSnippet, removeAgentConfigurations } from "./lib/adapters.mjs";
8
+ import { setupSwarm, testAgent } from "./lib/onboard.mjs";
9
+ import { removeLocalWakes } from "./lib/wakes.mjs";
7
10
  import { detectAgents } from "./lib/commands.mjs";
8
11
  import { daemonRequest } from "./lib/control-client.mjs";
9
12
  import { ensureDir, readJson, writeJsonAtomic } from "./lib/files.mjs";
@@ -116,13 +119,6 @@ async function waitForDaemon(timeoutMs = 12_000) {
116
119
  throw new Error(`daemon started but its authenticated Harness bridge did not become ready: ${lastError?.message || lastStatus?.lastError || "timeout"}. Run: npx tryharness doctor`);
117
120
  }
118
121
 
119
- function printAgentStates(states) {
120
- for (const state of states) {
121
- const icon = !state.installed ? "–" : state.configured ? "✓" : "!";
122
- console.log(` ${icon} ${state.agent}: ${state.detail}`);
123
- }
124
- }
125
-
126
122
  async function connect() {
127
123
  ensureDir(paths.home);
128
124
  const detected = detectAgents();
@@ -151,14 +147,19 @@ async function connect() {
151
147
  const stateMap = Object.fromEntries(states.map((state) => [state.agent, state]));
152
148
  config = { ...readJson(paths.config, config), version: VERSION, runtimeRoot, agentStates: stateMap };
153
149
  writeJsonAtomic(paths.config, config);
154
- printAgentStates(states);
155
- for (const client of detectMcpClients()) {
156
- if (client.path) console.log(` ! ${client.client}: found; it joins the task board once you paste its MCP snippet (npx tryharness mcp ${client.client})`);
157
- }
158
-
159
150
  const service = installService({ runtimeRoot });
160
151
  const ready = await waitForDaemon();
161
152
  console.log(`daemon: ready via ${service.manager} and connected to Harness`);
153
+ await setupSwarm({ runtimeRoot, states, daemonRequest });
154
+ try {
155
+ const devices = await daemonRequest({ command: "phone.list" });
156
+ if (devices.ok && !devices.phones.some((device) => device.kind === "browser" && !device.revoked_at)) {
157
+ console.log("browser: connecting it to this computer end to end");
158
+ await link({ quiet: true });
159
+ } else if (devices.ok) console.log("browser: connected end to end");
160
+ } catch (error) {
161
+ console.log(`browser: not connected (${error.message}); run npx tryharness link`);
162
+ }
162
163
  if (!has("--no-desktop")) desktop();
163
164
  // The context space's embedder lives with the daemon and comes down with setup, so the machine
164
165
  // can search a task by meaning before the first task exists. About 110 MB, once.
@@ -263,9 +264,22 @@ async function connectAgent() {
263
264
  let brief = "";
264
265
  if (file) brief = fs.readFileSync(path.resolve(file), "utf8");
265
266
  else if (!process.stdin.isTTY) for await (const chunk of process.stdin) brief += chunk;
266
- const response = await daemonRequest({ command: "relay.request", to, fromAgent, about, brief }, 20_000);
267
+ const response = await daemonRequest({ command: "relay.request", to, fromAgent, about, brief, trustNewKey: has("--trust-new-key") }, 20_000);
267
268
  if (!response.ok) throw new Error(response.error || "request failed");
268
269
  console.log(`Asked ${to} to work "${about}" with your ${fromAgent}. Thread ${String(response.threadId || "").slice(0, 8)} is ${response.status || "requested"}; once they accept, it appears on your board and your ${fromAgent} is told.`);
270
+ console.log(response.sealed
271
+ ? `Sealed end to end to ${to.split("/")[0]}'s computer (key ${response.fingerprint}); Harness carries it and cannot read it. Check the key with them once: npx tryharness peers`
272
+ : `Not end to end: ${to.split("/")[0]}'s computer runs a Harness without sealed threads yet, so Harness can read this thread.`);
273
+ }
274
+
275
+ // The keys of other people's computers this one has worked with, pinned the first time, and its own.
276
+ async function peers() {
277
+ const response = await daemonRequest({ command: "relay.peers" });
278
+ if (!response.ok) throw new Error(response.error || "the daemon could not list keys");
279
+ console.log(`This computer: ${response.mine ?? "no key yet"}`);
280
+ if (!response.peers.length) { console.log("No other computers yet. A key is pinned the first time you connect with someone."); return; }
281
+ for (const peer of response.peers) console.log(`${peer.handle.padEnd(20)} ${peer.fingerprint} pinned ${peer.pinned_at.slice(0, 10)}`);
282
+ console.log("Read a fingerprint to its owner over a channel you trust; if it matches what their npx tryharness peers shows, nobody stands between you.");
269
283
  }
270
284
 
271
285
  async function mcpSnippet() {
@@ -289,7 +303,7 @@ async function board() {
289
303
  const targets = String(arg("--for", "")).split(",").map((value) => value.trim()).filter(Boolean);
290
304
  const target = targets.length === 1 ? targets[0] : null;
291
305
  // Coding agents, Grok Bot and Harness live on this machine's board; Bankr, Quotient and Muse live in Harness and the daemon posts there.
292
- const allowed = (name) => AGENTS.includes(name) || ["harness", "grok-bot", "muse", "bankr", "quotient"].includes(name);
306
+ const allowed = (name) => AGENTS.includes(name) || ["harness", "grok-bot", "openclaw", "hermes", "muse", "bankr", "quotient"].includes(name);
293
307
  if (targets.some((name) => !allowed(name))) throw new Error("usage: tryharness board post --for <agent[,agent...]> --objective <text> [--file <brief.md>] [--run --workspace <dir>]");
294
308
  const objective = arg("--objective", "");
295
309
  const file = arg("--file");
@@ -477,13 +491,39 @@ async function goal() {
477
491
  throw new Error("usage: tryharness goal [list | set --objective <text> [--brief <text>] [--team a,b] [--facilitator <agent>] [--workspace <dir>] [--max-posts n] [--max-wakes n] | show <id> | stop <id> | resume <id> [--max-posts n] | folder <id> <dir> | invite <id> <handle/agent>]");
478
492
  }
479
493
 
494
+ // The swarm's coordinator: who coordinates, and the record of every round it ran.
495
+ async function swarm() {
496
+ const sub = argv[0] || "rounds";
497
+ if (sub === "coordinator") {
498
+ const mode = argv[1];
499
+ if (mode && mode !== "harness" && mode !== "local") throw new Error("usage: tryharness swarm coordinator [harness|local]");
500
+ const response = await daemonRequest({ command: "board.swarm.coordinator", agent: "user", ...(mode ? { mode } : {}) });
501
+ if (!response.ok) throw new Error(response.error || "the coordinator setting was not changed");
502
+ console.log(response.mode === "local" ? "Threads are coordinated by an agent on this computer with its tools off; nothing in them goes to Harness." : "Threads are coordinated by Harness: each round sends the thread's newest posts and its options to Harness, which decides and words the next step.");
503
+ return;
504
+ }
505
+ if (sub === "rounds") {
506
+ const response = await daemonRequest({ command: "board.swarm.rounds", agent: "user", ...(argv[1] ? { id: argv[1] } : {}), limit: Number(arg("--limit", "30")) });
507
+ if (!response.ok) throw new Error(response.error || "rounds unavailable");
508
+ if (!response.rounds.length) { console.log("No rounds yet."); return; }
509
+ for (const r of response.rounds) {
510
+ const cost = r.estimated_micro_usd == null ? "cost unknown" : `$${(r.estimated_micro_usd / 1e6).toFixed(4)} est.`;
511
+ const progress = `${r.progress.runsFinished} runs done, ${r.progress.newResults} new, ${r.progress.roundsWithoutProgress} rounds idle`;
512
+ console.log(`${r.at} ${r.goal_id.slice(0, 8)} ${r.coordinator.padEnd(11)} ${(r.error ? "error" : r.action ?? "-").padEnd(10)} ${(r.agent ?? "").padEnd(11)} ${(r.loop ?? "").padEnd(13)} ${progress} ${r.elapsed_ms}ms ${cost}${r.error ? ` ${r.error}` : ""}`);
513
+ }
514
+ return;
515
+ }
516
+ throw new Error("usage: tryharness swarm [rounds [<thread>] [--limit n] | coordinator [harness|local]]");
517
+ }
518
+
480
519
  async function room() {
481
520
  const agent = arg("--as", "user");
482
- const words = argv.filter((value, index, all) => !value.startsWith("--") && all[index - 1] !== "--as" && all[index - 1] !== "--since");
521
+ const words = argv.filter((value, index, all) => !value.startsWith("--") && !["--as", "--since", "--ask"].includes(all[index - 1]));
483
522
  if (words[0] === "post") {
484
- const response = await daemonRequest({ command: "board.room.post", agent, id: String(words[1] || ""), text: words.slice(2).join(" "), shareWithGuests: has("--guests") });
523
+ const ask = String(arg("--ask", "")).split(",").map((name) => name.trim()).filter(Boolean);
524
+ const response = await daemonRequest({ command: "board.room.post", agent, id: String(words[1] || ""), text: words.slice(2).join(" "), ask, shareWithGuests: has("--guests") });
485
525
  if (!response.ok) throw new Error(response.error || "post failed");
486
- console.log(response.held ? `Held: it looks like ${response.held}.` : `Posted [${response.post.seq}].`);
526
+ console.log(response.held ? `Held: it looks like ${response.held}.` : `Posted [${response.post.seq}].${response.note ? ` ${response.note}` : ""}`);
487
527
  return;
488
528
  }
489
529
  const response = await daemonRequest({ command: "board.room.read", agent, id: String(words[0] || ""), since: Number(arg("--since", "0")) });
@@ -544,13 +584,73 @@ async function disconnect() {
544
584
  retireDesktop({});
545
585
  try { await daemonRequest({ command: "shutdown" }, 2_000); } catch {}
546
586
  removeAgentConfigurations();
587
+ removeLocalWakes();
547
588
  try { fs.renameSync(paths.config, paths.config + `.disconnected-${Date.now()}`); } catch {}
548
589
  for (const target of [paths.status, paths.socket]) try { fs.unlinkSync(target); } catch {}
549
590
  console.log("Harness disconnected. The local history database was preserved.");
550
591
  }
551
592
 
593
+ // How Harness reaches each agent: `agents` walks the whole swarm, `agents setup <agent>` one agent, `agents test <agent>`
594
+ // posts a check and waits for the agent to answer it.
595
+ async function agents() {
596
+ const config = readJson(paths.config, {});
597
+ const [sub, agent] = argv;
598
+ if (sub === "test" && agent) { process.exitCode = (await testAgent({ agent, daemonRequest })) ? 0 : 1; return; }
599
+ await setupSwarm({ runtimeRoot: config.runtimeRoot, states: Object.values(config.agentStates || {}), daemonRequest, only: sub === "setup" ? agent || null : null });
600
+ }
601
+
602
+ async function phone() {
603
+ const sub = argv[0] || "pair";
604
+ if (sub === "pair") {
605
+ const response = await daemonRequest({ command: "phone.offer" });
606
+ if (!response.ok) throw new Error(response.error || "the daemon could not make a pairing code");
607
+ const { default: encodeQR } = await import("qr");
608
+ console.log("Scan this with your phone's camera. The code works once, for 10 minutes.\n");
609
+ console.log(encodeQR(response.url, "term"));
610
+ if (has("--link")) console.log(`\n${response.url}\n\nThis link is the pairing secret: open it only on your own phone.`);
611
+ console.log("\nThe phone talks to this computer through Harness, and Harness only relays sealed messages it cannot read.");
612
+ return;
613
+ }
614
+ if (sub === "list") {
615
+ const response = await daemonRequest({ command: "phone.list" });
616
+ if (!response.ok) throw new Error(response.error || "the daemon could not list phones");
617
+ if (!response.phones.length) { console.log("Nothing paired. Pair a phone: npx tryharness phone pair; connect a browser: npx tryharness link"); return; }
618
+ for (const p of response.phones) console.log(`${p.id.slice(0, 8)} ${(p.kind || "phone").padEnd(8)} ${p.label.padEnd(20)} paired ${p.paired_at.slice(0, 10)} ${p.revoked_at ? `disconnected ${p.revoked_at.slice(0, 10)}` : p.last_seen_at ? `last seen ${p.last_seen_at.slice(0, 16).replace("T", " ")}` : "not used yet"}`);
619
+ return;
620
+ }
621
+ if (sub === "revoke") {
622
+ const prefix = String(argv[1] || "");
623
+ if (!prefix) throw new Error("usage: tryharness phone revoke <phone id>");
624
+ const listed = await daemonRequest({ command: "phone.list" });
625
+ const matches = (listed.phones || []).filter((p) => p.id.startsWith(prefix));
626
+ if (matches.length !== 1) throw new Error(matches.length ? "that id matches more than one phone; type more of it" : "no phone with that id (see: tryharness phone list)");
627
+ const response = await daemonRequest({ command: "phone.revoke", id: matches[0].id });
628
+ if (!response.ok) throw new Error(response.error);
629
+ console.log(`${matches[0].label} is disconnected. It can no longer reach this computer.`);
630
+ return;
631
+ }
632
+ throw new Error("usage: tryharness phone [pair [--link] | list | revoke <phone id>]");
633
+ }
634
+
635
+ // A browser joins the way a phone does, by a one-time link this machine makes: its secret and this
636
+ // machine's key ride in the part of the address no server receives. Once one browser is paired,
637
+ // this machine answers Harness's calls for the person's board, sessions and runs only sealed.
638
+ function openUrl(url) {
639
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
640
+ try { spawn(command, args, { detached: true, stdio: "ignore" }).on("error", () => {}).unref(); return true; } catch { return false; }
641
+ }
642
+ async function link({ quiet = false } = {}) {
643
+ const response = await daemonRequest({ command: "phone.offer", kind: "browser" });
644
+ if (!response.ok) throw new Error(response.error || "the daemon could not make a link");
645
+ const opened = !has("--print") && openUrl(response.url);
646
+ if (!quiet || !opened) {
647
+ console.log(opened ? "Opened Harness in your browser to connect it to this computer end to end. The link works once, for 10 minutes." : `Open this in the browser you use Harness in. It works once, for 10 minutes, and is the pairing secret:\n\n${response.url}`);
648
+ console.log("Harness then carries what your browser and this computer say to each other sealed, and cannot read it.");
649
+ }
650
+ }
651
+
552
652
  function help() {
553
- console.log(`tryharness ${VERSION}\n\nCommands:\n tryharness set up everything and start the daemon\n tryharness desktop open the Harness desktop app (setup opens it too; --no-desktop skips it)\n tryharness status show daemon, bridge, agent, and index health\n tryharness doctor diagnose the complete connection\n tryharness sync [--agent] rescan local coding history in the background\n tryharness task <id> --follow\n follow a delegated agent run in this terminal\n tryharness board [--all] list open work on the machine's task board\n tryharness board post --for <agent> --objective <text> [--file <brief.md>]\n tryharness board show <id>\n tryharness board post --for codex --objective <text> --run --workspace <dir>\n the daemon runs it and reports back on the task\n tryharness board run <id> authorize a run an agent asked for\n tryharness board release <id>\n tryharness board agents when each agent last read the board\n tryharness board --watch follow the board in this terminal\n tryharness notify on|off desktop notifications for what waits on you\n tryharness board send <task> <message> send a held reply to another person's agent\n tryharness board auto <task> on|off auto-send replies on that thread\n tryharness board end <task> --outcome <text>\n tryharness board context <task> <query> search everything written about a task\n tryharness goal set --objective <text> [--team a,b] [--workspace <dir>] a goal your agents work together in one room\n tryharness goal [list | show <id> | stop <id> | resume <id>]\n tryharness room <goal> | room post <goal> <text>\n tryharness connect <handle/agent> --about <text> ask another person's agent to work an issue with yours\n tryharness mcp grok-bot print the MCP snippet that puts Grok Bot on the board\n tryharness logs [--follow]\n tryharness disconnect\n`);
653
+ console.log(`tryharness ${VERSION}\n\nCommands:\n tryharness set up everything and start the daemon\n tryharness desktop open the Harness desktop app (setup opens it too; --no-desktop skips it)\n tryharness status show daemon, bridge, agent, and index health\n tryharness doctor diagnose the complete connection\n tryharness sync [--agent] rescan local coding history in the background\n tryharness task <id> --follow\n follow a delegated agent run in this terminal\n tryharness board [--all] list open work on the machine's task board\n tryharness board post --for <agent> --objective <text> [--file <brief.md>]\n tryharness board show <id>\n tryharness board post --for codex --objective <text> --run --workspace <dir>\n the daemon runs it and reports back on the task\n tryharness board run <id> authorize a run an agent asked for\n tryharness board release <id>\n tryharness board agents when each agent last read the board\n tryharness board --watch follow the board in this terminal\n tryharness notify on|off desktop notifications for what waits on you\n tryharness board send <task> <message> send a held reply to another person's agent\n tryharness board auto <task> on|off auto-send replies on that thread\n tryharness board end <task> --outcome <text>\n tryharness board context <task> <query> search everything written about a task\n tryharness goal set --objective <text> [--team a,b] [--workspace <dir>] a goal your agents work together in one room\n tryharness goal [list | show <id> | stop <id> | resume <id>]\n tryharness room <goal> | room post <goal> <text> [--ask agent,agent]\n tryharness swarm rounds [<thread>] what the coordinator saw, chose and cost each round\n tryharness swarm coordinator [harness|local] who coordinates your threads\n tryharness connect <handle/agent> --about <text> ask another person's agent to work an issue with yours, sealed to their computer\n tryharness peers the keys of other people's computers this one has worked with, to compare\n tryharness agents how Harness wakes each of your agents, and set up the rest\n tryharness agents setup <agent> | agents test <agent>\n tryharness mcp grok-bot print the MCP snippet that puts Grok Bot on the board\n tryharness link connect the browser you use Harness in to this computer, end to end (--print shows the link)\n tryharness phone pair show a QR code that pairs your phone with this computer\n tryharness phone list | revoke <id>\n tryharness logs [--follow]\n tryharness disconnect\n`);
554
654
  }
555
655
 
556
656
  try {
@@ -562,8 +662,13 @@ try {
562
662
  else if (command === "board") await board();
563
663
  else if (command === "goal") await goal();
564
664
  else if (command === "room") await room();
665
+ else if (command === "swarm") await swarm();
565
666
  else if (command === "mcp") await mcpSnippet();
566
667
  else if (command === "notify") await notify();
668
+ else if (command === "phone") await phone();
669
+ else if (command === "agents") await agents();
670
+ else if (command === "link") await link();
671
+ else if (command === "peers") await peers();
567
672
  else if (command === "connect") await connectAgent();
568
673
  else if (command === "desktop") { if (!desktop()) process.exitCode = 1; }
569
674
  else if (command === "logs") logs();
@@ -0,0 +1,191 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { listJsonl, readJson } from "./files.mjs";
5
+ import { historyRoots } from "./paths.mjs";
6
+ import { PS } from "./nudge.mjs";
7
+
8
+ // Is anything on this machine doing agent work right now? Board runs and queued runs come from
9
+ // the daemon. Interactive Claude Code, Codex and pi sessions are read from their live processes
10
+ // and transcripts: a session is busy while its turn is open and a command it started is running
11
+ // (whatever its CPU), or while it wrote within QUIET_MS. A process an agent left running counts
12
+ // only while it uses CPU. An open turn with nothing running and nothing written is waiting,
13
+ // most often at a permission prompt that nobody can answer with the lid closed.
14
+
15
+ // Measured 2026-10-10 on the owner's transcripts, from a prompt or tool result to the model's next
16
+ // write: p99.9 131 s for Claude Code (n=14,783), 310 s for Codex (n=3,673); 9 of 18,456 exceed 5 min.
17
+ export const QUIET_MS = 5 * 60_000;
18
+ // Measured 2026-10-10 with ps: an idle server with a file watcher reads 0.0-0.1 %, a working process 99.9-100 %.
19
+ export const CPU_BUSY = 5;
20
+
21
+ const AGENT_NAMES = { claude: "claude-code", codex: "codex", pi: "pi" };
22
+ const RUNTIMES = new Set(["node", "bun", "deno"]);
23
+ // Claude Code runs every tool command in a shell; its other children (MCP servers, caffeinate) are not work.
24
+ const SHELLS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh"]);
25
+ // Started by Codex or pi during a turn without being a command it ran. A shell may exec the command itself, so any other child counts.
26
+ const HELPERS = new Set(["caffeinate"]);
27
+ const TAIL_BYTES = 256 << 10;
28
+
29
+ const base = (token) => path.basename(String(token || "")).replace(/^-/, "");
30
+
31
+ /** The agent a process is, from its command line, or null. */
32
+ export function agentOf(command) {
33
+ if (/\/@anthropic-ai\/claude-code\//.test(command)) return "claude-code";
34
+ const [first, second] = String(command).trim().split(/\s+/);
35
+ const name = RUNTIMES.has(base(first).replace(/\d+$/, "")) ? base(second) : base(first);
36
+ return AGENT_NAMES[name] || null;
37
+ }
38
+
39
+ /** ps rows: pid, parent, CPU percent, start time (ms) and command line. */
40
+ export function parsePs(text) {
41
+ const rows = [];
42
+ for (const line of String(text).split("\n")) {
43
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+([\d.]+)\s+(\w{3}\s+\w{3}\s+\d+\s+[\d:]+\s+\d{4})\s+(.*)$/);
44
+ if (!match) continue;
45
+ const startedAt = Date.parse(match[4]);
46
+ if (Number.isFinite(startedAt)) rows.push({ pid: Number(match[1]), ppid: Number(match[2]), cpu: Number(match[3]), startedAt, command: match[5] });
47
+ }
48
+ return rows;
49
+ }
50
+
51
+ const stamp = (value) => (typeof value === "number" ? value : Date.parse(value || "")) || null;
52
+ const textOf = (content) => (typeof content === "string" ? content : Array.isArray(content) ? content.map((part) => part?.text || "").join("") : "");
53
+
54
+ /** Whether a transcript's last turn is still open, and when it started, from its newest rows. */
55
+ export function turnOf(agent, rows) {
56
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
57
+ const row = rows[index];
58
+ if (agent === "codex") {
59
+ const kind = row.type === "event_msg" ? row.payload?.type : null;
60
+ if (kind === "task_started") return { open: true, startedAt: stamp(row.timestamp) };
61
+ if (kind === "task_complete" || kind === "turn_aborted") return { open: false };
62
+ } else if (agent === "pi") {
63
+ const message = row.type === "message" ? row.message : null;
64
+ if (!message) continue;
65
+ if (message.role === "user") return { open: true, startedAt: stamp(row.timestamp ?? message.timestamp) };
66
+ if (message.role === "assistant" && message.stopReason !== "toolUse") return { open: false };
67
+ } else {
68
+ if (row.isMeta || (row.type !== "user" && row.type !== "assistant")) continue;
69
+ const content = row.message?.content;
70
+ if (row.type === "assistant") {
71
+ if (row.message?.stop_reason === "end_turn") return { open: false };
72
+ continue;
73
+ }
74
+ if (Array.isArray(content) && content.some((part) => part?.type === "tool_result")) continue;
75
+ const text = textOf(content);
76
+ if (text.startsWith("[Request interrupted by user")) return { open: false };
77
+ if (text.startsWith("<local-command") || text.startsWith("<command-")) continue;
78
+ return { open: true, startedAt: stamp(row.timestamp) };
79
+ }
80
+ }
81
+ return { open: false };
82
+ }
83
+
84
+ function tailRows(file) {
85
+ let fd;
86
+ try {
87
+ fd = fs.openSync(file, "r");
88
+ const size = fs.fstatSync(fd).size;
89
+ const length = Math.min(size, TAIL_BYTES);
90
+ const buffer = Buffer.alloc(length);
91
+ fs.readSync(fd, buffer, 0, length, size - length);
92
+ const lines = buffer.toString("utf8").split("\n");
93
+ if (length < size) lines.shift();
94
+ return lines.flatMap((line) => { try { const row = JSON.parse(line); return row && typeof row === "object" ? [row] : []; } catch { return []; } });
95
+ } catch {
96
+ return [];
97
+ } finally {
98
+ if (fd !== undefined) fs.closeSync(fd);
99
+ }
100
+ }
101
+
102
+ const stat = (file) => { try { return { file, mtime: fs.statSync(file).mtimeMs }; } catch { return null; } };
103
+
104
+ const exec = (file, args) => new Promise((resolve) => {
105
+ execFile(file, args, { encoding: "utf8", maxBuffer: 16 << 20, timeout: 10_000, env: { ...process.env, LC_ALL: "C" } }, (error, stdout) => resolve(error && !stdout ? "" : stdout));
106
+ });
107
+
108
+ // Each process's working directory: Claude Code keeps a session's transcript under it.
109
+ async function workingDirectories(pids) {
110
+ const found = new Map();
111
+ if (!pids.length) return found;
112
+ if (process.platform === "linux") {
113
+ for (const pid of pids) { try { found.set(pid, fs.readlinkSync(`/proc/${pid}/cwd`)); } catch {} }
114
+ return found;
115
+ }
116
+ const lsof = ["/usr/sbin/lsof", "/usr/bin/lsof"].find((file) => fs.existsSync(file));
117
+ if (!lsof) return found;
118
+ let pid = null;
119
+ for (const line of (await exec(lsof, ["-a", "-d", "cwd", "-p", pids.join(","), "-Fpn"])).split("\n")) {
120
+ if (line.startsWith("p")) pid = Number(line.slice(1));
121
+ else if (line.startsWith("n") && pid) found.set(pid, line.slice(1));
122
+ }
123
+ return found;
124
+ }
125
+
126
+ /**
127
+ * Judges the machine from what was read. `processes` are ps rows, `sessions` are transcripts
128
+ * ({ agent, file, mtime, turn, owners: pids of the live agent processes it may belong to }).
129
+ */
130
+ export function judgeActivity({ processes, sessions, runs = [], queued = 0, now = Date.now() }) {
131
+ const children = new Map();
132
+ for (const row of processes) children.set(row.ppid, [...(children.get(row.ppid) || []), row]);
133
+ const descendants = (pid) => {
134
+ const found = [];
135
+ const pending = [...(children.get(pid) || [])];
136
+ while (pending.length) { const row = pending.pop(); found.push(row); pending.push(...(children.get(row.pid) || [])); }
137
+ return found;
138
+ };
139
+ const reasons = runs.map((run) => ({ kind: "run", agent: run.agent, id: run.id }));
140
+ if (queued) reasons.push({ kind: "queued", count: queued });
141
+ const waiting = [];
142
+ for (const session of sessions) {
143
+ if (!session.turn.open) continue;
144
+ const started = session.turn.startedAt || 0;
145
+ const ranCommand = (row) => { const name = base(row.command.split(/\s+/)[0]); return session.agent === "claude-code" ? SHELLS.has(name) : !HELPERS.has(name); };
146
+ const command = session.owners.flatMap(descendants).find((row) => row.startedAt >= started && ranCommand(row));
147
+ if (command) reasons.push({ kind: "command", agent: session.agent, file: session.file, command: command.command.slice(0, 120) });
148
+ else if (now - session.mtime < QUIET_MS) reasons.push({ kind: "turn", agent: session.agent, file: session.file });
149
+ else waiting.push({ agent: session.agent, file: session.file, since: new Date(session.mtime).toISOString() });
150
+ }
151
+ for (const agentRow of processes.filter((row) => agentOf(row.command))) {
152
+ const hot = descendants(agentRow.pid).find((row) => row.cpu >= CPU_BUSY);
153
+ if (hot) reasons.push({ kind: "background", agent: agentOf(agentRow.command), pid: agentRow.pid, command: hot.command.slice(0, 120), cpu: hot.cpu });
154
+ }
155
+ return { busy: reasons.length > 0, reasons, waiting, sessions: sessions.map((session) => ({ agent: session.agent, file: session.file, open: session.turn.open })), checkedAt: new Date(now).toISOString() };
156
+ }
157
+
158
+ /** Reads this machine's agent processes and transcripts and judges them. */
159
+ export async function machineActivity({ runs = [], queued = 0, agents = ["claude-code", "codex", "pi"], now = Date.now() } = {}) {
160
+ const processes = process.platform === "win32" ? [] : parsePs(await exec(PS, ["-axww", "-o", "pid=,ppid=,pcpu=,lstart=,command="]));
161
+ const live = processes.filter((row) => agents.includes(agentOf(row.command)));
162
+ const claudeRecords = (pid) => historyRoots("claude-code").some((root) => fs.existsSync(path.join(root, "..", "sessions", `${pid}.json`)));
163
+ const cwds = await workingDirectories(live.filter((row) => agentOf(row.command) === "claude-code" && !claudeRecords(row.pid)).map((row) => row.pid));
164
+ const sessions = [];
165
+ for (const agent of agents) {
166
+ const owners = live.filter((row) => agentOf(row.command) === agent);
167
+ if (!owners.length) continue;
168
+ // A transcript older than every live process of its agent cannot be one of their open turns.
169
+ const since = Math.min(...owners.map((row) => row.startedAt));
170
+ for (const root of historyRoots(agent)) {
171
+ if (agent === "claude-code") {
172
+ // Claude Code records each process's session beside its projects folder; older versions are matched by folder.
173
+ const records = path.join(root, "..", "sessions");
174
+ for (const row of owners) {
175
+ const record = readJson(path.join(records, `${row.pid}.json`), null);
176
+ const cwd = record?.cwd || cwds.get(row.pid);
177
+ if (!cwd) continue;
178
+ const folder = path.join(root, cwd.replace(/[^a-zA-Z0-9]/g, "-"));
179
+ const items = record?.sessionId ? [stat(path.join(folder, `${record.sessionId}.jsonl`))].filter(Boolean) : listJsonl(folder).filter((item) => item.mtime >= row.startedAt);
180
+ for (const item of items) sessions.push({ agent, file: item.file, mtime: item.mtime, turn: turnOf(agent, tailRows(item.file)), owners: [row.pid] });
181
+ }
182
+ } else {
183
+ for (const item of listJsonl(root)) {
184
+ if (item.mtime >= since) sessions.push({ agent, file: item.file, mtime: item.mtime, turn: turnOf(agent, tailRows(item.file)), owners: owners.map((row) => row.pid) });
185
+ }
186
+ }
187
+ }
188
+ }
189
+ // Every agent process, whether or not it is working: the desktop app never pauses the app hosting one.
190
+ return { ...judgeActivity({ processes, sessions, runs, queued, now }), agentPids: processes.filter((row) => agentOf(row.command)).map((row) => row.pid) };
191
+ }
package/lib/adapters.mjs CHANGED
@@ -4,8 +4,9 @@ import path from "node:path";
4
4
  import { detectAgents, run } from "./commands.mjs";
5
5
  import { ensureDir, readJson } from "./files.mjs";
6
6
  import { paths } from "./paths.mjs";
7
+ import { MACHINE_RUNNERS } from "./members.mjs";
7
8
 
8
- export const AGENTS = ["claude-code", "codex", "pi", "opencode"];
9
+ export const AGENTS = MACHINE_RUNNERS;
9
10
 
10
11
  // MCP clients that are not coding agents: no history to index and nothing to run, but they
11
12
  // join the machine's task board by launching the same stdio MCP under their own name.
@@ -0,0 +1,90 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+
6
+ // claude-mem (github.com/thedotmack/claude-mem), when the person runs it: its local worker
7
+ // remembers what earlier agent sessions did in a project. A board run starts with the same
8
+ // newest notes claude-mem would give an agent starting there. The project is named the way
9
+ // claude-mem names it (git root folder, its origin slug under git-remote naming, or a marker
10
+ // folder) and used only when the worker lists it, so a miss gives no notes, never another
11
+ // project's. Read only; nothing here leaves the machine.
12
+
13
+ function readSettings(env) {
14
+ const dir = env.CLAUDE_MEM_DATA_DIR || path.join(os.homedir(), ".claude-mem");
15
+ try { return JSON.parse(fs.readFileSync(path.join(dir, "settings.json"), "utf8")); } catch { return null; }
16
+ }
17
+
18
+ const setting = (env, settings, key, fallback) => env[key] || settings[key] || fallback;
19
+
20
+ function git(cwd, args) {
21
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
22
+ return result.status === 0 ? result.stdout.trim() || null : null;
23
+ }
24
+
25
+ // The last two segments of a remote URL (https or scp form), as claude-mem's git-remote naming reads it.
26
+ function remoteSlug(url) {
27
+ if (!url || /^(file:|[/.~\\]|[a-z]:[\\/])/i.test(url)) return null;
28
+ const cleaned = url.replace(/\/+$/, "").replace(/\.git$/i, "");
29
+ const tail = cleaned.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]+\/(.+)$/i)?.[1] ?? cleaned.match(/^(?:[^/@\s]+@)?[^:/\s]+:(?!\/\/)(.+)$/)?.[1];
30
+ const segments = (tail || "").split("/").filter((segment) => segment && segment !== "_git");
31
+ return segments.length ? segments.slice(-2).join("/") : null;
32
+ }
33
+
34
+ function markerRoot(dir) {
35
+ const stops = new Set([os.homedir(), os.tmpdir(), "/"].map((stop) => path.resolve(stop)));
36
+ for (let current = path.resolve(dir); !stops.has(current); current = path.dirname(current)) {
37
+ if ([".claude-mem-project", ".claude-mem.json"].some((marker) => fs.existsSync(path.join(current, marker)))) return current;
38
+ if (path.dirname(current) === current) break;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /** The names claude-mem may hold this folder's memory under, most specific first. */
44
+ export function projectNames(cwd, env, settings) {
45
+ const root = git(cwd, ["rev-parse", "--show-toplevel"]);
46
+ if (!root) return [path.basename(markerRoot(cwd) || cwd)];
47
+ const names = [];
48
+ if (String(setting(env, settings, "CLAUDE_MEM_PROJECT_NAME_SOURCE", "path")).toLowerCase() === "git-remote") names.push(remoteSlug(git(root, ["remote", "get-url", "origin"])));
49
+ names.push(path.basename(root));
50
+ // A worktree's memory can sit under the main checkout's name.
51
+ const common = git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
52
+ if (common && path.basename(common) === ".git") names.push(path.basename(path.dirname(common)));
53
+ return [...new Set(names.filter(Boolean))];
54
+ }
55
+
56
+ function render(observation) {
57
+ const heading = `- ${String(observation.created_at).slice(0, 10)} · ${observation.type} · ${observation.title || "untitled"}`;
58
+ return [heading, observation.subtitle, observation.narrative].filter(Boolean).join("\n ");
59
+ }
60
+
61
+ /** claude-mem's newest notes on the project `cwd` belongs to, or null when it has none or is not running. */
62
+ export async function claudeMemNotes(cwd, { env = process.env, timeoutMs, maxChars = 20_000 } = {}) {
63
+ const settings = cwd && readSettings(env);
64
+ if (!settings) return null;
65
+ const host = setting(env, settings, "CLAUDE_MEM_WORKER_HOST", "127.0.0.1");
66
+ const port = setting(env, settings, "CLAUDE_MEM_WORKER_PORT", String(37700 + ((process.getuid?.() ?? 77) % 100)));
67
+ const base = `http://${host.includes(":") ? `[${host}]` : host}:${port}`;
68
+ const signal = AbortSignal.timeout(timeoutMs);
69
+ try {
70
+ const known = new Set(((await (await fetch(`${base}/api/projects`, { signal })).json()).projects || []).map((name) => String(name).toLowerCase()));
71
+ const project = projectNames(cwd, env, settings).find((name) => known.has(name.toLowerCase()));
72
+ if (!project) return null;
73
+ const limit = Number(setting(env, settings, "CLAUDE_MEM_CONTEXT_OBSERVATIONS", "50")) || 50;
74
+ const response = await fetch(`${base}/api/search?type=observations&format=json&project=${encodeURIComponent(project)}&limit=${limit}`, { signal });
75
+ if (!response.ok) return null;
76
+ // Whole notes, newest first, as many as fit the task context's entry size; a note is never cut.
77
+ const header = `Project "${project}", newest first:`;
78
+ const notes = [header];
79
+ let size = header.length;
80
+ for (const observation of (await response.json()).observations || []) {
81
+ const text = render(observation);
82
+ if (size + text.length + 1 > maxChars) break;
83
+ notes.push(text);
84
+ size += text.length + 1;
85
+ }
86
+ return notes.length > 1 ? notes.join("\n") : null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }