tryharness 0.7.22 → 0.7.25

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.
Files changed (64) hide show
  1. package/README.md +48 -8
  2. package/assets/opencode-plugin.js +48 -10
  3. package/assets/pi-extension.ts +106 -26
  4. package/cli.mjs +1281 -289
  5. package/lib/activity.mjs +197 -40
  6. package/lib/adapters.mjs +337 -54
  7. package/lib/agentnode.mjs +175 -35
  8. package/lib/atlas.mjs +604 -0
  9. package/lib/bip39-english.txt +2048 -0
  10. package/lib/cards.mjs +177 -39
  11. package/lib/claudemem.mjs +74 -18
  12. package/lib/commands.mjs +13 -5
  13. package/lib/context.mjs +628 -0
  14. package/lib/control-client.mjs +5 -2
  15. package/lib/coordinator.mjs +281 -56
  16. package/lib/daemon.mjs +5590 -838
  17. package/lib/desktop.mjs +149 -35
  18. package/lib/digest.mjs +81 -0
  19. package/lib/environment.mjs +180 -0
  20. package/lib/files.mjs +246 -13
  21. package/lib/firewall.mjs +288 -0
  22. package/lib/folders.mjs +223 -57
  23. package/lib/frames.mjs +333 -0
  24. package/lib/inference.mjs +663 -0
  25. package/lib/learn.mjs +182 -0
  26. package/lib/live.mjs +286 -61
  27. package/lib/localmodels.mjs +571 -0
  28. package/lib/mcp-stdio.mjs +912 -103
  29. package/lib/media.mjs +228 -0
  30. package/lib/members.json +1 -1
  31. package/lib/members.mjs +35 -9
  32. package/lib/models.mjs +342 -80
  33. package/lib/network.mjs +201 -42
  34. package/lib/notify.mjs +49 -12
  35. package/lib/nudge.mjs +190 -32
  36. package/lib/ocr.mjs +380 -0
  37. package/lib/onboard.mjs +321 -70
  38. package/lib/parsers.mjs +132 -41
  39. package/lib/paths.mjs +33 -10
  40. package/lib/personal.mjs +77 -0
  41. package/lib/phones.mjs +398 -51
  42. package/lib/places.mjs +566 -102
  43. package/lib/provenance.mjs +19 -4
  44. package/lib/receipts.mjs +82 -0
  45. package/lib/redact.mjs +730 -0
  46. package/lib/runner.mjs +347 -55
  47. package/lib/runtime-install.mjs +59 -17
  48. package/lib/screens.mjs +212 -0
  49. package/lib/sealed.mjs +160 -28
  50. package/lib/secrets.mjs +18 -5
  51. package/lib/service.mjs +173 -35
  52. package/lib/snippets.mjs +140 -0
  53. package/lib/speech.mjs +993 -0
  54. package/lib/store.mjs +1678 -301
  55. package/lib/swarm.mjs +598 -117
  56. package/lib/t3.mjs +400 -79
  57. package/lib/trails.mjs +401 -0
  58. package/lib/ui.mjs +95 -23
  59. package/lib/vectors.mjs +5 -2
  60. package/lib/wakes.mjs +284 -68
  61. package/lib/watches.mjs +362 -0
  62. package/lib/worktrees.mjs +186 -38
  63. package/package.json +6 -4
  64. package/lib/embedder.mjs +0 -156
