@cmgmyr/hive 1.6.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 +8 -1
- package/dist/build-info.json +1 -1
- package/dist/cli.js +43 -18
- package/dist/help.js +9 -1
- package/dist/leadWatch.js +9 -7
- package/dist/processes.js +37 -15
- package/dist/scheduler.js +179 -62
- package/dist/spawn.js +60 -10
- package/dist/tmux.js +15 -1
- package/dist/tools/agents.js +90 -35
- package/dist/tools/pads.js +28 -2
- package/dist/tools/wakes.js +44 -21
- package/package.json +1 -1
|
@@ -47,13 +47,21 @@ Archive it. A pad still describing in-flight state for work that finished is wor
|
|
|
47
47
|
|
|
48
48
|
A watch armed for a crew that is gone is noise. A dated reminder someone set on purpose is not - cancelling one silently loses a commitment nobody will notice missing until the date passes. If you cannot tell which it is, leave it and say so.
|
|
49
49
|
|
|
50
|
+
## Project copies of the shared profile: report, do not rewrite
|
|
51
|
+
|
|
52
|
+
At wrap-up, run `hive profile list` and use the `*` to identify the active profile and every file it carries, including extra Markdown files. Compare its rendered output from `hive posture`, `hive runbook`, and `hive profile read <file>` for every listed file with five kinds of project artifacts: pad headers, lines in the board pad, active todo bodies, the lead's project memory, and `CLAUDE.md`, `AGENTS.md`, or files under `docs/`. Read pad headers rather than whole lessons pads, and inspect todos without their comments. Read profile sources only when this pass runs: redirect `hive posture`, `hive runbook`, and each `hive profile read <file>` to files, then inspect them in bounded sections. For example, use `hive runbook > <file>` and read it by range. For every match, give the artifact and location, the exact profile source and line, its class, and a proposed pointer such as `see <profile source>, <section or line>`.
|
|
53
|
+
|
|
54
|
+
List contradictions first, then restatements, then likely deliberate copies. A contradiction gives an outdated project-specific value as current guidance when the rendered profile now says something different or no longer says it. For example, a project memory that says commits use `--no-gpg-sign` contradicts a rendered rule requiring signed commits. A restatement repeats a profile rule without adding a project-specific choice; if replacing it with a pointer loses no useful detail, propose the pointer. A deliberate override has a project-specific delta, such as a path, a measured value, or a dated human decision; keep that detail and report it as an override. When a copy says it was kept because readers could not find the on-demand source, report it as a likely deliberate copy and ask whether it is still needed. Never infer that an outdated value is an override just because it is project-specific.
|
|
55
|
+
|
|
56
|
+
This pass only reports and proposes. The lead may apply pointer changes to pads and board lines. Any memory change or deletion needs the human's OK. Do not edit, delete, archive, or send anything as part of this pass. Run it at wrap-up, not at every lane end.
|
|
57
|
+
|
|
50
58
|
## Residue you report and do not remove
|
|
51
59
|
|
|
52
60
|
Worktrees, branches, running agents. Removing a worktree can destroy uncommitted work, and a running agent may be mid-turn. Name what you found and let the human or the lead decide.
|
|
53
61
|
|
|
54
62
|
## Finish by saying what you left, not just what you changed
|
|
55
63
|
|
|
56
|
-
A cleanup that reports only its edits cannot be audited, and the interesting part is usually what was deliberately not touched. Leave the board in the shape the next session needs: where the code is now, whatever verification number this project quotes, what is genuinely in flight (often nothing),
|
|
64
|
+
A cleanup that reports only its edits cannot be audited, and the interesting part is usually what was deliberately not touched. Leave the board in the shape the next session needs: where the code is now, whatever verification number this project quotes, what is genuinely in flight (often nothing), what is waiting on a human, and the profile-drift report with its proposed pointers.
|
|
57
65
|
|
|
58
66
|
## Where to route evidence in this project
|
|
59
67
|
|
|
@@ -92,7 +92,14 @@ A command, a repository name, a ticket prefix: these belong in `hive.yml` `vars`
|
|
|
92
92
|
|
|
93
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.
|
|
94
94
|
|
|
95
|
+
## After a profile edit lands, ask once about notifying leads
|
|
96
|
+
|
|
97
|
+
Do this once after the full profile edit has been reviewed and committed, not after each commit or review round. Find the changed files and the commit range for that edit, and record when both review and commit are complete; that is when the edit landed. Use `project_list` to enumerate registered projects and their paths. In each project, run `hive profile list`; its `*` marks the active profile. Keep projects whose active profile matches the one you edited.
|
|
98
|
+
|
|
99
|
+
For each matching project, call `agent_list` with `{project_id: <project id>}`. A recipient must have `kind: "lead"`, `status: "running"`, and `alive: true`, which means the row owns a live pane. Inspect its `command` for `--append-system-prompt-file <path>`. When the named file exists, compare its modification time with the edit's landed time: notify if the file is older, and exclude it if it is equal or newer. `hive lead` rewrites that file when it launches, so its modification time reflects the current session's posture render. If the command has no such flag or the file is missing, include the lead and state the reason, such as `no posture file flag` or `posture file missing`. Do not include rows with `alive: false` or `alive: null`. Ask once and show the project names, lead names, and any reason a posture age could not be checked: `N running leads use this profile: <projects and reasons>. Notify them?` If no leads qualify, report that and stop.
|
|
100
|
+
|
|
101
|
+
On no, send nothing. On yes, call `wake_set` once per qualifying lead with `delay_seconds: 1`, `project_id: <that project's integer id>`, and `deliver_to: <lead's integer agent_id>`. Do not pass an actor id such as `lead:663` as `deliver_to`. Each wake body names the changed files and commit range, then tells the lead to re-read with `hive posture`, `hive runbook`, and `hive profile read <file>`, or restart through `hive lead` to reload the posture. Do not send a wake before the human says yes.
|
|
102
|
+
|
|
95
103
|
## What this skill does not do
|
|
96
104
|
|
|
97
105
|
It does not dispatch project work, change which agent application or model a project uses beyond what was explicitly asked, install a third-party tool, or commit or publish anything. It edits `hive.yml`'s `profile`, `agents`, `vars`, `context_checkpoint_percent`, and `lead_turn_budget` keys only on an explicit ask, and only to values the user gave or confirmed.
|
|
98
|
-
|
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.
|
|
1
|
+
{"version":"1.7.0","sha":"2000149","dirty":false,"build_id":"c539d81be530294a451590d781bd6922cd7155087a70b8fc64dedc35cf94f3e2"}
|
package/dist/cli.js
CHANGED
|
@@ -22,7 +22,7 @@ import { confirmQueenWrite, listQueenAudit } from "./queenAudit.js";
|
|
|
22
22
|
import { formatRowCounts, projectRowCounts, removeProject } from "./projectRemove.js";
|
|
23
23
|
import { ensureHooksFile } from "./hooks.js";
|
|
24
24
|
import { errorMessage, registrationNoticeText, withTrailingNewline } from "./result.js";
|
|
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,
|
|
25
|
+
import { ACTIVE_TIMER_WHERE, dashboardFileContained, describeStall, HELD_REASON_CONVERSATION, HELD_REASON_LEAD_PANE_DEAD, HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX, isLeadRowClosedHold, isUnclassifiablePaneHold, HELD_REASON_UNSUBMITTED_INPUT_PREFIX, isUnsubmittedInputHold, janitor, resolveDashboardFile, generateQueenDashboardNow, transcriptStaleness, wasHeldForPaneIdentity, } from "./scheduler.js";
|
|
26
26
|
import { readTurnCount } from "./turnCount.js";
|
|
27
27
|
import { collectPortfolio, leadText } from "./portfolio.js";
|
|
28
28
|
import { STALL_BOUND_SECONDS } from "./backgroundTasks.js";
|
|
@@ -30,7 +30,7 @@ import { probeSessionInterpreter, reexecTarget, sessionStartVerdict, } from "./s
|
|
|
30
30
|
import { backupHealth, backupNow, backupsDir, formatBytes, listSnapshots, previewRestore, restoreSnapshot, totalSizeBytes, } from "./backup.js";
|
|
31
31
|
import { describeTeardownWindow, readTeardowns, teardownLogPath, } from "./teardown.js";
|
|
32
32
|
import { asNameClash, buildEnvFlags, closeAgentRow, isReservedAgentName, launchAgent, LEAD_KIND, LEAD_NAME, mintLeadActorId, upsertActor, withWindowClaim, } from "./spawn.js";
|
|
33
|
-
import { adoptableWindow, applyLayout, claimInitialWindow, configureHiveWindow, controlModeFor, createWindow, DEFAULT_LAYOUT, describePaneChoice, ensureSession, findProjectWindow, foreignSocket,
|
|
33
|
+
import { adoptableWindow, applyLayout, claimInitialWindow, configureHiveWindow, controlModeFor, createWindow, DEFAULT_LAYOUT, describePaneChoice, ensureSession, findProjectWindow, foreignSocket, isViewSessionName, listOwnedWindows, liveTargets, observationFailed, observeRowOwnership, ORPHAN_MIN_AGE_MS, orphanScratchServers, orphansWorthWarningAbout, paneIsAloneInWindow, paneVisibility, panePidForRecord, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, projectWindows, RAW_ATTACH_TMUX_CONFIG, renderAttachCommand, resolveAttachTarget, resolveInTmuxTarget, rowLive, rowOwnership, SESSION_PREFIX, appendGlobalHook, globalHooks, replaceGlobalHooks, sessionName, setPaneTitle, shellQuote, shownPaneTitle, makeProcessesWindow, tmux, TMUX_DOC, tmuxSaysNothingThere, tmuxTimeoutOverride, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, } from "./tmux.js";
|
|
34
34
|
import { describeVisibility, processCounts } from "./dashboard.js";
|
|
35
35
|
import { runningCommandRow, runningCommandRows, snapshotProcesses, stopAllProcesses, stopLine, stopProcess, STOP_REASONS, } from "./processes.js";
|
|
36
36
|
import { activeProfile, agentVarKeys, BUILT_IN_PROJECT_YML, configHash, loadProjectYml, mergedProjectVars, NO_PROFILE, PROJECT_YML_KEYS, resolveCommandDir, resolveFirstMessage, } from "./projectYml.js";
|
|
@@ -322,17 +322,20 @@ function ensureLeadRow(project, command) {
|
|
|
322
322
|
previousCommand: existing.command,
|
|
323
323
|
previousCodexHome: existing.codex_home,
|
|
324
324
|
casExpected: existing.tmux_target,
|
|
325
|
+
casSocket: existing.tmux_socket,
|
|
326
|
+
casPid: existing.pane_pid,
|
|
325
327
|
};
|
|
326
328
|
}
|
|
327
329
|
const priorClosed = db
|
|
328
330
|
.prepare("SELECT actor_id, tmux_target, tmux_socket, pane_pid, command, codex_home FROM agents WHERE project_id = ? AND kind = ? AND status = 'closed' AND actor_id != '' ORDER BY id DESC LIMIT 1")
|
|
329
331
|
.get(project.id, LEAD_KIND);
|
|
332
|
+
const insertedSocket = tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR);
|
|
330
333
|
let result;
|
|
331
334
|
try {
|
|
332
335
|
result = db.transaction(() => {
|
|
333
336
|
const info = db
|
|
334
337
|
.prepare("INSERT INTO agents (project_id, name, command, cwd, kind, parent_actor_id, tmux_target, tmux_socket, pane_pid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
335
|
-
.run(project.id, LEAD_NAME, command, project.path, LEAD_KIND, currentActor(), "",
|
|
338
|
+
.run(project.id, LEAD_NAME, command, project.path, LEAD_KIND, currentActor(), "", insertedSocket, "");
|
|
336
339
|
const agentId = Number(info.lastInsertRowid);
|
|
337
340
|
const actorId = priorClosed?.actor_id ?? mintLeadActorId(agentId);
|
|
338
341
|
db.prepare("UPDATE agents SET actor_id = ? WHERE id = ?").run(actorId, agentId);
|
|
@@ -352,6 +355,8 @@ function ensureLeadRow(project, command) {
|
|
|
352
355
|
previousCommand: priorClosed?.command ?? "",
|
|
353
356
|
previousCodexHome: priorClosed?.codex_home ?? "",
|
|
354
357
|
casExpected: "",
|
|
358
|
+
casSocket: insertedSocket,
|
|
359
|
+
casPid: "",
|
|
355
360
|
};
|
|
356
361
|
}
|
|
357
362
|
function reportMigrationResult(result) {
|
|
@@ -375,6 +380,8 @@ async function cmdLead(argv) {
|
|
|
375
380
|
registrationNotice = text;
|
|
376
381
|
});
|
|
377
382
|
const auditIdentity = captureQueenWriteIdentity(project.id);
|
|
383
|
+
// Only rows running before this invocation began are leftovers, trust prompts included; a later `hive start` is not.
|
|
384
|
+
const leftoverIds = new Set(runningCommandRows(project.id).map((row) => row.id));
|
|
378
385
|
const { config, warnings, sources } = loadProjectYml(project.path);
|
|
379
386
|
if (detach && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
380
387
|
let untrusted;
|
|
@@ -453,7 +460,8 @@ async function cmdLead(argv) {
|
|
|
453
460
|
}
|
|
454
461
|
}
|
|
455
462
|
const windowName = project.name;
|
|
456
|
-
const { agentId: leadAgentId, actorId: leadActorId, previousTarget, previousSocket, previousPanePid, previousCommand, previousCodexHome, casExpected, } = ensureLeadRow(project, leadCommand);
|
|
463
|
+
const { agentId: leadAgentId, actorId: leadActorId, previousTarget, previousSocket, previousPanePid, previousCommand, previousCodexHome, casExpected, casSocket, casPid, } = ensureLeadRow(project, leadCommand);
|
|
464
|
+
const previousIdentity = { tmux_target: previousTarget, tmux_socket: previousSocket, pane_pid: previousPanePid };
|
|
457
465
|
// A fresh CODEX_HOME is built on every invocation, same as claude's hooks/posture files above -
|
|
458
466
|
// cheap (a few KB; the codex-side plugins/cache bootstrap only materializes once a process
|
|
459
467
|
// actually starts under it) and correct regardless of whether this invocation ends up creating
|
|
@@ -524,10 +532,8 @@ async function cmdLead(argv) {
|
|
|
524
532
|
}
|
|
525
533
|
else {
|
|
526
534
|
const foundWindow = findProjectWindow(session, project.id);
|
|
527
|
-
|
|
528
|
-
const adopted =
|
|
529
|
-
? adoptableWindow(session, project.id, previousTarget)
|
|
530
|
-
: null;
|
|
535
|
+
// Only a pane the row provably owns is adopted; unknown or reissued gets a fresh pane, never the stranger's pid.
|
|
536
|
+
const adopted = rowOwnership(previousIdentity) === "live" ? adoptableWindow(session, project.id, previousTarget) : null;
|
|
531
537
|
if (adopted) {
|
|
532
538
|
leadPane = previousTarget;
|
|
533
539
|
leadWindow = adopted;
|
|
@@ -574,12 +580,24 @@ async function cmdLead(argv) {
|
|
|
574
580
|
if (newCodexHomeKey)
|
|
575
581
|
reapCodexHome(newCodexHomeKey);
|
|
576
582
|
};
|
|
583
|
+
if (!createdPane && rowOwnership(previousIdentity) !== "live") {
|
|
584
|
+
abandonClaim();
|
|
585
|
+
throw new Error("The lead pane this `hive lead` was about to adopt no longer reads as the lead's own (its pane pid changed " +
|
|
586
|
+
"or could not be verified), so nothing was recorded. Re-run `hive lead`; it will start a fresh pane.");
|
|
587
|
+
}
|
|
588
|
+
const recordedPanePid = createdPane ? panePidForRecord(leadPane) : previousPanePid;
|
|
589
|
+
if (recordedPanePid === "") {
|
|
590
|
+
abandonClaim();
|
|
591
|
+
throw new Error(`tmux did not report a process id for the new lead pane ${leadPane} (asked twice), so it was discarded ` +
|
|
592
|
+
"rather than recorded: a lead row with no pane pid can never be verified as owning its pane. Re-run `hive lead`.");
|
|
593
|
+
}
|
|
577
594
|
let wonRace;
|
|
578
595
|
try {
|
|
579
596
|
wonRace = db.transaction(() => {
|
|
580
597
|
const updated = db
|
|
581
|
-
.prepare("UPDATE agents SET tmux_target = ?, tmux_socket = ?, pane_pid = ?, command = ?, codex_home = ?
|
|
582
|
-
|
|
598
|
+
.prepare("UPDATE agents SET tmux_target = ?, tmux_socket = ?, pane_pid = ?, command = ?, codex_home = ? " +
|
|
599
|
+
"WHERE id = ? AND tmux_target = ? AND tmux_socket = ? AND pane_pid = ? AND status = 'running'")
|
|
600
|
+
.run(leadPane, tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR), recordedPanePid, recordedCommand, recordedCodexHome, leadAgentId, casExpected, casSocket, casPid).changes;
|
|
583
601
|
if (updated === 0)
|
|
584
602
|
return false;
|
|
585
603
|
db.prepare(`UPDATE wakes SET deliver_pane = ?, held_at = NULL, held_reason = NULL
|
|
@@ -610,7 +628,7 @@ async function cmdLead(argv) {
|
|
|
610
628
|
}
|
|
611
629
|
if (createdPane) {
|
|
612
630
|
armLeadPaneExitedHook();
|
|
613
|
-
clearLeftoverProcesses(project, config);
|
|
631
|
+
clearLeftoverProcesses(project, config, leftoverIds);
|
|
614
632
|
}
|
|
615
633
|
else if (!leadPaneExitedHookArmed()) {
|
|
616
634
|
console.log(`! this tmux server carries no ${LEAD_PANE_EXITED_HOOK} backstop and hive arms one only on a pane it created, ` +
|
|
@@ -1293,7 +1311,7 @@ function openDashboardOnce(project, dashboardFile) {
|
|
|
1293
1311
|
if (claim.changes === 0)
|
|
1294
1312
|
return;
|
|
1295
1313
|
try {
|
|
1296
|
-
execFileSync("open", [pathToFileURL(dashboardFile).href], { stdio: "ignore", timeout:
|
|
1314
|
+
execFileSync("open", [pathToFileURL(dashboardFile).href], { stdio: "ignore", timeout: 10000 });
|
|
1297
1315
|
}
|
|
1298
1316
|
catch {
|
|
1299
1317
|
db.prepare("DELETE FROM kv WHERE project_id = ? AND key = ?").run(project.id, DASHBOARD_OPENED_KV_KEY);
|
|
@@ -1377,14 +1395,18 @@ function resolveNamedProcess(argv, verb) {
|
|
|
1377
1395
|
console.log(`${name}: not running (start with: hive start "${name}")`);
|
|
1378
1396
|
return null;
|
|
1379
1397
|
}
|
|
1380
|
-
const
|
|
1381
|
-
if (
|
|
1398
|
+
const ownership = observeRowOwnership(row);
|
|
1399
|
+
if (ownership === "probe-failed") {
|
|
1382
1400
|
console.log(CANNOT_TELL_WHERE(name));
|
|
1383
1401
|
return null;
|
|
1384
1402
|
}
|
|
1403
|
+
if (ownership === "unknown") {
|
|
1404
|
+
console.log(`${name}: its pane ownership is unknown (no pane pid recorded, or another tmux socket), so hive will not move that pane`);
|
|
1405
|
+
return null;
|
|
1406
|
+
}
|
|
1385
1407
|
// A pane id alone is not an identity: tmux restarts them at %0 on a fresh server, so a stale
|
|
1386
|
-
// running row names whoever holds that id now.
|
|
1387
|
-
if (
|
|
1408
|
+
// running row names whoever holds that id now.
|
|
1409
|
+
if (ownership !== "live") {
|
|
1388
1410
|
console.log(`${name}: not running (start with: hive start "${name}")`);
|
|
1389
1411
|
return null;
|
|
1390
1412
|
}
|
|
@@ -1466,8 +1488,10 @@ function cmdLeadPaneExited(argv) {
|
|
|
1466
1488
|
}
|
|
1467
1489
|
// Only a lead that CREATED its pane clears leftovers. An adopted live lead is the same lead those
|
|
1468
1490
|
// processes belong to, and stopping them there would take down a running crew's dev server.
|
|
1469
|
-
function clearLeftoverProcesses(project, config) {
|
|
1491
|
+
function clearLeftoverProcesses(project, config, leftoverIds) {
|
|
1470
1492
|
for (const row of runningCommandRows(project.id)) {
|
|
1493
|
+
if (!leftoverIds.has(row.id))
|
|
1494
|
+
continue;
|
|
1471
1495
|
const stopped = stopProcess(row, STOP_REASONS.previousLead);
|
|
1472
1496
|
// Nothing was running under it, so the auto-start line that follows is the whole story.
|
|
1473
1497
|
if (stopped.leg === "already-gone")
|
|
@@ -2793,7 +2817,8 @@ export function heldReasonLabel(heldReason) {
|
|
|
2793
2817
|
if (isUnsubmittedInputHold(heldReason))
|
|
2794
2818
|
return "typing";
|
|
2795
2819
|
if (heldReason === HELD_REASON_LEAD_PANE_DEAD ||
|
|
2796
|
-
|
|
2820
|
+
wasHeldForPaneIdentity(heldReason) ||
|
|
2821
|
+
isLeadRowClosedHold(heldReason) ||
|
|
2797
2822
|
isUnclassifiablePaneHold(heldReason)) {
|
|
2798
2823
|
return "needs you";
|
|
2799
2824
|
}
|
package/dist/help.js
CHANGED
|
@@ -179,6 +179,10 @@ runbook (fork it first), not a pad write.`,
|
|
|
179
179
|
a stale row closed with no kill, stop or typing; a human or peer lead
|
|
180
180
|
only, refused for a row that provably owns a live pane or when tmux
|
|
181
181
|
could not be probed.
|
|
182
|
+
Every kill or type above acts only on a pane whose recorded pid matches.
|
|
183
|
+
Unverifiable ownership (no recorded pid) refuses by name; a gone or
|
|
184
|
+
reissued pane is never touched, and close, park and rename then act
|
|
185
|
+
on the row alone.
|
|
182
186
|
|
|
183
187
|
Address a worker by its name, not its id: agent_send(name="impl", ...). A
|
|
184
188
|
partial name works when it matches one running worker, so name="123" finds
|
|
@@ -316,7 +320,11 @@ place.`,
|
|
|
316
320
|
pad_write(name, content, tags?) — create a pad; names are unique per project
|
|
317
321
|
pad_write(pad_id, name, content, expected_revision) — full overwrite; the
|
|
318
322
|
revision guard makes concurrent writers safe
|
|
319
|
-
pad_read(pad_id | name) — content plus revision and metadata
|
|
323
|
+
pad_read(pad_id | name, offset?, limit?) — content plus revision and metadata
|
|
324
|
+
offset and limit are nonnegative safe integers in UTF-16 code units, with
|
|
325
|
+
no default cap; a cut inside a surrogate pair moves back one unit, or
|
|
326
|
+
forward if moving back would return nothing, and the result reports its
|
|
327
|
+
effective offset
|
|
320
328
|
pad_append(pad_id, content, expected_revision?) — add to the end
|
|
321
329
|
pad_edit(pad_id, old_text, new_text, expected_revision?) — replace one
|
|
322
330
|
unique literal occurrence; include enough context to disambiguate
|
package/dist/leadWatch.js
CHANGED
|
@@ -2,7 +2,7 @@ import { db } from "./db.js";
|
|
|
2
2
|
import { paneClassifierFor } from "./harnesses.js";
|
|
3
3
|
import { readLeadTurnState } from "./leadState.js";
|
|
4
4
|
import { LEAD_KIND } from "./spawn.js";
|
|
5
|
-
import { holdsHumanInput,
|
|
5
|
+
import { foreignSocket, holdsHumanInput, observationFailed, rowOwnership } from "./tmux.js";
|
|
6
6
|
export const LEAD_TARGET_GONE = "LEAD_TARGET_GONE";
|
|
7
7
|
export const LEAD_TARGET_REISSUED = "LEAD_TARGET_REISSUED";
|
|
8
8
|
export const LEAD_TARGET_RESTARTED = "LEAD_TARGET_RESTARTED";
|
|
@@ -39,14 +39,16 @@ export function evaluateLeadWatch(sub, mode, snapshot, readers) {
|
|
|
39
39
|
return { kind: "invalid", reason: LEAD_TARGET_GONE };
|
|
40
40
|
if (lead.pane_pid !== sub.pane_pid)
|
|
41
41
|
return { kind: "invalid", reason: LEAD_TARGET_RESTARTED };
|
|
42
|
-
if (
|
|
43
|
-
return { kind: "pending", reason: "tmux did not answer" };
|
|
44
|
-
const probe = rowAliveProbe(lead.tmux_socket, lead.tmux_target, snapshot);
|
|
45
|
-
if (probe.live === null)
|
|
42
|
+
if (foreignSocket(lead.tmux_socket))
|
|
46
43
|
return { kind: "pending", reason: "its pane cannot be probed from this process" };
|
|
47
|
-
if (
|
|
44
|
+
if (observationFailed(snapshot))
|
|
45
|
+
return { kind: "pending", reason: "tmux did not answer" };
|
|
46
|
+
const ownership = rowOwnership(lead, snapshot);
|
|
47
|
+
if (ownership === "unknown")
|
|
48
|
+
return { kind: "pending", reason: "pane ownership unknown" };
|
|
49
|
+
if (ownership === "gone")
|
|
48
50
|
return { kind: "invalid", reason: LEAD_TARGET_GONE };
|
|
49
|
-
if (
|
|
51
|
+
if (ownership === "reissued")
|
|
50
52
|
return { kind: "invalid", reason: LEAD_TARGET_REISSUED };
|
|
51
53
|
const state = readLeadTurnState(lead.id);
|
|
52
54
|
if (state === null || state.pane_pid !== sub.pane_pid)
|
package/dist/processes.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { getProject } from "./context.js";
|
|
2
2
|
import { db } from "./db.js";
|
|
3
3
|
import { loadProjectYml } from "./projectYml.js";
|
|
4
|
-
import { closeAgentRow, killAgentPane, relayoutAfterPaneLeft } from "./spawn.js";
|
|
5
|
-
import { cancelCopyMode,
|
|
4
|
+
import { closeAgentRow, currentOwnership, killAgentPane, paneIdentity, relayoutAfterPaneLeft } from "./spawn.js";
|
|
5
|
+
import { cancelCopyMode, observeRowOwnership, paneProcessExited, paneVisibility, paneWindow, projectWindows, rowLive, sessionName, tmux, } from "./tmux.js";
|
|
6
6
|
// One derivation for every surface that reports where a project's processes are: `hive status`,
|
|
7
7
|
// `hive doctor`, and the dashboard. Two tmux window lookups per project, then one per running
|
|
8
8
|
// process, and none at all for a project with neither a running command nor a defined one.
|
|
@@ -96,6 +96,19 @@ function waitForPaneToExit(target, timeoutMs) {
|
|
|
96
96
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(STOP_POLL_MS, remaining));
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
|
+
const UNKNOWN_OWNERSHIP_NOTE = "its pane ownership is unknown (no pane pid recorded, a legacy window target, or another tmux socket), so hive left it running";
|
|
100
|
+
const REISSUED_NOTE = "its pane id now belongs to another process, which was left alone";
|
|
101
|
+
const CHANGED_NOTE = "its row was closed or recorded a different pane during the stop, so hive touched nothing further";
|
|
102
|
+
// Unknown, or a row that changed underneath the stop, is left running: closing it would hide a
|
|
103
|
+
// process that may still be running from every surface that reports one.
|
|
104
|
+
function notOwned(row, ownership) {
|
|
105
|
+
if (ownership === "unknown")
|
|
106
|
+
return { name: row.name, leg: "unreachable", note: UNKNOWN_OWNERSHIP_NOTE };
|
|
107
|
+
if (ownership === "changed")
|
|
108
|
+
return { name: row.name, leg: "unreachable", note: CHANGED_NOTE };
|
|
109
|
+
closeAgentRow(row.id, paneIdentity(row));
|
|
110
|
+
return { name: row.name, leg: "already-gone", ...(ownership === "reissued" ? { note: REISSUED_NOTE } : {}) };
|
|
111
|
+
}
|
|
99
112
|
// The only thing that stops a hive.yml command. kill-pane alone is SIGHUP, which a dev server with
|
|
100
113
|
// detached grandchildren survives while still holding its port, so the graceful leg comes first and
|
|
101
114
|
// the receipt says which one ended it.
|
|
@@ -103,19 +116,19 @@ function waitForPaneToExit(target, timeoutMs) {
|
|
|
103
116
|
// argument so every caller has to declare its trigger from the closed set docs/projects.md is
|
|
104
117
|
// checked against, and whichever caller prints it already knows which one it passed.
|
|
105
118
|
export function stopProcess(row, reason) {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (probe.live === null)
|
|
119
|
+
const identity = paneIdentity(row);
|
|
120
|
+
const ownership = observeRowOwnership(identity);
|
|
121
|
+
if (ownership === "probe-failed")
|
|
110
122
|
return { name: row.name, leg: "unreachable" };
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (probe.live === false || paneReissued(row.pane_pid, probe)) {
|
|
114
|
-
closeAgentRow(row.id);
|
|
115
|
-
return { name: row.name, leg: "already-gone" };
|
|
116
|
-
}
|
|
123
|
+
if (ownership !== "live")
|
|
124
|
+
return notOwned(row, ownership);
|
|
117
125
|
markStopping(row.project_id, row.id);
|
|
118
126
|
const window = paneWindow(row.tmux_target);
|
|
127
|
+
const beforeSignal = currentOwnership(row.id, identity);
|
|
128
|
+
if (beforeSignal !== "live") {
|
|
129
|
+
clearStoppingMarker(row.project_id, row.id);
|
|
130
|
+
return notOwned(row, beforeSignal);
|
|
131
|
+
}
|
|
119
132
|
try {
|
|
120
133
|
// In copy mode C-c is `cancel`, not SIGINT, so the graceful leg would silently do nothing to a
|
|
121
134
|
// pane someone scrolled back in. A stop may take that scrollback position: the process is ending.
|
|
@@ -126,6 +139,11 @@ export function stopProcess(row, reason) {
|
|
|
126
139
|
}
|
|
127
140
|
let leg = "interrupted";
|
|
128
141
|
if (!waitForPaneToExit(row.tmux_target, STOP_GRACE_MS)) {
|
|
142
|
+
const beforeKill = currentOwnership(row.id, identity);
|
|
143
|
+
if (beforeKill !== "live") {
|
|
144
|
+
clearStoppingMarker(row.project_id, row.id);
|
|
145
|
+
return notOwned(row, beforeKill);
|
|
146
|
+
}
|
|
129
147
|
try {
|
|
130
148
|
killAgentPane(row.tmux_target);
|
|
131
149
|
}
|
|
@@ -137,7 +155,7 @@ export function stopProcess(row, reason) {
|
|
|
137
155
|
// RUNNING, so the process stays visible to hive status, hive stop and the janitor.
|
|
138
156
|
if (leg === "still-running")
|
|
139
157
|
return { name: row.name, leg };
|
|
140
|
-
closeAgentRow(row.id,
|
|
158
|
+
closeAgentRow(row.id, identity);
|
|
141
159
|
clearStoppingMarker(row.project_id, row.id);
|
|
142
160
|
relayoutAfterPaneLeft(window);
|
|
143
161
|
return { name: row.name, leg };
|
|
@@ -152,9 +170,13 @@ export function stopLine(stopped) {
|
|
|
152
170
|
case "killed":
|
|
153
171
|
return `${stopped.name}: stopped (killed after ${STOP_GRACE_MS / 1000}s)`;
|
|
154
172
|
case "already-gone":
|
|
155
|
-
return
|
|
173
|
+
return stopped.note
|
|
174
|
+
? `${stopped.name}: already gone (${stopped.note})`
|
|
175
|
+
: `${stopped.name}: already gone (nothing was running)`;
|
|
156
176
|
case "unreachable":
|
|
157
|
-
return
|
|
177
|
+
return stopped.note
|
|
178
|
+
? `${stopped.name}: ${stopped.note}`
|
|
179
|
+
: `${stopped.name}: tmux could not be probed, so hive left it running`;
|
|
158
180
|
case "still-running":
|
|
159
181
|
return `${stopped.name}: still running: its pane survived C-c and kill-pane, so hive left the row open`;
|
|
160
182
|
}
|