@cmgmyr/hive 1.2.2 → 1.4.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.
Files changed (48) hide show
  1. package/README.md +6 -1
  2. package/claude-plugin/README.md +4 -1
  3. package/claude-plugin/kickoff.mjs +2 -1
  4. package/claude-plugin/skills/profile/SKILL.md +78 -0
  5. package/claude-plugin/skills/profile/references/interview.md +139 -0
  6. package/claude-plugin/skills/profile/references/recipes/index.md +12 -0
  7. package/claude-plugin/skills/profile/references/recipes/review-decision.md +41 -0
  8. package/claude-plugin/skills/profile/references/recipes/session-continuity.md +37 -0
  9. package/claude-plugin/skills/profile/references/recipes/shared-test-resource.md +45 -0
  10. package/claude-plugin/skills/profile/references/recipes/verification.md +50 -0
  11. package/claude-plugin/skills/profile/references/recipes/worker-context-checkpoint.md +51 -0
  12. package/dist/brief.js +1 -1
  13. package/dist/build-info.json +1 -1
  14. package/dist/cli.js +436 -112
  15. package/dist/codexHome.js +23 -8
  16. package/dist/config.js +8 -61
  17. package/dist/context.js +191 -2
  18. package/dist/db.js +80 -20
  19. package/dist/firstMessage.js +5 -0
  20. package/dist/globalConfig.js +247 -0
  21. package/dist/harnesses.js +14 -4
  22. package/dist/help.js +29 -11
  23. package/dist/hook.js +9 -0
  24. package/dist/hooks.js +2 -1
  25. package/dist/index.js +11 -5
  26. package/dist/kickoff.js +42 -29
  27. package/dist/leadState.js +63 -0
  28. package/dist/leadWatch.js +73 -0
  29. package/dist/portfolio.js +210 -0
  30. package/dist/projectYml.js +242 -178
  31. package/dist/queenAudit.js +118 -0
  32. package/dist/queenDashboard.js +573 -0
  33. package/dist/scheduler.js +190 -20
  34. package/dist/spawn.js +17 -4
  35. package/dist/strictInput.js +24 -7
  36. package/dist/tmux.js +65 -0
  37. package/dist/tools/agents.js +153 -15
  38. package/dist/tools/kv.js +24 -0
  39. package/dist/tools/leases.js +14 -2
  40. package/dist/tools/meta.js +65 -3
  41. package/dist/tools/pads.js +42 -0
  42. package/dist/tools/queenAudit.js +13 -0
  43. package/dist/tools/todos.js +72 -13
  44. package/dist/tools/wakes.js +233 -65
  45. package/package.json +4 -4
  46. package/profiles/orchestration/worker.md +1 -1
  47. package/profiles/queen/posture.md +31 -0
  48. package/profiles/queen/runbook.md +26 -0
@@ -0,0 +1,13 @@
1
+ import { listQueenAudit } from "../queenAudit.js";
2
+ import { run } from "../result.js";
3
+ import { idParam, limitParam } from "./params.js";
4
+ export function registerQueenAudit(server) {
5
+ server.registerTool("queen_audit_list", {
6
+ description: "List confirmed queen writes into other projects, newest first. The queen may read all targets; other callers see only their own project. Default 20, maximum 100 entries. History is retained for 30 days with a 20,000 id-range backstop. A crash between a completed terminal send and its audit insert can leave that send unrecorded.",
7
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
8
+ inputSchema: {
9
+ project_id: idParam.optional().describe("Filter by the target project id. Only the queen may name another project."),
10
+ limit: limitParam,
11
+ },
12
+ }, (args) => run(() => listQueenAudit(args)));
13
+ }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { db } from "../db.js";
3
+ import { commitQueenStateWrite } from "../queenAudit.js";
3
4
  import { currentActor, effectiveProjectId, resolveProject } from "../context.js";
4
5
  import { matchesAnyTag, parseTags, run } from "../result.js";
5
6
  import { findUnsafeControlChar } from "../tmux.js";
