@cmgmyr/hive 1.11.5 → 1.11.7

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.
Files changed (51) hide show
  1. package/dist/agentKinds.js +6 -0
  2. package/dist/agentRecords.js +98 -0
  3. package/dist/backup.js +1 -1
  4. package/dist/build-info.json +1 -1
  5. package/dist/claudeWake.js +52 -4
  6. package/dist/cli.js +69 -43
  7. package/dist/context.js +2 -2
  8. package/dist/crew.js +5 -4
  9. package/dist/dashboard.js +10 -12
  10. package/dist/db.js +1 -1
  11. package/dist/errors.js +3 -0
  12. package/dist/etime.js +37 -0
  13. package/dist/firstPrompt.js +6 -0
  14. package/dist/globalConfig.js +1 -1
  15. package/dist/heldLabel.js +1 -1
  16. package/dist/kickoff.js +2 -2
  17. package/dist/leadHandoff.js +12 -11
  18. package/dist/leadHandoffCli.js +22 -1
  19. package/dist/leadHandoffState.js +1 -2
  20. package/dist/leadLaunch.js +1 -1
  21. package/dist/leadMessage.js +13 -0
  22. package/dist/leadMessageDelivery.js +14 -74
  23. package/dist/leadState.js +3 -2
  24. package/dist/leadWatch.js +1 -1
  25. package/dist/leaseStore.js +54 -0
  26. package/dist/pads.js +70 -0
  27. package/dist/portfolio.js +7 -3
  28. package/dist/projectRemove.js +1 -1
  29. package/dist/projectYml.js +1 -1
  30. package/dist/ptys.js +2 -9
  31. package/dist/queenAudit.js +2 -18
  32. package/dist/queenAuditStore.js +20 -0
  33. package/dist/queenDashboard.js +2 -1
  34. package/dist/questions.js +1 -1
  35. package/dist/result.js +2 -3
  36. package/dist/scheduler.js +42 -166
  37. package/dist/spawn.js +2 -6
  38. package/dist/staleServers.js +4 -28
  39. package/dist/stateProvenance.js +1 -3
  40. package/dist/storeClock.js +4 -0
  41. package/dist/teardown.js +2 -1
  42. package/dist/todoScope.js +1 -0
  43. package/dist/todos.js +90 -0
  44. package/dist/tools/agents.js +4 -96
  45. package/dist/tools/leases.js +5 -25
  46. package/dist/tools/pads.js +3 -82
  47. package/dist/tools/todos.js +6 -89
  48. package/dist/tools/wakes.js +12 -88
  49. package/dist/wakeSql.js +135 -0
  50. package/dist/wakeTarget.js +76 -0
  51. package/package.json +1 -1
