@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/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
@@ -354,6 +354,7 @@ export const QUEEN_REACH = {
354
354
  lease_release: "home",
355
355
  agent_spawn: "home",
356
356
  agent_resume: "home",
357
+ agent_fork: "home",
357
358
  agent_park: "home",
358
359
  agent_rename: "home",
359
360
  agent_close: "home",
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.auto_handoff on, a lead past its warn budget gets
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 (typeof row.codex_home === "string" && row.codex_home !== "") {
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
- const row = db.prepare("SELECT kind, command, status, codex_home, session_id FROM agents WHERE actor_id = ?")
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")],
@@ -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.auto_handoff !== true)
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?.auto_handoff !== true) {
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?.auto_handoff !== true)
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" };
@@ -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?.quiet_messaging === true),
156
- sidebarPluginDir: config?.lead_sidebar === true ? crewPluginDir() : null,
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, renderLeadMessage(row.id, row.text, row.sender_tag ?? "", row.socket_attempt_at !== null), true, () => {
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
- }, () => requireStillOwned(row.to_agent_id, identity, `message #${row.id} fallback`));
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 actor_id FROM agents WHERE id = ?").get(row.to_agent_id);
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);