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/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
|
|
12
|
-
// their home folder. With none chosen, Harness picks one from the message among those folders
|
|
13
|
-
// else the folder the agent last worked in. The home folder itself and hidden folders are never
|
|
14
|
-
// Harness's and T3's own copies
|
|
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) => {
|
|
27
|
-
|
|
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) => {
|
|
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))
|
|
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) => {
|
|
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
|
-
|
|
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,
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
agents: place.agents.map(nameOf),
|
|
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({
|
|
62
|
-
|
|
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
|
|
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) ?? {
|
|
69
|
-
|
|
70
|
-
|
|
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.
|
|
74
|
-
|
|
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())
|
|
78
|
-
|
|
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 =
|
|
88
|
-
|
|
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))
|
|
174
|
+
if (!isDir(dir))
|
|
175
|
+
return { ok: false, error: `${dir} is not a folder on this computer` };
|
|
94
176
|
let entries = [];
|
|
95
|
-
try {
|
|
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(
|
|
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) =>
|
|
100
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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)
|
|
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
|
|
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
|
|
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)
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|