@shanesaravia/hive 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.md +18 -1
- package/node_modules/@hive/shared/dist/directStudio.d.ts +6 -0
- package/node_modules/@hive/shared/dist/directStudio.js +12 -0
- package/node_modules/@hive/shared/dist/index.d.ts +2 -0
- package/node_modules/@hive/shared/dist/index.js +2 -0
- package/node_modules/@hive/shared/dist/reviewHall.d.ts +15 -0
- package/node_modules/@hive/shared/dist/reviewHall.js +49 -0
- package/node_modules/@hive/shared/dist/status.js +9 -0
- package/node_modules/@hive/shared/dist/types.d.ts +191 -1
- package/node_modules/@hive/shared/dist/types.js +27 -0
- package/node_modules/@hive/shared/dist/workers.d.ts +14 -0
- package/node_modules/@hive/shared/dist/workers.js +22 -0
- package/package.json +1 -1
- package/packages/server/dist/agents/agentDiscovery.js +64 -0
- package/packages/server/dist/api/rest.js +538 -23
- package/packages/server/dist/api/ws.js +90 -9
- package/packages/server/dist/control/launcher.js +50 -11
- package/packages/server/dist/control/messaging.js +3 -2
- package/packages/server/dist/control/missionQuiesce.js +66 -0
- package/packages/server/dist/health/deriveAlerts.js +9 -2
- package/packages/server/dist/hooks/hookIngest.js +69 -10
- package/packages/server/dist/index.js +44 -4
- package/packages/server/dist/loops/loopCommand.js +56 -0
- package/packages/server/dist/loops/loopNoop.js +38 -0
- package/packages/server/dist/loops/loopScheduler.js +58 -0
- package/packages/server/dist/loops/loopStore.js +118 -0
- package/packages/server/dist/loops/monitors.js +38 -0
- package/packages/server/dist/messages/attachmentStore.js +92 -0
- package/packages/server/dist/messages/messagesStore.js +110 -33
- package/packages/server/dist/missions/missionsStore.js +9 -0
- package/packages/server/dist/missions/reopenOnWork.js +20 -0
- package/packages/server/dist/plans/planReconcile.js +114 -0
- package/packages/server/dist/plans/plansStore.js +46 -3
- package/packages/server/dist/reviews/reviewDiff.js +47 -0
- package/packages/server/dist/roster/missionReplay.js +82 -0
- package/packages/server/dist/roster/replyAsk.js +62 -0
- package/packages/server/dist/roster/rosterBuilder.js +78 -147
- package/packages/server/dist/roster/workerIdentity.js +923 -0
- package/packages/server/dist/skills/skillDiscovery.js +28 -4
- package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
- package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
- package/packages/server/dist/terminals/providerDetection.js +27 -0
- package/packages/server/dist/terminals/terminalCapability.js +45 -0
- package/packages/server/dist/terminals/terminalFeatures.js +11 -0
- package/packages/server/dist/terminals/terminalObservability.js +21 -0
- package/packages/server/dist/terminals/terminalRuntime.js +125 -0
- package/packages/server/dist/terminals/terminalStream.js +30 -0
- package/packages/server/dist/transcripts/transcriptReader.js +345 -0
- package/packages/server/dist/watch/jobsWatcher.js +55 -24
- package/packages/web/dist/assets/index-DWjqiitn.js +17 -0
- package/packages/web/dist/assets/index-rd4RnLqj.css +2 -0
- package/packages/web/dist/index.html +2 -2
- package/templates/agents/hive-orchestrator.md +1 -0
- package/packages/web/dist/assets/index-Bzle5Xla.css +0 -2
- package/packages/web/dist/assets/index-C6AY0vYC.js +0 -11
|
@@ -1,17 +1,58 @@
|
|
|
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 } = 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();
|
|
32
|
+
// Every roster the client ever sees is built the same way. The first one
|
|
33
|
+
// used to be built without the identity store, so a reload resolved workers
|
|
34
|
+
// from the bare event window and the next broadcast disagreed with it.
|
|
35
|
+
const currentRoster = () => buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans, Date.now(), workerIdentity);
|
|
36
|
+
let lastRosterJson = "";
|
|
9
37
|
function broadcastRoster() {
|
|
10
|
-
|
|
11
|
-
const msg = JSON.stringify({ type: "roster", snapshot });
|
|
38
|
+
lastRosterJson = JSON.stringify({ type: "roster", snapshot: currentRoster() });
|
|
12
39
|
for (const client of clients)
|
|
13
|
-
client.send(
|
|
40
|
+
client.send(lastRosterJson);
|
|
14
41
|
}
|
|
42
|
+
// Time-based rules — correlation graces, the stop-settle, stalled work — used
|
|
43
|
+
// to take effect only when some unrelated event happened to arrive. A short
|
|
44
|
+
// ticker re-derives the roster and pushes it only when something changed.
|
|
45
|
+
const ticker = setInterval(() => {
|
|
46
|
+
if (!clients.size)
|
|
47
|
+
return;
|
|
48
|
+
const next = JSON.stringify({ type: "roster", snapshot: currentRoster() });
|
|
49
|
+
if (next === lastRosterJson)
|
|
50
|
+
return;
|
|
51
|
+
lastRosterJson = next;
|
|
52
|
+
for (const client of clients)
|
|
53
|
+
client.send(next);
|
|
54
|
+
}, 1_500);
|
|
55
|
+
app.addHook("onClose", async () => { clearInterval(ticker); });
|
|
15
56
|
function broadcastEvent(event) {
|
|
16
57
|
const msg = JSON.stringify({ type: "event", event });
|
|
17
58
|
for (const client of clients)
|
|
@@ -26,12 +67,52 @@ export function registerWs(app, deps) {
|
|
|
26
67
|
broadcastEvent(event);
|
|
27
68
|
broadcastRoster();
|
|
28
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());
|
|
29
85
|
app.get("/ws", { websocket: true }, (socket) => {
|
|
30
86
|
clients.add(socket);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}));
|
|
35
|
-
socket.on("
|
|
87
|
+
terminalSubscriptions.set(socket, new Set());
|
|
88
|
+
if (webBuild)
|
|
89
|
+
socket.send(JSON.stringify({ type: "hello", build: webBuild }));
|
|
90
|
+
socket.send(JSON.stringify({ type: "roster", snapshot: currentRoster() }));
|
|
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); });
|
|
36
117
|
});
|
|
37
118
|
}
|
|
@@ -20,11 +20,23 @@ export async function startOrchestrator(opts) {
|
|
|
20
20
|
const args = buildLaunchArgs(opts);
|
|
21
21
|
const before = new Set(listSessionFiles());
|
|
22
22
|
const cwd = requireWorkingDirectory(opts.cwd);
|
|
23
|
-
|
|
23
|
+
// stderr is piped rather than ignored: when the CLI refuses to launch it
|
|
24
|
+
// says exactly why, and throwing that away left every refusal looking like
|
|
25
|
+
// the same 30-second timeout. The pipe is drained and the child is still
|
|
26
|
+
// unref'd, so nothing keeps the server alive.
|
|
27
|
+
const child = spawn("claude", args, { cwd, detached: true, stdio: ["ignore", "ignore", "pipe"] });
|
|
28
|
+
// `claude --bg` detaches and exits at once on the happy path, so an exit is
|
|
29
|
+
// not a failure — only a non-zero one is. Reading the exit alone as failure
|
|
30
|
+
// aborts every successful launch before its session can register.
|
|
31
|
+
const refusal = { text: "", failed: false };
|
|
32
|
+
child.stderr?.setEncoding("utf8");
|
|
33
|
+
child.stderr?.on("data", (chunk) => { refusal.text = (refusal.text + chunk).slice(0, 2000); });
|
|
34
|
+
child.once("exit", (code) => { refusal.failed = (code ?? 0) !== 0; });
|
|
35
|
+
child.once("error", (error) => { refusal.text ||= error.message; refusal.failed = true; });
|
|
24
36
|
child.unref();
|
|
25
37
|
// Worktree launches must copy/check out the repo before the session
|
|
26
38
|
// registers, which can take well over 8s on large repositories.
|
|
27
|
-
const session = await waitForNewSession(before, opts.worktree ? 30000 : 8000);
|
|
39
|
+
const session = await waitForNewSession(before, opts.worktree ? 30000 : 8000, refusal);
|
|
28
40
|
if (!session.jobId) {
|
|
29
41
|
throw new Error("Orchestrator session started without a background job ID");
|
|
30
42
|
}
|
|
@@ -45,13 +57,29 @@ export function generateWorktreeName(missionName) {
|
|
|
45
57
|
const suffix = Math.random().toString(36).slice(2, 8);
|
|
46
58
|
return slug ? `hive-${slug}-${suffix}` : `hive-${suffix}`;
|
|
47
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The permission mode a background session runs in.
|
|
62
|
+
*
|
|
63
|
+
* Nothing is watching a background job's TTY, so any mode that stops to ask a
|
|
64
|
+
* question is a mode that can park a mission indefinitely. That is why this
|
|
65
|
+
* was "bypassPermissions" — but the CLI now requires a one-time interactive
|
|
66
|
+
* disclaimer before --bg will accept it, which is not something a server can
|
|
67
|
+
* agree to on the user's behalf, so every launch failed outright.
|
|
68
|
+
*
|
|
69
|
+
* "auto" takes it. It is also the mode that should have been asked for: it
|
|
70
|
+
* still declines what it judges genuinely dangerous rather than waiving every
|
|
71
|
+
* check, and Hive already surfaces a parked mission as a decision the user can
|
|
72
|
+
* answer from the board. A prompt nobody sees is the thing to avoid; a prompt
|
|
73
|
+
* Hive can put in front of someone is the product working.
|
|
74
|
+
*
|
|
75
|
+
* The policy remains the real boundary either way, and it is a stronger one
|
|
76
|
+
* than a prompt: `--disallowedTools` denies rm, git reset --hard, git clean,
|
|
77
|
+
* releases and publishes outright, and a denied tool cannot be used at any
|
|
78
|
+
* permission level.
|
|
79
|
+
*/
|
|
80
|
+
export const PERMISSION_MODE = "auto";
|
|
48
81
|
export function buildLaunchArgs(opts) {
|
|
49
|
-
const args = [
|
|
50
|
-
"--bg",
|
|
51
|
-
// Background sessions have no TTY to answer permission prompts — "auto"
|
|
52
|
-
// (the mode Claude Code's own background jobs use) lets it proceed
|
|
53
|
-
// without stalling. disallowedTools remains the actual safety boundary.
|
|
54
|
-
];
|
|
82
|
+
const args = ["--bg"];
|
|
55
83
|
// Variadic --add-dir must come before the trailing task positional, with a
|
|
56
84
|
// flag after it, or it would swallow the task as another directory.
|
|
57
85
|
if (opts.addDirs?.length)
|
|
@@ -64,7 +92,7 @@ export function buildLaunchArgs(opts) {
|
|
|
64
92
|
args.push("--disallowedTools", [...new Set(denied)].join(","));
|
|
65
93
|
if (opts.policy?.allowedTools.length)
|
|
66
94
|
args.push("--allowedTools", opts.policy.allowedTools.join(","));
|
|
67
|
-
args.push("--permission-mode",
|
|
95
|
+
args.push("--permission-mode", PERMISSION_MODE);
|
|
68
96
|
if (opts.worktree) {
|
|
69
97
|
// Always pass an explicit name: the CLI's --worktree greedily consumes the
|
|
70
98
|
// next non-flag argument, so a bare --worktree would swallow the task
|
|
@@ -93,7 +121,9 @@ function listSessionFiles() {
|
|
|
93
121
|
* new session is up is its sessions/<pid>.json file appearing. Poll the
|
|
94
122
|
* filesystem briefly (startup confirmation only, not steady-state polling).
|
|
95
123
|
*/
|
|
96
|
-
async function waitForNewSession(before, timeoutMs
|
|
124
|
+
async function waitForNewSession(before, timeoutMs,
|
|
125
|
+
/** What the CLI said on its way out, if it refused to start at all. */
|
|
126
|
+
refusal) {
|
|
97
127
|
const start = Date.now();
|
|
98
128
|
while (Date.now() - start < timeoutMs) {
|
|
99
129
|
const now = listSessionFiles();
|
|
@@ -108,7 +138,16 @@ async function waitForNewSession(before, timeoutMs) {
|
|
|
108
138
|
// The daemon may still be writing the newly discovered file.
|
|
109
139
|
}
|
|
110
140
|
}
|
|
141
|
+
// A CLI that has already failed is never going to register a session, so
|
|
142
|
+
// report what it said instead of waiting out the rest of the timeout.
|
|
143
|
+
if (refusal?.failed)
|
|
144
|
+
throw new Error(launchFailure(refusal.text));
|
|
111
145
|
await new Promise((r) => setTimeout(r, 250));
|
|
112
146
|
}
|
|
113
|
-
throw new Error("Timed out waiting for orchestrator session to start");
|
|
147
|
+
throw new Error(refusal?.text ? launchFailure(refusal.text) : "Timed out waiting for orchestrator session to start");
|
|
148
|
+
}
|
|
149
|
+
/** The CLI's own words, trimmed to the part worth putting on a banner. */
|
|
150
|
+
export function launchFailure(stderr) {
|
|
151
|
+
const said = stderr.split("\n").map((line) => line.trim()).filter(Boolean).join(" ").trim();
|
|
152
|
+
return said ? `Claude CLI refused to start the mission: ${said}` : "Claude CLI exited before the mission started";
|
|
114
153
|
}
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { config } from "../config.js";
|
|
6
6
|
import { disallowedTools } from "../policies/policiesStore.js";
|
|
7
|
+
import { PERMISSION_MODE } from "./launcher.js";
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
/**
|
|
9
10
|
* Sends a follow-up message into a running session. The per-session Unix
|
|
@@ -22,10 +23,10 @@ export function buildResumeArgs(sessionId, text, policy, addDirs) {
|
|
|
22
23
|
// The prompt positional must precede --resume/--bg: trailing positionals are
|
|
23
24
|
// silently dropped on resumed background turns (the session starts idle,
|
|
24
25
|
// "send a prompt to start", and never replies).
|
|
25
|
-
// Resumes inherit the session's permission mode today, but pin
|
|
26
|
+
// Resumes inherit the session's permission mode today, but pin it
|
|
26
27
|
// explicitly so a CLI change can never drop follow-up turns into a
|
|
27
28
|
// prompting mode no background session can answer.
|
|
28
|
-
const args = [text, "--resume", sessionId, "--bg", "--permission-mode",
|
|
29
|
+
const args = [text, "--resume", sessionId, "--bg", "--permission-mode", PERMISSION_MODE];
|
|
29
30
|
const denied = policy ? disallowedTools(policy) : [];
|
|
30
31
|
if (denied.length)
|
|
31
32
|
args.push("--disallowedTools", denied.join(","));
|
|
@@ -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
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { matchesWorker } from "@hive/shared";
|
|
1
2
|
const LONG_RUNNING_MS = 15 * 60 * 1000;
|
|
2
3
|
const HIGH_TOKEN_TURN = 20_000;
|
|
3
4
|
const MIN_TOKEN_ALERT_RUNTIME_MS = 5 * 60 * 1000;
|
|
@@ -7,6 +8,14 @@ export function deriveAlerts(input) {
|
|
|
7
8
|
const alerts = [];
|
|
8
9
|
if (input.stale)
|
|
9
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." });
|
|
10
19
|
for (const worker of input.workers.filter((item) => !item.doneAt && (item.jobState === "working" || item.jobState === "busy"))) {
|
|
11
20
|
const workerEvents = input.events.filter((event) => matchesWorker(event.targetWorker, worker));
|
|
12
21
|
const lastUpdate = Math.max(worker.startedAt, worker.jobUpdatedAt ?? 0, ...workerEvents.map((event) => event.ts));
|
|
@@ -50,6 +59,4 @@ export function deriveAlerts(input) {
|
|
|
50
59
|
alerts.push({ id: "unclear-wait", severity: "warning", title: "Waiting without a clear question", explanation: "The runtime says it needs user input, but Hive has no structured pending decision to display.", evidence: "Waiting state present; no unresolved blocked_on_user event found.", recovery: "Open the mission and ask the orchestrator to state one concrete question with choices and a recommendation." });
|
|
51
60
|
return alerts;
|
|
52
61
|
}
|
|
53
|
-
function matchesWorker(target, worker) { if (!target)
|
|
54
|
-
return false; const value = target.toLowerCase(); return value === worker.id.toLowerCase() || value === worker.label.toLowerCase() || worker.id.toLowerCase().startsWith(value) || worker.label.toLowerCase().includes(value); }
|
|
55
62
|
function minutes(ms) { return Math.max(1, Math.floor(ms / 60_000)); }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ORCHESTRATOR_AGENT_NAME } from "@hive/shared";
|
|
1
2
|
const SUMMARY_LIMIT = 140;
|
|
2
3
|
const RAW_STRING_LIMIT = 2_000;
|
|
3
4
|
const SECRET_KEY = /(token|secret|password|authorization|cookie|api[_-]?key)/i;
|
|
@@ -57,11 +58,64 @@ export function skillPromptEvents(payload, jobId) {
|
|
|
57
58
|
}];
|
|
58
59
|
});
|
|
59
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Structured worker attribution. Only a provider agent id may name a person:
|
|
63
|
+
* `agent_type` is a role shared by many workers and previously landed in the
|
|
64
|
+
* same field, which could merge two workers or split one in two.
|
|
65
|
+
*
|
|
66
|
+
* For an Agent launch the payload's top-level ids belong to the *launching*
|
|
67
|
+
* agent, so they become the parent and the new worker's id comes from the tool
|
|
68
|
+
* response. That is what lets a worker's own sub-agent roll up into it instead
|
|
69
|
+
* of taking a desk of its own.
|
|
70
|
+
*/
|
|
71
|
+
function workerRef(payload) {
|
|
72
|
+
const top = record(payload);
|
|
73
|
+
const response = record(payload.tool_response);
|
|
74
|
+
const input = record(payload.tool_input);
|
|
75
|
+
// A launch that errored still launched: the provider returns the child id in
|
|
76
|
+
// the failure response too, and the child may already have started.
|
|
77
|
+
const launch = (payload.hook_event_name === "PostToolUse" || payload.hook_event_name === "PostToolUseFailure") && payload.tool_name === "Agent";
|
|
78
|
+
const agentId = launch
|
|
79
|
+
? stringField(response, "agentId", "agent_id")
|
|
80
|
+
: stringField(top, "agent_id", "subagent_id");
|
|
81
|
+
const parentAgentId = launch
|
|
82
|
+
? stringField(top, "agent_id", "subagent_id")
|
|
83
|
+
: stringField(top, "parent_agent_id", "parent_id", "parent_agent");
|
|
84
|
+
const ref = {
|
|
85
|
+
agentId,
|
|
86
|
+
agentType: stringField(top, "agent_type"),
|
|
87
|
+
label: launch
|
|
88
|
+
? stringField(response, "description") ?? stringField(input, "description") ?? stringField(input, "subagent_type")
|
|
89
|
+
: stringField(top, "agent_name", "teammate_name", "description"),
|
|
90
|
+
// A worker's parent is only meaningful when it is another worker.
|
|
91
|
+
parentAgentId: parentAgentId && parentAgentId !== agentId && parentAgentId !== ORCHESTRATOR_AGENT_NAME ? parentAgentId : undefined,
|
|
92
|
+
};
|
|
93
|
+
return Object.values(ref).some(Boolean) ? ref : undefined;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* A tool call that leaves work running past the end of the agent's turn.
|
|
97
|
+
*
|
|
98
|
+
* Three shapes: a backgrounded shell command; `Monitor`, which is the sanctioned
|
|
99
|
+
* way to wait (foreground sleep is blocked) and always outlives the turn; and an
|
|
100
|
+
* `Agent` launch the provider answered `isAsync`, which leaves the *launcher*
|
|
101
|
+
* waiting on a child. Missing the last two made a waiting worker look retired.
|
|
102
|
+
*/
|
|
103
|
+
function backgrounded(payload) {
|
|
104
|
+
const input = record(payload.tool_input);
|
|
105
|
+
if (input.run_in_background === true || input.runInBackground === true)
|
|
106
|
+
return true;
|
|
107
|
+
if (payload.tool_name === "Monitor" && (payload.hook_event_name === "PostToolUse" || payload.hook_event_name === "PreToolUse"))
|
|
108
|
+
return true;
|
|
109
|
+
if (payload.tool_name === "Agent" && payload.hook_event_name === "PostToolUse" && record(payload.tool_response).isAsync === true)
|
|
110
|
+
return true;
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
60
113
|
function evidence(payload) {
|
|
61
114
|
const input = record(payload.tool_input);
|
|
62
115
|
const tool = payload.tool_name ?? "";
|
|
63
116
|
const command = stringField(input, "command");
|
|
64
117
|
const filePath = stringField(input, "file_path", "path", "notebook_path");
|
|
118
|
+
const ref = workerRef(payload);
|
|
65
119
|
let targetWorker = stringField(payload, "agent_id", "subagent_id", "agent_name", "agent_type", "teammate_name");
|
|
66
120
|
// Agent launches are emitted by the manager, so the payload's top-level
|
|
67
121
|
// agent_type is `hive-orchestrator`. Claude returns the durable worker id in
|
|
@@ -71,28 +125,32 @@ function evidence(payload) {
|
|
|
71
125
|
targetWorker = stringField(record(payload.tool_response), "agentId", "agent_id") ?? targetWorker;
|
|
72
126
|
}
|
|
73
127
|
if (["TaskCreated", "TaskCompleted", "TeammateIdle"].includes(payload.hook_event_name))
|
|
74
|
-
return { activityKind: "agent", targetWorker };
|
|
128
|
+
return { activityKind: "agent", targetWorker, workerRef: ref };
|
|
129
|
+
// The provider's idle notification fires after every turn; it means "the
|
|
130
|
+
// session is idle", not "the assistant asked you something".
|
|
131
|
+
if (payload.hook_event_name === "Notification" && payload.notification_type === "idle_prompt")
|
|
132
|
+
return { activityKind: "agent", targetWorker, workerRef: ref };
|
|
75
133
|
if (["PermissionRequest", "PermissionDenied", "Elicitation", "ElicitationResult", "Notification"].includes(payload.hook_event_name))
|
|
76
|
-
return { activityKind: "decision", targetWorker };
|
|
134
|
+
return { activityKind: "decision", targetWorker, workerRef: ref };
|
|
77
135
|
if (["InstructionsLoaded", "ConfigChange"].includes(payload.hook_event_name))
|
|
78
|
-
return { activityKind: "file_read", filePath: stringField(payload, "file_path", "path"), targetWorker };
|
|
136
|
+
return { activityKind: "file_read", filePath: stringField(payload, "file_path", "path"), targetWorker, workerRef: ref };
|
|
79
137
|
if (["WorktreeCreate", "WorktreeRemove"].includes(payload.hook_event_name))
|
|
80
|
-
return { activityKind: "output", artifactPath: stringField(payload, "worktree_path", "path"), targetWorker };
|
|
138
|
+
return { activityKind: "output", artifactPath: stringField(payload, "worktree_path", "path"), targetWorker, workerRef: ref };
|
|
81
139
|
if (payload.hook_event_name === "SubagentStart" || payload.hook_event_name === "SubagentStop")
|
|
82
|
-
return { activityKind: "agent", targetWorker };
|
|
140
|
+
return { activityKind: "agent", targetWorker, workerRef: ref };
|
|
83
141
|
if (tool === "Read" || tool === "Glob" || tool === "Grep")
|
|
84
|
-
return { activityKind: "file_read", filePath, targetWorker };
|
|
142
|
+
return { activityKind: "file_read", filePath, targetWorker, workerRef: ref };
|
|
85
143
|
if (["Edit", "Write", "NotebookEdit"].includes(tool)) {
|
|
86
144
|
const artifactPath = filePath && /\.(png|jpe?g|webp|gif|svg|pdf|docx?|xlsx?|pptx?|zip)$/i.test(filePath) ? filePath : undefined;
|
|
87
|
-
return { activityKind: artifactPath ? "output" : "file_write", filePath, artifactPath, targetWorker };
|
|
145
|
+
return { activityKind: artifactPath ? "output" : "file_write", filePath, artifactPath, targetWorker, workerRef: ref };
|
|
88
146
|
}
|
|
89
147
|
if (tool === "Bash") {
|
|
90
148
|
const isTest = Boolean(command && /(^|\s)(test|pytest|vitest|jest|mocha|cargo test|go test|npm test|npm run test|typecheck|lint)(\s|$)/i.test(command));
|
|
91
|
-
return { activityKind: isTest ? "test" : "command", command: command?.slice(0, 500), targetWorker };
|
|
149
|
+
return { activityKind: isTest ? "test" : "command", command: command?.slice(0, 500), targetWorker, workerRef: ref };
|
|
92
150
|
}
|
|
93
151
|
if (payload.hook_event_name === "StopFailure")
|
|
94
|
-
return { activityKind: "lifecycle", targetWorker };
|
|
95
|
-
return { activityKind: payload.tool_name ? "tool" : "lifecycle", targetWorker };
|
|
152
|
+
return { activityKind: "lifecycle", targetWorker, workerRef: ref };
|
|
153
|
+
return { activityKind: payload.tool_name ? "tool" : "lifecycle", targetWorker, workerRef: ref };
|
|
96
154
|
}
|
|
97
155
|
function outcome(payload) {
|
|
98
156
|
if (payload.hook_event_name === "PostToolUseFailure" || payload.hook_event_name === "StopFailure" || payload.hook_event_name === "PermissionDenied")
|
|
@@ -129,5 +187,6 @@ export function toHiveEvent(payload, jobId) {
|
|
|
129
187
|
outcome: outcome(payload),
|
|
130
188
|
rawPayload: sanitizeRaw(payload),
|
|
131
189
|
...evidence(payload),
|
|
190
|
+
backgrounded: backgrounded(payload),
|
|
132
191
|
};
|
|
133
192
|
}
|
|
@@ -2,7 +2,7 @@ import Fastify from "fastify";
|
|
|
2
2
|
import cors from "@fastify/cors";
|
|
3
3
|
import fastifyStatic from "@fastify/static";
|
|
4
4
|
import websocket from "@fastify/websocket";
|
|
5
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { config } from "./config.js";
|
|
@@ -12,12 +12,17 @@ import { EventsStore } from "./events/eventsStore.js";
|
|
|
12
12
|
import { MissionsStore } from "./missions/missionsStore.js";
|
|
13
13
|
import { MessagesStore } from "./messages/messagesStore.js";
|
|
14
14
|
import { PlansStore } from "./plans/plansStore.js";
|
|
15
|
+
import { WorkerIdentityStore } from "./roster/workerIdentity.js";
|
|
15
16
|
import { registerRest } from "./api/rest.js";
|
|
16
17
|
import { registerWs } from "./api/ws.js";
|
|
17
18
|
import { drainHookSpool } from "./hooks/hookSpool.js";
|
|
18
19
|
import { PoliciesStore } from "./policies/policiesStore.js";
|
|
19
20
|
import { CodexRuntime } from "./control/codexRuntime.js";
|
|
21
|
+
import { LoopStore } from "./loops/loopStore.js";
|
|
22
|
+
import { LoopScheduler } from "./loops/loopScheduler.js";
|
|
20
23
|
import { allowedBrowserOrigins, browserOriginAllowed } from "./security/originPolicy.js";
|
|
24
|
+
import { TerminalRuntime } from "./terminals/terminalRuntime.js";
|
|
25
|
+
import { TerminalObservability } from "./terminals/terminalObservability.js";
|
|
21
26
|
async function main() {
|
|
22
27
|
const app = Fastify({ logger: true });
|
|
23
28
|
const browserOrigins = allowedBrowserOrigins({
|
|
@@ -41,8 +46,12 @@ async function main() {
|
|
|
41
46
|
const missions = new MissionsStore();
|
|
42
47
|
const messages = new MessagesStore();
|
|
43
48
|
const plans = new PlansStore();
|
|
49
|
+
const workerIdentity = new WorkerIdentityStore();
|
|
44
50
|
const policies = new PoliciesStore();
|
|
45
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();
|
|
46
55
|
events.init();
|
|
47
56
|
policies.init();
|
|
48
57
|
codex.init();
|
|
@@ -58,12 +67,34 @@ async function main() {
|
|
|
58
67
|
messages.syncJobs(jobsWatcher.getAll(), missions);
|
|
59
68
|
jobsWatcher.onJobsChange((jobs) => messages.syncJobs(jobs, missions));
|
|
60
69
|
missions.onChange(() => messages.syncJobs(jobsWatcher.getAll(), missions));
|
|
61
|
-
registerRest(app, { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex });
|
|
62
|
-
registerWs(app, { sessionsWatcher, jobsWatcher, events, missions, messages, plans });
|
|
63
|
-
app.get("/health", async () => ({ ok: true }));
|
|
64
70
|
const webRoot = process.env.HIVE_WEB_DIST
|
|
65
71
|
? path.resolve(process.env.HIVE_WEB_DIST)
|
|
66
72
|
: fileURLToPath(new URL("../../web/dist/", import.meta.url));
|
|
73
|
+
// Identifies the web bundle this server serves. A tab that loaded an older
|
|
74
|
+
// bundle reconnects after a restart and keeps running old code against new
|
|
75
|
+
// data — a fixed bug reported again from a stale tab. The client reloads
|
|
76
|
+
// when this changes.
|
|
77
|
+
const webBuild = webBuildId(webRoot);
|
|
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());
|
|
97
|
+
app.get("/health", async () => ({ ok: true, webBuild }));
|
|
67
98
|
const isCompiledRuntime = fileURLToPath(import.meta.url).includes(`${path.sep}dist${path.sep}`);
|
|
68
99
|
if (existsSync(path.join(webRoot, "index.html"))) {
|
|
69
100
|
await app.register(fastifyStatic, { root: webRoot });
|
|
@@ -82,6 +113,15 @@ async function main() {
|
|
|
82
113
|
if (replayedHooks)
|
|
83
114
|
app.log.info(`replayed ${replayedHooks} hook event(s) captured while Hive was offline`);
|
|
84
115
|
}
|
|
116
|
+
function webBuildId(webRoot) {
|
|
117
|
+
try {
|
|
118
|
+
const entry = readdirSync(path.join(webRoot, "assets")).find((file) => /^index-.*\.js$/.test(file));
|
|
119
|
+
if (entry)
|
|
120
|
+
return entry;
|
|
121
|
+
}
|
|
122
|
+
catch { /* no built bundle: dev server, or tests */ }
|
|
123
|
+
return `dev-${process.pid}-${Date.now()}`;
|
|
124
|
+
}
|
|
85
125
|
main().catch((err) => {
|
|
86
126
|
console.error(err);
|
|
87
127
|
process.exit(1);
|
|
@@ -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
|
+
}
|