@cmgmyr/hive 1.11.8 → 1.12.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/README.md CHANGED
@@ -57,7 +57,7 @@ hive is a personal daily-driver tool, released low-key. It is single-user by des
57
57
 
58
58
  | Page | What's there |
59
59
  |---|---|
60
- | [Architecture](docs/architecture.md) | Seven diagrams: process topology, module layering, spawn sequence, wake lifecycle, worker state, project scoping, store and server identity |
60
+ | [Architecture](docs/architecture.md) | Eight diagrams: process topology, module layering, spawn sequence, wake lifecycle, worker state (with the lead handoff), project scoping, store and server identity |
61
61
  | [Patterns](docs/patterns.md) | Standing trades, refused approaches, evidence standards, and guard shapes distilled from the project's own decisions and dead-ends |
62
62
  | [Concepts](docs/concepts.md) | Vocabulary, why not subagents, identity, the workflow, project scope, the shared store |
63
63
  | [Daily driver](docs/daily-driver.md) | A day with hive, starting a session, watching workers, wake-ups |
@@ -51,4 +51,4 @@ path is ever written into a committed file.
51
51
 
52
52
  ## The crew mod
53
53
 
54
- `crew/` is a second, separate plugin (`hive-crew`) that `hive lead` loads with `--plugin-dir` only when `lead_sidebar: true` is set and the lead is Claude. It is not part of the symlink install above and workers never load it. See [docs/crew.md](../docs/crew.md).
54
+ `crew/` is a second, separate plugin (`hive-crew`) that `hive lead` loads with `--plugin-dir` for Claude leads by default. Set `lead_sidebar: false` to disable it. It is not part of the symlink install above and workers never load it. See [docs/crew.md](../docs/crew.md).
@@ -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.8","sha":"500220b","dirty":false,"build_id":"1286494bf19194350611bfbf75688ec5a3c7e4b033b7c8dc84a101f57528dcbb"}
1
+ {"version":"1.12.0","sha":"aea4e1b","dirty":false,"build_id":"29032035781c51496a882c8bca3ea8edc17cab379acdac96b255751bf5bd67a1"}
@@ -1,7 +1,9 @@
1
1
  import { createConnection } from "node:net";
2
2
  import { join } from "node:path";
3
- import { db } from "./db.js";
4
- import { loadProjectYml } from "./projectYml.js";
3
+ import { dataDir, db } from "./db.js";
4
+ import { harnessFor } from "./harnesses.js";
5
+ import { loadProjectYml, quietMessagingEnabled } from "./projectYml.js";
6
+ import { shellQuote } from "./tmux.js";
5
7
  export { DELIVERY_METHOD } from "./wakeSql.js";
6
8
  export const CROSS_SESSION_CLOSE = "</cross-session-message>";
7
9
  export const ENVELOPE_OPEN = '<cross-session-message from="';
@@ -23,7 +25,12 @@ function claudeWakeFrame(sender, text) {
23
25
  }
24
26
  export function quietMessagingOn(projectId) {
25
27
  const project = db.prepare("SELECT path FROM projects WHERE id = ?").get(projectId);
26
- return project !== undefined && loadProjectYml(project.path).config?.quiet_messaging === true;
28
+ return project !== undefined && quietMessagingEnabled(loadProjectYml(project.path).config);
29
+ }
30
+ export function quietMessagingHooksLaunched(projectId, command) {
31
+ const path = join(dataDir, `lead-${projectId}-hooks.json`);
32
+ const args = harnessFor("claude").briefDelivery?.settingsArgs(path);
33
+ return args !== undefined && command.includes(args.map(shellQuote).join(" "));
27
34
  }
28
35
  export function promptMarkerEvidenceSql(markerAt) {
29
36
  const evPrompt = "(CASE WHEN json_valid(ev.payload) THEN COALESCE(json_extract(ev.payload, '$.prompt'), '') ELSE ev.payload END)";
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, 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
- import { activeProfile, agentVarKeys, BUILT_IN_PROJECT_YML, configHash, loadProjectYml, mergedProjectVars, NO_PROFILE, PROJECT_YML_KEYS, resolveCommandDir, resolveFirstMessage, } from "./projectYml.js";
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;
@@ -421,7 +433,7 @@ async function cmdLead(argv) {
421
433
  for (const w of warnings)
422
434
  console.log(`! ${w}`);
423
435
  const session = sessionName();
424
- const hooksPath = ensureLeadHooksFile(project.id, config?.quiet_messaging === true);
436
+ const hooksPath = ensureLeadHooksFile(project.id, quietMessagingEnabled(config));
425
437
  let leadCommand = "claude";
426
438
  if (!config) {
427
439
  console.log("No hive.yml here; starting a plain claude lead. Add a hive.yml to define project commands and a custom lead (run hive --help for the format).");
@@ -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)
@@ -464,7 +480,7 @@ async function cmdLead(argv) {
464
480
  projectId: project.id,
465
481
  projectName: project.name,
466
482
  hooksPath,
467
- sidebarPluginDir: config?.lead_sidebar === true && leadHarness.name === "claude" ? crewPluginDir() : null,
483
+ sidebarPluginDir: leadSidebarEnabled(config) && leadHarness.name === "claude" ? crewPluginDir() : null,
468
484
  posture: renderedPosture,
469
485
  firstMessage,
470
486
  }));
