@cmgmyr/hive 1.10.1 → 1.11.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/README.md +1 -0
- package/claude-plugin/crew/model.mjs +2 -0
- package/dist/build-info.json +1 -1
- package/dist/cli.js +135 -52
- package/dist/context.js +49 -5
- package/dist/crew.js +20 -2
- package/dist/dashboard.js +52 -0
- package/dist/db.js +144 -0
- package/dist/help.js +27 -0
- package/dist/hook.js +32 -2
- package/dist/index.js +2 -0
- package/dist/kickoff.js +29 -6
- package/dist/leadHandoff.js +525 -0
- package/dist/leadHandoffCli.js +295 -0
- package/dist/leadHandoffState.js +96 -0
- package/dist/leadLaunch.js +54 -0
- package/dist/leadMessageDelivery.js +44 -7
- package/dist/leadState.js +98 -17
- package/dist/portfolio.js +7 -1
- package/dist/projectRemove.js +1 -0
- package/dist/projectYml.js +8 -1
- package/dist/queenAudit.js +14 -2
- package/dist/queenDashboard.js +14 -8
- package/dist/questions.js +279 -0
- package/dist/scheduler.js +25 -5
- package/dist/staleServers.js +135 -0
- package/dist/tools/agents.js +2 -2
- package/dist/tools/meta.js +4 -1
- package/dist/tools/queenAudit.js +1 -1
- package/dist/tools/questions.js +88 -0
- package/dist/tools/wakes.js +25 -17
- package/package.json +1 -1
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { openSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { dataDir, db } from "./db.js";
|
|
6
|
+
import { cliPath } from "./dispatcher.js";
|
|
7
|
+
import { FIRST_MESSAGE_SHA_ENV, firstMessageDigest } from "./firstMessage.js";
|
|
8
|
+
import { ensureLeadHooksFile } from "./hooks.js";
|
|
9
|
+
import { appendClaudeLeadArgs, crewPluginDir, isTrusted, leadIdentityEnv, renderLeadPosture } from "./leadLaunch.js";
|
|
10
|
+
import { loadProjectYml } from "./projectYml.js";
|
|
11
|
+
import { buildEnvFlags } from "./spawn.js";
|
|
12
|
+
import { panePidForRecord, rowOwnership, shellQuote, tmux, TmuxTimeoutError } from "./tmux.js";
|
|
13
|
+
import { automationBlocker, failHandoff, missingHeadings, padProblem, readPad, RESPAWN_WAIT_LIMIT_SECONDS, respawnVerdict, runningLeadByActor, sha256, successorPrompt, } from "./leadHandoff.js";
|
|
14
|
+
import { casHandoff, nowSql, olderThan, postponeGrace, quotedPolicy, readHandoff } from "./leadHandoffState.js";
|
|
15
|
+
import { readLeadSafetySnapshot } from "./leadState.js";
|
|
16
|
+
import { readTurnCount } from "./turnCount.js";
|
|
17
|
+
import { harnessFor } from "./harnesses.js";
|
|
18
|
+
const POLL_MS = 1_000;
|
|
19
|
+
const HANDOFF_PAD_SOFT_CAP = 8_000;
|
|
20
|
+
function flags(argv, names) {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (let i = 0; i < argv.length; i++) {
|
|
23
|
+
const name = argv[i].replace(/^--/, "");
|
|
24
|
+
if (!argv[i].startsWith("--") || !names.includes(name) || argv[i + 1] === undefined)
|
|
25
|
+
return `unexpected argument "${argv[i]}"`;
|
|
26
|
+
out[name] = argv[++i];
|
|
27
|
+
}
|
|
28
|
+
const absent = names.filter((n) => out[n] === undefined);
|
|
29
|
+
return absent.length > 0 ? `missing --${absent.join(", --")}` : out;
|
|
30
|
+
}
|
|
31
|
+
const intFlag = (value) => (/^\d+$/.test(value) ? Number(value) : null);
|
|
32
|
+
function runningLeadById(id) {
|
|
33
|
+
return db
|
|
34
|
+
.prepare(`SELECT a.id, a.project_id, a.actor_id, a.command, a.pane_pid, a.tmux_target, a.tmux_socket, p.path, p.name
|
|
35
|
+
FROM agents a JOIN projects p ON p.id = a.project_id WHERE a.id = ? AND a.kind = 'lead' AND a.status = 'running'`)
|
|
36
|
+
.get(id);
|
|
37
|
+
}
|
|
38
|
+
export async function cmdLeadHandoff(argv) {
|
|
39
|
+
const refuse = (why) => {
|
|
40
|
+
console.error(`hive lead-handoff: ${why}. This session stays as it is.`);
|
|
41
|
+
return 1;
|
|
42
|
+
};
|
|
43
|
+
const parsed = flags(argv, ["request", "pad", "revision"]);
|
|
44
|
+
if (typeof parsed === "string")
|
|
45
|
+
return refuse(`${parsed}; usage: hive lead-handoff --request <id> --pad <id> --revision <n>`);
|
|
46
|
+
const [requestId, padId, revision] = [intFlag(parsed.request), intFlag(parsed.pad), intFlag(parsed.revision)];
|
|
47
|
+
if (requestId === null || padId === null || revision === null)
|
|
48
|
+
return refuse("--request, --pad and --revision take whole numbers");
|
|
49
|
+
const actor = process.env.HIVE_AGENT_ID ?? "";
|
|
50
|
+
const lead = process.env.HIVE_LEAD === "1" ? runningLeadByActor(actor) : undefined;
|
|
51
|
+
if (lead === undefined)
|
|
52
|
+
return refuse("only a running lead can hand itself off");
|
|
53
|
+
if ((process.env.TMUX_PANE ?? "") !== lead.tmux_target || rowOwnership(lead) !== "live") {
|
|
54
|
+
return refuse("this is not running in the lead's own recorded pane");
|
|
55
|
+
}
|
|
56
|
+
const row = readHandoff(requestId);
|
|
57
|
+
if (row === null || row.lead_agent_id !== lead.id)
|
|
58
|
+
return refuse(`there is no handoff request #${requestId} for this lead`);
|
|
59
|
+
if (row.state !== "requested")
|
|
60
|
+
return refuse(`handoff #${requestId} is ${row.state}, not waiting for this command`);
|
|
61
|
+
const snapshot = readLeadSafetySnapshot(lead.id);
|
|
62
|
+
if (lead.pane_pid !== row.predecessor_pane_pid || snapshot?.session_id !== row.predecessor_session_id) {
|
|
63
|
+
return refuse(`handoff #${requestId} was requested for a different session of this lead`);
|
|
64
|
+
}
|
|
65
|
+
const pad = readPad(padId);
|
|
66
|
+
const problem = padProblem(pad, lead.project_id, revision);
|
|
67
|
+
if (problem !== null)
|
|
68
|
+
return refuse(problem);
|
|
69
|
+
if (row.pad_id === pad.id && row.pad_revision !== null && pad.revision <= row.pad_revision) {
|
|
70
|
+
return refuse("this attempt was postponed, so refresh the pad (write it again) and pass the new revision");
|
|
71
|
+
}
|
|
72
|
+
const missing = missingHeadings(pad.content);
|
|
73
|
+
if (missing.length > 0)
|
|
74
|
+
return refuse(`the pad is missing these headings, in this order: ${missing.join("; ")}`);
|
|
75
|
+
const inFlight = automationBlocker(lead, null);
|
|
76
|
+
if (inFlight !== null)
|
|
77
|
+
return refuse(`${inFlight}; run this command again in a minute`);
|
|
78
|
+
const token = randomUUID();
|
|
79
|
+
const policy = quotedPolicy(row);
|
|
80
|
+
const claimed = casHandoff(row.id, ["requested"], {
|
|
81
|
+
state: "grace",
|
|
82
|
+
attempt: row.attempt + 1,
|
|
83
|
+
owner_token: token,
|
|
84
|
+
grace_seconds: policy.graceSeconds,
|
|
85
|
+
grace_started_at: nowSql(),
|
|
86
|
+
human_prompt_baseline: snapshot.human_prompt_seq,
|
|
87
|
+
pad_id: pad.id,
|
|
88
|
+
pad_revision: pad.revision,
|
|
89
|
+
pad_length: pad.content.length,
|
|
90
|
+
pad_sha256: sha256(pad.content),
|
|
91
|
+
predecessor_turns: readTurnCount(snapshot.transcript_path),
|
|
92
|
+
hold_since: row.hold_since ?? nowSql(),
|
|
93
|
+
hold_released_at: null,
|
|
94
|
+
}, { attempt: row.attempt });
|
|
95
|
+
if (!claimed)
|
|
96
|
+
return refuse(`handoff #${requestId} changed while this command ran`);
|
|
97
|
+
const attempt = row.attempt + 1;
|
|
98
|
+
try {
|
|
99
|
+
const log = openSync(join(dataDir, `lead-handoff-${row.id}.log`), "a");
|
|
100
|
+
const child = spawn(process.execPath, [cliPath(), "lead-handoff-grace", "--request", String(row.id), "--attempt", String(attempt), "--token", token], { detached: true, stdio: ["ignore", log, log], env: process.env });
|
|
101
|
+
child.unref();
|
|
102
|
+
if (child.pid === undefined)
|
|
103
|
+
throw new Error("no process id");
|
|
104
|
+
casHandoff(row.id, ["grace"], { owner_pid: child.pid }, { attempt, owner_token: token });
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
failHandoff(row.id, `the grace owner could not start: ${e instanceof Error ? e.message : String(e)}`, ["grace"], {
|
|
108
|
+
attempt,
|
|
109
|
+
owner_token: token,
|
|
110
|
+
});
|
|
111
|
+
return refuse("the grace owner could not start, so the handoff failed");
|
|
112
|
+
}
|
|
113
|
+
if (pad.content.length > HANDOFF_PAD_SOFT_CAP) {
|
|
114
|
+
console.log(`! the pad is ${pad.content.length} characters, over the ${HANDOFF_PAD_SOFT_CAP}-character guide; point into the store instead of copying it.`);
|
|
115
|
+
}
|
|
116
|
+
console.log(`Handoff #${row.id} armed (attempt ${attempt}, pad #${pad.id} revision ${pad.revision}). hive replaces this session in ` +
|
|
117
|
+
`${policy.graceSeconds} s if nothing happens; any human input postpones it. End your turn now.`);
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
// Prepared before the destructive claim: a launch that cannot be built leaves the predecessor alive.
|
|
121
|
+
async function successorLaunch(row, lead, attempt, token) {
|
|
122
|
+
const config = loadProjectYml(lead.path).config;
|
|
123
|
+
let command = "claude";
|
|
124
|
+
if (config?.lead) {
|
|
125
|
+
if (!isTrusted(lead.project_id, "lead", config.lead, null, {}))
|
|
126
|
+
throw new Error("the configured lead command is not trusted");
|
|
127
|
+
command = config.lead;
|
|
128
|
+
}
|
|
129
|
+
const harness = harnessFor(command);
|
|
130
|
+
if (harness.name !== "claude" || !harness.briefDelivery)
|
|
131
|
+
throw new Error("the configured lead command is not a Claude lead");
|
|
132
|
+
const firstMessage = successorPrompt(row);
|
|
133
|
+
const built = appendClaudeLeadArgs({
|
|
134
|
+
command,
|
|
135
|
+
harness,
|
|
136
|
+
projectId: lead.project_id,
|
|
137
|
+
projectName: lead.name,
|
|
138
|
+
hooksPath: ensureLeadHooksFile(lead.project_id, config?.quiet_messaging === true),
|
|
139
|
+
sidebarPluginDir: config?.lead_sidebar === true ? crewPluginDir() : null,
|
|
140
|
+
posture: renderLeadPosture(config).rendered,
|
|
141
|
+
firstMessage,
|
|
142
|
+
});
|
|
143
|
+
const bootstrap = [process.execPath, cliPath(), "lead-handoff-start", "--request", String(row.id), "--attempt", String(attempt), "--token", token]
|
|
144
|
+
.map(shellQuote)
|
|
145
|
+
.join(" ");
|
|
146
|
+
return {
|
|
147
|
+
launch: `${bootstrap} && exec ${built.command}${built.promptSuffix}`,
|
|
148
|
+
env: buildEnvFlags({ ...leadIdentityEnv(lead.actor_id), [FIRST_MESSAGE_SHA_ENV]: firstMessageDigest(firstMessage) }),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
152
|
+
// `hive lead-handoff-grace`: the one detached owner of an attempt. It never outlives a changed
|
|
153
|
+
// attempt or token, and after it claims the respawn it never repeats the kill.
|
|
154
|
+
export async function runHandoffGrace(argv) {
|
|
155
|
+
try {
|
|
156
|
+
return await graceLoop(argv);
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
const parsed = flags(argv, ["request", "attempt", "token"]);
|
|
160
|
+
const id = typeof parsed === "string" ? null : intFlag(parsed.request);
|
|
161
|
+
const attempt = typeof parsed === "string" ? null : intFlag(parsed.attempt);
|
|
162
|
+
if (id !== null && attempt !== null && typeof parsed !== "string") {
|
|
163
|
+
failHandoff(id, `the grace owner failed: ${e instanceof Error ? e.message : String(e)}`, ["grace"], {
|
|
164
|
+
attempt,
|
|
165
|
+
owner_token: parsed.token,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function graceLoop(argv) {
|
|
172
|
+
const parsed = flags(argv, ["request", "attempt", "token"]);
|
|
173
|
+
if (typeof parsed === "string")
|
|
174
|
+
return 1;
|
|
175
|
+
const id = intFlag(parsed.request);
|
|
176
|
+
const attempt = intFlag(parsed.attempt);
|
|
177
|
+
const token = parsed.token;
|
|
178
|
+
if (id === null || attempt === null)
|
|
179
|
+
return 1;
|
|
180
|
+
const own = (row) => row !== null && row.state === "grace" && row.attempt === attempt && row.owner_token === token;
|
|
181
|
+
let waitingSince = null;
|
|
182
|
+
for (;;) {
|
|
183
|
+
const row = readHandoff(id);
|
|
184
|
+
if (!own(row))
|
|
185
|
+
return 0;
|
|
186
|
+
const lead = runningLeadById(row.lead_agent_id);
|
|
187
|
+
const snapshot = lead === undefined ? null : readLeadSafetySnapshot(lead.id);
|
|
188
|
+
if (snapshot !== null && row.human_prompt_baseline !== null && snapshot.human_prompt_seq > row.human_prompt_baseline) {
|
|
189
|
+
postponeGrace(row, { attempt: row.attempt, owner_token: token }, "a human prompt arrived during grace", true);
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
if (!olderThan(row.grace_started_at, row.grace_seconds ?? 120)) {
|
|
193
|
+
await sleepMs(POLL_MS);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const budget = lead === undefined ? null : (loadProjectYml(lead.path).config?.lead_turn_budget ?? null);
|
|
197
|
+
const verdict = await respawnVerdict(row, lead, budget);
|
|
198
|
+
if ("fail" in verdict) {
|
|
199
|
+
failHandoff(row.id, verdict.fail, ["grace"], { attempt, owner_token: token });
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
if ("postpone" in verdict) {
|
|
203
|
+
postponeGrace(row, { attempt: row.attempt, owner_token: token }, verdict.postpone, verdict.escalate);
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
if ("wait" in verdict) {
|
|
207
|
+
waitingSince ??= Date.now();
|
|
208
|
+
if (row.blocked_reason !== verdict.wait)
|
|
209
|
+
casHandoff(row.id, ["grace"], { blocked_reason: verdict.wait }, { attempt, owner_token: token });
|
|
210
|
+
if (Date.now() - waitingSince >= RESPAWN_WAIT_LIMIT_SECONDS * 1000) {
|
|
211
|
+
postponeGrace(row, { attempt: row.attempt, owner_token: token }, verdict.wait, false);
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
await sleepMs(POLL_MS);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
let launch;
|
|
218
|
+
try {
|
|
219
|
+
launch = await successorLaunch(row, lead, attempt, token);
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
failHandoff(row.id, `the successor launch could not be prepared: ${e instanceof Error ? e.message : String(e)}`, ["grace"], {
|
|
223
|
+
attempt,
|
|
224
|
+
owner_token: token,
|
|
225
|
+
});
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
if (!casHandoff(row.id, ["grace"], { state: "respawning", owner_pid: process.pid, respawn_claimed_at: nowSql() }, { attempt, owner_token: token }))
|
|
229
|
+
return 0;
|
|
230
|
+
try {
|
|
231
|
+
tmux("respawn-pane", "-k", "-t", row.pane_target, "-c", lead.path, ...launch.env, launch.launch);
|
|
232
|
+
}
|
|
233
|
+
catch (e) {
|
|
234
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
235
|
+
if (e instanceof TmuxTimeoutError) {
|
|
236
|
+
casHandoff(row.id, ["respawning"], { state: "ambiguous", failure: `respawn-pane timed out; the pane's owner is unknown: ${detail}` }, { attempt, owner_token: token });
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
failHandoff(row.id, `respawn-pane refused: ${detail}`, ["respawning"], { attempt, owner_token: token });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// `hive lead-handoff-start`: runs in the respawned pane before Claude. It publishes the successor's
|
|
246
|
+
// pid on the same lead row by CAS against the predecessor, and only then lets the shell exec Claude.
|
|
247
|
+
export async function runHandoffStart(argv) {
|
|
248
|
+
const parsed = flags(argv, ["request", "attempt", "token"]);
|
|
249
|
+
const refuse = (why, id) => {
|
|
250
|
+
if (id !== undefined) {
|
|
251
|
+
casHandoff(id, ["respawning", "ambiguous"], { state: "failed", failure: `the successor bootstrap refused: ${why}`, owner_token: null, owner_pid: null });
|
|
252
|
+
}
|
|
253
|
+
console.error(`hive lead-handoff-start: ${why}. The handoff pad stays active; run hive lead to start a lead.`);
|
|
254
|
+
return 1;
|
|
255
|
+
};
|
|
256
|
+
if (typeof parsed === "string")
|
|
257
|
+
return refuse(parsed);
|
|
258
|
+
const id = intFlag(parsed.request);
|
|
259
|
+
const attempt = intFlag(parsed.attempt);
|
|
260
|
+
if (id === null || attempt === null)
|
|
261
|
+
return refuse("bad arguments");
|
|
262
|
+
const row = readHandoff(id);
|
|
263
|
+
if (row === null || (row.state !== "respawning" && row.state !== "ambiguous") || row.attempt !== attempt || row.owner_token !== parsed.token) {
|
|
264
|
+
return refuse("this bootstrap does not own a respawning handoff");
|
|
265
|
+
}
|
|
266
|
+
const pane = process.env.TMUX_PANE ?? "";
|
|
267
|
+
if (pane !== row.pane_target)
|
|
268
|
+
return refuse("this is not the pane the handoff respawned", id);
|
|
269
|
+
const pid = panePidForRecord(pane);
|
|
270
|
+
if (pid === "" || pid !== String(process.ppid))
|
|
271
|
+
return refuse("the pane's process id does not match this bootstrap's shell", id);
|
|
272
|
+
const publish = db.transaction(() => {
|
|
273
|
+
const moved = db
|
|
274
|
+
.prepare(`UPDATE agents SET pane_pid = ?, claude_messaging_socket = '', claude_messaging_pane_pid = ''
|
|
275
|
+
WHERE id = ? AND kind = 'lead' AND status = 'running' AND tmux_target = ? AND pane_pid = ?`)
|
|
276
|
+
.run(pid, row.lead_agent_id, row.pane_target, row.predecessor_pane_pid).changes;
|
|
277
|
+
if (moved !== 1)
|
|
278
|
+
return false;
|
|
279
|
+
const started = casHandoff(id, ["respawning", "ambiguous"], { state: "started", successor_pane_pid: pid, started_at: nowSql(), owner_token: null, owner_pid: null, failure: null }, { attempt, owner_token: parsed.token });
|
|
280
|
+
if (!started)
|
|
281
|
+
throw new Error("handoff changed");
|
|
282
|
+
return true;
|
|
283
|
+
});
|
|
284
|
+
let published;
|
|
285
|
+
try {
|
|
286
|
+
published = publish.immediate();
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
published = false;
|
|
290
|
+
}
|
|
291
|
+
if (!published)
|
|
292
|
+
return refuse("the lead row could not be moved to the new pane process", id);
|
|
293
|
+
console.log(`hive: handoff #${id}: starting a fresh lead session in this pane.`);
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
export const PRE_RESPAWN_STATES = ["pending", "wind_down", "requested", "grace", "postponed"];
|
|
3
|
+
export const PASS_TIMEOUT_SECONDS = 10 * 60;
|
|
4
|
+
export const STOP_HOLD_CAP_SECONDS = 15 * 60;
|
|
5
|
+
// The fence trigger's ceiling on lead_handoffs.updated_at age (src/db.ts); change both together.
|
|
6
|
+
export const HOLD_CEILING_SECONDS = 3600;
|
|
7
|
+
export const SUCCESSOR_NOT_READY = "the successor has not completed its first turn";
|
|
8
|
+
// The policy the request body quoted, never one recomputed after a warn->stop upgrade.
|
|
9
|
+
export const quotedPolicy = (row) => passPolicy(row.pass, row.request_reason ?? row.reason);
|
|
10
|
+
export function passPolicy(pass, reason) {
|
|
11
|
+
if (reason === "stop" || pass >= 4) {
|
|
12
|
+
return { graceSeconds: 45, humanQuietSeconds: 60, automationQuietSeconds: null, holdsFromRequest: true, requestNeedsQuiet: false };
|
|
13
|
+
}
|
|
14
|
+
if (pass === 3) {
|
|
15
|
+
return { graceSeconds: 60, humanQuietSeconds: 75, automationQuietSeconds: 30, holdsFromRequest: true, requestNeedsQuiet: false };
|
|
16
|
+
}
|
|
17
|
+
if (pass === 2) {
|
|
18
|
+
return { graceSeconds: 90, humanQuietSeconds: 150, automationQuietSeconds: 60, holdsFromRequest: true, requestNeedsQuiet: false };
|
|
19
|
+
}
|
|
20
|
+
return { graceSeconds: 120, humanQuietSeconds: 300, automationQuietSeconds: 120, holdsFromRequest: false, requestNeedsQuiet: true };
|
|
21
|
+
}
|
|
22
|
+
export function readHandoff(id) {
|
|
23
|
+
return db.prepare("SELECT * FROM lead_handoffs WHERE id = ?").get(id) ?? null;
|
|
24
|
+
}
|
|
25
|
+
export function readActiveHandoff(leadAgentId) {
|
|
26
|
+
return (db
|
|
27
|
+
.prepare("SELECT * FROM lead_handoffs WHERE lead_agent_id = ? AND state NOT IN ('completed', 'failed')")
|
|
28
|
+
.get(leadAgentId) ?? null);
|
|
29
|
+
}
|
|
30
|
+
export function handoffHoldsAutomation(row) {
|
|
31
|
+
switch (row.state) {
|
|
32
|
+
case "grace":
|
|
33
|
+
return true;
|
|
34
|
+
case "started":
|
|
35
|
+
return !olderThan(row.updated_at, HOLD_CEILING_SECONDS);
|
|
36
|
+
case "respawning":
|
|
37
|
+
case "ambiguous":
|
|
38
|
+
return row.hold_released_at === null;
|
|
39
|
+
case "wind_down":
|
|
40
|
+
return row.hold_released_at === null;
|
|
41
|
+
case "requested":
|
|
42
|
+
return row.hold_released_at === null && passPolicy(row.pass, row.reason).holdsFromRequest;
|
|
43
|
+
case "postponed":
|
|
44
|
+
return row.hold_released_at === null && row.reason === "stop";
|
|
45
|
+
default:
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// The one predicate every automated lead delivery consults before it writes to the lead.
|
|
50
|
+
export function readHandoffGate(leadAgentId) {
|
|
51
|
+
const row = readActiveHandoff(leadAgentId);
|
|
52
|
+
return row === null
|
|
53
|
+
? null
|
|
54
|
+
: { requestId: row.id, requestWakeId: row.request_wake_id, holdAutomation: handoffHoldsAutomation(row) };
|
|
55
|
+
}
|
|
56
|
+
export const HELD_REASON_HANDOFF = "the lead is handing off to a fresh session; held through the wind-down and the successor's boot, and delivered to " +
|
|
57
|
+
"the lead row once the successor completes its first turn, or when the handoff fails or releases its hold";
|
|
58
|
+
// A wake addressed to a lead row is held while that lead's handoff holds automation, except the
|
|
59
|
+
// current request wake itself, which still passes every human-input and ownership guard.
|
|
60
|
+
export function handoffHoldsWake(wakeId, deliverRowId) {
|
|
61
|
+
if (deliverRowId === null)
|
|
62
|
+
return false;
|
|
63
|
+
const gate = readHandoffGate(deliverRowId);
|
|
64
|
+
return gate !== null && gate.holdAutomation && gate.requestWakeId !== wakeId;
|
|
65
|
+
}
|
|
66
|
+
const NOW_SQL = "strftime('%Y-%m-%d %H:%M:%f', 'now')";
|
|
67
|
+
export function olderThan(ts, seconds) {
|
|
68
|
+
if (ts === null)
|
|
69
|
+
return true;
|
|
70
|
+
return db.prepare("SELECT (julianday('now') - julianday(?)) * 86400 >= ? AS old").get(ts, seconds).old === 1;
|
|
71
|
+
}
|
|
72
|
+
export const nowSql = () => db.prepare(`SELECT ${NOW_SQL} AS now`).get().now;
|
|
73
|
+
// Every transition is a compare-and-set on the row's state; callers bind any extra identity
|
|
74
|
+
// (attempt, owner token) through `where`.
|
|
75
|
+
export function casHandoff(id, from, set, where = {}) {
|
|
76
|
+
const setKeys = Object.keys(set);
|
|
77
|
+
const whereKeys = Object.keys(where);
|
|
78
|
+
const sql = `UPDATE lead_handoffs SET ${[...setKeys.map((k) => `${k} = ?`), "updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now')"].join(", ")}
|
|
79
|
+
WHERE id = ? AND state IN (${from.map(() => "?").join(", ")})` +
|
|
80
|
+
whereKeys.map((k) => ` AND ${k} IS ?`).join("");
|
|
81
|
+
const result = db
|
|
82
|
+
.prepare(sql)
|
|
83
|
+
.run(...setKeys.map((k) => set[k] ?? null), id, ...from, ...whereKeys.map((k) => where[k] ?? null));
|
|
84
|
+
return result.changes === 1;
|
|
85
|
+
}
|
|
86
|
+
// The one writer of the grace -> postponed transition; `where` binds the caller's identity, and pass is always bound.
|
|
87
|
+
export function postponeGrace(row, where, reason, escalate) {
|
|
88
|
+
return casHandoff(row.id, ["grace"], {
|
|
89
|
+
state: "postponed",
|
|
90
|
+
pass: escalate ? row.pass + 1 : row.pass,
|
|
91
|
+
pass_started_at: nowSql(),
|
|
92
|
+
owner_token: null,
|
|
93
|
+
owner_pid: null,
|
|
94
|
+
blocked_reason: reason,
|
|
95
|
+
}, { ...where, pass: row.pass });
|
|
96
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { writeProjectPosture } from "./brief.js";
|
|
2
|
+
import { dataDir, db } from "./db.js";
|
|
3
|
+
import { carriesNameFlag } from "./harnesses.js";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { checkoutRoot, renderProfileFile, resolveProfileFile } from "./profiles.js";
|
|
6
|
+
import { activeProfile, configHash, mergedProjectVars } from "./projectYml.js";
|
|
7
|
+
import { LEAD_NAME } from "./spawn.js";
|
|
8
|
+
import { shellQuote } from "./tmux.js";
|
|
9
|
+
// What `hive lead` and the handoff bootstrap both need to launch a claude lead: trust, posture,
|
|
10
|
+
// hooks and identity env. Pane adoption and leftover cleanup stay in cmdLead.
|
|
11
|
+
export function isTrusted(projectId, name, command, dir, env) {
|
|
12
|
+
const hash = configHash(name, command, dir, env);
|
|
13
|
+
return !!db
|
|
14
|
+
.prepare("SELECT 1 FROM command_trust WHERE project_id = ? AND name = ? AND config_hash = ?")
|
|
15
|
+
.get(projectId, name, hash);
|
|
16
|
+
}
|
|
17
|
+
export function crewPluginDir() {
|
|
18
|
+
return join(checkoutRoot, "claude-plugin", "crew");
|
|
19
|
+
}
|
|
20
|
+
export function renderLeadPosture(config) {
|
|
21
|
+
const profile = activeProfile(config);
|
|
22
|
+
if (!profile)
|
|
23
|
+
return { profile: null, rendered: null };
|
|
24
|
+
const posture = resolveProfileFile(profile, "posture.md");
|
|
25
|
+
if (!posture)
|
|
26
|
+
return { profile, rendered: null };
|
|
27
|
+
return { profile, rendered: renderProfileFile(profile, "posture.md", mergedProjectVars(config)) ?? "", source: posture.source };
|
|
28
|
+
}
|
|
29
|
+
export function appendClaudeLeadArgs(input) {
|
|
30
|
+
const delivery = input.harness.briefDelivery;
|
|
31
|
+
let command = input.command;
|
|
32
|
+
if (!carriesNameFlag(command.trim().split(/\s+/)))
|
|
33
|
+
command += ` --name ${shellQuote(input.projectName)}`;
|
|
34
|
+
command += ` ${delivery.settingsArgs(input.hooksPath).map(shellQuote).join(" ")}`;
|
|
35
|
+
if (input.sidebarPluginDir !== null)
|
|
36
|
+
command += ` ${["--plugin-dir", input.sidebarPluginDir].map(shellQuote).join(" ")}`;
|
|
37
|
+
if (input.posture !== null) {
|
|
38
|
+
command += ` ${delivery.systemPromptArgs(writeProjectPosture(input.projectId, input.posture)).map(shellQuote).join(" ")}`;
|
|
39
|
+
}
|
|
40
|
+
const promptSuffix = input.harness.initialPromptArgs && input.firstMessage !== ""
|
|
41
|
+
? ` ${input.harness.initialPromptArgs(input.firstMessage).map(shellQuote).join(" ")}`
|
|
42
|
+
: "";
|
|
43
|
+
return { command, promptSuffix };
|
|
44
|
+
}
|
|
45
|
+
export function leadIdentityEnv(actorId) {
|
|
46
|
+
return {
|
|
47
|
+
HIVE_AGENT_ID: actorId,
|
|
48
|
+
HIVE_AGENT_NAME: LEAD_NAME,
|
|
49
|
+
HIVE_LEAD: "1",
|
|
50
|
+
HIVE_DATA_DIR: dataDir,
|
|
51
|
+
HIVE_PROJECT_LOCK: "",
|
|
52
|
+
HIVE_PROJECT_PATH: "",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -3,7 +3,8 @@ import { dataDir, db } from "./db.js";
|
|
|
3
3
|
import { CROSS_SESSION_CLOSE, postClaudeWake, senderAddress } from "./claudeWake.js";
|
|
4
4
|
import { harnessFor, paneClassifierFor } from "./harnesses.js";
|
|
5
5
|
import { LEAD_MESSAGE_THRESHOLD, renderLeadMessage, senderTag, shortenedSendNote, storeLeadMessage, } from "./leadMessage.js";
|
|
6
|
-
import {
|
|
6
|
+
import { readHandoffGate } from "./leadHandoffState.js";
|
|
7
|
+
import { HANDED_OFF_STATES_SQL, readLeadTurnState } from "./leadState.js";
|
|
7
8
|
import { loadProjectYml } from "./projectYml.js";
|
|
8
9
|
import { currentOwnership, PaneOwnershipLost, paneIdentity, requireStillOwned } from "./spawn.js";
|
|
9
10
|
import { holdsHumanInput, paneInCopyMode, sendText, sleep, TmuxTimeoutError, } from "./tmux.js";
|
|
@@ -46,12 +47,16 @@ function quietMessagingOn(projectId) {
|
|
|
46
47
|
const project = db.prepare("SELECT path FROM projects WHERE id = ?").get(projectId);
|
|
47
48
|
return project !== undefined && loadProjectYml(project.path).config?.quiet_messaging === true;
|
|
48
49
|
}
|
|
50
|
+
const handoffHolds = (agentId) => readHandoffGate(agentId)?.holdAutomation === true;
|
|
51
|
+
const HANDOFF_HOLD_NOTE = "the lead is handing off to a fresh session; held until the successor completes its first turn";
|
|
49
52
|
function eligible(options) {
|
|
50
53
|
const { projectId, fromActor, target, submit } = options;
|
|
51
54
|
if (!submit || target.kind !== "lead" || target.status !== "running" || target.actor_id === fromActor)
|
|
52
55
|
return false;
|
|
53
56
|
if (harnessFor(target.command).name !== "claude")
|
|
54
57
|
return false;
|
|
58
|
+
if (handoffHolds(target.id))
|
|
59
|
+
return true;
|
|
55
60
|
const sender = db
|
|
56
61
|
.prepare("SELECT 1 AS hit FROM agents WHERE project_id = ? AND actor_id = ? AND status = 'running' AND kind = 'agent'")
|
|
57
62
|
.get(projectId, fromActor);
|
|
@@ -138,8 +143,38 @@ function pasteFailure(err, pasted, buffered) {
|
|
|
138
143
|
return `the paste timed out, so the text MAY be on screen unsubmitted: ${why}`;
|
|
139
144
|
return `nothing was pasted: ${why}`;
|
|
140
145
|
}
|
|
146
|
+
// A message accepted for a predecessor whose pane the same lead row now holds as a handed-off
|
|
147
|
+
// successor is re-pointed at that successor, once, rather than failed as a replacement pane.
|
|
148
|
+
function retargetAfterHandoff(row) {
|
|
149
|
+
let accepted;
|
|
150
|
+
try {
|
|
151
|
+
accepted = JSON.parse(row.target_identity ?? "{}");
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return row;
|
|
155
|
+
}
|
|
156
|
+
const lead = db.prepare("SELECT * FROM agents WHERE id = ? AND kind = 'lead' AND status = 'running'").get(row.to_agent_id);
|
|
157
|
+
if (lead === undefined || accepted.pane_pid === lead.pane_pid || accepted.tmux_target !== lead.tmux_target)
|
|
158
|
+
return row;
|
|
159
|
+
const handedOff = db
|
|
160
|
+
.prepare(`SELECT 1 AS hit FROM lead_handoffs WHERE lead_agent_id = ? AND pane_target = ? AND predecessor_pane_pid = ?
|
|
161
|
+
AND successor_pane_pid = ? AND state IN ${HANDED_OFF_STATES_SQL}`)
|
|
162
|
+
.get(lead.id, lead.tmux_target, accepted.pane_pid ?? "", lead.pane_pid);
|
|
163
|
+
if (handedOff === undefined)
|
|
164
|
+
return row;
|
|
165
|
+
const identity = JSON.stringify(paneIdentity(lead));
|
|
166
|
+
try {
|
|
167
|
+
db.prepare("UPDATE agent_messages SET target_identity = ? WHERE id = ? AND target_identity IS ? AND delivery_status = ?").run(identity, row.id, row.target_identity, MESSAGE_STATUS.fallbackPending);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
}
|
|
171
|
+
return readRow(row.id) ?? row;
|
|
172
|
+
}
|
|
141
173
|
// One guarded pane attempt. The claim is the CAS that makes it at most one per message.
|
|
142
|
-
async function paneFallback(
|
|
174
|
+
async function paneFallback(original, snapshot) {
|
|
175
|
+
if (handoffHolds(original.to_agent_id))
|
|
176
|
+
return hold(original, HANDOFF_HOLD_NOTE);
|
|
177
|
+
const row = retargetAfterHandoff(original);
|
|
143
178
|
let identity;
|
|
144
179
|
try {
|
|
145
180
|
identity = paneIdentity(JSON.parse(row.target_identity ?? ""));
|
|
@@ -229,11 +264,13 @@ export async function sendQuietLeadMessage(options) {
|
|
|
229
264
|
const base = { agent_id: target.id, name: target.name, message_id: id, ...(long ? { shortened: true } : {}) };
|
|
230
265
|
const shortNote = (channel, redelivered = false) => long ? ` ${shortenedSendNote(id, renderLeadMessage(id, text, tag, redelivered).length, channel)}` : "";
|
|
231
266
|
const body = socketText(id, text, tag);
|
|
232
|
-
const skip =
|
|
233
|
-
?
|
|
234
|
-
:
|
|
235
|
-
? "
|
|
236
|
-
:
|
|
267
|
+
const skip = handoffHolds(target.id)
|
|
268
|
+
? HANDOFF_HOLD_NOTE
|
|
269
|
+
: !registeredSocket(target)
|
|
270
|
+
? "no messaging socket registered for this lead pane"
|
|
271
|
+
: body.includes(CROSS_SESSION_CLOSE)
|
|
272
|
+
? "the text contains the envelope's closing tag"
|
|
273
|
+
: null;
|
|
237
274
|
// One statement makes the row visible to the scheduler, already in its route's status.
|
|
238
275
|
db.prepare(`UPDATE agent_messages SET delivery_status = ?, delivery_method = ?, socket_attempt_at = ${skip === null ? NOW : "NULL"},
|
|
239
276
|
delivery_note = ?, target_identity = ?, sender_tag = ? WHERE id = ?`).run(skip === null ? MESSAGE_STATUS.socketPending : MESSAGE_STATUS.fallbackPending, skip === null ? MESSAGE_METHOD.socket : MESSAGE_METHOD.pty, skip, JSON.stringify(identity), tag, id);
|