@shanesaravia/hive 0.3.0 → 0.5.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 (42) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +16 -1
  3. package/node_modules/@hive/shared/dist/status.js +9 -0
  4. package/node_modules/@hive/shared/dist/types.d.ts +170 -1
  5. package/node_modules/@hive/shared/dist/types.js +35 -0
  6. package/package.json +1 -1
  7. package/packages/server/dist/agents/agentDiscovery.js +64 -0
  8. package/packages/server/dist/api/rest.js +574 -25
  9. package/packages/server/dist/api/ws.js +66 -2
  10. package/packages/server/dist/control/missionQuiesce.js +70 -0
  11. package/packages/server/dist/health/deriveAlerts.js +8 -0
  12. package/packages/server/dist/index.js +26 -2
  13. package/packages/server/dist/loops/loopCommand.js +76 -0
  14. package/packages/server/dist/loops/loopNoop.js +38 -0
  15. package/packages/server/dist/loops/loopScheduler.js +58 -0
  16. package/packages/server/dist/loops/loopStore.js +118 -0
  17. package/packages/server/dist/loops/monitors.js +38 -0
  18. package/packages/server/dist/messages/attachmentStore.js +92 -0
  19. package/packages/server/dist/messages/messagesStore.js +119 -31
  20. package/packages/server/dist/missions/missionsStore.js +1 -1
  21. package/packages/server/dist/missions/reopenOnWork.js +7 -0
  22. package/packages/server/dist/paths/pathResolver.js +112 -17
  23. package/packages/server/dist/plans/plansStore.js +32 -2
  24. package/packages/server/dist/reviews/reviewDiff.js +47 -0
  25. package/packages/server/dist/roster/replyAsk.js +62 -0
  26. package/packages/server/dist/roster/rosterBuilder.js +41 -5
  27. package/packages/server/dist/roster/workerIdentity.js +135 -30
  28. package/packages/server/dist/skills/skillDiscovery.js +28 -4
  29. package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
  30. package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
  31. package/packages/server/dist/terminals/providerDetection.js +27 -0
  32. package/packages/server/dist/terminals/terminalCapability.js +45 -0
  33. package/packages/server/dist/terminals/terminalFeatures.js +11 -0
  34. package/packages/server/dist/terminals/terminalObservability.js +21 -0
  35. package/packages/server/dist/terminals/terminalRuntime.js +125 -0
  36. package/packages/server/dist/terminals/terminalStream.js +30 -0
  37. package/packages/server/dist/transcripts/transcriptReader.js +345 -0
  38. package/packages/web/dist/assets/index-C1T22qkK.css +2 -0
  39. package/packages/web/dist/assets/index-CVc04VBT.js +18 -0
  40. package/packages/web/dist/index.html +2 -2
  41. package/packages/web/dist/assets/index-BpEYVjCF.css +0 -2
  42. package/packages/web/dist/assets/index-rIAIJyuF.js +0 -12
@@ -4,25 +4,141 @@ import { canAcceptReviewMission, canClearDirectStudioMission, reviewAcceptanceBl
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";
8
- import { isWorkEvent, reopensOnWork } from "../missions/reopenOnWork.js";
9
+ import { isWorkEvent, reopensOnWork, returnsToReviewOnStop } from "../missions/reopenOnWork.js";
9
10
  import { startOrchestrator } from "../control/launcher.js";
10
11
  import { matchNativeCommand, runNativeCommand } from "../control/nativeCommands.js";
11
12
  import { sendMessage } from "../control/messaging.js";
13
+ import { jobsToQuiesce, replacedManagerAfterResume } from "../control/missionQuiesce.js";
12
14
  import { parsePermissionAsk, permissionGrant } from "../control/permissionPark.js";
13
15
  import { forceStopSession, stopSession } from "../control/killer.js";
14
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, parseMissionStartLoop } from "../loops/loopCommand.js";
20
+ import { activeMonitors } from "../loops/monitors.js";
15
21
  import { translateSkillInvocations } from "../skills/skillInvocation.js";
16
- import { detectRepositoryMentions, foreignRepositoryForPath, inspectWorkingDirectory, recentRepositories, requireWorkingDirectory, suggestDirectories } from "../paths/pathResolver.js";
22
+ import { detectRepositoryMentions, foreignRepositoryForPath, inspectWorkingDirectory, recentRepositories, requireWorkingDirectory, suggestDirectories, suggestPathEntries, suggestRepositories } from "../paths/pathResolver.js";
17
23
  import { missionReport, reportMarkdown } from "../reports/missionReport.js";
18
24
  import { createTemplate, deleteTemplate, discoverTemplates, discoverTemplatesDetailed, updateTemplate } from "../templates/templateDiscovery.js";
19
25
  import { publishGitHubReport } from "../reports/githubPublisher.js";
20
26
  import { enforceWorkingDirectory, normalize } from "../policies/policiesStore.js";
21
27
  import { detectProviderModels } from "../control/providerModels.js";
