@shanesaravia/hive 0.1.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 +17 -0
- package/LICENSE +21 -0
- package/README.md +417 -0
- package/dist/bin/hive-emit.js +75 -0
- package/dist/bin/hive.js +506 -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/status.d.ts +12 -0
- package/node_modules/@hive/shared/dist/status.js +52 -0
- package/node_modules/@hive/shared/dist/types.d.ts +384 -0
- package/node_modules/@hive/shared/dist/types.js +14 -0
- package/node_modules/@hive/shared/package.json +18 -0
- package/package.json +72 -0
- package/packages/server/dist/api/rest.js +793 -0
- package/packages/server/dist/api/ws.js +37 -0
- package/packages/server/dist/config.js +24 -0
- package/packages/server/dist/control/codexRuntime.js +169 -0
- package/packages/server/dist/control/killer.js +25 -0
- package/packages/server/dist/control/launcher.js +114 -0
- package/packages/server/dist/control/messaging.js +75 -0
- package/packages/server/dist/control/nativeCommands.js +29 -0
- package/packages/server/dist/control/permissionPark.js +23 -0
- package/packages/server/dist/control/providerModels.js +53 -0
- package/packages/server/dist/events/eventsStore.js +55 -0
- package/packages/server/dist/health/deriveAlerts.js +55 -0
- package/packages/server/dist/hooks/hookIngest.js +90 -0
- package/packages/server/dist/hooks/hookSpool.js +33 -0
- package/packages/server/dist/hooks/setupHooks.js +102 -0
- package/packages/server/dist/index.js +88 -0
- package/packages/server/dist/messages/messagesStore.js +211 -0
- package/packages/server/dist/missions/missionsStore.js +283 -0
- package/packages/server/dist/paths/pathResolver.js +167 -0
- package/packages/server/dist/plans/plansStore.js +212 -0
- package/packages/server/dist/policies/policiesStore.js +61 -0
- package/packages/server/dist/reports/githubPublisher.js +21 -0
- package/packages/server/dist/reports/missionReport.js +16 -0
- package/packages/server/dist/roster/rosterBuilder.js +243 -0
- package/packages/server/dist/security/originPolicy.js +31 -0
- package/packages/server/dist/skills/skillDiscovery.js +69 -0
- package/packages/server/dist/templates/templateDiscovery.js +97 -0
- package/packages/server/dist/watch/jobsWatcher.js +224 -0
- package/packages/server/dist/watch/sessionsWatcher.js +65 -0
- package/packages/web/dist/assets/index-CrKMFCkZ.js +11 -0
- package/packages/web/dist/assets/index-gEGU_lr3.css +2 -0
- package/packages/web/dist/favicon.svg +12 -0
- package/packages/web/dist/index.html +14 -0
- package/templates/agents/hive-orchestrator.md +42 -0
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { CONTEXT_BUDGETS } from "../messages/messagesStore.js";
|
|
3
|
+
import { allPlanTasks } from "../plans/plansStore.js";
|
|
4
|
+
import { buildFleetSnapshot } from "../roster/rosterBuilder.js";
|
|
5
|
+
import { toHiveEvent } from "../hooks/hookIngest.js";
|
|
6
|
+
import { startOrchestrator } from "../control/launcher.js";
|
|
7
|
+
import { matchNativeCommand, runNativeCommand } from "../control/nativeCommands.js";
|
|
8
|
+
import { sendMessage } from "../control/messaging.js";
|
|
9
|
+
import { parsePermissionAsk, permissionGrant } from "../control/permissionPark.js";
|
|
10
|
+
import { forceStopSession, stopSession } from "../control/killer.js";
|
|
11
|
+
import { discoverSkills } from "../skills/skillDiscovery.js";
|
|
12
|
+
import { detectRepositoryMentions, foreignRepositoryForPath, inspectWorkingDirectory, recentRepositories, requireWorkingDirectory, suggestDirectories } from "../paths/pathResolver.js";
|
|
13
|
+
import { missionReport, reportMarkdown } from "../reports/missionReport.js";
|
|
14
|
+
import { createTemplate, deleteTemplate, discoverTemplates, discoverTemplatesDetailed, updateTemplate } from "../templates/templateDiscovery.js";
|
|
15
|
+
import { publishGitHubReport } from "../reports/githubPublisher.js";
|
|
16
|
+
import { enforceWorkingDirectory, normalize } from "../policies/policiesStore.js";
|
|
17
|
+
import { detectProviderModels } from "../control/providerModels.js";
|
|
18
|
+
export function registerRest(app, deps) {
|
|
19
|
+
const { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex } = deps;
|
|
20
|
+
const missionSendTails = new Map();
|
|
21
|
+
function latestMissionTarget(missionId) {
|
|
22
|
+
const candidates = [...jobsWatcher.getAll()].filter(([jobId]) => missions.missionFor(jobId) === missionId);
|
|
23
|
+
candidates.sort((a, b) => Date.parse(b[1].createdAt ?? "") - Date.parse(a[1].createdAt ?? ""));
|
|
24
|
+
const latest = candidates[0];
|
|
25
|
+
const sessionId = missions.latestSessionFor(missionId) ?? latest?.[1].sessionId ?? latest?.[1].resumeSessionId;
|
|
26
|
+
return latest && sessionId ? { jobId: latest[0], sessionId } : undefined;
|
|
27
|
+
}
|
|
28
|
+
async function withMissionQueue(missionId, run) {
|
|
29
|
+
const previous = missionSendTails.get(missionId) ?? Promise.resolve();
|
|
30
|
+
const operation = previous.catch(() => undefined).then(run);
|
|
31
|
+
const tail = operation.then(() => undefined, () => undefined);
|
|
32
|
+
missionSendTails.set(missionId, tail);
|
|
33
|
+
try {
|
|
34
|
+
return await operation;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
if (missionSendTails.get(missionId) === tail)
|
|
38
|
+
missionSendTails.delete(missionId);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function sendToMission(missionId, text) {
|
|
42
|
+
return withMissionQueue(missionId, async () => {
|
|
43
|
+
const target = latestMissionTarget(missionId);
|
|
44
|
+
if (!target)
|
|
45
|
+
throw new Error("mission not found");
|
|
46
|
+
const summary = missions.summaryFor(missionId);
|
|
47
|
+
const policy = summary.policy ?? policies.get();
|
|
48
|
+
// "add this to monorepo" should just work: repos mentioned by name get
|
|
49
|
+
// --add-dir access, persisted so every later turn keeps the grant.
|
|
50
|
+
const mentioned = detectRepositoryMentions(text, summary.repository);
|
|
51
|
+
const additionalRepos = [...new Set([...(summary.additionalRepositories ?? []), ...mentioned])];
|
|
52
|
+
if (mentioned.length)
|
|
53
|
+
missions.addRepositories(missionId, mentioned);
|
|
54
|
+
const prompt = additionalRepos.length ? `${text}\n\n(Hive: you also have tool access to these repositories: ${additionalRepos.join(", ")})` : text;
|
|
55
|
+
const result = summary.provider === "codex"
|
|
56
|
+
? await codex.start({ task: prompt, cwd: summary.repository, model: summary.model, mode: summary.mode, policy, resumeSessionId: target.sessionId })
|
|
57
|
+
: await sendMessage(target.sessionId, prompt, policy, additionalRepos);
|
|
58
|
+
missions.linkJob(missionId, result.jobId, result.sessionId);
|
|
59
|
+
messages.add({ id: `${result.jobId}:user`, missionId, role: "user", text, createdAt: Date.now(), jobId: result.jobId });
|
|
60
|
+
// A follow-up to a closed mission is an implicit reopen — work resumed.
|
|
61
|
+
if (["completed", "archived", "failed", "paused"].includes(summary.lifecycleStatus)) {
|
|
62
|
+
missions.setLifecycleStatus(missionId, "active");
|
|
63
|
+
events.add({ ts: Date.now(), sessionId: result.sessionId ?? target.sessionId, jobId: result.jobId, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Mission reopened from ${summary.lifecycleStatus} by a new message` });
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
app.get("/api/fleet", async () => buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans));
|
|
69
|
+
app.get("/api/policies", async () => ({ policy: policies.get() }));
|
|
70
|
+
app.get("/api/providers/models", async () => ({ providers: detectProviderModels() }));
|
|
71
|
+
app.put("/api/policies", async (req, reply) => {
|
|
72
|
+
try {
|
|
73
|
+
const policy = policies.set(req.body ?? {});
|
|
74
|
+
events.add({ ts: Date.now(), sessionId: "hive:policies", source: "custom", phase: "custom", activityKind: "decision", detail: "Workspace permission defaults updated by user" });
|
|
75
|
+
return { policy };
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
reply.code(400);
|
|
79
|
+
return { error: err.message };
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
app.post("/api/templates/discover", async (req, reply) => { try {
|
|
83
|
+
return discoverTemplatesDetailed(req.body?.cwd);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
reply.code(400);
|
|
87
|
+
return { error: err.message };
|
|
88
|
+
} });
|
|
89
|
+
app.post("/api/templates", async (req, reply) => { try {
|
|
90
|
+
if (req.body?.source !== "user" && req.body?.source !== "repository")
|
|
91
|
+
throw new Error("template source must be user or repository");
|
|
92
|
+
return { template: createTemplate(req.body.template, req.body.source, req.body.cwd) };
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
reply.code(400);
|
|
96
|
+
return { error: err.message };
|
|
97
|
+
} });
|
|
98
|
+
app.put("/api/templates/:source/:id", async (req, reply) => { try {
|
|
99
|
+
if (req.params.source !== "user" && req.params.source !== "repository")
|
|
100
|
+
throw new Error("built-in templates are read-only");
|
|
101
|
+
return { template: updateTemplate(req.params.id, req.body.template, req.params.source, req.body.cwd) };
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
reply.code(400);
|
|
105
|
+
return { error: err.message };
|
|
106
|
+
} });
|
|
107
|
+
app.delete("/api/templates/:source/:id", async (req, reply) => { try {
|
|
108
|
+
if (req.params.source !== "user" && req.params.source !== "repository")
|
|
109
|
+
throw new Error("built-in templates cannot be deleted");
|
|
110
|
+
deleteTemplate(req.params.id, req.params.source, req.query.cwd);
|
|
111
|
+
events.add({ ts: Date.now(), sessionId: "hive:templates", source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Template deleted: ${req.params.source}/${req.params.id}${req.query.cwd ? ` · ${req.query.cwd}` : ""}` });
|
|
112
|
+
return { ok: true };
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
reply.code(400);
|
|
116
|
+
return { error: err.message };
|
|
117
|
+
} });
|
|
118
|
+
app.get("/api/mission/:missionId/report", async (req, reply) => {
|
|
119
|
+
const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans);
|
|
120
|
+
const node = snapshot.orchestrators.find((item) => item.missionId === req.params.missionId);
|
|
121
|
+
if (!node) {
|
|
122
|
+
reply.code(404);
|
|
123
|
+
return { error: "mission not found" };
|
|
124
|
+
}
|
|
125
|
+
const report = missionReport(node, snapshot.decisions.filter((decision) => decision.missionId === node.missionId));
|
|
126
|
+
const safeName = node.name.replace(/[^a-z0-9_-]+/gi, "-").replace(/^-|-$/g, "") || "mission";
|
|
127
|
+
if (req.query.format === "json")
|
|
128
|
+
return reply.header("Content-Disposition", `attachment; filename="${safeName}-report.json"`).type("application/json").send(JSON.stringify(report, null, 2));
|
|
129
|
+
return reply.header("Content-Disposition", `attachment; filename="${safeName}-report.md"`).type("text/markdown").send(reportMarkdown(report));
|
|
130
|
+
});
|
|
131
|
+
app.post("/api/mission/:missionId/report/github", async (req, reply) => {
|
|
132
|
+
if (!req.body?.confirmed) {
|
|
133
|
+
reply.code(400);
|
|
134
|
+
return { error: "explicit confirmation is required before publishing externally" };
|
|
135
|
+
}
|
|
136
|
+
if (req.body.kind !== "issue" && req.body.kind !== "pr") {
|
|
137
|
+
reply.code(400);
|
|
138
|
+
return { error: "choose a GitHub issue or pull request" };
|
|
139
|
+
}
|
|
140
|
+
const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans);
|
|
141
|
+
const node = snapshot.orchestrators.find((item) => item.missionId === req.params.missionId);
|
|
142
|
+
if (!node?.mission.repository) {
|
|
143
|
+
reply.code(404);
|
|
144
|
+
return { error: "mission repository not found" };
|
|
145
|
+
}
|
|
146
|
+
const target = { sessionId: node.sessionId, jobId: node.jobId };
|
|
147
|
+
events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `External publish approved: mission report to GitHub ${req.body.kind} ${req.body.target ?? ""}` });
|
|
148
|
+
try {
|
|
149
|
+
const report = missionReport(node, snapshot.decisions.filter((decision) => decision.missionId === node.missionId));
|
|
150
|
+
await publishGitHubReport({ kind: req.body.kind, target: req.body.target ?? "", markdown: reportMarkdown(report), cwd: node.mission.repository });
|
|
151
|
+
events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "output", outcome: "success", detail: `Mission report published to GitHub ${req.body.kind} ${req.body.target}` });
|
|
152
|
+
return { ok: true };
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "output", outcome: "failure", detail: `GitHub report publish failed: ${err.message}` });
|
|
156
|
+
reply.code(400);
|
|
157
|
+
return { error: err.message };
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
app.get("/api/session/:sessionId/timeline", async (req) => {
|
|
161
|
+
return { events: events.recentFor(req.params.sessionId, 200) };
|
|
162
|
+
});
|
|
163
|
+
app.post("/hooks/:eventName", async (req, reply) => {
|
|
164
|
+
const payload = {
|
|
165
|
+
...req.body,
|
|
166
|
+
hook_event_name: req.body.hook_event_name ?? req.params.eventName,
|
|
167
|
+
};
|
|
168
|
+
if (!payload.session_id) {
|
|
169
|
+
reply.code(400);
|
|
170
|
+
return { error: "missing session_id" };
|
|
171
|
+
}
|
|
172
|
+
const jobId = sessionForId(sessionsWatcher, payload.session_id)?.jobId;
|
|
173
|
+
const event = toHiveEvent(payload, jobId);
|
|
174
|
+
events.add(event);
|
|
175
|
+
// A write landing in another known repo marks that repo as touched by
|
|
176
|
+
// the mission, so its footprint chips reflect reality, not just intent.
|
|
177
|
+
if (event.activityKind === "file_write" && event.filePath && jobId) {
|
|
178
|
+
const missionId = missions.missionFor(jobId);
|
|
179
|
+
const mission = missions.get(missionId);
|
|
180
|
+
if (mission) {
|
|
181
|
+
const touched = foreignRepositoryForPath(event.filePath, mission.repository);
|
|
182
|
+
if (touched)
|
|
183
|
+
missions.addRepositories(missionId, [touched]);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { ok: true };
|
|
187
|
+
});
|
|
188
|
+
app.post("/events", async (req, reply) => {
|
|
189
|
+
const { sessionId, jobId, phase, detail, targetWorker, targetTask } = req.body;
|
|
190
|
+
if (!sessionId || !phase || !detail) {
|
|
191
|
+
reply.code(400);
|
|
192
|
+
return { error: "sessionId, phase, and detail are required" };
|
|
193
|
+
}
|
|
194
|
+
const effectiveJobId = jobId ?? sessionForId(sessionsWatcher, sessionId)?.jobId;
|
|
195
|
+
let eventDetail = detail;
|
|
196
|
+
if (phase === "plan_updated") {
|
|
197
|
+
if (!effectiveJobId) {
|
|
198
|
+
reply.code(404);
|
|
199
|
+
return { error: "could not resolve mission for plan update" };
|
|
200
|
+
}
|
|
201
|
+
const missionId = missions.missionFor(effectiveJobId);
|
|
202
|
+
if (!missions.get(missionId)) {
|
|
203
|
+
reply.code(404);
|
|
204
|
+
return { error: "mission not found for plan update" };
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const plan = plans.replace(missionId, JSON.parse(detail));
|
|
208
|
+
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"}`;
|
|
209
|
+
const current = plan.phases.find((item) => ["working", "reviewing", "blocked"].includes(item.status));
|
|
210
|
+
const blocked = plan.phases.flatMap((item) => allPlanTasks(item.tasks)).filter((task) => task.status === "blocked").map((task) => task.title);
|
|
211
|
+
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 });
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
reply.code(400);
|
|
215
|
+
return { error: err.message };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
let decision;
|
|
219
|
+
if (phase === "blocked_on_user") {
|
|
220
|
+
try {
|
|
221
|
+
decision = JSON.parse(detail);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
decision = { question: detail };
|
|
225
|
+
}
|
|
226
|
+
eventDetail = decision?.question?.trim() || detail;
|
|
227
|
+
}
|
|
228
|
+
events.add({
|
|
229
|
+
ts: Date.now(),
|
|
230
|
+
sessionId,
|
|
231
|
+
jobId: effectiveJobId,
|
|
232
|
+
source: "custom",
|
|
233
|
+
phase,
|
|
234
|
+
activityKind: phase === "blocked_on_user" ? "decision" : undefined,
|
|
235
|
+
detail: eventDetail,
|
|
236
|
+
targetWorker,
|
|
237
|
+
targetTask,
|
|
238
|
+
decisionId: phase === "blocked_on_user" ? decision?.id ?? randomUUID() : undefined,
|
|
239
|
+
decisionKind: phase === "blocked_on_user" ? decision?.kind ?? "question" : undefined,
|
|
240
|
+
choices: phase === "blocked_on_user" && Array.isArray(decision?.choices) ? decision.choices.slice(0, 6).map(String) : undefined,
|
|
241
|
+
recommendation: phase === "blocked_on_user" ? decision?.recommendation : undefined,
|
|
242
|
+
impact: phase === "blocked_on_user" ? decision?.impact : undefined,
|
|
243
|
+
context: phase === "blocked_on_user" ? decision?.context : undefined,
|
|
244
|
+
});
|
|
245
|
+
if (phase === "ready_for_review" && effectiveJobId) {
|
|
246
|
+
const missionId = missions.missionFor(effectiveJobId);
|
|
247
|
+
if (missions.get(missionId)) {
|
|
248
|
+
missions.setLifecycleStatus(missionId, "ready_for_review");
|
|
249
|
+
missions.updateContext(missionId, { compactSummary: detail.slice(0, 1_200), currentState: "Awaiting user acceptance" });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if ((phase === "delegating" || phase === "worker_reported" || phase === "reviewing") && effectiveJobId) {
|
|
253
|
+
const missionId = missions.missionFor(effectiveJobId);
|
|
254
|
+
if (missions.get(missionId)) {
|
|
255
|
+
// Best-effort: keeps plan task statuses truthful when the
|
|
256
|
+
// orchestrator emits activity without republishing the plan.
|
|
257
|
+
try {
|
|
258
|
+
plans.applyTaskEvent(missionId, { phase, targetTask, targetWorker, evidence: phase === "worker_reported" ? detail : undefined });
|
|
259
|
+
}
|
|
260
|
+
catch { /* a later plan_updated remains the source of truth */ }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return { ok: true };
|
|
264
|
+
});
|
|
265
|
+
app.get("/api/mission/:missionId/plan", async (req, reply) => {
|
|
266
|
+
if (!missions.get(req.params.missionId)) {
|
|
267
|
+
reply.code(404);
|
|
268
|
+
return { error: "mission not found" };
|
|
269
|
+
}
|
|
270
|
+
return { plan: plans.get(req.params.missionId), revisions: plans.revisions(req.params.missionId).map(({ revision, updatedAt }) => ({ revision, updatedAt })) };
|
|
271
|
+
});
|
|
272
|
+
app.get("/api/mission/:missionId/plan/revisions", async (req, reply) => {
|
|
273
|
+
if (!missions.get(req.params.missionId)) {
|
|
274
|
+
reply.code(404);
|
|
275
|
+
return { error: "mission not found" };
|
|
276
|
+
}
|
|
277
|
+
return { revisions: plans.revisions(req.params.missionId) };
|
|
278
|
+
});
|
|
279
|
+
app.post("/api/mission/:missionId/plan", async (req, reply) => {
|
|
280
|
+
if (!missions.get(req.params.missionId)) {
|
|
281
|
+
reply.code(404);
|
|
282
|
+
return { error: "mission not found" };
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
return { plan: plans.replace(req.params.missionId, req.body) };
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
reply.code(400);
|
|
289
|
+
return { error: err.message };
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
app.post("/api/mission/:missionId/gate/:gateId/waive", async (req, reply) => {
|
|
293
|
+
try {
|
|
294
|
+
const plan = plans.waiveGate(req.params.missionId, req.params.gateId, req.body?.reason ?? "");
|
|
295
|
+
const gate = plan.gates.find((item) => item.id === req.params.gateId);
|
|
296
|
+
const target = latestMissionTarget(req.params.missionId);
|
|
297
|
+
if (target)
|
|
298
|
+
events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "decision", detail: `Completion gate waived: ${gate.label}. Reason: ${gate.waiver?.reason}` });
|
|
299
|
+
return { ok: true, plan };
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
reply.code(400);
|
|
303
|
+
return { error: err.message };
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
app.post("/api/mission/:missionId/plan/approval", async (req, reply) => {
|
|
307
|
+
if (req.body?.status !== "approved" && req.body?.status !== "rejected") {
|
|
308
|
+
reply.code(400);
|
|
309
|
+
return { error: "invalid plan approval status" };
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const plan = plans.setApproval(req.params.missionId, req.body.status, req.body.reason);
|
|
313
|
+
const target = latestMissionTarget(req.params.missionId);
|
|
314
|
+
if (target)
|
|
315
|
+
events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "decision", detail: req.body.status === "approved" ? `Plan revision ${plan.revision - 1} approved` : `Plan rejected: ${plan.approvalReason}` });
|
|
316
|
+
if (req.body.status === "rejected") {
|
|
317
|
+
try {
|
|
318
|
+
await sendToMission(req.params.missionId, `The current plan was rejected. Revise it before continuing. Feedback: ${plan.approvalReason}`);
|
|
319
|
+
}
|
|
320
|
+
catch { /* rejection remains recorded even when no live job can resume */ }
|
|
321
|
+
}
|
|
322
|
+
return { ok: true, plan };
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
reply.code(400);
|
|
326
|
+
return { error: err.message };
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
app.post("/api/orchestrator/start", async (req, reply) => {
|
|
330
|
+
const { task, name, cwd, templateId } = req.body;
|
|
331
|
+
if (!task) {
|
|
332
|
+
reply.code(400);
|
|
333
|
+
return { error: "task is required" };
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
const template = templateId ? discoverTemplates(cwd).find((item) => item.id === templateId) : undefined;
|
|
337
|
+
if (templateId && !template) {
|
|
338
|
+
reply.code(400);
|
|
339
|
+
return { error: `mission template not found: ${templateId}` };
|
|
340
|
+
}
|
|
341
|
+
const mode = req.body.mode ?? template?.mode;
|
|
342
|
+
const budgets = { ...(template?.budgets ?? {}), ...(req.body.budgets ?? {}) };
|
|
343
|
+
const worktree = req.body.worktree ?? template?.worktree;
|
|
344
|
+
const policy = normalize({
|
|
345
|
+
...policies.get(),
|
|
346
|
+
...(template ? {
|
|
347
|
+
commits: template.permissions.commits,
|
|
348
|
+
pushes: template.permissions.pushes,
|
|
349
|
+
pullRequests: template.permissions.pullRequests,
|
|
350
|
+
releases: template.permissions.releases,
|
|
351
|
+
destructiveActions: template.permissions.destructiveActions,
|
|
352
|
+
networkAccess: template.permissions.network,
|
|
353
|
+
} : {}),
|
|
354
|
+
...(req.body.policy ?? {}),
|
|
355
|
+
});
|
|
356
|
+
const resolvedCwd = requireWorkingDirectory(cwd);
|
|
357
|
+
enforceWorkingDirectory(resolvedCwd, policy);
|
|
358
|
+
const limits = Object.values(budgets).some(Boolean) ? `\n\nHive mission limits (soft boundaries): ${JSON.stringify(budgets)}. Stay within these limits. Before exceeding one, stop and emit blocked_on_user with a concrete approval request and the projected additional usage.` : "";
|
|
359
|
+
const templatePrompt = template ? `\n\nMission template ${template.name} v${template.version}: ${template.managerInstructions}\nPermissions: ${JSON.stringify(template.permissions)}. Request approval before any disallowed action.` : "";
|
|
360
|
+
const policyPrompt = `\n\nHive permission policy: ${JSON.stringify(policy)}. These are hard boundaries where enforced by CLI tool restrictions. Before any action outside the policy, stop and emit blocked_on_user with the exact access needed; do not attempt to bypass the restriction.`;
|
|
361
|
+
const provider = req.body.provider ?? "claude";
|
|
362
|
+
const model = req.body.model?.trim() || undefined;
|
|
363
|
+
const additionalRepos = detectRepositoryMentions(task, resolvedCwd);
|
|
364
|
+
const repoPrompt = additionalRepos.length ? `\n\n(Hive: you also have tool access to these repositories: ${additionalRepos.join(", ")})` : "";
|
|
365
|
+
const prompt = task + templatePrompt + limits + policyPrompt + repoPrompt;
|
|
366
|
+
const result = provider === "codex"
|
|
367
|
+
? await codex.start({ task: prompt, cwd: resolvedCwd, name, model, mode, policy })
|
|
368
|
+
: await startOrchestrator({ task: prompt, worktree, name, cwd: resolvedCwd, mode, policy, model, addDirs: additionalRepos });
|
|
369
|
+
const mission = missions.create({
|
|
370
|
+
name,
|
|
371
|
+
objective: task,
|
|
372
|
+
repository: result.cwd,
|
|
373
|
+
jobId: result.jobId,
|
|
374
|
+
sessionId: result.sessionId,
|
|
375
|
+
mode,
|
|
376
|
+
budgets,
|
|
377
|
+
template: template ? { id: template.id, name: template.name, version: template.version, source: template.source } : undefined,
|
|
378
|
+
policy,
|
|
379
|
+
provider,
|
|
380
|
+
model,
|
|
381
|
+
additionalRepositories: additionalRepos,
|
|
382
|
+
});
|
|
383
|
+
messages.add({
|
|
384
|
+
id: `${result.jobId}:user`,
|
|
385
|
+
missionId: mission.id,
|
|
386
|
+
role: "user",
|
|
387
|
+
text: task,
|
|
388
|
+
createdAt: Date.now(),
|
|
389
|
+
jobId: result.jobId,
|
|
390
|
+
});
|
|
391
|
+
if (template)
|
|
392
|
+
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 });
|
|
393
|
+
return { ...result, missionId: mission.id };
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
reply.code(400);
|
|
397
|
+
return { error: err.message };
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
app.post("/api/skills/discover", async (req, reply) => {
|
|
401
|
+
try {
|
|
402
|
+
return { skills: discoverSkills(req.body?.cwd) };
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
reply.code(400);
|
|
406
|
+
return { error: err.message };
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
app.post("/api/session/:sessionId/adopt", async (req, reply) => {
|
|
410
|
+
const session = [...sessionsWatcher.getAll().values()].find((candidate) => candidate.sessionId === req.params.sessionId);
|
|
411
|
+
if (!session) {
|
|
412
|
+
reply.code(404);
|
|
413
|
+
return { error: "session not found" };
|
|
414
|
+
}
|
|
415
|
+
if (!session.jobId) {
|
|
416
|
+
reply.code(409);
|
|
417
|
+
return { error: "Only Claude background jobs can be adopted; this interactive process can still be ended or hidden." };
|
|
418
|
+
}
|
|
419
|
+
const existingId = missions.missionFor(session.jobId);
|
|
420
|
+
const existing = missions.get(existingId);
|
|
421
|
+
if (existing)
|
|
422
|
+
return { ok: true, missionId: existing.id };
|
|
423
|
+
const job = jobsWatcher.getAll().get(session.jobId);
|
|
424
|
+
const mission = missions.create({
|
|
425
|
+
name: req.body?.name ?? job?.name ?? session.name,
|
|
426
|
+
objective: job?.intent ?? job?.detail ?? "Adopted Claude session",
|
|
427
|
+
repository: session.cwd,
|
|
428
|
+
jobId: session.jobId,
|
|
429
|
+
sessionId: session.sessionId,
|
|
430
|
+
mode: "direct",
|
|
431
|
+
});
|
|
432
|
+
if (job?.intent)
|
|
433
|
+
messages.add({ id: `${session.jobId}:user`, missionId: mission.id, role: "user", text: job.intent, createdAt: Date.parse(job.createdAt ?? "") || Date.now(), jobId: session.jobId });
|
|
434
|
+
return { ok: true, missionId: mission.id };
|
|
435
|
+
});
|
|
436
|
+
app.post("/api/directories/suggest", async (req) => ({
|
|
437
|
+
directories: req.body?.input?.trim() ? suggestDirectories(req.body.input) : recentRepositories(),
|
|
438
|
+
}));
|
|
439
|
+
app.post("/api/paths/inspect", async (req) => inspectWorkingDirectory(req.body?.input));
|
|
440
|
+
app.post("/api/session/:pid/message", async (req, reply) => {
|
|
441
|
+
const pid = Number(req.params.pid);
|
|
442
|
+
const session = sessionsWatcher.getAll().get(pid);
|
|
443
|
+
if (!session) {
|
|
444
|
+
reply.code(404);
|
|
445
|
+
return { error: "session not found" };
|
|
446
|
+
}
|
|
447
|
+
if (!req.body.text) {
|
|
448
|
+
reply.code(400);
|
|
449
|
+
return { error: "text is required" };
|
|
450
|
+
}
|
|
451
|
+
const result = await sendMessage(session.sessionId, req.body.text);
|
|
452
|
+
const missionId = session.jobId
|
|
453
|
+
? missions.missionFor(session.jobId)
|
|
454
|
+
: session.sessionId;
|
|
455
|
+
missions.linkJob(missionId, result.jobId, result.sessionId);
|
|
456
|
+
messages.add({
|
|
457
|
+
id: `${result.jobId}:user`,
|
|
458
|
+
missionId,
|
|
459
|
+
role: "user",
|
|
460
|
+
text: req.body.text,
|
|
461
|
+
createdAt: Date.now(),
|
|
462
|
+
jobId: result.jobId,
|
|
463
|
+
});
|
|
464
|
+
return { ok: true };
|
|
465
|
+
});
|
|
466
|
+
app.post("/api/mission/:missionId/message", async (req, reply) => {
|
|
467
|
+
const text = req.body?.text?.trim();
|
|
468
|
+
if (!text) {
|
|
469
|
+
reply.code(400);
|
|
470
|
+
return { error: "text is required" };
|
|
471
|
+
}
|
|
472
|
+
const native = matchNativeCommand(text);
|
|
473
|
+
if (native) {
|
|
474
|
+
const missionId = req.params.missionId;
|
|
475
|
+
if (!missions.get(missionId)) {
|
|
476
|
+
reply.code(404);
|
|
477
|
+
return { error: "mission not found" };
|
|
478
|
+
}
|
|
479
|
+
const jobRef = `native:${randomUUID()}`;
|
|
480
|
+
const summary = missions.summaryFor(missionId);
|
|
481
|
+
const target = native.scope === "session" ? latestMissionTarget(missionId) : undefined;
|
|
482
|
+
messages.add({ id: `${jobRef}:user`, missionId, role: "user", text, createdAt: Date.now(), jobId: jobRef });
|
|
483
|
+
// Runs out-of-band: native commands hang as background-session prompts,
|
|
484
|
+
// and the answer lands in the transcript when the CLI finishes.
|
|
485
|
+
void runNativeCommand(native, { cwd: summary.repository, sessionId: target?.sessionId })
|
|
486
|
+
.then((output) => messages.add({ id: `${jobRef}:assistant`, missionId, role: "assistant", text: output, createdAt: Date.now(), jobId: jobRef }))
|
|
487
|
+
.catch((err) => messages.add({ id: `${jobRef}:assistant`, missionId, role: "assistant", text: `⚠️ /${native.name} failed: ${err.message}`, createdAt: Date.now(), jobId: jobRef }));
|
|
488
|
+
return { ok: true, jobId: jobRef };
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
const result = await sendToMission(req.params.missionId, text);
|
|
492
|
+
return { ok: true, jobId: result.jobId };
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
reply.code(404);
|
|
496
|
+
return { error: err.message };
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
app.post("/api/mission/:missionId/decision/:decisionId/answer", async (req, reply) => {
|
|
500
|
+
const answer = req.body?.answer?.trim();
|
|
501
|
+
if (!answer) {
|
|
502
|
+
reply.code(400);
|
|
503
|
+
return { error: "answer is required" };
|
|
504
|
+
}
|
|
505
|
+
const mission = missions.get(req.params.missionId);
|
|
506
|
+
if (!mission) {
|
|
507
|
+
reply.code(404);
|
|
508
|
+
return { error: "mission not found" };
|
|
509
|
+
}
|
|
510
|
+
// Parked permission prompts (decision id perm:<jobId>): a background
|
|
511
|
+
// session cannot answer its own prompt, so end the parked turn and
|
|
512
|
+
// resume the conversation with the verdict — pre-allowing the asked
|
|
513
|
+
// tool on approval so the retry does not park again.
|
|
514
|
+
if (req.params.decisionId.startsWith("perm:")) {
|
|
515
|
+
const jobId = req.params.decisionId.slice(5);
|
|
516
|
+
const job = jobsWatcher.getAll().get(jobId);
|
|
517
|
+
if (!job || job.tempo !== "blocked" || !job.needs) {
|
|
518
|
+
reply.code(409);
|
|
519
|
+
return { error: "permission request is no longer pending" };
|
|
520
|
+
}
|
|
521
|
+
const sessionId = job.sessionId ?? job.resumeSessionId;
|
|
522
|
+
if (!sessionId) {
|
|
523
|
+
reply.code(500);
|
|
524
|
+
return { error: "no session to resume" };
|
|
525
|
+
}
|
|
526
|
+
const approved = /^(approve|allow|yes)/i.test(answer);
|
|
527
|
+
const ask = parsePermissionAsk(job.needs);
|
|
528
|
+
const askLabel = ask ? `${ask.tool}: ${ask.detail}`.slice(0, 300) : job.needs.slice(0, 300);
|
|
529
|
+
try {
|
|
530
|
+
await stopSession(jobId);
|
|
531
|
+
const summary = missions.summaryFor(req.params.missionId);
|
|
532
|
+
const policy = summary.policy ?? policies.get();
|
|
533
|
+
const grant = approved ? permissionGrant(job.needs) : undefined;
|
|
534
|
+
const text = approved
|
|
535
|
+
? `Your permission request (${askLabel}) is approved and now pre-allowed. Re-run it and continue where you left off.`
|
|
536
|
+
: `Your permission request (${askLabel}) was denied${/^deny$/i.test(answer) ? "" : ` — ${answer}`}. Continue without it or propose an alternative.`;
|
|
537
|
+
const result = await sendMessage(sessionId, text, grant ? { ...policy, allowedTools: [...policy.allowedTools, grant] } : policy);
|
|
538
|
+
missions.linkJob(req.params.missionId, result.jobId, result.sessionId);
|
|
539
|
+
messages.add({ id: `${result.jobId}:user`, missionId: req.params.missionId, role: "user", text, createdAt: Date.now(), jobId: result.jobId });
|
|
540
|
+
events.add({ ts: Date.now(), sessionId, jobId: result.jobId, source: "custom", phase: "decision_resolved", activityKind: "decision", detail: answer, decisionId: req.params.decisionId });
|
|
541
|
+
return { ok: true };
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
reply.code(500);
|
|
545
|
+
return { error: err.message };
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
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));
|
|
549
|
+
const pending = nodeEvents.find((event) => event.phase === "blocked_on_user" && event.decisionId === req.params.decisionId);
|
|
550
|
+
const alreadyResolved = nodeEvents.some((event) => event.phase === "decision_resolved" && event.decisionId === req.params.decisionId);
|
|
551
|
+
if (!pending || alreadyResolved) {
|
|
552
|
+
reply.code(409);
|
|
553
|
+
return { error: "decision is no longer pending" };
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
const result = await sendToMission(req.params.missionId, `Answer to your pending question: ${answer}`);
|
|
557
|
+
events.add({ ts: Date.now(), sessionId: result.sessionId ?? pending.sessionId, jobId: result.jobId, source: "custom", phase: "decision_resolved", activityKind: "decision", detail: answer, decisionId: req.params.decisionId });
|
|
558
|
+
return { ok: true };
|
|
559
|
+
}
|
|
560
|
+
catch (err) {
|
|
561
|
+
reply.code(500);
|
|
562
|
+
return { error: err.message };
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
app.get("/api/mission/:missionId/messages", async (req) => {
|
|
566
|
+
const before = req.query.before ? Number(req.query.before) : undefined;
|
|
567
|
+
const limit = req.query.limit ? Number(req.query.limit) : undefined;
|
|
568
|
+
return messages.page(req.params.missionId, {
|
|
569
|
+
before: Number.isFinite(before) ? before : undefined,
|
|
570
|
+
limit: Number.isFinite(limit) ? limit : undefined,
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
app.get("/api/mission/:missionId/context", async (req, reply) => {
|
|
574
|
+
const mission = missions.get(req.params.missionId);
|
|
575
|
+
if (!mission) {
|
|
576
|
+
reply.code(404);
|
|
577
|
+
return { error: "mission not found" };
|
|
578
|
+
}
|
|
579
|
+
const summary = missions.summaryFor(req.params.missionId);
|
|
580
|
+
const recentMessages = messages.modelContext(req.params.missionId);
|
|
581
|
+
const retrievedMessages = messages.relevantContext(req.params.missionId, `${summary.objective} ${summary.compactSummary ?? ""} ${summary.currentState ?? ""}`, new Set(recentMessages.map((message) => message.id)));
|
|
582
|
+
return { mission: summary, recentMessages, retrievedMessages, budgets: CONTEXT_BUDGETS, estimatedTokens: { summary: Math.ceil(((summary.compactSummary?.length ?? 0) + (summary.currentState?.length ?? 0)) / 4), recent: Math.ceil(recentMessages.reduce((sum, message) => sum + message.text.length, 0) / 4), retrieved: Math.ceil(retrievedMessages.reduce((sum, message) => sum + message.text.length, 0) / 4) } };
|
|
583
|
+
});
|
|
584
|
+
app.post("/api/mission/:missionId/context", async (req) => {
|
|
585
|
+
missions.updateContext(req.params.missionId, req.body ?? {});
|
|
586
|
+
return { ok: true };
|
|
587
|
+
});
|
|
588
|
+
app.post("/api/mission/:missionId/handoff", async (req, reply) => {
|
|
589
|
+
const mission = missions.get(req.params.missionId);
|
|
590
|
+
if (!mission) {
|
|
591
|
+
reply.code(404);
|
|
592
|
+
return { error: "mission not found" };
|
|
593
|
+
}
|
|
594
|
+
try {
|
|
595
|
+
return await withMissionQueue(req.params.missionId, async () => {
|
|
596
|
+
const recent = messages.modelContext(req.params.missionId);
|
|
597
|
+
const retrieved = messages.relevantContext(req.params.missionId, `${mission.objective} ${mission.compactSummary ?? ""} ${mission.currentState ?? ""}`, new Set(recent.map((message) => message.id)));
|
|
598
|
+
const target = latestMissionTarget(req.params.missionId);
|
|
599
|
+
const currentJob = target ? jobsWatcher.getAll().get(target.jobId) : undefined;
|
|
600
|
+
if (target && currentJob && ["working", "busy", "queued"].includes(currentJob.state)) {
|
|
601
|
+
if (mission.provider === "codex")
|
|
602
|
+
await codex.stop(target.jobId);
|
|
603
|
+
else
|
|
604
|
+
await stopSession(target.jobId);
|
|
605
|
+
}
|
|
606
|
+
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 };
|
|
607
|
+
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)}`;
|
|
608
|
+
const result = mission.provider === "codex"
|
|
609
|
+
? await codex.start({ task: prompt, name: mission.name, cwd: mission.repository, mode: mission.mode, policy: mission.policy ?? policies.get(), model: mission.model })
|
|
610
|
+
: await startOrchestrator({ task: prompt, name: mission.name, cwd: mission.repository, mode: mission.mode, worktree: false, policy: mission.policy, model: mission.model });
|
|
611
|
+
missions.linkJob(req.params.missionId, result.jobId, result.sessionId);
|
|
612
|
+
events.add({ ts: Date.now(), sessionId: result.sessionId, jobId: result.jobId, source: "custom", phase: "resuming", activityKind: "lifecycle", detail: `Manager handoff completed; replacement job ${result.jobId.slice(0, 8)} started with bounded context.` });
|
|
613
|
+
return { ok: true, jobId: result.jobId };
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
catch (err) {
|
|
617
|
+
reply.code(500);
|
|
618
|
+
return { error: err.message };
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
app.put("/api/mission/:missionId/policy", async (req, reply) => {
|
|
622
|
+
const mission = missions.get(req.params.missionId);
|
|
623
|
+
if (!mission) {
|
|
624
|
+
reply.code(404);
|
|
625
|
+
return { error: "mission not found" };
|
|
626
|
+
}
|
|
627
|
+
if (!req.body?.confirmed) {
|
|
628
|
+
reply.code(400);
|
|
629
|
+
return { error: "explicit confirmation is required to change a running mission's permissions" };
|
|
630
|
+
}
|
|
631
|
+
try {
|
|
632
|
+
const policy = normalize({ ...(mission.policy ?? policies.get()), ...(req.body.policy ?? {}) });
|
|
633
|
+
if (mission.repository)
|
|
634
|
+
enforceWorkingDirectory(mission.repository, policy);
|
|
635
|
+
missions.setPolicy(req.params.missionId, policy);
|
|
636
|
+
const target = latestMissionTarget(req.params.missionId);
|
|
637
|
+
if (target)
|
|
638
|
+
events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "decision", detail: `Mission permission policy updated by user: ${JSON.stringify(policy)}` });
|
|
639
|
+
return { policy };
|
|
640
|
+
}
|
|
641
|
+
catch (err) {
|
|
642
|
+
reply.code(400);
|
|
643
|
+
return { error: err.message };
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
app.post("/api/mission/:missionId/status", async (req, reply) => {
|
|
647
|
+
const allowed = ["active", "ready_for_review", "paused", "completed", "failed", "archived"];
|
|
648
|
+
if (!allowed.includes(req.body?.status)) {
|
|
649
|
+
reply.code(400);
|
|
650
|
+
return { error: "invalid mission status" };
|
|
651
|
+
}
|
|
652
|
+
if (req.body.status === "completed") {
|
|
653
|
+
const unresolved = plans.unresolvedRequiredGates(req.params.missionId);
|
|
654
|
+
const blocking = unresolved.filter((gate) => gate.type !== "user_approval");
|
|
655
|
+
if (blocking.length) {
|
|
656
|
+
reply.code(409);
|
|
657
|
+
return { error: `Resolve required completion gates first: ${blocking.map((gate) => gate.label).join(", ")}` };
|
|
658
|
+
}
|
|
659
|
+
plans.satisfyUserApprovalGates(req.params.missionId);
|
|
660
|
+
}
|
|
661
|
+
// Legacy Claude jobs can appear as mission cards before Hive has a durable
|
|
662
|
+
// record for them. setLifecycleStatus adopts those cards on first use.
|
|
663
|
+
const previous = missions.summaryFor(req.params.missionId).lifecycleStatus;
|
|
664
|
+
missions.setLifecycleStatus(req.params.missionId, req.body.status);
|
|
665
|
+
const target = latestMissionTarget(req.params.missionId);
|
|
666
|
+
if (target)
|
|
667
|
+
events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Mission status changed from ${previous} to ${req.body.status}` });
|
|
668
|
+
return { ok: true };
|
|
669
|
+
});
|
|
670
|
+
app.post("/api/mission/:missionId/action", async (req, reply) => {
|
|
671
|
+
const actions = {
|
|
672
|
+
status_summary: { label: "Immediate status summary requested", prompt: "Provide an immediate concise status summary: completed work, active work, blockers, verification, and the next concrete step. Update the structured plan if it is stale." },
|
|
673
|
+
plan_revision: { label: "Plan revision requested", prompt: "Review the current structured plan against actual progress and revise it now. Preserve stable task IDs, explain blockers, and identify the next milestone." },
|
|
674
|
+
review: { label: "Review requested", prompt: "Coordinate a fresh review of the current work before completion. Report findings, address important issues, and attach concrete evidence to the structured plan." },
|
|
675
|
+
test_pass: { label: "Verification pass requested", prompt: "Run or coordinate the relevant tests, typechecks, lint, and other verification for this mission. Report exact outcomes and attach evidence to the structured plan." },
|
|
676
|
+
};
|
|
677
|
+
const selected = actions[req.body?.action];
|
|
678
|
+
if (!selected) {
|
|
679
|
+
reply.code(400);
|
|
680
|
+
return { error: "invalid mission action" };
|
|
681
|
+
}
|
|
682
|
+
try {
|
|
683
|
+
const result = await sendToMission(req.params.missionId, selected.prompt);
|
|
684
|
+
events.add({ ts: Date.now(), sessionId: result.sessionId ?? latestMissionTarget(req.params.missionId)?.sessionId ?? result.jobId, jobId: result.jobId, source: "custom", phase: "custom", activityKind: "lifecycle", detail: selected.label });
|
|
685
|
+
return { ok: true, jobId: result.jobId };
|
|
686
|
+
}
|
|
687
|
+
catch (err) {
|
|
688
|
+
reply.code(404);
|
|
689
|
+
return { error: err.message };
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
app.post("/api/mission/:missionId/task/:taskId/action", async (req, reply) => {
|
|
693
|
+
const plan = plans.get(req.params.missionId);
|
|
694
|
+
const task = plan?.phases.flatMap((phase) => allPlanTasks(phase.tasks)).find((item) => item.id === req.params.taskId);
|
|
695
|
+
if (!task) {
|
|
696
|
+
reply.code(404);
|
|
697
|
+
return { error: "plan task not found" };
|
|
698
|
+
}
|
|
699
|
+
const detail = req.body?.detail?.trim().slice(0, 1000);
|
|
700
|
+
const actions = {
|
|
701
|
+
retry: `Retry failed task ${task.id} (${task.title}) without replaying completed work. Diagnose the prior failure, delegate only the remaining scope, and update the plan with new evidence.`,
|
|
702
|
+
cancel: `Cancel task ${task.id} (${task.title}) and gracefully stop its assigned worker if still active. Preserve its reports and mark the task cancelled. Reason: ${detail || "User cancelled this task."}`,
|
|
703
|
+
replace_worker: `Replace the worker assigned to task ${task.id} (${task.title}) without stopping the mission. Preserve useful context and evidence, gracefully stop the previous worker if active, then delegate the remaining scope to a new worker. ${detail || ""}`,
|
|
704
|
+
reassign: `Reassign task ${task.id} (${task.title}) and update its owner/worker in the structured plan. New assignment guidance: ${detail || "Choose the most appropriate available worker."}`,
|
|
705
|
+
priority: `Change the priority of task ${task.id} (${task.title}) to ${detail || "high"}. Reorder eligible work and update dependencies only when necessary.`,
|
|
706
|
+
};
|
|
707
|
+
const prompt = actions[req.body?.action];
|
|
708
|
+
if (!prompt) {
|
|
709
|
+
reply.code(400);
|
|
710
|
+
return { error: "invalid task action" };
|
|
711
|
+
}
|
|
712
|
+
try {
|
|
713
|
+
const result = await sendToMission(req.params.missionId, prompt);
|
|
714
|
+
events.add({ ts: Date.now(), sessionId: result.sessionId ?? latestMissionTarget(req.params.missionId)?.sessionId ?? result.jobId, jobId: result.jobId, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Task control: ${req.body.action.replaceAll("_", " ")} · ${task.title}`, targetTask: task.id, targetWorker: task.workerId });
|
|
715
|
+
return { ok: true, jobId: result.jobId };
|
|
716
|
+
}
|
|
717
|
+
catch (err) {
|
|
718
|
+
reply.code(404);
|
|
719
|
+
return { error: err.message };
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
app.post("/api/mission/:missionId/task", async (req, reply) => {
|
|
723
|
+
const description = req.body?.description?.trim().slice(0, 1000);
|
|
724
|
+
if (!description) {
|
|
725
|
+
reply.code(400);
|
|
726
|
+
return { error: "task description is required" };
|
|
727
|
+
}
|
|
728
|
+
if (!plans.get(req.params.missionId)) {
|
|
729
|
+
reply.code(404);
|
|
730
|
+
return { error: "mission plan not found" };
|
|
731
|
+
}
|
|
732
|
+
try {
|
|
733
|
+
const result = await sendToMission(req.params.missionId, `Add this task to the current structured plan with a stable ID, appropriate priority, dependencies, owner, and acceptance evidence; then schedule it without replaying completed work: ${description}`);
|
|
734
|
+
events.add({ ts: Date.now(), sessionId: result.sessionId ?? latestMissionTarget(req.params.missionId)?.sessionId ?? result.jobId, jobId: result.jobId, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Task addition requested: ${description}` });
|
|
735
|
+
return { ok: true, jobId: result.jobId };
|
|
736
|
+
}
|
|
737
|
+
catch (err) {
|
|
738
|
+
reply.code(404);
|
|
739
|
+
return { error: err.message };
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
app.post("/api/mission/:missionId/rename", async (req, reply) => {
|
|
743
|
+
if (typeof req.body?.name !== "string") {
|
|
744
|
+
reply.code(400);
|
|
745
|
+
return { error: "name is required" };
|
|
746
|
+
}
|
|
747
|
+
missions.rename(req.params.missionId, req.body.name);
|
|
748
|
+
return { ok: true };
|
|
749
|
+
});
|
|
750
|
+
app.delete("/api/mission/:missionId", async (req, reply) => {
|
|
751
|
+
const target = latestMissionTarget(req.params.missionId);
|
|
752
|
+
if (target)
|
|
753
|
+
events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Mission permanently deleted: ${req.params.missionId}` });
|
|
754
|
+
if (!missions.remove(req.params.missionId)) {
|
|
755
|
+
reply.code(404);
|
|
756
|
+
return { error: "mission not found" };
|
|
757
|
+
}
|
|
758
|
+
messages.removeMission(req.params.missionId);
|
|
759
|
+
plans.removeMission(req.params.missionId);
|
|
760
|
+
return { ok: true };
|
|
761
|
+
});
|
|
762
|
+
app.post("/api/session/:pid/stop", async (req, reply) => {
|
|
763
|
+
const pid = Number(req.params.pid);
|
|
764
|
+
const targetSession = sessionsWatcher.getAll().get(pid);
|
|
765
|
+
if (!targetSession) {
|
|
766
|
+
reply.code(404);
|
|
767
|
+
return { error: "session not found" };
|
|
768
|
+
}
|
|
769
|
+
if (req.body?.force) {
|
|
770
|
+
forceStopSession(pid);
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
if (!targetSession.jobId) {
|
|
774
|
+
reply.code(409);
|
|
775
|
+
return { error: "session is not associated with a background job" };
|
|
776
|
+
}
|
|
777
|
+
const missionId = missions.missionFor(targetSession.jobId);
|
|
778
|
+
if (missions.summaryFor(missionId).provider === "codex")
|
|
779
|
+
await codex.stop(targetSession.jobId);
|
|
780
|
+
else
|
|
781
|
+
await stopSession(targetSession.jobId);
|
|
782
|
+
}
|
|
783
|
+
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" });
|
|
784
|
+
return { ok: true };
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
function sessionForId(watcher, sessionId) {
|
|
788
|
+
for (const session of watcher.getAll().values()) {
|
|
789
|
+
if (session.sessionId === sessionId)
|
|
790
|
+
return session;
|
|
791
|
+
}
|
|
792
|
+
return undefined;
|
|
793
|
+
}
|