@cmgmyr/hive 1.2.2 → 1.3.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.
@@ -3,15 +3,15 @@ import { randomUUID } from "node:crypto";
3
3
  import { z } from "zod";
4
4
  import { db } from "../db.js";
5
5
  import { agentBriefPath, mergedBriefVars, readAgentBrief, workerBrief, workerCommandString, writeAgentBrief, } from "../brief.js";
6
- import { codexHomeDir, codexInstructionsPhrase, codexLaunchArgs, ensureCodexHome, ensureCodexHooksFile } from "../codexHome.js";
6
+ import { codexHomeDir, codexInstructionsPhrase, codexLaunchArgs, ensureCodexHome, ensureCodexHooksFile, refreshCodexMcpEnv } from "../codexHome.js";
7
7
  import { currentActor, findProjectForDir, getProject, linkedWorktreePrimaryRoot, resolveProject } from "../context.js";
8
- import { commandHead, harnessFor, harnessNames, paneClassifierFor, resolvedCommandPrefix, screenClassifiable, } from "../harnesses.js";
8
+ import { commandHead, harnessFor, harnessNames, readOnlyExtraArgsAllowed, paneClassifierFor, resolvedCommandPrefix, screenClassifiable, } from "../harnesses.js";
9
9
  import { ensureWorkerHooksFile } from "../hooks.js";
10
10
  import { activeProfile, allowedAgents, loadProjectYml } from "../projectYml.js";
11
11
  import { classifyMiss, LEAD_MESSAGE_THRESHOLD, leadPointerMarker, missMessage, readLeadMessage, renderLeadPointer, senderTag, shortenedSendFailureClause, shortenedSendNote, storeLeadMessage, } from "../leadMessage.js";
12
12
  import { run } from "../result.js";
13
13
  import { markGoneReported } from "../scheduler.js";
14
- import { branchAt, closeAgentRow, discardOrphanedPane, isReservedAgentName, isRunningLeadActor, killAgentPane, launchAgent, LEAD_KIND, parkAgentRow, reapCodexHomeForClosedAgent, releaseParkRow, renameAgent, resumeAgent, } from "../spawn.js";
14
+ import { agentIdentityEnv, branchAt, closeAgentRow, discardOrphanedPane, isReservedAgentName, isRunningLeadActor, killAgentPane, launchAgent, LEAD_KIND, parkAgentRow, reapCodexHomeForClosedAgent, releaseParkRow, renameAgent, resumeAgent, storedLaunchOptions, } from "../spawn.js";
15
15
  import { COMMAND_KIND, runningCommandRow, stopLine, stopProcess, STOP_REASONS } from "../processes.js";
16
16
  import { readContextTokens, readContextFill, resolveTranscriptDir } from "../transcript.js";
17
17
  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 +161,67 @@ function requireNameFree(projectId, name, exceptAgentId) {
161
161
  throw new Error(`A running agent named "${taken.name}" already exists. Pick another name.`);
162
162
  }
163
163
  }
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.
164
+ function commandTokens(command) {
165
+ const tokens = [];
166
+ let token = "";
167
+ let quote = null;
168
+ let escaped = false;
169
+ for (const char of command) {
170
+ if (escaped) {
171
+ token += char;
172
+ escaped = false;
173
+ }
174
+ else if (char === "\\" && quote !== "'") {
175
+ escaped = true;
176
+ }
177
+ else if (quote) {
178
+ if (char === quote)
179
+ quote = null;
180
+ else
181
+ token += char;
182
+ }
183
+ else if (char === "'" || char === '"') {
184
+ quote = char;
185
+ }
186
+ else if (/\s/.test(char)) {
187
+ if (token) {
188
+ tokens.push(token);
189
+ token = "";
190
+ }
191
+ }
192
+ else {
193
+ token += char;
194
+ }
195
+ }
196
+ if (escaped)
197
+ token += "\\";
198
+ if (token)
199
+ tokens.push(token);
200
+ return tokens;
201
+ }
168
202
  function extractCommandFlag(command, flag) {
169
- const tokens = command.split(/\s+/);
203
+ const tokens = commandTokens(command);
170
204
  const i = tokens.indexOf(flag);
171
205
  if (i === -1 || i + 1 >= tokens.length)
172
206
  return undefined;
173
207
  return tokens[i + 1];
174
208
  }
