@cmgmyr/hive 1.11.3 → 1.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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.3","sha":"959b92c","dirty":false,"build_id":"9ec08cbe1418fce5591b6a2d0373e6bd5173810edd4f572aa06bdd23e49bdd11"}
1
+ {"version":"1.11.5","sha":"486161d","dirty":false,"build_id":"3a33da919c1ac52f423bfdb530cfeda01207921e9c0fb8b262462fae1c266b12"}
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";
@@ -36,7 +36,7 @@ import { asNameClash, buildEnvFlags, closeAgentRow, isReservedAgentName, launchA
36
36
  import { adoptableWindow, applyLayout, claimInitialWindow, configureHiveWindow, controlModeFor, createWindow, DEFAULT_LAYOUT, describePaneChoice, ensureSession, findProjectWindow, foreignSocket, isViewSessionName, listOwnedWindows, liveTargets, observationFailed, observeRowOwnership, ORPHAN_MIN_AGE_MS, orphanScratchServers, orphansWorthWarningAbout, paneIsAloneInWindow, paneVisibility, panePidForRecord, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, projectWindows, RAW_ATTACH_TMUX_CONFIG, renderAttachCommand, resolveAttachTarget, resolveInTmuxTarget, rowLive, rowOwnership, SESSION_PREFIX, appendGlobalHook, globalHooks, replaceGlobalHooks, sessionName, setPaneTitle, shellQuote, shownPaneTitle, makeProcessesWindow, tmux, TMUX_DOC, tmuxSaysNothingThere, tmuxTimeoutOverride, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, } from "./tmux.js";
37
37
  import { collectCrew, crewStoreProblem } from "./crew.js";
38
38
  import { describeVisibility, formatLocal, processCounts } from "./dashboard.js";
39
- import { runningCommandRow, runningCommandRows, snapshotProcesses, stopAllProcesses, stopLine, stopProcess, STOP_REASONS, } from "./processes.js";
39
+ import { runningCommandRow, runningCommandRows, snapshotProcesses, stopAllProcesses, stopLine, stopProcess, renderStart, STOP_REASONS, } from "./processes.js";
40
40
  import { activeProfile, agentVarKeys, BUILT_IN_PROJECT_YML, configHash, loadProjectYml, mergedProjectVars, NO_PROFILE, PROJECT_YML_KEYS, resolveCommandDir, resolveFirstMessage, } from "./projectYml.js";
41
41
  import { appendClaudeLeadArgs, crewPluginDir, isTrusted, leadIdentityEnv, renderLeadPosture } from "./leadLaunch.js";
42
42
  import { handoffStatusSegment } from "./leadHandoff.js";
@@ -47,7 +47,8 @@ import { FIRST_MESSAGE_SHA_ENV, firstMessageDigest } from "./firstMessage.js";
47
47
  import { ageSecondsSince, deriveProvenance, describeForHuman, describeLastLogEvent, humanizeAge, lastLogEvent, lastPermissionMode, reportsAgentStateLog, } from "./stateProvenance.js";
48
48
  import { awaitingFirstPromptSql } from "./firstPrompt.js";
49
49
  import { checkoutRoot, createProfile, forkProfile, PROFILE_FILES, ProfileError, profileExists, profileFileNames, profileNames, profileStatus, readProfileFile, referencedPads, referencedPaths, renderProfileFile, resolveProfileFile, REWRITE_THRESHOLD, templateVars, userProfilesDir, } from "./profiles.js";
50
- import { getTodoDetail, listTodoSummaries, OPEN_BLOCKERS_SQL, TODO_STATUSES, } from "./tools/todos.js";
50
+ import { getTodoDetail, listTodoSummaries, TODO_STATUSES, } from "./tools/todos.js";
51
+ import { ACTIVE_TODO_SQL, OPEN_BLOCKERS_SQL } from "./todoScope.js";
51
52
  import { createPad, getActivePadByName, listActivePads, overwritePadContent, } from "./tools/pads.js";
52
53
  import { answerQuestion, listQuestions } from "./questions.js";
53
54
  function usage() {
@@ -213,9 +214,15 @@ async function ensureTrusted(projectId, name, command, dir, env) {
213
214
  db.prepare("INSERT OR IGNORE INTO command_trust (project_id, name, config_hash) VALUES (?, ?, ?)").run(projectId, name, hash);
214
215
  return true;
215
216
  }
216
- function startYmlCommand(project, name, proc, config) {
217
+ function startYmlCommand(project, name, proc, config, trusted) {
218
+ if (!trusted)
219
+ return { status: "refused", reason: "untrusted" };
217
220
  if (isReservedAgentName(name)) {
218
- return `skipped: "lead" is reserved for this project's lead session and cannot be used as a process name`;
221
+ return {
222
+ status: "refused",
223
+ reason: "reserved-name",
224
+ message: `skipped: "lead" is reserved for this project's lead session and cannot be used as a process name`,
225
+ };
219
226
  }
220
227
  const existing = db
221
228
  .prepare("SELECT id, tmux_target, tmux_socket FROM agents WHERE project_id = ? AND name = ? AND status = 'running'")
@@ -225,10 +232,15 @@ function startYmlCommand(project, name, proc, config) {
225
232
  if (live) {
226
233
  const windows = projectWindows(sessionName(), project.id);
227
234
  const where = windows === null ? null : paneVisibility(existing.tmux_target, windows);
228
- return where === "shown" || where === "hidden" ? `already running (${where})` : "already running";
235
+ return { status: "already-running", location: where === "shown" || where === "hidden" ? where : null };
236
+ }
237
+ if (live === null) {
238
+ return {
239
+ status: "refused",
240
+ reason: "unknown-liveness",
241
+ message: "skipped: tmux could not be probed, so hive cannot tell whether it is already running",
242
+ };
229
243
  }
230
- if (live === null)
231
- return "skipped: tmux could not be probed, so hive cannot tell whether it is already running";
232
244
  closeAgentRow(existing.id);
233
245
  }
234
246
  let dir;
@@ -236,7 +248,7 @@ function startYmlCommand(project, name, proc, config) {
236
248
  dir = resolveCommandDir(project.path, proc.dir);
237
249
  }
238
250
  catch (e) {
239
- return `skipped: ${errorMessage(e)}`;
251
+ return { status: "refused", reason: "escaped-dir", message: `skipped: ${errorMessage(e)}` };
240
252
  }
241
253
  const placement = proc.visible
242
254
  ? (config.placement ?? (process.env.HIVE_SPAWN_PLACEMENT === "window" ? "window" : "split"))
@@ -260,17 +272,20 @@ function startYmlCommand(project, name, proc, config) {
260
272
  setPaneTitle(target, shownPaneTitle(project.name, name));
261
273
  if (landedInProjectId != null) {
262
274
  const landedName = getProject(landedInProjectId)?.name ?? `project ${landedInProjectId}`;
263
- return `started, landed in project "${landedName}"'s window`;
275
+ return { status: "started", message: `started, landed in project "${landedName}"'s window` };
264
276
  }
265
- return "started";
277
+ return { status: "started", message: "started" };
266
278
  }
267
- return inProcessesWindow
268
- ? "started (hidden)"
269
- : "started in its own window: it opened this project's tmux session, and a session's first window " +
270
- `cannot be a tile. Move it with: hive hide "${name}"`;
279
+ return {
280
+ status: "started",
281
+ message: inProcessesWindow
282
+ ? "started (hidden)"
283
+ : "started in its own window: it opened this project's tmux session, and a session's first window " +
284
+ `cannot be a tile. Move it with: hive hide "${name}"`,
285
+ };
271
286
  }
272
287
  catch (e) {
273
- return `failed: ${errorMessage(e)}`;
288
+ return { status: "failed", message: errorMessage(e) };
274
289
  }
275
290
  }
276
291
  function attach(session, project, window) {
@@ -636,9 +651,10 @@ async function cmdLead(argv) {
636
651
  console.log(`- ${name}: defined, auto_start off (start with: hive start "${name}")`);
637
652
  continue;
638
653
  }
639
- if (await ensureTrusted(project.id, name, proc.command, proc.dir, proc.env)) {
640
- console.log(`- ${name}: ${startYmlCommand(project, name, proc, config)}`);
641
- }
654
+ const trusted = await ensureTrusted(project.id, name, proc.command, proc.dir, proc.env);
655
+ const result = startYmlCommand(project, name, proc, config, trusted);
656
+ if (trusted)
657
+ console.log(`- ${name}: ${renderStart(result)}`);
642
658
  }
643
659
  }
644
660
  if (registrationNotice) {
@@ -1366,9 +1382,12 @@ async function cmdStart(argv) {
1366
1382
  console.log(processNotDefined(config, name));
1367
1383
  process.exit(1);
1368
1384
  }
1369
- if (await ensureTrusted(project.id, name, proc.command, proc.dir, proc.env)) {
1370
- console.log(`${name}: ${startYmlCommand(project, name, proc, config)}`);
1371
- }
1385
+ const trusted = await ensureTrusted(project.id, name, proc.command, proc.dir, proc.env);
1386
+ const result = startYmlCommand(project, name, proc, config, trusted);
1387
+ if (trusted)
1388
+ console.log(`${name}: ${renderStart(result)}`);
1389
+ if (!trusted || result.status === "failed" || result.status === "refused")
1390
+ process.exitCode = 1;
1372
1391
  }
1373
1392
  function processNotDefined(config, name) {
1374
1393
  const known = Object.keys(config?.processes ?? {});
@@ -1631,7 +1650,7 @@ function cmdStatus() {
1631
1650
  .prepare("SELECT * FROM agents WHERE project_id = ? AND status = 'running' ORDER BY kind DESC, id")
1632
1651
  .all(project.id);
1633
1652
  const todos = db
1634
- .prepare("SELECT COUNT(*) AS n FROM todos WHERE project_id = ? AND status IN ('open', 'in_progress') AND archived_at IS NULL")
1653
+ .prepare(`SELECT COUNT(*) AS n FROM todos t WHERE t.project_id = ? AND ${ACTIVE_TODO_SQL}`)
1635
1654
  .get(project.id).n;
1636
1655
  const { wakes, heldWakes } = db
1637
1656
  .prepare(`SELECT COUNT(*) AS wakes, COUNT(held_at) AS heldWakes FROM wakes WHERE project_id = ? AND ${ACTIVE_TIMER_WHERE}`)
@@ -1889,7 +1908,7 @@ function reportDispatcher() {
1889
1908
  }
1890
1909
  info("dispatcher", `${dispatcher.file} -> ${dispatcher.node ?? "an exec line hive cannot parse"}`);
1891
1910
  if (!dispatcher.node || !dispatcher.cli) {
1892
- gatingWarn("dispatcher", "written by another version of hive; re-run `hive setup` to refresh it.");
1911
+ gatingWarn("dispatcher", "written by another version of hive; use this re-pin command to refresh it:", ` ${repinCommand(process.execPath, cliPath())}`);
1893
1912
  }
1894
1913
  else if (!existsSync(dispatcher.node)) {
1895
1914
  const need = requiredNodeApi();
@@ -1907,7 +1926,7 @@ function reportDispatcher() {
1907
1926
  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
1927
  }
1909
1928
  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");
1929
+ 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
1930
  }
1912
1931
  if (onPath !== dispatcher.file)
1913
1932
  gatingWarn("dispatcher", ...pathAdvice(dispatcherDir(), dispatcher.file));
@@ -2005,8 +2024,12 @@ function reportPtyHeadroom(orphans) {
2005
2024
  const scratchHeld = orphans ? orphans.live + orphans.wedged : 0;
2006
2025
  const livePanes = db.prepare("SELECT COUNT(*) AS n FROM agents WHERE status = 'running'").get()
2007
2026
  .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)");
2027
+ 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"))
2028
+ ? [
2029
+ "reap the orphaned shells and scratch servers with `node scripts/sweep-scratch.mjs` (dry run by default, " +
2030
+ "`--kill` to act)",
2031
+ ]
2032
+ : []));
2010
2033
  }
