tryharness 0.7.13 → 0.7.18

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/cli.mjs CHANGED
@@ -16,7 +16,7 @@ import { paths } from "./lib/paths.mjs";
16
16
  import { currentRuntime, installRuntime } from "./lib/runtime-install.mjs";
17
17
  import { ensureModel, modelPresent } from "./lib/embedder.mjs";
18
18
  import { installService, serviceInfo, uninstallService } from "./lib/service.mjs";
19
- import { canShowDesktop, desktopSource, desktopTarget, installDesktop, openDesktop, retireDesktop } from "./lib/desktop.mjs";
19
+ import { canShowDesktop, openDesktop, retireDesktop, setupDesktop } from "./lib/desktop.mjs";
20
20
 
21
21
  const SOURCE_ROOT = path.dirname(fileURLToPath(import.meta.url));
22
22
  const PACKAGE = readJson(path.join(SOURCE_ROOT, "package.json"), { version: "0.4.3" });
@@ -167,7 +167,12 @@ async function connect() {
167
167
  } catch (error) {
168
168
  ui.warn(`Browser not connected (${error.message}). Run: npx tryharness link`);
169
169
  }
170
- if (!has("--no-desktop")) { const shown = desktop(); (shown.ok ? ui.success : ui.info)(shown.text); }
170
+ if (!has("--no-desktop")) {
171
+ const spin = ui.spinner();
172
+ spin.start("Getting the newest desktop app");
173
+ const shown = await desktop();
174
+ spin.stop(shown.text, shown.ok ? 0 : 2);
175
+ }
171
176
  // The context space's embedder lives with the daemon and comes down with setup, so the machine
172
177
  // can search a task by meaning before the first task exists. About 110 MB, once.
173
178
  if (modelPresent(paths.models)) ui.success("Embedder ready");
@@ -186,14 +191,14 @@ async function connect() {
186
191
  ui.outro("Done. You can close this terminal.");
187
192
  }
188
193
 
189
- // The desktop app: installed from this machine's platform package and opened. Never fatal; the
194
+ // The desktop app: the newest build for this machine, installed and opened. Never fatal; the
190
195
  // daemon works without it.
