@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 +1 -1
- package/claude-plugin/README.md +1 -1
- package/dist/agentResume.js +222 -0
- package/dist/build-info.json +1 -1
- package/dist/claudeWake.js +10 -3
- package/dist/cli.js +271 -8
- package/dist/codexHome.js +105 -1
- package/dist/context.js +1 -0
- package/dist/db.js +25 -0
- package/dist/help.js +42 -6
- package/dist/hook.js +61 -5
- package/dist/hooks.js +1 -0
- package/dist/leadHandoff.js +6 -3
- package/dist/leadHandoffCli.js +3 -3
- package/dist/leadMessageDelivery.js +79 -5
- package/dist/projectYml.js +43 -7
- package/dist/scheduler.js +168 -29
- package/dist/spawn.js +35 -39
- 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
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) |
|
|
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 |
|
package/claude-plugin/README.md
CHANGED
|
@@ -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`
|
|
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
|
+
}
|
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.
|
|
1
|
+
{"version":"1.12.0","sha":"aea4e1b","dirty":false,"build_id":"29032035781c51496a882c8bca3ea8edc17cab379acdac96b255751bf5bd67a1"}
|
package/dist/claudeWake.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
773
|
+
# lead_sidebar: false # disable the crew pane in a claude lead; restart the lead
|
|
757
774
|
|
|
758
|
-
# quiet_messaging:
|
|
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;
|