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.
- package/assets/opencode-plugin.js +48 -11
- package/assets/pi-extension.ts +105 -26
- package/cli.mjs +1238 -322
- package/lib/activity.mjs +197 -40
- package/lib/adapters.mjs +337 -54
- package/lib/agentnode.mjs +175 -35
- package/lib/atlas.mjs +383 -83
- package/lib/cards.mjs +174 -40
- package/lib/claudemem.mjs +74 -18
- package/lib/commands.mjs +13 -5
- package/lib/context.mjs +409 -71
- package/lib/control-client.mjs +5 -2
- package/lib/coordinator.mjs +274 -57
- package/lib/daemon.mjs +5248 -1024
- package/lib/desktop.mjs +149 -35
- package/lib/digest.mjs +54 -13
- package/lib/environment.mjs +103 -21
- package/lib/files.mjs +246 -13
- package/lib/firewall.mjs +169 -35
- package/lib/folders.mjs +179 -36
- package/lib/frames.mjs +214 -40
- package/lib/inference.mjs +463 -126
- package/lib/learn.mjs +78 -16
- package/lib/live.mjs +286 -61
- package/lib/localmodels.mjs +424 -83
- package/lib/mcp-stdio.mjs +868 -151
- package/lib/media.mjs +138 -28
- package/lib/members.json +1 -1
- package/lib/members.mjs +35 -9
- package/lib/models.mjs +342 -80
- package/lib/network.mjs +201 -42
- package/lib/notify.mjs +49 -12
- package/lib/nudge.mjs +184 -33
- package/lib/ocr.mjs +223 -60
- package/lib/onboard.mjs +321 -70
- package/lib/parsers.mjs +132 -41
- package/lib/paths.mjs +33 -10
- package/lib/personal.mjs +51 -13
- package/lib/phones.mjs +382 -60
- package/lib/places.mjs +555 -107
- package/lib/provenance.mjs +19 -4
- package/lib/receipts.mjs +51 -11
- package/lib/redact.mjs +512 -116
- package/lib/runner.mjs +345 -55
- package/lib/runtime-install.mjs +55 -14
- package/lib/screens.mjs +154 -38
- package/lib/sealed.mjs +157 -28
- package/lib/secrets.mjs +18 -5
- package/lib/service.mjs +170 -34
- package/lib/snippets.mjs +94 -18
- package/lib/speech.mjs +688 -175
- package/lib/store.mjs +1654 -307
- package/lib/swarm.mjs +596 -117
- package/lib/t3.mjs +400 -79
- package/lib/trails.mjs +285 -61
- package/lib/ui.mjs +95 -23
- package/lib/vectors.mjs +5 -2
- package/lib/wakes.mjs +284 -68
- package/lib/watches.mjs +247 -48
- package/lib/worktrees.mjs +186 -38
- 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 = [
|
|
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) {
|
|
25
|
-
|
|
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:
|
|
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)
|
|
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(
|
|
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) =>
|
|
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(
|
|
71
|
-
|
|
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) =>
|
|
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),
|
|
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 =
|
|
96
|
-
|
|
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 {
|
|
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) => {
|
|
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) => {
|
|
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="], {
|
|
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)
|
|
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) =>
|
|
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", [
|
|
48
|
-
|
|
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", [
|
|
51
|
-
|
|
52
|
-
|
|
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(
|
|
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(
|
|
84
|
-
|
|
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
|
|
89
|
-
|
|
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({
|
|
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 {
|
|
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 (
|
|
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({
|
|
107
|
-
|
|
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]
|
|
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(
|
|
114
|
-
|
|
115
|
-
|
|
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)
|
|
120
|
-
|
|
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 (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 =
|
|
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 (
|
|
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)
|
|
155
|
-
|
|
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", [
|
|
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) =>
|
|
166
|
-
|
|
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) =>
|
|
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(
|
|
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(
|
|
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)], {
|
|
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 {
|
|
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)
|
|
189
|
-
|
|
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") {
|
|
192
|
-
|
|
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 {
|
|
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 {
|
|
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))
|
|
223
|
-
|
|
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") {
|
|
227
|
-
|
|
228
|
-
|
|
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") {
|
|
231
|
-
|
|
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);
|