@shanesaravia/hive 0.3.0 → 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 (38) hide show
  1. package/CHANGELOG.md +21 -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 +162 -1
  5. package/node_modules/@hive/shared/dist/types.js +27 -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 +474 -14
  9. package/packages/server/dist/api/ws.js +66 -2
  10. package/packages/server/dist/control/missionQuiesce.js +66 -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 +56 -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 +97 -33
  20. package/packages/server/dist/reviews/reviewDiff.js +47 -0
  21. package/packages/server/dist/roster/replyAsk.js +62 -0
  22. package/packages/server/dist/roster/rosterBuilder.js +41 -5
  23. package/packages/server/dist/roster/workerIdentity.js +46 -9
  24. package/packages/server/dist/skills/skillDiscovery.js +28 -4
  25. package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
  26. package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
  27. package/packages/server/dist/terminals/providerDetection.js +27 -0
  28. package/packages/server/dist/terminals/terminalCapability.js +45 -0
  29. package/packages/server/dist/terminals/terminalFeatures.js +11 -0
  30. package/packages/server/dist/terminals/terminalObservability.js +21 -0
  31. package/packages/server/dist/terminals/terminalRuntime.js +125 -0
  32. package/packages/server/dist/terminals/terminalStream.js +30 -0
  33. package/packages/server/dist/transcripts/transcriptReader.js +345 -0
  34. package/packages/web/dist/assets/index-DWjqiitn.js +17 -0
  35. package/packages/web/dist/assets/index-rd4RnLqj.css +2 -0
  36. package/packages/web/dist/index.html +2 -2
  37. package/packages/web/dist/assets/index-BpEYVjCF.css +0 -2
  38. package/packages/web/dist/assets/index-rIAIJyuF.js +0 -12
@@ -4,14 +4,20 @@ 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
9
  import { isWorkEvent, reopensOnWork } 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 } 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 } from "../loops/loopCommand.js";
20
+ import { activeMonitors } from "../loops/monitors.js";
15
21
  import { translateSkillInvocations } from "../skills/skillInvocation.js";
16
22
  import { detectRepositoryMentions, foreignRepositoryForPath, inspectWorkingDirectory, recentRepositories, requireWorkingDirectory, suggestDirectories } from "../paths/pathResolver.js";
17
23
  import { missionReport, reportMarkdown } from "../reports/missionReport.js";
