@cmgmyr/hive 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync, spawn, spawnSync } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
- import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from "node:fs";
4
+ import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from "node:fs";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { join, resolve, sep } from "node:path";
7
7
  import { createInterface } from "node:readline/promises";
@@ -12,16 +12,18 @@ import { claudeConfigDir } from "./claudeDir.js";
12
12
  import { ATTACH_MODES, AUTO_ATTACH_MODES, attachMode, isAttachMode, isAutoAttach, migrateLegacyConfig, resolvedAutoAttach, setAttachMode, setAutoAttach, } from "./config.js";
13
13
  import { cliPath, dispatcherDir, dispatcherPath, dispatcherScript, durabilityLines, firstHiveOnPath, linkedWorktreePin, pathAdvice, readDispatcher, } from "./dispatcher.js";
14
14
  import { codexConfigPath, codexHiveRegistrations, codexRegistrationProblem, hiveRegistrations, registrationOffer, registrationProblem, } from "./mcpConfig.js";
15
- import { DEFAULT_DATA_DIR } from "./dataDir.js";
15
+ import { DEFAULT_DATA_DIR, storeDir } from "./dataDir.js";
16
16
  import { dataDir, db, migrate, storeSchemaAhead } from "./db.js";
17
17
  import { cacheIsStale, queryUpdate, readCachedUpdate, refreshUpdate, shouldAutoRefresh, updateLine } from "./updateCheck.js";
18
18
  import { checkoutUpgradeSteps, detectInstallShape, globalUpgradeSteps } from "./upgrade.js";
19
19
  import { isLowHeadroom, orphanLoginShellDetails, ptyHeadroom } from "./ptys.js";
20
- import { addProject, agentProjectPin, currentActor, effectiveProjectId, findProjectForCwd, getProjectByPath, gitPrimaryRoot, getProject, listProjects, registeredAncestor, takeRegistrationNotice, } from "./context.js";
20
+ import { addProject, agentProjectPin, assertQueenCrossProjectWrite, captureQueenWriteIdentity, currentActor, effectiveProjectId, findProjectForCwd, findProjectForDir, getProjectByPath, gitPrimaryRoot, getProject, listProjects, QUEEN_PROFILE, queenHomeDir, registeredAncestor, takeRegistrationNotice, } from "./context.js";
21
+ import { confirmQueenWrite, listQueenAudit } from "./queenAudit.js";
21
22
  import { ensureHooksFile } from "./hooks.js";
22
23
  import { errorMessage, registrationNoticeText, withTrailingNewline } from "./result.js";
23
- 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";
24
+ 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, generateQueenDashboardNow, transcriptStaleness, wasHeldForPaneReissue, } from "./scheduler.js";
24
25
  import { readTurnCount } from "./turnCount.js";
26
+ import { collectPortfolio, leadText } from "./portfolio.js";
25
27
  import { STALL_BOUND_SECONDS } from "./backgroundTasks.js";
26
28
  import { probeSessionInterpreter, reexecTarget, sessionStartVerdict, } from "./sessionProbe.js";
27
29
  import { backupHealth, backupNow, backupsDir, formatBytes, listSnapshots, previewRestore, restoreSnapshot, totalSizeBytes, } from "./backup.js";
@@ -30,11 +32,11 @@ import { asNameClash, buildEnvFlags, closeAgentRow, isReservedAgentName, launchA
30
32
  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";
31
33
  import { describeVisibility, processCounts } from "./dashboard.js";
32
34
  import { runningCommandRow, runningCommandRows, snapshotProcesses, stopAllProcesses, stopLine, stopProcess, STOP_REASONS, } from "./processes.js";
33
- import { activeProfile, agentVarKeys, BUILT_IN_PROJECT_YML, configHash, loadProjectYml, mergedProjectVars, NO_PROFILE, PROJECT_YML_KEYS, resolveCommandDir, } from "./projectYml.js";
35
+ import { activeProfile, agentVarKeys, BUILT_IN_PROJECT_YML, configHash, loadProjectYml, mergedProjectVars, NO_PROFILE, PROJECT_YML_KEYS, resolveCommandDir, resolveFirstMessage, } from "./projectYml.js";
34
36
  import { writeProjectPosture } from "./brief.js";
35
37
  import { carriesNameFlag, harnessFor, hasTranscriptSignal, paneClassifierFor, transcriptDirFor } from "./harnesses.js";
36
38
  import { codexHomeDir, codexInstructionsPhrase, ensureCodexHome, reapCodexHome } from "./codexHome.js";
37
- import { TRIAGE_MESSAGE } from "./kickoff.js";
39
+ import { FIRST_MESSAGE_SHA_ENV, firstMessageDigest } from "./firstMessage.js";
38
40
  import { ageSecondsSince, deriveProvenance, describeForHuman, describeLastLogEvent, humanizeAge, lastLogEvent, lastPermissionMode, reportsAgentStateLog, } from "./stateProvenance.js";
39
41
  import { awaitingFirstPromptSql } from "./firstPrompt.js";
40
42
  import { checkoutRoot, createProfile, forkProfile, PROFILE_FILES, ProfileError, profileExists, profileFileNames, profileNames, profileStatus, readProfileFile, referencedPads, referencedPaths, renderProfileFile, resolveProfileFile, REWRITE_THRESHOLD, templateVars, userProfilesDir, } from "./profiles.js";
@@ -46,11 +48,16 @@ function usage() {
46
48
  Usage:
47
49
  hive --version version, short sha, and dirty marker for this build
48
50
  hive [path] open the project's session with a lead window
49
- hive lead [path] [--no-dashboard]
51
+ hive lead [path] [--no-dashboard] [--detach]
50
52
  same; lead is the default command. --no-dashboard
51
53
  skips this run's dashboard auto-open -
52
54
  scripts/restart-lead.sh passes it; a human rarely
53
- needs to
55
+ needs it. --detach starts or adopts the lead and
56
+ prints its pane and attach command without attaching
57
+ hive queen [--no-dashboard]
58
+ start, or reattach to, the lead that reads across
59
+ every registered project; its home is
60
+ <data dir>/queen, created on first run
54
61
  hive init [path] [--profile <name>|--no-profile]
55
62
  set the project up: hive.yml, profile, starter pads
56
63
  hive attach [path] attach without adding windows
@@ -59,6 +66,14 @@ Usage:
59
66
  hive show <process> [path] move a running process's pane beside the lead
60
67
  hive hide <process> [path] move it back into the <project>/processes window
61
68
  hive status overview of agents, todos, and wake-ups everywhere
69
+ hive portfolio [--json] one row per registered project: lane, lead, workers,
70
+ todos, needs-human count, wakes; read-only
71
+ hive queen-audit confirmed queen writes, newest first; 30-day history
72
+ [--project-id <id>] filter the target project (queen only across projects)
73
+ [--limit <n>] [--json] default 20, maximum 100; JSON prints one object
74
+ hive next [--print] attach to the lead of the project that needs you most
75
+ (waiting on you, then stuck), starting a dead or
76
+ missing lead first; --print only names the choice
62
77
  hive upgrade [--check] upgrade a global npm install; preview with --check
63
78
  hive upgrade --run run the printed pull/install/build/setup checkout recipe
64
79
  hive setup [--dir <dir>] write a \`hive\` that runs the interpreter this build
@@ -124,6 +139,27 @@ function resolveProject(path, onNotice) {
124
139
  const pinnedProject = getProject(pinned);
125
140
  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.`);
126
141
  }
