@cmgmyr/hive 1.11.9 → 1.12.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.
@@ -0,0 +1,222 @@
1
+ import { existsSync } from "node:fs";
2
+ import { db } from "./db.js";
3
+ import { getAgentRow } from "./agentRecords.js";
4
+ import { getProject } from "./context.js";
5
+ import { workerCommandString } from "./brief.js";
6
+ import { codexHomeDir, codexLaunchArgs, ensureCodexHooksFile, refreshCodexMcpEnv, } from "./codexHome.js";
7
+ import { agentIdentityEnv, branchAt, LEAD_KIND, resumeAgent, storedLaunchOptions } from "./spawn.js";
8
+ import { ensureWorkerHooksFile } from "./hooks.js";
9
+ import { harnessFor, resolvedCommandPrefix } from "./harnesses.js";
10
+ import { loadProjectYml } from "./projectYml.js";
11
+ import { limitedHarness, observeProjectUsageLimits } from "./usageLimit.js";
12
+ import { DEFAULT_LAYOUT } from "./tmux.js";
13
+ export function refuseLimitedHarness(projectId, harness, action = "agent_spawn") {
14
+ const now = Date.now();
15
+ observeProjectUsageLimits(projectId, harness, now);
16
+ const held = limitedHarness(projectId, harness, now);
17
+ if (held) {
18
+ throw new Error(`[${action}:usage-limit] ${harness} is stopped by a usage limit until ${held.until}. ` +
19
+ `This ${action === "agent_resume" ? "resume" : action === "agent_fork" ? "fork" : "spawn"} was not queued; retry it after that time.`);
20
+ }
21
+ }
22
+ export function contextCheckpointEnv(config) {
23
+ const value = config?.context_checkpoint_percent;
24
+ return value == null ? {} : { HIVE_CONTEXT_CHECKPOINT_PERCENT: String(value) };
25
+ }
26
+ function commandTokens(command) {
27
+ const tokens = [];
28
+ let token = "";
29
+ let quote = null;
30
+ let escaped = false;
31
+ for (const char of command) {
32
+ if (escaped) {
33
+ token += char;
34
+ escaped = false;
35
+ }
36
+ else if (char === "\\" && quote !== "'") {
37
+ escaped = true;
38
+ }
39
+ else if (quote) {
40
+ if (char === quote)
41
+ quote = null;
42
+ else
43
+ token += char;
44
+ }
45
+ else if (char === "'" || char === '"') {
46
+ quote = char;
47
+ }
48
+ else if (/\s/.test(char)) {
49
+ if (token) {
50
+ tokens.push(token);
51
+ token = "";
52
+ }
53
+ }
54
+ else {
55
+ token += char;
56
+ }
57
+ }
58
+ if (escaped)
59
+ token += "\\";
60
+ if (token)
61
+ tokens.push(token);
62
+ return tokens;
63
+ }
64
+ function extractCommandFlag(command, flag) {
65
+ const tokens = commandTokens(command);
66
+ const i = tokens.indexOf(flag);
67
+ return i === -1 || i + 1 >= tokens.length ? undefined : tokens[i + 1];
68
+ }
69
+ function stripSessionArgs(args) {
70
+ const stripped = [];
71
+ for (let i = 0; i < args.length; i++) {
72
+ const arg = args[i];
73
+ if (arg === "--resume" || arg === "--session-id" || arg === "-r") {
74
+ i++;
75
+ }
76
+ else if (arg === "--last" || arg === "--fork-session" || arg.startsWith("--resume=") || arg.startsWith("--session-id=")) {
77
+ continue;
78
+ }
79
+ else {
80
+ stripped.push(arg);
81
+ }
82
+ }
83
+ return stripped;
84
+ }
85
+ export function validateResumeWorker(project, agent, options) {
86
+ const harness = harnessFor(agent.command);
87
+ let stateProblem;
88
+ let blocker;
89
+ if (agent.status !== "closed") {
90
+ stateProblem = `Agent ${agent.id} ("${agent.name}") is not closed (status: ${agent.status}).`;
91
+ }
92
+ else if (agent.kind === LEAD_KIND) {
93
+ stateProblem = `Agent ${agent.id} ("${agent.name}") is this project's lead session. agent_resume is for workers; start a lead session with \`hive lead\`.`;
94
+ }
95
+ else if (options.recoveryOnly && (agent.recovery_state !== "pending" || agent.parked_at)) {
96
+ stateProblem = "row is not marked for crash recovery";
97
+ }
98
+ else if (!harness.supportsResume) {
99
+ blocker =
100
+ `Agent ${agent.id} ("${agent.name}")'s harness ("${harness.name}", command: "${agent.command}") ` +
101
+ "does not support resume, so it has no session id to resume from.";
102
+ }
103
+ else if (!agent.session_id) {
104
+ blocker =
105
+ `Agent ${agent.id} ("${agent.name}") has no recorded session id, so it cannot be resumed. It may ` +
106
+ "predate this feature, or it may have closed before its first hook event ever fired. Spawn a new worker instead.";
107
+ }
108
+ else if (harness.name === "codex" && !agent.codex_home) {
109
+ blocker =
110
+ `Agent ${agent.id} ("${agent.name}") has no recorded CODEX_HOME, so its session cannot be resumed. ` +
111
+ (agent.parked_at
112
+ ? "This should not happen for a parked codex worker - its home is only reaped on agent_close. Check agent_status for how it actually got here."
113
+ : "Its Codex home was released after close, so there is nothing here to resume; spawn a new worker instead.");
114
+ }
115
+ else if (!agent.cwd || !existsSync(agent.cwd)) {
116
+ const recreate = agent.parked_branch
117
+ ? `git worktree add ${agent.cwd} ${agent.parked_branch}`
118
+ : `recreate a checkout at ${agent.cwd || "(empty)"} (no branch was recorded for it - agent_park records one)`;
119
+ const reason = harness.name === "codex" ? "It resumes into that directory as its working directory" : "Its transcript is resolved from that path";
120
+ blocker =
121
+ `Agent ${agent.id} ("${agent.name}")'s working directory is gone: ${agent.cwd || "(empty)"}. ${reason}, ` +
122
+ `so recreate it and the resume works unchanged: ${recreate}`;
123
+ }
124
+ else if (options.recoveryOnly && options.parentActor == null) {
125
+ blocker = "no recorded parent actor for recovery placement";
126
+ }
127
+ else if (options.recoveryOnly && harness.name === "codex" && !existsSync(codexHomeDir(agent.codex_home))) {
128
+ blocker = "Codex home is missing";
129
+ }
130
+ else {
131
+ const running = db
132
+ .prepare("SELECT id, name FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
133
+ .all(project.id);
134
+ const collision = running.find((row) => row.id !== agent.id && row.name.toLowerCase() === agent.name.toLowerCase());
135
+ if (collision) {
136
+ blocker = options.recoveryOnly
137
+ ? "a running worker already uses this name"
138
+ : `Cannot resume agent ${agent.id} ("${agent.name}") under its recorded name: a running agent ` +
139
+ `(agent ${collision.id}) already has it. agent_resume does not rename on your behalf - the ` +
140
+ `${agent.parked_at ? "parked" : "closed"} lane is not lost, it just cannot come back under a name ` +
141
+ "someone else is using. Free the name first (agent_rename or agent_close on the running one), " +
142
+ `then retry agent_resume(agent_id: ${agent.id}).`;
143
+ }
144
+ }
145
+ return { project, agent, harness, stateProblem, blocker };
146
+ }
147
+ export async function resumeWorker(projectId, agentId, options) {
148
+ const project = getProject(projectId);
149
+ if (!project)
150
+ throw new Error(`Project ${projectId} is no longer registered.`);
151
+ const agent = getAgentRow(project.id, agentId, "Call agent_list(include_closed: true).");
152
+ const validation = validateResumeWorker(project, agent, options);
153
+ const resumeHarness = validation.harness;
154
+ if (validation.stateProblem)
155
+ throw new Error(validation.stateProblem);
156
+ refuseLimitedHarness(project.id, validation.harness.name, "agent_resume");
157
+ if (validation.blocker)
158
+ throw new Error(validation.blocker);
159
+ const branchNow = agent.parked_branch ? branchAt(agent.cwd) : "";
160
+ const branchDrift = branchNow && branchNow !== agent.parked_branch
161
+ ? { parked_branch: agent.parked_branch, branch_now: branchNow }
162
+ : undefined;
163
+ const { config: projectConfig } = loadProjectYml(project.path);
164
+ const placement = projectConfig?.placement ?? (process.env.HIVE_SPAWN_PLACEMENT === "window" ? "window" : "split");
165
+ const layout = projectConfig?.layout ?? DEFAULT_LAYOUT;
166
+ const launchOptions = storedLaunchOptions(agent.extra_args);
167
+ let commandString;
168
+ let resumeEnv = contextCheckpointEnv(projectConfig);
169
+ if (resumeHarness.name === "codex") {
170
+ commandString = workerCommandString({
171
+ command: resolvedCommandPrefix(agent.command) || "codex",
172
+ model: agent.model ?? extractCommandFlag(agent.command, "--model"),
173
+ displayName: agent.name,
174
+ extraArgs: [
175
+ ...codexLaunchArgs(agent.cwd, launchOptions.readOnly),
176
+ ...stripSessionArgs(launchOptions.args),
177
+ "resume",
178
+ agent.session_id,
179
+ ],
180
+ });
181
+ resumeEnv = { ...resumeEnv, CODEX_HOME: codexHomeDir(agent.codex_home) };
182
+ ensureCodexHooksFile(agent.codex_home, { includePostToolUse: projectConfig?.context_checkpoint_percent != null });
183
+ refreshCodexMcpEnv(agent.codex_home, agentIdentityEnv(agent.actor_id, agent.name, project.path));
184
+ }
185
+ else {
186
+ commandString = workerCommandString({
187
+ command: resolvedCommandPrefix(agent.command) || "claude",
188
+ displayName: agent.name,
189
+ model: agent.model ?? undefined,
190
+ extraArgs: [...stripSessionArgs(launchOptions.args), "--resume", agent.session_id],
191
+ settingsPath: ensureWorkerHooksFile(agent.id, {
192
+ readOnly: launchOptions.readOnly,
193
+ includePostToolUse: projectConfig?.context_checkpoint_percent != null,
194
+ }),
195
+ });
196
+ }
197
+ const { target, landedInProjectId } = resumeAgent({
198
+ agentId: agent.id,
199
+ actorId: agent.actor_id,
200
+ name: agent.name,
201
+ projectId: project.id,
202
+ projectName: project.name,
203
+ projectPath: project.path,
204
+ cwd: agent.cwd,
205
+ commandString,
206
+ env: resumeEnv,
207
+ placement,
208
+ layout,
209
+ parentActor: options.parentActor,
210
+ recoveryOnly: options.recoveryOnly,
211
+ });
212
+ return {
213
+ agent_id: agent.id,
214
+ actor_id: agent.actor_id,
215
+ name: agent.name,
216
+ tmux_target: target,
217
+ resumed_session_id: agent.session_id,
218
+ ...(agent.parked_at ? { was_parked_at: agent.parked_at } : {}),
219
+ ...(branchDrift ? { branch_drift: branchDrift } : {}),
220
+ ...(landedInProjectId != null ? { landed_in_project: getProject(landedInProjectId)?.name ?? `project ${landedInProjectId}` } : {}),
221
+ };
222
+ }
@@ -1 +1 @@
1
- {"version":"1.11.9","sha":"5d08b61","dirty":false,"build_id":"c6006fe410e3627175581a5b66e7d8fbb68b92f1b2edc49a00ce1c9256bacc30"}
1
+ {"version":"1.12.1","sha":"10a48d4","dirty":false,"build_id":"bf1c5acc24e6752e59a0f10acf0e8d07d633a3cf7709bfb701aa0900028aee56"}
package/dist/cli.js CHANGED
@@ -34,12 +34,15 @@ import { probeSessionInterpreter, reexecTarget, sessionStartVerdict, } from "./s
34
34
  import { backupHealth, backupNow, backupsDir, formatBytes, listSnapshots, previewRestore, restoreSnapshot, totalSizeBytes, } from "./backup.js";
35
35
  import { describeTeardownWindow, readTeardowns, teardownLogPath, } from "./teardown.js";
36
36
  import { asNameClash, buildEnvFlags, closeAgentRow, isReservedAgentName, launchAgent, LEAD_KIND, LEAD_NAME, mintLeadActorId, relayoutAfterPaneLeft, upsertActor, withWindowClaim, } from "./spawn.js";
37
- 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";
37
+ 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, paneWindow, 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, tmuxNotInstalled, tmuxSaysNothingThere, tmuxTimeoutOverride, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, } from "./tmux.js";
38
38
  import { collectCrew, crewStoreProblem } from "./crew.js";
