@cmgmyr/hive 1.11.2 → 1.11.4

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.
@@ -1,7 +1,7 @@
1
1
  import { atom, read, update } from 'claude-code'
2
2
  import type { EngineInterface, Register } from 'claude-code'
3
3
 
4
- import { REFRESH_MS, buildCrewView, failedView, readCrew } from '../model.mjs'
4
+ import { REFRESH_MS, buildCrewView, failedView, pollCrew } from '../model.mjs'
5
5
  import type { CrewView } from '../types'
6
6
 
7
7
  const PANE = 'hive-crew'
@@ -12,13 +12,14 @@ let polling = false
12
12
  async function startPolling($: EngineInterface) {
13
13
  if (polling) return
14
14
  polling = true
15
- const cwd = await $.session.cwd()
15
+ const state = { cwd: await $.session.cwd() }
16
+ const isDir = (dir: string) => $.fs.stat(dir).then(s => s.kind === 'dir', () => false)
16
17
  let busy = false
17
18
  const refresh = async () => {
18
19
  if (busy) return
19
20
  busy = true
20
21
  try {
21
- const snapshot = await readCrew((argv, init) => $.process.run(argv, init), cwd)
22
+ const snapshot = await pollCrew((argv, init) => $.process.run(argv, init), isDir, state, [() => $.session.cwd(), () => $.session.root()])
22
23
  await update($, crew, prev => buildCrewView(snapshot, prev, Date.now()))
23
24
  } catch (err) {
24
25
  await update($, crew, prev => failedView(prev, String(err instanceof Error ? err.message : err)))
@@ -9,12 +9,33 @@ export function ago(seconds) {
9
9
  return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`;
10
10
  }
11
11
 
12
+ export async function resolveCwd(isDir, candidates) {
13
+ const tried = [];
14
+ for (const candidate of candidates) {
15
+ let dir;
16
+ try {
17
+ dir = await candidate();
18
+ } catch {
19
+ continue;
20
+ }
21
+ if (typeof dir !== "string" || dir === "" || tried.includes(dir)) continue;
22
+ tried.push(dir);
23
+ if (await isDir(dir)) return dir;
24
+ }
25
+ throw new Error(`hive crew has no directory to run in (tried ${tried.join(", ") || "none"})`);
26
+ }
27
+
28
+ export async function pollCrew(run, isDir, state, fallbacks) {
29
+ state.cwd = await resolveCwd(isDir, [() => state.cwd, ...fallbacks]);
30
+ return readCrew(run, state.cwd);
31
+ }
32
+
12
33
  export async function readCrew(run, cwd) {
13
34
  let res;
14
35
  try {
15
36
  res = await run(["hive", "crew", "--json"], { cwd, timeoutMs: TIMEOUT_MS });
16
37
  } catch (err) {
17
- throw new Error(`hive crew did not answer: ${String(err instanceof Error ? err.message : err).slice(0, 100)}`);
38
+ throw new Error(`hive crew --json in ${cwd} did not answer: ${String(err instanceof Error ? err.message : err).slice(0, 100)}`);
18
39
  }
19
40
  if (res.exitCode !== 0) {
20
41
  const line = String(res.stderr ?? "").split("\n").map((l) => l.trim()).find((l) => l !== "");
package/dist/abi.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { dirname, join } from "node:path";
4
- import { cliPath, dispatcherPath, readDispatcher } from "./dispatcher.js";
4
+ import { cliPath, dispatcherPath, readDispatcher, repinCommand } from "./dispatcher.js";
5
5
  const requireFromHere = createRequire(import.meta.url);
6
6
  export function classifyAddonLoadError(error) {
7
7
  return /NODE_MODULE_VERSION \d+|Module did not self-register/i.test(error) ? "mismatch" : "load";
@@ -129,7 +129,7 @@ export function abiFixLines(status, pinned) {
129
129
  if (status.failure === "napi") {
130
130
  const need = status.nodeApiRequired;
131
131
  const range = need === null ? null : nodeRangeForNodeApi(need);
132
- const good = pinned ? `"${pinned}"` : `<a Node matching ${range ?? "that Node-API level"}>`;
132
+ const good = pinned ?? `<a Node matching ${range ?? "that Node-API level"}>`;
133
133
  return [
134
134
  `better-sqlite3's addon is built against Node-API ${need}${range ? `, which only Node ${range} provides` : ""}.`,
135
135
  `This interpreter provides Node-API ${describeNodeApi(status.nodeApi)}, so the addon cannot load here.`,
@@ -141,7 +141,7 @@ export function abiFixLines(status, pinned) {
141
141
  "",
142
142
  `Run hive under a Node matching ${range ?? "that Node-API level"}, and re-pin the \`hive\` command`,
143
143
  "to it (setup pins whatever Node runs it, so it has to be that one):",
144
- ` ${good} "${cliPath()}" setup`,
144
+ ` ${repinCommand(good, cliPath())}`,
145
145
  ];
146
146
  }
147
147
  if (status.failure === "missing") {
@@ -164,7 +164,7 @@ export function abiFixLines(status, pinned) {
164
164
  if (status.failure !== "mismatch") {
165
165
  return ["Build it: npm install && npm run build"];
166
166
  }
167
- const good = pinned ? `"${pinned}"` : "<the Node that built it>";
167
+ const good = pinned ?? "<the Node that built it>";
168
168
  return [
169
169
  "better-sqlite3's addon is ABI-locked to the Node that compiled it, and a Node",
170
170
  "version manager resolves `node` from the working directory, so a `cd` is enough",
@@ -172,10 +172,12 @@ export function abiFixLines(status, pinned) {
172
172
  "",
173
173
  "Run hive under the Node it was built for, and re-pin the `hive` command to it",
174
174
  "(setup pins whatever Node runs it, so it has to be that one):",
175
- ` ${good} "${cliPath()}" setup`,
175
+ ` ${repinCommand(good, cliPath())}`,
176
176
  "",
177
- "Or rebuild the addon for the Node you are on, and re-pin to that:",
178
- " npm install && npm run build && hive setup",
177
+ "Rebuilding is not the fix for the supported addon. Update hive, then re-pin this CLI:",
178
+ " Global install: npm install -g @cmgmyr/hive",
179
+ " Checkout: npm install && npm run build",
180
+ ` ${repinCommand(good, cliPath())}`,
179
181
  ];
180
182
  }
181
183
  function workingInterpreterFromDispatcher() {
@@ -1 +1 @@
1
- {"version":"1.11.2","sha":"6919b35","dirty":false,"build_id":"f1a0c66c3af90eb5af9a295a8b233d987cf4133f6355eea5cf46b87f5289e0bd"}
1
+ {"version":"1.11.4","sha":"be85c43","dirty":false,"build_id":"002d81642b9604f453a66605e3bc23656ad7b425069e016be8bfda87d6e532d1"}
package/dist/cli.js CHANGED
@@ -3,14 +3,14 @@ import { execFileSync, spawn, spawnSync } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from "node:fs";
5
5
  import { homedir, tmpdir } from "node:os";
6
- import { join, resolve, sep } from "node:path";
6
+ import { dirname, join, resolve, sep } from "node:path";
7
7
  import { createInterface } from "node:readline/promises";
8
8
  import { pathToFileURL } from "node:url";
9
9
  import { checkAbi, describeAbi, describeInterpreter, nodeRangeForNodeApi, requiredNodeApi } from "./abi.js";
10
10
  import { versionInfo } from "./version.js";
11
11
  import { claudeConfigDir } from "./claudeDir.js";
12
12
  import { ATTACH_MODES, AUTO_ATTACH_MODES, attachMode, isAttachMode, isAutoAttach, migrateLegacyConfig, resolvedAutoAttach, setAttachMode, setAutoAttach, } from "./config.js";
13
- import { cliPath, dispatcherDir, dispatcherPath, dispatcherScript, durabilityLines, firstHiveOnPath, linkedWorktreePin, pathAdvice, readDispatcher, } from "./dispatcher.js";
13
+ import { cliPath, dispatcherDir, dispatcherPath, dispatcherScript, durabilityLines, firstHiveOnPath, linkedWorktreePin, pathAdvice, repinCommand, readDispatcher, } from "./dispatcher.js";
14
14
  import { codexConfigPath, codexHiveRegistrations, codexRegistrationProblem, hiveRegistrations, registrationOffer, registrationProblem, } from "./mcpConfig.js";
15
15
  import { DEFAULT_DATA_DIR, storeDir } from "./dataDir.js";
16
16
  import { dataDir, db, migrate, storeSchemaAhead } from "./db.js";
@@ -1889,7 +1889,7 @@ function reportDispatcher() {
1889
1889
  }
1890
1890
  info("dispatcher", `${dispatcher.file} -> ${dispatcher.node ?? "an exec line hive cannot parse"}`);
1891
1891
  if (!dispatcher.node || !dispatcher.cli) {
1892
- gatingWarn("dispatcher", "written by another version of hive; re-run `hive setup` to refresh it.");
1892
+ gatingWarn("dispatcher", "written by another version of hive; use this re-pin command to refresh it:", ` ${repinCommand(process.execPath, cliPath())}`);
1893
1893
  }
1894
1894
  else if (!existsSync(dispatcher.node)) {
1895
1895
  const need = requiredNodeApi();
@@ -1907,7 +1907,7 @@ function reportDispatcher() {
1907
1907
  gatingWarn("dispatcher", `pinned to a linked git worktree, which this repo tears down: ${dispatcher.cli}`, "A worktree teardown leaves `hive` failing with MODULE_NOT_FOUND, and running `hive setup`", "to repair it just re-pins the same worktree, since that shim is what runs the command.", `Re-pin from the durable checkout instead: "${dispatcher.node}" "${repairCli}" setup`);
1908
1908
  }
1909
1909
  else if (dispatcher.node !== process.execPath || dispatcher.cli !== cliPath()) {
1910
- gatingWarn("dispatcher", "pinned to a different build than this CLI is running.", `this run: ${process.execPath} ${cliPath()}`, "Re-pin after a rebuild: npm run build && hive setup");
1910
+ gatingWarn("dispatcher", "pinned to a different build than this CLI is running.", `this run: ${process.execPath} ${cliPath()}`, `Re-pin after a rebuild: ${repinCommand(process.execPath, cliPath())}`);
1911
1911
  }
1912
1912
  if (onPath !== dispatcher.file)
1913
1913
  gatingWarn("dispatcher", ...pathAdvice(dispatcherDir(), dispatcher.file));
@@ -2005,8 +2005,12 @@ function reportPtyHeadroom(orphans) {
2005
2005
  const scratchHeld = orphans ? orphans.live + orphans.wedged : 0;
2006
2006
  const livePanes = db.prepare("SELECT COUNT(*) AS n FROM agents WHERE status = 'running'").get()
2007
2007
  .n;
2008
- warn("ptys", `${headroom.allocated} of ${headroom.max} in use (${free} free), below the safety margin${overrideNote}`, shellsLine, `${scratchHeld} orphaned scratch tmux server(s) still holding a socket (detail in the scratch tmux servers check)`, `${livePanes} pane(s) recorded 'running' in hive's own store (leads and workers, this machine)`, "reap the orphaned shells and scratch servers with `node scripts/sweep-scratch.mjs` (dry run by default, " +
2009
- "`--kill` to act)");
2008
+ warn("ptys", `${headroom.allocated} of ${headroom.max} in use (${free} free), below the safety margin${overrideNote}`, shellsLine, `${scratchHeld} orphaned scratch tmux server(s) still holding a socket (detail in the scratch tmux servers check)`, `${livePanes} pane(s) recorded 'running' in hive's own store (leads and workers, this machine)`, ...(existsSync(join(dirname(cliPath()), "..", "scripts", "sweep-scratch.mjs"))
2009
+ ? [
2010
+ "reap the orphaned shells and scratch servers with `node scripts/sweep-scratch.mjs` (dry run by default, " +
2011
+ "`--kill` to act)",
2012
+ ]
2013
+ : []));
2010
2014
  }
2011
2015
  function reportOrphanTmuxServers(orphans) {
2012
2016
  if (!orphans)
@@ -10,6 +10,9 @@ export const cliPath = () => fileURLToPath(new URL("./cli.js", import.meta.url))
10
10
  function shQuote(s) {
11
11
  return `'${s.replace(/'/g, `'\\''`)}'`;
12
12
  }
13
+ export function repinCommand(node, cli) {
14
+ return `${shQuote(node)} ${shQuote(cli)} setup`;
15
+ }
13
16
  export function dispatcherScript(node, cli) {
14
17
  return [
15
18
  "#!/bin/sh",
@@ -17,7 +20,7 @@ export function dispatcherScript(node, cli) {
17
20
  "# Written by `hive setup`. It runs hive under one fixed interpreter, so a",
18
21
  "# version manager resolving `node` per directory cannot land hive on a Node",
19
22
  "# better-sqlite3's addon refuses to load under.",
20
- "# Regenerate after every update: npm install && npm run build && hive setup",
23
+ `# Regenerate after every update: ${repinCommand(node, cli)}`,
21
24
  `exec ${shQuote(node)} ${shQuote(cli)} "$@"`,
22
25
  "",
23
26
  ].join("\n");
package/dist/help.js CHANGED
@@ -7,10 +7,10 @@ one project-scoped state store: pads, todos, kv, leases.
7
7
  Call help(topic="<name>") for details. Topics: ${Object.keys(HELP_TOPICS).join(", ")}.
8
8
 
9
9
  Scope resolution for project-scoped tools:
10
- pass project_id for a one-off override
11
- otherwise hive uses the project selected in this session
12
- otherwise it auto-detects from the working directory, or auto-selects a
13
- single registered project
10
+ resolution order is project_id override, session selection, worker pin,
11
+ then working directory; a git worktree or subdirectory resolves to its
12
+ primary checkout, and an unmatched directory becomes a new project
13
+ instead of falling back to an unrelated one
14
14
 
15
15
  Quick start:
16
16
  1. whoami — see your actor id and effective project
@@ -46,6 +46,9 @@ QUESTIONS — open questions for the human, recorded in the store
46
46
  PROJECTS — scope management
47
47
  project_list, project_add, project_select, project_prune
48
48
 
49
+ QUEEN AUDIT — confirmed queen writes and refused cross-project guard calls
50
+ queen_audit_list(project_id?, limit?)
51
+
49
52
  ACTORS — who is writing
50
53
  actor_prune`;
51
54
  }
@@ -131,7 +134,7 @@ If the human asks to change how work runs here, that is an edit to the
131
134
  runbook (fork it first), not a pad write.`,
132
135
  agents: `AGENTS — spawn and drive worker sessions in tmux
133
136
 
134
- agent_spawn(name?, model?, command?, extra_args?, read_only?, cwd?, placement?, layout?) —
137
+ agent_spawn(name?, model?, command?, extra_args?, harness?, read_only?, cwd?, placement?, layout?, todo_id?) —
135
138
  start a worker (default command: claude) in session hive-main.
136
139
  placement="split" (default) tiles the worker as a pane in the lead's
137
140
  window so the whole crew shares one screen; placement="window" gives it
@@ -169,7 +172,7 @@ runbook (fork it first), not a pad write.`,
169
172
  stays agent:N, so older pad writes and todo comments still point here.
170
173
  A live claude worker is told to retitle its own session, which arrives as
171
174
  a user turn: rename between assignments, not mid-task.
172
- agent_park(name|agent_id) — END OF DAY. Kill the pane, mark the row PARKED
175
+ agent_park(name|agent_id, confirm_self?) — END OF DAY. Kill the pane, mark the row PARKED
173
176
  rather than plain closed, record the branch, and hand back a board line
174
177
  plus the one call that brings the lane back. Use this instead of
175
178
  agent_close whenever the lane is PAUSED rather than finished: "closed"
@@ -179,7 +182,7 @@ runbook (fork it first), not a pad write.`,
179
182
  with the same actor_id and its full prior context. It does not send
180
183
  the assignment; agent_send it afterwards. Read the pane before
181
184
  believing any wake about a worker you just resumed.
182
- agent_close(name|agent_id) — kill the worker's pane and mark closed. Capture
185
+ agent_close(name|agent_id, confirm_self?, row_only?) — kill the worker's pane and mark closed. Capture
183
186
  handoffs first; output is not retained. Self-close needs confirm_self.
184
187
  On a PARKED row (by agent_id) it releases the park instead, which is how
185
188
  you abandon a lane you have decided not to resume. row_only=true marks
@@ -239,8 +242,9 @@ its turn), waiting (needs permission or input). Non-claude commands show
239
242
  interval used for firings after this one. Only body can be edited on an
240
243
  idle wake (from wake_when_idle); delay_seconds/repeat_every_seconds
241
244
  apply to a delay wake (from wake_set) only.
242
- wake_cancel(wake_id) — cancel a pending wake-up you own
243
- wake_list() — pending wake-ups in this project
245
+ wake_cancel(wake_id) — cancel a pending wake-up you own, or any pending
246
+ wake-up in this project when you are a running lead
247
+ wake_list() — pending wake-ups and recently delivered one-shot wakes
244
248
 
245
249
  Delivery contract:
246
250
  When a wake-up fires, its body is typed into the target session's terminal
@@ -338,7 +342,7 @@ place.`,
338
342
  pad_append(pad_id, content, expected_revision?) — add to the end
339
343
  pad_edit(pad_id, old_text, new_text, expected_revision?) — replace one
340
344
  unique literal occurrence; include enough context to disambiguate
341
- pad_list(query?, tags?, include_archived?) — summaries without content;
345
+ pad_list(query?, tags?, include_archived?, limit?, offset?) — summaries without content;
342
346
  query matches names and content and returns a snippet
343
347
  pad_archive(pad_id, archived?) — archive frees the name for a new active
344
348
  pad; content stays readable by pad_id. archived=false unarchives.
@@ -401,10 +405,12 @@ a phase flag. Use pads for anything longer than a line or two.`,
401
405
  to a pending or defaulted question
402
406
 
403
407
  hive stores the question; it never puts it to the human. Render it in your
404
- own turn. Answering queues a wake to the asker carrying the question,
405
- answer and notes, unless the answerer is the asker or the asker cannot be
406
- reached; the answer is kept either way. Nothing expires or defaults on its
407
- own: only the asker or the human moves a question out of pending.`,
408
+ own turn. Answering queues a wake to the asker carrying the question, answer
409
+ and notes, or reroutes it to the project's running lead when the asker has
410
+ no running row. It skips wake delivery when you are the asker, no eligible
411
+ target is running, or the target cannot be resolved. The answer stays
412
+ readable either way. Nothing expires or defaults on its own: only the asker
413
+ or the human moves a question out of pending.`,
408
414
  leases: `LEASES — advisory expiring claims on shared state
409
415
 
410
416
  lease_acquire(key, ttl_seconds) — non-blocking; returns the holder if
@@ -0,0 +1,8 @@
1
+ export function decodeKvValue(value) {
2
+ try {
3
+ return JSON.parse(value);
4
+ }
5
+ catch {
6
+ return value;
7
+ }
8
+ }
package/dist/scheduler.js CHANGED
@@ -4,6 +4,7 @@ import { existsSync, lstatSync, mkdirSync, realpathSync, renameSync, statSync, u
4
4
  import { createHash } from "node:crypto";
5
5
  import { dirname, join, sep } from "node:path";
6
6
  import { dataDir, db, storeReplaced } from "./db.js";
7
+ import { decodeKvValue } from "./kvValue.js";
7
8
  import { maybeBackupHourly } from "./backup.js";
8
9
  import { renderDashboardForWrite } from "./dashboard.js";
9
10
  import { snapshotProcesses } from "./processes.js";
@@ -183,11 +184,11 @@ function reportDeadProcess(agent, snapshot) {
183
184
  // interrupted between the two is not reported as a crash.
184
185
  if (stoppingMarkerLive(agent.project_id, agent.id))
185
186
  return;
186
- const lead = stmt(`SELECT actor_id, tmux_target, tmux_socket, pane_pid FROM agents
187
+ const lead = stmt(`SELECT actor_id, tmux_target, tmux_socket, pane_pid, command FROM agents
187
188
  WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id DESC LIMIT 1`).get(agent.project_id, LEAD_KIND);
188
189
  // Never mint a notice hive could not deliver: with no live lead pane there is nobody this could
189
190
  // ever reach, and the row would sit in wakes forever.
190
- if (!lead?.tmux_target || rowOwnership(lead, snapshot) !== "live")
191
+ if (!lead?.tmux_target || !screenClassifiable(lead.command) || rowOwnership(lead, snapshot) !== "live")
191
192
  return;
192
193
  insertNotice({ project_id: agent.project_id, owner: lead.actor_id }, lead.actor_id, lead.tmux_target, deadProcessBody(agent.name), null);
193
194
  }
@@ -410,19 +411,22 @@ function renderQueenPage(queenId) {
410
411
  const QUEEN_PAGE_CLAIM_KEY = "hive:queen-page-claim";
411
412
  const QUEEN_PAGE_MARK_KEY = "hive:queen-page-mark";
412
413
  function claimQueenPageAttempt(queenId) {
413
- bestEffortRun("INSERT OR IGNORE INTO kv (project_id, key, value, updated_at) VALUES (?, ?, '', '1970-01-01 00:00:00')", queenId, QUEEN_PAGE_CLAIM_KEY);
414
+ bestEffortRun("INSERT OR IGNORE INTO kv (project_id, key, value, updated_at) VALUES (?, ?, ?, '1970-01-01 00:00:00')", queenId, QUEEN_PAGE_CLAIM_KEY, JSON.stringify(""));
414
415
  return (stmt(`UPDATE kv SET updated_at = datetime('now')
415
416
  WHERE project_id = ? AND key = ?
416
417
  AND updated_at <= datetime('now', '-${DASHBOARD_MIN_INTERVAL_SECONDS} seconds')`).run(queenId, QUEEN_PAGE_CLAIM_KEY).changes === 1);
417
418
  }
418
419
  function readQueenPageMark(queenId) {
419
420
  const row = stmt("SELECT value FROM kv WHERE project_id = ? AND key = ?").get(queenId, QUEEN_PAGE_MARK_KEY);
420
- return row?.value ?? null;
421
+ if (!row)
422
+ return null;
423
+ const value = decodeKvValue(row.value);
424
+ return typeof value === "string" ? value : row.value;
421
425
  }
422
426
  function publishQueenPage(queenId, target, page) {
423
427
  writeDashboardAtomically(target, page.html);
424
428
  bestEffortRun(`INSERT INTO kv (project_id, key, value) VALUES (?, ?, ?)
425
- ON CONFLICT(project_id, key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, queenId, QUEEN_PAGE_MARK_KEY, page.hash);
429
+ ON CONFLICT(project_id, key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, queenId, QUEEN_PAGE_MARK_KEY, JSON.stringify(page.hash));
426
430
  }
427
431
  export function generateQueenDashboardNow() {
428
432
  try {
@@ -494,6 +498,24 @@ function driveOwnLeadHandoff() {
494
498
  catch {
495
499
  }
496
500
  }
501
+ function isolated(label, task) {
502
+ const report = (err) => {
503
+ try {
504
+ console.error(`[scheduler] ${label} failed: ${err instanceof Error ? err.message : String(err)}`);
505
+ }
506
+ catch {
507
+ }
508
+ };
509
+ try {
510
+ const result = task();
511
+ if (result !== null && typeof result === "object" && "then" in result) {
512
+ return Promise.resolve(result).then(() => { }, report);
513
+ }
514
+ }
515
+ catch (err) {
516
+ report(err);
517
+ }
518
+ }
497
519
  export async function tick(snapshot) {
498
520
  if (ticking)
499
521
  return;
@@ -506,14 +528,15 @@ export async function tick(snapshot) {
506
528
  }
507
529
  if (snapshot === undefined)
508
530
  snapshot = liveTargets();
509
- janitor(snapshot);
510
- if (snapshot)
511
- reportRunningBuildChange(snapshot);
512
- driveOwnLeadHandoff();
513
- checkConfirmations();
514
- pruneStateLog();
515
- maybeBackupHourly(db, dataDir);
516
- maybeGenerateDashboards();
531
+ const tickSnapshot = snapshot;
532
+ isolated("janitor", () => janitor(tickSnapshot));
533
+ if (tickSnapshot)
534
+ isolated("running-build check", () => reportRunningBuildChange(tickSnapshot));
535
+ isolated("lead handoff", driveOwnLeadHandoff);
536
+ isolated("wake confirmations", checkConfirmations);
537
+ isolated("state-log pruning", pruneStateLog);
538
+ isolated("hourly backup", () => maybeBackupHourly(db, dataDir));
539
+ isolated("dashboard generation", maybeGenerateDashboards);
517
540
  const now = stmt("SELECT datetime('now') AS now").get().now;
518
541
  const candidates = stmt(`SELECT wakes.*, ${DELIVER_ROW_COLUMNS}
519
542
  FROM wakes ${DELIVER_SOCKET_JOIN}
@@ -524,18 +547,20 @@ export async function tick(snapshot) {
524
547
  )`).all();
525
548
  const choices = new Map();
526
549
  for (const timer of candidates) {
527
- if (timer.kind === "delay") {
528
- await fireDelay(timer, snapshot, choices);
529
- continue;
530
- }
531
- const leadWatch = leadSubscription(timer.id);
532
- if (leadWatch !== null)
533
- await maybeFireLeadWatch(timer, leadWatch, snapshot, now, choices);
534
- else
535
- await maybeFireIdle(timer, snapshot, now, choices);
550
+ await isolated(`timer ${timer.id}`, async () => {
551
+ if (timer.kind === "delay") {
552
+ await fireDelay(timer, tickSnapshot, choices);
553
+ return;
554
+ }
555
+ const leadWatch = leadSubscription(timer.id);
556
+ if (leadWatch !== null)
557
+ await maybeFireLeadWatch(timer, leadWatch, tickSnapshot, now, choices);
558
+ else
559
+ await maybeFireIdle(timer, tickSnapshot, now, choices);
560
+ });
536
561
  }
537
- await retryUnconfirmedSocketWakes(snapshot, choices);
538
- await retryQuietLeadMessages(snapshot);
562
+ await isolated("unconfirmed socket wake retry", () => retryUnconfirmedSocketWakes(tickSnapshot, choices));
563
+ await isolated("quiet lead message retry", () => retryQuietLeadMessages(tickSnapshot));
539
564
  }
540
565
  catch {
541
566
  }
@@ -972,6 +997,8 @@ function alreadyToldAbout(timerId, agentId, blockedSince) {
972
997
  const CONDITION_IDLE = "idle";
973
998
  const CONDITION_GONE = "gone";
974
999
  const NOTICE_RETRY_AFTER = "-60 seconds";
1000
+ const spentUntypedNotice = (alias) => `${alias}.fired_at IS NOT NULL AND COALESCE(${alias}.typed_at, ${alias}.socket_attempt_at) IS NULL ` +
1001
+ `AND ${alias}.cancelled_at IS NULL AND ${alias}.fired_at < datetime('now', '${NOTICE_RETRY_AFTER}')`;
975
1002
  const NOTICE_MAX_AGE = "-1 hours";
976
1003
  const ROSTER_STILL_GOING = 8;
977
1004
  const FINISHED_SHOWN_CAP = ROSTER_STILL_GOING;
@@ -979,8 +1006,7 @@ const STANDING_EXPIRED_NOTE = "this standing watch has expired and nothing is wa
979
1006
  const unreported = (condition, episode) => `NOT EXISTS (
980
1007
  SELECT 1 FROM wake_idle_notices n LEFT JOIN wakes nt ON nt.id = n.notice_wake_id
981
1008
  WHERE n.wake_id = ? AND n.agent_id = a.id AND n.condition = '${condition}' AND n.episode = ${episode}
982
- AND NOT (nt.fired_at IS NOT NULL AND COALESCE(nt.typed_at, nt.socket_attempt_at) IS NULL AND nt.cancelled_at IS NULL
983
- AND nt.fired_at < datetime('now', '${NOTICE_RETRY_AFTER}')))`;
1009
+ AND NOT (${spentUntypedNotice("nt")}))`;
984
1010
  const CREW_COLUMNS = `a.id, a.name, a.actor_id, a.tmux_target, a.tmux_socket, a.pane_pid, a.agent_state,
985
1011
  a.state_changed_at, a.status, a.command, a.kind, a.resumed_at, a.closed_at,
986
1012
  a.cwd, a.session_id, a.transcript_path`;
@@ -1218,12 +1244,11 @@ function standingNoticeBody(timer, finished, totalFinished, carriedTotal, span)
1218
1244
  return lines.join("\n");
1219
1245
  }
1220
1246
  function rearmSpentEpisode(timerId, agentId, condition, episode) {
1221
- stmt(`DELETE FROM wake_idle_notices
1247
+ return stmt(`DELETE FROM wake_idle_notices
1222
1248
  WHERE wake_id = ? AND agent_id = ? AND condition = ? AND episode = ?
1223
1249
  AND notice_wake_id IS NOT NULL
1224
1250
  AND EXISTS (SELECT 1 FROM wakes t WHERE t.id = wake_idle_notices.notice_wake_id
1225
- AND t.fired_at IS NOT NULL AND COALESCE(t.typed_at, t.socket_attempt_at) IS NULL AND t.cancelled_at IS NULL
1226
- AND t.fired_at < datetime('now', '${NOTICE_RETRY_AFTER}'))`).run(timerId, agentId, condition, episode);
1251
+ AND ${spentUntypedNotice("t")})`).run(timerId, agentId, condition, episode).changes > 0;
1227
1252
  }
1228
1253
  function claimEpisode(timerId, agentId, condition, episode) {
1229
1254
  return (stmt(`INSERT OR IGNORE INTO wake_idle_notices (wake_id, agent_id, condition, episode)
@@ -1366,11 +1391,15 @@ const claimStallBatch = db.transaction((timer, tell, candidates, observedAt) =>
1366
1391
  return false;
1367
1392
  const won = [];
1368
1393
  for (const c of candidates) {
1369
- if (c.row.agent_state === "waiting" && !claimBlockNotice(timer.id, c.row.id, c.row.episode))
1394
+ const rearmed = rearmSpentEpisode(timer.id, c.row.id, CONDITION_STALL, c.row.episode);
1395
+ if (!claimEpisode(timer.id, c.row.id, CONDITION_STALL, c.row.episode))
1370
1396
  continue;
1371
- rearmSpentEpisode(timer.id, c.row.id, CONDITION_STALL, c.row.episode);
1372
- if (claimEpisode(timer.id, c.row.id, CONDITION_STALL, c.row.episode))
1373
- won.push(c);
1397
+ if (c.row.agent_state === "waiting" && !rearmed && !claimBlockNotice(timer.id, c.row.id, c.row.episode)) {
1398
+ stmt(`DELETE FROM wake_idle_notices
1399
+ WHERE wake_id = ? AND agent_id = ? AND condition = ? AND episode = ? AND notice_wake_id IS NULL`).run(timer.id, c.row.id, CONDITION_STALL, c.row.episode);
1400
+ continue;
1401
+ }
1402
+ won.push(c);
1374
1403
  }
1375
1404
  if (won.length === 0)
1376
1405
  return false;
@@ -2164,10 +2193,21 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fall
2164
2193
  return;
2165
2194
  if (firing !== null && !pasted)
2166
2195
  dropRepeatingFiring(timer, firing, err);
2167
- if (!(err instanceof PaneOwnershipLost))
2168
- throw err;
2169
- if (firing === null || pasted)
2170
- recordLostAfterClaim(timer, pasted, err.message, firing);
2196
+ if (err instanceof PaneOwnershipLost) {
2197
+ if (firing === null || pasted) {
2198
+ recordLostAfterClaim(timer, pasted, err instanceof Error ? err.message : String(err), firing);
2199
+ }
2200
+ return;
2201
+ }
2202
+ if (pasted) {
2203
+ const repairableNotice = stmt(`SELECT 1 FROM wake_idle_notices n JOIN wakes w ON w.id = n.notice_wake_id
2204
+ WHERE n.notice_wake_id = ? AND w.fired_at IS NOT NULL
2205
+ AND COALESCE(w.typed_at, w.socket_attempt_at) IS NULL AND w.cancelled_at IS NULL
2206
+ LIMIT 1`).get(timer.id);
2207
+ if (pasted && repairableNotice === undefined) {
2208
+ recordLostAfterClaim(timer, pasted, err instanceof Error ? err.message : String(err), firing);
2209
+ }
2210
+ }
2171
2211
  return;
2172
2212
  }
2173
2213
  finally {
@@ -77,6 +77,23 @@ export function findAgent(projectId, ref) {
77
77
  const candidates = partial.map((r) => `${r.name} (agent_id ${r.id})`).join(", ");
78
78
  throw new Error(`"${ref.name}" matches ${partial.length} running agents: ${candidates}. Use the full name or agent_id.`);
79
79
  }
80
+ if (/^\d+$/.test(ref.name)) {
81
+ const id = Number(ref.name);
82
+ if (Number.isSafeInteger(id)) {
83
+ const byId = db
84
+ .prepare("SELECT * FROM agents WHERE project_id = ? AND id = ? AND status = 'running'")
85
+ .get(projectId, id);
86
+ if (byId)
87
+ return byId;
88
+ }
89
+ }
90
+ else if (/^[a-z]+:\d+$/i.test(ref.name)) {
91
+ const byActorId = db
92
+ .prepare("SELECT * FROM agents WHERE project_id = ? AND actor_id = ? AND status = 'running'")
93
+ .get(projectId, ref.name);
94
+ if (byActorId)
95
+ return byActorId;
96
+ }
80
97
  throw new Error(`No running agent matching "${ref.name}" in project ${projectId}. Call agent_list.`);
81
98
  }
82
99
  throw new Error("Pass agent_id or name.");
@@ -979,6 +996,7 @@ export function registerAgents(server) {
979
996
  const classifier = renameHarness.paneClassifier;
980
997
  const { awaitingChoice, tail } = classifier.choiceCheck(agent.tmux_target);
981
998
  const box = awaitingChoice === false ? classifier.inputBoxState(agent.tmux_target) : null;
999
+ const inCopyMode = paneInCopyMode(agent.tmux_target);
982
1000
  if (awaitingChoice === true) {
983
1001
  heldNote =
984
1002
  "Not retitled: the pane is waiting on a choice, so typing /rename would answer it instead of setting the title. Clear the prompt first (agent_send with keys), then retry.";
@@ -1000,6 +1018,11 @@ export function registerAgents(server) {
1000
1018
  `Not retitled: the pane's input box holds unsubmitted text, so /rename would be pasted onto the end of it and submitted as prose rather than run as a command. The row IS renamed - it is "${newName}" now - and only the pane's own title was left alone. Clear the line with agent_send(keys: ["C-a", "C-k"]) once you can attribute the text, then call agent_rename(name: "${newName}", new_name: "${newName}") to retitle the pane.`;
1001
1019
  heldTail = tail;
1002
1020
  }
1021
+ else if (inCopyMode === true) {
1022
+ heldNote =
1023
+ `Not retitled: the pane is in tmux copy mode, so /rename would not reach its prompt. The row IS renamed - it is "${newName}" now - and only the pane's own title was left alone. Leave copy mode (press q or Escape there), then call agent_rename(name: "${newName}", new_name: "${newName}") to retitle the pane.`;
1024
+ heldTail = tail;
1025
+ }
1003
1026
  else {
1004
1027
  let pasted = false;
1005
1028
  try {
@@ -1174,6 +1197,7 @@ export function registerAgents(server) {
1174
1197
  const agent = resolveQueenLeadTarget("agent_send", project.id, args.agent_id ?? args.name, () => findAgent(project.id, args));
1175
1198
  requireLive(agent);
1176
1199
  const target = agent.tmux_target;
1200
+ const identity = paneIdentity(agent);
1177
1201
  const auditIdentity = captureQueenWriteIdentity(project.id);
1178
1202
  let outgoing = args.text ?? "";
1179
1203
  const tag = senderTag(project.id, currentActor());
@@ -1224,10 +1248,9 @@ export function registerAgents(server) {
1224
1248
  }
1225
1249
  if (args.wait_ms == null)
1226
1250
  return quiet;
1227
- await sleep(Math.min(Math.max(args.wait_ms, 250), 10000));
1251
+ await sleep(args.wait_ms);
1228
1252
  try {
1229
- if (observeOwnership(agent) !== "live")
1230
- throw new Error("not live");
1253
+ requireStillOwned(agent.id, identity, "agent_send");
1231
1254
  return { ...quiet, tail: capturePane(target, 15) };
1232
1255
  }
1233
1256
  catch {
@@ -1325,7 +1348,6 @@ export function registerAgents(server) {
1325
1348
  outgoing = tag + args.text;
1326
1349
  let pasted = false;
1327
1350
  let buffered = false;
1328
- const identity = paneIdentity(agent);
1329
1351
  try {
1330
1352
  await sendText(target, outgoing, submitting, () => {
1331
1353
  pasted = true;
@@ -1359,9 +1381,10 @@ export function registerAgents(server) {
1359
1381
  throw new Error("Pass text or keys.");
1360
1382
  }
1361
1383
  if (args.wait_ms != null) {
1362
- await sleep(Math.min(Math.max(args.wait_ms, 250), 10000));
1384
+ await sleep(args.wait_ms);
1363
1385
  let tailField;
1364
1386
  try {
1387
+ requireStillOwned(agent.id, identity, "agent_send");
1365
1388
  tailField = { tail: capturePane(target, 15) };
1366
1389
  }
1367
1390
  catch {
package/dist/tools/kv.js CHANGED
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { db } from "../db.js";
3
3
  import { currentActor, effectiveProjectId } from "../context.js";
4
4
  import { run } from "../result.js";
5
+ import { decodeKvValue } from "../kvValue.js";
5
6
  import { idParam, projectIdParam } from "./params.js";
6
7
  function purgeExpired(projectId) {
7
8
  db.prepare("DELETE FROM kv WHERE project_id = ? AND expires_at IS NOT NULL AND expires_at < datetime('now')").run(projectId);
@@ -56,7 +57,7 @@ export function registerKv(server) {
56
57
  project_id: projectId,
57
58
  key: row.key,
58
59
  found: true,
59
- value: JSON.parse(row.value),
60
+ value: decodeKvValue(row.value),
60
61
  updated_by: row.updated_by,
61
62
  updated_at: row.updated_at,
62
63
  expires_at: row.expires_at,
@@ -76,9 +77,9 @@ export function registerKv(server) {
76
77
  purgeExpired(projectId);
77
78
  let sql = "SELECT * FROM kv WHERE project_id = ?";
78
79
  const params = [projectId];
79
- if (args.prefix) {
80
- sql += " AND key LIKE ?";
81
- params.push(`${args.prefix}%`);
80
+ if (args.prefix !== undefined) {
81
+ sql += " AND substr(key, 1, length(?)) = ?";
82
+ params.push(args.prefix, args.prefix);
82
83
  }
83
84
  sql += " ORDER BY key";
84
85
  const rows = db.prepare(sql).all(...params);
@@ -86,7 +87,7 @@ export function registerKv(server) {
86
87
  project_id: projectId,
87
88
  entries: rows.map((r) => ({
88
89
  key: r.key,
89
- value: JSON.parse(r.value),
90
+ value: decodeKvValue(r.value),
90
91
  updated_by: r.updated_by,
91
92
  updated_at: r.updated_at,
92
93
  expires_at: r.expires_at,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmgmyr/hive",
3
- "version": "1.11.2",
3
+ "version": "1.11.4",
4
4
  "description": "Run a crew of Claude Code and Codex workers from one lead session: visible tmux panes, exact state from hooks, one local SQLite store.",
5
5
  "type": "module",
6
6
  "bin": {