@cmgmyr/hive 1.6.0 → 1.8.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/claude-plugin/skills/cleanup/SKILL.md +9 -1
- package/claude-plugin/skills/profile/SKILL.md +20 -5
- package/claude-plugin/skills/profile/references/examples/README.md +26 -0
- package/claude-plugin/skills/profile/references/examples/skeleton.md +127 -0
- package/claude-plugin/skills/profile/references/examples/two-projects.md +105 -0
- package/claude-plugin/skills/profile/references/interview.md +25 -3
- package/claude-plugin/skills/profile/references/recipes/index.md +1 -0
- package/claude-plugin/skills/profile/references/recipes/review-decision.md +2 -0
- package/claude-plugin/skills/profile/references/recipes/shared-test-resource.md +2 -0
- package/claude-plugin/skills/profile/references/recipes/verification.md +2 -2
- package/claude-plugin/skills/profile/references/recipes/worker-context-checkpoint.md +2 -0
- package/dist/build-info.json +1 -1
- package/dist/cli.js +49 -19
- package/dist/help.js +9 -1
- package/dist/leadWatch.js +9 -7
- package/dist/processes.js +37 -15
- package/dist/scheduler.js +179 -62
- package/dist/spawn.js +60 -10
- package/dist/tmux.js +15 -1
- package/dist/tools/agents.js +90 -35
- package/dist/tools/pads.js +28 -2
- package/dist/tools/wakes.js +44 -21
- package/package.json +1 -1
- package/profiles/orchestration/posture.md +17 -26
- package/profiles/orchestration/runbook.md +103 -47
- package/profiles/orchestration/worker.md +28 -18
package/dist/tools/agents.js
CHANGED
|
@@ -13,10 +13,10 @@ import { activeProfile, allowedAgents, loadProjectYml } from "../projectYml.js";
|
|
|
13
13
|
import { classifyMiss, LEAD_MESSAGE_THRESHOLD, leadPointerMarker, missMessage, readLeadMessage, renderLeadPointer, senderTag, shortenedSendFailureClause, shortenedSendNote, storeLeadMessage, } from "../leadMessage.js";
|
|
14
14
|
import { run } from "../result.js";
|
|
15
15
|
import { markGoneReported } from "../scheduler.js";
|
|
16
|
-
import { agentIdentityEnv, branchAt, closeAgentRow, discardOrphanedPane, isReservedAgentName, isRunningLeadActor, killAgentPane, launchAgent, LEAD_KIND, parkAgentRow, reapCodexHomeForClosedAgent, releaseParkRow, renameAgent, resumeAgent, storedLaunchOptions, } from "../spawn.js";
|
|
16
|
+
import { agentIdentityEnv, branchAt, closeAgentRow, currentOwnership, discardOrphanedPane, isReservedAgentName, isRunningLeadActor, killAgentPane, launchAgent, LEAD_KIND, PaneOwnershipLost, paneIdentity, parkAgentRow, reapCodexHomeForClosedAgent, requireStillOwned, releaseParkRow, renameAgent, resumeAgent, storedLaunchOptions, } from "../spawn.js";
|
|
17
17
|
import { COMMAND_KIND, runningCommandRow, stopLine, stopProcess, STOP_REASONS } from "../processes.js";
|
|
18
18
|
import { readContextTokens, readContextFill, resolveTranscriptDir } from "../transcript.js";
|
|
19
|
-
import { capturePane, captureFinalScreen, DEFAULT_LAYOUT, cancelCopyMode, describePaneChoice, ensureAttached, findUnsafeControlChar,
|
|
19
|
+
import { capturePane, captureFinalScreen, DEFAULT_LAYOUT, cancelCopyMode, describePaneChoice, ensureAttached, findUnsafeControlChar, holdsHumanInput, liveTargets, observationFailed, observeRowOwnership, ownershipLiveness, paneCurrentCommand, paneInCopyMode, paneWindow, pollPaneReadiness, rowOwnership, sendText, sessionName, setRemainOnExit, sleep, TEXT_ALLOWED_CONTROL_CHARS, tmux, TmuxTimeoutError, WINDOW_LAYOUTS, } from "../tmux.js";
|
|
20
20
|
import { agentIdParam, agentNameParam, idParam, projectIdParam } from "./params.js";
|
|
21
21
|
import { deriveProvenance, lastLogEvent, lastPermissionMode, reportsAgentStateLog, } from "../stateProvenance.js";
|
|
22
22
|
const CLOSED_ROW_ORDER = "(parked_at != '') DESC, closed_at DESC, id DESC";
|
|
@@ -145,11 +145,7 @@ function closeRowOnly(agent, confirmSelf) {
|
|
|
145
145
|
note: "Already closed; row_only changed nothing and left any park in place.",
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
|
-
const
|
|
149
|
-
const snapshot = observes ? liveTargets() : null;
|
|
150
|
-
if (observes && observationFailed(snapshot))
|
|
151
|
-
throw probeFailed(agent);
|
|
152
|
-
const ownership = rowOwnership(agent, snapshot);
|
|
148
|
+
const ownership = observeOwnership(agent);
|
|
153
149
|
if (ownership === "live") {
|
|
154
150
|
throw new Error(`Agent ${agent.id} ("${agent.name}") owns a live pane (its recorded pane pid matches), so row_only refuses: ` +
|
|
155
151
|
"retiring the row would orphan a running process with nothing left to stop it by. " +
|
|
@@ -157,7 +153,7 @@ function closeRowOnly(agent, confirmSelf) {
|
|
|
157
153
|
? "Restart or end the lead from its own terminal."
|
|
158
154
|
: "Use agent_close without row_only to stop it."));
|
|
159
155
|
}
|
|
160
|
-
if (!closeAgentRow(agent.id,
|
|
156
|
+
if (!closeAgentRow(agent.id, paneIdentity(agent))) {
|
|
161
157
|
const after = getAgentRow(agent.project_id, agent.id, "Call agent_list(include_closed: true).");
|
|
162
158
|
if (after.status !== "running") {
|
|
163
159
|
return {
|
|
@@ -175,25 +171,42 @@ function closeRowOnly(agent, confirmSelf) {
|
|
|
175
171
|
(ownership === "unknown" ? " Anything still running in that pane was left alone." : ""),
|
|
176
172
|
};
|
|
177
173
|
}
|
|
178
|
-
export function isLive(agent) {
|
|
179
|
-
if (agent.status !== "running")
|
|
180
|
-
return false;
|
|
181
|
-
return rowLive(agent.tmux_socket, agent.tmux_target);
|
|
182
|
-
}
|
|
183
174
|
export const PROBE_FAILED_NOTE = "tmux could not be probed, so liveness is unknown. Nothing was changed. Retry in a few seconds.";
|
|
184
175
|
const unknownOwnershipNote = (snapshot) => observationFailed(snapshot) ? PROBE_FAILED_NOTE : UNVERIFIED_OWNERSHIP_NOTE;
|
|
185
176
|
const UNVERIFIED_OWNERSHIP_NOTE = "This row's pane cannot be verified as its own: it is on another tmux socket, or no pane pid was recorded for it, so liveness is unknown and no screen was read. Nothing was changed. If the row is stale, a human or peer lead can retire it with agent_close(row_only=true).";
|
|
186
177
|
export const probeFailed = (agent) => new Error(`Agent ${agent.id} ("${agent.name}"): ${PROBE_FAILED_NOTE}`);
|
|
178
|
+
// A failed probe throws rather than answering unknown, so callers keep the retry wording.
|
|
179
|
+
export function observeOwnership(agent) {
|
|
180
|
+
const ownership = observeRowOwnership(agent);
|
|
181
|
+
if (ownership === "probe-failed")
|
|
182
|
+
throw probeFailed(agent);
|
|
183
|
+
return ownership;
|
|
184
|
+
}
|
|
185
|
+
export const REISSUED_PANE_NOTE = "Its recorded pane id now belongs to a different process (the recorded pane pid no longer matches), so its own process is gone and the pane is someone else's. Nothing was typed, read or killed there.";
|
|
186
|
+
export function notOwnedError(agent, ownership) {
|
|
187
|
+
const who = `Agent ${agent.id} ("${agent.name}")`;
|
|
188
|
+
if (ownership === "gone") {
|
|
189
|
+
return new Error(`${who} has no live tmux window (its process exited or the window was killed). Close it with agent_close and spawn a new one.`);
|
|
190
|
+
}
|
|
191
|
+
if (ownership === "reissued") {
|
|
192
|
+
return new Error(`${who}: pane ownership reads reissued. ${REISSUED_PANE_NOTE} Close the row with agent_close, which retires it without touching that pane.`);
|
|
193
|
+
}
|
|
194
|
+
return new Error(`${who}: pane ownership reads unknown. ${UNVERIFIED_OWNERSHIP_NOTE}`);
|
|
195
|
+
}
|
|
196
|
+
// Rechecked just before a kill. "changed" skips the kill and lets the row CAS report the race.
|
|
197
|
+
function stillOwnsPane(agent, action) {
|
|
198
|
+
const now = currentOwnership(agent.id, paneIdentity(agent));
|
|
199
|
+
if (now === "unknown")
|
|
200
|
+
throw new PaneOwnershipLost(agent.id, now, action);
|
|
201
|
+
return now === "live";
|
|
202
|
+
}
|
|
187
203
|
function requireLive(agent) {
|
|
188
204
|
if (agent.status !== "running") {
|
|
189
205
|
throw new Error(`Agent ${agent.id} ("${agent.name}") is closed.`);
|
|
190
206
|
}
|
|
191
|
-
const
|
|
192
|
-
if (
|
|
193
|
-
throw
|
|
194
|
-
if (!live) {
|
|
195
|
-
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.`);
|
|
196
|
-
}
|
|
207
|
+
const ownership = observeOwnership(agent);
|
|
208
|
+
if (ownership !== "live")
|
|
209
|
+
throw notOwnedError(agent, ownership);
|
|
197
210
|
}
|
|
198
211
|
function runningAgentNamed(projectId, name, exceptAgentId) {
|
|
199
212
|
const needle = name.toLowerCase();
|
|
@@ -854,16 +867,19 @@ export function registerAgents(server) {
|
|
|
854
867
|
"would record a lane that cannot be resumed, and its branch can no longer be read. Recreate that " +
|
|
855
868
|
"path first if you want this lane back tomorrow, or close it with agent_close.");
|
|
856
869
|
}
|
|
857
|
-
const
|
|
858
|
-
if (
|
|
859
|
-
throw
|
|
870
|
+
const ownership = observeOwnership(agent);
|
|
871
|
+
if (ownership === "unknown")
|
|
872
|
+
throw notOwnedError(agent, ownership);
|
|
873
|
+
let live = ownership === "live";
|
|
860
874
|
if (agent.actor_id === currentActor() && args.confirm_self !== true) {
|
|
861
875
|
throw new Error("This would park your own session. Pass confirm_self=true only if the user explicitly asked you to park yourself.");
|
|
862
876
|
}
|
|
863
877
|
const branch = branchAt(agent.cwd);
|
|
878
|
+
if (live)
|
|
879
|
+
live = stillOwnsPane(agent, "agent_park");
|
|
864
880
|
if (live)
|
|
865
881
|
killAgentPane(agent.tmux_target);
|
|
866
|
-
const parkedAt = parkAgentRow(agent.id, agent
|
|
882
|
+
const parkedAt = parkAgentRow(agent.id, paneIdentity(agent), branch);
|
|
867
883
|
if (parkedAt === undefined) {
|
|
868
884
|
const after = getAgentRow(project.id, agent.id, "Call agent_list(include_closed: true).");
|
|
869
885
|
const report = classifyLostCas(after);
|
|
@@ -880,7 +896,10 @@ export function registerAgents(server) {
|
|
|
880
896
|
}
|
|
881
897
|
throw revivedError(agent, live, "a concurrent agent_resume reviving it", "Nothing was parked. Re-read it with agent_status and try again.");
|
|
882
898
|
}
|
|
883
|
-
return buildParkReceipt(project, agent, parkedAt, branch
|
|
899
|
+
return buildParkReceipt(project, agent, parkedAt, branch, live
|
|
900
|
+
? undefined
|
|
901
|
+
: `Parked with no kill: its pane ownership read ${ownership === "live" ? "changed" : ownership}, so its own process was already gone and ` +
|
|
902
|
+
"that pane was left alone.");
|
|
884
903
|
}));
|
|
885
904
|
server.registerTool("agent_rename", {
|
|
886
905
|
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.",
|
|
@@ -919,7 +938,11 @@ export function registerAgents(server) {
|
|
|
919
938
|
}
|
|
920
939
|
const newName = normalizeAgentName(args.new_name, "new_name");
|
|
921
940
|
requireNameFree(project.id, newName, agent.id);
|
|
922
|
-
const
|
|
941
|
+
const ownership = observeOwnership(agent);
|
|
942
|
+
if (ownership === "unknown")
|
|
943
|
+
throw notOwnedError(agent, ownership);
|
|
944
|
+
const live = ownership === "live";
|
|
945
|
+
const identity = paneIdentity(agent);
|
|
923
946
|
renameAgent(agent, newName, live ? project.name : null);
|
|
924
947
|
let retitled = false;
|
|
925
948
|
let heldNote;
|
|
@@ -955,11 +978,18 @@ export function registerAgents(server) {
|
|
|
955
978
|
try {
|
|
956
979
|
await sendText(agent.tmux_target, `/rename ${newName}`, true, () => {
|
|
957
980
|
pasted = true;
|
|
958
|
-
});
|
|
981
|
+
}, undefined, () => requireStillOwned(agent.id, identity, "agent_rename"));
|
|
959
982
|
retitled = true;
|
|
960
983
|
}
|
|
961
|
-
catch {
|
|
962
|
-
if (
|
|
984
|
+
catch (err) {
|
|
985
|
+
if (err instanceof PaneOwnershipLost) {
|
|
986
|
+
heldNote =
|
|
987
|
+
`Not retitled: ${err.message} The row IS renamed - it is "${newName}" now.` +
|
|
988
|
+
(pasted
|
|
989
|
+
? ` "/rename ${newName}" was pasted before the check failed and was left unsubmitted; no Enter was sent. Do not send keys to that pane until you know whose it is.`
|
|
990
|
+
: "");
|
|
991
|
+
}
|
|
992
|
+
else if (pasted) {
|
|
963
993
|
heldNote =
|
|
964
994
|
`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.`;
|
|
965
995
|
try {
|
|
@@ -971,6 +1001,9 @@ export function registerAgents(server) {
|
|
|
971
1001
|
}
|
|
972
1002
|
}
|
|
973
1003
|
}
|
|
1004
|
+
if (!live) {
|
|
1005
|
+
heldNote = `Not retitled: its pane ownership read ${ownership}, so no pane was read or typed into. The row IS renamed - it is "${newName}" now.`;
|
|
1006
|
+
}
|
|
974
1007
|
return {
|
|
975
1008
|
agent_id: agent.id,
|
|
976
1009
|
actor_id: agent.actor_id,
|
|
@@ -1236,15 +1269,21 @@ export function registerAgents(server) {
|
|
|
1236
1269
|
outgoing = tag + args.text;
|
|
1237
1270
|
let pasted = false;
|
|
1238
1271
|
let buffered = false;
|
|
1272
|
+
const identity = paneIdentity(agent);
|
|
1239
1273
|
try {
|
|
1240
1274
|
await sendText(target, outgoing, submitting, () => {
|
|
1241
1275
|
pasted = true;
|
|
1242
1276
|
}, () => {
|
|
1243
1277
|
buffered = true;
|
|
1244
|
-
});
|
|
1278
|
+
}, () => requireStillOwned(agent.id, identity, "agent_send"));
|
|
1245
1279
|
}
|
|
1246
1280
|
catch (err) {
|
|
1247
1281
|
const shortenedClause = shortened === null ? "" : shortenedSendFailureClause(shortened.marker, shortened.message_id);
|
|
1282
|
+
if (err instanceof PaneOwnershipLost) {
|
|
1283
|
+
if (!pasted)
|
|
1284
|
+
throw err;
|
|
1285
|
+
throw new Error(`[agent_send:paste-landed-enter-withheld] agent_send pasted into ${agent.name}'s pane, then withheld the Enter: ${err.message} The text is on that screen unsubmitted. Do NOT resend it and do NOT press Enter there: the pane may no longer be this worker's. Read it with agent_status first.${shortenedClause}`, { cause: err });
|
|
1286
|
+
}
|
|
1248
1287
|
if (!pasted) {
|
|
1249
1288
|
if (buffered && err instanceof TmuxTimeoutError) {
|
|
1250
1289
|
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 });
|
|
@@ -1408,9 +1447,13 @@ export function registerAgents(server) {
|
|
|
1408
1447
|
}
|
|
1409
1448
|
return { agent_id: agent.id, name: agent.name, closed: true, park_released: true };
|
|
1410
1449
|
}
|
|
1411
|
-
const
|
|
1412
|
-
if (
|
|
1413
|
-
throw
|
|
1450
|
+
const ownership = agent.status === "running" ? observeOwnership(agent) : "gone";
|
|
1451
|
+
if (ownership === "unknown") {
|
|
1452
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}"): pane ownership reads unknown, so agent_close neither stops nor ` +
|
|
1453
|
+
"retires it. " +
|
|
1454
|
+
UNVERIFIED_OWNERSHIP_NOTE);
|
|
1455
|
+
}
|
|
1456
|
+
let live = ownership === "live";
|
|
1414
1457
|
if (agent.kind === LEAD_KIND && live) {
|
|
1415
1458
|
throw new Error(`Agent ${agent.id} ("${agent.name}") is this project's lead session, the one actor with no ` +
|
|
1416
1459
|
"supervisor above it, and its pane is still live. agent_close refuses to end a running " +
|
|
@@ -1424,17 +1467,20 @@ export function registerAgents(server) {
|
|
|
1424
1467
|
const commandRow = agent.kind === COMMAND_KIND ? runningCommandRow(project.id, agent.name) : undefined;
|
|
1425
1468
|
if (commandRow && live) {
|
|
1426
1469
|
const stopped = stopProcess(commandRow, STOP_REASONS.byHand);
|
|
1470
|
+
const after = getAgentRow(project.id, agent.id, "Call agent_list(include_closed: true).");
|
|
1427
1471
|
return {
|
|
1428
1472
|
agent_id: agent.id,
|
|
1429
1473
|
name: agent.name,
|
|
1430
|
-
closed:
|
|
1474
|
+
closed: after.status !== "running",
|
|
1431
1475
|
stop_leg: stopped.leg,
|
|
1432
1476
|
note: stopLine(stopped),
|
|
1433
1477
|
};
|
|
1434
1478
|
}
|
|
1479
|
+
if (live)
|
|
1480
|
+
live = stillOwnsPane(agent, "agent_close");
|
|
1435
1481
|
if (live)
|
|
1436
1482
|
killAgentPane(agent.tmux_target);
|
|
1437
|
-
if (!closeAgentRow(agent.id, agent
|
|
1483
|
+
if (!closeAgentRow(agent.id, paneIdentity(agent))) {
|
|
1438
1484
|
const after = getAgentRow(project.id, agent.id, "Call agent_list(include_closed: true).");
|
|
1439
1485
|
const report = classifyLostCas(after);
|
|
1440
1486
|
if (report.outcome === "retired") {
|
|
@@ -1470,6 +1516,15 @@ export function registerAgents(server) {
|
|
|
1470
1516
|
catch {
|
|
1471
1517
|
}
|
|
1472
1518
|
}
|
|
1473
|
-
return {
|
|
1519
|
+
return {
|
|
1520
|
+
agent_id: agent.id,
|
|
1521
|
+
name: agent.name,
|
|
1522
|
+
closed: true,
|
|
1523
|
+
...(agent.status === "running" && !live
|
|
1524
|
+
? {
|
|
1525
|
+
note: `Closed with no kill: its pane ownership read ${ownership === "live" ? "changed" : ownership}, so that pane was left alone.`,
|
|
1526
|
+
}
|
|
1527
|
+
: {}),
|
|
1528
|
+
};
|
|
1474
1529
|
}));
|
|
1475
1530
|
}
|
package/dist/tools/pads.js
CHANGED
|
@@ -3,6 +3,13 @@ import { db } from "../db.js";
|
|
|
3
3
|
import { currentActor, effectiveProjectId, resolveProject } from "../context.js";
|
|
4
4
|
import { matchesAnyTag, parseTags, run } from "../result.js";
|
|
5
5
|
import { idParam, limitParam, offsetParam, projectIdParam } from "./params.js";
|
|
6
|
+
function splitsSurrogatePair(content, index) {
|
|
7
|
+
if (index <= 0 || index >= content.length)
|
|
8
|
+
return false;
|
|
9
|
+
const before = content.charCodeAt(index - 1);
|
|
10
|
+
const after = content.charCodeAt(index);
|
|
11
|
+
return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;
|
|
12
|
+
}
|
|
6
13
|
function selectPad(projectId, padId, columns) {
|
|
7
14
|
const row = db
|
|
8
15
|
.prepare(`SELECT ${columns} FROM pads WHERE project_id = ? AND id = ?`)
|
|
@@ -110,7 +117,7 @@ export function registerPads(server) {
|
|
|
110
117
|
return { pad_id: padId, revision: 1 };
|
|
111
118
|
}));
|
|
112
119
|
server.registerTool("pad_read", {
|
|
113
|
-
description: "Read a pad's content, revision, and metadata by pad_id or name.",
|
|
120
|
+
description: "Read a pad's content, revision, and metadata by pad_id or name. Optional offset and limit use UTF-16 code units and are nonnegative safe integers. There is no default cap; a cut inside a surrogate pair moves back one code unit, or forward if moving back would return nothing, and the result reports the effective offset.",
|
|
114
121
|
annotations: {
|
|
115
122
|
readOnlyHint: false,
|
|
116
123
|
destructiveHint: false,
|
|
@@ -120,6 +127,8 @@ export function registerPads(server) {
|
|
|
120
127
|
inputSchema: {
|
|
121
128
|
pad_id: idParam.optional(),
|
|
122
129
|
name: z.string().optional(),
|
|
130
|
+
offset: z.number().int().nonnegative().optional().describe("Start index in UTF-16 code units; an index inside a surrogate pair moves back one code unit."),
|
|
131
|
+
limit: z.number().int().nonnegative().optional().describe("Requested UTF-16 code units to read; no default cap. An end cut inside a surrogate pair moves back one code unit, or forward if moving back would return nothing."),
|
|
123
132
|
project_id: projectIdParam,
|
|
124
133
|
},
|
|
125
134
|
}, (args) => run(() => {
|
|
@@ -139,7 +148,7 @@ export function registerPads(server) {
|
|
|
139
148
|
else {
|
|
140
149
|
throw new Error("Pass pad_id or name.");
|
|
141
150
|
}
|
|
142
|
-
|
|
151
|
+
const result = {
|
|
143
152
|
pad_id: pad.id,
|
|
144
153
|
name: pad.name,
|
|
145
154
|
revision: pad.revision,
|
|
@@ -148,6 +157,23 @@ export function registerPads(server) {
|
|
|
148
157
|
updated_at: pad.updated_at,
|
|
149
158
|
content: pad.content,
|
|
150
159
|
};
|
|
160
|
+
if (args.offset == null && args.limit == null)
|
|
161
|
+
return result;
|
|
162
|
+
const totalLength = pad.content.length;
|
|
163
|
+
const requestedOffset = args.offset ?? 0;
|
|
164
|
+
const offset = splitsSurrogatePair(pad.content, requestedOffset) ? requestedOffset - 1 : requestedOffset;
|
|
165
|
+
let end = args.limit == null ? totalLength : Math.min(totalLength, requestedOffset + args.limit);
|
|
166
|
+
if (splitsSurrogatePair(pad.content, end)) {
|
|
167
|
+
const backedUpEnd = end - 1;
|
|
168
|
+
end = args.limit != null && args.limit > 0 && backedUpEnd <= offset ? end + 1 : backedUpEnd;
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
...result,
|
|
172
|
+
content: pad.content.slice(offset, end),
|
|
173
|
+
total_length: totalLength,
|
|
174
|
+
offset,
|
|
175
|
+
next_offset: end < totalLength ? end : null,
|
|
176
|
+
};
|
|
151
177
|
}));
|
|
152
178
|
server.registerTool("pad_append", {
|
|
153
179
|
description: "Append content to the end of a pad. Optional expected_revision guards against concurrent writes.",
|
package/dist/tools/wakes.js
CHANGED
|
@@ -3,13 +3,13 @@ import { db } from "../db.js";
|
|
|
3
3
|
import { commitQueenStateWrite } from "../queenAudit.js";
|
|
4
4
|
import { currentActor, effectiveProjectId, getProject, isQueenLead } from "../context.js";
|
|
5
5
|
import { run } from "../result.js";
|
|
6
|
-
import { findAgent,
|
|
6
|
+
import { findAgent, observeOwnership, notOwnedError, summaryLiveness } from "./agents.js";
|
|
7
7
|
import { ACTIVE_TIMER_WHERE, LOG_RETENTION, OWNED_BY_WATCH, WATCH_SCOPE_PROJECT, seedGoneCursor, } from "../scheduler.js";
|
|
8
8
|
import { idParam, projectIdParam } from "./params.js";
|
|
9
9
|
import { cutToUnitBudget } from "../slug.js";
|
|
10
10
|
import { awaitingFirstPrompt } from "../firstPrompt.js";
|
|
11
11
|
import { deriveProvenance } from "../stateProvenance.js";
|
|
12
|
-
import { findUnsafeControlChar, liveTargets,
|
|
12
|
+
import { findUnsafeControlChar, foreignSocket, liveTargets, observationFailed, rowOwnership, TEXT_ALLOWED_CONTROL_CHARS, } from "../tmux.js";
|
|
13
13
|
import { readLeadTurnState } from "../leadState.js";
|
|
14
14
|
import { LEAD_TARGET_GONE, LEAD_TARGET_REISSUED, leadSubscription, paneVeto, runningLeadOf } from "../leadWatch.js";
|
|
15
15
|
import { isRunningLeadActor, LEAD_KIND } from "../spawn.js";
|
|
@@ -41,12 +41,11 @@ function resolveDelivery(projectId, deliverTo) {
|
|
|
41
41
|
// Before the liveness probe: the harness is a durable fact about the row, while liveness is a
|
|
42
42
|
// question about right now, so a dead bash worker should say what is actually wrong with it.
|
|
43
43
|
refuseUnclassifiableTarget(agent);
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
throw probeFailed(agent);
|
|
47
|
-
if (!live) {
|
|
44
|
+
const ownership = agent.status === "running" ? observeOwnership(agent) : "gone";
|
|
45
|
+
if (ownership === "gone")
|
|
48
46
|
throw new Error(`Agent "${agent.name}" has no live tmux window to deliver to.`);
|
|
49
|
-
|
|
47
|
+
if (ownership !== "live")
|
|
48
|
+
throw notOwnedError(agent, ownership);
|
|
50
49
|
return { actor: agent.actor_id, pane: agent.tmux_target };
|
|
51
50
|
}
|
|
52
51
|
const actor = currentActor();
|
|
@@ -58,8 +57,13 @@ function resolveDelivery(projectId, deliverTo) {
|
|
|
58
57
|
// against that same row's command - the never-firing timer this refusal exists to prevent.
|
|
59
58
|
if (own)
|
|
60
59
|
refuseUnclassifiableTarget(own);
|
|
61
|
-
if (own
|
|
62
|
-
|
|
60
|
+
if (own) {
|
|
61
|
+
const ownership = observeOwnership(own);
|
|
62
|
+
if (ownership === "live")
|
|
63
|
+
return { actor, pane: own.tmux_target };
|
|
64
|
+
// Unknown never falls through to the ambient pane: that would bind this row's actor to a pane it may not own.
|
|
65
|
+
if (ownership === "unknown")
|
|
66
|
+
throw notOwnedError(own, ownership);
|
|
63
67
|
}
|
|
64
68
|
const pane = process.env.TMUX_PANE;
|
|
65
69
|
if (pane)
|
|
@@ -216,20 +220,20 @@ function createLeadWatch(leadProjectId, args) {
|
|
|
216
220
|
throw new Error(`Project ${leadProjectId}'s lead runs ${JSON.stringify(commandHead(lead.command))}, whose screen hive cannot ` +
|
|
217
221
|
"classify, so a turn ending while a human is typing to it could not be told apart from one nobody is at.");
|
|
218
222
|
}
|
|
219
|
-
if (lead.
|
|
220
|
-
throw new Error(
|
|
221
|
-
"could not be told from this lead. Restart it with hive lead to record one.");
|
|
223
|
+
if (foreignSocket(lead.tmux_socket)) {
|
|
224
|
+
throw new Error(`Project ${leadProjectId}'s lead lives on a tmux socket this process cannot probe.`);
|
|
222
225
|
}
|
|
223
226
|
const snapshot = liveTargets();
|
|
224
|
-
if (snapshot
|
|
227
|
+
if (observationFailed(snapshot))
|
|
225
228
|
throw new Error("tmux did not answer, so the watched lead's pane cannot be verified. Try again.");
|
|
226
|
-
const
|
|
227
|
-
if (
|
|
228
|
-
throw new Error(`Project ${leadProjectId}'s lead
|
|
229
|
+
const ownership = rowOwnership(lead, snapshot);
|
|
230
|
+
if (ownership === "unknown") {
|
|
231
|
+
throw new Error(`Project ${leadProjectId}'s lead's pane ownership is unknown: it has no recorded pane pid (or a legacy ` +
|
|
232
|
+
"window target), so a reused pane id could not be told from this lead. Restart it with hive lead to record one.");
|
|
229
233
|
}
|
|
230
|
-
if (
|
|
234
|
+
if (ownership === "gone")
|
|
231
235
|
throw new Error(`${LEAD_TARGET_GONE}: project ${leadProjectId}'s lead pane is gone.`);
|
|
232
|
-
if (
|
|
236
|
+
if (ownership === "reissued") {
|
|
233
237
|
throw new Error(`${LEAD_TARGET_REISSUED}: project ${leadProjectId}'s lead pane id now belongs to another process.`);
|
|
234
238
|
}
|
|
235
239
|
const mode = args.mode ?? "any";
|
|
@@ -327,7 +331,7 @@ export function registerWakes(server) {
|
|
|
327
331
|
});
|
|
328
332
|
}));
|
|
329
333
|
server.registerTool("wake_when_idle", {
|
|
330
|
-
description: "Wake up when watched agents go idle (exact state from Claude Code hooks) or max_wait_seconds passes - except delivery HOLDS past that bound instead, for as long as the target pane is on a dialog or has unsubmitted human text in it, rather than pasting the wake body into either (.claude/rules/tmux-and-panes.md). Two shapes for workers, plus one for the queen, and you pass EXACTLY ONE of them. agents=[...] is a ONE-SHOT over a named list: mode=any fires on the first fresh idle transition, mode=all fires when every watched agent is idle (returns already_satisfied without scheduling anything if they all are now), and either way it stops watching once it fires. scope=\"project\" is a STANDING WATCH over the crew you spawn in this project, including workers spawned later: it never stops watching, and on each finish it delivers a roster naming who finished and who is still going, until max_wait_seconds runs out or you wake_cancel it. You may hold ONE standing watch per project: a second call is refused and names the one already running, since two would report every finish twice. Use the standing watch when you are running more than one worker - a one-shot leaves every other worker unwatched from the moment it fires. Use either instead of polling. agents=[...] refuses a lead: it watches worker state, which a lead does not write. lead_project_id is the QUEEN's alone: a one-shot that fires when another registered project's running lead ENDS A TURN (never 'finished its work'), stored in and delivered to the queen's own project, and ended with a named reason if that lead's pane dies, is reissued, or restarts.",
|
|
334
|
+
description: "Wake up when watched agents go idle (exact state from Claude Code hooks) or max_wait_seconds passes - except delivery HOLDS past that bound instead, for as long as the target pane is on a dialog or has unsubmitted human text in it, rather than pasting the wake body into either (.claude/rules/tmux-and-panes.md). Two shapes for workers, plus one for the queen, and you pass EXACTLY ONE of them. agents=[...] is a ONE-SHOT over a named list: mode=any fires on the first fresh idle transition, mode=all fires when every watched agent is idle (returns already_satisfied without scheduling anything if they all are now), and either way it stops watching once it fires. Arming a one-shot of the same mode for the same deliver target and an identical watched set cancels your own older pending one and names it in the receipt's superseded. scope=\"project\" is a STANDING WATCH over the crew you spawn in this project, including workers spawned later: it never stops watching, and on each finish it delivers a roster naming who finished and who is still going, until max_wait_seconds runs out or you wake_cancel it. You may hold ONE standing watch per project: a second call is refused and names the one already running, since two would report every finish twice. Use the standing watch when you are running more than one worker - a one-shot leaves every other worker unwatched from the moment it fires. Use either instead of polling. agents=[...] refuses a lead: it watches worker state, which a lead does not write. lead_project_id is the QUEEN's alone: a one-shot that fires when another registered project's running lead ENDS A TURN (never 'finished its work'), stored in and delivered to the queen's own project, and ended with a named reason if that lead's pane dies, is reissued, or restarts.",
|
|
331
335
|
annotations: {
|
|
332
336
|
readOnlyHint: false,
|
|
333
337
|
destructiveHint: false,
|
|
@@ -371,6 +375,7 @@ export function registerWakes(server) {
|
|
|
371
375
|
scope: z.literal("project").optional(),
|
|
372
376
|
standing: z.boolean().optional(),
|
|
373
377
|
watching_now: z.array(z.string()).optional(),
|
|
378
|
+
superseded: z.array(idParam).optional(),
|
|
374
379
|
mode: z.enum(["any", "all"]).optional(),
|
|
375
380
|
watching: z
|
|
376
381
|
.array(z.object({
|
|
@@ -437,7 +442,24 @@ export function registerWakes(server) {
|
|
|
437
442
|
}
|
|
438
443
|
}
|
|
439
444
|
const maxWait = args.max_wait_seconds ?? 900;
|
|
440
|
-
return commitQueenStateWrite("wake_when_idle", projectId, args, () => {
|
|
445
|
+
return commitQueenStateWrite("wake_when_idle", projectId, args, () => db.transaction(() => {
|
|
446
|
+
const watchedIds = watched.map((a) => a.id);
|
|
447
|
+
const sameSet = (json) => {
|
|
448
|
+
const ids = JSON.parse(json);
|
|
449
|
+
return ids.length === watchedIds.length && watchedIds.every((id) => ids.includes(id));
|
|
450
|
+
};
|
|
451
|
+
const candidates = db
|
|
452
|
+
.prepare(`SELECT id, watch FROM wakes
|
|
453
|
+
WHERE project_id = ? AND owner = ? AND kind = ?
|
|
454
|
+
AND watch_scope IS NULL AND deliver_actor = ? AND deliver_pane = ?
|
|
455
|
+
AND fired_at IS NULL AND cancelled_at IS NULL
|
|
456
|
+
AND id NOT IN (SELECT wake_id FROM lead_idle_subscriptions)`)
|
|
457
|
+
.all(projectId, currentActor(), `idle_${mode}`, delivery.actor, delivery.pane)
|
|
458
|
+
.filter((w) => sameSet(w.watch))
|
|
459
|
+
.map((w) => w.id);
|
|
460
|
+
const superseded = candidates.filter((id) => db
|
|
461
|
+
.prepare("UPDATE wakes SET cancelled_at = datetime('now') WHERE id = ? AND cancelled_at IS NULL AND fired_at IS NULL")
|
|
462
|
+
.run(id).changes === 1);
|
|
441
463
|
const info = db
|
|
442
464
|
.prepare(`INSERT INTO wakes (project_id, owner, body, kind, watch, deliver_actor, deliver_pane,
|
|
443
465
|
max_wait_at)
|
|
@@ -445,6 +467,7 @@ export function registerWakes(server) {
|
|
|
445
467
|
.run(projectId, currentActor(), args.body, `idle_${mode}`, JSON.stringify(watched.map((a) => a.id)), delivery.actor, delivery.pane, maxWait);
|
|
446
468
|
return {
|
|
447
469
|
wake_id: Number(info.lastInsertRowid),
|
|
470
|
+
...(superseded.length > 0 ? { superseded } : {}),
|
|
448
471
|
mode,
|
|
449
472
|
watching: watched.map((a) => {
|
|
450
473
|
const { state, ...provenance } = deriveProvenance(a, summaryLiveness(a, snapshot));
|
|
@@ -456,7 +479,7 @@ export function registerWakes(server) {
|
|
|
456
479
|
? "Fires on the next fresh idle transition; agents already idle now do not count."
|
|
457
480
|
: "Fires when all watched agents are idle.",
|
|
458
481
|
};
|
|
459
|
-
});
|
|
482
|
+
}).immediate());
|
|
460
483
|
}));
|
|
461
484
|
server.registerTool("wake_get", {
|
|
462
485
|
description: "Read one wake-up by id, in this project, with its UNTRUNCATED body. wake_list truncates " +
|
package/package.json
CHANGED
|
@@ -1,27 +1,18 @@
|
|
|
1
|
-
You are the lead of a hive crew. You hold the plan, the workers do the work.
|
|
1
|
+
You are the lead of a hive crew. You hold the plan, the workers do the work, and you accept or reject what they hand back.
|
|
2
2
|
|
|
3
|
-
-
|
|
4
|
-
|
|
5
|
-
-
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
- Do not poll workers. With more than one running, set
|
|
20
|
-
wake_when_idle(scope="project") once and go quiet: it keeps watching and
|
|
21
|
-
reports each worker as it finishes. Waiting is free; a status loop is not.
|
|
22
|
-
- Read real output before believing a worker. agent_output and the actual
|
|
23
|
-
diff, not the worker's summary of them.
|
|
24
|
-
- Anything outward-facing (pushes, published PRs, posted reviews, anything
|
|
25
|
-
that leaves the machine) waits for explicit human approval.
|
|
26
|
-
- When the human asks how work runs here, read the project runbook first:
|
|
27
|
-
`hive runbook`. It is the standing process for this project.
|
|
3
|
+
- Name the lane before acting: change, research, review or smoke test. Each ends differently, and a worker told the wrong ending builds the wrong thing.
|
|
4
|
+
- Ambiguity is a question, not a guess. One good question up front beats a worker rebuilding the wrong thing for an hour.
|
|
5
|
+
- Dispatch by assigning todo ids. Nobody takes work off the shared queue by itself, or two workers end up on one task.
|
|
6
|
+
- Give a worker a brief it can follow cold: goal, decisions already made, files it owns, what is out of scope, base, checks, and the observable result that means done. A vague brief makes the worker invent scope.
|
|
7
|
+
- Keep every lane isolated: its own worktree for file edits, a lease for any shared runtime such as a dev database or a port. Two lanes in one checkout overwrite each other.
|
|
8
|
+
- Plans go in pads, work goes in todos, decisions go in comments. Sessions die; the store survives.
|
|
9
|
+
- Carry a short label with any todo or pad id you show anyone: "todo 318 (give todos a slug)", not "todo 318". A cold-booting session gets no other context.
|
|
10
|
+
- Do not poll workers. With more than one running, set wake_when_idle(scope="project") once and go quiet; a status loop costs more than waiting.
|
|
11
|
+
- Read the real diff and run the change yourself before you accept it. A worker's summary is a claim, not evidence.
|
|
12
|
+
- Only you complete a todo, and only after you have accepted it. Code-done is not lane-done.
|
|
13
|
+
- Outward-facing actions (pushes, PRs, posted comments, merges, tracker updates) need authority the human has already given or gives now. Approval in one place does not carry to another.
|
|
14
|
+
- Run yourself on the strongest model tier you have, the model in your lead command, and spawn workers on a cheaper one with agent_spawn's `model` argument. Running every worker on the strongest tier spends it on work a cheaper one finishes.
|
|
15
|
+
<!--if:worker_model-->
|
|
16
|
+
- Default worker model: {{worker_model}}.
|
|
17
|
+
<!--end-->
|
|
18
|
+
- When the human asks how work runs here, run `hive runbook`. It is the standing process.
|