39
+ import { resumeWorker, validateResumeWorker } from "./agentResume.js";
40
+ import { markWorkerLost } from "./workerRecovery.js";
39
41
  import { describeVisibility, formatLocal, processCounts } from "./dashboard.js";
40
42
  import { runningCommandRow, runningCommandRows, snapshotProcesses, stopAllProcesses, stopLine, stopProcess, renderStart, STOP_REASONS, } from "./processes.js";
41
43
  import { activeProfile, agentVarKeys, BUILT_IN_PROJECT_YML, configHash, leadSidebarEnabled, loadProjectYml, mergedProjectVars, NO_PROFILE, PROJECT_YML_KEYS, quietMessagingEnabled, resolveCommandDir, resolveFirstMessage, } from "./projectYml.js";
42
44
  import { appendClaudeLeadArgs, crewPluginDir, isTrusted, leadIdentityEnv, renderLeadPosture } from "./leadLaunch.js";
45
+ import { completionGateTrust } from "./todoCompletionGate.js";
43
46
  import { handoffStatusSegment } from "./leadHandoff.js";
44
47
  import { cmdLeadHandoff, runHandoffGrace, runHandoffStart } from "./leadHandoffCli.js";
45
48
  import { harnessFor, hasTranscriptSignal, paneClassifierFor, transcriptDirFor } from "./harnesses.js";
