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.
Files changed (64) hide show
  1. package/README.md +48 -8
  2. package/assets/opencode-plugin.js +48 -10
  3. package/assets/pi-extension.ts +106 -26
  4. package/cli.mjs +1281 -289
  5. package/lib/activity.mjs +197 -40
  6. package/lib/adapters.mjs +337 -54
  7. package/lib/agentnode.mjs +175 -35
  8. package/lib/atlas.mjs +604 -0
  9. package/lib/bip39-english.txt +2048 -0
  10. package/lib/cards.mjs +177 -39
  11. package/lib/claudemem.mjs +74 -18
  12. package/lib/commands.mjs +13 -5
  13. package/lib/context.mjs +628 -0
  14. package/lib/control-client.mjs +5 -2
  15. package/lib/coordinator.mjs +281 -56
  16. package/lib/daemon.mjs +5590 -838
  17. package/lib/desktop.mjs +149 -35
  18. package/lib/digest.mjs +81 -0
  19. package/lib/environment.mjs +180 -0
  20. package/lib/files.mjs +246 -13
  21. package/lib/firewall.mjs +288 -0
  22. package/lib/folders.mjs +223 -57
  23. package/lib/frames.mjs +333 -0
  24. package/lib/inference.mjs +663 -0
  25. package/lib/learn.mjs +182 -0
  26. package/lib/live.mjs +286 -61
  27. package/lib/localmodels.mjs +571 -0
  28. package/lib/mcp-stdio.mjs +912 -103
  29. package/lib/media.mjs +228 -0
  30. package/lib/members.json +1 -1
  31. package/lib/members.mjs +35 -9
  32. package/lib/models.mjs +342 -80
  33. package/lib/network.mjs +201 -42
  34. package/lib/notify.mjs +49 -12
  35. package/lib/nudge.mjs +190 -32
  36. package/lib/ocr.mjs +380 -0
  37. package/lib/onboard.mjs +321 -70
  38. package/lib/parsers.mjs +132 -41
  39. package/lib/paths.mjs +33 -10
  40. package/lib/personal.mjs +77 -0
  41. package/lib/phones.mjs +398 -51
  42. package/lib/places.mjs +566 -102
  43. package/lib/provenance.mjs +19 -4
  44. package/lib/receipts.mjs +82 -0
  45. package/lib/redact.mjs +730 -0
  46. package/lib/runner.mjs +347 -55
  47. package/lib/runtime-install.mjs +59 -17
  48. package/lib/screens.mjs +212 -0
  49. package/lib/sealed.mjs +160 -28
  50. package/lib/secrets.mjs +18 -5
  51. package/lib/service.mjs +173 -35
  52. package/lib/snippets.mjs +140 -0
  53. package/lib/speech.mjs +993 -0
  54. package/lib/store.mjs +1678 -301
  55. package/lib/swarm.mjs +598 -117
  56. package/lib/t3.mjs +400 -79
  57. package/lib/trails.mjs +401 -0
  58. package/lib/ui.mjs +95 -23
  59. package/lib/vectors.mjs +5 -2
  60. package/lib/wakes.mjs +284 -68
  61. package/lib/watches.mjs +362 -0
  62. package/lib/worktrees.mjs +186 -38
  63. package/package.json +6 -4
  64. package/lib/embedder.mjs +0 -156
package/lib/phones.mjs CHANGED
@@ -1,6 +1,15 @@
1
1
  import os from "node:os";
2
2
  import { randomBytes } from "node:crypto";
3
- import { b64u, channelKey, constantTimeEqual, loadOrCreateIdentity, MAX_SKEW_MS, open, pairProof, seal } from "./sealed.mjs";
3
+ import {
4
+ b64u,
5
+ channelKey,
6
+ constantTimeEqual,
7
+ loadOrCreateIdentity,
8
+ MAX_SKEW_MS,
9
+ open,
10
+ pairProof,
11
+ seal,
12
+ } from "./sealed.mjs";
4
13
 
5
14
  // The daemon's side of the person's own devices: pairing offers made on this machine (a QR code
6
15
  // for a phone, a link opened in a browser), and sealed calls from paired devices, opened here and
