@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
package/dist/scheduler.js
CHANGED
|
@@ -12,7 +12,7 @@ import { getProjectByPath, listProjects, queenHomeDir } from "./context.js";
|
|
|
12
12
|
import { collectPortfolio } from "./portfolio.js";
|
|
13
13
|
import { QUEEN_GENERATED_MARKER, readQueenBrief, renderQueenDashboard } from "./queenDashboard.js";
|
|
14
14
|
import { COMMAND_KIND, stoppingMarkerLive } from "./processes.js";
|
|
15
|
-
import { closeAgentRow, isLeadActorId, LEAD_ACTOR_PREFIX, LEAD_KIND, reapCodexHomeForClosedAgent } from "./spawn.js";
|
|
15
|
+
import { closeAgentRow, isLeadActorId, LEAD_ACTOR_PREFIX, LEAD_KIND, PaneOwnershipLost, reapCodexHomeForClosedAgent, requireStillOwned, } from "./spawn.js";
|
|
16
16
|
import { awaitingFirstPrompt, awaitingFirstPromptSql } from "./firstPrompt.js";
|
|
17
17
|
import { evaluateLeadWatch, leadSubscription } from "./leadWatch.js";
|
|
18
18
|
import { describeLiveTasks, describeOneTask, liveBackgroundTasks, STALL_BOUND_SECONDS, } from "./backgroundTasks.js";
|
|
@@ -20,7 +20,7 @@ import { MESSAGE_MAX_ROWS, MESSAGE_RETENTION } from "./leadMessage.js";
|
|
|
20
20
|
import { harnessFor, hasTranscriptSignal, paneClassifierFor, screenClassifiable, transcriptDirFor } from "./harnesses.js";
|
|
21
21
|
import { transcriptDir, readContextFill } from "./transcript.js";
|
|
22
22
|
import { ageSecondsSince, describeLastLogEvent, humanizeAge, lastLogEvent, reportsAgentStateLog, } from "./stateProvenance.js";
|
|
23
|
-
import { capturePane, foreignSocket, holdsHumanInput, liveTargets, maskChoiceMarker, paneInCopyMode, paneReissued, rowAlive, rowAliveProbe, rowLiveProbe, renderTrailerView, sanitizeTail, sendText, tailCaptureLines, sleep, tmuxSocketPath, } from "./tmux.js";
|
|
23
|
+
import { capturePane, foreignSocket, holdsHumanInput, liveTargets, maskChoiceMarker, observationFailed, paneInCopyMode, paneReissued, rowAlive, rowAliveProbe, rowLiveProbe, rowOwnership, renderTrailerView, sanitizeTail, sendText, tailCaptureLines, sleep, tmuxSocketPath, } from "./tmux.js";
|
|
24
24
|
import { appendTeardown, NOT_ATTRIBUTED, sqlNow, teardownWindow, TEARDOWN_TRIGGER, } from "./teardown.js";
|
|
25
25
|
export const WATCH_SCOPE_PROJECT = "project";
|
|
26
26
|
const isStandingWatch = (timer) => timer.watch_scope === WATCH_SCOPE_PROJECT;
|
|
@@ -28,6 +28,24 @@ export const DELIVER_SOCKET_JOIN = `LEFT JOIN agents ON agents.id = (
|
|
|
28
28
|
SELECT a.id FROM agents a WHERE a.actor_id = wakes.deliver_actor
|
|
29
29
|
ORDER BY (a.status = 'running') DESC, a.id DESC LIMIT 1
|
|
30
30
|
)`;
|
|
31
|
+
const DELIVER_ROW_COLUMNS = `COALESCE(agents.tmux_socket, '') AS deliver_socket,
|
|
32
|
+
COALESCE(agents.pane_pid, '') AS deliver_pane_pid,
|
|
33
|
+
COALESCE(agents.command, '') AS deliver_command,
|
|
34
|
+
agents.id AS deliver_row_id, agents.status AS deliver_row_status,
|
|
35
|
+
COALESCE(agents.tmux_target, '') AS deliver_row_target`;
|
|
36
|
+
// A row's pid certifies only the pane that row records, never a different deliver_pane.
|
|
37
|
+
const deliveryIdentity = (row) => ({
|
|
38
|
+
tmux_target: row.deliver_pane,
|
|
39
|
+
tmux_socket: row.deliver_socket,
|
|
40
|
+
pane_pid: row.deliver_row_target === row.deliver_pane ? row.deliver_pane_pid : "",
|
|
41
|
+
});
|
|
42
|
+
function deliveryOwnership(row, snapshot) {
|
|
43
|
+
if (row.deliver_row_id === null)
|
|
44
|
+
return "raw";
|
|
45
|
+
if (row.deliver_row_status !== "running")
|
|
46
|
+
return "closed";
|
|
47
|
+
return rowOwnership(deliveryIdentity(row), snapshot);
|
|
48
|
+
}
|
|
31
49
|
export const ACTIVE_TIMER_WHERE = "cancelled_at IS NULL AND (fired_at IS NULL OR repeat_every_ms IS NOT NULL)";
|
|
32
50
|
const SETTLE_WINDOW = "-15 seconds";
|
|
33
51
|
const prepared = new Map();
|
|
@@ -92,7 +110,7 @@ const teardownMember = (row, swept) => ({
|
|
|
92
110
|
// Leads are READ for the roster and never swept: janitor's own query excludes them deliberately, and
|
|
93
111
|
// a crew death that took a lead's pane is exactly the casualty the roster would otherwise omit.
|
|
94
112
|
function goneLeadRows(snapshot) {
|
|
95
|
-
return stmt(`SELECT ${CREW_ROW_COLUMNS} FROM agents WHERE status = 'running' AND kind = ? AND tmux_target != ''`).all(LEAD_KIND).filter((row) =>
|
|
113
|
+
return stmt(`SELECT ${CREW_ROW_COLUMNS} FROM agents WHERE status = 'running' AND kind = ? AND tmux_target != ''`).all(LEAD_KIND).filter((row) => ["gone", "reissued"].includes(rowOwnership(row, snapshot)));
|
|
96
114
|
}
|
|
97
115
|
function recordTeardown(snapshot, swept) {
|
|
98
116
|
try {
|
|
@@ -156,11 +174,11 @@ function reportDeadProcess(agent, snapshot) {
|
|
|
156
174
|
// interrupted between the two is not reported as a crash.
|
|
157
175
|
if (stoppingMarkerLive(agent.project_id, agent.id))
|
|
158
176
|
return;
|
|
159
|
-
const lead = stmt(`SELECT actor_id, tmux_target, tmux_socket FROM agents
|
|
177
|
+
const lead = stmt(`SELECT actor_id, tmux_target, tmux_socket, pane_pid FROM agents
|
|
160
178
|
WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id DESC LIMIT 1`).get(agent.project_id, LEAD_KIND);
|
|
161
179
|
// Never mint a notice hive could not deliver: with no live lead pane there is nobody this could
|
|
162
180
|
// ever reach, and the row would sit in wakes forever.
|
|
163
|
-
if (!lead?.tmux_target ||
|
|
181
|
+
if (!lead?.tmux_target || rowOwnership(lead, snapshot) !== "live")
|
|
164
182
|
return;
|
|
165
183
|
insertNotice({ project_id: agent.project_id, owner: lead.actor_id }, lead.actor_id, lead.tmux_target, deadProcessBody(agent.name), null);
|
|
166
184
|
}
|
|
@@ -176,9 +194,7 @@ export function reportRunningBuildChange(snapshot) {
|
|
|
176
194
|
return;
|
|
177
195
|
const lead = stmt(`SELECT project_id, actor_id, tmux_target, tmux_socket, pane_pid, command FROM agents
|
|
178
196
|
WHERE actor_id = ? AND kind = ? AND status = 'running'`).get(actor, LEAD_KIND);
|
|
179
|
-
if (!lead?.tmux_target || !screenClassifiable(lead.command) ||
|
|
180
|
-
rowAlive(lead.tmux_socket, lead.tmux_target, snapshot) !== true ||
|
|
181
|
-
paneReissued(lead.pane_pid, rowAliveProbe(lead.tmux_socket, lead.tmux_target, snapshot)))
|
|
197
|
+
if (!lead?.tmux_target || !screenClassifiable(lead.command) || rowOwnership(lead, snapshot) !== "live")
|
|
182
198
|
return;
|
|
183
199
|
insertNotice({ project_id: lead.project_id, owner: lead.actor_id }, lead.actor_id, lead.tmux_target, runningBuildNotice(change), null);
|
|
184
200
|
reportedBuilds.add(change.disk.build_id);
|
|
@@ -220,15 +236,19 @@ export function janitor(snapshot = liveTargets()) {
|
|
|
220
236
|
// recording that as a teardown manufactures an incident out of an unset PATH.
|
|
221
237
|
const sawARealServer = snapshot.serverAnswered !== false;
|
|
222
238
|
const teardown = sawARealServer && snapshot.panes.size === 0 && swept.length > 0 ? recordTeardown(snapshot, swept) : undefined;
|
|
223
|
-
const wakes = stmt(`SELECT wakes.id, wakes.due_at, wakes.held_reason, wakes.deliver_pane,
|
|
224
|
-
COALESCE(agents.tmux_socket, '') AS deliver_socket
|
|
239
|
+
const wakes = stmt(`SELECT wakes.id, wakes.due_at, wakes.held_reason, wakes.deliver_pane, wakes.deliver_actor, ${DELIVER_ROW_COLUMNS}
|
|
225
240
|
FROM wakes ${DELIVER_SOCKET_JOIN}
|
|
226
241
|
WHERE ${ACTIVE_TIMER_WHERE} AND wakes.deliver_actor NOT LIKE ?
|
|
227
242
|
AND wakes.created_at < datetime('now', ?)`).all(`${LEAD_ACTOR_PREFIX}%`, SETTLE_WINDOW);
|
|
228
243
|
for (const timer of wakes) {
|
|
244
|
+
if (deliveryOwnership(timer, snapshot) === "closed") {
|
|
245
|
+
cancelWithReason(timer.id, HELD_REASON_ACTOR_CLOSED);
|
|
246
|
+
cancelledTimers += 1;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
229
249
|
if (rowAlive(timer.deliver_socket, timer.deliver_pane, snapshot) === false) {
|
|
230
|
-
if (
|
|
231
|
-
holdTimer(timer,
|
|
250
|
+
if (wasHeldForPaneIdentity(timer.held_reason)) {
|
|
251
|
+
holdTimer(timer, identityThenDead(timer.held_reason));
|
|
232
252
|
}
|
|
233
253
|
else {
|
|
234
254
|
cancelTimer(timer.id);
|
|
@@ -247,6 +267,9 @@ export function janitor(snapshot = liveTargets()) {
|
|
|
247
267
|
function cancelTimer(timerId) {
|
|
248
268
|
stmt("UPDATE wakes SET cancelled_at = datetime('now') WHERE id = ?").run(timerId);
|
|
249
269
|
}
|
|
270
|
+
function cancelWithReason(timerId, reason) {
|
|
271
|
+
bestEffortRun("UPDATE wakes SET cancelled_at = datetime('now'), held_reason = ? WHERE id = ? AND cancelled_at IS NULL", reason, timerId);
|
|
272
|
+
}
|
|
250
273
|
export const LOG_RETENTION = "-7 days";
|
|
251
274
|
const LOG_MAX_ROWS = 20_000;
|
|
252
275
|
function pruneStateLog() {
|
|
@@ -469,9 +492,7 @@ export async function tick(snapshot) {
|
|
|
469
492
|
maybeBackupHourly(db, dataDir);
|
|
470
493
|
maybeGenerateDashboards();
|
|
471
494
|
const now = stmt("SELECT datetime('now') AS now").get().now;
|
|
472
|
-
const candidates = stmt(`SELECT wakes.*,
|
|
473
|
-
COALESCE(agents.pane_pid, '') AS deliver_pane_pid,
|
|
474
|
-
COALESCE(agents.command, '') AS deliver_command
|
|
495
|
+
const candidates = stmt(`SELECT wakes.*, ${DELIVER_ROW_COLUMNS}
|
|
475
496
|
FROM wakes ${DELIVER_SOCKET_JOIN}
|
|
476
497
|
WHERE wakes.cancelled_at IS NULL AND (
|
|
477
498
|
(wakes.kind = 'delay' AND wakes.due_at <= datetime('now')
|
|
@@ -567,6 +588,28 @@ const HELD_REASON_PANE_REISSUED_THEN_DEAD = `${HELD_REASON_PANE_REISSUED_PREFIX}
|
|
|
567
588
|
export function wasHeldForPaneReissue(heldReason) {
|
|
568
589
|
return heldReason != null && heldReason.startsWith(HELD_REASON_PANE_REISSUED_PREFIX);
|
|
569
590
|
}
|
|
591
|
+
const HELD_REASON_PANE_UNKNOWN_PREFIX = "pane ownership for this wake's row cannot be verified (no pane pid was recorded for it, its row now " +
|
|
592
|
+
"records a different pane, or the pane is on another tmux socket); held rather than typed into a pane " +
|
|
593
|
+
"that may not be its own - ";
|
|
594
|
+
const HELD_REASON_PANE_UNKNOWN_LEAD = `${HELD_REASON_PANE_UNKNOWN_PREFIX}run \`hive lead\` to start or adopt a lead with a recorded pane and re-point it`;
|
|
595
|
+
const HELD_REASON_PANE_UNKNOWN_WORKER = `${HELD_REASON_PANE_UNKNOWN_PREFIX}nothing re-points a worker's wake automatically, so cancel it with ` +
|
|
596
|
+
"wake_cancel (any running lead may, even though the wake is not theirs), or leave it: it keeps holding " +
|
|
597
|
+
"rather than deliver wrongly";
|
|
598
|
+
const HELD_REASON_PANE_UNKNOWN_THEN_DEAD = `${HELD_REASON_PANE_UNKNOWN_PREFIX}that pane has since gone dead too; nothing re-points a worker's wake ` +
|
|
599
|
+
"automatically, so cancel it with wake_cancel (any running lead may) if it is no longer needed";
|
|
600
|
+
// One family for both identity holds, so a later dead pane re-holds them instead of cancelling.
|
|
601
|
+
export function wasHeldForPaneIdentity(heldReason) {
|
|
602
|
+
return wasHeldForPaneReissue(heldReason) || (heldReason?.startsWith(HELD_REASON_PANE_UNKNOWN_PREFIX) ?? false);
|
|
603
|
+
}
|
|
604
|
+
const identityThenDead = (heldReason) => wasHeldForPaneReissue(heldReason) ? HELD_REASON_PANE_REISSUED_THEN_DEAD : HELD_REASON_PANE_UNKNOWN_THEN_DEAD;
|
|
605
|
+
export const HELD_REASON_ACTOR_CLOSED = "delivery actor closed: the row this wake was bound to is closed, so nothing was typed and the wake was " +
|
|
606
|
+
"cancelled rather than delivered into a pane that row no longer owns";
|
|
607
|
+
const HELD_REASON_LEAD_ROW_CLOSED = "the lead's row is closed, so no pane is this wake's to type into; held until `hive lead` starts or adopts a " +
|
|
608
|
+
"lead and re-points it";
|
|
609
|
+
export function isLeadRowClosedHold(heldReason) {
|
|
610
|
+
return heldReason === HELD_REASON_LEAD_ROW_CLOSED;
|
|
611
|
+
}
|
|
612
|
+
const HELD_REASON_OWNERSHIP_LOST_AFTER_CLAIM = "delivery stopped after claim: ";
|
|
570
613
|
export const HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX = "the pane's harness is not one hive can classify; ";
|
|
571
614
|
export const HELD_REASON_UNCLASSIFIABLE_PANE = `${HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX}neither the dialog guard nor the unsubmitted-text ` +
|
|
572
615
|
"guard can read this screen and delivery would type blind. wake_set and wake_when_idle refuse such a " +
|
|
@@ -599,13 +642,14 @@ export const HELD_REASON_CONVERSATION = "a human talked to this lead more recent
|
|
|
599
642
|
const CONVERSATION_HOLD_TTL_SECONDS = 5 * 60;
|
|
600
643
|
const CONVERSATION_HOLD_TTL = `-${CONVERSATION_HOLD_TTL_SECONDS} seconds`;
|
|
601
644
|
// Must stay well under NOTICE_MAX_AGE, past which noticeDisposition cancels a held notice rather than
|
|
602
|
-
// typing stale news (it says so in a replacement wake; it is not silent). Measured against due_at, not first_held_at: cli.ts's hive-lead
|
|
645
|
+
// typing stale news (it says so in a replacement wake; it is not silent). Measured against due_at (idleCeilingStart for an idle wake), not first_held_at: cli.ts's hive-lead
|
|
603
646
|
// re-point clears held_at (and so first_held_at) on every ordinary reattach, which would otherwise
|
|
604
647
|
// launder this ceiling indefinitely.
|
|
605
648
|
const CONVERSATION_HOLD_MAX = "-15 minutes";
|
|
606
|
-
function conversationHoldsWake(timer) {
|
|
607
|
-
|
|
608
|
-
|
|
649
|
+
function conversationHoldsWake(timer, snapshot) {
|
|
650
|
+
const clockStart = timer.due_at ?? idleCeilingStart(timer, snapshot);
|
|
651
|
+
if (clockStart !== null) {
|
|
652
|
+
const withinCeiling = stmt(`SELECT ? >= datetime('now', ?) AS within`).get(clockStart, CONVERSATION_HOLD_MAX);
|
|
609
653
|
if (!withinCeiling.within)
|
|
610
654
|
return false;
|
|
611
655
|
}
|
|
@@ -671,11 +715,11 @@ function standingBlockNoticeBody(timer, names, observedAt) {
|
|
|
671
715
|
return lines.join("\n");
|
|
672
716
|
}
|
|
673
717
|
function ownerPaneIfLive(timer, snapshot) {
|
|
674
|
-
const row = stmt(`SELECT tmux_target, tmux_socket FROM agents WHERE actor_id = ? AND status = 'running'
|
|
718
|
+
const row = stmt(`SELECT tmux_target, tmux_socket, pane_pid FROM agents WHERE actor_id = ? AND status = 'running'
|
|
675
719
|
ORDER BY id DESC LIMIT 1`).get(timer.owner);
|
|
676
720
|
if (!row?.tmux_target || snapshot === null)
|
|
677
721
|
return null;
|
|
678
|
-
return
|
|
722
|
+
return rowOwnership(row, snapshot) === "live" ? row.tmux_target : null;
|
|
679
723
|
}
|
|
680
724
|
function blockNoticeTarget(timer, snapshot) {
|
|
681
725
|
const pane = ownerPaneIfLive(timer, snapshot);
|
|
@@ -813,7 +857,7 @@ const standingBlockedRows = (timer, tellActor) => stmt(`SELECT ${CREW_COLUMNS},
|
|
|
813
857
|
function blockedWatchedAgents(timer, snapshot, tellActor) {
|
|
814
858
|
const rows = isStandingWatch(timer) ? standingBlockedRows(timer, tellActor) : oneShotBlockedRows(timer);
|
|
815
859
|
return rows
|
|
816
|
-
.filter((r) =>
|
|
860
|
+
.filter((r) => rowOwnership(r, snapshot) === "live")
|
|
817
861
|
.map((r) => ({
|
|
818
862
|
id: r.id,
|
|
819
863
|
name: r.name,
|
|
@@ -889,7 +933,7 @@ const unreported = (condition, episode) => `NOT EXISTS (
|
|
|
889
933
|
WHERE n.wake_id = ? AND n.agent_id = a.id AND n.condition = '${condition}' AND n.episode = ${episode}
|
|
890
934
|
AND NOT (nt.fired_at IS NOT NULL AND nt.typed_at IS NULL AND nt.cancelled_at IS NULL
|
|
891
935
|
AND nt.fired_at < datetime('now', '${NOTICE_RETRY_AFTER}')))`;
|
|
892
|
-
const CREW_COLUMNS = `a.id, a.name, a.actor_id, a.tmux_target, a.tmux_socket, a.agent_state,
|
|
936
|
+
const CREW_COLUMNS = `a.id, a.name, a.actor_id, a.tmux_target, a.tmux_socket, a.pane_pid, a.agent_state,
|
|
893
937
|
a.state_changed_at, a.status, a.command, a.kind, a.resumed_at, a.closed_at,
|
|
894
938
|
a.cwd, a.session_id, a.transcript_path`;
|
|
895
939
|
// Scopes a standing watch to the OWNER's own crew (bound to owner, not deliver_actor - a watch can
|
|
@@ -1201,7 +1245,7 @@ function noteStandingTransitions(timer, snapshot) {
|
|
|
1201
1245
|
for (const row of standingIdleRows(timer)) {
|
|
1202
1246
|
if (row.tmux_target === pane)
|
|
1203
1247
|
continue;
|
|
1204
|
-
if (
|
|
1248
|
+
if (rowOwnership(row, snapshot) !== "live")
|
|
1205
1249
|
continue;
|
|
1206
1250
|
if (!idleIsAFreshTransition(timer.id, row))
|
|
1207
1251
|
continue;
|
|
@@ -1321,7 +1365,7 @@ function noteStalledCrew(timer, snapshot, choices) {
|
|
|
1321
1365
|
if (row.agent_state === "waiting") {
|
|
1322
1366
|
if (snapshot === null)
|
|
1323
1367
|
continue;
|
|
1324
|
-
if (
|
|
1368
|
+
if (rowOwnership(row, snapshot) !== "live")
|
|
1325
1369
|
continue;
|
|
1326
1370
|
if (awaitingChoice(row.tmux_target, row.command, choices) !== false)
|
|
1327
1371
|
continue;
|
|
@@ -1414,33 +1458,63 @@ const ageOutNotice = db.transaction((timer) => {
|
|
|
1414
1458
|
return;
|
|
1415
1459
|
insertNotice(timer, timer.deliver_actor, timer.deliver_pane, agedOutBody(timer), null);
|
|
1416
1460
|
});
|
|
1461
|
+
function freshDeliveryRow(timerId) {
|
|
1462
|
+
return stmt(`SELECT wakes.deliver_actor, wakes.deliver_pane, ${DELIVER_ROW_COLUMNS}
|
|
1463
|
+
FROM wakes ${DELIVER_SOCKET_JOIN} WHERE wakes.id = ? AND wakes.cancelled_at IS NULL`).get(timerId);
|
|
1464
|
+
}
|
|
1465
|
+
// Applies the hold, cancel or wait for a row-bound wake whose row does not own deliver_pane.
|
|
1466
|
+
function refuseUnowned(timer, ownership, snapshot) {
|
|
1467
|
+
const lead = isLeadActorId(timer.deliver_actor);
|
|
1468
|
+
switch (ownership) {
|
|
1469
|
+
case "closed":
|
|
1470
|
+
if (lead)
|
|
1471
|
+
holdTimer(timer, HELD_REASON_LEAD_ROW_CLOSED);
|
|
1472
|
+
else
|
|
1473
|
+
cancelWithReason(timer.id, HELD_REASON_ACTOR_CLOSED);
|
|
1474
|
+
return;
|
|
1475
|
+
case "unknown":
|
|
1476
|
+
// A failed observation re-evaluates next tick; it is never read as gone.
|
|
1477
|
+
if (!foreignSocket(timer.deliver_socket) && observationFailed(snapshot))
|
|
1478
|
+
return;
|
|
1479
|
+
holdTimer(timer, lead ? HELD_REASON_PANE_UNKNOWN_LEAD : HELD_REASON_PANE_UNKNOWN_WORKER);
|
|
1480
|
+
return;
|
|
1481
|
+
case "reissued":
|
|
1482
|
+
holdTimer(timer, lead ? HELD_REASON_PANE_REISSUED_LEAD : HELD_REASON_PANE_REISSUED_WORKER);
|
|
1483
|
+
return;
|
|
1484
|
+
case "gone":
|
|
1485
|
+
if (lead)
|
|
1486
|
+
holdTimer(timer, HELD_REASON_LEAD_PANE_DEAD);
|
|
1487
|
+
else if (wasHeldForPaneIdentity(timer.held_reason))
|
|
1488
|
+
holdTimer(timer, identityThenDead(timer.held_reason));
|
|
1489
|
+
else
|
|
1490
|
+
cancelTimer(timer.id);
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1417
1494
|
function deliverable(timer, snapshot, choices) {
|
|
1418
|
-
const
|
|
1419
|
-
|
|
1420
|
-
: rowLiveProbe(timer.deliver_socket, timer.deliver_pane);
|
|
1421
|
-
const live = probe.live;
|
|
1422
|
-
if (live === null)
|
|
1495
|
+
const row = freshDeliveryRow(timer.id);
|
|
1496
|
+
if (!row || row.deliver_pane !== timer.deliver_pane)
|
|
1423
1497
|
return { ok: false };
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
}
|
|
1498
|
+
let owner = null;
|
|
1499
|
+
const ownership = deliveryOwnership(row, snapshot);
|
|
1500
|
+
if (ownership === "raw") {
|
|
1501
|
+
const probe = snapshot
|
|
1502
|
+
? rowAliveProbe(row.deliver_socket, row.deliver_pane, snapshot)
|
|
1503
|
+
: rowLiveProbe(row.deliver_socket, row.deliver_pane);
|
|
1504
|
+
if (probe.live === null)
|
|
1505
|
+
return { ok: false };
|
|
1506
|
+
if (!probe.live) {
|
|
1507
|
+
refuseUnowned(timer, "gone", snapshot);
|
|
1508
|
+
return { ok: false };
|
|
1435
1509
|
}
|
|
1436
|
-
return { ok: false };
|
|
1437
1510
|
}
|
|
1438
|
-
if (
|
|
1439
|
-
|
|
1440
|
-
? HELD_REASON_PANE_REISSUED_LEAD
|
|
1441
|
-
: HELD_REASON_PANE_REISSUED_WORKER);
|
|
1511
|
+
else if (ownership !== "live") {
|
|
1512
|
+
refuseUnowned(timer, ownership, snapshot);
|
|
1442
1513
|
return { ok: false };
|
|
1443
1514
|
}
|
|
1515
|
+
else {
|
|
1516
|
+
owner = { rowId: row.deliver_row_id, identity: deliveryIdentity(row) };
|
|
1517
|
+
}
|
|
1444
1518
|
// Above both pane reads: this population must not be captured at all, and the verdicts would be
|
|
1445
1519
|
// wrong in opposite directions if they were.
|
|
1446
1520
|
if (!screenClassifiable(timer.deliver_command)) {
|
|
@@ -1469,15 +1543,11 @@ function deliverable(timer, snapshot, choices) {
|
|
|
1469
1543
|
// so it sits after them. No notice-claim of its own: the human being held for is the human at the
|
|
1470
1544
|
// keyboard, and the statusline is what tells them (see the two dead-ends on notice-claim latching
|
|
1471
1545
|
// and on splitting a hold reason in two).
|
|
1472
|
-
if (isLeadActorId(timer.deliver_actor) && conversationHoldsWake(timer)) {
|
|
1546
|
+
if (isLeadActorId(timer.deliver_actor) && conversationHoldsWake(timer, snapshot)) {
|
|
1473
1547
|
holdTimer(timer, HELD_REASON_CONVERSATION);
|
|
1474
1548
|
return { ok: false };
|
|
1475
1549
|
}
|
|
1476
|
-
const pid =
|
|
1477
|
-
? "no-fact"
|
|
1478
|
-
: probe.pid === timer.deliver_pane_pid
|
|
1479
|
-
? "ok"
|
|
1480
|
-
: "reissued";
|
|
1550
|
+
const pid = owner === null ? "no-fact" : "ok";
|
|
1481
1551
|
const dialogSeen = dialog === false ? "no" : "unknown";
|
|
1482
1552
|
const boxState = cacheEntry(timer.deliver_pane, choices).box;
|
|
1483
1553
|
const box = boxState === undefined ? "unknown" : boxState === null ? "absent" : boxState.state;
|
|
@@ -1485,6 +1555,7 @@ function deliverable(timer, snapshot, choices) {
|
|
|
1485
1555
|
ok: true,
|
|
1486
1556
|
typedSeen: `live=yes pid=${pid} dialog=${dialogSeen} box=${box}`,
|
|
1487
1557
|
firstHeldAt: timer.held_at != null ? timer.first_held_at : null,
|
|
1558
|
+
owner,
|
|
1488
1559
|
};
|
|
1489
1560
|
}
|
|
1490
1561
|
const PANE_CLAIM_TTL_SECONDS = 6;
|
|
@@ -1557,7 +1628,7 @@ async function fireDelayClaimed(timer, snapshot, choices) {
|
|
|
1557
1628
|
}
|
|
1558
1629
|
if (!claimed)
|
|
1559
1630
|
return false;
|
|
1560
|
-
await deliver(timer, "", choices, decision.typedSeen, decision.firstHeldAt);
|
|
1631
|
+
await deliver(timer, "", choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
|
|
1561
1632
|
return true;
|
|
1562
1633
|
}
|
|
1563
1634
|
function claimOneShot(timer) {
|
|
@@ -1573,10 +1644,10 @@ function watchedStates(timer, snapshot) {
|
|
|
1573
1644
|
const agent = stmt(`SELECT *, created_at < datetime('now', ?) AS settled FROM agents WHERE id = ?`).get(SETTLE_WINDOW, id);
|
|
1574
1645
|
if (!agent || agent.status !== "running")
|
|
1575
1646
|
return GONE;
|
|
1576
|
-
const
|
|
1577
|
-
if (
|
|
1647
|
+
const ownership = rowOwnership(agent, snapshot);
|
|
1648
|
+
if (ownership === "unknown")
|
|
1578
1649
|
return UNKNOWN;
|
|
1579
|
-
if (
|
|
1650
|
+
if (ownership !== "live") {
|
|
1580
1651
|
if (!agent.settled)
|
|
1581
1652
|
return UNKNOWN;
|
|
1582
1653
|
return GONE;
|
|
@@ -1594,6 +1665,28 @@ function idleConditionMet(timer, snapshot) {
|
|
|
1594
1665
|
? states.some((s) => s.gone || (s.idle && s.since != null && s.since >= timer.created_at))
|
|
1595
1666
|
: states.length > 0 && states.every((s) => s.idle);
|
|
1596
1667
|
}
|
|
1668
|
+
// An idle wake has no due_at, so its ceiling clock is the moment its condition was met (the watched
|
|
1669
|
+
// worker's state_changed_at, latest for idle_all, earliest qualifying for idle_any), or max_wait_at
|
|
1670
|
+
// once it has timed out unmet. A met condition with no such moment (a gone worker) falls back to
|
|
1671
|
+
// first_held_at.
|
|
1672
|
+
function idleCeilingStart(timer, snapshot) {
|
|
1673
|
+
if (timer.kind !== "idle_any" && timer.kind !== "idle_all")
|
|
1674
|
+
return null;
|
|
1675
|
+
if (snapshot === null || !idleConditionMet(timer, snapshot)) {
|
|
1676
|
+
if (timer.max_wait_at === null)
|
|
1677
|
+
return null;
|
|
1678
|
+
const timedOut = stmt(`SELECT ? <= datetime('now') AS out`).get(timer.max_wait_at);
|
|
1679
|
+
return timedOut.out ? timer.max_wait_at : null;
|
|
1680
|
+
}
|
|
1681
|
+
const states = watchedStates(timer, snapshot);
|
|
1682
|
+
const sinces = states
|
|
1683
|
+
.filter((s) => (timer.kind === "idle_any" ? s.idle && !s.gone && s.since != null && s.since >= timer.created_at : true))
|
|
1684
|
+
.map((s) => s.since);
|
|
1685
|
+
const met = sinces.length > 0 && sinces.every((v) => v != null) ? sinces : null;
|
|
1686
|
+
if (met !== null)
|
|
1687
|
+
return timer.kind === "idle_any" ? met.reduce((a, b) => (a < b ? a : b)) : met.reduce((a, b) => (a > b ? a : b));
|
|
1688
|
+
return timer.held_at != null ? timer.first_held_at : null;
|
|
1689
|
+
}
|
|
1597
1690
|
async function maybeFireIdle(timer, snapshot, now, choices) {
|
|
1598
1691
|
const timedOut = timer.max_wait_at != null && timer.max_wait_at <= now;
|
|
1599
1692
|
if (isStandingWatch(timer)) {
|
|
@@ -1606,7 +1699,7 @@ async function maybeFireIdle(timer, snapshot, now, choices) {
|
|
|
1606
1699
|
const decision = deliverable(timer, snapshot, choices);
|
|
1607
1700
|
if (!decision.ok || !claimOneShot(timer))
|
|
1608
1701
|
return false;
|
|
1609
|
-
await deliver(timer, STANDING_EXPIRED_NOTE, choices, decision.typedSeen, decision.firstHeldAt);
|
|
1702
|
+
await deliver(timer, STANDING_EXPIRED_NOTE, choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
|
|
1610
1703
|
return true;
|
|
1611
1704
|
});
|
|
1612
1705
|
}
|
|
@@ -1632,7 +1725,7 @@ async function maybeFireIdle(timer, snapshot, now, choices) {
|
|
|
1632
1725
|
const decision = deliverable(timer, snapshot, choices);
|
|
1633
1726
|
if (!decision.ok || !claimOneShot(timer))
|
|
1634
1727
|
return false;
|
|
1635
|
-
await deliver(timer, timedOut && !idleMet ? "max wait reached" : "", choices, decision.typedSeen, decision.firstHeldAt);
|
|
1728
|
+
await deliver(timer, timedOut && !idleMet ? "max wait reached" : "", choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
|
|
1636
1729
|
return true;
|
|
1637
1730
|
});
|
|
1638
1731
|
}
|
|
@@ -1662,7 +1755,7 @@ async function maybeFireLeadWatch(timer, sub, snapshot, now, choices) {
|
|
|
1662
1755
|
if (result.kind === "invalid") {
|
|
1663
1756
|
bestEffortRun("UPDATE lead_idle_subscriptions SET terminal_reason = ? WHERE wake_id = ?", result.reason, timer.id);
|
|
1664
1757
|
}
|
|
1665
|
-
await deliver(timer, note, choices, decision.typedSeen, decision.firstHeldAt);
|
|
1758
|
+
await deliver(timer, note, choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
|
|
1666
1759
|
return true;
|
|
1667
1760
|
});
|
|
1668
1761
|
}
|
|
@@ -1700,7 +1793,7 @@ function watchedTail(timer) {
|
|
|
1700
1793
|
return "";
|
|
1701
1794
|
const shown = [];
|
|
1702
1795
|
for (const id of ids.slice(0, TAIL_AGENTS)) {
|
|
1703
|
-
const agent = stmt("SELECT name, tmux_target, tmux_socket, agent_state, state_changed_at, status, actor_id, command, kind, cwd, session_id, transcript_path FROM agents WHERE id = ?").get(id);
|
|
1796
|
+
const agent = stmt("SELECT name, tmux_target, tmux_socket, pane_pid, agent_state, state_changed_at, status, actor_id, command, kind, cwd, session_id, transcript_path FROM agents WHERE id = ?").get(id);
|
|
1704
1797
|
if (!agent)
|
|
1705
1798
|
continue;
|
|
1706
1799
|
const context = contextClause(agent);
|
|
@@ -1713,6 +1806,12 @@ function watchedTail(timer) {
|
|
|
1713
1806
|
"socket than this process, so it cannot honestly be read from here.");
|
|
1714
1807
|
continue;
|
|
1715
1808
|
}
|
|
1809
|
+
const ownership = rowOwnership(agent);
|
|
1810
|
+
if (ownership !== "live") {
|
|
1811
|
+
shown.push(`${agent.name} (hive state now: ${stateNowClause(agent)}${context}): its pane ownership reads ${ownership}, ` +
|
|
1812
|
+
"so its terminal was not read.");
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1716
1815
|
let tail = "";
|
|
1717
1816
|
try {
|
|
1718
1817
|
const view = paneClassifierFor(agent.command)?.trailerView(agent.tmux_target) ?? null;
|
|
@@ -1786,7 +1885,14 @@ export function shortRenderForLeadDelivery(timer) {
|
|
|
1786
1885
|
return null;
|
|
1787
1886
|
return standingNoticeBodyShort(timer, candidates, timer.id);
|
|
1788
1887
|
}
|
|
1789
|
-
|
|
1888
|
+
// The claim already happened, so this records why and never re-arms or replays the wake.
|
|
1889
|
+
function recordLostAfterClaim(timer, pasted, why) {
|
|
1890
|
+
bestEffortRun(`UPDATE wakes SET held_reason = ?,
|
|
1891
|
+
typed_at = CASE WHEN ? = 1 THEN strftime('%Y-%m-%d %H:%M:%f', 'now') ELSE typed_at END,
|
|
1892
|
+
cancelled_at = CASE WHEN repeat_every_ms IS NULL THEN datetime('now') ELSE cancelled_at END
|
|
1893
|
+
WHERE id = ?`, HELD_REASON_OWNERSHIP_LOST_AFTER_CLAIM + why + (pasted ? " The body was pasted; no Enter was sent." : ""), pasted ? 1 : 0, timer.id);
|
|
1894
|
+
}
|
|
1895
|
+
async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner) {
|
|
1790
1896
|
const tail = watchedTail(timer);
|
|
1791
1897
|
const prefix = `[hive wake #${timer.id}${note ? `, ${note}` : ""}] `;
|
|
1792
1898
|
const body = (isLeadActorId(timer.deliver_actor) ? shortRenderForLeadDelivery(timer) : null) ?? timer.body;
|
|
@@ -1803,8 +1909,19 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt) {
|
|
|
1803
1909
|
WHERE id = ?`, typedBusy, typedSeen, firstHeldAt, timer.id);
|
|
1804
1910
|
const box = cacheEntry(timer.deliver_pane, choices).box;
|
|
1805
1911
|
const strandedTextWouldHold = box !== undefined && box !== null && box.state !== "unknown";
|
|
1912
|
+
let pasted = false;
|
|
1806
1913
|
try {
|
|
1807
|
-
await sendText(timer.deliver_pane, prefix + body + noticeStalenessNote(timer) + tail, true,
|
|
1914
|
+
await sendText(timer.deliver_pane, prefix + body + noticeStalenessNote(timer) + tail, true, () => {
|
|
1915
|
+
pasted = true;
|
|
1916
|
+
if (strandedTextWouldHold)
|
|
1917
|
+
recordTyped();
|
|
1918
|
+
}, undefined, owner ? () => requireStillOwned(owner.rowId, owner.identity, `wake #${timer.id} delivery`) : undefined);
|
|
1919
|
+
}
|
|
1920
|
+
catch (err) {
|
|
1921
|
+
if (!(err instanceof PaneOwnershipLost))
|
|
1922
|
+
throw err;
|
|
1923
|
+
recordLostAfterClaim(timer, pasted, err.message);
|
|
1924
|
+
return;
|
|
1808
1925
|
}
|
|
1809
1926
|
finally {
|
|
1810
1927
|
forgetPaneAnswers(timer.deliver_pane, choices);
|
package/dist/spawn.js
CHANGED
|
@@ -4,7 +4,7 @@ import { preservedRolloutPath, reapCodexHome } from "./codexHome.js";
|
|
|
4
4
|
import { getProject } from "./context.js";
|
|
5
5
|
import { dataDir, db } from "./db.js";
|
|
6
6
|
import { loadProjectYml } from "./projectYml.js";
|
|
7
|
-
import { applyLayout, claimInitialWindow, createWindow, DEFAULT_LAYOUT, ensureSession, crossServerRefusal, findProcessesWindow, findProjectWindow, isPaneTarget,
|
|
7
|
+
import { applyLayout, claimInitialWindow, createWindow, DEFAULT_LAYOUT, ensureSession, crossServerRefusal, findProcessesWindow, findProjectWindow, isPaneTarget, panePidForRecord, paneWindow, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, retainOnExitArgs, rowLive, sessionName, setPaneTitle, makeProcessesWindow, tmux, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, windowOwner, windowTitle, rowOwnership, } from "./tmux.js";
|
|
8
8
|
export function withWindowClaim(claim) {
|
|
9
9
|
if (db.inTransaction) {
|
|
10
10
|
throw new Error("withWindowClaim was called from inside an already-open transaction. better-sqlite3 nests a " +
|
|
@@ -51,10 +51,18 @@ export function agentIdentityEnv(actorId, name, projectPath) {
|
|
|
51
51
|
HIVE_LEAD: "",
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
|
-
function recordPane(agentId, target, socket) {
|
|
54
|
+
function recordPane(agentId, target, socket, pid) {
|
|
55
55
|
return (db
|
|
56
56
|
.prepare("UPDATE agents SET tmux_target = ?, tmux_socket = ?, pane_pid = ? WHERE id = ? AND status = 'running'")
|
|
57
|
-
.run(target, socket,
|
|
57
|
+
.run(target, socket, pid, agentId).changes > 0);
|
|
58
|
+
}
|
|
59
|
+
function readPidOrDiscard(target) {
|
|
60
|
+
const pid = panePidForRecord(target);
|
|
61
|
+
if (pid !== "")
|
|
62
|
+
return pid;
|
|
63
|
+
discardOrphanedPane(target);
|
|
64
|
+
throw new Error(`tmux did not report a process id for the new pane ${target} (asked twice), so it was discarded rather than ` +
|
|
65
|
+
"recorded: a row with no pane pid can never be verified as owning its pane. Retry.");
|
|
58
66
|
}
|
|
59
67
|
// Two callers, one meaning: whatever is left in the window re-tiles to the layout it was arranged
|
|
60
68
|
// with. agent_close kills a worker's pane through this; stopProcess (src/processes.ts) is the only
|
|
@@ -186,8 +194,9 @@ export function launchAgent(spec) {
|
|
|
186
194
|
const envFlags = buildEnvFlags(env);
|
|
187
195
|
const title = windowTitle(spec.projectName, spec.name);
|
|
188
196
|
const { target, landedInProjectId, layoutApplied, inProcessesWindow } = placeAgentPane(session, spec, envFlags, commandString, title);
|
|
197
|
+
const pid = readPidOrDiscard(target);
|
|
189
198
|
paneUp = true;
|
|
190
|
-
if (!recordPane(agentId, target, socket)) {
|
|
199
|
+
if (!recordPane(agentId, target, socket, pid)) {
|
|
191
200
|
discardOrphanedPane(target);
|
|
192
201
|
throw paneRacedRetirement(agentId);
|
|
193
202
|
}
|
|
@@ -261,8 +270,9 @@ export function resumeAgent(spec) {
|
|
|
261
270
|
const session = sessionName();
|
|
262
271
|
const title = windowTitle(spec.projectName, spec.name);
|
|
263
272
|
const { target, landedInProjectId } = placeAgentPane(session, spec, envFlags, spec.commandString, title);
|
|
273
|
+
const pid = readPidOrDiscard(target);
|
|
264
274
|
paneUp = true;
|
|
265
|
-
if (!recordPane(spec.agentId, target, socket)) {
|
|
275
|
+
if (!recordPane(spec.agentId, target, socket, pid)) {
|
|
266
276
|
discardOrphanedPane(target);
|
|
267
277
|
throw paneRacedRetirement(spec.agentId);
|
|
268
278
|
}
|
|
@@ -328,11 +338,51 @@ export function branchAt(cwd) {
|
|
|
328
338
|
return branch;
|
|
329
339
|
return git(["rev-parse", "--short", "HEAD"]) ?? "";
|
|
330
340
|
}
|
|
331
|
-
export function parkAgentRow(agentId,
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
.get(branch, agentId,
|
|
341
|
+
export function parkAgentRow(agentId, expected, branch) {
|
|
342
|
+
const park = "UPDATE agents SET status = 'closed', closed_at = datetime('now'), parked_at = datetime('now'), " +
|
|
343
|
+
"parked_branch = ? WHERE id = ? AND status = 'running' AND tmux_target = ?";
|
|
344
|
+
const row = typeof expected === "string"
|
|
345
|
+
? db.prepare(`${park} RETURNING parked_at`).get(branch, agentId, expected)
|
|
346
|
+
: db
|
|
347
|
+
.prepare(`${park} AND tmux_socket = ? AND pane_pid = ? RETURNING parked_at`)
|
|
348
|
+
.get(branch, agentId, expected.tmux_target, expected.tmux_socket, expected.pane_pid);
|
|
349
|
+
return row?.parked_at;
|
|
350
|
+
}
|
|
351
|
+
export const paneIdentity = (row) => ({
|
|
352
|
+
tmux_target: row.tmux_target,
|
|
353
|
+
tmux_socket: row.tmux_socket,
|
|
354
|
+
pane_pid: row.pane_pid,
|
|
355
|
+
});
|
|
356
|
+
// "changed" means the row is no longer running or no longer carries the identity the caller read.
|
|
357
|
+
export function currentOwnership(agentId, expected, snapshot) {
|
|
358
|
+
const row = db
|
|
359
|
+
.prepare("SELECT status, tmux_target, tmux_socket, pane_pid FROM agents WHERE id = ?")
|
|
360
|
+
.get(agentId);
|
|
361
|
+
if (!row ||
|
|
362
|
+
row.status !== "running" ||
|
|
363
|
+
row.tmux_target !== expected.tmux_target ||
|
|
364
|
+
row.tmux_socket !== expected.tmux_socket ||
|
|
365
|
+
row.pane_pid !== expected.pane_pid) {
|
|
366
|
+
return "changed";
|
|
367
|
+
}
|
|
368
|
+
return rowOwnership(row, snapshot);
|
|
369
|
+
}
|
|
370
|
+
export class PaneOwnershipLost extends Error {
|
|
371
|
+
agentId;
|
|
372
|
+
ownership;
|
|
373
|
+
constructor(agentId, ownership, action) {
|
|
374
|
+
super(`[pane-ownership-lost] ${action} stopped before touching agent ${agentId}'s pane: ` +
|
|
375
|
+
(ownership === "changed"
|
|
376
|
+
? "its row was closed or recorded a different pane since this call read it."
|
|
377
|
+
: `its pane ownership now reads ${ownership}, so the pane may belong to another process.`));
|
|
378
|
+
this.agentId = agentId;
|
|
379
|
+
this.ownership = ownership;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
export function requireStillOwned(agentId, expected, action) {
|
|
383
|
+
const ownership = currentOwnership(agentId, expected);
|
|
384
|
+
if (ownership !== "live")
|
|
385
|
+
throw new PaneOwnershipLost(agentId, ownership, action);
|
|
336
386
|
}
|
|
337
387
|
export const PARK_STAMP_CLEARED = "parked_at = '', parked_branch = ''";
|
|
338
388
|
export function releaseParkRow(agentId) {
|
package/dist/tmux.js
CHANGED
|
@@ -413,6 +413,10 @@ export function targetPid(target, snapshot) {
|
|
|
413
413
|
export function panePid(target) {
|
|
414
414
|
return targetLiveProbe(target).pid ?? "";
|
|
415
415
|
}
|
|
416
|
+
// A row recorded with an empty pid reads unknown for life, so a pane hive just created gets one retry.
|
|
417
|
+
export function panePidForRecord(target) {
|
|
418
|
+
return panePid(target) || panePid(target);
|
|
419
|
+
}
|
|
416
420
|
const DESTROY_READINESS_BOUND_MS = 200;
|
|
417
421
|
function paneHasEstablishedProcess(pid) {
|
|
418
422
|
try {
|
|
@@ -492,6 +496,14 @@ export function rowOwnership(row, snapshot) {
|
|
|
492
496
|
return "unknown";
|
|
493
497
|
return pid === row.pane_pid ? "live" : "reissued";
|
|
494
498
|
}
|
|
499
|
+
// The one place a row's ownership is observed: a failed probe is its own answer, never unknown.
|
|
500
|
+
export function observeRowOwnership(row) {
|
|
501
|
+
const observes = !foreignSocket(row.tmux_socket) && row.tmux_target !== "";
|
|
502
|
+
const snapshot = observes ? liveTargets() : null;
|
|
503
|
+
if (observes && observationFailed(snapshot))
|
|
504
|
+
return "probe-failed";
|
|
505
|
+
return rowOwnership(row, snapshot);
|
|
506
|
+
}
|
|
495
507
|
export function ownershipLiveness(ownership) {
|
|
496
508
|
return ownership === "live" ? true : ownership === "unknown" ? null : false;
|
|
497
509
|
}
|
|
@@ -1359,7 +1371,7 @@ export function findUnsafeControlChar(text, allowed) {
|
|
|
1359
1371
|
}
|
|
1360
1372
|
return null;
|
|
1361
1373
|
}
|
|
1362
|
-
export async function sendText(target, text, submit = true, onPasted, onBuffered) {
|
|
1374
|
+
export async function sendText(target, text, submit = true, onPasted, onBuffered, beforePaneWrite) {
|
|
1363
1375
|
// tmux hands a pane its input in 1022-byte writes, and only paste-buffer -p
|
|
1364
1376
|
// brackets them, so send-keys -l loses everything before the last write.
|
|
1365
1377
|
// An empty text skips the pair: set-buffer creates no buffer for it, so
|
|
@@ -1371,6 +1383,7 @@ export async function sendText(target, text, submit = true, onPasted, onBuffered
|
|
|
1371
1383
|
tmux("set-buffer", "-b", buffer, "--", text);
|
|
1372
1384
|
onBuffered?.();
|
|
1373
1385
|
try {
|
|
1386
|
+
beforePaneWrite?.();
|
|
1374
1387
|
tmux("paste-buffer", "-d", "-p", "-b", buffer, "-t", target);
|
|
1375
1388
|
}
|
|
1376
1389
|
catch (err) {
|
|
@@ -1391,6 +1404,7 @@ export async function sendText(target, text, submit = true, onPasted, onBuffered
|
|
|
1391
1404
|
}
|
|
1392
1405
|
if (submit) {
|
|
1393
1406
|
await sleep(ENTER_DELAY_MS);
|
|
1407
|
+
beforePaneWrite?.();
|
|
1394
1408
|
tmux("send-keys", "-t", target, "Enter");
|
|
1395
1409
|
}
|
|
1396
1410
|
}
|