mercury-agent 0.21.0 → 0.23.0-beta.1

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 (145) hide show
  1. package/README.md +2 -2
  2. package/container/Dockerfile +2 -1
  3. package/container/Dockerfile.base +2 -1
  4. package/container/agent-package.json +1 -1
  5. package/container/build.sh +6 -3
  6. package/docs/configuration.md +249 -1
  7. package/docs/container-lifecycle.md +30 -8
  8. package/docs/deployment.md +45 -4
  9. package/docs/extensions.md +44 -5
  10. package/docs/goals/feed-watch-social-sources/decisions.md +602 -1
  11. package/docs/goals/feed-watch-social-sources/roadmap.md +27 -8
  12. package/docs/goals/feed-watch-social-sources/source-review-task-draft.md +80 -0
  13. package/docs/goals/football-match-day-mode/decisions.md +194 -25
  14. package/docs/goals/football-match-day-mode/goal.md +3 -3
  15. package/docs/goals/football-match-day-mode/roadmap.md +109 -50
  16. package/docs/goals/football-reporter-profile/decisions.md +143 -0
  17. package/docs/goals/football-reporter-profile/roadmap.md +10 -7
  18. package/docs/goals/rehearsal-bench/decisions.md +76 -0
  19. package/docs/goals/rehearsal-bench/goal.md +1 -1
  20. package/docs/goals/rehearsal-bench/roadmap.md +9 -6
  21. package/docs/goals/release-gate/README.md +4 -2
  22. package/docs/goals/release-gate/decisions.md +118 -0
  23. package/docs/goals/release-gate/goal.md +2 -2
  24. package/docs/goals/release-gate/roadmap.md +23 -12
  25. package/docs/goals/whatsapp-bot-hardening/decisions.md +53 -0
  26. package/docs/goals/whatsapp-bot-hardening/roadmap.md +15 -1
  27. package/docs/live-testing.md +88 -119
  28. package/docs/pending-verification.md +738 -69
  29. package/docs/pipeline.md +4 -1
  30. package/docs/profile-guide.md +6 -2
  31. package/docs/release-gate.md +276 -0
  32. package/docs/smoke-fixtures.md +245 -0
  33. package/docs/subagents.md +8 -4
  34. package/examples/extensions/feed-watch/config.ts +157 -5
  35. package/examples/extensions/feed-watch/digest.ts +631 -29
  36. package/examples/extensions/feed-watch/feeds.ts +124 -10
  37. package/examples/extensions/feed-watch/index.ts +164 -10
  38. package/examples/extensions/feed-watch/items.ts +147 -10
  39. package/examples/extensions/feed-watch/match.ts +77 -15
  40. package/examples/extensions/feed-watch/posted.ts +275 -0
  41. package/examples/extensions/feed-watch/prompts/verify.md +9 -0
  42. package/examples/extensions/feed-watch/scorecard.ts +232 -0
  43. package/examples/extensions/feed-watch/skill/SKILL.md +80 -10
  44. package/examples/extensions/feed-watch/watch.ts +961 -46
  45. package/examples/extensions/feed-watch/yield.ts +221 -0
  46. package/examples/extensions/gws/index.ts +150 -14
  47. package/examples/extensions/longview/hook.ts +63 -4
  48. package/examples/extensions/longview/index.ts +9 -0
  49. package/examples/extensions/longview/render/telegraph-nodes.ts +5 -1
  50. package/examples/extensions/morning/index.ts +1 -1
  51. package/examples/extensions/napkin/index.ts +167 -64
  52. package/examples/extensions/napkin/pi-spawn.ts +157 -2
  53. package/examples/extensions/poster/index.ts +13 -3
  54. package/examples/extensions/tradestation/host/refresh.ts +20 -0
  55. package/examples/extensions/tradestation/index.ts +81 -1
  56. package/examples/extensions/voice-transcribe/index.ts +1 -1
  57. package/examples/extensions/yahoo-mail/index.ts +23 -2
  58. package/examples/extensions/yahoo-mail/lib/ymail.ts +147 -0
  59. package/examples/extensions/yahoo-mail/package.json +1 -1
  60. package/examples/profiles/_template/config.yaml +11 -1
  61. package/examples/profiles/football-reporter/AGENTS.md +95 -21
  62. package/examples/profiles/football-reporter/README.md +100 -0
  63. package/examples/profiles/football-reporter/config.yaml +651 -22
  64. package/examples/profiles/football-reporter/seed/MEMORY.md +1 -1
  65. package/examples/profiles/football-reporter/seed/episodes/man-united-2026-27.md +1 -1
  66. package/examples/profiles/football-reporter/seed/names.md +54 -0
  67. package/examples/profiles/football-reporter/standard.json +46 -6
  68. package/examples/profiles/football-reporter/tasks/daily-article.md +102 -30
  69. package/examples/profiles/football-reporter/tasks/memes.md +72 -0
  70. package/examples/profiles/football-reporter/tasks/roundup.md +52 -0
  71. package/examples/smoke/club-newsroom/smoke.yaml +46 -0
  72. package/examples/smoke/proposal-desk/brief.md +8 -0
  73. package/examples/smoke/proposal-desk/smoke.yaml +26 -0
  74. package/package.json +17 -12
  75. package/resources/agents/explore.md +8 -0
  76. package/resources/pi-extensions/subagent/spawn.ts +22 -0
  77. package/resources/templates/mercury.example.yaml +5 -0
  78. package/src/adapters/slack.ts +2 -0
  79. package/src/adapters/whatsapp-db-lookups.ts +95 -0
  80. package/src/adapters/whatsapp-egress.ts +135 -0
  81. package/src/adapters/whatsapp-ingress.ts +62 -13
  82. package/src/adapters/whatsapp-media.ts +46 -33
  83. package/src/adapters/whatsapp-mentions.ts +307 -0
  84. package/src/adapters/whatsapp-thread.ts +54 -0
  85. package/src/adapters/whatsapp.ts +100 -28
  86. package/src/agent/container-entry.ts +20 -1
  87. package/src/agent/container-runner.ts +236 -90
  88. package/src/agent/image-contract.ts +1 -0
  89. package/src/agent/image-refresh.ts +27 -10
  90. package/src/bridges/whatsapp.ts +110 -30
  91. package/src/cli/mercury.ts +712 -251
  92. package/src/cli/upgrade.ts +392 -2
  93. package/src/config-file.ts +29 -0
  94. package/src/config.ts +137 -3
  95. package/src/core/commands.ts +17 -1
  96. package/src/core/connection-health.ts +278 -151
  97. package/src/core/handler.ts +194 -12
  98. package/src/core/heartbeat.ts +188 -0
  99. package/src/core/inbox-write.ts +148 -0
  100. package/src/core/operator-alerts.ts +210 -0
  101. package/src/core/permissions.ts +19 -0
  102. package/src/core/reporter-lint-on-send.ts +293 -0
  103. package/src/core/routes/capability.ts +23 -1
  104. package/src/core/routes/chat.ts +44 -6
  105. package/src/core/routes/config-builtin.ts +27 -0
  106. package/src/core/routes/connections.ts +52 -4
  107. package/src/core/routes/dashboard.ts +22 -0
  108. package/src/core/routes/shadow.ts +470 -0
  109. package/src/core/runtime.ts +770 -68
  110. package/src/core/shadow-bridge.ts +609 -0
  111. package/src/core/shadow-outbox.ts +319 -0
  112. package/src/core/simulate-ingress.ts +1106 -0
  113. package/src/core/system-messages.ts +10 -0
  114. package/src/core/task-scheduler.ts +78 -0
  115. package/src/core/task-sender.ts +164 -0
  116. package/src/env-dump/diff.ts +721 -0
  117. package/src/env-dump/manifest.ts +6 -2
  118. package/src/extensions/api.ts +29 -1
  119. package/src/extensions/bash-timeout.ts +112 -0
  120. package/src/extensions/loader.ts +28 -0
  121. package/src/extensions/types.ts +119 -7
  122. package/src/logger.ts +51 -2
  123. package/src/main.ts +106 -101
  124. package/src/ops/shadow-root.ts +60 -0
  125. package/src/ops/shadow-service.ts +421 -14
  126. package/src/ops/shadow-snapshot.ts +130 -3
  127. package/src/preflight/boot.ts +118 -0
  128. package/src/preflight/checks/docker.ts +23 -1
  129. package/src/preflight/checks/host-deps.ts +130 -0
  130. package/src/preflight/checks/roundtrip.ts +4 -0
  131. package/src/preflight/report.ts +3 -0
  132. package/src/preflight/run.ts +2 -0
  133. package/src/profile/readonly-store.ts +118 -0
  134. package/src/profile/space-profile.ts +186 -8
  135. package/src/server.ts +32 -0
  136. package/src/smoke/boot.ts +303 -0
  137. package/src/smoke/cli.ts +256 -0
  138. package/src/smoke/evaluate.ts +401 -0
  139. package/src/smoke/fixture.ts +387 -0
  140. package/src/smoke/refusal.ts +96 -0
  141. package/src/smoke/run.ts +355 -0
  142. package/src/storage/db.ts +236 -10
  143. package/src/storage/pi-auth.ts +42 -10
  144. package/src/text/reporter-lint.ts +501 -74
  145. package/src/types.ts +15 -0
