@cmgmyr/hive 1.10.0 → 1.11.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/crew/model.mjs +3 -0
- package/dist/build-info.json +1 -1
- package/dist/cli.js +125 -46
- package/dist/context.js +7 -1
- package/dist/crew.js +20 -2
- package/dist/db.js +139 -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 +2 -0
- package/dist/projectRemove.js +1 -0
- package/dist/projectYml.js +8 -1
- package/dist/questions.js +254 -0
- package/dist/scheduler.js +25 -5
- package/dist/spawn.js +44 -15
- package/dist/staleServers.js +122 -0
- package/dist/tmux.js +9 -6
- package/dist/tools/agents.js +35 -35
- package/dist/tools/meta.js +4 -1
- package/dist/tools/questions.js +87 -0
- package/dist/tools/wakes.js +9 -9
- package/package.json +1 -1
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
const serverEntrypoint = resolve(dirname(fileURLToPath(import.meta.url)), "index.js");
|
|
8
|
+
export function elapsedSeconds(value) {
|
|
9
|
+
const dayForm = /^(\d+)-(\d{1,2}):(\d{2}):(\d{2})$/.exec(value);
|
|
10
|
+
const fields = dayForm ? dayForm.slice(2).map(Number) : value.split(":").map(Number);
|
|
11
|
+
if ((!dayForm && fields.length !== 2 && fields.length !== 3) || fields.some((field) => !Number.isSafeInteger(field) || field < 0)) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
let total;
|
|
15
|
+
if (dayForm) {
|
|
16
|
+
const [hours, minutes, seconds] = fields;
|
|
17
|
+
if (hours > 23 || minutes > 59 || seconds > 59)
|
|
18
|
+
return null;
|
|
19
|
+
total = (Number(dayForm[1]) * 24 + hours) * 3600 + minutes * 60 + seconds;
|
|
20
|
+
}
|
|
21
|
+
else if (fields.length === 2) {
|
|
22
|
+
const [minutes, seconds] = fields;
|
|
23
|
+
if (seconds > 59)
|
|
24
|
+
return null;
|
|
25
|
+
total = minutes * 60 + seconds;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
const [hours, minutes, seconds] = fields;
|
|
29
|
+
if (minutes > 59 || seconds > 59)
|
|
30
|
+
return null;
|
|
31
|
+
total = hours * 3600 + minutes * 60 + seconds;
|
|
32
|
+
}
|
|
33
|
+
return Number.isSafeInteger(total) ? total : null;
|
|
34
|
+
}
|
|
35
|
+
function appliedAtInstant(appliedAt) {
|
|
36
|
+
if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(appliedAt))
|
|
37
|
+
return null;
|
|
38
|
+
const instant = Date.parse(`${appliedAt.replace(" ", "T")}Z`);
|
|
39
|
+
return Number.isFinite(instant) ? instant : null;
|
|
40
|
+
}
|
|
41
|
+
function runsThisServer(command) {
|
|
42
|
+
const escapedPath = serverEntrypoint.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
43
|
+
const script = new RegExp(`(?:^|\\s|["'])${escapedPath}(?=$|\\s|["'])`).exec(command);
|
|
44
|
+
if (!script)
|
|
45
|
+
return false;
|
|
46
|
+
const executable = command.slice(0, script.index + script[0].indexOf(serverEntrypoint)).trim();
|
|
47
|
+
return /(?:^|[\\/])node(?:\.exe)?$/.test(executable);
|
|
48
|
+
}
|
|
49
|
+
function canonicalDataDir(path) {
|
|
50
|
+
try {
|
|
51
|
+
return realpathSync(path);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return resolve(path);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function processUsesDataDir(psOutput, dataDir) {
|
|
58
|
+
if (psOutput.trim() === "")
|
|
59
|
+
return false;
|
|
60
|
+
if (!runsThisServer(psOutput))
|
|
61
|
+
return false;
|
|
62
|
+
const store = /(?:^|\s)HIVE_DATA_DIR=(.*?)(?=\s+[A-Za-z_][A-Za-z0-9_]*=|$)/.exec(psOutput);
|
|
63
|
+
const path = store ? store[1].trim() || DEFAULT_DATA_DIR : /(?:^|\s)PATH=/.test(psOutput) ? DEFAULT_DATA_DIR : null;
|
|
64
|
+
if (path === null)
|
|
65
|
+
return null;
|
|
66
|
+
return canonicalDataDir(path) === canonicalDataDir(dataDir);
|
|
67
|
+
}
|
|
68
|
+
export function parseStaleHiveServers(psOutput, appliedAt, observedAt = Date.now()) {
|
|
69
|
+
const newestMigration = appliedAtInstant(appliedAt);
|
|
70
|
+
if (newestMigration === null)
|
|
71
|
+
return { skipped: "the newest migration time is unavailable or invalid" };
|
|
72
|
+
if (!Number.isFinite(observedAt))
|
|
73
|
+
return { skipped: "the process snapshot time is invalid" };
|
|
74
|
+
const rows = [];
|
|
75
|
+
for (const line of psOutput.split(/\r?\n/)) {
|
|
76
|
+
if (line.trim() === "")
|
|
77
|
+
continue;
|
|
78
|
+
const row = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+?)\s*$/.exec(line);
|
|
79
|
+
if (!row)
|
|
80
|
+
return { skipped: "ps returned a row that could not be parsed" };
|
|
81
|
+
const elapsed = elapsedSeconds(row[3]);
|
|
82
|
+
if (elapsed === null)
|
|
83
|
+
return { skipped: "ps returned an invalid elapsed time" };
|
|
84
|
+
rows.push({ pid: Number(row[1]), ppid: Number(row[2]), elapsed, command: row[4] });
|
|
85
|
+
}
|
|
86
|
+
const commands = new Map(rows.map((row) => [row.pid, row.command]));
|
|
87
|
+
const servers = rows
|
|
88
|
+
.map((row) => ({ ...row, started: observedAt - row.elapsed * 1000 }))
|
|
89
|
+
.filter((row) => row.started < newestMigration - STALE_SERVER_BOUNDARY_SLACK_MS && runsThisServer(row.command))
|
|
90
|
+
.map(({ elapsed: _elapsed, ...row }) => ({
|
|
91
|
+
...row,
|
|
92
|
+
startedAt: new Date(row.started).toISOString(),
|
|
93
|
+
parentCommand: commands.get(row.ppid) ?? null,
|
|
94
|
+
}));
|
|
95
|
+
return { servers };
|
|
96
|
+
}
|
|
97
|
+
export function staleHiveServers(appliedAt, dataDir = DEFAULT_DATA_DIR) {
|
|
98
|
+
if (appliedAt === null)
|
|
99
|
+
return { skipped: "the newest migration time is unavailable" };
|
|
100
|
+
try {
|
|
101
|
+
const psOutput = execFileSync("ps", ["-eo", "pid=,ppid=,etime=,command="], { encoding: "utf8" });
|
|
102
|
+
const parsed = parseStaleHiveServers(psOutput, appliedAt, Date.now());
|
|
103
|
+
if ("skipped" in parsed)
|
|
104
|
+
return parsed;
|
|
105
|
+
const servers = [];
|
|
106
|
+
for (const server of parsed.servers) {
|
|
107
|
+
const envOutput = execFileSync("ps", ["eww", "-p", String(server.pid), "-o", "command="], {
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
});
|
|
110
|
+
const sameStore = processUsesDataDir(envOutput, dataDir);
|
|
111
|
+
if (sameStore === null)
|
|
112
|
+
return { skipped: "ps returned environment output that could not be parsed" };
|
|
113
|
+
if (sameStore)
|
|
114
|
+
servers.push(server);
|
|
115
|
+
}
|
|
116
|
+
return { servers };
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
120
|
+
return { skipped: `ps could not be run: ${reason}` };
|
|
121
|
+
}
|
|
122
|
+
}
|
package/dist/tmux.js
CHANGED
|
@@ -184,16 +184,19 @@ function unsetAtSessionScope(name, envFlags) {
|
|
|
184
184
|
// HIVE_PROJECT_PATH at session scope, silently, for that session's whole life.
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
|
+
// The pid is read from the creating call because tmux 3.8 reports an empty #{pane_pid} once a pane's
|
|
188
|
+
// process has exited, so a later list-panes can lose the race against a command that dies at once.
|
|
189
|
+
export const CREATED_PANE_FORMAT = "#{pane_id}\t#{session_name}:#{window_id}\t#{pane_pid}";
|
|
187
190
|
export function ensureSession(name, cwd, initial) {
|
|
188
191
|
if (quietTmux("has-session", "-t", `=${name}`))
|
|
189
192
|
return { created: false };
|
|
190
193
|
if (untrustedTmuxServer())
|
|
191
194
|
throw crossServerRefusal("create a tmux session");
|
|
192
195
|
try {
|
|
193
|
-
const [pane, window] = tmux("new-session", "-d", "-P", "-F",
|
|
196
|
+
const [pane, window, pid = ""] = tmux("new-session", "-d", "-P", "-F", CREATED_PANE_FORMAT, "-s", name, "-c", cwd, ...(initial && "command" in initial ? [...initial.envFlags, initial.command] : [])).split("\t");
|
|
194
197
|
if (initial && "command" in initial)
|
|
195
198
|
unsetAtSessionScope(name, initial.envFlags);
|
|
196
|
-
return { created: true, pane, window };
|
|
199
|
+
return { created: true, pane, window, pid };
|
|
197
200
|
}
|
|
198
201
|
catch (e) {
|
|
199
202
|
if (!isDuplicateSession(e))
|
|
@@ -360,7 +363,7 @@ export function targetLiveProbe(target) {
|
|
|
360
363
|
const rows = tmux("list-panes", "-t", target, "-F", "#{pane_id} #{pane_pid}")
|
|
361
364
|
.split("\n")
|
|
362
365
|
.map((line) => line.split(" "));
|
|
363
|
-
const pid = isPaneTarget(target) ? (rows.find(([id]) => id === target)?.[1]
|
|
366
|
+
const pid = isPaneTarget(target) ? (rows.find(([id]) => id === target)?.[1] || null) : null;
|
|
364
367
|
return { live: true, pid };
|
|
365
368
|
}
|
|
366
369
|
catch (e) {
|
|
@@ -700,10 +703,10 @@ export function captureFinalScreen(target, lines) {
|
|
|
700
703
|
}
|
|
701
704
|
}
|
|
702
705
|
export function createWindow(session, windowName, cwd, envFlags, command, projectId, detach = false, retainOnExit = false) {
|
|
703
|
-
const created = tmux("new-window", ...(detach ? ["-d"] : []), "-P", "-F",
|
|
704
|
-
const [pane, window] = created.split("\t");
|
|
706
|
+
const created = tmux("new-window", ...(detach ? ["-d"] : []), "-P", "-F", CREATED_PANE_FORMAT, "-t", `=${session}`, "-n", windowName, "-c", cwd, ...envFlags, command, ...(retainOnExit ? retainOnExitArgs(`=${session}:${windowName}`) : []));
|
|
707
|
+
const [pane, window, pid = ""] = created.split("\t");
|
|
705
708
|
configureHiveWindow(window, true, projectId);
|
|
706
|
-
return { pane, window };
|
|
709
|
+
return { pane, window, pid };
|
|
707
710
|
}
|
|
708
711
|
function viewSessionChain(session, view) {
|
|
709
712
|
return [
|
package/dist/tools/agents.js
CHANGED
|
@@ -18,7 +18,7 @@ import { agentIdentityEnv, branchAt, closeAgentRow, currentOwnership, discardOrp
|
|
|
18
18
|
import { COMMAND_KIND, runningCommandRow, stopLine, stopProcess, STOP_REASONS } from "../processes.js";
|
|
19
19
|
import { readContextTokens, readContextFill, resolveTranscriptDir } from "../transcript.js";
|
|
20
20
|
import { leadSessionEnded } from "../leadState.js";
|
|
21
|
-
import { capturePane, captureFinalScreen, DEFAULT_LAYOUT, cancelCopyMode, describePaneChoice, ensureAttached, findUnsafeControlChar, holdsHumanInput, liveTargets, observationFailed, observeRowOwnership, ownershipLiveness, paneCurrentCommand, paneInCopyMode,
|
|
21
|
+
import { capturePane, captureFinalScreen, DEFAULT_LAYOUT, cancelCopyMode, describePaneChoice, ensureAttached, findUnsafeControlChar, holdsHumanInput, liveTargets, observationFailed, observeRowOwnership, ownershipLiveness, paneCurrentCommand, paneInCopyMode, pollPaneReadiness, rowOwnership, sendText, sessionName, setRemainOnExit, sleep, TEXT_ALLOWED_CONTROL_CHARS, tmux, TmuxTimeoutError, WINDOW_LAYOUTS, } from "../tmux.js";
|
|
22
22
|
import { agentIdParam, agentNameParam, idParam, projectIdParam } from "./params.js";
|
|
23
23
|
import { deriveProvenance, lastLogEvent, lastPermissionMode, reportsAgentStateLog, } from "../stateProvenance.js";
|
|
24
24
|
const CLOSED_ROW_ORDER = "(parked_at != '') DESC, closed_at DESC, id DESC";
|
|
@@ -599,48 +599,48 @@ export function registerAgents(server) {
|
|
|
599
599
|
rmSync(codexHomeDir(codexHomeKey), { recursive: true, force: true });
|
|
600
600
|
throw e;
|
|
601
601
|
}
|
|
602
|
-
const { agentId, actorId, target, landedInProjectId, layoutApplied } = spawned;
|
|
603
|
-
ensureAttached(sessionName());
|
|
602
|
+
const { agentId, actorId, target, landedInProjectId, layoutApplied, retainedWindow } = spawned;
|
|
604
603
|
let ready = false;
|
|
605
604
|
let dialogTail;
|
|
606
605
|
let exited = false;
|
|
607
606
|
let exitTail;
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
discardOrphanedPane(target);
|
|
624
|
-
}
|
|
625
|
-
else {
|
|
626
|
-
const { awaitingChoice, tail } = classifier.choiceCheck(target);
|
|
627
|
-
if (awaitingChoice === true) {
|
|
628
|
-
dialogTail = tail;
|
|
607
|
+
try {
|
|
608
|
+
ensureAttached(sessionName());
|
|
609
|
+
if (harness.classifiesPaneScreen) {
|
|
610
|
+
const classifier = harness.paneClassifier;
|
|
611
|
+
try {
|
|
612
|
+
const outcome = await pollPaneReadiness(target, PANE_READY_MS, classifier.hasInputBox);
|
|
613
|
+
if (outcome === "gone") {
|
|
614
|
+
// remain-on-exit held the pane so its final screen is still readable; capture it,
|
|
615
|
+
// persist it onto the row (the pane below is about to be killed), then release it.
|
|
616
|
+
// Never run choiceCheck against a retained-but-dead pane: it is frozen content, not
|
|
617
|
+
// a live dialog, and choiceCheck's own capture is tuned to read a live screen.
|
|
618
|
+
exited = true;
|
|
619
|
+
exitTail = captureFinalScreen(target, 30);
|
|
620
|
+
db.prepare("UPDATE agents SET exit_tail = ? WHERE id = ?").run(exitTail, agentId);
|
|
621
|
+
discardOrphanedPane(target);
|
|
629
622
|
}
|
|
630
|
-
else
|
|
631
|
-
|
|
623
|
+
else {
|
|
624
|
+
const { awaitingChoice, tail } = classifier.choiceCheck(target);
|
|
625
|
+
if (awaitingChoice === true) {
|
|
626
|
+
dialogTail = tail;
|
|
627
|
+
}
|
|
628
|
+
else if (outcome === "ready") {
|
|
629
|
+
ready = true;
|
|
630
|
+
}
|
|
632
631
|
}
|
|
633
632
|
}
|
|
633
|
+
catch {
|
|
634
|
+
ready = false;
|
|
635
|
+
}
|
|
634
636
|
}
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
setRemainOnExit(windowTarget, false);
|
|
643
|
-
}
|
|
637
|
+
}
|
|
638
|
+
finally {
|
|
639
|
+
// Unconditional: every early return, throw, or timeout between arming remain-on-exit
|
|
640
|
+
// and here must still clear it, or the shared window (the default split placement's)
|
|
641
|
+
// is left silently retaining every later exit in it, not just this one.
|
|
642
|
+
if (retainedWindow)
|
|
643
|
+
setRemainOnExit(retainedWindow, false);
|
|
644
644
|
}
|
|
645
645
|
const codexInstructions = codexHomeKey ? codexInstructionsPhrase(codexInstructionLayers) : undefined;
|
|
646
646
|
const receipt = {
|
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,
|
|
@@ -0,0 +1,87 @@
|
|
|
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: a picked option label or free text (any nonblank text is accepted, including one outside the offered labels), plus optional notes. The answer commits first. It then queues one wake to the asking session carrying the question, answer and notes verbatim, unless you ARE the asker (you recorded the human's own pick) or the asker cannot be reached now; either way the receipt says wake_id null and why in wake_skipped, and the answer stays readable with question_get. wake_id means queued, not delivered. An answered or withdrawn question refuses with QUESTION_STATE_CONFLICT.",
|
|
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
|
+
},
|
|
86
|
+
}, (args) => run(() => answerQuestion(effectiveProjectId(), currentActor(), args.question_id, args.answer, args.notes)));
|
|
87
|
+
}
|
package/dist/tools/wakes.js
CHANGED
|
@@ -35,7 +35,7 @@ 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) {
|
|
39
39
|
if (deliverTo != null) {
|
|
40
40
|
const agent = resolveAgentRef(projectId, deliverTo);
|
|
41
41
|
// Before the liveness probe: the harness is a durable fact about the row, while liveness is a
|
|
@@ -130,7 +130,7 @@ function deliveredByNote(t) {
|
|
|
130
130
|
: null;
|
|
131
131
|
}
|
|
132
132
|
export const truncateBody = (body) => body.length > 120 ? `${cutToUnitBudget(body, 120)}…` : body;
|
|
133
|
-
function
|
|
133
|
+
export function assertSafeWakeBody(body) {
|
|
134
134
|
const bad = findUnsafeControlChar(body, TEXT_ALLOWED_CONTROL_CHARS);
|
|
135
135
|
if (bad) {
|
|
136
136
|
throw new Error(`body cannot contain ${bad.label} at offset ${bad.index}: it is typed literally into the target pane ` +
|
|
@@ -173,7 +173,7 @@ const openStandingWatch = db.transaction((projectId, owner, body, deliverActor,
|
|
|
173
173
|
return row;
|
|
174
174
|
});
|
|
175
175
|
function createStandingWatch(projectId, args) {
|
|
176
|
-
const delivery =
|
|
176
|
+
const delivery = resolveWakeDelivery(projectId, args.deliver_to);
|
|
177
177
|
const maxWait = args.max_wait_seconds ?? STANDING_WATCH_LIFETIME_SECONDS;
|
|
178
178
|
const row = openStandingWatch.immediate(projectId, currentActor(), args.body, delivery.actor, delivery.pane, maxWait);
|
|
179
179
|
const crew = db
|
|
@@ -261,7 +261,7 @@ function createLeadWatch(leadProjectId, args) {
|
|
|
261
261
|
note: `Project ${leadProjectId}'s lead has already ended its turn; nothing was scheduled. Read it now.`,
|
|
262
262
|
};
|
|
263
263
|
}
|
|
264
|
-
const delivery =
|
|
264
|
+
const delivery = resolveWakeDelivery(home);
|
|
265
265
|
const maxWait = args.max_wait_seconds ?? 900;
|
|
266
266
|
const row = openLeadWatch.immediate(home, args.body, mode, delivery, maxWait, {
|
|
267
267
|
projectId: leadProjectId,
|
|
@@ -329,9 +329,9 @@ export function registerWakes(server) {
|
|
|
329
329
|
repeating: z.boolean(),
|
|
330
330
|
},
|
|
331
331
|
}, (args) => run(() => {
|
|
332
|
-
|
|
332
|
+
assertSafeWakeBody(args.body);
|
|
333
333
|
const projectId = effectiveProjectId(args.project_id);
|
|
334
|
-
const delivery =
|
|
334
|
+
const delivery = resolveWakeDelivery(projectId, args.deliver_to);
|
|
335
335
|
return commitQueenStateWrite("wake_set", projectId, args, () => {
|
|
336
336
|
const row = db
|
|
337
337
|
.prepare(`INSERT INTO wakes (project_id, owner, body, kind, deliver_actor, deliver_pane, due_at, repeat_every_ms)
|
|
@@ -407,7 +407,7 @@ export function registerWakes(server) {
|
|
|
407
407
|
note: z.string().optional(),
|
|
408
408
|
},
|
|
409
409
|
}, (args) => run(() => {
|
|
410
|
-
|
|
410
|
+
assertSafeWakeBody(args.body);
|
|
411
411
|
const projectId = effectiveProjectId(args.project_id);
|
|
412
412
|
const selectors = [
|
|
413
413
|
args.agents != null && "agents",
|
|
@@ -441,7 +441,7 @@ export function registerWakes(server) {
|
|
|
441
441
|
"reported as a timeout instead of the failure it actually is. A lead's turn has its own channel, " +
|
|
442
442
|
"which only the queen may watch, with lead_project_id=<that lead's project id>.");
|
|
443
443
|
}
|
|
444
|
-
const delivery =
|
|
444
|
+
const delivery = resolveWakeDelivery(projectId, args.deliver_to);
|
|
445
445
|
const snapshot = liveTargets();
|
|
446
446
|
if (mode === "all") {
|
|
447
447
|
const allIdle = watched.every((a) => {
|
|
@@ -577,7 +577,7 @@ export function registerWakes(server) {
|
|
|
577
577
|
const sets = [];
|
|
578
578
|
const params = [];
|
|
579
579
|
if (args.body != null) {
|
|
580
|
-
|
|
580
|
+
assertSafeWakeBody(args.body);
|
|
581
581
|
sets.push("body = ?");
|
|
582
582
|
params.push(args.body);
|
|
583
583
|
}
|
package/package.json
CHANGED