tryharness 0.7.6 → 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 +20 -17
- package/lib/activity.mjs +191 -0
- package/lib/adapters.mjs +2 -1
- package/lib/claudemem.mjs +90 -0
- package/lib/coordinator.mjs +7 -3
- package/lib/daemon.mjs +316 -66
- package/lib/mcp-stdio.mjs +23 -5
- package/lib/members.json +31 -0
- package/lib/members.mjs +47 -0
- package/lib/onboard.mjs +131 -0
- package/lib/phones.mjs +9 -5
- package/lib/runner.mjs +18 -2
- package/lib/store.mjs +49 -4
- package/lib/swarm.mjs +51 -4
- package/lib/wakes.mjs +196 -0
- package/package.json +3 -3
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("--") &&
|
|
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
|
|
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
|
|
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();
|
package/lib/activity.mjs
ADDED
|
@@ -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 =
|
|
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
|
+
}
|
package/lib/coordinator.mjs
CHANGED
|
@@ -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
|
|
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: `${
|
|
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;
|