tryharness 0.7.22 → 0.7.25

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 (64) hide show
  1. package/README.md +48 -8
  2. package/assets/opencode-plugin.js +48 -10
  3. package/assets/pi-extension.ts +106 -26
  4. package/cli.mjs +1281 -289
  5. package/lib/activity.mjs +197 -40
  6. package/lib/adapters.mjs +337 -54
  7. package/lib/agentnode.mjs +175 -35
  8. package/lib/atlas.mjs +604 -0
  9. package/lib/bip39-english.txt +2048 -0
  10. package/lib/cards.mjs +177 -39
  11. package/lib/claudemem.mjs +74 -18
  12. package/lib/commands.mjs +13 -5
  13. package/lib/context.mjs +628 -0
  14. package/lib/control-client.mjs +5 -2
  15. package/lib/coordinator.mjs +281 -56
  16. package/lib/daemon.mjs +5590 -838
  17. package/lib/desktop.mjs +149 -35
  18. package/lib/digest.mjs +81 -0
  19. package/lib/environment.mjs +180 -0
  20. package/lib/files.mjs +246 -13
  21. package/lib/firewall.mjs +288 -0
  22. package/lib/folders.mjs +223 -57
  23. package/lib/frames.mjs +333 -0
  24. package/lib/inference.mjs +663 -0
  25. package/lib/learn.mjs +182 -0
  26. package/lib/live.mjs +286 -61
  27. package/lib/localmodels.mjs +571 -0
  28. package/lib/mcp-stdio.mjs +912 -103
  29. package/lib/media.mjs +228 -0
  30. package/lib/members.json +1 -1
  31. package/lib/members.mjs +35 -9
  32. package/lib/models.mjs +342 -80
  33. package/lib/network.mjs +201 -42
  34. package/lib/notify.mjs +49 -12
  35. package/lib/nudge.mjs +190 -32
  36. package/lib/ocr.mjs +380 -0
  37. package/lib/onboard.mjs +321 -70
  38. package/lib/parsers.mjs +132 -41
  39. package/lib/paths.mjs +33 -10
  40. package/lib/personal.mjs +77 -0
  41. package/lib/phones.mjs +398 -51
  42. package/lib/places.mjs +566 -102
  43. package/lib/provenance.mjs +19 -4
  44. package/lib/receipts.mjs +82 -0
  45. package/lib/redact.mjs +730 -0
  46. package/lib/runner.mjs +347 -55
  47. package/lib/runtime-install.mjs +59 -17
  48. package/lib/screens.mjs +212 -0
  49. package/lib/sealed.mjs +160 -28
  50. package/lib/secrets.mjs +18 -5
  51. package/lib/service.mjs +173 -35
  52. package/lib/snippets.mjs +140 -0
  53. package/lib/speech.mjs +993 -0
  54. package/lib/store.mjs +1678 -301
  55. package/lib/swarm.mjs +598 -117
  56. package/lib/t3.mjs +400 -79
  57. package/lib/trails.mjs +401 -0
  58. package/lib/ui.mjs +95 -23
  59. package/lib/vectors.mjs +5 -2
  60. package/lib/wakes.mjs +284 -68
  61. package/lib/watches.mjs +362 -0
  62. package/lib/worktrees.mjs +186 -38
  63. package/package.json +6 -4
  64. package/lib/embedder.mjs +0 -156
package/README.md CHANGED
@@ -50,15 +50,51 @@ command as soon as the agent reports its session id, including while the run is
50
50
  so the user can enter and steer the agent directly rather than interacting through a
51
51
  Harness wrapper.
52
52
 
