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