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/assets/opencode-plugin.js +11 -0
- package/assets/pi-extension.ts +8 -0
- package/cli.mjs +27 -1
- package/lib/adapters.mjs +10 -5
- package/lib/daemon.mjs +289 -2
- package/lib/live.mjs +205 -0
- package/lib/mcp-stdio.mjs +1 -1
- package/lib/nudge.mjs +55 -5
- package/lib/phones.mjs +2 -0
- package/lib/places.mjs +297 -0
- package/lib/swarm.mjs +13 -5
- package/lib/t3.mjs +172 -0
- package/package.json +3 -3
|
@@ -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
|
},
|
package/assets/pi-extension.ts
CHANGED
|
@@ -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/cli.mjs
CHANGED
|
@@ -649,8 +649,33 @@ async function link({ quiet = false } = {}) {
|
|
|
649
649
|
}
|
|
650
650
|
}
|
|
651
651
|
|
|
652
|
+
// Approving Harness in T3 Code (lib/t3.mjs): then what you send your T3 Code sessions from Harness, in the desktop app or
|
|
653
|
+
// on your phone, goes straight into their threads. No T3 account; T3's page asks for a pairing code from its settings.
|
|
654
|
+
const t3Words = (access) => !access ? "Harness is not approved in T3 Code. Messages to its sessions wait for them to pick them up (run: tryharness t3)."
|
|
655
|
+
: access.state === "send" ? `Harness sends your messages into T3 Code's threads, until ${access.until.slice(0, 10)}.`
|
|
656
|
+
: access.state === "read" ? "Harness was approved in T3 Code to read only, so messages still wait for its sessions to pick them up. Approve it again with the access your threads run with: tryharness t3"
|
|
657
|
+
: "Harness's approval in T3 Code ran out. Approve it again: tryharness t3";
|
|
658
|
+
async function t3() {
|
|
659
|
+
const sub = argv[0] || "connect";
|
|
660
|
+
if (sub === "status") { console.log(t3Words((await daemonRequest({ command: "t3.status" })).t3)); return; }
|
|
661
|
+
if (sub === "forget") { await daemonRequest({ command: "t3.forget" }); console.log("Harness forgot its approval. To end it in T3 Code as well: Settings → Connections."); return; }
|
|
662
|
+
if (sub !== "connect") throw new Error("usage: tryharness t3 [connect [--print] | status | forget]");
|
|
663
|
+
const before = (await daemonRequest({ command: "t3.status" })).t3;
|
|
664
|
+
const started = await daemonRequest({ command: "t3.connect" }, 20_000);
|
|
665
|
+
if (!started.ok) throw new Error(started.error || "the daemon could not start the approval");
|
|
666
|
+
const opened = !has("--print") && openUrl(started.url);
|
|
667
|
+
console.log(opened ? "T3 Code's approval page is open in your browser." : `Open this in a browser on this computer:\n\n${started.url}\n`);
|
|
668
|
+
console.log("There, enter a pairing code from T3 Code (Settings → Connections), then choose what Harness may do. To message threads that run in Full access, choose Full access.");
|
|
669
|
+
console.log("Waiting for your approval (Ctrl-C stops waiting; an approval still counts).");
|
|
670
|
+
for (;;) {
|
|
671
|
+
await sleep(1_000);
|
|
672
|
+
const now = (await daemonRequest({ command: "t3.status" })).t3;
|
|
673
|
+
if (now && JSON.stringify(now) !== JSON.stringify(before)) { console.log(t3Words(now)); return; }
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
652
677
|
function help() {
|
|
653
|
-
console.log(`tryharness ${VERSION}\n\nCommands:\n tryharness set up everything and start the daemon\n tryharness desktop open the Harness desktop app (setup opens it too; --no-desktop skips it)\n tryharness status show daemon, bridge, agent, and index health\n tryharness doctor diagnose the complete connection\n tryharness sync [--agent] rescan local coding history in the background\n tryharness task <id> --follow\n follow a delegated agent run in this terminal\n tryharness board [--all] list open work on the machine's task board\n tryharness board post --for <agent> --objective <text> [--file <brief.md>]\n tryharness board show <id>\n tryharness board post --for codex --objective <text> --run --workspace <dir>\n the daemon runs it and reports back on the task\n tryharness board run <id> authorize a run an agent asked for\n tryharness board release <id>\n tryharness board agents when each agent last read the board\n tryharness board --watch follow the board in this terminal\n tryharness notify on|off desktop notifications for what waits on you\n tryharness board send <task> <message> send a held reply to another person's agent\n tryharness board auto <task> on|off auto-send replies on that thread\n tryharness board end <task> --outcome <text>\n tryharness board context <task> <query> search everything written about a task\n tryharness goal set --objective <text> [--team a,b] [--workspace <dir>] a goal your agents work together in one room\n tryharness goal [list | show <id> | stop <id> | resume <id>]\n tryharness room <goal> | room post <goal> <text> [--ask agent,agent]\n tryharness swarm rounds [<thread>] what the coordinator saw, chose and cost each round\n tryharness swarm coordinator [harness|local] who coordinates your threads\n tryharness connect <handle/agent> --about <text> ask another person's agent to work an issue with yours, sealed to their computer\n tryharness peers the keys of other people's computers this one has worked with, to compare\n tryharness agents how Harness wakes each of your agents, and set up the rest\n tryharness agents setup <agent> | agents test <agent>\n tryharness mcp grok-bot print the MCP snippet that puts Grok Bot on the board\n tryharness link connect the browser you use Harness in to this computer, end to end (--print shows the link)\n tryharness phone pair show a QR code that pairs your phone with this computer\n tryharness phone list | revoke <id>\n tryharness logs [--follow]\n tryharness disconnect\n`);
|
|
678
|
+
console.log(`tryharness ${VERSION}\n\nCommands:\n tryharness set up everything and start the daemon\n tryharness desktop open the Harness desktop app (setup opens it too; --no-desktop skips it)\n tryharness status show daemon, bridge, agent, and index health\n tryharness doctor diagnose the complete connection\n tryharness sync [--agent] rescan local coding history in the background\n tryharness task <id> --follow\n follow a delegated agent run in this terminal\n tryharness board [--all] list open work on the machine's task board\n tryharness board post --for <agent> --objective <text> [--file <brief.md>]\n tryharness board show <id>\n tryharness board post --for codex --objective <text> --run --workspace <dir>\n the daemon runs it and reports back on the task\n tryharness board run <id> authorize a run an agent asked for\n tryharness board release <id>\n tryharness board agents when each agent last read the board\n tryharness board --watch follow the board in this terminal\n tryharness notify on|off desktop notifications for what waits on you\n tryharness board send <task> <message> send a held reply to another person's agent\n tryharness board auto <task> on|off auto-send replies on that thread\n tryharness board end <task> --outcome <text>\n tryharness board context <task> <query> search everything written about a task\n tryharness goal set --objective <text> [--team a,b] [--workspace <dir>] a goal your agents work together in one room\n tryharness goal [list | show <id> | stop <id> | resume <id>]\n tryharness room <goal> | room post <goal> <text> [--ask agent,agent]\n tryharness swarm rounds [<thread>] what the coordinator saw, chose and cost each round\n tryharness swarm coordinator [harness|local] who coordinates your threads\n tryharness connect <handle/agent> --about <text> ask another person's agent to work an issue with yours, sealed to their computer\n tryharness peers the keys of other people's computers this one has worked with, to compare\n tryharness agents how Harness wakes each of your agents, and set up the rest\n tryharness agents setup <agent> | agents test <agent>\n tryharness mcp grok-bot print the MCP snippet that puts Grok Bot on the board\n tryharness link connect the browser you use Harness in to this computer, end to end (--print shows the link)\n tryharness phone pair show a QR code that pairs your phone with this computer\n tryharness phone list | revoke <id>\n tryharness t3 approve Harness in T3 Code once, so your messages go into its threads (t3 status | t3 forget)\n tryharness logs [--follow]\n tryharness disconnect\n`);
|
|
654
679
|
}
|
|
655
680
|
|
|
656
681
|
try {
|
|
@@ -668,6 +693,7 @@ try {
|
|
|
668
693
|
else if (command === "phone") await phone();
|
|
669
694
|
else if (command === "agents") await agents();
|
|
670
695
|
else if (command === "link") await link();
|
|
696
|
+
else if (command === "t3") await t3();
|
|
671
697
|
else if (command === "peers") await peers();
|
|
672
698
|
else if (command === "connect") await connectAgent();
|
|
673
699
|
else if (command === "desktop") { if (!desktop()) process.exitCode = 1; }
|
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
|
|
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,10 @@ 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";
|
|
33
|
+
import { forgetT3, sendToT3, startT3SignIn, t3Access } from "./t3.mjs";
|
|
30
34
|
|
|
31
35
|
// What may be referenced on a thread with another person (the server's rule, src/server/relay/refs.ts, kept
|
|
32
36
|
// here since a sealed request is opened only on the machines).
|
|
@@ -775,6 +779,276 @@ export async function startDaemon() {
|
|
|
775
779
|
authorizeRun: (id) => boardCommand({ command: "board.run", agent: "user", id }),
|
|
776
780
|
});
|
|
777
781
|
|
|
782
|
+
// The person's own sessions running now (live.mjs), and what they sent one from Harness: it waits here until that
|
|
783
|
+
// session's hook takes it (nudge.mjs), when the agent stops or the person next types there.
|
|
784
|
+
const saidKey = (agent, sessionId) => `said:${agent}:${sessionId}`;
|
|
785
|
+
const said = (agent, sessionId) => { try { return JSON.parse(store.meta(saidKey(agent, sessionId)) || "[]"); } catch { return []; } };
|
|
786
|
+
const CODING_AGENTS = ["claude-code", "codex", "pi", "opencode"];
|
|
787
|
+
const SESSION_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
788
|
+
const folderOf = (request) => (typeof request.folder === "string" && path.isAbsolute(request.folder) ? request.folder : null);
|
|
789
|
+
|
|
790
|
+
// Where each of the person's sessions runs (places.mjs), recorded when it starts: its hook, plugin or extension sends
|
|
791
|
+
// its environment (board.live.place), else it is read here from the running process. Thirty days of them, per agent.
|
|
792
|
+
const PLACE_MS = 30 * 24 * 3600_000;
|
|
793
|
+
const placeCache = new Map();
|
|
794
|
+
const placesOf = (agentId) => {
|
|
795
|
+
if (!placeCache.has(agentId)) {
|
|
796
|
+
let found = {};
|
|
797
|
+
try { found = JSON.parse(store.meta(`places:${agentId}`) || "{}"); } catch {}
|
|
798
|
+
placeCache.set(agentId, found);
|
|
799
|
+
}
|
|
800
|
+
return placeCache.get(agentId);
|
|
801
|
+
};
|
|
802
|
+
const placedAgents = () => { try { return JSON.parse(store.meta("places:agents") || "[]"); } catch { return []; } };
|
|
803
|
+
const notePlace = (agentId, sessionId, place) => {
|
|
804
|
+
if (!place || !SESSION_ID.test(sessionId)) return;
|
|
805
|
+
const places = placesOf(agentId);
|
|
806
|
+
const now = Date.now();
|
|
807
|
+
const before = places[sessionId];
|
|
808
|
+
const next = { ...place, at: before?.at ?? now, seen: now };
|
|
809
|
+
// The same place, already seen today: nothing worth a write.
|
|
810
|
+
if (before && JSON.stringify({ ...before, seen: 0 }) === JSON.stringify({ ...next, seen: 0 }) && now - before.seen < 24 * 3600_000) return;
|
|
811
|
+
const kept = Object.fromEntries(Object.entries({ ...places, [sessionId]: next }).filter(([, entry]) => now - (entry.seen || 0) < PLACE_MS)
|
|
812
|
+
.sort((a, b) => b[1].seen - a[1].seen).slice(0, 400));
|
|
813
|
+
placeCache.set(agentId, kept);
|
|
814
|
+
store.meta(`places:${agentId}`, JSON.stringify(kept));
|
|
815
|
+
if (!placedAgents().includes(agentId)) store.meta("places:agents", JSON.stringify([...placedAgents(), agentId].slice(-50)));
|
|
816
|
+
};
|
|
817
|
+
/** A run Harness started itself (in its own folders, or a process it runs) is not where the person works. */
|
|
818
|
+
const harnessRun = (folder, pids = []) => Boolean(folder && folder.startsWith(paths.home + path.sep)) || pids.some((pid) => runningPids().includes(pid));
|
|
819
|
+
// A session says where it runs: its environment, and the agent's process (the hook's parent), read here for the app up
|
|
820
|
+
// its process tree and its terminal. Codex's terminals share one background server, started from wherever Codex first
|
|
821
|
+
// ran: a session there has no app or terminal of its own, and is placed by the Codex terminal working in its folder.
|
|
822
|
+
const placeOfReport = (agentId, folder, request) => {
|
|
823
|
+
const table = processTable();
|
|
824
|
+
const pids = (Array.isArray(request.pids) ? request.pids : []).filter((pid) => Number.isInteger(pid) && pid > 1 && table.has(pid)).slice(0, 2);
|
|
825
|
+
if (harnessRun(folder, pids)) return null;
|
|
826
|
+
let env = placeEnv(request.env);
|
|
827
|
+
let host = pids.map((pid) => hostApp(table, pid)).find(Boolean) ?? null;
|
|
828
|
+
let tty = pids.map((pid) => table.get(pid)?.tty).find(Boolean) ?? null;
|
|
829
|
+
if (agentId === "codex" && pids.length && !host && !tty) {
|
|
830
|
+
const clients = [...table].filter(([, row]) => path.basename(row.comm) === "codex" && row.tty).map(([pid]) => pid);
|
|
831
|
+
const cwds = workingDirectories(clients);
|
|
832
|
+
const client = clients.filter((pid) => folder && cwds.get(pid) === folder).sort((a, b) => b - a)[0];
|
|
833
|
+
if (!client) return null;
|
|
834
|
+
env = processEnv(client);
|
|
835
|
+
host = hostApp(table, client);
|
|
836
|
+
tty = table.get(client).tty;
|
|
837
|
+
}
|
|
838
|
+
return placeFrom({ env, host, tty, folder });
|
|
839
|
+
};
|
|
840
|
+
// A custom harness may say how one of its sessions opens again: a page it serves on this computer, or a command to type.
|
|
841
|
+
const reopenOf = (value) => {
|
|
842
|
+
if (!value || typeof value !== "object") return null;
|
|
843
|
+
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;
|
|
844
|
+
const command = typeof value.command === "string" && value.command.trim() && value.command.length <= 500 && !/[\0-\x1f]/.test(value.command) ? value.command.trim() : null;
|
|
845
|
+
return link || command ? { link, command } : null;
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
const liveNow = () => {
|
|
849
|
+
const recent = store.recentSessions(200);
|
|
850
|
+
// With the title the index gives it (its first request), when the index has it yet.
|
|
851
|
+
const titleOf = (session) => {
|
|
852
|
+
const title = recent.find((row) => row.agent === session.agent && String(row.session_id).includes(session.sessionId))?.title || null;
|
|
853
|
+
// A session opened with a slash command starts with the agent's own markup, not the person's words.
|
|
854
|
+
return title && !title.startsWith("<") ? title : null;
|
|
855
|
+
};
|
|
856
|
+
const linksOk = t3LinksOk();
|
|
857
|
+
const sessions = liveSessions({ exclude: new Set(runningPids()), recent })
|
|
858
|
+
// A T3 Code that cannot open links is brought forward instead, and messages reach its sessions through their hooks.
|
|
859
|
+
.map((session) => ({ ...session, t3: linksOk ? session.t3 : null, title: titleOf(session), waiting: said(session.agent, session.sessionId).length }));
|
|
860
|
+
// A running session no hook placed (it started before Harness was set up, or its agent has none): read from its process.
|
|
861
|
+
for (const session of sessions) {
|
|
862
|
+
if (placesOf(session.agent)[session.sessionId]) continue;
|
|
863
|
+
const env = { ...processEnv(session.pid), ...(session.entrypoint ? { CLAUDE_CODE_ENTRYPOINT: session.entrypoint } : {}) };
|
|
864
|
+
notePlace(session.agent, session.sessionId, placeFrom({ env, host: session.host, tty: session.tty, folder: session.folder }));
|
|
865
|
+
}
|
|
866
|
+
return sessions;
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
// An agent's home: the place most of its sessions started in over the last thirty days (ties: the latest), where its
|
|
870
|
+
// sessions open when they are not running.
|
|
871
|
+
let seeded = { at: 0, homes: {} };
|
|
872
|
+
/** Before any session was recorded: what this computer already shows, threads run in T3 Code and the agent started in an editor's terminal. */
|
|
873
|
+
const seedHome = (agentId) => {
|
|
874
|
+
if (Date.now() - seeded.at > 10 * 60_000) seeded = { at: Date.now(), homes: {} };
|
|
875
|
+
if (agentId in seeded.homes) return seeded.homes[agentId];
|
|
876
|
+
const command = agentCommand(agentId);
|
|
877
|
+
const seeds = [{ count: t3App() ? t3Runs(agentId, new Date(Date.now() - PLACE_MS).toISOString()) : 0, place: { kind: "t3", name: "T3 Code" } },
|
|
878
|
+
...(command ? EDITOR_IDS.map((id) => ({ count: editorTerminalStarts(id, command), place: { kind: "editor", editor: id, name: editorName(id), surface: "terminal" } })) : [])];
|
|
879
|
+
return (seeded.homes[agentId] = seeds.filter((seed) => seed.count > 0).sort((a, b) => b.count - a.count)[0]?.place ?? null);
|
|
880
|
+
};
|
|
881
|
+
/** 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. */
|
|
882
|
+
const usable = (place) => {
|
|
883
|
+
if (!place) return null;
|
|
884
|
+
if (place.kind === "t3") { const t3 = t3App(); return t3 ? { ...place, app: t3.app } : null; }
|
|
885
|
+
if (place.kind === "editor" && !(place.cli && fs.existsSync(place.cli))) {
|
|
886
|
+
const install = editorInstall(place.editor, place.app || "");
|
|
887
|
+
return install ? { ...place, ...install } : place.app ? place : null;
|
|
888
|
+
}
|
|
889
|
+
return place;
|
|
890
|
+
};
|
|
891
|
+
const homeOf = (agentId) => {
|
|
892
|
+
const tally = new Map();
|
|
893
|
+
for (const place of Object.values(placesOf(agentId))) {
|
|
894
|
+
if (!place?.kind || place.kind === "unknown") continue;
|
|
895
|
+
const entry = tally.get(placeKey(place)) ?? { place, count: 0 };
|
|
896
|
+
entry.count += 1;
|
|
897
|
+
if ((place.seen || 0) >= (entry.place.seen || 0)) entry.place = place;
|
|
898
|
+
tally.set(placeKey(place), entry);
|
|
899
|
+
}
|
|
900
|
+
const best = [...tally.values()].sort((a, b) => b.count - a.count || (b.place.seen || 0) - (a.place.seen || 0))[0]?.place;
|
|
901
|
+
return usable(best ?? seedHome(agentId));
|
|
902
|
+
};
|
|
903
|
+
/** Where each agent lives, for Open's labels: coding agents' homes, and Grok Bot's own app. */
|
|
904
|
+
const homes = () => {
|
|
905
|
+
const found = {};
|
|
906
|
+
for (const id of new Set([...CODING_AGENTS, ...placedAgents()])) {
|
|
907
|
+
const home = homeOf(id);
|
|
908
|
+
if (home) found[id] = { name: home.name, app: home.app ?? null, kind: home.kind };
|
|
909
|
+
}
|
|
910
|
+
const grok = detectMcpClients().find((client) => client.client === "grok-bot" && client.path);
|
|
911
|
+
if (grok) found["grok-bot"] = { name: "Grok Bot", app: grok.path, kind: "app" };
|
|
912
|
+
return found;
|
|
913
|
+
};
|
|
914
|
+
const t3LinksOk = () => { const t3 = t3App(); return Boolean(t3 && t3OpensLinks(t3.app)); };
|
|
915
|
+
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.";
|
|
916
|
+
/** The person's own sessions, not the runs Harness starts in its own folders (their worktrees are not the person's). */
|
|
917
|
+
const ownSession = (row) => !String(row.cwd || "").startsWith(paths.home + path.sep);
|
|
918
|
+
const short = (name) => String(name || "").replace(/ \(.*\)$/, "");
|
|
919
|
+
|
|
920
|
+
/** How a session opens in one place (or the agent itself, with no session); null when that place cannot show it. */
|
|
921
|
+
const placePlan = (agentId, place, sessionId, folder, act) => {
|
|
922
|
+
if (!place) return null;
|
|
923
|
+
const name = short(place.name);
|
|
924
|
+
if (place.kind === "t3") {
|
|
925
|
+
if (!place.app) return null;
|
|
926
|
+
const thread = sessionId ? t3Thread(sessionId) : null;
|
|
927
|
+
if (thread && t3OpensLinks(place.app)) return { opensIn: "T3 Code", link: t3Link(thread) };
|
|
928
|
+
if (thread || !sessionId) return { opensIn: "T3 Code", app: place.app, ...(thread ? { note: T3_OLD } : {}) };
|
|
929
|
+
// A session T3 never had (a run Harness started in its own folder, say): T3 comes forward as it is, saying so,
|
|
930
|
+
// and the session can still be resumed in a terminal instead.
|
|
931
|
+
return { opensIn: "T3 Code", app: place.app, resumable: true, note: harnessRun(folder)
|
|
932
|
+
? "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."
|
|
933
|
+
: "T3 Code doesn't list this session, so it is in front as it is." };
|
|
934
|
+
}
|
|
935
|
+
if (place.kind === "editor") {
|
|
936
|
+
// Its window first (the folder the editor has open around the session's), so a panel link lands there.
|
|
937
|
+
const window = editorFolder(place.editor, folder);
|
|
938
|
+
if (act && window) openEditorFolder(place, window);
|
|
939
|
+
const link = sessionId ? panelLink(agentId, place.editor, sessionId) : null;
|
|
940
|
+
if (link) return { opensIn: name, link };
|
|
941
|
+
if (!place.app) return null;
|
|
942
|
+
// An editor's terminal takes no command from another app: the session's own command is copied to paste there.
|
|
943
|
+
const line = sessionId ? resumeLine(agentId, sessionId, folder) : null;
|
|
944
|
+
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.` } : {}) };
|
|
945
|
+
}
|
|
946
|
+
if (place.kind === "codex-app") return agentId === "codex" && sessionId ? { opensIn: "Codex", link: `codex://threads/${sessionId}` } : place.app ? { opensIn: "Codex", app: place.app } : null;
|
|
947
|
+
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;
|
|
948
|
+
return place.app ? { opensIn: name, app: place.app } : null;
|
|
949
|
+
};
|
|
950
|
+
/** A running session, where it runs: its T3 thread, its panel, its editor's window, its terminal pane and tab. */
|
|
951
|
+
const runningPlan = (live, act) => {
|
|
952
|
+
const place = placesOf(live.agent)[live.sessionId] ?? null;
|
|
953
|
+
if ((live.host && /^T3 Code\b/.test(live.host.name)) || place?.kind === "t3") {
|
|
954
|
+
if (live.t3) return { opensIn: "T3 Code", link: live.t3 };
|
|
955
|
+
return { opensIn: "T3 Code", app: live.host?.app ?? t3App()?.app ?? null, tty: null, ...(t3Thread(live.sessionId) ? { note: T3_OLD } : {}) };
|
|
956
|
+
}
|
|
957
|
+
if (place?.kind === "editor") {
|
|
958
|
+
const link = place.surface === "panel" ? panelLink(live.agent, place.editor, live.sessionId) : null;
|
|
959
|
+
if (link) return { opensIn: short(place.name), link };
|
|
960
|
+
// A session in the editor's terminal: its window. Which terminal tab shows, only the editor itself can pick.
|
|
961
|
+
if (act) openEditorFolder(usable(place), editorFolder(place.editor, live.folder));
|
|
962
|
+
return { opensIn: short(place.name), app: place.app ?? live.host?.app ?? null };
|
|
963
|
+
}
|
|
964
|
+
if (place?.kind === "codex-app" && live.agent === "codex") return { opensIn: "Codex", link: `codex://threads/${live.sessionId}` };
|
|
965
|
+
const opensIn = live.host ? short(live.host.name) : null;
|
|
966
|
+
return { opensIn, ...(act ? focusTerminal(live, place) : live.host ? { app: live.host.app, tty: live.tty } : {}) };
|
|
967
|
+
};
|
|
968
|
+
/**
|
|
969
|
+
* How a session opens, or the agent itself with no session: what the app does next (views/ui.ts openPlace). A running
|
|
970
|
+
* session opens where it runs; one that ended opens in the agent's home, else where it ran, else a terminal resumes
|
|
971
|
+
* it. `act`: the daemon's part happens now (a pane selected, an editor's window brought up); without it, this is only
|
|
972
|
+
* where it would open, for Open's label.
|
|
973
|
+
*/
|
|
974
|
+
const openPlan = (agentId, sessionId, folder, act) => {
|
|
975
|
+
const live = sessionId ? liveNow().find((row) => row.agent === agentId && row.sessionId === sessionId) ?? null : null;
|
|
976
|
+
if (live) return runningPlan(live, act);
|
|
977
|
+
const own = sessionId ? placesOf(agentId)[sessionId] ?? null : null;
|
|
978
|
+
const where = folder ?? own?.folder ?? null;
|
|
979
|
+
if (own?.reopen?.link) return { opensIn: short(own.name) || nameOf(agentId), link: own.reopen.link };
|
|
980
|
+
const plan = placePlan(agentId, homeOf(agentId), sessionId, where, act) ?? placePlan(agentId, usable(own), sessionId, where, act);
|
|
981
|
+
if (plan) return plan;
|
|
982
|
+
if (CODING_AGENTS.includes(agentId)) return { opensIn: "Terminal", resume: { agent: agentId, session: sessionId, cwd: where, app: null } };
|
|
983
|
+
const command = own?.reopen?.command;
|
|
984
|
+
return command ? { opensIn: null, copy: command, note: "The command that opens this session again is copied: paste it in a terminal." } : { opensIn: null };
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
const liveCommand = (request, agent) => {
|
|
988
|
+
const sessionId = String(request.sessionId || "");
|
|
989
|
+
if (request.command === "board.live.take") {
|
|
990
|
+
if (!LIVE_AGENTS.includes(agent) || !sessionId) return { ok: false, error: "only a session's own agent takes its messages" };
|
|
991
|
+
const messages = said(agent, sessionId);
|
|
992
|
+
if (messages.length) store.meta(saidKey(agent, sessionId), "[]");
|
|
993
|
+
return { ok: true, messages };
|
|
994
|
+
}
|
|
995
|
+
// A session says where it runs (its hook, plugin or extension, or a custom harness, as it starts).
|
|
996
|
+
if (request.command === "board.live.place") {
|
|
997
|
+
if (agent === "user") return { ok: false, error: "a session reports its own place" };
|
|
998
|
+
if (!SESSION_ID.test(sessionId)) return { ok: false, error: "a place needs the session's id" };
|
|
999
|
+
const folder = folderOf(request);
|
|
1000
|
+
const place = placeOfReport(agent, folder, request);
|
|
1001
|
+
const reopen = reopenOf(request.reopen);
|
|
1002
|
+
if (place || (reopen && !harnessRun(folder))) notePlace(agent, sessionId, { ...(place ?? { kind: "unknown", name: nameOf(agent), folder }), ...(reopen ? { reopen } : {}) });
|
|
1003
|
+
return { ok: true };
|
|
1004
|
+
}
|
|
1005
|
+
if (agent !== "user") return { ok: false, error: "only the person reads or messages their sessions" };
|
|
1006
|
+
if (request.command === "board.live") {
|
|
1007
|
+
// Each coding agent's newest session too, so one not running now still opens where it last worked.
|
|
1008
|
+
const last = {};
|
|
1009
|
+
for (const row of store.recentSessions(200)) {
|
|
1010
|
+
if (!LIVE_AGENTS.includes(row.agent) || last[row.agent] || !ownSession(row)) continue;
|
|
1011
|
+
last[row.agent] = { sessionId: nativeSessionId(row.agent, row.session_id), folder: row.cwd || null };
|
|
1012
|
+
}
|
|
1013
|
+
return { ok: true, sessions: liveNow(), last, homes: homes(), t3: t3Access() };
|
|
1014
|
+
}
|
|
1015
|
+
// A session's latest messages and steps, from its own file (running or not).
|
|
1016
|
+
if (request.command === "board.live.read") {
|
|
1017
|
+
const agentId = String(request.to || "");
|
|
1018
|
+
if (!LIVE_AGENTS.includes(agentId) && agentId !== "pi") return { ok: false, error: "only a coding agent's session can be read here" };
|
|
1019
|
+
const file = sessionFile(agentId, sessionId, folderOf(request), store.recentSessions(200));
|
|
1020
|
+
if (!file) return { ok: true, items: [] };
|
|
1021
|
+
return { ok: true, items: sessionFeed(agentId, file, Math.min(200, Number(request.limit) || 80)) };
|
|
1022
|
+
}
|
|
1023
|
+
// Where a session (or, with none, the agent) opens, for Open's label: nothing happens yet.
|
|
1024
|
+
if (request.command === "board.live.where" || request.command === "board.live.focus") {
|
|
1025
|
+
const agentId = String(request.to || "");
|
|
1026
|
+
if (!validAgent(agentId) || (sessionId && !SESSION_ID.test(sessionId))) return { ok: false, error: "that session can't be opened" };
|
|
1027
|
+
// Open itself: the daemon's part happens now, and the app does the rest.
|
|
1028
|
+
return { ok: true, ...openPlan(agentId, sessionId || null, folderOf(request), request.command === "board.live.focus") };
|
|
1029
|
+
}
|
|
1030
|
+
if (request.command === "board.live.say") {
|
|
1031
|
+
const text = String(request.text || "").trim().slice(0, 20_000);
|
|
1032
|
+
if (!text) return { ok: false, error: "a message needs text" };
|
|
1033
|
+
const session = liveNow().find((row) => row.agent === request.to && row.sessionId === sessionId);
|
|
1034
|
+
if (!session) return { ok: false, error: "that session is not running any more" };
|
|
1035
|
+
// Kept until the session's own hook takes it: when its turn ends, or the person next types there.
|
|
1036
|
+
const keep = (t3) => {
|
|
1037
|
+
const messages = [...said(session.agent, sessionId), { text, at: new Date().toISOString() }];
|
|
1038
|
+
store.meta(saidKey(session.agent, sessionId), JSON.stringify(messages));
|
|
1039
|
+
return { ok: true, sent: "hooks", session: { ...session, waiting: messages.length }, ...(t3 ? { t3 } : {}) };
|
|
1040
|
+
};
|
|
1041
|
+
// A session in T3 Code, once the person approved Harness there: into its T3 thread, as T3 sends any message.
|
|
1042
|
+
const thread = session.host && /^T3 Code\b/.test(session.host.name) ? t3Thread(sessionId) : null;
|
|
1043
|
+
if (!thread) return keep(null);
|
|
1044
|
+
const access = t3Access();
|
|
1045
|
+
if (access?.state !== "send") return keep(!access ? "not_connected" : access.state === "read" ? "read_only" : access.state);
|
|
1046
|
+
return { pending: sendToT3(thread, text).then((delivery) => ({ ok: true, sent: "t3", delivery, session }),
|
|
1047
|
+
(error) => { log(`T3 Code did not take a message: ${error.message}`); return keep(error.code || "failed"); }) };
|
|
1048
|
+
}
|
|
1049
|
+
return { ok: false, error: `unknown command ${request.command}` };
|
|
1050
|
+
};
|
|
1051
|
+
|
|
778
1052
|
// Lid-Closed Mode's Standby: when anything last came in (a phone message, a wake, a new task).
|
|
779
1053
|
let listenerAt = Date.now();
|
|
780
1054
|
const touchListener = () => { listenerAt = Date.now(); };
|
|
@@ -791,8 +1065,11 @@ export async function startDaemon() {
|
|
|
791
1065
|
if (String(request.command).startsWith("board.swarm.")) return swarmCommand(request, agent);
|
|
792
1066
|
if (request.command === "board.prompted") {
|
|
793
1067
|
for (const pid of Array.isArray(request.pids) ? request.pids.slice(0, 2) : []) if (Number.isInteger(pid) && pid > 1) promptedBy.set(pid, agent);
|
|
1068
|
+
// A session that started before its hook said where it runs says so on its next prompt.
|
|
1069
|
+
if (request.sessionId && !placesOf(agent)[String(request.sessionId)]) liveCommand({ ...request, command: "board.live.place" }, agent);
|
|
794
1070
|
return { ok: true };
|
|
795
1071
|
}
|
|
1072
|
+
if (String(request.command).startsWith("board.live")) return liveCommand(request, agent);
|
|
796
1073
|
if (request.command === "board.post" && request.thread && typeof request.thread === "object" && !request.thread[OPENED]
|
|
797
1074
|
&& (request.thread.sealed || (request.thread.role === "asker" && store.meta(`relay_pending:${String(request.id || "")}`)))) {
|
|
798
1075
|
return { pending: openThreadPost(request).then((prepared) => {
|
|
@@ -966,7 +1243,13 @@ export async function startDaemon() {
|
|
|
966
1243
|
}
|
|
967
1244
|
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
1245
|
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")
|
|
1246
|
+
else if (request.command === "board.claim") {
|
|
1247
|
+
outcome = store.claimTask(id, agent, expected, Number.isInteger(request.leaseMinutes) ? request.leaseMinutes : 60);
|
|
1248
|
+
// The person's own session that took it, so the task opens there (its MCP server is a child of that session).
|
|
1249
|
+
const claimed = outcome?.ok ? store.getTask(id) : null;
|
|
1250
|
+
const session = claimed && !claimed.session_id && LIVE_AGENTS.includes(agent) ? sessionOfProcess(agent, request.claimerPid, store.recentSessions(200)) : null;
|
|
1251
|
+
if (session) { store.setRunSession(id, session); outcome = { ...outcome, task: store.getTask(id) }; }
|
|
1252
|
+
}
|
|
970
1253
|
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
1254
|
else if (request.command === "board.answer") outcome = store.answerTask(id, agent, String(request.answer || "").slice(0, 20_000), expected);
|
|
972
1255
|
else if (request.command === "board.release") outcome = store.releaseTask(id, agent, expected);
|
|
@@ -1307,6 +1590,10 @@ export async function startDaemon() {
|
|
|
1307
1590
|
else if (request.command === "phone.list") reply({ ok: true, phones: phones.list() });
|
|
1308
1591
|
else if (request.command === "phone.revoke") reply(phones.revoke(request.id));
|
|
1309
1592
|
else if (request.command === "wake.local") reply({ ok: true, agents: localWaker.status() });
|
|
1593
|
+
// Approving Harness in T3 Code: the page to approve it on, for the desktop app or the CLI to open.
|
|
1594
|
+
else if (request.command === "t3.connect") startT3SignIn().then((started) => reply({ ok: true, ...started }), (error) => reply({ ok: false, error: error.message }));
|
|
1595
|
+
else if (request.command === "t3.status") reply({ ok: true, t3: t3Access() });
|
|
1596
|
+
else if (request.command === "t3.forget") { forgetT3(); reply({ ok: true }); }
|
|
1310
1597
|
// How Harness wakes the agents it keeps on the server (Grok Bot): read and saved there, never here.
|
|
1311
1598
|
else if (request.command === "wake.server") askHarness("wake", request.input || {}).then(reply, (error) => reply({ ok: false, error: error.message }));
|
|
1312
1599
|
// Whether agent work is going on here, for the desktop app deciding when the Mac may sleep.
|