@@ -75,6 +78,9 @@ Usage:
75
78
  hive stop <process> [path] stop one running process; --all stops every one
76
79
  hive show <process> [path] move a running process's pane beside the lead
77
80
  hive hide <process> [path] move it back into the <project>/processes window
81
+ hive layout gather this project's live workers and restore its configured layout
82
+ hive crew-restore [--dry-run] [--json]
83
+ resume this project's workers marked for crash recovery
78
84
  hive status overview of agents, todos, and wake-ups everywhere
79
85
  hive crew --json this project's workers, todos and wakes as one JSON
80
86
  snapshot; read-only, writes nothing
@@ -411,6 +417,12 @@ async function cmdLead(argv) {
411
417
  if (!untrusted && config) {
412
418
  untrusted = Object.entries(config.processes).find(([name, proc]) => proc.auto_start !== false && !isTrusted(project.id, name, proc.command, proc.dir, proc.env))?.[0];
413
419
  }
420
+ if (!untrusted && config?.completion_gate) {
421
+ const gateTrust = completionGateTrust(config.completion_gate);
422
+ if (!isTrusted(project.id, gateTrust.name, gateTrust.command, gateTrust.dir, gateTrust.env)) {
423
+ untrusted = "completion gate";
424
+ }
425
+ }
414
426
  if (untrusted) {
415
427
  console.error(`hive lead: "${untrusted}" is not trusted; run hive lead <path> interactively once`);
416
428
  process.exitCode = 1;
@@ -434,6 +446,10 @@ async function cmdLead(argv) {
434
446
  console.log("Using the default claude lead instead.");
435
447
  }
436
448
  }
449
+ if (config?.completion_gate) {
450
+ const gateTrust = completionGateTrust(config.completion_gate);
451
+ await ensureTrusted(project.id, gateTrust.name, gateTrust.command, gateTrust.dir, gateTrust.env);
452
+ }
437
453
  const leadHarness = harnessFor(leadCommand);
438
454
  let firstMessage = resolveFirstMessage(config, sources).message;
439
455
  if (firstMessage !== "" && !(await (await import("./kickoff.js")).kickoffGate(project.path)).ok)
@@ -748,6 +764,7 @@ const HIVE_YML_TEMPLATE = `# hive project config. Read by \`hive lead\` from the
748
764
  # first_message: "..." # a lead's first turn; absent or "" sends none
749
765
 
750
766
  # context_checkpoint_percent: null # unset means off; integer 1-100 to enable
767
+ # completion_gate: null # optional project todo check; null keeps it disabled
751
768
 
752
769
  # lead_turn_budget: {warn: 300, stop: 600} # optional thresholds; auto_handoff defaults on
753
770
 
@@ -1612,6 +1629,108 @@ function cmdHide(argv) {
1612
1629
  return `${name}: hidden`;
1613
1630
  }));
1614
1631
  }
1632
+ function cmdLayout(argv) {
1633
+ const parsed = parseArgs(argv, {});
1634
+ rejectUnknownFlags("layout", parsed, "none");
1635
+ if (parsed.positional.length > 0) {
1636
+ console.error("Usage: hive layout");
1637
+ process.exit(1);
1638
+ }
1639
+ const project = resolveProjectForRead("layout");
1640
+ const { config } = loadProjectYml(project.path);
1641
+ if (config?.placement === "window") {
1642
+ console.log("placement is window in hive.yml; nothing to gather");
1643
+ return;
1644
+ }
1645
+ const layout = config?.layout ?? DEFAULT_LAYOUT;
1646
+ const session = sessionName();
1647
+ const result = withWindowClaim(() => {
1648
+ const snapshot = liveTargets();
1649
+ if (observationFailed(snapshot)) {
1650
+ return { lines: ["tmux could not be probed; nothing was changed"], exitCode: 1 };
1651
+ }
1652
+ const windows = projectWindows(session, project.id);
1653
+ if (windows === null) {
1654
+ return { lines: ["tmux could not be probed; nothing was changed"], exitCode: 1 };
1655
+ }
1656
+ if (!windows.project) {
1657
+ return { lines: ["this project has no window yet; run hive first"], exitCode: 1 };
1658
+ }
1659
+ const lead = db
1660
+ .prepare("SELECT tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id LIMIT 1")
1661
+ .get(project.id, LEAD_KIND);
1662
+ if (!lead || rowOwnership(lead, snapshot) !== "live") {
1663
+ return { lines: ["the lead pane could not be verified; nothing was changed"], exitCode: 1 };
1664
+ }
1665
+ const leadWindow = paneWindow(lead.tmux_target);
1666
+ if (!leadWindow || leadWindow.split(":")[1] !== windows.project.split(":")[1]) {
1667
+ return {
1668
+ lines: [`the lead pane is not in this project's window (${leadWindow ?? "unknown"} vs ${windows.project}); nothing was changed`],
1669
+ exitCode: 1,
1670
+ };
1671
+ }
1672
+ const ownedWindows = listOwnedWindows(session);
1673
+ const workers = db
1674
+ .prepare("SELECT id, name, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND kind = 'agent' AND status = 'running' ORDER BY id")
1675
+ .all(project.id);
1676
+ const moved = [];
1677
+ const sourceWindows = new Set();
1678
+ for (const worker of workers) {
1679
+ if (rowOwnership(worker, snapshot) !== "live")
1680
+ continue;
1681
+ const source = paneWindow(worker.tmux_target);
1682
+ if (!source || source.split(":")[1] === windows.project.split(":")[1])
1683
+ continue;
1684
+ const sourceId = source.split(":")[1];
1685
+ const stamped = ownedWindows.find((window) => window.window.split(":")[1] === sourceId);
1686
+ if (stamped &&
1687
+ ((stamped.projectId !== "" && Number(stamped.projectId) !== project.id) ||
1688
+ (stamped.processesOf !== "" && Number(stamped.processesOf) !== project.id))) {
1689
+ continue;
1690
+ }
1691
+ tmux("join-pane", "-d", "-s", worker.tmux_target, "-t", windows.project);
1692
+ sourceWindows.add(source);
1693
+ moved.push({ name: worker.name, pane: worker.tmux_target });
1694
+ }
1695
+ const leadPanes = tmux("list-panes", "-t", windows.project, "-F", "#{pane_id}").split("\n").filter(Boolean);
1696
+ if (leadPanes[0] !== lead.tmux_target) {
1697
+ tmux("swap-pane", "-s", lead.tmux_target, "-t", leadPanes[0]);
1698
+ }
1699
+ applyLayout(windows.project, layout);
1700
+ const layoutApplied = windowLayout(windows.project) === layout;
1701
+ const projectRunningRows = db
1702
+ .prepare("SELECT tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND status = 'running' AND tmux_target != ''")
1703
+ .all(project.id);
1704
+ const projectRunningTargets = new Set(projectRunningRows.filter((row) => rowOwnership(row, snapshot) === "live").map((row) => row.tmux_target));
1705
+ const leftWindows = [];
1706
+ for (const source of sourceWindows) {
1707
+ let remaining;
1708
+ try {
1709
+ remaining = tmux("list-panes", "-t", source, "-F", "#{pane_id}").split("\n").filter(Boolean);
1710
+ }
1711
+ catch (error) {
1712
+ if (tmuxSaysNothingThere(error))
1713
+ continue;
1714
+ throw error;
1715
+ }
1716
+ if (remaining.some((pane) => !projectRunningTargets.has(pane)))
1717
+ leftWindows.push(source);
1718
+ }
1719
+ const lines = moved.map(({ name, pane }) => `moved ${name} pane ${pane} into ${windows.project}`);
1720
+ if (leftWindows.length > 0)
1721
+ lines.push(`left windows with panes remaining: ${leftWindows.join(", ")}`);
1722
+ if (moved.length === 0)
1723
+ lines.push("nothing to move");
1724
+ if (!layoutApplied) {
1725
+ lines.push(`layout ${layout} could not be applied; ${moved.length > 0 ? "panes were moved" : "no panes were moved"}`);
1726
+ return { lines, exitCode: 1 };
1727
+ }
1728
+ lines.push(`moved ${moved.length} pane${moved.length === 1 ? "" : "s"} into ${windows.project}; layout ${layout}`);
1729
+ return { lines, exitCode: 0 };
1730
+ });
1731
+ console.log(result.lines.join("\n"));
1732
+ process.exitCode = result.exitCode;
1733
+ }
1615
1734
  const LEAD_OWNERSHIP_LABEL = {
1616
1735
  live: "running",
1617
1736
  gone: "no live pane",
@@ -2355,6 +2474,17 @@ function cmdDoctor(argv) {
2355
2474
  const override = tmuxTimeoutOverride();
2356
2475
  return override === null ? version : `${version} (HIVE_TMUX_TIMEOUT_MS=${override}ms override; testing only)`;
2357
2476
  });
