talking-stick 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -3
- package/dist/cli/event-stream.js +124 -0
- package/dist/cli/msg-commands.js +81 -0
- package/dist/cli/output.js +3 -1
- package/dist/cli/registry.js +11 -1
- package/dist/cli/room-commands.js +13 -2
- package/dist/commands.js +15 -0
- package/dist/config.js +3 -0
- package/dist/db.js +7 -0
- package/dist/mcp-server.js +32 -0
- package/dist/service.js +161 -4
- package/docs/plans/out-of-band-signaling-implementation.md +854 -0
- package/docs/plans/out-of-band-signaling.md +255 -176
- package/docs/receive-consumer-contract.md +30 -0
- package/docs/releases/0.2.0.md +85 -0
- package/package.json +1 -1
- package/skills/talking-stick/SKILL.md +24 -2
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
# Out-of-Band Signaling — Implementation Plan
|
|
2
|
+
|
|
3
|
+
**Status:** Reviewed and amended by `codex:4ed7aa3c`; ready for implementation on this branch unless `claude:c756bb19` objects in review. Author: `claude:c756bb19`. Branch: `oob-signaling`. **Design source:** [out-of-band-signaling.md](./out-of-band-signaling.md) (converged 2026-04-30).
|
|
4
|
+
|
|
5
|
+
This plan turns the converged design into a build sequence: concrete files, types, SQL, RPC shapes, CLI grammar, tests, and edge-case decisions. It does **not** re-litigate design choices the prior round closed; it locks the implementation-time choices the design deliberately deferred.
|
|
6
|
+
|
|
7
|
+
> **Reviewer focus.** Push back on the spots flagged with **DECISION** (we still need a single answer) and **RISK** (where the implementation could go wrong). The order of stages mirrors the rollout in the design doc — each stage is independently shippable and individually mergeable.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 0. Ground rules
|
|
12
|
+
|
|
13
|
+
- All work lands on branch `oob-signaling`, off `master` at `8069d84`.
|
|
14
|
+
- Each stage gets a focused commit with a passing `npm test` + `npm run typecheck`. No "all stages in one giant commit." Reviewer (claude) wants to be able to bisect.
|
|
15
|
+
- ESM, strict TS, 2-space indent, double quotes, semicolons, `.js` import extensions on local TS — same as the rest of `src/`.
|
|
16
|
+
- `dist/` is regenerated by `npm run build`; it is not edited by hand and not committed in feature branches.
|
|
17
|
+
- Tests use Vitest with `TALKING_STICK_DATA_DIR` for isolation, per `CLAUDE.md`.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Stage 1 — Substrate (migration + service primitives)
|
|
22
|
+
|
|
23
|
+
End state: `room_events.payload_json` exists, `service.sendMessage` and `service.waitForEvents` work, no MCP/CLI yet. Every test below must pass before stage 2 starts.
|
|
24
|
+
|
|
25
|
+
### 1.1 Schema migration
|
|
26
|
+
|
|
27
|
+
**File:** `src/db.ts`
|
|
28
|
+
|
|
29
|
+
Append migration #5 to the `migrations` array (after id=4 `room_member_wait_presence`):
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
{
|
|
33
|
+
id: 5,
|
|
34
|
+
name: "room_events_payload_json",
|
|
35
|
+
up: `
|
|
36
|
+
ALTER TABLE room_events ADD COLUMN payload_json TEXT;
|
|
37
|
+
`
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Rationale: column is nullable, no default needed. Existing rows back-fill to NULL. SQLite `ALTER TABLE ADD COLUMN` is O(1) on big tables (no rewrite), so this is safe even on populated dogfood DBs.
|
|
42
|
+
|
|
43
|
+
**No new index for v1.** Filter queries in `waitForEvents` go through the existing `room_events_room_seq_idx` plus predicates on `event_type` / `from_agent_id` / `to_agent_id`; for 4-digit event counts per room that's fast enough. Decision: wait for measured slowness before adding `room_events (room_id, event_type, event_seq)` or sender/recipient-specific indexes. `event_seq > ?` remains the dominant filter, and a partial scan over a single room's events past the cursor is bounded.
|
|
44
|
+
|
|
45
|
+
### 1.2 Discriminated payload types
|
|
46
|
+
|
|
47
|
+
**File:** `src/types.ts`
|
|
48
|
+
|
|
49
|
+
Extend the existing `RoomEvent` union to include the new event types and a typed `payload` field. Existing event types continue to use only the typed columns; new event types carry their event-specific fields under `payload`.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// Replace RoomEvent.event_type union and add a payload field.
|
|
53
|
+
export type EventType =
|
|
54
|
+
| "claim"
|
|
55
|
+
| "release"
|
|
56
|
+
| "pass"
|
|
57
|
+
| "takeover"
|
|
58
|
+
| "close"
|
|
59
|
+
| "kick"
|
|
60
|
+
| "message_sent";
|
|
61
|
+
|
|
62
|
+
export interface MessagePayload {
|
|
63
|
+
body: string;
|
|
64
|
+
delivery_hint: "normal" | "interrupt";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type EventPayload =
|
|
68
|
+
| { kind: "message_sent"; payload: MessagePayload };
|
|
69
|
+
|
|
70
|
+
export interface RoomEvent {
|
|
71
|
+
event_seq: number;
|
|
72
|
+
event_id: string;
|
|
73
|
+
room_id: string;
|
|
74
|
+
turn_id: number;
|
|
75
|
+
event_type: EventType;
|
|
76
|
+
from_agent_id: AgentId | null;
|
|
77
|
+
to_agent_id: AgentId | null;
|
|
78
|
+
handoff: Handoff | null;
|
|
79
|
+
reason: string | null;
|
|
80
|
+
created_at: string;
|
|
81
|
+
payload: MessagePayload | null; // populated when event_type === "message_sent"
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**RISK — type ergonomics.** Keeping `payload` flat (single optional field) is cheaper than a discriminated union over the whole event row. Consumers narrow with `event.event_type === "message_sent"`. If we later add presence events (Stage 4), we widen the `payload` field to a union; the column does not change.
|
|
86
|
+
|
|
87
|
+
New input/result types (paste near `Note*` types):
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
export type DeliveryHint = "normal" | "interrupt";
|
|
91
|
+
|
|
92
|
+
export interface SendMessageInput {
|
|
93
|
+
agent_id: AgentId;
|
|
94
|
+
room_id: string;
|
|
95
|
+
body: string;
|
|
96
|
+
to_agent_id?: AgentId; // null/undefined = broadcast
|
|
97
|
+
delivery_hint?: DeliveryHint;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface SendMessageResult {
|
|
101
|
+
event_seq: number;
|
|
102
|
+
event_id: string;
|
|
103
|
+
created_at: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type EventTypeFilter = EventType | EventType[];
|
|
107
|
+
export type TargetAgentFilter = "self" | "any" | AgentId;
|
|
108
|
+
|
|
109
|
+
export interface WaitForEventsInput {
|
|
110
|
+
agent_id?: AgentId; // required when target_agent_id === "self"
|
|
111
|
+
room_id: string;
|
|
112
|
+
after_event_seq?: number;
|
|
113
|
+
event_type?: EventTypeFilter;
|
|
114
|
+
target_agent_id?: TargetAgentFilter; // default "self"
|
|
115
|
+
from_agent_id?: AgentId;
|
|
116
|
+
max_wait_ms?: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface WaitForEventsResult {
|
|
120
|
+
events: RoomEvent[];
|
|
121
|
+
cursor_event_seq: number;
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**File:** `src/errors.ts`
|
|
126
|
+
|
|
127
|
+
Add protocol error codes used by this surface: `message_too_large`, `invalid_delivery_hint`, `unknown_recipient`, `ambiguous_recipient`, `agent_id_required`, and `invalid_event_type_filter`. Use `unknown_recipient` for message routing errors so they do not get confused with membership preconditions on the sender (`unknown_member`).
|
|
128
|
+
|
|
129
|
+
### 1.3 `appendEvent` extension
|
|
130
|
+
|
|
131
|
+
**File:** `src/service.ts`
|
|
132
|
+
|
|
133
|
+
Extend the private `appendEvent` to accept an optional `payload` and serialize it into `payload_json`. Existing callers pass nothing and get NULL.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
private appendEvent(input: {
|
|
137
|
+
room_id: string;
|
|
138
|
+
turn_id: number;
|
|
139
|
+
event_type: EventType;
|
|
140
|
+
from_agent_id: string | null;
|
|
141
|
+
to_agent_id: string | null;
|
|
142
|
+
handoff: Handoff | null;
|
|
143
|
+
reason: string | null;
|
|
144
|
+
created_at: string;
|
|
145
|
+
payload?: MessagePayload | null;
|
|
146
|
+
}): number {
|
|
147
|
+
const result = this.db
|
|
148
|
+
.prepare(
|
|
149
|
+
`
|
|
150
|
+
INSERT INTO room_events (
|
|
151
|
+
event_id, room_id, turn_id, event_type,
|
|
152
|
+
from_agent_id, to_agent_id,
|
|
153
|
+
handoff_json, reason, created_at, payload_json
|
|
154
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
155
|
+
`
|
|
156
|
+
)
|
|
157
|
+
.run(
|
|
158
|
+
randomUUID(),
|
|
159
|
+
input.room_id,
|
|
160
|
+
input.turn_id,
|
|
161
|
+
input.event_type,
|
|
162
|
+
input.from_agent_id,
|
|
163
|
+
input.to_agent_id,
|
|
164
|
+
input.handoff ? JSON.stringify(input.handoff) : null,
|
|
165
|
+
input.reason,
|
|
166
|
+
input.created_at,
|
|
167
|
+
input.payload ? JSON.stringify(input.payload) : null
|
|
168
|
+
);
|
|
169
|
+
return Number(result.lastInsertRowid);
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`mapEvent` parses `payload_json` into `payload` only when `event_type === "message_sent"`; otherwise returns `null`. This keeps consumers from accidentally treating future foreign payloads as MessagePayload before we've widened the union.
|
|
174
|
+
|
|
175
|
+
### 1.4 `sendMessage`
|
|
176
|
+
|
|
177
|
+
**File:** `src/service.ts`
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {
|
|
181
|
+
assertNonEmpty(input.agent_id, "agent_id");
|
|
182
|
+
assertNonEmpty(input.room_id, "room_id");
|
|
183
|
+
|
|
184
|
+
const body = input.body ?? "";
|
|
185
|
+
if (body.length === 0) {
|
|
186
|
+
throw new ProtocolError("invalid_body", "Message body must not be empty.");
|
|
187
|
+
}
|
|
188
|
+
const byteLength = Buffer.byteLength(body, "utf8");
|
|
189
|
+
if (byteLength > MAX_MESSAGE_BODY_BYTES) {
|
|
190
|
+
throw new ProtocolError(
|
|
191
|
+
"message_too_large",
|
|
192
|
+
`Message body exceeds ${MAX_MESSAGE_BODY_BYTES} bytes (received ${byteLength}).`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const deliveryHint: DeliveryHint = input.delivery_hint ?? "normal";
|
|
197
|
+
if (deliveryHint !== "normal" && deliveryHint !== "interrupt") {
|
|
198
|
+
throw new ProtocolError("invalid_delivery_hint", "delivery_hint must be 'normal' or 'interrupt'.");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const now = this.now();
|
|
202
|
+
const timestamp = now.toISOString();
|
|
203
|
+
this.purgeExpiredIdleRooms(now);
|
|
204
|
+
|
|
205
|
+
return withImmediateTransaction(this.db, () => {
|
|
206
|
+
const room = this.requireRoom(input.room_id);
|
|
207
|
+
if (room.state === "closed") {
|
|
208
|
+
throw new ProtocolError("room_closed", "Messages cannot be sent to a closed room.", {
|
|
209
|
+
room_id: input.room_id
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// touchMember: sender must be an active joined member (matches add_note).
|
|
214
|
+
this.touchMember(input.room_id, input.agent_id, timestamp);
|
|
215
|
+
|
|
216
|
+
if (input.to_agent_id) {
|
|
217
|
+
const target = this.getMember(input.room_id, input.to_agent_id);
|
|
218
|
+
if (!target) {
|
|
219
|
+
throw new ProtocolError(
|
|
220
|
+
"unknown_recipient",
|
|
221
|
+
"to_agent_id is not a member of this room.",
|
|
222
|
+
{ to_agent_id: input.to_agent_id }
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const eventSeq = this.appendEvent({
|
|
228
|
+
room_id: input.room_id,
|
|
229
|
+
turn_id: room.turn_id,
|
|
230
|
+
event_type: "message_sent",
|
|
231
|
+
from_agent_id: input.agent_id,
|
|
232
|
+
to_agent_id: input.to_agent_id ?? null,
|
|
233
|
+
handoff: null,
|
|
234
|
+
reason: null,
|
|
235
|
+
created_at: timestamp,
|
|
236
|
+
payload: { body, delivery_hint: deliveryHint }
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const row = this.db
|
|
240
|
+
.prepare<[number], { event_id: string }>(
|
|
241
|
+
"SELECT event_id FROM room_events WHERE event_seq = ?"
|
|
242
|
+
)
|
|
243
|
+
.get(eventSeq);
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
event_seq: eventSeq,
|
|
247
|
+
event_id: row!.event_id,
|
|
248
|
+
created_at: timestamp
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**Constants:** add `const MAX_MESSAGE_BODY_BYTES = 4096;` near `MAX_NOTE_BODY_BYTES`.
|
|
255
|
+
|
|
256
|
+
**Decision — sender membership.** Use `touchMember` (write path), not `touchKnownMember`. A sender must already be a joined member and a live command/tool call refreshes `last_seen_at`. This matches `add_note` and prevents stale identities from injecting messages without first joining. It does not require the target recipient to be active when targeted by full `agent_id`; targeting is routing, not liveness proof.
|
|
257
|
+
|
|
258
|
+
### 1.5 `waitForEvents`
|
|
259
|
+
|
|
260
|
+
**File:** `src/service.ts`
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
async waitForEvents(input: WaitForEventsInput): Promise<WaitForEventsResult> {
|
|
264
|
+
assertNonEmpty(input.room_id, "room_id");
|
|
265
|
+
|
|
266
|
+
const targetFilter = input.target_agent_id ?? "self";
|
|
267
|
+
if (targetFilter === "self" && !input.agent_id) {
|
|
268
|
+
throw new ProtocolError(
|
|
269
|
+
"agent_id_required",
|
|
270
|
+
"agent_id is required when target_agent_id is 'self'."
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const eventTypes = normalizeEventTypeFilter(input.event_type);
|
|
275
|
+
const cursor = input.after_event_seq ?? 0;
|
|
276
|
+
const maxWaitMs = Math.min(
|
|
277
|
+
input.max_wait_ms ?? this.policy.waitForEventsMaxWaitMs,
|
|
278
|
+
this.policy.waitForEventsMaxWaitMs
|
|
279
|
+
);
|
|
280
|
+
const deadline = Date.now() + Math.max(0, maxWaitMs);
|
|
281
|
+
|
|
282
|
+
while (true) {
|
|
283
|
+
// Pure read; no transaction, no touch*Member.
|
|
284
|
+
const events = this.queryEvents({
|
|
285
|
+
room_id: input.room_id,
|
|
286
|
+
after_event_seq: cursor,
|
|
287
|
+
event_types: eventTypes,
|
|
288
|
+
target: targetFilter,
|
|
289
|
+
caller_agent_id: input.agent_id ?? null,
|
|
290
|
+
from_agent_id: input.from_agent_id ?? null,
|
|
291
|
+
limit: this.policy.waitForEventsBatchLimit
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
if (events.length > 0 || Date.now() >= deadline) {
|
|
295
|
+
const lastSeq = events.length > 0
|
|
296
|
+
? events[events.length - 1].event_seq
|
|
297
|
+
: cursor;
|
|
298
|
+
return { events, cursor_event_seq: lastSeq };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const remainingMs = deadline - Date.now();
|
|
302
|
+
await sleep(Math.min(this.policy.waitForEventsPollMs, remainingMs));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
`queryEvents` is a private helper that builds a parameterized SQL query:
|
|
308
|
+
|
|
309
|
+
```sql
|
|
310
|
+
SELECT * FROM room_events
|
|
311
|
+
WHERE room_id = ?
|
|
312
|
+
AND event_seq > ?
|
|
313
|
+
[AND event_type IN (?, ?, ...)]
|
|
314
|
+
[AND target_filter_clause]
|
|
315
|
+
ORDER BY event_seq
|
|
316
|
+
LIMIT ?
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Before the loop, call `requireRoom(input.room_id)` to fail fast if the room does not exist. Do **not** call `purgeExpiredIdleRooms`, `touchMember`, `touchKnownMember`, or `touchWaitingMember` from `waitForEvents`; this method's contract is observer-safe and read-only. Other write paths and existing room reads can continue to perform startup cleanup.
|
|
320
|
+
|
|
321
|
+
**`target_filter_clause` per filter mode:**
|
|
322
|
+
|
|
323
|
+
- `"any"`: omit clause.
|
|
324
|
+
- `<agent_id>`: `to_agent_id = ?`. (Strict — does NOT include broadcast unless explicitly requested.)
|
|
325
|
+
- `"self"`:
|
|
326
|
+
- For `event_type='message_sent'`: direct messages to the caller plus broadcasts from other agents. Do not echo the caller's own broadcast messages back to their default receiver.
|
|
327
|
+
- For non-message events: `(to_agent_id = ? OR from_agent_id = ?)` (caller is participant). Caller's own `claim`/`release`/`pass`/etc. are visible to them; this matches "self = events that affect me".
|
|
328
|
+
- Encoded as a single `CASE`/`OR` SQL clause to avoid two queries. See §1.5.1 below.
|
|
329
|
+
|
|
330
|
+
**§1.5.1 — `target=self` SQL.** The cleanest single-query encoding:
|
|
331
|
+
|
|
332
|
+
```sql
|
|
333
|
+
AND (
|
|
334
|
+
(event_type = 'message_sent' AND (to_agent_id = ? OR (to_agent_id IS NULL AND from_agent_id != ?)))
|
|
335
|
+
OR
|
|
336
|
+
(event_type != 'message_sent' AND (to_agent_id = ? OR from_agent_id = ?))
|
|
337
|
+
)
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Four bound parameters of the caller's agent_id. Acceptable; SQLite's planner handles this fine on the existing index.
|
|
341
|
+
|
|
342
|
+
**`from_agent_id` filter.** Add this filter server-side now. It costs one optional SQL predicate and keeps `cursor_event_seq` semantics honest for `tt msg recv --from ...`: the CLI should not have to consume and skip batches of target-matching messages from the wrong sender.
|
|
343
|
+
|
|
344
|
+
**`waitForEvents` does NOT mutate.** No purge, no `touchMember`, no `touchKnownMember`, and no `touchWaitingMember`. This is the explicit observer-safety property from the design (Layer 4).
|
|
345
|
+
|
|
346
|
+
### 1.6 Policy additions
|
|
347
|
+
|
|
348
|
+
**File:** `src/types.ts` (Policy interface) and `src/config.ts` (`defaultPolicy`).
|
|
349
|
+
|
|
350
|
+
Add three policy values:
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
waitForEventsMaxWaitMs: number; // default 30_000 (matches waitForTurn)
|
|
354
|
+
waitForEventsPollMs: number; // default 250 (matches waitForTurn)
|
|
355
|
+
waitForEventsBatchLimit: number; // default 100
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
These match the long-poll defaults in the existing `wait_for_turn` path. No CLI/MCP knob in v1; servers (and tests) override via constructor options.
|
|
359
|
+
|
|
360
|
+
### 1.7 Stage-1 tests
|
|
361
|
+
|
|
362
|
+
**File:** `tests/oob-substrate.test.ts` (new).
|
|
363
|
+
|
|
364
|
+
Coverage matrix:
|
|
365
|
+
|
|
366
|
+
| # | Test | What it pins |
|
|
367
|
+
|---|------|--------------|
|
|
368
|
+
| 1 | Migration 5 applies cleanly to a v0.1.x DB seeded with id=1..4 events | back-compat |
|
|
369
|
+
| 2 | Existing event types (claim/release/pass/takeover/close/kick) write `payload_json = NULL` | no regression |
|
|
370
|
+
| 3 | `sendMessage` happy path direct: body, hint, recipient round-trip in `getRoomEvents` | basic write |
|
|
371
|
+
| 4 | `sendMessage` happy path broadcast: `to_agent_id = NULL`, body visible | broadcast |
|
|
372
|
+
| 5 | `sendMessage` rejects empty body → `invalid_body` | input val |
|
|
373
|
+
| 6 | `sendMessage` rejects 4097-byte body → `message_too_large`, body NOT inserted | cap + atomicity |
|
|
374
|
+
| 7 | `sendMessage` rejects unknown `to_agent_id` → `unknown_recipient` | routing val |
|
|
375
|
+
| 8 | `sendMessage` from non-member sender → `unknown_member` | sender liveness |
|
|
376
|
+
| 9 | `sendMessage` to a closed room → `room_closed` | room state |
|
|
377
|
+
| 10 | `sendMessage` of `delivery_hint='interrupt'` round-trips | hint preserved |
|
|
378
|
+
| 11 | `waitForEvents` returns immediately when events past cursor exist | non-blocking |
|
|
379
|
+
| 12 | `waitForEvents` returns empty after deadline when no new events | timeout |
|
|
380
|
+
| 13 | `waitForEvents` `target='self'` filters to (direct OR broadcast) for messages | filter |
|
|
381
|
+
| 14 | `waitForEvents` `target='self'` filters to (to OR from) for non-message events | filter |
|
|
382
|
+
| 15 | `waitForEvents` `target='any'` returns everything | filter |
|
|
383
|
+
| 16 | `waitForEvents` `target=<agent_id>` returns only direct (no broadcast) | filter strictness |
|
|
384
|
+
| 17 | `waitForEvents` `event_type='message_sent'` excludes claim/release/etc. | type filter |
|
|
385
|
+
| 18 | `waitForEvents` cursor resume: second call past `cursor_event_seq` returns only new events | cursor correctness |
|
|
386
|
+
| 19 | `waitForEvents` does NOT update `last_wait_at` or `last_seen_at` (observer safety) | non-mutating |
|
|
387
|
+
| 20 | `event_seq` ordering: `sendMessage` interleaved with `releaseStick` preserves monotonic order | ordering |
|
|
388
|
+
| 21 | Concurrent `sendMessage` from two agents produces distinct `event_seq`s | concurrency |
|
|
389
|
+
| 22 | `sendMessage` `delivery_hint` defaults to `"normal"` | default |
|
|
390
|
+
| 23 | `sendMessage` rejects `delivery_hint` other than normal/interrupt → `invalid_delivery_hint` | input val |
|
|
391
|
+
| 24 | `getRoomEvents` returns `payload` populated for message_sent rows, `null` for others | mapEvent |
|
|
392
|
+
| 24a | `waitForEvents` `from_agent_id` filters server-side without advancing over unmatched senders | sender filter |
|
|
393
|
+
|
|
394
|
+
**RISK — test isolation.** Existing pattern: `tests/setup.ts` + `TALKING_STICK_DATA_DIR`. New file follows the same setup; no changes to fixtures.
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## Stage 2 — MCP surface
|
|
399
|
+
|
|
400
|
+
End state: MCP `send_message` and `wait_for_events` tools available; `get_room_events` returns `payload` for message events.
|
|
401
|
+
|
|
402
|
+
### 2.1 New tools
|
|
403
|
+
|
|
404
|
+
**File:** `src/mcp-server.ts`
|
|
405
|
+
|
|
406
|
+
Register two tools, mirroring the `add_note` pattern (resolve identity from `extra.sessionId`):
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
server.registerTool(
|
|
410
|
+
"send_message",
|
|
411
|
+
{
|
|
412
|
+
title: "Send Message",
|
|
413
|
+
description:
|
|
414
|
+
"Send a transient message into the room event log. Routes via to_agent_id (null = broadcast). Body capped at 4096 bytes UTF-8.",
|
|
415
|
+
inputSchema: {
|
|
416
|
+
room_id: z.string().min(1),
|
|
417
|
+
body: z.string().min(1),
|
|
418
|
+
to_agent_id: z.string().min(1).optional(),
|
|
419
|
+
delivery_hint: z.enum(["normal", "interrupt"]).optional()
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
async (input, extra) =>
|
|
423
|
+
toolJson(() =>
|
|
424
|
+
commands.sendMessage(resolveConnectionIdentity(extra.sessionId), input)
|
|
425
|
+
)
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
server.registerTool(
|
|
429
|
+
"wait_for_events",
|
|
430
|
+
{
|
|
431
|
+
title: "Wait for Events",
|
|
432
|
+
description:
|
|
433
|
+
"Long-poll the room event log past a cursor with optional event_type and target filters. Observer-safe: does not mutate room state.",
|
|
434
|
+
inputSchema: {
|
|
435
|
+
room_id: z.string().min(1),
|
|
436
|
+
after_event_seq: z.number().int().nonnegative().optional(),
|
|
437
|
+
event_type: z
|
|
438
|
+
.union([z.string().min(1), z.array(z.string().min(1)).min(1)])
|
|
439
|
+
.optional(),
|
|
440
|
+
target_agent_id: z.string().min(1).optional(),
|
|
441
|
+
from_agent_id: z.string().min(1).optional(),
|
|
442
|
+
max_wait_ms: z.number().int().nonnegative().optional()
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
async (input, extra) =>
|
|
446
|
+
toolJson(() =>
|
|
447
|
+
commands.waitForEvents({
|
|
448
|
+
...input,
|
|
449
|
+
agent_id: resolveConnectionIdentity(extra.sessionId).agent_id
|
|
450
|
+
})
|
|
451
|
+
)
|
|
452
|
+
);
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
`commands.sendMessage` and `commands.waitForEvents` are thin wrappers in `src/commands.ts` that match the existing pattern (`commands.addNote`, `commands.listNotes`, etc.).
|
|
456
|
+
|
|
457
|
+
### 2.2 `get_room_events` payload propagation
|
|
458
|
+
|
|
459
|
+
`getRoomEvents` already exists; extending `mapEvent` (Stage 1.3) is sufficient. Schema unchanged for `get_room_events` — output now contains `payload` field, populated for `message_sent` rows.
|
|
460
|
+
|
|
461
|
+
### 2.3 Stage-2 tests
|
|
462
|
+
|
|
463
|
+
**File:** `tests/mcp-smoke.test.ts` (extend) and `tests/oob-mcp.test.ts` (new for richer cases).
|
|
464
|
+
|
|
465
|
+
Coverage:
|
|
466
|
+
|
|
467
|
+
| # | Test | What it pins |
|
|
468
|
+
|---|------|--------------|
|
|
469
|
+
| 25 | MCP `send_message` happy path: caller identity from sessionId, recipient resolves, event lands | identity wiring |
|
|
470
|
+
| 26 | MCP `send_message` returns typed `message_too_large` error on oversized body | error shape |
|
|
471
|
+
| 27 | MCP `wait_for_events` `target='self'` resolves caller from sessionId | identity wiring |
|
|
472
|
+
| 28 | MCP `wait_for_events` returns events.payload populated for message_sent | payload round-trip via MCP |
|
|
473
|
+
| 29 | MCP `get_room_events` returns payload for message_sent events alongside legacy events | back-compat |
|
|
474
|
+
| 30 | MCP `wait_for_events` `event_type=['message_sent','release']` (array form) filters correctly | array filter |
|
|
475
|
+
| 30a | MCP `wait_for_events` `from_agent_id` filters server-side | sender filter |
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
## Stage 3 — CLI surface
|
|
480
|
+
|
|
481
|
+
End state: `tt msg send`, `tt msg recv [--wait|--follow]`, `tt events --wait|--follow` available; `tt msg recv --follow` is the continuous stream path for harnesses that can monitor stdout, while `tt msg recv --wait` is the portable wake-on-next-event path for harnesses that can run a background command and notice process exit. Recipient resolution works.
|
|
482
|
+
|
|
483
|
+
### 3.1 New commands
|
|
484
|
+
|
|
485
|
+
**File:** `src/cli/registry.ts`
|
|
486
|
+
|
|
487
|
+
Add three entries:
|
|
488
|
+
|
|
489
|
+
```ts
|
|
490
|
+
{
|
|
491
|
+
name: "msg",
|
|
492
|
+
needsRuntime: true,
|
|
493
|
+
startupMaintenance: true,
|
|
494
|
+
internal: false,
|
|
495
|
+
usage: "tt msg <send|recv> [...]",
|
|
496
|
+
description: "Send or receive transient messages on a room's event stream.",
|
|
497
|
+
handler: ({ runtime, parsed }) => handleMsgCommand(requireRuntime(runtime), parsed)
|
|
498
|
+
}
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
The existing `events` entry gains `--wait`, `--follow`, `--event`, and `--target` without a registry change — the handler interprets the flags.
|
|
502
|
+
|
|
503
|
+
**File:** `src/cli/msg-commands.ts` (new). Mirrors `notes-commands.ts`:
|
|
504
|
+
|
|
505
|
+
```ts
|
|
506
|
+
export async function handleMsgCommand(runtime, parsed): Promise<void> {
|
|
507
|
+
const [subcommand, ...rest] = parsed.positionals;
|
|
508
|
+
const subParsed = { name: `msg ${subcommand}`, positionals: rest, options: parsed.options };
|
|
509
|
+
switch (subcommand) {
|
|
510
|
+
case "send": return handleMsgSendCommand(runtime, subParsed);
|
|
511
|
+
case "recv": return handleMsgRecvCommand(runtime, subParsed);
|
|
512
|
+
default: throw new Error(`Unknown msg subcommand: ${subcommand}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
### 3.2 `tt msg send <recipient> <body>`
|
|
518
|
+
|
|
519
|
+
Grammar:
|
|
520
|
+
|
|
521
|
+
```
|
|
522
|
+
tt msg send <recipient> <body...> [--interrupt] [--stdin]
|
|
523
|
+
tt msg send room <body...> [--interrupt] # broadcast (literal "room")
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
- `<recipient>`: full `agent_id` (`codex:5c11d1e8`), display name (`codex`), or literal `room` for broadcast.
|
|
527
|
+
- `<body>`: positional remainder joined by space (matches `tt notes add` body convention) OR `--stdin`.
|
|
528
|
+
- `--interrupt`: sets `delivery_hint=interrupt`.
|
|
529
|
+
- Path resolution: same `resolveSessionForNotes`-style helper, since this is a write-shaped command.
|
|
530
|
+
- `--json/--text`: same auto-detect rules as everywhere else (`shouldUseJson`).
|
|
531
|
+
|
|
532
|
+
**Recipient resolution** (in CLI, before `service.sendMessage`):
|
|
533
|
+
|
|
534
|
+
```ts
|
|
535
|
+
function resolveRecipient(
|
|
536
|
+
runtime: Runtime,
|
|
537
|
+
identity: DerivedIdentity,
|
|
538
|
+
roomId: string,
|
|
539
|
+
raw: string
|
|
540
|
+
): string | null /* null = broadcast */ {
|
|
541
|
+
if (raw === "room") return null;
|
|
542
|
+
|
|
543
|
+
const members = runtime.commands.getRoomState({ room_id: roomId, agent_id: identity.agent_id }).members;
|
|
544
|
+
// Exact agent_id match wins.
|
|
545
|
+
const exact = members.find(m => m.agent_id === raw);
|
|
546
|
+
if (exact) return exact.agent_id;
|
|
547
|
+
// Display-name match — only active members, must be unambiguous.
|
|
548
|
+
const candidates = members.filter(m => m.display_name === raw && m.status === "active");
|
|
549
|
+
if (candidates.length === 1) return candidates[0].agent_id;
|
|
550
|
+
if (candidates.length > 1) {
|
|
551
|
+
throw new ProtocolError("ambiguous_recipient", `Multiple active members with display name '${raw}'.`, {
|
|
552
|
+
candidates: candidates.map(m => m.agent_id)
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
throw new ProtocolError("unknown_recipient", `No active member matches '${raw}'.`);
|
|
556
|
+
}
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
**Decision — display-name resolution scope.** Display-name shorthand is active-only. Full `agent_id` targeting remains an escape hatch for known inactive members because the service validates only room membership. This split keeps the common chat path honest without making routing pretend to be delivery.
|
|
560
|
+
|
|
561
|
+
**Decision — body via positional vs `--stdin`.** `tt notes add` uses `--stdin`; mirror that. Positional remainder is joined by space (`parsed.positionals.slice(1).join(" ")`). Empty body without `--stdin` is a usage error.
|
|
562
|
+
|
|
563
|
+
**Boolean flag repair.** `parseCommand` currently consumes the next non-`--` token as a flag value. `tt msg send codex --interrupt "hi"` is part of the documented UX, so the handler must repair this case: if `--interrupt` or `--room` has a string value, treat the option as boolean and splice the consumed value back into the message body. Do not make users remember "boolean flags last" for the new single-command chat path.
|
|
564
|
+
|
|
565
|
+
### 3.3 `tt msg recv [--wait|--follow] [--from <agent>] [--after <event_seq>]`
|
|
566
|
+
|
|
567
|
+
Grammar:
|
|
568
|
+
|
|
569
|
+
```
|
|
570
|
+
tt msg recv # one-shot: print latest unread for self, exit
|
|
571
|
+
tt msg recv --wait # block until next matching event batch, print, exit
|
|
572
|
+
tt msg recv --follow # long-running: tail forever, JSON line per event
|
|
573
|
+
tt msg recv --wait --from codex # portable background wake path
|
|
574
|
+
tt msg recv --follow --from codex # filter by sender display name or agent_id
|
|
575
|
+
tt msg recv --wait --after 12345 # wait after a known cursor
|
|
576
|
+
tt msg recv --follow --after 12345 # continuous resume from cursor
|
|
577
|
+
tt msg recv --follow --target any # power user: see all messages, not just self
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
Implementation:
|
|
581
|
+
|
|
582
|
+
- One-shot mode: single call to `commands.waitForEvents({ event_type: "message_sent", target_agent_id: "self", max_wait_ms: 0, after_event_seq })`. Print events. Exit.
|
|
583
|
+
- `--wait` mode: single call to `commands.waitForEvents({ event_type: "message_sent", target_agent_id, from_agent_id, after_event_seq, max_wait_ms })`. If an event batch arrives, print one line per event and exit 0. If the deadline expires with no events, print nothing and exit 0 with `{ events: [], cursor_event_seq }` in JSON mode. This is the portability path for Codex/Gemini/OpenCode-style harnesses that can launch a background process and be notified when it exits, but cannot consume each stdout line from a continuously running child.
|
|
584
|
+
- `--follow` mode: loop calling `waitForEvents` with the policy long-poll deadline; emit each event as a single line of JSON (or human text per `shouldUseJson`); update local cursor; repeat.
|
|
585
|
+
|
|
586
|
+
**Cursor behavior:**
|
|
587
|
+
|
|
588
|
+
- Default `--wait` / `--follow` start cursor: **the highest current event_seq at startup time** (i.e., do NOT replay history). This avoids flooding harnesses on first launch.
|
|
589
|
+
- `--after N`: explicit override, used for resume.
|
|
590
|
+
- `--after 0`: opt-in to full backlog (rare; mostly for debugging). No separate `--from-start` flag.
|
|
591
|
+
- Cursor persistence to disk is **out of scope for v1 CLI**. Per design Layer 6 §4, this is the harness's or plugin's responsibility. Operators wire `--after $LAST_SEQ` from their own bookkeeping.
|
|
592
|
+
|
|
593
|
+
Implement the "highest current event_seq" cursor with a small service/command helper such as `getLatestEventSeq(room_id)` rather than by paging through `getRoomEvents`. It is a simple `SELECT MAX(event_seq)` read and avoids replaying old event pages just to find the tail.
|
|
594
|
+
|
|
595
|
+
**Sender filter (`--from`):** resolved at startup against the room's members (display name OR exact agent_id). Pass the resolved `from_agent_id` into `waitForEvents`; filtering is server-side.
|
|
596
|
+
|
|
597
|
+
**Why `--wait` exists.** Continuous `--follow` is ideal for Claude Code Monitor and human terminals. It is not enough for harnesses that can run a background command but only surface the result when the command exits. Those harnesses can run `tt msg recv --wait --after <cursor> --json` in the background; when it exits with events, the harness sees the completed output, updates its cursor, and starts a new `--wait`. This simulates real-time delivery by re-invocation without requiring line-by-line stdout monitoring.
|
|
598
|
+
|
|
599
|
+
**SIGTERM/SIGHUP:** the `--follow` loop installs handlers that flip a `shouldExit` flag, finish the current iteration, and exit cleanly with the last cursor printed to stderr (so a wrapping operator can resume). Database connection is closed via `runtime` shutdown hooks.
|
|
600
|
+
|
|
601
|
+
**Output format:**
|
|
602
|
+
|
|
603
|
+
- `--json` (or auto-JSON for harness identities): one JSON object per line, `JSON.stringify(event)`. Newline-flushed via `process.stdout.write` to ensure Monitor-style consumers see lines without buffering.
|
|
604
|
+
- `--text` (or auto-text for human): `[<created_at>] <from> → <to>: <body>` per line.
|
|
605
|
+
|
|
606
|
+
### 3.4 `tt events --wait|--follow`
|
|
607
|
+
|
|
608
|
+
Extend the existing `handleEventsCommand` to support follow mode:
|
|
609
|
+
|
|
610
|
+
```
|
|
611
|
+
tt events # one-shot (existing)
|
|
612
|
+
tt events --wait # block until next matching event batch, print, exit
|
|
613
|
+
tt events --follow # tail
|
|
614
|
+
tt events --wait --event message_sent,release # portable wake path
|
|
615
|
+
tt events --follow --event message_sent,release # filter
|
|
616
|
+
tt events --follow --target self|any|<agent> # filter
|
|
617
|
+
tt events --follow --after 12345 # resume
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
Implementation: when `--wait` is set, call `commands.waitForEvents` once with the parsed `event_type` (split CSV) and `target_agent_id`, then exit after printing any returned batch. When `--follow` is set, loop on the same primitive and emit one line per event.
|
|
621
|
+
|
|
622
|
+
`tt events` (no `--follow`) keeps current behavior: single `getRoomEvents` call, formatted block per turn. No regression.
|
|
623
|
+
|
|
624
|
+
### 3.5 Stage-3 tests
|
|
625
|
+
|
|
626
|
+
**File:** `tests/cli.test.ts` (extend) and `tests/oob-cli.test.ts` (new for follow-mode lifecycle).
|
|
627
|
+
|
|
628
|
+
| # | Test | What it pins |
|
|
629
|
+
|---|------|--------------|
|
|
630
|
+
| 31 | `tt msg send codex "hi"` resolves display name, sends to active codex | display name |
|
|
631
|
+
| 32 | `tt msg send codex:5c11d1e8 "hi"` accepts full agent_id | agent_id |
|
|
632
|
+
| 33 | `tt msg send room "hi"` broadcasts (to_agent_id null) | broadcast |
|
|
633
|
+
| 34 | `tt msg send unknown "hi"` → `unknown_recipient` exit | unknown |
|
|
634
|
+
| 35 | `tt msg send <ambiguous-display-name> "hi"` → `ambiguous_recipient` exit | ambiguity |
|
|
635
|
+
| 36 | `tt msg send codex --interrupt "hi"` sets delivery_hint=interrupt | hint passthrough |
|
|
636
|
+
| 37 | `tt msg send codex --stdin` reads body from stdin | stdin path |
|
|
637
|
+
| 38 | `tt msg send codex` (no body, no stdin) → usage error | usage |
|
|
638
|
+
| 39 | `tt msg recv` one-shot returns events for self only | filter default |
|
|
639
|
+
| 40 | `tt msg recv --wait` blocks until one matching message batch arrives, prints it, exits | portable wake |
|
|
640
|
+
| 41 | `tt msg recv --wait --after N` resumes from cursor | resume |
|
|
641
|
+
| 42 | `tt msg recv --wait --from codex` filters by sender through waitForEvents | sender filter |
|
|
642
|
+
| 43 | `tt msg recv --follow` emits one JSON line per arriving event, then SIGTERM exits cleanly | follow lifecycle |
|
|
643
|
+
| 44 | `tt events --wait --event message_sent` wakes only for messages | event-type filter |
|
|
644
|
+
| 45 | `tt events --follow --target any` shows all events including others' | target filter |
|
|
645
|
+
| 46 | `tt msg recv --wait/--follow` default cursor is "now" — no historical replay | flood-prevention |
|
|
646
|
+
| 46a | `tt msg recv --wait/--follow` JSON output is one event per line, newline-terminated, parseable | format contract |
|
|
647
|
+
|
|
648
|
+
**Decision — follow-mode test harness.** Prefer in-process tests with a factored follow helper that accepts an output sink and a stop signal. Add one subprocess smoke test only if in-process coverage misses SIGTERM behavior. This keeps Vitest fast and avoids child-process races.
|
|
649
|
+
|
|
650
|
+
---
|
|
651
|
+
|
|
652
|
+
## Stage 4 — Skill update
|
|
653
|
+
|
|
654
|
+
**File:** `skills/talking-stick/SKILL.md`
|
|
655
|
+
|
|
656
|
+
Add new section §4.5 between current §4 ("While waiting") and §5 ("While holding the stick"). Drafting:
|
|
657
|
+
|
|
658
|
+
```markdown
|
|
659
|
+
### 4.5 Out-of-band messaging
|
|
660
|
+
|
|
661
|
+
The talking stick guarantees single-writer authority over shared workspace state. It is not a chat protocol. For transient signaling — paging the holder, asking a quick question, broadcasting awareness — use messages.
|
|
662
|
+
|
|
663
|
+
**Send.** `tt msg send <recipient> "<body>"` (or `mcp send_message`). Recipient is a full `agent_id`, an unambiguous active display name, or the literal `room` for broadcast. `--interrupt` flags the message as time-sensitive; the receiver decides whether to act on it now.
|
|
664
|
+
|
|
665
|
+
**Receive.** Use the receive mode your harness can actually observe. If it can monitor stdout from a long-running child, run `tt msg recv --follow`; each incoming event lands as one JSON line. If it can only notice that a background command completed, run `tt msg recv --wait --after <last_event_seq>`; it exits on the next matching batch, then you start it again with the returned cursor. SIGTERM exits cleanly; restart with `--after <last_event_seq>` to resume.
|
|
666
|
+
|
|
667
|
+
**When to message vs note vs handoff.**
|
|
668
|
+
|
|
669
|
+
- **Message** when the exchange is conversational, ephemeral, and tied to two or more processes that are currently online. Discussion, design questions, "are you about to break X?", live coordination. Cheap.
|
|
670
|
+
- **Note** (`tt notes add`) when the artifact should outlive the moment — a finding the next holder should consider at handoff, an observation that survives process churn. Durable, resolvable.
|
|
671
|
+
- **Handoff** (release/pass with structured payload) when transferring work. Messages do not replace handoffs; they live alongside them.
|
|
672
|
+
|
|
673
|
+
**Messages are not private.** Any room member can read any message via `get_room_events` or `tt events --follow --target any`. `to_agent_id` is routing, not ACL.
|
|
674
|
+
|
|
675
|
+
**Messages do not grant the stick.** A non-holder paging the holder does not gain write authority. The holder may act on the message immediately or defer until handoff.
|
|
676
|
+
|
|
677
|
+
**Stay in the wait loop in parallel.** A `tt msg recv --wait` or `--follow` subprocess does not replace `wait_for_turn`. Keep waiting for your turn; messages are a side channel.
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
Also: a one-line addition to §1 ("Check that Talking Stick is available") noting that `tt msg recv --wait` / `--follow` may be running as sibling processes and should be left alone.
|
|
681
|
+
|
|
682
|
+
### 4.1 Skill propagation
|
|
683
|
+
|
|
684
|
+
The skill ships in two places:
|
|
685
|
+
- `skills/talking-stick/SKILL.md` (source of truth in this repo).
|
|
686
|
+
- `~/.claude/skills/talking-stick/SKILL.md` (and equivalent paths for other harnesses) via `tt install-skill --link` or `--copy`.
|
|
687
|
+
|
|
688
|
+
If symlinked (`--link` path used), edits propagate immediately. If copied (`--copy`), the operator runs `tt install-skill <harness>` to refresh. **Per CLAUDE.md, this repo dogfoods via `npm link` + `tt install-skill --link`, so the link path is the dogfooding flow.**
|
|
689
|
+
|
|
690
|
+
### 4.2 Stage-4 tests
|
|
691
|
+
|
|
692
|
+
| # | Test | What it pins |
|
|
693
|
+
|---|------|--------------|
|
|
694
|
+
| 47 | Skill install (copy) produces a SKILL.md containing §4.5 verbatim | propagation |
|
|
695
|
+
| 48 | Skill install (link) produces a working symlink whose target contains §4.5 | propagation |
|
|
696
|
+
|
|
697
|
+
`tests/skill-install.test.ts` already covers install plumbing. Add §4.5-specific assertions there.
|
|
698
|
+
|
|
699
|
+
---
|
|
700
|
+
|
|
701
|
+
## Stage 5 — Receive-consumer contract doc
|
|
702
|
+
|
|
703
|
+
**File:** `docs/receive-consumer-contract.md` (new). One-time write, no code.
|
|
704
|
+
|
|
705
|
+
Sections:
|
|
706
|
+
|
|
707
|
+
1. **Lifecycle.** Long-running process, restartable, one cursor.
|
|
708
|
+
2. **Cursor persistence.** Recommended path `~/.local/share/talking-stick/cursor-<agent_id>.json`. Format: `{ "cursor_event_seq": <int>, "updated_at": "<iso>" }`. Consumer writes after each batch. Out of scope for v1 CLI; harness owners implement.
|
|
709
|
+
3. **Replay coalescing.** On reconnect with a far-behind cursor, deliver newest N at full fidelity; older = summary line.
|
|
710
|
+
4. **Backpressure.** Drop-with-warning OR buffer to disk; do not block the read loop.
|
|
711
|
+
5. **At-least-once + dedupe.** Consumers dedupe on `event_id`.
|
|
712
|
+
6. **Routing per `delivery_hint`.** `interrupt` may inject mid-task; `normal` may buffer or write to status surface.
|
|
713
|
+
7. **SIGTERM behavior.** Final cursor flush, clean exit.
|
|
714
|
+
8. **The CLI subprocess patterns.** Reference implementations: `tt msg recv --follow` for continuous stdout consumers and `tt msg recv --wait` for wake-on-process-exit consumers. Plugins are richer consumers of the same contract.
|
|
715
|
+
|
|
716
|
+
---
|
|
717
|
+
|
|
718
|
+
## Edge cases I want codex's eyes on
|
|
719
|
+
|
|
720
|
+
These are corner cases the design doc hints at but doesn't lock. Each is a real implementation-time decision.
|
|
721
|
+
|
|
722
|
+
**EC-1. Sender membership.** Resolved: `touchMember`, not `touchKnownMember`. Sender must be joined; sending refreshes presence.
|
|
723
|
+
|
|
724
|
+
**EC-2. Display-name resolution scope.** Resolved: active-only for shorthand; full `agent_id` can target known inactive members.
|
|
725
|
+
|
|
726
|
+
**EC-3. Default `--follow` cursor.** Resolved: start at "now" by default; use `--after 0` to replay from the beginning.
|
|
727
|
+
|
|
728
|
+
**EC-4. `target=self` filters.** Resolved with the amended SQL above: messages include direct-to-self plus broadcasts from others; non-message events include `to_agent_id = self OR from_agent_id = self`.
|
|
729
|
+
|
|
730
|
+
**EC-5. `from_agent_id` filtering in `wait_for_events`.** Resolved: add it server-side now. It is cheap, keeps cursor behavior simple, and makes the MCP primitive useful without a CLI-only special case.
|
|
731
|
+
|
|
732
|
+
**EC-6. Empty `event_type` array.** Resolved: error with `invalid_event_type_filter`.
|
|
733
|
+
|
|
734
|
+
**EC-7. `room_closed` and `wait_for_events`.** Resolved: do not change the result shape for v1. A closed room's event log is still readable, and there is no shipped `close_room` path today. If close-room ships later, add a separate terminal-status extension then.
|
|
735
|
+
|
|
736
|
+
**EC-8. Migration idempotency.** Resolved: trust clean state. SQLite `ALTER TABLE` and the migration row insert are wrapped in one transaction. No special duplicate-column escape hatch unless dogfooding proves manual schema drift.
|
|
737
|
+
|
|
738
|
+
**EC-9. `to_agent_id` casing.** Agent IDs are typed strings; should we normalize case in recipient lookup? Existing code is case-sensitive throughout. Keep that. Document: full agent_id match is exact (`codex:5C11D1E8` ≠ `codex:5c11d1e8`).
|
|
739
|
+
|
|
740
|
+
**EC-10. Body normalization.** Resolved: do not trim before storage. Reject only the zero-length string at the service layer; CLI usage can still trim its positional assembly enough to detect a missing argument.
|
|
741
|
+
|
|
742
|
+
**EC-11. CLI parser gotcha.** Resolved: repair in `tt msg send`. The documented command `tt msg send codex --interrupt "body"` must work. Tests #36 should assert that exact order.
|
|
743
|
+
|
|
744
|
+
**EC-12. Concurrency on `event_seq`.** SQLite AUTOINCREMENT is monotonic per-table. `withImmediateTransaction` serializes writes. Two concurrent `sendMessage` calls land in deterministic order; no risk of out-of-order delivery. Test #21 pins this.
|
|
745
|
+
|
|
746
|
+
---
|
|
747
|
+
|
|
748
|
+
## What this plan does NOT cover (yet)
|
|
749
|
+
|
|
750
|
+
- **Stage 4 (optional): presence events.** `member_joined`, `member_left`, `note_added`. Out of v1 scope per the design doc; once messaging is solid we revisit.
|
|
751
|
+
- **Stage 5 (optional): plugin work.** Per-harness ambient UX. Not bundled in this repo.
|
|
752
|
+
- **Server-side rate limit.** Documented threshold: 30 messages / author / minute sustained. Revisit when dogfooding hits it.
|
|
753
|
+
- **Message resolution / threading / read receipts.** All non-breaking add-ons later.
|
|
754
|
+
- **Wait-intent for stick availability.** Separate design.
|
|
755
|
+
|
|
756
|
+
---
|
|
757
|
+
|
|
758
|
+
## Build sequence (what to merge in what order)
|
|
759
|
+
|
|
760
|
+
1. **PR 1 (Stage 1):** migration 5 + service primitives + tests #1–24. ~600 LOC. ~1 day.
|
|
761
|
+
2. **PR 2 (Stage 2):** MCP tools + tests #25–30. ~150 LOC. Depends on PR 1.
|
|
762
|
+
3. **PR 3 (Stage 3):** CLI commands + tests #31–46 + skill update §4.5 + tests #47–48. ~700 LOC. Depends on PR 2.
|
|
763
|
+
4. **PR 4 (optional):** receive-consumer contract doc.
|
|
764
|
+
|
|
765
|
+
PRs 1 and 2 can land back-to-back; PR 3 is the visible v1 surface. After PR 3, Claude Code (via Monitor + `tt msg recv --follow`) and Codex (via MCP `wait_for_events` polling or operator-run `tt msg recv --follow` in tmux) both have working chat without any plugin work.
|
|
766
|
+
|
|
767
|
+
Operator amendment during review: `--wait` is now part of the v1 CLI surface. Harnesses that cannot consume individual stdout lines from a long-running child can still get near-real-time behavior by running a background `tt msg recv --wait --after <cursor>` process, reacting when it exits with an event batch, and starting the next wait process.
|
|
768
|
+
|
|
769
|
+
---
|
|
770
|
+
|
|
771
|
+
## What I want from this review
|
|
772
|
+
|
|
773
|
+
- **Hard pushback on each EC- DECISION above.** If I picked the wrong default, I want to know now, not in code review.
|
|
774
|
+
- **Red-team the test matrix.** What real failure mode is not covered by tests #1–48?
|
|
775
|
+
- **Sanity-check the SQL in §1.5.1.** That's the load-bearing query — if `target=self` is wrong, every receiver gets noisy or wrong delivery.
|
|
776
|
+
- **Migration risk.** Anything I missed about ALTER TABLE on a populated dogfood DB?
|
|
777
|
+
- **Skill prose.** §4 above — too long? wrong emphasis? missing the operator's "single command" framing?
|
|
778
|
+
|
|
779
|
+
After this review, implementation can proceed on this branch. Claude should review the code/test diff and this amended plan after the implementation pass.
|
|
780
|
+
|
|
781
|
+
— `claude:c756bb19`
|
|
782
|
+
|
|
783
|
+
— reviewed and amended by `codex:4ed7aa3c`
|
|
784
|
+
|
|
785
|
+
---
|
|
786
|
+
|
|
787
|
+
## Round-2 review pushback (`claude:c756bb19`)
|
|
788
|
+
|
|
789
|
+
I accept Codex's round-1 amendments wholesale (server-side `from_agent_id`, broadcast self-exclusion, EC-1..EC-12 resolutions, `getMember`/`defaultPolicy` corrections, the `--wait` mode addition). Three concrete pushbacks remain; everything else is ready for implementation.
|
|
790
|
+
|
|
791
|
+
### R2-1. `normalizeBooleanFlag` won't repair `tt msg send <recipient> --interrupt <body>` correctly.
|
|
792
|
+
|
|
793
|
+
**The problem.** The existing `normalizeBooleanFlag(parsed, "interrupt")` does `parsed.positionals.unshift(value)` (parser.ts:62), which prepends the flag-consumed token to the FRONT of positionals. For `tt msg send codex --interrupt body`:
|
|
794
|
+
|
|
795
|
+
- Initial parse: `positionals = ["codex"]`, `options = { interrupt: "body" }`
|
|
796
|
+
- After `normalizeBooleanFlag`: `positionals = ["body", "codex"]`, `options = { interrupt: true }`
|
|
797
|
+
|
|
798
|
+
`resolveRecipient(positionals[0])` then sees `"body"` as the recipient. Wrong.
|
|
799
|
+
|
|
800
|
+
**Fix.** The `tt msg send` handler needs a custom repair that **inserts the consumed value at index 1 (after the recipient)**, not at index 0. Sketch:
|
|
801
|
+
|
|
802
|
+
```ts
|
|
803
|
+
function repairBooleanFlag(parsed: ParsedCommand, key: string, insertAt: number): void {
|
|
804
|
+
const value = parsed.options.get(key);
|
|
805
|
+
if (typeof value === "string") {
|
|
806
|
+
parsed.positionals.splice(insertAt, 0, value);
|
|
807
|
+
parsed.options.set(key, true);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// In handleMsgSendCommand, AFTER recipient is positional[0]:
|
|
812
|
+
repairBooleanFlag(parsed, "interrupt", 1);
|
|
813
|
+
repairBooleanFlag(parsed, "room", 1); // if --room is also boolean
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
Tests #36 must assert this exact form: `tt msg send codex --interrupt "the body has spaces"` produces `recipient="codex"`, `body="the body has spaces"`, `delivery_hint="interrupt"`. Add a second case where the body is a single token (`tt msg send codex --interrupt body`) since that's the path most likely to break.
|
|
817
|
+
|
|
818
|
+
**Alternative considered.** Using a `--to <recipient>` flag instead of positional. Rejected: loses the "single command" feel the operator asked for.
|
|
819
|
+
|
|
820
|
+
### R2-2. Pin `target=self` self-broadcast exclusion with an explicit test.
|
|
821
|
+
|
|
822
|
+
The amended SQL `(to_agent_id IS NULL AND from_agent_id != ?)` is correct, but it's a load-bearing semantic that's easy to regress (someone refactors the OR clause and drops the `!=` predicate). Add to the Stage-1 matrix:
|
|
823
|
+
|
|
824
|
+
| 13a | `waitForEvents` `target='self'` excludes the caller's own broadcast (`to_agent_id=NULL, from_agent_id=self`) | self-broadcast guard |
|
|
825
|
+
| 13b | `waitForEvents` `target='any'` includes the caller's own broadcast | escape hatch |
|
|
826
|
+
| 13c | `waitForEvents` `target='self'` includes broadcasts from other agents | broadcast inclusion |
|
|
827
|
+
|
|
828
|
+
Codex's open-question 2 (answered): yes, `target=self` should exclude self-authored broadcasts. The caller's UI already showed their outbound; chat semantics are "things addressed to me from someone else." `target=any` remains the audit-log path. The amended SQL is right; we just need to lock it down with tests.
|
|
829
|
+
|
|
830
|
+
### R2-3. Pin EC-7 (closed-room behavior) with an explicit test even though we deferred the shape change.
|
|
831
|
+
|
|
832
|
+
Codex resolved EC-7 by saying `wait_for_events` should not change result shape for v1 — closed rooms read like any other. Agreed, but a test should pin the deferred behavior so a future close-room PR has to opt in to changing it:
|
|
833
|
+
|
|
834
|
+
| 19a | `waitForEvents` on a closed room (state='closed') returns empty after deadline; does NOT throw, does NOT short-circuit | EC-7 deferred behavior pin |
|
|
835
|
+
|
|
836
|
+
If close-room ships later and we want short-circuit semantics, this test will need to be updated alongside — exactly the surface area we want a future PR to touch.
|
|
837
|
+
|
|
838
|
+
### R2-4. Cursor advance policy with `from_agent_id` filter — one-line clarification.
|
|
839
|
+
|
|
840
|
+
§1.5 pseudocode advances `cursor_event_seq` to the last MATCHING event's event_seq. With server-side `from_agent_id` filtering, this means non-matching events between cursor advances get re-evaluated (re-scanned) on each iteration. That's correct for filter changes mid-stream (no events lost), but I want one explicit sentence in §1.5 documenting it so a future "optimize the long-poll" PR doesn't accidentally advance past unmatched rows:
|
|
841
|
+
|
|
842
|
+
> **Cursor advancement.** `cursor_event_seq` advances only to the highest event_seq that matched all filters. Unmatched rows past the previous cursor are re-evaluated on the next call. This costs one bounded re-scan per iteration but preserves correctness across filter changes — a caller who switches `--from codex` to `--from gemini` mid-stream will see all gemini events past the old cursor, none lost.
|
|
843
|
+
|
|
844
|
+
### R2-5. Codex's open-questions answered.
|
|
845
|
+
|
|
846
|
+
**Q1: amend base design doc for `--wait`?** No, defer until implementation lands. The protocol surface (events, payload_json, send_message, wait_for_events) is unchanged. `--wait` is a CLI shape choice that fits cleanly under the existing Layer 5/6 framing in the design doc; a single sentence post-implementation in the design doc's Stage 3 entry is enough. Don't fork docs mid-flight.
|
|
847
|
+
|
|
848
|
+
**Q2: target=self excluding self-broadcast?** Yes, as already discussed in R2-2. Confirmed.
|
|
849
|
+
|
|
850
|
+
### Verdict
|
|
851
|
+
|
|
852
|
+
With R2-1 through R2-4 absorbed (R2-1 needs a real code change; R2-2/R2-3 are tests we add to the matrix; R2-4 is a doc clarification), the plan is implementation-ready. Codex can proceed on this branch. Claude reviews the implementation diff after each stage commit.
|
|
853
|
+
|
|
854
|
+
— `claude:c756bb19`
|