@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +18 -1
  3. package/node_modules/@hive/shared/dist/directStudio.d.ts +6 -0
  4. package/node_modules/@hive/shared/dist/directStudio.js +12 -0
  5. package/node_modules/@hive/shared/dist/index.d.ts +2 -0
  6. package/node_modules/@hive/shared/dist/index.js +2 -0
  7. package/node_modules/@hive/shared/dist/reviewHall.d.ts +15 -0
  8. package/node_modules/@hive/shared/dist/reviewHall.js +49 -0
  9. package/node_modules/@hive/shared/dist/status.js +9 -0
  10. package/node_modules/@hive/shared/dist/types.d.ts +191 -1
  11. package/node_modules/@hive/shared/dist/types.js +27 -0
  12. package/node_modules/@hive/shared/dist/workers.d.ts +14 -0
  13. package/node_modules/@hive/shared/dist/workers.js +22 -0
  14. package/package.json +1 -1
  15. package/packages/server/dist/agents/agentDiscovery.js +64 -0
  16. package/packages/server/dist/api/rest.js +538 -23
  17. package/packages/server/dist/api/ws.js +90 -9
  18. package/packages/server/dist/control/launcher.js +50 -11
  19. package/packages/server/dist/control/messaging.js +3 -2
  20. package/packages/server/dist/control/missionQuiesce.js +66 -0
  21. package/packages/server/dist/health/deriveAlerts.js +9 -2
  22. package/packages/server/dist/hooks/hookIngest.js +69 -10
  23. package/packages/server/dist/index.js +44 -4
  24. package/packages/server/dist/loops/loopCommand.js +56 -0
  25. package/packages/server/dist/loops/loopNoop.js +38 -0
  26. package/packages/server/dist/loops/loopScheduler.js +58 -0
  27. package/packages/server/dist/loops/loopStore.js +118 -0
  28. package/packages/server/dist/loops/monitors.js +38 -0
  29. package/packages/server/dist/messages/attachmentStore.js +92 -0
  30. package/packages/server/dist/messages/messagesStore.js +110 -33
  31. package/packages/server/dist/missions/missionsStore.js +9 -0
  32. package/packages/server/dist/missions/reopenOnWork.js +20 -0
  33. package/packages/server/dist/plans/planReconcile.js +114 -0
  34. package/packages/server/dist/plans/plansStore.js +46 -3
  35. package/packages/server/dist/reviews/reviewDiff.js +47 -0
  36. package/packages/server/dist/roster/missionReplay.js +82 -0
  37. package/packages/server/dist/roster/replyAsk.js +62 -0
  38. package/packages/server/dist/roster/rosterBuilder.js +78 -147
  39. package/packages/server/dist/roster/workerIdentity.js +923 -0
  40. package/packages/server/dist/skills/skillDiscovery.js +28 -4
  41. package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
  42. package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
  43. package/packages/server/dist/terminals/providerDetection.js +27 -0
  44. package/packages/server/dist/terminals/terminalCapability.js +45 -0
  45. package/packages/server/dist/terminals/terminalFeatures.js +11 -0
  46. package/packages/server/dist/terminals/terminalObservability.js +21 -0
  47. package/packages/server/dist/terminals/terminalRuntime.js +125 -0
  48. package/packages/server/dist/terminals/terminalStream.js +30 -0
  49. package/packages/server/dist/transcripts/transcriptReader.js +345 -0
  50. package/packages/server/dist/watch/jobsWatcher.js +55 -24
  51. package/packages/web/dist/assets/index-DWjqiitn.js +17 -0
  52. package/packages/web/dist/assets/index-rd4RnLqj.css +2 -0
  53. package/packages/web/dist/index.html +2 -2
  54. package/templates/agents/hive-orchestrator.md +1 -0
  55. package/packages/web/dist/assets/index-Bzle5Xla.css +0 -2
  56. 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
- job.lastText = this.resolveLastText(jobId, job, directory);
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
- /** Cached per job/updatedAt so reloads don't re-read transcripts. */
74
- resolveLastText(jobId, job, directory) {
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.text;
78
- const text = readTranscriptFinalText(job.sessionId)
79
- ?? readLatestTimelineText(path.join(directory, jobId, "timeline.jsonl"));
80
- this.lastTextCache.set(jobId, { updatedAt: job.updatedAt, text });
81
- return text;
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 and returns the
120
- * last assistant text message — the model's actual final reply. */
121
- function readTranscriptFinalText(sessionId) {
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
- return finalAssistantText(file);
151
+ transcriptPaths.set(sessionId, file);
152
+ return file;
137
153
  }
138
154
  return undefined;
139
155
  }
140
- function finalAssistantText(file) {
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 undefined;
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
- if (entry.type !== "assistant" || !Array.isArray(entry.message?.content))
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
- const text = entry.message.content
164
- .filter((item) => item?.type === "text" && typeof item.text === "string")
165
- .map((item) => item.text)
166
- .join("\n")
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
- return text;
198
+ texts.unshift(text);
170
199
  }
171
200
  catch {
172
201
  continue;
173
202
  }
174
203
  }
175
- return undefined;
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);