tryharness 0.7.4 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/runner.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import { fileURLToPath } from "node:url";
2
3
  import path from "node:path";
3
4
  import { spawn } from "node:child_process";
4
5
 
@@ -13,6 +14,11 @@ export function stopAgentTask(runId) {
13
14
  return true;
14
15
  }
15
16
 
17
+ /** The process ids of the runs in progress, so they are not mistaken for the person's own sessions. */
18
+ export function runningPids() {
19
+ return [...children.values()].map((child) => child.pid).filter(Boolean);
20
+ }
21
+
16
22
  export function resolveWorkingDirectory(requested) {
17
23
  if (!requested || !path.isAbsolute(requested)) throw new Error("Delegated tasks require an absolute working directory.");
18
24
  let stat;
@@ -22,17 +28,33 @@ export function resolveWorkingDirectory(requested) {
22
28
  return requested;
23
29
  }
24
30
 
31
+ // A run another agent started without the person's tap (docs/member-runs.md): anything that would ask is refused, file
32
+ // edits only in its worktree, shell commands only inside the operating system's sandbox with no network, and no hooks or
33
+ // settings from the repository. A review gets no edits at all.
34
+ export const RESTRICTED_CLAUDE_SETTINGS = {
35
+ disableAllHooks: true,
36
+ permissions: { deny: ["WebFetch", "WebSearch"] },
37
+ sandbox: { enabled: true, autoAllowBashIfSandboxed: true, allowUnsandboxedCommands: false, failIfUnavailable: true, excludedCommands: [], network: { allowedDomains: [], strictAllowlist: true } },
38
+ };
39
+ // Only the Harness board MCP, as Claude Code: none of the person's other MCP servers or claude.ai connectors load.
40
+ export const restrictedMcpConfig = () => ({ mcpServers: { harness: { command: process.execPath, args: [fileURLToPath(new URL("./mcp-stdio.mjs", import.meta.url)), "--agent", "claude-code"] } } });
41
+ const restrictedClaude = (task) => ["--permission-mode", "dontAsk", "--allowedTools", task.readOnly ? "mcp__harness" : "Edit Write mcp__harness",
42
+ "--setting-sources", "user", "--settings", JSON.stringify(RESTRICTED_CLAUDE_SETTINGS), "--strict-mcp-config", "--mcp-config", JSON.stringify(restrictedMcpConfig())];
43
+
25
44
  export function agentInvocation(task) {
26
45
  if (task.agent === "claude-code") {
27
46
  return {
28
47
  command: "claude",
29
- args: ["-p", task.brief, "--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions", "--name", `Harness: ${task.title}`, ...(task.resumeSession ? ["--resume", task.resumeSession] : [])],
48
+ // A review reads and reports: plan mode lets Claude Code read and run read-only tools but never edit.
49
+ args: ["-p", task.brief, "--output-format", "stream-json", "--verbose", ...(task.restricted ? restrictedClaude(task) : task.readOnly ? ["--permission-mode", "plan"] : ["--dangerously-skip-permissions"]), "--name", `Harness: ${task.title}`, ...(task.resumeSession ? ["--resume", task.resumeSession] : [])],
30
50
  };
31
51
  }
52
+ // Only Claude Code has a restricted mode; any other agent's restricted run is refused here, whatever let it start.
53
+ if (task.restricted) throw new Error(`${task.agent} has no restricted mode; it runs only after the person's Start`);
32
54
  if (task.agent === "codex") {
33
55
  return {
34
56
  command: "codex",
35
- args: ["exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "-"],
57
+ args: ["exec", ...(task.resumeSession ? ["resume", task.resumeSession] : []), "--json", ...(task.readOnly ? ["-c", 'sandbox_mode="read-only"', "-c", 'approval_policy="never"'] : ["--dangerously-bypass-approvals-and-sandbox"]), "--skip-git-repo-check", "-"],
36
58
  stdin: task.brief,
37
59
  };
38
60
  }
@@ -40,7 +62,7 @@ export function agentInvocation(task) {
40
62
  // this non-interactive run; its complete built-in tool set remains enabled.
41
63
  if (task.agent === "pi") return { command: "pi", args: ["--mode", "json", "--approve", "-p", task.brief] };
42
64
  if (task.agent === "opencode") {
43
- return { command: "opencode", args: ["run", "--format", "json", "--auto", "--title", task.title, "--", task.brief] };
65
+ return { command: "opencode", args: ["run", "--format", "json", "--auto", ...(task.resumeSession ? ["--session", task.resumeSession] : ["--title", task.title]), "--", task.brief] };
44
66
  }
45
67
  throw new Error(`Unsupported agent: ${task.agent}`);
46
68
  }
@@ -74,9 +96,57 @@ export function usageCollector() {
74
96
  };
75
97
  }
76
98
 
99
+ // What an agent did, as the steps of one event in its JSON stream: "say" for its words to the person, "do" for a tool
100
+ // it used. Claude Code, Codex, Pi and OpenCode each say this their own way; anything else is not a step.
101
+ const brief = (input) => {
102
+ if (!input || typeof input !== "object") return String(input ?? "").slice(0, 200);
103
+ const named = input.command ?? input.cmd ?? input.file_path ?? input.path ?? input.filePath ?? input.pattern ?? input.url ?? input.query ?? input.description;
104
+ return String(named ?? JSON.stringify(input)).slice(0, 200);
105
+ };
106
+ export function stepsOf(event) {
107
+ if (!event || typeof event !== "object") return [];
108
+ if (event.type === "assistant" && Array.isArray(event.message?.content)) {
109
+ return event.message.content.flatMap((block) => block.type === "text" && block.text?.trim() ? [{ kind: "say", text: block.text.trim() }]
110
+ : block.type === "tool_use" ? [{ kind: "do", text: `${block.name}: ${brief(block.input)}` }] : []);
111
+ }
112
+ if (event.type === "item.completed" && event.item) {
113
+ const item = event.item;
114
+ if (item.type === "agent_message" && item.text?.trim()) return [{ kind: "say", text: item.text.trim() }];
115
+ if (item.type === "command_execution") return [{ kind: "do", text: `ran: ${String(item.command ?? "").slice(0, 200)}` }];
116
+ if (item.type === "file_change") return [{ kind: "do", text: `edited: ${(item.changes ?? []).map((change) => change.path).join(", ").slice(0, 200)}` }];
117
+ if (item.type === "mcp_tool_call") return [{ kind: "do", text: `${item.server ?? "tool"}: ${item.tool ?? ""}` }];
118
+ if (item.type === "web_search") return [{ kind: "do", text: `searched: ${String(item.query ?? "").slice(0, 200)}` }];
119
+ return [];
120
+ }
121
+ if (event.type === "message_end" && event.message?.role === "assistant" && Array.isArray(event.message.content)) {
122
+ return event.message.content.flatMap((block) => block.type === "text" && block.text?.trim() ? [{ kind: "say", text: block.text.trim() }]
123
+ : block.type === "toolCall" ? [{ kind: "do", text: `${block.name}: ${brief(block.arguments)}` }] : []);
124
+ }
125
+ const part = event.part;
126
+ if (part?.type === "text" && part.text?.trim() && (part.time?.end || event.type === "text")) return [{ kind: "say", text: part.text.trim() }];
127
+ if (part?.type === "tool" && part.state?.status === "completed") return [{ kind: "do", text: `${part.tool}: ${brief(part.state.input)}` }];
128
+ return [];
129
+ }
130
+
131
+ /** The steps of a run from its log, oldest first; at most `limit`, the latest kept. */
132
+ export function readRunSteps(file, limit = 200) {
133
+ let raw = "";
134
+ try { raw = fs.readFileSync(file, "utf8"); } catch { return []; }
135
+ const steps = [];
136
+ for (const line of raw.split("\n")) {
137
+ if (!line.startsWith("{")) continue;
138
+ let event;
139
+ try { event = JSON.parse(line); } catch { continue; }
140
+ steps.push(...stepsOf(event));
141
+ }
142
+ return steps.slice(-limit);
143
+ }
144
+
77
145
  export function runAgentTask(task, emit, observe = () => {}) {
78
- if (running.has(task.agent)) {
79
- emit("failed", `A ${task.agent} task is already running on this machine.`);
146
+ // One run per folder: two agents in one checkout would trip over each other; a thread's worktree is its own folder.
147
+ const slot = task.cwd ? path.resolve(task.cwd) : task.agent;
148
+ if (running.has(slot)) {
149
+ emit("failed", `Another run is already working in ${task.cwd || "that folder"}.`);
80
150
  return;
81
151
  }
82
152
  let cwd;
@@ -92,13 +162,13 @@ export function runAgentTask(task, emit, observe = () => {}) {
92
162
  let child;
93
163
  try {
94
164
  child = spawn(invocation.command, invocation.args, { cwd });
95
- if (invocation.stdin !== undefined) child.stdin.end(invocation.stdin);
165
+ child.stdin.end(invocation.stdin);
96
166
  } catch (error) {
97
- running.delete(task.agent);
167
+ running.delete(slot);
98
168
  emit("failed", `Could not start ${task.agent}: ${error.message}`);
99
169
  return;
100
170
  }
101
- running.add(task.agent);
171
+ running.add(slot);
102
172
  children.set(task.runId, child);
103
173
 
104
174
  let resultText = "";
@@ -126,14 +196,7 @@ export function runAgentTask(task, emit, observe = () => {}) {
126
196
  if (event.type === "result" && typeof event.result === "string") resultText = event.result;
127
197
  if (event.type === "item.completed" && event.item?.type === "agent_message" && typeof event.item.text === "string") resultText = event.item.text;
128
198
  if (["text", "message.part.updated"].includes(event.type) && typeof event.part?.text === "string") resultText = event.part.text;
129
- const progress = event.type === "assistant" && Array.isArray(event.message?.content)
130
- ? event.message.content.filter((block) => block.type === "text").map((block) => block.text).join(" ")
131
- : event.type === "message_update" && typeof event.text === "string"
132
- ? event.text
133
- : event.type === "item.completed" && event.item?.type && event.item.type !== "agent_message"
134
- ? event.item.type + (event.item.command ? ": " + event.item.command : "")
135
- : typeof event.part?.text === "string" ? event.part.text : null;
136
- if (progress?.trim()) emit("progress", progress.trim().slice(0, 200));
199
+ for (const step of stepsOf(event)) emit("progress", step.text.slice(0, 200), { step });
137
200
  }
138
201
  });
139
202
  child.stderr?.on("data", (chunk) => {
@@ -142,13 +205,13 @@ export function runAgentTask(task, emit, observe = () => {}) {
142
205
  tail = (tail + "\n" + raw).slice(-4_000);
143
206
  });
144
207
  child.on("close", (code) => {
145
- running.delete(task.agent);
208
+ running.delete(slot);
146
209
  children.delete(task.runId);
147
210
  if (code === 0) emit("done", (resultText || "The run finished; check the repository for its changes.").slice(0, 20_000), { usage: usage.result() });
148
211
  else emit("failed", (`${task.agent} exited with code ${code}.\n${tail}`).slice(0, 4_000), { usage: usage.result() });
149
212
  });
150
213
  child.on("error", (error) => {
151
- running.delete(task.agent);
214
+ running.delete(slot);
152
215
  children.delete(task.runId);
153
216
  emit("failed", `Could not start ${task.agent}: ${error.message}`);
154
217
  });
package/lib/sealed.mjs ADDED
@@ -0,0 +1,103 @@
1
+ import fs from "node:fs";
2
+ import { timingSafeEqual, webcrypto } from "node:crypto";
3
+
4
+ // Harness sealed channel, version 1 (HSC1): the phone talks to this daemon through Harness, and
5
+ // Harness carries only ciphertext. The browser twin is src/shared/crypto/sealedChannel.ts; both
6
+ // must produce the same bytes, and smoke `phone-channel` drives one against the other.
7
+ //
8
+ // - The daemon has a static ECDH P-256 identity. Its public key reaches the phone only inside the
9
+ // pairing QR code, never through Harness, so Harness cannot stand in for the daemon.
10
+ // - Pairing: the QR also carries a one-time 32-byte secret. The phone proves it scanned the code
11
+ // with HMAC-SHA256(secret, offer, device key, daemon key); Harness never sees the secret, so it
12
+ // cannot pair a device of its own.
13
+ // - Every message: AES-256-GCM under HKDF-SHA256(ECDH(device, daemon)), a fresh 96-bit nonce,
14
+ // and the direction, device and request id bound as associated data. Requests carry a
15
+ // timestamp; the daemon refuses stale ones and any request id it has already seen.
16
+ // Not provided: forward secrecy (both keys are static). Revoking a device ends its key.
17
+
18
+ const subtle = webcrypto.subtle;
19
+ const encoder = new TextEncoder();
20
+ const decoder = new TextDecoder();
21
+ const CURVE = { name: "ECDH", namedCurve: "P-256" };
22
+ export const SEALED_VERSION = 1;
23
+ export const MAX_SKEW_MS = 5 * 60_000;
24
+
25
+ export const b64u = (bytes) => Buffer.from(bytes).toString("base64url");
26
+ export const fromB64u = (text) => new Uint8Array(Buffer.from(String(text), "base64url"));
27
+
28
+ export async function loadOrCreateIdentity(file) {
29
+ try {
30
+ const saved = JSON.parse(fs.readFileSync(file, "utf8"));
31
+ const privateKey = await subtle.importKey("jwk", saved.privateJwk, CURVE, false, ["deriveBits"]);
32
+ return { privateKey, publicKey: saved.publicKey };
33
+ } catch (error) {
34
+ if (error.code !== "ENOENT") throw new Error(`the phone identity at ${file} is unreadable: ${error.message}`);
35
+ }
36
+ const pair = await subtle.generateKey(CURVE, true, ["deriveBits"]);
37
+ const publicKey = b64u(new Uint8Array(await subtle.exportKey("raw", pair.publicKey)));
38
+ const privateJwk = await subtle.exportKey("jwk", pair.privateKey);
39
+ fs.writeFileSync(file, JSON.stringify({ version: SEALED_VERSION, publicKey, privateJwk }), { mode: 0o600, flag: "wx" });
40
+ return loadOrCreateIdentity(file);
41
+ }
42
+
43
+ export async function pairProof(secret, offerId, devicePublicKey, daemonPublicKey) {
44
+ const key = await subtle.importKey("raw", fromB64u(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
45
+ return b64u(new Uint8Array(await subtle.sign("HMAC", key, encoder.encode(`HSC1-pair\n${offerId}\n${devicePublicKey}\n${daemonPublicKey}`))));
46
+ }
47
+
48
+ export async function channelKey(privateKey, peerPublicKey, deviceId) {
49
+ const peer = await subtle.importKey("raw", fromB64u(peerPublicKey), CURVE, false, []);
50
+ const shared = await subtle.deriveBits({ name: "ECDH", public: peer }, privateKey, 256);
51
+ const material = await subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
52
+ return subtle.deriveKey(
53
+ { name: "HKDF", hash: "SHA-256", salt: encoder.encode("HSC1"), info: encoder.encode(`HSC1 channel ${deviceId}`) },
54
+ material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],
55
+ );
56
+ }
57
+
58
+ const aad = (direction, deviceId, requestId) => encoder.encode(`HSC1|${direction}|${deviceId}|${requestId}`);
59
+
60
+ export async function seal(key, direction, deviceId, requestId, value) {
61
+ const iv = webcrypto.getRandomValues(new Uint8Array(12));
62
+ const ct = await subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad(direction, deviceId, requestId) }, key, encoder.encode(JSON.stringify(value)));
63
+ return { v: SEALED_VERSION, deviceId, requestId, iv: b64u(iv), ct: b64u(new Uint8Array(ct)) };
64
+ }
65
+
66
+ // Throws on a wrong key, a wrong direction, device or request id, or any changed byte.
67
+ export async function open(key, direction, envelope) {
68
+ const plain = await subtle.decrypt(
69
+ { name: "AES-GCM", iv: fromB64u(envelope.iv), additionalData: aad(direction, envelope.deviceId, envelope.requestId) },
70
+ key, fromB64u(envelope.ct),
71
+ );
72
+ return JSON.parse(decoder.decode(plain));
73
+ }
74
+
75
+ export function constantTimeEqual(a, b) {
76
+ const left = Buffer.from(String(a)), right = Buffer.from(String(b));
77
+ return left.length === right.length && timingSafeEqual(left, right);
78
+ }
79
+
80
+ // Threads with another person's computer (HSC1 relay): the two daemons' static identities give
81
+ // one key per pair of machines; every request, message and ending is bound to its thread and id as
82
+ // associated data. A peer's key comes from Harness's directory the first time and is pinned by
83
+ // handle on this machine; a different key later is refused until the person trusts it.
84
+ export async function relayKey(privateKey, peerPublicKey) {
85
+ const peer = await subtle.importKey("raw", fromB64u(peerPublicKey), CURVE, false, []);
86
+ const shared = await subtle.deriveBits({ name: "ECDH", public: peer }, privateKey, 256);
87
+ const material = await subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
88
+ return subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: encoder.encode("HSC1"), info: encoder.encode("HSC1 relay") }, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
89
+ }
90
+ export async function sealBound(key, bound, value) {
91
+ const iv = webcrypto.getRandomValues(new Uint8Array(12));
92
+ const ct = await subtle.encrypt({ name: "AES-GCM", iv, additionalData: encoder.encode(`HSC1|${bound}`) }, key, encoder.encode(JSON.stringify(value)));
93
+ return { iv: b64u(iv), ct: b64u(new Uint8Array(ct)) };
94
+ }
95
+ export async function openBound(key, bound, sealed) {
96
+ const plain = await subtle.decrypt({ name: "AES-GCM", iv: fromB64u(sealed.iv), additionalData: encoder.encode(`HSC1|${bound}`) }, key, fromB64u(sealed.ct));
97
+ return JSON.parse(decoder.decode(plain));
98
+ }
99
+ // What two people read to each other to check they hold each other's key: SHA-256 of the key, 20 hex digits in groups of four.
100
+ export async function fingerprint(publicKey) {
101
+ const digest = Buffer.from(await subtle.digest("SHA-256", fromB64u(publicKey))).toString("hex");
102
+ return digest.slice(0, 20).match(/.{4}/g).join(" ");
103
+ }
package/lib/store.mjs CHANGED
@@ -3,7 +3,7 @@ import sqliteWasm from "node-sqlite3-wasm";
3
3
  import { parseSession } from "./parsers.mjs";
4
4
  import { provenanceFor } from "./provenance.mjs";
5
5
 
6
- const SCHEMA_VERSION = 6;
6
+ const SCHEMA_VERSION = 9;
7
7
  const INDEX_FORMAT_VERSION = 2;
8
8
  const { Database } = sqliteWasm;
9
9
 
@@ -78,6 +78,7 @@ export function openStore(file) {
78
78
  trust text not null default 'agent',
79
79
  origin_chain text not null default '[]',
80
80
  usage text,
81
+ removed_at text,
81
82
  created_at text not null,
82
83
  updated_at text not null
83
84
  );
@@ -145,6 +146,40 @@ export function openStore(file) {
145
146
  );
146
147
  create index if not exists board_contributions_task_idx on board_contributions(task_id, seq);
147
148
  create virtual table if not exists board_contributions_fts using fts5(id unindexed, task_id unindexed, text, tokenize='unicode61');
149
+ create table if not exists phone_requests (
150
+ device_id text not null,
151
+ request_id text not null,
152
+ received_at text not null,
153
+ primary key(device_id, request_id)
154
+ );
155
+ create table if not exists phone_devices (
156
+ id text primary key,
157
+ label text not null,
158
+ public_key text not null,
159
+ paired_at text not null,
160
+ last_seen_at text,
161
+ revoked_at text
162
+ );
163
+ create table if not exists relay_peers (
164
+ handle text primary key,
165
+ public_key text not null,
166
+ pinned_at text not null
167
+ );
168
+ create table if not exists swarm_rounds (
169
+ id integer primary key autoincrement,
170
+ goal_id text not null,
171
+ at text not null,
172
+ coordinator text not null,
173
+ action text,
174
+ agent text,
175
+ loop text,
176
+ judgments text,
177
+ progress text not null,
178
+ estimated_micro_usd integer,
179
+ elapsed_ms integer not null,
180
+ error text
181
+ );
182
+ create index if not exists swarm_rounds_goal_idx on swarm_rounds(goal_id, at);
148
183
  create table if not exists board_vectors (
149
184
  contribution_id text primary key,
150
185
  version text not null,
@@ -159,6 +194,10 @@ export function openStore(file) {
159
194
  const boardColumns = new Set(db.all("pragma table_info(board_tasks)").map((column) => column.name));
160
195
  if (!db.all("pragma table_info(board_standing)").some((column) => column.name === "mode")) db.exec("alter table board_standing add column mode text not null default 'named'");
161
196
  if (!db.all("pragma table_info(board_contributions)").some((column) => column.name === "mentions")) db.exec("alter table board_contributions add column mentions text");
197
+ // Devices paired before browsers could pair were all phones.
198
+ if (!db.all("pragma table_info(phone_devices)").some((column) => column.name === "kind")) db.exec("alter table phone_devices add column kind text not null default 'phone'");
199
+ // A thread whose other side's computer holds a key: everything on it goes sealed to that key.
200
+ if (!db.all("pragma table_info(board_threads)").some((column) => column.name === "peer_key")) db.exec("alter table board_threads add column peer_key text");
162
201
  if (!db.all("pragma table_info(board_goals)").some((column) => column.name === "guests")) db.exec("alter table board_goals add column guests text not null default '[]'");
163
202
  if (!boardColumns.has("holder")) {
164
203
  db.exec(`alter table board_tasks add column holder text;
@@ -170,6 +209,7 @@ export function openStore(file) {
170
209
  }
171
210
  if (!boardColumns.has("kind") && boardColumns.size) db.exec("alter table board_tasks add column kind text not null default 'task'");
172
211
  if (!boardColumns.has("usage") && boardColumns.size) db.exec("alter table board_tasks add column usage text");
212
+ if (!boardColumns.has("removed_at") && boardColumns.size) db.exec("alter table board_tasks add column removed_at text");
173
213
  if (!boardColumns.has("trust") && boardColumns.size) db.exec("alter table board_tasks add column trust text not null default 'agent'; alter table board_tasks add column origin_chain text not null default '[]'");
174
214
 
175
215
  const statements = [];
@@ -261,6 +301,10 @@ export function openStore(file) {
261
301
  });
262
302
  return parsed.meta;
263
303
  },
304
+ endedSince(since) {
305
+ const rows = execute("all", "select status, count(*) as count from board_tasks where kind not in ('goal', 'group') and status in ('done', 'failed', 'unknown') and reported_at >= ? group by status", since);
306
+ return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
307
+ },
264
308
  counts() {
265
309
  const rows = execute("all", "select agent, count(*) as count from sessions group by agent");
266
310
  return Object.fromEntries(rows.map((row) => [row.agent, Number(row.count)]));
@@ -415,7 +459,7 @@ export function openStore(file) {
415
459
  paused_reason: row.paused_reason, sessions: JSON.parse(row.sessions || "{}"), guests: JSON.parse(row.guests || "[]"), posts: this.roomCount(id), spend: this.goalSpend(id) };
416
460
  },
417
461
  goals({ all = false } = {}) {
418
- return execute("all", `select id from board_tasks where kind = 'goal' ${all ? "" : "and status in ('open', 'paused')"} order by created_at desc limit 50`).map((row) => this.getTask(row.id));
462
+ return execute("all", `select id from board_tasks where kind = 'goal' and removed_at is null ${all ? "" : "and status in ('open', 'paused')"} order by created_at desc limit 50`).map((row) => this.getTask(row.id));
419
463
  },
420
464
  setGoalState(id, { status, result, pausedReason, maxPosts }) {
421
465
  const now = new Date().toISOString();
@@ -432,9 +476,17 @@ export function openStore(file) {
432
476
  const row = execute("all", "select goal_id, guests from board_goals").find((entry) => JSON.parse(entry.guests || "[]").some((guest) => guest.thread === thread));
433
477
  return row?.goal_id || null;
434
478
  },
479
+ // At posting only: the task's room is part of how it was posted, not a change anyone races.
480
+ setParent(id, parentId) {
481
+ execute("run", "update board_tasks set parent_id = ? where id = ?", parentId, id);
482
+ return this.getTask(id);
483
+ },
435
484
  setGoalFolder(id, workspace) {
436
485
  execute("run", "update board_goals set workspace = ? where goal_id = ?", workspace, id);
437
486
  },
487
+ setGoalTeam(id, team) {
488
+ execute("run", "update board_goals set team = ? where goal_id = ?", JSON.stringify([...new Set(team)]), id);
489
+ },
438
490
  setGoalSession(id, agent, sessionId) {
439
491
  const sessions = { ...(this.goal(id)?.sessions || {}), [agent]: sessionId };
440
492
  execute("run", "update board_goals set sessions = ? where goal_id = ?", JSON.stringify(sessions), id);
@@ -480,8 +532,38 @@ export function openStore(file) {
480
532
  execute("run", "update board_tasks set status = 'failed', result = ?, holder = null, reported_at = ?, updated_at = ?, version = version + 1 where parent_id = ? and status in ('open', 'claimed', 'needs_input', 'running')", reason, now, now, goalId);
481
533
  return running;
482
534
  },
535
+ // One coordinator round: what it saw, what it decided and what it cost. The evidence for its limits.
536
+ recordRound({ goalId, coordinator, action, agent, loop, judgments, progress, estimatedMicroUsd, elapsedMs, error }) {
537
+ execute("run", `insert into swarm_rounds(goal_id, at, coordinator, action, agent, loop, judgments, progress, estimated_micro_usd, elapsed_ms, error)
538
+ values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, goalId, new Date().toISOString(), coordinator, action, agent, loop, judgments ? JSON.stringify(judgments) : null,
539
+ JSON.stringify(progress), Number.isFinite(estimatedMicroUsd) ? Math.round(estimatedMicroUsd) : null, Math.round(elapsedMs), error);
540
+ },
541
+ rounds(goalId, limit = 50) {
542
+ const bounded = Math.max(1, Math.min(500, Number(limit) || 50));
543
+ return (goalId ? execute("all", "select * from swarm_rounds where goal_id = ? order by id desc limit ?", goalId, bounded) : execute("all", "select * from swarm_rounds order by id desc limit ?", bounded))
544
+ .map((row) => ({ ...row, judgments: row.judgments ? JSON.parse(row.judgments) : null, progress: JSON.parse(row.progress) }));
545
+ },
546
+ // A thread's runs (wakes and jobs), oldest first, for its transcript.
547
+ threadRuns(goalId) {
548
+ return execute("all", `select id, kind, objective, target_agent, claimed_by, status, holder, result, session_id, run_workspace, run_authorized_at, usage, created_at, updated_at, reported_at
549
+ from board_tasks where parent_id = ? and kind in ('wake', 'task') order by created_at asc`, goalId)
550
+ .map((row) => ({ ...row, usage: row.usage ? JSON.parse(row.usage) : null }));
551
+ },
552
+ // The coding sessions on this machine, newest first: the person's own and the ones runs started.
553
+ recentSessions(limit = 30) {
554
+ return execute("all", `select agent, session_id, title, cwd, started_at, ended_at, message_count, source from sessions
555
+ order by coalesce(ended_at, started_at, indexed_at) desc limit ?`, Math.max(1, Math.min(100, Number(limit) || 30)))
556
+ .map((row) => ({ ...row, message_count: Number(row.message_count) }));
557
+ },
558
+ // The thread that already carries a session, if any.
559
+ threadForSession(agent, sessionId) {
560
+ const row = execute("all", "select goal_id, sessions from board_goals").find((entry) => JSON.parse(entry.sessions || "{}")[agent] === sessionId);
561
+ return row?.goal_id || null;
562
+ },
563
+ // The rooms an agent's own sessions are told about: threads it was brought into, not every task's room (the daemon
564
+ // runs those, and the task itself is already on its list).
483
565
  activeGoalsFor(agent) {
484
- return this.goals().filter((goal) => goal.status === "open" && goal.goal.team.includes(agent));
566
+ return this.goals().filter((goal) => goal.status === "open" && goal.goal.team.includes(agent) && !this.meta(`task-room:${goal.id}`));
485
567
  },
486
568
  authorizeGoalRun(id) {
487
569
  const now = new Date().toISOString();
@@ -512,6 +594,14 @@ export function openStore(file) {
512
594
  setRunSession(id, sessionId) {
513
595
  execute("run", "update board_tasks set session_id = ?, updated_at = ? where id = ?", sessionId, new Date().toISOString(), id);
514
596
  },
597
+ // A run the daemon stopped itself for a safety rule (Lid-Closed Mode): the task goes back on the board, still
598
+ // authorized, and its next run picks up where this one stopped.
599
+ requeueRun(id) {
600
+ const now = new Date().toISOString();
601
+ const written = execute("run", `update board_tasks set status = 'open', holder = target_agent, claimed_by = null, claimed_at = null, lease_until = null, updated_at = @now, version = version + 1
602
+ where id = @id and status = 'running'`, { id, now });
603
+ return written.changes === 1 ? { ok: true, task: this.getTask(id) } : { ok: false, error: `task is ${this.getTask(id)?.status ?? "missing"}` };
604
+ },
515
605
  // A daemon restart lost every agent process it was running; those outcomes are unknown, never assumed.
516
606
  settleInterruptedRuns() {
517
607
  const now = new Date().toISOString();
@@ -558,6 +648,17 @@ export function openStore(file) {
558
648
  const row = execute("get", "select count(*) as total, (select count(*) from board_vectors v join board_contributions c on c.id = v.contribution_id where c.task_id = ?) as embedded from board_contributions where task_id = ?", taskId, taskId);
559
649
  return { total: Number(row.total), embedded: Number(row.embedded) };
560
650
  },
651
+ // The swarm's memory by words: everything written in the person's threads, best first, with the thread it is in.
652
+ searchThreads(query, { goalIds = null, k = 8 } = {}) {
653
+ const terms = [...new Set(String(query).toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [])].filter((term) => term.length > 1).slice(0, 12);
654
+ if (!terms.length) return [];
655
+ const match = terms.map((term) => `"${term.replaceAll('"', '""')}"`).join(" OR ");
656
+ return execute("all", `select c.id, c.task_id as thread, t.objective as thread_title, c.kind, c.author, c.text, c.created_at, bm25(board_contributions_fts) as rank
657
+ from board_contributions_fts f join board_contributions c on c.id = f.id join board_tasks t on t.id = c.task_id
658
+ where board_contributions_fts match @match and t.kind = 'goal' order by rank limit 200`, { match })
659
+ .filter((row) => !goalIds || goalIds.includes(row.thread)).slice(0, Math.max(1, Math.min(30, k)))
660
+ .map(({ rank, ...row }) => ({ ...row, text: row.text.slice(0, 2_000) }));
661
+ },
561
662
  // Words first: full-text candidates for a query on one task, best first.
562
663
  searchContextText(taskId, query, k = 8) {
563
664
  const terms = [...new Set(String(query).toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [])].filter((term) => term.length > 1).slice(0, 12);
@@ -573,10 +674,21 @@ export function openStore(file) {
573
674
  // Outbound messages start held (unless auto-send) or flagged (a secret), become sent when
574
675
  // the person releases them, and delivered when the server acknowledges them. Inbound ones
575
676
  // are received as data with the sender's handle on them.
576
- openThread(taskId, { withAgent, role }) {
577
- execute("run", "insert or ignore into board_threads(task_id, with_agent, role, auto_send, opened_at) values(?, ?, ?, 0, ?)", taskId, withAgent, role, new Date().toISOString());
677
+ openThread(taskId, { withAgent, role, peerKey = null }) {
678
+ execute("run", "insert or ignore into board_threads(task_id, with_agent, role, auto_send, opened_at, peer_key) values(?, ?, ?, 0, ?, ?)", taskId, withAgent, role, new Date().toISOString(), peerKey);
578
679
  return this.getThread(taskId);
579
680
  },
681
+ // Another person's computer, by handle: the key first seen for it. A different key later is refused until the person trusts it.
682
+ relayPeer(handle) {
683
+ return execute("get", "select handle, public_key, pinned_at from relay_peers where handle = ?", handle) || null;
684
+ },
685
+ pinRelayPeer(handle, publicKey) {
686
+ execute("run", "insert into relay_peers(handle, public_key, pinned_at) values(?, ?, ?) on conflict(handle) do update set public_key = excluded.public_key, pinned_at = excluded.pinned_at", handle, publicKey, new Date().toISOString());
687
+ return this.relayPeer(handle);
688
+ },
689
+ relayPeers() {
690
+ return execute("all", "select handle, public_key, pinned_at from relay_peers order by pinned_at desc");
691
+ },
580
692
  getThread(taskId) {
581
693
  const row = execute("get", "select * from board_threads where task_id = ?", taskId);
582
694
  return row ? { ...row, auto_send: Number(row.auto_send) === 1 } : null;
@@ -653,12 +765,54 @@ export function openStore(file) {
653
765
  return { ok: true, thread: this.getThread(taskId), task: this.getTask(taskId) };
654
766
  },
655
767
  // Messages another person's agent sent on this agent's tasks since it last read the board.
768
+ lastSeenAt(agent) {
769
+ return execute("get", "select last_seen_at from board_agents where agent = ?", agent)?.last_seen_at || null;
770
+ },
771
+ // Work an agent asked for that finished, failed or came back with a question since it last called the board: how an
772
+ // asker that cannot be woken (the Claude app, ChatGPT) learns of its answers.
773
+ finishedFor(agent, since) {
774
+ return execute("all", `select id, objective, target_agent, claimed_by, status, updated_at from board_tasks
775
+ where origin_agent = ? and kind = 'task' and removed_at is null and status in ('done', 'failed', 'needs_input') and updated_at > ?
776
+ order by updated_at desc limit 20`, agent, since || "1970-01-01T00:00:00.000Z");
777
+ },
656
778
  unreadFor(agent) {
657
779
  const seen = execute("get", "select last_seen_at from board_agents where agent = ?", agent)?.last_seen_at || "1970-01-01T00:00:00.000Z";
658
780
  return execute("all", `select m.task_id, t.objective, m.sender, count(*) as count from board_messages m join board_tasks t on t.id = m.task_id
659
781
  where m.direction = 'in' and m.created_at > ? and (t.claimed_by = ? or t.target_agent = ? or t.origin_agent = ?) group by m.task_id, t.objective, m.sender`, seen, agent, agent, agent)
660
782
  .map((row) => ({ ...row, count: Number(row.count) }));
661
783
  },
784
+ // Phones (QR code) and browsers (link) paired with this machine. Only the public key is kept; a revoked one is refused at once.
785
+ addPhone({ id, label, publicKey, kind = "phone" }) {
786
+ execute("run", "insert into phone_devices(id, label, public_key, paired_at, kind) values(?, ?, ?, ?, ?)", id, label, publicKey, new Date().toISOString(), kind);
787
+ return this.getPhone(id);
788
+ },
789
+ getPhone(id) {
790
+ return execute("get", "select id, label, public_key, kind, paired_at, last_seen_at, revoked_at from phone_devices where id = ?", id) || null;
791
+ },
792
+ phones() {
793
+ return execute("all", "select id, label, kind, paired_at, last_seen_at, revoked_at from phone_devices order by paired_at desc");
794
+ },
795
+ // Once a browser is paired, Harness reaches the person's board, sessions and runs only sealed.
796
+ sealedOnly() {
797
+ return Boolean(execute("get", "select 1 as paired from phone_devices where kind = 'browser' and revoked_at is null limit 1"));
798
+ },
799
+ // A request id is accepted once per phone, across restarts; ids older than the queue's 7 days are forgotten.
800
+ firstPhoneRequest(deviceId, requestId) {
801
+ const now = new Date();
802
+ execute("run", "delete from phone_requests where received_at < ?", new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000).toISOString());
803
+ if (execute("get", "select 1 as seen from phone_requests where device_id = ? and request_id = ?", deviceId, requestId)) return false;
804
+ execute("run", "insert into phone_requests(device_id, request_id, received_at) values(?, ?, ?)", deviceId, requestId, now.toISOString());
805
+ return true;
806
+ },
807
+ touchPhone(id) {
808
+ execute("run", "update phone_devices set last_seen_at = ? where id = ?", new Date().toISOString(), id);
809
+ },
810
+ revokePhone(id) {
811
+ const phone = this.getPhone(id);
812
+ if (!phone) return { ok: false, error: "no phone with that id" };
813
+ if (!phone.revoked_at) execute("run", "update phone_devices set revoked_at = ? where id = ?", new Date().toISOString(), id);
814
+ return { ok: true, phone: this.getPhone(id) };
815
+ },
662
816
  touchAgent(agent) {
663
817
  execute("run", "insert into board_agents(agent, last_seen_at) values(?, ?) on conflict(agent) do update set last_seen_at = excluded.last_seen_at", agent, new Date().toISOString());
664
818
  },
@@ -666,10 +820,12 @@ export function openStore(file) {
666
820
  return execute("all", "select agent, last_seen_at from board_agents order by last_seen_at desc");
667
821
  },
668
822
  listTasks({ agent, status, all = false, limit = 20 } = {}) {
669
- const clauses = [];
823
+ const clauses = ["b.removed_at is null"];
670
824
  const params = { limit: Math.max(1, Math.min(100, Number(limit) || 20)) };
671
825
  if (status) { clauses.push("b.status = @status"); params.status = status; }
672
826
  else if (!all) clauses.push("b.status in ('open', 'claimed', 'running', 'needs_input')");
827
+ // An agent's whole-board list is work, not threads: those it reads as its rooms.
828
+ if (agent && agent !== "user" && all) clauses.push("b.kind not in ('goal', 'wake')");
673
829
  if (agent && !all) {
674
830
  clauses.push("b.kind not in ('group', 'goal', 'wake') and (b.target_agent is null or b.target_agent = @agent or b.origin_agent = @agent or b.claimed_by = @agent)");
675
831
  params.agent = agent;
@@ -684,6 +840,18 @@ export function openStore(file) {
684
840
  from board_tasks b left join board_threads th on th.task_id = b.id ${clauses.length ? `where ${clauses.join(" and ")}` : ""} order by b.created_at desc limit @limit`, params)
685
841
  .map((row) => ({ ...row, origin_chain: JSON.parse(row.origin_chain || "[]"), version: Number(row.version), held_messages: Number(row.held_messages), child_count: Number(row.child_count), child_done: Number(row.child_done), stale: row.status === "claimed" && Boolean(row.lease_until) && row.lease_until < now }));
686
842
  },
843
+ // Off the board for good, row kept: unfinished work and its parts close first, so nothing picks them up again.
844
+ removeTask(id) {
845
+ const now = new Date().toISOString();
846
+ const ids = [id, ...execute("all", "select id from board_tasks where parent_id = ?", id).map((row) => row.id)];
847
+ const running = [];
848
+ for (const taskId of ids) {
849
+ if (execute("get", "select 1 from board_tasks where id = ? and status = 'running'", taskId)) running.push(taskId);
850
+ execute("run", "update board_tasks set status = 'failed', result = 'Removed by you.', holder = null, reported_at = ?, updated_at = ?, version = version + 1 where id = ? and kind != 'goal' and status in ('open', 'claimed', 'needs_input', 'running')", now, now, taskId);
851
+ execute("run", "update board_tasks set removed_at = ?, updated_at = ? where id = ? and removed_at is null", now, now, taskId);
852
+ }
853
+ return { ids, running };
854
+ },
687
855
  getTask(id) {
688
856
  const row = execute("get", "select * from board_tasks where id = ?", id);
689
857
  if (!row) return null;