@shanesaravia/hive 0.2.1 → 0.4.0
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/CHANGELOG.md +53 -0
- package/README.md +18 -1
- package/node_modules/@hive/shared/dist/directStudio.d.ts +6 -0
- package/node_modules/@hive/shared/dist/directStudio.js +12 -0
- package/node_modules/@hive/shared/dist/index.d.ts +2 -0
- package/node_modules/@hive/shared/dist/index.js +2 -0
- package/node_modules/@hive/shared/dist/reviewHall.d.ts +15 -0
- package/node_modules/@hive/shared/dist/reviewHall.js +49 -0
- package/node_modules/@hive/shared/dist/status.js +9 -0
- package/node_modules/@hive/shared/dist/types.d.ts +191 -1
- package/node_modules/@hive/shared/dist/types.js +27 -0
- package/node_modules/@hive/shared/dist/workers.d.ts +14 -0
- package/node_modules/@hive/shared/dist/workers.js +22 -0
- package/package.json +1 -1
- package/packages/server/dist/agents/agentDiscovery.js +64 -0
- package/packages/server/dist/api/rest.js +538 -23
- package/packages/server/dist/api/ws.js +90 -9
- package/packages/server/dist/control/launcher.js +50 -11
- package/packages/server/dist/control/messaging.js +3 -2
- package/packages/server/dist/control/missionQuiesce.js +66 -0
- package/packages/server/dist/health/deriveAlerts.js +9 -2
- package/packages/server/dist/hooks/hookIngest.js +69 -10
- package/packages/server/dist/index.js +44 -4
- package/packages/server/dist/loops/loopCommand.js +56 -0
- package/packages/server/dist/loops/loopNoop.js +38 -0
- package/packages/server/dist/loops/loopScheduler.js +58 -0
- package/packages/server/dist/loops/loopStore.js +118 -0
- package/packages/server/dist/loops/monitors.js +38 -0
- package/packages/server/dist/messages/attachmentStore.js +92 -0
- package/packages/server/dist/messages/messagesStore.js +110 -33
- package/packages/server/dist/missions/missionsStore.js +9 -0
- package/packages/server/dist/missions/reopenOnWork.js +20 -0
- package/packages/server/dist/plans/planReconcile.js +114 -0
- package/packages/server/dist/plans/plansStore.js +46 -3
- package/packages/server/dist/reviews/reviewDiff.js +47 -0
- package/packages/server/dist/roster/missionReplay.js +82 -0
- package/packages/server/dist/roster/replyAsk.js +62 -0
- package/packages/server/dist/roster/rosterBuilder.js +78 -147
- package/packages/server/dist/roster/workerIdentity.js +923 -0
- package/packages/server/dist/skills/skillDiscovery.js +28 -4
- package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
- package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
- package/packages/server/dist/terminals/providerDetection.js +27 -0
- package/packages/server/dist/terminals/terminalCapability.js +45 -0
- package/packages/server/dist/terminals/terminalFeatures.js +11 -0
- package/packages/server/dist/terminals/terminalObservability.js +21 -0
- package/packages/server/dist/terminals/terminalRuntime.js +125 -0
- package/packages/server/dist/terminals/terminalStream.js +30 -0
- package/packages/server/dist/transcripts/transcriptReader.js +345 -0
- package/packages/server/dist/watch/jobsWatcher.js +55 -24
- package/packages/web/dist/assets/index-DWjqiitn.js +17 -0
- package/packages/web/dist/assets/index-rd4RnLqj.css +2 -0
- package/packages/web/dist/index.html +2 -2
- package/templates/agents/hive-orchestrator.md +1 -0
- package/packages/web/dist/assets/index-Bzle5Xla.css +0 -2
- package/packages/web/dist/assets/index-C6AY0vYC.js +0 -11
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* The session transcript, read as the terminal renders it.
|
|
6
|
+
*
|
|
7
|
+
* Hive's activity feed is built from hooks, and a hook says only that a tool
|
|
8
|
+
* fired: it carries no assistant prose, no tool input beyond a summary, and no
|
|
9
|
+
* result at all. So the feed can report "Notification: Claude is waiting for
|
|
10
|
+
* your input" while being structurally incapable of saying what for.
|
|
11
|
+
*
|
|
12
|
+
* The provider already writes the whole thing to disk — every assistant turn,
|
|
13
|
+
* every tool call with its real input, every result — and each subagent gets
|
|
14
|
+
* its own file keyed by the same native agent id the roster tracks:
|
|
15
|
+
*
|
|
16
|
+
* ~/.claude/projects/<slug>/<sessionId>.jsonl
|
|
17
|
+
* ~/.claude/projects/<slug>/<sessionId>/subagents/agent-<agentId>.jsonl
|
|
18
|
+
* ~/.claude/projects/<slug>/<sessionId>/subagents/agent-<agentId>.meta.json
|
|
19
|
+
*
|
|
20
|
+
* These files reach several megabytes on a long mission, so every read here is
|
|
21
|
+
* bounded — walking backwards from the end and stopping once it has enough.
|
|
22
|
+
*/
|
|
23
|
+
/** How much of a transcript to read per backward step. */
|
|
24
|
+
const CHUNK_BYTES = 512 * 1024;
|
|
25
|
+
/**
|
|
26
|
+
* The ceiling on how far back to walk for one read.
|
|
27
|
+
*
|
|
28
|
+
* A fixed tail window cannot work here: a single tool result carrying an image
|
|
29
|
+
* is megabytes of base64 on one line, and one observed worker's last 768KB held
|
|
30
|
+
* nine entries. So the read is driven by how many entries it has collected and
|
|
31
|
+
* merely bounded by bytes.
|
|
32
|
+
*/
|
|
33
|
+
const MAX_SCAN_BYTES = 16 * 1024 * 1024;
|
|
34
|
+
/** Per-entry output cap. The reader keeps the line count so the UI can say what it cut. */
|
|
35
|
+
const OUTPUT_CHARS = 4_000;
|
|
36
|
+
const TEXT_CHARS = 12_000;
|
|
37
|
+
// Read per call rather than captured, and honouring the same override
|
|
38
|
+
// `skillDiscovery` uses, so a test can point the whole reader at a fixture.
|
|
39
|
+
const projectsRoot = () => path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"), "projects");
|
|
40
|
+
/**
|
|
41
|
+
* Caches the containing directory per session; the file itself may be
|
|
42
|
+
* rewritten, and the cached entry is revalidated before it is trusted, so a
|
|
43
|
+
* changed root cannot serve a stale hit.
|
|
44
|
+
*/
|
|
45
|
+
const transcriptDirs = new Map();
|
|
46
|
+
/** The `~/.claude/projects/<slug>` directory holding this session's transcript. */
|
|
47
|
+
function projectDirFor(sessionId) {
|
|
48
|
+
const known = transcriptDirs.get(sessionId);
|
|
49
|
+
if (known && fs.existsSync(path.join(known, `${sessionId}.jsonl`)))
|
|
50
|
+
return known;
|
|
51
|
+
let slugs;
|
|
52
|
+
try {
|
|
53
|
+
slugs = fs.readdirSync(projectsRoot());
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
for (const slug of slugs) {
|
|
59
|
+
const dir = path.join(projectsRoot(), slug);
|
|
60
|
+
if (!fs.existsSync(path.join(dir, `${sessionId}.jsonl`)))
|
|
61
|
+
continue;
|
|
62
|
+
transcriptDirs.set(sessionId, dir);
|
|
63
|
+
return dir;
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
export function orchestratorTranscriptPath(sessionId) {
|
|
68
|
+
const dir = projectDirFor(sessionId);
|
|
69
|
+
const file = dir && path.join(dir, `${sessionId}.jsonl`);
|
|
70
|
+
return file && fs.existsSync(file) ? file : undefined;
|
|
71
|
+
}
|
|
72
|
+
export function workerTranscriptPath(sessionId, agentId) {
|
|
73
|
+
const dir = projectDirFor(sessionId);
|
|
74
|
+
if (!dir)
|
|
75
|
+
return undefined;
|
|
76
|
+
// The id comes from a URL, so it must never be able to climb out of the
|
|
77
|
+
// subagents directory.
|
|
78
|
+
if (!/^[A-Za-z0-9_-]+$/.test(agentId))
|
|
79
|
+
return undefined;
|
|
80
|
+
const file = path.join(dir, sessionId, "subagents", `agent-${agentId}.jsonl`);
|
|
81
|
+
return fs.existsSync(file) ? file : undefined;
|
|
82
|
+
}
|
|
83
|
+
function workerMeta(sessionId, agentId) {
|
|
84
|
+
const dir = projectDirFor(sessionId);
|
|
85
|
+
if (!dir || !/^[A-Za-z0-9_-]+$/.test(agentId))
|
|
86
|
+
return {};
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(fs.readFileSync(path.join(dir, sessionId, "subagents", `agent-${agentId}.meta.json`), "utf8"));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return {};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Every transcript belonging to a mission: the manager's, then one per worker
|
|
96
|
+
* the provider actually recorded.
|
|
97
|
+
*
|
|
98
|
+
* The worker list comes from the filesystem rather than from the roster, so a
|
|
99
|
+
* worker whose identity Hive never resolved still has a readable log — and a
|
|
100
|
+
* roster entry with no transcript is not offered as an empty tab.
|
|
101
|
+
*/
|
|
102
|
+
export function transcriptSources(input) {
|
|
103
|
+
const sources = [];
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
for (const sessionId of input.sessionIds) {
|
|
106
|
+
const manager = orchestratorTranscriptPath(sessionId);
|
|
107
|
+
if (manager && !seen.has("orchestrator")) {
|
|
108
|
+
seen.add("orchestrator");
|
|
109
|
+
sources.push({ id: "orchestrator", label: "Orchestrator", kind: "orchestrator", sessionId, updatedAt: mtime(manager), bytes: size(manager) });
|
|
110
|
+
}
|
|
111
|
+
const dir = projectDirFor(sessionId);
|
|
112
|
+
const subagents = dir ? path.join(dir, sessionId, "subagents") : undefined;
|
|
113
|
+
let entries;
|
|
114
|
+
try {
|
|
115
|
+
entries = subagents ? fs.readdirSync(subagents) : [];
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
entries = [];
|
|
119
|
+
}
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
const match = entry.match(/^agent-([A-Za-z0-9_-]+)\.jsonl$/);
|
|
122
|
+
if (!match || seen.has(match[1]))
|
|
123
|
+
continue;
|
|
124
|
+
seen.add(match[1]);
|
|
125
|
+
const meta = workerMeta(sessionId, match[1]);
|
|
126
|
+
const file = path.join(subagents, entry);
|
|
127
|
+
sources.push({
|
|
128
|
+
id: match[1],
|
|
129
|
+
// The roster's name for the worker is the one already on screen
|
|
130
|
+
// elsewhere; the transcript's own description is the fallback.
|
|
131
|
+
label: input.labelFor?.(match[1]) ?? meta.description ?? match[1],
|
|
132
|
+
kind: "worker",
|
|
133
|
+
sessionId,
|
|
134
|
+
agentType: meta.agentType,
|
|
135
|
+
updatedAt: mtime(file),
|
|
136
|
+
bytes: size(file),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Newest activity first among the workers; the manager always leads.
|
|
141
|
+
const workers = sources.filter((source) => source.kind === "worker").sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
142
|
+
return [...sources.filter((source) => source.kind === "orchestrator"), ...workers];
|
|
143
|
+
}
|
|
144
|
+
function mtime(file) {
|
|
145
|
+
try {
|
|
146
|
+
return fs.statSync(file).mtimeMs;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function size(file) {
|
|
153
|
+
try {
|
|
154
|
+
return fs.statSync(file).size;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function clamp(text, max) {
|
|
161
|
+
return text.length > max ? { text: `${text.slice(0, max)}…`, truncated: true } : { text, truncated: false };
|
|
162
|
+
}
|
|
163
|
+
/** A tool result's body, which the provider writes as a string or as blocks. */
|
|
164
|
+
function resultText(content) {
|
|
165
|
+
if (typeof content === "string")
|
|
166
|
+
return content;
|
|
167
|
+
if (Array.isArray(content)) {
|
|
168
|
+
return content
|
|
169
|
+
.map((item) => {
|
|
170
|
+
if (typeof item === "string")
|
|
171
|
+
return item;
|
|
172
|
+
if (item && typeof item === "object") {
|
|
173
|
+
const block = item;
|
|
174
|
+
if (typeof block.text === "string")
|
|
175
|
+
return block.text;
|
|
176
|
+
if (block.type)
|
|
177
|
+
return `[${block.type}]`;
|
|
178
|
+
}
|
|
179
|
+
return "";
|
|
180
|
+
})
|
|
181
|
+
.filter(Boolean)
|
|
182
|
+
.join("\n");
|
|
183
|
+
}
|
|
184
|
+
if (content && typeof content === "object")
|
|
185
|
+
return JSON.stringify(content);
|
|
186
|
+
return "";
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* How the terminal titles a tool call: the one field that says what it is
|
|
190
|
+
* doing, not the whole input object.
|
|
191
|
+
*
|
|
192
|
+
* A `Bash` whose header is `{"command":"…","description":"…"}` tells the reader
|
|
193
|
+
* nothing they could not have guessed; the command tells them everything.
|
|
194
|
+
*/
|
|
195
|
+
export function invocationOf(tool, input) {
|
|
196
|
+
const fields = input && typeof input === "object" ? input : {};
|
|
197
|
+
const str = (key) => (typeof fields[key] === "string" ? fields[key] : undefined);
|
|
198
|
+
const headline = str("command")
|
|
199
|
+
?? str("file_path")
|
|
200
|
+
?? str("path")
|
|
201
|
+
?? str("pattern")
|
|
202
|
+
?? str("description")
|
|
203
|
+
?? str("query")
|
|
204
|
+
?? str("prompt")
|
|
205
|
+
?? str("url")
|
|
206
|
+
?? str("skill")
|
|
207
|
+
?? str("action")
|
|
208
|
+
?? (Object.keys(fields).length ? JSON.stringify(fields) : "");
|
|
209
|
+
const single = headline.replace(/\s+/g, " ").trim();
|
|
210
|
+
return single ? `${tool}(${clamp(single, 200).text})` : tool;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Parses one transcript line into what the terminal would print, or nothing.
|
|
214
|
+
*
|
|
215
|
+
* Dropped outright: `attachment` and the provider's own bookkeeping records
|
|
216
|
+
* (titles, modes, queue operations), which have no on-screen form at all.
|
|
217
|
+
*/
|
|
218
|
+
function entriesFrom(raw) {
|
|
219
|
+
const ts = raw.timestamp ? Date.parse(raw.timestamp) : undefined;
|
|
220
|
+
const at = Number.isFinite(ts) ? ts : undefined;
|
|
221
|
+
const content = raw.message?.content;
|
|
222
|
+
if (raw.type === "user") {
|
|
223
|
+
// The turn's actual prompt — for a worker, its assignment.
|
|
224
|
+
if (typeof content === "string") {
|
|
225
|
+
const { text, truncated } = clamp(content.trim(), TEXT_CHARS);
|
|
226
|
+
return text ? [{ kind: "prompt", ts: at, text, truncated }] : [];
|
|
227
|
+
}
|
|
228
|
+
if (!Array.isArray(content))
|
|
229
|
+
return [];
|
|
230
|
+
const out = [];
|
|
231
|
+
for (const item of content) {
|
|
232
|
+
if (item?.type === "tool_result") {
|
|
233
|
+
const body = resultText(item.content);
|
|
234
|
+
const { text, truncated } = clamp(body.trim(), OUTPUT_CHARS);
|
|
235
|
+
out.push({
|
|
236
|
+
kind: "tool_result",
|
|
237
|
+
ts: at,
|
|
238
|
+
output: text,
|
|
239
|
+
truncated,
|
|
240
|
+
lines: body ? body.split("\n").length : 0,
|
|
241
|
+
isError: item.is_error === true,
|
|
242
|
+
toolUseId: typeof item.tool_use_id === "string" ? item.tool_use_id : undefined,
|
|
243
|
+
});
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (item?.type === "text" && typeof item.text === "string") {
|
|
247
|
+
// Injected rather than typed: a skill's instructions, a system
|
|
248
|
+
// reminder, a tool's follow-up. Real in the transcript, but not a
|
|
249
|
+
// thing the user said — so it is kept and marked, not shown as a turn.
|
|
250
|
+
const injected = raw.isMeta === true || Boolean(raw.sourceToolUseID);
|
|
251
|
+
const { text, truncated } = clamp(item.text.trim(), TEXT_CHARS);
|
|
252
|
+
if (text)
|
|
253
|
+
out.push({ kind: injected ? "injected" : "prompt", ts: at, text, truncated });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
if (raw.type !== "assistant" || !Array.isArray(content))
|
|
259
|
+
return [];
|
|
260
|
+
const out = [];
|
|
261
|
+
for (const item of content) {
|
|
262
|
+
if (item?.type === "text" && typeof item.text === "string") {
|
|
263
|
+
const { text, truncated } = clamp(item.text.trim(), TEXT_CHARS);
|
|
264
|
+
if (text)
|
|
265
|
+
out.push({ kind: "assistant", ts: at, text, truncated, skill: raw.attributionSkill });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (item?.type === "thinking") {
|
|
269
|
+
// Redacted thinking arrives as a signature with an empty body; there is
|
|
270
|
+
// nothing to show and a "Thinking" header over blank space is noise.
|
|
271
|
+
const thinking = typeof item.thinking === "string" ? item.thinking.trim() : "";
|
|
272
|
+
if (thinking) {
|
|
273
|
+
const { text, truncated } = clamp(thinking, TEXT_CHARS);
|
|
274
|
+
out.push({ kind: "thinking", ts: at, text, truncated });
|
|
275
|
+
}
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (item?.type === "tool_use" && typeof item.name === "string") {
|
|
279
|
+
out.push({
|
|
280
|
+
kind: "tool_use",
|
|
281
|
+
ts: at,
|
|
282
|
+
tool: item.name,
|
|
283
|
+
invocation: invocationOf(item.name, item.input),
|
|
284
|
+
input: clamp(JSON.stringify(item.input ?? {}, null, 2), OUTPUT_CHARS).text,
|
|
285
|
+
toolUseId: typeof item.id === "string" ? item.id : undefined,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Reads the tail of a transcript file into terminal-shaped entries.
|
|
293
|
+
*
|
|
294
|
+
* `limit` counts entries, not lines: one assistant message can produce a text
|
|
295
|
+
* block and several tool calls, and cutting mid-message would strand a tool
|
|
296
|
+
* call from the result that answers it.
|
|
297
|
+
*/
|
|
298
|
+
export function readTranscript(file, limit = 400) {
|
|
299
|
+
let fd;
|
|
300
|
+
try {
|
|
301
|
+
fd = fs.openSync(file, "r");
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return { entries: [], truncated: false };
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
const total = fs.fstatSync(fd).size;
|
|
308
|
+
let offset = total;
|
|
309
|
+
let entries = [];
|
|
310
|
+
// A line split across a chunk boundary: held here and prepended to the
|
|
311
|
+
// earlier chunk, which is the half that carries its beginning.
|
|
312
|
+
let partial = "";
|
|
313
|
+
while (offset > 0 && entries.length < limit && total - offset < MAX_SCAN_BYTES) {
|
|
314
|
+
const start = Math.max(0, offset - CHUNK_BYTES);
|
|
315
|
+
const buffer = Buffer.alloc(offset - start);
|
|
316
|
+
fs.readSync(fd, buffer, 0, buffer.length, start);
|
|
317
|
+
const lines = `${buffer.toString("utf8")}${partial}`.split("\n");
|
|
318
|
+
// Only a chunk that does not begin the file can begin mid-line.
|
|
319
|
+
partial = start > 0 ? lines.shift() ?? "" : "";
|
|
320
|
+
const chunk = [];
|
|
321
|
+
for (const line of lines) {
|
|
322
|
+
const trimmed = line.trim();
|
|
323
|
+
if (!trimmed)
|
|
324
|
+
continue;
|
|
325
|
+
try {
|
|
326
|
+
chunk.push(...entriesFrom(JSON.parse(trimmed)));
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
entries = [...chunk, ...entries];
|
|
333
|
+
offset = start;
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
entries: entries.slice(-limit),
|
|
337
|
+
truncated: offset > 0 || entries.length > limit,
|
|
338
|
+
path: file,
|
|
339
|
+
updatedAt: mtime(file),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
fs.closeSync(fd);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -55,8 +55,11 @@ export class JobsWatcher {
|
|
|
55
55
|
// only a terse question in needs/detail, and done turns store a
|
|
56
56
|
// one-line summary in output.result. The full reply lives in the
|
|
57
57
|
// session transcript (timeline text is a 4KB tail as fallback).
|
|
58
|
-
if (job.state === "done" || job.state === "blocked") {
|
|
59
|
-
|
|
58
|
+
if (job.state === "done" || job.state === "blocked" || job.state === "working") {
|
|
59
|
+
const turn = this.resolveTurnTexts(jobId, job, directory);
|
|
60
|
+
if (job.state !== "working")
|
|
61
|
+
job.lastText = turn.final;
|
|
62
|
+
job.progressTexts = turn.updates;
|
|
60
63
|
}
|
|
61
64
|
next.set(jobId, job);
|
|
62
65
|
}
|
|
@@ -69,16 +72,25 @@ export class JobsWatcher {
|
|
|
69
72
|
for (const listener of this.jobsListeners)
|
|
70
73
|
listener(this.jobs);
|
|
71
74
|
}
|
|
75
|
+
/** Cached per job by state.json's updatedAt and the transcript's size, so reloads don't re-read transcripts. */
|
|
72
76
|
lastTextCache = new Map();
|
|
73
|
-
|
|
74
|
-
|
|
77
|
+
resolveTurnTexts(jobId, job, directory) {
|
|
78
|
+
const transcript = transcriptPath(job.sessionId);
|
|
79
|
+
let size;
|
|
80
|
+
try {
|
|
81
|
+
size = transcript ? fs.statSync(transcript).size : undefined;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
size = undefined;
|
|
85
|
+
}
|
|
75
86
|
const cached = this.lastTextCache.get(jobId);
|
|
76
|
-
if (cached && cached.updatedAt === job.updatedAt)
|
|
77
|
-
return cached.
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
87
|
+
if (cached && cached.updatedAt === job.updatedAt && cached.size === size)
|
|
88
|
+
return { final: cached.final, updates: cached.updates };
|
|
89
|
+
const turn = transcript ? readTranscriptTurn(transcript) : undefined;
|
|
90
|
+
const final = turn?.final ?? readLatestTimelineText(path.join(directory, jobId, "timeline.jsonl"));
|
|
91
|
+
const entry = { updatedAt: job.updatedAt, size, final, updates: turn?.updates ?? [] };
|
|
92
|
+
this.lastTextCache.set(jobId, entry);
|
|
93
|
+
return { final, updates: entry.updates };
|
|
82
94
|
}
|
|
83
95
|
tailTimeline(jobId, directory) {
|
|
84
96
|
const timelinePath = path.join(directory, jobId, "timeline.jsonl");
|
|
@@ -116,11 +128,14 @@ export class JobsWatcher {
|
|
|
116
128
|
}
|
|
117
129
|
}
|
|
118
130
|
const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
|
|
119
|
-
/** Locates the session's transcript under ~/.claude/projects
|
|
120
|
-
|
|
121
|
-
function
|
|
131
|
+
/** Locates the session's transcript under ~/.claude/projects. */
|
|
132
|
+
const transcriptPaths = new Map();
|
|
133
|
+
function transcriptPath(sessionId) {
|
|
122
134
|
if (!sessionId)
|
|
123
135
|
return undefined;
|
|
136
|
+
const known = transcriptPaths.get(sessionId);
|
|
137
|
+
if (known && fs.existsSync(known))
|
|
138
|
+
return known;
|
|
124
139
|
const root = path.join(os.homedir(), ".claude", "projects");
|
|
125
140
|
let dirs;
|
|
126
141
|
try {
|
|
@@ -133,17 +148,27 @@ function readTranscriptFinalText(sessionId) {
|
|
|
133
148
|
const file = path.join(root, dir, `${sessionId}.jsonl`);
|
|
134
149
|
if (!fs.existsSync(file))
|
|
135
150
|
continue;
|
|
136
|
-
|
|
151
|
+
transcriptPaths.set(sessionId, file);
|
|
152
|
+
return file;
|
|
137
153
|
}
|
|
138
154
|
return undefined;
|
|
139
155
|
}
|
|
140
|
-
|
|
156
|
+
/** How much of what the model says between tool calls the conversation keeps. */
|
|
157
|
+
const PROGRESS_UPDATE_LIMIT = 8;
|
|
158
|
+
const PROGRESS_UPDATE_CHARS = 320;
|
|
159
|
+
/**
|
|
160
|
+
* The current turn's assistant text, from the tail of the transcript: the
|
|
161
|
+
* final reply, and what the model said along the way — narration between
|
|
162
|
+
* tool calls, oldest first, trimmed. A turn starts at the last user message
|
|
163
|
+
* that is not a tool result.
|
|
164
|
+
*/
|
|
165
|
+
export function readTranscriptTurn(file) {
|
|
141
166
|
let fd;
|
|
142
167
|
try {
|
|
143
168
|
fd = fs.openSync(file, "r");
|
|
144
169
|
}
|
|
145
170
|
catch {
|
|
146
|
-
return
|
|
171
|
+
return { updates: [] };
|
|
147
172
|
}
|
|
148
173
|
try {
|
|
149
174
|
const size = fs.fstatSync(fd).size;
|
|
@@ -151,6 +176,7 @@ function finalAssistantText(file) {
|
|
|
151
176
|
const buf = Buffer.alloc(size - offset);
|
|
152
177
|
fs.readSync(fd, buf, 0, buf.length, offset);
|
|
153
178
|
const lines = buf.toString("utf-8").split("\n");
|
|
179
|
+
const texts = [];
|
|
154
180
|
// Skip a line truncated by the tail window (index 0 when offset > 0).
|
|
155
181
|
for (let i = lines.length - 1; i >= (offset > 0 ? 1 : 0); i--) {
|
|
156
182
|
const line = lines[i].trim();
|
|
@@ -158,21 +184,26 @@ function finalAssistantText(file) {
|
|
|
158
184
|
continue;
|
|
159
185
|
try {
|
|
160
186
|
const entry = JSON.parse(line);
|
|
161
|
-
|
|
187
|
+
const content = entry.message?.content;
|
|
188
|
+
if (entry.type === "user") {
|
|
189
|
+
const isPrompt = typeof content === "string" || (Array.isArray(content) && content.some((item) => item?.type === "text"));
|
|
190
|
+
if (isPrompt)
|
|
191
|
+
break;
|
|
162
192
|
continue;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
.trim();
|
|
193
|
+
}
|
|
194
|
+
if (entry.type !== "assistant" || !Array.isArray(content))
|
|
195
|
+
continue;
|
|
196
|
+
const text = content.filter((item) => item?.type === "text" && typeof item.text === "string").map((item) => item.text).join("\n").trim();
|
|
168
197
|
if (text)
|
|
169
|
-
|
|
198
|
+
texts.unshift(text);
|
|
170
199
|
}
|
|
171
200
|
catch {
|
|
172
201
|
continue;
|
|
173
202
|
}
|
|
174
203
|
}
|
|
175
|
-
|
|
204
|
+
const final = texts.at(-1);
|
|
205
|
+
const updates = texts.slice(0, -1).slice(-PROGRESS_UPDATE_LIMIT).map((text) => text.length > PROGRESS_UPDATE_CHARS ? `${text.slice(0, PROGRESS_UPDATE_CHARS - 1).trimEnd()}…` : text);
|
|
206
|
+
return { final, updates };
|
|
176
207
|
}
|
|
177
208
|
finally {
|
|
178
209
|
fs.closeSync(fd);
|