@shanesaravia/hive 0.2.0 → 0.2.1
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 +14 -0
- package/package.json +1 -1
- package/packages/server/dist/api/rest.js +65 -1
- package/packages/server/dist/missions/missionsStore.js +10 -0
- package/packages/server/dist/roster/rosterBuilder.js +56 -47
- package/packages/server/dist/worktrees/worktreeReclaim.js +156 -0
- package/packages/web/dist/assets/index-Bzle5Xla.css +2 -0
- package/packages/web/dist/assets/index-C6AY0vYC.js +11 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-BrkIk6ny.js +0 -11
- package/packages/web/dist/assets/index-DJFn_ZsI.css +0 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Hive will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.2.1 — 2026-08-25
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Plan tab shows a "Phases" / "Tasks" section header, clarifying that task numbers are global across phases.
|
|
10
|
+
- Pending decisions appear in the top notice slot as a compact strip and slide-over, visible from every tab and both presentations, replacing the Conversation-tab-only placement.
|
|
11
|
+
- Office desks are click targets that behave exactly like their avatar, across manager, worker, and direct seats in both the fleet floor and the mission room.
|
|
12
|
+
- Shared `DetailDrawer` shell so worker inspection and pending decisions match in width, background, header rhythm, and dismissal.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Mission room collapses its goal to one line, compacts summary tiles, drops duplicate side-panel titles, and lets the room and chat grow with the viewport, adding roughly 100px of chat at 900px tall and 320px on tall displays.
|
|
17
|
+
- Widened the mission room viewBox so the whole floor stays inside the canvas, with height capped to avoid empty canvas beneath it.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Reclaim a mission's worktree and branch when it is deleted, archived, or completed, instead of leaving every worktree-isolated mission behind on disk forever. Completion keeps a worktree that holds uncommitted changes, since a follow-up message reopens a completed mission and **Clear completed desks** completes many at once.
|
|
22
|
+
- Resume a reopened mission in its base repository when its worktree has already been reclaimed, rather than resuming into a path that no longer exists.
|
|
23
|
+
- An unanswered decision keeps a mission in `waiting_on_you` instead of decaying to idle or stalled once the session record goes quiet, so office lighting, desks, status pill, and the attention badge stay in agreement.
|
|
24
|
+
- Suppressed the duplicate "Waiting for your reply" banner when a structured decision already covers it.
|
|
25
|
+
|
|
5
26
|
## 0.2.0 — 2026-08-25
|
|
6
27
|
|
|
7
28
|
Adds the visual office, a second presentation of the same fleet state alongside the existing cards.
|
package/README.md
CHANGED
|
@@ -311,6 +311,20 @@ Hive deliberately separates the durable mission lifecycle from current runtime a
|
|
|
311
311
|
|
|
312
312
|
A Claude job ending does **not** automatically mean its mission succeeded. Orchestrators move missions to **Awaiting acceptance**; the user accepts completion or requests changes. Manual lifecycle controls are available in the mission header.
|
|
313
313
|
|
|
314
|
+
### Worktree reclamation
|
|
315
|
+
|
|
316
|
+
Missions launched with worktree isolation get a checked-out worktree under `.claude/worktrees/` and a matching `worktree-` branch. Hive reclaims both when a mission reaches a terminal state, so repositories do not accumulate abandoned checkouts.
|
|
317
|
+
|
|
318
|
+
| Mission reaches | Worktree |
|
|
319
|
+
| --- | --- |
|
|
320
|
+
| Deleted | Removed |
|
|
321
|
+
| Archived | Removed |
|
|
322
|
+
| Completed | Removed when clean; **kept** when it holds uncommitted changes |
|
|
323
|
+
|
|
324
|
+
Completion is treated more cautiously than deletion or archiving because it is reversible — sending a follow-up message to a completed mission reopens it — and because **Clear completed desks** completes many Direct missions at once. Uncommitted work in a worktree therefore survives completion, and a worktree left behind for that reason is recorded in the mission's Activity tab.
|
|
325
|
+
|
|
326
|
+
Reclamation only ever touches worktrees in Hive's managed layout; a repository or a hand-made directory is never removed, whatever a stale job record claims. If a reopened mission's worktree has already been reclaimed, Hive resumes it in the repository the worktree was cut from and records the change.
|
|
327
|
+
|
|
314
328
|
## MCP servers, skills, and project configuration
|
|
315
329
|
|
|
316
330
|
Hive launches the normal local provider CLI rather than a separate container or account.
|
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
2
3
|
import { canClearDirectStudioMission } from "@hive/shared";
|
|
3
4
|
import { CONTEXT_BUDGETS } from "../messages/messagesStore.js";
|
|
4
5
|
import { allPlanTasks } from "../plans/plansStore.js";
|
|
@@ -17,6 +18,7 @@ import { createTemplate, deleteTemplate, discoverTemplates, discoverTemplatesDet
|
|
|
17
18
|
import { publishGitHubReport } from "../reports/githubPublisher.js";
|
|
18
19
|
import { enforceWorkingDirectory, normalize } from "../policies/policiesStore.js";
|
|
19
20
|
import { detectProviderModels } from "../control/providerModels.js";
|
|
21
|
+
import { reclaimEventDetail, reclaimWorktree } from "../worktrees/worktreeReclaim.js";
|
|
20
22
|
export function registerRest(app, deps) {
|
|
21
23
|
const { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex } = deps;
|
|
22
24
|
const missionSendTails = new Map();
|
|
@@ -27,6 +29,47 @@ export function registerRest(app, deps) {
|
|
|
27
29
|
const sessionId = missions.latestSessionFor(missionId) ?? latest?.[1].sessionId ?? latest?.[1].resumeSessionId;
|
|
28
30
|
return latest && sessionId ? { jobId: latest[0], sessionId } : undefined;
|
|
29
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Worktree identity lives on the provider job records, not the durable
|
|
34
|
+
* mission, so it must be read while those jobs are still linked — callers
|
|
35
|
+
* that also delete the mission have to resolve this first.
|
|
36
|
+
*/
|
|
37
|
+
function missionWorktree(missionId) {
|
|
38
|
+
const jobs = [...jobsWatcher.getAll()].filter(([jobId]) => missions.missionFor(jobId) === missionId);
|
|
39
|
+
const carrier = jobs.find(([, job]) => job.worktreePath)?.[1];
|
|
40
|
+
return { path: carrier?.worktreePath, branch: carrier?.worktreeBranch };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The working directory to resume a mission in. Normally the recorded
|
|
44
|
+
* repository; when that was a worktree Hive has since reclaimed, the base
|
|
45
|
+
* repository the worktree was cut from.
|
|
46
|
+
*/
|
|
47
|
+
function resumeRepository(missionId, repository) {
|
|
48
|
+
if (!repository || fs.existsSync(repository))
|
|
49
|
+
return repository;
|
|
50
|
+
const jobs = [...jobsWatcher.getAll()].filter(([jobId]) => missions.missionFor(jobId) === missionId);
|
|
51
|
+
const origin = jobs.map(([, job]) => job.originCwd).find((cwd) => cwd && fs.existsSync(cwd));
|
|
52
|
+
return origin ?? repository;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Reclaims the mission's worktree and records the outcome on the timeline.
|
|
56
|
+
* Never throws: losing a worktree cleanup must not fail the lifecycle
|
|
57
|
+
* change the user actually asked for.
|
|
58
|
+
*/
|
|
59
|
+
function reclaimMissionWorktree(missionId, trigger, target, worktree = missionWorktree(missionId)) {
|
|
60
|
+
if (!worktree.path)
|
|
61
|
+
return;
|
|
62
|
+
let detail;
|
|
63
|
+
try {
|
|
64
|
+
detail = reclaimEventDetail(reclaimWorktree(worktree, trigger), trigger);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
detail = `Worktree reclaim failed: ${error.message}`;
|
|
68
|
+
}
|
|
69
|
+
if (detail && target) {
|
|
70
|
+
events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "lifecycle", detail });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
30
73
|
async function withMissionQueue(missionId, run) {
|
|
31
74
|
const previous = missionSendTails.get(missionId) ?? Promise.resolve();
|
|
32
75
|
const operation = previous.catch(() => undefined).then(run);
|
|
@@ -63,10 +106,19 @@ export function registerRest(app, deps) {
|
|
|
63
106
|
const reopening = ["ready_for_review", "completed", "archived", "failed", "paused"].includes(summary.lifecycleStatus);
|
|
64
107
|
if (reopening)
|
|
65
108
|
missions.setLifecycleStatus(missionId, "active");
|
|
109
|
+
// A follow-up implicitly reopens a closed mission, but reaching a
|
|
110
|
+
// terminal state reclaims its worktree — so the recorded repository can
|
|
111
|
+
// be a directory that no longer exists. Fall back to the repository the
|
|
112
|
+
// worktree was cut from rather than resuming into a dead path.
|
|
113
|
+
const repository = resumeRepository(missionId, summary.repository);
|
|
114
|
+
if (repository && repository !== summary.repository) {
|
|
115
|
+
missions.setRepository(missionId, repository);
|
|
116
|
+
events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Mission worktree was reclaimed; resuming in ${repository}` });
|
|
117
|
+
}
|
|
66
118
|
let result;
|
|
67
119
|
try {
|
|
68
120
|
result = provider === "codex"
|
|
69
|
-
? await codex.start({ task: prompt, cwd:
|
|
121
|
+
? await codex.start({ task: prompt, cwd: repository, model: summary.model, mode: summary.mode, policy, resumeSessionId: target.sessionId })
|
|
70
122
|
: await sendMessage(target.sessionId, prompt, policy, additionalRepos);
|
|
71
123
|
}
|
|
72
124
|
catch (error) {
|
|
@@ -90,6 +142,9 @@ export function registerRest(app, deps) {
|
|
|
90
142
|
for (const node of eligible) {
|
|
91
143
|
missions.setLifecycleStatus(node.missionId, "completed");
|
|
92
144
|
events.add({ ts: Date.now(), sessionId: node.sessionId, jobId: node.jobId, source: "custom", phase: "custom", activityKind: "lifecycle", detail: "Direct mission completed through Clear completed desks" });
|
|
145
|
+
// Bulk completion reclaims on the same clean-only terms as a single
|
|
146
|
+
// completion: uncommitted work in any desk's worktree survives.
|
|
147
|
+
reclaimMissionWorktree(node.missionId, "completed", { jobId: node.jobId, sessionId: node.sessionId });
|
|
93
148
|
}
|
|
94
149
|
return { ok: true, cleared: eligible.map((node) => node.missionId), remaining: snapshot.orchestrators.filter((node) => node.mission.mode === "direct" && node.lifecycleStatus === "active").length - eligible.length };
|
|
95
150
|
});
|
|
@@ -816,6 +871,11 @@ Revise the structured plan to remove that requirement and stop work that was nee
|
|
|
816
871
|
const target = latestMissionTarget(req.params.missionId);
|
|
817
872
|
if (target)
|
|
818
873
|
events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Mission status changed from ${previous} to ${req.body.status}` });
|
|
874
|
+
// Reclaim only on entry into a terminal state, so re-archiving an already
|
|
875
|
+
// archived mission does not re-run git against a path that is long gone.
|
|
876
|
+
if (previous !== req.body.status && (req.body.status === "archived" || req.body.status === "completed")) {
|
|
877
|
+
reclaimMissionWorktree(req.params.missionId, req.body.status, target);
|
|
878
|
+
}
|
|
819
879
|
return { ok: true };
|
|
820
880
|
});
|
|
821
881
|
app.post("/api/mission/:missionId/action", async (req, reply) => {
|
|
@@ -900,6 +960,9 @@ Revise the structured plan to remove that requirement and stop work that was nee
|
|
|
900
960
|
});
|
|
901
961
|
app.delete("/api/mission/:missionId", async (req, reply) => {
|
|
902
962
|
const target = latestMissionTarget(req.params.missionId);
|
|
963
|
+
// Resolved before removal: missions.remove() unlinks the jobs that carry
|
|
964
|
+
// the worktree path, and afterwards it can no longer be found.
|
|
965
|
+
const worktree = missionWorktree(req.params.missionId);
|
|
903
966
|
if (target)
|
|
904
967
|
events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Mission permanently deleted: ${req.params.missionId}` });
|
|
905
968
|
if (!missions.remove(req.params.missionId)) {
|
|
@@ -908,6 +971,7 @@ Revise the structured plan to remove that requirement and stop work that was nee
|
|
|
908
971
|
}
|
|
909
972
|
messages.removeMission(req.params.missionId);
|
|
910
973
|
plans.removeMission(req.params.missionId);
|
|
974
|
+
reclaimMissionWorktree(req.params.missionId, "deleted", target, worktree);
|
|
911
975
|
return { ok: true };
|
|
912
976
|
});
|
|
913
977
|
app.post("/api/session/:pid/stop", async (req, reply) => {
|
|
@@ -190,6 +190,16 @@ export class MissionsStore {
|
|
|
190
190
|
mission.updatedAt = Date.now();
|
|
191
191
|
this.changed();
|
|
192
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Repoints a mission at a different working directory. Used when a mission's
|
|
195
|
+
* worktree has been reclaimed and work must continue in the base repository.
|
|
196
|
+
*/
|
|
197
|
+
setRepository(missionId, repository) {
|
|
198
|
+
const mission = this.ensureLegacyMission(missionId);
|
|
199
|
+
mission.repository = repository;
|
|
200
|
+
mission.updatedAt = Date.now();
|
|
201
|
+
this.changed();
|
|
202
|
+
}
|
|
193
203
|
setLifecycleStatus(missionId, status) {
|
|
194
204
|
const mission = this.ensureLegacyMission(missionId);
|
|
195
205
|
mission.lifecycleStatus = status;
|
|
@@ -52,6 +52,51 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
52
52
|
group.push({ jobId, job });
|
|
53
53
|
groups.set(missionId, group);
|
|
54
54
|
}
|
|
55
|
+
// Per-mission pending decisions. Used both for the global inbox and, inside the
|
|
56
|
+
// mission loop, to keep a mission reading as waiting_on_you for as long as a
|
|
57
|
+
// decision is unanswered — even after the provider session record has gone
|
|
58
|
+
// quiet — so the office lighting, desk, status pill, and "?" badge agree.
|
|
59
|
+
const pendingDecisionsFor = (node) => {
|
|
60
|
+
if (node.lifecycleStatus === "completed" || node.lifecycleStatus === "archived")
|
|
61
|
+
return [];
|
|
62
|
+
const resolved = new Set(node.recentEvents.filter((event) => event.phase === "decision_resolved" && event.decisionId).map((event) => event.decisionId));
|
|
63
|
+
const missionContext = { missionObjective: node.mission.objective || undefined, repository: node.mission.repository };
|
|
64
|
+
// Only the current, mid-turn blocker belongs in the decision inbox. An
|
|
65
|
+
// unresolved event from an older/completed turn is history: the mission
|
|
66
|
+
// may remain open for optional follow-up, but it is not waiting on the
|
|
67
|
+
// user. Requiring the blocker to be the latest observed event also clears
|
|
68
|
+
// it as soon as provider activity resumes.
|
|
69
|
+
const latestEvent = node.recentEvents.at(-1);
|
|
70
|
+
const fromEvents = !node.turnCompleted && latestEvent?.phase === "blocked_on_user"
|
|
71
|
+
&& latestEvent.decisionId && !resolved.has(latestEvent.decisionId)
|
|
72
|
+
&& (!latestEvent.jobId || latestEvent.jobId === node.jobId)
|
|
73
|
+
? [{
|
|
74
|
+
id: latestEvent.decisionId, missionId: node.missionId, missionName: node.name,
|
|
75
|
+
kind: latestEvent.decisionKind ?? "question", question: latestEvent.detail, context: latestEvent.context,
|
|
76
|
+
choices: latestEvent.choices ?? [], recommendation: latestEvent.recommendation, impact: latestEvent.impact, createdAt: latestEvent.ts, ...missionContext,
|
|
77
|
+
}]
|
|
78
|
+
: [];
|
|
79
|
+
// A permission prompt parks the session mid-turn (tempo blocked, the ask
|
|
80
|
+
// in needs) with no TTY to answer it — surface it as an answerable
|
|
81
|
+
// decision. It self-clears once the job is no longer parked.
|
|
82
|
+
const latestJob = jobs.get(node.jobId);
|
|
83
|
+
const parked = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && /^approve\s/i.test(latestJob.needs) && !resolved.has(`perm:${node.jobId}`)
|
|
84
|
+
? [{
|
|
85
|
+
id: `perm:${node.jobId}`, missionId: node.missionId, missionName: node.name,
|
|
86
|
+
kind: "permission", question: latestJob.needs, context: latestJob.detail,
|
|
87
|
+
choices: ["Approve & continue", "Deny"], impact: "The session is parked at this prompt until you answer.", createdAt: node.updatedAt, ...missionContext,
|
|
88
|
+
}]
|
|
89
|
+
: [];
|
|
90
|
+
const hasStructuredPrompt = node.recentEvents.some((event) => event.jobId === node.jobId && event.phase === "blocked_on_user" && event.decisionId?.startsWith("skill-prompt:"));
|
|
91
|
+
const providerPrompt = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && (latestJob.promptChoices?.length ?? 0) > 0 && !/^approve\s/i.test(latestJob.needs) && !/rate limit|spend limit/i.test(latestJob.needs) && !hasStructuredPrompt && !resolved.has(`prompt:${node.jobId}`)
|
|
92
|
+
? [{
|
|
93
|
+
id: `prompt:${node.jobId}`, missionId: node.missionId, missionName: node.name,
|
|
94
|
+
kind: "question", question: latestJob.needs, context: latestJob.detail,
|
|
95
|
+
choices: latestJob.promptChoices ?? [], impact: "The provider is waiting for this answer before the skill can continue.", createdAt: node.updatedAt, ...missionContext,
|
|
96
|
+
}]
|
|
97
|
+
: [];
|
|
98
|
+
return [...fromEvents, ...parked, ...providerPrompt];
|
|
99
|
+
};
|
|
55
100
|
for (const [missionId, group] of groups) {
|
|
56
101
|
group.sort((a, b) => dateMs(a.job.createdAt) - dateMs(b.job.createdAt));
|
|
57
102
|
const latest = group[group.length - 1];
|
|
@@ -298,8 +343,15 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
298
343
|
activityStatus = "stalled";
|
|
299
344
|
}
|
|
300
345
|
}
|
|
346
|
+
const name = missions?.nameFor(missionId) ?? oldest.job.name ?? latest.job.name ?? missionId;
|
|
347
|
+
const turnCompleted = latest.job.state === "done";
|
|
348
|
+
const pendingDecisions = pendingDecisionsFor({ missionId, name, jobId: latest.jobId, mission, lifecycleStatus: mission.lifecycleStatus, turnCompleted, recentEvents, updatedAt });
|
|
349
|
+
// An unanswered decision is, by definition, waiting on the user. Do not let the
|
|
350
|
+
// runtime's quiet session decay it to idle/stalled while the question stands.
|
|
351
|
+
if (mission.lifecycleStatus === "active" && pendingDecisions.length && (activityStatus === "idle" || activityStatus === "stalled"))
|
|
352
|
+
activityStatus = "waiting_on_you";
|
|
301
353
|
const runStartedAt = dateMs(latest.job.createdAt) || createdAt;
|
|
302
|
-
const alerts = deriveAlerts({ lifecycle: mission.lifecycleStatus, activity: activityStatus, stale: derived.stale, turnCompleted
|
|
354
|
+
const alerts = deriveAlerts({ lifecycle: mission.lifecycleStatus, activity: activityStatus, stale: derived.stale, turnCompleted, workers, events: recentEvents, now, turnTokens: latest.job.tokens ?? 0, runStartedAt });
|
|
303
355
|
orchestrators.push({
|
|
304
356
|
missionId,
|
|
305
357
|
threadId: missionId,
|
|
@@ -312,14 +364,11 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
312
364
|
latest.job.resumeSessionId ??
|
|
313
365
|
latest.jobId,
|
|
314
366
|
pid: session?.pid ?? -1,
|
|
315
|
-
name
|
|
316
|
-
oldest.job.name ??
|
|
317
|
-
latest.job.name ??
|
|
318
|
-
missionId,
|
|
367
|
+
name,
|
|
319
368
|
status: activityStatus,
|
|
320
369
|
lifecycleStatus: mission.lifecycleStatus,
|
|
321
370
|
activityStatus,
|
|
322
|
-
turnCompleted
|
|
371
|
+
turnCompleted,
|
|
323
372
|
inactiveForMs,
|
|
324
373
|
waitingFor: mission.lifecycleStatus === "completed" || mission.lifecycleStatus === "archived" ? undefined : session?.waitingFor ?? latest.job.needs,
|
|
325
374
|
worktreePath: latest.job.worktreePath ?? oldest.job.worktreePath,
|
|
@@ -362,46 +411,6 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
362
411
|
});
|
|
363
412
|
}
|
|
364
413
|
orchestrators.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
365
|
-
const decisions = orchestrators.flatMap((node) =>
|
|
366
|
-
if (node.lifecycleStatus === "completed" || node.lifecycleStatus === "archived")
|
|
367
|
-
return [];
|
|
368
|
-
const resolved = new Set(node.recentEvents.filter((event) => event.phase === "decision_resolved" && event.decisionId).map((event) => event.decisionId));
|
|
369
|
-
const missionContext = { missionObjective: node.mission.objective || undefined, repository: node.mission.repository };
|
|
370
|
-
// Only the current, mid-turn blocker belongs in the decision inbox. An
|
|
371
|
-
// unresolved event from an older/completed turn is history: the mission
|
|
372
|
-
// may remain open for optional follow-up, but it is not waiting on the
|
|
373
|
-
// user. Requiring the blocker to be the latest observed event also clears
|
|
374
|
-
// it as soon as provider activity resumes.
|
|
375
|
-
const latestEvent = node.recentEvents.at(-1);
|
|
376
|
-
const fromEvents = !node.turnCompleted && latestEvent?.phase === "blocked_on_user"
|
|
377
|
-
&& latestEvent.decisionId && !resolved.has(latestEvent.decisionId)
|
|
378
|
-
&& (!latestEvent.jobId || latestEvent.jobId === node.jobId)
|
|
379
|
-
? [{
|
|
380
|
-
id: latestEvent.decisionId, missionId: node.missionId, missionName: node.name,
|
|
381
|
-
kind: latestEvent.decisionKind ?? "question", question: latestEvent.detail, context: latestEvent.context,
|
|
382
|
-
choices: latestEvent.choices ?? [], recommendation: latestEvent.recommendation, impact: latestEvent.impact, createdAt: latestEvent.ts, ...missionContext,
|
|
383
|
-
}]
|
|
384
|
-
: [];
|
|
385
|
-
// A permission prompt parks the session mid-turn (tempo blocked, the ask
|
|
386
|
-
// in needs) with no TTY to answer it — surface it as an answerable
|
|
387
|
-
// decision. It self-clears once the job is no longer parked.
|
|
388
|
-
const latestJob = jobs.get(node.jobId);
|
|
389
|
-
const parked = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && /^approve\s/i.test(latestJob.needs) && !resolved.has(`perm:${node.jobId}`)
|
|
390
|
-
? [{
|
|
391
|
-
id: `perm:${node.jobId}`, missionId: node.missionId, missionName: node.name,
|
|
392
|
-
kind: "permission", question: latestJob.needs, context: latestJob.detail,
|
|
393
|
-
choices: ["Approve & continue", "Deny"], impact: "The session is parked at this prompt until you answer.", createdAt: node.updatedAt, ...missionContext,
|
|
394
|
-
}]
|
|
395
|
-
: [];
|
|
396
|
-
const hasStructuredPrompt = node.recentEvents.some((event) => event.jobId === node.jobId && event.phase === "blocked_on_user" && event.decisionId?.startsWith("skill-prompt:"));
|
|
397
|
-
const providerPrompt = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && (latestJob.promptChoices?.length ?? 0) > 0 && !/^approve\s/i.test(latestJob.needs) && !/rate limit|spend limit/i.test(latestJob.needs) && !hasStructuredPrompt && !resolved.has(`prompt:${node.jobId}`)
|
|
398
|
-
? [{
|
|
399
|
-
id: `prompt:${node.jobId}`, missionId: node.missionId, missionName: node.name,
|
|
400
|
-
kind: "question", question: latestJob.needs, context: latestJob.detail,
|
|
401
|
-
choices: latestJob.promptChoices ?? [], impact: "The provider is waiting for this answer before the skill can continue.", createdAt: node.updatedAt, ...missionContext,
|
|
402
|
-
}]
|
|
403
|
-
: [];
|
|
404
|
-
return [...fromEvents, ...parked, ...providerPrompt];
|
|
405
|
-
}).sort((a, b) => b.createdAt - a.createdAt);
|
|
414
|
+
const decisions = orchestrators.flatMap((node) => pendingDecisionsFor(node)).sort((a, b) => b.createdAt - a.createdAt);
|
|
406
415
|
return { orchestrators, decisions, other, generatedAt: Date.now() };
|
|
407
416
|
}
|
|
@@ -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
|
+
}
|