tryharness 0.7.6 → 0.7.8

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.
@@ -54,11 +54,22 @@ function notify(message) {
54
54
  } catch {}
55
55
  }
56
56
 
57
+ // Where a session runs, as opencode's own environment says (Harness's places.mjs reads it): once per session.
58
+ const PLACE_ENV = ["__CFBundleIdentifier", "TERM_PROGRAM", "TMUX", "TMUX_PANE", "WEZTERM_PANE", "KITTY_WINDOW_ID", "KITTY_LISTEN_ON", "WT_SESSION", "VSCODE_GIT_ASKPASS_NODE"];
59
+ const placed = new Set();
60
+ function notePlace(sessionId, directory) {
61
+ if (placed.has(sessionId)) return;
62
+ placed.add(sessionId);
63
+ const env = Object.fromEntries(PLACE_ENV.filter((name) => process.env[name]).map((name) => [name, process.env[name]]));
64
+ notify({ command: "board.live.place", agent: "opencode", sessionId, folder: directory, pids: [process.pid, process.ppid], env });
65
+ }
66
+
57
67
  export const TryHarnessPlugin = async ({ directory }) => ({
58
68
  // Every message of a session carries the board nudge as an extra text part when the queue changed since the last one.
59
69
  "chat.message": async (input, output) => {
60
70
  const sessionId = input?.sessionID || input?.sessionId;
61
71
  if (!sessionId || !Array.isArray(output?.parts)) return;
72
+ notePlace(sessionId, directory);
62
73
  const nudge = await boardNudge(sessionId);
63
74
  if (nudge) output.parts.push({ type: "text", text: nudge });
64
75
  },
@@ -134,6 +134,14 @@ export default function tryHarness(pi) {
134
134
  ctx?.ui?.notify?.(nudge.split("\n").slice(0, 2).join(" "), "info");
135
135
  };
136
136
  pi.on("session_start", async (_event, ctx) => tell(await boardNudge(false), ctx));
137
+ // Where the session runs, as pi's own environment says (Harness's places.mjs reads it).
138
+ pi.on("session_start", (_event, ctx) => {
139
+ const sessionId = ctx?.sessionManager?.getSessionId?.();
140
+ if (!sessionId) return;
141
+ const names = ["__CFBundleIdentifier", "TERM_PROGRAM", "TMUX", "TMUX_PANE", "WEZTERM_PANE", "KITTY_WINDOW_ID", "KITTY_LISTEN_ON", "WT_SESSION", "VSCODE_GIT_ASKPASS_NODE"];
142
+ const env = Object.fromEntries(names.filter((name) => process.env[name]).map((name) => [name, process.env[name]]));
143
+ void daemon({ command: "board.live.place", agent: "pi", sessionId, folder: ctx?.cwd || process.cwd(), pids: [process.pid, process.ppid], env });
144
+ });
137
145
  // Into the model's own context: pi lets an extension return the system prompt it should use
138
146
  // (pi 0.87: before_agent_start -> { systemPrompt }). The queue is appended, never substituted.
139
147
  pi.on("before_agent_start", async (event) => {
package/cli.mjs CHANGED
@@ -5,6 +5,8 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
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";
8
10
  import { detectAgents } from "./lib/commands.mjs";
9
11
  import { daemonRequest } from "./lib/control-client.mjs";
10
12
  import { ensureDir, readJson, writeJsonAtomic } from "./lib/files.mjs";
@@ -117,13 +119,6 @@ async function waitForDaemon(timeoutMs = 12_000) {
117
119
  throw new Error(`daemon started but its authenticated Harness bridge did not become ready: ${lastError?.message || lastStatus?.lastError || "timeout"}. Run: npx tryharness doctor`);
118
120
  }
119
121
 
120
- function printAgentStates(states) {
121
- for (const state of states) {
122
- const icon = !state.installed ? "–" : state.configured ? "✓" : "!";
123
- console.log(` ${icon} ${state.agent}: ${state.detail}`);
124
- }
125
- }
126
-
127
122
  async function connect() {
128
123
  ensureDir(paths.home);
129
124
  const detected = detectAgents();
@@ -152,14 +147,10 @@ async function connect() {
152
147
  const stateMap = Object.fromEntries(states.map((state) => [state.agent, state]));
153
148
  config = { ...readJson(paths.config, config), version: VERSION, runtimeRoot, agentStates: stateMap };
154
149
  writeJsonAtomic(paths.config, config);
155
- printAgentStates(states);
156
- for (const client of detectMcpClients()) {
157
- if (client.path) console.log(` ! ${client.client}: found; it joins the task board once you paste its MCP snippet (npx tryharness mcp ${client.client})`);
158
- }
159
-
160
150
  const service = installService({ runtimeRoot });
161
151
  const ready = await waitForDaemon();
162
152
  console.log(`daemon: ready via ${service.manager} and connected to Harness`);
153
+ await setupSwarm({ runtimeRoot, states, daemonRequest });
163
154
  try {
164
155
  const devices = await daemonRequest({ command: "phone.list" });
165
156
  if (devices.ok && !devices.phones.some((device) => device.kind === "browser" && !device.revoked_at)) {
@@ -312,7 +303,7 @@ async function board() {
312
303
  const targets = String(arg("--for", "")).split(",").map((value) => value.trim()).filter(Boolean);
313
304
  const target = targets.length === 1 ? targets[0] : null;
314
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.
315
- 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);
316
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>]");
317
308
  const objective = arg("--objective", "");
318
309
  const file = arg("--file");
@@ -527,11 +518,12 @@ async function swarm() {
527
518
 
528
519
  async function room() {
529
520
  const agent = arg("--as", "user");
530
- 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]));
531
522
  if (words[0] === "post") {
532
- 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") });
533
525
  if (!response.ok) throw new Error(response.error || "post failed");
534
- 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}` : ""}`);
535
527
  return;
536
528
  }
537
529
  const response = await daemonRequest({ command: "board.room.read", agent, id: String(words[0] || ""), since: Number(arg("--since", "0")) });
@@ -592,11 +584,21 @@ async function disconnect() {
592
584
  retireDesktop({});
593
585
  try { await daemonRequest({ command: "shutdown" }, 2_000); } catch {}
594
586
  removeAgentConfigurations();
587
+ removeLocalWakes();
595
588
  try { fs.renameSync(paths.config, paths.config + `.disconnected-${Date.now()}`); } catch {}
596
589
  for (const target of [paths.status, paths.socket]) try { fs.unlinkSync(target); } catch {}
597
590
  console.log("Harness disconnected. The local history database was preserved.");
598
591
  }
599
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
+
600
602
  async function phone() {
601
603
  const sub = argv[0] || "pair";
602
604
  if (sub === "pair") {
@@ -648,7 +650,7 @@ async function link({ quiet = false } = {}) {
648
650
  }
649
651
 
650
652
  function help() {
651
- 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 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 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`);
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`);
652
654
  }
653
655
 
654
656
  try {
@@ -664,6 +666,7 @@ try {
664
666
  else if (command === "mcp") await mcpSnippet();
665
667
  else if (command === "notify") await notify();
666
668
  else if (command === "phone") await phone();
669
+ else if (command === "agents") await agents();
667
670
  else if (command === "link") await link();
668
671
  else if (command === "peers") await peers();
669
672
  else if (command === "connect") await connectAgent();
@@ -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.
@@ -59,11 +60,14 @@ export function installClaudeSessionHook({ runtimeRoot, homeDir = os.homedir(),
59
60
  // Mid-session: on every prompt, one line only when the queue changed since the session last saw it.
60
61
  const prompts = (Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : []).filter((entry) => !isHarnessHook(entry));
61
62
  prompts.push({ hooks: [{ type: "command", command: `${JSON.stringify(nodePath)} ${nudge} --agent claude-code --if-changed`, timeout: 10 }] });
63
+ // When a turn ends: a message the person sent this session from Harness keeps it going.
64
+ const stops = (Array.isArray(hooks.Stop) ? hooks.Stop : []).filter((entry) => !isHarnessHook(entry));
65
+ stops.push({ hooks: [{ type: "command", command: `${JSON.stringify(nodePath)} ${nudge} --agent claude-code --stop`, timeout: 10 }] });
62
66
  ensureDir(path.dirname(file));
63
- fs.writeFileSync(file, JSON.stringify({ ...settings, hooks: { ...hooks, SessionStart: starts, UserPromptSubmit: prompts } }, null, 2) + "\n");
67
+ fs.writeFileSync(file, JSON.stringify({ ...settings, hooks: { ...hooks, SessionStart: starts, UserPromptSubmit: prompts, Stop: stops } }, null, 2) + "\n");
64
68
  }
65
69
 
66
- // Codex reads the same hook shape from ~/.codex/hooks.json (SessionStart and UserPromptSubmit,
70
+ // Codex reads the same hook shape from ~/.codex/hooks.json (SessionStart, UserPromptSubmit and Stop,
67
71
  // stdout added as developer context). Codex asks the person to trust a new or changed hook once,
68
72
  // with /hooks in its TUI, before it runs; setup says so.
69
73
  export function codexHooksPath(homeDir = os.homedir()) {
@@ -79,8 +83,10 @@ export function installCodexHooks({ runtimeRoot, homeDir = os.homedir(), nodePat
79
83
  starts.push({ matcher: "startup|resume", hooks: [{ type: "command", command: `${JSON.stringify(nodePath)} ${nudge} --agent codex`, statusMessage: "Reading the Harness board", timeout: 10 }] });
80
84
  const prompts = (Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : []).filter((entry) => !isHarnessHook(entry));
81
85
  prompts.push({ hooks: [{ type: "command", command: `${JSON.stringify(nodePath)} ${nudge} --agent codex --if-changed`, timeout: 10 }] });
86
+ const stops = (Array.isArray(hooks.Stop) ? hooks.Stop : []).filter((entry) => !isHarnessHook(entry));
87
+ stops.push({ hooks: [{ type: "command", command: `${JSON.stringify(nodePath)} ${nudge} --agent codex --stop`, timeout: 10 }] });
82
88
  ensureDir(path.dirname(file));
83
- fs.writeFileSync(file, JSON.stringify({ ...settings, hooks: { ...hooks, SessionStart: starts, UserPromptSubmit: prompts } }, null, 2) + "\n");
89
+ fs.writeFileSync(file, JSON.stringify({ ...settings, hooks: { ...hooks, SessionStart: starts, UserPromptSubmit: prompts, Stop: stops } }, null, 2) + "\n");
84
90
  }
85
91
 
86
92
  export function removeCodexHooks(homeDir = os.homedir()) {
@@ -88,7 +94,7 @@ export function removeCodexHooks(homeDir = os.homedir()) {
88
94
  const settings = readJson(file, null);
89
95
  if (!settings?.hooks) return;
90
96
  const hooks = { ...settings.hooks };
91
- for (const event of ["SessionStart", "UserPromptSubmit"]) {
97
+ for (const event of ["SessionStart", "UserPromptSubmit", "Stop"]) {
92
98
  if (Array.isArray(hooks[event])) hooks[event] = hooks[event].filter((entry) => !isHarnessHook(entry));
93
99
  }
94
100
  fs.writeFileSync(file, JSON.stringify({ ...settings, hooks }, null, 2) + "\n");
@@ -99,7 +105,7 @@ export function removeClaudeSessionHook(homeDir = os.homedir()) {
99
105
  const settings = readJson(file, null);
100
106
  if (!settings?.hooks) return;
101
107
  const hooks = { ...settings.hooks };
102
- for (const event of ["SessionStart", "UserPromptSubmit"]) {
108
+ for (const event of ["SessionStart", "UserPromptSubmit", "Stop"]) {
103
109
  if (Array.isArray(hooks[event])) hooks[event] = hooks[event].filter((entry) => !isHarnessHook(entry));
104
110
  }
105
111
  fs.writeFileSync(file, JSON.stringify({ ...settings, hooks }, null, 2) + "\n");
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import os from "node:os";
2
2
  import { spawn } from "node:child_process";
3
+ import { MACHINE_RUNNERS, nameOf } from "./members.mjs";
3
4
 
4
5
  // The swarm's coordinator, run from this machine whether or not a Harness window is open. When a thread goes quiet
5
6
  // (no run left going) and has new posts the coordinator has not looked at, it runs one round: it gathers the facts
@@ -8,8 +9,7 @@ import { spawn } from "node:child_process";
8
9
  // with its tools off. The round's answer is carried out here under the thread's own rules: a brief that wakes an
9
10
  // agent, a question for the person, a part left behind, a pause. Every round is recorded with its facts and cost.
10
11
 
11
- const NAMES = { "claude-code": "Claude Code", codex: "Codex", pi: "Pi", opencode: "OpenCode", "grok-bot": "Grok Bot", bankr: "Bankr", quotient: "Quotient", muse: "Muse" };
12
- const CODING = new Set(["claude-code", "codex", "pi", "opencode"]);
12
+ const CODING = new Set(MACHINE_RUNNERS);
13
13
  // The agents with a read-only switch (runner.mjs): only they can be handed a review.
14
14
  const REVIEWERS = new Set(["claude-code", "codex"]);
15
15
  const TRANSCRIPT_POSTS = 30;
@@ -79,7 +79,7 @@ export function createCoordinator({ store, mode, askHarness, connected, roster,
79
79
  const found = await searchContext(thread.id, newest, 6).catch(() => ({ entries: [] }));
80
80
  related = found.entries.filter((entry) => !shown.has(entry.text)).slice(0, 4).map((entry) => ({ source: `this thread, ${entry.author}`, text: entry.text.slice(0, 4_000) }));
81
81
  for (const hit of store.search({ query: newest.split(/\s+/).slice(0, 12).join(" "), limit: 3 })) {
82
- if (hit.excerpt) related.push({ source: `${NAMES[hit.agent] ?? hit.agent} session "${String(hit.title || "").slice(0, 120)}"`, text: String(hit.excerpt).slice(0, 4_000) });
82
+ if (hit.excerpt) related.push({ source: `${nameOf(hit.agent)} session "${String(hit.title || "").slice(0, 120)}"`, text: String(hit.excerpt).slice(0, 4_000) });
83
83
  }
84
84
  }
85
85
  return {
@@ -114,6 +114,10 @@ export function createCoordinator({ store, mode, askHarness, connected, roster,
114
114
  const posts = store.roomPosts(goalId, state.lastSeenSeq, 500).filter((post) => !QUIET_KINDS.has(post.kind) && post.author !== thread.goal.facilitator);
115
115
  const active = store.threadRuns(goalId).some((run) => ["open", "claimed", "running"].includes(run.status));
116
116
  if (!posts.length || active) return;
117
+ // A task's own room is coordinated once it becomes a conversation: someone posted in it, not only the task and its result.
118
+ if (store.meta(`task-room:${goalId}`) && !store.roomPosts(goalId, 0, 500).some((post) => post.kind === "post")) return;
119
+ // A thread started through Harness by an agent that runs elsewhere gets no rounds until the person sets a spend ceiling.
120
+ if (store.meta(`hosted-thread:${goalId}`)) return;
117
121
  const local = mode() === "local";
118
122
  if (!local && !connected()) return;
119
123
  const localAgent = local ? localAgents().find((agent) => agent === "claude-code" || agent === "codex") : null;