tryharness 0.7.4 → 0.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.mjs +123 -18
- package/lib/activity.mjs +191 -0
- package/lib/adapters.mjs +2 -1
- package/lib/claudemem.mjs +90 -0
- package/lib/coordinator.mjs +162 -0
- package/lib/daemon.mjs +698 -96
- package/lib/mcp-stdio.mjs +29 -6
- package/lib/members.json +31 -0
- package/lib/members.mjs +47 -0
- package/lib/onboard.mjs +131 -0
- package/lib/paths.mjs +1 -0
- package/lib/phones.mjs +152 -0
- package/lib/runner.mjs +81 -18
- package/lib/sealed.mjs +103 -0
- package/lib/store.mjs +174 -6
- package/lib/swarm.mjs +271 -0
- package/lib/wakes.mjs +196 -0
- package/lib/worktrees.mjs +132 -0
- package/package.json +4 -3
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
165
|
+
child.stdin.end(invocation.stdin);
|
|
96
166
|
} catch (error) {
|
|
97
|
-
running.delete(
|
|
167
|
+
running.delete(slot);
|
|
98
168
|
emit("failed", `Could not start ${task.agent}: ${error.message}`);
|
|
99
169
|
return;
|
|
100
170
|
}
|
|
101
|
-
running.add(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
+
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;
|