@cmgmyr/hive 1.6.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
 
@@ -92,7 +92,14 @@ A command, a repository name, a ticket prefix: these belong in `hive.yml` `vars`
92
92
 
93
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
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.
102
+
95
103
  ## What this skill does not do
96
104
 
97
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.
98
-
@@ -1 +1 @@
1
- {"version":"1.6.0","sha":"ac13530","dirty":false,"build_id":"428a468f-98af-497e-a40c-fb87feef27ac"}
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, 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";
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, ` +
@@ -1293,7 +1311,7 @@ function openDashboardOnce(project, dashboardFile) {
1293
1311
  if (claim.changes === 0)
1294
1312
  return;
1295
1313
  try {
1296
- execFileSync("open", [pathToFileURL(dashboardFile).href], { stdio: "ignore", timeout: 5000 });
1314
+ execFileSync("open", [pathToFileURL(dashboardFile).href], { stdio: "ignore", timeout: 10000 });
1297
1315
  }
1298
1316
  catch {
1299
1317
  db.prepare("DELETE FROM kv WHERE project_id = ? AND key = ?").run(project.id, DASHBOARD_OPENED_KV_KEY);
@@ -1377,14 +1395,18 @@ function resolveNamedProcess(argv, verb) {
1377
1395
  console.log(`${name}: not running (start with: hive start "${name}")`);
1378
1396
  return null;
1379
1397
  }
1380
- const probe = rowLiveProbe(row.tmux_socket, row.tmux_target);
1381
- if (probe.live === null) {
1398
+ const ownership = observeRowOwnership(row);
1399
+ if (ownership === "probe-failed") {
1382
1400
  console.log(CANNOT_TELL_WHERE(name));
1383
1401
  return null;
1384
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
+ }
1385
1407
  // A pane id alone is not an identity: tmux restarts them at %0 on a fresh server, so a stale
1386
- // running row names whoever holds that id now. Same compare as janitor() and wake delivery.
1387
- if (!probe.live || paneReissued(row.pane_pid, probe)) {
1408
+ // running row names whoever holds that id now.
1409
+ if (ownership !== "live") {
1388
1410
  console.log(`${name}: not running (start with: hive start "${name}")`);
1389
1411
  return null;
1390
1412
  }
@@ -1466,8 +1488,10 @@ function cmdLeadPaneExited(argv) {
1466
1488
  }
1467
1489
  // Only a lead that CREATED its pane clears leftovers. An adopted live lead is the same lead those
1468
1490
  // processes belong to, and stopping them there would take down a running crew's dev server.
1469
- function clearLeftoverProcesses(project, config) {
1491
+ function clearLeftoverProcesses(project, config, leftoverIds) {
1470
1492
  for (const row of runningCommandRows(project.id)) {
1493
+ if (!leftoverIds.has(row.id))
1494
+ continue;
1471
1495
  const stopped = stopProcess(row, STOP_REASONS.previousLead);
1472
1496
  // Nothing was running under it, so the auto-start line that follows is the whole story.
1473
1497
  if (stopped.leg === "already-gone")
@@ -2793,7 +2817,8 @@ export function heldReasonLabel(heldReason) {
2793
2817
  if (isUnsubmittedInputHold(heldReason))
2794
2818
  return "typing";
2795
2819
  if (heldReason === HELD_REASON_LEAD_PANE_DEAD ||
2796
- wasHeldForPaneReissue(heldReason) ||
2820
+ wasHeldForPaneIdentity(heldReason) ||
2821
+ isLeadRowClosedHold(heldReason) ||
2797
2822
  isUnclassifiablePaneHold(heldReason)) {
2798
2823
  return "needs you";
2799
2824
  }
package/dist/help.js CHANGED
@@ -179,6 +179,10 @@ runbook (fork it first), not a pad write.`,
179
179
  a stale row closed with no kill, stop or typing; a human or peer lead
180
180
  only, refused for a row that provably owns a live pane or when tmux
181
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.
182
186
 
183
187
  Address a worker by its name, not its id: agent_send(name="impl", ...). A
184
188
  partial name works when it matches one running worker, so name="123" finds
