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/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));
package/lib/service.mjs CHANGED
@@ -12,12 +12,80 @@ function xml(value) {
12
12
  return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
13
13
  }
14
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");
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 (process.platform === "darwin") {
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 (process.platform === "linux") {
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
- // 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.
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 (process.platform === "darwin" && file) {
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 (process.platform === "linux" && file) {
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
- return { manager: process.platform === "darwin" ? "launchd" : process.platform === "linux" ? "systemd" : "detached", installed: Boolean(file && fs.existsSync(file)), file };
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
  }