2011
2034
  function reportOrphanTmuxServers(orphans) {
2012
2035
  if (!orphans)
@@ -2765,9 +2788,9 @@ function cmdStatusline() {
2765
2788
  const count = (sql) => db.prepare(sql).get(project.id).n;
2766
2789
  const agents = count("SELECT COUNT(*) AS n FROM agents WHERE project_id = ? AND status = 'running' AND kind = 'agent'");
2767
2790
  const commands = count("SELECT COUNT(*) AS n FROM agents WHERE project_id = ? AND status = 'running' AND kind = 'command'");
2768
- const todos = count("SELECT COUNT(*) AS n FROM todos WHERE project_id = ? AND status IN ('open', 'in_progress') AND archived_at IS NULL");
2791
+ const todos = count(`SELECT COUNT(*) AS n FROM todos t WHERE t.project_id = ? AND ${ACTIVE_TODO_SQL}`);
2769
2792
  const ready = count(`SELECT COUNT(*) AS n FROM todos t
2770
- WHERE t.project_id = ? AND t.status IN ('open', 'in_progress') AND t.archived_at IS NULL
2793
+ WHERE t.project_id = ? AND ${ACTIVE_TODO_SQL}
2771
2794
  AND NOT EXISTS (${OPEN_BLOCKERS_SQL})`);
2772
2795
  const pads = count("SELECT COUNT(*) AS n FROM pads WHERE project_id = ? AND archived = 0");
2773
2796
  const wakes = count(`SELECT COUNT(*) AS n FROM wakes WHERE project_id = ? AND ${ACTIVE_TIMER_WHERE}`);
package/dist/codexHome.js CHANGED
@@ -303,7 +303,6 @@ export function ensureCodexHome(input) {
303
303
  indexJs,
304
304
  serverEnv: { ...input.serverEnv, HIVE_AGENT_ID: input.actorId },
305
305
  statusLine,
306
- readOnly: input.readOnly,
307
306
  modelContextWindow,
308
307
  modelAutoCompactTokenLimit,
309
308
  }));
