@cmgmyr/hive 1.0.0 → 1.2.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/README.md CHANGED
@@ -67,3 +67,7 @@ hive is a personal daily-driver tool, released low-key. It is single-user by des
67
67
  | [Tools](docs/tools.md) | The 45 MCP tools: what each does and when to use it |
68
68
  | [tmux settings](docs/tmux.md) | Attach modes, pane options, and what to put in `~/.tmux.conf` |
69
69
  | [Development](docs/development.md) | Building and testing hive itself |
70
+
71
+ ## Updating
72
+
73
+ Run `hive upgrade` for a global npm install, or `hive upgrade --check` to preview the commands without changing your install. In a git checkout, the default prints the recipe; `hive upgrade --run` executes it. Upgrade reports Claude Code and Codex registration repairs without editing their configs. Restart every session with hive loaded after the upgrade. See [Updating and recovery](docs/install.md#updating).
@@ -1 +1 @@
1
- {"version":"1.0.0","sha":"119838a","dirty":false}
1
+ {"version":"1.2.0","sha":"6abbb1c","dirty":false,"build_id":"7bfa0131-a4ec-4341-a842-7d0cc0cd803d"}
package/dist/cli.js CHANGED
@@ -11,14 +11,17 @@ import { versionInfo } from "./version.js";
11
11
  import { claudeConfigDir } from "./claudeDir.js";
12
12
  import { ATTACH_MODES, AUTO_ATTACH_MODES, attachMode, isAttachMode, isAutoAttach, resolvedAttachMode, resolvedAutoAttach, setAttachMode, setAutoAttach, } from "./config.js";
13
13
  import { cliPath, dispatcherDir, dispatcherPath, dispatcherScript, durabilityLines, firstHiveOnPath, linkedWorktreePin, pathAdvice, readDispatcher, } from "./dispatcher.js";
14
- import { codexConfigPath, codexHiveRegistrations, hiveRegistrations, registrationOffer, registrationProblem, } from "./mcpConfig.js";
14
+ import { codexConfigPath, codexHiveRegistrations, codexRegistrationProblem, hiveRegistrations, registrationOffer, registrationProblem, } from "./mcpConfig.js";
15
15
  import { DEFAULT_DATA_DIR } from "./dataDir.js";
16
- import { dataDir, db, migrate } from "./db.js";
16
+ import { dataDir, db, migrate, storeSchemaAhead } from "./db.js";
17
+ import { cacheIsStale, queryUpdate, readCachedUpdate, refreshUpdate, shouldAutoRefresh, updateLine } from "./updateCheck.js";
18
+ import { checkoutUpgradeSteps, detectInstallShape, globalUpgradeSteps } from "./upgrade.js";
17
19
  import { isLowHeadroom, orphanLoginShellDetails, ptyHeadroom } from "./ptys.js";
18
20
  import { addProject, agentProjectPin, currentActor, effectiveProjectId, findProjectForCwd, getProjectByPath, gitPrimaryRoot, getProject, listProjects, registeredAncestor, takeRegistrationNotice, } from "./context.js";
19
21
  import { ensureHooksFile } from "./hooks.js";
20
22
  import { errorMessage, parseTags, registrationNoticeText, withTrailingNewline } from "./result.js";
21
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 { readTurnCount } from "./turnCount.js";
22
25
  import { STALL_BOUND_SECONDS } from "./backgroundTasks.js";
23
26
  import { probeSessionInterpreter, reexecTarget, sessionStartVerdict, } from "./sessionProbe.js";
24
27
  import { backupHealth, backupNow, backupsDir, formatBytes, listSnapshots, previewRestore, restoreSnapshot, totalSizeBytes, } from "./backup.js";
@@ -56,6 +59,8 @@ Usage:
56
59
  hive show <process> [path] move a running process's pane beside the lead
57
60
  hive hide <process> [path] move it back into the <project>/processes window
58
61
  hive status overview of agents, todos, and wake-ups everywhere
62
+ hive upgrade [--check] upgrade a global npm install; preview with --check
63
+ hive upgrade --run run the printed pull/install/build/setup checkout recipe
59
64
  hive setup [--dir <dir>] write a \`hive\` that runs the interpreter this build
60
65
  was compiled for; re-run after every update
61
66
  hive setup --attach <mode> auto|raw|control: whether tmux attaches carry -CC
@@ -558,13 +563,20 @@ placement: split # placement for workers and visible processes: s
558
563
 
559
564
  # lead: claude --model opus # custom command for the lead window (default: claude)
560
565
 
566
+ # agents: [claude, codex] # crew harness pool; the first entry is the default
567
+
561
568
  # lead_branches: [main, master] # branches where a session gets hive's kickoff
562
569
 
563
570
  # context_checkpoint_percent: null # unset means off; integer 1-100 to enable
564
571
 
572
+ # lead_turn_budget: {warn: 300, stop: 600} # optional lead statusline thresholds
573
+
574
+ # dashboard: true # write .hive/dashboard.html (default: false)
575
+
565
576
  # review_tags: [from-review] # todo tags \`hive doctor\` counts as review findings and
566
577
  # reports as triaged (has a comment, completed, or archived)
567
- # or untriaged. Absent means doctor tracks none.
578
+ # or untriaged. A tag also matches its own suffixed rounds.
579
+ # Absent means it tracks none.
568
580
 
569
581
  # vars: # substituted into the profile runbook
570
582
  # repo: owner/name # {{repo}}
@@ -1451,6 +1463,74 @@ const verboseInfo = (_check, label, ...lines) => {
1451
1463
  report("info", label, lines);
1452
1464
  };
1453
1465
  const isReviewFindingTag = (bases, tag) => bases.some((base) => tag === base || tag.startsWith(`${base}-`));
1466
+ function cmdUpgrade(argv) {
1467
+ const parsed = parseArgs(argv, { flags: ["--check", "--run"] });
1468
+ rejectUnknownFlags("upgrade", parsed, "--check, --run");
1469
+ if (parsed.positional.length || (parsed.flags.has("--check") && parsed.flags.has("--run"))) {
1470
+ console.error("hive upgrade: use --check or --run, without positional arguments.");
1471
+ process.exitCode = 1;
1472
+ return;
1473
+ }
1474
+ const shape = detectInstallShape();
1475
+ if (shape.kind === "unknown") {
1476
+ console.error(`Cannot upgrade this install: ${shape.reason}`);
1477
+ console.error(`CLI: ${shape.cli}\nPackage root: ${shape.packageRoot}\n.git: ${shape.gitState}\nnpm root -g: ${shape.npmRoot ?? "unavailable"}`);
1478
+ console.error("Put the npm for this package's global root first on PATH, or use a git checkout.");
1479
+ process.exitCode = 1;
1480
+ return;
1481
+ }
1482
+ if (shape.kind === "global" && parsed.flags.has("--run")) {
1483
+ console.error("hive upgrade: --run is only for checkouts; global installs upgrade by default.");
1484
+ process.exitCode = 1;
1485
+ return;
1486
+ }
1487
+ const steps = shape.kind === "checkout" ? checkoutUpgradeSteps(shape.packageRoot) : globalUpgradeSteps(shape);
1488
+ const render = (step) => [step.command, ...step.args]
1489
+ .map((arg) => /^[a-zA-Z0-9_@./=-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`).join(" ");
1490
+ const restart = "Restart every Claude Code or Codex session that has hive loaded; existing sessions keep running old in-memory code.";
1491
+ if (shape.kind === "global") {
1492
+ const update = queryUpdate();
1493
+ console.log(updateLine(update));
1494
+ if (update.status === "unknown") {
1495
+ console.error("not upgrading: could not read the latest version");
1496
+ process.exitCode = 1;
1497
+ return;
1498
+ }
1499
+ if (update.status === "current")
1500
+ return;
1501
+ }
1502
+ if (parsed.flags.has("--check") || (shape.kind === "checkout" && !parsed.flags.has("--run"))) {
1503
+ console.log(shape.kind === "checkout" ? `Checkout: ${shape.packageRoot}\nRun in that directory, or use hive upgrade --run:` : "Would run:");
1504
+ for (const step of steps)
1505
+ console.log(` ${render(step)}`);
1506
+ console.log(restart);
1507
+ return;
1508
+ }
1509
+ for (const [index, step] of steps.entries()) {
1510
+ console.log(`\n${step.label}: ${render(step)}`);
1511
+ const result = spawnSync(step.command, step.args, { cwd: step.cwd, stdio: "inherit" });
1512
+ if (result.status === 0 && !result.error && !result.signal)
1513
+ continue;
1514
+ console.error(`${step.label} failed: ${result.error?.message ?? (result.signal ? `signal ${result.signal}` : `exit ${result.status}`)}`);
1515
+ if (shape.kind === "global") {
1516
+ console.error(index === 0
1517
+ ? "npm may have left the global package partially changed. Retry the install, then repair setup:"
1518
+ : "The package is new, but the dispatcher was not confirmed re-pinned. Repair setup:");
1519
+ for (const remaining of steps.slice(index))
1520
+ console.error(` ${render(remaining)}`);
1521
+ console.error(" hive doctor --strict");
1522
+ }
1523
+ else {
1524
+ console.error(`Stopped at ${render(step)}. Remaining recipe not run:`);
1525
+ for (const remaining of steps.slice(index + 1))
1526
+ console.error(` ${render(remaining)}`);
1527
+ }
1528
+ console.error(`After repair: ${restart}`);
1529
+ process.exitCode = 1;
1530
+ return;
1531
+ }
1532
+ console.log(`\nUpgrade complete. ${restart}`);
1533
+ }
1454
1534
  function cmdSetup(argv) {
1455
1535
  const parsed = parseArgs(argv, { flags: ["--force"], valued: ["--dir", "--attach", "--auto-attach"] });
1456
1536
  rejectUnknownFlags("setup", parsed, "--dir, --attach, --auto-attach, --force");
@@ -1528,8 +1608,8 @@ function cmdSetup(argv) {
1528
1608
  console.log("");
1529
1609
  for (const line of durabilityLines(node))
1530
1610
  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");
1611
+ console.log("\nFor future updates, run hive upgrade. It re-pins after installing;");
1612
+ console.log("checkouts print the recipe and run it only with --run.");
1533
1613
  console.log(`\nPATH: ${pathAdvice(dir, file).join("\n")}`);
1534
1614
  reportSetupRegistrations(node);
1535
1615
  }
@@ -1546,7 +1626,14 @@ function reportSetupRegistrations(pinned) {
1546
1626
  for (const line of lines.slice(1))
1547
1627
  console.log(` ${line}`);
1548
1628
  }
1549
- return;
1629
+ }
1630
+ for (const r of codexHiveRegistrations()) {
1631
+ const lines = codexRegistrationProblem(r, pinned);
1632
+ if (!lines)
1633
+ continue;
1634
+ console.log(`\n mcp registration (codex): ${lines[0]}`);
1635
+ for (const line of lines.slice(1))
1636
+ console.log(` ${line}`);
1550
1637
  }