2477
+ try {
2478
+ const serverEnv = tmux("show-environment", "-g", "NO_COLOR");
2479
+ if (serverEnv.startsWith("NO_COLOR=")) {
2480
+ warn("tmux NO_COLOR", "Claude Code drops the dim styling on placeholders and suggestions, so hive reads them as typed input; agent_send refuses and wakes hold.", "clear it with `tmux set-environment -g -u NO_COLOR`, then restart affected workers");
2481
+ }
2482
+ }
2483
+ catch (e) {
2484
+ if (!tmuxNotInstalled(e) && tmuxSaysNothingThere(e) && process.env.NO_COLOR !== undefined) {
2485
+ warn("tmux NO_COLOR", "NO_COLOR is set in this shell; a tmux server started here would pass it to every worker, causing Claude Code placeholders and suggestions to read as typed input; agent_send refuses and wakes hold.", "unset NO_COLOR before starting tmux, or clear it with `tmux set-environment -g -u NO_COLOR` on a running server, then restart affected workers");
2486
+ }
2487
+ }
2358
2488
  const orphans = orphanScratchServers();
2359
2489
  reportPtyHeadroom(orphans);
2360
2490
  reportOrphanTmuxServers(orphans);
@@ -3004,6 +3134,144 @@ function cmdCrew(argv) {
3004
3134
  }