package/lib/learn.mjs ADDED
@@ -0,0 +1,182 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ // The context engine's personal ranking model: a logistic regression over a few signals the frozen models and the
5
+ // relay's own records give for each candidate file (meaning, words, names, what the swarm edited lately and last).
6
+ // It learns from finished runs: the files a run went on to edit are the right answers for its brief. A new model is
7
+ // trained on older runs and kept only if it beats the current one on the newest runs, which it never saw.
8
+
9
+ export const FEATURES = [
10
+ "dense",
11
+ "denseRank",
12
+ "lexical",
13
+ "lexicalRank",
14
+ "named",
15
+ "prior",
16
+ "priorRead",
17
+ "lastEdit",
18
+ "lastRun",
19
+ "recent",
20
+ "test",
21
+ "doc",
22
+ "depth",
23
+ ];
24
+ const HALF_LIFE_DAYS = 7;
25
+
26
+ /**
27
+ * What the swarm did to each file before a moment: decayed edit and read counts from earlier runs' trails, when each
28
+ * file was last edited, and what the latest earlier run edited (work often picks up where the last session left off).
29
+ */
30
+ export function swarmPrior(history, at) {
31
+ const edits = new Map();
32
+ const reads = new Map();
33
+ const lastEdit = new Map();
34
+ let latest = null;
35
+ for (const run of history) {
36
+ if (!(run.at < at)) continue;
37
+ const weight = 0.5 ** ((at - run.at) / 86_400_000 / HALF_LIFE_DAYS);
38
+ for (const file of run.edited) {
39
+ edits.set(file, (edits.get(file) ?? 0) + weight);
40
+ lastEdit.set(file, Math.max(lastEdit.get(file) ?? 0, run.at));
41
+ }
42
+ for (const file of run.read)
43
+ reads.set(file, (reads.get(file) ?? 0) + weight);
44
+ if (run.edited.length && (!latest || run.at > latest.at)) latest = run;
45
+ }
46
+ return { edits, reads, lastEdit, lastRun: new Set(latest?.edited ?? []), at };
47
+ }
48
+
49
+ /** One feature row per candidate. candidates: searchFiles output; extras: { prior, recent: Map(path→commits) }. */
50
+ export function featureRows(candidates, { prior, recent = new Map() }) {
51
+ const byDense = [...candidates].sort((a, b) => b.dense - a.dense);
52
+ const byWords = [...candidates].sort((a, b) => b.lexical - a.lexical);
53
+ const denseRank = new Map(byDense.map((row, i) => [row.path, i]));
54
+ const wordsRank = new Map(byWords.map((row, i) => [row.path, i]));
55
+ const topWords = byWords[0]?.lexical || 1;
56
+ return candidates.map((row) => ({
57
+ path: row.path,
58
+ x: {
59
+ dense: row.dense,
60
+ denseRank: Math.log1p(denseRank.get(row.path)),
61
+ lexical: row.lexical > 0 ? row.lexical / topWords : 0,
62
+ lexicalRank:
63
+ row.lexical > 0
64
+ ? Math.log1p(wordsRank.get(row.path))
65
+ : Math.log1p(candidates.length),
66
+ named: row.named,
67
+ prior: Math.log1p(prior.edits.get(row.path) ?? 0),
68
+ priorRead: Math.log1p(prior.reads.get(row.path) ?? 0),
69
+ // Hours since any run last edited it, as a closeness from 1 (just now) toward 0 (weeks ago, or never).
70
+ lastEdit: prior.lastEdit.has(row.path)
71
+ ? 1 / (1 + (prior.at - prior.lastEdit.get(row.path)) / 3_600_000 / 24)
72
+ : 0,
73
+ lastRun: prior.lastRun.has(row.path) ? 1 : 0,
74
+ recent: Math.log1p(recent.get(row.path) ?? 0),
75
+ test: /(?:^|\/)(?:tests?|__tests__)\/|\.(?:test|spec)\.[^.]+$/.test(
76
+ row.path
77
+ )
78
+ ? 1
79
+ : 0,
80
+ doc: /\.(?:md|mdx|txt)$/i.test(row.path) ? 1 : 0,
81
+ depth: row.path.split("/").length,
82
+ },
83
+ }));
84
+ }
85
+
86
+ /** The hand-set scorer used before any learning: meaning, words and names, with the swarm's recent edits as a tiebreak. */
87
+ export function baselineScore(x) {
88
+ return x.dense + 0.25 * x.lexical + 0.2 * x.named + 0.02 * x.prior;
89
+ }
90
+
91
+ /** What a pack is for: code. Notes and status docs (edited by nearly every session) are neither right answers nor misses. */
92
+ export const codeFiles = (files) =>
93
+ files.filter((file) => !/\.(?:md|mdx|txt)$/i.test(file));
94
+
95
+ /** Trains a logistic regression on rows { x, y }; positives weighted up to balance the classes. */
96
+ export function train(rows, { epochs = 300, rate = 0.05, l2 = 1e-3 } = {}) {
97
+ const n = rows.length;
98
+ const mean = Object.fromEntries(
99
+ FEATURES.map((name) => [
100
+ name,
101
+ rows.reduce((sum, row) => sum + row.x[name], 0) / n,
102
+ ])
103
+ );
104
+ const std = Object.fromEntries(
105
+ FEATURES.map((name) => [
106
+ name,
107
+ Math.sqrt(
108
+ rows.reduce((sum, row) => sum + (row.x[name] - mean[name]) ** 2, 0) / n
109
+ ) || 1,
110
+ ])
111
+ );
112
+ const positives = rows.filter((row) => row.y).length;
113
+ const weightPos = positives ? Math.min(50, (n - positives) / positives) : 1;
114
+ const xs = rows.map((row) =>
115
+ FEATURES.map((name) => (row.x[name] - mean[name]) / std[name])
116
+ );
117
+ const w = new Float64Array(FEATURES.length);
118
+ let b = 0;
119
+ const m = new Float64Array(FEATURES.length + 1),
120
+ v = new Float64Array(FEATURES.length + 1);
121
+ for (let epoch = 1; epoch <= epochs; epoch += 1) {
122
+ const grad = new Float64Array(FEATURES.length + 1);
123
+ let total = 0;
124
+ for (let i = 0; i < n; i += 1) {
125
+ let z = b;
126
+ for (let j = 0; j < w.length; j += 1) z += w[j] * xs[i][j];
127
+ const p = 1 / (1 + Math.exp(-z));
128
+ const weight = rows[i].y ? weightPos : 1;
129
+ const error = (p - rows[i].y) * weight;
130
+ total += weight;
131
+ for (let j = 0; j < w.length; j += 1) grad[j] += error * xs[i][j];
132
+ grad[w.length] += error;
133
+ }
134
+ for (let j = 0; j <= w.length; j += 1) {
135
+ const g = grad[j] / total + (j < w.length ? l2 * w[j] : 0);
136
+ m[j] = 0.9 * m[j] + 0.1 * g;
137
+ v[j] = 0.999 * v[j] + 0.001 * g * g;
138
+ const step =
139
+ (rate * (m[j] / (1 - 0.9 ** epoch))) /
140
+ (Math.sqrt(v[j] / (1 - 0.999 ** epoch)) + 1e-8);
141
+ if (j < w.length) w[j] -= step;
142
+ else b -= step;
143
+ }
144
+ }
145
+ return {
146
+ features: FEATURES,
147
+ weights: Array.from(w),
148
+ bias: b,
149
+ mean,
150
+ std,
151
+ rows: n,
152
+ positives,
153
+ };
154
+ }
155
+
156
+ export function modelScore(model, x) {
157
+ let z = model.bias;
158
+ model.features.forEach((name, j) => {
159
+ z += model.weights[j] * ((x[name] - model.mean[name]) / model.std[name]);
160
+ });
161
+ return z;
162
+ }
163
+
164
+ /** Recall at k of the right files among ranked paths, for one request. */
165
+ export function recallAt(ranked, wanted, k) {
166
+ if (!wanted.length) return null;
167
+ const top = new Set(ranked.slice(0, k));
168
+ return wanted.filter((file) => top.has(file)).length / wanted.length;
169
+ }
170
+
171
+ export const loadModel = (file) => {
172
+ try {
173
+ return JSON.parse(fs.readFileSync(file, "utf8"));
174
+ } catch {
175
+ return null;
176
+ }
177
+ };
178
+ export function saveModel(file, model) {
179
+ fs.mkdirSync(path.dirname(file), { recursive: true });
180
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify(model, null, 2));
181
+ fs.renameSync(`${file}.tmp`, file);
182
+ }
package/lib/live.mjs CHANGED
@@ -21,10 +21,18 @@ const PLAIN_ID = /^[A-Za-z0-9._:-]{1,128}$/;
21
21
  export function processTable() {
22
22
  const table = new Map();
23
23
  if (process.platform === "win32") return table;
24
- const ps = spawnSync(PS, ["-axo", "pid=,ppid=,tty=,comm="], { encoding: "utf8", maxBuffer: 16 << 20 });
24
+ const ps = spawnSync(PS, ["-axo", "pid=,ppid=,tty=,comm="], {
25
+ encoding: "utf8",
26
+ maxBuffer: 16 << 20,
27
+ });
25
28
  for (const line of (ps.stdout || "").split("\n")) {
26
29
  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] });
