tryharness 0.3.0 → 0.4.0

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/lib/paths.mjs ADDED
@@ -0,0 +1,35 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ // HARNESS_HOME is deliberately task-specific. It also makes every filesystem-affecting
5
+ // command testable without touching a developer's real ~/.harness installation.
6
+ export const harnessHome = process.env.HARNESS_HOME
7
+ ? path.resolve(process.env.HARNESS_HOME)
8
+ : path.join(os.homedir(), ".harness");
9
+
10
+ export const paths = {
11
+ home: harnessHome,
12
+ config: path.join(harnessHome, "config.json"),
13
+ database: path.join(harnessHome, "harness.db"),
14
+ status: path.join(harnessHome, "daemon-status.json"),
15
+ socket: process.platform === "win32" ? "\\\\.\\pipe\\tryharness-daemon" : path.join(harnessHome, "daemon.sock"),
16
+ log: path.join(harnessHome, "daemon.log"),
17
+ runtime: path.join(harnessHome, "runtime"),
18
+ legacyBridgeConfig: path.join(harnessHome, "bridge.json"),
19
+ legacySpool: path.join(harnessHome, "pending-sessions.json"),
20
+ };
21
+
22
+ export function historyRoots(agent) {
23
+ if (agent === "claude-code") return [process.env.HARNESS_CLAUDE_HISTORY_ROOT || path.join(os.homedir(), ".claude", "projects")];
24
+ if (agent === "codex") return [process.env.HARNESS_CODEX_HISTORY_ROOT || path.join(os.homedir(), ".codex", "sessions")];
25
+ if (agent === "pi") {
26
+ return Array.from(new Set([
27
+ process.env.HARNESS_PI_HISTORY_ROOT,
28
+ process.env.PI_CODING_AGENT_SESSION_DIR,
29
+ process.env.PI_CODING_AGENT_DIR ? path.join(process.env.PI_CODING_AGENT_DIR, "sessions") : null,
30
+ path.join(os.homedir(), ".pi", "agent", "sessions"),
31
+ path.join(os.homedir(), ".local", "share", "pi-coding-agent", "sessions"),
32
+ ].filter(Boolean)));
33
+ }
34
+ return [];
35
+ }
package/lib/runner.mjs ADDED
@@ -0,0 +1,89 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawn, spawnSync } from "node:child_process";
5
+
6
+ const running = new Set();
7
+
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;
12
+ }
13
+
14
+ export function runAgentTask(task, emit) {
15
+ if (running.has(task.agent)) {
16
+ emit("failed", `A ${task.agent} task is already running on this machine.`);
17
+ return;
18
+ }
19
+ running.add(task.agent);
20
+ emit("accepted");
21
+ const cwd = workingDirectory(task.cwd);
22
+ let child;
23
+ 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
+ }
42
+ } catch (error) {
43
+ running.delete(task.agent);
44
+ emit("failed", `Could not start ${task.agent}: ${error.message}`);
45
+ return;
46
+ }
47
+
48
+ let resultText = "";
49
+ let tail = "";
50
+ let buffer = "";
51
+ let sessionReported = false;
52
+ child.stdout?.on("data", (chunk) => {
53
+ buffer += String(chunk);
54
+ const lines = buffer.split("\n");
55
+ buffer = lines.pop() || "";
56
+ for (const line of lines) {
57
+ if (!line.trim()) continue;
58
+ tail = (tail + "\n" + line).slice(-4_000);
59
+ let event;
60
+ try { event = JSON.parse(line); } catch { continue; }
61
+ const sessionId = event.session_id || event.sessionId || event.sessionID || event.thread_id || event.thread?.id || event.properties?.sessionID;
62
+ if (!sessionReported && typeof sessionId === "string" && sessionId) {
63
+ sessionReported = true;
64
+ emit("session", sessionId, { cwd });
65
+ }
66
+ if (event.type === "result" && typeof event.result === "string") resultText = event.result;
67
+ if (event.type === "item.completed" && event.item?.type === "agent_message" && typeof event.item.text === "string") resultText = event.item.text;
68
+ if (["text", "message.part.updated"].includes(event.type) && typeof event.part?.text === "string") resultText = event.part.text;
69
+ const progress = event.type === "assistant" && Array.isArray(event.message?.content)
70
+ ? event.message.content.filter((block) => block.type === "text").map((block) => block.text).join(" ")
71
+ : event.type === "message_update" && typeof event.text === "string"
72
+ ? event.text
73
+ : event.type === "item.completed" && event.item?.type && event.item.type !== "agent_message"
74
+ ? event.item.type + (event.item.command ? ": " + event.item.command : "")
75
+ : typeof event.part?.text === "string" ? event.part.text : null;
76
+ if (progress?.trim()) emit("progress", progress.trim().slice(0, 200));
77
+ }
78
+ });
79
+ child.stderr?.on("data", (chunk) => { tail = (tail + "\n" + String(chunk)).slice(-4_000); });
80
+ child.on("close", (code) => {
81
+ running.delete(task.agent);
82
+ if (code === 0) emit("done", (resultText || "The run finished; check the repository for its changes.").slice(0, 20_000));
83
+ else emit("failed", (`${task.agent} exited with code ${code}.\n${tail}`).slice(0, 4_000));
84
+ });
85
+ child.on("error", (error) => {
86
+ running.delete(task.agent);
87
+ emit("failed", `Could not start ${task.agent}: ${error.message}`);
88
+ });
89
+ }
@@ -0,0 +1,75 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createRequire } from "node:module";
4
+ import { ensureDir, readJson, writeJsonAtomic } from "./files.mjs";
5
+ import { paths } from "./paths.mjs";
6
+
7
+ function packageDirectory(packageJson) {
8
+ return path.dirname(packageJson);
9
+ }
10
+
11
+ function dependencyTarget(root, name) {
12
+ return path.join(root, "node_modules", ...name.split("/"));
13
+ }
14
+
15
+ function copyDependencyClosure(sourceRoot, releaseRoot, roots) {
16
+ const queue = roots.map((name) => ({ name, require: createRequire(path.join(sourceRoot, "package.json")) }));
17
+ const copied = new Set();
18
+ while (queue.length) {
19
+ const item = queue.shift();
20
+ if (copied.has(item.name)) continue;
21
+ let packageJson;
22
+ try { packageJson = item.require.resolve(`${item.name}/package.json`); }
23
+ catch {
24
+ // Packages with restrictive exports still resolve their main file. Walk upward to
25
+ // the package.json whose name matches the requested dependency.
26
+ let current = path.dirname(item.require.resolve(item.name));
27
+ while (current !== path.dirname(current)) {
28
+ const candidate = path.join(current, "package.json");
29
+ if (readJson(candidate, {}).name === item.name) { packageJson = candidate; break; }
30
+ current = path.dirname(current);
31
+ }
32
+ }
33
+ if (!packageJson) throw new Error(`could not package runtime dependency ${item.name}`);
34
+ const source = packageDirectory(packageJson);
35
+ const target = dependencyTarget(releaseRoot, item.name);
36
+ ensureDir(path.dirname(target));
37
+ fs.cpSync(source, target, { recursive: true, force: true });
38
+ copied.add(item.name);
39
+ const metadata = readJson(packageJson, {});
40
+ const nestedRequire = createRequire(packageJson);
41
+ for (const name of Object.keys({ ...(metadata.dependencies || {}), ...(metadata.optionalDependencies || {}) })) {
42
+ try {
43
+ nestedRequire.resolve(name);
44
+ queue.push({ name, require: nestedRequire });
45
+ } catch {}
46
+ }
47
+ }
48
+ }
49
+
50
+ export function installRuntime({ sourceRoot, version }) {
51
+ const release = path.join(paths.runtime, "releases", version);
52
+ const marker = path.join(release, ".ready");
53
+ if (!fs.existsSync(marker)) {
54
+ const staging = `${release}.staging-${process.pid}`;
55
+ fs.rmSync(staging, { recursive: true, force: true });
56
+ ensureDir(staging);
57
+ for (const entry of ["cli.mjs", "package.json", "README.md", "lib", "assets"]) {
58
+ const source = path.join(sourceRoot, entry);
59
+ if (!fs.existsSync(source)) continue;
60
+ fs.cpSync(source, path.join(staging, entry), { recursive: true, force: true });
61
+ }
62
+ copyDependencyClosure(sourceRoot, staging, ["better-sqlite3", "ws"]);
63
+ fs.writeFileSync(path.join(staging, ".ready"), version + "\n", { mode: 0o600 });
64
+ ensureDir(path.dirname(release));
65
+ fs.rmSync(release, { recursive: true, force: true });
66
+ fs.renameSync(staging, release);
67
+ }
68
+ writeJsonAtomic(path.join(paths.runtime, "current.json"), { version, path: release });
69
+ return release;
70
+ }
71
+
72
+ export function currentRuntime() {
73
+ const current = readJson(path.join(paths.runtime, "current.json"), {});
74
+ return typeof current.path === "string" && fs.existsSync(current.path) ? current.path : null;
75
+ }
@@ -0,0 +1,99 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { resolveCommand, run } from "./commands.mjs";
6
+ import { ensureDir } from "./files.mjs";
7
+ import { paths } from "./paths.mjs";
8
+
9
+ const LABEL = "ai.tryharness.daemon";
10
+
11
+ function xml(value) {
12
+ return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
13
+ }
14
+
15
+ function servicePath() {
16
+ if (process.platform === "darwin") return path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
17
+ if (process.platform === "linux") return path.join(os.homedir(), ".config", "systemd", "user", "tryharness-daemon.service");
18
+ return null;
19
+ }
20
+
21
+ function agentPath() {
22
+ const dirs = new Set([path.dirname(process.execPath), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]);
23
+ for (const command of ["claude", "codex", "pi", "opencode"]) {
24
+ const found = resolveCommand(command);
25
+ if (found) dirs.add(path.dirname(found));
26
+ }
27
+ return [...dirs].join(path.delimiter);
28
+ }
29
+
30
+ function retireLegacyBridge() {
31
+ if (process.platform === "darwin") {
32
+ const legacy = path.join(os.homedir(), "Library", "LaunchAgents", "ai.tryharness.bridge.plist");
33
+ if (!fs.existsSync(legacy)) return;
34
+ run("launchctl", ["unload", legacy]);
35
+ try { fs.renameSync(legacy, legacy + ".retired"); } catch {}
36
+ } else if (process.platform === "linux") {
37
+ const legacy = path.join(os.homedir(), ".config", "systemd", "user", "tryharness-bridge.service");
38
+ if (!fs.existsSync(legacy)) return;
39
+ run("systemctl", ["--user", "disable", "--now", "tryharness-bridge.service"]);
40
+ try { fs.renameSync(legacy, legacy + ".retired"); } catch {}
41
+ }
42
+ }
43
+
44
+ export function installService({ runtimeRoot, nodePath = process.execPath }) {
45
+ ensureDir(paths.home);
46
+ retireLegacyBridge();
47
+ const entry = path.join(runtimeRoot, "cli.mjs");
48
+ const envPath = agentPath();
49
+ if (process.platform === "darwin") {
50
+ const plist = servicePath();
51
+ ensureDir(path.dirname(plist));
52
+ fs.writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
53
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
54
+ <plist version="1.0"><dict>
55
+ <key>Label</key><string>${LABEL}</string>
56
+ <key>ProgramArguments</key><array><string>${xml(nodePath)}</string><string>${xml(entry)}</string><string>daemon</string></array>
57
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(envPath)}</string></dict>
58
+ <key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>5</integer>
59
+ <key>StandardOutPath</key><string>${xml(paths.log)}</string><key>StandardErrorPath</key><string>${xml(paths.log)}</string>
60
+ </dict></plist>\n`, { mode: 0o600 });
61
+ const domain = `gui/${process.getuid()}`;
62
+ run("launchctl", ["bootout", domain, plist]);
63
+ const started = run("launchctl", ["bootstrap", domain, plist]);
64
+ if (!started.ok) throw new Error(`could not start Harness daemon with launchd: ${started.stderr || started.error}`);
65
+ run("launchctl", ["kickstart", "-k", `${domain}/${LABEL}`]);
66
+ return { manager: "launchd", file: plist };
67
+ }
68
+ if (process.platform === "linux") {
69
+ const unit = servicePath();
70
+ ensureDir(path.dirname(unit));
71
+ fs.writeFileSync(unit, `[Unit]\nDescription=Harness local daemon\nAfter=network-online.target\n\n[Service]\nExecStart=${JSON.stringify(nodePath)} ${JSON.stringify(entry)} daemon\nEnvironment=PATH=${envPath}\nRestart=always\nRestartSec=5\nStandardOutput=append:${paths.log}\nStandardError=append:${paths.log}\n\n[Install]\nWantedBy=default.target\n`, { mode: 0o600 });
72
+ const reload = run("systemctl", ["--user", "daemon-reload"]);
73
+ const started = run("systemctl", ["--user", "enable", "--now", "tryharness-daemon.service"]);
74
+ if (!reload.ok || !started.ok) throw new Error(`could not start Harness daemon with systemd: ${started.stderr || reload.stderr}`);
75
+ return { manager: "systemd", file: unit };
76
+ }
77
+ // Windows and other Node platforms still get a ready daemon immediately. `doctor`
78
+ // reports that login persistence is unavailable rather than pretending it was set.
79
+ const child = spawn(nodePath, [entry, "daemon"], { detached: true, stdio: "ignore", env: { ...process.env, PATH: envPath } });
80
+ child.unref();
81
+ return { manager: "detached", file: null };
82
+ }
83
+
84
+ export function uninstallService() {
85
+ const file = servicePath();
86
+ if (process.platform === "darwin" && file) {
87
+ run("launchctl", ["bootout", `gui/${process.getuid()}`, file]);
88
+ try { fs.unlinkSync(file); } catch {}
89
+ } else if (process.platform === "linux" && file) {
90
+ run("systemctl", ["--user", "disable", "--now", "tryharness-daemon.service"]);
91
+ try { fs.unlinkSync(file); } catch {}
92
+ run("systemctl", ["--user", "daemon-reload"]);
93
+ }
94
+ }
95
+
96
+ export function serviceInfo() {
97
+ const file = servicePath();
98
+ return { manager: process.platform === "darwin" ? "launchd" : process.platform === "linux" ? "systemd" : "detached", installed: Boolean(file && fs.existsSync(file)), file };
99
+ }
package/lib/store.mjs ADDED
@@ -0,0 +1,160 @@
1
+ import Database from "better-sqlite3";
2
+ import { parseSession } from "./parsers.mjs";
3
+
4
+ const SCHEMA_VERSION = 2;
5
+
6
+ export function openStore(file) {
7
+ const db = new Database(file);
8
+ db.pragma("journal_mode = WAL");
9
+ db.pragma("synchronous = NORMAL");
10
+ db.pragma("busy_timeout = 5000");
11
+ db.exec(`
12
+ create table if not exists schema_info (version integer not null);
13
+ insert into schema_info(version) select ${SCHEMA_VERSION} where not exists (select 1 from schema_info);
14
+ update schema_info set version = ${SCHEMA_VERSION};
15
+ create table if not exists sessions (
16
+ agent text not null,
17
+ session_id text not null,
18
+ source text,
19
+ cwd text,
20
+ title text not null default '',
21
+ started_at text,
22
+ ended_at text,
23
+ message_count integer not null default 0,
24
+ source_mtime real not null default 0,
25
+ source_size integer not null default 0,
26
+ transcript text not null default '',
27
+ indexed_at text not null,
28
+ primary key(agent, session_id)
29
+ );
30
+ create index if not exists sessions_started_idx on sessions(started_at desc);
31
+ create index if not exists sessions_source_idx on sessions(source);
32
+ create virtual table if not exists session_fts using fts5(
33
+ agent unindexed,
34
+ session_id unindexed,
35
+ title,
36
+ cwd,
37
+ transcript,
38
+ tokenize='unicode61'
39
+ );
40
+ create table if not exists handoffs (
41
+ id text primary key,
42
+ target_agent text not null,
43
+ target_session_id text,
44
+ title text not null,
45
+ body text not null,
46
+ source text,
47
+ created_at text not null,
48
+ consumed_at text
49
+ );
50
+ `);
51
+
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)
56
+ on conflict(agent, session_id) do update set
57
+ source=excluded.source, cwd=excluded.cwd, title=excluded.title,
58
+ started_at=excluded.started_at, ended_at=excluded.ended_at,
59
+ message_count=excluded.message_count, source_mtime=excluded.source_mtime,
60
+ source_size=excluded.source_size, transcript=excluded.transcript,
61
+ indexed_at=excluded.indexed_at
62
+ `);
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);
72
+ if (sessionCount !== searchCount) {
73
+ db.exec(`delete from session_fts;
74
+ insert into session_fts(agent, session_id, title, cwd, transcript)
75
+ select agent, session_id, title, cwd, transcript from sessions;`);
76
+ }
77
+
78
+ return {
79
+ db,
80
+ needsIndex(agent, sessionId, mtime, size) {
81
+ const row = existing.get(agent, sessionId);
82
+ return !row || Number(row.source_mtime) !== Number(mtime) || Number(row.source_size) !== Number(size);
83
+ },
84
+ indexRaw({ agent, sessionId, source = null, raw, mtime = Date.now(), size = raw.length, cwd }) {
85
+ const parsed = parseSession(agent, raw);
86
+ indexParsed({
87
+ agent, sessionId, source, cwd: cwd || parsed.meta.cwd || null,
88
+ title: parsed.meta.title, startedAt: parsed.meta.startedAt,
89
+ endedAt: parsed.meta.endedAt, messageCount: parsed.meta.messageCount,
90
+ mtime, size, transcript: parsed.transcript, indexedAt: new Date().toISOString(),
91
+ });
92
+ return parsed.meta;
93
+ },
94
+ counts() {
95
+ const rows = db.prepare("select agent, count(*) as count from sessions group by agent").all();
96
+ return Object.fromEntries(rows.map((row) => [row.agent, Number(row.count)]));
97
+ },
98
+ search({ query = "", agent, days, limit = 12 }) {
99
+ const clauses = [];
100
+ const params = {};
101
+ if (agent) { clauses.push("sessions.agent = @agent"); params.agent = agent; }
102
+ if (Number.isFinite(days) && days > 0) {
103
+ clauses.push("coalesce(sessions.started_at, sessions.indexed_at) >= @since");
104
+ params.since = new Date(Date.now() - days * 86_400_000).toISOString();
105
+ }
106
+ const phrase = String(query).trim().toLowerCase();
107
+ const boundedLimit = Math.max(1, Math.min(50, Number(limit) || 12));
108
+ if (!phrase) {
109
+ return db.prepare(`select sessions.agent, session_id, title, cwd, started_at, ended_at, message_count, null as excerpt
110
+ from sessions ${clauses.length ? `where ${clauses.join(" and ")}` : ""}
111
+ order by coalesce(started_at, indexed_at) desc limit @limit`).all({ ...params, limit: boundedLimit })
112
+ .map((row) => ({ ...row, message_count: Number(row.message_count) }));
113
+ }
114
+ const terms = [...new Set(phrase.match(/[\p{L}\p{N}_-]+/gu) || [])].filter((term) => term.length > 1).slice(0, 8);
115
+ if (!terms.length) return [];
116
+ params.match = terms.map((term) => `"${term.replaceAll('"', '""')}"`).join(" OR ");
117
+ params.phrase = phrase;
118
+ params.limit = boundedLimit;
119
+ const where = ["session_fts match @match", ...clauses].join(" and ");
120
+ return db.prepare(`select sessions.agent, sessions.session_id, sessions.title, sessions.cwd,
121
+ sessions.started_at, sessions.ended_at, sessions.message_count,
122
+ snippet(session_fts, 4, '', '', ' … ', 48) as excerpt
123
+ from session_fts join sessions
124
+ on sessions.agent = session_fts.agent and sessions.session_id = session_fts.session_id
125
+ where ${where}
126
+ order by case when instr(lower(sessions.title || ' ' || session_fts.transcript), @phrase) > 0 then 0 else 1 end,
127
+ 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) }));
129
+ },
130
+ 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);
133
+ return row ? { ...row, message_count: Number(row.message_count) } : null;
134
+ },
135
+ findSource(agent, sessionHint) {
136
+ const row = db.prepare(`select source, cwd from sessions
137
+ 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;
141
+ },
142
+ 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());
145
+ },
146
+ listHandoffs(targetAgent) {
147
+ 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();
152
+ },
153
+ 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);
156
+ return row || null;
157
+ },
158
+ close() { db.close(); },
159
+ };
160
+ }
package/package.json CHANGED
@@ -1,16 +1,21 @@
1
1
  {
2
2
  "name": "tryharness",
3
- "version": "0.3.0",
4
- "description": "Connect Claude Code, Codex, opencode, and pi to Harness memory, session history, status, and delegated tasks.",
3
+ "version": "0.4.0",
4
+ "description": "One-command local daemon for Harness memory, coding-agent history, handoffs, and delegated tasks.",
5
5
  "bin": {
6
6
  "tryharness": "cli.mjs"
7
7
  },
8
+ "scripts": {
9
+ "test": "node --test test/*.test.mjs"
10
+ },
8
11
  "type": "module",
9
12
  "engines": {
10
13
  "node": ">=18"
11
14
  },
12
15
  "files": [
13
16
  "cli.mjs",
17
+ "lib",
18
+ "assets",
14
19
  "README.md"
15
20
  ],
16
21
  "keywords": [
@@ -24,6 +29,7 @@
24
29
  "homepage": "https://tryharness.ai",
25
30
  "license": "MIT",
26
31
  "dependencies": {
27
- "ws": "^8.18.0"
32
+ "better-sqlite3": "11.10.0",
33
+ "ws": "^8.18.3"
28
34
  }
29
35
  }