tryharness 0.7.7 → 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.
@@ -54,11 +54,22 @@ function notify(message) {
54
54
  } catch {}
55
55
  }
56
56
 
57
+ // Where a session runs, as opencode's own environment says (Harness's places.mjs reads it): once per session.
58
+ const PLACE_ENV = ["__CFBundleIdentifier", "TERM_PROGRAM", "TMUX", "TMUX_PANE", "WEZTERM_PANE", "KITTY_WINDOW_ID", "KITTY_LISTEN_ON", "WT_SESSION", "VSCODE_GIT_ASKPASS_NODE"];
59
+ const placed = new Set();
60
+ function notePlace(sessionId, directory) {
61
+ if (placed.has(sessionId)) return;
62
+ placed.add(sessionId);
63
+ const env = Object.fromEntries(PLACE_ENV.filter((name) => process.env[name]).map((name) => [name, process.env[name]]));
64
+ notify({ command: "board.live.place", agent: "opencode", sessionId, folder: directory, pids: [process.pid, process.ppid], env });
65
+ }
66
+
57
67
  export const TryHarnessPlugin = async ({ directory }) => ({
58
68
  // Every message of a session carries the board nudge as an extra text part when the queue changed since the last one.
59
69
  "chat.message": async (input, output) => {
60
70
  const sessionId = input?.sessionID || input?.sessionId;
61
71
  if (!sessionId || !Array.isArray(output?.parts)) return;
72
+ notePlace(sessionId, directory);
62
73
  const nudge = await boardNudge(sessionId);
63
74
  if (nudge) output.parts.push({ type: "text", text: nudge });
64
75
  },
@@ -134,6 +134,14 @@ export default function tryHarness(pi) {
134
134
  ctx?.ui?.notify?.(nudge.split("\n").slice(0, 2).join(" "), "info");
135
135
  };
136
136
  pi.on("session_start", async (_event, ctx) => tell(await boardNudge(false), ctx));
137
+ // Where the session runs, as pi's own environment says (Harness's places.mjs reads it).
138
+ pi.on("session_start", (_event, ctx) => {
139
+ const sessionId = ctx?.sessionManager?.getSessionId?.();
140
+ if (!sessionId) return;
141
+ const names = ["__CFBundleIdentifier", "TERM_PROGRAM", "TMUX", "TMUX_PANE", "WEZTERM_PANE", "KITTY_WINDOW_ID", "KITTY_LISTEN_ON", "WT_SESSION", "VSCODE_GIT_ASKPASS_NODE"];
142
+ const env = Object.fromEntries(names.filter((name) => process.env[name]).map((name) => [name, process.env[name]]));
143
+ void daemon({ command: "board.live.place", agent: "pi", sessionId, folder: ctx?.cwd || process.cwd(), pids: [process.pid, process.ppid], env });
144
+ });
137
145
  // Into the model's own context: pi lets an extension return the system prompt it should use
138
146
  // (pi 0.87: before_agent_start -> { systemPrompt }). The queue is appended, never substituted.
139
147
  pi.on("before_agent_start", async (event) => {
package/lib/adapters.mjs CHANGED
@@ -60,11 +60,14 @@ export function installClaudeSessionHook({ runtimeRoot, homeDir = os.homedir(),
60
60
  // Mid-session: on every prompt, one line only when the queue changed since the session last saw it.
61
61
  const prompts = (Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : []).filter((entry) => !isHarnessHook(entry));
62
62
  prompts.push({ hooks: [{ type: "command", command: `${JSON.stringify(nodePath)} ${nudge} --agent claude-code --if-changed`, timeout: 10 }] });
63
+ // When a turn ends: a message the person sent this session from Harness keeps it going.
64
+ const stops = (Array.isArray(hooks.Stop) ? hooks.Stop : []).filter((entry) => !isHarnessHook(entry));
65
+ stops.push({ hooks: [{ type: "command", command: `${JSON.stringify(nodePath)} ${nudge} --agent claude-code --stop`, timeout: 10 }] });
63
66
  ensureDir(path.dirname(file));
64
- fs.writeFileSync(file, JSON.stringify({ ...settings, hooks: { ...hooks, SessionStart: starts, UserPromptSubmit: prompts } }, null, 2) + "\n");
67
+ fs.writeFileSync(file, JSON.stringify({ ...settings, hooks: { ...hooks, SessionStart: starts, UserPromptSubmit: prompts, Stop: stops } }, null, 2) + "\n");
65
68
  }
66
69
 
67
- // Codex reads the same hook shape from ~/.codex/hooks.json (SessionStart and UserPromptSubmit,
70
+ // Codex reads the same hook shape from ~/.codex/hooks.json (SessionStart, UserPromptSubmit and Stop,
68
71
  // stdout added as developer context). Codex asks the person to trust a new or changed hook once,
69
72
  // with /hooks in its TUI, before it runs; setup says so.
70
73
  export function codexHooksPath(homeDir = os.homedir()) {
@@ -80,8 +83,10 @@ export function installCodexHooks({ runtimeRoot, homeDir = os.homedir(), nodePat
80
83
  starts.push({ matcher: "startup|resume", hooks: [{ type: "command", command: `${JSON.stringify(nodePath)} ${nudge} --agent codex`, statusMessage: "Reading the Harness board", timeout: 10 }] });
81
84
  const prompts = (Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : []).filter((entry) => !isHarnessHook(entry));
82
85
  prompts.push({ hooks: [{ type: "command", command: `${JSON.stringify(nodePath)} ${nudge} --agent codex --if-changed`, timeout: 10 }] });
86
+ const stops = (Array.isArray(hooks.Stop) ? hooks.Stop : []).filter((entry) => !isHarnessHook(entry));
87
+ stops.push({ hooks: [{ type: "command", command: `${JSON.stringify(nodePath)} ${nudge} --agent codex --stop`, timeout: 10 }] });
83
88
  ensureDir(path.dirname(file));
84
- fs.writeFileSync(file, JSON.stringify({ ...settings, hooks: { ...hooks, SessionStart: starts, UserPromptSubmit: prompts } }, null, 2) + "\n");
89
+ fs.writeFileSync(file, JSON.stringify({ ...settings, hooks: { ...hooks, SessionStart: starts, UserPromptSubmit: prompts, Stop: stops } }, null, 2) + "\n");
85
90
  }
86
91
 
87
92
  export function removeCodexHooks(homeDir = os.homedir()) {
@@ -89,7 +94,7 @@ export function removeCodexHooks(homeDir = os.homedir()) {
89
94
  const settings = readJson(file, null);
90
95
  if (!settings?.hooks) return;
91
96
  const hooks = { ...settings.hooks };
92
- for (const event of ["SessionStart", "UserPromptSubmit"]) {
97
+ for (const event of ["SessionStart", "UserPromptSubmit", "Stop"]) {
93
98
  if (Array.isArray(hooks[event])) hooks[event] = hooks[event].filter((entry) => !isHarnessHook(entry));
94
99
  }
95
100
  fs.writeFileSync(file, JSON.stringify({ ...settings, hooks }, null, 2) + "\n");
@@ -100,7 +105,7 @@ export function removeClaudeSessionHook(homeDir = os.homedir()) {
100
105
  const settings = readJson(file, null);
101
106
  if (!settings?.hooks) return;
102
107
  const hooks = { ...settings.hooks };
103
- for (const event of ["SessionStart", "UserPromptSubmit"]) {
108
+ for (const event of ["SessionStart", "UserPromptSubmit", "Stop"]) {
104
109
  if (Array.isArray(hooks[event])) hooks[event] = hooks[event].filter((entry) => !isHarnessHook(entry));
105
110
  }
106
111
  fs.writeFileSync(file, JSON.stringify({ ...settings, hooks }, null, 2) + "\n");
package/lib/daemon.mjs CHANGED
@@ -13,7 +13,7 @@ import { parseSessionFile } from "./parsers.mjs";
13
13
  import { historyRoots, paths } from "./paths.mjs";
14
14
  import { openStore } from "./store.mjs";
15
15
  import { readRunSteps, runAgentTask, runningPids, stopAgentTask } from "./runner.mjs";
16
- import { createSwarm } from "./swarm.mjs";
16
+ import { createSwarm, nativeSessionId } from "./swarm.mjs";
17
17
  import { createCoordinator } from "./coordinator.mjs";
18
18
  import { createWorktrees } from "./worktrees.mjs";
19
19
  import { notificationFor, notifyDesktop } from "./notify.mjs";
@@ -27,6 +27,9 @@ import { ASKED_BY_TASK, CLOUD_HOMED, MACHINE_RUNNERS, TARGETS, aboutOf, member,
27
27
  import { fingerprint, openBound, relayKey, sealBound } from "./sealed.mjs";
28
28
  import { claudeMemNotes } from "./claudemem.mjs";
29
29
  import { machineActivity } from "./activity.mjs";
30
+ import { LIVE_AGENTS, focusTerminal, hostApp, liveSessions, processTable, sessionFeed, sessionFile, sessionOfProcess, workingDirectories } from "./live.mjs";
31
+ import { EDITOR_IDS, agentCommand, editorFolder, editorInstall, editorName, editorTerminalStarts, openEditorFolder, panelLink, placeEnv, placeFrom, placeKey, processEnv, resumeLine, t3App, t3Link, t3OpensLinks, t3Runs, t3Thread } from "./places.mjs";
32
+ import { detectMcpClients } from "./adapters.mjs";
30
33
 
31
34
  // What may be referenced on a thread with another person (the server's rule, src/server/relay/refs.ts, kept
32
35
  // here since a sealed request is opened only on the machines).
@@ -775,6 +778,266 @@ export async function startDaemon() {
775
778
  authorizeRun: (id) => boardCommand({ command: "board.run", agent: "user", id }),
776
779
  });
777
780
 
781
+ // The person's own sessions running now (live.mjs), and what they sent one from Harness: it waits here until that
782
+ // session's hook takes it (nudge.mjs), when the agent stops or the person next types there.
783
+ const saidKey = (agent, sessionId) => `said:${agent}:${sessionId}`;
784
+ const said = (agent, sessionId) => { try { return JSON.parse(store.meta(saidKey(agent, sessionId)) || "[]"); } catch { return []; } };
785
+ const CODING_AGENTS = ["claude-code", "codex", "pi", "opencode"];
786
+ const SESSION_ID = /^[A-Za-z0-9._:-]{1,128}$/;
787
+ const folderOf = (request) => (typeof request.folder === "string" && path.isAbsolute(request.folder) ? request.folder : null);
788
+
789
+ // Where each of the person's sessions runs (places.mjs), recorded when it starts: its hook, plugin or extension sends
790
+ // its environment (board.live.place), else it is read here from the running process. Thirty days of them, per agent.
791
+ const PLACE_MS = 30 * 24 * 3600_000;
792
+ const placeCache = new Map();
793
+ const placesOf = (agentId) => {
794
+ if (!placeCache.has(agentId)) {
795
+ let found = {};
796
+ try { found = JSON.parse(store.meta(`places:${agentId}`) || "{}"); } catch {}
797
+ placeCache.set(agentId, found);
798
+ }
799
+ return placeCache.get(agentId);
800
+ };
801
+ const placedAgents = () => { try { return JSON.parse(store.meta("places:agents") || "[]"); } catch { return []; } };
802
+ const notePlace = (agentId, sessionId, place) => {
803
+ if (!place || !SESSION_ID.test(sessionId)) return;
804
+ const places = placesOf(agentId);
805
+ const now = Date.now();
806
+ const before = places[sessionId];
807
+ const next = { ...place, at: before?.at ?? now, seen: now };
808
+ // The same place, already seen today: nothing worth a write.
809
+ if (before && JSON.stringify({ ...before, seen: 0 }) === JSON.stringify({ ...next, seen: 0 }) && now - before.seen < 24 * 3600_000) return;
810
+ const kept = Object.fromEntries(Object.entries({ ...places, [sessionId]: next }).filter(([, entry]) => now - (entry.seen || 0) < PLACE_MS)
811
+ .sort((a, b) => b[1].seen - a[1].seen).slice(0, 400));
812
+ placeCache.set(agentId, kept);
813
+ store.meta(`places:${agentId}`, JSON.stringify(kept));
814
+ if (!placedAgents().includes(agentId)) store.meta("places:agents", JSON.stringify([...placedAgents(), agentId].slice(-50)));
815
+ };
816
+ /** A run Harness started itself (in its own folders, or a process it runs) is not where the person works. */
817
+ const harnessRun = (folder, pids = []) => Boolean(folder && folder.startsWith(paths.home + path.sep)) || pids.some((pid) => runningPids().includes(pid));
818
+ // A session says where it runs: its environment, and the agent's process (the hook's parent), read here for the app up
819
+ // its process tree and its terminal. Codex's terminals share one background server, started from wherever Codex first
820
+ // ran: a session there has no app or terminal of its own, and is placed by the Codex terminal working in its folder.
821
+ const placeOfReport = (agentId, folder, request) => {
822
+ const table = processTable();
823
+ const pids = (Array.isArray(request.pids) ? request.pids : []).filter((pid) => Number.isInteger(pid) && pid > 1 && table.has(pid)).slice(0, 2);
824
+ if (harnessRun(folder, pids)) return null;
825
+ let env = placeEnv(request.env);
826
+ let host = pids.map((pid) => hostApp(table, pid)).find(Boolean) ?? null;
827
+ let tty = pids.map((pid) => table.get(pid)?.tty).find(Boolean) ?? null;
828
+ if (agentId === "codex" && pids.length && !host && !tty) {
829
+ const clients = [...table].filter(([, row]) => path.basename(row.comm) === "codex" && row.tty).map(([pid]) => pid);
830
+ const cwds = workingDirectories(clients);
831
+ const client = clients.filter((pid) => folder && cwds.get(pid) === folder).sort((a, b) => b - a)[0];
832
+ if (!client) return null;
833
+ env = processEnv(client);
834
+ host = hostApp(table, client);
835
+ tty = table.get(client).tty;
836
+ }
837
+ return placeFrom({ env, host, tty, folder });
838
+ };
839
+ // A custom harness may say how one of its sessions opens again: a page it serves on this computer, or a command to type.
840
+ const reopenOf = (value) => {
841
+ if (!value || typeof value !== "object") return null;
842
+ const link = typeof value.link === "string" && value.link.length <= 2048 && /^http:\/\/(127\.0\.0\.1|localhost)(:\d{1,5})?\/\S*$/.test(value.link) ? value.link : null;
843
+ const command = typeof value.command === "string" && value.command.trim() && value.command.length <= 500 && !/[\0-\x1f]/.test(value.command) ? value.command.trim() : null;
844
+ return link || command ? { link, command } : null;
845
+ };
846
+
847
+ const liveNow = () => {
848
+ const recent = store.recentSessions(200);
849
+ // With the title the index gives it (its first request), when the index has it yet.
850
+ const titleOf = (session) => {
851
+ const title = recent.find((row) => row.agent === session.agent && String(row.session_id).includes(session.sessionId))?.title || null;
852
+ // A session opened with a slash command starts with the agent's own markup, not the person's words.
853
+ return title && !title.startsWith("<") ? title : null;
854
+ };
855
+ const linksOk = t3LinksOk();
856
+ const sessions = liveSessions({ exclude: new Set(runningPids()), recent })
857
+ // A T3 Code that cannot open links is brought forward instead, and messages reach its sessions through their hooks.
858
+ .map((session) => ({ ...session, t3: linksOk ? session.t3 : null, title: titleOf(session), waiting: said(session.agent, session.sessionId).length }));
859
+ // A running session no hook placed (it started before Harness was set up, or its agent has none): read from its process.
860
+ for (const session of sessions) {
861
+ if (placesOf(session.agent)[session.sessionId]) continue;
862
+ const env = { ...processEnv(session.pid), ...(session.entrypoint ? { CLAUDE_CODE_ENTRYPOINT: session.entrypoint } : {}) };
863
+ notePlace(session.agent, session.sessionId, placeFrom({ env, host: session.host, tty: session.tty, folder: session.folder }));
864
+ }
865
+ return sessions;
866
+ };
867
+
868
+ // An agent's home: the place most of its sessions started in over the last thirty days (ties: the latest), where its
869
+ // sessions open when they are not running.
870
+ let seeded = { at: 0, homes: {} };
871
+ /** Before any session was recorded: what this computer already shows, threads run in T3 Code and the agent started in an editor's terminal. */
872
+ const seedHome = (agentId) => {
873
+ if (Date.now() - seeded.at > 10 * 60_000) seeded = { at: Date.now(), homes: {} };
874
+ if (agentId in seeded.homes) return seeded.homes[agentId];
875
+ const command = agentCommand(agentId);
876
+ const seeds = [{ count: t3App() ? t3Runs(agentId, new Date(Date.now() - PLACE_MS).toISOString()) : 0, place: { kind: "t3", name: "T3 Code" } },
877
+ ...(command ? EDITOR_IDS.map((id) => ({ count: editorTerminalStarts(id, command), place: { kind: "editor", editor: id, name: editorName(id), surface: "terminal" } })) : [])];
878
+ return (seeded.homes[agentId] = seeds.filter((seed) => seed.count > 0).sort((a, b) => b.count - a.count)[0]?.place ?? null);
879
+ };
880
+ /** A place with the app that opens it now (T3's running build, an editor's install); null when it is not on this computer. */
881
+ const usable = (place) => {
882
+ if (!place) return null;
883
+ if (place.kind === "t3") { const t3 = t3App(); return t3 ? { ...place, app: t3.app } : null; }
884
+ if (place.kind === "editor" && !(place.cli && fs.existsSync(place.cli))) {
885
+ const install = editorInstall(place.editor, place.app || "");
886
+ return install ? { ...place, ...install } : place.app ? place : null;
887
+ }
888
+ return place;
889
+ };
890
+ const homeOf = (agentId) => {
891
+ const tally = new Map();
892
+ for (const place of Object.values(placesOf(agentId))) {
893
+ if (!place?.kind || place.kind === "unknown") continue;
894
+ const entry = tally.get(placeKey(place)) ?? { place, count: 0 };
895
+ entry.count += 1;
896
+ if ((place.seen || 0) >= (entry.place.seen || 0)) entry.place = place;
897
+ tally.set(placeKey(place), entry);
898
+ }
899
+ const best = [...tally.values()].sort((a, b) => b.count - a.count || (b.place.seen || 0) - (a.place.seen || 0))[0]?.place;
900
+ return usable(best ?? seedHome(agentId));
901
+ };
902
+ /** Where each agent lives, for Open's labels: coding agents' homes, and Grok Bot's own app. */
903
+ const homes = () => {
904
+ const found = {};
905
+ for (const id of new Set([...CODING_AGENTS, ...placedAgents()])) {
906
+ const home = homeOf(id);
907
+ if (home) found[id] = { name: home.name, app: home.app ?? null, kind: home.kind };
908
+ }
909
+ const grok = detectMcpClients().find((client) => client.client === "grok-bot" && client.path);
910
+ if (grok) found["grok-bot"] = { name: "Grok Bot", app: grok.path, kind: "app" };
911
+ return found;
912
+ };
913
+ const t3LinksOk = () => { const t3 = t3App(); return Boolean(t3 && t3OpensLinks(t3.app)); };
914
+ const T3_OLD = "This T3 Code can't be opened at a thread from another app, so it is in front as it is. T3's nightly builds open the exact thread.";
915
+ /** The person's own sessions, not the runs Harness starts in its own folders (their worktrees are not the person's). */
916
+ const ownSession = (row) => !String(row.cwd || "").startsWith(paths.home + path.sep);
917
+ const short = (name) => String(name || "").replace(/ \(.*\)$/, "");
918
+
919
+ /** How a session opens in one place (or the agent itself, with no session); null when that place cannot show it. */
920
+ const placePlan = (agentId, place, sessionId, folder, act) => {
921
+ if (!place) return null;
922
+ const name = short(place.name);
923
+ if (place.kind === "t3") {
924
+ if (!place.app) return null;
925
+ const thread = sessionId ? t3Thread(sessionId) : null;
926
+ if (thread && t3OpensLinks(place.app)) return { opensIn: "T3 Code", link: t3Link(thread) };
927
+ if (thread || !sessionId) return { opensIn: "T3 Code", app: place.app, ...(thread ? { note: T3_OLD } : {}) };
928
+ // A session T3 never had (a run Harness started in its own folder, say): T3 comes forward as it is, saying so,
929
+ // and the session can still be resumed in a terminal instead.
930
+ return { opensIn: "T3 Code", app: place.app, resumable: true, note: harnessRun(folder)
931
+ ? "This run happened in Harness's own copy of the repo, which T3 Code doesn't list, so T3 Code is in front as it is."
932
+ : "T3 Code doesn't list this session, so it is in front as it is." };
933
+ }
934
+ if (place.kind === "editor") {
935
+ // Its window first (the folder the editor has open around the session's), so a panel link lands there.
936
+ const window = editorFolder(place.editor, folder);
937
+ if (act && window) openEditorFolder(place, window);
938
+ const link = sessionId ? panelLink(agentId, place.editor, sessionId) : null;
939
+ if (link) return { opensIn: name, link };
940
+ if (!place.app) return null;
941
+ // An editor's terminal takes no command from another app: the session's own command is copied to paste there.
942
+ const line = sessionId ? resumeLine(agentId, sessionId, folder) : null;
943
+ return { opensIn: name, app: place.app, ...(line ? { copy: line, note: `${name} is up${window ? ` on ${path.basename(window)}` : ""}. The command that resumes this session is copied: paste it in a terminal there.` } : {}) };
944
+ }
945
+ if (place.kind === "codex-app") return agentId === "codex" && sessionId ? { opensIn: "Codex", link: `codex://threads/${sessionId}` } : place.app ? { opensIn: "Codex", app: place.app } : null;
946
+ if (place.kind === "terminal") return CODING_AGENTS.includes(agentId) ? { opensIn: name, resume: { agent: agentId, session: sessionId ?? null, cwd: folder ?? null, app: place.app ?? null } } : null;
947
+ return place.app ? { opensIn: name, app: place.app } : null;
948
+ };
949
+ /** A running session, where it runs: its T3 thread, its panel, its editor's window, its terminal pane and tab. */
950
+ const runningPlan = (live, act) => {
951
+ const place = placesOf(live.agent)[live.sessionId] ?? null;
952
+ if ((live.host && /^T3 Code\b/.test(live.host.name)) || place?.kind === "t3") {
953
+ if (live.t3) return { opensIn: "T3 Code", link: live.t3 };
954
+ return { opensIn: "T3 Code", app: live.host?.app ?? t3App()?.app ?? null, tty: null, ...(t3Thread(live.sessionId) ? { note: T3_OLD } : {}) };
955
+ }
956
+ if (place?.kind === "editor") {
957
+ const link = place.surface === "panel" ? panelLink(live.agent, place.editor, live.sessionId) : null;
958
+ if (link) return { opensIn: short(place.name), link };
959
+ // A session in the editor's terminal: its window. Which terminal tab shows, only the editor itself can pick.
960
+ if (act) openEditorFolder(usable(place), editorFolder(place.editor, live.folder));
961
+ return { opensIn: short(place.name), app: place.app ?? live.host?.app ?? null };
962
+ }
963
+ if (place?.kind === "codex-app" && live.agent === "codex") return { opensIn: "Codex", link: `codex://threads/${live.sessionId}` };
964
+ const opensIn = live.host ? short(live.host.name) : null;
965
+ return { opensIn, ...(act ? focusTerminal(live, place) : live.host ? { app: live.host.app, tty: live.tty } : {}) };
966
+ };
967
+ /**
968
+ * How a session opens, or the agent itself with no session: what the app does next (views/ui.ts openPlace). A running
969
+ * session opens where it runs; one that ended opens in the agent's home, else where it ran, else a terminal resumes
970
+ * it. `act`: the daemon's part happens now (a pane selected, an editor's window brought up); without it, this is only
971
+ * where it would open, for Open's label.
972
+ */
973
+ const openPlan = (agentId, sessionId, folder, act) => {
974
+ const live = sessionId ? liveNow().find((row) => row.agent === agentId && row.sessionId === sessionId) ?? null : null;
975
+ if (live) return runningPlan(live, act);
976
+ const own = sessionId ? placesOf(agentId)[sessionId] ?? null : null;
977
+ const where = folder ?? own?.folder ?? null;
978
+ if (own?.reopen?.link) return { opensIn: short(own.name) || nameOf(agentId), link: own.reopen.link };
979
+ const plan = placePlan(agentId, homeOf(agentId), sessionId, where, act) ?? placePlan(agentId, usable(own), sessionId, where, act);
980
+ if (plan) return plan;
981
+ if (CODING_AGENTS.includes(agentId)) return { opensIn: "Terminal", resume: { agent: agentId, session: sessionId, cwd: where, app: null } };
982
+ const command = own?.reopen?.command;
983
+ return command ? { opensIn: null, copy: command, note: "The command that opens this session again is copied: paste it in a terminal." } : { opensIn: null };
984
+ };
985
+
986
+ const liveCommand = (request, agent) => {
987
+ const sessionId = String(request.sessionId || "");
988
+ if (request.command === "board.live.take") {
989
+ if (!LIVE_AGENTS.includes(agent) || !sessionId) return { ok: false, error: "only a session's own agent takes its messages" };
990
+ const messages = said(agent, sessionId);
991
+ if (messages.length) store.meta(saidKey(agent, sessionId), "[]");
992
+ return { ok: true, messages };
993
+ }
994
+ // A session says where it runs (its hook, plugin or extension, or a custom harness, as it starts).
995
+ if (request.command === "board.live.place") {
996
+ if (agent === "user") return { ok: false, error: "a session reports its own place" };
997
+ if (!SESSION_ID.test(sessionId)) return { ok: false, error: "a place needs the session's id" };
998
+ const folder = folderOf(request);
999
+ const place = placeOfReport(agent, folder, request);
1000
+ const reopen = reopenOf(request.reopen);
1001
+ if (place || (reopen && !harnessRun(folder))) notePlace(agent, sessionId, { ...(place ?? { kind: "unknown", name: nameOf(agent), folder }), ...(reopen ? { reopen } : {}) });
1002
+ return { ok: true };
1003
+ }
1004
+ if (agent !== "user") return { ok: false, error: "only the person reads or messages their sessions" };
1005
+ if (request.command === "board.live") {
1006
+ // Each coding agent's newest session too, so one not running now still opens where it last worked.
1007
+ const last = {};
1008
+ for (const row of store.recentSessions(200)) {
1009
+ if (!LIVE_AGENTS.includes(row.agent) || last[row.agent] || !ownSession(row)) continue;
1010
+ last[row.agent] = { sessionId: nativeSessionId(row.agent, row.session_id), folder: row.cwd || null };
1011
+ }
1012
+ return { ok: true, sessions: liveNow(), last, homes: homes() };
1013
+ }
1014
+ // A session's latest messages and steps, from its own file (running or not).
1015
+ if (request.command === "board.live.read") {
1016
+ const agentId = String(request.to || "");
1017
+ if (!LIVE_AGENTS.includes(agentId) && agentId !== "pi") return { ok: false, error: "only a coding agent's session can be read here" };
1018
+ const file = sessionFile(agentId, sessionId, folderOf(request), store.recentSessions(200));
1019
+ if (!file) return { ok: true, items: [] };
1020
+ return { ok: true, items: sessionFeed(agentId, file, Math.min(200, Number(request.limit) || 80)) };
1021
+ }
1022
+ // Where a session (or, with none, the agent) opens, for Open's label: nothing happens yet.
1023
+ if (request.command === "board.live.where" || request.command === "board.live.focus") {
1024
+ const agentId = String(request.to || "");
1025
+ if (!validAgent(agentId) || (sessionId && !SESSION_ID.test(sessionId))) return { ok: false, error: "that session can't be opened" };
1026
+ // Open itself: the daemon's part happens now, and the app does the rest.
1027
+ return { ok: true, ...openPlan(agentId, sessionId || null, folderOf(request), request.command === "board.live.focus") };
1028
+ }
1029
+ if (request.command === "board.live.say") {
1030
+ const text = String(request.text || "").trim().slice(0, 20_000);
1031
+ if (!text) return { ok: false, error: "a message needs text" };
1032
+ const session = liveNow().find((row) => row.agent === request.to && row.sessionId === sessionId);
1033
+ if (!session) return { ok: false, error: "that session is not running any more" };
1034
+ const messages = [...said(session.agent, sessionId), { text, at: new Date().toISOString() }];
1035
+ store.meta(saidKey(session.agent, sessionId), JSON.stringify(messages));
1036
+ return { ok: true, session: { ...session, waiting: messages.length } };
1037
+ }
1038
+ return { ok: false, error: `unknown command ${request.command}` };
1039
+ };
1040
+
778
1041
  // Lid-Closed Mode's Standby: when anything last came in (a phone message, a wake, a new task).
779
1042
  let listenerAt = Date.now();
780
1043
  const touchListener = () => { listenerAt = Date.now(); };
@@ -791,8 +1054,11 @@ export async function startDaemon() {
791
1054
  if (String(request.command).startsWith("board.swarm.")) return swarmCommand(request, agent);
792
1055
  if (request.command === "board.prompted") {
793
1056
  for (const pid of Array.isArray(request.pids) ? request.pids.slice(0, 2) : []) if (Number.isInteger(pid) && pid > 1) promptedBy.set(pid, agent);
1057
+ // A session that started before its hook said where it runs says so on its next prompt.
1058
+ if (request.sessionId && !placesOf(agent)[String(request.sessionId)]) liveCommand({ ...request, command: "board.live.place" }, agent);
794
1059
  return { ok: true };
795
1060
  }
1061
+ if (String(request.command).startsWith("board.live")) return liveCommand(request, agent);
796
1062
  if (request.command === "board.post" && request.thread && typeof request.thread === "object" && !request.thread[OPENED]
797
1063
  && (request.thread.sealed || (request.thread.role === "asker" && store.meta(`relay_pending:${String(request.id || "")}`)))) {
798
1064
  return { pending: openThreadPost(request).then((prepared) => {
@@ -966,7 +1232,13 @@ export async function startDaemon() {
966
1232
  }
967
1233
  else if (request.command === "board.agents") return { ok: true, agents: store.agentsSeen(), running: [...activeRuns.values()].map(({ agent: runAgent, id: taskId }) => ({ agent: runAgent, id: taskId })) };
968
1234
  else if (["board.claim", "board.report", "board.answer", "board.release"].includes(request.command) && !store.getTask(id) && state.connected) return forwardToHarness(request);
969
- else if (request.command === "board.claim") outcome = store.claimTask(id, agent, expected, Number.isInteger(request.leaseMinutes) ? request.leaseMinutes : 60);
1235
+ else if (request.command === "board.claim") {
1236
+ outcome = store.claimTask(id, agent, expected, Number.isInteger(request.leaseMinutes) ? request.leaseMinutes : 60);
1237
+ // The person's own session that took it, so the task opens there (its MCP server is a child of that session).
1238
+ const claimed = outcome?.ok ? store.getTask(id) : null;
1239
+ const session = claimed && !claimed.session_id && LIVE_AGENTS.includes(agent) ? sessionOfProcess(agent, request.claimerPid, store.recentSessions(200)) : null;
1240
+ if (session) { store.setRunSession(id, session); outcome = { ...outcome, task: store.getTask(id) }; }
1241
+ }
970
1242
  else if (request.command === "board.report") outcome = store.reportTask(id, agent, { status: String(request.status || ""), result: String(request.result || "").slice(0, 200_000) }, expected);
971
1243
  else if (request.command === "board.answer") outcome = store.answerTask(id, agent, String(request.answer || "").slice(0, 20_000), expected);
972
1244
  else if (request.command === "board.release") outcome = store.releaseTask(id, agent, expected);
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/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/swarm.mjs CHANGED
@@ -114,6 +114,9 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
114
114
 
115
115
  const sessionOpen = (agent, sessionId) => sessions().find((session) => session.agent === agent && session.sessionId === sessionId)?.open ?? false;
116
116
 
117
+ // The agent a request names in `to`: null for none, false for one no thread can have.
118
+ const recipient = (request) => (request.to == null || request.to === "" ? null : roomAgents.includes(String(request.to)) ? String(request.to) : false);
119
+
117
120
  const lastFolder = (agent) => store.recentSessions(100).find((session) => session.agent === agent && isDir(session.cwd))?.cwd ?? null;
118
121
 
119
122
  const read = (thread) => {
@@ -125,16 +128,17 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
125
128
  return { ok: true, thread: summary(thread), posts: store.roomPosts(thread.id, 0, 500), runs };
126
129
  };
127
130
 
128
- const post = (thread, text) => {
131
+ // `to`: the one agent the person wrote to (the desktop's agent list), so their words go as written, with no @name.
132
+ const post = (thread, text, to = null) => {
129
133
  // A new name in the person's message joins the thread.
130
- const joining = mentionsIn(text, roomAgents).filter((agent) => !thread.goal.team.includes(agent));
134
+ const joining = [...new Set([...mentionsIn(text, roomAgents), ...(to ? [to] : [])])].filter((agent) => !thread.goal.team.includes(agent));
131
135
  if (joining.length) store.setGoalTeam(thread.id, [...thread.goal.team, ...joining]);
132
136
  if (thread.status !== "open") {
133
137
  store.setGoalState(thread.id, { status: "open" });
134
138
  store.roomPost({ goalId: thread.id, author: "user", kind: "note", text: "Picked up again by you." });
135
139
  }
136
140
  const current = store.getTask(thread.id);
137
- return goalCommand({ command: "board.room.post", id: thread.id, text, mentions: addressed(current, text) }, "user");
141
+ return goalCommand({ command: "board.room.post", id: thread.id, text, mentions: to ? [to] : addressed(current, text) }, "user");
138
142
  };
139
143
 
140
144
  return function swarmCommand(request, agent) {
@@ -198,7 +202,9 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
198
202
  const from = request.continueSession && typeof request.continueSession === "object" ? request.continueSession : null;
199
203
  let folder = request.folder ? String(request.folder) : null;
200
204
  if (folder && !isDir(folder)) return { ok: false, error: `${folder} is not an absolute path to a folder` };
201
- const team = new Set(mentionsIn(text, roomAgents));
205
+ const to = recipient(request);
206
+ if (to === false) return { ok: false, error: `${request.to} can't be written to in a thread` };
207
+ const team = new Set([...mentionsIn(text, roomAgents), ...(to ? [to] : [])]);
202
208
  if (from) {
203
209
  const agentName = String(from.agent || ""), sessionId = String(from.sessionId || "");
204
210
  if (!PROCESS_NAMES[agentName]) return { ok: false, error: "only a coding agent's session continues in a thread" };
@@ -245,7 +251,9 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
245
251
  if (command === "board.swarm.post") {
246
252
  const text = String(request.text || "").trim().slice(0, 20_000);
247
253
  if (!text) return { ok: false, error: "a message needs text" };
248
- const posted = post(thread, text);
254
+ const to = recipient(request);
255
+ if (to === false) return { ok: false, error: `${request.to} can't be written to in a thread` };
256
+ const posted = post(thread, text, to);
249
257
  return posted.ok ? { ...posted, thread: summary(store.getTask(thread.id)) } : posted;
250
258
  }
251
259
  if (command === "board.swarm.stop") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tryharness",
3
- "version": "0.7.7",
3
+ "version": "0.7.8",
4
4
  "description": "Harness Relay: the daemon that turns the agents on this machine into your personal swarm. Memory, history, and work handed between any of them.",
5
5
  "bin": {
6
6
  "tryharness": "cli.mjs"
@@ -36,8 +36,8 @@
36
36
  "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c"
37
37
  },
38
38
  "optionalDependencies": {
39
- "tryharness-desktop-darwin-arm64": "0.7.7",
40
- "tryharness-desktop-darwin-x64": "0.7.7",
39
+ "tryharness-desktop-darwin-arm64": "0.7.8",
40
+ "tryharness-desktop-darwin-x64": "0.7.8",
41
41
  "tryharness-desktop-linux-x64": "0.7.4",
42
42
  "tryharness-desktop-win32-x64": "0.7.4"
43
43
  }