@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.
@@ -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, observeRowOwnership, ownershipLiveness, paneCurrentCommand, paneInCopyMode, pollPaneReadiness, rowOwnership, sendText, sessionName, setRemainOnExit, sleep, TEXT_ALLOWED_CONTROL_CHARS, tmux, TmuxTimeoutError, WINDOW_LAYOUTS, } from "../tmux.js";
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 CLOSED_ROW_ORDER = "(parked_at != '') DESC, closed_at DESC, id DESC";
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
- : harness.classifiesPaneScreen
699
- ? {
700
- instructions: workerBrief(briefFor(actorId)),
701
- }
702
- : {
703
- brief_path: writeAgentBrief(agentId, workerBrief(briefFor(actorId))),
704
- note: `This worker runs ${JSON.stringify(commandHead(baseCommand))}, which hive cannot brief and ` +
705
- "cannot type into: the guards that make typing safe read claude's chrome, so agent_send's " +
706
- "text path refuses this pane. There are no instructions to prepend, because there is no " +
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 : "";
@@ -1,89 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { db } from "../db.js";
3
- import { currentActor, effectiveProjectId, resolveProject } from "../context.js";
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
- function splitsSurrogatePair(content, index) {
7
- if (index <= 0 || index >= content.length)
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.",
@@ -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 { matchesAnyTag, parseTags, run } from "../result.js";
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.status != 'completed' AND t.archived_at IS NULL`;
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 };
@@ -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 { captureQueenWriteIdentity, currentActor, effectiveProjectId, getProject, isQueenLead, resolveQueenLeadTarget, } from "../context.js";
6
+ import { currentActor, effectiveProjectId, getProject, isQueenLead, } from "../context.js";
5
7
  import { run } from "../result.js";
6
- import { findAgent, observeOwnership, notOwnedError, summaryLiveness } from "./agents.js";
7
- import { ACTIVE_TIMER_WHERE, LOG_RETENTION, OWNED_BY_WATCH, WATCH_SCOPE_PROJECT, seedGoneCursor, } from "../scheduler.js";
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 { findUnsafeControlChar, foreignSocket, liveTargets, observationFailed, rowOwnership, TEXT_ALLOWED_CONTROL_CHARS, } from "../tmux.js";
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
- try {
64
- const sha = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
65
- cwd: checkoutRoot,
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() {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmgmyr/hive",
3
- "version": "1.11.4",
3
+ "version": "1.11.6",
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": {