tryharness 0.7.23 → 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 (61) hide show
  1. package/assets/opencode-plugin.js +48 -11
  2. package/assets/pi-extension.ts +105 -26
  3. package/cli.mjs +1238 -322
  4. package/lib/activity.mjs +197 -40
  5. package/lib/adapters.mjs +337 -54
  6. package/lib/agentnode.mjs +175 -35
  7. package/lib/atlas.mjs +383 -83
  8. package/lib/cards.mjs +174 -40
  9. package/lib/claudemem.mjs +74 -18
  10. package/lib/commands.mjs +13 -5
  11. package/lib/context.mjs +409 -71
  12. package/lib/control-client.mjs +5 -2
  13. package/lib/coordinator.mjs +274 -57
  14. package/lib/daemon.mjs +5248 -1024
  15. package/lib/desktop.mjs +149 -35
  16. package/lib/digest.mjs +54 -13
  17. package/lib/environment.mjs +103 -21
  18. package/lib/files.mjs +246 -13
  19. package/lib/firewall.mjs +169 -35
  20. package/lib/folders.mjs +179 -36
  21. package/lib/frames.mjs +214 -40
  22. package/lib/inference.mjs +463 -126
  23. package/lib/learn.mjs +78 -16
  24. package/lib/live.mjs +286 -61
  25. package/lib/localmodels.mjs +424 -83
  26. package/lib/mcp-stdio.mjs +868 -151
  27. package/lib/media.mjs +138 -28
  28. package/lib/members.json +1 -1
  29. package/lib/members.mjs +35 -9
  30. package/lib/models.mjs +342 -80
  31. package/lib/network.mjs +201 -42
  32. package/lib/notify.mjs +49 -12
  33. package/lib/nudge.mjs +184 -33
  34. package/lib/ocr.mjs +223 -60
  35. package/lib/onboard.mjs +321 -70
  36. package/lib/parsers.mjs +132 -41
  37. package/lib/paths.mjs +33 -10
  38. package/lib/personal.mjs +51 -13
  39. package/lib/phones.mjs +382 -60
  40. package/lib/places.mjs +555 -107
  41. package/lib/provenance.mjs +19 -4
  42. package/lib/receipts.mjs +51 -11
  43. package/lib/redact.mjs +512 -116
  44. package/lib/runner.mjs +345 -55
  45. package/lib/runtime-install.mjs +55 -14
  46. package/lib/screens.mjs +154 -38
  47. package/lib/sealed.mjs +157 -28
  48. package/lib/secrets.mjs +18 -5
  49. package/lib/service.mjs +170 -34
  50. package/lib/snippets.mjs +94 -18
  51. package/lib/speech.mjs +688 -175
  52. package/lib/store.mjs +1654 -307
  53. package/lib/swarm.mjs +596 -117
  54. package/lib/t3.mjs +400 -79
  55. package/lib/trails.mjs +285 -61
  56. package/lib/ui.mjs +95 -23
  57. package/lib/vectors.mjs +5 -2
  58. package/lib/wakes.mjs +284 -68
  59. package/lib/watches.mjs +247 -48
  60. package/lib/worktrees.mjs +186 -38
  61. package/package.json +3 -3
package/lib/learn.mjs CHANGED
@@ -6,7 +6,21 @@ import path from "node:path";
6
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
7
  // trained on older runs and kept only if it beats the current one on the newest runs, which it never saw.
8
8
 
9
- export const FEATURES = ["dense", "denseRank", "lexical", "lexicalRank", "named", "prior", "priorRead", "lastEdit", "lastRun", "recent", "test", "doc", "depth"];
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
+ ];
10
24
  const HALF_LIFE_DAYS = 7;
11
25
 
12
26
  /**
@@ -21,8 +35,12 @@ export function swarmPrior(history, at) {
21
35
  for (const run of history) {
22
36
  if (!(run.at < at)) continue;
23
37
  const weight = 0.5 ** ((at - run.at) / 86_400_000 / HALF_LIFE_DAYS);
24
- for (const file of run.edited) { edits.set(file, (edits.get(file) ?? 0) + weight); lastEdit.set(file, Math.max(lastEdit.get(file) ?? 0, run.at)); }
25
- for (const file of run.read) reads.set(file, (reads.get(file) ?? 0) + weight);
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);
26
44
  if (run.edited.length && (!latest || run.at > latest.at)) latest = run;
27
45
  }
28
46
  return { edits, reads, lastEdit, lastRun: new Set(latest?.edited ?? []), at };
@@ -41,15 +59,24 @@ export function featureRows(candidates, { prior, recent = new Map() }) {
41
59
  dense: row.dense,
42
60
  denseRank: Math.log1p(denseRank.get(row.path)),
43
61
  lexical: row.lexical > 0 ? row.lexical / topWords : 0,
44
- lexicalRank: row.lexical > 0 ? Math.log1p(wordsRank.get(row.path)) : Math.log1p(candidates.length),
62
+ lexicalRank:
63
+ row.lexical > 0
64
+ ? Math.log1p(wordsRank.get(row.path))
65
+ : Math.log1p(candidates.length),
45
66
  named: row.named,
46
67
  prior: Math.log1p(prior.edits.get(row.path) ?? 0),
47
68
  priorRead: Math.log1p(prior.reads.get(row.path) ?? 0),
48
69
  // Hours since any run last edited it, as a closeness from 1 (just now) toward 0 (weeks ago, or never).
49
- lastEdit: prior.lastEdit.has(row.path) ? 1 / (1 + (prior.at - prior.lastEdit.get(row.path)) / 3_600_000 / 24) : 0,
70
+ lastEdit: prior.lastEdit.has(row.path)
71
+ ? 1 / (1 + (prior.at - prior.lastEdit.get(row.path)) / 3_600_000 / 24)
72
+ : 0,
50
73
  lastRun: prior.lastRun.has(row.path) ? 1 : 0,
51
74
  recent: Math.log1p(recent.get(row.path) ?? 0),
52
- test: /(?:^|\/)(?:tests?|__tests__)\/|\.(?:test|spec)\.[^.]+$/.test(row.path) ? 1 : 0,
75
+ test: /(?:^|\/)(?:tests?|__tests__)\/|\.(?:test|spec)\.[^.]+$/.test(
76
+ row.path
77
+ )
78
+ ? 1
79
+ : 0,
53
80
  doc: /\.(?:md|mdx|txt)$/i.test(row.path) ? 1 : 0,
54
81
  depth: row.path.split("/").length,
55
82
  },
@@ -62,19 +89,35 @@ export function baselineScore(x) {
62
89
  }
63
90
 
64
91
  /** What a pack is for: code. Notes and status docs (edited by nearly every session) are neither right answers nor misses. */