30
+ if (match)
31
+ table.set(Number(match[1]), {
32
+ ppid: Number(match[2]),
33
+ tty: /^ttys?\d+$/.test(match[3]) ? `/dev/${match[3]}` : null,
34
+ comm: match[4],
35
+ });
28
36
  }
29
37
  return table;
30
38
  }
@@ -40,16 +48,40 @@ function underTmux(table, pid) {
40
48
  return false;
41
49
  }
42
50
 
43
- const run = (command, args) => spawnSync(command, args, { encoding: "utf8", timeout: 3_000 });
51
+ const run = (command, args) =>
52
+ spawnSync(command, args, { encoding: "utf8", timeout: 3_000 });
44
53
 
45
54
  /** The tmux pane showing a terminal, and the terminal (app and tty) a tmux client shows it in. */
46
55
  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);
56
+ const panes = run("tmux", [
57
+ "list-panes",
58
+ "-a",
59
+ "-F",
60
+ "#{pane_tty}\t#{session_name}\t#{session_name}:#{window_index}.#{pane_index}",
61
+ ]);
62
+ const pane = (panes.stdout || "")
63
+ .split("\n")
64
+ .map((line) => line.split("\t"))
65
+ .find(([paneTty]) => paneTty === tty);
49
66
  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 };
67
+ const clients = run("tmux", [
68
+ "list-clients",
69
+ "-t",
70
+ pane[1],
71
+ "-F",
72
+ "#{client_tty}\t#{client_pid}",
73
+ ]);
74
+ const [clientTty, clientPid] =
75
+ (clients.stdout || "")
76
+ .split("\n")
77
+ .filter(Boolean)
78
+ .map((line) => line.split("\t"))[0] ?? [];
79
+ return {
80
+ target: pane[2],
81
+ client: clientPid
82
+ ? { tty: clientTty || null, host: hostApp(table, Number(clientPid)) }
83
+ : null,
84
+ };
53
85
  }