@@ -201,6 +202,12 @@ const completeTodo = db.transaction((projectId, todoId, completed) => {
201
202
  export function registerTodos(server) {
202
203
  server.registerTool("todo_create", {
203
204
  description: "Create a project-scoped todo. Pass a short slug to have it render beside the id wherever this todo is listed; left unset, those surfaces fall back to the title instead (truncated in the dashboard). Optionally pass blocked_by todo ids to encode ordering. Returns a slim receipt.",
205
+ annotations: {
206
+ readOnlyHint: false,
207
+ destructiveHint: false,
208
+ idempotentHint: false,
209
+ openWorldHint: false,
210
+ },
204
211
  inputSchema: {
205
212
  title: z.string(),
206
213
  body: z.string().optional().describe("Objective, owned files, acceptance criteria."),
@@ -214,17 +221,25 @@ export function registerTodos(server) {
214
221
  }, (args) => run(() => {
215
222
  const projectId = effectiveProjectId(args.project_id);
216
223
  currentActor();
217
- const info = db
218
- .prepare("INSERT INTO todos (project_id, title, body, priority, tags, slug) VALUES (?, ?, ?, ?, ?, ?)")
219
- .run(projectId, args.title, args.body ?? "", args.priority ?? "medium", JSON.stringify(args.tags ?? []), args.slug ?? "");
220
- const todoId = Number(info.lastInsertRowid);
221
- for (const blockerId of args.blocked_by ?? []) {
222
- addBlocker.immediate(projectId, todoId, blockerId);
223
- }
224
- return { project_id: projectId, todo_id: todoId };
224
+ return commitQueenStateWrite("todo_create", projectId, args, () => {
225
+ const info = db
226
+ .prepare("INSERT INTO todos (project_id, title, body, priority, tags, slug) VALUES (?, ?, ?, ?, ?, ?)")
227
+ .run(projectId, args.title, args.body ?? "", args.priority ?? "medium", JSON.stringify(args.tags ?? []), args.slug ?? "");
228
+ const todoId = Number(info.lastInsertRowid);
229
+ for (const blockerId of args.blocked_by ?? []) {
230
+ addBlocker.immediate(projectId, todoId, blockerId);
231
+ }
232
+ return { project_id: projectId, todo_id: todoId };
233
+ });
225
234
  }));
226
235
  server.registerTool("todo_list", {
227
236
  description: "List todo summaries. is_blocked=false finds dispatchable work. query matches title, body, and slug. Archived todos are excluded by default; include_archived=true retrieves them too.",
237
+ annotations: {
238
+ readOnlyHint: false,
239
+ destructiveHint: false,
240
+ idempotentHint: true,
241
+ openWorldHint: false,
242
+ },
228
243
  inputSchema: {
229
244
  status: statusParam.optional(),
230
245
  is_blocked: z.boolean().optional(),
@@ -256,6 +271,12 @@ export function registerTodos(server) {
256
271
  }));
257
272
  server.registerTool("todo_get", {
258
273
  description: "Read one todo in full: body, blockers, what it blocks, and optionally comments.",
274
+ annotations: {
275
+ readOnlyHint: false,
276
+ destructiveHint: false,
277
+ idempotentHint: true,
278
+ openWorldHint: false,
279
+ },
259
280
  inputSchema: {
260
281
  todo_id: idParam,
261
282
  include_comments: z.boolean().optional(),
@@ -267,6 +288,12 @@ export function registerTodos(server) {
267
288
  }));
268
289
  server.registerTool("todo_update", {
269
290
  description: "Update todo fields. Omitted fields are preserved. Returns a slim receipt.",
291
+ annotations: {
292
+ readOnlyHint: false,
293
+ destructiveHint: true,
294
+ idempotentHint: false,
295
+ openWorldHint: false,
296
+ },
270
297
  inputSchema: {
271
298
  todo_id: idParam,
272
299
  title: z.string().optional(),
@@ -292,6 +319,12 @@ export function registerTodos(server) {
292
319
  }));
293
320
  server.registerTool("todo_archive", {
294
321
  description: "Archive a todo (or unarchive with archived=false), mirroring pad_archive. Archived todos are excluded from todo_list by default; todo_get always reaches them by id. Refuses when this todo still blocks another todo that is not completed.",
322
+ annotations: {
323
+ readOnlyHint: false,
324
+ destructiveHint: true,
325
+ idempotentHint: false,
326
+ openWorldHint: false,
327
+ },
295
328
  inputSchema: {
296
329
  todo_id: idParam,
297
330
  archived: z.boolean().optional().describe("Default true. Pass false to unarchive."),
@@ -305,6 +338,12 @@ export function registerTodos(server) {
305
338
  }));
306
339
  server.registerTool("todo_complete", {
307
340
  description: "Mark a todo complete (or reopen with completed=false). Returns todo ids that this completion newly unblocked.",
341
+ annotations: {
342
+ readOnlyHint: false,
343
+ destructiveHint: true,
344
+ idempotentHint: false,
345
+ openWorldHint: false,
346
+ },
308
347
  inputSchema: {
309
348
  todo_id: idParam,
310
349
  completed: z.boolean().optional().describe("Defaults to true."),
@@ -323,6 +362,12 @@ export function registerTodos(server) {
323
362
  }));
324
363
  server.registerTool("todo_comment", {
325
364
  description: "Add a comment to a todo. Use for handoffs: changed files, tests run, decisions, remaining risk.",
365
+ annotations: {
366
+ readOnlyHint: false,
367
+ destructiveHint: false,
368
+ idempotentHint: false,
369
+ openWorldHint: false,
370
+ },
326
371
  inputSchema: {
327
372
  todo_id: idParam,
328
373
  body: z.string(),
@@ -332,14 +377,22 @@ export function registerTodos(server) {
332
377
  }, (args) => run(() => {
333
378
  const projectId = effectiveProjectId(args.project_id);
334
379
  const todo = getTodo(projectId, args.todo_id);
335
- const info = db
336
- .prepare("INSERT INTO todo_comments (todo_id, author, body) VALUES (?, ?, ?)")
337
- .run(todo.id, currentActor(), args.body);
338
- touch(todo.id);
339
- return { project_id: projectId, todo_id: todo.id, comment_id: Number(info.lastInsertRowid) };
380
+ return commitQueenStateWrite("todo_comment", projectId, args, () => {
381
+ const info = db
382
+ .prepare("INSERT INTO todo_comments (todo_id, author, body) VALUES (?, ?, ?)")
383
+ .run(todo.id, currentActor(), args.body);
384
+ touch(todo.id);
385
+ return { project_id: projectId, todo_id: todo.id, comment_id: Number(info.lastInsertRowid) };
386
+ });
340
387
  }));
341
388
  server.registerTool("todo_block", {
342
389
  description: "Add a blocker: todo_id cannot start until blocker_id completes. Cycles are rejected.",
390
+ annotations: {
391
+ readOnlyHint: false,
392
+ destructiveHint: false,
393
+ idempotentHint: false,
394
+ openWorldHint: false,
395
+ },
343
396
  inputSchema: {
344
397
  todo_id: idParam,
345
398
  blocker_id: idParam,
@@ -354,6 +407,12 @@ export function registerTodos(server) {
354
407
  }));
355
408
  server.registerTool("todo_unblock", {
356
409
  description: "Remove one blocker relationship from a todo.",
410
+ annotations: {
411
+ readOnlyHint: false,
412
+ destructiveHint: true,
413
+ idempotentHint: false,
414
+ openWorldHint: false,
415
+ },
357
416
  inputSchema: {
358
417
  todo_id: idParam,
359
418
  blocker_id: idParam,
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { db } from "../db.js";
3
- import { currentActor, effectiveProjectId } from "../context.js";
3
+ import { commitQueenStateWrite } from "../queenAudit.js";
4
+ import { currentActor, effectiveProjectId, getProject, isQueenLead } from "../context.js";
4
5
  import { run } from "../result.js";
5
6
  import { findAgent, isLive, probeFailed, summaryLiveness } from "./agents.js";
6
7
  import { ACTIVE_TIMER_WHERE, LOG_RETENTION, OWNED_BY_WATCH, WATCH_SCOPE_PROJECT, seedGoneCursor, } from "../scheduler.js";
@@ -8,7 +9,9 @@ import { idParam, projectIdParam } from "./params.js";
8
9
  import { cutToUnitBudget } from "../slug.js";
9
10
  import { awaitingFirstPrompt } from "../firstPrompt.js";
10
11
  import { deriveProvenance } from "../stateProvenance.js";
11
- import { findUnsafeControlChar, liveTargets, TEXT_ALLOWED_CONTROL_CHARS } from "../tmux.js";
12
+ import { findUnsafeControlChar, liveTargets, paneReissued, rowAliveProbe, TEXT_ALLOWED_CONTROL_CHARS, } from "../tmux.js";
13
+ import { readLeadTurnState } from "../leadState.js";
14
+ import { LEAD_TARGET_GONE, LEAD_TARGET_REISSUED, leadSubscription, paneVeto, runningLeadOf } from "../leadWatch.js";
12
15
  import { isRunningLeadActor, LEAD_KIND } from "../spawn.js";
13
16
  import { commandHead, screenClassifiable } from "../harnesses.js";
14
17
  const agentRefParam = z
@@ -125,6 +128,7 @@ const baseWakeFields = (t, { truncate = true } = {}) => ({
125
128
  deliver_to: t.deliver_actor,
126
129
  ...(t.watch_scope ? { scope: t.watch_scope, standing: true } : {}),
127
130
  ...(t.parent_wake_id != null ? { parent_wake_id: t.parent_wake_id } : {}),
131
+ ...leadWatchFields(t),
128
132
  });
129
133
  const STANDING_WATCH_LIFETIME_SECONDS = 4 * 60 * 60;
130
134
  const openStandingWatch = db.transaction((projectId, owner, body, deliverActor, deliverPane, maxWait) => {
@@ -172,6 +176,112 @@ function createStandingWatch(projectId, args) {
172
176
  "finished before the watch was set is exactly the finish a one-shot loses. Go quiet.",
173
177
  };
174
178
  }
179
+ export const LEAD_WATCH_QUEEN_ONLY = "LEAD_WATCH_QUEEN_ONLY";
180
+ const openLeadWatch = db.transaction((homeProjectId, body, mode, delivery, maxWait, target) => {
181
+ const state = readLeadTurnState(target.agentId);
182
+ const sameLaunch = state !== null && state.pane_pid === target.panePid;
183
+ const sessionId = sameLaunch && state.state !== "unknown" ? state.session_id : "";
184
+ const baseline = state?.idle_seq ?? 0;
185
+ const row = db
186
+ .prepare(`INSERT INTO wakes (project_id, owner, body, kind, watch, deliver_actor, deliver_pane, max_wait_at)
187
+ VALUES (?, ?, ?, ?, '[]', ?, ?, datetime('now', printf('+%d seconds', ?)))
188
+ RETURNING id`)
189
+ .get(homeProjectId, currentActor(), body, `idle_${mode}`, delivery.actor, delivery.pane, maxWait);
190
+ db.prepare(`INSERT INTO lead_idle_subscriptions (wake_id, target_project_id, agent_id, pane_pid, session_id, baseline_idle_seq)
191
+ VALUES (?, ?, ?, ?, ?, ?)`).run(row.id, target.projectId, target.agentId, target.panePid, sessionId, baseline);
192
+ return { id: row.id, session_id: sessionId, baseline_idle_seq: baseline };
193
+ });
194
+ function createLeadWatch(leadProjectId, args) {
195
+ if (!isQueenLead()) {
196
+ throw new Error(`${LEAD_WATCH_QUEEN_ONLY}: lead_project_id is the queen's alone. Only the running queen lead, in its own ` +
197
+ "project and without HIVE_PROJECT_LOCK, may wait on another project's lead; a project's own lead and " +
198
+ "every worker are refused. A worker is watched with agents=[...] or scope=\"project\" instead.");
199
+ }
200
+ if (args.project_id != null || args.deliver_to != null) {
201
+ throw new Error("lead_project_id refuses project_id and deliver_to: the wake is always stored in the queen's own project " +
202
+ "and delivered to the queen's own pane, so neither has anything to choose. Name the watched project " +
203
+ "with lead_project_id alone.");
204
+ }
205
+ const home = effectiveProjectId();
206
+ if (leadProjectId === home) {
207
+ throw new Error("lead_project_id names the queen's own project, and the queen cannot wait on itself.");
208
+ }
209
+ const project = getProject(leadProjectId);
210
+ if (!project)
211
+ throw new Error(`Unknown project_id ${leadProjectId}. Call project_list to see options.`);
212
+ const lead = runningLeadOf(leadProjectId);
213
+ if (!lead)
214
+ throw new Error(`${LEAD_TARGET_GONE}: project ${leadProjectId} ("${project.name}") has no running lead to watch.`);
215
+ if (!screenClassifiable(lead.command)) {
216
+ throw new Error(`Project ${leadProjectId}'s lead runs ${JSON.stringify(commandHead(lead.command))}, whose screen hive cannot ` +
217
+ "classify, so a turn ending while a human is typing to it could not be told apart from one nobody is at.");
218
+ }
219
+ if (lead.pane_pid === "") {
220
+ throw new Error(`${LEAD_TARGET_REISSUED}: project ${leadProjectId}'s lead has no recorded pane pid, so a reused pane id ` +
221
+ "could not be told from this lead. Restart it with hive lead to record one.");
222
+ }
223
+ const snapshot = liveTargets();
224
+ if (snapshot === null)
225
+ throw new Error("tmux did not answer, so the watched lead's pane cannot be verified. Try again.");
226
+ const probe = rowAliveProbe(lead.tmux_socket, lead.tmux_target, snapshot);
227
+ if (probe.live === null) {
228
+ throw new Error(`Project ${leadProjectId}'s lead lives on a tmux socket this process cannot probe.`);
229
+ }
230
+ if (!probe.live)
231
+ throw new Error(`${LEAD_TARGET_GONE}: project ${leadProjectId}'s lead pane is gone.`);
232
+ if (paneReissued(lead.pane_pid, probe)) {
233
+ throw new Error(`${LEAD_TARGET_REISSUED}: project ${leadProjectId}'s lead pane id now belongs to another process.`);
234
+ }
235
+ const mode = args.mode ?? "any";
236
+ const state = readLeadTurnState(lead.id);
237
+ const idleNow = state !== null && state.pane_pid === lead.pane_pid && state.state === "idle";
238
+ if (mode === "all" && idleNow && paneVeto(lead) === null) {
239
+ return {
240
+ status: "already_satisfied",
241
+ note: `Project ${leadProjectId}'s lead has already ended its turn; nothing was scheduled. Read it now.`,
242
+ };
243
+ }
244
+ const delivery = resolveDelivery(home);
245
+ const maxWait = args.max_wait_seconds ?? 900;
246
+ const row = openLeadWatch.immediate(home, args.body, mode, delivery, maxWait, {
247
+ projectId: leadProjectId,
248
+ agentId: lead.id,
249
+ panePid: lead.pane_pid,
250
+ });
251
+ return {
252
+ wake_id: row.id,
253
+ mode,
254
+ lead_watch: {
255
+ project_id: leadProjectId,
256
+ agent_id: lead.id,
257
+ name: lead.name,
258
+ state: state !== null && state.pane_pid === lead.pane_pid ? state.state : "unknown",
259
+ baseline_idle_seq: row.baseline_idle_seq,
260
+ },
261
+ max_wait_seconds: maxWait,
262
+ deliver_to: delivery.actor,
263
+ note: (mode === "any"
264
+ ? "Fires on the lead's NEXT turn ending; a turn that already ended does not count. "
265
+ : "Fires once the lead's turn has ended. ") +
266
+ "A turn ending is not the lead finishing its work: read it before acting. A dead, restarted or reissued " +
267
+ "lead pane ends the watch with that reason instead.",
268
+ };
269
+ }
270
+ function leadWatchFields(t) {
271
+ const sub = leadSubscription(t.id);
272
+ if (sub === null)
273
+ return {};
274
+ return {
275
+ lead_watch: {
276
+ project_id: sub.target_project_id,
277
+ agent_id: sub.agent_id,
278
+ pane_pid: sub.pane_pid,
279
+ session_id: sub.session_id,
280
+ baseline_idle_seq: sub.baseline_idle_seq,
281
+ ...(sub.terminal_reason !== null ? { terminal_reason: sub.terminal_reason } : {}),
282
+ },
283
+ };
284
+ }
175
285
  export function registerWakes(server) {
176
286
  server.registerTool("wake_set", {
177
287
  description: "Schedule a wake-up: after delay_seconds the body is typed into the target session's terminal " +
@@ -179,6 +289,12 @@ export function registerWakes(server) {
179
289
  "instead of polling. Write the body self-contained: ids, context, next action - it may arrive in " +
180
290
  "a session that has none of this conversation. Delivering to your OWN lead pane, where the " +
181
291
  "context is already there, prefer the action, the ids, and a pointer to where the detail lives.",
292
+ annotations: {
293
+ readOnlyHint: false,
294
+ destructiveHint: false,
295
+ idempotentHint: false,
296
+ openWorldHint: false,
297
+ },
182
298
  inputSchema: {
183
299
  delay_seconds: z.number().int().positive(),
184
300
  body: z.string(),
@@ -196,20 +312,28 @@ export function registerWakes(server) {
196
312
  rejectUnsafeBody(args.body);
197
313
  const projectId = effectiveProjectId(args.project_id);
198
314
  const delivery = resolveDelivery(projectId, args.deliver_to);
199
- const row = db
200
- .prepare(`INSERT INTO wakes (project_id, owner, body, kind, deliver_actor, deliver_pane, due_at, repeat_every_ms)
201
- VALUES (?, ?, ?, 'delay', ?, ?, datetime('now', printf('+%d seconds', ?)), ?)
202
- RETURNING id, due_at`)
203
- .get(projectId, currentActor(), args.body, delivery.actor, delivery.pane, args.delay_seconds, args.repeat_every_seconds != null ? args.repeat_every_seconds * 1000 : null);
204
- return {
205
- wake_id: row.id,
206
- due_at: row.due_at,
207
- deliver_to: delivery.actor,
208
- repeating: args.repeat_every_seconds != null,
209
- };
315
+ return commitQueenStateWrite("wake_set", projectId, args, () => {
316
+ const row = db
317
+ .prepare(`INSERT INTO wakes (project_id, owner, body, kind, deliver_actor, deliver_pane, due_at, repeat_every_ms)
318
+ VALUES (?, ?, ?, 'delay', ?, ?, datetime('now', printf('+%d seconds', ?)), ?)
319
+ RETURNING id, due_at`)
320
+ .get(projectId, currentActor(), args.body, delivery.actor, delivery.pane, args.delay_seconds, args.repeat_every_seconds != null ? args.repeat_every_seconds * 1000 : null);
321
+ return {
322
+ wake_id: row.id,
323
+ due_at: row.due_at,
324
+ deliver_to: delivery.actor,
325
+ repeating: args.repeat_every_seconds != null,
326
+ };
327
+ });
210
328
  }));
211
329
  server.registerTool("wake_when_idle", {
212
- description: "Wake up when watched agents go idle (exact state from Claude Code hooks) or max_wait_seconds passes - except delivery HOLDS past that bound instead, for as long as the target pane is on a dialog or has unsubmitted human text in it, rather than pasting the wake body into either (.claude/rules/tmux-and-panes.md). Two shapes, and you pass EXACTLY ONE of them. agents=[...] is a ONE-SHOT over a named list: mode=any fires on the first fresh idle transition, mode=all fires when every watched agent is idle (returns already_satisfied without scheduling anything if they all are now), and either way it stops watching once it fires. scope=\"project\" is a STANDING WATCH over the crew you spawn in this project, including workers spawned later: it never stops watching, and on each finish it delivers a roster naming who finished and who is still going, until max_wait_seconds runs out or you wake_cancel it. You may hold ONE standing watch per project: a second call is refused and names the one already running, since two would report every finish twice. Use the standing watch when you are running more than one worker - a one-shot leaves every other worker unwatched from the moment it fires. Use either instead of polling. Refuses a lead target: a lead has no idle/working state channel.",
330
+ description: "Wake up when watched agents go idle (exact state from Claude Code hooks) or max_wait_seconds passes - except delivery HOLDS past that bound instead, for as long as the target pane is on a dialog or has unsubmitted human text in it, rather than pasting the wake body into either (.claude/rules/tmux-and-panes.md). Two shapes for workers, plus one for the queen, and you pass EXACTLY ONE of them. agents=[...] is a ONE-SHOT over a named list: mode=any fires on the first fresh idle transition, mode=all fires when every watched agent is idle (returns already_satisfied without scheduling anything if they all are now), and either way it stops watching once it fires. scope=\"project\" is a STANDING WATCH over the crew you spawn in this project, including workers spawned later: it never stops watching, and on each finish it delivers a roster naming who finished and who is still going, until max_wait_seconds runs out or you wake_cancel it. You may hold ONE standing watch per project: a second call is refused and names the one already running, since two would report every finish twice. Use the standing watch when you are running more than one worker - a one-shot leaves every other worker unwatched from the moment it fires. Use either instead of polling. agents=[...] refuses a lead: it watches worker state, which a lead does not write. lead_project_id is the QUEEN's alone: a one-shot that fires when another registered project's running lead ENDS A TURN (never 'finished its work'), stored in and delivered to the queen's own project, and ended with a named reason if that lead's pane dies, is reissued, or restarts.",
331
+ annotations: {
332
+ readOnlyHint: false,
333
+ destructiveHint: false,
334
+ idempotentHint: false,
335
+ openWorldHint: false,
336
+ },
213
337
  inputSchema: {
214
338
  agents: z
215
339
  .array(agentRefParam)
@@ -232,11 +356,17 @@ export function registerWakes(server) {
232
356
  "scope=\"project\": THE WATCH'S LIFETIME, default 14400 (4 hours), after which it delivers one " +
233
357
  "last wake saying it has expired and stops watching. Not a hard deadline either way: delivery " +
234
358
  "holds past it while the target pane is on a dialog or has unsubmitted text, until the pane clears."),
359
+ lead_project_id: idParam
360
+ .optional()
361
+ .describe("QUEEN ONLY: wake when the running lead of this OTHER registered project ends a turn. The wake is " +
362
+ "stored in the queen's own project and delivered only to the queen; the watched lead is read, never " +
363
+ "typed into. Mutually exclusive with agents and scope; refuses project_id and deliver_to."),
235
364
  deliver_to: agentRefParam.optional().describe("Deliver to a spawned agent instead of this session."),
236
365
  project_id: projectIdParam,
237
366
  },
238
367
  outputSchema: {
239
368
  status: z.literal("already_satisfied").optional(),
369
+ lead_watch: z.record(z.string(), z.unknown()).optional(),
240
370
  wake_id: idParam.optional(),
241
371
  scope: z.literal("project").optional(),
242
372
  standing: z.boolean().optional(),
@@ -258,13 +388,18 @@ export function registerWakes(server) {
258
388
  }, (args) => run(() => {
259
389
  rejectUnsafeBody(args.body);
260
390
  const projectId = effectiveProjectId(args.project_id);
261
- if ((args.agents == null) === (args.scope == null)) {
262
- throw new Error("wake_when_idle needs exactly one of agents=[...] (a one-shot over a named list) or " +
263
- 'scope="project" (a standing watch over this project\'s crew). ' +
264
- (args.agents == null
265
- ? "You passed neither."
266
- : "You passed both, and they mean different things: a standing watch computes its own " +
267
- "membership every tick, so the list would be ignored."));
391
+ const selectors = [
392
+ args.agents != null && "agents",
393
+ args.scope != null && "scope",
394
+ args.lead_project_id != null && "lead_project_id",
395
+ ].filter((x) => x !== false);
396
+ if (selectors.length !== 1) {
397
+ throw new Error("wake_when_idle needs exactly one of agents=[...] (a one-shot over a named list), " +
398
+ 'scope="project" (a standing watch over this project\'s crew), or lead_project_id (the queen ' +
399
+ "waiting on another project's lead). " +
400
+ (selectors.length === 0
401
+ ? "You passed none."
402
+ : `You passed ${selectors.length === 2 ? "both" : "all of"} ${selectors.join(" and ")}, and they mean different things.`));
268
403
  }
269
404
  if (args.scope != null && args.mode != null) {
270
405
  throw new Error(`mode="${args.mode}" has no meaning for a standing watch: a standing watch reports EACH crew ` +
@@ -273,14 +408,17 @@ export function registerWakes(server) {
273
408
  }
274
409
  if (args.scope != null)
275
410
  return createStandingWatch(projectId, args);
411
+ if (args.lead_project_id != null)
412
+ return createLeadWatch(args.lead_project_id, args);
276
413
  const mode = args.mode ?? "any";
277
414
  const watched = (args.agents ?? []).map((ref) => resolveAgentRef(projectId, ref));
278
415
  const leadWatched = watched.find((a) => a.kind === LEAD_KIND);
279
416
  if (leadWatched) {
280
- throw new Error(`Agent ${leadWatched.id} ("${leadWatched.name}") is this project's lead session, which has no ` +
281
- "idle/working state channel: its hook writes only agent_state_log, never agent_state. " +
282
- "wake_when_idle refuses a lead target; it could only ever fire at max_wait_seconds, reported " +
283
- "as a timeout instead of the failure it actually is.");
417
+ throw new Error(`Agent ${leadWatched.id} ("${leadWatched.name}") is this project's lead session, and agents=[...] ` +
418
+ "watches worker state, which a lead does not write: its hook never moves agent_state. " +
419
+ "wake_when_idle refuses a lead target in agents=[...]; it could only ever fire at max_wait_seconds, " +
420
+ "reported as a timeout instead of the failure it actually is. A lead's turn has its own channel, " +
421
+ "which only the queen may watch, with lead_project_id=<that lead's project id>.");
284
422
  }
285
423
  const delivery = resolveDelivery(projectId, args.deliver_to);
286
424
  const snapshot = liveTargets();
@@ -299,29 +437,37 @@ export function registerWakes(server) {
299
437
  }
300
438
  }
301
439
  const maxWait = args.max_wait_seconds ?? 900;
302
- const info = db
303
- .prepare(`INSERT INTO wakes (project_id, owner, body, kind, watch, deliver_actor, deliver_pane,
304
- max_wait_at)
305
- VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', printf('+%d seconds', ?)))`)
306
- .run(projectId, currentActor(), args.body, `idle_${mode}`, JSON.stringify(watched.map((a) => a.id)), delivery.actor, delivery.pane, maxWait);
307
- return {
308
- wake_id: Number(info.lastInsertRowid),
309
- mode,
310
- watching: watched.map((a) => {
311
- const { state, ...provenance } = deriveProvenance(a, summaryLiveness(a, snapshot));
312
- return { agent_id: a.id, name: a.name, state, provenance };
313
- }),
314
- max_wait_seconds: maxWait,
315
- deliver_to: delivery.actor,
316
- note: mode === "any"
317
- ? "Fires on the next fresh idle transition; agents already idle now do not count."
318
- : "Fires when all watched agents are idle.",
319
- };
440
+ return commitQueenStateWrite("wake_when_idle", projectId, args, () => {
441
+ const info = db
442
+ .prepare(`INSERT INTO wakes (project_id, owner, body, kind, watch, deliver_actor, deliver_pane,
443
+ max_wait_at)
444
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', printf('+%d seconds', ?)))`)
445
+ .run(projectId, currentActor(), args.body, `idle_${mode}`, JSON.stringify(watched.map((a) => a.id)), delivery.actor, delivery.pane, maxWait);
446
+ return {
447
+ wake_id: Number(info.lastInsertRowid),
448
+ mode,
449
+ watching: watched.map((a) => {
450
+ const { state, ...provenance } = deriveProvenance(a, summaryLiveness(a, snapshot));
451
+ return { agent_id: a.id, name: a.name, state, provenance };
452
+ }),
453
+ max_wait_seconds: maxWait,
454
+ deliver_to: delivery.actor,
455
+ note: mode === "any"
456
+ ? "Fires on the next fresh idle transition; agents already idle now do not count."
457
+ : "Fires when all watched agents are idle.",
458
+ };
459
+ });
320
460
  }));
321
461
  server.registerTool("wake_get", {
322
462
  description: "Read one wake-up by id, in this project, with its UNTRUNCATED body. wake_list truncates " +
323
463
  "body at 120 chars; use this to see exactly what a wake will say, or to confirm what " +
324
464
  "wake_update just changed.",
465
+ annotations: {
466
+ readOnlyHint: false,
467
+ destructiveHint: false,
468
+ idempotentHint: true,
469
+ openWorldHint: false,
470
+ },
325
471
  inputSchema: { wake_id: idParam, project_id: projectIdParam },
326
472
  }, (args) => run(() => {
327
473
  const projectId = effectiveProjectId(args.project_id);
@@ -352,6 +498,12 @@ export function registerWakes(server) {
352
498
  "delay_seconds and repeat_every_seconds only apply to a delay wake (from wake_set) - an idle " +
353
499
  "wake (from wake_when_idle) fires on watched-agent state and max_wait_seconds instead, so " +
354
500
  "only body can be edited on one.",
501
+ annotations: {
502
+ readOnlyHint: false,
503
+ destructiveHint: true,
504
+ idempotentHint: false,
505
+ openWorldHint: false,
506
+ },
355
507
  inputSchema: {
356
508
  wake_id: idParam,
357
509
  delay_seconds: z.number().int().positive().optional(),
@@ -392,12 +544,14 @@ export function registerWakes(server) {
392
544
  params.push(args.delay_seconds);
393
545
  }
394
546
  params.push(args.wake_id, projectId, currentActor());
395
- const row = db
396
- .prepare(`UPDATE wakes SET ${sets.join(", ")}
397
- WHERE id = ? AND project_id = ? AND owner = ? AND parent_wake_id IS NULL AND ${ACTIVE_TIMER_WHERE}
398
- RETURNING due_at`)
399
- .get(...params);
400
- return { wake_id: args.wake_id, updated: row !== undefined, due_at: row?.due_at ?? null };
547
+ return commitQueenStateWrite("wake_update", projectId, args, () => {
548
+ const row = db
549
+ .prepare(`UPDATE wakes SET ${sets.join(", ")}
550
+ WHERE id = ? AND project_id = ? AND owner = ? AND parent_wake_id IS NULL AND ${ACTIVE_TIMER_WHERE}
551
+ RETURNING due_at`)
552
+ .get(...params);
553
+ return { wake_id: args.wake_id, updated: row !== undefined, due_at: row?.due_at ?? null };
554
+ });
401
555
  }));
402
556
  server.registerTool("wake_cancel", {
403
557
  description: "Cancel a pending wake-up you own, or - if you are a running lead - any pending wake-up in this " +
@@ -409,6 +563,12 @@ export function registerWakes(server) {
409
563
  "per-worker block notice (a crew member stopped on a dialog): that carries no parent link, so one " +
410
564
  "already filed still delivers, and it may still be true - the worker is probably still on that " +
411
565
  "dialog - but it no longer claims anything about the watch's own liveness, deliberately.",
566
+ annotations: {
567
+ readOnlyHint: false,
568
+ destructiveHint: true,
569
+ idempotentHint: true,
570
+ openWorldHint: false,
571
+ },
412
572
  inputSchema: { wake_id: idParam, project_id: projectIdParam },
413
573
  outputSchema: {
414
574
  wake_id: idParam,
@@ -418,22 +578,24 @@ export function registerWakes(server) {
418
578
  }, (args) => run(() => {
419
579
  const projectId = effectiveProjectId(args.project_id);
420
580
  const isLead = isRunningLeadActor(currentActor());
421
- const info = isLead
422
- ? db
423
- .prepare(`UPDATE wakes SET cancelled_at = datetime('now')
424
- WHERE id = ? AND project_id = ? AND cancelled_at IS NULL`)
425
- .run(args.wake_id, projectId)
426
- : db
427
- .prepare(`UPDATE wakes SET cancelled_at = datetime('now')
428
- WHERE id = ? AND project_id = ? AND owner = ? AND cancelled_at IS NULL`)
429
- .run(args.wake_id, projectId, currentActor());
430
- const notices = info.changes > 0
431
- ? db
432
- .prepare(`UPDATE wakes SET cancelled_at = datetime('now')
433
- WHERE parent_wake_id = ? AND cancelled_at IS NULL AND fired_at IS NULL`)
434
- .run(args.wake_id).changes
435
- : 0;
436
- return { wake_id: args.wake_id, cancelled: info.changes > 0, cancelled_notices: notices };
581
+ return commitQueenStateWrite("wake_cancel", projectId, args, () => {
582
+ const info = isLead
583
+ ? db
584
+ .prepare(`UPDATE wakes SET cancelled_at = datetime('now')
585
+ WHERE id = ? AND project_id = ? AND cancelled_at IS NULL`)
586
+ .run(args.wake_id, projectId)
587
+ : db
588
+ .prepare(`UPDATE wakes SET cancelled_at = datetime('now')
589
+ WHERE id = ? AND project_id = ? AND owner = ? AND cancelled_at IS NULL`)
590
+ .run(args.wake_id, projectId, currentActor());
591
+ const notices = info.changes > 0
592
+ ? db
593
+ .prepare(`UPDATE wakes SET cancelled_at = datetime('now')
594
+ WHERE parent_wake_id = ? AND cancelled_at IS NULL AND fired_at IS NULL`)
595
+ .run(args.wake_id).changes
596
+ : 0;
597
+ return { wake_id: args.wake_id, cancelled: info.changes > 0, cancelled_notices: notices };
598
+ });
437
599
  }));
438
600
  server.registerTool("wake_list", {
439
601
  description: "List pending wake-ups in this project, plus recently_delivered: the last " +
@@ -441,6 +603,12 @@ export function registerWakes(server) {
441
603
  "state (typed_at, held_at/held_reason, confirmation). A one-shot wake leaves the " +
442
604
  "pending list the moment it fires; recently_delivered is where to check whether it " +
443
605
  "was actually typed and, if its target has a confirmation channel, acknowledged.",
606
+ annotations: {
607
+ readOnlyHint: false,
608
+ destructiveHint: false,
609
+ idempotentHint: true,
610
+ openWorldHint: false,
611
+ },
444
612
  inputSchema: { project_id: projectIdParam },
445
613
  }, (args) => run(() => {
446
614
  const projectId = effectiveProjectId(args.project_id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmgmyr/hive",
3
- "version": "1.2.2",
3
+ "version": "1.4.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": {
@@ -47,15 +47,15 @@
47
47
  "coordination"
48
48
  ],
49
49
  "dependencies": {
50
- "@modelcontextprotocol/sdk": "^1.30.0",
50
+ "@modelcontextprotocol/sdk": "^1.30.1",
51
51
  "better-sqlite3": "^13.0.3",
52
- "smol-toml": "^1.8.0",
52
+ "smol-toml": "^1.9.0",
53
53
  "yaml": "^2.9.1",
54
54
  "zod": "^4.6.5"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/better-sqlite3": "^9.6.0",
58
- "@types/node": "^26.6.2",
58
+ "@types/node": "^26.6.3",
59
59
  "typescript": "^7.0.2"
60
60
  },
61
61
  "allowScripts": {
@@ -7,7 +7,7 @@ Coordinate through the hive MCP tools:
7
7
  - pad_list / pad_read for the shared plan and findings. Record decisions there.
8
8
  - todo_list(is_blocked=false, status="open") for dispatchable work; set status to in_progress while working.
9
9
  - todo_comment for handoffs (changed files, tests run, remaining risk), then todo_complete.
10
- - lease_acquire before editing shared file areas; leases expire on their own.
10
+ - lease_acquire before changing state another session can also change (a shared checkout, a dev database, a port); files in a directory only you work in need none. Leases expire on their own.
11
11
  <!--if:primary_root-->
12
12
  The primary checkout is at {{primary_root}} - untracked project files (a session corpus, local notes) live there, not in your worktree, if this project keeps any.
13
13
  <!--end-->