@shanesaravia/hive 0.2.1 → 0.4.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 (56) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +18 -1
  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/status.js +9 -0
  10. package/node_modules/@hive/shared/dist/types.d.ts +191 -1
  11. package/node_modules/@hive/shared/dist/types.js +27 -0
  12. package/node_modules/@hive/shared/dist/workers.d.ts +14 -0
  13. package/node_modules/@hive/shared/dist/workers.js +22 -0
  14. package/package.json +1 -1
  15. package/packages/server/dist/agents/agentDiscovery.js +64 -0
  16. package/packages/server/dist/api/rest.js +538 -23
  17. package/packages/server/dist/api/ws.js +90 -9
  18. package/packages/server/dist/control/launcher.js +50 -11
  19. package/packages/server/dist/control/messaging.js +3 -2
  20. package/packages/server/dist/control/missionQuiesce.js +66 -0
  21. package/packages/server/dist/health/deriveAlerts.js +9 -2
  22. package/packages/server/dist/hooks/hookIngest.js +69 -10
  23. package/packages/server/dist/index.js +44 -4
  24. package/packages/server/dist/loops/loopCommand.js +56 -0
  25. package/packages/server/dist/loops/loopNoop.js +38 -0
  26. package/packages/server/dist/loops/loopScheduler.js +58 -0
  27. package/packages/server/dist/loops/loopStore.js +118 -0
  28. package/packages/server/dist/loops/monitors.js +38 -0
  29. package/packages/server/dist/messages/attachmentStore.js +92 -0
  30. package/packages/server/dist/messages/messagesStore.js +110 -33
  31. package/packages/server/dist/missions/missionsStore.js +9 -0
  32. package/packages/server/dist/missions/reopenOnWork.js +20 -0
  33. package/packages/server/dist/plans/planReconcile.js +114 -0
  34. package/packages/server/dist/plans/plansStore.js +46 -3
  35. package/packages/server/dist/reviews/reviewDiff.js +47 -0
  36. package/packages/server/dist/roster/missionReplay.js +82 -0
  37. package/packages/server/dist/roster/replyAsk.js +62 -0
  38. package/packages/server/dist/roster/rosterBuilder.js +78 -147
  39. package/packages/server/dist/roster/workerIdentity.js +923 -0
  40. package/packages/server/dist/skills/skillDiscovery.js +28 -4
  41. package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
  42. package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
  43. package/packages/server/dist/terminals/providerDetection.js +27 -0
  44. package/packages/server/dist/terminals/terminalCapability.js +45 -0
  45. package/packages/server/dist/terminals/terminalFeatures.js +11 -0
  46. package/packages/server/dist/terminals/terminalObservability.js +21 -0
  47. package/packages/server/dist/terminals/terminalRuntime.js +125 -0
  48. package/packages/server/dist/terminals/terminalStream.js +30 -0
  49. package/packages/server/dist/transcripts/transcriptReader.js +345 -0
  50. package/packages/server/dist/watch/jobsWatcher.js +55 -24
  51. package/packages/web/dist/assets/index-DWjqiitn.js +17 -0
  52. package/packages/web/dist/assets/index-rd4RnLqj.css +2 -0
  53. package/packages/web/dist/index.html +2 -2
  54. package/templates/agents/hive-orchestrator.md +1 -0
  55. package/packages/web/dist/assets/index-Bzle5Xla.css +0 -2
  56. package/packages/web/dist/assets/index-C6AY0vYC.js +0 -11
@@ -1,16 +1,23 @@
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
+ import { orchestratorTranscriptPath, readTranscript, transcriptSources, workerTranscriptPath } from "../transcripts/transcriptReader.js";
7
8
  import { skillPromptEvents, toHiveEvent } from "../hooks/hookIngest.js";
9
+ import { isWorkEvent, reopensOnWork } from "../missions/reopenOnWork.js";
8
10
  import { startOrchestrator } from "../control/launcher.js";
9
11
  import { matchNativeCommand, runNativeCommand } from "../control/nativeCommands.js";
10
12
  import { sendMessage } from "../control/messaging.js";
13
+ import { jobsToQuiesce } from "../control/missionQuiesce.js";
11
14
  import { parsePermissionAsk, permissionGrant } from "../control/permissionPark.js";
12
15
  import { forceStopSession, stopSession } from "../control/killer.js";
13
16
  import { discoverSkills } from "../skills/skillDiscovery.js";
17
+ import { discoverAgents } from "../agents/agentDiscovery.js";
18
+ import { AttachmentStore, isSupportedMediaType, MAX_ATTACHMENT_BYTES } from "../messages/attachmentStore.js";
19
+ import { formatInterval, parseLoopCommand } from "../loops/loopCommand.js";
20
+ import { activeMonitors } from "../loops/monitors.js";
14
21
  import { translateSkillInvocations } from "../skills/skillInvocation.js";
15
22
  import { detectRepositoryMentions, foreignRepositoryForPath, inspectWorkingDirectory, recentRepositories, requireWorkingDirectory, suggestDirectories } from "../paths/pathResolver.js";
16
23
  import { missionReport, reportMarkdown } from "../reports/missionReport.js";
@@ -19,9 +26,116 @@ import { publishGitHubReport } from "../reports/githubPublisher.js";
19
26
  import { enforceWorkingDirectory, normalize } from "../policies/policiesStore.js";
20
27
  import { detectProviderModels } from "../control/providerModels.js";
21
28
  import { reclaimEventDetail, reclaimWorktree } from "../worktrees/worktreeReclaim.js";