191
- function desktop() {
196
+ async function desktop() {
192
197
  if (!canShowDesktop({})) return { ok: false, text: "Desktop app skipped (no screen on this login)" };
193
- const source = desktopSource({ sourceRoot: SOURCE_ROOT });
194
- if (!source) return { ok: false, text: `No desktop app for ${process.platform} ${process.arch} yet` };
195
198
  try {
196
- const { target, updated } = installDesktop({ source, target: desktopTarget({ source }) });
199
+ const installed = await setupDesktop({ sourceRoot: SOURCE_ROOT });
200
+ if (!installed) return { ok: false, text: `No desktop app for ${process.platform} ${process.arch} yet` };
201
+ const { target, updated } = installed;
197
202
  openDesktop({ target });
198
203
  return { ok: true, text: `Desktop app ${updated ? "installed and opened" : "opened"}. The Harness mark is at the top right of your screen; it starts when you log in.` };
199
204
  } catch (error) {
@@ -721,7 +726,7 @@ try {
721
726
  else if (command === "t3") await t3();
722
727
  else if (command === "peers") await peers();
723
728
  else if (command === "connect") await connectAgent();
724
- else if (command === "desktop") { const shown = desktop(); console.log(shown.text); if (!shown.ok) process.exitCode = 1; }
729
+ else if (command === "desktop") { const shown = await desktop(); console.log(shown.text); if (!shown.ok) process.exitCode = 1; }
725
730
  else if (command === "logs") logs();
726
731
  else if (command === "disconnect") await disconnect();
727
732
  else if (command === "daemon") { const { startDaemon } = await import("./lib/daemon.mjs"); await startDaemon(); }
package/lib/daemon.mjs CHANGED
@@ -28,10 +28,10 @@ import { fingerprint, openBound, openBytes, relayKey, sealBound, sealBytes } fro
28
28
  import { claudeMemNotes } from "./claudemem.mjs";
29
29
  import { machineActivity } from "./activity.mjs";
30
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";
31
+ import { EDITOR_IDS, agentCommand, editorFolder, editorInstall, editorName, editorTerminalStarts, openEditorFolder, panelLink, placeEnv, placeFrom, placeKey, processEnv, resumeLine, t3App, t3Link, t3Model, t3Open, t3OpensLinks, t3Project, t3Provider, t3Runs, t3Thread } from "./places.mjs";
32
32
  import { detectAgentApps, detectMcpClients } from "./adapters.mjs";
33
33
  import { cardLine, swarmBlock, swarmCards, swarmDigest } from "./cards.mjs";
34
- import { forgetT3, sendToT3, startT3SignIn, t3Access } from "./t3.mjs";
34
+ import { forgetT3, handedToT3, runInT3, sendToT3, startT3SignIn, t3Access } from "./t3.mjs";
35
35
 
36
36
  // What may be referenced on a thread with another person (the server's rule, src/server/relay/refs.ts, kept
37
37
  // here since a sealed request is opened only on the machines).
@@ -599,6 +599,33 @@ export async function startDaemon() {
599
599
  return !root || Boolean(store.getTask(root)?.run_authorized_at);
600
600
  };
601
601
  const roomDigest = (goalId, limit = 30) => store.roomPosts(goalId).slice(-limit).map((post) => `[${post.seq}] ${post.author}${post.kind !== "post" ? ` (${post.kind})` : ""}: ${post.text.slice(0, 1_200)}`).join("\n");
602
+ // A thread's coding agent works in T3 Code when the person uses it there: its home is T3, Harness may send into T3,
603
+ // T3 is open with a project for the thread's folder, and the agent has run in T3 before (its model there is the
604
+ // person's last choice). A thread in Harness's own copy moves into T3 on its next turn, in that same checkout (a git
605
+ // worktree of T3's project, which T3 takes as it is); one carrying on a session in place stays there; one in T3 stays
606
+ // in T3, and a turn T3 can't take then fails saying why. Reviews keep the agent's read-only switch here.
607
+ const inT3 = (thread, agent, review) => {
608
+ if (review || !t3Provider(agent) || !thread.goal.workspace) return false;
609
+ const tree = worktrees.get(thread.id);
610
+ if (tree?.state === "t3") return true;
611
+ if (tree && !(tree.state === "open" && fs.existsSync(tree.path))) return false;
612
+ return t3Access()?.state === "send" && homeOf(agent)?.kind === "t3" && t3Open() && Boolean(t3Project(thread.goal.workspace)) && Boolean(t3Model(agent));
613
+ };
614
+ // A turn T3 carries out (t3.mjs runInT3): into the agent's T3 thread in this thread, else a new one in T3's project
615
+ // for the folder, on the thread's T3 checkout. The thread works in T3 from its first launch.
616
+ const t3Turn = (task, agent) => {
617
+ const thread = store.getTask(task.parent_id);
618
+ const key = `t3-thread:${thread.id}:${agent}`;
619
+ const model = t3Model(agent);
620
+ const project = t3Project(thread.goal.workspace);
621
+ return {
622
+ threadId: store.meta(key) || null,
623
+ title: thread.objective.slice(0, 120),
624
+ launch: { ...(project ? { projectId: project.id } : {}), ...(model ? { modelSelection: model } : {}), workspaceStrategy: worktrees.t3Workspace(thread) },
625
+ onThread: (threadId) => { store.meta(key, threadId); worktrees.inT3(thread.id); },
626
+ onWorktree: (where, branch) => worktrees.inT3(thread.id, where, branch),
627
+ };
628
+ };
602
629
  // Wake one teammate. A coding agent here gets a run in the goal's folder that resumes its last session on this goal;
603
630
  // Harness and agents that cannot be started here learn of it through the board change and their own reads.
604
631
  const pendingWakes = new Map();
@@ -618,10 +645,13 @@ export async function startDaemon() {
618
645
  if (store.openWake(goalId, agent)) { pendingWakes.set(`${goalId}:${agent}`, { reason, byPerson: byPerson || Boolean(pendingWakes.get(`${goalId}:${agent}`)?.byPerson) }); return; }
619
646
  if (store.wakeCount(goalId, agent) >= goal.max_wakes) { pauseGoal(goalId, `${agent} was woken ${goal.max_wakes} times`); return; }
620
647
  const brief = swarmBrief(task, agent, reason, { review });
621
- // A coding thread in a repository works in its own worktree, so threads never share a checkout.
622
- let folder;
623
- try { folder = worktrees.folderFor(task); } catch (error) { store.roomPost({ goalId, author: "harness", kind: "note", text: `${agent} cannot start: ${error.message}` }); notifyBoard(store.getTask(goalId)); return; }
648
+ // A coding thread in a repository works in its own worktree, so threads never share a checkout; one whose agents the
649
+ // person uses in T3 Code works in T3 (inT3), where T3 makes and keeps the checkout.
650
+ const t3 = inT3(task, agent, review);
651
+ let folder = goal.workspace;
652
+ if (!t3) try { folder = worktrees.folderFor(task); } catch (error) { store.roomPost({ goalId, author: "harness", kind: "note", text: `${agent} cannot start: ${error.message}` }); notifyBoard(store.getTask(goalId)); return; }
624
653
  const wake = store.postTask({ objective: `Room: ${task.objective}`.slice(0, 500), brief, originAgent: "harness", provenance: { trust: "agent", chain: ["room"] }, targetAgent: agent, parentId: goalId, runWorkspace: folder, kind: "wake" });
654
+ if (t3) store.meta(`t3-run:${wake.id}`, "1");
625
655
  // A review runs with the agent's own read-only switch (runner.mjs); only Claude Code and Codex have one.
626
656
  if (review) store.meta(`review:${wake.id}`, "1");
627
657
  if (threadMayRun(task)) {
@@ -925,7 +955,10 @@ export async function startDaemon() {
925
955
  return title && !title.startsWith("<") ? title : null;
926
956
  };
927
957
  const linksOk = t3LinksOk();
958
+ const handed = handedToT3();
928
959
  const sessions = liveSessions({ exclude: new Set(runningPids()), recent })
960
+ // A T3 thread running a turn Harness handed it is Harness's run, shown in its thread.
961
+ .filter((session) => !handed.has(session.t3Thread))
929
962
  // A T3 Code that cannot open links is brought forward instead, and messages reach its sessions through their hooks.
930
963
  .map((session) => ({ ...session, t3: linksOk ? session.t3 : null, title: titleOf(session), waiting: said(session.agent, session.sessionId).length }));
931
964
  // A running session no hook placed (it started before Harness was set up, or its agent has none): read from its process.
@@ -999,9 +1032,10 @@ export async function startDaemon() {
999
1032
  if (thread && t3OpensLinks(place.app)) return { opensIn: "T3 Code", link: t3Link(thread) };
1000
1033
  if (thread || !sessionId) return { opensIn: "T3 Code", app: place.app, ...(thread ? { note: T3_OLD } : {}) };
1001
1034
  // A session T3 never had (a run Harness started in its own folder, say): T3 comes forward as it is, saying so,
1002
- // and the session can still be resumed in a terminal instead.
1035
+ // and the session can still be resumed in a terminal instead. Until Harness may send into T3, its threads run in
1036
+ // that copy: approving it is what moves them into T3 (inT3).
1003
1037
  return { opensIn: "T3 Code", app: place.app, resumable: true, note: harnessRun(folder)
1004
- ? "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."
1038
+ ? `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.${t3Access()?.state === "send" ? "" : " Approve Harness in T3 Code and this thread's next turns run there, where Open finds them."}`
1005
1039
  : "T3 Code doesn't list this session, so it is in front as it is." };
1006
1040
  }
1007
1041
  if (place.kind === "editor") {
@@ -1046,6 +1080,11 @@ export async function startDaemon() {
1046
1080
  const openPlan = (agentId, sessionId, folder, act) => {
1047
1081
  const live = sessionId ? liveNow().find((row) => row.agent === agentId && row.sessionId === sessionId) ?? null : null;
1048
1082
  if (live) return runningPlan(live, act);
1083
+ // A T3 Code thread itself (a turn Harness handed to T3) opens there, wherever the agent usually works.
1084
+ if (sessionId && t3Thread(sessionId) === sessionId) {
1085
+ const plan = placePlan(agentId, usable({ kind: "t3", name: "T3 Code" }), sessionId, folder, act);
1086
+ if (plan) return plan;
1087
+ }
1049
1088
  const own = sessionId ? placesOf(agentId)[sessionId] ?? null : null;
1050
1089
  const where = folder ?? own?.folder ?? null;
1051
1090
  if (own?.reopen?.link) return { opensIn: short(own.name) || nameOf(agentId), link: own.reopen.link };
@@ -1407,6 +1446,8 @@ export async function startDaemon() {
1407
1446
  const text = `${defaults.map((rule) => rule.agent).join(", ")} now ${one ? "starts" : "start"} the tasks you ask for in folders ${one ? "it has" : "they have"} worked in, up to ${defaults[0].daily_cap} a day${one ? "" : " each"}, without a Start. Change it: npx tryharness board standing`;
1408
1447
  log(text); notifyDesktop(text); store.meta("standing_announced", new Date().toISOString());
1409
1448
  }
1449
+ // Where a run works, one at a time: its folder, or for a turn T3 Code carries out, its thread (T3 keeps the checkout).
1450
+ const slotOf = (task) => (store.meta(`t3-run:${task.id}`) === "1" ? `t3:${task.parent_id}` : task.run_workspace ? path.resolve(task.run_workspace) : task.target_agent);
1410
1451
  // Lid-Closed Mode holds new runs while the Mac runs warm with the lid closed; running ones go on.
1411
1452
  let runsHeld = false;
1412
1453
  const runBoard = () => {
@@ -1414,7 +1455,7 @@ export async function startDaemon() {
1414
1455
  // One run per folder at a time: each thread's worktree is its own folder, so threads run side by side.
1415
1456
  for (const task of store.runnableTasks(installedAgents)) {
1416
1457
  const agent = task.target_agent;
1417
- const slot = task.run_workspace ? path.resolve(task.run_workspace) : agent;
1458
+ const slot = slotOf(task);
1418
1459
  if (activeRuns.has(slot)) continue;
1419
1460
  const claimed = store.claimTask(task.id, agent, task.version, null);
1420
1461
  if (!claimed.ok) continue;
@@ -1451,15 +1492,16 @@ export async function startDaemon() {
1451
1492
  const readOnly = store.meta(`review:${task.id}`) === "1";
1452
1493
  // Another agent's words on a goal's authority, not the person's tap: restricted (docs/member-runs.md).
1453
1494
  const restricted = task.trust !== "user" && task.run_authorized_by === "goal" && store.meta(`person-wake:${task.id}`) !== "1";
1454
- startTask({ runId: task.id, agent, title: task.objective.slice(0, 120), brief: resumeSession && again && !wake ? again : [again, brief].filter(Boolean).join("\n\n"), cwd: task.run_workspace, ...(stoppedFor ? { again: true } : {}), ...(resumeSession ? { resumeSession } : {}), ...(readOnly ? { readOnly } : {}), ...(restricted ? { restricted } : {}) }, (kind, text, extra) => {
1455
- // The agent's session in a thread is resumed when the thread calls on it again.
1456
- if (kind === "session" && text) { store.setRunSession(task.id, text); if (wake || room?.kind === "goal") store.setGoalSession(task.parent_id, agent, text); notifyBoard(store.getTask(task.id)); }
1495
+ const t3 = wake && store.meta(`t3-run:${task.id}`) === "1" ? t3Turn(task, agent) : null;
1496
+ startTask({ runId: task.id, agent, title: task.objective.slice(0, 120), brief: resumeSession && again && !wake ? again : [again, brief].filter(Boolean).join("\n\n"), cwd: task.run_workspace, ...(stoppedFor ? { again: true } : {}), ...(resumeSession && !t3 ? { resumeSession } : {}), ...(readOnly ? { readOnly } : {}), ...(restricted ? { restricted } : {}), ...(t3 ? { t3 } : {}) }, (kind, text, extra) => {
1497
+ // The agent's session in a thread is resumed when the thread calls on it again; in T3 it is the agent's T3 thread.
1498
+ if (kind === "session" && text) { store.setRunSession(task.id, text); if (!t3 && (wake || room?.kind === "goal")) store.setGoalSession(task.parent_id, agent, text); notifyBoard(store.getTask(task.id)); }
1457
1499
  // A step the agent took: whoever has the thread open reads it again, at most once a second per run.
1458
1500
  if (kind === "progress" && Date.now() - (lastStepNotice.get(task.id) ?? 0) >= 1_000) { lastStepNotice.set(task.id, Date.now()); notifyBoard(store.getTask(task.id)); }
1459
1501
  if (kind === "accepted" && stoppedFor) store.meta(`requeued:${task.id}`, "");
1460
1502
  if (kind === "done" || kind === "failed") {
1461
1503
  lastStepNotice.delete(task.id);
1462
- activeRuns.delete(task.run_workspace ? path.resolve(task.run_workspace) : agent);
1504
+ activeRuns.delete(slotOf(task));
1463
1505
  const reason = interrupting.get(task.id);
1464
1506
  interrupting.delete(task.id);
1465
1507
  if (kind === "failed" && reason && store.requeueRun(task.id).ok) {
@@ -1820,7 +1862,8 @@ export async function startDaemon() {
1820
1862
  const observe = (stream, raw) => {
1821
1863
  try { fs.appendFileSync(files.log, stream === "stderr" ? `\n[stderr] ${raw}` : raw, { mode: 0o600 }); } catch {}
1822
1864
  };
1823
- runAgentTask(message, emit, observe);
1865
+ if (message.t3) runInT3({ runId: message.runId, brief: message.brief, cwd: message.cwd, ...message.t3 }, emit, observe);
1866
+ else runAgentTask(message, emit, observe);
1824
1867
  };
1825
1868
  // Drops the connection and dials again in about a second, whatever the backoff had grown to.
1826
1869
  const reconnectNow = (reason) => {
package/lib/desktop.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
@@ -7,12 +8,75 @@ import { run } from "./commands.mjs";
7
8
  import { ensureDir, readJson, writeJsonAtomic } from "./files.mjs";
8
9
  import { paths } from "./paths.mjs";
9
10
 
10
- // The desktop app ships as one optional npm package per platform (tryharness-desktop-<os>-<cpu>),
11
- // so `npx tryharness` brings the build for this machine and nothing else. Setup copies it out of
12
- // npm's temporary cache to a place it keeps, opens it, and the app registers itself to start at
13
- // login from there.
11
+ // The desktop app ships as one npm package per platform (tryharness-desktop-<os>-<cpu>), an optional
12
+ // dependency of tryharness, so `npx tryharness` brings the build for this machine and nothing else.
13
+ // Setup installs the newest build there is: npx may run a tryharness it cached before a newer app was
14
+ // published, so setup also asks npm, and never puts an older build over a newer one. It copies the app
15
+ // to a place it keeps, opens it, and the app registers itself to start at login from there.
14
16
 
17
+ const REGISTRY = "https://registry.npmjs.org";
15
18
  const marker = () => path.join(paths.home, "desktop.json");
19
+ const downloads = () => path.join(paths.home, "desktop-download");
20
+
21
+ /** Whether version `a` is newer than `b`: x.y.z, a prerelease (0.7.13-local.ab12) before its release. */
22
+ export function newerVersion(a, b) {
23
+ const parse = (v) => /^(\d+)\.(\d+)\.(\d+)(-.+)?$/.exec(String(v ?? ""));
24
+ const x = parse(a), y = parse(b);
25
+ if (!x || !y) return Boolean(x);
26
+ for (let i = 1; i <= 3; i++) if (Number(x[i]) !== Number(y[i])) return Number(x[i]) > Number(y[i]);
27
+ return !x[4] && Boolean(y[4]);
28
+ }
29
+
30
+ /**
31
+ * This machine's desktop package as npm has it now, downloaded when it is newer than `than` and unpacked under
32
+ * Harness's home. The download must match the checksum npm publishes for it. Null when there is nothing newer,
33
+ * nothing for this machine, or npm cannot be reached: setup goes on with what it has.
34
+ */
35
+ export async function latestDesktop({ than, platform = process.platform, arch = process.arch, registry = REGISTRY }) {
36
+ try {
37
+ const response = await fetch(`${registry}/${desktopPackageName(platform, arch)}/latest`);
38
+ if (!response.ok) return null;
39
+ const meta = await response.json();
40
+ const tarball = String(meta.dist?.tarball ?? "");
41
+ if (!meta.harnessDesktop || !newerVersion(meta.version, than) || !tarball.startsWith(`${registry}/`)) return null;
42
+ const download = await fetch(tarball);
43
+ if (!download.ok) return null;
44
+ const bytes = Buffer.from(await download.arrayBuffer());
45
+ const [algorithm, digest] = String(meta.dist.integrity ?? "").split(/-(.*)/s);
46
+ if (algorithm !== "sha512" || createHash("sha512").update(bytes).digest("base64") !== digest) return null;
47
+ fs.rmSync(downloads(), { recursive: true, force: true });
48
+ ensureDir(downloads());
49
+ const file = path.join(downloads(), "package.tgz");
50
+ fs.writeFileSync(file, bytes);
51
+ if (!run("tar", ["-xzf", file, "-C", downloads()]).ok) return null;
52
+ const app = path.join(downloads(), "package", String(meta.harnessDesktop));
53
+ return fs.existsSync(app) ? { app, version: String(meta.version) } : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Setup's desktop app: the newest of the one npx brought, the one on npm and the one installed, never an older build
61
+ * over a newer one (a local build stays). Where it is and whether it changed, or null when this machine has none.
62
+ * HARNESS_DESKTOP_APP installs that app as it is.
63
+ */
64
+ export async function setupDesktop({ sourceRoot, env = process.env, platform = process.platform, arch = process.arch, registry = REGISTRY, execute = run }) {
65
+ const bundled = desktopSource({ sourceRoot, env, platform, arch });
66
+ const install = (source) => installDesktop({ source, target: desktopTarget({ source, env, platform }), platform, execute });
67
+ if (env.HARNESS_DESKTOP_APP) return bundled && install(bundled);
68
+ const installed = readJson(marker(), {});
69
+ const have = installed.path && fs.existsSync(installed.path) ? installed : null;
70
+ const than = newerVersion(have?.version, bundled?.version) ? have.version : bundled?.version ?? have?.version;
71
+ const latest = await latestDesktop({ than, platform, arch, registry });
72
+ const source = latest ?? bundled;
73
+ try {
74
+ if (have && (!source || newerVersion(have.version, source.version))) return { target: have.path, updated: false };
75
+ return source && install(source);
76
+ } finally {
77
+ if (latest) fs.rmSync(downloads(), { recursive: true, force: true });
78
+ }
79
+ }
16
80
 
17
81
  export function desktopPackageName(platform = process.platform, arch = process.arch) {
18
82
  return `tryharness-desktop-${platform}-${arch}`;
package/lib/live.mjs CHANGED
@@ -6,7 +6,7 @@ import { readJson } from "./files.mjs";
6
6
  import { historyRoots } from "./paths.mjs";
7
7
  import { PS } from "./nudge.mjs";
8
8
  import { stepsOf } from "./runner.mjs";
9
- import { t3Link, t3Thread } from "./places.mjs";
9
+ import { t3App, t3Link, t3Thread, t3Working } from "./places.mjs";
10
10
 
11
11
  // The person's own coding sessions running on this computer right now, each with the app it runs in: a terminal, an
12
12
  // editor or T3 Code, found by walking up the session's process tree to the first process inside an app bundle. A
@@ -113,12 +113,17 @@ export function liveSessions({ exclude = new Set(), recent = [], table = process
113
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
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
115
  }
116
+ // Codex in T3 Code runs on that shared server too, so no process says which T3 thread it works in: T3's own record does.
117
+ const t3 = t3App();
118
+ if (t3) for (const row of t3Working("codex")) {
119
+ if (!sessions.some((session) => session.sessionId === row.sessionId)) sessions.push({ agent: "codex", sessionId: row.sessionId, folder: row.folder, pid: null, busy: true, at: row.at, host: { name: t3.name, app: t3.app }, t3Thread: row.thread });
120
+ }
116
121
  return sessions.sort((a, b) => b.at - a.at).map((session) => {
117
122
  const tty = table.get(session.pid)?.tty ?? null;
118
123
  // Inside tmux the app is the terminal a tmux client shows the pane in.
119
124
  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;
125
+ const host = session.host ?? tmux?.client?.host ?? hostApp(table, session.pid);
126
+ const t3 = host && /^T3 Code\b/.test(host.name) ? t3Link(session.t3Thread ?? t3Thread(session.sessionId)) : null;
122
127
  return { ...session, tty, tmux: tmux?.target ?? null, host, t3 };
123
128
  });
124
129
  }
package/lib/places.mjs CHANGED
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
+ import { mainCheckout } from "./worktrees.mjs";
6
7
 
7
8
  // Where a coding session runs, and how it opens there again. A session's place comes from its own environment: the
8
9
  // Harness piece inside the agent sends it when the session starts (the Claude Code and Codex hooks, nudge.mjs; the
@@ -154,6 +155,8 @@ const AGENTS = {
154
155
  opencode: { command: "opencode", resume: "opencode --session" },
155
156
  };
156
157
  export const agentCommand = (agent) => AGENTS[agent]?.command ?? null;
158
+ /** An agent's provider in T3 Code ("claudeAgent", "codex"); null for an agent T3 does not run. */
159
+ export const t3Provider = (agent) => AGENTS[agent]?.t3 ?? null;
157
160
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
158
161
  const PLAIN_ID = /^[A-Za-z0-9._:-]{1,128}$/;
159
162
  const quote = (text) => `'${String(text).replace(/'/g, `'\\''`)}'`;
@@ -196,7 +199,8 @@ const t3Tables = (home) => new Set((t3Query(home, "select name from sqlite_maste
196
199
 
197
200
  /**
198
201
  * 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.
202
+ * provider thread; 0.0.45 in provider_session_runtime), else T3's import of it. A T3 thread's own id (a turn Harness
203
+ * handed to T3, t3.mjs runInT3) is that thread.
200
204
  */
201
205
  export function t3Thread(sessionId, home = t3Home()) {
202
206
  if (!PLAIN_ID.test(String(sessionId || ""))) return null;
@@ -204,7 +208,7 @@ export function t3Thread(sessionId, home = t3Home()) {
204
208
  // The id is checked above: letters, digits and . _ : - only.
205
209
  const parts = [];
206
210
  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}')`);
211
+ 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 ('${sessionId}', 'import:claudeAgent:${sessionId}', 'import:codex:${sessionId}')`);
208
212
  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
213
  if (!parts.length) return null;
210
214
  return t3Query(home, `select thread_id from (${parts.join(" union all ")}) order by at desc limit 1;`) || null;
@@ -217,6 +221,51 @@ export function t3Link(threadId, home = t3Home()) {
217
221
  return threadId && PLAIN_ID.test(environment) ? `t3code://threads/${environment}/${encodeURIComponent(threadId)}` : null;
218
222
  }
219
223
 
224
+ const realDir = (dir) => { try { return fs.realpathSync(dir); } catch { return null; } };
225
+
226
+ /** The T3 Code project for a folder: the one T3 opened on the folder itself or on the repository it is in. */
227
+ export function t3Project(folder, home = t3Home()) {
228
+ if (!folder || !t3Tables(home).has("projection_projects")) return null;
229
+ const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: folder, encoding: "utf8" });
230
+ const root = top.status === 0 ? top.stdout.trim() : null;
231
+ // A worktree of a project's checkout (a thread's copy here, T3's own) is that project's, as T3 counts it.
232
+ const wanted = new Set([realDir(folder), root && realDir(root), root && realDir(mainCheckout(root))].filter(Boolean));
233
+ const rows = (t3Query(home, "select project_id || char(9) || workspace_root from projection_projects where deleted_at is null;") || "").split("\n").map((line) => line.split("\t"));
234
+ const found = rows.find(([id, root]) => id && root && wanted.has(realDir(root)));
235
+ return found ? { id: found[0], root: realDir(found[1]) } : null;
236
+ }
237
+
238
+ /** The model (and its options) the person last ran an agent with in T3 Code, as T3 records each thread's choice. */
239
+ export function t3Model(agent, home = t3Home()) {
240
+ const provider = t3Provider(agent);
241
+ if (!provider || !t3Tables(home).has("orchestration_v2_projection_threads")) return null;
242
+ const raw = t3Query(home, `select json_extract(payload_json, '$.modelSelection') from orchestration_v2_projection_threads where deleted_at is null and json_extract(payload_json, '$.modelSelection.instanceId') = '${provider}' order by updated_at desc limit 1;`);
243
+ try { const model = JSON.parse(raw || "null"); return typeof model?.model === "string" ? model : null; } catch { return null; }
244
+ }
245
+
246
+ /**
247
+ * The threads T3 Code is running a turn in right now for an agent: the thread, the agent's own session id there, and
248
+ * its folder (the thread's worktree, else its project's). Codex in T3 runs on Codex's shared background server, outside
249
+ * T3's app, so this is how its running sessions there are found.
250
+ */
251
+ export function t3Working(agent, home = t3Home()) {
252
+ const provider = t3Provider(agent);
253
+ const tables = t3Tables(home);
254
+ if (!provider || !tables.has("orchestration_v2_projection_provider_threads") || !tables.has("orchestration_v2_projection_threads")) return [];
255
+ const projects = tables.has("projection_projects");
256
+ const rows = t3Query(home, `select p.thread_id || char(9) || json_extract(p.payload_json, '$.nativeThreadRef.nativeId') || char(9) || coalesce(json_extract(t.payload_json, '$.worktreePath'), ${projects ? "r.workspace_root, " : ""}'') || char(9) || p.updated_at
257
+ from orchestration_v2_projection_provider_threads p join orchestration_v2_projection_threads t on t.thread_id = p.thread_id${projects ? " left join projection_projects r on r.project_id = t.project_id" : ""}
258
+ where p.provider = '${provider}' and p.status = 'active' and t.deleted_at is null;`);
259
+ return (rows || "").split("\n").map((line) => line.split("\t")).filter(([thread, session]) => thread && PLAIN_ID.test(session || ""))
260
+ .map(([thread, session, folder, at]) => ({ thread, sessionId: session, folder: folder || null, at: Date.parse(at) || 0 }));
261
+ }
262
+
263
+ /** True while T3 Code is open on this computer. */
264
+ export function t3Open() {
265
+ const running = spawnSync(PS, ["-axo", "comm="], { encoding: "utf8", maxBuffer: 16 << 20 }).stdout || "";
266
+ return /\/T3 Code\b[^/\n]*\.app\/Contents\/MacOS\//.test(running);
267
+ }
268
+
220
269
  /** 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
270
  export function t3Runs(agent, sinceIso, home = t3Home()) {
222
271
  const provider = AGENTS[agent]?.t3;
package/lib/runner.mjs CHANGED
@@ -14,6 +14,13 @@ export function stopAgentTask(runId) {
14
14
  return true;
15
15
  }
16
16
 
17
+ /** A run another app carries out (T3 Code, t3.mjs): Stop reaches it through `stop`. Returns its release. */
18
+ export function trackRun(runId, stop) {
19
+ const entry = { pid: null, kill: stop };
20
+ children.set(runId, entry);
21
+ return () => { if (children.get(runId) === entry) children.delete(runId); };
22
+ }
23
+
17
24
  /** The process ids of the runs in progress, so they are not mistaken for the person's own sessions. */
18
25
  export function runningPids() {
19
26
  return [...children.values()].map((child) => child.pid).filter(Boolean);
@@ -105,6 +112,8 @@ const brief = (input) => {
105
112
  };
106
113
  export function stepsOf(event) {
107
114
  if (!event || typeof event !== "object") return [];
115
+ // A step as Harness writes it for a run another app carries out (t3.mjs).
116
+ if (event.type === "harness.step" && (event.kind === "say" || event.kind === "do") && typeof event.text === "string") return [{ kind: event.kind, text: event.text }];
108
117
  if (event.type === "assistant" && Array.isArray(event.message?.content)) {
109
118
  return event.message.content.flatMap((block) => block.type === "text" && block.text?.trim() ? [{ kind: "say", text: block.text.trim() }]
110
119
  : block.type === "tool_use" ? [{ kind: "do", text: `${block.name}: ${brief(block.input)}` }] : []);
package/lib/swarm.mjs CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { readRunSteps } from "./runner.mjs";
5
+ import { mainCheckout } from "./worktrees.mjs";
5
6
  import { nameOf, resolveMember } from "./members.mjs";
6
7
  import { setNote, swarmBlock, swarmCards, swarmDigest } from "./cards.mjs";
7
8
 
@@ -221,9 +222,11 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
221
222
  team.add(agentName);
222
223
  folder ??= isDir(session.cwd) ? session.cwd : null;
223
224
  }
224
- // Where the work happens: the named agent's last folder, else the latest coding session's on this computer.
225
- folder ??= [...team].map((name) => PROCESS_NAMES[name] ? lastFolder(name) : null).find(Boolean)
225
+ // Where the work happens: the named agent's last folder, else the latest coding session's on this computer; one in
226
+ // a worktree (a thread's copy here, T3 Code's) is the repository's own checkout, where the person works.
227
+ const last = [...team].map((name) => PROCESS_NAMES[name] ? lastFolder(name) : null).find(Boolean)
226
228
  ?? store.recentSessions(100).find((session) => PROCESS_NAMES[session.agent] && isDir(session.cwd))?.cwd ?? null;
229
+ folder ??= last ? mainCheckout(last) : null;
227
230
  const objective = text.split("\n").map((line) => line.trim()).find(Boolean).replace(/@([a-z][a-z0-9-]*)\s*/g, "").slice(0, 120) || text.slice(0, 120);
228
231
  const thread = store.createGoal({ objective, originAgent: "user", provenance: provenanceFor("user"), team: [...team, "harness"], facilitator: "harness", workspace: folder ? path.resolve(folder) : null, maxPosts: defaults.maxPosts, maxWakes: defaults.maxWakes });
229
232
  if (from) { store.setGoalSession(thread.id, String(from.agent), nativeSessionId(String(from.agent), String(from.sessionId))); worktrees.inPlace(thread.id); }
package/lib/t3.mjs CHANGED
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { readJson, writeJsonAtomic } from "./files.mjs";
6
6
  import { paths } from "./paths.mjs";
7
7
  import { t3Home } from "./places.mjs";
8
+ import { trackRun } from "./runner.mjs";
8
9
 
9
10
  // T3 Code's door for agents it did not start (its /mcp; T3's docs/user/outside-agents.md). Harness signs in once and the
10
11
  // person approves it inside T3, choosing what it may do; T3 asks for a pairing code from its Settings → Connections, and
@@ -151,8 +152,8 @@ async function callTool(origin, token, name, args) {
151
152
  }
152
153
  }
153
154
 
154
- /** Sends the person's words into a T3 Code thread: "started" or "queued" behind its turn. */
155
- export async function sendToT3(threadId, text) {
155
+ /** One of T3's tools, as Harness was approved to use them, at T3's current address. */
156
+ async function t3Tool(name, args) {
156
157
  const saved = readJson(file(), null);
157
158
  const access = t3Access();
158
159
  if (!access) throw new T3Error("not_connected", "Harness is not approved in T3 Code yet.");
@@ -163,10 +164,101 @@ export async function sendToT3(threadId, text) {
163
164
  if (!t3 || t3.environmentId !== saved.environmentId) throw new T3Error("not_running", "T3 Code is not running on this computer.");
164
165
  if (t3.origin !== saved.origin) writeJsonAtomic(file(), { ...saved, origin: t3.origin });
165
166
  try {
166
- const sent = await callTool(t3.origin, saved.token, "t3_thread_send", { threadId, message: text, mode: "queue", clientRequestId: randomUUID() });
167
- return sent.delivery === "queued" ? "queued" : "started";
167
+ return await callTool(t3.origin, saved.token, name, args);
168
168
  } catch (error) {
169
169
  if (error.code === "expired") writeJsonAtomic(file(), { ...saved, expiresAt: new Date(0).toISOString() });
170
170
  throw error;
171
171
  }
172
172
  }
173
+
174
+ /** Sends the person's words into a T3 Code thread: "started" or "queued" behind its turn. */
175
+ export async function sendToT3(threadId, text) {
176
+ const sent = await t3Tool("t3_thread_send", { threadId, message: text, mode: "queue", clientRequestId: randomUUID() });
177
+ return sent.delivery === "queued" ? "queued" : "started";
178
+ }
179
+
180
+ // ── A thread's turn, carried out by T3 Code ──────────────────────────────────
181
+
182
+ // How often a turn handed to T3 is read back for its steps and its end: as often as the buddy reads a session's feed.
183
+ const READ_MS = 2_000;
184
+ const SETTLED = new Set(["completed", "failed", "cancelled", "interrupted"]);
185
+ const ENDED = new Set(["interrupted", "failed", "cancelled", "rolled_back"]);
186
+ const handed = new Map();
187
+
188
+ /** The T3 threads running a turn Harness handed them: Harness's runs, not the person's own sessions. */
189
+ export const handedToT3 = () => new Set(handed.values());
190
+
191
+ /** One item of a T3 thread as a step on the board: its words, or what it did in a line. */
192
+ function stepOf(item) {
193
+ const text = String(item.text || "").trim();
194
+ if (item.type === "assistant_message") return text ? { kind: "say", text } : null;
195
+ if (item.type === "command_execution") return { kind: "do", text: `ran: ${(item.title === "Workspace ready" ? item.title : text.split("\n")[0].replace(/^\$\s*/, "")).slice(0, 200)}` };
196
+ if (item.type === "web_search") return { kind: "do", text: "searched the web" };
197
+ if (["user_message", "reasoning", "checkpoint"].includes(item.type)) return null;
198
+ return { kind: "do", text: `${item.title || String(item.type).replace(/_/g, " ")}`.slice(0, 200) };
199
+ }
200
+
201
+ /**
202
+ * A coding agent's turn in a Harness thread, carried out by T3 Code, so the person finds it in T3 like any thread there.
203
+ * The agent's first turn launches its own T3 thread (`launch`: T3's project, workspace and the model the person last
204
+ * chose for the agent there; T3 applies the mode the person approved Harness with); later turns go into that thread,
205
+ * queued behind whatever it is doing. It reports as runner.mjs does: accepted, its session (the T3 thread, which is
206
+ * how Open finds it), each step, then done with the agent's last words or failed; Stop interrupts it in T3.
207
+ */
208
+ export function runInT3(run, emit, observe) {
209
+ let threadId = run.threadId ?? null, runId = null, cursor = null, timer = null, stopped = false, last = "";
210
+ const seen = new Set();
211
+ const interrupt = () => t3Tool("t3_thread_interrupt", { threadId, ...(runId ? { runId } : {}), reason: "Stopped from Harness.", clientRequestId: `${run.runId}:stop` }).catch(() => {});
212
+ const release = trackRun(run.runId, () => { stopped = true; if (threadId) void interrupt(); });
213
+ const finish = (kind, text) => { clearTimeout(timer); handed.delete(run.runId); release(); emit(kind, text.slice(0, 20_000), { usage: null }); };
214
+ const read = async () => {
215
+ try {
216
+ let page, after = cursor, settled = true;
217
+ do {
218
+ page = await t3Tool("t3_thread_read", { threadId, view: "activity", limit: 100, maxCharsPerItem: 20_000, runLimit: 5, ...(after === null ? {} : { afterPosition: after }) });
219
+ for (const item of page.items ?? []) {
220
+ if (item.runId === runId && SETTLED.has(item.status) && !seen.has(item.itemId)) {
221
+ seen.add(item.itemId);
222
+ const step = stepOf(item);
223
+ if (step) {
224
+ if (step.kind === "say") last = step.text;
225
+ observe("stdout", `${JSON.stringify({ type: "harness.step", ...step })}\n`);
226
+ emit("progress", step.text.slice(0, 200), { step });
227
+ }
228
+ }
229
+ // Read again from the first item still going; everything before it is settled.
230
+ settled &&= SETTLED.has(item.status);
231
+ if (settled) cursor = item.position;
232
+ }
233
+ after = page.nextPosition ?? after;
234
+ } while (page.hasMore && page.items?.length);
235
+ if (page.thread?.worktreePath) run.onWorktree?.(page.thread.worktreePath, page.thread.branch ?? null);
236
+ const status = (page.recentRuns ?? []).find((entry) => entry.runId === runId)?.status;
237
+ if (status === "completed") return finish("done", last || "The run finished in T3 Code; its changes are in T3's checkout.");
238
+ if (ENDED.has(status)) return finish("failed", stopped ? "Stopped." : `T3 Code ended the turn (${status})${last ? `: ${last}` : "."}`);
239
+ } catch (error) {
240
+ if (["expired", "not_connected", "read_only"].includes(error.code)) return finish("failed", `${error.message} The turn may still be going in T3 Code.`);
241
+ // T3 restarting or busy: read again next time.
242
+ }
243
+ timer = setTimeout(() => void read(), READ_MS);
244
+ };
245
+ emit("accepted");
246
+ void (async () => {
247
+ try {
248
+ if (threadId) runId = (await t3Tool("t3_thread_send", { threadId, message: run.brief, mode: "queue", clientRequestId: run.runId })).runId ?? null;
249
+ else {
250
+ const launched = await t3Tool("t3_thread_launch", { title: run.title, message: run.brief, ...run.launch });
251
+ threadId = launched.threadId;
252
+ runId = launched.runId ?? null;
253
+ run.onThread?.(threadId);
254
+ }
255
+ if (!threadId || !runId) throw new T3Error("t3", "T3 Code did not say which thread and turn it started");
256
+ handed.set(run.runId, threadId);
257
+ emit("session", threadId, { cwd: run.cwd });
258
+ if (stopped) await interrupt();
259
+ void read();
260
+ } catch (error) {
261
+ finish("failed", `T3 Code did not take it: ${error.message}`);
262
+ }
263
+ })();
264
+ }
package/lib/worktrees.mjs CHANGED
@@ -24,6 +24,21 @@ export function repositoryOf(folder) {
24
24
  return { root: fs.realpathSync(top.out), inside: path.relative(fs.realpathSync(top.out), fs.realpathSync(folder)) };
25
25
  }
26
26
 
27
+ /**
28
+ * The folder a linked git worktree stands for: the same place in its repository's main checkout. A thread's copy here,
29
+ * T3 Code's and the person's own are all worktrees of the checkout the person works in (T3 counts their history as
30
+ * that checkout's too). Any other folder is itself.
31
+ */
32
+ export function mainCheckout(folder) {
33
+ if (!folder || !fs.existsSync(folder)) return folder;
34
+ const found = git(folder, ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-common-dir"]);
35
+ const [top, common] = found.ok ? found.out.split("\n") : [];
36
+ if (!top || !common || path.basename(common) !== ".git") return folder;
37
+ const main = path.dirname(common);
38
+ if (!fs.existsSync(main) || fs.realpathSync(main) === fs.realpathSync(top)) return folder;
39
+ return path.join(main, path.relative(fs.realpathSync(top), fs.realpathSync(folder)));
40
+ }
41
+
27
42
  const slug = (text) => String(text).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32) || "thread";
28
43
 
29
44
  export function createWorktrees({ store, root }) {
@@ -37,6 +52,7 @@ export function createWorktrees({ store, root }) {
37
52
  if (!folder) return null;
38
53
  const current = get(thread.id);
39
54
  if (current?.state === "in_place") return folder;
55
+ if (current?.state === "t3") return current.path && fs.existsSync(current.path) ? current.path : folder;
40
56
  if (current && current.state === "open" && fs.existsSync(current.path)) return path.join(current.path, current.inside);
41
57
  const repo = repositoryOf(folder);
42
58
  if (!repo) return folder;
@@ -86,7 +102,8 @@ export function createWorktrees({ store, root }) {
86
102
 
87
103
  const summary = (threadId) => {
88
104
  const tree = get(threadId);
89
- if (!tree || tree.state === "in_place") return null;
105
+ // A thread T3 Code works in keeps its changes in T3's own checkout, where T3 shows and merges them.
106
+ if (!tree || tree.state === "in_place" || tree.state === "t3") return null;
90
107
  const files = tree.state === "open" && fs.existsSync(tree.path) ? stat(tree.path, `${tree.base}..HEAD`).length : 0;
91
108
  return { state: tree.state, branch: tree.branch, repo: tree.repo, files };
92
109
  };
@@ -128,5 +145,26 @@ export function createWorktrees({ store, root }) {
128
145
  /** A thread that carries on a session from a terminal works where that session did: agents find sessions by folder. */
129
146
  const inPlace = (threadId) => save(threadId, { state: "in_place" });
130
147
 
131
- return { folderFor, commitTurn, diff, summary, merge, discard, inPlace, get };
148
+ /**
149
+ * Where T3 Code runs a thread's agents (t3.mjs runInT3): the checkout T3 made for the thread's first agent, shared by
150
+ * the rest as its own worktree is, or the thread's own copy when it worked here before; else a new one on its own
151
+ * branch from what the person has checked out (as folderFor's), or T3's project folder when the thread's folder is in
152
+ * no repository.
153
+ */
154
+ const t3Workspace = (thread) => {
155
+ const current = get(thread.id);
156
+ if ((current?.state === "t3" || current?.state === "open") && current.path) return { type: "existing_worktree", worktreePath: current.path, ...(current.branch ? { branch: current.branch } : {}) };
157
+ const repo = repositoryOf(thread.goal.workspace);
158
+ if (!repo) return { type: "root" };
159
+ const branch = git(repo.root, ["rev-parse", "--abbrev-ref", "HEAD"]).out;
160
+ return { type: "worktree", baseRef: branch && branch !== "HEAD" ? branch : git(repo.root, ["rev-parse", "HEAD"]).out,
161
+ branch: `harness/${slug(thread.objective)}-${thread.id.slice(0, 8)}`, startFromOrigin: false };
162
+ };
163
+ /** The thread works in T3 Code from now on; `where` and `branch` once T3 has made its checkout. */
164
+ const inT3 = (threadId, where = null, branch = null) => {
165
+ const current = get(threadId);
166
+ if (current?.state !== "t3" || (where && where !== current.path)) save(threadId, { ...current, state: "t3", path: where ?? current?.path ?? null, branch: branch ?? current?.branch ?? null });
167
+ };
168
+
169
+ return { folderFor, commitTurn, diff, summary, merge, discard, inPlace, t3Workspace, inT3, get };
132
170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tryharness",
3
- "version": "0.7.13",
3
+ "version": "0.7.18",
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"
@@ -39,8 +39,8 @@
39
39
  "ws": "^8.18.3"
40
40
  },
41
41
  "optionalDependencies": {
42
- "tryharness-desktop-darwin-arm64": "0.7.10",
43
- "tryharness-desktop-darwin-x64": "0.7.10",
42
+ "tryharness-desktop-darwin-arm64": "0.7.17",
43
+ "tryharness-desktop-darwin-x64": "0.7.17",
44
44
  "tryharness-desktop-linux-x64": "0.7.4",
45
45
  "tryharness-desktop-win32-x64": "0.7.4"
46
46
  }