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/folders.mjs CHANGED
@@ -2,31 +2,40 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
- import { mainCheckout } from "./worktrees.mjs";
6
5
  import { nameOf } from "./members.mjs";
7
6
  import { paths } from "./paths.mjs";
8
7
  import { t3Projects } from "./places.mjs";
9
8
 
10
9
  // Where the person's threads work. The folder is the person's to choose, any folder on the computer: the composer offers
11
- // the folders they work in (T3 Code's projects, the folders their agents' sessions and threads ran in) and browses from
12
- // their home folder. With none chosen, Harness picks one from the message among those folders (src/server/swarm/folder.ts),
13
- // else the folder the agent last worked in. The home folder itself and hidden folders are never offered or picked, nor
14
- // Harness's and T3's own copies (their repository's checkout stands for them): work there is never what was meant (the
15
- // owner's October 13 thread ran Codex over their whole home folder).
10
+ // the folders they work in (T3 Code's projects, the folders their agents' sessions and threads ran in, each as its
11
+ // repository) and browses from their home folder. With none chosen, Harness picks one from the message among those folders
12
+ // (src/server/swarm/folder.ts), else the folder the agent last worked in. The home folder itself and hidden folders are never
13
+ // offered or picked, nor Harness's and T3's own copies or a repository's subfolders (the repository stands for them): work
14
+ // there is never what was meant (the owner's October 13 thread ran Codex over their whole home folder; on October 14 a
15
+ // buddy message was picked into ~/harness/src).
16
16
 
17
17
  const CODING = new Set(["claude-code", "codex", "pi", "opencode"]);
18
18
  // Folders offered and sent to Jev: the most recently worked in. Browsing reaches any other.
19
19
  const OFFERED = 60;
20
20
  const LISTED = 200;
21
- const README_MAX = 100_000;
22
- const TOP_LEVEL_MAX = 2_000;
23
21
  const PICK_TTL_MS = 10 * 60_000;
24
- const README = /^readme(\.(md|markdown|txt|rst))?$/i;
25
22
 
26
- const realDir = (dir) => { try { return fs.realpathSync(dir); } catch { return null; } };
27
- export const isDir = (dir) => typeof dir === "string" && path.isAbsolute(dir) && fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory() === true;
23
+ const realDir = (dir) => {
24
+ try {
25
+ return fs.realpathSync(dir);
26
+ } catch {
27
+ return null;
28
+ }
29
+ };
30
+ export const isDir = (dir) =>
31
+ typeof dir === "string" &&
32
+ path.isAbsolute(dir) &&
33
+ fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory() === true;
28
34
 
29
- const within = (dir, root) => { const rel = root ? path.relative(root, dir) : ".."; return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); };
35
+ const within = (dir, root) => {
36
+ const rel = root ? path.relative(root, dir) : "..";
37
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
38
+ };
30
39
 
31
40
  /** A folder Harness may offer or pick by itself: not the home folder or above it, not hidden, not Harness's own, not gone. */
32
41
  function offerable(dir) {
@@ -35,47 +44,113 @@ function offerable(dir) {
35
44
  const home = realDir(os.homedir()) ?? os.homedir();
36
45
  const rel = path.relative(home, real);
37
46
  if (rel === "" || path.parse(real).root === real) return false;
38
- if (!rel.startsWith("..") && !path.isAbsolute(rel)) return !rel.split(path.sep).some((part) => part.startsWith("."));
47
+ if (!rel.startsWith("..") && !path.isAbsolute(rel))
48
+ return !rel.split(path.sep).some((part) => part.startsWith("."));
39
49
  return !real.split(path.sep).some((part) => part.startsWith("."));
40
50
  }
41
51
 
42
- const git = (cwd, args) => { const found = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 2_000 }); return found.status === 0 ? (found.stdout || "").trim() : ""; };
52
+ const git = (cwd, args) => {
53
+ const found = spawnSync("git", args, {
54
+ cwd,
55
+ encoding: "utf8",
56
+ timeout: 2_000,
57
+ });
58
+ return found.status === 0 ? (found.stdout || "").trim() : "";
59
+ };
43
60
 
