@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/claude-plugin/skills/cleanup/SKILL.md +9 -1
- package/claude-plugin/skills/profile/SKILL.md +30 -3
- package/dist/build-info.json +1 -1
- package/dist/cli.js +123 -40
- package/dist/contextCheckpoint.js +2 -2
- package/dist/help.js +16 -4
- package/dist/hook.js +141 -11
- package/dist/leadWatch.js +9 -7
- package/dist/portfolio.js +4 -5
- package/dist/processes.js +37 -15
- package/dist/projectRemove.js +46 -10
- package/dist/scheduler.js +179 -62
- package/dist/spawn.js +70 -15
- package/dist/tmux.js +40 -1
- package/dist/tools/agents.js +147 -39
- package/dist/tools/meta.js +1 -1
- package/dist/tools/pads.js +28 -2
- package/dist/tools/wakes.js +44 -21
- package/package.json +1 -1
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
|
}
|
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, holdsHumanInput, liveTargets, paneCurrentCommand, paneInCopyMode, paneWindow, pollPaneReadiness,
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
144
|
-
if (
|
|
145
|
-
throw
|
|
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
|
-
|
|
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
|
|
814
|
-
if (
|
|
815
|
-
throw
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
?
|
|
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
|
|
1359
|
-
if (
|
|
1360
|
-
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";
|
|
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:
|
|
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
|
|
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 {
|
|
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
|
}
|
package/dist/tools/meta.js
CHANGED
|
@@ -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
|
|
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,
|
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 " +
|