tryharness 0.7.23 → 0.7.26

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.
Files changed (61) hide show
  1. package/assets/opencode-plugin.js +48 -11
  2. package/assets/pi-extension.ts +105 -26
  3. package/cli.mjs +1238 -322
  4. package/lib/activity.mjs +197 -40
  5. package/lib/adapters.mjs +337 -54
  6. package/lib/agentnode.mjs +175 -35
  7. package/lib/atlas.mjs +383 -83
  8. package/lib/cards.mjs +174 -40
  9. package/lib/claudemem.mjs +74 -18
  10. package/lib/commands.mjs +13 -5
  11. package/lib/context.mjs +409 -71
  12. package/lib/control-client.mjs +5 -2
  13. package/lib/coordinator.mjs +274 -57
  14. package/lib/daemon.mjs +5268 -1024
  15. package/lib/desktop.mjs +149 -35
  16. package/lib/digest.mjs +54 -13
  17. package/lib/environment.mjs +103 -21
  18. package/lib/files.mjs +246 -13
  19. package/lib/firewall.mjs +169 -35
  20. package/lib/folders.mjs +179 -36
  21. package/lib/frames.mjs +214 -40
  22. package/lib/inference.mjs +463 -126
  23. package/lib/learn.mjs +78 -16
  24. package/lib/live.mjs +286 -61
  25. package/lib/localmodels.mjs +424 -83
  26. package/lib/mcp-stdio.mjs +868 -151
  27. package/lib/media.mjs +138 -28
  28. package/lib/members.json +1 -1
  29. package/lib/members.mjs +35 -9
  30. package/lib/models.mjs +342 -80
  31. package/lib/network.mjs +201 -42
  32. package/lib/notify.mjs +49 -12
  33. package/lib/nudge.mjs +184 -33
  34. package/lib/ocr.mjs +223 -60
  35. package/lib/onboard.mjs +321 -70
  36. package/lib/parsers.mjs +132 -41
  37. package/lib/paths.mjs +33 -10
  38. package/lib/personal.mjs +51 -13
  39. package/lib/phones.mjs +384 -60
  40. package/lib/places.mjs +555 -107
  41. package/lib/provenance.mjs +19 -4
  42. package/lib/receipts.mjs +51 -11
  43. package/lib/redact.mjs +512 -116
  44. package/lib/runner.mjs +345 -55
  45. package/lib/runtime-install.mjs +55 -14
  46. package/lib/screens.mjs +154 -38
  47. package/lib/sealed.mjs +157 -28
  48. package/lib/secrets.mjs +18 -5
  49. package/lib/service.mjs +170 -34
  50. package/lib/snippets.mjs +94 -18
  51. package/lib/speech.mjs +688 -175
  52. package/lib/store.mjs +1654 -307
  53. package/lib/swarm.mjs +596 -117
  54. package/lib/t3.mjs +400 -79
  55. package/lib/trails.mjs +285 -61
  56. package/lib/ui.mjs +95 -23
  57. package/lib/vectors.mjs +5 -2
  58. package/lib/wakes.mjs +284 -68
  59. package/lib/watches.mjs +247 -48
  60. package/lib/worktrees.mjs +186 -38
  61. package/package.json +3 -3
package/lib/desktop.mjs CHANGED
@@ -21,9 +21,11 @@ const downloads = () => path.join(paths.home, "desktop-download");
21
21
  /** Whether version `a` is newer than `b`: x.y.z, a prerelease (0.7.13-local.ab12) before its release. */
22
22
  export function newerVersion(a, b) {
23
23
  const parse = (v) => /^(\d+)\.(\d+)\.(\d+)(-.+)?$/.exec(String(v ?? ""));
24
- const x = parse(a), y = parse(b);
24
+ const x = parse(a),
25
+ y = parse(b);
25
26
  if (!x || !y) return Boolean(x);
26
- for (let i = 1; i <= 3; i++) if (Number(x[i]) !== Number(y[i])) return Number(x[i]) > Number(y[i]);
27
+ for (let i = 1; i <= 3; i++)
28
+ if (Number(x[i]) !== Number(y[i])) return Number(x[i]) > Number(y[i]);
27
29
  return !x[4] && Boolean(y[4]);
28
30
  }
