@shanesaravia/hive 0.3.0 → 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 +21 -0
- package/README.md +16 -1
- package/node_modules/@hive/shared/dist/status.js +9 -0
- package/node_modules/@hive/shared/dist/types.d.ts +162 -1
- package/node_modules/@hive/shared/dist/types.js +27 -0
- package/package.json +1 -1
- package/packages/server/dist/agents/agentDiscovery.js +64 -0
- package/packages/server/dist/api/rest.js +474 -14
- package/packages/server/dist/api/ws.js +66 -2
- package/packages/server/dist/control/missionQuiesce.js +66 -0
- package/packages/server/dist/health/deriveAlerts.js +8 -0
- package/packages/server/dist/index.js +26 -2
- 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 +97 -33
- package/packages/server/dist/reviews/reviewDiff.js +47 -0
- package/packages/server/dist/roster/replyAsk.js +62 -0
- package/packages/server/dist/roster/rosterBuilder.js +41 -5
- package/packages/server/dist/roster/workerIdentity.js +46 -9
- 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/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/packages/web/dist/assets/index-BpEYVjCF.css +0 -2
- package/packages/web/dist/assets/index-rIAIJyuF.js +0 -12
|
@@ -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
|
+
}
|