tryharness 0.4.1 → 0.4.3
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 +56 -5
- package/assets/opencode-plugin.js +36 -0
- package/assets/pi-extension.ts +38 -0
- package/cli.mjs +125 -17
- package/lib/adapters.mjs +47 -2
- package/lib/daemon.mjs +197 -36
- package/lib/files.mjs +0 -14
- package/lib/mcp-stdio.mjs +51 -10
- package/lib/nudge.mjs +35 -0
- package/lib/parsers.mjs +130 -46
- package/lib/paths.mjs +1 -0
- package/lib/runner.mjs +52 -29
- package/lib/service.mjs +90 -15
- package/lib/store.mjs +184 -31
- package/package.json +2 -2
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/service.mjs
CHANGED
|
@@ -12,12 +12,80 @@ function xml(value) {
|
|
|
12
12
|
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
const WINDOWS_TASK = "tryharness-daemon";
|
|
16
|
+
|
|
17
|
+
function servicePath(platform = process.platform, homeDir = os.homedir()) {
|
|
18
|
+
if (platform === "darwin") return path.join(homeDir, "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
19
|
+
if (platform === "linux") return path.join(homeDir, ".config", "systemd", "user", "tryharness-daemon.service");
|
|
20
|
+
if (platform === "win32") return path.join(paths.home, `${WINDOWS_TASK}.xml`);
|
|
18
21
|
return null;
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
// Windows: a Task Scheduler task in the user's own context, the same job launchd and systemd
|
|
25
|
+
// do elsewhere. It runs at logon, hidden (through a script host, so no console window), with
|
|
26
|
+
// no execution time limit (the default would end the daemon after three days), restarts if
|
|
27
|
+
// the daemon dies, and never runs twice. Creating a task for oneself needs no elevation.
|
|
28
|
+
export function windowsTaskDefinition({ nodePath, entry, user, launcher }) {
|
|
29
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
30
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
31
|
+
<RegistrationInfo><Description>Harness local daemon: coding history, the task board and the bridge to Harness.</Description></RegistrationInfo>
|
|
32
|
+
<Triggers><LogonTrigger><Enabled>true</Enabled><UserId>${xml(user)}</UserId></LogonTrigger></Triggers>
|
|
33
|
+
<Principals><Principal id="Author"><UserId>${xml(user)}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
|
|
34
|
+
<Settings>
|
|
35
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
36
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
37
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
38
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
39
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
40
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
41
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
42
|
+
<Enabled>true</Enabled>
|
|
43
|
+
<Hidden>true</Hidden>
|
|
44
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
45
|
+
<RestartOnFailure><Interval>PT1M</Interval><Count>10</Count></RestartOnFailure>
|
|
46
|
+
<Priority>7</Priority>
|
|
47
|
+
</Settings>
|
|
48
|
+
<Actions Context="Author">
|
|
49
|
+
<Exec><Command>wscript.exe</Command><Arguments>"${xml(launcher)}"</Arguments><WorkingDirectory>${xml(path.win32.dirname(entry))}</WorkingDirectory></Exec>
|
|
50
|
+
</Actions>
|
|
51
|
+
</Task>
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function windowsLauncher({ nodePath, entry }) {
|
|
56
|
+
// WScript.Shell Run with window style 0 starts the daemon with no console window.
|
|
57
|
+
const quote = (value) => '""' + String(value).replaceAll('"', "") + '""';
|
|
58
|
+
return `Set shell = CreateObject("WScript.Shell")\r\nshell.Run "${quote(nodePath)} ${quote(entry)} daemon", 0, False\r\n`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function windowsUser(env = process.env) {
|
|
62
|
+
return env.USERDOMAIN && env.USERNAME ? `${env.USERDOMAIN}\\${env.USERNAME}` : env.USERNAME || os.userInfo().username;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function installWindowsTask({ runtimeRoot, nodePath = process.execPath, execute = run, env = process.env }) {
|
|
66
|
+
ensureDir(paths.home);
|
|
67
|
+
const entry = path.join(runtimeRoot, "cli.mjs");
|
|
68
|
+
const launcher = path.join(paths.home, `${WINDOWS_TASK}.vbs`);
|
|
69
|
+
const definition = path.join(paths.home, `${WINDOWS_TASK}.xml`);
|
|
70
|
+
fs.writeFileSync(launcher, windowsLauncher({ nodePath, entry }));
|
|
71
|
+
// schtasks reads task XML as UTF-16 with a byte order mark.
|
|
72
|
+
fs.writeFileSync(definition, "\ufeff" + windowsTaskDefinition({ nodePath, entry, user: windowsUser(env), launcher }), "utf16le");
|
|
73
|
+
const created = execute("schtasks", ["/Create", "/TN", WINDOWS_TASK, "/XML", definition, "/F"]);
|
|
74
|
+
if (!created.ok) throw new Error(`could not register the Harness daemon with Task Scheduler: ${created.stderr || created.stdout || created.error}`);
|
|
75
|
+
execute("schtasks", ["/End", "/TN", WINDOWS_TASK]);
|
|
76
|
+
const started = execute("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
|
|
77
|
+
if (!started.ok) throw new Error(`the Harness daemon task was registered but did not start: ${started.stderr || started.stdout || started.error}`);
|
|
78
|
+
return { manager: "task-scheduler", file: definition };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function uninstallWindowsTask({ execute = run } = {}) {
|
|
82
|
+
execute("schtasks", ["/End", "/TN", WINDOWS_TASK]);
|
|
83
|
+
execute("schtasks", ["/Delete", "/TN", WINDOWS_TASK, "/F"]);
|
|
84
|
+
for (const file of [path.join(paths.home, `${WINDOWS_TASK}.xml`), path.join(paths.home, `${WINDOWS_TASK}.vbs`)]) {
|
|
85
|
+
try { fs.unlinkSync(file); } catch {}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
21
89
|
function agentPath() {
|
|
22
90
|
const dirs = new Set([path.dirname(process.execPath), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]);
|
|
23
91
|
for (const command of ["claude", "codex", "pi", "opencode"]) {
|
|
@@ -41,12 +109,13 @@ function retireLegacyBridge() {
|
|
|
41
109
|
}
|
|
42
110
|
}
|
|
43
111
|
|
|
44
|
-
export function installService({ runtimeRoot, nodePath = process.execPath }) {
|
|
112
|
+
export function installService({ runtimeRoot, nodePath = process.execPath, platform = process.platform, execute = run }) {
|
|
45
113
|
ensureDir(paths.home);
|
|
46
114
|
retireLegacyBridge();
|
|
47
115
|
const entry = path.join(runtimeRoot, "cli.mjs");
|
|
48
116
|
const envPath = agentPath();
|
|
49
|
-
if (
|
|
117
|
+
if (platform === "win32") return installWindowsTask({ runtimeRoot, nodePath, execute });
|
|
118
|
+
if (platform === "darwin") {
|
|
50
119
|
const plist = servicePath();
|
|
51
120
|
ensureDir(path.dirname(plist));
|
|
52
121
|
fs.writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -65,7 +134,7 @@ export function installService({ runtimeRoot, nodePath = process.execPath }) {
|
|
|
65
134
|
run("launchctl", ["kickstart", "-k", `${domain}/${LABEL}`]);
|
|
66
135
|
return { manager: "launchd", file: plist };
|
|
67
136
|
}
|
|
68
|
-
if (
|
|
137
|
+
if (platform === "linux") {
|
|
69
138
|
const unit = servicePath();
|
|
70
139
|
ensureDir(path.dirname(unit));
|
|
71
140
|
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 });
|
|
@@ -74,26 +143,32 @@ export function installService({ runtimeRoot, nodePath = process.execPath }) {
|
|
|
74
143
|
if (!reload.ok || !started.ok) throw new Error(`could not start Harness daemon with systemd: ${started.stderr || reload.stderr}`);
|
|
75
144
|
return { manager: "systemd", file: unit };
|
|
76
145
|
}
|
|
77
|
-
//
|
|
78
|
-
//
|
|
146
|
+
// Other Node platforms get a ready daemon for this login only. `doctor` reports that
|
|
147
|
+
// login persistence is unavailable rather than pretending it was set.
|
|
79
148
|
const child = spawn(nodePath, [entry, "daemon"], { detached: true, stdio: "ignore", env: { ...process.env, PATH: envPath } });
|
|
80
149
|
child.unref();
|
|
81
150
|
return { manager: "detached", file: null };
|
|
82
151
|
}
|
|
83
152
|
|
|
84
|
-
export function uninstallService() {
|
|
85
|
-
const file = servicePath();
|
|
86
|
-
if (
|
|
153
|
+
export function uninstallService({ platform = process.platform, execute = run } = {}) {
|
|
154
|
+
const file = servicePath(platform);
|
|
155
|
+
if (platform === "win32") { uninstallWindowsTask({ execute }); return; }
|
|
156
|
+
if (platform === "darwin" && file) {
|
|
87
157
|
run("launchctl", ["bootout", `gui/${process.getuid()}`, file]);
|
|
88
158
|
try { fs.unlinkSync(file); } catch {}
|
|
89
|
-
} else if (
|
|
159
|
+
} else if (platform === "linux" && file) {
|
|
90
160
|
run("systemctl", ["--user", "disable", "--now", "tryharness-daemon.service"]);
|
|
91
161
|
try { fs.unlinkSync(file); } catch {}
|
|
92
162
|
run("systemctl", ["--user", "daemon-reload"]);
|
|
93
163
|
}
|
|
94
164
|
}
|
|
95
165
|
|
|
96
|
-
export function serviceInfo() {
|
|
97
|
-
const file = servicePath();
|
|
98
|
-
|
|
166
|
+
export function serviceInfo({ platform = process.platform, execute = run } = {}) {
|
|
167
|
+
const file = servicePath(platform);
|
|
168
|
+
if (platform === "win32") {
|
|
169
|
+
// Installed means the task is registered, not merely that its files exist.
|
|
170
|
+
const registered = Boolean(file && fs.existsSync(file)) && execute("schtasks", ["/Query", "/TN", WINDOWS_TASK]).ok;
|
|
171
|
+
return { manager: "task-scheduler", installed: registered, file };
|
|
172
|
+
}
|
|
173
|
+
return { manager: platform === "darwin" ? "launchd" : platform === "linux" ? "systemd" : "detached", installed: Boolean(file && fs.existsSync(file)), file };
|
|
99
174
|
}
|