@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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,59 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Hive will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.3.0 — 2026-08-29
|
|
6
|
+
|
|
7
|
+
Turns a mission's full page into a dashboard, carries the keyboard into it, and settles a run of places where two parts of the board disagreed about one fact.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- A mission's full page is a dashboard rather than five tabs: the objective and its acceptance criteria, the conversation, and what is happening now sit in three columns that cannot hide each other, with the plan full width beneath them and diagnostics folded away at the foot.
|
|
12
|
+
- Progress measures that say how far along a mission is — phase and gate progress and the files that actually changed — replacing elapsed time, a worker count and the same worker count again.
|
|
13
|
+
- Keyboard control of a mission's own page: `a` and `d` do what the mission is asking, `r` reaches the composer, `p` pauses, `g c` / `g w` / `g p` / `g d` / `g g` jump around the page, `h` and `l` walk the missions still yours to do, and `⇧H` / `⇧L` walk only the ones asking for you.
|
|
14
|
+
- Bulk **Complete** and **Delete**, with `⇧C`, `⇧P`, `⇧A` and `⇧⌫` on the selection bar and `⏎` to commit a confirmation. Archive and Delete arm a confirmation rather than firing, so nothing irreversible happens on a keystroke.
|
|
15
|
+
- Click-to-copy on a mission's repository path, branch and id, and a note on returning to a mission of what arrived while you were away.
|
|
16
|
+
- A **Settings** menu for sound, notifications, workspace policy and templates.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- One page width for the board, the office and both mission presentations, which had grown three between them.
|
|
21
|
+
- The Needs you rail leads the board and reads as the most important thing on it; running work now sits above work awaiting acceptance, which the rail directly above it already lists.
|
|
22
|
+
- Bulk Accept and Complete became one **Complete**, over running work and work awaiting acceptance alike, and every bulk control appears only when it applies to the selection.
|
|
23
|
+
- Filters survive a reload, as the sort beside them already did, and the board's shortcuts keep working in the office.
|
|
24
|
+
- **Request changes** asks what should change instead of quietly reopening the mission, and a mission awaiting acceptance reopens when it starts working again rather than because you spoke to it.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Background sessions launch in `auto`. `bypassPermissions` now requires a one-time interactive disclaimer that a server cannot give on the user's behalf, so every mission launch was failing; a launch that is refused now reports the CLI's own words in about a second instead of a thirty-second timeout.
|
|
29
|
+
- One owner for Escape. Ten independent listeners each guessed whether the key was theirs, so two open slide-overs closed together on one press.
|
|
30
|
+
- Clicking a worker opens that worker beside the board, instead of promoting the whole board to a full page and dropping the worker on the way.
|
|
31
|
+
- The office counts the missions the board says need you. Its floor badges and its avatars each used a different rule, and neither counted a pending decision or an unapproved plan.
|
|
32
|
+
- A card no longer asks you to approve a plan whose work has already run, and a reopened mission stops reporting that it awaits an acceptance.
|
|
33
|
+
- The activity line's tense follows the evidence — `Ran` for a finished command, `Running` only while one is open — and it names a worker's own events rather than labelling them "Worker activity".
|
|
34
|
+
- A stale mission is called stale in the rail, as it already was in its pill and in the filter.
|
|
35
|
+
- Cards keep one shape whatever state they are in, rows end level, and a card's standing summary clamps to the two lines it was always meant to.
|
|
36
|
+
|
|
37
|
+
## 0.2.1 — 2026-08-25
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- Plan tab shows a "Phases" / "Tasks" section header, clarifying that task numbers are global across phases.
|
|
42
|
+
- 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.
|
|
43
|
+
- 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.
|
|
44
|
+
- Shared `DetailDrawer` shell so worker inspection and pending decisions match in width, background, header rhythm, and dismissal.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- 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.
|
|
49
|
+
- Widened the mission room viewBox so the whole floor stays inside the canvas, with height capped to avoid empty canvas beneath it.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- 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.
|
|
54
|
+
- 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.
|
|
55
|
+
- 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.
|
|
56
|
+
- Suppressed the duplicate "Waiting for your reply" banner when a structured decision already covers it.
|
|
57
|
+
|
|
5
58
|
## 0.2.0 — 2026-08-25
|
|
6
59
|
|
|
7
60
|
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.
|
|
@@ -414,6 +428,8 @@ Project planning is split by scope:
|
|
|
414
428
|
- [`docs/IMPROVEMENTS.md`](docs/IMPROVEMENTS.md) — core product roadmap and completion checklist
|
|
415
429
|
- [`docs/VISUAL_OFFICE_PLAN.md`](docs/VISUAL_OFFICE_PLAN.md) — completed visual-office foundation
|
|
416
430
|
- [`docs/VISUAL_OFFICE_FEEL_ALIVE_PLAN.md`](docs/VISUAL_OFFICE_FEEL_ALIVE_PLAN.md) — current and upcoming atmosphere, interaction, and polish phases
|
|
431
|
+
- [`docs/AGENT_SYNC_AND_OFFICE_STATE_PLAN.md`](docs/AGENT_SYNC_AND_OFFICE_STATE_PLAN.md) — audit and phased plan for 1:1 terminal-agent sync, canonical worker identity, and checkpointed office animation state
|
|
432
|
+
- [`docs/OFFICE_STABILITY_AUDIT.md`](docs/OFFICE_STABILITY_AUDIT.md) — replay-driven audit of the backend → office pipeline, with the tiered plan for a dependable roster and animation scene
|
|
417
433
|
|
|
418
434
|
## Troubleshooting
|
|
419
435
|
|
|
@@ -4,4 +4,10 @@ import type { OrchestratorNode } from "./types.js";
|
|
|
4
4
|
* Bulk cleanup must never accept gates or dismiss a mission that still needs
|
|
5
5
|
* work or user attention.
|
|
6
6
|
*/
|
|
7
|
+
/**
|
|
8
|
+
* A direct agent has finished when its turn is over and nothing is pending:
|
|
9
|
+
* its desk goes green with the check, and it stays seated until the user
|
|
10
|
+
* marks it complete — leaving at once would take its reply with it.
|
|
11
|
+
*/
|
|
12
|
+
export declare function isDirectMissionFinished(node: OrchestratorNode): boolean;
|
|
7
13
|
export declare function canClearDirectStudioMission(node: OrchestratorNode): boolean;
|
|
@@ -4,6 +4,18 @@ const CLEARABLE_ACTIVITY = new Set(["idle", "done", "offline"]);
|
|
|
4
4
|
* Bulk cleanup must never accept gates or dismiss a mission that still needs
|
|
5
5
|
* work or user attention.
|
|
6
6
|
*/
|
|
7
|
+
/**
|
|
8
|
+
* A direct agent has finished when its turn is over and nothing is pending:
|
|
9
|
+
* its desk goes green with the check, and it stays seated until the user
|
|
10
|
+
* marks it complete — leaving at once would take its reply with it.
|
|
11
|
+
*/
|
|
12
|
+
export function isDirectMissionFinished(node) {
|
|
13
|
+
if (node.mission.mode !== "direct" || node.lifecycleStatus !== "active" || !node.turnCompleted)
|
|
14
|
+
return false;
|
|
15
|
+
if (!["idle", "done"].includes(node.activityStatus) || node.inFlight.tasks > 0 || node.inFlight.queued > 0)
|
|
16
|
+
return false;
|
|
17
|
+
return node.alerts.every((alert) => alert.severity !== "critical");
|
|
18
|
+
}
|
|
7
19
|
export function canClearDirectStudioMission(node) {
|
|
8
20
|
if (node.mission.mode !== "direct" || node.lifecycleStatus !== "active")
|
|
9
21
|
return false;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { OrchestratorNode } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Whether a mission awaiting acceptance can be accepted without a human
|
|
4
|
+
* reading it first.
|
|
5
|
+
*
|
|
6
|
+
* Acceptance is the human sign-off that `ready_for_review` exists to require,
|
|
7
|
+
* so a bulk action must only ever cover missions where there is demonstrably
|
|
8
|
+
* nothing to judge: every required gate is satisfied or explicitly waived,
|
|
9
|
+
* nothing is asking a question, no alert is open, and no work is still moving.
|
|
10
|
+
* Anything else keeps its seat and has to be accepted individually — a bulk
|
|
11
|
+
* button must never turn the review step into a rubber stamp.
|
|
12
|
+
*/
|
|
13
|
+
export declare function canAcceptReviewMission(node: OrchestratorNode, hasPendingDecision?: boolean): boolean;
|
|
14
|
+
/** Why a seated mission was left for the user to accept themselves. */
|
|
15
|
+
export declare function reviewAcceptanceBlocker(node: OrchestratorNode, hasPendingDecision?: boolean): string | undefined;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a mission awaiting acceptance can be accepted without a human
|
|
3
|
+
* reading it first.
|
|
4
|
+
*
|
|
5
|
+
* Acceptance is the human sign-off that `ready_for_review` exists to require,
|
|
6
|
+
* so a bulk action must only ever cover missions where there is demonstrably
|
|
7
|
+
* nothing to judge: every required gate is satisfied or explicitly waived,
|
|
8
|
+
* nothing is asking a question, no alert is open, and no work is still moving.
|
|
9
|
+
* Anything else keeps its seat and has to be accepted individually — a bulk
|
|
10
|
+
* button must never turn the review step into a rubber stamp.
|
|
11
|
+
*/
|
|
12
|
+
export function canAcceptReviewMission(node, hasPendingDecision = false) {
|
|
13
|
+
if (node.lifecycleStatus !== "ready_for_review")
|
|
14
|
+
return false;
|
|
15
|
+
if (hasPendingDecision)
|
|
16
|
+
return false;
|
|
17
|
+
if (node.activityStatus === "waiting_on_you" || node.activityStatus === "error" || node.activityStatus === "stalled")
|
|
18
|
+
return false;
|
|
19
|
+
if (node.inFlight.tasks > 0 || node.inFlight.queued > 0)
|
|
20
|
+
return false;
|
|
21
|
+
if (node.workers.some((worker) => !worker.doneAt && (worker.jobState === "working" || worker.jobState === "busy")))
|
|
22
|
+
return false;
|
|
23
|
+
if (node.alerts.length > 0)
|
|
24
|
+
return false;
|
|
25
|
+
return !(node.plan?.gates ?? []).some((gate) => gate.required && gate.status !== "satisfied" && gate.status !== "waived");
|
|
26
|
+
}
|
|
27
|
+
/** Why a seated mission was left for the user to accept themselves. */
|
|
28
|
+
export function reviewAcceptanceBlocker(node, hasPendingDecision = false) {
|
|
29
|
+
if (node.lifecycleStatus !== "ready_for_review")
|
|
30
|
+
return "not awaiting acceptance";
|
|
31
|
+
if (hasPendingDecision)
|
|
32
|
+
return "waiting on your answer";
|
|
33
|
+
if (node.activityStatus === "waiting_on_you")
|
|
34
|
+
return "waiting on you";
|
|
35
|
+
if (node.activityStatus === "error")
|
|
36
|
+
return "runtime error";
|
|
37
|
+
if (node.activityStatus === "stalled")
|
|
38
|
+
return "work stalled";
|
|
39
|
+
if (node.inFlight.tasks > 0 || node.inFlight.queued > 0)
|
|
40
|
+
return "work still in flight";
|
|
41
|
+
if (node.workers.some((worker) => !worker.doneAt && (worker.jobState === "working" || worker.jobState === "busy")))
|
|
42
|
+
return "a worker is still running";
|
|
43
|
+
if (node.alerts.length > 0)
|
|
44
|
+
return node.alerts.length === 1 ? "1 open alert" : `${node.alerts.length} open alerts`;
|
|
45
|
+
const unmet = (node.plan?.gates ?? []).filter((gate) => gate.required && gate.status !== "satisfied" && gate.status !== "waived");
|
|
46
|
+
if (unmet.length)
|
|
47
|
+
return unmet.length === 1 ? `unmet gate: ${unmet[0].label}` : `${unmet.length} unmet gates`;
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
@@ -57,6 +57,8 @@ export interface ClaudeJob {
|
|
|
57
57
|
* needs/detail — the full conversational reply lives in the timeline.
|
|
58
58
|
*/
|
|
59
59
|
lastText?: string;
|
|
60
|
+
/** Hive-augmented: what the model said between tool calls this turn, oldest first. */
|
|
61
|
+
progressTexts?: string[];
|
|
60
62
|
children: JobChild[];
|
|
61
63
|
intent?: string;
|
|
62
64
|
name?: string;
|
|
@@ -80,6 +82,8 @@ export interface MissionMessage {
|
|
|
80
82
|
text: string;
|
|
81
83
|
createdAt: number;
|
|
82
84
|
jobId: string;
|
|
85
|
+
/** `update`: something the agent said on the way to its reply, shown compactly. */
|
|
86
|
+
kind?: "reply" | "update";
|
|
83
87
|
}
|
|
84
88
|
/** @deprecated Use MissionMessage. */
|
|
85
89
|
export type ThreadMessage = MissionMessage;
|
|
@@ -87,6 +91,22 @@ export interface WorkerActivity extends JobFanEntry {
|
|
|
87
91
|
jobId: string;
|
|
88
92
|
jobState: string;
|
|
89
93
|
jobUpdatedAt?: number;
|
|
94
|
+
/** Other provider ids and delegation labels that refer to this same worker. */
|
|
95
|
+
aliases?: string[];
|
|
96
|
+
/** Plan task ids delegated to this worker. */
|
|
97
|
+
taskIds?: string[];
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Structured worker attribution for a hook firing. Replaces overloading one
|
|
101
|
+
* string with an agent id, an agent *type*, and a human label: only `agentId`
|
|
102
|
+
* may create a worker identity, and `parentAgentId` marks a nested agent whose
|
|
103
|
+
* activity belongs to its parent.
|
|
104
|
+
*/
|
|
105
|
+
export interface WorkerRef {
|
|
106
|
+
agentId?: string;
|
|
107
|
+
agentType?: string;
|
|
108
|
+
label?: string;
|
|
109
|
+
parentAgentId?: string;
|
|
90
110
|
}
|
|
91
111
|
export interface MissionSummary {
|
|
92
112
|
id: string;
|
|
@@ -295,10 +315,19 @@ export interface HiveEvent {
|
|
|
295
315
|
command?: string;
|
|
296
316
|
artifactPath?: string;
|
|
297
317
|
outcome?: "success" | "failure";
|
|
318
|
+
/**
|
|
319
|
+
* The tool call left work running in the background. The provider ends the
|
|
320
|
+
* agent's turn and wakes it when that work finishes, so a worker with an
|
|
321
|
+
* outstanding backgrounded command has not finished — it is waiting.
|
|
322
|
+
*/
|
|
323
|
+
backgrounded?: boolean;
|
|
298
324
|
/** For source: 'custom' — one of the orchestrator lifecycle phases. */
|
|
299
325
|
phase?: "delegating" | "worker_started" | "worker_reported" | "reviewing" | "blocked_on_user" | "ready_for_review" | "plan_updated" | "decision_resolved" | "resuming" | "custom";
|
|
300
326
|
detail: string;
|
|
327
|
+
/** Best-effort display name for the attributed worker. */
|
|
301
328
|
targetWorker?: string;
|
|
329
|
+
/** Structured attribution; `workerRef.agentId` is the only identity source. */
|
|
330
|
+
workerRef?: WorkerRef;
|
|
302
331
|
targetTask?: string;
|
|
303
332
|
decisionId?: string;
|
|
304
333
|
decisionKind?: DecisionKind;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { WorkerActivity } from "./types.js";
|
|
2
|
+
/** The manager's own agent name. Never a worker on the floor. */
|
|
3
|
+
export declare const ORCHESTRATOR_AGENT_NAME = "hive-orchestrator";
|
|
4
|
+
/** Every string that refers to one canonical worker. */
|
|
5
|
+
export declare function workerRefs(worker: Pick<WorkerActivity, "id" | "label" | "aliases" | "taskIds">): string[];
|
|
6
|
+
/**
|
|
7
|
+
* The single worker-identity comparison for the whole app.
|
|
8
|
+
*
|
|
9
|
+
* The server resolves every provider name, delegation label, and plan task id
|
|
10
|
+
* for a worker into one canonical identity and publishes the rest as aliases,
|
|
11
|
+
* so consumers can compare exactly. Prefix and substring matching used to
|
|
12
|
+
* stand in for this and could silently merge two workers or split one.
|
|
13
|
+
*/
|
|
14
|
+
export declare function matchesWorker(target: string | undefined, worker: Pick<WorkerActivity, "id" | "label" | "aliases" | "taskIds">): boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** The manager's own agent name. Never a worker on the floor. */
|
|
2
|
+
export const ORCHESTRATOR_AGENT_NAME = "hive-orchestrator";
|
|
3
|
+
/** Every string that refers to one canonical worker. */
|
|
4
|
+
export function workerRefs(worker) {
|
|
5
|
+
return [worker.id, worker.label, ...(worker.aliases ?? []), ...(worker.taskIds ?? [])]
|
|
6
|
+
.map((value) => value?.trim())
|
|
7
|
+
.filter((value) => Boolean(value));
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The single worker-identity comparison for the whole app.
|
|
11
|
+
*
|
|
12
|
+
* The server resolves every provider name, delegation label, and plan task id
|
|
13
|
+
* for a worker into one canonical identity and publishes the rest as aliases,
|
|
14
|
+
* so consumers can compare exactly. Prefix and substring matching used to
|
|
15
|
+
* stand in for this and could silently merge two workers or split one.
|
|
16
|
+
*/
|
|
17
|
+
export function matchesWorker(target, worker) {
|
|
18
|
+
const wanted = target?.trim().toLowerCase();
|
|
19
|
+
if (!wanted)
|
|
20
|
+
return false;
|
|
21
|
+
return workerRefs(worker).some((ref) => ref.toLowerCase() === wanted);
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { canAcceptReviewMission, canClearDirectStudioMission, reviewAcceptanceBlocker } from "@hive/shared";
|
|
3
4
|
import { CONTEXT_BUDGETS } from "../messages/messagesStore.js";
|
|
4
5
|
import { allPlanTasks } from "../plans/plansStore.js";
|
|
5
6
|
import { buildFleetSnapshot } from "../roster/rosterBuilder.js";
|
|
6
7
|
import { skillPromptEvents, toHiveEvent } from "../hooks/hookIngest.js";
|
|
8
|
+
import { isWorkEvent, reopensOnWork } from "../missions/reopenOnWork.js";
|
|
7
9
|
import { startOrchestrator } from "../control/launcher.js";
|
|
8
10
|
import { matchNativeCommand, runNativeCommand } from "../control/nativeCommands.js";
|
|
9
11
|
import { sendMessage } from "../control/messaging.js";
|
|
@@ -17,8 +19,9 @@ import { createTemplate, deleteTemplate, discoverTemplates, discoverTemplatesDet
|
|
|
17
19
|
import { publishGitHubReport } from "../reports/githubPublisher.js";
|
|
18
20
|
import { enforceWorkingDirectory, normalize } from "../policies/policiesStore.js";
|
|
19
21
|
import { detectProviderModels } from "../control/providerModels.js";
|
|
22
|
+
import { reclaimEventDetail, reclaimWorktree } from "../worktrees/worktreeReclaim.js";
|
|
20
23
|
export function registerRest(app, deps) {
|
|
21
|
-
const { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex } = deps;
|
|
24
|
+
const { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex, workerIdentity } = deps;
|
|
22
25
|
const missionSendTails = new Map();
|
|
23
26
|
function latestMissionTarget(missionId) {
|
|
24
27
|
const candidates = [...jobsWatcher.getAll()].filter(([jobId]) => missions.missionFor(jobId) === missionId);
|
|
@@ -27,6 +30,47 @@ export function registerRest(app, deps) {
|
|
|
27
30
|
const sessionId = missions.latestSessionFor(missionId) ?? latest?.[1].sessionId ?? latest?.[1].resumeSessionId;
|
|
28
31
|
return latest && sessionId ? { jobId: latest[0], sessionId } : undefined;
|
|
29
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Worktree identity lives on the provider job records, not the durable
|
|
35
|
+
* mission, so it must be read while those jobs are still linked — callers
|
|
36
|
+
* that also delete the mission have to resolve this first.
|
|
37
|
+
*/
|
|
38
|
+
function missionWorktree(missionId) {
|
|
39
|
+
const jobs = [...jobsWatcher.getAll()].filter(([jobId]) => missions.missionFor(jobId) === missionId);
|
|
40
|
+
const carrier = jobs.find(([, job]) => job.worktreePath)?.[1];
|
|
41
|
+
return { path: carrier?.worktreePath, branch: carrier?.worktreeBranch };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The working directory to resume a mission in. Normally the recorded
|
|
45
|
+
* repository; when that was a worktree Hive has since reclaimed, the base
|
|
46
|
+
* repository the worktree was cut from.
|
|
47
|
+
*/
|
|
48
|
+
function resumeRepository(missionId, repository) {
|
|
49
|
+
if (!repository || fs.existsSync(repository))
|
|
50
|
+
return repository;
|
|
51
|
+
const jobs = [...jobsWatcher.getAll()].filter(([jobId]) => missions.missionFor(jobId) === missionId);
|
|
52
|
+
const origin = jobs.map(([, job]) => job.originCwd).find((cwd) => cwd && fs.existsSync(cwd));
|
|
53
|
+
return origin ?? repository;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Reclaims the mission's worktree and records the outcome on the timeline.
|
|
57
|
+
* Never throws: losing a worktree cleanup must not fail the lifecycle
|
|
58
|
+
* change the user actually asked for.
|
|
59
|
+
*/
|
|
60
|
+
function reclaimMissionWorktree(missionId, trigger, target, worktree = missionWorktree(missionId)) {
|
|
61
|
+
if (!worktree.path)
|
|
62
|
+
return;
|
|
63
|
+
let detail;
|
|
64
|
+
try {
|
|
65
|
+
detail = reclaimEventDetail(reclaimWorktree(worktree, trigger), trigger);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
detail = `Worktree reclaim failed: ${error.message}`;
|
|
69
|
+
}
|
|
70
|
+
if (detail && target) {
|
|
71
|
+
events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "lifecycle", detail });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
30
74
|
async function withMissionQueue(missionId, run) {
|
|
31
75
|
const previous = missionSendTails.get(missionId) ?? Promise.resolve();
|
|
32
76
|
const operation = previous.catch(() => undefined).then(run);
|
|
@@ -60,13 +104,25 @@ export function registerRest(app, deps) {
|
|
|
60
104
|
// provider to resume. Fast subagents can start and finish while the CLI
|
|
61
105
|
// launch call is still pending; reopening afterward leaves their FIFO
|
|
62
106
|
// stories assigned to a room with no worker desks.
|
|
63
|
-
|
|
107
|
+
// "ready_for_review" is deliberately absent: a question asked of finished
|
|
108
|
+
// work is not a reason to un-finish it. That mission reopens only if the
|
|
109
|
+
// turn actually does something — see reopenOnWork.
|
|
110
|
+
const reopening = ["completed", "archived", "failed", "paused"].includes(summary.lifecycleStatus);
|
|
64
111
|
if (reopening)
|
|
65
112
|
missions.setLifecycleStatus(missionId, "active");
|
|
113
|
+
// A follow-up implicitly reopens a closed mission, but reaching a
|
|
114
|
+
// terminal state reclaims its worktree — so the recorded repository can
|
|
115
|
+
// be a directory that no longer exists. Fall back to the repository the
|
|
116
|
+
// worktree was cut from rather than resuming into a dead path.
|
|
117
|
+
const repository = resumeRepository(missionId, summary.repository);
|
|
118
|
+
if (repository && repository !== summary.repository) {
|
|
119
|
+
missions.setRepository(missionId, repository);
|
|
120
|
+
events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Mission worktree was reclaimed; resuming in ${repository}` });
|
|
121
|
+
}
|
|
66
122
|
let result;
|
|
67
123
|
try {
|
|
68
124
|
result = provider === "codex"
|
|
69
|
-
? await codex.start({ task: prompt, cwd:
|
|
125
|
+
? await codex.start({ task: prompt, cwd: repository, model: summary.model, mode: summary.mode, policy, resumeSessionId: target.sessionId })
|
|
70
126
|
: await sendMessage(target.sessionId, prompt, policy, additionalRepos);
|
|
71
127
|
}
|
|
72
128
|
catch (error) {
|
|
@@ -83,16 +139,43 @@ export function registerRest(app, deps) {
|
|
|
83
139
|
return result;
|
|
84
140
|
});
|
|
85
141
|
}
|
|
86
|
-
app.get("/api/fleet", async () => buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans));
|
|
142
|
+
app.get("/api/fleet", async () => buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans, Date.now(), workerIdentity));
|
|
87
143
|
app.post("/api/direct-studio/clear-completed", async () => {
|
|
88
|
-
const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans);
|
|
144
|
+
const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans, Date.now(), workerIdentity);
|
|
89
145
|
const eligible = snapshot.orchestrators.filter(canClearDirectStudioMission);
|
|
90
146
|
for (const node of eligible) {
|
|
91
147
|
missions.setLifecycleStatus(node.missionId, "completed");
|
|
92
148
|
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" });
|
|
149
|
+
// Bulk completion reclaims on the same clean-only terms as a single
|
|
150
|
+
// completion: uncommitted work in any desk's worktree survives.
|
|
151
|
+
reclaimMissionWorktree(node.missionId, "completed", { jobId: node.jobId, sessionId: node.sessionId });
|
|
93
152
|
}
|
|
94
153
|
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
154
|
});
|
|
155
|
+
app.post("/api/review-hall/accept-clear", async () => {
|
|
156
|
+
const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans, Date.now(), workerIdentity);
|
|
157
|
+
const pending = new Set((snapshot.decisions ?? []).map((decision) => decision.missionId));
|
|
158
|
+
const seated = snapshot.orchestrators.filter((node) => node.lifecycleStatus === "ready_for_review");
|
|
159
|
+
const eligible = seated.filter((node) => canAcceptReviewMission(node, pending.has(node.missionId)));
|
|
160
|
+
for (const node of eligible) {
|
|
161
|
+
missions.setLifecycleStatus(node.missionId, "completed");
|
|
162
|
+
events.add({ ts: Date.now(), sessionId: node.sessionId, jobId: node.jobId, source: "custom", phase: "custom", activityKind: "lifecycle", detail: "Accepted through Accept all clear in the review hall" });
|
|
163
|
+
// Acceptance reclaims on clean-only terms, so a worktree holding
|
|
164
|
+
// uncommitted work is preserved rather than discarded in bulk.
|
|
165
|
+
reclaimMissionWorktree(node.missionId, "completed", { jobId: node.jobId, sessionId: node.sessionId });
|
|
166
|
+
}
|
|
167
|
+
const accepted = new Set(eligible.map((node) => node.missionId));
|
|
168
|
+
return {
|
|
169
|
+
ok: true,
|
|
170
|
+
accepted: eligible.map((node) => node.missionId),
|
|
171
|
+
// Everything left seated, with the reason it still needs a human.
|
|
172
|
+
skipped: seated.filter((node) => !accepted.has(node.missionId)).map((node) => ({
|
|
173
|
+
missionId: node.missionId,
|
|
174
|
+
name: node.name,
|
|
175
|
+
reason: reviewAcceptanceBlocker(node, pending.has(node.missionId)) ?? "needs review",
|
|
176
|
+
})),
|
|
177
|
+
};
|
|
178
|
+
});
|
|
96
179
|
app.get("/api/policies", async () => ({ policy: policies.get() }));
|
|
97
180
|
app.get("/api/providers/models", async () => ({ providers: detectProviderModels() }));
|
|
98
181
|
app.put("/api/policies", async (req, reply) => {
|
|
@@ -143,7 +226,7 @@ export function registerRest(app, deps) {
|
|
|
143
226
|
return { error: err.message };
|
|
144
227
|
} });
|
|
145
228
|
app.get("/api/mission/:missionId/report", async (req, reply) => {
|
|
146
|
-
const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans);
|
|
229
|
+
const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans, Date.now(), workerIdentity);
|
|
147
230
|
const node = snapshot.orchestrators.find((item) => item.missionId === req.params.missionId);
|
|
148
231
|
if (!node) {
|
|
149
232
|
reply.code(404);
|
|
@@ -164,7 +247,7 @@ export function registerRest(app, deps) {
|
|
|
164
247
|
reply.code(400);
|
|
165
248
|
return { error: "choose a GitHub issue or pull request" };
|
|
166
249
|
}
|
|
167
|
-
const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans);
|
|
250
|
+
const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans, Date.now(), workerIdentity);
|
|
168
251
|
const node = snapshot.orchestrators.find((item) => item.missionId === req.params.missionId);
|
|
169
252
|
if (!node?.mission.repository) {
|
|
170
253
|
reply.code(404);
|
|
@@ -187,6 +270,24 @@ export function registerRest(app, deps) {
|
|
|
187
270
|
app.get("/api/session/:sessionId/timeline", async (req) => {
|
|
188
271
|
return { events: events.recentFor(req.params.sessionId, 200) };
|
|
189
272
|
});
|
|
273
|
+
/**
|
|
274
|
+
* Finished work that starts working again is no longer finished.
|
|
275
|
+
*
|
|
276
|
+
* The mission stays awaiting acceptance while it answers a question; the
|
|
277
|
+
* moment it writes something or starts a worker, it is active again and the
|
|
278
|
+
* board should say so without the user having to notice.
|
|
279
|
+
*/
|
|
280
|
+
function reopenIfWorking(event, jobId) {
|
|
281
|
+
if (!isWorkEvent(event))
|
|
282
|
+
return;
|
|
283
|
+
const missionId = missions.missionFor(jobId);
|
|
284
|
+
const mission = missions.get(missionId);
|
|
285
|
+
if (!mission || !reopensOnWork(mission.lifecycleStatus))
|
|
286
|
+
return;
|
|
287
|
+
missions.setLifecycleStatus(missionId, "active");
|
|
288
|
+
missions.updateContext(missionId, { currentState: "Working again after a follow-up" });
|
|
289
|
+
events.add({ ts: Date.now(), sessionId: event.sessionId, jobId, source: "custom", phase: "custom", activityKind: "lifecycle", detail: "Mission reopened from ready_for_review: it started working again" });
|
|
290
|
+
}
|
|
190
291
|
app.post("/hooks/:eventName", async (req, reply) => {
|
|
191
292
|
const payload = {
|
|
192
293
|
...req.body,
|
|
@@ -201,6 +302,8 @@ export function registerRest(app, deps) {
|
|
|
201
302
|
events.add(event);
|
|
202
303
|
for (const promptEvent of skillPromptEvents(payload, jobId))
|
|
203
304
|
events.add(promptEvent);
|
|
305
|
+
if (jobId)
|
|
306
|
+
reopenIfWorking(event, jobId);
|
|
204
307
|
// A write landing in another known repo marks that repo as touched by
|
|
205
308
|
// the mission, so its footprint chips reflect reality, not just intent.
|
|
206
309
|
if (event.activityKind === "file_write" && event.filePath && jobId) {
|
|
@@ -271,6 +374,8 @@ export function registerRest(app, deps) {
|
|
|
271
374
|
impact: phase === "blocked_on_user" ? decision?.impact : undefined,
|
|
272
375
|
context: phase === "blocked_on_user" ? decision?.context : undefined,
|
|
273
376
|
});
|
|
377
|
+
if (effectiveJobId && phase !== "ready_for_review")
|
|
378
|
+
reopenIfWorking({ sessionId, source: "custom", phase }, effectiveJobId);
|
|
274
379
|
if (phase === "ready_for_review" && effectiveJobId) {
|
|
275
380
|
const missionId = missions.missionFor(effectiveJobId);
|
|
276
381
|
if (missions.get(missionId)) {
|
|
@@ -283,10 +388,15 @@ export function registerRest(app, deps) {
|
|
|
283
388
|
if (missions.get(missionId)) {
|
|
284
389
|
// Best-effort: keeps plan task statuses truthful when the
|
|
285
390
|
// orchestrator emits activity without republishing the plan.
|
|
286
|
-
|
|
287
|
-
|
|
391
|
+
// One emit may name several workers (`--target a,b,c`); the task moves for each.
|
|
392
|
+
const targets = targetWorker && /[,;]/.test(targetWorker) ? [...new Set(targetWorker.split(/[,;]/).map((value) => value.trim()).filter(Boolean))] : [targetWorker];
|
|
393
|
+
for (const target of targets) {
|
|
394
|
+
const canonicalWorker = workerIdentity.canonicalFor(missionId, target);
|
|
395
|
+
try {
|
|
396
|
+
plans.applyTaskEvent(missionId, { phase, targetTask, targetWorker: target, canonicalWorker, evidence: phase === "worker_reported" ? detail : undefined });
|
|
397
|
+
}
|
|
398
|
+
catch { /* a later plan_updated remains the source of truth */ }
|
|
288
399
|
}
|
|
289
|
-
catch { /* a later plan_updated remains the source of truth */ }
|
|
290
400
|
}
|
|
291
401
|
}
|
|
292
402
|
return { ok: true };
|
|
@@ -816,6 +926,11 @@ Revise the structured plan to remove that requirement and stop work that was nee
|
|
|
816
926
|
const target = latestMissionTarget(req.params.missionId);
|
|
817
927
|
if (target)
|
|
818
928
|
events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Mission status changed from ${previous} to ${req.body.status}` });
|
|
929
|
+
// Reclaim only on entry into a terminal state, so re-archiving an already
|
|
930
|
+
// archived mission does not re-run git against a path that is long gone.
|
|
931
|
+
if (previous !== req.body.status && (req.body.status === "archived" || req.body.status === "completed")) {
|
|
932
|
+
reclaimMissionWorktree(req.params.missionId, req.body.status, target);
|
|
933
|
+
}
|
|
819
934
|
return { ok: true };
|
|
820
935
|
});
|
|
821
936
|
app.post("/api/mission/:missionId/action", async (req, reply) => {
|
|
@@ -900,6 +1015,9 @@ Revise the structured plan to remove that requirement and stop work that was nee
|
|
|
900
1015
|
});
|
|
901
1016
|
app.delete("/api/mission/:missionId", async (req, reply) => {
|
|
902
1017
|
const target = latestMissionTarget(req.params.missionId);
|
|
1018
|
+
// Resolved before removal: missions.remove() unlinks the jobs that carry
|
|
1019
|
+
// the worktree path, and afterwards it can no longer be found.
|
|
1020
|
+
const worktree = missionWorktree(req.params.missionId);
|
|
903
1021
|
if (target)
|
|
904
1022
|
events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Mission permanently deleted: ${req.params.missionId}` });
|
|
905
1023
|
if (!missions.remove(req.params.missionId)) {
|
|
@@ -908,6 +1026,7 @@ Revise the structured plan to remove that requirement and stop work that was nee
|
|
|
908
1026
|
}
|
|
909
1027
|
messages.removeMission(req.params.missionId);
|
|
910
1028
|
plans.removeMission(req.params.missionId);
|
|
1029
|
+
reclaimMissionWorktree(req.params.missionId, "deleted", target, worktree);
|
|
911
1030
|
return { ok: true };
|
|
912
1031
|
});
|
|
913
1032
|
app.post("/api/session/:pid/stop", async (req, reply) => {
|
|
@@ -4,14 +4,32 @@ import { buildFleetSnapshot } from "../roster/rosterBuilder.js";
|
|
|
4
4
|
* REST for live state — this is the only steady-state channel.
|
|
5
5
|
*/
|
|
6
6
|
export function registerWs(app, deps) {
|
|
7
|
-
const { sessionsWatcher, jobsWatcher, events, missions, messages, plans } = deps;
|
|
7
|
+
const { sessionsWatcher, jobsWatcher, events, missions, messages, plans, workerIdentity, webBuild } = deps;
|
|
8
8
|
const clients = new Set();
|
|
9
|
+
// Every roster the client ever sees is built the same way. The first one
|
|
10
|
+
// used to be built without the identity store, so a reload resolved workers
|
|
11
|
+
// from the bare event window and the next broadcast disagreed with it.
|
|
12
|
+
const currentRoster = () => buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans, Date.now(), workerIdentity);
|
|
13
|
+
let lastRosterJson = "";
|
|
9
14
|
function broadcastRoster() {
|
|
10
|
-
|
|
11
|
-
const msg = JSON.stringify({ type: "roster", snapshot });
|
|
15
|
+
lastRosterJson = JSON.stringify({ type: "roster", snapshot: currentRoster() });
|
|
12
16
|
for (const client of clients)
|
|
13
|
-
client.send(
|
|
17
|
+
client.send(lastRosterJson);
|
|
14
18
|
}
|
|
19
|
+
// Time-based rules — correlation graces, the stop-settle, stalled work — used
|
|
20
|
+
// to take effect only when some unrelated event happened to arrive. A short
|
|
21
|
+
// ticker re-derives the roster and pushes it only when something changed.
|
|
22
|
+
const ticker = setInterval(() => {
|
|
23
|
+
if (!clients.size)
|
|
24
|
+
return;
|
|
25
|
+
const next = JSON.stringify({ type: "roster", snapshot: currentRoster() });
|
|
26
|
+
if (next === lastRosterJson)
|
|
27
|
+
return;
|
|
28
|
+
lastRosterJson = next;
|
|
29
|
+
for (const client of clients)
|
|
30
|
+
client.send(next);
|
|
31
|
+
}, 1_500);
|
|
32
|
+
app.addHook("onClose", async () => { clearInterval(ticker); });
|
|
15
33
|
function broadcastEvent(event) {
|
|
16
34
|
const msg = JSON.stringify({ type: "event", event });
|
|
17
35
|
for (const client of clients)
|
|
@@ -28,10 +46,9 @@ export function registerWs(app, deps) {
|
|
|
28
46
|
});
|
|
29
47
|
app.get("/ws", { websocket: true }, (socket) => {
|
|
30
48
|
clients.add(socket);
|
|
31
|
-
|
|
32
|
-
type: "
|
|
33
|
-
|
|
34
|
-
}));
|
|
49
|
+
if (webBuild)
|
|
50
|
+
socket.send(JSON.stringify({ type: "hello", build: webBuild }));
|
|
51
|
+
socket.send(JSON.stringify({ type: "roster", snapshot: currentRoster() }));
|
|
35
52
|
socket.on("close", () => clients.delete(socket));
|
|
36
53
|
});
|
|
37
54
|
}
|