@joe-broadhead/opencode-messaging 0.0.0-stage → 0.3.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.
@@ -0,0 +1,401 @@
1
+ # Architecture
2
+
3
+ ```
4
+ OpenCode 2.2 (one plugin instance per location)
5
+ │ prompt hook ─────────┐
6
+ │ session.execution.* ─┤ ┌──────────── Runtime ────────────┐
7
+ │ tools.messaging.* ──────┼──────► │ sessions: key → state, address │
8
+ │ │ │ tool ops · inbound queue/session │
9
+ │ ◄── session.prompt ──┤ └──────────────┬──────────────────┘
10
+ │ ◄── session.synthetic┘ │ DaemonConnection (only @joe-broadhead/messaging/ipc user)
11
+ │ ▼
12
+ │ $MESSAGING_HOME/run/agent.sock (JSON-RPC NDJSON; the TUI: run/admin.sock)
13
+ │ │
14
+ └───────────────────────────────────────── messagingd ══ relay ══ teammates
15
+ ```
16
+
17
+ ## Source layout
18
+
19
+ Each layer has one job and a fixed set of allowed imports. `test/architecture.test.ts` parses
20
+ the TypeScript AST of `src/` and, when it exists, `dist/`, and fails on a violation.
21
+
22
+ ```
23
+ src/
24
+ index.ts Plugin.define: options → runtime → host surfaces → cleanup
25
+ tui.tsx human console (`./tui`): /messaging registration and the secret dialog. Not an
26
+ agent tool. Compiled to dist/tui.js by scripts/build-tui.mjs (Babel).
27
+ rpc.ts public RPC contract (`./rpc`): Rpc.define + Zod, `wake` only
28
+ runtime.ts orchestrator facade: wires the link to sessions and delivery; tool operations
29
+ runtime/
30
+ sessions.ts session registry, lazy registration, busy/idle, re-registration
31
+ delivery.ts inbound mail, wakes and their budget, held notes, mid-turn notes, setup note, the wake bridge
32
+ connection.ts the daemon link: connect, autostart hook, reconnect, agent-method allowlist
33
+ host/ everything that imports @opencode/plugin
34
+ context.ts host types derived once
35
+ tools.ts namespace + tool adapters: validate, local checks, assert, runtime, render
36
+ permissions.ts assert request builders; default rules (send ask, shell asks, home denies, DM read ask) via an agent transform
37
+ peers.ts peer sessions (find or create, stored rules, quarantine folder) and the guard's evaluate and context hooks
38
+ sessions.ts prompt hook, execution- and tool-event follower, HostBridge (prompt, note, workspace, peer sessions)
39
+ midturn.ts tool hooks that put held notes into the next model-facing tool result
40
+ skills.ts the opencode-messaging skill
41
+ rpc.ts the RPC implementation
42
+ domain/ pure logic
43
+ contract.ts Zod input/output schemas for every tool
44
+ config.ts strict options schema and defaults
45
+ framing.ts the untrusted-content fence
46
+ present.ts daemon results → framed tool outputs
47
+ inbound.ts delivery decision, wake prompts, notices, task notes, the mid-turn note
48
+ peers.ts peer-turn policy: the guard's verdicts, stored rules, tool allowlist, session marker
49
+ setup.ts the setup note for a machine that has not joined a team
50
+ managed.ts Messaging by Jelly: the pinned realm, and the check of its messaging.json
51
+ budget.ts per-peer wake budget (sliding window)
52
+ naming.ts keys, session names, addresses, ids
53
+ records.ts compact (drop undefined members)
54
+ errors.ts, version.ts
55
+ process/ the only spawn sites
56
+ daemon.ts resolve and start messagingd; the identity.json presence check
57
+ clipboard.ts copy a card, or a ticket on request, for the TUI (stdin, never argv, 2 s cap)
58
+ managed.ts a home's state, the one config.json realm write, the messaging.json fetch (TUI only)
59
+ tui/ the TUI's helpers, compiled to dist/tui/
60
+ link.ts the console's one daemon connection, its chat subscription and follow()
61
+ flows.ts the console's screens and team flows, over a dialog port (no JSX)
62
+ server.ts the console's configuration, from the server part's RPC `config`
63
+ managed.ts Use Messaging by Jelly: check, write the realm, start the daemon, then Devices
64
+ console.ts terminal-safe text: chat lines, person and room titles, team names
65
+ errors.ts every error as `<code>: <what happened>. Next: <step>.`
66
+ people.ts People: search, a person's page, their grant's switches (grant.*), the review list
67
+ requests.ts Requests: request.list and request.answer
68
+ devices.ts Devices: device.list, link this device, approve a new device, remove this device
69
+ notices.ts Notices: notice.list and notice.ack, and the fixed text of toasts
70
+ status.ts This device and /messaging-status
71
+ qr.ts terminal QR codes
72
+ skills/opencode-messaging/SKILL.md
73
+ ```
74
+
75
+ | Layer | May import |
76
+ |---|---|
77
+ | `index.ts` | everything in this package |
78
+ | `rpc.ts` | `@opencode/plugin/rpc`, `zod` |
79
+ | `host/` | `@opencode/plugin`, `domain/`, `runtime.ts`, `rpc.ts` |
80
+ | `runtime.ts`, `runtime/` | `runtime/`, `domain/`, `connection.ts`, `process/`, `@joe-broadhead/messaging/core` |
81
+ | `connection.ts` | `domain/`, `@joe-broadhead/messaging/core`, `@joe-broadhead/messaging/ipc` |
82
+ | `process/` | `domain/`, Node built-ins (the only `node:child_process` user) |
83
+ | `domain/` | `domain/`, `zod`, `@joe-broadhead/messaging/core` (schemas only), `node:crypto`, `node:path` |
84
+ | `tui` | `tui/`, `process/`, `domain/`, `@joe-broadhead/messaging/core`, `@joe-broadhead/messaging/ipc`, `uqr`, the host's `@opentui/solid` and `solid-js`, and `@opencode/plugin/tui` for types only; calls the human and admin tiers (`HUMAN_METHODS`, `ADMIN_METHODS`: `conv.*`, `person.send`, `people.*`, `grant.*`, `request.*`, `notice.*`, `device.*`, `channel.*`, `team.*`) on the admin socket. Everything else calls only `AGENT_METHODS`. |
85
+
86
+ The plugin uses `@joe-broadhead/messaging/core` schemas and types only, never its protocol functions, so it does not
87
+ depend on the core lane's implementation. Nothing imports `node:net`; only `@joe-broadhead/messaging/ipc` speaks
88
+ the socket protocol.
89
+
90
+ ## Lifecycle
91
+
92
+ - **Setup** (per location) does local work only:
93
+ 1. validates the options;
94
+ 2. builds the runtime;
95
+ 3. registers the agent transform, the tools, the skill, the RPC, the prompt hook, and the
96
+ execution-event follower.
97
+
98
+ It never connects to the daemon, so it cannot fail because the daemon is missing.
99
+ - **Cleanup** disposes every registration and stops the event follower. It then closes the daemon
100
+ link and waits up to 1 s for in-flight inbound work. It does not unregister sessions.
101
+ - OpenCode evicts idle location instances after about an hour and reloads plugins when the config
102
+ is saved. Both run cleanup. The next prompt re-registers, and a catch-up note covers mail that
103
+ arrived in between. The new instance's wake budget starts empty.
104
+ - After a build or a new tarball, restart OpenCode, both the server and the TUI: the package is
105
+ its bundles (`dist/`), and a running host keeps the modules it has loaded.
106
+
107
+ ## The daemon link
108
+
109
+ `DaemonConnection` holds at most one `MessagingClient`.
110
+
111
+ - `link()` is single-flight. On `daemon_unavailable`, an on-demand caller (prompt or tool) runs
112
+ the autostart hook: choose the daemon (`resolveDaemon`: `daemon.bin`, `daemon.node`, the platform
113
+ package's executable, else `dist/messagingd.js` under Node 22.15 or later), spawn, then poll the
114
+ endpoint every 100 ms for up to 8 s.
115
+ - The connect timeout is 5 s. A missing daemon fails at once (`ENOENT`, `ECONNREFUSED`), and a
116
+ live one connects in the kernel at once, so the timeout mostly measures this process's own
117
+ event loop. A 1 s timeout turned a busy host into a false `daemon_unavailable`: Node runs the
118
+ expired timer before the completed connect. That would trigger a needless autostart, or leave a
119
+ session unregistered.
120
+ - After the first successful connection, a drop schedules background reconnects with jittered
121
+ exponential backoff (0.5 s to 30 s). These never autostart.
122
+ - Every new connection runs `onConnected` before it is handed out. The runtime then re-registers
123
+ every session it had registered, asking for the same name. It subscribes all their keys with one
124
+ `events.subscribe` call and queues a catch-up for each.
125
+ - `Link.call` refuses any method not in `AGENT_METHODS`. It turns `MessagingError` and JSON-RPC
126
+ errors into code-prefixed tool errors (`daemon_unavailable: …`).
127
+
128
+ ## The `/messaging` console
129
+
130
+ The TUI part is a separate entry (`./tui`) with its own connection, `ConsoleLink`
131
+ (`src/tui/link.ts`), on the admin socket. It registers no session and never calls `inbox.*`:
132
+ people's chats are the daemon's DMs and channels (spec §6.8), which need no session key, and an
133
+ agent conversation (`agent:…`), which needs one, is not shown.
134
+
135
+ - **Options.** OpenCode 2.2 gives the TUI part no options, so it never reads `context.options` or
136
+ resolves a home itself. `tui/server.ts` asks the plugin's server part over the RPC (`config`: the
137
+ resolved home and the daemon options), validates the answer with `loadConfig`, and only then makes
138
+ the link and the console. Without an answer, `/messaging` and `/messaging-status` say
139
+ `server_unavailable` and nothing connects, starts or writes; the next command asks again.
140
+ - **Follow.** There is no badge and no poll. At setup, and after a team create, join or finished
141
+ pairing, `ConsoleLink.follow()` connects once, only if `identity.json` exists, and calls
142
+ `notice.subscribe` (without lines).
143
+ When the connection drops it reconnects with jittered backoff (0.5 s doubling to 30 s, unref'd)
144
+ while the file exists. Following never starts the daemon.
145
+ - **Starting the daemon.** Every flow collects and checks its input first (a ticket must start
146
+ with `messaging1.`, a team card must decode), and Esc at any prompt ends it. Then create, join and
147
+ pair call `client({ start: true })`; every other action starts the daemon only on a machine that
148
+ has joined a team. Use Messaging by Jelly starts it once the realm is written (a fresh home chooses
149
+ between it and a self-hosted team first; nothing is written before the choice). Finish join never starts it. With `daemon.autostart: false` nothing starts it
150
+ and the message says to run `messaging daemon start`. The start uses `process/daemon.ts`, the
151
+ plugin's single implementation.
152
+ - **Notices.** Each new connection of a device with an identity calls `notice.subscribe` once,
153
+ after the last notice it saw (`after`). A `notice` (a line from someone else or from the person's
154
+ own agents, a request, an alarm) becomes a toast and a notification for a blurred terminal in
155
+ fixed text with the sender's cleaned name, never a line. Of a replay, only the newest shows;
156
+ repeated ids are dropped.
157
+ - **Screens.** People (`people.search`, `people.get`, `grant.list`, `grant.set.preview` then
158
+ `grant.set` with the whole merged grant), Requests (`request.list`, narrowed by `kind`: asks, message requests, suggestions; `request.answer`
159
+ with `once|always|decline|block`, `accept|decline|block`, or `decline` to dismiss), message requests from People
160
+ (`contact.request.preview` then `contact.request`, hosted realm only) and cards (`reach` from `contacts.list`,
161
+ `card.get`), Chats (DMs
162
+ and Me from `conv.list`, Channels, Search with `search`), Devices (`device.status`,
163
+ `device.list`; in a directory realm `device.link`, `device.approve.preview` then `device.approve`
164
+ with the code the person typed, `device.remove.preview` then `device.remove` for this device),
165
+ Notices (`notice.list`, `notice.ack`), Team and This device; `/messaging-status` prints
166
+ `daemon.status`, `daemon.health` and `device.status` in fixed text and never starts the daemon.
167
+ A room shows its latest 40 lines from `conv.read` (`kind: "lines"`), with delivery state on your
168
+ own lines and notices as system lines; picking a line replies in its thread (`person.send` with
169
+ the thread's root id), and "Write a message" starts a new one. Prepare to create calls `team.prepare` and
170
+ shows the machine id (copied only on request); Discard pending keys calls `team.pairCancel`
171
+ after a confirm. The daemon allows one purpose for pending keys at a time, and the console turns
172
+ its refusal into "choose Discard pending keys first". Finish join polls `team.claim` in 5 s steps for
173
+ up to 2 minutes behind a notice that stops the wait when closed.
174
+ - **Secrets.** Team and admit cards are public and are copied as shown. An invite ticket is copied
175
+ only when the person picks "Show the ticket and copy it". The clipboard helper kills a copier
176
+ that has not finished within 2 s.
177
+ - **Cleanup** stops following, closes the connection, and makes a running flow stop at its next step.
178
+
179
+ ## Before the machine has joined a team
180
+
181
+ `hasIdentity` (`process/daemon.ts`) checks that `<home>/identity.json` is a regular file, with a
182
+ `stat`. It never opens the file: no key material enters the OpenCode process, and the daemon,
183
+ which reads the file, judges its contents. A team create or join writes the file, and a daemon
184
+ started without one refuses every agent method with `no_identity`.
185
+
186
+ - `ensure` (registration for prompts, tools and retries) checks the file before it connects. With
187
+ no file it throws `no_identity` at once: no connection, no autostart, no `session.register`. A
188
+ prompt only logs it, and a background retry follows `daemon_unavailable` only, so nothing loops.
189
+ - The check runs on every call that is not yet registered, so a team joined from a terminal is
190
+ picked up by the next prompt or tool call, which then autostarts the daemon if needed.
191
+
192
+ **Setup note.** When a tool call gets `no_identity` (from the file check or from the daemon), the
193
+ runtime marks the session's setup note `held`. Prompts never do. Held, it waits for the session
194
+ to be idle like any other note, then posts once with `session.synthetic` (`resume: false`,
195
+ `delivery: "queue"`, metadata `delivery: "setup"`) and id `msg_messaging_s_<digest of sessionID>`.
196
+ It becomes `done` whether or not the post succeeds, and also when the session registers after all
197
+ (the user joined during the turn), so it is never posted stale or twice. The host keeps the first
198
+ message with a given id, so a reloaded plugin cannot add a second note to the session either.
199
+
200
+ The note goes to the transcript as well as the tool error going to the model. The error alone
201
+ reaches the model, but in Code Mode a script decides what to do with it, and the model may
202
+ paraphrase it. The note gives the user the exact commands, with `--home` when needed, and warns
203
+ that the invite ticket belongs in the terminal and not in the chat.
204
+
205
+ ## Sessions
206
+
207
+ - **Key:** `opencode:<sessionID>`.
208
+ - **Name:** `session.name`, or the slug of the location directory's basename as the fallback.
209
+ - **Peer sessions** (`host/peers.ts`) never register. The `prompt` hook skips a prompt whose
210
+ metadata says `turn: "peer"` and any session whose metadata marks it as a peer session; a tool
211
+ call from one uses its working session's key and link, scoped to the peer unless its latest wake
212
+ was trusted (every item `consent: "trusted"`, the daemon's decision; in memory only, so a reload
213
+ guards every peer session again).
214
+ - **Agent:** `opencode`.
215
+ - **Workspace:** `cwd` is the location. `repo` is the project's canonical directory basename,
216
+ sent only when OpenCode reports a VCS. `branch` comes from `vcs.get()` with a 1 s timeout and is
217
+ cached for 30 s.
218
+ - **Tier:** `push` when `inbound.wake` is on, else `pull`.
219
+ - **Host:** with wakes on, `session.register` includes `host: {kind: "opencode", location, sessionID, pid}`
220
+ so the daemon can reload this plugin for wake mail (spec §8.6). A location that is not an absolute
221
+ path is omitted, and registration still succeeds. The daemon's call is `messaging.opencode/wake`. The
222
+ handler checks the session belongs to this location (`session.get`), registers without starting the
223
+ daemon, fetches each named item with `inbox.get`, and decides it on the same inbound path as a live
224
+ event. Repeated and stale ids are no-ops. A session this process has not seen is treated as idle
225
+ until a prompt or execution event says otherwise.
226
+
227
+ Registration is lazy: the first prompt (through the `prompt` hook, which never changes the
228
+ prompt) or the first Messaging tool call, once the machine has joined a team. It is single-flight per session, retries once across a
229
+ reconnect, and is followed by `events.subscribe` for the key. A tool call reports a failure to the
230
+ model. A registration started by the `prompt` hook has nobody to report to. If it fails with
231
+ `daemon_unavailable`, the runtime retries it in the background with the connection's backoff
232
+ (0.5 s doubling to 30 s), never starting the daemon, until the session registers. One way this
233
+ happens: the connect timed out because the host's event loop was too busy to see it complete.
234
+
235
+ Busy/idle follows `session.execution.started` (busy) and `…succeeded | failed | interrupted`
236
+ (idle). OpenCode 2.2 does not emit `session.status` or `session.idle`; the real-host suite
237
+ confirmed this. A change made while `session.register` is in flight is sent with `session.update`
238
+ once registration completes. A successful wake into the working session marks it busy; a wake into
239
+ a peer session leaves the working session's state alone.
240
+
241
+ A prompt overrides the events until the host confirms the prompt's work has ended. The `prompt`
242
+ hook runs before the host starts the prompt's execution, and the events carry no execution id. An
243
+ execution that ends in between therefore looks like the end of this prompt's work: an earlier
244
+ execution settling, or one the host resumed when `opencode run --session` started. In the live
245
+ re-run, that idle blip let a notice be queued for an item that the turn then read, and the model
246
+ received it as new input after its answer. So, after a prompt hook:
247
+
248
+ - the session stays busy, and execution events are held;
249
+ - after 1 s, enough for the host to start the prompt's execution, the runtime calls
250
+ `session.wait`, which resolves once no execution is running;
251
+ - if another prompt arrived meanwhile, it waits again;
252
+ - then the session is idle.
253
+
254
+ If `session.wait` fails, the last held event decides.
255
+
256
+ ## Inbound flow
257
+
258
+ Events are handled one at a time per session, in order. Envelope ids already handled in this
259
+ process (by an event, a catch-up or an `inbox` listing) are skipped.
260
+
261
+ 1. **`inbox` event, stage `delivered`.**
262
+
263
+ | Condition | What the plugin does |
264
+ |---|---|
265
+ | wake: the item's `wake`, `inbound.wake`, the session is idle, and the sender has wake budget | Add the item to the session's 2 s coalescing window (see below). |
266
+ | wake, but the sender is over its wake budget | As notify. |
267
+ | notify: `inbound.notify` | Hold the item for a note (see below). |
268
+ | otherwise | Nothing; the agent reads with `inbox`. |
269
+
270
+ **Wake budget.** `WakeBudget` (`domain/budget.ts`) gives each sender person
271
+ `inbound.wakeBudget.perPeer` wakes per sliding `windowMs` (default 6 per hour), shared by all
272
+ sessions of the process. Keying by person and not by address stops a sender from multiplying its
273
+ budget by opening sessions. A sender is charged once, when its first item enters a window; later
274
+ items from that person in the same window join for free.
275
+
276
+ **Coalescing window.** The first admitted wake opens a 2 s window for the session, and later
277
+ admitted wakes join it. When the window closes:
278
+
279
+ | Condition | What the plugin does |
280
+ |---|---|
281
+ | the session is busy, or its link changed | The items are held for notes (when `notify` is on). |
282
+ | otherwise | The items are grouped by sender person. Items from this machine's own person go to the working session. Each other person's items go to their peer session (`HostBridge.peerSession`, see "Peer turns" in security.md), which the host layer finds or creates; if that fails, they get notices in the working session. Each group then gets one wake as below, in its target session; the working session is marked busy only for its own wake. |
283
+ | one item | 1. `inbox.get {message: true}`, for the full message and its provenance, with no stage change.<br>2. `session.prompt` with id `msg_messaging_w_<envelopeId>` (idempotent), `delivery: "queue"`, metadata `{source, delivery: "wake", turn: "own" or "peer", peer, envelopeId, kind, taskId}`, and the text: a plugin line, the peer-turn line in a guarded peer session, then the preamble and the framed message.<br>3. Once the host admitted the prompt: `inbox.ack {stage: "handed_to_agent"}`, then `inbox.ack {stage: "read"}`. |
284
+ | several items | 1. One `session.prompt` with id `msg_messaging_w_<digest of the ids>`, metadata with `count`, `envelopeIds` and `taskIds`: a plugin line, the peer-turn line in a guarded peer session, the preamble, and a framed preview per item (oldest first, at most 10 shown). Nothing is read for the model.<br>2. Once the host admitted it: `inbox.ack {stage: "handed_to_agent"}` for each. |
285
+ | the prompt fails | Post a notice per item instead, and ack each `handed_to_agent` after its notice. |
286
+
287
+ Nothing changes an item's stage before the host has admitted the prompt (spec §8.2): a crash or a
288
+ refused prompt in between leaves the item at `delivered`, and the next catch-up shows it. The
289
+ cost is the other way round: a crash after the prompt and before the acks leaves an item that was
290
+ shown at `delivered`, so a catch-up note may repeat it.
291
+ 2. **`task` event with `origin: "peer"`.** A note for a requester task that reached a terminal or
292
+ interrupted state, or an executor task that the requester cancelled. `origin: "local"` events
293
+ (this session's own updates) are ignored. When a requested task ends, the note also lists the
294
+ session's follow-ups (`TaskRecord.followUps`, from the daemon's receipts) that did not reach
295
+ `read`: envelope id, send time, stage, and a fenced excerpt of at most 200 characters from the
296
+ task history; or one line saying all were read. Task notes are held like inbox notes until the
297
+ session is idle (or go into a tool result, see "Mid-turn notes"); the latest change per task wins. A held note is dropped if the agent has
298
+ looked at that task since the change (`task`, `tasks`, `task_update` or `task_cancel`).
299
+ 3. **Catch-up** after (re)registration. `inbox.list {unreadOnly: true}`; every item still at
300
+ `delivered` and not yet handled is held for a note.
301
+
302
+ **Held notes.** Notices and catch-up notes are posted only while the session is idle. A note queued
303
+ into a running turn reaches the model after a later step, when the model may already have read the
304
+ item (the live test showed this: a stale note and an extra step). While the session is busy,
305
+ items wait: the next model-facing tool result carries them (see "Mid-turn notes" below), or else
306
+ the next `session.execution.*` idle event posts them. Immediately before posting, the
307
+ plugin asks the daemon again: `inbox.get` for one item, `inbox.list {unreadOnly: true}` for
308
+ several. Items no longer at `delivered` are dropped: they were read, or the `inbox` tool showed
309
+ them. If nothing is left, no note is posted. Otherwise:
310
+
311
+ - one item that arrived during a turn gets a notice: `session.synthetic` with id
312
+ `msg_messaging_n_<envelopeId>`, `resume: false`, `delivery: "queue"`, and a framed preview;
313
+ - anything else gets one note (oldest first, at most 10 shown), with `delivery: "catch-up"` in its
314
+ metadata.
315
+
316
+ Each posted item is then acked `handed_to_agent`. If the re-check fails, the items stay held for
317
+ the next idle.
318
+
319
+ **Mid-turn notes.** With `inbound.notify` and `inbound.midTurn` on, held items and held task
320
+ notes go into the result of the busy session's next tool call instead of waiting for the turn to
321
+ end. They are put inside a tool result, not posted as a separate message, so they cannot arrive
322
+ after the model has moved on: the model reads them with the output of the call it just made.
323
+ `test/host/after-hook.test.ts` pins what OpenCode 2.2 does; `host/midturn.ts` builds on it:
324
+
325
+ 1. `tool` hooks `execute.before` and `execute.after` count open calls per call id. Code Mode runs
326
+ its nested calls through the same hooks with the outer `execute` call's id, and their results go
327
+ back to the script, not to the model. Only the `execute.after` that closes the last open
328
+ `execute.before` of an id is a model-facing result. A call that started before the plugin loaded
329
+ is unknown and is left alone.
330
+ 2. A completed model-facing call that ran no Messaging tool (`messaging_*`, direct or nested) asks
331
+ `runtime.offer`. A Messaging tool's output is already Messaging's, and a note beside it could be read
332
+ as part of that output.
333
+ 3. `offer` takes every held item and task note at once (a parallel call of the same step finds
334
+ none), asks the daemon again as the idle path does (at most 1 s; on failure nothing is offered
335
+ and the notes stay held), and drops items no longer at `delivered`. It renders one note, oldest
336
+ item first, then task notes: at most 5 entries and 7,168 characters, always at least one; the
337
+ rest stay held and the note says how many. The entries are now *offered* under the call id.
338
+ 4. The hook appends the note to the result as its own text part (after the content; a result with
339
+ only structured output is first rendered as its JSON text, as the host would). The note starts
340
+ with a blank line and a plugin line that says it is not part of the tool output; previews are
341
+ fenced as in a catch-up note.
342
+ 5. The host then cuts the result to its output limits (keeping the head) and records it:
343
+ `session.tool.success` carries the recorded content per call id. If that content holds the whole
344
+ note, the items are acked `handed_to_agent` and the tasks count as seen. If the note was cut, the
345
+ call failed (`session.tool.failed`), or no event comes within 30 s, the entries go back to held,
346
+ for the next tool call or the next idle. An entry is never acked unless the model got it, so a
347
+ lost event can show an item twice but never hides one.
348
+
349
+ The hook never throws: a failure leaves the result as the tool returned it. The setup note is
350
+ never put into a tool result; it is for the user.
351
+
352
+ The `inbox` tool, and a `read({ with })` page of an agent's mail, acknowledge the previews they show
353
+ as `handed_to_agent`. `read({ id })` of a message uses `item.read`, which marks the item read. A
354
+ `send` with `replyTo` finds the message with `inbox.get`, which changes nothing.
355
+
356
+ ## Tools
357
+
358
+ The eight tools and their schemas are `AgentTool` in `@joe-broadhead/messaging/core` (spec §11), shared with
359
+ `messaging-mcp`; `runtime/operations.ts` runs them. Each adapter in `host/tools.ts` follows the same
360
+ steps:
361
+
362
+ 1. Re-validate the input with the contract schema.
363
+ 2. Run local checks that need no permission. For `send` (`planSend`), this is address parsing
364
+ (`parseToolAddress`), the guarded-turn allowance, what may go to whom, plus, with `replyTo`, an
365
+ `inbox.get` (the message must be from `to`) or a `task.get` (this session must be the requester
366
+ and the task's peer `to`). With `onBehalfOf`, it is a `task.get` that requires this session to be
367
+ the executor of a live task. For `task_update` and `task_cancel`, it is a `task.get` plus the
368
+ role, terminal-state and transition checks.
369
+ 3. Call `permission.assert`. `read` asserts the conversation: for `{ with }` the one named, for
370
+ `{ id }` the item's, found with `inbox.get` or `task.get`, neither of which change
371
+ anything.
372
+ 4. Call the runtime. `search` without `with` asserts after the daemon call instead: one assert per
373
+ conversation in the hits, keeping the hits of each that passes
374
+ ([security](security.md#chats-lines-posts-conversations-and-search)).
375
+ 5. Return `{ output, content, metadata }`:
376
+ - `output` matches the output schema, and Code Mode receives it as an object;
377
+ - `content` is compact JSON, capped at 128 KiB;
378
+ - `metadata` has the tool, small facts and the duration.
379
+
380
+ ## Testing
381
+
382
+ | Layer | What | Where |
383
+ |---|---|---|
384
+ | Unit | options, names, framing (hostile content), inbound texts, wake budget, permission builders and the default-rule evaluation | `test/{config,naming,framing,inbound,permissions}.test.ts` |
385
+ | Architecture | import boundaries; every agent-side `.call("…")` is in `AGENT_METHODS` and no non-agent method name appears outside the TUI | `test/architecture.test.ts` |
386
+ | Fake host + fake daemon | tool registration, assert action/resource/save, deny propagation with no daemon call, output schemas, the chat tools (room asserts, search per room, post to a DM) | `test/tools.test.ts` |
387
+ | Console | the TUI's link (no daemon start on connect, `notice.subscribe` once, follow, reconnect and resume, autostart off, close), its terminal-safe text and its toasts | `test/console.test.ts` |
388
+ | Console flows | no start before input or on Esc, start rules, ticket and card checks, the ticket only in its dialog and copied on request, empty lists, Finish join, channel remove, notices; the fresh-home choice and Use Messaging by Jelly (exactly the pinned block, a refused messaging.json, never offered or written over), the realm by name in status | `test/tui-flows.test.ts` |
389
+ | Managed realm | the pinned realm is its own `directoryRealmId` and a valid `DirectoryRealmConfig`; messaging.json mismatches refused; home states; the write keeps other fields and never overwrites | `test/managed.test.ts` |
390
+ | TUI build | `build-tui.mjs` output has no JSX and imports only `@opentui/solid`, `@joe-broadhead/messaging/ipc` and compiled modules | `test/tui-build.test.ts` |
391
+ | Fake daemon | lazy registration, reconnect, busy/idle; wake vs note, wake budget and coalescing, held notes, acks, catch-up, task notes, the wake bridge; tool operations; autostart, unavailability, no team identity (no start, `no_identity`, one setup note, a join between calls) | `test/{sessions,delivery,operations,autostart}.test.ts` (shared `test/fixtures/runtime.ts`) |
392
+ | Entrypoint | setup with a fake context, no daemon contact, cleanup, skill and RPC contract, no autostart without a team | `test/plugin.test.ts` |
393
+ | Real host | the embedded OpenCode 2.2.5 with a fake daemon: registries, ask/reject/once/always, configured allow/deny, the chat search and DM post rules, shell asks and home denies, note and wake, wake budget and coalescing, no team identity and a later join; Use Messaging by Jelly with the release's real daemon (a loopback realm: no test dials the deployment) | `test/host/` (Node ≥ 24, separate install) |
394
+ | TUI types and build | `src/tui.tsx` type-checked against `@opencode/plugin/tui` and `@opentui/solid`, then compiled with Babel | `tsconfig.tui.json` and `scripts/build-tui.mjs`, run by `npm run compile` |
395
+ | Packaged TUI | the installed `./tui` export under Bun: setup without and with a team, no slot, one subscription, the invite dialog rendered by `@opentui/solid`, cleanup | `npm run smoke:pack` (`scripts/smoke-tui-probe.mjs`) |
396
+
397
+ The fake daemon (`test/fixtures/fake-daemon.ts`) is `recordingDaemon()` from `@joe-broadhead/messaging/ipc/testing`
398
+ (every call recorded, a method with no handler refused), with minimal state (sessions, inbox, tasks, chat rooms). Its directory is the home, and by
399
+ default it has joined a team (a stand-in `identity.json`). With `joined: false` it has no file,
400
+ answers `daemon.status` without a team and refuses everything else with `no_identity`, until
401
+ `join()`.