@cmgmyr/hive 1.5.0 → 1.7.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.
@@ -47,13 +47,21 @@ Archive it. A pad still describing in-flight state for work that finished is wor
47
47
 
48
48
  A watch armed for a crew that is gone is noise. A dated reminder someone set on purpose is not - cancelling one silently loses a commitment nobody will notice missing until the date passes. If you cannot tell which it is, leave it and say so.
49
49
 
50
+ ## Project copies of the shared profile: report, do not rewrite
51
+
52
+ At wrap-up, run `hive profile list` and use the `*` to identify the active profile and every file it carries, including extra Markdown files. Compare its rendered output from `hive posture`, `hive runbook`, and `hive profile read <file>` for every listed file with five kinds of project artifacts: pad headers, lines in the board pad, active todo bodies, the lead's project memory, and `CLAUDE.md`, `AGENTS.md`, or files under `docs/`. Read pad headers rather than whole lessons pads, and inspect todos without their comments. Read profile sources only when this pass runs: redirect `hive posture`, `hive runbook`, and each `hive profile read <file>` to files, then inspect them in bounded sections. For example, use `hive runbook > <file>` and read it by range. For every match, give the artifact and location, the exact profile source and line, its class, and a proposed pointer such as `see <profile source>, <section or line>`.
53
+
54
+ List contradictions first, then restatements, then likely deliberate copies. A contradiction gives an outdated project-specific value as current guidance when the rendered profile now says something different or no longer says it. For example, a project memory that says commits use `--no-gpg-sign` contradicts a rendered rule requiring signed commits. A restatement repeats a profile rule without adding a project-specific choice; if replacing it with a pointer loses no useful detail, propose the pointer. A deliberate override has a project-specific delta, such as a path, a measured value, or a dated human decision; keep that detail and report it as an override. When a copy says it was kept because readers could not find the on-demand source, report it as a likely deliberate copy and ask whether it is still needed. Never infer that an outdated value is an override just because it is project-specific.
55
+
56
+ This pass only reports and proposes. The lead may apply pointer changes to pads and board lines. Any memory change or deletion needs the human's OK. Do not edit, delete, archive, or send anything as part of this pass. Run it at wrap-up, not at every lane end.
57
+
50
58
  ## Residue you report and do not remove
51
59
 
52
60
  Worktrees, branches, running agents. Removing a worktree can destroy uncommitted work, and a running agent may be mid-turn. Name what you found and let the human or the lead decide.
53
61
 
54
62
  ## Finish by saying what you left, not just what you changed
55
63
 
56
- A cleanup that reports only its edits cannot be audited, and the interesting part is usually what was deliberately not touched. Leave the board in the shape the next session needs: where the code is now, whatever verification number this project quotes, what is genuinely in flight (often nothing), and what is waiting on a human.
64
+ A cleanup that reports only its edits cannot be audited, and the interesting part is usually what was deliberately not touched. Leave the board in the shape the next session needs: where the code is now, whatever verification number this project quotes, what is genuinely in flight (often nothing), what is waiting on a human, and the profile-drift report with its proposed pointers.
57
65
 
58
66
  ## Where to route evidence in this project
59
67
 
@@ -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,9 +90,16 @@ 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.
94
+
95
+ ## After a profile edit lands, ask once about notifying leads
96
+
97
+ Do this once after the full profile edit has been reviewed and committed, not after each commit or review round. Find the changed files and the commit range for that edit, and record when both review and commit are complete; that is when the edit landed. Use `project_list` to enumerate registered projects and their paths. In each project, run `hive profile list`; its `*` marks the active profile. Keep projects whose active profile matches the one you edited.
98
+
99
+ For each matching project, call `agent_list` with `{project_id: <project id>}`. A recipient must have `kind: "lead"`, `status: "running"`, and `alive: true`, which means the row owns a live pane. Inspect its `command` for `--append-system-prompt-file <path>`. When the named file exists, compare its modification time with the edit's landed time: notify if the file is older, and exclude it if it is equal or newer. `hive lead` rewrites that file when it launches, so its modification time reflects the current session's posture render. If the command has no such flag or the file is missing, include the lead and state the reason, such as `no posture file flag` or `posture file missing`. Do not include rows with `alive: false` or `alive: null`. Ask once and show the project names, lead names, and any reason a posture age could not be checked: `N running leads use this profile: <projects and reasons>. Notify them?` If no leads qualify, report that and stop.
100
+
101
+ On no, send nothing. On yes, call `wake_set` once per qualifying lead with `delay_seconds: 1`, `project_id: <that project's integer id>`, and `deliver_to: <lead's integer agent_id>`. Do not pass an actor id such as `lead:663` as `deliver_to`. Each wake body names the changed files and commit range, then tells the lead to re-read with `hive posture`, `hive runbook`, and `hive profile read <file>`, or restart through `hive lead` to reload the posture. Do not send a wake before the human says yes.
74
102
 
