@cmgmyr/hive 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,27 @@ You are interviewing someone to produce, or change, a hive profile: the standing
9
9
 
10
10
  ## Read before you ask anything
11
11
 
12
- Run `hive profile list` and `hive doctor` (or read this project's `hive.yml` directly) before the first question. If this is an edit, also read the named profile's current, rendered behavior with `hive profile read <file> --profile <name>` for each of `posture.md`, `runbook.md`, `worker.md`, and any other `.md` it carries, so you understand what this profile actually does today. Do not ask the user to repeat anything already answered by what you just read. This rendered read is for understanding only - `references/interview.md` covers the raw template (via `hive profile path`) you must read from, and write onto, when you actually apply an edit; a rendered copy drops any conditional section whose var is unset, so it is never a safe edit source.
12
+ Run `hive profile list` and `hive doctor` (or read this project's `hive.yml` directly) before the first question. If this is an edit, also read the named profile's current, rendered behavior for each of `posture.md`, `runbook.md`, `worker.md`, and any other `.md` it carries, using "Read a rendered file in bounded sections" below, so you understand what this profile actually does today. Do not ask the user to repeat anything already answered by what you just read. This rendered read is for understanding only - `references/interview.md` covers the raw template (via `hive profile path`) you must read from, and write onto, when you actually apply an edit; a rendered copy drops any conditional section whose var is unset, so it is never a safe edit source.
13
+
14
+ ## Read a rendered file in bounded sections
15
+
16
+ A harness may persist tool output over about 30KB to a file and show only a short preview, so a large rendered file read straight into a tool result is read mostly unseen. Redirect first, then look. `profile_name` is a name from `hive profile list`, `profile_file` is a filename it lists, and `profile_rendered` is a disposable file:
17
+
18
+ ```sh
19
+ profile_rendered=$(mktemp)
20
+ hive profile read "$profile_file" --profile "$profile_name" > "$profile_rendered" && wc -c < "$profile_rendered"
21
+ printf '%s\n' "$profile_rendered"
22
+ ```
23
+
24
+ Shell variables do not survive between tool calls. Note the path the last line printed and write that literal path into every later command and every file-reader call; do not rely on `$profile_rendered` after this call.
25
+
26
+ Use the saved content only if the read command succeeded. `wc -c` is the rendered size in bytes, the same number `hive profile list` and `hive doctor` show. If it is 25600 or less, read the whole file with your file reader. Above that, never read the whole file in one tool call; read it in sections of at most 16000 bytes each:
27
+
28
+ 1. Check for long lines first: `awk 'length > 2000 {print NR; exit}' PATH`. A file reader truncates any line over 2000 characters, so if this prints a line number, read the whole file by bytes with step 3 instead of with line ranges.
29
+ 2. Otherwise pick a line range with your file reader's offset and limit, and measure it first: `sed -n '1,80p' PATH | wc -c`. Shrink the range until the count is 16000 or less, then read it. Continue from the next line until you have read the last line. A scan of the headers is not a read.
30
+ 3. To read by bytes: `dd if=PATH bs=1 skip=0 count=16000 2>/dev/null`. Advance `skip` by 15996 each time, which overlaps 4 bytes so text split at a section edge shows whole in the next section. Stop when a section reaches the byte count from `wc -c`.
31
+
32
+ Delete the temporary file when you are done. It is for understanding and checking only, never a source for an edit (see below).
13
33
 
14
34
  ## Discovery first, not a checklist
15
35
 
@@ -70,7 +90,7 @@ A command, a repository name, a ticket prefix: these belong in `hive.yml` `vars`
70
90
 
71
91
  ## Validate before you tell them it's done
72
92
 
73
- After writing, run `hive doctor` and `hive profile read <file> --profile <name>` for each file you touched, from the project the profile is meant to serve. A profile with no readable `runbook.md` fails `hive doctor` outright; `posture.md` and `worker.md` do not, but check them anyway. Tell the user to restart any running hive session so it picks up the change, and invite one small follow-up edit once they've tried it - that is the expected way a profile keeps improving.
93
+ After writing, run `hive doctor` and read each file you touched with "Read a rendered file in bounded sections" above, from the project the profile is meant to serve. Read it to the last line: a change near the end of a large file is easy to miss. A profile with no readable `runbook.md` fails `hive doctor` outright; `posture.md` and `worker.md` do not, but check them anyway. Tell the user to restart any running hive session so it picks up the change, and invite one small follow-up edit once they've tried it - that is the expected way a profile keeps improving.
74
94
 
75
95
  ## What this skill does not do
76
96
 
@@ -1 +1 @@
1
- {"version":"1.5.0","sha":"c050e7f","dirty":false,"build_id":"56840ccc-881f-49ae-bbc4-440224077976"}
1
+ {"version":"1.6.0","sha":"ac13530","dirty":false,"build_id":"428a468f-98af-497e-a40c-fb87feef27ac"}
package/dist/cli.js CHANGED
@@ -30,7 +30,7 @@ import { probeSessionInterpreter, reexecTarget, sessionStartVerdict, } from "./s
30
30
  import { backupHealth, backupNow, backupsDir, formatBytes, listSnapshots, previewRestore, restoreSnapshot, totalSizeBytes, } from "./backup.js";
31
31
  import { describeTeardownWindow, readTeardowns, teardownLogPath, } from "./teardown.js";
32
32
  import { asNameClash, buildEnvFlags, closeAgentRow, isReservedAgentName, launchAgent, LEAD_KIND, LEAD_NAME, mintLeadActorId, upsertActor, withWindowClaim, } from "./spawn.js";
33
- import { adoptableWindow, applyLayout, claimInitialWindow, configureHiveWindow, controlModeFor, createWindow, DEFAULT_LAYOUT, describePaneChoice, ensureSession, findProjectWindow, foreignSocket, isPaneTarget, isViewSessionName, listOwnedWindows, ORPHAN_MIN_AGE_MS, orphanScratchServers, orphansWorthWarningAbout, paneIsAloneInWindow, paneVisibility, panePid, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, projectWindows, RAW_ATTACH_TMUX_CONFIG, renderAttachCommand, resolveAttachTarget, resolveInTmuxTarget, paneReissued, rowLive, rowLiveProbe, SESSION_PREFIX, appendGlobalHook, globalHooks, replaceGlobalHooks, sessionName, setPaneTitle, shellQuote, shownPaneTitle, makeProcessesWindow, tmux, TMUX_DOC, tmuxSaysNothingThere, tmuxTimeoutOverride, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, } from "./tmux.js";
33
+ import { adoptableWindow, applyLayout, claimInitialWindow, configureHiveWindow, controlModeFor, createWindow, DEFAULT_LAYOUT, describePaneChoice, ensureSession, findProjectWindow, foreignSocket, isPaneTarget, isViewSessionName, listOwnedWindows, liveTargets, observationFailed, ORPHAN_MIN_AGE_MS, orphanScratchServers, orphansWorthWarningAbout, paneIsAloneInWindow, paneVisibility, panePid, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, projectWindows, RAW_ATTACH_TMUX_CONFIG, renderAttachCommand, resolveAttachTarget, resolveInTmuxTarget, paneReissued, rowLive, rowLiveProbe, rowOwnership, SESSION_PREFIX, appendGlobalHook, globalHooks, replaceGlobalHooks, sessionName, setPaneTitle, shellQuote, shownPaneTitle, makeProcessesWindow, tmux, TMUX_DOC, tmuxSaysNothingThere, tmuxTimeoutOverride, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, } from "./tmux.js";
34
34
  import { describeVisibility, processCounts } from "./dashboard.js";
35
35
  import { runningCommandRow, runningCommandRows, snapshotProcesses, stopAllProcesses, stopLine, stopProcess, STOP_REASONS, } from "./processes.js";
36
36
  import { activeProfile, agentVarKeys, BUILT_IN_PROJECT_YML, configHash, loadProjectYml, mergedProjectVars, NO_PROFILE, PROJECT_YML_KEYS, resolveCommandDir, resolveFirstMessage, } from "./projectYml.js";
@@ -1111,6 +1111,16 @@ function profileDriftText(f) {
1111
1111
  text: `hive's default ${f.file} changed since you forked it${pct != null ? ` (${pct}% diverged)` : ""}`,
1112
1112
  };
1113
1113
  }
1114
+ const PROFILE_SIZE_WARNING_BYTES = 25600;
1115
+ function profileRenderedBytes(name, file, vars) {
1116
+ const rendered = renderProfileFile(name, file, vars);
1117
+ return rendered == null ? null : Buffer.byteLength(withTrailingNewline(rendered), "utf8");
1118
+ }
1119
+ function profileSizeAdvisory(name, file) {
1120
+ return (`${name}/${file}: rendered size exceeds ${PROFILE_SIZE_WARNING_BYTES} bytes. A harness may persist tool output ` +
1121
+ "over about 30KB and show only a short preview. Split into smaller profile files read with hive profile read, " +
1122
+ "or redirect hive profile read to a file and read by section.");
1123
+ }
1114
1124
  function cmdProfile(argv) {
1115
1125
  const [sub, ...rest] = argv;
1116
1126
  try {
@@ -1125,7 +1135,9 @@ function cmdProfile(argv) {
1125
1135
  return;
1126
1136
  }
1127
1137
  const here = findProjectForCwd();
1128
- const current = here ? activeProfile(loadProjectYml(here.path).config) : null;
1138
+ const hereConfig = here ? loadProjectYml(here.path).config : null;
1139
+ const current = here ? activeProfile(hereConfig) : null;
1140
+ const listVars = mergedProjectVars(hereConfig);
1129
1141
  const statuses = names.map((profile) => profileStatus(profile));
1130
1142
  const fileWidth = Math.max(11, ...statuses.flatMap((s) => s.files.map((f) => f.file.length)));
1131
1143
  for (const [i, profile] of names.entries()) {
@@ -1133,7 +1145,11 @@ function cmdProfile(argv) {
1133
1145
  console.log(`${profile === current ? "*" : " "} ${profile}`);
1134
1146
  for (const f of status.files) {
1135
1147
  const drift = profileDriftText(f);
1136
- console.log(` ${f.file.padEnd(fileWidth)} ${f.source.padEnd(7)} ${f.path}${drift ? ` (${drift.text})` : ""}`);
1148
+ const bytes = profileRenderedBytes(profile, f.file, listVars);
1149
+ const size = bytes == null ? "[rendered size unavailable]" : `[${bytes} rendered bytes]`;
1150
+ console.log(` ${f.file.padEnd(fileWidth)} ${f.source.padEnd(7)} ${f.path}${drift ? ` (${drift.text})` : ""} ${size}`);
1151
+ if (bytes != null && bytes > PROFILE_SIZE_WARNING_BYTES)
1152
+ console.log(` ${profileSizeAdvisory(profile, f.file)}`);
1137
1153
  }
1138
1154
  }
1139
1155
  if (current)
@@ -1550,9 +1566,17 @@ function cmdHide(argv) {
1550
1566
  return `${name}: hidden`;
1551
1567
  }));
1552
1568
  }
1569
+ const LEAD_OWNERSHIP_LABEL = {
1570
+ live: "running",
1571
+ gone: "no live pane",
1572
+ reissued: "no live pane",
1573
+ unknown: "pane identity unknown",
1574
+ };
1553
1575
  function cmdStatus() {
1554
1576
  janitor();
1555
1577
  let anyOutput = false;
1578
+ let snapshot;
1579
+ const statusSnapshot = () => (snapshot === undefined ? (snapshot = liveTargets()) : snapshot);
1556
1580
  let windows;
1557
1581
  let windowsError;
1558
1582
  const ownedWindows = () => {
@@ -1598,7 +1622,9 @@ function cmdStatus() {
1598
1622
  for (const a of agents) {
1599
1623
  const state = a.kind === "agent"
1600
1624
  ? describeForHuman(deriveProvenance(a, null))
1601
- : (whereIs.get(a.name) ?? "running");
1625
+ : a.kind === LEAD_KIND
1626
+ ? LEAD_OWNERSHIP_LABEL[rowOwnership(a, statusSnapshot())]
1627
+ : (whereIs.get(a.name) ?? "running");
1602
1628
  const label = a.kind === "command" ? "cmd " : a.kind === LEAD_KIND ? "lead " : "agent";
1603
1629
  console.log(` ${label} ${a.name.padEnd(20)} ${state}`);
1604
1630
  if (reportsAgentStateLog(a)) {
@@ -2153,7 +2179,7 @@ function reportCrewTeardown(projectId) {
2153
2179
  }
2154
2180
  function reportStalledWorkers(projectId) {
2155
2181
  const latched = db
2156
- .prepare(`SELECT name, command, kind, cwd, session_id, transcript_path, agent_state, state_changed_at, tmux_target, tmux_socket
2182
+ .prepare(`SELECT name, command, kind, cwd, session_id, transcript_path, agent_state, state_changed_at, tmux_target, tmux_socket, pane_pid
2157
2183
  FROM agents
2158
2184
  WHERE project_id = ? AND status = 'running' AND kind = 'agent'
2159
2185
  AND agent_state IN ('working', 'waiting') AND state_changed_at IS NOT NULL
@@ -2164,6 +2190,7 @@ function reportStalledWorkers(projectId) {
2164
2190
  // never this latch"), and a harness with no transcript to check would have nothing to read.
2165
2191
  .filter((row) => reportsAgentStateLog(row) && hasTranscriptSignal(row) && row.session_id !== "");
2166
2192
  const stalled = [];
2193
+ let snapshot;
2167
2194
  for (const row of latched) {
2168
2195
  const latchedFor = ageSecondsSince(row.state_changed_at);
2169
2196
  if (latchedFor < STALL_BOUND_SECONDS)
@@ -2180,7 +2207,9 @@ function reportStalledWorkers(projectId) {
2180
2207
  if (stale !== "never" && stale.seconds < STALL_BOUND_SECONDS)
2181
2208
  continue;
2182
2209
  if (row.agent_state === "waiting") {
2183
- if (foreignSocket(row.tmux_socket))
2210
+ if (snapshot === undefined)
2211
+ snapshot = liveTargets();
2212
+ if (rowOwnership(row, snapshot) !== "live")
2184
2213
  continue;
2185
2214
  const choiceCheck = paneClassifierFor(row.command)?.choiceCheck;
2186
2215
  if (!choiceCheck || choiceCheck(row.tmux_target).awaitingChoice !== false)
@@ -2359,6 +2388,12 @@ function cmdDoctor(argv) {
2359
2388
  warn("profile vars", `hive.yml vars ${collisions.join(", ")} are derived from agents: and are ignored.`);
2360
2389
  }
2361
2390
  const vars = mergedProjectVars(cfg);
2391
+ for (const f of resolved) {
2392
+ const bytes = profileRenderedBytes(name, f.file, vars);
2393
+ info("profile size", bytes == null ? `${name}/${f.file}: rendered size unavailable` : `${name}/${f.file}: ${bytes} rendered bytes`);
2394
+ if (bytes != null && bytes > PROFILE_SIZE_WARNING_BYTES)
2395
+ info("profile size warning", profileSizeAdvisory(name, f.file));
2396
+ }
2362
2397
  const renderedText = profileFileNames(name)
2363
2398
  .map((file) => renderProfileFile(name, file, vars))
2364
2399
  .filter((t) => t != null)
@@ -2408,21 +2443,36 @@ function cmdDoctor(argv) {
2408
2443
  });
2409
2444
  if (here) {
2410
2445
  const lead = db
2411
- .prepare("SELECT tmux_target, tmux_socket FROM agents WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id")
2446
+ .prepare("SELECT id, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id")
2412
2447
  .get(here.id, LEAD_KIND);
2413
2448
  if (lead) {
2414
- const live = rowLive(lead.tmux_socket, lead.tmux_target);
2415
- if (live === false) {
2416
- warn("lead", "the lead's row is running but its pane is not live. The janitor leaves lead rows alone on " +
2449
+ const snapshot = liveTargets();
2450
+ const ownership = rowOwnership(lead, snapshot);
2451
+ const unobserved = !foreignSocket(lead.tmux_socket) && observationFailed(snapshot);
2452
+ if (ownership === "gone" || ownership === "reissued") {
2453
+ warn("lead", (ownership === "reissued"
2454
+ ? "the lead's row is running but its pane id now belongs to a different process. "
2455
+ : "the lead's row is running but its pane is not live. ") +
2456
+ "The janitor leaves lead rows alone on " +
2417
2457
  "purpose, so nothing will fix this by itself. Run `hive lead` to record a fresh pane and reuse " +
2418
2458
  "this identity, or ask a claude session connected to this project's hive MCP server to call " +
2419
- "the agent_close tool on it to retire the row for good - required before `hive restore`, which " +
2459
+ (ownership === "reissued"
2460
+ ? `agent_close({agent_id: ${lead.id}, row_only: true}) on it (plain agent_close refuses a pane id ` +
2461
+ "that still exists)"
2462
+ : "the agent_close tool on it") +
2463
+ " to retire the row for good - required before `hive restore`, which " +
2420
2464
  "otherwise refuses while any lead row reads running, and end that session too, since it also " +
2421
2465
  "holds this store open.");
2422
2466
  }
2423
- else if (live === null) {
2467
+ else if (ownership === "unknown" && unobserved) {
2424
2468
  warn("lead", "the lead's pane liveness could not be probed (tmux did not answer).");
2425
2469
  }
2470
+ else if (ownership === "unknown") {
2471
+ warn("lead", `pane identity unknown: the lead's row (agents.id ${lead.id}) cannot be verified as owning pane ` +
2472
+ `${lead.tmux_target || "(none)"}, because it is on another tmux socket or no pane pid was recorded ` +
2473
+ "for it. Its screen was not read. If this lead is gone, a human or peer lead (never a worker) can " +
2474
+ `retire the row with the MCP tool agent_close({agent_id: ${lead.id}, row_only: true}).`);
2475
+ }
2426
2476
  }
2427
2477
  const stuck = db
2428
2478
  .prepare("SELECT name, kind, tmux_socket FROM agents WHERE project_id = ? AND status = 'running' AND kind != ?")
@@ -2441,19 +2491,21 @@ function cmdDoctor(argv) {
2441
2491
  }
2442
2492
  if (here) {
2443
2493
  const workers = db
2444
- .prepare("SELECT name, actor_id, command, kind, tmux_target, tmux_socket FROM agents WHERE project_id = ? AND status = 'running' AND kind = 'agent' ORDER BY id")
2494
+ .prepare("SELECT name, actor_id, command, kind, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND status = 'running' AND kind = 'agent' ORDER BY id")
2445
2495
  .all(here.id);
2496
+ const paneSnapshot = workers.length > 0 ? liveTargets() : null;
2446
2497
  let inputBoxClean = 0;
2447
2498
  let inputBoxDrifted = 0;
2448
2499
  let inputBoxUnclassified = 0;
2449
2500
  let leadsProbed = 0;
2450
2501
  let workersProbed = 0;
2451
2502
  const leadRows = db
2452
- .prepare("SELECT name, command, tmux_target, tmux_socket FROM agents WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id")
2503
+ .prepare("SELECT name, command, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id")
2453
2504
  .all(here.id, LEAD_KIND);
2505
+ const leadSnapshot = leadRows.length > 0 ? (paneSnapshot ?? liveTargets()) : null;
2454
2506
  for (const leadBox of leadRows) {
2455
2507
  const leadHarness = harnessFor(leadBox.command);
2456
- if (!leadHarness.classifiesPaneScreen || foreignSocket(leadBox.tmux_socket))
2508
+ if (!leadHarness.classifiesPaneScreen || rowOwnership(leadBox, leadSnapshot) !== "live")
2457
2509
  continue;
2458
2510
  leadsProbed += 1;
2459
2511
  const box = leadHarness.paneClassifier.inputBoxState(leadBox.tmux_target);
@@ -2478,20 +2530,26 @@ function cmdDoctor(argv) {
2478
2530
  workersReported += 1;
2479
2531
  // Dispatched through w's OWN harness, never the claude-only import - reading a codex pane
2480
2532
  // with claude's dialog regexes fails silently in either direction (.claude/rules/tmux-and-panes.md).
2533
+ const ownership = rowOwnership(w, paneSnapshot);
2481
2534
  const foreign = foreignSocket(w.tmux_socket);
2535
+ const unowned = ownership !== "live";
2482
2536
  const workerChoiceCheck = paneClassifierFor(w.command)?.choiceCheck;
2483
- const { awaitingChoice, tail } = foreign || !workerChoiceCheck ? { awaitingChoice: null, tail: "" } : workerChoiceCheck(w.tmux_target);
2537
+ const { awaitingChoice, tail } = unowned || !workerChoiceCheck ? { awaitingChoice: null, tail: "" } : workerChoiceCheck(w.tmux_target);
2484
2538
  if (awaitingChoice === true)
2485
2539
  dialogCount += 1;
2486
2540
  verboseInfo("worker live state", `worker ${w.name}`, `last log event: ${describeLastLogEvent(lastLogEvent(w.actor_id))}`, `permission mode: ${lastPermissionMode(w.actor_id) ?? "unknown (no record)"}`, foreign
2487
2541
  ? `pane: recorded on a different tmux socket (${w.tmux_socket}); this process cannot read it`
2488
- : `pane: ${describePaneChoice(awaitingChoice)}`, ...(foreign
2542
+ : unowned
2543
+ ? `pane: not read - pane ownership ${ownership}`
2544
+ : `pane: ${describePaneChoice(awaitingChoice)}`, ...(foreign
2489
2545
  ? ["tail: (not read - foreign socket)"]
2490
- : awaitingChoice === null
2491
- ? ["tail: (pane could not be read)"]
2492
- : tail === ""
2493
- ? ["tail: (pane rendered nothing)"]
2494
- : ["tail:", ...tail.split("\n").map((line) => `| ${line}`)]));
2546
+ : unowned
2547
+ ? [`tail: (not read - pane ownership ${ownership})`]
2548
+ : awaitingChoice === null
2549
+ ? ["tail: (pane could not be read)"]
2550
+ : tail === ""
2551
+ ? ["tail: (pane rendered nothing)"]
2552
+ : ["tail:", ...tail.split("\n").map((line) => `| ${line}`)]));
2495
2553
  }
2496
2554
  // Gated on classifiesPaneScreen, not reportsAgentStateLog: this drift check only needs the pane,
2497
2555
  // and a codex worker's pane has been readable since todo 524, independent of whether its
@@ -2499,7 +2557,7 @@ function cmdDoctor(argv) {
2499
2557
  // own harness, never the claude-only import above, or a codex worker's screen gets read with
2500
2558
  // claude's regexes (see hive-internals).
2501
2559
  const harness = harnessFor(w.command);
2502
- if (!harness.classifiesPaneScreen || foreignSocket(w.tmux_socket))
2560
+ if (!harness.classifiesPaneScreen || rowOwnership(w, paneSnapshot) !== "live")
2503
2561
  continue;
2504
2562
  workersProbed += 1;
2505
2563
  const box = harness.paneClassifier.inputBoxState(w.tmux_target);
@@ -24,7 +24,7 @@ export function contextCheckpointAdditionalContext(actorId, thresholdPercent, fi
24
24
  return null;
25
25
  }
26
26
  }
27
- export function runContextCheckpointHook(kind) {
27
+ export function runContextCheckpointHook(kind, suppliedPayload) {
28
28
  const thresholdText = process.env.HIVE_CONTEXT_CHECKPOINT_PERCENT;
29
29
  if (!thresholdText || !/^\d+$/.test(thresholdText))
30
30
  return;
@@ -35,7 +35,7 @@ export function runContextCheckpointHook(kind) {
35
35
  if (!actorId.startsWith("agent:") || (kind !== "claude" && kind !== "codex"))
36
36
  return;
37
37
  try {
38
- const payload = JSON.parse(readFileSync(0, "utf8"));
38
+ const payload = suppliedPayload === undefined ? JSON.parse(readFileSync(0, "utf8")) : suppliedPayload;
39
39
  if (payload === null || typeof payload !== "object" || Array.isArray(payload))
40
40
  return;
41
41
  const fill = readContextFill(kind, {
package/dist/help.js CHANGED
@@ -175,7 +175,10 @@ runbook (fork it first), not a pad write.`,
175
175
  agent_close(name|agent_id) — kill the worker's pane and mark closed. Capture
176
176
  handoffs first; output is not retained. Self-close needs confirm_self.
177
177
  On a PARKED row (by agent_id) it releases the park instead, which is how
178
- you abandon a lane you have decided not to resume.
178
+ you abandon a lane you have decided not to resume. row_only=true marks
179
+ a stale row closed with no kill, stop or typing; a human or peer lead
180
+ only, refused for a row that provably owns a live pane or when tmux
181
+ could not be probed.
179
182
 
180
183
  Address a worker by its name, not its id: agent_send(name="impl", ...). A
181
184
  partial name works when it matches one running worker, so name="123" finds
@@ -257,8 +260,9 @@ Every pad, todo, kv entry, and lease belongs to one project (a directory).
257
260
  under HIVE_PROJECT_LOCK=1 since it sweeps every project, not just yours.
258
261
  With project_id, removes only that project, and only if it owns no rows.
259
262
  With project_id and confirm_name (its exact name), removes it with every
260
- row it owns after a snapshot; refuses while it has a running agent. From a
261
- terminal: hive project rm <id|path> [--yes].
263
+ row it owns after a snapshot; refuses while a running row owns a live
264
+ pane or cannot be verified (agent_close row_only retires the latter).
265
+ From a terminal: hive project rm <id|path> [--yes].
262
266
 
263
267
  Resolution order: explicit project_id argument, then session selection, then
264
268
  working-directory auto-detection. When the working directory matches no
package/dist/hook.js CHANGED
@@ -1,7 +1,70 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from "node:fs";
2
+ import { readFileSync, realpathSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, parse, relative, sep } from "node:path";
3
4
  import { awaitingFirstPromptSql } from "./firstPrompt.js";
4
5
  import { liveBackgroundTasks, SUBAGENT_LATCH_SQL, withholdsIdle } from "./backgroundTasks.js";
6
+ function canonicalPathWithMissingSuffix(input) {
7
+ if (!isAbsolute(input) || input.includes("\0"))
8
+ return null;
9
+ let current = parse(input).root;
10
+ const missing = [];
11
+ try {
12
+ current = realpathSync(current);
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ for (const part of input.slice(parse(input).root.length).split(sep)) {
18
+ if (part === "" || part === ".")
19
+ continue;
20
+ if (part === "..") {
21
+ if (missing.length)
22
+ missing.pop();
23
+ else
24
+ current = dirname(current);
25
+ continue;
26
+ }
27
+ if (missing.length) {
28
+ missing.push(part);
29
+ continue;
30
+ }
31
+ const candidate = join(current, part);
32
+ try {
33
+ current = realpathSync(candidate);
34
+ }
35
+ catch (error) {
36
+ if (error.code !== "ENOENT")
37
+ return null;
38
+ missing.push(part);
39
+ }
40
+ }
41
+ return join(current, ...missing);
42
+ }
43
+ function acceptsWorkerHook(row, payload, sessionsRoot) {
44
+ if (!row || row.kind !== "agent" || row.codex_home === "" || sessionsRoot === undefined)
45
+ return true;
46
+ const transcript = payload.transcript_path;
47
+ if (typeof transcript === "string" && transcript !== "") {
48
+ const canonicalRoot = canonicalPathWithMissingSuffix(sessionsRoot);
49
+ const canonicalTranscript = canonicalPathWithMissingSuffix(transcript);
50
+ if (canonicalRoot === null || canonicalTranscript === null)
51
+ return false;
52
+ const rel = relative(canonicalRoot, canonicalTranscript);
53
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
54
+ }
55
+ if (transcript !== undefined && transcript !== null && transcript !== "")
56
+ return false;
57
+ return (typeof row.session_id === "string" && row.session_id !== "" &&
58
+ typeof payload.session_id === "string" && payload.session_id !== "" &&
59
+ row.session_id === payload.session_id);
60
+ }
61
+ function validCheckpointThreshold() {
62
+ const text = process.env.HIVE_CONTEXT_CHECKPOINT_PERCENT;
63
+ if (!text || !/^\d+$/.test(text))
64
+ return false;
65
+ const value = Number(text);
66
+ return Number.isInteger(value) && value >= 1 && value <= 100;
67
+ }
5
68
  let raw;
6
69
  function readRaw() {
7
70
  if (raw === undefined) {
@@ -161,8 +224,33 @@ function stateFor(event, actorId) {
161
224
  }
162
225
  if (process.argv[2] === "post_tool_use") {
163
226
  try {
227
+ const kind = process.argv[3];
228
+ const actorId = process.env.HIVE_AGENT_ID ?? "";
229
+ let suppliedPayload;
230
+ if (kind === "codex" && validCheckpointThreshold() && actorId.startsWith("agent:")) {
231
+ suppliedPayload = readPayload();
232
+ const { db } = await import("./db.js");
233
+ const row = db.prepare("SELECT kind, command, status, codex_home, session_id FROM agents WHERE actor_id = ?")
234
+ .get(actorId);
235
+ if (row?.kind === "agent") {
236
+ const { harnessFor } = await import("./harnesses.js");
237
+ if (harnessFor(row.command).name === "codex") {
238
+ if (row.status !== "running")
239
+ process.exit(0);
240
+ if (typeof row.codex_home === "string" && row.codex_home !== "") {
241
+ const { codexHomeDir } = await import("./codexHome.js");
242
+ if (!acceptsWorkerHook(row, suppliedPayload, join(codexHomeDir(row.codex_home), "sessions"))) {
243
+ process.exit(0);
244
+ }
245
+ }
246
+ }
247
+ }
248
+ }
164
249
  const { runContextCheckpointHook } = await import("./contextCheckpoint.js");
165
- runContextCheckpointHook(process.argv[3]);
250
+ if (suppliedPayload === undefined)
251
+ runContextCheckpointHook(kind);
252
+ else
253
+ runContextCheckpointHook(kind, suppliedPayload);
166
254
  }
167
255
  catch { }
168
256
  process.exit(0);
@@ -172,13 +260,43 @@ try {
172
260
  const actorId = process.env.HIVE_AGENT_ID;
173
261
  if (actorId) {
174
262
  const event = process.argv[2] ?? "";
175
- const state = stateFor(event, actorId);
176
- if (state !== null) {
177
- db.prepare("UPDATE agents SET agent_state = ?, state_changed_at = datetime('now') WHERE actor_id = ? AND kind = 'agent'").run(state, actorId);
178
- }
179
- if (event === "prompt") {
180
- db.prepare(`UPDATE agents SET resumed_at = '' WHERE actor_id = ? AND kind = 'agent' AND ${awaitingFirstPromptSql("agents")}`).run(actorId);
263
+ const hookPayload = readPayload();
264
+ const row = db.prepare("SELECT kind, command, status, codex_home, session_id FROM agents WHERE actor_id = ?")
265
+ .get(actorId);
266
+ let guarded = false;
267
+ let state = null;
268
+ if (row?.kind === "agent") {
269
+ const { harnessFor } = await import("./harnesses.js");
270
+ if (harnessFor(row.command).name === "codex") {
271
+ if (row.status !== "running")
272
+ process.exit(0);
273
+ const { codexHomeDir } = await import("./codexHome.js");
274
+ const result = db.transaction(() => {
275
+ const current = db.prepare("SELECT kind, command, status, codex_home, session_id FROM agents WHERE actor_id = ?")
276
+ .get(actorId);
277
+ if (current?.kind === "agent" && harnessFor(current.command).name === "codex" && current.status !== "running") {
278
+ return { status: "inactive", state: null };
279
+ }
280
+ if (!current || current.kind !== "agent" || typeof current.codex_home !== "string" || !current.codex_home || harnessFor(current.command).name !== "codex") {
281
+ return { status: "changed", state: null };
282
+ }
283
+ const sessionsRoot = join(codexHomeDir(current.codex_home), "sessions");
284
+ if (!acceptsWorkerHook(current, hookPayload, sessionsRoot))
285
+ return { status: "rejected", state: null };
286
+ const state = applyWorkerTransition(actorId, event);
287
+ reconcileSessionId(actorId, hookPayload);
288
+ reconcileTranscriptPath(actorId, hookPayload);
289
+ db.prepare("UPDATE actors SET last_seen_at = datetime('now') WHERE id = ?").run(actorId);
290
+ return { status: "accepted", state };
291
+ }).immediate();
292
+ if (result.status === "rejected" || result.status === "inactive")
293
+ process.exit(0);
294
+ guarded = result.status === "accepted";
295
+ state = result.state;
296
+ }
181
297
  }
298
+ if (!guarded)
299
+ state = applyWorkerTransition(actorId, event);
182
300
  // A lead's turn state is its own table; agents.agent_state stays worker-only.
183
301
  if (process.env.HIVE_LEAD === "1" && (event === "prompt" || event === "stop" || event === "session_end")) {
184
302
  try {
@@ -195,12 +313,24 @@ try {
195
313
  catch {
196
314
  }
197
315
  }
198
- reconcileSessionId(actorId, readPayload());
199
- reconcileTranscriptPath(actorId, readPayload());
200
- db.prepare("UPDATE actors SET last_seen_at = datetime('now') WHERE id = ?").run(actorId);
316
+ if (!guarded) {
317
+ reconcileSessionId(actorId, hookPayload);
318
+ reconcileTranscriptPath(actorId, hookPayload);
319
+ db.prepare("UPDATE actors SET last_seen_at = datetime('now') WHERE id = ?").run(actorId);
320
+ }
201
321
  record(actorId, event, state ?? UNCHANGED);
202
322
  }
203
323
  }
204
324
  catch {
205
325
  }
206
326
  process.exit(0);
327
+ function applyWorkerTransition(actorId, event) {
328
+ const state = stateFor(event, actorId);
329
+ if (state !== null) {
330
+ db.prepare("UPDATE agents SET agent_state = ?, state_changed_at = datetime('now') WHERE actor_id = ? AND kind = 'agent'").run(state, actorId);
331
+ }
332
+ if (event === "prompt") {
333
+ db.prepare(`UPDATE agents SET resumed_at = '' WHERE actor_id = ? AND kind = 'agent' AND ${awaitingFirstPromptSql("agents")}`).run(actorId);
334
+ }
335
+ return state;
336
+ }
package/dist/portfolio.js CHANGED
@@ -4,7 +4,7 @@ import { existsSync } from "node:fs";
4
4
  import { parseTags } from "./result.js";
5
5
  import { OPEN_BLOCKERS_SQL } from "./tools/todos.js";
6
6
  import { readLeadTurnState } from "./leadState.js";
7
- import { liveTargets, paneReissued, rowAlive, rowAliveProbe } from "./tmux.js";
7
+ import { liveTargets, ownershipLiveness, rowOwnership } from "./tmux.js";
8
8
  export const PORTFOLIO_REASONS = [
9
9
  "needs_human",
10
10
  "dead_lead_pane",
@@ -84,15 +84,14 @@ function projectRow(project, now, snapshot) {
84
84
  const agents = db
85
85
  .prepare("SELECT id, kind, agent_state, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
86
86
  .all(project.id);
87
- const liveness = (a) => snapshot ? rowAlive(a.tmux_socket, a.tmux_target, snapshot) : null;
88
- const reissued = (a) => snapshot !== null && paneReissued(a.pane_pid, rowAliveProbe(a.tmux_socket, a.tmux_target, snapshot));
87
+ const liveness = (a) => ownershipLiveness(rowOwnership(a, snapshot));
89
88
  const workers = { working: 0, idle: 0, needs_input: 0, other: 0, unreachable: 0, unconfirmed: 0 };
90
89
  let liveWorking = 0;
91
90
  let liveWaiting = 0;
92
91
  let lead = { state: "none", agent_id: null, turn: "unknown" };
93
92
  for (const a of agents) {
94
93
  if (a.kind === "lead") {
95
- const live = reissued(a) ? false : liveness(a);
94
+ const live = liveness(a);
96
95
  lead = {
97
96
  state: live === true ? "alive" : live === false ? "dead_pane" : "unknown",
98
97
  agent_id: a.id,
@@ -102,7 +101,7 @@ function projectRow(project, now, snapshot) {
102
101
  }
103
102
  if (a.kind !== "agent")
104
103
  continue;
105
- const live = reissued(a) ? false : liveness(a);
104
+ const live = liveness(a);
106
105
  if (live === false)
107
106
  workers.unreachable++;
108
107
  else if (live === null)
@@ -1,5 +1,7 @@
1
1
  import { backupNow } from "./backup.js";
2
2
  import { dataDir, db } from "./db.js";
3
+ import { LEAD_KIND } from "./spawn.js";
4
+ import { foreignSocket, liveTargets, observationFailed, rowOwnership } from "./tmux.js";
3
5
  export const PROJECT_OWNER_TABLES = [
4
6
  "pads",
5
7
  "todos",
@@ -28,17 +30,49 @@ export function formatRowCounts(counts) {
28
30
  .map(([table, n]) => `${table}: ${n}`)
29
31
  .join(", ");
30
32
  }
31
- function assertNoRunningAgents(projectId, name) {
32
- const running = db
33
- .prepare("SELECT id, name FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
34
- .all(projectId);
33
+ const runningRows = (projectId) => db
34
+ .prepare("SELECT id, name, kind, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
35
+ .all(projectId);
36
+ const identities = (rows) => JSON.stringify(rows.map((r) => [r.id, r.tmux_target, r.tmux_socket, r.pane_pid]));
37
+ function assertNoRunningAgents(projectId, name, running, panes) {
35
38
  if (running.length === 0)
36
39
  return;
37
- const names = running.map((a) => `${a.name} (agent ${a.id})`).join(", ");
38
- throw new Error(`project ${projectId} ("${name}") has running agents: ${names}. Nothing deleted. Run hive doctor to close rows whose panes are gone, or agent_close them.`);
40
+ const snapshot = panes === undefined ? liveTargets() : panes;
41
+ const blockers = running
42
+ .map((a) => ({ ...a, ownership: rowOwnership(a, snapshot) }))
43
+ .filter((a) => a.ownership === "live" || a.ownership === "unknown");
44
+ if (blockers.length === 0)
45
+ return;
46
+ const names = blockers
47
+ .map((a) => `${a.name} (agent ${a.id}, ${a.ownership === "live" ? "owns a live pane" : "pane identity unknown"})`)
48
+ .join(", ");
49
+ const hints = [];
50
+ const liveLeads = blockers.filter((a) => a.ownership === "live" && a.kind === LEAD_KIND);
51
+ const liveOthers = blockers.filter((a) => a.ownership === "live" && a.kind !== LEAD_KIND);
52
+ const unknown = blockers.filter((a) => a.ownership === "unknown");
53
+ if (liveLeads.length > 0)
54
+ hints.push("A live lead is stopped or restarted from its own terminal, never retired with row_only.");
55
+ if (liveOthers.length > 0)
56
+ hints.push("Stop a live worker or command with agent_close first.");
57
+ if (unknown.length > 0 && observationFailed(snapshot)) {
58
+ hints.push("tmux could not be probed, so no row's ownership could be checked. Retry in a few seconds.");
59
+ }
60
+ else if (unknown.length > 0) {
61
+ const foreign = unknown.some((a) => foreignSocket(a.tmux_socket));
62
+ hints.push("A row whose pane cannot be verified is retired explicitly by a human or peer lead through this project's MCP " +
63
+ `tool, ${unknown.map((a) => `agent_close({agent_id: ${a.id}, row_only: true})`).join(", ")}` +
64
+ (foreign
65
+ ? ", or run this again from the tmux socket a foreign-socket row was recorded on, where its ownership can be verified."
66
+ : "."));
67
+ }
68
+ throw new Error(`project ${projectId} ("${name}") has running agents: ${names}. Nothing deleted. ${hints.join(" ")}`);
39
69
  }
40
- const deleteProjectRows = db.transaction((projectId, name, expected, after) => {
41
- assertNoRunningAgents(projectId, name);
70
+ const deleteProjectRows = db.transaction((projectId, name, expected, observed, after) => {
71
+ const running = runningRows(projectId);
72
+ if (identities(running) !== observed.rows) {
73
+ throw new Error(`project ${projectId} ("${name}") changed while the snapshot was taken; nothing removed, run it again.`);
74
+ }
75
+ assertNoRunningAgents(projectId, name, running, observed.panes);
42
76
  if (JSON.stringify(projectRowCounts(projectId)) !== JSON.stringify(expected)) {
43
77
  throw new Error(`project ${projectId} ("${name}") changed while the snapshot was taken; nothing removed, run it again.`);
44
78
  }
@@ -52,7 +86,7 @@ export function removeProject(projectId, opts) {
52
86
  const target = db.prepare("SELECT id, name, path FROM projects WHERE id = ?").get(projectId);
53
87
  if (!target)
54
88
  throw new Error(`no project ${projectId}.`);
55
- assertNoRunningAgents(projectId, target.name);
89
+ assertNoRunningAgents(projectId, target.name, runningRows(projectId));
56
90
  const counts = projectRowCounts(projectId);
57
91
  let snapshot = null;
58
92
  if (opts.snapshot) {
@@ -62,7 +96,9 @@ export function removeProject(projectId, opts) {
62
96
  snapshot = result.path ?? null;
63
97
  }
64
98
  opts.afterSnapshot?.();
99
+ const rows = runningRows(projectId);
100
+ const observed = { rows: identities(rows), panes: rows.length > 0 ? liveTargets() : null };
65
101
  const removed = { deleted: { id: target.id, name: target.name, path: target.path }, counts, snapshot };
66
- deleteProjectRows.immediate(projectId, target.name, counts, () => opts.onRemoved?.(removed));
102
+ deleteProjectRows.immediate(projectId, target.name, counts, observed, () => opts.onRemoved?.(removed));
67
103
  return removed;
68
104
  }
package/dist/spawn.js CHANGED
@@ -371,13 +371,18 @@ export function reapCodexHomeForClosedAgent(agentId, codexHome) {
371
371
  }
372
372
  db.prepare("UPDATE agents SET codex_home = '' WHERE id = ? AND codex_home = ?").run(agentId, codexHome);
373
373
  }
374
- export function closeAgentRow(agentId, expectedTmuxTarget) {
375
- const info = expectedTmuxTarget === undefined
374
+ export function closeAgentRow(agentId, expected) {
375
+ const info = expected === undefined
376
376
  ? db
377
377
  .prepare("UPDATE agents SET status = 'closed', closed_at = datetime('now') WHERE id = ? AND status = 'running'")
378
378
  .run(agentId)
379
- : db
380
- .prepare("UPDATE agents SET status = 'closed', closed_at = datetime('now') WHERE id = ? AND status = 'running' AND tmux_target = ?")
381
- .run(agentId, expectedTmuxTarget);
379
+ : typeof expected === "string"
380
+ ? db
381
+ .prepare("UPDATE agents SET status = 'closed', closed_at = datetime('now') WHERE id = ? AND status = 'running' AND tmux_target = ?")
382
+ .run(agentId, expected)
383
+ : db
384
+ .prepare("UPDATE agents SET status = 'closed', closed_at = datetime('now') WHERE id = ? AND status = 'running' " +
385
+ "AND tmux_target = ? AND tmux_socket = ? AND pane_pid = ?")
386
+ .run(agentId, expected.tmux_target, expected.tmux_socket, expected.pane_pid);
382
387
  return info.changes > 0;
383
388
  }
package/dist/tmux.js CHANGED
@@ -470,6 +470,31 @@ export function rowAliveProbe(recordedSocket, target, snapshot) {
470
470
  export function paneReissued(recordedPid, probe) {
471
471
  return probe.live === true && recordedPid !== "" && probe.pid !== null && probe.pid !== recordedPid;
472
472
  }
473
+ // The one place a row's claim on a pane is decided. An existing pane is "live" only when both
474
+ // pids are known and equal; an empty recorded pid on a live id is "unknown", never "live".
475
+ export function observationFailed(snapshot) {
476
+ return snapshot === null || snapshot.serverAnswered === false;
477
+ }
478
+ export function rowOwnership(row, snapshot) {
479
+ if (foreignSocket(row.tmux_socket))
480
+ return "unknown";
481
+ if (row.tmux_target === "")
482
+ return "gone";
483
+ const observed = snapshot === undefined ? liveTargets() : snapshot;
484
+ if (observed === null || observationFailed(observed))
485
+ return "unknown";
486
+ if (!targetAlive(row.tmux_target, observed))
487
+ return "gone";
488
+ if (!isPaneTarget(row.tmux_target))
489
+ return "unknown";
490
+ const pid = targetPid(row.tmux_target, observed) ?? "";
491
+ if (row.pane_pid === "" || pid === "")
492
+ return "unknown";
493
+ return pid === row.pane_pid ? "live" : "reissued";
494
+ }
495
+ export function ownershipLiveness(ownership) {
496
+ return ownership === "live" ? true : ownership === "unknown" ? null : false;
497
+ }
473
498
  export const RAW_ATTACH_TMUX_CONFIG = [
474
499
  "set -g allow-passthrough all",
475
500
  ];
@@ -16,7 +16,7 @@ import { markGoneReported } from "../scheduler.js";
16
16
  import { agentIdentityEnv, branchAt, closeAgentRow, discardOrphanedPane, isReservedAgentName, isRunningLeadActor, killAgentPane, launchAgent, LEAD_KIND, parkAgentRow, reapCodexHomeForClosedAgent, releaseParkRow, renameAgent, resumeAgent, storedLaunchOptions, } from "../spawn.js";
17
17
  import { COMMAND_KIND, runningCommandRow, stopLine, stopProcess, STOP_REASONS } from "../processes.js";
18
18
  import { readContextTokens, readContextFill, resolveTranscriptDir } from "../transcript.js";
19
- import { capturePane, captureFinalScreen, DEFAULT_LAYOUT, cancelCopyMode, describePaneChoice, ensureAttached, findUnsafeControlChar, holdsHumanInput, liveTargets, paneCurrentCommand, paneInCopyMode, paneWindow, pollPaneReadiness, rowAlive, rowLive, sendText, sessionName, setRemainOnExit, sleep, TEXT_ALLOWED_CONTROL_CHARS, tmux, TmuxTimeoutError, WINDOW_LAYOUTS, } from "../tmux.js";
19
+ import { capturePane, captureFinalScreen, DEFAULT_LAYOUT, cancelCopyMode, describePaneChoice, ensureAttached, findUnsafeControlChar, foreignSocket, holdsHumanInput, liveTargets, observationFailed, ownershipLiveness, paneCurrentCommand, paneInCopyMode, paneWindow, pollPaneReadiness, rowLive, rowOwnership, sendText, sessionName, setRemainOnExit, sleep, TEXT_ALLOWED_CONTROL_CHARS, tmux, TmuxTimeoutError, WINDOW_LAYOUTS, } from "../tmux.js";
20
20
  import { agentIdParam, agentNameParam, idParam, projectIdParam } from "./params.js";
21
21
  import { deriveProvenance, lastLogEvent, lastPermissionMode, reportsAgentStateLog, } from "../stateProvenance.js";
22
22
  const CLOSED_ROW_ORDER = "(parked_at != '') DESC, closed_at DESC, id DESC";
@@ -129,12 +129,60 @@ function buildParkReceipt(project, agent, parkedAt, branch, note) {
129
129
  ...(note ? { note } : {}),
130
130
  };
131
131
  }
132
+ function closeRowOnly(agent, confirmSelf) {
133
+ const receipt = { row_only: true, agent_id: agent.id, name: agent.name, closed: true };
134
+ if (currentActor().startsWith("agent:")) {
135
+ throw new Error(`Agent ${agent.id} ("${agent.name}"): row_only retirement is reserved for a human at a terminal or a peer ` +
136
+ "lead; a worker may not retire rows this way. Nothing was changed.");
137
+ }
138
+ if (agent.actor_id === currentActor() && !confirmSelf) {
139
+ throw new Error("This would close your own session. Pass confirm_self=true only if the user explicitly asked you to close yourself.");
140
+ }
141
+ if (agent.status !== "running") {
142
+ return {
143
+ ...receipt,
144
+ ...(agent.parked_at ? { parked: true } : {}),
145
+ note: "Already closed; row_only changed nothing and left any park in place.",
146
+ };
147
+ }
148
+ const observes = !foreignSocket(agent.tmux_socket) && agent.tmux_target !== "";
149
+ const snapshot = observes ? liveTargets() : null;
150
+ if (observes && observationFailed(snapshot))
151
+ throw probeFailed(agent);
152
+ const ownership = rowOwnership(agent, snapshot);
153
+ if (ownership === "live") {
154
+ throw new Error(`Agent ${agent.id} ("${agent.name}") owns a live pane (its recorded pane pid matches), so row_only refuses: ` +
155
+ "retiring the row would orphan a running process with nothing left to stop it by. " +
156
+ (agent.kind === LEAD_KIND
157
+ ? "Restart or end the lead from its own terminal."
158
+ : "Use agent_close without row_only to stop it."));
159
+ }
160
+ if (!closeAgentRow(agent.id, { tmux_target: agent.tmux_target, tmux_socket: agent.tmux_socket, pane_pid: agent.pane_pid })) {
161
+ const after = getAgentRow(agent.project_id, agent.id, "Call agent_list(include_closed: true).");
162
+ if (after.status !== "running") {
163
+ return {
164
+ ...receipt,
165
+ ...(after.parked_at ? { parked: true } : {}),
166
+ note: "Already closed by someone else before this call's row-only retirement landed. Nothing else was changed.",
167
+ };
168
+ }
169
+ throw new Error(`Agent ${agent.id} ("${agent.name}")'s pane identity changed since this call read it, most likely a fresh ` +
170
+ "pane recorded on it. Nothing was closed. Re-read it with agent_status and decide again.");
171
+ }
172
+ return {
173
+ ...receipt,
174
+ note: `Row retired with no kill, stop or typing; its pane ownership read ${ownership}.` +
175
+ (ownership === "unknown" ? " Anything still running in that pane was left alone." : ""),
176
+ };
177
+ }
132
178
  export function isLive(agent) {
133
179
  if (agent.status !== "running")
134
180
  return false;
135
181
  return rowLive(agent.tmux_socket, agent.tmux_target);
136
182
  }
137
183
  export const PROBE_FAILED_NOTE = "tmux could not be probed, so liveness is unknown. Nothing was changed. Retry in a few seconds.";
184
+ const unknownOwnershipNote = (snapshot) => observationFailed(snapshot) ? PROBE_FAILED_NOTE : UNVERIFIED_OWNERSHIP_NOTE;
185
+ const UNVERIFIED_OWNERSHIP_NOTE = "This row's pane cannot be verified as its own: it is on another tmux socket, or no pane pid was recorded for it, so liveness is unknown and no screen was read. Nothing was changed. If the row is stale, a human or peer lead can retire it with agent_close(row_only=true).";
138
186
  export const probeFailed = (agent) => new Error(`Agent ${agent.id} ("${agent.name}"): ${PROBE_FAILED_NOTE}`);
139
187
  function requireLive(agent) {
140
188
  if (agent.status !== "running") {
@@ -250,11 +298,7 @@ const PANE_READY_MS = Number(process.env.HIVE_SPAWN_READY_MS ?? 45_000);
250
298
  export function summaryLiveness(row, snapshot) {
251
299
  if (row.status !== "running")
252
300
  return false;
253
- if (snapshot === undefined)
254
- return rowLive(row.tmux_socket, row.tmux_target);
255
- if (snapshot === null)
256
- return null;
257
- return rowAlive(row.tmux_socket, row.tmux_target, snapshot);
301
+ return ownershipLiveness(rowOwnership(row, snapshot));
258
302
  }
259
303
  function capturePaneQuietly(target) {
260
304
  try {
@@ -1031,11 +1075,12 @@ export function registerAgents(server) {
1031
1075
  }, (args) => run(() => {
1032
1076
  const project = resolveProject(args.project_id);
1033
1077
  const agent = findAgent(project.id, args);
1034
- const summary = agentSummary(agent);
1078
+ const snapshot = agent.status === "running" ? liveTargets() : null;
1079
+ const summary = agentSummary(agent, snapshot);
1035
1080
  const briefPath = agentBriefPath(agent.id);
1036
1081
  return {
1037
1082
  ...summary,
1038
- ...(summary.alive === null ? { note: PROBE_FAILED_NOTE } : {}),
1083
+ ...(summary.alive === null ? { note: unknownOwnershipNote(snapshot) } : {}),
1039
1084
  closed_at: agent.closed_at,
1040
1085
  current_command: summary.alive ? paneCurrentCommand(agent.tmux_target) : null,
1041
1086
  brief_path: existsSync(briefPath) ? briefPath : null,
@@ -1285,7 +1330,8 @@ export function registerAgents(server) {
1285
1330
  const project = resolveProject(args.project_id);
1286
1331
  const agent = findAgent(project.id, args);
1287
1332
  const lines = Math.min(args.lines ?? 50, 200);
1288
- const alive = isLive(agent);
1333
+ const snapshot = agent.status === "running" ? liveTargets() : null;
1334
+ const alive = summaryLiveness(agent, snapshot);
1289
1335
  return {
1290
1336
  agent_id: agent.id,
1291
1337
  name: agent.name,
@@ -1296,14 +1342,14 @@ export function registerAgents(server) {
1296
1342
  ? {}
1297
1343
  : {
1298
1344
  note: alive === null
1299
- ? PROBE_FAILED_NOTE
1300
- : "No live tmux window; output is not retained after exit.",
1345
+ ? unknownOwnershipNote(snapshot)
1346
+ : "No live tmux window this row owns; output is not retained after exit.",
1301
1347
  }),
1302
1348
  ...(alive !== true && agent.exit_tail ? { exit_tail: agent.exit_tail } : {}),
1303
1349
  };
1304
1350
  }));
1305
1351
  server.registerTool("agent_close", {
1306
- description: "Kill an agent's tmux window and mark it closed, addressed by name (or agent_id). Capture handoffs (todo comments, pads) BEFORE closing; terminal output is not retained. Closing yourself requires confirm_self=true. Refuses a lead target whose pane is live; retires one whose pane is confirmed dead. A worker may never close a lead, live or dead.",
1352
+ description: "Kill an agent's tmux window and mark it closed, addressed by name (or agent_id). Capture handoffs (todo comments, pads) BEFORE closing; terminal output is not retained. Closing yourself requires confirm_self=true. Refuses a lead target whose pane is live; retires one whose pane is confirmed dead. A worker may never close a lead, live or dead. row_only=true retires a stale row with no kill, stop or typing: for a human or peer lead only, and refused for any row that provably owns a live pane or when tmux could not be probed.",
1307
1353
  annotations: {
1308
1354
  readOnlyHint: false,
1309
1355
  destructiveHint: true,
@@ -1314,12 +1360,17 @@ export function registerAgents(server) {
1314
1360
  name: agentNameParam,
1315
1361
  agent_id: agentIdParam,
1316
1362
  confirm_self: z.boolean().optional(),
1363
+ row_only: z
1364
+ .boolean()
1365
+ .optional()
1366
+ .describe("Mark the row closed with no kill, stop or typing, for a row whose pane is gone, reissued or unverifiable. Human or peer lead only. Defaults to false."),
1317
1367
  project_id: projectIdParam,
1318
1368
  },
1319
1369
  outputSchema: {
1320
1370
  agent_id: idParam,
1321
1371
  name: z.string(),
1322
1372
  closed: z.boolean(),
1373
+ row_only: z.boolean().optional(),
1323
1374
  park_released: z.boolean().optional(),
1324
1375
  parked: z.boolean().optional(),
1325
1376
  note: z.string().optional(),
@@ -1327,6 +1378,8 @@ export function registerAgents(server) {
1327
1378
  }, (args) => run(() => {
1328
1379
  const project = resolveProject(args.project_id);
1329
1380
  const agent = findAgent(project.id, args);
1381
+ if (args.row_only === true)
1382
+ return closeRowOnly(agent, args.confirm_self === true);
1330
1383
  if (agent.kind === LEAD_KIND && currentActor().startsWith("agent:")) {
1331
1384
  throw new Error(`Agent ${agent.id} ("${agent.name}") is this project's lead session. Retiring a lead - live, ` +
1332
1385
  "confirmed dead, or unprobed - is reserved for a human at a terminal or a peer lead; a " +
@@ -139,7 +139,7 @@ export function registerMeta(server) {
139
139
  inputSchema: { project_id: idParam },
140
140
  }, ({ project_id }) => run(() => selectProjectById(project_id)));
141
141
  server.registerTool("project_prune", {
142
- description: "Delete every registered project that owns no rows anywhere in the store (pads, todos, kv, leases, agents, wakes, command_trust), verified individually before each delete. Never prunes the caller's own project. With project_id, removes exactly that one project instead of sweeping, and only if it owns no rows; otherwise refuses and names what it owns. With project_id AND confirm_name (the project's exact name), removes that one project even when it owns rows: it takes a snapshot first, refuses while the project has a running agent, then deletes the project with everything it owns (todos, comments, pads, kv, leases, agents, wakes, trust, agent messages) in one transaction and reports per-table counts and the snapshot path. queen_audit rows naming it are kept, and so are other projects' lead-idle subscriptions that target it, so a watch on its lead ends with a named reason. confirm_name without project_id is refused, and there is no forced sweep. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep, and a project-locked session may only touch its own project. Immediate, permanent: no dry-run mode.",
142
+ description: "Delete every registered project that owns no rows anywhere in the store (pads, todos, kv, leases, agents, wakes, command_trust), verified individually before each delete. Never prunes the caller's own project. With project_id, removes exactly that one project instead of sweeping, and only if it owns no rows; otherwise refuses and names what it owns. With project_id AND confirm_name (the project's exact name), removes that one project even when it owns rows: it takes a snapshot first, refuses while a running row owns a live pane or its pane ownership cannot be verified (agent_close row_only retires the latter), then deletes the project with everything it owns (todos, comments, pads, kv, leases, agents, wakes, trust, agent messages) in one transaction and reports per-table counts and the snapshot path. queen_audit rows naming it are kept, and so are other projects' lead-idle subscriptions that target it, so a watch on its lead ends with a named reason. confirm_name without project_id is refused, and there is no forced sweep. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep, and a project-locked session may only touch its own project. Immediate, permanent: no dry-run mode.",
143
143
  annotations: {
144
144
  readOnlyHint: false,
145
145
  destructiveHint: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmgmyr/hive",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
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": {