tryharness 0.4.3 → 0.5.1

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/daemon.mjs CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import net from "node:net";
3
3
  import path from "node:path";
4
4
  import { spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
5
6
  import WebSocket from "ws";
6
7
  import { randomBytes, randomUUID } from "node:crypto";
7
8
  import { detectAgents, run } from "./commands.mjs";
@@ -10,16 +11,37 @@ import { parseSessionFile } from "./parsers.mjs";
10
11
  import { historyRoots, paths } from "./paths.mjs";
11
12
  import { openStore } from "./store.mjs";
12
13
  import { runAgentTask } from "./runner.mjs";
14
+ import { notificationFor, notifyDesktop } from "./notify.mjs";
15
+ import { secretReason } from "./secrets.mjs";
16
+ import { provenanceFor, provenanceLine } from "./provenance.mjs";
17
+ import { createEmbedder, EMBEDDING_VERSION, modelPresent } from "./embedder.mjs";
18
+ import { fromBlob, toBlob, topK } from "./vectors.mjs";
13
19
 
14
- const DAEMON_VERSION = "0.4.3";
20
+ const DAEMON_VERSION = readJson(fileURLToPath(new URL("../package.json", import.meta.url)), {}).version;
15
21
  const PROTOCOL_VERSION = 1;
16
22
  const RESCAN_MS = 60_000;
17
23
  const TASK_LOG_PAGE = 64_000;
18
24
 
25
+ function daemonAnswers(socketPath) {
26
+ return new Promise((resolve) => {
27
+ const probe = net.createConnection(socketPath);
28
+ probe.once("connect", () => { probe.destroy(); resolve(true); });
29
+ probe.once("error", () => resolve(false));
30
+ });
31
+ }
32
+
19
33
  export async function startDaemon() {
20
34
  ensureDir(paths.home);
21
35
  const config = readJson(paths.config, {});
22
36
  if (!config.origin || !config.token) throw new Error("Harness is not paired. Run: npx tryharness");
37
+ // node-sqlite3-wasm locks with a directory that exists only during a transaction. A daemon
38
+ // killed mid-transaction leaves it behind and every later open fails "database is locked";
39
+ // with no daemon answering, nothing can hold it.
40
+ const lock = `${paths.database}.lock`;
41
+ if (fs.existsSync(lock) && !(await daemonAnswers(paths.socket))) {
42
+ fs.rmdirSync(lock);
43
+ appendLog(paths.log, "removed a stale database lock left by a stopped daemon");
44
+ }
23
45
  const store = openStore(paths.database);
24
46
  const detected = detectAgents();
25
47
  const installedAgents = detected.filter((entry) => entry.path).map((entry) => entry.agent);
@@ -226,21 +248,101 @@ export async function startDaemon() {
226
248
  try { if (fs.existsSync(paths.socket)) fs.unlinkSync(paths.socket); } catch {}
227
249
  }
228
250
  let socket = null;
251
+ const relayWaiters = new Map();
229
252
  const send = (message) => {
230
253
  try { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); } catch {}
231
254
  };
255
+ // A question for Harness over the bridge, answered by its bridge_response. Rejects when the
256
+ // daemon is not connected or Harness does not answer in time.
257
+ const askHarness = (type, input, timeoutMs = 15_000) => new Promise((resolve, reject) => {
258
+ if (!state.connected) { reject(new Error("the daemon is not connected to Harness")); return; }
259
+ const requestId = randomUUID();
260
+ const timer = setTimeout(() => { relayWaiters.delete(requestId); reject(new Error("Harness did not answer; is the daemon connected?")); }, timeoutMs);
261
+ relayWaiters.set(requestId, (response) => { clearTimeout(timer); resolve(response); });
262
+ send({ type, requestId, input });
263
+ });
264
+ // Agents that live on the server, not on any machine: a task for one of them goes up to
265
+ // Harness and lives there; reading or answering it comes back the same way.
266
+ const CLOUD_TARGETS = new Set(["bankr", "quotient", "muse"]);
267
+ const forwardToHarness = (request) => ({ pending: askHarness("board_call", { ...request, agent: String(request.agent) }).then((response) => (response.ok ? response : Promise.reject(new Error(response.error || "Harness refused the request")))) });
232
268
 
233
269
  // Board callers identify themselves by agent name (the stdio MCP passes --agent; the CLI is
234
270
  // "user"; the server names hosted callers). The name is an identity label for attribution
235
271
  // and the holder check, not an execution grant: a run needs the user's authorization.
236
- const validAgent = (value) => /^[a-z0-9][a-z0-9._-]{0,63}$/.test(String(value || ""));
272
+ // One slash admits another person's agent, handle first: "ana/claude-code".
273
+ const validAgent = (value) => /^[a-z0-9][a-z0-9._-]{0,63}(\/[a-z0-9][a-z0-9._-]{0,63})?$/.test(String(value || ""));
274
+ // A thread's outbound leg: a message the person released (or auto-send let through) goes up
275
+ // the bridge; the server acknowledges it once the other side has it. Unacknowledged
276
+ // messages go again on reconnect, so a dropped socket never loses a reply.
277
+ const sendThreadMessage = (message) => send({ type: "thread_message", taskId: message.task_id, messageId: message.id, seq: message.seq, to: message.recipient, text: message.text, refs: message.refs, sentAt: message.sent_at });
237
278
  // Every change to the board goes up the bridge as identity and status; the server mirrors it
238
279
  // and the PiP follows from there. Briefs and results never ride this message.
280
+ // The person is told on their desktop about what waits on them, whether or not Harness is
281
+ // open: a run an agent asked for, a question, an unknown outcome, work posted for Harness, a
282
+ // claim gone stale. Once per change; off with `tryharness notify off`.
283
+ const lastSeen = new Map();
284
+ const notificationsOn = () => readJson(paths.config, {}).notifications !== false;
285
+ const notifyPerson = (task, previous) => {
286
+ if (!notificationsOn()) return;
287
+ const body = notificationFor(task, { previous });
288
+ if (body) notifyDesktop(body);
289
+ };
239
290
  const notifyBoard = (task) => {
240
291
  if (!task) return;
241
- send({ type: "board_changed", task: { id: task.id, status: task.status, holder: task.holder, version: task.version, origin_agent: task.origin_agent, target_agent: task.target_agent, claimed_by: task.claimed_by, session_id: task.session_id || null, updated_at: task.updated_at } });
292
+ send({ type: "board_changed", task: { id: task.id, status: task.status, holder: task.holder, version: task.version, origin_agent: task.origin_agent, target_agent: task.target_agent, claimed_by: task.claimed_by, session_id: task.session_id || null, parent_id: task.parent_id || null, kind: task.kind || "task", updated_at: task.updated_at } });
293
+ notifyPerson(task, lastSeen.get(task.id) || null);
294
+ lastSeen.set(task.id, { status: task.status, run_workspace: task.run_workspace, run_authorized_at: task.run_authorized_at });
295
+ // The last part to finish closes its group, which is then announced too.
296
+ if (task.parent_id) { const group = store.refreshGroup(task.parent_id); if (group) notifyBoard(group); }
242
297
  setImmediate(runBoard);
243
298
  };
299
+ const staleTold = new Set();
300
+ const staleCheck = setInterval(() => {
301
+ if (!notificationsOn()) return;
302
+ for (const task of store.listTasks({ all: false, limit: 100 })) {
303
+ if (task.stale && !staleTold.has(task.id)) { staleTold.add(task.id); notifyDesktop(`${task.claimed_by}'s claim went stale on: ${String(task.objective).slice(0, 80)}. Release with tryharness board release ${String(task.id).slice(0, 8)}`); }
304
+ if (!task.stale) staleTold.delete(task.id);
305
+ }
306
+ }, 60_000);
307
+ // The context space's embedder: the model setup downloaded, loaded lazily, run in the
308
+ // background one contribution at a time so no request waits on it. Until the model is
309
+ // present, search answers from words and says so.
310
+ let embedder = null;
311
+ let embedding = false;
312
+ const embedderReady = () => modelPresent(paths.models);
313
+ const embedPending = async () => {
314
+ if (embedding || !embedderReady()) return;
315
+ embedding = true;
316
+ try {
317
+ embedder ||= createEmbedder({ modelsDir: paths.models });
318
+ for (const contribution of store.contributionsToEmbed(EMBEDDING_VERSION, 5)) {
319
+ const [vector] = await embedder.embedDocuments([contribution.text]);
320
+ store.storeVector(contribution.id, EMBEDDING_VERSION, toBlob(vector));
321
+ }
322
+ } catch (error) {
323
+ log(`embedding failed: ${error.message}`);
324
+ } finally {
325
+ embedding = false;
326
+ }
327
+ };
328
+ const embedTimer = setInterval(() => void embedPending(), 2_000);
329
+ const searchContext = async (taskId, query, k) => {
330
+ const text = store.searchContextText(taskId, query, k);
331
+ const context = store.contextCount(taskId);
332
+ if (!embedderReady()) return { retrieval: "text", embedder: "absent", context, entries: text.map(({ rank, ...entry }) => ({ ...entry, score: Number(entry.score.toFixed(3)) })) };
333
+ embedder ||= createEmbedder({ modelsDir: paths.models });
334
+ const queryVector = await embedder.embedQuery(query);
335
+ const rows = store.vectorsFor(taskId, EMBEDDING_VERSION).map((row) => ({ id: row.id, vector: fromBlob(row.vector) }));
336
+ const best = topK(queryVector, rows, k);
337
+ const byId = new Map(best.map((hit) => [hit.id, hit.score]));
338
+ // Words still count: a text hit with no vector yet rides along below the vector hits.
339
+ for (const hit of text) if (!byId.has(hit.id)) byId.set(hit.id, 0);
340
+ const entries = [...byId.entries()].map(([id, score]) => ({ ...store.getContribution(id), score: Number(score.toFixed(3)) })).filter((entry) => entry.id).sort((a, b) => b.score - a.score).slice(0, k);
341
+ return { retrieval: rows.length ? "vectors" : "text", embedder: "ready", context, entries: entries.map(({ seq, ...entry }) => entry) };
342
+ };
343
+ // Tasks written before the context space existed get their brief and result as entries.
344
+ for (const id of store.backfillContributions()) log(`context: backfilled task ${id}`);
345
+
244
346
  const boardCommand = (request) => {
245
347
  if (!validAgent(request.agent)) return { ok: false, error: "board requests need an agent name" };
246
348
  const agent = String(request.agent);
@@ -252,22 +354,92 @@ export async function startDaemon() {
252
354
  const objective = String(request.objective || "").trim();
253
355
  if (!objective) return { ok: false, error: "a task needs an objective" };
254
356
  if (request.targetAgent != null && !validAgent(request.targetAgent)) return { ok: false, error: "invalid target agent" };
357
+ // Provenance is code's: the local parent's label and what Harness sent from above the poster can only tighten it.
358
+ const parentTask = request.parentId ? store.getTask(String(request.parentId)) : null;
359
+ const claimed = request.provenance && typeof request.provenance === "object" && request.provenance.trust === "agent" ? request.provenance : null;
360
+ const upstream = parentTask ? { trust: claimed ? "agent" : parentTask.trust, chain: parentTask.origin_chain } : claimed;
361
+ const provenance = provenanceFor(agent, upstream);
362
+ if (CLOUD_TARGETS.has(String(request.targetAgent || "")) && !request.thread) {
363
+ if (!state.connected) return { ok: false, error: `${request.targetAgent} lives in Harness, and the daemon is not connected to it right now` };
364
+ return forwardToHarness({ ...request, provenance: upstream });
365
+ }
255
366
  const runWorkspace = request.runWorkspace ? String(request.runWorkspace) : null;
256
367
  if (runWorkspace && !path.isAbsolute(runWorkspace)) return { ok: false, error: "a run needs an absolute workspace directory" };
257
- if (runWorkspace && !request.targetAgent) return { ok: false, error: "a run needs a target agent" };
368
+ if (runWorkspace && !request.targetAgent && !(Array.isArray(request.targetAgents) && request.targetAgents.length)) return { ok: false, error: "a run needs a target agent" };
258
369
  const refs = Array.isArray(request.contextRefs) ? request.contextRefs.slice(0, 50) : [];
259
370
  // The server may name the task (its run id) so the run record, the session layer and the board agree.
260
371
  if (request.id != null && !validRunId(request.id)) return { ok: false, error: "invalid task id" };
261
372
  if (request.id != null && store.getTask(String(request.id))) return { ok: false, error: "a task with this id already exists", task: store.getTask(String(request.id)) };
373
+ const thread = request.thread && typeof request.thread === "object" ? request.thread : null;
374
+ if (thread && (!validAgent(thread.with) || !["asker", "target"].includes(thread.role))) return { ok: false, error: "a thread needs the other side's agent and a role" };
375
+ // Several agents at once: one group task with a part per agent.
376
+ const targetAgents = Array.isArray(request.targetAgents) ? [...new Set(request.targetAgents.map(String))] : null;
377
+ if (targetAgents) {
378
+ if (thread) return { ok: false, error: "a thread with another person has one target" };
379
+ if (!targetAgents.length || targetAgents.length > 8 || !targetAgents.every(validAgent)) return { ok: false, error: "target_agents must be one to eight agent names" };
380
+ if (targetAgents.length === 1) request.targetAgent = targetAgents[0];
381
+ else {
382
+ const posted = store.postGroup({ ...(request.id != null ? { id: String(request.id) } : {}), objective: objective.slice(0, 500), brief: String(request.brief || "").slice(0, 200_000), originAgent: agent, provenance,
383
+ targetAgents, contextRefs: refs, runWorkspace, runAuthorizedBy: runWorkspace && agent === "user" ? "user" : null });
384
+ for (const part of posted.children) notifyBoard(store.getTask(part.id));
385
+ notifyBoard(posted.group);
386
+ return { ok: true, task: posted.group };
387
+ }
388
+ }
262
389
  const task = store.postTask({ id: request.id != null ? String(request.id) : randomUUID(), objective: objective.slice(0, 500), brief: String(request.brief || "").slice(0, 200_000),
263
- originAgent: agent, targetAgent: request.targetAgent ? String(request.targetAgent) : null, parentId: request.parentId ? String(request.parentId) : null, contextRefs: refs,
264
- runWorkspace, runAuthorizedBy: runWorkspace && agent === "user" ? "user" : null });
265
- outcome = { ok: true, task };
266
- } else if (request.command === "board.list") return { ok: true, tasks: store.listTasks({ agent, status: request.status ? String(request.status) : undefined, all: request.all === true, limit: request.limit }) };
390
+ originAgent: agent, provenance, targetAgent: request.targetAgent ? String(request.targetAgent) : null, parentId: request.parentId ? String(request.parentId) : null, contextRefs: refs,
391
+ runWorkspace: thread ? null : runWorkspace, runAuthorizedBy: !thread && runWorkspace && agent === "user" ? "user" : null });
392
+ if (thread) store.openThread(task.id, { withAgent: String(thread.with), role: thread.role });
393
+ outcome = { ok: true, task: store.getTask(task.id), thread: thread ? store.getThread(task.id) : null };
394
+ } else if (request.command === "board.list") {
395
+ const tasks = store.listTasks({ agent, status: request.status ? String(request.status) : undefined, all: request.all === true, limit: request.limit });
396
+ return request.unread ? { ok: true, tasks, unread: store.unreadFor(agent) } : { ok: true, tasks };
397
+ } else if (request.command === "board.context.add") {
398
+ if (!store.getTask(id)) return { ok: false, error: "task not found" };
399
+ const kind = String(request.kind || "note");
400
+ if (!["note", "log", "link", "file", "repro"].includes(kind)) return { ok: false, error: "kind must be note, log, link, file or repro" };
401
+ const entry = store.addContribution({ taskId: id, kind, author: agent, text: String(request.text || "").slice(0, 20_000), source: request.source ? String(request.source).slice(0, 1_024) : null });
402
+ return entry ? { ok: true, entry, context: store.contextCount(id) } : { ok: false, error: "nothing to add" };
403
+ } else if (request.command === "board.context.search") {
404
+ if (!store.getTask(id)) return { ok: false, error: "task not found" };
405
+ return { ok: true, pending: searchContext(id, String(request.query || ""), Math.max(1, Math.min(20, Number(request.limit) || 8))) };
406
+ } else if (request.command === "board.thread.read") {
407
+ const thread = store.getThread(id);
408
+ return thread ? { ok: true, thread, task: store.getTask(id), messages: store.threadMessages(id) } : { ok: false, error: "no thread on this task" };
409
+ } else if (request.command === "board.thread.post") {
410
+ const text = String(request.text || "").slice(0, 50_000);
411
+ if (!text.trim()) return { ok: false, error: "a message needs text" };
412
+ const refs = (Array.isArray(request.refs) ? request.refs : []).filter((ref) => ref && ["log", "repro", "link", "file"].includes(ref.kind) && typeof ref.ref === "string").slice(0, 20);
413
+ const flagReason = secretReason(text) || secretReason(JSON.stringify(refs));
414
+ outcome = store.postMessage({ id: randomUUID(), taskId: id, sender: agent, text, refs, flagReason });
415
+ if (outcome.ok && flagReason) store.revertAutoSend(id);
416
+ if (outcome.ok && outcome.message.state === "sent") sendThreadMessage(outcome.message);
417
+ if (outcome.ok) { outcome = { ...outcome, task: store.getTask(id) }; if (flagReason) notifyDesktop(`A reply to ${outcome.message.recipient} was held: it looks like ${flagReason}. Review it in Harness or tryharness board show ${id.slice(0, 8)}`); else if (outcome.message.state === "held") notifyDesktop(`${agent} drafted a reply to ${outcome.message.recipient}; send it from Harness or tryharness board send ${id.slice(0, 8)}`); }
418
+ } else if (request.command === "board.thread.send" || request.command === "board.thread.discard") {
419
+ if (agent !== "user") return { ok: false, error: "only the user sends or discards a held reply" };
420
+ const messageId = String(request.messageId || "");
421
+ if (request.command === "board.thread.discard") { outcome = store.discardMessage(messageId); if (outcome.ok) outcome = { ...outcome, task: store.getTask(id) }; }
422
+ else { outcome = store.releaseMessage(messageId); if (outcome.ok) { sendThreadMessage(outcome.message); outcome = { ...outcome, task: store.getTask(id) }; } }
423
+ } else if (request.command === "board.thread.auto") {
424
+ if (agent !== "user") return { ok: false, error: "only the user changes auto-send" };
425
+ outcome = store.setAutoSend(id, request.on === true);
426
+ if (outcome.ok) outcome = { ...outcome, task: store.getTask(id) };
427
+ } else if (request.command === "board.thread.end") {
428
+ const task = store.getTask(id);
429
+ if (!task) return { ok: false, error: "task not found" };
430
+ if (agent !== "user" && task.claimed_by !== agent) return { ok: false, error: "only the user or the agent holding the task ends its thread" };
431
+ outcome = store.closeThread(id, String(request.outcome || "").slice(0, 20_000) || null);
432
+ if (outcome.ok && !outcome.already) send({ type: "thread_end", taskId: id, by: agent, outcome: outcome.thread.outcome });
433
+ }
267
434
  else if (request.command === "board.get") {
268
435
  const task = store.getTask(id);
269
- return task ? { ok: true, task } : { ok: false, error: "task not found" };
270
- } else if (request.command === "board.agents") return { ok: true, agents: store.agentsSeen(), running: [...activeRuns.entries()].map(([runAgent, taskId]) => ({ agent: runAgent, id: taskId })) };
436
+ if (task) return { ok: true, task };
437
+ return state.connected ? forwardToHarness(request) : { ok: false, error: "task not found" };
438
+ } else if (request.command === "board.children") return { ok: true, tasks: store.children(id).map((child) => store.getTask(child.id)).filter(Boolean) };
439
+ // A trace reads both homes, and only Harness can: the machine asks it.
440
+ else if (request.command === "board.trace") return state.connected ? forwardToHarness(request) : { ok: false, error: "a trace reads the whole chain on this machine and in Harness, and the daemon is not connected to Harness right now" };
441
+ else if (request.command === "board.agents") return { ok: true, agents: store.agentsSeen(), running: [...activeRuns.entries()].map(([runAgent, taskId]) => ({ agent: runAgent, id: taskId })) };
442
+ else if (["board.claim", "board.report", "board.answer", "board.release"].includes(request.command) && !store.getTask(id) && state.connected) return forwardToHarness(request);
271
443
  else if (request.command === "board.claim") outcome = store.claimTask(id, agent, expected, Number.isInteger(request.leaseMinutes) ? request.leaseMinutes : 60);
