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 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.0" });
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, tailFile, writeJsonAtomic } from "./files.mjs";
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.0";
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 raw = tailFile(item.file);
109
- store.indexRaw({ agent, sessionId, source: item.file, raw, mtime: item.mtime, size: item.size });
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", "-n", "500", "--format", "json"], { timeout: 20_000, maxBuffer: 20_000_000 });
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
- try { return { raw: tailFile(row.source, 800_000), cwd: row.cwd || undefined }; } catch { return null; }
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
- runAgentTask(message, (kind, text, extra) => send({ type: "event", runId: message.runId, kind, ...(text ? { text } : {}), ...(extra || {}) }));
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
- // Fail-open normalizers for the four supported agents. The daemon owns this copy so
2
- // indexing never depends on a Harness tab or on code downloaded after installation.
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 parseClaude(text) {
49
- const records = parseLines(text).filter((row) => row.type === "user" || row.type === "assistant");
50
- const byId = new Map(records.filter((row) => typeof row.uuid === "string").map((row) => [row.uuid, row]));
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 (typeof cursor.uuid === "string") {
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 = typeof cursor.parentUuid === "string" ? byId.get(cursor.parentUuid) : undefined;
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
- const textValue = contentText(row.message?.content);
65
- return textValue ? [{ role: row.type === "user" ? "user" : "agent", text: textValue, ts: typeof row.timestamp === "string" ? row.timestamp : null }] : [];
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: typeof records[0]?.cwd === "string" ? records[0].cwd : null,
70
- startedAt: typeof records[0]?.timestamp === "string" ? records[0].timestamp : null,
71
- endedAt: typeof records.at(-1)?.timestamp === "string" ? records.at(-1).timestamp : null,
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
- const records = parseLines(text);
79
- const meta = records.find((row) => row.type === "session_meta")?.payload || {};
80
- const messages = records.flatMap((row) => {
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") return [];
115
+ if (!payload || payload.type !== "message") continue;
83
116
  const textValue = contentText(payload.content);
84
- return textValue ? [{ role: payload.role === "user" ? "user" : "agent", text: textValue, ts: typeof row.timestamp === "string" ? row.timestamp : null }] : [];
85
- });
86
- const timestamps = records.map((row) => row.timestamp).filter((value) => typeof value === "string");
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 meta.cwd === "string" ? meta.cwd : null,
90
- startedAt: timestamps[0] || null,
91
- endedAt: timestamps.at(-1) || null,
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
- const records = parseLines(text);
99
- const header = records.find((row) => row.type === "session") || {};
100
- const messages = records.flatMap((row) => {
101
- if (row.type !== "message") return [];
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
- return textValue ? [{ role: row.message?.role === "user" ? "user" : "agent", text: textValue, ts: typeof row.timestamp === "string" ? row.timestamp : null }] : [];
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
- const PARSERS = { "claude-code": parseClaude, codex: parseCodex, pi: parsePi, opencode: parseOpencode };
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
- export function parseSession(agent, raw) {
149
- const parser = PARSERS[agent];
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").slice(0, 400_000),
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, spawnSync } from "node:child_process";
3
+ import { spawn } from "node:child_process";
5
4
 
6
5
  const running = new Set();
7
6
 
8
- function workingDirectory(requested) {
9
- const home = os.homedir();
10
- const resolved = !requested ? home : requested === "~" ? home : requested.startsWith("~/") ? path.join(home, requested.slice(2)) : requested;
11
- return fs.existsSync(resolved) ? resolved : home;
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 runAgentTask(task, emit) {
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
- running.add(task.agent);
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
- if (task.agent === "claude-code") {
25
- child = spawn("claude", ["-p", task.brief, "--output-format", "stream-json", "--verbose", "--permission-mode", "acceptEdits"], { cwd });
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
- buffer += String(chunk);
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) => { tail = (tail + "\n" + String(chunk)).slice(-4_000); });
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));
@@ -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, ["better-sqlite3", "ws"]);
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 Database from "better-sqlite3";
1
+ import sqliteWasm from "node-sqlite3-wasm";
2
2
  import { parseSession } from "./parsers.mjs";
3
3
 
4
- const SCHEMA_VERSION = 2;
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
- db.pragma("journal_mode = WAL");
9
- db.pragma("synchronous = NORMAL");
10
- db.pragma("busy_timeout = 5000");
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 existing = db.prepare("select source_mtime, source_size from sessions where agent = ? and session_id = ?");
53
- const upsert = db.prepare(`
54
- insert into sessions(agent, session_id, source, cwd, title, started_at, ended_at, message_count, source_mtime, source_size, transcript, indexed_at)
55
- values(@agent, @sessionId, @source, @cwd, @title, @startedAt, @endedAt, @messageCount, @mtime, @size, @transcript, @indexedAt)
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, transcript=excluded.transcript,
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 = db.prepare("delete from session_fts where agent = ? and session_id = ?");
64
- const insertSearch = db.prepare("insert into session_fts(agent, session_id, title, cwd, transcript) values(?, ?, ?, ?, ?)");
65
- const indexParsed = db.transaction((record) => {
66
- upsert.run(record);
67
- deleteSearch.run(record.agent, record.sessionId);
68
- insertSearch.run(record.agent, record.sessionId, record.title, record.cwd, record.transcript);
69
- });
70
- const sessionCount = Number(db.prepare("select count(*) as count from sessions").get().count);
71
- const searchCount = Number(db.prepare("select count(*) as count from session_fts").get().count);
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 || Number(row.source_mtime) !== Number(mtime) || Number(row.source_size) !== Number(size);
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
- indexParsed({
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, transcript: parsed.transcript, indexedAt: new Date().toISOString(),
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 = db.prepare("select agent, count(*) as count from sessions group by agent").all();
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 db.prepare(`select sessions.agent, session_id, title, cwd, started_at, ended_at, message_count, null as excerpt
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`).all({ ...params, limit: boundedLimit })
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 db.prepare(`select sessions.agent, sessions.session_id, sessions.title, sessions.cwd,
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`).all(params).map((row) => ({ ...row, message_count: Number(row.message_count) }));
184
+ limit @limit`, params).map((row) => ({ ...row, message_count: Number(row.message_count) }));
129
185
  },
130
186
  read({ agent, sessionId }) {
131
- const row = db.prepare(`select agent, session_id, title, cwd, started_at, ended_at, message_count, transcript, source
132
- from sessions where agent = ? and session_id = ?`).get(agent, sessionId);
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 row = db.prepare(`select source, cwd from sessions
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
- .get(agent, sessionHint, sessionHint, sessionHint);
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
- db.prepare(`insert or replace into handoffs(id, target_agent, target_session_id, title, body, source, created_at, consumed_at)
144
- values(?, ?, ?, ?, ?, ?, ?, null)`).run(id, targetAgent, targetSessionId || null, title, body, source || null, new Date().toISOString());
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
- ? db.prepare(`select id, target_agent, target_session_id, title, source, created_at
149
- from handoffs where target_agent = ? and consumed_at is null order by created_at desc limit 20`).all(targetAgent)
150
- : db.prepare(`select id, target_agent, target_session_id, title, source, created_at
151
- from handoffs where consumed_at is null order by created_at desc limit 20`).all();
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 = db.prepare("select * from handoffs where id = ?").get(id);
155
- if (row && consume) db.prepare("update handoffs set consumed_at = ? where id = ?").run(new Date().toISOString(), id);
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() { db.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.0",
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
- "better-sqlite3": "11.10.0",
32
+ "node-sqlite3-wasm": "0.8.60",
33
33
  "ws": "^8.18.3"
34
34
  }
35
35
  }