@@ -13,34 +22,134 @@ const OFFER_TTL_MS = 10 * 60_000;
13
22
  const MAX_OFFERS = 5;
14
23
  // A message queued at Harness while this computer slept may arrive up to 7 days late; only decisions and posts may.
15
24
  const QUEUED_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
16
- const QUEUEABLE = new Set(["board.post", "board.run", "board.release", "board.answer", "board.remove", "board.thread.post", "board.thread.send", "board.thread.discard", "board.thread.end", "board.goal.stop",
17
- "board.swarm.start", "board.swarm.post", "board.swarm.stop", "board.swarm.stop_all", "board.swarm.resume", "board.swarm.start_run"]);
25
+ const QUEUEABLE = new Set([
26
+ "board.post",
27
+ "board.run",
28
+ "board.release",
29
+ "board.answer",
30
+ "board.remove",
31
+ "board.thread.post",
32
+ "board.thread.send",
33
+ "board.thread.discard",
34
+ "board.thread.end",
35
+ "board.goal.stop",
36
+ "board.swarm.start",
37
+ "board.swarm.post",
38
+ "board.swarm.stop",
39
+ "board.swarm.stop_all",
40
+ "board.swarm.resume",
41
+ "board.swarm.start_run",
42
+ ]);
18
43
  const ID = /^[A-Za-z0-9_-]{8,64}$/;
19
44
  const KEY = /^[A-Za-z0-9_-]{80,100}$/;
20
45
  const KINDS = new Set(["phone", "browser"]);
21
46
  const PHONE_COMMANDS = new Set([
22
- "board.list", "board.get", "board.children", "board.agents", "board.post", "board.run", "board.release", "board.answer", "board.remove",
23
- "board.thread.read", "board.thread.post", "board.thread.send", "board.thread.discard", "board.thread.auto", "board.thread.end",
24
- "board.goal.list", "board.goal.get", "board.goal.stop", "board.room.read",
25
- "board.swarm.list", "board.swarm.read", "board.swarm.start", "board.swarm.post", "board.swarm.stop", "board.swarm.stop_all", "board.swarm.resume", "board.swarm.folder", "board.swarm.coordinator",
26
- "board.swarm.diff", "board.swarm.merge", "board.swarm.discard", "board.swarm.start_run", "board.swarm.agents", "board.swarm.note",
47
+ "board.list",
48
+ "board.get",
49
+ "board.children",
50
+ "board.agents",
51
+ "board.post",
52
+ "board.run",
53
+ "board.release",
54
+ "board.answer",
55
+ "board.remove",
56
+ "board.thread.read",
57
+ "board.thread.post",
58
+ "board.thread.send",
59
+ "board.thread.discard",
60
+ "board.thread.auto",
61
+ "board.thread.end",
62
+ "board.goal.list",
63
+ "board.goal.get",
64
+ "board.goal.stop",
65
+ "board.room.read",
66
+ // A thread's files: shown, opened and downloaded, and shared or kept on this computer, by the person.
67
+ "board.file.get",
68
+ "board.file.share",
69
+ "board.swarm.list",
70
+ "board.swarm.read",
71
+ "board.swarm.start",
72
+ "board.swarm.post",
73
+ "board.swarm.stop",
74
+ "board.swarm.stop_all",
75
+ "board.swarm.resume",
76
+ "board.swarm.folder",
77
+ "board.swarm.coordinator",
78
+ "board.swarm.diff",
79
+ "board.swarm.merge",
80
+ "board.swarm.discard",
81
+ "board.swarm.start_run",
82
+ "board.swarm.agents",
83
+ "board.swarm.note",
27
84
  // Where a thread works: the folders offered, any folder browsed to, and where a message would work (folders.mjs).
28
- "board.swarm.places", "board.swarm.browse", "board.swarm.place",
85
+ "board.swarm.places",
86
+ "board.swarm.browse",
87
+ "board.swarm.place",
29
88
  // The person's own running sessions, read and messaged as from the desktop app; never queued (a session may be gone).
30
- "board.live", "board.live.read", "board.live.say",
89
+ "board.live",
90
+ "board.live.read",
91
+ "board.live.say",
31
92
  ]);
32
93
  // What the chat owner does on the board, in Harness's name (was src/server/mcp/board.ts HARNESS_COMMANDS).
