@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +16 -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 +130 -11
  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 +19 -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 +93 -189
  27. package/packages/server/dist/roster/workerIdentity.js +886 -0
  28. package/packages/server/dist/watch/jobsWatcher.js +55 -24
  29. package/packages/server/dist/worktrees/worktreeReclaim.js +156 -0
  30. package/packages/web/dist/assets/index-BpEYVjCF.css +2 -0
  31. package/packages/web/dist/assets/index-rIAIJyuF.js +12 -0
  32. package/packages/web/dist/index.html +2 -2
  33. package/templates/agents/hive-orchestrator.md +1 -0
  34. package/packages/web/dist/assets/index-BrkIk6ny.js +0 -11
  35. 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;
@@ -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.0",
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,9 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { canClearDirectStudioMission } from "@hive/shared";
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
- 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);
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: summary.repository, model: summary.model, mode: summary.mode, policy, resumeSessionId: target.sessionId })
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
- try {
287
- 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 */ }
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
- 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
  }