@cmgmyr/hive 1.11.4 → 1.11.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agentKinds.js +6 -0
- package/dist/agentRecords.js +98 -0
- package/dist/backup.js +1 -1
- package/dist/build-info.json +1 -1
- package/dist/cli.js +46 -26
- package/dist/codexHome.js +0 -1
- package/dist/context.js +15 -26
- package/dist/crew.js +7 -24
- package/dist/dashboard.js +15 -21
- package/dist/db.js +37 -1
- package/dist/errors.js +3 -0
- package/dist/git.js +16 -0
- package/dist/globalConfig.js +1 -1
- package/dist/harnesses.js +0 -3
- package/dist/kickoff.js +6 -15
- package/dist/leadHandoffState.js +1 -1
- package/dist/leadLaunch.js +1 -1
- package/dist/leadWatch.js +1 -1
- package/dist/pads.js +70 -0
- package/dist/portfolio.js +4 -3
- package/dist/processes.js +12 -0
- package/dist/projectRemove.js +1 -1
- package/dist/projectYml.js +1 -1
- package/dist/queenAudit.js +2 -18
- package/dist/queenAuditStore.js +20 -0
- package/dist/questions.js +1 -1
- package/dist/result.js +2 -3
- package/dist/scheduler.js +4 -2
- package/dist/spawn.js +12 -35
- package/dist/todoScope.js +6 -0
- package/dist/todos.js +90 -0
- package/dist/tools/agents.js +13 -109
- package/dist/tools/pads.js +3 -82
- package/dist/tools/todos.js +8 -96
- package/dist/tools/wakes.js +7 -75
- package/dist/version.js +4 -17
- package/dist/wakeSql.js +1 -0
- package/dist/wakeTarget.js +76 -0
- package/package.json +1 -1
package/dist/tools/agents.js
CHANGED
|
@@ -2,6 +2,8 @@ import { existsSync, rmSync, statSync, realpathSync } from "node:fs";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { db } from "../db.js";
|
|
5
|
+
import { CLOSED_ROW_ORDER, findAgent, getAgentRow, notOwnedError, observeOwnership, PROBE_FAILED_NOTE, summaryLiveness, UNVERIFIED_OWNERSHIP_NOTE } from "../agentRecords.js";
|
|
6
|
+
export { CLOSED_ROW_ORDER, findAgent, getAgentRow, notOwnedError, observeOwnership, probeFailed, PROBE_FAILED_NOTE, REISSUED_PANE_NOTE, summaryLiveness, UNVERIFIED_OWNERSHIP_NOTE } from "../agentRecords.js";
|
|
5
7
|
import { confirmQueenWrite } from "../queenAudit.js";
|
|
6
8
|
import { captureQueenWriteIdentity, resolveQueenLeadTarget } from "../context.js";
|
|
7
9
|
import { agentBriefPath, mergedBriefVars, readAgentBrief, workerBrief, workerCommandString, writeAgentBrief, } from "../brief.js";
|
|
@@ -18,21 +20,10 @@ import { agentIdentityEnv, branchAt, closeAgentRow, currentOwnership, discardOrp
|
|
|
18
20
|
import { COMMAND_KIND, runningCommandRow, stopLine, stopProcess, STOP_REASONS } from "../processes.js";
|
|
19
21
|
import { readContextTokens, readContextFill, resolveTranscriptDir } from "../transcript.js";
|
|
20
22
|
import { leadSessionEnded } from "../leadState.js";
|
|
21
|
-
import { capturePane, captureFinalScreen, DEFAULT_LAYOUT, cancelCopyMode, describePaneChoice, ensureAttached, findUnsafeControlChar, holdsHumanInput, liveTargets, observationFailed,
|
|
23
|
+
import { capturePane, captureFinalScreen, DEFAULT_LAYOUT, cancelCopyMode, describePaneChoice, ensureAttached, findUnsafeControlChar, holdsHumanInput, liveTargets, observationFailed, ownershipLiveness, paneCurrentCommand, paneInCopyMode, pollPaneReadiness, rowOwnership, sendText, sessionName, setRemainOnExit, sleep, TEXT_ALLOWED_CONTROL_CHARS, tmux, TmuxTimeoutError, WINDOW_LAYOUTS, } from "../tmux.js";
|
|
22
24
|
import { agentIdParam, agentNameParam, idParam, projectIdParam } from "./params.js";
|
|
23
25
|
import { deriveProvenance, lastLogEvent, lastPermissionMode, reportsAgentStateLog, } from "../stateProvenance.js";
|
|
24
|
-
const
|
|
25
|
-
function closedAgentNamed(projectId, needle) {
|
|
26
|
-
return db
|
|
27
|
-
.prepare(`SELECT id, name, kind, parked_at FROM agents WHERE project_id = ? AND status != 'running' ORDER BY ${CLOSED_ROW_ORDER}`)
|
|
28
|
-
.all(projectId).find((r) => r.name.toLowerCase() === needle);
|
|
29
|
-
}
|
|
30
|
-
function getAgentRow(projectId, id, notFoundHint) {
|
|
31
|
-
const row = db.prepare("SELECT * FROM agents WHERE project_id = ? AND id = ?").get(projectId, id);
|
|
32
|
-
if (!row)
|
|
33
|
-
throw new Error(`No agent ${id} in project ${projectId}. ${notFoundHint}`);
|
|
34
|
-
return row;
|
|
35
|
-
}
|
|
26
|
+
const unknownOwnershipNote = (snapshot) => observationFailed(snapshot) ? PROBE_FAILED_NOTE : UNVERIFIED_OWNERSHIP_NOTE;
|
|
36
27
|
export function classifyLostCas(row) {
|
|
37
28
|
return row.status !== "running" ? { outcome: "retired", parked: !!row.parked_at } : { outcome: "revived" };
|
|
38
29
|
}
|
|
@@ -42,62 +33,6 @@ function revivedError(agent, live, cause, remedy) {
|
|
|
42
33
|
(live ? " after this call's kill-pane took the old one down" : "") +
|
|
43
34
|
`. ${remedy}`);
|
|
44
35
|
}
|
|
45
|
-
export function findAgent(projectId, ref) {
|
|
46
|
-
if (ref.agent_id != null) {
|
|
47
|
-
return getAgentRow(projectId, ref.agent_id, "Call agent_list.");
|
|
48
|
-
}
|
|
49
|
-
if (ref.name) {
|
|
50
|
-
const rows = db
|
|
51
|
-
.prepare("SELECT * FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
|
|
52
|
-
.all(projectId);
|
|
53
|
-
const exact = rows.filter((r) => r.name === ref.name);
|
|
54
|
-
if (exact.length === 1)
|
|
55
|
-
return exact[0];
|
|
56
|
-
if (exact.length > 1) {
|
|
57
|
-
throw new Error(`Multiple running agents named "${ref.name}". Target by agent_id instead.`);
|
|
58
|
-
}
|
|
59
|
-
const needle = ref.name.toLowerCase();
|
|
60
|
-
const sameName = rows.filter((r) => r.name.toLowerCase() === needle);
|
|
61
|
-
if (sameName.length === 1)
|
|
62
|
-
return sameName[0];
|
|
63
|
-
const closed = closedAgentNamed(projectId, needle);
|
|
64
|
-
if (closed) {
|
|
65
|
-
if (closed.parked_at) {
|
|
66
|
-
throw new Error(`Agent ${closed.id} ("${closed.name}") is PARKED, not finished - it was paused on ` +
|
|
67
|
-
`${closed.parked_at} and its session is waiting. Bring it back with agent_resume(agent_id: ` +
|
|
68
|
-
`${closed.id}), or abandon the park with agent_close(agent_id: ${closed.id}).`);
|
|
69
|
-
}
|
|
70
|
-
const remedy = closed.kind === LEAD_KIND ? "Run `hive lead` to start a new one" : "Spawn a new worker";
|
|
71
|
-
throw new Error(`Agent ${closed.id} ("${closed.name}") is closed. ${remedy}, or target a running one by name or agent_id.`);
|
|
72
|
-
}
|
|
73
|
-
const partial = rows.filter((r) => r.name.toLowerCase().includes(needle));
|
|
74
|
-
if (partial.length === 1)
|
|
75
|
-
return partial[0];
|
|
76
|
-
if (partial.length > 1) {
|
|
77
|
-
const candidates = partial.map((r) => `${r.name} (agent_id ${r.id})`).join(", ");
|
|
78
|
-
throw new Error(`"${ref.name}" matches ${partial.length} running agents: ${candidates}. Use the full name or agent_id.`);
|
|
79
|
-
}
|
|
80
|
-
if (/^\d+$/.test(ref.name)) {
|
|
81
|
-
const id = Number(ref.name);
|
|
82
|
-
if (Number.isSafeInteger(id)) {
|
|
83
|
-
const byId = db
|
|
84
|
-
.prepare("SELECT * FROM agents WHERE project_id = ? AND id = ? AND status = 'running'")
|
|
85
|
-
.get(projectId, id);
|
|
86
|
-
if (byId)
|
|
87
|
-
return byId;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
else if (/^[a-z]+:\d+$/i.test(ref.name)) {
|
|
91
|
-
const byActorId = db
|
|
92
|
-
.prepare("SELECT * FROM agents WHERE project_id = ? AND actor_id = ? AND status = 'running'")
|
|
93
|
-
.get(projectId, ref.name);
|
|
94
|
-
if (byActorId)
|
|
95
|
-
return byActorId;
|
|
96
|
-
}
|
|
97
|
-
throw new Error(`No running agent matching "${ref.name}" in project ${projectId}. Call agent_list.`);
|
|
98
|
-
}
|
|
99
|
-
throw new Error("Pass agent_id or name.");
|
|
100
|
-
}
|
|
101
36
|
function findClosedAgent(projectId, ref) {
|
|
102
37
|
if (ref.agent_id != null) {
|
|
103
38
|
const row = getAgentRow(projectId, ref.agent_id, "Call agent_list(include_closed: true).");
|
|
@@ -190,28 +125,6 @@ function closeRowOnly(agent, confirmSelf) {
|
|
|
190
125
|
(ownership === "unknown" ? " Anything still running in that pane was left alone." : ""),
|
|
191
126
|
};
|
|
192
127
|
}
|
|
193
|
-
export const PROBE_FAILED_NOTE = "tmux could not be probed, so liveness is unknown. Nothing was changed. Retry in a few seconds.";
|
|
194
|
-
const unknownOwnershipNote = (snapshot) => observationFailed(snapshot) ? PROBE_FAILED_NOTE : UNVERIFIED_OWNERSHIP_NOTE;
|
|
195
|
-
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).";
|
|
196
|
-
export const probeFailed = (agent) => new Error(`Agent ${agent.id} ("${agent.name}"): ${PROBE_FAILED_NOTE}`);
|
|
197
|
-
// A failed probe throws rather than answering unknown, so callers keep the retry wording.
|
|
198
|
-
export function observeOwnership(agent) {
|
|
199
|
-
const ownership = observeRowOwnership(agent);
|
|
200
|
-
if (ownership === "probe-failed")
|
|
201
|
-
throw probeFailed(agent);
|
|
202
|
-
return ownership;
|
|
203
|
-
}
|
|
204
|
-
export const REISSUED_PANE_NOTE = "Its recorded pane id now belongs to a different process (the recorded pane pid no longer matches), so its own process is gone and the pane is someone else's. Nothing was typed, read or killed there.";
|
|
205
|
-
export function notOwnedError(agent, ownership) {
|
|
206
|
-
const who = `Agent ${agent.id} ("${agent.name}")`;
|
|
207
|
-
if (ownership === "gone") {
|
|
208
|
-
return new Error(`${who} has no live tmux window (its process exited or the window was killed). Close it with agent_close and spawn a new one.`);
|
|
209
|
-
}
|
|
210
|
-
if (ownership === "reissued") {
|
|
211
|
-
return new Error(`${who}: pane ownership reads reissued. ${REISSUED_PANE_NOTE} Close the row with agent_close, which retires it without touching that pane.`);
|
|
212
|
-
}
|
|
213
|
-
return new Error(`${who}: pane ownership reads unknown. ${UNVERIFIED_OWNERSHIP_NOTE}`);
|
|
214
|
-
}
|
|
215
128
|
// Rechecked just before a kill. "changed" skips the kill and lets the row CAS report the race.
|
|
216
129
|
function stillOwnsPane(agent, action) {
|
|
217
130
|
const now = currentOwnership(agent.id, paneIdentity(agent));
|
|
@@ -327,11 +240,6 @@ function nextWorkerName(projectId) {
|
|
|
327
240
|
return `worker-${count + 1}`;
|
|
328
241
|
}
|
|
329
242
|
const PANE_READY_MS = Number(process.env.HIVE_SPAWN_READY_MS ?? 45_000);
|
|
330
|
-
export function summaryLiveness(row, snapshot) {
|
|
331
|
-
if (row.status !== "running")
|
|
332
|
-
return false;
|
|
333
|
-
return ownershipLiveness(rowOwnership(row, snapshot));
|
|
334
|
-
}
|
|
335
243
|
function capturePaneQuietly(target) {
|
|
336
244
|
try {
|
|
337
245
|
return capturePane(target, 15);
|
|
@@ -695,19 +603,15 @@ export function registerAgents(server) {
|
|
|
695
603
|
note: "The pane never became ready. The brief is loaded regardless; check agent_output before sending the worker its assignment - typing into it now risks losing the text silently.",
|
|
696
604
|
}),
|
|
697
605
|
}
|
|
698
|
-
:
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
brief_path
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
"supported way to send them. The brief is written to brief_path - open that pane " +
|
|
708
|
-
"(tmux attach) and paste it in yourself, or drive the worker with agent_send(keys: [...]). " +
|
|
709
|
-
"Wakes aimed at this worker are refused for the same reason.",
|
|
710
|
-
}),
|
|
606
|
+
: {
|
|
607
|
+
brief_path: writeAgentBrief(agentId, workerBrief(briefFor(actorId))),
|
|
608
|
+
note: `This worker runs ${JSON.stringify(commandHead(baseCommand))}, which hive cannot brief and ` +
|
|
609
|
+
"cannot type into: the guards that make typing safe read claude's chrome, so agent_send's " +
|
|
610
|
+
"text path refuses this pane. There are no instructions to prepend, because there is no " +
|
|
611
|
+
"supported way to send them. The brief is written to brief_path - open that pane " +
|
|
612
|
+
"(tmux attach) and paste it in yourself, or drive the worker with agent_send(keys: [...]). " +
|
|
613
|
+
"Wakes aimed at this worker are refused for the same reason.",
|
|
614
|
+
}),
|
|
711
615
|
};
|
|
712
616
|
if (omittedTodoWarning) {
|
|
713
617
|
const existingNote = typeof receipt.note === "string" ? receipt.note : "";
|
package/dist/tools/pads.js
CHANGED
|
@@ -1,89 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { db } from "../db.js";
|
|
3
|
-
import {
|
|
3
|
+
import { effectiveProjectId, resolveProject } from "../context.js";
|
|
4
4
|
import { matchesAnyTag, parseTags, run } from "../result.js";
|
|
5
5
|
import { idParam, limitParam, offsetParam, projectIdParam } from "./params.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return false;
|
|
9
|
-
const before = content.charCodeAt(index - 1);
|
|
10
|
-
const after = content.charCodeAt(index);
|
|
11
|
-
return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;
|
|
12
|
-
}
|
|
13
|
-
function selectPad(projectId, padId, columns) {
|
|
14
|
-
const row = db
|
|
15
|
-
.prepare(`SELECT ${columns} FROM pads WHERE project_id = ? AND id = ?`)
|
|
16
|
-
.get(projectId, padId);
|
|
17
|
-
if (!row)
|
|
18
|
-
throw new Error(`No pad with id ${padId} in project ${projectId}. Call pad_list.`);
|
|
19
|
-
return row;
|
|
20
|
-
}
|
|
21
|
-
function getPad(projectId, padId) {
|
|
22
|
-
return selectPad(projectId, padId, "*");
|
|
23
|
-
}
|
|
24
|
-
export function getPadMeta(projectId, padId) {
|
|
25
|
-
return selectPad(projectId, padId, "id, name, revision, archived");
|
|
26
|
-
}
|
|
27
|
-
function revisionMismatchError(padId, expected, current) {
|
|
28
|
-
if (current == null) {
|
|
29
|
-
return new Error(`Pad ${padId} no longer exists; it may have just been deleted by another write. Call pad_list to confirm.`);
|
|
30
|
-
}
|
|
31
|
-
return new Error(`Revision mismatch for pad ${padId}: expected ${expected}, current ${current}. Re-read with pad_read and retry.`);
|
|
32
|
-
}
|
|
33
|
-
function currentRevisionOrDeleted(padId) {
|
|
34
|
-
const row = db.prepare("SELECT revision FROM pads WHERE id = ?").get(padId);
|
|
35
|
-
return row?.revision ?? null;
|
|
36
|
-
}
|
|
37
|
-
function assertRowChanged(padId, predicateRevision, changed) {
|
|
38
|
-
if (!changed) {
|
|
39
|
-
throw revisionMismatchError(padId, predicateRevision, currentRevisionOrDeleted(padId));
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
function checkRevision(pad, expected, required) {
|
|
43
|
-
if (expected == null) {
|
|
44
|
-
if (required) {
|
|
45
|
-
throw new Error(`expected_revision is required to overwrite pad ${pad.id}. Current revision: ${pad.revision}. Read it first with pad_read.`);
|
|
46
|
-
}
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
if (expected !== pad.revision) {
|
|
50
|
-
throw revisionMismatchError(pad.id, expected, pad.revision);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
export function bumpPad(padId, predicateRevision, set, ...params) {
|
|
54
|
-
const where = predicateRevision != null ? "id = ? AND revision = ?" : "id = ?";
|
|
55
|
-
const whereParams = predicateRevision != null ? [padId, predicateRevision] : [padId];
|
|
56
|
-
const row = db
|
|
57
|
-
.prepare(`UPDATE pads SET ${set}, revision = revision + 1,
|
|
58
|
-
updated_by = ?, updated_at = datetime('now') WHERE ${where} RETURNING revision`)
|
|
59
|
-
.get(...params, currentActor(), ...whereParams);
|
|
60
|
-
assertRowChanged(padId, predicateRevision, row != null);
|
|
61
|
-
return row.revision;
|
|
62
|
-
}
|
|
63
|
-
export const APPEND_WITH_SEPARATOR_SET = "content = content || (CASE WHEN content = '' OR substr(content, -1) = char(10) THEN '' ELSE char(10) END) || ?";
|
|
64
|
-
export function getActivePadByName(projectId, name) {
|
|
65
|
-
return db
|
|
66
|
-
.prepare("SELECT * FROM pads WHERE project_id = ? AND name = ? AND archived = 0")
|
|
67
|
-
.get(projectId, name);
|
|
68
|
-
}
|
|
69
|
-
export function listActivePads(projectId) {
|
|
70
|
-
return db
|
|
71
|
-
.prepare(`SELECT name, revision, length(content) AS content_length, updated_by, updated_at
|
|
72
|
-
FROM pads WHERE project_id = ? AND archived = 0 ORDER BY name`)
|
|
73
|
-
.all(projectId);
|
|
74
|
-
}
|
|
75
|
-
export function overwritePadContent(projectId, padId, content, expectedRevision) {
|
|
76
|
-
const pad = getPadMeta(projectId, padId);
|
|
77
|
-
checkRevision(pad, expectedRevision, true);
|
|
78
|
-
return bumpPad(padId, pad.revision, "content = ?", content);
|
|
79
|
-
}
|
|
80
|
-
export function createPad(projectId, name, content, tags) {
|
|
81
|
-
const row = db
|
|
82
|
-
.prepare(`INSERT OR IGNORE INTO pads (project_id, name, content, tags, updated_by)
|
|
83
|
-
VALUES (?, ?, ?, ?, ?) RETURNING id`)
|
|
84
|
-
.get(projectId, name, content, JSON.stringify(tags), currentActor());
|
|
85
|
-
return row?.id ?? null;
|
|
86
|
-
}
|
|
6
|
+
import { APPEND_WITH_SEPARATOR_SET, assertRowChanged, bumpPad, checkRevision, createPad, getPad, getPadMeta, splitsSurrogatePair } from "../pads.js";
|
|
7
|
+
export { APPEND_WITH_SEPARATOR_SET, assertRowChanged, bumpPad, checkRevision, createPad, getActivePadByName, getPad, getPadMeta, listActivePads, overwritePadContent, splitsSurrogatePair } from "../pads.js";
|
|
87
8
|
export function registerPads(server) {
|
|
88
9
|
server.registerTool("pad_write", {
|
|
89
10
|
description: "Create a pad, or fully overwrite one by passing pad_id plus expected_revision. Pad names are unique per project. Prefer pad_append/pad_edit for targeted changes.",
|
package/dist/tools/todos.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { db } from "../db.js";
|
|
3
|
+
import { getTodo, getTodoDetail, listTodoSummaries } from "../todos.js";
|
|
4
|
+
import { TODO_STATUSES } from "../todoScope.js";
|
|
5
|
+
export { COMMENT_COUNT_SQL, getTodo, getTodoDetail, listTodoSummaries } from "../todos.js";
|
|
6
|
+
export { TODO_STATUSES } from "../todoScope.js";
|
|
3
7
|
import { commitQueenStateWrite } from "../queenAudit.js";
|
|
4
8
|
import { currentActor, effectiveProjectId, resolveProject } from "../context.js";
|
|
5
|
-
import {
|
|
9
|
+
import { run } from "../result.js";
|
|
6
10
|
import { findUnsafeControlChar } from "../tmux.js";
|
|
7
11
|
import { SLUG_MAX_LEN, fallbackSlug } from "../slug.js";
|
|
8
12
|
import { idParam, limitParam, offsetParam, projectIdParam } from "./params.js";
|
|
13
|
+
import { NOT_COMPLETED_TODO_SQL, OPEN_BLOCKERS_SQL } from "../todoScope.js";
|
|
9
14
|
const priorityParam = z.enum(["high", "medium", "low"]);
|
|
10
15
|
const slugParam = z
|
|
11
16
|
.string()
|
|
@@ -17,99 +22,9 @@ const slugParam = z
|
|
|
17
22
|
.describe(`Short label, ~3-5 words (${SLUG_MAX_LEN} chars max). When set, it renders beside the id: bracketed in \`hive todos\`, \`hive todo <id>\`, and the session-start digest, and as the row's whole label in the dashboard. Left unset, those surfaces fall back to the title - shown in full in \`hive todos\`/\`hive todo <id>\`/the digest, truncated to this same length in the dashboard. Free text, not a pad-style slug. Pass "" to clear a previously-set slug back to unset.`)
|
|
18
23
|
.optional();
|
|
19
24
|
export { SLUG_MAX_LEN, fallbackSlug };
|
|
20
|
-
export const TODO_STATUSES = ["open", "in_progress", "backlog", "completed"];
|
|
21
25
|
const statusParam = z.enum(TODO_STATUSES);
|
|
22
|
-
const OPEN_BLOCKERS_FROM_WHERE = `FROM todo_blockers b JOIN todos bt ON bt.id = b.blocker_id
|
|
23
|
-
WHERE b.todo_id = t.id AND bt.status != 'completed'`;
|
|
24
|
-
export const OPEN_BLOCKERS_SQL = `SELECT 1 ${OPEN_BLOCKERS_FROM_WHERE}`;
|
|
25
26
|
const LIVE_DEPENDENTS_SQL = `SELECT t.id AS todo_id FROM todo_blockers b JOIN todos t ON t.id = b.todo_id
|
|
26
|
-
WHERE b.blocker_id = ? AND t
|
|
27
|
-
export const COMMENT_COUNT_SQL = `(SELECT COUNT(*) FROM todo_comments c WHERE c.todo_id = t.id)`;
|
|
28
|
-
const SUMMARY_SQL = `
|
|
29
|
-
SELECT t.*,
|
|
30
|
-
(SELECT COUNT(*) ${OPEN_BLOCKERS_FROM_WHERE}) AS open_blockers,
|
|
31
|
-
${COMMENT_COUNT_SQL} AS comment_count
|
|
32
|
-
FROM todos t`;
|
|
33
|
-
function getTodo(projectId, todoId) {
|
|
34
|
-
const row = db
|
|
35
|
-
.prepare(`${SUMMARY_SQL} WHERE t.project_id = ? AND t.id = ?`)
|
|
36
|
-
.get(projectId, todoId);
|
|
37
|
-
if (!row)
|
|
38
|
-
throw new Error(`No todo with id ${todoId} in project ${projectId}. Call todo_list.`);
|
|
39
|
-
return row;
|
|
40
|
-
}
|
|
41
|
-
function summarize(row) {
|
|
42
|
-
return {
|
|
43
|
-
todo_id: row.id,
|
|
44
|
-
title: row.title,
|
|
45
|
-
slug: row.slug || fallbackSlug(row.title) || `todo ${row.id}`,
|
|
46
|
-
status: row.status,
|
|
47
|
-
priority: row.priority,
|
|
48
|
-
tags: parseTags(row.tags),
|
|
49
|
-
archived: row.archived_at != null,
|
|
50
|
-
is_blocked: (row.open_blockers ?? 0) > 0,
|
|
51
|
-
open_blockers: row.open_blockers ?? 0,
|
|
52
|
-
comment_count: row.comment_count ?? 0,
|
|
53
|
-
updated_at: row.updated_at,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
export function listTodoSummaries(projectId, filter = {}) {
|
|
57
|
-
const limit = Math.min(filter.limit ?? 50, 200);
|
|
58
|
-
const offset = filter.offset ?? 0;
|
|
59
|
-
let sql = `${SUMMARY_SQL} WHERE t.project_id = ?`;
|
|
60
|
-
const params = [projectId];
|
|
61
|
-
if (!filter.includeArchived) {
|
|
62
|
-
sql += " AND t.archived_at IS NULL";
|
|
63
|
-
}
|
|
64
|
-
if (filter.statuses && filter.statuses.length > 0) {
|
|
65
|
-
sql += ` AND t.status IN (${filter.statuses.map(() => "?").join(",")})`;
|
|
66
|
-
params.push(...filter.statuses);
|
|
67
|
-
}
|
|
68
|
-
if (filter.priority) {
|
|
69
|
-
sql += " AND t.priority = ?";
|
|
70
|
-
params.push(filter.priority);
|
|
71
|
-
}
|
|
72
|
-
if (filter.query) {
|
|
73
|
-
sql += " AND (t.title LIKE ? OR t.body LIKE ? OR t.slug LIKE ?)";
|
|
74
|
-
params.push(`%${filter.query}%`, `%${filter.query}%`, `%${filter.query}%`);
|
|
75
|
-
}
|
|
76
|
-
sql += " ORDER BY t.updated_at DESC";
|
|
77
|
-
let rows = db.prepare(sql).all(...params).filter((r) => matchesAnyTag(r.tags, filter.tags));
|
|
78
|
-
if (filter.isBlocked != null) {
|
|
79
|
-
rows = rows.filter((r) => ((r.open_blockers ?? 0) > 0) === filter.isBlocked);
|
|
80
|
-
}
|
|
81
|
-
return {
|
|
82
|
-
total_count: rows.length,
|
|
83
|
-
offset,
|
|
84
|
-
limit,
|
|
85
|
-
todos: rows.slice(offset, offset + limit).map(summarize),
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
export function getTodoDetail(projectId, todoId, includeComments) {
|
|
89
|
-
const todo = getTodo(projectId, todoId);
|
|
90
|
-
const blockers = db
|
|
91
|
-
.prepare(`SELECT t.id, t.title, t.status FROM todo_blockers b
|
|
92
|
-
JOIN todos t ON t.id = b.blocker_id WHERE b.todo_id = ?`)
|
|
93
|
-
.all(todo.id);
|
|
94
|
-
const blocking = db
|
|
95
|
-
.prepare(`SELECT t.id, t.title, t.status FROM todo_blockers b
|
|
96
|
-
JOIN todos t ON t.id = b.todo_id WHERE b.blocker_id = ?`)
|
|
97
|
-
.all(todo.id);
|
|
98
|
-
const result = {
|
|
99
|
-
...summarize(todo),
|
|
100
|
-
body: todo.body,
|
|
101
|
-
created_at: todo.created_at,
|
|
102
|
-
completed_at: todo.completed_at,
|
|
103
|
-
blockers,
|
|
104
|
-
blocking,
|
|
105
|
-
};
|
|
106
|
-
if (includeComments) {
|
|
107
|
-
result.comments = db
|
|
108
|
-
.prepare("SELECT id, author, body, created_at FROM todo_comments WHERE todo_id = ? ORDER BY created_at")
|
|
109
|
-
.all(todo.id);
|
|
110
|
-
}
|
|
111
|
-
return result;
|
|
112
|
-
}
|
|
27
|
+
WHERE b.blocker_id = ? AND t.${NOT_COMPLETED_TODO_SQL} AND t.archived_at IS NULL`;
|
|
113
28
|
function transitiveBlockers(startId) {
|
|
114
29
|
const seen = new Set();
|
|
115
30
|
const stack = [startId];
|
|
@@ -191,10 +106,7 @@ const completeTodo = db.transaction((projectId, todoId, completed) => {
|
|
|
191
106
|
if (completed) {
|
|
192
107
|
newlyUnblocked = db
|
|
193
108
|
.prepare(`${LIVE_DEPENDENTS_SQL}
|
|
194
|
-
AND NOT EXISTS (
|
|
195
|
-
SELECT 1 FROM todo_blockers b2 JOIN todos bt ON bt.id = b2.blocker_id
|
|
196
|
-
WHERE b2.todo_id = t.id AND bt.status != 'completed'
|
|
197
|
-
)`)
|
|
109
|
+
AND NOT EXISTS (${OPEN_BLOCKERS_SQL})`)
|
|
198
110
|
.all(todo.id).map((r) => r.todo_id);
|
|
199
111
|
}
|
|
200
112
|
return { todo_id: todo.id, completed, newly_unblocked: newlyUnblocked };
|
package/dist/tools/wakes.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { db } from "../db.js";
|
|
3
|
+
import { assertSafeWakeBody, resolveAgentRef, resolveWakeDelivery } from "../wakeTarget.js";
|
|
4
|
+
export { assertSafeWakeBody, resolveAgentRef, resolveWakeDelivery } from "../wakeTarget.js";
|
|
3
5
|
import { commitQueenStateWrite } from "../queenAudit.js";
|
|
4
|
-
import {
|
|
6
|
+
import { currentActor, effectiveProjectId, getProject, isQueenLead, } from "../context.js";
|
|
5
7
|
import { run } from "../result.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
+
import { summaryLiveness } from "../agentRecords.js";
|
|
9
|
+
import { LOG_RETENTION, OWNED_BY_WATCH, WATCH_SCOPE_PROJECT, seedGoneCursor, } from "../scheduler.js";
|
|
10
|
+
import { ACTIVE_TIMER_WHERE } from "../wakeSql.js";
|
|
8
11
|
import { idParam, projectIdParam } from "./params.js";
|
|
9
12
|
import { cutToUnitBudget } from "../slug.js";
|
|
10
13
|
import { awaitingFirstPrompt } from "../firstPrompt.js";
|
|
11
14
|
import { deriveProvenance } from "../stateProvenance.js";
|
|
12
|
-
import {
|
|
15
|
+
import { foreignSocket, liveTargets, observationFailed, rowOwnership, } from "../tmux.js";
|
|
13
16
|
import { readLeadTurnState } from "../leadState.js";
|
|
14
17
|
import { LEAD_TARGET_GONE, LEAD_TARGET_REISSUED, leadSubscription, paneVeto, runningLeadOf } from "../leadWatch.js";
|
|
15
18
|
import { isRunningLeadActor, LEAD_KIND } from "../spawn.js";
|
|
@@ -17,67 +20,6 @@ import { commandHead, screenClassifiable } from "../harnesses.js";
|
|
|
17
20
|
const agentRefParam = z
|
|
18
21
|
.union([idParam, z.string()])
|
|
19
22
|
.describe("Running agent's name (preferred), or its numeric agent id.");
|
|
20
|
-
function resolveAgentRef(projectId, ref) {
|
|
21
|
-
return typeof ref === "number"
|
|
22
|
-
? findAgent(projectId, { agent_id: ref })
|
|
23
|
-
: findAgent(projectId, { name: ref });
|
|
24
|
-
}
|
|
25
|
-
function refuseUnclassifiableTarget(agent) {
|
|
26
|
-
if (screenClassifiable(agent.command))
|
|
27
|
-
return;
|
|
28
|
-
throw new Error(`Agent ${agent.id} ("${agent.name}") runs ${JSON.stringify(commandHead(agent.command))}, and hive can only ` +
|
|
29
|
-
"classify a claude screen. A wake is delivered by pasting its body and pressing Enter, and the two guards " +
|
|
30
|
-
"that decide whether that is safe - a dialog on screen, unsubmitted human text in the box - both read " +
|
|
31
|
-
"claude's own chrome, so on this pane neither can answer. Delivery would type blind: into a dialog it " +
|
|
32
|
-
"answers the highlighted option, and on a shell it executes whatever the paste merged with. " +
|
|
33
|
-
"Refused here rather than at delivery, where it could only ever be held forever - a pane's harness never " +
|
|
34
|
-
"becomes classifiable, so that hold would never clear, and a timer that cannot fire is worse than an " +
|
|
35
|
-
"error you can read now. Drive this pane with agent_send(keys: [...]) instead, which is unguarded for " +
|
|
36
|
-
"exactly this reason.");
|
|
37
|
-
}
|
|
38
|
-
export function resolveWakeDelivery(projectId, deliverTo, queenOperation) {
|
|
39
|
-
if (deliverTo != null) {
|
|
40
|
-
const agent = queenOperation
|
|
41
|
-
? resolveQueenLeadTarget(queenOperation, projectId, deliverTo, () => resolveAgentRef(projectId, deliverTo))
|
|
42
|
-
: resolveAgentRef(projectId, deliverTo);
|
|
43
|
-
// Before the liveness probe: the harness is a durable fact about the row, while liveness is a
|
|
44
|
-
// question about right now, so a dead bash worker should say what is actually wrong with it.
|
|
45
|
-
refuseUnclassifiableTarget(agent);
|
|
46
|
-
const ownership = agent.status === "running" ? observeOwnership(agent) : "gone";
|
|
47
|
-
if (ownership === "gone")
|
|
48
|
-
throw new Error(`Agent "${agent.name}" has no live tmux window to deliver to.`);
|
|
49
|
-
if (ownership !== "live")
|
|
50
|
-
throw notOwnedError(agent, ownership);
|
|
51
|
-
return { actor: agent.actor_id, pane: agent.tmux_target };
|
|
52
|
-
}
|
|
53
|
-
const actor = currentActor();
|
|
54
|
-
const own = db
|
|
55
|
-
.prepare("SELECT * FROM agents WHERE actor_id = ? AND status = 'running' ORDER BY id DESC LIMIT 1")
|
|
56
|
-
.get(actor);
|
|
57
|
-
if (queenOperation && !own && captureQueenWriteIdentity(projectId)) {
|
|
58
|
-
resolveQueenLeadTarget(queenOperation, projectId, deliverTo, () => {
|
|
59
|
-
throw new Error("the calling session has no running agent row to receive this wake");
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
// Checked on the ROW, not behind the liveness probe: isLive is three-state, and letting a null
|
|
63
|
-
// fall through to the TMUX_PANE branch below accepts a wake the scheduler will then hold forever
|
|
64
|
-
// against that same row's command - the never-firing timer this refusal exists to prevent.
|
|
65
|
-
const target = own && queenOperation ? resolveQueenLeadTarget(queenOperation, projectId, deliverTo, () => own) : own;
|
|
66
|
-
if (target)
|
|
67
|
-
refuseUnclassifiableTarget(target);
|
|
68
|
-
if (target) {
|
|
69
|
-
const ownership = observeOwnership(target);
|
|
70
|
-
if (ownership === "live")
|
|
71
|
-
return { actor, pane: target.tmux_target };
|
|
72
|
-
// Unknown never falls through to the ambient pane: that would bind this row's actor to a pane it may not own.
|
|
73
|
-
if (ownership === "unknown")
|
|
74
|
-
throw notOwnedError(target, ownership);
|
|
75
|
-
}
|
|
76
|
-
const pane = process.env.TMUX_PANE;
|
|
77
|
-
if (pane)
|
|
78
|
-
return { actor, pane };
|
|
79
|
-
throw new Error("This session cannot receive wake-ups: it is not running inside tmux, so nothing can be typed into its terminal. Start the lead inside tmux (run tmux, then claude; watch with tmux attach, or tmux -CC attach for iTerm's native windows), or pass deliver_to targeting a spawned agent.");
|
|
80
|
-
}
|
|
81
23
|
function pendingWakes(projectId) {
|
|
82
24
|
return db
|
|
83
25
|
.prepare(`SELECT * FROM wakes WHERE project_id = ? AND ${ACTIVE_TIMER_WHERE} ORDER BY id`)
|
|
@@ -138,16 +80,6 @@ function deliveredByNote(t) {
|
|
|
138
80
|
: null;
|
|
139
81
|
}
|
|
140
82
|
export const truncateBody = (body) => body.length > 120 ? `${cutToUnitBudget(body, 120)}…` : body;
|
|
141
|
-
export function assertSafeWakeBody(body) {
|
|
142
|
-
const bad = findUnsafeControlChar(body, TEXT_ALLOWED_CONTROL_CHARS);
|
|
143
|
-
if (bad) {
|
|
144
|
-
throw new Error(`body cannot contain ${bad.label} at offset ${bad.index}: it is typed literally into the target pane ` +
|
|
145
|
-
"when the wake fires, and a raw control byte reaches tmux as a keystroke instead of as text, " +
|
|
146
|
-
"silently turning the delivery into a keys call nobody chose. Tab and newline are the only control " +
|
|
147
|
-
"characters allowed - every wake in this project is multi-line prose. To send an actual keystroke " +
|
|
148
|
-
'on purpose, call agent_send(keys: [...]) against the target directly instead of scheduling it here.');
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
83
|
const baseWakeFields = (t, { truncate = true } = {}) => ({
|
|
152
84
|
wake_id: t.id,
|
|
153
85
|
kind: t.kind,
|
package/dist/version.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
1
|
import { readFileSync, statSync } from "node:fs";
|
|
3
2
|
import { dirname, join } from "node:path";
|
|
4
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { gitOut } from "./git.js";
|
|
5
5
|
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
6
6
|
const checkoutRoot = join(moduleDir, "..");
|
|
7
7
|
function readBuildInfo() {
|
|
@@ -60,22 +60,9 @@ function packageVersion() {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
function currentGit() {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
encoding: "utf8",
|
|
67
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
68
|
-
}).trim();
|
|
69
|
-
const status = execFileSync("git", ["status", "--porcelain"], {
|
|
70
|
-
cwd: checkoutRoot,
|
|
71
|
-
encoding: "utf8",
|
|
72
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
73
|
-
});
|
|
74
|
-
return { sha, dirty: status.trim().length > 0 };
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
63
|
+
const sha = gitOut(checkoutRoot, ["rev-parse", "--short", "HEAD"]);
|
|
64
|
+
const status = gitOut(checkoutRoot, ["status", "--porcelain"]);
|
|
65
|
+
return sha === null || status === null ? null : { sha, dirty: status.length > 0 };
|
|
79
66
|
}
|
|
80
67
|
const describe = (sha, dirty) => `${sha}${dirty ? "-dirty" : ""}`;
|
|
81
68
|
export function versionInfo() {
|
package/dist/wakeSql.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const ACTIVE_TIMER_WHERE = "cancelled_at IS NULL AND (fired_at IS NULL OR repeat_every_ms IS NOT NULL)";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
import { captureQueenWriteIdentity, currentActor, resolveQueenLeadTarget } from "./context.js";
|
|
3
|
+
import { findAgent, observeOwnership, notOwnedError } from "./agentRecords.js";
|
|
4
|
+
import { commandHead, screenClassifiable } from "./harnesses.js";
|
|
5
|
+
import { findUnsafeControlChar, TEXT_ALLOWED_CONTROL_CHARS } from "./tmux.js";
|
|
6
|
+
export function resolveAgentRef(projectId, ref) {
|
|
7
|
+
return typeof ref === "number"
|
|
8
|
+
? findAgent(projectId, { agent_id: ref })
|
|
9
|
+
: findAgent(projectId, { name: ref });
|
|
10
|
+
}
|
|
11
|
+
function refuseUnclassifiableTarget(agent) {
|
|
12
|
+
if (screenClassifiable(agent.command))
|
|
13
|
+
return;
|
|
14
|
+
throw new Error(`Agent ${agent.id} ("${agent.name}") runs ${JSON.stringify(commandHead(agent.command))}, and hive can only ` +
|
|
15
|
+
"classify a claude screen. A wake is delivered by pasting its body and pressing Enter, and the two guards " +
|
|
16
|
+
"that decide whether that is safe - a dialog on screen, unsubmitted human text in the box - both read " +
|
|
17
|
+
"claude's own chrome, so on this pane neither can answer. Delivery would type blind: into a dialog it " +
|
|
18
|
+
"answers the highlighted option, and on a shell it executes whatever the paste merged with. " +
|
|
19
|
+
"Refused here rather than at delivery, where it could only ever be held forever - a pane's harness never " +
|
|
20
|
+
"becomes classifiable, so that hold would never clear, and a timer that cannot fire is worse than an " +
|
|
21
|
+
"error you can read now. Drive this pane with agent_send(keys: [...]) instead, which is unguarded for " +
|
|
22
|
+
"exactly this reason.");
|
|
23
|
+
}
|
|
24
|
+
export function resolveWakeDelivery(projectId, deliverTo, queenOperation) {
|
|
25
|
+
if (deliverTo != null) {
|
|
26
|
+
const agent = queenOperation
|
|
27
|
+
? resolveQueenLeadTarget(queenOperation, projectId, deliverTo, () => resolveAgentRef(projectId, deliverTo))
|
|
28
|
+
: resolveAgentRef(projectId, deliverTo);
|
|
29
|
+
// Before the liveness probe: the harness is a durable fact about the row, while liveness is a
|
|
30
|
+
// question about right now, so a dead bash worker should say what is actually wrong with it.
|
|
31
|
+
refuseUnclassifiableTarget(agent);
|
|
32
|
+
const ownership = agent.status === "running" ? observeOwnership(agent) : "gone";
|
|
33
|
+
if (ownership === "gone")
|
|
34
|
+
throw new Error(`Agent "${agent.name}" has no live tmux window to deliver to.`);
|
|
35
|
+
if (ownership !== "live")
|
|
36
|
+
throw notOwnedError(agent, ownership);
|
|
37
|
+
return { actor: agent.actor_id, pane: agent.tmux_target };
|
|
38
|
+
}
|
|
39
|
+
const actor = currentActor();
|
|
40
|
+
const own = db
|
|
41
|
+
.prepare("SELECT * FROM agents WHERE actor_id = ? AND status = 'running' ORDER BY id DESC LIMIT 1")
|
|
42
|
+
.get(actor);
|
|
43
|
+
if (queenOperation && !own && captureQueenWriteIdentity(projectId)) {
|
|
44
|
+
resolveQueenLeadTarget(queenOperation, projectId, deliverTo, () => {
|
|
45
|
+
throw new Error("the calling session has no running agent row to receive this wake");
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
// Checked on the ROW, not behind the liveness probe: isLive is three-state, and letting a null
|
|
49
|
+
// fall through to the TMUX_PANE branch below accepts a wake the scheduler will then hold forever
|
|
50
|
+
// against that same row's command - the never-firing timer this refusal exists to prevent.
|
|
51
|
+
const target = own && queenOperation ? resolveQueenLeadTarget(queenOperation, projectId, deliverTo, () => own) : own;
|
|
52
|
+
if (target)
|
|
53
|
+
refuseUnclassifiableTarget(target);
|
|
54
|
+
if (target) {
|
|
55
|
+
const ownership = observeOwnership(target);
|
|
56
|
+
if (ownership === "live")
|
|
57
|
+
return { actor, pane: target.tmux_target };
|
|
58
|
+
// Unknown never falls through to the ambient pane: that would bind this row's actor to a pane it may not own.
|
|
59
|
+
if (ownership === "unknown")
|
|
60
|
+
throw notOwnedError(target, ownership);
|
|
61
|
+
}
|
|
62
|
+
const pane = process.env.TMUX_PANE;
|
|
63
|
+
if (pane)
|
|
64
|
+
return { actor, pane };
|
|
65
|
+
throw new Error("This session cannot receive wake-ups: it is not running inside tmux, so nothing can be typed into its terminal. Start the lead inside tmux (run tmux, then claude; watch with tmux attach, or tmux -CC attach for iTerm's native windows), or pass deliver_to targeting a spawned agent.");
|
|
66
|
+
}
|
|
67
|
+
export function assertSafeWakeBody(body) {
|
|
68
|
+
const bad = findUnsafeControlChar(body, TEXT_ALLOWED_CONTROL_CHARS);
|
|
69
|
+
if (bad) {
|
|
70
|
+
throw new Error(`body cannot contain ${bad.label} at offset ${bad.index}: it is typed literally into the target pane ` +
|
|
71
|
+
"when the wake fires, and a raw control byte reaches tmux as a keystroke instead of as text, " +
|
|
72
|
+
"silently turning the delivery into a keys call nobody chose. Tab and newline are the only control " +
|
|
73
|
+
"characters allowed - every wake in this project is multi-line prose. To send an actual keystroke " +
|
|
74
|
+
'on purpose, call agent_send(keys: [...]) against the target directly instead of scheduling it here.');
|
|
75
|
+
}
|
|
76
|
+
}
|
package/package.json
CHANGED