@cmgmyr/hive 0.1.0 → 1.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.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center"><img src="https://raw.githubusercontent.com/cmgmyr/hive/main/docs/assets/logo-card.svg" alt="hive" height="112"></p>
2
- <p align="center"><a href="https://github.com/cmgmyr/hive/actions/workflows/ci.yml"><img src="https://github.com/cmgmyr/hive/actions/workflows/ci.yml/badge.svg" alt="CI"></a></p>
2
+ <p align="center"><a href="https://www.npmjs.com/package/@cmgmyr/hive"><img src="https://img.shields.io/npm/v/%40cmgmyr%2Fhive?label=npm" alt="npm"></a> <a href="https://github.com/cmgmyr/hive/actions/workflows/ci.yml"><img src="https://github.com/cmgmyr/hive/actions/workflows/ci.yml/badge.svg" alt="CI"></a></p>
3
3
 
4
4
  Shared, persistent memory and a visible crew of tmux workers for Claude Code and Codex sessions on one project.
5
5
 
@@ -14,17 +14,16 @@ Shared, persistent memory and a visible crew of tmux workers for Claude Code and
14
14
  Requirements: macOS, Node `^22.14.0 || >=23.6.0`, [Claude Code](https://claude.com/claude-code), and tmux for the agent tools. codex is optional, only needed if a project opts a worker into it; see [docs/install.md](docs/install.md#codex-workers).
15
15
 
16
16
  ```bash
17
- git clone https://github.com/cmgmyr/hive.git hive && cd hive
18
- npm install
19
- npm run build
20
- npm link # puts the hive command on your PATH
21
- hive setup # pins that command to one interpreter
17
+ npm install -g @cmgmyr/hive
18
+ hive setup # pins the hive command to one interpreter, prints the MCP line
22
19
  brew install tmux
23
- claude mcp add --scope user hive -- "$(command -v node)" "$(pwd)/dist/index.js"
24
- ln -s "$(pwd)/claude-plugin" ~/.claude/skills/hive # optional: session-start kickoff
20
+ claude mcp add --scope user hive -- "$(command -v node)" "$(npm root -g)/@cmgmyr/hive/dist/index.js"
21
+ ln -s "$(npm root -g)/@cmgmyr/hive/claude-plugin" ~/.claude/skills/hive # optional: session-start kickoff
25
22
  hive doctor # verify: node, ABI, tmux, claude, database, hooks all green
26
23
  ```
27
24
 
25
+ Working from a clone instead? See [docs/install.md](docs/install.md#from-source).
26
+
28
27
  Put `~/.local/bin` on your PATH below your version manager's block. See [Node version and the interpreter pin](docs/install.md#node-version-and-the-interpreter-pin) for why the order matters.
29
28
 
30
29
  ## First run
@@ -1 +1 @@
1
- {"version":"0.1.0","sha":"de83214","dirty":false}
1
+ {"version":"1.1.0","sha":"38f535a","dirty":false}
package/dist/cli.js CHANGED
@@ -13,12 +13,13 @@ import { ATTACH_MODES, AUTO_ATTACH_MODES, attachMode, isAttachMode, isAutoAttach
13
13
  import { cliPath, dispatcherDir, dispatcherPath, dispatcherScript, durabilityLines, firstHiveOnPath, linkedWorktreePin, pathAdvice, readDispatcher, } from "./dispatcher.js";
14
14
  import { codexConfigPath, codexHiveRegistrations, 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
17
  import { isLowHeadroom, orphanLoginShellDetails, ptyHeadroom } from "./ptys.js";
18
18
  import { addProject, agentProjectPin, currentActor, effectiveProjectId, findProjectForCwd, getProjectByPath, gitPrimaryRoot, getProject, listProjects, registeredAncestor, takeRegistrationNotice, } from "./context.js";
19
19
  import { ensureHooksFile } from "./hooks.js";
20
20
  import { errorMessage, parseTags, registrationNoticeText, withTrailingNewline } from "./result.js";
21
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 { readTurnCount } from "./turnCount.js";
22
23
  import { STALL_BOUND_SECONDS } from "./backgroundTasks.js";
23
24
  import { probeSessionInterpreter, reexecTarget, sessionStartVerdict, } from "./sessionProbe.js";
24
25
  import { backupHealth, backupNow, backupsDir, formatBytes, listSnapshots, previewRestore, restoreSnapshot, totalSizeBytes, } from "./backup.js";
@@ -562,6 +563,8 @@ placement: split # placement for workers and visible processes: s
562
563
 
563
564
  # context_checkpoint_percent: null # unset means off; integer 1-100 to enable
564
565
 
566
+ # lead_turn_budget: {warn: 300, stop: 600} # optional lead statusline thresholds
567
+
565
568
  # review_tags: [from-review] # todo tags \`hive doctor\` counts as review findings and
566
569
  # reports as triaged (has a comment, completed, or archived)
567
570
  # or untriaged. Absent means doctor tracks none.
@@ -1978,6 +1981,10 @@ function cmdDoctor(argv) {
1978
1981
  check("claude", () => execFileSync("which", ["claude"], { encoding: "utf8" }).trim());
1979
1982
  check("database", () => {
1980
1983
  const n = db.prepare("SELECT COUNT(*) AS n FROM migrations").get().n;
1984
+ const ahead = storeSchemaAhead(db);
1985
+ if (ahead) {
1986
+ 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.`);
1987
+ }
1981
1988
  return `${dataDir} (schema v${n})`;
1982
1989
  });
1983
1990
  check("hooks file", () => ensureHooksFile());
@@ -2388,6 +2395,7 @@ function cmdStatusline() {
2388
2395
  AND NOT EXISTS (${OPEN_BLOCKERS_SQL})`);
2389
2396
  const pads = count("SELECT COUNT(*) AS n FROM pads WHERE project_id = ? AND archived = 0");
2390
2397
  const wakes = count(`SELECT COUNT(*) AS n FROM wakes WHERE project_id = ? AND ${ACTIVE_TIMER_WHERE}`);
2398
+ const schemaAhead = storeSchemaAhead(db);
2391
2399
  // One statement, not two: the count and the chosen row must come from the same read, or a wake that
2392
2400
  // delivers/re-arms between two separate queries leaves the second with no row to read.
2393
2401
  const held = db
@@ -2403,7 +2411,7 @@ function cmdStatusline() {
2403
2411
  (SELECT held_reason FROM chosen) AS held_reason,
2404
2412
  (SELECT first_held_at FROM chosen) AS first_held_at`)
2405
2413
  .get(project.id, `${HELD_REASON_UNSUBMITTED_INPUT_PREFIX}%`, `${HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX}%`, project.id);
2406
- if (agents + commands + todos + pads + wakes === 0)
2414
+ if (agents + commands + todos + pads + wakes === 0 && !schemaAhead)
2407
2415
  return;
2408
2416
  const s = (n) => (n === 1 ? "" : "s");
2409
2417
  const parts = [
@@ -2419,6 +2427,34 @@ function cmdStatusline() {
2419
2427
  const age = held.first_held_at ? humanizeAge(ageSecondsSince(held.first_held_at)) : "?";
2420
2428
  parts.push(`${held.n} held (${age}, ${heldReasonLabel(held.held_reason)})`);
2421
2429
  }
2430
+ if (schemaAhead)
2431
+ parts.push("store ahead (update hive)");
2432
+ let turns = null;
2433
+ let budget = null;
2434
+ try {
2435
+ const actorId = process.env.HIVE_AGENT_ID;
2436
+ if (actorId) {
2437
+ 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);
2438
+ if (lead) {
2439
+ let inputPath = "";
2440
+ if (!process.stdin.isTTY) {
2441
+ try {
2442
+ const raw = readFileSync(0, "utf8").trim();
2443
+ const input = raw ? JSON.parse(raw) : null;
2444
+ inputPath = typeof input?.transcript_path === "string" ? input.transcript_path : "";
2445
+ }
2446
+ catch { }
2447
+ }
2448
+ turns = readTurnCount(inputPath);
2449
+ budget = loadProjectYml(project.path).config?.lead_turn_budget ?? null;
2450
+ }
2451
+ }
2452
+ }
2453
+ catch { }
2454
+ if (turns !== null) {
2455
+ const color = budget === null ? "" : turns >= budget.stop ? "\x1b[31m" : turns >= budget.warn ? "\x1b[33m" : "";
2456
+ parts.push(`${color}turns ${turns}${color ? "\x1b[0m" : ""}`);
2457
+ }
2422
2458
  console.log(`\x1b[33m⬡\x1b[0m \x1b[2mhive:\x1b[0m ${parts.join(" \x1b[2m·\x1b[0m ")}`);
2423
2459
  }
2424
2460
  // test/docs.test.mjs fails if a label here is missing from docs/install.md. It checks
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
@@ -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,
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmgmyr/hive",
3
- "version": "0.1.0",
3
+ "version": "1.1.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",
@@ -31,6 +32,7 @@
31
32
  "url": "git+https://github.com/cmgmyr/hive.git"
32
33
  },
33
34
  "homepage": "https://github.com/cmgmyr/hive#readme",
35
+ "funding": "https://github.com/sponsors/cmgmyr",
34
36
  "bugs": {
35
37
  "url": "https://github.com/cmgmyr/hive/issues"
36
38
  },
@@ -48,12 +50,12 @@
48
50
  "@modelcontextprotocol/sdk": "^1.30.0",
49
51
  "better-sqlite3": "^13.0.3",
50
52
  "smol-toml": "^1.8.0",
51
- "yaml": "^2.9.0",
52
- "zod": "^4.4.3"
53
+ "yaml": "^2.9.1",
54
+ "zod": "^4.6.5"
53
55
  },
54
56
  "devDependencies": {
55
57
  "@types/better-sqlite3": "^9.6.0",
56
- "@types/node": "^26.1.2",
58
+ "@types/node": "^26.6.2",
57
59
  "typescript": "^7.0.2"
58
60
  },
59
61
  "allowScripts": {