@shanesaravia/hive 0.2.0 → 0.3.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 +16 -0
- 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/types.d.ts +29 -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/api/rest.js +130 -11
- package/packages/server/dist/api/ws.js +25 -8
- package/packages/server/dist/control/launcher.js +50 -11
- package/packages/server/dist/control/messaging.js +3 -2
- package/packages/server/dist/health/deriveAlerts.js +1 -2
- package/packages/server/dist/hooks/hookIngest.js +69 -10
- package/packages/server/dist/index.js +20 -4
- package/packages/server/dist/messages/messagesStore.js +25 -12
- package/packages/server/dist/missions/missionsStore.js +19 -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/roster/missionReplay.js +82 -0
- package/packages/server/dist/roster/rosterBuilder.js +93 -189
- package/packages/server/dist/roster/workerIdentity.js +886 -0
- package/packages/server/dist/watch/jobsWatcher.js +55 -24
- package/packages/server/dist/worktrees/worktreeReclaim.js +156 -0
- package/packages/web/dist/assets/index-BpEYVjCF.css +2 -0
- package/packages/web/dist/assets/index-rIAIJyuF.js +12 -0
- package/packages/web/dist/index.html +2 -2
- package/templates/agents/hive-orchestrator.md +1 -0
- package/packages/web/dist/assets/index-BrkIk6ny.js +0 -11
- package/packages/web/dist/assets/index-DJFn_ZsI.css +0 -2
|
@@ -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);
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Hive launches missions through the provider CLI's native `--worktree`
|
|
6
|
+
* primitive (see control/launcher.ts) but nothing ever reclaimed the result,
|
|
7
|
+
* so every templated mission leaked a checked-out worktree and a branch.
|
|
8
|
+
*
|
|
9
|
+
* Reclamation is deliberately scoped to worktrees Hive itself caused to
|
|
10
|
+
* exist: the CLI creates them under `<repo>/.claude/worktrees/<name>` on a
|
|
11
|
+
* `worktree-<name>` branch. Anything outside that layout is left alone, so a
|
|
12
|
+
* stale or wrong `worktreePath` on a job record can never delete a real
|
|
13
|
+
* checkout.
|
|
14
|
+
*/
|
|
15
|
+
/** Path segment the provider CLI uses for its managed worktrees. */
|
|
16
|
+
const MANAGED_SEGMENT = path.join(".claude", "worktrees");
|
|
17
|
+
function git(cwd, args) {
|
|
18
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* git may answer --git-common-dir with either an absolute or a
|
|
22
|
+
* repository-relative path depending on where it runs, and resolving a
|
|
23
|
+
* relative answer against the wrong base silently yields a bogus path.
|
|
24
|
+
* --path-format is authoritative where available (git 2.31+).
|
|
25
|
+
*/
|
|
26
|
+
function commonGitDir(cwd) {
|
|
27
|
+
try {
|
|
28
|
+
return git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return path.resolve(cwd, git(cwd, ["rev-parse", "--git-common-dir"]));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A managed worktree lives under `.claude/worktrees/` AND reports a git common
|
|
36
|
+
* directory different from its own git dir. Both checks matter: the path shape
|
|
37
|
+
* alone would trust unverified job metadata, and the git check alone would
|
|
38
|
+
* happily remove a worktree the user created by hand somewhere else.
|
|
39
|
+
*/
|
|
40
|
+
export function isManagedWorktree(worktreePath) {
|
|
41
|
+
if (!worktreePath.includes(MANAGED_SEGMENT))
|
|
42
|
+
return false;
|
|
43
|
+
let gitEntry;
|
|
44
|
+
try {
|
|
45
|
+
gitEntry = fs.statSync(path.join(worktreePath, ".git"));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
// A linked worktree records its git directory in a `.git` FILE; a main
|
|
51
|
+
// checkout has a `.git` DIRECTORY. That distinction is what stops a real
|
|
52
|
+
// repository from ever being reclaimed, and unlike comparing
|
|
53
|
+
// --git-dir against --git-common-dir it cannot be fooled by git mixing
|
|
54
|
+
// absolute and relative path output for a nested directory.
|
|
55
|
+
if (!gitEntry.isFile())
|
|
56
|
+
return false;
|
|
57
|
+
try {
|
|
58
|
+
return git(worktreePath, ["rev-parse", "--is-inside-work-tree"]) === "true";
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Uncommitted tracked or untracked changes in the worktree. */
|
|
65
|
+
export function isWorktreeDirty(worktreePath) {
|
|
66
|
+
try {
|
|
67
|
+
return git(worktreePath, ["status", "--porcelain"]).length > 0;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Unreadable means unverifiable, and unverifiable must not be discarded.
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* `completed` is reversible — a follow-up message implicitly reopens a
|
|
76
|
+
* completed mission (see rest.ts), and "Clear completed desks" completes many
|
|
77
|
+
* missions at once — so uncommitted work survives it. `deleted` and `archived`
|
|
78
|
+
* are explicit, per-mission, terminal gestures and reclaim unconditionally.
|
|
79
|
+
*/
|
|
80
|
+
function discardsUncommittedWork(trigger) {
|
|
81
|
+
return trigger !== "completed";
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Removes a mission's worktree and its branch. Safe to call for any mission:
|
|
85
|
+
* missions without a worktree, and worktrees already gone, report "absent".
|
|
86
|
+
*/
|
|
87
|
+
export function reclaimWorktree(target, trigger) {
|
|
88
|
+
const worktreePath = target.path;
|
|
89
|
+
if (!worktreePath)
|
|
90
|
+
return { status: "absent" };
|
|
91
|
+
if (!fs.existsSync(worktreePath))
|
|
92
|
+
return { status: "absent", path: worktreePath, branch: target.branch };
|
|
93
|
+
if (!isManagedWorktree(worktreePath)) {
|
|
94
|
+
return { status: "unmanaged", path: worktreePath, branch: target.branch, detail: "not a Hive-managed worktree" };
|
|
95
|
+
}
|
|
96
|
+
if (!discardsUncommittedWork(trigger) && isWorktreeDirty(worktreePath)) {
|
|
97
|
+
return { status: "kept_dirty", path: worktreePath, branch: target.branch, detail: "uncommitted changes preserved" };
|
|
98
|
+
}
|
|
99
|
+
// The main checkout owns the worktree administrative data, so removal and
|
|
100
|
+
// branch deletion must run from the common repository, not from inside the
|
|
101
|
+
// directory being deleted.
|
|
102
|
+
let repositoryRoot;
|
|
103
|
+
try {
|
|
104
|
+
repositoryRoot = path.dirname(commonGitDir(worktreePath));
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return { status: "failed", path: worktreePath, branch: target.branch, detail: error.message };
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
// Locked worktrees are Hive's own (the CLI locks some during launch);
|
|
111
|
+
// unlock is a no-op when the worktree was never locked.
|
|
112
|
+
try {
|
|
113
|
+
git(repositoryRoot, ["worktree", "unlock", worktreePath]);
|
|
114
|
+
}
|
|
115
|
+
catch { /* not locked */ }
|
|
116
|
+
git(repositoryRoot, ["worktree", "remove", "--force", worktreePath]);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
return { status: "failed", path: worktreePath, branch: target.branch, detail: error.message };
|
|
120
|
+
}
|
|
121
|
+
let detail;
|
|
122
|
+
if (target.branch) {
|
|
123
|
+
const branch = target.branch.replace(/^refs\/heads\//, "");
|
|
124
|
+
try {
|
|
125
|
+
// -D rather than -d: the branch was just verified to hold no
|
|
126
|
+
// uncommitted work, and an unmerged experiment branch is exactly what
|
|
127
|
+
// this reclaim is meant to collect. The commits stay in the reflog.
|
|
128
|
+
git(repositoryRoot, ["branch", "-D", branch]);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
detail = `worktree removed; branch ${branch} retained`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
git(repositoryRoot, ["worktree", "prune"]);
|
|
136
|
+
}
|
|
137
|
+
catch { /* best effort */ }
|
|
138
|
+
return { status: "removed", path: worktreePath, branch: target.branch, detail };
|
|
139
|
+
}
|
|
140
|
+
/** Human-readable line for the mission activity timeline. */
|
|
141
|
+
export function reclaimEventDetail(result, trigger) {
|
|
142
|
+
const name = result.path ? path.basename(result.path) : "worktree";
|
|
143
|
+
if (result.status === "removed") {
|
|
144
|
+
return `Worktree ${name} reclaimed after mission ${trigger}${result.detail ? ` · ${result.detail}` : ""}`;
|
|
145
|
+
}
|
|
146
|
+
if (result.status === "kept_dirty") {
|
|
147
|
+
return `Worktree ${name} kept: uncommitted changes remain after mission ${trigger}`;
|
|
148
|
+
}
|
|
149
|
+
if (result.status === "failed") {
|
|
150
|
+
return `Worktree ${name} could not be reclaimed: ${result.detail ?? "git error"}`;
|
|
151
|
+
}
|
|
152
|
+
if (result.status === "unmanaged") {
|
|
153
|
+
return `Worktree ${name} left in place: not a Hive-managed worktree`;
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|