@cmgmyr/hive 1.6.0 → 1.8.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.
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, ` +
@@ -732,8 +750,13 @@ const HIVE_YML_TEMPLATE = `# hive project config. Read by \`hive lead\` from the
732
750
  # start_command: /jira-start
733
751
  # check: npm run lint && npx tsc --noEmit # what a worker runs before
734
752
  # reporting done; gates only,
735
- # no suite (test_all is that)
753
+ # no suite (suite_command is that)
736
754
  # check: ./vendor/bin/pint --test && ./vendor/bin/phpstan # a PHP stack's
755
+ # test_command: npm test -- test/foo.test.mjs # scoped check for one change
756
+ # suite_command: npm test # full suite; one run at a time, lead's slot
757
+ # verify_command: npm run build && node dist/cli.js --version # exercises the real outcome
758
+ # review_command: /review # a fresh-context reviewer, if your harness has one
759
+ # worker_model: sonnet # the cheaper tier workers spawn on
737
760
  # ci: weekly # absent: CI runs on push/PR. weekly: CI runs
738
761
  # on schedule + dispatch only, and the local
739
762
  # full suite gates the merge
@@ -1293,7 +1316,7 @@ function openDashboardOnce(project, dashboardFile) {
1293
1316
  if (claim.changes === 0)
1294
1317
  return;
1295
1318
  try {
1296
- execFileSync("open", [pathToFileURL(dashboardFile).href], { stdio: "ignore", timeout: 5000 });
1319
+ execFileSync("open", [pathToFileURL(dashboardFile).href], { stdio: "ignore", timeout: 10000 });
1297
1320
  }
1298
1321
  catch {
1299
1322
  db.prepare("DELETE FROM kv WHERE project_id = ? AND key = ?").run(project.id, DASHBOARD_OPENED_KV_KEY);
@@ -1377,14 +1400,18 @@ function resolveNamedProcess(argv, verb) {
1377
1400
  console.log(`${name}: not running (start with: hive start "${name}")`);
1378
1401
  return null;
1379
1402
  }
1380
- const probe = rowLiveProbe(row.tmux_socket, row.tmux_target);
1381
- if (probe.live === null) {
1403
+ const ownership = observeRowOwnership(row);
1404
+ if (ownership === "probe-failed") {
1382
1405
  console.log(CANNOT_TELL_WHERE(name));
1383
1406
  return null;
1384
1407
  }
1408
+ if (ownership === "unknown") {
1409
+ console.log(`${name}: its pane ownership is unknown (no pane pid recorded, or another tmux socket), so hive will not move that pane`);
1410
+ return null;
1411
+ }
1385
1412
  // 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)) {
1413
+ // running row names whoever holds that id now.
1414
+ if (ownership !== "live") {
1388
1415
  console.log(`${name}: not running (start with: hive start "${name}")`);
1389
1416
  return null;
1390
1417
  }
@@ -1466,8 +1493,10 @@ function cmdLeadPaneExited(argv) {
1466
1493
  }
1467
1494
  // Only a lead that CREATED its pane clears leftovers. An adopted live lead is the same lead those
1468
1495
  // processes belong to, and stopping them there would take down a running crew's dev server.
1469
- function clearLeftoverProcesses(project, config) {
1496
+ function clearLeftoverProcesses(project, config, leftoverIds) {
1470
1497
  for (const row of runningCommandRows(project.id)) {
1498
+ if (!leftoverIds.has(row.id))
1499
+ continue;
1471
1500
  const stopped = stopProcess(row, STOP_REASONS.previousLead);
1472
1501
  // Nothing was running under it, so the auto-start line that follows is the whole story.
1473
1502
  if (stopped.leg === "already-gone")
@@ -2793,7 +2822,8 @@ export function heldReasonLabel(heldReason) {
2793
2822
  if (isUnsubmittedInputHold(heldReason))
2794
2823
  return "typing";
2795
2824
  if (heldReason === HELD_REASON_LEAD_PANE_DEAD ||
2796
- wasHeldForPaneReissue(heldReason) ||
2825
+ wasHeldForPaneIdentity(heldReason) ||
2826
+ isLeadRowClosedHold(heldReason) ||
2797
2827
  isUnclassifiablePaneHold(heldReason)) {
2798
2828
  return "needs you";
2799
2829
  }
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
  }