mercury-agent 0.22.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 (118) hide show
  1. package/README.md +2 -2
  2. package/container/Dockerfile +1 -1
  3. package/container/Dockerfile.base +1 -1
  4. package/container/agent-package.json +1 -1
  5. package/container/build.sh +6 -3
  6. package/docs/configuration.md +207 -1
  7. package/docs/container-lifecycle.md +24 -8
  8. package/docs/deployment.md +24 -4
  9. package/docs/extensions.md +11 -5
  10. package/docs/goals/feed-watch-social-sources/decisions.md +382 -1
  11. package/docs/goals/feed-watch-social-sources/roadmap.md +15 -5
  12. package/docs/goals/feed-watch-social-sources/source-review-task-draft.md +80 -0
  13. package/docs/goals/football-match-day-mode/goal.md +2 -2
  14. package/docs/goals/football-match-day-mode/roadmap.md +2 -2
  15. package/docs/goals/football-reporter-profile/decisions.md +143 -0
  16. package/docs/goals/football-reporter-profile/roadmap.md +10 -7
  17. package/docs/goals/rehearsal-bench/decisions.md +76 -0
  18. package/docs/goals/rehearsal-bench/roadmap.md +2 -1
  19. package/docs/goals/release-gate/decisions.md +118 -0
  20. package/docs/goals/release-gate/goal.md +1 -1
  21. package/docs/goals/release-gate/roadmap.md +6 -6
  22. package/docs/goals/whatsapp-bot-hardening/decisions.md +53 -0
  23. package/docs/goals/whatsapp-bot-hardening/roadmap.md +15 -1
  24. package/docs/live-testing.md +57 -110
  25. package/docs/pending-verification.md +300 -14
  26. package/docs/pipeline.md +4 -1
  27. package/docs/profile-guide.md +6 -2
  28. package/docs/release-gate.md +276 -0
  29. package/docs/smoke-fixtures.md +245 -0
  30. package/examples/extensions/feed-watch/config.ts +103 -4
  31. package/examples/extensions/feed-watch/digest.ts +465 -19
  32. package/examples/extensions/feed-watch/feeds.ts +124 -10
  33. package/examples/extensions/feed-watch/index.ts +111 -1
  34. package/examples/extensions/feed-watch/items.ts +147 -10
  35. package/examples/extensions/feed-watch/match.ts +77 -15
  36. package/examples/extensions/feed-watch/posted.ts +275 -0
  37. package/examples/extensions/feed-watch/prompts/verify.md +9 -0
  38. package/examples/extensions/feed-watch/scorecard.ts +232 -0
  39. package/examples/extensions/feed-watch/skill/SKILL.md +80 -10
  40. package/examples/extensions/feed-watch/watch.ts +531 -38
  41. package/examples/extensions/feed-watch/yield.ts +221 -0
  42. package/examples/extensions/gws/index.ts +150 -14
  43. package/examples/extensions/longview/hook.ts +63 -4
  44. package/examples/extensions/longview/index.ts +9 -0
  45. package/examples/extensions/longview/render/telegraph-nodes.ts +5 -1
  46. package/examples/extensions/morning/index.ts +1 -1
  47. package/examples/extensions/napkin/index.ts +167 -64
  48. package/examples/extensions/napkin/pi-spawn.ts +157 -2
  49. package/examples/extensions/poster/index.ts +13 -3
  50. package/examples/extensions/tradestation/host/refresh.ts +20 -0
  51. package/examples/extensions/tradestation/index.ts +81 -1
  52. package/examples/extensions/voice-transcribe/index.ts +1 -1
  53. package/examples/extensions/yahoo-mail/index.ts +23 -2
  54. package/examples/extensions/yahoo-mail/lib/ymail.ts +147 -0
  55. package/examples/extensions/yahoo-mail/package.json +1 -1
  56. package/examples/profiles/_template/config.yaml +11 -1
  57. package/examples/profiles/football-reporter/AGENTS.md +86 -21
  58. package/examples/profiles/football-reporter/README.md +47 -0
  59. package/examples/profiles/football-reporter/config.yaml +246 -60
  60. package/examples/profiles/football-reporter/seed/MEMORY.md +1 -1
  61. package/examples/profiles/football-reporter/seed/episodes/man-united-2026-27.md +1 -1
  62. package/examples/profiles/football-reporter/seed/names.md +54 -0
  63. package/examples/profiles/football-reporter/standard.json +46 -6
  64. package/examples/profiles/football-reporter/tasks/daily-article.md +102 -30
  65. package/examples/profiles/football-reporter/tasks/memes.md +72 -0
  66. package/examples/profiles/football-reporter/tasks/roundup.md +8 -1
  67. package/examples/smoke/club-newsroom/smoke.yaml +46 -0
  68. package/examples/smoke/proposal-desk/brief.md +8 -0
  69. package/examples/smoke/proposal-desk/smoke.yaml +26 -0
  70. package/package.json +13 -9
  71. package/src/adapters/whatsapp-media.ts +46 -33
  72. package/src/agent/container-runner.ts +4 -2
  73. package/src/agent/image-refresh.ts +27 -10
  74. package/src/cli/mercury.ts +636 -267
  75. package/src/cli/upgrade.ts +392 -2
  76. package/src/config-file.ts +6 -0
  77. package/src/config.ts +73 -3
  78. package/src/core/commands.ts +17 -1
  79. package/src/core/connection-health.ts +278 -151
  80. package/src/core/heartbeat.ts +188 -0
  81. package/src/core/inbox-write.ts +148 -0
  82. package/src/core/operator-alerts.ts +210 -0
  83. package/src/core/reporter-lint-on-send.ts +293 -0
  84. package/src/core/routes/chat.ts +44 -6
  85. package/src/core/routes/config-builtin.ts +27 -0
  86. package/src/core/routes/dashboard.ts +22 -0
  87. package/src/core/routes/shadow.ts +470 -0
  88. package/src/core/runtime.ts +431 -16
  89. package/src/core/shadow-bridge.ts +64 -9
  90. package/src/core/shadow-outbox.ts +70 -8
  91. package/src/core/simulate-ingress.ts +226 -69
  92. package/src/core/system-messages.ts +10 -0
  93. package/src/core/task-scheduler.ts +78 -0
  94. package/src/core/task-sender.ts +164 -0
  95. package/src/env-dump/manifest.ts +6 -2
  96. package/src/extensions/loader.ts +13 -0
  97. package/src/extensions/types.ts +109 -7
  98. package/src/logger.ts +51 -2
  99. package/src/main.ts +34 -61
  100. package/src/ops/shadow-service.ts +386 -10
  101. package/src/ops/shadow-snapshot.ts +124 -3
  102. package/src/preflight/checks/docker.ts +23 -1
  103. package/src/preflight/checks/host-deps.ts +130 -0
  104. package/src/preflight/report.ts +3 -0
  105. package/src/preflight/run.ts +2 -0
  106. package/src/profile/readonly-store.ts +118 -0
  107. package/src/profile/space-profile.ts +170 -7
  108. package/src/server.ts +32 -0
  109. package/src/smoke/boot.ts +303 -0
  110. package/src/smoke/cli.ts +256 -0
  111. package/src/smoke/evaluate.ts +401 -0
  112. package/src/smoke/fixture.ts +387 -0
  113. package/src/smoke/refusal.ts +96 -0
  114. package/src/smoke/run.ts +355 -0
  115. package/src/storage/db.ts +200 -10
  116. package/src/storage/pi-auth.ts +42 -10
  117. package/src/text/reporter-lint.ts +501 -74
  118. 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
 
