@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
|
@@ -1,11 +1,34 @@
|
|
|
1
1
|
import { buildFleetSnapshot } from "../roster/rosterBuilder.js";
|
|
2
|
+
/** Strictly parse the only client messages that may affect terminal delivery. */
|
|
3
|
+
export function parseTerminalSubscription(raw) {
|
|
4
|
+
if (!raw || typeof raw !== "object")
|
|
5
|
+
return undefined;
|
|
6
|
+
const message = raw;
|
|
7
|
+
if (message.type !== "terminal/subscribe" && message.type !== "terminal/unsubscribe")
|
|
8
|
+
return undefined;
|
|
9
|
+
if (typeof message.missionId !== "string" || !message.missionId.trim())
|
|
10
|
+
return undefined;
|
|
11
|
+
if (message.source !== undefined && typeof message.source !== "string")
|
|
12
|
+
return undefined;
|
|
13
|
+
const source = typeof message.source === "string" ? message.source.trim() : "orchestrator";
|
|
14
|
+
if (!source)
|
|
15
|
+
return undefined;
|
|
16
|
+
const numericAfter = typeof message.after === "number" ? message.after : 0;
|
|
17
|
+
return {
|
|
18
|
+
action: message.type === "terminal/subscribe" ? "subscribe" : "unsubscribe",
|
|
19
|
+
missionId: message.missionId.trim(),
|
|
20
|
+
source,
|
|
21
|
+
after: Number.isFinite(numericAfter) ? Math.max(0, Math.floor(numericAfter)) : 0,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
2
24
|
/**
|
|
3
25
|
* Single WS endpoint pushing roster + event deltas. The frontend never polls
|
|
4
26
|
* REST for live state — this is the only steady-state channel.
|
|
5
27
|
*/
|
|
6
28
|
export function registerWs(app, deps) {
|
|
7
|
-
const { sessionsWatcher, jobsWatcher, events, missions, messages, plans, workerIdentity, webBuild } = deps;
|
|
29
|
+
const { sessionsWatcher, jobsWatcher, events, missions, messages, plans, workerIdentity, terminalStream, terminalObservability, webBuild } = deps;
|
|
8
30
|
const clients = new Set();
|
|
31
|
+
const terminalSubscriptions = new Map();
|
|
9
32
|
// Every roster the client ever sees is built the same way. The first one
|
|
10
33
|
// used to be built without the identity store, so a reload resolved workers
|
|
11
34
|
// from the bare event window and the next broadcast disagreed with it.
|
|
@@ -44,11 +67,52 @@ export function registerWs(app, deps) {
|
|
|
44
67
|
broadcastEvent(event);
|
|
45
68
|
broadcastRoster();
|
|
46
69
|
});
|
|
70
|
+
const stopTerminalEvents = terminalStream.onEvent((event) => {
|
|
71
|
+
const key = `${event.missionId}:${event.source}`;
|
|
72
|
+
const message = JSON.stringify({ type: "terminal/event", event });
|
|
73
|
+
for (const client of clients) {
|
|
74
|
+
if (!terminalSubscriptions.get(client)?.has(key))
|
|
75
|
+
continue;
|
|
76
|
+
// A slow browser catches up through sequence replay; never let terminal
|
|
77
|
+
// output grow the ws library's pending buffer without bound.
|
|
78
|
+
if (client.readyState === 1 && client.bufferedAmount < 1_000_000)
|
|
79
|
+
client.send(message);
|
|
80
|
+
else
|
|
81
|
+
terminalObservability.metric("stream.slow_client_recovery");
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
app.addHook("onClose", async () => stopTerminalEvents());
|
|
47
85
|
app.get("/ws", { websocket: true }, (socket) => {
|
|
48
86
|
clients.add(socket);
|
|
87
|
+
terminalSubscriptions.set(socket, new Set());
|
|
49
88
|
if (webBuild)
|
|
50
89
|
socket.send(JSON.stringify({ type: "hello", build: webBuild }));
|
|
51
90
|
socket.send(JSON.stringify({ type: "roster", snapshot: currentRoster() }));
|
|
52
|
-
socket.on("
|
|
91
|
+
socket.on("message", (raw) => {
|
|
92
|
+
let parsed;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(String(raw));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const message = parseTerminalSubscription(parsed);
|
|
100
|
+
if (!message)
|
|
101
|
+
return;
|
|
102
|
+
const { missionId, source } = message;
|
|
103
|
+
const key = `${missionId}:${source}`;
|
|
104
|
+
if (message.action === "unsubscribe") {
|
|
105
|
+
terminalSubscriptions.get(socket)?.delete(key);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
terminalSubscriptions.get(socket)?.add(key);
|
|
109
|
+
const replay = terminalStream.replay(missionId, source, message.after);
|
|
110
|
+
terminalObservability.metric(message.after > 0 ? "stream.reconnect" : "stream.subscription");
|
|
111
|
+
if (replay.truncated)
|
|
112
|
+
terminalObservability.metric("stream.truncated_recovery");
|
|
113
|
+
if (socket.readyState === 1)
|
|
114
|
+
socket.send(JSON.stringify({ type: "terminal/replay", missionId, source, ...replay }));
|
|
115
|
+
});
|
|
116
|
+
socket.on("close", () => { clients.delete(socket); terminalSubscriptions.delete(socket); });
|
|
53
117
|
});
|
|
54
118
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job states in which the provider is holding a turn open — running one, or
|
|
3
|
+
* parked partway through one waiting for an answer. A resume cannot join a
|
|
4
|
+
* turn that is already in progress; it starts a second one beside it.
|
|
5
|
+
*/
|
|
6
|
+
const MID_TURN_JOB_STATES = ["working", "busy", "queued", "blocked"];
|
|
7
|
+
/**
|
|
8
|
+
* Whether the provider can still act through this job.
|
|
9
|
+
*
|
|
10
|
+
* Live is not the same as mid-turn, and reading it that way is what let this
|
|
11
|
+
* go unnoticed for a day. A background orchestrator that has finished its turn
|
|
12
|
+
* keeps whatever it armed while running — background monitors, scheduled
|
|
13
|
+
* wake-ups, polling shells — and the daemon brings it back to act on them
|
|
14
|
+
* minutes or hours later. Mission f7ae71c7's first manager sat at "blocked"
|
|
15
|
+
* for 24 hours and spawned workers, pushed commits and re-closed gates the
|
|
16
|
+
* whole time.
|
|
17
|
+
*
|
|
18
|
+
* The session record is the honest signal: the daemon writes one while the
|
|
19
|
+
* session exists and removes it when the session is gone. While it is there,
|
|
20
|
+
* something can still wake the job.
|
|
21
|
+
*/
|
|
22
|
+
export function jobIsLive(jobId, job, sessions) {
|
|
23
|
+
if (MID_TURN_JOB_STATES.includes(job.state))
|
|
24
|
+
return true;
|
|
25
|
+
for (const session of sessions) {
|
|
26
|
+
if (session.jobId === jobId)
|
|
27
|
+
return true;
|
|
28
|
+
if (job.sessionId && session.sessionId === job.sessionId)
|
|
29
|
+
return true;
|
|
30
|
+
if (job.resumeSessionId && session.sessionId === job.resumeSessionId)
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The jobs that must be stopped before a mission is resumed, so that the
|
|
37
|
+
* resume leaves exactly one manager able to act.
|
|
38
|
+
*
|
|
39
|
+
* `claude --resume` forks. The session it resumes goes on running — with its
|
|
40
|
+
* armed monitors, its scheduled wake-ups and its background shells intact —
|
|
41
|
+
* while the fork carries the conversation on under a fresh session id, and
|
|
42
|
+
* Hive repoints the mission at the fork. Nothing ever addresses the old branch
|
|
43
|
+
* again, but nothing stops it either. On mission f7ae71c7 thirteen sends left
|
|
44
|
+
* five managers alive at once against one worktree and one merge request: each
|
|
45
|
+
* re-closed the same gate, each spawned its own worker for the same plan task,
|
|
46
|
+
* and each read the others' commits as changes it had never asked for and
|
|
47
|
+
* blamed them on its own worker.
|
|
48
|
+
*
|
|
49
|
+
* The resume target is spared while it is between turns — that is the ordinary
|
|
50
|
+
* case, where the resume continues the conversation rather than branching it.
|
|
51
|
+
* A target that is mid-turn or parked is stopped first, which is what the
|
|
52
|
+
* handoff and parked-permission paths already do before resuming.
|
|
53
|
+
*/
|
|
54
|
+
export function jobsToQuiesce(input) {
|
|
55
|
+
const stop = [];
|
|
56
|
+
for (const { jobId, job, missionId } of input.jobs) {
|
|
57
|
+
if (missionId !== input.missionId)
|
|
58
|
+
continue;
|
|
59
|
+
if (!jobIsLive(jobId, job, input.sessions))
|
|
60
|
+
continue;
|
|
61
|
+
if (jobId === input.targetJobId && !MID_TURN_JOB_STATES.includes(job.state))
|
|
62
|
+
continue;
|
|
63
|
+
stop.push(jobId);
|
|
64
|
+
}
|
|
65
|
+
return stop;
|
|
66
|
+
}
|
|
@@ -8,6 +8,14 @@ export function deriveAlerts(input) {
|
|
|
8
8
|
const alerts = [];
|
|
9
9
|
if (input.stale)
|
|
10
10
|
alerts.push({ id: "stale-job", severity: "critical", title: "Stale job state", explanation: "Claude reports this job as working, but Hive cannot find its live process.", evidence: "A working/busy job record exists without a matching session record.", recovery: "Restart or resume the mission; archive the stale record if the work is no longer needed." });
|
|
11
|
+
// A mission is meant to have one manager. More than one means a resume
|
|
12
|
+
// forked instead of continuing, and the extras did not stop: they share the
|
|
13
|
+
// worktree, the branch and the plan, so each redoes the other's work and
|
|
14
|
+
// reads the other's commits as changes it never made. The board shows only
|
|
15
|
+
// the newest, which is exactly why this needs saying.
|
|
16
|
+
const live = input.liveJobIds ?? [];
|
|
17
|
+
if (live.length > 1)
|
|
18
|
+
alerts.push({ id: "concurrent-jobs", severity: "critical", title: "More than one manager is live on this mission", explanation: "Several orchestrator jobs for this mission can still act, and they share one worktree, branch and plan. Expect duplicated delegations, repeated gate transitions, and each manager reporting the others' commits as work it never asked for.", evidence: `${live.length} live jobs: ${live.map((jobId) => jobId.slice(0, 8)).join(", ")}`, recovery: "Send the mission a message: resuming now stops every superseded manager first. Any that survive that can be stopped from the sessions list." });
|
|
11
19
|
for (const worker of input.workers.filter((item) => !item.doneAt && (item.jobState === "working" || item.jobState === "busy"))) {
|
|
12
20
|
const workerEvents = input.events.filter((event) => matchesWorker(event.targetWorker, worker));
|
|
13
21
|
const lastUpdate = Math.max(worker.startedAt, worker.jobUpdatedAt ?? 0, ...workerEvents.map((event) => event.ts));
|
|
@@ -18,7 +18,11 @@ import { registerWs } from "./api/ws.js";
|
|
|
18
18
|
import { drainHookSpool } from "./hooks/hookSpool.js";
|
|
19
19
|
import { PoliciesStore } from "./policies/policiesStore.js";
|
|
20
20
|
import { CodexRuntime } from "./control/codexRuntime.js";
|
|
21
|
+
import { LoopStore } from "./loops/loopStore.js";
|
|
22
|
+
import { LoopScheduler } from "./loops/loopScheduler.js";
|
|
21
23
|
import { allowedBrowserOrigins, browserOriginAllowed } from "./security/originPolicy.js";
|
|
24
|
+
import { TerminalRuntime } from "./terminals/terminalRuntime.js";
|
|
25
|
+
import { TerminalObservability } from "./terminals/terminalObservability.js";
|
|
22
26
|
async function main() {
|
|
23
27
|
const app = Fastify({ logger: true });
|
|
24
28
|
const browserOrigins = allowedBrowserOrigins({
|
|
@@ -45,6 +49,9 @@ async function main() {
|
|
|
45
49
|
const workerIdentity = new WorkerIdentityStore();
|
|
46
50
|
const policies = new PoliciesStore();
|
|
47
51
|
const codex = new CodexRuntime();
|
|
52
|
+
const terminalObservability = new TerminalObservability((record) => app.log.info({ terminalAudit: record }, "terminal input operation"));
|
|
53
|
+
const terminalRuntime = new TerminalRuntime(undefined, undefined, terminalObservability);
|
|
54
|
+
const loops = new LoopStore();
|
|
48
55
|
events.init();
|
|
49
56
|
policies.init();
|
|
50
57
|
codex.init();
|
|
@@ -68,8 +75,25 @@ async function main() {
|
|
|
68
75
|
// data — a fixed bug reported again from a stale tab. The client reloads
|
|
69
76
|
// when this changes.
|
|
70
77
|
const webBuild = webBuildId(webRoot);
|
|
71
|
-
registerRest(app, { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex, workerIdentity });
|
|
72
|
-
|
|
78
|
+
const { sendToMission } = registerRest(app, { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex, workerIdentity, terminalRuntime, loops });
|
|
79
|
+
// Loops survive a restart because they live in the database, so the
|
|
80
|
+
// scheduler picks up whatever was already running the moment it starts.
|
|
81
|
+
const loopScheduler = new LoopScheduler({
|
|
82
|
+
loops,
|
|
83
|
+
send: (missionId, prompt) => sendToMission(missionId, prompt),
|
|
84
|
+
// Never interrupt a live turn: a loop that fires mid-conversation would
|
|
85
|
+
// quiesce whatever the agent is currently saying.
|
|
86
|
+
isBusy: (missionId) => [...jobsWatcher.getAll()].some(([jobId, job]) => missions.missionFor(jobId) === missionId && job.state !== "done" && job.state !== "failed"),
|
|
87
|
+
// A finished or archived mission has no session left to wake.
|
|
88
|
+
isRunnable: (missionId) => {
|
|
89
|
+
const lifecycle = missions.get(missionId)?.lifecycleStatus;
|
|
90
|
+
return Boolean(lifecycle) && lifecycle !== "archived" && lifecycle !== "completed";
|
|
91
|
+
},
|
|
92
|
+
onError: (loop, error) => app.log.warn({ loopId: loop.id, missionId: loop.missionId, err: error.message }, "loop turn failed"),
|
|
93
|
+
});
|
|
94
|
+
loopScheduler.start();
|
|
95
|
+
registerWs(app, { sessionsWatcher, jobsWatcher, events, missions, messages, plans, workerIdentity, terminalStream: terminalRuntime.stream, terminalObservability, webBuild });
|
|
96
|
+
app.addHook("onClose", async () => terminalRuntime.close());
|
|
73
97
|
app.get("/health", async () => ({ ok: true, webBuild }));
|
|
74
98
|
const isCompiledRuntime = fileURLToPath(import.meta.url).includes(`${path.sep}dist${path.sep}`);
|
|
75
99
|
if (existsSync(path.join(webRoot, "index.html"))) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** The shortest useful cadence; anything faster is a runaway, not a loop. */
|
|
2
|
+
export const MIN_LOOP_INTERVAL_MS = 30_000;
|
|
3
|
+
export const MAX_LOOP_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
export const DEFAULT_LOOP_INTERVAL_MS = 5 * 60 * 1000;
|
|
5
|
+
const UNITS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
6
|
+
/**
|
|
7
|
+
* "5m", "30s", "1h30m" — the same shorthand the CLI's own `/loop` accepts.
|
|
8
|
+
* Returns undefined for anything that is not purely an interval, so a prompt
|
|
9
|
+
* beginning with a word is never silently eaten as a duration.
|
|
10
|
+
*/
|
|
11
|
+
export function parseInterval(token) {
|
|
12
|
+
const trimmed = token.trim().toLowerCase();
|
|
13
|
+
if (!trimmed || !/^(\d+(?:\.\d+)?[smhd])+$/.test(trimmed))
|
|
14
|
+
return undefined;
|
|
15
|
+
let total = 0;
|
|
16
|
+
for (const [, amount, unit] of trimmed.matchAll(/(\d+(?:\.\d+)?)([smhd])/g)) {
|
|
17
|
+
total += Number(amount) * UNITS[unit];
|
|
18
|
+
}
|
|
19
|
+
return total > 0 ? total : undefined;
|
|
20
|
+
}
|
|
21
|
+
export function formatInterval(ms) {
|
|
22
|
+
const hours = Math.floor(ms / 3_600_000);
|
|
23
|
+
const minutes = Math.floor((ms % 3_600_000) / 60_000);
|
|
24
|
+
const seconds = Math.round((ms % 60_000) / 1000);
|
|
25
|
+
return [hours && `${hours}h`, minutes && `${minutes}m`, seconds && `${seconds}s`].filter(Boolean).join("") || "0s";
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Reads `/loop …` typed into a mission conversation.
|
|
29
|
+
*
|
|
30
|
+
* Returns undefined when the text is not a loop command at all, so ordinary
|
|
31
|
+
* messages fall straight through to the mission.
|
|
32
|
+
*/
|
|
33
|
+
export function parseLoopCommand(text) {
|
|
34
|
+
const match = text.trim().match(/^\/loop\b\s*([\s\S]*)$/i);
|
|
35
|
+
if (!match)
|
|
36
|
+
return undefined;
|
|
37
|
+
const rest = match[1].trim();
|
|
38
|
+
if (!rest || rest.toLowerCase() === "list")
|
|
39
|
+
return { kind: "list" };
|
|
40
|
+
const stop = rest.match(/^stop\b\s*(\S*)$/i);
|
|
41
|
+
if (stop)
|
|
42
|
+
return { kind: "stop", loopId: stop[1] || undefined };
|
|
43
|
+
const [first, ...remainder] = rest.split(/\s+/);
|
|
44
|
+
const parsed = parseInterval(first);
|
|
45
|
+
const intervalMs = parsed ?? DEFAULT_LOOP_INTERVAL_MS;
|
|
46
|
+
const prompt = (parsed ? remainder.join(" ") : rest).trim();
|
|
47
|
+
if (!prompt)
|
|
48
|
+
return { kind: "error", message: "A loop needs something to run: /loop 10m check for new PR comments" };
|
|
49
|
+
if (intervalMs < MIN_LOOP_INTERVAL_MS) {
|
|
50
|
+
return { kind: "error", message: `The shortest loop interval is ${formatInterval(MIN_LOOP_INTERVAL_MS)}; ${formatInterval(intervalMs)} would run away.` };
|
|
51
|
+
}
|
|
52
|
+
if (intervalMs > MAX_LOOP_INTERVAL_MS) {
|
|
53
|
+
return { kind: "error", message: `The longest loop interval is ${formatInterval(MAX_LOOP_INTERVAL_MS)}.` };
|
|
54
|
+
}
|
|
55
|
+
return { kind: "start", intervalMs, prompt };
|
|
56
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The line a looping agent ends on when its pass found nothing to do.
|
|
3
|
+
*
|
|
4
|
+
* Bracketed and namespaced so it cannot be mistaken for prose the agent meant
|
|
5
|
+
* to say, and so a reply that merely discusses looping is not mistaken for a
|
|
6
|
+
* quiet run.
|
|
7
|
+
*/
|
|
8
|
+
export const NOOP_MARKER = "[hive:no-change]";
|
|
9
|
+
const NOOP_PATTERN = new RegExp(`\\n*\\s*${NOOP_MARKER.replace(/[[\]]/g, "\\$&")}\\s*$`, "i");
|
|
10
|
+
/**
|
|
11
|
+
* What Hive appends when it re-sends a loop's prompt.
|
|
12
|
+
*
|
|
13
|
+
* Kept off the stored prompt so the Tasks panel shows what the reader actually
|
|
14
|
+
* asked for, not Hive's bookkeeping.
|
|
15
|
+
*/
|
|
16
|
+
export function withLoopInstructions(prompt) {
|
|
17
|
+
return `${prompt}\n\n(Hive loop: this prompt runs on a schedule. If this pass found nothing new to act on, end your reply with the single line ${NOOP_MARKER} — Hive folds those away so the quiet runs do not bury the ones that mattered.)`;
|
|
18
|
+
}
|
|
19
|
+
const INSTRUCTION_PATTERN = /\n*\(Hive loop: this prompt runs on a schedule\.[\s\S]*?\)\s*$/;
|
|
20
|
+
/**
|
|
21
|
+
* Removes Hive's own appended instruction from a recorded prompt.
|
|
22
|
+
*
|
|
23
|
+
* The job records the full text that was sent, but the bookkeeping half was
|
|
24
|
+
* never the reader's words and has no business in their chat bubble.
|
|
25
|
+
*/
|
|
26
|
+
export function stripLoopInstructions(text) {
|
|
27
|
+
return text.replace(INSTRUCTION_PATTERN, "").trimEnd();
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Splits the marker off a reply.
|
|
31
|
+
*
|
|
32
|
+
* The marker is bookkeeping, not something the reader should have to look at,
|
|
33
|
+
* so it never reaches the transcript — only the flag it sets does.
|
|
34
|
+
*/
|
|
35
|
+
export function stripNoopMarker(text) {
|
|
36
|
+
const stripped = text.replace(NOOP_PATTERN, "");
|
|
37
|
+
return stripped === text ? { text, noop: false } : { text: stripped.trimEnd(), noop: true };
|
|
38
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { withLoopInstructions } from "./loopNoop.js";
|
|
2
|
+
export const LOOP_TICK_MS = 15_000;
|
|
3
|
+
/**
|
|
4
|
+
* Fires due loops.
|
|
5
|
+
*
|
|
6
|
+
* A loop never overlaps itself: the run is marked before the send, so a slow
|
|
7
|
+
* turn delays the next firing rather than stacking a second one behind it.
|
|
8
|
+
* A mission that has gone away stops its loops instead of failing forever.
|
|
9
|
+
*/
|
|
10
|
+
export class LoopScheduler {
|
|
11
|
+
deps;
|
|
12
|
+
timer;
|
|
13
|
+
running = new Set();
|
|
14
|
+
constructor(deps) {
|
|
15
|
+
this.deps = deps;
|
|
16
|
+
}
|
|
17
|
+
start(intervalMs = LOOP_TICK_MS) {
|
|
18
|
+
if (this.timer)
|
|
19
|
+
return;
|
|
20
|
+
this.timer = setInterval(() => { void this.tick(); }, intervalMs);
|
|
21
|
+
this.timer.unref?.();
|
|
22
|
+
}
|
|
23
|
+
stop() {
|
|
24
|
+
if (this.timer)
|
|
25
|
+
clearInterval(this.timer);
|
|
26
|
+
this.timer = undefined;
|
|
27
|
+
}
|
|
28
|
+
async tick(now = Date.now()) {
|
|
29
|
+
for (const loop of this.deps.loops.due(now)) {
|
|
30
|
+
if (this.running.has(loop.id))
|
|
31
|
+
continue;
|
|
32
|
+
if (!this.deps.isRunnable(loop.missionId)) {
|
|
33
|
+
this.deps.loops.stop(loop.id, "the mission is no longer running");
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
// Deferred, not skipped: nextRunAt stays in the past, so the loop fires
|
|
37
|
+
// on the first tick after the mission goes quiet rather than losing its
|
|
38
|
+
// turn or interrupting one.
|
|
39
|
+
if (this.deps.isBusy(loop.missionId))
|
|
40
|
+
continue;
|
|
41
|
+
this.running.add(loop.id);
|
|
42
|
+
// Marked before the send: a turn that outlasts the interval must push the
|
|
43
|
+
// next firing out, never queue another on top of it.
|
|
44
|
+
this.deps.loops.markRun(loop.id, now);
|
|
45
|
+
try {
|
|
46
|
+
// The stored prompt stays exactly what was asked for; the folding
|
|
47
|
+
// instruction is added only on the way out.
|
|
48
|
+
await this.deps.send(loop.missionId, withLoopInstructions(loop.prompt));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
this.deps.onError?.(loop, error);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
this.running.delete(loop.id);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { config } from "../config.js";
|
|
4
|
+
function toLoop(row) {
|
|
5
|
+
return {
|
|
6
|
+
id: row.id,
|
|
7
|
+
missionId: row.mission_id,
|
|
8
|
+
prompt: row.prompt,
|
|
9
|
+
intervalMs: row.interval_ms,
|
|
10
|
+
status: row.status,
|
|
11
|
+
createdAt: row.created_at,
|
|
12
|
+
...(row.last_run_at ? { lastRunAt: row.last_run_at } : {}),
|
|
13
|
+
nextRunAt: row.next_run_at,
|
|
14
|
+
runCount: row.run_count,
|
|
15
|
+
...(row.stopped_reason ? { stoppedReason: row.stopped_reason } : {}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Recurring prompts Hive re-sends to a mission.
|
|
20
|
+
*
|
|
21
|
+
* Persisted rather than held in memory: a loop that quietly dies when the
|
|
22
|
+
* server restarts is a loop the reader still believes is running, which is the
|
|
23
|
+
* failure this whole feature exists to prevent.
|
|
24
|
+
*/
|
|
25
|
+
export class LoopStore {
|
|
26
|
+
db;
|
|
27
|
+
listeners = new Set();
|
|
28
|
+
constructor(filePath = config.databasePath) {
|
|
29
|
+
this.db = new Database(filePath);
|
|
30
|
+
this.db.pragma("journal_mode = WAL");
|
|
31
|
+
this.db.exec(`
|
|
32
|
+
CREATE TABLE IF NOT EXISTS mission_loops (
|
|
33
|
+
id TEXT PRIMARY KEY,
|
|
34
|
+
mission_id TEXT NOT NULL,
|
|
35
|
+
prompt TEXT NOT NULL,
|
|
36
|
+
interval_ms INTEGER NOT NULL,
|
|
37
|
+
status TEXT NOT NULL,
|
|
38
|
+
created_at INTEGER NOT NULL,
|
|
39
|
+
last_run_at INTEGER,
|
|
40
|
+
next_run_at INTEGER NOT NULL,
|
|
41
|
+
run_count INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
stopped_reason TEXT
|
|
43
|
+
);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_mission_loops_mission ON mission_loops (mission_id, status);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_mission_loops_due ON mission_loops (status, next_run_at);
|
|
46
|
+
`);
|
|
47
|
+
}
|
|
48
|
+
close() { this.db.close(); }
|
|
49
|
+
onChange(listener) { this.listeners.add(listener); }
|
|
50
|
+
notify() { for (const listener of this.listeners)
|
|
51
|
+
listener(); }
|
|
52
|
+
create(input) {
|
|
53
|
+
const now = input.now ?? Date.now();
|
|
54
|
+
const loop = {
|
|
55
|
+
id: randomUUID().slice(0, 8),
|
|
56
|
+
mission_id: input.missionId,
|
|
57
|
+
prompt: input.prompt,
|
|
58
|
+
interval_ms: input.intervalMs,
|
|
59
|
+
status: "active",
|
|
60
|
+
created_at: now,
|
|
61
|
+
last_run_at: null,
|
|
62
|
+
// The first run is one interval away, not immediate: the message that
|
|
63
|
+
// created the loop is itself the first pass.
|
|
64
|
+
next_run_at: now + input.intervalMs,
|
|
65
|
+
run_count: 0,
|
|
66
|
+
stopped_reason: null,
|
|
67
|
+
};
|
|
68
|
+
this.db.prepare(`
|
|
69
|
+
INSERT INTO mission_loops (id, mission_id, prompt, interval_ms, status, created_at, last_run_at, next_run_at, run_count, stopped_reason)
|
|
70
|
+
VALUES (@id, @mission_id, @prompt, @interval_ms, @status, @created_at, @last_run_at, @next_run_at, @run_count, @stopped_reason)
|
|
71
|
+
`).run(loop);
|
|
72
|
+
this.notify();
|
|
73
|
+
return toLoop(loop);
|
|
74
|
+
}
|
|
75
|
+
get(id) {
|
|
76
|
+
const row = this.db.prepare("SELECT * FROM mission_loops WHERE id = ?").get(id);
|
|
77
|
+
return row ? toLoop(row) : undefined;
|
|
78
|
+
}
|
|
79
|
+
/** Active loops, soonest first. Stopped ones are history, not state. */
|
|
80
|
+
active(missionId) {
|
|
81
|
+
const rows = missionId
|
|
82
|
+
? this.db.prepare("SELECT * FROM mission_loops WHERE status = 'active' AND mission_id = ? ORDER BY next_run_at").all(missionId)
|
|
83
|
+
: this.db.prepare("SELECT * FROM mission_loops WHERE status = 'active' ORDER BY next_run_at").all();
|
|
84
|
+
return rows.map(toLoop);
|
|
85
|
+
}
|
|
86
|
+
due(now = Date.now()) {
|
|
87
|
+
return this.db.prepare("SELECT * FROM mission_loops WHERE status = 'active' AND next_run_at <= ? ORDER BY next_run_at").all(now).map(toLoop);
|
|
88
|
+
}
|
|
89
|
+
/** Records a firing and schedules the next one from now, not from the due time. */
|
|
90
|
+
markRun(id, now = Date.now()) {
|
|
91
|
+
this.db.prepare(`
|
|
92
|
+
UPDATE mission_loops
|
|
93
|
+
SET last_run_at = @now, next_run_at = @now + interval_ms, run_count = run_count + 1
|
|
94
|
+
WHERE id = @id AND status = 'active'
|
|
95
|
+
`).run({ id, now });
|
|
96
|
+
this.notify();
|
|
97
|
+
}
|
|
98
|
+
stop(id, reason) {
|
|
99
|
+
const result = this.db
|
|
100
|
+
.prepare("UPDATE mission_loops SET status = 'stopped', stopped_reason = ? WHERE id = ? AND status = 'active'")
|
|
101
|
+
.run(reason ?? null, id);
|
|
102
|
+
if (result.changes)
|
|
103
|
+
this.notify();
|
|
104
|
+
return result.changes > 0;
|
|
105
|
+
}
|
|
106
|
+
stopMission(missionId, reason) {
|
|
107
|
+
const result = this.db
|
|
108
|
+
.prepare("UPDATE mission_loops SET status = 'stopped', stopped_reason = ? WHERE mission_id = ? AND status = 'active'")
|
|
109
|
+
.run(reason ?? null, missionId);
|
|
110
|
+
if (result.changes)
|
|
111
|
+
this.notify();
|
|
112
|
+
return result.changes;
|
|
113
|
+
}
|
|
114
|
+
removeMission(missionId) {
|
|
115
|
+
this.db.prepare("DELETE FROM mission_loops WHERE mission_id = ?").run(missionId);
|
|
116
|
+
this.notify();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background watchers a mission's agent has open, each by name.
|
|
3
|
+
*
|
|
4
|
+
* They come from the job's fan rather than the `inFlight.kinds` tally, which
|
|
5
|
+
* only ever yields a count — and a count cannot tell you whether the watcher
|
|
6
|
+
* you are looking for is the one still running.
|
|
7
|
+
*
|
|
8
|
+
* Hive does not start these and the CLI has no per-watcher stop (`claude stop`
|
|
9
|
+
* takes a session), so the turn that owns them is what can actually be ended.
|
|
10
|
+
* Naming them is what makes that choice an informed one.
|
|
11
|
+
*/
|
|
12
|
+
export function activeMonitors(jobs, missionFor, missionName) {
|
|
13
|
+
const monitors = [];
|
|
14
|
+
for (const [jobId, job] of jobs) {
|
|
15
|
+
// A finished turn's watchers are finished with it. Real job records keep
|
|
16
|
+
// fan entries with no doneAt long after the turn ended, so trusting the
|
|
17
|
+
// entry alone would report watchers that stopped days ago.
|
|
18
|
+
if (job.state === "done" || job.state === "failed")
|
|
19
|
+
continue;
|
|
20
|
+
const missionId = missionFor(jobId);
|
|
21
|
+
if (!missionId)
|
|
22
|
+
continue;
|
|
23
|
+
for (const entry of job.fan ?? []) {
|
|
24
|
+
if (entry.kind !== "monitor" || entry.doneAt)
|
|
25
|
+
continue;
|
|
26
|
+
monitors.push({
|
|
27
|
+
missionId,
|
|
28
|
+
missionName: missionName(missionId),
|
|
29
|
+
jobId,
|
|
30
|
+
id: entry.id,
|
|
31
|
+
label: entry.label?.trim() || "unnamed watcher",
|
|
32
|
+
startedAt: entry.startedAt || Date.parse(job.createdAt ?? "") || 0,
|
|
33
|
+
...(job.detail ? { detail: job.detail } : {}),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return monitors.sort((a, b) => a.startedAt - b.startedAt);
|
|
38
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { config } from "../config.js";
|
|
5
|
+
/** What a pasted screenshot can actually be, and the extension it lands under. */
|
|
6
|
+
const EXTENSIONS = {
|
|
7
|
+
"image/png": "png",
|
|
8
|
+
"image/jpeg": "jpg",
|
|
9
|
+
"image/gif": "gif",
|
|
10
|
+
"image/webp": "webp",
|
|
11
|
+
};
|
|
12
|
+
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
13
|
+
export function isSupportedMediaType(mediaType) {
|
|
14
|
+
return mediaType in EXTENSIONS;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Images pasted into a mission conversation, written to disk so the agent can
|
|
18
|
+
* open them.
|
|
19
|
+
*
|
|
20
|
+
* The model does not receive bytes — Hive hands the session an absolute path
|
|
21
|
+
* and the agent reads it with its own Read tool, which is the one route that
|
|
22
|
+
* works identically for every provider and needs no protocol of its own.
|
|
23
|
+
*/
|
|
24
|
+
export class AttachmentStore {
|
|
25
|
+
root;
|
|
26
|
+
constructor(root = path.join(config.hiveDataDir, "attachments")) {
|
|
27
|
+
this.root = root;
|
|
28
|
+
}
|
|
29
|
+
/** Ids are opaque and generated here, so a caller can never address a path. */
|
|
30
|
+
fileFor(missionId, id) {
|
|
31
|
+
const dir = path.join(this.root, encodeURIComponent(missionId));
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = fs.readdirSync(dir);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const match = entries.find((entry) => path.basename(entry, path.extname(entry)) === id);
|
|
40
|
+
return match ? path.join(dir, match) : undefined;
|
|
41
|
+
}
|
|
42
|
+
save(missionId, input) {
|
|
43
|
+
const extension = EXTENSIONS[input.mediaType];
|
|
44
|
+
if (!extension)
|
|
45
|
+
throw new Error(`unsupported attachment type: ${input.mediaType}`);
|
|
46
|
+
if (input.data.byteLength > MAX_ATTACHMENT_BYTES)
|
|
47
|
+
throw new Error("attachment is larger than 10MB");
|
|
48
|
+
const id = randomUUID();
|
|
49
|
+
const dir = path.join(this.root, encodeURIComponent(missionId));
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
51
|
+
const file = path.join(dir, `${id}.${extension}`);
|
|
52
|
+
fs.writeFileSync(file, input.data);
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
name: input.name?.trim() || `pasted-image.${extension}`,
|
|
56
|
+
mediaType: input.mediaType,
|
|
57
|
+
bytes: input.data.byteLength,
|
|
58
|
+
path: file,
|
|
59
|
+
url: `/api/mission/${encodeURIComponent(missionId)}/attachment/${id}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
read(missionId, id) {
|
|
63
|
+
const file = this.fileFor(missionId, id);
|
|
64
|
+
if (!file)
|
|
65
|
+
return undefined;
|
|
66
|
+
const extension = path.extname(file).slice(1);
|
|
67
|
+
const mediaType = Object.entries(EXTENSIONS).find(([, value]) => value === extension)?.[0];
|
|
68
|
+
return mediaType ? { file, mediaType } : undefined;
|
|
69
|
+
}
|
|
70
|
+
/** The stored descriptor, rebuilt from disk so a caller cannot assert one. */
|
|
71
|
+
describe(missionId, id, name) {
|
|
72
|
+
const found = this.read(missionId, id);
|
|
73
|
+
if (!found)
|
|
74
|
+
return undefined;
|
|
75
|
+
let bytes = 0;
|
|
76
|
+
try {
|
|
77
|
+
bytes = fs.statSync(found.file).size;
|
|
78
|
+
}
|
|
79
|
+
catch { /* reported as zero */ }
|
|
80
|
+
return {
|
|
81
|
+
id,
|
|
82
|
+
name: name?.trim() || path.basename(found.file),
|
|
83
|
+
mediaType: found.mediaType,
|
|
84
|
+
bytes,
|
|
85
|
+
path: found.file,
|
|
86
|
+
url: `/api/mission/${encodeURIComponent(missionId)}/attachment/${id}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
removeMission(missionId) {
|
|
90
|
+
fs.rmSync(path.join(this.root, encodeURIComponent(missionId)), { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|