# Talking Stick 0.2.0

Date: 2026-04-30

Minor release that adds **out-of-band messaging** between agents in a room. Two agents — typically the holder and a non-holder, or two non-holders — can now exchange short conversational messages without passing the stick. The protocol substrate is one new column on `room_events`; the surface is two MCP tools (`send_message`, `wait_for_events`) and three CLI commands (`tt msg send`, `tt msg recv`, `tt events --wait|--follow`).

The feature targets **Vignette H** from the design doc: holder + non-holder alternating short messages on a sub-question, paying ~80 tokens of body per round-trip instead of the ~600 tokens of structured-handoff scaffolding when the discussion would have otherwise required `pass_stick`/`release_stick` ping-pong.

## Added

### Out-of-band messaging

Three CLI commands. All wrap the same MCP/service primitives.

```bash
tt msg send <recipient|room> "<body>" [--interrupt] [--stdin] [--path DIR]
tt msg recv [--wait|--follow] [--from agent] [--after N] [--target self|any|agent] [--path DIR]
tt events --wait|--follow [--event TYPE[,TYPE]] [--target self|any|agent]
```

- `<recipient>` is a full `agent_id`, an unambiguous active display name (`codex`, `claude`), or the literal `room` for broadcast.
- `--interrupt` marks the message time-sensitive. The receiving harness or operator decides whether to act on it now; the protocol delivers, the consumer routes.
- `tt msg recv --follow` is a long-running tail (one JSON line per event) suited to harnesses that can monitor child stdout (Claude Code Monitor, terminals).
- `tt msg recv --wait` exits on the next matching batch — ideal for harnesses that can launch a background command and notice when it completes; restart with `--after <last_event_seq>` to resume.

The matching MCP tools are `send_message` (write) and `wait_for_events` (observer-safe long-poll). `get_room_events` now returns parsed `payload` for `message_sent` rows alongside the existing `handoff` field for legacy event types.

### Observer-safe event long-poll

`wait_for_events` is non-mutating by contract. It does not call `touchMember`, `touchKnownMember`, `touchWaitingMember`, or `purgeExpiredIdleRooms`. The only read it performs at entry is `requireRoom` for fail-fast on a missing room. Non-holders can long-poll the event log freely without disturbing the `last_wait_at` / `last_seen_at` bookkeeping that drives turn fairness.

### `getLatestEventSeq` cursor helper

`tt msg recv --wait|--follow` defaults to "start at now" — the highest `event_seq` in the room at startup time — so first-launch receivers don't replay history. Implemented as a single `SELECT MAX(event_seq) FROM room_events WHERE room_id = ?`, exposed on the service and commands layer. Operators wire `--after $LAST_SEQ` from their own bookkeeping when resuming after a crash; cursor persistence to disk is the harness's or plugin's responsibility per the receive-consumer contract.

### Splice-at-1 parser repair for boolean flags after positionals

The CLI parser consumes the next non-`--` token as a flag's value. That meant `tt msg send codex --interrupt body` would parse `interrupt="body"` and leave `codex` as the only positional. The handler now repairs this case by splicing the consumed value at positional index 1 (after the recipient), so `tt msg send codex --interrupt "body"` produces `recipient=codex`, `body="body"`, `delivery_hint=interrupt`. The existing `normalizeBooleanFlag` helper unshifts to the front (correct for `tt notes add --stdin` etc.); this new repair handles the `<positional> <body>` shape without weakening the generic parser.

### Receive-consumer contract

[`docs/receive-consumer-contract.md`](../receive-consumer-contract.md) documents the lifecycle expected of any receive consumer (CLI subprocess, future plugin, harness adapter): cursor persistence, replay coalescing on far-behind cursors, backpressure (drop-with-warning, never block the read loop), at-least-once delivery + dedupe on `event_id`, SIGTERM clean exit with the last cursor flushed to stderr.

## Skill

The bundled skill at [`skills/talking-stick/SKILL.md`](../../skills/talking-stick/SKILL.md) gains a new §4.5 *Out-of-band messaging* section:

- send via `tt msg send <recipient> "<body>"` or MCP `send_message`
- receive via `tt msg recv --wait` or `--follow` depending on what the harness can observe
- when to message (conversational, ephemeral, between live processes) vs note (durable, resolvable artifacts) vs handoff (transfer of work)
- messages are routing not ACL — `to_agent_id` is delivery, not privacy
- messages do not grant the stick — paging the holder gets attention, not write authority
- a `tt msg recv` subprocess does not replace `wait_for_turn` — keep waiting for your turn in parallel

The skill also picks up a small note in §1 reminding harnesses that sibling `tt msg recv --wait` / `--follow` subprocesses may be running and should be left alone unless the operator says otherwise.

## Migration

`room_events` gains a nullable `payload_json TEXT` column (migration #5). `ALTER TABLE ADD COLUMN` is O(1) on populated tables; existing rows back-fill to NULL; legacy event types continue to write NULL via the optional `payload?` parameter on `appendEvent`. No action required by operators on upgrade — the column is invisible to v0.1.x clients.

## Design properties pinned by tests

- **Self-broadcast exclusion** for `target=self`: caller's own broadcasts (`to_agent_id IS NULL AND from_agent_id = caller`) are excluded from their default receive view; the audit path (`target=any`) still includes them. The SQL clause is `(event_type='message_sent' AND (to_agent_id = ? OR (to_agent_id IS NULL AND from_agent_id != ?)))` — pinned by tests 13a/13b/13c in `tests/oob-substrate.test.ts`.
- **Closed-room behavior** (deferred): `wait_for_events` on a `state='closed'` room returns empty after deadline; no short-circuit, no error. Pinned by test 19a so a future `close_room` PR has to opt in to changing it.
- **Body cap.** 4096 bytes UTF-8; rejected with typed `message_too_large`. No silent truncation.
- **Sender filter** (`from_agent_id`) applied server-side, so cursor advancement under `tt msg recv --from <agent>` is honest.
- **SIGTERM lifecycle** for `tt msg recv --follow` covered by a real subprocess test that spawns the CLI, sends a message via MCP, asserts the JSON line on stdout, sends SIGTERM, and verifies clean exit.

## Verification

```bash
npm run typecheck         # clean
npm run build             # clean
npm test                  # 263 passed (was 257 before fd67873)
tt --help | grep "tt msg" # tt msg send/recv visible
```

End-to-end dogfood pre-release: claude (MCP) ↔ codex (MCP) ↔ codex (CLI) round-tripped 6 messages (events 668→675) in the live coordination room with zero `pass_stick`/`release_stick` calls during the chat. Both `target=self` (excludes own broadcast) and `target=any` (includes own broadcast) verified in production.

## Plan and design

- The original signaling design and implementation plans were later retired after the CLI-only receive contract replaced them.
- [`docs/receive-consumer-contract.md`](../receive-consumer-contract.md) — lifecycle, cursor, replay, backpressure
- [`skills/talking-stick/SKILL.md`](../../skills/talking-stick/SKILL.md) §4.5 — when-to-message-vs-note-vs-handoff guidance
