tryharness 0.7.7 → 0.7.9

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/lib/live.mjs ADDED
@@ -0,0 +1,205 @@
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 { readJson } from "./files.mjs";
6
+ import { historyRoots } from "./paths.mjs";
7
+ import { PS } from "./nudge.mjs";
8
+ import { stepsOf } from "./runner.mjs";
9
+ import { t3Link, t3Thread } from "./places.mjs";
10
+
11
+ // The person's own coding sessions running on this computer right now, each with the app it runs in: a terminal, an
12
+ // editor or T3 Code, found by walking up the session's process tree to the first process inside an app bundle. A
13
+ // session in T3 Code also gets the link that opens its thread there (T3's own prompt links, t3code://threads/…).
14
+ // Only Claude Code and Codex: their hooks (nudge.mjs) are how a message from Harness reaches a session. Where each
15
+ // session runs, and how it opens there again, is places.mjs.
16
+
17
+ export const LIVE_AGENTS = ["claude-code", "codex"];
18
+ const PLAIN_ID = /^[A-Za-z0-9._:-]{1,128}$/;
19
+
20
+ /** Every process: its parent, its terminal (/dev/ttys003, or null) and its executable, by pid. */
21
+ export function processTable() {
22
+ const table = new Map();
23
+ if (process.platform === "win32") return table;
24
+ const ps = spawnSync(PS, ["-axo", "pid=,ppid=,tty=,comm="], { encoding: "utf8", maxBuffer: 16 << 20 });
25
+ for (const line of (ps.stdout || "").split("\n")) {
26
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
27
+ if (match) table.set(Number(match[1]), { ppid: Number(match[2]), tty: /^ttys?\d+$/.test(match[3]) ? `/dev/${match[3]}` : null, comm: match[4] });
28
+ }
29
+ return table;
30
+ }
31
+
32
+ /** True when a process runs inside tmux: a tmux server is among its ancestors. */
33
+ function underTmux(table, pid) {
34
+ for (let current = pid, steps = 0; current > 1 && steps < 64; steps++) {
35
+ const row = table.get(current);
36
+ if (!row) return false;
37
+ if (/^tmux\b/.test(path.basename(row.comm))) return true;
38
+ current = row.ppid;
39
+ }
40
+ return false;
41
+ }
42
+
43
+ const run = (command, args) => spawnSync(command, args, { encoding: "utf8", timeout: 3_000 });
44
+
45
+ /** The tmux pane showing a terminal, and the terminal (app and tty) a tmux client shows it in. */
46
+ export function tmuxPlace(table, tty) {
47
+ const panes = run("tmux", ["list-panes", "-a", "-F", "#{pane_tty}\t#{session_name}\t#{session_name}:#{window_index}.#{pane_index}"]);
48
+ const pane = (panes.stdout || "").split("\n").map((line) => line.split("\t")).find(([paneTty]) => paneTty === tty);
49
+ if (!pane) return null;
50
+ const clients = run("tmux", ["list-clients", "-t", pane[1], "-F", "#{client_tty}\t#{client_pid}"]);
51
+ const [clientTty, clientPid] = (clients.stdout || "").split("\n").filter(Boolean).map((line) => line.split("\t"))[0] ?? [];
52
+ return { target: pane[2], client: clientPid ? { tty: clientTty || null, host: hostApp(table, Number(clientPid)) } : null };
53
+ }
54
+
55
+ /** The app a process runs in: the outermost app bundle on its way up to launchd, or null (tmux, ssh, a service). */
56
+ export function hostApp(table, pid) {
57
+ for (let current = pid, steps = 0; current > 1 && steps < 64; steps++) {
58
+ const row = table.get(current);
59
+ if (!row) return null;
60
+ const bundle = row.comm.match(/^(\/.*?\.app)\/Contents\//)?.[1];
61
+ if (bundle) return { name: path.basename(bundle, ".app"), app: bundle };
62
+ current = row.ppid;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ export function workingDirectories(pids) {
68
+ const found = new Map();
69
+ if (!pids.length) return found;
70
+ const lsof = spawnSync("lsof", ["-a", "-d", "cwd", "-Fpn", "-p", pids.join(",")], { encoding: "utf8" });
71
+ let pid = null;
72
+ for (const line of (lsof.stdout || "").split("\n")) {
73
+ if (line.startsWith("p")) pid = Number(line.slice(1));
74
+ else if (line.startsWith("n") && pid) found.set(pid, line.slice(1));
75
+ }
76
+ return found;
77
+ }
78
+
79
+ /** The session a running agent process has open: Claude Code's own record of it, or Codex's newest session in its folder. */
80
+ export function sessionOfProcess(agent, pid, recent = []) {
81
+ if (!Number.isInteger(pid) || pid <= 1) return null;
82
+ if (agent === "claude-code") {
83
+ const record = readJson(path.join(historyRoots("claude-code")[0], "..", "sessions", `${pid}.json`), null);
84
+ return record && PLAIN_ID.test(String(record.sessionId || "")) ? record.sessionId : null;
85
+ }
86
+ if (agent !== "codex") return null;
87
+ const cwd = workingDirectories([pid]).get(pid);
88
+ const session = cwd ? recent.find((row) => row.agent === "codex" && row.cwd === cwd) : null;
89
+ return String(session?.session_id || "").match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0] ?? null;
90
+ }
91
+
92
+ /**
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).
95
+ */
96
+ export function liveSessions({ exclude = new Set(), recent = [], table = processTable() } = {}) {
97
+ const sessions = [];
98
+ const records = path.join(historyRoots("claude-code")[0], "..", "sessions");
99
+ let files = [];
100
+ try { files = fs.readdirSync(records).filter((name) => name.endsWith(".json")); } catch {}
101
+ for (const name of files) {
102
+ const record = readJson(path.join(records, name), null);
103
+ if (!record?.pid || !table.has(record.pid) || exclude.has(record.pid) || !PLAIN_ID.test(String(record.sessionId || ""))) continue;
104
+ if (record.kind && record.kind !== "interactive") continue;
105
+ sessions.push({ agent: "claude-code", sessionId: record.sessionId, folder: record.cwd || null, pid: record.pid, entrypoint: record.entrypoint || null,
106
+ busy: record.status === "busy" ? true : record.status === "idle" ? false : null, at: Number(record.statusUpdatedAt || record.startedAt || 0) });
107
+ }
108
+ // A Codex process outside any terminal and app is Codex's own background server (its terminals share it), not a session.
109
+ const codex = [...table].filter(([pid, row]) => path.basename(row.comm) === "codex" && !exclude.has(pid) && (row.tty || hostApp(table, pid))).map(([pid]) => pid);
110
+ const cwds = workingDirectories(codex);
111
+ for (const pid of codex) {
112
+ const session = recent.find((row) => row.agent === "codex" && row.cwd && row.cwd === cwds.get(pid));
113
+ const id = String(session?.session_id || "").replace(/\.jsonl$/, "").match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0];
114
+ if (id && !sessions.some((row) => row.sessionId === id)) sessions.push({ agent: "codex", sessionId: id, folder: session.cwd, pid, busy: null, at: Date.parse(session.started_at) || 0 });
115
+ }
116
+ return sessions.sort((a, b) => b.at - a.at).map((session) => {
117
+ const tty = table.get(session.pid)?.tty ?? null;
118
+ // Inside tmux the app is the terminal a tmux client shows the pane in.
119
+ const tmux = underTmux(table, session.pid) && tty ? tmuxPlace(table, tty) : null;
120
+ const host = tmux?.client?.host ?? hostApp(table, session.pid);
121
+ const t3 = host && /^T3 Code\b/.test(host.name) ? t3Link(t3Thread(session.sessionId)) : null;
122
+ return { ...session, tty, tmux: tmux?.target ?? null, host, t3 };
123
+ });
124
+ }
125
+
126
+ /**
127
+ * A running terminal session brought to the front on its own pane: its tmux pane, WezTerm pane or kitty window selected
128
+ * here (by the ids it started with, else by its terminal device), then its app on its tab (the app does that step:
129
+ * src-tauri agents.rs focus_session, by the tty).
130
+ */
131
+ export function focusTerminal(session, place = null, table = processTable()) {
132
+ let tty = session.tty;
133
+ const tmux = tty && (session.tmux || place?.tmux) ? tmuxPlace(table, tty) : null;
134
+ if (tmux) {
135
+ run("tmux", ["select-window", "-t", tmux.target]);
136
+ run("tmux", ["select-pane", "-t", tmux.target]);
137
+ tty = tmux.client?.tty ?? null;
138
+ }
139
+ if (place?.wezterm || (session.host && /^WezTerm$/i.test(session.host.name))) {
140
+ let pane = place?.wezterm ?? null;
141
+ if (!pane) try { pane = JSON.parse(run("wezterm", ["cli", "list", "--format", "json"]).stdout || "[]").find((entry) => entry.tty_name === session.tty)?.pane_id ?? null; } catch {}
142
+ if (pane != null) run("wezterm", ["cli", "activate-pane", "--pane-id", String(pane)]);
143
+ }
144
+ if (place?.kitty || (session.host && /^kitty$/i.test(session.host.name))) {
145
+ const to = place?.kitty?.socket ? ["--to", place.kitty.socket] : [];
146
+ run("kitty", ["@", ...to, "focus-window", "--match", place?.kitty ? `id:${place.kitty.id}` : `pid:${session.pid}`]);
147
+ }
148
+ return session.host ? { app: session.host.app, tty } : {};
149
+ }
150
+
151
+ const FEED_BYTES = 768 << 10;
152
+ const promptText = (content) => typeof content === "string" ? content
153
+ : Array.isArray(content) ? content.filter((block) => ["text", "input_text"].includes(block?.type) && typeof block.text === "string").map((block) => block.text).join("\n") : "";
154
+ // What the agent itself was handed (instructions, environment, hook output), not words the person typed.
155
+ const machinery = (text) => !text.trim() || /^\s*<[a-z_-]+[\s>]/i.test(text) || text.startsWith("Caveat:");
156
+
157
+ /** A session's own file: the index's source, else Claude Code's projects folder for its working folder. */
158
+ export function sessionFile(agent, sessionId, folder, recent = []) {
159
+ const row = recent.find((entry) => entry.agent === agent && String(entry.session_id).includes(sessionId) && entry.source);
160
+ if (row && fs.existsSync(row.source)) return row.source;
161
+ if (agent === "claude-code" && folder && PLAIN_ID.test(sessionId)) {
162
+ const file = path.join(historyRoots("claude-code")[0], folder.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`);
163
+ if (fs.existsSync(file)) return file;
164
+ }
165
+ return null;
166
+ }
167
+
168
+ /**
169
+ * The end of a session as the person reads it, oldest first: what they asked ("you"), what the agent said ("say") and
170
+ * the tools it used ("do"). Read from the last part of the session's own file, so a long session costs no more.
171
+ */
172
+ export function sessionFeed(agent, file, limit = 80) {
173
+ let text = "";
174
+ try {
175
+ const size = fs.statSync(file).size;
176
+ const handle = fs.openSync(file, "r");
177
+ const buffer = Buffer.alloc(Math.min(size, FEED_BYTES));
178
+ fs.readSync(handle, buffer, 0, buffer.length, size - buffer.length);
179
+ fs.closeSync(handle);
180
+ text = buffer.toString("utf8");
181
+ if (size > buffer.length) text = text.slice(text.indexOf("\n") + 1);
182
+ } catch { return []; }
183
+ const items = [];
184
+ for (const line of text.split("\n")) {
185
+ if (!line.startsWith("{")) continue;
186
+ let row;
187
+ try { row = JSON.parse(line); } catch { continue; }
188
+ const at = row.timestamp || null;
189
+ if (agent === "claude-code") {
190
+ if (row.type === "user" && !row.isMeta) {
191
+ const said = promptText(row.message?.content);
192
+ if (!machinery(said)) items.push({ kind: "you", text: said.trim(), at });
193
+ } else if (row.type === "assistant") items.push(...stepsOf(row).map((step) => ({ ...step, at })));
194
+ } else if (agent === "codex" && row.type === "response_item") {
195
+ const item = row.payload || {};
196
+ if (item.type === "message" && item.role === "user") { const said = promptText(item.content); if (!machinery(said)) items.push({ kind: "you", text: said.trim(), at }); }
197
+ else if (item.type === "message" && item.role === "assistant") { const said = (item.content || []).map((block) => block?.text || "").join("\n").trim(); if (said) items.push({ kind: "say", text: said, at }); }
198
+ else if (item.type === "function_call" || item.type === "custom_tool_call") items.push({ kind: "do", text: `${item.name}: ${String(item.arguments ?? item.input ?? "").replace(/\s+/g, " ").slice(0, 200)}`, at });
199
+ } else if (agent === "pi" && row.type === "message" && row.message) {
200
+ if (row.message.role === "user") { const said = promptText(row.message.content); if (!machinery(said)) items.push({ kind: "you", text: said.trim(), at }); }
201
+ else if (row.message.role === "assistant") items.push(...stepsOf({ type: "message_end", message: row.message }).map((step) => ({ ...step, at })));
202
+ }
203
+ }
204
+ return items.slice(-limit);
205
+ }
package/lib/mcp-stdio.mjs CHANGED
@@ -135,7 +135,7 @@ const BOARD_CALLS = {
135
135
  list_tasks: (args) => ({ command: "board.list", status: args.status, all: args.all === true }),
136
136
  get_task: (args) => ({ command: "board.get", id: String(args.id || "") }),
137
137
  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 }),
138
- claim_task: (args) => ({ command: "board.claim", id: String(args.id || ""), expectedVersion: args.expected_version, leaseMinutes: args.lease_minutes }),
138
+ claim_task: (args) => ({ command: "board.claim", id: String(args.id || ""), expectedVersion: args.expected_version, leaseMinutes: args.lease_minutes, claimerPid: process.ppid }),
139
139
  report_task: (args) => args.status === "answered"
140
140
  ? { command: "board.answer", id: String(args.id || ""), answer: args.result, expectedVersion: args.expected_version }
141
141
  : args.status === "released"
package/lib/nudge.mjs CHANGED
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { daemonRequest } from "./control-client.mjs";
6
6
  import { paths } from "./paths.mjs";
7
+ import { placeEnv } from "./places.mjs";
7
8
 
8
9
  // The session-start nudge: what an interactive agent is told when a session opens, so work
9
10
  // waiting on the board is not silent until someone asks. Claude Code runs this as a
@@ -68,16 +69,65 @@ export async function boardNudgeIfChanged(agent, homeDir = paths.home) {
68
69
  // its grandparent when the hook runs through a shell.
69
70
  export const PS = ["/bin/ps", "/usr/bin/ps"].find((file) => fs.existsSync(file)) || "ps";
70
71
 
71
- export async function notePrompt(agent) {
72
+ function agentPids() {
72
73
  const pids = [process.ppid];
73
74
  try { const up = Number(spawnSync(PS, ["-o", "ppid=", "-p", String(process.ppid)], { encoding: "utf8" }).stdout.trim()); if (up > 1) pids.push(up); } catch {}
74
- try { await daemonRequest({ command: "board.prompted", agent, pids }, 1_500); } catch {}
75
+ return pids;
76
+ }
77
+
78
+ // Where the session runs, as its own environment says (the agent's, which the hook shares): places.mjs reads it.
79
+ const placeOf = (input) => ({ sessionId: input?.session_id, folder: input?.cwd || process.cwd(), env: placeEnv(process.env) });
80
+
81
+ export async function notePrompt(agent, input = null) {
82
+ try { await daemonRequest({ command: "board.prompted", agent, pids: agentPids(), ...(input?.session_id ? placeOf(input) : {}) }, 1_500); } catch {}
83
+ }
84
+
85
+ /** A session starting says where it runs. */
86
+ export async function notePlace(agent, input) {
87
+ if (!input?.session_id) return;
88
+ try { await daemonRequest({ command: "board.live.place", agent, pids: agentPids(), ...placeOf(input) }, 1_500); } catch {}
89
+ }
90
+
91
+ // What the person sent this session from Harness (daemon board.live.say), taken once.
92
+ export async function takeMessages(agent, sessionId) {
93
+ if (!sessionId) return [];
94
+ try {
95
+ const response = await daemonRequest({ command: "board.live.take", agent, sessionId }, 1_500);
96
+ return response?.ok ? response.messages || [] : [];
97
+ } catch {
98
+ return [];
99
+ }
100
+ }
101
+
102
+ export function messagesText(messages) {
103
+ return messages.map((message) => `The person sent you this from Harness:\n\n${message.text}`).join("\n\n");
104
+ }
105
+
106
+ // The JSON the agent hands every hook on stdin (session_id among it); null when there is none.
107
+ async function hookInput() {
108
+ if (process.stdin.isTTY) return null;
109
+ let text = "";
110
+ process.stdin.setEncoding("utf8");
111
+ const read = new Promise((resolve) => { process.stdin.on("data", (chunk) => { text += chunk; }); process.stdin.on("end", resolve); process.stdin.on("error", resolve); });
112
+ await Promise.race([read, new Promise((resolve) => setTimeout(resolve, 1_000).unref())]);
113
+ process.stdin.destroy();
114
+ try { return JSON.parse(text); } catch { return null; }
75
115
  }
76
116
 
77
117
  if (import.meta.url === `file://${process.argv[1]}`) {
78
118
  const index = process.argv.indexOf("--agent");
79
119
  const agent = index >= 0 ? process.argv[index + 1] : "claude-code";
80
- if (process.argv.includes("--if-changed")) await notePrompt(agent);
81
- const text = process.argv.includes("--if-changed") ? await boardNudgeIfChanged(agent) : await boardNudge(agent);
82
- if (text) process.stdout.write(text + "\n");
120
+ const input = await hookInput();
121
+ const sessionId = input?.session_id;
122
+ if (process.argv.includes("--stop")) {
123
+ // The Stop hook: a message waiting for this session keeps the agent going with it.
124
+ const messages = await takeMessages(agent, sessionId);
125
+ if (messages.length) process.stdout.write(JSON.stringify({ decision: "block", reason: messagesText(messages) }) + "\n");
126
+ } else {
127
+ if (process.argv.includes("--if-changed")) await notePrompt(agent, input);
128
+ else await notePlace(agent, input);
129
+ const messages = process.argv.includes("--if-changed") ? await takeMessages(agent, sessionId) : [];
130
+ const text = [messages.length ? messagesText(messages) : "", process.argv.includes("--if-changed") ? await boardNudgeIfChanged(agent) : await boardNudge(agent)].filter(Boolean).join("\n\n");
131
+ if (text) process.stdout.write(text + "\n");
132
+ }
83
133
  }
package/lib/phones.mjs CHANGED
@@ -24,6 +24,8 @@ const PHONE_COMMANDS = new Set([
24
24
  "board.goal.list", "board.goal.get", "board.goal.stop", "board.room.read",
25
25
  "board.swarm.list", "board.swarm.read", "board.swarm.start", "board.swarm.post", "board.swarm.stop", "board.swarm.stop_all", "board.swarm.resume", "board.swarm.folder", "board.swarm.coordinator",
26
26
  "board.swarm.diff", "board.swarm.merge", "board.swarm.discard", "board.swarm.start_run",
27
+ // The person's own running sessions, read and messaged as from the desktop app; never queued (a session may be gone).
28
+ "board.live", "board.live.read", "board.live.say",
27
29
  ]);
28
30
  // What the chat owner does on the board, in Harness's name (was src/server/mcp/board.ts HARNESS_COMMANDS).
29
31
  const HARNESS_COMMANDS = new Set([
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}`);