@cmgmyr/hive 1.5.0 → 1.7.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/dist/tmux.js CHANGED
@@ -413,6 +413,10 @@ export function targetPid(target, snapshot) {
413
413
  export function panePid(target) {
414
414
  return targetLiveProbe(target).pid ?? "";
415
415
  }
416
+ // A row recorded with an empty pid reads unknown for life, so a pane hive just created gets one retry.
417
+ export function panePidForRecord(target) {
418
+ return panePid(target) || panePid(target);
419
+ }
416
420
  const DESTROY_READINESS_BOUND_MS = 200;
417
421
  function paneHasEstablishedProcess(pid) {
418
422
  try {
@@ -470,6 +474,39 @@ export function rowAliveProbe(recordedSocket, target, snapshot) {
470
474
  export function paneReissued(recordedPid, probe) {
471
475
  return probe.live === true && recordedPid !== "" && probe.pid !== null && probe.pid !== recordedPid;
472
476
  }
477
+ // The one place a row's claim on a pane is decided. An existing pane is "live" only when both
478
+ // pids are known and equal; an empty recorded pid on a live id is "unknown", never "live".
479
+ export function observationFailed(snapshot) {
480
+ return snapshot === null || snapshot.serverAnswered === false;
481
+ }
482
+ export function rowOwnership(row, snapshot) {
483
+ if (foreignSocket(row.tmux_socket))
484
+ return "unknown";
485
+ if (row.tmux_target === "")
486
+ return "gone";
487
+ const observed = snapshot === undefined ? liveTargets() : snapshot;
488
+ if (observed === null || observationFailed(observed))
489
+ return "unknown";
490
+ if (!targetAlive(row.tmux_target, observed))
491
+ return "gone";
492
+ if (!isPaneTarget(row.tmux_target))
493
+ return "unknown";
494
+ const pid = targetPid(row.tmux_target, observed) ?? "";
495
+ if (row.pane_pid === "" || pid === "")
496
+ return "unknown";
497
+ return pid === row.pane_pid ? "live" : "reissued";
498
+ }
499
+ // The one place a row's ownership is observed: a failed probe is its own answer, never unknown.
500
+ export function observeRowOwnership(row) {
501
+ const observes = !foreignSocket(row.tmux_socket) && row.tmux_target !== "";
502
+ const snapshot = observes ? liveTargets() : null;
503
+ if (observes && observationFailed(snapshot))
504
+ return "probe-failed";
505
+ return rowOwnership(row, snapshot);
506
+ }
507
+ export function ownershipLiveness(ownership) {
508
+ return ownership === "live" ? true : ownership === "unknown" ? null : false;
509
+ }
473
510
  export const RAW_ATTACH_TMUX_CONFIG = [
474
511
  "set -g allow-passthrough all",
475
512
  ];
@@ -1334,7 +1371,7 @@ export function findUnsafeControlChar(text, allowed) {
1334
1371
  }
1335
1372
  return null;
1336
1373
  }
1337
- export async function sendText(target, text, submit = true, onPasted, onBuffered) {
1374
+ export async function sendText(target, text, submit = true, onPasted, onBuffered, beforePaneWrite) {
1338
1375
  // tmux hands a pane its input in 1022-byte writes, and only paste-buffer -p
1339
1376
  // brackets them, so send-keys -l loses everything before the last write.
1340
1377
  // An empty text skips the pair: set-buffer creates no buffer for it, so
@@ -1346,6 +1383,7 @@ export async function sendText(target, text, submit = true, onPasted, onBuffered
1346
1383
  tmux("set-buffer", "-b", buffer, "--", text);
1347
1384
  onBuffered?.();
1348
1385
  try {
1386
+ beforePaneWrite?.();
1349
1387
  tmux("paste-buffer", "-d", "-p", "-b", buffer, "-t", target);
1350
1388
  }
1351
1389
  catch (err) {
@@ -1366,6 +1404,7 @@ export async function sendText(target, text, submit = true, onPasted, onBuffered
1366
1404
  }
1367
1405
  if (submit) {
1368
1406
  await sleep(ENTER_DELAY_MS);
1407
+ beforePaneWrite?.();
1369
1408
  tmux("send-keys", "-t", target, "Enter");
1370
1409
  }
1371
1410
  }
@@ -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, holdsHumanInput, liveTargets, paneCurrentCommand, paneInCopyMode, paneWindow, pollPaneReadiness, rowAlive, rowLive, 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";
@@ -129,23 +129,84 @@ function buildParkReceipt(project, agent, parkedAt, branch, note) {
129
129
  ...(note ? { note } : {}),
130
130
  };
131
131
  }
132
- export function isLive(agent) {
133
- if (agent.status !== "running")
134
- return false;
135
- return rowLive(agent.tmux_socket, agent.tmux_target);
132
+ function closeRowOnly(agent, confirmSelf) {
133
+ const receipt = { row_only: true, agent_id: agent.id, name: agent.name, closed: true };
134
+ if (currentActor().startsWith("agent:")) {
135
+ throw new Error(`Agent ${agent.id} ("${agent.name}"): row_only retirement is reserved for a human at a terminal or a peer ` +
136
+ "lead; a worker may not retire rows this way. Nothing was changed.");
137
+ }
138
+ if (agent.actor_id === currentActor() && !confirmSelf) {
139
+ throw new Error("This would close your own session. Pass confirm_self=true only if the user explicitly asked you to close yourself.");
140
+ }
141
+ if (agent.status !== "running") {
142
+ return {
143
+ ...receipt,
144
+ ...(agent.parked_at ? { parked: true } : {}),
145
+ note: "Already closed; row_only changed nothing and left any park in place.",
146
+ };
147
+ }
148
+ const ownership = observeOwnership(agent);
149
+ if (ownership === "live") {
150
+ throw new Error(`Agent ${agent.id} ("${agent.name}") owns a live pane (its recorded pane pid matches), so row_only refuses: ` +
151
+ "retiring the row would orphan a running process with nothing left to stop it by. " +
152
+ (agent.kind === LEAD_KIND
153
+ ? "Restart or end the lead from its own terminal."
154
+ : "Use agent_close without row_only to stop it."));
155
+ }
156
+ if (!closeAgentRow(agent.id, paneIdentity(agent))) {
157
+ const after = getAgentRow(agent.project_id, agent.id, "Call agent_list(include_closed: true).");
158
+ if (after.status !== "running") {
159
+ return {
160
+ ...receipt,
161
+ ...(after.parked_at ? { parked: true } : {}),
162
+ note: "Already closed by someone else before this call's row-only retirement landed. Nothing else was changed.",
163
+ };
164
+ }
165
+ throw new Error(`Agent ${agent.id} ("${agent.name}")'s pane identity changed since this call read it, most likely a fresh ` +
166
+ "pane recorded on it. Nothing was closed. Re-read it with agent_status and decide again.");
167
+ }
168
+ return {
169
+ ...receipt,
170
+ note: `Row retired with no kill, stop or typing; its pane ownership read ${ownership}.` +
171
+ (ownership === "unknown" ? " Anything still running in that pane was left alone." : ""),
172
+ };
136
173
  }
137
174
  export const PROBE_FAILED_NOTE = "tmux could not be probed, so liveness is unknown. Nothing was changed. Retry in a few seconds.";
175
+ const unknownOwnershipNote = (snapshot) => observationFailed(snapshot) ? PROBE_FAILED_NOTE : UNVERIFIED_OWNERSHIP_NOTE;
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).";
138
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
+ }
139
203
  function requireLive(agent) {
140
204
  if (agent.status !== "running") {
141
205
  throw new Error(`Agent ${agent.id} ("${agent.name}") is closed.`);
142
206
  }
143
- const live = isLive(agent);
144
- if (live === null)
145
- throw probeFailed(agent);
146
- if (!live) {
147
- 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.`);
148
- }
207
+ const ownership = observeOwnership(agent);
208
+ if (ownership !== "live")
209
+ throw notOwnedError(agent, ownership);
149
210
  }
150
211
  function runningAgentNamed(projectId, name, exceptAgentId) {
151
212
  const needle = name.toLowerCase();
@@ -250,11 +311,7 @@ const PANE_READY_MS = Number(process.env.HIVE_SPAWN_READY_MS ?? 45_000);
250
311
  export function summaryLiveness(row, snapshot) {
251
312
  if (row.status !== "running")
252
313
  return false;
253
- if (snapshot === undefined)
254
- return rowLive(row.tmux_socket, row.tmux_target);
255
- if (snapshot === null)
256
- return null;
257
- return rowAlive(row.tmux_socket, row.tmux_target, snapshot);
314
+ return ownershipLiveness(rowOwnership(row, snapshot));
258
315
  }
259
316
  function capturePaneQuietly(target) {
260
317
  try {
@@ -810,16 +867,19 @@ export function registerAgents(server) {
810
867
  "would record a lane that cannot be resumed, and its branch can no longer be read. Recreate that " +
811
868
  "path first if you want this lane back tomorrow, or close it with agent_close.");
812
869
  }
813
- const live = isLive(agent);
814
- if (live === null)
815
- throw probeFailed(agent);
870
+ const ownership = observeOwnership(agent);
871
+ if (ownership === "unknown")
872
+ throw notOwnedError(agent, ownership);
873
+ let live = ownership === "live";
816
874
  if (agent.actor_id === currentActor() && args.confirm_self !== true) {
817
875
  throw new Error("This would park your own session. Pass confirm_self=true only if the user explicitly asked you to park yourself.");
818
876
  }
819
877
  const branch = branchAt(agent.cwd);
878
+ if (live)
879
+ live = stillOwnsPane(agent, "agent_park");
820
880
  if (live)
821
881
  killAgentPane(agent.tmux_target);
822
- const parkedAt = parkAgentRow(agent.id, agent.tmux_target, branch);
882
+ const parkedAt = parkAgentRow(agent.id, paneIdentity(agent), branch);
823
883
  if (parkedAt === undefined) {
824
884
  const after = getAgentRow(project.id, agent.id, "Call agent_list(include_closed: true).");
825
885
  const report = classifyLostCas(after);
@@ -836,7 +896,10 @@ export function registerAgents(server) {
836
896
  }
837
897
  throw revivedError(agent, live, "a concurrent agent_resume reviving it", "Nothing was parked. Re-read it with agent_status and try again.");
838
898
  }
839
- 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.");
840
903
  }));
841
904
  server.registerTool("agent_rename", {
842
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.",
@@ -875,7 +938,11 @@ export function registerAgents(server) {
875
938
  }
876
939
  const newName = normalizeAgentName(args.new_name, "new_name");
877
940
  requireNameFree(project.id, newName, agent.id);
878
- 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);
879
946
  renameAgent(agent, newName, live ? project.name : null);
880
947
  let retitled = false;
881
948
  let heldNote;
@@ -911,11 +978,18 @@ export function registerAgents(server) {
911
978
  try {
912
979
  await sendText(agent.tmux_target, `/rename ${newName}`, true, () => {
913
980
  pasted = true;
914
- });
981
+ }, undefined, () => requireStillOwned(agent.id, identity, "agent_rename"));
915
982
  retitled = true;
916
983
  }
917
- catch {
918
- 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) {
919
993
  heldNote =
920
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.`;
921
995
  try {
@@ -927,6 +1001,9 @@ export function registerAgents(server) {
927
1001
  }
928
1002
  }
929
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
+ }
930
1007
  return {
931
1008
  agent_id: agent.id,
932
1009
  actor_id: agent.actor_id,
@@ -1031,11 +1108,12 @@ export function registerAgents(server) {
1031
1108
  }, (args) => run(() => {
1032
1109
  const project = resolveProject(args.project_id);
1033
1110
  const agent = findAgent(project.id, args);
1034
- const summary = agentSummary(agent);
1111
+ const snapshot = agent.status === "running" ? liveTargets() : null;
1112
+ const summary = agentSummary(agent, snapshot);
1035
1113
  const briefPath = agentBriefPath(agent.id);
1036
1114
  return {
1037
1115
  ...summary,
1038
- ...(summary.alive === null ? { note: PROBE_FAILED_NOTE } : {}),
1116
+ ...(summary.alive === null ? { note: unknownOwnershipNote(snapshot) } : {}),
1039
1117
  closed_at: agent.closed_at,
1040
1118
  current_command: summary.alive ? paneCurrentCommand(agent.tmux_target) : null,
1041
1119
  brief_path: existsSync(briefPath) ? briefPath : null,
@@ -1191,15 +1269,21 @@ export function registerAgents(server) {
1191
1269
  outgoing = tag + args.text;
1192
1270
  let pasted = false;
1193
1271
  let buffered = false;
1272
+ const identity = paneIdentity(agent);
1194
1273
  try {
1195
1274
  await sendText(target, outgoing, submitting, () => {
1196
1275
  pasted = true;
1197
1276
  }, () => {
1198
1277
  buffered = true;
1199
- });
1278
+ }, () => requireStillOwned(agent.id, identity, "agent_send"));
1200
1279
  }
1201
1280
  catch (err) {
1202
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
+ }
1203
1287
  if (!pasted) {
1204
1288
  if (buffered && err instanceof TmuxTimeoutError) {
1205
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 });
@@ -1285,7 +1369,8 @@ export function registerAgents(server) {
1285
1369
  const project = resolveProject(args.project_id);
1286
1370
  const agent = findAgent(project.id, args);
1287
1371
  const lines = Math.min(args.lines ?? 50, 200);
1288
- const alive = isLive(agent);
1372
+ const snapshot = agent.status === "running" ? liveTargets() : null;
1373
+ const alive = summaryLiveness(agent, snapshot);
1289
1374
  return {
1290
1375
  agent_id: agent.id,
1291
1376
  name: agent.name,
@@ -1296,14 +1381,14 @@ export function registerAgents(server) {
1296
1381
  ? {}
1297
1382
  : {
1298
1383
  note: alive === null
1299
- ? PROBE_FAILED_NOTE
1300
- : "No live tmux window; output is not retained after exit.",
1384
+ ? unknownOwnershipNote(snapshot)
1385
+ : "No live tmux window this row owns; output is not retained after exit.",
1301
1386
  }),
1302
1387
  ...(alive !== true && agent.exit_tail ? { exit_tail: agent.exit_tail } : {}),
1303
1388
  };
1304
1389
  }));
1305
1390
  server.registerTool("agent_close", {
1306
- 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.",
1391
+ 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. row_only=true retires a stale row with no kill, stop or typing: for a human or peer lead only, and refused for any row that provably owns a live pane or when tmux could not be probed.",
1307
1392
  annotations: {
1308
1393
  readOnlyHint: false,
1309
1394
  destructiveHint: true,
@@ -1314,12 +1399,17 @@ export function registerAgents(server) {
1314
1399
  name: agentNameParam,
1315
1400
  agent_id: agentIdParam,
1316
1401
  confirm_self: z.boolean().optional(),
1402
+ row_only: z
1403
+ .boolean()
1404
+ .optional()
1405
+ .describe("Mark the row closed with no kill, stop or typing, for a row whose pane is gone, reissued or unverifiable. Human or peer lead only. Defaults to false."),
1317
1406
  project_id: projectIdParam,
1318
1407
  },
1319
1408
  outputSchema: {
1320
1409
  agent_id: idParam,
1321
1410
  name: z.string(),
1322
1411
  closed: z.boolean(),
1412
+ row_only: z.boolean().optional(),
1323
1413
  park_released: z.boolean().optional(),
1324
1414
  parked: z.boolean().optional(),
1325
1415
  note: z.string().optional(),
@@ -1327,6 +1417,8 @@ export function registerAgents(server) {
1327
1417
  }, (args) => run(() => {
1328
1418
  const project = resolveProject(args.project_id);
1329
1419
  const agent = findAgent(project.id, args);
1420
+ if (args.row_only === true)
1421
+ return closeRowOnly(agent, args.confirm_self === true);
1330
1422
  if (agent.kind === LEAD_KIND && currentActor().startsWith("agent:")) {
1331
1423
  throw new Error(`Agent ${agent.id} ("${agent.name}") is this project's lead session. Retiring a lead - live, ` +
1332
1424
  "confirmed dead, or unprobed - is reserved for a human at a terminal or a peer lead; a " +
@@ -1355,9 +1447,13 @@ export function registerAgents(server) {
1355
1447
  }
1356
1448
  return { agent_id: agent.id, name: agent.name, closed: true, park_released: true };
1357
1449
  }
1358
- const live = isLive(agent);
1359
- if (live === null)
1360
- 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";
1361
1457
  if (agent.kind === LEAD_KIND && live) {
1362
1458
  throw new Error(`Agent ${agent.id} ("${agent.name}") is this project's lead session, the one actor with no ` +
1363
1459
  "supervisor above it, and its pane is still live. agent_close refuses to end a running " +
@@ -1371,17 +1467,20 @@ export function registerAgents(server) {
1371
1467
  const commandRow = agent.kind === COMMAND_KIND ? runningCommandRow(project.id, agent.name) : undefined;
1372
1468
  if (commandRow && live) {
1373
1469
  const stopped = stopProcess(commandRow, STOP_REASONS.byHand);
1470
+ const after = getAgentRow(project.id, agent.id, "Call agent_list(include_closed: true).");
1374
1471
  return {
1375
1472
  agent_id: agent.id,
1376
1473
  name: agent.name,
1377
- closed: stopped.leg !== "still-running",
1474
+ closed: after.status !== "running",
1378
1475
  stop_leg: stopped.leg,
1379
1476
  note: stopLine(stopped),
1380
1477
  };
1381
1478
  }
1479
+ if (live)
1480
+ live = stillOwnsPane(agent, "agent_close");
1382
1481
  if (live)
1383
1482
  killAgentPane(agent.tmux_target);
1384
- if (!closeAgentRow(agent.id, agent.tmux_target)) {
1483
+ if (!closeAgentRow(agent.id, paneIdentity(agent))) {
1385
1484
  const after = getAgentRow(project.id, agent.id, "Call agent_list(include_closed: true).");
1386
1485
  const report = classifyLostCas(after);
1387
1486
  if (report.outcome === "retired") {
@@ -1417,6 +1516,15 @@ export function registerAgents(server) {
1417
1516
  catch {
1418
1517
  }
1419
1518
  }
1420
- 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
+ };
1421
1529
  }));
1422
1530
  }
@@ -139,7 +139,7 @@ export function registerMeta(server) {
139
139
  inputSchema: { project_id: idParam },
140
140
  }, ({ project_id }) => run(() => selectProjectById(project_id)));
141
141
  server.registerTool("project_prune", {
142
- description: "Delete every registered project that owns no rows anywhere in the store (pads, todos, kv, leases, agents, wakes, command_trust), verified individually before each delete. Never prunes the caller's own project. With project_id, removes exactly that one project instead of sweeping, and only if it owns no rows; otherwise refuses and names what it owns. With project_id AND confirm_name (the project's exact name), removes that one project even when it owns rows: it takes a snapshot first, refuses while the project has a running agent, then deletes the project with everything it owns (todos, comments, pads, kv, leases, agents, wakes, trust, agent messages) in one transaction and reports per-table counts and the snapshot path. queen_audit rows naming it are kept, and so are other projects' lead-idle subscriptions that target it, so a watch on its lead ends with a named reason. confirm_name without project_id is refused, and there is no forced sweep. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep, and a project-locked session may only touch its own project. Immediate, permanent: no dry-run mode.",
142
+ description: "Delete every registered project that owns no rows anywhere in the store (pads, todos, kv, leases, agents, wakes, command_trust), verified individually before each delete. Never prunes the caller's own project. With project_id, removes exactly that one project instead of sweeping, and only if it owns no rows; otherwise refuses and names what it owns. With project_id AND confirm_name (the project's exact name), removes that one project even when it owns rows: it takes a snapshot first, refuses while a running row owns a live pane or its pane ownership cannot be verified (agent_close row_only retires the latter), then deletes the project with everything it owns (todos, comments, pads, kv, leases, agents, wakes, trust, agent messages) in one transaction and reports per-table counts and the snapshot path. queen_audit rows naming it are kept, and so are other projects' lead-idle subscriptions that target it, so a watch on its lead ends with a named reason. confirm_name without project_id is refused, and there is no forced sweep. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep, and a project-locked session may only touch its own project. Immediate, permanent: no dry-run mode.",
143
143
  annotations: {
144
144
  readOnlyHint: false,
145
145
  destructiveHint: true,
@@ -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 " +