@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.
package/dist/hook.js CHANGED
@@ -1,7 +1,70 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from "node:fs";
2
+ import { readFileSync, realpathSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, parse, relative, sep } from "node:path";
3
4
  import { awaitingFirstPromptSql } from "./firstPrompt.js";
4
5
  import { liveBackgroundTasks, SUBAGENT_LATCH_SQL, withholdsIdle } from "./backgroundTasks.js";
6
+ function canonicalPathWithMissingSuffix(input) {
7
+ if (!isAbsolute(input) || input.includes("\0"))
8
+ return null;
9
+ let current = parse(input).root;
10
+ const missing = [];
11
+ try {
12
+ current = realpathSync(current);
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ for (const part of input.slice(parse(input).root.length).split(sep)) {
18
+ if (part === "" || part === ".")
19
+ continue;
20
+ if (part === "..") {
21
+ if (missing.length)
22
+ missing.pop();
23
+ else
24
+ current = dirname(current);
25
+ continue;
26
+ }
27
+ if (missing.length) {
28
+ missing.push(part);
29
+ continue;
30
+ }
31
+ const candidate = join(current, part);
32
+ try {
33
+ current = realpathSync(candidate);
34
+ }
35
+ catch (error) {
36
+ if (error.code !== "ENOENT")
37
+ return null;
38
+ missing.push(part);
39
+ }
40
+ }
41
+ return join(current, ...missing);
42
+ }
43
+ function acceptsWorkerHook(row, payload, sessionsRoot) {
44
+ if (!row || row.kind !== "agent" || row.codex_home === "" || sessionsRoot === undefined)
45
+ return true;
46
+ const transcript = payload.transcript_path;
47
+ if (typeof transcript === "string" && transcript !== "") {
48
+ const canonicalRoot = canonicalPathWithMissingSuffix(sessionsRoot);
49
+ const canonicalTranscript = canonicalPathWithMissingSuffix(transcript);
50
+ if (canonicalRoot === null || canonicalTranscript === null)
51
+ return false;
52
+ const rel = relative(canonicalRoot, canonicalTranscript);
53
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
54
+ }
55
+ if (transcript !== undefined && transcript !== null && transcript !== "")
56
+ return false;
57
+ return (typeof row.session_id === "string" && row.session_id !== "" &&
58
+ typeof payload.session_id === "string" && payload.session_id !== "" &&
59
+ row.session_id === payload.session_id);
60
+ }
61
+ function validCheckpointThreshold() {
62
+ const text = process.env.HIVE_CONTEXT_CHECKPOINT_PERCENT;
63
+ if (!text || !/^\d+$/.test(text))
64
+ return false;
65
+ const value = Number(text);
66
+ return Number.isInteger(value) && value >= 1 && value <= 100;
67
+ }
5
68
  let raw;
6
69
  function readRaw() {
7
70
  if (raw === undefined) {
@@ -161,8 +224,33 @@ function stateFor(event, actorId) {
161
224
  }
162
225
  if (process.argv[2] === "post_tool_use") {
163
226
  try {
227
+ const kind = process.argv[3];
228
+ const actorId = process.env.HIVE_AGENT_ID ?? "";
229
+ let suppliedPayload;
230
+ if (kind === "codex" && validCheckpointThreshold() && actorId.startsWith("agent:")) {
231
+ suppliedPayload = readPayload();
232
+ const { db } = await import("./db.js");
233
+ const row = db.prepare("SELECT kind, command, status, codex_home, session_id FROM agents WHERE actor_id = ?")
234
+ .get(actorId);
235
+ if (row?.kind === "agent") {
236
+ const { harnessFor } = await import("./harnesses.js");
237
+ if (harnessFor(row.command).name === "codex") {
238
+ if (row.status !== "running")
239
+ process.exit(0);
240
+ if (typeof row.codex_home === "string" && row.codex_home !== "") {
241
+ const { codexHomeDir } = await import("./codexHome.js");
242
+ if (!acceptsWorkerHook(row, suppliedPayload, join(codexHomeDir(row.codex_home), "sessions"))) {
243
+ process.exit(0);
244
+ }
245
+ }
246
+ }
247
+ }
248
+ }
164
249
  const { runContextCheckpointHook } = await import("./contextCheckpoint.js");
165
- runContextCheckpointHook(process.argv[3]);
250
+ if (suppliedPayload === undefined)
251
+ runContextCheckpointHook(kind);
252
+ else
253
+ runContextCheckpointHook(kind, suppliedPayload);
166
254
  }
167
255
  catch { }
168
256
  process.exit(0);
@@ -172,13 +260,43 @@ try {
172
260
  const actorId = process.env.HIVE_AGENT_ID;
173
261
  if (actorId) {
174
262
  const event = process.argv[2] ?? "";
175
- const state = stateFor(event, actorId);
176
- if (state !== null) {
177
- db.prepare("UPDATE agents SET agent_state = ?, state_changed_at = datetime('now') WHERE actor_id = ? AND kind = 'agent'").run(state, actorId);
178
- }
179
- if (event === "prompt") {
180
- db.prepare(`UPDATE agents SET resumed_at = '' WHERE actor_id = ? AND kind = 'agent' AND ${awaitingFirstPromptSql("agents")}`).run(actorId);
263
+ const hookPayload = readPayload();
264
+ const row = db.prepare("SELECT kind, command, status, codex_home, session_id FROM agents WHERE actor_id = ?")
265
+ .get(actorId);
266
+ let guarded = false;
267
+ let state = null;
268
+ if (row?.kind === "agent") {
269
+ const { harnessFor } = await import("./harnesses.js");
270
+ if (harnessFor(row.command).name === "codex") {
271
+ if (row.status !== "running")
272
+ process.exit(0);
273
+ const { codexHomeDir } = await import("./codexHome.js");
274
+ const result = db.transaction(() => {
275
+ const current = db.prepare("SELECT kind, command, status, codex_home, session_id FROM agents WHERE actor_id = ?")
276
+ .get(actorId);
277
+ if (current?.kind === "agent" && harnessFor(current.command).name === "codex" && current.status !== "running") {
278
+ return { status: "inactive", state: null };
279
+ }
280
+ if (!current || current.kind !== "agent" || typeof current.codex_home !== "string" || !current.codex_home || harnessFor(current.command).name !== "codex") {
281
+ return { status: "changed", state: null };
282
+ }
283
+ const sessionsRoot = join(codexHomeDir(current.codex_home), "sessions");
284
+ if (!acceptsWorkerHook(current, hookPayload, sessionsRoot))
285
+ return { status: "rejected", state: null };
286
+ const state = applyWorkerTransition(actorId, event);
287
+ reconcileSessionId(actorId, hookPayload);
288
+ reconcileTranscriptPath(actorId, hookPayload);
289
+ db.prepare("UPDATE actors SET last_seen_at = datetime('now') WHERE id = ?").run(actorId);
290
+ return { status: "accepted", state };
291
+ }).immediate();
292
+ if (result.status === "rejected" || result.status === "inactive")
293
+ process.exit(0);
294
+ guarded = result.status === "accepted";
295
+ state = result.state;
296
+ }
181
297
  }
298
+ if (!guarded)
299
+ state = applyWorkerTransition(actorId, event);
182
300
  // A lead's turn state is its own table; agents.agent_state stays worker-only.
183
301
  if (process.env.HIVE_LEAD === "1" && (event === "prompt" || event === "stop" || event === "session_end")) {
184
302
  try {
@@ -195,12 +313,24 @@ try {
195
313
  catch {
196
314
  }
197
315
  }
198
- reconcileSessionId(actorId, readPayload());
199
- reconcileTranscriptPath(actorId, readPayload());
200
- db.prepare("UPDATE actors SET last_seen_at = datetime('now') WHERE id = ?").run(actorId);
316
+ if (!guarded) {
317
+ reconcileSessionId(actorId, hookPayload);
318
+ reconcileTranscriptPath(actorId, hookPayload);
319
+ db.prepare("UPDATE actors SET last_seen_at = datetime('now') WHERE id = ?").run(actorId);
320
+ }
201
321
  record(actorId, event, state ?? UNCHANGED);
202
322
  }
203
323
  }
204
324
  catch {
205
325
  }
206
326
  process.exit(0);
327
+ function applyWorkerTransition(actorId, event) {
328
+ const state = stateFor(event, actorId);
329
+ if (state !== null) {
330
+ db.prepare("UPDATE agents SET agent_state = ?, state_changed_at = datetime('now') WHERE actor_id = ? AND kind = 'agent'").run(state, actorId);
331
+ }
332
+ if (event === "prompt") {
333
+ db.prepare(`UPDATE agents SET resumed_at = '' WHERE actor_id = ? AND kind = 'agent' AND ${awaitingFirstPromptSql("agents")}`).run(actorId);
334
+ }
335
+ return state;
336
+ }
package/dist/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/portfolio.js CHANGED
@@ -4,7 +4,7 @@ import { existsSync } from "node:fs";
4
4
  import { parseTags } from "./result.js";
5
5
  import { OPEN_BLOCKERS_SQL } from "./tools/todos.js";
6
6
  import { readLeadTurnState } from "./leadState.js";
7
- import { liveTargets, paneReissued, rowAlive, rowAliveProbe } from "./tmux.js";
7
+ import { liveTargets, ownershipLiveness, rowOwnership } from "./tmux.js";
8
8
  export const PORTFOLIO_REASONS = [
9
9
  "needs_human",
10
10
  "dead_lead_pane",
@@ -84,15 +84,14 @@ function projectRow(project, now, snapshot) {
84
84
  const agents = db
85
85
  .prepare("SELECT id, kind, agent_state, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
86
86
  .all(project.id);
87
- const liveness = (a) => snapshot ? rowAlive(a.tmux_socket, a.tmux_target, snapshot) : null;
88
- const reissued = (a) => snapshot !== null && paneReissued(a.pane_pid, rowAliveProbe(a.tmux_socket, a.tmux_target, snapshot));
87
+ const liveness = (a) => ownershipLiveness(rowOwnership(a, snapshot));
89
88
  const workers = { working: 0, idle: 0, needs_input: 0, other: 0, unreachable: 0, unconfirmed: 0 };
90
89
  let liveWorking = 0;
91
90
  let liveWaiting = 0;
92
91
  let lead = { state: "none", agent_id: null, turn: "unknown" };
93
92
  for (const a of agents) {
94
93
  if (a.kind === "lead") {
95
- const live = reissued(a) ? false : liveness(a);
94
+ const live = liveness(a);
96
95
  lead = {
97
96
  state: live === true ? "alive" : live === false ? "dead_pane" : "unknown",
98
97
  agent_id: a.id,
@@ -102,7 +101,7 @@ function projectRow(project, now, snapshot) {
102
101
  }
103
102
  if (a.kind !== "agent")
104
103
  continue;
105
- const live = reissued(a) ? false : liveness(a);
104
+ const live = liveness(a);
106
105
  if (live === false)
107
106
  workers.unreachable++;
108
107
  else if (live === null)
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
  }
@@ -1,5 +1,7 @@
1
1
  import { backupNow } from "./backup.js";
2
2
  import { dataDir, db } from "./db.js";
3
+ import { LEAD_KIND } from "./spawn.js";
4
+ import { foreignSocket, liveTargets, observationFailed, rowOwnership } from "./tmux.js";
3
5
  export const PROJECT_OWNER_TABLES = [
4
6
  "pads",
5
7
  "todos",
@@ -28,17 +30,49 @@ export function formatRowCounts(counts) {
28
30
  .map(([table, n]) => `${table}: ${n}`)
29
31
  .join(", ");
30
32
  }
31
- function assertNoRunningAgents(projectId, name) {
32
- const running = db
33
- .prepare("SELECT id, name FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
34
- .all(projectId);
33
+ const runningRows = (projectId) => db
34
+ .prepare("SELECT id, name, kind, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
35
+ .all(projectId);
36
+ const identities = (rows) => JSON.stringify(rows.map((r) => [r.id, r.tmux_target, r.tmux_socket, r.pane_pid]));
37
+ function assertNoRunningAgents(projectId, name, running, panes) {
35
38
  if (running.length === 0)
36
39
  return;
37
- const names = running.map((a) => `${a.name} (agent ${a.id})`).join(", ");
38
- throw new Error(`project ${projectId} ("${name}") has running agents: ${names}. Nothing deleted. Run hive doctor to close rows whose panes are gone, or agent_close them.`);
40
+ const snapshot = panes === undefined ? liveTargets() : panes;
41
+ const blockers = running
42
+ .map((a) => ({ ...a, ownership: rowOwnership(a, snapshot) }))
43
+ .filter((a) => a.ownership === "live" || a.ownership === "unknown");
44
+ if (blockers.length === 0)
45
+ return;
46
+ const names = blockers
47
+ .map((a) => `${a.name} (agent ${a.id}, ${a.ownership === "live" ? "owns a live pane" : "pane identity unknown"})`)
48
+ .join(", ");
49
+ const hints = [];
50
+ const liveLeads = blockers.filter((a) => a.ownership === "live" && a.kind === LEAD_KIND);
51
+ const liveOthers = blockers.filter((a) => a.ownership === "live" && a.kind !== LEAD_KIND);
52
+ const unknown = blockers.filter((a) => a.ownership === "unknown");
53
+ if (liveLeads.length > 0)
54
+ hints.push("A live lead is stopped or restarted from its own terminal, never retired with row_only.");
55
+ if (liveOthers.length > 0)
56
+ hints.push("Stop a live worker or command with agent_close first.");
57
+ if (unknown.length > 0 && observationFailed(snapshot)) {
58
+ hints.push("tmux could not be probed, so no row's ownership could be checked. Retry in a few seconds.");
59
+ }
60
+ else if (unknown.length > 0) {
61
+ const foreign = unknown.some((a) => foreignSocket(a.tmux_socket));
62
+ hints.push("A row whose pane cannot be verified is retired explicitly by a human or peer lead through this project's MCP " +
63
+ `tool, ${unknown.map((a) => `agent_close({agent_id: ${a.id}, row_only: true})`).join(", ")}` +
64
+ (foreign
65
+ ? ", or run this again from the tmux socket a foreign-socket row was recorded on, where its ownership can be verified."
66
+ : "."));
67
+ }
68
+ throw new Error(`project ${projectId} ("${name}") has running agents: ${names}. Nothing deleted. ${hints.join(" ")}`);
39
69
  }
40
- const deleteProjectRows = db.transaction((projectId, name, expected, after) => {
41
- assertNoRunningAgents(projectId, name);
70
+ const deleteProjectRows = db.transaction((projectId, name, expected, observed, after) => {
71
+ const running = runningRows(projectId);
72
+ if (identities(running) !== observed.rows) {
73
+ throw new Error(`project ${projectId} ("${name}") changed while the snapshot was taken; nothing removed, run it again.`);
74
+ }
75
+ assertNoRunningAgents(projectId, name, running, observed.panes);
42
76
  if (JSON.stringify(projectRowCounts(projectId)) !== JSON.stringify(expected)) {
43
77
  throw new Error(`project ${projectId} ("${name}") changed while the snapshot was taken; nothing removed, run it again.`);
44
78
  }
@@ -52,7 +86,7 @@ export function removeProject(projectId, opts) {
52
86
  const target = db.prepare("SELECT id, name, path FROM projects WHERE id = ?").get(projectId);
53
87
  if (!target)
54
88
  throw new Error(`no project ${projectId}.`);
55
- assertNoRunningAgents(projectId, target.name);
89
+ assertNoRunningAgents(projectId, target.name, runningRows(projectId));
56
90
  const counts = projectRowCounts(projectId);
57
91
  let snapshot = null;
58
92
  if (opts.snapshot) {
@@ -62,7 +96,9 @@ export function removeProject(projectId, opts) {
62
96
  snapshot = result.path ?? null;
63
97
  }
64
98
  opts.afterSnapshot?.();
99
+ const rows = runningRows(projectId);
100
+ const observed = { rows: identities(rows), panes: rows.length > 0 ? liveTargets() : null };
65
101
  const removed = { deleted: { id: target.id, name: target.name, path: target.path }, counts, snapshot };
66
- deleteProjectRows.immediate(projectId, target.name, counts, () => opts.onRemoved?.(removed));
102
+ deleteProjectRows.immediate(projectId, target.name, counts, observed, () => opts.onRemoved?.(removed));
67
103
  return removed;
68
104
  }