@cmgmyr/hive 0.1.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/LICENSE +21 -0
- package/README.md +70 -0
- package/claude-plugin/.claude-plugin/plugin.json +7 -0
- package/claude-plugin/README.md +47 -0
- package/claude-plugin/hooks/hooks.json +14 -0
- package/claude-plugin/kickoff.mjs +41 -0
- package/claude-plugin/skills/cleanup/SKILL.md +60 -0
- package/dist/abi.js +220 -0
- package/dist/abiProbe.js +9 -0
- package/dist/backgroundTasks.js +51 -0
- package/dist/backup.js +378 -0
- package/dist/brief.js +113 -0
- package/dist/build-info.json +1 -0
- package/dist/claudeDir.js +5 -0
- package/dist/cli.js +2901 -0
- package/dist/codexHome.js +297 -0
- package/dist/config.js +72 -0
- package/dist/context.js +256 -0
- package/dist/contextCheckpoint.js +55 -0
- package/dist/dashboard.js +1158 -0
- package/dist/dataDir.js +89 -0
- package/dist/db.js +421 -0
- package/dist/dispatcher.js +150 -0
- package/dist/firstPrompt.js +6 -0
- package/dist/harnesses.js +173 -0
- package/dist/help.js +353 -0
- package/dist/hook.js +197 -0
- package/dist/hooks.js +50 -0
- package/dist/index.js +34 -0
- package/dist/kickoff.js +191 -0
- package/dist/leadMessage.js +98 -0
- package/dist/mcpConfig.js +121 -0
- package/dist/processes.js +161 -0
- package/dist/profiles.js +278 -0
- package/dist/projectYml.js +254 -0
- package/dist/prompts.js +18 -0
- package/dist/ptys.js +146 -0
- package/dist/result.js +53 -0
- package/dist/scheduler.js +1622 -0
- package/dist/sessionProbe.js +131 -0
- package/dist/slug.js +29 -0
- package/dist/spawn.js +370 -0
- package/dist/stateProvenance.js +97 -0
- package/dist/statusline.js +82 -0
- package/dist/strictInput.js +52 -0
- package/dist/teardown.js +89 -0
- package/dist/tmux.js +1295 -0
- package/dist/tools/agents.js +1246 -0
- package/dist/tools/kv.js +89 -0
- package/dist/tools/leases.js +87 -0
- package/dist/tools/meta.js +183 -0
- package/dist/tools/pads.js +279 -0
- package/dist/tools/params.js +14 -0
- package/dist/tools/todos.js +382 -0
- package/dist/tools/wakes.js +466 -0
- package/dist/transcript.js +123 -0
- package/dist/version.js +59 -0
- package/package.json +62 -0
- package/profiles/orchestration/posture.md +27 -0
- package/profiles/orchestration/runbook.md +72 -0
- package/profiles/orchestration/worker.md +38 -0
- package/profiles/simple/posture.md +14 -0
|
@@ -0,0 +1,1246 @@
|
|
|
1
|
+
import { existsSync, rmSync, statSync, realpathSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { db } from "../db.js";
|
|
5
|
+
import { agentBriefPath, mergedBriefVars, readAgentBrief, workerBrief, workerCommandString, writeAgentBrief, } from "../brief.js";
|
|
6
|
+
import { codexHomeDir, codexInstructionsPhrase, codexLaunchArgs, ensureCodexHome, ensureCodexHooksFile } from "../codexHome.js";
|
|
7
|
+
import { currentActor, findProjectForDir, getProject, linkedWorktreePrimaryRoot, resolveProject } from "../context.js";
|
|
8
|
+
import { commandHead, harnessFor, harnessNames, paneClassifierFor, resolvedCommandPrefix, screenClassifiable, } from "../harnesses.js";
|
|
9
|
+
import { ensureWorkerHooksFile } from "../hooks.js";
|
|
10
|
+
import { activeProfile, allowedAgents, loadProjectYml } from "../projectYml.js";
|
|
11
|
+
import { classifyMiss, LEAD_MESSAGE_THRESHOLD, leadPointerMarker, missMessage, readLeadMessage, renderLeadPointer, senderTag, shortenedSendFailureClause, shortenedSendNote, storeLeadMessage, } from "../leadMessage.js";
|
|
12
|
+
import { run } from "../result.js";
|
|
13
|
+
import { markGoneReported } from "../scheduler.js";
|
|
14
|
+
import { branchAt, closeAgentRow, discardOrphanedPane, isReservedAgentName, isRunningLeadActor, killAgentPane, launchAgent, LEAD_KIND, parkAgentRow, reapCodexHomeForClosedAgent, releaseParkRow, renameAgent, resumeAgent, } from "../spawn.js";
|
|
15
|
+
import { COMMAND_KIND, runningCommandRow, stopLine, stopProcess, STOP_REASONS } from "../processes.js";
|
|
16
|
+
import { readContextTokens, readContextFill, resolveTranscriptDir } from "../transcript.js";
|
|
17
|
+
import { capturePane, captureFinalScreen, DEFAULT_LAYOUT, cancelCopyMode, describePaneChoice, ensureAttached, findUnsafeControlChar, holdsHumanInput, liveTargets, paneCurrentCommand, paneInCopyMode, paneWindow, pollPaneReadiness, rowAlive, rowLive, sendText, sessionName, setRemainOnExit, sleep, TEXT_ALLOWED_CONTROL_CHARS, tmux, TmuxTimeoutError, WINDOW_LAYOUTS, } from "../tmux.js";
|
|
18
|
+
import { agentIdParam, agentNameParam, idParam, projectIdParam } from "./params.js";
|
|
19
|
+
import { deriveProvenance, lastLogEvent, lastPermissionMode, reportsAgentStateLog, } from "../stateProvenance.js";
|
|
20
|
+
const CLOSED_ROW_ORDER = "(parked_at != '') DESC, closed_at DESC, id DESC";
|
|
21
|
+
function closedAgentNamed(projectId, needle) {
|
|
22
|
+
return db
|
|
23
|
+
.prepare(`SELECT id, name, kind, parked_at FROM agents WHERE project_id = ? AND status != 'running' ORDER BY ${CLOSED_ROW_ORDER}`)
|
|
24
|
+
.all(projectId).find((r) => r.name.toLowerCase() === needle);
|
|
25
|
+
}
|
|
26
|
+
function getAgentRow(projectId, id, notFoundHint) {
|
|
27
|
+
const row = db.prepare("SELECT * FROM agents WHERE project_id = ? AND id = ?").get(projectId, id);
|
|
28
|
+
if (!row)
|
|
29
|
+
throw new Error(`No agent ${id} in project ${projectId}. ${notFoundHint}`);
|
|
30
|
+
return row;
|
|
31
|
+
}
|
|
32
|
+
export function classifyLostCas(row) {
|
|
33
|
+
return row.status !== "running" ? { outcome: "retired", parked: !!row.parked_at } : { outcome: "revived" };
|
|
34
|
+
}
|
|
35
|
+
function revivedError(agent, live, cause, remedy) {
|
|
36
|
+
return new Error(`Agent ${agent.id} ("${agent.name}") is running again: its row changed since this call probed it, most ` +
|
|
37
|
+
`likely ${cause}` +
|
|
38
|
+
(live ? " after this call's kill-pane took the old one down" : "") +
|
|
39
|
+
`. ${remedy}`);
|
|
40
|
+
}
|
|
41
|
+
export function findAgent(projectId, ref) {
|
|
42
|
+
if (ref.agent_id != null) {
|
|
43
|
+
return getAgentRow(projectId, ref.agent_id, "Call agent_list.");
|
|
44
|
+
}
|
|
45
|
+
if (ref.name) {
|
|
46
|
+
const rows = db
|
|
47
|
+
.prepare("SELECT * FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
|
|
48
|
+
.all(projectId);
|
|
49
|
+
const exact = rows.filter((r) => r.name === ref.name);
|
|
50
|
+
if (exact.length === 1)
|
|
51
|
+
return exact[0];
|
|
52
|
+
if (exact.length > 1) {
|
|
53
|
+
throw new Error(`Multiple running agents named "${ref.name}". Target by agent_id instead.`);
|
|
54
|
+
}
|
|
55
|
+
const needle = ref.name.toLowerCase();
|
|
56
|
+
const sameName = rows.filter((r) => r.name.toLowerCase() === needle);
|
|
57
|
+
if (sameName.length === 1)
|
|
58
|
+
return sameName[0];
|
|
59
|
+
const closed = closedAgentNamed(projectId, needle);
|
|
60
|
+
if (closed) {
|
|
61
|
+
if (closed.parked_at) {
|
|
62
|
+
throw new Error(`Agent ${closed.id} ("${closed.name}") is PARKED, not finished - it was paused on ` +
|
|
63
|
+
`${closed.parked_at} and its session is waiting. Bring it back with agent_resume(agent_id: ` +
|
|
64
|
+
`${closed.id}), or abandon the park with agent_close(agent_id: ${closed.id}).`);
|
|
65
|
+
}
|
|
66
|
+
const remedy = closed.kind === LEAD_KIND ? "Run `hive lead` to start a new one" : "Spawn a new worker";
|
|
67
|
+
throw new Error(`Agent ${closed.id} ("${closed.name}") is closed. ${remedy}, or target a running one by name or agent_id.`);
|
|
68
|
+
}
|
|
69
|
+
const partial = rows.filter((r) => r.name.toLowerCase().includes(needle));
|
|
70
|
+
if (partial.length === 1)
|
|
71
|
+
return partial[0];
|
|
72
|
+
if (partial.length > 1) {
|
|
73
|
+
const candidates = partial.map((r) => `${r.name} (agent_id ${r.id})`).join(", ");
|
|
74
|
+
throw new Error(`"${ref.name}" matches ${partial.length} running agents: ${candidates}. Use the full name or agent_id.`);
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`No running agent matching "${ref.name}" in project ${projectId}. Call agent_list.`);
|
|
77
|
+
}
|
|
78
|
+
throw new Error("Pass agent_id or name.");
|
|
79
|
+
}
|
|
80
|
+
function findClosedAgent(projectId, ref) {
|
|
81
|
+
if (ref.agent_id != null) {
|
|
82
|
+
const row = getAgentRow(projectId, ref.agent_id, "Call agent_list(include_closed: true).");
|
|
83
|
+
if (row.status !== "closed") {
|
|
84
|
+
throw new Error(`Agent ${row.id} ("${row.name}") is not closed (status: ${row.status}).`);
|
|
85
|
+
}
|
|
86
|
+
return row;
|
|
87
|
+
}
|
|
88
|
+
if (ref.name) {
|
|
89
|
+
const needle = ref.name.toLowerCase();
|
|
90
|
+
const match = db
|
|
91
|
+
.prepare(`SELECT * FROM agents WHERE project_id = ? AND status = 'closed' ORDER BY ${CLOSED_ROW_ORDER}`)
|
|
92
|
+
.all(projectId).find((r) => r.name.toLowerCase() === needle);
|
|
93
|
+
if (!match) {
|
|
94
|
+
throw new Error(`No closed agent matching "${ref.name}" in project ${projectId}. Call agent_list(include_closed: true).`);
|
|
95
|
+
}
|
|
96
|
+
return match;
|
|
97
|
+
}
|
|
98
|
+
throw new Error("Pass agent_id or name.");
|
|
99
|
+
}
|
|
100
|
+
function laneTodoIds(projectId, actorId) {
|
|
101
|
+
return db
|
|
102
|
+
.prepare(`SELECT DISTINCT c.todo_id AS id FROM todo_comments c
|
|
103
|
+
JOIN todos t ON t.id = c.todo_id
|
|
104
|
+
WHERE c.author = ? AND t.project_id = ? AND t.archived_at IS NULL
|
|
105
|
+
ORDER BY c.todo_id`)
|
|
106
|
+
.all(actorId, projectId).map((r) => r.id);
|
|
107
|
+
}
|
|
108
|
+
function parkedBoardLine(fields) {
|
|
109
|
+
const day = fields.parkedAt.slice(0, 10);
|
|
110
|
+
const todos = fields.todoIds.length > 0 ? ` todos ${fields.todoIds.join(", ")}` : "";
|
|
111
|
+
return (`PARKED ${day} ${fields.name} agent_id ${fields.agentId}${todos}\n` +
|
|
112
|
+
` branch ${fields.branch || "(unrecorded)"} cwd ${fields.cwd}\n` +
|
|
113
|
+
` resume: agent_resume(agent_id: ${fields.agentId})`);
|
|
114
|
+
}
|
|
115
|
+
function buildParkReceipt(project, agent, parkedAt, branch, note) {
|
|
116
|
+
const todoIds = laneTodoIds(project.id, agent.actor_id);
|
|
117
|
+
return {
|
|
118
|
+
agent_id: agent.id,
|
|
119
|
+
name: agent.name,
|
|
120
|
+
parked: true,
|
|
121
|
+
parked_at: parkedAt,
|
|
122
|
+
parked_branch: branch,
|
|
123
|
+
cwd: agent.cwd,
|
|
124
|
+
session_id: agent.session_id,
|
|
125
|
+
todo_ids: todoIds,
|
|
126
|
+
board_line: parkedBoardLine({ parkedAt, name: agent.name, agentId: agent.id, branch, cwd: agent.cwd, todoIds }),
|
|
127
|
+
...(note ? { note } : {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export function isLive(agent) {
|
|
131
|
+
if (agent.status !== "running")
|
|
132
|
+
return false;
|
|
133
|
+
return rowLive(agent.tmux_socket, agent.tmux_target);
|
|
134
|
+
}
|
|
135
|
+
export const PROBE_FAILED_NOTE = "tmux could not be probed, so liveness is unknown. Nothing was changed. Retry in a few seconds.";
|
|
136
|
+
export const probeFailed = (agent) => new Error(`Agent ${agent.id} ("${agent.name}"): ${PROBE_FAILED_NOTE}`);
|
|
137
|
+
function requireLive(agent) {
|
|
138
|
+
if (agent.status !== "running") {
|
|
139
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}") is closed.`);
|
|
140
|
+
}
|
|
141
|
+
const live = isLive(agent);
|
|
142
|
+
if (live === null)
|
|
143
|
+
throw probeFailed(agent);
|
|
144
|
+
if (!live) {
|
|
145
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}") has no live tmux window (its process exited or the window was killed). Close it with agent_close and spawn a new one.`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function runningAgentNamed(projectId, name, exceptAgentId) {
|
|
149
|
+
const needle = name.toLowerCase();
|
|
150
|
+
return db
|
|
151
|
+
.prepare("SELECT id, name FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
|
|
152
|
+
.all(projectId).find((r) => r.id !== exceptAgentId && r.name.toLowerCase() === needle);
|
|
153
|
+
}
|
|
154
|
+
function requireNameFree(projectId, name, exceptAgentId) {
|
|
155
|
+
const needle = name.toLowerCase();
|
|
156
|
+
if (isReservedAgentName(needle)) {
|
|
157
|
+
throw new Error(`"${name}" is reserved for this project's lead session and cannot be used as a worker name.`);
|
|
158
|
+
}
|
|
159
|
+
const taken = runningAgentNamed(projectId, name, exceptAgentId);
|
|
160
|
+
if (taken) {
|
|
161
|
+
throw new Error(`A running agent named "${taken.name}" already exists. Pick another name.`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Recovers a flag's value from a row's own recorded command string - the only place agent_resume
|
|
165
|
+
// can read it back from, since ResumeSpec is built fresh and does not carry agent_spawn's original
|
|
166
|
+
// args. Model ids are shell-safe by construction (gpt-5.6-luna, sonnet), so shellQuote never quotes
|
|
167
|
+
// this token and a plain whitespace split is enough - no quote-stripping to get wrong.
|
|
168
|
+
function extractCommandFlag(command, flag) {
|
|
169
|
+
const tokens = command.split(/\s+/);
|
|
170
|
+
const i = tokens.indexOf(flag);
|
|
171
|
+
if (i === -1 || i + 1 >= tokens.length)
|
|
172
|
+
return undefined;
|
|
173
|
+
return tokens[i + 1];
|
|
174
|
+
}
|
|
175
|
+
function requestsExistingSession(extraArgs) {
|
|
176
|
+
return (extraArgs ?? []).some((arg) => arg === "--resume" ||
|
|
177
|
+
arg.startsWith("--resume=") ||
|
|
178
|
+
arg === "--fork-session" ||
|
|
179
|
+
(arg.startsWith("-r") && !arg.startsWith("--")));
|
|
180
|
+
}
|
|
181
|
+
function normalizeAgentName(raw, field) {
|
|
182
|
+
const name = raw.trim();
|
|
183
|
+
if (!name)
|
|
184
|
+
throw new Error(`${field} cannot be empty.`);
|
|
185
|
+
const bad = findUnsafeControlChar(name, new Set());
|
|
186
|
+
if (bad) {
|
|
187
|
+
throw new Error(`${field} cannot contain ${bad.label} at offset ${bad.index}: it is typed into the worker's terminal, ` +
|
|
188
|
+
"where a raw control byte reaches tmux as a keystroke instead of as text. No control characters are " +
|
|
189
|
+
"allowed in a name, including tabs and newlines.");
|
|
190
|
+
}
|
|
191
|
+
return name;
|
|
192
|
+
}
|
|
193
|
+
function nextWorkerName(projectId) {
|
|
194
|
+
const count = db.prepare("SELECT COUNT(*) AS n FROM agents WHERE project_id = ?").get(projectId).n;
|
|
195
|
+
return `worker-${count + 1}`;
|
|
196
|
+
}
|
|
197
|
+
const PANE_READY_MS = Number(process.env.HIVE_SPAWN_READY_MS ?? 45_000);
|
|
198
|
+
export function summaryLiveness(row, snapshot) {
|
|
199
|
+
if (row.status !== "running")
|
|
200
|
+
return false;
|
|
201
|
+
if (snapshot === undefined)
|
|
202
|
+
return rowLive(row.tmux_socket, row.tmux_target);
|
|
203
|
+
if (snapshot === null)
|
|
204
|
+
return null;
|
|
205
|
+
return rowAlive(row.tmux_socket, row.tmux_target, snapshot);
|
|
206
|
+
}
|
|
207
|
+
function capturePaneQuietly(target) {
|
|
208
|
+
try {
|
|
209
|
+
return capturePane(target, 15);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return "";
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function inputBoxField(command, target) {
|
|
216
|
+
const box = paneClassifierFor(command)?.inputBoxState(target) ?? null;
|
|
217
|
+
if (!box)
|
|
218
|
+
return {};
|
|
219
|
+
if (box.state === "ghost") {
|
|
220
|
+
const { state } = box;
|
|
221
|
+
return { input_box: { state } };
|
|
222
|
+
}
|
|
223
|
+
return { input_box: box };
|
|
224
|
+
}
|
|
225
|
+
export function claudeOnlyFields(row) {
|
|
226
|
+
return harnessFor(row.command).transcriptDir
|
|
227
|
+
? { transcript_dir: resolveTranscriptDir(row.cwd), session_id: row.session_id || null }
|
|
228
|
+
: {};
|
|
229
|
+
}
|
|
230
|
+
function lastLogEventField(row) {
|
|
231
|
+
return reportsAgentStateLog(row) ? { last_log_event: lastLogEvent(row.actor_id) } : {};
|
|
232
|
+
}
|
|
233
|
+
function permissionModeField(row) {
|
|
234
|
+
return reportsAgentStateLog(row) ? { permission_mode: lastPermissionMode(row.actor_id) } : {};
|
|
235
|
+
}
|
|
236
|
+
export function contextFillField(row) {
|
|
237
|
+
const kind = harnessFor(row.command).contextRecord;
|
|
238
|
+
return row.kind === "agent" && kind != null ? { context_fill: readContextFill(kind, row) } : {};
|
|
239
|
+
}
|
|
240
|
+
export function contextTokensField(row, fill = null) {
|
|
241
|
+
return harnessFor(row.command).contextTokens ? { context_tokens: fill?.used_tokens ?? readContextTokens(row.cwd, row.session_id, row.transcript_path) } : {};
|
|
242
|
+
}
|
|
243
|
+
function paneField(row, alive) {
|
|
244
|
+
if (!reportsAgentStateLog(row) || alive !== true)
|
|
245
|
+
return {};
|
|
246
|
+
const awaitingChoice = paneClassifierFor(row.command)?.choiceCheck(row.tmux_target).awaitingChoice ?? null;
|
|
247
|
+
return { pane: describePaneChoice(awaitingChoice) };
|
|
248
|
+
}
|
|
249
|
+
function agentSummary(row, snapshot) {
|
|
250
|
+
const alive = summaryLiveness(row, snapshot);
|
|
251
|
+
const { state, ...provenance } = deriveProvenance(row, alive);
|
|
252
|
+
return {
|
|
253
|
+
agent_id: row.id,
|
|
254
|
+
kind: row.kind,
|
|
255
|
+
name: row.name,
|
|
256
|
+
actor_id: row.actor_id,
|
|
257
|
+
status: alive === false && row.status === "running" ? "exited" : row.status,
|
|
258
|
+
alive,
|
|
259
|
+
agent_state: state,
|
|
260
|
+
state_changed_at: row.state_changed_at,
|
|
261
|
+
provenance,
|
|
262
|
+
...lastLogEventField(row),
|
|
263
|
+
...permissionModeField(row),
|
|
264
|
+
...contextFillField(row),
|
|
265
|
+
...(row.parked_at ? { parked_at: row.parked_at, parked_branch: row.parked_branch || null } : {}),
|
|
266
|
+
tmux_target: row.tmux_target,
|
|
267
|
+
command: row.command,
|
|
268
|
+
cwd: row.cwd,
|
|
269
|
+
parent_actor_id: row.parent_actor_id,
|
|
270
|
+
created_at: row.created_at,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
export function contextCheckpointEnv(config) {
|
|
274
|
+
const value = config?.context_checkpoint_percent;
|
|
275
|
+
return value == null ? {} : { HIVE_CONTEXT_CHECKPOINT_PERCENT: String(value) };
|
|
276
|
+
}
|
|
277
|
+
export function worktreeInstallNotice(cwd, projectPath, config) {
|
|
278
|
+
const install = config?.vars?.install?.trim();
|
|
279
|
+
if (!install)
|
|
280
|
+
return undefined;
|
|
281
|
+
const primaryRoot = linkedWorktreePrimaryRoot(cwd);
|
|
282
|
+
if (primaryRoot === null || primaryRoot !== projectPath)
|
|
283
|
+
return undefined;
|
|
284
|
+
return install;
|
|
285
|
+
}
|
|
286
|
+
export function registerAgents(server) {
|
|
287
|
+
server.registerTool("agent_spawn", {
|
|
288
|
+
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. The worker is locked to this project. Humans can watch with: tmux attach -t hive-main.",
|
|
289
|
+
inputSchema: {
|
|
290
|
+
name: z
|
|
291
|
+
.string()
|
|
292
|
+
.optional()
|
|
293
|
+
.describe("Display name; defaults to worker-N. This is how you address the worker later."),
|
|
294
|
+
model: z.string().optional().describe("Passed as --model to the agent command."),
|
|
295
|
+
command: z
|
|
296
|
+
.string()
|
|
297
|
+
.optional()
|
|
298
|
+
.describe("Raw agent command to run. Overrides harness when both are given. Defaults to the project's hive.yml agents: default, or claude. Refused if it resolves to a known harness the project's agents: list does not allow."),
|
|
299
|
+
harness: z
|
|
300
|
+
.string()
|
|
301
|
+
.optional()
|
|
302
|
+
.describe("Spawn a known harness by name (e.g. \"codex\") instead of a raw command. Ignored when command is also given. Must be in the project's hive.yml agents: list (default: claude only)."),
|
|
303
|
+
extra_args: z.array(z.string()).optional().describe("Extra CLI arguments."),
|
|
304
|
+
cwd: z
|
|
305
|
+
.string()
|
|
306
|
+
.optional()
|
|
307
|
+
.describe("Working directory, e.g. a git worktree path. Defaults to the project root."),
|
|
308
|
+
placement: z
|
|
309
|
+
.enum(["split", "window"])
|
|
310
|
+
.optional()
|
|
311
|
+
.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)."),
|
|
312
|
+
layout: z
|
|
313
|
+
.enum(WINDOW_LAYOUTS)
|
|
314
|
+
.optional()
|
|
315
|
+
.describe("How to arrange the lead's window when placement is split. main-vertical gives the lead the left half with workers stacked on the right; tiled (default) splits evenly. Projects can set a default in hive.yml."),
|
|
316
|
+
project_id: projectIdParam,
|
|
317
|
+
},
|
|
318
|
+
outputSchema: {
|
|
319
|
+
agent_id: idParam,
|
|
320
|
+
actor_id: z.string(),
|
|
321
|
+
name: z.string(),
|
|
322
|
+
tmux_target: z.string(),
|
|
323
|
+
layout: z.enum(WINDOW_LAYOUTS).optional(),
|
|
324
|
+
landed_in_project: z.string().optional(),
|
|
325
|
+
config_warnings: z.array(z.string()).optional(),
|
|
326
|
+
worktree_install: z.string().optional(),
|
|
327
|
+
brief_path: z.string().optional(),
|
|
328
|
+
codex_home: z.string().optional(),
|
|
329
|
+
codex_instructions: z.string().optional(),
|
|
330
|
+
ready: z.boolean().optional(),
|
|
331
|
+
exited: z.boolean().optional(),
|
|
332
|
+
note: z.string().optional(),
|
|
333
|
+
tail: z.string().optional(),
|
|
334
|
+
instructions: z.string().optional(),
|
|
335
|
+
},
|
|
336
|
+
}, (args) => run(async () => {
|
|
337
|
+
const project = resolveProject(args.project_id);
|
|
338
|
+
const parent = currentActor();
|
|
339
|
+
let cwd = project.path;
|
|
340
|
+
if (args.cwd) {
|
|
341
|
+
cwd = realpathSync(args.cwd);
|
|
342
|
+
if (!statSync(cwd).isDirectory())
|
|
343
|
+
throw new Error(`cwd is not a directory: ${args.cwd}`);
|
|
344
|
+
const cwdProject = findProjectForDir(cwd);
|
|
345
|
+
if (cwdProject && cwdProject.id !== project.id) {
|
|
346
|
+
const remedy = process.env.HIVE_PROJECT_LOCK === "1"
|
|
347
|
+
? `This session is locked to project ${project.id} (HIVE_PROJECT_LOCK=1) and cannot spawn outside it.`
|
|
348
|
+
: `Pass project_id: ${cwdProject.id} to spawn into the cwd's project deliberately.`;
|
|
349
|
+
throw new Error(`cwd ${cwd} belongs to project "${cwdProject.name}" (id ${cwdProject.id}), but this spawn resolved to project "${project.name}" (id ${project.id}). ${remedy}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const name = args.name != null
|
|
353
|
+
? normalizeAgentName(args.name, "name")
|
|
354
|
+
: nextWorkerName(project.id);
|
|
355
|
+
requireNameFree(project.id, name);
|
|
356
|
+
const { config: projectConfig, warnings: configWarnings } = loadProjectYml(project.path);
|
|
357
|
+
let baseCommand;
|
|
358
|
+
if (args.command) {
|
|
359
|
+
baseCommand = args.command;
|
|
360
|
+
}
|
|
361
|
+
else if (args.harness) {
|
|
362
|
+
if (!harnessNames().includes(args.harness)) {
|
|
363
|
+
throw new Error(`Unknown harness "${args.harness}". Known harnesses: ${harnessNames().join(", ")}.`);
|
|
364
|
+
}
|
|
365
|
+
baseCommand = args.harness;
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
baseCommand = allowedAgents(projectConfig)[0];
|
|
369
|
+
}
|
|
370
|
+
// Gates only a command hive actually recognizes as one of its own harnesses (matched by
|
|
371
|
+
// basename, todo 521's known blind spot: a command whose basename merely LOOKS like "claude"
|
|
372
|
+
// or "codex" is gated exactly as strongly as it is classified elsewhere, no more). A command
|
|
373
|
+
// hive cannot classify at all (harnessFor -> "unknown", not in harnessNames()) was never part
|
|
374
|
+
// of the crew-harness pool this key governs and stays ungated, same as before this lane.
|
|
375
|
+
const harness = harnessFor(baseCommand);
|
|
376
|
+
const allowed = allowedAgents(projectConfig);
|
|
377
|
+
if (harnessNames().includes(harness.name) && !allowed.includes(harness.name)) {
|
|
378
|
+
throw new Error(`[agent_spawn:harness-not-allowed] "${harness.name}" is not in this project's hive.yml ` +
|
|
379
|
+
`agents: list (${allowed.join(", ")}). Add it to agents: to allow spawning it.`);
|
|
380
|
+
}
|
|
381
|
+
const mintsSession = harness.mintsSessionId;
|
|
382
|
+
const sessionId = mintsSession && !requestsExistingSession(args.extra_args) ? randomUUID() : "";
|
|
383
|
+
const worktreeInstall = worktreeInstallNotice(cwd, project.path, projectConfig);
|
|
384
|
+
const briefFor = (actorId) => ({
|
|
385
|
+
name,
|
|
386
|
+
actorId,
|
|
387
|
+
projectName: project.name,
|
|
388
|
+
projectPath: project.path,
|
|
389
|
+
cwd,
|
|
390
|
+
profile: activeProfile(projectConfig),
|
|
391
|
+
vars: mergedBriefVars(projectConfig?.vars, harness.name),
|
|
392
|
+
});
|
|
393
|
+
// Minted before the agent row exists (unlike agentId/actorId), so CODEX_HOME's own path can
|
|
394
|
+
// go straight into launchAgent's static env below rather than needing agentId to name it.
|
|
395
|
+
const codexHomeKey = harness.needsHome ? randomUUID() : undefined;
|
|
396
|
+
// Set inside buildCommand (below), which ensureCodexHome runs in - captured here so the
|
|
397
|
+
// receipt built after launchAgent returns can still name which layers were found.
|
|
398
|
+
let codexInstructionLayers = [];
|
|
399
|
+
const buildCommand = ({ agentId, actorId }) => {
|
|
400
|
+
const brief = harness.briefDelivery || codexHomeKey ? workerBrief(briefFor(actorId)) : undefined;
|
|
401
|
+
const briefPath = harness.briefDelivery ? writeAgentBrief(agentId, brief) : undefined;
|
|
402
|
+
let homeArgs = [];
|
|
403
|
+
if (codexHomeKey) {
|
|
404
|
+
const home = ensureCodexHome({ key: codexHomeKey, actorId, cwd, brief: brief, includePostToolUse: projectConfig?.context_checkpoint_percent != null });
|
|
405
|
+
homeArgs = home.extraArgs;
|
|
406
|
+
codexInstructionLayers = home.instructionLayers;
|
|
407
|
+
}
|
|
408
|
+
return workerCommandString({
|
|
409
|
+
command: baseCommand,
|
|
410
|
+
displayName: name,
|
|
411
|
+
model: args.model,
|
|
412
|
+
extraArgs: [
|
|
413
|
+
...homeArgs,
|
|
414
|
+
...(mintsSession
|
|
415
|
+
? [...(sessionId ? ["--session-id", sessionId] : []), ...(args.extra_args ?? [])]
|
|
416
|
+
: (args.extra_args ?? [])),
|
|
417
|
+
],
|
|
418
|
+
settingsPath: harness.briefDelivery ? ensureWorkerHooksFile(agentId, { includePostToolUse: projectConfig?.context_checkpoint_percent != null }) : undefined,
|
|
419
|
+
briefPath,
|
|
420
|
+
});
|
|
421
|
+
};
|
|
422
|
+
const placement = args.placement ??
|
|
423
|
+
projectConfig?.placement ??
|
|
424
|
+
(process.env.HIVE_SPAWN_PLACEMENT === "window" ? "window" : "split");
|
|
425
|
+
const layout = args.layout ?? projectConfig?.layout ?? DEFAULT_LAYOUT;
|
|
426
|
+
let spawned;
|
|
427
|
+
try {
|
|
428
|
+
spawned = launchAgent({
|
|
429
|
+
projectId: project.id,
|
|
430
|
+
projectName: project.name,
|
|
431
|
+
projectPath: project.path,
|
|
432
|
+
name,
|
|
433
|
+
kind: "agent",
|
|
434
|
+
commandString: buildCommand,
|
|
435
|
+
cwd,
|
|
436
|
+
env: { ...contextCheckpointEnv(projectConfig), ...(codexHomeKey ? { CODEX_HOME: codexHomeDir(codexHomeKey) } : {}) },
|
|
437
|
+
placement,
|
|
438
|
+
layout,
|
|
439
|
+
parentActor: parent,
|
|
440
|
+
sessionId,
|
|
441
|
+
codexHome: codexHomeKey,
|
|
442
|
+
retainOnExit: harness.classifiesPaneScreen,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
catch (e) {
|
|
446
|
+
// buildCommand already wrote CODEX_HOME to disk (auth.json symlink included) before
|
|
447
|
+
// launchAgent's own failure paths run; a codex worker that never got a pane must not
|
|
448
|
+
// leave that home orphaned. Narrow on purpose - the wider "nothing ever reaps
|
|
449
|
+
// codex-homes/briefs/postures" question is a separate, filed concern, not this fix.
|
|
450
|
+
if (codexHomeKey)
|
|
451
|
+
rmSync(codexHomeDir(codexHomeKey), { recursive: true, force: true });
|
|
452
|
+
throw e;
|
|
453
|
+
}
|
|
454
|
+
const { agentId, actorId, target, landedInProjectId, layoutApplied } = spawned;
|
|
455
|
+
ensureAttached(sessionName());
|
|
456
|
+
let ready = false;
|
|
457
|
+
let dialogTail;
|
|
458
|
+
let exited = false;
|
|
459
|
+
let exitTail;
|
|
460
|
+
if (harness.classifiesPaneScreen) {
|
|
461
|
+
const classifier = harness.paneClassifier;
|
|
462
|
+
// Captured before anything below can kill the pane: remain-on-exit is a WINDOW option,
|
|
463
|
+
// and the pane id itself stops being addressable once discardOrphanedPane has run.
|
|
464
|
+
const windowTarget = paneWindow(target) ?? target;
|
|
465
|
+
try {
|
|
466
|
+
const outcome = await pollPaneReadiness(target, PANE_READY_MS, classifier.hasInputBox);
|
|
467
|
+
if (outcome === "gone") {
|
|
468
|
+
// remain-on-exit held the pane so its final screen is still readable; capture it,
|
|
469
|
+
// persist it onto the row (the pane below is about to be killed), then release it.
|
|
470
|
+
// Never run choiceCheck against a retained-but-dead pane: it is frozen content, not
|
|
471
|
+
// a live dialog, and choiceCheck's own capture is tuned to read a live screen.
|
|
472
|
+
exited = true;
|
|
473
|
+
exitTail = captureFinalScreen(target, 30);
|
|
474
|
+
db.prepare("UPDATE agents SET exit_tail = ? WHERE id = ?").run(exitTail, agentId);
|
|
475
|
+
discardOrphanedPane(target);
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
const { awaitingChoice, tail } = classifier.choiceCheck(target);
|
|
479
|
+
if (awaitingChoice === true) {
|
|
480
|
+
dialogTail = tail;
|
|
481
|
+
}
|
|
482
|
+
else if (outcome === "ready") {
|
|
483
|
+
ready = true;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
ready = false;
|
|
489
|
+
}
|
|
490
|
+
finally {
|
|
491
|
+
// Unconditional: every early return, throw, or timeout between arming remain-on-exit
|
|
492
|
+
// and here must still clear it, or the shared window (the default split placement's)
|
|
493
|
+
// is left silently retaining every later exit in it, not just this one.
|
|
494
|
+
setRemainOnExit(windowTarget, false);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const codexInstructions = codexHomeKey ? codexInstructionsPhrase(codexInstructionLayers) : undefined;
|
|
498
|
+
return {
|
|
499
|
+
agent_id: agentId,
|
|
500
|
+
actor_id: actorId,
|
|
501
|
+
name,
|
|
502
|
+
tmux_target: target,
|
|
503
|
+
...(layoutApplied ? { layout } : {}),
|
|
504
|
+
...(landedInProjectId != null
|
|
505
|
+
? { landed_in_project: getProject(landedInProjectId)?.name ?? `project ${landedInProjectId}` }
|
|
506
|
+
: {}),
|
|
507
|
+
...(configWarnings.length > 0 ? { config_warnings: configWarnings } : {}),
|
|
508
|
+
...(worktreeInstall ? { worktree_install: worktreeInstall } : {}),
|
|
509
|
+
...(harness.briefDelivery || codexHomeKey
|
|
510
|
+
? {
|
|
511
|
+
...(harness.briefDelivery ? { brief_path: agentBriefPath(agentId) } : {}),
|
|
512
|
+
...(codexHomeKey ? { codex_home: codexHomeDir(codexHomeKey) } : {}),
|
|
513
|
+
...(codexInstructions ? { codex_instructions: codexInstructions } : {}),
|
|
514
|
+
ready,
|
|
515
|
+
...(ready
|
|
516
|
+
? {}
|
|
517
|
+
: dialogTail !== undefined
|
|
518
|
+
? {
|
|
519
|
+
note: "The pane is waiting on a choice (e.g. a folder-trust or permission prompt). Clear it with agent_send keys, then send the worker its assignment.",
|
|
520
|
+
tail: dialogTail,
|
|
521
|
+
}
|
|
522
|
+
: exited
|
|
523
|
+
? {
|
|
524
|
+
exited: true,
|
|
525
|
+
note: "The pane's process exited before it became ready. Its final screen (likely the reason) is in tail.",
|
|
526
|
+
tail: exitTail,
|
|
527
|
+
}
|
|
528
|
+
: {
|
|
529
|
+
note: "The pane never became ready. The brief is loaded regardless; check agent_output before sending the worker its assignment - typing into it now risks losing the text silently.",
|
|
530
|
+
}),
|
|
531
|
+
}
|
|
532
|
+
: harness.classifiesPaneScreen
|
|
533
|
+
? {
|
|
534
|
+
instructions: workerBrief(briefFor(actorId)),
|
|
535
|
+
}
|
|
536
|
+
: {
|
|
537
|
+
brief_path: writeAgentBrief(agentId, workerBrief(briefFor(actorId))),
|
|
538
|
+
note: `This worker runs ${JSON.stringify(commandHead(baseCommand))}, which hive cannot brief and ` +
|
|
539
|
+
"cannot type into: the guards that make typing safe read claude's chrome, so agent_send's " +
|
|
540
|
+
"text path refuses this pane. There are no instructions to prepend, because there is no " +
|
|
541
|
+
"supported way to send them. The brief is written to brief_path - open that pane " +
|
|
542
|
+
"(tmux attach) and paste it in yourself, or drive the worker with agent_send(keys: [...]). " +
|
|
543
|
+
"Wakes aimed at this worker are refused for the same reason.",
|
|
544
|
+
}),
|
|
545
|
+
};
|
|
546
|
+
}));
|
|
547
|
+
server.registerTool("agent_resume", {
|
|
548
|
+
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.",
|
|
549
|
+
inputSchema: {
|
|
550
|
+
name: agentNameParam,
|
|
551
|
+
agent_id: agentIdParam,
|
|
552
|
+
project_id: projectIdParam,
|
|
553
|
+
},
|
|
554
|
+
outputSchema: {
|
|
555
|
+
agent_id: idParam,
|
|
556
|
+
actor_id: z.string(),
|
|
557
|
+
name: z.string(),
|
|
558
|
+
tmux_target: z.string(),
|
|
559
|
+
resumed_session_id: z.string(),
|
|
560
|
+
was_parked_at: z.string().optional(),
|
|
561
|
+
branch_drift: z
|
|
562
|
+
.object({ parked_branch: z.string(), branch_now: z.string() })
|
|
563
|
+
.optional(),
|
|
564
|
+
landed_in_project: z.string().optional(),
|
|
565
|
+
},
|
|
566
|
+
}, (args) => run(async () => {
|
|
567
|
+
const project = resolveProject(args.project_id);
|
|
568
|
+
const agent = findClosedAgent(project.id, args);
|
|
569
|
+
if (agent.kind === LEAD_KIND) {
|
|
570
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}") is this project's lead session. agent_resume is for workers; ` +
|
|
571
|
+
"start a lead session with `hive lead`.");
|
|
572
|
+
}
|
|
573
|
+
const resumeHarness = harnessFor(agent.command);
|
|
574
|
+
if (!resumeHarness.supportsResume) {
|
|
575
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}")'s harness ("${resumeHarness.name}", command: "${agent.command}") ` +
|
|
576
|
+
"does not support resume, so it has no session id to resume from.");
|
|
577
|
+
}
|
|
578
|
+
if (!agent.session_id) {
|
|
579
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}") has no recorded session id, so it cannot be resumed. It may ` +
|
|
580
|
+
"predate this feature, or it may have closed before its first hook event ever fired. Spawn a new " +
|
|
581
|
+
"worker instead.");
|
|
582
|
+
}
|
|
583
|
+
if (resumeHarness.name === "codex" && !agent.codex_home) {
|
|
584
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}") has no recorded CODEX_HOME, so its session cannot be resumed. ` +
|
|
585
|
+
(agent.parked_at
|
|
586
|
+
? "This should not happen for a parked codex worker - its home is only reaped on agent_close. " +
|
|
587
|
+
"Check agent_status for how it actually got here."
|
|
588
|
+
: "It was closed with agent_close, which reaps a codex worker's home (rollout files included) " +
|
|
589
|
+
"immediately - only a PARKED codex worker keeps its home. Nothing here to resume; spawn a new " +
|
|
590
|
+
"worker instead."));
|
|
591
|
+
}
|
|
592
|
+
if (!existsSync(agent.cwd)) {
|
|
593
|
+
const recreate = agent.parked_branch
|
|
594
|
+
? `git worktree add ${agent.cwd} ${agent.parked_branch}`
|
|
595
|
+
: `recreate a checkout at ${agent.cwd} (no branch was recorded for it - agent_park records one)`;
|
|
596
|
+
const reason = resumeHarness.name === "codex"
|
|
597
|
+
? "It resumes into that directory as its working directory"
|
|
598
|
+
: "Its transcript is resolved from that path";
|
|
599
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}")'s working directory is gone: ${agent.cwd}. ${reason}, so ` +
|
|
600
|
+
`recreate it and the resume works unchanged: ${recreate}`);
|
|
601
|
+
}
|
|
602
|
+
const branchNow = agent.parked_branch ? branchAt(agent.cwd) : "";
|
|
603
|
+
const branchDrift = branchNow && branchNow !== agent.parked_branch
|
|
604
|
+
? { parked_branch: agent.parked_branch, branch_now: branchNow }
|
|
605
|
+
: null;
|
|
606
|
+
const collision = runningAgentNamed(project.id, agent.name);
|
|
607
|
+
if (collision) {
|
|
608
|
+
throw new Error(`Cannot resume agent ${agent.id} ("${agent.name}") under its recorded name: a running agent ` +
|
|
609
|
+
`(agent ${collision.id}) already has it. agent_resume does not rename on your behalf - the ` +
|
|
610
|
+
`${agent.parked_at ? "parked" : "closed"} lane is not lost, it just cannot come back under a name ` +
|
|
611
|
+
"someone else is using. Free the name first (agent_rename or agent_close on the running one), " +
|
|
612
|
+
`then retry agent_resume(agent_id: ${agent.id}).`);
|
|
613
|
+
}
|
|
614
|
+
const { config: projectConfig } = loadProjectYml(project.path);
|
|
615
|
+
const placement = projectConfig?.placement ?? (process.env.HIVE_SPAWN_PLACEMENT === "window" ? "window" : "split");
|
|
616
|
+
const layout = projectConfig?.layout ?? DEFAULT_LAYOUT;
|
|
617
|
+
let commandString;
|
|
618
|
+
let resumeEnv = contextCheckpointEnv(projectConfig);
|
|
619
|
+
if (resumeHarness.name === "codex") {
|
|
620
|
+
commandString = workerCommandString({
|
|
621
|
+
command: resolvedCommandPrefix(agent.command) || "codex",
|
|
622
|
+
// Recovered from the row's own recorded command: every codex worker hive spawns
|
|
623
|
+
// carries a pinned model (codex has no bare alias), so dropping this would silently
|
|
624
|
+
// resume on codex's default model instead of the one the lane actually chose.
|
|
625
|
+
model: extractCommandFlag(agent.command, "--model"),
|
|
626
|
+
displayName: agent.name,
|
|
627
|
+
// Subcommand positional, not a flag - live-verified on v0.149.0 (`codex resume --help`:
|
|
628
|
+
// "Usage: codex resume [OPTIONS] [SESSION_ID] [PROMPT]"). No --settings/hooks file: a
|
|
629
|
+
// codex worker's hooks come from its own home's hooks.json, never claude's shared one.
|
|
630
|
+
extraArgs: [...codexLaunchArgs(agent.cwd), "resume", agent.session_id],
|
|
631
|
+
});
|
|
632
|
+
resumeEnv = { ...resumeEnv, CODEX_HOME: codexHomeDir(agent.codex_home) };
|
|
633
|
+
ensureCodexHooksFile(agent.codex_home, { includePostToolUse: projectConfig?.context_checkpoint_percent != null });
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
const claudeBinary = resolvedCommandPrefix(agent.command) || "claude";
|
|
637
|
+
commandString = workerCommandString({
|
|
638
|
+
command: claudeBinary,
|
|
639
|
+
displayName: agent.name,
|
|
640
|
+
extraArgs: ["--resume", agent.session_id],
|
|
641
|
+
settingsPath: ensureWorkerHooksFile(agent.id, { includePostToolUse: projectConfig?.context_checkpoint_percent != null }),
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
const { target, landedInProjectId } = resumeAgent({
|
|
645
|
+
agentId: agent.id,
|
|
646
|
+
actorId: agent.actor_id,
|
|
647
|
+
name: agent.name,
|
|
648
|
+
projectId: project.id,
|
|
649
|
+
projectName: project.name,
|
|
650
|
+
projectPath: project.path,
|
|
651
|
+
cwd: agent.cwd,
|
|
652
|
+
commandString,
|
|
653
|
+
env: resumeEnv,
|
|
654
|
+
placement,
|
|
655
|
+
layout,
|
|
656
|
+
parentActor: currentActor(),
|
|
657
|
+
});
|
|
658
|
+
ensureAttached(sessionName());
|
|
659
|
+
return {
|
|
660
|
+
agent_id: agent.id,
|
|
661
|
+
actor_id: agent.actor_id,
|
|
662
|
+
name: agent.name,
|
|
663
|
+
tmux_target: target,
|
|
664
|
+
resumed_session_id: agent.session_id,
|
|
665
|
+
...(agent.parked_at ? { was_parked_at: agent.parked_at } : {}),
|
|
666
|
+
...(branchDrift ? { branch_drift: branchDrift } : {}),
|
|
667
|
+
...(landedInProjectId != null
|
|
668
|
+
? { landed_in_project: getProject(landedInProjectId)?.name ?? `project ${landedInProjectId}` }
|
|
669
|
+
: {}),
|
|
670
|
+
};
|
|
671
|
+
}));
|
|
672
|
+
server.registerTool("agent_park", {
|
|
673
|
+
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.",
|
|
674
|
+
inputSchema: {
|
|
675
|
+
name: agentNameParam,
|
|
676
|
+
agent_id: agentIdParam,
|
|
677
|
+
confirm_self: z.boolean().optional(),
|
|
678
|
+
project_id: projectIdParam,
|
|
679
|
+
},
|
|
680
|
+
outputSchema: {
|
|
681
|
+
agent_id: idParam,
|
|
682
|
+
name: z.string(),
|
|
683
|
+
parked: z.boolean(),
|
|
684
|
+
parked_at: z.string(),
|
|
685
|
+
parked_branch: z.string(),
|
|
686
|
+
cwd: z.string(),
|
|
687
|
+
session_id: z.string(),
|
|
688
|
+
todo_ids: z.array(idParam),
|
|
689
|
+
board_line: z.string(),
|
|
690
|
+
note: z.string().optional(),
|
|
691
|
+
},
|
|
692
|
+
}, (args) => run(() => {
|
|
693
|
+
const project = resolveProject(args.project_id);
|
|
694
|
+
const agent = findAgent(project.id, args);
|
|
695
|
+
if (agent.kind === COMMAND_KIND) {
|
|
696
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}") is a hive.yml process, not a worker session, so there is no ` +
|
|
697
|
+
"conversation to resume and parking it would promise one. Stop it with agent_close, or " +
|
|
698
|
+
`hive stop "${agent.name}".`);
|
|
699
|
+
}
|
|
700
|
+
if (agent.kind === LEAD_KIND) {
|
|
701
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}") is this project's lead session, which is not a lane to pause. ` +
|
|
702
|
+
"Leads restart with `hive lead`, which recovers the same row and actor id.");
|
|
703
|
+
}
|
|
704
|
+
const parkHarness = harnessFor(agent.command);
|
|
705
|
+
if (!parkHarness.supportsResume) {
|
|
706
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}")'s harness ("${parkHarness.name}", command: "${agent.command}") ` +
|
|
707
|
+
"does not support resume, so it has no session to resume and nothing to park. Close it with agent_close.");
|
|
708
|
+
}
|
|
709
|
+
if (!agent.session_id) {
|
|
710
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}") has no recorded session id, so parking it would promise a resume ` +
|
|
711
|
+
"that cannot happen. It may predate this feature, or have closed before its first hook event fired. " +
|
|
712
|
+
"Close it with agent_close and spawn a fresh worker tomorrow.");
|
|
713
|
+
}
|
|
714
|
+
if (!existsSync(agent.cwd)) {
|
|
715
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}")'s working directory is already gone: ${agent.cwd}. Parking it ` +
|
|
716
|
+
"would record a lane that cannot be resumed, and its branch can no longer be read. Recreate that " +
|
|
717
|
+
"path first if you want this lane back tomorrow, or close it with agent_close.");
|
|
718
|
+
}
|
|
719
|
+
const live = isLive(agent);
|
|
720
|
+
if (live === null)
|
|
721
|
+
throw probeFailed(agent);
|
|
722
|
+
if (agent.actor_id === currentActor() && args.confirm_self !== true) {
|
|
723
|
+
throw new Error("This would park your own session. Pass confirm_self=true only if the user explicitly asked you to park yourself.");
|
|
724
|
+
}
|
|
725
|
+
const branch = branchAt(agent.cwd);
|
|
726
|
+
if (live)
|
|
727
|
+
killAgentPane(agent.tmux_target);
|
|
728
|
+
const parkedAt = parkAgentRow(agent.id, agent.tmux_target, branch);
|
|
729
|
+
if (parkedAt === undefined) {
|
|
730
|
+
const after = getAgentRow(project.id, agent.id, "Call agent_list(include_closed: true).");
|
|
731
|
+
const report = classifyLostCas(after);
|
|
732
|
+
if (report.outcome === "retired") {
|
|
733
|
+
if (report.parked) {
|
|
734
|
+
return buildParkReceipt(project, agent, after.parked_at, after.parked_branch, `Already parked by a concurrent agent_park before this call's own write landed` +
|
|
735
|
+
(live ? " (this call's kill-pane already took the pane down)." : "."));
|
|
736
|
+
}
|
|
737
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}") was closed by someone else, not parked, before this call's ` +
|
|
738
|
+
`own write landed` +
|
|
739
|
+
(live ? " - this call's kill-pane already took the pane down" : "") +
|
|
740
|
+
". It is closed, but has no recorded branch through this call, so resuming it may not work the " +
|
|
741
|
+
"way this park was meant to. Check how it was actually closed with agent_list(include_closed: true).");
|
|
742
|
+
}
|
|
743
|
+
throw revivedError(agent, live, "a concurrent agent_resume reviving it", "Nothing was parked. Re-read it with agent_status and try again.");
|
|
744
|
+
}
|
|
745
|
+
return buildParkReceipt(project, agent, parkedAt, branch);
|
|
746
|
+
}));
|
|
747
|
+
server.registerTool("agent_rename", {
|
|
748
|
+
description: "Change a worker's display name. Its actor_id (agent:N) does not change, so every pad write, todo comment and lease it has already made stays attributable. A live claude worker is also told to retitle its own session, which shows up in its pane; that arrives as a user turn, so rename between assignments rather than mid-task. Refuses a lead target outright.",
|
|
749
|
+
inputSchema: {
|
|
750
|
+
name: agentNameParam,
|
|
751
|
+
agent_id: agentIdParam,
|
|
752
|
+
new_name: z
|
|
753
|
+
.string()
|
|
754
|
+
.describe("The new display name. No other running worker may have it, case aside."),
|
|
755
|
+
project_id: projectIdParam,
|
|
756
|
+
},
|
|
757
|
+
outputSchema: {
|
|
758
|
+
agent_id: idParam,
|
|
759
|
+
actor_id: z.string(),
|
|
760
|
+
name: z.string(),
|
|
761
|
+
previous_name: z.string(),
|
|
762
|
+
retitled: z.boolean(),
|
|
763
|
+
note: z.string().optional(),
|
|
764
|
+
tail: z.string().optional(),
|
|
765
|
+
},
|
|
766
|
+
}, (args) => run(async () => {
|
|
767
|
+
const project = resolveProject(args.project_id);
|
|
768
|
+
const agent = findAgent(project.id, args);
|
|
769
|
+
if (agent.kind === LEAD_KIND) {
|
|
770
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}") is this project's lead session. Its name is the handle ` +
|
|
771
|
+
"every wake, pad and todo addresses it by; agent_rename refuses a lead target.");
|
|
772
|
+
}
|
|
773
|
+
if (agent.status !== "running") {
|
|
774
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}") is closed and cannot be renamed.`);
|
|
775
|
+
}
|
|
776
|
+
const newName = normalizeAgentName(args.new_name, "new_name");
|
|
777
|
+
requireNameFree(project.id, newName, agent.id);
|
|
778
|
+
const live = isLive(agent) === true;
|
|
779
|
+
renameAgent(agent, newName, live ? project.name : null);
|
|
780
|
+
let retitled = false;
|
|
781
|
+
let heldNote;
|
|
782
|
+
let heldTail;
|
|
783
|
+
const renameHarness = harnessFor(agent.command);
|
|
784
|
+
if (live && renameHarness.supportsRename) {
|
|
785
|
+
const classifier = renameHarness.paneClassifier;
|
|
786
|
+
const { awaitingChoice, tail } = classifier.choiceCheck(agent.tmux_target);
|
|
787
|
+
const box = awaitingChoice === false ? classifier.inputBoxState(agent.tmux_target) : null;
|
|
788
|
+
if (awaitingChoice === true) {
|
|
789
|
+
heldNote =
|
|
790
|
+
"Not retitled: the pane is waiting on a choice, so typing /rename would answer it instead of setting the title. Clear the prompt first (agent_send with keys), then retry.";
|
|
791
|
+
heldTail = tail;
|
|
792
|
+
}
|
|
793
|
+
else if (awaitingChoice === null) {
|
|
794
|
+
// A read that did not answer is a THIRD outcome, never "no dialog". /rename is a paste
|
|
795
|
+
// followed by Enter, so this path types blind exactly as agent_send's did.
|
|
796
|
+
heldNote =
|
|
797
|
+
"Not retitled: the pane could not be read, so hive cannot tell whether a dialog is on screen, and " +
|
|
798
|
+
"typing /rename into one answers it instead of setting the title. The row IS renamed - it is " +
|
|
799
|
+
`"${newName}" now - and only the pane's own title was left alone. This is usually a tmux call that ` +
|
|
800
|
+
"timed out rather than anything about the target; check it with agent_output, then call " +
|
|
801
|
+
`agent_rename(name: "${newName}", new_name: "${newName}") to retitle the pane.`;
|
|
802
|
+
heldTail = tail;
|
|
803
|
+
}
|
|
804
|
+
else if (holdsHumanInput(box)) {
|
|
805
|
+
heldNote =
|
|
806
|
+
`Not retitled: the pane's input box holds unsubmitted text, so /rename would be pasted onto the end of it and submitted as prose rather than run as a command. The row IS renamed - it is "${newName}" now - and only the pane's own title was left alone. Clear the line with agent_send(keys: ["C-a", "C-k"]) once you can attribute the text, then call agent_rename(name: "${newName}", new_name: "${newName}") to retitle the pane.`;
|
|
807
|
+
heldTail = tail;
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
let pasted = false;
|
|
811
|
+
try {
|
|
812
|
+
await sendText(agent.tmux_target, `/rename ${newName}`, true, () => {
|
|
813
|
+
pasted = true;
|
|
814
|
+
});
|
|
815
|
+
retitled = true;
|
|
816
|
+
}
|
|
817
|
+
catch {
|
|
818
|
+
if (pasted) {
|
|
819
|
+
heldNote =
|
|
820
|
+
`Not retitled: the rename command reached the pane but the Enter that submits it failed, so "/rename ${newName}" is sitting on screen unsubmitted. The row IS renamed - it is "${newName}" now - only the pane's own title was left alone. Do not resend /rename: agent_send(name: ${JSON.stringify(newName)}, keys: ["Enter"]) finishes this exact delivery, or agent_send(name: ${JSON.stringify(newName)}, keys: ["C-a", "C-k"]) clears it.`;
|
|
821
|
+
try {
|
|
822
|
+
heldTail = capturePane(agent.tmux_target, 15);
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
return {
|
|
831
|
+
agent_id: agent.id,
|
|
832
|
+
actor_id: agent.actor_id,
|
|
833
|
+
name: newName,
|
|
834
|
+
previous_name: agent.name,
|
|
835
|
+
retitled,
|
|
836
|
+
...(heldNote ? { note: heldNote, tail: heldTail } : {}),
|
|
837
|
+
};
|
|
838
|
+
}));
|
|
839
|
+
server.registerTool("agent_list", {
|
|
840
|
+
description: "List this project's agents with live status.",
|
|
841
|
+
inputSchema: {
|
|
842
|
+
include_closed: z.boolean().optional(),
|
|
843
|
+
project_id: projectIdParam,
|
|
844
|
+
},
|
|
845
|
+
}, (args) => run(() => {
|
|
846
|
+
const project = resolveProject(args.project_id);
|
|
847
|
+
let sql = "SELECT * FROM agents WHERE project_id = ?";
|
|
848
|
+
if (!args.include_closed)
|
|
849
|
+
sql += " AND status = 'running'";
|
|
850
|
+
const rows = db.prepare(`${sql} ORDER BY id`).all(project.id);
|
|
851
|
+
const snapshot = liveTargets();
|
|
852
|
+
return {
|
|
853
|
+
project_id: project.id,
|
|
854
|
+
project_name: project.name,
|
|
855
|
+
...(snapshot === null
|
|
856
|
+
? {
|
|
857
|
+
note: `${PROBE_FAILED_NOTE} These rows are what the store holds; do not conclude a worker died.`,
|
|
858
|
+
}
|
|
859
|
+
: {}),
|
|
860
|
+
agents: rows.map((r) => {
|
|
861
|
+
const summary = agentSummary(r, snapshot);
|
|
862
|
+
return {
|
|
863
|
+
...summary,
|
|
864
|
+
...(summary.alive !== true ? claudeOnlyFields(r) : {}),
|
|
865
|
+
...paneField(r, summary.alive),
|
|
866
|
+
};
|
|
867
|
+
}),
|
|
868
|
+
};
|
|
869
|
+
}));
|
|
870
|
+
server.registerTool("agent_status", {
|
|
871
|
+
description: "Detailed status for one agent, addressed by name (or agent_id), including a short tail of its terminal. include_brief=true returns the exact brief this worker was given; hive keeps that copy because an appended system prompt appears in no transcript.",
|
|
872
|
+
inputSchema: {
|
|
873
|
+
name: agentNameParam,
|
|
874
|
+
agent_id: agentIdParam,
|
|
875
|
+
include_brief: z
|
|
876
|
+
.boolean()
|
|
877
|
+
.optional()
|
|
878
|
+
.describe("Return the full injected brief, not just its path. Defaults to false."),
|
|
879
|
+
project_id: projectIdParam,
|
|
880
|
+
},
|
|
881
|
+
}, (args) => run(() => {
|
|
882
|
+
const project = resolveProject(args.project_id);
|
|
883
|
+
const agent = findAgent(project.id, args);
|
|
884
|
+
const summary = agentSummary(agent);
|
|
885
|
+
const briefPath = agentBriefPath(agent.id);
|
|
886
|
+
return {
|
|
887
|
+
...summary,
|
|
888
|
+
...(summary.alive === null ? { note: PROBE_FAILED_NOTE } : {}),
|
|
889
|
+
closed_at: agent.closed_at,
|
|
890
|
+
current_command: summary.alive ? paneCurrentCommand(agent.tmux_target) : null,
|
|
891
|
+
brief_path: existsSync(briefPath) ? briefPath : null,
|
|
892
|
+
...(args.include_brief ? { brief: readAgentBrief(agent.id) } : {}),
|
|
893
|
+
tail: summary.alive ? capturePane(agent.tmux_target, 15) : "",
|
|
894
|
+
...(summary.alive ? inputBoxField(agent.command, agent.tmux_target) : {}),
|
|
895
|
+
...(agent.exit_tail ? { exit_tail: agent.exit_tail } : {}),
|
|
896
|
+
...claudeOnlyFields(agent),
|
|
897
|
+
...contextTokensField(agent, "context_fill" in summary ? summary.context_fill : null),
|
|
898
|
+
};
|
|
899
|
+
}));
|
|
900
|
+
server.registerTool("agent_send", {
|
|
901
|
+
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. 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.",
|
|
902
|
+
inputSchema: {
|
|
903
|
+
name: agentNameParam,
|
|
904
|
+
agent_id: agentIdParam,
|
|
905
|
+
text: z.string().optional(),
|
|
906
|
+
keys: z.array(z.string()).optional().describe("tmux key names, e.g. [\"Escape\"] or [\"C-c\"]."),
|
|
907
|
+
submit: z.boolean().optional().describe("Append Enter after text. Defaults to true."),
|
|
908
|
+
wait_ms: z.number().int().min(250).max(10000).optional(),
|
|
909
|
+
project_id: projectIdParam,
|
|
910
|
+
},
|
|
911
|
+
}, (args) => run(async () => {
|
|
912
|
+
const project = resolveProject(args.project_id);
|
|
913
|
+
const agent = findAgent(project.id, args);
|
|
914
|
+
requireLive(agent);
|
|
915
|
+
const target = agent.tmux_target;
|
|
916
|
+
let outgoing = args.text ?? "";
|
|
917
|
+
const tag = senderTag(project.id, currentActor());
|
|
918
|
+
let shortened = null;
|
|
919
|
+
const withShortened = (receipt) => shortened === null
|
|
920
|
+
? receipt
|
|
921
|
+
: {
|
|
922
|
+
...receipt,
|
|
923
|
+
shortened: true,
|
|
924
|
+
message_id: shortened.message_id,
|
|
925
|
+
note: receipt.note == null ? shortened.note : `${receipt.note} ${shortened.note}`,
|
|
926
|
+
};
|
|
927
|
+
if (args.keys && args.keys.length > 0 && args.text != null) {
|
|
928
|
+
throw new Error("Pass text or keys, not both.");
|
|
929
|
+
}
|
|
930
|
+
else if (args.keys && args.keys.length > 0) {
|
|
931
|
+
if (agent.kind === LEAD_KIND && !isRunningLeadActor(currentActor())) {
|
|
932
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}") is this project's lead session, the one actor with no ` +
|
|
933
|
+
"supervisor above it. agent_send refuses to send raw keys to a lead from a non-lead caller " +
|
|
934
|
+
"(text still works); unstick or restart it from its own terminal instead.");
|
|
935
|
+
}
|
|
936
|
+
if (args.keys.length === 2 && args.keys[0] === "-X" && args.keys[1] === "cancel") {
|
|
937
|
+
cancelCopyMode(target);
|
|
938
|
+
}
|
|
939
|
+
else {
|
|
940
|
+
tmux("send-keys", "-t", target, "--", ...args.keys);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
else if (args.text != null) {
|
|
944
|
+
const badChar = findUnsafeControlChar(args.text, TEXT_ALLOWED_CONTROL_CHARS);
|
|
945
|
+
if (badChar) {
|
|
946
|
+
throw new Error(`text cannot contain ${badChar.label} at offset ${badChar.index}: it is typed literally into ` +
|
|
947
|
+
"the pane, and a raw control byte reaches tmux as a keystroke instead of as text, silently " +
|
|
948
|
+
"turning a text call into a keys call. Tab and newline are the only control characters " +
|
|
949
|
+
'allowed. To send an actual keystroke on purpose, use keys instead (e.g. keys: ["C-c"]).');
|
|
950
|
+
}
|
|
951
|
+
if (!screenClassifiable(agent.command)) {
|
|
952
|
+
return {
|
|
953
|
+
agent_id: agent.id,
|
|
954
|
+
name: agent.name,
|
|
955
|
+
sent: false,
|
|
956
|
+
note: `Text was NOT sent: this pane runs ${JSON.stringify(commandHead(agent.command))}, and hive can ` +
|
|
957
|
+
"only classify a claude screen. Both guards that make typing safe read that chrome, so on this " +
|
|
958
|
+
"pane neither can answer: a dialog would not be seen (the Enter after the paste would answer " +
|
|
959
|
+
"it), and unsubmitted text in the box would not be seen (the paste would land on the end of it " +
|
|
960
|
+
"and Enter would submit both - on a shell, execute both). A predicate that cannot answer is not " +
|
|
961
|
+
"a predicate that answered \"safe\". " +
|
|
962
|
+
'Drive this pane deliberately instead: agent_send(name: ' +
|
|
963
|
+
`${JSON.stringify(agent.name)}, keys: [...]) is unguarded for exactly this reason, and reaches ` +
|
|
964
|
+
"an arbitrary TUI a text turn cannot. Read it first with agent_output.",
|
|
965
|
+
tail: capturePaneQuietly(agent.tmux_target),
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
if (paneInCopyMode(target) === true) {
|
|
969
|
+
return {
|
|
970
|
+
agent_id: agent.id,
|
|
971
|
+
name: agent.name,
|
|
972
|
+
sent: false,
|
|
973
|
+
note: "The pane is in tmux copy mode, so text was NOT sent: tmux clears a pane's bracketed-paste " +
|
|
974
|
+
"flag there, so the paste would arrive with no markers - anything past one 1022-byte write " +
|
|
975
|
+
"loses its head - and the Enter after it is eaten by the mode rather than submitting. Both " +
|
|
976
|
+
"failures are silent: tmux reports success for each call. Someone is reading or scrolling " +
|
|
977
|
+
"this pane. Leave copy mode (press q or Escape there, or " +
|
|
978
|
+
`agent_send(name: ${JSON.stringify(agent.name)}, keys: ["-X", "cancel"]) to cancel it ` +
|
|
979
|
+
"deliberately), then retry - this refusal is retriable and nothing was lost.",
|
|
980
|
+
tail: capturePaneQuietly(agent.tmux_target),
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
const sendClassifier = harnessFor(agent.command).paneClassifier;
|
|
984
|
+
const submitting = args.submit !== false;
|
|
985
|
+
const { awaitingChoice, tail } = sendClassifier.choiceCheck(target);
|
|
986
|
+
// Unreadable refuses only when an Enter follows: with submit=false there is no keypress
|
|
987
|
+
// for a dialog to eat, the same reasoning that exempts submit=false from the box check.
|
|
988
|
+
if (awaitingChoice === true || (submitting && awaitingChoice === null)) {
|
|
989
|
+
return {
|
|
990
|
+
agent_id: agent.id,
|
|
991
|
+
name: agent.name,
|
|
992
|
+
sent: false,
|
|
993
|
+
note: awaitingChoice === true
|
|
994
|
+
? "The pane is waiting on a choice (e.g. a permission or trust prompt), so text was NOT sent: typing here would answer the prompt instead of reaching the worker. Use keys to answer or dismiss it deliberately (e.g. [\"Escape\"], or the option's number plus Enter), then retry."
|
|
995
|
+
: "The pane could not be read, so text was NOT sent: hive cannot tell whether a dialog is on " +
|
|
996
|
+
"screen, and typing into one answers it instead of reaching the worker. This is usually a " +
|
|
997
|
+
"tmux call that timed out (the server may be wedged or heavily loaded) rather than anything " +
|
|
998
|
+
"about the target. Retry; if it persists, check the pane with agent_output and run hive doctor.",
|
|
999
|
+
tail,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
if (submitting) {
|
|
1003
|
+
const box = sendClassifier.inputBoxState(target);
|
|
1004
|
+
if (holdsHumanInput(box)) {
|
|
1005
|
+
return {
|
|
1006
|
+
agent_id: agent.id,
|
|
1007
|
+
name: agent.name,
|
|
1008
|
+
sent: false,
|
|
1009
|
+
note: "The pane's input box holds unsubmitted text, so text was NOT sent: it would be pasted onto the end of that text and Enter would submit both as one message. Someone is mid-sentence at this terminal, or an earlier agent_send used submit=false and has not been submitted yet. " +
|
|
1010
|
+
(agent.kind === LEAD_KIND && !isRunningLeadActor(currentActor())
|
|
1011
|
+
? "That target is a LEAD session, so agent_send's keys path is refused against it from a non-lead caller: you cannot clear or submit that line yourself. A human at that terminal, or another lead, has to. Leave it and try again later."
|
|
1012
|
+
: "Read the pane with agent_output first. If the text is your own composition, submit it with agent_send(keys: [\"Enter\"]). If it is a human's, leave it alone, or clear the line with agent_send(keys: [\"C-a\", \"C-k\"]) once you can attribute it, then retry."),
|
|
1013
|
+
tail,
|
|
1014
|
+
input_box: box,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (agent.kind === LEAD_KIND &&
|
|
1019
|
+
!isRunningLeadActor(currentActor()) &&
|
|
1020
|
+
args.text.length > LEAD_MESSAGE_THRESHOLD) {
|
|
1021
|
+
// Stored BEFORE the paste, deliberately: storing after a successful paste lets a pointer
|
|
1022
|
+
// reach the pane naming a row that does not exist yet, which is worse than the orphan row a
|
|
1023
|
+
// failed paste leaves behind.
|
|
1024
|
+
const { id } = storeLeadMessage(project.id, currentActor(), agent.id, args.text);
|
|
1025
|
+
const pointer = renderLeadPointer(id, args.text, tag);
|
|
1026
|
+
shortened = {
|
|
1027
|
+
message_id: id,
|
|
1028
|
+
note: shortenedSendNote(id, pointer.length),
|
|
1029
|
+
marker: tag + leadPointerMarker(id, args.text),
|
|
1030
|
+
};
|
|
1031
|
+
outgoing = pointer;
|
|
1032
|
+
}
|
|
1033
|
+
if (shortened === null)
|
|
1034
|
+
outgoing = tag + args.text;
|
|
1035
|
+
let pasted = false;
|
|
1036
|
+
let buffered = false;
|
|
1037
|
+
try {
|
|
1038
|
+
await sendText(target, outgoing, submitting, () => {
|
|
1039
|
+
pasted = true;
|
|
1040
|
+
}, () => {
|
|
1041
|
+
buffered = true;
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
catch (err) {
|
|
1045
|
+
const shortenedClause = shortened === null ? "" : shortenedSendFailureClause(shortened.marker, shortened.message_id);
|
|
1046
|
+
if (!pasted) {
|
|
1047
|
+
if (buffered && err instanceof TmuxTimeoutError) {
|
|
1048
|
+
throw new Error(`[agent_send:paste-timeout-ambiguous] agent_send's paste call to ${agent.name}'s pane timed out. A timed-out tmux call does not prove nothing happened - the server can finish a command after the client gives up waiting on it - so the text MAY already be on that screen, unsubmitted. Do not resend blindly: read the pane first with agent_output(name: ${JSON.stringify(agent.name)}), and only send again if the text genuinely is not there.${shortenedClause}`, { cause: err });
|
|
1049
|
+
}
|
|
1050
|
+
throw err;
|
|
1051
|
+
}
|
|
1052
|
+
const canFinishItself = !(agent.kind === LEAD_KIND && !isRunningLeadActor(currentActor()));
|
|
1053
|
+
throw new Error(`[agent_send:paste-landed-enter-failed] agent_send's Enter failed after the paste to ${agent.name}'s pane already succeeded: that text was on the target's screen, unsubmitted, the moment the paste returned - it was pasted, not lost, though a concurrent send could have changed the screen since. Do NOT resend it: retrying pastes a second copy onto the end of the first, and the Enter that follows submits both as one message (on a non-claude pane, that merged line EXECUTES). ` +
|
|
1054
|
+
(canFinishItself
|
|
1055
|
+
? `Finish this exact delivery instead: agent_send(name: ${JSON.stringify(agent.name)}, keys: ["Enter"]).`
|
|
1056
|
+
: `That target is a LEAD session, so agent_send's keys path is refused against it from a non-lead caller: you cannot finish this yourself. A human at that terminal, or another lead, has to press Enter there. Leave it and try again later.${shortened === null ? "" : " Pressing Enter there is the INTENDED finish, not a stray paste: that stranded line is hive's own pointer, and submitting it is exactly what this send was supposed to do."}`) +
|
|
1057
|
+
shortenedClause, { cause: err });
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
else {
|
|
1061
|
+
throw new Error("Pass text or keys.");
|
|
1062
|
+
}
|
|
1063
|
+
if (args.wait_ms != null) {
|
|
1064
|
+
await sleep(Math.min(Math.max(args.wait_ms, 250), 10000));
|
|
1065
|
+
let tailField;
|
|
1066
|
+
try {
|
|
1067
|
+
tailField = { tail: capturePane(target, 15) };
|
|
1068
|
+
}
|
|
1069
|
+
catch {
|
|
1070
|
+
tailField = {
|
|
1071
|
+
note: "Sent, but the terminal tail could not be read afterward (the pane may have died during the wait). Check agent_status or agent_output to confirm the worker is still there.",
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
return withShortened({
|
|
1075
|
+
agent_id: agent.id,
|
|
1076
|
+
name: agent.name,
|
|
1077
|
+
sent: true,
|
|
1078
|
+
...tailField,
|
|
1079
|
+
...inputBoxField(agent.command, target),
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
return withShortened({ agent_id: agent.id, name: agent.name, sent: true });
|
|
1083
|
+
}));
|
|
1084
|
+
server.registerTool("agent_message_get", {
|
|
1085
|
+
description: "Read one agent-to-lead message in full, by the id in a \"[hive:worker NAME] [message #N ...]\" pointer line. hive stores a message here only when it shortens one: text over 300 characters sent to a lead by someone who is not that lead. Every other send is typed with its sender tag and stores nothing, so there is no id to read. Messages are pruned after 7 days, and a lookup for a pruned id says so rather than reporting it missing.",
|
|
1086
|
+
inputSchema: {
|
|
1087
|
+
message_id: idParam,
|
|
1088
|
+
project_id: projectIdParam,
|
|
1089
|
+
},
|
|
1090
|
+
}, (args) => run(() => {
|
|
1091
|
+
const project = resolveProject(args.project_id);
|
|
1092
|
+
const row = readLeadMessage(project.id, args.message_id);
|
|
1093
|
+
if (row === undefined) {
|
|
1094
|
+
throw new Error(missMessage(args.message_id, classifyMiss(args.message_id)));
|
|
1095
|
+
}
|
|
1096
|
+
return {
|
|
1097
|
+
message_id: row.id,
|
|
1098
|
+
from: row.from_name,
|
|
1099
|
+
from_actor: row.from_actor,
|
|
1100
|
+
to_agent_id: row.to_agent_id,
|
|
1101
|
+
chars: row.text.length,
|
|
1102
|
+
created_at: row.created_at,
|
|
1103
|
+
text: row.text,
|
|
1104
|
+
};
|
|
1105
|
+
}));
|
|
1106
|
+
server.registerTool("agent_output", {
|
|
1107
|
+
description: "Read the rendered terminal of an agent (default 50 lines, max 200), addressed by name or agent_id. Read REAL output before declaring a worker done.",
|
|
1108
|
+
inputSchema: {
|
|
1109
|
+
name: agentNameParam,
|
|
1110
|
+
agent_id: agentIdParam,
|
|
1111
|
+
lines: z.number().int().min(1).max(200).optional(),
|
|
1112
|
+
project_id: projectIdParam,
|
|
1113
|
+
},
|
|
1114
|
+
}, (args) => run(() => {
|
|
1115
|
+
const project = resolveProject(args.project_id);
|
|
1116
|
+
const agent = findAgent(project.id, args);
|
|
1117
|
+
const lines = Math.min(args.lines ?? 50, 200);
|
|
1118
|
+
const alive = isLive(agent);
|
|
1119
|
+
return {
|
|
1120
|
+
agent_id: agent.id,
|
|
1121
|
+
name: agent.name,
|
|
1122
|
+
alive,
|
|
1123
|
+
output: alive ? capturePane(agent.tmux_target, lines) : "",
|
|
1124
|
+
...(alive ? inputBoxField(agent.command, agent.tmux_target) : {}),
|
|
1125
|
+
...(alive === true
|
|
1126
|
+
? {}
|
|
1127
|
+
: {
|
|
1128
|
+
note: alive === null
|
|
1129
|
+
? PROBE_FAILED_NOTE
|
|
1130
|
+
: "No live tmux window; output is not retained after exit.",
|
|
1131
|
+
}),
|
|
1132
|
+
...(alive !== true && agent.exit_tail ? { exit_tail: agent.exit_tail } : {}),
|
|
1133
|
+
};
|
|
1134
|
+
}));
|
|
1135
|
+
server.registerTool("agent_close", {
|
|
1136
|
+
description: "Kill an agent's tmux window and mark it closed, addressed by name (or agent_id). Capture handoffs (todo comments, pads) BEFORE closing; terminal output is not retained. Closing yourself requires confirm_self=true. Refuses a lead target whose pane is live; retires one whose pane is confirmed dead. A worker may never close a lead, live or dead.",
|
|
1137
|
+
inputSchema: {
|
|
1138
|
+
name: agentNameParam,
|
|
1139
|
+
agent_id: agentIdParam,
|
|
1140
|
+
confirm_self: z.boolean().optional(),
|
|
1141
|
+
project_id: projectIdParam,
|
|
1142
|
+
},
|
|
1143
|
+
outputSchema: {
|
|
1144
|
+
agent_id: idParam,
|
|
1145
|
+
name: z.string(),
|
|
1146
|
+
closed: z.boolean(),
|
|
1147
|
+
park_released: z.boolean().optional(),
|
|
1148
|
+
parked: z.boolean().optional(),
|
|
1149
|
+
note: z.string().optional(),
|
|
1150
|
+
},
|
|
1151
|
+
}, (args) => run(() => {
|
|
1152
|
+
const project = resolveProject(args.project_id);
|
|
1153
|
+
const agent = findAgent(project.id, args);
|
|
1154
|
+
if (agent.kind === LEAD_KIND && currentActor().startsWith("agent:")) {
|
|
1155
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}") is this project's lead session. Retiring a lead - live, ` +
|
|
1156
|
+
"confirmed dead, or unprobed - is reserved for a human at a terminal or a peer lead; a " +
|
|
1157
|
+
"worker this project spawned may not close it.");
|
|
1158
|
+
}
|
|
1159
|
+
if (agent.status === "closed" && agent.parked_at) {
|
|
1160
|
+
const released = db.transaction(() => {
|
|
1161
|
+
if (!releaseParkRow(agent.id))
|
|
1162
|
+
return false;
|
|
1163
|
+
markGoneReported(agent.id, project.id);
|
|
1164
|
+
return true;
|
|
1165
|
+
})();
|
|
1166
|
+
if (!released) {
|
|
1167
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}")'s row changed since this call probed it - most likely a ` +
|
|
1168
|
+
"concurrent agent_resume. Nothing was released and nothing was closed. Re-read it with " +
|
|
1169
|
+
"agent_status and try again.");
|
|
1170
|
+
}
|
|
1171
|
+
// Best-effort: a failed reap must not fail the close it rides on. The janitor's own
|
|
1172
|
+
// backstop sweep retries it (codex_home stays set until reapCodexHome actually succeeds).
|
|
1173
|
+
if (agent.codex_home) {
|
|
1174
|
+
try {
|
|
1175
|
+
reapCodexHomeForClosedAgent(agent.id, agent.codex_home);
|
|
1176
|
+
}
|
|
1177
|
+
catch {
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
return { agent_id: agent.id, name: agent.name, closed: true, park_released: true };
|
|
1181
|
+
}
|
|
1182
|
+
const live = isLive(agent);
|
|
1183
|
+
if (live === null)
|
|
1184
|
+
throw probeFailed(agent);
|
|
1185
|
+
if (agent.kind === LEAD_KIND && live) {
|
|
1186
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}") is this project's lead session, the one actor with no ` +
|
|
1187
|
+
"supervisor above it, and its pane is still live. agent_close refuses to end a running " +
|
|
1188
|
+
"lead's session; restart it from its own terminal instead.");
|
|
1189
|
+
}
|
|
1190
|
+
if (agent.actor_id === currentActor() && args.confirm_self !== true) {
|
|
1191
|
+
throw new Error("This would close your own session. Pass confirm_self=true only if the user explicitly asked you to close yourself.");
|
|
1192
|
+
}
|
|
1193
|
+
// A hive.yml process gets the graceful leg and the stopping marker, exactly as `hive stop`
|
|
1194
|
+
// does: kill-pane alone is SIGHUP, and a kill without the marker can be reported as a crash.
|
|
1195
|
+
const commandRow = agent.kind === COMMAND_KIND ? runningCommandRow(project.id, agent.name) : undefined;
|
|
1196
|
+
if (commandRow && live) {
|
|
1197
|
+
const stopped = stopProcess(commandRow, STOP_REASONS.byHand);
|
|
1198
|
+
return {
|
|
1199
|
+
agent_id: agent.id,
|
|
1200
|
+
name: agent.name,
|
|
1201
|
+
closed: stopped.leg !== "still-running",
|
|
1202
|
+
stop_leg: stopped.leg,
|
|
1203
|
+
note: stopLine(stopped),
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
if (live)
|
|
1207
|
+
killAgentPane(agent.tmux_target);
|
|
1208
|
+
if (!closeAgentRow(agent.id, agent.tmux_target)) {
|
|
1209
|
+
const after = getAgentRow(project.id, agent.id, "Call agent_list(include_closed: true).");
|
|
1210
|
+
const report = classifyLostCas(after);
|
|
1211
|
+
if (report.outcome === "retired") {
|
|
1212
|
+
const probedTmux = agent.status === "running";
|
|
1213
|
+
return {
|
|
1214
|
+
agent_id: agent.id,
|
|
1215
|
+
name: agent.name,
|
|
1216
|
+
closed: true,
|
|
1217
|
+
...(report.parked ? { parked: true } : {}),
|
|
1218
|
+
note: report.parked
|
|
1219
|
+
? `Already retired as a park by a concurrent agent_park before this call's own close landed` +
|
|
1220
|
+
(live ? " (this call's kill-pane already took the pane down)." : ".")
|
|
1221
|
+
: live
|
|
1222
|
+
? "Already closed by someone else before this call's own close landed - most likely the " +
|
|
1223
|
+
"janitor (or a peer closer), reaping the pane this call's own kill left dead. End state is " +
|
|
1224
|
+
"the same as a successful close."
|
|
1225
|
+
: probedTmux
|
|
1226
|
+
? "Already closed by someone else before this call's own close landed. This call's own " +
|
|
1227
|
+
"probe already found the pane dead, so nothing was left running either way. End state is " +
|
|
1228
|
+
"the same as a successful close."
|
|
1229
|
+
: "Already closed by someone else before this call ever probed it, so this call never " +
|
|
1230
|
+
"checked the pane - it may still be running. Re-read it with agent_status if that matters.",
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
throw revivedError(agent, live, "a concurrent `hive lead` restart or agent_resume recording a fresh pane on it", "Nothing further was closed. Re-read it with agent_status and decide what you want.");
|
|
1234
|
+
}
|
|
1235
|
+
// Best-effort, same as the park-release branch above: a failed reap must not fail the close,
|
|
1236
|
+
// and the janitor's backstop sweep retries it.
|
|
1237
|
+
if (agent.codex_home) {
|
|
1238
|
+
try {
|
|
1239
|
+
reapCodexHomeForClosedAgent(agent.id, agent.codex_home);
|
|
1240
|
+
}
|
|
1241
|
+
catch {
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
return { agent_id: agent.id, name: agent.name, closed: true };
|
|
1245
|
+
}));
|
|
1246
|
+
}
|