@cmgmyr/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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/claude-plugin/.claude-plugin/plugin.json +7 -0
  4. package/claude-plugin/README.md +47 -0
  5. package/claude-plugin/hooks/hooks.json +14 -0
  6. package/claude-plugin/kickoff.mjs +41 -0
  7. package/claude-plugin/skills/cleanup/SKILL.md +60 -0
  8. package/dist/abi.js +220 -0
  9. package/dist/abiProbe.js +9 -0
  10. package/dist/backgroundTasks.js +51 -0
  11. package/dist/backup.js +378 -0
  12. package/dist/brief.js +113 -0
  13. package/dist/build-info.json +1 -0
  14. package/dist/claudeDir.js +5 -0
  15. package/dist/cli.js +2901 -0
  16. package/dist/codexHome.js +297 -0
  17. package/dist/config.js +72 -0
  18. package/dist/context.js +256 -0
  19. package/dist/contextCheckpoint.js +55 -0
  20. package/dist/dashboard.js +1158 -0
  21. package/dist/dataDir.js +89 -0
  22. package/dist/db.js +421 -0
  23. package/dist/dispatcher.js +150 -0
  24. package/dist/firstPrompt.js +6 -0
  25. package/dist/harnesses.js +173 -0
  26. package/dist/help.js +353 -0
  27. package/dist/hook.js +197 -0
  28. package/dist/hooks.js +50 -0
  29. package/dist/index.js +34 -0
  30. package/dist/kickoff.js +191 -0
  31. package/dist/leadMessage.js +98 -0
  32. package/dist/mcpConfig.js +121 -0
  33. package/dist/processes.js +161 -0
  34. package/dist/profiles.js +278 -0
  35. package/dist/projectYml.js +254 -0
  36. package/dist/prompts.js +18 -0
  37. package/dist/ptys.js +146 -0
  38. package/dist/result.js +53 -0
  39. package/dist/scheduler.js +1622 -0
  40. package/dist/sessionProbe.js +131 -0
  41. package/dist/slug.js +29 -0
  42. package/dist/spawn.js +370 -0
  43. package/dist/stateProvenance.js +97 -0
  44. package/dist/statusline.js +82 -0
  45. package/dist/strictInput.js +52 -0
  46. package/dist/teardown.js +89 -0
  47. package/dist/tmux.js +1295 -0
  48. package/dist/tools/agents.js +1246 -0
  49. package/dist/tools/kv.js +89 -0
  50. package/dist/tools/leases.js +87 -0
  51. package/dist/tools/meta.js +183 -0
  52. package/dist/tools/pads.js +279 -0
  53. package/dist/tools/params.js +14 -0
  54. package/dist/tools/todos.js +382 -0
  55. package/dist/tools/wakes.js +466 -0
  56. package/dist/transcript.js +123 -0
  57. package/dist/version.js +59 -0
  58. package/package.json +62 -0
  59. package/profiles/orchestration/posture.md +27 -0
  60. package/profiles/orchestration/runbook.md +72 -0
  61. package/profiles/orchestration/worker.md +38 -0
  62. package/profiles/simple/posture.md +14 -0
