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.
- package/assets/opencode-plugin.js +48 -11
- package/assets/pi-extension.ts +105 -26
- package/cli.mjs +1238 -322
- package/lib/activity.mjs +197 -40
- package/lib/adapters.mjs +337 -54
- package/lib/agentnode.mjs +175 -35
- package/lib/atlas.mjs +383 -83
- package/lib/cards.mjs +174 -40
- package/lib/claudemem.mjs +74 -18
- package/lib/commands.mjs +13 -5
- package/lib/context.mjs +409 -71
- package/lib/control-client.mjs +5 -2
- package/lib/coordinator.mjs +274 -57
- package/lib/daemon.mjs +5268 -1024
- package/lib/desktop.mjs +149 -35
- package/lib/digest.mjs +54 -13
- package/lib/environment.mjs +103 -21
- package/lib/files.mjs +246 -13
- package/lib/firewall.mjs +169 -35
- package/lib/folders.mjs +179 -36
- package/lib/frames.mjs +214 -40
- package/lib/inference.mjs +463 -126
- package/lib/learn.mjs +78 -16
- package/lib/live.mjs +286 -61
- package/lib/localmodels.mjs +424 -83
- package/lib/mcp-stdio.mjs +868 -151
- package/lib/media.mjs +138 -28
- 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 +184 -33
- package/lib/ocr.mjs +223 -60
- package/lib/onboard.mjs +321 -70
- package/lib/parsers.mjs +132 -41
- package/lib/paths.mjs +33 -10
- package/lib/personal.mjs +51 -13
- package/lib/phones.mjs +384 -60
- package/lib/places.mjs +555 -107
- package/lib/provenance.mjs +19 -4
- package/lib/receipts.mjs +51 -11
- package/lib/redact.mjs +512 -116
- package/lib/runner.mjs +345 -55
- package/lib/runtime-install.mjs +55 -14
- package/lib/screens.mjs +154 -38
- package/lib/sealed.mjs +157 -28
- package/lib/secrets.mjs +18 -5
- package/lib/service.mjs +170 -34
- package/lib/snippets.mjs +94 -18
- package/lib/speech.mjs +688 -175
- package/lib/store.mjs +1654 -307
- package/lib/swarm.mjs +596 -117
- package/lib/t3.mjs +400 -79
- package/lib/trails.mjs +285 -61
- package/lib/ui.mjs +95 -23
- package/lib/vectors.mjs +5 -2
- package/lib/wakes.mjs +284 -68
- package/lib/watches.mjs +247 -48
- package/lib/worktrees.mjs +186 -38
- 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),
|
|
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
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) => {
|
|
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(
|
|
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
|
|
18
|
-
|
|
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
|
|
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"),
|
|
25
|
-
|
|
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
|
|
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)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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)
|
|
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
|
}
|
package/lib/environment.mjs
CHANGED
|
@@ -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 =
|
|
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, {
|
|
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 {
|
|
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({
|
|
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 {
|
|
29
|
-
|
|
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
|
|
34
|
-
|
|
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 {
|
|
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"], {
|
|
81
|
+
const top = exec("git", ["rev-parse", "--show-toplevel"], {
|
|
82
|
+
cwd: folder,
|
|
83
|
+
})?.trim();
|
|
50
84
|
if (!top) return null;
|
|
51
|
-
const branch =
|
|
52
|
-
|
|
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 {
|
|
63
|
-
|
|
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"], {
|
|
67
|
-
|
|
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({
|
|
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 (
|
|
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 = {
|
|
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 =
|
|
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(
|
|
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
|
};
|