75
103
  ## What this skill does not do
76
104
 
77
105
  It does not dispatch project work, change which agent application or model a project uses beyond what was explicitly asked, install a third-party tool, or commit or publish anything. It edits `hive.yml`'s `profile`, `agents`, `vars`, `context_checkpoint_percent`, and `lead_turn_budget` keys only on an explicit ask, and only to values the user gave or confirmed.
78
-
@@ -1 +1 @@
1
- {"version":"1.5.0","sha":"c050e7f","dirty":false,"build_id":"56840ccc-881f-49ae-bbc4-440224077976"}
1
+ {"version":"1.7.0","sha":"2000149","dirty":false,"build_id":"c539d81be530294a451590d781bd6922cd7155087a70b8fc64dedc35cf94f3e2"}
package/dist/cli.js CHANGED
@@ -22,7 +22,7 @@ import { confirmQueenWrite, listQueenAudit } from "./queenAudit.js";
22
22
  import { formatRowCounts, projectRowCounts, removeProject } from "./projectRemove.js";
23
23
  import { ensureHooksFile } from "./hooks.js";
24
24
  import { errorMessage, registrationNoticeText, withTrailingNewline } from "./result.js";
25
- import { ACTIVE_TIMER_WHERE, dashboardFileContained, describeStall, HELD_REASON_CONVERSATION, HELD_REASON_LEAD_PANE_DEAD, HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX, isUnclassifiablePaneHold, HELD_REASON_UNSUBMITTED_INPUT_PREFIX, isUnsubmittedInputHold, janitor, resolveDashboardFile, generateQueenDashboardNow, transcriptStaleness, wasHeldForPaneReissue, } from "./scheduler.js";
25
+ import { ACTIVE_TIMER_WHERE, dashboardFileContained, describeStall, HELD_REASON_CONVERSATION, HELD_REASON_LEAD_PANE_DEAD, HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX, isLeadRowClosedHold, isUnclassifiablePaneHold, HELD_REASON_UNSUBMITTED_INPUT_PREFIX, isUnsubmittedInputHold, janitor, resolveDashboardFile, generateQueenDashboardNow, transcriptStaleness, wasHeldForPaneIdentity, } from "./scheduler.js";
26
26
  import { readTurnCount } from "./turnCount.js";
27
27
  import { collectPortfolio, leadText } from "./portfolio.js";
28
28
  import { STALL_BOUND_SECONDS } from "./backgroundTasks.js";
@@ -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, 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";
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";
@@ -322,17 +322,20 @@ function ensureLeadRow(project, command) {
322
322
  previousCommand: existing.command,
323
323
  previousCodexHome: existing.codex_home,
324
324
  casExpected: existing.tmux_target,
325
+ casSocket: existing.tmux_socket,
326
+ casPid: existing.pane_pid,
325
327
  };
326
328
  }
327
329
  const priorClosed = db
328
330
  .prepare("SELECT actor_id, tmux_target, tmux_socket, pane_pid, command, codex_home FROM agents WHERE project_id = ? AND kind = ? AND status = 'closed' AND actor_id != '' ORDER BY id DESC LIMIT 1")
329
331
  .get(project.id, LEAD_KIND);
332
+ const insertedSocket = tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR);
330
333
  let result;
