@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +417 -0
  4. package/dist/bin/hive-emit.js +75 -0
  5. package/dist/bin/hive.js +506 -0
  6. package/node_modules/@hive/shared/dist/index.d.ts +2 -0
  7. package/node_modules/@hive/shared/dist/index.js +2 -0
  8. package/node_modules/@hive/shared/dist/status.d.ts +12 -0
  9. package/node_modules/@hive/shared/dist/status.js +52 -0
  10. package/node_modules/@hive/shared/dist/types.d.ts +384 -0
  11. package/node_modules/@hive/shared/dist/types.js +14 -0
  12. package/node_modules/@hive/shared/package.json +18 -0
  13. package/package.json +72 -0
  14. package/packages/server/dist/api/rest.js +793 -0
  15. package/packages/server/dist/api/ws.js +37 -0
  16. package/packages/server/dist/config.js +24 -0
  17. package/packages/server/dist/control/codexRuntime.js +169 -0
  18. package/packages/server/dist/control/killer.js +25 -0
  19. package/packages/server/dist/control/launcher.js +114 -0
  20. package/packages/server/dist/control/messaging.js +75 -0
  21. package/packages/server/dist/control/nativeCommands.js +29 -0
  22. package/packages/server/dist/control/permissionPark.js +23 -0
  23. package/packages/server/dist/control/providerModels.js +53 -0
  24. package/packages/server/dist/events/eventsStore.js +55 -0
  25. package/packages/server/dist/health/deriveAlerts.js +55 -0
  26. package/packages/server/dist/hooks/hookIngest.js +90 -0
  27. package/packages/server/dist/hooks/hookSpool.js +33 -0
  28. package/packages/server/dist/hooks/setupHooks.js +102 -0
  29. package/packages/server/dist/index.js +88 -0
  30. package/packages/server/dist/messages/messagesStore.js +211 -0
  31. package/packages/server/dist/missions/missionsStore.js +283 -0
  32. package/packages/server/dist/paths/pathResolver.js +167 -0
  33. package/packages/server/dist/plans/plansStore.js +212 -0
  34. package/packages/server/dist/policies/policiesStore.js +61 -0
  35. package/packages/server/dist/reports/githubPublisher.js +21 -0
  36. package/packages/server/dist/reports/missionReport.js +16 -0
  37. package/packages/server/dist/roster/rosterBuilder.js +243 -0
  38. package/packages/server/dist/security/originPolicy.js +31 -0
  39. package/packages/server/dist/skills/skillDiscovery.js +69 -0
  40. package/packages/server/dist/templates/templateDiscovery.js +97 -0
  41. package/packages/server/dist/watch/jobsWatcher.js +224 -0
  42. package/packages/server/dist/watch/sessionsWatcher.js +65 -0
  43. package/packages/web/dist/assets/index-CrKMFCkZ.js +11 -0
  44. package/packages/web/dist/assets/index-gEGU_lr3.css +2 -0
  45. package/packages/web/dist/favicon.svg +12 -0
  46. package/packages/web/dist/index.html +14 -0
  47. package/templates/agents/hive-orchestrator.md +42 -0