@@ -316,7 +320,11 @@ place.`,
316
320
  pad_write(name, content, tags?) — create a pad; names are unique per project
317
321
  pad_write(pad_id, name, content, expected_revision) — full overwrite; the
318
322
  revision guard makes concurrent writers safe
319
- 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
320
328
  pad_append(pad_id, content, expected_revision?) — add to the end
321
329
  pad_edit(pad_id, old_text, new_text, expected_revision?) — replace one
322
330
  unique literal occurrence; include enough context to disambiguate
package/dist/leadWatch.js CHANGED
@@ -2,7 +2,7 @@ import { db } from "./db.js";
2
2
  import { paneClassifierFor } from "./harnesses.js";
3
3
  import { readLeadTurnState } from "./leadState.js";
4
4
  import { LEAD_KIND } from "./spawn.js";
5
- import { holdsHumanInput, paneReissued, rowAliveProbe } from "./tmux.js";
5
+ import { foreignSocket, holdsHumanInput, observationFailed, rowOwnership } from "./tmux.js";
6
6
  export const LEAD_TARGET_GONE = "LEAD_TARGET_GONE";
7
7
  export const LEAD_TARGET_REISSUED = "LEAD_TARGET_REISSUED";
8
8
  export const LEAD_TARGET_RESTARTED = "LEAD_TARGET_RESTARTED";
@@ -39,14 +39,16 @@ export function evaluateLeadWatch(sub, mode, snapshot, readers) {
39
39
  return { kind: "invalid", reason: LEAD_TARGET_GONE };
40
40
  if (lead.pane_pid !== sub.pane_pid)
41
41
  return { kind: "invalid", reason: LEAD_TARGET_RESTARTED };
42
- if (snapshot === null)
43
- return { kind: "pending", reason: "tmux did not answer" };
44
- const probe = rowAliveProbe(lead.tmux_socket, lead.tmux_target, snapshot);
45
- if (probe.live === null)
42
+ if (foreignSocket(lead.tmux_socket))
46
43
  return { kind: "pending", reason: "its pane cannot be probed from this process" };
47
- if (!probe.live)
44
+ if (observationFailed(snapshot))
45
+ return { kind: "pending", reason: "tmux did not answer" };
46
+ const ownership = rowOwnership(lead, snapshot);
47
+ if (ownership === "unknown")
48
+ return { kind: "pending", reason: "pane ownership unknown" };
49
+ if (ownership === "gone")
48
50
  return { kind: "invalid", reason: LEAD_TARGET_GONE };
49
- if (paneReissued(sub.pane_pid, probe))
51
+ if (ownership === "reissued")
50
52
  return { kind: "invalid", reason: LEAD_TARGET_REISSUED };
51
53
  const state = readLeadTurnState(lead.id);
52
54
  if (state === null || state.pane_pid !== sub.pane_pid)
package/dist/processes.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { getProject } from "./context.js";
2
2
  import { db } from "./db.js";
3
3
  import { loadProjectYml } from "./projectYml.js";
4
- import { closeAgentRow, killAgentPane, relayoutAfterPaneLeft } from "./spawn.js";
5
- import { cancelCopyMode, paneProcessExited, paneReissued, paneVisibility, paneWindow, projectWindows, rowLive, rowLiveProbe, sessionName, tmux, } from "./tmux.js";
4
+ import { closeAgentRow, currentOwnership, killAgentPane, paneIdentity, relayoutAfterPaneLeft } from "./spawn.js";
5
+ import { cancelCopyMode, observeRowOwnership, paneProcessExited, paneVisibility, paneWindow, projectWindows, rowLive, sessionName, tmux, } from "./tmux.js";
6
6
  // One derivation for every surface that reports where a project's processes are: `hive status`,
7
7
  // `hive doctor`, and the dashboard. Two tmux window lookups per project, then one per running
8
8
  // process, and none at all for a project with neither a running command nor a defined one.
@@ -96,6 +96,19 @@ function waitForPaneToExit(target, timeoutMs) {
96
96
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(STOP_POLL_MS, remaining));
97
97
  }
98
98
  }
99
+ const UNKNOWN_OWNERSHIP_NOTE = "its pane ownership is unknown (no pane pid recorded, a legacy window target, or another tmux socket), so hive left it running";
100
+ const REISSUED_NOTE = "its pane id now belongs to another process, which was left alone";
101
+ const CHANGED_NOTE = "its row was closed or recorded a different pane during the stop, so hive touched nothing further";
102
+ // Unknown, or a row that changed underneath the stop, is left running: closing it would hide a
103
+ // process that may still be running from every surface that reports one.
104
+ function notOwned(row, ownership) {
105
+ if (ownership === "unknown")
106
+ return { name: row.name, leg: "unreachable", note: UNKNOWN_OWNERSHIP_NOTE };
107
+ if (ownership === "changed")
108
+ return { name: row.name, leg: "unreachable", note: CHANGED_NOTE };
109
+ closeAgentRow(row.id, paneIdentity(row));
110
+ return { name: row.name, leg: "already-gone", ...(ownership === "reissued" ? { note: REISSUED_NOTE } : {}) };
111
+ }
99
112
  // The only thing that stops a hive.yml command. kill-pane alone is SIGHUP, which a dev server with
100
113
  // detached grandchildren survives while still holding its port, so the graceful leg comes first and
101
114
  // the receipt says which one ended it.
@@ -103,19 +116,19 @@ function waitForPaneToExit(target, timeoutMs) {
103
116
  // argument so every caller has to declare its trigger from the closed set docs/projects.md is
104
117
  // checked against, and whichever caller prints it already knows which one it passed.
105
118
  export function stopProcess(row, reason) {
106
- const probe = rowLiveProbe(row.tmux_socket, row.tmux_target);
107
- // A pane hive cannot see is left alone, row and all: closing the row here would hide a process
108
- // that is still running from every surface that reports one.
109
- if (probe.live === null)
119
+ const identity = paneIdentity(row);
120
+ const ownership = observeRowOwnership(identity);
121
+ if (ownership === "probe-failed")
110
122
  return { name: row.name, leg: "unreachable" };
111
- // A reissued pane id belongs to whoever holds it now, so it is never killed - same compare as
112
- // janitor() and wake delivery.
113
- if (probe.live === false || paneReissued(row.pane_pid, probe)) {
114
- closeAgentRow(row.id);
115
- return { name: row.name, leg: "already-gone" };
116
- }
123
+ if (ownership !== "live")
124
+ return notOwned(row, ownership);
117
125
  markStopping(row.project_id, row.id);
118
126
  const window = paneWindow(row.tmux_target);
127
+ const beforeSignal = currentOwnership(row.id, identity);
128
+ if (beforeSignal !== "live") {
129
+ clearStoppingMarker(row.project_id, row.id);
130
+ return notOwned(row, beforeSignal);
131
+ }
119
132
  try {
120
133
  // In copy mode C-c is `cancel`, not SIGINT, so the graceful leg would silently do nothing to a
121
134
  // pane someone scrolled back in. A stop may take that scrollback position: the process is ending.
@@ -126,6 +139,11 @@ export function stopProcess(row, reason) {
126
139
  }
127
140
  let leg = "interrupted";
128
141
  if (!waitForPaneToExit(row.tmux_target, STOP_GRACE_MS)) {
142
+ const beforeKill = currentOwnership(row.id, identity);
143
+ if (beforeKill !== "live") {
144
+ clearStoppingMarker(row.project_id, row.id);
145
+ return notOwned(row, beforeKill);
146
+ }
129
147
  try {
130
148
  killAgentPane(row.tmux_target);
131
149
  }
@@ -137,7 +155,7 @@ export function stopProcess(row, reason) {
137
155
  // RUNNING, so the process stays visible to hive status, hive stop and the janitor.
138
156
  if (leg === "still-running")
139
157
  return { name: row.name, leg };
140
- closeAgentRow(row.id, row.tmux_target);
158
+ closeAgentRow(row.id, identity);
141
159
  clearStoppingMarker(row.project_id, row.id);
142
160
  relayoutAfterPaneLeft(window);
143
161
  return { name: row.name, leg };
@@ -152,9 +170,13 @@ export function stopLine(stopped) {
152
170
  case "killed":
153
171
  return `${stopped.name}: stopped (killed after ${STOP_GRACE_MS / 1000}s)`;
154
172
  case "already-gone":
155
- return `${stopped.name}: already gone (nothing was running)`;
173
+ return stopped.note
174
+ ? `${stopped.name}: already gone (${stopped.note})`
175
+ : `${stopped.name}: already gone (nothing was running)`;
156
176
  case "unreachable":
157
- return `${stopped.name}: tmux could not be probed, so hive left it running`;
177
+ return stopped.note
178
+ ? `${stopped.name}: ${stopped.note}`
179
+ : `${stopped.name}: tmux could not be probed, so hive left it running`;
158
180
  case "still-running":
159
181
  return `${stopped.name}: still running: its pane survived C-c and kill-pane, so hive left the row open`;
160
182
  }