53
- ## Embedder
53
+ ## Local models
54
+
55
+ Setup downloads the relay's core local models into `~/.harness/local`, each file checked
56
+ against a pinned SHA-256 and every model under an allowlisted licence (`cli/lib/localmodels.mjs`):
57
+ EmbeddingGemma 2's text part (one search space for code, agent sessions and board posts), an
58
+ two injection guards (one reads long stretches and holds, one reads short ones and flags), a
59
+ credential detector and a screenshot text reader (PP-OCR), plus llama.cpp's own server build
60
+ (about 860 MB of models). Two more come down the first time they are used: speech for the buddy's
61
+ Talk (Silero VAD, 1.3 MB, and on a Mac with macOS 26 Apple's own on-device transcriber through
62
+ the buddy; elsewhere NVIDIA's Parakeet TDT 0.6B v3, 672 MB) and the search model's image and audio part
63
+ (555 MB) when you first add a folder of recordings or screenshots. The search model runs on the
64
+ GPU when this machine's check finds it faster, the CPU otherwise; the classifiers run in native
65
+ ONNX Runtime. Background work runs at the OS's background priority and waits in Low Power Mode,
66
+ on low battery and when the machine is hot.
67
+
68
+ What they do:
69
+
70
+ - **Briefs:** every brief a local coding agent gets carries a context pack (the files in its
71
+ repository most likely needed, earlier sessions on them, and a recording's minute or a
72
+ screenshot from your added folders when one is close) and the thread's task frame: what you
73
+ named resolved to file and line, the repository's state, other threads changing the same
74
+ repository, and each run's receipt. Agents can call `search_code`, `search_media`,
75
+ `compare_screens` (two screenshots of UI work, before and after: what changed and which text)
76
+ and `search_screen` (what you saw in tabs shared with Harness, asked of your own Harness window
77
+ sealed over the `tryharness link` line, so Harness's server carries it unread; your Allow in
78
+ that window comes first).
79
+ - **Checks:** a finished run gets a receipt from its own session file; the coordinator hands an
80
+ untested "tests pass" to an agent to check. An agent's post that reads like orders to the
81
+ swarm is held; a key in anything sent to Harness's server goes as `[SECRET_n]` (and, in requests
82
+ that only decide, emails, phone numbers, wallet addresses and amounts as placeholders too); a
83
+ screenshot a cloud agent reads has keys, addresses and amounts covered; code a cloud agent asks to
84
+ see (`request_snippet`) leaves only when you share it (`tryharness board snippet`).
85
+ - **Watches:** a command on this computer checked on a schedule (a heartbeat's age, a balance, a
86
+ count of error lines), said out loud only when it is off. Agents suggest them with
87
+ `suggest_watch`; nothing runs until you start it.
88
+ - **Learning:** overnight, on the charger, the ranking model retrains on what the agents' runs
89
+ actually changed and is kept only if it beats the current one on the newest runs.
54
90
 
55
- Setup also downloads the embedding model the daemon uses for a task's context space,
56
- about 110 MB into `~/.harness/models`, reported like indexing is. It is the same model,
57
- quantization, pooling and normalization as Harness in the browser (bge-base, q8, CLS,
58
- L2), under the same version key, run on the wasm build of the ONNX runtime with no native
59
- addon, so a vector the daemon makes and one the browser makes describe the same space. The
60
- daemon never asks a browser tab to embed; until the model is present, context search
61
- answers from full-text search and says so.
91
+ ```sh
92
+ npx tryharness local # what is installed, where it runs, what is indexed, the last learning
93
+ npx tryharness local learn # start the learner now
94
+ npx tryharness local add ~/Recordings # a folder of recordings and screenshots agents can search
95
+ npx tryharness watch # watches and what each last read
96
+ npx tryharness watch add "keeper heartbeat" --run "cat state/heartbeat.json" --age completedAt --above 900 --every 5m
97
+ ```
62
98
 
63
99
  ## Task board
64
100
 
@@ -135,3 +171,7 @@ room. Agents read, post and search the room with `read_room`, `post_to_room` and
135
171
  Start. Harness facilitates and answers once. `tryharness goal show <id>` prints the room, `goal stop <id>` ends
136
172
  everything, `goal resume <id>` reopens a room that paused at a backstop, and `goal invite <id> handle/agent` asks another
137
173
  person's agent to join as a guest over a relay thread. `tryharness room post <goal> <text> [--guests]` posts as you.