@@ -20,9 +26,116 @@ 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
+ */
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. */
23
125
  export function registerRest(app, deps) {
24
- const { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex, workerIdentity } = deps;
126
+ const { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex, workerIdentity, terminalRuntime, loops } = deps;
127
+ const terminalFeatures = terminalFeatureFlags();
128
+ const attachments = new AttachmentStore();
25
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
+ });
26
139
  function latestMissionTarget(missionId) {
27
140
  const candidates = [...jobsWatcher.getAll()].filter(([jobId]) => missions.missionFor(jobId) === missionId);
28
141
  candidates.sort((a, b) => Date.parse(b[1].createdAt ?? "") - Date.parse(a[1].createdAt ?? ""));
@@ -84,11 +197,51 @@ export function registerRest(app, deps) {
84
197
  missionSendTails.delete(missionId);
85
198
  }
86
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
+ }
87
239
  async function sendToMission(missionId, text) {
88
240
  return withMissionQueue(missionId, async () => {
89
241
  const target = latestMissionTarget(missionId);
90
242
  if (!target)
91
243
  throw new Error("mission not found");
244
+ await quiesceMission(missionId, target.jobId);
92
245
  const summary = missions.summaryFor(missionId);
93
246
  const policy = summary.policy ?? policies.get();
94
247
  const provider = summary.provider ?? "claude";
@@ -267,9 +420,123 @@ export function registerRest(app, deps) {
267
420
  return { error: err.message };
268
421
  }
269
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
+ });
270
465
  app.get("/api/session/:sessionId/timeline", async (req) => {
271
466
  return { events: events.recentFor(req.params.sessionId, 200) };
272
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() }));
273
540
  /**
274
541
  * Finished work that starts working again is no longer finished.
275
542
  *
@@ -325,6 +592,7 @@ export function registerRest(app, deps) {
325
592
  }
326
593
  const effectiveJobId = jobId ?? sessionForId(sessionsWatcher, sessionId)?.jobId;
327
594
  let eventDetail = detail;
595
+ let derivedPlanMilestones = [];
328
596
  if (phase === "plan_updated") {
329
597
  if (!effectiveJobId) {
330
598
  reply.code(404);
@@ -336,8 +604,11 @@ export function registerRest(app, deps) {
336
604
  return { error: "mission not found for plan update" };
337
605
  }
338
606
  try {
607
+ const previousPlan = plans.get(missionId);
339
608
  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"}`;
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}` : ""}`;
341
612
  const current = plan.phases.find((item) => ["working", "reviewing", "blocked"].includes(item.status));
342
613
  const blocked = plan.phases.flatMap((item) => allPlanTasks(item.tasks)).filter((task) => task.status === "blocked").map((task) => task.title);
343
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 });
@@ -374,6 +645,15 @@ export function registerRest(app, deps) {
374
645
  impact: phase === "blocked_on_user" ? decision?.impact : undefined,
375
646
  context: phase === "blocked_on_user" ? decision?.context : undefined,
376
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
+ });
377
657
  if (effectiveJobId && phase !== "ready_for_review")
378
658
  reopenIfWorking({ sessionId, source: "custom", phase }, effectiveJobId);
379
659
  if (phase === "ready_for_review" && effectiveJobId) {
@@ -624,6 +904,87 @@ Revise the structured plan to remove that requirement and stop work that was nee
624
904
  return { error: err.message };
625
905
  }
626
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
+ });
627
988
  app.post("/api/session/:sessionId/adopt", async (req, reply) => {
628
989
  const session = [...sessionsWatcher.getAll().values()].find((candidate) => candidate.sessionId === req.params.sessionId);
629
990
  if (!session) {
@@ -666,10 +1027,15 @@ Revise the structured plan to remove that requirement and stop work that was nee
666
1027
  reply.code(400);
667
1028
  return { error: "text is required" };
668
1029
  }
669
- const result = await sendMessage(session.sessionId, req.body.text);
670
1030
  const missionId = session.jobId
671
1031
  ? missions.missionFor(session.jobId)
672
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);
673
1039
  missions.linkJob(missionId, result.jobId, result.sessionId);
674
1040
  messages.add({
675
1041
  id: `${result.jobId}:user`,
@@ -683,10 +1049,83 @@ Revise the structured plan to remove that requirement and stop work that was nee
683
1049
  });
684
1050
  app.post("/api/mission/:missionId/message", async (req, reply) => {
685
1051
  const text = req.body?.text?.trim();
686
- 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) {
687
1056
  reply.code(400);
688
1057
  return { error: "text is required" };
689
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
+ }
690
1129
  const native = matchNativeCommand(text);
691
1130
  if (native) {
692
1131
  const missionId = req.params.missionId;
@@ -706,7 +1145,13 @@ Revise the structured plan to remove that requirement and stop work that was nee
706
1145
  return { ok: true, jobId: jobRef };
707
1146
  }
708
1147
  try {
709
- 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
+ }
710
1155
  return { ok: true, jobId: result.jobId };
711
1156
  }
712
1157
  catch (err) {
@@ -761,7 +1206,9 @@ Revise the structured plan to remove that requirement and stop work that was nee
761
1206
  const ask = parsePermissionAsk(job.needs);
762
1207
  const askLabel = ask ? `${ask.tool}: ${ask.detail}`.slice(0, 300) : job.needs.slice(0, 300);
763
1208
  try {
764
- 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);
765
1212
  const summary = missions.summaryFor(req.params.missionId);
766
1213
  const policy = summary.policy ?? policies.get();
767
1214
  const grant = approved ? permissionGrant(job.needs) : undefined;
@@ -796,6 +1243,21 @@ Revise the structured plan to remove that requirement and stop work that was nee
796
1243
  return { error: err.message };
797
1244
  }
798
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
+ }
799
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));
800
1262
  const pending = nodeEvents.find((event) => event.phase === "blocked_on_user" && event.decisionId === req.params.decisionId);
801
1263
  const alreadyResolved = nodeEvents.some((event) => event.phase === "decision_resolved" && event.decisionId === req.params.decisionId);
@@ -856,14 +1318,11 @@ Revise the structured plan to remove that requirement and stop work that was nee
856
1318
  return await withMissionQueue(req.params.missionId, async () => {
857
1319
  const recent = messages.modelContext(req.params.missionId);
858
1320
  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
- }
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);
867
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 };
868
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)}`;
869
1328
  const result = mission.provider === "codex"
@@ -1053,6 +1512,7 @@ Revise the structured plan to remove that requirement and stop work that was nee
1053
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" });
1054
1513
  return { ok: true };
1055
1514
  });
1515
+ return { sendToMission };
1056
1516
  }
1057
1517
  function sessionForId(watcher, sessionId) {
1058
1518
  for (const session of watcher.getAll().values()) {