54
86
 
55
87
  /** The app a process runs in: the outermost app bundle on its way up to launchd, or null (tmux, ssh, a service). */
@@ -67,7 +99,11 @@ export function hostApp(table, pid) {
67
99
  export function workingDirectories(pids) {
68
100
  const found = new Map();
69
101
  if (!pids.length) return found;
70
- const lsof = spawnSync("lsof", ["-a", "-d", "cwd", "-Fpn", "-p", pids.join(",")], { encoding: "utf8" });
102
+ const lsof = spawnSync(
103
+ "lsof",
104
+ ["-a", "-d", "cwd", "-Fpn", "-p", pids.join(",")],
105
+ { encoding: "utf8" }
106
+ );
71
107
  let pid = null;
72
108
  for (const line of (lsof.stdout || "").split("\n")) {
73
109
  if (line.startsWith("p")) pid = Number(line.slice(1));
@@ -80,13 +116,29 @@ export function workingDirectories(pids) {
80
116
  export function sessionOfProcess(agent, pid, recent = []) {
81
117
  if (!Number.isInteger(pid) || pid <= 1) return null;
82
118
  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;
119
+ const record = readJson(
120
+ path.join(
121
+ historyRoots("claude-code")[0],
122
+ "..",
123
+ "sessions",
124
+ `${pid}.json`
125
+ ),
126
+ null
127
+ );
128
+ return record && PLAIN_ID.test(String(record.sessionId || ""))
129
+ ? record.sessionId
130
+ : null;
85
131
  }
86
132
  if (agent !== "codex") return null;
87
133
  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;
134
+ const session = cwd
135
+ ? recent.find((row) => row.agent === "codex" && row.cwd === cwd)
136
+ : null;
137
+ return (
138
+ String(session?.session_id || "").match(
139
+ /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
140
+ )?.[0] ?? null
141
+ );
90
142
  }
91
143
 
92
144
  /**
@@ -94,46 +146,123 @@ export function sessionOfProcess(agent, pid, recent = []) {
94
146
  * index (Codex keeps no per-process record, so its session is the newest indexed one in the process's folder);
95
147
  * `reported` holds the sessions that named their own process ({ agent, sessionId, folder, pid, comm, at }).
96
148
  */
97
- export function liveSessions({ exclude = new Set(), recent = [], table = processTable(), reported = [] } = {}) {
149
+ export function liveSessions({
150
+ exclude = new Set(),
151
+ recent = [],
152
+ table = processTable(),
153
+ reported = [],
154
+ } = {}) {
98
155
  const sessions = [];
99
156
  const records = path.join(historyRoots("claude-code")[0], "..", "sessions");
100
157
  let files = [];
101
- try { files = fs.readdirSync(records).filter((name) => name.endsWith(".json")); } catch {}
158
+ try {
159
+ files = fs.readdirSync(records).filter((name) => name.endsWith(".json"));
160
+ } catch {}
102
161
  for (const name of files) {
103
162
  const record = readJson(path.join(records, name), null);
104
- if (!record?.pid || !table.has(record.pid) || exclude.has(record.pid) || !PLAIN_ID.test(String(record.sessionId || ""))) continue;
163
+ if (
164
+ !record?.pid ||
165
+ !table.has(record.pid) ||
166
+ exclude.has(record.pid) ||
167
+ !PLAIN_ID.test(String(record.sessionId || ""))
168
+ )
169
+ continue;
105
170
  if (record.kind && record.kind !== "interactive") continue;
106
- sessions.push({ agent: "claude-code", sessionId: record.sessionId, folder: record.cwd || null, pid: record.pid, entrypoint: record.entrypoint || null,
107
- busy: record.status === "busy" ? true : record.status === "idle" ? false : null, at: Number(record.statusUpdatedAt || record.startedAt || 0) });
171
+ sessions.push({
172
+ agent: "claude-code",
173
+ sessionId: record.sessionId,
174
+ folder: record.cwd || null,
175
+ pid: record.pid,
176
+ entrypoint: record.entrypoint || null,
177
+ busy:
178
+ record.status === "busy"
179
+ ? true
180
+ : record.status === "idle"
181
+ ? false
182
+ : null,
183
+ at: Number(record.statusUpdatedAt || record.startedAt || 0),
184
+ });
108
185
  }
109
186
  // A Codex process outside any terminal and app is Codex's own background server (its terminals share it), not a session.
110
- const codex = [...table].filter(([pid, row]) => path.basename(row.comm) === "codex" && !exclude.has(pid) && (row.tty || hostApp(table, pid))).map(([pid]) => pid);
187
+ const codex = [...table]
188
+ .filter(
189
+ ([pid, row]) =>
190
+ path.basename(row.comm) === "codex" &&
191
+ !exclude.has(pid) &&
192
+ (row.tty || hostApp(table, pid))
193
+ )
194
+ .map(([pid]) => pid);
111
195
  const cwds = workingDirectories(codex);
112
196
  for (const pid of codex) {
113
- const session = recent.find((row) => row.agent === "codex" && row.cwd && row.cwd === cwds.get(pid));
114
- 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];
115
- 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 });
197
+ const session = recent.find(
198
+ (row) => row.agent === "codex" && row.cwd && row.cwd === cwds.get(pid)
199
+ );
200
+ const id = String(session?.session_id || "")
201
+ .replace(/\.jsonl$/, "")
202
+ .match(
203
+ /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
204
+ )?.[0];
205
+ if (id && !sessions.some((row) => row.sessionId === id))
206
+ sessions.push({
207
+ agent: "codex",
208
+ sessionId: id,
209
+ folder: session.cwd,
210
+ pid,
211
+ busy: null,
212
+ at: Date.parse(session.started_at) || 0,
213
+ });
116
214
  }
117
215
  // 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.
118
216
  const t3 = t3App();
119
- if (t3) for (const row of t3Working("codex")) {
120
- 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 });
121
- }
217
+ if (t3)
218
+ for (const row of t3Working("codex")) {
219
+ if (!sessions.some((session) => session.sessionId === row.sessionId))
220
+ sessions.push({
221
+ agent: "codex",
222
+ sessionId: row.sessionId,
223
+ folder: row.folder,
224
+ pid: null,
225
+ busy: true,
226
+ at: row.at,
227
+ host: { name: t3.name, app: t3.app },
228
+ t3Thread: row.thread,
229
+ });
230
+ }
122
231
  // A session that said which process it runs in when it started (pi's extension, OpenCode's plugin, any agent's hook):