package/dist/todos.js ADDED
@@ -0,0 +1,90 @@
1
+ import { db } from "./db.js";
2
+ import { matchesAnyTag, parseTags } from "./result.js";
3
+ import { fallbackSlug } from "./slug.js";
4
+ import { OPEN_BLOCKERS_FROM_WHERE } from "./todoScope.js";
5
+ export const COMMENT_COUNT_SQL = `(SELECT COUNT(*) FROM todo_comments c WHERE c.todo_id = t.id)`;
6
+ const SUMMARY_SQL = `
7
+ SELECT t.*,
8
+ (SELECT COUNT(*) ${OPEN_BLOCKERS_FROM_WHERE}) AS open_blockers,
9
+ ${COMMENT_COUNT_SQL} AS comment_count
10
+ FROM todos t`;
11
+ export function getTodo(projectId, todoId) {
12
+ const row = db
13
+ .prepare(`${SUMMARY_SQL} WHERE t.project_id = ? AND t.id = ?`)
14
+ .get(projectId, todoId);
15
+ if (!row)
16
+ throw new Error(`No todo with id ${todoId} in project ${projectId}. Call todo_list.`);
17
+ return row;
18
+ }
19
+ function summarize(row) {
20
+ return {
21
+ todo_id: row.id,
22
+ title: row.title,
23
+ slug: row.slug || fallbackSlug(row.title) || `todo ${row.id}`,
24
+ status: row.status,
25
+ priority: row.priority,
26
+ tags: parseTags(row.tags),
27
+ archived: row.archived_at != null,
28
+ is_blocked: (row.open_blockers ?? 0) > 0,
29
+ open_blockers: row.open_blockers ?? 0,
30
+ comment_count: row.comment_count ?? 0,
31
+ updated_at: row.updated_at,
32
+ };
33
+ }
34
+ export function listTodoSummaries(projectId, filter = {}) {
35
+ const limit = Math.min(filter.limit ?? 50, 200);
36
+ const offset = filter.offset ?? 0;
37
+ let sql = `${SUMMARY_SQL} WHERE t.project_id = ?`;
38
+ const params = [projectId];
39
+ if (!filter.includeArchived) {
40
+ sql += " AND t.archived_at IS NULL";
41
+ }
42
+ if (filter.statuses && filter.statuses.length > 0) {
43
+ sql += ` AND t.status IN (${filter.statuses.map(() => "?").join(",")})`;
44
+ params.push(...filter.statuses);
45
+ }
46
+ if (filter.priority) {
47
+ sql += " AND t.priority = ?";
48
+ params.push(filter.priority);
49
+ }
50
+ if (filter.query) {
51
+ sql += " AND (t.title LIKE ? OR t.body LIKE ? OR t.slug LIKE ?)";
52
+ params.push(`%${filter.query}%`, `%${filter.query}%`, `%${filter.query}%`);
53
+ }
54
+ sql += " ORDER BY t.updated_at DESC";
55
+ let rows = db.prepare(sql).all(...params).filter((r) => matchesAnyTag(r.tags, filter.tags));
56
+ if (filter.isBlocked != null) {
57
+ rows = rows.filter((r) => ((r.open_blockers ?? 0) > 0) === filter.isBlocked);
58
+ }
59
+ return {
60
+ total_count: rows.length,
61
+ offset,
62
+ limit,
63
+ todos: rows.slice(offset, offset + limit).map(summarize),
64
+ };
65
+ }
66
+ export function getTodoDetail(projectId, todoId, includeComments) {
67
+ const todo = getTodo(projectId, todoId);
68
+ const blockers = db
69
+ .prepare(`SELECT t.id, t.title, t.status FROM todo_blockers b
70
+ JOIN todos t ON t.id = b.blocker_id WHERE b.todo_id = ?`)
71
+ .all(todo.id);
72
+ const blocking = db
73
+ .prepare(`SELECT t.id, t.title, t.status FROM todo_blockers b
74
+ JOIN todos t ON t.id = b.todo_id WHERE b.blocker_id = ?`)
75
+ .all(todo.id);
76
+ const result = {
77
+ ...summarize(todo),
78
+ body: todo.body,
79
+ created_at: todo.created_at,
80
+ completed_at: todo.completed_at,
81
+ blockers,
82
+ blocking,
83
+ };
84
+ if (includeComments) {
85
+ result.comments = db
86
+ .prepare("SELECT id, author, body, created_at FROM todo_comments WHERE todo_id = ? ORDER BY created_at")
87
+ .all(todo.id);
88
+ }
89
+ return result;
90
+ }
@@ -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);
@@ -1,21 +1,8 @@
1
1
  import { z } from "zod";
2
- import { db } from "../db.js";
3
2
  import { currentActor, effectiveProjectId } from "../context.js";
3
+ import { extendOwnedLease, insertLease, purgeExpiredLeases, readLease, releaseOwnedLease } from "../leaseStore.js";
4
4
  import { run } from "../result.js";
5
5
  import { idParam, projectIdParam } from "./params.js";