209
+ function stripSessionArgs(args) {
210
+ const stripped = [];
211
+ for (let i = 0; i < args.length; i++) {
212
+ const arg = args[i];
213
+ if (arg === "--resume" || arg === "--session-id" || arg === "-r") {
214
+ i++;
215
+ }
216
+ else if (arg === "--last" || arg === "--fork-session" || arg.startsWith("--resume=") || arg.startsWith("--session-id=")) {
217
+ continue;
218
+ }
219
+ else {
220
+ stripped.push(arg);
221
+ }
222
+ }
223
+ return stripped;
224
+ }
175
225
  function requestsExistingSession(extraArgs) {
176
226
  return (extraArgs ?? []).some((arg) => arg === "--resume" ||
177
227
  arg.startsWith("--resume=") ||
@@ -261,6 +311,7 @@ function agentSummary(row, snapshot) {
261
311
  provenance,
262
312
  ...lastLogEventField(row),
263
313
  ...permissionModeField(row),
314
+ read_only: storedLaunchOptions(row.extra_args).readOnly,
264
315
  ...contextFillField(row),
265
316
  ...(row.parked_at ? { parked_at: row.parked_at, parked_branch: row.parked_branch || null } : {}),
266
317
  tmux_target: row.tmux_target,
@@ -285,7 +336,13 @@ export function worktreeInstallNotice(cwd, projectPath, config) {
285
336
  }
286
337
  export function registerAgents(server) {
287
338
  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.",
339
+ 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.",
340
+ annotations: {
341
+ readOnlyHint: false,
342
+ destructiveHint: false,
343
+ idempotentHint: false,
344
+ openWorldHint: false,
345
+ },
289
346
  inputSchema: {
290
347
  name: z
291
348
  .string()
@@ -301,6 +358,7 @@ export function registerAgents(server) {
301
358
  .optional()
302
359
  .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
360
  extra_args: z.array(z.string()).optional().describe("Extra CLI arguments."),
361
+ 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
362
  cwd: z
305
363
  .string()
306
364
  .optional()
@@ -326,6 +384,7 @@ export function registerAgents(server) {
326
384
  worktree_install: z.string().optional(),
327
385
  brief_path: z.string().optional(),
328
386
  codex_home: z.string().optional(),
387
+ read_only: z.boolean().optional(),
329
388
  codex_instructions: z.string().optional(),
330
389
  ready: z.boolean().optional(),
331
390
  exited: z.boolean().optional(),
@@ -378,6 +437,11 @@ export function registerAgents(server) {
378
437
  throw new Error(`[agent_spawn:harness-not-allowed] "${harness.name}" is not in this project's hive.yml ` +
379
438
  `agents: list (${allowed.join(", ")}). Add it to agents: to allow spawning it.`);
380
439
  }
440
+ if (args.read_only && (!["claude", "codex"].includes(harness.name) ||
441
+ !/^[A-Za-z0-9_./-]+$/.test(baseCommand) ||
442
+ !readOnlyExtraArgsAllowed(harness.name, args.extra_args))) {
443
+ throw new Error("read_only requires a bare claude or codex executable and no extra_args; launch overrides can bypass its restrictions.");
444
+ }
381
445
  const mintsSession = harness.mintsSessionId;
382
446
  const sessionId = mintsSession && !requestsExistingSession(args.extra_args) ? randomUUID() : "";
383
447
  const worktreeInstall = worktreeInstallNotice(cwd, project.path, projectConfig);
@@ -401,7 +465,7 @@ export function registerAgents(server) {
401
465
  const briefPath = harness.briefDelivery ? writeAgentBrief(agentId, brief) : undefined;
402
466
  let homeArgs = [];
403
467
  if (codexHomeKey) {
404
- const home = ensureCodexHome({ key: codexHomeKey, actorId, cwd, brief: brief, includePostToolUse: projectConfig?.context_checkpoint_percent != null });
468
+ 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
469
  homeArgs = home.extraArgs;
406
470
  codexInstructionLayers = home.instructionLayers;
407
471
  }
@@ -415,7 +479,7 @@ export function registerAgents(server) {
415
479
  ? [...(sessionId ? ["--session-id", sessionId] : []), ...(args.extra_args ?? [])]
416
480
  : (args.extra_args ?? [])),
417
481
  ],
418
- settingsPath: harness.briefDelivery ? ensureWorkerHooksFile(agentId, { includePostToolUse: projectConfig?.context_checkpoint_percent != null }) : undefined,
482
+ settingsPath: harness.briefDelivery ? ensureWorkerHooksFile(agentId, { readOnly: args.read_only, includePostToolUse: projectConfig?.context_checkpoint_percent != null }) : undefined,
419
483
  briefPath,
420
484
  });
421
485
  };
@@ -439,6 +503,9 @@ export function registerAgents(server) {
439
503
  parentActor: parent,
440
504
  sessionId,
441
505
  codexHome: codexHomeKey,
506
+ model: args.model,
507
+ extraArgs: args.extra_args,
508
+ readOnly: args.read_only,
442
509
  retainOnExit: harness.classifiesPaneScreen,
443
510
  });
444
511
  }
@@ -497,6 +564,7 @@ export function registerAgents(server) {
497
564
  const codexInstructions = codexHomeKey ? codexInstructionsPhrase(codexInstructionLayers) : undefined;
498
565
  return {
499
566
  agent_id: agentId,
567
+ ...(args.read_only ? { read_only: true } : {}),
500
568
  actor_id: actorId,
501
569
  name,
502
570
  tmux_target: target,
@@ -546,6 +614,12 @@ export function registerAgents(server) {
546
614
  }));
547
615
  server.registerTool("agent_resume", {
548
616
  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.",
617
+ annotations: {
618
+ readOnlyHint: false,
619
+ destructiveHint: false,
620
+ idempotentHint: false,
621
+ openWorldHint: false,
622
+ },
549
623
  inputSchema: {
550
624
  name: agentNameParam,
551
625
  agent_id: agentIdParam,
@@ -614,6 +688,7 @@ export function registerAgents(server) {
614
688
  const { config: projectConfig } = loadProjectYml(project.path);
615
689
  const placement = projectConfig?.placement ?? (process.env.HIVE_SPAWN_PLACEMENT === "window" ? "window" : "split");
616
690
  const layout = projectConfig?.layout ?? DEFAULT_LAYOUT;
691
+ const launchOptions = storedLaunchOptions(agent.extra_args);
617
692
  let commandString;
618
693
  let resumeEnv = contextCheckpointEnv(projectConfig);
619
694
  if (resumeHarness.name === "codex") {
@@ -622,23 +697,34 @@ export function registerAgents(server) {
622
697
  // Recovered from the row's own recorded command: every codex worker hive spawns
623
698
  // carries a pinned model (codex has no bare alias), so dropping this would silently
624
699
  // resume on codex's default model instead of the one the lane actually chose.
625
- model: extractCommandFlag(agent.command, "--model"),
700
+ model: agent.model ?? extractCommandFlag(agent.command, "--model"),
626
701
  displayName: agent.name,
627
702
  // Subcommand positional, not a flag - live-verified on v0.149.0 (`codex resume --help`:
628
703
  // "Usage: codex resume [OPTIONS] [SESSION_ID] [PROMPT]"). No --settings/hooks file: a
629
704
  // 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],
705
+ extraArgs: [
706
+ ...codexLaunchArgs(agent.cwd, launchOptions.readOnly),
707
+ ...stripSessionArgs(launchOptions.args),
708
+ "resume",
709
+ agent.session_id,
710
+ ],
631
711
  });
632
712
  resumeEnv = { ...resumeEnv, CODEX_HOME: codexHomeDir(agent.codex_home) };
633
713
  ensureCodexHooksFile(agent.codex_home, { includePostToolUse: projectConfig?.context_checkpoint_percent != null });
714
+ refreshCodexMcpEnv(agent.codex_home, agentIdentityEnv(agent.actor_id, agent.name, project.path));
634
715
  }
635
716
  else {
636
717
  const claudeBinary = resolvedCommandPrefix(agent.command) || "claude";
637
718
  commandString = workerCommandString({
638
719
  command: claudeBinary,
639
720
  displayName: agent.name,
640
- extraArgs: ["--resume", agent.session_id],
641
- settingsPath: ensureWorkerHooksFile(agent.id, { includePostToolUse: projectConfig?.context_checkpoint_percent != null }),
721
+ model: agent.model ?? undefined,
722
+ extraArgs: [
723
+ ...stripSessionArgs(launchOptions.args),
724
+ "--resume",
725
+ agent.session_id,
726
+ ],
727
+ settingsPath: ensureWorkerHooksFile(agent.id, { readOnly: launchOptions.readOnly, includePostToolUse: projectConfig?.context_checkpoint_percent != null }),
642
728
  });
643
729
  }
644
730
  const { target, landedInProjectId } = resumeAgent({
@@ -671,6 +757,12 @@ export function registerAgents(server) {
671
757
  }));
672
758
  server.registerTool("agent_park", {
673
759
  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.",
760
+ annotations: {
761
+ readOnlyHint: false,
762
+ destructiveHint: true,
763
+ idempotentHint: false,
764
+ openWorldHint: false,
765
+ },
674
766
  inputSchema: {
675
767
  name: agentNameParam,
676
768
  agent_id: agentIdParam,
@@ -746,6 +838,12 @@ export function registerAgents(server) {
746
838
  }));
747
839
  server.registerTool("agent_rename", {
748
840
  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.",
841
+ annotations: {
842
+ readOnlyHint: false,
843
+ destructiveHint: true,
844
+ idempotentHint: false,
845
+ openWorldHint: false,
846
+ },
749
847
  inputSchema: {
750
848
  name: agentNameParam,
751
849
  agent_id: agentIdParam,
@@ -838,6 +936,12 @@ export function registerAgents(server) {
838
936
  }));
839
937
  server.registerTool("agent_list", {
840
938
  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.",
939
+ annotations: {
940
+ readOnlyHint: false,
941
+ destructiveHint: false,
942
+ idempotentHint: true,
943
+ openWorldHint: false,
944
+ },
841
945
  inputSchema: {
842
946
  include_closed: z.boolean().optional(),
843
947
  limit: z
@@ -907,6 +1011,12 @@ export function registerAgents(server) {
907
1011
  }));
908
1012
  server.registerTool("agent_status", {
909
1013
  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.",
1014
+ annotations: {
1015
+ readOnlyHint: false,
1016
+ destructiveHint: false,
1017
+ idempotentHint: true,
1018
+ openWorldHint: false,
1019
+ },
910
1020
  inputSchema: {
911
1021
  name: agentNameParam,
912
1022
  agent_id: agentIdParam,
@@ -937,6 +1047,12 @@ export function registerAgents(server) {
937
1047
  }));
938
1048
  server.registerTool("agent_send", {
939
1049
  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.",
1050
+ annotations: {
1051
+ readOnlyHint: false,
1052
+ destructiveHint: true,
1053
+ idempotentHint: false,
1054
+ openWorldHint: false,
1055
+ },
940
1056
  inputSchema: {
941
1057
  name: agentNameParam,
942
1058
  agent_id: agentIdParam,
@@ -1121,6 +1237,12 @@ export function registerAgents(server) {
1121
1237
  }));
1122
1238
  server.registerTool("agent_message_get", {
1123
1239
  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.",
1240
+ annotations: {
1241
+ readOnlyHint: false,
1242
+ destructiveHint: false,
1243
+ idempotentHint: true,
1244
+ openWorldHint: false,
1245
+ },
1124
1246
  inputSchema: {
1125
1247
  message_id: idParam,
1126
1248
  project_id: projectIdParam,
@@ -1143,6 +1265,12 @@ export function registerAgents(server) {
1143
1265
  }));
1144
1266
  server.registerTool("agent_output", {
1145
1267
  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.",
1268
+ annotations: {
1269
+ readOnlyHint: false,
1270
+ destructiveHint: false,
1271
+ idempotentHint: true,
1272
+ openWorldHint: false,
1273
+ },
1146
1274
  inputSchema: {
1147
1275
  name: agentNameParam,
1148
1276
  agent_id: agentIdParam,
@@ -1172,6 +1300,12 @@ export function registerAgents(server) {
1172
1300
  }));
1173
1301
  server.registerTool("agent_close", {
1174
1302
  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.",
1303
+ annotations: {
1304
+ readOnlyHint: false,
1305
+ destructiveHint: true,
1306
+ idempotentHint: true,
1307
+ openWorldHint: false,
1308
+ },
1175
1309
  inputSchema: {
1176
1310
  name: agentNameParam,
1177
1311
  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(() => {
@@ -19,6 +19,12 @@ export function extendOwnedLease(projectId, key, actor, ttlSeconds) {
19
19
  export function registerLeases(server) {
20
20
  server.registerTool("lease_acquire", {
21
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.",
22
+ annotations: {
23
+ readOnlyHint: false,
24
+ destructiveHint: false,
25
+ idempotentHint: false,
26
+ openWorldHint: false,
27
+ },
22
28
  inputSchema: {
23
29
  key: z.string().describe('Stable and specific, like "file:src/api/routes.ts".'),
24
30
  ttl_seconds: z.number().int().positive(),
@@ -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(() => {
@@ -60,6 +60,12 @@ const pruneActorIfInert = db.transaction((actorId) => {
60
60
  export function registerMeta(server) {
61
61
  server.registerTool("whoami", {
62
62
  description: "Show this session's actor identity and effective project scope. Call this first in a new session.",
63
+ annotations: {
64
+ readOnlyHint: false,
65
+ destructiveHint: false,
66
+ idempotentHint: false,
67
+ openWorldHint: false,
68
+ },
63
69
  inputSchema: {},
64
70
  }, () => run(() => {
65
71
  const actorId = currentActor();
@@ -83,6 +89,12 @@ export function registerMeta(server) {
83
89
  }));
84
90
  server.registerTool("help", {
85
91
  description: `Hive usage guidance. Omit topic for an overview, or pass one of: ${Object.keys(HELP_TOPICS).join(", ")}.`,
92
+ annotations: {
93
+ readOnlyHint: true,
94
+ destructiveHint: false,
95
+ idempotentHint: true,
96
+ openWorldHint: false,
97
+ },
86
98
  inputSchema: { topic: z.string().optional() },
87
99
  }, ({ topic }) => run(() => {
88
100
  if (!topic)
@@ -95,6 +107,12 @@ export function registerMeta(server) {
95
107
  }));
96
108
  server.registerTool("project_list", {
97
109
  description: "List registered projects and the currently selected one.",
110
+ annotations: {
111
+ readOnlyHint: false,
112
+ destructiveHint: false,
113
+ idempotentHint: true,
114
+ openWorldHint: false,
115
+ },
98
116
  inputSchema: {},
99
117
  }, () => run(() => ({
100
118
  projects: listProjects(),
@@ -102,6 +120,12 @@ export function registerMeta(server) {
102
120
  })));
103
121
  server.registerTool("project_add", {
104
122
  description: "Register a directory as a project. Defaults to the current working directory. Returns the existing project if the path is already registered.",
123
+ annotations: {
124
+ readOnlyHint: false,
125
+ destructiveHint: false,
126
+ idempotentHint: true,
127
+ openWorldHint: false,
128
+ },
105
129
  inputSchema: {
106
130
  path: z.string().optional(),
107
131
  name: z.string().optional(),
@@ -110,10 +134,22 @@ export function registerMeta(server) {
110
134
  }, ({ path, name }) => run(() => addProject(path, name)));
111
135
  server.registerTool("project_select", {
112
136
  description: "Set which project later tools act on in this session.",
137
+ annotations: {
138
+ readOnlyHint: false,
139
+ destructiveHint: false,
140
+ idempotentHint: true,
141
+ openWorldHint: false,
142
+ },
113
143
  inputSchema: { project_id: idParam },
114
144
  }, ({ project_id }) => run(() => selectProjectById(project_id)));
115
145
  server.registerTool("project_prune", {
116
146
  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.",
147
+ annotations: {
148
+ readOnlyHint: false,
149
+ destructiveHint: true,
150
+ idempotentHint: true,
151
+ openWorldHint: false,
152
+ },
117
153
  inputSchema: {},
118
154
  outputSchema: {
119
155
  deleted: z.array(z.object({ id: idParam, name: z.string() })),
@@ -145,6 +181,12 @@ export function registerMeta(server) {
145
181
  }));
146
182
  server.registerTool("actor_prune", {
147
183
  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.",
184
+ annotations: {
185
+ readOnlyHint: false,
186
+ destructiveHint: true,
187
+ idempotentHint: true,
188
+ openWorldHint: false,
189
+ },
148
190
  inputSchema: {},
149
191
  outputSchema: {
150
192
  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(),