272
444
  else if (request.command === "board.report") outcome = store.reportTask(id, agent, { status: String(request.status || ""), result: String(request.result || "").slice(0, 200_000) }, expected);
273
445
  else if (request.command === "board.answer") outcome = store.answerTask(id, agent, String(request.answer || "").slice(0, 20_000), expected);
@@ -295,16 +467,17 @@ export async function startDaemon() {
295
467
  activeRuns.set(agent, task.id);
296
468
  notifyBoard(started.task);
297
469
  const refs = task.context_refs.length ? "\n\nContext:\n" + task.context_refs.map((ref) => `- ${ref.kind}: ${ref.ref}${ref.note ? ` (${ref.note})` : ""}`).join("\n") : "";
298
- const brief = `${task.objective}\n\n${task.brief}${refs}`.trim();
470
+ const notice = provenanceLine(task);
471
+ const brief = `${notice ? `${notice}\n\n` : ""}${task.objective}\n\n${task.brief}${refs}`.trim();
299
472
  startTask({ runId: task.id, agent, title: task.objective.slice(0, 120), brief, cwd: task.run_workspace }, (kind, text, extra) => {
300
473
  if (kind === "session" && text) { store.setRunSession(task.id, text); notifyBoard(store.getTask(task.id)); }
301
474
  if (kind === "done" || kind === "failed") {
302
475
  activeRuns.delete(agent);
476
+ if (extra?.usage) store.setRunUsage(task.id, extra.usage);
303
477
  const reported = store.reportTask(task.id, agent, { status: kind, result: text || "" });
304
478
  if (reported.ok) notifyBoard(reported.task);
305
479
  else log(`board task ${task.id} finished ${kind} but could not be reported: ${reported.error}`);
306
480
  }
307
- void extra;
308
481
  });
309
482
  }
