@cmgmyr/hive 1.10.1 → 1.11.1
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/README.md +1 -0
- package/claude-plugin/crew/model.mjs +2 -0
- package/dist/build-info.json +1 -1
- package/dist/cli.js +135 -52
- package/dist/context.js +49 -5
- package/dist/crew.js +20 -2
- package/dist/dashboard.js +52 -0
- package/dist/db.js +144 -0
- package/dist/help.js +27 -0
- package/dist/hook.js +32 -2
- package/dist/index.js +2 -0
- package/dist/kickoff.js +29 -6
- package/dist/leadHandoff.js +525 -0
- package/dist/leadHandoffCli.js +295 -0
- package/dist/leadHandoffState.js +96 -0
- package/dist/leadLaunch.js +54 -0
- package/dist/leadMessageDelivery.js +44 -7
- package/dist/leadState.js +98 -17
- package/dist/portfolio.js +7 -1
- package/dist/projectRemove.js +1 -0
- package/dist/projectYml.js +8 -1
- package/dist/queenAudit.js +14 -2
- package/dist/queenDashboard.js +14 -8
- package/dist/questions.js +279 -0
- package/dist/scheduler.js +25 -5
- package/dist/staleServers.js +135 -0
- package/dist/tools/agents.js +2 -2
- package/dist/tools/meta.js +4 -1
- package/dist/tools/queenAudit.js +1 -1
- package/dist/tools/questions.js +88 -0
- package/dist/tools/wakes.js +25 -17
- package/package.json +1 -1
package/dist/scheduler.js
CHANGED
|
@@ -18,7 +18,9 @@ import { evaluateLeadWatch, leadSubscription } from "./leadWatch.js";
|
|
|
18
18
|
import { describeLiveTasks, describeOneTask, liveBackgroundTasks, STALL_BOUND_SECONDS, } from "./backgroundTasks.js";
|
|
19
19
|
import { MESSAGE_MAX_ROWS, MESSAGE_RETENTION } from "./leadMessage.js";
|
|
20
20
|
import { CROSS_SESSION_CLOSE, postClaudeWake, senderAddress, SOCKET_WAKE_FOOTER } from "./claudeWake.js";
|
|
21
|
-
import {
|
|
21
|
+
import { driveLeadHandoff } from "./leadHandoff.js";
|
|
22
|
+
import { handoffHoldsWake, HELD_REASON_HANDOFF } from "./leadHandoffState.js";
|
|
23
|
+
import { FIRST_MESSAGE_LOG_STATE, humanPromptSql, readLeadTurnState } from "./leadState.js";
|
|
22
24
|
import { retryQuietLeadMessages } from "./leadMessageDelivery.js";
|
|
23
25
|
import { harnessFor, hasTranscriptSignal, paneClassifierFor, screenClassifiable, transcriptDirFor } from "./harnesses.js";
|
|
24
26
|
import { transcriptDir, readContextFill } from "./transcript.js";
|
|
@@ -481,6 +483,17 @@ export function checkConfirmations() {
|
|
|
481
483
|
catch {
|
|
482
484
|
}
|
|
483
485
|
}
|
|
486
|
+
// Only the lead's own server drives its handoff; every server honours the hold it produces.
|
|
487
|
+
function driveOwnLeadHandoff() {
|
|
488
|
+
const actor = process.env.HIVE_AGENT_ID;
|
|
489
|
+
if (!actor || !isLeadActorId(actor))
|
|
490
|
+
return;
|
|
491
|
+
try {
|
|
492
|
+
driveLeadHandoff(actor, (path) => loadProjectYml(path).config?.lead_turn_budget ?? null);
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
}
|
|
496
|
+
}
|
|
484
497
|
export async function tick(snapshot) {
|
|
485
498
|
if (ticking)
|
|
486
499
|
return;
|
|
@@ -496,6 +509,7 @@ export async function tick(snapshot) {
|
|
|
496
509
|
janitor(snapshot);
|
|
497
510
|
if (snapshot)
|
|
498
511
|
reportRunningBuildChange(snapshot);
|
|
512
|
+
driveOwnLeadHandoff();
|
|
499
513
|
checkConfirmations();
|
|
500
514
|
pruneStateLog();
|
|
501
515
|
maybeBackupHourly(db, dataDir);
|
|
@@ -667,11 +681,9 @@ function conversationHoldsWake(timer, snapshot) {
|
|
|
667
681
|
return false;
|
|
668
682
|
}
|
|
669
683
|
return (stmt(`SELECT 1 AS hit FROM agent_state_log
|
|
670
|
-
WHERE actor_id = ? AND event = 'prompt' AND
|
|
671
|
-
AND CASE WHEN json_valid(payload) THEN COALESCE(json_extract(payload, '$.prompt'), payload) ELSE payload END NOT LIKE '<task-notification>%'
|
|
672
|
-
AND CASE WHEN json_valid(payload) THEN COALESCE(json_extract(payload, '$.prompt'), payload) ELSE payload END NOT LIKE '<cross-session-message from="%" from-name="hive">' || char(10) || '[hive:worker %] [message #%,%' || char(10) || '</cross-session-message>%'
|
|
684
|
+
WHERE actor_id = ? AND event = 'prompt' AND state != ? AND ${humanPromptSql("payload")}
|
|
673
685
|
AND created_at >= datetime('now', ?)
|
|
674
|
-
ORDER BY id DESC LIMIT 1`).get(timer.deliver_actor, CONVERSATION_HOLD_TTL) !== undefined);
|
|
686
|
+
ORDER BY id DESC LIMIT 1`).get(timer.deliver_actor, FIRST_MESSAGE_LOG_STATE, CONVERSATION_HOLD_TTL) !== undefined);
|
|
675
687
|
}
|
|
676
688
|
function heldTarget(timer) {
|
|
677
689
|
const row = stmt(`SELECT id, name, kind, COALESCE(state_changed_at, '') AS blocked_since FROM agents WHERE actor_id = ?
|
|
@@ -1530,6 +1542,10 @@ function deliverable(timer, snapshot, choices) {
|
|
|
1530
1542
|
else {
|
|
1531
1543
|
owner = { rowId: row.deliver_row_id, identity: deliveryIdentity(row) };
|
|
1532
1544
|
}
|
|
1545
|
+
if (handoffHoldsWake(timer.id, row.deliver_row_id)) {
|
|
1546
|
+
holdTimer(timer, HELD_REASON_HANDOFF);
|
|
1547
|
+
return { ok: false };
|
|
1548
|
+
}
|
|
1533
1549
|
// Above both pane reads: this population must not be captured at all, and the verdicts would be
|
|
1534
1550
|
// wrong in opposite directions if they were.
|
|
1535
1551
|
if (!screenClassifiable(timer.deliver_command)) {
|
|
@@ -1609,6 +1625,10 @@ async function withPaneClaim(timer, fn) {
|
|
|
1609
1625
|
}
|
|
1610
1626
|
}
|
|
1611
1627
|
async function fireDelay(timer, snapshot, choices) {
|
|
1628
|
+
if (handoffHoldsWake(timer.id, timer.deliver_row_id)) {
|
|
1629
|
+
holdTimer(timer, HELD_REASON_HANDOFF);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1612
1632
|
const disposition = noticeDisposition(timer);
|
|
1613
1633
|
if (disposition === "aged") {
|
|
1614
1634
|
try {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { DEFAULT_DATA_DIR } from "./dataDir.js";
|
|
6
|
+
export const STALE_SERVER_BOUNDARY_SLACK_MS = 3000;
|
|
7
|
+
export function readProcessEnvironment(pid) {
|
|
8
|
+
try {
|
|
9
|
+
return execFileSync("ps", ["eww", "-p", String(pid), "-o", "command="], { encoding: "utf8" });
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (typeof error === "object" && error !== null && "status" in error && error.status === 1 &&
|
|
13
|
+
"stdout" in error && String(error.stdout ?? "").trim() === "" &&
|
|
14
|
+
"stderr" in error && (String(error.stderr ?? "").trim() === "" || /no such process|process id .* (?:not found|does not exist)/i.test(String(error.stderr)))) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const serverEntrypoint = resolve(dirname(fileURLToPath(import.meta.url)), "index.js");
|
|
21
|
+
export function elapsedSeconds(value) {
|
|
22
|
+
const dayForm = /^(\d+)-(\d{1,2}):(\d{2}):(\d{2})$/.exec(value);
|
|
23
|
+
const fields = dayForm ? dayForm.slice(2).map(Number) : value.split(":").map(Number);
|
|
24
|
+
if ((!dayForm && fields.length !== 2 && fields.length !== 3) || fields.some((field) => !Number.isSafeInteger(field) || field < 0)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
let total;
|
|
28
|
+
if (dayForm) {
|
|
29
|
+
const [hours, minutes, seconds] = fields;
|
|
30
|
+
if (hours > 23 || minutes > 59 || seconds > 59)
|
|
31
|
+
return null;
|
|
32
|
+
total = (Number(dayForm[1]) * 24 + hours) * 3600 + minutes * 60 + seconds;
|
|
33
|
+
}
|
|
34
|
+
else if (fields.length === 2) {
|
|
35
|
+
const [minutes, seconds] = fields;
|
|
36
|
+
if (seconds > 59)
|
|
37
|
+
return null;
|
|
38
|
+
total = minutes * 60 + seconds;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const [hours, minutes, seconds] = fields;
|
|
42
|
+
if (minutes > 59 || seconds > 59)
|
|
43
|
+
return null;
|
|
44
|
+
total = hours * 3600 + minutes * 60 + seconds;
|
|
45
|
+
}
|
|
46
|
+
return Number.isSafeInteger(total) ? total : null;
|
|
47
|
+
}
|
|
48
|
+
function appliedAtInstant(appliedAt) {
|
|
49
|
+
if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(appliedAt))
|
|
50
|
+
return null;
|
|
51
|
+
const instant = Date.parse(`${appliedAt.replace(" ", "T")}Z`);
|
|
52
|
+
return Number.isFinite(instant) ? instant : null;
|
|
53
|
+
}
|
|
54
|
+
function runsThisServer(command) {
|
|
55
|
+
const escapedPath = serverEntrypoint.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
56
|
+
const script = new RegExp(`(?:^|\\s|["'])${escapedPath}(?=$|\\s|["'])`).exec(command);
|
|
57
|
+
if (!script)
|
|
58
|
+
return false;
|
|
59
|
+
const executable = command.slice(0, script.index + script[0].indexOf(serverEntrypoint)).trim();
|
|
60
|
+
return /(?:^|[\\/])node(?:\.exe)?$/.test(executable);
|
|
61
|
+
}
|
|
62
|
+
function canonicalDataDir(path) {
|
|
63
|
+
try {
|
|
64
|
+
return realpathSync(path);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return resolve(path);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function processUsesDataDir(psOutput, dataDir) {
|
|
71
|
+
if (psOutput.trim() === "")
|
|
72
|
+
return false;
|
|
73
|
+
if (!runsThisServer(psOutput))
|
|
74
|
+
return false;
|
|
75
|
+
const store = /(?:^|\s)HIVE_DATA_DIR=(.*?)(?=\s+[A-Za-z_][A-Za-z0-9_]*=|$)/.exec(psOutput);
|
|
76
|
+
const path = store ? store[1].trim() || DEFAULT_DATA_DIR : /(?:^|\s)PATH=/.test(psOutput) ? DEFAULT_DATA_DIR : null;
|
|
77
|
+
if (path === null)
|
|
78
|
+
return null;
|
|
79
|
+
return canonicalDataDir(path) === canonicalDataDir(dataDir);
|
|
80
|
+
}
|
|
81
|
+
export function parseStaleHiveServers(psOutput, appliedAt, observedAt = Date.now()) {
|
|
82
|
+
const newestMigration = appliedAtInstant(appliedAt);
|
|
83
|
+
if (newestMigration === null)
|
|
84
|
+
return { skipped: "the newest migration time is unavailable or invalid" };
|
|
85
|
+
if (!Number.isFinite(observedAt))
|
|
86
|
+
return { skipped: "the process snapshot time is invalid" };
|
|
87
|
+
const rows = [];
|
|
88
|
+
for (const line of psOutput.split(/\r?\n/)) {
|
|
89
|
+
if (line.trim() === "")
|
|
90
|
+
continue;
|
|
91
|
+
const row = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+?)\s*$/.exec(line);
|
|
92
|
+
if (!row)
|
|
93
|
+
return { skipped: "ps returned a row that could not be parsed" };
|
|
94
|
+
const elapsed = elapsedSeconds(row[3]);
|
|
95
|
+
if (elapsed === null)
|
|
96
|
+
return { skipped: "ps returned an invalid elapsed time" };
|
|
97
|
+
rows.push({ pid: Number(row[1]), ppid: Number(row[2]), elapsed, command: row[4] });
|
|
98
|
+
}
|
|
99
|
+
const commands = new Map(rows.map((row) => [row.pid, row.command]));
|
|
100
|
+
const servers = rows
|
|
101
|
+
.map((row) => ({ ...row, started: observedAt - row.elapsed * 1000 }))
|
|
102
|
+
.filter((row) => row.started < newestMigration - STALE_SERVER_BOUNDARY_SLACK_MS && runsThisServer(row.command))
|
|
103
|
+
.map(({ elapsed: _elapsed, ...row }) => ({
|
|
104
|
+
...row,
|
|
105
|
+
startedAt: new Date(row.started).toISOString(),
|
|
106
|
+
parentCommand: commands.get(row.ppid) ?? null,
|
|
107
|
+
}));
|
|
108
|
+
return { servers };
|
|
109
|
+
}
|
|
110
|
+
export function staleHiveServers(appliedAt, dataDir = DEFAULT_DATA_DIR, readEnvironment = readProcessEnvironment) {
|
|
111
|
+
if (appliedAt === null)
|
|
112
|
+
return { skipped: "the newest migration time is unavailable" };
|
|
113
|
+
try {
|
|
114
|
+
const psOutput = execFileSync("ps", ["-eo", "pid=,ppid=,etime=,command="], { encoding: "utf8" });
|
|
115
|
+
const parsed = parseStaleHiveServers(psOutput, appliedAt, Date.now());
|
|
116
|
+
if ("skipped" in parsed)
|
|
117
|
+
return parsed;
|
|
118
|
+
const servers = [];
|
|
119
|
+
for (const server of parsed.servers) {
|
|
120
|
+
const envOutput = readEnvironment(server.pid);
|
|
121
|
+
if (envOutput === null)
|
|
122
|
+
continue;
|
|
123
|
+
const sameStore = processUsesDataDir(envOutput, dataDir);
|
|
124
|
+
if (sameStore === null)
|
|
125
|
+
return { skipped: "ps returned environment output that could not be parsed" };
|
|
126
|
+
if (sameStore)
|
|
127
|
+
servers.push(server);
|
|
128
|
+
}
|
|
129
|
+
return { servers };
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
133
|
+
return { skipped: `ps could not be run: ${reason}` };
|
|
134
|
+
}
|
|
135
|
+
}
|
package/dist/tools/agents.js
CHANGED
|
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { db } from "../db.js";
|
|
5
5
|
import { confirmQueenWrite } from "../queenAudit.js";
|
|
6
|
-
import { captureQueenWriteIdentity } from "../context.js";
|
|
6
|
+
import { captureQueenWriteIdentity, resolveQueenLeadTarget } from "../context.js";
|
|
7
7
|
import { agentBriefPath, mergedBriefVars, readAgentBrief, workerBrief, workerCommandString, writeAgentBrief, } from "../brief.js";
|
|
8
8
|
import { codexHomeDir, codexInstructionsPhrase, codexLaunchArgs, ensureCodexHome, ensureCodexHooksFile, refreshCodexMcpEnv } from "../codexHome.js";
|
|
9
9
|
import { currentActor, findProjectForDir, getProject, linkedWorktreePrimaryRoot, resolveProject } from "../context.js";
|
|
@@ -1171,7 +1171,7 @@ export function registerAgents(server) {
|
|
|
1171
1171
|
},
|
|
1172
1172
|
}, (args) => run(async () => {
|
|
1173
1173
|
const project = resolveProject(args.project_id);
|
|
1174
|
-
const agent = findAgent(project.id, args);
|
|
1174
|
+
const agent = resolveQueenLeadTarget("agent_send", project.id, args.agent_id ?? args.name, () => findAgent(project.id, args));
|
|
1175
1175
|
requireLive(agent);
|
|
1176
1176
|
const target = agent.tmux_target;
|
|
1177
1177
|
const auditIdentity = captureQueenWriteIdentity(project.id);
|
package/dist/tools/meta.js
CHANGED
|
@@ -36,6 +36,9 @@ export const ACTOR_OWNER_COLUMNS = [
|
|
|
36
36
|
["wakes", "owner"],
|
|
37
37
|
["wakes", "deliver_actor"],
|
|
38
38
|
["agent_state_log", "actor_id"],
|
|
39
|
+
["questions", "asker"],
|
|
40
|
+
["questions", "updated_by"],
|
|
41
|
+
["questions", "answered_by"],
|
|
39
42
|
];
|
|
40
43
|
function actorOwnsRows(actorId) {
|
|
41
44
|
return ACTOR_OWNER_COLUMNS.some(([table, column]) => existsWhere(table, column, actorId));
|
|
@@ -139,7 +142,7 @@ export function registerMeta(server) {
|
|
|
139
142
|
inputSchema: { project_id: idParam },
|
|
140
143
|
}, ({ project_id }) => run(() => selectProjectById(project_id)));
|
|
141
144
|
server.registerTool("project_prune", {
|
|
142
|
-
description: "Delete every registered project that owns no rows anywhere in the store (pads, todos, kv, leases, agents, wakes, command_trust), verified individually before each delete. Never prunes the caller's own project. With project_id, removes exactly that one project instead of sweeping, and only if it owns no rows; otherwise refuses and names what it owns. With project_id AND confirm_name (the project's exact name), removes that one project even when it owns rows: it takes a snapshot first, refuses while a running row owns a live pane or its pane ownership cannot be verified (agent_close row_only retires the latter), then deletes the project with everything it owns (todos, comments, pads, kv, leases, agents, wakes, trust, agent messages) in one transaction and reports per-table counts and the snapshot path. queen_audit rows naming it are kept, and so are other projects' lead-idle subscriptions that target it, so a watch on its lead ends with a named reason. confirm_name without project_id is refused, and there is no forced sweep. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep, and a project-locked session may only touch its own project. Immediate, permanent: no dry-run mode.",
|
|
145
|
+
description: "Delete every registered project that owns no rows anywhere in the store (pads, todos, kv, leases, agents, wakes, command_trust, questions), verified individually before each delete. Never prunes the caller's own project. With project_id, removes exactly that one project instead of sweeping, and only if it owns no rows; otherwise refuses and names what it owns. With project_id AND confirm_name (the project's exact name), removes that one project even when it owns rows: it takes a snapshot first, refuses while a running row owns a live pane or its pane ownership cannot be verified (agent_close row_only retires the latter), then deletes the project with everything it owns (todos, comments, pads, kv, leases, agents, wakes, trust, questions, agent messages) in one transaction and reports per-table counts and the snapshot path. queen_audit rows naming it are kept, and so are other projects' lead-idle subscriptions that target it, so a watch on its lead ends with a named reason. confirm_name without project_id is refused, and there is no forced sweep. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep, and a project-locked session may only touch its own project. Immediate, permanent: no dry-run mode.",
|
|
143
146
|
annotations: {
|
|
144
147
|
readOnlyHint: false,
|
|
145
148
|
destructiveHint: true,
|
package/dist/tools/queenAudit.js
CHANGED
|
@@ -3,7 +3,7 @@ import { run } from "../result.js";
|
|
|
3
3
|
import { idParam, limitParam } from "./params.js";
|
|
4
4
|
export function registerQueenAudit(server) {
|
|
5
5
|
server.registerTool("queen_audit_list", {
|
|
6
|
-
description: "List confirmed queen writes
|
|
6
|
+
description: "List confirmed queen writes and refused cross-project guard calls, newest first. Refusal rows include outcome=refused and the problem. The queen may read all targets; other callers see only their own project. Default 20, maximum 100 entries. History is retained for 30 days with a 20,000 id-range backstop. A crash between a completed terminal send and its audit insert can leave that send unrecorded.",
|
|
7
7
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
8
8
|
inputSchema: {
|
|
9
9
|
project_id: idParam.optional().describe("Filter by the target project id. Only the queen may name another project."),
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { currentActor, effectiveProjectId } from "../context.js";
|
|
3
|
+
import { run } from "../result.js";
|
|
4
|
+
import { answerQuestion, askQuestion, getQuestion, listQuestions, updateQuestion, } from "../questions.js";
|
|
5
|
+
import { idParam } from "./params.js";
|
|
6
|
+
const optionParam = z.strictObject({
|
|
7
|
+
label: z.string().describe("Short choice label; distinct within the question."),
|
|
8
|
+
description: z.string().describe("What choosing this means."),
|
|
9
|
+
recommended: z.boolean().describe("Exactly one option is recommended; it is the default."),
|
|
10
|
+
});
|
|
11
|
+
const refParam = z.strictObject({
|
|
12
|
+
kind: z.enum(["todo", "pr"]),
|
|
13
|
+
number: idParam.describe("A todo id or PR number. An identifier, not looked up."),
|
|
14
|
+
});
|
|
15
|
+
const stateParam = z.enum(["answered", "defaulted", "pending", "withdrawn"]);
|
|
16
|
+
const noWrites = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
17
|
+
const writes = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
|
|
18
|
+
export function registerQuestions(server) {
|
|
19
|
+
server.registerTool("question_ask", {
|
|
20
|
+
description: "Record an open question for the human, keyed to this project and to you as the asker. kind=choice takes 2-4 options (each label, description, recommended; exactly one recommended) and a header of 12 characters or fewer; kind=free_text takes just the question and header. Every question carries default_answer: for a choice it is the recommended option's label. Pass ref (a todo id or PR number) to dedupe: asking again with a ref that already has a PENDING question in this project updates that row instead of adding one. The receipt lists other pending questions in this project that look similar (same normalized text, or at least half their longer words shared), so you can question_update one instead. The record is only stored: put the question to the human yourself, in your own turn. You must be a session an answer can be woken back to. When the question is answered, an ordinary wake carrying the answer is queued to you; read the record with question_get.",
|
|
21
|
+
annotations: writes,
|
|
22
|
+
inputSchema: {
|
|
23
|
+
kind: z.enum(["choice", "free_text"]),
|
|
24
|
+
question: z.string(),
|
|
25
|
+
header: z.string().describe("12 characters or fewer."),
|
|
26
|
+
options: z.array(optionParam).optional().describe("Required for choice, forbidden for free_text."),
|
|
27
|
+
default_answer: z.string().describe("What you do if unanswered. For a choice, the recommended label."),
|
|
28
|
+
ref: refParam.optional(),
|
|
29
|
+
},
|
|
30
|
+
outputSchema: {
|
|
31
|
+
question_id: idParam,
|
|
32
|
+
created: z.boolean(),
|
|
33
|
+
similar_question_ids: z.array(idParam),
|
|
34
|
+
},
|
|
35
|
+
}, (args) => run(() => {
|
|
36
|
+
const projectId = effectiveProjectId();
|
|
37
|
+
const input = {
|
|
38
|
+
kind: args.kind,
|
|
39
|
+
question: args.question,
|
|
40
|
+
header: args.header,
|
|
41
|
+
default_answer: args.default_answer,
|
|
42
|
+
...(args.options !== undefined ? { options: args.options } : {}),
|
|
43
|
+
...(args.ref !== undefined ? { ref: args.ref } : {}),
|
|
44
|
+
};
|
|
45
|
+
return askQuestion(projectId, currentActor(), input);
|
|
46
|
+
}));
|
|
47
|
+
server.registerTool("question_get", {
|
|
48
|
+
description: "Read one question in this project by id, in any state, with its answer and notes once answered.",
|
|
49
|
+
annotations: noWrites,
|
|
50
|
+
inputSchema: { question_id: idParam },
|
|
51
|
+
}, (args) => run(() => getQuestion(effectiveProjectId(), args.question_id)));
|
|
52
|
+
server.registerTool("question_list", {
|
|
53
|
+
description: "List this project's pending questions, then its defaulted ones (acted on by default, still open to reversal), oldest first within each group. Answered and withdrawn questions are not listed; read one by id with question_get.",
|
|
54
|
+
annotations: noWrites,
|
|
55
|
+
inputSchema: {},
|
|
56
|
+
}, () => run(() => ({ questions: listQuestions(effectiveProjectId()) })));
|
|
57
|
+
server.registerTool("question_update", {
|
|
58
|
+
description: "Edit a pending question's text (question, header, options, default_answer; the whole result is revalidated) or move it out of pending with state=defaulted (you acted on your default) or state=withdrawn (moot). A defaulted question can still be withdrawn. Moving state needs the asking session or the human; an answered or withdrawn question cannot be changed. The ref, asker and wake target never change.",
|
|
59
|
+
annotations: writes,
|
|
60
|
+
inputSchema: {
|
|
61
|
+
question_id: idParam,
|
|
62
|
+
question: z.string().optional(),
|
|
63
|
+
header: z.string().optional(),
|
|
64
|
+
options: z.array(optionParam).optional(),
|
|
65
|
+
default_answer: z.string().optional(),
|
|
66
|
+
state: z.enum(["defaulted", "withdrawn"]).optional(),
|
|
67
|
+
},
|
|
68
|
+
outputSchema: { question_id: idParam, state: stateParam },
|
|
69
|
+
}, (args) => run(() => {
|
|
70
|
+
const { question_id, ...patch } = args;
|
|
71
|
+
return updateQuestion(effectiveProjectId(), currentActor(), question_id, patch);
|
|
72
|
+
}));
|
|
73
|
+
server.registerTool("question_answer", {
|
|
74
|
+
description: "Record the human's answer to a pending or defaulted question in this project, plus optional notes. The answer may be a picked option label or free text, including text outside the offered labels. The answer update comes first; the answer and any wake commit together, and the answer stays readable with `question_get`. An answered or withdrawn question refuses with `QUESTION_STATE_CONFLICT`. A queued wake carries the question, answer and notes verbatim. When you are the asker, skip with `wake_id: null` and `wake_skipped`; otherwise, queue a wake to the running asker or, if the asker has no running row, to the project's running lead with `wake_rerouted_to`. Skip that fallback with `wake_id: null` and `wake_skipped` if no lead runs or the lead is the answerer. `wake_id` means queued, not delivered.",
|
|
75
|
+
annotations: writes,
|
|
76
|
+
inputSchema: {
|
|
77
|
+
question_id: idParam,
|
|
78
|
+
answer: z.string(),
|
|
79
|
+
notes: z.string().optional(),
|
|
80
|
+
},
|
|
81
|
+
outputSchema: {
|
|
82
|
+
question_id: idParam,
|
|
83
|
+
wake_id: idParam.nullable(),
|
|
84
|
+
wake_skipped: z.string().optional(),
|
|
85
|
+
wake_rerouted_to: z.string().optional(),
|
|
86
|
+
},
|
|
87
|
+
}, (args) => run(() => answerQuestion(effectiveProjectId(), currentActor(), args.question_id, args.answer, args.notes)));
|
|
88
|
+
}
|
package/dist/tools/wakes.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { db } from "../db.js";
|
|
3
3
|
import { commitQueenStateWrite } from "../queenAudit.js";
|
|
4
|
-
import { currentActor, effectiveProjectId, getProject, isQueenLead } from "../context.js";
|
|
4
|
+
import { captureQueenWriteIdentity, currentActor, effectiveProjectId, getProject, isQueenLead, resolveQueenLeadTarget, } from "../context.js";
|
|
5
5
|
import { run } from "../result.js";
|
|
6
6
|
import { findAgent, observeOwnership, notOwnedError, summaryLiveness } from "./agents.js";
|
|
7
7
|
import { ACTIVE_TIMER_WHERE, LOG_RETENTION, OWNED_BY_WATCH, WATCH_SCOPE_PROJECT, seedGoneCursor, } from "../scheduler.js";
|
|
@@ -35,9 +35,11 @@ function refuseUnclassifiableTarget(agent) {
|
|
|
35
35
|
"error you can read now. Drive this pane with agent_send(keys: [...]) instead, which is unguarded for " +
|
|
36
36
|
"exactly this reason.");
|
|
37
37
|
}
|
|
38
|
-
function
|
|
38
|
+
export function resolveWakeDelivery(projectId, deliverTo, queenOperation) {
|
|
39
39
|
if (deliverTo != null) {
|
|
40
|
-
const agent =
|
|
40
|
+
const agent = queenOperation
|
|
41
|
+
? resolveQueenLeadTarget(queenOperation, projectId, deliverTo, () => resolveAgentRef(projectId, deliverTo))
|
|
42
|
+
: resolveAgentRef(projectId, deliverTo);
|
|
41
43
|
// Before the liveness probe: the harness is a durable fact about the row, while liveness is a
|
|
42
44
|
// question about right now, so a dead bash worker should say what is actually wrong with it.
|
|
43
45
|
refuseUnclassifiableTarget(agent);
|
|
@@ -52,18 +54,24 @@ function resolveDelivery(projectId, deliverTo) {
|
|
|
52
54
|
const own = db
|
|
53
55
|
.prepare("SELECT * FROM agents WHERE actor_id = ? AND status = 'running' ORDER BY id DESC LIMIT 1")
|
|
54
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
|
+
}
|
|
55
62
|
// Checked on the ROW, not behind the liveness probe: isLive is three-state, and letting a null
|
|
56
63
|
// fall through to the TMUX_PANE branch below accepts a wake the scheduler will then hold forever
|
|
57
64
|
// against that same row's command - the never-firing timer this refusal exists to prevent.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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);
|
|
62
70
|
if (ownership === "live")
|
|
63
|
-
return { actor, pane:
|
|
71
|
+
return { actor, pane: target.tmux_target };
|
|
64
72
|
// Unknown never falls through to the ambient pane: that would bind this row's actor to a pane it may not own.
|
|
65
73
|
if (ownership === "unknown")
|
|
66
|
-
throw notOwnedError(
|
|
74
|
+
throw notOwnedError(target, ownership);
|
|
67
75
|
}
|
|
68
76
|
const pane = process.env.TMUX_PANE;
|
|
69
77
|
if (pane)
|
|
@@ -130,7 +138,7 @@ function deliveredByNote(t) {
|
|
|
130
138
|
: null;
|
|
131
139
|
}
|
|
132
140
|
export const truncateBody = (body) => body.length > 120 ? `${cutToUnitBudget(body, 120)}…` : body;
|
|
133
|
-
function
|
|
141
|
+
export function assertSafeWakeBody(body) {
|
|
134
142
|
const bad = findUnsafeControlChar(body, TEXT_ALLOWED_CONTROL_CHARS);
|
|
135
143
|
if (bad) {
|
|
136
144
|
throw new Error(`body cannot contain ${bad.label} at offset ${bad.index}: it is typed literally into the target pane ` +
|
|
@@ -173,7 +181,7 @@ const openStandingWatch = db.transaction((projectId, owner, body, deliverActor,
|
|
|
173
181
|
return row;
|
|
174
182
|
});
|
|
175
183
|
function createStandingWatch(projectId, args) {
|
|
176
|
-
const delivery =
|
|
184
|
+
const delivery = resolveWakeDelivery(projectId, args.deliver_to);
|
|
177
185
|
const maxWait = args.max_wait_seconds ?? STANDING_WATCH_LIFETIME_SECONDS;
|
|
178
186
|
const row = openStandingWatch.immediate(projectId, currentActor(), args.body, delivery.actor, delivery.pane, maxWait);
|
|
179
187
|
const crew = db
|
|
@@ -261,7 +269,7 @@ function createLeadWatch(leadProjectId, args) {
|
|
|
261
269
|
note: `Project ${leadProjectId}'s lead has already ended its turn; nothing was scheduled. Read it now.`,
|
|
262
270
|
};
|
|
263
271
|
}
|
|
264
|
-
const delivery =
|
|
272
|
+
const delivery = resolveWakeDelivery(home);
|
|
265
273
|
const maxWait = args.max_wait_seconds ?? 900;
|
|
266
274
|
const row = openLeadWatch.immediate(home, args.body, mode, delivery, maxWait, {
|
|
267
275
|
projectId: leadProjectId,
|
|
@@ -329,9 +337,9 @@ export function registerWakes(server) {
|
|
|
329
337
|
repeating: z.boolean(),
|
|
330
338
|
},
|
|
331
339
|
}, (args) => run(() => {
|
|
332
|
-
|
|
340
|
+
assertSafeWakeBody(args.body);
|
|
333
341
|
const projectId = effectiveProjectId(args.project_id);
|
|
334
|
-
const delivery =
|
|
342
|
+
const delivery = resolveWakeDelivery(projectId, args.deliver_to, "wake_set");
|
|
335
343
|
return commitQueenStateWrite("wake_set", projectId, args, () => {
|
|
336
344
|
const row = db
|
|
337
345
|
.prepare(`INSERT INTO wakes (project_id, owner, body, kind, deliver_actor, deliver_pane, due_at, repeat_every_ms)
|
|
@@ -407,7 +415,7 @@ export function registerWakes(server) {
|
|
|
407
415
|
note: z.string().optional(),
|
|
408
416
|
},
|
|
409
417
|
}, (args) => run(() => {
|
|
410
|
-
|
|
418
|
+
assertSafeWakeBody(args.body);
|
|
411
419
|
const projectId = effectiveProjectId(args.project_id);
|
|
412
420
|
const selectors = [
|
|
413
421
|
args.agents != null && "agents",
|
|
@@ -441,7 +449,7 @@ export function registerWakes(server) {
|
|
|
441
449
|
"reported as a timeout instead of the failure it actually is. A lead's turn has its own channel, " +
|
|
442
450
|
"which only the queen may watch, with lead_project_id=<that lead's project id>.");
|
|
443
451
|
}
|
|
444
|
-
const delivery =
|
|
452
|
+
const delivery = resolveWakeDelivery(projectId, args.deliver_to, "wake_when_idle");
|
|
445
453
|
const snapshot = liveTargets();
|
|
446
454
|
if (mode === "all") {
|
|
447
455
|
const allIdle = watched.every((a) => {
|
|
@@ -577,7 +585,7 @@ export function registerWakes(server) {
|
|
|
577
585
|
const sets = [];
|
|
578
586
|
const params = [];
|
|
579
587
|
if (args.body != null) {
|
|
580
|
-
|
|
588
|
+
assertSafeWakeBody(args.body);
|
|
581
589
|
sets.push("body = ?");
|
|
582
590
|
params.push(args.body);
|
|
583
591
|
}
|
package/package.json
CHANGED