@@ -748,14 +764,15 @@ 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
- # lead_turn_budget: {warn: 300, stop: 600} # optional lead statusline thresholds
769
+ # lead_turn_budget: {warn: 300, stop: 600} # optional thresholds; auto_handoff defaults on
753
770
 
754
771
  # dashboard: true # write .hive/dashboard.html (default: false)
755
772
 
756
- # lead_sidebar: true # a crew pane in a claude lead (default: false; restart the lead)
773
+ # lead_sidebar: false # disable the crew pane in a claude lead; restart the lead
757
774
 
758
- # quiet_messaging: true # wakes and worker messages reach a claude lead as one row (default: false; restart the lead)
775
+ # quiet_messaging: false # use typed wakes and worker messages instead of one row; restart the lead
759
776
 
760
777
  # vars: # substituted into the profile runbook
761
778
  # repo: owner/name # {{repo}}
@@ -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",
@@ -3004,6 +3123,144 @@ function cmdCrew(argv) {
3004
3123
  }
3005
3124
  console.log(JSON.stringify(collectCrew(project, new Date())));
3006
3125
  }
3126
+ async function cmdCrewRestore(argv) {
3127
+ const parsed = parseArgs(argv, { flags: ["--dry-run", "--json"] });
3128
+ if (parsed.positional.length > 0)
3129
+ parsed.unknown.push(parsed.positional[0]);
3130
+ rejectUnknownFlags("crew-restore", parsed, "--dry-run and --json");
3131
+ const dryRun = parsed.flags.has("--dry-run");
3132
+ let project;
3133
+ try {
3134
+ project = pinnedOrCwdProject();
3135
+ }
3136
+ catch (e) {
3137
+ console.error(`hive crew-restore: ${errorMessage(e)}`);
3138
+ process.exitCode = 1;
3139
+ return;
3140
+ }
3141
+ if (!project) {
3142
+ console.error("hive crew-restore: this directory is not a registered hive project.");
3143
+ process.exitCode = 1;
3144
+ return;
3145
+ }
3146
+ const snapshot = liveTargets();
3147
+ const workers = db
3148
+ .prepare("SELECT * FROM agents WHERE project_id = ? AND kind = 'agent' ORDER BY id")
3149
+ .all(project.id);
3150
+ const candidates = workers.filter((worker) => {
3151
+ if (worker.parked_at)
3152
+ return false;
3153
+ if (worker.status === "closed")
3154
+ return worker.recovery_state === "pending";
3155
+ return worker.status === "running";
3156
+ });
3157
+ const notConsidered = workers.length - candidates.length;
3158
+ const rows = [];
3159
+ for (const initial of candidates) {
3160
+ let candidate = initial;
3161
+ if (candidate.status === "running") {
3162
+ if (!candidate.tmux_target) {
3163
+ 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" });
3164
+ continue;
3165
+ }
3166
+ const ownership = rowOwnership(candidate, snapshot);
3167
+ if (ownership === "live") {
3168
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "skipped", reason: "the recorded pane is still live" });
3169
+ continue;
3170
+ }
3171
+ if (ownership === "unknown") {
3172
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "skipped", reason: snapshot === null ? "tmux ownership could not be checked" : "pane ownership is unknown" });
3173
+ continue;
3174
+ }
3175
+ if (ownership !== "gone" && ownership !== "reissued") {
3176
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "skipped", reason: "the row changed while it was inspected" });
3177
+ continue;
3178
+ }
3179
+ if (!dryRun) {
3180
+ const expected = {
3181
+ tmux_target: candidate.tmux_target,
3182
+ tmux_socket: candidate.tmux_socket,
3183
+ pane_pid: candidate.pane_pid,
3184
+ };
3185
+ if (!markWorkerLost(candidate.id, expected)) {
3186
+ const latest = db.prepare("SELECT * FROM agents WHERE id = ? AND project_id = ?").get(candidate.id, project.id);
3187
+ if (!latest || latest.status !== "closed" || latest.recovery_state !== "pending") {
3188
+ rows.push({ agent_id: initial.id, name: initial.name, outcome: "skipped", reason: "the row changed before crash recovery could be claimed" });
3189
+ continue;
3190
+ }
3191
+ candidate = latest;
3192
+ }
3193
+ else {
3194
+ const latest = db.prepare("SELECT * FROM agents WHERE id = ? AND project_id = ?").get(candidate.id, project.id);
3195
+ if (!latest) {
3196
+ rows.push({ agent_id: initial.id, name: initial.name, outcome: "skipped", reason: "the row was removed during crash recovery" });
3197
+ continue;
3198
+ }
3199
+ candidate = latest;
3200
+ }
3201
+ }
3202
+ else {
3203
+ candidate = { ...candidate, status: "closed", recovery_state: "pending" };
3204
+ }
3205
+ }
3206
+ if (candidate.parked_at) {
3207
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "skipped", reason: "parked workers are not crash recoveries" });
3208
+ continue;
3209
+ }
3210
+ if (candidate.status !== "closed" || candidate.recovery_state !== "pending") {
3211
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "skipped", reason: "row is not marked for crash recovery" });
3212
+ continue;
3213
+ }
3214
+ const validation = validateResumeWorker(project, candidate, {
3215
+ parentActor: candidate.parent_actor_id,
3216
+ recoveryOnly: true,
3217
+ });
3218
+ candidate = validation.agent;
3219
+ if (validation.stateProblem) {
3220
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "skipped", reason: "the row changed before recovery validation" });
3221
+ continue;
3222
+ }
3223
+ if (validation.blocker) {
3224
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: dryRun ? "skipped" : "failed", reason: validation.blocker });
3225
+ continue;
3226
+ }
3227
+ if (dryRun) {
3228
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "would_restore" });
3229
+ continue;
3230
+ }
3231
+ try {
3232
+ const receipt = await resumeWorker(project.id, candidate.id, {
3233
+ parentActor: candidate.parent_actor_id,
3234
+ recoveryOnly: true,
3235
+ });
3236
+ rows.push({ agent_id: receipt.agent_id, name: receipt.name, outcome: "restored", tmux_target: receipt.tmux_target });
3237
+ }
3238
+ catch (e) {
3239
+ const latest = db.prepare("SELECT status, recovery_state FROM agents WHERE id = ? AND project_id = ?").get(candidate.id, project.id);
3240
+ if (latest?.status !== "closed" || latest.recovery_state !== "pending") {
3241
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "skipped", reason: "the row changed before the guarded recovery claim" });
3242
+ }
3243
+ else {
3244
+ rows.push({ agent_id: candidate.id, name: candidate.name, outcome: "failed", reason: errorMessage(e) });
3245
+ }
3246
+ }
3247
+ }
3248
+ if (parsed.flags.has("--json")) {
3249
+ console.log(JSON.stringify({ project_id: project.id, dry_run: dryRun, rows, not_considered: notConsidered }));
3250
+ }
3251
+ else {
3252
+ const counts = { restored: 0, would_restore: 0, skipped: 0, failed: 0 };
3253
+ for (const row of rows) {
3254
+ counts[row.outcome]++;
3255
+ const target = row.tmux_target ? ` ${row.tmux_target}` : "";
3256
+ const reason = row.reason ? `: ${row.reason}` : "";
3257
+ console.log(`${row.outcome.padEnd(13)} agent ${row.agent_id} ${row.name}${target}${reason}`);
3258
+ }
3259
+ console.log(`crew-restore: ${counts.restored} restored, ${counts.would_restore} would restore, ${counts.skipped} skipped, ${counts.failed} failed, ${notConsidered} not considered`);
3260
+ }
3261
+ if (rows.some((row) => row.outcome === "failed"))
3262
+ process.exitCode = 1;
3263
+ }
3007
3264
  function cmdPortfolio(argv) {
3008
3265
  const parsed = parseArgs(argv, { flags: ["--json"] });
3009
3266
  if (parsed.positional.length > 0)
@@ -3535,7 +3792,7 @@ if (command === "--version" || command === "-v") {
3535
3792
  process.exit(0);
3536
3793
  }
3537
3794
  const COMMANDS = [
3538
- "lead", "queen", "queen-audit", "init", "attach", "start", "stop", "show", "hide", "status", "portfolio", "crew", "next", "setup", "upgrade", "doctor",
3795
+ "lead", "queen", "queen-audit", "init", "attach", "start", "stop", "show", "hide", "layout", "status", "portfolio", "crew", "crew-restore", "next", "setup", "upgrade", "doctor",
3539
3796
  LEAD_PANE_EXITED_VERB,
3540
3797
  LEAD_HANDOFF_GRACE_VERB,
3541
3798
  LEAD_HANDOFF_START_VERB,
@@ -3595,6 +3852,9 @@ try {
3595
3852
  case "hide":
3596
3853
  cmdHide(rest);
3597
3854
  break;
3855
+ case "layout":
3856
+ cmdLayout(rest);
3857
+ break;
3598
3858
  case "status":
3599
3859
  rejectUnknownFlags("status", parseArgs(rest, {}), "none");
3600
3860
  cmdStatus();
@@ -3605,6 +3865,9 @@ try {
3605
3865
  case "crew":
3606
3866
  cmdCrew(rest);
3607
3867
  break;
3868
+ case "crew-restore":
3869
+ await cmdCrewRestore(rest);
3870
+ break;
3608
3871
  case "queen-audit":
3609
3872
  cmdQueenAudit(rest);
3610
3873
  break;