@cmgmyr/hive 1.9.1 → 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.
@@ -2,6 +2,7 @@ import { existsSync, realpathSync } from "node:fs";
2
2
  import { join, sep } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { db } from "./db.js";
5
+ import { SECTION_STYLE, SPRITE, chevron, renderBoardPadsTodos, sectionScript } from "./dashboard.js";
5
6
  export const QUEEN_BRIEF_KEY = "queen:brief";
6
7
  const LANE_ORDER = ["waiting_on_you", "stuck", "moving", "quiet"];
7
8
  const LANE_RANK = { waiting_on_you: 0, stuck: 1, moving: 2, quiet: 3 };
@@ -158,6 +159,8 @@ function leadPill(p) {
158
159
  return `<span class="status status-ok">lead up</span>${turnPill(p)}`;
159
160
  case "dead_pane":
160
161
  return '<span class="status status-fail">lead dead</span>';
162
+ case "dormant":
163
+ return '<span class="status status-off">lead dormant</span>';
161
164
  case "unknown":
162
165
  return '<span class="status status-off">lead unknown</span>';
163
166
  default:
@@ -243,7 +246,7 @@ function quietList(list, asOf, href) {
243
246
  return ('<section class="card"><ul class="quiet-list">' +
244
247
  list
245
248
  .map((p) => {
246
- const sub = `${p.lead.state === "alive" ? (turnText(p) ? `lead up, ${turnText(p)}` : "lead up") : p.lead.state === "dead_pane" ? "lead dead" : p.lead.state === "unknown" ? "lead unknown" : "no lead"}, ${p.todos.open} open`;
249
+ const sub = `${p.lead.state === "alive" ? (turnText(p) ? `lead up, ${turnText(p)}` : "lead up") : p.lead.state === "dead_pane" ? "lead dead" : p.lead.state === "dormant" ? "lead dormant" : p.lead.state === "unknown" ? "lead unknown" : "no lead"}, ${p.todos.open} open`;
247
250
  return (`<li data-project="${p.id}"><span>${projectLink(p, href)}` +
248
251
  `<a class="sub" href="#row-${p.id}" data-jump="${p.id}">${esc(sub)}</a></span>` +
249
252
  `<span class="age">${esc(age(p.last_activity_at, asOf))}</span></li>`);
@@ -338,7 +341,7 @@ function briefSection(state, report, href, queenHomeId) {
338
341
  (picks ? `<ol>${picks}</ol>` : "") +
339
342
  `${driftLine(brief, report, queenHomeId)}</section>`);
340
343
  }
341
- const CSS = `
344
+ const CSS = `${SECTION_STYLE}
342
345
  :root { color-scheme: light;
343
346
  --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
344
347
  --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
@@ -350,7 +353,7 @@ const CSS = `
350
353
  --accent: #1f5fd0; --focus: #1f5fd0; --hover: #f1f4f8;
351
354
  --shadow: 0 1px 2px rgba(19, 26, 38, 0.06), 0 2px 8px rgba(19, 26, 38, 0.05);
352
355
  --card-border: transparent; --r-card: 14px; --r-ctl: 9px; --r-pill: 999px;
353
- --ease-out: cubic-bezier(0.23, 1, 0.32, 1); }
356
+ --ease-out: cubic-bezier(0.23, 1, 0.32, 1); --header-offset: 5rem; }
354
357
  @media (prefers-color-scheme: dark) {
355
358
  :root:not([data-theme="light"]) { color-scheme: dark;
356
359
  --bg: #101317; --panel: #171b21; --panel-sunken: #1c2129;
@@ -478,17 +481,22 @@ a.proj:hover { color: var(--accent); text-decoration: underline; text-underline-
478
481
  a.sub:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
479
482
  .turn { font-size: 0.75rem; color: var(--fg-muted); }
480
483
  .turn-work { color: var(--live); }
481
- .c-audit { margin-top: 1.5rem; padding-bottom: 0.85rem; }
484
+ .c-audit { margin-top: 1.5rem; }
485
+ .c-audit[open] { padding-bottom: 0.85rem; }
486
+ details.card:not([open]) > .card-head { padding-bottom: 0.9rem; }
482
487
  .c-audit .audit-list, .c-audit .brief-empty { padding: 0 1.1rem; }
483
488
  .audit-list { list-style: none; margin: 0.5rem 0 0; padding: 0; display: grid; gap: 0.4rem; font-size: 0.8125rem; }
484
489
  .audit-list li { display: flex; flex-wrap: wrap; gap: 0.15rem 0.75rem; align-items: baseline; }
485
490
  .audit-op { font-weight: 600; }
486
491
  .audit-sum { color: var(--fg-muted); min-width: 0; overflow-wrap: anywhere; }
487
492
  .d-grid { margin-top: 1.5rem; }
493
+ .card-head > .chev { margin-left: auto; }
494
+ .home { margin-top: 1.5rem; }
495
+ .home-head { padding: 0 0.2rem 0.6rem; }
488
496
  .d-grid .g-row:not(.g-head) { box-shadow: inset 3px 0 0 var(--lane, transparent); }
489
497
  .d-grid .g-row { scroll-margin-top: 6rem; transition: background-color 600ms ease; }
490
498
  .d-grid .g-row.is-flash { background: var(--live-bg); transition: none; }
491
- .empty { margin-top: 1.25rem; padding: 1.25rem 1.1rem; color: var(--fg-muted); }
499
+ .no-projects { margin-top: 1.25rem; padding: 1.25rem 1.1rem; color: var(--fg-muted); }
492
500
  @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
493
501
  `;
494
502
  const SCRIPT = `(function () {
@@ -504,12 +512,17 @@ const SCRIPT = `(function () {
504
512
  var row = document.getElementById("row-" + j.getAttribute("data-jump"));
505
513
  if (!row) return;
506
514
  e.preventDefault();
515
+ var box = row.closest("details");
516
+ if (box) box.open = true;
507
517
  var still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
508
518
  row.scrollIntoView({ behavior: still ? "auto" : "smooth", block: "center" });
509
519
  row.classList.add("is-flash");
510
520
  setTimeout(function () { row.classList.remove("is-flash"); }, 900);
511
521
  });
512
522
  })();`;
523
+ const SECTIONS_SCRIPT = `(function () {
524
+ ${sectionScript("hive-queen-state")}
525
+ })();`;
513
526
  const MARK = '<symbol id="mark" viewBox="0 0 32 32"><path d="M16 3.2 27 9.6v12.8L16 28.8 5 22.4V9.6Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M16 11 21 14v6l-5 3-5-3v-6Z" fill="currentColor" fill-opacity="0.22" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></symbol>';
514
527
  export const QUEEN_GENERATED_MARKER = "data-generated";
515
528
  function auditSection(audit, report) {
@@ -525,7 +538,7 @@ function auditSection(audit, report) {
525
538
  .join("") +
526
539
  "</ul>"
527
540
  : '<p class="brief-empty">No queen actions recorded yet.</p>';
528
- return `<section class="card c-audit"><div class="card-head"><h2>Recent queen actions</h2><span class="card-sub">the newest writes the queen made into other projects</span></div>${body}</section>`;
541
+ return `<details class="card c-audit" id="queen-recent-actions" open><summary class="card-head"><h2>Recent queen actions</h2><span class="card-sub">the newest writes the queen made into other projects</span>${chevron()}</summary>${body}</details>`;
529
542
  }
530
543
  export function renderQueenDashboard(report, briefState, href = (p) => dashboardHref(p.root), audit = [], queenHomeId = null) {
531
544
  const asOf = report.as_of;
@@ -552,22 +565,26 @@ export function renderQueenDashboard(report, briefState, href = (p) => dashboard
552
565
  `<span class="count">${report.totals.lanes[lane]}</span></h2></div>` +
553
566
  `<p class="lane-rule">${LANE_RULE[lane]}</p>${body}</section>`);
554
567
  }).join("");
568
+ const home = queenHomeId === null
569
+ ? ""
570
+ : `<div class="home"><div class="home-head"><h2>Queen home project</h2></div>${renderBoardPadsTodos(queenHomeId)}</div>`;
555
571
  const main = n === 0
556
- ? `${briefSection(briefState, report, href, queenHomeId)}<section class="card empty"><h2>No projects registered</h2><p>Run <code>hive lead</code> in a project folder to register it, and it will appear here.</p></section>`
572
+ ? `${briefSection(briefState, report, href, queenHomeId)}<section class="card no-projects"><h2>No projects registered</h2><p>Run <code>hive lead</code> in a project folder to register it, and it will appear here.</p></section>`
557
573
  : `${briefSection(briefState, report, href, queenHomeId)}<div class="lanes">${lanes}</div>` +
558
- `<section class="card d-grid"><div class="card-head"><h2>Every project <span class="count">${n}</span></h2>` +
559
- '<span class="card-sub">in lane order; a name opens that project’s dashboard</span></div>' +
574
+ `<details class="card d-grid" id="queen-every-project" open><summary class="card-head"><h2>Every project <span class="count">${n}</span></h2>` +
575
+ `<span class="card-sub">in lane order; a name opens that project’s dashboard</span>${chevron()}</summary>` +
560
576
  `<div class="grid" role="table">${GRID_HEAD}${ordered.map((p) => gridRow(p, asOf, href)).join("")}</div>` +
561
- '<p class="freshnote">Every row here is read from the store when the page is written. Only the picks are written by a model, which is why they carry a time.</p></section>' +
562
- auditSection(audit, report);
577
+ '<p class="freshnote">Every row here is read from the store when the page is written. Only the picks are written by a model, which is why they carry a time.</p></details>';
578
+ const page = main + home + (n === 0 ? "" : auditSection(audit, report));
563
579
  return ('<!doctype html><html lang="en"><head><meta charset="utf-8">' +
564
580
  '<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">' +
565
581
  `<title>All projects</title><style>${CSS}</style></head><body>` +
582
+ SPRITE +
566
583
  `<svg style="position:absolute;width:0;height:0" aria-hidden="true"><defs>${MARK}</defs></svg>` +
567
- '<header class="topbar"><div class="wrap topbar-inner"><div class="brand">' +
584
+ '<header class="topbar" id="topbar"><div class="wrap topbar-inner"><div class="brand">' +
568
585
  '<svg class="mark" role="img" aria-label="hive"><use href="#mark"/></svg><div><h1>All projects</h1>' +
569
586
  `<p class="generated" ${QUEEN_GENERATED_MARKER}>Live rows as of ${esc(asOf)} UTC. ${n} project${n === 1 ? "" : "s"} on this machine.</p></div></div>` +
570
587
  '<label class="live-toggle" title="Off stops this page reloading. The scheduler still rewrites the file.">' +
571
588
  '<input type="checkbox" id="autoreload"> Reload every minute</label></div></header>' +
572
- `<main class="wrap">${main}</main><script>${SCRIPT}</script></body></html>\n`);
589
+ `<main class="wrap">${page}</main><script>${SCRIPT}\n${SECTIONS_SCRIPT}</script></body></html>\n`);
573
590
  }
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())
@@ -171,11 +191,11 @@ export function launchAgent(spec) {
171
191
  let info;
172
192
  try {
173
193
  info = db
174
- .prepare("INSERT INTO agents (project_id, name, command, cwd, kind, parent_actor_id, tmux_socket, session_id, codex_home, model, extra_args) " +
175
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
194
+ .prepare("INSERT INTO agents (project_id, name, command, cwd, kind, parent_actor_id, tmux_socket, session_id, codex_home, model, extra_args, todo_id) " +
195
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
176
196
  .run(spec.projectId, spec.name, typeof spec.commandString === "string" ? spec.commandString : "", spec.cwd, spec.kind, spec.parentActor, socket, spec.sessionId ?? "", spec.codexHome ?? "", spec.model ?? null, spec.readOnly
177
197
  ? JSON.stringify({ args: spec.extraArgs ?? [], read_only: true })
178
- : spec.extraArgs ? JSON.stringify(spec.extraArgs) : null);
198
+ : spec.extraArgs ? JSON.stringify(spec.extraArgs) : null, spec.todoId ?? null);
179
199
  }
180
200
  catch (e) {
181
201
  throw asNameClash(e, spec.name);
@@ -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 [
@@ -17,7 +17,8 @@ import { markGoneReported } from "../scheduler.js";
17
17
  import { agentIdentityEnv, branchAt, closeAgentRow, currentOwnership, discardOrphanedPane, isReservedAgentName, isRunningLeadActor, killAgentPane, launchAgent, LEAD_KIND, PaneOwnershipLost, paneIdentity, parkAgentRow, reapCodexHomeForClosedAgent, requireStillOwned, releaseParkRow, renameAgent, resumeAgent, storedLaunchOptions, } from "../spawn.js";
18
18
  import { COMMAND_KIND, runningCommandRow, stopLine, stopProcess, STOP_REASONS } from "../processes.js";
19
19
  import { readContextTokens, readContextFill, resolveTranscriptDir } from "../transcript.js";
20
- 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";
20
+ import { leadSessionEnded } from "../leadState.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";
21
22
  import { agentIdParam, agentNameParam, idParam, projectIdParam } from "./params.js";
22
23
  import { deriveProvenance, lastLogEvent, lastPermissionMode, reportsAgentStateLog, } from "../stateProvenance.js";
23
24
  const CLOSED_ROW_ORDER = "(parked_at != '') DESC, closed_at DESC, id DESC";
@@ -357,7 +358,7 @@ function paneField(row, alive) {
357
358
  return { pane: describePaneChoice(awaitingChoice) };
358
359
  }
359
360
  function agentSummary(row, snapshot) {
360
- const alive = summaryLiveness(row, snapshot);
361
+ const { alive, dormant } = row.kind === LEAD_KIND ? leadSummaryLiveness(row, snapshot) : { alive: summaryLiveness(row, snapshot), dormant: null };
361
362
  const { state, ...provenance } = deriveProvenance(row, alive);
362
363
  return {
363
364
  agent_id: row.id,
@@ -378,7 +379,9 @@ function agentSummary(row, snapshot) {
378
379
  command: row.command,
379
380
  cwd: row.cwd,
380
381
  parent_actor_id: row.parent_actor_id,
382
+ todo_id: row.todo_id,
381
383
  created_at: row.created_at,
384
+ ...(dormant ? { dormant_since: dormant.ended_at } : {}),
382
385
  };
383
386
  }
384
387
  export function contextCheckpointEnv(config) {
@@ -419,6 +422,9 @@ export function registerAgents(server) {
419
422
  .describe("Spawn a known harness by name (e.g. \"codex\") instead of a raw command. Ignored when command is also given. Must be in the project's hive.yml agents: list (default: claude only)."),
420
423
  extra_args: z.array(z.string()).optional().describe("Extra CLI arguments."),
421
424
  read_only: z.boolean().optional().describe("Prevent local file writes and mutating shell commands while allowing Hive MCP tools. Only bare claude/codex executables. Extra args allow codex -c model_reasoning_effort=low|medium|high|xhigh, or claude --effort low|medium|high|xhigh|max; all other arguments are refused."),
425
+ todo_id: idParam
426
+ .optional()
427
+ .describe("The todo this worker is on, recorded as a link (a non-archived todo in the same project). Changes no todo status."),
422
428
  cwd: z
423
429
  .string()
424
430
  .optional()
@@ -468,6 +474,20 @@ export function registerAgents(server) {
468
474
  throw new Error(`cwd ${cwd} belongs to project "${cwdProject.name}" (id ${cwdProject.id}), but this spawn resolved to project "${project.name}" (id ${project.id}). ${remedy}`);
469
475
  }
470
476
  }
477
+ if (args.todo_id !== undefined) {
478
+ const linked = db
479
+ .prepare("SELECT archived_at FROM todos WHERE id = ? AND project_id = ?")
480
+ .get(args.todo_id, project.id);
481
+ if (!linked)
482
+ throw new Error(`todo_id ${args.todo_id} is not a todo in project "${project.name}" (id ${project.id}).`);
483
+ if (linked.archived_at != null)
484
+ throw new Error(`todo_id ${args.todo_id} is archived; unarchive it or link another todo.`);
485
+ }
486
+ const omittedTodoWarning = args.todo_id === undefined && db
487
+ .prepare("SELECT 1 FROM todos WHERE project_id = ? AND status = 'in_progress' AND archived_at IS NULL LIMIT 1")
488
+ .get(project.id)
489
+ ? "todo_id was omitted while this project has an in-progress todo; the crew view may infer a link from this worker's comments or pad updates."
490
+ : undefined;
471
491
  const name = args.name != null
472
492
  ? normalizeAgentName(args.name, "name")
473
493
  : nextWorkerName(project.id);
@@ -566,6 +586,7 @@ export function registerAgents(server) {
566
586
  model: args.model,
567
587
  extraArgs: args.extra_args,
568
588
  readOnly: args.read_only,
589
+ todoId: args.todo_id,
569
590
  retainOnExit: harness.classifiesPaneScreen,
570
591
  });
571
592
  }
@@ -578,51 +599,51 @@ export function registerAgents(server) {
578
599
  rmSync(codexHomeDir(codexHomeKey), { recursive: true, force: true });
579
600
  throw e;
580
601
  }
581
- const { agentId, actorId, target, landedInProjectId, layoutApplied } = spawned;
582
- ensureAttached(sessionName());
602
+ const { agentId, actorId, target, landedInProjectId, layoutApplied, retainedWindow } = spawned;
583
603
  let ready = false;
584
604
  let dialogTail;
585
605
  let exited = false;
586
606
  let exitTail;
587
- if (harness.classifiesPaneScreen) {
588
- const classifier = harness.paneClassifier;
589
- // Captured before anything below can kill the pane: remain-on-exit is a WINDOW option,
590
- // and the pane id itself stops being addressable once discardOrphanedPane has run.
591
- const windowTarget = paneWindow(target) ?? target;
592
- try {
593
- const outcome = await pollPaneReadiness(target, PANE_READY_MS, classifier.hasInputBox);
594
- if (outcome === "gone") {
595
- // remain-on-exit held the pane so its final screen is still readable; capture it,
596
- // persist it onto the row (the pane below is about to be killed), then release it.
597
- // Never run choiceCheck against a retained-but-dead pane: it is frozen content, not
598
- // a live dialog, and choiceCheck's own capture is tuned to read a live screen.
599
- exited = true;
600
- exitTail = captureFinalScreen(target, 30);
601
- db.prepare("UPDATE agents SET exit_tail = ? WHERE id = ?").run(exitTail, agentId);
602
- discardOrphanedPane(target);
603
- }
604
- else {
605
- const { awaitingChoice, tail } = classifier.choiceCheck(target);
606
- if (awaitingChoice === true) {
607
- 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);
608
622
  }
609
- else if (outcome === "ready") {
610
- 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
+ }
611
631
  }
612
632
  }
613
- }
614
- catch {
615
- ready = false;
616
- }
617
- finally {
618
- // Unconditional: every early return, throw, or timeout between arming remain-on-exit
619
- // and here must still clear it, or the shared window (the default split placement's)
620
- // is left silently retaining every later exit in it, not just this one.
621
- setRemainOnExit(windowTarget, false);
633
+ catch {
634
+ ready = false;
635
+ }
622
636
  }
623
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
+ }
624
645
  const codexInstructions = codexHomeKey ? codexInstructionsPhrase(codexInstructionLayers) : undefined;
625
- return {
646
+ const receipt = {
626
647
  agent_id: agentId,
627
648
  ...(args.read_only ? { read_only: true } : {}),
628
649
  actor_id: actorId,
@@ -671,6 +692,11 @@ export function registerAgents(server) {
671
692
  "Wakes aimed at this worker are refused for the same reason.",
672
693
  }),
673
694
  };
695
+ if (omittedTodoWarning) {
696
+ const existingNote = typeof receipt.note === "string" ? receipt.note : "";
697
+ receipt.note = existingNote ? `${existingNote} ${omittedTodoWarning}` : omittedTodoWarning;
698
+ }
699
+ return receipt;
674
700
  }));
675
701
  server.registerTool("agent_resume", {
676
702
  description: "Resume a CLOSED claude or codex worker from its recorded session id (claude --resume / codex resume): a fresh pane, the same actor_id, and the worker's full prior context. Addressed by name or agent_id among closed agents (agent_list(include_closed: true)). Send it its next instruction with agent_send once resumed - this tool does not.",
@@ -1580,3 +1606,8 @@ function readInputBox(classifier, target) {
1580
1606
  const box = classifier.inputBoxState(target);
1581
1607
  return box === null ? null : { box };
1582
1608
  }
1609
+ function leadSummaryLiveness(row, snapshot) {
1610
+ const ownership = row.status === "running" ? rowOwnership(row, snapshot) : null;
1611
+ const alive = ownership === null ? false : ownershipLiveness(ownership);
1612
+ return { alive, dormant: ownership === null ? null : leadSessionEnded(row, ownership) };
1613
+ }