310
483
  };
@@ -318,7 +491,9 @@ export async function startDaemon() {
318
491
  if (newline < 0) return;
319
492
  let request;
320
493
  try { request = JSON.parse(buffer.slice(0, newline)); }
321
- catch { socket.end(JSON.stringify({ ok: false, error: "invalid request" }) + "\n"); return; }
494
+ catch { request = null; }
495
+ // Anything but one JSON object is refused here; nothing a local process writes can reach a property read on it.
496
+ if (!request || typeof request !== "object" || Array.isArray(request)) { socket.end(JSON.stringify({ ok: false, error: "invalid request" }) + "\n"); return; }
322
497
  const reply = (value) => socket.end(JSON.stringify(value) + "\n");
323
498
  if (request.command === "status") reply({ ok: true, ...state, counts: store.counts() });
324
499
  else if (request.command === "doctor") reply({ ok: true, status: state, database: paths.database, socket: paths.socket, log: paths.log });
@@ -326,8 +501,21 @@ export async function startDaemon() {
326
501
  if (request.agent === "opencode" && request.sessionId) targetedOpencode.push(request);
327
502
  void sync(request.agent && request.agent !== "all" ? request.agent : null);
328
503
  reply({ ok: true, accepted: true, indexing: true });
329
- } else if (String(request.command || "").startsWith("board.")) reply(boardCommand(request));
330
- else if (request.command === "tasks.get") {
504
+ } else if (String(request.command || "").startsWith("board.")) {
505
+ // A bad request never takes the daemon down: it is answered as a refusal.
506
+ let result;
507
+ try { result = boardCommand(request); } catch (error) { log(`board ${request.command}: ${error.message}`); result = { ok: false, error: `the board could not answer: ${error.message}` }; }
508
+ if (result?.pending) result.pending.then((value) => reply({ ok: true, ...value }), (error) => reply({ ok: false, error: error.message }));
509
+ else reply(result);
510
+ }
511
+ else if (request.command === "relay.request") {
512
+ const requestId = randomUUID();
513
+ const timer = setTimeout(() => { relayWaiters.delete(requestId); reply({ ok: false, error: "Harness did not answer the request; is the daemon connected?" }); }, 15_000);
514
+ relayWaiters.set(requestId, (response) => { clearTimeout(timer); reply(response); });
515
+ // `socket` here is the control connection; the bridge's state is what matters.
516
+ if (!state.connected) { clearTimeout(timer); relayWaiters.delete(requestId); reply({ ok: false, error: "the daemon is not connected to Harness" }); }
517
+ else send({ type: "relay_request", requestId, input: { to: String(request.to || ""), fromAgent: String(request.fromAgent || ""), about: String(request.about || "").slice(0, 500), brief: String(request.brief || "").slice(0, 200_000), refs: Array.isArray(request.refs) ? request.refs.slice(0, 20) : [] } });
518
+ } else if (request.command === "tasks.get") {
331
519
  const task = readTask(request.runId, request.offset);
332
520
  reply(task ? { ok: true, task } : { ok: false, error: "task not found" });
333
521
  } else if (request.command === "shutdown") {
@@ -385,6 +573,7 @@ export async function startDaemon() {
385
573
  task.status = kind;
386
574
  task.finishedAt = new Date().toISOString();
387
575
  task.message = text || null;
576
+ if (extra?.usage) task.usage = extra.usage;
388
577
  }
389
578
  task.updatedAt = new Date().toISOString();
390
579
  persistTask(task);
@@ -413,7 +602,8 @@ export async function startDaemon() {
413
602
  state.connected = true;
414
603
  state.lastError = null;
415
604
  saveStatus();
416
- send({ type: "hello", protocolVersion: PROTOCOL_VERSION, daemonVersion: DAEMON_VERSION, agents: installedAgents, health: state.agents, capabilities: ["sessions.search", "sessions.read", "tasks.status", "board"] });
605
+ send({ type: "hello", protocolVersion: PROTOCOL_VERSION, daemonVersion: DAEMON_VERSION, agents: installedAgents, health: state.agents, capabilities: ["sessions.search", "sessions.read", "tasks.status", "board", "threads"] });
606
+ for (const message of store.undeliveredMessages()) sendThreadMessage(message);
417
607
  });
418
608
  socket.on("message", (data) => {
419
609
  let message;
@@ -440,10 +630,37 @@ export async function startDaemon() {
440
630
  send({ type: "bridge_response", requestId: message.requestId, ok: true, result: { tasks: runIds.map(taskStatus).filter(Boolean) } });
441
631
  return;
442
632
  }
633
+ if (message.type === "thread_message") {
634
+ const input = message.input || {};
635
+ const received = store.receiveMessage({ id: String(input.messageId || ""), taskId: String(input.taskId || ""), seq: input.seq, sender: String(input.from || "someone"), text: String(input.text || "").slice(0, 50_000), refs: Array.isArray(input.refs) ? input.refs : [], sentAt: input.sentAt });
636
+ send({ type: "bridge_response", requestId: message.requestId, ok: received.ok, result: received.ok ? { acknowledged: input.messageId } : null, ...(received.ok ? {} : { error: received.error }) });
637
+ if (received.ok && !received.duplicate) {
638
+ const task = store.getTask(String(input.taskId));
639
+ notifyBoard(task);
640
+ if (notificationsOn()) notifyDesktop(`${input.from} replied on: ${String(task?.objective || "").slice(0, 80)}`);
641
+ }
642
+ return;
643
+ }
644
+ if (message.type === "thread_ack") { store.markDelivered(String(message.messageId || "")); return; }
645
+ if (message.type === "bridge_response" && relayWaiters.has(message.requestId)) {
646
+ relayWaiters.get(message.requestId)(message.ok ? { ok: true, ...(message.result || {}) } : { ok: false, error: message.error || "Harness refused the request" });
647
+ relayWaiters.delete(message.requestId);
648
+ return;
649
+ }
650
+ if (message.type === "thread_end") {
651
+ const input = message.input || {};
652
+ const closed = store.closeThread(String(input.taskId || ""), String(input.outcome || "").slice(0, 20_000) || null);
653
+ send({ type: "bridge_response", requestId: message.requestId, ok: closed.ok, result: closed.ok ? { closed: input.taskId } : null });
654
+ if (closed.ok && !closed.already) { notifyBoard(closed.task); if (notificationsOn()) notifyDesktop(`${input.by} ended the thread on: ${String(closed.task?.objective || "").slice(0, 80)}`); }
655
+ return;
656
+ }
443
657
  if (message.type === "board") {
444
658
  // A hosted caller's board call, identified by the name Harness assigns it. The same
445
659
  // checks as the local control socket apply; the server persists nothing of it.
446
- send({ type: "bridge_response", requestId: message.requestId, ok: true, result: boardCommand({ ...(message.input || {}), command: String(message.input?.command || "") }) });
660
+ let result;
661
+ try { result = boardCommand({ ...(message.input || {}), command: String(message.input?.command || "") }); } catch (error) { result = { ok: false, error: `the board could not answer: ${error.message}` }; }
662
+ if (result?.pending) result.pending.then((value) => send({ type: "bridge_response", requestId: message.requestId, ok: true, result: { ok: true, ...value } }), (error) => send({ type: "bridge_response", requestId: message.requestId, ok: true, result: { ok: false, error: error.message } }));
663
+ else send({ type: "bridge_response", requestId: message.requestId, ok: true, result });
447
664
  return;
448
665
  }
449
666
 
@@ -484,6 +701,8 @@ export async function startDaemon() {
484
701
 
485
702
  const shutdown = () => {
486
703
  clearInterval(heartbeat);
704
+ clearInterval(staleCheck);
705
+ clearInterval(embedTimer);
487
706
  clearInterval(rescan);
488
707
  for (const watcher of watchers) watcher.close();
489
708
  try { socket?.close(); } catch {}
@@ -0,0 +1,156 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { ensureDir } from "./files.mjs";
4
+
5
+ // The daemon's own embedder for the context space: the same model, quantization, pooling
6
+ // and normalization as the browser (src/shared/memory/embeddingService.ts), so the two
7
+ // sides describe the same space under the same version key. It runs on onnxruntime-web's
8
+ // Node build (wasm, no native addon) with the same tokenizer library transformers.js uses,
9
+ // and mirrors transformers' feature-extraction pipeline step by step: tokenize with special
10
+ // tokens, truncate to the model's 512, pad to the batch, take the [CLS] vector, L2-normalize.
11
+ // Weights are downloaded into the Harness home during setup, never fetched from a browser.
12
+
13
+ export const EMBEDDING_MODEL = "Xenova/bge-base-en-v1.5";
14
+ export const EMBEDDING_DIMENSIONS = 768;
15
+ export const EMBEDDING_VERSION = "bge-base-en-v1.5:q8:cls:l2:asymmetric:v1";
16
+ const QUERY_PREFIX = "Represent this sentence for searching relevant passages: ";
17
+ const MODEL_FILES = ["config.json", "tokenizer.json", "tokenizer_config.json", "onnx/model_quantized.onnx"];
18
+ const REMOTE = `https://huggingface.co/${EMBEDDING_MODEL}/resolve/main/`;
19
+ // One text per run: a q8 model quantizes per batch tensor, so the same text in different company
20
+ // differs in the last decimals (transformers.js shows the same, cosine 0.992 alone versus batched).
21
+ // Entries arrive one at a time and a vector must not depend on its neighbours, so no batching.
22
+ const BATCH = 1;
23
+
24
+ export function modelDirectory(modelsDir) {
25
+ return path.join(modelsDir, ...EMBEDDING_MODEL.split("/"));
26
+ }
27
+
28
+ /** True when every model file is present and non-empty. */
29
+ export function modelPresent(modelsDir) {
30
+ const dir = modelDirectory(modelsDir);
31
+ return MODEL_FILES.every((file) => {
32
+ try { return fs.statSync(path.join(dir, file)).size > 0; } catch { return false; }
33
+ });
34
+ }
35
+
36
+ async function download(url, target, { fetchImpl, log }) {
37
+ const partial = `${target}.partial`;
38
+ ensureDir(path.dirname(target));
39
+ let have = 0;
40
+ try { have = fs.statSync(partial).size; } catch {}
41
+ const headers = have > 0 ? { range: `bytes=${have}-` } : {};
42
+ const response = await fetchImpl(url, { headers, redirect: "follow" });
43
+ if (response.status === 416) { fs.renameSync(partial, target); return; }
44
+ if (!response.ok) throw new Error(`download of ${path.basename(target)} failed: HTTP ${response.status}`);
45
+ const resumed = response.status === 206;
46
+ const total = Number(response.headers.get("content-length") || 0) + (resumed ? have : 0);
47
+ const out = fs.createWriteStream(partial, { flags: resumed ? "a" : "w" });
48
+ let done = resumed ? have : 0;
49
+ let shown = -1;
50
+ const name = path.basename(target);
51
+ for await (const chunk of response.body) {
52
+ if (!out.write(chunk)) await new Promise((resolve) => out.once("drain", resolve));
53
+ done += chunk.length;
54
+ const percent = total ? Math.floor((done / total) * 10) * 10 : -1;
55
+ if (percent !== shown && percent >= 0) { shown = percent; log?.(`embedder: ${name} ${percent}%`); }
56
+ }
57
+ await new Promise((resolve, reject) => { out.end(); out.on("finish", resolve); out.on("error", reject); });
58
+ if (total && done !== total) throw new Error(`download of ${name} stopped at ${done} of ${total} bytes; run setup again to resume`);
59
+ fs.renameSync(partial, target);
60
+ }
61
+
62
+ /** Downloads any missing model file into modelsDir. Idempotent; a partial file resumes. */
63
+ export async function ensureModel({ modelsDir, log = () => {}, fetchImpl = fetch } = {}) {
64
+ const dir = modelDirectory(modelsDir);
65
+ const missing = MODEL_FILES.filter((file) => { try { return fs.statSync(path.join(dir, file)).size === 0; } catch { return true; } });
66
+ if (!missing.length) return { downloaded: [], present: true };
67
+ log(`embedder: downloading ${EMBEDDING_MODEL} (${missing.length} file${missing.length === 1 ? "" : "s"}, about 110 MB) into ${modelsDir}`);
68
+ for (const file of missing) await download(REMOTE + file, path.join(dir, file), { fetchImpl, log });
69
+ log("embedder: ready");
70
+ return { downloaded: missing, present: true };
71
+ }
72
+
73
+ function truncate(encoded, maxLength) {
74
+ for (const key of Object.keys(encoded)) encoded[key] = encoded[key].slice(0, maxLength);
75
+ }
76
+
77
+ function pad(encoded, maxLength, padId) {
78
+ for (const key of Object.keys(encoded)) {
79
+ const fill = key === "input_ids" ? padId : 0;
80
+ while (encoded[key].length < maxLength) encoded[key].push(fill);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * A lazily loaded embedder over local files only. Loading the session takes about half a
86
+ * second and inference runs on the calling thread (onnxruntime-web's Node build has no
87
+ * worker), so each short text holds the event loop for a few tens of milliseconds;
88
+ * callers embed in the background, never inside a request the person is waiting on.
89
+ */
90
+ export function createEmbedder({ modelsDir }) {
91
+ const dir = modelDirectory(modelsDir);
92
+ let loading = null;
93
+ const load = () => {
94
+ if (loading) return loading;
95
+ loading = (async () => {
96
+ if (!modelPresent(modelsDir)) throw new Error(`the embedding model is not present in ${modelsDir}; run npx tryharness to download it`);
97
+ const [{ Tokenizer }, ort] = await Promise.all([import("@huggingface/tokenizers"), import("onnxruntime-web")]);
98
+ const tokenizerJson = JSON.parse(fs.readFileSync(path.join(dir, "tokenizer.json"), "utf8"));
99
+ const tokenizerConfig = JSON.parse(fs.readFileSync(path.join(dir, "tokenizer_config.json"), "utf8"));
100
+ const modelConfig = JSON.parse(fs.readFileSync(path.join(dir, "config.json"), "utf8"));
101
+ const tokenizer = new Tokenizer(tokenizerJson, tokenizerConfig);
102
+ ort.env.wasm.numThreads = 1;
103
+ const session = await ort.InferenceSession.create(path.join(dir, "onnx", "model_quantized.onnx"), { executionProviders: ["wasm"] });
104
+ return {
105
+ ort, tokenizer, session,
106
+ maxLength: Number(tokenizerConfig.model_max_length) || 512,
107
+ padId: Number(modelConfig.pad_token_id ?? 0),
108
+ wantsTypeIds: session.inputNames.includes("token_type_ids"),
109
+ };
110
+ })();
111
+ loading.catch(() => { loading = null; });
112
+ return loading;
113
+ };
114
+
115
+ const embedBatch = async (texts) => {
116
+ const { ort, tokenizer, session, maxLength, padId, wantsTypeIds } = await load();
117
+ const encoded = texts.map((text) => {
118
+ const { ids, attention_mask, token_type_ids } = tokenizer.encode(text, { add_special_tokens: true, return_token_type_ids: true });
119
+ return { input_ids: [...ids], attention_mask: [...attention_mask], ...(wantsTypeIds ? { token_type_ids: [...(token_type_ids || ids.map(() => 0))] } : {}) };
120
+ });
121
+ const width = Math.min(Math.max(...encoded.map((e) => e.input_ids.length)), maxLength);
122
+ for (const e of encoded) {
123
+ if (e.input_ids.length > width) truncate(e, width);
124
+ else if (e.input_ids.length < width) pad(e, width, padId);
125
+ }
126
+ const tensor = (key) => new ort.Tensor("int64", BigInt64Array.from(encoded.flatMap((e) => e[key].map((v) => BigInt(v)))), [texts.length, width]);
127
+ const feeds = { input_ids: tensor("input_ids"), attention_mask: tensor("attention_mask"), ...(wantsTypeIds ? { token_type_ids: tensor("token_type_ids") } : {}) };
128
+ const outputs = await session.run(feeds);
129
+ const hidden = outputs.last_hidden_state;
130
+ const [, seq, dims] = hidden.dims;
131
+ const data = hidden.data;
132
+ return texts.map((_, row) => {
133
+ const start = row * seq * dims;
134
+ const vector = new Float32Array(dims);
135
+ let norm = 0;
136
+ for (let i = 0; i < dims; i += 1) { vector[i] = data[start + i]; norm += vector[i] * vector[i]; }
137
+ norm = Math.sqrt(norm) || 1;
138
+ for (let i = 0; i < dims; i += 1) vector[i] /= norm;
139
+ return vector;
140
+ });
141
+ };
142
+
143
+ return {
144
+ version: EMBEDDING_VERSION,
145
+ dimensions: EMBEDDING_DIMENSIONS,
146
+ ready: () => load().then(() => true),
147
+ async embedDocuments(texts) {
148
+ const out = [];
149
+ for (let i = 0; i < texts.length; i += BATCH) out.push(...(await embedBatch(texts.slice(i, i + BATCH))));
150
+ return out;
151
+ },
152
+ async embedQuery(text) {
153
+ return (await embedBatch([QUERY_PREFIX + text]))[0];
154
+ },
155
+ };
156
+ }