@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/dist/codexHome.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
1
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join, relative } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -6,6 +6,7 @@ import { parse as parseToml } from "smol-toml";
|
|
|
6
6
|
import { gitPrimaryRoot } from "./context.js";
|
|
7
7
|
import { storeDir } from "./dataDir.js";
|
|
8
8
|
import { hookEntry } from "./hooks.js";
|
|
9
|
+
import { harnessFor } from "./harnesses.js";
|
|
9
10
|
import { shellQuote } from "./tmux.js";
|
|
10
11
|
export function codexHomeDir(key) {
|
|
11
12
|
return join(storeDir(), "codex-homes", key);
|
|
@@ -21,6 +22,109 @@ export function preservedRolloutPath(key, transcriptPath) {
|
|
|
21
22
|
return join(codexRolloutsDir(key), transcriptPath.slice(sessionsPrefix.length));
|
|
22
23
|
}
|
|
23
24
|
const ROLLOUT_FILE = /^rollout-.*\.jsonl$/;
|
|
25
|
+
const MAX_FORK_ROLLOUT_BYTES = 64 * 1024 * 1024;
|
|
26
|
+
function firstSessionMeta(path) {
|
|
27
|
+
const firstLine = readFileSync(path, "utf8").split("\n", 1)[0];
|
|
28
|
+
let record;
|
|
29
|
+
try {
|
|
30
|
+
record = JSON.parse(firstLine);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
if (firstLine.includes("\"forked_from_id\""))
|
|
34
|
+
throw new Error("Codex fork rollout has malformed session metadata.");
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (!record || typeof record !== "object" || record.type !== "session_meta")
|
|
38
|
+
return null;
|
|
39
|
+
const payload = record.payload;
|
|
40
|
+
return payload && typeof payload === "object" ? payload : record;
|
|
41
|
+
}
|
|
42
|
+
function checkedSessionsPath(home, transcriptPath) {
|
|
43
|
+
const sessionsRoot = realpathSync(join(home, "sessions"));
|
|
44
|
+
const sourcePath = realpathSync(transcriptPath);
|
|
45
|
+
const relativePath = relative(sessionsRoot, sourcePath);
|
|
46
|
+
if (!relativePath || relativePath === ".." || relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
|
|
47
|
+
throw new Error("Codex transcript is outside its recorded CODEX_HOME sessions directory.");
|
|
48
|
+
}
|
|
49
|
+
const file = lstatSync(sourcePath);
|
|
50
|
+
if (!file.isFile() || !ROLLOUT_FILE.test(sourcePath.split(/[\\/]/).at(-1) ?? "")) {
|
|
51
|
+
throw new Error("Codex transcript is not a regular rollout file.");
|
|
52
|
+
}
|
|
53
|
+
return { sessionsRoot, sourcePath, relativePath };
|
|
54
|
+
}
|
|
55
|
+
export function seedCodexFork(source, childKey, seed = readCodexForkSeed(source)) {
|
|
56
|
+
writeCodexForkSeed(source, childKey, seed);
|
|
57
|
+
}
|
|
58
|
+
export function readCodexForkSeed(source) {
|
|
59
|
+
if (harnessFor(source.command).name !== "codex")
|
|
60
|
+
throw new Error("Only a Codex worker can seed a Codex fork.");
|
|
61
|
+
if (!source.session_id || !source.codex_home || !source.transcript_path) {
|
|
62
|
+
throw new Error("Codex source must have a recorded session, CODEX_HOME, and transcript path.");
|
|
63
|
+
}
|
|
64
|
+
const sourceHome = codexHomeDir(source.codex_home);
|
|
65
|
+
const { sessionsRoot, sourcePath, relativePath } = checkedSessionsPath(sourceHome, source.transcript_path);
|
|
66
|
+
const size = lstatSync(sourcePath).size;
|
|
67
|
+
if (size > MAX_FORK_ROLLOUT_BYTES)
|
|
68
|
+
throw new Error("Codex source rollout exceeds the 64 MiB fork-seed limit.");
|
|
69
|
+
const contents = readFileSync(sourcePath);
|
|
70
|
+
const lastNewline = contents.lastIndexOf(0x0a);
|
|
71
|
+
if (lastNewline < 0)
|
|
72
|
+
throw new Error("Codex source rollout has no complete JSONL records.");
|
|
73
|
+
const completePrefix = contents.subarray(0, lastNewline + 1);
|
|
74
|
+
const lines = completePrefix.toString("utf8").split("\n").filter(Boolean);
|
|
75
|
+
let sessionMeta = null;
|
|
76
|
+
try {
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
const record = JSON.parse(line);
|
|
79
|
+
if (record?.type === "session_meta") {
|
|
80
|
+
const payload = record.payload;
|
|
81
|
+
sessionMeta = payload && typeof payload === "object" ? payload : record;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new Error("Codex source rollout contains a malformed complete JSONL record.");
|
|
87
|
+
}
|
|
88
|
+
if (sessionMeta?.id !== source.session_id) {
|
|
89
|
+
throw new Error("Codex source rollout metadata.id does not match its recorded session id.");
|
|
90
|
+
}
|
|
91
|
+
return { sourceSessionId: source.session_id, relativePath, contents: completePrefix };
|
|
92
|
+
}
|
|
93
|
+
function writeCodexForkSeed(source, childKey, seed) {
|
|
94
|
+
if (seed.sourceSessionId !== source.session_id)
|
|
95
|
+
throw new Error("Codex fork seed no longer matches the captured source session.");
|
|
96
|
+
const childSessions = join(codexHomeDir(childKey), "sessions");
|
|
97
|
+
mkdirSync(childSessions, { recursive: true });
|
|
98
|
+
const destination = join(childSessions, seed.relativePath);
|
|
99
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
100
|
+
if (existsSync(destination))
|
|
101
|
+
throw new Error("Fresh Codex child home already contains the source rollout path.");
|
|
102
|
+
writeFileSync(destination, seed.contents, { flag: "wx" });
|
|
103
|
+
}
|
|
104
|
+
export function findCodexFork(childKey, sourceSessionId) {
|
|
105
|
+
const sessionsRoot = join(codexHomeDir(childKey), "sessions");
|
|
106
|
+
const matches = [];
|
|
107
|
+
for (const path of collectRolloutFiles(sessionsRoot)) {
|
|
108
|
+
let meta;
|
|
109
|
+
try {
|
|
110
|
+
meta = firstSessionMeta(path);
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
if (e.code === "ENOENT")
|
|
114
|
+
continue;
|
|
115
|
+
throw e;
|
|
116
|
+
}
|
|
117
|
+
if (meta?.forked_from_id !== sourceSessionId)
|
|
118
|
+
continue;
|
|
119
|
+
if (typeof meta.id !== "string" || !meta.id || meta.id === sourceSessionId) {
|
|
120
|
+
throw new Error("Codex fork rollout has malformed session metadata.");
|
|
121
|
+
}
|
|
122
|
+
matches.push({ sessionId: meta.id, transcriptPath: path });
|
|
123
|
+
}
|
|
124
|
+
if (matches.length > 1)
|
|
125
|
+
throw new Error(`Found ${matches.length} Codex rollouts forked from the source session.`);
|
|
126
|
+
return matches[0] ?? null;
|
|
127
|
+
}
|
|
24
128
|
// Dirent.isFile()/.isDirectory() answer about the entry itself, not its target, so a symlinked
|
|
25
129
|
// file or directory under sessions/ is neither and is silently skipped - never followed, never
|
|
26
130
|
// moved. That is what keeps this walk off the auth.json symlink without naming it specially.
|
package/dist/context.js
CHANGED
package/dist/db.js
CHANGED
|
@@ -612,6 +612,31 @@ WHEN (NEW.fire_count > OLD.fire_count
|
|
|
612
612
|
BEGIN
|
|
613
613
|
SELECT RAISE(IGNORE);
|
|
614
614
|
END;
|
|
615
|
+
`,
|
|
616
|
+
`
|
|
617
|
+
ALTER TABLE agents ADD COLUMN recovery_state TEXT NOT NULL DEFAULT ''
|
|
618
|
+
CHECK (recovery_state IN ('', 'pending', 'excluded'));
|
|
619
|
+
`,
|
|
620
|
+
`
|
|
621
|
+
CREATE TABLE agent_usage_limits (
|
|
622
|
+
agent_id INTEGER PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
|
|
623
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
624
|
+
harness TEXT NOT NULL CHECK (harness IN ('claude', 'codex')),
|
|
625
|
+
pane_pid TEXT NOT NULL,
|
|
626
|
+
session_id TEXT NOT NULL,
|
|
627
|
+
signal_key TEXT NOT NULL,
|
|
628
|
+
observed_at TEXT NOT NULL,
|
|
629
|
+
reset_at TEXT,
|
|
630
|
+
expires_at TEXT NOT NULL,
|
|
631
|
+
released_at TEXT,
|
|
632
|
+
continue_claimed_at TEXT
|
|
633
|
+
);
|
|
634
|
+
CREATE INDEX idx_agent_usage_limits_gate ON agent_usage_limits(project_id, harness, expires_at)
|
|
635
|
+
WHERE released_at IS NULL;
|
|
636
|
+
`,
|
|
637
|
+
`
|
|
638
|
+
ALTER TABLE agents ADD COLUMN forked_from_agent_id INTEGER REFERENCES agents(id);
|
|
639
|
+
ALTER TABLE agents ADD COLUMN forked_from_session_id TEXT NOT NULL DEFAULT '';
|
|
615
640
|
`,
|
|
616
641
|
];
|
|
617
642
|
export function storeSchemaAhead(database) {
|
package/dist/help.js
CHANGED
|
@@ -21,7 +21,7 @@ PROMPTS — invokable prompts (slash commands in MCP clients)
|
|
|
21
21
|
runbook — load this project's own standing process
|
|
22
22
|
|
|
23
23
|
AGENTS — spawn and drive worker sessions in tmux
|
|
24
|
-
agent_spawn, agent_list, agent_status, agent_send, agent_output,
|
|
24
|
+
agent_spawn, agent_fork, agent_list, agent_status, agent_send, agent_output,
|
|
25
25
|
agent_message_get, agent_rename, agent_park, agent_resume, agent_close
|
|
26
26
|
|
|
27
27
|
WAKE-UPS — scheduled nudges instead of polling
|
|
@@ -53,6 +53,23 @@ ACTORS — who is writing
|
|
|
53
53
|
actor_prune`;
|
|
54
54
|
}
|
|
55
55
|
export const HELP_TOPICS = {
|
|
56
|
+
"crew-restore": `CREW RESTORE — resume crashed workers for this project
|
|
57
|
+
|
|
58
|
+
Run \`hive crew-restore [--dry-run] [--json]\` from a registered project.
|
|
59
|
+
The command resumes rows marked pending after a confirmed worker pane loss.
|
|
60
|
+
It skips live, unknown, parked, deliberately closed and historical rows.
|
|
61
|
+
The dry run reports candidates and blockers without changing rows or panes.
|
|
62
|
+
Each restored worker keeps its row, actor, session, working directory and
|
|
63
|
+
launch options. The command does not replay its previous assignment.
|
|
64
|
+
|
|
65
|
+
This is a CLI command for the local tmux environment, not an MCP tool.`,
|
|
66
|
+
layout: `LAYOUT — restore a project's worker pane layout
|
|
67
|
+
|
|
68
|
+
Run \`hive layout\` in a project to gather its live worker panes into the lead
|
|
69
|
+
window and re-apply the merged hive.yml layout. The command takes no flags. It
|
|
70
|
+
does nothing when the resolved placement is "window".
|
|
71
|
+
|
|
72
|
+
This is a CLI command for the local tmux environment, not an MCP tool.`,
|
|
56
73
|
workflow: `WORKFLOW — lead/worker orchestration
|
|
57
74
|
|
|
58
75
|
One session acts as the lead. It spawns and drives workers with the agent
|
|
@@ -146,8 +163,18 @@ runbook (fork it first), not a pad write.`,
|
|
|
146
163
|
and layout in hive.yml; an explicit argument overrides it. cwd defaults to the project root; pass
|
|
147
164
|
a git worktree path to isolate parallel file edits. A claude worker gets
|
|
148
165
|
its brief in the system prompt; other commands return instructions to
|
|
149
|
-
PREPEND to the first prompt.
|
|
166
|
+
PREPEND to the first prompt. A same-project worker or lead currently
|
|
167
|
+
stopped by that harness's usage limit makes spawn retryable until its
|
|
168
|
+
reset or bounded deadline; hive does not queue spawns.
|
|
150
169
|
Workers run with HIVE_PROJECT_LOCK=1.
|
|
170
|
+
agent_fork(agent_id, name, cwd?, model?, effort?, read_only?, todo_id?,
|
|
171
|
+
placement?, layout?):
|
|
172
|
+
make a new worker from a same-project Claude or Codex session. The child
|
|
173
|
+
gets a new identity, pane, session and brief, and receives no assignment
|
|
174
|
+
until you send one. It does not copy the source worktree, Hive todo link,
|
|
175
|
+
or promise that harness todos or tasks copy. Claude process-scoped grants
|
|
176
|
+
need approval again. A Codex child gets a fresh home seeded with the
|
|
177
|
+
source rollout; a closed Codex worker whose home was reaped cannot fork.
|
|
151
178
|
agent_send(name|agent_id, text?, keys?, submit?, wait_ms?) — type into the
|
|
152
179
|
worker's terminal. Multi-line text pastes safely; keys sends tmux key
|
|
153
180
|
names like Escape or C-c. wait_ms returns the terminal tail after.
|
|
@@ -159,12 +186,15 @@ runbook (fork it first), not a pad write.`,
|
|
|
159
186
|
posted to the lead's messaging socket instead; the receipt says pending
|
|
160
187
|
until the lead's prompt confirms it, and hive types it into the pane once
|
|
161
188
|
if nothing does. Do not resend a pending send.
|
|
189
|
+
A submitted text send to a target stopped by its usage limit is stored
|
|
190
|
+
as pending until release. keys and submit=false remain manual recovery.
|
|
162
191
|
agent_message_get(message_id) — the full text behind a pointer line, plus
|
|
163
192
|
the delivery state of a quiet message. Kept 7 days; a lookup for a pruned id says it expired, not that it is missing.
|
|
164
193
|
agent_output(name|agent_id, lines?) — read the rendered terminal.
|
|
165
194
|
agent_status(name|agent_id, include_brief?) — liveness, current command,
|
|
166
195
|
short tail, and the path to the brief this worker was given
|
|
167
|
-
(include_brief=true returns its text; no transcript records it).
|
|
196
|
+
(include_brief=true returns its text; no transcript records it). Forked
|
|
197
|
+
workers show forked_from_agent_id and forked_from_session_id.
|
|
168
198
|
agent_list(include_closed?, limit?, before_id?) — agents with live status.
|
|
169
199
|
include_closed adds closed/parked rows, newest first, limit 20/max 100;
|
|
170
200
|
page with before_id set to the receipt's next_before_id.
|
|
@@ -181,7 +211,9 @@ runbook (fork it first), not a pad write.`,
|
|
|
181
211
|
claude or codex worker on a fresh pane from its recorded session id,
|
|
182
212
|
with the same actor_id and its full prior context. It does not send
|
|
183
213
|
the assignment; agent_send it afterwards. Read the pane before
|
|
184
|
-
believing any wake about a worker you just resumed.
|
|
214
|
+
believing any wake about a worker you just resumed. A same-project
|
|
215
|
+
harness limit makes resume retryable until its reset or bounded deadline;
|
|
216
|
+
hive does not queue resumes.
|
|
185
217
|
agent_close(name|agent_id, confirm_self?, row_only?) — kill the worker's pane and mark closed. Capture
|
|
186
218
|
handoffs first; output is not retained. Self-close needs confirm_self.
|
|
187
219
|
On a PARKED row (by agent_id) it releases the park instead, which is how
|
|
@@ -253,7 +285,9 @@ Delivery contract:
|
|
|
253
285
|
and the next action. Plain English, no markup.
|
|
254
286
|
Waking your OWN lead pane is the exception: it is not cold, so carry the
|
|
255
287
|
action and the ids and point at where the detail lives.
|
|
256
|
-
With lead_turn_budget
|
|
288
|
+
With a lead_turn_budget, auto_handoff is on by default. Set
|
|
289
|
+
lead_turn_budget.auto_handoff to false to keep the lead in this session.
|
|
290
|
+
When enabled, a lead past its warn budget gets
|
|
257
291
|
a "hive lead handoff request #N" wake: follow its steps, then run
|
|
258
292
|
hive lead-handoff. A human prompt during the grace postpones it.
|
|
259
293
|
|
|
@@ -378,7 +412,9 @@ since every read pays for its full length.`,
|
|
|
378
412
|
Blockers form a dependency graph (cycles are rejected). A todo is blocked
|
|
379
413
|
while any of its blockers is not completed. Archived and completed are
|
|
380
414
|
independent axes: archiving hides a closed lane's scaffolding from
|
|
381
|
-
todo_list without marking anything done
|
|
415
|
+
todo_list without marking anything done. A project's optional completion_gate
|
|
416
|
+
in hive.yml runs before a todo becomes completed; hive lead reviews its
|
|
417
|
+
command at startup, and a refused gate leaves the todo unchanged.`,
|
|
382
418
|
kv: `KV — small shared status values
|
|
383
419
|
|
|
384
420
|
kv_set(key, value, ttl_seconds?) — value is any JSON; TTL optional
|
package/dist/hook.js
CHANGED
|
@@ -58,6 +58,26 @@ function acceptsWorkerHook(row, payload, sessionsRoot) {
|
|
|
58
58
|
typeof payload.session_id === "string" && payload.session_id !== "" &&
|
|
59
59
|
row.session_id === payload.session_id);
|
|
60
60
|
}
|
|
61
|
+
async function acceptsForkHook(row, payload, sessionsRoot) {
|
|
62
|
+
if (!row.forked_from_session_id)
|
|
63
|
+
return acceptsWorkerHook(row, payload, sessionsRoot);
|
|
64
|
+
if (payload.session_id === row.forked_from_session_id)
|
|
65
|
+
return false;
|
|
66
|
+
if (typeof payload.session_id !== "string" || typeof payload.transcript_path !== "string")
|
|
67
|
+
return false;
|
|
68
|
+
try {
|
|
69
|
+
const { findCodexFork } = await import("./codexHome.js");
|
|
70
|
+
const child = findCodexFork(row.codex_home, row.forked_from_session_id);
|
|
71
|
+
if (!child || child.sessionId !== payload.session_id)
|
|
72
|
+
return false;
|
|
73
|
+
const reported = canonicalPathWithMissingSuffix(payload.transcript_path);
|
|
74
|
+
const discovered = canonicalPathWithMissingSuffix(child.transcriptPath);
|
|
75
|
+
return reported !== null && reported === discovered && acceptsWorkerHook(row, payload, sessionsRoot);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
61
81
|
function validCheckpointThreshold() {
|
|
62
82
|
const text = process.env.HIVE_CONTEXT_CHECKPOINT_PERCENT;
|
|
63
83
|
if (!text || !/^\d+$/.test(text))
|
|
@@ -252,14 +272,21 @@ if (process.argv[2] === "post_tool_use") {
|
|
|
252
272
|
if (kind === "codex" && validCheckpointThreshold() && actorId.startsWith("agent:")) {
|
|
253
273
|
suppliedPayload = readPayload();
|
|
254
274
|
const { db } = await import("./db.js");
|
|
255
|
-
const row = db.prepare("SELECT kind, command, status, codex_home, session_id FROM agents WHERE actor_id = ?")
|
|
275
|
+
const row = db.prepare("SELECT kind, command, status, codex_home, session_id, forked_from_session_id FROM agents WHERE actor_id = ?")
|
|
256
276
|
.get(actorId);
|
|
257
277
|
if (row?.kind === "agent") {
|
|
258
278
|
const { harnessFor } = await import("./harnesses.js");
|
|
259
279
|
if (harnessFor(row.command).name === "codex") {
|
|
260
280
|
if (row.status !== "running")
|
|
261
281
|
process.exit(0);
|
|
262
|
-
if (
|
|
282
|
+
if (row.forked_from_session_id) {
|
|
283
|
+
if (!row.codex_home)
|
|
284
|
+
process.exit(0);
|
|
285
|
+
const { codexHomeDir } = await import("./codexHome.js");
|
|
286
|
+
if (!await acceptsForkHook(row, suppliedPayload, join(codexHomeDir(row.codex_home), "sessions")))
|
|
287
|
+
process.exit(0);
|
|
288
|
+
}
|
|
289
|
+
else if (typeof row.codex_home === "string" && row.codex_home !== "") {
|
|
263
290
|
const { codexHomeDir } = await import("./codexHome.js");
|
|
264
291
|
if (!acceptsWorkerHook(row, suppliedPayload, join(codexHomeDir(row.codex_home), "sessions"))) {
|
|
265
292
|
process.exit(0);
|
|
@@ -283,8 +310,31 @@ try {
|
|
|
283
310
|
if (actorId) {
|
|
284
311
|
const event = process.argv[2] ?? "";
|
|
285
312
|
const hookPayload = readPayload();
|
|
286
|
-
|
|
313
|
+
if (event === "stop_failure") {
|
|
314
|
+
try {
|
|
315
|
+
const { observeClaudeStopFailure } = await import("./usageLimit.js");
|
|
316
|
+
const row = db.prepare("SELECT id, command, kind, status FROM agents WHERE actor_id = ? AND status = 'running'")
|
|
317
|
+
.get(actorId);
|
|
318
|
+
const { harnessFor } = await import("./harnesses.js");
|
|
319
|
+
if (row && ["agent", "lead"].includes(row.kind) && harnessFor(row.command).name === "claude" && hookPayload.error === "rate_limit") {
|
|
320
|
+
reconcileSessionId(actorId, hookPayload);
|
|
321
|
+
reconcileTranscriptPath(actorId, hookPayload);
|
|
322
|
+
observeClaudeStopFailure(row.id, hookPayload, Date.now());
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
catch { }
|
|
326
|
+
record(actorId, event, UNCHANGED);
|
|
327
|
+
process.exit(0);
|
|
328
|
+
}
|
|
329
|
+
const row = db.prepare("SELECT kind, command, status, codex_home, session_id, forked_from_session_id FROM agents WHERE actor_id = ?")
|
|
287
330
|
.get(actorId);
|
|
331
|
+
if (row?.kind === "agent" && row.forked_from_session_id) {
|
|
332
|
+
if (hookPayload.session_id === row.forked_from_session_id)
|
|
333
|
+
process.exit(0);
|
|
334
|
+
const { harnessFor } = await import("./harnesses.js");
|
|
335
|
+
if (harnessFor(row.command).name === "claude" && hookPayload.session_id !== row.session_id)
|
|
336
|
+
process.exit(0);
|
|
337
|
+
}
|
|
288
338
|
let guarded = false;
|
|
289
339
|
let state = null;
|
|
290
340
|
if (row?.kind === "agent") {
|
|
@@ -293,8 +343,13 @@ try {
|
|
|
293
343
|
if (row.status !== "running")
|
|
294
344
|
process.exit(0);
|
|
295
345
|
const { codexHomeDir } = await import("./codexHome.js");
|
|
346
|
+
if (row.forked_from_session_id) {
|
|
347
|
+
if (!row.codex_home || !await acceptsForkHook(row, hookPayload, join(codexHomeDir(row.codex_home), "sessions"))) {
|
|
348
|
+
process.exit(0);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
296
351
|
const result = db.transaction(() => {
|
|
297
|
-
const current = db.prepare("SELECT kind, command, status, codex_home, session_id FROM agents WHERE actor_id = ?")
|
|
352
|
+
const current = db.prepare("SELECT kind, command, status, codex_home, session_id, forked_from_session_id FROM agents WHERE actor_id = ?")
|
|
298
353
|
.get(actorId);
|
|
299
354
|
if (current?.kind === "agent" && harnessFor(current.command).name === "codex" && current.status !== "running") {
|
|
300
355
|
return { status: "inactive", state: null };
|
|
@@ -303,8 +358,9 @@ try {
|
|
|
303
358
|
return { status: "changed", state: null };
|
|
304
359
|
}
|
|
305
360
|
const sessionsRoot = join(codexHomeDir(current.codex_home), "sessions");
|
|
306
|
-
if (!acceptsWorkerHook(current, hookPayload, sessionsRoot))
|
|
361
|
+
if (current.forked_from_session_id !== row.forked_from_session_id || !acceptsWorkerHook(current, hookPayload, sessionsRoot)) {
|
|
307
362
|
return { status: "rejected", state: null };
|
|
363
|
+
}
|
|
308
364
|
const state = applyWorkerTransition(actorId, event);
|
|
309
365
|
reconcileSessionId(actorId, hookPayload);
|
|
310
366
|
reconcileTranscriptPath(actorId, hookPayload);
|
package/dist/hooks.js
CHANGED
|
@@ -40,6 +40,7 @@ function stateHookSettings() {
|
|
|
40
40
|
return {
|
|
41
41
|
hooks: {
|
|
42
42
|
Stop: [hookEntry("stop")],
|
|
43
|
+
StopFailure: [{ ...hookEntry("stop_failure"), matcher: "rate_limit" }],
|
|
43
44
|
UserPromptSubmit: [hookEntry("prompt")],
|
|
44
45
|
Notification: [hookEntry("notify")],
|
|
45
46
|
SessionEnd: [hookEntry("session_end")],
|
package/dist/leadHandoff.js
CHANGED
|
@@ -11,8 +11,11 @@ import { readTurnCount } from "./turnCount.js";
|
|
|
11
11
|
export { casHandoff, HELD_REASON_HANDOFF, handoffHoldsWake, readActiveHandoff, readHandoff, readHandoffGate, SUCCESSOR_NOT_READY, } from "./leadHandoffState.js";
|
|
12
12
|
export const HANDOFF_PAD_NAME = "hive-lead-handoff";
|
|
13
13
|
export const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
14
|
+
function autoHandoffEnabled(budget) {
|
|
15
|
+
return budget !== null && (budget.auto_handoff ?? true);
|
|
16
|
+
}
|
|
14
17
|
export function handoffEligibility(lead, budget, snapshot = readLeadSafetySnapshot(lead.id)) {
|
|
15
|
-
if (budget === null || budget
|
|
18
|
+
if (budget === null || !autoHandoffEnabled(budget))
|
|
16
19
|
return { eligible: false, why: "auto_handoff is not enabled" };
|
|
17
20
|
if (harnessFor(lead.command).name !== "claude")
|
|
18
21
|
return { eligible: false, why: "the lead is not a Claude lead" };
|
|
@@ -274,7 +277,7 @@ export function driveLeadHandoff(actorId, budgetFor) {
|
|
|
274
277
|
settleAfterRespawn(row, lead);
|
|
275
278
|
return;
|
|
276
279
|
}
|
|
277
|
-
if (budget
|
|
280
|
+
if (!autoHandoffEnabled(budget)) {
|
|
278
281
|
failHandoff(row.id, "lead_turn_budget.auto_handoff is no longer on");
|
|
279
282
|
return;
|
|
280
283
|
}
|
|
@@ -364,7 +367,7 @@ export function padProblem(pad, projectId, revision) {
|
|
|
364
367
|
}
|
|
365
368
|
// The final quiet check. A screen or probe that cannot be read waits; it never establishes quiet.
|
|
366
369
|
export async function respawnVerdict(row, lead, budget) {
|
|
367
|
-
if (budget
|
|
370
|
+
if (!autoHandoffEnabled(budget))
|
|
368
371
|
return { fail: "lead_turn_budget.auto_handoff is no longer on" };
|
|
369
372
|
if (lead === undefined || lead.pane_pid !== row.predecessor_pane_pid || lead.tmux_target !== row.pane_target) {
|
|
370
373
|
return { fail: "the lead row no longer names the pane this handoff was requested for" };
|
package/dist/leadHandoffCli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { cliPath } from "./dispatcher.js";
|
|
|
7
7
|
import { FIRST_MESSAGE_SHA_ENV, firstMessageDigest } from "./firstMessage.js";
|
|
8
8
|
import { ensureLeadHooksFile } from "./hooks.js";
|
|
9
9
|
import { appendClaudeLeadArgs, crewPluginDir, isTrusted, leadIdentityEnv, renderLeadPosture } from "./leadLaunch.js";
|
|
10
|
-
import { loadProjectYml } from "./projectYml.js";
|
|
10
|
+
import { leadSidebarEnabled, loadProjectYml, quietMessagingEnabled } from "./projectYml.js";
|
|
11
11
|
import { buildEnvFlags } from "./spawn.js";
|
|
12
12
|
import { panePidForRecord, rowOwnership, shellQuote, tmux, TmuxTimeoutError } from "./tmux.js";
|
|
13
13
|
import { automationBlocker, failHandoff, missingHeadings, padProblem, readPad, RESPAWN_WAIT_LIMIT_SECONDS, respawnVerdict, runningLeadByActor, sha256, successorPrompt, } from "./leadHandoff.js";
|
|
@@ -152,8 +152,8 @@ async function successorLaunch(row, lead, attempt, token) {
|
|
|
152
152
|
harness,
|
|
153
153
|
projectId: lead.project_id,
|
|
154
154
|
projectName: lead.name,
|
|
155
|
-
hooksPath: ensureLeadHooksFile(lead.project_id, config
|
|
156
|
-
sidebarPluginDir: config
|
|
155
|
+
hooksPath: ensureLeadHooksFile(lead.project_id, quietMessagingEnabled(config)),
|
|
156
|
+
sidebarPluginDir: leadSidebarEnabled(config) ? crewPluginDir() : null,
|
|
157
157
|
posture: renderLeadPosture(config).rendered,
|
|
158
158
|
firstMessage,
|
|
159
159
|
});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { dataDir, db } from "./db.js";
|
|
2
|
-
import { CROSS_SESSION_CLOSE, promptMarkerEvidenceSql, postClaudeWake, quietMessagingOn, socketGraceDue, socketSenderAddress, SOCKET_MESSAGE_FOOTER, } from "./claudeWake.js";
|
|
2
|
+
import { CROSS_SESSION_CLOSE, promptMarkerEvidenceSql, quietMessagingHooksLaunched, postClaudeWake, quietMessagingOn, socketGraceDue, socketSenderAddress, SOCKET_MESSAGE_FOOTER, } from "./claudeWake.js";
|
|
3
3
|
import { harnessFor, paneClassifierFor } from "./harnesses.js";
|
|
4
4
|
import { LEAD_MESSAGE_THRESHOLD, MESSAGE_METHOD, MESSAGE_RETENTION, MESSAGE_STATUS, renderLeadMessage, senderTag, shortenedSendNote, storeLeadMessage, } from "./leadMessage.js";
|
|
5
5
|
import { readHandoffGate } from "./leadHandoffState.js";
|
|
6
6
|
import { HANDED_OFF_STATES_SQL, readLeadTurnState } from "./leadState.js";
|
|
7
7
|
import { currentOwnership, PaneOwnershipLost, paneIdentity, requireStillOwned } from "./spawn.js";
|
|
8
|
+
import { observeUsageLimit, usageLimitGate } from "./usageLimit.js";
|
|
8
9
|
import { PANE_LEASE_SETTLE_MS, releasePaneLease, tryAcquirePaneLease } from "./leaseStore.js";
|
|
9
10
|
import { holdsHumanInput, paneInCopyMode, sendText, sleep, TmuxTimeoutError, } from "./tmux.js";
|
|
10
11
|
const EV_MARKER = "(agent_messages.sender_tag || '[message #' || agent_messages.id || ',')";
|
|
@@ -18,6 +19,12 @@ const messageEvidenceSql = (select) => `
|
|
|
18
19
|
AND ${promptMarkerEvidenceSql(messageMarkerAt)}`;
|
|
19
20
|
const RETAINED = `agent_messages.created_at >= datetime('now', '${MESSAGE_RETENTION}')`;
|
|
20
21
|
const handoffHolds = (agentId) => readHandoffGate(agentId)?.holdAutomation === true;
|
|
22
|
+
function usageLimitUntil(target) {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
observeUsageLimit(target.id, now);
|
|
25
|
+
const gate = usageLimitGate(target.id, now);
|
|
26
|
+
return gate.held ? gate.until : null;
|
|
27
|
+
}
|
|
21
28
|
const HANDOFF_HOLD_NOTE = "the lead is handing off to a fresh session; held until the successor completes its first turn";
|
|
22
29
|
function eligible(options) {
|
|
23
30
|
const { projectId, fromActor, target, submit } = options;
|
|
@@ -30,7 +37,7 @@ function eligible(options) {
|
|
|
30
37
|
const sender = db
|
|
31
38
|
.prepare("SELECT 1 AS hit FROM agents WHERE project_id = ? AND actor_id = ? AND status = 'running' AND kind = 'agent'")
|
|
32
39
|
.get(projectId, fromActor);
|
|
33
|
-
return sender !== undefined && quietMessagingOn(projectId);
|
|
40
|
+
return sender !== undefined && quietMessagingOn(projectId) && quietMessagingHooksLaunched(projectId, target.command);
|
|
34
41
|
}
|
|
35
42
|
function registeredSocket(target) {
|
|
36
43
|
return (target.claude_messaging_socket !== "" &&
|
|
@@ -67,6 +74,8 @@ function stamp(id, column) {
|
|
|
67
74
|
function socketText(id, text, tag) {
|
|
68
75
|
return `${renderLeadMessage(id, text, tag)}\n\n${SOCKET_MESSAGE_FOOTER}`;
|
|
69
76
|
}
|
|
77
|
+
class UsageLimitRace extends Error {
|
|
78
|
+
}
|
|
70
79
|
function hold(row, reason) {
|
|
71
80
|
try {
|
|
72
81
|
db.prepare("UPDATE agent_messages SET delivery_note = ? WHERE id = ? AND delivery_status = ?").run(reason, row.id, MESSAGE_STATUS.fallbackPending);
|
|
@@ -119,6 +128,10 @@ function retargetAfterHandoff(row) {
|
|
|
119
128
|
}
|
|
120
129
|
// One guarded pane attempt. The claim is the CAS that makes it at most one per message.
|
|
121
130
|
async function paneFallback(original, snapshot) {
|
|
131
|
+
const initialTarget = db.prepare("SELECT * FROM agents WHERE id = ?").get(original.to_agent_id);
|
|
132
|
+
const heldUntil = initialTarget ? usageLimitUntil(initialTarget) : null;
|
|
133
|
+
if (heldUntil !== null)
|
|
134
|
+
return hold(original, `the target harness is stopped by a usage limit until ${heldUntil}`);
|
|
122
135
|
if (handoffHolds(original.to_agent_id))
|
|
123
136
|
return hold(original, HANDOFF_HOLD_NOTE);
|
|
124
137
|
const row = retargetAfterHandoff(original);
|
|
@@ -156,6 +169,10 @@ async function paneFallback(original, snapshot) {
|
|
|
156
169
|
return hold(row, choice ? "the lead's pane is waiting on a choice" : "the lead's pane could not be read");
|
|
157
170
|
if (holdsHumanInput(classifier.inputBoxState(pane)))
|
|
158
171
|
return hold(row, "the lead's input box holds unsubmitted text");
|
|
172
|
+
const latestTarget = db.prepare("SELECT * FROM agents WHERE id = ?").get(row.to_agent_id);
|
|
173
|
+
const latestHold = latestTarget ? usageLimitUntil(latestTarget) : null;
|
|
174
|
+
if (latestHold !== null)
|
|
175
|
+
return hold(row, `the target harness is stopped by a usage limit until ${latestHold}`);
|
|
159
176
|
const claimed = (() => {
|
|
160
177
|
try {
|
|
161
178
|
return (db
|
|
@@ -173,14 +190,29 @@ async function paneFallback(original, snapshot) {
|
|
|
173
190
|
let buffered = false;
|
|
174
191
|
wrote = true;
|
|
175
192
|
try {
|
|
176
|
-
await sendText(pane,
|
|
193
|
+
await sendText(pane, target.kind === "lead"
|
|
194
|
+
? renderLeadMessage(row.id, row.text, row.sender_tag ?? "", row.socket_attempt_at !== null)
|
|
195
|
+
: `${row.sender_tag ?? ""}${row.text}`, true, () => {
|
|
177
196
|
pasted = true;
|
|
178
197
|
stamp(row.id, "typed_at");
|
|
179
198
|
}, () => {
|
|
180
199
|
buffered = true;
|
|
181
|
-
}, () =>
|
|
200
|
+
}, () => {
|
|
201
|
+
const current = db.prepare("SELECT * FROM agents WHERE id = ?").get(row.to_agent_id);
|
|
202
|
+
if (current) {
|
|
203
|
+
const until = usageLimitUntil(current);
|
|
204
|
+
if (until !== null)
|
|
205
|
+
throw new UsageLimitRace(`the target harness is stopped by a usage limit until ${until}`);
|
|
206
|
+
}
|
|
207
|
+
requireStillOwned(row.to_agent_id, identity, `message #${row.id} fallback`);
|
|
208
|
+
});
|
|
182
209
|
}
|
|
183
210
|
catch (err) {
|
|
211
|
+
if (err instanceof UsageLimitRace && !pasted) {
|
|
212
|
+
const reason = err.message;
|
|
213
|
+
setStatus(row.id, MESSAGE_STATUS.fallbackClaimed, MESSAGE_STATUS.fallbackPending, { delivery_note: reason });
|
|
214
|
+
return { kind: "held", reason };
|
|
215
|
+
}
|
|
184
216
|
const reason = pasteFailure(err, pasted, buffered);
|
|
185
217
|
setStatus(row.id, MESSAGE_STATUS.fallbackClaimed, MESSAGE_STATUS.failed, { delivery_note: reason });
|
|
186
218
|
return { kind: "failed", reason };
|
|
@@ -201,8 +233,33 @@ function earlierFallbackPending(row) {
|
|
|
201
233
|
.get(row.from_actor, row.to_agent_id, row.id, MESSAGE_STATUS.fallbackPending) !== undefined);
|
|
202
234
|
}
|
|
203
235
|
const DO_NOT_RESEND = "do not resend";
|
|
236
|
+
export async function sendUsageHeldMessage(options) {
|
|
237
|
+
if (!options.submit || options.target.status !== "running")
|
|
238
|
+
return null;
|
|
239
|
+
const until = usageLimitUntil(options.target);
|
|
240
|
+
if (until === null)
|
|
241
|
+
return null;
|
|
242
|
+
const { projectId, fromActor, target, text } = options;
|
|
243
|
+
const { id } = storeLeadMessage(projectId, fromActor, target.id, text);
|
|
244
|
+
const tag = senderTag(projectId, fromActor);
|
|
245
|
+
db.prepare(`UPDATE agent_messages SET delivery_status = ?, delivery_method = ?, delivery_note = ?,
|
|
246
|
+
target_identity = ?, sender_tag = ? WHERE id = ?`)
|
|
247
|
+
.run(MESSAGE_STATUS.fallbackPending, MESSAGE_METHOD.pty, `held for usage limit until ${until}`, JSON.stringify(paneIdentity(target)), tag, id);
|
|
248
|
+
return {
|
|
249
|
+
agent_id: target.id,
|
|
250
|
+
name: target.name,
|
|
251
|
+
sent: false,
|
|
252
|
+
pending: true,
|
|
253
|
+
message_id: id,
|
|
254
|
+
delivery_method: MESSAGE_METHOD.pty,
|
|
255
|
+
note: `Message #${id} is accepted and stored, held for the target's usage limit until ${until}; do NOT resubmit it. agent_message_get(${id}) shows its delivery state.`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
204
258
|
// null means this send is not eligible, and agent_send's ordinary pane path runs unchanged.
|
|
205
259
|
export async function sendQuietLeadMessage(options) {
|
|
260
|
+
const held = await sendUsageHeldMessage(options);
|
|
261
|
+
if (held !== null)
|
|
262
|
+
return held;
|
|
206
263
|
if (!eligible(options))
|
|
207
264
|
return null;
|
|
208
265
|
const { projectId, fromActor, target, text } = options;
|
|
@@ -231,6 +288,14 @@ export async function sendQuietLeadMessage(options) {
|
|
|
231
288
|
text: body,
|
|
232
289
|
beforeWrite: () => {
|
|
233
290
|
requireStillOwned(target.id, identity, `message #${id} socket delivery`);
|
|
291
|
+
const until = usageLimitUntil(target);
|
|
292
|
+
if (until !== null) {
|
|
293
|
+
setStatus(id, MESSAGE_STATUS.socketPending, MESSAGE_STATUS.fallbackPending, {
|
|
294
|
+
delivery_method: MESSAGE_METHOD.pty,
|
|
295
|
+
delivery_note: `held for usage limit until ${until}`,
|
|
296
|
+
});
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
234
299
|
const now = db
|
|
235
300
|
.prepare(`SELECT a.status, a.kind, a.pane_pid, a.claude_messaging_socket AS socket, a.claude_messaging_pane_pid AS pid,
|
|
236
301
|
m.delivery_status
|
|
@@ -311,7 +376,9 @@ function expireGrace() {
|
|
|
311
376
|
.all(MESSAGE_STATUS.socketPending);
|
|
312
377
|
for (const row of pending) {
|
|
313
378
|
try {
|
|
314
|
-
const target = db.prepare("SELECT
|
|
379
|
+
const target = db.prepare("SELECT * FROM agents WHERE id = ?").get(row.to_agent_id);
|
|
380
|
+
if (target && usageLimitUntil(target) !== null)
|
|
381
|
+
continue;
|
|
315
382
|
const panePid = JSON.parse(row.target_identity ?? "{}").pane_pid ?? "";
|
|
316
383
|
if (target === undefined || !graceExpired(row, target.actor_id, panePid))
|
|
317
384
|
continue;
|
|
@@ -345,6 +412,13 @@ export async function retryQuietLeadMessages(snapshot) {
|
|
|
345
412
|
if (blocked.has(pair))
|
|
346
413
|
continue;
|
|
347
414
|
try {
|
|
415
|
+
const target = db.prepare("SELECT * FROM agents WHERE id = ?").get(row.to_agent_id);
|
|
416
|
+
const until = target ? usageLimitUntil(target) : null;
|
|
417
|
+
if (until !== null) {
|
|
418
|
+
hold(row, `the target harness is stopped by a usage limit until ${until}`);
|
|
419
|
+
blocked.add(pair);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
348
422
|
const outcome = await paneFallback(row, snapshot);
|
|
349
423
|
if (outcome.kind === "held")
|
|
350
424
|
blocked.add(pair);
|