tryharness 0.4.1 → 0.4.2
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 +15 -0
- package/cli.mjs +55 -2
- package/lib/daemon.mjs +77 -7
- package/lib/files.mjs +0 -14
- package/lib/parsers.mjs +130 -46
- package/lib/paths.mjs +1 -0
- package/lib/runner.mjs +52 -29
- package/lib/store.mjs +30 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,6 +14,10 @@ service, validates readiness, and starts indexing existing coding sessions local
|
|
|
14
14
|
SQLite. Setup returns as soon as the daemon is ready; indexing continues in the
|
|
15
15
|
background and does not require an open Harness tab.
|
|
16
16
|
|
|
17
|
+
JSONL histories are streamed from beginning to end and only normalized conversation
|
|
18
|
+
records are retained. Source-file size does not truncate recall; long read results are
|
|
19
|
+
paged when Harness displays them.
|
|
20
|
+
|
|
17
21
|
The command is idempotent. Run it again after installing another agent or to repair its
|
|
18
22
|
configuration.
|
|
19
23
|
|
|
@@ -23,6 +27,7 @@ configuration.
|
|
|
23
27
|
npx tryharness status
|
|
24
28
|
npx tryharness doctor
|
|
25
29
|
npx tryharness sync --agent codex
|
|
30
|
+
npx tryharness task <run-id> --follow
|
|
26
31
|
npx tryharness logs --follow
|
|
27
32
|
npx tryharness handoff codex --file ./implementation-brief.md
|
|
28
33
|
npx tryharness disconnect
|
|
@@ -32,6 +37,16 @@ npx tryharness disconnect
|
|
|
32
37
|
daemon, the bridge, the database, and agent discovery. `sync` requests a background
|
|
33
38
|
rescan; it is diagnostic, not part of normal setup.
|
|
34
39
|
|
|
40
|
+
Delegated coding tasks start only after the user presses Start on Harness's proposal
|
|
41
|
+
card. They then run non-interactively with each agent's full-authority/auto-approve mode
|
|
42
|
+
inside the exact absolute working directory shown on that card; Harness never falls
|
|
43
|
+
back to the user's home directory. The card immediately shows a
|
|
44
|
+
`npx tryharness task <run-id> --follow` command for watching the machine-local raw output
|
|
45
|
+
in a terminal. Both the card and the follow command print the agent's native session
|
|
46
|
+
command as soon as the agent reports its session id, including while the run is active,
|
|
47
|
+
so the user can enter and steer the agent directly rather than interacting through a
|
|
48
|
+
Harness wrapper.
|
|
49
|
+
|
|
35
50
|
The daemon dials out to Harness over one authenticated WebSocket. Its local control
|
|
36
51
|
socket is permissioned to the current user. When OpenCode is installed, the daemon also
|
|
37
52
|
starts OpenCode's own helper on a random loopback-only port with a random per-process
|
package/cli.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { installRuntime } from "./lib/runtime-install.mjs";
|
|
|
12
12
|
import { installService, serviceInfo, uninstallService } from "./lib/service.mjs";
|
|
13
13
|
|
|
14
14
|
const SOURCE_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
-
const PACKAGE = readJson(path.join(SOURCE_ROOT, "package.json"), { version: "0.4.
|
|
15
|
+
const PACKAGE = readJson(path.join(SOURCE_ROOT, "package.json"), { version: "0.4.2" });
|
|
16
16
|
const VERSION = PACKAGE.version;
|
|
17
17
|
const NODE_MAJOR = Number(process.versions.node.split(".")[0]);
|
|
18
18
|
if (!Number.isFinite(NODE_MAJOR) || NODE_MAJOR < 18) {
|
|
@@ -31,6 +31,18 @@ function arg(flag, fallback = null) {
|
|
|
31
31
|
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
|
32
32
|
}
|
|
33
33
|
function has(flag) { return argv.includes(flag); }
|
|
34
|
+
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
|
35
|
+
function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; }
|
|
36
|
+
function resumeCommand(agent, sessionId, cwd) {
|
|
37
|
+
const command = agent === "claude-code"
|
|
38
|
+
? `claude --dangerously-skip-permissions --resume ${shellQuote(sessionId)}`
|
|
39
|
+
: agent === "codex"
|
|
40
|
+
? `codex resume --dangerously-bypass-approvals-and-sandbox ${shellQuote(sessionId)}`
|
|
41
|
+
: agent === "pi"
|
|
42
|
+
? `pi --approve --session ${shellQuote(sessionId)}`
|
|
43
|
+
: `opencode --auto --session ${shellQuote(sessionId)}`;
|
|
44
|
+
return cwd ? `cd ${shellQuote(cwd)} && ${command}` : command;
|
|
45
|
+
}
|
|
34
46
|
function origin() {
|
|
35
47
|
const saved = readJson(paths.config, {});
|
|
36
48
|
return String(arg("--origin", saved.origin || "https://tryharness.ai")).replace(/\/$/, "");
|
|
@@ -213,6 +225,46 @@ async function handoff() {
|
|
|
213
225
|
console.log(`Handoff ${response.id} is ready for ${agent}. Harness's get_handoff tool can read it.`);
|
|
214
226
|
}
|
|
215
227
|
|
|
228
|
+
async function task() {
|
|
229
|
+
const runId = String(argv[0] || "").trim();
|
|
230
|
+
if (!/^[a-zA-Z0-9._-]{1,128}$/.test(runId)) throw new Error("usage: tryharness task <run-id> [--follow]");
|
|
231
|
+
const follow = has("--follow") || has("-f");
|
|
232
|
+
let offset = Math.max(0, Math.floor(Number(arg("--offset", "0")) || 0));
|
|
233
|
+
let announced = false;
|
|
234
|
+
let sessionShown = null;
|
|
235
|
+
let misses = 0;
|
|
236
|
+
for (;;) {
|
|
237
|
+
const response = await daemonRequest({ command: "tasks.get", runId, offset });
|
|
238
|
+
if (!response.ok) {
|
|
239
|
+
if (follow && misses < 10) { misses += 1; await sleep(500); continue; }
|
|
240
|
+
throw new Error(response.error || `task ${runId} was not found on this machine`);
|
|
241
|
+
}
|
|
242
|
+
misses = 0;
|
|
243
|
+
const current = response.task;
|
|
244
|
+
if (!announced) {
|
|
245
|
+
console.log(`Harness task ${runId} · ${current.agent} · ${current.status}`);
|
|
246
|
+
if (current.cwd) console.log(`directory: ${current.cwd}`);
|
|
247
|
+
if (follow) console.log("following machine-local agent output (Ctrl-C stops following, not the task)\n");
|
|
248
|
+
announced = true;
|
|
249
|
+
}
|
|
250
|
+
if (current.log) process.stdout.write(current.log);
|
|
251
|
+
offset = current.nextOffset;
|
|
252
|
+
if (current.sessionId && current.sessionId !== sessionShown) {
|
|
253
|
+
sessionShown = current.sessionId;
|
|
254
|
+
console.log(`\nagent session: ${current.sessionId}`);
|
|
255
|
+
console.log(`open live session: ${resumeCommand(current.agent, current.sessionId, current.cwd)}`);
|
|
256
|
+
}
|
|
257
|
+
const terminal = current.status === "done" || current.status === "failed";
|
|
258
|
+
if (!follow || (terminal && offset >= current.logLength)) {
|
|
259
|
+
console.log(`\nstatus: ${current.status}`);
|
|
260
|
+
if (current.message) console.log(current.message);
|
|
261
|
+
if (current.status === "failed") process.exitCode = 1;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
await sleep(500);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
216
268
|
function logs() {
|
|
217
269
|
if (!fs.existsSync(paths.log)) { console.log("No daemon log yet."); return; }
|
|
218
270
|
const lines = fs.readFileSync(paths.log, "utf8").split("\n");
|
|
@@ -230,7 +282,7 @@ async function disconnect() {
|
|
|
230
282
|
}
|
|
231
283
|
|
|
232
284
|
function help() {
|
|
233
|
-
console.log(`tryharness ${VERSION}\n\nCommands:\n tryharness set up everything and start the daemon\n tryharness status show daemon, bridge, agent, and index health\n tryharness doctor diagnose the complete connection\n tryharness sync [--agent] rescan local coding history in the background\n tryharness handoff <agent> --file <path>\n tryharness logs [--follow]\n tryharness disconnect\n`);
|
|
285
|
+
console.log(`tryharness ${VERSION}\n\nCommands:\n tryharness set up everything and start the daemon\n tryharness status show daemon, bridge, agent, and index health\n tryharness doctor diagnose the complete connection\n tryharness sync [--agent] rescan local coding history in the background\n tryharness task <id> --follow\n follow a delegated agent run in this terminal\n tryharness handoff <agent> --file <path>\n tryharness logs [--follow]\n tryharness disconnect\n`);
|
|
234
286
|
}
|
|
235
287
|
|
|
236
288
|
try {
|
|
@@ -238,6 +290,7 @@ try {
|
|
|
238
290
|
else if (command === "status" || command === "bridge") await status();
|
|
239
291
|
else if (command === "doctor") await doctor();
|
|
240
292
|
else if (command === "sync" || command === "backfill" || command === "drain") await sync();
|
|
293
|
+
else if (command === "task") await task();
|
|
241
294
|
else if (command === "handoff") await handoff();
|
|
242
295
|
else if (command === "logs") logs();
|
|
243
296
|
else if (command === "disconnect") await disconnect();
|
package/lib/daemon.mjs
CHANGED
|
@@ -5,14 +5,16 @@ import { spawn } from "node:child_process";
|
|
|
5
5
|
import WebSocket from "ws";
|
|
6
6
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
7
7
|
import { detectAgents, run } from "./commands.mjs";
|
|
8
|
-
import { appendLog, ensureDir, listJsonl, readJson,
|
|
8
|
+
import { appendLog, ensureDir, listJsonl, readJson, writeJsonAtomic } from "./files.mjs";
|
|
9
|
+
import { parseSessionFile } from "./parsers.mjs";
|
|
9
10
|
import { historyRoots, paths } from "./paths.mjs";
|
|
10
11
|
import { openStore } from "./store.mjs";
|
|
11
12
|
import { runAgentTask } from "./runner.mjs";
|
|
12
13
|
|
|
13
|
-
const DAEMON_VERSION = "0.4.
|
|
14
|
+
const DAEMON_VERSION = "0.4.2";
|
|
14
15
|
const PROTOCOL_VERSION = 1;
|
|
15
16
|
const RESCAN_MS = 60_000;
|
|
17
|
+
const TASK_LOG_PAGE = 64_000;
|
|
16
18
|
|
|
17
19
|
export async function startDaemon() {
|
|
18
20
|
ensureDir(paths.home);
|
|
@@ -38,6 +40,27 @@ export async function startDaemon() {
|
|
|
38
40
|
writeJsonAtomic(paths.status, state);
|
|
39
41
|
};
|
|
40
42
|
const log = (message) => { appendLog(paths.log, message); };
|
|
43
|
+
const taskRuns = new Map();
|
|
44
|
+
const validRunId = (runId) => typeof runId === "string" && /^[a-zA-Z0-9._-]{1,128}$/.test(runId);
|
|
45
|
+
const taskFiles = (runId) => ({
|
|
46
|
+
state: path.join(paths.runs, `${runId}.json`),
|
|
47
|
+
log: path.join(paths.runs, `${runId}.log`),
|
|
48
|
+
});
|
|
49
|
+
const persistTask = (task) => {
|
|
50
|
+
ensureDir(paths.runs);
|
|
51
|
+
writeJsonAtomic(taskFiles(task.runId).state, task);
|
|
52
|
+
};
|
|
53
|
+
const readTask = (runId, requestedOffset) => {
|
|
54
|
+
if (!validRunId(runId)) return null;
|
|
55
|
+
const files = taskFiles(runId);
|
|
56
|
+
const task = taskRuns.get(runId) || readJson(files.state, null);
|
|
57
|
+
if (!task) return null;
|
|
58
|
+
let raw = "";
|
|
59
|
+
try { raw = fs.readFileSync(files.log, "utf8"); } catch {}
|
|
60
|
+
const offset = Math.max(0, Math.min(raw.length, Math.floor(Number(requestedOffset) || 0)));
|
|
61
|
+
const chunk = raw.slice(offset, offset + TASK_LOG_PAGE);
|
|
62
|
+
return { ...task, log: chunk, offset, nextOffset: offset + chunk.length, logLength: raw.length };
|
|
63
|
+
};
|
|
41
64
|
saveStatus();
|
|
42
65
|
log(`daemon ${DAEMON_VERSION} starting; agents=${installedAgents.join(",") || "none"}`);
|
|
43
66
|
|
|
@@ -105,8 +128,8 @@ export async function startDaemon() {
|
|
|
105
128
|
async function indexFile(agent, item) {
|
|
106
129
|
const sessionId = path.basename(item.file);
|
|
107
130
|
if (!store.needsIndex(agent, sessionId, item.mtime, item.size)) return false;
|
|
108
|
-
const
|
|
109
|
-
store.
|
|
131
|
+
const parsed = await parseSessionFile(agent, item.file);
|
|
132
|
+
store.indexParsed({ agent, sessionId, source: item.file, parsed, mtime: item.mtime, size: item.size });
|
|
110
133
|
return true;
|
|
111
134
|
}
|
|
112
135
|
|
|
@@ -152,7 +175,7 @@ export async function startDaemon() {
|
|
|
152
175
|
if (requested.length) {
|
|
153
176
|
for (const item of requested) await readOpencodeSession(item.sessionId, item.cwd);
|
|
154
177
|
} else {
|
|
155
|
-
const listed = run("opencode", ["session", "list", "
|
|
178
|
+
const listed = run("opencode", ["session", "list", "--format", "json"], { timeout: 20_000, maxBuffer: 20_000_000 });
|
|
156
179
|
if (listed.ok) {
|
|
157
180
|
let rows = [];
|
|
158
181
|
try { rows = JSON.parse(listed.stdout); } catch {}
|
|
@@ -176,7 +199,10 @@ export async function startDaemon() {
|
|
|
176
199
|
function sourceRaw(agent, sessionId) {
|
|
177
200
|
const row = store.findSource(agent, sessionId);
|
|
178
201
|
if (!row?.source || row.source.startsWith("opencode:")) return null;
|
|
179
|
-
|
|
202
|
+
// Compatibility fetches are explicit reads. Return the complete source instead of
|
|
203
|
+
// silently changing their meaning to "the last N bytes"; normal 0.4 search/read
|
|
204
|
+
// traffic is served from the normalized SQLite transcript and never ships raw logs.
|
|
205
|
+
try { return { raw: fs.readFileSync(row.source, "utf8"), cwd: row.cwd || undefined }; } catch { return null; }
|
|
180
206
|
}
|
|
181
207
|
|
|
182
208
|
if (process.platform !== "win32") {
|
|
@@ -207,6 +233,9 @@ export async function startDaemon() {
|
|
|
207
233
|
else if (request.command === "handoffs.get") {
|
|
208
234
|
const handoff = store.getHandoff(request.id, request.consume === true);
|
|
209
235
|
reply(handoff && handoff.target_agent === request.agent ? { ok: true, handoff } : { ok: false, error: "handoff not found" });
|
|
236
|
+
} else if (request.command === "tasks.get") {
|
|
237
|
+
const task = readTask(request.runId, request.offset);
|
|
238
|
+
reply(task ? { ok: true, task } : { ok: false, error: "task not found" });
|
|
210
239
|
} else if (request.command === "shutdown") {
|
|
211
240
|
reply({ ok: true, shuttingDown: true });
|
|
212
241
|
setTimeout(shutdown, 25);
|
|
@@ -225,6 +254,47 @@ export async function startDaemon() {
|
|
|
225
254
|
const send = (message) => {
|
|
226
255
|
try { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); } catch {}
|
|
227
256
|
};
|
|
257
|
+
const startTask = (message) => {
|
|
258
|
+
if (!validRunId(message.runId)) {
|
|
259
|
+
send({ type: "event", runId: message.runId, kind: "failed", text: "Invalid task run id." });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
ensureDir(paths.runs);
|
|
263
|
+
const files = taskFiles(message.runId);
|
|
264
|
+
try { fs.writeFileSync(files.log, "", { mode: 0o600 }); } catch {}
|
|
265
|
+
const task = {
|
|
266
|
+
runId: message.runId,
|
|
267
|
+
agent: message.agent,
|
|
268
|
+
title: message.title,
|
|
269
|
+
cwd: message.cwd || null,
|
|
270
|
+
status: "starting",
|
|
271
|
+
sessionId: null,
|
|
272
|
+
startedAt: new Date().toISOString(),
|
|
273
|
+
updatedAt: new Date().toISOString(),
|
|
274
|
+
message: null,
|
|
275
|
+
};
|
|
276
|
+
taskRuns.set(message.runId, task);
|
|
277
|
+
persistTask(task);
|
|
278
|
+
const emit = (kind, text, extra) => {
|
|
279
|
+
if (kind === "accepted") task.status = "running";
|
|
280
|
+
if (kind === "session") {
|
|
281
|
+
task.sessionId = text || null;
|
|
282
|
+
if (extra?.cwd) task.cwd = extra.cwd;
|
|
283
|
+
}
|
|
284
|
+
if (kind === "done" || kind === "failed") {
|
|
285
|
+
task.status = kind;
|
|
286
|
+
task.finishedAt = new Date().toISOString();
|
|
287
|
+
task.message = text || null;
|
|
288
|
+
}
|
|
289
|
+
task.updatedAt = new Date().toISOString();
|
|
290
|
+
persistTask(task);
|
|
291
|
+
send({ type: "event", runId: message.runId, kind, ...(text ? { text } : {}), ...(extra || {}) });
|
|
292
|
+
};
|
|
293
|
+
const observe = (stream, raw) => {
|
|
294
|
+
try { fs.appendFileSync(files.log, stream === "stderr" ? `\n[stderr] ${raw}` : raw, { mode: 0o600 }); } catch {}
|
|
295
|
+
};
|
|
296
|
+
runAgentTask(message, emit, observe);
|
|
297
|
+
};
|
|
228
298
|
const scheduleReconnect = () => {
|
|
229
299
|
if (reconnectTimer) return;
|
|
230
300
|
const delay = Math.round(retry * (0.8 + Math.random() * 0.4));
|
|
@@ -285,7 +355,7 @@ export async function startDaemon() {
|
|
|
285
355
|
return;
|
|
286
356
|
}
|
|
287
357
|
if (message.type === "task") {
|
|
288
|
-
|
|
358
|
+
startTask(message);
|
|
289
359
|
}
|
|
290
360
|
});
|
|
291
361
|
const dropped = (error) => {
|
package/lib/files.mjs
CHANGED
|
@@ -43,17 +43,3 @@ export function listJsonl(root) {
|
|
|
43
43
|
}
|
|
44
44
|
return found;
|
|
45
45
|
}
|
|
46
|
-
|
|
47
|
-
export function tailFile(file, cap = 2_000_000) {
|
|
48
|
-
const stat = fs.statSync(file);
|
|
49
|
-
const start = Math.max(0, stat.size - cap);
|
|
50
|
-
const length = stat.size - start;
|
|
51
|
-
const fd = fs.openSync(file, "r");
|
|
52
|
-
try {
|
|
53
|
-
const buffer = Buffer.alloc(length);
|
|
54
|
-
fs.readSync(fd, buffer, 0, length, start);
|
|
55
|
-
return buffer.toString("utf8");
|
|
56
|
-
} finally {
|
|
57
|
-
fs.closeSync(fd);
|
|
58
|
-
}
|
|
59
|
-
}
|
package/lib/parsers.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
|
|
4
|
+
// Fail-open normalizers for the four supported agents. JSONL sources are streamed so
|
|
5
|
+
// source-file size never decides which part of a conversation is remembered.
|
|
3
6
|
|
|
4
7
|
const TITLE_MAX = 120;
|
|
5
8
|
const TOOL_INPUT_CLIP = 100;
|
|
@@ -45,71 +48,98 @@ function contentText(content) {
|
|
|
45
48
|
return parts.join("\n").trim();
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
function slimClaude(row) {
|
|
52
|
+
if (row?.type !== "user" && row?.type !== "assistant") return null;
|
|
53
|
+
return {
|
|
54
|
+
type: row.type,
|
|
55
|
+
uuid: typeof row.uuid === "string" ? row.uuid : null,
|
|
56
|
+
parentUuid: typeof row.parentUuid === "string" ? row.parentUuid : null,
|
|
57
|
+
timestamp: typeof row.timestamp === "string" ? row.timestamp : null,
|
|
58
|
+
cwd: typeof row.cwd === "string" ? row.cwd : null,
|
|
59
|
+
text: contentText(row.message?.content),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function finishClaude(records) {
|
|
64
|
+
const byId = new Map(records.filter((row) => row.uuid).map((row) => [row.uuid, row]));
|
|
51
65
|
const chain = [];
|
|
52
66
|
const seen = new Set();
|
|
53
67
|
let cursor = records.at(-1);
|
|
54
68
|
while (cursor) {
|
|
55
|
-
if (
|
|
69
|
+
if (cursor.uuid) {
|
|
56
70
|
if (seen.has(cursor.uuid)) break;
|
|
57
71
|
seen.add(cursor.uuid);
|
|
58
72
|
}
|
|
59
73
|
chain.unshift(cursor);
|
|
60
|
-
cursor =
|
|
74
|
+
cursor = cursor.parentUuid ? byId.get(cursor.parentUuid) : undefined;
|
|
61
75
|
}
|
|
62
76
|
const source = chain.length >= records.length / 2 ? chain : records;
|
|
63
|
-
const messages = source.flatMap((row) => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
77
|
+
const messages = source.flatMap((row) => row.text ? [{
|
|
78
|
+
role: row.type === "user" ? "user" : "agent",
|
|
79
|
+
text: row.text,
|
|
80
|
+
ts: row.timestamp,
|
|
81
|
+
}] : []);
|
|
67
82
|
return {
|
|
68
83
|
meta: {
|
|
69
|
-
cwd:
|
|
70
|
-
startedAt:
|
|
71
|
-
endedAt:
|
|
84
|
+
cwd: records.find((row) => row.cwd)?.cwd || null,
|
|
85
|
+
startedAt: records.find((row) => row.timestamp)?.timestamp || null,
|
|
86
|
+
endedAt: [...records].reverse().find((row) => row.timestamp)?.timestamp || null,
|
|
72
87
|
},
|
|
73
88
|
messages,
|
|
74
89
|
};
|
|
75
90
|
}
|
|
76
91
|
|
|
92
|
+
function parseClaude(text) {
|
|
93
|
+
return finishClaude(parseLines(text).map(slimClaude).filter(Boolean));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function finishCodex(meta, messages, startedAt, endedAt) {
|
|
97
|
+
return {
|
|
98
|
+
meta: { cwd: typeof meta?.cwd === "string" ? meta.cwd : null, startedAt, endedAt },
|
|
99
|
+
messages,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
77
103
|
function parseCodex(text) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
104
|
+
let meta = {};
|
|
105
|
+
let startedAt = null;
|
|
106
|
+
let endedAt = null;
|
|
107
|
+
const messages = [];
|
|
108
|
+
for (const row of parseLines(text)) {
|
|
109
|
+
if (row.type === "session_meta" && row.payload && !Object.keys(meta).length) meta = row.payload;
|
|
110
|
+
if (typeof row.timestamp === "string") {
|
|
111
|
+
startedAt ||= row.timestamp;
|
|
112
|
+
endedAt = row.timestamp;
|
|
113
|
+
}
|
|
81
114
|
const payload = row.type === "response_item" ? row.payload : null;
|
|
82
|
-
if (!payload || payload.type !== "message")
|
|
115
|
+
if (!payload || payload.type !== "message") continue;
|
|
83
116
|
const textValue = contentText(payload.content);
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
117
|
+
if (textValue) messages.push({ role: payload.role === "user" ? "user" : "agent", text: textValue, ts: typeof row.timestamp === "string" ? row.timestamp : null });
|
|
118
|
+
}
|
|
119
|
+
return finishCodex(meta, messages, startedAt, endedAt);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function finishPi(header, messages) {
|
|
87
123
|
return {
|
|
88
124
|
meta: {
|
|
89
|
-
cwd: typeof
|
|
90
|
-
startedAt:
|
|
91
|
-
endedAt:
|
|
125
|
+
cwd: typeof header?.cwd === "string" ? header.cwd : null,
|
|
126
|
+
startedAt: typeof header?.timestamp === "string" ? header.timestamp : null,
|
|
127
|
+
endedAt: messages.at(-1)?.ts || null,
|
|
92
128
|
},
|
|
93
129
|
messages,
|
|
94
130
|
};
|
|
95
131
|
}
|
|
96
132
|
|
|
97
133
|
function parsePi(text) {
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
if (row.type
|
|
134
|
+
let header = {};
|
|
135
|
+
const messages = [];
|
|
136
|
+
for (const row of parseLines(text)) {
|
|
137
|
+
if (row.type === "session" && !Object.keys(header).length) header = row;
|
|
138
|
+
if (row.type !== "message") continue;
|
|
102
139
|
const textValue = contentText(row.message?.content);
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
return
|
|
106
|
-
meta: {
|
|
107
|
-
cwd: typeof header.cwd === "string" ? header.cwd : null,
|
|
108
|
-
startedAt: typeof header.timestamp === "string" ? header.timestamp : null,
|
|
109
|
-
endedAt: messages.at(-1)?.ts || null,
|
|
110
|
-
},
|
|
111
|
-
messages,
|
|
112
|
-
};
|
|
140
|
+
if (textValue) messages.push({ role: row.message?.role === "user" ? "user" : "agent", text: textValue, ts: typeof row.timestamp === "string" ? row.timestamp : null });
|
|
141
|
+
}
|
|
142
|
+
return finishPi(header, messages);
|
|
113
143
|
}
|
|
114
144
|
|
|
115
145
|
function asIso(value) {
|
|
@@ -143,20 +173,74 @@ function parseOpencode(text) {
|
|
|
143
173
|
};
|
|
144
174
|
}
|
|
145
175
|
|
|
146
|
-
|
|
176
|
+
function titleCandidate(message) {
|
|
177
|
+
if (message.role !== "user") return false;
|
|
178
|
+
const text = message.text.trim().toLowerCase();
|
|
179
|
+
if (text.length <= 2 || text.startsWith("[")) return false;
|
|
180
|
+
return !text.startsWith("<environment_context>")
|
|
181
|
+
&& !text.startsWith("<permissions instructions>")
|
|
182
|
+
&& !text.startsWith("<skills_instructions>");
|
|
183
|
+
}
|
|
147
184
|
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
if (!parser) throw new Error(`unsupported agent: ${agent}`);
|
|
151
|
-
const { meta, messages } = parser(raw);
|
|
152
|
-
const firstUser = messages.find((message) => message.role === "user" && !message.text.startsWith("[") && message.text.trim().length > 2)
|
|
153
|
-
|| messages.find((message) => message.role === "user");
|
|
185
|
+
function normalize({ meta, messages }) {
|
|
186
|
+
const firstUser = messages.find(titleCandidate) || messages.find((message) => message.role === "user");
|
|
154
187
|
return {
|
|
155
188
|
meta: {
|
|
156
189
|
...meta,
|
|
157
190
|
title: (firstUser?.text || "").replace(/\s+/g, " ").trim().slice(0, TITLE_MAX),
|
|
158
191
|
messageCount: messages.length,
|
|
159
192
|
},
|
|
160
|
-
transcript: messages.map((message) => `${message.role === "user" ? "User" : "Agent"}: ${message.text}`).join("\n\n")
|
|
193
|
+
transcript: messages.map((message) => `${message.role === "user" ? "User" : "Agent"}: ${message.text}`).join("\n\n"),
|
|
161
194
|
};
|
|
162
195
|
}
|
|
196
|
+
|
|
197
|
+
const PARSERS = { "claude-code": parseClaude, codex: parseCodex, pi: parsePi, opencode: parseOpencode };
|
|
198
|
+
|
|
199
|
+
export function parseSession(agent, raw) {
|
|
200
|
+
const parser = PARSERS[agent];
|
|
201
|
+
if (!parser) throw new Error(`unsupported agent: ${agent}`);
|
|
202
|
+
return normalize(parser(raw));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function parseSessionFile(agent, file) {
|
|
206
|
+
if (agent === "opencode") return parseSession(agent, await fs.promises.readFile(file, "utf8"));
|
|
207
|
+
if (!["claude-code", "codex", "pi"].includes(agent)) throw new Error(`unsupported agent: ${agent}`);
|
|
208
|
+
|
|
209
|
+
const claudeRecords = [];
|
|
210
|
+
const messages = [];
|
|
211
|
+
let codexMeta = {};
|
|
212
|
+
let piHeader = {};
|
|
213
|
+
let startedAt = null;
|
|
214
|
+
let endedAt = null;
|
|
215
|
+
const input = fs.createReadStream(file, { encoding: "utf8" });
|
|
216
|
+
const lines = readline.createInterface({ input, crlfDelay: Infinity });
|
|
217
|
+
for await (const line of lines) {
|
|
218
|
+
if (!line.trim()) continue;
|
|
219
|
+
let row;
|
|
220
|
+
try { row = JSON.parse(line); } catch { continue; }
|
|
221
|
+
if (agent === "claude-code") {
|
|
222
|
+
const slim = slimClaude(row);
|
|
223
|
+
if (slim) claudeRecords.push(slim);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (agent === "codex") {
|
|
227
|
+
if (row.type === "session_meta" && row.payload && !Object.keys(codexMeta).length) codexMeta = row.payload;
|
|
228
|
+
if (typeof row.timestamp === "string") {
|
|
229
|
+
startedAt ||= row.timestamp;
|
|
230
|
+
endedAt = row.timestamp;
|
|
231
|
+
}
|
|
232
|
+
const payload = row.type === "response_item" ? row.payload : null;
|
|
233
|
+
if (!payload || payload.type !== "message") continue;
|
|
234
|
+
const textValue = contentText(payload.content);
|
|
235
|
+
if (textValue) messages.push({ role: payload.role === "user" ? "user" : "agent", text: textValue, ts: typeof row.timestamp === "string" ? row.timestamp : null });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (row.type === "session" && !Object.keys(piHeader).length) piHeader = row;
|
|
239
|
+
if (row.type !== "message") continue;
|
|
240
|
+
const textValue = contentText(row.message?.content);
|
|
241
|
+
if (textValue) messages.push({ role: row.message?.role === "user" ? "user" : "agent", text: textValue, ts: typeof row.timestamp === "string" ? row.timestamp : null });
|
|
242
|
+
}
|
|
243
|
+
if (agent === "claude-code") return normalize(finishClaude(claudeRecords));
|
|
244
|
+
if (agent === "codex") return normalize(finishCodex(codexMeta, messages, startedAt, endedAt));
|
|
245
|
+
return normalize(finishPi(piHeader, messages));
|
|
246
|
+
}
|
package/lib/paths.mjs
CHANGED
|
@@ -14,6 +14,7 @@ export const paths = {
|
|
|
14
14
|
status: path.join(harnessHome, "daemon-status.json"),
|
|
15
15
|
socket: process.platform === "win32" ? "\\\\.\\pipe\\tryharness-daemon" : path.join(harnessHome, "daemon.sock"),
|
|
16
16
|
log: path.join(harnessHome, "daemon.log"),
|
|
17
|
+
runs: path.join(harnessHome, "runs"),
|
|
17
18
|
runtime: path.join(harnessHome, "runtime"),
|
|
18
19
|
legacyBridgeConfig: path.join(harnessHome, "bridge.json"),
|
|
19
20
|
legacySpool: path.join(harnessHome, "pending-sessions.json"),
|
package/lib/runner.mjs
CHANGED
|
@@ -1,56 +1,75 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
2
|
import path from "node:path";
|
|
4
|
-
import { spawn
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
5
4
|
|
|
6
5
|
const running = new Set();
|
|
7
6
|
|
|
8
|
-
function
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
export function resolveWorkingDirectory(requested) {
|
|
8
|
+
if (!requested || !path.isAbsolute(requested)) throw new Error("Delegated tasks require an absolute working directory.");
|
|
9
|
+
let stat;
|
|
10
|
+
try { stat = fs.statSync(requested); }
|
|
11
|
+
catch { throw new Error(`Delegated task directory does not exist: ${requested}`); }
|
|
12
|
+
if (!stat.isDirectory()) throw new Error(`Delegated task working directory is not a directory: ${requested}`);
|
|
13
|
+
return requested;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
export function
|
|
16
|
+
export function agentInvocation(task) {
|
|
17
|
+
if (task.agent === "claude-code") {
|
|
18
|
+
return {
|
|
19
|
+
command: "claude",
|
|
20
|
+
args: ["-p", task.brief, "--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions", "--name", `Harness: ${task.title}`],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (task.agent === "codex") {
|
|
24
|
+
return {
|
|
25
|
+
command: "codex",
|
|
26
|
+
args: ["exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "-"],
|
|
27
|
+
stdin: task.brief,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
// Pi has no permission prompts. --approve trusts project-local configuration for
|
|
31
|
+
// this non-interactive run; its complete built-in tool set remains enabled.
|
|
32
|
+
if (task.agent === "pi") return { command: "pi", args: ["--mode", "json", "--approve", "-p", task.brief] };
|
|
33
|
+
if (task.agent === "opencode") {
|
|
34
|
+
return { command: "opencode", args: ["run", "--format", "json", "--auto", "--title", task.title, "--", task.brief] };
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`Unsupported agent: ${task.agent}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function runAgentTask(task, emit, observe = () => {}) {
|
|
15
40
|
if (running.has(task.agent)) {
|
|
16
41
|
emit("failed", `A ${task.agent} task is already running on this machine.`);
|
|
17
42
|
return;
|
|
18
43
|
}
|
|
19
|
-
|
|
44
|
+
let cwd;
|
|
45
|
+
let invocation;
|
|
46
|
+
try {
|
|
47
|
+
cwd = resolveWorkingDirectory(task.cwd);
|
|
48
|
+
invocation = agentInvocation(task);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
emit("failed", error.message);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
20
53
|
emit("accepted");
|
|
21
|
-
const cwd = workingDirectory(task.cwd);
|
|
22
54
|
let child;
|
|
23
55
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
} else if (task.agent === "codex") {
|
|
27
|
-
const help = spawnSync("codex", ["exec", "--help"], { encoding: "utf8" });
|
|
28
|
-
const args = ["exec", "--json", "--sandbox", "workspace-write", "--skip-git-repo-check"];
|
|
29
|
-
if (String(help.stdout || "").includes("--approve-for-me")) args.push("--approve-for-me");
|
|
30
|
-
args.push("-");
|
|
31
|
-
child = spawn("codex", args, { cwd });
|
|
32
|
-
child.stdin.end(task.brief);
|
|
33
|
-
} else if (task.agent === "pi") {
|
|
34
|
-
child = spawn("pi", ["--mode", "json", "-p", task.brief], { cwd });
|
|
35
|
-
} else if (task.agent === "opencode") {
|
|
36
|
-
child = spawn("opencode", ["run", "--format", "json", "--title", task.title, "--", task.brief], { cwd });
|
|
37
|
-
} else {
|
|
38
|
-
running.delete(task.agent);
|
|
39
|
-
emit("failed", `Unsupported agent: ${task.agent}`);
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
56
|
+
child = spawn(invocation.command, invocation.args, { cwd });
|
|
57
|
+
if (invocation.stdin !== undefined) child.stdin.end(invocation.stdin);
|
|
42
58
|
} catch (error) {
|
|
43
59
|
running.delete(task.agent);
|
|
44
60
|
emit("failed", `Could not start ${task.agent}: ${error.message}`);
|
|
45
61
|
return;
|
|
46
62
|
}
|
|
63
|
+
running.add(task.agent);
|
|
47
64
|
|
|
48
65
|
let resultText = "";
|
|
49
66
|
let tail = "";
|
|
50
67
|
let buffer = "";
|
|
51
68
|
let sessionReported = false;
|
|
52
69
|
child.stdout?.on("data", (chunk) => {
|
|
53
|
-
|
|
70
|
+
const raw = String(chunk);
|
|
71
|
+
observe("stdout", raw);
|
|
72
|
+
buffer += raw;
|
|
54
73
|
const lines = buffer.split("\n");
|
|
55
74
|
buffer = lines.pop() || "";
|
|
56
75
|
for (const line of lines) {
|
|
@@ -76,7 +95,11 @@ export function runAgentTask(task, emit) {
|
|
|
76
95
|
if (progress?.trim()) emit("progress", progress.trim().slice(0, 200));
|
|
77
96
|
}
|
|
78
97
|
});
|
|
79
|
-
child.stderr?.on("data", (chunk) => {
|
|
98
|
+
child.stderr?.on("data", (chunk) => {
|
|
99
|
+
const raw = String(chunk);
|
|
100
|
+
observe("stderr", raw);
|
|
101
|
+
tail = (tail + "\n" + raw).slice(-4_000);
|
|
102
|
+
});
|
|
80
103
|
child.on("close", (code) => {
|
|
81
104
|
running.delete(task.agent);
|
|
82
105
|
if (code === 0) emit("done", (resultText || "The run finished; check the repository for its changes.").slice(0, 20_000));
|
package/lib/store.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import sqliteWasm from "node-sqlite3-wasm";
|
|
2
2
|
import { parseSession } from "./parsers.mjs";
|
|
3
3
|
|
|
4
|
-
const SCHEMA_VERSION =
|
|
4
|
+
const SCHEMA_VERSION = 3;
|
|
5
|
+
const INDEX_FORMAT_VERSION = 2;
|
|
5
6
|
const { Database } = sqliteWasm;
|
|
6
7
|
|
|
7
8
|
function bindings(values) {
|
|
@@ -33,6 +34,7 @@ export function openStore(file) {
|
|
|
33
34
|
message_count integer not null default 0,
|
|
34
35
|
source_mtime real not null default 0,
|
|
35
36
|
source_size integer not null default 0,
|
|
37
|
+
parser_version integer not null default 0,
|
|
36
38
|
transcript text not null default '',
|
|
37
39
|
indexed_at text not null,
|
|
38
40
|
primary key(agent, session_id)
|
|
@@ -58,6 +60,10 @@ export function openStore(file) {
|
|
|
58
60
|
consumed_at text
|
|
59
61
|
);
|
|
60
62
|
`);
|
|
63
|
+
const sessionColumns = new Set(db.all("pragma table_info(sessions)").map((column) => column.name));
|
|
64
|
+
if (!sessionColumns.has("parser_version")) {
|
|
65
|
+
db.exec("alter table sessions add column parser_version integer not null default 0");
|
|
66
|
+
}
|
|
61
67
|
|
|
62
68
|
const statements = [];
|
|
63
69
|
const prepare = (sql) => {
|
|
@@ -77,20 +83,21 @@ export function openStore(file) {
|
|
|
77
83
|
const bound = bindings(values);
|
|
78
84
|
return bound === undefined ? db[method](sql) : db[method](sql, bound);
|
|
79
85
|
};
|
|
80
|
-
const existing = prepare("select source_mtime, source_size from sessions where agent = ? and session_id = ?");
|
|
86
|
+
const existing = prepare("select source_mtime, source_size, parser_version from sessions where agent = ? and session_id = ?");
|
|
81
87
|
const upsert = prepare(`
|
|
82
|
-
insert into sessions(agent, session_id, source, cwd, title, started_at, ended_at, message_count, source_mtime, source_size, transcript, indexed_at)
|
|
83
|
-
values(@agent, @sessionId, @source, @cwd, @title, @startedAt, @endedAt, @messageCount, @mtime, @size, @transcript, @indexedAt)
|
|
88
|
+
insert into sessions(agent, session_id, source, cwd, title, started_at, ended_at, message_count, source_mtime, source_size, parser_version, transcript, indexed_at)
|
|
89
|
+
values(@agent, @sessionId, @source, @cwd, @title, @startedAt, @endedAt, @messageCount, @mtime, @size, @parserVersion, @transcript, @indexedAt)
|
|
84
90
|
on conflict(agent, session_id) do update set
|
|
85
91
|
source=excluded.source, cwd=excluded.cwd, title=excluded.title,
|
|
86
92
|
started_at=excluded.started_at, ended_at=excluded.ended_at,
|
|
87
93
|
message_count=excluded.message_count, source_mtime=excluded.source_mtime,
|
|
88
|
-
source_size=excluded.source_size,
|
|
94
|
+
source_size=excluded.source_size, parser_version=excluded.parser_version,
|
|
95
|
+
transcript=excluded.transcript,
|
|
89
96
|
indexed_at=excluded.indexed_at
|
|
90
97
|
`);
|
|
91
98
|
const deleteSearch = prepare("delete from session_fts where agent = ? and session_id = ?");
|
|
92
99
|
const insertSearch = prepare("insert into session_fts(agent, session_id, title, cwd, transcript) values(?, ?, ?, ?, ?)");
|
|
93
|
-
const
|
|
100
|
+
const persistParsed = (record) => {
|
|
94
101
|
db.exec("begin immediate");
|
|
95
102
|
try {
|
|
96
103
|
upsert.run(record);
|
|
@@ -114,15 +121,29 @@ export function openStore(file) {
|
|
|
114
121
|
db,
|
|
115
122
|
needsIndex(agent, sessionId, mtime, size) {
|
|
116
123
|
const row = existing.get(agent, sessionId);
|
|
117
|
-
return !row
|
|
124
|
+
return !row
|
|
125
|
+
|| Number(row.source_mtime) !== Number(mtime)
|
|
126
|
+
|| Number(row.source_size) !== Number(size)
|
|
127
|
+
|| Number(row.parser_version) !== INDEX_FORMAT_VERSION;
|
|
118
128
|
},
|
|
119
129
|
indexRaw({ agent, sessionId, source = null, raw, mtime = Date.now(), size = raw.length, cwd }) {
|
|
120
130
|
const parsed = parseSession(agent, raw);
|
|
121
|
-
|
|
131
|
+
persistParsed({
|
|
132
|
+
agent, sessionId, source, cwd: cwd || parsed.meta.cwd || null,
|
|
133
|
+
title: parsed.meta.title, startedAt: parsed.meta.startedAt,
|
|
134
|
+
endedAt: parsed.meta.endedAt, messageCount: parsed.meta.messageCount,
|
|
135
|
+
mtime, size, parserVersion: INDEX_FORMAT_VERSION,
|
|
136
|
+
transcript: parsed.transcript, indexedAt: new Date().toISOString(),
|
|
137
|
+
});
|
|
138
|
+
return parsed.meta;
|
|
139
|
+
},
|
|
140
|
+
indexParsed({ agent, sessionId, source = null, parsed, mtime, size, cwd }) {
|
|
141
|
+
persistParsed({
|
|
122
142
|
agent, sessionId, source, cwd: cwd || parsed.meta.cwd || null,
|
|
123
143
|
title: parsed.meta.title, startedAt: parsed.meta.startedAt,
|
|
124
144
|
endedAt: parsed.meta.endedAt, messageCount: parsed.meta.messageCount,
|
|
125
|
-
mtime, size,
|
|
145
|
+
mtime, size, parserVersion: INDEX_FORMAT_VERSION,
|
|
146
|
+
transcript: parsed.transcript, indexedAt: new Date().toISOString(),
|
|
126
147
|
});
|
|
127
148
|
return parsed.meta;
|
|
128
149
|
},
|