22
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
+ * Each attachment's name is the same "[Image #N]" token the user sees inline
117
+ * in their own message text, so this list is a direct lookup from what they
118
+ * already typed to the file the agent needs to read.
119
+ */
120
+ export function promptWithAttachments(text, attached) {
121
+ if (!attached.length)
122
+ return text;
123
+ const list = attached.map((item) => `- [${item.name}]: ${item.path}`).join("\n");
124
+ const preamble = attached.length === 1 ? "The user attached an image" : `The user attached ${attached.length} images`;
125
+ return `${text ? `${text}\n\n` : ""}${preamble}, referenced inline in the message above. Read ${attached.length === 1 ? "it" : "them"} before replying:\n${list}`;
126
+ }
127
+ /** Returns the mission send path, so the loop scheduler drives turns exactly as a person's message does. */
23
128
  export function registerRest(app, deps) {
24
- const { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex, workerIdentity } = deps;
129
+ const { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex, workerIdentity, terminalRuntime, loops } = deps;
130
+ const terminalFeatures = terminalFeatureFlags();
131
+ const attachments = new AttachmentStore();
25
132
  const missionSendTails = new Map();
133
+ app.get("/api/mission/:missionId/review-diff", async (req, reply) => {
134
+ const mission = missions.get(req.params.missionId);
135
+ if (!mission) {
136
+ reply.code(404);
137
+ return { error: "mission not found" };
138
+ }
139
+ const worktree = missionWorktree(req.params.missionId);
140
+ return reviewDiff(worktree.path ?? mission.repository);
141
+ });
26
142
  function latestMissionTarget(missionId) {
27
143
  const candidates = [...jobsWatcher.getAll()].filter(([jobId]) => missions.missionFor(jobId) === missionId);
28
144
  candidates.sort((a, b) => Date.parse(b[1].createdAt ?? "") - Date.parse(a[1].createdAt ?? ""));
@@ -84,11 +200,71 @@ export function registerRest(app, deps) {
84
200
  missionSendTails.delete(missionId);
85
201
  }
86
202
  }
203
+ /**
204
+ * Leaves the mission with at most one manager able to act: the one whose
205
+ * session is about to be resumed.
206
+ *
207
+ * A resume forks. The session it resumes keeps running, keeps its armed
208
+ * monitors and scheduled wake-ups, and keeps acting on a mission nobody is
209
+ * addressing any more — while Hive repoints the mission at the fork. Left
210
+ * unchecked that compounds one message at a time: mission f7ae71c7 reached
211
+ * five live managers against a single worktree and merge request, each
212
+ * re-closing the same gate, each spawning its own worker for the same plan
213
+ * task, and each reading the others' commits as work it never asked for.
214
+ *
215
+ * Failures are recorded and swallowed. A manager that will not stop is worth
216
+ * saying out loud, but it must not cost the user the message they sent.
217
+ */
218
+ async function quiesceMission(missionId, targetJobId) {
219
+ const provider = missions.summaryFor(missionId).provider ?? "claude";
220
+ const stopping = jobsToQuiesce({
221
+ jobs: [...jobsWatcher.getAll()].map(([jobId, job]) => ({ jobId, job, missionId: missions.missionFor(jobId) })),
222
+ sessions: sessionsWatcher.getAll().values(),
223
+ missionId,
224
+ targetJobId,
225
+ });
226
+ const stopped = [];
227
+ for (const jobId of stopping) {
228
+ const job = jobsWatcher.getAll().get(jobId);
229
+ const sessionId = job?.sessionId ?? job?.resumeSessionId ?? jobId;
230
+ const superseded = jobId !== targetJobId;
231
+ try {
232
+ if (provider === "codex")
233
+ await codex.stop(jobId);
234
+ else
235
+ await stopSession(jobId);
236
+ stopped.push(jobId);
237
+ 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.` });
238
+ }
239
+ catch (error) {
240
+ 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}` });
241
+ }
242
+ }
243
+ return stopped;
244
+ }
245
+ async function resumeClaude(previousJobId, sessionId, prompt, policy, additionalRepos) {
246
+ const result = await sendMessage(sessionId, prompt, policy, additionalRepos);
247
+ const replaced = replacedManagerAfterResume(previousJobId, result.jobId);
248
+ if (replaced) {
249
+ try {
250
+ await stopSession(replaced);
251
+ events.add({ ts: Date.now(), sessionId, jobId: replaced, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Retired manager ${replaced.slice(0, 8)} after resumed job ${result.jobId.slice(0, 8)} took over` });
252
+ }
253
+ catch (error) {
254
+ // The continuation is already live. Preserve it and make cleanup
255
+ // failure visible rather than turning a successful send into an error
256
+ // that invites the user to retry and create yet another manager.
257
+ events.add({ ts: Date.now(), sessionId, jobId: replaced, source: "custom", phase: "custom", activityKind: "lifecycle", outcome: "failure", detail: `Could not retire replaced manager ${replaced.slice(0, 8)}: ${error.message}` });
258
+ }
259
+ }
260
+ return result;
261
+ }
87
262
  async function sendToMission(missionId, text) {
88
263
  return withMissionQueue(missionId, async () => {
89
264
  const target = latestMissionTarget(missionId);
90
265
  if (!target)
91
266
  throw new Error("mission not found");
267
+ const quiesced = await quiesceMission(missionId, target.jobId);
92
268
  const summary = missions.summaryFor(missionId);
93
269
  const policy = summary.policy ?? policies.get();
94
270
  const provider = summary.provider ?? "claude";
@@ -123,7 +299,7 @@ export function registerRest(app, deps) {
123
299
  try {
124
300
  result = provider === "codex"
125
301
  ? await codex.start({ task: prompt, cwd: repository, model: summary.model, mode: summary.mode, policy, resumeSessionId: target.sessionId })
126
- : await sendMessage(target.sessionId, prompt, policy, additionalRepos);
302
+ : await resumeClaude(quiesced.includes(target.jobId) ? undefined : target.jobId, target.sessionId, prompt, policy, additionalRepos);
127
303
  }
128
304
  catch (error) {
129
305
  if (reopening)
@@ -267,9 +443,123 @@ export function registerRest(app, deps) {
267
443
  return { error: err.message };
268
444
  }
269
445
  });
446
+ /**
447
+ * The mission's transcripts, as the terminal renders them.
448
+ *
449
+ * The hook-derived timeline above says that a tool ran; this says what it
450
+ * ran, what came back, and what the agent said about it. Both are kept: the
451
+ * timeline is the skimmable index, this is the record.
452
+ *
453
+ * `source` is `orchestrator` (the default) or a worker's native agent id.
454
+ * Every session the mission has ever held is searched, so a transcript from
455
+ * a resumed turn is still reachable.
456
+ */
457
+ app.get("/api/mission/:missionId/transcript", async (req, reply) => {
458
+ const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans, Date.now(), workerIdentity);
459
+ const node = snapshot.orchestrators.find((item) => item.missionId === req.params.missionId);
460
+ if (!node) {
461
+ reply.code(404);
462
+ return { error: "mission not found" };
463
+ }
464
+ const sessionIds = [...new Set([
465
+ node.sessionId,
466
+ ...node.jobHistory.flatMap((entry) => [entry.sessionId, entry.resumedFrom].filter(Boolean)),
467
+ ])];
468
+ // A worker's roster label is the name already on screen everywhere else;
469
+ // matching on aliases too covers a worker the provider renamed.
470
+ const labelFor = (agentId) => node.workers.find((worker) => worker.id === agentId || worker.aliases?.includes(agentId))?.label;
471
+ const sources = transcriptSources({ sessionIds, labelFor }).map((source) => ({
472
+ ...source,
473
+ terminal: terminalCapability(source, node.mission.provider ?? "claude", node.lifecycleStatus, undefined, node.activityStatus === "working", terminalFeatures),
474
+ }));
475
+ const requested = req.query.source?.trim() || "orchestrator";
476
+ const source = sources.find((item) => item.id === requested) ?? sources[0];
477
+ if (!source)
478
+ return { sources, source: undefined, entries: [], truncated: false };
479
+ const file = source.kind === "orchestrator"
480
+ ? orchestratorTranscriptPath(source.sessionId)
481
+ : workerTranscriptPath(source.sessionId, source.id);
482
+ if (!file)
483
+ return { sources, source: source.id, entries: [], truncated: false };
484
+ const limit = Math.min(1_000, Math.max(20, Number(req.query.limit) || 400));
485
+ const read = readTranscript(file, limit);
486
+ return { sources, source: source.id, entries: read.entries, truncated: read.truncated, updatedAt: read.updatedAt };
487
+ });
270
488
  app.get("/api/session/:sessionId/timeline", async (req) => {
271
489
  return { events: events.recentFor(req.params.sessionId, 200) };
272
490
  });
491
+ app.get("/api/mission/:missionId/terminal/events", async (req, reply) => {
492
+ if (!missions.get(req.params.missionId)) {
493
+ reply.code(404);
494
+ return { error: "mission not found" };
495
+ }
496
+ const source = req.query.source?.trim() || "orchestrator";
497
+ const after = Math.max(0, Number(req.query.after) || 0);
498
+ return terminalRuntime.stream.replay(req.params.missionId, source, after);
499
+ });
500
+ app.post("/api/mission/:missionId/terminal/input", async (req, reply) => {
501
+ const source = req.body?.source?.trim() || "orchestrator";
502
+ const text = req.body?.text?.trim();
503
+ const clientInputId = req.body?.clientInputId?.trim();
504
+ const auditReject = (status, code, error, provider = "unknown") => {
505
+ terminalRuntime.observability.input({ missionId: req.params.missionId, source, provider, inputCharacters: req.body?.text?.length ?? 0, outcome: "rejected", reason: code });
506
+ reply.code(status);
507
+ return { error, code };
508
+ };
509
+ if (source !== "orchestrator")
510
+ return auditReject(409, "terminal_read_only", "Worker terminal sources are read only");
511
+ if (!text)
512
+ return auditReject(400, "invalid_terminal_input", "text is required");
513
+ if (text.length > 100_000)
514
+ return auditReject(413, "terminal_input_too_large", "terminal input exceeds 100000 characters");
515
+ if (!clientInputId || clientInputId.length > 128)
516
+ return auditReject(400, "invalid_client_input_id", "a clientInputId of at most 128 characters is required");
517
+ const mission = missions.get(req.params.missionId);
518
+ if (!mission)
519
+ return auditReject(404, "mission_not_found", "mission not found");
520
+ const summary = missions.summaryFor(req.params.missionId);
521
+ if ((summary.provider ?? "claude") !== "codex")
522
+ return auditReject(409, "terminal_follow_up_only", "This provider currently supports follow-up input, not interactive terminal input", "claude");
523
+ if (!terminalFeatures.codexInput)
524
+ return auditReject(409, "terminal_input_disabled", "Writable Codex Terminal input is disabled", "codex");
525
+ const detection = providerTerminalCatalog().codex;
526
+ if (detection.interactiveTransport !== "codex_app_server")
527
+ return auditReject(409, "terminal_transport_unavailable", detection.reason ?? "The installed Codex CLI does not support interactive terminal input", "codex");
528
+ const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans, Date.now(), workerIdentity);
529
+ const node = snapshot.orchestrators.find((item) => item.missionId === req.params.missionId);
530
+ if (node?.activityStatus === "working")
531
+ return auditReject(409, "terminal_turn_active", "The current provider turn is still running", "codex");
532
+ if (summary.lifecycleStatus === "completed" || summary.lifecycleStatus === "archived")
533
+ return auditReject(409, "terminal_read_only", "Finished missions are read only until reopened", "codex");
534
+ const target = latestMissionTarget(req.params.missionId);
535
+ if (!target)
536
+ return auditReject(409, "terminal_thread_unavailable", "No provider thread is available for this mission", "codex");
537
+ const cwd = resumeRepository(req.params.missionId, summary.repository);
538
+ if (!cwd)
539
+ return auditReject(409, "terminal_cwd_unavailable", "No working directory is available for this mission", "codex");
540
+ try {
541
+ return await terminalRuntime.submitCodex({ missionId: req.params.missionId, source: "orchestrator", threadId: target.sessionId, cwd, text, clientInputId, model: summary.model, policy: summary.policy ?? policies.get() });
542
+ }
543
+ catch (error) {
544
+ reply.code(409);
545
+ return { error: error.message, code: "terminal_input_failed" };
546
+ }
547
+ });
548
+ app.post("/api/mission/:missionId/terminal/interrupt", async (req, reply) => {
549
+ if (!missions.get(req.params.missionId)) {
550
+ reply.code(404);
551
+ return { error: "mission not found", code: "mission_not_found" };
552
+ }
553
+ try {
554
+ await terminalRuntime.interrupt(req.params.missionId);
555
+ return { ok: true };
556
+ }
557
+ catch (error) {
558
+ reply.code(409);
559
+ return { error: error.message, code: "terminal_interrupt_unavailable" };
560
+ }
561
+ });
562
+ app.get("/api/terminal/status", async () => ({ features: terminalFeatures, observability: terminalRuntime.observability.snapshot() }));
273
563
  /**
274
564
  * Finished work that starts working again is no longer finished.
275
565
  *
@@ -304,6 +594,17 @@ export function registerRest(app, deps) {
304
594
  events.add(promptEvent);
305
595
  if (jobId)
306
596
  reopenIfWorking(event, jobId);
597
+ if (jobId && event.hookEventName === "Stop") {
598
+ const missionId = missions.missionFor(jobId);
599
+ const mission = missions.get(missionId);
600
+ const pendingDecision = events.recentFor(event.sessionId).some((candidate) => candidate.jobId === jobId && candidate.phase === "blocked_on_user" && candidate.ts <= event.ts);
601
+ if (mission && returnsToReviewOnStop(mission, pendingDecision)) {
602
+ const finalMessage = typeof req.body.last_assistant_message === "string" ? req.body.last_assistant_message : undefined;
603
+ missions.setLifecycleStatus(missionId, "ready_for_review");
604
+ missions.updateContext(missionId, { compactSummary: finalMessage?.slice(0, 1_200), currentState: "Awaiting user acceptance" });
605
+ events.add({ ts: Date.now(), sessionId: event.sessionId, jobId, source: "custom", phase: "ready_for_review", activityKind: "lifecycle", detail: "Follow-up work finished and returned to user acceptance" });
606
+ }
607
+ }
307
608
  // A write landing in another known repo marks that repo as touched by
308
609
  // the mission, so its footprint chips reflect reality, not just intent.
309
610
  if (event.activityKind === "file_write" && event.filePath && jobId) {
@@ -325,6 +626,7 @@ export function registerRest(app, deps) {
325
626
  }
326
627
  const effectiveJobId = jobId ?? sessionForId(sessionsWatcher, sessionId)?.jobId;
327
628
  let eventDetail = detail;
629
+ let derivedPlanMilestones = [];
328
630
  if (phase === "plan_updated") {
329
631
  if (!effectiveJobId) {
330
632
  reply.code(404);
@@ -336,8 +638,11 @@ export function registerRest(app, deps) {
336
638
  return { error: "mission not found for plan update" };
337
639
  }
338
640
  try {
641
+ const previousPlan = plans.get(missionId);
339
642
  const plan = plans.replace(missionId, JSON.parse(detail));
340
- 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"}`;
643
+ derivedPlanMilestones = planMilestones(previousPlan, plan);
644
+ const revisionSummary = planRevisionSummary(previousPlan, plan);
645
+ 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}` : ""}`;
341
646
  const current = plan.phases.find((item) => ["working", "reviewing", "blocked"].includes(item.status));
342
647
  const blocked = plan.phases.flatMap((item) => allPlanTasks(item.tasks)).filter((task) => task.status === "blocked").map((task) => task.title);
343
648
  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 });
@@ -374,6 +679,15 @@ export function registerRest(app, deps) {
374
679
  impact: phase === "blocked_on_user" ? decision?.impact : undefined,
375
680
  context: phase === "blocked_on_user" ? decision?.context : undefined,
376
681
  });
682
+ for (const [index, milestone] of derivedPlanMilestones.entries())
683
+ events.add({
684
+ ts: Date.now() + index + 1,
685
+ sessionId,
686
+ jobId: effectiveJobId,
687
+ source: "custom",
688
+ activityKind: "lifecycle",
689
+ ...milestone,
690
+ });
377
691
  if (effectiveJobId && phase !== "ready_for_review")
378
692
  reopenIfWorking({ sessionId, source: "custom", phase }, effectiveJobId);
379
693
  if (phase === "ready_for_review" && effectiveJobId) {
@@ -391,7 +705,7 @@ export function registerRest(app, deps) {
391
705
  // One emit may name several workers (`--target a,b,c`); the task moves for each.
392
706
  const targets = targetWorker && /[,;]/.test(targetWorker) ? [...new Set(targetWorker.split(/[,;]/).map((value) => value.trim()).filter(Boolean))] : [targetWorker];
393
707
  for (const target of targets) {
394
- const canonicalWorker = workerIdentity.canonicalFor(missionId, target);
708
+ const canonicalWorker = workerIdentity.canonicalFor(missionId, target, effectiveJobId);
395
709
  try {
396
710
  plans.applyTaskEvent(missionId, { phase, targetTask, targetWorker: target, canonicalWorker, evidence: phase === "worker_reported" ? detail : undefined });
397
711
  }
@@ -540,12 +854,32 @@ Revise the structured plan to remove that requirement and stop work that was nee
540
854
  return { error: err.message };
541
855
  }
542
856
  });
543
- app.post("/api/orchestrator/start", async (req, reply) => {
857
+ app.post("/api/orchestrator/start", { bodyLimit: Math.ceil(MAX_ATTACHMENT_BYTES * 1.4 * 8) }, async (req, reply) => {
544
858
  const { task, name, cwd, templateId } = req.body;
545
859
  if (!task) {
546
860
  reply.code(400);
547
861
  return { error: "task is required" };
548
862
  }
863
+ const pendingAttachments = req.body.attachments ?? [];
864
+ for (const item of pendingAttachments) {
865
+ if (!isSupportedMediaType(item.mediaType)) {
866
+ reply.code(415);
867
+ return { error: `unsupported image type: ${item.mediaType || "none given"}` };
868
+ }
869
+ if (!item.data) {
870
+ reply.code(400);
871
+ return { error: "attachment data is required" };
872
+ }
873
+ }
874
+ // `/loop …` is Hive's wherever it is typed, not only in a conversation.
875
+ // Handed to the CLI as a task it would run as the agent's own loop, which
876
+ // Hive cannot list or stop — the failure this feature exists to prevent.
877
+ const start = parseMissionStartLoop(task);
878
+ if ("error" in start) {
879
+ reply.code(400);
880
+ return { error: start.error };
881
+ }
882
+ const missionTask = start.prompt;
549
883
  try {
550
884
  const template = templateId ? discoverTemplates(cwd).find((item) => item.id === templateId) : undefined;
551
885
  if (templateId && !template) {
@@ -575,17 +909,23 @@ Revise the structured plan to remove that requirement and stop work that was nee
575
909
  const provider = req.body.provider ?? "claude";
576
910
  const model = req.body.model?.trim() || undefined;
577
911
  const planFirst = mode === "orchestrated" && req.body.planFirst === true;
578
- const additionalRepos = detectRepositoryMentions(task, resolvedCwd);
912
+ const additionalRepos = detectRepositoryMentions(missionTask, resolvedCwd);
579
913
  const repoPrompt = additionalRepos.length ? `\n\n(Hive: you also have tool access to these repositories: ${additionalRepos.join(", ")})` : "";
580
914
  const planFirstPrompt = planFirst ? `\n\nHive Plan First checkpoint (mandatory): Before editing files, running implementation commands, or spawning/delegating to any workers, investigate only enough to propose a concrete structured plan. Publish that plan with every task and phase still queued and approvalStatus \"proposed\". Then emit blocked_on_user with decision id \"plan-approval\", kind \"scope\", and question \"Approve the proposed plan before implementation begins?\" Stop the turn after emitting it. Do not begin implementation until Hive resumes you with explicit plan approval. If changes are requested, revise and republish the queued plan, emit the same plan-approval decision again, and stop.` : "";
581
- const providerTask = translateSkillInvocations(task, provider, new Set(discoverSkills(resolvedCwd, provider).map((skill) => skill.name)));
582
- const prompt = providerTask + templatePrompt + limits + policyPrompt + repoPrompt + planFirstPrompt;
915
+ const providerTask = translateSkillInvocations(missionTask, provider, new Set(discoverSkills(resolvedCwd, provider).map((skill) => skill.name)));
916
+ // Generated ahead of `missions.create` so pasted images can be filed
917
+ // under the mission's id before the mission record itself exists.
918
+ const missionId = randomUUID();
919
+ const attached = pendingAttachments.map((item) => attachments.save(missionId, { name: item.name, mediaType: item.mediaType, data: Buffer.from(item.data, "base64") }));
920
+ const taskWithAttachments = promptWithAttachments(providerTask, attached);
921
+ const prompt = taskWithAttachments + templatePrompt + limits + policyPrompt + repoPrompt + planFirstPrompt;
583
922
  const result = provider === "codex"
584
923
  ? await codex.start({ task: prompt, cwd: resolvedCwd, name, model, mode, policy })
585
924
  : await startOrchestrator({ task: prompt, worktree, name, cwd: resolvedCwd, mode, policy, model, addDirs: additionalRepos });
586
925
  const mission = missions.create({
926
+ id: missionId,
587
927
  name,
588
- objective: task,
928
+ objective: missionTask,
589
929
  repository: result.cwd,
590
930
  jobId: result.jobId,
591
931
  sessionId: result.sessionId,
@@ -605,10 +945,24 @@ Revise the structured plan to remove that requirement and stop work that was nee
605
945
  text: task,
606
946
  createdAt: Date.now(),
607
947
  jobId: result.jobId,
948
+ ...(attached.length ? { attachments: attached } : {}),
608
949
  });
950
+ // The opening turn is the loop's first pass, so the schedule starts one
951
+ // interval out — exactly what `loops.create` does.
952
+ const loop = start.loop ? loops.create({ missionId: mission.id, ...start.loop }) : undefined;
953
+ if (loop) {
954
+ messages.add({
955
+ id: `${result.jobId}:loop`,
956
+ missionId: mission.id,
957
+ role: "assistant",
958
+ text: `Looping every ${formatInterval(loop.intervalMs)}: ${loop.prompt}\n\nThis first pass is running now; the next is at ${new Date(loop.nextRunAt).toLocaleTimeString()}. Stop it with \`/loop stop ${loop.id}\`, or from the Tasks panel.`,
959
+ createdAt: Date.now() + 1,
960
+ jobId: result.jobId,
961
+ });
962
+ }
609
963
  if (template)
610
964
  plans.replace(mission.id, { phases: [{ id: "template", title: template.name, description: template.description, status: "queued", acceptanceCriteria: template.gates.map((gate) => gate.label), dependsOn: [], tasks: template.stages.map((stage, index) => ({ id: stage.id, title: stage.title, role: stage.role, status: "queued", weight: 1, dependsOn: stage.dependsOn ?? (index ? [template.stages[index - 1].id] : []), evidence: [] })) }], gates: template.gates.map((gate) => ({ ...gate, source: "template" })) });
611
- return { ...result, missionId: mission.id };
965
+ return { ...result, missionId: mission.id, ...(loop ? { loopId: loop.id } : {}) };
612
966
  }
613
967
  catch (err) {
614
968
  reply.code(400);
@@ -624,6 +978,87 @@ Revise the structured plan to remove that requirement and stop work that was nee
624
978
  return { error: err.message };
625
979
  }
626
980
  });
981
+ // Base64 in a JSON body rather than multipart: a pasted screenshot arrives
982
+ // as bytes in the browser either way, and this needs no new dependency.
983
+ app.post("/api/mission/:missionId/attachment", { bodyLimit: Math.ceil(MAX_ATTACHMENT_BYTES * 1.4) }, async (req, reply) => {
984
+ const { missionId } = req.params;
985
+ if (!missions.get(missionId)) {
986
+ reply.code(404);
987
+ return { error: "mission not found" };
988
+ }
989
+ const mediaType = req.body?.mediaType ?? "";
990
+ if (!isSupportedMediaType(mediaType)) {
991
+ reply.code(415);
992
+ return { error: `unsupported image type: ${mediaType || "none given"}` };
993
+ }
994
+ if (!req.body?.data) {
995
+ reply.code(400);
996
+ return { error: "data is required" };
997
+ }
998
+ try {
999
+ return { attachment: attachments.save(missionId, { name: req.body.name, mediaType, data: Buffer.from(req.body.data, "base64") }) };
1000
+ }
1001
+ catch (err) {
1002
+ reply.code(400);
1003
+ return { error: err.message };
1004
+ }
1005
+ });
1006
+ app.get("/api/mission/:missionId/attachment/:attachmentId", async (req, reply) => {
1007
+ const found = attachments.read(req.params.missionId, req.params.attachmentId);
1008
+ if (!found) {
1009
+ reply.code(404);
1010
+ return { error: "attachment not found" };
1011
+ }
1012
+ reply.header("Content-Type", found.mediaType);
1013
+ reply.header("Cache-Control", "private, max-age=31536000, immutable");
1014
+ return reply.send(fs.createReadStream(found.file));
1015
+ });
1016
+ /** Everything currently running on a schedule or watching in the background. */
1017
+ app.get("/api/tasks", async () => ({
1018
+ loops: loops.active().map((loop) => ({ ...loop, missionName: missions.get(loop.missionId)?.name })),
1019
+ monitors: activeMonitors(jobsWatcher.getAll(), (jobId) => missions.missionFor(jobId), (missionId) => missions.get(missionId)?.name ?? missionId),
1020
+ }));
1021
+ app.post("/api/loop/:loopId/stop", async (req, reply) => {
1022
+ if (!loops.stop(req.params.loopId, "you stopped it")) {
1023
+ reply.code(404);
1024
+ return { error: "no active loop with that id" };
1025
+ }
1026
+ return { ok: true };
1027
+ });
1028
+ app.post("/api/mission/:missionId/loops/stop", async (req, reply) => {
1029
+ if (!missions.get(req.params.missionId)) {
1030
+ reply.code(404);
1031
+ return { error: "mission not found" };
1032
+ }
1033
+ return { ok: true, stopped: loops.stopMission(req.params.missionId, "you stopped it") };
1034
+ });
1035
+ /**
1036
+ * Ends the turn that owns a mission's monitors. Hive did not start them and
1037
+ * cannot address one individually, so stopping the turn is the honest lever.
1038
+ */
1039
+ app.post("/api/mission/:missionId/monitors/stop", async (req, reply) => {
1040
+ if (!missions.get(req.params.missionId)) {
1041
+ reply.code(404);
1042
+ return { error: "mission not found" };
1043
+ }
1044
+ try {
1045
+ await quiesceMission(req.params.missionId);
1046
+ return { ok: true };
1047
+ }
1048
+ catch (err) {
1049
+ reply.code(500);
1050
+ return { error: err.message };
1051
+ }
1052
+ });
1053
+ app.post("/api/agents/discover", async (req, reply) => {
1054
+ try {
1055
+ return { agents: discoverAgents(req.body?.cwd) };
1056
+ }
1057
+ catch (err) {
1058
+ reply.code(400);
1059
+ return { error: err.message };
1060
+ }
1061
+ });
627
1062
  app.post("/api/session/:sessionId/adopt", async (req, reply) => {
628
1063
  const session = [...sessionsWatcher.getAll().values()].find((candidate) => candidate.sessionId === req.params.sessionId);
629
1064
  if (!session) {
@@ -655,6 +1090,22 @@ Revise the structured plan to remove that requirement and stop work that was nee
655
1090
  directories: req.body?.input?.trim() ? suggestDirectories(req.body.input) : recentRepositories(),
656
1091
  }));
657
1092
  app.post("/api/paths/inspect", async (req) => inspectWorkingDirectory(req.body?.input));
1093
+ // Live "@path" completion in a composer: files and directories under a
1094
+ // mission's own working directory, matching what's typed after the `@`.
1095
+ app.post("/api/paths/suggest-entries", async (req, reply) => {
1096
+ try {
1097
+ const cwd = requireWorkingDirectory(req.body?.cwd);
1098
+ return { entries: suggestPathEntries(cwd, req.body?.query ?? "") };
1099
+ }
1100
+ catch (err) {
1101
+ reply.code(400);
1102
+ return { error: err.message };
1103
+ }
1104
+ });
1105
+ // Live "#repo" completion: sibling and recently used repositories matching what's typed after the `#`.
1106
+ app.post("/api/repositories/suggest", async (req) => ({
1107
+ repositories: suggestRepositories(req.body?.cwd, req.body?.query ?? ""),
1108
+ }));
658
1109
  app.post("/api/session/:pid/message", async (req, reply) => {
659
1110
  const pid = Number(req.params.pid);
660
1111
  const session = sessionsWatcher.getAll().get(pid);
@@ -666,10 +1117,14 @@ Revise the structured plan to remove that requirement and stop work that was nee
666
1117
  reply.code(400);
667
1118
  return { error: "text is required" };
668
1119
  }
669
- const result = await sendMessage(session.sessionId, req.body.text);
670
1120
  const missionId = session.jobId
671
1121
  ? missions.missionFor(session.jobId)
672
1122
  : session.sessionId;
1123
+ // Messaging a session that belongs to a mission is the same fork as
1124
+ // messaging the mission, through a second door. Only a session Hive does
1125
+ // not manage as a mission is resumed without quiescing.
1126
+ const quiesced = missions.get(missionId) ? await quiesceMission(missionId, session.jobId) : [];
1127
+ const result = await resumeClaude(quiesced.includes(session.jobId ?? "") ? undefined : session.jobId, session.sessionId, req.body.text);
673
1128
  missions.linkJob(missionId, result.jobId, result.sessionId);
674
1129
  messages.add({
675
1130
  id: `${result.jobId}:user`,
@@ -683,10 +1138,83 @@ Revise the structured plan to remove that requirement and stop work that was nee
683
1138
  });
684
1139
  app.post("/api/mission/:missionId/message", async (req, reply) => {
685
1140
  const text = req.body?.text?.trim();
686
- if (!text) {
1141
+ const attached = (req.body?.attachmentIds ?? [])
1142
+ .map((id) => attachments.describe(req.params.missionId, id))
1143
+ .filter((item) => Boolean(item));
1144
+ if (!text && !attached.length) {
687
1145
  reply.code(400);
688
1146
  return { error: "text is required" };
689
1147
  }
1148
+ // `/tasks` answers from Hive's own books rather than the agent's, which
1149
+ // is the only place the whole fleet's loops and monitors are visible.
1150
+ if (text && /^\/tasks\s*$/i.test(text.trim())) {
1151
+ const missionId = req.params.missionId;
1152
+ if (!missions.get(missionId)) {
1153
+ reply.code(404);
1154
+ return { error: "mission not found" };
1155
+ }
1156
+ const jobRef = `tasks:${randomUUID()}`;
1157
+ const running = loops.active();
1158
+ const monitors = activeMonitors(jobsWatcher.getAll(), (jobId) => missions.missionFor(jobId), (id) => missions.get(id)?.name ?? id);
1159
+ const here = running.filter((loop) => loop.missionId === missionId);
1160
+ const elsewhere = running.filter((loop) => loop.missionId !== missionId);
1161
+ const lines = [
1162
+ here.length
1163
+ ? `**Loops on this mission**\n${here.map((loop) => `• \`${loop.id}\` every ${formatInterval(loop.intervalMs)} — ${loop.prompt}`).join("\n")}`
1164
+ : "**Loops on this mission**\nNone.",
1165
+ elsewhere.length ? `\n**Loops elsewhere**\n${elsewhere.map((loop) => `• \`${loop.id}\` ${missions.get(loop.missionId)?.name ?? loop.missionId} — every ${formatInterval(loop.intervalMs)}`).join("\n")}` : "",
1166
+ monitors.length
1167
+ ? `\n**Monitors**\n${monitors.map((monitor) => `• ${monitor.label} — ${monitor.missionName}${monitor.missionId === missionId ? " (here)" : ""}`).join("\n")}`
1168
+ : "\n**Monitors**\nNone running.",
1169
+ "\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.",
1170
+ ];
1171
+ messages.add({ id: `${jobRef}:user`, missionId, role: "user", text, createdAt: Date.now(), jobId: jobRef });
1172
+ messages.add({ id: `${jobRef}:assistant`, missionId, role: "assistant", text: lines.filter(Boolean).join("\n"), createdAt: Date.now() + 1, jobId: jobRef });
1173
+ return { ok: true, jobId: jobRef };
1174
+ }
1175
+ // `/loop` is Hive's, not the agent's: a loop the agent starts internally
1176
+ // cannot be listed or stopped from here, and this one can.
1177
+ const loopCommand = text ? parseLoopCommand(text) : undefined;
1178
+ if (loopCommand) {
1179
+ const missionId = req.params.missionId;
1180
+ if (!missions.get(missionId)) {
1181
+ reply.code(404);
1182
+ return { error: "mission not found" };
1183
+ }
1184
+ const jobRef = `loop:${randomUUID()}`;
1185
+ const say = (body) => {
1186
+ messages.add({ id: `${jobRef}:user`, missionId, role: "user", text, createdAt: Date.now(), jobId: jobRef });
1187
+ messages.add({ id: `${jobRef}:assistant`, missionId, role: "assistant", text: body, createdAt: Date.now() + 1, jobId: jobRef });
1188
+ };
1189
+ if (loopCommand.kind === "error") {
1190
+ say(`⚠️ ${loopCommand.message}`);
1191
+ return { ok: true, jobId: jobRef };
1192
+ }
1193
+ if (loopCommand.kind === "list") {
1194
+ const running = loops.active(missionId);
1195
+ say(running.length
1196
+ ? `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\`.`
1197
+ : "No loops are running on this mission. Start one with `/loop 10m check for new PR comments`.");
1198
+ return { ok: true, jobId: jobRef };
1199
+ }
1200
+ if (loopCommand.kind === "stop") {
1201
+ const stopped = loopCommand.loopId
1202
+ ? (loops.stop(loopCommand.loopId, "you stopped it") ? 1 : 0)
1203
+ : loops.stopMission(missionId, "you stopped it");
1204
+ say(stopped
1205
+ ? `Stopped ${stopped} loop${stopped === 1 ? "" : "s"}.`
1206
+ : loopCommand.loopId ? `No active loop with id \`${loopCommand.loopId}\`.` : "No loops were running on this mission.");
1207
+ return { ok: true, jobId: jobRef };
1208
+ }
1209
+ const created = loops.create({ missionId, prompt: loopCommand.prompt, intervalMs: loopCommand.intervalMs });
1210
+ 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.`);
1211
+ // The loop's own prompt runs now too, so the first pass is not a wait.
1212
+ try {
1213
+ await sendToMission(missionId, loopCommand.prompt);
1214
+ }
1215
+ catch { /* the schedule carries it from here */ }
1216
+ return { ok: true, jobId: jobRef };
1217
+ }
690
1218
  const native = matchNativeCommand(text);
691
1219
  if (native) {
692
1220
  const missionId = req.params.missionId;
@@ -706,7 +1234,13 @@ Revise the structured plan to remove that requirement and stop work that was nee
706
1234
  return { ok: true, jobId: jobRef };
707
1235
  }
708
1236
  try {
709
- const result = await sendToMission(req.params.missionId, text);
1237
+ // The model never receives bytes: the images are on the Hive host's
1238
+ // disk and the agent opens them with its own Read tool, which behaves
1239
+ // the same for every provider.
1240
+ const result = await sendToMission(req.params.missionId, promptWithAttachments(text, attached));
1241
+ if (attached.length) {
1242
+ messages.add({ id: `${result.jobId}:user`, missionId: req.params.missionId, role: "user", text, createdAt: Date.now(), jobId: result.jobId, attachments: attached });
1243
+ }
710
1244
  return { ok: true, jobId: result.jobId };
711
1245
  }
712
1246
  catch (err) {
@@ -761,14 +1295,16 @@ Revise the structured plan to remove that requirement and stop work that was nee
761
1295
  const ask = parsePermissionAsk(job.needs);
762
1296
  const askLabel = ask ? `${ask.tool}: ${ask.detail}`.slice(0, 300) : job.needs.slice(0, 300);
763
1297
  try {
764
- await stopSession(jobId);
1298
+ // Ends the parked turn, and any other manager still live on the
1299
+ // mission with it — the verdict below resumes one conversation.
1300
+ const quiesced = await quiesceMission(req.params.missionId, jobId);
765
1301
  const summary = missions.summaryFor(req.params.missionId);
766
1302
  const policy = summary.policy ?? policies.get();
767
1303
  const grant = approved ? permissionGrant(job.needs) : undefined;
768
1304
  const text = approved
769
1305
  ? `Your permission request (${askLabel}) is approved and now pre-allowed. Re-run it and continue where you left off.`
770
1306
  : `Your permission request (${askLabel}) was denied${/^deny$/i.test(answer) ? "" : ` — ${answer}`}. Continue without it or propose an alternative.`;
771
- const result = await sendMessage(sessionId, text, grant ? { ...policy, allowedTools: [...policy.allowedTools, grant] } : policy);
1307
+ const result = await resumeClaude(quiesced.includes(jobId) ? undefined : jobId, sessionId, text, grant ? { ...policy, allowedTools: [...policy.allowedTools, grant] } : policy);
772
1308
  missions.linkJob(req.params.missionId, result.jobId, result.sessionId);
773
1309
  messages.add({ id: `${result.jobId}:user`, missionId: req.params.missionId, role: "user", text, createdAt: Date.now(), jobId: result.jobId });
774
1310
  events.add({ ts: Date.now(), sessionId, jobId: result.jobId, source: "custom", phase: "decision_resolved", activityKind: "decision", detail: answer, decisionId: req.params.decisionId });
@@ -796,6 +1332,21 @@ Revise the structured plan to remove that requirement and stop work that was nee
796
1332
  return { error: err.message };
797
1333
  }
798
1334
  }
1335
+ // An ask the agent wrote in its own reply (decision id ask:<jobId>) has
1336
+ // no blocked_on_user event behind it — the turn simply ended with a
1337
+ // question. Answering it is just replying, and the resolution event is
1338
+ // what clears it from the inbox.
1339
+ if (req.params.decisionId.startsWith("ask:")) {
1340
+ try {
1341
+ const result = await sendToMission(req.params.missionId, answer);
1342
+ 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 });
1343
+ return { ok: true };
1344
+ }
1345
+ catch (err) {
1346
+ reply.code(500);
1347
+ return { error: err.message };
1348
+ }
1349
+ }
799
1350
  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));
800
1351
  const pending = nodeEvents.find((event) => event.phase === "blocked_on_user" && event.decisionId === req.params.decisionId);
801
1352
  const alreadyResolved = nodeEvents.some((event) => event.phase === "decision_resolved" && event.decisionId === req.params.decisionId);
@@ -856,14 +1407,11 @@ Revise the structured plan to remove that requirement and stop work that was nee
856
1407
  return await withMissionQueue(req.params.missionId, async () => {
857
1408
  const recent = messages.modelContext(req.params.missionId);
858
1409
  const retrieved = messages.relevantContext(req.params.missionId, `${mission.objective} ${mission.compactSummary ?? ""} ${mission.currentState ?? ""}`, new Set(recent.map((message) => message.id)));
859
- const target = latestMissionTarget(req.params.missionId);
860
- const currentJob = target ? jobsWatcher.getAll().get(target.jobId) : undefined;
861
- if (target && currentJob && ["working", "busy", "queued"].includes(currentJob.state)) {
862
- if (mission.provider === "codex")
863
- await codex.stop(target.jobId);
864
- else
865
- await stopSession(target.jobId);
866
- }
1410
+ // The replacement manager is about to take the mission over, so every
1411
+ // manager already on it stands down — not just the one mid-turn. A
1412
+ // handoff that left the old job running would be the same collision
1413
+ // the resume path had, with the two managers a turn further apart.
1414
+ await quiesceMission(req.params.missionId);
867
1415
  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 };
868
1416
  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)}`;
869
1417
  const result = mission.provider === "codex"
@@ -1053,6 +1601,7 @@ Revise the structured plan to remove that requirement and stop work that was nee
1053
1601
  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" });
1054
1602
  return { ok: true };
1055
1603
  });
1604
+ return { sendToMission };
1056
1605
  }
1057
1606
  function sessionForId(watcher, sessionId) {
1058
1607
  for (const session of watcher.getAll().values()) {