tryharness 0.4.2 → 0.5.0

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
@@ -10,8 +10,13 @@ import { parseSessionFile } from "./parsers.mjs";
10
10
  import { historyRoots, paths } from "./paths.mjs";
11
11
  import { openStore } from "./store.mjs";
12
12
  import { runAgentTask } from "./runner.mjs";
13
+ import { notificationFor, notifyDesktop } from "./notify.mjs";
14
+ import { secretReason } from "./secrets.mjs";
15
+ import { provenanceFor, provenanceLine } from "./provenance.mjs";
16
+ import { createEmbedder, EMBEDDING_VERSION, modelPresent } from "./embedder.mjs";
17
+ import { fromBlob, toBlob, topK } from "./vectors.mjs";
13
18
 
14
- const DAEMON_VERSION = "0.4.2";
19
+ const DAEMON_VERSION = "0.4.3";
15
20
  const PROTOCOL_VERSION = 1;
16
21
  const RESCAN_MS = 60_000;
17
22
  const TASK_LOG_PAGE = 64_000;
@@ -61,6 +66,23 @@ export async function startDaemon() {
61
66
  const chunk = raw.slice(offset, offset + TASK_LOG_PAGE);
62
67
  return { ...task, log: chunk, offset, nextOffset: offset + chunk.length, logLength: raw.length };
63
68
  };
69
+ // Runs left starting/running by a previous daemon process lost their agent process
70
+ // with it; their outcome is unknown, never assumed finished.
71
+ try {
72
+ for (const name of fs.readdirSync(paths.runs)) {
73
+ if (!name.endsWith(".json")) continue;
74
+ const task = readJson(path.join(paths.runs, name), null);
75
+ if (task && validRunId(task.runId) && (task.status === "starting" || task.status === "running")) {
76
+ persistTask({ ...task, status: "interrupted", updatedAt: new Date().toISOString() });
77
+ }
78
+ }
79
+ } catch {}
80
+ const taskStatus = (runId) => {
81
+ const task = validRunId(runId) ? taskRuns.get(runId) || readJson(taskFiles(runId).state, null) : null;
82
+ if (!task) return null;
83
+ const message = typeof task.message === "string" ? task.message.slice(0, 100_000) : null;
84
+ return { runId: task.runId, status: task.status, message, sessionId: task.sessionId || null, cwd: task.cwd || null };
85
+ };
64
86
  saveStatus();
65
87
  log(`daemon ${DAEMON_VERSION} starting; agents=${installedAgents.join(",") || "none"}`);
66
88
 
