tryharness 0.7.18 → 0.7.23

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.
Files changed (48) hide show
  1. package/README.md +57 -14
  2. package/assets/opencode-plugin.js +47 -66
  3. package/assets/pi-extension.ts +91 -90
  4. package/cli.mjs +115 -9
  5. package/lib/adapters.mjs +19 -11
  6. package/lib/agentnode.mjs +154 -0
  7. package/lib/atlas.mjs +304 -0
  8. package/lib/bip39-english.txt +2048 -0
  9. package/lib/cards.mjs +16 -4
  10. package/lib/context.mjs +290 -0
  11. package/lib/coordinator.mjs +27 -9
  12. package/lib/daemon.mjs +790 -93
  13. package/lib/digest.mjs +40 -0
  14. package/lib/environment.mjs +98 -0
  15. package/lib/firewall.mjs +154 -0
  16. package/lib/folders.mjs +161 -0
  17. package/lib/frames.mjs +159 -0
  18. package/lib/inference.mjs +326 -0
  19. package/lib/learn.mjs +120 -0
  20. package/lib/live.mjs +30 -5
  21. package/lib/localmodels.mjs +230 -0
  22. package/lib/mcp-stdio.mjs +107 -7
  23. package/lib/media.mjs +118 -0
  24. package/lib/members.json +1 -1
  25. package/lib/models.mjs +233 -0
  26. package/lib/network.mjs +118 -0
  27. package/lib/nudge.mjs +13 -5
  28. package/lib/ocr.mjs +217 -0
  29. package/lib/personal.mjs +39 -0
  30. package/lib/phones.mjs +31 -2
  31. package/lib/places.mjs +61 -0
  32. package/lib/receipts.mjs +42 -0
  33. package/lib/redact.mjs +334 -0
  34. package/lib/runner.mjs +37 -6
  35. package/lib/runtime-install.mjs +6 -5
  36. package/lib/screens.mjs +96 -0
  37. package/lib/sealed.mjs +3 -0
  38. package/lib/service.mjs +4 -2
  39. package/lib/snippets.mjs +64 -0
  40. package/lib/speech.mjs +480 -0
  41. package/lib/store.mjs +64 -5
  42. package/lib/swarm.mjs +89 -26
  43. package/lib/t3.mjs +7 -4
  44. package/lib/trails.mjs +177 -0
  45. package/lib/watches.mjs +163 -0
  46. package/lib/worktrees.mjs +21 -5
  47. package/package.json +6 -4
  48. package/lib/embedder.mjs +0 -156
package/cli.mjs CHANGED
@@ -14,7 +14,7 @@ import { daemonRequest } from "./lib/control-client.mjs";
14
14
  import { ensureDir, readJson, writeJsonAtomic } from "./lib/files.mjs";
15
15
  import { paths } from "./lib/paths.mjs";
16
16
  import { currentRuntime, installRuntime } from "./lib/runtime-install.mjs";
17
- import { ensureModel, modelPresent } from "./lib/embedder.mjs";
17
+ import { MODELS, ensureLlama, ensureModels, modelsPresent } from "./lib/localmodels.mjs";
18
18
  import { installService, serviceInfo, uninstallService } from "./lib/service.mjs";
19
19
  import { canShowDesktop, openDesktop, retireDesktop, setupDesktop } from "./lib/desktop.mjs";
20
20
 
@@ -173,14 +173,23 @@ async function connect() {
173
173
  const shown = await desktop();
174
174
  spin.stop(shown.text, shown.ok ? 0 : 2);
175
175
  }
176
- // The context space's embedder lives with the daemon and comes down with setup, so the machine
177
- // can search a task by meaning before the first task exists. About 110 MB, once.
178
- if (modelPresent(paths.models)) ui.success("Embedder ready");
176
+ // The relay's core local models (localmodels.mjs) and the server that runs them come down with setup, so agents get
177
+ // context packs, code search, the handoff firewall and images checked before they leave from the first task. Each file
178
+ // is checked against its pinned hash.
179
+ const core = MODELS.filter((entry) => entry.tier === "core").map((entry) => entry.id);
180
+ const localRoot = path.join(paths.home, "local");
181
+ if (modelsPresent(localRoot, core)) ui.success("Local models ready");
179
182
  else {
180
183
  const download = ui.spinner();
181
- download.start("Downloading the embedder (about 110 MB, once)");
182
- try { await ensureModel({ modelsDir: paths.models, log: (line) => download.message(`Downloading the embedder · ${line.slice(0, 30)}`) }); download.stop("Embedder ready"); }
183
- catch (error) { download.stop(`Embedder not downloaded (${error.message}). Task search uses words until the next setup run`, 2); }
184
+ const size = Math.round(MODELS.filter((entry) => core.includes(entry.id)).flatMap((entry) => entry.files).reduce((sum, file) => sum + file.size, 0) / 1e6);
185
+ download.start(`Downloading the local models (about ${size} MB, once)`);
186
+ // The classifiers run without the model server, so one that cannot start here does not hold them back.
187
+ const failures = [];
188
+ for (const step of [() => ensureLlama({ root: localRoot, log: (line) => download.message(`Downloading the local models · ${line.slice(0, 40)}`) }), () => ensureModels({ root: localRoot, ids: core, log: (line) => download.message(`Downloading the local models · ${line.slice(0, 40)}`) })]) {
189
+ try { await step(); } catch (error) { failures.push(error.message); }
190
+ }
191
+ if (failures.length) download.stop(`Local models not ready (${failures.join("; ")}). Agents work as before until the next setup run`, 2);
192
+ else download.stop("Local models ready");
184
193
  }
