@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +2 -0
  3. package/node_modules/@hive/shared/dist/directStudio.d.ts +6 -0
  4. package/node_modules/@hive/shared/dist/directStudio.js +12 -0
  5. package/node_modules/@hive/shared/dist/index.d.ts +2 -0
  6. package/node_modules/@hive/shared/dist/index.js +2 -0
  7. package/node_modules/@hive/shared/dist/reviewHall.d.ts +15 -0
  8. package/node_modules/@hive/shared/dist/reviewHall.js +49 -0
  9. package/node_modules/@hive/shared/dist/types.d.ts +29 -0
  10. package/node_modules/@hive/shared/dist/workers.d.ts +14 -0
  11. package/node_modules/@hive/shared/dist/workers.js +22 -0
  12. package/package.json +1 -1
  13. package/packages/server/dist/api/rest.js +65 -10
  14. package/packages/server/dist/api/ws.js +25 -8
  15. package/packages/server/dist/control/launcher.js +50 -11
  16. package/packages/server/dist/control/messaging.js +3 -2
  17. package/packages/server/dist/health/deriveAlerts.js +1 -2
  18. package/packages/server/dist/hooks/hookIngest.js +69 -10
  19. package/packages/server/dist/index.js +20 -4
  20. package/packages/server/dist/messages/messagesStore.js +25 -12
  21. package/packages/server/dist/missions/missionsStore.js +9 -0
  22. package/packages/server/dist/missions/reopenOnWork.js +20 -0
  23. package/packages/server/dist/plans/planReconcile.js +114 -0
  24. package/packages/server/dist/plans/plansStore.js +46 -3
  25. package/packages/server/dist/roster/missionReplay.js +82 -0
  26. package/packages/server/dist/roster/rosterBuilder.js +37 -142
  27. package/packages/server/dist/roster/workerIdentity.js +886 -0
  28. package/packages/server/dist/watch/jobsWatcher.js +55 -24
  29. package/packages/web/dist/assets/index-BpEYVjCF.css +2 -0
  30. package/packages/web/dist/assets/index-rIAIJyuF.js +12 -0
  31. package/packages/web/dist/index.html +2 -2
  32. package/templates/agents/hive-orchestrator.md +1 -0
  33. package/packages/web/dist/assets/index-Bzle5Xla.css +0 -2
  34. 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;
@@ -1,3 +1,5 @@
1
1
  export * from "./types.js";
2
2
  export * from "./status.js";
3
+ export * from "./workers.js";
3
4
  export * from "./directStudio.js";
5
+ export * from "./reviewHall.js";
@@ -1,3 +1,5 @@
1
1
  export * from "./types.js";
2
2
  export * from "./status.js";
3
+ export * from "./workers.js";
3
4
  export * from "./directStudio.js";
5
+ export * from "./reviewHall.js";
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "@shanesaravia/hive",
3
3
  "private": false,
4
- "version": "0.2.1",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "description": "Provider-neutral local mission control for Claude Code, Codex, and agent fleets.",
7
7
  "license": "MIT",
@@ -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
- const reopening = ["ready_for_review", "completed", "archived", "failed", "paused"].includes(summary.lifecycleStatus);
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
- try {
342
- plans.applyTaskEvent(missionId, { phase, targetTask, targetWorker, evidence: phase === "worker_reported" ? detail : undefined });
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
- const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans);
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(msg);
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
- socket.send(JSON.stringify({
32
- type: "roster",
33
- snapshot: buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans),
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
- const child = spawn("claude", args, { cwd, detached: true, stdio: "ignore" });
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", "auto");
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 auto
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", "auto"];
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)); }