1551
1638
  const offer = registrationOffer(pinned, found);
1552
1639
  if (offer) {
@@ -1652,6 +1739,9 @@ function reportMcpRegistrations(project) {
1652
1739
  }
1653
1740
  for (const r of codexRegistrations) {
1654
1741
  info("mcp registration (codex)", [r.command, ...r.args].join(" "));
1742
+ const problem = codexRegistrationProblem(r, process.execPath);
1743
+ if (problem)
1744
+ gatingWarn("mcp registration (codex)", ...problem);
1655
1745
  }
1656
1746
  }
1657
1747
  const PTY_OVERRIDE_VARS = ["HIVE_PTY_HEADROOM_JSON", "HIVE_PTY_PS_ROWS_JSON", "HIVE_ORPHAN_SCRATCH_JSON"];
@@ -1960,6 +2050,13 @@ function cmdDoctor(argv) {
1960
2050
  const { line, drift } = versionInfo();
1961
2051
  return drift ? [line, drift].join("\n ") : line;
1962
2052
  });
2053
+ let cachedUpdate = readCachedUpdate();
2054
+ if (cacheIsStale(cachedUpdate) && shouldAutoRefresh())
2055
+ cachedUpdate = refreshUpdate();
2056
+ if (cachedUpdate?.status === "newer" && cachedUpdate.checkedAt) {
2057
+ const hours = Math.max(0, Math.floor((Date.now() - Date.parse(cachedUpdate.checkedAt)) / (60 * 60 * 1000)));
2058
+ warn("update", `${updateLine(cachedUpdate)} (checked ${hours}h ago)`);
2059
+ }
1963
2060
  check("node", () => describeInterpreter());