44
- /** What Jev reads of a folder: its name, repository, top level and README, whole. */
61
+ // The repository a folder is in, as its main checkout (for a worktree's or a subfolder's sessions), else the folder itself.
62
+ const ROOT_TTL_MS = 10 * 60_000;
63
+ const roots = new Map();
64
+ function repositoryOf(dir) {
65
+ const cached = roots.get(dir);
66
+ if (cached && cached.at > Date.now() - ROOT_TTL_MS) return cached.root;
67
+ const [top, common] = git(dir, [
68
+ "rev-parse",
69
+ "--path-format=absolute",
70
+ "--show-toplevel",
71
+ "--git-common-dir",
72
+ ]).split("\n");
73
+ const root =
74
+ (top && common
75
+ ? realDir(path.basename(common) === ".git" ? path.dirname(common) : top)
76
+ : null) ?? dir;
77
+ roots.set(dir, { root, at: Date.now() });
78
+ return root;
79
+ }
80
+
81
+ /**
82
+ * What Jev reads of a folder: its name, its repository, who worked there and when, and the titles of the sessions there
83
+ * (each the start of its first message), newest first. What was worked on in a folder says where new work goes; READMEs
84
+ * mostly say how to install a project, and together they passed Jev's limit (October 14).
85
+ */
45
86
  function facts(place) {
46
- let entries = [];
47
- try { entries = fs.readdirSync(place.path, { withFileTypes: true }).filter((entry) => ![".git", ".DS_Store"].includes(entry.name)); } catch {}
48
- const readme = entries.find((entry) => entry.isFile() && README.test(entry.name));
49
- let text = null;
50
- try { text = readme ? fs.readFileSync(path.join(place.path, readme.name), "utf8") : null; } catch {}
51
87
  // A remote's address without any credentials in it.
52
- const remote = git(place.path, ["remote", "get-url", "origin"]).replace(/\/\/[^@/]+@/, "//");
88
+ const remote = git(place.path, ["remote", "get-url", "origin"]).replace(
89
+ /\/\/[^@/]+@/,
90
+ "//"
91
+ );
53
92
  return {
54
- path: place.path, name: place.name, repository: remote || null,
55
- topLevel: entries.map((entry) => `${entry.name}${entry.isDirectory() ? "/" : ""}`).sort().slice(0, TOP_LEVEL_MAX),
56
- readme: text && text.length <= README_MAX ? text : null,
57
- agents: place.agents.map(nameOf), lastWorked: place.lastWorked,
93
+ path: place.path,
94
+ name: place.name,
95
+ repository: remote || null,
96
+ agents: place.agents.map(nameOf),
97
+ lastWorked: place.lastWorked,
98
+ titles: place.titles,
58
99
  };
59
100
  }
60
101
 