package/dist/context.js CHANGED
@@ -1,4 +1,3 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import { lstatSync, realpathSync } from "node:fs";
3
2
  import { userInfo } from "node:os";
4
3
  import { basename, dirname, join, resolve, sep } from "node:path";
@@ -7,6 +6,7 @@ import { db } from "./db.js";
7
6
  import { LEAD_KIND } from "./spawn.js";
8
7
  import { rowOwnership } from "./tmux.js";
9
8
  import { recordQueenRefusal } from "./queenAudit.js";
9
+ import { gitOut } from "./git.js";
10
10
  let selectedId = null;
11
11
  let cachedActorId = null;
12
12
  let lastTouchMs = 0;
@@ -92,18 +92,13 @@ export function registeredAncestor(dir) {
92
92
  return bestPrefixMatch(projects, dir);
93
93
  }
94
94
  export function gitPrimaryRoot(dir) {
95
+ const commonDir = gitOut(dir, ["rev-parse", "--git-common-dir"]);
96
+ if (commonDir === null)
97
+ return null;
98
+ const resolvedCommonDir = resolve(dir, commonDir);
99
+ if (basename(resolvedCommonDir) !== ".git")
100
+ return null;
95
101
  try {
96
- const { GIT_DIR: _gitDir, GIT_COMMON_DIR: _gitCommonDir, GIT_WORK_TREE: _gitWorkTree, ...env } = process.env;
97
- const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
98
- cwd: dir,
99
- encoding: "utf8",
100
- stdio: ["ignore", "pipe", "ignore"],
101
- timeout: 2000,
102
- env,
103
- }).trim();
104
- const resolvedCommonDir = resolve(dir, commonDir);
105
- if (basename(resolvedCommonDir) !== ".git")
106
- return null;
107
102
  return realpathSync(dirname(resolvedCommonDir));
108
103
  }
109
104
  catch {
@@ -111,19 +106,13 @@ export function gitPrimaryRoot(dir) {
111
106
  }
112
107
  }
