@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
@@ -124,8 +124,11 @@ export class PlansStore {
124
124
  const now = Date.now();
125
125
  const mapTasks = (tasks) => tasks.map((task) => {
126
126
  const subtasks = task.subtasks?.length ? mapTasks(task.subtasks) : task.subtasks;
127
+ // A worker is addressed by whichever name the orchestrator used; the plan
128
+ // stores the canonical identity so every consumer can compare exactly.
129
+ const workerNames = [input.canonicalWorker, input.targetWorker].filter((value) => Boolean(value));
127
130
  const matched = (input.targetTask !== undefined && task.id === input.targetTask)
128
- || (input.targetWorker !== undefined && (task.workerId === input.targetWorker || task.owner === input.targetWorker));
131
+ || workerNames.some((name) => task.workerId === name || task.owner === name);
129
132
  // A parent cannot complete ahead of its subtasks (normalize rejects it).
130
133
  const blockedBySubtasks = nextStatus === "completed" && (subtasks ?? []).some((subtask) => subtask.status !== "completed" && subtask.status !== "cancelled");
131
134
  if (!matched || blockedBySubtasks || !allowedFrom[nextStatus].includes(task.status)) {
@@ -138,8 +141,8 @@ export class PlansStore {
138
141
  status: nextStatus,
139
142
  updatedAt: now,
140
143
  startedAt: task.startedAt ?? now,
141
- owner: task.owner ?? input.targetWorker,
142
- workerId: task.workerId ?? input.targetWorker,
144
+ owner: task.owner ?? input.canonicalWorker ?? input.targetWorker,
145
+ workerId: input.canonicalWorker ?? task.workerId ?? input.targetWorker,
143
146
  evidence: nextStatus === "completed" && input.evidence?.trim() ? [...task.evidence, input.evidence.trim().slice(0, 300)] : task.evidence,
144
147
  };
145
148
  });
@@ -157,6 +160,46 @@ export class PlansStore {
157
160
  return undefined;
158
161
  return this.replace(missionId, { ...(plan.layout === "flat" ? { tasks: phases[0]?.tasks ?? [] } : { phases }), gates: plan.gates ?? [], approvalStatus: plan.approvalStatus ?? "proposed", approvalReason: plan.approvalReason });
159
162
  }
163
+ /**
164
+ * Records that a worker is running a task: stamps the canonical identity and
165
+ * advances a queued/blocked task to working. Forward-only and idempotent, so
166
+ * a repeated snapshot cannot churn revisions, and never marks work complete —
167
+ * a running worker proves work started, not that it succeeded.
168
+ */
169
+ attachWorker(missionId, input) {
170
+ const plan = this.get(missionId);
171
+ if (!plan)
172
+ return undefined;
173
+ let changed = false;
174
+ const now = Date.now();
175
+ const mapTasks = (tasks) => tasks.map((task) => {
176
+ const subtasks = task.subtasks?.length ? mapTasks(task.subtasks) : task.subtasks;
177
+ if (task.id !== input.taskId)
178
+ return subtasks === task.subtasks ? task : { ...task, subtasks };
179
+ const advancing = task.status === "queued" || task.status === "blocked";
180
+ if (task.workerId === input.canonicalWorker && !advancing)
181
+ return subtasks === task.subtasks ? task : { ...task, subtasks };
182
+ changed = true;
183
+ return {
184
+ ...task,
185
+ subtasks,
186
+ status: advancing ? "working" : task.status,
187
+ owner: task.owner ?? input.label ?? input.canonicalWorker,
188
+ workerId: input.canonicalWorker,
189
+ startedAt: task.startedAt ?? now,
190
+ updatedAt: now,
191
+ };
192
+ });
193
+ const phases = plan.phases.map((phase) => {
194
+ const tasks = mapTasks(phase.tasks);
195
+ const statuses = allPlanTasks(tasks).map((task) => task.status);
196
+ const status = (phase.status === "queued" || phase.status === "completed") && statuses.some((s) => s === "working" || s === "reviewing") ? "working" : phase.status;
197
+ return { ...phase, status, tasks };
198
+ });
199
+ if (!changed)
200
+ return undefined;
201
+ return this.replace(missionId, { ...(plan.layout === "flat" ? { tasks: phases[0]?.tasks ?? [] } : { phases }), gates: plan.gates ?? [], approvalStatus: plan.approvalStatus ?? "proposed", approvalReason: plan.approvalReason });
202
+ }
160
203
  removeMission(missionId) { this.db.transaction(() => { this.db.prepare("DELETE FROM mission_plans WHERE mission_id = ?").run(missionId); this.db.prepare("DELETE FROM mission_plan_revisions WHERE mission_id = ?").run(missionId); })(); for (const listener of this.listeners)
161
204
  listener(); }
162
205
  migrateMission(oldId, newId) { this.db.transaction(() => { const current = this.get(oldId); if (current) {
@@ -0,0 +1,82 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { MissionsStore } from "../missions/missionsStore.js";
6
+ import { buildFleetSnapshot } from "./rosterBuilder.js";
7
+ import { WorkerIdentityStore } from "./workerIdentity.js";
8
+ const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
9
+ export function loadMissionFixture(name) {
10
+ return JSON.parse(fs.readFileSync(path.join(fixturesDir, `${name}.events.json`), "utf8"));
11
+ }
12
+ /**
13
+ * The manager's job is `done` between a Stop and the next prompt — the turn
14
+ * boundary that repeatedly got mistaken for the end of the mission.
15
+ */
16
+ function jobStateAt(events) {
17
+ let state = "working";
18
+ for (const event of events) {
19
+ if (event.hookEventName === "Stop")
20
+ state = "done";
21
+ if (event.hookEventName === "UserPromptSubmit")
22
+ state = "working";
23
+ }
24
+ return state;
25
+ }
26
+ export function replayMission(fixture, options = {}) {
27
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `hive-replay-${fixture.name}-`));
28
+ options.cleanup?.(() => fs.rmSync(dir, { recursive: true, force: true }));
29
+ const missions = new MissionsStore(path.join(dir, "missions.json"), path.join(dir, "threads.json"));
30
+ missions.init();
31
+ const primaryJob = fixture.jobs[0];
32
+ const mission = missions.create({
33
+ name: fixture.name, objective: fixture.objective, jobId: primaryJob?.jobId ?? fixture.missionId,
34
+ sessionId: fixture.sessionId, mode: fixture.mode,
35
+ });
36
+ for (const { jobId } of fixture.jobs.slice(1))
37
+ missions.linkJob(mission.id, jobId);
38
+ const identity = new WorkerIdentityStore(path.join(dir, "hive.db"));
39
+ // A read-only stand-in for the plans store: the recorded revisions already
40
+ // contain the worker bindings production wrote, so replaying must not write
41
+ // new ones over them.
42
+ const revisions = [...(fixture.planRevisions ?? [])].sort((a, b) => a.updatedAt - b.updatedAt);
43
+ const plansAt = (at) => ({
44
+ get: () => [...revisions].reverse().find((entry) => entry.updatedAt <= at)?.plan,
45
+ attachWorker: () => undefined,
46
+ });
47
+ const start = fixture.events[0]?.ts ?? 0;
48
+ const steps = [];
49
+ for (let index = 1; index <= fixture.events.length; index += 1) {
50
+ const seen = fixture.events.slice(0, index);
51
+ const event = seen[seen.length - 1];
52
+ const now = event.ts + 1;
53
+ // The REST ingest moves the mission to review on this event; a replay that
54
+ // left it active never showed the office the moment the manager leaves
55
+ // for the hall while workers are still on their way out (mission test41).
56
+ if (event.source === "custom" && event.phase === "ready_for_review")
57
+ missions.setLifecycleStatus(mission.id, "ready_for_review");
58
+ const state = jobStateAt(seen);
59
+ const jobs = new Map(fixture.jobs.map(({ jobId, job }) => [jobId, {
60
+ ...job,
61
+ state,
62
+ // The provider's fan file is written as workers finish; before the turn
63
+ // ends the roster only has hook evidence to go on. A recorded fixture
64
+ // holds the file's *final* contents, so entries — and their doneAt — are
65
+ // masked until the moment they were really written. Without that the
66
+ // replay hands the roster a worker's completion long before it happened.
67
+ fan: state === "done"
68
+ ? job.fan.filter((entry) => entry.startedAt <= now).map((entry) => ({ ...entry, doneAt: entry.doneAt !== undefined && entry.doneAt <= now ? entry.doneAt : undefined }))
69
+ : [],
70
+ }]));
71
+ const events = {
72
+ recentFor: (id, limit = 50) => seen.filter((item) => item.sessionId === id || item.jobId === id).slice(-limit),
73
+ sessionIds: () => [...new Set(seen.map((item) => item.sessionId))],
74
+ };
75
+ steps.push({ elapsedMs: event.ts - start, event, snapshot: buildFleetSnapshot(new Map(), jobs, events, missions, undefined, plansAt(now), now, identity) });
76
+ }
77
+ return steps;
78
+ }
79
+ /** The mission node under replay, for the one mission a fixture contains. */
80
+ export function replayedMission(step) {
81
+ return step.snapshot.orchestrators[0];
82
+ }
@@ -1,7 +1,9 @@
1
1
  import { deriveStatus, } from "@hive/shared";
2
2
  import { allPlanTasks } from "../plans/plansStore.js";
3
+ import { deriveChangedFiles, reconcilePlanWorkers } from "../plans/planReconcile.js";
3
4
  import { config } from "../config.js";
4
5
  import { deriveAlerts } from "../health/deriveAlerts.js";
6
+ import { resolveMissionWorkers } from "./workerIdentity.js";
5
7
  const ORCHESTRATOR_AGENT_NAME = "hive-orchestrator";
6
8
  function dateMs(value) {
7
9
  const parsed = value ? Date.parse(value) : NaN;
@@ -18,7 +20,7 @@ function resultText(job) {
18
20
  return undefined;
19
21
  }
20
22
  /** Joins Claude's short-lived jobs into durable, Hive-managed missions. */
21
- export function buildFleetSnapshot(sessions, jobs, events, missions, messageStore, planStore, now = Date.now()) {
23
+ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStore, planStore, now = Date.now(), workerIdentity) {
22
24
  const workerCorrelationGraceMs = 8_000;
23
25
  const orchestrators = [];
24
26
  const other = [];
@@ -52,6 +54,51 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
52
54
  group.push({ jobId, job });
53
55
  groups.set(missionId, group);
54
56
  }
57
+ // Per-mission pending decisions. Used both for the global inbox and, inside the
58
+ // mission loop, to keep a mission reading as waiting_on_you for as long as a
59
+ // decision is unanswered — even after the provider session record has gone
60
+ // quiet — so the office lighting, desk, status pill, and "?" badge agree.
61
+ const pendingDecisionsFor = (node) => {
62
+ if (node.lifecycleStatus === "completed" || node.lifecycleStatus === "archived")
63
+ return [];
64
+ const resolved = new Set(node.recentEvents.filter((event) => event.phase === "decision_resolved" && event.decisionId).map((event) => event.decisionId));
65
+ const missionContext = { missionObjective: node.mission.objective || undefined, repository: node.mission.repository };
66
+ // Only the current, mid-turn blocker belongs in the decision inbox. An
67
+ // unresolved event from an older/completed turn is history: the mission
68
+ // may remain open for optional follow-up, but it is not waiting on the
69
+ // user. Requiring the blocker to be the latest observed event also clears
70
+ // it as soon as provider activity resumes.
71
+ const latestEvent = node.recentEvents.at(-1);
72
+ const fromEvents = !node.turnCompleted && latestEvent?.phase === "blocked_on_user"
73
+ && latestEvent.decisionId && !resolved.has(latestEvent.decisionId)
74
+ && (!latestEvent.jobId || latestEvent.jobId === node.jobId)
75
+ ? [{
76
+ id: latestEvent.decisionId, missionId: node.missionId, missionName: node.name,
77
+ kind: latestEvent.decisionKind ?? "question", question: latestEvent.detail, context: latestEvent.context,
78
+ choices: latestEvent.choices ?? [], recommendation: latestEvent.recommendation, impact: latestEvent.impact, createdAt: latestEvent.ts, ...missionContext,
79
+ }]
80
+ : [];
81
+ // A permission prompt parks the session mid-turn (tempo blocked, the ask
82
+ // in needs) with no TTY to answer it — surface it as an answerable
83
+ // decision. It self-clears once the job is no longer parked.
84
+ const latestJob = jobs.get(node.jobId);
85
+ const parked = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && /^approve\s/i.test(latestJob.needs) && !resolved.has(`perm:${node.jobId}`)
86
+ ? [{
87
+ id: `perm:${node.jobId}`, missionId: node.missionId, missionName: node.name,
88
+ kind: "permission", question: latestJob.needs, context: latestJob.detail,
89
+ choices: ["Approve & continue", "Deny"], impact: "The session is parked at this prompt until you answer.", createdAt: node.updatedAt, ...missionContext,
90
+ }]
91
+ : [];
92
+ const hasStructuredPrompt = node.recentEvents.some((event) => event.jobId === node.jobId && event.phase === "blocked_on_user" && event.decisionId?.startsWith("skill-prompt:"));
93
+ const providerPrompt = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && (latestJob.promptChoices?.length ?? 0) > 0 && !/^approve\s/i.test(latestJob.needs) && !/rate limit|spend limit/i.test(latestJob.needs) && !hasStructuredPrompt && !resolved.has(`prompt:${node.jobId}`)
94
+ ? [{
95
+ id: `prompt:${node.jobId}`, missionId: node.missionId, missionName: node.name,
96
+ kind: "question", question: latestJob.needs, context: latestJob.detail,
97
+ choices: latestJob.promptChoices ?? [], impact: "The provider is waiting for this answer before the skill can continue.", createdAt: node.updatedAt, ...missionContext,
98
+ }]
99
+ : [];
100
+ return [...fromEvents, ...parked, ...providerPrompt];
101
+ };
55
102
  for (const [missionId, group] of groups) {
56
103
  group.sort((a, b) => dateMs(a.job.createdAt) - dateMs(b.job.createdAt));
57
104
  const latest = group[group.length - 1];
@@ -79,7 +126,7 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
79
126
  .map((event) => [`${event.sessionId}:${event.jobId ?? ""}:${event.ts}:${event.phase}:${event.decisionId ?? ""}:${event.targetWorker ?? ""}:${event.detail}`, event])).values()]
80
127
  .sort((a, b) => a.ts - b.ts);
81
128
  let recentEvents = allEvents.slice(-100);
82
- let workers = group.flatMap(({ jobId, job }) => (job.fan ?? []).filter((worker) => worker.kind === "agent" || worker.kind === "subagent").map((worker) => ({
129
+ const fanWorkers = group.flatMap(({ jobId, job }) => (job.fan ?? []).filter((worker) => worker.kind === "agent" || worker.kind === "subagent").map((worker) => ({
83
130
  ...worker,
84
131
  jobId,
85
132
  jobState: job.state,
@@ -89,150 +136,41 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
89
136
  const linkedSession = sessionsByJob.get(jobId);
90
137
  return [...(job.sessionId ? [[job.sessionId, jobId]] : []), ...(linkedSession ? [[linkedSession.sessionId, jobId]] : [])];
91
138
  }));
92
- const jobStateById = new Map(group.map(({ jobId, job }) => [jobId, job.state]));
93
- const parentTurnFinished = (jobId) => ["done", "completed", "failed", "error", "cancelled", "canceled", "stopped"].includes((jobStateById.get(jobId) ?? "").toLowerCase());
94
139
  const eventJobId = (event) => event.jobId ?? jobIdBySession.get(event.sessionId);
95
- // A job's fan array is only a current view and can drop an earlier worker
96
- // as soon as a later worker starts. Rebuild the durable native roster from
97
- // hook history, keyed by Claude's real agent id, and merge fan metadata.
98
- const nativeEvidence = new Map();
99
- for (const event of allEvents) {
100
- const resolvedJobId = eventJobId(event);
101
- const raw = event.rawPayload && typeof event.rawPayload === "object" ? event.rawPayload : {};
102
- const response = raw.tool_response && typeof raw.tool_response === "object" ? raw.tool_response : {};
103
- const input = raw.tool_input && typeof raw.tool_input === "object" ? raw.tool_input : {};
104
- const launchId = typeof response.agentId === "string" ? response.agentId : typeof response.agent_id === "string" ? response.agent_id : undefined;
105
- const nativeId = launchId ?? event.targetWorker;
106
- if (!resolvedJobId || !nativeId || nativeId === "hive-orchestrator")
107
- continue;
108
- const isStart = event.hookEventName === "SubagentStart" || (event.hookEventName === "PostToolUse" && event.toolName === "Agent" && Boolean(launchId));
109
- const isStop = event.hookEventName === "SubagentStop";
110
- if (!isStart && !isStop)
111
- continue;
112
- const label = typeof response.description === "string" ? response.description : typeof input.description === "string" ? input.description : nativeId;
113
- const key = `${resolvedJobId}:${nativeId}`;
114
- const prior = nativeEvidence.get(key);
115
- // A stop without any observed launch is often a nested/background task
116
- // notification. It is evidence, but not enough to invent a person.
117
- if (isStop && !prior)
118
- continue;
119
- nativeEvidence.set(key, {
120
- id: nativeId,
121
- jobId: resolvedJobId,
122
- label: prior?.label !== prior?.id ? prior.label : label,
123
- startedAt: Math.min(prior?.startedAt ?? event.ts, event.ts),
124
- doneAt: isStop ? event.ts : isStart ? undefined : prior?.doneAt,
125
- updatedAt: event.ts,
126
- running: isStart,
127
- });
128
- }
129
- const workerByNativeKey = new Map(workers.map((worker) => [`${worker.jobId}:${worker.id}`, worker]));
130
- for (const native of nativeEvidence.values()) {
131
- const key = `${native.jobId}:${native.id}`;
132
- const fanWorker = workerByNativeKey.get(key);
133
- const turnFinished = parentTurnFinished(native.jobId);
134
- // SubagentStop is the worker's own terminal lifecycle boundary. The
135
- // manager may remain idle/open for follow-up, but that must not keep a
136
- // finished worker seated indefinitely. A later SubagentStart for the
137
- // same native id clears native.doneAt above and resumes that person.
138
- const workerFinished = Boolean(native.doneAt) || turnFinished;
139
- const merged = fanWorker ? {
140
- ...fanWorker,
141
- label: fanWorker.label || native.label,
142
- startedAt: Math.min(fanWorker.startedAt, native.startedAt),
143
- doneAt: native.doneAt ?? (turnFinished ? fanWorker.doneAt : undefined),
144
- updatedAt: Math.max(fanWorker.updatedAt ?? 0, native.updatedAt),
145
- jobState: workerFinished ? "done" : native.running ? "working" : "idle",
146
- } : {
147
- id: native.id,
148
- kind: "agent",
149
- label: native.label,
150
- startedAt: native.startedAt,
151
- doneAt: native.doneAt,
152
- updatedAt: native.updatedAt,
153
- jobId: native.jobId,
154
- jobState: workerFinished ? "done" : native.running ? "working" : "idle",
155
- };
156
- workerByNativeKey.set(key, merged);
140
+ const missionSummary = missions?.summaryFor(missionId, oldest.job.intent ?? "");
141
+ // One canonical person per delegated job. The resolver owns every identity
142
+ // decision native ids, delegation labels, plan tasks, nested agents
143
+ // and persists them so a later poll cannot re-decide them differently.
144
+ const resolved = resolveMissionWorkers({
145
+ missionId,
146
+ fanWorkers,
147
+ events: allEvents,
148
+ eventJobId,
149
+ planTasks: planStore?.get(missionId)?.phases.flatMap((phase) => allPlanTasks(phase.tasks)),
150
+ missionActive: (missionSummary?.lifecycleStatus ?? "active") === "active",
151
+ seeded: workerIdentity?.list(missionId),
152
+ seededNested: workerIdentity?.listNested(missionId),
153
+ now,
154
+ correlationGraceMs: workerCorrelationGraceMs,
155
+ });
156
+ let workers = resolved.workers;
157
+ if (workerIdentity && resolved.dirty) {
158
+ workerIdentity.save(missionId, resolved.records);
159
+ workerIdentity.saveNested(missionId, resolved.nested);
157
160
  }
158
- workers = [...workerByNativeKey.values()];
159
- // A resumed/follow-up job may address the same provider-native agent id.
160
- // The mission roster is person-oriented, so expose that id once using its
161
- // latest lifecycle state instead of creating duplicate React/desk actors
162
- // for each short-lived parent job.
163
- const workersByIdentity = new Map();
164
- for (const worker of workers.sort((a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id))) {
165
- const prior = workersByIdentity.get(worker.id);
166
- if (!prior) {
167
- workersByIdentity.set(worker.id, worker);
168
- continue;
161
+ // Keep the board honest without waiting for the orchestrator to republish
162
+ // it: a worker that is provably running advances its task to working.
163
+ if (planStore) {
164
+ // `doneAt` is the only lifecycle signal; `jobState` is decoration.
165
+ const present = workers.filter((worker) => !worker.doneAt);
166
+ for (const binding of reconcilePlanWorkers(planStore.get(missionId), present)) {
167
+ planStore.attachWorker(missionId, binding);
168
+ if (binding.inferred)
169
+ workerIdentity?.addTaskBinding(missionId, binding.canonicalWorker, binding.taskId);
169
170
  }
170
- const priorFreshness = prior.updatedAt ?? prior.doneAt ?? prior.startedAt;
171
- const workerFreshness = worker.updatedAt ?? worker.doneAt ?? worker.startedAt;
172
- const latest = workerFreshness >= priorFreshness ? worker : prior;
173
- workersByIdentity.set(worker.id, {
174
- ...latest,
175
- label: latest.label || prior.label,
176
- startedAt: Math.min(prior.startedAt, worker.startedAt),
177
- });
178
171
  }
179
- workers = [...workersByIdentity.values()];
180
- // Friendly/custom worker names are presentation aliases only. Native
181
- // Claude ids remain canonical so close or repeated delegations can never
182
- // swap React identities or manufacture an extra avatar.
183
- const workerAliases = new Map();
184
- const claimedNativeWorkers = new Set();
185
- for (const event of allEvents.filter((candidate) => candidate.source === "custom" && candidate.phase === "delegating" && candidate.targetWorker)) {
186
- const resolvedJobId = eventJobId(event);
187
- if (!resolvedJobId)
188
- continue;
189
- const aliasKey = `${resolvedJobId}:${event.targetWorker}`;
190
- const candidate = workers
191
- .filter((worker) => worker.jobId === resolvedJobId && !claimedNativeWorkers.has(worker.id) && Math.abs(worker.startedAt - event.ts) <= 30_000)
192
- .sort((a, b) => Math.abs(a.startedAt - event.ts) - Math.abs(b.startedAt - event.ts))[0];
193
- workerAliases.set(aliasKey, candidate?.id ?? aliasKey);
194
- if (!candidate)
195
- continue;
196
- claimedNativeWorkers.add(candidate.id);
197
- }
198
- if (workerAliases.size)
199
- recentEvents = recentEvents.map((event) => {
200
- if (!event.targetWorker)
201
- return event;
202
- const resolvedJobId = eventJobId(event);
203
- if (!resolvedJobId)
204
- return event;
205
- const key = `${resolvedJobId}:${event.targetWorker}`;
206
- const targetWorker = workerAliases.get(key);
207
- return targetWorker ? { ...event, targetWorker } : event;
208
- });
209
- // Claude's fan file can arrive just before its delegation hook. Publishing
210
- // that unmatched native id for a single poll makes the office animate a
211
- // second person. Hold it briefly for correlation; canonical/event-backed
212
- // workers remain immediate, and hookless workers appear after the grace.
213
- const correlatedWorkerIds = new Set(recentEvents.filter((event) => event.source === "custom" && event.phase === "delegating").map((event) => event.targetWorker).filter((id) => Boolean(id)));
214
- workers = workers.filter((worker) => nativeEvidence.has(`${worker.jobId}:${worker.id}`) || correlatedWorkerIds.has(worker.id) || now - worker.startedAt >= workerCorrelationGraceMs);
172
+ recentEvents = recentEvents.map(resolved.rewrite);
215
173
  const derived = deriveStatus(session, latest.job, recentEvents, now);
216
- const workerIds = new Set(workers.map((worker) => worker.id));
217
- for (const event of recentEvents) {
218
- if (!event.targetWorker || workerIds.has(event.targetWorker))
219
- continue;
220
- const related = recentEvents.filter((candidate) => candidate.targetWorker === event.targetWorker);
221
- const start = related.find((candidate) => candidate.phase === "delegating" || candidate.phase?.toLowerCase().includes("subagentstart") || candidate.hookEventName?.toLowerCase() === "subagentstart");
222
- if (!start)
223
- continue;
224
- // A friendly delegation target is an alias, not a person. Hold every
225
- // unmatched identity through the correlation window; a native Agent id
226
- // normally arrives within milliseconds and becomes the sole actor.
227
- if (now - start.ts < workerCorrelationGraceMs)
228
- continue;
229
- const done = [...related].reverse().find((candidate) => candidate.phase === "worker_reported" || candidate.phase?.toLowerCase().includes("subagentstop") || candidate.hookEventName?.toLowerCase() === "subagentstop");
230
- const resolvedJobId = eventJobId(event);
231
- if (!resolvedJobId)
232
- continue;
233
- workers.push({ id: event.targetWorker, label: event.targetWorker, kind: "subagent", startedAt: start.ts, doneAt: done?.ts, updatedAt: related.at(-1)?.ts, jobId: resolvedJobId, jobState: done ? "done" : latest.job.state });
234
- workerIds.add(event.targetWorker);
235
- }
236
174
  const derivedMessages = group.flatMap(({ jobId, job }) => {
237
175
  const createdAt = dateMs(job.createdAt);
238
176
  const updatedAt = dateMs(job.updatedAt) || createdAt;
@@ -274,7 +212,9 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
274
212
  lifecycleStatus: "active",
275
213
  mode: "orchestrated",
276
214
  };
277
- const missionPlan = planStore?.get(missionId);
215
+ // Derived at read time rather than written back: the events are the source
216
+ // of truth, so this always reflects the latest ones without extra writes.
217
+ const missionPlan = deriveChangedFiles(planStore?.get(missionId), workers, recentEvents);
278
218
  const inactiveForMs = Math.max(0, now - updatedAt);
279
219
  let activityStatus = derived.status;
280
220
  if (mission.lifecycleStatus === "completed" || mission.lifecycleStatus === "archived") {
@@ -298,8 +238,15 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
298
238
  activityStatus = "stalled";
299
239
  }
300
240
  }
241
+ const name = missions?.nameFor(missionId) ?? oldest.job.name ?? latest.job.name ?? missionId;
242
+ const turnCompleted = latest.job.state === "done";
243
+ const pendingDecisions = pendingDecisionsFor({ missionId, name, jobId: latest.jobId, mission, lifecycleStatus: mission.lifecycleStatus, turnCompleted, recentEvents, updatedAt });
244
+ // An unanswered decision is, by definition, waiting on the user. Do not let the
245
+ // runtime's quiet session decay it to idle/stalled while the question stands.
246
+ if (mission.lifecycleStatus === "active" && pendingDecisions.length && (activityStatus === "idle" || activityStatus === "stalled"))
247
+ activityStatus = "waiting_on_you";
301
248
  const runStartedAt = dateMs(latest.job.createdAt) || createdAt;
302
- const alerts = deriveAlerts({ lifecycle: mission.lifecycleStatus, activity: activityStatus, stale: derived.stale, turnCompleted: latest.job.state === "done", workers, events: recentEvents, now, turnTokens: latest.job.tokens ?? 0, runStartedAt });
249
+ const alerts = deriveAlerts({ lifecycle: mission.lifecycleStatus, activity: activityStatus, stale: derived.stale, turnCompleted, workers, events: recentEvents, now, turnTokens: latest.job.tokens ?? 0, runStartedAt });
303
250
  orchestrators.push({
304
251
  missionId,
305
252
  threadId: missionId,
@@ -312,14 +259,11 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
312
259
  latest.job.resumeSessionId ??
313
260
  latest.jobId,
314
261
  pid: session?.pid ?? -1,
315
- name: missions?.nameFor(missionId) ??
316
- oldest.job.name ??
317
- latest.job.name ??
318
- missionId,
262
+ name,
319
263
  status: activityStatus,
320
264
  lifecycleStatus: mission.lifecycleStatus,
321
265
  activityStatus,
322
- turnCompleted: latest.job.state === "done",
266
+ turnCompleted,
323
267
  inactiveForMs,
324
268
  waitingFor: mission.lifecycleStatus === "completed" || mission.lifecycleStatus === "archived" ? undefined : session?.waitingFor ?? latest.job.needs,
325
269
  worktreePath: latest.job.worktreePath ?? oldest.job.worktreePath,
@@ -362,46 +306,6 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
362
306
  });
363
307
  }
364
308
  orchestrators.sort((a, b) => b.updatedAt - a.updatedAt);
365
- const decisions = orchestrators.flatMap((node) => {
366
- if (node.lifecycleStatus === "completed" || node.lifecycleStatus === "archived")
367
- return [];
368
- const resolved = new Set(node.recentEvents.filter((event) => event.phase === "decision_resolved" && event.decisionId).map((event) => event.decisionId));
369
- const missionContext = { missionObjective: node.mission.objective || undefined, repository: node.mission.repository };
370
- // Only the current, mid-turn blocker belongs in the decision inbox. An
371
- // unresolved event from an older/completed turn is history: the mission
372
- // may remain open for optional follow-up, but it is not waiting on the
373
- // user. Requiring the blocker to be the latest observed event also clears
374
- // it as soon as provider activity resumes.
375
- const latestEvent = node.recentEvents.at(-1);
376
- const fromEvents = !node.turnCompleted && latestEvent?.phase === "blocked_on_user"
377
- && latestEvent.decisionId && !resolved.has(latestEvent.decisionId)
378
- && (!latestEvent.jobId || latestEvent.jobId === node.jobId)
379
- ? [{
380
- id: latestEvent.decisionId, missionId: node.missionId, missionName: node.name,
381
- kind: latestEvent.decisionKind ?? "question", question: latestEvent.detail, context: latestEvent.context,
382
- choices: latestEvent.choices ?? [], recommendation: latestEvent.recommendation, impact: latestEvent.impact, createdAt: latestEvent.ts, ...missionContext,
383
- }]
384
- : [];
385
- // A permission prompt parks the session mid-turn (tempo blocked, the ask
386
- // in needs) with no TTY to answer it — surface it as an answerable
387
- // decision. It self-clears once the job is no longer parked.
388
- const latestJob = jobs.get(node.jobId);
389
- const parked = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && /^approve\s/i.test(latestJob.needs) && !resolved.has(`perm:${node.jobId}`)
390
- ? [{
391
- id: `perm:${node.jobId}`, missionId: node.missionId, missionName: node.name,
392
- kind: "permission", question: latestJob.needs, context: latestJob.detail,
393
- choices: ["Approve & continue", "Deny"], impact: "The session is parked at this prompt until you answer.", createdAt: node.updatedAt, ...missionContext,
394
- }]
395
- : [];
396
- const hasStructuredPrompt = node.recentEvents.some((event) => event.jobId === node.jobId && event.phase === "blocked_on_user" && event.decisionId?.startsWith("skill-prompt:"));
397
- const providerPrompt = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && (latestJob.promptChoices?.length ?? 0) > 0 && !/^approve\s/i.test(latestJob.needs) && !/rate limit|spend limit/i.test(latestJob.needs) && !hasStructuredPrompt && !resolved.has(`prompt:${node.jobId}`)
398
- ? [{
399
- id: `prompt:${node.jobId}`, missionId: node.missionId, missionName: node.name,
400
- kind: "question", question: latestJob.needs, context: latestJob.detail,
401
- choices: latestJob.promptChoices ?? [], impact: "The provider is waiting for this answer before the skill can continue.", createdAt: node.updatedAt, ...missionContext,
402
- }]
403
- : [];
404
- return [...fromEvents, ...parked, ...providerPrompt];
405
- }).sort((a, b) => b.createdAt - a.createdAt);
309
+ const decisions = orchestrators.flatMap((node) => pendingDecisionsFor(node)).sort((a, b) => b.createdAt - a.createdAt);
406
310
  return { orchestrators, decisions, other, generatedAt: Date.now() };
407
311
  }