package/dist/cli.js ADDED
@@ -0,0 +1,2901 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from "node:fs";
5
+ import { homedir, tmpdir } from "node:os";
6
+ import { join, resolve, sep } from "node:path";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { pathToFileURL } from "node:url";
9
+ import { checkAbi, describeAbi, describeInterpreter, nodeRangeForNodeApi, requiredNodeApi } from "./abi.js";
10
+ import { versionInfo } from "./version.js";
11
+ import { claudeConfigDir } from "./claudeDir.js";
12
+ import { ATTACH_MODES, AUTO_ATTACH_MODES, attachMode, isAttachMode, isAutoAttach, resolvedAttachMode, resolvedAutoAttach, setAttachMode, setAutoAttach, } from "./config.js";
13
+ import { cliPath, dispatcherDir, dispatcherPath, dispatcherScript, durabilityLines, firstHiveOnPath, linkedWorktreePin, pathAdvice, readDispatcher, } from "./dispatcher.js";
14
+ import { codexConfigPath, codexHiveRegistrations, hiveRegistrations, registrationOffer, registrationProblem, } from "./mcpConfig.js";
15
+ import { DEFAULT_DATA_DIR } from "./dataDir.js";
16
+ import { dataDir, db, migrate } from "./db.js";
17
+ import { isLowHeadroom, orphanLoginShellDetails, ptyHeadroom } from "./ptys.js";
18
+ import { addProject, agentProjectPin, currentActor, effectiveProjectId, findProjectForCwd, getProjectByPath, gitPrimaryRoot, getProject, listProjects, registeredAncestor, takeRegistrationNotice, } from "./context.js";
19
+ import { ensureHooksFile } from "./hooks.js";
20
+ import { errorMessage, parseTags, registrationNoticeText, withTrailingNewline } from "./result.js";
21
+ import { ACTIVE_TIMER_WHERE, dashboardFileContained, describeStall, HELD_REASON_CONVERSATION, HELD_REASON_LEAD_PANE_DEAD, HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX, isUnclassifiablePaneHold, HELD_REASON_UNSUBMITTED_INPUT_PREFIX, isUnsubmittedInputHold, janitor, resolveDashboardFile, transcriptStaleness, wasHeldForPaneReissue, } from "./scheduler.js";
22
+ import { STALL_BOUND_SECONDS } from "./backgroundTasks.js";
23
+ import { probeSessionInterpreter, reexecTarget, sessionStartVerdict, } from "./sessionProbe.js";
24
+ import { backupHealth, backupNow, backupsDir, formatBytes, listSnapshots, previewRestore, restoreSnapshot, totalSizeBytes, } from "./backup.js";
25
+ import { describeTeardownWindow, readTeardowns, teardownLogPath, } from "./teardown.js";
26
+ import { asNameClash, buildEnvFlags, closeAgentRow, isReservedAgentName, launchAgent, LEAD_KIND, LEAD_NAME, mintLeadActorId, upsertActor, withWindowClaim, } from "./spawn.js";
27
+ import { adoptableWindow, applyLayout, claimInitialWindow, configureHiveWindow, controlModeFor, createWindow, DEFAULT_LAYOUT, describePaneChoice, ensureSession, findProjectWindow, foreignSocket, isPaneTarget, isViewSessionName, listOwnedWindows, ORPHAN_MIN_AGE_MS, orphanScratchServers, orphansWorthWarningAbout, paneIsAloneInWindow, paneVisibility, panePid, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, projectWindows, RAW_ATTACH_TMUX_CONFIG, renderAttachCommand, resolveAttachTarget, resolveInTmuxTarget, paneReissued, rowLive, rowLiveProbe, SESSION_PREFIX, appendGlobalHook, globalHooks, replaceGlobalHooks, sessionName, setPaneTitle, shellQuote, shownPaneTitle, makeProcessesWindow, tmux, TMUX_DOC, tmuxSaysNothingThere, tmuxTimeoutOverride, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, } from "./tmux.js";
28
+ import { describeVisibility, processCounts } from "./dashboard.js";
29
+ import { runningCommandRow, runningCommandRows, snapshotProcesses, stopAllProcesses, stopLine, stopProcess, STOP_REASONS, } from "./processes.js";
30
+ import { activeProfile, agentVarKeys, configHash, loadProjectYml, mergedProjectVars, NO_PROFILE, resolveCommandDir, } from "./projectYml.js";
31
+ import { writeProjectPosture } from "./brief.js";
32
+ import { carriesNameFlag, harnessFor, hasTranscriptSignal, paneClassifierFor, transcriptDirFor } from "./harnesses.js";
33
+ import { codexHomeDir, codexInstructionsPhrase, ensureCodexHome, reapCodexHome } from "./codexHome.js";
34
+ import { TRIAGE_MESSAGE } from "./kickoff.js";
35
+ import { ageSecondsSince, deriveProvenance, describeForHuman, describeLastLogEvent, humanizeAge, lastLogEvent, lastPermissionMode, reportsAgentStateLog, } from "./stateProvenance.js";
36
+ import { awaitingFirstPromptSql } from "./firstPrompt.js";
37
+ import { checkoutRoot, createProfile, forkProfile, PROFILE_FILES, ProfileError, profileExists, profileFileNames, profileNames, profileStatus, readProfileFile, referencedPads, referencedPaths, renderProfileFile, resolveProfileFile, REWRITE_THRESHOLD, templateVars, userProfilesDir, } from "./profiles.js";
38
+ import { COMMENT_COUNT_SQL, getTodoDetail, listTodoSummaries, OPEN_BLOCKERS_SQL, TODO_STATUSES, } from "./tools/todos.js";
39
+ import { createPad, getActivePadByName, listActivePads, overwritePadContent, } from "./tools/pads.js";
40
+ function usage() {
41
+ console.log(`hive — shared memory and coordination for Claude Code sessions
42
+
43
+ Usage:
44
+ hive --version version, short sha, and dirty marker for this build
45
+ hive [path] open the project's session with a lead window
46
+ hive lead [path] [--no-dashboard]
47
+ same; lead is the default command. --no-dashboard
48
+ skips this run's dashboard auto-open -
49
+ scripts/restart-lead.sh passes it; a human rarely
50
+ needs to
51
+ hive init [path] [--profile <name>|--no-profile]
52
+ set the project up: hive.yml, profile, starter pads
53
+ hive attach [path] attach without adding windows
54
+ hive start <process> [path] start one hive.yml process by name
55
+ hive stop <process> [path] stop one running process; --all stops every one
56
+ hive show <process> [path] move a running process's pane beside the lead
57
+ hive hide <process> [path] move it back into the <project>/processes window
58
+ hive status overview of agents, todos, and wake-ups everywhere
59
+ hive setup [--dir <dir>] write a \`hive\` that runs the interpreter this build
60
+ was compiled for; re-run after every update
61
+ hive setup --attach <mode> auto|raw|control: whether tmux attaches carry -CC
62
+ hive doctor [--strict] [--verbose]
63
+ check the environment and clean up stale state;
64
+ --strict also exits non-zero on warnings that mean
65
+ this install is wrong (dispatcher, registration, ABI);
66
+ --verbose adds per-worker pane detail (last log
67
+ event, permission mode, pane tail), collapsed to
68
+ one line by default
69
+ hive pads list the current project's pads
70
+ hive pad <name> print a pad's content
71
+ hive pad <name> --edit export to a temp file and open your markdown editor
72
+ hive pad <name> --save write the edited export back (revision-guarded)
73
+ hive todos [--all] [--status <s>] [--tag <t>]
74
+ list this project's todos; open work by default
75
+ hive todo <id> print one todo in full, comments included
76
+ hive backups list automatic store snapshots
77
+ hive restore <name> [--yes] [--force] overwrite the live store from a snapshot
78
+ hive runbook this project's standing process, vars resolved
79
+ hive posture the posture text this project's lead starts with
80
+ hive kickoff [--explain] SessionStart hook output; silent unless this is a lead checkout
81
+ hive profile [list|path|fork|create|read] standing instructions shared across projects
82
+ hive statusline one-line store summary; silent outside hive projects
83
+
84
+ hive lead reads hive.yml from the project root when present; hive init
85
+ writes this starter file (uncomment what you need):
86
+
87
+ ${HIVE_YML_TEMPLATE.trimEnd().replace(/^/gm, " ")}
88
+
89
+ Repo-defined commands run only after a one-time interactive approval; any
90
+ change to a command re-requires it. By default (attach mode auto), lead/attach
91
+ use iTerm's control mode: the lead, workers, and commands all appear as native
92
+ windows and panes. hive setup --attach raw switches to a plain tmux attach;
93
+ raw mode works without tmux config; one global notification setting is recommended
94
+ for Claude Code panes hive did not create (see docs/tmux.md).`);
95
+ process.exit(1);
96
+ }
97
+ function resolveProjectAndNotify(id, onNotice) {
98
+ const project = getProject(id);
99
+ const notice = takeRegistrationNotice();
100
+ if (notice) {
101
+ const text = registrationNoticeText(notice);
102
+ if (onNotice)
103
+ onNotice(text);
104
+ else
105
+ console.error(text);
106
+ }
107
+ return project;
108
+ }
109
+ function resolveProject(path, onNotice) {
110
+ if (!path)
111
+ return resolveProjectAndNotify(effectiveProjectId(), onNotice);
112
+ process.chdir(path);
113
+ const pinned = agentProjectPin();
114
+ if (pinned == null)
115
+ return resolveProjectAndNotify(effectiveProjectId(), onNotice);
116
+ const target = findProjectForCwd();
117
+ if (target != null && target.id === pinned)
118
+ return target;
119
+ const pinnedProject = getProject(pinned);
120
+ throw new Error(`This session is locked to project ${pinned} ("${pinnedProject.name}") but "${path}" resolves to ${target ? `project ${target.id} ("${target.name}")` : "no registered project"}. An explicit path argument cannot escape HIVE_PROJECT_LOCK=1. Unset HIVE_AGENT_ID and HIVE_PROJECT_LOCK in this pane, or open a new one, to act on a different project.`);
121
+ }
122
+ function pinnedOrCwdProject() {
123
+ const pinned = agentProjectPin();
124
+ if (pinned != null)
125
+ return getProject(pinned) ?? null;
126
+ return findProjectForCwd();
127
+ }
128
+ async function confirmYesNo(question) {
129
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
130
+ return null;
131
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
132
+ const answer = await rl.question(question);
133
+ rl.close();
134
+ return /^y(es)?$/i.test(answer.trim());
135
+ }
136
+ async function ensureTrusted(projectId, name, command, dir, env) {
137
+ const hash = configHash(name, command, dir, env);
138
+ const trusted = db
139
+ .prepare("SELECT 1 FROM command_trust WHERE project_id = ? AND name = ? AND config_hash = ?")
140
+ .get(projectId, name, hash);
141
+ if (trusted)
142
+ return true;
143
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
144
+ console.log(`! "${name}" is not trusted yet; run hive interactively to review it.`);
145
+ return false;
146
+ }
147
+ console.log(`\nhive.yml defines "${name}":`);
148
+ console.log(` command: ${command}`);
149
+ if (dir)
150
+ console.log(` dir: ${dir}`);
151
+ if (Object.keys(env).length > 0)
152
+ console.log(` env: ${JSON.stringify(env)}`);
153
+ if (!(await confirmYesNo("Trust and run this command from now on? [y/N] "))) {
154
+ console.log(`Skipped "${name}".`);
155
+ return false;
156
+ }
157
+ db.prepare("INSERT OR IGNORE INTO command_trust (project_id, name, config_hash) VALUES (?, ?, ?)").run(projectId, name, hash);
158
+ return true;
159
+ }
160
+ function startYmlCommand(project, name, proc, config) {
161
+ if (isReservedAgentName(name)) {
162
+ return `skipped: "lead" is reserved for this project's lead session and cannot be used as a process name`;
163
+ }
164
+ const existing = db
165
+ .prepare("SELECT id, tmux_target, tmux_socket FROM agents WHERE project_id = ? AND name = ? AND status = 'running'")
166
+ .get(project.id, name);
167
+ if (existing) {
168
+ const live = rowLive(existing.tmux_socket, existing.tmux_target);
169
+ if (live) {
170
+ const windows = projectWindows(sessionName(), project.id);
171
+ const where = windows === null ? null : paneVisibility(existing.tmux_target, windows);
172
+ return where === "shown" || where === "hidden" ? `already running (${where})` : "already running";
173
+ }
174
+ if (live === null)
175
+ return "skipped: tmux could not be probed, so hive cannot tell whether it is already running";
176
+ closeAgentRow(existing.id);
177
+ }
178
+ let dir;
179
+ try {
180
+ dir = resolveCommandDir(project.path, proc.dir);
181
+ }
182
+ catch (e) {
183
+ return `skipped: ${errorMessage(e)}`;
184
+ }
185
+ const placement = proc.visible
186
+ ? (config.placement ?? (process.env.HIVE_SPAWN_PLACEMENT === "window" ? "window" : "split"))
187
+ : "processes";
188
+ try {
189
+ const { target, landedInProjectId, inProcessesWindow } = launchAgent({
190
+ projectId: project.id,
191
+ projectName: project.name,
192
+ projectPath: project.path,
193
+ name,
194
+ kind: "command",
195
+ commandString: proc.command,
196
+ cwd: dir,
197
+ env: proc.env,
198
+ placement,
199
+ layout: config.layout ?? DEFAULT_LAYOUT,
200
+ parentActor: currentActor(),
201
+ });
202
+ if (proc.visible) {
203
+ // Its own window never set a pane title, so a status line rendering #T showed the hostname.
204
+ setPaneTitle(target, shownPaneTitle(project.name, name));
205
+ if (landedInProjectId != null) {
206
+ const landedName = getProject(landedInProjectId)?.name ?? `project ${landedInProjectId}`;
207
+ return `started, landed in project "${landedName}"'s window`;
208
+ }
209
+ return "started";
210
+ }
211
+ return inProcessesWindow
212
+ ? "started (hidden)"
213
+ : "started in its own window: it opened this project's tmux session, and a session's first window " +
214
+ `cannot be a tile. Move it with: hive hide "${name}"`;
215
+ }
216
+ catch (e) {
217
+ return `failed: ${errorMessage(e)}`;
218
+ }
219
+ }
220
+ function attach(session, project, window) {
221
+ if (process.env.TMUX) {
222
+ const argv = resolveInTmuxTarget(session, window ?? findProjectWindow(session, project.id));
223
+ if (argv)
224
+ spawnSync("tmux", argv, { stdio: "inherit" });
225
+ return;
226
+ }
227
+ const controlMode = controlModeFor(process.env.TERM_PROGRAM === "iTerm.app");
228
+ const argv = resolveAttachTarget(session, project.id, controlMode, window);
229
+ if (!process.stdout.isTTY) {
230
+ console.log(`Session ${session} is ready for project "${project.name}" (${project.path}).`);
231
+ console.log(`Attach from a terminal with: tmux ${renderAttachCommand(argv)}`);
232
+ return;
233
+ }
234
+ const result = spawnSync("tmux", argv, { stdio: "inherit" });
235
+ process.exit(result.status ?? 0);
236
+ }
237
+ function asLeadNameReuseClash(e, projectId, leadRowId) {
238
+ const err = e;
239
+ const message = err.message ?? "";
240
+ if (err.code !== "SQLITE_CONSTRAINT_UNIQUE" || !(message.includes("agents.name") || message.includes("idx_agents_running_name"))) {
241
+ return e;
242
+ }
243
+ const holder = db
244
+ .prepare("SELECT id, kind, actor_id FROM agents WHERE project_id = ? AND name = ? COLLATE NOCASE AND status = 'running' AND id != ?")
245
+ .get(projectId, LEAD_NAME, leadRowId);
246
+ if (!holder) {
247
+ return new Error('Cannot reclaim the name "lead": another running row already holds it, but a second look could not find ' +
248
+ "which one. Re-run `hive lead`.");
249
+ }
250
+ return new Error(`Cannot reclaim the name "lead": a running ${holder.kind} (actor ${holder.actor_id}, agents.id ${holder.id}) ` +
251
+ `already holds it. Rename or close that ${holder.kind} first, then re-run \`hive lead\`.`);
252
+ }
253
+ function ensureLeadRow(project, command) {
254
+ const existing = db
255
+ .prepare("SELECT id, actor_id, tmux_target, tmux_socket, pane_pid, command, codex_home FROM agents WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id")
256
+ .get(project.id, LEAD_KIND);
257
+ if (existing) {
258
+ const actorId = existing.actor_id || mintLeadActorId(existing.id);
259
+ try {
260
+ db.transaction(() => {
261
+ db.prepare("UPDATE agents SET actor_id = ?, name = ? WHERE id = ?").run(actorId, LEAD_NAME, existing.id);
262
+ upsertActor(actorId, LEAD_NAME, LEAD_KIND);
263
+ })();
264
+ }
265
+ catch (e) {
266
+ throw asLeadNameReuseClash(e, project.id, existing.id);
267
+ }
268
+ return {
269
+ agentId: existing.id,
270
+ actorId,
271
+ previousTarget: existing.tmux_target,
272
+ previousSocket: existing.tmux_socket,
273
+ previousPanePid: existing.pane_pid,
274
+ previousCommand: existing.command,
275
+ previousCodexHome: existing.codex_home,
276
+ casExpected: existing.tmux_target,
277
+ };
278
+ }
279
+ const priorClosed = db
280
+ .prepare("SELECT actor_id, tmux_target, tmux_socket, pane_pid, command, codex_home FROM agents WHERE project_id = ? AND kind = ? AND status = 'closed' AND actor_id != '' ORDER BY id DESC LIMIT 1")
281
+ .get(project.id, LEAD_KIND);
282
+ let result;
283
+ try {
284
+ result = db.transaction(() => {
285
+ const info = db
286
+ .prepare("INSERT INTO agents (project_id, name, command, cwd, kind, parent_actor_id, tmux_target, tmux_socket, pane_pid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
287
+ .run(project.id, LEAD_NAME, command, project.path, LEAD_KIND, currentActor(), "", tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR), "");
288
+ const agentId = Number(info.lastInsertRowid);
289
+ const actorId = priorClosed?.actor_id ?? mintLeadActorId(agentId);
290
+ db.prepare("UPDATE agents SET actor_id = ? WHERE id = ?").run(actorId, agentId);
291
+ upsertActor(actorId, LEAD_NAME, LEAD_KIND);
292
+ return { agentId, actorId };
293
+ })();
294
+ }
295
+ catch (e) {
296
+ throw asNameClash(e, LEAD_NAME);
297
+ }
298
+ return {
299
+ agentId: result.agentId,
300
+ actorId: result.actorId,
301
+ previousTarget: priorClosed?.tmux_target ?? "",
302
+ previousSocket: priorClosed?.tmux_socket ?? "",
303
+ previousPanePid: priorClosed?.pane_pid ?? "",
304
+ previousCommand: priorClosed?.command ?? "",
305
+ previousCodexHome: priorClosed?.codex_home ?? "",
306
+ casExpected: "",
307
+ };
308
+ }
309
+ async function cmdLead(argv) {
310
+ const unknownFlag = argv.find((a) => a.startsWith("--") && a !== "--no-dashboard");
311
+ if (unknownFlag !== undefined) {
312
+ console.error(`hive lead: unknown flag "${unknownFlag}". The only flag is --no-dashboard.`);
313
+ process.exit(1);
314
+ }
315
+ const noDashboard = argv.includes("--no-dashboard");
316
+ const path = argv.find((a) => !a.startsWith("--"));
317
+ let registrationNotice = null;
318
+ try {
319
+ const project = resolveProject(path, (text) => {
320
+ registrationNotice = text;
321
+ });
322
+ const session = sessionName();
323
+ const hooksPath = ensureHooksFile();
324
+ const { config, warnings } = loadProjectYml(project.path);
325
+ for (const w of warnings)
326
+ console.log(`! ${w}`);
327
+ let leadCommand = "claude";
328
+ if (!config) {
329
+ console.log("No hive.yml here; starting a plain claude lead. Add a hive.yml to define project commands and a custom lead (run hive --help for the format).");
330
+ }
331
+ else if (config.lead) {
332
+ if (await ensureTrusted(project.id, "lead", config.lead, null, {})) {
333
+ leadCommand = config.lead;
334
+ }
335
+ else {
336
+ console.log("Using the default claude lead instead.");
337
+ }
338
+ }
339
+ const leadHarness = harnessFor(leadCommand);
340
+ // Posture is rendered once, then delivered through whichever channel this harness has: a
341
+ // claude lead gets it as --append-system-prompt-file below; a codex lead gets the identical
342
+ // text as config.toml's developer_instructions, built after ensureLeadRow (it needs leadActorId).
343
+ const profile = activeProfile(config);
344
+ let renderedPosture = null;
345
+ let postureSource;
346
+ if (profile) {
347
+ const posture = resolveProfileFile(profile, "posture.md");
348
+ if (!posture) {
349
+ console.log(`! profile "${profile}" has no posture.md on this machine; starting without it.`);
350
+ }
351
+ else {
352
+ renderedPosture = renderProfileFile(profile, "posture.md", mergedProjectVars(config)) ?? "";
353
+ postureSource = posture.source;
354
+ }
355
+ }
356
+ if (leadHarness.needsHome) {
357
+ // Wired below, once leadActorId is known - a codex lead's hooks/posture/triage all live in
358
+ // its generated CODEX_HOME rather than as CLI flags on leadCommand (todo 575: briefDelivery
359
+ // is CLI-flag shaped and codex has no such flags; its route is the home workers already use).
360
+ }
361
+ else if (!leadHarness.briefDelivery) {
362
+ console.log("! lead command is not claude; skipping hooks.");
363
+ if (renderedPosture !== null) {
364
+ console.log(`! lead command is not claude; skipping profile "${profile}" posture.`);
365
+ }
366
+ }
367
+ else {
368
+ if (!carriesNameFlag(leadCommand.trim().split(/\s+/))) {
369
+ leadCommand += ` --name ${shellQuote(project.name)}`;
370
+ }
371
+ leadCommand += ` ${leadHarness.briefDelivery.settingsArgs(hooksPath).map(shellQuote).join(" ")}`;
372
+ if (renderedPosture !== null) {
373
+ const posturePath = writeProjectPosture(project.id, renderedPosture);
374
+ leadCommand += ` ${leadHarness.briefDelivery.systemPromptArgs(posturePath).map(shellQuote).join(" ")}`;
375
+ console.log(`- profile: ${profile} (${postureSource} posture; see it with: hive posture)`);
376
+ }
377
+ }
378
+ const windowName = project.name;
379
+ const { agentId: leadAgentId, actorId: leadActorId, previousTarget, previousSocket, previousPanePid, previousCommand, previousCodexHome, casExpected, } = ensureLeadRow(project, leadCommand);
380
+ // A fresh CODEX_HOME is built on every invocation, same as claude's hooks/posture files above -
381
+ // cheap (a few KB; the codex-side plugins/cache bootstrap only materializes once a process
382
+ // actually starts under it) and correct regardless of whether this invocation ends up creating
383
+ // a pane or adopting one. Which home ends up orphaned - this one, or the previous invocation's -
384
+ // is decided after createdPane is known, below.
385
+ let newCodexHomeKey;
386
+ if (leadHarness.needsHome) {
387
+ newCodexHomeKey = randomUUID();
388
+ // Mirrors the worker path's own failure handling (src/tools/agents.ts:625-631): a home half
389
+ // written (auth.json symlinked, then a missing interpreter or missing claude-plugin/ throws)
390
+ // must not survive the throw, because nothing records newCodexHomeKey anywhere until the CAS
391
+ // UPDATE below succeeds - an unrecorded key is unreapable by every other path in this file.
392
+ let homeArgs;
393
+ let hooksWired;
394
+ let instructionLayers;
395
+ try {
396
+ const home = ensureCodexHome({
397
+ key: newCodexHomeKey,
398
+ actorId: leadActorId,
399
+ cwd: project.path,
400
+ brief: renderedPosture ?? "",
401
+ lead: true,
402
+ });
403
+ homeArgs = home.extraArgs;
404
+ hooksWired = home.hooksWired;
405
+ instructionLayers = home.instructionLayers;
406
+ }
407
+ catch (e) {
408
+ reapCodexHome(newCodexHomeKey);
409
+ throw e;
410
+ }
411
+ leadCommand += ` ${homeArgs.map(shellQuote).join(" ")}`;
412
+ if (leadHarness.initialPromptArgs) {
413
+ leadCommand += ` ${leadHarness.initialPromptArgs(TRIAGE_MESSAGE).map(shellQuote).join(" ")}`;
414
+ }
415
+ if (renderedPosture !== null) {
416
+ console.log(`- profile: ${profile} (${postureSource} posture; see it with: hive posture)`);
417
+ }
418
+ const instructionsPhrase = codexInstructionsPhrase(instructionLayers);
419
+ console.log(`- codex home: ${codexHomeDir(newCodexHomeKey)} (${hooksWired.join("/")} hooks wired)` +
420
+ (instructionsPhrase ? ` (${instructionsPhrase})` : ""));
421
+ }
422
+ const envFlags = buildEnvFlags({
423
+ HIVE_AGENT_ID: leadActorId,
424
+ HIVE_AGENT_NAME: LEAD_NAME,
425
+ HIVE_LEAD: "1",
426
+ HIVE_DATA_DIR: dataDir,
427
+ HIVE_PROJECT_LOCK: "",
428
+ HIVE_PROJECT_PATH: "",
429
+ ...(newCodexHomeKey ? { CODEX_HOME: codexHomeDir(newCodexHomeKey) } : {}),
430
+ });
431
+ const { leadPane, leadWindow, createdPane } = withWindowClaim(() => {
432
+ let leadPane;
433
+ let leadWindow;
434
+ let createdPane;
435
+ const started = ensureSession(session, project.path, { envFlags, command: leadCommand });
436
+ if (started.created) {
437
+ const claimed = claimInitialWindow(started, windowName, project.id);
438
+ leadPane = claimed.pane;
439
+ leadWindow = claimed.window;
440
+ createdPane = true;
441
+ }
442
+ else {
443
+ const foundWindow = findProjectWindow(session, project.id);
444
+ const probe = rowLiveProbe(previousSocket, previousTarget);
445
+ const adopted = isPaneTarget(previousTarget) && probe.live === true && !paneReissued(previousPanePid, probe)
446
+ ? adoptableWindow(session, project.id, previousTarget)
447
+ : null;
448
+ if (adopted) {
449
+ leadPane = previousTarget;
450
+ leadWindow = adopted;
451
+ createdPane = false;
452
+ }
453
+ else if (!foundWindow) {
454
+ const fresh = createWindow(session, windowName, project.path, envFlags, leadCommand, project.id);
455
+ leadPane = fresh.pane;
456
+ leadWindow = fresh.window;
457
+ createdPane = true;
458
+ }
459
+ else {
460
+ leadPane = tmux("split-window", "-P", "-F", "#{pane_id}", "-t", foundWindow, "-c", project.path, ...envFlags, leadCommand);
461
+ leadWindow = foundWindow;
462
+ createdPane = true;
463
+ }
464
+ }
465
+ return { leadPane, leadWindow, createdPane };
466
+ });
467
+ // The row may only claim a command hive just launched into a pane it made. On an adopt the
468
+ // occupant is unknown, so the previous row's command stands (todo 507).
469
+ const recordedCommand = createdPane ? leadCommand : previousCommand || leadCommand;
470
+ if (!createdPane && previousCommand && previousCommand !== leadCommand) {
471
+ console.log(`! adopted the existing lead pane, which is still running: ${previousCommand}\n` +
472
+ ` the configured lead command is now: ${leadCommand}\n` +
473
+ " hive did not start that pane and cannot change what it runs, so the new command takes " +
474
+ "effect only once the pane is restarted (close it, or agent_close the lead, then hive lead).");
475
+ }
476
+ // Whichever home this invocation did NOT end up running the pane under is now orphaned: a
477
+ // fresh pane runs under newCodexHomeKey and leaves the previous invocation's home behind; an
478
+ // adopted pane keeps running under previousCodexHome and leaves the one just built unused. This
479
+ // also reaps a stale codex home left over from a lead config that has since switched away from
480
+ // codex, since createdPane's branch does not require leadHarness.needsHome to fire.
481
+ const recordedCodexHome = createdPane ? (newCodexHomeKey ?? "") : previousCodexHome;
482
+ const wonRace = db.transaction(() => {
483
+ const updated = db
484
+ .prepare("UPDATE agents SET tmux_target = ?, tmux_socket = ?, pane_pid = ?, command = ?, codex_home = ? WHERE id = ? AND tmux_target = ? AND status = 'running'")
485
+ .run(leadPane, tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR), panePid(leadPane), recordedCommand, recordedCodexHome, leadAgentId, casExpected).changes;
486
+ if (updated === 0)
487
+ return false;
488
+ db.prepare(`UPDATE wakes SET deliver_pane = ?, held_at = NULL, held_reason = NULL
489
+ WHERE ${ACTIVE_TIMER_WHERE} AND deliver_actor = ?
490
+ AND (? = 1 OR held_reason IS NULL OR held_reason NOT LIKE ?)`).run(leadPane, leadActorId, createdPane ? 1 : 0, `${HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX}%`);
491
+ return true;
492
+ })();
493
+ if (!wonRace) {
494
+ if (createdPane) {
495
+ try {
496
+ waitForPaneEstablished(leadPane);
497
+ tmux("kill-pane", "-t", leadPane);
498
+ }
499
+ catch {
500
+ }
501
+ }
502
+ if (newCodexHomeKey)
503
+ reapCodexHome(newCodexHomeKey);
504
+ throw new Error("Another `hive lead` won the race to record a live pane for this project's lead session (both saw the " +
505
+ "same dead pane and both tried to replace it, or this row was closed by another process mid-restart). " +
506
+ "Re-run `hive lead`; it will attach to the pane that invocation recorded.");
507
+ }
508
+ if (createdPane && previousCodexHome && previousCodexHome !== recordedCodexHome) {
509
+ reapCodexHome(previousCodexHome);
510
+ }
511
+ if (!createdPane && newCodexHomeKey && newCodexHomeKey !== previousCodexHome) {
512
+ reapCodexHome(newCodexHomeKey);
513
+ }
514
+ if (createdPane) {
515
+ armLeadPaneExitedHook();
516
+ clearLeftoverProcesses(project, config);
517
+ }
518
+ else if (!leadPaneExitedHookArmed()) {
519
+ console.log(`! this tmux server carries no ${LEAD_PANE_EXITED_HOOK} backstop and hive arms one only on a pane it created, ` +
520
+ "so this project's processes will outlive a crash here. Clear them with: hive stop --all");
521
+ }
522
+ if (config) {
523
+ for (const [name, proc] of Object.entries(config.processes)) {
524
+ if (!proc.auto_start) {
525
+ console.log(`- ${name}: defined, auto_start off (start with: hive start "${name}")`);
526
+ continue;
527
+ }
528
+ if (await ensureTrusted(project.id, name, proc.command, proc.dir, proc.env)) {
529
+ console.log(`- ${name}: ${startYmlCommand(project, name, proc, config)}`);
530
+ }
531
+ }
532
+ }
533
+ if (registrationNotice) {
534
+ console.error(registrationNotice);
535
+ registrationNotice = null;
536
+ }
537
+ if (!noDashboard)
538
+ maybeOpenDashboard(project, !!config?.dashboard);
539
+ attach(session, project, leadWindow);
540
+ }
541
+ catch (e) {
542
+ if (registrationNotice)
543
+ console.error(registrationNotice);
544
+ throw e;
545
+ }
546
+ }
547
+ const HIVE_YML_TEMPLATE = `# hive project config. Read by \`hive lead\` from the project root.
548
+ # Commands defined here run only after a one-time interactive approval,
549
+ # and re-require it whenever they change.
550
+
551
+ placement: split # placement for workers and visible processes: split (panes) or
552
+ # window (tabs)
553
+
554
+ # layout: main-vertical # pane arrangement for placement: split.
555
+ # tiled (default) | main-vertical | main-horizontal
556
+ # | even-horizontal | even-vertical.
557
+ # main-* gives the lead half the window.
558
+
559
+ # lead: claude --model opus # custom command for the lead window (default: claude)
560
+
561
+ # lead_branches: [main, master] # branches where a session gets hive's kickoff
562
+
563
+ # context_checkpoint_percent: null # unset means off; integer 1-100 to enable
564
+
565
+ # review_tags: [from-review] # todo tags \`hive doctor\` counts as review findings and
566
+ # reports as triaged (has a comment, completed, or archived)
567
+ # or untriaged. Absent means doctor tracks none.
568
+
569
+ # vars: # substituted into the profile runbook
570
+ # repo: owner/name # {{repo}}
571
+ # ticket_prefix: DEVX # sections needing it drop when it is unset
572
+ # install: npm install
573
+ # start_command: /jira-start
574
+ # check: npm run lint && npx tsc --noEmit # what a worker runs before
575
+ # reporting done; gates only,
576
+ # no suite (test_all is that)
577
+ # check: ./vendor/bin/pint --test && ./vendor/bin/phpstan # a PHP stack's
578
+ # ci: weekly # absent: CI runs on push/PR. weekly: CI runs
579
+ # on schedule + dispatch only, and the local
580
+ # full suite gates the merge
581
+
582
+ # processes:
583
+ # npm:dev: npm run dev # shorthand; auto-starts with the session
584
+ # typecheck: # expanded form
585
+ # command: npx tsc --watch --preserveWatchOutput
586
+ # dir: ./packages/api # relative to the project root
587
+ # auto_start: false # start manually with: hive start typecheck
588
+ # visible: false # tile it in one <project>/processes window instead of
589
+ # # following placement: above; hive show/hide move it (default true)
590
+ # env:
591
+ # NODE_ENV: development
592
+ `;
593
+ const RUNBOOK_TEMPLATE = `RUNBOOK — standing instructions for this project's lead session.
594
+
595
+ This pad is the durable operating manual. Read it whenever the human opens
596
+ the day ("good morning", "let's triage") or asks you to orchestrate work.
597
+ Live state belongs in the "board" pad and the todos, not here.
598
+
599
+ FIRST RUN (delete this section once done)
600
+ This runbook is a starter template. Before dispatching any real work,
601
+ interview the human and rewrite every section to fit this project. Ask:
602
+ - How does work arrive? Tickets, ad-hoc requests, PR reviews, planning?
603
+ - What are the branch, commit, and PR rules? What may the lead itself do?
604
+ - Where do worktrees live, and what does a fresh one need (installs, env)?
605
+ - How do workers verify their work before a lane is called done?
606
+ - What must never happen without explicit human approval?
607
+ Rewrite the sections below from the answers, keep the whole pad short
608
+ (every read costs tokens), then delete this section.
609
+
610
+ THE ONE RULE
611
+ <the invariant that always holds, e.g. "every commit goes through a PR;
612
+ the lead stays on main and never commits">
613
+
614
+ LANES
615
+ <the kinds of work and how each one runs, e.g.
616
+ - ticket (commits): worktree + ticket branch, one worker, PR at the end
617
+ - ad-hoc task (commits): worktree + slug branch, commit, PR
618
+ - review (no commits): worker drafts the review, the human posts it
619
+ - planning: interview first, plan to a pad, seed todos with blockers>
620
+
621
+ WORKTREES
622
+ <where they live, what setup a fresh one needs, when to remove them>
623
+
624
+ MORNING TRIAGE
625
+ 1. Read the "board" pad, then todo_list(status="open") for the queue.
626
+ 2. <project checks: PR queue, ticket tracker, CI state>
627
+ 3. Update the board, agree the day's lanes with the human, then dispatch
628
+ per help(topic="workflow").
629
+
630
+ BOARD DISCIPLINE
631
+ The "board" pad is the live picture of the work. Update it the moment
632
+ tasks change: a todo is created, re-scoped, blocked, or completed; a
633
+ worker starts or finishes a lane; something lands in "waiting on human".
634
+ A stale board is worse than no board. Keep it small: active work-streams
635
+ and their worktree, todo, and worker ids. At day end, pad_archive the old
636
+ board and write a fresh "board" carrying forward only what is still live.
637
+ History stays readable with pad_list(include_archived=true).
638
+
639
+ STANDING RULES
640
+ - Do not poll workers. With more than one running, set
641
+ wake_when_idle(scope="project") once and go quiet: it reports each worker
642
+ as it finishes and keeps watching. The agents=[...] form is a one-shot and
643
+ stops watching the rest after the first finish.
644
+ - Read real diffs and agent_output before calling a lane done.
645
+ - Record decisions in pads or todo comments; sessions die, the store lives.
646
+ - Anything outward-facing (pushes, published PRs, posted reviews) waits
647
+ for explicit human approval.
648
+ `;
649
+ const BOARD_TEMPLATE = `BOARD — live state for this project. Keep it small and current.
650
+
651
+ Update this pad whenever tasks change: todos created, re-scoped, blocked,
652
+ or completed; lanes started or finished. At day end, archive it and write
653
+ a fresh "board" carrying forward only what is still live.
654
+
655
+ TODAY
656
+ <the day's agreed lanes, one line each: worker, worktree, todo ids, status>
657
+
658
+ WAITING ON HUMAN
659
+ <approvals, answers, or reviews the crew is blocked on>
660
+
661
+ NEXT UP
662
+ <dispatchable todos worth starting when a lane frees>
663
+ `;
664
+ const pluginLinkPath = () => join(claudeConfigDir(), "skills", "hive");
665
+ const displayLinkPath = () => {
666
+ const path = pluginLinkPath();
667
+ const fromHome = join(homedir(), ".claude", "skills", "hive");
668
+ return path === fromHome ? "~/.claude/skills/hive" : path;
669
+ };
670
+ function pluginInstallState() {
671
+ let target;
672
+ try {
673
+ target = realpathSync(pluginLinkPath());
674
+ }
675
+ catch {
676
+ return { state: "missing" };
677
+ }
678
+ let expected;
679
+ try {
680
+ expected = realpathSync(join(checkoutRoot, "claude-plugin"));
681
+ }
682
+ catch {
683
+ return { state: "elsewhere", target };
684
+ }
685
+ return target === expected ? { state: "linked" } : { state: "elsewhere", target };
686
+ }
687
+ const PROFILE_BLURBS = {
688
+ orchestration: "a lead that plans and delegates to workers in tmux",
689
+ simple: "one session doing the work itself; hive is shared memory",
690
+ [NO_PROFILE]: "project-only; hive seeds a runbook pad you fill in",
691
+ };
692
+ async function askForProfile() {
693
+ const options = [...profileNames(), NO_PROFILE];
694
+ console.log(`\nA profile is a set of standing instructions shared across projects.
695
+ It gives the lead its posture and this project a runbook you can edit.`);
696
+ options.forEach((name, i) => console.log(` ${i + 1}) ${name.padEnd(15)} ${PROFILE_BLURBS[name] ?? ""}`));
697
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
698
+ const answer = (await rl.question(`Profile for this project? [1-${options.length}, default 1] `)).trim();
699
+ rl.close();
700
+ if (answer === "")
701
+ return options[0] ?? null;
702
+ const index = Number(answer);
703
+ if (Number.isInteger(index) && index >= 1 && index <= options.length)
704
+ return options[index - 1];
705
+ if (options.includes(answer))
706
+ return answer;
707
+ console.log(`Not one of the options; leaving the profile unset. Re-run hive init to pick one.`);
708
+ return null;
709
+ }
710
+ function writeProfileKey(ymlPath, profile) {
711
+ const existing = readFileSync(ymlPath, "utf8");
712
+ const block = `\n# Standing instructions for this project (hive profile list).\nprofile: ${profile}\n`;
713
+ writeFileSync(ymlPath, existing.endsWith("\n") ? existing + block : `${existing}\n${block}`);
714
+ }
715
+ async function cmdInit(argv) {
716
+ const parsed = parseArgs(argv, { flags: ["--no-profile"], valued: ["--profile"] });
717
+ rejectUnknownFlags("init", parsed, "--profile <name> and --no-profile");
718
+ requireFlagValues("init", parsed);
719
+ const noProfile = parsed.flags.has("--no-profile");
720
+ const path = parsed.positional[0];
721
+ let chosen = noProfile ? NO_PROFILE : null;
722
+ if (parsed.values.has("--profile")) {
723
+ const value = parsed.values.get("--profile");
724
+ if (!value || value.startsWith("--")) {
725
+ console.log("Usage: hive init [path] [--profile <name> | --no-profile]");
726
+ process.exit(1);
727
+ }
728
+ if (value !== NO_PROFILE && !profileExists(value)) {
729
+ console.log(`No profile named "${value}". Available: ${profileNames().join(", ")}, none`);
730
+ process.exit(1);
731
+ }
732
+ chosen = value;
733
+ }
734
+ let project;
735
+ let registration = null;
736
+ let target = null;
737
+ let newlyRegistered = false;
738
+ if (agentProjectPin() != null) {
739
+ project = resolveProject(path);
740
+ }
741
+ else {
742
+ try {
743
+ target = realpathSync(path ?? process.cwd());
744
+ }
745
+ catch {
746
+ throw new Error(`Path does not exist: ${path}`);
747
+ }
748
+ process.chdir(target);
749
+ registration = gitPrimaryRoot(target) ?? target;
750
+ let homePath;
751
+ try {
752
+ homePath = realpathSync(homedir());
753
+ }
754
+ catch {
755
+ homePath = homedir();
756
+ }
757
+ if (registration === homePath || registration === "/") {
758
+ console.log(`hive init: ${registration} is a home directory, not a project; every directory under it would resolve to it. Run hive init inside the project.`);
759
+ process.exit(1);
760
+ }
761
+ const existing = getProjectByPath(registration);
762
+ project = existing ?? addProject(registration);
763
+ newlyRegistered = existing == null;
764
+ }
765
+ console.log(newlyRegistered
766
+ ? `Project: ${project.name} (${project.path}) - registered`
767
+ : `Project: ${project.name} (${project.path})`);
768
+ if (registration != null && target !== registration) {
769
+ console.log(` (the checkout root for ${target})`);
770
+ }
771
+ if (newlyRegistered) {
772
+ const ancestor = registeredAncestor(registration);
773
+ if (ancestor != null) {
774
+ console.log(` note: "${ancestor.name}" (${ancestor.path}) is registered above this directory; hive resolves the deepest registration, so sessions here belong to ${project.name}.`);
775
+ }
776
+ }
777
+ if (path == null && newlyRegistered)
778
+ console.error(registrationNoticeText(project));
779
+ const ymlPath = join(project.path, "hive.yml");
780
+ const already = existsSync(ymlPath) ? loadProjectYml(project.path).config?.profile ?? null : null;
781
+ if (already != null) {
782
+ if (chosen != null && chosen !== already) {
783
+ console.log(`- hive.yml: already set to "profile: ${already}"; edit it by hand to change it`);
784
+ }
785
+ chosen = already;
786
+ }
787
+ else if (chosen == null && process.stdin.isTTY && process.stdout.isTTY) {
788
+ chosen = await askForProfile();
789
+ }
790
+ if (!existsSync(ymlPath)) {
791
+ writeFileSync(ymlPath, HIVE_YML_TEMPLATE + (chosen ? `\nprofile: ${chosen}\n` : ""));
792
+ console.log(`- hive.yml: created${chosen ? ` with profile: ${chosen}` : ""}`);
793
+ }
794
+ else if (already == null && chosen != null) {
795
+ writeProfileKey(ymlPath, chosen);
796
+ console.log(`- hive.yml: added profile: ${chosen}`);
797
+ }
798
+ else {
799
+ console.log(`- hive.yml: already exists${already ? ` (profile: ${already})` : ""}, left untouched`);
800
+ }
801
+ const usingProfile = chosen != null && chosen !== NO_PROFILE;
802
+ if (usingProfile) {
803
+ console.log(`- runbook: from profile "${chosen}" (read it with: hive runbook)`);
804
+ }
805
+ else {
806
+ const padId = createPad(project.id, "runbook", RUNBOOK_TEMPLATE, []);
807
+ console.log(padId == null
808
+ ? "- runbook pad: already exists, left untouched"
809
+ : `- runbook pad: seeded starter template (pad ${padId})`);
810
+ }
811
+ const boardId = createPad(project.id, "board", BOARD_TEMPLATE, []);
812
+ console.log(boardId == null
813
+ ? "- board pad: already exists, left untouched"
814
+ : `- board pad: seeded starter template (pad ${boardId})`);
815
+ console.log("- .hive/: hive writes generated output here (the dashboard); ignore it in .gitignore or your global excludes.");
816
+ console.log(" hive.yml is meant to be committed.");
817
+ const gitignorePath = join(project.path, ".gitignore");
818
+ if (process.stdin.isTTY && process.stdout.isTTY && existsSync(gitignorePath)) {
819
+ const gitignore = readFileSync(gitignorePath, "utf8");
820
+ const hasHiveIgnore = gitignore.split(/\r?\n/).some((line) => [".hive/", ".hive", "/.hive/", "/.hive"].includes(line));
821
+ if (!hasHiveIgnore) {
822
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
823
+ const answer = (await rl.question("Append `.hive/` to .gitignore? [y/N] ")).trim().toLowerCase();
824
+ rl.close();
825
+ if (answer === "y") {
826
+ writeFileSync(gitignorePath, `${gitignore}${gitignore.endsWith("\n") ? "" : "\n"}.hive/\n`);
827
+ console.log("- .gitignore: added .hive/");
828
+ }
829
+ else {
830
+ console.log("- .gitignore: left untouched");
831
+ }
832
+ }
833
+ }
834
+ if (chosen == null) {
835
+ console.log(`\nNo profile set (nothing was asked, since this is not an interactive terminal).
836
+ Pick one later with: hive init --profile <name> (hive profile list shows them)
837
+ or decide against it with: hive init --no-profile`);
838
+ }
839
+ if (usingProfile) {
840
+ const plugin = pluginInstallState();
841
+ if (plugin.state === "linked") {
842
+ console.log(`\nSession-start kickoff: already installed for this machine, nothing to do.
843
+ Check what a session here would get with: hive kickoff --explain`);
844
+ }
845
+ else if (plugin.state === "elsewhere") {
846
+ console.log(`\n! ${displayLinkPath()} resolves to ${plugin.target}, not this checkout.
847
+ Sessions here run THAT hive's kickoff. Repoint it if this checkout is the one you use:
848
+ rm ${displayLinkPath()} && ln -s ${join(checkoutRoot, "claude-plugin")} ${displayLinkPath()}`);
849
+ }
850
+ else {
851
+ console.log(`\nOptional, once per machine (not per project): let a session in this project
852
+ start with hive's live state already loaded.
853
+ ln -s ${join(checkoutRoot, "claude-plugin")} ${displayLinkPath()}`);
854
+ }
855
+ console.log(`\nNext: run \`hive\` here. The lead starts with the "${chosen}" posture in its
856
+ system prompt; \`hive runbook\` is the process it follows. Fork the runbook to
857
+ make it yours: hive profile fork ${chosen} runbook.md`);
858
+ }
859
+ else {
860
+ console.log(`\nNext: run \`hive\` here and tell the lead "good morning, let's triage".
861
+ The runbook's first-run section has the lead interview you and tailor
862
+ itself to this project before any real work runs.`);
863
+ }
864
+ }
865
+ const NO_RUNBOOK_PAD_MESSAGE = `is on "profile: none" but has no runbook pad. Create one with: hive init`;
866
+ function cmdRunbook(path) {
867
+ const project = resolveProject(path);
868
+ const { config, warnings } = loadProjectYml(project.path);
869
+ for (const w of warnings)
870
+ console.log(`! ${w}`);
871
+ const profile = activeProfile(config);
872
+ if (!profile) {
873
+ const pad = getActivePadByName(project.id, "runbook");
874
+ if (pad) {
875
+ process.stdout.write(withTrailingNewline(pad.content));
876
+ return;
877
+ }
878
+ console.log(config?.profile === NO_PROFILE
879
+ ? `This project ${NO_RUNBOOK_PAD_MESSAGE}`
880
+ : `This project has no profile. Add "profile: <name>" to hive.yml (hive profile list) or run hive init.`);
881
+ process.exit(1);
882
+ }
883
+ const vars = mergedProjectVars(config);
884
+ const rendered = renderProfileFile(profile, "runbook.md", vars);
885
+ if (rendered == null) {
886
+ console.log(`Profile "${profile}" has no runbook.md on this machine. Profiles hive can see: ${profileNames().join(", ") || "none"}`);
887
+ process.exit(1);
888
+ }
889
+ process.stdout.write(withTrailingNewline(rendered));
890
+ }
891
+ function cmdPosture(path) {
892
+ const project = resolveProject(path);
893
+ const { config, warnings } = loadProjectYml(project.path);
894
+ for (const w of warnings)
895
+ console.log(`! ${w}`);
896
+ const profile = activeProfile(config);
897
+ if (!profile) {
898
+ console.log(`This project has no profile, so its lead starts with no posture. Add "profile: <name>" to hive.yml (hive profile list) or run hive init.`);
899
+ process.exit(1);
900
+ }
901
+ const vars = mergedProjectVars(config);
902
+ const rendered = renderProfileFile(profile, "posture.md", vars);
903
+ if (rendered == null) {
904
+ console.log(`Profile "${profile}" has no posture.md on this machine, so the lead starts without one.`);
905
+ process.exit(1);
906
+ }
907
+ process.stdout.write(withTrailingNewline(rendered));
908
+ }
909
+ function profileUsage() {
910
+ console.log(`Usage:
911
+ hive profile list profiles hive can see, and where each file comes from
912
+ hive profile path <name> [file] where a profile's files resolve to
913
+ hive profile fork <name> [file] copy hive's default into ${userProfilesDir()} to edit
914
+ hive profile create <name> [--from <other>] start a new profile
915
+ hive profile read <file> [--profile <name>] resolve, render vars, print any .md a profile has;
916
+ defaults to this project's profile and vars
917
+
918
+ Files in a profile: ${PROFILE_FILES.join(", ")}, plus anything else a fork carries (see hive profile list)`);
919
+ process.exit(1);
920
+ }
921
+ function asProfileFile(value) {
922
+ if (value == null)
923
+ return undefined;
924
+ if (!PROFILE_FILES.includes(value)) {
925
+ console.log(`Unknown profile file "${value}". Files are: ${PROFILE_FILES.join(", ")}`);
926
+ process.exit(1);
927
+ }
928
+ return value;
929
+ }
930
+ function profileDriftText(f) {
931
+ if (!f.upstreamMoved)
932
+ return null;
933
+ const pct = f.divergence != null ? Math.round(f.divergence * 100) : null;
934
+ if (f.divergence != null && f.divergence >= REWRITE_THRESHOLD) {
935
+ return { rewrite: true, text: `${f.file} is a ${pct}% rewrite of hive's default, not an edited copy of it` };
936
+ }
937
+ return {
938
+ rewrite: false,
939
+ text: `hive's default ${f.file} changed since you forked it${pct != null ? ` (${pct}% diverged)` : ""}`,
940
+ };
941
+ }
942
+ function cmdProfile(argv) {
943
+ const [sub, ...rest] = argv;
944
+ try {
945
+ switch (sub) {
946
+ case undefined:
947
+ case "list": {
948
+ const parsed = parseArgs(rest, {});
949
+ rejectUnknownFlags("profile list", parsed, "none");
950
+ const names = profileNames();
951
+ if (names.length === 0) {
952
+ console.log("No profiles found. hive ships orchestration and simple; check your install.");
953
+ return;
954
+ }
955
+ const here = findProjectForCwd();
956
+ const current = here ? activeProfile(loadProjectYml(here.path).config) : null;
957
+ const statuses = names.map((profile) => profileStatus(profile));
958
+ const fileWidth = Math.max(11, ...statuses.flatMap((s) => s.files.map((f) => f.file.length)));
959
+ for (const [i, profile] of names.entries()) {
960
+ const status = statuses[i];
961
+ console.log(`${profile === current ? "*" : " "} ${profile}`);
962
+ for (const f of status.files) {
963
+ const drift = profileDriftText(f);
964
+ console.log(` ${f.file.padEnd(fileWidth)} ${f.source.padEnd(7)} ${f.path}${drift ? ` (${drift.text})` : ""}`);
965
+ }
966
+ }
967
+ if (current)
968
+ console.log(`\n* is this project's profile.`);
969
+ return;
970
+ }
971
+ case "path": {
972
+ const parsed = parseArgs(rest, {});
973
+ rejectUnknownFlags("profile path", parsed, "none");
974
+ const name = parsed.positional[0];
975
+ if (!name)
976
+ profileUsage();
977
+ if (!profileExists(name)) {
978
+ console.log(`No profile named "${name}". List them with: hive profile list`);
979
+ process.exit(1);
980
+ }
981
+ const only = asProfileFile(parsed.positional[1]);
982
+ for (const file of only ? [only] : PROFILE_FILES) {
983
+ const resolved = resolveProfileFile(name, file);
984
+ if (resolved)
985
+ console.log(resolved.path);
986
+ }
987
+ return;
988
+ }
989
+ case "fork": {
990
+ const parsed = parseArgs(rest, {});
991
+ rejectUnknownFlags("profile fork", parsed, "none");
992
+ const name = parsed.positional[0];
993
+ if (!name)
994
+ profileUsage();
995
+ const { copied, skipped } = forkProfile(name, asProfileFile(parsed.positional[1]));
996
+ for (const file of copied)
997
+ console.log(`forked ${file} -> ${join(userProfilesDir(), name, file)}`);
998
+ for (const file of skipped)
999
+ console.log(`kept your ${file} (already forked)`);
1000
+ if (copied.length === 0 && skipped.length === 0)
1001
+ console.log(`Profile "${name}" ships no files to fork.`);
1002
+ return;
1003
+ }
1004
+ case "create": {
1005
+ const parsed = parseArgs(rest, { valued: ["--from"] });
1006
+ rejectUnknownFlags("profile create", parsed, "--from <other>");
1007
+ requireFlagValues("profile create", parsed);
1008
+ const name = parsed.positional[0];
1009
+ if (!name)
1010
+ profileUsage();
1011
+ const from = parsed.values.get("--from");
1012
+ const dir = createProfile(name, from);
1013
+ console.log(`Created ${dir}`);
1014
+ console.log(`Use it with "profile: ${name}" in a project's hive.yml.`);
1015
+ return;
1016
+ }
1017
+ case "read": {
1018
+ const parsed = parseArgs(rest, { valued: ["--profile"] });
1019
+ rejectUnknownFlags("profile read", parsed, "--profile <name>");
1020
+ requireFlagValues("profile read", parsed);
1021
+ const override = parsed.values.get("--profile");
1022
+ const file = parsed.positional[0];
1023
+ if (!file)
1024
+ profileUsage();
1025
+ let profileName;
1026
+ let vars;
1027
+ if (override != null) {
1028
+ profileName = override;
1029
+ const here = findProjectForCwd();
1030
+ vars = mergedProjectVars(here ? loadProjectYml(here.path).config : null);
1031
+ }
1032
+ else {
1033
+ const project = resolveProject();
1034
+ const { config, warnings } = loadProjectYml(project.path);
1035
+ for (const w of warnings)
1036
+ console.log(`! ${w}`);
1037
+ const active = activeProfile(config);
1038
+ if (!active) {
1039
+ console.log(`This project has no profile. Add "profile: <name>" to hive.yml (hive profile list) or run hive init.`);
1040
+ process.exit(1);
1041
+ }
1042
+ profileName = active;
1043
+ vars = mergedProjectVars(config);
1044
+ }
1045
+ const rendered = renderProfileFile(profileName, file, vars);
1046
+ if (rendered == null) {
1047
+ const present = profileFileNames(profileName);
1048
+ console.log(`No readable "${file}" for profile "${profileName}". Files present: ${present.length > 0 ? present.join(", ") : "none"}`);
1049
+ process.exit(1);
1050
+ }
1051
+ process.stdout.write(withTrailingNewline(rendered));
1052
+ return;
1053
+ }
1054
+ default:
1055
+ profileUsage();
1056
+ }
1057
+ }
1058
+ catch (e) {
1059
+ if (e instanceof ProfileError) {
1060
+ console.log(errorMessage(e));
1061
+ process.exit(1);
1062
+ }
1063
+ throw e;
1064
+ }
1065
+ }
1066
+ const DASHBOARD_OPENED_KV_KEY = "hive:dashboard_opened";
1067
+ const DASHBOARD_OPENED_TTL_SECONDS = 8 * 60 * 60;
1068
+ function maybeOpenDashboard(project, dashboardEnabled) {
1069
+ if (process.platform !== "darwin")
1070
+ return;
1071
+ if (!dashboardEnabled)
1072
+ return;
1073
+ const dashboardFile = resolveDashboardFile(project.path);
1074
+ if (dashboardFile === null)
1075
+ return;
1076
+ if (!existsSync(dashboardFile))
1077
+ return;
1078
+ if (!dashboardFileContained(dashboardFile, project.path))
1079
+ return;
1080
+ const actor = currentActor();
1081
+ const claim = db
1082
+ .prepare(`INSERT INTO kv (project_id, key, value, updated_by, expires_at)
1083
+ VALUES (?, ?, ?, ?, datetime('now', printf('+%d seconds', ?)))
1084
+ ON CONFLICT(project_id, key) DO UPDATE SET
1085
+ value = excluded.value, updated_by = excluded.updated_by,
1086
+ updated_at = datetime('now'), expires_at = excluded.expires_at
1087
+ WHERE kv.expires_at IS NOT NULL AND kv.expires_at < datetime('now')`)
1088
+ .run(project.id, DASHBOARD_OPENED_KV_KEY, JSON.stringify(true), actor, DASHBOARD_OPENED_TTL_SECONDS);
1089
+ if (claim.changes === 0)
1090
+ return;
1091
+ try {
1092
+ execFileSync("open", [pathToFileURL(dashboardFile).href], { stdio: "ignore", timeout: 5000 });
1093
+ }
1094
+ catch {
1095
+ db.prepare("DELETE FROM kv WHERE project_id = ? AND key = ?").run(project.id, DASHBOARD_OPENED_KV_KEY);
1096
+ }
1097
+ }
1098
+ function cmdAttach(argv) {
1099
+ const unknownFlag = argv.find((a) => a.startsWith("--"));
1100
+ if (unknownFlag !== undefined) {
1101
+ console.error(`hive attach: unknown flag "${unknownFlag}". hive attach takes no flags.`);
1102
+ process.exit(1);
1103
+ }
1104
+ const path = argv.find((a) => !a.startsWith("--"));
1105
+ const project = resolveProject(path);
1106
+ maybeOpenDashboard(project, !!loadProjectYml(project.path).config?.dashboard);
1107
+ const session = sessionName();
1108
+ const window = withWindowClaim(() => {
1109
+ const started = ensureSession(session, project.path, { bare: true });
1110
+ if (started.created) {
1111
+ configureHiveWindow(started.window, true, project.id);
1112
+ tmux("rename-window", "-t", started.window, project.name);
1113
+ return started.window;
1114
+ }
1115
+ const found = findProjectWindow(session, project.id);
1116
+ if (found)
1117
+ return found;
1118
+ const created = tmux("new-window", "-P", "-F", "#{session_name}:#{window_id}", "-t", `=${session}`, "-n", project.name, "-c", project.path);
1119
+ configureHiveWindow(created, true, project.id);
1120
+ return created;
1121
+ });
1122
+ attach(session, project, window);
1123
+ }
1124
+ async function cmdStart(argv) {
1125
+ const parsed = parseArgs(argv, {});
1126
+ rejectUnknownFlags("start", parsed, "none");
1127
+ const name = parsed.positional[0];
1128
+ const path = parsed.positional[1];
1129
+ if (!name) {
1130
+ console.log("Usage: hive start <process> [path]");
1131
+ process.exit(1);
1132
+ }
1133
+ const project = resolveProject(path);
1134
+ const { config, warnings } = loadProjectYml(project.path);
1135
+ for (const w of warnings)
1136
+ console.log(`! ${w}`);
1137
+ const proc = config?.processes[name];
1138
+ if (!proc) {
1139
+ console.log(processNotDefined(config, name));
1140
+ process.exit(1);
1141
+ }
1142
+ if (await ensureTrusted(project.id, name, proc.command, proc.dir, proc.env)) {
1143
+ console.log(`${name}: ${startYmlCommand(project, name, proc, config)}`);
1144
+ }
1145
+ }
1146
+ function processNotDefined(config, name) {
1147
+ const known = Object.keys(config?.processes ?? {});
1148
+ return known.length > 0
1149
+ ? `No process "${name}" in hive.yml. Defined: ${known.join(", ")}`
1150
+ : "This project has no hive.yml processes.";
1151
+ }
1152
+ const CANNOT_TELL_WHERE = (name) => `${name}: tmux could not be probed, so hive cannot tell where its pane is`;
1153
+ function resolveNamedProcess(argv, verb) {
1154
+ const parsed = parseArgs(argv, {});
1155
+ rejectUnknownFlags(verb, parsed, "none");
1156
+ const name = parsed.positional[0];
1157
+ if (!name) {
1158
+ console.log(`Usage: hive ${verb} <process> [path]`);
1159
+ process.exit(1);
1160
+ }
1161
+ const project = resolveProject(parsed.positional[1]);
1162
+ const { config, warnings } = loadProjectYml(project.path);
1163
+ for (const w of warnings)
1164
+ console.log(`! ${w}`);
1165
+ if (!config?.processes[name]) {
1166
+ console.log(processNotDefined(config, name));
1167
+ process.exit(1);
1168
+ }
1169
+ const row = db
1170
+ .prepare("SELECT tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND name = ? AND kind = 'command' AND status = 'running'")
1171
+ .get(project.id, name);
1172
+ if (!row) {
1173
+ console.log(`${name}: not running (start with: hive start "${name}")`);
1174
+ return null;
1175
+ }
1176
+ const probe = rowLiveProbe(row.tmux_socket, row.tmux_target);
1177
+ if (probe.live === null) {
1178
+ console.log(CANNOT_TELL_WHERE(name));
1179
+ return null;
1180
+ }
1181
+ // A pane id alone is not an identity: tmux restarts them at %0 on a fresh server, so a stale
1182
+ // running row names whoever holds that id now. Same compare as janitor() and wake delivery.
1183
+ if (!probe.live || paneReissued(row.pane_pid, probe)) {
1184
+ console.log(`${name}: not running (start with: hive start "${name}")`);
1185
+ return null;
1186
+ }
1187
+ return { project, name, config, target: row.tmux_target };
1188
+ }
1189
+ const LEAD_PANE_EXITED_HOOK = "pane-exited";
1190
+ const LEAD_PANE_EXITED_VERB = "lead-pane-exited";
1191
+ // tmux runs a hook with the SERVER's environment and no login shell, so a bare `hive` resolves to
1192
+ // nothing (the iTerm rule in .claude/rules/tmux-and-panes.md) and HIVE_DATA_DIR is not there at all
1193
+ // - without it the backstop would open the DEFAULT store and find no project. Absolute interpreter,
1194
+ // absolute script, and this lead's own store, all resolved at arm time.
1195
+ function leadPaneExitedHookCommand() {
1196
+ const argv = [process.execPath, cliPath(), LEAD_PANE_EXITED_VERB];
1197
+ // The identity vars are cleared, not carried: this runs on behalf of nobody, and a tmux server
1198
+ // started from inside a worker's pane hands them to every run-shell it ever runs, where a stale
1199
+ // HIVE_AGENT_ID is a hard refusal before the verb is reached at all. The same clearing
1200
+ // scripts/restart-lead.sh does around its own `hive lead`, plus the two project vars.
1201
+ const clear = ["HIVE_AGENT_ID", "HIVE_AGENT_NAME", "HIVE_LEAD", "HIVE_PROJECT_LOCK", "HIVE_PROJECT_PATH"]
1202
+ .flatMap((name) => ["-u", name]);
1203
+ // Redirected because tmux prints a run-shell's output into the pane it fires from, which is a
1204
+ // human's own window: a backstop that failed to start must not type into it.
1205
+ const command = `env ${clear.join(" ")} HIVE_DATA_DIR=${shellQuote(dataDir)} ${argv
1206
+ .map(shellQuote)
1207
+ .join(" ")} "#{hook_pane}" >/dev/null 2>&1`;
1208
+ return `run-shell ${shellQuote(command)}`;
1209
+ }
1210
+ // tmux re-quotes an option value on read-back, so the string hive armed is never the string it gets
1211
+ // back and an equality test can only ever be false. Both halves of this predicate survive requoting.
1212
+ const ourHookEntry = (entry) => entry.includes(LEAD_PANE_EXITED_VERB) && entry.includes(dataDir);
1213
+ // A worktree torn down leaves its own entry behind forever, and nothing else would ever remove it.
1214
+ // An entry whose script path cannot be extracted is KEPT: a path with a space in it does not match,
1215
+ // and dropping an entry hive cannot read is worse than leaving a dead one.
1216
+ function hookScriptGone(entry) {
1217
+ const script = entry.match(/([^\s'"]+cli\.js)\s/);
1218
+ return script !== null && !existsSync(script[1]);
1219
+ }
1220
+ // Under the store's own write lock: two `hive lead` runs on one store would otherwise both read the
1221
+ // list, both append, and leave a duplicate that fires an extra node process on every pane exit.
1222
+ function armLeadPaneExitedHook() {
1223
+ withWindowClaim(() => {
1224
+ const entries = globalHooks(LEAD_PANE_EXITED_HOOK);
1225
+ const dead = entries.filter((e) => ourHookEntry(e) && hookScriptGone(e));
1226
+ const armed = entries.some((e) => ourHookEntry(e) && !hookScriptGone(e));
1227
+ if (armed && dead.length === 0)
1228
+ return;
1229
+ const kept = entries.filter((e) => !dead.includes(e));
1230
+ if (armed)
1231
+ replaceGlobalHooks(LEAD_PANE_EXITED_HOOK, kept);
1232
+ else if (dead.length === 0)
1233
+ appendGlobalHook(LEAD_PANE_EXITED_HOOK, leadPaneExitedHookCommand());
1234
+ else
1235
+ replaceGlobalHooks(LEAD_PANE_EXITED_HOOK, [...kept, leadPaneExitedHookCommand()]);
1236
+ });
1237
+ }
1238
+ const leadPaneExitedHookArmed = () => globalHooks(LEAD_PANE_EXITED_HOOK).some((e) => ourHookEntry(e) && !hookScriptGone(e));
1239
+ // Runs from tmux with nobody reading it, so it prints nothing and never throws. Every pane exit in
1240
+ // the session reaches this - workers, processes, a human's own shell - and the only thing that makes
1241
+ // one of them act is the store saying that pane is a project's running lead. That lookup is also
1242
+ // what identifies the project, so the hook itself carries no project and one hook serves them all.
1243
+ function cmdLeadPaneExited(argv) {
1244
+ try {
1245
+ const parsed = parseArgs(argv, {});
1246
+ if (parsed.unknown.length > 0)
1247
+ return;
1248
+ const pane = parsed.positional[0];
1249
+ if (!pane)
1250
+ return;
1251
+ // Pane id alone, with no socket compare: a global hook only ever runs on the server it was
1252
+ // armed on, so a pane id reaching here cannot be another server's.
1253
+ const lead = db
1254
+ .prepare("SELECT project_id FROM agents WHERE kind = ? AND status = 'running' AND tmux_target = ?")
1255
+ .get(LEAD_KIND, pane);
1256
+ if (!lead)
1257
+ return;
1258
+ stopAllProcesses(lead.project_id, STOP_REASONS.leadPaneExited);
1259
+ }
1260
+ catch {
1261
+ }
1262
+ }
1263
+ // Only a lead that CREATED its pane clears leftovers. An adopted live lead is the same lead those
1264
+ // processes belong to, and stopping them there would take down a running crew's dev server.
1265
+ function clearLeftoverProcesses(project, config) {
1266
+ for (const row of runningCommandRows(project.id)) {
1267
+ const stopped = stopProcess(row, STOP_REASONS.previousLead);
1268
+ // Nothing was running under it, so the auto-start line that follows is the whole story.
1269
+ if (stopped.leg === "already-gone")
1270
+ continue;
1271
+ const why = stopped.leg === "unreachable"
1272
+ ? ""
1273
+ : `; ${STOP_REASONS.previousLead}${config?.processes[row.name] ? "" : ", and it is no longer defined in hive.yml"}`;
1274
+ console.log(`- ${stopLine(stopped)}${why}`);
1275
+ }
1276
+ }
1277
+ // Row first, hive.yml second, unlike show and hide: a process whose definition has since been
1278
+ // deleted from hive.yml is exactly the leftover a human most needs to stop.
1279
+ function cmdStop(argv) {
1280
+ const parsed = parseArgs(argv, { flags: ["--all"] });
1281
+ rejectUnknownFlags("stop", parsed, "--all");
1282
+ if (parsed.flags.has("--all")) {
1283
+ const project = resolveProject(parsed.positional[0]);
1284
+ const stopped = stopAllProcesses(project.id, STOP_REASONS.byHand);
1285
+ console.log(stopped.length === 0 ? "No processes are running." : stopped.map(stopLine).join("\n"));
1286
+ return;
1287
+ }
1288
+ const name = parsed.positional[0];
1289
+ if (!name) {
1290
+ console.log("Usage: hive stop <process> [path] | hive stop --all [path]");
1291
+ process.exit(1);
1292
+ }
1293
+ const project = resolveProject(parsed.positional[1]);
1294
+ const row = runningCommandRow(project.id, name);
1295
+ if (!row) {
1296
+ const { config, warnings } = loadProjectYml(project.path);
1297
+ for (const w of warnings)
1298
+ console.log(`! ${w}`);
1299
+ if (!config?.processes[name]) {
1300
+ console.log(processNotDefined(config, name));
1301
+ process.exit(1);
1302
+ }
1303
+ console.log(`${name}: not running (start with: hive start "${name}")`);
1304
+ return;
1305
+ }
1306
+ console.log(stopLine(stopProcess(row, STOP_REASONS.byHand)));
1307
+ }
1308
+ function cmdShow(argv) {
1309
+ const found = resolveNamedProcess(argv, "show");
1310
+ if (!found)
1311
+ return;
1312
+ const { project, name, config, target } = found;
1313
+ const session = sessionName();
1314
+ // Read the window inside the claim, never before it: tmux destroys a window when its last pane
1315
+ // leaves, so an id resolved outside the lock names a window a concurrent show or hide may have
1316
+ // already taken down, and join-pane then fails with tmux's own "can't find window".
1317
+ console.log(withWindowClaim(() => {
1318
+ const windows = projectWindows(session, project.id);
1319
+ if (windows === null)
1320
+ return CANNOT_TELL_WHERE(name);
1321
+ if (!windows.project)
1322
+ return `${name}: this project has no window to show it beside yet; run hive first`;
1323
+ if (paneVisibility(target, windows) === "shown")
1324
+ return `${name}: already shown`;
1325
+ tmux("join-pane", "-h", "-d", "-s", target, "-t", windows.project);
1326
+ applyLayout(windows.project, windowLayout(windows.project) ?? config.layout ?? DEFAULT_LAYOUT);
1327
+ setPaneTitle(target, shownPaneTitle(project.name, name));
1328
+ return `${name}: shown`;
1329
+ }));
1330
+ }
1331
+ function cmdHide(argv) {
1332
+ const found = resolveNamedProcess(argv, "hide");
1333
+ if (!found)
1334
+ return;
1335
+ const { project, name, target } = found;
1336
+ const session = sessionName();
1337
+ // One claim over the whole decision: the group window comes and goes with its last pane, so both
1338
+ // the read that finds it and the read that finds it missing have to hold the lock the creating
1339
+ // branch takes, or two callers still each create one and a join still races a destroy.
1340
+ console.log(withWindowClaim(() => {
1341
+ const windows = projectWindows(session, project.id);
1342
+ if (windows === null)
1343
+ return CANNOT_TELL_WHERE(name);
1344
+ const where = paneVisibility(target, windows);
1345
+ if (where === "hidden")
1346
+ return `${name}: already hidden`;
1347
+ if (windows.processes) {
1348
+ tmux("join-pane", "-d", "-s", target, "-t", windows.processes);
1349
+ applyLayout(windows.processes, PROCESSES_LAYOUT);
1350
+ }
1351
+ else if (where === "shown" && paneIsAloneInWindow(target) !== false) {
1352
+ // break-pane on a lone pane renames its window in place and returns that same window, so
1353
+ // this would stamp @hive-processes-of onto the window already carrying @hive-project-id.
1354
+ return (`${name}: it is the only pane in this project's window, so there is nothing to hide it behind; ` +
1355
+ "start the lead (hive) first, then hide it");
1356
+ }
1357
+ else {
1358
+ const created = tmux("break-pane", "-d", "-P", "-F", "#{session_name}:#{window_id}", "-s", target, "-n", processesWindowName(project.name));
1359
+ makeProcessesWindow(created, project.id);
1360
+ }
1361
+ setPaneTitle(target, processesPaneTitle(project.name, name));
1362
+ return `${name}: hidden`;
1363
+ }));
1364
+ }
1365
+ function cmdStatus() {
1366
+ janitor();
1367
+ let anyOutput = false;
1368
+ let windows;
1369
+ let windowsError;
1370
+ const ownedWindows = () => {
1371
+ if (windows === undefined) {
1372
+ try {
1373
+ windows = listOwnedWindows(sessionName());
1374
+ }
1375
+ catch (e) {
1376
+ windows = null;
1377
+ windowsError = e;
1378
+ }
1379
+ }
1380
+ return windows;
1381
+ };
1382
+ for (const project of listProjects()) {
1383
+ const agents = db
1384
+ .prepare("SELECT * FROM agents WHERE project_id = ? AND status = 'running' ORDER BY kind DESC, id")
1385
+ .all(project.id);
1386
+ const todos = db
1387
+ .prepare("SELECT COUNT(*) AS n FROM todos WHERE project_id = ? AND status IN ('open', 'in_progress') AND archived_at IS NULL")
1388
+ .get(project.id).n;
1389
+ const { wakes, heldWakes } = db
1390
+ .prepare(`SELECT COUNT(*) AS wakes, COUNT(held_at) AS heldWakes FROM wakes WHERE project_id = ? AND ${ACTIVE_TIMER_WHERE}`)
1391
+ .get(project.id);
1392
+ const parked = db
1393
+ .prepare("SELECT id, name, parked_at, parked_branch, cwd FROM agents " +
1394
+ "WHERE project_id = ? AND status = 'closed' AND parked_at != '' ORDER BY parked_at, id")
1395
+ .all(project.id);
1396
+ if (agents.length === 0 && todos === 0 && wakes === 0 && parked.length === 0)
1397
+ continue;
1398
+ anyOutput = true;
1399
+ let windowLabel;
1400
+ const fetched = ownedWindows();
1401
+ if (fetched) {
1402
+ windowLabel = fetched.find((w) => Number(w.projectId) === project.id)?.window ?? "none yet";
1403
+ }
1404
+ else {
1405
+ windowLabel = tmuxSaysNothingThere(windowsError) ? "none yet" : "unknown (tmux unreachable)";
1406
+ }
1407
+ console.log(`\n${project.name} (${project.path}) window: ${windowLabel}`);
1408
+ const procs = agents.some((a) => a.kind === "command") ? snapshotProcesses(project.id) : [];
1409
+ const whereIs = new Map(procs.filter((p) => p.running).map((p) => [p.name, describeVisibility(p.visibility)]));
1410
+ for (const a of agents) {
1411
+ const state = a.kind === "agent"
1412
+ ? describeForHuman(deriveProvenance(a, null))
1413
+ : (whereIs.get(a.name) ?? "running");
1414
+ const label = a.kind === "command" ? "cmd " : a.kind === LEAD_KIND ? "lead " : "agent";
1415
+ console.log(` ${label} ${a.name.padEnd(20)} ${state}`);
1416
+ if (reportsAgentStateLog(a)) {
1417
+ console.log(` last log event: ${describeLastLogEvent(lastLogEvent(a.actor_id))}`);
1418
+ }
1419
+ }
1420
+ if (agents.length === 0)
1421
+ console.log(" no running agents or commands");
1422
+ for (const p of parked) {
1423
+ console.log(` parked ${p.name.padEnd(20)} ${p.parked_at} branch ${p.parked_branch || "(unrecorded)"}`);
1424
+ console.log(` ${p.cwd}`);
1425
+ console.log(` resume: agent_resume(agent_id: ${p.id})`);
1426
+ }
1427
+ console.log(` open todos: ${todos} pending wake-ups: ${wakes}${heldWakes > 0 ? ` (${heldWakes} held)` : ""}`);
1428
+ }
1429
+ if (!anyOutput)
1430
+ console.log("Nothing running and no open work in any project.");
1431
+ }
1432
+ const report = (level, label, lines) => {
1433
+ console.log(` ${level} ${label}: ${lines[0]}`);
1434
+ for (const line of lines.slice(1))
1435
+ console.log(` ${line}`);
1436
+ };
1437
+ const info = (label, ...lines) => report("info", label, lines);
1438
+ let warnings = 0;
1439
+ let gatingWarnings = 0;
1440
+ const warn = (label, ...lines) => {
1441
+ warnings += 1;
1442
+ report("warn", label, lines);
1443
+ };
1444
+ const gatingWarn = (label, ...lines) => {
1445
+ gatingWarnings += 1;
1446
+ warn(label, ...lines);
1447
+ };
1448
+ let doctorVerbose = false;
1449
+ const verboseInfo = (_check, label, ...lines) => {
1450
+ if (doctorVerbose)
1451
+ report("info", label, lines);
1452
+ };
1453
+ const isReviewFindingTag = (bases, tag) => bases.some((base) => tag === base || tag.startsWith(`${base}-`));
1454
+ function cmdSetup(argv) {
1455
+ const parsed = parseArgs(argv, { flags: ["--force"], valued: ["--dir", "--attach", "--auto-attach"] });
1456
+ rejectUnknownFlags("setup", parsed, "--dir, --attach, --auto-attach, --force");
1457
+ requireFlagValues("setup", parsed);
1458
+ const dir = parsed.values.has("--dir") ? resolve(parsed.values.get("--dir") ?? "") : dispatcherDir();
1459
+ const file = join(dir, "hive");
1460
+ const node = process.execPath;
1461
+ const cli = cliPath();
1462
+ const attachValue = parsed.values.get("--attach");
1463
+ let attachArg;
1464
+ if (parsed.values.has("--attach")) {
1465
+ if (!isAttachMode(attachValue)) {
1466
+ console.log(`--attach must be one of: ${ATTACH_MODES.join(", ")} (got ${attachValue ?? "nothing"})`);
1467
+ process.exit(1);
1468
+ }
1469
+ attachArg = attachValue;
1470
+ }
1471
+ const autoAttachValue = parsed.values.get("--auto-attach");
1472
+ let autoAttachArg;
1473
+ if (parsed.values.has("--auto-attach")) {
1474
+ if (!isAutoAttach(autoAttachValue)) {
1475
+ console.log(`--auto-attach must be one of: ${AUTO_ATTACH_MODES.join(", ")} (got ${autoAttachValue ?? "nothing"})`);
1476
+ process.exit(1);
1477
+ }
1478
+ autoAttachArg = autoAttachValue;
1479
+ }
1480
+ const existing = readDispatcher(file);
1481
+ if (existing && !existing.mine && !parsed.flags.has("--force")) {
1482
+ console.log(`${file} exists and was not written by hive setup; refusing to overwrite it.`);
1483
+ console.log("Move it aside, pick another directory with --dir, or overwrite it with --force.");
1484
+ process.exit(1);
1485
+ }
1486
+ const worktreePin = linkedWorktreePin(cli);
1487
+ if (worktreePin.linked && !parsed.flags.has("--force")) {
1488
+ const repairCli = worktreePin.durableRoot
1489
+ ? join(worktreePin.durableRoot, "dist", "cli.js")
1490
+ : "<durable checkout>/dist/cli.js";
1491
+ console.log(`${cli} is inside a linked git worktree; refusing to pin \`hive\` to it without --force.`);
1492
+ console.log("Worktrees in this repo are disposable, so a pin into one becomes a broken shim - failing");
1493
+ console.log("with MODULE_NOT_FOUND - the moment it is torn down, and the ordinary repair (`hive setup`");
1494
+ console.log("run through that same shim) just re-pins the same worktree instead of fixing it.");
1495
+ console.log(`Repair from the durable checkout instead: "${process.execPath}" "${repairCli}" setup`);
1496
+ console.log("Or pin this worktree anyway: hive setup --force");
1497
+ process.exit(1);
1498
+ }
1499
+ mkdirSync(dir, { recursive: true });
1500
+ writeFileSync(file, dispatcherScript(node, cli));
1501
+ chmodSync(file, 0o755);
1502
+ const rewritten = existing?.mine && (existing.node !== node || existing.cli !== cli);
1503
+ console.log(`${existing?.mine ? (rewritten ? "Re-pinned" : "Refreshed") : "Wrote"} ${file}`);
1504
+ if (rewritten)
1505
+ console.log(` was ${existing.node ?? "an unrecognized exec line"} ${existing.cli ?? ""}`.trimEnd());
1506
+ console.log(` interpreter ${node}`);
1507
+ console.log(` ${process.version}, NODE_MODULE_VERSION ${process.versions.modules}`);
1508
+ console.log(` runs ${cli}`);
1509
+ const need = requiredNodeApi();
1510
+ const range = need === null ? null : nodeRangeForNodeApi(need);
1511
+ console.log("\n`hive` now runs under that interpreter from any directory, whatever `node` a");
1512
+ console.log("version manager resolves there. better-sqlite3's addon needs a Node providing");
1513
+ console.log(range
1514
+ ? `Node-API ${need} (${range}); below that it does not fail, it dies inside dlopen.`
1515
+ : `Node-API ${need ?? "the level its binding.gyp names"}; below that it does not fail, it dies inside dlopen.`);
1516
+ if (attachArg)
1517
+ setAttachMode(attachArg);
1518
+ if (autoAttachArg)
1519
+ setAutoAttach(autoAttachArg);
1520
+ console.log(`\nattach mode ${attachMode()}`);
1521
+ console.log(`auto-attach ${resolvedAutoAttach().value}`);
1522
+ if (attachArg === "raw") {
1523
+ console.log("\nRecommended ~/.tmux.conf setting for raw attach mode:");
1524
+ for (const line of RAW_ATTACH_TMUX_CONFIG)
1525
+ console.log(` ${line}`);
1526
+ console.log(`\nWhy these, and what else helps: ${TMUX_DOC}`);
1527
+ }
1528
+ console.log("");
1529
+ for (const line of durabilityLines(node))
1530
+ console.log(line);
1531
+ console.log("\nA rebuild does not re-pin anything on its own. After every update:");
1532
+ console.log(" npm install && npm run build && hive setup");
1533
+ console.log(`\nPATH: ${pathAdvice(dir, file).join("\n")}`);
1534
+ reportSetupRegistrations(node);
1535
+ }
1536
+ function reportSetupRegistrations(pinned) {
1537
+ const found = hiveRegistrations(findProjectForCwd()?.path ?? null);
1538
+ const problems = found
1539
+ .map((r) => ({ r, lines: registrationProblem(r, pinned) }))
1540
+ .filter((p) => p.lines !== null);
1541
+ if (problems.length > 0) {
1542
+ console.log("\n! hive setup pins the `hive` command, not the MCP server. Claude Code starts");
1543
+ console.log(" hive's server from its own registration, and this one disagrees:");
1544
+ for (const { r, lines } of problems) {
1545
+ console.log(` mcp registration (${r.scope} scope): ${lines[0]}`);
1546
+ for (const line of lines.slice(1))
1547
+ console.log(` ${line}`);
1548
+ }
1549
+ return;
1550
+ }
1551
+ const offer = registrationOffer(pinned, found);
1552
+ if (offer) {
1553
+ console.log("");
1554
+ for (const line of offer)
1555
+ console.log(` ${line}`);
1556
+ }
1557
+ }
1558
+ function reportDispatcher() {
1559
+ const onPath = firstHiveOnPath();
1560
+ const winner = onPath ? readDispatcher(onPath) : null;
1561
+ const dispatcher = winner?.mine ? winner : readDispatcher(dispatcherPath());
1562
+ if (!dispatcher?.mine) {
1563
+ info("dispatcher", `none at ${dispatcherPath()}; \`hive setup\` writes one pinned to this build`, ...(onPath ? [`\`hive\` on PATH is ${onPath}`] : []));
1564
+ return;
1565
+ }
1566
+ info("dispatcher", `${dispatcher.file} -> ${dispatcher.node ?? "an exec line hive cannot parse"}`);
1567
+ if (!dispatcher.node || !dispatcher.cli) {
1568
+ gatingWarn("dispatcher", "written by another version of hive; re-run `hive setup` to refresh it.");
1569
+ }
1570
+ else if (!existsSync(dispatcher.node)) {
1571
+ const need = requiredNodeApi();
1572
+ const range = (need === null ? null : nodeRangeForNodeApi(need)) ?? "that Node-API level";
1573
+ gatingWarn("dispatcher", `the interpreter it pins is gone: ${dispatcher.node}`, "A version manager can remove one. Typing `hive` now fails with an exec error, and the", "SessionStart hook loses the interpreter it re-execs into when a project's own `node`", "cannot load the addon - a session there prints the addon banner instead.", "Re-pin by naming a Node that exists (setup pins whatever Node runs it, and the `hive`", "on PATH is this dispatcher):", ` <a Node matching ${range}> "${cliPath()}" setup`);
1574
+ }
1575
+ else if (!existsSync(dispatcher.cli)) {
1576
+ gatingWarn("dispatcher", `the checkout it pins is gone: ${dispatcher.cli}`, "That checkout was removed - typically a torn-down git worktree - so `hive` now fails with", "MODULE_NOT_FOUND instead of running, and the shim cannot repair itself: `hive setup` run", "through it just tries to re-read the same missing file.", `Re-pin from a durable checkout that still exists: "${dispatcher.node}" <durable checkout>/dist/cli.js setup`);
1577
+ }
1578
+ else if (linkedWorktreePin(dispatcher.cli).linked) {
1579
+ const worktreePin = linkedWorktreePin(dispatcher.cli);
1580
+ const repairCli = worktreePin.durableRoot
1581
+ ? join(worktreePin.durableRoot, "dist", "cli.js")
1582
+ : "<durable checkout>/dist/cli.js";
1583
+ gatingWarn("dispatcher", `pinned to a linked git worktree, which this repo tears down: ${dispatcher.cli}`, "A worktree teardown leaves `hive` failing with MODULE_NOT_FOUND, and running `hive setup`", "to repair it just re-pins the same worktree, since that shim is what runs the command.", `Re-pin from the durable checkout instead: "${dispatcher.node}" "${repairCli}" setup`);
1584
+ }
1585
+ else if (dispatcher.node !== process.execPath || dispatcher.cli !== cliPath()) {
1586
+ gatingWarn("dispatcher", "pinned to a different build than this CLI is running.", `this run: ${process.execPath} ${cliPath()}`, "Re-pin after a rebuild: npm run build && hive setup");
1587
+ }
1588
+ if (onPath !== dispatcher.file)
1589
+ gatingWarn("dispatcher", ...pathAdvice(dispatcherDir(), dispatcher.file));
1590
+ }
1591
+ function reportSessionInterpreters() {
1592
+ const locked = process.env.HIVE_PROJECT_LOCK === "1";
1593
+ const here = locked ? findProjectForCwd() : null;
1594
+ const registered = locked ? (here ? [here] : []) : listProjects();
1595
+ const projects = registered.filter((p) => existsSync(join(p.path, "hive.yml")));
1596
+ if (projects.length === 0)
1597
+ return;
1598
+ info("session interpreters", "one `node` resolved per project directory, with this process's own environment.", "That is how a version manager resolves the bare `node` the SessionStart hook runs under;", "a session started from a different environment can resolve a different interpreter.", ...(locked ? ["this project only: HIVE_PROJECT_LOCK is set for this session."] : []));
1599
+ let resolved;
1600
+ const target = () => (resolved ??= reexecTarget());
1601
+ for (const project of projects) {
1602
+ const verdict = sessionStartVerdict(probeSessionInterpreter(project.path), target);
1603
+ const emit = verdict.level === "warn" ? warn : info;
1604
+ emit(`project ${project.name} (${project.path})`, ...verdict.lines);
1605
+ }
1606
+ }
1607
+ function reportProjectScope(here) {
1608
+ const locked = process.env.HIVE_PROJECT_LOCK === "1";
1609
+ let homePath;
1610
+ try {
1611
+ homePath = realpathSync(homedir());
1612
+ }
1613
+ catch {
1614
+ homePath = homedir();
1615
+ }
1616
+ const projects = listProjects().sort((a, b) => a.path.localeCompare(b.path));
1617
+ for (const project of projects) {
1618
+ if (locked && project.id !== here?.id)
1619
+ continue;
1620
+ if (project.path === homePath || project.path === "/") {
1621
+ warn("project scope", `"${project.name}" (${project.path}) is a home directory; every unregistered directory under it resolves to this project. hive project prune removes it once it holds nothing.`);
1622
+ }
1623
+ }
1624
+ for (const parent of projects) {
1625
+ for (const child of projects) {
1626
+ if (locked && parent.id !== here?.id && child.id !== here?.id)
1627
+ continue;
1628
+ if (parent.path === child.path || !child.path.startsWith(parent.path + sep))
1629
+ continue;
1630
+ warn("project scope", `"${parent.name}" (${parent.path}) is registered above "${child.name}" (${child.path}); sessions in ${parent.path} outside ${child.path} resolve to "${parent.name}"`);
1631
+ }
1632
+ }
1633
+ }
1634
+ function reportMcpRegistrations(project) {
1635
+ const registrations = hiveRegistrations(project?.path ?? null);
1636
+ if (registrations.length === 0) {
1637
+ info("mcp registration", "none found for hive (checked ~/.claude.json and .mcp.json)", ...(registrationOffer(process.execPath, registrations) ?? []));
1638
+ }
1639
+ else {
1640
+ for (const r of registrations) {
1641
+ const where = `mcp registration (${r.scope} scope)`;
1642
+ info(where, [r.command, ...r.args].join(" "));
1643
+ const problem = registrationProblem(r, process.execPath);
1644
+ if (problem)
1645
+ gatingWarn(where, ...problem);
1646
+ }
1647
+ }
1648
+ const codexRegistrations = codexHiveRegistrations();
1649
+ if (codexRegistrations.length === 0) {
1650
+ info("mcp registration (codex)", `none found for hive under codex (checked ${codexConfigPath()})`);
1651
+ return;
1652
+ }
1653
+ for (const r of codexRegistrations) {
1654
+ info("mcp registration (codex)", [r.command, ...r.args].join(" "));
1655
+ }
1656
+ }
1657
+ const PTY_OVERRIDE_VARS = ["HIVE_PTY_HEADROOM_JSON", "HIVE_PTY_PS_ROWS_JSON", "HIVE_ORPHAN_SCRATCH_JSON"];
1658
+ function ptyOverrideNote() {
1659
+ const active = PTY_OVERRIDE_VARS.filter((v) => process.env[v]);
1660
+ return active.length === 0 ? "" : ` (${active.join(", ")} override${active.length > 1 ? "s" : ""}; testing only)`;
1661
+ }
1662
+ function reportPtyHeadroom(orphans) {
1663
+ const headroom = ptyHeadroom();
1664
+ if (!headroom) {
1665
+ return;
1666
+ }
1667
+ const free = headroom.max - headroom.allocated;
1668
+ const overrideNote = ptyOverrideNote();
1669
+ if (!isLowHeadroom(headroom)) {
1670
+ info("ptys", `${headroom.allocated} of ${headroom.max} in use (${free} free)${overrideNote}`);
1671
+ return;
1672
+ }
1673
+ const shells = orphanLoginShellDetails();
1674
+ const shellsLine = shells === null
1675
+ ? "orphaned login shell count unavailable (ps did not respond)"
1676
+ : `${shells.length} orphaned login shell(s) holding a pty (ppid 1, reparented to launchd)` +
1677
+ (shells.length === 0 ? "" : `, oldest ${(Math.max(...shells.map((s) => s.ageMs)) / 3_600_000).toFixed(1)}h`);
1678
+ const scratchHeld = orphans ? orphans.live + orphans.wedged : 0;
1679
+ const livePanes = db.prepare("SELECT COUNT(*) AS n FROM agents WHERE status = 'running'").get()
1680
+ .n;
1681
+ warn("ptys", `${headroom.allocated} of ${headroom.max} in use (${free} free), below the safety margin${overrideNote}`, shellsLine, `${scratchHeld} orphaned scratch tmux server(s) still holding a socket (detail in the scratch tmux servers check)`, `${livePanes} pane(s) recorded 'running' in hive's own store (leads and workers, this machine)`, "reap the orphaned shells and scratch servers with `node scripts/sweep-scratch.mjs` (dry run by default, " +
1682
+ "`--kill` to act)");
1683
+ }
1684
+ function reportOrphanTmuxServers(orphans) {
1685
+ if (!orphans)
1686
+ return;
1687
+ const truncated = orphans.probed < orphans.aged ? `, ${orphans.aged - orphans.probed} not probed (time budget)` : "";
1688
+ const found = orphans.live + orphans.wedged;
1689
+ const hours = orphans.oldestMs === null ? null : (orphans.oldestMs / 3_600_000).toFixed(1);
1690
+ const detail = [
1691
+ `${found} orphaned server(s) on scratch sockets (${orphans.live} answering, ${orphans.wedged} not answering` +
1692
+ `${hours === null ? "" : `, oldest ${hours}h`}), out of ${orphans.candidates} scratch socket(s)${truncated}`,
1693
+ `not touched here - doctor reports: ${orphans.sockets.slice(0, 5).join(", ")}` +
1694
+ (orphans.sockets.length > 5 ? `, and ${orphans.sockets.length - 5} more` : ""),
1695
+ "reap one that ANSWERS by socket: resolve the live socket with `tmux display-message -p '#{socket_path}'`, " +
1696
+ "then `tmux -S <path> kill-server` on the others. Never by pid - a pid-to-socket mapping has been observed " +
1697
+ "ambiguous on a real row.",
1698
+ "a server that does NOT answer is the wedged case, and kill-server blocks against it, leaving another " +
1699
+ "spinning client behind per attempt. Resolve the live PID as well (`tmux display-message -p '#{pid}'`), " +
1700
+ "exclude both it and the live socket, re-check each candidate with lsof, and verify the live session after " +
1701
+ "every step.",
1702
+ ];
1703
+ if (found === 0) {
1704
+ info("scratch tmux servers", `none older than ${Math.round(ORPHAN_MIN_AGE_MS / 60000)}m (${orphans.candidates} scratch socket(s) present, ` +
1705
+ `${orphans.aged} old enough to probe${truncated})`);
1706
+ return;
1707
+ }
1708
+ (orphansWorthWarningAbout(orphans) ? warn : info)("scratch tmux servers", ...detail);
1709
+ }
1710
+ // Walks with lstat, never stat: a home directory holds an auth.json SYMLINK to the user's own real
1711
+ // ~/.codex/auth.json, and this report must never read through it - the link's own (tiny) size is
1712
+ // what counts against the home, not the real credential file's.
1713
+ function codexHomeSizeBytes(path) {
1714
+ let total = 0;
1715
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
1716
+ const full = join(path, entry.name);
1717
+ total += entry.isDirectory() && !entry.isSymbolicLink() ? codexHomeSizeBytes(full) : lstatSync(full).size;
1718
+ }
1719
+ return total;
1720
+ }
1721
+ function summarizeCodexHomes(projectId) {
1722
+ const homesDir = join(dataDir, "codex-homes");
1723
+ if (!existsSync(homesDir))
1724
+ return null;
1725
+ const keys = readdirSync(homesDir, { withFileTypes: true })
1726
+ .filter((e) => e.isDirectory())
1727
+ .map((e) => e.name);
1728
+ const rows = db
1729
+ .prepare("SELECT codex_home, project_id, status, parked_at FROM agents WHERE codex_home != ''")
1730
+ .all();
1731
+ const byKey = new Map(rows.map((r) => [r.codex_home, r]));
1732
+ let totalSizeBytes = 0;
1733
+ let pendingReap = 0;
1734
+ let unaccounted = 0;
1735
+ let thisProjectInUse = 0;
1736
+ let otherProjectsInUse = 0;
1737
+ for (const key of keys) {
1738
+ try {
1739
+ totalSizeBytes += codexHomeSizeBytes(join(homesDir, key));
1740
+ }
1741
+ catch {
1742
+ }
1743
+ const row = byKey.get(key);
1744
+ if (!row) {
1745
+ unaccounted += 1;
1746
+ }
1747
+ else if (row.status === "closed" && !row.parked_at) {
1748
+ pendingReap += 1;
1749
+ }
1750
+ else if (row.project_id === projectId) {
1751
+ thisProjectInUse += 1;
1752
+ }
1753
+ else {
1754
+ otherProjectsInUse += 1;
1755
+ }
1756
+ }
1757
+ return { count: keys.length, totalSizeBytes, pendingReap, unaccounted, thisProjectInUse, otherProjectsInUse };
1758
+ }
1759
+ // Project-scoped reader of a machine-scoped artifact, same convention as the teardown report
1760
+ // (.claude/rules/store-and-datadir.md): codex-homes/ is one directory shared by every project on
1761
+ // this store, so an in-use home belonging to another project is a bare count, never a name or path.
1762
+ function reportCodexHomes(projectId) {
1763
+ const summary = summarizeCodexHomes(projectId);
1764
+ if (!summary || summary.count === 0) {
1765
+ info("codex homes", "none on disk");
1766
+ return;
1767
+ }
1768
+ const mb = (summary.totalSizeBytes / 1_000_000).toFixed(1);
1769
+ const detail = [
1770
+ `${summary.count} per-worker home(s) on disk, ${mb} MB total`,
1771
+ `${summary.thisProjectInUse} in use by this project's running or parked worker(s)` +
1772
+ (summary.otherProjectsInUse > 0 ? `, ${summary.otherProjectsInUse} in use by other project(s)` : ""),
1773
+ ];
1774
+ if (summary.pendingReap > 0) {
1775
+ detail.push(`${summary.pendingReap} closed and not yet reaped - the scheduler janitor sweeps these on its next tick; ` +
1776
+ "staying nonzero across repeated doctor runs means no hive process is ticking");
1777
+ }
1778
+ if (summary.unaccounted > 0) {
1779
+ detail.push(`${summary.unaccounted} on disk with no matching agents row at all - not touched here, worth a manual ` +
1780
+ `look (\`ls ${join(dataDir, "codex-homes")}\`)`);
1781
+ }
1782
+ (summary.pendingReap > 0 || summary.unaccounted > 0 ? warn : info)("codex homes", ...detail);
1783
+ }
1784
+ const UNBRIEFED_WORKER_BOUND_SECONDS = 30 * 60;
1785
+ function reportUnbriefedWorkers(projectId) {
1786
+ const waiting = db
1787
+ .prepare(`SELECT name, command, kind, resumed_at, tmux_socket FROM agents WHERE project_id = ? AND status = 'running'
1788
+ AND kind = 'agent' AND ${awaitingFirstPromptSql("agents")} ORDER BY id`)
1789
+ .all(projectId)
1790
+ .filter((row) => reportsAgentStateLog(row))
1791
+ .filter((row) => !foreignSocket(row.tmux_socket));
1792
+ const overdue = waiting
1793
+ .map((row) => ({ name: row.name, seconds: ageSecondsSince(row.resumed_at) }))
1794
+ .filter((row) => row.seconds >= UNBRIEFED_WORKER_BOUND_SECONDS);
1795
+ if (overdue.length === 0) {
1796
+ info("first assignment", `${waiting.length} worker(s) awaiting one, none for more than ` +
1797
+ `${Math.round(UNBRIEFED_WORKER_BOUND_SECONDS / 60)}m`);
1798
+ return;
1799
+ }
1800
+ for (const row of overdue) {
1801
+ warn(`worker ${row.name}`, `has had its first-prompt latch set for ${humanizeAge(row.seconds)} - hive has suppressed every finish it ` +
1802
+ "reported for that whole time, so a lead waiting on this worker will wait forever.", "the latch is agents.resumed_at, cleared by the worker's first prompt (src/hook.ts). It is still set, " +
1803
+ "which means either this worker was resumed and nobody has re-briefed it yet, or its next assignment " +
1804
+ "landed in a pane that was still replaying its restore turn and fired no UserPromptSubmit - in which " +
1805
+ "case the worker may be working normally and only its FINISHES are lost - or its hooks never wired up " +
1806
+ "at all (.claude/rules/worker-state.md).", "either way the fix is the same: send it something while its pane is idle - any real prompt clears the " +
1807
+ "latch and its finishes start being reported again. `hive status` shows whether the pane is busy.");
1808
+ }
1809
+ }
1810
+ function describeProbed(workers, leads) {
1811
+ if (workers > 0 && leads > 0)
1812
+ return "workers plus the lead's own pane";
1813
+ if (leads > 0)
1814
+ return "the lead's own pane only - no worker pane was probeable";
1815
+ if (workers > 0)
1816
+ return "workers only - no lead pane was probeable";
1817
+ return "nothing was probeable";
1818
+ }
1819
+ // Quiet by default and self-clearing without a threshold: a record is reported only while nobody
1820
+ // has started a replacement crew in THIS project since it, and only while it still names a worker
1821
+ // that is still closed. Both are exact facts about the store, so this can never become furniture.
1822
+ //
1823
+ // Reports EVERY still-current record, not just the newest: the janitor's 15-second settle window
1824
+ // means one death can be detected across two ticks, and a crew silently absent from the report is
1825
+ // the session ids this artifact exists to carry never reaching the human.
1826
+ function currentTeardowns(projectId) {
1827
+ const movedOn = (record) => db
1828
+ .prepare(
1829
+ // agents.created_at is second-precision and detected_at is millisecond, so this compares
1830
+ // against the truncated second, and it excludes the record's own crew.
1831
+ `SELECT COUNT(*) AS n FROM agents WHERE project_id = ? AND created_at >= ?` +
1832
+ ` AND id NOT IN (${record.crew.map(() => "?").join(",") || "-1"})`)
1833
+ .get(projectId, record.detected_at.slice(0, 19), ...record.crew.map((m) => m.agent_id)).n > 0;
1834
+ const current = [];
1835
+ for (const record of readTeardowns(dataDir).reverse()) {
1836
+ if (movedOn(record))
1837
+ break;
1838
+ current.push(record);
1839
+ }
1840
+ return current.reverse();
1841
+ }
1842
+ // Identity is the actor_id, not the row id. teardowns.jsonl is not in a backup snapshot and a
1843
+ // restore does not touch it, so the store can roll back under a record that outlived it and a later
1844
+ // spawn can take an id this record already names. Naming a live worker as a casualty is the
1845
+ // wrong-owner failure this whole artifact is built to avoid.
1846
+ const stillClosedNow = (m) => {
1847
+ const row = db.prepare("SELECT status, actor_id FROM agents WHERE id = ?").get(m.agent_id);
1848
+ return row?.status === "closed" && row.actor_id === m.actor_id;
1849
+ };
1850
+ // The cross-project reach IS the incident - every recorded death took a second project's lead - so
1851
+ // other projects' casualties are reported as a BARE COUNT. The fact that the death was machine-wide
1852
+ // survives; no name, cwd, id or resume command for a row this project does not own crosses over.
1853
+ function reportOneTeardown(record, projectId) {
1854
+ const mine = record.crew.filter((m) => m.project_id === projectId);
1855
+ const theirs = record.crew.length - mine.length;
1856
+ const stillClosed = mine.filter((m) => m.swept && stillClosedNow(m));
1857
+ if (stillClosed.length === 0)
1858
+ return;
1859
+ const resumable = stillClosed.filter((m) => m.session_id !== "");
1860
+ warn("crew teardown", `${mine.length} of this project's pane(s) went with the tmux server at ${record.socket}; ` +
1861
+ `${record.attribution}.`, `hive detected it at ${record.detected_at}. The teardown happened ${describeTeardownWindow(record.window)}.`, ...(theirs > 0
1862
+ ? [
1863
+ `${theirs} further pane(s) on that socket died with it, belonging to other projects or to no ` +
1864
+ "project hive can name. They are counted and not listed: the death crossing projects is the " +
1865
+ "fact worth having, and this project may not read another's state.",
1866
+ ]
1867
+ : []), "hive records who WAS there, never who killed it. To attribute it, dump EVERY tool call in that " +
1868
+ `window from these working directories - the gaps matter as much as the hits: ${[
1869
+ ...new Set(mine.map((m) => m.cwd)),
1870
+ ].join(", ")}`, resumable.length > 0
1871
+ ? `still closed and resumable: ${resumable
1872
+ .map((m) => `${m.name} (agent_resume(agent_id: ${m.agent_id}))`)
1873
+ .join(", ")}. The recorded session id does not expire; its transcript and its cwd can.`
1874
+ : `still closed: ${stillClosed.map((m) => m.name).join(", ")}. None recorded a session id, so none can be resumed.`, `full record: ${teardownLogPath(dataDir)}`);
1875
+ }
1876
+ // This is the one reporter in doctor that reads a file a human is invited to open, so it is wrapped:
1877
+ // a throw here is not inside a check() and would abort every remaining check, and doctor is what
1878
+ // people run once things are already broken.
1879
+ function reportCrewTeardown(projectId) {
1880
+ try {
1881
+ for (const record of currentTeardowns(projectId))
1882
+ reportOneTeardown(record, projectId);
1883
+ }
1884
+ catch (e) {
1885
+ warn("crew teardown", `a teardown record could not be read: ${errorMessage(e)}`, `hive is saying so rather than staying quiet, because this file is the only trace of a dead crew: ${teardownLogPath(dataDir)}`);
1886
+ }
1887
+ }
1888
+ function reportStalledWorkers(projectId) {
1889
+ const latched = db
1890
+ .prepare(`SELECT name, command, kind, cwd, session_id, transcript_path, agent_state, state_changed_at, tmux_target, tmux_socket
1891
+ FROM agents
1892
+ WHERE project_id = ? AND status = 'running' AND kind = 'agent'
1893
+ AND agent_state IN ('working', 'waiting') AND state_changed_at IS NOT NULL
1894
+ ORDER BY id`)
1895
+ .all(projectId)
1896
+ // A transcript signal, not just reportsAgentStateLog: this whole report corroborates a latch
1897
+ // against transcript mtime (worker-state.md - "the sampler is the worker's transcript mtime,
1898
+ // never this latch"), and a harness with no transcript to check would have nothing to read.
1899
+ .filter((row) => reportsAgentStateLog(row) && hasTranscriptSignal(row) && row.session_id !== "");
1900
+ const stalled = [];
1901
+ for (const row of latched) {
1902
+ const latchedFor = ageSecondsSince(row.state_changed_at);
1903
+ if (latchedFor < STALL_BOUND_SECONDS)
1904
+ continue;
1905
+ const stale = transcriptStaleness(row);
1906
+ // A stored-path harness (codex) with no readable file has nothing to report from - a missing
1907
+ // file is never treated as a stall for it, unlike claude's directory-resolved "never wrote"
1908
+ // (todo 591). LOAD-BEARING, do not remove: this SELECT's status='running' snapshot and the
1909
+ // statSync above can straddle a concurrent agent_close/agent_park in another process reaping
1910
+ // CODEX_HOME mid-tick, and an external deletion of the rollout file is not guarded against at
1911
+ // all - either way the file can be gone under a row this run still sees as running.
1912
+ if (stale === "never" && !transcriptDirFor(row.command))
1913
+ continue;
1914
+ if (stale !== "never" && stale.seconds < STALL_BOUND_SECONDS)
1915
+ continue;
1916
+ if (row.agent_state === "waiting") {
1917
+ if (foreignSocket(row.tmux_socket))
1918
+ continue;
1919
+ const choiceCheck = paneClassifierFor(row.command)?.choiceCheck;
1920
+ if (!choiceCheck || choiceCheck(row.tmux_target).awaitingChoice !== false)
1921
+ continue;
1922
+ }
1923
+ stalled.push({ name: row.name, sentence: describeStall(row.agent_state, latchedFor, stale) });
1924
+ }
1925
+ if (stalled.length === 0) {
1926
+ info("stalled workers", `${latched.length} worker(s) latched working/waiting, none stalled past ` +
1927
+ `${Math.round(STALL_BOUND_SECONDS / 60)}m`);
1928
+ return;
1929
+ }
1930
+ for (const row of stalled) {
1931
+ warn(`worker ${row.name}`, row.sentence, "hive is reporting what it OBSERVED, not that this worker is dead: one very long tool call looks " +
1932
+ "identical from here. Read its pane before acting.", "if the turn really did die, send it a message AND TELL IT WHAT STATE YOU FOUND - after an API error a " +
1933
+ "worker does not reliably remember what it was doing (.claude/rules/worker-state.md).");
1934
+ }
1935
+ }
1936
+ function cmdDoctor(argv) {
1937
+ const strict = argv.includes("--strict");
1938
+ doctorVerbose = argv.includes("--verbose");
1939
+ const KNOWN_DOCTOR_FLAGS = new Set(["--strict", "--verbose"]);
1940
+ const unknown = argv.find((a) => !KNOWN_DOCTOR_FLAGS.has(a));
1941
+ if (unknown !== undefined) {
1942
+ console.error(`hive doctor: unknown argument "${unknown}". Flags are --strict and --verbose.`);
1943
+ process.exit(1);
1944
+ }
1945
+ let failures = 0;
1946
+ const fail = (label, ...lines) => {
1947
+ failures += 1;
1948
+ report("FAIL", label, lines);
1949
+ };
1950
+ const check = (label, fn) => {
1951
+ try {
1952
+ console.log(` ok ${label}: ${fn()}`);
1953
+ }
1954
+ catch (e) {
1955
+ fail(label, errorMessage(e));
1956
+ }
1957
+ };
1958
+ console.log("hive doctor\n");
1959
+ check("version", () => {
1960
+ const { line, drift } = versionInfo();
1961
+ return drift ? [line, drift].join("\n ") : line;
1962
+ });
1963
+ check("node", () => describeInterpreter());
1964
+ check("better-sqlite3", () => {
1965
+ const status = checkAbi();
1966
+ if (!status.ok)
1967
+ throw new Error(describeAbi(status));
1968
+ return [describeAbi(status), status.addon].join("\n ");
1969
+ });
1970
+ check("tmux", () => {
1971
+ const version = execFileSync("tmux", ["-V"], { encoding: "utf8" }).trim();
1972
+ const override = tmuxTimeoutOverride();
1973
+ return override === null ? version : `${version} (HIVE_TMUX_TIMEOUT_MS=${override}ms override; testing only)`;
1974
+ });
1975
+ const orphans = orphanScratchServers();
1976
+ reportPtyHeadroom(orphans);
1977
+ reportOrphanTmuxServers(orphans);
1978
+ check("claude", () => execFileSync("which", ["claude"], { encoding: "utf8" }).trim());
1979
+ check("database", () => {
1980
+ const n = db.prepare("SELECT COUNT(*) AS n FROM migrations").get().n;
1981
+ return `${dataDir} (schema v${n})`;
1982
+ });
1983
+ check("hooks file", () => ensureHooksFile());
1984
+ const here = findProjectForCwd();
1985
+ reportDispatcher();
1986
+ reportMcpRegistrations(here);
1987
+ reportProjectScope(here);
1988
+ reportSessionInterpreters();
1989
+ reportCodexHomes(here?.id ?? null);
1990
+ const loaded = loadProjectYml(here?.path ?? process.cwd());
1991
+ for (const w of loaded.warnings)
1992
+ warn("hive.yml", w);
1993
+ const config = loaded.config;
1994
+ const profile = activeProfile(config);
1995
+ if (here && existsSync(join(here.path, ".claude", "dashboard", "index.html"))) {
1996
+ info("dashboard", "an older hive wrote .claude/dashboard/index.html; hive now writes .hive/dashboard.html. Delete the old file and directory.");
1997
+ }
1998
+ const reportProfile = (name, cfg) => {
1999
+ if (!profileExists(name)) {
2000
+ fail("profile", `"${name}" is named in hive.yml but is not on this machine. List what you have with: hive profile list`);
2001
+ return;
2002
+ }
2003
+ const resolved = profileStatus(name).files;
2004
+ const readable = (file) => readProfileFile(name, file) != null;
2005
+ if (!resolved.some((f) => readable(f.file))) {
2006
+ fail("profile", `"${name}" has a directory but none of its files (${PROFILE_FILES.join(", ")}) resolve to readable content, here or in hive's shipped defaults.`);
2007
+ return;
2008
+ }
2009
+ const hasRunbookPad = here != null && getActivePadByName(here.id, "runbook") != null;
2010
+ if (!readable("runbook.md") && !hasRunbookPad) {
2011
+ fail("profile", `"${name}" has no readable runbook.md, here or in hive's shipped defaults, so a lead using it starts with no standing process.`);
2012
+ }
2013
+ const usable = resolved.filter((f) => readable(f.file));
2014
+ info("profile", `${name} (${usable.map((f) => `${f.file}: ${f.source}`).join(", ")})`);
2015
+ for (const f of resolved) {
2016
+ const drift = profileDriftText(f);
2017
+ if (drift)
2018
+ (drift.rewrite ? info : warn)("profile", drift.text);
2019
+ }
2020
+ const agentKeys = agentVarKeys();
2021
+ const referenced = [...new Set(
2022
+ // worker.md's vars are per-spawn identity (agent_name, actor_id, ...), never hive.yml vars.
2023
+ // agents_* vars are derived from hive.yml agents:, never missing or unused by definition (todo 597).
2024
+ profileFileNames(name)
2025
+ .filter((file) => file !== "worker.md")
2026
+ .flatMap((file) => {
2027
+ const text = readProfileFile(name, file);
2028
+ return text ? templateVars(text) : [];
2029
+ }))].filter((v) => !agentKeys.includes(v)).sort();
2030
+ // worker.md can still reference a hive.yml var (e.g. {{check}}) even though it stays out of
2031
+ // `referenced` above; count that for the unused direction only, never for missing.
2032
+ const workerReferenced = templateVars(readProfileFile(name, "worker.md") ?? "").filter((v) => !agentKeys.includes(v));
2033
+ const configuredKeys = Object.keys(cfg?.vars ?? {});
2034
+ const defined = configuredKeys.filter((v) => !agentKeys.includes(v));
2035
+ const missing = referenced.filter((v) => !defined.includes(v));
2036
+ const unused = defined.filter((v) => !referenced.includes(v) && !workerReferenced.includes(v));
2037
+ if (referenced.length > 0) {
2038
+ info("profile vars", `profile files reference ${referenced.join(", ")}`);
2039
+ if (missing.length > 0)
2040
+ info("profile vars", `not set here (sections drop): ${missing.join(", ")}`);
2041
+ if (unused.length > 0)
2042
+ info("profile vars", `defined but unreferenced: ${unused.join(", ")}`);
2043
+ }
2044
+ // A hive.yml vars: entry sharing a name with a derived agents_* var never wins (mergedProjectVars
2045
+ // strips it); warn rather than let it silently lie about which harnesses this project allows.
2046
+ const collisions = configuredKeys.filter((v) => agentKeys.includes(v));
2047
+ if (collisions.length > 0) {
2048
+ warn("profile vars", `hive.yml vars ${collisions.join(", ")} are derived from agents: and are ignored.`);
2049
+ }
2050
+ const vars = mergedProjectVars(cfg);
2051
+ const renderedText = profileFileNames(name)
2052
+ .map((file) => renderProfileFile(name, file, vars))
2053
+ .filter((t) => t != null)
2054
+ .join("\n");
2055
+ const pads = referencedPads(renderedText);
2056
+ const paths = referencedPaths(renderedText);
2057
+ const missingPads = here ? pads.filter((p) => getActivePadByName(here.id, p) == null) : [];
2058
+ const projectRoot = here?.path ?? process.cwd();
2059
+ const missingPaths = paths.filter((p) => {
2060
+ const clean = p.endsWith("/") ? p.slice(0, -1) : p;
2061
+ const segments = clean.split("/");
2062
+ const last = segments[segments.length - 1];
2063
+ const target = last.includes("*") ? segments.slice(0, -1).join("/") : clean;
2064
+ return target !== "" && !existsSync(join(projectRoot, target));
2065
+ });
2066
+ if (missingPads.length > 0)
2067
+ info("profile references", `pad(s) referenced but not here: ${missingPads.join(", ")}`);
2068
+ if (missingPaths.length > 0)
2069
+ info("profile references", `path(s) referenced but not here: ${missingPaths.join(", ")}`);
2070
+ const checkCommand = cfg?.vars?.check;
2071
+ if (checkCommand != null && checkCommand.trim() !== "") {
2072
+ info("check", checkCommand);
2073
+ }
2074
+ else {
2075
+ info("check", "not set in hive.yml; workers are not told what to run before reporting (vars: check: <command>)");
2076
+ }
2077
+ };
2078
+ if (profile) {
2079
+ reportProfile(profile, config);
2080
+ }
2081
+ else if (here && config?.profile === NO_PROFILE && !getActivePadByName(here.id, "runbook")) {
2082
+ fail("profile", `this project ${NO_RUNBOOK_PAD_MESSAGE}`);
2083
+ }
2084
+ check("stale state", () => {
2085
+ const r = janitor();
2086
+ if (!r.probed) {
2087
+ if (untrustedTmuxServer()) {
2088
+ throw new Error(`TMUX_TMPDIR points at a private tmux server (${process.env.TMUX_TMPDIR}) while hive is ` +
2089
+ `using its default store at ${DEFAULT_DATA_DIR}. Nothing was swept, deliberately: the ` +
2090
+ "agents in that store live on the shared tmux server, so this one would report every " +
2091
+ "single one of them as dead. Unset TMUX_TMPDIR, or set HIVE_DATA_DIR to a scratch store " +
2092
+ "to go with the private server.");
2093
+ }
2094
+ throw new Error("tmux did not answer, so nothing was swept. Re-run when tmux responds.");
2095
+ }
2096
+ return `${r.closed_agents} dead agents closed, ${r.cancelled_timers} undeliverable wake-ups cancelled`;
2097
+ });
2098
+ if (here) {
2099
+ const lead = db
2100
+ .prepare("SELECT tmux_target, tmux_socket FROM agents WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id")
2101
+ .get(here.id, LEAD_KIND);
2102
+ if (lead) {
2103
+ const live = rowLive(lead.tmux_socket, lead.tmux_target);
2104
+ if (live === false) {
2105
+ warn("lead", "the lead's row is running but its pane is not live. The janitor leaves lead rows alone on " +
2106
+ "purpose, so nothing will fix this by itself. Run `hive lead` to record a fresh pane and reuse " +
2107
+ "this identity, or ask a claude session connected to this project's hive MCP server to call " +
2108
+ "the agent_close tool on it to retire the row for good - required before `hive restore`, which " +
2109
+ "otherwise refuses while any lead row reads running, and end that session too, since it also " +
2110
+ "holds this store open.");
2111
+ }
2112
+ else if (live === null) {
2113
+ warn("lead", "the lead's pane liveness could not be probed (tmux did not answer).");
2114
+ }
2115
+ }
2116
+ const stuck = db
2117
+ .prepare("SELECT name, kind, tmux_socket FROM agents WHERE project_id = ? AND status = 'running' AND kind != ?")
2118
+ .all(here.id, LEAD_KIND);
2119
+ for (const row of stuck) {
2120
+ if (!foreignSocket(row.tmux_socket))
2121
+ continue;
2122
+ warn(`${row.kind} ${row.name}`, `recorded on tmux socket ${row.tmux_socket}, but this process would use ` +
2123
+ `${tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR)}. The janitor cannot judge this row ` +
2124
+ "from here, so it stays 'running' - and its name stays taken - until it is probed from wherever " +
2125
+ "that socket actually lives.");
2126
+ }
2127
+ reportUnbriefedWorkers(here.id);
2128
+ reportStalledWorkers(here.id);
2129
+ reportCrewTeardown(here.id);
2130
+ }
2131
+ if (here) {
2132
+ const workers = db
2133
+ .prepare("SELECT name, actor_id, command, kind, tmux_target, tmux_socket FROM agents WHERE project_id = ? AND status = 'running' AND kind = 'agent' ORDER BY id")
2134
+ .all(here.id);
2135
+ let inputBoxClean = 0;
2136
+ let inputBoxDrifted = 0;
2137
+ let inputBoxUnclassified = 0;
2138
+ let leadsProbed = 0;
2139
+ let workersProbed = 0;
2140
+ const leadRows = db
2141
+ .prepare("SELECT name, command, tmux_target, tmux_socket FROM agents WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id")
2142
+ .all(here.id, LEAD_KIND);
2143
+ for (const leadBox of leadRows) {
2144
+ const leadHarness = harnessFor(leadBox.command);
2145
+ if (!leadHarness.classifiesPaneScreen || foreignSocket(leadBox.tmux_socket))
2146
+ continue;
2147
+ leadsProbed += 1;
2148
+ const box = leadHarness.paneClassifier.inputBoxState(leadBox.tmux_target);
2149
+ if (box === null) {
2150
+ inputBoxUnclassified += 1;
2151
+ }
2152
+ else if (box.state === "unknown") {
2153
+ inputBoxDrifted += 1;
2154
+ warn(`lead ${leadBox.name}`, "input box classifies 'unknown': an input box is on screen (its own borders were found) but its " +
2155
+ "prompt row could not be found inside it. This is the pane a human types into, so the guard that " +
2156
+ "protects unsubmitted text is the one at risk (.claude/rules/tmux-and-panes.md, the 'unknown " +
2157
+ "exemption' section).");
2158
+ }
2159
+ else {
2160
+ inputBoxClean += 1;
2161
+ }
2162
+ }
2163
+ let workersReported = 0;
2164
+ let dialogCount = 0;
2165
+ for (const w of workers) {
2166
+ if (reportsAgentStateLog(w)) {
2167
+ workersReported += 1;
2168
+ // Dispatched through w's OWN harness, never the claude-only import - reading a codex pane
2169
+ // with claude's dialog regexes fails silently in either direction (.claude/rules/tmux-and-panes.md).
2170
+ const foreign = foreignSocket(w.tmux_socket);
2171
+ const workerChoiceCheck = paneClassifierFor(w.command)?.choiceCheck;
2172
+ const { awaitingChoice, tail } = foreign || !workerChoiceCheck ? { awaitingChoice: null, tail: "" } : workerChoiceCheck(w.tmux_target);
2173
+ if (awaitingChoice === true)
2174
+ dialogCount += 1;
2175
+ verboseInfo("worker live state", `worker ${w.name}`, `last log event: ${describeLastLogEvent(lastLogEvent(w.actor_id))}`, `permission mode: ${lastPermissionMode(w.actor_id) ?? "unknown (no record)"}`, foreign
2176
+ ? `pane: recorded on a different tmux socket (${w.tmux_socket}); this process cannot read it`
2177
+ : `pane: ${describePaneChoice(awaitingChoice)}`, ...(foreign
2178
+ ? ["tail: (not read - foreign socket)"]
2179
+ : awaitingChoice === null
2180
+ ? ["tail: (pane could not be read)"]
2181
+ : tail === ""
2182
+ ? ["tail: (pane rendered nothing)"]
2183
+ : ["tail:", ...tail.split("\n").map((line) => `| ${line}`)]));
2184
+ }
2185
+ // Gated on classifiesPaneScreen, not reportsAgentStateLog: this drift check only needs the pane,
2186
+ // and a codex worker's pane has been readable since todo 524, independent of whether its
2187
+ // agent_state_log is trusted (stateSource, earned by todo 525). Dispatched through the worker's
2188
+ // own harness, never the claude-only import above, or a codex worker's screen gets read with
2189
+ // claude's regexes (see hive-internals).
2190
+ const harness = harnessFor(w.command);
2191
+ if (!harness.classifiesPaneScreen || foreignSocket(w.tmux_socket))
2192
+ continue;
2193
+ workersProbed += 1;
2194
+ const box = harness.paneClassifier.inputBoxState(w.tmux_target);
2195
+ if (box === null) {
2196
+ inputBoxUnclassified += 1;
2197
+ }
2198
+ else if (box.state === "unknown") {
2199
+ inputBoxDrifted += 1;
2200
+ warn(`worker ${w.name}`, "input box classifies 'unknown': an input box is on screen (its own borders were found) but its prompt " +
2201
+ "row could not be found inside it (.claude/rules/tmux-and-panes.md, the 'unknown exemption' section).");
2202
+ }
2203
+ else {
2204
+ inputBoxClean += 1;
2205
+ }
2206
+ }
2207
+ info("worker detail", `${workersReported} worker(s)${dialogCount > 0 ? `, ${dialogCount} awaiting a dialog` : ""}; ` +
2208
+ "--verbose for last log event, permission mode and pane tail per worker, or `hive status` for live state");
2209
+ const inputBoxChecked = inputBoxClean + inputBoxDrifted + inputBoxUnclassified;
2210
+ if (inputBoxChecked > 0) {
2211
+ info("input box classifier", `${inputBoxChecked} running box(es) probed (${describeProbed(workersProbed, leadsProbed)}): ` +
2212
+ `${inputBoxClean} classified cleanly, ` +
2213
+ `${inputBoxDrifted} classified 'unknown', ${inputBoxUnclassified} not classified (no box currently on ` +
2214
+ "screen to classify - a dialog, mid-turn, or an unreadable pane)");
2215
+ if (inputBoxDrifted > 0) {
2216
+ warn("input box classifier", inputBoxDrifted === inputBoxChecked
2217
+ ? "every probed input box in this project classified 'unknown' - the chrome-change signature, not " +
2218
+ "pane-specific noise. The wake hold, agent_send's text refusal and agent_rename's refusal have " +
2219
+ `likely reverted to pre-guard behaviour on every pane probed here (${inputBoxChecked}) ` +
2220
+ "(.claude/rules/tmux-and-panes.md, the 'unknown exemption' section)."
2221
+ : `${inputBoxDrifted} of ${inputBoxChecked} probed input boxes in this project classified 'unknown' - ` +
2222
+ "some but not all, so this reads as pane-specific rather than project-wide drift; the guards " +
2223
+ "above still work on the other worker(s)' panes.");
2224
+ }
2225
+ }
2226
+ }
2227
+ const hiveSessions = () => {
2228
+ try {
2229
+ return {
2230
+ sessions: tmux("ls", "-F", "#{session_name}")
2231
+ .trim()
2232
+ .split("\n")
2233
+ .filter((s) => s.startsWith(SESSION_PREFIX)),
2234
+ answered: true,
2235
+ };
2236
+ }
2237
+ catch (e) {
2238
+ return { sessions: [], answered: tmuxSaysNothingThere(e) };
2239
+ }
2240
+ };
2241
+ const { sessions: allSessions, answered: tmuxAnswered } = hiveSessions();
2242
+ if (!tmuxAnswered) {
2243
+ warn("tmux server", `tmux did not answer: the call either hit its bound against a server hive itself talks to, or ` +
2244
+ "failed in a way that is not an answer about the world at all. Either way the session, window-stamp " +
2245
+ "and view-session lines below say unknown rather than none.", "the orphaned-scratch-server report above deliberately EXCLUDES this live socket, so it cannot see this " +
2246
+ "one either. Resolve it by hand: `tmux display-message -p '#{socket_path}'`, then `tmux -S <path> " +
2247
+ "list-sessions` to confirm it is unreachable.");
2248
+ }
2249
+ check("sessions", () => {
2250
+ if (!tmuxAnswered)
2251
+ return "unknown - tmux did not answer";
2252
+ const base = allSessions.filter((s) => !isViewSessionName(s));
2253
+ return base.length > 0 ? base.join(", ") : "none running";
2254
+ });
2255
+ check("window stamps", () => {
2256
+ const session = sessionName();
2257
+ if (!tmuxAnswered)
2258
+ return "unknown - tmux did not answer";
2259
+ if (!allSessions.includes(session))
2260
+ return "no session";
2261
+ const byProject = new Map();
2262
+ for (const { window, projectId } of listOwnedWindows(session)) {
2263
+ if (projectId === "")
2264
+ continue;
2265
+ byProject.set(projectId, [...(byProject.get(projectId) ?? []), window]);
2266
+ }
2267
+ const duplicates = [...byProject.entries()].filter(([, windows]) => windows.length > 1);
2268
+ if (duplicates.length > 0) {
2269
+ throw new Error(duplicates
2270
+ .map(([owner, windows]) => `project ${owner} is stamped on ${windows.length} windows (${windows.join(", ")}). ` +
2271
+ "Only the lowest-indexed one is ever found, so this project's lead and its workers can end up " +
2272
+ "in different tabs. Move the panes into one window (tmux join-pane -t <window>) and " +
2273
+ `unstamp the other (tmux set-window-option -t <window> -u @hive-project-id).`)
2274
+ .join("\n "));
2275
+ }
2276
+ const stamped = [...byProject.keys()].length;
2277
+ return stamped > 0 ? `${stamped} project window(s), no duplicates` : "no project windows";
2278
+ });
2279
+ for (const name of allSessions.filter(isViewSessionName)) {
2280
+ let hasClient;
2281
+ try {
2282
+ hasClient = tmux("list-clients", "-t", `=${name}`).trim() !== "";
2283
+ }
2284
+ catch {
2285
+ continue;
2286
+ }
2287
+ if (hasClient)
2288
+ continue;
2289
+ warn("view session", `${name} has no attached client, so it did not clean itself up (destroy-unattached should remove a view ` +
2290
+ "the instant its client detaches). Not touched here - a view session owns no panes, so removing it can " +
2291
+ `never lose a worker's output, but doctor's own posture stops at reporting. Remove it by hand: ` +
2292
+ `tmux kill-session -t '=${name}'`);
2293
+ }
2294
+ check("backups", () => {
2295
+ const health = backupHealth(db, dataDir);
2296
+ if (!health.ok)
2297
+ throw new Error(health.message);
2298
+ return health.message;
2299
+ });
2300
+ {
2301
+ const { value, source } = resolvedAutoAttach();
2302
+ info("auto-attach", source === "env"
2303
+ ? `${value} (HIVE_AUTO_ATTACH override; testing only)`
2304
+ : source === "config"
2305
+ ? `${value} (set with \`hive setup --auto-attach\`)`
2306
+ : `${value} (default; set with \`hive setup --auto-attach\`)`);
2307
+ }
2308
+ {
2309
+ const { mode, source } = resolvedAttachMode();
2310
+ info("attach mode", source === "env"
2311
+ ? `${mode} (HIVE_ATTACH_MODE override; testing only, does not reach auto-attach)`
2312
+ : source === "config"
2313
+ ? `${mode} (set with \`hive setup --attach\`)`
2314
+ : `${mode} (default; set with \`hive setup --attach\`)`);
2315
+ if (mode === "raw") {
2316
+ try {
2317
+ const owned = tmux("list-windows", "-a", "-F", "#{session_name}:#{window_id}\t#{@hive-owned}")
2318
+ .trim()
2319
+ .split("\n")
2320
+ .map((row) => row.split("\t"))
2321
+ .filter(([target, marker]) => {
2322
+ const [sess] = target.split(":");
2323
+ return target.startsWith(SESSION_PREFIX) && marker === "1" && !isViewSessionName(sess);
2324
+ });
2325
+ for (const [target] of owned) {
2326
+ const pane = tmux("list-panes", "-t", target, "-F", "#{pane_id}").trim().split("\n")[0];
2327
+ const option = (scope, optionName) => tmux("show-options", scope, "-A", "-v", "-t", scope === "-p" ? pane : target, optionName).trim();
2328
+ info(`tmux window ${target}`, `allow-passthrough ${option("-p", "allow-passthrough")}; ` +
2329
+ `pane-border-status ${option("-w", "pane-border-status")}; ` +
2330
+ `pane-border-format ${JSON.stringify(option("-w", "pane-border-format"))}; ` +
2331
+ `monitor-bell ${option("-w", "monitor-bell")}`);
2332
+ }
2333
+ }
2334
+ catch {
2335
+ }
2336
+ }
2337
+ }
2338
+ if (here) {
2339
+ const procs = snapshotProcesses(here.id);
2340
+ if (procs.length > 0) {
2341
+ const { running, hidden, unlocated, notStarted } = processCounts(procs);
2342
+ const located = unlocated > 0 ? `${hidden} hidden, ${unlocated} hive cannot locate` : `${hidden} hidden`;
2343
+ info("processes", `${running} running (${located}), ${notStarted} defined not running`);
2344
+ }
2345
+ }
2346
+ const reviewTags = config?.review_tags ?? [];
2347
+ // Never name hive.yml here: doctor stays silent about that file when nothing is wrong with it
2348
+ // (test/config-warnings.test.mjs), and this line prints on every ordinary run.
2349
+ if (here && reviewTags.length === 0) {
2350
+ info("review findings", "no review_tags configured; nothing to track.");
2351
+ }
2352
+ else if (here) {
2353
+ const findings = db
2354
+ .prepare(`SELECT id, tags, status, archived_at, ${COMMENT_COUNT_SQL} AS comment_count
2355
+ FROM todos t WHERE t.project_id = ?`)
2356
+ .all(here.id).filter((t) => parseTags(t.tags).some((tag) => isReviewFindingTag(reviewTags, tag)));
2357
+ const untriaged = findings.filter((t) => t.comment_count === 0 && t.status !== "completed" && t.archived_at == null);
2358
+ info("review findings", `${findings.length} tracked (tagged ${reviewTags.join(", ")}): ` +
2359
+ `${findings.length - untriaged.length} triaged, ${untriaged.length} untriaged.`);
2360
+ if (untriaged.length > 0) {
2361
+ warn("review findings", `${untriaged.length} untriaged: ${untriaged.map((t) => `todo ${t.id}`).join(", ")}.`, "Triage means a comment recording a decision, or completed/archived - comment with the outcome " +
2362
+ "(dispatched, rejected and why, etc.) on each before closing this wave.");
2363
+ }
2364
+ }
2365
+ const problems = failures + (strict ? gatingWarnings : 0);
2366
+ const tail = strict
2367
+ ? `${warnings} warning(s), ${gatingWarnings} promoted by --strict.`
2368
+ : `${warnings} warning(s).`;
2369
+ console.log(failures === 0 && warnings === 0 ? "\nAll good." : `\n${problems} problem(s) found, ${tail}`);
2370
+ process.exit(problems === 0 ? 0 : 1);
2371
+ }
2372
+ function cmdStatusline() {
2373
+ let project;
2374
+ try {
2375
+ project = pinnedOrCwdProject();
2376
+ }
2377
+ catch {
2378
+ return;
2379
+ }
2380
+ if (!project)
2381
+ return;
2382
+ const count = (sql) => db.prepare(sql).get(project.id).n;
2383
+ const agents = count("SELECT COUNT(*) AS n FROM agents WHERE project_id = ? AND status = 'running' AND kind = 'agent'");
2384
+ const commands = count("SELECT COUNT(*) AS n FROM agents WHERE project_id = ? AND status = 'running' AND kind = 'command'");
2385
+ const todos = count("SELECT COUNT(*) AS n FROM todos WHERE project_id = ? AND status IN ('open', 'in_progress') AND archived_at IS NULL");
2386
+ const ready = count(`SELECT COUNT(*) AS n FROM todos t
2387
+ WHERE t.project_id = ? AND t.status IN ('open', 'in_progress') AND t.archived_at IS NULL
2388
+ AND NOT EXISTS (${OPEN_BLOCKERS_SQL})`);
2389
+ const pads = count("SELECT COUNT(*) AS n FROM pads WHERE project_id = ? AND archived = 0");
2390
+ const wakes = count(`SELECT COUNT(*) AS n FROM wakes WHERE project_id = ? AND ${ACTIVE_TIMER_WHERE}`);
2391
+ // One statement, not two: the count and the chosen row must come from the same read, or a wake that
2392
+ // delivers/re-arms between two separate queries leaves the second with no row to read.
2393
+ const held = db
2394
+ .prepare(`WITH chosen AS (
2395
+ SELECT held_reason, first_held_at FROM wakes
2396
+ WHERE project_id = ? AND ${ACTIVE_TIMER_WHERE} AND held_at IS NOT NULL
2397
+ ORDER BY (held_reason LIKE ?) DESC, (held_reason LIKE ?) ASC,
2398
+ first_held_at IS NULL ASC, first_held_at ASC
2399
+ LIMIT 1
2400
+ )
2401
+ SELECT
2402
+ (SELECT COUNT(*) FROM wakes WHERE project_id = ? AND ${ACTIVE_TIMER_WHERE} AND held_at IS NOT NULL) AS n,
2403
+ (SELECT held_reason FROM chosen) AS held_reason,
2404
+ (SELECT first_held_at FROM chosen) AS first_held_at`)
2405
+ .get(project.id, `${HELD_REASON_UNSUBMITTED_INPUT_PREFIX}%`, `${HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX}%`, project.id);
2406
+ if (agents + commands + todos + pads + wakes === 0)
2407
+ return;
2408
+ const s = (n) => (n === 1 ? "" : "s");
2409
+ const parts = [
2410
+ `${agents} agent${s(agents)}`,
2411
+ `${todos} todo${s(todos)}${todos > 0 ? ` (${ready} ready)` : ""}`,
2412
+ `${pads} pad${s(pads)}`,
2413
+ ];
2414
+ if (commands > 0)
2415
+ parts.push(`${commands} cmd${s(commands)}`);
2416
+ if (wakes > 0)
2417
+ parts.push(`${wakes} wake${s(wakes)}`);
2418
+ if (held.n > 0) {
2419
+ const age = held.first_held_at ? humanizeAge(ageSecondsSince(held.first_held_at)) : "?";
2420
+ parts.push(`${held.n} held (${age}, ${heldReasonLabel(held.held_reason)})`);
2421
+ }
2422
+ console.log(`\x1b[33m⬡\x1b[0m \x1b[2mhive:\x1b[0m ${parts.join(" \x1b[2m·\x1b[0m ")}`);
2423
+ }
2424
+ // test/docs.test.mjs fails if a label here is missing from docs/install.md. It checks
2425
+ // presence only: docs/install.md also counts the set in prose, and nothing guards that number.
2426
+ export const HELD_REASON_LABELS = ["typing", "talking", "needs you", "blocked"];
2427
+ export function heldReasonLabel(heldReason) {
2428
+ if (isUnsubmittedInputHold(heldReason))
2429
+ return "typing";
2430
+ if (heldReason === HELD_REASON_LEAD_PANE_DEAD ||
2431
+ wasHeldForPaneReissue(heldReason) ||
2432
+ isUnclassifiablePaneHold(heldReason)) {
2433
+ return "needs you";
2434
+ }
2435
+ if (heldReason === HELD_REASON_CONVERSATION)
2436
+ return "talking";
2437
+ return "blocked";
2438
+ }
2439
+ function padSlug(name) {
2440
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "pad";
2441
+ }
2442
+ const padExportPrefix = (projectId, name) => `hive-pad-${projectId}-${padSlug(name)}.r`;
2443
+ function findPadExports(projectId, name) {
2444
+ const prefix = padExportPrefix(projectId, name);
2445
+ return readdirSync(tmpdir())
2446
+ .filter((f) => f.startsWith(prefix) && f.endsWith(".md"))
2447
+ .map((f) => join(tmpdir(), f));
2448
+ }
2449
+ function cmdBackups() {
2450
+ const snapshots = listSnapshots(dataDir);
2451
+ if (snapshots.length === 0) {
2452
+ console.log(`No backups yet in ${backupsDir(dataDir)}.`);
2453
+ console.log("They are taken automatically before migrations and hourly while any hive session is open.");
2454
+ return;
2455
+ }
2456
+ for (const s of snapshots) {
2457
+ console.log(`${s.name} ${s.reason.padEnd(9)} ${formatBytes(s.sizeBytes).padStart(7)} ${s.createdAt.toISOString()}`);
2458
+ }
2459
+ console.log(`\n${snapshots.length} backup(s), ${formatBytes(totalSizeBytes(snapshots))} total, in ${backupsDir(dataDir)}`);
2460
+ console.log(`Restore one with: hive restore <name>`);
2461
+ }
2462
+ function activeHiveUsage() {
2463
+ const reasons = [];
2464
+ const runningNonLeads = db.prepare("SELECT COUNT(*) AS c FROM agents WHERE status = 'running' AND kind != ?").get(LEAD_KIND).c;
2465
+ if (runningNonLeads > 0)
2466
+ reasons.push(`${runningNonLeads} agent(s)/command(s) recorded as running`);
2467
+ const leadRows = db.prepare("SELECT COUNT(*) AS c FROM agents WHERE status = 'running' AND kind = ?").get(LEAD_KIND).c;
2468
+ if (leadRows > 0) {
2469
+ reasons.push(`${leadRows} lead session(s) recorded as running - run \`hive doctor\` to check whether each is ` +
2470
+ "actually live; a confirmed-dead one can be retired by asking a claude session connected to this " +
2471
+ "project's hive MCP server to call the agent_close tool on it, which lets a later restore proceed " +
2472
+ "without --force. End that session before restoring either way, since it holds this store open too.");
2473
+ }
2474
+ try {
2475
+ const sessions = tmux("ls", "-F", "#{session_name}")
2476
+ .trim()
2477
+ .split("\n")
2478
+ .filter((s) => s.startsWith(SESSION_PREFIX) && !isViewSessionName(s));
2479
+ if (sessions.length > 0)
2480
+ reasons.push(`tmux session(s) still running: ${sessions.join(", ")}`);
2481
+ }
2482
+ catch (e) {
2483
+ if (!tmuxSaysNothingThere(e)) {
2484
+ reasons.push("tmux did not answer, so whether hive sessions are still running is UNKNOWN - the server may be " +
2485
+ `wedged, or the probe failed for another reason: ${errorMessage(e)}. A restore ` +
2486
+ "overwrites this store, so an unanswered probe blocks rather than passes. Confirm by hand with " +
2487
+ "`tmux display-message -p '#{socket_path}'` and `tmux -S <path> list-sessions`; reap a wedged " +
2488
+ "server before restoring, or pass --force if you are certain nothing is using this store.");
2489
+ }
2490
+ }
2491
+ return reasons;
2492
+ }
2493
+ async function cmdRestore(argv) {
2494
+ const parsed = parseArgs(argv, { flags: ["--yes", "-y", "--force"] });
2495
+ rejectUnknownFlags("restore", parsed, "--yes/-y and --force");
2496
+ const yes = parsed.flags.has("--yes") || parsed.flags.has("-y");
2497
+ const force = parsed.flags.has("--force");
2498
+ const name = parsed.positional[0];
2499
+ if (!name) {
2500
+ console.log("Usage: hive restore <name> [--yes] [--force]");
2501
+ console.log("List available snapshots with: hive backups");
2502
+ process.exit(1);
2503
+ }
2504
+ let preview;
2505
+ try {
2506
+ preview = previewRestore(dataDir, name);
2507
+ }
2508
+ catch (e) {
2509
+ console.log(errorMessage(e));
2510
+ process.exit(1);
2511
+ }
2512
+ const activity = activeHiveUsage();
2513
+ if (activity.length > 0 && !force) {
2514
+ console.log("Refusing to restore: this store looks like it is still in use.");
2515
+ for (const reason of activity)
2516
+ console.log(` ${reason}`);
2517
+ console.log("Close those sessions first, or pass --force: a server on this version will refuse every " +
2518
+ "hive tool once it notices (up to one tick of writes lost first); an older server has no " +
2519
+ "such guard and will keep writing to a file that no longer exists until it exits, losing " +
2520
+ "that work silently.");
2521
+ process.exit(1);
2522
+ }
2523
+ console.log("This will overwrite:");
2524
+ console.log(` ${preview.currentDbPath}` +
2525
+ (preview.currentDbExists ? ` (${formatBytes(preview.currentDbSizeBytes)})` : " (does not exist yet)"));
2526
+ if (preview.hasProfiles)
2527
+ console.log(` ${join(dataDir, "profiles")}`);
2528
+ console.log(`with the snapshot "${preview.snapshot.name}" (${formatBytes(preview.snapshot.sizeBytes)}, ` +
2529
+ `${preview.snapshot.reason}, taken ${preview.snapshot.createdAt.toISOString()}).`);
2530
+ console.log("\nRestart every other hive session using this store once this completes.");
2531
+ if (!yes) {
2532
+ const confirmed = await confirmYesNo("Overwrite the live store with this snapshot? [y/N] ");
2533
+ if (confirmed === null) {
2534
+ console.log("Not restored: run hive interactively to confirm, or pass --yes.");
2535
+ return;
2536
+ }
2537
+ if (!confirmed) {
2538
+ console.log("Not restored.");
2539
+ return;
2540
+ }
2541
+ }
2542
+ const preRestoreBackup = backupNow(db, dataDir, "manual", new Set([preview.snapshot.name]));
2543
+ if (preRestoreBackup.ok) {
2544
+ console.log(`Snapshotted the current store first: ${preRestoreBackup.path}`);
2545
+ }
2546
+ else {
2547
+ console.log(`Warning: could not snapshot the current store before restoring: ${preRestoreBackup.error}`);
2548
+ }
2549
+ db.close();
2550
+ const { restoredProfiles } = restoreSnapshot(dataDir, name);
2551
+ console.log(`Restored hive.db from "${name}"${restoredProfiles ? " (and profiles/)" : ""}.`);
2552
+ }
2553
+ function flagValue(argv, flag) {
2554
+ const i = argv.indexOf(flag);
2555
+ return i !== -1 ? argv[i + 1] : undefined;
2556
+ }
2557
+ function parseArgs(argv, spec) {
2558
+ const knownFlags = new Set(spec.flags ?? []);
2559
+ const knownValued = new Set(spec.valued ?? []);
2560
+ const flags = new Set();
2561
+ const values = new Map();
2562
+ const positional = [];
2563
+ const unknown = [];
2564
+ const missingValue = [];
2565
+ const consumedAsValue = new Set();
2566
+ let endOfFlags = false;
2567
+ for (let i = 0; i < argv.length; i++) {
2568
+ if (consumedAsValue.has(i))
2569
+ continue;
2570
+ const a = argv[i];
2571
+ if (endOfFlags) {
2572
+ positional.push(a);
2573
+ }
2574
+ else if (a === "--") {
2575
+ endOfFlags = true;
2576
+ }
2577
+ else if (knownValued.has(a)) {
2578
+ const next = argv[i + 1];
2579
+ const nextIsFlagOrSeparator = next === "--" || knownFlags.has(next) || knownValued.has(next);
2580
+ if (nextIsFlagOrSeparator) {
2581
+ if (!values.has(a))
2582
+ missingValue.push({ flag: a, got: next });
2583
+ }
2584
+ else {
2585
+ if (!values.has(a))
2586
+ values.set(a, next);
2587
+ consumedAsValue.add(i + 1);
2588
+ }
2589
+ }
2590
+ else if (knownFlags.has(a)) {
2591
+ flags.add(a);
2592
+ }
2593
+ else if (a.startsWith("-")) {
2594
+ unknown.push(a);
2595
+ }
2596
+ else {
2597
+ positional.push(a);
2598
+ }
2599
+ }
2600
+ return { positional, flags, values, unknown, missingValue };
2601
+ }
2602
+ function rejectUnknownFlags(command, parsed, flagsText) {
2603
+ if (parsed.unknown.length === 0)
2604
+ return;
2605
+ console.error(`hive ${command}: unknown argument "${parsed.unknown[0]}". Flags are ${flagsText}.`);
2606
+ process.exit(1);
2607
+ }
2608
+ function requireFlagValues(command, parsed) {
2609
+ if (parsed.missingValue.length === 0)
2610
+ return;
2611
+ const { flag, got } = parsed.missingValue[0];
2612
+ console.error(`hive ${command}: ${flag} requires a value (got ${got})`);
2613
+ process.exit(1);
2614
+ }
2615
+ function cmdTodos(argv) {
2616
+ const project = pinnedOrCwdProject();
2617
+ if (!project)
2618
+ return;
2619
+ const all = argv.includes("--all");
2620
+ const statusIdx = argv.indexOf("--status");
2621
+ const status = flagValue(argv, "--status");
2622
+ if (statusIdx !== -1 && (status === undefined || status.startsWith("--"))) {
2623
+ console.log(`Usage: hive todos --status <s> where <s> is one of: ${TODO_STATUSES.join(", ")}`);
2624
+ process.exit(1);
2625
+ }
2626
+ if (status !== undefined && !TODO_STATUSES.includes(status)) {
2627
+ console.log(`Unknown status "${status}". Valid: ${TODO_STATUSES.join(", ")}`);
2628
+ process.exit(1);
2629
+ }
2630
+ const tagIdx = argv.indexOf("--tag");
2631
+ const tag = flagValue(argv, "--tag");
2632
+ if (tagIdx !== -1 && (tag === undefined || tag.startsWith("--"))) {
2633
+ console.log("Usage: hive todos --tag <t>");
2634
+ process.exit(1);
2635
+ }
2636
+ const statuses = all ? undefined : status ? [status] : ["open", "in_progress"];
2637
+ const { todos, total_count } = listTodoSummaries(project.id, {
2638
+ statuses,
2639
+ tags: tag ? [tag] : undefined,
2640
+ });
2641
+ if (todos.length === 0) {
2642
+ const statusDesc = all ? "" : status ? `${status} ` : "open ";
2643
+ const tagDesc = tag ? ` with tag "${tag}"` : "";
2644
+ const hint = all ? "" : " Try --all.";
2645
+ console.log(`No ${statusDesc}todos in project "${project.name}"${tagDesc}.${hint}`);
2646
+ return;
2647
+ }
2648
+ const slugById = new Map(db
2649
+ .prepare(`SELECT id, slug FROM todos WHERE project_id = ? AND id IN (${todos.map(() => "?").join(",")})`)
2650
+ .all(project.id, ...todos.map((t) => t.todo_id)).map((r) => [r.id, r.slug]));
2651
+ const width = Math.max(...todos.map((t) => String(t.todo_id).length));
2652
+ for (const t of todos) {
2653
+ const blocked = t.is_blocked ? "blocked" : "";
2654
+ const slug = slugById.get(t.todo_id);
2655
+ console.log(`#${String(t.todo_id).padEnd(width)} ${t.status.padEnd(11)} ${blocked.padEnd(8)} ${slug ? `[${slug}] ` : ""}${t.title}`);
2656
+ }
2657
+ if (total_count > todos.length) {
2658
+ console.log(`\n...and ${total_count - todos.length} more not shown. Narrow with --status or --tag.`);
2659
+ }
2660
+ }
2661
+ function cmdTodo(argv) {
2662
+ const project = pinnedOrCwdProject();
2663
+ if (!project)
2664
+ return;
2665
+ const id = Number(argv.find((a) => !a.startsWith("--")));
2666
+ if (!Number.isInteger(id)) {
2667
+ console.log("Usage: hive todo <id> (run inside the project)");
2668
+ process.exit(1);
2669
+ }
2670
+ let d;
2671
+ try {
2672
+ d = getTodoDetail(project.id, id, true);
2673
+ }
2674
+ catch {
2675
+ console.log(`No todo with id ${id} in project "${project.name}". List them with: hive todos --all`);
2676
+ process.exit(1);
2677
+ }
2678
+ const rawSlug = db.prepare("SELECT slug FROM todos WHERE id = ?").get(d.todo_id)?.slug;
2679
+ console.log(`#${d.todo_id} ${rawSlug ? `[${rawSlug}] ` : ""}${d.title}`);
2680
+ console.log(`status ${d.status} priority ${d.priority}${d.is_blocked ? " blocked" : ""}${d.archived ? " archived" : ""}`);
2681
+ if (d.tags.length > 0)
2682
+ console.log(`tags ${d.tags.join(", ")}`);
2683
+ if (d.body)
2684
+ console.log(`\n${d.body}`);
2685
+ if (d.blockers.length > 0) {
2686
+ console.log(`\nblocked by:`);
2687
+ for (const b of d.blockers)
2688
+ console.log(` #${b.id} [${b.status}] ${b.title}`);
2689
+ }
2690
+ if (d.blocking.length > 0) {
2691
+ console.log(`\nblocks:`);
2692
+ for (const b of d.blocking)
2693
+ console.log(` #${b.id} [${b.status}] ${b.title}`);
2694
+ }
2695
+ const comments = d.comments ?? [];
2696
+ if (comments.length > 0) {
2697
+ console.log(`\ncomments:`);
2698
+ for (const c of comments) {
2699
+ console.log(`\n[${c.author} @ ${c.created_at}]`);
2700
+ console.log(c.body);
2701
+ }
2702
+ }
2703
+ }
2704
+ function cmdPads() {
2705
+ const project = resolveProject();
2706
+ const pads = listActivePads(project.id);
2707
+ if (pads.length === 0) {
2708
+ console.log(`No pads in project "${project.name}".`);
2709
+ return;
2710
+ }
2711
+ const width = Math.max(...pads.map((p) => p.name.length));
2712
+ for (const p of pads) {
2713
+ console.log(`${p.name.padEnd(width)} rev ${String(p.revision).padStart(3)} ${String(p.content_length).padStart(6)} chars ${p.updated_by ?? "?"} @ ${p.updated_at}`);
2714
+ }
2715
+ }
2716
+ function cmdPad(argv) {
2717
+ const parsed = parseArgs(argv, { flags: ["--edit", "--save"] });
2718
+ rejectUnknownFlags("pad", parsed, "--edit and --save");
2719
+ const name = parsed.positional[0];
2720
+ if (!name) {
2721
+ console.log("Usage: hive pad <name> [--edit | --save [file]] (run inside the project)");
2722
+ process.exit(1);
2723
+ }
2724
+ const project = resolveProject();
2725
+ const pad = getActivePadByName(project.id, name);
2726
+ if (!pad) {
2727
+ console.log(`No pad named "${name}" in project "${project.name}". List them with: hive pads`);
2728
+ process.exit(1);
2729
+ }
2730
+ if (parsed.flags.has("--edit")) {
2731
+ const existing = findPadExports(project.id, name);
2732
+ if (existing.length > 0) {
2733
+ console.log(`An unsaved export already exists:\n ${existing.join("\n ")}`);
2734
+ console.log(`Save it with: hive pad "${name}" --save (or delete the file to discard)`);
2735
+ process.exit(1);
2736
+ }
2737
+ const file = join(tmpdir(), `${padExportPrefix(project.id, name)}${pad.revision}.md`);
2738
+ try {
2739
+ writeFileSync(file, pad.content, { flag: "wx", mode: 0o600 });
2740
+ }
2741
+ catch (e) {
2742
+ if (e.code === "EEXIST") {
2743
+ console.log(`A file already exists at ${file}; refusing to overwrite it. Save or delete it first.`);
2744
+ process.exit(1);
2745
+ }
2746
+ throw e;
2747
+ }
2748
+ const editor = process.env.HIVE_EDITOR || "open";
2749
+ const [cmd, ...cmdArgs] = editor.split(/\s+/);
2750
+ spawn(cmd, [...cmdArgs, file], { detached: true, stdio: "ignore" }).unref();
2751
+ console.log(file);
2752
+ console.log(`Opened "${name}" (rev ${pad.revision}) with ${cmd}. After saving your edits, write back with:`);
2753
+ console.log(` hive pad "${name}" --save`);
2754
+ return;
2755
+ }
2756
+ if (parsed.flags.has("--save")) {
2757
+ let file = parsed.positional[1];
2758
+ if (!file) {
2759
+ const matches = findPadExports(project.id, name);
2760
+ if (matches.length === 0) {
2761
+ console.log(`No exported file for "${name}" in ${tmpdir()}. Export one with: hive pad "${name}" --edit`);
2762
+ process.exit(1);
2763
+ }
2764
+ if (matches.length > 1) {
2765
+ console.log(`Multiple exports found; pass one explicitly:\n ${matches.join("\n ")}`);
2766
+ process.exit(1);
2767
+ }
2768
+ file = matches[0];
2769
+ }
2770
+ const content = readFileSync(file, "utf8");
2771
+ const marker = /\.r(\d+)\.md$/.exec(file);
2772
+ const expected = marker ? Number(marker[1]) : pad.revision;
2773
+ if (content === pad.content) {
2774
+ unlinkSync(file);
2775
+ console.log(`No changes; "${name}" left at rev ${pad.revision}. Removed ${file}.`);
2776
+ return;
2777
+ }
2778
+ try {
2779
+ const newRevision = overwritePadContent(project.id, pad.id, content, expected);
2780
+ unlinkSync(file);
2781
+ console.log(`Saved "${name}": rev ${expected} -> ${newRevision}. Removed ${file}.`);
2782
+ }
2783
+ catch (e) {
2784
+ console.log(errorMessage(e));
2785
+ console.log(`Your edits are untouched in ${file}.`);
2786
+ console.log(`Someone changed the pad since the export. Compare with: hive pad "${name}"`);
2787
+ console.log(`Then merge into the file and save with: hive pad "${name}" --save ${file}`);
2788
+ process.exit(1);
2789
+ }
2790
+ return;
2791
+ }
2792
+ process.stdout.write(withTrailingNewline(pad.content));
2793
+ }
2794
+ const args = process.argv.slice(2);
2795
+ let command = args[0] ?? "lead";
2796
+ let rest = args.slice(1);
2797
+ if (command === "--help" || command === "-h" || command === "help")
2798
+ usage();
2799
+ if (command === "--version" || command === "-v") {
2800
+ const { line, drift } = versionInfo();
2801
+ console.log(line);
2802
+ if (drift)
2803
+ console.log(`! ${drift}`);
2804
+ process.exit(0);
2805
+ }
2806
+ const COMMANDS = [
2807
+ "lead", "init", "attach", "start", "stop", "show", "hide", "status", "setup", "doctor",
2808
+ LEAD_PANE_EXITED_VERB,
2809
+ "pads", "pad", "todos", "todo", "backups", "restore", "runbook", "posture", "profile", "kickoff", "statusline",
2810
+ ];
2811
+ if (!COMMANDS.includes(command)) {
2812
+ if (command.startsWith("--")) {
2813
+ rest = args;
2814
+ command = "lead";
2815
+ }
2816
+ else if (existsSync(command)) {
2817
+ rest = [command, ...rest];
2818
+ command = "lead";
2819
+ }
2820
+ else {
2821
+ usage();
2822
+ }
2823
+ }
2824
+ migrate();
2825
+ try {
2826
+ switch (command) {
2827
+ case "lead":
2828
+ await cmdLead(rest);
2829
+ break;
2830
+ case "init":
2831
+ await cmdInit(rest);
2832
+ break;
2833
+ case "attach":
2834
+ cmdAttach(rest);
2835
+ break;
2836
+ case "start":
2837
+ await cmdStart(rest);
2838
+ break;
2839
+ case "stop":
2840
+ cmdStop(rest);
2841
+ break;
2842
+ case LEAD_PANE_EXITED_VERB:
2843
+ cmdLeadPaneExited(rest);
2844
+ break;
2845
+ case "show":
2846
+ cmdShow(rest);
2847
+ break;
2848
+ case "hide":
2849
+ cmdHide(rest);
2850
+ break;
2851
+ case "status":
2852
+ cmdStatus();
2853
+ break;
2854
+ case "setup":
2855
+ cmdSetup(rest);
2856
+ break;
2857
+ case "doctor":
2858
+ cmdDoctor(rest);
2859
+ break;
2860
+ case "pads":
2861
+ cmdPads();
2862
+ break;
2863
+ case "pad":
2864
+ cmdPad(rest);
2865
+ break;
2866
+ case "todos":
2867
+ cmdTodos(rest);
2868
+ break;
2869
+ case "todo":
2870
+ cmdTodo(rest);
2871
+ break;
2872
+ case "backups":
2873
+ cmdBackups();
2874
+ break;
2875
+ case "restore":
2876
+ await cmdRestore(rest);
2877
+ break;
2878
+ case "runbook":
2879
+ cmdRunbook(rest[0]);
2880
+ break;
2881
+ case "posture":
2882
+ cmdPosture(rest[0]);
2883
+ break;
2884
+ case "profile":
2885
+ cmdProfile(rest);
2886
+ break;
2887
+ case "kickoff":
2888
+ await (await import("./kickoff.js")).runKickoff(rest);
2889
+ break;
2890
+ case "statusline":
2891
+ cmdStatusline();
2892
+ break;
2893
+ default:
2894
+ console.log(`hive: no handler registered for command "${command}"`);
2895
+ process.exit(1);
2896
+ }
2897
+ }
2898
+ catch (e) {
2899
+ console.log(errorMessage(e));
2900
+ process.exit(1);
2901
+ }