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