3005
3135
  console.log(JSON.stringify(collectCrew(project, new Date())));
3006
3136
  }
3137
+ async function cmdCrewRestore(argv) {
3138
+ const parsed = parseArgs(argv, { flags: ["--dry-run", "--json"] });
3139
+ if (parsed.positional.length > 0)
3140
+ parsed.unknown.push(parsed.positional[0]);
3141
+ rejectUnknownFlags("crew-restore", parsed, "--dry-run and --json");
3142
+ const dryRun = parsed.flags.has("--dry-run");
3143
+ let project;
3144
+ try {
3145
+ project = pinnedOrCwdProject();
3146
+ }
3147
+ catch (e) {
3148
+ console.error(`hive crew-restore: ${errorMessage(e)}`);
3149
+ process.exitCode = 1;
3150
+ return;
3151
+ }
3152
+ if (!project) {
3153
+ console.error("hive crew-restore: this directory is not a registered hive project.");
3154
+ process.exitCode = 1;
3155
+ return;
3156
+ }
3157
+ const snapshot = liveTargets();
3158
+ const workers = db
3159
+ .prepare("SELECT * FROM agents WHERE project_id = ? AND kind = 'agent' ORDER BY id")
3160
+ .all(project.id);
3161
+ const candidates = workers.filter((worker) => {
3162
+ if (worker.parked_at)
3163
+ return false;
3164
+ if (worker.status === "closed")
3165
+ return worker.recovery_state === "pending";
3166
+ return worker.status === "running";
3167
+ });
3168
+ const notConsidered = workers.length - candidates.length;
3169
+ const rows = [];
3170
+ for (const initial of candidates) {
3171
+ let candidate = initial;
3172
+ if (candidate.status === "running") {
3173
+ if (!candidate.tmux_target) {
3174
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "skipped", reason: "resume is already in flight or was interrupted before pane publication; inspect manually" });
3175
+ continue;
3176
+ }
3177
+ const ownership = rowOwnership(candidate, snapshot);
3178
+ if (ownership === "live") {
3179
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "skipped", reason: "the recorded pane is still live" });
3180
+ continue;
3181
+ }
3182
+ if (ownership === "unknown") {
3183
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "skipped", reason: snapshot === null ? "tmux ownership could not be checked" : "pane ownership is unknown" });
3184
+ continue;
3185
+ }
3186
+ if (ownership !== "gone" && ownership !== "reissued") {
3187
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "skipped", reason: "the row changed while it was inspected" });
3188
+ continue;
3189
+ }
3190
+ if (!dryRun) {
3191
+ const expected = {
3192
+ tmux_target: candidate.tmux_target,
3193
+ tmux_socket: candidate.tmux_socket,
3194
+ pane_pid: candidate.pane_pid,
3195
+ };
3196
+ if (!markWorkerLost(candidate.id, expected)) {
3197
+ const latest = db.prepare("SELECT * FROM agents WHERE id = ? AND project_id = ?").get(candidate.id, project.id);
3198
+ if (!latest || latest.status !== "closed" || latest.recovery_state !== "pending") {
3199
+ rows.push({ agent_id: initial.id, name: initial.name, outcome: "skipped", reason: "the row changed before crash recovery could be claimed" });
3200
+ continue;
3201
+ }
3202
+ candidate = latest;
3203
+ }
3204
+ else {
3205
+ const latest = db.prepare("SELECT * FROM agents WHERE id = ? AND project_id = ?").get(candidate.id, project.id);
3206
+ if (!latest) {
3207
+ rows.push({ agent_id: initial.id, name: initial.name, outcome: "skipped", reason: "the row was removed during crash recovery" });
3208
+ continue;
3209
+ }
3210
+ candidate = latest;
3211
+ }
3212
+ }
3213
+ else {
3214
+ candidate = { ...candidate, status: "closed", recovery_state: "pending" };
3215
+ }
3216
+ }
3217
+ if (candidate.parked_at) {
3218
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "skipped", reason: "parked workers are not crash recoveries" });
3219
+ continue;
3220
+ }
3221
+ if (candidate.status !== "closed" || candidate.recovery_state !== "pending") {
3222
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "skipped", reason: "row is not marked for crash recovery" });
3223
+ continue;
3224
+ }
3225
+ const validation = validateResumeWorker(project, candidate, {
3226
+ parentActor: candidate.parent_actor_id,
3227
+ recoveryOnly: true,
3228
+ });
3229
+ candidate = validation.agent;
3230
+ if (validation.stateProblem) {
3231
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "skipped", reason: "the row changed before recovery validation" });
3232
+ continue;
3233
+ }
3234
+ if (validation.blocker) {
3235
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: dryRun ? "skipped" : "failed", reason: validation.blocker });
3236
+ continue;
3237
+ }
3238
+ if (dryRun) {
3239
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "would_restore" });
3240
+ continue;
3241
+ }
3242
+ try {
3243
+ const receipt = await resumeWorker(project.id, candidate.id, {
3244
+ parentActor: candidate.parent_actor_id,
3245
+ recoveryOnly: true,
3246
+ });
3247
+ rows.push({ agent_id: receipt.agent_id, name: receipt.name, outcome: "restored", tmux_target: receipt.tmux_target });
3248
+ }
3249
+ catch (e) {
3250
+ const latest = db.prepare("SELECT status, recovery_state FROM agents WHERE id = ? AND project_id = ?").get(candidate.id, project.id);
3251
+ if (latest?.status !== "closed" || latest.recovery_state !== "pending") {
3252
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "skipped", reason: "the row changed before the guarded recovery claim" });
3253
+ }
3254
+ else {
3255
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "failed", reason: errorMessage(e) });
3256
+ }
3257
+ }
3258
+ }
3259
+ if (parsed.flags.has("--json")) {
3260
+ console.log(JSON.stringify({ project_id: project.id, dry_run: dryRun, rows, not_considered: notConsidered }));
3261
+ }
3262
+ else {
3263
+ const counts = { restored: 0, would_restore: 0, skipped: 0, failed: 0 };
3264
+ for (const row of rows) {
3265
+ counts[row.outcome]++;
3266
+ const target = row.tmux_target ? ` ${row.tmux_target}` : "";
3267
+ const reason = row.reason ? `: ${row.reason}` : "";
3268
+ console.log(`${row.outcome.padEnd(13)} agent ${row.agent_id} ${row.name}${target}${reason}`);
3269
+ }
3270
+ console.log(`crew-restore: ${counts.restored} restored, ${counts.would_restore} would restore, ${counts.skipped} skipped, ${counts.failed} failed, ${notConsidered} not considered`);
3271
+ }
3272
+ if (rows.some((row) => row.outcome === "failed"))
3273
+ process.exitCode = 1;
3274
+ }
3007
3275
  function cmdPortfolio(argv) {
3008
3276
  const parsed = parseArgs(argv, { flags: ["--json"] });
3009
3277
  if (parsed.positional.length > 0)
@@ -3535,7 +3803,7 @@ if (command === "--version" || command === "-v") {
3535
3803
  process.exit(0);
3536
3804
  }
3537
3805
  const COMMANDS = [
3538
- "lead", "queen", "queen-audit", "init", "attach", "start", "stop", "show", "hide", "status", "portfolio", "crew", "next", "setup", "upgrade", "doctor",
3806
+ "lead", "queen", "queen-audit", "init", "attach", "start", "stop", "show", "hide", "layout", "status", "portfolio", "crew", "crew-restore", "next", "setup", "upgrade", "doctor",
3539
3807
  LEAD_PANE_EXITED_VERB,
3540
3808
  LEAD_HANDOFF_GRACE_VERB,
3541
3809
  LEAD_HANDOFF_START_VERB,
@@ -3595,6 +3863,9 @@ try {
3595
3863
  case "hide":
3596
3864
  cmdHide(rest);
3597
3865
  break;
3866
+ case "layout":
3867
+ cmdLayout(rest);
3868
+ break;
3598
3869
  case "status":
3599
3870
  rejectUnknownFlags("status", parseArgs(rest, {}), "none");
3600
3871
  cmdStatus();
@@ -3605,6 +3876,9 @@ try {
3605
3876
  case "crew":
3606
3877
  cmdCrew(rest);
3607
3878
  break;
3879
+ case "crew-restore":
3880
+ await cmdCrewRestore(rest);
3881
+ break;
3608
3882
  case "queen-audit":
3609
3883
  cmdQueenAudit(rest);
3610
3884
  break;