@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
@@ -2,16 +2,18 @@ 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 { confirmQueenWrite } from "../queenAudit.js";
6
+ import { captureQueenWriteIdentity } from "../context.js";
5
7
  import { agentBriefPath, mergedBriefVars, readAgentBrief, workerBrief, workerCommandString, writeAgentBrief, } from "../brief.js";
6
- import { codexHomeDir, codexInstructionsPhrase, codexLaunchArgs, ensureCodexHome, ensureCodexHooksFile } from "../codexHome.js";
8
+ import { codexHomeDir, codexInstructionsPhrase, codexLaunchArgs, ensureCodexHome, ensureCodexHooksFile, refreshCodexMcpEnv } from "../codexHome.js";
7
9
  import { currentActor, findProjectForDir, getProject, linkedWorktreePrimaryRoot, resolveProject } from "../context.js";
8
- import { commandHead, harnessFor, harnessNames, paneClassifierFor, resolvedCommandPrefix, screenClassifiable, } from "../harnesses.js";
10
+ import { commandHead, harnessFor, harnessNames, readOnlyExtraArgsAllowed, paneClassifierFor, resolvedCommandPrefix, screenClassifiable, } from "../harnesses.js";
9
11
  import { ensureWorkerHooksFile } from "../hooks.js";
10
12
  import { activeProfile, allowedAgents, loadProjectYml } from "../projectYml.js";
11
13
  import { classifyMiss, LEAD_MESSAGE_THRESHOLD, leadPointerMarker, missMessage, readLeadMessage, renderLeadPointer, senderTag, shortenedSendFailureClause, shortenedSendNote, storeLeadMessage, } from "../leadMessage.js";
12
14
  import { run } from "../result.js";
13
15
  import { markGoneReported } from "../scheduler.js";
14
- import { branchAt, closeAgentRow, discardOrphanedPane, isReservedAgentName, isRunningLeadActor, killAgentPane, launchAgent, LEAD_KIND, parkAgentRow, reapCodexHomeForClosedAgent, releaseParkRow, renameAgent, resumeAgent, } from "../spawn.js";
16
+ import { agentIdentityEnv, branchAt, closeAgentRow, discardOrphanedPane, isReservedAgentName, isRunningLeadActor, killAgentPane, launchAgent, LEAD_KIND, parkAgentRow, reapCodexHomeForClosedAgent, releaseParkRow, renameAgent, resumeAgent, storedLaunchOptions, } from "../spawn.js";
15
17
  import { COMMAND_KIND, runningCommandRow, stopLine, stopProcess, STOP_REASONS } from "../processes.js";
16
18
  import { readContextTokens, readContextFill, resolveTranscriptDir } from "../transcript.js";
17
19
  import { capturePane, captureFinalScreen, DEFAULT_LAYOUT, cancelCopyMode, describePaneChoice, ensureAttached, findUnsafeControlChar, holdsHumanInput, liveTargets, paneCurrentCommand, paneInCopyMode, paneWindow, pollPaneReadiness, rowAlive, rowLive, sendText, sessionName, setRemainOnExit, sleep, TEXT_ALLOWED_CONTROL_CHARS, tmux, TmuxTimeoutError, WINDOW_LAYOUTS, } from "../tmux.js";
@@ -161,17 +163,67 @@ function requireNameFree(projectId, name, exceptAgentId) {
161
163
  throw new Error(`A running agent named "${taken.name}" already exists. Pick another name.`);
162
164
  }
163
165
  }
164
- // Recovers a flag's value from a row's own recorded command string - the only place agent_resume
165
- // can read it back from, since ResumeSpec is built fresh and does not carry agent_spawn's original
166
- // args. Model ids are shell-safe by construction (gpt-5.6-luna, sonnet), so shellQuote never quotes
167
- // this token and a plain whitespace split is enough - no quote-stripping to get wrong.
166
+ function commandTokens(command) {
167
+ const tokens = [];
168
+ let token = "";
169
+ let quote = null;
170
+ let escaped = false;
171
+ for (const char of command) {
172
+ if (escaped) {
173
+ token += char;
174
+ escaped = false;
175
+ }
176
+ else if (char === "\\" && quote !== "'") {
177
+ escaped = true;
178
+ }
179
+ else if (quote) {
180
+ if (char === quote)
181
+ quote = null;
182
+ else
183
+ token += char;
184
+ }
185
+ else if (char === "'" || char === '"') {
186
+ quote = char;
187
+ }
188
+ else if (/\s/.test(char)) {
189
+ if (token) {
190
+ tokens.push(token);
191
+ token = "";
192
+ }
193
+ }
194
+ else {
195
+ token += char;
196
+ }
197
+ }
198
+ if (escaped)
199
+ token += "\\";
200
+ if (token)
201
+ tokens.push(token);
202
+ return tokens;
203
+ }
168
204
  function extractCommandFlag(command, flag) {
169
- const tokens = command.split(/\s+/);
205
+ const tokens = commandTokens(command);
170
206
  const i = tokens.indexOf(flag);
171
207
  if (i === -1 || i + 1 >= tokens.length)
172
208
  return undefined;
173
209
  return tokens[i + 1];
174
210
  }
