tryharness 0.7.4 → 0.7.6
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/cli.mjs +104 -2
- package/lib/coordinator.mjs +158 -0
- package/lib/daemon.mjs +420 -68
- package/lib/mcp-stdio.mjs +7 -2
- package/lib/paths.mjs +1 -0
- package/lib/phones.mjs +148 -0
- package/lib/runner.mjs +64 -17
- package/lib/sealed.mjs +103 -0
- package/lib/store.mjs +126 -3
- package/lib/swarm.mjs +224 -0
- package/lib/worktrees.mjs +132 -0
- package/package.json +2 -1
package/lib/swarm.mjs
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { readRunSteps } from "./runner.mjs";
|
|
5
|
+
|
|
6
|
+
// The person's swarm as threads. A thread is a goal under the hood: its room is the transcript, its team the agents in
|
|
7
|
+
// it, its folder where coding agents work, and it runs on its own authority. A message the person sends wakes the
|
|
8
|
+
// agents it names (or the only agent in the thread), each resuming its own session; their runs' steps are read back
|
|
9
|
+
// from the run logs. The person's own terminal sessions are listed too and continue in a thread once idle.
|
|
10
|
+
|
|
11
|
+
const PROCESS_NAMES = { "claude-code": "claude", codex: "codex", pi: "pi", opencode: "opencode" };
|
|
12
|
+
const SAY_LIMIT = 4_000;
|
|
13
|
+
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
14
|
+
|
|
15
|
+
// The id an agent resumes a session by: the index keys sessions by their file name.
|
|
16
|
+
export function nativeSessionId(agent, sessionId) {
|
|
17
|
+
const bare = String(sessionId).replace(/\.jsonl$/, "");
|
|
18
|
+
return agent === "codex" ? bare.match(UUID)?.[0] ?? bare : bare;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The folder each running coding agent works in, by agent, leaving out the runs the daemon started itself.
|
|
22
|
+
// Null when the machine cannot say, and every session then counts as open in a terminal.
|
|
23
|
+
export function agentProcessCwds(exclude = new Set()) {
|
|
24
|
+
if (process.platform === "win32") return null;
|
|
25
|
+
const ps = spawnSync("ps", ["-axo", "pid=,comm="], { encoding: "utf8" });
|
|
26
|
+
if (ps.status !== 0) return null;
|
|
27
|
+
const byPid = new Map();
|
|
28
|
+
for (const line of ps.stdout.split("\n")) {
|
|
29
|
+
const match = line.trim().match(/^(\d+)\s+(.+)$/);
|
|
30
|
+
if (!match || exclude.has(Number(match[1]))) continue;
|
|
31
|
+
const agent = Object.keys(PROCESS_NAMES).find((name) => path.basename(match[2]) === PROCESS_NAMES[name]);
|
|
32
|
+
if (agent) byPid.set(match[1], agent);
|
|
33
|
+
}
|
|
34
|
+
const cwds = new Map();
|
|
35
|
+
if (!byPid.size) return cwds;
|
|
36
|
+
const lsof = spawnSync("lsof", ["-a", "-d", "cwd", "-Fn", "-p", [...byPid.keys()].join(",")], { encoding: "utf8" });
|
|
37
|
+
if (lsof.status !== 0 && !lsof.stdout) return null;
|
|
38
|
+
let pid = null;
|
|
39
|
+
for (const line of lsof.stdout.split("\n")) {
|
|
40
|
+
if (line.startsWith("p")) pid = line.slice(1);
|
|
41
|
+
else if (line.startsWith("n") && byPid.has(pid)) {
|
|
42
|
+
const agent = byPid.get(pid);
|
|
43
|
+
if (!cwds.has(agent)) cwds.set(agent, new Set());
|
|
44
|
+
cwds.get(agent).add(line.slice(1));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return cwds;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoard, goalCommand, stopThreadRuns, runLog, runningPids, provenanceFor, defaults, coordinator, worktrees, roster }) {
|
|
51
|
+
const isDir = (folder) => typeof folder === "string" && path.isAbsolute(folder) && fs.statSync(folder, { throwIfNoEntry: false })?.isDirectory();
|
|
52
|
+
const agentsOf = (thread) => thread.goal.team.filter((agent) => agent !== thread.goal.facilitator);
|
|
53
|
+
|
|
54
|
+
// Who a message is for: the agents it names, else the only agent in the thread, else whoever coordinates.
|
|
55
|
+
const addressed = (thread, text) => {
|
|
56
|
+
const named = mentionsIn(text, thread.goal.team);
|
|
57
|
+
if (named.length) return named;
|
|
58
|
+
const agents = agentsOf(thread);
|
|
59
|
+
return agents.length === 1 ? agents : [thread.goal.facilitator];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// What the thread waits on the person for, if anything: a pause, an agent's question, a run's question.
|
|
63
|
+
const waitingOn = (thread, posts, runs) => {
|
|
64
|
+
if (thread.status === "paused") return { kind: "paused", text: thread.goal.paused_reason || "Paused." };
|
|
65
|
+
const asking = runs.find((run) => run.status === "needs_input" && run.holder === "user");
|
|
66
|
+
if (asking) return { kind: "question", agent: asking.claimed_by || asking.target_agent, text: asking.result || "" };
|
|
67
|
+
const lastMine = posts.findLastIndex((post) => post.author === "user");
|
|
68
|
+
const question = posts.slice(lastMine + 1).findLast((post) => post.kind === "question");
|
|
69
|
+
return question ? { kind: "question", agent: question.author, text: question.text } : null;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const summary = (thread) => {
|
|
73
|
+
const posts = store.roomPosts(thread.id, 0, 500);
|
|
74
|
+
const runs = store.threadRuns(thread.id);
|
|
75
|
+
const last = posts.at(-1);
|
|
76
|
+
const live = runs.find((run) => run.status === "running");
|
|
77
|
+
const step = live ? readRunSteps(runLog(live.id), 1)[0] : null;
|
|
78
|
+
return {
|
|
79
|
+
id: thread.id, title: thread.objective, status: thread.status, agents: agentsOf(thread), coordinator: thread.goal.facilitator,
|
|
80
|
+
folder: thread.goal.workspace, working: [...new Set(runs.filter((run) => ["claimed", "running"].includes(run.status)).map((run) => run.target_agent))],
|
|
81
|
+
queued: [...new Set(runs.filter((run) => run.status === "open").map((run) => run.target_agent))],
|
|
82
|
+
step: step ? { agent: live.claimed_by || live.target_agent, kind: step.kind, text: step.text.split("\n")[0].slice(0, 200) } : null,
|
|
83
|
+
waiting: waitingOn(thread, posts, runs), last: last ? { author: last.author, kind: last.kind, text: last.text.slice(0, 280), at: last.created_at } : null,
|
|
84
|
+
posts: thread.goal.posts, spend: thread.goal.spend, createdAt: thread.created_at, updatedAt: last?.created_at ?? thread.updated_at,
|
|
85
|
+
// Parts the swarm moved past: never silent, one tap sends one back.
|
|
86
|
+
openItems: posts.filter((post) => post.kind === "open_item").map((post) => ({ seq: post.seq, text: post.text })),
|
|
87
|
+
coordination: { mode: coordinator.mode(), rounds: coordinator.state(thread.id).rounds },
|
|
88
|
+
changes: worktrees.summary(thread.id),
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// The person's terminal sessions not yet in a thread, newest first, each saying whether it is still open there.
|
|
93
|
+
const sessions = () => {
|
|
94
|
+
const live = agentProcessCwds(new Set(runningPids()));
|
|
95
|
+
const newestIn = new Set();
|
|
96
|
+
return store.recentSessions(30).filter((session) => PROCESS_NAMES[session.agent] && !store.threadForSession(session.agent, nativeSessionId(session.agent, session.session_id))).map((session) => {
|
|
97
|
+
const key = `${session.agent}:${session.cwd}`;
|
|
98
|
+
const newest = !newestIn.has(key);
|
|
99
|
+
newestIn.add(key);
|
|
100
|
+
const open = live === null || (newest && Boolean(live.get(session.agent)?.has(session.cwd)));
|
|
101
|
+
return { agent: session.agent, sessionId: session.session_id, title: session.title, folder: session.cwd, startedAt: session.started_at, endedAt: session.ended_at, messages: session.message_count, open };
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const sessionOpen = (agent, sessionId) => sessions().find((session) => session.agent === agent && session.sessionId === sessionId)?.open ?? false;
|
|
106
|
+
|
|
107
|
+
const lastFolder = (agent) => store.recentSessions(100).find((session) => session.agent === agent && isDir(session.cwd))?.cwd ?? null;
|
|
108
|
+
|
|
109
|
+
const read = (thread) => {
|
|
110
|
+
const runs = store.threadRuns(thread.id).map((run) => ({
|
|
111
|
+
id: run.id, agent: run.claimed_by || run.target_agent, kind: run.kind, status: run.status, startedAt: run.created_at, endedAt: run.reported_at,
|
|
112
|
+
sessionId: run.session_id, folder: run.run_workspace, usage: run.usage,
|
|
113
|
+
steps: readRunSteps(runLog(run.id), 300).map((step) => ({ ...step, text: step.text.slice(0, SAY_LIMIT) })),
|
|
114
|
+
}));
|
|
115
|
+
return { ok: true, thread: summary(thread), posts: store.roomPosts(thread.id, 0, 500), runs };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const post = (thread, text) => {
|
|
119
|
+
// A new name in the person's message joins the thread.
|
|
120
|
+
const joining = mentionsIn(text, roomAgents).filter((agent) => !thread.goal.team.includes(agent));
|
|
121
|
+
if (joining.length) store.setGoalTeam(thread.id, [...thread.goal.team, ...joining]);
|
|
122
|
+
if (thread.status !== "open") {
|
|
123
|
+
store.setGoalState(thread.id, { status: "open" });
|
|
124
|
+
store.roomPost({ goalId: thread.id, author: "user", kind: "note", text: "Picked up again by you." });
|
|
125
|
+
}
|
|
126
|
+
const current = store.getTask(thread.id);
|
|
127
|
+
return goalCommand({ command: "board.room.post", id: thread.id, text, mentions: addressed(current, text) }, "user");
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return function swarmCommand(request, agent) {
|
|
131
|
+
const command = String(request.command);
|
|
132
|
+
if (command === "board.swarm.list") {
|
|
133
|
+
if (agent !== "user" && agent !== "harness") return { ok: false, error: "only the person and Harness read the swarm" };
|
|
134
|
+
return { ok: true, threads: store.goals({ all: request.all === true }).map(summary), sessions: request.sessions === false ? [] : sessions(), agents: roster() };
|
|
135
|
+
}
|
|
136
|
+
// The swarm's shared memory, for any of the person's agents: what any agent wrote in any thread, and the coding
|
|
137
|
+
// sessions on this machine. A caller limited to its own threads (a hosted agent while the machine is sealed) gets only those.
|
|
138
|
+
if (command === "board.swarm.search") {
|
|
139
|
+
const query = String(request.query || "").trim().slice(0, 500);
|
|
140
|
+
if (!query) return { ok: false, error: "search needs a query" };
|
|
141
|
+
const mine = request.teamOnly ? store.goals({ all: true }).filter((thread) => thread.goal.team.includes(agent)).map((thread) => thread.id) : null;
|
|
142
|
+
return { ok: true, threads: store.searchThreads(query, { goalIds: mine, k: 8 }), sessions: request.teamOnly ? [] : store.search({ query, limit: 5 }) };
|
|
143
|
+
}
|
|
144
|
+
if (agent !== "user") return { ok: false, error: "only the person starts, writes in, stops or resumes a thread" };
|
|
145
|
+
// The coordinator's record: what each round saw, chose and cost. The evidence for its limits.
|
|
146
|
+
if (command === "board.swarm.rounds") return { ok: true, rounds: store.rounds(request.id ? String(request.id) : null, request.limit) };
|
|
147
|
+
if (command === "board.swarm.coordinator") {
|
|
148
|
+
if (request.mode === "harness" || request.mode === "local") coordinator.setMode(request.mode);
|
|
149
|
+
return { ok: true, mode: coordinator.mode() };
|
|
150
|
+
}
|
|
151
|
+
if (command === "board.swarm.stop_all") {
|
|
152
|
+
const stopped = store.goals().filter((thread) => thread.status === "open").map((thread) => { stopThreadRuns(thread.id); return thread.id; });
|
|
153
|
+
return { ok: true, stopped };
|
|
154
|
+
}
|
|
155
|
+
if (command === "board.swarm.start") {
|
|
156
|
+
const text = String(request.text || "").trim().slice(0, 20_000);
|
|
157
|
+
if (!text) return { ok: false, error: "a thread starts with a message" };
|
|
158
|
+
const from = request.continueSession && typeof request.continueSession === "object" ? request.continueSession : null;
|
|
159
|
+
let folder = request.folder ? String(request.folder) : null;
|
|
160
|
+
if (folder && !isDir(folder)) return { ok: false, error: `${folder} is not an absolute path to a folder` };
|
|
161
|
+
const team = new Set(mentionsIn(text, roomAgents));
|
|
162
|
+
if (from) {
|
|
163
|
+
const agentName = String(from.agent || ""), sessionId = String(from.sessionId || "");
|
|
164
|
+
if (!PROCESS_NAMES[agentName]) return { ok: false, error: "only a coding agent's session continues in a thread" };
|
|
165
|
+
const session = store.read({ agent: agentName, sessionId });
|
|
166
|
+
if (!session) return { ok: false, error: "session not found" };
|
|
167
|
+
const already = store.threadForSession(agentName, nativeSessionId(agentName, sessionId));
|
|
168
|
+
if (already) return post(store.getTask(already), text);
|
|
169
|
+
if (sessionOpen(agentName, sessionId)) return { ok: false, error: "that session is still open in a terminal; it continues here once it is closed there" };
|
|
170
|
+
team.add(agentName);
|
|
171
|
+
folder ??= isDir(session.cwd) ? session.cwd : null;
|
|
172
|
+
}
|
|
173
|
+
// Where the work happens: the named agent's last folder, else the latest coding session's on this computer.
|
|
174
|
+
folder ??= [...team].map((name) => PROCESS_NAMES[name] ? lastFolder(name) : null).find(Boolean)
|
|
175
|
+
?? store.recentSessions(100).find((session) => PROCESS_NAMES[session.agent] && isDir(session.cwd))?.cwd ?? null;
|
|
176
|
+
const objective = text.split("\n").map((line) => line.trim()).find(Boolean).replace(/@([a-z][a-z0-9-]*)\s*/g, "").slice(0, 120) || text.slice(0, 120);
|
|
177
|
+
const thread = store.createGoal({ objective, originAgent: "user", provenance: provenanceFor("user"), team: [...team, "harness"], facilitator: "harness", workspace: folder ? path.resolve(folder) : null, maxPosts: defaults.maxPosts, maxWakes: defaults.maxWakes });
|
|
178
|
+
if (from) { store.setGoalSession(thread.id, String(from.agent), nativeSessionId(String(from.agent), String(from.sessionId))); worktrees.inPlace(thread.id); }
|
|
179
|
+
const opened = store.getTask(thread.id);
|
|
180
|
+
const first = store.roomPost({ goalId: thread.id, author: "user", kind: "post", text, mentions: addressed(opened, text) });
|
|
181
|
+
wakeFor(thread.id, first, "user");
|
|
182
|
+
notifyBoard(store.getTask(thread.id));
|
|
183
|
+
return { ok: true, thread: summary(store.getTask(thread.id)) };
|
|
184
|
+
}
|
|
185
|
+
const thread = store.getTask(String(request.id || ""));
|
|
186
|
+
if (!thread || thread.kind !== "goal") return { ok: false, error: "thread not found" };
|
|
187
|
+
if (command === "board.swarm.read") return read(thread);
|
|
188
|
+
if (command === "board.swarm.diff") return worktrees.diff(thread.id, request.runId ? String(request.runId) : null);
|
|
189
|
+
if (command === "board.swarm.merge" || command === "board.swarm.discard") {
|
|
190
|
+
// Both change the person's repository, so both wait for the person, and never while an agent is changing files.
|
|
191
|
+
if (store.threadRuns(thread.id).some((run) => ["open", "claimed", "running"].includes(run.status))) return { ok: false, error: "an agent is still working in this thread; stop it or wait for it first" };
|
|
192
|
+
const done = command === "board.swarm.merge" ? worktrees.merge(thread.id, thread.objective) : worktrees.discard(thread.id);
|
|
193
|
+
if (done.ok) store.roomPost({ goalId: thread.id, author: "user", kind: "note", text: command === "board.swarm.merge" ? `Merged into ${done.into}.` : "Discarded this thread's changes." });
|
|
194
|
+
else if (done.conflict) store.roomPost({ goalId: thread.id, author: thread.goal.facilitator, kind: "question", text: `${done.error[0].toUpperCase()}${done.error.slice(1)}. Resolve it in your checkout, or ask an agent here to bring the thread's branch up to date first.` });
|
|
195
|
+
notifyBoard(store.getTask(thread.id));
|
|
196
|
+
return done.ok ? { ok: true, thread: summary(store.getTask(thread.id)) } : done;
|
|
197
|
+
}
|
|
198
|
+
if (command === "board.swarm.post") {
|
|
199
|
+
const text = String(request.text || "").trim().slice(0, 20_000);
|
|
200
|
+
if (!text) return { ok: false, error: "a message needs text" };
|
|
201
|
+
const posted = post(thread, text);
|
|
202
|
+
return posted.ok ? { ...posted, thread: summary(store.getTask(thread.id)) } : posted;
|
|
203
|
+
}
|
|
204
|
+
if (command === "board.swarm.stop") {
|
|
205
|
+
if (thread.status !== "open") return { ok: false, error: `the thread is ${thread.status}` };
|
|
206
|
+
stopThreadRuns(thread.id);
|
|
207
|
+
return { ok: true, thread: summary(store.getTask(thread.id)) };
|
|
208
|
+
}
|
|
209
|
+
if (command === "board.swarm.resume") {
|
|
210
|
+
if (thread.status !== "paused") return { ok: false, error: `the thread is ${thread.status}` };
|
|
211
|
+
store.setGoalState(thread.id, { status: "open" });
|
|
212
|
+
store.roomPost({ goalId: thread.id, author: "user", kind: "note", text: "Resumed by you." });
|
|
213
|
+
notifyBoard(store.getTask(thread.id));
|
|
214
|
+
return { ok: true, thread: summary(store.getTask(thread.id)) };
|
|
215
|
+
}
|
|
216
|
+
if (command === "board.swarm.folder") {
|
|
217
|
+
const folder = String(request.folder || "");
|
|
218
|
+
if (!isDir(folder)) return { ok: false, error: `${folder} is not an absolute path to a folder` };
|
|
219
|
+
store.setGoalFolder(thread.id, path.resolve(folder));
|
|
220
|
+
return { ok: true, thread: summary(store.getTask(thread.id)) };
|
|
221
|
+
}
|
|
222
|
+
return { ok: false, error: "unknown command" };
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
// A coding thread's own copy of the person's repository: a git worktree on its own branch, so several threads work
|
|
6
|
+
// at once without touching each other's files or the person's checkout. Each agent turn is committed on the branch,
|
|
7
|
+
// which gives every turn its diff; merge and discard are the person's, never the swarm's.
|
|
8
|
+
|
|
9
|
+
const IDENTITY = ["-c", "user.name=Harness", "-c", "user.email=harness@localhost"];
|
|
10
|
+
const PATCH_LIMIT = 200_000;
|
|
11
|
+
|
|
12
|
+
function git(cwd, args) {
|
|
13
|
+
const result = spawnSync("git", [...IDENTITY, ...args], { cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
14
|
+
return { ok: result.status === 0, out: (result.stdout || "").trim(), err: (result.stderr || "").trim() };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The repository a folder is in, and the folder's place inside it; null when it is not in one. */
|
|
18
|
+
export function repositoryOf(folder) {
|
|
19
|
+
if (!folder || !fs.existsSync(folder)) return null;
|
|
20
|
+
const top = git(folder, ["rev-parse", "--show-toplevel"]);
|
|
21
|
+
if (!top.ok || !top.out) return null;
|
|
22
|
+
const head = git(folder, ["rev-parse", "--verify", "HEAD"]);
|
|
23
|
+
if (!head.ok) return null;
|
|
24
|
+
return { root: fs.realpathSync(top.out), inside: path.relative(fs.realpathSync(top.out), fs.realpathSync(folder)) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const slug = (text) => String(text).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32) || "thread";
|
|
28
|
+
|
|
29
|
+
export function createWorktrees({ store, root }) {
|
|
30
|
+
const key = (threadId) => `worktree:${threadId}`;
|
|
31
|
+
const get = (threadId) => { const raw = store.meta(key(threadId)); return raw ? JSON.parse(raw) : null; };
|
|
32
|
+
const save = (threadId, value) => store.meta(key(threadId), value ? JSON.stringify(value) : "null");
|
|
33
|
+
|
|
34
|
+
/** Where a thread's coding runs work: its worktree when the folder is in a repository, else the folder itself. */
|
|
35
|
+
const folderFor = (thread) => {
|
|
36
|
+
const folder = thread.goal.workspace;
|
|
37
|
+
if (!folder) return null;
|
|
38
|
+
const current = get(thread.id);
|
|
39
|
+
if (current?.state === "in_place") return folder;
|
|
40
|
+
if (current && current.state === "open" && fs.existsSync(current.path)) return path.join(current.path, current.inside);
|
|
41
|
+
const repo = repositoryOf(folder);
|
|
42
|
+
if (!repo) return folder;
|
|
43
|
+
const where = path.join(root, thread.id);
|
|
44
|
+
const branch = `harness/${slug(thread.objective)}-${thread.id.slice(0, 8)}`;
|
|
45
|
+
const base = git(repo.root, ["rev-parse", "HEAD"]).out;
|
|
46
|
+
fs.mkdirSync(root, { recursive: true });
|
|
47
|
+
const added = git(repo.root, ["worktree", "add", "-b", branch, where, base]);
|
|
48
|
+
if (!added.ok) throw new Error(`could not make the thread's copy of ${repo.root}: ${added.err}`);
|
|
49
|
+
save(thread.id, { state: "open", repo: repo.root, inside: repo.inside, path: where, branch, base });
|
|
50
|
+
return path.join(where, repo.inside);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Commits what a turn changed on the thread's branch; the commit is that turn's diff. Null when nothing changed. */
|
|
54
|
+
const commitTurn = (threadId, runId, message) => {
|
|
55
|
+
const tree = get(threadId);
|
|
56
|
+
if (!tree || tree.state !== "open" || !fs.existsSync(tree.path)) return null;
|
|
57
|
+
git(tree.path, ["add", "-A"]);
|
|
58
|
+
if (git(tree.path, ["diff", "--cached", "--quiet"]).ok) return null;
|
|
59
|
+
const committed = git(tree.path, ["commit", "-q", "-m", String(message).slice(0, 200)]);
|
|
60
|
+
if (!committed.ok) return null;
|
|
61
|
+
const sha = git(tree.path, ["rev-parse", "HEAD"]).out;
|
|
62
|
+
store.meta(`turn:${runId}`, sha);
|
|
63
|
+
return sha;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const stat = (cwd, range) => {
|
|
67
|
+
const out = git(cwd, ["diff", "--numstat", range]).out;
|
|
68
|
+
const files = out ? out.split("\n").map((line) => { const [added, removed, file] = line.split("\t"); return { file, added: Number(added) || 0, removed: Number(removed) || 0 }; }) : [];
|
|
69
|
+
return files;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** What a thread changed: everything since it began, or one turn's commit. */
|
|
73
|
+
const diff = (threadId, runId = null) => {
|
|
74
|
+
const tree = get(threadId);
|
|
75
|
+
if (!tree) return { ok: false, error: "this thread has no copy of a repository" };
|
|
76
|
+
const cwd = fs.existsSync(tree.path) ? tree.path : tree.repo;
|
|
77
|
+
let range;
|
|
78
|
+
if (runId) {
|
|
79
|
+
const sha = store.meta(`turn:${runId}`);
|
|
80
|
+
if (!sha) return { ok: true, files: [], patch: "" };
|
|
81
|
+
range = `${sha}~1..${sha}`;
|
|
82
|
+
} else range = `${tree.base}..${tree.state === "open" ? "HEAD" : tree.branch}`;
|
|
83
|
+
const patch = git(cwd, ["diff", range]).out;
|
|
84
|
+
return { ok: true, files: stat(cwd, range), patch: patch.length > PATCH_LIMIT ? `${patch.slice(0, PATCH_LIMIT)}\n… (cut here; the rest is on branch ${tree.branch})` : patch, branch: tree.branch, state: tree.state };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const summary = (threadId) => {
|
|
88
|
+
const tree = get(threadId);
|
|
89
|
+
if (!tree || tree.state === "in_place") return null;
|
|
90
|
+
const files = tree.state === "open" && fs.existsSync(tree.path) ? stat(tree.path, `${tree.base}..HEAD`).length : 0;
|
|
91
|
+
return { state: tree.state, branch: tree.branch, repo: tree.repo, files };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const retire = (tree) => {
|
|
95
|
+
git(tree.repo, ["worktree", "remove", "--force", tree.path]);
|
|
96
|
+
git(tree.repo, ["worktree", "prune"]);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Brings the thread's branch into the branch the person has checked out. A conflict is undone and reported. */
|
|
100
|
+
const merge = (threadId, title) => {
|
|
101
|
+
const tree = get(threadId);
|
|
102
|
+
if (!tree || tree.state !== "open") return { ok: false, error: "there is nothing of this thread's to merge" };
|
|
103
|
+
if (!stat(tree.path, `${tree.base}..HEAD`).length) return { ok: false, error: "this thread has not changed anything" };
|
|
104
|
+
const into = git(tree.repo, ["rev-parse", "--abbrev-ref", "HEAD"]).out;
|
|
105
|
+
const merged = git(tree.repo, ["merge", "--no-ff", "-m", `Merge ${tree.branch}: ${String(title).slice(0, 120)}`, tree.branch]);
|
|
106
|
+
if (!merged.ok) {
|
|
107
|
+
const conflicts = git(tree.repo, ["diff", "--name-only", "--diff-filter=U"]).out.split("\n").filter(Boolean);
|
|
108
|
+
git(tree.repo, ["merge", "--abort"]);
|
|
109
|
+
return { ok: false, conflict: true, files: conflicts, error: conflicts.length
|
|
110
|
+
? `merging into ${into} conflicts in ${conflicts.join(", ")}; nothing was merged`
|
|
111
|
+
: `git would not merge into ${into}: ${merged.err.split("\n")[0]}; nothing was merged` };
|
|
112
|
+
}
|
|
113
|
+
retire(tree);
|
|
114
|
+
git(tree.repo, ["branch", "-d", tree.branch]);
|
|
115
|
+
save(threadId, { ...tree, state: "merged", into, mergedAt: new Date().toISOString() });
|
|
116
|
+
return { ok: true, into };
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const discard = (threadId) => {
|
|
120
|
+
const tree = get(threadId);
|
|
121
|
+
if (!tree || tree.state !== "open") return { ok: false, error: "there is nothing of this thread's to discard" };
|
|
122
|
+
retire(tree);
|
|
123
|
+
git(tree.repo, ["branch", "-D", tree.branch]);
|
|
124
|
+
save(threadId, { ...tree, state: "discarded" });
|
|
125
|
+
return { ok: true };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** A thread that carries on a session from a terminal works where that session did: agents find sessions by folder. */
|
|
129
|
+
const inPlace = (threadId) => save(threadId, { state: "in_place" });
|
|
130
|
+
|
|
131
|
+
return { folderFor, commitTurn, diff, summary, merge, discard, inPlace, get };
|
|
132
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tryharness",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.6",
|
|
4
4
|
"description": "Harness Relay: the daemon that turns the agents on this machine into your personal swarm. Memory, history, and work handed between any of them.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tryharness": "cli.mjs"
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"license": "MIT",
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"node-sqlite3-wasm": "0.8.60",
|
|
33
|
+
"qr": "0.4.2",
|
|
33
34
|
"ws": "^8.18.3",
|
|
34
35
|
"@huggingface/tokenizers": "^0.1.3",
|
|
35
36
|
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c"
|