61
- export function createFolders({ store, askHarness, canAsk, privacy, log = () => {} }) {
62
- /** The folders the person works in, most recently worked in first: { path, name, t3, agents, lastWorked }. */
102
+ export function createFolders({
103
+ store,
104
+ askHarness,
105
+ canAsk,
106
+ privacy,
107
+ log = () => {},
108
+ lastChanged = () => null,
109
+ rank = async (_message, places) => places,
110
+ }) {
111
+ /** The folders the person works in, most recently worked in first: { path, name, t3, agents, lastWorked, titles }. */
63
112
  const known = () => {
64
113
  const byPath = new Map();
65
- const add = (dir, agent, at) => {
66
- const where = realDir(dir) && realDir(mainCheckout(realDir(dir)));
114
+ const add = (dir, agent, at, title) => {
115
+ const real = realDir(dir);
116
+ const where = real && repositoryOf(real);
67
117
  if (!where || !offerable(where)) return;
68
- const place = byPath.get(where) ?? { path: where, name: path.basename(where), t3: false, agents: [], lastWorked: null };
69
- if (agent && CODING.has(agent) && !place.agents.includes(agent)) place.agents.push(agent);
70
- if (at && (!place.lastWorked || at > place.lastWorked)) place.lastWorked = at;
118
+ const place = byPath.get(where) ?? {
119
+ path: where,
120
+ name: path.basename(where),
121
+ t3: false,
122
+ agents: [],
123
+ lastWorked: null,
124
+ titles: [],
125
+ };
126
+ if (agent && CODING.has(agent) && !place.agents.includes(agent))
127
+ place.agents.push(agent);
128
+ if (at && (!place.lastWorked || at > place.lastWorked))
129
+ place.lastWorked = at;
130
+ if (title && !place.titles.includes(title)) place.titles.push(title);
71
131
  byPath.set(where, place);
72
132
  };
73
- for (const row of store.sessionFolders(300)) add(row.cwd, row.agent, row.at);
74
- for (const thread of store.goals({ all: true })) if (thread.goal?.workspace) for (const agent of thread.goal.team) add(thread.goal.workspace, agent, thread.updated_at || thread.created_at);
133
+ for (const row of store.sessionPlaces())
134
+ add(row.cwd, row.agent, row.at, row.title);
135
+ for (const thread of store.goals({ all: true }))
136
+ if (thread.goal?.workspace)
137
+ for (const agent of thread.goal.team)
138
+ add(
139
+ thread.goal.workspace,
140
+ agent,
141
+ thread.updated_at || thread.created_at
142
+ );
75
143
  const projects = t3Projects();
76
144
  for (const dir of projects) add(dir, null, null);
77
- for (const place of byPath.values()) place.t3 = projects.includes(place.path);
78
- return [...byPath.values()].sort((a, b) => (b.lastWorked ?? "").localeCompare(a.lastWorked ?? "") || a.name.localeCompare(b.name)).slice(0, OFFERED);
145
+ for (const place of byPath.values())
146
+ place.t3 = projects.includes(place.path);
147
+ return [...byPath.values()]
148
+ .sort(
149
+ (a, b) =>
150
+ (b.lastWorked ?? "").localeCompare(a.lastWorked ?? "") ||
151
+ a.name.localeCompare(b.name)
152
+ )
153
+ .slice(0, OFFERED);
79
154
  };
80
155
 
81
156
  /**
@@ -84,20 +159,48 @@ export function createFolders({ store, askHarness, canAsk, privacy, log = () =>
84
159
  */
85
160
  const browse = (typed) => {
86
161
  const raw = String(typed ?? "").trim() || "~";
87
- const expanded = raw === "~" ? os.homedir() : raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw;
88
- if (!path.isAbsolute(expanded)) return { ok: false, error: "type a full path, or start with ~" };
162
+ const expanded =
163
+ raw === "~"
164
+ ? os.homedir()
165
+ : raw.startsWith("~/")
166
+ ? path.join(os.homedir(), raw.slice(2))
167
+ : raw;
168
+ if (!path.isAbsolute(expanded))
169
+ return { ok: false, error: "type a full path, or start with ~" };
89
170
  // "~" or a path ending in "/" lists that folder; anything else lists the folders beside it that start with its name.
90
171
  const inside = raw === "~" || raw.endsWith("/");
91
172
  const dir = path.resolve(inside ? expanded : path.dirname(expanded));
92
173
  const start = inside ? "" : path.basename(expanded).toLowerCase();
93
- if (!isDir(dir)) return { ok: false, error: `${dir} is not a folder on this computer` };
174
+ if (!isDir(dir))
175
+ return { ok: false, error: `${dir} is not a folder on this computer` };
94
176
  let entries = [];
95
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (error) { return { ok: false, error: `${dir} can't be read: ${error.code || error.message}` }; }
177
+ try {
178
+ entries = fs.readdirSync(dir, { withFileTypes: true });
179
+ } catch (error) {
180
+ return {
181
+ ok: false,
182
+ error: `${dir} can't be read: ${error.code || error.message}`,
183
+ };
184
+ }
96
185
  const folders = entries
97
- .filter((entry) => (entry.isDirectory() || (entry.isSymbolicLink() && isDir(path.join(dir, entry.name)))) && entry.name.toLowerCase().startsWith(start) && (start.startsWith(".") || !entry.name.startsWith(".")))
186
+ .filter(
187
+ (entry) =>
188
+ (entry.isDirectory() ||
189
+ (entry.isSymbolicLink() && isDir(path.join(dir, entry.name)))) &&
190
+ entry.name.toLowerCase().startsWith(start) &&
191
+ (start.startsWith(".") || !entry.name.startsWith("."))
192
+ )
98
193
  .map((entry) => ({ path: path.join(dir, entry.name), name: entry.name }))
99
- .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })).slice(0, LISTED);
100
- return { ok: true, dir, parent: path.dirname(dir) === dir ? null : path.dirname(dir), folders };
194
+ .sort((a, b) =>
195
+ a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
196
+ )
197
+ .slice(0, LISTED);
198
+ return {
199
+ ok: true,
200
+ dir,
201
+ parent: path.dirname(dir) === dir ? null : path.dirname(dir),
202
+ folders,
203
+ };
101
204
  };
102
205
 
103
206
  // A pick is asked once per message: the composer shows it, and the thread it starts uses it.