package/README.md CHANGED
@@ -292,7 +292,7 @@ mercury service uninstall
292
292
  mercury service status
293
293
  mercury service logs [-f]
294
294
 
295
- # upgrade (stops the service, installs globally, restarts)
295
+ # upgrade (pulls the matching agent image first, then stops the service, installs globally, restarts; --dry-run prints the plan)
296
296
  mercury upgrade # latest
297
297
  mercury upgrade 0.14.0 # a specific version
298
298
  ```
@@ -420,7 +420,7 @@ Supported OAuth providers: Anthropic, GitHub Copilot, Google Gemini CLI, Antigra
420
420
 
421
421
  | Variable | Default | Description |
422
422
  |----------|---------|-------------|
423
- | `MERCURY_AGENT_IMAGE` | `ghcr.io/avishai-tsabari/mercury-agent:latest` | Container image |
423
+ | `MERCURY_AGENT_IMAGE` | `ghcr.io/avishai-tsabari/mercury-agent:<version>` | Container image; pinned to the installed version, see `docs/configuration.md` § Agent image |
424
424
  | `MERCURY_CONTAINER_TIMEOUT_MS` | `300000` | Container timeout (5 min) |
425
425
  | `MERCURY_CONTAINER_RUNTIME` | `runc` | `runc` (default) or `runsc` ([gVisor](https://gvisor.dev)) |
426
426
  | `MERCURY_CONTAINER_BWRAP_DOCKER_COMPAT` | `false` | Set `true` on Linux Docker Engine (see note below) |
@@ -60,7 +60,7 @@ RUN echo '{"args":["--no-sandbox"]}' > /home/mercury/.puppeteerrc.json
60
60
  ENV CHROMIUM_FLAGS="--no-sandbox"
61
61
 
62
62
  # Install CLIs
63
- RUN bun add -g @earendil-works/pi-coding-agent@~0.84.1
63
+ RUN bun add -g @earendil-works/pi-coding-agent@~0.85.1
64
64
 
65
65
  WORKDIR /app
66
66
 
@@ -111,6 +111,7 @@ COPY src/cli/mrctl.ts /app/src/cli/mrctl.ts
111
111
  COPY src/cli/mrctl-http.ts /app/src/cli/mrctl-http.ts
112
112
  COPY src/extensions/reserved.ts /app/src/extensions/reserved.ts
113
113
  COPY src/extensions/permission-guard.ts /app/src/extensions/permission-guard.ts
114
+ COPY src/extensions/bash-timeout.ts /app/src/extensions/bash-timeout.ts
114
115
  COPY src/text/sanitize-text.ts /app/src/text/sanitize-text.ts
115
116
  COPY src/types.ts /app/src/types.ts
116
117
  COPY resources/ /app/resources/
@@ -42,7 +42,7 @@ RUN echo '{"args":["--no-sandbox"]}' > /home/mercury/.puppeteerrc.json
42
42
  ENV CHROMIUM_FLAGS="--no-sandbox"
43
43
 
44
44
  # Install CLIs
45
- RUN bun add -g @earendil-works/pi-coding-agent@~0.84.1
45
+ RUN bun add -g @earendil-works/pi-coding-agent@~0.85.1
46
46
 
47
47
  WORKDIR /app
48
48
 
@@ -80,6 +80,7 @@ COPY src/cli/mrctl.ts /app/src/cli/mrctl.ts
80
80
  COPY src/cli/mrctl-http.ts /app/src/cli/mrctl-http.ts
81
81
  COPY src/extensions/reserved.ts /app/src/extensions/reserved.ts
82
82
  COPY src/extensions/permission-guard.ts /app/src/extensions/permission-guard.ts
83
+ COPY src/extensions/bash-timeout.ts /app/src/extensions/bash-timeout.ts
83
84
  COPY src/text/sanitize-text.ts /app/src/text/sanitize-text.ts
84
85
  COPY src/types.ts /app/src/types.ts
85
86
  COPY resources/ /app/resources/
@@ -3,6 +3,6 @@
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@earendil-works/pi-coding-agent": "~0.84.1"
6
+ "@earendil-works/pi-coding-agent": "~0.85.1"
7
7
  }
8
8
  }
@@ -31,9 +31,12 @@ docker build -f container/Dockerfile \
31
31
  -t "${IMAGE_NAME}:${VERSION}" \
32
32
  -t "${IMAGE_NAME}:latest" .
33
33
 
34
- # Both tags, on purpose. `:latest` is what src/config.ts still defaults to, so
35
- # dropping it would break every existing host; `:<version>` is what a host can
36
- # pin to once it stops trusting a floating tag (release-gate R0.4).
34
+ # Both tags, on purpose. `mercury-agent:latest` is the explicit local override
35
+ # a source-run box sets (`agent.image` / MERCURY_AGENT_IMAGE), so dropping it
36
+ # would break every such host; `mercury-agent:<version>` mirrors the registry
37
+ # tag the default now pins to (release-gate R0.6 — `src/config.ts` defaults to
38
+ # `ghcr.io/…/mercury-agent:<version>`, which `mercury build` tags as well).
39
+ # Neither local name has a registry host, so neither is ever pulled.
37
40
  echo "✓ Built ${IMAGE_NAME}:${VERSION} and ${IMAGE_NAME}:latest"
38
41
  echo ""
39
42
  echo "Version label:"
@@ -20,7 +20,9 @@ These must be supplied via environment variables only; they are **not** read fro
20
20
 
21
21
  - `MERCURY_API_SECRET`
22
22
  - `MERCURY_CHAT_API_KEY`
23
+ - `MERCURY_SHADOW_API_SECRET` (a shadow's own; see [Shadow mode](#shadow-mode-mercury_shadow))
23
24
  - `MERCURY_DISCORD_GATEWAY_SECRET`
25
+ - `MERCURY_HEARTBEAT_URL` (see [Heartbeat](#heartbeat-mercury_heartbeat_url-mercury_heartbeat_tasks))
24
26
 
25
27
  Platform tokens, provider API keys, and extension keys (e.g. `MERCURY_TELEGRAM_BOT_TOKEN`, `MERCURY_BRAVE_API_KEY`) are also **env-only** today—they are not part of the YAML schema.
26
28
 
@@ -103,6 +105,107 @@ Restarting *before* the make-up run lands drops it, the same way a restart drops
103
105
  a pending retry: the boot pass leaves `next_run_at` off the cron grid, and the
104
106
  next boot realigns normally.
105
107
 
108
+ ## Heartbeat (`MERCURY_HEARTBEAT_URL`, `MERCURY_HEARTBEAT_TASKS`)
109
+
110
+ Nothing on the box can alert while the box is off. Twice in six days the box
111
+ was down overnight (2026-09-05, 2026-09-10) and the 09:00 article with it, and
112
+ the missed-run report above could only speak once the box was back. The
113
+ heartbeat is the reader that lives somewhere else: after a listed task
114
+ **delivers**, the host GETs a URL, and the *service* behind that URL alerts the
115
+ owner's phone when the ping is late. A dead box, a dead WSL distro and a dead
116
+ WhatsApp socket are all heard the same way, because nothing here has to be
117
+ alive to raise the alarm.
118
+
119
+ Both keys are **env-only** (the URL is a credential — anyone holding it can mark
120
+ the check up — and is listed under Secrets above; `mercury env dump` reports
121
+ both as present or absent and prints neither value, so a swapped pair cannot
122
+ leak the URL through the task list):
123
+
124
+ ```bash
125
+ # .env
126
+ MERCURY_HEARTBEAT_URL=https://hc-ping.com/<ping-key>/mercury-{task}
127
+ MERCURY_HEARTBEAT_TASKS=24,621
128
+ ```
129
+
130
+ - **`{task}`** in the URL is replaced by the task id, so one credential serves
131
+ one check per task — with healthchecks.io's slug pings the checks above are
132
+ `mercury-24` and `mercury-621` under one project ping key. A URL without the
133
+ placeholder is fine for a single listed task; with two or more, every ping
134
+ lands on one check and a delivered roundup would silence the alarm for a
135
+ missing article — `mercury preflight` warns on that shape.
136
+ - **When a ping is sent — one rule.** A run of a listed task that recorded
137
+ `status: ok` with `outcome: posted` (the ledger's own words, see
138
+ `mercury env dump` / `task_runs`). **Not** on `no_update` — the article that
139
+ found nothing to say is exactly the case to alert on. Not on an error, not on
140
+ a delivery failure, not for an unlisted task (extension one-shots, chat turns).
141
+ A manual **Run now** that delivers counts; a `silent` task that delivers
142
+ counts (the run ran and produced; whether the group heard it is `silent`'s
143
+ question).
144
+ - **Bounded and never fatal.** One GET with a 5-second bound, after the ledger
145
+ row and the schedule advance, so a slow or dead ping host holds neither
146
+ hostage. Success is `Heartbeat pinged taskId=24 status=200` at info; failure
147
+ is `Heartbeat ping failed taskId=24 reason=ENOTFOUND` at **warn**, never an
148
+ error and never a failed run — a missing ping is what the service alerts on.
149
+ The log line carries the task id and the status; never the URL.
150
+ - **A shadow never pings.** `MERCURY_SHADOW=1` (below) disables the heartbeat
151
+ whatever the copied `.env` says, so a rehearsal cannot mark the live check up.
152
+ - **`mercury preflight` / `mercury doctor` — `host.heartbeat`.** `skipped` on a
153
+ box with no active cron task; `warn` (never `blocked`) when there are cron
154
+ tasks and no URL, a URL and no listed task, a listed id that is not an active
155
+ cron task, a malformed URL, or the shared-check shape above; `pass` otherwise.
156
+ The evidence names task ids, never the URL.
157
+
158
+ **Setting up the service (recommended: healthchecks.io, free tier).** One check
159
+ per listed task, shaped by how often the task *delivers*, not by its cron: only
160
+ a `posted` run pings, a `no_update` slot does not. A task that posts on every
161
+ slot gets a cron-shaped check — the article as `0 9 * * *` Asia/Jerusalem with a
162
+ 30-minute grace (alert at 09:30). A task that is allowed to stay silent on a
163
+ slot gets a *simple* check whose period covers its longest expected silence —
164
+ the roundup, where 3 of 9 slots in one week were `no_update`, as period 1 day
165
+ with a 6-hour grace; a cron-shaped check there alerts on every quiet slot.
166
+ Alert channels: email is enough — it reached the owner's phone within the
167
+ grace period the first time the box died (2026-09-12); a push channel
168
+ (Telegram, Signal) is optional. Never the bot's own WhatsApp, which is the
169
+ thing that is down.
170
+
171
+ **One healthchecks.io project per box, named after the box.** One account
172
+ will end up holding checks for several bots — a client's, a second project's,
173
+ this one's. The alert email leads with the *project* name, then the check
174
+ name, and `mercury-24` says nothing about which box missed. A project named
175
+ after the box (`Mercury — home PC (WSL)`) is the cheapest fix and the safest
176
+ shape: the project is the credential boundary — it owns the ping key, so a
177
+ key that leaks from a client's box cannot mark this bot's checks up — and it
178
+ owns its own alert channels and members, so a colleague added to a work
179
+ project never sees a private bot. Renaming the project touches no slug and
180
+ nothing on the box. Rename the checks inside it only if the task is not
181
+ obvious from the name (`article 09:00`, `roundup`, `WSL anchor`), and then
182
+ mind that the name and the **slug** are separate fields: the URL template
183
+ above pings by slug, so a rename must leave the slug (`mercury-24`) as it is
184
+ — read the Slug field in the same dialog before saving, and put it back if
185
+ the new name re-derived it — or the ping starts returning 404 and every
186
+ listed task reads as down. A changed slug is a box-side change: new
187
+ `MERCURY_HEARTBEAT_URL` template in `.env`, a restart in a gap, and the
188
+ anchor loop's own URL.
189
+
190
+ **Optional second signal, not built here.** A box that pings every few minutes
191
+ from outside Mercury gives the same service a "the machine is up" check that is
192
+ independent of any task — and it is the one that speaks first: a dead distro
193
+ is heard within its grace period instead of at 09:30. On the WSL box that is a
194
+ loop started by the owner's anchor script, against its own check
195
+ (`mercury-anchor`, a *simple* check — period 10 min, grace 10 min, pinged every
196
+ 5 min):
197
+
198
+ ```bash
199
+ # ~/mercury-anchor.sh — alongside the sleep that holds the distro open
200
+ while true; do curl -fsS -m 10 --retry 2 -o /dev/null "https://hc-ping.com/<ping-key>/mercury-anchor" || true; sleep 300; done &
201
+ ```
202
+
203
+ The loop dies with the distro, and the logon task that starts the anchor fires
204
+ at Windows logon only — after a distro restart with no logon (a WSL crash, a
205
+ `wsl --shutdown`) the anchor and the loop are both gone until
206
+ `Start-ScheduledTask 'Start Mercury WSL'` is run again; the check going late a
207
+ second time is how that shows.
208
+
106
209
  ## Operator alerts (`alerts:`)
107
210
 
108
211
  When a provider OAuth credential can no longer be refreshed, every space stops
@@ -138,6 +241,49 @@ Env equivalents: **`MERCURY_ALERTS_ENABLED`**, **`MERCURY_ALERTS_COOLDOWN_MS`**.
138
241
  - **Transient failures** (HTTP 408/429/5xx from the token endpoint) alert only
139
242
  after three in a row; a single blip heals itself. A rejected refresh token, or
140
243
  a cause that could not be classified, alerts on the first occurrence.
244
+ - **Scheduled-task failures go two ways.** A task a person created reports its
245
+ final failure into its own space, as before (`scheduling.notify_on_failure`).
246
+ A task an **extension** opened — feed-watch's verify one-shots; any task named
247
+ after a loaded extension, or whose `created_by` is not a platform user id
248
+ (`system`, the console, `space-profile`) — reports to the operators above
249
+ instead, and its space hears nothing, even when no operator is reachable
250
+ (then the only record is the ERROR log line). No cooldown: a final failure is
251
+ already once per due cycle.
252
+ - **Partial deliveries.** A scheduled post that reached some of a space's linked
253
+ conversations and not others is **not** retried — a retry would post a second
254
+ copy where the first landed — and the operators are told which conversations
255
+ missed it.
256
+
257
+ ## History per conversation, and the reporter lint (per-space)
258
+
259
+ Two behaviours that only matter to a space with **more than one linked
260
+ conversation** or with a **reporter standard** — every other space is
261
+ byte-identical.
262
+
263
+ **History per conversation.** Chat and ambient rows record the conversation
264
+ they were said in (`messages.conversation`, a thread id such as
265
+ `whatsapp:<group-jid>`). When a space has two or more linked conversations, a
266
+ run answering one of them reads only that conversation's rows plus the space's
267
+ own rows — scheduled posts, and rows written before the column existed — and
268
+ never the other conversation's chatter. A scheduled run in such a space reads
269
+ only the space's own rows. A space with one conversation reads exactly what it
270
+ always read. Nothing to configure.
271
+
272
+ **`reporter.standard`** (per space, unset by default) — the path, relative to
273
+ the space directory, of a reporter standard JSON file (the shape of
274
+ `examples/profiles/football-reporter/standard.json`; the football profile
275
+ seeds it there and sets the key). When set, every scheduled reply the space
276
+ sends is linted on the host **after** delivery and the findings are written
277
+ to the journal — one `reporter-lint: finding` line per finding with the rule
278
+ id and the task id, and one `reporter-lint: <n> findings` line per run. The
279
+ reply is never altered or held; an unreadable file is one warn line. A reply
280
+ that carries a `[longview:summary]` block is linted as the article, anything
281
+ else as a scan. The path must stay inside the space directory and end in
282
+ `.json`.
283
+
284
+ ```bash
285
+ mrctl config set reporter.standard standard.json
286
+ ```
141
287
 
142
288
  ## Ambient group context
143
289
 
@@ -218,6 +364,28 @@ Snapshot directories are built by `mercury shadow snapshot`, which generates
218
364
  the shadow's `.env` and `mercury.yaml` — nothing here needs to be set by
219
365
  hand. See [live-testing.md](live-testing.md) §2 and §7.
220
366
 
367
+ **A shadow answers to no live credential.** Every snapshot gets a fresh
368
+ **`MERCURY_SHADOW_API_SECRET`** (64 hex characters), and the generated `.env`
369
+ also overrides `MERCURY_API_SECRET` and `MERCURY_CHAT_API_KEY` with that same
370
+ value — so the live bot's secret opens nothing on the shadow: not `/api/*`,
371
+ not `/chat`, not the dashboard, and not the route below. The value lives
372
+ only in the snapshot's `.env`; `shadow snapshot`, `shadow up`, `shadow
373
+ status`, `/health` and the logs print key names, never the value. Rotation
374
+ is a new snapshot.
375
+
376
+ **`POST /api/shadow/simulate`** (goal `rehearsal-bench` M2.2b) exists only on
377
+ a shadow — on a live process the path is whatever `/api`'s guard says (401
378
+ without the live secret, 404 with it). It is authenticated by
379
+ `MERCURY_SHADOW_API_SECRET` **alone**: unset ⇒ 503 (a snapshot taken before
380
+ this key existed — take a fresh one), mismatch ⇒ 401. The body is a
381
+ `SyntheticWhatsAppEvent` by field name (`application/json`, or
382
+ `multipart/form-data` with an `event` part and `file` parts for attachments);
383
+ `isBotMentioned` / `isReplyToBot` are derived from the stanza and a body
384
+ carrying either is a 400. The response is `text/event-stream`: one `entry`
385
+ event per outbox line as it is journaled, then a `result` event with the
386
+ `SimulateResult`. `/health` reports `simulateRoute: true|false` and
387
+ `mercury shadow status` prints it.
388
+
221
389
  ## System prompt authorship (`agent.override_pi_system_prompt`)
222
390
 
223
391
  Chooses whether Mercury replaces pi's own system prompt or appends to it:
@@ -300,7 +468,49 @@ and under an explicit override, the vars reaching every container:
300
468
  Container env passthrough: explicit `all` override — these vars reach every space's container and are scoped to nothing. […] vars=MERCURY_SCRAPER_API_KEY, MERCURY_BILLING_API_KEY
301
469
  ```
302
470
 
303
- For secrets that only host-side hooks and jobs need, prefer `mercury.env({ from: "…", hostOnly: true })`, which keeps them out of containers in either mode. For credentials the agent should never hold at all, use a host-side capability handler (`mercury.capability()`), which runs the privileged call on the host and returns only the result.
471
+ For secrets that only host-side hooks and jobs need, prefer `mercury.env({ from: "…", hostOnly: true })`, which keeps them out of containers in either mode. For credentials the agent should never hold at all, use a host-side capability handler (`mercury.capability()`), which runs the privileged call on the host and returns only the result. `hostOnly` is a required field: under `claimed` a claim is the only way a `MERCURY_*` var reaches a container, so a claim has to say whether it means to — `hostOnly: false` is a decision that something inside the container reads the var, and the repo gate (`tests/env-claims-declare-hostonly.test.ts`) holds those to an allowlist naming the reader. The loader warns at startup about any installed extension whose claim does not decide.
472
+
473
+ ## Container shared memory (`agent.container_shm_size`)
474
+
475
+ How big `/dev/shm` is inside every agent container, in Docker's `--shm-size` spelling:
476
+
477
+ ```yaml
478
+ agent:
479
+ container_shm_size: 512m # 512m (default) | 1g | any Docker size
480
+ ```
481
+
482
+ Env: `MERCURY_CONTAINER_SHM_SIZE`. Applies to both runtimes — `/dev/shm` is Docker's, not gVisor's.
483
+
484
+ Docker's own default is **64 MB**, and that is the wrong size for what these containers actually do. Chromium is the workload: the `web-browser` extension drives it, the `poster` extension renders through it, and an agent asked to screenshot a page runs it directly. A large frame (a 1856x2304 poster, say) exhausts 64 MB, and Chromium's response is not an error — it **hangs**. The turn then sits idle until the host kills the container at `container_timeout_ms`, and the user gets no reply, only the time-limit marker. Three customer-facing runs were lost that way on 2026-09-10 before the cause was found.
485
+
486
+ The default is deliberately generous rather than minimal. A tmpfs is allocated as it is used, so a container that renders nothing pays nothing for the headroom, and under `container_runtime: runsc` the total is bounded by the container's `--memory` limit anyway. Lower it only on a host that is genuinely short of RAM, and expect large renders to hang again if you go back to 64 MB.
487
+
488
+ Two related things worth knowing:
489
+
490
+ - Passing `--disable-dev-shm-usage` to Chromium sidesteps `/dev/shm` entirely and works at any size. The `poster` extension already does this, which is why it kept working while direct Chromium calls hung.
491
+ - `mercury preflight`'s round-trip probe spawns with the same value, so the shape it vouches for is the shape a real turn gets.
492
+
493
+ ## Bash call timeout (`agent.container_bash_timeout_seconds`)
494
+
495
+ The longest a single `bash` tool call may run inside an agent container, in seconds:
496
+
497
+ ```yaml
498
+ agent:
499
+ container_bash_timeout_seconds: 300 # 300 (default)
500
+ ```
501
+
502
+ Env: `MERCURY_CONTAINER_BASH_TIMEOUT_SECONDS`. It is both a default and a ceiling: a call that names no `timeout` gets this one, and a call that asks for more is clamped down to it. The agent can only ask for *less*.
503
+
504
+ pi's `bash` tool takes an optional `timeout` and arms no timer at all when the model omits it — its own parameter description says "optional, no default timeout". A command that hangs rather than failing therefore blocks the turn with nothing watching it, until the host kills the whole container at `container_timeout_ms` and the user gets no reply, only the time-limit marker. That is the other half of the 2026-09-10 incident described under `container_shm_size`: the hanging Chromium was the cause, and the missing bound is what turned each hang into a lost 20-minute run instead of a recoverable error.
505
+
506
+ When the bound fires, pi kills the command's whole process tree and returns `Command timed out after N seconds` as a normal tool error, so the turn can retry differently, fall back, or explain — while it still has budget left to reply.
507
+
508
+ Two things to keep in mind when changing it:
509
+
510
+ - **Keep it comfortably below `container_timeout_ms`.** A bound at or above the container limit buys nothing, because the SIGKILL arrives first. The defaults (300 s against 5 minutes) leave that ratio to whoever tunes both.
511
+ - **Long jobs need a different shape, not a bigger number.** Raising this to cover a slow build makes every hang that much more expensive. Prefer backgrounding the work and polling a file for its result.
512
+
513
+ The value in force is stated to the model in its system prompt, so it can plan around the ceiling instead of meeting it as a dead tool call.
304
514
 
305
515
  ## Agent run traces (`agent.trace_runs`)
306
516
 
@@ -380,6 +590,7 @@ a detail on the page can be answered.
380
590
  | `longview.enabled` | `false` | Publish long replies from this space. |
381
591
  | `longview.threshold_chars` | `2000` | Publish when a reply exceeds this many characters. Minimum 500. |
382
592
  | `longview.target` | `telegraph` | Where pages are published. |
593
+ | `longview.sources` | `both` | Which replies are published: `both` (any reply over the threshold, whoever asked), `scheduled` (task runs only — a member's long answer stays in chat), or `chat` (the reverse). A turn whose origin the host did not report is never published under `scheduled` or `chat`. |
383
594
  | `longview.summary_model` | *(empty)* | Model that writes the chat summary. Empty uses the space's configured model. |
384
595
 
385
596
  `summary_model` must name a model on the space's **existing provider**. The
@@ -535,3 +746,40 @@ lands back in `default`.
535
746
  The **Cursor Agent CLI** integration has been removed. All model legs use **pi** with standard providers (`anthropic`, `openai`, `google`, `mistral`, `groq`, `openrouter`, etc.).
536
747
 
537
748
  If your chain still has `provider: cursor`, the agent run **fails fast** with an error that points here. Switch to the **native provider** for the model you want (for example `anthropic` for Claude, `openai` for GPT) and set the matching **`MERCURY_*_API_KEY`**.
749
+
750
+ ## Agent image (`agent.image`)
751
+
752
+ Which container image every agent run spawns:
753
+
754
+ ```yaml
755
+ agent:
756
+ image: mercury-agent:latest # a locally built image (container/build.sh or `mercury build`)
757
+ ```
758
+
759
+ Env: `MERCURY_AGENT_IMAGE`.
760
+
761
+ **The default is the registry image at this host's own version** —
762
+ `ghcr.io/avishai-tsabari/mercury-agent:<version>`, where `<version>` is the
763
+ installed package's `package.json` version (release-gate R0.6, since 0.22.0;
764
+ before that the default floated on `:latest`). Nothing needs to be set for it,
765
+ and a host upgrade changes the reference by itself, so the box asks for the
766
+ new tag on its next boot. The tag is what `release.yml` pushes on every
767
+ release; the package is private, so a box on the default needs a registry
768
+ token that can read it, and a box that has none should set an explicit image
769
+ instead. `mercury preflight`'s `docker.image` check names the pinned tag, the
770
+ host version and both ways out when the tag is absent — that is the check to
771
+ read, not the first failed spawn.
772
+
773
+ **An explicit value is kept exactly as written.** Nothing normalises it, and
774
+ the pin never rewrites it: a locally built `mercury-agent:latest`, another
775
+ registry, a digest-pinned reference. Two of those shapes are never pulled or
776
+ re-pulled — a name with no registry host (`mercury-agent:latest`,
777
+ `mercury-agent-ext:<hash>`) is a local build, and a digest cannot drift; see
778
+ [container-lifecycle.md](container-lifecycle.md) § Staying Current. An
779
+ explicit *floating* tag with a registry host is re-pulled when the host
780
+ version changes, as before.
781
+
782
+ `mercury env dump --json` reports the key with its source
783
+ (`config.keys[].key == "agentContainerImage"`, `source` one of `default`,
784
+ `file`, `env`), which is how a deploy tells a box on the pinned default from
785
+ one with an override before restarting it.
@@ -100,6 +100,12 @@ When a container exceeds the timeout:
100
100
  4. A marker assistant message ("[System: this run was killed at the container time limit before replying. …]") is recorded in chat history, so the next run's agent knows the previous run was cut off and can check the workspace for partial work
101
101
  5. Queue unblocks, next message can proceed
102
102
 
103
+ A container that hits the timeout was not necessarily busy. The commonest way to burn the whole budget is a child process that hangs rather than fails, and the commonest such child is Chromium on a too-small `/dev/shm`. Every spawn therefore sizes it explicitly — see `container_shm_size` in [configuration.md](configuration.md#container-shared-memory-agentcontainer_shm_size).
104
+
105
+ | Config | Env Var | Default | Applies to |
106
+ |--------|---------|---------|------------|
107
+ | `containerShmSize` | `MERCURY_CONTAINER_SHM_SIZE` | `512m` | both runtimes |
108
+
103
109
  The host always injects a resolved **model chain** into the container (after `MERCURY_*` passthrough) so retries and fallbacks use the same policy Mercury loaded at startup:
104
110
 
105
111
  | In-container env | Source (host) | Purpose |
@@ -176,8 +182,9 @@ Message received
176
182
  # Set container timeout to 10 minutes
177
183
  export MERCURY_CONTAINER_TIMEOUT_MS=600000
178
184
 
179
- # Use the preset image from GitHub Container Registry
180
- export MERCURY_AGENT_IMAGE=ghcr.io/avishai-tsabari/mercury-agent:latest # Full (default)
185
+ # Override the agent image. The default needs no setting: it is the registry
186
+ # image at this host's own version, ghcr.io/avishai-tsabari/mercury-agent:<version>
187
+ export MERCURY_AGENT_IMAGE=mercury-agent:latest # a local `mercury build` / container/build.sh
181
188
  ```
182
189
 
183
190
  ## Sandboxing (Bubblewrap)
@@ -330,12 +337,24 @@ Mercury publishes an image preset to GitHub Container Registry:
330
337
 
331
338
  | Preset | Size | Contents |
332
339
  |--------|------|----------|
333
- | `ghcr.io/avishai-tsabari/mercury-agent:latest` | ~2.8GB | Full devcontainer: Bun, Node.js, Python, Go, git, build tools |
340
+ | `ghcr.io/avishai-tsabari/mercury-agent:<version>` | ~2.8GB | Full devcontainer: Bun, Node.js, Python, Go, git, build tools |
334
341
 
335
342
  Images are published on each release by `release.yml`'s `publish-image` job,
336
343
  which builds from the published npm tarball and pushes `:<version>` alongside
337
344
  `:latest`. A prerelease publishes its own tag only and leaves `:latest` alone.
338
345
 
346
+ **The default is pinned to the host's version** (release-gate R0.6, since
347
+ 0.22.0): a host running mercury `X.Y.Z` with no `agent.image` override asks
348
+ for `ghcr.io/avishai-tsabari/mercury-agent:X.Y.Z` — never `:latest`. So a
349
+ host upgrade changes the reference, the new tag is absent locally, and boot
350
+ pulls it (or fails loudly if it cannot); `mercury preflight`'s `docker.image`
351
+ check names the pinned tag, the host version and the two ways out when the
352
+ tag is missing. An explicit override (`agent.image` in `mercury.yaml`,
353
+ `MERCURY_AGENT_IMAGE`) is kept byte-for-byte: a source-run box that builds
354
+ its own image sets `mercury-agent:latest` and is untouched by the pin. The
355
+ package is private (G-007), so a box on the default needs a registry token
356
+ that can read it; the boot line and preflight are where its absence shows.
357
+
339
358
  > Before 0.19.0 this sentence was aspirational: nothing in CI built the image,
340
359
  > the registry copy was hand-pushed and stale, and an install outside the
341
360
  > tagula fleet had no image to pull. See `docs/goals/release-gate/decisions.md`
@@ -348,8 +367,11 @@ A floating tag used to mean "whatever this box pulled the first time":
348
367
  upgraded through npm kept running the old container against new host code with
349
368
  `/health` at 200 and nothing in the log.
350
369
 
351
- Startup now re-pulls the image whenever the host's Mercury version is not the
352
- one that last pulled it, recording the pair in `<dataDir>/image-pull.json`.
370
+ Two things close that. The default is now pinned to the host's version (above),
371
+ so on a default box the question is simply whether `:<version>` is present.
372
+ And for any **floating** reference an operator sets explicitly, startup
373
+ re-pulls the image whenever the host's Mercury version is not the one that
374
+ last pulled it, recording the pair in `<dataDir>/image-pull.json`.
353
375
  Two references are never re-pulled: a **digest-pinned** one (`…@sha256:…`),
354
376
  which cannot drift, and a **locally built** one, which exists in no registry —
355
377
  so a pull would fail on every boot. "Locally built" means any name with no
@@ -365,9 +387,9 @@ image id, repo digest, version label and whether a refresh happened, so drift
365
387
  is visible without being asked for:
366
388
 
367
389
  ```
368
- Agent image resolved image=ghcr.io/avishai-tsabari/mercury-agent:latest
369
- imageId=sha256:… digest=…@sha256:… imageVersion=null hostVersion=0.18.2
370
- refresh=refreshed reason=version-changed previousImageId=sha256:…
390
+ Agent image resolved image=ghcr.io/avishai-tsabari/mercury-agent:0.22.0
391
+ imageId=sha256:… digest=…@sha256:… imageVersion=0.22.0 hostVersion=0.22.0
392
+ refresh=pulled reason=not-present
371
393
  ```
372
394
 
373
395
  `imageVersion` is the image's `com.mercury.version` label (release-gate R0.1
@@ -102,12 +102,32 @@ Not currently supported via `mercury service`. Options:
102
102
  ## Upgrading
103
103
 
104
104
  ```bash
105
- mercury upgrade # to the latest published version
106
- mercury upgrade 0.14.0 # to a specific version
105
+ mercury upgrade # to the latest published version
106
+ mercury upgrade 0.14.0 # to a specific version
107
+ mercury upgrade --dry-run # print the plan, do nothing
108
+ mercury upgrade --skip-image-pull # host only — for a box on a locally built image
107
109
  ```
108
110
 
109
- On macOS and Linux this stops the managed service, installs the new version
110
- globally, and restarts the service.
111
+ **Image first, host second** (release-gate R0.5). The command resolves what
112
+ you asked for to one exact version (`latest`, a dist-tag or a range go
113
+ through `npm view`), then **pulls the agent image that version's host will
114
+ ask for** — the default is pinned to the version, so that is
115
+ `ghcr.io/avishai-tsabari/mercury-agent:<version>` — and refuses when the
116
+ registry has no such tag or refuses the credential, **leaving the host
117
+ untouched**. Only then, on macOS and Linux, does it stop the managed service,
118
+ install the new version globally and restart the service. It installs the
119
+ exact version it pulled the image for, not the spec, so the two cannot drift
120
+ apart between the pull and the install. Every step prints a line; the last
121
+ one names `mercury preflight` as what to run next from the project
122
+ directory (the derived image rebuilds on the first run, as before).
123
+
124
+ An explicit `agent.image` is respected: a registry reference is pulled as
125
+ configured (if it pins another version, preflight's `image.contract` says so
126
+ after the restart); a **local build** (`mercury-agent:latest`) cannot be
127
+ fetched, so the upgrade refuses unless you pass `--skip-image-pull` — then
128
+ run `mercury build` before the first run on the new version. The rules are
129
+ `docs/release-gate.md` Gate 4; `upgrade` does not run preflight itself
130
+ because it runs outside any project directory.
111
131
 
112
132
  ### Windows: stop Mercury before upgrading
113
133
 
@@ -135,6 +155,27 @@ first:
135
155
  Mercury's own CLI does not load the WhatsApp stack at startup, so running
136
156
  `mercury upgrade` never locks the tree it is replacing.
137
157
 
158
+ ### Disk: prune the build cache after every upgrade
159
+
160
+ Each upgrade pulls a new base image (~2 GB of layers) and Mercury rebuilds the
161
+ derived `mercury-agent-ext` image on the next start. Removing the old base with
162
+ `docker rmi` / `docker image prune` does **not** free its layers: the derived
163
+ build leaves BuildKit cache records that keep the old base layers alive, so
164
+ overlay2 grows by roughly 2 GB per release and `docker system df` under-reports
165
+ it. A 38 GB box hit 93% after five releases (2026-09-08).
166
+
167
+ Once `/health` reports the new version and the journal shows `Built derived
168
+ agent image`, run:
169
+
170
+ ```bash
171
+ docker builder prune -af
172
+ ```
173
+
174
+ Safe with the bot running: it touches no image, container or data. The only
175
+ cost is that the next derived-image build starts cold, which it does after a
176
+ base change anyway. Never `docker image prune -a` on an idle box — with no
177
+ container running it removes the derived image and the base too.
178
+
138
179
  ## Auto-Restart Behavior
139
180
 
140
181
  Both systemd and launchd are configured to automatically restart Mercury if it crashes:
@@ -95,17 +95,23 @@ Can only be called once per extension.
95
95
 
96
96
  ### `mercury.env(def)`
97
97
 
98
- Declare an environment variable this extension needs. Only injected into containers when the caller has permission for this extension. Claimed vars are excluded from the blind `MERCURY_*` passthrough, preventing credential leakage to unprivileged callers.
98
+ Declare an environment variable this extension needs, and decide whether it enters the container. Claimed vars are excluded from the blind `MERCURY_*` passthrough; a claim with `hostOnly: false` is injected into containers only when the caller has permission for this extension.
99
99
 
100
100
  ```typescript
101
- mercury.env({ from: "MERCURY_GH_TOKEN" }); // injected as GH_TOKEN
102
- mercury.env({ from: "MERCURY_GH_TOKEN", as: "GITHUB_TOKEN" }); // custom container name
103
- mercury.env({ from: "MERCURY_STT_API_KEY", hostOnly: true }); // host-side only
101
+ mercury.env({ from: "MERCURY_STT_API_KEY", hostOnly: true }); // host-side only — never enters a container
102
+ mercury.env({ from: "MERCURY_GH_TOKEN", hostOnly: false }); // injected as GH_TOKEN
103
+ mercury.env({ from: "MERCURY_GH_TOKEN", as: "GITHUB_TOKEN", hostOnly: false }); // custom container name
104
104
  ```
105
105
 
106
106
  - `from` — env var name as set in `.env` (e.g. `MERCURY_GH_TOKEN`)
107
107
  - `as` — (optional) name inside the container. Defaults to `from` with `MERCURY_` prefix stripped
108
- - `hostOnly` — (optional) claim the var (kept out of the blind `MERCURY_*` passthrough) but **never inject it into any container**. Use for secrets consumed only by host-side hooks/jobs (e.g. an STT API key used in `before_container`).
108
+ - `hostOnly` — **required**, no default. `true`: claim the var (kept out of the blind `MERCURY_*` passthrough) but **never inject it into any container** — secrets consumed only by host-side hooks/jobs (an STT API key used in `before_container`, an OAuth refresh token the host exchanges, a credential a `mercury.capability()` handler uses). `false`: something inside the container reads it — name that reader, because a reviewer will ask.
109
+
110
+ **Why it is required.** The claim loop in `src/core/runtime.ts` skips a claim only when `hostOnly` is `true`; a claim that did not decide was injected. Under the default `claimed` passthrough (`docs/configuration.md`, *Container env passthrough*) a claim is the *only* way a `MERCURY_*` var reaches a container, so a bare claim was strictly wider than not claiming at all — the one call an author made in order to be careful about a secret was the call that exposed it. It shipped three times (`docs/debug/moderate/2026-09-01-tradestation-credentials-enter-the-container.md`, `docs/debug/moderate/2026-09-12-gws-env-claim-bypasses-the-credential-shape-guard.md`) before the field became required.
111
+
112
+ **The gate.** `tests/env-claims-declare-hostonly.test.ts` walks every `mercury.env()` call in `examples/**/index.ts` and `src/**`, loads every example extension through the real API, and asserts that the set of container-visible claims **equals** `ENV_CLAIM_ALLOWLIST` — a row per `hostOnly: false` claim naming the file inside the container that reads it. A new container-visible claim without a row, a bare claim, or a row whose reader no longer mentions the key is a red `bun run check`. The three rows today: `poster`'s two provider keys (the poster CLI runs inside the container) and `gws`'s legacy credentials-file path (a path, not a secret). Everything else is `hostOnly: true`.
113
+
114
+ **On a live box.** The type cannot reach an extension written outside this repo, so `ExtensionLoader` logs `Extension "<name>": env claim "<VAR>" does not state hostOnly; it is container-visible …` for each bare claim it loads. The extension still loads and behaves as before; the line is the reader.
109
115
 
110
116
  Can be called multiple times for multiple env vars.
111
117
 
@@ -279,6 +285,39 @@ mercury.widget({
279
285
 
280
286
  Widgets render HTML fragments in the dashboard overview. Errors show a placeholder — never crash the dashboard.
281
287
 
288
+ ### `mercury.capability(name, handler)`
289
+
290
+ Register a host-side handler the agent reaches from inside the container with
291
+ `mrctl capability <name> <action> '<json>'` → `POST /api/capability/:name/:action`.
292
+ Credentials the handler uses stay on the host and never enter the container.
293
+
294
+ ```typescript
295
+ // in extensions/rooms/index.ts
296
+ mercury.permission({ defaultRoles: ["admin"] });
297
+ mercury.capability("rooms", async (req, ctx) => {
298
+ if (req.action === "book") return { data: await bookRoom(req.callerId, req.body) };
299
+ return { status: 400, data: { error: "unknown action" } };
300
+ });
301
+ ```
302
+
303
+ - **`name` must equal the extension's own name** — registering under any other
304
+ name throws at load. The broker authorizes a call with `checkPerm(<name>)`,
305
+ so the equality is what makes that the *extension's* permission: the same
306
+ predicate the env-var injection rule and the sensitive-connection guard use.
307
+ A capability named after some other extension's permission would be reachable
308
+ by callers those two legs believe cannot reach it.
309
+ - **The extension must also call `mercury.permission()`.** Without a *registered*
310
+ permission of that name, `checkPerm` refuses every role — including `admin`,
311
+ whose grant is the enumerated set of built-in plus registered permissions, not
312
+ a wildcard — so the capability is unreachable, silently.
313
+ - An extension named after a **built-in** permission (`prompt`, `stop`, `clear`,
314
+ …) cannot register a capability at all: it could not own that permission
315
+ (`mercury.permission()` refuses a built-in name) while the broker would still
316
+ authorize the handler with it, and `prompt` is one every member holds.
317
+ - One capability per extension; branch on `req.action` for sub-verbs.
318
+ - The name is resolved host-wide, so two extensions cannot own it — a
319
+ collision is rejected at load time.
320
+
282
321
  ### `mercury.store`
283
322
 
284
323
  Scoped key-value store for persistent state.