@cmgmyr/hive 1.11.9 → 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.
@@ -7,23 +7,28 @@ export { CLOSED_ROW_ORDER, findAgent, getAgentRow, notOwnedError, observeOwnersh
7
7
  import { confirmQueenWrite } from "../queenAudit.js";
8
8
  import { captureQueenWriteIdentity, resolveQueenLeadTarget } from "../context.js";
9
9
  import { agentBriefPath, mergedBriefVars, readAgentBrief, workerBrief, workerCommandString, writeAgentBrief, } from "../brief.js";
10
- import { codexHomeDir, codexInstructionsPhrase, codexLaunchArgs, ensureCodexHome, ensureCodexHooksFile, refreshCodexMcpEnv } from "../codexHome.js";
10
+ import { codexHomeDir, codexInstructionsPhrase, ensureCodexHome, ensureCodexHooksFile, findCodexFork, readCodexForkSeed, seedCodexFork } from "../codexHome.js";
11
11
  import { currentActor, findProjectForDir, getProject, linkedWorktreePrimaryRoot, resolveProject } from "../context.js";
12
12
  import { commandHead, harnessFor, harnessNames, readOnlyExtraArgsAllowed, paneClassifierFor, resolvedCommandPrefix, screenClassifiable, } from "../harnesses.js";
13
13
  import { ensureWorkerHooksFile } from "../hooks.js";
14
14
  import { activeProfile, allowedAgents, loadProjectYml } from "../projectYml.js";
15
15
  import { classifyMiss, DELIVERY_FIELDS, LEAD_MESSAGE_THRESHOLD, leadPointerMarker, missMessage, readLeadMessage, renderLeadPointer, senderTag, shortenedSendFailureClause, shortenedSendNote, storeLeadMessage, } from "../leadMessage.js";
16
- import { sendQuietLeadMessage } from "../leadMessageDelivery.js";
16
+ import { sendQuietLeadMessage, sendUsageHeldMessage } from "../leadMessageDelivery.js";
17
+ import { observeUsageLimit, usageLimitGate } from "../usageLimit.js";
17
18
  import { run } from "../result.js";
18
19
  import { markGoneReported } from "../scheduler.js";
19
- import { agentIdentityEnv, branchAt, closeAgentRow, currentOwnership, discardOrphanedPane, isReservedAgentName, isRunningLeadActor, killAgentPane, launchAgent, LEAD_KIND, PaneOwnershipLost, paneIdentity, parkAgentRow, reapCodexHomeForClosedAgent, requireStillOwned, releaseParkRow, renameAgent, resumeAgent, storedLaunchOptions, } from "../spawn.js";
20
+ import { agentIdentityEnv, branchAt, closeAgentRow, currentOwnership, discardOrphanedPane, isReservedAgentName, isRunningLeadActor, killAgentPane, launchAgent, LEAD_KIND, PaneOwnershipLost, paneIdentity, parkAgentRow, reapCodexHomeForClosedAgent, requireStillOwned, releaseParkRow, renameAgent, storedLaunchOptions, } from "../spawn.js";
20
21
  import { COMMAND_KIND, runningCommandRow, stopLine, stopProcess, STOP_REASONS } from "../processes.js";
22
+ import { markWorkerRetirementIntent } from "../workerRecovery.js";
23
+ import { contextCheckpointEnv, refuseLimitedHarness, resumeWorker } from "../agentResume.js";
21
24
  import { readContextTokens, readContextFill, resolveTranscriptDir } from "../transcript.js";
22
25
  import { leadSessionEnded } from "../leadState.js";
23
26
  import { capturePane, captureFinalScreen, DEFAULT_LAYOUT, cancelCopyMode, describePaneChoice, ensureAttached, findUnsafeControlChar, holdsHumanInput, liveTargets, observationFailed, ownershipLiveness, paneCurrentCommand, paneInCopyMode, pollPaneReadiness, rowOwnership, sendText, sessionName, setRemainOnExit, sleep, TEXT_ALLOWED_CONTROL_CHARS, tmux, TmuxTimeoutError, WINDOW_LAYOUTS, } from "../tmux.js";
24
27
  import { agentIdParam, agentNameParam, idParam, projectIdParam } from "./params.js";
25
28
  import { deriveProvenance, lastLogEvent, lastPermissionMode, reportsAgentStateLog, } from "../stateProvenance.js";
26
29
  const unknownOwnershipNote = (snapshot) => observationFailed(snapshot) ? PROBE_FAILED_NOTE : UNVERIFIED_OWNERSHIP_NOTE;
30
+ class UsageLimitActive extends Error {
31
+ }
27
32
  export function classifyLostCas(row) {
28
33
  return row.status !== "running" ? { outcome: "retired", parked: !!row.parked_at } : { outcome: "revived" };
29
34
  }
@@ -93,6 +98,24 @@ function closeRowOnly(agent, confirmSelf) {
93
98
  throw new Error("This would close your own session. Pass confirm_self=true only if the user explicitly asked you to close yourself.");
94
99
  }
95
100
  if (agent.status !== "running") {
101
+ if (agent.kind === "agent" && agent.recovery_state === "pending") {
102
+ if (!markWorkerRetirementIntent(agent.id, paneIdentity(agent))) {
103
+ throw new Error(`Agent ${agent.id} changed before its recovery was abandoned. Nothing was changed.`);
104
+ }
105
+ let releasedHome = null;
106
+ if (agent.codex_home) {
107
+ try {
108
+ releasedHome = reapCodexHomeForClosedAgent(agent.id, agent.codex_home);
109
+ }
110
+ catch {
111
+ }
112
+ }
113
+ return {
114
+ ...receipt,
115
+ ...(agent.parked_at ? { parked: true } : {}),
116
+ note: pendingRecoveryAbandonmentNote(agent.id, agent.codex_home, releasedHome),
117
+ };
118
+ }
96
119
  return {
97
120
  ...receipt,
98
121
  ...(agent.parked_at ? { parked: true } : {}),
@@ -107,6 +130,9 @@ function closeRowOnly(agent, confirmSelf) {
107
130
  ? "Restart or end the lead from its own terminal."
108
131
  : "Use agent_close without row_only to stop it."));
109
132
  }
133
+ if (agent.kind === "agent" && !markWorkerRetirementIntent(agent.id, paneIdentity(agent))) {
134
+ throw new Error(`Agent ${agent.id} changed before row-only retirement. Nothing was changed.`);
135
+ }
110
136
  if (!closeAgentRow(agent.id, paneIdentity(agent))) {
111
137
  const after = getAgentRow(agent.project_id, agent.id, "Call agent_list(include_closed: true).");
112
138
  if (after.status !== "running") {
@@ -125,6 +151,22 @@ function closeRowOnly(agent, confirmSelf) {
125
151
  (ownership === "unknown" ? " Anything still running in that pane was left alone." : ""),
126
152
  };
127
153
  }
154
+ function pendingRecoveryAbandonmentNote(agentId, codexHome, releasedHome) {
155
+ const row = db.prepare("SELECT status, recovery_state FROM agents WHERE id = ?").get(agentId);
156
+ if (row?.status === "running")
157
+ return "A concurrent resume now owns the row; no Codex home was released.";
158
+ const stateNote = row?.recovery_state === "excluded"
159
+ ? "The recovery marker changed to excluded"
160
+ : "The recovery marker could not be changed to excluded";
161
+ const homeNote = !codexHome
162
+ ? "no Codex home was associated with this row and none was released"
163
+ : releasedHome === true
164
+ ? "the Codex home directory existed and was released"
165
+ : releasedHome === false
166
+ ? "no Codex home directory existed to release"
167
+ : "the Codex home was not released because the row changed";
168
+ return `${stateNote}; ${homeNote}.`;
169
+ }
128
170
  // Rechecked just before a kill. "changed" skips the kill and lets the row CAS report the race.
129
171
  function stillOwnsPane(agent, action) {
130
172
  const now = currentOwnership(agent.id, paneIdentity(agent));
@@ -217,6 +259,65 @@ function stripSessionArgs(args) {
217
259
  }
218
260
  return stripped;
219
261
  }
262
+ function effortForAgent(agent, harness) {
263
+ const args = storedLaunchOptions(agent.extra_args).args;
264
+ if (harness === "claude") {
265
+ for (let i = args.length - 1; i >= 0; i--) {
266
+ if (args[i].startsWith("--effort="))
267
+ return args[i].slice("--effort=".length);
268
+ if (args[i] === "--effort" && i + 1 < args.length)
269
+ return args[i + 1];
270
+ }
271
+ return undefined;
272
+ }
273
+ for (let i = args.length - 1; i >= 0; i--) {
274
+ const value = args[i] === "-c" ? args[i + 1] : args[i].startsWith("-c") ? args[i].slice(2) : args[i];
275
+ const match = /^model_reasoning_effort=(low|medium|high|xhigh)$/.exec(value ?? "");
276
+ if (match)
277
+ return match[1];
278
+ }
279
+ return undefined;
280
+ }
281
+ function effortArgs(harness, effort) {
282
+ if (!effort)
283
+ return [];
284
+ const allowed = harness === "claude" ? ["low", "medium", "high", "xhigh", "max"] : ["low", "medium", "high", "xhigh"];
285
+ if (!allowed.includes(effort))
286
+ throw new Error(`effort "${effort}" is not supported by ${harness}.`);
287
+ return harness === "claude" ? ["--effort", effort] : ["-c", `model_reasoning_effort=${effort}`];
288
+ }
289
+ async function waitForCodexFork(childKey, sourceSessionId, deadline) {
290
+ while (Date.now() < deadline) {
291
+ const found = findCodexFork(childKey, sourceSessionId);
292
+ if (found)
293
+ return found;
294
+ await sleep(Math.min(250, Math.max(1, deadline - Date.now())));
295
+ }
296
+ return findCodexFork(childKey, sourceSessionId);
297
+ }
298
+ function retireForkChild(projectId, childId) {
299
+ const child = getAgentRow(projectId, childId, "Check agent_list(include_closed: true).");
300
+ if (child.status !== "running")
301
+ return;
302
+ const ownership = observeOwnership(child);
303
+ if (ownership === "unknown") {
304
+ throw new Error(`Fork child agent_id ${child.id} is retained because its pane ownership is unknown.`);
305
+ }
306
+ const live = ownership === "live" && stillOwnsPane(child, "agent_fork cleanup");
307
+ if (live)
308
+ killAgentPane(child.tmux_target);
309
+ if (!closeAgentRow(child.id, paneIdentity(child))) {
310
+ const after = getAgentRow(projectId, child.id, "Check agent_list(include_closed: true).");
311
+ if (after.status !== "closed") {
312
+ throw new Error(`Fork child agent_id ${child.id} changed during cleanup and is retained for recovery.`);
313
+ }
314
+ }
315
+ if (child.codex_home) {
316
+ const closed = getAgentRow(projectId, child.id, "Check agent_list(include_closed: true).");
317
+ if (closed.codex_home)
318
+ reapCodexHomeForClosedAgent(child.id, closed.codex_home);
319
+ }
320
+ }
220
321
  function requestsExistingSession(extraArgs) {
221
322
  return (extraArgs ?? []).some((arg) => arg === "--resume" ||
222
323
  arg.startsWith("--resume=") ||
@@ -305,14 +406,13 @@ function agentSummary(row, snapshot) {
305
406
  cwd: row.cwd,
306
407
  parent_actor_id: row.parent_actor_id,
307
408
  todo_id: row.todo_id,
409
+ ...(row.forked_from_agent_id != null
410
+ ? { forked_from_agent_id: row.forked_from_agent_id, forked_from_session_id: row.forked_from_session_id }
411
+ : {}),
308
412
  created_at: row.created_at,
309
413
  ...(dormant ? { dormant_since: dormant.ended_at } : {}),
310
414
  };
311
415
  }
312
- export function contextCheckpointEnv(config) {
313
- const value = config?.context_checkpoint_percent;
314
- return value == null ? {} : { HIVE_CONTEXT_CHECKPOINT_PERCENT: String(value) };
315
- }
316
416
  export function worktreeInstallNotice(cwd, projectPath, config) {
317
417
  const install = config?.vars?.install?.trim();
318
418
  if (!install)
@@ -324,7 +424,7 @@ export function worktreeInstallNotice(cwd, projectPath, config) {
324
424
  }
325
425
  export function registerAgents(server) {
326
426
  server.registerTool("agent_spawn", {
327
- description: "Spawn a worker agent (default: claude, or the project's hive.yml agents: default). A claude worker is briefed automatically: the full brief is appended to its system prompt, so send it its assignment directly. A command or harness that resolves to a known harness (claude, codex) not listed in the project's hive.yml agents: is refused; absent agents: means claude only. A command hive cannot classify the screen of (claude and codex both do; a harness with no entry does not) can be spawned but NOT typed into: the receipt carries brief_path and says so, and agent_send's text path and wakes both refuse that pane. Set read_only: true to block local file writes and mutating shell while keeping Hive MCP available; agent_resume preserves this mode and agent_status reports it. Only bare claude/codex executables are supported in this mode, with only effort-setting extra_args. The worker is locked to this project. Humans can watch with: tmux attach -t hive-main.",
427
+ description: "Spawn a worker agent (default: claude, or the project's hive.yml agents: default). A claude worker is briefed automatically: the full brief is appended to its system prompt, so send it its assignment directly. A command or harness that resolves to a known harness (claude, codex) not listed in the project's hive.yml agents: is refused; absent agents: means claude only. A command hive cannot classify the screen of (claude and codex both do; a harness with no entry does not) can be spawned but NOT typed into: the receipt carries brief_path and says so, and agent_send's text path and wakes both refuse that pane. A same-project worker or lead on that harness's active usage limit makes this spawn retryable until its reset or bounded deadline; hive does not queue spawns. Set read_only: true to block local file writes and mutating shell while keeping Hive MCP available; agent_resume preserves this mode and agent_status reports it. Only bare claude/codex executables are supported in this mode, with only effort-setting extra_args. The worker is locked to this project. Humans can watch with: tmux attach -t hive-main.",
328
428
  annotations: {
329
429
  readOnlyHint: false,
330
430
  destructiveHint: false,
@@ -357,7 +457,7 @@ export function registerAgents(server) {
357
457
  placement: z
358
458
  .enum(["split", "window"])
359
459
  .optional()
360
- .describe("split (default): the worker appears as a pane in the lead's window, auto-tiled, so the whole crew shares one screen. window: its own tmux window (an iTerm tab under control mode)."),
460
+ .describe("Omit this to use the project's hive.yml placement, else HIVE_SPAWN_PLACEMENT, else split. split: the worker appears as a pane in the lead's window, auto-tiled, so the whole crew shares one screen. window: its own tmux window (an iTerm tab under control mode). agent_output reads scrollback, so a crowded window does not limit what a lead can read. If workers end up in other windows, run hive layout to gather them."),
361
461
  layout: z
362
462
  .enum(WINDOW_LAYOUTS)
363
463
  .optional()
@@ -442,6 +542,8 @@ export function registerAgents(server) {
442
542
  throw new Error(`[agent_spawn:harness-not-allowed] "${harness.name}" is not in this project's hive.yml ` +
443
543
  `agents: list (${allowed.join(", ")}). Add it to agents: to allow spawning it.`);
444
544
  }
545
+ if (harnessNames().includes(harness.name))
546
+ refuseLimitedHarness(project.id, harness.name);
445
547
  if (args.read_only && (!["claude", "codex"].includes(harness.name) ||
446
548
  !/^[A-Za-z0-9_./-]+$/.test(baseCommand) ||
447
549
  !readOnlyExtraArgsAllowed(harness.name, args.extra_args))) {
@@ -488,9 +590,11 @@ export function registerAgents(server) {
488
590
  briefPath,
489
591
  });
490
592
  };
491
- const placement = args.placement ??
492
- projectConfig?.placement ??
493
- (process.env.HIVE_SPAWN_PLACEMENT === "window" ? "window" : "split");
593
+ const configuredPlacement = projectConfig?.placement ?? (process.env.HIVE_SPAWN_PLACEMENT === "window" ? "window" : "split");
594
+ const placement = args.placement ?? configuredPlacement;
595
+ const placementNote = args.placement === "window" && configuredPlacement === "split"
596
+ ? 'This worker got its own window because per-call placement "window" overrides this project\'s split config; run `hive layout` to gather it.'
597
+ : undefined;
494
598
  const layout = args.layout ?? projectConfig?.layout ?? DEFAULT_LAYOUT;
495
599
  let spawned;
496
600
  try {
@@ -617,10 +721,270 @@ export function registerAgents(server) {
617
721
  const existingNote = typeof receipt.note === "string" ? receipt.note : "";
618
722
  receipt.note = existingNote ? `${existingNote} ${omittedTodoWarning}` : omittedTodoWarning;
619
723
  }
724
+ if (placementNote) {
725
+ const existingNote = typeof receipt.note === "string" ? receipt.note : "";
726
+ receipt.note = existingNote ? `${existingNote} ${placementNote}` : placementNote;
727
+ }
620
728
  return receipt;
621
729
  }));
730
+ server.registerTool("agent_fork", {
731
+ description: "Fork a same-project Claude Code or Codex worker into a new worker with a new identity and pane. The child starts from a copy of the source conversation, gets a fresh brief, and does not inherit the source todo. Claude Code process-scoped permission grants are not copied and may need approval again.",
732
+ annotations: {
733
+ readOnlyHint: false,
734
+ destructiveHint: false,
735
+ idempotentHint: false,
736
+ openWorldHint: false,
737
+ },
738
+ inputSchema: {
739
+ agent_id: idParam.describe("Numeric id of the same-project worker to fork."),
740
+ name: z.string().describe("Required name for the new child worker."),
741
+ cwd: z.string().optional().describe("Child working directory; defaults to the source worker's directory."),
742
+ model: z.string().min(1).optional().describe("Model override; defaults to the source worker's model."),
743
+ effort: z.enum(["low", "medium", "high", "xhigh", "max"]).optional().describe("Reasoning effort override; defaults to the source worker's effort."),
744
+ read_only: z.boolean().optional().describe("Read-only mode override; defaults to the source worker's mode."),
745
+ todo_id: idParam.optional().describe("Explicit todo for this new child; absent means no todo link."),
746
+ placement: z.enum(["split", "window"]).optional(),
747
+ layout: z.enum(WINDOW_LAYOUTS).optional(),
748
+ project_id: projectIdParam,
749
+ },
750
+ outputSchema: {
751
+ agent_id: idParam,
752
+ actor_id: z.string(),
753
+ name: z.string(),
754
+ tmux_target: z.string(),
755
+ forked_from_agent_id: idParam,
756
+ forked_from_session_id: z.string(),
757
+ session_id: z.string(),
758
+ ready: z.boolean(),
759
+ permission_note: z.string(),
760
+ note: z.string().optional(),
761
+ tail: z.string().optional(),
762
+ },
763
+ }, (args) => run(async () => {
764
+ const callerProject = resolveProject();
765
+ const project = args.project_id === undefined ? callerProject : resolveProject(args.project_id);
766
+ if (project.id !== callerProject.id)
767
+ throw new Error("agent_fork is same-project only; source and child must belong to this session's project.");
768
+ const source = getAgentRow(project.id, args.agent_id, "Call agent_list(include_closed: true). agent_fork is same-project only.");
769
+ if (source.kind !== "agent")
770
+ throw new Error("agent_fork only accepts a worker, not a lead or command row.");
771
+ if (source.status !== "running" && source.status !== "closed") {
772
+ throw new Error(`Agent ${source.id} cannot be forked from status ${source.status}.`);
773
+ }
774
+ const harness = harnessFor(source.command);
775
+ refuseLimitedHarness(project.id, harness.name, "agent_fork");
776
+ if (!["claude", "codex"].includes(harness.name)) {
777
+ throw new Error(`Agent ${source.id} uses unsupported fork harness "${harness.name}".`);
778
+ }
779
+ if (harness.name === "codex" && source.status === "closed" && !source.parked_at) {
780
+ throw new Error(`Closed Codex agent ${source.id} is not parked; its fork home may have been reaped.`);
781
+ }
782
+ if (harness.name === "codex" && !source.codex_home) {
783
+ throw new Error(`Codex agent ${source.id} has no recorded CODEX_HOME; it cannot be forked.`);
784
+ }
785
+ if (!source.session_id)
786
+ throw new Error(`Agent ${source.id} has no recorded session id and cannot be forked.`);
787
+ if (!source.cwd || !existsSync(source.cwd) || !statSync(source.cwd).isDirectory()) {
788
+ throw new Error(`Agent ${source.id} has no readable working directory and cannot be forked.`);
789
+ }
790
+ if (!source.transcript_path || !existsSync(source.transcript_path) || !statSync(source.transcript_path).isFile()) {
791
+ throw new Error(`Agent ${source.id} has no readable transcript and cannot be forked.`);
792
+ }
793
+ const codexSeed = harness.name === "codex" ? readCodexForkSeed(source) : undefined;
794
+ const name = normalizeAgentName(args.name, "name");
795
+ requireNameFree(project.id, name);
796
+ let cwd = source.cwd;
797
+ if (args.cwd !== undefined) {
798
+ cwd = realpathSync(args.cwd);
799
+ if (!statSync(cwd).isDirectory())
800
+ throw new Error(`cwd is not a directory: ${args.cwd}`);
801
+ const cwdProject = findProjectForDir(cwd);
802
+ if (cwdProject && cwdProject.id !== project.id) {
803
+ throw new Error(`cwd ${cwd} belongs to project "${cwdProject.name}" (id ${cwdProject.id}), not source project "${project.name}" (id ${project.id}).`);
804
+ }
805
+ }
806
+ if (args.todo_id !== undefined) {
807
+ const linked = db.prepare("SELECT archived_at FROM todos WHERE id = ? AND project_id = ?").get(args.todo_id, project.id);
808
+ if (!linked)
809
+ throw new Error(`todo_id ${args.todo_id} is not a todo in project "${project.name}" (id ${project.id}).`);
810
+ if (linked.archived_at != null)
811
+ throw new Error(`todo_id ${args.todo_id} is archived; unarchive it or link another todo.`);
812
+ }
813
+ const model = args.model ?? source.model ?? extractCommandFlag(source.command, "--model");
814
+ const launchOptions = storedLaunchOptions(source.extra_args);
815
+ const readOnly = args.read_only ?? launchOptions.readOnly;
816
+ const effort = args.effort ?? effortForAgent(source, harness.name);
817
+ const forkEffort = effortArgs(harness.name, effort);
818
+ const projectConfig = loadProjectYml(project.path).config;
819
+ const placement = args.placement ?? projectConfig?.placement ?? (process.env.HIVE_SPAWN_PLACEMENT === "window" ? "window" : "split");
820
+ const layout = args.layout ?? projectConfig?.layout ?? DEFAULT_LAYOUT;
821
+ const childSessionId = harness.name === "claude" ? randomUUID() : "";
822
+ const codexHomeKey = harness.name === "codex" ? randomUUID() : undefined;
823
+ const deadline = Date.now() + PANE_READY_MS;
824
+ let instructionLayers = [];
825
+ const buildCommand = ({ agentId, actorId }) => {
826
+ const brief = workerBrief({
827
+ name,
828
+ actorId,
829
+ projectName: project.name,
830
+ projectPath: project.path,
831
+ cwd,
832
+ profile: activeProfile(projectConfig),
833
+ vars: mergedBriefVars(projectConfig?.vars, harness.name),
834
+ });
835
+ if (harness.name === "codex" && codexHomeKey) {
836
+ const home = ensureCodexHome({
837
+ key: codexHomeKey,
838
+ actorId,
839
+ serverEnv: agentIdentityEnv(actorId, name, project.path),
840
+ cwd,
841
+ brief,
842
+ readOnly,
843
+ includePostToolUse: projectConfig?.context_checkpoint_percent != null,
844
+ });
845
+ instructionLayers = home.instructionLayers;
846
+ ensureCodexHooksFile(codexHomeKey, { includePostToolUse: projectConfig?.context_checkpoint_percent != null });
847
+ seedCodexFork(source, codexHomeKey, codexSeed);
848
+ if (findCodexFork(codexHomeKey, source.session_id)) {
849
+ throw new Error("Fresh Codex fork home already contains a child rollout before launch.");
850
+ }
851
+ return workerCommandString({
852
+ command: resolvedCommandPrefix(source.command) || "codex",
853
+ model,
854
+ displayName: name,
855
+ extraArgs: [...home.extraArgs, "-C", cwd, ...forkEffort, "fork", source.session_id],
856
+ });
857
+ }
858
+ const briefPath = writeAgentBrief(agentId, brief);
859
+ const settingsPath = ensureWorkerHooksFile(agentId, { readOnly, includePostToolUse: projectConfig?.context_checkpoint_percent != null });
860
+ return workerCommandString({
861
+ command: resolvedCommandPrefix(source.command) || "claude",
862
+ model,
863
+ displayName: name,
864
+ settingsPath,
865
+ briefPath,
866
+ extraArgs: [...forkEffort, "--session-id", childSessionId, "--resume", source.session_id, "--fork-session"],
867
+ });
868
+ };
869
+ let spawned;
870
+ try {
871
+ spawned = launchAgent({
872
+ projectId: project.id,
873
+ projectName: project.name,
874
+ projectPath: project.path,
875
+ name,
876
+ kind: "agent",
877
+ commandString: buildCommand,
878
+ cwd,
879
+ env: { ...contextCheckpointEnv(projectConfig), ...(codexHomeKey ? { CODEX_HOME: codexHomeDir(codexHomeKey) } : {}) },
880
+ placement,
881
+ layout,
882
+ parentActor: currentActor(),
883
+ sessionId: childSessionId,
884
+ codexHome: codexHomeKey,
885
+ model,
886
+ extraArgs: forkEffort,
887
+ readOnly,
888
+ todoId: args.todo_id,
889
+ forkedFromAgentId: source.id,
890
+ forkedFromSessionId: source.session_id,
891
+ retainOnExit: true,
892
+ });
893
+ }
894
+ catch (error) {
895
+ if (codexHomeKey)
896
+ rmSync(codexHomeDir(codexHomeKey), { recursive: true, force: true });
897
+ throw error;
898
+ }
899
+ const classifier = paneClassifierFor(source.command);
900
+ const readiness = await pollPaneReadiness(spawned.target, Math.max(0, deadline - Date.now()), classifier.hasInputBox);
901
+ if (harness.name === "codex") {
902
+ let found;
903
+ try {
904
+ found = await waitForCodexFork(codexHomeKey, source.session_id, deadline);
905
+ }
906
+ catch (error) {
907
+ try {
908
+ retireForkChild(project.id, spawned.agentId);
909
+ }
910
+ catch (cleanupError) {
911
+ throw new Error(`Codex fork discovery failed (${String(error)}); child agent_id ${spawned.agentId} needs recovery (${String(cleanupError)}).`);
912
+ }
913
+ throw error;
914
+ }
915
+ if (!found) {
916
+ try {
917
+ retireForkChild(project.id, spawned.agentId);
918
+ }
919
+ catch (cleanupError) {
920
+ throw new Error(`Codex fork session was not identified within ${PANE_READY_MS}ms; child agent_id ${spawned.agentId} needs recovery (${String(cleanupError)}).`);
921
+ }
922
+ throw new Error(`Codex fork session was not identified within ${PANE_READY_MS}ms; child agent_id ${spawned.agentId} was retired.`);
923
+ }
924
+ if (readiness === "gone") {
925
+ retireForkChild(project.id, spawned.agentId);
926
+ throw new Error(`Codex fork child agent_id ${spawned.agentId} exited before it became ready.`);
927
+ }
928
+ const child = getAgentRow(project.id, spawned.agentId, "Call agent_list(include_closed: true).");
929
+ if (!stillOwnsPane(child, "agent_fork session discovery")) {
930
+ retireForkChild(project.id, spawned.agentId);
931
+ throw new Error(`Codex fork child agent_id ${spawned.agentId} no longer owns its pane.`);
932
+ }
933
+ const bound = db.prepare("UPDATE agents SET session_id = ?, transcript_path = ? WHERE id = ? AND status = 'running' AND tmux_target = ? AND tmux_socket = ? AND pane_pid = ? AND session_id = '' AND forked_from_session_id = ?").run(found.sessionId, found.transcriptPath, child.id, child.tmux_target, child.tmux_socket, child.pane_pid, source.session_id);
934
+ if (bound.changes !== 1) {
935
+ retireForkChild(project.id, spawned.agentId);
936
+ throw new Error(`Codex fork identity could not be bound to child agent_id ${spawned.agentId}; the child was retired.`);
937
+ }
938
+ const choice = readiness === "ready" ? classifier.choiceCheck(spawned.target) : { awaitingChoice: null, tail: "" };
939
+ return {
940
+ agent_id: spawned.agentId,
941
+ actor_id: spawned.actorId,
942
+ name,
943
+ tmux_target: spawned.target,
944
+ forked_from_agent_id: source.id,
945
+ forked_from_session_id: source.session_id,
946
+ session_id: found.sessionId,
947
+ ready: readiness === "ready" && choice.awaitingChoice !== true,
948
+ permission_note: "Claude Code process-scoped permission grants are not copied to a fork; approve them again in the child if prompted.",
949
+ ...(readiness !== "ready" || choice.awaitingChoice === true ? {
950
+ note: readiness === "timeout" ? "The child session was found, but its pane did not reach a classifiable input box before the readiness deadline." : "The child session was found, but the pane is waiting on a choice. Clear it before sending an assignment.",
951
+ tail: choice.tail || capturePaneQuietly(spawned.target),
952
+ } : {}),
953
+ };
954
+ }
955
+ let tail;
956
+ let exited = false;
957
+ let dialog = false;
958
+ if (readiness === "gone") {
959
+ exited = true;
960
+ tail = captureFinalScreen(spawned.target, 30);
961
+ db.prepare("UPDATE agents SET exit_tail = ? WHERE id = ?").run(tail, spawned.agentId);
962
+ discardOrphanedPane(spawned.target);
963
+ }
964
+ else if (readiness === "ready") {
965
+ const choice = classifier.choiceCheck(spawned.target);
966
+ dialog = choice.awaitingChoice === true;
967
+ if (dialog)
968
+ tail = choice.tail;
969
+ }
970
+ else {
971
+ tail = capturePaneQuietly(spawned.target);
972
+ }
973
+ return {
974
+ agent_id: spawned.agentId,
975
+ actor_id: spawned.actorId,
976
+ name,
977
+ tmux_target: spawned.target,
978
+ forked_from_agent_id: source.id,
979
+ forked_from_session_id: source.session_id,
980
+ session_id: childSessionId,
981
+ ready: readiness === "ready" && !dialog,
982
+ permission_note: "Claude Code process-scoped permission grants are not copied to a fork; approve them again in the child if prompted.",
983
+ ...(exited ? { note: "The forked session was created, but its pane exited before becoming ready.", tail } : dialog ? { note: "The forked session is waiting on a choice. Clear it before sending an assignment.", tail } : readiness === "timeout" ? { note: "The forked session was created, but its pane did not reach a classifiable input box before the readiness deadline.", tail } : {}),
984
+ };
985
+ }));
622
986
  server.registerTool("agent_resume", {
623
- description: "Resume a CLOSED claude or codex worker from its recorded session id (claude --resume / codex resume): a fresh pane, the same actor_id, and the worker's full prior context. Addressed by name or agent_id among closed agents (agent_list(include_closed: true)). Send it its next instruction with agent_send once resumed - this tool does not.",
987
+ description: "Resume a CLOSED claude or codex worker from its recorded session id (claude --resume / codex resume): a fresh pane, the same actor_id, and the worker's full prior context. A same-project worker or lead on that harness's active usage limit makes this resume retryable until its reset or bounded deadline; hive does not queue resumes. Addressed by name or agent_id among closed agents (agent_list(include_closed: true)). Send it its next instruction with agent_send once resumed - this tool does not.",
624
988
  annotations: {
625
989
  readOnlyHint: false,
626
990
  destructiveHint: false,
@@ -647,120 +1011,9 @@ export function registerAgents(server) {
647
1011
  }, (args) => run(async () => {
648
1012
  const project = resolveProject(args.project_id);
649
1013
  const agent = findClosedAgent(project.id, args);
650
- if (agent.kind === LEAD_KIND) {
651
- throw new Error(`Agent ${agent.id} ("${agent.name}") is this project's lead session. agent_resume is for workers; ` +
652
- "start a lead session with `hive lead`.");
653
- }
654
- const resumeHarness = harnessFor(agent.command);
655
- if (!resumeHarness.supportsResume) {
656
- throw new Error(`Agent ${agent.id} ("${agent.name}")'s harness ("${resumeHarness.name}", command: "${agent.command}") ` +
657
- "does not support resume, so it has no session id to resume from.");
658
- }
659
- if (!agent.session_id) {
660
- throw new Error(`Agent ${agent.id} ("${agent.name}") has no recorded session id, so it cannot be resumed. It may ` +
661
- "predate this feature, or it may have closed before its first hook event ever fired. Spawn a new " +
662
- "worker instead.");
663
- }
664
- if (resumeHarness.name === "codex" && !agent.codex_home) {
665
- throw new Error(`Agent ${agent.id} ("${agent.name}") has no recorded CODEX_HOME, so its session cannot be resumed. ` +
666
- (agent.parked_at
667
- ? "This should not happen for a parked codex worker - its home is only reaped on agent_close. " +
668
- "Check agent_status for how it actually got here."
669
- : "It was closed with agent_close, which reaps a codex worker's home (rollout files included) " +
670
- "immediately - only a PARKED codex worker keeps its home. Nothing here to resume; spawn a new " +
671
- "worker instead."));
672
- }
673
- if (!existsSync(agent.cwd)) {
674
- const recreate = agent.parked_branch
675
- ? `git worktree add ${agent.cwd} ${agent.parked_branch}`
676
- : `recreate a checkout at ${agent.cwd} (no branch was recorded for it - agent_park records one)`;
677
- const reason = resumeHarness.name === "codex"
678
- ? "It resumes into that directory as its working directory"
679
- : "Its transcript is resolved from that path";
680
- throw new Error(`Agent ${agent.id} ("${agent.name}")'s working directory is gone: ${agent.cwd}. ${reason}, so ` +
681
- `recreate it and the resume works unchanged: ${recreate}`);
682
- }
683
- const branchNow = agent.parked_branch ? branchAt(agent.cwd) : "";
684
- const branchDrift = branchNow && branchNow !== agent.parked_branch
685
- ? { parked_branch: agent.parked_branch, branch_now: branchNow }
686
- : null;
687
- const collision = runningAgentNamed(project.id, agent.name);
688
- if (collision) {
689
- throw new Error(`Cannot resume agent ${agent.id} ("${agent.name}") under its recorded name: a running agent ` +
690
- `(agent ${collision.id}) already has it. agent_resume does not rename on your behalf - the ` +
691
- `${agent.parked_at ? "parked" : "closed"} lane is not lost, it just cannot come back under a name ` +
692
- "someone else is using. Free the name first (agent_rename or agent_close on the running one), " +
693
- `then retry agent_resume(agent_id: ${agent.id}).`);
694
- }
695
- const { config: projectConfig } = loadProjectYml(project.path);
696
- const placement = projectConfig?.placement ?? (process.env.HIVE_SPAWN_PLACEMENT === "window" ? "window" : "split");
697
- const layout = projectConfig?.layout ?? DEFAULT_LAYOUT;
698
- const launchOptions = storedLaunchOptions(agent.extra_args);
699
- let commandString;
700
- let resumeEnv = contextCheckpointEnv(projectConfig);
701
- if (resumeHarness.name === "codex") {
702
- commandString = workerCommandString({
703
- command: resolvedCommandPrefix(agent.command) || "codex",
704
- // Recovered from the row's own recorded command: every codex worker hive spawns
705
- // carries a pinned model (codex has no bare alias), so dropping this would silently
706
- // resume on codex's default model instead of the one the lane actually chose.
707
- model: agent.model ?? extractCommandFlag(agent.command, "--model"),
708
- displayName: agent.name,
709
- // Subcommand positional, not a flag - live-verified on v0.149.0 (`codex resume --help`:
710
- // "Usage: codex resume [OPTIONS] [SESSION_ID] [PROMPT]"). No --settings/hooks file: a
711
- // codex worker's hooks come from its own home's hooks.json, never claude's shared one.
712
- extraArgs: [
713
- ...codexLaunchArgs(agent.cwd, launchOptions.readOnly),
714
- ...stripSessionArgs(launchOptions.args),
715
- "resume",
716
- agent.session_id,
717
- ],
718
- });
719
- resumeEnv = { ...resumeEnv, CODEX_HOME: codexHomeDir(agent.codex_home) };
720
- ensureCodexHooksFile(agent.codex_home, { includePostToolUse: projectConfig?.context_checkpoint_percent != null });
721
- refreshCodexMcpEnv(agent.codex_home, agentIdentityEnv(agent.actor_id, agent.name, project.path));
722
- }
723
- else {
724
- const claudeBinary = resolvedCommandPrefix(agent.command) || "claude";
725
- commandString = workerCommandString({
726
- command: claudeBinary,
727
- displayName: agent.name,
728
- model: agent.model ?? undefined,
729
- extraArgs: [
730
- ...stripSessionArgs(launchOptions.args),
731
- "--resume",
732
- agent.session_id,
733
- ],
734
- settingsPath: ensureWorkerHooksFile(agent.id, { readOnly: launchOptions.readOnly, includePostToolUse: projectConfig?.context_checkpoint_percent != null }),
735
- });
736
- }
737
- const { target, landedInProjectId } = resumeAgent({
738
- agentId: agent.id,
739
- actorId: agent.actor_id,
740
- name: agent.name,
741
- projectId: project.id,
742
- projectName: project.name,
743
- projectPath: project.path,
744
- cwd: agent.cwd,
745
- commandString,
746
- env: resumeEnv,
747
- placement,
748
- layout,
749
- parentActor: currentActor(),
750
- });
1014
+ const receipt = await resumeWorker(project.id, agent.id, { parentActor: currentActor() });
751
1015
  ensureAttached(sessionName());
752
- return {
753
- agent_id: agent.id,
754
- actor_id: agent.actor_id,
755
- name: agent.name,
756
- tmux_target: target,
757
- resumed_session_id: agent.session_id,
758
- ...(agent.parked_at ? { was_parked_at: agent.parked_at } : {}),
759
- ...(branchDrift ? { branch_drift: branchDrift } : {}),
760
- ...(landedInProjectId != null
761
- ? { landed_in_project: getProject(landedInProjectId)?.name ?? `project ${landedInProjectId}` }
762
- : {}),
763
- };
1016
+ return receipt;
764
1017
  }));
765
1018
  server.registerTool("agent_park", {
766
1019
  description: "Park a claude or codex worker for the night: kill its pane, mark the row PARKED rather than plain closed, record the branch, and hand back a board line plus the one call that brings it back. Use this instead of agent_close when the lane is paused, not finished - `closed` alone cannot tell a next-morning lead which is which. Resume it with agent_resume.",
@@ -825,6 +1078,9 @@ export function registerAgents(server) {
825
1078
  const branch = branchAt(agent.cwd);
826
1079
  if (live)
827
1080
  live = stillOwnsPane(agent, "agent_park");
1081
+ if (agent.kind === "agent" && !markWorkerRetirementIntent(agent.id, paneIdentity(agent))) {
1082
+ throw new Error(`Agent ${agent.id} changed before its park intent was recorded. Nothing was changed.`);
1083
+ }
828
1084
  if (live)
829
1085
  killAgentPane(agent.tmux_target);
830
1086
  const parkedAt = parkAgentRow(agent.id, paneIdentity(agent), branch);
@@ -891,11 +1147,18 @@ export function registerAgents(server) {
891
1147
  throw notOwnedError(agent, ownership);
892
1148
  const live = ownership === "live";
893
1149
  const identity = paneIdentity(agent);
1150
+ const renameHarness = harnessFor(agent.command);
1151
+ if (live && renameHarness.supportsRename) {
1152
+ observeUsageLimit(agent.id, Date.now());
1153
+ const limit = usageLimitGate(agent.id, Date.now());
1154
+ if (limit.held) {
1155
+ throw new Error(`agent_rename is retryable after the target usage limit at ${limit.until}; the row and pane were not renamed.`);
1156
+ }
1157
+ }
894
1158
  renameAgent(agent, newName, live ? project.name : null);
895
1159
  let retitled = false;
896
1160
  let heldNote;
897
1161
  let heldTail;
898
- const renameHarness = harnessFor(agent.command);
899
1162
  if (live && renameHarness.supportsRename) {
900
1163
  const classifier = renameHarness.paneClassifier;
901
1164
  const { awaitingChoice, tail } = classifier.choiceCheck(agent.tmux_target);
@@ -1080,7 +1343,7 @@ export function registerAgents(server) {
1080
1343
  };
1081
1344
  }));
1082
1345
  server.registerTool("agent_send", {
1083
- description: "Type into an agent's terminal, addressed by name (or agent_id). text of any shape is prefixed with the sender tag, delivered as one bracketed paste and submitted with Enter unless submit=false. ONE EXCEPTION: text over 300 characters sent to a LEAD by anyone who is not that lead is stored and delivered as a one-line pointer instead, because a lead's pane is a human's own window; the receipt says so and names agent_message_get for the full text. Worker-bound text is never shortened at any length. With quiet_messaging on, a worker's submitted text to a Claude lead is stored with a message id and posted over the lead's messaging socket instead of typed (the same pointer when over 300 characters): the receipt says pending until the lead's prompt confirms it, hive types it into the pane once if no confirmation comes, and the socket post skips the pane checks below; do not resend a pending send. Alternatively pass keys (tmux key names like Escape, C-c, Enter). wait_ms (250-10000) returns the terminal tail after sending. A claude worker is already briefed by agent_spawn. A worker whose screen hive cannot classify is REFUSED on the text path entirely (its brief is at the spawn receipt's brief_path); keys still reaches it. A pane in tmux copy mode is REFUSED too, and retriably: tmux clears its bracketed-paste flag there, so the paste would lose its markers and the Enter would be eaten - leave copy mode (or agent_send(keys: [\"-X\", \"cancel\"]) to cancel it deliberately) and send again.",
1346
+ description: "Type into an agent's terminal, addressed by name (or agent_id). text of any shape is prefixed with the sender tag, delivered as one bracketed paste and submitted with Enter unless submit=false. A submitted text send to a target on its current usage limit is stored and held in its message row until release; its receipt says pending, so do not resend it. keys and submit=false remain manual recovery paths. ONE EXCEPTION: text over 300 characters sent to a LEAD by anyone who is not that lead is stored and delivered as a one-line pointer instead, because a lead's pane is a human's own window; the receipt says so and names agent_message_get for the full text. Worker-bound text is never shortened at any length. With quiet_messaging on, a worker's submitted text to a Claude lead is stored with a message id and posted over the lead's messaging socket instead of typed (the same pointer when over 300 characters): the receipt says pending until the lead's prompt confirms it, hive types it into the pane once if no confirmation comes, and the socket post skips the pane checks below; do not resend a pending send. Alternatively pass keys (tmux key names like Escape, C-c, Enter). wait_ms (250-10000) returns the terminal tail after sending. A claude worker is already briefed by agent_spawn. A worker whose screen hive cannot classify is REFUSED on the text path entirely (its brief is at the spawn receipt's brief_path); keys still reaches it. A pane in tmux copy mode is REFUSED too, and retriably: tmux clears its bracketed-paste flag there, so the paste would lose its markers and the Enter would be eaten - leave copy mode (or agent_send(keys: [\"-X\", \"cancel\"]) to cancel it deliberately) and send again.",
1084
1347
  annotations: {
1085
1348
  readOnlyHint: false,
1086
1349
  destructiveHint: true,
@@ -1139,13 +1402,14 @@ export function registerAgents(server) {
1139
1402
  "turning a text call into a keys call. Tab and newline are the only control characters " +
1140
1403
  'allowed. To send an actual keystroke on purpose, use keys instead (e.g. keys: ["C-c"]).');
1141
1404
  }
1142
- const quiet = await sendQuietLeadMessage({
1405
+ const messageOptions = {
1143
1406
  projectId: project.id,
1144
1407
  fromActor: currentActor(),
1145
1408
  target: agent,
1146
1409
  text: args.text,
1147
1410
  submit: args.submit !== false,
1148
- });
1411
+ };
1412
+ const quiet = await sendUsageHeldMessage(messageOptions) ?? await sendQuietLeadMessage(messageOptions);
1149
1413
  if (quiet !== null) {
1150
1414
  if (quiet.sent) {
1151
1415
  confirmQueenWrite("agent_send", project.id, args, { sent: true, agent_id: agent.id }, auditIdentity);
@@ -1233,6 +1497,15 @@ export function registerAgents(server) {
1233
1497
  };
1234
1498
  }
1235
1499
  }
1500
+ const newlyHeld = await sendUsageHeldMessage({
1501
+ projectId: project.id,
1502
+ fromActor: currentActor(),
1503
+ target: agent,
1504
+ text: args.text,
1505
+ submit: submitting,
1506
+ });
1507
+ if (newlyHeld !== null)
1508
+ return newlyHeld;
1236
1509
  if (agent.kind === LEAD_KIND &&
1237
1510
  !isRunningLeadActor(currentActor()) &&
1238
1511
  args.text.length > LEAD_MESSAGE_THRESHOLD) {
@@ -1257,9 +1530,32 @@ export function registerAgents(server) {
1257
1530
  pasted = true;
1258
1531
  }, () => {
1259
1532
  buffered = true;
1260
- }, () => requireStillOwned(agent.id, identity, "agent_send"));
1533
+ }, () => {
1534
+ requireStillOwned(agent.id, identity, "agent_send");
1535
+ if (submitting) {
1536
+ observeUsageLimit(agent.id, Date.now());
1537
+ const gate = usageLimitGate(agent.id, Date.now());
1538
+ if (gate.held)
1539
+ throw new UsageLimitActive(`the target harness is stopped by a usage limit until ${gate.until}`);
1540
+ }
1541
+ });
1261
1542
  }
1262
1543
  catch (err) {
1544
+ if (err instanceof UsageLimitActive && !pasted) {
1545
+ const held = await sendUsageHeldMessage({
1546
+ projectId: project.id,
1547
+ fromActor: currentActor(),
1548
+ target: agent,
1549
+ text: args.text,
1550
+ submit: submitting,
1551
+ });
1552
+ if (held !== null)
1553
+ return held;
1554
+ }
1555
+ if (err instanceof UsageLimitActive && pasted) {
1556
+ throw new Error(`[agent_send:usage-limit-after-paste] ${err.message}. The text was pasted but no Enter was sent; ` +
1557
+ "read the pane before deciding whether to submit or clear it.", { cause: err });
1558
+ }
1263
1559
  const shortenedClause = shortened === null ? "" : shortenedSendFailureClause(shortened.marker, shortened.message_id);
1264
1560
  if (err instanceof PaneOwnershipLost) {
1265
1561
  if (!pasted)
@@ -1434,6 +1730,25 @@ export function registerAgents(server) {
1434
1730
  }
1435
1731
  return { agent_id: agent.id, name: agent.name, closed: true, park_released: true };
1436
1732
  }
1733
+ if (agent.status === "closed" && agent.kind === "agent" && agent.recovery_state === "pending") {
1734
+ if (!markWorkerRetirementIntent(agent.id, paneIdentity(agent))) {
1735
+ throw new Error(`Agent ${agent.id} changed before its recovery was abandoned. Nothing was changed.`);
1736
+ }
1737
+ let releasedHome = null;
1738
+ if (agent.codex_home) {
1739
+ try {
1740
+ releasedHome = reapCodexHomeForClosedAgent(agent.id, agent.codex_home);
1741
+ }
1742
+ catch {
1743
+ }
1744
+ }
1745
+ return {
1746
+ agent_id: agent.id,
1747
+ name: agent.name,
1748
+ closed: true,
1749
+ note: pendingRecoveryAbandonmentNote(agent.id, agent.codex_home, releasedHome),
1750
+ };
1751
+ }
1437
1752
  const ownership = agent.status === "running" ? observeOwnership(agent) : "gone";
1438
1753
  if (ownership === "unknown") {
1439
1754
  throw new Error(`Agent ${agent.id} ("${agent.name}"): pane ownership reads unknown, so agent_close neither stops nor ` +
@@ -1465,6 +1780,9 @@ export function registerAgents(server) {
1465
1780
  }
1466
1781
  if (live)
1467
1782
  live = stillOwnsPane(agent, "agent_close");
1783
+ if (agent.kind === "agent" && !markWorkerRetirementIntent(agent.id, paneIdentity(agent))) {
1784
+ throw new Error(`Agent ${agent.id} changed before its close intent was recorded. Nothing was changed.`);
1785
+ }
1468
1786
  if (live)
1469
1787
  killAgentPane(agent.tmux_target);
1470
1788
  if (!closeAgentRow(agent.id, paneIdentity(agent))) {