tryharness 0.7.16 → 0.7.22
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 +9 -6
- package/assets/opencode-plugin.js +46 -66
- package/assets/pi-extension.ts +90 -90
- package/cli.mjs +31 -1
- package/lib/adapters.mjs +19 -11
- package/lib/agentnode.mjs +154 -0
- package/lib/cards.mjs +11 -3
- package/lib/coordinator.mjs +19 -9
- package/lib/daemon.mjs +234 -62
- package/lib/folders.mjs +138 -0
- package/lib/live.mjs +30 -5
- package/lib/mcp-stdio.mjs +10 -2
- package/lib/members.json +1 -1
- package/lib/models.mjs +233 -0
- package/lib/network.mjs +118 -0
- package/lib/nudge.mjs +6 -5
- package/lib/phones.mjs +5 -1
- package/lib/places.mjs +49 -1
- package/lib/runner.mjs +35 -6
- package/lib/store.mjs +32 -3
- package/lib/swarm.mjs +84 -20
- package/lib/t3.mjs +7 -4
- package/lib/worktrees.mjs +36 -4
- package/package.json +3 -3
package/lib/folders.mjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { mainCheckout } from "./worktrees.mjs";
|
|
6
|
+
import { nameOf } from "./members.mjs";
|
|
7
|
+
import { paths } from "./paths.mjs";
|
|
8
|
+
import { t3Projects } from "./places.mjs";
|
|
9
|
+
|
|
10
|
+
// 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).
|
|
16
|
+
|
|
17
|
+
const CODING = new Set(["claude-code", "codex", "pi", "opencode"]);
|
|
18
|
+
// Folders offered and sent to Jev: the most recently worked in. Browsing reaches any other.
|
|
19
|
+
const OFFERED = 60;
|
|
20
|
+
const LISTED = 200;
|
|
21
|
+
const README_MAX = 100_000;
|
|
22
|
+
const TOP_LEVEL_MAX = 2_000;
|
|
23
|
+
const PICK_TTL_MS = 10 * 60_000;
|
|
24
|
+
const README = /^readme(\.(md|markdown|txt|rst))?$/i;
|
|
25
|
+
|
|
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;
|
|
28
|
+
|
|
29
|
+
const within = (dir, root) => { const rel = root ? path.relative(root, dir) : ".."; return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); };
|
|
30
|
+
|
|
31
|
+
/** 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
|
+
function offerable(dir) {
|
|
33
|
+
const real = realDir(dir);
|
|
34
|
+
if (!real || !isDir(real) || within(real, realDir(paths.home))) return false;
|
|
35
|
+
const home = realDir(os.homedir()) ?? os.homedir();
|
|
36
|
+
const rel = path.relative(home, real);
|
|
37
|
+
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("."));
|
|
39
|
+
return !real.split(path.sep).some((part) => part.startsWith("."));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const git = (cwd, args) => { const found = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 2_000 }); return found.status === 0 ? (found.stdout || "").trim() : ""; };
|
|
43
|
+
|
|
44
|
+
/** What Jev reads of a folder: its name, repository, top level and README, whole. */
|
|
45
|
+
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
|
+
// A remote's address without any credentials in it.
|
|
52
|
+
const remote = git(place.path, ["remote", "get-url", "origin"]).replace(/\/\/[^@/]+@/, "//");
|
|
53
|
+
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,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
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 }. */
|
|
63
|
+
const known = () => {
|
|
64
|
+
const byPath = new Map();
|
|
65
|
+
const add = (dir, agent, at) => {
|
|
66
|
+
const where = realDir(dir) && realDir(mainCheckout(realDir(dir)));
|
|
67
|
+
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;
|
|
71
|
+
byPath.set(where, place);
|
|
72
|
+
};
|
|
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);
|
|
75
|
+
const projects = t3Projects();
|
|
76
|
+
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);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Any folder on the computer, from a path the person types: a folder's own folders, or the folders beside a partly
|
|
83
|
+
* typed name that start with it. "~" is the home folder. Hidden folders show once their name is typed.
|
|
84
|
+
*/
|
|
85
|
+
const browse = (typed) => {
|
|
86
|
+
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 ~" };
|
|
89
|
+
// "~" or a path ending in "/" lists that folder; anything else lists the folders beside it that start with its name.
|
|
90
|
+
const inside = raw === "~" || raw.endsWith("/");
|
|
91
|
+
const dir = path.resolve(inside ? expanded : path.dirname(expanded));
|
|
92
|
+
const start = inside ? "" : path.basename(expanded).toLowerCase();
|
|
93
|
+
if (!isDir(dir)) return { ok: false, error: `${dir} is not a folder on this computer` };
|
|
94
|
+
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}` }; }
|
|
96
|
+
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(".")))
|
|
98
|
+
.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 };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// A pick is asked once per message: the composer shows it, and the thread it starts uses it.
|
|
104
|
+
const picks = new Map();
|
|
105
|
+
const pickFromMessage = async (message, places) => {
|
|
106
|
+
const key = JSON.stringify([message, places.map((place) => place.path)]);
|
|
107
|
+
const cached = picks.get(key);
|
|
108
|
+
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
|
+
});
|
|
113
|
+
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);
|
|
115
|
+
pending.catch(() => picks.delete(key));
|
|
116
|
+
return pending;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
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.
|
|
122
|
+
*/
|
|
123
|
+
const suggest = async (text, agents) => {
|
|
124
|
+
const places = known();
|
|
125
|
+
const message = String(text).replace(/@([a-z][a-z0-9-]*)(:\S+)?\s*/g, "").trim();
|
|
126
|
+
if (message && places.length && canAsk() && !privacy()) {
|
|
127
|
+
try {
|
|
128
|
+
const folder = await pickFromMessage(message, places);
|
|
129
|
+
if (folder) return { folder, by: "message" };
|
|
130
|
+
} catch (error) { log(`folder pick: ${error.message}`); }
|
|
131
|
+
}
|
|
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 };
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return { known, browse, suggest };
|
|
138
|
+
}
|
package/lib/live.mjs
CHANGED
|
@@ -11,10 +11,10 @@ import { t3App, t3Link, t3Thread, t3Working } from "./places.mjs";
|
|
|
11
11
|
// The person's own coding sessions running on this computer right now, each with the app it runs in: a terminal, an
|
|
12
12
|
// editor or T3 Code, found by walking up the session's process tree to the first process inside an app bundle. A
|
|
13
13
|
// session in T3 Code also gets the link that opens its thread there (T3's own prompt links, t3code://threads/…).
|
|
14
|
-
//
|
|
15
|
-
// session runs, and how it opens there again, is places.mjs.
|
|
14
|
+
// Every coding agent here: its hooks (Claude Code, Codex), extension (pi) or plugin (OpenCode) run nudge.mjs, which is
|
|
15
|
+
// how a message from Harness reaches a session. Where each session runs, and how it opens there again, is places.mjs.
|
|
16
16
|
|
|
17
|
-
export const LIVE_AGENTS = ["claude-code", "codex"];
|
|
17
|
+
export const LIVE_AGENTS = ["claude-code", "codex", "pi", "opencode"];
|
|
18
18
|
const PLAIN_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
19
19
|
|
|
20
20
|
/** Every process: its parent, its terminal (/dev/ttys003, or null) and its executable, by pid. */
|
|
@@ -91,9 +91,10 @@ export function sessionOfProcess(agent, pid, recent = []) {
|
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
93
|
* The live sessions, newest first. `exclude` holds the pids of runs Harness started itself; `recent` is the session
|
|
94
|
-
* index (Codex keeps no per-process record, so its session is the newest indexed one in the process's folder)
|
|
94
|
+
* index (Codex keeps no per-process record, so its session is the newest indexed one in the process's folder);
|
|
95
|
+
* `reported` holds the sessions that named their own process ({ agent, sessionId, folder, pid, comm, at }).
|
|
95
96
|
*/
|
|
96
|
-
export function liveSessions({ exclude = new Set(), recent = [], table = processTable() } = {}) {
|
|
97
|
+
export function liveSessions({ exclude = new Set(), recent = [], table = processTable(), reported = [] } = {}) {
|
|
97
98
|
const sessions = [];
|
|
98
99
|
const records = path.join(historyRoots("claude-code")[0], "..", "sessions");
|
|
99
100
|
let files = [];
|
|
@@ -118,6 +119,13 @@ export function liveSessions({ exclude = new Set(), recent = [], table = process
|
|
|
118
119
|
if (t3) for (const row of t3Working("codex")) {
|
|
119
120
|
if (!sessions.some((session) => session.sessionId === row.sessionId)) sessions.push({ agent: "codex", sessionId: row.sessionId, folder: row.folder, pid: null, busy: true, at: row.at, host: { name: t3.name, app: t3.app }, t3Thread: row.thread });
|
|
120
121
|
}
|
|
122
|
+
// A session that said which process it runs in when it started (pi's extension, OpenCode's plugin, any agent's hook):
|
|
123
|
+
// running while that process is, under the same name, so a reused process id is not taken for it.
|
|
124
|
+
for (const row of reported) {
|
|
125
|
+
const now = table.get(row.pid);
|
|
126
|
+
if (!now || exclude.has(row.pid) || path.basename(now.comm) !== path.basename(String(row.comm || "")) || sessions.some((session) => session.sessionId === row.sessionId)) continue;
|
|
127
|
+
sessions.push({ agent: row.agent, sessionId: row.sessionId, folder: row.folder || null, pid: row.pid, busy: null, at: row.at || 0 });
|
|
128
|
+
}
|
|
121
129
|
return sessions.sort((a, b) => b.at - a.at).map((session) => {
|
|
122
130
|
const tty = table.get(session.pid)?.tty ?? null;
|
|
123
131
|
// Inside tmux the app is the terminal a tmux client shows the pane in.
|
|
@@ -170,6 +178,23 @@ export function sessionFile(agent, sessionId, folder, recent = []) {
|
|
|
170
178
|
return null;
|
|
171
179
|
}
|
|
172
180
|
|
|
181
|
+
/** OpenCode's session as the person reads it: OpenCode keeps sessions in its own database, so `opencode export` reads it. */
|
|
182
|
+
export function opencodeFeed(command, sessionId, limit = 80) {
|
|
183
|
+
if (!PLAIN_ID.test(String(sessionId))) return [];
|
|
184
|
+
const exported = spawnSync(command, ["export", String(sessionId)], { encoding: "utf8", timeout: 20_000, maxBuffer: 64 << 20, stdio: ["ignore", "pipe", "ignore"] });
|
|
185
|
+
let session;
|
|
186
|
+
try { session = JSON.parse(exported.stdout); } catch { return []; }
|
|
187
|
+
const items = [];
|
|
188
|
+
for (const message of Array.isArray(session?.messages) ? session.messages : []) {
|
|
189
|
+
const at = message.info?.time?.created ? new Date(message.info.time.created).toISOString() : null;
|
|
190
|
+
for (const part of Array.isArray(message.parts) ? message.parts : []) {
|
|
191
|
+
if (message.info?.role === "user") { if (part.type === "text" && !part.synthetic && !machinery(String(part.text || ""))) items.push({ kind: "you", text: String(part.text).trim(), at }); }
|
|
192
|
+
else items.push(...stepsOf({ type: part.type, part }).map((step) => ({ ...step, at })));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return items.slice(-limit);
|
|
196
|
+
}
|
|
197
|
+
|
|
173
198
|
/**
|
|
174
199
|
* The end of a session as the person reads it, oldest first: what they asked ("you"), what the agent said ("say") and
|
|
175
200
|
* the tools it used ("do"). Read from the last part of the session's own file, so a long session costs no more.
|
package/lib/mcp-stdio.mjs
CHANGED
|
@@ -38,6 +38,8 @@ const LOCAL_TOOLS = [
|
|
|
38
38
|
parent_id: { type: "string", description: "the board task this one continues" },
|
|
39
39
|
run_workspace: { type: "string", description: "the absolute directory a coding agent runs this task in, non-interactively, once the user authorizes it; the run's result and session come back on the task. Left out, a task for a coding agent runs in the folder you are working in" },
|
|
40
40
|
in_session: { type: "boolean", description: "leave the task for the target to pick up in an open session instead of a run" },
|
|
41
|
+
model: { type: "string", description: "only when the user asked for one: the model the target coding agent runs with, an id from list_models, or auto to let Harness pick; left out, the agent runs on the user's choice for it" },
|
|
42
|
+
effort: { type: "string", description: "with model: one of the effort levels list_models gives for that model" },
|
|
41
43
|
context_refs: { type: "array", items: { type: "object", properties: { kind: { type: "string" }, ref: { type: "string" }, note: { type: "string" } }, required: ["kind", "ref"], additionalProperties: false } },
|
|
42
44
|
},
|
|
43
45
|
required: ["objective"],
|
|
@@ -106,7 +108,7 @@ const LOCAL_TOOLS = [
|
|
|
106
108
|
},
|
|
107
109
|
{
|
|
108
110
|
name: "post_to_room",
|
|
109
|
-
description: "Post in a goal's room for the whole team: a finding, partial work, a question or what you did. To ask another agent for help, put its name in ask (claude-code, codex, pi, opencode, grok-bot, openclaw, hermes, bankr, quotient, muse, or harness to have the thread coordinated), say exactly what you need, then stop: you are woken again when it answers you by name. When you answer someone who asked you, name them (@codex) and they are woken. Any other @name, like a thanks, wakes no one. Post results and sources, not secrets: anything that looks like a key is held. For work someone else should do, post_task with parent_id set to the goal id.",
|
|
111
|
+
description: "Post in a goal's room for the whole team: a finding, partial work, a question or what you did. To ask another agent for help, put its name in ask (claude-code, codex, pi, opencode, grok-bot, openclaw, hermes, bankr, quotient, muse, or harness to have the thread coordinated), say exactly what you need, then stop: you are woken again when it answers you by name. When you answer someone who asked you, name them (@codex) and they are woken. To have a coding agent run on a particular model, write it after the name, @codex:gpt-6-luna or @codex:gpt-6-luna/high with an effort, from list_models. Any other @name, like a thanks, wakes no one. Post results and sources, not secrets: anything that looks like a key is held. For work someone else should do, post_task with parent_id set to the goal id.",
|
|
110
112
|
inputSchema: { type: "object", properties: { id: { type: "string", description: "the goal id" }, text: { type: "string" }, ask: { type: "array", items: { type: "string" }, description: "the agents you are asking for something; each is woken" }, share_with_guests: { type: "boolean", description: "also send it to the goal's invited guests (other people's agents); only what is fine for them to see" } }, required: ["id", "text"], additionalProperties: false },
|
|
111
113
|
},
|
|
112
114
|
{
|
|
@@ -123,6 +125,11 @@ const LOCAL_TOOLS = [
|
|
|
123
125
|
description: "Every other agent in the user's swarm, each with its card: what it does and cannot do, where it runs, its model, whether it can review without changing files, how it answers (some start at once, some are woken, some only pick work up later, and some are not set up yet, so work for them waits), how its work on the board went, what it holds now, and the user's own note about it. Use it to pick who should get the work before post_task or start_thread instead of guessing a name.",
|
|
124
126
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
125
127
|
},
|
|
128
|
+
{
|
|
129
|
+
name: "list_models",
|
|
130
|
+
description: "The models a coding agent on this machine (claude-code, codex, pi, opencode) can run with, as the agent itself lists them, with each model's effort levels, and the user's choice for it. Use it before naming a model in post_task (model, effort) or in a post (@codex:gpt-6-luna/high).",
|
|
131
|
+
inputSchema: { type: "object", properties: { agent: { type: "string", description: "claude-code, codex, pi or opencode; left out, every one installed here" } }, additionalProperties: false },
|
|
132
|
+
},
|
|
126
133
|
{
|
|
127
134
|
name: "start_thread",
|
|
128
135
|
description: "Start a shared thread with one or more of the user's agents, named with @ in the message (for example \"@codex @grok-bot compare the two parsers\"). The thread carries your words, not the user's: nothing in it runs until the user starts it. Answers arrive in the thread; read it with read_room and add to it with post_to_room. Give a folder only when the work concerns one.",
|
|
@@ -137,6 +144,7 @@ const LOCAL_TOOLS = [
|
|
|
137
144
|
|
|
138
145
|
const BOARD_CALLS = {
|
|
139
146
|
list_agents: () => ({ command: "board.swarm.agents" }),
|
|
147
|
+
list_models: (args) => ({ command: "board.swarm.models", target: args.agent }),
|
|
140
148
|
start_thread: (args) => ({ command: "board.swarm.start", text: args.text, folder: args.folder }),
|
|
141
149
|
stop_thread: (args) => ({ command: "board.swarm.stop", id: String(args.id || "") }),
|
|
142
150
|
read_room: (args) => ({ command: "board.room.read", id: String(args.id || ""), since: args.since }),
|
|
@@ -152,7 +160,7 @@ const BOARD_CALLS = {
|
|
|
152
160
|
list_tasks: (args) => ({ command: "board.list", status: args.status, all: args.all === true }),
|
|
153
161
|
get_task: (args) => ({ command: "board.get", id: String(args.id || "") }),
|
|
154
162
|
wallet: (args) => ({ command: "board.wallet", action: args.action, hash: args.hash, chain: args.chain }),
|
|
155
|
-
post_task: (args) => ({ command: "board.post", objective: args.objective, brief: args.brief, targetAgent: args.target_agent, targetAgents: args.target_agents, parentId: args.parent_id, contextRefs: args.context_refs, runWorkspace: args.run_workspace, inSession: args.in_session === true, posterCwd: process.cwd(), posterPid: process.ppid }),
|
|
163
|
+
post_task: (args) => ({ command: "board.post", objective: args.objective, brief: args.brief, targetAgent: args.target_agent, targetAgents: args.target_agents, parentId: args.parent_id, contextRefs: args.context_refs, runWorkspace: args.run_workspace, inSession: args.in_session === true, model: args.model, effort: args.effort, posterCwd: process.cwd(), posterPid: process.ppid }),
|
|
156
164
|
claim_task: (args) => ({ command: "board.claim", id: String(args.id || ""), expectedVersion: args.expected_version, leaseMinutes: args.lease_minutes, claimerPid: process.ppid }),
|
|
157
165
|
report_task: (args) => args.status === "answered"
|
|
158
166
|
? { command: "board.answer", id: String(args.id || ""), answer: args.result, expectedVersion: args.expected_version }
|
package/lib/members.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"version": 1,
|
|
3
3
|
"members": [
|
|
4
4
|
{ "id": "harness", "name": "Harness", "aliases": [], "road": "server", "home": "cloud", "runs": "server", "target": true, "wake": "run",
|
|
5
|
-
"about": "coordinates the thread and asks the person when only they can decide",
|
|
5
|
+
"about": "answers and researches with its own tools, and works in the person's browser while their Harness window is open; coordinates the thread and asks the person when only they can decide",
|
|
6
6
|
"where": "on Harness's server", "limits": "Does not change files or run commands on this computer." },
|
|
7
7
|
{ "id": "claude-code", "name": "Claude Code", "aliases": ["claude code"], "road": "local", "home": "machine", "runs": "machine", "target": true, "wake": "run",
|
|
8
8
|
"about": "reads and changes code in the thread's folder, runs commands and tests",
|
package/lib/models.mjs
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { agentEnvironment } from "./agentnode.mjs";
|
|
4
|
+
import { nameOf } from "./members.mjs";
|
|
5
|
+
import { trackHelper } from "./runner.mjs";
|
|
6
|
+
|
|
7
|
+
// Which model and effort each coding agent runs with, owned by the relay. Every agent lists its own models (Codex,
|
|
8
|
+
// Pi and OpenCode have a command for it; Claude Code has none, so its list is its own short names plus the Anthropic
|
|
9
|
+
// models the others list here). A run uses, first: a model named for it (`@codex:gpt-6-luna/high` in a post, or
|
|
10
|
+
// post_task's model); then the person's choice for the agent, which may be Auto (Harness's Jev picks from the list
|
|
11
|
+
// against the person's routing words); else nothing, and the agent runs on its own settings. Every pick is checked
|
|
12
|
+
// against the agent's list before a run starts.
|
|
13
|
+
|
|
14
|
+
const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; // claude --help
|
|
15
|
+
const PI_THINKING = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; // pi --help
|
|
16
|
+
const CLAUDE_NAMES = ["fable", "opus", "sonnet", "haiku"];
|
|
17
|
+
/**
|
|
18
|
+
* The routing words Auto goes by until the person writes their own. The first wording (owner, October 13), "The cheapest
|
|
19
|
+
* model that can do this work well; more effort only for hard work.", sent demanding work to cheap models (eval
|
|
20
|
+
* model-routing v1, 20261014-010225-80250750-357a).
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_ROUTING = "Match the model to the work: for easy work, the cheapest model that does it well; for demanding work, the most capable one. Pass over a model that keeps failing here. More effort only for hard work.";
|
|
23
|
+
|
|
24
|
+
const tokens = (text) => {
|
|
25
|
+
const match = /^(\d+(?:\.\d+)?)([KM]?)$/i.exec(String(text || ""));
|
|
26
|
+
return match ? Math.round(Number(match[1]) * ({ k: 1e3, m: 1e6 }[match[2].toLowerCase()] ?? 1)) : null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// One reader per agent's own list command, its output as the agent prints it today.
|
|
30
|
+
const LISTS = {
|
|
31
|
+
codex: { command: "codex", args: ["debug", "models"], parse: (out) => JSON.parse(out).models.map((entry) => ({
|
|
32
|
+
id: entry.slug, about: entry.description || null, efforts: (entry.supported_reasoning_levels || []).map((level) => level.effort),
|
|
33
|
+
defaultEffort: entry.default_reasoning_level || null, context: entry.context_window || null })) },
|
|
34
|
+
// provider model context max-out thinking images
|
|
35
|
+
pi: { command: "pi", args: ["--list-models"], parse: (out) => out.split("\n").map((line) => line.trim().split(/\s+/))
|
|
36
|
+
.filter((cells) => cells.length >= 5 && cells[0] !== "provider")
|
|
37
|
+
.map(([provider, model, context, , thinking]) => ({ id: `${provider}/${model}`, efforts: thinking === "yes" ? PI_THINKING : [], context: tokens(context) })) },
|
|
38
|
+
// Each model as "provider/id" on its own line, then its details as JSON.
|
|
39
|
+
opencode: { command: "opencode", args: ["models", "--verbose"], parse: (out) => {
|
|
40
|
+
const models = [];
|
|
41
|
+
let id = null, body = [];
|
|
42
|
+
const flush = () => {
|
|
43
|
+
if (!id) return;
|
|
44
|
+
const entry = JSON.parse(body.join("\n"));
|
|
45
|
+
models.push({ id, efforts: Object.keys(entry.variants || {}), context: entry.limit?.context ?? null,
|
|
46
|
+
...(entry.cost && Number.isFinite(entry.cost.input) ? { price: { inputUsdPerMillion: entry.cost.input, outputUsdPerMillion: entry.cost.output } } : {}) });
|
|
47
|
+
};
|
|
48
|
+
for (const line of out.split("\n")) {
|
|
49
|
+
if (/^[^\s{}"]+\/\S+$/.test(line)) { flush(); id = line.trim(); body = []; } else if (id) body.push(line);
|
|
50
|
+
}
|
|
51
|
+
flush();
|
|
52
|
+
return models;
|
|
53
|
+
} },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function capture(command, args) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const environment = agentEnvironment(command);
|
|
59
|
+
if (environment.error) { reject(new Error(environment.error)); return; }
|
|
60
|
+
let child;
|
|
61
|
+
try { child = spawn(command, args, { cwd: os.tmpdir(), env: environment.env }); } catch (error) { reject(error); return; }
|
|
62
|
+
child.stdin.end();
|
|
63
|
+
trackHelper(child);
|
|
64
|
+
let out = "", err = "";
|
|
65
|
+
child.stdout.on("data", (chunk) => { out += chunk; });
|
|
66
|
+
child.stderr.on("data", (chunk) => { err += chunk; });
|
|
67
|
+
child.on("error", reject);
|
|
68
|
+
child.on("close", (code) => code === 0 ? resolve(out) : reject(new Error(`${command} ${args.join(" ")} exited with code ${code}: ${err.trim().slice(0, 200)}`)));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const catalogKey = (agent) => `models:${agent}`;
|
|
73
|
+
/** An agent's last listing: { at, models, error? }, error when its list command last failed. */
|
|
74
|
+
export const listingOf = (store, agent) => { try { return JSON.parse(store.meta(catalogKey(agent)) || "null"); } catch { return null; } };
|
|
75
|
+
/** An agent's models as it last listed them: [{ id, about?, efforts, defaultEffort?, context?, price? }]. */
|
|
76
|
+
export const catalogOf = (store, agent) => listingOf(store, agent)?.models ?? [];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Asks each installed agent for its models, all at once so one slow agent holds up no other, and keeps the lists; an
|
|
80
|
+
* agent whose command fails keeps its last list and the error.
|
|
81
|
+
*/
|
|
82
|
+
export async function refreshCatalogs(store, agents, log = () => {}) {
|
|
83
|
+
await Promise.all(agents.filter((name) => LISTS[name]).map(async (agent) => {
|
|
84
|
+
try {
|
|
85
|
+
const models = LISTS[agent].parse(await capture(LISTS[agent].command, LISTS[agent].args)).filter((entry) => entry.id);
|
|
86
|
+
store.meta(catalogKey(agent), JSON.stringify({ at: new Date().toISOString(), models }));
|
|
87
|
+
} catch (error) {
|
|
88
|
+
log(`model list for ${agent}: ${error.message}`);
|
|
89
|
+
store.meta(catalogKey(agent), JSON.stringify({ ...(listingOf(store, agent) ?? { models: [] }), error: error.message.slice(0, 300) }));
|
|
90
|
+
}
|
|
91
|
+
}));
|
|
92
|
+
if (agents.includes("claude-code")) {
|
|
93
|
+
const anthropic = [...new Set(["pi", "opencode"].flatMap((other) => catalogOf(store, other)).map((entry) => entry.id).filter((id) => id.startsWith("anthropic/")).map((id) => id.slice("anthropic/".length)))];
|
|
94
|
+
const models = [...CLAUDE_NAMES.map((name) => ({ id: name, about: `Claude Code's name for its newest ${name[0].toUpperCase()}${name.slice(1)} model.`, efforts: CLAUDE_EFFORTS })),
|
|
95
|
+
...anthropic.sort().map((id) => ({ id, efforts: CLAUDE_EFFORTS }))];
|
|
96
|
+
store.meta(catalogKey("claude-code"), JSON.stringify({ at: new Date().toISOString(), models }));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One model in an agent's list, written as its id or, when only one model has it, the part after the provider. */
|
|
101
|
+
function findModel(store, agent, written) {
|
|
102
|
+
const models = catalogOf(store, agent);
|
|
103
|
+
if (!models.length) return { error: `the relay has no model list for ${nameOf(agent)} yet` };
|
|
104
|
+
const wanted = String(written || "").trim().toLowerCase();
|
|
105
|
+
const exact = models.find((entry) => entry.id.toLowerCase() === wanted);
|
|
106
|
+
if (exact) return { entry: exact };
|
|
107
|
+
const tail = models.filter((entry) => entry.id.toLowerCase().split("/").pop() === wanted);
|
|
108
|
+
if (tail.length === 1) return { entry: tail[0] };
|
|
109
|
+
return { error: tail.length ? `"${written}" could be ${tail.map((entry) => entry.id).join(" or ")}; name one` : `"${written}" is not one of ${nameOf(agent)}'s models (npx tryharness agents models ${agent})` };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A model and effort for an agent, checked against its list: { ok, pick } with pick { model, effort } or { auto: true }. */
|
|
113
|
+
export function resolvePick(store, agent, model, effort = null) {
|
|
114
|
+
if (String(model || "").trim().toLowerCase() === "auto") return { ok: true, pick: { auto: true } };
|
|
115
|
+
const found = findModel(store, agent, model);
|
|
116
|
+
if (found.error) return { ok: false, error: found.error };
|
|
117
|
+
const level = effort ? String(effort).trim().toLowerCase() : null;
|
|
118
|
+
if (level && !found.entry.efforts.includes(level)) {
|
|
119
|
+
return { ok: false, error: found.entry.efforts.length ? `${found.entry.id} takes effort ${found.entry.efforts.join(", ")}, not "${effort}"` : `${found.entry.id} has no effort setting in ${nameOf(agent)}` };
|
|
120
|
+
}
|
|
121
|
+
return { ok: true, pick: { model: found.entry.id, effort: level } };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A pick as written after an agent's name in a post, "model", "model/effort" or "auto". */
|
|
125
|
+
export function resolveSpec(store, agent, spec) {
|
|
126
|
+
const whole = resolvePick(store, agent, spec);
|
|
127
|
+
if (whole.ok) return whole;
|
|
128
|
+
const cut = spec.lastIndexOf("/");
|
|
129
|
+
if (cut > 0) { const split = resolvePick(store, agent, spec.slice(0, cut), spec.slice(cut + 1)); if (split.ok) return split; }
|
|
130
|
+
return whole;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The agents whose model the relay sets. */
|
|
134
|
+
export const PICKABLE = new Set(["claude-code", ...Object.keys(LISTS)]);
|
|
135
|
+
const PICKED = /(?:^|\s)@([a-z][a-z0-9-]*):([^\s]+)/g;
|
|
136
|
+
/** The picks a post names, `@codex:gpt-6-luna/high`, by agent: { picks: Map, error? }. Only agents in `agents` count. */
|
|
137
|
+
export function picksIn(store, text, agents) {
|
|
138
|
+
const picks = new Map();
|
|
139
|
+
for (const match of String(text || "").matchAll(PICKED)) {
|
|
140
|
+
const agent = match[1] === "claude" ? "claude-code" : match[1];
|
|
141
|
+
if (!agents.includes(agent) || !PICKABLE.has(agent)) continue;
|
|
142
|
+
const spec = match[2].replace(/[.,;:!?)]+$/, "");
|
|
143
|
+
const resolved = resolveSpec(store, agent, spec);
|
|
144
|
+
if (!resolved.ok) return { picks, error: `@${match[1]}:${spec}: ${resolved.error}` };
|
|
145
|
+
picks.set(agent, resolved.pick);
|
|
146
|
+
}
|
|
147
|
+
return { picks };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const choiceKey = (agent) => `model-choice:${agent}`;
|
|
151
|
+
/** The person's choice for an agent: { model, effort }, { auto: true }, or null for the agent's own settings. */
|
|
152
|
+
export const choiceOf = (store, agent) => { try { return JSON.parse(store.meta(choiceKey(agent)) || "null"); } catch { return null; } };
|
|
153
|
+
export function setChoice(store, agent, model, effort = null) {
|
|
154
|
+
if (!model || String(model).toLowerCase() === "default") { store.meta(choiceKey(agent), ""); return { ok: true, choice: null }; }
|
|
155
|
+
const resolved = resolvePick(store, agent, model, effort);
|
|
156
|
+
if (!resolved.ok) return resolved;
|
|
157
|
+
store.meta(choiceKey(agent), JSON.stringify(resolved.pick));
|
|
158
|
+
return { ok: true, choice: resolved.pick };
|
|
159
|
+
}
|
|
160
|
+
export const routingOf = (store) => store.meta("routing-note") || null;
|
|
161
|
+
export const setRouting = (store, text) => { const note = String(text || "").replace(/\s+/g, " ").trim().slice(0, 1_000); store.meta("routing-note", note); return note || null; };
|
|
162
|
+
|
|
163
|
+
/** A pick in words: "gpt-6-luna, effort high", "Auto". */
|
|
164
|
+
export const describePick = (pick) => !pick ? null : pick.auto ? "Auto" : [pick.model, pick.effort && `effort ${pick.effort}`].filter(Boolean).join(", ");
|
|
165
|
+
|
|
166
|
+
/** The pick named for one run (a post, post_task), kept with the run until it starts. */
|
|
167
|
+
export const setRunPick = (store, runId, pick, by) => store.meta(`model:${runId}`, JSON.stringify({ ...pick, by }));
|
|
168
|
+
const runPick = (store, runId) => { try { return JSON.parse(store.meta(`model:${runId}`) || "null"); } catch { return null; } };
|
|
169
|
+
|
|
170
|
+
const median = (values) => { const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); return sorted.length ? sorted[Math.floor(sorted.length / 2)] : null; };
|
|
171
|
+
/** How each of an agent's models did on this board: finished, failed, and the median minutes and dollars of a finished run. */
|
|
172
|
+
function recordByModel(store, agent) {
|
|
173
|
+
const byModel = new Map();
|
|
174
|
+
for (const row of store.modelWork(agent)) {
|
|
175
|
+
const entry = byModel.get(row.model) ?? { done: 0, failed: 0, minutes: [], cost: [] };
|
|
176
|
+
if (row.status === "done") {
|
|
177
|
+
entry.done++;
|
|
178
|
+
entry.minutes.push((Date.parse(row.reported_at) - Date.parse(row.started_at)) / 60_000);
|
|
179
|
+
entry.cost.push(Number(row.usage?.cost_usd));
|
|
180
|
+
} else if (row.status === "failed") entry.failed++;
|
|
181
|
+
byModel.set(row.model, entry);
|
|
182
|
+
}
|
|
183
|
+
return (model) => {
|
|
184
|
+
const entry = byModel.get(model);
|
|
185
|
+
if (!entry) return null;
|
|
186
|
+
const minutes = median(entry.minutes), cost = median(entry.cost);
|
|
187
|
+
return { done: entry.done, failed: entry.failed, medianMinutes: minutes === null ? null : Math.round(minutes * 10) / 10, medianCostUsd: cost === null ? null : Math.round(cost * 10_000) / 10_000 };
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** What Auto sends Harness for one run (src/server/swarm/route.ts): the work, the person's words, every listed model with its record here. */
|
|
192
|
+
export const routeInput = ({ agent, work, transcript, routing, ownModel, models, record }) => ({
|
|
193
|
+
agent, name: nameOf(agent), work, transcript, routing: routing ?? DEFAULT_ROUTING, ownModel,
|
|
194
|
+
models: models.slice(0, 250).map((entry) => ({ ...entry, record: record(entry.id) })),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The model and effort a run starts with: { model, effort, by, note? }, or null for the agent's own settings. `by` says
|
|
199
|
+
* who chose: "you" (the person's post), another agent's id, "your choice" (the person's setting) or "auto".
|
|
200
|
+
* Auto asks Harness only when the person allows it (not the privacy setting) and Harness can answer.
|
|
201
|
+
*/
|
|
202
|
+
export function createModelRouter({ store, askHarness, canAsk, privacy, ownModel, workOf, log = () => {} }) {
|
|
203
|
+
return async function modelForRun(task, agent) {
|
|
204
|
+
const named = runPick(store, task.id);
|
|
205
|
+
const pick = named ?? (choiceOf(store, agent) ? { ...choiceOf(store, agent), by: "your choice" } : null);
|
|
206
|
+
if (!pick) return null;
|
|
207
|
+
const by = named ? (named.by === "user" ? "you" : named.by) : pick.by;
|
|
208
|
+
if (!pick.auto) {
|
|
209
|
+
// The list may have changed since the pick: a model the agent no longer lists runs on its own settings, said so.
|
|
210
|
+
const checked = resolvePick(store, agent, pick.model, pick.effort);
|
|
211
|
+
return checked.ok ? { ...checked.pick, by } : { model: null, effort: null, by, note: `${checked.error}; it ran on its own settings` };
|
|
212
|
+
}
|
|
213
|
+
const fallback = (note) => ({ model: null, effort: null, by: "auto", note });
|
|
214
|
+
if (privacy()) return fallback("Auto is off on the privacy setting; it ran on its own settings");
|
|
215
|
+
if (!canAsk()) return fallback("Harness could not pick a model just now; it ran on its own settings");
|
|
216
|
+
const models = catalogOf(store, agent);
|
|
217
|
+
if (!models.length) return fallback(`the relay has no model list for ${nameOf(agent)} yet; it ran on its own settings`);
|
|
218
|
+
try {
|
|
219
|
+
const reply = await askHarness("route_model", routeInput({ agent, ...workOf(task), routing: routingOf(store), ownModel: ownModel(agent), models, record: recordByModel(store, agent) }), null);
|
|
220
|
+
if (!reply.ok) throw new Error(reply.error || "Harness did not pick a model");
|
|
221
|
+
// The bridge hands back Harness's answer with its fields beside ok.
|
|
222
|
+
const picked = reply;
|
|
223
|
+
if (!picked.model) return fallback("Harness picked no model; it ran on its own settings");
|
|
224
|
+
const checked = resolvePick(store, agent, picked.model, picked.effort && models.find((entry) => entry.id === picked.model)?.efforts.includes(picked.effort) ? picked.effort : null);
|
|
225
|
+
if (!checked.ok) return fallback(`Auto picked ${picked.model}, which ${nameOf(agent)} does not list; it ran on its own settings`);
|
|
226
|
+
// Jev's confidence in the pick stays with the run, to see later how sure the picks that worked were.
|
|
227
|
+
return { ...checked.pick, by: "auto", judgments: picked.judgments ?? null, estimatedMicroUsd: picked.estimatedMicroUsd ?? null };
|
|
228
|
+
} catch (error) {
|
|
229
|
+
log(`auto model for ${task.id}: ${error.message}`);
|
|
230
|
+
return fallback(`Harness could not pick a model (${error.message}); it ran on its own settings`);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
package/lib/network.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Swarms talking to swarms, on the person's computer: the one list every surface shows (the buddy's
|
|
2
|
+
// Swarms tab, the dashboard, the PiP), built here from what Harness holds (who asked whom, and the
|
|
3
|
+
// answer) and what only this computer holds (each thread's words, held replies, what the person has
|
|
4
|
+
// read). A sealed request's subject is opened here, never at Harness. Only the person ("user") calls
|
|
5
|
+
// these; another agent works a thread through board.thread.* on the task it holds.
|
|
6
|
+
|
|
7
|
+
const SEEN = (id) => `relay_seen:${id}`;
|
|
8
|
+
const AUTO_SEND_DEFAULT = "relay_auto_send_default";
|
|
9
|
+
const DECISIONS = new Set(["accept", "decline", "block"]);
|
|
10
|
+
const handleOf = (identity) => String(identity || "").split("/")[0].toLowerCase();
|
|
11
|
+
const agentOf = (identity) => String(identity || "").split("/")[1] || "";
|
|
12
|
+
|
|
13
|
+
export function createNetwork({ store, askHarness, relayRequest, openRelayRequest, fingerprint, myKey }) {
|
|
14
|
+
// Harness answers for the server's half; a refusal comes back as a plain error the person can read.
|
|
15
|
+
const harness = async (input) => {
|
|
16
|
+
const answer = await askHarness("relay_network", input);
|
|
17
|
+
if (!answer.ok) throw new Error(answer.error || "Harness refused the request");
|
|
18
|
+
const { ok: _ok, ...rest } = answer;
|
|
19
|
+
return rest;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// One row per request or thread, grouped by what it needs: the person (a request to answer, a held reply,
|
|
23
|
+
// a message from a person), nothing (open), the other side (a request not answered yet), or nobody (ended).
|
|
24
|
+
const conversations = async () => {
|
|
25
|
+
const server = await harness({ op: "threads" }).catch(() => null);
|
|
26
|
+
const local = new Map(store.relayThreads().map((row) => [row.task_id, row]));
|
|
27
|
+
const items = [];
|
|
28
|
+
for (const thread of server?.threads ?? []) {
|
|
29
|
+
const here = local.get(thread.id) || null;
|
|
30
|
+
local.delete(thread.id);
|
|
31
|
+
const peer = thread.role === "asker" ? thread.target : thread.asker;
|
|
32
|
+
let about = thread.about || here?.objective || "";
|
|
33
|
+
let keyPrint = null;
|
|
34
|
+
// A sealed request's subject: opened on this computer, with the other computer's key to compare.
|
|
35
|
+
if (thread.sealed && !about) {
|
|
36
|
+
const opened = await openRelayRequest({ sealed: thread.sealed, from: `${peer.handle}/${peer.agent}` }).catch(() => null);
|
|
37
|
+
if (opened) { about = opened.about; keyPrint = opened.fingerprint; }
|
|
38
|
+
}
|
|
39
|
+
items.push(item({ id: thread.id, role: thread.role, kind: thread.kind || (thread.target?.agent === "person" ? "person" : "agent"), status: thread.status, about,
|
|
40
|
+
peer: { handle: peer.handle, agent: peer.agent }, mine: thread.role === "asker" ? thread.asker.agent : thread.target.agent,
|
|
41
|
+
askerSince: thread.role === "target" ? thread.asker.since : null, sealed: Boolean(thread.sealed), fingerprint: keyPrint, createdAt: thread.createdAt, here }));
|
|
42
|
+
}
|
|
43
|
+
// Threads this computer holds that Harness no longer lists (older ones, or Harness out of reach).
|
|
44
|
+
for (const here of local.values()) {
|
|
45
|
+
items.push(item({ id: here.task_id, role: here.role, kind: agentOf(here.with_agent) === "person" ? "person" : "agent", status: here.closed_at ? "closed" : "accepted", about: here.objective,
|
|
46
|
+
peer: { handle: handleOf(here.with_agent), agent: agentOf(here.with_agent) }, mine: here.target_agent === "user" ? "person" : here.target_agent,
|
|
47
|
+
askerSince: null, sealed: false, fingerprint: null, createdAt: here.opened_at, here }));
|
|
48
|
+
}
|
|
49
|
+
items.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
50
|
+
return { ok: true, connected: Boolean(server), items, needsYou: items.filter((entry) => entry.group === "needs_you").length };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const item = ({ id, role, kind, status, about, peer, mine, askerSince, sealed, fingerprint: keyPrint, createdAt, here }) => {
|
|
54
|
+
const seen = Number(store.meta(SEEN(id)) || 0);
|
|
55
|
+
const thread = here ? {
|
|
56
|
+
open: !here.closed_at, autoSend: here.auto_send, held: here.held, unread: here.last_in_seq > seen, messages: here.messages, outcome: here.outcome,
|
|
57
|
+
last: here.last ? { direction: here.last.direction, sender: here.last.sender, text: String(here.last.text || "").slice(0, 160), state: here.last.state, at: here.last.created_at } : null,
|
|
58
|
+
} : null;
|
|
59
|
+
const reason = role === "target" && status === "requested" ? "request"
|
|
60
|
+
: thread?.open && thread.held ? "held"
|
|
61
|
+
: thread?.open && kind === "person" && thread.unread ? "message"
|
|
62
|
+
: null;
|
|
63
|
+
const group = reason ? "needs_you"
|
|
64
|
+
: thread ? (thread.open ? "open" : "ended")
|
|
65
|
+
: status === "requested" ? "waiting"
|
|
66
|
+
: status === "accepted" || status === "accepted_pending" ? "open"
|
|
67
|
+
: "ended";
|
|
68
|
+
return { id, role, kind, status, about, peer, mine, askerSince, sealed, fingerprint: keyPrint, group, reason, thread, createdAt, updatedAt: here?.updated_at || createdAt };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// A refusal from Harness or the other computer comes back as the answer, in its own words.
|
|
72
|
+
const answered = (promise) => ({ pending: promise.catch((error) => ({ ok: false, error: error.message })) });
|
|
73
|
+
const command = (request, agent) => {
|
|
74
|
+
if (agent !== "user") return { ok: false, error: "only the person talks to other swarms from here" };
|
|
75
|
+
const id = String(request.id || "");
|
|
76
|
+
switch (request.command) {
|
|
77
|
+
case "board.relay.list":
|
|
78
|
+
return answered(conversations());
|
|
79
|
+
// The server's half of Swarms (card, search, contacts, blocks): what the dashboard asks Harness directly.
|
|
80
|
+
case "board.relay.network": {
|
|
81
|
+
const { command: _command, agent: _agent, ...input } = request;
|
|
82
|
+
if (input.op === "decide" || input.op === "threads") return { ok: false, error: "use board.relay.decide and board.relay.list" };
|
|
83
|
+
return answered(harness(input).then((answer) => ({ ok: true, ...answer })));
|
|
84
|
+
}
|
|
85
|
+
case "board.relay.decide":
|
|
86
|
+
if (!DECISIONS.has(String(request.decision))) return { ok: false, error: "decision is accept, decline or block" };
|
|
87
|
+
return answered(harness({ op: "decide", id, decision: String(request.decision) }).then((answer) => ({ ok: true, ...answer })));
|
|
88
|
+
// A request from this computer: to one of their listed agents from one of the person's, or to the person themselves.
|
|
89
|
+
case "board.relay.request": {
|
|
90
|
+
const handle = String(request.handle || "").toLowerCase().replace(/^@/, "");
|
|
91
|
+
const toAgent = String(request.toAgent || "person");
|
|
92
|
+
const fromAgent = toAgent === "person" ? "person" : String(request.fromAgent || "");
|
|
93
|
+
return answered(relayRequest({ to: `${handle}/${toAgent}`, fromAgent, about: request.about, brief: request.brief, refs: request.refs }).then((made) => ({ ok: true, ...made })));
|
|
94
|
+
}
|
|
95
|
+
case "board.relay.seen": {
|
|
96
|
+
const thread = store.getThread(id);
|
|
97
|
+
if (!thread) return { ok: false, error: "no thread on this task" };
|
|
98
|
+
const last = store.threadMessages(id).filter((message) => message.direction === "in").at(-1);
|
|
99
|
+
store.meta(SEEN(id), String(last?.seq ?? 0));
|
|
100
|
+
return { ok: true };
|
|
101
|
+
}
|
|
102
|
+
// Whether replies the person's agents write wait for the person on a new thread (the default) or send on their own.
|
|
103
|
+
case "board.relay.defaults":
|
|
104
|
+
if (typeof request.autoSend === "boolean") store.meta(AUTO_SEND_DEFAULT, request.autoSend ? "1" : "0");
|
|
105
|
+
return { ok: true, autoSend: store.meta(AUTO_SEND_DEFAULT) === "1" };
|
|
106
|
+
case "board.relay.peers":
|
|
107
|
+
return answered(Promise.all(store.relayPeers().map(async (peer) => ({ handle: peer.handle, pinnedAt: peer.pinned_at, fingerprint: await fingerprint(peer.public_key) })))
|
|
108
|
+
.then(async (peers) => ({ ok: true, peers, mine: myKey() ? await fingerprint(myKey()) : null })));
|
|
109
|
+
default:
|
|
110
|
+
return { ok: false, error: `unknown command ${request.command}` };
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** A thread just opened on this computer starts with the person's default for their agents' replies. */
|
|
115
|
+
const opened = (taskId) => { if (store.meta(AUTO_SEND_DEFAULT) === "1") store.setAutoSend(taskId, true); };
|
|
116
|
+
|
|
117
|
+
return { command, opened };
|
|
118
|
+
}
|