@cmgmyr/hive 1.10.0 → 1.10.1

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.
@@ -66,6 +66,7 @@ function workerRow(lane, snapshot, nowMs, memory, nextMemory) {
66
66
  else {
67
67
  const since = state === "blocked" ? null : w.state_changed_at;
68
68
  head.push(`${state} ${duration(w, { label: state, since, lower_bound: false }, nowMs, memory, nextMemory)}`);
69
+ if (state !== "blocked" && w.activity?.label) head.push(w.activity.label);
69
70
  }
70
71
  const tail = [ago(w.age_seconds)];
71
72
  if (w.commits_ahead !== null && w.commits_ahead !== undefined) tail.push(`+${w.commits_ahead} commits`);
@@ -1 +1 @@
1
- {"version":"1.10.0","sha":"9dd5fee","dirty":false,"build_id":"d7050d60f67cf44e2ca833e2ba422563b4e6b8fbdd68bccd51772e429cac1fa5"}
1
+ {"version":"1.10.1","sha":"47de74a","dirty":false,"build_id":"8d90ac219e940dac865c9770e28e1686dbed96d3d7ec219884aa4b6aa328dab5"}
package/dist/spawn.js CHANGED
@@ -4,7 +4,7 @@ import { preservedRolloutPath, reapCodexHome } from "./codexHome.js";
4
4
  import { getProject } from "./context.js";
5
5
  import { dataDir, db } from "./db.js";
6
6
  import { loadProjectYml } from "./projectYml.js";
7
- import { applyLayout, claimInitialWindow, createWindow, DEFAULT_LAYOUT, ensureSession, crossServerRefusal, findProcessesWindow, findProjectWindow, isPaneTarget, panePidForRecord, paneWindow, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, retainOnExitArgs, rowLive, sessionName, setPaneTitle, makeProcessesWindow, tmux, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, windowOwner, windowTitle, rowOwnership, } from "./tmux.js";
7
+ import { applyLayout, claimInitialWindow, createWindow, DEFAULT_LAYOUT, ensureSession, crossServerRefusal, findProcessesWindow, findProjectWindow, isPaneTarget, paneProcessExited, paneWindow, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, retainOnExitArgs, rowLive, sessionName, setPaneTitle, setRemainOnExit, makeProcessesWindow, tmux, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, windowOwner, windowTitle, rowOwnership, } from "./tmux.js";
8
8
  export function withWindowClaim(claim) {
9
9
  if (db.inTransaction) {
10
10
  throw new Error("withWindowClaim was called from inside an already-open transaction. better-sqlite3 nests a " +
@@ -56,13 +56,21 @@ function recordPane(agentId, target, socket, pid) {
56
56
  .prepare("UPDATE agents SET tmux_target = ?, tmux_socket = ?, pane_pid = ? WHERE id = ? AND status = 'running'")
57
57
  .run(target, socket, pid, agentId).changes > 0);
58
58
  }
59
- function readPidOrDiscard(target) {
60
- const pid = panePidForRecord(target);
61
- if (pid !== "")
59
+ // The creation pid proves the pane existed, not that its process still runs. A retained pane is
60
+ // left to the caller's readiness wait, which reports the exit with its tail.
61
+ function refuseIfExitedImmediately(target, retained) {
62
+ if (retained || paneProcessExited(target) !== true)
63
+ return;
64
+ discardOrphanedPane(target);
65
+ throw new Error(`The command exited immediately, so its pane ${target} was discarded rather than recorded as running. ` +
66
+ "Check the command and its arguments, then retry.");
67
+ }
68
+ export function createdPidOrDiscard(target, pid) {
69
+ if (/^\d+$/.test(pid))
62
70
  return pid;
63
71
  discardOrphanedPane(target);
64
- throw new Error(`tmux did not report a process id for the new pane ${target} (asked twice), so it was discarded rather than ` +
65
- "recorded: a row with no pane pid can never be verified as owning its pane. Retry.");
72
+ throw new Error(`tmux did not report a process id for the new pane ${target} when it created it, so it was discarded rather ` +
73
+ "than recorded: a row with no pane pid can never be verified as owning its pane. Retry.");
66
74
  }
67
75
  // Two callers, one meaning: whatever is left in the window re-tiles to the layout it was arranged
68
76
  // with. agent_close kills a worker's pane through this; stopProcess (src/processes.ts) is the only
@@ -116,10 +124,11 @@ export function upsertActor(actorId, name, kind) {
116
124
  db.prepare(`INSERT INTO actors (id, name, kind) VALUES (?, ?, ?)
117
125
  ON CONFLICT(id) DO UPDATE SET name = excluded.name, last_seen_at = datetime('now')`).run(actorId, name, kind);
118
126
  }
119
- function placeAgentPane(session, spec, envFlags, commandString, title) {
127
+ function placeAgentPane(session, spec, envFlags, commandString, title, onRetainArmed = () => { }) {
120
128
  let landedInProjectId = null;
121
129
  let layoutApplied = false;
122
130
  let inProcessesWindow = false;
131
+ let pid = "";
123
132
  const target = withWindowClaim(() => {
124
133
  const windowName = spec.placement === "split" ? spec.projectName : title;
125
134
  const windowOwnerId = spec.placement === "split" ? spec.projectId : null;
@@ -127,10 +136,14 @@ function placeAgentPane(session, spec, envFlags, commandString, title) {
127
136
  if (started.created) {
128
137
  // Deliberate for placement "processes" too: this pane IS the new session's initial window, so it
129
138
  // takes that window rather than a tile. The caller reports it; `hive hide` moves it in later.
139
+ pid = started.pid;
130
140
  return claimInitialWindow(started, windowName, windowOwnerId).pane;
131
141
  }
132
142
  const splitInto = (window, layout) => {
133
- const pane = tmux("split-window", "-d", "-P", "-F", "#{pane_id}", "-t", window, "-c", spec.cwd, ...envFlags, commandString, ...(spec.retainOnExit ? retainOnExitArgs(window) : []));
143
+ const [pane, created = ""] = tmux("split-window", "-d", "-P", "-F", "#{pane_id}\t#{pane_pid}", "-t", window, "-c", spec.cwd, ...envFlags, commandString, ...(spec.retainOnExit ? retainOnExitArgs(window) : [])).split("\t");
144
+ pid = created;
145
+ if (spec.retainOnExit)
146
+ onRetainArmed(window);
134
147
  applyLayout(window, layout);
135
148
  layoutApplied = true;
136
149
  return pane;
@@ -143,6 +156,9 @@ function placeAgentPane(session, spec, envFlags, commandString, title) {
143
156
  }
144
157
  else {
145
158
  const made = createWindow(session, processesWindowName(spec.projectName), spec.cwd, envFlags, commandString, null, true, spec.retainOnExit ?? false);
159
+ pid = made.pid;
160
+ if (spec.retainOnExit)
161
+ onRetainArmed(made.window);
146
162
  makeProcessesWindow(made.window, spec.projectId);
147
163
  pane = made.pane;
148
164
  }
@@ -160,9 +176,13 @@ function placeAgentPane(session, spec, envFlags, commandString, title) {
160
176
  return pane;
161
177
  }
162
178
  }
163
- return createWindow(session, windowName, spec.cwd, envFlags, commandString, windowOwnerId, true, spec.retainOnExit ?? false).pane;
179
+ const made = createWindow(session, windowName, spec.cwd, envFlags, commandString, windowOwnerId, true, spec.retainOnExit ?? false);
180
+ pid = made.pid;
181
+ if (spec.retainOnExit)
182
+ onRetainArmed(made.window);
183
+ return made.pane;
164
184
  });
165
- return { target, landedInProjectId, layoutApplied, inProcessesWindow };
185
+ return { target, pid, landedInProjectId, layoutApplied, inProcessesWindow };
166
186
  }
167
187
  export function launchAgent(spec) {
168
188
  if (untrustedTmuxServer())
@@ -183,6 +203,7 @@ export function launchAgent(spec) {
183
203
  const agentId = Number(info.lastInsertRowid);
184
204
  const actorId = `${spec.kind}:${agentId}`;
185
205
  let paneUp = false;
206
+ let retainedWindow = null;
186
207
  try {
187
208
  const commandString = typeof spec.commandString === "string" ? spec.commandString : spec.commandString({ agentId, actorId });
188
209
  db.prepare("UPDATE agents SET actor_id = ?, command = ? WHERE id = ?").run(actorId, commandString, agentId);
@@ -193,16 +214,22 @@ export function launchAgent(spec) {
193
214
  : spec.env;
194
215
  const envFlags = buildEnvFlags(env);
195
216
  const title = windowTitle(spec.projectName, spec.name);
196
- const { target, landedInProjectId, layoutApplied, inProcessesWindow } = placeAgentPane(session, spec, envFlags, commandString, title);
197
- const pid = readPidOrDiscard(target);
217
+ const placed = placeAgentPane(session, spec, envFlags, commandString, title, (window) => {
218
+ retainedWindow = window;
219
+ });
220
+ const { target, landedInProjectId, layoutApplied, inProcessesWindow } = placed;
221
+ const pid = createdPidOrDiscard(target, placed.pid);
222
+ refuseIfExitedImmediately(target, spec.retainOnExit ?? false);
198
223
  paneUp = true;
199
224
  if (!recordPane(agentId, target, socket, pid)) {
200
225
  discardOrphanedPane(target);
201
226
  throw paneRacedRetirement(agentId);
202
227
  }
203
- return { agentId, actorId, target, landedInProjectId, layoutApplied, inProcessesWindow };
228
+ return { agentId, actorId, target, landedInProjectId, layoutApplied, inProcessesWindow, retainedWindow };
204
229
  }
205
230
  catch (e) {
231
+ if (retainedWindow)
232
+ setRemainOnExit(retainedWindow, false);
206
233
  if (paneUp)
207
234
  throw e;
208
235
  db.prepare("DELETE FROM agents WHERE id = ?").run(agentId);
@@ -269,8 +296,10 @@ export function resumeAgent(spec) {
269
296
  });
270
297
  const session = sessionName();
271
298
  const title = windowTitle(spec.projectName, spec.name);
272
- const { target, landedInProjectId } = placeAgentPane(session, spec, envFlags, spec.commandString, title);
273
- const pid = readPidOrDiscard(target);
299
+ const placed = placeAgentPane(session, spec, envFlags, spec.commandString, title);
300
+ const { target, landedInProjectId } = placed;
301
+ const pid = createdPidOrDiscard(target, placed.pid);
302
+ refuseIfExitedImmediately(target, false);
274
303
  paneUp = true;
275
304
  if (!recordPane(spec.agentId, target, socket, pid)) {
276
305
  discardOrphanedPane(target);
package/dist/tmux.js CHANGED
@@ -184,16 +184,19 @@ function unsetAtSessionScope(name, envFlags) {
184
184
  // HIVE_PROJECT_PATH at session scope, silently, for that session's whole life.
185
185
  }
186
186
  }
187
+ // The pid is read from the creating call because tmux 3.8 reports an empty #{pane_pid} once a pane's
188
+ // process has exited, so a later list-panes can lose the race against a command that dies at once.
189
+ export const CREATED_PANE_FORMAT = "#{pane_id}\t#{session_name}:#{window_id}\t#{pane_pid}";
187
190
  export function ensureSession(name, cwd, initial) {
188
191
  if (quietTmux("has-session", "-t", `=${name}`))
189
192
  return { created: false };
190
193
  if (untrustedTmuxServer())
191
194
  throw crossServerRefusal("create a tmux session");
192
195
  try {
193
- const [pane, window] = tmux("new-session", "-d", "-P", "-F", "#{pane_id}\t#{session_name}:#{window_id}", "-s", name, "-c", cwd, ...(initial && "command" in initial ? [...initial.envFlags, initial.command] : [])).split("\t");
196
+ const [pane, window, pid = ""] = tmux("new-session", "-d", "-P", "-F", CREATED_PANE_FORMAT, "-s", name, "-c", cwd, ...(initial && "command" in initial ? [...initial.envFlags, initial.command] : [])).split("\t");
194
197
  if (initial && "command" in initial)
195
198
  unsetAtSessionScope(name, initial.envFlags);
196
- return { created: true, pane, window };
199
+ return { created: true, pane, window, pid };
197
200
  }
198
201
  catch (e) {
199
202
  if (!isDuplicateSession(e))
@@ -360,7 +363,7 @@ export function targetLiveProbe(target) {
360
363
  const rows = tmux("list-panes", "-t", target, "-F", "#{pane_id} #{pane_pid}")
361
364
  .split("\n")
362
365
  .map((line) => line.split(" "));
363
- const pid = isPaneTarget(target) ? (rows.find(([id]) => id === target)?.[1] ?? null) : null;
366
+ const pid = isPaneTarget(target) ? (rows.find(([id]) => id === target)?.[1] || null) : null;
364
367
  return { live: true, pid };
365
368
  }
366
369
  catch (e) {
@@ -700,10 +703,10 @@ export function captureFinalScreen(target, lines) {
700
703
  }
701
704
  }
702
705
  export function createWindow(session, windowName, cwd, envFlags, command, projectId, detach = false, retainOnExit = false) {
703
- const created = tmux("new-window", ...(detach ? ["-d"] : []), "-P", "-F", "#{pane_id}\t#{session_name}:#{window_id}", "-t", `=${session}`, "-n", windowName, "-c", cwd, ...envFlags, command, ...(retainOnExit ? retainOnExitArgs(`=${session}:${windowName}`) : []));
704
- const [pane, window] = created.split("\t");
706
+ const created = tmux("new-window", ...(detach ? ["-d"] : []), "-P", "-F", CREATED_PANE_FORMAT, "-t", `=${session}`, "-n", windowName, "-c", cwd, ...envFlags, command, ...(retainOnExit ? retainOnExitArgs(`=${session}:${windowName}`) : []));
707
+ const [pane, window, pid = ""] = created.split("\t");
705
708
  configureHiveWindow(window, true, projectId);
706
- return { pane, window };
709
+ return { pane, window, pid };
707
710
  }
708
711
  function viewSessionChain(session, view) {
709
712
  return [
@@ -18,7 +18,7 @@ import { agentIdentityEnv, branchAt, closeAgentRow, currentOwnership, discardOrp
18
18
  import { COMMAND_KIND, runningCommandRow, stopLine, stopProcess, STOP_REASONS } from "../processes.js";
19
19
  import { readContextTokens, readContextFill, resolveTranscriptDir } from "../transcript.js";
20
20
  import { leadSessionEnded } from "../leadState.js";
21
- import { capturePane, captureFinalScreen, DEFAULT_LAYOUT, cancelCopyMode, describePaneChoice, ensureAttached, findUnsafeControlChar, holdsHumanInput, liveTargets, observationFailed, observeRowOwnership, ownershipLiveness, paneCurrentCommand, paneInCopyMode, paneWindow, pollPaneReadiness, rowOwnership, sendText, sessionName, setRemainOnExit, sleep, TEXT_ALLOWED_CONTROL_CHARS, tmux, TmuxTimeoutError, WINDOW_LAYOUTS, } from "../tmux.js";
21
+ import { capturePane, captureFinalScreen, DEFAULT_LAYOUT, cancelCopyMode, describePaneChoice, ensureAttached, findUnsafeControlChar, holdsHumanInput, liveTargets, observationFailed, observeRowOwnership, ownershipLiveness, paneCurrentCommand, paneInCopyMode, pollPaneReadiness, rowOwnership, sendText, sessionName, setRemainOnExit, sleep, TEXT_ALLOWED_CONTROL_CHARS, tmux, TmuxTimeoutError, WINDOW_LAYOUTS, } from "../tmux.js";
22
22
  import { agentIdParam, agentNameParam, idParam, projectIdParam } from "./params.js";
23
23
  import { deriveProvenance, lastLogEvent, lastPermissionMode, reportsAgentStateLog, } from "../stateProvenance.js";
24
24
  const CLOSED_ROW_ORDER = "(parked_at != '') DESC, closed_at DESC, id DESC";
@@ -599,48 +599,48 @@ export function registerAgents(server) {
599
599
  rmSync(codexHomeDir(codexHomeKey), { recursive: true, force: true });
600
600
  throw e;
601
601
  }
602
- const { agentId, actorId, target, landedInProjectId, layoutApplied } = spawned;
603
- ensureAttached(sessionName());
602
+ const { agentId, actorId, target, landedInProjectId, layoutApplied, retainedWindow } = spawned;
604
603
  let ready = false;
605
604
  let dialogTail;
606
605
  let exited = false;
607
606
  let exitTail;
608
- if (harness.classifiesPaneScreen) {
609
- const classifier = harness.paneClassifier;
610
- // Captured before anything below can kill the pane: remain-on-exit is a WINDOW option,
611
- // and the pane id itself stops being addressable once discardOrphanedPane has run.
612
- const windowTarget = paneWindow(target) ?? target;
613
- try {
614
- const outcome = await pollPaneReadiness(target, PANE_READY_MS, classifier.hasInputBox);
615
- if (outcome === "gone") {
616
- // remain-on-exit held the pane so its final screen is still readable; capture it,
617
- // persist it onto the row (the pane below is about to be killed), then release it.
618
- // Never run choiceCheck against a retained-but-dead pane: it is frozen content, not
619
- // a live dialog, and choiceCheck's own capture is tuned to read a live screen.
620
- exited = true;
621
- exitTail = captureFinalScreen(target, 30);
622
- db.prepare("UPDATE agents SET exit_tail = ? WHERE id = ?").run(exitTail, agentId);
623
- discardOrphanedPane(target);
624
- }
625
- else {
626
- const { awaitingChoice, tail } = classifier.choiceCheck(target);
627
- if (awaitingChoice === true) {
628
- dialogTail = tail;
607
+ try {
608
+ ensureAttached(sessionName());
609
+ if (harness.classifiesPaneScreen) {
610
+ const classifier = harness.paneClassifier;
611
+ try {
612
+ const outcome = await pollPaneReadiness(target, PANE_READY_MS, classifier.hasInputBox);
613
+ if (outcome === "gone") {
614
+ // remain-on-exit held the pane so its final screen is still readable; capture it,
615
+ // persist it onto the row (the pane below is about to be killed), then release it.
616
+ // Never run choiceCheck against a retained-but-dead pane: it is frozen content, not
617
+ // a live dialog, and choiceCheck's own capture is tuned to read a live screen.
618
+ exited = true;
619
+ exitTail = captureFinalScreen(target, 30);
620
+ db.prepare("UPDATE agents SET exit_tail = ? WHERE id = ?").run(exitTail, agentId);
621
+ discardOrphanedPane(target);
629
622
  }
630
- else if (outcome === "ready") {
631
- ready = true;
623
+ else {
624
+ const { awaitingChoice, tail } = classifier.choiceCheck(target);
625
+ if (awaitingChoice === true) {
626
+ dialogTail = tail;
627
+ }
628
+ else if (outcome === "ready") {
629
+ ready = true;
630
+ }
632
631
  }
633
632
  }
633
+ catch {
634
+ ready = false;
635
+ }
634
636
  }
635
- catch {
636
- ready = false;
637
- }
638
- finally {
639
- // Unconditional: every early return, throw, or timeout between arming remain-on-exit
640
- // and here must still clear it, or the shared window (the default split placement's)
641
- // is left silently retaining every later exit in it, not just this one.
642
- setRemainOnExit(windowTarget, false);
643
- }
637
+ }
638
+ finally {
639
+ // Unconditional: every early return, throw, or timeout between arming remain-on-exit
640
+ // and here must still clear it, or the shared window (the default split placement's)
641
+ // is left silently retaining every later exit in it, not just this one.
642
+ if (retainedWindow)
643
+ setRemainOnExit(retainedWindow, false);
644
644
  }
645
645
  const codexInstructions = codexHomeKey ? codexInstructionsPhrase(codexInstructionLayers) : undefined;
646
646
  const receipt = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmgmyr/hive",
3
- "version": "1.10.0",
3
+ "version": "1.10.1",
4
4
  "description": "Run a crew of Claude Code and Codex workers from one lead session: visible tmux panes, exact state from hooks, one local SQLite store.",
5
5
  "type": "module",
6
6
  "bin": {