33
94
  const HARNESS_COMMANDS = new Set([
34
- "board.list", "board.get", "board.post", "board.claim", "board.report", "board.answer", "board.thread.read", "board.thread.post", "board.standing.check",
35
- "board.goal.create", "board.goal.get", "board.goal.list", "board.goal.finish", "board.room.read", "board.room.post", "board.room.search", "board.children", "board.agents",
36
- "board.swarm.list", "board.swarm.read", "board.swarm.diff",
95
+ "board.list",
96
+ "board.get",
97
+ "board.post",
98
+ "board.claim",
99
+ "board.report",
100
+ "board.answer",
101
+ "board.thread.read",
102
+ "board.thread.post",
103
+ "board.standing.check",
104
+ "board.goal.create",
105
+ "board.goal.get",
106
+ "board.goal.list",
107
+ "board.goal.finish",
108
+ "board.room.read",
109
+ "board.room.post",
110
+ "board.room.search",
111
+ "board.children",
112
+ "board.agents",
113
+ "board.swarm.list",
114
+ "board.swarm.read",
115
+ "board.swarm.diff",
37
116
  ]);
38
117
  // Swarms talking to swarms (network.mjs), from the Harness window: the person's own, never Harness's.
39
- const RELAY_PERSON_COMMANDS = ["board.relay.list", "board.relay.network", "board.relay.decide", "board.relay.request", "board.relay.seen", "board.relay.defaults", "board.relay.peers"];
40
- const BROWSER_PERSON_COMMANDS = new Set([...PHONE_COMMANDS, "board.goal.resume", "board.standing", "board.standing.check", "board.standing.off", ...RELAY_PERSON_COMMANDS]);
41
- const BROWSER_READS = new Set(["sessions.search", "sessions.read", "sessions.raw", "tasks.status", "relay.request", "relay.open"]);
118
+ const RELAY_PERSON_COMMANDS = [
119
+ "board.relay.list",
120
+ "board.relay.network",
121
+ "board.relay.decide",
122
+ "board.relay.request",
123
+ "board.relay.seen",
124
+ "board.relay.defaults",
125
+ "board.relay.peers",
126
+ ];
127
+ const BROWSER_PERSON_COMMANDS = new Set([
128
+ ...PHONE_COMMANDS,
129
+ "board.goal.resume",
130
+ "board.standing",
131
+ "board.standing.check",
132
+ "board.standing.off",
133
+ ...RELAY_PERSON_COMMANDS,
134
+ ]);
135
+ const BROWSER_READS = new Set([
136
+ "sessions.search",
137
+ "sessions.read",
138
+ "sessions.raw",
139
+ "tasks.status",
140
+ "relay.request",
141
+ "relay.open",
142
+ ]);
42
143
 
