@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.
@@ -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, foreignSocket, holdsHumanInput, liveTargets, observationFailed, ownershipLiveness, paneCurrentCommand, paneInCopyMode, paneWindow, pollPaneReadiness, rowLive, rowOwnership, sendText, sessionName, setRemainOnExit, sleep, TEXT_ALLOWED_CONTROL_CHARS, tmux, TmuxTimeoutError, WINDOW_LAYOUTS, } from "../tmux.js";
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 observes = !foreignSocket(agent.tmux_socket) && agent.tmux_target !== "";
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, { tmux_target: agent.tmux_target, tmux_socket: agent.tmux_socket, pane_pid: agent.pane_pid })) {
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 live = isLive(agent);
192
- if (live === null)
193
- throw probeFailed(agent);
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 live = isLive(agent);
858
- if (live === null)
859
- throw probeFailed(agent);
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.tmux_target, branch);
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 live = isLive(agent) === true;
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 (pasted) {
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 live = isLive(agent);
1412
- if (live === null)
1413
- throw probeFailed(agent);
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: stopped.leg !== "still-running",
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.tmux_target)) {
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 { agent_id: agent.id, name: agent.name, closed: true };
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
  }
@@ -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
- return {
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.",
@@ -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, isLive, probeFailed, summaryLiveness } from "./agents.js";
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, paneReissued, rowAliveProbe, TEXT_ALLOWED_CONTROL_CHARS, } from "../tmux.js";
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 live = isLive(agent);
45
- if (live === null)
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 && isLive(own) === true) {
62
- return { actor, pane: own.tmux_target };
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.pane_pid === "") {
220
- throw new Error(`${LEAD_TARGET_REISSUED}: project ${leadProjectId}'s lead has no recorded pane pid, so a reused pane id ` +
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 === null)
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 probe = rowAliveProbe(lead.tmux_socket, lead.tmux_target, snapshot);
227
- if (probe.live === null) {
228
- throw new Error(`Project ${leadProjectId}'s lead lives on a tmux socket this process cannot probe.`);
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 (!probe.live)
234
+ if (ownership === "gone")
231
235
  throw new Error(`${LEAD_TARGET_GONE}: project ${leadProjectId}'s lead pane is gone.`);
232
- if (paneReissued(lead.pane_pid, probe)) {
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmgmyr/hive",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Run a crew of Claude Code and Codex workers from one lead session: visible tmux panes, exact state from hooks, one local SQLite store.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- - Lead, do not IC. Before writing code yourself, ask whether this belongs to a
4
- worker. Small edits and integration are yours; a lane of real work is not.
5
- - Name the lane before acting. Ticket work, ad-hoc change, review, research,
6
- and smoke test have different rules. Say which one you are in, out loud.
7
- - Ambiguity is a question, not a guess. One good question up front beats a
8
- worker rebuilding the wrong thing for an hour.
9
- - Plans go in pads, work goes in todos, decisions go in comments. Sessions
10
- die; the store survives. Anything you would have to re-explain tomorrow
11
- belongs in the store today.
12
- - Carry a short label with any todo or pad id you show anyone: the todo's
13
- slug, the pad's name - "todo 318 (give todos a slug)", not "todo 318".
14
- This holds in wake bodies and board entries too, not only in chat, since a
15
- cold-booting session gets no other context to fill the gap. Give a todo
16
- one when you create it: todo_create takes `slug` directly.
17
- - Give a worker a self-contained brief: the objective, its pad and todo ids,
18
- the files it owns, and how it will know it is done.
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.