174
+
175
+ Parakeet TDT 0.6B v3 is by NVIDIA, licensed under CC BY 4.0
176
+ (https://creativecommons.org/licenses/by/4.0/); the relay uses the 8-bit ONNX export by
177
+ istupakov (https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx), unmodified.
@@ -8,8 +8,11 @@ import { pathToFileURL } from "node:url";
8
8
  // Filled in by setup: the runtime the other agents' Harness server runs from.
9
9
  const RUNTIME = "__HARNESS_RUNTIME__";
10
10
  const AGENT = "opencode";
11
- const harness = () => import(pathToFileURL(path.join(RUNTIME, "lib", "nudge.mjs")).href);
12
- const socketPath = async () => (await import(pathToFileURL(path.join(RUNTIME, "lib", "paths.mjs")).href)).paths.socket;
11
+ const harness = () =>
12
+ import(pathToFileURL(path.join(RUNTIME, "lib", "nudge.mjs")).href);
13
+ const socketPath = async () =>
14
+ (await import(pathToFileURL(path.join(RUNTIME, "lib", "paths.mjs")).href))
15
+ .paths.socket;
13
16
 
14
17
  async function notify(message) {
15
18
  try {
@@ -28,8 +31,17 @@ const pids = [process.pid, process.ppid];
28
31
  let counter = 0;
29
32
  const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
30
33
  const partId = () => {
31
- const time = ((BigInt(Date.now()) * 0x1000n) + BigInt(++counter % 0x1000)) & 0xffffffffffffn;
32
- return "prt_" + time.toString(16).padStart(12, "0") + Array.from(crypto.getRandomValues(new Uint8Array(14)), (byte) => BASE62[byte % 62]).join("");
34
+ const time =
35
+ (BigInt(Date.now()) * 0x1000n + BigInt(++counter % 0x1000)) &
36
+ 0xffffffffffffn;
37
+ return (
38
+ "prt_" +
39
+ time.toString(16).padStart(12, "0") +
40
+ Array.from(
41
+ crypto.getRandomValues(new Uint8Array(14)),
42
+ (byte) => BASE62[byte % 62]
43
+ ).join("")
44
+ );
33
45
  };
34
46
 
35
47
  export const TryHarnessPlugin = async ({ directory, client }) => ({
@@ -48,21 +60,47 @@ export const TryHarnessPlugin = async ({ directory, client }) => ({
48
60
  const messageID = output.message?.id || input?.messageID;
49
61
  if (!messageID) return;
50
62
  const messages = await board.takeMessages(AGENT, sessionId);
51
- const nudge = first ? await board.boardNudge(AGENT) : await board.boardNudgeIfChanged(AGENT);
63
+ const nudge = first
64
+ ? await board.boardNudge(AGENT)
65
+ : await board.boardNudgeIfChanged(AGENT);
52
66
  // Marked synthetic: the model reads them, and they are not the words typed here.
53
- for (const text of [messages.length ? board.messagesText(messages) : "", nudge]) {
54
- if (text) output.parts.push({ id: partId(), sessionID: sessionId, messageID, type: "text", text, synthetic: true });
67
+ for (const text of [
68
+ messages.length ? board.messagesText(messages) : "",
69
+ nudge,
70
+ ]) {
71
+ if (text)
72
+ output.parts.push({
73
+ id: partId(),
74
+ sessionID: sessionId,
75
+ messageID,
76
+ type: "text",
77
+ text,
78
+ synthetic: true,
79
+ });
55
80
  }
56
81
  },
57
82
  event: async ({ event }) => {
58
83
  if (event?.type !== "session.idle") return;
59
- const sessionId = event?.properties?.sessionID || event?.properties?.sessionId;
84
+ const sessionId =
85
+ event?.properties?.sessionID || event?.properties?.sessionId;
60
86
  if (!sessionId) return;
61
87
  await notify({ command: "sync", agent: AGENT, sessionId, cwd: directory });
62
88
  // When a turn ends, a message waiting from Harness starts the next one (Claude Code's Stop hook does the same).
63
89
  const board = await harness().catch(() => null);
64
90
  const messages = board ? await board.takeMessages(AGENT, sessionId) : [];
65
- const send = client?.session?.promptAsync?.bind(client.session) ?? client?.session?.prompt?.bind(client.session);
66
- if (messages.length && send) await send({ path: { id: sessionId }, body: { parts: [{ type: "text", text: board.messagesText(messages) }] } }).catch(() => {});
91
+ const send =
92
+ client?.session?.promptAsync?.bind(client.session) ??
93
+ client?.session?.prompt?.bind(client.session);
94
+ if (messages.length && send)
95
+ await send({
96
+ path: { id: sessionId },
97
+ body: { parts: [{ type: "text", text: board.messagesText(messages) }] },
98
+ }).catch(() => {});
99
+ else if (board && !messages.length)
100
+ await board.noteTurn(
101
+ AGENT,
102
+ { session_id: sessionId, cwd: directory },
103
+ pids
104
+ );
67
105
  },
68
106
  });
@@ -11,14 +11,19 @@ import { pathToFileURL } from "node:url";
11
11
  const RUNTIME = "__HARNESS_RUNTIME__";
12
12
  const NODE = "__HARNESS_NODE__";
13
13
  const AGENT = "pi";
14
- const harness = () => import(pathToFileURL(path.join(RUNTIME, "lib", "nudge.mjs")).href);
14
+ const harness = () =>
15
+ import(pathToFileURL(path.join(RUNTIME, "lib", "nudge.mjs")).href);
15
16
 
16
17
  // The server, one per session: JSON-RPC over its stdin and stdout.
17
18
  let server = null;
18
19
  let nextId = 1;
19
20
  function start() {
20
21
  if (server) return server;
21
- const child = spawn(NODE, [path.join(RUNTIME, "lib", "mcp-stdio.mjs"), "--agent", AGENT], { stdio: ["pipe", "pipe", "ignore"] });
22
+ const child = spawn(
23
+ NODE,
24
+ [path.join(RUNTIME, "lib", "mcp-stdio.mjs"), "--agent", AGENT],
25
+ { stdio: ["pipe", "pipe", "ignore"] }
26
+ );
22
27
  const pending = new Map();
23
28
  let buffer = "";
24
29
  child.stdout.setEncoding("utf8");
@@ -28,14 +33,19 @@ function start() {
28
33
  buffer = lines.pop();
29
34
  for (const line of lines) {
30
35
  let message;
31
- try { message = JSON.parse(line); } catch { continue; }
36
+ try {
37
+ message = JSON.parse(line);
38
+ } catch {
39
+ continue;
40
+ }
32
41
  pending.get(message.id)?.(message);
33
42
  pending.delete(message.id);
34
43
  }
35
44
  });
36
45
  const stopped = () => {
37
46
  if (server?.child === child) server = null;
38
- for (const settle of pending.values()) settle({ error: { message: "the Harness server stopped" } });
47
+ for (const settle of pending.values())
48
+ settle({ error: { message: "the Harness server stopped" } });
39
49
  pending.clear();
40
50
  };
41
51
  child.on("exit", stopped);
@@ -47,34 +57,61 @@ function rpc(method, params = {}) {
47
57
  const { child, pending } = start();
48
58
  const id = nextId++;
49
59
  return new Promise((resolve, reject) => {
50
- pending.set(id, (message) => (message.error ? reject(new Error(message.error.message)) : resolve(message.result)));
51
- child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
60
+ pending.set(id, (message) =>
61
+ message.error
62
+ ? reject(new Error(message.error.message))
63
+ : resolve(message.result)
64
+ );
65
+ child.stdin.write(
66
+ JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"
67
+ );
52
68
  });
53
69
  }
54
70
 
55
71
  let typeApi = null;
56
72
  async function types() {
57
73
  if (typeApi) return typeApi;
58
- try { typeApi = (await import("@sinclair/typebox")).Type; }
59
- catch { const loaded = await import("typebox"); typeApi = loaded.Type || loaded; }
74
+ try {
75
+ typeApi = (await import("@sinclair/typebox")).Type;
76
+ } catch {
77
+ const loaded = await import("typebox");
78
+ typeApi = loaded.Type || loaded;
79
+ }
60
80
  return typeApi;
61
81
  }
62
82
 
63
83
  function toType(schema, Type) {
64
84
  if (!schema || typeof schema !== "object") return Type.Any();
65
- const opts = typeof schema.description === "string" ? { description: schema.description } : {};
66
- if (Array.isArray(schema.enum) && schema.enum.length) return Type.Union(schema.enum.map((value) => Type.Literal(value)), opts);
67
- if (Array.isArray(schema.anyOf || schema.oneOf)) return Type.Union((schema.anyOf || schema.oneOf).map((entry) => toType(entry, Type)), opts);
85
+ const opts =
86
+ typeof schema.description === "string"
87
+ ? { description: schema.description }
88
+ : {};
89
+ if (Array.isArray(schema.enum) && schema.enum.length)
90
+ return Type.Union(
91
+ schema.enum.map((value) => Type.Literal(value)),
92
+ opts
93
+ );
94
+ if (Array.isArray(schema.anyOf || schema.oneOf))
95
+ return Type.Union(
96
+ (schema.anyOf || schema.oneOf).map((entry) => toType(entry, Type)),
97
+ opts
98
+ );
68
99
  if (schema.type === "object") {
69
100
  const required = new Set(schema.required || []);
70
101
  const properties = {};
71
102
  for (const [name, child] of Object.entries(schema.properties || {})) {
72
103
  const converted = toType(child, Type);
73
- properties[name] = required.has(name) ? converted : Type.Optional(converted);
104
+ properties[name] = required.has(name)
105
+ ? converted
106
+ : Type.Optional(converted);
74
107
  }
75
- return Type.Object(properties, { ...opts, additionalProperties: schema.additionalProperties === true });
108
+ return Type.Object(properties, {
109
+ ...opts,
110
+ additionalProperties: schema.additionalProperties === true,
111
+ });
76
112
  }
77
- if (schema.type === "array") return Type.Array(toType(schema.items, Type), opts);
113
+ if (schema.type === "array")
114
+ return Type.Array(toType(schema.items, Type), opts);
78
115
  if (schema.type === "string") return Type.String(opts);
79
116
  if (schema.type === "integer") return Type.Integer(opts);
80
117
  if (schema.type === "number") return Type.Number(opts);
@@ -91,32 +128,61 @@ export default function tryHarness(pi) {
91
128
  const Type = await types();
92
129
  const listing = await rpc("tools/list");
93
130
  for (const def of listing?.tools || []) {
94
- if (!def?.name || def.name === "report_session" || installed.has(def.name)) continue;
131
+ if (
132
+ !def?.name ||
133
+ def.name === "report_session" ||
134
+ installed.has(def.name)
135
+ )
136
+ continue;
95
137
  pi.registerTool({
96
138
  name: def.name,
97
139
  label: "Harness: " + def.name.replaceAll("_", " "),
98
- description: def.description || "Use this connected Harness capability.",
140
+ description:
141
+ def.description || "Use this connected Harness capability.",
99
142
  parameters: toType(def.inputSchema, Type),
100
143
  async execute(_id, params) {
101
- const result = await rpc("tools/call", { name: def.name, arguments: params || {} });
102
- const content = result?.content || [{ type: "text", text: "Harness returned no content." }];
144
+ const result = await rpc("tools/call", {
145
+ name: def.name,
146
+ arguments: params || {},
147
+ });
148
+ const content = result?.content || [
149
+ { type: "text", text: "Harness returned no content." },
150
+ ];
103
151
  // pi marks a tool failed when it throws.
104
- if (result?.isError) throw new Error(content.map((part) => part.text || "").join("\n") || "Harness call failed.");
152
+ if (result?.isError)
153
+ throw new Error(
154
+ content.map((part) => part.text || "").join("\n") ||
155
+ "Harness call failed."
156
+ );
105
157
  return { content, details: undefined };
106
158
  },
107
159
  });
108
160
  installed.add(def.name);
109
161
  }
110
162
  ctx?.ui?.notify?.("Harness connected", "info");
111
- })().catch((error) => { connecting = null; ctx?.ui?.notify?.("Harness unavailable: " + error?.message, "warning"); });
163
+ })().catch((error) => {
164
+ connecting = null;
165
+ ctx?.ui?.notify?.("Harness unavailable: " + error?.message, "warning");
166
+ });
112
167
  return connecting;
113
168
  };
114
- const where = (ctx) => ({ session_id: ctx?.sessionManager?.getSessionId?.(), cwd: ctx?.cwd || process.cwd() });
169
+ const where = (ctx) => ({
170
+ session_id: ctx?.sessionManager?.getSessionId?.(),
171
+ cwd: ctx?.cwd || process.cwd(),
172
+ });
115
173
  const pids = [process.pid, process.ppid];
116
174
  const tell = (nudge, ctx) => {
117
175
  if (!nudge) return;
118
176
  // Into the model's context when pi offers it; always visible to the person.
119
- if (typeof pi.sendMessage === "function") { try { pi.sendMessage({ customType: "harness-board", content: nudge, display: true }); } catch {} }
177
+ if (typeof pi.sendMessage === "function") {
178
+ try {
179
+ pi.sendMessage({
180
+ customType: "harness-board",
181
+ content: nudge,
182
+ display: true,
183
+ });
184
+ } catch {}
185
+ }
120
186
  ctx?.ui?.notify?.(nudge.split("\n").slice(0, 2).join(" "), "info");
121
187
  };
122
188
  pi.on("session_start", async (_event, ctx) => {
@@ -132,7 +198,9 @@ export default function tryHarness(pi) {
132
198
  // What the person sent this session from Harness (board.live.say), taken once, as Claude Code's hooks take it.
133
199
  const said = async (board, ctx) => {
134
200
  const sessionId = where(ctx).session_id;
135
- const messages = sessionId ? await board.takeMessages(AGENT, sessionId) : [];
201
+ const messages = sessionId
202
+ ? await board.takeMessages(AGENT, sessionId)
203
+ : [];
136
204
  return messages.length ? board.messagesText(messages) : "";
137
205
  };
138
206
  pi.on("before_agent_start", async (event, ctx) => {
@@ -140,7 +208,9 @@ export default function tryHarness(pi) {
140
208
  const board = await harness().catch(() => null);
141
209
  if (!board) return undefined;
142
210
  await board.notePrompt(AGENT, where(ctx), pids);
143
- const text = [await said(board, ctx), await board.boardNudge(AGENT)].filter(Boolean).join("\n\n");
211
+ const text = [await said(board, ctx), await board.boardNudge(AGENT)]
212
+ .filter(Boolean)
213
+ .join("\n\n");
144
214
  if (!text || typeof event?.systemPrompt !== "string") return undefined;
145
215
  return { systemPrompt: `${event.systemPrompt}\n\n${text}` };
146
216
  });
@@ -149,12 +219,22 @@ export default function tryHarness(pi) {
149
219
  const board = await harness().catch(() => null);
150
220
  const text = board ? await said(board, ctx) : "";
151
221
  if (text) pi.sendUserMessage(text);
222
+ else if (board) await board.noteTurn(AGENT, where(ctx), pids);
152
223
  });
153
224
  // After each turn (pi 0.87 event name turn_end), only when the queue moved.
154
225
  pi.on("turn_end", async (_event, ctx) => {
155
226
  const board = await harness().catch(() => null);
156
227
  if (board) tell(await board.boardNudgeIfChanged(AGENT), ctx);
157
228
  });
158
- pi.on("session_shutdown", () => { server?.child.kill(); server = null; });
159
- pi.registerCommand("harness-reconnect", { description: "Reconnect Harness", handler: async (_args, ctx) => { connecting = null; await connect(ctx); } });
229
+ pi.on("session_shutdown", () => {
230
+ server?.child.kill();
231
+ server = null;
232
+ });
233
+ pi.registerCommand("harness-reconnect", {
234
+ description: "Reconnect Harness",
235
+ handler: async (_args, ctx) => {
236
+ connecting = null;
237
+ await connect(ctx);
238
+ },
239
+ });
160
240
  }