tryharness 0.7.6 → 0.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/opencode-plugin.js +11 -0
- package/assets/pi-extension.ts +8 -0
- package/cli.mjs +20 -17
- package/lib/activity.mjs +191 -0
- package/lib/adapters.mjs +12 -6
- package/lib/claudemem.mjs +90 -0
- package/lib/coordinator.mjs +7 -3
- package/lib/daemon.mjs +590 -68
- package/lib/live.mjs +205 -0
- package/lib/mcp-stdio.mjs +24 -6
- package/lib/members.json +31 -0
- package/lib/members.mjs +47 -0
- package/lib/nudge.mjs +55 -5
- package/lib/onboard.mjs +131 -0
- package/lib/phones.mjs +9 -5
- package/lib/places.mjs +297 -0
- package/lib/runner.mjs +18 -2
- package/lib/store.mjs +49 -4
- package/lib/swarm.mjs +64 -9
- package/lib/wakes.mjs +196 -0
- package/package.json +3 -3
package/lib/places.mjs
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
// Where a coding session runs, and how it opens there again. A session's place comes from its own environment: the
|
|
8
|
+
// Harness piece inside the agent sends it when the session starts (the Claude Code and Codex hooks, nudge.mjs; the
|
|
9
|
+
// opencode plugin; the pi extension), or the daemon reads it from the running process when nothing did. Terminals and
|
|
10
|
+
// editors put their own ids into everything they start, the same on every OS (TERM_PROGRAM, TMUX_PANE, WEZTERM_PANE,
|
|
11
|
+
// KITTY_WINDOW_ID, VSCODE_GIT_ASKPASS_NODE: the editor's own install); macOS adds the app (__CFBundleIdentifier).
|
|
12
|
+
//
|
|
13
|
+
// Any other agent (a custom harness) does the same with one line to the daemon's socket as a session starts:
|
|
14
|
+
// {"command":"board.live.place","agent":"<its id>","sessionId":"<id>","folder":"<absolute path>","pids":[<its pid>],
|
|
15
|
+
// "env":{<its environment>},"reopen":{"link":"http://127.0.0.1:<port>/…","command":"<what a person types to resume>"}}
|
|
16
|
+
// `reopen` is optional: a page it serves on this computer opens directly; a command is only ever copied for the person
|
|
17
|
+
// to paste, never run. Only PLACE_ENV is kept of `env`.
|
|
18
|
+
|
|
19
|
+
const PS = ["/bin/ps", "/usr/bin/ps"].find((file) => fs.existsSync(file)) || "ps";
|
|
20
|
+
|
|
21
|
+
/** The names a place is read from: nothing else of a session's environment is kept. */
|
|
22
|
+
export const PLACE_ENV = ["__CFBundleIdentifier", "TERM_PROGRAM", "TMUX", "TMUX_PANE", "WEZTERM_PANE", "KITTY_WINDOW_ID", "KITTY_LISTEN_ON", "WT_SESSION", "VSCODE_GIT_ASKPASS_NODE", "CLAUDE_CODE_ENTRYPOINT", "T3CODE_CLI_PATH"];
|
|
23
|
+
|
|
24
|
+
export function placeEnv(env) {
|
|
25
|
+
const picked = {};
|
|
26
|
+
for (const name of PLACE_ENV) if (typeof env?.[name] === "string" && env[name] && env[name].length <= 1024) picked[name] = env[name];
|
|
27
|
+
return picked;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A running process's environment, as far as this OS shows another process's (Linux /proc, macOS ps); {} elsewhere. */
|
|
31
|
+
export function processEnv(pid) {
|
|
32
|
+
if (!Number.isInteger(pid) || pid <= 1) return {};
|
|
33
|
+
if (process.platform === "linux") {
|
|
34
|
+
try {
|
|
35
|
+
return placeEnv(Object.fromEntries(fs.readFileSync(`/proc/${pid}/environ`, "utf8").split("\0").filter((entry) => entry.includes("="))
|
|
36
|
+
.map((entry) => [entry.slice(0, entry.indexOf("=")), entry.slice(entry.indexOf("=") + 1)])));
|
|
37
|
+
} catch { return {}; }
|
|
38
|
+
}
|
|
39
|
+
if (process.platform !== "darwin") return {};
|
|
40
|
+
// `ps eww` prints the command, then each variable as NAME=value, space separated (values may hold spaces).
|
|
41
|
+
const line = spawnSync(PS, ["eww", "-o", "command=", "-p", String(pid)], { encoding: "utf8", timeout: 3_000 }).stdout || "";
|
|
42
|
+
const env = {};
|
|
43
|
+
for (const name of PLACE_ENV) {
|
|
44
|
+
const found = line.match(new RegExp(`(?:^|\\s)${name}=(.*?)(?=\\s[A-Za-z_][A-Za-z0-9_]*=|\\s*$)`));
|
|
45
|
+
if (found?.[1]) env[name] = found[1];
|
|
46
|
+
}
|
|
47
|
+
return placeEnv(env);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const appDirs = () => (process.env.HARNESS_APP_DIRS ? process.env.HARNESS_APP_DIRS.split(path.delimiter) : ["/Applications", path.join(os.homedir(), "Applications")]);
|
|
51
|
+
|
|
52
|
+
// ── Editors built on VS Code ──────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/** Each one's app, link scheme, command-line tool, settings folder and extensions folder. */
|
|
55
|
+
const EDITORS = [
|
|
56
|
+
{ id: "cursor", name: "Cursor", bundle: "com.todesktop.230313mzl4w4u92", match: /cursor/i, scheme: "cursor", cli: "cursor", data: "Cursor", extensions: ".cursor" },
|
|
57
|
+
{ id: "windsurf", name: "Windsurf", bundle: "com.exafunction.windsurf", match: /windsurf/i, scheme: "windsurf", cli: "windsurf", data: "Windsurf", extensions: ".windsurf" },
|
|
58
|
+
{ id: "vscode-insiders", name: "Visual Studio Code - Insiders", bundle: "com.microsoft.VSCodeInsiders", match: /insiders/i, scheme: "vscode-insiders", cli: "code-insiders", data: "Code - Insiders", extensions: ".vscode-insiders" },
|
|
59
|
+
{ id: "vscode", name: "Visual Studio Code", bundle: "com.microsoft.VSCode", match: /visual studio code|microsoft vs code|[\\/]code[\\/]/i, scheme: "vscode", cli: "code", data: "Code", extensions: ".vscode" },
|
|
60
|
+
];
|
|
61
|
+
export const EDITOR_IDS = EDITORS.map((editor) => editor.id);
|
|
62
|
+
const editorById = (id) => EDITORS.find((editor) => editor.id === id) ?? null;
|
|
63
|
+
export const editorName = (id) => editorById(id)?.name ?? null;
|
|
64
|
+
|
|
65
|
+
/** An editor's install with its command-line tool, from a path inside it (or where its installer puts it). */
|
|
66
|
+
export function editorInstall(editorId, hint = "") {
|
|
67
|
+
const editor = editorById(editorId);
|
|
68
|
+
if (!editor) return null;
|
|
69
|
+
const tries = [];
|
|
70
|
+
const app = String(hint).match(/^(.*?\.app)(?:\/|$)/)?.[1];
|
|
71
|
+
if (app) tries.push({ app, cli: path.join(app, "Contents", "Resources", "app", "bin", editor.cli) });
|
|
72
|
+
// Linux and Windows: the editor's executable sits beside its bin folder.
|
|
73
|
+
else if (hint && path.isAbsolute(hint)) tries.push({ app: path.dirname(hint), cli: path.join(path.dirname(hint), "bin", editor.cli + (process.platform === "win32" ? ".cmd" : "")) });
|
|
74
|
+
for (const dir of appDirs()) {
|
|
75
|
+
const at = path.join(dir, `${editor.name}.app`);
|
|
76
|
+
tries.push({ app: at, cli: path.join(at, "Contents", "Resources", "app", "bin", editor.cli) });
|
|
77
|
+
}
|
|
78
|
+
return tries.find((entry) => fs.existsSync(entry.cli)) ?? null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function editorData(editor) {
|
|
82
|
+
if (process.env.HARNESS_EDITOR_ROOT) return path.join(process.env.HARNESS_EDITOR_ROOT, editor.data);
|
|
83
|
+
if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support", editor.data);
|
|
84
|
+
if (process.platform === "win32") return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), editor.data);
|
|
85
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), editor.data);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Whether an editor has an extension installed (and not marked for removal). */
|
|
89
|
+
export function editorHas(editorId, extension) {
|
|
90
|
+
const editor = editorById(editorId);
|
|
91
|
+
if (!editor) return false;
|
|
92
|
+
const dir = path.join(process.env.HARNESS_EDITOR_ROOT || os.homedir(), editor.extensions, "extensions");
|
|
93
|
+
let removed = {};
|
|
94
|
+
try { removed = JSON.parse(fs.readFileSync(path.join(dir, ".obsolete"), "utf8")); } catch {}
|
|
95
|
+
try { return fs.readdirSync(dir).some((name) => name.toLowerCase().startsWith(`${extension}-`) && !removed[name]); } catch { return false; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The folder the person opens in an editor that holds `folder`: the deepest one the editor has a record of (it keeps one
|
|
100
|
+
* per folder it opened, User/workspaceStorage/<id>/workspace.json), else the folder itself.
|
|
101
|
+
*/
|
|
102
|
+
export function editorFolder(editorId, folder) {
|
|
103
|
+
const editor = editorById(editorId);
|
|
104
|
+
if (!editor || !folder) return folder || null;
|
|
105
|
+
const root = path.join(editorData(editor), "User", "workspaceStorage");
|
|
106
|
+
let best = null;
|
|
107
|
+
let dirs = [];
|
|
108
|
+
try { dirs = fs.readdirSync(root); } catch {}
|
|
109
|
+
for (const dir of dirs) {
|
|
110
|
+
let opened;
|
|
111
|
+
try { opened = fileURLToPath(JSON.parse(fs.readFileSync(path.join(root, dir, "workspace.json"), "utf8")).folder); } catch { continue; }
|
|
112
|
+
if ((folder === opened || folder.startsWith(opened + path.sep)) && (!best || opened.length > best.length)) best = opened;
|
|
113
|
+
}
|
|
114
|
+
return best ?? folder;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The editor's window on a folder comes up, by the editor's own command-line tool: the window that has the folder open,
|
|
119
|
+
* else a new one on it. No link, so the editor asks nothing; its tool brings itself forward.
|
|
120
|
+
*/
|
|
121
|
+
export function openEditorFolder(place, folder) {
|
|
122
|
+
if (!place?.cli || !folder || !path.isAbsolute(folder) || !fs.existsSync(place.cli)) return false;
|
|
123
|
+
const env = { ...process.env };
|
|
124
|
+
// Not inside one of its terminals: a tool started there would replace that window's folder.
|
|
125
|
+
delete env.VSCODE_IPC_HOOK_CLI;
|
|
126
|
+
delete env.ELECTRON_RUN_AS_NODE;
|
|
127
|
+
const windows = process.platform === "win32";
|
|
128
|
+
const child = spawn(place.cli, [windows ? `"${folder}"` : folder], { env, detached: !windows, stdio: "ignore", shell: windows });
|
|
129
|
+
child.on("error", () => {});
|
|
130
|
+
child.unref();
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** How many times the person started a command in an editor's terminal, by the editor's own record of its last commands. */
|
|
135
|
+
export function editorTerminalStarts(editorId, command) {
|
|
136
|
+
const editor = editorById(editorId);
|
|
137
|
+
const db = editor ? path.join(editorData(editor), "User", "globalStorage", "state.vscdb") : null;
|
|
138
|
+
if (!db || !fs.existsSync(db) || !fs.existsSync(SQLITE)) return 0;
|
|
139
|
+
const found = spawnSync(SQLITE, ["-readonly", "-cmd", ".timeout 500", db, "select value from ItemTable where key = 'terminal.history.entries.commands';"], { encoding: "utf8", timeout: 3_000 });
|
|
140
|
+
let entries = [];
|
|
141
|
+
try { entries = JSON.parse(found.stdout || "{}").entries || []; } catch { return 0; }
|
|
142
|
+
return entries.filter((entry) => path.basename(String(entry?.key || "").trim().split(/\s+/)[0] || "") === command).length;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── The agents ────────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/** How each agent picks a session up again; its own panel in an editor, when it has one; its name in T3 Code. */
|
|
148
|
+
const AGENTS = {
|
|
149
|
+
"claude-code": { command: "claude", resume: "claude --resume", t3: "claudeAgent",
|
|
150
|
+
panel: { extension: "anthropic.claude-code", link: (scheme, id) => `${scheme}://anthropic.claude-code/open?session=${id}` } },
|
|
151
|
+
codex: { command: "codex", resume: "codex resume", t3: "codex",
|
|
152
|
+
panel: { extension: "openai.chatgpt", link: (scheme, id) => `${scheme}://openai.chatgpt/local/${id}` } },
|
|
153
|
+
pi: { command: "pi", resume: "pi --session" },
|
|
154
|
+
opencode: { command: "opencode", resume: "opencode --session" },
|
|
155
|
+
};
|
|
156
|
+
export const agentCommand = (agent) => AGENTS[agent]?.command ?? null;
|
|
157
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
158
|
+
const PLAIN_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
159
|
+
const quote = (text) => `'${String(text).replace(/'/g, `'\\''`)}'`;
|
|
160
|
+
|
|
161
|
+
/** The command a person types to pick a session up again, in its folder. */
|
|
162
|
+
export function resumeLine(agent, sessionId, folder = null) {
|
|
163
|
+
const resume = AGENTS[agent]?.resume;
|
|
164
|
+
if (!resume || !PLAIN_ID.test(String(sessionId || ""))) return null;
|
|
165
|
+
return `${folder && path.isAbsolute(folder) ? `cd ${quote(folder)} && ` : ""}${resume} ${sessionId}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A session in its agent's own panel in an editor (Claude Code's, OpenAI's Codex extension), when it is installed there. */
|
|
169
|
+
export function panelLink(agent, editorId, sessionId) {
|
|
170
|
+
const panel = AGENTS[agent]?.panel;
|
|
171
|
+
const editor = editorById(editorId);
|
|
172
|
+
if (!panel || !editor || !UUID.test(String(sessionId || "")) || !editorHas(editorId, panel.extension)) return null;
|
|
173
|
+
return panel.link(editor.scheme, sessionId);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── T3 Code ───────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
const SQLITE = "/usr/bin/sqlite3";
|
|
179
|
+
const T3_BUNDLE = "com.t3tools.t3code";
|
|
180
|
+
export const t3Home = () => process.env.T3_HOME || path.join(os.homedir(), ".t3", "userdata");
|
|
181
|
+
|
|
182
|
+
/** T3 Code's state database: 0.0.46 nightlies (Orchestrator V2) keep a copy of their own, statev2.sqlite; the newer file is the one in use. */
|
|
183
|
+
function t3Database(home) {
|
|
184
|
+
const [found] = ["statev2.sqlite", "state.sqlite"].map((name) => path.join(home, name))
|
|
185
|
+
.map((file) => { try { return { file, at: fs.statSync(file).mtimeMs }; } catch { return null; } })
|
|
186
|
+
.filter(Boolean).sort((a, b) => b.at - a.at);
|
|
187
|
+
return found?.file ?? null;
|
|
188
|
+
}
|
|
189
|
+
function t3Query(home, sql) {
|
|
190
|
+
const db = t3Database(home);
|
|
191
|
+
if (!db || !fs.existsSync(SQLITE)) return null;
|
|
192
|
+
const found = spawnSync(SQLITE, ["-readonly", "-cmd", ".timeout 500", db, sql], { encoding: "utf8", timeout: 3_000 });
|
|
193
|
+
return found.status === 0 ? (found.stdout || "").trim() : null;
|
|
194
|
+
}
|
|
195
|
+
const t3Tables = (home) => new Set((t3Query(home, "select name from sqlite_master where type = 'table';") || "").split("\n"));
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The T3 Code thread that has a provider session: the thread T3 ran it in (nightlies record the session on the thread's
|
|
199
|
+
* provider thread; 0.0.45 in provider_session_runtime), else T3's import of it.
|
|
200
|
+
*/
|
|
201
|
+
export function t3Thread(sessionId, home = t3Home()) {
|
|
202
|
+
if (!PLAIN_ID.test(String(sessionId || ""))) return null;
|
|
203
|
+
const tables = t3Tables(home);
|
|
204
|
+
// The id is checked above: letters, digits and . _ : - only.
|
|
205
|
+
const parts = [];
|
|
206
|
+
if (tables.has("orchestration_v2_projection_provider_threads")) parts.push(`select thread_id, updated_at as at from orchestration_v2_projection_provider_threads where thread_id is not null and json_extract(payload_json, '$.nativeThreadRef.nativeId') = '${sessionId}'`);
|
|
207
|
+
if (tables.has("orchestration_v2_projection_threads")) parts.push(`select thread_id, updated_at as at from orchestration_v2_projection_threads where deleted_at is null and thread_id in ('import:claudeAgent:${sessionId}', 'import:codex:${sessionId}')`);
|
|
208
|
+
else if (tables.has("provider_session_runtime")) parts.push(`select thread_id, last_seen_at as at from provider_session_runtime where thread_id like '%:${sessionId}' or json_extract(resume_cursor_json, '$.resume') = '${sessionId}'`);
|
|
209
|
+
if (!parts.length) return null;
|
|
210
|
+
return t3Query(home, `select thread_id from (${parts.join(" union all ")}) order by at desc limit 1;`) || null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** T3 Code's own link to one of its threads (t3code://threads/<environment>/<thread>). */
|
|
214
|
+
export function t3Link(threadId, home = t3Home()) {
|
|
215
|
+
let environment = "";
|
|
216
|
+
try { environment = fs.readFileSync(path.join(home, "environment-id"), "utf8").trim(); } catch { return null; }
|
|
217
|
+
return threadId && PLAIN_ID.test(environment) ? `t3code://threads/${environment}/${encodeURIComponent(threadId)}` : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** How many threads the person ran an agent in T3 Code since a time: threads T3 itself ran (an import it never ran is not use). */
|
|
221
|
+
export function t3Runs(agent, sinceIso, home = t3Home()) {
|
|
222
|
+
const provider = AGENTS[agent]?.t3;
|
|
223
|
+
if (!provider || !/^[0-9TZ:.-]+$/.test(sinceIso)) return 0;
|
|
224
|
+
const tables = t3Tables(home);
|
|
225
|
+
const parts = [];
|
|
226
|
+
if (tables.has("orchestration_v2_projection_provider_threads")) parts.push(`select thread_id from orchestration_v2_projection_provider_threads where provider = '${provider}' and updated_at > '${sinceIso}'`);
|
|
227
|
+
if (tables.has("provider_session_runtime")) parts.push(`select thread_id from provider_session_runtime where provider_name = '${provider}' and last_seen_at > '${sinceIso}' and (thread_id not like 'import:%' or resume_cursor_json like '%turnCount%')`);
|
|
228
|
+
return parts.length ? Number(t3Query(home, `select count(*) from (${parts.join(" union ")});`) || 0) || 0 : 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// T3 Code opens thread and prompt links from other apps only in its newer builds (0.0.46 nightlies on); 0.0.45 hands
|
|
232
|
+
// t3code:// links to its sign-in alone. Read once per installed build: its bundle carries the link handler's own error
|
|
233
|
+
// message (code names do not survive its minifier; this text does).
|
|
234
|
+
const t3LinkSupport = new Map();
|
|
235
|
+
export function t3OpensLinks(app) {
|
|
236
|
+
const archive = path.join(app, "Contents", "Resources", "app.asar");
|
|
237
|
+
let stamp = "";
|
|
238
|
+
try { stamp = String(fs.statSync(archive).mtimeMs); } catch { return false; }
|
|
239
|
+
const known = t3LinkSupport.get(archive);
|
|
240
|
+
if (known?.stamp === stamp) return known.ok;
|
|
241
|
+
let ok = false;
|
|
242
|
+
try { ok = fs.readFileSync(archive).includes("so T3 Code did not open it"); } catch {}
|
|
243
|
+
t3LinkSupport.set(archive, { stamp, ok });
|
|
244
|
+
return ok;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** T3 Code's app: the one running when several builds are installed (stable and nightly side by side), else one that opens links, else any. */
|
|
248
|
+
export function t3App() {
|
|
249
|
+
const apps = appDirs().flatMap((dir) => { try { return fs.readdirSync(dir).filter((entry) => /^T3 Code\b.*\.app$/.test(entry)).map((entry) => path.join(dir, entry)); } catch { return []; } });
|
|
250
|
+
if (!apps.length) return null;
|
|
251
|
+
const running = apps.length > 1 ? spawnSync(PS, ["-axo", "comm="], { encoding: "utf8", maxBuffer: 16 << 20 }).stdout || "" : "";
|
|
252
|
+
const app = apps.find((candidate) => running.includes(`${candidate}/Contents/MacOS/`)) ?? apps.find((candidate) => t3OpensLinks(candidate)) ?? apps[0];
|
|
253
|
+
return { name: path.basename(app, ".app"), app };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── A place ───────────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
const TERMINAL_NAMES = { Apple_Terminal: "Terminal", "iTerm.app": "iTerm", WezTerm: "WezTerm", ghostty: "Ghostty", WarpTerminal: "Warp", Hyper: "Hyper", kitty: "kitty", alacritty: "Alacritty" };
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Where a session runs, from its environment and the app found up its process tree (`host`, live.mjs hostApp):
|
|
262
|
+
* t3 T3 Code;
|
|
263
|
+
* editor an editor built on VS Code: its terminal, or an agent's own panel there ("surface");
|
|
264
|
+
* codex-app the Codex app;
|
|
265
|
+
* terminal a terminal app, with the ids that pick its pane on any OS (tmux, WezTerm, kitty) and its tty;
|
|
266
|
+
* app any other app.
|
|
267
|
+
* The environment comes first: the app a process sits under can be a different one (a terminal started from an editor).
|
|
268
|
+
*/
|
|
269
|
+
export function placeFrom({ env = {}, host = null, tty = null, folder = null }) {
|
|
270
|
+
const bundle = env.__CFBundleIdentifier || null;
|
|
271
|
+
const hostName = host?.name ?? "";
|
|
272
|
+
const editorOf = () => EDITORS.find((editor) => editor.bundle === bundle) ?? EDITORS.find((editor) => editor.match.test(env.VSCODE_GIT_ASKPASS_NODE || ""))
|
|
273
|
+
?? (env.TERM_PROGRAM === "vscode" || env.CLAUDE_CODE_ENTRYPOINT === "claude-vscode" ? editorById("vscode") : null);
|
|
274
|
+
const editorPlace = (editor) => {
|
|
275
|
+
const install = editorInstall(editor.id, env.VSCODE_GIT_ASKPASS_NODE || host?.app || "");
|
|
276
|
+
const panel = env.CLAUDE_CODE_ENTRYPOINT === "claude-vscode" || env.TERM_PROGRAM !== "vscode";
|
|
277
|
+
return { kind: "editor", editor: editor.id, name: editor.name, app: install?.app ?? host?.app ?? null, cli: install?.cli ?? null, surface: panel ? "panel" : "terminal", folder };
|
|
278
|
+
};
|
|
279
|
+
if (env.T3CODE_CLI_PATH || bundle === T3_BUNDLE) return { kind: "t3", name: "T3 Code", folder };
|
|
280
|
+
const editor = editorOf();
|
|
281
|
+
if (editor) return editorPlace(editor);
|
|
282
|
+
const ids = { tty,
|
|
283
|
+
tmux: env.TMUX && /^%\d+$/.test(env.TMUX_PANE || "") ? env.TMUX_PANE : null,
|
|
284
|
+
wezterm: /^\d+$/.test(env.WEZTERM_PANE || "") ? env.WEZTERM_PANE : null,
|
|
285
|
+
kitty: /^\d+$/.test(env.KITTY_WINDOW_ID || "") ? { id: env.KITTY_WINDOW_ID, socket: env.KITTY_LISTEN_ON || null } : null };
|
|
286
|
+
if (env.TERM_PROGRAM && env.TERM_PROGRAM !== "vscode") return { kind: "terminal", name: hostName || TERMINAL_NAMES[env.TERM_PROGRAM] || env.TERM_PROGRAM, app: host?.app ?? null, ...ids, folder };
|
|
287
|
+
// Nothing in its environment says: the app it runs under.
|
|
288
|
+
if (/^T3 Code\b/.test(hostName)) return { kind: "t3", name: "T3 Code", folder };
|
|
289
|
+
const hosted = EDITORS.find((entry) => entry.name === hostName);
|
|
290
|
+
if (hosted) return editorPlace(hosted);
|
|
291
|
+
if (hostName === "Codex") return { kind: "codex-app", name: "Codex", app: host.app, folder };
|
|
292
|
+
if (tty || ids.tmux || ids.wezterm || ids.kitty) return { kind: "terminal", name: hostName || "Terminal", app: host?.app ?? null, ...ids, folder };
|
|
293
|
+
return host ? { kind: "app", name: hostName, app: host.app, folder } : null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** One key per place the person uses an agent in: both T3 builds are T3; an editor's terminal and its panel differ. */
|
|
297
|
+
export const placeKey = (place) => (place.kind === "editor" ? `editor:${place.editor}:${place.surface}` : place.kind === "t3" ? "t3" : `${place.kind}:${place.name}`);
|
package/lib/runner.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { spawn } from "node:child_process";
|
|
4
5
|
|
|
@@ -27,14 +28,29 @@ export function resolveWorkingDirectory(requested) {
|
|
|
27
28
|
return requested;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
// A run another agent started without the person's tap (docs/member-runs.md): anything that would ask is refused, file
|
|
32
|
+
// edits only in its worktree, shell commands only inside the operating system's sandbox with no network, and no hooks or
|
|
33
|
+
// settings from the repository. A review gets no edits at all.
|
|
34
|
+
export const RESTRICTED_CLAUDE_SETTINGS = {
|
|
35
|
+
disableAllHooks: true,
|
|
36
|
+
permissions: { deny: ["WebFetch", "WebSearch"] },
|
|
37
|
+
sandbox: { enabled: true, autoAllowBashIfSandboxed: true, allowUnsandboxedCommands: false, failIfUnavailable: true, excludedCommands: [], network: { allowedDomains: [], strictAllowlist: true } },
|
|
38
|
+
};
|
|
39
|
+
// Only the Harness board MCP, as Claude Code: none of the person's other MCP servers or claude.ai connectors load.
|
|
40
|
+
export const restrictedMcpConfig = () => ({ mcpServers: { harness: { command: process.execPath, args: [fileURLToPath(new URL("./mcp-stdio.mjs", import.meta.url)), "--agent", "claude-code"] } } });
|
|
41
|
+
const restrictedClaude = (task) => ["--permission-mode", "dontAsk", "--allowedTools", task.readOnly ? "mcp__harness" : "Edit Write mcp__harness",
|
|
42
|
+
"--setting-sources", "user", "--settings", JSON.stringify(RESTRICTED_CLAUDE_SETTINGS), "--strict-mcp-config", "--mcp-config", JSON.stringify(restrictedMcpConfig())];
|
|
43
|
+
|
|
30
44
|
export function agentInvocation(task) {
|
|
31
45
|
if (task.agent === "claude-code") {
|
|
32
46
|
return {
|
|
33
47
|
command: "claude",
|
|
34
48
|
// A review reads and reports: plan mode lets Claude Code read and run read-only tools but never edit.
|
|
35
|
-
args: ["-p", task.brief, "--output-format", "stream-json", "--verbose", ...(task.readOnly ? ["--permission-mode", "plan"] : ["--dangerously-skip-permissions"]), "--name", `Harness: ${task.title}`, ...(task.resumeSession ? ["--resume", task.resumeSession] : [])],
|
|
49
|
+
args: ["-p", task.brief, "--output-format", "stream-json", "--verbose", ...(task.restricted ? restrictedClaude(task) : task.readOnly ? ["--permission-mode", "plan"] : ["--dangerously-skip-permissions"]), "--name", `Harness: ${task.title}`, ...(task.resumeSession ? ["--resume", task.resumeSession] : [])],
|
|
36
50
|
};
|
|
37
51
|
}
|
|
52
|
+
// Only Claude Code has a restricted mode; any other agent's restricted run is refused here, whatever let it start.
|
|
53
|
+
if (task.restricted) throw new Error(`${task.agent} has no restricted mode; it runs only after the person's Start`);
|
|
38
54
|
if (task.agent === "codex") {
|
|
39
55
|
return {
|
|
40
56
|
command: "codex",
|
|
@@ -146,7 +162,7 @@ export function runAgentTask(task, emit, observe = () => {}) {
|
|
|
146
162
|
let child;
|
|
147
163
|
try {
|
|
148
164
|
child = spawn(invocation.command, invocation.args, { cwd });
|
|
149
|
-
|
|
165
|
+
child.stdin.end(invocation.stdin);
|
|
150
166
|
} catch (error) {
|
|
151
167
|
running.delete(slot);
|
|
152
168
|
emit("failed", `Could not start ${task.agent}: ${error.message}`);
|
package/lib/store.mjs
CHANGED
|
@@ -78,6 +78,7 @@ export function openStore(file) {
|
|
|
78
78
|
trust text not null default 'agent',
|
|
79
79
|
origin_chain text not null default '[]',
|
|
80
80
|
usage text,
|
|
81
|
+
removed_at text,
|
|
81
82
|
created_at text not null,
|
|
82
83
|
updated_at text not null
|
|
83
84
|
);
|
|
@@ -208,6 +209,7 @@ export function openStore(file) {
|
|
|
208
209
|
}
|
|
209
210
|
if (!boardColumns.has("kind") && boardColumns.size) db.exec("alter table board_tasks add column kind text not null default 'task'");
|
|
210
211
|
if (!boardColumns.has("usage") && boardColumns.size) db.exec("alter table board_tasks add column usage text");
|
|
212
|
+
if (!boardColumns.has("removed_at") && boardColumns.size) db.exec("alter table board_tasks add column removed_at text");
|
|
211
213
|
if (!boardColumns.has("trust") && boardColumns.size) db.exec("alter table board_tasks add column trust text not null default 'agent'; alter table board_tasks add column origin_chain text not null default '[]'");
|
|
212
214
|
|
|
213
215
|
const statements = [];
|
|
@@ -299,6 +301,10 @@ export function openStore(file) {
|
|
|
299
301
|
});
|
|
300
302
|
return parsed.meta;
|
|
301
303
|
},
|
|
304
|
+
endedSince(since) {
|
|
305
|
+
const rows = execute("all", "select status, count(*) as count from board_tasks where kind not in ('goal', 'group') and status in ('done', 'failed', 'unknown') and reported_at >= ? group by status", since);
|
|
306
|
+
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
|
|
307
|
+
},
|
|
302
308
|
counts() {
|
|
303
309
|
const rows = execute("all", "select agent, count(*) as count from sessions group by agent");
|
|
304
310
|
return Object.fromEntries(rows.map((row) => [row.agent, Number(row.count)]));
|
|
@@ -453,7 +459,7 @@ export function openStore(file) {
|
|
|
453
459
|
paused_reason: row.paused_reason, sessions: JSON.parse(row.sessions || "{}"), guests: JSON.parse(row.guests || "[]"), posts: this.roomCount(id), spend: this.goalSpend(id) };
|
|
454
460
|
},
|
|
455
461
|
goals({ all = false } = {}) {
|
|
456
|
-
return execute("all", `select id from board_tasks where kind = 'goal' ${all ? "" : "and status in ('open', 'paused')"} order by created_at desc limit 50`).map((row) => this.getTask(row.id));
|
|
462
|
+
return execute("all", `select id from board_tasks where kind = 'goal' and removed_at is null ${all ? "" : "and status in ('open', 'paused')"} order by created_at desc limit 50`).map((row) => this.getTask(row.id));
|
|
457
463
|
},
|
|
458
464
|
setGoalState(id, { status, result, pausedReason, maxPosts }) {
|
|
459
465
|
const now = new Date().toISOString();
|
|
@@ -470,6 +476,11 @@ export function openStore(file) {
|
|
|
470
476
|
const row = execute("all", "select goal_id, guests from board_goals").find((entry) => JSON.parse(entry.guests || "[]").some((guest) => guest.thread === thread));
|
|
471
477
|
return row?.goal_id || null;
|
|
472
478
|
},
|
|
479
|
+
// At posting only: the task's room is part of how it was posted, not a change anyone races.
|
|
480
|
+
setParent(id, parentId) {
|
|
481
|
+
execute("run", "update board_tasks set parent_id = ? where id = ?", parentId, id);
|
|
482
|
+
return this.getTask(id);
|
|
483
|
+
},
|
|
473
484
|
setGoalFolder(id, workspace) {
|
|
474
485
|
execute("run", "update board_goals set workspace = ? where goal_id = ?", workspace, id);
|
|
475
486
|
},
|
|
@@ -534,7 +545,7 @@ export function openStore(file) {
|
|
|
534
545
|
},
|
|
535
546
|
// A thread's runs (wakes and jobs), oldest first, for its transcript.
|
|
536
547
|
threadRuns(goalId) {
|
|
537
|
-
return execute("all", `select id, kind, objective, target_agent, claimed_by, status, holder, result, session_id, run_workspace, usage, created_at, updated_at, reported_at
|
|
548
|
+
return execute("all", `select id, kind, objective, target_agent, claimed_by, status, holder, result, session_id, run_workspace, run_authorized_at, usage, created_at, updated_at, reported_at
|
|
538
549
|
from board_tasks where parent_id = ? and kind in ('wake', 'task') order by created_at asc`, goalId)
|
|
539
550
|
.map((row) => ({ ...row, usage: row.usage ? JSON.parse(row.usage) : null }));
|
|
540
551
|
},
|
|
@@ -549,8 +560,10 @@ export function openStore(file) {
|
|
|
549
560
|
const row = execute("all", "select goal_id, sessions from board_goals").find((entry) => JSON.parse(entry.sessions || "{}")[agent] === sessionId);
|
|
550
561
|
return row?.goal_id || null;
|
|
551
562
|
},
|
|
563
|
+
// The rooms an agent's own sessions are told about: threads it was brought into, not every task's room (the daemon
|
|
564
|
+
// runs those, and the task itself is already on its list).
|
|
552
565
|
activeGoalsFor(agent) {
|
|
553
|
-
return this.goals().filter((goal) => goal.status === "open" && goal.goal.team.includes(agent));
|
|
566
|
+
return this.goals().filter((goal) => goal.status === "open" && goal.goal.team.includes(agent) && !this.meta(`task-room:${goal.id}`));
|
|
554
567
|
},
|
|
555
568
|
authorizeGoalRun(id) {
|
|
556
569
|
const now = new Date().toISOString();
|
|
@@ -581,6 +594,14 @@ export function openStore(file) {
|
|
|
581
594
|
setRunSession(id, sessionId) {
|
|
582
595
|
execute("run", "update board_tasks set session_id = ?, updated_at = ? where id = ?", sessionId, new Date().toISOString(), id);
|
|
583
596
|
},
|
|
597
|
+
// A run the daemon stopped itself for a safety rule (Lid-Closed Mode): the task goes back on the board, still
|
|
598
|
+
// authorized, and its next run picks up where this one stopped.
|
|
599
|
+
requeueRun(id) {
|
|
600
|
+
const now = new Date().toISOString();
|
|
601
|
+
const written = execute("run", `update board_tasks set status = 'open', holder = target_agent, claimed_by = null, claimed_at = null, lease_until = null, updated_at = @now, version = version + 1
|
|
602
|
+
where id = @id and status = 'running'`, { id, now });
|
|
603
|
+
return written.changes === 1 ? { ok: true, task: this.getTask(id) } : { ok: false, error: `task is ${this.getTask(id)?.status ?? "missing"}` };
|
|
604
|
+
},
|
|
584
605
|
// A daemon restart lost every agent process it was running; those outcomes are unknown, never assumed.
|
|
585
606
|
settleInterruptedRuns() {
|
|
586
607
|
const now = new Date().toISOString();
|
|
@@ -744,6 +765,16 @@ export function openStore(file) {
|
|
|
744
765
|
return { ok: true, thread: this.getThread(taskId), task: this.getTask(taskId) };
|
|
745
766
|
},
|
|
746
767
|
// Messages another person's agent sent on this agent's tasks since it last read the board.
|
|
768
|
+
lastSeenAt(agent) {
|
|
769
|
+
return execute("get", "select last_seen_at from board_agents where agent = ?", agent)?.last_seen_at || null;
|
|
770
|
+
},
|
|
771
|
+
// Work an agent asked for that finished, failed or came back with a question since it last called the board: how an
|
|
772
|
+
// asker that cannot be woken (the Claude app, ChatGPT) learns of its answers.
|
|
773
|
+
finishedFor(agent, since) {
|
|
774
|
+
return execute("all", `select id, objective, target_agent, claimed_by, status, updated_at from board_tasks
|
|
775
|
+
where origin_agent = ? and kind = 'task' and removed_at is null and status in ('done', 'failed', 'needs_input') and updated_at > ?
|
|
776
|
+
order by updated_at desc limit 20`, agent, since || "1970-01-01T00:00:00.000Z");
|
|
777
|
+
},
|
|
747
778
|
unreadFor(agent) {
|
|
748
779
|
const seen = execute("get", "select last_seen_at from board_agents where agent = ?", agent)?.last_seen_at || "1970-01-01T00:00:00.000Z";
|
|
749
780
|
return execute("all", `select m.task_id, t.objective, m.sender, count(*) as count from board_messages m join board_tasks t on t.id = m.task_id
|
|
@@ -789,10 +820,12 @@ export function openStore(file) {
|
|
|
789
820
|
return execute("all", "select agent, last_seen_at from board_agents order by last_seen_at desc");
|
|
790
821
|
},
|
|
791
822
|
listTasks({ agent, status, all = false, limit = 20 } = {}) {
|
|
792
|
-
const clauses = [];
|
|
823
|
+
const clauses = ["b.removed_at is null"];
|
|
793
824
|
const params = { limit: Math.max(1, Math.min(100, Number(limit) || 20)) };
|
|
794
825
|
if (status) { clauses.push("b.status = @status"); params.status = status; }
|
|
795
826
|
else if (!all) clauses.push("b.status in ('open', 'claimed', 'running', 'needs_input')");
|
|
827
|
+
// An agent's whole-board list is work, not threads: those it reads as its rooms.
|
|
828
|
+
if (agent && agent !== "user" && all) clauses.push("b.kind not in ('goal', 'wake')");
|
|
796
829
|
if (agent && !all) {
|
|
797
830
|
clauses.push("b.kind not in ('group', 'goal', 'wake') and (b.target_agent is null or b.target_agent = @agent or b.origin_agent = @agent or b.claimed_by = @agent)");
|
|
798
831
|
params.agent = agent;
|
|
@@ -807,6 +840,18 @@ export function openStore(file) {
|
|
|
807
840
|
from board_tasks b left join board_threads th on th.task_id = b.id ${clauses.length ? `where ${clauses.join(" and ")}` : ""} order by b.created_at desc limit @limit`, params)
|
|
808
841
|
.map((row) => ({ ...row, origin_chain: JSON.parse(row.origin_chain || "[]"), version: Number(row.version), held_messages: Number(row.held_messages), child_count: Number(row.child_count), child_done: Number(row.child_done), stale: row.status === "claimed" && Boolean(row.lease_until) && row.lease_until < now }));
|
|
809
842
|
},
|
|
843
|
+
// Off the board for good, row kept: unfinished work and its parts close first, so nothing picks them up again.
|
|
844
|
+
removeTask(id) {
|
|
845
|
+
const now = new Date().toISOString();
|
|
846
|
+
const ids = [id, ...execute("all", "select id from board_tasks where parent_id = ?", id).map((row) => row.id)];
|
|
847
|
+
const running = [];
|
|
848
|
+
for (const taskId of ids) {
|
|
849
|
+
if (execute("get", "select 1 from board_tasks where id = ? and status = 'running'", taskId)) running.push(taskId);
|
|
850
|
+
execute("run", "update board_tasks set status = 'failed', result = 'Removed by you.', holder = null, reported_at = ?, updated_at = ?, version = version + 1 where id = ? and kind != 'goal' and status in ('open', 'claimed', 'needs_input', 'running')", now, now, taskId);
|
|
851
|
+
execute("run", "update board_tasks set removed_at = ?, updated_at = ? where id = ? and removed_at is null", now, now, taskId);
|
|
852
|
+
}
|
|
853
|
+
return { ids, running };
|
|
854
|
+
},
|
|
810
855
|
getTask(id) {
|
|
811
856
|
const row = execute("get", "select * from board_tasks where id = ?", id);
|
|
812
857
|
if (!row) return null;
|