@@ -208,6 +230,241 @@ export async function startDaemon() {
208
230
  if (process.platform !== "win32") {
209
231
  try { if (fs.existsSync(paths.socket)) fs.unlinkSync(paths.socket); } catch {}
210
232
  }
233
+ let socket = null;
234
+ const relayWaiters = new Map();
235
+ const send = (message) => {
236
+ try { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); } catch {}
237
+ };
238
+ // A question for Harness over the bridge, answered by its bridge_response. Rejects when the
239
+ // daemon is not connected or Harness does not answer in time.
240
+ const askHarness = (type, input, timeoutMs = 15_000) => new Promise((resolve, reject) => {
241
+ if (!state.connected) { reject(new Error("the daemon is not connected to Harness")); return; }
242
+ const requestId = randomUUID();
243
+ const timer = setTimeout(() => { relayWaiters.delete(requestId); reject(new Error("Harness did not answer; is the daemon connected?")); }, timeoutMs);
244
+ relayWaiters.set(requestId, (response) => { clearTimeout(timer); resolve(response); });
245
+ send({ type, requestId, input });
246
+ });
247
+ // Agents that live on the server, not on any machine: a task for one of them goes up to
248
+ // Harness and lives there; reading or answering it comes back the same way.
249
+ const CLOUD_TARGETS = new Set(["bankr", "quotient", "muse"]);
250
+ 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")))) });
251
+
252
+ // Board callers identify themselves by agent name (the stdio MCP passes --agent; the CLI is
253
+ // "user"; the server names hosted callers). The name is an identity label for attribution
254
+ // and the holder check, not an execution grant: a run needs the user's authorization.
255
+ // One slash admits another person's agent, handle first: "ana/claude-code".
256
+ const validAgent = (value) => /^[a-z0-9][a-z0-9._-]{0,63}(\/[a-z0-9][a-z0-9._-]{0,63})?$/.test(String(value || ""));
257
+ // A thread's outbound leg: a message the person released (or auto-send let through) goes up
258
+ // the bridge; the server acknowledges it once the other side has it. Unacknowledged
259
+ // messages go again on reconnect, so a dropped socket never loses a reply.
260
+ 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 });
261
+ // Every change to the board goes up the bridge as identity and status; the server mirrors it
262
+ // and the PiP follows from there. Briefs and results never ride this message.
263
+ // The person is told on their desktop about what waits on them, whether or not Harness is
264
+ // open: a run an agent asked for, a question, an unknown outcome, work posted for Harness, a
265
+ // claim gone stale. Once per change; off with `tryharness notify off`.
266
+ const lastSeen = new Map();
267
+ const notificationsOn = () => readJson(paths.config, {}).notifications !== false;
268
+ const notifyPerson = (task, previous) => {
269
+ if (!notificationsOn()) return;
270
+ const body = notificationFor(task, { previous });
271
+ if (body) notifyDesktop(body);
272
+ };
273
+ const notifyBoard = (task) => {
274
+ if (!task) return;
275
+ 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 } });
276
+ notifyPerson(task, lastSeen.get(task.id) || null);
277
+ lastSeen.set(task.id, { status: task.status, run_workspace: task.run_workspace, run_authorized_at: task.run_authorized_at });
278
+ // The last part to finish closes its group, which is then announced too.
279
+ if (task.parent_id) { const group = store.refreshGroup(task.parent_id); if (group) notifyBoard(group); }
280
+ setImmediate(runBoard);
281
+ };
282
+ const staleTold = new Set();
283
+ const staleCheck = setInterval(() => {
284
+ if (!notificationsOn()) return;
285
+ for (const task of store.listTasks({ all: false, limit: 100 })) {
286
+ 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)}`); }
287
+ if (!task.stale) staleTold.delete(task.id);
288
+ }
289
+ }, 60_000);
290
+ // The context space's embedder: the model setup downloaded, loaded lazily, run in the
291
+ // background one contribution at a time so no request waits on it. Until the model is
292
+ // present, search answers from words and says so.
293
+ let embedder = null;
294
+ let embedding = false;
295
+ const embedderReady = () => modelPresent(paths.models);
296
+ const embedPending = async () => {
297
+ if (embedding || !embedderReady()) return;
298
+ embedding = true;
299
+ try {
300
+ embedder ||= createEmbedder({ modelsDir: paths.models });
301
+ for (const contribution of store.contributionsToEmbed(EMBEDDING_VERSION, 5)) {
302
+ const [vector] = await embedder.embedDocuments([contribution.text]);
303
+ store.storeVector(contribution.id, EMBEDDING_VERSION, toBlob(vector));
304
+ }
305
+ } catch (error) {
306
+ log(`embedding failed: ${error.message}`);
307
+ } finally {
308
+ embedding = false;
309
+ }
310
+ };
311
+ const embedTimer = setInterval(() => void embedPending(), 2_000);
312
+ const searchContext = async (taskId, query, k) => {
313
+ const text = store.searchContextText(taskId, query, k);
314
+ const context = store.contextCount(taskId);
315
+ if (!embedderReady()) return { retrieval: "text", embedder: "absent", context, entries: text.map(({ rank, ...entry }) => ({ ...entry, score: Number(entry.score.toFixed(3)) })) };
316
+ embedder ||= createEmbedder({ modelsDir: paths.models });
317
+ const queryVector = await embedder.embedQuery(query);
318
+ const rows = store.vectorsFor(taskId, EMBEDDING_VERSION).map((row) => ({ id: row.id, vector: fromBlob(row.vector) }));
319
+ const best = topK(queryVector, rows, k);
320
+ const byId = new Map(best.map((hit) => [hit.id, hit.score]));
321
+ // Words still count: a text hit with no vector yet rides along below the vector hits.
322
+ for (const hit of text) if (!byId.has(hit.id)) byId.set(hit.id, 0);
323
+ 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);
324
+ return { retrieval: rows.length ? "vectors" : "text", embedder: "ready", context, entries: entries.map(({ seq, ...entry }) => entry) };
325
+ };
326
+ // Tasks written before the context space existed get their brief and result as entries.
327
+ for (const id of store.backfillContributions()) log(`context: backfilled task ${id}`);
328
+
329
+ const boardCommand = (request) => {
330
+ if (!validAgent(request.agent)) return { ok: false, error: "board requests need an agent name" };
331
+ const agent = String(request.agent);
332
+ store.touchAgent(agent);
333
+ const id = String(request.id || "");
334
+ const expected = Number.isInteger(request.expectedVersion) ? request.expectedVersion : null;
335
+ let outcome;
336
+ if (request.command === "board.post") {
337
+ const objective = String(request.objective || "").trim();
338
+ if (!objective) return { ok: false, error: "a task needs an objective" };
339
+ if (request.targetAgent != null && !validAgent(request.targetAgent)) return { ok: false, error: "invalid target agent" };
340
+ // Provenance is code's: the local parent's label and what Harness sent from above the poster can only tighten it.
341
+ const parentTask = request.parentId ? store.getTask(String(request.parentId)) : null;
342
+ const claimed = request.provenance && typeof request.provenance === "object" && request.provenance.trust === "agent" ? request.provenance : null;
343
+ const upstream = parentTask ? { trust: claimed ? "agent" : parentTask.trust, chain: parentTask.origin_chain } : claimed;
344
+ const provenance = provenanceFor(agent, upstream);
345
+ if (CLOUD_TARGETS.has(String(request.targetAgent || "")) && !request.thread) {
346
+ if (!state.connected) return { ok: false, error: `${request.targetAgent} lives in Harness, and the daemon is not connected to it right now` };
347
+ return forwardToHarness({ ...request, provenance: upstream });
348
+ }
349
+ const runWorkspace = request.runWorkspace ? String(request.runWorkspace) : null;
350
+ if (runWorkspace && !path.isAbsolute(runWorkspace)) return { ok: false, error: "a run needs an absolute workspace directory" };
351
+ if (runWorkspace && !request.targetAgent && !(Array.isArray(request.targetAgents) && request.targetAgents.length)) return { ok: false, error: "a run needs a target agent" };
352
+ const refs = Array.isArray(request.contextRefs) ? request.contextRefs.slice(0, 50) : [];
353
+ // The server may name the task (its run id) so the run record, the session layer and the board agree.
354
+ if (request.id != null && !validRunId(request.id)) return { ok: false, error: "invalid task id" };
355
+ 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)) };
356
+ const thread = request.thread && typeof request.thread === "object" ? request.thread : null;
357
+ 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" };
358
+ // Several agents at once: one group task with a part per agent.
359
+ const targetAgents = Array.isArray(request.targetAgents) ? [...new Set(request.targetAgents.map(String))] : null;
360
+ if (targetAgents) {
361
+ if (thread) return { ok: false, error: "a thread with another person has one target" };
362
+ if (!targetAgents.length || targetAgents.length > 8 || !targetAgents.every(validAgent)) return { ok: false, error: "target_agents must be one to eight agent names" };
363
+ if (targetAgents.length === 1) request.targetAgent = targetAgents[0];
364
+ else {
365
+ 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,
366
+ targetAgents, contextRefs: refs, runWorkspace, runAuthorizedBy: runWorkspace && agent === "user" ? "user" : null });
367
+ for (const part of posted.children) notifyBoard(store.getTask(part.id));
368
+ notifyBoard(posted.group);
369
+ return { ok: true, task: posted.group };
370
+ }
371
+ }
372
+ 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),
373
+ originAgent: agent, provenance, targetAgent: request.targetAgent ? String(request.targetAgent) : null, parentId: request.parentId ? String(request.parentId) : null, contextRefs: refs,
374
+ runWorkspace: thread ? null : runWorkspace, runAuthorizedBy: !thread && runWorkspace && agent === "user" ? "user" : null });
375
+ if (thread) store.openThread(task.id, { withAgent: String(thread.with), role: thread.role });
376
+ outcome = { ok: true, task: store.getTask(task.id), thread: thread ? store.getThread(task.id) : null };
377
+ } else if (request.command === "board.list") {
378
+ const tasks = store.listTasks({ agent, status: request.status ? String(request.status) : undefined, all: request.all === true, limit: request.limit });
379
+ return request.unread ? { ok: true, tasks, unread: store.unreadFor(agent) } : { ok: true, tasks };
380
+ } else if (request.command === "board.context.add") {
381
+ if (!store.getTask(id)) return { ok: false, error: "task not found" };
382
+ const kind = String(request.kind || "note");
383
+ if (!["note", "log", "link", "file", "repro"].includes(kind)) return { ok: false, error: "kind must be note, log, link, file or repro" };
384
+ 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 });
385
+ return entry ? { ok: true, entry, context: store.contextCount(id) } : { ok: false, error: "nothing to add" };
386
+ } else if (request.command === "board.context.search") {
387
+ if (!store.getTask(id)) return { ok: false, error: "task not found" };
388
+ return { ok: true, pending: searchContext(id, String(request.query || ""), Math.max(1, Math.min(20, Number(request.limit) || 8))) };
389
+ } else if (request.command === "board.thread.read") {
390
+ const thread = store.getThread(id);
391
+ return thread ? { ok: true, thread, task: store.getTask(id), messages: store.threadMessages(id) } : { ok: false, error: "no thread on this task" };
392
+ } else if (request.command === "board.thread.post") {
393
+ const text = String(request.text || "").slice(0, 50_000);
394
+ if (!text.trim()) return { ok: false, error: "a message needs text" };
395
+ 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);
396
+ const flagReason = secretReason(text) || secretReason(JSON.stringify(refs));
397
+ outcome = store.postMessage({ id: randomUUID(), taskId: id, sender: agent, text, refs, flagReason });
398
+ if (outcome.ok && flagReason) store.revertAutoSend(id);
399
+ if (outcome.ok && outcome.message.state === "sent") sendThreadMessage(outcome.message);
400
+ 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)}`); }
401
+ } else if (request.command === "board.thread.send" || request.command === "board.thread.discard") {
402
+ if (agent !== "user") return { ok: false, error: "only the user sends or discards a held reply" };
403
+ const messageId = String(request.messageId || "");
404
+ if (request.command === "board.thread.discard") { outcome = store.discardMessage(messageId); if (outcome.ok) outcome = { ...outcome, task: store.getTask(id) }; }
405
+ else { outcome = store.releaseMessage(messageId); if (outcome.ok) { sendThreadMessage(outcome.message); outcome = { ...outcome, task: store.getTask(id) }; } }
406
+ } else if (request.command === "board.thread.auto") {
407
+ if (agent !== "user") return { ok: false, error: "only the user changes auto-send" };
408
+ outcome = store.setAutoSend(id, request.on === true);
409
+ if (outcome.ok) outcome = { ...outcome, task: store.getTask(id) };
410
+ } else if (request.command === "board.thread.end") {
411
+ const task = store.getTask(id);
412
+ if (!task) return { ok: false, error: "task not found" };
413
+ if (agent !== "user" && task.claimed_by !== agent) return { ok: false, error: "only the user or the agent holding the task ends its thread" };
414
+ outcome = store.closeThread(id, String(request.outcome || "").slice(0, 20_000) || null);
415
+ if (outcome.ok && !outcome.already) send({ type: "thread_end", taskId: id, by: agent, outcome: outcome.thread.outcome });
416
+ }
417
+ else if (request.command === "board.get") {
418
+ const task = store.getTask(id);
419
+ if (task) return { ok: true, task };
420
+ return state.connected ? forwardToHarness(request) : { ok: false, error: "task not found" };
421
+ } else if (request.command === "board.children") return { ok: true, tasks: store.children(id).map((child) => store.getTask(child.id)).filter(Boolean) };
422
+ // A trace reads both homes, and only Harness can: the machine asks it.
423
+ 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" };
424
+ else if (request.command === "board.agents") return { ok: true, agents: store.agentsSeen(), running: [...activeRuns.entries()].map(([runAgent, taskId]) => ({ agent: runAgent, id: taskId })) };
425
+ else if (["board.claim", "board.report", "board.answer", "board.release"].includes(request.command) && !store.getTask(id) && state.connected) return forwardToHarness(request);
426
+ else if (request.command === "board.claim") outcome = store.claimTask(id, agent, expected, Number.isInteger(request.leaseMinutes) ? request.leaseMinutes : 60);
427
+ else if (request.command === "board.report") outcome = store.reportTask(id, agent, { status: String(request.status || ""), result: String(request.result || "").slice(0, 200_000) }, expected);
428
+ else if (request.command === "board.answer") outcome = store.answerTask(id, agent, String(request.answer || "").slice(0, 20_000), expected);
429
+ else if (request.command === "board.release") outcome = store.releaseTask(id, agent, expected);
430
+ else if (request.command === "board.run") outcome = agent === "user" ? store.authorizeRun(id, agent) : { ok: false, error: "only the user can authorize a run" };
431
+ else return { ok: false, error: "unknown command" };
432
+ if (outcome.ok) notifyBoard(outcome.task);
433
+ return outcome;
434
+ };
435
+
436
+ // The board worker. An authorized task for an agent installed here is claimed by the daemon
437
+ // in that agent's name, run through the same runner as a pushed run (so `tryharness task <id>
438
+ // --follow` and the run record work unchanged), and reported back on the task. One run per
439
+ // agent at a time; the rest wait open. Nothing here polls: a board write calls runBoard.
440
+ const activeRuns = new Map();
441
+ for (const id of store.settleInterruptedRuns()) log(`board task ${id} was running when the daemon stopped; marked unknown`);
442
+ const runBoard = () => {
443
+ for (const task of store.runnableTasks(installedAgents.filter((agent) => !activeRuns.has(agent)))) {
444
+ const agent = task.target_agent;
445
+ if (activeRuns.has(agent)) continue;
446
+ const claimed = store.claimTask(task.id, agent, task.version, null);
447
+ if (!claimed.ok) continue;
448
+ const started = store.startRun(task.id, agent);
449
+ if (!started.ok) continue;
450
+ activeRuns.set(agent, task.id);
451
+ notifyBoard(started.task);
452
+ const refs = task.context_refs.length ? "\n\nContext:\n" + task.context_refs.map((ref) => `- ${ref.kind}: ${ref.ref}${ref.note ? ` (${ref.note})` : ""}`).join("\n") : "";
453
+ const notice = provenanceLine(task);
454
+ const brief = `${notice ? `${notice}\n\n` : ""}${task.objective}\n\n${task.brief}${refs}`.trim();
455
+ startTask({ runId: task.id, agent, title: task.objective.slice(0, 120), brief, cwd: task.run_workspace }, (kind, text, extra) => {
456
+ if (kind === "session" && text) { store.setRunSession(task.id, text); notifyBoard(store.getTask(task.id)); }
457
+ if (kind === "done" || kind === "failed") {
458
+ activeRuns.delete(agent);
459
+ if (extra?.usage) store.setRunUsage(task.id, extra.usage);
460
+ const reported = store.reportTask(task.id, agent, { status: kind, result: text || "" });
461
+ if (reported.ok) notifyBoard(reported.task);
462
+ else log(`board task ${task.id} finished ${kind} but could not be reported: ${reported.error}`);
463
+ }
464
+ });
465
+ }
466
+ };
467
+
211
468
  const control = net.createServer((socket) => {
212
469
  socket.setEncoding("utf8");
213
470
  let buffer = "";
@@ -217,7 +474,9 @@ export async function startDaemon() {
217
474
  if (newline < 0) return;
218
475
  let request;
219
476
  try { request = JSON.parse(buffer.slice(0, newline)); }
220
- catch { socket.end(JSON.stringify({ ok: false, error: "invalid request" }) + "\n"); return; }
477
+ catch { request = null; }
478
+ // Anything but one JSON object is refused here; nothing a local process writes can reach a property read on it.
479
+ if (!request || typeof request !== "object" || Array.isArray(request)) { socket.end(JSON.stringify({ ok: false, error: "invalid request" }) + "\n"); return; }
221
480
  const reply = (value) => socket.end(JSON.stringify(value) + "\n");
222
481
  if (request.command === "status") reply({ ok: true, ...state, counts: store.counts() });
223
482
  else if (request.command === "doctor") reply({ ok: true, status: state, database: paths.database, socket: paths.socket, log: paths.log });
@@ -225,14 +484,20 @@ export async function startDaemon() {
225
484
  if (request.agent === "opencode" && request.sessionId) targetedOpencode.push(request);
226
485
  void sync(request.agent && request.agent !== "all" ? request.agent : null);
227
486
  reply({ ok: true, accepted: true, indexing: true });
228
- } else if (request.command === "handoffs.create") {
229
- const id = request.id || randomUUID();
230
- store.addHandoff({ id, targetAgent: request.agent, targetSessionId: request.sessionId, title: request.title || "Harness handoff", body: request.body || "", source: request.source });
231
- reply({ ok: true, id });
232
- } else if (request.command === "handoffs.list") reply({ ok: true, handoffs: store.listHandoffs(request.agent) });
233
- else if (request.command === "handoffs.get") {
234
- const handoff = store.getHandoff(request.id, request.consume === true);
235
- reply(handoff && handoff.target_agent === request.agent ? { ok: true, handoff } : { ok: false, error: "handoff not found" });
487
+ } else if (String(request.command || "").startsWith("board.")) {
488
+ // A bad request never takes the daemon down: it is answered as a refusal.
489
+ let result;
490
+ try { result = boardCommand(request); } catch (error) { log(`board ${request.command}: ${error.message}`); result = { ok: false, error: `the board could not answer: ${error.message}` }; }
491
+ if (result?.pending) result.pending.then((value) => reply({ ok: true, ...value }), (error) => reply({ ok: false, error: error.message }));
492
+ else reply(result);
493
+ }
494
+ else if (request.command === "relay.request") {
495
+ const requestId = randomUUID();
496
+ const timer = setTimeout(() => { relayWaiters.delete(requestId); reply({ ok: false, error: "Harness did not answer the request; is the daemon connected?" }); }, 15_000);
497
+ relayWaiters.set(requestId, (response) => { clearTimeout(timer); reply(response); });
498
+ // `socket` here is the control connection; the bridge's state is what matters.
499
+ if (!state.connected) { clearTimeout(timer); relayWaiters.delete(requestId); reply({ ok: false, error: "the daemon is not connected to Harness" }); }
500
+ 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) : [] } });
236
501
  } else if (request.command === "tasks.get") {
237
502
  const task = readTask(request.runId, request.offset);
238
503
  reply(task ? { ok: true, task } : { ok: false, error: "task not found" });
@@ -248,15 +513,21 @@ export async function startDaemon() {
248
513
  });
