@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.
- package/dist/agentResume.js +222 -0
- package/dist/build-info.json +1 -1
- package/dist/cli.js +276 -2
- package/dist/codexHome.js +105 -1
- package/dist/context.js +1 -0
- package/dist/db.js +25 -0
- package/dist/help.js +39 -5
- package/dist/hook.js +61 -5
- package/dist/hooks.js +1 -0
- package/dist/leadMessageDelivery.js +77 -3
- package/dist/projectYml.js +30 -2
- package/dist/scheduler.js +165 -27
- package/dist/spawn.js +37 -41
- package/dist/tmux.js +25 -2
- package/dist/todoCompletionGate.js +124 -0
- package/dist/tools/agents.js +449 -131
- package/dist/tools/todos.js +84 -9
- package/dist/usageLimit.js +470 -0
- package/dist/wakeSql.js +2 -0
- package/dist/workerRecovery.js +16 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.
|
|
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;
|