@@ -106,33 +209,96 @@ export function createFolders({ store, askHarness, canAsk, privacy, log = () =>
106
209
  const key = JSON.stringify([message, places.map((place) => place.path)]);
107
210
  const cached = picks.get(key);
108
211
  if (cached && cached.at > Date.now() - PICK_TTL_MS) return cached.pending;
109
- const pending = askHarness("pick_folder", { message, folders: places.map(facts) }, null).then((reply) => {
110
- if (!reply?.ok) throw new Error(reply?.error || "Harness did not pick a folder");
111
- return places.some((place) => place.path === reply.folder) ? reply.folder : null;
112
- });
212
+ // How recently each folder's files changed, where Harness takes it (a daemon newer than the server would be refused).
213
+ const changed = lastChanged();
214
+ const ask = (offered) =>
215
+ askHarness(
216
+ "pick_folder",
217
+ {
218
+ message,
219
+ folders: offered.map((place) =>
220
+ changed
221
+ ? { ...facts(place), lastChanged: changed(place.path) }
222
+ : facts(place)
223
+ ),
224
+ },
225
+ null
226
+ );
227
+ const pending = (async () => {
228
+ let reply = await ask(places);
229
+ // Over Jev's limit: asked again about the most recently worked in half, halving until it fits. On the owner's
230
+ // sessions that half held the folder 99.5% of the time; the search model's closest half, 95.6% (eval folder-shortlist).
231
+ if (reply?.ok && reply.tooLarge) {
232
+ const ranked = await rank(message, places);
233
+ for (
234
+ let n = Math.ceil(places.length / 2);
235
+ reply?.ok && reply.tooLarge && n >= 2;
236
+ n = Math.floor(n / 2)
237
+ ) {
238
+ log(
239
+ `folder pick: ${places.length} folders are over Jev's limit; asking about the closest ${n}`
240
+ );
241
+ reply = await ask(ranked.slice(0, n));
242
+ }
243
+ }
244
+ if (!reply?.ok)
245
+ throw new Error(reply?.error || "Harness did not pick a folder");
246
+ const folder = places.some((place) => place.path === reply.folder)
247
+ ? reply.folder
248
+ : null;
249
+ // Jev's answers, so a wrong pick can be explained (not the message).
250
+ log(
251
+ `folder pick: ${folder ?? "none"}${reply.tooLarge ? " (over Jev's limit)" : ""} ${JSON.stringify(reply.judgments ?? null)}`
252
+ );
253
+ return folder;
254
+ })();
113
255
  picks.set(key, { at: Date.now(), pending });
114
- for (const [old, entry] of picks) if (entry.at <= Date.now() - PICK_TTL_MS || picks.size > 50) picks.delete(old);
256
+ for (const [old, entry] of picks)
257
+ if (entry.at <= Date.now() - PICK_TTL_MS || picks.size > 50)
258
+ picks.delete(old);
115
259
  pending.catch(() => picks.delete(key));
116
260
  return pending;
117
261
  };
118
262
 
119
263
  /**
120
- * The folder for a thread the person starts without choosing one: { folder, by }, by "message" (Jev's pick from what
121
- * they wrote), "last" (where one of its agents last worked) or null when there is none to suggest.
264
+ * The folder for a thread the person starts without choosing one: { folder, by, lastWorked }, by "message" (Jev's pick
265
+ * from what they wrote), "last" (where one of its agents last worked) or null when there is none to suggest; lastWorked
266
+ * is that last folder when Jev picked another, for the person to take instead.
122
267
  */
123
268
  const suggest = async (text, agents) => {
124
269
  const places = known();
125
- const message = String(text).replace(/@([a-z][a-z0-9-]*)(:\S+)?\s*/g, "").trim();
270
+ const coding = agents.filter((agent) => CODING.has(agent));
271
+ const last =
272
+ places
273
+ .filter((place) => place.lastWorked)
274
+ .find(
275
+ (place) =>
276
+ !coding.length ||
277
+ place.agents.some((agent) => coding.includes(agent))
278
+ )?.path ?? null;
279
+ const message = String(text)
280
+ .replace(/@([a-z][a-z0-9-]*)(:\S+)?\s*/g, "")
281
+ .trim();
126
282
  if (message && places.length && canAsk() && !privacy()) {
127
283
  try {
128
284
  const folder = await pickFromMessage(message, places);
129
- if (folder) return { folder, by: "message" };
130
- } catch (error) { log(`folder pick: ${error.message}`); }
285
+ if (folder)
286
+ return {
287
+ folder,
288
+ by: "message",
289
+ lastWorked: folder === last ? null : last,
290
+ };
291
+ } catch (error) {
292
+ log(`folder pick: ${error.message}`);
293
+ }
131
294
  }
132
- const coding = agents.filter((agent) => CODING.has(agent));
133
- const last = places.filter((place) => place.lastWorked).find((place) => !coding.length || place.agents.some((agent) => coding.includes(agent)));
134
- return last ? { folder: last.path, by: "last" } : { folder: null, by: null };
295
+ return { folder: last, by: last ? "last" : null, lastWorked: null };
135
296
  };
136
297
 
137
- return { known, browse, suggest };
298
+ // Offered to the composer without the session titles, which only Jev reads.
299
+ return {
300
+ known: () => known().map(({ titles, ...place }) => place),
301
+ browse,
302
+ suggest,
303
+ };
138
304
  }