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/lib/runner.mjs CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import path from "node:path";
4
4
  import { spawn } from "node:child_process";
5
+ import { agentEnvironment } from "./agentnode.mjs";
5
6
 
6
7
  const running = new Set();
7
8
  const children = new Map();
@@ -21,6 +22,13 @@ export function trackRun(runId, stop) {
21
22
  return () => { if (children.get(runId) === entry) children.delete(runId); };
22
23
  }
23
24
 
25
+ /** A short command the daemon runs itself (an agent listing its models): while it runs, not the person's session either. */
26
+ export function trackHelper(child) {
27
+ const id = `helper:${child.pid}`;
28
+ children.set(id, child);
29
+ child.on("close", () => children.delete(id));
30
+ }
31
+
24
32
  /** The process ids of the runs in progress, so they are not mistaken for the person's own sessions. */
25
33
  export function runningPids() {
26
34
  return [...children.values()].map((child) => child.pid).filter(Boolean);
@@ -48,12 +56,21 @@ export const restrictedMcpConfig = () => ({ mcpServers: { harness: { command: pr
48
56
  const restrictedClaude = (task) => ["--permission-mode", "dontAsk", "--allowedTools", task.readOnly ? "mcp__harness" : "Edit Write mcp__harness",
49
57
  "--setting-sources", "user", "--settings", JSON.stringify(RESTRICTED_CLAUDE_SETTINGS), "--strict-mcp-config", "--mcp-config", JSON.stringify(restrictedMcpConfig())];
50
58
 
59
+ // The model and effort the relay chose for the run (models.mjs), in each agent's own flags; none leaves its settings as they are.
60
+ const MODEL_FLAGS = {
61
+ "claude-code": { model: (id) => ["--model", id], effort: (level) => ["--effort", level] },
62
+ codex: { model: (id) => ["-m", id], effort: (level) => ["-c", `model_reasoning_effort="${level}"`] },
63
+ pi: { model: (id) => ["--model", id], effort: (level) => ["--thinking", level] },
64
+ opencode: { model: (id) => ["-m", id], effort: (level) => ["--variant", level] },
65
+ };
66
+ const modelFlags = ({ agent, model, effort }) => [...(model ? MODEL_FLAGS[agent]?.model(model) ?? [] : []), ...(effort ? MODEL_FLAGS[agent]?.effort(effort) ?? [] : [])];
67
+
51
68
  export function agentInvocation(task) {
52
69
  if (task.agent === "claude-code") {
53
70
  return {
54
71
  command: "claude",
55
72
  // A review reads and reports: plan mode lets Claude Code read and run read-only tools but never edit.
56
- args: ["-p", task.brief, "--output-format", "stream-json", "--verbose", ...(task.restricted ? restrictedClaude(task) : task.readOnly ? ["--permission-mode", "plan"] : ["--dangerously-skip-permissions"]), "--name", `Harness: ${task.title}`, ...(task.resumeSession ? ["--resume", task.resumeSession] : [])],
73
+ args: ["-p", task.brief, "--output-format", "stream-json", "--verbose", ...(task.restricted ? restrictedClaude(task) : task.readOnly ? ["--permission-mode", "plan"] : ["--dangerously-skip-permissions"]), "--name", `Harness: ${task.title}`, ...modelFlags(task), ...(task.resumeSession ? ["--resume", task.resumeSession] : [])],
57
74
  };
58
75
  }
59
76
  // Only Claude Code has a restricted mode; any other agent's restricted run is refused here, whatever let it start.
@@ -61,15 +78,15 @@ export function agentInvocation(task) {
61
78
  if (task.agent === "codex") {
62
79
  return {
63
80
  command: "codex",
64
- args: ["exec", ...(task.resumeSession ? ["resume", task.resumeSession] : []), "--json", ...(task.readOnly ? ["-c", 'sandbox_mode="read-only"', "-c", 'approval_policy="never"'] : ["--dangerously-bypass-approvals-and-sandbox"]), "--skip-git-repo-check", "-"],
81
+ args: ["exec", ...(task.resumeSession ? ["resume", task.resumeSession] : []), "--json", ...modelFlags(task), ...(task.readOnly ? ["-c", 'sandbox_mode="read-only"', "-c", 'approval_policy="never"'] : ["--dangerously-bypass-approvals-and-sandbox"]), "--skip-git-repo-check", "-"],
65
82
  stdin: task.brief,
66
83
  };
67
84
  }
68
85
  // Pi has no permission prompts. --approve trusts project-local configuration for
69
86
  // this non-interactive run; its complete built-in tool set remains enabled.
70
- if (task.agent === "pi") return { command: "pi", args: ["--mode", "json", "--approve", "-p", task.brief] };
87
+ if (task.agent === "pi") return { command: "pi", args: ["--mode", "json", "--approve", ...modelFlags(task), "-p", task.brief] };
71
88
  if (task.agent === "opencode") {
72
- return { command: "opencode", args: ["run", "--format", "json", "--auto", ...(task.resumeSession ? ["--session", task.resumeSession] : ["--title", task.title]), "--", task.brief] };
89
+ return { command: "opencode", args: ["run", "--format", "json", "--auto", ...modelFlags(task), ...(task.resumeSession ? ["--session", task.resumeSession] : ["--title", task.title]), "--", task.brief] };
73
90
  }
74
91
  throw new Error(`Unsupported agent: ${task.agent}`);
75
92
  }
@@ -160,9 +177,13 @@ export function runAgentTask(task, emit, observe = () => {}) {
160
177
  }
161
178
  let cwd;
162
179
  let invocation;
180
+ let environment;
163
181
  try {
164
182
  cwd = resolveWorkingDirectory(task.cwd);
165
183
  invocation = agentInvocation(task);
184
+ // A Node agent runs on a Node it accepts, whichever Node Harness itself runs on.
185
+ environment = agentEnvironment(invocation.command);
186
+ if (environment.error) throw new Error(environment.error);
166
187
  } catch (error) {
167
188
  emit("failed", error.message);
168
189
  return;
@@ -170,7 +191,9 @@ export function runAgentTask(task, emit, observe = () => {}) {
170
191
  emit("accepted");
171
192
  let child;
172
193
  try {
173
- child = spawn(invocation.command, invocation.args, { cwd });
194
+ child = spawn(invocation.command, invocation.args, { cwd, env: environment.env });
195
+ // An agent that exits before reading its brief breaks the pipe; its exit says how the run went.
196
+ child.stdin.on("error", () => {});
174
197
  child.stdin.end(invocation.stdin);
175
198
  } catch (error) {
176
199
  running.delete(slot);
@@ -181,6 +204,7 @@ export function runAgentTask(task, emit, observe = () => {}) {
181
204
  children.set(task.runId, child);
182
205
 
183
206
  let resultText = "";
207
+ let modelError = "";
184
208
  let tail = "";
185
209
  let buffer = "";
186
210
  let sessionReported = false;
@@ -205,6 +229,12 @@ export function runAgentTask(task, emit, observe = () => {}) {
205
229
  if (event.type === "result" && typeof event.result === "string") resultText = event.result;
206
230
  if (event.type === "item.completed" && event.item?.type === "agent_message" && typeof event.item.text === "string") resultText = event.item.text;
207
231
  if (["text", "message.part.updated"].includes(event.type) && typeof event.part?.text === "string") resultText = event.part.text;
232
+ // Pi: its last words are its answer; when its model call fails, Pi still exits 0 with the error on the message.
233
+ if (event.type === "message_end" && event.message?.role === "assistant") {
234
+ const said = (Array.isArray(event.message.content) ? event.message.content : []).filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
235
+ if (said) resultText = said;
236
+ modelError = event.message.stopReason === "error" ? String(event.message.errorMessage || "no error message") : "";
237
+ }
208
238
  for (const step of stepsOf(event)) emit("progress", step.text.slice(0, 200), { step });
209
239
  }
210
240
  });
@@ -216,7 +246,8 @@ export function runAgentTask(task, emit, observe = () => {}) {
216
246
  child.on("close", (code) => {
217
247
  running.delete(slot);
218
248
  children.delete(task.runId);
219
- if (code === 0) emit("done", (resultText || "The run finished; check the repository for its changes.").slice(0, 20_000), { usage: usage.result() });
249
+ if (code === 0 && modelError) emit("failed", `${task.agent} stopped on an error from its model: ${modelError}`.slice(0, 4_000), { usage: usage.result() });
250
+ else if (code === 0) emit("done", (resultText || "The run finished; check the repository for its changes.").slice(0, 20_000), { usage: usage.result() });
220
251
  else emit("failed", (`${task.agent} exited with code ${code}.\n${tail}`).slice(0, 4_000), { usage: usage.result() });
221
252
  });
222
253
  child.on("error", (error) => {
@@ -12,10 +12,10 @@ function dependencyTarget(root, name) {
12
12
  return path.join(root, "node_modules", ...name.split("/"));
13
13
  }
14
14
 
15
- // What the runtime keeps of a large package. onnxruntime-web ships browser bundles for every
16
- // backend (125 MB); the daemon needs its Node entry and the one wasm binary that entry loads.
15
+ // What the runtime keeps of a large package. onnxruntime-node ships native binaries for every OS (about 200 MB); the
16
+ // daemon needs its JavaScript and this machine's own binary.
17
17
  const KEEP = {
18
- "onnxruntime-web": ["package.json", "types.d.ts", "dist/ort.node.min.mjs", "dist/ort.node.min.js", "dist/ort-wasm-simd-threaded.wasm", "dist/ort-wasm-simd-threaded.mjs"],
18
+ "onnxruntime-node": ["package.json", "dist", "lib", `bin/napi-v6/${process.platform}/${process.arch}`],
19
19
  };
20
20
 
21
21
  function copyPackage(source, target, name) {
@@ -25,7 +25,7 @@ function copyPackage(source, target, name) {
25
25
  const from = path.join(source, file);
26
26
  if (!fs.existsSync(from)) continue;
27
27
  ensureDir(path.dirname(path.join(target, file)));
28
- fs.cpSync(from, path.join(target, file), { force: true });
28
+ fs.cpSync(from, path.join(target, file), { recursive: true, force: true });
29
29
  }
30
30
  }
31
31
 
@@ -76,7 +76,8 @@ export function installRuntime({ sourceRoot, version }) {
76
76
  if (!fs.existsSync(source)) continue;
77
77
  fs.cpSync(source, path.join(staging, entry), { recursive: true, force: true });
78
78
  }
79
- copyDependencyClosure(sourceRoot, staging, ["node-sqlite3-wasm", "ws", "onnxruntime-web", "@huggingface/tokenizers", "@clack/prompts", "@clack/core", "picocolors"]);
79
+ // Everything the package depends on, so a dependency added there cannot be left out here.
80
+ copyDependencyClosure(sourceRoot, staging, Object.keys(readJson(path.join(sourceRoot, "package.json"), {}).dependencies || {}));
80
81
  fs.writeFileSync(path.join(staging, ".ready"), version + "\n", { mode: 0o600 });
81
82
  ensureDir(path.dirname(release));
82
83
  fs.rmSync(release, { recursive: true, force: true });
@@ -0,0 +1,96 @@
1
+ import { decodeImage } from "./ocr.mjs";
2
+
3
+ // Two screenshots of the same screen, before and after an agent's UI change: which parts of the picture changed, and
4
+ // what the text on screen says now that it did not before. The pixels find where; the text read on this computer
5
+ // (ocr.mjs) finds what, down to a lost space ("yet OPEN" becoming "yetOPEN"). Code only, no model judges: the agent
6
+ // that made the change reads the list against what it was asked to do.
7
+
8
+ const CELL = 16;
9
+ // A cell counts as changed when its pixels differ by more than this on average (0 to 255): re-rendering the same page
10
+ // moves anti-aliasing by a little; the ui-checks eval set it against identical renders.
11
+ const CELL_DIFF = 6;
12
+ const MIN_OVERLAP = 0.3;
13
+
14
+ const luminance = (data, i) => 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
15
+ const overlap = (a, b) => {
16
+ const w = Math.max(0, Math.min(a[2], b[2]) - Math.max(a[0], b[0])), h = Math.max(0, Math.min(a[3], b[3]) - Math.max(a[1], b[1]));
17
+ const area = (box) => Math.max(1, (box[2] - box[0]) * (box[3] - box[1]));
18
+ return (w * h) / Math.min(area(a), area(b));
19
+ };
20
+
21
+ /** Changed regions of two same-size images: [{ box: [x0, y0, x1, y1], share }], largest first. */
22
+ export function changedRegions(before, after, { cell = CELL, threshold = CELL_DIFF } = {}) {
23
+ const cols = Math.ceil(before.width / cell), rows = Math.ceil(before.height / cell);
24
+ const changed = new Uint8Array(cols * rows);
25
+ for (let cy = 0; cy < rows; cy += 1) for (let cx = 0; cx < cols; cx += 1) {
26
+ let sum = 0, count = 0;
27
+ for (let y = cy * cell; y < Math.min(before.height, (cy + 1) * cell); y += 1) for (let x = cx * cell; x < Math.min(before.width, (cx + 1) * cell); x += 1) {
28
+ const i = (y * before.width + x) * 4;
29
+ sum += Math.abs(luminance(before.data, i) - luminance(after.data, i)) + Math.abs(before.data[i] - after.data[i]) / 3 + Math.abs(before.data[i + 2] - after.data[i + 2]) / 3;
30
+ count += 1;
31
+ }
32
+ if (sum / count > threshold) changed[cy * cols + cx] = 1;
33
+ }
34
+ // Touching changed cells become one region.
35
+ const regions = [];
36
+ const seen = new Uint8Array(cols * rows);
37
+ for (let start = 0; start < changed.length; start += 1) {
38
+ if (!changed[start] || seen[start]) continue;
39
+ const stack = [start];
40
+ seen[start] = 1;
41
+ let x0 = Infinity, y0 = Infinity, x1 = -1, y1 = -1, cells = 0;
42
+ while (stack.length) {
43
+ const at = stack.pop(), cx = at % cols, cy = (at - cx) / cols;
44
+ cells += 1; x0 = Math.min(x0, cx); y0 = Math.min(y0, cy); x1 = Math.max(x1, cx); y1 = Math.max(y1, cy);
45
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1], [1, -1], [-1, 1]]) {
46
+ const nx = cx + dx, ny = cy + dy, next = ny * cols + nx;
47
+ if (nx >= 0 && ny >= 0 && nx < cols && ny < rows && changed[next] && !seen[next]) { seen[next] = 1; stack.push(next); }
48
+ }
49
+ }
50
+ regions.push({ box: [x0 * cell, y0 * cell, Math.min(before.width, (x1 + 1) * cell), Math.min(before.height, (y1 + 1) * cell)], share: Math.round((cells / (cols * rows)) * 10_000) / 10_000 });
51
+ }
52
+ return regions.sort((a, b) => b.share - a.share);
53
+ }
54
+
55
+ /** Lines of text read before and after, paired by where they sit: { changed: [{ before, after, box }], moved, removed, added }. */
56
+ export function textChanges(beforeLines, afterLines) {
57
+ const used = new Set();
58
+ const changed = [], removed = [];
59
+ for (const line of beforeLines) {
60
+ const same = afterLines.findIndex((other, i) => !used.has(i) && other.text === line.text && overlap(line.box, other.box) >= MIN_OVERLAP);
61
+ if (same >= 0) { used.add(same); continue; }
62
+ let best = -1, bestOverlap = MIN_OVERLAP;
63
+ afterLines.forEach((other, i) => { const o = overlap(line.box, other.box); if (!used.has(i) && o >= bestOverlap) { best = i; bestOverlap = o; } });
64
+ if (best >= 0) { used.add(best); changed.push({ before: line.text, after: afterLines[best].text, box: afterLines[best].box }); }
65
+ else removed.push({ text: line.text, box: line.box });
66
+ }
67
+ const added = afterLines.filter((_, i) => !used.has(i)).map((line) => ({ text: line.text, box: line.box }));
68
+ // The same words gone from one place and new in another moved.
69
+ const moved = [];
70
+ for (const line of [...removed]) {
71
+ const to = added.findIndex((other) => other.text === line.text);
72
+ if (to < 0) continue;
73
+ moved.push({ text: line.text, from: line.box, to: added[to].box });
74
+ removed.splice(removed.indexOf(line), 1);
75
+ added.splice(to, 1);
76
+ }
77
+ return { changed, moved, removed, added };
78
+ }
79
+
80
+ /** Compares two screenshots (PNG or JPEG bytes): { size, regions, text, summary }. */
81
+ export async function compareScreens(beforeBytes, afterBytes, { ocr, lane = "interactive" }) {
82
+ const before = decodeImage(beforeBytes), after = decodeImage(afterBytes);
83
+ if (!before || !after) throw new Error("both screenshots must be PNG or JPEG");
84
+ const sameSize = before.width === after.width && before.height === after.height;
85
+ const regions = sameSize ? changedRegions(before, after) : [];
86
+ const [readBefore, readAfter] = [await ocr.read(before, { lane }), await ocr.read(after, { lane })];
87
+ const text = textChanges(readBefore.lines, readAfter.lines);
88
+ const parts = [];
89
+ if (!sameSize) parts.push(`the screenshots differ in size (${before.width}x${before.height} and ${after.width}x${after.height}), so only their text was compared`);
90
+ else parts.push(regions.length ? `${regions.length} region${regions.length === 1 ? "" : "s"} of the picture changed` : "no part of the picture changed");
91
+ for (const change of text.changed) parts.push(`"${change.before}" now reads "${change.after}"`);
92
+ for (const line of text.moved) parts.push(`"${line.text}" moved`);
93
+ if (text.removed.length) parts.push(`gone: ${text.removed.map((line) => `"${line.text}"`).join(", ")}`);
94
+ if (text.added.length) parts.push(`new: ${text.added.map((line) => `"${line.text}"`).join(", ")}`);
95
+ return { size: { before: [before.width, before.height], after: [after.width, after.height] }, regions: regions.slice(0, 20), text, summary: `${parts.join("; ")}.`, ms: readBefore.ms + readAfter.ms };
96
+ }
package/lib/sealed.mjs CHANGED
@@ -13,6 +13,9 @@ import { timingSafeEqual, webcrypto } from "node:crypto";
13
13
  // - Every message: AES-256-GCM under HKDF-SHA256(ECDH(device, daemon)), a fresh 96-bit nonce,
14
14
  // and the direction, device and request id bound as associated data. Requests carry a
15
15
  // timestamp; the daemon refuses stale ones and any request id it has already seen.
16
+ // - The other way round, the daemon asks a paired browser (its screen history): the question goes
17
+ // "d2b" and the answer "b2d" under the same key, so neither opens as a message of the device's
18
+ // own calls. The browser applies the same freshness and replay rules (phones.mjs ask).
16
19
  // Not provided: forward secrecy (both keys are static). Revoking a device ends its key.
17
20
 
18
21
  const subtle = webcrypto.subtle;
package/lib/service.mjs CHANGED
@@ -118,12 +118,14 @@ export function installService({ runtimeRoot, nodePath = process.execPath, platf
118
118
  if (platform === "darwin") {
119
119
  const plist = servicePath();
120
120
  ensureDir(path.dirname(plist));
121
+ // MallocLargeCache=0: macOS otherwise keeps the large blocks ONNX Runtime frees (a screenshot's OCR held 1.6 GB after
122
+ // it finished; 255 MB with it off, image-redaction eval).
121
123
  fs.writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
122
124
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
123
125
  <plist version="1.0"><dict>
124
126
  <key>Label</key><string>${LABEL}</string>
125
127
  <key>ProgramArguments</key><array><string>${xml(nodePath)}</string><string>${xml(entry)}</string><string>daemon</string></array>
126
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(envPath)}</string></dict>
128
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(envPath)}</string><key>MallocLargeCache</key><string>0</string></dict>
127
129
  <key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>5</integer>
128
130
  <key>StandardOutPath</key><string>${xml(paths.log)}</string><key>StandardErrorPath</key><string>${xml(paths.log)}</string>
129
131
  </dict></plist>\n`, { mode: 0o600 });
@@ -145,7 +147,7 @@ export function installService({ runtimeRoot, nodePath = process.execPath, platf
145
147
  }
146
148
  // Other Node platforms get a ready daemon for this login only. `doctor` reports that
147
149
  // login persistence is unavailable rather than pretending it was set.
148
- const child = spawn(nodePath, [entry, "daemon"], { detached: true, stdio: "ignore", env: { ...process.env, PATH: envPath } });
150
+ const child = spawn(nodePath, [entry, "daemon"], { detached: true, stdio: "ignore", env: { ...process.env, PATH: envPath, ...(process.platform === "darwin" ? { MallocLargeCache: "0" } : {}) } });
149
151
  child.unref();
150
152
  return { manager: "detached", file: null };
151
153
  }
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { nameOf } from "./members.mjs";
5
+ import { repositoryOf } from "./worktrees.mjs";
6
+
7
+ // Code for an agent that does not run on this computer (Grok Bot, Muse) leaves it only with the person's OK. The agent
8
+ // says what it needs to see (request_snippet); the relay finds it in the thread's folder with its local search, takes
9
+ // the lines around it, masks any secret in them, and holds them. The person shares or keeps them (the buddy, or
10
+ // `tryharness board snippet <id> share|keep`); shared lines are posted in the thread for that agent, kept ones never leave.
11
+
12
+ const BEFORE = 5;
13
+ const LINES = 40;
14
+
15
+ export function createSnippets({ store, search, scrub, wake = () => {}, notify = () => {}, log = () => {} }) {
16
+ const read = (id) => { try { return JSON.parse(store.meta(`snippet:${id}`) || "null"); } catch { return null; } };
17
+ const save = (snippet) => {
18
+ store.meta(`snippet:${snippet.id}`, JSON.stringify(snippet));
19
+ const ids = JSON.parse(store.meta("snippets") || "[]");
20
+ if (!ids.includes(snippet.id)) store.meta("snippets", JSON.stringify([...ids, snippet.id].slice(-200)));
21
+ };
22
+
23
+ /** A cloud agent's request: { ok, snippet } held for the person, or why there is nothing to hold. */
24
+ const request = async ({ threadId, agent, about }) => {
25
+ const thread = store.getTask(threadId);
26
+ const folder = thread?.kind === "goal" ? thread.goal.workspace : null;
27
+ if (!folder) return { ok: false, error: "this thread has no folder on the person's computer to show code from" };
28
+ const words = String(about || "").trim().slice(0, 500);
29
+ if (!words) return { ok: false, error: "say what you need to see" };
30
+ const found = await search({ query: words, folder, limit: 1 });
31
+ const file = found?.ok ? found.files[0] : null;
32
+ if (!file) return { ok: false, error: found?.error || "nothing in this thread's folder matched that" };
33
+ const root = repositoryOf(folder)?.root ?? folder;
34
+ let lines;
35
+ try { lines = fs.readFileSync(path.join(root, file.path), "utf8").split("\n"); } catch (error) { return { ok: false, error: `${file.path} could not be read (${error.code || error.message})` }; }
36
+ const from = Math.max(1, file.line - BEFORE), to = Math.min(lines.length, from + LINES - 1);
37
+ // A secret in the lines stays a placeholder for good: it is never filled back in for an agent elsewhere.
38
+ const { text, masked } = await scrub(lines.slice(from - 1, to).join("\n"));
39
+ const snippet = { id: randomUUID(), thread: threadId, agent, about: words, path: file.path, from, to, text, masked, state: "waiting", at: new Date().toISOString() };
40
+ save(snippet);
41
+ store.roomPost({ goalId: threadId, author: "harness", kind: "note", text: `${nameOf(agent)} asks to see lines ${from} to ${to} of ${file.path} ("${words.slice(0, 120)}"). They stay on this computer until you share them.` });
42
+ notify(`${nameOf(agent)} asks to see ${to - from + 1} lines of ${path.basename(file.path)}. Share or keep them in Harness.`, snippet);
43
+ return { ok: true, snippet: { id: snippet.id, path: file.path, from, to, state: "waiting" }, note: "The person decides whether to share these lines; you are told in the thread." };
44
+ };
45
+
46
+ /** The person shares or keeps a waiting snippet. */
47
+ const decide = (id, share) => {
48
+ const snippet = read(id);
49
+ if (!snippet) return { ok: false, error: "no such request" };
50
+ if (snippet.state !== "waiting") return { ok: false, error: `already ${snippet.state}` };
51
+ const done = { ...snippet, state: share ? "shared" : "kept", decidedAt: new Date().toISOString() };
52
+ save(done);
53
+ if (share) {
54
+ const post = store.roomPost({ goalId: snippet.thread, author: "harness", kind: "post", mentions: [snippet.agent],
55
+ text: `@${snippet.agent} the person shared lines ${snippet.from} to ${snippet.to} of ${snippet.path}${snippet.masked ? ` (${snippet.masked} secret${snippet.masked === 1 ? "" : "s"} in them stay masked)` : ""}:\n\n\`\`\`\n${snippet.text}\n\`\`\`` });
56
+ wake(snippet.thread, post, [snippet.agent]);
57
+ } else store.roomPost({ goalId: snippet.thread, author: "harness", kind: "note", text: `The person kept lines ${snippet.from} to ${snippet.to} of ${snippet.path} on this computer.` });
58
+ log(`snippet ${id}: ${done.state}`);
59
+ return { ok: true, snippet: { id, state: done.state } };
60
+ };
61
+
62
+ const list = ({ all = false } = {}) => JSON.parse(store.meta("snippets") || "[]").map(read).filter((snippet) => snippet && (all || snippet.state === "waiting"));
63
+ return { request, decide, list };
64
+ }