@shanesaravia/hive 0.2.1 → 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 +32 -0
- package/README.md +2 -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 +65 -10
- 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 +9 -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 +37 -142
- package/packages/server/dist/roster/workerIdentity.js +886 -0
- package/packages/server/dist/watch/jobsWatcher.js +55 -24
- 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-Bzle5Xla.css +0 -2
- package/packages/web/dist/assets/index-C6AY0vYC.js +0 -11
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,38 @@
|
|
|
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
|
+
|
|
5
37
|
## 0.2.1 — 2026-08-25
|
|
6
38
|
|
|
7
39
|
### Added
|
package/README.md
CHANGED
|
@@ -428,6 +428,8 @@ Project planning is split by scope:
|
|
|
428
428
|
- [`docs/IMPROVEMENTS.md`](docs/IMPROVEMENTS.md) — core product roadmap and completion checklist
|
|
429
429
|
- [`docs/VISUAL_OFFICE_PLAN.md`](docs/VISUAL_OFFICE_PLAN.md) — completed visual-office foundation
|
|
430
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
|
|
431
433
|
|
|
432
434
|
## Troubleshooting
|
|
433
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,10 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
-
import { canClearDirectStudioMission } from "@hive/shared";
|
|
3
|
+
import { canAcceptReviewMission, canClearDirectStudioMission, reviewAcceptanceBlocker } from "@hive/shared";
|
|
4
4
|
import { CONTEXT_BUDGETS } from "../messages/messagesStore.js";
|
|
5
5
|
import { allPlanTasks } from "../plans/plansStore.js";
|
|
6
6
|
import { buildFleetSnapshot } from "../roster/rosterBuilder.js";
|
|
7
7
|
import { skillPromptEvents, toHiveEvent } from "../hooks/hookIngest.js";
|
|
8
|
+
import { isWorkEvent, reopensOnWork } from "../missions/reopenOnWork.js";
|
|
8
9
|
import { startOrchestrator } from "../control/launcher.js";
|
|
9
10
|
import { matchNativeCommand, runNativeCommand } from "../control/nativeCommands.js";
|
|
10
11
|
import { sendMessage } from "../control/messaging.js";
|
|
@@ -20,7 +21,7 @@ import { enforceWorkingDirectory, normalize } from "../policies/policiesStore.js
|
|
|
20
21
|
import { detectProviderModels } from "../control/providerModels.js";
|
|
21
22
|
import { reclaimEventDetail, reclaimWorktree } from "../worktrees/worktreeReclaim.js";
|
|
22
23
|
export function registerRest(app, deps) {
|
|
23
|
-
const { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex } = deps;
|
|
24
|
+
const { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex, workerIdentity } = deps;
|
|
24
25
|
const missionSendTails = new Map();
|
|
25
26
|
function latestMissionTarget(missionId) {
|
|
26
27
|
const candidates = [...jobsWatcher.getAll()].filter(([jobId]) => missions.missionFor(jobId) === missionId);
|
|
@@ -103,7 +104,10 @@ export function registerRest(app, deps) {
|
|
|
103
104
|
// provider to resume. Fast subagents can start and finish while the CLI
|
|
104
105
|
// launch call is still pending; reopening afterward leaves their FIFO
|
|
105
106
|
// stories assigned to a room with no worker desks.
|
|
106
|
-
|
|
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);
|
|
107
111
|
if (reopening)
|
|
108
112
|
missions.setLifecycleStatus(missionId, "active");
|
|
109
113
|
// A follow-up implicitly reopens a closed mission, but reaching a
|
|
@@ -135,9 +139,9 @@ export function registerRest(app, deps) {
|
|
|
135
139
|
return result;
|
|
136
140
|
});
|
|
137
141
|
}
|
|
138
|
-
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));
|
|
139
143
|
app.post("/api/direct-studio/clear-completed", async () => {
|
|
140
|
-
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);
|
|
141
145
|
const eligible = snapshot.orchestrators.filter(canClearDirectStudioMission);
|
|
142
146
|
for (const node of eligible) {
|
|
143
147
|
missions.setLifecycleStatus(node.missionId, "completed");
|
|
@@ -148,6 +152,30 @@ export function registerRest(app, deps) {
|
|
|
148
152
|
}
|
|
149
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 };
|
|
150
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
|
+
});
|
|
151
179
|
app.get("/api/policies", async () => ({ policy: policies.get() }));
|
|
152
180
|
app.get("/api/providers/models", async () => ({ providers: detectProviderModels() }));
|
|
153
181
|
app.put("/api/policies", async (req, reply) => {
|
|
@@ -198,7 +226,7 @@ export function registerRest(app, deps) {
|
|
|
198
226
|
return { error: err.message };
|
|
199
227
|
} });
|
|
200
228
|
app.get("/api/mission/:missionId/report", async (req, reply) => {
|
|
201
|
-
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);
|
|
202
230
|
const node = snapshot.orchestrators.find((item) => item.missionId === req.params.missionId);
|
|
203
231
|
if (!node) {
|
|
204
232
|
reply.code(404);
|
|
@@ -219,7 +247,7 @@ export function registerRest(app, deps) {
|
|
|
219
247
|
reply.code(400);
|
|
220
248
|
return { error: "choose a GitHub issue or pull request" };
|
|
221
249
|
}
|
|
222
|
-
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);
|
|
223
251
|
const node = snapshot.orchestrators.find((item) => item.missionId === req.params.missionId);
|
|
224
252
|
if (!node?.mission.repository) {
|
|
225
253
|
reply.code(404);
|
|
@@ -242,6 +270,24 @@ export function registerRest(app, deps) {
|
|
|
242
270
|
app.get("/api/session/:sessionId/timeline", async (req) => {
|
|
243
271
|
return { events: events.recentFor(req.params.sessionId, 200) };
|
|
244
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
|
+
}
|
|
245
291
|
app.post("/hooks/:eventName", async (req, reply) => {
|
|
246
292
|
const payload = {
|
|
247
293
|
...req.body,
|
|
@@ -256,6 +302,8 @@ export function registerRest(app, deps) {
|
|
|
256
302
|
events.add(event);
|
|
257
303
|
for (const promptEvent of skillPromptEvents(payload, jobId))
|
|
258
304
|
events.add(promptEvent);
|
|
305
|
+
if (jobId)
|
|
306
|
+
reopenIfWorking(event, jobId);
|
|
259
307
|
// A write landing in another known repo marks that repo as touched by
|
|
260
308
|
// the mission, so its footprint chips reflect reality, not just intent.
|
|
261
309
|
if (event.activityKind === "file_write" && event.filePath && jobId) {
|
|
@@ -326,6 +374,8 @@ export function registerRest(app, deps) {
|
|
|
326
374
|
impact: phase === "blocked_on_user" ? decision?.impact : undefined,
|
|
327
375
|
context: phase === "blocked_on_user" ? decision?.context : undefined,
|
|
328
376
|
});
|
|
377
|
+
if (effectiveJobId && phase !== "ready_for_review")
|
|
378
|
+
reopenIfWorking({ sessionId, source: "custom", phase }, effectiveJobId);
|
|
329
379
|
if (phase === "ready_for_review" && effectiveJobId) {
|
|
330
380
|
const missionId = missions.missionFor(effectiveJobId);
|
|
331
381
|
if (missions.get(missionId)) {
|
|
@@ -338,10 +388,15 @@ export function registerRest(app, deps) {
|
|
|
338
388
|
if (missions.get(missionId)) {
|
|
339
389
|
// Best-effort: keeps plan task statuses truthful when the
|
|
340
390
|
// orchestrator emits activity without republishing the plan.
|
|
341
|
-
|
|
342
|
-
|
|
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 */ }
|
|
343
399
|
}
|
|
344
|
-
catch { /* a later plan_updated remains the source of truth */ }
|
|
345
400
|
}
|
|
346
401
|
}
|
|
347
402
|
return { ok: true };
|
|
@@ -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
|
}
|
|
@@ -20,11 +20,23 @@ export async function startOrchestrator(opts) {
|
|
|
20
20
|
const args = buildLaunchArgs(opts);
|
|
21
21
|
const before = new Set(listSessionFiles());
|
|
22
22
|
const cwd = requireWorkingDirectory(opts.cwd);
|
|
23
|
-
|
|
23
|
+
// stderr is piped rather than ignored: when the CLI refuses to launch it
|
|
24
|
+
// says exactly why, and throwing that away left every refusal looking like
|
|
25
|
+
// the same 30-second timeout. The pipe is drained and the child is still
|
|
26
|
+
// unref'd, so nothing keeps the server alive.
|
|
27
|
+
const child = spawn("claude", args, { cwd, detached: true, stdio: ["ignore", "ignore", "pipe"] });
|
|
28
|
+
// `claude --bg` detaches and exits at once on the happy path, so an exit is
|
|
29
|
+
// not a failure — only a non-zero one is. Reading the exit alone as failure
|
|
30
|
+
// aborts every successful launch before its session can register.
|
|
31
|
+
const refusal = { text: "", failed: false };
|
|
32
|
+
child.stderr?.setEncoding("utf8");
|
|
33
|
+
child.stderr?.on("data", (chunk) => { refusal.text = (refusal.text + chunk).slice(0, 2000); });
|
|
34
|
+
child.once("exit", (code) => { refusal.failed = (code ?? 0) !== 0; });
|
|
35
|
+
child.once("error", (error) => { refusal.text ||= error.message; refusal.failed = true; });
|
|
24
36
|
child.unref();
|
|
25
37
|
// Worktree launches must copy/check out the repo before the session
|
|
26
38
|
// registers, which can take well over 8s on large repositories.
|
|
27
|
-
const session = await waitForNewSession(before, opts.worktree ? 30000 : 8000);
|
|
39
|
+
const session = await waitForNewSession(before, opts.worktree ? 30000 : 8000, refusal);
|
|
28
40
|
if (!session.jobId) {
|
|
29
41
|
throw new Error("Orchestrator session started without a background job ID");
|
|
30
42
|
}
|
|
@@ -45,13 +57,29 @@ export function generateWorktreeName(missionName) {
|
|
|
45
57
|
const suffix = Math.random().toString(36).slice(2, 8);
|
|
46
58
|
return slug ? `hive-${slug}-${suffix}` : `hive-${suffix}`;
|
|
47
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The permission mode a background session runs in.
|
|
62
|
+
*
|
|
63
|
+
* Nothing is watching a background job's TTY, so any mode that stops to ask a
|
|
64
|
+
* question is a mode that can park a mission indefinitely. That is why this
|
|
65
|
+
* was "bypassPermissions" — but the CLI now requires a one-time interactive
|
|
66
|
+
* disclaimer before --bg will accept it, which is not something a server can
|
|
67
|
+
* agree to on the user's behalf, so every launch failed outright.
|
|
68
|
+
*
|
|
69
|
+
* "auto" takes it. It is also the mode that should have been asked for: it
|
|
70
|
+
* still declines what it judges genuinely dangerous rather than waiving every
|
|
71
|
+
* check, and Hive already surfaces a parked mission as a decision the user can
|
|
72
|
+
* answer from the board. A prompt nobody sees is the thing to avoid; a prompt
|
|
73
|
+
* Hive can put in front of someone is the product working.
|
|
74
|
+
*
|
|
75
|
+
* The policy remains the real boundary either way, and it is a stronger one
|
|
76
|
+
* than a prompt: `--disallowedTools` denies rm, git reset --hard, git clean,
|
|
77
|
+
* releases and publishes outright, and a denied tool cannot be used at any
|
|
78
|
+
* permission level.
|
|
79
|
+
*/
|
|
80
|
+
export const PERMISSION_MODE = "auto";
|
|
48
81
|
export function buildLaunchArgs(opts) {
|
|
49
|
-
const args = [
|
|
50
|
-
"--bg",
|
|
51
|
-
// Background sessions have no TTY to answer permission prompts — "auto"
|
|
52
|
-
// (the mode Claude Code's own background jobs use) lets it proceed
|
|
53
|
-
// without stalling. disallowedTools remains the actual safety boundary.
|
|
54
|
-
];
|
|
82
|
+
const args = ["--bg"];
|
|
55
83
|
// Variadic --add-dir must come before the trailing task positional, with a
|
|
56
84
|
// flag after it, or it would swallow the task as another directory.
|
|
57
85
|
if (opts.addDirs?.length)
|
|
@@ -64,7 +92,7 @@ export function buildLaunchArgs(opts) {
|
|
|
64
92
|
args.push("--disallowedTools", [...new Set(denied)].join(","));
|
|
65
93
|
if (opts.policy?.allowedTools.length)
|
|
66
94
|
args.push("--allowedTools", opts.policy.allowedTools.join(","));
|
|
67
|
-
args.push("--permission-mode",
|
|
95
|
+
args.push("--permission-mode", PERMISSION_MODE);
|
|
68
96
|
if (opts.worktree) {
|
|
69
97
|
// Always pass an explicit name: the CLI's --worktree greedily consumes the
|
|
70
98
|
// next non-flag argument, so a bare --worktree would swallow the task
|
|
@@ -93,7 +121,9 @@ function listSessionFiles() {
|
|
|
93
121
|
* new session is up is its sessions/<pid>.json file appearing. Poll the
|
|
94
122
|
* filesystem briefly (startup confirmation only, not steady-state polling).
|
|
95
123
|
*/
|
|
96
|
-
async function waitForNewSession(before, timeoutMs
|
|
124
|
+
async function waitForNewSession(before, timeoutMs,
|
|
125
|
+
/** What the CLI said on its way out, if it refused to start at all. */
|
|
126
|
+
refusal) {
|
|
97
127
|
const start = Date.now();
|
|
98
128
|
while (Date.now() - start < timeoutMs) {
|
|
99
129
|
const now = listSessionFiles();
|
|
@@ -108,7 +138,16 @@ async function waitForNewSession(before, timeoutMs) {
|
|
|
108
138
|
// The daemon may still be writing the newly discovered file.
|
|
109
139
|
}
|
|
110
140
|
}
|
|
141
|
+
// A CLI that has already failed is never going to register a session, so
|
|
142
|
+
// report what it said instead of waiting out the rest of the timeout.
|
|
143
|
+
if (refusal?.failed)
|
|
144
|
+
throw new Error(launchFailure(refusal.text));
|
|
111
145
|
await new Promise((r) => setTimeout(r, 250));
|
|
112
146
|
}
|
|
113
|
-
throw new Error("Timed out waiting for orchestrator session to start");
|
|
147
|
+
throw new Error(refusal?.text ? launchFailure(refusal.text) : "Timed out waiting for orchestrator session to start");
|
|
148
|
+
}
|
|
149
|
+
/** The CLI's own words, trimmed to the part worth putting on a banner. */
|
|
150
|
+
export function launchFailure(stderr) {
|
|
151
|
+
const said = stderr.split("\n").map((line) => line.trim()).filter(Boolean).join(" ").trim();
|
|
152
|
+
return said ? `Claude CLI refused to start the mission: ${said}` : "Claude CLI exited before the mission started";
|
|
114
153
|
}
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { config } from "../config.js";
|
|
6
6
|
import { disallowedTools } from "../policies/policiesStore.js";
|
|
7
|
+
import { PERMISSION_MODE } from "./launcher.js";
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
/**
|
|
9
10
|
* Sends a follow-up message into a running session. The per-session Unix
|
|
@@ -22,10 +23,10 @@ export function buildResumeArgs(sessionId, text, policy, addDirs) {
|
|
|
22
23
|
// The prompt positional must precede --resume/--bg: trailing positionals are
|
|
23
24
|
// silently dropped on resumed background turns (the session starts idle,
|
|
24
25
|
// "send a prompt to start", and never replies).
|
|
25
|
-
// Resumes inherit the session's permission mode today, but pin
|
|
26
|
+
// Resumes inherit the session's permission mode today, but pin it
|
|
26
27
|
// explicitly so a CLI change can never drop follow-up turns into a
|
|
27
28
|
// prompting mode no background session can answer.
|
|
28
|
-
const args = [text, "--resume", sessionId, "--bg", "--permission-mode",
|
|
29
|
+
const args = [text, "--resume", sessionId, "--bg", "--permission-mode", PERMISSION_MODE];
|
|
29
30
|
const denied = policy ? disallowedTools(policy) : [];
|
|
30
31
|
if (denied.length)
|
|
31
32
|
args.push("--disallowedTools", denied.join(","));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { matchesWorker } from "@hive/shared";
|
|
1
2
|
const LONG_RUNNING_MS = 15 * 60 * 1000;
|
|
2
3
|
const HIGH_TOKEN_TURN = 20_000;
|
|
3
4
|
const MIN_TOKEN_ALERT_RUNTIME_MS = 5 * 60 * 1000;
|
|
@@ -50,6 +51,4 @@ export function deriveAlerts(input) {
|
|
|
50
51
|
alerts.push({ id: "unclear-wait", severity: "warning", title: "Waiting without a clear question", explanation: "The runtime says it needs user input, but Hive has no structured pending decision to display.", evidence: "Waiting state present; no unresolved blocked_on_user event found.", recovery: "Open the mission and ask the orchestrator to state one concrete question with choices and a recommendation." });
|
|
51
52
|
return alerts;
|
|
52
53
|
}
|
|
53
|
-
function matchesWorker(target, worker) { if (!target)
|
|
54
|
-
return false; const value = target.toLowerCase(); return value === worker.id.toLowerCase() || value === worker.label.toLowerCase() || worker.id.toLowerCase().startsWith(value) || worker.label.toLowerCase().includes(value); }
|
|
55
54
|
function minutes(ms) { return Math.max(1, Math.floor(ms / 60_000)); }
|