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.
package/lib/live.mjs ADDED
@@ -0,0 +1,205 @@
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
+ import { readJson } from "./files.mjs";
6
+ import { historyRoots } from "./paths.mjs";
7
+ import { PS } from "./nudge.mjs";
8
+ import { stepsOf } from "./runner.mjs";
9
+ import { t3Link, t3Thread } from "./places.mjs";
10
+
11
+ // The person's own coding sessions running on this computer right now, each with the app it runs in: a terminal, an
12
+ // editor or T3 Code, found by walking up the session's process tree to the first process inside an app bundle. A
13
+ // session in T3 Code also gets the link that opens its thread there (T3's own prompt links, t3code://threads/…).
14
+ // Only Claude Code and Codex: their hooks (nudge.mjs) are how a message from Harness reaches a session. Where each
15
+ // session runs, and how it opens there again, is places.mjs.
16
+
17
+ export const LIVE_AGENTS = ["claude-code", "codex"];
18
+ const PLAIN_ID = /^[A-Za-z0-9._:-]{1,128}$/;
19
+
20
+ /** Every process: its parent, its terminal (/dev/ttys003, or null) and its executable, by pid. */
21
+ export function processTable() {
22
+ const table = new Map();
23
+ if (process.platform === "win32") return table;
24
+ const ps = spawnSync(PS, ["-axo", "pid=,ppid=,tty=,comm="], { encoding: "utf8", maxBuffer: 16 << 20 });
25
+ for (const line of (ps.stdout || "").split("\n")) {
26
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
27
+ if (match) table.set(Number(match[1]), { ppid: Number(match[2]), tty: /^ttys?\d+$/.test(match[3]) ? `/dev/${match[3]}` : null, comm: match[4] });
28
+ }
29
+ return table;
30
+ }
31
+
32
+ /** True when a process runs inside tmux: a tmux server is among its ancestors. */
33
+ function underTmux(table, pid) {
34
+ for (let current = pid, steps = 0; current > 1 && steps < 64; steps++) {
35
+ const row = table.get(current);
36
+ if (!row) return false;
37
+ if (/^tmux\b/.test(path.basename(row.comm))) return true;
38
+ current = row.ppid;
39
+ }
40
+ return false;
41
+ }
42
+
43
+ const run = (command, args) => spawnSync(command, args, { encoding: "utf8", timeout: 3_000 });
44
+
45
+ /** The tmux pane showing a terminal, and the terminal (app and tty) a tmux client shows it in. */
46
+ export function tmuxPlace(table, tty) {
47
+ const panes = run("tmux", ["list-panes", "-a", "-F", "#{pane_tty}\t#{session_name}\t#{session_name}:#{window_index}.#{pane_index}"]);
48
+ const pane = (panes.stdout || "").split("\n").map((line) => line.split("\t")).find(([paneTty]) => paneTty === tty);
49
+ if (!pane) return null;
50
+ const clients = run("tmux", ["list-clients", "-t", pane[1], "-F", "#{client_tty}\t#{client_pid}"]);
51
+ const [clientTty, clientPid] = (clients.stdout || "").split("\n").filter(Boolean).map((line) => line.split("\t"))[0] ?? [];
52
+ return { target: pane[2], client: clientPid ? { tty: clientTty || null, host: hostApp(table, Number(clientPid)) } : null };
53
+ }
54
+
55
+ /** The app a process runs in: the outermost app bundle on its way up to launchd, or null (tmux, ssh, a service). */
56
+ export function hostApp(table, pid) {
57
+ for (let current = pid, steps = 0; current > 1 && steps < 64; steps++) {
58
+ const row = table.get(current);
59
+ if (!row) return null;
60
+ const bundle = row.comm.match(/^(\/.*?\.app)\/Contents\//)?.[1];
61
+ if (bundle) return { name: path.basename(bundle, ".app"), app: bundle };
62
+ current = row.ppid;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ export function workingDirectories(pids) {
68
+ const found = new Map();
69
+ if (!pids.length) return found;
70
+ const lsof = spawnSync("lsof", ["-a", "-d", "cwd", "-Fpn", "-p", pids.join(",")], { encoding: "utf8" });
71
+ let pid = null;
72
+ for (const line of (lsof.stdout || "").split("\n")) {
73
+ if (line.startsWith("p")) pid = Number(line.slice(1));
74
+ else if (line.startsWith("n") && pid) found.set(pid, line.slice(1));
75
+ }
76
+ return found;
77
+ }
78
+
79
+ /** The session a running agent process has open: Claude Code's own record of it, or Codex's newest session in its folder. */
80
+ export function sessionOfProcess(agent, pid, recent = []) {
81
+ if (!Number.isInteger(pid) || pid <= 1) return null;
82
+ if (agent === "claude-code") {
83
+ const record = readJson(path.join(historyRoots("claude-code")[0], "..", "sessions", `${pid}.json`), null);
84
+ return record && PLAIN_ID.test(String(record.sessionId || "")) ? record.sessionId : null;
85
+ }
86
+ if (agent !== "codex") return null;
87
+ const cwd = workingDirectories([pid]).get(pid);
88
+ const session = cwd ? recent.find((row) => row.agent === "codex" && row.cwd === cwd) : null;
89
+ return String(session?.session_id || "").match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0] ?? null;
90
+ }
91
+
92
+ /**
93
+ * The live sessions, newest first. `exclude` holds the pids of runs Harness started itself; `recent` is the session
94
+ * index (Codex keeps no per-process record, so its session is the newest indexed one in the process's folder).
95
+ */
96
+ export function liveSessions({ exclude = new Set(), recent = [], table = processTable() } = {}) {
97
+ const sessions = [];
98
+ const records = path.join(historyRoots("claude-code")[0], "..", "sessions");
99
+ let files = [];
100
+ try { files = fs.readdirSync(records).filter((name) => name.endsWith(".json")); } catch {}
101
+ for (const name of files) {
102
+ const record = readJson(path.join(records, name), null);
103
+ if (!record?.pid || !table.has(record.pid) || exclude.has(record.pid) || !PLAIN_ID.test(String(record.sessionId || ""))) continue;
104
+ if (record.kind && record.kind !== "interactive") continue;
105
+ sessions.push({ agent: "claude-code", sessionId: record.sessionId, folder: record.cwd || null, pid: record.pid, entrypoint: record.entrypoint || null,
106
+ busy: record.status === "busy" ? true : record.status === "idle" ? false : null, at: Number(record.statusUpdatedAt || record.startedAt || 0) });
107
+ }
108
+ // A Codex process outside any terminal and app is Codex's own background server (its terminals share it), not a session.
109
+ const codex = [...table].filter(([pid, row]) => path.basename(row.comm) === "codex" && !exclude.has(pid) && (row.tty || hostApp(table, pid))).map(([pid]) => pid);
110
+ const cwds = workingDirectories(codex);
111
+ for (const pid of codex) {
112
+ const session = recent.find((row) => row.agent === "codex" && row.cwd && row.cwd === cwds.get(pid));
113
+ const id = String(session?.session_id || "").replace(/\.jsonl$/, "").match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0];
114
+ if (id && !sessions.some((row) => row.sessionId === id)) sessions.push({ agent: "codex", sessionId: id, folder: session.cwd, pid, busy: null, at: Date.parse(session.started_at) || 0 });
115
+ }
116
+ return sessions.sort((a, b) => b.at - a.at).map((session) => {
117
+ const tty = table.get(session.pid)?.tty ?? null;
118
+ // Inside tmux the app is the terminal a tmux client shows the pane in.
119
+ const tmux = underTmux(table, session.pid) && tty ? tmuxPlace(table, tty) : null;
120
+ const host = tmux?.client?.host ?? hostApp(table, session.pid);
121
+ const t3 = host && /^T3 Code\b/.test(host.name) ? t3Link(t3Thread(session.sessionId)) : null;
122
+ return { ...session, tty, tmux: tmux?.target ?? null, host, t3 };
123
+ });
124
+ }
125
+
126
+ /**
127
+ * A running terminal session brought to the front on its own pane: its tmux pane, WezTerm pane or kitty window selected
128
+ * here (by the ids it started with, else by its terminal device), then its app on its tab (the app does that step:
129
+ * src-tauri agents.rs focus_session, by the tty).
130
+ */
131
+ export function focusTerminal(session, place = null, table = processTable()) {
132
+ let tty = session.tty;
133
+ const tmux = tty && (session.tmux || place?.tmux) ? tmuxPlace(table, tty) : null;
134
+ if (tmux) {
135
+ run("tmux", ["select-window", "-t", tmux.target]);
136
+ run("tmux", ["select-pane", "-t", tmux.target]);
137
+ tty = tmux.client?.tty ?? null;
138
+ }
139
+ if (place?.wezterm || (session.host && /^WezTerm$/i.test(session.host.name))) {
140
+ let pane = place?.wezterm ?? null;
141
+ if (!pane) try { pane = JSON.parse(run("wezterm", ["cli", "list", "--format", "json"]).stdout || "[]").find((entry) => entry.tty_name === session.tty)?.pane_id ?? null; } catch {}
142
+ if (pane != null) run("wezterm", ["cli", "activate-pane", "--pane-id", String(pane)]);
143
+ }
144
+ if (place?.kitty || (session.host && /^kitty$/i.test(session.host.name))) {
145
+ const to = place?.kitty?.socket ? ["--to", place.kitty.socket] : [];
146
+ run("kitty", ["@", ...to, "focus-window", "--match", place?.kitty ? `id:${place.kitty.id}` : `pid:${session.pid}`]);
147
+ }
148
+ return session.host ? { app: session.host.app, tty } : {};
149
+ }
150
+
151
+ const FEED_BYTES = 768 << 10;
152
+ const promptText = (content) => typeof content === "string" ? content
153
+ : Array.isArray(content) ? content.filter((block) => ["text", "input_text"].includes(block?.type) && typeof block.text === "string").map((block) => block.text).join("\n") : "";
154
+ // What the agent itself was handed (instructions, environment, hook output), not words the person typed.
155
+ const machinery = (text) => !text.trim() || /^\s*<[a-z_-]+[\s>]/i.test(text) || text.startsWith("Caveat:");
156
+
157
+ /** A session's own file: the index's source, else Claude Code's projects folder for its working folder. */
158
+ export function sessionFile(agent, sessionId, folder, recent = []) {
159
+ const row = recent.find((entry) => entry.agent === agent && String(entry.session_id).includes(sessionId) && entry.source);
160
+ if (row && fs.existsSync(row.source)) return row.source;
161
+ if (agent === "claude-code" && folder && PLAIN_ID.test(sessionId)) {
162
+ const file = path.join(historyRoots("claude-code")[0], folder.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`);
163
+ if (fs.existsSync(file)) return file;
164
+ }
165
+ return null;
166
+ }
167
+
168
+ /**
169
+ * The end of a session as the person reads it, oldest first: what they asked ("you"), what the agent said ("say") and
170
+ * the tools it used ("do"). Read from the last part of the session's own file, so a long session costs no more.
171
+ */
172
+ export function sessionFeed(agent, file, limit = 80) {
173
+ let text = "";
174
+ try {
175
+ const size = fs.statSync(file).size;
176
+ const handle = fs.openSync(file, "r");
177
+ const buffer = Buffer.alloc(Math.min(size, FEED_BYTES));
178
+ fs.readSync(handle, buffer, 0, buffer.length, size - buffer.length);
179
+ fs.closeSync(handle);
180
+ text = buffer.toString("utf8");
181
+ if (size > buffer.length) text = text.slice(text.indexOf("\n") + 1);
182
+ } catch { return []; }
183
+ const items = [];
184
+ for (const line of text.split("\n")) {
185
+ if (!line.startsWith("{")) continue;
186
+ let row;
187
+ try { row = JSON.parse(line); } catch { continue; }
188
+ const at = row.timestamp || null;
189
+ if (agent === "claude-code") {
190
+ if (row.type === "user" && !row.isMeta) {
191
+ const said = promptText(row.message?.content);
192
+ if (!machinery(said)) items.push({ kind: "you", text: said.trim(), at });
193
+ } else if (row.type === "assistant") items.push(...stepsOf(row).map((step) => ({ ...step, at })));
194
+ } else if (agent === "codex" && row.type === "response_item") {
195
+ const item = row.payload || {};
196
+ if (item.type === "message" && item.role === "user") { const said = promptText(item.content); if (!machinery(said)) items.push({ kind: "you", text: said.trim(), at }); }
197
+ else if (item.type === "message" && item.role === "assistant") { const said = (item.content || []).map((block) => block?.text || "").join("\n").trim(); if (said) items.push({ kind: "say", text: said, at }); }
198
+ else if (item.type === "function_call" || item.type === "custom_tool_call") items.push({ kind: "do", text: `${item.name}: ${String(item.arguments ?? item.input ?? "").replace(/\s+/g, " ").slice(0, 200)}`, at });
199
+ } else if (agent === "pi" && row.type === "message" && row.message) {
200
+ if (row.message.role === "user") { const said = promptText(row.message.content); if (!machinery(said)) items.push({ kind: "you", text: said.trim(), at }); }
201
+ else if (row.message.role === "assistant") items.push(...stepsOf({ type: "message_end", message: row.message }).map((step) => ({ ...step, at })));
202
+ }
203
+ }
204
+ return items.slice(-limit);
205
+ }
package/lib/mcp-stdio.mjs CHANGED
@@ -13,7 +13,7 @@ let chain = Promise.resolve();
13
13
  const LOCAL_TOOLS = [
14
14
  {
15
15
  name: "list_tasks",
16
- description: "List open work on this machine's Harness task board: tasks posted by the user or by other agents (Harness, Claude Code, Codex, Pi, OpenCode, Grok Bot) for this agent or for anyone. Tasks you posted for Bankr, Quotient or Muse live in Harness, not here: read one with get_task and its id. A task for a coding agent with a folder may run on its own (the user's standing permission or a goal); otherwise claim a task before working on it.",
16
+ description: "List open work on this machine's Harness task board: tasks posted by the user or by other agents (Harness, Claude Code, Codex, Pi, OpenCode, Grok Bot) for this agent or for anyone. Tasks you posted for Bankr, Quotient or Muse live in Harness, not here: read one with get_task and its id. A task for a coding agent with a folder may run on its own (the user's standing permission or a goal); otherwise claim a task before working on it. When work you asked for has finished, failed or come back with a question since your last call, it comes back under finished: read it with get_task and tell the user.",
17
17
  inputSchema: {
18
18
  type: "object",
19
19
  properties: { status: { type: "string", enum: ["open", "claimed", "needs_input", "done", "failed"] }, all: { type: "boolean", description: "include every task on the board, finished ones too" } },
@@ -33,7 +33,7 @@ const LOCAL_TOOLS = [
33
33
  properties: {
34
34
  objective: { type: "string", description: "one line, in the user's words" },
35
35
  brief: { type: "string" },
36
- target_agent: { type: "string", description: "claude-code, codex, pi, opencode, grok-bot or harness on this machine; bankr, quotient or muse in Harness; omit for any agent" },
36
+ target_agent: { type: "string", description: "claude-code, codex, pi, opencode, grok-bot, openclaw, hermes or harness on this machine; bankr, quotient or muse in Harness; omit for any agent" },
37
37
  target_agents: { type: "array", items: { type: "string" }, description: "several agents at once: one task with a part for each, closed with every result when the last part finishes" },
38
38
  parent_id: { type: "string", description: "the board task this one continues" },
39
39
  run_workspace: { type: "string", description: "the absolute directory a coding agent runs this task in, non-interactively, once the user authorizes it; the run's result and session come back on the task. Left out, a task for a coding agent runs in the folder you are working in" },
@@ -91,8 +91,8 @@ const LOCAL_TOOLS = [
91
91
  },
92
92
  {
93
93
  name: "post_to_room",
94
- description: "Post in a goal's room for the whole team: a finding, partial work, a question or what you did. Mention @agent to bring that agent in and wake it: @claude-code, @codex, @pi, @opencode, @grok-bot, @bankr, @quotient, @muse, or @harness to have the thread coordinated. To ask another agent for help, name it, say exactly what you need, then stop: you are woken again when someone answers you by name. Post results and sources, not secrets: anything that looks like a key is held. For work someone else should do, post_task with parent_id set to the goal id.",
95
- inputSchema: { type: "object", properties: { id: { type: "string", description: "the goal id" }, text: { type: "string" }, share_with_guests: { type: "boolean", description: "also send it to the goal's invited guests (other people's agents); only what is fine for them to see" } }, required: ["id", "text"], additionalProperties: false },
94
+ description: "Post in a goal's room for the whole team: a finding, partial work, a question or what you did. To ask another agent for help, put its name in ask (claude-code, codex, pi, opencode, grok-bot, openclaw, hermes, bankr, quotient, muse, or harness to have the thread coordinated), say exactly what you need, then stop: you are woken again when it answers you by name. When you answer someone who asked you, name them (@codex) and they are woken. Any other @name, like a thanks, wakes no one. Post results and sources, not secrets: anything that looks like a key is held. For work someone else should do, post_task with parent_id set to the goal id.",
95
+ inputSchema: { type: "object", properties: { id: { type: "string", description: "the goal id" }, text: { type: "string" }, ask: { type: "array", items: { type: "string" }, description: "the agents you are asking for something; each is woken" }, share_with_guests: { type: "boolean", description: "also send it to the goal's invited guests (other people's agents); only what is fine for them to see" } }, required: ["id", "text"], additionalProperties: false },
96
96
  },
97
97
  {
98
98
  name: "search_room",
@@ -103,11 +103,29 @@ const LOCAL_TOOLS = [
103
103
  description: "Search the shared memory of the user's agents: everything any agent wrote in any thread (findings, answers, questions, results) and the user's coding sessions on this computer. Use it before starting work to build on what another agent already did, and when you need to know how something was done before.",
104
104
  inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false },
105
105
  },
106
+ {
107
+ name: "list_agents",
108
+ description: "The user's other agents you can ask, what each is for, and how each answers (some start at once, some are woken, some only pick work up later). Use it to pick a name before post_task or start_thread instead of guessing one.",
109
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
110
+ },
111
+ {
112
+ name: "start_thread",
113
+ description: "Start a shared thread with one or more of the user's agents, named with @ in the message (for example \"@codex @grok-bot compare the two parsers\"). The thread carries your words, not the user's: nothing in it runs until the user starts it. Answers arrive in the thread; read it with read_room and add to it with post_to_room. Give a folder only when the work concerns one.",
114
+ inputSchema: { type: "object", properties: { text: { type: "string" }, folder: { type: "string", description: "absolute folder the coding agents work in; never guessed" } }, required: ["text"], additionalProperties: false },
115
+ },
116
+ {
117
+ name: "stop_thread",
118
+ description: "Stop the runs in a thread you started. The user can resume it.",
119
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
120
+ },
106
121
  ];
107
122
 
108
123
  const BOARD_CALLS = {
124
+ list_agents: () => ({ command: "board.swarm.agents" }),
125
+ start_thread: (args) => ({ command: "board.swarm.start", text: args.text, folder: args.folder }),
126
+ stop_thread: (args) => ({ command: "board.swarm.stop", id: String(args.id || "") }),
109
127
  read_room: (args) => ({ command: "board.room.read", id: String(args.id || ""), since: args.since }),
110
- post_to_room: (args) => ({ command: "board.room.post", id: String(args.id || ""), text: args.text, shareWithGuests: args.share_with_guests === true }),
128
+ post_to_room: (args) => ({ command: "board.room.post", id: String(args.id || ""), text: args.text, ask: Array.isArray(args.ask) ? args.ask : [], shareWithGuests: args.share_with_guests === true }),
111
129
  search_room: (args) => ({ command: "board.room.search", id: String(args.id || ""), query: args.query }),
112
130
  search_swarm: (args) => ({ command: "board.swarm.search", query: args.query }),
113
131
  search_task_context: (args) => ({ command: "board.context.search", id: String(args.id || ""), query: args.query, limit: args.limit }),
@@ -117,7 +135,7 @@ const BOARD_CALLS = {
117
135
  list_tasks: (args) => ({ command: "board.list", status: args.status, all: args.all === true }),
118
136
  get_task: (args) => ({ command: "board.get", id: String(args.id || "") }),
119
137
  post_task: (args) => ({ command: "board.post", objective: args.objective, brief: args.brief, targetAgent: args.target_agent, targetAgents: args.target_agents, parentId: args.parent_id, contextRefs: args.context_refs, runWorkspace: args.run_workspace, inSession: args.in_session === true, posterCwd: process.cwd(), posterPid: process.ppid }),
120
- claim_task: (args) => ({ command: "board.claim", id: String(args.id || ""), expectedVersion: args.expected_version, leaseMinutes: args.lease_minutes }),
138
+ claim_task: (args) => ({ command: "board.claim", id: String(args.id || ""), expectedVersion: args.expected_version, leaseMinutes: args.lease_minutes, claimerPid: process.ppid }),
121
139
  report_task: (args) => args.status === "answered"
122
140
  ? { command: "board.answer", id: String(args.id || ""), answer: args.result, expectedVersion: args.expected_version }
123
141
  : args.status === "released"
@@ -0,0 +1,31 @@
1
+ {
2
+ "version": 1,
3
+ "members": [
4
+ { "id": "harness", "name": "Harness", "aliases": [], "road": "server", "home": "cloud", "runs": "server", "target": true, "wake": "run",
5
+ "about": "coordinates the thread and asks the person when only they can decide" },
6
+ { "id": "claude-code", "name": "Claude Code", "aliases": ["claude code"], "road": "local", "home": "machine", "runs": "machine", "target": true, "wake": "run",
7
+ "about": "reads and changes code in the thread's folder, runs commands and tests" },
8
+ { "id": "codex", "name": "Codex", "aliases": [], "road": "local", "home": "machine", "runs": "machine", "target": true, "wake": "run",
9
+ "about": "reads and changes code in the thread's folder, runs commands and tests" },
10
+ { "id": "pi", "name": "Pi", "aliases": [], "road": "local", "home": "machine", "runs": "machine", "target": true, "wake": "run",
11
+ "about": "reads and changes code in the thread's folder and runs commands" },
12
+ { "id": "opencode", "name": "OpenCode", "aliases": ["open code"], "road": "local", "home": "machine", "runs": "machine", "target": true, "wake": "run",
13
+ "about": "reads and changes code in the thread's folder and runs commands" },
14
+ { "id": "grok-bot", "name": "Grok Bot", "aliases": ["grok", "grok bot"], "road": "hosted", "home": "machine", "runs": "self", "target": true, "wake": "email",
15
+ "about": "research on X and the web" },
16
+ { "id": "bankr", "name": "Bankr", "aliases": [], "road": "server", "home": "cloud", "runs": "server", "target": true, "wake": "run",
17
+ "about": "the person's crypto wallet and trading, always under the person's own approval and limits" },
18
+ { "id": "quotient", "name": "Quotient", "aliases": [], "road": "server", "home": "cloud", "runs": "server", "target": true, "wake": "run",
19
+ "about": "prediction markets research and positions, under the person's own approval and limits" },
20
+ { "id": "muse", "name": "Muse", "aliases": [], "road": "hosted", "home": "cloud", "runs": "self", "target": true, "wake": "schedule",
21
+ "about": "writing and creative drafts" },
22
+ { "id": "openclaw", "name": "OpenClaw", "aliases": ["open claw"], "road": "local", "home": "machine", "runs": "self", "target": true, "wake": "local-webhook",
23
+ "about": "the person's own assistant on this computer, with their chat apps and tools" },
24
+ { "id": "hermes", "name": "Hermes", "aliases": [], "road": "local", "home": "machine", "runs": "self", "target": true, "wake": "local-webhook",
25
+ "about": "the person's own assistant on this computer, with their chat apps and tools" },
26
+ { "id": "claude-app", "name": "Claude app", "aliases": ["claude ai"], "road": "hosted", "home": "cloud", "runs": "self", "target": false, "wake": "none",
27
+ "about": "the Claude chat app; can ask, cannot be woken" },
28
+ { "id": "chatgpt", "name": "ChatGPT", "aliases": ["chat gpt"], "road": "hosted", "home": "cloud", "runs": "self", "target": false, "wake": "none",
29
+ "about": "the ChatGPT app; can ask, cannot be woken" }
30
+ ]
31
+ }
@@ -0,0 +1,47 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ // The swarm's members, one entry each (members.json): who they are, which road they call in on, where their tasks
4
+ // live (home), who runs their work (runs), whether others may hand them work (target) and how they are told work is
5
+ // waiting (wake). The server reads the same file (src/shared/members.ts). A caller on this machine names itself
6
+ // (--agent) and is taken at its word: anything running as the person could read a token kept for it, so none is
7
+ // kept (owner, October 10). A call through Harness is never taken for a member on this machine (hostedCaller).
8
+ const ROSTER = JSON.parse(readFileSync(new URL("./members.json", import.meta.url), "utf8"));
9
+
10
+ export const ROSTER_VERSION = ROSTER.version;
11
+ export const MEMBERS = ROSTER.members;
12
+ const byId = new Map(MEMBERS.map((entry) => [entry.id, entry]));
13
+
14
+ export const member = (id) => byId.get(String(id)) ?? null;
15
+ export const ids = (keep = () => true) => MEMBERS.filter(keep).map((entry) => entry.id);
16
+ /** Members others may hand work to, Harness included. */
17
+ export const TARGETS = ids((entry) => entry.target);
18
+ /** Coding agents the daemon runs on this machine. */
19
+ export const MACHINE_RUNNERS = ids((entry) => entry.runs === "machine");
20
+ /** Targets whose tasks live on Harness's server, not the machine (Harness itself excluded). */
21
+ export const CLOUD_HOMED = ids((entry) => entry.target && entry.home === "cloud" && entry.id !== "harness");
22
+ /** Targets asked by a task rather than started here: they run themselves or on the server. */
23
+ export const ASKED_BY_TASK = ids((entry) => entry.target && entry.runs !== "machine" && entry.id !== "harness");
24
+ export const nameOf = (id) => member(id)?.name ?? String(id);
25
+ export const aboutOf = (id) => {
26
+ const entry = member(id);
27
+ return entry ? `${entry.name}: ${entry.about}` : String(id);
28
+ };
29
+
30
+ const plain = (value) => String(value || "").trim().toLowerCase().replace(/^@/, "").replace(/[\s_]+/g, " ");
31
+ /**
32
+ * A name as people and agents write it, resolved to one member: an id, a display name or an alias, else the one
33
+ * member whose name it starts. A start shared by several ("claude": Claude Code and the Claude app) comes back
34
+ * ambiguous with the candidates, so the caller asks its person or picks. `extra` admits board names the roster does
35
+ * not list (a connector named by its person), matched exactly.
36
+ */
37
+ export function resolveMember(name, { extra = [] } = {}) {
38
+ const wanted = plain(name);
39
+ if (!wanted) return { ok: false, candidates: [] };
40
+ const exact = MEMBERS.find((entry) => entry.id === wanted || plain(entry.name) === wanted || entry.aliases.some((alias) => plain(alias) === wanted));
41
+ if (exact) return { ok: true, id: exact.id };
42
+ if (extra.map(String).includes(wanted)) return { ok: true, id: wanted };
43
+ const words = wanted.split(" ");
44
+ const candidates = MEMBERS.filter((entry) => [entry.id.replace(/-/g, " "), plain(entry.name), ...entry.aliases.map(plain)]
45
+ .some((label) => words.every((word, index) => label.split(" ")[index]?.startsWith(word)))).map((entry) => entry.id);
46
+ return candidates.length === 1 ? { ok: true, id: candidates[0] } : { ok: false, candidates };
47
+ }
package/lib/nudge.mjs CHANGED
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { daemonRequest } from "./control-client.mjs";
6
6
  import { paths } from "./paths.mjs";
7
+ import { placeEnv } from "./places.mjs";
7
8
 
8
9
  // The session-start nudge: what an interactive agent is told when a session opens, so work
9
10
  // waiting on the board is not silent until someone asks. Claude Code runs this as a
@@ -68,16 +69,65 @@ export async function boardNudgeIfChanged(agent, homeDir = paths.home) {
68
69
  // its grandparent when the hook runs through a shell.
69
70
  export const PS = ["/bin/ps", "/usr/bin/ps"].find((file) => fs.existsSync(file)) || "ps";
70
71
 
71
- export async function notePrompt(agent) {
72
+ function agentPids() {
72
73
  const pids = [process.ppid];
73
74
  try { const up = Number(spawnSync(PS, ["-o", "ppid=", "-p", String(process.ppid)], { encoding: "utf8" }).stdout.trim()); if (up > 1) pids.push(up); } catch {}
74
- try { await daemonRequest({ command: "board.prompted", agent, pids }, 1_500); } catch {}
75
+ return pids;
76
+ }
77
+
78
+ // Where the session runs, as its own environment says (the agent's, which the hook shares): places.mjs reads it.
79
+ const placeOf = (input) => ({ sessionId: input?.session_id, folder: input?.cwd || process.cwd(), env: placeEnv(process.env) });
80
+
81
+ export async function notePrompt(agent, input = null) {
82
+ try { await daemonRequest({ command: "board.prompted", agent, pids: agentPids(), ...(input?.session_id ? placeOf(input) : {}) }, 1_500); } catch {}
83
+ }
84
+
85
+ /** A session starting says where it runs. */
86
+ export async function notePlace(agent, input) {
87
+ if (!input?.session_id) return;
88
+ try { await daemonRequest({ command: "board.live.place", agent, pids: agentPids(), ...placeOf(input) }, 1_500); } catch {}
89
+ }
90
+
91
+ // What the person sent this session from Harness (daemon board.live.say), taken once.
92
+ export async function takeMessages(agent, sessionId) {
93
+ if (!sessionId) return [];
94
+ try {
95
+ const response = await daemonRequest({ command: "board.live.take", agent, sessionId }, 1_500);
96
+ return response?.ok ? response.messages || [] : [];
97
+ } catch {
98
+ return [];
99
+ }
100
+ }
101
+
102
+ export function messagesText(messages) {
103
+ return messages.map((message) => `The person sent you this from Harness:\n\n${message.text}`).join("\n\n");
104
+ }
105
+
106
+ // The JSON the agent hands every hook on stdin (session_id among it); null when there is none.
107
+ async function hookInput() {
108
+ if (process.stdin.isTTY) return null;
109
+ let text = "";
110
+ process.stdin.setEncoding("utf8");
111
+ const read = new Promise((resolve) => { process.stdin.on("data", (chunk) => { text += chunk; }); process.stdin.on("end", resolve); process.stdin.on("error", resolve); });
112
+ await Promise.race([read, new Promise((resolve) => setTimeout(resolve, 1_000).unref())]);
113
+ process.stdin.destroy();
114
+ try { return JSON.parse(text); } catch { return null; }
75
115
  }
76
116
 
77
117
  if (import.meta.url === `file://${process.argv[1]}`) {
78
118
  const index = process.argv.indexOf("--agent");
79
119
  const agent = index >= 0 ? process.argv[index + 1] : "claude-code";
80
- if (process.argv.includes("--if-changed")) await notePrompt(agent);
81
- const text = process.argv.includes("--if-changed") ? await boardNudgeIfChanged(agent) : await boardNudge(agent);
82
- if (text) process.stdout.write(text + "\n");
120
+ const input = await hookInput();
121
+ const sessionId = input?.session_id;
122
+ if (process.argv.includes("--stop")) {
123
+ // The Stop hook: a message waiting for this session keeps the agent going with it.
124
+ const messages = await takeMessages(agent, sessionId);
125
+ if (messages.length) process.stdout.write(JSON.stringify({ decision: "block", reason: messagesText(messages) }) + "\n");
126
+ } else {
127
+ if (process.argv.includes("--if-changed")) await notePrompt(agent, input);
128
+ else await notePlace(agent, input);
129
+ const messages = process.argv.includes("--if-changed") ? await takeMessages(agent, sessionId) : [];
130
+ const text = [messages.length ? messagesText(messages) : "", process.argv.includes("--if-changed") ? await boardNudgeIfChanged(agent) : await boardNudge(agent)].filter(Boolean).join("\n\n");
131
+ if (text) process.stdout.write(text + "\n");
132
+ }
83
133
  }
@@ -0,0 +1,131 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import readline from "node:readline";
3
+ import { detectLocalAgents, setupHermes, setupOpenClaw, localWakes } from "./wakes.mjs";
4
+
5
+ // "Your swarm" in setup: every agent class with how Harness reaches it, the recommended way already chosen, the steps
6
+ // numbered, and a real test at the end that passes only when the agent claims and reports a task. Also reachable on its
7
+ // own as `npx tryharness agents`. Without a terminal to answer in, it only says what is set up and how to finish.
8
+
9
+ const SETUP = { openclaw: setupOpenClaw, hermes: setupHermes };
10
+ const LABEL = { openclaw: "OpenClaw", hermes: "Hermes", "grok-bot": "Grok Bot" };
11
+ const MEASURED = "measured on a real Grok: about 23s by email, 17s by webhook";
12
+
13
+ export function createPrompter({ input = process.stdin, output = process.stdout } = {}) {
14
+ const ask = (question, { hidden = false } = {}) => new Promise((resolve) => {
15
+ const rl = readline.createInterface({ input, output, terminal: true });
16
+ if (hidden) rl._writeToOutput = (text) => { if (text.includes(question)) output.write(text); };
17
+ rl.question(question, (answer) => { rl.close(); if (hidden) output.write("\n"); resolve(answer.trim()); });
18
+ });
19
+ return { ask };
20
+ }
21
+
22
+ const copy = (text) => {
23
+ const tool = process.platform === "darwin" ? "pbcopy" : process.platform === "win32" ? "clip" : "xclip";
24
+ const result = spawnSync(tool, tool === "xclip" ? ["-selection", "clipboard"] : [], { input: text });
25
+ return result.status === 0;
26
+ };
27
+
28
+ /** Post a setup check for the agent and wait for its report: the same path real work takes. */
29
+ export async function testAgent({ agent, daemonRequest, out = console.log, waitMs = 10 * 60_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
30
+ const posted = await daemonRequest({ command: "board.post", agent: "user", objective: "Setup check from Harness: reply ready", brief: "Harness is checking that it can wake you. Claim this task and report it done with the single word: ready. Do nothing else.", targetAgent: agent });
31
+ if (!posted?.ok) { out(` ! the test task could not be posted: ${posted?.error || "no answer"}`); return false; }
32
+ const started = Date.now();
33
+ out(` Test sent. Waiting for ${LABEL[agent] || agent} to answer (Ctrl-C stops waiting; the test task stays on your board).`);
34
+ while (Date.now() - started < waitMs) {
35
+ await sleep(2_000);
36
+ const task = (await daemonRequest({ command: "board.get", agent: "user", id: posted.task.id }).catch(() => null))?.task;
37
+ if (task?.status === "done") { out(` ✓ ${LABEL[agent] || agent} answered in ${Math.round((Date.now() - started) / 1000)}s. Ready.`); return true; }
38
+ if (task && ["failed", "needs_input"].includes(task.status)) { out(` ! ${LABEL[agent] || agent} answered but the task is ${task.status}: ${task.result || ""}`); return false; }
39
+ }
40
+ out(` ! No answer after ${Math.round(waitMs / 60_000)} minutes. Check that the agent is running, then: npx tryharness agents test ${agent}`);
41
+ return false;
42
+ }
43
+
44
+ async function setupLocal({ entry, runtimeRoot, prompter, daemonRequest, out }) {
45
+ const name = LABEL[entry.agent];
46
+ if (localWakes()[entry.agent]) {
47
+ const health = (await daemonRequest({ command: "wake.local" }).catch(() => null))?.agents?.[entry.agent];
48
+ if (!health || health.answering) { out(` ✓ ${name}: woken on this computer by its own webhook`); return; }
49
+ out(` ! ${name}: not answering wakes (${health.lastFailure}). Check that it is running.`);
50
+ if (!prompter || /^n/i.test(await prompter.ask(` Set ${name} up again? [Y/n] `))) { out(` Later: npx tryharness agents setup ${entry.agent}`); return; }
51
+ const result = SETUP[entry.agent]({ runtimeRoot });
52
+ out(` ${result.ok ? "✓" : "!"} ${result.detail}`);
53
+ if (result.ok) await testAgent({ agent: entry.agent, daemonRequest, out });
54
+ return;
55
+ }
56
+ if (!prompter) { out(` ! ${name}: found. Set it up with: npx tryharness agents setup ${entry.agent}`); return; }
57
+ out(` ${name} found. Harness wakes it with a webhook on this computer (recommended, nothing to copy).`);
58
+ out(` This adds Harness to ${name}'s MCP servers and turns on its webhook with a key made here${entry.agent === "openclaw" ? ", then restarts its gateway" : ""}.`);
59
+ if (/^n/i.test(await prompter.ask(` Set up ${name} now? [Y/n] `))) { out(` Skipped. Later: npx tryharness agents setup ${entry.agent}`); return; }
60
+ const result = SETUP[entry.agent]({ runtimeRoot });
61
+ out(` ${result.ok ? "✓" : "!"} ${result.detail}`);
62
+ if (result.ok) await testAgent({ agent: entry.agent, daemonRequest, out });
63
+ }
64
+
65
+ async function setupGrok({ prompter, daemonRequest, out, clipboard }) {
66
+ const listed = await daemonRequest({ command: "wake.server", input: { action: "list" } }).catch((error) => ({ ok: false, error: error.message }));
67
+ if (!listed.ok) { out(` ! Grok Bot: Harness could not be reached (${listed.error}). Later: npx tryharness agents setup grok-bot`); return; }
68
+ const current = (listed.wakes || []).find((wake) => wake.agent === "grok-bot");
69
+ if (current) {
70
+ const how = current.channel === "email" ? `email at ${current.address}` : "its webhook";
71
+ if (current.answering) { out(` ✓ Grok Bot: woken by ${how}`); return; }
72
+ out(` ! Grok Bot: not answering wakes by ${how} (${current.last_failure}).`);
73
+ if (!prompter || /^n/i.test(await prompter.ask(" Set it up again? [Y/n] "))) { out(" Later: npx tryharness agents setup grok-bot"); return; }
74
+ }
75
+ if (!prompter) { out(" Grok Bot: not set up. Set it up with: npx tryharness agents setup grok-bot"); return; }
76
+ out(` Grok Bot. How should Harness wake it? (${MEASURED})`);
77
+ out(" 1) Email nothing to copy back (recommended)");
78
+ out(" 2) Webhook a little faster; you paste its URL and key here");
79
+ out(" 3) Not now");
80
+ const choice = (await prompter.ask(" Choose [1]: ")) || "1";
81
+ if (choice === "3") { out(" Skipped. Later: npx tryharness agents setup grok-bot"); return; }
82
+ const channel = choice === "2" ? "webhook" : "email";
83
+ const message = listed.messages?.["grok-bot"]?.[channel];
84
+ if (!message) { out(" ! Harness did not send the setup message; update tryharness and try again."); return; }
85
+ const copied = clipboard(message);
86
+ out(` 1. ${copied ? "This message is on your clipboard. Paste it into Grok:" : "Paste this message into Grok:"}`);
87
+ out(message.split("\n").map((line) => ` ${line}`).join("\n"));
88
+ out(" 2. Grok opens Harness's approval page. Name the connection Grok Bot and approve.");
89
+ if (channel === "webhook") {
90
+ out(" 3. In Grok, open Routines, then Harness wake, and copy its webhook URL and key.");
91
+ const address = await prompter.ask(" Webhook URL: ");
92
+ const key = await prompter.ask(" Key (hidden): ", { hidden: true });
93
+ const saved = await daemonRequest({ command: "wake.server", input: { action: "save", agent: "grok-bot", channel: "webhook", address, key } });
94
+ if (!saved.ok) { out(` ! ${saved.error}. Try again: npx tryharness agents setup grok-bot`); return; }
95
+ out(" ✓ Saved. The key is stored sealed on Harness and is never shown again.");
96
+ } else {
97
+ out(" 3. Grok registers its own address with Harness. Waiting for it (Ctrl-C stops waiting)...");
98
+ const registered = await waitFor(async () => ((await daemonRequest({ command: "wake.server", input: { action: "list" } }).catch(() => null))?.wakes || []).some((wake) => wake.agent === "grok-bot"));
99
+ if (!registered) { out(" Not registered yet. When Grok has done it: npx tryharness agents test grok-bot"); return; }
100
+ out(" ✓ Grok registered its address.");
101
+ }
102
+ await testAgent({ agent: "grok-bot", daemonRequest, out });
103
+ }
104
+
105
+ async function waitFor(check, waitMs = 10 * 60_000, everyMs = 3_000) {
106
+ const end = Date.now() + waitMs;
107
+ while (Date.now() < end) {
108
+ if (await check()) return true;
109
+ await new Promise((r) => setTimeout(r, everyMs));
110
+ }
111
+ return false;
112
+ }
113
+
114
+ /** The whole "Your swarm" step. `only` limits it to one agent (npx tryharness agents setup <agent>). */
115
+ export async function setupSwarm({ runtimeRoot, states = [], daemonRequest, prompter = process.stdin.isTTY ? createPrompter() : null, out = console.log, only = null, clipboard = copy }) {
116
+ const want = (agent) => !only || only === agent;
117
+ out("\nYour swarm");
118
+ if (!only) {
119
+ out(" On this computer: Harness starts them when work is posted");
120
+ for (const state of states) out(` ${!state.installed ? "–" : state.configured ? "✓" : "!"} ${state.agent}: ${state.detail}`);
121
+ }
122
+ const local = detectLocalAgents().filter((entry) => entry.found && want(entry.agent));
123
+ if (local.length) out(" Agent apps on this computer: woken by a webhook Harness sets up");
124
+ for (const entry of local) await setupLocal({ entry, runtimeRoot, prompter, daemonRequest, out });
125
+ if (only && LABEL[only] && only !== "grok-bot" && !local.length) out(` ${LABEL[only]} was not found on this computer.`);
126
+ if (want("grok-bot")) {
127
+ out(" Cloud agents: woken by email or webhook");
128
+ await setupGrok({ prompter, daemonRequest, out, clipboard });
129
+ }
130
+ if (!only) out(" Built in: Bankr and Quotient run on Harness, nothing to set up");
131
+ }
package/lib/phones.mjs CHANGED
@@ -13,17 +13,17 @@ const OFFER_TTL_MS = 10 * 60_000;
13
13
  const MAX_OFFERS = 5;
14
14
  // A message queued at Harness while this computer slept may arrive up to 7 days late; only decisions and posts may.
15
15
  const QUEUED_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
16
- const QUEUEABLE = new Set(["board.post", "board.run", "board.release", "board.answer", "board.thread.post", "board.thread.send", "board.thread.discard", "board.thread.end", "board.goal.stop",
17
- "board.swarm.start", "board.swarm.post", "board.swarm.stop", "board.swarm.stop_all", "board.swarm.resume"]);
16
+ const QUEUEABLE = new Set(["board.post", "board.run", "board.release", "board.answer", "board.remove", "board.thread.post", "board.thread.send", "board.thread.discard", "board.thread.end", "board.goal.stop",
17
+ "board.swarm.start", "board.swarm.post", "board.swarm.stop", "board.swarm.stop_all", "board.swarm.resume", "board.swarm.start_run"]);
18
18
  const ID = /^[A-Za-z0-9_-]{8,64}$/;
19
19
  const KEY = /^[A-Za-z0-9_-]{80,100}$/;
20
20
  const KINDS = new Set(["phone", "browser"]);
21
21
  const PHONE_COMMANDS = new Set([
22
- "board.list", "board.get", "board.children", "board.agents", "board.post", "board.run", "board.release", "board.answer",
22
+ "board.list", "board.get", "board.children", "board.agents", "board.post", "board.run", "board.release", "board.answer", "board.remove",
23
23
  "board.thread.read", "board.thread.post", "board.thread.send", "board.thread.discard", "board.thread.auto", "board.thread.end",
24
24
  "board.goal.list", "board.goal.get", "board.goal.stop", "board.room.read",
25
25
  "board.swarm.list", "board.swarm.read", "board.swarm.start", "board.swarm.post", "board.swarm.stop", "board.swarm.stop_all", "board.swarm.resume", "board.swarm.folder", "board.swarm.coordinator",
26
- "board.swarm.diff", "board.swarm.merge", "board.swarm.discard",
26
+ "board.swarm.diff", "board.swarm.merge", "board.swarm.discard", "board.swarm.start_run",
27
27
  ]);
28
28
  // What the chat owner does on the board, in Harness's name (was src/server/mcp/board.ts HARNESS_COMMANDS).
29
29
  const HARNESS_COMMANDS = new Set([
@@ -34,7 +34,7 @@ const HARNESS_COMMANDS = new Set([
34
34
  const BROWSER_PERSON_COMMANDS = new Set([...PHONE_COMMANDS, "board.goal.resume", "board.standing", "board.standing.check", "board.standing.off"]);
35
35
  const BROWSER_READS = new Set(["sessions.search", "sessions.read", "sessions.raw", "tasks.status", "relay.request", "relay.open"]);
36
36
 
37
- export async function createPhoneDesk({ store, identityFile, boardCommand, reads = {}, version }) {
37
+ export async function createPhoneDesk({ store, identityFile, boardCommand, reads = {}, version, onMessage = () => {}, machine = () => null }) {
38
38
  const identity = await loadOrCreateIdentity(identityFile);
39
39
  const offers = new Map();
40
40
  const keys = new Map();
@@ -96,6 +96,8 @@ export async function createPhoneDesk({ store, identityFile, boardCommand, reads
96
96
  if (queued ? age > QUEUED_MAX_AGE_MS || age < -MAX_SKEW_MS : Math.abs(age) > MAX_SKEW_MS) return { ok: false, error: "the message is too old or the device's clock is off", code: "stale" };
97
97
  if (!store.firstPhoneRequest(device.id, String(envelope.requestId))) return { ok: false, error: "this message was already received", code: "replayed" };
98
98
  store.touchPhone(device.id);
99
+ // Any message from a paired device keeps a Mac in Standby awake for the next hour (Lid-Closed Mode).
100
+ onMessage();
99
101
  if (request.call?.command === "phone.forget") {
100
102
  const forgotten = revoke(device.id);
101
103
  return { ok: true, envelope: await seal(key, "d2p", device.id, envelope.requestId, { ok: forgotten.ok }) };
@@ -131,6 +133,8 @@ export async function createPhoneDesk({ store, identityFile, boardCommand, reads
131
133
  const { as: _as, writtenBy: _written, provenance: _provenance, ...rest } = callInput;
132
134
  return settled(() => boardCommand({ ...rest, command, agent: as, ...(writtenBy.length ? { provenance: { trust: "agent", chain: writtenBy } } : {}) }));
133
135
  }
136
+ // Lid-Closed Mode's wake: no arguments, never queued, and it can only keep the Mac awake and say how it is.
137
+ if (command === "machine.wake") return { ok: true, machine: machine() };
134
138
  if (!PHONE_COMMANDS.has(command)) return { ok: false, error: `a phone cannot call ${command || "that"}` };
135
139
  // The phone is the person; whatever it names itself, it acts as "user".
136
140
  return settled(() => boardCommand({ ...callInput, command, agent: "user", ...(command === "board.list" && callInput.all === undefined ? { all: true } : {}) }));