43
- export async function createPhoneDesk({ store, identityFile, boardCommand, reads = {}, version, onMessage = () => {}, machine = () => null }) {
144
+ export async function createPhoneDesk({
145
+ store,
146
+ identityFile,
147
+ boardCommand,
148
+ reads = {},
149
+ version,
150
+ onMessage = () => {},
151
+ machine = () => null,
152
+ }) {
44
153
  const identity = await loadOrCreateIdentity(identityFile);
45
154
  const offers = new Map();
46
155
  const keys = new Map();
@@ -48,10 +157,15 @@ export async function createPhoneDesk({ store, identityFile, boardCommand, reads
48
157
 
49
158
  const sweep = () => {
50
159
  const now = Date.now();
51
- for (const [id, offer] of offers) if (offer.expiresAt < now) offers.delete(id);
160
+ for (const [id, offer] of offers)
161
+ if (offer.expiresAt < now) offers.delete(id);
52
162
  };
53
163
  const keyFor = async (device) => {
54
- if (!keys.has(device.id)) keys.set(device.id, await channelKey(identity.privateKey, device.public_key, device.id));
164
+ if (!keys.has(device.id))
165
+ keys.set(
166
+ device.id,
167
+ await channelKey(identity.privateKey, device.public_key, device.id)
168
+ );
55
169
  return keys.get(device.id);
56
170
  };
57
171
 
@@ -64,52 +178,152 @@ export async function createPhoneDesk({ store, identityFile, boardCommand, reads
64
178
  const offerId = b64u(randomBytes(16));
65
179
  const secret = b64u(randomBytes(32));
66
180
  offers.set(offerId, { secret, kind, expiresAt: Date.now() + OFFER_TTL_MS });
67
- const fragment = new URLSearchParams({ v: "1", o: offerId, s: secret, k: identity.publicKey, h: hostname });
181
+ const fragment = new URLSearchParams({
182
+ v: "1",
183
+ o: offerId,
184
+ s: secret,
185
+ k: identity.publicKey,
186
+ h: hostname,
187
+ });
68
188
  const page = kind === "browser" ? "/link" : "/m/pair";
69
- return { offerId, kind, expiresInSeconds: OFFER_TTL_MS / 1000, url: `${String(origin).replace(/\/$/, "")}${page}#${fragment}` };
189
+ return {
190
+ offerId,
191
+ kind,
192
+ expiresInSeconds: OFFER_TTL_MS / 1000,
193
+ url: `${String(origin).replace(/\/$/, "")}${page}#${fragment}`,
194
+ };
70
195
  };
71
196
 
72
197
  const pair = async (input) => {
73
198
  sweep();
74
199
  const { offerId, deviceId, devicePublicKey, proof, requestId } = input;
75
- if (![offerId, deviceId, requestId].every((value) => ID.test(String(value))) || !KEY.test(String(devicePublicKey))) return { ok: false, error: "invalid pairing request" };
200
+ if (
201
+ ![offerId, deviceId, requestId].every((value) =>
202
+ ID.test(String(value))
203
+ ) ||
204
+ !KEY.test(String(devicePublicKey))
205
+ )
206
+ return { ok: false, error: "invalid pairing request" };
76
207
  const pending = offers.get(String(offerId));
77
208
  // One attempt per code, right or wrong: a guessed proof burns the code.
78
209
  offers.delete(String(offerId));
79
- if (!pending) return { ok: false, error: "this pairing code expired or was already used; make a new one on the computer" };
80
- if (!constantTimeEqual(proof, await pairProof(pending.secret, offerId, devicePublicKey, identity.publicKey))) return { ok: false, error: "the pairing proof did not match" };
81
- if (store.getPhone(String(deviceId))) return { ok: false, error: "this device is already paired" };
210
+ if (!pending)
211
+ return {
212
+ ok: false,
213
+ error:
214
+ "this pairing code expired or was already used; make a new one on the computer",
215
+ };
216
+ if (
217
+ !constantTimeEqual(
218
+ proof,
219
+ await pairProof(
220
+ pending.secret,
221
+ offerId,
222
+ devicePublicKey,
223
+ identity.publicKey
224
+ )
225
+ )
226
+ )
227
+ return { ok: false, error: "the pairing proof did not match" };
228
+ if (store.getPhone(String(deviceId)))
229
+ return { ok: false, error: "this device is already paired" };
82
230
  const fallback = pending.kind === "browser" ? "Browser" : "Phone";
83
- const label = String(input.label || fallback).replace(/[^\p{L}\p{N} ._'-]/gu, "").slice(0, 40) || fallback;
84
- const device = store.addPhone({ id: String(deviceId), label, publicKey: String(devicePublicKey), kind: pending.kind });
85
- return { ok: true, envelope: await seal(await keyFor(device), "d2p", device.id, String(requestId), { paired: true, hostname, label, kind: device.kind, version }) };
231
+ const label =
232
+ String(input.label || fallback)
233
+ .replace(/[^\p{L}\p{N} ._'-]/gu, "")
234
+ .slice(0, 40) || fallback;
235
+ const device = store.addPhone({
236
+ id: String(deviceId),
237
+ label,
238
+ publicKey: String(devicePublicKey),
239
+ kind: pending.kind,
240
+ });
241
+ return {
242
+ ok: true,
243
+ envelope: await seal(
244
+ await keyFor(device),
245
+ "d2p",
246
+ device.id,
247
+ String(requestId),
248
+ { paired: true, hostname, label, kind: device.kind, version }
249
+ ),
250
+ };
86
251
  };
87
252
 
88
253
  const call = async (envelope) => {
89
254
  sweep();
90
- if (!envelope || envelope.v !== 1 || !ID.test(String(envelope.deviceId)) || !ID.test(String(envelope.requestId)) || typeof envelope.iv !== "string" || typeof envelope.ct !== "string") {
255
+ if (
256
+ !envelope ||
257
+ envelope.v !== 1 ||
258
+ !ID.test(String(envelope.deviceId)) ||
259
+ !ID.test(String(envelope.requestId)) ||
260
+ typeof envelope.iv !== "string" ||
261
+ typeof envelope.ct !== "string"
262
+ ) {
91
263
  return { ok: false, error: "invalid envelope" };
92
264
  }
93
265
  const device = store.getPhone(envelope.deviceId);
94
- if (!device) return { ok: false, error: "unknown device", code: "unpaired" };
95
- if (device.revoked_at) return { ok: false, error: "this device was disconnected from the computer", code: "revoked" };
266
+ if (!device)
267
+ return { ok: false, error: "unknown device", code: "unpaired" };
268
+ if (device.revoked_at)
269
+ return {
270
+ ok: false,
271
+ error: "this device was disconnected from the computer",
272
+ code: "revoked",
273
+ };
96
274
  const key = await keyFor(device);
97
275
  let request;
98
- try { request = await open(key, "p2d", envelope); } catch { return { ok: false, error: "the message could not be opened", code: "unreadable" }; }
276
+ try {
277
+ request = await open(key, "p2d", envelope);
278
+ } catch {
279
+ return {
280
+ ok: false,
281
+ error: "the message could not be opened",
282
+ code: "unreadable",
283
+ };
284
+ }
99
285
  const queued = request?.queued === true;
100
- if (queued && !QUEUEABLE.has(String(request?.call?.command || ""))) return { ok: false, error: "only posts and decisions wait for the computer", code: "refused" };
101
- const age = typeof request?.ts === "number" ? Date.now() - request.ts : Infinity;
102
- if (queued ? age > QUEUED_MAX_AGE_MS || age < -MAX_SKEW_MS : Math.abs(age) > MAX_SKEW_MS) return { ok: false, error: "the message is too old or the device's clock is off", code: "stale" };
103
- if (!store.firstPhoneRequest(device.id, String(envelope.requestId))) return { ok: false, error: "this message was already received", code: "replayed" };
286
+ if (queued && !QUEUEABLE.has(String(request?.call?.command || "")))
287
+ return {
288
+ ok: false,
289
+ error: "only posts and decisions wait for the computer",
290
+ code: "refused",
291
+ };
292
+ const age =
293
+ typeof request?.ts === "number" ? Date.now() - request.ts : Infinity;
294
+ if (
295
+ queued
296
+ ? age > QUEUED_MAX_AGE_MS || age < -MAX_SKEW_MS
297
+ : Math.abs(age) > MAX_SKEW_MS
298
+ )
299
+ return {
300
+ ok: false,
301
+ error: "the message is too old or the device's clock is off",
302
+ code: "stale",
303
+ };
304
+ if (!store.firstPhoneRequest(device.id, String(envelope.requestId)))
305
+ return {
306
+ ok: false,
307
+ error: "this message was already received",
308
+ code: "replayed",
309
+ };
104
310
  store.touchPhone(device.id);
105
311
  // Any message from a paired device keeps a Mac in Standby awake for the next hour (Lid-Closed Mode).
106
312
  onMessage();
107
313
  if (request.call?.command === "phone.forget") {
108
314
  const forgotten = revoke(device.id);
109
- return { ok: true, envelope: await seal(key, "d2p", device.id, envelope.requestId, { ok: forgotten.ok }) };
315
+ return {
316
+ ok: true,
317
+ envelope: await seal(key, "d2p", device.id, envelope.requestId, {
318
+ ok: forgotten.ok,
319
+ }),
320
+ };
110
321
  }
111
322
  const result = await answer(request.call, device);
112
- return { ok: true, envelope: await seal(key, "d2p", device.id, envelope.requestId, result) };
323
+ return {
324
+ ok: true,
325
+ envelope: await seal(key, "d2p", device.id, envelope.requestId, result),
326
+ };
113
327
  };
114
328
 
115
329
  const settled = async (run) => {
@@ -117,42 +331,175 @@ export async function createPhoneDesk({ store, identityFile, boardCommand, reads
117
331
  const result = run();
118
332
  return result?.pending ? { ok: true, ...(await result.pending) } : result;
119
333
  } catch (error) {
120
- return { ok: false, error: `the board could not answer: ${error.message}` };
334
+ return {
335
+ ok: false,
336
+ error: `the board could not answer: ${error.message}`,
337
+ };
121
338
  }
122
339
  };
123
340
 
124
341
  const answer = async (callInput, device) => {
125
342
  const command = String(callInput?.command || "");
126
- if (command === "phone.hello") return { ok: true, hostname, version, kind: device.kind };
343
+ if (command === "phone.hello")
344
+ return { ok: true, hostname, version, kind: device.kind };
127
345
  if (device.kind === "browser") {
128
346
  if (BROWSER_READS.has(command)) {
129
347
  const read = reads[command];
130
- if (!read) return { ok: false, error: `this computer cannot answer ${command}` };
131
- try { return { ok: true, result: await read(callInput) }; } catch (error) { return { ok: false, error: error.message }; }
348
+ if (!read)
349
+ return { ok: false, error: `this computer cannot answer ${command}` };
350
+ try {
351
+ return { ok: true, result: await read(callInput) };
352
+ } catch (error) {
353
+ return { ok: false, error: error.message };
354
+ }
132
355
  }
133
356
  // The browser says whose call it is: the person's own action, or the chat owner's tool call.
134
357
  const as = callInput.as === "harness" ? "harness" : "user";
135
- if (!(as === "harness" ? HARNESS_COMMANDS : BROWSER_PERSON_COMMANDS).has(command)) return { ok: false, error: `a browser cannot call ${command || "that"} as ${as}` };
358
+ if (
359
+ !(as === "harness" ? HARNESS_COMMANDS : BROWSER_PERSON_COMMANDS).has(
360
+ command
361
+ )
362
+ )
363
+ return {
364
+ ok: false,
365
+ error: `a browser cannot call ${command || "that"} as ${as}`,
366
+ };
136
367
  // Words another agent wrote that drove Harness's call ride as a taint the board keeps.
137
- const writtenBy = Array.isArray(callInput.writtenBy) ? callInput.writtenBy.map(String).slice(0, 16) : [];
138
- if (as === "harness" && command === "board.goal.create" && writtenBy.length) return { ok: false, error: "a goal is set only in a turn the owner started" };
139
- const { as: _as, writtenBy: _written, provenance: _provenance, ...rest } = callInput;
140
- return settled(() => boardCommand({ ...rest, command, agent: as, ...(writtenBy.length ? { provenance: { trust: "agent", chain: writtenBy } } : {}) }));
368
+ const writtenBy = Array.isArray(callInput.writtenBy)
369
+ ? callInput.writtenBy.map(String).slice(0, 16)
370
+ : [];
371
+ if (
372
+ as === "harness" &&
373
+ command === "board.goal.create" &&
374
+ writtenBy.length
375
+ )
376
+ return {
377
+ ok: false,
378
+ error: "a goal is set only in a turn the owner started",
379
+ };
380
+ const {
381
+ as: _as,
382
+ writtenBy: _written,
383
+ provenance: _provenance,
384
+ ...rest
385
+ } = callInput;
386
+ return settled(() =>
387
+ boardCommand({
388
+ ...rest,
389
+ command,
390
+ agent: as,
391
+ ...(writtenBy.length
392
+ ? { provenance: { trust: "agent", chain: writtenBy } }
393
+ : {}),
394
+ })
395
+ );
141
396
  }
142
397
  // Lid-Closed Mode's wake: no arguments, never queued, and it can only keep the Mac awake and say how it is.
143
398
  if (command === "machine.wake") return { ok: true, machine: machine() };
144
- if (!PHONE_COMMANDS.has(command)) return { ok: false, error: `a phone cannot call ${command || "that"}` };
399
+ if (!PHONE_COMMANDS.has(command))
400
+ return { ok: false, error: `a phone cannot call ${command || "that"}` };
145
401
  // The phone is the person; whatever it names itself, it acts as "user".
146
- return settled(() => boardCommand({ ...callInput, command, agent: "user", ...(command === "board.list" && callInput.all === undefined ? { all: true } : {}) }));
402
+ return settled(() =>
403
+ boardCommand({
404
+ ...callInput,
405
+ command,
406
+ agent: "user",
407
+ ...(command === "board.list" && callInput.all === undefined
408
+ ? { all: true }
409
+ : {}),
410
+ })
411
+ );
147
412
  };
148
413
 
149
414
  // Harness's bridge message: a pairing or a sealed call. Everything returned is safe to pass through Harness.
150
- const handle = async (input) => (input?.kind === "pair" ? pair(input) : input?.kind === "call" ? call(input.envelope) : { ok: false, error: "unknown sealed request" });
415
+ const handle = async (input) =>
416
+ input?.kind === "pair"
417
+ ? pair(input)
418
+ : input?.kind === "call"
419
+ ? call(input.envelope)
420
+ : { ok: false, error: "unknown sealed request" };
421
+
422
+ // The other way round: this computer asking the person's own Harness window for what only the window holds (its
423
+ // screen history). The question is sealed to every browser linked here ("d2b") under one fresh request id; Harness
424
+ // hands the envelopes to the window that is open (`carry`, the bridge's leg), only a browser holding one of these
425
+ // links can open its envelope, and its answer comes back sealed ("b2d") for this request id alone. Harness sees which
426
+ // devices, sizes and timing, never the question or the answer.
427
+ const ask = async (call, carry) => {
428
+ const browsers = store
429
+ .phones()
430
+ .filter((device) => device.kind === "browser" && !device.revoked_at)
431
+ .slice(0, 8)
432
+ .map((device) => store.getPhone(device.id));
433
+ if (!browsers.length)
434
+ return {
435
+ ok: false,
436
+ code: "unpaired",
437
+ error:
438
+ "no Harness window is linked to this computer: the person links one with npx tryharness link",
439
+ };
440
+ const requestId = b64u(randomBytes(16));
441
+ const ts = Date.now();
442
+ const envelopes = await Promise.all(
443
+ browsers.map(async (device) =>
444
+ seal(await keyFor(device), "d2b", device.id, requestId, { ts, call })
445
+ )
446
+ );
447
+ const reply = await carry(envelopes);
448
+ if (reply?.window === "closed")
449
+ return {
450
+ ok: false,
451
+ code: "closed",
452
+ error:
453
+ "the person's Harness window isn't open: ask them to open Harness, then try again",
454
+ };
455
+ if (reply?.window !== "open")
456
+ return {
457
+ ok: false,
458
+ error: reply?.error || "Harness could not carry the question",
459
+ };
460
+ if (!reply.ok || !reply.envelope)
461
+ return {
462
+ ok: false,
463
+ code: reply.code,
464
+ error:
465
+ reply.code === "window"
466
+ ? `the open Harness window could not answer: ${reply.error}`
467
+ : reply.error,
468
+ };
469
+ const device = browsers.find((item) => item.id === reply.envelope.deviceId);
470
+ if (!device || reply.envelope.requestId !== requestId)
471
+ return {
472
+ ok: false,
473
+ code: "unreadable",
474
+ error: "the answer was for another question",
475
+ };
476
+ let answer;
477
+ try {
478
+ answer = await open(await keyFor(device), "b2d", reply.envelope);
479
+ } catch {
480
+ return {
481
+ ok: false,
482
+ code: "unreadable",
483
+ error:
484
+ "the answer could not be opened: it did not come from a window linked to this computer",
485
+ };
486
+ }
487
+ store.touchPhone(device.id);
488
+ return answer;
489
+ };
151
490
 
152
491
  const revoke = (id) => {
153
492
  keys.delete(String(id));
154
493
  return store.revokePhone(String(id));
155
494
  };
156
495
 
157
- return { offer, handle, revoke, list: () => store.phones(), publicKey: identity.publicKey, identity };
496
+ return {
497
+ offer,
498
+ handle,
499
+ ask,
500
+ revoke,
501
+ list: () => store.phones(),
502
+ publicKey: identity.publicKey,
503
+ identity,
504
+ };
158
505
  }