123
232
  // running while that process is, under the same name, so a reused process id is not taken for it.
124
233
  for (const row of reported) {
125
234
  const now = table.get(row.pid);
126
- if (!now || exclude.has(row.pid) || path.basename(now.comm) !== path.basename(String(row.comm || "")) || sessions.some((session) => session.sessionId === row.sessionId)) continue;
127
- sessions.push({ agent: row.agent, sessionId: row.sessionId, folder: row.folder || null, pid: row.pid, busy: null, at: row.at || 0 });
128
- }
129
- return sessions.sort((a, b) => b.at - a.at).map((session) => {
130
- const tty = table.get(session.pid)?.tty ?? null;
131
- // Inside tmux the app is the terminal a tmux client shows the pane in.
132
- const tmux = underTmux(table, session.pid) && tty ? tmuxPlace(table, tty) : null;
133
- const host = session.host ?? tmux?.client?.host ?? hostApp(table, session.pid);
134
- const t3 = host && /^T3 Code\b/.test(host.name) ? t3Link(session.t3Thread ?? t3Thread(session.sessionId)) : null;
135
- return { ...session, tty, tmux: tmux?.target ?? null, host, t3 };
136
- });
235
+ if (
236
+ !now ||
237
+ exclude.has(row.pid) ||
238
+ path.basename(now.comm) !== path.basename(String(row.comm || "")) ||
239
+ sessions.some((session) => session.sessionId === row.sessionId)
240
+ )
241
+ continue;
242
+ sessions.push({
243
+ agent: row.agent,
244
+ sessionId: row.sessionId,
245
+ folder: row.folder || null,
246
+ pid: row.pid,
247
+ busy: null,
248
+ at: row.at || 0,
249
+ });
250
+ }
251
+ return sessions
252
+ .sort((a, b) => b.at - a.at)
253
+ .map((session) => {
254
+ const tty = table.get(session.pid)?.tty ?? null;
255
+ // Inside tmux the app is the terminal a tmux client shows the pane in.
256
+ const tmux =
257
+ underTmux(table, session.pid) && tty ? tmuxPlace(table, tty) : null;
258
+ const host =
259
+ session.host ?? tmux?.client?.host ?? hostApp(table, session.pid);
260
+ const t3 =
261
+ host && /^T3 Code\b/.test(host.name)
262
+ ? t3Link(session.t3Thread ?? t3Thread(session.sessionId))
263
+ : null;
264
+ return { ...session, tty, tmux: tmux?.target ?? null, host, t3 };
265
+ });
137
266
  }
