tryharness 0.7.22 → 0.7.25
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 +48 -8
- package/assets/opencode-plugin.js +48 -10
- package/assets/pi-extension.ts +106 -26
- package/cli.mjs +1281 -289
- package/lib/activity.mjs +197 -40
- package/lib/adapters.mjs +337 -54
- package/lib/agentnode.mjs +175 -35
- package/lib/atlas.mjs +604 -0
- package/lib/bip39-english.txt +2048 -0
- package/lib/cards.mjs +177 -39
- package/lib/claudemem.mjs +74 -18
- package/lib/commands.mjs +13 -5
- package/lib/context.mjs +628 -0
- package/lib/control-client.mjs +5 -2
- package/lib/coordinator.mjs +281 -56
- package/lib/daemon.mjs +5590 -838
- package/lib/desktop.mjs +149 -35
- package/lib/digest.mjs +81 -0
- package/lib/environment.mjs +180 -0
- package/lib/files.mjs +246 -13
- package/lib/firewall.mjs +288 -0
- package/lib/folders.mjs +223 -57
- package/lib/frames.mjs +333 -0
- package/lib/inference.mjs +663 -0
- package/lib/learn.mjs +182 -0
- package/lib/live.mjs +286 -61
- package/lib/localmodels.mjs +571 -0
- package/lib/mcp-stdio.mjs +912 -103
- package/lib/media.mjs +228 -0
- package/lib/members.json +1 -1
- package/lib/members.mjs +35 -9
- package/lib/models.mjs +342 -80
- package/lib/network.mjs +201 -42
- package/lib/notify.mjs +49 -12
- package/lib/nudge.mjs +190 -32
- package/lib/ocr.mjs +380 -0
- package/lib/onboard.mjs +321 -70
- package/lib/parsers.mjs +132 -41
- package/lib/paths.mjs +33 -10
- package/lib/personal.mjs +77 -0
- package/lib/phones.mjs +398 -51
- package/lib/places.mjs +566 -102
- package/lib/provenance.mjs +19 -4
- package/lib/receipts.mjs +82 -0
- package/lib/redact.mjs +730 -0
- package/lib/runner.mjs +347 -55
- package/lib/runtime-install.mjs +59 -17
- package/lib/screens.mjs +212 -0
- package/lib/sealed.mjs +160 -28
- package/lib/secrets.mjs +18 -5
- package/lib/service.mjs +173 -35
- package/lib/snippets.mjs +140 -0
- package/lib/speech.mjs +993 -0
- package/lib/store.mjs +1678 -301
- package/lib/swarm.mjs +598 -117
- package/lib/t3.mjs +400 -79
- package/lib/trails.mjs +401 -0
- package/lib/ui.mjs +95 -23
- package/lib/vectors.mjs +5 -2
- package/lib/wakes.mjs +284 -68
- package/lib/watches.mjs +362 -0
- package/lib/worktrees.mjs +186 -38
- package/package.json +6 -4
- package/lib/embedder.mjs +0 -156
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),
|
|
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++)
|
|
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({
|
|
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(
|
|
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 (
|
|
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(
|
|
46
|
-
|
|
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({
|
|
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) =>
|
|
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 =
|
|
70
|
-
|
|
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)))
|
|
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(
|
|
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({
|
|
87
|
-
|
|
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 {
|
|
90
|
-
|
|
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(
|
|
93
|
-
|
|
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({
|
|
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)
|
|
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)
|
|
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({
|
|
112
|
-
|
|
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 (
|
|
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({
|
|
200
|
+
export function installDesktop({
|
|
201
|
+
source,
|
|
202
|
+
target,
|
|
203
|
+
platform = process.platform,
|
|
204
|
+
execute = run,
|
|
205
|
+
}) {
|
|
123
206
|
const installed = readJson(marker(), {});
|
|
124
|
-
if (
|
|
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({
|
|
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({
|
|
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)
|
|
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({
|
|
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)
|
|
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")
|
|
167
|
-
|
|
168
|
-
|
|
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
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { nameOf } from "./members.mjs";
|
|
2
|
+
|
|
3
|
+
// "While you were away": what happened on the board since a moment, built by code from the board's own records (no
|
|
4
|
+
// model writes it, so every name, number and verdict in it is one the board holds). Threads with finished work and what
|
|
5
|
+
// each run's own session shows, questions waiting for the person, posts the guard held, watches that went off, and what
|
|
6
|
+
// the overnight learner kept. The buddy shows it when the person comes back.
|
|
7
|
+
|
|
8
|
+
const MAX_THREADS = 6;
|
|
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
|
+
};
|
|
15
|
+
|
|
16
|
+
/** { since, lines, threads, empty }: lines are plain sentences, threads their ids for opening them. */
|
|
17
|
+
export function digestSince(
|
|
18
|
+
store,
|
|
19
|
+
since,
|
|
20
|
+
{ alerts = [], learning = null, receiptOf = () => null } = {}
|
|
21
|
+
) {
|
|
22
|
+
const lines = [];
|
|
23
|
+
const threads = [];
|
|
24
|
+
for (const thread of store.goals({ all: true })) {
|
|
25
|
+
if (thread.updated_at <= since) continue;
|
|
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
|
+
);
|
|
35
|
+
const questions = posts.filter((post) => post.kind === "question");
|
|
36
|
+
const held = store.heldCount(thread.id, since);
|
|
37
|
+
const runs = store
|
|
38
|
+
.threadRuns(thread.id)
|
|
39
|
+
.filter((run) => run.reported_at && run.reported_at > since);
|
|
40
|
+
if (!results.length && !questions.length && !runs.length) continue;
|
|
41
|
+
const parts = [];
|
|
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
|
+
);
|
|
48
|
+
if (failed.length) parts.push(`${failed.length} failed`);
|
|
49
|
+
const checks = runs
|
|
50
|
+
.map((run) => ({ agent: run.target_agent, ...receiptOf(run.id) }))
|
|
51
|
+
.filter((check) => check.verdict && check.verdict !== "no changes");
|
|
52
|
+
const untested = checks.filter((check) => check.verdict !== "tested");
|
|
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
|
+
);
|
|
71
|
+
lines.push(`"${clip(thread.objective, 60)}": ${parts.join("; ")}.`);
|
|
72
|
+
threads.push(thread.id);
|
|
73
|
+
if (threads.length >= MAX_THREADS) break;
|
|
74
|
+
}
|
|
75
|
+
for (const alert of alerts) lines.push(`Watch: ${alert.text}`);
|
|
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
|
+
);
|
|
80
|
+
return { since, lines, threads, empty: !lines.length };
|
|
81
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
// What the relay can see of the person's computer without asking for any permission: which app is in front and how
|
|
6
|
+
// long since they last touched the keyboard or mouse (macOS: lsappinfo and the HID idle timer; Linux under X11 when
|
|
7
|
+
// xprintidle and xdotool are installed; nothing elsewhere), and the state of each repository the swarm works in (its
|
|
8
|
+
// branch, files changed and not committed, and how long ago). The front app and idle time stay on this computer: they
|
|
9
|
+
// time the buddy's digest and keep it quiet while the person is in a call. Window titles are never read.
|
|
10
|
+
|
|
11
|
+
const PERSON_TTL_MS = 15_000;
|
|
12
|
+
const REPO_TTL_MS = 10_000;
|
|
13
|
+
const CALL_APPS =
|
|
14
|
+
/^(?:zoom\.us|FaceTime|Microsoft Teams|Webex|Slack Huddle|Discord|Around|Google Meet|Tuple|Pop)$/i;
|
|
15
|
+
|
|
16
|
+
const run = (command, args, options = {}) => {
|
|
17
|
+
try {
|
|
18
|
+
const out = spawnSync(command, args, {
|
|
19
|
+
encoding: "utf8",
|
|
20
|
+
timeout: 2_000,
|
|
21
|
+
...options,
|
|
22
|
+
});
|
|
23
|
+
return out.status === 0 ? out.stdout : null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The front app's name and seconds since the last input, or nulls where the OS can't say without a permission. */
|
|
30
|
+
export function readPerson({
|
|
31
|
+
platform = process.platform,
|
|
32
|
+
exec = run,
|
|
33
|
+
file = process.env.HARNESS_PRESENCE_FILE,
|
|
34
|
+
} = {}) {
|
|
35
|
+
// Tests stand in for the OS: a JSON file { app, idleSeconds }, read instead of it (tests/e2e/desktop-local.mts).
|
|
36
|
+
if (file) {
|
|
37
|
+
try {
|
|
38
|
+
const read = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
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
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (platform === "darwin") {
|
|
50
|
+
const asn = exec("lsappinfo", ["front"])?.trim();
|
|
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];
|
|
65
|
+
return { app, idleSeconds: idle ? Math.round(Number(idle) / 1e9) : null };
|
|
66
|
+
}
|
|
67
|
+
if (platform === "linux" && process.env.DISPLAY) {
|
|
68
|
+
const idle = exec("xprintidle", []);
|
|
69
|
+
const pid = exec("xdotool", ["getactivewindow", "getwindowpid"])?.trim();
|
|
70
|
+
let app = null;
|
|
71
|
+
try {
|
|
72
|
+
if (pid) app = fs.readFileSync(`/proc/${pid}/comm`, "utf8").trim();
|
|
73
|
+
} catch {}
|
|
74
|
+
return { app, idleSeconds: idle ? Math.round(Number(idle) / 1000) : null };
|
|
75
|
+
}
|
|
76
|
+
return { app: null, idleSeconds: null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A repository's branch and the files changed in it and not committed, newest change first. */
|
|
80
|
+
export function readRepository(folder, { exec = run } = {}) {
|
|
81
|
+
const top = exec("git", ["rev-parse", "--show-toplevel"], {
|
|
82
|
+
cwd: folder,
|
|
83
|
+
})?.trim();
|
|
84
|
+
if (!top) return null;
|
|
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
|
+
) ?? "";
|
|
93
|
+
const changed = [];
|
|
94
|
+
const entries = status.split("\0");
|
|
95
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
96
|
+
const entry = entries[i];
|
|
97
|
+
if (entry.length < 4) continue;
|
|
98
|
+
// A rename's old path follows it.
|
|
99
|
+
if (entry[0] === "R" || entry[0] === "C") i += 1;
|
|
100
|
+
const rel = entry.slice(3);
|
|
101
|
+
let mtime = null;
|
|
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
|
+
});
|
|
115
|
+
}
|
|
116
|
+
changed.sort((a, b) => (b.at ?? 0) - (a.at ?? 0));
|
|
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
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createEnvironment({
|
|
136
|
+
platform = process.platform,
|
|
137
|
+
exec = run,
|
|
138
|
+
} = {}) {
|
|
139
|
+
let person = null;
|
|
140
|
+
const repos = new Map();
|
|
141
|
+
return {
|
|
142
|
+
/** { app, idleSeconds, inCall, at }: never sent off this computer. */
|
|
143
|
+
person() {
|
|
144
|
+
if (
|
|
145
|
+
!person ||
|
|
146
|
+
Date.now() - person.at >
|
|
147
|
+
(process.env.HARNESS_PRESENCE_FILE ? 0 : PERSON_TTL_MS)
|
|
148
|
+
) {
|
|
149
|
+
const read = readPerson({ platform, exec });
|
|
150
|
+
person = {
|
|
151
|
+
...read,
|
|
152
|
+
inCall: Boolean(read.app && CALL_APPS.test(read.app)),
|
|
153
|
+
at: Date.now(),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return person;
|
|
157
|
+
},
|
|
158
|
+
repository(folder) {
|
|
159
|
+
const cached = repos.get(folder);
|
|
160
|
+
if (cached && Date.now() - cached.at < REPO_TTL_MS) return cached.value;
|
|
161
|
+
const value =
|
|
162
|
+
folder && fs.existsSync(folder)
|
|
163
|
+
? readRepository(folder, { exec })
|
|
164
|
+
: null;
|
|
165
|
+
repos.set(folder, { at: Date.now(), value });
|
|
166
|
+
if (repos.size > 200) repos.delete(repos.keys().next().value);
|
|
167
|
+
return value;
|
|
168
|
+
},
|
|
169
|
+
/** Minutes since a file in folder last changed, from its uncommitted files or its last commit; null when unknown. */
|
|
170
|
+
lastChanged(folder, now = Date.now()) {
|
|
171
|
+
const repo = this.repository(folder);
|
|
172
|
+
if (!repo) return null;
|
|
173
|
+
const newest = Math.max(
|
|
174
|
+
repo.changed[0]?.at ?? 0,
|
|
175
|
+
repo.lastCommit?.at ?? 0
|
|
176
|
+
);
|
|
177
|
+
return newest ? Math.max(0, Math.round((now - newest) / 60_000)) : null;
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|