249
514
  if (process.platform !== "win32") try { fs.chmodSync(paths.socket, 0o600); } catch {}
250
515
 
251
- let socket = null;
252
516
  let retry = 1_000;
253
517
  let reconnectTimer = null;
254
- const send = (message) => {
255
- try { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); } catch {}
256
- };
257
- const startTask = (message) => {
518
+ // Runs an agent for a board task, writing the run record and log under the task id. A run id
519
+ // is executed at most once: a known record answers with its outcome instead of a rerun.
520
+ const startTask = (message, report) => {
258
521
  if (!validRunId(message.runId)) {
259
- send({ type: "event", runId: message.runId, kind: "failed", text: "Invalid task run id." });
522
+ report("failed", "Invalid task run id.");
523
+ return;
524
+ }
525
+ // A run id is executed at most once, even if the same task arrives again.
526
+ const known = taskStatus(message.runId);
527
+ if (known) {
528
+ if (known.status === "done" || known.status === "failed") report(known.status, known.message || undefined);
529
+ else if (known.status === "running") report("accepted");
530
+ else report("failed", "This run was interrupted earlier and is not started again.");
260
531
  return;
261
532
  }
262
533
  ensureDir(paths.runs);
@@ -285,10 +556,11 @@ export async function startDaemon() {
285
556
  task.status = kind;
286
557
  task.finishedAt = new Date().toISOString();
287
558
  task.message = text || null;
559
+ if (extra?.usage) task.usage = extra.usage;
288
560
  }
289
561
  task.updatedAt = new Date().toISOString();
290
562
  persistTask(task);
291
- send({ type: "event", runId: message.runId, kind, ...(text ? { text } : {}), ...(extra || {}) });
563
+ report(kind, text, extra);
292
564
  };
293
565
  const observe = (stream, raw) => {
294
566
  try { fs.appendFileSync(files.log, stream === "stderr" ? `\n[stderr] ${raw}` : raw, { mode: 0o600 }); } catch {}
@@ -313,7 +585,8 @@ export async function startDaemon() {
313
585
  state.connected = true;
314
586
  state.lastError = null;
315
587
  saveStatus();
316
- send({ type: "hello", protocolVersion: PROTOCOL_VERSION, daemonVersion: DAEMON_VERSION, agents: installedAgents, health: state.agents, capabilities: ["sessions.search", "sessions.read", "tasks.run", "handoffs.put", "handoffs.read"] });
588
+ send({ type: "hello", protocolVersion: PROTOCOL_VERSION, daemonVersion: DAEMON_VERSION, agents: installedAgents, health: state.agents, capabilities: ["sessions.search", "sessions.read", "tasks.status", "board", "threads"] });
589
+ for (const message of store.undeliveredMessages()) sendThreadMessage(message);
317
590
  });
318
591
  socket.on("message", (data) => {
319
592
  let message;
@@ -335,28 +608,45 @@ export async function startDaemon() {
335
608
  catch (error) { send({ type: "bridge_response", requestId: message.requestId, ok: false, error: error.message }); }
336
609
  return;
337
610
  }
338
- if (message.type === "handoffs_list") {
339
- try { send({ type: "bridge_response", requestId: message.requestId, ok: true, result: { handoffs: store.listHandoffs(message.input?.agent) } }); }
340
- catch (error) { send({ type: "bridge_response", requestId: message.requestId, ok: false, error: error.message }); }
611
+ if (message.type === "tasks_status") {
612
+ const runIds = Array.isArray(message.input?.runIds) ? message.input.runIds.slice(0, 50) : [];
613
+ send({ type: "bridge_response", requestId: message.requestId, ok: true, result: { tasks: runIds.map(taskStatus).filter(Boolean) } });
341
614
  return;
342
615
  }
343
- if (message.type === "handoffs_get") {
344
- try {
345
- const handoff = store.getHandoff(message.input?.id, message.input?.consume === true);
346
- send({ type: "bridge_response", requestId: message.requestId, ok: Boolean(handoff), result: handoff ? { handoff } : null });
347
- } catch (error) { send({ type: "bridge_response", requestId: message.requestId, ok: false, error: error.message }); }
616
+ if (message.type === "thread_message") {
617
+ const input = message.input || {};
618
+ 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 });
619
+ send({ type: "bridge_response", requestId: message.requestId, ok: received.ok, result: received.ok ? { acknowledged: input.messageId } : null, ...(received.ok ? {} : { error: received.error }) });
620
+ if (received.ok && !received.duplicate) {
621
+ const task = store.getTask(String(input.taskId));
622
+ notifyBoard(task);
623
+ if (notificationsOn()) notifyDesktop(`${input.from} replied on: ${String(task?.objective || "").slice(0, 80)}`);
624
+ }
348
625
  return;
349
626
  }
350
- if (message.type === "handoff") {
351
- try {
352
- store.addHandoff({ id: message.handoff?.id || randomUUID(), targetAgent: message.handoff?.agent, targetSessionId: message.handoff?.sessionId, title: message.handoff?.title || "Harness handoff", body: message.handoff?.body || "", source: message.handoff?.source });
353
- send({ type: "bridge_response", requestId: message.requestId, ok: true, result: { stored: true } });
354
- } catch (error) { send({ type: "bridge_response", requestId: message.requestId, ok: false, error: error.message }); }
627
+ if (message.type === "thread_ack") { store.markDelivered(String(message.messageId || "")); return; }
628
+ if (message.type === "bridge_response" && relayWaiters.has(message.requestId)) {
629
+ relayWaiters.get(message.requestId)(message.ok ? { ok: true, ...(message.result || {}) } : { ok: false, error: message.error || "Harness refused the request" });
630
+ relayWaiters.delete(message.requestId);
355
631
  return;
356
632
  }
357
- if (message.type === "task") {
358
- startTask(message);
633
+ if (message.type === "thread_end") {
634
+ const input = message.input || {};
635
+ const closed = store.closeThread(String(input.taskId || ""), String(input.outcome || "").slice(0, 20_000) || null);
636
+ send({ type: "bridge_response", requestId: message.requestId, ok: closed.ok, result: closed.ok ? { closed: input.taskId } : null });
637
+ if (closed.ok && !closed.already) { notifyBoard(closed.task); if (notificationsOn()) notifyDesktop(`${input.by} ended the thread on: ${String(closed.task?.objective || "").slice(0, 80)}`); }
638
+ return;
359
639
  }
640
+ if (message.type === "board") {
641
+ // A hosted caller's board call, identified by the name Harness assigns it. The same
642
+ // checks as the local control socket apply; the server persists nothing of it.
643
+ let result;
644
+ try { result = boardCommand({ ...(message.input || {}), command: String(message.input?.command || "") }); } catch (error) { result = { ok: false, error: `the board could not answer: ${error.message}` }; }
645
+ 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 } }));
646
+ else send({ type: "bridge_response", requestId: message.requestId, ok: true, result });
647
+ return;
648
+ }
649
+
360
650
  });
361
651
  const dropped = (error) => {
362
652
  state.connected = false;
@@ -374,6 +664,7 @@ export async function startDaemon() {
374
664
  }
375
665
  };
376
666
  connect();
667
+ setImmediate(runBoard);
377
668
  const heartbeat = setInterval(() => send({ type: "ping" }), 30_000);
378
669
  const rescan = setInterval(() => void sync(), RESCAN_MS);
379
670
  const watchers = [];
@@ -393,6 +684,8 @@ export async function startDaemon() {
393
684
 
394
685
  const shutdown = () => {
395
686
  clearInterval(heartbeat);
687
+ clearInterval(staleCheck);
688
+ clearInterval(embedTimer);
396
689
  clearInterval(rescan);
397
690
  for (const watcher of watchers) watcher.close();
398
691
  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
+ }