tryharness 0.4.0 → 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 +17 -0
- package/cli.mjs +60 -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/runtime-install.mjs +1 -1
- package/lib/store.mjs +102 -42
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,12 +6,18 @@ Connect the coding agents installed on this machine to [Harness](https://tryharn
|
|
|
6
6
|
npx tryharness
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
+
Supports the Node.js 18, 20, 22, and 24 release lines.
|
|
10
|
+
|
|
9
11
|
That one command pairs the machine, detects Claude Code, Codex, pi, and opencode,
|
|
10
12
|
configures every installed agent, installs the Harness daemon as a user-level login
|
|
11
13
|
service, validates readiness, and starts indexing existing coding sessions locally in
|
|
12
14
|
SQLite. Setup returns as soon as the daemon is ready; indexing continues in the
|
|
13
15
|
background and does not require an open Harness tab.
|
|
14
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
|
+
|
|
15
21
|
The command is idempotent. Run it again after installing another agent or to repair its
|
|
16
22
|
configuration.
|
|
17
23
|
|
|
@@ -21,6 +27,7 @@ configuration.
|
|
|
21
27
|
npx tryharness status
|
|
22
28
|
npx tryharness doctor
|
|
23
29
|
npx tryharness sync --agent codex
|
|
30
|
+
npx tryharness task <run-id> --follow
|
|
24
31
|
npx tryharness logs --follow
|
|
25
32
|
npx tryharness handoff codex --file ./implementation-brief.md
|
|
26
33
|
npx tryharness disconnect
|
|
@@ -30,6 +37,16 @@ npx tryharness disconnect
|
|
|
30
37
|
daemon, the bridge, the database, and agent discovery. `sync` requests a background
|
|
31
38
|
rescan; it is diagnostic, not part of normal setup.
|
|
32
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
|
+
|
|
33
50
|
The daemon dials out to Harness over one authenticated WebSocket. Its local control
|
|
34
51
|
socket is permissioned to the current user. When OpenCode is installed, the daemon also
|
|
35
52
|
starts OpenCode's own helper on a random loopback-only port with a random per-process
|
package/cli.mjs
CHANGED
|
@@ -12,8 +12,13 @@ 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
|
+
const NODE_MAJOR = Number(process.versions.node.split(".")[0]);
|
|
18
|
+
if (!Number.isFinite(NODE_MAJOR) || NODE_MAJOR < 18) {
|
|
19
|
+
console.error(`tryharness requires Node.js 18 or newer (current: ${process.version}). Upgrade Node, then rerun: npx tryharness`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
17
22
|
const argv = process.argv.slice(2);
|
|
18
23
|
const command = ["--help", "-h"].includes(argv[0])
|
|
19
24
|
? (argv.shift(), "help")
|
|
@@ -26,6 +31,18 @@ function arg(flag, fallback = null) {
|
|
|
26
31
|
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
|
27
32
|
}
|
|
28
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
|
+
}
|
|
29
46
|
function origin() {
|
|
30
47
|
const saved = readJson(paths.config, {});
|
|
31
48
|
return String(arg("--origin", saved.origin || "https://tryharness.ai")).replace(/\/$/, "");
|
|
@@ -208,6 +225,46 @@ async function handoff() {
|
|
|
208
225
|
console.log(`Handoff ${response.id} is ready for ${agent}. Harness's get_handoff tool can read it.`);
|
|
209
226
|
}
|
|
210
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
|
+
|
|
211
268
|
function logs() {
|
|
212
269
|
if (!fs.existsSync(paths.log)) { console.log("No daemon log yet."); return; }
|
|
213
270
|
const lines = fs.readFileSync(paths.log, "utf8").split("\n");
|
|
@@ -225,7 +282,7 @@ async function disconnect() {
|
|
|
225
282
|
}
|
|
226
283
|
|
|
227
284
|
function help() {
|
|
228
|
-
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`);
|
|
229
286
|
}
|
|
230
287
|
|
|
231
288
|
try {
|
|
@@ -233,6 +290,7 @@ try {
|
|
|
233
290
|
else if (command === "status" || command === "bridge") await status();
|
|
234
291
|
else if (command === "doctor") await doctor();
|
|
235
292
|
else if (command === "sync" || command === "backfill" || command === "drain") await sync();
|
|
293
|
+
else if (command === "task") await task();
|
|
236
294
|
else if (command === "handoff") await handoff();
|
|
237
295
|
else if (command === "logs") logs();
|
|
238
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/runtime-install.mjs
CHANGED
|
@@ -59,7 +59,7 @@ export function installRuntime({ sourceRoot, version }) {
|
|
|
59
59
|
if (!fs.existsSync(source)) continue;
|
|
60
60
|
fs.cpSync(source, path.join(staging, entry), { recursive: true, force: true });
|
|
61
61
|
}
|
|
62
|
-
copyDependencyClosure(sourceRoot, staging, ["
|
|
62
|
+
copyDependencyClosure(sourceRoot, staging, ["node-sqlite3-wasm", "ws"]);
|
|
63
63
|
fs.writeFileSync(path.join(staging, ".ready"), version + "\n", { mode: 0o600 });
|
|
64
64
|
ensureDir(path.dirname(release));
|
|
65
65
|
fs.rmSync(release, { recursive: true, force: true });
|
package/lib/store.mjs
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
|
-
import
|
|
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;
|
|
6
|
+
const { Database } = sqliteWasm;
|
|
7
|
+
|
|
8
|
+
function bindings(values) {
|
|
9
|
+
if (!values.length) return undefined;
|
|
10
|
+
const value = values.length === 1 ? values[0] : values;
|
|
11
|
+
if (!value || Array.isArray(value) || typeof value !== "object") return value;
|
|
12
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => (
|
|
13
|
+
[key.startsWith("@") || key.startsWith(":") || key.startsWith("$") ? key : `@${key}`, entry]
|
|
14
|
+
)));
|
|
15
|
+
}
|
|
5
16
|
|
|
6
17
|
export function openStore(file) {
|
|
7
18
|
const db = new Database(file);
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
db.
|
|
19
|
+
// The daemon is the database's single writer. DELETE mode is portable across the
|
|
20
|
+
// WASM VFS on every supported Node LTS line; no native addon or ABI is involved.
|
|
21
|
+
db.exec("pragma journal_mode = DELETE; pragma synchronous = NORMAL; pragma busy_timeout = 5000;");
|
|
11
22
|
db.exec(`
|
|
12
23
|
create table if not exists schema_info (version integer not null);
|
|
13
24
|
insert into schema_info(version) select ${SCHEMA_VERSION} where not exists (select 1 from schema_info);
|
|
@@ -23,6 +34,7 @@ export function openStore(file) {
|
|
|
23
34
|
message_count integer not null default 0,
|
|
24
35
|
source_mtime real not null default 0,
|
|
25
36
|
source_size integer not null default 0,
|
|
37
|
+
parser_version integer not null default 0,
|
|
26
38
|
transcript text not null default '',
|
|
27
39
|
indexed_at text not null,
|
|
28
40
|
primary key(agent, session_id)
|
|
@@ -48,27 +60,57 @@ export function openStore(file) {
|
|
|
48
60
|
consumed_at text
|
|
49
61
|
);
|
|
50
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
|
+
}
|
|
51
67
|
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
const statements = [];
|
|
69
|
+
const prepare = (sql) => {
|
|
70
|
+
const raw = db.prepare(sql);
|
|
71
|
+
statements.push(raw);
|
|
72
|
+
const call = (method, values) => {
|
|
73
|
+
const bound = bindings(values);
|
|
74
|
+
return bound === undefined ? raw[method]() : raw[method](bound);
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
get: (...values) => call("get", values),
|
|
78
|
+
all: (...values) => call("all", values),
|
|
79
|
+
run: (...values) => call("run", values),
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
const execute = (method, sql, ...values) => {
|
|
83
|
+
const bound = bindings(values);
|
|
84
|
+
return bound === undefined ? db[method](sql) : db[method](sql, bound);
|
|
85
|
+
};
|
|
86
|
+
const existing = prepare("select source_mtime, source_size, parser_version from sessions where agent = ? and session_id = ?");
|
|
87
|
+
const upsert = prepare(`
|
|
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)
|
|
56
90
|
on conflict(agent, session_id) do update set
|
|
57
91
|
source=excluded.source, cwd=excluded.cwd, title=excluded.title,
|
|
58
92
|
started_at=excluded.started_at, ended_at=excluded.ended_at,
|
|
59
93
|
message_count=excluded.message_count, source_mtime=excluded.source_mtime,
|
|
60
|
-
source_size=excluded.source_size,
|
|
94
|
+
source_size=excluded.source_size, parser_version=excluded.parser_version,
|
|
95
|
+
transcript=excluded.transcript,
|
|
61
96
|
indexed_at=excluded.indexed_at
|
|
62
97
|
`);
|
|
63
|
-
const deleteSearch =
|
|
64
|
-
const insertSearch =
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
98
|
+
const deleteSearch = prepare("delete from session_fts where agent = ? and session_id = ?");
|
|
99
|
+
const insertSearch = prepare("insert into session_fts(agent, session_id, title, cwd, transcript) values(?, ?, ?, ?, ?)");
|
|
100
|
+
const persistParsed = (record) => {
|
|
101
|
+
db.exec("begin immediate");
|
|
102
|
+
try {
|
|
103
|
+
upsert.run(record);
|
|
104
|
+
deleteSearch.run(record.agent, record.sessionId);
|
|
105
|
+
insertSearch.run(record.agent, record.sessionId, record.title, record.cwd, record.transcript);
|
|
106
|
+
db.exec("commit");
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (db.inTransaction) db.exec("rollback");
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const sessionCount = Number(execute("get", "select count(*) as count from sessions").count);
|
|
113
|
+
const searchCount = Number(execute("get", "select count(*) as count from session_fts").count);
|
|
72
114
|
if (sessionCount !== searchCount) {
|
|
73
115
|
db.exec(`delete from session_fts;
|
|
74
116
|
insert into session_fts(agent, session_id, title, cwd, transcript)
|
|
@@ -79,20 +121,34 @@ export function openStore(file) {
|
|
|
79
121
|
db,
|
|
80
122
|
needsIndex(agent, sessionId, mtime, size) {
|
|
81
123
|
const row = existing.get(agent, sessionId);
|
|
82
|
-
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;
|
|
83
128
|
},
|
|
84
129
|
indexRaw({ agent, sessionId, source = null, raw, mtime = Date.now(), size = raw.length, cwd }) {
|
|
85
130
|
const parsed = parseSession(agent, raw);
|
|
86
|
-
|
|
131
|
+
persistParsed({
|
|
87
132
|
agent, sessionId, source, cwd: cwd || parsed.meta.cwd || null,
|
|
88
133
|
title: parsed.meta.title, startedAt: parsed.meta.startedAt,
|
|
89
134
|
endedAt: parsed.meta.endedAt, messageCount: parsed.meta.messageCount,
|
|
90
|
-
mtime, size,
|
|
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({
|
|
142
|
+
agent, sessionId, source, cwd: cwd || parsed.meta.cwd || null,
|
|
143
|
+
title: parsed.meta.title, startedAt: parsed.meta.startedAt,
|
|
144
|
+
endedAt: parsed.meta.endedAt, messageCount: parsed.meta.messageCount,
|
|
145
|
+
mtime, size, parserVersion: INDEX_FORMAT_VERSION,
|
|
146
|
+
transcript: parsed.transcript, indexedAt: new Date().toISOString(),
|
|
91
147
|
});
|
|
92
148
|
return parsed.meta;
|
|
93
149
|
},
|
|
94
150
|
counts() {
|
|
95
|
-
const rows =
|
|
151
|
+
const rows = execute("all", "select agent, count(*) as count from sessions group by agent");
|
|
96
152
|
return Object.fromEntries(rows.map((row) => [row.agent, Number(row.count)]));
|
|
97
153
|
},
|
|
98
154
|
search({ query = "", agent, days, limit = 12 }) {
|
|
@@ -106,9 +162,9 @@ export function openStore(file) {
|
|
|
106
162
|
const phrase = String(query).trim().toLowerCase();
|
|
107
163
|
const boundedLimit = Math.max(1, Math.min(50, Number(limit) || 12));
|
|
108
164
|
if (!phrase) {
|
|
109
|
-
return
|
|
165
|
+
return execute("all", `select sessions.agent, session_id, title, cwd, started_at, ended_at, message_count, null as excerpt
|
|
110
166
|
from sessions ${clauses.length ? `where ${clauses.join(" and ")}` : ""}
|
|
111
|
-
order by coalesce(started_at, indexed_at) desc limit @limit
|
|
167
|
+
order by coalesce(started_at, indexed_at) desc limit @limit`, { ...params, limit: boundedLimit })
|
|
112
168
|
.map((row) => ({ ...row, message_count: Number(row.message_count) }));
|
|
113
169
|
}
|
|
114
170
|
const terms = [...new Set(phrase.match(/[\p{L}\p{N}_-]+/gu) || [])].filter((term) => term.length > 1).slice(0, 8);
|
|
@@ -117,7 +173,7 @@ export function openStore(file) {
|
|
|
117
173
|
params.phrase = phrase;
|
|
118
174
|
params.limit = boundedLimit;
|
|
119
175
|
const where = ["session_fts match @match", ...clauses].join(" and ");
|
|
120
|
-
return
|
|
176
|
+
return execute("all", `select sessions.agent, sessions.session_id, sessions.title, sessions.cwd,
|
|
121
177
|
sessions.started_at, sessions.ended_at, sessions.message_count,
|
|
122
178
|
snippet(session_fts, 4, '', '', ' … ', 48) as excerpt
|
|
123
179
|
from session_fts join sessions
|
|
@@ -125,36 +181,40 @@ export function openStore(file) {
|
|
|
125
181
|
where ${where}
|
|
126
182
|
order by case when instr(lower(sessions.title || ' ' || session_fts.transcript), @phrase) > 0 then 0 else 1 end,
|
|
127
183
|
bm25(session_fts), coalesce(sessions.started_at, sessions.indexed_at) desc
|
|
128
|
-
limit @limit
|
|
184
|
+
limit @limit`, params).map((row) => ({ ...row, message_count: Number(row.message_count) }));
|
|
129
185
|
},
|
|
130
186
|
read({ agent, sessionId }) {
|
|
131
|
-
const row =
|
|
132
|
-
from sessions where agent = ? and session_id =
|
|
187
|
+
const row = execute("get", `select agent, session_id, title, cwd, started_at, ended_at, message_count, transcript, source
|
|
188
|
+
from sessions where agent = ? and session_id = ?`, agent, sessionId);
|
|
133
189
|
return row ? { ...row, message_count: Number(row.message_count) } : null;
|
|
134
190
|
},
|
|
135
191
|
findSource(agent, sessionHint) {
|
|
136
|
-
const
|
|
192
|
+
const found = execute("get", `select source, cwd from sessions
|
|
137
193
|
where agent = ? and (session_id = ? or instr(session_id, ?) > 0)
|
|
138
|
-
order by case when session_id = ? then 0 else 1 end, indexed_at desc limit 1
|
|
139
|
-
|
|
140
|
-
return row || null;
|
|
194
|
+
order by case when session_id = ? then 0 else 1 end, indexed_at desc limit 1`, agent, sessionHint, sessionHint, sessionHint);
|
|
195
|
+
return found || null;
|
|
141
196
|
},
|
|
142
197
|
addHandoff({ id, targetAgent, targetSessionId, title, body, source }) {
|
|
143
|
-
|
|
144
|
-
values(?, ?, ?, ?, ?, ?, ?, null)
|
|
198
|
+
execute("run", `insert or replace into handoffs(id, target_agent, target_session_id, title, body, source, created_at, consumed_at)
|
|
199
|
+
values(?, ?, ?, ?, ?, ?, ?, null)`, id, targetAgent, targetSessionId || null, title, body, source || null, new Date().toISOString());
|
|
145
200
|
},
|
|
146
201
|
listHandoffs(targetAgent) {
|
|
147
202
|
return targetAgent
|
|
148
|
-
?
|
|
149
|
-
from handoffs where target_agent = ? and consumed_at is null order by created_at desc limit 20
|
|
150
|
-
:
|
|
151
|
-
from handoffs where consumed_at is null order by created_at desc limit 20`)
|
|
203
|
+
? execute("all", `select id, target_agent, target_session_id, title, source, created_at
|
|
204
|
+
from handoffs where target_agent = ? and consumed_at is null order by created_at desc limit 20`, targetAgent)
|
|
205
|
+
: execute("all", `select id, target_agent, target_session_id, title, source, created_at
|
|
206
|
+
from handoffs where consumed_at is null order by created_at desc limit 20`);
|
|
152
207
|
},
|
|
153
208
|
getHandoff(id, consume = false) {
|
|
154
|
-
const row =
|
|
155
|
-
if (row && consume)
|
|
209
|
+
const row = execute("get", "select * from handoffs where id = ?", id);
|
|
210
|
+
if (row && consume) execute("run", "update handoffs set consumed_at = ? where id = ?", new Date().toISOString(), id);
|
|
156
211
|
return row || null;
|
|
157
212
|
},
|
|
158
|
-
close() {
|
|
213
|
+
close() {
|
|
214
|
+
for (const statement of statements) {
|
|
215
|
+
try { statement.finalize(); } catch {}
|
|
216
|
+
}
|
|
217
|
+
db.close();
|
|
218
|
+
},
|
|
159
219
|
};
|
|
160
220
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tryharness",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "One-command local daemon for Harness memory, coding-agent history, handoffs, and delegated tasks.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tryharness": "cli.mjs"
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"homepage": "https://tryharness.ai",
|
|
30
30
|
"license": "MIT",
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"
|
|
32
|
+
"node-sqlite3-wasm": "0.8.60",
|
|
33
33
|
"ws": "^8.18.3"
|
|
34
34
|
}
|
|
35
35
|
}
|