1964
2061
  check("better-sqlite3", () => {
1965
2062
  const status = checkAbi();
@@ -1978,6 +2075,10 @@ function cmdDoctor(argv) {
1978
2075
  check("claude", () => execFileSync("which", ["claude"], { encoding: "utf8" }).trim());
1979
2076
  check("database", () => {
1980
2077
  const n = db.prepare("SELECT COUNT(*) AS n FROM migrations").get().n;
2078
+ const ahead = storeSchemaAhead(db);
2079
+ if (ahead) {
2080
+ warn("database", `store schema v${ahead.store} is ahead of this build (v${ahead.build}): a newer hive has migrated your shared store. Upgrade this install before relying on it.`);
2081
+ }
1981
2082
  return `${dataDir} (schema v${n})`;
1982
2083
  });
1983
2084
  check("hooks file", () => ensureHooksFile());
@@ -2388,6 +2489,7 @@ function cmdStatusline() {
2388
2489
  AND NOT EXISTS (${OPEN_BLOCKERS_SQL})`);
2389
2490
  const pads = count("SELECT COUNT(*) AS n FROM pads WHERE project_id = ? AND archived = 0");
2390
2491
  const wakes = count(`SELECT COUNT(*) AS n FROM wakes WHERE project_id = ? AND ${ACTIVE_TIMER_WHERE}`);
2492
+ const schemaAhead = storeSchemaAhead(db);
2391
2493
  // One statement, not two: the count and the chosen row must come from the same read, or a wake that
2392
2494
  // delivers/re-arms between two separate queries leaves the second with no row to read.
2393
2495
  const held = db
@@ -2403,7 +2505,7 @@ function cmdStatusline() {
2403
2505
  (SELECT held_reason FROM chosen) AS held_reason,
2404
2506
  (SELECT first_held_at FROM chosen) AS first_held_at`)
2405
2507
  .get(project.id, `${HELD_REASON_UNSUBMITTED_INPUT_PREFIX}%`, `${HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX}%`, project.id);
2406
- if (agents + commands + todos + pads + wakes === 0)
2508
+ if (agents + commands + todos + pads + wakes === 0 && !schemaAhead)
2407
2509
  return;
2408
2510
  const s = (n) => (n === 1 ? "" : "s");
2409
2511
  const parts = [
@@ -2419,6 +2521,34 @@ function cmdStatusline() {
2419
2521
  const age = held.first_held_at ? humanizeAge(ageSecondsSince(held.first_held_at)) : "?";
2420
2522
  parts.push(`${held.n} held (${age}, ${heldReasonLabel(held.held_reason)})`);
2421
2523
  }
2524
+ if (schemaAhead)
2525
+ parts.push("store ahead (update hive)");
2526
+ let turns = null;
2527
+ let budget = null;
2528
+ try {
2529
+ const actorId = process.env.HIVE_AGENT_ID;
2530
+ if (actorId) {
2531
+ const lead = db.prepare("SELECT 1 AS lead FROM agents WHERE project_id = ? AND actor_id = ? AND kind = 'lead' AND status = 'running'").get(project.id, actorId);
2532
+ if (lead) {
2533
+ let inputPath = "";
2534
+ if (!process.stdin.isTTY) {
2535
+ try {
2536
+ const raw = readFileSync(0, "utf8").trim();
2537
+ const input = raw ? JSON.parse(raw) : null;
2538
+ inputPath = typeof input?.transcript_path === "string" ? input.transcript_path : "";
2539
+ }
2540
+ catch { }
2541
+ }
2542
+ turns = readTurnCount(inputPath);
2543
+ budget = loadProjectYml(project.path).config?.lead_turn_budget ?? null;
2544
+ }
2545
+ }
2546
+ }
2547
+ catch { }
2548
+ if (turns !== null) {
2549
+ const color = budget === null ? "" : turns >= budget.stop ? "\x1b[31m" : turns >= budget.warn ? "\x1b[33m" : "";
2550
+ parts.push(`${color}turns ${turns}${color ? "\x1b[0m" : ""}`);
2551
+ }
2422
2552
  console.log(`\x1b[33m⬡\x1b[0m \x1b[2mhive:\x1b[0m ${parts.join(" \x1b[2m·\x1b[0m ")}`);
2423
2553
  }
2424
2554
  // test/docs.test.mjs fails if a label here is missing from docs/install.md. It checks
@@ -2801,10 +2931,21 @@ if (command === "--version" || command === "-v") {
2801
2931
  console.log(line);
2802
2932
  if (drift)
2803
2933
  console.log(`! ${drift}`);
2934
+ if (rest.includes("--check")) {
2935
+ if (process.env.HIVE_NO_UPDATE_CHECK === "1")
2936
+ console.log("update check disabled (HIVE_NO_UPDATE_CHECK)");
2937
+ else
2938
+ console.log(updateLine(refreshUpdate()));
2939
+ }
2940
+ else {
2941
+ const cached = readCachedUpdate();
2942
+ if (cached?.status === "newer" && !cacheIsStale(cached))
2943
+ console.log(`! ${updateLine(cached)}`);
2944
+ }
2804
2945
  process.exit(0);
2805
2946
  }
2806
2947
  const COMMANDS = [
2807
- "lead", "init", "attach", "start", "stop", "show", "hide", "status", "setup", "doctor",
2948
+ "lead", "init", "attach", "start", "stop", "show", "hide", "status", "setup", "upgrade", "doctor",
2808
2949
  LEAD_PANE_EXITED_VERB,
2809
2950
  "pads", "pad", "todos", "todo", "backups", "restore", "runbook", "posture", "profile", "kickoff", "statusline",
2810
2951
  ];
@@ -2821,7 +2962,8 @@ if (!COMMANDS.includes(command)) {
2821
2962
  usage();
2822
2963
  }
2823
2964
  }
2824
- migrate();
2965
+ if (command !== "upgrade")
2966
+ migrate();
2825
2967
  try {
2826
2968
  switch (command) {
2827
2969
  case "lead":
@@ -2854,6 +2996,9 @@ try {
2854
2996
  case "setup":
2855
2997
  cmdSetup(rest);
2856
2998
  break;
2999
+ case "upgrade":
3000
+ cmdUpgrade(rest);
3001
+ break;
2857
3002
  case "doctor":
2858
3003
  cmdDoctor(rest);
2859
3004
  break;
package/dist/db.js CHANGED
@@ -370,6 +370,17 @@ CREATE INDEX idx_wakes_active ON wakes(project_id, kind)
370
370
  CREATE INDEX idx_wakes_parent ON wakes(parent_wake_id) WHERE parent_wake_id IS NOT NULL;
371
371
  `,
372
372
  ];
373
+ export function storeSchemaAhead(database) {
374
+ try {
375
+ const { version } = database.prepare("SELECT MAX(version) AS version FROM migrations").get();
376
+ if (version == null || !Number.isFinite(version) || version <= MIGRATIONS.length)
377
+ return null;
378
+ return { store: version, build: MIGRATIONS.length };
379
+ }
380
+ catch {
381
+ return null;
382
+ }
383
+ }
373
384
  function readAppliedVersions() {
374
385
  return new Set(db.prepare("SELECT version FROM migrations").all().map((r) => r.version));
375
386
  }
package/dist/help.js CHANGED
@@ -153,7 +153,9 @@ runbook (fork it first), not a pad write.`,
153
153
  agent_status(name|agent_id, include_brief?) — liveness, current command,
154
154
  short tail, and the path to the brief this worker was given
155
155
  (include_brief=true returns its text; no transcript records it).
156
- agent_list(include_closed?) — all agents with live status.
156
+ agent_list(include_closed?, limit?, before_id?) — agents with live status.
157
+ include_closed adds closed/parked rows, newest first, limit 20/max 100;
158
+ page with before_id set to the receipt's next_before_id.
157
159
  agent_rename(name|agent_id, new_name) — change the display name. actor_id
158
160
  stays agent:N, so older pad writes and todo comments still point here.
159
161
  A live claude worker is told to retitle its own session, which arrives as
package/dist/mcpConfig.js CHANGED
@@ -1,14 +1,27 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { parse as parseToml } from "smol-toml";
6
- const serverPath = () => fileURLToPath(new URL("./index.js", import.meta.url));
6
+ export const serverPath = () => fileURLToPath(new URL("./index.js", import.meta.url));
7
+ function serverProblem(args, expected) {
8
+ const server = args[0];
9
+ if (!server)
10
+ return "has no server path.";
11
+ if (!existsSync(server))
12
+ return `points at a missing server: ${server}`;
13
+ try {
14
+ if (realpathSync(server) === realpathSync(expected))
15
+ return null;
16
+ }
17
+ catch { }
18
+ return `points at a different server: ${server} (expected ${expected})`;
19
+ }
7
20
  function addCommand(scope, name, pinned, args) {
8
21
  return ` claude mcp add --scope ${scope} ${name} -- ${[pinned, ...args].map((a) => `"${a}"`).join(" ")}`;
9
22
  }
10
- export function registrationProblem(r, pinned) {
11
- const reRegister = [addCommand(r.scope, r.name, pinned, r.args)];
23
+ export function registrationProblem(r, pinned, expectedServer = serverPath()) {
24
+ const reRegister = [addCommand(r.scope, r.name, pinned, [expectedServer])];
12
25
  if (!r.command.includes("/")) {
13
26
  return [
14
27
  `runs "${r.command}", which a Node version manager re-resolves per`,
@@ -25,7 +38,23 @@ export function registrationProblem(r, pinned) {
25
38
  ...reRegister,
26
39
  ];
27
40
  }
28
- return null;
41
+ const problem = serverProblem(r.args, expectedServer);
42
+ return problem ? [problem, `Re-register in ${r.source}:`, ...reRegister] : null;
43
+ }
44
+ export function codexRegistrationProblem(r, pinned, expectedServer = serverPath()) {
45
+ const problem = !r.command.includes("/")
46
+ ? `runs "${r.command}", which a Node version manager re-resolves per directory.`
47
+ : r.command !== pinned
48
+ ? `pins a different interpreter than this CLI runs (${pinned}).`
49
+ : serverProblem(r.args, expectedServer);
50
+ if (!problem)
51
+ return null;
52
+ return [
53
+ problem,
54
+ `Re-register in ${r.source}:`,
55
+ ` codex mcp remove ${r.name}`,
56
+ ` codex mcp add ${r.name} -- "${pinned}" "${expectedServer}"`,
57
+ ];
29
58
  }
30
59
  function readJson(file) {
31
60
  try {
@@ -65,6 +65,22 @@ export function loadProjectYml(projectPath) {
65
65
  warnings.push("context_checkpoint_percent must be an integer from 1 through 100; ignoring it.");
66
66
  }
67
67
  }
68
+ let lead_turn_budget = null;
69
+ if (root.lead_turn_budget != null) {
70
+ const value = root.lead_turn_budget;
71
+ const budget = typeof value === "object" && value !== null && !Array.isArray(value)
72
+ ? value
73
+ : null;
74
+ const warn = budget?.warn;
75
+ const stop = budget?.stop;
76
+ if (typeof warn === "number" && Number.isInteger(warn) && warn > 0 &&
77
+ typeof stop === "number" && Number.isInteger(stop) && stop > warn) {
78
+ lead_turn_budget = { warn, stop };
79
+ }
80
+ else {
81
+ warnings.push("lead_turn_budget must contain positive integer warn and stop values, with stop greater than warn; ignoring it.");
82
+ }
83
+ }
68
84
  const lead = typeof root.lead === "string" && root.lead.trim() !== "" ? root.lead.trim() : null;
69
85
  let placement = null;
70
86
  if (root.placement != null) {
@@ -231,6 +247,7 @@ export function loadProjectYml(projectPath) {
231
247
  review_tags,
232
248
  lead_branches,
233
249
  context_checkpoint_percent,
250
+ lead_turn_budget,
234
251
  dashboard,
235
252
  vars,
236
253
  processes,
package/dist/result.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { runningBuildChange, runningBuildNotice } from "./version.js";
1
2
  export function errorMessage(e) {
2
3
  return e instanceof Error ? e.message : String(e);
3
4
  }
@@ -14,10 +15,32 @@ export function registrationNoticeText(notice) {
14
15
  `it if it owns nothing yet, run the session from the intended directory, or call ` +
15
16
  `project_select.`);
16
17
  }
18
+ const reportedBuilds = new Set();
19
+ export async function appendRunningBuildNotice(result, session) {
20
+ try {
21
+ const change = runningBuildChange();
22
+ if (!change || reportedBuilds.has(change.disk.build_id))
23
+ return result;
24
+ const actor = process.env.HIVE_AGENT_ID;
25
+ if (actor) {
26
+ const { db } = await import("./db.js");
27
+ const row = db.prepare("SELECT kind FROM agents WHERE actor_id = ?").get(actor);
28
+ if (row?.kind === "agent")
29
+ return result;
30
+ }
31
+ if (reportedBuilds.has(change.disk.build_id))
32
+ return result;
33
+ result.content.push({ type: "text", text: runningBuildNotice(change, session) });
34
+ reportedBuilds.add(change.disk.build_id);
35
+ }
36
+ catch {
37
+ }
38
+ return result;
39
+ }
17
40
  export async function run(fn) {
18
41
  const { storeReplaced } = await import("./db.js");
19
42
  if (storeReplaced()) {
20
- return { content: [{ type: "text", text: STORE_REPLACED_MESSAGE }], isError: true };
43
+ return appendRunningBuildNotice({ content: [{ type: "text", text: STORE_REPLACED_MESSAGE }], isError: true });
21
44
  }
22
45
  const { takeRegistrationNotice } = await import("./context.js");
23
46
  let result;
@@ -31,7 +54,7 @@ export async function run(fn) {
31
54
  if (notice) {
32
55
  result.content.push({ type: "text", text: registrationNoticeText(notice) });
33
56
  }
34
- return result;
57
+ return appendRunningBuildNotice(result);
35
58
  }
36
59
  export function parseTags(text) {
37
60
  try {
package/dist/scheduler.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { runningBuildChange, runningBuildNotice } from "./version.js";
1
2
  import { existsSync, mkdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
3
  import { dirname, join, sep } from "node:path";
3
4
  import { dataDir, db, storeReplaced } from "./db.js";
@@ -161,6 +162,25 @@ function reportDeadProcess(agent, snapshot) {
161
162
  catch {
162
163
  }
163
164
  }
165
+ const reportedBuilds = new Set();
166
+ export function reportRunningBuildChange(snapshot) {
167
+ try {
168
+ const change = runningBuildChange();
169
+ const actor = process.env.HIVE_AGENT_ID;
170
+ if (!change || !actor || reportedBuilds.has(change.disk.build_id))
171
+ return;
172
+ const lead = stmt(`SELECT project_id, actor_id, tmux_target, tmux_socket, pane_pid, command FROM agents
173
+ WHERE actor_id = ? AND kind = ? AND status = 'running'`).get(actor, LEAD_KIND);
174
+ if (!lead?.tmux_target || !screenClassifiable(lead.command) ||
175
+ rowAlive(lead.tmux_socket, lead.tmux_target, snapshot) !== true ||
176
+ paneReissued(lead.pane_pid, rowAliveProbe(lead.tmux_socket, lead.tmux_target, snapshot)))
177
+ return;
178
+ insertNotice({ project_id: lead.project_id, owner: lead.actor_id }, lead.actor_id, lead.tmux_target, runningBuildNotice(change), null);
179
+ reportedBuilds.add(change.disk.build_id);
180
+ }
181
+ catch {
182
+ }
183
+ }
164
184
  const deadProcessBody = (name) => `[hive] process "${name}" exited on its own; its pane is gone. Restart it with: hive start "${name}"`;
165
185
  export function janitor(snapshot = liveTargets()) {
166
186
  const reapedCodexHomes = reapClosedCodexHomes();
@@ -359,6 +379,8 @@ export async function tick(snapshot) {
359
379
  if (snapshot === undefined)
360
380
  snapshot = liveTargets();
361
381
  janitor(snapshot);
382
+ if (snapshot)
383
+ reportRunningBuildChange(snapshot);
362
384
  checkConfirmations();
363
385
  pruneStateLog();
364
386
  maybeBackupHourly(db, dataDir);
@@ -837,18 +837,53 @@ export function registerAgents(server) {
837
837
  };
838
838
  }));
839
839
  server.registerTool("agent_list", {
840
- description: "List this project's agents with live status.",
840
+ description: "List this project's agents with live status. Without include_closed, this is every running agent, in full. With include_closed, it is every agent (running and closed/parked), newest first, bounded by limit (default 20, max 100); when the receipt carries next_before_id, page through the rest by passing it back as before_id.",
841
841
  inputSchema: {
842
842
  include_closed: z.boolean().optional(),
843
+ limit: z
844
+ .number()
845
+ .int()
846
+ .min(1)
847
+ .max(100)
848
+ .optional()
849
+ .describe("Max rows to return when include_closed is true, newest first. Default 20, max 100. Ignored otherwise."),
850
+ before_id: z
851
+ .number()
852
+ .int()
853
+ .positive()
854
+ .optional()
855
+ .describe("Page cursor for include_closed: only rows with id below this value. Pass the previous receipt's next_before_id to get the next page."),
843
856
  project_id: projectIdParam,
844
857
  },
845
858
  }, (args) => run(() => {
846
859
  const project = resolveProject(args.project_id);
847
- let sql = "SELECT * FROM agents WHERE project_id = ?";
848
- if (!args.include_closed)
849
- sql += " AND status = 'running'";
850
- const rows = db.prepare(`${sql} ORDER BY id`).all(project.id);
851
860
  const snapshot = liveTargets();
861
+ let rows;
862
+ let total;
863
+ let nextBeforeId;
864
+ if (args.include_closed) {
865
+ const limit = Math.min(args.limit ?? 20, 100);
866
+ total = db.prepare("SELECT COUNT(*) AS n FROM agents WHERE project_id = ?").get(project.id).n;
867
+ let sql = "SELECT * FROM agents WHERE project_id = ?";
868
+ const params = [project.id];
869
+ if (args.before_id != null) {
870
+ sql += " AND id < ?";
871
+ params.push(args.before_id);
872
+ }
873
+ sql += " ORDER BY id DESC LIMIT ?";
874
+ params.push(limit + 1);
875
+ const page = db.prepare(sql).all(...params);
876
+ const truncated = page.length > limit;
877
+ rows = truncated ? page.slice(0, limit) : page;
878
+ if (truncated)
879
+ nextBeforeId = rows[rows.length - 1].id;
880
+ }
881
+ else {
882
+ rows = db
883
+ .prepare("SELECT * FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
884
+ .all(project.id);
885
+ total = rows.length;
886
+ }
852
887
  return {
853
888
  project_id: project.id,
854
889
  project_name: project.name,
@@ -857,6 +892,9 @@ export function registerAgents(server) {
857
892
  note: `${PROBE_FAILED_NOTE} These rows are what the store holds; do not conclude a worker died.`,
858
893
  }
859
894
  : {}),
895
+ total,
896
+ returned: rows.length,
897
+ ...(nextBeforeId !== undefined ? { next_before_id: nextBeforeId } : {}),
860
898
  agents: rows.map((r) => {
861
899
  const summary = agentSummary(r, snapshot);
862
900
  return {
@@ -8,6 +8,9 @@ export function transcriptDirName(cwd) {
8
8
  export function transcriptDir(cwd) {
9
9
  return join(claudeConfigDir(), "projects", transcriptDirName(cwd));
10
10
  }
11
+ export function transcriptPath(cwd, sessionId, explicitPath = "") {
12
+ return explicitPath || (sessionId ? join(transcriptDir(cwd), `${sessionId}.jsonl`) : "");
13
+ }
11
14
  export function resolveTranscriptDir(cwd) {
12
15
  const dir = transcriptDir(cwd);
13
16
  return existsSync(dir) ? dir : null;
@@ -116,8 +119,8 @@ export function readContextFill(kind, worker) {
116
119
  used_tokens: used, window_tokens: window, used_percent: Math.round(used * 100 / window),
117
120
  };
118
121
  }
119
- export function readContextTokens(cwd, sessionId, transcriptPath = "") {
120
- if (!sessionId && !transcriptPath)
122
+ export function readContextTokens(cwd, sessionId, explicitPath = "") {
123
+ if (!sessionId && !explicitPath)
121
124
  return null;
122
- return claudeTokens(tailRecords(transcriptPath || join(transcriptDir(cwd), `${sessionId}.jsonl`)));
125
+ return claudeTokens(tailRecords(transcriptPath(cwd, sessionId, explicitPath)));
123
126
  }
@@ -0,0 +1,26 @@
1
+ import { readFileSync } from "node:fs";
2
+ export function readTurnCount(path) {
3
+ if (!path)
4
+ return null;
5
+ try {
6
+ const ids = new Set();
7
+ for (const line of readFileSync(path, "utf8").split("\n")) {
8
+ if (!line.includes('"type":"assistant"'))
9
+ continue;
10
+ try {
11
+ const record = JSON.parse(line);
12
+ if (record.type !== "assistant")
13
+ continue;
14
+ const message = record.message;
15
+ if (typeof message?.id !== "string" || message.id === "" || message.model === "<synthetic>")
16
+ continue;
17
+ ids.add(message.id);
18
+ }
19
+ catch { }
20
+ }
21
+ return ids.size;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
@@ -0,0 +1,111 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { storeDir } from "./dataDir.js";
5
+ import { versionInfo } from "./version.js";
6
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
7
+ const CACHE_FILE = "update-check.json";
8
+ const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)$/;
9
+ function currentVersion() {
10
+ return versionInfo().line.match(/^hive\s+(\S+)/)?.[1] ?? "0.0.0";
11
+ }
12
+ function compareVersions(current, latest) {
13
+ const currentMatch = VERSION_RE.exec(current);
14
+ const latestMatch = VERSION_RE.exec(latest);
15
+ if (!currentMatch || !latestMatch)
16
+ return "unknown";
17
+ for (let i = 1; i <= 3; i += 1) {
18
+ const difference = Number(latestMatch[i]) - Number(currentMatch[i]);
19
+ if (difference > 0)
20
+ return "newer";
21
+ if (difference < 0)
22
+ return "current";
23
+ }
24
+ return "current";
25
+ }
26
+ function fromCache(record) {
27
+ const current = currentVersion();
28
+ if (record.error !== null) {
29
+ return { current, latest: null, status: "unknown", reason: record.error, checkedAt: record.checked_at };
30
+ }
31
+ const latest = typeof record.latest === "string" ? record.latest : null;
32
+ const status = latest === null ? "unknown" : compareVersions(current, latest);
33
+ return {
34
+ current,
35
+ latest,
36
+ status,
37
+ reason: status === "unknown" ? "unparseable version" : null,
38
+ checkedAt: record.checked_at,
39
+ };
40
+ }
41
+ function cachePath() {
42
+ return join(storeDir(), CACHE_FILE);
43
+ }
44
+ export function readCachedUpdate() {
45
+ try {
46
+ const raw = JSON.parse(readFileSync(cachePath(), "utf8"));
47
+ if (typeof raw.checked_at !== "string" || (typeof raw.latest !== "string" && raw.latest !== null) || (typeof raw.error !== "string" && raw.error !== null)) {
48
+ return null;
49
+ }
50
+ return fromCache({ checked_at: raw.checked_at, latest: raw.latest, error: raw.error });
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ function writeCache(result) {
57
+ const dir = storeDir();
58
+ mkdirSync(dir, { recursive: true });
59
+ const path = cachePath();
60
+ const temporary = `${path}.${process.pid}.tmp`;
61
+ writeFileSync(temporary, JSON.stringify({ checked_at: result.checkedAt, latest: result.latest, error: result.reason }));
62
+ renameSync(temporary, path);
63
+ }
64
+ export function queryUpdate() {
65
+ const current = currentVersion();
66
+ const checkedAt = new Date().toISOString();
67
+ let latest = null;
68
+ let reason = null;
69
+ if (process.env.HIVE_NO_UPDATE_CHECK === "1") {
70
+ reason = "disabled (HIVE_NO_UPDATE_CHECK)";
71
+ }
72
+ else {
73
+ try {
74
+ latest = execFileSync("npm", ["view", "@cmgmyr/hive", "version"], {
75
+ encoding: "utf8",
76
+ stdio: ["ignore", "pipe", "pipe"],
77
+ timeout: 5000,
78
+ }).trim();
79
+ if (!VERSION_RE.test(latest) || !VERSION_RE.test(current))
80
+ reason = "unparseable version";
81
+ }
82
+ catch (error) {
83
+ const code = error.code;
84
+ reason = code === "ENOENT" ? "npm not found" : code === "ETIMEDOUT" ? "timed out" : "offline or npm failed";
85
+ }
86
+ }
87
+ const status = reason === null && latest !== null ? compareVersions(current, latest) : "unknown";
88
+ return { current, latest, status, reason, checkedAt };
89
+ }
90
+ export function refreshUpdate() {
91
+ const result = queryUpdate();
92
+ writeCache(result);
93
+ return result;
94
+ }
95
+ export function cacheIsStale(update, now = Date.now()) {
96
+ if (!update?.checkedAt)
97
+ return true;
98
+ const checked = Date.parse(update.checkedAt);
99
+ return !Number.isFinite(checked) || now - checked >= CACHE_TTL_MS;
100
+ }
101
+ export function updateLine(update) {
102
+ if (update.status === "newer" && update.latest) {
103
+ return `update available: hive ${update.latest} (you have ${update.current}); run: hive upgrade`;
104
+ }
105
+ if (update.status === "current")
106
+ return `up to date: hive ${update.current} is the latest on npm`;
107
+ return `update check: unknown (${update.reason ?? "offline or npm failed"})`;
108
+ }
109
+ export function shouldAutoRefresh() {
110
+ return process.stdout.isTTY === true && !process.env.CI && process.env.HIVE_NO_UPDATE_CHECK !== "1";
111
+ }
@@ -0,0 +1,49 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { realpathSync, statSync } from "node:fs";
3
+ import { dirname, isAbsolute, join } from "node:path";
4
+ import { cliPath } from "./dispatcher.js";
5
+ export function detectInstallShape(cli = cliPath(), npmCommand = "npm") {
6
+ const packageRoot = dirname(dirname(cli));
7
+ let gitState = "absent";
8
+ try {
9
+ const git = statSync(join(packageRoot, ".git"));
10
+ if (git.isDirectory() || git.isFile())
11
+ return { kind: "checkout", packageRoot };
12
+ gitState = "neither file nor directory";
13
+ }
14
+ catch (error) {
15
+ if (error.code !== "ENOENT")
16
+ gitState = String(error);
17
+ }
18
+ let npmRoot = null;
19
+ let reason;
20
+ try {
21
+ npmRoot = execFileSync(npmCommand, ["root", "-g"], {
22
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5000,
23
+ }).trim();
24
+ if (!isAbsolute(npmRoot))
25
+ throw new Error("npm root -g did not return an absolute path");
26
+ if (realpathSync(packageRoot) === realpathSync(join(npmRoot, "@cmgmyr/hive"))) {
27
+ return { kind: "global", packageRoot, npmRoot, npmCommand };
28
+ }
29
+ reason = "npm root -g does not own the running package";
30
+ }
31
+ catch (error) {
32
+ reason = `could not confirm npm root ownership: ${String(error)}`;
33
+ }
34
+ return { kind: "unknown", packageRoot, cli, gitState, npmRoot, reason };
35
+ }
36
+ export function checkoutUpgradeSteps(packageRoot, node = process.execPath) {
37
+ return [
38
+ { label: "pull", command: "git", args: ["pull", "--ff-only"], cwd: packageRoot },
39
+ { label: "install", command: "npm", args: ["install"], cwd: packageRoot },
40
+ { label: "build", command: "npm", args: ["run", "build"], cwd: packageRoot },
41
+ { label: "setup", command: node, args: [join(packageRoot, "dist/cli.js"), "setup"], cwd: packageRoot },
42
+ ];
43
+ }
44
+ export function globalUpgradeSteps(shape, node = process.execPath) {
45
+ return [
46
+ { label: "install", command: shape.npmCommand, args: ["install", "-g", "@cmgmyr/hive@latest"] },
47
+ { label: "setup", command: node, args: [join(shape.npmRoot, "@cmgmyr/hive/dist/cli.js"), "setup"] },
48
+ ];
49
+ }
package/dist/version.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { readFileSync } from "node:fs";
2
+ import { readFileSync, statSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  const moduleDir = dirname(fileURLToPath(import.meta.url));
@@ -13,6 +13,43 @@ function readBuildInfo() {
13
13
  return null;
14
14
  }
15
15
  }
16
+ function readRunningBuild() {
17
+ const info = readBuildInfo();
18
+ return info && typeof info.build_id === "string" && info.build_id.trim() !== "" &&
19
+ (info.sha === null || typeof info.sha === "string") && typeof info.dirty === "boolean"
20
+ ? info : null;
21
+ }
22
+ const loadedBuild = readRunningBuild();
23
+ let diskSignature;
24
+ let diskBuild = null;
25
+ export function runningBuildChange() {
26
+ try {
27
+ if (!loadedBuild)
28
+ return null;
29
+ const stat = statSync(join(moduleDir, "build-info.json"));
30
+ const signature = `${stat.ino}:${stat.size}:${stat.mtimeMs}`;
31
+ if (signature !== diskSignature) {
32
+ diskBuild = readRunningBuild();
33
+ diskSignature = signature;
34
+ }
35
+ return diskBuild && diskBuild.build_id !== loadedBuild.build_id
36
+ ? { loaded: loadedBuild, disk: diskBuild } : null;
37
+ }
38
+ catch {
39
+ diskSignature = undefined;
40
+ diskBuild = null;
41
+ return null;
42
+ }
43
+ }
44
+ export function runningBuildNotice(change, session) {
45
+ const subject = session ? `session ${JSON.stringify(session)}'s` : "this session's";
46
+ const detail = (info) => info.sha === null ? "no git sha" : describe(info.sha, info.dirty);
47
+ const sameDescription = change.loaded.version === change.disk.version && detail(change.loaded) === detail(change.disk);
48
+ const label = (info) => `hive ${info.version} (${detail(info)}${sameDescription ? `, build ${info.build_id.slice(0, 8)}` : ""})`;
49
+ return `hive: ${subject} hive server loaded ${label(change.loaded)}; ` +
50
+ `the build on disk changed to ${label(change.disk)}. ` +
51
+ "Restart this session, or reconnect hive in /mcp, to pick it up.";
52
+ }
16
53
  function packageVersion() {
17
54
  try {
18
55
  const raw = JSON.parse(readFileSync(join(checkoutRoot, "package.json"), "utf8"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmgmyr/hive",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Shared memory and coordination for Claude Code sessions: one lead, many tmux workers, one project-scoped store.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "build": "tsc && node scripts/gen-version.mjs",
12
+ "check:mermaid": "node scripts/check-mermaid.mjs",
12
13
  "prepublishOnly": "npm run build",
13
14
  "watch": "tsc --watch",
14
15
  "start": "node dist/index.js",