211
+ function stripSessionArgs(args) {
212
+ const stripped = [];
213
+ for (let i = 0; i < args.length; i++) {
214
+ const arg = args[i];
215
+ if (arg === "--resume" || arg === "--session-id" || arg === "-r") {
216
+ i++;
217
+ }
218
+ else if (arg === "--last" || arg === "--fork-session" || arg.startsWith("--resume=") || arg.startsWith("--session-id=")) {
219
+ continue;
220
+ }
221
+ else {
222
+ stripped.push(arg);
223
+ }
224
+ }
225
+ return stripped;
226
+ }
175
227
  function requestsExistingSession(extraArgs) {
176
228
  return (extraArgs ?? []).some((arg) => arg === "--resume" ||
177
229
  arg.startsWith("--resume=") ||
@@ -261,6 +313,7 @@ function agentSummary(row, snapshot) {
261
313
  provenance,
262
314
  ...lastLogEventField(row),
263
315
  ...permissionModeField(row),
316
+ read_only: storedLaunchOptions(row.extra_args).readOnly,
264
317
  ...contextFillField(row),
265
318
  ...(row.parked_at ? { parked_at: row.parked_at, parked_branch: row.parked_branch || null } : {}),
266
319
  tmux_target: row.tmux_target,
@@ -285,7 +338,13 @@ export function worktreeInstallNotice(cwd, projectPath, config) {
285
338
  }
286
339
  export function registerAgents(server) {
287
340
  server.registerTool("agent_spawn", {
288
- description: "Spawn a worker agent (default: claude, or the project's hive.yml agents: default). A claude worker is briefed automatically: the full brief is appended to its system prompt, so send it its assignment directly. A command or harness that resolves to a known harness (claude, codex) not listed in the project's hive.yml agents: is refused; absent agents: means claude only. A command hive cannot classify the screen of (claude and codex both do; a harness with no entry does not) can be spawned but NOT typed into: the receipt carries brief_path and says so, and agent_send's text path and wakes both refuse that pane. The worker is locked to this project. Humans can watch with: tmux attach -t hive-main.",
341
+ description: "Spawn a worker agent (default: claude, or the project's hive.yml agents: default). A claude worker is briefed automatically: the full brief is appended to its system prompt, so send it its assignment directly. A command or harness that resolves to a known harness (claude, codex) not listed in the project's hive.yml agents: is refused; absent agents: means claude only. A command hive cannot classify the screen of (claude and codex both do; a harness with no entry does not) can be spawned but NOT typed into: the receipt carries brief_path and says so, and agent_send's text path and wakes both refuse that pane. Set read_only: true to block local file writes and mutating shell while keeping Hive MCP available; agent_resume preserves this mode and agent_status reports it. Only bare claude/codex executables are supported in this mode, with only effort-setting extra_args. The worker is locked to this project. Humans can watch with: tmux attach -t hive-main.",
342
+ annotations: {
343
+ readOnlyHint: false,
344
+ destructiveHint: false,
345
+ idempotentHint: false,
346
+ openWorldHint: false,
347
+ },
289
348
  inputSchema: {
290
349
  name: z
291
350
  .string()
@@ -301,6 +360,7 @@ export function registerAgents(server) {
301
360
  .optional()
302
361
  .describe("Spawn a known harness by name (e.g. \"codex\") instead of a raw command. Ignored when command is also given. Must be in the project's hive.yml agents: list (default: claude only)."),
303
362
  extra_args: z.array(z.string()).optional().describe("Extra CLI arguments."),
363
+ read_only: z.boolean().optional().describe("Prevent local file writes and mutating shell commands while allowing Hive MCP tools. Only bare claude/codex executables. Extra args allow codex -c model_reasoning_effort=low|medium|high|xhigh, or claude --effort low|medium|high|xhigh|max; all other arguments are refused."),
304
364
  cwd: z
305
365
  .string()
306
366
  .optional()
@@ -326,6 +386,7 @@ export function registerAgents(server) {
326
386
  worktree_install: z.string().optional(),
327
387
  brief_path: z.string().optional(),
328
388
  codex_home: z.string().optional(),
389
+ read_only: z.boolean().optional(),
329
390
  codex_instructions: z.string().optional(),
330
391
  ready: z.boolean().optional(),
331
392
  exited: z.boolean().optional(),
@@ -378,6 +439,11 @@ export function registerAgents(server) {
378
439
  throw new Error(`[agent_spawn:harness-not-allowed] "${harness.name}" is not in this project's hive.yml ` +
379
440
  `agents: list (${allowed.join(", ")}). Add it to agents: to allow spawning it.`);
380
441
  }
442
+ if (args.read_only && (!["claude", "codex"].includes(harness.name) ||
443
+ !/^[A-Za-z0-9_./-]+$/.test(baseCommand) ||
444
+ !readOnlyExtraArgsAllowed(harness.name, args.extra_args))) {
445
+ throw new Error("read_only requires a bare claude or codex executable and no extra_args; launch overrides can bypass its restrictions.");
446
+ }
381
447
  const mintsSession = harness.mintsSessionId;
382
448
  const sessionId = mintsSession && !requestsExistingSession(args.extra_args) ? randomUUID() : "";
383
449
  const worktreeInstall = worktreeInstallNotice(cwd, project.path, projectConfig);
@@ -401,7 +467,7 @@ export function registerAgents(server) {
401
467
  const briefPath = harness.briefDelivery ? writeAgentBrief(agentId, brief) : undefined;
402
468
  let homeArgs = [];
403
469
  if (codexHomeKey) {
404
- const home = ensureCodexHome({ key: codexHomeKey, actorId, cwd, brief: brief, includePostToolUse: projectConfig?.context_checkpoint_percent != null });
470
+ const home = ensureCodexHome({ key: codexHomeKey, actorId, serverEnv: agentIdentityEnv(actorId, name, project.path), cwd, brief: brief, readOnly: args.read_only, includePostToolUse: projectConfig?.context_checkpoint_percent != null });
405
471
  homeArgs = home.extraArgs;
406
472
  codexInstructionLayers = home.instructionLayers;
407
473
  }
@@ -415,7 +481,7 @@ export function registerAgents(server) {
415
481
  ? [...(sessionId ? ["--session-id", sessionId] : []), ...(args.extra_args ?? [])]
416
482
  : (args.extra_args ?? [])),
417
483
  ],
418
- settingsPath: harness.briefDelivery ? ensureWorkerHooksFile(agentId, { includePostToolUse: projectConfig?.context_checkpoint_percent != null }) : undefined,
484
+ settingsPath: harness.briefDelivery ? ensureWorkerHooksFile(agentId, { readOnly: args.read_only, includePostToolUse: projectConfig?.context_checkpoint_percent != null }) : undefined,
419
485
  briefPath,
420
486
  });
421
487
  };
@@ -439,6 +505,9 @@ export function registerAgents(server) {
439
505
  parentActor: parent,
440
506
  sessionId,
441
507
  codexHome: codexHomeKey,
508
+ model: args.model,
509
+ extraArgs: args.extra_args,
510
+ readOnly: args.read_only,
442
511
  retainOnExit: harness.classifiesPaneScreen,
443
512
  });
444
513
  }
@@ -497,6 +566,7 @@ export function registerAgents(server) {
497
566
  const codexInstructions = codexHomeKey ? codexInstructionsPhrase(codexInstructionLayers) : undefined;
498
567
  return {
499
568
  agent_id: agentId,
569
+ ...(args.read_only ? { read_only: true } : {}),
500
570
  actor_id: actorId,
501
571
  name,
502
572
  tmux_target: target,
@@ -546,6 +616,12 @@ export function registerAgents(server) {
546
616
  }));
547
617
  server.registerTool("agent_resume", {
548
618
  description: "Resume a CLOSED claude or codex worker from its recorded session id (claude --resume / codex resume): a fresh pane, the same actor_id, and the worker's full prior context. Addressed by name or agent_id among closed agents (agent_list(include_closed: true)). Send it its next instruction with agent_send once resumed - this tool does not.",
619
+ annotations: {
620
+ readOnlyHint: false,
621
+ destructiveHint: false,
622
+ idempotentHint: false,
623
+ openWorldHint: false,
624
+ },
549
625
  inputSchema: {
550
626
  name: agentNameParam,
551
627
  agent_id: agentIdParam,
@@ -614,6 +690,7 @@ export function registerAgents(server) {
614
690
  const { config: projectConfig } = loadProjectYml(project.path);
615
691
  const placement = projectConfig?.placement ?? (process.env.HIVE_SPAWN_PLACEMENT === "window" ? "window" : "split");
616
692
  const layout = projectConfig?.layout ?? DEFAULT_LAYOUT;
693
+ const launchOptions = storedLaunchOptions(agent.extra_args);
617
694
  let commandString;
618
695
  let resumeEnv = contextCheckpointEnv(projectConfig);
619
696
  if (resumeHarness.name === "codex") {
@@ -622,23 +699,34 @@ export function registerAgents(server) {
622
699
  // Recovered from the row's own recorded command: every codex worker hive spawns
623
700
  // carries a pinned model (codex has no bare alias), so dropping this would silently
624
701
  // resume on codex's default model instead of the one the lane actually chose.
625
- model: extractCommandFlag(agent.command, "--model"),
702
+ model: agent.model ?? extractCommandFlag(agent.command, "--model"),
626
703
  displayName: agent.name,
627
704
  // Subcommand positional, not a flag - live-verified on v0.149.0 (`codex resume --help`:
628
705
  // "Usage: codex resume [OPTIONS] [SESSION_ID] [PROMPT]"). No --settings/hooks file: a
629
706
  // codex worker's hooks come from its own home's hooks.json, never claude's shared one.
630
- extraArgs: [...codexLaunchArgs(agent.cwd), "resume", agent.session_id],
707
+ extraArgs: [
708
+ ...codexLaunchArgs(agent.cwd, launchOptions.readOnly),
709
+ ...stripSessionArgs(launchOptions.args),
710
+ "resume",
711
+ agent.session_id,
712
+ ],
631
713
  });
632
714
  resumeEnv = { ...resumeEnv, CODEX_HOME: codexHomeDir(agent.codex_home) };
633
715
  ensureCodexHooksFile(agent.codex_home, { includePostToolUse: projectConfig?.context_checkpoint_percent != null });
716
+ refreshCodexMcpEnv(agent.codex_home, agentIdentityEnv(agent.actor_id, agent.name, project.path));
634
717
  }
635
718
  else {
636
719
  const claudeBinary = resolvedCommandPrefix(agent.command) || "claude";
637
720
  commandString = workerCommandString({
638
721
  command: claudeBinary,
639
722
  displayName: agent.name,
640
- extraArgs: ["--resume", agent.session_id],
641
- settingsPath: ensureWorkerHooksFile(agent.id, { includePostToolUse: projectConfig?.context_checkpoint_percent != null }),
723
+ model: agent.model ?? undefined,
724
+ extraArgs: [
725
+ ...stripSessionArgs(launchOptions.args),
726
+ "--resume",
727
+ agent.session_id,
728
+ ],
729
+ settingsPath: ensureWorkerHooksFile(agent.id, { readOnly: launchOptions.readOnly, includePostToolUse: projectConfig?.context_checkpoint_percent != null }),
642
730
  });
643
731
  }
644
732
  const { target, landedInProjectId } = resumeAgent({
@@ -671,6 +759,12 @@ export function registerAgents(server) {
671
759
  }));
672
760
  server.registerTool("agent_park", {
673
761
  description: "Park a claude or codex worker for the night: kill its pane, mark the row PARKED rather than plain closed, record the branch, and hand back a board line plus the one call that brings it back. Use this instead of agent_close when the lane is paused, not finished - `closed` alone cannot tell a next-morning lead which is which. Resume it with agent_resume.",
762
+ annotations: {
763
+ readOnlyHint: false,
764
+ destructiveHint: true,
765
+ idempotentHint: false,
766
+ openWorldHint: false,
767
+ },
674
768
  inputSchema: {
675
769
  name: agentNameParam,
676
770
  agent_id: agentIdParam,
@@ -746,6 +840,12 @@ export function registerAgents(server) {
746
840
  }));
747
841
  server.registerTool("agent_rename", {
748
842
  description: "Change a worker's display name. Its actor_id (agent:N) does not change, so every pad write, todo comment and lease it has already made stays attributable. A live claude worker is also told to retitle its own session, which shows up in its pane; that arrives as a user turn, so rename between assignments rather than mid-task. Refuses a lead target outright.",
843
+ annotations: {
844
+ readOnlyHint: false,
845
+ destructiveHint: true,
846
+ idempotentHint: false,
847
+ openWorldHint: false,
848
+ },
749
849
  inputSchema: {
750
850
  name: agentNameParam,
751
851
  agent_id: agentIdParam,
@@ -838,6 +938,12 @@ export function registerAgents(server) {
838
938
  }));
839
939
  server.registerTool("agent_list", {
840
940
  description: "List this project's agents with live status. Without include_closed, this is every running agent, in full. With include_closed, it is every agent (running and closed/parked), newest first, bounded by limit (default 20, max 100); when the receipt carries next_before_id, page through the rest by passing it back as before_id.",
941
+ annotations: {
942
+ readOnlyHint: false,
943
+ destructiveHint: false,
944
+ idempotentHint: true,
945
+ openWorldHint: false,
946
+ },
841
947
  inputSchema: {
842
948
  include_closed: z.boolean().optional(),
843
949
  limit: z
@@ -907,6 +1013,12 @@ export function registerAgents(server) {
907
1013
  }));
908
1014
  server.registerTool("agent_status", {
909
1015
  description: "Detailed status for one agent, addressed by name (or agent_id), including a short tail of its terminal. include_brief=true returns the exact brief this worker was given; hive keeps that copy because an appended system prompt appears in no transcript.",
1016
+ annotations: {
1017
+ readOnlyHint: false,
1018
+ destructiveHint: false,
1019
+ idempotentHint: true,
1020
+ openWorldHint: false,
1021
+ },
910
1022
  inputSchema: {
911
1023
  name: agentNameParam,
912
1024
  agent_id: agentIdParam,
@@ -937,6 +1049,12 @@ export function registerAgents(server) {
937
1049
  }));
938
1050
  server.registerTool("agent_send", {
939
1051
  description: "Type into an agent's terminal, addressed by name (or agent_id). text of any shape is prefixed with the sender tag, delivered as one bracketed paste and submitted with Enter unless submit=false. ONE EXCEPTION: text over 300 characters sent to a LEAD by anyone who is not that lead is stored and delivered as a one-line pointer instead, because a lead's pane is a human's own window; the receipt says so and names agent_message_get for the full text. Worker-bound text is never shortened at any length. Alternatively pass keys (tmux key names like Escape, C-c, Enter). wait_ms (250-10000) returns the terminal tail after sending. A claude worker is already briefed by agent_spawn. A worker whose screen hive cannot classify is REFUSED on the text path entirely (its brief is at the spawn receipt's brief_path); keys still reaches it. A pane in tmux copy mode is REFUSED too, and retriably: tmux clears its bracketed-paste flag there, so the paste would lose its markers and the Enter would be eaten - leave copy mode (or agent_send(keys: [\"-X\", \"cancel\"]) to cancel it deliberately) and send again.",
1052
+ annotations: {
1053
+ readOnlyHint: false,
1054
+ destructiveHint: true,
1055
+ idempotentHint: false,
1056
+ openWorldHint: false,
1057
+ },
940
1058
  inputSchema: {
941
1059
  name: agentNameParam,
942
1060
  agent_id: agentIdParam,
@@ -951,6 +1069,7 @@ export function registerAgents(server) {
951
1069
  const agent = findAgent(project.id, args);
952
1070
  requireLive(agent);
953
1071
  const target = agent.tmux_target;
1072
+ const auditIdentity = captureQueenWriteIdentity(project.id);
954
1073
  let outgoing = args.text ?? "";
955
1074
  const tag = senderTag(project.id, currentActor());
956
1075
  let shortened = null;
@@ -1094,6 +1213,7 @@ export function registerAgents(server) {
1094
1213
  : `That target is a LEAD session, so agent_send's keys path is refused against it from a non-lead caller: you cannot finish this yourself. A human at that terminal, or another lead, has to press Enter there. Leave it and try again later.${shortened === null ? "" : " Pressing Enter there is the INTENDED finish, not a stray paste: that stranded line is hive's own pointer, and submitting it is exactly what this send was supposed to do."}`) +
1095
1214
  shortenedClause, { cause: err });
1096
1215
  }
1216
+ confirmQueenWrite("agent_send", project.id, args, { sent: true, agent_id: agent.id }, auditIdentity);
1097
1217
  }
1098
1218
  else {
1099
1219
  throw new Error("Pass text or keys.");
@@ -1121,6 +1241,12 @@ export function registerAgents(server) {
1121
1241
  }));
1122
1242
  server.registerTool("agent_message_get", {
1123
1243
  description: "Read one agent-to-lead message in full, by the id in a \"[hive:worker NAME] [message #N ...]\" pointer line. hive stores a message here only when it shortens one: text over 300 characters sent to a lead by someone who is not that lead. Every other send is typed with its sender tag and stores nothing, so there is no id to read. Messages are pruned after 7 days, and a lookup for a pruned id says so rather than reporting it missing.",
1244
+ annotations: {
1245
+ readOnlyHint: false,
1246
+ destructiveHint: false,
1247
+ idempotentHint: true,
1248
+ openWorldHint: false,
1249
+ },
1124
1250
  inputSchema: {
1125
1251
  message_id: idParam,
1126
1252
  project_id: projectIdParam,
@@ -1143,6 +1269,12 @@ export function registerAgents(server) {
1143
1269
  }));
1144
1270
  server.registerTool("agent_output", {
1145
1271
  description: "Read the rendered terminal of an agent (default 50 lines, max 200), addressed by name or agent_id. Read REAL output before declaring a worker done.",
1272
+ annotations: {
1273
+ readOnlyHint: false,
1274
+ destructiveHint: false,
1275
+ idempotentHint: true,
1276
+ openWorldHint: false,
1277
+ },
1146
1278
  inputSchema: {
1147
1279
  name: agentNameParam,
1148
1280
  agent_id: agentIdParam,
@@ -1172,6 +1304,12 @@ export function registerAgents(server) {
1172
1304
  }));
1173
1305
  server.registerTool("agent_close", {
1174
1306
  description: "Kill an agent's tmux window and mark it closed, addressed by name (or agent_id). Capture handoffs (todo comments, pads) BEFORE closing; terminal output is not retained. Closing yourself requires confirm_self=true. Refuses a lead target whose pane is live; retires one whose pane is confirmed dead. A worker may never close a lead, live or dead.",
1307
+ annotations: {
1308
+ readOnlyHint: false,
1309
+ destructiveHint: true,
1310
+ idempotentHint: true,
1311
+ openWorldHint: false,
1312
+ },
1175
1313
  inputSchema: {
1176
1314
  name: agentNameParam,
1177
1315
  agent_id: agentIdParam,
package/dist/tools/kv.js CHANGED
@@ -9,6 +9,12 @@ function purgeExpired(projectId) {
9
9
  export function registerKv(server) {
10
10
  server.registerTool("kv_set", {
11
11
  description: "Set a small shared JSON value other sessions can discover. Optional TTL in seconds.",
12
+ annotations: {
13
+ readOnlyHint: false,
14
+ destructiveHint: true,
15
+ idempotentHint: false,
16
+ openWorldHint: false,
17
+ },
12
18
  inputSchema: {
13
19
  key: z.string(),
14
20
  value: z.any().describe("Any JSON value."),
@@ -31,6 +37,12 @@ export function registerKv(server) {
31
37
  }));
32
38
  server.registerTool("kv_get", {
33
39
  description: "Get a shared JSON value by key.",
40
+ annotations: {
41
+ readOnlyHint: false,
42
+ destructiveHint: true,
43
+ idempotentHint: true,
44
+ openWorldHint: false,
45
+ },
34
46
  inputSchema: { key: z.string(), project_id: projectIdParam },
35
47
  }, (args) => run(() => {
36
48
  const projectId = effectiveProjectId(args.project_id);
@@ -52,6 +64,12 @@ export function registerKv(server) {
52
64
  }));
53
65
  server.registerTool("kv_list", {
54
66
  description: "List shared values, optionally filtered by key prefix.",
67
+ annotations: {
68
+ readOnlyHint: false,
69
+ destructiveHint: true,
70
+ idempotentHint: true,
71
+ openWorldHint: false,
72
+ },
55
73
  inputSchema: { prefix: z.string().optional(), project_id: projectIdParam },
56
74
  }, (args) => run(() => {
57
75
  const projectId = effectiveProjectId(args.project_id);
@@ -77,6 +95,12 @@ export function registerKv(server) {
77
95
  }));
78
96
  server.registerTool("kv_delete", {
79
97
  description: "Delete a shared value by key.",
98
+ annotations: {
99
+ readOnlyHint: false,
100
+ destructiveHint: true,
101
+ idempotentHint: true,
102
+ openWorldHint: false,
103
+ },
80
104
  inputSchema: { key: z.string(), project_id: projectIdParam },
81
105
  outputSchema: { project_id: idParam, key: z.string(), deleted: z.boolean() },
82
106
  }, (args) => run(() => {
@@ -18,9 +18,15 @@ export function extendOwnedLease(projectId, key, actor, ttlSeconds) {
18
18
  }
19
19
  export function registerLeases(server) {
20
20
  server.registerTool("lease_acquire", {
21
- description: "Try to take a named lease on a shared work area (non-blocking). Re-taking your own lease extends it. Leases expire on their own.",
21
+ 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.",
22
+ annotations: {
23
+ readOnlyHint: false,
24
+ destructiveHint: false,
25
+ idempotentHint: false,
26
+ openWorldHint: false,
27
+ },
22
28
  inputSchema: {
23
- key: z.string().describe('Stable and specific, like "file:src/api/routes.ts".'),
29
+ key: z.string().describe('Stable and specific, like "db:dev".'),
24
30
  ttl_seconds: z.number().int().positive(),
25
31
  project_id: projectIdParam,
26
32
  },
@@ -75,6 +81,12 @@ export function registerLeases(server) {
75
81
  }));
76
82
  server.registerTool("lease_release", {
77
83
  description: "Release a lease you own.",
84
+ annotations: {
85
+ readOnlyHint: false,
86
+ destructiveHint: true,
87
+ idempotentHint: true,
88
+ openWorldHint: false,
89
+ },
78
90
  inputSchema: { key: z.string(), project_id: projectIdParam },
79
91
  outputSchema: { project_id: idParam, key: z.string(), released: z.boolean() },
80
92
  }, (args) => run(() => {
@@ -24,6 +24,12 @@ function existsWhere(table, column, value) {
24
24
  function projectOwnsRows(projectId) {
25
25
  return PROJECT_OWNER_TABLES.some((table) => existsWhere(table, "project_id", projectId));
26
26
  }
27
+ function ownedRowCounts(projectId) {
28
+ return PROJECT_OWNER_TABLES.flatMap((table) => {
29
+ const n = db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE project_id = ?`).get(projectId).n;
30
+ return n > 0 ? [`${table}: ${n}`] : [];
31
+ });
32
+ }
27
33
  const pruneProjectIfEmpty = db.transaction((projectId) => {
28
34
  if (projectOwnsRows(projectId))
29
35
  return false;
@@ -60,6 +66,12 @@ const pruneActorIfInert = db.transaction((actorId) => {
60
66
  export function registerMeta(server) {
61
67
  server.registerTool("whoami", {
62
68
  description: "Show this session's actor identity and effective project scope. Call this first in a new session.",
69
+ annotations: {
70
+ readOnlyHint: false,
71
+ destructiveHint: false,
72
+ idempotentHint: false,
73
+ openWorldHint: false,
74
+ },
63
75
  inputSchema: {},
64
76
  }, () => run(() => {
65
77
  const actorId = currentActor();
@@ -83,6 +95,12 @@ export function registerMeta(server) {
83
95
  }));
84
96
  server.registerTool("help", {
85
97
  description: `Hive usage guidance. Omit topic for an overview, or pass one of: ${Object.keys(HELP_TOPICS).join(", ")}.`,
98
+ annotations: {
99
+ readOnlyHint: true,
100
+ destructiveHint: false,
101
+ idempotentHint: true,
102
+ openWorldHint: false,
103
+ },
86
104
  inputSchema: { topic: z.string().optional() },
87
105
  }, ({ topic }) => run(() => {
88
106
  if (!topic)
@@ -95,6 +113,12 @@ export function registerMeta(server) {
95
113
  }));
96
114
  server.registerTool("project_list", {
97
115
  description: "List registered projects and the currently selected one.",
116
+ annotations: {
117
+ readOnlyHint: false,
118
+ destructiveHint: false,
119
+ idempotentHint: true,
120
+ openWorldHint: false,
121
+ },
98
122
  inputSchema: {},
99
123
  }, () => run(() => ({
100
124
  projects: listProjects(),
@@ -102,6 +126,12 @@ export function registerMeta(server) {
102
126
  })));
103
127
  server.registerTool("project_add", {
104
128
  description: "Register a directory as a project. Defaults to the current working directory. Returns the existing project if the path is already registered.",
129
+ annotations: {
130
+ readOnlyHint: false,
131
+ destructiveHint: false,
132
+ idempotentHint: true,
133
+ openWorldHint: false,
134
+ },
105
135
  inputSchema: {
106
136
  path: z.string().optional(),
107
137
  name: z.string().optional(),
@@ -110,19 +140,45 @@ export function registerMeta(server) {
110
140
  }, ({ path, name }) => run(() => addProject(path, name)));
111
141
  server.registerTool("project_select", {
112
142
  description: "Set which project later tools act on in this session.",
143
+ annotations: {
144
+ readOnlyHint: false,
145
+ destructiveHint: false,
146
+ idempotentHint: true,
147
+ openWorldHint: false,
148
+ },
113
149
  inputSchema: { project_id: idParam },
114
150
  }, ({ project_id }) => run(() => selectProjectById(project_id)));
115
151
  server.registerTool("project_prune", {
116
- description: "Delete every registered project that owns no rows anywhere in the store (pads, todos, kv, leases, agents, wakes, command_trust), verified individually before each delete. Never prunes the caller's own project. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep, and a project-locked session may only touch its own project. Immediate, permanent: no dry-run mode.",
117
- inputSchema: {},
152
+ description: "Delete every registered project that owns no rows anywhere in the store (pads, todos, kv, leases, agents, wakes, command_trust), verified individually before each delete. Never prunes the caller's own project. With project_id, removes exactly that one project instead of sweeping, and only if it owns no rows; otherwise refuses and names what it owns. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep, and a project-locked session may only touch its own project. Immediate, permanent: no dry-run mode.",
153
+ annotations: {
154
+ readOnlyHint: false,
155
+ destructiveHint: true,
156
+ idempotentHint: true,
157
+ openWorldHint: false,
158
+ },
159
+ inputSchema: { project_id: idParam.optional() },
118
160
  outputSchema: {
119
161
  deleted: z.array(z.object({ id: idParam, name: z.string() })),
120
162
  held_back: z.object({ project_id: idParam, reason: z.string() }),
121
163
  errors: z.array(z.object({ id: idParam, name: z.string(), error: z.string() })).optional(),
122
164
  },
123
- }, () => run(() => {
165
+ }, ({ project_id }) => run(() => {
124
166
  refuseIfLocked("project_prune");
125
167
  const homeId = resolveProject().id;
168
+ if (project_id !== undefined) {
169
+ if (project_id === homeId)
170
+ throw new Error(`project_prune: project ${project_id} is the caller's own project and is never pruned.`);
171
+ const target = listProjects().find((p) => p.id === project_id);
172
+ if (!target)
173
+ throw new Error(`project_prune: no project ${project_id}. List them with project_list.`);
174
+ if (!pruneProjectIfEmpty.immediate(project_id)) {
175
+ throw new Error(`project_prune: project ${project_id} ("${target.name}") is not empty, nothing deleted. It owns ${ownedRowCounts(project_id).join(", ")}.`);
176
+ }
177
+ return {
178
+ deleted: [{ id: target.id, name: target.name }],
179
+ held_back: { project_id: homeId, reason: "caller's own project" },
180
+ };
181
+ }
126
182
  const deleted = [];
127
183
  const errors = [];
128
184
  for (const project of listProjects()) {
@@ -145,6 +201,12 @@ export function registerMeta(server) {
145
201
  }));
146
202
  server.registerTool("actor_prune", {
147
203
  description: "Delete every actor that owns no rows anywhere in the store and has not been active in the last minute: agents.actor_id, agents.parent_actor_id, todos.locked_by, todo_comments.author, kv.updated_by, leases.owner, pads.updated_by, wakes.owner, wakes.deliver_actor, agent_state_log.actor_id. The scan is global across every project, never scoped to the caller's: actors carry no project_id, so an actor can own rows in a project the caller cannot see, and a project-scoped scan would misread that actor as inert and delete it. Never prunes the caller's own actor. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep. Run this after project_prune when sweeping the store: an empty project owns no agents rows either, so today the order cannot orphan an actor, but that stops being true the day project deletion ever covers a non-empty project, and this ordering is the one that stays safe if it does. Immediate, permanent: no dry-run mode.",
204
+ annotations: {
205
+ readOnlyHint: false,
206
+ destructiveHint: true,
207
+ idempotentHint: true,
208
+ openWorldHint: false,
209
+ },
148
210
  inputSchema: {},
149
211
  outputSchema: {
150
212
  deleted: z.array(z.object({ id: z.string(), name: z.string() })),
@@ -80,6 +80,12 @@ export function createPad(projectId, name, content, tags) {
80
80
  export function registerPads(server) {
81
81
  server.registerTool("pad_write", {
82
82
  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.",
83
+ annotations: {
84
+ readOnlyHint: false,
85
+ destructiveHint: true,
86
+ idempotentHint: false,
87
+ openWorldHint: false,
88
+ },
83
89
  inputSchema: {
84
90
  name: z.string(),
85
91
  content: z.string(),
@@ -105,6 +111,12 @@ export function registerPads(server) {
105
111
  }));
106
112
  server.registerTool("pad_read", {
107
113
  description: "Read a pad's content, revision, and metadata by pad_id or name.",
114
+ annotations: {
115
+ readOnlyHint: false,
116
+ destructiveHint: false,
117
+ idempotentHint: true,
118
+ openWorldHint: false,
119
+ },
108
120
  inputSchema: {
109
121
  pad_id: idParam.optional(),
110
122
  name: z.string().optional(),
@@ -139,6 +151,12 @@ export function registerPads(server) {
139
151
  }));
140
152
  server.registerTool("pad_append", {
141
153
  description: "Append content to the end of a pad. Optional expected_revision guards against concurrent writes.",
154
+ annotations: {
155
+ readOnlyHint: false,
156
+ destructiveHint: false,
157
+ idempotentHint: false,
158
+ openWorldHint: false,
159
+ },
142
160
  inputSchema: {
143
161
  pad_id: idParam,
144
162
  content: z.string(),
@@ -155,6 +173,12 @@ export function registerPads(server) {
155
173
  }));
156
174
  server.registerTool("pad_edit", {
157
175
  description: "Replace one literal occurrence of old_text with new_text in a pad. old_text must match exactly once; include surrounding context to disambiguate.",
176
+ annotations: {
177
+ readOnlyHint: false,
178
+ destructiveHint: true,
179
+ idempotentHint: false,
180
+ openWorldHint: false,
181
+ },
158
182
  inputSchema: {
159
183
  pad_id: idParam,
160
184
  old_text: z.string(),
@@ -180,6 +204,12 @@ export function registerPads(server) {
180
204
  }));
181
205
  server.registerTool("pad_archive", {
182
206
  description: "Archive a pad (or unarchive with archived=false). Archiving frees the name for a new active pad; the old content stays readable by pad_id.",
207
+ annotations: {
208
+ readOnlyHint: false,
209
+ destructiveHint: true,
210
+ idempotentHint: false,
211
+ openWorldHint: false,
212
+ },
183
213
  inputSchema: {
184
214
  pad_id: idParam,
185
215
  archived: z.boolean().optional().describe("Default true. Pass false to unarchive."),
@@ -207,6 +237,12 @@ export function registerPads(server) {
207
237
  }));
208
238
  server.registerTool("pad_delete", {
209
239
  description: "Permanently delete a pad. Irreversible; prefer pad_archive. Optional expected_revision guards against deleting a pad someone just updated.",
240
+ annotations: {
241
+ readOnlyHint: false,
242
+ destructiveHint: true,
243
+ idempotentHint: true,
244
+ openWorldHint: false,
245
+ },
210
246
  inputSchema: {
211
247
  pad_id: idParam,
212
248
  expected_revision: idParam.optional(),
@@ -223,6 +259,12 @@ export function registerPads(server) {
223
259
  }));
224
260
  server.registerTool("pad_list", {
225
261
  description: "List pads without full content. query matches names and content (returns a snippet); tags matches any listed tag.",
262
+ annotations: {
263
+ readOnlyHint: false,
264
+ destructiveHint: false,
265
+ idempotentHint: true,
266
+ openWorldHint: false,
267
+ },
226
268
  inputSchema: {
227
269
  query: z.string().optional(),
228
270
  tags: z.array(z.string()).optional(),