331
334
  try {
332
335
  result = db.transaction(() => {
333
336
  const info = db
334
337
  .prepare("INSERT INTO agents (project_id, name, command, cwd, kind, parent_actor_id, tmux_target, tmux_socket, pane_pid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
335
- .run(project.id, LEAD_NAME, command, project.path, LEAD_KIND, currentActor(), "", tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR), "");
338
+ .run(project.id, LEAD_NAME, command, project.path, LEAD_KIND, currentActor(), "", insertedSocket, "");
336
339
  const agentId = Number(info.lastInsertRowid);
337
340
  const actorId = priorClosed?.actor_id ?? mintLeadActorId(agentId);
338
341
  db.prepare("UPDATE agents SET actor_id = ? WHERE id = ?").run(actorId, agentId);
@@ -352,6 +355,8 @@ function ensureLeadRow(project, command) {
352
355
  previousCommand: priorClosed?.command ?? "",
353
356
  previousCodexHome: priorClosed?.codex_home ?? "",
354
357
  casExpected: "",
358
+ casSocket: insertedSocket,
359
+ casPid: "",
355
360
  };
356
361
  }
357
362
  function reportMigrationResult(result) {
@@ -375,6 +380,8 @@ async function cmdLead(argv) {
375
380
  registrationNotice = text;
376
381
  });
377
382
  const auditIdentity = captureQueenWriteIdentity(project.id);
383
+ // Only rows running before this invocation began are leftovers, trust prompts included; a later `hive start` is not.
384
+ const leftoverIds = new Set(runningCommandRows(project.id).map((row) => row.id));
378
385
  const { config, warnings, sources } = loadProjectYml(project.path);
379
386
  if (detach && (!process.stdin.isTTY || !process.stdout.isTTY)) {
380
387
  let untrusted;
@@ -453,7 +460,8 @@ async function cmdLead(argv) {
453
460
  }
454
461
  }
455
462
  const windowName = project.name;
456
- const { agentId: leadAgentId, actorId: leadActorId, previousTarget, previousSocket, previousPanePid, previousCommand, previousCodexHome, casExpected, } = ensureLeadRow(project, leadCommand);
463
+ const { agentId: leadAgentId, actorId: leadActorId, previousTarget, previousSocket, previousPanePid, previousCommand, previousCodexHome, casExpected, casSocket, casPid, } = ensureLeadRow(project, leadCommand);
464
+ const previousIdentity = { tmux_target: previousTarget, tmux_socket: previousSocket, pane_pid: previousPanePid };
457
465
  // A fresh CODEX_HOME is built on every invocation, same as claude's hooks/posture files above -
458
466
  // cheap (a few KB; the codex-side plugins/cache bootstrap only materializes once a process
459
467
  // actually starts under it) and correct regardless of whether this invocation ends up creating
@@ -524,10 +532,8 @@ async function cmdLead(argv) {
524
532
  }
525
533
  else {
526
534
  const foundWindow = findProjectWindow(session, project.id);
527
- const probe = rowLiveProbe(previousSocket, previousTarget);
528
- const adopted = isPaneTarget(previousTarget) && probe.live === true && !paneReissued(previousPanePid, probe)
529
- ? adoptableWindow(session, project.id, previousTarget)
530
- : null;
535
+ // Only a pane the row provably owns is adopted; unknown or reissued gets a fresh pane, never the stranger's pid.
536
+ const adopted = rowOwnership(previousIdentity) === "live" ? adoptableWindow(session, project.id, previousTarget) : null;
531
537
  if (adopted) {
532
538
  leadPane = previousTarget;
533
539
  leadWindow = adopted;
@@ -574,12 +580,24 @@ async function cmdLead(argv) {
574
580
  if (newCodexHomeKey)
575
581
  reapCodexHome(newCodexHomeKey);
576
582
  };
583
+ if (!createdPane && rowOwnership(previousIdentity) !== "live") {
584
+ abandonClaim();
585
+ throw new Error("The lead pane this `hive lead` was about to adopt no longer reads as the lead's own (its pane pid changed " +
586
+ "or could not be verified), so nothing was recorded. Re-run `hive lead`; it will start a fresh pane.");
587
+ }
588
+ const recordedPanePid = createdPane ? panePidForRecord(leadPane) : previousPanePid;
589
+ if (recordedPanePid === "") {
590
+ abandonClaim();
591
+ throw new Error(`tmux did not report a process id for the new lead pane ${leadPane} (asked twice), so it was discarded ` +
592
+ "rather than recorded: a lead row with no pane pid can never be verified as owning its pane. Re-run `hive lead`.");
593
+ }
577
594
  let wonRace;
578
595
  try {
579
596
  wonRace = db.transaction(() => {
580
597
  const updated = db
581
- .prepare("UPDATE agents SET tmux_target = ?, tmux_socket = ?, pane_pid = ?, command = ?, codex_home = ? WHERE id = ? AND tmux_target = ? AND status = 'running'")
582
- .run(leadPane, tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR), panePid(leadPane), recordedCommand, recordedCodexHome, leadAgentId, casExpected).changes;
598
+ .prepare("UPDATE agents SET tmux_target = ?, tmux_socket = ?, pane_pid = ?, command = ?, codex_home = ? " +
599
+ "WHERE id = ? AND tmux_target = ? AND tmux_socket = ? AND pane_pid = ? AND status = 'running'")
600
+ .run(leadPane, tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR), recordedPanePid, recordedCommand, recordedCodexHome, leadAgentId, casExpected, casSocket, casPid).changes;
583
601
  if (updated === 0)
584
602
  return false;
585
603
  db.prepare(`UPDATE wakes SET deliver_pane = ?, held_at = NULL, held_reason = NULL
@@ -610,7 +628,7 @@ async function cmdLead(argv) {
610
628
  }
611
629
  if (createdPane) {
612
630
  armLeadPaneExitedHook();
613
- clearLeftoverProcesses(project, config);
631
+ clearLeftoverProcesses(project, config, leftoverIds);
614
632
  }
615
633
  else if (!leadPaneExitedHookArmed()) {
616
634
  console.log(`! this tmux server carries no ${LEAD_PANE_EXITED_HOOK} backstop and hive arms one only on a pane it created, ` +
@@ -1111,6 +1129,16 @@ function profileDriftText(f) {
1111
1129
  text: `hive's default ${f.file} changed since you forked it${pct != null ? ` (${pct}% diverged)` : ""}`,
1112
1130
  };
1113
1131
  }
1132
+ const PROFILE_SIZE_WARNING_BYTES = 25600;
1133
+ function profileRenderedBytes(name, file, vars) {
1134
+ const rendered = renderProfileFile(name, file, vars);
1135
+ return rendered == null ? null : Buffer.byteLength(withTrailingNewline(rendered), "utf8");
1136
+ }
1137
+ function profileSizeAdvisory(name, file) {
1138
+ return (`${name}/${file}: rendered size exceeds ${PROFILE_SIZE_WARNING_BYTES} bytes. A harness may persist tool output ` +
1139
+ "over about 30KB and show only a short preview. Split into smaller profile files read with hive profile read, " +
1140
+ "or redirect hive profile read to a file and read by section.");
1141
+ }
1114
1142
  function cmdProfile(argv) {
1115
1143
  const [sub, ...rest] = argv;
1116
1144
  try {
@@ -1125,7 +1153,9 @@ function cmdProfile(argv) {
1125
1153
  return;
1126
1154
  }
1127
1155
  const here = findProjectForCwd();
1128
- const current = here ? activeProfile(loadProjectYml(here.path).config) : null;
1156
+ const hereConfig = here ? loadProjectYml(here.path).config : null;
1157
+ const current = here ? activeProfile(hereConfig) : null;
1158
+ const listVars = mergedProjectVars(hereConfig);
1129
1159
  const statuses = names.map((profile) => profileStatus(profile));
1130
1160
  const fileWidth = Math.max(11, ...statuses.flatMap((s) => s.files.map((f) => f.file.length)));
1131
1161
  for (const [i, profile] of names.entries()) {
@@ -1133,7 +1163,11 @@ function cmdProfile(argv) {
1133
1163
  console.log(`${profile === current ? "*" : " "} ${profile}`);
1134
1164
  for (const f of status.files) {
1135
1165
  const drift = profileDriftText(f);
1136
- console.log(` ${f.file.padEnd(fileWidth)} ${f.source.padEnd(7)} ${f.path}${drift ? ` (${drift.text})` : ""}`);
1166
+ const bytes = profileRenderedBytes(profile, f.file, listVars);
1167
+ const size = bytes == null ? "[rendered size unavailable]" : `[${bytes} rendered bytes]`;
1168
+ console.log(` ${f.file.padEnd(fileWidth)} ${f.source.padEnd(7)} ${f.path}${drift ? ` (${drift.text})` : ""} ${size}`);
1169
+ if (bytes != null && bytes > PROFILE_SIZE_WARNING_BYTES)
1170
+ console.log(` ${profileSizeAdvisory(profile, f.file)}`);
1137
1171
  }
1138
1172
  }
1139
1173
  if (current)
@@ -1277,7 +1311,7 @@ function openDashboardOnce(project, dashboardFile) {
1277
1311
  if (claim.changes === 0)
1278
1312
  return;
1279
1313
  try {
1280
- execFileSync("open", [pathToFileURL(dashboardFile).href], { stdio: "ignore", timeout: 5000 });
1314
+ execFileSync("open", [pathToFileURL(dashboardFile).href], { stdio: "ignore", timeout: 10000 });
1281
1315
  }
1282
1316
  catch {
1283
1317
  db.prepare("DELETE FROM kv WHERE project_id = ? AND key = ?").run(project.id, DASHBOARD_OPENED_KV_KEY);
@@ -1361,14 +1395,18 @@ function resolveNamedProcess(argv, verb) {
1361
1395
  console.log(`${name}: not running (start with: hive start "${name}")`);
1362
1396
  return null;
1363
1397
  }
1364
- const probe = rowLiveProbe(row.tmux_socket, row.tmux_target);
1365
- if (probe.live === null) {
1398
+ const ownership = observeRowOwnership(row);
1399
+ if (ownership === "probe-failed") {
1366
1400
  console.log(CANNOT_TELL_WHERE(name));
1367
1401
  return null;
1368
1402
  }
1403
+ if (ownership === "unknown") {
1404
+ console.log(`${name}: its pane ownership is unknown (no pane pid recorded, or another tmux socket), so hive will not move that pane`);
1405
+ return null;
1406
+ }
1369
1407
  // A pane id alone is not an identity: tmux restarts them at %0 on a fresh server, so a stale
1370
- // running row names whoever holds that id now. Same compare as janitor() and wake delivery.
1371
- if (!probe.live || paneReissued(row.pane_pid, probe)) {
1408
+ // running row names whoever holds that id now.
1409
+ if (ownership !== "live") {
1372
1410
  console.log(`${name}: not running (start with: hive start "${name}")`);
1373
1411
  return null;
1374
1412
  }
@@ -1450,8 +1488,10 @@ function cmdLeadPaneExited(argv) {
1450
1488
  }
1451
1489
  // Only a lead that CREATED its pane clears leftovers. An adopted live lead is the same lead those
1452
1490
  // processes belong to, and stopping them there would take down a running crew's dev server.
1453
- function clearLeftoverProcesses(project, config) {
1491
+ function clearLeftoverProcesses(project, config, leftoverIds) {
1454
1492
  for (const row of runningCommandRows(project.id)) {
1493
+ if (!leftoverIds.has(row.id))
1494
+ continue;
1455
1495
  const stopped = stopProcess(row, STOP_REASONS.previousLead);
1456
1496
  // Nothing was running under it, so the auto-start line that follows is the whole story.
1457
1497
  if (stopped.leg === "already-gone")
@@ -1550,9 +1590,17 @@ function cmdHide(argv) {
1550
1590
  return `${name}: hidden`;
1551
1591
  }));
1552
1592
  }
1593
+ const LEAD_OWNERSHIP_LABEL = {
1594
+ live: "running",
1595
+ gone: "no live pane",
1596
+ reissued: "no live pane",
1597
+ unknown: "pane identity unknown",
1598
+ };
1553
1599
  function cmdStatus() {
1554
1600
  janitor();
1555
1601
  let anyOutput = false;
1602
+ let snapshot;
1603
+ const statusSnapshot = () => (snapshot === undefined ? (snapshot = liveTargets()) : snapshot);
1556
1604
  let windows;
1557
1605
  let windowsError;
1558
1606
  const ownedWindows = () => {
@@ -1598,7 +1646,9 @@ function cmdStatus() {
1598
1646
  for (const a of agents) {
1599
1647
  const state = a.kind === "agent"
1600
1648
  ? describeForHuman(deriveProvenance(a, null))
1601
- : (whereIs.get(a.name) ?? "running");
1649
+ : a.kind === LEAD_KIND
1650
+ ? LEAD_OWNERSHIP_LABEL[rowOwnership(a, statusSnapshot())]
1651
+ : (whereIs.get(a.name) ?? "running");
1602
1652
  const label = a.kind === "command" ? "cmd " : a.kind === LEAD_KIND ? "lead " : "agent";
1603
1653
  console.log(` ${label} ${a.name.padEnd(20)} ${state}`);
1604
1654
  if (reportsAgentStateLog(a)) {
@@ -2153,7 +2203,7 @@ function reportCrewTeardown(projectId) {
2153
2203
  }
2154
2204
  function reportStalledWorkers(projectId) {
2155
2205
  const latched = db
2156
- .prepare(`SELECT name, command, kind, cwd, session_id, transcript_path, agent_state, state_changed_at, tmux_target, tmux_socket
2206
+ .prepare(`SELECT name, command, kind, cwd, session_id, transcript_path, agent_state, state_changed_at, tmux_target, tmux_socket, pane_pid
2157
2207
  FROM agents
2158
2208
  WHERE project_id = ? AND status = 'running' AND kind = 'agent'
2159
2209
  AND agent_state IN ('working', 'waiting') AND state_changed_at IS NOT NULL
@@ -2164,6 +2214,7 @@ function reportStalledWorkers(projectId) {
2164
2214
  // never this latch"), and a harness with no transcript to check would have nothing to read.
2165
2215
  .filter((row) => reportsAgentStateLog(row) && hasTranscriptSignal(row) && row.session_id !== "");
2166
2216
  const stalled = [];
2217
+ let snapshot;
2167
2218
  for (const row of latched) {
2168
2219
  const latchedFor = ageSecondsSince(row.state_changed_at);
2169
2220
  if (latchedFor < STALL_BOUND_SECONDS)
@@ -2180,7 +2231,9 @@ function reportStalledWorkers(projectId) {
2180
2231
  if (stale !== "never" && stale.seconds < STALL_BOUND_SECONDS)
2181
2232
  continue;
2182
2233
  if (row.agent_state === "waiting") {
2183
- if (foreignSocket(row.tmux_socket))
2234
+ if (snapshot === undefined)
2235
+ snapshot = liveTargets();
2236
+ if (rowOwnership(row, snapshot) !== "live")
2184
2237
  continue;
2185
2238
  const choiceCheck = paneClassifierFor(row.command)?.choiceCheck;
2186
2239
  if (!choiceCheck || choiceCheck(row.tmux_target).awaitingChoice !== false)
@@ -2359,6 +2412,12 @@ function cmdDoctor(argv) {
2359
2412
  warn("profile vars", `hive.yml vars ${collisions.join(", ")} are derived from agents: and are ignored.`);
2360
2413
  }
2361
2414
  const vars = mergedProjectVars(cfg);
2415
+ for (const f of resolved) {
2416
+ const bytes = profileRenderedBytes(name, f.file, vars);
2417
+ info("profile size", bytes == null ? `${name}/${f.file}: rendered size unavailable` : `${name}/${f.file}: ${bytes} rendered bytes`);
2418
+ if (bytes != null && bytes > PROFILE_SIZE_WARNING_BYTES)
2419
+ info("profile size warning", profileSizeAdvisory(name, f.file));
2420
+ }
2362
2421
  const renderedText = profileFileNames(name)
2363
2422
  .map((file) => renderProfileFile(name, file, vars))
2364
2423
  .filter((t) => t != null)
@@ -2408,21 +2467,36 @@ function cmdDoctor(argv) {
2408
2467
  });
2409
2468
  if (here) {
2410
2469
  const lead = db
2411
- .prepare("SELECT tmux_target, tmux_socket FROM agents WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id")
2470
+ .prepare("SELECT id, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id")
2412
2471
  .get(here.id, LEAD_KIND);
2413
2472
  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 " +
2473
+ const snapshot = liveTargets();
2474
+ const ownership = rowOwnership(lead, snapshot);
2475
+ const unobserved = !foreignSocket(lead.tmux_socket) && observationFailed(snapshot);
2476
+ if (ownership === "gone" || ownership === "reissued") {
2477
+ warn("lead", (ownership === "reissued"
2478
+ ? "the lead's row is running but its pane id now belongs to a different process. "
2479
+ : "the lead's row is running but its pane is not live. ") +
2480
+ "The janitor leaves lead rows alone on " +
2417
2481
  "purpose, so nothing will fix this by itself. Run `hive lead` to record a fresh pane and reuse " +
2418
2482
  "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 " +
2483
+ (ownership === "reissued"
2484
+ ? `agent_close({agent_id: ${lead.id}, row_only: true}) on it (plain agent_close refuses a pane id ` +
2485
+ "that still exists)"
2486
+ : "the agent_close tool on it") +
2487
+ " to retire the row for good - required before `hive restore`, which " +
2420
2488
  "otherwise refuses while any lead row reads running, and end that session too, since it also " +
2421
2489
  "holds this store open.");
2422
2490
  }
2423
- else if (live === null) {
2491
+ else if (ownership === "unknown" && unobserved) {
2424
2492
  warn("lead", "the lead's pane liveness could not be probed (tmux did not answer).");
2425
2493
  }
2494
+ else if (ownership === "unknown") {
2495
+ warn("lead", `pane identity unknown: the lead's row (agents.id ${lead.id}) cannot be verified as owning pane ` +
2496
+ `${lead.tmux_target || "(none)"}, because it is on another tmux socket or no pane pid was recorded ` +
2497
+ "for it. Its screen was not read. If this lead is gone, a human or peer lead (never a worker) can " +
2498
+ `retire the row with the MCP tool agent_close({agent_id: ${lead.id}, row_only: true}).`);
2499
+ }
2426
2500
  }
2427
2501
  const stuck = db
2428
2502
  .prepare("SELECT name, kind, tmux_socket FROM agents WHERE project_id = ? AND status = 'running' AND kind != ?")
@@ -2441,19 +2515,21 @@ function cmdDoctor(argv) {
2441
2515
  }
2442
2516
  if (here) {
2443
2517
  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")
2518
+ .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
2519
  .all(here.id);
2520
+ const paneSnapshot = workers.length > 0 ? liveTargets() : null;
2446
2521
  let inputBoxClean = 0;
2447
2522
  let inputBoxDrifted = 0;
2448
2523
  let inputBoxUnclassified = 0;
2449
2524
  let leadsProbed = 0;
2450
2525
  let workersProbed = 0;
2451
2526
  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")
2527
+ .prepare("SELECT name, command, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id")
2453
2528
  .all(here.id, LEAD_KIND);
2529
+ const leadSnapshot = leadRows.length > 0 ? (paneSnapshot ?? liveTargets()) : null;
2454
2530
  for (const leadBox of leadRows) {
2455
2531
  const leadHarness = harnessFor(leadBox.command);
2456
- if (!leadHarness.classifiesPaneScreen || foreignSocket(leadBox.tmux_socket))
2532
+ if (!leadHarness.classifiesPaneScreen || rowOwnership(leadBox, leadSnapshot) !== "live")
2457
2533
  continue;
2458
2534
  leadsProbed += 1;
2459
2535
  const box = leadHarness.paneClassifier.inputBoxState(leadBox.tmux_target);
@@ -2478,20 +2554,26 @@ function cmdDoctor(argv) {
2478
2554
  workersReported += 1;
2479
2555
  // Dispatched through w's OWN harness, never the claude-only import - reading a codex pane
2480
2556
  // with claude's dialog regexes fails silently in either direction (.claude/rules/tmux-and-panes.md).
2557
+ const ownership = rowOwnership(w, paneSnapshot);
2481
2558
  const foreign = foreignSocket(w.tmux_socket);
2559
+ const unowned = ownership !== "live";
2482
2560
  const workerChoiceCheck = paneClassifierFor(w.command)?.choiceCheck;
2483
- const { awaitingChoice, tail } = foreign || !workerChoiceCheck ? { awaitingChoice: null, tail: "" } : workerChoiceCheck(w.tmux_target);
2561
+ const { awaitingChoice, tail } = unowned || !workerChoiceCheck ? { awaitingChoice: null, tail: "" } : workerChoiceCheck(w.tmux_target);
2484
2562
  if (awaitingChoice === true)
2485
2563
  dialogCount += 1;
2486
2564
  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
2565
  ? `pane: recorded on a different tmux socket (${w.tmux_socket}); this process cannot read it`
2488
- : `pane: ${describePaneChoice(awaitingChoice)}`, ...(foreign
2566
+ : unowned
2567
+ ? `pane: not read - pane ownership ${ownership}`
2568
+ : `pane: ${describePaneChoice(awaitingChoice)}`, ...(foreign
2489
2569
  ? ["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}`)]));
2570
+ : unowned
2571
+ ? [`tail: (not read - pane ownership ${ownership})`]
2572
+ : awaitingChoice === null
2573
+ ? ["tail: (pane could not be read)"]
2574
+ : tail === ""
2575
+ ? ["tail: (pane rendered nothing)"]
2576
+ : ["tail:", ...tail.split("\n").map((line) => `| ${line}`)]));
2495
2577
  }
2496
2578
  // Gated on classifiesPaneScreen, not reportsAgentStateLog: this drift check only needs the pane,
2497
2579
  // and a codex worker's pane has been readable since todo 524, independent of whether its
@@ -2499,7 +2581,7 @@ function cmdDoctor(argv) {
2499
2581
  // own harness, never the claude-only import above, or a codex worker's screen gets read with
2500
2582
  // claude's regexes (see hive-internals).
2501
2583
  const harness = harnessFor(w.command);
2502
- if (!harness.classifiesPaneScreen || foreignSocket(w.tmux_socket))
2584
+ if (!harness.classifiesPaneScreen || rowOwnership(w, paneSnapshot) !== "live")
2503
2585
  continue;
2504
2586
  workersProbed += 1;
2505
2587
  const box = harness.paneClassifier.inputBoxState(w.tmux_target);
@@ -2735,7 +2817,8 @@ export function heldReasonLabel(heldReason) {
2735
2817
  if (isUnsubmittedInputHold(heldReason))
2736
2818
  return "typing";
2737
2819
  if (heldReason === HELD_REASON_LEAD_PANE_DEAD ||
2738
- wasHeldForPaneReissue(heldReason) ||
2820
+ wasHeldForPaneIdentity(heldReason) ||
2821
+ isLeadRowClosedHold(heldReason) ||
2739
2822
  isUnclassifiablePaneHold(heldReason)) {
2740
2823
  return "needs you";
2741
2824
  }
@@ -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,14 @@ 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.
182
+ Every kill or type above acts only on a pane whose recorded pid matches.
183
+ Unverifiable ownership (no recorded pid) refuses by name; a gone or
184
+ reissued pane is never touched, and close, park and rename then act
185
+ on the row alone.
179
186
 
180
187
  Address a worker by its name, not its id: agent_send(name="impl", ...). A
181
188
  partial name works when it matches one running worker, so name="123" finds
@@ -257,8 +264,9 @@ Every pad, todo, kv entry, and lease belongs to one project (a directory).
257
264
  under HIVE_PROJECT_LOCK=1 since it sweeps every project, not just yours.
258
265
  With project_id, removes only that project, and only if it owns no rows.
259
266
  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].
267
+ row it owns after a snapshot; refuses while a running row owns a live
268
+ pane or cannot be verified (agent_close row_only retires the latter).
269
+ From a terminal: hive project rm <id|path> [--yes].
262
270
 
263
271
  Resolution order: explicit project_id argument, then session selection, then
264
272
  working-directory auto-detection. When the working directory matches no
@@ -312,7 +320,11 @@ place.`,
312
320
  pad_write(name, content, tags?) — create a pad; names are unique per project
313
321
  pad_write(pad_id, name, content, expected_revision) — full overwrite; the
314
322
  revision guard makes concurrent writers safe
315
- pad_read(pad_id | name) — content plus revision and metadata
323
+ pad_read(pad_id | name, offset?, limit?) — content plus revision and metadata
324
+ offset and limit are nonnegative safe integers in UTF-16 code units, with
325
+ no default cap; a cut inside a surrogate pair moves back one unit, or
326
+ forward if moving back would return nothing, and the result reports its
327
+ effective offset
316
328
  pad_append(pad_id, content, expected_revision?) — add to the end
317
329
  pad_edit(pad_id, old_text, new_text, expected_revision?) — replace one
318
330
  unique literal occurrence; include enough context to disambiguate