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.
- package/README.md +57 -14
- package/assets/opencode-plugin.js +47 -66
- package/assets/pi-extension.ts +91 -90
- package/cli.mjs +115 -9
- package/lib/adapters.mjs +19 -11
- package/lib/agentnode.mjs +154 -0
- package/lib/atlas.mjs +304 -0
- package/lib/bip39-english.txt +2048 -0
- package/lib/cards.mjs +16 -4
- package/lib/context.mjs +290 -0
- package/lib/coordinator.mjs +27 -9
- package/lib/daemon.mjs +790 -93
- package/lib/digest.mjs +40 -0
- package/lib/environment.mjs +98 -0
- package/lib/firewall.mjs +154 -0
- package/lib/folders.mjs +161 -0
- package/lib/frames.mjs +159 -0
- package/lib/inference.mjs +326 -0
- package/lib/learn.mjs +120 -0
- package/lib/live.mjs +30 -5
- package/lib/localmodels.mjs +230 -0
- package/lib/mcp-stdio.mjs +107 -7
- package/lib/media.mjs +118 -0
- package/lib/members.json +1 -1
- package/lib/models.mjs +233 -0
- package/lib/network.mjs +118 -0
- package/lib/nudge.mjs +13 -5
- package/lib/ocr.mjs +217 -0
- package/lib/personal.mjs +39 -0
- package/lib/phones.mjs +31 -2
- package/lib/places.mjs +61 -0
- package/lib/receipts.mjs +42 -0
- package/lib/redact.mjs +334 -0
- package/lib/runner.mjs +37 -6
- package/lib/runtime-install.mjs +6 -5
- package/lib/screens.mjs +96 -0
- package/lib/sealed.mjs +3 -0
- package/lib/service.mjs +4 -2
- package/lib/snippets.mjs +64 -0
- package/lib/speech.mjs +480 -0
- package/lib/store.mjs +64 -5
- package/lib/swarm.mjs +89 -26
- package/lib/t3.mjs +7 -4
- package/lib/trails.mjs +177 -0
- package/lib/watches.mjs +163 -0
- package/lib/worktrees.mjs +21 -5
- package/package.json +6 -4
- 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("
|
|
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) => {
|
package/lib/runtime-install.mjs
CHANGED
|
@@ -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-
|
|
16
|
-
//
|
|
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-
|
|
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
|
-
|
|
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 });
|
package/lib/screens.mjs
ADDED
|
@@ -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
|
}
|
package/lib/snippets.mjs
ADDED
|
@@ -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
|
+
}
|