185
194
  const failed = states.filter((state) => selected.includes(state.agent) && state.installed && !state.configured);
186
195
  if (failed.length) {
@@ -227,6 +236,55 @@ async function status() {
227
236
  }
228
237
  }
229
238
 
239
+ // The relay's local models: each one's job, licence, size and whether it is here, where they run, what the context engine
240
+ // has indexed, and what the overnight learner last did. `tryharness local learn` starts the learner now; `local add
241
+ // <folder>` and `local remove <folder>` name folders of recordings and screenshots for search_media and briefs.
242
+ async function local() {
243
+ const root = path.join(paths.home, "local");
244
+ if (argv[0] === "add" || argv[0] === "remove") {
245
+ const response = await daemonRequest({ command: `local.${argv[0]}`, folder: path.resolve(argv[1] || process.cwd()) });
246
+ if (!response?.ok) throw new Error(response?.error || "the daemon did not answer");
247
+ console.log(argv[0] === "add" ? `Added. The relay reads the recordings and screenshots there in the background${response.downloading ? ", once the image and audio part of the search model has downloaded (555 MB)" : ""}.` : "Removed.");
248
+ return;
249
+ }
250
+ for (const entry of MODELS) {
251
+ const here = modelsPresent(root, [entry.id]);
252
+ const mb = Math.round(entry.files.reduce((sum, file) => sum + file.size, 0) / 1e6);
253
+ console.log(`${here ? "ready " : "missing"} ${entry.name} (${entry.tier}, ${entry.licence}, ${mb} MB): ${entry.purpose}`);
254
+ }
255
+ let current = null;
256
+ try { current = await daemonRequest({ command: argv[0] === "learn" ? "local.learn" : "local.status" }); }
257
+ catch (error) { console.log(`daemon: ${error.message}`); process.exitCode = 1; return; }
258
+ if (!current?.ok) { console.log(current?.error || "the daemon did not answer"); process.exitCode = 1; return; }
259
+ if (argv[0] === "learn") { console.log("Learning from your agents' runs now; tryharness local shows the result when it is done."); return; }
260
+ console.log(`runs on: ${current.device ? (current.device.gpu ? "GPU" : "CPU") : "not checked yet"}`);
261
+ for (const indexed of current.roots || []) console.log(`indexed: ${indexed.root} (${indexed.files} files)`);
262
+ if (current.pieces?.waiting) console.log(`waiting to be embedded: ${current.pieces.waiting} pieces`);
263
+ console.log(current.model ? `ranking: learned ${current.model.trainedAt}, held-out recall at 10 ${JSON.stringify(current.model.heldOut)}` : "ranking: the default until the learner has enough runs");
264
+ for (const folder of current.media?.folders || []) console.log(`recordings and screenshots: ${folder}`);
265
+ if (current.media?.folders?.length) console.log(`media files read: ${current.media.files}`);
266
+ if (current.learning) console.log(`last learning: ${current.learning.at} · ${current.learning.kept ? "kept a new model" : `kept the old one${current.learning.why ? ` (${current.learning.why})` : ""}`}`);
267
+ }
268
+
269
+ // Watches (watches.mjs): `tryharness watch` lists them and what each last read; `watch add` starts one; agents' suggestions
270
+ // wait for `watch start`.
271
+ async function watch() {
272
+ const sub = argv[0] || "list";
273
+ const minutes = (text) => { const found = String(text || "").match(/^(\d+(?:\.\d+)?)\s*(s|m|h)?$/); return found ? Number(found[1]) * ({ s: 1, m: 60, h: 3600 }[found[2] || "m"]) : null; };
274
+ const response = sub === "add" ? await daemonRequest({ command: "board.watch.add", agent: "user", name: argv[1], watchCommand: arg("--run", ""), folder: path.resolve(arg("--in", process.cwd())),
275
+ everySeconds: minutes(arg("--every", "5m")) ?? 300, field: arg("--field", undefined), age: arg("--age", undefined), count: arg("--count", undefined), above: arg("--above", undefined), below: arg("--below", undefined) })
276
+ : sub === "start" || sub === "remove" ? await daemonRequest({ command: `board.watch.${sub}`, agent: "user", id: argv[1] })
277
+ : await daemonRequest({ command: "board.watch.list", agent: "user" });
278
+ if (!response?.ok) throw new Error(response?.error || "the daemon did not answer");
279
+ if (sub === "add") { console.log(`Watching ${response.watch.name} every ${response.watch.everySeconds} s (${response.watch.id}).`); return; }
280
+ if (sub !== "list") { console.log("Done."); return; }
281
+ if (!response.watches.length) console.log("No watches. Add one: tryharness watch add \"keeper heartbeat\" --run \"<command>\" --age completedAt --above 900");
282
+ for (const entry of response.watches) {
283
+ const last = entry.samples.at(-1);
284
+ console.log(`${entry.state === "on" ? (entry.off ? "OFF " : "ok ") : "asks "} ${entry.name} · every ${entry.everySeconds} s · ${last ? (last.error ? `last check failed: ${last.error}` : `last read ${last.value} (${last.ms} ms)`) : "not checked yet"}${entry.state === "suggested" ? ` · suggested by ${entry.by}: tryharness watch start ${entry.id}` : ""}${entry.off ? ` · ${entry.off.text}` : ""}`);
285
+ }
286
+ }
287
+
230
288
  async function doctor() {
231
289
  console.log(`tryharness ${VERSION}`);
232
290
  const config = readJson(paths.config, {});
@@ -341,6 +399,22 @@ async function board() {
341
399
  console.log(sub === "send" ? `Sent to ${response.message.recipient}.` : "Discarded.");
342
400
  return;
343
401
  }
402
+ // Code an agent elsewhere asked to see (snippets.mjs): list the requests waiting, or share or keep one.
403
+ if (sub === "snippet") {
404
+ const [id, choice] = argv.slice(argv.indexOf(sub) + 1);
405
+ if (!id) {
406
+ const response = await daemonRequest({ command: "board.snippet.list", agent: "user" });
407
+ if (!response?.ok) throw new Error(response?.error || "the daemon did not answer");
408
+ if (!response.snippets.length) { console.log("No agent is waiting to see code."); return; }
409
+ for (const each of response.snippets) console.log(`${each.id} ${each.agent} asks to see ${each.path} lines ${each.from} to ${each.to} ("${each.about}")\n${each.text.split("\n").map((line) => ` ${line}`).join("\n")}\n tryharness board snippet ${each.id} share|keep\n`);
410
+ return;
411
+ }
412
+ if (choice !== "share" && choice !== "keep") throw new Error("usage: tryharness board snippet <id> share|keep");
413
+ const response = await daemonRequest({ command: "board.snippet.decide", agent: "user", id, share: choice === "share" });
414
+ if (!response?.ok) throw new Error(response?.error || "the daemon did not answer");
415
+ console.log(choice === "share" ? "Shared in the thread." : "Kept on this computer.");
416
+ return;
417
+ }
344
418
  if (sub === "auto" || sub === "end") {
345
419
  const id = String(argv[argv.indexOf(sub) + 1] || "");
346
420
  const response = sub === "auto"
@@ -356,7 +430,7 @@ async function board() {
356
430
  if (!id || !query) throw new Error("usage: tryharness board context <task id> <what you are looking for>");
357
431
  const response = await daemonRequest({ command: "board.context.search", agent, id, query, limit: 8 }, 20_000);
358
432
  if (!response.ok) throw new Error(response.error || "search failed");
359
- const how = response.retrieval === "vectors" ? "meaning" : response.embedder === "absent" ? "words (the embedder is not downloaded yet; run npx tryharness)" : `words (${response.context?.embedded ?? 0} of ${response.context?.total ?? 0} entries embedded so far)`;
433
+ const how = response.retrieval === "vectors" ? "meaning" : response.embedder === "absent" ? "words (the local models are not downloaded yet; run npx tryharness)" : `words (${response.context?.embedded ?? 0} of ${response.context?.total ?? 0} entries embedded so far)`;
360
434
  console.log(`${response.entries.length} result${response.entries.length === 1 ? "" : "s"} by ${how}`);
361
435
  for (const entry of response.entries) console.log(`\n[${entry.kind} · ${entry.author}${entry.source ? ` · ${entry.source}` : ""} · ${entry.score}]\n${entry.text.slice(0, 400)}`);
362
436
  return;
@@ -622,6 +696,36 @@ async function agents() {
622
696
  console.log(response.note ? `Every agent now reads this on ${response.agent}'s card: ${response.note}` : `${response.agent}'s card has no note now.`);
623
697
  return;
624
698
  }
699
+ // Each coding agent's models as it lists them, your choice for it, and your words on how Auto picks (models.mjs).
700
+ if (sub === "models") {
701
+ const response = await daemonRequest({ command: "board.swarm.models", agent: "user", ...(agent ? { target: agent } : {}), refresh: has("--refresh") });
702
+ if (!response.ok) throw new Error(response.error);
703
+ for (const entry of response.agents) {
704
+ const choice = entry.choice ? (entry.choice.auto ? "Auto" : [entry.choice.model, entry.choice.effort && `effort ${entry.choice.effort}`].filter(Boolean).join(", ")) : `its own settings${entry.ownModel ? ` (${entry.ownModel})` : ""}`;
705
+ console.log(`${entry.name}: runs on ${choice}`);
706
+ if (entry.error) console.log(` its list could not be read: ${entry.error}`);
707
+ if (agent) for (const model of entry.models) console.log(` ${model.id}${model.efforts.length ? ` effort ${model.efforts.join(", ")}` : ""}${model.about ? ` ${model.about}` : ""}`);
708
+ else console.log(` ${entry.models.length} models; list them with: npx tryharness agents models ${entry.id}`);
709
+ }
710
+ console.log(`\nAuto picks by: ${response.routing ?? `${response.defaultRouting} (the default; change it with: npx tryharness agents routing "<your words>")`}`);
711
+ return;
712
+ }
713
+ if (sub === "model") {
714
+ const model = argv[2];
715
+ if (!agent || !model) throw new Error("usage: tryharness agents model <agent> <model from its list> [--effort <level>] | auto | default");
716
+ const response = await daemonRequest({ command: "board.swarm.model", agent: "user", target: agent, model, effort: arg("--effort") });
717
+ if (!response.ok) throw new Error(response.error);
718
+ console.log(!response.choice ? `${response.agent} runs on its own settings.` : response.choice.auto ? `Auto picks ${response.agent}'s model for each run.` : `${response.agent} runs on ${response.choice.model}${response.choice.effort ? `, effort ${response.choice.effort}` : ""}.`);
719
+ return;
720
+ }
721
+ if (sub === "routing") {
722
+ const text = argv.slice(1).filter((word) => word !== "--clear").join(" ").trim();
723
+ if (!text && !has("--clear")) throw new Error('usage: tryharness agents routing "<how Auto should pick models for your swarm>" | --clear');
724
+ const response = await daemonRequest({ command: "board.swarm.routing", agent: "user", text: has("--clear") ? "" : text });
725
+ if (!response.ok) throw new Error(response.error);
726
+ console.log(`Auto picks by: ${response.routing ?? `${response.defaultRouting} (the default)`}`);
727
+ return;
728
+ }
625
729
  const ui = createUi(), only = sub === "setup" ? agent || null : null;
626
730
  ui.intro(`${pc.bgCyan(pc.black(" tryharness "))} ${only ? `Setting up ${only}` : "Your swarm"}`);
627
731
  await setupSwarm({ runtimeRoot: config.runtimeRoot, states: Object.values(config.agentStates || {}), daemonRequest, ui, only });
@@ -705,13 +809,15 @@ async function t3() {
705
809
  }
706
810
 
707
811
  function help() {
708
- console.log(`tryharness ${VERSION}\n\nCommands:\n tryharness set up everything and start the daemon\n tryharness desktop open the Harness desktop app (setup opens it too; --no-desktop skips it)\n tryharness status show daemon, bridge, agent, and index health\n tryharness doctor diagnose the complete connection\n tryharness sync [--agent] rescan local coding history in the background\n tryharness task <id> --follow\n follow a delegated agent run in this terminal\n tryharness board [--all] list open work on the machine's task board\n tryharness board post --for <agent> --objective <text> [--file <brief.md>]\n tryharness board show <id>\n tryharness board post --for codex --objective <text> --run --workspace <dir>\n the daemon runs it and reports back on the task\n tryharness board run <id> authorize a run an agent asked for\n tryharness board release <id>\n tryharness board agents when each agent last read the board\n tryharness board --watch follow the board in this terminal\n tryharness notify on|off desktop notifications for what waits on you\n tryharness board send <task> <message> send a held reply to another person's agent\n tryharness board auto <task> on|off auto-send replies on that thread\n tryharness board end <task> --outcome <text>\n tryharness board context <task> <query> search everything written about a task\n tryharness goal set --objective <text> [--team a,b] [--workspace <dir>] a goal your agents work together in one room\n tryharness goal [list | show <id> | stop <id> | resume <id>]\n tryharness room <goal> | room post <goal> <text> [--ask agent,agent]\n tryharness swarm rounds [<thread>] what the coordinator saw, chose and cost each round\n tryharness swarm coordinator [harness|local] who coordinates your threads\n tryharness connect <handle/agent> --about <text> ask another person's agent to work an issue with yours, sealed to their computer\n tryharness peers the keys of other people's computers this one has worked with, to compare\n tryharness agents how Harness wakes each of your agents, and set up the rest\n tryharness agents setup <agent> | agents test <agent>\n tryharness agents cards what each of your agents is told about the others\n tryharness agents note <agent> <text> [--clear] your own note on an agent's card, told to every other agent\n tryharness mcp grok-bot print the MCP snippet that puts Grok Bot on the board\n tryharness link connect the browser you use Harness in to this computer, end to end (--print shows the link)\n tryharness phone pair show a QR code that pairs your phone with this computer\n tryharness phone list | revoke <id>\n tryharness t3 approve Harness in T3 Code once, so your messages go into its threads (t3 status | t3 forget)\n tryharness logs [--follow]\n tryharness disconnect\n`);
812
+ console.log(`tryharness ${VERSION}\n\nCommands:\n tryharness set up everything and start the daemon\n tryharness desktop open the Harness desktop app (setup opens it too; --no-desktop skips it)\n tryharness status show daemon, bridge, agent, and index health\n tryharness doctor diagnose the complete connection\n tryharness local [learn] the relay's local models, where they run, what is indexed and the last learning\n tryharness local add|remove <folder> a folder of recordings and screenshots agents can search by what was said or shown\n tryharness watch [add <name> --run <command> [--every 5m] [--field f | --age f | --count text] [--above n] [--below n] | start <id> | remove <id>]\n watches the relay checks and says out loud only when they are off\n tryharness sync [--agent] rescan local coding history in the background\n tryharness task <id> --follow\n follow a delegated agent run in this terminal\n tryharness board [--all] list open work on the machine's task board\n tryharness board post --for <agent> --objective <text> [--file <brief.md>]\n tryharness board show <id>\n tryharness board post --for codex --objective <text> --run --workspace <dir>\n the daemon runs it and reports back on the task\n tryharness board run <id> authorize a run an agent asked for\n tryharness board release <id>\n tryharness board agents when each agent last read the board\n tryharness board --watch follow the board in this terminal\n tryharness notify on|off desktop notifications for what waits on you\n tryharness board send <task> <message> send a held reply to another person's agent\n tryharness board snippet [<id> share|keep] code an agent elsewhere asked to see; nothing leaves until you share it\n tryharness board auto <task> on|off auto-send replies on that thread\n tryharness board end <task> --outcome <text>\n tryharness board context <task> <query> search everything written about a task\n tryharness goal set --objective <text> [--team a,b] [--workspace <dir>] a goal your agents work together in one room\n tryharness goal [list | show <id> | stop <id> | resume <id>]\n tryharness room <goal> | room post <goal> <text> [--ask agent,agent]\n tryharness swarm rounds [<thread>] what the coordinator saw, chose and cost each round\n tryharness swarm coordinator [harness|local] who coordinates your threads\n tryharness connect <handle/agent> --about <text> ask another person's agent to work an issue with yours, sealed to their computer\n tryharness peers the keys of other people's computers this one has worked with, to compare\n tryharness agents how Harness wakes each of your agents, and set up the rest\n tryharness agents setup <agent> | agents test <agent>\n tryharness agents cards what each of your agents is told about the others\n tryharness agents note <agent> <text> [--clear] your own note on an agent's card, told to every other agent\n tryharness agents models [<agent>] [--refresh] the models each coding agent lists and the one it runs on\n tryharness agents model <agent> <model> [--effort <level>] | auto | default the model it runs on; auto lets Harness pick per run\n tryharness agents routing "<text>" [--clear] how Auto picks models for your swarm\n tryharness mcp grok-bot print the MCP snippet that puts Grok Bot on the board\n tryharness link connect the browser you use Harness in to this computer, end to end (--print shows the link)\n tryharness phone pair show a QR code that pairs your phone with this computer\n tryharness phone list | revoke <id>\n tryharness t3 approve Harness in T3 Code once, so your messages go into its threads (t3 status | t3 forget)\n tryharness logs [--follow]\n tryharness disconnect\n`);
709
813
  }
710
814
 
711
815
  try {
712
816
  if (command === "setup" || (command === "connect" && (!argv[0] || argv[0].startsWith("-")))) await connect();
713
817
  else if (command === "status" || command === "bridge") await status();
714
818
  else if (command === "doctor") await doctor();
819
+ else if (command === "local") await local();
820
+ else if (command === "watch") await watch();
715
821
  else if (command === "sync" || command === "backfill" || command === "drain") await sync();
716
822
  else if (command === "task") await task();
717
823
  else if (command === "board") await board();
package/lib/adapters.mjs CHANGED
@@ -3,7 +3,6 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { detectAgents, run } from "./commands.mjs";
5
5
  import { ensureDir, readJson } from "./files.mjs";
6
- import { paths } from "./paths.mjs";
7
6
  import { MACHINE_RUNNERS } from "./members.mjs";
8
7
 
9
8
  export const AGENTS = MACHINE_RUNNERS;
@@ -37,9 +36,12 @@ export function mcpClientSnippet({ runtimeRoot, client, nodePath = process.execP
37
36
  return { mcpServers: { harness: { command: nodePath, args: [path.join(runtimeRoot, "lib", "mcp-stdio.mjs"), "--agent", client] } } };
38
37
  }
39
38
 
40
- function installAsset(source, target) {
39
+ // An extension or plugin runs inside its agent and starts Harness from the runtime and Node setup used, as the hooks
40
+ // and MCP entries written here do.
41
+ function installAsset(source, target, { runtimeRoot, nodePath = process.execPath }) {
41
42
  ensureDir(path.dirname(target));
42
- fs.copyFileSync(source, target);
43
+ const text = fs.readFileSync(source, "utf8").replaceAll('"__HARNESS_RUNTIME__"', JSON.stringify(runtimeRoot)).replaceAll('"__HARNESS_NODE__"', JSON.stringify(nodePath));
44
+ fs.writeFileSync(target, text, { mode: 0o600 });
43
45
  try { fs.chmodSync(target, 0o600); } catch {}
44
46
  }
45
47
 
@@ -123,7 +125,7 @@ export function removeClaudeSessionHook(homeDir = os.homedir()) {
123
125
  fs.writeFileSync(file, JSON.stringify({ ...settings, hooks }, null, 2) + "\n");
124
126
  }
125
127
 
126
- export function configureAgents({ runtimeRoot, selected = AGENTS, homeDir = os.homedir(), detected = detectAgents(), execute = run, connection = readJson(paths.config, {}) }) {
128
+ export function configureAgents({ runtimeRoot, selected = AGENTS, homeDir = os.homedir(), detected = detectAgents(), execute = run }) {
127
129
  const byAgent = new Map(detected.map((entry) => [entry.agent, entry]));
128
130
  const mcpPath = path.join(runtimeRoot, "lib", "mcp-stdio.mjs");
129
131
  const assets = integrationAssets(homeDir);
@@ -151,14 +153,13 @@ export function configureAgents({ runtimeRoot, selected = AGENTS, homeDir = os.h
151
153
  try { installCodexHooks({ runtimeRoot, homeDir }); } catch { hooked = false; }
152
154
  states.push({ agent, installed: true, configured: added.ok, detail: added.ok ? (hooked ? "MCP and session hooks configured; in Codex run /hooks once to trust them" : "MCP configured; the session hooks could not be written") : diagnostic(added) });
153
155
  } else if (agent === "pi") {
154
- installAsset(path.join(runtimeRoot, "assets", "pi-extension.ts"), assets.pi);
156
+ // pi has no MCP client: the extension starts the same stdio server and gives pi its tools.
157
+ installAsset(path.join(runtimeRoot, "assets", "pi-extension.ts"), assets.pi, { runtimeRoot });
155
158
  states.push({ agent, installed: true, configured: fs.existsSync(assets.pi), detail: "extension installed" });
156
159
  } else {
157
- installAsset(path.join(runtimeRoot, "assets", "opencode-plugin.js"), assets.opencode);
158
- // Keep opencode's already-working remote MCP configuration. The daemon/plugin path
159
- // handles history; this command supplies Harness memory tools.
160
- execute(found.path, ["mcp", "remove", "harness"]);
161
- const added = execute(found.path, ["mcp", "add", "harness", "--url", String(connection.origin).replace(/\/$/, "") + "/api/mcp", "--header", "Authorization=Bearer " + connection.token]);
160
+ installAsset(path.join(runtimeRoot, "assets", "opencode-plugin.js"), assets.opencode, { runtimeRoot });
161
+ // The same stdio server as Claude Code and Codex. opencode has no `mcp remove`; `mcp add` replaces the entry.
162
+ const added = execute(found.path, ["mcp", "add", "harness", "--", process.execPath, mcpPath, "--agent", agent]);
162
163
  states.push({ agent, installed: true, configured: added.ok, detail: added.ok ? "MCP and plugin configured" : `plugin installed; MCP: ${diagnostic(added)}` });
163
164
  }
164
165
  }
@@ -174,7 +175,14 @@ export function removeAgentConfigurations({ homeDir = os.homedir(), detected = d
174
175
  if (!found.path) continue;
175
176
  if (found.agent === "claude-code") { execute(found.path, ["mcp", "remove", "--scope", "user", "harness"]); removeClaudeSessionHook(homeDir); }
176
177
  else if (found.agent === "codex") { execute(found.path, ["mcp", "remove", "harness"]); removeCodexHooks(homeDir); }
177
- else if (found.agent === "opencode") execute(found.path, ["mcp", "remove", "harness"]);
178
+ }
179
+ // opencode has no `mcp remove`, so its entry comes out of the config file `mcp add` wrote it to.
180
+ for (const name of ["opencode.json", "opencode.jsonc"]) {
181
+ const file = path.join(homeDir, ".config", "opencode", name);
182
+ const config = readJson(file, null);
183
+ if (!config?.mcp?.harness) continue;
184
+ delete config.mcp.harness;
185
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
178
186
  }
179
187
  const assets = integrationAssets(homeDir);
180
188
  for (const target of Object.values(assets)) {
@@ -0,0 +1,154 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+
6
+ // The Node an agent runs on. Some agents are Node programs with a minimum of their own (Pi 0.87.1 needs 22.19), while
7
+ // Harness runs on whichever Node set it up and a person's default may be older. An agent that would not start on the
8
+ // Node first on its PATH runs on the newest Node on this computer that it accepts, from wherever people install Node;
9
+ // with none, its run says what to install instead of failing inside the agent. Native agents are left as they are.
10
+
11
+ const WINDOWS = process.platform === "win32";
12
+
13
+ /** The executable a command name runs on this PATH. */
14
+ export function onPath(command, envPath = process.env.PATH || "") {
15
+ for (const dir of String(envPath).split(path.delimiter).filter(Boolean)) {
16
+ for (const name of WINDOWS ? [`${command}.exe`, `${command}.cmd`, command] : [command]) {
17
+ const file = path.join(dir, name);
18
+ try { if (fs.statSync(file).isFile()) { fs.accessSync(file, fs.constants.X_OK); return file; } } catch {}
19
+ }
20
+ }
21
+ return null;
22
+ }
23
+
24
+ const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } };
25
+ const real = (file) => { try { return fs.realpathSync(file); } catch { return file; } };
26
+
27
+ /** Whether a file is a script Node runs (`#!/usr/bin/env node`), not a native program. */
28
+ function nodeScript(file) {
29
+ let head = "";
30
+ try { const fd = fs.openSync(file, "r"); const buffer = Buffer.alloc(200); fs.readSync(fd, buffer, 0, 200, 0); fs.closeSync(fd); head = buffer.toString("utf8"); } catch { return false; }
31
+ return head.startsWith("#!") && /\bnode\b/.test(head.split("\n")[0]);
32
+ }
33
+
34
+ /** A managed install's launcher (Pi's: <dir>/bin/<command> runs <dir>/install/releases/<current-version>/node_modules/.bin/<command>). */
35
+ function managedEntry(launcher, command) {
36
+ const dir = path.dirname(path.dirname(launcher));
37
+ let version = "";
38
+ try { version = fs.readFileSync(path.join(dir, "install", "current-version"), "utf8").trim(); } catch { return null; }
39
+ if (!/^[\w.+-]+$/.test(version)) return null;
40
+ const entry = path.join(dir, "install", "releases", version, "node_modules", ".bin", command);
41
+ return fs.existsSync(entry) ? real(entry) : null;
42
+ }
43
+
44
+ /** The Node range an agent's own package asks for (its engines.node); null for a native program or one that says none. */
45
+ export function nodeRange(command, envPath = process.env.PATH || "") {
46
+ const found = onPath(command, envPath);
47
+ if (!found) return null;
48
+ const entry = managedEntry(real(found), command) ?? real(found);
49
+ if (!nodeScript(entry)) return null;
50
+ for (let dir = path.dirname(entry); ; dir = path.dirname(dir)) {
51
+ const pkg = readJson(path.join(dir, "package.json"));
52
+ if (pkg?.name) return typeof pkg.engines?.node === "string" && pkg.engines.node.trim() ? pkg.engines.node.trim() : null;
53
+ if (path.dirname(dir) === dir) return null;
54
+ }
55
+ }
56
+
57
+ // ── Versions and ranges (the forms package.json engines use) ─────────────────
58
+
59
+ const parse = (text) => { const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(text).trim()); return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; };
60
+ const compare = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
61
+
62
+ /** One comparator (">=22.19.0", "^20", "~18.2", "22.x", "<23"); one it does not understand holds nothing back. */
63
+ function holds(version, comparator) {
64
+ if (["", "*", "x", "X"].includes(comparator)) return true;
65
+ const m = /^(>=|<=|>|<|=|\^|~)?v?(\d+)(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?$/.exec(comparator);
66
+ if (!m) return true;
67
+ const [, op = "", major, minor, patch] = m;
68
+ const part = (value) => (value === undefined || /^[xX*]$/.test(value) ? null : Number(value));
69
+ const low = [Number(major), part(minor) ?? 0, part(patch) ?? 0];
70
+ // The first version past a partial one: "22" ends before 23.0.0, "22.4" before 22.5.0.
71
+ const past = part(minor) === null ? [low[0] + 1, 0, 0] : part(patch) === null ? [low[0], low[1] + 1, 0] : [low[0], low[1], low[2] + 1];
72
+ const at = compare(version, low);
73
+ if (op === ">=") return at >= 0;
74
+ if (op === ">") return compare(version, past) >= 0;
75
+ if (op === "<") return at < 0;
76
+ if (op === "<=") return compare(version, past) < 0;
77
+ if (op === "^") return at >= 0 && compare(version, low[0] > 0 ? [low[0] + 1, 0, 0] : low[1] > 0 ? [0, low[1] + 1, 0] : [0, 0, low[2] + 1]) < 0;
78
+ if (op === "~") return at >= 0 && compare(version, part(minor) === null ? [low[0] + 1, 0, 0] : [low[0], low[1] + 1, 0]) < 0;
79
+ return at >= 0 && compare(version, past) < 0;
80
+ }
81
+
82
+ /** Whether a Node version is in an engines range ("a || b", comparators by space, "1.2.3 - 2.3.4"). */
83
+ export function satisfies(version, range) {
84
+ const parsed = parse(version);
85
+ if (!parsed) return false;
86
+ return String(range).split("||").some((alternative) => {
87
+ const hyphen = /^\s*(\S+)\s+-\s+(\S+)\s*$/.exec(alternative);
88
+ const comparators = hyphen ? [`>=${hyphen[1]}`, `<=${hyphen[2]}`] : alternative.trim().split(/\s+/).filter(Boolean);
89
+ return comparators.every((comparator) => holds(parsed, comparator));
90
+ });
91
+ }
92
+
93
+ // ── The Nodes on this computer ───────────────────────────────────────────────
94
+
95
+ const versions = new Map();
96
+ /** A Node's version, asked once per file. */
97
+ function versionOf(node) {
98
+ if (!versions.has(node)) {
99
+ const said = spawnSync(node, ["-v"], { encoding: "utf8", timeout: 5_000 });
100
+ versions.set(node, said.status === 0 && parse(said.stdout) ? said.stdout.trim() : null);
101
+ }
102
+ return versions.get(node);
103
+ }
104
+
105
+ /** Every Node installed where people put them: version managers, Homebrew, the system's, the one Harness runs on. */
106
+ export function installedNodes(envPath = process.env.PATH || "") {
107
+ const home = os.homedir();
108
+ const bin = (dir) => (WINDOWS ? [path.join(dir, "node.exe")] : [path.join(dir, "bin", "node"), path.join(dir, "installation", "bin", "node")]);
109
+ const managers = [
110
+ process.env.NVM_DIR ? path.join(process.env.NVM_DIR, "versions", "node") : path.join(home, ".nvm", "versions", "node"),
111
+ path.join(process.env.FNM_DIR || path.join(home, ".local", "share", "fnm"), "node-versions"),
112
+ path.join(home, "Library", "Application Support", "fnm", "node-versions"),
113
+ path.join(process.env.VOLTA_HOME || path.join(home, ".volta"), "tools", "image", "node"),
114
+ path.join(process.env.ASDF_DATA_DIR || path.join(home, ".asdf"), "installs", "nodejs"),
115
+ path.join(home, ".local", "share", "mise", "installs", "node"),
116
+ path.join(process.env.N_PREFIX || "/usr/local", "n", "versions", "node"),
117
+ ...(process.env.NVM_HOME ? [process.env.NVM_HOME] : []),
118
+ ...(process.env.APPDATA ? [path.join(process.env.APPDATA, "nvm")] : []),
119
+ ];
120
+ const found = [process.execPath, onPath("node", envPath)];
121
+ for (const dir of managers) {
122
+ let entries = [];
123
+ try { entries = fs.readdirSync(dir); } catch { continue; }
124
+ for (const entry of entries) found.push(...bin(path.join(dir, entry)));
125
+ }
126
+ for (const brew of ["/opt/homebrew/opt", "/usr/local/opt"]) {
127
+ let entries = [];
128
+ try { entries = fs.readdirSync(brew).filter((name) => /^node(@\d+)?$/.test(name)); } catch {}
129
+ for (const entry of entries) found.push(path.join(brew, entry, "bin", "node"));
130
+ }
131
+ found.push(...(WINDOWS ? [path.join(process.env.ProgramFiles || "C:\\Program Files", "nodejs", "node.exe")] : ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"]));
132
+ const seen = new Set();
133
+ return found.filter((node) => node && fs.existsSync(node) && !seen.has(real(node)) && seen.add(real(node)))
134
+ .map((node) => ({ node, version: versionOf(node) })).filter((entry) => entry.version);
135
+ }
136
+
137
+ /**
138
+ * The environment an agent runs in: as given, unless it is a Node program whose range the Node first on the PATH misses;
139
+ * then the newest installed Node in its range leads the PATH. `bin` is the folder of a Node in its range (for a session
140
+ * resumed in the person's own shell, whatever their default), `error` when there is none, in the person's words.
141
+ */
142
+ export function agentEnvironment(command, env = process.env) {
143
+ const range = nodeRange(command, env.PATH);
144
+ if (!range) return { env };
145
+ const current = onPath("node", env.PATH);
146
+ const have = current ? versionOf(current) : null;
147
+ if (have && satisfies(have, range)) return { env, bin: path.dirname(current) };
148
+ const fits = installedNodes(env.PATH).filter((entry) => satisfies(entry.version, range))
149
+ .sort((a, b) => compare(parse(b.version), parse(a.version)))[0];
150
+ if (!fits) {
151
+ return { env, error: `${command} needs Node ${range}, and this computer has ${have ? `Node ${have.replace(/^v/, "")}` : "no Node"} and no other in that range. Install one (for example: nvm install 22), then ask again.` };
152
+ }
153
+ return { env: { ...env, PATH: [path.dirname(fits.node), env.PATH || ""].join(path.delimiter) }, bin: path.dirname(fits.node), node: fits.version };
154
+ }