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