142
+ function resolveProjectForWrite(verb, path, onNotice) {
143
+ assertQueenCrossProjectWrite(verb, findProjectForDir(path ?? process.cwd())?.id ?? null, {});
144
+ return resolveProject(path, onNotice);
145
+ }
146
+ function resolveProjectForRead(verb, path) {
147
+ if (path) {
148
+ process.chdir(path);
149
+ const pinned = agentProjectPin();
150
+ if (pinned != null) {
151
+ const target = findProjectForCwd();
152
+ if (target == null || target.id !== pinned) {
153
+ throw new Error(`This session is locked to project ${pinned} ("${getProject(pinned).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.`);
154
+ }
155
+ }
156
+ }
157
+ const project = pinnedOrCwdProject();
158
+ if (project)
159
+ return project;
160
+ console.error(`hive ${verb}: this directory is not a registered hive project. cd to a registered checkout, or run \`hive init\` here to register it.`);
161
+ process.exit(1);
162
+ }
127
163
  function pinnedOrCwdProject() {
128
164
  const pinned = agentProjectPin();
129
165
  if (pinned != null)
@@ -139,12 +175,9 @@ async function confirmYesNo(question) {
139
175
  return /^y(es)?$/i.test(answer.trim());
140
176
  }
141
177
  async function ensureTrusted(projectId, name, command, dir, env) {
142
- const hash = configHash(name, command, dir, env);
143
- const trusted = db
144
- .prepare("SELECT 1 FROM command_trust WHERE project_id = ? AND name = ? AND config_hash = ?")
145
- .get(projectId, name, hash);
146
- if (trusted)
178
+ if (isTrusted(projectId, name, command, dir, env))
147
179
  return true;
180
+ const hash = configHash(name, command, dir, env);
148
181
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
149
182
  console.log(`! "${name}" is not trusted yet; run hive interactively to review it.`);
150
183
  return false;
@@ -162,6 +195,12 @@ async function ensureTrusted(projectId, name, command, dir, env) {
162
195
  db.prepare("INSERT OR IGNORE INTO command_trust (project_id, name, config_hash) VALUES (?, ?, ?)").run(projectId, name, hash);
163
196
  return true;
164
197
  }
198
+ function isTrusted(projectId, name, command, dir, env) {
199
+ const hash = configHash(name, command, dir, env);
200
+ return !!db
201
+ .prepare("SELECT 1 FROM command_trust WHERE project_id = ? AND name = ? AND config_hash = ?")
202
+ .get(projectId, name, hash);
203
+ }
165
204
  function startYmlCommand(project, name, proc, config) {
166
205
  if (isReservedAgentName(name)) {
167
206
  return `skipped: "lead" is reserved for this project's lead session and cannot be used as a process name`;
@@ -318,24 +357,39 @@ function reportMigrationResult(result) {
318
357
  console.error(`! ${warning}`);
319
358
  }
320
359
  async function cmdLead(argv) {
321
- const unknownFlag = argv.find((a) => a.startsWith("--") && a !== "--no-dashboard");
360
+ const unknownFlag = argv.find((a) => a.startsWith("--") && a !== "--no-dashboard" && a !== "--detach");
322
361
  if (unknownFlag !== undefined) {
323
- console.error(`hive lead: unknown flag "${unknownFlag}". The only flag is --no-dashboard.`);
362
+ console.error(`hive lead: unknown flag "${unknownFlag}". Flags are --no-dashboard and --detach.`);
324
363
  process.exit(1);
325
364
  }
326
365
  const noDashboard = argv.includes("--no-dashboard");
366
+ const detach = argv.includes("--detach");
327
367
  const path = argv.find((a) => !a.startsWith("--"));
328
368
  let registrationNotice = null;
329
369
  try {
330
- const project = resolveProject(path, (text) => {
370
+ const project = resolveProjectForWrite("hive lead", path, (text) => {
331
371
  registrationNotice = text;
332
372
  });
373
+ const auditIdentity = captureQueenWriteIdentity(project.id);
374
+ const { config, warnings, sources } = loadProjectYml(project.path);
375
+ if (detach && (!process.stdin.isTTY || !process.stdout.isTTY)) {
376
+ let untrusted;
377
+ if (config?.lead && !isTrusted(project.id, "lead", config.lead, null, {}))
378
+ untrusted = "lead";
379
+ if (!untrusted && config) {
380
+ untrusted = Object.entries(config.processes).find(([name, proc]) => proc.auto_start !== false && !isTrusted(project.id, name, proc.command, proc.dir, proc.env))?.[0];
381
+ }
382
+ if (untrusted) {
383
+ console.error(`hive lead: "${untrusted}" is not trusted; run hive lead <path> interactively once`);
384
+ process.exitCode = 1;
385
+ return;
386
+ }
387
+ }
333
388
  reportMigrationResult(migrateLegacyConfig());
334
- const session = sessionName();
335
- const hooksPath = ensureHooksFile();
336
- const { config, warnings } = loadProjectYml(project.path);
337
389
  for (const w of warnings)
338
390
  console.log(`! ${w}`);
391
+ const session = sessionName();
392
+ const hooksPath = ensureHooksFile();
339
393
  let leadCommand = "claude";
340
394
  if (!config) {
341
395
  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).");
@@ -349,6 +403,10 @@ async function cmdLead(argv) {
349
403
  }
350
404
  }
351
405
  const leadHarness = harnessFor(leadCommand);
406
+ let firstMessage = resolveFirstMessage(config, sources).message;
407
+ if (firstMessage !== "" && !(await (await import("./kickoff.js")).kickoffGate(project.path)).ok)
408
+ firstMessage = "";
409
+ let promptSuffix = "";
352
410
  // Posture is rendered once, then delivered through whichever channel this harness has: a
353
411
  // claude lead gets it as --append-system-prompt-file below; a codex lead gets the identical
354
412
  // text as config.toml's developer_instructions, built after ensureLeadRow (it needs leadActorId).
@@ -386,6 +444,9 @@ async function cmdLead(argv) {
386
444
  leadCommand += ` ${leadHarness.briefDelivery.systemPromptArgs(posturePath).map(shellQuote).join(" ")}`;
387
445
  console.log(`- profile: ${profile} (${postureSource} posture; see it with: hive posture)`);
388
446
  }
447
+ if (leadHarness.initialPromptArgs && firstMessage !== "") {
448
+ promptSuffix = ` ${leadHarness.initialPromptArgs(firstMessage).map(shellQuote).join(" ")}`;
449
+ }
389
450
  }
390
451
  const windowName = project.name;
391
452
  const { agentId: leadAgentId, actorId: leadActorId, previousTarget, previousSocket, previousPanePid, previousCommand, previousCodexHome, casExpected, } = ensureLeadRow(project, leadCommand);
@@ -430,8 +491,8 @@ async function cmdLead(argv) {
430
491
  throw e;
431
492
  }
432
493
  leadCommand += ` ${homeArgs.map(shellQuote).join(" ")}`;
433
- if (leadHarness.initialPromptArgs) {
434
- leadCommand += ` ${leadHarness.initialPromptArgs(TRIAGE_MESSAGE).map(shellQuote).join(" ")}`;
494
+ if (leadHarness.initialPromptArgs && firstMessage !== "") {
495
+ promptSuffix = ` ${leadHarness.initialPromptArgs(firstMessage).map(shellQuote).join(" ")}`;
435
496
  }
436
497
  if (renderedPosture !== null) {
437
498
  console.log(`- profile: ${profile} (${postureSource} posture; see it with: hive posture)`);
@@ -442,13 +503,15 @@ async function cmdLead(argv) {
442
503
  }
443
504
  const envFlags = buildEnvFlags({
444
505
  ...leadEnv,
506
+ [FIRST_MESSAGE_SHA_ENV]: promptSuffix !== "" ? firstMessageDigest(firstMessage) : "",
445
507
  ...(newCodexHomeKey ? { CODEX_HOME: codexHomeDir(newCodexHomeKey) } : {}),
446
508
  });
509
+ const launchCommand = leadCommand + promptSuffix;
447
510
  const { leadPane, leadWindow, createdPane } = withWindowClaim(() => {
448
511
  let leadPane;
449
512
  let leadWindow;
450
513
  let createdPane;
451
- const started = ensureSession(session, project.path, { envFlags, command: leadCommand });
514
+ const started = ensureSession(session, project.path, { envFlags, command: launchCommand });
452
515
  if (started.created) {
453
516
  const claimed = claimInitialWindow(started, windowName, project.id);
454
517
  leadPane = claimed.pane;
@@ -467,13 +530,13 @@ async function cmdLead(argv) {
467
530
  createdPane = false;
468
531
  }
469
532
  else if (!foundWindow) {
470
- const fresh = createWindow(session, windowName, project.path, envFlags, leadCommand, project.id);
533
+ const fresh = createWindow(session, windowName, project.path, envFlags, launchCommand, project.id, detach);
471
534
  leadPane = fresh.pane;
472
535
  leadWindow = fresh.window;
473
536
  createdPane = true;
474
537
  }
475
538
  else {
476
- leadPane = tmux("split-window", "-P", "-F", "#{pane_id}", "-t", foundWindow, "-c", project.path, ...envFlags, leadCommand);
539
+ leadPane = tmux("split-window", ...(detach ? ["-d"] : []), "-P", "-F", "#{pane_id}", "-t", foundWindow, "-c", project.path, ...envFlags, launchCommand);
477
540
  leadWindow = foundWindow;
478
541
  createdPane = true;
479
542
  }
@@ -495,18 +558,7 @@ async function cmdLead(argv) {
495
558
  // also reaps a stale codex home left over from a lead config that has since switched away from
496
559
  // codex, since createdPane's branch does not require leadHarness.needsHome to fire.
497
560
  const recordedCodexHome = createdPane ? (newCodexHomeKey ?? "") : previousCodexHome;
498
- const wonRace = db.transaction(() => {
499
- const updated = db
500
- .prepare("UPDATE agents SET tmux_target = ?, tmux_socket = ?, pane_pid = ?, command = ?, codex_home = ? WHERE id = ? AND tmux_target = ? AND status = 'running'")
501
- .run(leadPane, tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR), panePid(leadPane), recordedCommand, recordedCodexHome, leadAgentId, casExpected).changes;
502
- if (updated === 0)
503
- return false;
504
- db.prepare(`UPDATE wakes SET deliver_pane = ?, held_at = NULL, held_reason = NULL
505
- WHERE ${ACTIVE_TIMER_WHERE} AND deliver_actor = ?
506
- AND (? = 1 OR held_reason IS NULL OR held_reason NOT LIKE ?)`).run(leadPane, leadActorId, createdPane ? 1 : 0, `${HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX}%`);
507
- return true;
508
- })();
509
- if (!wonRace) {
561
+ const abandonClaim = () => {
510
562
  if (createdPane) {
511
563
  try {
512
564
  waitForPaneEstablished(leadPane);
@@ -517,6 +569,31 @@ async function cmdLead(argv) {
517
569
  }
518
570
  if (newCodexHomeKey)
519
571
  reapCodexHome(newCodexHomeKey);
572
+ };
573
+ let wonRace;
574
+ try {
575
+ wonRace = db.transaction(() => {
576
+ const updated = db
577
+ .prepare("UPDATE agents SET tmux_target = ?, tmux_socket = ?, pane_pid = ?, command = ?, codex_home = ? WHERE id = ? AND tmux_target = ? AND status = 'running'")
578
+ .run(leadPane, tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR), panePid(leadPane), recordedCommand, recordedCodexHome, leadAgentId, casExpected).changes;
579
+ if (updated === 0)
580
+ return false;
581
+ db.prepare(`UPDATE wakes SET deliver_pane = ?, held_at = NULL, held_reason = NULL
582
+ WHERE ${ACTIVE_TIMER_WHERE} AND deliver_actor = ?
583
+ AND (? = 1 OR held_reason IS NULL OR held_reason NOT LIKE ?)`).run(leadPane, leadActorId, createdPane ? 1 : 0, `${HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX}%`);
584
+ confirmQueenWrite("hive lead", project.id, { detach }, {
585
+ agent_id: leadAgentId,
586
+ disposition: createdPane ? "started" : "adopted",
587
+ }, auditIdentity);
588
+ return true;
589
+ })();
590
+ }
591
+ catch (e) {
592
+ abandonClaim();
593
+ throw e;
594
+ }
595
+ if (!wonRace) {
596
+ abandonClaim();
520
597
  throw new Error("Another `hive lead` won the race to record a live pane for this project's lead session (both saw the " +
521
598
  "same dead pane and both tried to replace it, or this row was closed by another process mid-restart). " +
522
599
  "Re-run `hive lead`; it will attach to the pane that invocation recorded.");
@@ -550,6 +627,11 @@ async function cmdLead(argv) {
550
627
  console.error(registrationNotice);
551
628
  registrationNotice = null;
552
629
  }
630
+ if (detach) {
631
+ console.log(`LEAD_PANE=${leadPane}`);
632
+ console.log(`ATTACH_COMMAND=hive attach ${shellQuote(project.path)}`);
633
+ return;
634
+ }
553
635
  if (!noDashboard)
554
636
  maybeOpenDashboard(project, !!config?.dashboard);
555
637
  attach(session, project, leadWindow);
@@ -560,6 +642,60 @@ async function cmdLead(argv) {
560
642
  throw e;
561
643
  }
562
644
  }
645
+ function publishQueenYml(home) {
646
+ const yml = join(home, "hive.yml");
647
+ const staged = join(home, `.hive.yml.${process.pid}.${randomUUID()}`);
648
+ writeFileSync(staged, `profile: ${QUEEN_PROFILE}\n`, { flag: "wx" });
649
+ try {
650
+ linkSync(staged, yml);
651
+ }
652
+ catch (e) {
653
+ if (e.code !== "EEXIST")
654
+ throw e;
655
+ }
656
+ finally {
657
+ unlinkSync(staged);
658
+ }
659
+ if (lstatSync(yml).isSymbolicLink()) {
660
+ throw new Error(`hive queen: ${yml} is a symlink. The queen's config must be a real file; remove the link and run hive queen again.`);
661
+ }
662
+ const { config } = loadProjectYml(home);
663
+ if (activeProfile(config) !== QUEEN_PROFILE) {
664
+ throw new Error(`hive queen: ${yml} already exists and does not select "profile: ${QUEEN_PROFILE}". hive will not ` +
665
+ "overwrite it. Add that line yourself, or move the file away and run hive queen again.");
666
+ }
667
+ }
668
+ async function cmdQueen(argv) {
669
+ const parsed = parseArgs(argv, { flags: ["--no-dashboard"] });
670
+ rejectUnknownFlags("queen", parsed, "--no-dashboard");
671
+ if (parsed.positional.length > 0) {
672
+ console.error(`hive queen: takes no path. The queen lives in the data dir (${queenHomeDir()}).`);
673
+ process.exit(1);
674
+ }
675
+ if (process.env.HIVE_PROJECT_LOCK === "1" || agentProjectPin() != null) {
676
+ throw new Error("hive queen: this session is locked to its own project (HIVE_PROJECT_LOCK=1), so it cannot create or " +
677
+ "start the queen. Run hive queen from your own terminal.");
678
+ }
679
+ const home = queenHomeDir();
680
+ let existing = null;
681
+ try {
682
+ existing = lstatSync(home);
683
+ }
684
+ catch {
685
+ existing = null;
686
+ }
687
+ if (existing?.isSymbolicLink() || (existing && !existing.isDirectory())) {
688
+ throw new Error(`hive queen: ${home} exists and is not a plain directory. The queen's home must be a real directory ` +
689
+ "inside the data dir; move it away and run hive queen again.");
690
+ }
691
+ mkdirSync(home, { recursive: true });
692
+ publishQueenYml(home);
693
+ const queen = addProject(home, "queen");
694
+ generateQueenDashboardNow();
695
+ if (!parsed.flags.has("--no-dashboard"))
696
+ maybeOpenQueenDashboard(queen);
697
+ await cmdLead([home, ...parsed.flags]);
698
+ }
563
699
  const HIVE_YML_TEMPLATE = `# hive project config. Read by \`hive lead\` from the project root.
564
700
  # Commands defined here run only after a one-time interactive approval,
565
701
  # and re-require it whenever they change.
@@ -577,6 +713,7 @@ const HIVE_YML_TEMPLATE = `# hive project config. Read by \`hive lead\` from the
577
713
  # agents: [claude, codex] # crew harness pool; the first entry is the default
578
714
 
579
715
  # lead_branches: [main, master] # branches where a session gets hive's kickoff
716
+ # first_message: "..." # a lead's first turn; absent or "" sends none
580
717
 
581
718
  # context_checkpoint_percent: null # unset means off; integer 1-100 to enable
582
719
 
@@ -778,6 +915,7 @@ async function cmdInit(argv) {
778
915
  process.exit(1);
779
916
  }
780
917
  const existing = getProjectByPath(registration);
918
+ assertQueenCrossProjectWrite("hive init", existing?.id ?? null, {});
781
919
  project = existing ?? addProject(registration);
782
920
  newlyRegistered = existing == null;
783
921
  }
@@ -894,7 +1032,7 @@ itself to this project before any real work runs.`);
894
1032
  }
895
1033
  const NO_RUNBOOK_PAD_MESSAGE = `is on "profile: none" but has no runbook pad. Create one with: hive init`;
896
1034
  function cmdRunbook(path) {
897
- const project = resolveProject(path);
1035
+ const project = resolveProjectForRead("runbook", path);
898
1036
  const { config, warnings } = loadProjectYml(project.path);
899
1037
  for (const w of warnings)
900
1038
  console.log(`! ${w}`);
@@ -919,7 +1057,7 @@ function cmdRunbook(path) {
919
1057
  process.stdout.write(withTrailingNewline(rendered));
920
1058
  }
921
1059
  function cmdPosture(path) {
922
- const project = resolveProject(path);
1060
+ const project = resolveProjectForRead("posture", path);
923
1061
  const { config, warnings } = loadProjectYml(project.path);
924
1062
  for (const w of warnings)
925
1063
  console.log(`! ${w}`);
@@ -1060,7 +1198,7 @@ function cmdProfile(argv) {
1060
1198
  vars = mergedProjectVars(here ? loadProjectYml(here.path).config : null);
1061
1199
  }
1062
1200
  else {
1063
- const project = resolveProject();
1201
+ const project = resolveProjectForRead("profile read");
1064
1202
  const { config, warnings } = loadProjectYml(project.path);
1065
1203
  for (const w of warnings)
1066
1204
  console.log(`! ${w}`);
@@ -1107,6 +1245,22 @@ function maybeOpenDashboard(project, dashboardEnabled) {
1107
1245
  return;
1108
1246
  if (!dashboardFileContained(dashboardFile, project.path))
1109
1247
  return;
1248
+ openDashboardOnce(project, dashboardFile);
1249
+ }
1250
+ function maybeOpenQueenDashboard(project) {
1251
+ if (process.platform !== "darwin")
1252
+ return;
1253
+ const dashboardFile = join(project.path, "dashboard.html");
1254
+ try {
1255
+ if (!existsSync(dashboardFile) || !dashboardFileContained(dashboardFile, project.path))
1256
+ return;
1257
+ }
1258
+ catch {
1259
+ return;
1260
+ }
1261
+ openDashboardOnce(project, dashboardFile);
1262
+ }
1263
+ function openDashboardOnce(project, dashboardFile) {
1110
1264
  const actor = currentActor();
1111
1265
  const claim = db
1112
1266
  .prepare(`INSERT INTO kv (project_id, key, value, updated_by, expires_at)
@@ -1132,7 +1286,7 @@ function cmdAttach(argv) {
1132
1286
  process.exit(1);
1133
1287
  }
1134
1288
  const path = argv.find((a) => !a.startsWith("--"));
1135
- const project = resolveProject(path);
1289
+ const project = resolveProjectForWrite("hive attach", path);
1136
1290
  maybeOpenDashboard(project, !!loadProjectYml(project.path).config?.dashboard);
1137
1291
  const session = sessionName();
1138
1292
  const window = withWindowClaim(() => {
@@ -1160,7 +1314,7 @@ async function cmdStart(argv) {
1160
1314
  console.log("Usage: hive start <process> [path]");
1161
1315
  process.exit(1);
1162
1316
  }
1163
- const project = resolveProject(path);
1317
+ const project = resolveProjectForWrite("hive start", path);
1164
1318
  const { config, warnings } = loadProjectYml(project.path);
1165
1319
  for (const w of warnings)
1166
1320
  console.log(`! ${w}`);
@@ -1188,7 +1342,7 @@ function resolveNamedProcess(argv, verb) {
1188
1342
  console.log(`Usage: hive ${verb} <process> [path]`);
1189
1343
  process.exit(1);
1190
1344
  }
1191
- const project = resolveProject(parsed.positional[1]);
1345
+ const project = resolveProjectForWrite(`hive ${verb}`, parsed.positional[1]);
1192
1346
  const { config, warnings } = loadProjectYml(project.path);
1193
1347
  for (const w of warnings)
1194
1348
  console.log(`! ${w}`);
@@ -1228,7 +1382,7 @@ function leadPaneExitedHookCommand() {
1228
1382
  // started from inside a worker's pane hands them to every run-shell it ever runs, where a stale
1229
1383
  // HIVE_AGENT_ID is a hard refusal before the verb is reached at all. The same clearing
1230
1384
  // scripts/restart-lead.sh does around its own `hive lead`, plus the two project vars.
1231
- const clear = ["HIVE_AGENT_ID", "HIVE_AGENT_NAME", "HIVE_LEAD", "HIVE_PROJECT_LOCK", "HIVE_PROJECT_PATH"]
1385
+ const clear = ["HIVE_AGENT_ID", "HIVE_AGENT_NAME", "HIVE_LEAD", FIRST_MESSAGE_SHA_ENV, "HIVE_PROJECT_LOCK", "HIVE_PROJECT_PATH"]
1232
1386
  .flatMap((name) => ["-u", name]);
1233
1387
  // Redirected because tmux prints a run-shell's output into the pane it fires from, which is a
1234
1388
  // human's own window: a backstop that failed to start must not type into it.
@@ -1310,7 +1464,7 @@ function cmdStop(argv) {
1310
1464
  const parsed = parseArgs(argv, { flags: ["--all"] });
1311
1465
  rejectUnknownFlags("stop", parsed, "--all");
1312
1466
  if (parsed.flags.has("--all")) {
1313
- const project = resolveProject(parsed.positional[0]);
1467
+ const project = resolveProjectForWrite("hive stop", parsed.positional[0]);
1314
1468
  const stopped = stopAllProcesses(project.id, STOP_REASONS.byHand);
1315
1469
  console.log(stopped.length === 0 ? "No processes are running." : stopped.map(stopLine).join("\n"));
1316
1470
  return;
@@ -1320,7 +1474,7 @@ function cmdStop(argv) {
1320
1474
  console.log("Usage: hive stop <process> [path] | hive stop --all [path]");
1321
1475
  process.exit(1);
1322
1476
  }
1323
- const project = resolveProject(parsed.positional[1]);
1477
+ const project = resolveProjectForWrite("hive stop", parsed.positional[1]);
1324
1478
  const row = runningCommandRow(project.id, name);
1325
1479
  if (!row) {
1326
1480
  const { config, warnings } = loadProjectYml(project.path);
@@ -1723,7 +1877,7 @@ function reportProjectScope(here) {
1723
1877
  if (locked && project.id !== here?.id)
1724
1878
  continue;
1725
1879
  if (project.path === homePath || project.path === "/") {
1726
- 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.`);
1880
+ warn("project scope", `"${project.name}" (${project.path}) is a home directory; every unregistered directory under it resolves to this project. The project_prune tool with this project's id (from any hive session) removes it once it holds nothing.`);
1727
1881
  }
1728
1882
  }
1729
1883
  for (const parent of projects) {
@@ -2595,6 +2749,144 @@ function findPadExports(projectId, name) {
2595
2749
  .filter((f) => f.startsWith(prefix) && f.endsWith(".md"))
2596
2750
  .map((f) => join(tmpdir(), f));
2597
2751
  }
2752
+ function canonicalPath(path) {
2753
+ try {
2754
+ return realpathSync(path);
2755
+ }
2756
+ catch {
2757
+ return resolve(path);
2758
+ }
2759
+ }
2760
+ function nextCandidates() {
2761
+ const queenRoot = canonicalPath(join(storeDir(), "queen"));
2762
+ const byCount = (a, b) => b - a;
2763
+ return collectPortfolio()
2764
+ .projects.filter((p) => (p.lane === "waiting_on_you" || p.lane === "stuck" || (p.lane === "moving" && p.needs_human > 0)) &&
2765
+ canonicalPath(p.root) !== queenRoot)
2766
+ .sort((a, b) => {
2767
+ const tier = (p) => (p.lane === "waiting_on_you" ? 0 : p.needs_human > 0 ? 1 : 2);
2768
+ if (tier(a) !== tier(b))
2769
+ return tier(a) - tier(b);
2770
+ if (a.lane === "waiting_on_you") {
2771
+ return (byCount(a.needs_human, b.needs_human) ||
2772
+ a.last_activity_at.localeCompare(b.last_activity_at) ||
2773
+ a.id - b.id);
2774
+ }
2775
+ return (byCount(a.needs_human, b.needs_human) ||
2776
+ byCount(a.todos.blocked_in_progress, b.todos.blocked_in_progress) ||
2777
+ byCount(a.wakes.overdue, b.wakes.overdue) ||
2778
+ byCount(a.workers.needs_input, b.workers.needs_input) ||
2779
+ a.last_activity_at.localeCompare(b.last_activity_at) ||
2780
+ a.id - b.id);
2781
+ });
2782
+ }
2783
+ function collectProjectById(id) {
2784
+ return collectPortfolio().projects.find((p) => p.id === id);
2785
+ }
2786
+ async function cmdNext(argv) {
2787
+ const parsed = parseArgs(argv, { flags: ["--print"] });
2788
+ if (parsed.positional.length > 0)
2789
+ parsed.unknown.push(parsed.positional[0]);
2790
+ rejectUnknownFlags("next", parsed, "--print");
2791
+ if (process.env.HIVE_PROJECT_LOCK === "1") {
2792
+ console.error("hive next: this session is locked to one project (HIVE_PROJECT_LOCK=1) and cannot jump to another. Run it from a pane outside a worker session.");
2793
+ process.exit(1);
2794
+ }
2795
+ const chosen = nextCandidates()[0];
2796
+ if (!chosen) {
2797
+ console.log("No project needs you: none is waiting, stuck, or holding a needs-human item.");
2798
+ return;
2799
+ }
2800
+ console.log(JSON.stringify({
2801
+ project_id: chosen.id,
2802
+ name: chosen.name,
2803
+ root: chosen.root,
2804
+ lane: chosen.lane,
2805
+ reasons: chosen.reasons,
2806
+ lead_state: chosen.lead.state,
2807
+ }));
2808
+ if (parsed.flags.has("--print"))
2809
+ return;
2810
+ assertQueenCrossProjectWrite("hive next", chosen.id, {});
2811
+ if (!chosen.root_exists) {
2812
+ console.error(`hive next: project ${chosen.id} ("${chosen.name}") root is missing: ${chosen.root}. Remove the stale project with the project_prune tool, or restore the directory.`);
2813
+ process.exit(1);
2814
+ }
2815
+ let state = collectProjectById(chosen.id)?.lead.state ?? "none";
2816
+ if (state === "unknown") {
2817
+ console.error(`hive next: cannot tell whether the lead for project ${chosen.id} ("${chosen.name}") is alive (tmux did not answer for its socket). Starting nothing.`);
2818
+ process.exit(1);
2819
+ }
2820
+ await cmdLead([chosen.root, "--detach"]);
2821
+ state = collectProjectById(chosen.id)?.lead.state ?? "none";
2822
+ if (state !== "alive" || (process.exitCode !== undefined && process.exitCode !== 0)) {
2823
+ console.error(`hive next: the lead for project ${chosen.id} ("${chosen.name}") did not start cleanly (${state}). Not attaching.`);
2824
+ process.exit(1);
2825
+ }
2826
+ cmdAttach([chosen.root]);
2827
+ }
2828
+ function cmdQueenAudit(argv) {
2829
+ const parsed = parseArgs(argv, { flags: ["--json"], valued: ["--project-id", "--limit"] });
2830
+ if (parsed.positional.length > 0)
2831
+ parsed.unknown.push(parsed.positional[0]);
2832
+ rejectUnknownFlags("queen-audit", parsed, "--project-id <id>, --limit <n>, --json");
2833
+ requireFlagValues("queen-audit", parsed);
2834
+ let report;
2835
+ try {
2836
+ const project = pinnedOrCwdProject();
2837
+ if (!project)
2838
+ throw new Error("hive queen-audit: the cwd is not a registered project. Run this command from a registered project.");
2839
+ report = listQueenAudit({
2840
+ project_id: parsed.values.has("--project-id") ? Number(parsed.values.get("--project-id")) : undefined,
2841
+ limit: parsed.values.has("--limit") ? Number(parsed.values.get("--limit")) : undefined,
2842
+ }, project.id);
2843
+ }
2844
+ catch (e) {
2845
+ console.error(errorMessage(e));
2846
+ process.exitCode = 1;
2847
+ return;
2848
+ }
2849
+ if (parsed.flags.has("--json")) {
2850
+ console.log(JSON.stringify(report));
2851
+ return;
2852
+ }
2853
+ if (report.entries.length === 0) {
2854
+ console.log("No queen actions recorded.");
2855
+ return;
2856
+ }
2857
+ for (const entry of report.entries) {
2858
+ console.log(`${entry.created_at} UTC ${entry.actor_id} -> target #${entry.target_project_id}\n` +
2859
+ ` ${entry.operation} ${entry.resource_type} #${entry.resource_id}\n ${entry.summary}`);
2860
+ }
2861
+ }
2862
+ function cmdPortfolio(argv) {
2863
+ const parsed = parseArgs(argv, { flags: ["--json"] });
2864
+ if (parsed.positional.length > 0)
2865
+ parsed.unknown.push(parsed.positional[0]);
2866
+ rejectUnknownFlags("portfolio", parsed, "--json");
2867
+ const report = collectPortfolio();
2868
+ if (parsed.flags.has("--json")) {
2869
+ console.log(JSON.stringify(report));
2870
+ return;
2871
+ }
2872
+ if (report.projects.length === 0) {
2873
+ console.log("No registered projects.");
2874
+ return;
2875
+ }
2876
+ for (const p of report.projects) {
2877
+ const w = p.workers;
2878
+ const t = p.todos;
2879
+ console.log(`${p.lane.padEnd(14)} ${p.name} (#${p.id})${p.root_exists ? "" : " [root missing]"}\n` +
2880
+ ` why: ${p.reasons.join(", ")}\n` +
2881
+ ` lead ${leadText(p)}; workers ${w.working} working, ${w.idle} idle, ${w.needs_input} needs input, ` +
2882
+ `${w.other} other, ${w.unreachable} unreachable, ${w.unconfirmed} unconfirmed\n` +
2883
+ ` todos ${t.open} open, ${t.in_progress} in progress, ${t.blocked} blocked, ${t.high} high; ` +
2884
+ `needs human ${p.needs_human}; wakes ${p.wakes.pending} pending, ${p.wakes.overdue} overdue\n` +
2885
+ ` last activity ${p.last_activity_at} UTC`);
2886
+ }
2887
+ const l = report.totals.lanes;
2888
+ console.log(`\n${report.totals.projects} project(s): ${l.waiting_on_you} waiting on you, ${l.stuck} stuck, ${l.moving} moving, ${l.quiet} quiet`);
2889
+ }
2598
2890
  function cmdBackups() {
2599
2891
  const snapshots = listSnapshots(dataDir);
2600
2892
  if (snapshots.length === 0) {
@@ -2762,9 +3054,7 @@ function requireFlagValues(command, parsed) {
2762
3054
  process.exit(1);
2763
3055
  }
2764
3056
  function cmdTodos(argv) {
2765
- const project = pinnedOrCwdProject();
2766
- if (!project)
2767
- return;
3057
+ const project = resolveProjectForRead("todos");
2768
3058
  const all = argv.includes("--all");
2769
3059
  const statusIdx = argv.indexOf("--status");
2770
3060
  const status = flagValue(argv, "--status");
@@ -2808,9 +3098,7 @@ function cmdTodos(argv) {
2808
3098
  }
2809
3099
  }
2810
3100
  function cmdTodo(argv) {
2811
- const project = pinnedOrCwdProject();
2812
- if (!project)
2813
- return;
3101
+ const project = resolveProjectForRead("todo");
2814
3102
  const id = Number(argv.find((a) => !a.startsWith("--")));
2815
3103
  if (!Number.isInteger(id)) {
2816
3104
  console.log("Usage: hive todo <id> (run inside the project)");
@@ -2851,7 +3139,7 @@ function cmdTodo(argv) {
2851
3139
  }
2852
3140
  }
2853
3141
  function cmdPads() {
2854
- const project = resolveProject();
3142
+ const project = resolveProjectForRead("pads");
2855
3143
  const pads = listActivePads(project.id);
2856
3144
  if (pads.length === 0) {
2857
3145
  console.log(`No pads in project "${project.name}".`);
@@ -2870,7 +3158,7 @@ function cmdPad(argv) {
2870
3158
  console.log("Usage: hive pad <name> [--edit | --save [file]] (run inside the project)");
2871
3159
  process.exit(1);
2872
3160
  }
2873
- const project = resolveProject();
3161
+ const project = parsed.flags.has("--save") ? resolveProjectForWrite("hive pad --save") : resolveProjectForRead("pad");
2874
3162
  const pad = getActivePadByName(project.id, name);
2875
3163
  if (!pad) {
2876
3164
  console.log(`No pad named "${name}" in project "${project.name}". List them with: hive pads`);
@@ -2940,6 +3228,11 @@ function cmdPad(argv) {
2940
3228
  }
2941
3229
  process.stdout.write(withTrailingNewline(pad.content));
2942
3230
  }
3231
+ process.stdout.on("error", (error) => {
3232
+ if (error.code === "EPIPE")
3233
+ process.exit(0);
3234
+ throw error;
3235
+ });
2943
3236
  const args = process.argv.slice(2);
2944
3237
  let command = args[0] ?? "lead";
2945
3238
  let rest = args.slice(1);
@@ -2964,7 +3257,7 @@ if (command === "--version" || command === "-v") {
2964
3257
  process.exit(0);
2965
3258
  }
2966
3259
  const COMMANDS = [
2967
- "lead", "init", "attach", "start", "stop", "show", "hide", "status", "setup", "upgrade", "doctor",
3260
+ "lead", "queen", "queen-audit", "init", "attach", "start", "stop", "show", "hide", "status", "portfolio", "next", "setup", "upgrade", "doctor",
2968
3261
  LEAD_PANE_EXITED_VERB,
2969
3262
  "pads", "pad", "todos", "todo", "backups", "restore", "runbook", "posture", "profile", "kickoff", "statusline",
2970
3263
  ];
@@ -2988,6 +3281,9 @@ try {
2988
3281
  case "lead":
2989
3282
  await cmdLead(rest);
2990
3283
  break;
3284
+ case "queen":
3285
+ await cmdQueen(rest);
3286
+ break;
2991
3287
  case "init":
2992
3288
  await cmdInit(rest);
2993
3289
  break;
@@ -3012,6 +3308,15 @@ try {
3012
3308
  case "status":
3013
3309
  cmdStatus();
3014
3310
  break;
3311
+ case "portfolio":
3312
+ cmdPortfolio(rest);
3313
+ break;
3314
+ case "queen-audit":
3315
+ cmdQueenAudit(rest);
3316
+ break;
3317
+ case "next":
3318
+ await cmdNext(rest);
3319
+ break;
3015
3320
  case "setup":
3016
3321
  cmdSetup(rest);
3017
3322
  break;