138
267
 
139
268
  /**
@@ -143,36 +272,76 @@ export function liveSessions({ exclude = new Set(), recent = [], table = process
143
272
  */
144
273
  export function focusTerminal(session, place = null, table = processTable()) {
145
274
  let tty = session.tty;
146
- const tmux = tty && (session.tmux || place?.tmux) ? tmuxPlace(table, tty) : null;
275
+ const tmux =
276
+ tty && (session.tmux || place?.tmux) ? tmuxPlace(table, tty) : null;
147
277
  if (tmux) {
148
278
  run("tmux", ["select-window", "-t", tmux.target]);
149
279
  run("tmux", ["select-pane", "-t", tmux.target]);
150
280
  tty = tmux.client?.tty ?? null;
151
281
  }
152
- if (place?.wezterm || (session.host && /^WezTerm$/i.test(session.host.name))) {
282
+ if (
283
+ place?.wezterm ||
284
+ (session.host && /^WezTerm$/i.test(session.host.name))
285
+ ) {
153
286
  let pane = place?.wezterm ?? null;
154
- if (!pane) try { pane = JSON.parse(run("wezterm", ["cli", "list", "--format", "json"]).stdout || "[]").find((entry) => entry.tty_name === session.tty)?.pane_id ?? null; } catch {}
155
- if (pane != null) run("wezterm", ["cli", "activate-pane", "--pane-id", String(pane)]);
287
+ if (!pane)
288
+ try {
289
+ pane =
290
+ JSON.parse(
291
+ run("wezterm", ["cli", "list", "--format", "json"]).stdout || "[]"
292
+ ).find((entry) => entry.tty_name === session.tty)?.pane_id ?? null;
293
+ } catch {}
294
+ if (pane != null)
295
+ run("wezterm", ["cli", "activate-pane", "--pane-id", String(pane)]);
156
296
  }
157
297
  if (place?.kitty || (session.host && /^kitty$/i.test(session.host.name))) {
158
298
  const to = place?.kitty?.socket ? ["--to", place.kitty.socket] : [];
159
- run("kitty", ["@", ...to, "focus-window", "--match", place?.kitty ? `id:${place.kitty.id}` : `pid:${session.pid}`]);
299
+ run("kitty", [
300
+ "@",
301
+ ...to,
302
+ "focus-window",
303
+ "--match",
304
+ place?.kitty ? `id:${place.kitty.id}` : `pid:${session.pid}`,
305
+ ]);
160
306
  }
161
307
  return session.host ? { app: session.host.app, tty } : {};
162
308
  }
163
309
 
164
310
  const FEED_BYTES = 768 << 10;
165
- const promptText = (content) => typeof content === "string" ? content
166
- : Array.isArray(content) ? content.filter((block) => ["text", "input_text"].includes(block?.type) && typeof block.text === "string").map((block) => block.text).join("\n") : "";
311
+ const promptText = (content) =>
312
+ typeof content === "string"
313
+ ? content
314
+ : Array.isArray(content)
315
+ ? content
316
+ .filter(
317
+ (block) =>
318
+ ["text", "input_text"].includes(block?.type) &&
319
+ typeof block.text === "string"
320
+ )
321
+ .map((block) => block.text)
322
+ .join("\n")
323
+ : "";
167
324
  // What the agent itself was handed (instructions, environment, hook output), not words the person typed.
168
- const machinery = (text) => !text.trim() || /^\s*<[a-z_-]+[\s>]/i.test(text) || text.startsWith("Caveat:");
325
+ const machinery = (text) =>
326
+ !text.trim() ||
327
+ /^\s*<[a-z_-]+[\s>]/i.test(text) ||
328
+ text.startsWith("Caveat:");
169
329
 
170
330
  /** A session's own file: the index's source, else Claude Code's projects folder for its working folder. */
171
331
  export function sessionFile(agent, sessionId, folder, recent = []) {
172
- const row = recent.find((entry) => entry.agent === agent && String(entry.session_id).includes(sessionId) && entry.source);
332
+ const row = recent.find(
333
+ (entry) =>
334
+ entry.agent === agent &&
335
+ String(entry.session_id).includes(sessionId) &&
336
+ entry.source
337
+ );
173
338
  if (row && fs.existsSync(row.source)) return row.source;
174
339
  if (agent === "claude-code" && folder && PLAIN_ID.test(sessionId)) {
175
- const file = path.join(historyRoots("claude-code")[0], folder.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`);
340
+ const file = path.join(
341
+ historyRoots("claude-code")[0],
342
+ folder.replace(/[^a-zA-Z0-9]/g, "-"),
343
+ `${sessionId}.jsonl`
344
+ );
176
345
  if (fs.existsSync(file)) return file;
177
346
  }
178
347
  return null;
@@ -181,15 +350,37 @@ export function sessionFile(agent, sessionId, folder, recent = []) {
181
350
  /** OpenCode's session as the person reads it: OpenCode keeps sessions in its own database, so `opencode export` reads it. */
182
351
  export function opencodeFeed(command, sessionId, limit = 80) {
183
352
  if (!PLAIN_ID.test(String(sessionId))) return [];
184
- const exported = spawnSync(command, ["export", String(sessionId)], { encoding: "utf8", timeout: 20_000, maxBuffer: 64 << 20, stdio: ["ignore", "pipe", "ignore"] });
353
+ const exported = spawnSync(command, ["export", String(sessionId)], {
354
+ encoding: "utf8",
355
+ timeout: 20_000,
356
+ maxBuffer: 64 << 20,
357
+ stdio: ["ignore", "pipe", "ignore"],
358
+ });
185
359
  let session;
186
- try { session = JSON.parse(exported.stdout); } catch { return []; }
360
+ try {
361
+ session = JSON.parse(exported.stdout);
362
+ } catch {
363
+ return [];
364
+ }
187
365
  const items = [];
188
- for (const message of Array.isArray(session?.messages) ? session.messages : []) {
189
- const at = message.info?.time?.created ? new Date(message.info.time.created).toISOString() : null;
366
+ for (const message of Array.isArray(session?.messages)
367
+ ? session.messages
368
+ : []) {
369
+ const at = message.info?.time?.created
370
+ ? new Date(message.info.time.created).toISOString()
371
+ : null;
190
372
  for (const part of Array.isArray(message.parts) ? message.parts : []) {
191
- if (message.info?.role === "user") { if (part.type === "text" && !part.synthetic && !machinery(String(part.text || ""))) items.push({ kind: "you", text: String(part.text).trim(), at }); }
192
- else items.push(...stepsOf({ type: part.type, part }).map((step) => ({ ...step, at })));
373
+ if (message.info?.role === "user") {
374
+ if (
375
+ part.type === "text" &&
376
+ !part.synthetic &&
377
+ !machinery(String(part.text || ""))
378
+ )
379
+ items.push({ kind: "you", text: String(part.text).trim(), at });
380
+ } else
381
+ items.push(
382
+ ...stepsOf({ type: part.type, part }).map((step) => ({ ...step, at }))
383
+ );
193
384
  }
194
385
  }
195
386
  return items.slice(-limit);
@@ -209,26 +400,60 @@ export function sessionFeed(agent, file, limit = 80) {
209
400
  fs.closeSync(handle);
210
401
  text = buffer.toString("utf8");
211
402
  if (size > buffer.length) text = text.slice(text.indexOf("\n") + 1);
212
- } catch { return []; }
403
+ } catch {
404
+ return [];
405
+ }
213
406
  const items = [];
214
407
  for (const line of text.split("\n")) {
215
408
  if (!line.startsWith("{")) continue;
216
409
  let row;
217
- try { row = JSON.parse(line); } catch { continue; }
410
+ try {
411
+ row = JSON.parse(line);
412
+ } catch {
413
+ continue;
414
+ }
218
415
  const at = row.timestamp || null;
219
416
  if (agent === "claude-code") {
220
417
  if (row.type === "user" && !row.isMeta) {
221
418
  const said = promptText(row.message?.content);
222
- if (!machinery(said)) items.push({ kind: "you", text: said.trim(), at });
223
- } else if (row.type === "assistant") items.push(...stepsOf(row).map((step) => ({ ...step, at })));
419
+ if (!machinery(said))
420
+ items.push({ kind: "you", text: said.trim(), at });
421
+ } else if (row.type === "assistant")
422
+ items.push(...stepsOf(row).map((step) => ({ ...step, at })));
224
423
  } else if (agent === "codex" && row.type === "response_item") {
225
424
  const item = row.payload || {};
226
- if (item.type === "message" && item.role === "user") { const said = promptText(item.content); if (!machinery(said)) items.push({ kind: "you", text: said.trim(), at }); }
227
- 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 }); }
228
- 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 });
425
+ if (item.type === "message" && item.role === "user") {
426
+ const said = promptText(item.content);
427
+ if (!machinery(said))
428
+ items.push({ kind: "you", text: said.trim(), at });
429
+ } else if (item.type === "message" && item.role === "assistant") {
430
+ const said = (item.content || [])
431
+ .map((block) => block?.text || "")
432
+ .join("\n")
433
+ .trim();
434
+ if (said) items.push({ kind: "say", text: said, at });
435
+ } else if (
436
+ item.type === "function_call" ||
437
+ item.type === "custom_tool_call"
438
+ )
439
+ items.push({
440
+ kind: "do",
441
+ text: `${item.name}: ${String(item.arguments ?? item.input ?? "")
442
+ .replace(/\s+/g, " ")
443
+ .slice(0, 200)}`,
444
+ at,
445
+ });
229
446
  } else if (agent === "pi" && row.type === "message" && row.message) {
230
- if (row.message.role === "user") { const said = promptText(row.message.content); if (!machinery(said)) items.push({ kind: "you", text: said.trim(), at }); }
231
- else if (row.message.role === "assistant") items.push(...stepsOf({ type: "message_end", message: row.message }).map((step) => ({ ...step, at })));
447
+ if (row.message.role === "user") {
448
+ const said = promptText(row.message.content);
449
+ if (!machinery(said))
450
+ items.push({ kind: "you", text: said.trim(), at });
451
+ } else if (row.message.role === "assistant")
452
+ items.push(
453
+ ...stepsOf({ type: "message_end", message: row.message }).map(
454
+ (step) => ({ ...step, at })
455
+ )
456
+ );
232
457
  }
233
458
  }
234
459
  return items.slice(-limit);