# Changelog

All notable changes to `tiny-tech`. Versions follow semver; the package is published to npm as `tiny-tech`.

## 0.21.1 — 2026-10-03

### ⌨️ TUI: the terminal feels like a terminal

- **The cursor follows a paste.** The composer is our own (`src/tui/composer.tsx` + pure `line-edit.ts`), replacing ink-text-input. That field only moved its cursor on its own keypresses, so a paste, ↑ history recall or ⇥ completion left the cursor at column 0. Now a paste goes in at the cursor and the cursor lands after it; recall and completion leave it at the end.
- **Readline editing:** ⌥←/⌥→ (meta-arrow or ESC b/f) jump by word, with `/ . -` counting as word breaks so paths step one segment at a time. Also ^A/^E/Home/End/⌘←→, ^W/⌥⌫, ^U/⌘⌫, ^K and ⌥D. **Delete now deletes forward** (it used to delete backwards). Emoji move as one character. Questions the agent asks you and the onboarding fields use the same composer (they could only edit at the end before).
- **The first ^C no longer quits.** Ink's default `exitOnCtrlC` exited on the first press, so "^C stops the newest" never ran in the real binary (the test harness passed `false`, which hid it). Now: the first ^C cancels the newest turn, or clears the composer if nothing is running, and shows `^C again to quit`. A second ^C within 2 s quits.
- **Questions that time out come down.** `requestInteraction` passes the UI an `AbortSignal`. A question that timed out used to stay on screen, holding the keyboard, until you pressed Esc on something nobody was waiting for. It now leaves the screen, the transcript gets `→ (no answer — the question timed out)`, and the composer is usable again.
- **Live answers render correctly inside code blocks.** The live panel used to cut the raw markdown and then render it. When the cut started inside a ``` block, the code showed as plain text and the closing fence opened a new block that swallowed the text after it. Now it renders first and cuts the rendered rows. Streaming text also repaints at most every 33 ms (it repainted on every token before). Tool, error and done events still repaint immediately.
- Tray: `group` and `site_share` added to the worker event list (the drift check had been failing).

## 0.21.0 — 2026-09-22

### 🧠 OpenAI default → `gpt-6-sol`

`DEFAULT_IDS.openai` is `gpt-6-sol` (OpenAI's flagship, released 2026-09-22 — 1.05 M context, 128 k output, reasoning effort `none`…`max`; was `gpt-5-mini-2025-08-07`). Applies when `OPENAI_API_KEY` is set and no `TINY_MODEL_ID`/`openai:<id>` spec pins a model. Verified against `/v1/models` and both the Responses and Chat APIs. Pinned by `test/model-spec.test.mjs`.

### 🪜 Tools on demand — the model sees 17 tools, not 75; prompt caching on

Owner's words: *"the agent is getting too much tools upfront but we can load them on demand"* — then, after the research,
*"i think we need to ship this!"*. Measured before: the Strands SDK serialises `toolRegistry.list()` into **every** model
call, and the local mount on the author's Mac was **31,274 billed tokens** of tool specs per call (16 % of a 200k window),
with prompt caching off on every surface.

- **Prompt caching** (`src/agent/model.ts`): `cacheConfig: { strategy: 'auto' }` on both Bedrock constructions
  (`createLocalModel` + `createModelFromSpec`, so `/model` swaps and forks carry it). The SDK auto-detects Claude ids and
  emits a cache point after the tool block and the system prompt; other ids silently get none. `TINY_PROMPT_CACHE=0` kills
  it. Measured on the real 80-tool mount (haiku, 2 turns): turn 2 went from **53,388 input tokens → 3 + 53,373 cache-read**
  (≈ −90 % billed, wall 1.75 → 1.0 s). `TinyAgent.usage(before)` + `TINY_USAGE_LOG=1` print the cacheRead/cacheWrite split —
  tiny-tech had no usage line at all before.
- **The catalog** (`src/agent/catalog.ts`, generated `catalog.json`): CORE 18 names (bash, fileEditor, use_memory,
  tiny_recall/learn/whoami, use_device, manage_tools/messages, use_notify/render/interact, marquee_say, use_loop,
  spawn_agents, loop_done, find_tools, load_tools) and 25 groups `{summary, tools[], keywords[]}` — every builtin in exactly
  one; `my_*` = `forged`, `~/.tiny/tools` = `local` (or `export const group = '…'` in the file). `groupOf()` throws on an
  unplaced name and the test suite runs a REAL `TinyAgent.init()` in both modes: a new builtin without a home fails CI.
- **`find_tools` / `load_tools`** (`src/agent/tiers.ts`): deterministic ranking (keyword phrase/word/stem, group name,
  tool names, summary), token cost per group, a paste-ready load call; `load_tools` writes every registry the call can see
  (the executing fork's AND the session's) and folds into `allTools`, so the tool is in the **next model call of the same
  turn** (the row-2 spike is now a test with a scripted SDK `Model`) and every later fork/loop/sub-agent inherits it.
- **The mount** (`src/agent/agent.ts`): everything is still BUILT at init; `TINY_TOOL_TIERS≠0` (default) mounts CORE +
  find/load (17 on the author's Mac) and parks 58 tools in 22 groups. Catalog line in the system prompt (no session state —
  cache-stable); `[Tool groups mounted this session: …]` in the per-turn context. **Auto-mount** before every turn: the
  groups whose keywords the message plainly names (≥ 1 whole keyword, ≤ 3 groups) — "play some jazz" → spotify, "am I free
  thursday" → calendar. Loops and `spawn_agents` start with the parent's loaded set. `TINY_TOOL_TIERS=0` → the old flat
  list, same set, same order (tested).
- **The gate**: `test/fixtures/find-tools.json` — 30 stranger prompts → group, top-1 **30/30** (pin ≥ 90 %). Live, 10 real
  haiku turns: 9/10 loaded the right group (8 auto-mounted, 1 via find→load; the miss had no mesh node in the harness).
  **Billed**: tool block **31,274 → 6,730 (−78 %)**; per call incl. system prompt 52,088 → 27,951 (−46 % — the ~21k prompt
  of memory/history context is the next lever). Catalog line 404 tokens; groups 657 (telegram) … 4,494 (sites).
- `doctor` prints an `agent` line: tiers on/off, core count, groups, prompt cache on/off, and both kill switches.
- Gotcha found on the way: `"what"` stem-matched `whatsapp` and `"week"` → `next week`, each keyword adding +2 — stems now
  need ≥ 5 chars on both sides and count once per group, so a stem alone can never auto-mount.

## 0.20.2 — 2026-09-22 (unreleased)

### 🔐 The endpoint secret recipe lives in tiny-tech — `enroll` on the robot mints and pairs, zero code in the body repo

Owner's words: *"i think this enroll script needs to be in the tiny-tech's source"* — about neon-the-g1#1, a 91-line
`scripts/tiny_enroll.py` + `make tiny-enroll` that minted the dashboard's service JWT and handed it to `enroll --endpoint`. Every
robot has the same shape with different names, so the recipe is now **data** in the body registry, and `npx tiny-tech enroll
--body neon-the-g1` in the checkout does the whole thing.

- **`src/bodies/secrets.ts`**: `endpoint: { url: {env, file, default}, secret: [resolver…], name? }` — an **ordered** list of
  resolvers, first that yields wins: `env` · `file` (`~`, relative-to-repo) · `dotenv` (KEY in a `.env`) · `python` (sys.path +
  imports + an expression, run on the host's python3, value read from the child's **stdout**) · `exec` (argv, last stdout line).
  The implicit first rung is always `TINY_ENDPOINT_SECRET` (0.20.1's door); `--secret` beats everything. Every rung that loses
  carries a **reason** (`REACHY_TOKEN unset`, `ModuleNotFoundError: No module named 'jwt'`, `No such container: neon-dashboard`).
- **Secret hygiene**: secrets are never on a child's argv, never in our log — `--dry-run` shows `<jwt · 3 parts · 175 chars>`;
  `validateEndpointSpec` refuses an `exec` argv that *looks like* it carries one. **`--print-secret`** prints ONLY the secret on
  stdout (nothing sent, not even the origin probe) — the one explicit exception.
- **Registry recipes** read from the body repos: **Neon** `docs/dashboard/auth.service_token('tiny')` on the host (cwd = repo root:
  the store `.memory/.neon_auth.json` is relative) → `docker exec -i neon-dashboard …`; **Scout** `auth.issue_token('service:tiny',
  'tiny', ttl=10y)` → `scout-slim-dashboard` → `scout-dashboard` (what ENROLL-FLEET minted on Thor by hand); **FOMO** `FOMO_TOKEN` →
  `~/.fomo/token`, row pinned to the live name `fomo`; **Reachy** `REACHY_TOKEN` → `.env`. Defaults for the url are the owner's
  tunnels (`NEON_PUBLIC_URL`… override). spidey/lamp/microduck/leaf/bambu have **no recipe** (repos not in the monorepo) — `enroll`
  says so and takes `--secret`/`TINY_ENDPOINT_SECRET`.
- **`tiny.body.json` manifest** (cwd or `--manifest <path>`) with the same `endpoint{}` shape **beats the registry** and names the body
  (`detection.by = 'manifest'`); resolvers run relative to the manifest's directory. The `endpoint-python` template writes it
  (`<SLUG>_PUBLIC_URL`, `<SLUG>_TOKEN` → `~/.<slug>/token` — exactly what its `server.py` checks) and its `enroll.sh` is one line:
  `exec npx -y tiny-tech@latest enroll --body <slug> "$@"`.
- **`doctor`** on an endpoint body (or any cwd with a manifest) prints the ladder: url source, every secret rung ✓/✗ with its reason,
  `← wins`, and the cwd it ran in. A malformed manifest is named.
- `enroll --json` adds `url`, `urlBy`, `secretBy`.
- Tests: `secrets` 19 (scripted runner for every kind + one **real python3** round-trip minting through a module on sysPath), `enroll`
  +ladder refusal, `scaffold` manifest shape. **1602/1602.** Proof against the real `firmwares/neon-the-g1` checkout from the packed
  tarball: host rung fails honestly without `.memory/` (the exact FileNotFoundError), docker rung "No such container"; with `.memory/`
  present the REAL `auth.py` mints `{sub: service:tiny, name: tiny, exp: +10 y}`, `--print-secret` output verifies with the checkout's
  own `verify_token`; scout's checkout mints too; FOMO resolves the live bearer from `~/.fomo/token` (origin 200). neon-the-g1#1 closed.

## 0.20.0 — 2026-09-22 (unreleased)

### 🔌 One command per body — `enroll` · `flash` · `init --body` · `doctor`

Owner's words: *"when i plug the nicla vision it should be 1 command; if i ssh into arduino and run `npx tiny-tech enroll` it should be
enrolled."* Until now every headless board was enrolled by copying the owner's 90-day `credentials.json` onto it and running a
per-repo script (install-tiny.sh, enroll_vision.py, provision_new.sh).

- **Body registry** (`src/bodies/`): `fleet.generated.ts` seeded from tiny.technology's `lib/landing/fleet.ts` by `scripts/gen-bodies.mjs`
  (18 bodies + a generic `linux` host); `registry.ts` profiles (joins, platform word, detect rules, runtime, flash recipe, service,
  capabilities, template); `detect.ts` — `--body` › `/proc/device-tree/model` (the UNO Q says `Arduino SA,Imola`) › USB VID:PID
  (ioreg on macOS with ports tied by serial, sysfs on Linux; the CH343 bridge is never auto-picked) › python package › hostname › os.
  `kind` is never the slug (the worker clamps it); identity = `platform` + `body:<slug>` first in capabilities, `via:<hostId8>` for children.
- **`enroll`**: alive `device.json` kept · user JWT with a live-same-name guard / `--adopt` (POST `/api/devices/adopt`) · **headless
  pairing** — 32-byte secret minted on the device, `K7Q X2M` code + link + terminal QR (`qrcode`), poll `/api/devices/pair/*`, token
  collected once → `device.json` + `body.json` 0600 · service guard (no second mesh node beside `pi-tiny` / `q-tiny` / launchd) ·
  `loginctl enable-linger` best-effort · first heartbeat inline. `enrollDevice()` takes `platform` + `capabilities`.
  Proven on the UNO Q and the Raspberry Pi 5 with lane-only homes; rows removed afterwards.
- **`flash`**: port by VID:PID; Nicla over mpremote — inventory, copy, sha256 verified **on the board**, identity from
  `tiny_provision.ap_ssid`, enrol with `via:<host>`, `tiny_config.merge+save` of the identity in the same run, reset; **foreign-firmware
  guard** (a Nicla carrying `fomo_node.py` — FOMO's head — is refused without `--force` and reset); Sticky via `tools/provision_new.sh`
  when ESP-IDF is present; Crazyflie recipe.
- **`init --body <slug> [<dir>]`**: four templates shipped in the tarball (`templates/bodies/`): `linux-daemon`, `endpoint-python`
  (FastAPI exposing exactly the worker's endpoint actions behind `<SLUG>_TOKEN`), `usb-micropython`, `esp32`. Plain `init [url]` unchanged.
- **`doctor`**: machine · body + evidence · USB boards → bodies · tools · flash readiness · credentials/device/body state · service units.
- Tests: `bodies` 8 · `enroll` 8 · `flash` 8 · `scaffold` 7 · `doctor` 2 (node --test, scripted fetch/board/ioreg fixture). Server side
  (tinyai-id): worker `device-pair.ts` + migration 0054 (8 state-machine tests over sqlite), `/api/devices/pair/*` proxies + `PairCard`
  on `/devices` + `/devices/pair?code=` (10 tests), `tests/fleet-bodies.test.ts`.

## 0.19.0 — 2026-09-22

### 🔥 The embodied agent can edit the user's own tinys — `tiny_get` · `tiny_create` · `tiny_update` · `tiny_delete`

Owner's words: *"this is our core product, you ARE tiny."* Until now these four lived only in the MCP server (`server.ts`); the local
agent — the one in the TUI, the daemon, the mesh — could edit the user's calendar, sites and health but not the identity it embodies.
They now live in `agent/platform/tinys.ts` and are mounted on **both** surfaces by the shared roster (`platform/index.ts`, 34 tools);
`server.ts` drops its private copies (the bridge mounts them with annotations — `tiny_delete` is destructive).

- **Auth rail, once and for all:** the worker's `/upsert` trusts exactly one identity claim — `X-Internal-Key` + `body.userId`, held only by the
  Next.js app. Device/session tokens go through the app's session proxies: `POST /api/tiny` (record, `isOwner`) → `POST /api/control` (upsert)
  → `DELETE /api/delete`. A direct worker call with a Bearer is refused by design.
- **`buildUpdatePayload()` is pure and pinned:** core owner fields re-sent from the current record (the proxy stringifies JSON-ish fields);
  branding pass-through — omit = keep, `''` / `[]` = clear; `voice` added. Refusals stay data (`not_found` / `forbidden`) and write nothing.
- `tiny_update` description names the live flame URL (`/api/flame/<slug>.svg?detail=hero`) — a tiny with no logo gets its own flame.
- Tests: `platform-tools` +5 (roster, payload, refusals, get, create/delete paths); `server`/`mcp` counts → 34. 1544/0. Proven on prod with
  the owner's device token: `tiny_get isOwner:true` → `tiny_update logo` → `Success!`, tagline/chips/prompt preserved.

### Also since 0.18.0
- **model-limits** (`2769493`, `2a1d799`, `daf2548`): probe the provider for the model's real output cap, cache `~/.tiny/model-limits.json`,
  set `maxTokens` to the maximum at init; the MaxTokens heal GROWS the cap (→ 120000 ceiling) instead of halving; `KNOWN_ANTHROPIC_BETAS`.
- **history-budget** (`91324ac`): 850k by default when a `context-1m` beta is on the request; `TINY_CONTEXT_BUDGET` still wins.
- **tray roster** (`03b20ba`, `ea7ed5d`): `whatsapp_message` / `whatsapp_linked` / `whatsapp_unlinked` → message; `device_cred` named.
- **tinyble/linux** (`7cc4676`, `b4f8969`, `693461c`): Tiny Catalog GATT + D-Bus advertising on BlueZ (Thor proven), SPEC §2.4 mine tail, UA on
  the secret fetch, systemd user unit. **use_ble** (`1f2dbe0`): CoreBluetooth SHORT+FULL concatenation handled — one Sticky was three rows.
- **daemon** (`c1b5794`): opt-in Bluetooth presence via `TINY_BLE_ADVERTISE=1` (+`TINY_BLE_SOCIAL=1`), anonymous by default.

## 0.18.0 — 2026-09-21

### 📡 `use_ble` — this Mac sees and is seen by nearby tiny devices (TinyBeacon v2)

Spec: tinyai-id `docs/ble-mesh/SPEC.md` (same bytes pinned in TS, Swift and Python by `docs/ble-mesh/fixtures.json`). Node has no Bluetooth API and tiny-tech ships no native modules, so `native/tinyble/` (3 Swift files, ~280 KB binary) is compiled **on first use** via `xcrun swiftc` into `~/.tiny/bin/tinyble` — Xcode Command Line Tools are enough (CLT-only build proven: 29 s, `tinyble version` → `{"tinyble":"0.1.0"}`).

- **scan** (`seconds`, `all`) — tiny beacons ranked by distance band (here <1 m · near <3 m · room <8 m · far), grouped by rotating session id, EMA'd RSSI, v1 Nicla rows kept, "N other advertisers ignored". Your own devices read as **“your iPhone / your Sticky”**.
- **advertise** (`name`, `social`, `tools`) — puts THIS Mac on air with the Catalog GATT (identity / paged tool roster / hello / `tinyapp://device/<id>` endpoint). **Never automatic**: the agent must be asked; anonymous unless `social:true` (then the display name + @handle ride, and the tool says so). Session rotates every 15 min. macOS puts only the Catalog UUID on air (CoreBluetooth drops local name/mfg) — other tinys see a "tiny peer, likely a Mac" until they read the catalog.
- **catalog** (`id`) — connect (no pairing) and read a peer's identity, tools, endpoint. **stop** · **status** (helper, advertiser pid, secret source).
- **Account `mine` secret** (ROLLOUT §2.5): `syncAccountSecret()` reads `/api/devices/ble-secret` with the device token from `~/.tiny/device.json` (or the CLI session) into `~/.tiny/ble-mine-secret`, so every device of one account computes the same `mine = base64url(HMAC-SHA256(secret, session))[0..3]`. Fail-open (per-install secret until the first successful read), refreshed every 6 h.
- **Linux beacon** `native/tinyble/linux/tiny_beacon.py` (zero deps; `bluetoothctl` unprivileged or `btmgmt` as root): FULL mfg carrier, `--kind reachy|q-brain|linux…`, `--tools N`, name only with `--social`, `--fetch-secret` (same account secret via `device.json`), advertising interval 250–400 ms (BlueZ's default gave one sighting per ~10 s). Advertise-only — no Catalog on Linux yet.
- Tests: `test/ble.test.mjs` (8: bands, session grouping, argv privacy, secret wire shape, "your <kind>" titles, fail-open sync); `native/tinyble/linux/test_tiny_beacon.py` (fixture bytes, interval line, decode). `native/tinyble/sync-check.sh` diffs the Swift codec against the iOS one.

## 0.17.0 — 2026-09-21

### 📏 A long, long history survives: token budget instead of a 120-message window

The SDK's `SlidingWindowConversationManager` bounded the session by *message count* and trimmed by walking forward from `length − 120` to the next real user query. A tool-heavy turn (a loop iteration is easily 150 `toolUse`/`toolResult` messages) has no such query inside the window, so every absorb logged `window_size=<120>, messages=<456> | unable to trim conversation context, no valid trim point found`, trimmed nothing, and the history grew until the model refused it.

- **`src/agent/history-budget.ts` — `TinyHistoryManager`**, the session's conversation manager. Message count is **unlimited by default** (`TINY_SESSION_WINDOW=<n>` re-adds a cap); the floor is **`TINY_CONTEXT_BUDGET`** tokens (150 000). The estimate is the last assistant message's *real* provider usage plus a shape guess for newer messages (3.2 chars/token, 1 600 per image).
- **Turn-aware ladder**, built from the `manage_messages` primitives, only while over budget: **compact** turns older than the last `TINY_KEEP_TURNS` (3) — tool blocks out, text stays → **drop** the oldest whole turns (preamble first, the active turn never) → **truncate** the largest tool results head/tail, oldest first (the only step that reaches into the active turn — the one-giant-turn case). Pairing is validated and repaired after surgery; never through a tool pair.
- **On a real overflow** the target is 70 % of the current size and at least one step is forced; the manager then lowers its budget to 85 % of what the model refused, so the next compaction is proactive rather than a heal.
- **The agent stays in charge**: from 60 % of budget every turn carries a `[History pressure] N messages / T turns ≈ Xk tokens = P% …` line so it can `manage_messages compact`/`drop` on its own terms first; `manage_messages stats` shows the token estimate. `TinyAgent.lastReduction` says what the floor did.
- Tests: `test/history-budget.test.mjs` (12), `test/fork.test.mjs` re-pinned to the token bound + the giant-turn case.

### 🎙 Voice default → `gpt-realtime-2.1`

`DEFAULT_REALTIME_MODEL` is `gpt-realtime-2.1` (text + audio + image input, tool calling, 128k context) — eyes on the live call. `TINY_VOICE_MODEL=gpt-realtime-2.1-mini` stays the faster/cheaper override. Pinned by `test/realtime.test.mjs`.

### 🍎 `use_appstore`

App Store Connect from any tiny agent (TestFlight builds, groups, testers, versions, bundle ids, devices, certificates, profiles, reviews, plus a `raw` JSON:API escape hatch). Mints its own ES256 JWT with `node:crypto` from an `AuthKey_<KEYID>.p8` found in `~/app-store-connect-cred`, `~/.appstoreconnect/private_keys`, `~/private_keys` or `ASC_KEY_PATH`; the key id comes off the filename, the issuer id from `ASC_ISSUER_ID`, an `issuer_id` file beside the key, or `~/.tiny/appstore.json`. Every write (`add_tester`, `distribute`, `whats_new`, `add_device`, non-GET `raw`) needs `confirm=true`. Apple's `errors[]` envelope is surfaced verbatim, with a hint for 401 (issuer/key), 403 (role vs agreement) and 429. `TINY_APPSTORE=0` unmounts. 16 tests, no network. It also carries `submit` (reviewSubmissions → item → `submitted:true`, confirm-gated, refuses a version with no build) and `review_status`; sort keys Apple rejects (`expirationDate`, `addedDate`) are applied client-side.

## 0.16.0 — 2026-09-19

### ⏱ Loops no longer die on the 2-minute Bedrock stream idle

`@strands-agents/sdk`'s `BedrockModel` defaults the AWS request handler to **120 s of stream inactivity**. A long-thinking turn, or one big tool-call argument (a 40 KB `.scad` written in one go), goes quiet on the HTTP/2 stream longer than that and the iteration dies with `Stream timed out because of no activity for 120000 ms` — five CAD/design lanes were lost to it. Both Bedrock constructions in `src/agent/model.ts` now pass `clientConfig.requestHandler.requestTimeout` = **15 min** (`TINY_BEDROCK_REQUEST_TIMEOUT_MS` overrides, floor 60 s); the loop's own 1 h iteration timeout still bounds a truly wedged call.

### ⟳ A finished background loop wakes the agent that started it

Before: a loop ended with a banner (`use_notify`) and a platform push, and its result waited in `~/.tiny/loops` until the *next user-typed turn* drained it — "i get a notification but there's no agent triggered". `spawn_agents wait:false` had the same gap.

- **`LoopRunner.onFinished(rec)`** — fires exactly once per loop for every terminal status (`done` / `error` / `stopped` / `exhausted`, including an agent factory that fails to start), after the final record is on disk and after `notify` / `announce`. Its failure never fails the loop.
- **Wake rail** (`src/agent/wake.ts`): `WakeRequest { kind:'loop'|'spawn', id, status, prompt, result, iterations, prompt_text }`, `buildWakePrompt()` ("[AUTONOMOUS WAKE — no human typed this] …", result clipped to 2000 chars, "do not start work the owner didn't ask for"), `WakeRail` — subscribe with `onWake(cb)`, one wake in flight, FIFO queue, once per id, `claim()` hook, held until a subscriber exists.
- **`TinyAgent.wake` / `onWake(cb)` / `attachHeadlessWake()`** — the session's rail (shared with forks like `loops`). Headless wake = `invoke(prompt_text)` → `appendHistory` + `notify()` + `announceTaskResult('<id>-wake', …)`.
- **TUI** — a wake opens a tagged conversation `⟳ woke by <id>` with a compact echo line (the agent gets the full prompt); cancellable with `^C`; the rail is released when the turn lands or is cancelled; never fires while exiting.
- **REPL** — runs the wake turn itself with a two-way busy gate against typed turns; prints the tag; pushes the brief through notify + announceTaskResult.
- **Daemon (`serve`)** — relay per-envelope agents, mesh-answering agents and the tray agent run headless wakes.
- **`spawn_agents wait:false`** — `SpawnToolOptions.onFinished` fires the same shape; the batch wakes its parent.
- **Once per loop** — `claimLoopNews(id)` flips the record's `seen` flag when the wake is dispatched, so `takeNews()` on the next typed turn does not repeat it; a completion the user already drained is not woken for; `interrupted` records found at startup stay news.
- **Sibling news inside loops** — every iteration prompt appends `[Sibling loops that ended since your last iteration …]` (5 lines × 200 chars) computed from the loops dir; the watermark is the time the *previous prompt was built*, so a lane that dies while this loop is mid-step is reported.
- **Prompts** — `LOOP_DESCRIPTION`, the `use_loop start` reply and the system-prompt hunk now say the agent is *woken* with the result ("its result reaches you in a later turn on its own" is gone).
- **`TINY_LOOP_WAKE=off`** disables the rail. README: loop row, "A finished loop wakes its parent" section, env rows for the loop caps.
- Tests: `test/wake.test.mjs` (24) + 2 TUI tests (a wake runs a tagged turn with no keypress; wakes serialise and `^C` releases the next).

### Tray / menu bar

- Event-kind roster caught up with the worker: `booking` (calendar), `health_alert` / `health_stale` / `health_sync` / `health_media`, `ring_button` / `ring_command`, `site_submit` / `site_automation`. New tray types `booking` 📅, `health` 🩺, `site` 🌐 in both Swift switches; `ring_*` joins the `device` family. The roster test's ternary scan is scoped to the kind argument (it read `ok ? "done" : "failed"` inside a summary as two kinds) — `prepublishOnly` runs this suite, so the drift would have blocked `npm publish`.
- `tiny-tech --version` / `-v` / `version` prints the package version; `--help` shows it in the banner.

### Also in this release (0.15.0 was never published)

- Platform tool parity on the MCP server **and** the local agent: `calendar_*` (10), `site_*` + `site_state_*` (12), `health_*` (7 incl. `health_memos`), `read_inbox` — same names, schemas and result shapes as the web agent; `{ok:false,error,reason}` refusals stay data. README tool table.

## 0.13.10 and earlier

See the git history.