65
- export const codeFiles = (files) => files.filter((file) => !/\.(?:md|mdx|txt)$/i.test(file));
92
+ export const codeFiles = (files) =>
93
+ files.filter((file) => !/\.(?:md|mdx|txt)$/i.test(file));
66
94
 
67
95
  /** Trains a logistic regression on rows { x, y }; positives weighted up to balance the classes. */
68
96
  export function train(rows, { epochs = 300, rate = 0.05, l2 = 1e-3 } = {}) {
69
97
  const n = rows.length;
70
- const mean = Object.fromEntries(FEATURES.map((name) => [name, rows.reduce((sum, row) => sum + row.x[name], 0) / n]));
71
- const std = Object.fromEntries(FEATURES.map((name) => [name, Math.sqrt(rows.reduce((sum, row) => sum + (row.x[name] - mean[name]) ** 2, 0) / n) || 1]));
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
+ );
72
112
  const positives = rows.filter((row) => row.y).length;
73
113
  const weightPos = positives ? Math.min(50, (n - positives) / positives) : 1;
74
- const xs = rows.map((row) => FEATURES.map((name) => (row.x[name] - mean[name]) / std[name]));
114
+ const xs = rows.map((row) =>
115
+ FEATURES.map((name) => (row.x[name] - mean[name]) / std[name])
116
+ );
75
117
  const w = new Float64Array(FEATURES.length);
76
118
  let b = 0;
77
- const m = new Float64Array(FEATURES.length + 1), v = new Float64Array(FEATURES.length + 1);
119
+ const m = new Float64Array(FEATURES.length + 1),
120
+ v = new Float64Array(FEATURES.length + 1);
78
121
  for (let epoch = 1; epoch <= epochs; epoch += 1) {
79
122
  const grad = new Float64Array(FEATURES.length + 1);
80
123
  let total = 0;
@@ -92,16 +135,29 @@ export function train(rows, { epochs = 300, rate = 0.05, l2 = 1e-3 } = {}) {
92
135
  const g = grad[j] / total + (j < w.length ? l2 * w[j] : 0);
93
136
  m[j] = 0.9 * m[j] + 0.1 * g;
94
137
  v[j] = 0.999 * v[j] + 0.001 * g * g;
95
- const step = rate * (m[j] / (1 - 0.9 ** epoch)) / (Math.sqrt(v[j] / (1 - 0.999 ** epoch)) + 1e-8);
96
- if (j < w.length) w[j] -= step; else b -= step;
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;
97
143
  }
98
144
  }
99
- return { features: FEATURES, weights: Array.from(w), bias: b, mean, std, rows: n, positives };
145
+ return {
146
+ features: FEATURES,
147
+ weights: Array.from(w),
148
+ bias: b,
149
+ mean,
150
+ std,
151
+ rows: n,
152
+ positives,
153
+ };
100
154
  }
101
155
 
102
156
  export function modelScore(model, x) {
103
157
  let z = model.bias;
104
- model.features.forEach((name, j) => { z += model.weights[j] * ((x[name] - model.mean[name]) / model.std[name]); });
158
+ model.features.forEach((name, j) => {
159
+ z += model.weights[j] * ((x[name] - model.mean[name]) / model.std[name]);
160
+ });
105
161
  return z;
106
162
  }
107
163
 
@@ -112,7 +168,13 @@ export function recallAt(ranked, wanted, k) {
112
168
  return wanted.filter((file) => top.has(file)).length / wanted.length;
113
169
  }
114
170
 
115
- export const loadModel = (file) => { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } };
171
+ export const loadModel = (file) => {
172
+ try {
173
+ return JSON.parse(fs.readFileSync(file, "utf8"));
174
+ } catch {
175
+ return null;
176
+ }
177
+ };
116
178
  export function saveModel(file, model) {
117
179
  fs.mkdirSync(path.dirname(file), { recursive: true });
118
180
  fs.writeFileSync(`${file}.tmp`, JSON.stringify(model, null, 2));
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);