tryharness 0.7.4 → 0.7.6
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 +104 -2
- package/lib/coordinator.mjs +158 -0
- package/lib/daemon.mjs +420 -68
- package/lib/mcp-stdio.mjs +7 -2
- package/lib/paths.mjs +1 -0
- package/lib/phones.mjs +148 -0
- package/lib/runner.mjs +64 -17
- package/lib/sealed.mjs +103 -0
- package/lib/store.mjs +126 -3
- package/lib/swarm.mjs +224 -0
- package/lib/worktrees.mjs +132 -0
- package/package.json +2 -1
package/lib/mcp-stdio.mjs
CHANGED
|
@@ -91,13 +91,17 @@ 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
|
|
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
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 },
|
|
96
96
|
},
|
|
97
97
|
{
|
|
98
98
|
name: "search_room",
|
|
99
99
|
description: "Search a goal's room by meaning before starting work, so you build on what teammates already found instead of repeating it.",
|
|
100
100
|
inputSchema: { type: "object", properties: { id: { type: "string", description: "the goal id" }, query: { type: "string" } }, required: ["id", "query"], additionalProperties: false },
|
|
101
|
+
}, {
|
|
102
|
+
name: "search_swarm",
|
|
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
|
+
inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false },
|
|
101
105
|
},
|
|
102
106
|
];
|
|
103
107
|
|
|
@@ -105,6 +109,7 @@ const BOARD_CALLS = {
|
|
|
105
109
|
read_room: (args) => ({ command: "board.room.read", id: String(args.id || ""), since: args.since }),
|
|
106
110
|
post_to_room: (args) => ({ command: "board.room.post", id: String(args.id || ""), text: args.text, shareWithGuests: args.share_with_guests === true }),
|
|
107
111
|
search_room: (args) => ({ command: "board.room.search", id: String(args.id || ""), query: args.query }),
|
|
112
|
+
search_swarm: (args) => ({ command: "board.swarm.search", query: args.query }),
|
|
108
113
|
search_task_context: (args) => ({ command: "board.context.search", id: String(args.id || ""), query: args.query, limit: args.limit }),
|
|
109
114
|
add_task_context: (args) => ({ command: "board.context.add", id: String(args.id || ""), kind: args.kind, text: args.text, source: args.source }),
|
|
110
115
|
read_thread: (args) => ({ command: "board.thread.read", id: String(args.id || "") }),
|
|
@@ -126,7 +131,7 @@ async function localCall(request) {
|
|
|
126
131
|
const name = request.params?.name;
|
|
127
132
|
const args = request.params?.arguments || {};
|
|
128
133
|
try {
|
|
129
|
-
const result = await daemonRequest({ ...BOARD_CALLS[name](args), agent }, ["search_task_context", "search_room"].includes(name) ? 20_000 : 5_000);
|
|
134
|
+
const result = await daemonRequest({ ...BOARD_CALLS[name](args), agent }, ["search_task_context", "search_room", "search_swarm"].includes(name) ? 20_000 : 5_000);
|
|
130
135
|
return {
|
|
131
136
|
jsonrpc: "2.0",
|
|
132
137
|
id: request.id,
|
package/lib/paths.mjs
CHANGED
|
@@ -14,6 +14,7 @@ export const paths = {
|
|
|
14
14
|
status: path.join(harnessHome, "daemon-status.json"),
|
|
15
15
|
socket: process.platform === "win32" ? "\\\\.\\pipe\\tryharness-daemon" : path.join(harnessHome, "daemon.sock"),
|
|
16
16
|
log: path.join(harnessHome, "daemon.log"),
|
|
17
|
+
phoneIdentity: path.join(harnessHome, "phone-identity.json"),
|
|
17
18
|
runs: path.join(harnessHome, "runs"),
|
|
18
19
|
runtime: path.join(harnessHome, "runtime"),
|
|
19
20
|
models: path.join(harnessHome, "models"),
|
package/lib/phones.mjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { b64u, channelKey, constantTimeEqual, loadOrCreateIdentity, MAX_SKEW_MS, open, pairProof, seal } from "./sealed.mjs";
|
|
4
|
+
|
|
5
|
+
// The daemon's side of the person's own devices: pairing offers made on this machine (a QR code
|
|
6
|
+
// for a phone, a link opened in a browser), and sealed calls from paired devices, opened here and
|
|
7
|
+
// answered here. Harness only carries the envelopes.
|
|
8
|
+
// A phone acts as the person ("user") within PHONE_COMMANDS. A browser is the Harness window on a
|
|
9
|
+
// desk: it acts as the person or as Harness (the chat owner), within BROWSER_COMMANDS, and reads
|
|
10
|
+
// the coding sessions and run records Harness used to read in the clear.
|
|
11
|
+
|
|
12
|
+
const OFFER_TTL_MS = 10 * 60_000;
|
|
13
|
+
const MAX_OFFERS = 5;
|
|
14
|
+
// A message queued at Harness while this computer slept may arrive up to 7 days late; only decisions and posts may.
|
|
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"]);
|
|
18
|
+
const ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
19
|
+
const KEY = /^[A-Za-z0-9_-]{80,100}$/;
|
|
20
|
+
const KINDS = new Set(["phone", "browser"]);
|
|
21
|
+
const PHONE_COMMANDS = new Set([
|
|
22
|
+
"board.list", "board.get", "board.children", "board.agents", "board.post", "board.run", "board.release", "board.answer",
|
|
23
|
+
"board.thread.read", "board.thread.post", "board.thread.send", "board.thread.discard", "board.thread.auto", "board.thread.end",
|
|
24
|
+
"board.goal.list", "board.goal.get", "board.goal.stop", "board.room.read",
|
|
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",
|
|
27
|
+
]);
|
|
28
|
+
// What the chat owner does on the board, in Harness's name (was src/server/mcp/board.ts HARNESS_COMMANDS).
|
|
29
|
+
const HARNESS_COMMANDS = new Set([
|
|
30
|
+
"board.list", "board.get", "board.post", "board.claim", "board.report", "board.answer", "board.thread.read", "board.thread.post", "board.standing.check",
|
|
31
|
+
"board.goal.create", "board.goal.get", "board.goal.list", "board.goal.finish", "board.room.read", "board.room.post", "board.room.search", "board.children", "board.agents",
|
|
32
|
+
"board.swarm.list", "board.swarm.read", "board.swarm.diff",
|
|
33
|
+
]);
|
|
34
|
+
const BROWSER_PERSON_COMMANDS = new Set([...PHONE_COMMANDS, "board.goal.resume", "board.standing", "board.standing.check", "board.standing.off"]);
|
|
35
|
+
const BROWSER_READS = new Set(["sessions.search", "sessions.read", "sessions.raw", "tasks.status", "relay.request", "relay.open"]);
|
|
36
|
+
|
|
37
|
+
export async function createPhoneDesk({ store, identityFile, boardCommand, reads = {}, version }) {
|
|
38
|
+
const identity = await loadOrCreateIdentity(identityFile);
|
|
39
|
+
const offers = new Map();
|
|
40
|
+
const keys = new Map();
|
|
41
|
+
const hostname = os.hostname().slice(0, 64);
|
|
42
|
+
|
|
43
|
+
const sweep = () => {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
for (const [id, offer] of offers) if (offer.expiresAt < now) offers.delete(id);
|
|
46
|
+
};
|
|
47
|
+
const keyFor = async (device) => {
|
|
48
|
+
if (!keys.has(device.id)) keys.set(device.id, await channelKey(identity.privateKey, device.public_key, device.id));
|
|
49
|
+
return keys.get(device.id);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// What the QR code or link carries. The secret and the daemon's key never pass through Harness.
|
|
53
|
+
// The kind is fixed here, on the machine: a page cannot turn a phone's code into a browser's.
|
|
54
|
+
const offer = (origin, kind = "phone") => {
|
|
55
|
+
if (!KINDS.has(kind)) throw new Error(`unknown device kind ${kind}`);
|
|
56
|
+
sweep();
|
|
57
|
+
if (offers.size >= MAX_OFFERS) offers.delete(offers.keys().next().value);
|
|
58
|
+
const offerId = b64u(randomBytes(16));
|
|
59
|
+
const secret = b64u(randomBytes(32));
|
|
60
|
+
offers.set(offerId, { secret, kind, expiresAt: Date.now() + OFFER_TTL_MS });
|
|
61
|
+
const fragment = new URLSearchParams({ v: "1", o: offerId, s: secret, k: identity.publicKey, h: hostname });
|
|
62
|
+
const page = kind === "browser" ? "/link" : "/m/pair";
|
|
63
|
+
return { offerId, kind, expiresInSeconds: OFFER_TTL_MS / 1000, url: `${String(origin).replace(/\/$/, "")}${page}#${fragment}` };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const pair = async (input) => {
|
|
67
|
+
sweep();
|
|
68
|
+
const { offerId, deviceId, devicePublicKey, proof, requestId } = input;
|
|
69
|
+
if (![offerId, deviceId, requestId].every((value) => ID.test(String(value))) || !KEY.test(String(devicePublicKey))) return { ok: false, error: "invalid pairing request" };
|
|
70
|
+
const pending = offers.get(String(offerId));
|
|
71
|
+
// One attempt per code, right or wrong: a guessed proof burns the code.
|
|
72
|
+
offers.delete(String(offerId));
|
|
73
|
+
if (!pending) return { ok: false, error: "this pairing code expired or was already used; make a new one on the computer" };
|
|
74
|
+
if (!constantTimeEqual(proof, await pairProof(pending.secret, offerId, devicePublicKey, identity.publicKey))) return { ok: false, error: "the pairing proof did not match" };
|
|
75
|
+
if (store.getPhone(String(deviceId))) return { ok: false, error: "this device is already paired" };
|
|
76
|
+
const fallback = pending.kind === "browser" ? "Browser" : "Phone";
|
|
77
|
+
const label = String(input.label || fallback).replace(/[^\p{L}\p{N} ._'-]/gu, "").slice(0, 40) || fallback;
|
|
78
|
+
const device = store.addPhone({ id: String(deviceId), label, publicKey: String(devicePublicKey), kind: pending.kind });
|
|
79
|
+
return { ok: true, envelope: await seal(await keyFor(device), "d2p", device.id, String(requestId), { paired: true, hostname, label, kind: device.kind, version }) };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const call = async (envelope) => {
|
|
83
|
+
sweep();
|
|
84
|
+
if (!envelope || envelope.v !== 1 || !ID.test(String(envelope.deviceId)) || !ID.test(String(envelope.requestId)) || typeof envelope.iv !== "string" || typeof envelope.ct !== "string") {
|
|
85
|
+
return { ok: false, error: "invalid envelope" };
|
|
86
|
+
}
|
|
87
|
+
const device = store.getPhone(envelope.deviceId);
|
|
88
|
+
if (!device) return { ok: false, error: "unknown device", code: "unpaired" };
|
|
89
|
+
if (device.revoked_at) return { ok: false, error: "this device was disconnected from the computer", code: "revoked" };
|
|
90
|
+
const key = await keyFor(device);
|
|
91
|
+
let request;
|
|
92
|
+
try { request = await open(key, "p2d", envelope); } catch { return { ok: false, error: "the message could not be opened", code: "unreadable" }; }
|
|
93
|
+
const queued = request?.queued === true;
|
|
94
|
+
if (queued && !QUEUEABLE.has(String(request?.call?.command || ""))) return { ok: false, error: "only posts and decisions wait for the computer", code: "refused" };
|
|
95
|
+
const age = typeof request?.ts === "number" ? Date.now() - request.ts : Infinity;
|
|
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
|
+
if (!store.firstPhoneRequest(device.id, String(envelope.requestId))) return { ok: false, error: "this message was already received", code: "replayed" };
|
|
98
|
+
store.touchPhone(device.id);
|
|
99
|
+
if (request.call?.command === "phone.forget") {
|
|
100
|
+
const forgotten = revoke(device.id);
|
|
101
|
+
return { ok: true, envelope: await seal(key, "d2p", device.id, envelope.requestId, { ok: forgotten.ok }) };
|
|
102
|
+
}
|
|
103
|
+
const result = await answer(request.call, device);
|
|
104
|
+
return { ok: true, envelope: await seal(key, "d2p", device.id, envelope.requestId, result) };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const settled = async (run) => {
|
|
108
|
+
try {
|
|
109
|
+
const result = run();
|
|
110
|
+
return result?.pending ? { ok: true, ...(await result.pending) } : result;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return { ok: false, error: `the board could not answer: ${error.message}` };
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const answer = async (callInput, device) => {
|
|
117
|
+
const command = String(callInput?.command || "");
|
|
118
|
+
if (command === "phone.hello") return { ok: true, hostname, version, kind: device.kind };
|
|
119
|
+
if (device.kind === "browser") {
|
|
120
|
+
if (BROWSER_READS.has(command)) {
|
|
121
|
+
const read = reads[command];
|
|
122
|
+
if (!read) return { ok: false, error: `this computer cannot answer ${command}` };
|
|
123
|
+
try { return { ok: true, result: await read(callInput) }; } catch (error) { return { ok: false, error: error.message }; }
|
|
124
|
+
}
|
|
125
|
+
// The browser says whose call it is: the person's own action, or the chat owner's tool call.
|
|
126
|
+
const as = callInput.as === "harness" ? "harness" : "user";
|
|
127
|
+
if (!(as === "harness" ? HARNESS_COMMANDS : BROWSER_PERSON_COMMANDS).has(command)) return { ok: false, error: `a browser cannot call ${command || "that"} as ${as}` };
|
|
128
|
+
// Words another agent wrote that drove Harness's call ride as a taint the board keeps.
|
|
129
|
+
const writtenBy = Array.isArray(callInput.writtenBy) ? callInput.writtenBy.map(String).slice(0, 16) : [];
|
|
130
|
+
if (as === "harness" && command === "board.goal.create" && writtenBy.length) return { ok: false, error: "a goal is set only in a turn the owner started" };
|
|
131
|
+
const { as: _as, writtenBy: _written, provenance: _provenance, ...rest } = callInput;
|
|
132
|
+
return settled(() => boardCommand({ ...rest, command, agent: as, ...(writtenBy.length ? { provenance: { trust: "agent", chain: writtenBy } } : {}) }));
|
|
133
|
+
}
|
|
134
|
+
if (!PHONE_COMMANDS.has(command)) return { ok: false, error: `a phone cannot call ${command || "that"}` };
|
|
135
|
+
// The phone is the person; whatever it names itself, it acts as "user".
|
|
136
|
+
return settled(() => boardCommand({ ...callInput, command, agent: "user", ...(command === "board.list" && callInput.all === undefined ? { all: true } : {}) }));
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Harness's bridge message: a pairing or a sealed call. Everything returned is safe to pass through Harness.
|
|
140
|
+
const handle = async (input) => (input?.kind === "pair" ? pair(input) : input?.kind === "call" ? call(input.envelope) : { ok: false, error: "unknown sealed request" });
|
|
141
|
+
|
|
142
|
+
const revoke = (id) => {
|
|
143
|
+
keys.delete(String(id));
|
|
144
|
+
return store.revokePhone(String(id));
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return { offer, handle, revoke, list: () => store.phones(), publicKey: identity.publicKey, identity };
|
|
148
|
+
}
|
package/lib/runner.mjs
CHANGED
|
@@ -13,6 +13,11 @@ export function stopAgentTask(runId) {
|
|
|
13
13
|
return true;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** The process ids of the runs in progress, so they are not mistaken for the person's own sessions. */
|
|
17
|
+
export function runningPids() {
|
|
18
|
+
return [...children.values()].map((child) => child.pid).filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
export function resolveWorkingDirectory(requested) {
|
|
17
22
|
if (!requested || !path.isAbsolute(requested)) throw new Error("Delegated tasks require an absolute working directory.");
|
|
18
23
|
let stat;
|
|
@@ -26,13 +31,14 @@ export function agentInvocation(task) {
|
|
|
26
31
|
if (task.agent === "claude-code") {
|
|
27
32
|
return {
|
|
28
33
|
command: "claude",
|
|
29
|
-
|
|
34
|
+
// A review reads and reports: plan mode lets Claude Code read and run read-only tools but never edit.
|
|
35
|
+
args: ["-p", task.brief, "--output-format", "stream-json", "--verbose", ...(task.readOnly ? ["--permission-mode", "plan"] : ["--dangerously-skip-permissions"]), "--name", `Harness: ${task.title}`, ...(task.resumeSession ? ["--resume", task.resumeSession] : [])],
|
|
30
36
|
};
|
|
31
37
|
}
|
|
32
38
|
if (task.agent === "codex") {
|
|
33
39
|
return {
|
|
34
40
|
command: "codex",
|
|
35
|
-
args: ["exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "-"],
|
|
41
|
+
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
42
|
stdin: task.brief,
|
|
37
43
|
};
|
|
38
44
|
}
|
|
@@ -40,7 +46,7 @@ export function agentInvocation(task) {
|
|
|
40
46
|
// this non-interactive run; its complete built-in tool set remains enabled.
|
|
41
47
|
if (task.agent === "pi") return { command: "pi", args: ["--mode", "json", "--approve", "-p", task.brief] };
|
|
42
48
|
if (task.agent === "opencode") {
|
|
43
|
-
return { command: "opencode", args: ["run", "--format", "json", "--auto", "--title", task.title, "--", task.brief] };
|
|
49
|
+
return { command: "opencode", args: ["run", "--format", "json", "--auto", ...(task.resumeSession ? ["--session", task.resumeSession] : ["--title", task.title]), "--", task.brief] };
|
|
44
50
|
}
|
|
45
51
|
throw new Error(`Unsupported agent: ${task.agent}`);
|
|
46
52
|
}
|
|
@@ -74,9 +80,57 @@ export function usageCollector() {
|
|
|
74
80
|
};
|
|
75
81
|
}
|
|
76
82
|
|
|
83
|
+
// 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
|
|
84
|
+
// it used. Claude Code, Codex, Pi and OpenCode each say this their own way; anything else is not a step.
|
|
85
|
+
const brief = (input) => {
|
|
86
|
+
if (!input || typeof input !== "object") return String(input ?? "").slice(0, 200);
|
|
87
|
+
const named = input.command ?? input.cmd ?? input.file_path ?? input.path ?? input.filePath ?? input.pattern ?? input.url ?? input.query ?? input.description;
|
|
88
|
+
return String(named ?? JSON.stringify(input)).slice(0, 200);
|
|
89
|
+
};
|
|
90
|
+
export function stepsOf(event) {
|
|
91
|
+
if (!event || typeof event !== "object") return [];
|
|
92
|
+
if (event.type === "assistant" && Array.isArray(event.message?.content)) {
|
|
93
|
+
return event.message.content.flatMap((block) => block.type === "text" && block.text?.trim() ? [{ kind: "say", text: block.text.trim() }]
|
|
94
|
+
: block.type === "tool_use" ? [{ kind: "do", text: `${block.name}: ${brief(block.input)}` }] : []);
|
|
95
|
+
}
|
|
96
|
+
if (event.type === "item.completed" && event.item) {
|
|
97
|
+
const item = event.item;
|
|
98
|
+
if (item.type === "agent_message" && item.text?.trim()) return [{ kind: "say", text: item.text.trim() }];
|
|
99
|
+
if (item.type === "command_execution") return [{ kind: "do", text: `ran: ${String(item.command ?? "").slice(0, 200)}` }];
|
|
100
|
+
if (item.type === "file_change") return [{ kind: "do", text: `edited: ${(item.changes ?? []).map((change) => change.path).join(", ").slice(0, 200)}` }];
|
|
101
|
+
if (item.type === "mcp_tool_call") return [{ kind: "do", text: `${item.server ?? "tool"}: ${item.tool ?? ""}` }];
|
|
102
|
+
if (item.type === "web_search") return [{ kind: "do", text: `searched: ${String(item.query ?? "").slice(0, 200)}` }];
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
if (event.type === "message_end" && event.message?.role === "assistant" && Array.isArray(event.message.content)) {
|
|
106
|
+
return event.message.content.flatMap((block) => block.type === "text" && block.text?.trim() ? [{ kind: "say", text: block.text.trim() }]
|
|
107
|
+
: block.type === "toolCall" ? [{ kind: "do", text: `${block.name}: ${brief(block.arguments)}` }] : []);
|
|
108
|
+
}
|
|
109
|
+
const part = event.part;
|
|
110
|
+
if (part?.type === "text" && part.text?.trim() && (part.time?.end || event.type === "text")) return [{ kind: "say", text: part.text.trim() }];
|
|
111
|
+
if (part?.type === "tool" && part.state?.status === "completed") return [{ kind: "do", text: `${part.tool}: ${brief(part.state.input)}` }];
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The steps of a run from its log, oldest first; at most `limit`, the latest kept. */
|
|
116
|
+
export function readRunSteps(file, limit = 200) {
|
|
117
|
+
let raw = "";
|
|
118
|
+
try { raw = fs.readFileSync(file, "utf8"); } catch { return []; }
|
|
119
|
+
const steps = [];
|
|
120
|
+
for (const line of raw.split("\n")) {
|
|
121
|
+
if (!line.startsWith("{")) continue;
|
|
122
|
+
let event;
|
|
123
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
124
|
+
steps.push(...stepsOf(event));
|
|
125
|
+
}
|
|
126
|
+
return steps.slice(-limit);
|
|
127
|
+
}
|
|
128
|
+
|
|
77
129
|
export function runAgentTask(task, emit, observe = () => {}) {
|
|
78
|
-
|
|
79
|
-
|
|
130
|
+
// One run per folder: two agents in one checkout would trip over each other; a thread's worktree is its own folder.
|
|
131
|
+
const slot = task.cwd ? path.resolve(task.cwd) : task.agent;
|
|
132
|
+
if (running.has(slot)) {
|
|
133
|
+
emit("failed", `Another run is already working in ${task.cwd || "that folder"}.`);
|
|
80
134
|
return;
|
|
81
135
|
}
|
|
82
136
|
let cwd;
|
|
@@ -94,11 +148,11 @@ export function runAgentTask(task, emit, observe = () => {}) {
|
|
|
94
148
|
child = spawn(invocation.command, invocation.args, { cwd });
|
|
95
149
|
if (invocation.stdin !== undefined) child.stdin.end(invocation.stdin);
|
|
96
150
|
} catch (error) {
|
|
97
|
-
running.delete(
|
|
151
|
+
running.delete(slot);
|
|
98
152
|
emit("failed", `Could not start ${task.agent}: ${error.message}`);
|
|
99
153
|
return;
|
|
100
154
|
}
|
|
101
|
-
running.add(
|
|
155
|
+
running.add(slot);
|
|
102
156
|
children.set(task.runId, child);
|
|
103
157
|
|
|
104
158
|
let resultText = "";
|
|
@@ -126,14 +180,7 @@ export function runAgentTask(task, emit, observe = () => {}) {
|
|
|
126
180
|
if (event.type === "result" && typeof event.result === "string") resultText = event.result;
|
|
127
181
|
if (event.type === "item.completed" && event.item?.type === "agent_message" && typeof event.item.text === "string") resultText = event.item.text;
|
|
128
182
|
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));
|
|
183
|
+
for (const step of stepsOf(event)) emit("progress", step.text.slice(0, 200), { step });
|
|
137
184
|
}
|
|
138
185
|
});
|
|
139
186
|
child.stderr?.on("data", (chunk) => {
|
|
@@ -142,13 +189,13 @@ export function runAgentTask(task, emit, observe = () => {}) {
|
|
|
142
189
|
tail = (tail + "\n" + raw).slice(-4_000);
|
|
143
190
|
});
|
|
144
191
|
child.on("close", (code) => {
|
|
145
|
-
running.delete(
|
|
192
|
+
running.delete(slot);
|
|
146
193
|
children.delete(task.runId);
|
|
147
194
|
if (code === 0) emit("done", (resultText || "The run finished; check the repository for its changes.").slice(0, 20_000), { usage: usage.result() });
|
|
148
195
|
else emit("failed", (`${task.agent} exited with code ${code}.\n${tail}`).slice(0, 4_000), { usage: usage.result() });
|
|
149
196
|
});
|
|
150
197
|
child.on("error", (error) => {
|
|
151
|
-
running.delete(
|
|
198
|
+
running.delete(slot);
|
|
152
199
|
children.delete(task.runId);
|
|
153
200
|
emit("failed", `Could not start ${task.agent}: ${error.message}`);
|
|
154
201
|
});
|
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
|
|
|
@@ -145,6 +145,40 @@ export function openStore(file) {
|
|
|
145
145
|
);
|
|
146
146
|
create index if not exists board_contributions_task_idx on board_contributions(task_id, seq);
|
|
147
147
|
create virtual table if not exists board_contributions_fts using fts5(id unindexed, task_id unindexed, text, tokenize='unicode61');
|
|
148
|
+
create table if not exists phone_requests (
|
|
149
|
+
device_id text not null,
|
|
150
|
+
request_id text not null,
|
|
151
|
+
received_at text not null,
|
|
152
|
+
primary key(device_id, request_id)
|
|
153
|
+
);
|
|
154
|
+
create table if not exists phone_devices (
|
|
155
|
+
id text primary key,
|
|
156
|
+
label text not null,
|
|
157
|
+
public_key text not null,
|
|
158
|
+
paired_at text not null,
|
|
159
|
+
last_seen_at text,
|
|
160
|
+
revoked_at text
|
|
161
|
+
);
|
|
162
|
+
create table if not exists relay_peers (
|
|
163
|
+
handle text primary key,
|
|
164
|
+
public_key text not null,
|
|
165
|
+
pinned_at text not null
|
|
166
|
+
);
|
|
167
|
+
create table if not exists swarm_rounds (
|
|
168
|
+
id integer primary key autoincrement,
|
|
169
|
+
goal_id text not null,
|
|
170
|
+
at text not null,
|
|
171
|
+
coordinator text not null,
|
|
172
|
+
action text,
|
|
173
|
+
agent text,
|
|
174
|
+
loop text,
|
|
175
|
+
judgments text,
|
|
176
|
+
progress text not null,
|
|
177
|
+
estimated_micro_usd integer,
|
|
178
|
+
elapsed_ms integer not null,
|
|
179
|
+
error text
|
|
180
|
+
);
|
|
181
|
+
create index if not exists swarm_rounds_goal_idx on swarm_rounds(goal_id, at);
|
|
148
182
|
create table if not exists board_vectors (
|
|
149
183
|
contribution_id text primary key,
|
|
150
184
|
version text not null,
|
|
@@ -159,6 +193,10 @@ export function openStore(file) {
|
|
|
159
193
|
const boardColumns = new Set(db.all("pragma table_info(board_tasks)").map((column) => column.name));
|
|
160
194
|
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
195
|
if (!db.all("pragma table_info(board_contributions)").some((column) => column.name === "mentions")) db.exec("alter table board_contributions add column mentions text");
|
|
196
|
+
// Devices paired before browsers could pair were all phones.
|
|
197
|
+
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'");
|
|
198
|
+
// A thread whose other side's computer holds a key: everything on it goes sealed to that key.
|
|
199
|
+
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
200
|
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
201
|
if (!boardColumns.has("holder")) {
|
|
164
202
|
db.exec(`alter table board_tasks add column holder text;
|
|
@@ -435,6 +473,9 @@ export function openStore(file) {
|
|
|
435
473
|
setGoalFolder(id, workspace) {
|
|
436
474
|
execute("run", "update board_goals set workspace = ? where goal_id = ?", workspace, id);
|
|
437
475
|
},
|
|
476
|
+
setGoalTeam(id, team) {
|
|
477
|
+
execute("run", "update board_goals set team = ? where goal_id = ?", JSON.stringify([...new Set(team)]), id);
|
|
478
|
+
},
|
|
438
479
|
setGoalSession(id, agent, sessionId) {
|
|
439
480
|
const sessions = { ...(this.goal(id)?.sessions || {}), [agent]: sessionId };
|
|
440
481
|
execute("run", "update board_goals set sessions = ? where goal_id = ?", JSON.stringify(sessions), id);
|
|
@@ -480,6 +521,34 @@ export function openStore(file) {
|
|
|
480
521
|
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
522
|
return running;
|
|
482
523
|
},
|
|
524
|
+
// One coordinator round: what it saw, what it decided and what it cost. The evidence for its limits.
|
|
525
|
+
recordRound({ goalId, coordinator, action, agent, loop, judgments, progress, estimatedMicroUsd, elapsedMs, error }) {
|
|
526
|
+
execute("run", `insert into swarm_rounds(goal_id, at, coordinator, action, agent, loop, judgments, progress, estimated_micro_usd, elapsed_ms, error)
|
|
527
|
+
values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, goalId, new Date().toISOString(), coordinator, action, agent, loop, judgments ? JSON.stringify(judgments) : null,
|
|
528
|
+
JSON.stringify(progress), Number.isFinite(estimatedMicroUsd) ? Math.round(estimatedMicroUsd) : null, Math.round(elapsedMs), error);
|
|
529
|
+
},
|
|
530
|
+
rounds(goalId, limit = 50) {
|
|
531
|
+
const bounded = Math.max(1, Math.min(500, Number(limit) || 50));
|
|
532
|
+
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))
|
|
533
|
+
.map((row) => ({ ...row, judgments: row.judgments ? JSON.parse(row.judgments) : null, progress: JSON.parse(row.progress) }));
|
|
534
|
+
},
|
|
535
|
+
// A thread's runs (wakes and jobs), oldest first, for its transcript.
|
|
536
|
+
threadRuns(goalId) {
|
|
537
|
+
return execute("all", `select id, kind, objective, target_agent, claimed_by, status, holder, result, session_id, run_workspace, usage, created_at, updated_at, reported_at
|
|
538
|
+
from board_tasks where parent_id = ? and kind in ('wake', 'task') order by created_at asc`, goalId)
|
|
539
|
+
.map((row) => ({ ...row, usage: row.usage ? JSON.parse(row.usage) : null }));
|
|
540
|
+
},
|
|
541
|
+
// The coding sessions on this machine, newest first: the person's own and the ones runs started.
|
|
542
|
+
recentSessions(limit = 30) {
|
|
543
|
+
return execute("all", `select agent, session_id, title, cwd, started_at, ended_at, message_count, source from sessions
|
|
544
|
+
order by coalesce(ended_at, started_at, indexed_at) desc limit ?`, Math.max(1, Math.min(100, Number(limit) || 30)))
|
|
545
|
+
.map((row) => ({ ...row, message_count: Number(row.message_count) }));
|
|
546
|
+
},
|
|
547
|
+
// The thread that already carries a session, if any.
|
|
548
|
+
threadForSession(agent, sessionId) {
|
|
549
|
+
const row = execute("all", "select goal_id, sessions from board_goals").find((entry) => JSON.parse(entry.sessions || "{}")[agent] === sessionId);
|
|
550
|
+
return row?.goal_id || null;
|
|
551
|
+
},
|
|
483
552
|
activeGoalsFor(agent) {
|
|
484
553
|
return this.goals().filter((goal) => goal.status === "open" && goal.goal.team.includes(agent));
|
|
485
554
|
},
|
|
@@ -558,6 +627,17 @@ export function openStore(file) {
|
|
|
558
627
|
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
628
|
return { total: Number(row.total), embedded: Number(row.embedded) };
|
|
560
629
|
},
|
|
630
|
+
// The swarm's memory by words: everything written in the person's threads, best first, with the thread it is in.
|
|
631
|
+
searchThreads(query, { goalIds = null, k = 8 } = {}) {
|
|
632
|
+
const terms = [...new Set(String(query).toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [])].filter((term) => term.length > 1).slice(0, 12);
|
|
633
|
+
if (!terms.length) return [];
|
|
634
|
+
const match = terms.map((term) => `"${term.replaceAll('"', '""')}"`).join(" OR ");
|
|
635
|
+
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
|
|
636
|
+
from board_contributions_fts f join board_contributions c on c.id = f.id join board_tasks t on t.id = c.task_id
|
|
637
|
+
where board_contributions_fts match @match and t.kind = 'goal' order by rank limit 200`, { match })
|
|
638
|
+
.filter((row) => !goalIds || goalIds.includes(row.thread)).slice(0, Math.max(1, Math.min(30, k)))
|
|
639
|
+
.map(({ rank, ...row }) => ({ ...row, text: row.text.slice(0, 2_000) }));
|
|
640
|
+
},
|
|
561
641
|
// Words first: full-text candidates for a query on one task, best first.
|
|
562
642
|
searchContextText(taskId, query, k = 8) {
|
|
563
643
|
const terms = [...new Set(String(query).toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [])].filter((term) => term.length > 1).slice(0, 12);
|
|
@@ -573,10 +653,21 @@ export function openStore(file) {
|
|
|
573
653
|
// Outbound messages start held (unless auto-send) or flagged (a secret), become sent when
|
|
574
654
|
// the person releases them, and delivered when the server acknowledges them. Inbound ones
|
|
575
655
|
// 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());
|
|
656
|
+
openThread(taskId, { withAgent, role, peerKey = null }) {
|
|
657
|
+
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
658
|
return this.getThread(taskId);
|
|
579
659
|
},
|
|
660
|
+
// Another person's computer, by handle: the key first seen for it. A different key later is refused until the person trusts it.
|
|
661
|
+
relayPeer(handle) {
|
|
662
|
+
return execute("get", "select handle, public_key, pinned_at from relay_peers where handle = ?", handle) || null;
|
|
663
|
+
},
|
|
664
|
+
pinRelayPeer(handle, publicKey) {
|
|
665
|
+
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());
|
|
666
|
+
return this.relayPeer(handle);
|
|
667
|
+
},
|
|
668
|
+
relayPeers() {
|
|
669
|
+
return execute("all", "select handle, public_key, pinned_at from relay_peers order by pinned_at desc");
|
|
670
|
+
},
|
|
580
671
|
getThread(taskId) {
|
|
581
672
|
const row = execute("get", "select * from board_threads where task_id = ?", taskId);
|
|
582
673
|
return row ? { ...row, auto_send: Number(row.auto_send) === 1 } : null;
|
|
@@ -659,6 +750,38 @@ export function openStore(file) {
|
|
|
659
750
|
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
751
|
.map((row) => ({ ...row, count: Number(row.count) }));
|
|
661
752
|
},
|
|
753
|
+
// Phones (QR code) and browsers (link) paired with this machine. Only the public key is kept; a revoked one is refused at once.
|
|
754
|
+
addPhone({ id, label, publicKey, kind = "phone" }) {
|
|
755
|
+
execute("run", "insert into phone_devices(id, label, public_key, paired_at, kind) values(?, ?, ?, ?, ?)", id, label, publicKey, new Date().toISOString(), kind);
|
|
756
|
+
return this.getPhone(id);
|
|
757
|
+
},
|
|
758
|
+
getPhone(id) {
|
|
759
|
+
return execute("get", "select id, label, public_key, kind, paired_at, last_seen_at, revoked_at from phone_devices where id = ?", id) || null;
|
|
760
|
+
},
|
|
761
|
+
phones() {
|
|
762
|
+
return execute("all", "select id, label, kind, paired_at, last_seen_at, revoked_at from phone_devices order by paired_at desc");
|
|
763
|
+
},
|
|
764
|
+
// Once a browser is paired, Harness reaches the person's board, sessions and runs only sealed.
|
|
765
|
+
sealedOnly() {
|
|
766
|
+
return Boolean(execute("get", "select 1 as paired from phone_devices where kind = 'browser' and revoked_at is null limit 1"));
|
|
767
|
+
},
|
|
768
|
+
// A request id is accepted once per phone, across restarts; ids older than the queue's 7 days are forgotten.
|
|
769
|
+
firstPhoneRequest(deviceId, requestId) {
|
|
770
|
+
const now = new Date();
|
|
771
|
+
execute("run", "delete from phone_requests where received_at < ?", new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000).toISOString());
|
|
772
|
+
if (execute("get", "select 1 as seen from phone_requests where device_id = ? and request_id = ?", deviceId, requestId)) return false;
|
|
773
|
+
execute("run", "insert into phone_requests(device_id, request_id, received_at) values(?, ?, ?)", deviceId, requestId, now.toISOString());
|
|
774
|
+
return true;
|
|
775
|
+
},
|
|
776
|
+
touchPhone(id) {
|
|
777
|
+
execute("run", "update phone_devices set last_seen_at = ? where id = ?", new Date().toISOString(), id);
|
|
778
|
+
},
|
|
779
|
+
revokePhone(id) {
|
|
780
|
+
const phone = this.getPhone(id);
|
|
781
|
+
if (!phone) return { ok: false, error: "no phone with that id" };
|
|
782
|
+
if (!phone.revoked_at) execute("run", "update phone_devices set revoked_at = ? where id = ?", new Date().toISOString(), id);
|
|
783
|
+
return { ok: true, phone: this.getPhone(id) };
|
|
784
|
+
},
|
|
662
785
|
touchAgent(agent) {
|
|
663
786
|
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
787
|
},
|