6
- function purgeExpired(projectId) {
7
- db.prepare("DELETE FROM leases WHERE project_id = ? AND expires_at < datetime('now')").run(projectId);
8
- }
9
- export function readLease(projectId, key) {
10
- return db.prepare("SELECT * FROM leases WHERE project_id = ? AND lock_key = ?").get(projectId, key);
11
- }
12
- export function extendOwnedLease(projectId, key, actor, ttlSeconds) {
13
- const info = db
14
- .prepare(`UPDATE leases SET expires_at = datetime('now', printf('+%d seconds', ?))
15
- WHERE project_id = ? AND lock_key = ? AND owner = ?`)
16
- .run(ttlSeconds, projectId, key, actor);
17
- return info.changes > 0;
18
- }
19
6
  export function registerLeases(server) {
20
7
  server.registerTool("lease_acquire", {
21
8
  description: "Try to take a named lease on state another session could also change, such as a shared checkout, a dev database or a port (non-blocking). Re-taking your own lease extends it. Leases expire on their own.",
@@ -40,14 +27,9 @@ export function registerLeases(server) {
40
27
  },
41
28
  }, (args) => run(() => {
42
29
  const projectId = effectiveProjectId(args.project_id);
43
- purgeExpired(projectId);
30
+ purgeExpiredLeases(projectId);
44
31
  const actor = currentActor();
45
- const inserted = db
46
- .prepare(`INSERT INTO leases (project_id, lock_key, owner, expires_at)
47
- VALUES (?, ?, ?, datetime('now', printf('+%d seconds', ?)))
48
- ON CONFLICT(project_id, lock_key) DO NOTHING
49
- RETURNING expires_at`)
50
- .get(projectId, args.key, actor, args.ttl_seconds);
32
+ const inserted = insertLease(projectId, args.key, actor, args.ttl_seconds);
51
33
  if (inserted) {
52
34
  return { project_id: projectId, key: args.key, acquired: true, expires_at: inserted.expires_at };
53
35
  }
@@ -91,9 +73,7 @@ export function registerLeases(server) {
91
73
  outputSchema: { project_id: idParam, key: z.string(), released: z.boolean() },
92
74
  }, (args) => run(() => {
93
75
  const projectId = effectiveProjectId(args.project_id);
94
- const info = db
95
- .prepare("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND owner = ?")
96
- .run(projectId, args.key, currentActor());
97
- return { project_id: projectId, key: args.key, released: info.changes > 0 };
76
+ const released = releaseOwnedLease(projectId, args.key, currentActor());
77
+ return { project_id: projectId, key: args.key, released };
98
78
  }));
99
79
  }
@@ -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,12 +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";
9
- import { NOT_COMPLETED_TODO_SQL, OPEN_BLOCKERS_FROM_WHERE, OPEN_BLOCKERS_SQL } from "../todoScope.js";
13
+ import { NOT_COMPLETED_TODO_SQL, OPEN_BLOCKERS_SQL } from "../todoScope.js";
10
14
  const priorityParam = z.enum(["high", "medium", "low"]);
11
15
  const slugParam = z
12
16
  .string()
@@ -18,96 +22,9 @@ const slugParam = z
18
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.`)
19
23
  .optional();
20
24
  export { SLUG_MAX_LEN, fallbackSlug };
21
- export const TODO_STATUSES = ["open", "in_progress", "backlog", "completed"];
22
25
  const statusParam = z.enum(TODO_STATUSES);
23
26
  const LIVE_DEPENDENTS_SQL = `SELECT t.id AS todo_id FROM todo_blockers b JOIN todos t ON t.id = b.todo_id
24
27
  WHERE b.blocker_id = ? AND t.${NOT_COMPLETED_TODO_SQL} AND t.archived_at IS NULL`;
25
- export const COMMENT_COUNT_SQL = `(SELECT COUNT(*) FROM todo_comments c WHERE c.todo_id = t.id)`;
26
- const SUMMARY_SQL = `
27
- SELECT t.*,
28
- (SELECT COUNT(*) ${OPEN_BLOCKERS_FROM_WHERE}) AS open_blockers,
29
- ${COMMENT_COUNT_SQL} AS comment_count
30
- FROM todos t`;
31
- function getTodo(projectId, todoId) {
32
- const row = db
33
- .prepare(`${SUMMARY_SQL} WHERE t.project_id = ? AND t.id = ?`)
34
- .get(projectId, todoId);
35
- if (!row)
36
- throw new Error(`No todo with id ${todoId} in project ${projectId}. Call todo_list.`);
37
- return row;
38
- }
39
- function summarize(row) {
40
- return {
41
- todo_id: row.id,
42
- title: row.title,
43
- slug: row.slug || fallbackSlug(row.title) || `todo ${row.id}`,
44
- status: row.status,
45
- priority: row.priority,
46
- tags: parseTags(row.tags),
47
- archived: row.archived_at != null,
48
- is_blocked: (row.open_blockers ?? 0) > 0,
49
- open_blockers: row.open_blockers ?? 0,
50
- comment_count: row.comment_count ?? 0,
51
- updated_at: row.updated_at,
52
- };
53
- }
54
- export function listTodoSummaries(projectId, filter = {}) {
55
- const limit = Math.min(filter.limit ?? 50, 200);
56
- const offset = filter.offset ?? 0;
57
- let sql = `${SUMMARY_SQL} WHERE t.project_id = ?`;
58
- const params = [projectId];
59
- if (!filter.includeArchived) {
60
- sql += " AND t.archived_at IS NULL";
61
- }
62
- if (filter.statuses && filter.statuses.length > 0) {
63
- sql += ` AND t.status IN (${filter.statuses.map(() => "?").join(",")})`;
64
- params.push(...filter.statuses);
65
- }
66
- if (filter.priority) {
67
- sql += " AND t.priority = ?";
68
- params.push(filter.priority);
69
- }
70
- if (filter.query) {
71
- sql += " AND (t.title LIKE ? OR t.body LIKE ? OR t.slug LIKE ?)";
72
- params.push(`%${filter.query}%`, `%${filter.query}%`, `%${filter.query}%`);
73
- }
74
- sql += " ORDER BY t.updated_at DESC";
75
- let rows = db.prepare(sql).all(...params).filter((r) => matchesAnyTag(r.tags, filter.tags));
76
- if (filter.isBlocked != null) {
77
- rows = rows.filter((r) => ((r.open_blockers ?? 0) > 0) === filter.isBlocked);
78
- }
79
- return {
80
- total_count: rows.length,
81
- offset,
82
- limit,
83
- todos: rows.slice(offset, offset + limit).map(summarize),
84
- };
85
- }
86
- export function getTodoDetail(projectId, todoId, includeComments) {
87
- const todo = getTodo(projectId, todoId);
88
- const blockers = db
89
- .prepare(`SELECT t.id, t.title, t.status FROM todo_blockers b
90
- JOIN todos t ON t.id = b.blocker_id WHERE b.todo_id = ?`)
91
- .all(todo.id);
92
- const blocking = db
93
- .prepare(`SELECT t.id, t.title, t.status FROM todo_blockers b
94
- JOIN todos t ON t.id = b.todo_id WHERE b.blocker_id = ?`)
95
- .all(todo.id);
96
- const result = {
97
- ...summarize(todo),
98
- body: todo.body,
99
- created_at: todo.created_at,
100
- completed_at: todo.completed_at,
101
- blockers,
102
- blocking,
103
- };
104
- if (includeComments) {
105
- result.comments = db
106
- .prepare("SELECT id, author, body, created_at FROM todo_comments WHERE todo_id = ? ORDER BY created_at")
107
- .all(todo.id);
108
- }
109
- return result;
110
- }
111
28
  function transitiveBlockers(startId) {
112
29
  const seen = new Set();
113
30
  const stack = [startId];
@@ -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 { OWNED_BY_WATCH, WATCH_SCOPE_PROJECT, seedGoneCursor, } from "../scheduler.js";
10
+ import { ACTIVE_TIMER_WHERE, LOG_RETENTION, firedOneShotWhere, pendingOneShotWhere, wakeConfirmation, } 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`)
@@ -87,7 +29,7 @@ const RECENTLY_FIRED_LIMIT = 10;
87
29
  function recentlyFiredWakes(projectId) {
88
30
  return db
89
31
  .prepare(`SELECT * FROM wakes
90
- WHERE project_id = ? AND cancelled_at IS NULL AND fired_at IS NOT NULL AND repeat_every_ms IS NULL
32
+ WHERE project_id = ? AND ${firedOneShotWhere()}
91
33
  AND fired_at >= datetime('now', ?)
92
34
  ORDER BY fired_at DESC, id DESC LIMIT ${RECENTLY_FIRED_LIMIT}`)
93
35
  .all(projectId, LOG_RETENTION);
@@ -110,15 +52,7 @@ function deliveryState(t, hasChannel) {
110
52
  held_at: t.held_at,
111
53
  held_reason: t.held_reason,
112
54
  confirmed_at: t.confirmed_at,
113
- confirmation: t.typed_at == null && t.socket_attempt_at == null
114
- ? null
115
- : t.confirmed_at != null
116
- ? "confirmed"
117
- : !hasChannel(t.deliver_actor)
118
- ? "no_confirmation_channel"
119
- : t.typed_busy === 1
120
- ? "unconfirmed_busy"
121
- : "unconfirmed",
55
+ confirmation: wakeConfirmation(t, (t.typed_at == null && t.socket_attempt_at == null) || t.confirmed_at != null || hasChannel(t.deliver_actor)),
122
56
  delivery_method: t.delivery_method,
123
57
  delivered_by: t.delivered_by,
124
58
  socket_attempt_at: t.socket_attempt_at,
@@ -138,16 +72,6 @@ function deliveredByNote(t) {
138
72
  : null;
139
73
  }
140
74
  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
75
  const baseWakeFields = (t, { truncate = true } = {}) => ({
152
76
  wake_id: t.id,
153
77
  kind: t.kind,
@@ -476,13 +400,13 @@ export function registerWakes(server) {
476
400
  .prepare(`SELECT id, watch FROM wakes
477
401
  WHERE project_id = ? AND owner = ? AND kind = ?
478
402
  AND watch_scope IS NULL AND deliver_actor = ? AND deliver_pane = ?
479
- AND fired_at IS NULL AND cancelled_at IS NULL
403
+ AND ${pendingOneShotWhere()}
480
404
  AND id NOT IN (SELECT wake_id FROM lead_idle_subscriptions)`)
481
405
  .all(projectId, currentActor(), `idle_${mode}`, delivery.actor, delivery.pane)
482
406
  .filter((w) => sameSet(w.watch))
483
407
  .map((w) => w.id);
484
408
  const superseded = candidates.filter((id) => db
485
- .prepare("UPDATE wakes SET cancelled_at = datetime('now') WHERE id = ? AND cancelled_at IS NULL AND fired_at IS NULL")
409
+ .prepare(`UPDATE wakes SET cancelled_at = datetime('now') WHERE id = ? AND ${pendingOneShotWhere()}`)
486
410
  .run(id).changes === 1);
487
411
  const info = db
488
412
  .prepare(`INSERT INTO wakes (project_id, owner, body, kind, watch, deliver_actor, deliver_pane,
@@ -645,7 +569,7 @@ export function registerWakes(server) {
645
569
  const notices = info.changes > 0
646
570
  ? db
647
571
  .prepare(`UPDATE wakes SET cancelled_at = datetime('now')
648
- WHERE parent_wake_id = ? AND cancelled_at IS NULL AND fired_at IS NULL`)
572
+ WHERE parent_wake_id = ? AND ${pendingOneShotWhere()}`)
649
573
  .run(args.wake_id).changes
650
574
  : 0;
651
575
  return { wake_id: args.wake_id, cancelled: info.changes > 0, cancelled_notices: notices };