29
+ import { reviewDiff } from "../reviews/reviewDiff.js";
30
+ import { terminalCapability } from "../terminals/terminalCapability.js";
31
+ import { providerTerminalCatalog } from "../terminals/providerDetection.js";
32
+ import { terminalFeatureFlags } from "../terminals/terminalFeatures.js";
33
+ /** Persist the human-significant transitions hidden inside a full plan snapshot. */
34
+ export function planMilestones(previous, next) {
35
+ const milestones = [];
36
+ const previousPhases = new Map(previous?.phases.map((phase) => [phase.id, phase]));
37
+ const flatten = (plan) => {
38
+ const result = new Map();
39
+ const visit = (tasks) => tasks.forEach((task) => { result.set(task.id, task); visit(task.subtasks ?? []); });
40
+ plan?.phases.forEach((phase) => visit(phase.tasks));
41
+ return result;
42
+ };
43
+ const previousTasks = flatten(previous);
44
+ for (const phase of next.phases) {
45
+ const before = previousPhases.get(phase.id)?.status;
46
+ if (phase.status === "working" && before !== "working")
47
+ milestones.push({ phase: "phase_started", detail: phase.title });
48
+ if (phase.status === "completed" && before !== "completed")
49
+ milestones.push({ phase: "phase_completed", detail: phase.title });
50
+ for (const task of flatten({ ...next, phases: [{ ...phase, tasks: phase.tasks }] }).values()) {
51
+ const oldStatus = previousTasks.get(task.id)?.status;
52
+ if (task.status === "working" && oldStatus !== "working")
53
+ milestones.push({ phase: "task_started", detail: task.title, targetTask: task.id });
54
+ if (task.status === "completed" && oldStatus !== "completed")
55
+ milestones.push({ phase: "task_completed", detail: task.title, targetTask: task.id });
56
+ }
57
+ }
58
+ const previousGates = new Map(previous?.gates.map((gate) => [gate.id, gate.status]));
59
+ for (const gate of next.gates) {
60
+ if (previousGates.get(gate.id) === gate.status)
61
+ continue;
62
+ if (gate.status === "satisfied")
63
+ milestones.push({ phase: "gate_satisfied", detail: gate.label });
64
+ if (gate.status === "failed")
65
+ milestones.push({ phase: "gate_failed", detail: gate.label });
66
+ if (gate.status === "waived")
67
+ milestones.push({ phase: "gate_waived", detail: gate.label });
68
+ }
69
+ return milestones;
70
+ }
71
+ /** One sentence about structural plan changes; status transitions are separate milestone events. */
72
+ export function planRevisionSummary(previous, next) {
73
+ if (!previous)
74
+ return `Plan created with ${next.phases.length} phase${next.phases.length === 1 ? "" : "s"} and ${next.progress.totalTasks} task${next.progress.totalTasks === 1 ? "" : "s"}`;
75
+ const flatten = (plan) => {
76
+ const result = new Map();
77
+ const visit = (tasks) => tasks.forEach((task) => { result.set(task.id, task.title); visit(task.subtasks ?? []); });
78
+ plan.phases.forEach((phase) => visit(phase.tasks));
79
+ return result;
80
+ };
81
+ const beforeTasks = flatten(previous);
82
+ const afterTasks = flatten(next);
83
+ const beforePhases = new Map(previous.phases.map((phase) => [phase.id, phase.title]));
84
+ const afterPhases = new Map(next.phases.map((phase) => [phase.id, phase.title]));
85
+ const beforeGates = new Map(previous.gates.map((gate) => [gate.id, gate.label]));
86
+ const afterGates = new Map(next.gates.map((gate) => [gate.id, gate.label]));
87
+ const added = (before, after) => [...after].filter(([id]) => !before.has(id)).map(([, label]) => String(label));
88
+ const removed = (before, after) => [...before].filter(([id]) => !after.has(id)).map(([, label]) => String(label));
89
+ const parts = [];
90
+ const additions = added(beforeTasks, afterTasks);
91
+ const removals = removed(beforeTasks, afterTasks);
92
+ const phaseAdds = added(beforePhases, afterPhases);
93
+ const phaseRemovals = removed(beforePhases, afterPhases);
94
+ const gateAdds = added(beforeGates, afterGates);
95
+ const gateRemovals = removed(beforeGates, afterGates);
96
+ if (additions.length)
97
+ parts.push(`added ${additions.length} task${additions.length === 1 ? "" : "s"}: ${additions.slice(0, 3).join(", ")}`);
98
+ if (removals.length)
99
+ parts.push(`removed ${removals.length} task${removals.length === 1 ? "" : "s"}: ${removals.slice(0, 3).join(", ")}`);
100
+ if (phaseAdds.length)
101
+ parts.push(`added phase${phaseAdds.length === 1 ? "" : "s"}: ${phaseAdds.slice(0, 2).join(", ")}`);
102
+ if (phaseRemovals.length)
103
+ parts.push(`removed phase${phaseRemovals.length === 1 ? "" : "s"}: ${phaseRemovals.slice(0, 2).join(", ")}`);
104
+ if (gateAdds.length)
105
+ parts.push(`added gate${gateAdds.length === 1 ? "" : "s"}: ${gateAdds.slice(0, 2).join(", ")}`);
106
+ if (gateRemovals.length)
107
+ parts.push(`removed gate${gateRemovals.length === 1 ? "" : "s"}: ${gateRemovals.slice(0, 2).join(", ")}`);
108
+ return parts.length ? parts.join("; ") : undefined;
109
+ }
110
+ /**
111
+ * The prompt an agent actually receives for a message carrying images.
112
+ *
113
+ * Named paths rather than an opaque "see attachment": the agent has to be able
114
+ * to open them, and an absolute path is the one instruction that works whether
115
+ * the turn runs in the repository, a worktree, or somewhere else entirely.
116
+ */
117
+ export function promptWithAttachments(text, attached) {
118
+ if (!attached.length)
119
+ return text;
120
+ const list = attached.map((item) => `- ${item.path}`).join("\n");
121
+ const preamble = attached.length === 1 ? "The user attached an image" : `The user attached ${attached.length} images`;
122
+ return `${text ? `${text}\n\n` : ""}${preamble}. Read ${attached.length === 1 ? "it" : "them"} before replying:\n${list}`;
123
+ }
124
+ /** Returns the mission send path, so the loop scheduler drives turns exactly as a person's message does. */
22
125
  export function registerRest(app, deps) {
23
- const { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex } = deps;
126
+ const { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex, workerIdentity, terminalRuntime, loops } = deps;
127
+ const terminalFeatures = terminalFeatureFlags();
128
+ const attachments = new AttachmentStore();
24
129
  const missionSendTails = new Map();
130
+ app.get("/api/mission/:missionId/review-diff", async (req, reply) => {
131
+ const mission = missions.get(req.params.missionId);
132
+ if (!mission) {
133
+ reply.code(404);
134
+ return { error: "mission not found" };
135
+ }
136
+ const worktree = missionWorktree(req.params.missionId);
137
+ return reviewDiff(worktree.path ?? mission.repository);
138
+ });
25
139
  function latestMissionTarget(missionId) {
26
140
  const candidates = [...jobsWatcher.getAll()].filter(([jobId]) => missions.missionFor(jobId) === missionId);
27
141
  candidates.sort((a, b) => Date.parse(b[1].createdAt ?? "") - Date.parse(a[1].createdAt ?? ""));
@@ -83,11 +197,51 @@ export function registerRest(app, deps) {
83
197
  missionSendTails.delete(missionId);
84
198
  }
85
199
  }
200
+ /**
201
+ * Leaves the mission with at most one manager able to act: the one whose
202
+ * session is about to be resumed.
203
+ *
204
+ * A resume forks. The session it resumes keeps running, keeps its armed
205
+ * monitors and scheduled wake-ups, and keeps acting on a mission nobody is
206
+ * addressing any more — while Hive repoints the mission at the fork. Left
207
+ * unchecked that compounds one message at a time: mission f7ae71c7 reached
208
+ * five live managers against a single worktree and merge request, each
209
+ * re-closing the same gate, each spawning its own worker for the same plan
210
+ * task, and each reading the others' commits as work it never asked for.
211
+ *
212
+ * Failures are recorded and swallowed. A manager that will not stop is worth
213
+ * saying out loud, but it must not cost the user the message they sent.
214
+ */
215
+ async function quiesceMission(missionId, targetJobId) {
216
+ const provider = missions.summaryFor(missionId).provider ?? "claude";
217
+ const stopping = jobsToQuiesce({
218
+ jobs: [...jobsWatcher.getAll()].map(([jobId, job]) => ({ jobId, job, missionId: missions.missionFor(jobId) })),
219
+ sessions: sessionsWatcher.getAll().values(),
220
+ missionId,
221
+ targetJobId,
222
+ });
223
+ for (const jobId of stopping) {
224
+ const job = jobsWatcher.getAll().get(jobId);
225
+ const sessionId = job?.sessionId ?? job?.resumeSessionId ?? jobId;
226
+ const superseded = jobId !== targetJobId;
227
+ try {
228
+ if (provider === "codex")
229
+ await codex.stop(jobId);
230
+ else
231
+ await stopSession(jobId);
232
+ events.add({ ts: Date.now(), sessionId, jobId, source: "custom", phase: "custom", activityKind: "lifecycle", detail: superseded ? `Stopped superseded manager ${jobId.slice(0, 8)} before resuming: only one job may act on a mission.` : `Ended the parked turn on ${jobId.slice(0, 8)} so this message continues the conversation instead of branching it.` });
233
+ }
234
+ catch (error) {
235
+ events.add({ ts: Date.now(), sessionId, jobId, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Could not stop ${superseded ? "superseded" : "parked"} manager ${jobId.slice(0, 8)}: ${error.message}` });
236
+ }
237
+ }
238
+ }
86
239
  async function sendToMission(missionId, text) {
87
240
  return withMissionQueue(missionId, async () => {
88
241
  const target = latestMissionTarget(missionId);
89
242
  if (!target)
90
243
  throw new Error("mission not found");
244
+ await quiesceMission(missionId, target.jobId);
91
245
  const summary = missions.summaryFor(missionId);
92
246
  const policy = summary.policy ?? policies.get();
93
247
  const provider = summary.provider ?? "claude";
@@ -103,7 +257,10 @@ export function registerRest(app, deps) {
103
257
  // provider to resume. Fast subagents can start and finish while the CLI
104
258
  // launch call is still pending; reopening afterward leaves their FIFO
105
259
  // stories assigned to a room with no worker desks.
106
- const reopening = ["ready_for_review", "completed", "archived", "failed", "paused"].includes(summary.lifecycleStatus);
260
+ // "ready_for_review" is deliberately absent: a question asked of finished
261
+ // work is not a reason to un-finish it. That mission reopens only if the
262
+ // turn actually does something — see reopenOnWork.
263
+ const reopening = ["completed", "archived", "failed", "paused"].includes(summary.lifecycleStatus);
107
264
  if (reopening)
108
265
  missions.setLifecycleStatus(missionId, "active");
109
266
  // A follow-up implicitly reopens a closed mission, but reaching a
@@ -135,9 +292,9 @@ export function registerRest(app, deps) {
135
292
  return result;
136
293
  });
137
294
  }
138
- app.get("/api/fleet", async () => buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans));
295
+ app.get("/api/fleet", async () => buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans, Date.now(), workerIdentity));
139
296
  app.post("/api/direct-studio/clear-completed", async () => {
140
- const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans);
297
+ const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans, Date.now(), workerIdentity);
141
298
  const eligible = snapshot.orchestrators.filter(canClearDirectStudioMission);
142
299
  for (const node of eligible) {
143
300
  missions.setLifecycleStatus(node.missionId, "completed");
@@ -148,6 +305,30 @@ export function registerRest(app, deps) {
148
305
  }
149
306
  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
307
  });
308
+ app.post("/api/review-hall/accept-clear", async () => {
309
+ const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans, Date.now(), workerIdentity);
310
+ const pending = new Set((snapshot.decisions ?? []).map((decision) => decision.missionId));
311
+ const seated = snapshot.orchestrators.filter((node) => node.lifecycleStatus === "ready_for_review");
312
+ const eligible = seated.filter((node) => canAcceptReviewMission(node, pending.has(node.missionId)));
313
+ for (const node of eligible) {
314
+ missions.setLifecycleStatus(node.missionId, "completed");
315
+ 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" });
316
+ // Acceptance reclaims on clean-only terms, so a worktree holding
317
+ // uncommitted work is preserved rather than discarded in bulk.
318
+ reclaimMissionWorktree(node.missionId, "completed", { jobId: node.jobId, sessionId: node.sessionId });
319
+ }
320
+ const accepted = new Set(eligible.map((node) => node.missionId));
321
+ return {
322
+ ok: true,
323
+ accepted: eligible.map((node) => node.missionId),
324
+ // Everything left seated, with the reason it still needs a human.
325
+ skipped: seated.filter((node) => !accepted.has(node.missionId)).map((node) => ({
326
+ missionId: node.missionId,
327
+ name: node.name,
328
+ reason: reviewAcceptanceBlocker(node, pending.has(node.missionId)) ?? "needs review",
329
+ })),
330
+ };
331
+ });
151
332
  app.get("/api/policies", async () => ({ policy: policies.get() }));
152
333
  app.get("/api/providers/models", async () => ({ providers: detectProviderModels() }));
153
334
  app.put("/api/policies", async (req, reply) => {
@@ -198,7 +379,7 @@ export function registerRest(app, deps) {
198
379
  return { error: err.message };
199
380
  } });
200
381
  app.get("/api/mission/:missionId/report", async (req, reply) => {
201
- const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans);
382
+ const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans, Date.now(), workerIdentity);
202
383
  const node = snapshot.orchestrators.find((item) => item.missionId === req.params.missionId);
203
384
  if (!node) {
204
385
  reply.code(404);
@@ -219,7 +400,7 @@ export function registerRest(app, deps) {
219
400
  reply.code(400);
220
401
  return { error: "choose a GitHub issue or pull request" };
221
402
  }
222
- const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans);
403
+ const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans, Date.now(), workerIdentity);
223
404
  const node = snapshot.orchestrators.find((item) => item.missionId === req.params.missionId);
224
405
  if (!node?.mission.repository) {
225
406
  reply.code(404);
@@ -239,9 +420,141 @@ export function registerRest(app, deps) {
239
420
  return { error: err.message };
240
421
  }
241
422
  });
423
+ /**
424
+ * The mission's transcripts, as the terminal renders them.
425
+ *
426
+ * The hook-derived timeline above says that a tool ran; this says what it
427
+ * ran, what came back, and what the agent said about it. Both are kept: the
428
+ * timeline is the skimmable index, this is the record.
429
+ *
430
+ * `source` is `orchestrator` (the default) or a worker's native agent id.
431
+ * Every session the mission has ever held is searched, so a transcript from
432
+ * a resumed turn is still reachable.
433
+ */
434
+ app.get("/api/mission/:missionId/transcript", async (req, reply) => {
435
+ const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans, Date.now(), workerIdentity);
436
+ const node = snapshot.orchestrators.find((item) => item.missionId === req.params.missionId);
437
+ if (!node) {
438
+ reply.code(404);
439
+ return { error: "mission not found" };
440
+ }
441
+ const sessionIds = [...new Set([
442
+ node.sessionId,
443
+ ...node.jobHistory.flatMap((entry) => [entry.sessionId, entry.resumedFrom].filter(Boolean)),
444
+ ])];
445
+ // A worker's roster label is the name already on screen everywhere else;
446
+ // matching on aliases too covers a worker the provider renamed.
447
+ const labelFor = (agentId) => node.workers.find((worker) => worker.id === agentId || worker.aliases?.includes(agentId))?.label;
448
+ const sources = transcriptSources({ sessionIds, labelFor }).map((source) => ({
449
+ ...source,
450
+ terminal: terminalCapability(source, node.mission.provider ?? "claude", node.lifecycleStatus, undefined, node.activityStatus === "working", terminalFeatures),
451
+ }));
452
+ const requested = req.query.source?.trim() || "orchestrator";
453
+ const source = sources.find((item) => item.id === requested) ?? sources[0];
454
+ if (!source)
455
+ return { sources, source: undefined, entries: [], truncated: false };
456
+ const file = source.kind === "orchestrator"
457
+ ? orchestratorTranscriptPath(source.sessionId)
458
+ : workerTranscriptPath(source.sessionId, source.id);
459
+ if (!file)
460
+ return { sources, source: source.id, entries: [], truncated: false };
461
+ const limit = Math.min(1_000, Math.max(20, Number(req.query.limit) || 400));
462
+ const read = readTranscript(file, limit);
463
+ return { sources, source: source.id, entries: read.entries, truncated: read.truncated, updatedAt: read.updatedAt };
464
+ });
242
465
  app.get("/api/session/:sessionId/timeline", async (req) => {
243
466
  return { events: events.recentFor(req.params.sessionId, 200) };
244
467
  });
468
+ app.get("/api/mission/:missionId/terminal/events", async (req, reply) => {
469
+ if (!missions.get(req.params.missionId)) {
470
+ reply.code(404);
471
+ return { error: "mission not found" };
472
+ }
473
+ const source = req.query.source?.trim() || "orchestrator";
474
+ const after = Math.max(0, Number(req.query.after) || 0);
475
+ return terminalRuntime.stream.replay(req.params.missionId, source, after);
476
+ });
477
+ app.post("/api/mission/:missionId/terminal/input", async (req, reply) => {
478
+ const source = req.body?.source?.trim() || "orchestrator";
479
+ const text = req.body?.text?.trim();
480
+ const clientInputId = req.body?.clientInputId?.trim();
481
+ const auditReject = (status, code, error, provider = "unknown") => {
482
+ terminalRuntime.observability.input({ missionId: req.params.missionId, source, provider, inputCharacters: req.body?.text?.length ?? 0, outcome: "rejected", reason: code });
483
+ reply.code(status);
484
+ return { error, code };
485
+ };
486
+ if (source !== "orchestrator")
487
+ return auditReject(409, "terminal_read_only", "Worker terminal sources are read only");
488
+ if (!text)
489
+ return auditReject(400, "invalid_terminal_input", "text is required");
490
+ if (text.length > 100_000)
491
+ return auditReject(413, "terminal_input_too_large", "terminal input exceeds 100000 characters");
492
+ if (!clientInputId || clientInputId.length > 128)
493
+ return auditReject(400, "invalid_client_input_id", "a clientInputId of at most 128 characters is required");
494
+ const mission = missions.get(req.params.missionId);
495
+ if (!mission)
496
+ return auditReject(404, "mission_not_found", "mission not found");
497
+ const summary = missions.summaryFor(req.params.missionId);
498
+ if ((summary.provider ?? "claude") !== "codex")
499
+ return auditReject(409, "terminal_follow_up_only", "This provider currently supports follow-up input, not interactive terminal input", "claude");
500
+ if (!terminalFeatures.codexInput)
501
+ return auditReject(409, "terminal_input_disabled", "Writable Codex Terminal input is disabled", "codex");
502
+ const detection = providerTerminalCatalog().codex;
503
+ if (detection.interactiveTransport !== "codex_app_server")
504
+ return auditReject(409, "terminal_transport_unavailable", detection.reason ?? "The installed Codex CLI does not support interactive terminal input", "codex");
505
+ const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans, Date.now(), workerIdentity);
506
+ const node = snapshot.orchestrators.find((item) => item.missionId === req.params.missionId);
507
+ if (node?.activityStatus === "working")
508
+ return auditReject(409, "terminal_turn_active", "The current provider turn is still running", "codex");
509
+ if (summary.lifecycleStatus === "completed" || summary.lifecycleStatus === "archived")
510
+ return auditReject(409, "terminal_read_only", "Finished missions are read only until reopened", "codex");
511
+ const target = latestMissionTarget(req.params.missionId);
512
+ if (!target)
513
+ return auditReject(409, "terminal_thread_unavailable", "No provider thread is available for this mission", "codex");
514
+ const cwd = resumeRepository(req.params.missionId, summary.repository);
515
+ if (!cwd)
516
+ return auditReject(409, "terminal_cwd_unavailable", "No working directory is available for this mission", "codex");
517
+ try {
518
+ return await terminalRuntime.submitCodex({ missionId: req.params.missionId, source: "orchestrator", threadId: target.sessionId, cwd, text, clientInputId, model: summary.model, policy: summary.policy ?? policies.get() });
519
+ }
520
+ catch (error) {
521
+ reply.code(409);
522
+ return { error: error.message, code: "terminal_input_failed" };
523
+ }
524
+ });
525
+ app.post("/api/mission/:missionId/terminal/interrupt", async (req, reply) => {
526
+ if (!missions.get(req.params.missionId)) {
527
+ reply.code(404);
528
+ return { error: "mission not found", code: "mission_not_found" };
529
+ }
530
+ try {
531
+ await terminalRuntime.interrupt(req.params.missionId);
532
+ return { ok: true };
533
+ }
534
+ catch (error) {
535
+ reply.code(409);
536
+ return { error: error.message, code: "terminal_interrupt_unavailable" };
537
+ }
538
+ });
539
+ app.get("/api/terminal/status", async () => ({ features: terminalFeatures, observability: terminalRuntime.observability.snapshot() }));
540
+ /**
541
+ * Finished work that starts working again is no longer finished.
542
+ *
543
+ * The mission stays awaiting acceptance while it answers a question; the
544
+ * moment it writes something or starts a worker, it is active again and the
545
+ * board should say so without the user having to notice.
546
+ */
547
+ function reopenIfWorking(event, jobId) {
548
+ if (!isWorkEvent(event))
549
+ return;
550
+ const missionId = missions.missionFor(jobId);
551
+ const mission = missions.get(missionId);
552
+ if (!mission || !reopensOnWork(mission.lifecycleStatus))
553
+ return;
554
+ missions.setLifecycleStatus(missionId, "active");
555
+ missions.updateContext(missionId, { currentState: "Working again after a follow-up" });
556
+ 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" });
557
+ }
245
558
  app.post("/hooks/:eventName", async (req, reply) => {
246
559
  const payload = {
247
560
  ...req.body,
@@ -256,6 +569,8 @@ export function registerRest(app, deps) {
256
569
  events.add(event);
257
570
  for (const promptEvent of skillPromptEvents(payload, jobId))
258
571
  events.add(promptEvent);
572
+ if (jobId)
573
+ reopenIfWorking(event, jobId);
259
574
  // A write landing in another known repo marks that repo as touched by
260
575
  // the mission, so its footprint chips reflect reality, not just intent.
261
576
  if (event.activityKind === "file_write" && event.filePath && jobId) {
@@ -277,6 +592,7 @@ export function registerRest(app, deps) {
277
592
  }
278
593
  const effectiveJobId = jobId ?? sessionForId(sessionsWatcher, sessionId)?.jobId;
279
594
  let eventDetail = detail;
595
+ let derivedPlanMilestones = [];
280
596
  if (phase === "plan_updated") {
281
597
  if (!effectiveJobId) {
282
598
  reply.code(404);
@@ -288,8 +604,11 @@ export function registerRest(app, deps) {
288
604
  return { error: "mission not found for plan update" };
289
605
  }
290
606
  try {
607
+ const previousPlan = plans.get(missionId);
291
608
  const plan = plans.replace(missionId, JSON.parse(detail));
292
- eventDetail = `Plan updated to revision ${plan.revision}: ${plan.phases.length} phase${plan.phases.length === 1 ? "" : "s"}, ${plan.progress.totalTasks} task${plan.progress.totalTasks === 1 ? "" : "s"}`;
609
+ derivedPlanMilestones = planMilestones(previousPlan, plan);
610
+ const revisionSummary = planRevisionSummary(previousPlan, plan);
611
+ eventDetail = `Plan updated to revision ${plan.revision}: ${plan.phases.length} phase${plan.phases.length === 1 ? "" : "s"}, ${plan.progress.totalTasks} task${plan.progress.totalTasks === 1 ? "" : "s"}${revisionSummary ? ` · ${revisionSummary}` : ""}`;
293
612
  const current = plan.phases.find((item) => ["working", "reviewing", "blocked"].includes(item.status));
294
613
  const blocked = plan.phases.flatMap((item) => allPlanTasks(item.tasks)).filter((task) => task.status === "blocked").map((task) => task.title);
295
614
  missions.updateContext(missionId, { compactSummary: `Plan revision ${plan.revision}; ${plan.progress.completedTasks}/${plan.progress.totalTasks} tasks complete${plan.progress.percent !== undefined ? ` (${plan.progress.percent}%)` : ""}. ${current ? `Current phase: ${current.title}.` : ""}${blocked.length ? ` Blocked: ${blocked.slice(0, 3).join(", ")}.` : ""}`.slice(0, 1_200), currentState: eventDetail });
@@ -326,6 +645,17 @@ export function registerRest(app, deps) {
326
645
  impact: phase === "blocked_on_user" ? decision?.impact : undefined,
327
646
  context: phase === "blocked_on_user" ? decision?.context : undefined,
328
647
  });
648
+ for (const [index, milestone] of derivedPlanMilestones.entries())
649
+ events.add({
650
+ ts: Date.now() + index + 1,
651
+ sessionId,
652
+ jobId: effectiveJobId,
653
+ source: "custom",
654
+ activityKind: "lifecycle",
655
+ ...milestone,
656
+ });
657
+ if (effectiveJobId && phase !== "ready_for_review")
658
+ reopenIfWorking({ sessionId, source: "custom", phase }, effectiveJobId);
329
659
  if (phase === "ready_for_review" && effectiveJobId) {
330
660
  const missionId = missions.missionFor(effectiveJobId);
331
661
  if (missions.get(missionId)) {
@@ -338,10 +668,15 @@ export function registerRest(app, deps) {
338
668
  if (missions.get(missionId)) {
339
669
  // Best-effort: keeps plan task statuses truthful when the
340
670
  // orchestrator emits activity without republishing the plan.
341
- try {
342
- plans.applyTaskEvent(missionId, { phase, targetTask, targetWorker, evidence: phase === "worker_reported" ? detail : undefined });
671
+ // One emit may name several workers (`--target a,b,c`); the task moves for each.
672
+ const targets = targetWorker && /[,;]/.test(targetWorker) ? [...new Set(targetWorker.split(/[,;]/).map((value) => value.trim()).filter(Boolean))] : [targetWorker];
673
+ for (const target of targets) {
674
+ const canonicalWorker = workerIdentity.canonicalFor(missionId, target);
675
+ try {
676
+ plans.applyTaskEvent(missionId, { phase, targetTask, targetWorker: target, canonicalWorker, evidence: phase === "worker_reported" ? detail : undefined });
677
+ }
678
+ catch { /* a later plan_updated remains the source of truth */ }
343
679
  }
344
- catch { /* a later plan_updated remains the source of truth */ }
345
680
  }
346
681
  }
347
682
  return { ok: true };
@@ -569,6 +904,87 @@ Revise the structured plan to remove that requirement and stop work that was nee
569
904
  return { error: err.message };
570
905
  }
571
906
  });
907
+ // Base64 in a JSON body rather than multipart: a pasted screenshot arrives
908
+ // as bytes in the browser either way, and this needs no new dependency.
909
+ app.post("/api/mission/:missionId/attachment", { bodyLimit: Math.ceil(MAX_ATTACHMENT_BYTES * 1.4) }, async (req, reply) => {
910
+ const { missionId } = req.params;
911
+ if (!missions.get(missionId)) {
912
+ reply.code(404);
913
+ return { error: "mission not found" };
914
+ }
915
+ const mediaType = req.body?.mediaType ?? "";
916
+ if (!isSupportedMediaType(mediaType)) {
917
+ reply.code(415);
918
+ return { error: `unsupported image type: ${mediaType || "none given"}` };
919
+ }
920
+ if (!req.body?.data) {
921
+ reply.code(400);
922
+ return { error: "data is required" };
923
+ }
924
+ try {
925
+ return { attachment: attachments.save(missionId, { name: req.body.name, mediaType, data: Buffer.from(req.body.data, "base64") }) };
926
+ }
927
+ catch (err) {
928
+ reply.code(400);
929
+ return { error: err.message };
930
+ }
931
+ });
932
+ app.get("/api/mission/:missionId/attachment/:attachmentId", async (req, reply) => {
933
+ const found = attachments.read(req.params.missionId, req.params.attachmentId);
934
+ if (!found) {
935
+ reply.code(404);
936
+ return { error: "attachment not found" };
937
+ }
938
+ reply.header("Content-Type", found.mediaType);
939
+ reply.header("Cache-Control", "private, max-age=31536000, immutable");
940
+ return reply.send(fs.createReadStream(found.file));
941
+ });
942
+ /** Everything currently running on a schedule or watching in the background. */
943
+ app.get("/api/tasks", async () => ({
944
+ loops: loops.active().map((loop) => ({ ...loop, missionName: missions.get(loop.missionId)?.name })),
945
+ monitors: activeMonitors(jobsWatcher.getAll(), (jobId) => missions.missionFor(jobId), (missionId) => missions.get(missionId)?.name ?? missionId),
946
+ }));
947
+ app.post("/api/loop/:loopId/stop", async (req, reply) => {
948
+ if (!loops.stop(req.params.loopId, "you stopped it")) {
949
+ reply.code(404);
950
+ return { error: "no active loop with that id" };
951
+ }
952
+ return { ok: true };
953
+ });
954
+ app.post("/api/mission/:missionId/loops/stop", async (req, reply) => {
955
+ if (!missions.get(req.params.missionId)) {
956
+ reply.code(404);
957
+ return { error: "mission not found" };
958
+ }
959
+ return { ok: true, stopped: loops.stopMission(req.params.missionId, "you stopped it") };
960
+ });
961
+ /**
962
+ * Ends the turn that owns a mission's monitors. Hive did not start them and
963
+ * cannot address one individually, so stopping the turn is the honest lever.
964
+ */
965
+ app.post("/api/mission/:missionId/monitors/stop", async (req, reply) => {
966
+ if (!missions.get(req.params.missionId)) {
967
+ reply.code(404);
968
+ return { error: "mission not found" };
969
+ }
970
+ try {
971
+ await quiesceMission(req.params.missionId);
972
+ return { ok: true };
973
+ }
974
+ catch (err) {
975
+ reply.code(500);
976
+ return { error: err.message };
977
+ }
978
+ });
979
+ app.post("/api/agents/discover", async (req, reply) => {
980
+ try {
981
+ return { agents: discoverAgents(req.body?.cwd) };
982
+ }
983
+ catch (err) {
984
+ reply.code(400);
985
+ return { error: err.message };
986
+ }
987
+ });
572
988
  app.post("/api/session/:sessionId/adopt", async (req, reply) => {
573
989
  const session = [...sessionsWatcher.getAll().values()].find((candidate) => candidate.sessionId === req.params.sessionId);
574
990
  if (!session) {
@@ -611,10 +1027,15 @@ Revise the structured plan to remove that requirement and stop work that was nee
611
1027
  reply.code(400);
612
1028
  return { error: "text is required" };
613
1029
  }
614
- const result = await sendMessage(session.sessionId, req.body.text);
615
1030
  const missionId = session.jobId
616
1031
  ? missions.missionFor(session.jobId)
617
1032
  : session.sessionId;
1033
+ // Messaging a session that belongs to a mission is the same fork as
1034
+ // messaging the mission, through a second door. Only a session Hive does
1035
+ // not manage as a mission is resumed without quiescing.
1036
+ if (missions.get(missionId))
1037
+ await quiesceMission(missionId, session.jobId);
1038
+ const result = await sendMessage(session.sessionId, req.body.text);
618
1039
  missions.linkJob(missionId, result.jobId, result.sessionId);
619
1040
  messages.add({
620
1041
  id: `${result.jobId}:user`,
@@ -628,10 +1049,83 @@ Revise the structured plan to remove that requirement and stop work that was nee
628
1049
  });
629
1050
  app.post("/api/mission/:missionId/message", async (req, reply) => {
630
1051
  const text = req.body?.text?.trim();
631
- if (!text) {
1052
+ const attached = (req.body?.attachmentIds ?? [])
1053
+ .map((id) => attachments.describe(req.params.missionId, id))
1054
+ .filter((item) => Boolean(item));
1055
+ if (!text && !attached.length) {
632
1056
  reply.code(400);
633
1057
  return { error: "text is required" };
634
1058
  }
1059
+ // `/tasks` answers from Hive's own books rather than the agent's, which
1060
+ // is the only place the whole fleet's loops and monitors are visible.
1061
+ if (text && /^\/tasks\s*$/i.test(text.trim())) {
1062
+ const missionId = req.params.missionId;
1063
+ if (!missions.get(missionId)) {
1064
+ reply.code(404);
1065
+ return { error: "mission not found" };
1066
+ }
1067
+ const jobRef = `tasks:${randomUUID()}`;
1068
+ const running = loops.active();
1069
+ const monitors = activeMonitors(jobsWatcher.getAll(), (jobId) => missions.missionFor(jobId), (id) => missions.get(id)?.name ?? id);
1070
+ const here = running.filter((loop) => loop.missionId === missionId);
1071
+ const elsewhere = running.filter((loop) => loop.missionId !== missionId);
1072
+ const lines = [
1073
+ here.length
1074
+ ? `**Loops on this mission**\n${here.map((loop) => `• \`${loop.id}\` every ${formatInterval(loop.intervalMs)} — ${loop.prompt}`).join("\n")}`
1075
+ : "**Loops on this mission**\nNone.",
1076
+ elsewhere.length ? `\n**Loops elsewhere**\n${elsewhere.map((loop) => `• \`${loop.id}\` ${missions.get(loop.missionId)?.name ?? loop.missionId} — every ${formatInterval(loop.intervalMs)}`).join("\n")}` : "",
1077
+ monitors.length
1078
+ ? `\n**Monitors**\n${monitors.map((monitor) => `• ${monitor.label} — ${monitor.missionName}${monitor.missionId === missionId ? " (here)" : ""}`).join("\n")}`
1079
+ : "\n**Monitors**\nNone running.",
1080
+ "\nStop a loop with `/loop stop <id>`. Monitors have no per-watcher stop — ending the turn from the Tasks panel stops all of that turn's watchers together.",
1081
+ ];
1082
+ messages.add({ id: `${jobRef}:user`, missionId, role: "user", text, createdAt: Date.now(), jobId: jobRef });
1083
+ messages.add({ id: `${jobRef}:assistant`, missionId, role: "assistant", text: lines.filter(Boolean).join("\n"), createdAt: Date.now() + 1, jobId: jobRef });
1084
+ return { ok: true, jobId: jobRef };
1085
+ }
1086
+ // `/loop` is Hive's, not the agent's: a loop the agent starts internally
1087
+ // cannot be listed or stopped from here, and this one can.
1088
+ const loopCommand = text ? parseLoopCommand(text) : undefined;
1089
+ if (loopCommand) {
1090
+ const missionId = req.params.missionId;
1091
+ if (!missions.get(missionId)) {
1092
+ reply.code(404);
1093
+ return { error: "mission not found" };
1094
+ }
1095
+ const jobRef = `loop:${randomUUID()}`;
1096
+ const say = (body) => {
1097
+ messages.add({ id: `${jobRef}:user`, missionId, role: "user", text, createdAt: Date.now(), jobId: jobRef });
1098
+ messages.add({ id: `${jobRef}:assistant`, missionId, role: "assistant", text: body, createdAt: Date.now() + 1, jobId: jobRef });
1099
+ };
1100
+ if (loopCommand.kind === "error") {
1101
+ say(`⚠️ ${loopCommand.message}`);
1102
+ return { ok: true, jobId: jobRef };
1103
+ }
1104
+ if (loopCommand.kind === "list") {
1105
+ const running = loops.active(missionId);
1106
+ say(running.length
1107
+ ? `Running on this mission:\n${running.map((loop) => `• \`${loop.id}\` every ${formatInterval(loop.intervalMs)} — ${loop.prompt}`).join("\n")}\n\nStop one with \`/loop stop <id>\`, or all with \`/loop stop\`.`
1108
+ : "No loops are running on this mission. Start one with `/loop 10m check for new PR comments`.");
1109
+ return { ok: true, jobId: jobRef };
1110
+ }
1111
+ if (loopCommand.kind === "stop") {
1112
+ const stopped = loopCommand.loopId
1113
+ ? (loops.stop(loopCommand.loopId, "you stopped it") ? 1 : 0)
1114
+ : loops.stopMission(missionId, "you stopped it");
1115
+ say(stopped
1116
+ ? `Stopped ${stopped} loop${stopped === 1 ? "" : "s"}.`
1117
+ : loopCommand.loopId ? `No active loop with id \`${loopCommand.loopId}\`.` : "No loops were running on this mission.");
1118
+ return { ok: true, jobId: jobRef };
1119
+ }
1120
+ const created = loops.create({ missionId, prompt: loopCommand.prompt, intervalMs: loopCommand.intervalMs });
1121
+ say(`Looping every ${formatInterval(created.intervalMs)}: ${created.prompt}\n\nFirst run at ${new Date(created.nextRunAt).toLocaleTimeString()}. Stop it with \`/loop stop ${created.id}\`, or from the Tasks panel.`);
1122
+ // The loop's own prompt runs now too, so the first pass is not a wait.
1123
+ try {
1124
+ await sendToMission(missionId, loopCommand.prompt);
1125
+ }
1126
+ catch { /* the schedule carries it from here */ }
1127
+ return { ok: true, jobId: jobRef };
1128
+ }
635
1129
  const native = matchNativeCommand(text);
636
1130
  if (native) {
637
1131
  const missionId = req.params.missionId;
@@ -651,7 +1145,13 @@ Revise the structured plan to remove that requirement and stop work that was nee
651
1145
  return { ok: true, jobId: jobRef };
652
1146
  }
653
1147
  try {
654
- const result = await sendToMission(req.params.missionId, text);
1148
+ // The model never receives bytes: the images are on the Hive host's
1149
+ // disk and the agent opens them with its own Read tool, which behaves
1150
+ // the same for every provider.
1151
+ const result = await sendToMission(req.params.missionId, promptWithAttachments(text, attached));
1152
+ if (attached.length) {
1153
+ messages.add({ id: `${result.jobId}:user`, missionId: req.params.missionId, role: "user", text, createdAt: Date.now(), jobId: result.jobId, attachments: attached });
1154
+ }
655
1155
  return { ok: true, jobId: result.jobId };
656
1156
  }
657
1157
  catch (err) {
@@ -706,7 +1206,9 @@ Revise the structured plan to remove that requirement and stop work that was nee
706
1206
  const ask = parsePermissionAsk(job.needs);
707
1207
  const askLabel = ask ? `${ask.tool}: ${ask.detail}`.slice(0, 300) : job.needs.slice(0, 300);
708
1208
  try {
709
- await stopSession(jobId);
1209
+ // Ends the parked turn, and any other manager still live on the
1210
+ // mission with it — the verdict below resumes one conversation.
1211
+ await quiesceMission(req.params.missionId, jobId);
710
1212
  const summary = missions.summaryFor(req.params.missionId);
711
1213
  const policy = summary.policy ?? policies.get();
712
1214
  const grant = approved ? permissionGrant(job.needs) : undefined;
@@ -741,6 +1243,21 @@ Revise the structured plan to remove that requirement and stop work that was nee
741
1243
  return { error: err.message };
742
1244
  }
743
1245
  }
1246
+ // An ask the agent wrote in its own reply (decision id ask:<jobId>) has
1247
+ // no blocked_on_user event behind it — the turn simply ended with a
1248
+ // question. Answering it is just replying, and the resolution event is
1249
+ // what clears it from the inbox.
1250
+ if (req.params.decisionId.startsWith("ask:")) {
1251
+ try {
1252
+ const result = await sendToMission(req.params.missionId, answer);
1253
+ events.add({ ts: Date.now(), sessionId: result.sessionId ?? latestMissionTarget(req.params.missionId)?.sessionId ?? req.params.decisionId, jobId: result.jobId, source: "custom", phase: "decision_resolved", activityKind: "decision", detail: answer, decisionId: req.params.decisionId });
1254
+ return { ok: true };
1255
+ }
1256
+ catch (err) {
1257
+ reply.code(500);
1258
+ return { error: err.message };
1259
+ }
1260
+ }
744
1261
  const nodeEvents = [...jobsWatcher.getAll()].filter(([jobId]) => missions.missionFor(jobId) === req.params.missionId).flatMap(([, job]) => [job.sessionId, job.resumeSessionId].filter((value) => Boolean(value))).flatMap((sessionId) => events.recentFor(sessionId, 200));
745
1262
  const pending = nodeEvents.find((event) => event.phase === "blocked_on_user" && event.decisionId === req.params.decisionId);
746
1263
  const alreadyResolved = nodeEvents.some((event) => event.phase === "decision_resolved" && event.decisionId === req.params.decisionId);
@@ -801,14 +1318,11 @@ Revise the structured plan to remove that requirement and stop work that was nee
801
1318
  return await withMissionQueue(req.params.missionId, async () => {
802
1319
  const recent = messages.modelContext(req.params.missionId);
803
1320
  const retrieved = messages.relevantContext(req.params.missionId, `${mission.objective} ${mission.compactSummary ?? ""} ${mission.currentState ?? ""}`, new Set(recent.map((message) => message.id)));
804
- const target = latestMissionTarget(req.params.missionId);
805
- const currentJob = target ? jobsWatcher.getAll().get(target.jobId) : undefined;
806
- if (target && currentJob && ["working", "busy", "queued"].includes(currentJob.state)) {
807
- if (mission.provider === "codex")
808
- await codex.stop(target.jobId);
809
- else
810
- await stopSession(target.jobId);
811
- }
1321
+ // The replacement manager is about to take the mission over, so every
1322
+ // manager already on it stands down — not just the one mid-turn. A
1323
+ // handoff that left the old job running would be the same collision
1324
+ // the resume path had, with the two managers a turn further apart.
1325
+ await quiesceMission(req.params.missionId);
812
1326
  const context = { objective: mission.objective, compactSummary: mission.compactSummary, currentState: mission.currentState, recentMessages: recent.map(({ role, text }) => ({ role, text })), relevantOlderMessages: retrieved.map(({ role, text }) => ({ role, text })), budgets: mission.budgets };
813
1327
  const prompt = `You are taking over an existing Hive mission from another manager. Continue the same mission without replaying completed work. Inspect current repository state, reconcile this bounded handoff context with reality, publish a fresh structured plan revision, and ask the user only when a genuine decision is required.\n\nHandoff context:\n${JSON.stringify(context)}`;
814
1328
  const result = mission.provider === "codex"
@@ -998,6 +1512,7 @@ Revise the structured plan to remove that requirement and stop work that was nee
998
1512
  events.add({ ts: Date.now(), sessionId: targetSession.sessionId, jobId: targetSession.jobId, source: "custom", phase: "custom", activityKind: "lifecycle", detail: req.body?.force ? "Force stop requested" : "Graceful stop requested" });
999
1513
  return { ok: true };
1000
1514
  });
1515
+ return { sendToMission };
1001
1516
  }
1002
1517
  function sessionForId(watcher, sessionId) {
1003
1518
  for (const session of watcher.getAll().values()) {