@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.
@@ -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", "#{pane_id}\t#{session_name}:#{window_id}", "-s", name, "-c", cwd, ...(initial && "command" in initial ? [...initial.envFlags, initial.command] : [])).split("\t");
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] ?? null) : null;
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", "#{pane_id}\t#{session_name}:#{window_id}", "-t", `=${session}`, "-n", windowName, "-c", cwd, ...envFlags, command, ...(retainOnExit ? retainOnExitArgs(`=${session}:${windowName}`) : []));
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 [
@@ -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, paneWindow, pollPaneReadiness, rowOwnership, sendText, sessionName, setRemainOnExit, sleep, TEXT_ALLOWED_CONTROL_CHARS, tmux, TmuxTimeoutError, WINDOW_LAYOUTS, } from "../tmux.js";
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
- if (harness.classifiesPaneScreen) {
609
- const classifier = harness.paneClassifier;
610
- // Captured before anything below can kill the pane: remain-on-exit is a WINDOW option,
611
- // and the pane id itself stops being addressable once discardOrphanedPane has run.
612
- const windowTarget = paneWindow(target) ?? target;
613
- try {
614
- const outcome = await pollPaneReadiness(target, PANE_READY_MS, classifier.hasInputBox);
615
- if (outcome === "gone") {
616
- // remain-on-exit held the pane so its final screen is still readable; capture it,
617
- // persist it onto the row (the pane below is about to be killed), then release it.
618
- // Never run choiceCheck against a retained-but-dead pane: it is frozen content, not
619
- // a live dialog, and choiceCheck's own capture is tuned to read a live screen.
620
- exited = true;
621
- exitTail = captureFinalScreen(target, 30);
622
- db.prepare("UPDATE agents SET exit_tail = ? WHERE id = ?").run(exitTail, agentId);
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 if (outcome === "ready") {
631
- ready = true;
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
- catch {
636
- ready = false;
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
- 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 = {
@@ -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
+ }
@@ -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 resolveDelivery(projectId, deliverTo) {
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 rejectUnsafeBody(body) {
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 = resolveDelivery(projectId, args.deliver_to);
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 = resolveDelivery(home);
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
- rejectUnsafeBody(args.body);
332
+ assertSafeWakeBody(args.body);
333
333
  const projectId = effectiveProjectId(args.project_id);
334
- const delivery = resolveDelivery(projectId, args.deliver_to);
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
- rejectUnsafeBody(args.body);
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 = resolveDelivery(projectId, args.deliver_to);
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
- rejectUnsafeBody(args.body);
580
+ assertSafeWakeBody(args.body);
581
581
  sets.push("body = ?");
582
582
  params.push(args.body);
583
583
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmgmyr/hive",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Run a crew of Claude Code and Codex workers from one lead session: visible tmux panes, exact state from hooks, one local SQLite store.",
5
5
  "type": "module",
6
6
  "bin": {