@shanesaravia/hive 0.2.0 → 0.3.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 +16 -0
- 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/types.d.ts +29 -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/api/rest.js +130 -11
- package/packages/server/dist/api/ws.js +25 -8
- package/packages/server/dist/control/launcher.js +50 -11
- package/packages/server/dist/control/messaging.js +3 -2
- package/packages/server/dist/health/deriveAlerts.js +1 -2
- package/packages/server/dist/hooks/hookIngest.js +69 -10
- package/packages/server/dist/index.js +20 -4
- package/packages/server/dist/messages/messagesStore.js +25 -12
- package/packages/server/dist/missions/missionsStore.js +19 -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/roster/missionReplay.js +82 -0
- package/packages/server/dist/roster/rosterBuilder.js +93 -189
- package/packages/server/dist/roster/workerIdentity.js +886 -0
- package/packages/server/dist/watch/jobsWatcher.js +55 -24
- package/packages/server/dist/worktrees/worktreeReclaim.js +156 -0
- package/packages/web/dist/assets/index-BpEYVjCF.css +2 -0
- package/packages/web/dist/assets/index-rIAIJyuF.js +12 -0
- package/packages/web/dist/index.html +2 -2
- package/templates/agents/hive-orchestrator.md +1 -0
- package/packages/web/dist/assets/index-BrkIk6ny.js +0 -11
- package/packages/web/dist/assets/index-DJFn_ZsI.css +0 -2
|
@@ -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(","));
|
|
@@ -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;
|
|
@@ -50,6 +51,4 @@ export function deriveAlerts(input) {
|
|
|
50
51
|
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
52
|
return alerts;
|
|
52
53
|
}
|
|
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
54
|
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,6 +12,7 @@ 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";
|
|
@@ -41,6 +42,7 @@ async function main() {
|
|
|
41
42
|
const missions = new MissionsStore();
|
|
42
43
|
const messages = new MessagesStore();
|
|
43
44
|
const plans = new PlansStore();
|
|
45
|
+
const workerIdentity = new WorkerIdentityStore();
|
|
44
46
|
const policies = new PoliciesStore();
|
|
45
47
|
const codex = new CodexRuntime();
|
|
46
48
|
events.init();
|
|
@@ -58,12 +60,17 @@ async function main() {
|
|
|
58
60
|
messages.syncJobs(jobsWatcher.getAll(), missions);
|
|
59
61
|
jobsWatcher.onJobsChange((jobs) => messages.syncJobs(jobs, missions));
|
|
60
62
|
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
63
|
const webRoot = process.env.HIVE_WEB_DIST
|
|
65
64
|
? path.resolve(process.env.HIVE_WEB_DIST)
|
|
66
65
|
: fileURLToPath(new URL("../../web/dist/", import.meta.url));
|
|
66
|
+
// Identifies the web bundle this server serves. A tab that loaded an older
|
|
67
|
+
// bundle reconnects after a restart and keeps running old code against new
|
|
68
|
+
// data — a fixed bug reported again from a stale tab. The client reloads
|
|
69
|
+
// when this changes.
|
|
70
|
+
const webBuild = webBuildId(webRoot);
|
|
71
|
+
registerRest(app, { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex, workerIdentity });
|
|
72
|
+
registerWs(app, { sessionsWatcher, jobsWatcher, events, missions, messages, plans, workerIdentity, webBuild });
|
|
73
|
+
app.get("/health", async () => ({ ok: true, webBuild }));
|
|
67
74
|
const isCompiledRuntime = fileURLToPath(import.meta.url).includes(`${path.sep}dist${path.sep}`);
|
|
68
75
|
if (existsSync(path.join(webRoot, "index.html"))) {
|
|
69
76
|
await app.register(fastifyStatic, { root: webRoot });
|
|
@@ -82,6 +89,15 @@ async function main() {
|
|
|
82
89
|
if (replayedHooks)
|
|
83
90
|
app.log.info(`replayed ${replayedHooks} hook event(s) captured while Hive was offline`);
|
|
84
91
|
}
|
|
92
|
+
function webBuildId(webRoot) {
|
|
93
|
+
try {
|
|
94
|
+
const entry = readdirSync(path.join(webRoot, "assets")).find((file) => /^index-.*\.js$/.test(file));
|
|
95
|
+
if (entry)
|
|
96
|
+
return entry;
|
|
97
|
+
}
|
|
98
|
+
catch { /* no built bundle: dev server, or tests */ }
|
|
99
|
+
return `dev-${process.pid}-${Date.now()}`;
|
|
100
|
+
}
|
|
85
101
|
main().catch((err) => {
|
|
86
102
|
console.error(err);
|
|
87
103
|
process.exit(1);
|
|
@@ -27,6 +27,7 @@ function toMessage(row) {
|
|
|
27
27
|
text: row.text,
|
|
28
28
|
createdAt: row.created_at,
|
|
29
29
|
jobId: row.job_id ?? "",
|
|
30
|
+
...(row.kind === "update" ? { kind: "update" } : {}),
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
33
|
/** Indexed, paginated transcript storage. History is never prompt context by default. */
|
|
@@ -44,13 +45,18 @@ export class MessagesStore {
|
|
|
44
45
|
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
|
|
45
46
|
text TEXT NOT NULL,
|
|
46
47
|
created_at INTEGER NOT NULL,
|
|
47
|
-
job_id TEXT
|
|
48
|
+
job_id TEXT,
|
|
49
|
+
kind TEXT
|
|
48
50
|
);
|
|
49
51
|
CREATE INDEX IF NOT EXISTS idx_mission_messages_page
|
|
50
52
|
ON mission_messages (mission_id, created_at DESC, id DESC);
|
|
51
53
|
CREATE INDEX IF NOT EXISTS idx_mission_messages_job
|
|
52
54
|
ON mission_messages (job_id);
|
|
53
55
|
`);
|
|
56
|
+
try {
|
|
57
|
+
this.db.exec("ALTER TABLE mission_messages ADD COLUMN kind TEXT");
|
|
58
|
+
}
|
|
59
|
+
catch { /* already present */ }
|
|
54
60
|
}
|
|
55
61
|
close() {
|
|
56
62
|
this.db.close();
|
|
@@ -65,18 +71,19 @@ export class MessagesStore {
|
|
|
65
71
|
text: input.text,
|
|
66
72
|
createdAt: input.createdAt,
|
|
67
73
|
jobId: input.jobId,
|
|
74
|
+
...(input.kind ? { kind: input.kind } : {}),
|
|
68
75
|
};
|
|
69
76
|
this.db
|
|
70
77
|
.prepare(`
|
|
71
|
-
INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id)
|
|
72
|
-
VALUES (@id, @missionId, @role, @text, @createdAt, @jobId)
|
|
78
|
+
INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind)
|
|
79
|
+
VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind)
|
|
73
80
|
ON CONFLICT(id) DO UPDATE SET
|
|
74
81
|
mission_id = excluded.mission_id,
|
|
75
82
|
text = excluded.text,
|
|
76
83
|
created_at = excluded.created_at,
|
|
77
84
|
job_id = excluded.job_id
|
|
78
85
|
`)
|
|
79
|
-
.run({ ...message, missionId: input.missionId, jobId: input.jobId || null });
|
|
86
|
+
.run({ ...message, kind: message.kind ?? null, missionId: input.missionId, jobId: input.jobId || null });
|
|
80
87
|
this.notify();
|
|
81
88
|
return message;
|
|
82
89
|
}
|
|
@@ -84,7 +91,7 @@ export class MessagesStore {
|
|
|
84
91
|
const limit = Math.min(Math.max(options.limit ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE);
|
|
85
92
|
const rows = this.db
|
|
86
93
|
.prepare(`
|
|
87
|
-
SELECT id, mission_id, role, text, created_at, job_id
|
|
94
|
+
SELECT id, mission_id, role, text, created_at, job_id, kind
|
|
88
95
|
FROM mission_messages
|
|
89
96
|
WHERE mission_id = @missionId
|
|
90
97
|
AND (@before IS NULL OR created_at < @before)
|
|
@@ -123,7 +130,7 @@ export class MessagesStore {
|
|
|
123
130
|
const terms = [...new Set(query.toLowerCase().match(/[a-z0-9_-]{4,}/g) ?? [])].slice(0, 20);
|
|
124
131
|
if (!terms.length)
|
|
125
132
|
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);
|
|
133
|
+
const rows = this.db.prepare("SELECT id, mission_id, role, text, created_at, job_id, kind FROM mission_messages WHERE mission_id = ? ORDER BY created_at DESC LIMIT 250").all(missionId);
|
|
127
134
|
let remaining = chars(CONTEXT_BUDGETS.retrievedTokens);
|
|
128
135
|
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
136
|
return []; const text = message.text.slice(0, remaining); remaining -= text.length; return [{ ...message, text }]; }).sort((a, b) => a.createdAt - b.createdAt);
|
|
@@ -160,6 +167,12 @@ export class MessagesStore {
|
|
|
160
167
|
// Mid-turn permission prompts: the job stays "working" but tempo
|
|
161
168
|
// flips to blocked with the ask in needs (e.g. "approve Bash: …").
|
|
162
169
|
?? (job.tempo === "blocked" && job.needs ? `🔐 ${job.needs}` : undefined);
|
|
170
|
+
// What the model said on the way — narration between tool calls — as
|
|
171
|
+
// compact updates, so the conversation shows the work as it goes
|
|
172
|
+
// rather than a single reply at the end.
|
|
173
|
+
const updates = (job.progressTexts ?? []).filter((text) => text !== result);
|
|
174
|
+
const turnStartedAt = timestamp(job.createdAt);
|
|
175
|
+
updates.forEach((text, index) => this.upsertSynced({ id: `${jobId}:update:${index}`, missionId, role: "assistant", text, createdAt: turnStartedAt + 1 + index, jobId, kind: "update" }));
|
|
163
176
|
if (result) {
|
|
164
177
|
this.upsertSynced({
|
|
165
178
|
id: `${jobId}:assistant`,
|
|
@@ -182,27 +195,27 @@ export class MessagesStore {
|
|
|
182
195
|
insertSynced(message) {
|
|
183
196
|
this.db
|
|
184
197
|
.prepare(`
|
|
185
|
-
INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id)
|
|
186
|
-
VALUES (@id, @missionId, @role, @text, @createdAt, @jobId)
|
|
198
|
+
INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind)
|
|
199
|
+
VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind)
|
|
187
200
|
ON CONFLICT(id) DO UPDATE SET
|
|
188
201
|
mission_id = excluded.mission_id,
|
|
189
202
|
created_at = excluded.created_at,
|
|
190
203
|
job_id = excluded.job_id
|
|
191
204
|
`)
|
|
192
|
-
.run(message);
|
|
205
|
+
.run({ ...message, kind: message.kind ?? null });
|
|
193
206
|
}
|
|
194
207
|
upsertSynced(message) {
|
|
195
208
|
this.db
|
|
196
209
|
.prepare(`
|
|
197
|
-
INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id)
|
|
198
|
-
VALUES (@id, @missionId, @role, @text, @createdAt, @jobId)
|
|
210
|
+
INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind)
|
|
211
|
+
VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind)
|
|
199
212
|
ON CONFLICT(id) DO UPDATE SET
|
|
200
213
|
mission_id = excluded.mission_id,
|
|
201
214
|
text = excluded.text,
|
|
202
215
|
created_at = excluded.created_at,
|
|
203
216
|
job_id = excluded.job_id
|
|
204
217
|
`)
|
|
205
|
-
.run(message);
|
|
218
|
+
.run({ ...message, kind: message.kind ?? null });
|
|
206
219
|
}
|
|
207
220
|
notify() {
|
|
208
221
|
for (const listener of this.listeners)
|
|
@@ -11,6 +11,8 @@ const emptyData = () => ({
|
|
|
11
11
|
deletedJobIds: [],
|
|
12
12
|
});
|
|
13
13
|
/** Durable Hive mission identity and metadata, independent of Claude jobs. */
|
|
14
|
+
/** The standing this store writes when a mission reaches review, and only that. */
|
|
15
|
+
const AWAITING_ACCEPTANCE = /^awaiting (user )?acceptance\.?$/i;
|
|
14
16
|
export class MissionsStore {
|
|
15
17
|
filePath;
|
|
16
18
|
legacyThreadsPath;
|
|
@@ -190,9 +192,26 @@ export class MissionsStore {
|
|
|
190
192
|
mission.updatedAt = Date.now();
|
|
191
193
|
this.changed();
|
|
192
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Repoints a mission at a different working directory. Used when a mission's
|
|
197
|
+
* worktree has been reclaimed and work must continue in the base repository.
|
|
198
|
+
*/
|
|
199
|
+
setRepository(missionId, repository) {
|
|
200
|
+
const mission = this.ensureLegacyMission(missionId);
|
|
201
|
+
mission.repository = repository;
|
|
202
|
+
mission.updatedAt = Date.now();
|
|
203
|
+
this.changed();
|
|
204
|
+
}
|
|
193
205
|
setLifecycleStatus(missionId, status) {
|
|
194
206
|
const mission = this.ensureLegacyMission(missionId);
|
|
195
207
|
mission.lifecycleStatus = status;
|
|
208
|
+
// Reaching review writes "Awaiting user acceptance" as the mission's
|
|
209
|
+
// standing. Leaving review left it there, so a reopened mission kept
|
|
210
|
+
// telling the board it was waiting for an acceptance nobody could give —
|
|
211
|
+
// and the card, having no chip to explain it, just looked stuck.
|
|
212
|
+
if (status !== "ready_for_review" && AWAITING_ACCEPTANCE.test(mission.currentState?.trim() ?? "")) {
|
|
213
|
+
mission.currentState = undefined;
|
|
214
|
+
}
|
|
196
215
|
mission.updatedAt = Date.now();
|
|
197
216
|
this.changed();
|
|
198
217
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function isWorkEvent(event) {
|
|
2
|
+
// Something was written to the repository.
|
|
3
|
+
if (event.activityKind === "file_write")
|
|
4
|
+
return true;
|
|
5
|
+
// An artifact written through the same tools — an image, a PDF, a document.
|
|
6
|
+
if (event.activityKind === "output" && event.filePath)
|
|
7
|
+
return true;
|
|
8
|
+
// A worker started. Note the hook names rather than activityKind "agent",
|
|
9
|
+
// which also covers a teammate going idle — the opposite of work.
|
|
10
|
+
if (event.hookEventName === "SubagentStart" || event.hookEventName === "TaskCreated")
|
|
11
|
+
return true;
|
|
12
|
+
// The orchestrator saying it is handing work out.
|
|
13
|
+
if (event.source === "custom" && event.phase === "delegating")
|
|
14
|
+
return true;
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
/** The lifecycle states an observed piece of work should pull back to active. */
|
|
18
|
+
export function reopensOnWork(status) {
|
|
19
|
+
return status === "ready_for_review";
|
|
20
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { matchesWorker } from "@hive/shared";
|
|
2
|
+
import { allPlanTasks } from "./plansStore.js";
|
|
3
|
+
const key = (value) => value?.trim().toLowerCase() || undefined;
|
|
4
|
+
/**
|
|
5
|
+
* Reconciles the board against the workers that are actually running.
|
|
6
|
+
*
|
|
7
|
+
* Orchestrators reliably launch workers but often forget to republish the plan,
|
|
8
|
+
* and a worker launched without a `delegating` event carries no task id at all.
|
|
9
|
+
* Either way the board sits at "queued" while work is visibly in flight. Rather
|
|
10
|
+
* than warn about that, derive the link from evidence Hive already has: the
|
|
11
|
+
* task ids bound to the worker's identity, or an exact match between the Agent
|
|
12
|
+
* description and a task's title or deliverable.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately one-directional. A running worker is proof work started, so
|
|
15
|
+
* queued/blocked tasks may advance to working. A worker *stopping* is not proof
|
|
16
|
+
* the task succeeded, so completion still requires the orchestrator's report or
|
|
17
|
+
* a gate — this never marks anything done.
|
|
18
|
+
*/
|
|
19
|
+
export function reconcilePlanWorkers(plan, workers) {
|
|
20
|
+
if (!plan)
|
|
21
|
+
return [];
|
|
22
|
+
const tasks = plan.phases.flatMap((phase) => allPlanTasks(phase.tasks));
|
|
23
|
+
if (!tasks.length)
|
|
24
|
+
return [];
|
|
25
|
+
const open = (task) => task.status !== "completed" && task.status !== "cancelled";
|
|
26
|
+
const bindings = [];
|
|
27
|
+
// A task already claimed by another worker is never re-pointed here.
|
|
28
|
+
const claimedTasks = new Set(tasks.filter((task) => task.workerId || task.owner).map((task) => task.id));
|
|
29
|
+
for (const worker of workers) {
|
|
30
|
+
const explicit = (worker.taskIds ?? []).filter((id) => tasks.some((task) => task.id === id && open(task)));
|
|
31
|
+
if (explicit.length) {
|
|
32
|
+
for (const taskId of explicit)
|
|
33
|
+
bindings.push({ taskId, canonicalWorker: worker.id, label: worker.label, inferred: false });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
// No `--task` was ever emitted for this worker. Fall back to an exact,
|
|
37
|
+
// unambiguous text match: orchestrators normally pass the task's title or
|
|
38
|
+
// its deliverable as the Agent description.
|
|
39
|
+
const name = key(worker.label);
|
|
40
|
+
if (!name)
|
|
41
|
+
continue;
|
|
42
|
+
const candidates = tasks.filter((task) => open(task) && !claimedTasks.has(task.id)
|
|
43
|
+
&& (key(task.title) === name || key(task.assignment) === name || key(task.description) === name));
|
|
44
|
+
if (candidates.length !== 1)
|
|
45
|
+
continue;
|
|
46
|
+
claimedTasks.add(candidates[0].id);
|
|
47
|
+
bindings.push({ taskId: candidates[0].id, canonicalWorker: worker.id, label: worker.label, inferred: true });
|
|
48
|
+
}
|
|
49
|
+
// Skip anything the board already reflects.
|
|
50
|
+
return bindings.filter(({ taskId, canonicalWorker }) => {
|
|
51
|
+
const task = tasks.find((item) => item.id === taskId);
|
|
52
|
+
if (!task)
|
|
53
|
+
return false;
|
|
54
|
+
const worker = workers.find((item) => item.id === canonicalWorker);
|
|
55
|
+
const named = matchesWorker(task.workerId, worker) || matchesWorker(task.owner, worker);
|
|
56
|
+
const started = task.status === "working" || task.status === "reviewing";
|
|
57
|
+
return !named || !started;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Fill each task's changed files from the writes Hive already observed.
|
|
62
|
+
*
|
|
63
|
+
* `changedFiles` appears in the orchestrator's plan schema but nothing in its
|
|
64
|
+
* instructions ever asks it to be populated, so in practice it stays empty and
|
|
65
|
+
* the acceptance view has no idea what a mission touched. Hive does not need
|
|
66
|
+
* to be told: every file-write hook carries the path and the worker that made
|
|
67
|
+
* it, and workers are already bound to tasks. Deriving beats reporting here —
|
|
68
|
+
* it cannot drift from what actually happened, and it needs no cooperation
|
|
69
|
+
* from the model.
|
|
70
|
+
*
|
|
71
|
+
* Anything the orchestrator *did* report is kept and merged, never replaced:
|
|
72
|
+
* it may know about writes made before Hive was watching.
|
|
73
|
+
*/
|
|
74
|
+
export function deriveChangedFiles(plan, workers, events) {
|
|
75
|
+
if (!plan)
|
|
76
|
+
return plan;
|
|
77
|
+
const writes = events.filter((event) => event.activityKind === "file_write" && event.filePath);
|
|
78
|
+
if (!writes.length)
|
|
79
|
+
return plan;
|
|
80
|
+
const tasks = plan.phases.flatMap((phase) => allPlanTasks(phase.tasks));
|
|
81
|
+
const byTask = new Map();
|
|
82
|
+
const add = (taskId, filePath) => {
|
|
83
|
+
const set = byTask.get(taskId) ?? new Set();
|
|
84
|
+
set.add(filePath);
|
|
85
|
+
byTask.set(taskId, set);
|
|
86
|
+
};
|
|
87
|
+
for (const event of writes) {
|
|
88
|
+
// An explicit `--task` is the orchestrator's own attribution; trust it.
|
|
89
|
+
if (event.targetTask && tasks.some((task) => task.id === event.targetTask)) {
|
|
90
|
+
add(event.targetTask, event.filePath);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
// Otherwise go through the worker, using the one identity comparison the
|
|
94
|
+
// rest of Hive uses, so an alias or a delegation label resolves the same.
|
|
95
|
+
const worker = workers.find((item) => matchesWorker(event.targetWorker, item));
|
|
96
|
+
if (!worker)
|
|
97
|
+
continue;
|
|
98
|
+
const owned = tasks.filter((task) => (worker.taskIds ?? []).includes(task.id) || matchesWorker(task.workerId, worker) || matchesWorker(task.owner, worker));
|
|
99
|
+
// A worker covering several tasks gives no evidence about which one a
|
|
100
|
+
// write belongs to, so attribute only when the answer is unambiguous.
|
|
101
|
+
if (owned.length === 1)
|
|
102
|
+
add(owned[0].id, event.filePath);
|
|
103
|
+
}
|
|
104
|
+
if (!byTask.size)
|
|
105
|
+
return plan;
|
|
106
|
+
const merge = (task) => {
|
|
107
|
+
const derived = byTask.get(task.id);
|
|
108
|
+
const subtasks = task.subtasks?.length ? task.subtasks.map(merge) : task.subtasks;
|
|
109
|
+
if (!derived)
|
|
110
|
+
return subtasks === task.subtasks ? task : { ...task, subtasks };
|
|
111
|
+
return { ...task, changedFiles: [...new Set([...(task.changedFiles ?? []), ...derived])].sort(), subtasks };
|
|
112
|
+
};
|
|
113
|
+
return { ...plan, phases: plan.phases.map((phase) => ({ ...phase, tasks: phase.tasks.map(merge) })) };
|
|
114
|
+
}
|