113
108
  function gitDirs(dir) {
109
+ const result = gitOut(dir, ["rev-parse", "--git-dir", "--git-common-dir"]);
110
+ if (result === null)
111
+ return null;
112
+ const out = result.split("\n");
113
+ if (out.length !== 2)
114
+ return null;
114
115
  try {
115
- const { GIT_DIR: _gitDir, GIT_COMMON_DIR: _gitCommonDir, GIT_WORK_TREE: _gitWorkTree, ...env } = process.env;
116
- const out = execFileSync("git", ["rev-parse", "--git-dir", "--git-common-dir"], {
117
- cwd: dir,
118
- encoding: "utf8",
119
- stdio: ["ignore", "pipe", "ignore"],
120
- timeout: 2000,
121
- env,
122
- })
123
- .trim()
124
- .split("\n");
125
- if (out.length !== 2)
126
- return null;
127
116
  return {
128
117
  gitDir: realpathSync(resolve(dir, out[0])),
129
118
  commonDir: realpathSync(resolve(dir, out[1])),
package/dist/crew.js CHANGED
@@ -1,15 +1,16 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import { db } from "./db.js";
3
2
  import { isLinkedWorktree } from "./context.js";
4
3
  import { awaitingFirstPrompt } from "./firstPrompt.js";
5
4
  import { heldReasonLabel } from "./heldLabel.js";
6
5
  import { harnessFor, paneClassifierFor } from "./harnesses.js";
7
6
  import { NEEDS_HUMAN_TAG } from "./portfolio.js";
7
+ import { ACTIVE_TODO_SQL } from "./todoScope.js";
8
8
  import { pendingQuestionCount } from "./questions.js";
9
9
  import { loadProjectYml } from "./projectYml.js";
10
10
  import { parseTags } from "./result.js";
11
11
  import { ACTIVE_TIMER_WHERE } from "./scheduler.js";
12
12
  import { reportsAgentStateLog } from "./stateProvenance.js";
13
+ import { gitOut } from "./git.js";
13
14
  import { liveTargets, rowOwnership } from "./tmux.js";
14
15
  import { SKILL_TOOL, lastComponent, readContextFill, readRecentToolCalls, } from "./transcript.js";
15
16
  export function crewStoreProblem(database = db) {
@@ -50,36 +51,18 @@ function oneShotWatched(pending) {
50
51
  }
51
52
  });
52
53
  }
53
- function stripped(env) {
54
- const { GIT_DIR: _gitDir, GIT_COMMON_DIR: _gitCommonDir, GIT_WORK_TREE: _gitWorkTree, ...rest } = env;
55
- return rest;
56
- }
57
- function git(cwd, args) {
58
- try {
59
- return execFileSync("git", args, {
60
- cwd,
61
- encoding: "utf8",
62
- stdio: ["ignore", "pipe", "ignore"],
63
- timeout: 2000,
64
- env: stripped(process.env),
65
- }).trim();
66
- }
67
- catch {
68
- return null;
69
- }
70
- }
71
54
  export function readCommitsAhead(cwd, projectRoot) {
72
55
  if (!cwd || cwd === projectRoot || !isLinkedWorktree(cwd))
73
56
  return null;
74
57
  const candidates = [];
75
- const remoteHead = git(cwd, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
58
+ const remoteHead = gitOut(cwd, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
76
59
  if (remoteHead)
77
60
  candidates.push(remoteHead);
78
61
  candidates.push("refs/heads/main", "refs/heads/master");
79
62
  for (const ref of candidates) {
80
- if (git(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]) === null)
63
+ if (gitOut(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]) === null)
81
64
  continue;
82
- const count = git(cwd, ["rev-list", "--count", `${ref}..HEAD`]);
65
+ const count = gitOut(cwd, ["rev-list", "--count", `${ref}..HEAD`]);
83
66
  return count !== null && /^\d+$/.test(count) ? Number(count) : null;
84
67
  }
85
68
  return null;
@@ -481,7 +464,7 @@ export function collectCrew(project, now) {
481
464
  if (!staffed.has(todo.id))
482
465
  lanes.push({ todo, worker: null });
483
466
  const needsYou = db
484
- .prepare("SELECT id, slug, tags FROM todos WHERE project_id = ? AND status != 'completed' AND archived_at IS NULL ORDER BY id")
467
+ .prepare(`SELECT t.id, t.slug, t.tags FROM todos t WHERE t.project_id = ? AND ${ACTIVE_TODO_SQL} ORDER BY t.id`)
485
468
  .all(project.id)
486
469
  .filter((t) => parseTags(t.tags).includes(NEEDS_HUMAN_TAG))
487
470
  .map((t) => ({ id: t.id, slug: t.slug }));
package/dist/dashboard.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { createRequire } from "node:module";
3
3
  import { db } from "./db.js";
4
+ import { ACTIVE_TODO_SQL, NOT_COMPLETED_TODO_SQL, OPEN_BLOCKERS_SQL } from "./todoScope.js";
4
5
  import { getProject } from "./context.js";
5
6
  export const describeVisibility = (visibility) => visibility === null ? "running" : visibility === "window" ? "own window" : visibility;
6
7
  export function processCounts(procs) {
@@ -127,12 +128,9 @@ function fetchNextWake(projectId) {
127
128
  function fetchTodoCounts(projectId) {
128
129
  return db
129
130
  .prepare(`SELECT
130
- (SELECT COUNT(*) FROM todos WHERE project_id = ? AND archived_at IS NULL AND status != 'completed') AS open,
131
- (SELECT COUNT(*) FROM todos t WHERE t.project_id = ? AND t.archived_at IS NULL AND t.status != 'completed'
132
- AND EXISTS (
133
- SELECT 1 FROM todo_blockers b JOIN todos bt ON bt.id = b.blocker_id
134
- WHERE b.todo_id = t.id AND bt.status != 'completed'
135
- )) AS blocked,
131
+ (SELECT COUNT(*) FROM todos t WHERE t.project_id = ? AND ${ACTIVE_TODO_SQL}) AS open,
132
+ (SELECT COUNT(*) FROM todos t WHERE t.project_id = ? AND ${ACTIVE_TODO_SQL}
133
+ AND EXISTS (${OPEN_BLOCKERS_SQL})) AS blocked,
136
134
  (SELECT COUNT(*) FROM todos
137
135
  WHERE project_id = ? AND completed_at IS NOT NULL
138
136
  AND date(completed_at, 'localtime') >= date('now', 'localtime', '-6 days')) AS completed7d`)
@@ -262,10 +260,9 @@ function renderBoardSection(index, projectId) {
262
260
  }
263
261
  const OPEN_TODOS_SQL = `
264
262
  SELECT t.id, t.title, t.body, t.slug, t.status, t.priority,
265
- (SELECT COUNT(*) FROM todo_blockers b JOIN todos bt ON bt.id = b.blocker_id
266
- WHERE b.todo_id = t.id AND bt.status != 'completed') AS open_blockers
263
+ EXISTS (${OPEN_BLOCKERS_SQL}) AS blocked
267
264
  FROM todos t
268
- WHERE t.project_id = ? AND t.archived_at IS NULL AND t.status != 'completed'
265
+ WHERE t.project_id = ? AND ${ACTIVE_TODO_SQL}
269
266
  ORDER BY CASE t.priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, t.id`;
270
267
  function fetchBlockersFor(todoIds) {
271
268
  const byTodo = new Map();
@@ -274,7 +271,7 @@ function fetchBlockersFor(todoIds) {
274
271
  const placeholders = todoIds.map(() => "?").join(",");
275
272
  const rows = db
276
273
  .prepare(`SELECT b.todo_id, t.id, t.title FROM todo_blockers b JOIN todos t ON t.id = b.blocker_id
277
- WHERE b.todo_id IN (${placeholders}) AND t.status != 'completed'`)
274
+ WHERE b.todo_id IN (${placeholders}) AND t.${NOT_COMPLETED_TODO_SQL}`)
278
275
  .all(...todoIds);
279
276
  for (const row of rows) {
280
277
  const list = byTodo.get(row.todo_id) ?? [];
@@ -291,11 +288,11 @@ function todoBadgeLevel(status, blocked) {
291
288
  function renderTodosSection(index, projectId) {
292
289
  const all = db.prepare(OPEN_TODOS_SQL).all(projectId);
293
290
  const shown = all.slice(0, TODO_CAP);
294
- const blockersByTodo = fetchBlockersFor(shown.filter((t) => t.open_blockers > 0).map((t) => t.id));
295
- const blockedCount = all.filter((t) => t.open_blockers > 0).length;
291
+ const blockersByTodo = fetchBlockersFor(shown.filter((t) => t.blocked).map((t) => t.id));
292
+ const blockedCount = all.filter((t) => t.blocked).length;
296
293
  const rows = shown
297
294
  .map((t) => {
298
- const blocked = t.open_blockers > 0;
295
+ const blocked = Boolean(t.blocked);
299
296
  const blockers = blocked
300
297
  ? (blockersByTodo.get(t.id) ?? []).map((b) => `${mono(`#${b.id}`)} ${prose(b.title)}`).join(", ")
301
298
  : "";
@@ -324,7 +321,7 @@ function renderTodosSection(index, projectId) {
324
321
  `<p class="filter-none" id="todo-filter-none" hidden>No open todo matches that filter.</p>`
325
322
  : "";
326
323
  const body = filter +
327
- renderCappedList(shown.length, all.length, "open todos", "todos", rows, emptyState("No open todos.", "Every todo in this project is completed or archived. New ones land here."), "todo-list");
324
+ renderCappedList(shown.length, all.length, "open todos", "todos", rows, emptyState("No open todos.", "No active todos are listed. New open or in-progress todos land here. Backlog todos stay off this list."), "todo-list");
328
325
  return section(index, "todos", "Todos", `${all.length} open, ${blockedCount} blocked`, "checklist", String(shown.length), body);
329
326
  }
330
327
  export const CHART_DAYS = 7;
package/dist/db.js CHANGED
@@ -576,6 +576,42 @@ ALTER TABLE queen_audit ADD COLUMN problem TEXT NOT NULL DEFAULT '';
576
576
  `,
577
577
  `
578
578
  ALTER TABLE agents ADD COLUMN pane_started_at TEXT;
579
+ `,
580
+ `
581
+ DROP TRIGGER fence_handoff_held_claim;
582
+ CREATE TRIGGER fence_handoff_held_claim
583
+ BEFORE UPDATE OF fire_count, delivery_method ON wakes
584
+ FOR EACH ROW
585
+ WHEN (NEW.fire_count > OLD.fire_count
586
+ OR (NEW.delivery_method IS 'pty-after-socket-timeout'
587
+ AND OLD.delivery_method IS NOT 'pty-after-socket-timeout'
588
+ AND NEW.typed_at IS NULL))
589
+ AND EXISTS (
590
+ SELECT 1 FROM lead_handoffs h
591
+ WHERE h.lead_agent_id = (SELECT a.id FROM agents a
592
+ WHERE a.actor_id = NEW.deliver_actor
593
+ ORDER BY (a.status = 'running') DESC,
594
+ a.id DESC
595
+ LIMIT 1)
596
+ AND h.state NOT IN ('completed', 'failed')
597
+ AND h.request_wake_id IS NOT NEW.id
598
+ AND (
599
+ (h.state = 'grace'
600
+ AND h.hold_released_at IS NULL
601
+ AND h.grace_started_at IS NOT NULL
602
+ AND (julianday('now') - julianday(h.grace_started_at)) * 86400 < 900)
603
+ OR ((h.state IN ('grace', 'started')
604
+ OR (h.hold_released_at IS NULL AND (
605
+ h.state IN ('respawning', 'ambiguous', 'wind_down')
606
+ OR (h.state = 'requested'
607
+ AND (h.reason = 'stop' OR h.pass >= 2))
608
+ OR (h.state = 'postponed' AND h.reason = 'stop'))))
609
+ AND h.state <> 'grace'
610
+ AND (julianday('now') - julianday(h.updated_at)) * 86400 < 3600)
611
+ ))
612
+ BEGIN
613
+ SELECT RAISE(IGNORE);
614
+ END;
579
615
  `,
580
616
  ];
581
617
  export function storeSchemaAhead(database) {
@@ -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/git.js ADDED
@@ -0,0 +1,16 @@
1
+ import { execFileSync } from "node:child_process";
2
+ export function gitOut(cwd, args) {
3
+ const { GIT_DIR: _gitDir, GIT_COMMON_DIR: _gitCommonDir, GIT_WORK_TREE: _gitWorkTree, ...env } = process.env;
4
+ try {
5
+ return execFileSync("git", args, {
6
+ cwd,
7
+ encoding: "utf8",
8
+ stdio: ["ignore", "pipe", "ignore"],
9
+ timeout: 2000,
10
+ env,
11
+ }).trim();
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
package/dist/harnesses.js CHANGED
@@ -53,7 +53,6 @@ const claudeHarness = {
53
53
  supportsRename: true,
54
54
  classifiesPaneScreen: true,
55
55
  paneClassifier: { choiceCheck: paneChoiceCheck, inputBoxState, inputBoxSnapshot, hasInputBox: paneHasInputBox, trailerView: claudeTrailerView },
56
- hasScopes: true,
57
56
  needsHome: false,
58
57
  // "--" so a message that starts with a dash is never read as a flag.
59
58
  initialPromptArgs: (message) => ["--", message],
@@ -82,7 +81,6 @@ export const codexHarness = {
82
81
  hasInputBox: codexPaneHasInputBox,
83
82
  trailerView: codexTrailerView,
84
83
  },
85
- hasScopes: false,
86
84
  needsHome: true,
87
85
  // Auto-submits with no Enter; verified live on v0.149.0.
88
86
  initialPromptArgs: (message) => ["--", message],
@@ -106,7 +104,6 @@ const unknownHarness = {
106
104
  supportsRename: false,
107
105
  classifiesPaneScreen: false,
108
106
  paneClassifier: null,
109
- hasScopes: false,
110
107
  needsHome: false,
111
108
  initialPromptArgs: null,
112
109
  // An unrecognized command's vocabulary is unknown, so it stops nothing rather than guessing.
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
package/dist/kickoff.js CHANGED
@@ -1,22 +1,13 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import { realpathSync } from "node:fs";
3
2
  import { fileURLToPath } from "node:url";
4
3
  import { cutToUnitBudget } from "./slug.js";
5
4
  import { FIRST_MESSAGE_SHA_ENV } from "./firstMessage.js";
5
+ import { gitOut } from "./git.js";
6
6
  export const OUTPUT_BUDGET = 10_000;
7
7
  const CONTEXT_BUDGET = 6_000;
8
8
  const BOARD_BUDGET = 1_800;
9
9
  function currentBranch(dir) {
10
- try {
11
- return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
12
- cwd: dir,
13
- encoding: "utf8",
14
- stdio: ["ignore", "pipe", "ignore"],
15
- }).trim();
16
- }
17
- catch {
18
- return null;
19
- }
10
+ return gitOut(dir, ["rev-parse", "--abbrev-ref", "HEAD"]);
20
11
  }
21
12
  export function truncate(text, limit) {
22
13
  if (text.length <= limit)
@@ -28,7 +19,7 @@ async function digest(projectPath, profile, warnings) {
28
19
  const { findProjectForCwd } = await import("./context.js");
29
20
  const { ACTIVE_TIMER_WHERE } = await import("./scheduler.js");
30
21
  const { getActivePadByName } = await import("./tools/pads.js");
31
- const { OPEN_BLOCKERS_SQL } = await import("./tools/todos.js");
22
+ const { ACTIVE_TODO_SQL, OPEN_BLOCKERS_SQL } = await import("./todoScope.js");
32
23
  migrate();
33
24
  const project = findProjectForCwd();
34
25
  if (!project || project.path !== projectPath)
@@ -53,7 +44,7 @@ async function digest(projectPath, profile, warnings) {
53
44
  .all(project.id);
54
45
  const blocked = db
55
46
  .prepare(`SELECT COUNT(*) AS n FROM todos t
56
- WHERE t.project_id = ? AND t.status IN ('open', 'in_progress') AND t.archived_at IS NULL
47
+ WHERE t.project_id = ? AND ${ACTIVE_TODO_SQL}
57
48
  AND EXISTS (${OPEN_BLOCKERS_SQL})`)
58
49
  .get(project.id).n;
59
50
  if (inFlight.length > 0) {
@@ -0,0 +1,8 @@
1
+ export function decodeKvValue(value) {
2
+ try {
3
+ return JSON.parse(value);
4
+ }
5
+ catch {
6
+ return value;
7
+ }
8
+ }
@@ -30,7 +30,7 @@ export function readActiveHandoff(leadAgentId) {
30
30
  export function handoffHoldsAutomation(row) {
31
31
  switch (row.state) {
32
32
  case "grace":
33
- return true;
33
+ return row.hold_released_at === null && !olderThan(row.grace_started_at, STOP_HOLD_CAP_SECONDS);
34
34
  case "started":
35
35
  return !olderThan(row.updated_at, HOLD_CEILING_SECONDS);
36
36
  case "respawning":
package/dist/portfolio.js CHANGED
@@ -2,7 +2,7 @@ import { db } from "./db.js";
2
2
  import { listProjects } from "./context.js";
3
3
  import { existsSync } from "node:fs";
4
4
  import { parseTags } from "./result.js";
5
- import { OPEN_BLOCKERS_SQL } from "./tools/todos.js";
5
+ import { ACTIVE_TODO_SQL, OPEN_BLOCKERS_SQL } from "./todoScope.js";
6
6
  import { pendingQuestionCount } from "./questions.js";
7
7
  import { leadSessionEnded, readLeadTurnState } from "./leadState.js";
8
8
  import { liveTargets, ownershipLiveness, rowOwnership } from "./tmux.js";
@@ -63,7 +63,7 @@ function projectRow(project, now, snapshot) {
63
63
  .prepare(`SELECT t.id, t.title, t.slug, t.priority, t.status, t.tags, t.updated_at,
64
64
  EXISTS (${OPEN_BLOCKERS_SQL}) AS blocked
65
65
  FROM todos t
66
- WHERE t.project_id = ? AND t.status IN ('open', 'in_progress') AND t.archived_at IS NULL`)
66
+ WHERE t.project_id = ? AND ${ACTIVE_TODO_SQL}`)
67
67
  .all(project.id);
68
68
  const todos = { open: 0, in_progress: 0, blocked: 0, blocked_in_progress: 0, high: 0 };
69
69
  const tagged = [];
package/dist/processes.js CHANGED
@@ -38,6 +38,18 @@ export const STOP_REASONS = {
38
38
  leadPaneExited: "lead pane exited",
39
39
  previousLead: "left running by a previous lead",
40
40
  };
41
+ export function renderStart(result) {
42
+ switch (result.status) {
43
+ case "started":
44
+ return result.message;
45
+ case "already-running":
46
+ return `already running${result.location ? ` (${result.location})` : ""}`;
47
+ case "refused":
48
+ return result.message ?? "";
49
+ case "failed":
50
+ return `failed: ${result.message}`;
51
+ }
52
+ }
41
53
  const STOPPABLE_COLUMNS = "id, project_id, name, tmux_target, tmux_socket, pane_pid, created_at";
42
54
  export function runningCommandRows(projectId) {
43
55
  return db
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 {
package/dist/spawn.js CHANGED
@@ -1,7 +1,7 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import { existsSync } from "node:fs";
3
2
  import { preservedRolloutPath, reapCodexHome } from "./codexHome.js";
4
3
  import { getProject } from "./context.js";
4
+ import { gitOut } from "./git.js";
5
5
  import { dataDir, db } from "./db.js";
6
6
  import { loadProjectYml } from "./projectYml.js";
7
7
  import { applyLayout, claimInitialWindow, createWindow, DEFAULT_LAYOUT, ensureSession, crossServerRefusal, findProcessesWindow, findProjectWindow, isPaneTarget, paneProcessExited, paneWindow, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, retainOnExitArgs, rowLive, sessionName, setPaneTitle, setRemainOnExit, makeProcessesWindow, tmux, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, windowOwner, windowTitle, rowOwnership, } from "./tmux.js";
@@ -347,34 +347,19 @@ export function renameAgent(agent, newName, projectName) {
347
347
  }
348
348
  }
349
349
  export function branchAt(cwd) {
350
- const git = (args) => {
351
- try {
352
- return execFileSync("git", args, {
353
- cwd,
354
- encoding: "utf8",
355
- stdio: ["ignore", "pipe", "ignore"],
356
- timeout: 2000,
357
- }).trim();
358
- }
359
- catch {
360
- return null;
361
- }
362
- };
363
- const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
350
+ const branch = gitOut(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
364
351
  if (branch === null)
365
352
  return "";
366
353
  if (branch !== "HEAD")
367
354
  return branch;
368
- return git(["rev-parse", "--short", "HEAD"]) ?? "";
355
+ return gitOut(cwd, ["rev-parse", "--short", "HEAD"]) ?? "";
369
356
  }
370
357
  export function parkAgentRow(agentId, expected, branch) {
371
358
  const park = "UPDATE agents SET status = 'closed', closed_at = datetime('now'), parked_at = datetime('now'), " +
372
359
  "parked_branch = ? WHERE id = ? AND status = 'running' AND tmux_target = ?";
373
- const row = typeof expected === "string"
374
- ? db.prepare(`${park} RETURNING parked_at`).get(branch, agentId, expected)
375
- : db
376
- .prepare(`${park} AND tmux_socket = ? AND pane_pid = ? RETURNING parked_at`)
377
- .get(branch, agentId, expected.tmux_target, expected.tmux_socket, expected.pane_pid);
360
+ const row = db
361
+ .prepare(`${park} AND tmux_socket = ? AND pane_pid = ? RETURNING parked_at`)
362
+ .get(branch, agentId, expected.tmux_target, expected.tmux_socket, expected.pane_pid);
378
363
  return row?.parked_at;
379
364
  }
380
365
  export const paneIdentity = (row) => ({
@@ -455,13 +440,9 @@ export function closeAgentRow(agentId, expected) {
455
440
  ? db
456
441
  .prepare("UPDATE agents SET status = 'closed', closed_at = datetime('now') WHERE id = ? AND status = 'running'")
457
442
  .run(agentId)
458
- : typeof expected === "string"
459
- ? db
460
- .prepare("UPDATE agents SET status = 'closed', closed_at = datetime('now') WHERE id = ? AND status = 'running' AND tmux_target = ?")
461
- .run(agentId, expected)
462
- : db
463
- .prepare("UPDATE agents SET status = 'closed', closed_at = datetime('now') WHERE id = ? AND status = 'running' " +
464
- "AND tmux_target = ? AND tmux_socket = ? AND pane_pid = ?")
465
- .run(agentId, expected.tmux_target, expected.tmux_socket, expected.pane_pid);
443
+ : db
444
+ .prepare("UPDATE agents SET status = 'closed', closed_at = datetime('now') WHERE id = ? AND status = 'running' " +
445
+ "AND tmux_target = ? AND tmux_socket = ? AND pane_pid = ?")
446
+ .run(agentId, expected.tmux_target, expected.tmux_socket, expected.pane_pid);
466
447
  return info.changes > 0;
467
448
  }
@@ -0,0 +1,5 @@
1
+ export const ACTIVE_TODO_SQL = "t.status IN ('open', 'in_progress') AND t.archived_at IS NULL";
2
+ export const NOT_COMPLETED_TODO_SQL = "status != 'completed'";
3
+ export const OPEN_BLOCKERS_FROM_WHERE = `FROM todo_blockers b JOIN todos bt ON bt.id = b.blocker_id
4
+ WHERE b.todo_id = t.id AND bt.${NOT_COMPLETED_TODO_SQL}`;
5
+ export const OPEN_BLOCKERS_SQL = `SELECT 1 ${OPEN_BLOCKERS_FROM_WHERE}`;
@@ -695,19 +695,15 @@ export function registerAgents(server) {
695
695
  note: "The pane never became ready. The brief is loaded regardless; check agent_output before sending the worker its assignment - typing into it now risks losing the text silently.",
696
696
  }),
697
697
  }
698
- : harness.classifiesPaneScreen
699
- ? {
700
- instructions: workerBrief(briefFor(actorId)),
701
- }
702
- : {
703
- brief_path: writeAgentBrief(agentId, workerBrief(briefFor(actorId))),
704
- note: `This worker runs ${JSON.stringify(commandHead(baseCommand))}, which hive cannot brief and ` +
705
- "cannot type into: the guards that make typing safe read claude's chrome, so agent_send's " +
706
- "text path refuses this pane. There are no instructions to prepend, because there is no " +
707
- "supported way to send them. The brief is written to brief_path - open that pane " +
708
- "(tmux attach) and paste it in yourself, or drive the worker with agent_send(keys: [...]). " +
709
- "Wakes aimed at this worker are refused for the same reason.",
710
- }),
698
+ : {
699
+ brief_path: writeAgentBrief(agentId, workerBrief(briefFor(actorId))),
700
+ note: `This worker runs ${JSON.stringify(commandHead(baseCommand))}, which hive cannot brief and ` +
701
+ "cannot type into: the guards that make typing safe read claude's chrome, so agent_send's " +
702
+ "text path refuses this pane. There are no instructions to prepend, because there is no " +
703
+ "supported way to send them. The brief is written to brief_path - open that pane " +
704
+ "(tmux attach) and paste it in yourself, or drive the worker with agent_send(keys: [...]). " +
705
+ "Wakes aimed at this worker are refused for the same reason.",
706
+ }),
711
707
  };
712
708
  if (omittedTodoWarning) {
713
709
  const existingNote = typeof receipt.note === "string" ? receipt.note : "";
@@ -996,6 +992,7 @@ export function registerAgents(server) {
996
992
  const classifier = renameHarness.paneClassifier;
997
993
  const { awaitingChoice, tail } = classifier.choiceCheck(agent.tmux_target);
998
994
  const box = awaitingChoice === false ? classifier.inputBoxState(agent.tmux_target) : null;
995
+ const inCopyMode = paneInCopyMode(agent.tmux_target);
999
996
  if (awaitingChoice === true) {
1000
997
  heldNote =
1001
998
  "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.";
@@ -1017,6 +1014,11 @@ export function registerAgents(server) {
1017
1014
  `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.`;
1018
1015
  heldTail = tail;
1019
1016
  }
1017
+ else if (inCopyMode === true) {
1018
+ heldNote =
1019
+ `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.`;
1020
+ heldTail = tail;
1021
+ }
1020
1022
  else {
1021
1023
  let pasted = false;
1022
1024
  try {
@@ -1191,6 +1193,7 @@ export function registerAgents(server) {
1191
1193
  const agent = resolveQueenLeadTarget("agent_send", project.id, args.agent_id ?? args.name, () => findAgent(project.id, args));
1192
1194
  requireLive(agent);
1193
1195
  const target = agent.tmux_target;
1196
+ const identity = paneIdentity(agent);
1194
1197
  const auditIdentity = captureQueenWriteIdentity(project.id);
1195
1198
  let outgoing = args.text ?? "";
1196
1199
  const tag = senderTag(project.id, currentActor());
@@ -1241,10 +1244,9 @@ export function registerAgents(server) {
1241
1244
  }
1242
1245
  if (args.wait_ms == null)
1243
1246
  return quiet;
1244
- await sleep(Math.min(Math.max(args.wait_ms, 250), 10000));
1247
+ await sleep(args.wait_ms);
1245
1248
  try {
1246
- if (observeOwnership(agent) !== "live")
1247
- throw new Error("not live");
1249
+ requireStillOwned(agent.id, identity, "agent_send");
1248
1250
  return { ...quiet, tail: capturePane(target, 15) };
1249
1251
  }
1250
1252
  catch {
@@ -1342,7 +1344,6 @@ export function registerAgents(server) {
1342
1344
  outgoing = tag + args.text;
1343
1345
  let pasted = false;
1344
1346
  let buffered = false;
1345
- const identity = paneIdentity(agent);
1346
1347
  try {
1347
1348
  await sendText(target, outgoing, submitting, () => {
1348
1349
  pasted = true;
@@ -1376,9 +1377,10 @@ export function registerAgents(server) {
1376
1377
  throw new Error("Pass text or keys.");
1377
1378
  }
1378
1379
  if (args.wait_ms != null) {
1379
- await sleep(Math.min(Math.max(args.wait_ms, 250), 10000));
1380
+ await sleep(args.wait_ms);
1380
1381
  let tailField;
1381
1382
  try {
1383
+ requireStillOwned(agent.id, identity, "agent_send");
1382
1384
  tailField = { tail: capturePane(target, 15) };
1383
1385
  }
1384
1386
  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,
@@ -6,6 +6,7 @@ import { matchesAnyTag, parseTags, run } from "../result.js";
6
6
  import { findUnsafeControlChar } from "../tmux.js";
7
7
  import { SLUG_MAX_LEN, fallbackSlug } from "../slug.js";
8
8
  import { idParam, limitParam, offsetParam, projectIdParam } from "./params.js";
9
+ import { NOT_COMPLETED_TODO_SQL, OPEN_BLOCKERS_FROM_WHERE, OPEN_BLOCKERS_SQL } from "../todoScope.js";
9
10
  const priorityParam = z.enum(["high", "medium", "low"]);
10
11
  const slugParam = z
11
12
  .string()
@@ -19,11 +20,8 @@ const slugParam = z
19
20
  export { SLUG_MAX_LEN, fallbackSlug };
20
21
  export const TODO_STATUSES = ["open", "in_progress", "backlog", "completed"];
21
22
  const statusParam = z.enum(TODO_STATUSES);
22
- const OPEN_BLOCKERS_FROM_WHERE = `FROM todo_blockers b JOIN todos bt ON bt.id = b.blocker_id
23
- WHERE b.todo_id = t.id AND bt.status != 'completed'`;
24
- export const OPEN_BLOCKERS_SQL = `SELECT 1 ${OPEN_BLOCKERS_FROM_WHERE}`;
25
23
  const LIVE_DEPENDENTS_SQL = `SELECT t.id AS todo_id FROM todo_blockers b JOIN todos t ON t.id = b.todo_id
26
- WHERE b.blocker_id = ? AND t.status != 'completed' AND t.archived_at IS NULL`;
24
+ WHERE b.blocker_id = ? AND t.${NOT_COMPLETED_TODO_SQL} AND t.archived_at IS NULL`;
27
25
  export const COMMENT_COUNT_SQL = `(SELECT COUNT(*) FROM todo_comments c WHERE c.todo_id = t.id)`;
28
26
  const SUMMARY_SQL = `
29
27
  SELECT t.*,
@@ -191,10 +189,7 @@ const completeTodo = db.transaction((projectId, todoId, completed) => {
191
189
  if (completed) {
192
190
  newlyUnblocked = db
193
191
  .prepare(`${LIVE_DEPENDENTS_SQL}
194
- AND NOT EXISTS (
195
- SELECT 1 FROM todo_blockers b2 JOIN todos bt ON bt.id = b2.blocker_id
196
- WHERE b2.todo_id = t.id AND bt.status != 'completed'
197
- )`)
192
+ AND NOT EXISTS (${OPEN_BLOCKERS_SQL})`)
198
193
  .all(todo.id).map((r) => r.todo_id);
199
194
  }
200
195
  return { todo_id: todo.id, completed, newly_unblocked: newlyUnblocked };
package/dist/version.js CHANGED
@@ -1,7 +1,7 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import { readFileSync, statSync } from "node:fs";
3
2
  import { dirname, join } from "node:path";
4
3
  import { fileURLToPath } from "node:url";
4
+ import { gitOut } from "./git.js";
5
5
  const moduleDir = dirname(fileURLToPath(import.meta.url));
6
6
  const checkoutRoot = join(moduleDir, "..");
7
7
  function readBuildInfo() {
@@ -60,22 +60,9 @@ function packageVersion() {
60
60
  }
61
61
  }
62
62
  function currentGit() {
63
- try {
64
- const sha = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
65
- cwd: checkoutRoot,
66
- encoding: "utf8",
67
- stdio: ["ignore", "pipe", "ignore"],
68
- }).trim();
69
- const status = execFileSync("git", ["status", "--porcelain"], {
70
- cwd: checkoutRoot,
71
- encoding: "utf8",
72
- stdio: ["ignore", "pipe", "ignore"],
73
- });
74
- return { sha, dirty: status.trim().length > 0 };
75
- }
76
- catch {
77
- return null;
78
- }
63
+ const sha = gitOut(checkoutRoot, ["rev-parse", "--short", "HEAD"]);
64
+ const status = gitOut(checkoutRoot, ["status", "--porcelain"]);
65
+ return sha === null || status === null ? null : { sha, dirty: status.length > 0 };
79
66
  }
80
67
  const describe = (sha, dirty) => `${sha}${dirty ? "-dirty" : ""}`;
81
68
  export function versionInfo() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmgmyr/hive",
3
- "version": "1.11.3",
3
+ "version": "1.11.5",
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": {