29
31
 
@@ -32,18 +34,36 @@ export function newerVersion(a, b) {
32
34
  * Harness's home. The download must match the checksum npm publishes for it. Null when there is nothing newer,
33
35
  * nothing for this machine, or npm cannot be reached: setup goes on with what it has.
34
36
  */
35
- export async function latestDesktop({ than, platform = process.platform, arch = process.arch, registry = REGISTRY }) {
37
+ export async function latestDesktop({
38
+ than,
39
+ platform = process.platform,
40
+ arch = process.arch,
41
+ registry = REGISTRY,
42
+ }) {
36
43
  try {
37
- const response = await fetch(`${registry}/${desktopPackageName(platform, arch)}/latest`);
44
+ const response = await fetch(
45
+ `${registry}/${desktopPackageName(platform, arch)}/latest`
46
+ );
38
47
  if (!response.ok) return null;
39
48
  const meta = await response.json();
40
49
  const tarball = String(meta.dist?.tarball ?? "");
41
- if (!meta.harnessDesktop || !newerVersion(meta.version, than) || !tarball.startsWith(`${registry}/`)) return null;
50
+ if (
51
+ !meta.harnessDesktop ||
52
+ !newerVersion(meta.version, than) ||
53
+ !tarball.startsWith(`${registry}/`)
54
+ )
55
+ return null;
42
56
  const download = await fetch(tarball);
43
57
  if (!download.ok) return null;
44
58
  const bytes = Buffer.from(await download.arrayBuffer());
45
- const [algorithm, digest] = String(meta.dist.integrity ?? "").split(/-(.*)/s);
46
- if (algorithm !== "sha512" || createHash("sha512").update(bytes).digest("base64") !== digest) return null;
59
+ const [algorithm, digest] = String(meta.dist.integrity ?? "").split(
60
+ /-(.*)/s
61
+ );
62
+ if (
63
+ algorithm !== "sha512" ||
64
+ createHash("sha512").update(bytes).digest("base64") !== digest
65
+ )
66
+ return null;
47
67
  fs.rmSync(downloads(), { recursive: true, force: true });
48
68
  ensureDir(downloads());
49
69
  const file = path.join(downloads(), "package.tgz");
@@ -61,67 +81,135 @@ export async function latestDesktop({ than, platform = process.platform, arch =
61
81
  * over a newer one (a local build stays). Where it is and whether it changed, or null when this machine has none.
62
82
  * HARNESS_DESKTOP_APP installs that app as it is.
63
83
  */
64
- export async function setupDesktop({ sourceRoot, env = process.env, platform = process.platform, arch = process.arch, registry = REGISTRY, execute = run }) {
84
+ export async function setupDesktop({
85
+ sourceRoot,
86
+ env = process.env,
87
+ platform = process.platform,
88
+ arch = process.arch,
89
+ registry = REGISTRY,
90
+ execute = run,
91
+ }) {
65
92
  const bundled = desktopSource({ sourceRoot, env, platform, arch });
66
- const install = (source) => installDesktop({ source, target: desktopTarget({ source, env, platform }), platform, execute });
93
+ const install = (source) =>
94
+ installDesktop({
95
+ source,
96
+ target: desktopTarget({ source, env, platform }),
97
+ platform,
98
+ execute,
99
+ });
67
100
  if (env.HARNESS_DESKTOP_APP) return bundled && install(bundled);
68
101
  const installed = readJson(marker(), {});
69
- const have = installed.path && fs.existsSync(installed.path) ? installed : null;
70
- const than = newerVersion(have?.version, bundled?.version) ? have.version : bundled?.version ?? have?.version;
102
+ const have =
103
+ installed.path && fs.existsSync(installed.path) ? installed : null;
104
+ const than = newerVersion(have?.version, bundled?.version)
105
+ ? have.version
106
+ : (bundled?.version ?? have?.version);
71
107
  const latest = await latestDesktop({ than, platform, arch, registry });
72
108
  const source = latest ?? bundled;
73
109
  try {
74
- if (have && (!source || newerVersion(have.version, source.version))) return { target: have.path, updated: false };
110
+ if (have && (!source || newerVersion(have.version, source.version)))
111
+ return { target: have.path, updated: false };
75
112
  return source && install(source);
76
113
  } finally {
77
114
  if (latest) fs.rmSync(downloads(), { recursive: true, force: true });
78
115
  }
79
116
  }
80
117
 
81
- export function desktopPackageName(platform = process.platform, arch = process.arch) {
118
+ export function desktopPackageName(
119
+ platform = process.platform,
120
+ arch = process.arch
121
+ ) {
82
122
  return `tryharness-desktop-${platform}-${arch}`;
83
123
  }
84
124
 
85
125
  /** The app inside this machine's platform package, or null when there is none for it. */
86
- export function desktopSource({ sourceRoot, env = process.env, platform = process.platform, arch = process.arch }) {
87
- if (env.HARNESS_DESKTOP_APP) return { app: path.resolve(env.HARNESS_DESKTOP_APP), version: env.HARNESS_DESKTOP_VERSION || "dev" };
126
+ export function desktopSource({
127
+ sourceRoot,
128
+ env = process.env,
129
+ platform = process.platform,
130
+ arch = process.arch,
131
+ }) {
132
+ if (env.HARNESS_DESKTOP_APP)
133
+ return {
134
+ app: path.resolve(env.HARNESS_DESKTOP_APP),
135
+ version: env.HARNESS_DESKTOP_VERSION || "dev",
136
+ };
88
137
  let packageJson;
89
- try { packageJson = createRequire(path.join(sourceRoot, "package.json")).resolve(`${desktopPackageName(platform, arch)}/package.json`); }
90
- catch { return null; }
138
+ try {
139
+ packageJson = createRequire(path.join(sourceRoot, "package.json")).resolve(
140
+ `${desktopPackageName(platform, arch)}/package.json`
141
+ );
142
+ } catch {
143
+ return null;
144
+ }
91
145
  const meta = readJson(packageJson, {});
92
- const app = path.join(path.dirname(packageJson), String(meta.harnessDesktop || ""));
93
- return meta.harnessDesktop && fs.existsSync(app) ? { app, version: String(meta.version) } : null;
146
+ const app = path.join(
147
+ path.dirname(packageJson),
148
+ String(meta.harnessDesktop || "")
149
+ );
150
+ return meta.harnessDesktop && fs.existsSync(app)
151
+ ? { app, version: String(meta.version) }
152
+ : null;
94
153
  }
95
154
 
96
155
  /** Where the app is kept. On a Mac that is ~/Applications, so Spotlight and Launchpad find it. */
97
- export function desktopTarget({ source, env = process.env, platform = process.platform, homeDir = os.homedir() }) {
156
+ export function desktopTarget({
157
+ source,
158
+ env = process.env,
159
+ platform = process.platform,
160
+ homeDir = os.homedir(),
161
+ }) {
98
162
  const file = path.basename(source.app);
99
- if (platform === "darwin" && !env.HARNESS_HOME) return path.join(homeDir, "Applications", file);
163
+ if (platform === "darwin" && !env.HARNESS_HOME)
164
+ return path.join(homeDir, "Applications", file);
100
165
  return path.join(paths.home, "desktop", file);
101
166
  }
102
167
 
103
168
  function executables(target, platform) {
104
169
  if (platform === "darwin") {
105
170
  const dir = path.join(target, "Contents", "MacOS");
106
- return fs.existsSync(dir) ? fs.readdirSync(dir).map((name) => path.join(dir, name)) : [];
171
+ return fs.existsSync(dir)
172
+ ? fs.readdirSync(dir).map((name) => path.join(dir, name))
173
+ : [];
107
174
  }
108
175
  return [target];
109
176
  }
110
177
 
111
- export function quitDesktop({ target, platform = process.platform, execute = run }) {
112
- if (platform === "win32") { execute("taskkill", ["/F", "/IM", path.basename(target)]); return; }
178
+ export function quitDesktop({
179
+ target,
180
+ platform = process.platform,
181
+ execute = run,
182
+ }) {
183
+ if (platform === "win32") {
184
+ execute("taskkill", ["/F", "/IM", path.basename(target)]);
185
+ return;
186
+ }
113
187
  const exes = executables(target, platform);
114
188
  for (const exe of exes) execute("pkill", ["-f", exe]);
115
189
  // Opening it again before the old one has gone fails on macOS (LaunchServices error -600).
116
- for (let i = 0; i < 50 && exes.some((exe) => execute("pgrep", ["-f", exe]).ok); i++) {
190
+ for (
191
+ let i = 0;
192
+ i < 50 && exes.some((exe) => execute("pgrep", ["-f", exe]).ok);
193
+ i++
194
+ ) {
117
195
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
118
196
  }
119
197
  }
120
198
 
121
199
  /** Copies the app into place unless this version is already there. Returns where it is. */
122
- export function installDesktop({ source, target, platform = process.platform, execute = run }) {
200
+ export function installDesktop({
201
+ source,
202
+ target,
203
+ platform = process.platform,
204
+ execute = run,
205
+ }) {
123
206
  const installed = readJson(marker(), {});
124
- if (installed.version === source.version && installed.path === target && fs.existsSync(target)) return { target, updated: false };
207
+ if (
208
+ installed.version === source.version &&
209
+ installed.path === target &&
210
+ fs.existsSync(target)
211
+ )
212
+ return { target, updated: false };
125
213
  // Replacing a running app's files under it is how it crashes; it is quit first and reopened after.
126
214
  if (fs.existsSync(target)) quitDesktop({ target, platform, execute });
127
215
  ensureDir(path.dirname(target));
@@ -137,12 +225,19 @@ export function installDesktop({ source, target, platform = process.platform, ex
137
225
  }
138
226
 
139
227
  /** Whether this login can show a window at all: not a server reached over SSH. */
140
- export function canShowDesktop({ env = process.env, platform = process.platform }) {
228
+ export function canShowDesktop({
229
+ env = process.env,
230
+ platform = process.platform,
231
+ }) {
141
232
  if (platform === "linux") return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
142
233
  return platform === "darwin" || platform === "win32";
143
234
  }
144
235
 
145
- export function openDesktop({ target, platform = process.platform, execute = run }) {
236
+ export function openDesktop({
237
+ target,
238
+ platform = process.platform,
239
+ execute = run,
240
+ }) {
146
241
  if (platform === "darwin") {
147
242
  // Just after the old copy quits, LaunchServices can still refuse (error -600) for a moment.
148
243
  let opened = execute("open", [target]);
@@ -150,7 +245,8 @@ export function openDesktop({ target, platform = process.platform, execute = run
150
245
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250);
151
246
  opened = execute("open", [target]);
152
247
  }
153
- if (!opened.ok) throw new Error(opened.stderr.trim() || opened.error || "open failed");
248
+ if (!opened.ok)
249
+ throw new Error(opened.stderr.trim() || opened.error || "open failed");
154
250
  return;
155
251
  }
156
252
  const child = spawn(target, [], { detached: true, stdio: "ignore" });
@@ -159,11 +255,29 @@ export function openDesktop({ target, platform = process.platform, execute = run
159
255
  }
160
256
 
161
257
  /** Disconnecting stops the app and takes it out of login items; the app itself stays. */
162
- export function retireDesktop({ platform = process.platform, execute = run, homeDir = os.homedir() }) {
258
+ export function retireDesktop({
259
+ platform = process.platform,
260
+ execute = run,
261
+ homeDir = os.homedir(),
262
+ }) {
163
263
  const installed = readJson(marker(), {});
164
- if (installed.path) quitDesktop({ target: installed.path, platform, execute });
264
+ if (installed.path)
265
+ quitDesktop({ target: installed.path, platform, execute });
165
266
  // Where the app's own login item lives (tauri-plugin-autostart, named after the product).
166
- if (platform === "darwin") fs.rmSync(path.join(homeDir, "Library", "LaunchAgents", "Harness.plist"), { force: true });
167
- else if (platform === "linux") fs.rmSync(path.join(homeDir, ".config", "autostart", "Harness.desktop"), { force: true });
168
- else if (platform === "win32") execute("reg", ["delete", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "/v", "Harness", "/f"]);
267
+ if (platform === "darwin")
268
+ fs.rmSync(path.join(homeDir, "Library", "LaunchAgents", "Harness.plist"), {
269
+ force: true,
270
+ });
271
+ else if (platform === "linux")
272
+ fs.rmSync(path.join(homeDir, ".config", "autostart", "Harness.desktop"), {
273
+ force: true,
274
+ });
275
+ else if (platform === "win32")
276
+ execute("reg", [
277
+ "delete",
278
+ "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run",
279
+ "/v",
280
+ "Harness",
281
+ "/f",
282
+ ]);
169
283
  }
package/lib/digest.mjs CHANGED
@@ -6,35 +6,76 @@ import { nameOf } from "./members.mjs";
6
6
  // the overnight learner kept. The buddy shows it when the person comes back.
7
7
 
8
8
  const MAX_THREADS = 6;
9
- const clip = (text, length) => { const flat = String(text || "").replace(/\s+/g, " ").trim(); return flat.length > length ? `${flat.slice(0, length - 1)}…` : flat; };
9
+ const clip = (text, length) => {
10
+ const flat = String(text || "")
11
+ .replace(/\s+/g, " ")
12
+ .trim();
13
+ return flat.length > length ? `${flat.slice(0, length - 1)}…` : flat;
14
+ };
10
15
 
11
16
  /** { since, lines, threads, empty }: lines are plain sentences, threads their ids for opening them. */
12
- export function digestSince(store, since, { alerts = [], learning = null, receiptOf = () => null } = {}) {
17
+ export function digestSince(
18
+ store,
19
+ since,
20
+ { alerts = [], learning = null, receiptOf = () => null } = {}
21
+ ) {
13
22
  const lines = [];
14
23
  const threads = [];
15
24
  for (const thread of store.goals({ all: true })) {
16
25
  if (thread.updated_at <= since) continue;
17
- const posts = store.roomPosts(thread.id, 0, 500).filter((post) => post.created_at > since);
18
- const results = posts.filter((post) => post.author !== "user" && post.author !== thread.goal.facilitator && (post.kind === "post" || post.kind === "result"));
26
+ const posts = store
27
+ .roomPosts(thread.id, 0, 500)
28
+ .filter((post) => post.created_at > since);
29
+ const results = posts.filter(
30
+ (post) =>
31
+ post.author !== "user" &&
32
+ post.author !== thread.goal.facilitator &&
33
+ (post.kind === "post" || post.kind === "result")
34
+ );
19
35
  const questions = posts.filter((post) => post.kind === "question");
20
36
  const held = store.heldCount(thread.id, since);
21
- const runs = store.threadRuns(thread.id).filter((run) => run.reported_at && run.reported_at > since);
37
+ const runs = store
38
+ .threadRuns(thread.id)
39
+ .filter((run) => run.reported_at && run.reported_at > since);
22
40
  if (!results.length && !questions.length && !runs.length) continue;
23
41
  const parts = [];
24
- const finished = runs.filter((run) => run.status === "done"), failed = runs.filter((run) => run.status === "failed");
25
- if (finished.length) parts.push(`${[...new Set(finished.map((run) => nameOf(run.target_agent)))].join(" and ")} finished ${finished.length === 1 ? "a turn" : `${finished.length} turns`}`);
42
+ const finished = runs.filter((run) => run.status === "done"),
43
+ failed = runs.filter((run) => run.status === "failed");
44
+ if (finished.length)
45
+ parts.push(
46
+ `${[...new Set(finished.map((run) => nameOf(run.target_agent)))].join(" and ")} finished ${finished.length === 1 ? "a turn" : `${finished.length} turns`}`
47
+ );
26
48
  if (failed.length) parts.push(`${failed.length} failed`);
27
- const checks = runs.map((run) => ({ agent: run.target_agent, ...receiptOf(run.id) })).filter((check) => check.verdict && check.verdict !== "no changes");
49
+ const checks = runs
50
+ .map((run) => ({ agent: run.target_agent, ...receiptOf(run.id) }))
51
+ .filter((check) => check.verdict && check.verdict !== "no changes");
28
52
  const untested = checks.filter((check) => check.verdict !== "tested");
29
- if (checks.length) parts.push(untested.length ? `${untested.map((check) => `${nameOf(check.agent)}: ${check.verdict}`).join("; ")}` : "its changes were tested");
30
- if (questions.length) parts.push(`a question waits for you: "${clip(questions.at(-1).text, 90)}"`);
31
- if (held) parts.push(`${held} post${held === 1 ? "" : "s"} held because ${held === 1 ? "it reads" : "they read"} like orders to your agents`);
32
- if (!finished.length && !failed.length && results.length) parts.unshift(`${[...new Set(results.map((post) => nameOf(post.author)))].join(" and ")} answered`);
53
+ if (checks.length)
54
+ parts.push(
55
+ untested.length
56
+ ? `${untested.map((check) => `${nameOf(check.agent)}: ${check.verdict}`).join("; ")}`
57
+ : "its changes were tested"
58
+ );
59
+ if (questions.length)
60
+ parts.push(
61
+ `a question waits for you: "${clip(questions.at(-1).text, 90)}"`
62
+ );
63
+ if (held)
64
+ parts.push(
65
+ `${held} post${held === 1 ? "" : "s"} held because ${held === 1 ? "it reads" : "they read"} like orders to your agents`
66
+ );
67
+ if (!finished.length && !failed.length && results.length)
68
+ parts.unshift(
69
+ `${[...new Set(results.map((post) => nameOf(post.author)))].join(" and ")} answered`
70
+ );
33
71
  lines.push(`"${clip(thread.objective, 60)}": ${parts.join("; ")}.`);
34
72
  threads.push(thread.id);
35
73
  if (threads.length >= MAX_THREADS) break;
36
74
  }
37
75
  for (const alert of alerts) lines.push(`Watch: ${alert.text}`);
38
- if (learning?.kept && learning.at > since) lines.push(`Learned from ${learning.runs} runs: briefs now hold the files agents went on to change ${Math.round(learning.recallAt10.candidate * 100)}% of the time (was ${Math.round(Math.max(learning.recallAt10.default, learning.recallAt10.current ?? 0) * 100)}%).`);
76
+ if (learning?.kept && learning.at > since)
77
+ lines.push(
78
+ `Learned from ${learning.runs} runs: briefs now hold the files agents went on to change ${Math.round(learning.recallAt10.candidate * 100)}% of the time (was ${Math.round(Math.max(learning.recallAt10.default, learning.recallAt10.current ?? 0) * 100)}%).`
79
+ );
39
80
  return { since, lines, threads, empty: !lines.length };
40
81
  }
@@ -10,35 +10,67 @@ import { spawnSync } from "node:child_process";
10
10
 
11
11
  const PERSON_TTL_MS = 15_000;
12
12
  const REPO_TTL_MS = 10_000;
13
- const CALL_APPS = /^(?:zoom\.us|FaceTime|Microsoft Teams|Webex|Slack Huddle|Discord|Around|Google Meet|Tuple|Pop)$/i;
13
+ const CALL_APPS =
14
+ /^(?:zoom\.us|FaceTime|Microsoft Teams|Webex|Slack Huddle|Discord|Around|Google Meet|Tuple|Pop)$/i;
14
15
 
15
16
  const run = (command, args, options = {}) => {
16
17
  try {
17
- const out = spawnSync(command, args, { encoding: "utf8", timeout: 2_000, ...options });
18
+ const out = spawnSync(command, args, {
19
+ encoding: "utf8",
20
+ timeout: 2_000,
21
+ ...options,
22
+ });
18
23
  return out.status === 0 ? out.stdout : null;
19
- } catch { return null; }
24
+ } catch {
25
+ return null;
26
+ }
20
27
  };
21
28
 
22
29
  /** The front app's name and seconds since the last input, or nulls where the OS can't say without a permission. */
23
- export function readPerson({ platform = process.platform, exec = run, file = process.env.HARNESS_PRESENCE_FILE } = {}) {
30
+ export function readPerson({
31
+ platform = process.platform,
32
+ exec = run,
33
+ file = process.env.HARNESS_PRESENCE_FILE,
34
+ } = {}) {
24
35
  // Tests stand in for the OS: a JSON file { app, idleSeconds }, read instead of it (tests/e2e/desktop-local.mts).
25
36
  if (file) {
26
37
  try {
27
38
  const read = JSON.parse(fs.readFileSync(file, "utf8"));
28
- return { app: typeof read.app === "string" ? read.app : null, idleSeconds: Number.isFinite(read.idleSeconds) ? read.idleSeconds : null };
29
- } catch { return { app: null, idleSeconds: null }; }
39
+ return {
40
+ app: typeof read.app === "string" ? read.app : null,
41
+ idleSeconds: Number.isFinite(read.idleSeconds)
42
+ ? read.idleSeconds
43
+ : null,
44
+ };
45
+ } catch {
46
+ return { app: null, idleSeconds: null };
47
+ }
30
48
  }
31
49
  if (platform === "darwin") {
32
50
  const asn = exec("lsappinfo", ["front"])?.trim();
33
- const app = asn ? exec("lsappinfo", ["info", "-only", "name", asn])?.match(/"LSDisplayName"\s*=\s*"([^"]*)"/)?.[1] ?? null : null;
34
- const idle = exec("ioreg", ["-c", "IOHIDSystem", "-d", "4", "-r", "-k", "HIDIdleTime"])?.match(/"HIDIdleTime"\s*=\s*(\d+)/)?.[1];
51
+ const app = asn
52
+ ? (exec("lsappinfo", ["info", "-only", "name", asn])?.match(
53
+ /"LSDisplayName"\s*=\s*"([^"]*)"/
54
+ )?.[1] ?? null)
55
+ : null;
56
+ const idle = exec("ioreg", [
57
+ "-c",
58
+ "IOHIDSystem",
59
+ "-d",
60
+ "4",
61
+ "-r",
62
+ "-k",
63
+ "HIDIdleTime",
64
+ ])?.match(/"HIDIdleTime"\s*=\s*(\d+)/)?.[1];
35
65
  return { app, idleSeconds: idle ? Math.round(Number(idle) / 1e9) : null };
36
66
  }
37
67
  if (platform === "linux" && process.env.DISPLAY) {
38
68
  const idle = exec("xprintidle", []);
39
69
  const pid = exec("xdotool", ["getactivewindow", "getwindowpid"])?.trim();
40
70
  let app = null;
41
- try { if (pid) app = fs.readFileSync(`/proc/${pid}/comm`, "utf8").trim(); } catch {}
71
+ try {
72
+ if (pid) app = fs.readFileSync(`/proc/${pid}/comm`, "utf8").trim();
73
+ } catch {}
42
74
  return { app, idleSeconds: idle ? Math.round(Number(idle) / 1000) : null };
43
75
  }
44
76
  return { app: null, idleSeconds: null };
@@ -46,10 +78,18 @@ export function readPerson({ platform = process.platform, exec = run, file = pro
46
78
 
47
79
  /** A repository's branch and the files changed in it and not committed, newest change first. */
48
80
  export function readRepository(folder, { exec = run } = {}) {
49
- const top = exec("git", ["rev-parse", "--show-toplevel"], { cwd: folder })?.trim();
81
+ const top = exec("git", ["rev-parse", "--show-toplevel"], {
82
+ cwd: folder,
83
+ })?.trim();
50
84
  if (!top) return null;
51
- const branch = exec("git", ["branch", "--show-current"], { cwd: top })?.trim() || null;
52
- const status = exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=normal"], { cwd: top }) ?? "";
85
+ const branch =
86
+ exec("git", ["branch", "--show-current"], { cwd: top })?.trim() || null;
87
+ const status =
88
+ exec(
89
+ "git",
90
+ ["status", "--porcelain=v1", "-z", "--untracked-files=normal"],
91
+ { cwd: top }
92
+ ) ?? "";
53
93
  const changed = [];
54
94
  const entries = status.split("\0");
55
95
  for (let i = 0; i < entries.length; i += 1) {
@@ -59,30 +99,69 @@ export function readRepository(folder, { exec = run } = {}) {
59
99
  if (entry[0] === "R" || entry[0] === "C") i += 1;
60
100
  const rel = entry.slice(3);
61
101
  let mtime = null;
62
- try { mtime = fs.statSync(path.join(top, rel)).mtimeMs; } catch {}
63
- changed.push({ path: rel, state: entry.slice(0, 2).trim() === "??" ? "new" : entry[1] === "D" || entry[0] === "D" ? "deleted" : "changed", at: mtime });
102
+ try {
103
+ mtime = fs.statSync(path.join(top, rel)).mtimeMs;
104
+ } catch {}
105
+ changed.push({
106
+ path: rel,
107
+ state:
108
+ entry.slice(0, 2).trim() === "??"
109
+ ? "new"
110
+ : entry[1] === "D" || entry[0] === "D"
111
+ ? "deleted"
112
+ : "changed",
113
+ at: mtime,
114
+ });
64
115
  }
65
116
  changed.sort((a, b) => (b.at ?? 0) - (a.at ?? 0));
66
- const last = exec("git", ["log", "-1", "--format=%ct%x00%s"], { cwd: top })?.split("\0");
67
- return { root: top, branch, changed, lastCommit: last?.[0] ? { at: Number(last[0]) * 1000, subject: String(last[1] || "").trim().slice(0, 200) } : null };
117
+ const last = exec("git", ["log", "-1", "--format=%ct%x00%s"], {
118
+ cwd: top,
119
+ })?.split("\0");
120
+ return {
121
+ root: top,
122
+ branch,
123
+ changed,
124
+ lastCommit: last?.[0]
125
+ ? {
126
+ at: Number(last[0]) * 1000,
127
+ subject: String(last[1] || "")
128
+ .trim()
129
+ .slice(0, 200),
130
+ }
131
+ : null,
132
+ };
68
133
  }
69
134
 
70
- export function createEnvironment({ platform = process.platform, exec = run } = {}) {
135
+ export function createEnvironment({
136
+ platform = process.platform,
137
+ exec = run,
138
+ } = {}) {
71
139
  let person = null;
72
140
  const repos = new Map();
73
141
  return {
74
142
  /** { app, idleSeconds, inCall, at }: never sent off this computer. */
75
143
  person() {
76
- if (!person || Date.now() - person.at > (process.env.HARNESS_PRESENCE_FILE ? 0 : PERSON_TTL_MS)) {
144
+ if (
145
+ !person ||
146
+ Date.now() - person.at >
147
+ (process.env.HARNESS_PRESENCE_FILE ? 0 : PERSON_TTL_MS)
148
+ ) {
77
149
  const read = readPerson({ platform, exec });
78
- person = { ...read, inCall: Boolean(read.app && CALL_APPS.test(read.app)), at: Date.now() };
150
+ person = {
151
+ ...read,
152
+ inCall: Boolean(read.app && CALL_APPS.test(read.app)),
153
+ at: Date.now(),
154
+ };
79
155
  }
80
156
  return person;
81
157
  },
82
158
  repository(folder) {
83
159
  const cached = repos.get(folder);
84
160
  if (cached && Date.now() - cached.at < REPO_TTL_MS) return cached.value;
85
- const value = folder && fs.existsSync(folder) ? readRepository(folder, { exec }) : null;
161
+ const value =
162
+ folder && fs.existsSync(folder)
163
+ ? readRepository(folder, { exec })
164
+ : null;
86
165
  repos.set(folder, { at: Date.now(), value });
87
166
  if (repos.size > 200) repos.delete(repos.keys().next().value);
88
167
  return value;
@@ -91,7 +170,10 @@ export function createEnvironment({ platform = process.platform, exec = run } =
91
170
  lastChanged(folder, now = Date.now()) {
92
171
  const repo = this.repository(folder);
93
172
  if (!repo) return null;
94
- const newest = Math.max(repo.changed[0]?.at ?? 0, repo.lastCommit?.at ?? 0);
173
+ const newest = Math.max(
174
+ repo.changed[0]?.at ?? 0,
175
+ repo.lastCommit?.at ?? 0
176
+ );
95
177
  return newest ? Math.max(0, Math.round((now - newest) / 60_000)) : null;
96
178
  },
97
179
  };