@cmgmyr/hive 1.4.0 → 1.6.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/profile/SKILL.md +22 -2
- package/dist/build-info.json +1 -1
- package/dist/cli.js +141 -25
- package/dist/context.js +5 -3
- package/dist/contextCheckpoint.js +2 -2
- package/dist/help.js +10 -3
- package/dist/hook.js +141 -11
- package/dist/portfolio.js +4 -5
- package/dist/projectRemove.js +104 -0
- package/dist/queenAudit.js +14 -0
- package/dist/spawn.js +10 -5
- package/dist/tmux.js +42 -6
- package/dist/tools/agents.js +65 -12
- package/dist/tools/meta.js +40 -28
- package/package.json +1 -1
|
@@ -9,7 +9,27 @@ You are interviewing someone to produce, or change, a hive profile: the standing
|
|
|
9
9
|
|
|
10
10
|
## Read before you ask anything
|
|
11
11
|
|
|
12
|
-
Run `hive profile list` and `hive doctor` (or read this project's `hive.yml` directly) before the first question. If this is an edit, also read the named profile's current, rendered behavior
|
|
12
|
+
Run `hive profile list` and `hive doctor` (or read this project's `hive.yml` directly) before the first question. If this is an edit, also read the named profile's current, rendered behavior for each of `posture.md`, `runbook.md`, `worker.md`, and any other `.md` it carries, using "Read a rendered file in bounded sections" below, so you understand what this profile actually does today. Do not ask the user to repeat anything already answered by what you just read. This rendered read is for understanding only - `references/interview.md` covers the raw template (via `hive profile path`) you must read from, and write onto, when you actually apply an edit; a rendered copy drops any conditional section whose var is unset, so it is never a safe edit source.
|
|
13
|
+
|
|
14
|
+
## Read a rendered file in bounded sections
|
|
15
|
+
|
|
16
|
+
A harness may persist tool output over about 30KB to a file and show only a short preview, so a large rendered file read straight into a tool result is read mostly unseen. Redirect first, then look. `profile_name` is a name from `hive profile list`, `profile_file` is a filename it lists, and `profile_rendered` is a disposable file:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
profile_rendered=$(mktemp)
|
|
20
|
+
hive profile read "$profile_file" --profile "$profile_name" > "$profile_rendered" && wc -c < "$profile_rendered"
|
|
21
|
+
printf '%s\n' "$profile_rendered"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Shell variables do not survive between tool calls. Note the path the last line printed and write that literal path into every later command and every file-reader call; do not rely on `$profile_rendered` after this call.
|
|
25
|
+
|
|
26
|
+
Use the saved content only if the read command succeeded. `wc -c` is the rendered size in bytes, the same number `hive profile list` and `hive doctor` show. If it is 25600 or less, read the whole file with your file reader. Above that, never read the whole file in one tool call; read it in sections of at most 16000 bytes each:
|
|
27
|
+
|
|
28
|
+
1. Check for long lines first: `awk 'length > 2000 {print NR; exit}' PATH`. A file reader truncates any line over 2000 characters, so if this prints a line number, read the whole file by bytes with step 3 instead of with line ranges.
|
|
29
|
+
2. Otherwise pick a line range with your file reader's offset and limit, and measure it first: `sed -n '1,80p' PATH | wc -c`. Shrink the range until the count is 16000 or less, then read it. Continue from the next line until you have read the last line. A scan of the headers is not a read.
|
|
30
|
+
3. To read by bytes: `dd if=PATH bs=1 skip=0 count=16000 2>/dev/null`. Advance `skip` by 15996 each time, which overlaps 4 bytes so text split at a section edge shows whole in the next section. Stop when a section reaches the byte count from `wc -c`.
|
|
31
|
+
|
|
32
|
+
Delete the temporary file when you are done. It is for understanding and checking only, never a source for an edit (see below).
|
|
13
33
|
|
|
14
34
|
## Discovery first, not a checklist
|
|
15
35
|
|
|
@@ -70,7 +90,7 @@ A command, a repository name, a ticket prefix: these belong in `hive.yml` `vars`
|
|
|
70
90
|
|
|
71
91
|
## Validate before you tell them it's done
|
|
72
92
|
|
|
73
|
-
After writing, run `hive doctor` and
|
|
93
|
+
After writing, run `hive doctor` and read each file you touched with "Read a rendered file in bounded sections" above, from the project the profile is meant to serve. Read it to the last line: a change near the end of a large file is easy to miss. A profile with no readable `runbook.md` fails `hive doctor` outright; `posture.md` and `worker.md` do not, but check them anyway. Tell the user to restart any running hive session so it picks up the change, and invite one small follow-up edit once they've tried it - that is the expected way a profile keeps improving.
|
|
74
94
|
|
|
75
95
|
## What this skill does not do
|
|
76
96
|
|
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.
|
|
1
|
+
{"version":"1.6.0","sha":"ac13530","dirty":false,"build_id":"428a468f-98af-497e-a40c-fb87feef27ac"}
|
package/dist/cli.js
CHANGED
|
@@ -19,6 +19,7 @@ import { checkoutUpgradeSteps, detectInstallShape, globalUpgradeSteps } from "./
|
|
|
19
19
|
import { isLowHeadroom, orphanLoginShellDetails, ptyHeadroom } from "./ptys.js";
|
|
20
20
|
import { addProject, agentProjectPin, assertQueenCrossProjectWrite, captureQueenWriteIdentity, currentActor, effectiveProjectId, findProjectForCwd, findProjectForDir, getProjectByPath, gitPrimaryRoot, getProject, listProjects, QUEEN_PROFILE, queenHomeDir, registeredAncestor, takeRegistrationNotice, } from "./context.js";
|
|
21
21
|
import { confirmQueenWrite, listQueenAudit } from "./queenAudit.js";
|
|
22
|
+
import { formatRowCounts, projectRowCounts, removeProject } from "./projectRemove.js";
|
|
22
23
|
import { ensureHooksFile } from "./hooks.js";
|
|
23
24
|
import { errorMessage, registrationNoticeText, withTrailingNewline } from "./result.js";
|
|
24
25
|
import { ACTIVE_TIMER_WHERE, dashboardFileContained, describeStall, HELD_REASON_CONVERSATION, HELD_REASON_LEAD_PANE_DEAD, HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX, isUnclassifiablePaneHold, HELD_REASON_UNSUBMITTED_INPUT_PREFIX, isUnsubmittedInputHold, janitor, resolveDashboardFile, generateQueenDashboardNow, transcriptStaleness, wasHeldForPaneReissue, } from "./scheduler.js";
|
|
@@ -29,7 +30,7 @@ import { probeSessionInterpreter, reexecTarget, sessionStartVerdict, } from "./s
|
|
|
29
30
|
import { backupHealth, backupNow, backupsDir, formatBytes, listSnapshots, previewRestore, restoreSnapshot, totalSizeBytes, } from "./backup.js";
|
|
30
31
|
import { describeTeardownWindow, readTeardowns, teardownLogPath, } from "./teardown.js";
|
|
31
32
|
import { asNameClash, buildEnvFlags, closeAgentRow, isReservedAgentName, launchAgent, LEAD_KIND, LEAD_NAME, mintLeadActorId, upsertActor, withWindowClaim, } from "./spawn.js";
|
|
32
|
-
import { adoptableWindow, applyLayout, claimInitialWindow, configureHiveWindow, controlModeFor, createWindow, DEFAULT_LAYOUT, describePaneChoice, ensureSession, findProjectWindow, foreignSocket, isPaneTarget, isViewSessionName, listOwnedWindows, ORPHAN_MIN_AGE_MS, orphanScratchServers, orphansWorthWarningAbout, paneIsAloneInWindow, paneVisibility, panePid, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, projectWindows, RAW_ATTACH_TMUX_CONFIG, renderAttachCommand, resolveAttachTarget, resolveInTmuxTarget, paneReissued, rowLive, rowLiveProbe, SESSION_PREFIX, appendGlobalHook, globalHooks, replaceGlobalHooks, sessionName, setPaneTitle, shellQuote, shownPaneTitle, makeProcessesWindow, tmux, TMUX_DOC, tmuxSaysNothingThere, tmuxTimeoutOverride, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, } from "./tmux.js";
|
|
33
|
+
import { adoptableWindow, applyLayout, claimInitialWindow, configureHiveWindow, controlModeFor, createWindow, DEFAULT_LAYOUT, describePaneChoice, ensureSession, findProjectWindow, foreignSocket, isPaneTarget, isViewSessionName, listOwnedWindows, liveTargets, observationFailed, ORPHAN_MIN_AGE_MS, orphanScratchServers, orphansWorthWarningAbout, paneIsAloneInWindow, paneVisibility, panePid, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, projectWindows, RAW_ATTACH_TMUX_CONFIG, renderAttachCommand, resolveAttachTarget, resolveInTmuxTarget, paneReissued, rowLive, rowLiveProbe, rowOwnership, SESSION_PREFIX, appendGlobalHook, globalHooks, replaceGlobalHooks, sessionName, setPaneTitle, shellQuote, shownPaneTitle, makeProcessesWindow, tmux, TMUX_DOC, tmuxSaysNothingThere, tmuxTimeoutOverride, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, } from "./tmux.js";
|
|
33
34
|
import { describeVisibility, processCounts } from "./dashboard.js";
|
|
34
35
|
import { runningCommandRow, runningCommandRows, snapshotProcesses, stopAllProcesses, stopLine, stopProcess, STOP_REASONS, } from "./processes.js";
|
|
35
36
|
import { activeProfile, agentVarKeys, BUILT_IN_PROJECT_YML, configHash, loadProjectYml, mergedProjectVars, NO_PROFILE, PROJECT_YML_KEYS, resolveCommandDir, resolveFirstMessage, } from "./projectYml.js";
|
|
@@ -95,6 +96,9 @@ Usage:
|
|
|
95
96
|
hive todo <id> print one todo in full, comments included
|
|
96
97
|
hive backups list automatic store snapshots
|
|
97
98
|
hive restore <name> [--yes] [--force] overwrite the live store from a snapshot
|
|
99
|
+
hive project rm <id|path> [--yes]
|
|
100
|
+
remove another project and everything it owns,
|
|
101
|
+
after a snapshot; never the one you are in
|
|
98
102
|
hive runbook this project's standing process, vars resolved
|
|
99
103
|
hive posture the posture text this project's lead starts with
|
|
100
104
|
hive kickoff [--explain] SessionStart hook output; silent unless this is a lead checkout
|
|
@@ -1107,6 +1111,16 @@ function profileDriftText(f) {
|
|
|
1107
1111
|
text: `hive's default ${f.file} changed since you forked it${pct != null ? ` (${pct}% diverged)` : ""}`,
|
|
1108
1112
|
};
|
|
1109
1113
|
}
|
|
1114
|
+
const PROFILE_SIZE_WARNING_BYTES = 25600;
|
|
1115
|
+
function profileRenderedBytes(name, file, vars) {
|
|
1116
|
+
const rendered = renderProfileFile(name, file, vars);
|
|
1117
|
+
return rendered == null ? null : Buffer.byteLength(withTrailingNewline(rendered), "utf8");
|
|
1118
|
+
}
|
|
1119
|
+
function profileSizeAdvisory(name, file) {
|
|
1120
|
+
return (`${name}/${file}: rendered size exceeds ${PROFILE_SIZE_WARNING_BYTES} bytes. A harness may persist tool output ` +
|
|
1121
|
+
"over about 30KB and show only a short preview. Split into smaller profile files read with hive profile read, " +
|
|
1122
|
+
"or redirect hive profile read to a file and read by section.");
|
|
1123
|
+
}
|
|
1110
1124
|
function cmdProfile(argv) {
|
|
1111
1125
|
const [sub, ...rest] = argv;
|
|
1112
1126
|
try {
|
|
@@ -1121,7 +1135,9 @@ function cmdProfile(argv) {
|
|
|
1121
1135
|
return;
|
|
1122
1136
|
}
|
|
1123
1137
|
const here = findProjectForCwd();
|
|
1124
|
-
const
|
|
1138
|
+
const hereConfig = here ? loadProjectYml(here.path).config : null;
|
|
1139
|
+
const current = here ? activeProfile(hereConfig) : null;
|
|
1140
|
+
const listVars = mergedProjectVars(hereConfig);
|
|
1125
1141
|
const statuses = names.map((profile) => profileStatus(profile));
|
|
1126
1142
|
const fileWidth = Math.max(11, ...statuses.flatMap((s) => s.files.map((f) => f.file.length)));
|
|
1127
1143
|
for (const [i, profile] of names.entries()) {
|
|
@@ -1129,7 +1145,11 @@ function cmdProfile(argv) {
|
|
|
1129
1145
|
console.log(`${profile === current ? "*" : " "} ${profile}`);
|
|
1130
1146
|
for (const f of status.files) {
|
|
1131
1147
|
const drift = profileDriftText(f);
|
|
1132
|
-
|
|
1148
|
+
const bytes = profileRenderedBytes(profile, f.file, listVars);
|
|
1149
|
+
const size = bytes == null ? "[rendered size unavailable]" : `[${bytes} rendered bytes]`;
|
|
1150
|
+
console.log(` ${f.file.padEnd(fileWidth)} ${f.source.padEnd(7)} ${f.path}${drift ? ` (${drift.text})` : ""} ${size}`);
|
|
1151
|
+
if (bytes != null && bytes > PROFILE_SIZE_WARNING_BYTES)
|
|
1152
|
+
console.log(` ${profileSizeAdvisory(profile, f.file)}`);
|
|
1133
1153
|
}
|
|
1134
1154
|
}
|
|
1135
1155
|
if (current)
|
|
@@ -1546,9 +1566,17 @@ function cmdHide(argv) {
|
|
|
1546
1566
|
return `${name}: hidden`;
|
|
1547
1567
|
}));
|
|
1548
1568
|
}
|
|
1569
|
+
const LEAD_OWNERSHIP_LABEL = {
|
|
1570
|
+
live: "running",
|
|
1571
|
+
gone: "no live pane",
|
|
1572
|
+
reissued: "no live pane",
|
|
1573
|
+
unknown: "pane identity unknown",
|
|
1574
|
+
};
|
|
1549
1575
|
function cmdStatus() {
|
|
1550
1576
|
janitor();
|
|
1551
1577
|
let anyOutput = false;
|
|
1578
|
+
let snapshot;
|
|
1579
|
+
const statusSnapshot = () => (snapshot === undefined ? (snapshot = liveTargets()) : snapshot);
|
|
1552
1580
|
let windows;
|
|
1553
1581
|
let windowsError;
|
|
1554
1582
|
const ownedWindows = () => {
|
|
@@ -1594,7 +1622,9 @@ function cmdStatus() {
|
|
|
1594
1622
|
for (const a of agents) {
|
|
1595
1623
|
const state = a.kind === "agent"
|
|
1596
1624
|
? describeForHuman(deriveProvenance(a, null))
|
|
1597
|
-
:
|
|
1625
|
+
: a.kind === LEAD_KIND
|
|
1626
|
+
? LEAD_OWNERSHIP_LABEL[rowOwnership(a, statusSnapshot())]
|
|
1627
|
+
: (whereIs.get(a.name) ?? "running");
|
|
1598
1628
|
const label = a.kind === "command" ? "cmd " : a.kind === LEAD_KIND ? "lead " : "agent";
|
|
1599
1629
|
console.log(` ${label} ${a.name.padEnd(20)} ${state}`);
|
|
1600
1630
|
if (reportsAgentStateLog(a)) {
|
|
@@ -1877,7 +1907,7 @@ function reportProjectScope(here) {
|
|
|
1877
1907
|
if (locked && project.id !== here?.id)
|
|
1878
1908
|
continue;
|
|
1879
1909
|
if (project.path === homePath || project.path === "/") {
|
|
1880
|
-
warn("project scope", `"${project.name}" (${project.path}) is a home directory; every unregistered directory under it resolves to this project. The project_prune tool with this project's id (from
|
|
1910
|
+
warn("project scope", `"${project.name}" (${project.path}) is a home directory; every unregistered directory under it resolves to this project. The project_prune tool with this project's id (from an unlocked session in another project) removes it once it holds nothing.`);
|
|
1881
1911
|
}
|
|
1882
1912
|
}
|
|
1883
1913
|
for (const parent of projects) {
|
|
@@ -2149,7 +2179,7 @@ function reportCrewTeardown(projectId) {
|
|
|
2149
2179
|
}
|
|
2150
2180
|
function reportStalledWorkers(projectId) {
|
|
2151
2181
|
const latched = db
|
|
2152
|
-
.prepare(`SELECT name, command, kind, cwd, session_id, transcript_path, agent_state, state_changed_at, tmux_target, tmux_socket
|
|
2182
|
+
.prepare(`SELECT name, command, kind, cwd, session_id, transcript_path, agent_state, state_changed_at, tmux_target, tmux_socket, pane_pid
|
|
2153
2183
|
FROM agents
|
|
2154
2184
|
WHERE project_id = ? AND status = 'running' AND kind = 'agent'
|
|
2155
2185
|
AND agent_state IN ('working', 'waiting') AND state_changed_at IS NOT NULL
|
|
@@ -2160,6 +2190,7 @@ function reportStalledWorkers(projectId) {
|
|
|
2160
2190
|
// never this latch"), and a harness with no transcript to check would have nothing to read.
|
|
2161
2191
|
.filter((row) => reportsAgentStateLog(row) && hasTranscriptSignal(row) && row.session_id !== "");
|
|
2162
2192
|
const stalled = [];
|
|
2193
|
+
let snapshot;
|
|
2163
2194
|
for (const row of latched) {
|
|
2164
2195
|
const latchedFor = ageSecondsSince(row.state_changed_at);
|
|
2165
2196
|
if (latchedFor < STALL_BOUND_SECONDS)
|
|
@@ -2176,7 +2207,9 @@ function reportStalledWorkers(projectId) {
|
|
|
2176
2207
|
if (stale !== "never" && stale.seconds < STALL_BOUND_SECONDS)
|
|
2177
2208
|
continue;
|
|
2178
2209
|
if (row.agent_state === "waiting") {
|
|
2179
|
-
if (
|
|
2210
|
+
if (snapshot === undefined)
|
|
2211
|
+
snapshot = liveTargets();
|
|
2212
|
+
if (rowOwnership(row, snapshot) !== "live")
|
|
2180
2213
|
continue;
|
|
2181
2214
|
const choiceCheck = paneClassifierFor(row.command)?.choiceCheck;
|
|
2182
2215
|
if (!choiceCheck || choiceCheck(row.tmux_target).awaitingChoice !== false)
|
|
@@ -2355,6 +2388,12 @@ function cmdDoctor(argv) {
|
|
|
2355
2388
|
warn("profile vars", `hive.yml vars ${collisions.join(", ")} are derived from agents: and are ignored.`);
|
|
2356
2389
|
}
|
|
2357
2390
|
const vars = mergedProjectVars(cfg);
|
|
2391
|
+
for (const f of resolved) {
|
|
2392
|
+
const bytes = profileRenderedBytes(name, f.file, vars);
|
|
2393
|
+
info("profile size", bytes == null ? `${name}/${f.file}: rendered size unavailable` : `${name}/${f.file}: ${bytes} rendered bytes`);
|
|
2394
|
+
if (bytes != null && bytes > PROFILE_SIZE_WARNING_BYTES)
|
|
2395
|
+
info("profile size warning", profileSizeAdvisory(name, f.file));
|
|
2396
|
+
}
|
|
2358
2397
|
const renderedText = profileFileNames(name)
|
|
2359
2398
|
.map((file) => renderProfileFile(name, file, vars))
|
|
2360
2399
|
.filter((t) => t != null)
|
|
@@ -2404,21 +2443,36 @@ function cmdDoctor(argv) {
|
|
|
2404
2443
|
});
|
|
2405
2444
|
if (here) {
|
|
2406
2445
|
const lead = db
|
|
2407
|
-
.prepare("SELECT tmux_target, tmux_socket FROM agents WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id")
|
|
2446
|
+
.prepare("SELECT id, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id")
|
|
2408
2447
|
.get(here.id, LEAD_KIND);
|
|
2409
2448
|
if (lead) {
|
|
2410
|
-
const
|
|
2411
|
-
|
|
2412
|
-
|
|
2449
|
+
const snapshot = liveTargets();
|
|
2450
|
+
const ownership = rowOwnership(lead, snapshot);
|
|
2451
|
+
const unobserved = !foreignSocket(lead.tmux_socket) && observationFailed(snapshot);
|
|
2452
|
+
if (ownership === "gone" || ownership === "reissued") {
|
|
2453
|
+
warn("lead", (ownership === "reissued"
|
|
2454
|
+
? "the lead's row is running but its pane id now belongs to a different process. "
|
|
2455
|
+
: "the lead's row is running but its pane is not live. ") +
|
|
2456
|
+
"The janitor leaves lead rows alone on " +
|
|
2413
2457
|
"purpose, so nothing will fix this by itself. Run `hive lead` to record a fresh pane and reuse " +
|
|
2414
2458
|
"this identity, or ask a claude session connected to this project's hive MCP server to call " +
|
|
2415
|
-
|
|
2459
|
+
(ownership === "reissued"
|
|
2460
|
+
? `agent_close({agent_id: ${lead.id}, row_only: true}) on it (plain agent_close refuses a pane id ` +
|
|
2461
|
+
"that still exists)"
|
|
2462
|
+
: "the agent_close tool on it") +
|
|
2463
|
+
" to retire the row for good - required before `hive restore`, which " +
|
|
2416
2464
|
"otherwise refuses while any lead row reads running, and end that session too, since it also " +
|
|
2417
2465
|
"holds this store open.");
|
|
2418
2466
|
}
|
|
2419
|
-
else if (
|
|
2467
|
+
else if (ownership === "unknown" && unobserved) {
|
|
2420
2468
|
warn("lead", "the lead's pane liveness could not be probed (tmux did not answer).");
|
|
2421
2469
|
}
|
|
2470
|
+
else if (ownership === "unknown") {
|
|
2471
|
+
warn("lead", `pane identity unknown: the lead's row (agents.id ${lead.id}) cannot be verified as owning pane ` +
|
|
2472
|
+
`${lead.tmux_target || "(none)"}, because it is on another tmux socket or no pane pid was recorded ` +
|
|
2473
|
+
"for it. Its screen was not read. If this lead is gone, a human or peer lead (never a worker) can " +
|
|
2474
|
+
`retire the row with the MCP tool agent_close({agent_id: ${lead.id}, row_only: true}).`);
|
|
2475
|
+
}
|
|
2422
2476
|
}
|
|
2423
2477
|
const stuck = db
|
|
2424
2478
|
.prepare("SELECT name, kind, tmux_socket FROM agents WHERE project_id = ? AND status = 'running' AND kind != ?")
|
|
@@ -2437,19 +2491,21 @@ function cmdDoctor(argv) {
|
|
|
2437
2491
|
}
|
|
2438
2492
|
if (here) {
|
|
2439
2493
|
const workers = db
|
|
2440
|
-
.prepare("SELECT name, actor_id, command, kind, tmux_target, tmux_socket FROM agents WHERE project_id = ? AND status = 'running' AND kind = 'agent' ORDER BY id")
|
|
2494
|
+
.prepare("SELECT name, actor_id, command, kind, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND status = 'running' AND kind = 'agent' ORDER BY id")
|
|
2441
2495
|
.all(here.id);
|
|
2496
|
+
const paneSnapshot = workers.length > 0 ? liveTargets() : null;
|
|
2442
2497
|
let inputBoxClean = 0;
|
|
2443
2498
|
let inputBoxDrifted = 0;
|
|
2444
2499
|
let inputBoxUnclassified = 0;
|
|
2445
2500
|
let leadsProbed = 0;
|
|
2446
2501
|
let workersProbed = 0;
|
|
2447
2502
|
const leadRows = db
|
|
2448
|
-
.prepare("SELECT name, command, tmux_target, tmux_socket FROM agents WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id")
|
|
2503
|
+
.prepare("SELECT name, command, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id")
|
|
2449
2504
|
.all(here.id, LEAD_KIND);
|
|
2505
|
+
const leadSnapshot = leadRows.length > 0 ? (paneSnapshot ?? liveTargets()) : null;
|
|
2450
2506
|
for (const leadBox of leadRows) {
|
|
2451
2507
|
const leadHarness = harnessFor(leadBox.command);
|
|
2452
|
-
if (!leadHarness.classifiesPaneScreen ||
|
|
2508
|
+
if (!leadHarness.classifiesPaneScreen || rowOwnership(leadBox, leadSnapshot) !== "live")
|
|
2453
2509
|
continue;
|
|
2454
2510
|
leadsProbed += 1;
|
|
2455
2511
|
const box = leadHarness.paneClassifier.inputBoxState(leadBox.tmux_target);
|
|
@@ -2474,20 +2530,26 @@ function cmdDoctor(argv) {
|
|
|
2474
2530
|
workersReported += 1;
|
|
2475
2531
|
// Dispatched through w's OWN harness, never the claude-only import - reading a codex pane
|
|
2476
2532
|
// with claude's dialog regexes fails silently in either direction (.claude/rules/tmux-and-panes.md).
|
|
2533
|
+
const ownership = rowOwnership(w, paneSnapshot);
|
|
2477
2534
|
const foreign = foreignSocket(w.tmux_socket);
|
|
2535
|
+
const unowned = ownership !== "live";
|
|
2478
2536
|
const workerChoiceCheck = paneClassifierFor(w.command)?.choiceCheck;
|
|
2479
|
-
const { awaitingChoice, tail } =
|
|
2537
|
+
const { awaitingChoice, tail } = unowned || !workerChoiceCheck ? { awaitingChoice: null, tail: "" } : workerChoiceCheck(w.tmux_target);
|
|
2480
2538
|
if (awaitingChoice === true)
|
|
2481
2539
|
dialogCount += 1;
|
|
2482
2540
|
verboseInfo("worker live state", `worker ${w.name}`, `last log event: ${describeLastLogEvent(lastLogEvent(w.actor_id))}`, `permission mode: ${lastPermissionMode(w.actor_id) ?? "unknown (no record)"}`, foreign
|
|
2483
2541
|
? `pane: recorded on a different tmux socket (${w.tmux_socket}); this process cannot read it`
|
|
2484
|
-
:
|
|
2542
|
+
: unowned
|
|
2543
|
+
? `pane: not read - pane ownership ${ownership}`
|
|
2544
|
+
: `pane: ${describePaneChoice(awaitingChoice)}`, ...(foreign
|
|
2485
2545
|
? ["tail: (not read - foreign socket)"]
|
|
2486
|
-
:
|
|
2487
|
-
? [
|
|
2488
|
-
:
|
|
2489
|
-
? ["tail: (pane
|
|
2490
|
-
:
|
|
2546
|
+
: unowned
|
|
2547
|
+
? [`tail: (not read - pane ownership ${ownership})`]
|
|
2548
|
+
: awaitingChoice === null
|
|
2549
|
+
? ["tail: (pane could not be read)"]
|
|
2550
|
+
: tail === ""
|
|
2551
|
+
? ["tail: (pane rendered nothing)"]
|
|
2552
|
+
: ["tail:", ...tail.split("\n").map((line) => `| ${line}`)]));
|
|
2491
2553
|
}
|
|
2492
2554
|
// Gated on classifiesPaneScreen, not reportsAgentStateLog: this drift check only needs the pane,
|
|
2493
2555
|
// and a codex worker's pane has been readable since todo 524, independent of whether its
|
|
@@ -2495,7 +2557,7 @@ function cmdDoctor(argv) {
|
|
|
2495
2557
|
// own harness, never the claude-only import above, or a codex worker's screen gets read with
|
|
2496
2558
|
// claude's regexes (see hive-internals).
|
|
2497
2559
|
const harness = harnessFor(w.command);
|
|
2498
|
-
if (!harness.classifiesPaneScreen ||
|
|
2560
|
+
if (!harness.classifiesPaneScreen || rowOwnership(w, paneSnapshot) !== "live")
|
|
2499
2561
|
continue;
|
|
2500
2562
|
workersProbed += 1;
|
|
2501
2563
|
const box = harness.paneClassifier.inputBoxState(w.tmux_target);
|
|
@@ -2887,6 +2949,57 @@ function cmdPortfolio(argv) {
|
|
|
2887
2949
|
const l = report.totals.lanes;
|
|
2888
2950
|
console.log(`\n${report.totals.projects} project(s): ${l.waiting_on_you} waiting on you, ${l.stuck} stuck, ${l.moving} moving, ${l.quiet} quiet`);
|
|
2889
2951
|
}
|
|
2952
|
+
async function cmdProject(argv) {
|
|
2953
|
+
const parsed = parseArgs(argv, { flags: ["--yes", "-y"] });
|
|
2954
|
+
rejectUnknownFlags("project", parsed, "--yes/-y");
|
|
2955
|
+
const [verb, ref] = parsed.positional;
|
|
2956
|
+
if (verb !== "rm" || !ref || parsed.positional.length > 2) {
|
|
2957
|
+
console.log("Usage: hive project rm <id|path> [--yes]");
|
|
2958
|
+
process.exit(1);
|
|
2959
|
+
}
|
|
2960
|
+
if (process.env.HIVE_PROJECT_LOCK === "1" || agentProjectPin() != null) {
|
|
2961
|
+
console.log("hive project rm: this session is locked to one project (HIVE_PROJECT_LOCK=1) and cannot remove projects.");
|
|
2962
|
+
process.exit(1);
|
|
2963
|
+
}
|
|
2964
|
+
let target;
|
|
2965
|
+
if (/^\d+$/.test(ref))
|
|
2966
|
+
target = getProject(Number(ref));
|
|
2967
|
+
else
|
|
2968
|
+
target = getProjectByPath(ref) ?? getProjectByPath(canonicalPath(ref));
|
|
2969
|
+
if (!target) {
|
|
2970
|
+
console.log(`hive project rm: "${ref}" is not a registered project id or exact registered path. List them with the project_list tool.`);
|
|
2971
|
+
process.exit(1);
|
|
2972
|
+
}
|
|
2973
|
+
if (findProjectForCwd()?.id === target.id) {
|
|
2974
|
+
console.log(`hive project rm: project ${target.id} ("${target.name}") is the one this directory belongs to and is never removed from itself. Run it from another project.`);
|
|
2975
|
+
process.exit(1);
|
|
2976
|
+
}
|
|
2977
|
+
assertQueenCrossProjectWrite("hive project rm", target.id, {});
|
|
2978
|
+
const counts = projectRowCounts(target.id);
|
|
2979
|
+
console.log(`Project ${target.id}: "${target.name}" (${target.path})`);
|
|
2980
|
+
console.log(Object.keys(counts).length > 0 ? `It owns ${formatRowCounts(counts)}.` : "It owns no rows.");
|
|
2981
|
+
console.log("A snapshot of the store is taken first. Restore it with: hive restore <name>");
|
|
2982
|
+
if (!parsed.flags.has("--yes") && !parsed.flags.has("-y")) {
|
|
2983
|
+
const confirmed = await confirmYesNo("Remove this project and everything it owns? [y/N] ");
|
|
2984
|
+
if (confirmed === null) {
|
|
2985
|
+
console.log("Not removed: run hive interactively to confirm, or pass --yes.");
|
|
2986
|
+
process.exit(1);
|
|
2987
|
+
}
|
|
2988
|
+
if (!confirmed) {
|
|
2989
|
+
console.log("Not removed.");
|
|
2990
|
+
process.exit(1);
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
const removed = removeProject(target.id, {
|
|
2994
|
+
snapshot: true,
|
|
2995
|
+
onRemoved: (r) => confirmQueenWrite("hive project rm", r.deleted.id, {}, { project_id: r.deleted.id, name: r.deleted.name, counts: r.counts }),
|
|
2996
|
+
});
|
|
2997
|
+
console.log(`Removed project ${removed.deleted.id} ("${removed.deleted.name}").`);
|
|
2998
|
+
if (Object.keys(removed.counts).length > 0)
|
|
2999
|
+
console.log(`Deleted ${formatRowCounts(removed.counts)}.`);
|
|
3000
|
+
console.log(`Snapshot: ${removed.snapshot}`);
|
|
3001
|
+
console.log("Its actors and any files it left on disk are untouched; actor_prune removes inert actors.");
|
|
3002
|
+
}
|
|
2890
3003
|
function cmdBackups() {
|
|
2891
3004
|
const snapshots = listSnapshots(dataDir);
|
|
2892
3005
|
if (snapshots.length === 0) {
|
|
@@ -3259,7 +3372,7 @@ if (command === "--version" || command === "-v") {
|
|
|
3259
3372
|
const COMMANDS = [
|
|
3260
3373
|
"lead", "queen", "queen-audit", "init", "attach", "start", "stop", "show", "hide", "status", "portfolio", "next", "setup", "upgrade", "doctor",
|
|
3261
3374
|
LEAD_PANE_EXITED_VERB,
|
|
3262
|
-
"pads", "pad", "todos", "todo", "backups", "restore", "runbook", "posture", "profile", "kickoff", "statusline",
|
|
3375
|
+
"pads", "pad", "todos", "todo", "backups", "restore", "project", "runbook", "posture", "profile", "kickoff", "statusline",
|
|
3263
3376
|
];
|
|
3264
3377
|
if (!COMMANDS.includes(command)) {
|
|
3265
3378
|
if (command.startsWith("--")) {
|
|
@@ -3344,6 +3457,9 @@ try {
|
|
|
3344
3457
|
case "restore":
|
|
3345
3458
|
await cmdRestore(rest);
|
|
3346
3459
|
break;
|
|
3460
|
+
case "project":
|
|
3461
|
+
await cmdProject(rest);
|
|
3462
|
+
break;
|
|
3347
3463
|
case "runbook":
|
|
3348
3464
|
cmdRunbook(rest[0]);
|
|
3349
3465
|
break;
|
package/dist/context.js
CHANGED
|
@@ -339,10 +339,11 @@ export const QUEEN_REACH = {
|
|
|
339
339
|
agent_close: "home",
|
|
340
340
|
project_select: "select",
|
|
341
341
|
project_add: "global",
|
|
342
|
-
project_prune: "
|
|
342
|
+
project_prune: "prune",
|
|
343
343
|
actor_prune: "global",
|
|
344
344
|
"hive lead": "spin_up",
|
|
345
345
|
"hive queen-audit": "read",
|
|
346
|
+
"hive project rm": "prune",
|
|
346
347
|
"hive init": "home",
|
|
347
348
|
"hive attach": "home",
|
|
348
349
|
"hive next": "home",
|
|
@@ -365,6 +366,7 @@ function foreignWriteProblem(reach, targetProjectId, args) {
|
|
|
365
366
|
switch (reach) {
|
|
366
367
|
case "spin_up":
|
|
367
368
|
case "todo":
|
|
369
|
+
case "prune":
|
|
368
370
|
return null;
|
|
369
371
|
case "lead_wake": {
|
|
370
372
|
if (args.scope !== undefined)
|
|
@@ -440,6 +442,6 @@ function queenRefusal(operation, targetProjectId, problem) {
|
|
|
440
442
|
const target = targetProjectId === null ? null : getProject(targetProjectId);
|
|
441
443
|
const where = target ? `in project ${target.id} ("${target.name}")` : "outside the queen's own project";
|
|
442
444
|
return new Error(`${QUEEN_REFUSAL}: the queen cannot run ${operation} ${where}: ${problem}. Into another project the ` +
|
|
443
|
-
"queen may only todo_create, todo_comment, send text or wakes to its running lead,
|
|
444
|
-
"with `hive lead <path
|
|
445
|
+
"queen may only todo_create, todo_comment, send text or wakes to its running lead, start its lead " +
|
|
446
|
+
"with `hive lead <path>`, and run project_prune or `hive project rm` on it. Everything else there is its lead's to do.");
|
|
445
447
|
}
|
|
@@ -24,7 +24,7 @@ export function contextCheckpointAdditionalContext(actorId, thresholdPercent, fi
|
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
-
export function runContextCheckpointHook(kind) {
|
|
27
|
+
export function runContextCheckpointHook(kind, suppliedPayload) {
|
|
28
28
|
const thresholdText = process.env.HIVE_CONTEXT_CHECKPOINT_PERCENT;
|
|
29
29
|
if (!thresholdText || !/^\d+$/.test(thresholdText))
|
|
30
30
|
return;
|
|
@@ -35,7 +35,7 @@ export function runContextCheckpointHook(kind) {
|
|
|
35
35
|
if (!actorId.startsWith("agent:") || (kind !== "claude" && kind !== "codex"))
|
|
36
36
|
return;
|
|
37
37
|
try {
|
|
38
|
-
const payload = JSON.parse(readFileSync(0, "utf8"));
|
|
38
|
+
const payload = suppliedPayload === undefined ? JSON.parse(readFileSync(0, "utf8")) : suppliedPayload;
|
|
39
39
|
if (payload === null || typeof payload !== "object" || Array.isArray(payload))
|
|
40
40
|
return;
|
|
41
41
|
const fill = readContextFill(kind, {
|
package/dist/help.js
CHANGED
|
@@ -175,7 +175,10 @@ runbook (fork it first), not a pad write.`,
|
|
|
175
175
|
agent_close(name|agent_id) — kill the worker's pane and mark closed. Capture
|
|
176
176
|
handoffs first; output is not retained. Self-close needs confirm_self.
|
|
177
177
|
On a PARKED row (by agent_id) it releases the park instead, which is how
|
|
178
|
-
you abandon a lane you have decided not to resume.
|
|
178
|
+
you abandon a lane you have decided not to resume. row_only=true marks
|
|
179
|
+
a stale row closed with no kill, stop or typing; a human or peer lead
|
|
180
|
+
only, refused for a row that provably owns a live pane or when tmux
|
|
181
|
+
could not be probed.
|
|
179
182
|
|
|
180
183
|
Address a worker by its name, not its id: agent_send(name="impl", ...). A
|
|
181
184
|
partial name works when it matches one running worker, so name="123" finds
|
|
@@ -252,10 +255,14 @@ Every pad, todo, kv entry, and lease belongs to one project (a directory).
|
|
|
252
255
|
project_list — all registered projects plus the current selection
|
|
253
256
|
project_add(path?, name?) — register a directory (defaults to cwd)
|
|
254
257
|
project_select(project_id) — set this session's default scope
|
|
255
|
-
project_prune() — delete every registered project that owns no rows
|
|
258
|
+
project_prune(project_id?, confirm_name?) — delete every registered project that owns no rows
|
|
256
259
|
anywhere in the store, verified individually; never your own; refuses
|
|
257
260
|
under HIVE_PROJECT_LOCK=1 since it sweeps every project, not just yours.
|
|
258
|
-
With project_id, removes only that project, and only if it owns no rows
|
|
261
|
+
With project_id, removes only that project, and only if it owns no rows.
|
|
262
|
+
With project_id and confirm_name (its exact name), removes it with every
|
|
263
|
+
row it owns after a snapshot; refuses while a running row owns a live
|
|
264
|
+
pane or cannot be verified (agent_close row_only retires the latter).
|
|
265
|
+
From a terminal: hive project rm <id|path> [--yes].
|
|
259
266
|
|
|
260
267
|
Resolution order: explicit project_id argument, then session selection, then
|
|
261
268
|
working-directory auto-detection. When the working directory matches no
|
package/dist/hook.js
CHANGED
|
@@ -1,7 +1,70 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, parse, relative, sep } from "node:path";
|
|
3
4
|
import { awaitingFirstPromptSql } from "./firstPrompt.js";
|
|
4
5
|
import { liveBackgroundTasks, SUBAGENT_LATCH_SQL, withholdsIdle } from "./backgroundTasks.js";
|
|
6
|
+
function canonicalPathWithMissingSuffix(input) {
|
|
7
|
+
if (!isAbsolute(input) || input.includes("\0"))
|
|
8
|
+
return null;
|
|
9
|
+
let current = parse(input).root;
|
|
10
|
+
const missing = [];
|
|
11
|
+
try {
|
|
12
|
+
current = realpathSync(current);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
for (const part of input.slice(parse(input).root.length).split(sep)) {
|
|
18
|
+
if (part === "" || part === ".")
|
|
19
|
+
continue;
|
|
20
|
+
if (part === "..") {
|
|
21
|
+
if (missing.length)
|
|
22
|
+
missing.pop();
|
|
23
|
+
else
|
|
24
|
+
current = dirname(current);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (missing.length) {
|
|
28
|
+
missing.push(part);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const candidate = join(current, part);
|
|
32
|
+
try {
|
|
33
|
+
current = realpathSync(candidate);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code !== "ENOENT")
|
|
37
|
+
return null;
|
|
38
|
+
missing.push(part);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return join(current, ...missing);
|
|
42
|
+
}
|
|
43
|
+
function acceptsWorkerHook(row, payload, sessionsRoot) {
|
|
44
|
+
if (!row || row.kind !== "agent" || row.codex_home === "" || sessionsRoot === undefined)
|
|
45
|
+
return true;
|
|
46
|
+
const transcript = payload.transcript_path;
|
|
47
|
+
if (typeof transcript === "string" && transcript !== "") {
|
|
48
|
+
const canonicalRoot = canonicalPathWithMissingSuffix(sessionsRoot);
|
|
49
|
+
const canonicalTranscript = canonicalPathWithMissingSuffix(transcript);
|
|
50
|
+
if (canonicalRoot === null || canonicalTranscript === null)
|
|
51
|
+
return false;
|
|
52
|
+
const rel = relative(canonicalRoot, canonicalTranscript);
|
|
53
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
54
|
+
}
|
|
55
|
+
if (transcript !== undefined && transcript !== null && transcript !== "")
|
|
56
|
+
return false;
|
|
57
|
+
return (typeof row.session_id === "string" && row.session_id !== "" &&
|
|
58
|
+
typeof payload.session_id === "string" && payload.session_id !== "" &&
|
|
59
|
+
row.session_id === payload.session_id);
|
|
60
|
+
}
|
|
61
|
+
function validCheckpointThreshold() {
|
|
62
|
+
const text = process.env.HIVE_CONTEXT_CHECKPOINT_PERCENT;
|
|
63
|
+
if (!text || !/^\d+$/.test(text))
|
|
64
|
+
return false;
|
|
65
|
+
const value = Number(text);
|
|
66
|
+
return Number.isInteger(value) && value >= 1 && value <= 100;
|
|
67
|
+
}
|
|
5
68
|
let raw;
|
|
6
69
|
function readRaw() {
|
|
7
70
|
if (raw === undefined) {
|
|
@@ -161,8 +224,33 @@ function stateFor(event, actorId) {
|
|
|
161
224
|
}
|
|
162
225
|
if (process.argv[2] === "post_tool_use") {
|
|
163
226
|
try {
|
|
227
|
+
const kind = process.argv[3];
|
|
228
|
+
const actorId = process.env.HIVE_AGENT_ID ?? "";
|
|
229
|
+
let suppliedPayload;
|
|
230
|
+
if (kind === "codex" && validCheckpointThreshold() && actorId.startsWith("agent:")) {
|
|
231
|
+
suppliedPayload = readPayload();
|
|
232
|
+
const { db } = await import("./db.js");
|
|
233
|
+
const row = db.prepare("SELECT kind, command, status, codex_home, session_id FROM agents WHERE actor_id = ?")
|
|
234
|
+
.get(actorId);
|
|
235
|
+
if (row?.kind === "agent") {
|
|
236
|
+
const { harnessFor } = await import("./harnesses.js");
|
|
237
|
+
if (harnessFor(row.command).name === "codex") {
|
|
238
|
+
if (row.status !== "running")
|
|
239
|
+
process.exit(0);
|
|
240
|
+
if (typeof row.codex_home === "string" && row.codex_home !== "") {
|
|
241
|
+
const { codexHomeDir } = await import("./codexHome.js");
|
|
242
|
+
if (!acceptsWorkerHook(row, suppliedPayload, join(codexHomeDir(row.codex_home), "sessions"))) {
|
|
243
|
+
process.exit(0);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
164
249
|
const { runContextCheckpointHook } = await import("./contextCheckpoint.js");
|
|
165
|
-
|
|
250
|
+
if (suppliedPayload === undefined)
|
|
251
|
+
runContextCheckpointHook(kind);
|
|
252
|
+
else
|
|
253
|
+
runContextCheckpointHook(kind, suppliedPayload);
|
|
166
254
|
}
|
|
167
255
|
catch { }
|
|
168
256
|
process.exit(0);
|
|
@@ -172,13 +260,43 @@ try {
|
|
|
172
260
|
const actorId = process.env.HIVE_AGENT_ID;
|
|
173
261
|
if (actorId) {
|
|
174
262
|
const event = process.argv[2] ?? "";
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
263
|
+
const hookPayload = readPayload();
|
|
264
|
+
const row = db.prepare("SELECT kind, command, status, codex_home, session_id FROM agents WHERE actor_id = ?")
|
|
265
|
+
.get(actorId);
|
|
266
|
+
let guarded = false;
|
|
267
|
+
let state = null;
|
|
268
|
+
if (row?.kind === "agent") {
|
|
269
|
+
const { harnessFor } = await import("./harnesses.js");
|
|
270
|
+
if (harnessFor(row.command).name === "codex") {
|
|
271
|
+
if (row.status !== "running")
|
|
272
|
+
process.exit(0);
|
|
273
|
+
const { codexHomeDir } = await import("./codexHome.js");
|
|
274
|
+
const result = db.transaction(() => {
|
|
275
|
+
const current = db.prepare("SELECT kind, command, status, codex_home, session_id FROM agents WHERE actor_id = ?")
|
|
276
|
+
.get(actorId);
|
|
277
|
+
if (current?.kind === "agent" && harnessFor(current.command).name === "codex" && current.status !== "running") {
|
|
278
|
+
return { status: "inactive", state: null };
|
|
279
|
+
}
|
|
280
|
+
if (!current || current.kind !== "agent" || typeof current.codex_home !== "string" || !current.codex_home || harnessFor(current.command).name !== "codex") {
|
|
281
|
+
return { status: "changed", state: null };
|
|
282
|
+
}
|
|
283
|
+
const sessionsRoot = join(codexHomeDir(current.codex_home), "sessions");
|
|
284
|
+
if (!acceptsWorkerHook(current, hookPayload, sessionsRoot))
|
|
285
|
+
return { status: "rejected", state: null };
|
|
286
|
+
const state = applyWorkerTransition(actorId, event);
|
|
287
|
+
reconcileSessionId(actorId, hookPayload);
|
|
288
|
+
reconcileTranscriptPath(actorId, hookPayload);
|
|
289
|
+
db.prepare("UPDATE actors SET last_seen_at = datetime('now') WHERE id = ?").run(actorId);
|
|
290
|
+
return { status: "accepted", state };
|
|
291
|
+
}).immediate();
|
|
292
|
+
if (result.status === "rejected" || result.status === "inactive")
|
|
293
|
+
process.exit(0);
|
|
294
|
+
guarded = result.status === "accepted";
|
|
295
|
+
state = result.state;
|
|
296
|
+
}
|
|
181
297
|
}
|
|
298
|
+
if (!guarded)
|
|
299
|
+
state = applyWorkerTransition(actorId, event);
|
|
182
300
|
// A lead's turn state is its own table; agents.agent_state stays worker-only.
|
|
183
301
|
if (process.env.HIVE_LEAD === "1" && (event === "prompt" || event === "stop" || event === "session_end")) {
|
|
184
302
|
try {
|
|
@@ -195,12 +313,24 @@ try {
|
|
|
195
313
|
catch {
|
|
196
314
|
}
|
|
197
315
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
316
|
+
if (!guarded) {
|
|
317
|
+
reconcileSessionId(actorId, hookPayload);
|
|
318
|
+
reconcileTranscriptPath(actorId, hookPayload);
|
|
319
|
+
db.prepare("UPDATE actors SET last_seen_at = datetime('now') WHERE id = ?").run(actorId);
|
|
320
|
+
}
|
|
201
321
|
record(actorId, event, state ?? UNCHANGED);
|
|
202
322
|
}
|
|
203
323
|
}
|
|
204
324
|
catch {
|
|
205
325
|
}
|
|
206
326
|
process.exit(0);
|
|
327
|
+
function applyWorkerTransition(actorId, event) {
|
|
328
|
+
const state = stateFor(event, actorId);
|
|
329
|
+
if (state !== null) {
|
|
330
|
+
db.prepare("UPDATE agents SET agent_state = ?, state_changed_at = datetime('now') WHERE actor_id = ? AND kind = 'agent'").run(state, actorId);
|
|
331
|
+
}
|
|
332
|
+
if (event === "prompt") {
|
|
333
|
+
db.prepare(`UPDATE agents SET resumed_at = '' WHERE actor_id = ? AND kind = 'agent' AND ${awaitingFirstPromptSql("agents")}`).run(actorId);
|
|
334
|
+
}
|
|
335
|
+
return state;
|
|
336
|
+
}
|
package/dist/portfolio.js
CHANGED
|
@@ -4,7 +4,7 @@ import { existsSync } from "node:fs";
|
|
|
4
4
|
import { parseTags } from "./result.js";
|
|
5
5
|
import { OPEN_BLOCKERS_SQL } from "./tools/todos.js";
|
|
6
6
|
import { readLeadTurnState } from "./leadState.js";
|
|
7
|
-
import { liveTargets,
|
|
7
|
+
import { liveTargets, ownershipLiveness, rowOwnership } from "./tmux.js";
|
|
8
8
|
export const PORTFOLIO_REASONS = [
|
|
9
9
|
"needs_human",
|
|
10
10
|
"dead_lead_pane",
|
|
@@ -84,15 +84,14 @@ function projectRow(project, now, snapshot) {
|
|
|
84
84
|
const agents = db
|
|
85
85
|
.prepare("SELECT id, kind, agent_state, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
|
|
86
86
|
.all(project.id);
|
|
87
|
-
const liveness = (a) =>
|
|
88
|
-
const reissued = (a) => snapshot !== null && paneReissued(a.pane_pid, rowAliveProbe(a.tmux_socket, a.tmux_target, snapshot));
|
|
87
|
+
const liveness = (a) => ownershipLiveness(rowOwnership(a, snapshot));
|
|
89
88
|
const workers = { working: 0, idle: 0, needs_input: 0, other: 0, unreachable: 0, unconfirmed: 0 };
|
|
90
89
|
let liveWorking = 0;
|
|
91
90
|
let liveWaiting = 0;
|
|
92
91
|
let lead = { state: "none", agent_id: null, turn: "unknown" };
|
|
93
92
|
for (const a of agents) {
|
|
94
93
|
if (a.kind === "lead") {
|
|
95
|
-
const live =
|
|
94
|
+
const live = liveness(a);
|
|
96
95
|
lead = {
|
|
97
96
|
state: live === true ? "alive" : live === false ? "dead_pane" : "unknown",
|
|
98
97
|
agent_id: a.id,
|
|
@@ -102,7 +101,7 @@ function projectRow(project, now, snapshot) {
|
|
|
102
101
|
}
|
|
103
102
|
if (a.kind !== "agent")
|
|
104
103
|
continue;
|
|
105
|
-
const live =
|
|
104
|
+
const live = liveness(a);
|
|
106
105
|
if (live === false)
|
|
107
106
|
workers.unreachable++;
|
|
108
107
|
else if (live === null)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { backupNow } from "./backup.js";
|
|
2
|
+
import { dataDir, db } from "./db.js";
|
|
3
|
+
import { LEAD_KIND } from "./spawn.js";
|
|
4
|
+
import { foreignSocket, liveTargets, observationFailed, rowOwnership } from "./tmux.js";
|
|
5
|
+
export const PROJECT_OWNER_TABLES = [
|
|
6
|
+
"pads",
|
|
7
|
+
"todos",
|
|
8
|
+
"kv",
|
|
9
|
+
"leases",
|
|
10
|
+
"agents",
|
|
11
|
+
"wakes",
|
|
12
|
+
"command_trust",
|
|
13
|
+
];
|
|
14
|
+
const COUNTED_TABLES = [
|
|
15
|
+
...PROJECT_OWNER_TABLES.map((t) => [t, `SELECT COUNT(*) AS n FROM ${t} WHERE project_id = ?`]),
|
|
16
|
+
["todo_comments", "SELECT COUNT(*) AS n FROM todo_comments WHERE todo_id IN (SELECT id FROM todos WHERE project_id = ?)"],
|
|
17
|
+
["agent_messages", "SELECT COUNT(*) AS n FROM agent_messages WHERE project_id = ?"],
|
|
18
|
+
];
|
|
19
|
+
export function projectRowCounts(projectId) {
|
|
20
|
+
const counts = {};
|
|
21
|
+
for (const [table, sql] of COUNTED_TABLES) {
|
|
22
|
+
const n = db.prepare(sql).get(projectId).n;
|
|
23
|
+
if (n > 0)
|
|
24
|
+
counts[table] = n;
|
|
25
|
+
}
|
|
26
|
+
return counts;
|
|
27
|
+
}
|
|
28
|
+
export function formatRowCounts(counts) {
|
|
29
|
+
return Object.entries(counts)
|
|
30
|
+
.map(([table, n]) => `${table}: ${n}`)
|
|
31
|
+
.join(", ");
|
|
32
|
+
}
|
|
33
|
+
const runningRows = (projectId) => db
|
|
34
|
+
.prepare("SELECT id, name, kind, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
|
|
35
|
+
.all(projectId);
|
|
36
|
+
const identities = (rows) => JSON.stringify(rows.map((r) => [r.id, r.tmux_target, r.tmux_socket, r.pane_pid]));
|
|
37
|
+
function assertNoRunningAgents(projectId, name, running, panes) {
|
|
38
|
+
if (running.length === 0)
|
|
39
|
+
return;
|
|
40
|
+
const snapshot = panes === undefined ? liveTargets() : panes;
|
|
41
|
+
const blockers = running
|
|
42
|
+
.map((a) => ({ ...a, ownership: rowOwnership(a, snapshot) }))
|
|
43
|
+
.filter((a) => a.ownership === "live" || a.ownership === "unknown");
|
|
44
|
+
if (blockers.length === 0)
|
|
45
|
+
return;
|
|
46
|
+
const names = blockers
|
|
47
|
+
.map((a) => `${a.name} (agent ${a.id}, ${a.ownership === "live" ? "owns a live pane" : "pane identity unknown"})`)
|
|
48
|
+
.join(", ");
|
|
49
|
+
const hints = [];
|
|
50
|
+
const liveLeads = blockers.filter((a) => a.ownership === "live" && a.kind === LEAD_KIND);
|
|
51
|
+
const liveOthers = blockers.filter((a) => a.ownership === "live" && a.kind !== LEAD_KIND);
|
|
52
|
+
const unknown = blockers.filter((a) => a.ownership === "unknown");
|
|
53
|
+
if (liveLeads.length > 0)
|
|
54
|
+
hints.push("A live lead is stopped or restarted from its own terminal, never retired with row_only.");
|
|
55
|
+
if (liveOthers.length > 0)
|
|
56
|
+
hints.push("Stop a live worker or command with agent_close first.");
|
|
57
|
+
if (unknown.length > 0 && observationFailed(snapshot)) {
|
|
58
|
+
hints.push("tmux could not be probed, so no row's ownership could be checked. Retry in a few seconds.");
|
|
59
|
+
}
|
|
60
|
+
else if (unknown.length > 0) {
|
|
61
|
+
const foreign = unknown.some((a) => foreignSocket(a.tmux_socket));
|
|
62
|
+
hints.push("A row whose pane cannot be verified is retired explicitly by a human or peer lead through this project's MCP " +
|
|
63
|
+
`tool, ${unknown.map((a) => `agent_close({agent_id: ${a.id}, row_only: true})`).join(", ")}` +
|
|
64
|
+
(foreign
|
|
65
|
+
? ", or run this again from the tmux socket a foreign-socket row was recorded on, where its ownership can be verified."
|
|
66
|
+
: "."));
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`project ${projectId} ("${name}") has running agents: ${names}. Nothing deleted. ${hints.join(" ")}`);
|
|
69
|
+
}
|
|
70
|
+
const deleteProjectRows = db.transaction((projectId, name, expected, observed, after) => {
|
|
71
|
+
const running = runningRows(projectId);
|
|
72
|
+
if (identities(running) !== observed.rows) {
|
|
73
|
+
throw new Error(`project ${projectId} ("${name}") changed while the snapshot was taken; nothing removed, run it again.`);
|
|
74
|
+
}
|
|
75
|
+
assertNoRunningAgents(projectId, name, running, observed.panes);
|
|
76
|
+
if (JSON.stringify(projectRowCounts(projectId)) !== JSON.stringify(expected)) {
|
|
77
|
+
throw new Error(`project ${projectId} ("${name}") changed while the snapshot was taken; nothing removed, run it again.`);
|
|
78
|
+
}
|
|
79
|
+
db.prepare("DELETE FROM agent_messages WHERE project_id = ?").run(projectId);
|
|
80
|
+
if (db.prepare("DELETE FROM projects WHERE id = ?").run(projectId).changes === 0) {
|
|
81
|
+
throw new Error(`no project ${projectId}.`);
|
|
82
|
+
}
|
|
83
|
+
after();
|
|
84
|
+
});
|
|
85
|
+
export function removeProject(projectId, opts) {
|
|
86
|
+
const target = db.prepare("SELECT id, name, path FROM projects WHERE id = ?").get(projectId);
|
|
87
|
+
if (!target)
|
|
88
|
+
throw new Error(`no project ${projectId}.`);
|
|
89
|
+
assertNoRunningAgents(projectId, target.name, runningRows(projectId));
|
|
90
|
+
const counts = projectRowCounts(projectId);
|
|
91
|
+
let snapshot = null;
|
|
92
|
+
if (opts.snapshot) {
|
|
93
|
+
const result = backupNow(db, dataDir, "manual");
|
|
94
|
+
if (!result.ok)
|
|
95
|
+
throw new Error(`could not snapshot the store first, nothing deleted: ${result.error ?? "unknown error"}`);
|
|
96
|
+
snapshot = result.path ?? null;
|
|
97
|
+
}
|
|
98
|
+
opts.afterSnapshot?.();
|
|
99
|
+
const rows = runningRows(projectId);
|
|
100
|
+
const observed = { rows: identities(rows), panes: rows.length > 0 ? liveTargets() : null };
|
|
101
|
+
const removed = { deleted: { id: target.id, name: target.name, path: target.path }, counts, snapshot };
|
|
102
|
+
deleteProjectRows.immediate(projectId, target.name, counts, observed, () => opts.onRemoved?.(removed));
|
|
103
|
+
return removed;
|
|
104
|
+
}
|
package/dist/queenAudit.js
CHANGED
|
@@ -9,6 +9,10 @@ export const QUEEN_AUDIT_MAX_ROWS = 20_000;
|
|
|
9
9
|
function resource(type, value) {
|
|
10
10
|
return typeof value === "number" && Number.isInteger(value) && value > 0 ? { type, id: value } : null;
|
|
11
11
|
}
|
|
12
|
+
function removalSummary(r) {
|
|
13
|
+
const counts = Object.entries((r.counts ?? {})).map(([t, n]) => `${t} ${n}`);
|
|
14
|
+
return `removed project "${r.name}"${counts.length > 0 ? ` with ${counts.join(", ")}` : ""}`;
|
|
15
|
+
}
|
|
12
16
|
export const QUEEN_AUDIT_OPERATIONS = {
|
|
13
17
|
todo_create: {
|
|
14
18
|
mode: "database",
|
|
@@ -45,6 +49,16 @@ export const QUEEN_AUDIT_OPERATIONS = {
|
|
|
45
49
|
resource: (r) => r.sent === true ? resource("agent", r.agent_id) : null,
|
|
46
50
|
summary: (a, r) => `agent #${r.agent_id} ${a.submit === false ? "pasted without submit" : "submitted"}: ${a.text}`,
|
|
47
51
|
},
|
|
52
|
+
project_prune: {
|
|
53
|
+
mode: "database",
|
|
54
|
+
resource: (r) => resource("project", r.project_id),
|
|
55
|
+
summary: (_a, r) => removalSummary(r),
|
|
56
|
+
},
|
|
57
|
+
"hive project rm": {
|
|
58
|
+
mode: "database",
|
|
59
|
+
resource: (r) => resource("project", r.project_id),
|
|
60
|
+
summary: (_a, r) => removalSummary(r),
|
|
61
|
+
},
|
|
48
62
|
"hive lead": {
|
|
49
63
|
mode: "external",
|
|
50
64
|
resource: (r) => r.disposition === "started" || r.disposition === "adopted" ? resource("agent", r.agent_id) : null,
|
package/dist/spawn.js
CHANGED
|
@@ -371,13 +371,18 @@ export function reapCodexHomeForClosedAgent(agentId, codexHome) {
|
|
|
371
371
|
}
|
|
372
372
|
db.prepare("UPDATE agents SET codex_home = '' WHERE id = ? AND codex_home = ?").run(agentId, codexHome);
|
|
373
373
|
}
|
|
374
|
-
export function closeAgentRow(agentId,
|
|
375
|
-
const info =
|
|
374
|
+
export function closeAgentRow(agentId, expected) {
|
|
375
|
+
const info = expected === undefined
|
|
376
376
|
? db
|
|
377
377
|
.prepare("UPDATE agents SET status = 'closed', closed_at = datetime('now') WHERE id = ? AND status = 'running'")
|
|
378
378
|
.run(agentId)
|
|
379
|
-
:
|
|
380
|
-
|
|
381
|
-
|
|
379
|
+
: typeof expected === "string"
|
|
380
|
+
? db
|
|
381
|
+
.prepare("UPDATE agents SET status = 'closed', closed_at = datetime('now') WHERE id = ? AND status = 'running' AND tmux_target = ?")
|
|
382
|
+
.run(agentId, expected)
|
|
383
|
+
: db
|
|
384
|
+
.prepare("UPDATE agents SET status = 'closed', closed_at = datetime('now') WHERE id = ? AND status = 'running' " +
|
|
385
|
+
"AND tmux_target = ? AND tmux_socket = ? AND pane_pid = ?")
|
|
386
|
+
.run(agentId, expected.tmux_target, expected.tmux_socket, expected.pane_pid);
|
|
382
387
|
return info.changes > 0;
|
|
383
388
|
}
|
package/dist/tmux.js
CHANGED
|
@@ -470,6 +470,31 @@ export function rowAliveProbe(recordedSocket, target, snapshot) {
|
|
|
470
470
|
export function paneReissued(recordedPid, probe) {
|
|
471
471
|
return probe.live === true && recordedPid !== "" && probe.pid !== null && probe.pid !== recordedPid;
|
|
472
472
|
}
|
|
473
|
+
// The one place a row's claim on a pane is decided. An existing pane is "live" only when both
|
|
474
|
+
// pids are known and equal; an empty recorded pid on a live id is "unknown", never "live".
|
|
475
|
+
export function observationFailed(snapshot) {
|
|
476
|
+
return snapshot === null || snapshot.serverAnswered === false;
|
|
477
|
+
}
|
|
478
|
+
export function rowOwnership(row, snapshot) {
|
|
479
|
+
if (foreignSocket(row.tmux_socket))
|
|
480
|
+
return "unknown";
|
|
481
|
+
if (row.tmux_target === "")
|
|
482
|
+
return "gone";
|
|
483
|
+
const observed = snapshot === undefined ? liveTargets() : snapshot;
|
|
484
|
+
if (observed === null || observationFailed(observed))
|
|
485
|
+
return "unknown";
|
|
486
|
+
if (!targetAlive(row.tmux_target, observed))
|
|
487
|
+
return "gone";
|
|
488
|
+
if (!isPaneTarget(row.tmux_target))
|
|
489
|
+
return "unknown";
|
|
490
|
+
const pid = targetPid(row.tmux_target, observed) ?? "";
|
|
491
|
+
if (row.pane_pid === "" || pid === "")
|
|
492
|
+
return "unknown";
|
|
493
|
+
return pid === row.pane_pid ? "live" : "reissued";
|
|
494
|
+
}
|
|
495
|
+
export function ownershipLiveness(ownership) {
|
|
496
|
+
return ownership === "live" ? true : ownership === "unknown" ? null : false;
|
|
497
|
+
}
|
|
473
498
|
export const RAW_ATTACH_TMUX_CONFIG = [
|
|
474
499
|
"set -g allow-passthrough all",
|
|
475
500
|
];
|
|
@@ -1018,7 +1043,8 @@ export function paneChoiceCheck(target) {
|
|
|
1018
1043
|
// codex has no box border to anchor on, so the box's bottom edge used to be inferred from the literal
|
|
1019
1044
|
// text of its footer status line - but that text is not stable (todo 524: two different compositions
|
|
1020
1045
|
// seen on one codex version, one with no shared substring at all). The anchor below reads structure
|
|
1021
|
-
// instead of content: the pane's own last non-blank row IS the bottom edge, whatever it says
|
|
1046
|
+
// instead of content: the pane's own last non-blank row IS the bottom edge, whatever it says, except that a
|
|
1047
|
+
// "? for shortcuts" row or a right-aligned footer row pulls the edge up to the status row above it.
|
|
1022
1048
|
// codex's own choice-menu shape (see hive-internals), not claude's CHOICE_DIALOG wording. Declared
|
|
1023
1049
|
// above findCodexPromptBox because the box search itself now needs it to reject a choice row rather
|
|
1024
1050
|
// than mistake it for a live prompt.
|
|
@@ -1037,6 +1063,7 @@ function codexPromptContentStart(promptRow) {
|
|
|
1037
1063
|
i += 1;
|
|
1038
1064
|
return i;
|
|
1039
1065
|
}
|
|
1066
|
+
const CODEX_SHORTCUTS_ROW = /^\?\s+for shortcuts/;
|
|
1040
1067
|
function findCodexPromptBox(rows) {
|
|
1041
1068
|
const text = rows.map((row) => stripControlBytes(stripSgr(row)).trim());
|
|
1042
1069
|
let footer = text.length - 1;
|
|
@@ -1044,6 +1071,17 @@ function findCodexPromptBox(rows) {
|
|
|
1044
1071
|
footer -= 1;
|
|
1045
1072
|
if (footer < 0)
|
|
1046
1073
|
return null;
|
|
1074
|
+
// The row directly above "? for shortcuts" starts one footer block if it has codex's " · " status separator.
|
|
1075
|
+
if (CODEX_SHORTCUTS_ROW.test(text[footer]) && footer > 0 && text[footer - 1].includes(" · "))
|
|
1076
|
+
footer -= 1;
|
|
1077
|
+
else if (footer > 0 && text[footer - 1].includes(" · ")) {
|
|
1078
|
+
const footerRow = stripControlBytes(stripSgr(rows[footer]));
|
|
1079
|
+
const footerContent = footerRow.trim();
|
|
1080
|
+
const leadingWhitespace = footerRow.length - footerRow.trimStart().length;
|
|
1081
|
+
// Use the row's geometry so this remains independent of whatever Codex writes in its footer.
|
|
1082
|
+
if (leadingWhitespace > footerContent.length)
|
|
1083
|
+
footer -= 1;
|
|
1084
|
+
}
|
|
1047
1085
|
let prompt = -1;
|
|
1048
1086
|
for (let i = footer - 1; i >= 0 && footer - i <= BOX_MAX_ROWS; i--) {
|
|
1049
1087
|
// The arrow ALONE, against the trimmed row: capture-pane strips a trailing space, and a box with
|
|
@@ -1062,12 +1100,10 @@ function findCodexPromptBox(rows) {
|
|
|
1062
1100
|
// stale submitted prompt that also starts with "›").
|
|
1063
1101
|
if (CODEX_CHOICE_LINE.test(text[prompt]))
|
|
1064
1102
|
return null;
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
// box - a busy/mid-turn screen can have an old prompt sitting within BOX_MAX_ROWS of whatever text
|
|
1068
|
-
// is currently at the bottom, and only this check tells the two apart (see hive-internals).
|
|
1103
|
+
// Only indented continuation rows, then one blank gap, may sit between the prompt and footer.
|
|
1104
|
+
// Left-aligned output in that gap means the prompt is stale, not a live box (see hive-internals).
|
|
1069
1105
|
let i = prompt + 1;
|
|
1070
|
-
while (i <= footer && text[i] !== "")
|
|
1106
|
+
while (i <= footer && text[i] !== "" && /^\s/.test(stripControlBytes(stripSgr(rows[i]))))
|
|
1071
1107
|
i += 1;
|
|
1072
1108
|
while (i <= footer && text[i] === "")
|
|
1073
1109
|
i += 1;
|
package/dist/tools/agents.js
CHANGED
|
@@ -16,7 +16,7 @@ import { markGoneReported } from "../scheduler.js";
|
|
|
16
16
|
import { agentIdentityEnv, branchAt, closeAgentRow, discardOrphanedPane, isReservedAgentName, isRunningLeadActor, killAgentPane, launchAgent, LEAD_KIND, parkAgentRow, reapCodexHomeForClosedAgent, 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, 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";
|
|
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,12 +129,60 @@ function buildParkReceipt(project, agent, parkedAt, branch, note) {
|
|
|
129
129
|
...(note ? { note } : {}),
|
|
130
130
|
};
|
|
131
131
|
}
|
|
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 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);
|
|
153
|
+
if (ownership === "live") {
|
|
154
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}") owns a live pane (its recorded pane pid matches), so row_only refuses: ` +
|
|
155
|
+
"retiring the row would orphan a running process with nothing left to stop it by. " +
|
|
156
|
+
(agent.kind === LEAD_KIND
|
|
157
|
+
? "Restart or end the lead from its own terminal."
|
|
158
|
+
: "Use agent_close without row_only to stop it."));
|
|
159
|
+
}
|
|
160
|
+
if (!closeAgentRow(agent.id, { tmux_target: agent.tmux_target, tmux_socket: agent.tmux_socket, pane_pid: agent.pane_pid })) {
|
|
161
|
+
const after = getAgentRow(agent.project_id, agent.id, "Call agent_list(include_closed: true).");
|
|
162
|
+
if (after.status !== "running") {
|
|
163
|
+
return {
|
|
164
|
+
...receipt,
|
|
165
|
+
...(after.parked_at ? { parked: true } : {}),
|
|
166
|
+
note: "Already closed by someone else before this call's row-only retirement landed. Nothing else was changed.",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}")'s pane identity changed since this call read it, most likely a fresh ` +
|
|
170
|
+
"pane recorded on it. Nothing was closed. Re-read it with agent_status and decide again.");
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
...receipt,
|
|
174
|
+
note: `Row retired with no kill, stop or typing; its pane ownership read ${ownership}.` +
|
|
175
|
+
(ownership === "unknown" ? " Anything still running in that pane was left alone." : ""),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
132
178
|
export function isLive(agent) {
|
|
133
179
|
if (agent.status !== "running")
|
|
134
180
|
return false;
|
|
135
181
|
return rowLive(agent.tmux_socket, agent.tmux_target);
|
|
136
182
|
}
|
|
137
183
|
export const PROBE_FAILED_NOTE = "tmux could not be probed, so liveness is unknown. Nothing was changed. Retry in a few seconds.";
|
|
184
|
+
const unknownOwnershipNote = (snapshot) => observationFailed(snapshot) ? PROBE_FAILED_NOTE : UNVERIFIED_OWNERSHIP_NOTE;
|
|
185
|
+
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
186
|
export const probeFailed = (agent) => new Error(`Agent ${agent.id} ("${agent.name}"): ${PROBE_FAILED_NOTE}`);
|
|
139
187
|
function requireLive(agent) {
|
|
140
188
|
if (agent.status !== "running") {
|
|
@@ -250,11 +298,7 @@ const PANE_READY_MS = Number(process.env.HIVE_SPAWN_READY_MS ?? 45_000);
|
|
|
250
298
|
export function summaryLiveness(row, snapshot) {
|
|
251
299
|
if (row.status !== "running")
|
|
252
300
|
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);
|
|
301
|
+
return ownershipLiveness(rowOwnership(row, snapshot));
|
|
258
302
|
}
|
|
259
303
|
function capturePaneQuietly(target) {
|
|
260
304
|
try {
|
|
@@ -1031,11 +1075,12 @@ export function registerAgents(server) {
|
|
|
1031
1075
|
}, (args) => run(() => {
|
|
1032
1076
|
const project = resolveProject(args.project_id);
|
|
1033
1077
|
const agent = findAgent(project.id, args);
|
|
1034
|
-
const
|
|
1078
|
+
const snapshot = agent.status === "running" ? liveTargets() : null;
|
|
1079
|
+
const summary = agentSummary(agent, snapshot);
|
|
1035
1080
|
const briefPath = agentBriefPath(agent.id);
|
|
1036
1081
|
return {
|
|
1037
1082
|
...summary,
|
|
1038
|
-
...(summary.alive === null ? { note:
|
|
1083
|
+
...(summary.alive === null ? { note: unknownOwnershipNote(snapshot) } : {}),
|
|
1039
1084
|
closed_at: agent.closed_at,
|
|
1040
1085
|
current_command: summary.alive ? paneCurrentCommand(agent.tmux_target) : null,
|
|
1041
1086
|
brief_path: existsSync(briefPath) ? briefPath : null,
|
|
@@ -1285,7 +1330,8 @@ export function registerAgents(server) {
|
|
|
1285
1330
|
const project = resolveProject(args.project_id);
|
|
1286
1331
|
const agent = findAgent(project.id, args);
|
|
1287
1332
|
const lines = Math.min(args.lines ?? 50, 200);
|
|
1288
|
-
const
|
|
1333
|
+
const snapshot = agent.status === "running" ? liveTargets() : null;
|
|
1334
|
+
const alive = summaryLiveness(agent, snapshot);
|
|
1289
1335
|
return {
|
|
1290
1336
|
agent_id: agent.id,
|
|
1291
1337
|
name: agent.name,
|
|
@@ -1296,14 +1342,14 @@ export function registerAgents(server) {
|
|
|
1296
1342
|
? {}
|
|
1297
1343
|
: {
|
|
1298
1344
|
note: alive === null
|
|
1299
|
-
?
|
|
1300
|
-
: "No live tmux window; output is not retained after exit.",
|
|
1345
|
+
? unknownOwnershipNote(snapshot)
|
|
1346
|
+
: "No live tmux window this row owns; output is not retained after exit.",
|
|
1301
1347
|
}),
|
|
1302
1348
|
...(alive !== true && agent.exit_tail ? { exit_tail: agent.exit_tail } : {}),
|
|
1303
1349
|
};
|
|
1304
1350
|
}));
|
|
1305
1351
|
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.",
|
|
1352
|
+
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
1353
|
annotations: {
|
|
1308
1354
|
readOnlyHint: false,
|
|
1309
1355
|
destructiveHint: true,
|
|
@@ -1314,12 +1360,17 @@ export function registerAgents(server) {
|
|
|
1314
1360
|
name: agentNameParam,
|
|
1315
1361
|
agent_id: agentIdParam,
|
|
1316
1362
|
confirm_self: z.boolean().optional(),
|
|
1363
|
+
row_only: z
|
|
1364
|
+
.boolean()
|
|
1365
|
+
.optional()
|
|
1366
|
+
.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
1367
|
project_id: projectIdParam,
|
|
1318
1368
|
},
|
|
1319
1369
|
outputSchema: {
|
|
1320
1370
|
agent_id: idParam,
|
|
1321
1371
|
name: z.string(),
|
|
1322
1372
|
closed: z.boolean(),
|
|
1373
|
+
row_only: z.boolean().optional(),
|
|
1323
1374
|
park_released: z.boolean().optional(),
|
|
1324
1375
|
parked: z.boolean().optional(),
|
|
1325
1376
|
note: z.string().optional(),
|
|
@@ -1327,6 +1378,8 @@ export function registerAgents(server) {
|
|
|
1327
1378
|
}, (args) => run(() => {
|
|
1328
1379
|
const project = resolveProject(args.project_id);
|
|
1329
1380
|
const agent = findAgent(project.id, args);
|
|
1381
|
+
if (args.row_only === true)
|
|
1382
|
+
return closeRowOnly(agent, args.confirm_self === true);
|
|
1330
1383
|
if (agent.kind === LEAD_KIND && currentActor().startsWith("agent:")) {
|
|
1331
1384
|
throw new Error(`Agent ${agent.id} ("${agent.name}") is this project's lead session. Retiring a lead - live, ` +
|
|
1332
1385
|
"confirmed dead, or unprobed - is reserved for a human at a terminal or a peer lead; a " +
|
package/dist/tools/meta.js
CHANGED
|
@@ -1,39 +1,29 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { db } from "../db.js";
|
|
3
|
-
import { addProject, currentActor, listProjects, resolveProject, selectProjectById, TOUCH_INTERVAL_MS, trySelectedProject, } from "../context.js";
|
|
3
|
+
import { addProject, currentActor, listProjects, findProjectForCwd, resolveProject, selectProjectById, TOUCH_INTERVAL_MS, trySelectedProject, } from "../context.js";
|
|
4
|
+
import { confirmQueenWrite } from "../queenAudit.js";
|
|
4
5
|
import { errorMessage, run } from "../result.js";
|
|
5
6
|
import { HELP_TOPICS, helpOverview } from "../help.js";
|
|
6
7
|
import { idParam } from "./params.js";
|
|
8
|
+
import { formatRowCounts, PROJECT_OWNER_TABLES, projectRowCounts, removeProject } from "../projectRemove.js";
|
|
7
9
|
function refuseIfLocked(tool) {
|
|
8
10
|
if (process.env.HIVE_PROJECT_LOCK === "1") {
|
|
9
11
|
throw new Error(`${tool} sweeps the whole store, across every project and every actor. This session is locked to one project (HIVE_PROJECT_LOCK=1) and cannot run it. Run it from an unlocked session (a lead).`);
|
|
10
12
|
}
|
|
11
13
|
}
|
|
12
|
-
export const PROJECT_OWNER_TABLES = [
|
|
13
|
-
"pads",
|
|
14
|
-
"todos",
|
|
15
|
-
"kv",
|
|
16
|
-
"leases",
|
|
17
|
-
"agents",
|
|
18
|
-
"wakes",
|
|
19
|
-
"command_trust",
|
|
20
|
-
];
|
|
21
14
|
function existsWhere(table, column, value) {
|
|
22
15
|
return db.prepare(`SELECT 1 FROM ${table} WHERE ${column} = ? LIMIT 1`).get(value) !== undefined;
|
|
23
16
|
}
|
|
24
17
|
function projectOwnsRows(projectId) {
|
|
25
18
|
return PROJECT_OWNER_TABLES.some((table) => existsWhere(table, "project_id", projectId));
|
|
26
19
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
const pruneProjectIfEmpty = db.transaction((projectId) => {
|
|
34
|
-
if (projectOwnsRows(projectId))
|
|
20
|
+
const pruneProjectIfEmpty = db.transaction((project) => {
|
|
21
|
+
if (projectOwnsRows(project.id))
|
|
22
|
+
return false;
|
|
23
|
+
if (db.prepare("DELETE FROM projects WHERE id = ?").run(project.id).changes === 0)
|
|
35
24
|
return false;
|
|
36
|
-
|
|
25
|
+
confirmQueenWrite("project_prune", project.id, {}, { project_id: project.id, name: project.name });
|
|
26
|
+
return true;
|
|
37
27
|
});
|
|
38
28
|
export const ACTOR_OWNER_COLUMNS = [
|
|
39
29
|
["agents", "actor_id"],
|
|
@@ -149,30 +139,52 @@ export function registerMeta(server) {
|
|
|
149
139
|
inputSchema: { project_id: idParam },
|
|
150
140
|
}, ({ project_id }) => run(() => selectProjectById(project_id)));
|
|
151
141
|
server.registerTool("project_prune", {
|
|
152
|
-
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. 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.",
|
|
153
143
|
annotations: {
|
|
154
144
|
readOnlyHint: false,
|
|
155
145
|
destructiveHint: true,
|
|
156
146
|
idempotentHint: true,
|
|
157
147
|
openWorldHint: false,
|
|
158
148
|
},
|
|
159
|
-
inputSchema: { project_id: idParam.optional() },
|
|
149
|
+
inputSchema: { project_id: idParam.optional(), confirm_name: z.string().optional() },
|
|
160
150
|
outputSchema: {
|
|
161
151
|
deleted: z.array(z.object({ id: idParam, name: z.string() })),
|
|
162
152
|
held_back: z.object({ project_id: idParam, reason: z.string() }),
|
|
163
153
|
errors: z.array(z.object({ id: idParam, name: z.string(), error: z.string() })).optional(),
|
|
154
|
+
counts: z.record(z.string(), z.number()).optional(),
|
|
155
|
+
snapshot: z.string().nullable().optional(),
|
|
164
156
|
},
|
|
165
|
-
}, ({ project_id }) => run(() => {
|
|
157
|
+
}, ({ project_id, confirm_name }) => run(() => {
|
|
166
158
|
refuseIfLocked("project_prune");
|
|
167
159
|
const homeId = resolveProject().id;
|
|
160
|
+
const cwdId = findProjectForCwd()?.id;
|
|
161
|
+
const isOwn = (id) => id === homeId || id === cwdId;
|
|
162
|
+
if (confirm_name !== undefined && project_id === undefined) {
|
|
163
|
+
throw new Error("project_prune: confirm_name needs project_id; there is no forced sweep.");
|
|
164
|
+
}
|
|
168
165
|
if (project_id !== undefined) {
|
|
169
|
-
if (project_id
|
|
166
|
+
if (isOwn(project_id))
|
|
170
167
|
throw new Error(`project_prune: project ${project_id} is the caller's own project and is never pruned.`);
|
|
171
168
|
const target = listProjects().find((p) => p.id === project_id);
|
|
172
169
|
if (!target)
|
|
173
170
|
throw new Error(`project_prune: no project ${project_id}. List them with project_list.`);
|
|
174
|
-
if (
|
|
175
|
-
|
|
171
|
+
if (confirm_name !== undefined) {
|
|
172
|
+
if (confirm_name !== target.name) {
|
|
173
|
+
throw new Error(`project_prune: confirm_name does not match project ${project_id}, whose name is "${target.name}". Nothing deleted.`);
|
|
174
|
+
}
|
|
175
|
+
const removed = removeProject(project_id, {
|
|
176
|
+
snapshot: true,
|
|
177
|
+
onRemoved: (r) => confirmQueenWrite("project_prune", project_id, {}, { project_id, name: r.deleted.name, counts: r.counts }),
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
deleted: [{ id: removed.deleted.id, name: removed.deleted.name }],
|
|
181
|
+
held_back: { project_id: homeId, reason: "caller's own project" },
|
|
182
|
+
counts: removed.counts,
|
|
183
|
+
snapshot: removed.snapshot,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (!pruneProjectIfEmpty.immediate(target)) {
|
|
187
|
+
throw new Error(`project_prune: project ${project_id} ("${target.name}") is not empty, nothing deleted. It owns ${formatRowCounts(projectRowCounts(project_id))}. To remove it with everything it owns, pass confirm_name with its exact name.`);
|
|
176
188
|
}
|
|
177
189
|
return {
|
|
178
190
|
deleted: [{ id: target.id, name: target.name }],
|
|
@@ -182,10 +194,10 @@ export function registerMeta(server) {
|
|
|
182
194
|
const deleted = [];
|
|
183
195
|
const errors = [];
|
|
184
196
|
for (const project of listProjects()) {
|
|
185
|
-
if (project.id
|
|
197
|
+
if (isOwn(project.id))
|
|
186
198
|
continue;
|
|
187
199
|
try {
|
|
188
|
-
if (pruneProjectIfEmpty.immediate(project
|
|
200
|
+
if (pruneProjectIfEmpty.immediate(project)) {
|
|
189
201
|
deleted.push({ id: project.id, name: project.name });
|
|
190
202
|
}
|
|
191
203
|
}
|
|
@@ -200,7 +212,7 @@ export function registerMeta(server) {
|
|
|
200
212
|
};
|
|
201
213
|
}));
|
|
202
214
|
server.registerTool("actor_prune", {
|
|
203
|
-
description: "Delete every actor that owns no rows anywhere in the store and has not been active in the last minute: agents.actor_id, agents.parent_actor_id, todos.locked_by, todo_comments.author, kv.updated_by, leases.owner, pads.updated_by, wakes.owner, wakes.deliver_actor, agent_state_log.actor_id. The scan is global across every project, never scoped to the caller's: actors carry no project_id, so an actor can own rows in a project the caller cannot see, and a project-scoped scan would misread that actor as inert and delete it. Never prunes the caller's own actor. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep. Run this after project_prune when sweeping the store:
|
|
215
|
+
description: "Delete every actor that owns no rows anywhere in the store and has not been active in the last minute: agents.actor_id, agents.parent_actor_id, todos.locked_by, todo_comments.author, kv.updated_by, leases.owner, pads.updated_by, wakes.owner, wakes.deliver_actor, agent_state_log.actor_id. The scan is global across every project, never scoped to the caller's: actors carry no project_id, so an actor can own rows in a project the caller cannot see, and a project-scoped scan would misread that actor as inert and delete it. Never prunes the caller's own actor. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep. Run this after project_prune when sweeping the store: project_prune with confirm_name removes a non-empty project together with its agents rows, so run this afterwards to clear that project's actors that own nothing else. Actors with agent_state_log rows stay, because that log is append-only and has no project, which means every actor that ever ran a lead or worker stays. Immediate, permanent: no dry-run mode.",
|
|
204
216
|
annotations: {
|
|
205
217
|
readOnlyHint: false,
|
|
206
218
|
destructiveHint: true,
|
package/package.json
CHANGED