@@ -0,0 +1,37 @@
1
+ import { buildFleetSnapshot } from "../roster/rosterBuilder.js";
2
+ /**
3
+ * Single WS endpoint pushing roster + event deltas. The frontend never polls
4
+ * REST for live state — this is the only steady-state channel.
5
+ */
6
+ export function registerWs(app, deps) {
7
+ const { sessionsWatcher, jobsWatcher, events, missions, messages, plans } = deps;
8
+ const clients = new Set();
9
+ function broadcastRoster() {
10
+ const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans);
11
+ const msg = JSON.stringify({ type: "roster", snapshot });
12
+ for (const client of clients)
13
+ client.send(msg);
14
+ }
15
+ function broadcastEvent(event) {
16
+ const msg = JSON.stringify({ type: "event", event });
17
+ for (const client of clients)
18
+ client.send(msg);
19
+ }
20
+ sessionsWatcher.onChange(broadcastRoster);
21
+ jobsWatcher.onJobsChange(broadcastRoster);
22
+ missions.onChange(broadcastRoster);
23
+ messages.onChange(broadcastRoster);
24
+ plans.onChange(broadcastRoster);
25
+ events.onEvent((event) => {
26
+ broadcastEvent(event);
27
+ broadcastRoster();
28
+ });
29
+ app.get("/ws", { websocket: true }, (socket) => {
30
+ clients.add(socket);
31
+ socket.send(JSON.stringify({
32
+ type: "roster",
33
+ snapshot: buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans),
34
+ }));
35
+ socket.on("close", () => clients.delete(socket));
36
+ });
37
+ }
@@ -0,0 +1,24 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ const home = os.homedir();
4
+ const port = Number(process.env.HIVE_PORT ?? 4317);
5
+ const hiveDataDir = process.env.HIVE_DATA_DIR ?? path.join(home, ".claude-hive");
6
+ export const config = {
7
+ claudeDir: path.join(home, ".claude"),
8
+ sessionsDir: path.join(home, ".claude", "sessions"),
9
+ jobsDir: path.join(home, ".claude", "jobs"),
10
+ settingsPath: path.join(home, ".claude", "settings.json"),
11
+ hiveDataDir,
12
+ eventsLogPath: path.join(hiveDataDir, "events.jsonl"),
13
+ hookSpoolPath: path.join(hiveDataDir, "hook-spool.jsonl"),
14
+ hookRelayPath: path.join(hiveDataDir, "hive-hook-relay.cjs"),
15
+ threadsPath: path.join(hiveDataDir, "threads.json"),
16
+ missionsPath: path.join(hiveDataDir, "missions.json"),
17
+ policiesPath: path.join(hiveDataDir, "policies.json"),
18
+ codexJobsDir: path.join(hiveDataDir, "codex-jobs"),
19
+ codexSessionsDir: path.join(hiveDataDir, "codex-sessions"),
20
+ databasePath: path.join(hiveDataDir, "hive.db"),
21
+ stalledAfterMs: Number(process.env.HIVE_STALLED_AFTER_MS ?? 10 * 60 * 1000),
22
+ port,
23
+ dashboardUrl: process.env.HIVE_DASHBOARD_URL ?? `http://127.0.0.1:${port}`,
24
+ };
@@ -0,0 +1,169 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { fileURLToPath } from "node:url";
6
+ import { config } from "../config.js";
7
+ import { enforceWorkingDirectory } from "../policies/policiesStore.js";
8
+ import { requireWorkingDirectory } from "../paths/pathResolver.js";
9
+ const HIVE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../..");
10
+ export class CodexRuntime {
11
+ monitor;
12
+ init() { fs.mkdirSync(config.codexJobsDir, { recursive: true }); fs.mkdirSync(config.codexSessionsDir, { recursive: true }); this.reconcile(); this.monitor = setInterval(() => this.reconcile(), 1_000); this.monitor.unref(); }
13
+ async start(options) {
14
+ const cwd = requireWorkingDirectory(options.cwd);
15
+ enforceWorkingDirectory(cwd, options.policy);
16
+ const jobId = `codex-${randomUUID()}`;
17
+ const directory = path.join(config.codexJobsDir, jobId);
18
+ fs.mkdirSync(directory, { recursive: true });
19
+ const timelinePath = path.join(directory, "timeline.jsonl");
20
+ const stderrPath = path.join(directory, "stderr.log");
21
+ const output = fs.openSync(timelinePath, "a");
22
+ const error = fs.openSync(stderrPath, "a");
23
+ const args = options.resumeSessionId ? ["exec", "resume", "--json", "--skip-git-repo-check"] : ["exec", "--json", "--skip-git-repo-check", "-C", cwd, "-s", "workspace-write"];
24
+ if (options.model)
25
+ args.push("-m", options.model);
26
+ args.push("-c", 'approval_policy="never"');
27
+ if (!options.policy.networkAccess)
28
+ args.push("-c", 'web_search="disabled"');
29
+ if (options.resumeSessionId)
30
+ args.push(options.resumeSessionId);
31
+ args.push(this.prompt(options, jobId));
32
+ const createdAt = new Date().toISOString();
33
+ const child = spawn("codex", args, { cwd, detached: true, stdio: ["ignore", output, error] });
34
+ fs.closeSync(output);
35
+ fs.closeSync(error);
36
+ await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
37
+ const provisionalSessionId = options.resumeSessionId ?? jobId;
38
+ this.writeJob(jobId, { state: "working", detail: "Codex turn running", fan: [], children: [], intent: options.task, name: options.name, sessionId: provisionalSessionId, resumeSessionId: options.resumeSessionId, cwd, originCwd: cwd, createdAt, updatedAt: createdAt, backend: "codex", template: options.mode === "orchestrated" ? "hive-orchestrator" : undefined, daemonShort: String(child.pid), cliVersion: "codex" });
39
+ this.writeSession(child.pid, provisionalSessionId, jobId, cwd, options.name);
40
+ child.unref();
41
+ let effectiveSessionId = provisionalSessionId;
42
+ child.once("close", (code) => this.complete(jobId, child.pid, effectiveSessionId, code ?? 1));
43
+ const sessionId = await this.waitForThread(timelinePath, provisionalSessionId);
44
+ effectiveSessionId = sessionId;
45
+ if (sessionId !== provisionalSessionId) {
46
+ const job = this.readJob(jobId);
47
+ job.sessionId = sessionId;
48
+ this.writeJob(jobId, job);
49
+ this.writeSession(child.pid, sessionId, jobId, cwd, options.name);
50
+ }
51
+ return { pid: child.pid, jobId, sessionId, cwd };
52
+ }
53
+ async stop(jobId) {
54
+ const job = this.readJob(jobId);
55
+ const pid = Number(job?.daemonShort);
56
+ if (!job || !Number.isInteger(pid))
57
+ throw new Error("Codex job not found");
58
+ process.kill(pid, "SIGTERM");
59
+ job.state = "stopped";
60
+ job.detail = "Stopped by user; conversation can be resumed";
61
+ job.updatedAt = new Date().toISOString();
62
+ this.writeJob(jobId, job);
63
+ this.removeSession(pid);
64
+ }
65
+ prompt(options, jobId) {
66
+ let manager = "";
67
+ if (options.mode === "orchestrated") {
68
+ try {
69
+ const source = fs.readFileSync(path.join(HIVE_ROOT, "templates", "agents", "hive-orchestrator.md"), "utf8");
70
+ const emit = `npx tsx '${path.join(HIVE_ROOT, "bin", "hive-emit.ts").replaceAll("'", "'\\''")}' --sessionId '${jobId}' --jobId '${jobId}'`;
71
+ manager = `${source.replace(/^---[\s\S]*?---\s*/, "").replaceAll("{{HIVE_EMIT_CMD}}", emit)}\n\nCodex adaptation: delegate through Codex collaboration/subagent tools available in this session. The workspace-write sandbox is the structural editing boundary; follow the mission action policy below.\n\n`;
72
+ }
73
+ catch {
74
+ manager = "You are the manager for a Hive mission. Delegate independent work to subagents when useful, keep a structured plan current, and synthesize their evidence.\n\n";
75
+ }
76
+ }
77
+ return `${manager}${options.task}\n\nHive policy: ${JSON.stringify(options.policy)}. Work only inside the selected repository and workspace sandbox. Do not commit, push, open PRs, release, publish, use network access, or perform destructive actions when the corresponding policy value is false. If access or budget expansion is needed, stop and clearly ask the user for approval.`;
78
+ }
79
+ complete(jobId, pid, sessionId, code) {
80
+ const job = this.readJob(jobId);
81
+ if (!job || job.state !== "working")
82
+ return;
83
+ const events = readJsonLines(path.join(config.codexJobsDir, jobId, "timeline.jsonl"));
84
+ const message = events.filter((event) => event.type === "item.completed" && object(event.item)?.type === "agent_message").map((event) => String(object(event.item)?.text ?? "")).filter(Boolean).at(-1);
85
+ const usage = object([...events].reverse().find((event) => event.type === "turn.completed")?.usage);
86
+ job.state = code === 0 ? "done" : "error";
87
+ job.detail = code === 0 ? "Codex turn completed" : `Codex exited with code ${code}`;
88
+ job.output = message;
89
+ job.tokens = Number(usage?.input_tokens ?? 0) + Number(usage?.output_tokens ?? 0);
90
+ job.sessionId = sessionId;
91
+ job.updatedAt = new Date().toISOString();
92
+ this.writeJob(jobId, job);
93
+ this.removeSession(pid);
94
+ }
95
+ async waitForThread(file, fallback) { const deadline = Date.now() + 8_000; while (Date.now() < deadline) {
96
+ const started = readJsonLines(file).find((event) => event.type === "thread.started");
97
+ if (started?.thread_id)
98
+ return String(started.thread_id);
99
+ await new Promise((resolve) => setTimeout(resolve, 100));
100
+ } return fallback; }
101
+ reconcile() {
102
+ for (const jobId of safeEntries(config.codexJobsDir)) {
103
+ const job = this.readJob(jobId);
104
+ if (!job || job.state !== "working")
105
+ continue;
106
+ const pid = Number(job.daemonShort);
107
+ const completed = readJsonLines(path.join(config.codexJobsDir, jobId, "timeline.jsonl")).some((event) => event.type === "turn.completed");
108
+ if (completed)
109
+ this.complete(jobId, pid, job.sessionId ?? job.resumeSessionId ?? jobId, 0);
110
+ else {
111
+ let alive = false;
112
+ try {
113
+ process.kill(pid, 0);
114
+ alive = true;
115
+ }
116
+ catch { }
117
+ if (!alive) {
118
+ job.state = "error";
119
+ job.detail = "Codex turn was interrupted while Hive was offline";
120
+ job.updatedAt = new Date().toISOString();
121
+ this.writeJob(jobId, job);
122
+ }
123
+ }
124
+ }
125
+ for (const entry of safeEntries(config.codexSessionsDir)) {
126
+ try {
127
+ const session = JSON.parse(fs.readFileSync(path.join(config.codexSessionsDir, entry), "utf8"));
128
+ process.kill(session.pid, 0);
129
+ }
130
+ catch {
131
+ try {
132
+ fs.unlinkSync(path.join(config.codexSessionsDir, entry));
133
+ }
134
+ catch { }
135
+ }
136
+ }
137
+ }
138
+ readJob(jobId) { try {
139
+ return JSON.parse(fs.readFileSync(path.join(config.codexJobsDir, jobId, "state.json"), "utf8"));
140
+ }
141
+ catch {
142
+ return undefined;
143
+ } }
144
+ writeJob(jobId, job) { atomicWrite(path.join(config.codexJobsDir, jobId, "state.json"), job); }
145
+ writeSession(pid, sessionId, jobId, cwd, name) { atomicWrite(path.join(config.codexSessionsDir, `${pid}.json`), { pid, sessionId, cwd, startedAt: Date.now(), updatedAt: Date.now(), status: "busy", jobId, name, agent: "hive-orchestrator", entrypoint: "codex" }); }
146
+ removeSession(pid) { try {
147
+ fs.unlinkSync(path.join(config.codexSessionsDir, `${pid}.json`));
148
+ }
149
+ catch { } }
150
+ }
151
+ function atomicWrite(file, value) { const temporary = `${file}.tmp`; fs.writeFileSync(temporary, JSON.stringify(value, null, 2)); fs.renameSync(temporary, file); }
152
+ function safeEntries(directory) { try {
153
+ return fs.readdirSync(directory);
154
+ }
155
+ catch {
156
+ return [];
157
+ } }
158
+ function object(value) { return value && typeof value === "object" ? value : undefined; }
159
+ function readJsonLines(file) { try {
160
+ return fs.readFileSync(file, "utf8").split("\n").filter(Boolean).flatMap((line) => { try {
161
+ return [JSON.parse(line)];
162
+ }
163
+ catch {
164
+ return [];
165
+ } });
166
+ }
167
+ catch {
168
+ return [];
169
+ } }
@@ -0,0 +1,25 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ /**
5
+ * Stops a background job through Claude's lifecycle command. Unlike killing
6
+ * its current process, this keeps the conversation resumable and tells the
7
+ * daemon not to bring the job back.
8
+ */
9
+ export async function stopSession(jobId) {
10
+ await execFileAsync("claude", buildStopArgs(jobId));
11
+ }
12
+ export function buildStopArgs(jobId) { return ["stop", jobId]; }
13
+ /** Hard-stop escape hatch for a process that does not respond to `claude stop`. */
14
+ export function forceStopSession(pid) {
15
+ process.kill(pid, "SIGKILL");
16
+ }
17
+ export function isAlive(pid) {
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
@@ -0,0 +1,114 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { config } from "../config.js";
5
+ import { requireWorkingDirectory } from "../paths/pathResolver.js";
6
+ import { disallowedTools } from "../policies/policiesStore.js";
7
+ const ORCHESTRATOR_AGENT = "hive-orchestrator";
8
+ // "Orchestrator never edits code" is enforced by disallowedTools in the
9
+ // hive-orchestrator agent frontmatter, NOT by session-level --disallowedTools:
10
+ // session-level restrictions cascade to worker subagents, which forces workers
11
+ // to assemble files through dozens of Bash printf calls instead of Write.
12
+ // Session-level flags remain reserved for mission-policy boundaries that
13
+ // SHOULD bind workers too (pushes, releases, destructive commands).
14
+ /**
15
+ * Shells out to the `claude` CLI to launch a new orchestrator as a background
16
+ * job. Hive does not implement its own spawning/worktree logic — it wraps the
17
+ * CLI's native --bg/--worktree/--agent primitives.
18
+ */
19
+ export async function startOrchestrator(opts) {
20
+ const args = buildLaunchArgs(opts);
21
+ const before = new Set(listSessionFiles());
22
+ const cwd = requireWorkingDirectory(opts.cwd);
23
+ const child = spawn("claude", args, { cwd, detached: true, stdio: "ignore" });
24
+ child.unref();
25
+ // Worktree launches must copy/check out the repo before the session
26
+ // registers, which can take well over 8s on large repositories.
27
+ const session = await waitForNewSession(before, opts.worktree ? 30000 : 8000);
28
+ if (!session.jobId) {
29
+ throw new Error("Orchestrator session started without a background job ID");
30
+ }
31
+ return {
32
+ pid: session.pid,
33
+ jobId: session.jobId,
34
+ sessionId: session.sessionId,
35
+ cwd: session.cwd,
36
+ sessionFile: path.join(config.sessionsDir, `${session.pid}.json`),
37
+ };
38
+ }
39
+ export function generateWorktreeName(missionName) {
40
+ const slug = (missionName ?? "")
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9._-]+/g, "-")
43
+ .replace(/^-+|-+$/g, "")
44
+ .slice(0, 40);
45
+ const suffix = Math.random().toString(36).slice(2, 8);
46
+ return slug ? `hive-${slug}-${suffix}` : `hive-${suffix}`;
47
+ }
48
+ 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
+ ];
55
+ // Variadic --add-dir must come before the trailing task positional, with a
56
+ // flag after it, or it would swallow the task as another directory.
57
+ if (opts.addDirs?.length)
58
+ args.push("--add-dir", ...opts.addDirs);
59
+ const denied = opts.policy ? disallowedTools(opts.policy) : [];
60
+ if ((opts.mode ?? "orchestrated") === "orchestrated") {
61
+ args.push("--agent", ORCHESTRATOR_AGENT);
62
+ }
63
+ if (denied.length)
64
+ args.push("--disallowedTools", [...new Set(denied)].join(","));
65
+ if (opts.policy?.allowedTools.length)
66
+ args.push("--allowedTools", opts.policy.allowedTools.join(","));
67
+ args.push("--permission-mode", "auto");
68
+ if (opts.worktree) {
69
+ // Always pass an explicit name: the CLI's --worktree greedily consumes the
70
+ // next non-flag argument, so a bare --worktree would swallow the task
71
+ // prompt as the worktree name (and fail its 64-char/charset validation).
72
+ args.push("--worktree", typeof opts.worktree === "string" ? opts.worktree : generateWorktreeName(opts.name));
73
+ }
74
+ if (opts.name)
75
+ args.push("--name", opts.name);
76
+ if (opts.model)
77
+ args.push("--model", opts.model);
78
+ args.push(opts.task);
79
+ return args;
80
+ }
81
+ function listSessionFiles() {
82
+ try {
83
+ return fs
84
+ .readdirSync(config.sessionsDir)
85
+ .filter((f) => f.endsWith(".json"));
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ }
91
+ /**
92
+ * `claude --bg` detaches immediately, so the only reliable signal that the
93
+ * new session is up is its sessions/<pid>.json file appearing. Poll the
94
+ * filesystem briefly (startup confirmation only, not steady-state polling).
95
+ */
96
+ async function waitForNewSession(before, timeoutMs) {
97
+ const start = Date.now();
98
+ while (Date.now() - start < timeoutMs) {
99
+ const now = listSessionFiles();
100
+ const added = now.filter((f) => !before.has(f));
101
+ for (const file of added) {
102
+ try {
103
+ const session = JSON.parse(fs.readFileSync(path.join(config.sessionsDir, file), "utf8"));
104
+ if (Number.isFinite(session.pid) && session.sessionId)
105
+ return session;
106
+ }
107
+ catch {
108
+ // The daemon may still be writing the newly discovered file.
109
+ }
110
+ }
111
+ await new Promise((r) => setTimeout(r, 250));
112
+ }
113
+ throw new Error("Timed out waiting for orchestrator session to start");
114
+ }
@@ -0,0 +1,75 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { config } from "../config.js";
6
+ import { disallowedTools } from "../policies/policiesStore.js";
7
+ const execFileAsync = promisify(execFile);
8
+ /**
9
+ * Sends a follow-up message into a running session. The per-session Unix
10
+ * socket (messagingSocketPath) is an internal/undocumented protocol — rather
11
+ * than reverse-engineer it against live sessions, this uses the CLI's own
12
+ * public `--resume` flag as a background turn. `--bg` accepts the message as
13
+ * the positional prompt; it cannot be combined with print mode (`-p`).
14
+ * Slower than direct IPC but safe and stable across CLI versions.
15
+ */
16
+ export async function sendMessage(sessionId, text, policy, addDirs) {
17
+ const before = new Set(listJobIds());
18
+ await execFileAsync("claude", buildResumeArgs(sessionId, text, policy, addDirs));
19
+ return waitForNewJob(before);
20
+ }
21
+ export function buildResumeArgs(sessionId, text, policy, addDirs) {
22
+ // The prompt positional must precede --resume/--bg: trailing positionals are
23
+ // silently dropped on resumed background turns (the session starts idle,
24
+ // "send a prompt to start", and never replies).
25
+ // Resumes inherit the session's permission mode today, but pin auto
26
+ // explicitly so a CLI change can never drop follow-up turns into a
27
+ // prompting mode no background session can answer.
28
+ const args = [text, "--resume", sessionId, "--bg", "--permission-mode", "auto"];
29
+ const denied = policy ? disallowedTools(policy) : [];
30
+ if (denied.length)
31
+ args.push("--disallowedTools", denied.join(","));
32
+ if (policy?.allowedTools.length)
33
+ args.push("--allowedTools", policy.allowedTools.join(","));
34
+ // Variadic --add-dir goes last: with the prompt already first, there is no
35
+ // trailing positional for it to swallow.
36
+ if (addDirs?.length)
37
+ args.push("--add-dir", ...addDirs);
38
+ return args;
39
+ }
40
+ function listJobIds() {
41
+ try {
42
+ return fs
43
+ .readdirSync(config.jobsDir, { withFileTypes: true })
44
+ .filter((entry) => entry.isDirectory())
45
+ .map((entry) => entry.name);
46
+ }
47
+ catch {
48
+ return [];
49
+ }
50
+ }
51
+ async function waitForNewJob(before) {
52
+ const deadline = Date.now() + 5000;
53
+ while (Date.now() < deadline) {
54
+ const added = listJobIds().filter((jobId) => !before.has(jobId));
55
+ if (added.length) {
56
+ const jobId = added.sort((a, b) => {
57
+ const aPath = path.join(config.jobsDir, a);
58
+ const bPath = path.join(config.jobsDir, b);
59
+ return fs.statSync(bPath).mtimeMs - fs.statSync(aPath).mtimeMs;
60
+ })[0];
61
+ try {
62
+ const state = JSON.parse(fs.readFileSync(path.join(config.jobsDir, jobId, "state.json"), "utf8"));
63
+ return {
64
+ jobId,
65
+ sessionId: state.sessionId ?? state.resumeSessionId,
66
+ };
67
+ }
68
+ catch {
69
+ return { jobId };
70
+ }
71
+ }
72
+ await new Promise((resolve) => setTimeout(resolve, 100));
73
+ }
74
+ throw new Error("Claude resumed the session but no new job was discovered");
75
+ }
@@ -0,0 +1,29 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { NATIVE_COMMANDS } from "@hive/shared";
4
+ const execFileAsync = promisify(execFile);
5
+ /** Bare "/usage"-style input matching a supported native CLI command. */
6
+ export function matchNativeCommand(text) {
7
+ const match = text.trim().match(/^\/([a-z-]+)$/);
8
+ return match ? NATIVE_COMMANDS.find((command) => command.name === match[1]) : undefined;
9
+ }
10
+ /**
11
+ * Runs a native command headlessly. Sending these into a background session
12
+ * as a prompt hangs on an interactive dialog, so they run as a one-off
13
+ * `claude -p` instead. Session-scoped commands resume a FORK of the mission
14
+ * session so the report reflects it without appending to its history.
15
+ */
16
+ export async function runNativeCommand(command, opts) {
17
+ const args = ["-p", `/${command.name}`];
18
+ if (command.scope === "session") {
19
+ if (!opts.sessionId)
20
+ throw new Error("no live session to inspect yet");
21
+ args.push("--resume", opts.sessionId, "--fork-session");
22
+ }
23
+ const { stdout } = await execFileAsync("claude", args, {
24
+ cwd: opts.cwd,
25
+ timeout: 180_000,
26
+ maxBuffer: 4 * 1024 * 1024,
27
+ });
28
+ return stdout.trim() || `(/${command.name} returned no output)`;
29
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Helpers for answering a session parked at a permission prompt. Background
3
+ * sessions have no TTY and the PermissionRequest hook does not fire for
4
+ * auto-mode prompts, so approval works by stopping the parked turn and
5
+ * resuming the conversation with the verdict — granting the asked tool via
6
+ * allowedTools so the retry does not park again.
7
+ */
8
+ /** Parses the daemon's park ask ("approve Bash: glab mr view …") into tool + detail. */
9
+ export function parsePermissionAsk(needs) {
10
+ const match = needs.replace(/^approve\s+/i, "").match(/^([A-Za-z][\w-]*)\s*:?\s*([\s\S]*)$/);
11
+ return match ? { tool: match[1], detail: match[2].trim() } : undefined;
12
+ }
13
+ /** allowedTools grant that pre-approves the asked action on the resumed turn. */
14
+ export function permissionGrant(needs) {
15
+ const ask = parsePermissionAsk(needs);
16
+ if (!ask)
17
+ return undefined;
18
+ if (ask.tool === "Bash" && ask.detail) {
19
+ const prefix = ask.detail.split(/\s+/).slice(0, 3).join(" ");
20
+ return `Bash(${prefix}:*)`;
21
+ }
22
+ return ask.tool;
23
+ }
@@ -0,0 +1,53 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ export function detectProviderModels() { return [detectClaudeModels(), detectCodexModels()]; }
6
+ export function parseCodexModels(raw) {
7
+ const parsed = JSON.parse(raw);
8
+ return (parsed.models ?? []).filter((model) => model.visibility !== "hide" && typeof model.slug === "string").map((model) => ({ id: String(model.slug), label: typeof model.display_name === "string" ? model.display_name : String(model.slug), ...(typeof model.description === "string" ? { description: model.description } : {}) }));
9
+ }
10
+ export function parseClaudeModels(_help) {
11
+ return [
12
+ { id: "sonnet", label: "Sonnet 5", description: "Efficient for routine tasks" },
13
+ { id: "fable", label: "Fable 5", description: "Most capable for the hardest and longest-running tasks; may require usage credits" },
14
+ { id: "opus", label: "Opus 5", description: "Best for everyday complex tasks; higher usage" },
15
+ { id: "haiku", label: "Haiku 4.5", description: "Fastest for quick answers" },
16
+ ];
17
+ }
18
+ function detectCodexModels() {
19
+ try {
20
+ const raw = execFileSync("codex", ["debug", "models"], { encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "ignore"] });
21
+ const models = parseCodexModels(raw);
22
+ const configured = configuredModel(path.join(os.homedir(), ".codex", "config.toml"));
23
+ return { provider: "codex", available: true, models, defaultModel: models.find((model) => model.id === configured) ?? models[0], detection: "cli-catalog" };
24
+ }
25
+ catch (err) {
26
+ return { provider: "codex", available: false, models: [], detection: "unavailable", error: message(err) };
27
+ }
28
+ }
29
+ function detectClaudeModels() {
30
+ try {
31
+ const help = execFileSync("claude", ["--help"], { encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"] });
32
+ const models = parseClaudeModels(help);
33
+ const configured = configuredClaudeModel();
34
+ return { provider: "claude", available: true, models, defaultModel: models.find((model) => configured?.includes(model.id)) ?? models.find((model) => model.id === "sonnet"), detection: "cli-advertised" };
35
+ }
36
+ catch (err) {
37
+ return { provider: "claude", available: false, models: [], detection: "unavailable", error: message(err) };
38
+ }
39
+ }
40
+ function message(error) { return error instanceof Error ? error.message : String(error); }
41
+ function configuredModel(file) { try {
42
+ return fs.readFileSync(file, "utf8").match(/^model\s*=\s*["']([^"']+)["']/m)?.[1];
43
+ }
44
+ catch {
45
+ return undefined;
46
+ } }
47
+ function configuredClaudeModel() { try {
48
+ const settings = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".claude", "settings.json"), "utf8"));
49
+ return typeof settings.model === "string" ? settings.model : undefined;
50
+ }
51
+ catch {
52
+ return undefined;
53
+ } }
@@ -0,0 +1,55 @@
1
+ import fs from "node:fs";
2
+ import { config } from "../config.js";
3
+ const RING_SIZE = 200;
4
+ /**
5
+ * In-memory ring buffer per session for fast reads, backed by a durable
6
+ * append-only JSONL log on disk so history survives server restarts.
7
+ */
8
+ export class EventsStore {
9
+ dataDir;
10
+ logPath;
11
+ bySession = new Map();
12
+ listeners = new Set();
13
+ constructor(dataDir = config.hiveDataDir, logPath = config.eventsLogPath) {
14
+ this.dataDir = dataDir;
15
+ this.logPath = logPath;
16
+ }
17
+ init() {
18
+ fs.mkdirSync(this.dataDir, { recursive: true });
19
+ if (!fs.existsSync(this.logPath)) {
20
+ fs.writeFileSync(this.logPath, "");
21
+ return;
22
+ }
23
+ const lines = fs.readFileSync(this.logPath, "utf8").split("\n").filter(Boolean);
24
+ for (const line of lines) {
25
+ try {
26
+ const event = JSON.parse(line);
27
+ if (!event.sessionId || !Number.isFinite(event.ts))
28
+ continue;
29
+ const list = this.bySession.get(event.sessionId) ?? [];
30
+ list.push(event);
31
+ if (list.length > RING_SIZE)
32
+ list.shift();
33
+ this.bySession.set(event.sessionId, list);
34
+ }
35
+ catch { /* ignore a partially written or invalid historical line */ }
36
+ }
37
+ }
38
+ add(event) {
39
+ const list = this.bySession.get(event.sessionId) ?? [];
40
+ list.push(event);
41
+ if (list.length > RING_SIZE)
42
+ list.shift();
43
+ this.bySession.set(event.sessionId, list);
44
+ fs.appendFileSync(this.logPath, JSON.stringify(event) + "\n");
45
+ for (const listener of this.listeners)
46
+ listener(event);
47
+ }
48
+ recentFor(sessionId, limit = 50) {
49
+ const list = this.bySession.get(sessionId) ?? [];
50
+ return list.slice(-limit);
51
+ }
52
+ onEvent(listener) {
53
+ this.listeners.add(listener);
54
+ }
55
+ }