@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/tools/agents.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
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
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}, () =>
|
|
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))) {
|