@@ -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
 
@@ -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,7 @@ 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.
304
472
 
305
473
  ## Container shared memory (`agent.container_shm_size`)
306
474
 
@@ -422,6 +590,7 @@ a detail on the page can be answered.
422
590
  | `longview.enabled` | `false` | Publish long replies from this space. |
423
591
  | `longview.threshold_chars` | `2000` | Publish when a reply exceeds this many characters. Minimum 500. |
424
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`. |
425
594
  | `longview.summary_model` | *(empty)* | Model that writes the chat summary. Empty uses the space's configured model. |
426
595
 
427
596
  `summary_model` must name a model on the space's **existing provider**. The
@@ -577,3 +746,40 @@ lands back in `default`.
577
746
  The **Cursor Agent CLI** integration has been removed. All model legs use **pi** with standard providers (`anthropic`, `openai`, `google`, `mistral`, `groq`, `openrouter`, etc.).
578
747
 
579
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.
@@ -182,8 +182,9 @@ Message received
182
182
  # Set container timeout to 10 minutes
183
183
  export MERCURY_CONTAINER_TIMEOUT_MS=600000
184
184
 
185
- # Use the preset image from GitHub Container Registry
186
- 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
187
188
  ```
188
189
 
189
190
  ## Sandboxing (Bubblewrap)
@@ -336,12 +337,24 @@ Mercury publishes an image preset to GitHub Container Registry:
336
337
 
337
338
  | Preset | Size | Contents |
338
339
  |--------|------|----------|
339
- | `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 |
340
341
 
341
342
  Images are published on each release by `release.yml`'s `publish-image` job,
342
343
  which builds from the published npm tarball and pushes `:<version>` alongside
343
344
  `:latest`. A prerelease publishes its own tag only and leaves `:latest` alone.
344
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
+
345
358
  > Before 0.19.0 this sentence was aspirational: nothing in CI built the image,
346
359
  > the registry copy was hand-pushed and stale, and an install outside the
347
360
  > tagula fleet had no image to pull. See `docs/goals/release-gate/decisions.md`
@@ -354,8 +367,11 @@ A floating tag used to mean "whatever this box pulled the first time":
354
367
  upgraded through npm kept running the old container against new host code with
355
368
  `/health` at 200 and nothing in the log.
356
369
 
357
- Startup now re-pulls the image whenever the host's Mercury version is not the
358
- 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`.
359
375
  Two references are never re-pulled: a **digest-pinned** one (`…@sha256:…`),
360
376
  which cannot drift, and a **locally built** one, which exists in no registry —
361
377
  so a pull would fail on every boot. "Locally built" means any name with no
@@ -371,9 +387,9 @@ image id, repo digest, version label and whether a refresh happened, so drift
371
387
  is visible without being asked for:
372
388
 
373
389
  ```
374
- Agent image resolved image=ghcr.io/avishai-tsabari/mercury-agent:latest
375
- imageId=sha256:… digest=…@sha256:… imageVersion=null hostVersion=0.18.2
376
- 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
377
393
  ```
378
394
 
379
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
 
@@ -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