@shanesaravia/hive 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +417 -0
- package/dist/bin/hive-emit.js +75 -0
- package/dist/bin/hive.js +506 -0
- package/node_modules/@hive/shared/dist/index.d.ts +2 -0
- package/node_modules/@hive/shared/dist/index.js +2 -0
- package/node_modules/@hive/shared/dist/status.d.ts +12 -0
- package/node_modules/@hive/shared/dist/status.js +52 -0
- package/node_modules/@hive/shared/dist/types.d.ts +384 -0
- package/node_modules/@hive/shared/dist/types.js +14 -0
- package/node_modules/@hive/shared/package.json +18 -0
- package/package.json +72 -0
- package/packages/server/dist/api/rest.js +793 -0
- package/packages/server/dist/api/ws.js +37 -0
- package/packages/server/dist/config.js +24 -0
- package/packages/server/dist/control/codexRuntime.js +169 -0
- package/packages/server/dist/control/killer.js +25 -0
- package/packages/server/dist/control/launcher.js +114 -0
- package/packages/server/dist/control/messaging.js +75 -0
- package/packages/server/dist/control/nativeCommands.js +29 -0
- package/packages/server/dist/control/permissionPark.js +23 -0
- package/packages/server/dist/control/providerModels.js +53 -0
- package/packages/server/dist/events/eventsStore.js +55 -0
- package/packages/server/dist/health/deriveAlerts.js +55 -0
- package/packages/server/dist/hooks/hookIngest.js +90 -0
- package/packages/server/dist/hooks/hookSpool.js +33 -0
- package/packages/server/dist/hooks/setupHooks.js +102 -0
- package/packages/server/dist/index.js +88 -0
- package/packages/server/dist/messages/messagesStore.js +211 -0
- package/packages/server/dist/missions/missionsStore.js +283 -0
- package/packages/server/dist/paths/pathResolver.js +167 -0
- package/packages/server/dist/plans/plansStore.js +212 -0
- package/packages/server/dist/policies/policiesStore.js +61 -0
- package/packages/server/dist/reports/githubPublisher.js +21 -0
- package/packages/server/dist/reports/missionReport.js +16 -0
- package/packages/server/dist/roster/rosterBuilder.js +243 -0
- package/packages/server/dist/security/originPolicy.js +31 -0
- package/packages/server/dist/skills/skillDiscovery.js +69 -0
- package/packages/server/dist/templates/templateDiscovery.js +97 -0
- package/packages/server/dist/watch/jobsWatcher.js +224 -0
- package/packages/server/dist/watch/sessionsWatcher.js +65 -0
- package/packages/web/dist/assets/index-CrKMFCkZ.js +11 -0
- package/packages/web/dist/assets/index-gEGU_lr3.css +2 -0
- package/packages/web/dist/favicon.svg +12 -0
- package/packages/web/dist/index.html +14 -0
- package/templates/agents/hive-orchestrator.md +42 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const LONG_RUNNING_MS = 15 * 60 * 1000;
|
|
2
|
+
const HIGH_TOKEN_TURN = 20_000;
|
|
3
|
+
const MIN_TOKEN_ALERT_RUNTIME_MS = 5 * 60 * 1000;
|
|
4
|
+
export function deriveAlerts(input) {
|
|
5
|
+
if (["completed", "archived"].includes(input.lifecycle))
|
|
6
|
+
return [];
|
|
7
|
+
const alerts = [];
|
|
8
|
+
if (input.stale)
|
|
9
|
+
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." });
|
|
10
|
+
for (const worker of input.workers.filter((item) => !item.doneAt && (item.jobState === "working" || item.jobState === "busy"))) {
|
|
11
|
+
const workerEvents = input.events.filter((event) => matchesWorker(event.targetWorker, worker));
|
|
12
|
+
const lastUpdate = Math.max(worker.startedAt, worker.jobUpdatedAt ?? 0, ...workerEvents.map((event) => event.ts));
|
|
13
|
+
if (input.now - lastUpdate >= LONG_RUNNING_MS)
|
|
14
|
+
alerts.push({ id: `quiet-worker:${worker.id}`, severity: "warning", title: `${worker.label} has gone quiet`, explanation: "This worker is still marked active but has not produced a meaningful attributed update recently.", evidence: `No targeted worker activity for ${minutes(input.now - lastUpdate)} minutes.`, recovery: "Inspect the worker, send focused guidance, or ask the orchestrator to replace it.", workerId: worker.id });
|
|
15
|
+
}
|
|
16
|
+
if (input.lifecycle === "active" && !input.turnCompleted && input.workers.length > 0 && input.workers.every((worker) => Boolean(worker.doneAt)) && (input.activity === "idle" || input.activity === "stalled"))
|
|
17
|
+
alerts.push({ id: "idle-after-workers", severity: "warning", title: "Workers finished, orchestrator idle", explanation: "Every recorded worker has reported completion, but the mission remains active without current manager activity.", evidence: `${input.workers.length} of ${input.workers.length} workers are complete.`, recovery: "Ask for an immediate status summary, review the results, or mark the mission complete." });
|
|
18
|
+
const failedCommands = new Map();
|
|
19
|
+
for (const event of input.events)
|
|
20
|
+
if (event.command && event.outcome === "failure")
|
|
21
|
+
failedCommands.set(event.command, (failedCommands.get(event.command) ?? 0) + 1);
|
|
22
|
+
for (const [command, count] of failedCommands)
|
|
23
|
+
if (count >= 3)
|
|
24
|
+
alerts.push({ id: `retry-loop:${command}`, severity: "warning", title: "Possible command retry loop", explanation: "The same command has failed repeatedly without an observed successful run.", evidence: `${count} failed runs: ${command}`, recovery: "Inspect the first failure, change the approach, or ask the orchestrator to stop retrying and report the blocker." });
|
|
25
|
+
const writers = new Map();
|
|
26
|
+
for (const event of input.events)
|
|
27
|
+
if (event.activityKind === "file_write" && event.filePath && event.targetWorker) {
|
|
28
|
+
const set = writers.get(event.filePath) ?? new Set();
|
|
29
|
+
set.add(event.targetWorker);
|
|
30
|
+
writers.set(event.filePath, set);
|
|
31
|
+
}
|
|
32
|
+
for (const [file, workerIds] of writers)
|
|
33
|
+
if (workerIds.size > 1)
|
|
34
|
+
alerts.push({ id: `overlap:${file}`, severity: "warning", title: "Workers may be editing the same file", explanation: "Multiple workers reported writes to one path, which can create conflicting implementations or merges.", evidence: `${file} · ${[...workerIds].join(", ")}`, recovery: "Have the orchestrator assign ownership or sequence the dependent work before merging." });
|
|
35
|
+
const delegations = new Map();
|
|
36
|
+
for (const event of input.events)
|
|
37
|
+
if (event.phase === "delegating") {
|
|
38
|
+
const key = event.detail.trim().toLowerCase();
|
|
39
|
+
delegations.set(key, (delegations.get(key) ?? 0) + 1);
|
|
40
|
+
}
|
|
41
|
+
for (const [detail, count] of delegations)
|
|
42
|
+
if (count > 1)
|
|
43
|
+
alerts.push({ id: `duplicate:${detail}`, severity: "info", title: "Task may have been delegated twice", explanation: "The same delegation description appears multiple times in recent mission activity.", evidence: `${count} matching delegations: ${detail}`, recovery: "Confirm whether this is intentional retry work; otherwise cancel or reassign the duplicate." });
|
|
44
|
+
const turnEvents = input.runStartedAt ? input.events.filter((event) => event.ts >= input.runStartedAt) : input.events;
|
|
45
|
+
const measurableProgress = turnEvents.some((event) => event.activityKind === "file_write" || event.activityKind === "output" || (event.activityKind === "test" && event.outcome === "success") || event.phase === "worker_reported" || event.phase === "plan_updated" || event.phase === "ready_for_review");
|
|
46
|
+
if (input.lifecycle === "active" && (input.turnTokens ?? 0) >= HIGH_TOKEN_TURN && input.runStartedAt && input.now - input.runStartedAt >= MIN_TOKEN_ALERT_RUNTIME_MS && !measurableProgress)
|
|
47
|
+
alerts.push({ id: "tokens-without-progress", severity: "warning", title: "High token use without observed progress", explanation: "The current turn has consumed substantial context without producing measurable task, file, test, worker-report, or output evidence.", evidence: `${(input.turnTokens ?? 0).toLocaleString()} tokens this turn; no progress evidence recorded in ${minutes(input.now - input.runStartedAt)} minutes.`, recovery: "Request a status summary, narrow the task, or pause and redirect the orchestrator before more context is consumed." });
|
|
48
|
+
const unresolvedQuestion = [...input.events].reverse().find((event) => event.phase === "blocked_on_user" || event.phase === "decision_resolved");
|
|
49
|
+
if (input.activity === "waiting_on_you" && unresolvedQuestion?.phase !== "blocked_on_user")
|
|
50
|
+
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
|
+
return alerts;
|
|
52
|
+
}
|
|
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
|
+
function minutes(ms) { return Math.max(1, Math.floor(ms / 60_000)); }
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const SUMMARY_LIMIT = 140;
|
|
2
|
+
const RAW_STRING_LIMIT = 2_000;
|
|
3
|
+
const SECRET_KEY = /(token|secret|password|authorization|cookie|api[_-]?key)/i;
|
|
4
|
+
function sanitizeRaw(value, depth = 0) {
|
|
5
|
+
if (depth > 5)
|
|
6
|
+
return "[depth limit]";
|
|
7
|
+
if (typeof value === "string")
|
|
8
|
+
return value.length > RAW_STRING_LIMIT ? `${value.slice(0, RAW_STRING_LIMIT)}…` : value;
|
|
9
|
+
if (Array.isArray(value))
|
|
10
|
+
return value.slice(0, 50).map((item) => sanitizeRaw(item, depth + 1));
|
|
11
|
+
if (value && typeof value === "object")
|
|
12
|
+
return Object.fromEntries(Object.entries(value).slice(0, 100).map(([key, item]) => [key, SECRET_KEY.test(key) ? "[redacted]" : sanitizeRaw(item, depth + 1)]));
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
function record(value) {
|
|
16
|
+
return value && typeof value === "object" ? value : {};
|
|
17
|
+
}
|
|
18
|
+
function stringField(input, ...keys) {
|
|
19
|
+
for (const key of keys)
|
|
20
|
+
if (typeof input[key] === "string")
|
|
21
|
+
return input[key];
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
function evidence(payload) {
|
|
25
|
+
const input = record(payload.tool_input);
|
|
26
|
+
const tool = payload.tool_name ?? "";
|
|
27
|
+
const command = stringField(input, "command");
|
|
28
|
+
const filePath = stringField(input, "file_path", "path", "notebook_path");
|
|
29
|
+
const targetWorker = stringField(payload, "agent_id", "subagent_id", "agent_name", "agent_type", "teammate_name");
|
|
30
|
+
if (["TaskCreated", "TaskCompleted", "TeammateIdle"].includes(payload.hook_event_name))
|
|
31
|
+
return { activityKind: "agent", targetWorker };
|
|
32
|
+
if (["PermissionRequest", "PermissionDenied", "Elicitation", "ElicitationResult", "Notification"].includes(payload.hook_event_name))
|
|
33
|
+
return { activityKind: "decision", targetWorker };
|
|
34
|
+
if (["InstructionsLoaded", "ConfigChange"].includes(payload.hook_event_name))
|
|
35
|
+
return { activityKind: "file_read", filePath: stringField(payload, "file_path", "path"), targetWorker };
|
|
36
|
+
if (["WorktreeCreate", "WorktreeRemove"].includes(payload.hook_event_name))
|
|
37
|
+
return { activityKind: "output", artifactPath: stringField(payload, "worktree_path", "path"), targetWorker };
|
|
38
|
+
if (payload.hook_event_name === "SubagentStart" || payload.hook_event_name === "SubagentStop")
|
|
39
|
+
return { activityKind: "agent", targetWorker };
|
|
40
|
+
if (tool === "Read" || tool === "Glob" || tool === "Grep")
|
|
41
|
+
return { activityKind: "file_read", filePath, targetWorker };
|
|
42
|
+
if (["Edit", "Write", "NotebookEdit"].includes(tool)) {
|
|
43
|
+
const artifactPath = filePath && /\.(png|jpe?g|webp|gif|svg|pdf|docx?|xlsx?|pptx?|zip)$/i.test(filePath) ? filePath : undefined;
|
|
44
|
+
return { activityKind: artifactPath ? "output" : "file_write", filePath, artifactPath, targetWorker };
|
|
45
|
+
}
|
|
46
|
+
if (tool === "Bash") {
|
|
47
|
+
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));
|
|
48
|
+
return { activityKind: isTest ? "test" : "command", command: command?.slice(0, 500), targetWorker };
|
|
49
|
+
}
|
|
50
|
+
if (payload.hook_event_name === "StopFailure")
|
|
51
|
+
return { activityKind: "lifecycle", targetWorker };
|
|
52
|
+
return { activityKind: payload.tool_name ? "tool" : "lifecycle", targetWorker };
|
|
53
|
+
}
|
|
54
|
+
function outcome(payload) {
|
|
55
|
+
if (payload.hook_event_name === "PostToolUseFailure" || payload.hook_event_name === "StopFailure" || payload.hook_event_name === "PermissionDenied")
|
|
56
|
+
return "failure";
|
|
57
|
+
if (payload.hook_event_name !== "PostToolUse")
|
|
58
|
+
return undefined;
|
|
59
|
+
const response = record(payload.tool_response);
|
|
60
|
+
const exitCode = response.exit_code ?? response.exitCode;
|
|
61
|
+
if (response.is_error === true || response.success === false || typeof response.error === "string" || (typeof exitCode === "number" && exitCode !== 0))
|
|
62
|
+
return "failure";
|
|
63
|
+
return "success";
|
|
64
|
+
}
|
|
65
|
+
function summarize(payload) {
|
|
66
|
+
if (payload.tool_name) {
|
|
67
|
+
const input = typeof payload.tool_input === "object"
|
|
68
|
+
? JSON.stringify(payload.tool_input)
|
|
69
|
+
: String(payload.tool_input ?? "");
|
|
70
|
+
return `${payload.tool_name} ${input}`.slice(0, SUMMARY_LIMIT);
|
|
71
|
+
}
|
|
72
|
+
const fields = record(payload);
|
|
73
|
+
const subject = stringField(fields, "task_subject", "message", "reason", "error", "prompt", "teammate_name", "file_path", "worktree_path", "cwd");
|
|
74
|
+
return subject ? `${payload.hook_event_name}: ${subject}`.slice(0, SUMMARY_LIMIT) : payload.hook_event_name;
|
|
75
|
+
}
|
|
76
|
+
export function toHiveEvent(payload, jobId) {
|
|
77
|
+
return {
|
|
78
|
+
ts: typeof payload._hive_spooled_at === "number" ? payload._hive_spooled_at : Date.now(),
|
|
79
|
+
sessionId: payload.session_id,
|
|
80
|
+
jobId,
|
|
81
|
+
source: "hook",
|
|
82
|
+
hookEventName: payload.hook_event_name,
|
|
83
|
+
toolName: payload.tool_name,
|
|
84
|
+
targetTask: stringField(record(payload), "task_id"),
|
|
85
|
+
detail: summarize(payload),
|
|
86
|
+
outcome: outcome(payload),
|
|
87
|
+
rawPayload: sanitizeRaw(payload),
|
|
88
|
+
...evidence(payload),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
import { toHiveEvent } from "./hookIngest.js";
|
|
4
|
+
export function drainHookSpool(events, spoolPath = config.hookSpoolPath) {
|
|
5
|
+
const processing = `${spoolPath}.processing`;
|
|
6
|
+
let count = 0;
|
|
7
|
+
for (const file of [processing, spoolPath]) {
|
|
8
|
+
if (!fs.existsSync(file))
|
|
9
|
+
continue;
|
|
10
|
+
let source = file;
|
|
11
|
+
if (file === spoolPath) {
|
|
12
|
+
try {
|
|
13
|
+
fs.renameSync(spoolPath, processing);
|
|
14
|
+
source = processing;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const lines = fs.readFileSync(source, "utf8").split("\n").filter(Boolean);
|
|
21
|
+
for (const line of lines)
|
|
22
|
+
try {
|
|
23
|
+
const payload = JSON.parse(line);
|
|
24
|
+
if (payload.session_id && payload.hook_event_name) {
|
|
25
|
+
events.add(toHiveEvent(payload, undefined));
|
|
26
|
+
count += 1;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch { /* skip corrupt partial writes */ }
|
|
30
|
+
fs.rmSync(source, { force: true });
|
|
31
|
+
}
|
|
32
|
+
return count;
|
|
33
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { config } from "../config.js";
|
|
4
|
+
const HOOK_EVENTS = [
|
|
5
|
+
"Setup",
|
|
6
|
+
"PreToolUse",
|
|
7
|
+
"PostToolUse",
|
|
8
|
+
"PostToolUseFailure",
|
|
9
|
+
"PostToolBatch",
|
|
10
|
+
"PermissionRequest",
|
|
11
|
+
"PermissionDenied",
|
|
12
|
+
"Stop",
|
|
13
|
+
"StopFailure",
|
|
14
|
+
"Notification",
|
|
15
|
+
"SubagentStart",
|
|
16
|
+
"SubagentStop",
|
|
17
|
+
"TaskCreated",
|
|
18
|
+
"TaskCompleted",
|
|
19
|
+
"TeammateIdle",
|
|
20
|
+
"PreCompact",
|
|
21
|
+
"PostCompact",
|
|
22
|
+
"SessionStart",
|
|
23
|
+
"SessionEnd",
|
|
24
|
+
"UserPromptSubmit",
|
|
25
|
+
"UserPromptExpansion",
|
|
26
|
+
"InstructionsLoaded",
|
|
27
|
+
"ConfigChange",
|
|
28
|
+
"CwdChanged",
|
|
29
|
+
// WorktreeCreate/WorktreeRemove are deliberately absent: they are delegation
|
|
30
|
+
// hooks, not notifications — registering one makes the CLI expect the hook
|
|
31
|
+
// itself to create/remove the worktree (and print its path), so a passive
|
|
32
|
+
// relay breaks every `claude --worktree` launch.
|
|
33
|
+
"Elicitation",
|
|
34
|
+
"ElicitationResult",
|
|
35
|
+
];
|
|
36
|
+
const MARKER = "hive-hook-relay";
|
|
37
|
+
function hiveHookCommand(eventName) {
|
|
38
|
+
return `node '${config.hookRelayPath.replaceAll("'", "'\\''")}' '${eventName}' # ${MARKER}`;
|
|
39
|
+
}
|
|
40
|
+
const relaySource = `const fs=require("node:fs"),path=require("node:path");let body="";process.stdin.setEncoding("utf8");process.stdin.on("data",c=>body+=c);process.stdin.on("end",async()=>{let payload;try{payload=JSON.parse(body)}catch{return}const event=process.argv[2]||payload.hook_event_name||"unknown";try{const controller=new AbortController();const timer=setTimeout(()=>controller.abort(),900);const response=await fetch("http://127.0.0.1:"+(process.env.HIVE_PORT||"${config.port}")+"/hooks/"+encodeURIComponent(event),{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(payload),signal:controller.signal});clearTimeout(timer);if(!response.ok)throw new Error("HTTP "+response.status)}catch{try{fs.mkdirSync(path.dirname(${JSON.stringify(config.hookSpoolPath)}),{recursive:true});payload._hive_spooled_at=Date.now();fs.appendFileSync(${JSON.stringify(config.hookSpoolPath)},JSON.stringify(payload)+"\\n")}catch{}}});`;
|
|
41
|
+
/**
|
|
42
|
+
* Additively merges Hive's relay hook into ~/.claude/settings.json.
|
|
43
|
+
* Never removes or replaces existing hook entries (e.g. the sound-notification
|
|
44
|
+
* hooks). Backs up the original file before writing. Idempotent — re-running
|
|
45
|
+
* `hive setup` will not duplicate entries already tagged with MARKER.
|
|
46
|
+
*/
|
|
47
|
+
export function planHookSetup() {
|
|
48
|
+
const raw = fs.existsSync(config.settingsPath)
|
|
49
|
+
? fs.readFileSync(config.settingsPath, "utf-8")
|
|
50
|
+
: "{}";
|
|
51
|
+
const settings = JSON.parse(raw);
|
|
52
|
+
settings.hooks ??= {};
|
|
53
|
+
let changed = false;
|
|
54
|
+
// Prune relay entries left behind on events Hive no longer hooks
|
|
55
|
+
// (e.g. WorktreeCreate/WorktreeRemove, where a relay breaks the CLI).
|
|
56
|
+
for (const eventName of Object.keys(settings.hooks)) {
|
|
57
|
+
if (HOOK_EVENTS.includes(eventName))
|
|
58
|
+
continue;
|
|
59
|
+
const entries = settings.hooks[eventName];
|
|
60
|
+
const kept = entries.filter((entry) => !entry.hooks.some((hook) => hook.command.includes(MARKER)));
|
|
61
|
+
if (kept.length === entries.length)
|
|
62
|
+
continue;
|
|
63
|
+
if (kept.length)
|
|
64
|
+
settings.hooks[eventName] = kept;
|
|
65
|
+
else
|
|
66
|
+
delete settings.hooks[eventName];
|
|
67
|
+
changed = true;
|
|
68
|
+
}
|
|
69
|
+
for (const eventName of HOOK_EVENTS) {
|
|
70
|
+
const entries = settings.hooks[eventName] ?? [];
|
|
71
|
+
const desired = hiveHookCommand(eventName);
|
|
72
|
+
const existing = entries.flatMap((entry) => entry.hooks).find((hook) => hook.command.includes(MARKER));
|
|
73
|
+
if (existing) {
|
|
74
|
+
if (existing.command !== desired) {
|
|
75
|
+
existing.command = desired;
|
|
76
|
+
changed = true;
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
entries.push({
|
|
81
|
+
hooks: [{ type: "command", command: hiveHookCommand(eventName) }],
|
|
82
|
+
});
|
|
83
|
+
settings.hooks[eventName] = entries;
|
|
84
|
+
changed = true;
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
settings,
|
|
88
|
+
changed: changed || !fs.existsSync(config.hookRelayPath) || fs.readFileSync(config.hookRelayPath, "utf8") !== relaySource,
|
|
89
|
+
backupNeeded: fs.existsSync(config.settingsPath),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function applyHookSetup() {
|
|
93
|
+
const { settings, changed, backupNeeded } = planHookSetup();
|
|
94
|
+
fs.mkdirSync(path.dirname(config.hookRelayPath), { recursive: true });
|
|
95
|
+
fs.writeFileSync(config.hookRelayPath, relaySource, { mode: 0o755 });
|
|
96
|
+
if (!changed)
|
|
97
|
+
return;
|
|
98
|
+
if (backupNeeded) {
|
|
99
|
+
fs.copyFileSync(config.settingsPath, `${config.settingsPath}.hive-backup`);
|
|
100
|
+
}
|
|
101
|
+
fs.writeFileSync(config.settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
102
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import Fastify from "fastify";
|
|
2
|
+
import cors from "@fastify/cors";
|
|
3
|
+
import fastifyStatic from "@fastify/static";
|
|
4
|
+
import websocket from "@fastify/websocket";
|
|
5
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { config } from "./config.js";
|
|
9
|
+
import { SessionsWatcher } from "./watch/sessionsWatcher.js";
|
|
10
|
+
import { JobsWatcher } from "./watch/jobsWatcher.js";
|
|
11
|
+
import { EventsStore } from "./events/eventsStore.js";
|
|
12
|
+
import { MissionsStore } from "./missions/missionsStore.js";
|
|
13
|
+
import { MessagesStore } from "./messages/messagesStore.js";
|
|
14
|
+
import { PlansStore } from "./plans/plansStore.js";
|
|
15
|
+
import { registerRest } from "./api/rest.js";
|
|
16
|
+
import { registerWs } from "./api/ws.js";
|
|
17
|
+
import { drainHookSpool } from "./hooks/hookSpool.js";
|
|
18
|
+
import { PoliciesStore } from "./policies/policiesStore.js";
|
|
19
|
+
import { CodexRuntime } from "./control/codexRuntime.js";
|
|
20
|
+
import { allowedBrowserOrigins, browserOriginAllowed } from "./security/originPolicy.js";
|
|
21
|
+
async function main() {
|
|
22
|
+
const app = Fastify({ logger: true });
|
|
23
|
+
const browserOrigins = allowedBrowserOrigins({
|
|
24
|
+
serverPort: config.port,
|
|
25
|
+
dashboardUrl: config.dashboardUrl,
|
|
26
|
+
additionalOrigins: process.env.HIVE_ALLOWED_ORIGINS,
|
|
27
|
+
});
|
|
28
|
+
await app.register(cors, {
|
|
29
|
+
origin: (origin, callback) => callback(null, browserOriginAllowed(origin, browserOrigins)),
|
|
30
|
+
});
|
|
31
|
+
await app.register(websocket);
|
|
32
|
+
app.addHook("onRequest", async (request, reply) => {
|
|
33
|
+
if (!browserOriginAllowed(request.headers.origin, browserOrigins)) {
|
|
34
|
+
return reply.code(403).send({ error: "Browser origin is not allowed" });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
mkdirSync(config.hiveDataDir, { recursive: true });
|
|
38
|
+
const sessionsWatcher = new SessionsWatcher();
|
|
39
|
+
const jobsWatcher = new JobsWatcher();
|
|
40
|
+
const events = new EventsStore();
|
|
41
|
+
const missions = new MissionsStore();
|
|
42
|
+
const messages = new MessagesStore();
|
|
43
|
+
const plans = new PlansStore();
|
|
44
|
+
const policies = new PoliciesStore();
|
|
45
|
+
const codex = new CodexRuntime();
|
|
46
|
+
events.init();
|
|
47
|
+
policies.init();
|
|
48
|
+
codex.init();
|
|
49
|
+
const replayedHooks = drainHookSpool(events);
|
|
50
|
+
missions.init();
|
|
51
|
+
for (const [oldId, newId] of missions.migrations()) {
|
|
52
|
+
messages.migrateMission(oldId, newId);
|
|
53
|
+
plans.migrateMission(oldId, newId);
|
|
54
|
+
}
|
|
55
|
+
sessionsWatcher.start();
|
|
56
|
+
jobsWatcher.start();
|
|
57
|
+
missions.adoptLegacyOrchestrators(jobsWatcher.getAll());
|
|
58
|
+
messages.syncJobs(jobsWatcher.getAll(), missions);
|
|
59
|
+
jobsWatcher.onJobsChange((jobs) => messages.syncJobs(jobs, missions));
|
|
60
|
+
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
|
+
const webRoot = process.env.HIVE_WEB_DIST
|
|
65
|
+
? path.resolve(process.env.HIVE_WEB_DIST)
|
|
66
|
+
: fileURLToPath(new URL("../../web/dist/", import.meta.url));
|
|
67
|
+
const isCompiledRuntime = fileURLToPath(import.meta.url).includes(`${path.sep}dist${path.sep}`);
|
|
68
|
+
if (existsSync(path.join(webRoot, "index.html"))) {
|
|
69
|
+
await app.register(fastifyStatic, { root: webRoot });
|
|
70
|
+
app.setNotFoundHandler((request, reply) => {
|
|
71
|
+
if (request.method === "GET" && request.headers.accept?.includes("text/html")) {
|
|
72
|
+
return reply.sendFile("index.html");
|
|
73
|
+
}
|
|
74
|
+
return reply.code(404).send({ error: "Not found" });
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
else if (process.env.NODE_ENV === "production" || isCompiledRuntime) {
|
|
78
|
+
throw new Error(`Hive web build not found at ${webRoot}. Run the web build before starting production.`);
|
|
79
|
+
}
|
|
80
|
+
await app.listen({ port: config.port, host: "127.0.0.1" });
|
|
81
|
+
app.log.info(`hive server listening on http://127.0.0.1:${config.port}`);
|
|
82
|
+
if (replayedHooks)
|
|
83
|
+
app.log.info(`replayed ${replayedHooks} hook event(s) captured while Hive was offline`);
|
|
84
|
+
}
|
|
85
|
+
main().catch((err) => {
|
|
86
|
+
console.error(err);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { config } from "../config.js";
|
|
4
|
+
const DEFAULT_PAGE_SIZE = 30;
|
|
5
|
+
const MAX_PAGE_SIZE = 100;
|
|
6
|
+
export const MODEL_CONTEXT_MESSAGE_LIMIT = 12;
|
|
7
|
+
export const CONTEXT_BUDGETS = { summaryTokens: 800, recentTokens: 2_000, retrievedTokens: 1_000 };
|
|
8
|
+
const chars = (tokens) => tokens * 4;
|
|
9
|
+
function outputText(job) {
|
|
10
|
+
if (typeof job.output === "string")
|
|
11
|
+
return job.output;
|
|
12
|
+
if (job.output && typeof job.output === "object" && "result" in job.output) {
|
|
13
|
+
const result = job.output.result;
|
|
14
|
+
if (typeof result === "string")
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
function timestamp(value) {
|
|
20
|
+
const parsed = value ? Date.parse(value) : NaN;
|
|
21
|
+
return Number.isNaN(parsed) ? Date.now() : parsed;
|
|
22
|
+
}
|
|
23
|
+
function toMessage(row) {
|
|
24
|
+
return {
|
|
25
|
+
id: row.id,
|
|
26
|
+
role: row.role,
|
|
27
|
+
text: row.text,
|
|
28
|
+
createdAt: row.created_at,
|
|
29
|
+
jobId: row.job_id ?? "",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Indexed, paginated transcript storage. History is never prompt context by default. */
|
|
33
|
+
export class MessagesStore {
|
|
34
|
+
db;
|
|
35
|
+
listeners = new Set();
|
|
36
|
+
constructor(filePath = config.databasePath) {
|
|
37
|
+
this.db = new Database(filePath);
|
|
38
|
+
this.db.pragma("journal_mode = WAL");
|
|
39
|
+
this.db.pragma("foreign_keys = ON");
|
|
40
|
+
this.db.exec(`
|
|
41
|
+
CREATE TABLE IF NOT EXISTS mission_messages (
|
|
42
|
+
id TEXT PRIMARY KEY,
|
|
43
|
+
mission_id TEXT NOT NULL,
|
|
44
|
+
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
|
|
45
|
+
text TEXT NOT NULL,
|
|
46
|
+
created_at INTEGER NOT NULL,
|
|
47
|
+
job_id TEXT
|
|
48
|
+
);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_mission_messages_page
|
|
50
|
+
ON mission_messages (mission_id, created_at DESC, id DESC);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_mission_messages_job
|
|
52
|
+
ON mission_messages (job_id);
|
|
53
|
+
`);
|
|
54
|
+
}
|
|
55
|
+
close() {
|
|
56
|
+
this.db.close();
|
|
57
|
+
}
|
|
58
|
+
removeMission(missionId) { this.db.prepare("DELETE FROM mission_messages WHERE mission_id = ?").run(missionId); for (const listener of this.listeners)
|
|
59
|
+
listener(); }
|
|
60
|
+
migrateMission(oldId, newId) { this.db.prepare("UPDATE mission_messages SET mission_id = ? WHERE mission_id = ?").run(newId, oldId); }
|
|
61
|
+
add(input) {
|
|
62
|
+
const message = {
|
|
63
|
+
id: input.id ?? randomUUID(),
|
|
64
|
+
role: input.role,
|
|
65
|
+
text: input.text,
|
|
66
|
+
createdAt: input.createdAt,
|
|
67
|
+
jobId: input.jobId,
|
|
68
|
+
};
|
|
69
|
+
this.db
|
|
70
|
+
.prepare(`
|
|
71
|
+
INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id)
|
|
72
|
+
VALUES (@id, @missionId, @role, @text, @createdAt, @jobId)
|
|
73
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
74
|
+
mission_id = excluded.mission_id,
|
|
75
|
+
text = excluded.text,
|
|
76
|
+
created_at = excluded.created_at,
|
|
77
|
+
job_id = excluded.job_id
|
|
78
|
+
`)
|
|
79
|
+
.run({ ...message, missionId: input.missionId, jobId: input.jobId || null });
|
|
80
|
+
this.notify();
|
|
81
|
+
return message;
|
|
82
|
+
}
|
|
83
|
+
page(missionId, options = {}) {
|
|
84
|
+
const limit = Math.min(Math.max(options.limit ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE);
|
|
85
|
+
const rows = this.db
|
|
86
|
+
.prepare(`
|
|
87
|
+
SELECT id, mission_id, role, text, created_at, job_id
|
|
88
|
+
FROM mission_messages
|
|
89
|
+
WHERE mission_id = @missionId
|
|
90
|
+
AND (@before IS NULL OR created_at < @before)
|
|
91
|
+
ORDER BY created_at DESC, id DESC
|
|
92
|
+
LIMIT @fetchLimit
|
|
93
|
+
`)
|
|
94
|
+
.all({
|
|
95
|
+
missionId,
|
|
96
|
+
before: options.before ?? null,
|
|
97
|
+
fetchLimit: limit + 1,
|
|
98
|
+
});
|
|
99
|
+
const hasMore = rows.length > limit;
|
|
100
|
+
const pageRows = rows.slice(0, limit).reverse();
|
|
101
|
+
return {
|
|
102
|
+
messages: pageRows.map(toMessage),
|
|
103
|
+
hasMore,
|
|
104
|
+
nextBefore: hasMore ? pageRows[0]?.created_at : undefined,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
recent(missionId, limit = DEFAULT_PAGE_SIZE) {
|
|
108
|
+
return this.page(missionId, { limit }).messages;
|
|
109
|
+
}
|
|
110
|
+
count(missionId) {
|
|
111
|
+
const row = this.db
|
|
112
|
+
.prepare("SELECT COUNT(*) AS count FROM mission_messages WHERE mission_id = ?")
|
|
113
|
+
.get(missionId);
|
|
114
|
+
return row.count;
|
|
115
|
+
}
|
|
116
|
+
/** Fixed-size window for a future manager handoff; never returns full history. */
|
|
117
|
+
modelContext(missionId) {
|
|
118
|
+
let remaining = chars(CONTEXT_BUDGETS.recentTokens);
|
|
119
|
+
return this.recent(missionId, MODEL_CONTEXT_MESSAGE_LIMIT).reverse().flatMap((message) => { if (remaining <= 0)
|
|
120
|
+
return []; const text = message.text.slice(0, remaining); remaining -= text.length; return [{ ...message, text }]; }).reverse();
|
|
121
|
+
}
|
|
122
|
+
relevantContext(missionId, query, excludedIds = new Set()) {
|
|
123
|
+
const terms = [...new Set(query.toLowerCase().match(/[a-z0-9_-]{4,}/g) ?? [])].slice(0, 20);
|
|
124
|
+
if (!terms.length)
|
|
125
|
+
return [];
|
|
126
|
+
const rows = this.db.prepare("SELECT id, mission_id, role, text, created_at, job_id FROM mission_messages WHERE mission_id = ? ORDER BY created_at DESC LIMIT 250").all(missionId);
|
|
127
|
+
let remaining = chars(CONTEXT_BUDGETS.retrievedTokens);
|
|
128
|
+
return rows.filter((row) => !excludedIds.has(row.id)).map(toMessage).map((message) => ({ message, score: terms.filter((term) => message.text.toLowerCase().includes(term)).length })).filter((item) => item.score > 0).sort((a, b) => b.score - a.score || b.message.createdAt - a.message.createdAt).slice(0, 6).flatMap(({ message }) => { if (remaining <= 0)
|
|
129
|
+
return []; const text = message.text.slice(0, remaining); remaining -= text.length; return [{ ...message, text }]; }).sort((a, b) => a.createdAt - b.createdAt);
|
|
130
|
+
}
|
|
131
|
+
syncJobs(jobs, missions) {
|
|
132
|
+
const sync = this.db.transaction(() => {
|
|
133
|
+
for (const [jobId, job] of jobs) {
|
|
134
|
+
const missionId = missions.missionFor(jobId);
|
|
135
|
+
if (job.intent) {
|
|
136
|
+
// Insert-only: the route already stored the user's own words; the
|
|
137
|
+
// job's intent includes appended policy/template prompts that
|
|
138
|
+
// should never surface in their chat bubble.
|
|
139
|
+
this.insertSynced({
|
|
140
|
+
id: `${jobId}:user`,
|
|
141
|
+
missionId,
|
|
142
|
+
role: "user",
|
|
143
|
+
text: job.intent,
|
|
144
|
+
createdAt: timestamp(job.createdAt),
|
|
145
|
+
jobId,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
// The daemon compresses replies: done turns get a one-line summary in
|
|
149
|
+
// output.result, blocked turns park a terse question in needs/detail,
|
|
150
|
+
// and failed turns produce nothing. lastText carries the model's real
|
|
151
|
+
// final message from the transcript — prefer it when it says more.
|
|
152
|
+
const output = outputText(job);
|
|
153
|
+
// Blocked turns (e.g. awaiting acceptance) also carry a compressed
|
|
154
|
+
// output.result — the transcript text wins there too when it says more.
|
|
155
|
+
const fullReply = (job.state === "done" || job.state === "blocked") && job.lastText && job.lastText.length > (output?.length ?? 0) ? job.lastText : undefined;
|
|
156
|
+
const result = fullReply
|
|
157
|
+
?? output
|
|
158
|
+
?? (job.state === "failed" ? `⚠️ This turn failed: ${job.detail ?? "the session exited before producing a reply"}` : undefined)
|
|
159
|
+
?? (job.state === "blocked" ? job.lastText ?? job.needs ?? job.detail : undefined)
|
|
160
|
+
// Mid-turn permission prompts: the job stays "working" but tempo
|
|
161
|
+
// flips to blocked with the ask in needs (e.g. "approve Bash: …").
|
|
162
|
+
?? (job.tempo === "blocked" && job.needs ? `🔐 ${job.needs}` : undefined);
|
|
163
|
+
if (result) {
|
|
164
|
+
this.upsertSynced({
|
|
165
|
+
id: `${jobId}:assistant`,
|
|
166
|
+
missionId,
|
|
167
|
+
role: "assistant",
|
|
168
|
+
text: result,
|
|
169
|
+
createdAt: timestamp(job.updatedAt ?? job.createdAt),
|
|
170
|
+
jobId,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
sync();
|
|
176
|
+
this.notify();
|
|
177
|
+
}
|
|
178
|
+
onChange(listener) {
|
|
179
|
+
this.listeners.add(listener);
|
|
180
|
+
}
|
|
181
|
+
/** Upsert that follows mission relinking but never overwrites existing text. */
|
|
182
|
+
insertSynced(message) {
|
|
183
|
+
this.db
|
|
184
|
+
.prepare(`
|
|
185
|
+
INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id)
|
|
186
|
+
VALUES (@id, @missionId, @role, @text, @createdAt, @jobId)
|
|
187
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
188
|
+
mission_id = excluded.mission_id,
|
|
189
|
+
created_at = excluded.created_at,
|
|
190
|
+
job_id = excluded.job_id
|
|
191
|
+
`)
|
|
192
|
+
.run(message);
|
|
193
|
+
}
|
|
194
|
+
upsertSynced(message) {
|
|
195
|
+
this.db
|
|
196
|
+
.prepare(`
|
|
197
|
+
INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id)
|
|
198
|
+
VALUES (@id, @missionId, @role, @text, @createdAt, @jobId)
|
|
199
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
200
|
+
mission_id = excluded.mission_id,
|
|
201
|
+
text = excluded.text,
|
|
202
|
+
created_at = excluded.created_at,
|
|
203
|
+
job_id = excluded.job_id
|
|
204
|
+
`)
|
|
205
|
+
.run(message);
|
|
206
|
+
}
|
|
207
|
+
notify() {
|
|
208
|
+
for (const listener of this.listeners)
|
|
209
|
+
listener();
|
|
210
|
+
}
|
|
211
|
+
}
|