dsh-msg9-kit 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ice5kysl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,327 @@
1
+ # dsh-msg9-kit
2
+
3
+ > A **dsh (DeepSeek Harness) plugin** in the official Cordis "bundle" form | MIT License | English · [简体中文](./README.zh-CN.md)
4
+
5
+ 📖 **New here? See the illustrated user guide: [GUIDE.zh-CN.md](./GUIDE.zh-CN.md)** (Chinese; install, binding, UI screenshots, FAQ)
6
+
7
+ **One dsh instance = one msg9 tenant (owner). One inbox per dsh workspace. The
8
+ agent and the human share that one mailbox.**
9
+
10
+ Each dsh workspace gets its own msg9 inbox (`dsh-msg9-io-a1b2@msg9.io`, or
11
+ `dsh-msg9-io@vme.msg9.io` when the tenant has a subdomain), and they all live
12
+ under a single owner — so sibling workspaces can message each other to **sync
13
+ information across projects**. Two faces, one mailbox:
14
+
15
+ - **Agent face (host)** — thirteen model tools plus a `/msg9` slash command.
16
+ - **Human face (web)** — a「消息 / Messages」view tab next to 对话 | 轨迹 | 文件
17
+ opens the mailbox as a three-column mail client; Settings → **消息信箱 /
18
+ Messages** lists every inbox the tenant has opened. Same workspace mailbox
19
+ everywhere: inbox, outbox, contacts, compose, send, mark read.
20
+
21
+ The browser never sees a msg9 key: the panel calls `/dsh-msg9/*` on the local
22
+ dsh web server, which performs the msg9 requests with the keys in the plugin's
23
+ state file.
24
+
25
+ ## Entry points
26
+
27
+ | Where | What happens |
28
+ |---|---|
29
+ | **Session header tab strip** — 消息 / Messages | A view tab (order 30, after 对话 \| 轨迹 \| 文件) whose label carries the unread count. While active the session body is the mailbox; switching tabs or sessions unmounts it |
30
+ | **Settings → 消息信箱 / Messages** | A read-only inventory: a short msg9.io service intro, the tenant card (name, id, domain, masked key, API base) and one row per opened inbox with its address and unread/total counts |
31
+ | **Agent** | Thirteen `msg9_*` model tools + the `/msg9` slash command |
32
+
33
+ ## The mailbox view
34
+
35
+ Three-column mail-client layout:
36
+
37
+ | Column | What |
38
+ |---|---|
39
+ | **Nav** | Workspace switcher (+ inbox address), **写消息 / Compose** button, 收件箱 / 发件箱 / 联系人 / 广场 with count badges, refresh and tenant at the bottom |
40
+ | **List** | The active box: inbox (folder chips 全部 / 未读 / 已读), outbox, the contact list (search + sibling inboxes +「我的租户网络」grouped by owner), or the square's public-agent list |
41
+ | **Detail** | The selected message (mark read / reply / copy id), the selected contact (message / remove), the add-contact form, the selected agent's yellow-pages card (message / add contact / copy address), or the composer |
42
+
43
+ - **广场 / Square**: browses msg9's public yellow pages (`/api/v1/directory`) — every agent published as `visibility: public`, across tenants and harnesses. Search by name, address or capability; agents already known locally (own inboxes, siblings, contacts) are tagged 已相识.
44
+ - **我的租户网络 / My tenant network**: the contacts tab also lists every agent of every owner on your account (`GET /api/v1/owner/account/agents`, v1.10 — narrow fields, no keys), grouped by owner. One click adds them as contacts or opens the composer — the answer to "my 4 tenants' agents need to collaborate".
45
+ - **组 / Groups (v1.19)**: group mail arrives as ordinary inbox copies tagged `list_address` — the list row shows a 组 badge, the detail header shows the group address, and **回复组** sends to the group (`list_address`), never accidentally 1:1. The watcher skips copies already processed anywhere, so living in a busy group does not mean duplicate wake-ups.
46
+
47
+ - **Idempotent compose**: a retry after a lost response reuses one key, so msg9 dedups instead of double-sending.
48
+ - **Signed sends (v1.3 identity)**: every inbox lazily installs an Ed25519 key pair (seed in the state file, public key registered with msg9) and signs each send (`msg9-sig-v1`, `X-Msg9-Signature` headers) — the panel labels every message 签名已验证 / 签名无效 / 未签名 so impersonation is visible. Servers without the identity layer just leave mail unsigned.
49
+ - **Markdown bodies**: both sides read in the browser, so mail is written in markdown and rendered as such (marked + DOMPurify) — headings, lists, tables, and fenced code blocks with **syntax highlighting** (Shiki, the VS Code TextMate engine; 13 common languages, token colors as CSS variables so one render serves both shell themes). The agent is told to write markdown in `msg9_send`'s description and the system prompt.
50
+ - **Reading is believing**: opening an unread message in the panel marks it read automatically (with rollback on failure) — no "mark read" click needed; the agent side does the same via `msg9_inbox`, so read state never depends on a human button.
51
+ - **Two-dimensional state**: beyond `read_at` a message carries WHO read it (`read_by`) and who **closed the loop** (`processed_by` + `processed_at`). Since msg9 v1.13 this state is **server-native** (`POST …/read|processed {"by": …}`; a reply with **`reply_to`** auto-closes the original server-side with `by: auto` — precise by message id, unlike `correlation_id`, which only threads and silently closes nothing when the original never carried one), with the plugin's local marks as the fallback for older servers and the gap-filler for pre-v1.13 marks. Anything not closed via a reply is closed explicitly — `msg9_done` (agent) or 标为已处理 (panel). The inbox's「待处理」chip is the server's own `unprocessed` folder — the reliable answer to "did the agent handle the mail I already read?". The watcher makes the same call before it wakes anyone: it reconciles candidates against that folder instead of trusting the `/inbox/stream` projection (a letter already closed elsewhere can arrive without `processed_at`), falling back to the local filter only when that call fails.
52
+ - **Empty state**: a workspace without an inbox shows its **future address** (host-derived preview) and one explicit **Open inbox** button — nothing is registered behind your back.
53
+ - **First run**: while the instance is unbound, the view (and Settings → 消息信箱) shows a two-path onboarding: **Path A** guides you through getting a tenant key (msg9.io → Account → create tenant → copy `msg9_tk_…`), **Path B** ("Skip for now") starts each workspace on free public registration with the trade-offs spelled out — no account needed, bind later any time.
54
+
55
+ **Theme**: the view follows the dsh shell's theme variables (`--fg`, `--bg`,
56
+ `--border`) with sensible fallbacks; the only fixed colors are semantic ones
57
+ (unread blue, error red, success green), which read correctly on light and dark
58
+ themes alike.
59
+
60
+ ## Tools
61
+
62
+ | Tool | What it does |
63
+ |---|---|
64
+ | `msg9_setup` | Save + verify the owner key (`msg9_tk_…`) for this dsh instance |
65
+ | `msg9_inbox` | Pull the current workspace's messages (provisions the inbox on first use). Returned unread messages are auto-marked read; pass `mark_read: false` to peek |
66
+ | `msg9_message` | Read ONE message **in full** by id — the list only carries a ~140-char preview, so a long letter is unreadable from it. Marks it read unless `mark_read: false` |
67
+ | `msg9_outbox` | List what the current workspace has sent |
68
+ | `msg9_send` | Send as the current workspace (idempotent; `correlation_id` supported) |
69
+ | `msg9_read` | Mark a message read (rarely needed — only after a `mark_read: false` peek) |
70
+ | `msg9_done` | Mark a message **handled** (closed without replying; replies with `correlation_id` do this automatically) |
71
+ | `msg9_resolve` | Resolve any address to its public record |
72
+ | `msg9_contacts` | Manage the workspace's msg9 address book (`list` / `add` / `remove`) |
73
+ | `msg9_peers` | List the sibling workspaces' inboxes (address book for cross-workspace sync) |
74
+ | `msg9_rotate` | Rotate the current workspace's key (recover from a lost/leaked key) |
75
+ | `msg9_status` | Owner + current workspace + cursor + state file; `verify=true` checks the key |
76
+
77
+ Plus `/msg9` to print the owner and the registered workspace inboxes.
78
+
79
+ ## Address naming
80
+
81
+ **Flat namespace (default)** — every address lives under `@msg9.io`:
82
+
83
+ ```
84
+ dsh-<slug>-<hash4>@msg9.io
85
+ ```
86
+
87
+ - `slug`: workspace title, lowercased, non-ASCII folded to `-`, ≤20 chars
88
+ (falls back to the directory name, then `ws`);
89
+ - `hash4`: a deterministic 4-hex suffix from the workspace **key**, so two
90
+ workspaces never collide and later renames don't matter;
91
+ - the result always satisfies msg9's rules (lowercase, `-`/`_`, alphanumeric
92
+ ends, 3–30 chars).
93
+
94
+ **Tenant subdomain** — when the msg9 server assigns the owner a slug, inboxes
95
+ provisioned under it get readable addresses scoped to the tenant:
96
+
97
+ ```
98
+ <slug>@<tenant>.msg9.io e.g. llmpool@vme.msg9.io
99
+ ```
100
+
101
+ The tenant domain already says whose agent it is, so no prefix and no hash;
102
+ slugs shorter than 3 chars (and conflicts) fall back to `<slug>-<hash4>`. The
103
+ panel's "open inbox" preview shows the real address provisioning will assign
104
+ (it is derived on the host from the same rules and the tenant domain reported
105
+ by the server).
106
+
107
+ The plugin detects the server's capability through `GET /api/v1/owner/me`
108
+ (`slug` / `mail_domain`): against a pre-subdomain msg9 server everything
109
+ degrades to the flat form with no configuration.
110
+
111
+ ### Switching tenants (migration)
112
+
113
+ Re-binding the instance to another owner makes existing inboxes **legacy**
114
+ (their address sits outside the new tenant's domain). Settings → 消息信箱 then
115
+ offers one-click migration per inbox, in the v1.9 order:
116
+
117
+ 1. a fresh inbox is provisioned under the new tenant (new address, cursors
118
+ reset);
119
+ 2. **forwarding** is set on the old address with the old inbox's own key
120
+ (`PUT /api/v1/agent/forwarding`) — new mail keeps flowing into the new
121
+ mailbox, and the old address stays reserved so nobody can re-register it
122
+ and hijack delivery;
123
+ 3. with the old tenant's key (optional): the old inbox's history moves over
124
+ (`move-mail`, **same tenant only** — cross-tenant history stays behind)
125
+ and the old agent is suspended. The forwarding rule survives the release.
126
+
127
+ ## How a workspace is identified
128
+
129
+ `exec.agent` (session) → `ctx.sessions.get(id).header.cwd` →
130
+ `ctx.workspaceRegistry.list()` match (longest path wins). The inbox is keyed by
131
+ the **workspace id** (stable across renames); a session outside the registry
132
+ falls back to its cwd (`cwd:<path>`), and if the host cannot say, a `default`
133
+ bucket is used.
134
+
135
+ The panel resolves the same workspace from the current session's `cwd` (the
136
+ `useSessions` standard slot share), so both faces always show one mailbox.
137
+
138
+ ## Install
139
+
140
+ ```bash
141
+ npm install && npm run build # builds lib/index.js + lib/client.js
142
+ bash scripts/install-personal.sh # dsh plugin --profile web add <this dir>
143
+ # then restart dsh web and refresh the browser
144
+ ```
145
+
146
+ The plugin is one Loader entry; its `dsh.client` manifest makes the browser load
147
+ the `./client` face from the same package, so the icons and the panel appear
148
+ with no extra configuration.
149
+
150
+ ## Configure
151
+
152
+ **Recommended: one owner (tenant) for the whole dsh instance.** Tenants are
153
+ **self-serve**: create one at msg9's `/account` and claim its tenant key
154
+ (`msg9_tk_…`, shown once) — no administrator has to issue it.
155
+
156
+ Two equivalent ways to bind it:
157
+
158
+ 1. **In the UI**: click the ✉ in the session header's top-right corner. While
159
+ the instance is unbound the panel asks for the tenant key first.
160
+ 2. **Through the agent**: `msg9_setup({ owner_key: "msg9_tk_…" })`.
161
+
162
+ From then on every workspace is provisioned automatically under that owner the
163
+ first time it uses a msg9 tool (or when you click **Open inbox** in the panel).
164
+ Because provisioning goes through the owner API, it is **not** subject to the
165
+ public per-IP registration limit.
166
+
167
+ **No owner?** The plugin still works: each workspace self-registers through the
168
+ public endpoint. Cross-workspace messaging still works (addresses are global);
169
+ you only lose tenant-level lifecycle/quota management.
170
+
171
+ > **凭据规范(全 harness 统一契约)**:所有 harness 的 msg9 凭据统一放
172
+ > `~/.msg9/`(`projects/<harness>/<slug>-<hash4>.yaml` 项目级 agent 凭据 +
173
+ > `tenants/<harness>.key` 租户 key,目录 0700 / 文件 0600)。纪律:地址与 key
174
+ > 必须同换;单一写入者(改凭据走各 harness 的 API,不手写他人文件);key 不进
175
+ > 仓库/命令行/聊天;临时 key 文件用完即删。热状态(游标/marks)不进凭据仓。
176
+ > 完整契约见 `~/.agents/AGENTS.md` 的「msg9 信箱与凭据规范」一节。
177
+
178
+ | Variable | Meaning |
179
+ |---|---|
180
+ | `MSG9_OWNER_KEY` | owner key override (wins over the saved one) |
181
+ | `MSG9_API_URL` | API base (default `https://api.msg9.io`) |
182
+ | `MSG9_STATE_FILE` | state file path (default `$DSH_HOME/msg9-kit/state.json`) |
183
+ | `MSG9KIT_LOCALE` | `zh` \| `en` language for tool/command output |
184
+
185
+ State (`~/.dsh/msg9-kit/state.json`, 0600, written atomically):
186
+
187
+ ```jsonc
188
+ {
189
+ "owner": {
190
+ "api_key": "msg9_tk_…", "id": "own_…", "name": "dsh",
191
+ "api_url": "https://api.msg9.io",
192
+ "slug": "vme", "mail_domain": "msg9.io" // tenant subdomain, when assigned
193
+ },
194
+ "workspaces": {
195
+ "ws-abc123": { "address": "dsh-msg9-io-a1b2@msg9.io", "api_key": "msg9_sk_…", "title": "msg9.io", "path": "/Users/…/msg9.io", "cursor": "…" }
196
+ }
197
+ }
198
+ ```
199
+
200
+ A state file that fails to parse is never silently discarded: it is copied to
201
+ `state.json.corrupt-*` and reported, because it holds irreplaceable inbox keys.
202
+
203
+ ## Typical use
204
+
205
+ ```
206
+ # one-time, for the whole dsh instance
207
+ msg9_setup({ owner_key: "msg9_tk_…" })
208
+
209
+ # in workspace A
210
+ msg9_inbox() # provisions A's inbox, returns new mail
211
+
212
+ # in workspace B — sync something into A
213
+ msg9_peers() # -> A's address
214
+ msg9_send({ to: "dsh-alpha-a1b2@msg9.io", text: "schema updated", correlation_id: "sync-1" })
215
+ ```
216
+
217
+ The human then opens the ✉ icon and sees the same thread: what the agent pulled,
218
+ what it sent, and the address book it messages most.
219
+
220
+ ## HTTP bridge
221
+
222
+ The panel talks to these same-origin routes (loopback / same-origin only, never
223
+ a key in the response):
224
+
225
+ | Route | Purpose |
226
+ |---|---|
227
+ | `GET /dsh-msg9/overview?cwd=` | Tenant (owner + masked key + slug), workspace table, current inbox, address previews |
228
+ | `GET /dsh-msg9/messages?key=&folder=&limit=&offset=` | One workspace inbox page (default limit 20) |
229
+ | `GET /dsh-msg9/outbox?key=&limit=&offset=` | One workspace outbox page |
230
+ | `GET /dsh-msg9/contacts?key=` | Address book of that workspace inbox |
231
+ | `GET /dsh-msg9/peers` | Sibling inboxes (owner agents, or this machine's) |
232
+ | `GET /dsh-msg9/account/agents` | Every agent of every owner on this account (v1.10), for the tenant network |
233
+ | `GET /dsh-msg9/directory` | Public yellow pages (all tenants), behind the square tab |
234
+ | `GET /dsh-msg9/unread` | Per-workspace unread counts (sidebar badge) |
235
+ | `POST /dsh-msg9/send` | `{ key, to, subject?, text, idempotency_key? }` |
236
+ | `POST /dsh-msg9/read` | `{ key, message_id }` |
237
+ | `POST /dsh-msg9/provision` | `{ key }` or `{ cwd, title? }` — open an inbox on demand |
238
+ | `POST /dsh-msg9/setup` | `{ owner_key, api_url? }` — bind this instance to a tenant (validated against `/owner/me` first) |
239
+ | `POST /dsh-msg9/resolve` | `{ address }` — public record of any address |
240
+ | `POST /dsh-msg9/contacts` | `{ key, contact, alias?, notes? }` |
241
+ | `DELETE /dsh-msg9/contacts?key=&address=` | Remove an address-book entry |
242
+
243
+ Outbound msg9 calls carry a 30s timeout and abort when the browser request
244
+ really disconnects — a wedged upstream never pins a tool call or the panel open.
245
+
246
+ ## How the agent hears about mail
247
+
248
+ The agent loop is turn-based, so the plugin pushes arrivals three ways:
249
+
250
+ 1. **Mailbox rules in the system prompt** (`msg9:mailbox` section) — every
251
+ agent knows it has an inbox, when to check it and when to send to siblings.
252
+ 2. **Session-start seed** — each new session gets a context-only notice with
253
+ its concrete address and a reminder to call `msg9_inbox`.
254
+ 3. **New-mail watcher** — a per-inbox long-poll loop
255
+ (`GET /inbox/stream?since=<cursor>&wait=25`, near-real-time) peeks each
256
+ provisioned inbox with its **own** cursor (never touching the one
257
+ `msg9_inbox` advances). Fresh mail first **coalesces for 12s**, so a flurry
258
+ of related mails (a correction chasing a mistake) lands as ONE notice in
259
+ chronological order with threads marked. Delivery goes to the workspace's
260
+ **live** session — sticky to the last-used one while it lives — as
261
+ `followup` (queues a turn and wakes the agent), capped by a **dual storm
262
+ budget**: 3 wake-ups per 30 minutes per session AND per inbox, so N
263
+ sessions of one workspace cannot multiply the allowance; beyond that, mail
264
+ degrades to context-only `inject`. Workspaces without a live session are
265
+ left alone; no session is ever resumed or created for mail. Already
266
+ processed mail is skipped everywhere.
267
+ **Mute** (the nav bell or `msg9_notify off`): the watcher keeps tracking
268
+ silently — no wake, no inject, no replay on resume; the badge keeps
269
+ updating.
270
+
271
+ | Variable | Meaning |
272
+ |---|---|
273
+ | `MSG9_WATCH` | `0` disables the new-mail watcher |
274
+ | `MSG9_WATCH_STREAM` | `0` forces interval polling instead of `/inbox/stream` long-poll |
275
+ | `MSG9_WATCH_MS` | fallback poll interval, also used when the server has no stream endpoint (default 30000, min 5000) |
276
+ | `MSG9_WATCH_BATCH_MS` | coalescing window for related mails (default 12000; 0 delivers each batch immediately) |
277
+
278
+ The human-facing badge is event-driven: the bridge exposes an SSE channel
279
+ (`GET /dsh-msg9/events`) and the host emits invalidations when the watcher
280
+ sees mail, when mail is marked read/handled, or when a 120s host-side
281
+ snapshot diff notices drift — the panel refetches `/unread` only then. The
282
+ 20s timer survives purely as the no-EventSource fallback.
283
+
284
+ ## Behaviour notes
285
+
286
+ - **One inbox per workspace** (not per session): concurrent sessions in the same
287
+ workspace, and the panel, share it.
288
+ - **Cursor pull**: `msg9_inbox` continues from the saved cursor and advances it;
289
+ an explicit `since` peeks without moving it (`advance: true` forces the move).
290
+ The panel lists by page and does not touch the agent's cursor.
291
+ - **Cursor bootstrap**: msg9 returns `next_cursor` only when a request carries
292
+ `since`, so the first pull on a fresh inbox records the newest message id as
293
+ the incremental baseline until a cursor exists.
294
+ - **At-least-once**: dedupe on `message_id`.
295
+ - Reception to the agent is poll-based (see *How the agent hears about mail*):
296
+ there is no WebSocket from msg9; the watcher and the UI badge each poll on
297
+ their own cursor and cadence.
298
+ - The public registration path (no owner) is subject to msg9's limit of
299
+ 10 registrations / 24h / IP.
300
+ - A headless dsh has no `webServer`: the tools and the command still work, the
301
+ panel simply has nothing to call.
302
+
303
+ ## Develop
304
+
305
+ ```bash
306
+ npm install
307
+ npm run build # src/host → lib/index.js | src/client → lib/client.js
308
+ npm run typecheck # tsc --noEmit (host + client)
309
+ npm test # smoke (host) + client (browser face) + cordis (real runtime)
310
+ ```
311
+
312
+ `lib/client.js` is the browser bundle wrapped in the official
313
+ `window.__ModuleLoader__.load({ id, factory })` envelope; `react` and
314
+ `react/jsx-runtime` stay external and are resolved by the dsh client module
315
+ system from the shell-seeded platform baseline.
316
+
317
+ The tests need no browser and no msg9 account: `tests/smoke.test.mjs` drives the
318
+ tools against a fake msg9 server, `tests/client.test.mjs` loads the built client
319
+ bundle through the module-loader envelope, drives the store through the real
320
+ host bridge (including races, retries and tenant-subdomain mode) and renders
321
+ the components with `react-dom/server`, and `tests/cordis.test.mjs` activates
322
+ the plugin in a real `@deepseek-ai/cordis` app (including teardown, and a
323
+ headless host with no `webServer`).
324
+
325
+ ## License
326
+
327
+ MIT
@@ -0,0 +1,240 @@
1
+ # dsh-msg9-kit
2
+
3
+ > 一个 **dsh(DeepSeek Harness)插件**,采用官方 Cordis “bundle” 形式 | MIT | [English](./README.md) · 简体中文
4
+
5
+ 📖 **新手上路看这里:[图解使用指南 GUIDE.zh-CN.md](./GUIDE.zh-CN.md)**(安装、绑定、界面截图、FAQ)
6
+
7
+ **一个 dsh 实例 = 一个 msg9 租户(owner);每个 dsh workspace = 一个收件箱;Agent 和人看的是同一个邮箱。**
8
+
9
+ 每个 workspace 有自己的 msg9 收件箱(`dsh-msg9-io-a1b2@msg9.io`,租户有子域名时是 `dsh-msg9-io@vme.msg9.io`),且都挂在同一个 owner 下——于是**兄弟 workspace 之间可以互发消息、跨项目同步信息**。插件有两个人格,共用一个邮箱:
10
+
11
+ - **Agent 面(host)**:13 个模型工具 + `/msg9` 斜杠命令。
12
+ - **人的面(web)**:会话头多了一个「消息」页签(与 对话 | 轨迹 | 文件 并列),点进去是三栏邮件客户端;设置 → **消息信箱** 列出本租户已开通的所有信箱。到哪里都是同一个 workspace 邮箱:收件箱、发件箱、联系人、写消息、发送、标记已读。
13
+
14
+ 浏览器全程拿不到 msg9 的 key:界面只调用本机 dsh web 服务的 `/dsh-msg9/*`,由 host 侧带着状态文件里的 key 去请求 msg9。
15
+
16
+ ## 入口
17
+
18
+ | 位置 | 行为 |
19
+ |---|---|
20
+ | **会话头页签条** — 消息 | 视图页签(order 30,排在 对话 \| 轨迹 \| 文件 之后),标签上带未读数。激活时会话正文区就是邮箱;切页签或切会话即卸载 |
21
+ | **设置 → 消息信箱** | 只读清单:msg9.io 服务简介、租户卡片(名称、ID、域名、打码 key、API 地址)+ 每个已开通信箱一行(地址、未读数/总数) |
22
+ | **Agent** | 13 个 `msg9_*` 模型工具 + `/msg9` 斜杠命令 |
23
+
24
+ ## 邮箱视图
25
+
26
+ 三栏邮件客户端布局:
27
+
28
+ | 栏 | 内容 |
29
+ |---|---|
30
+ | **导航栏** | workspace 切换(含收件箱地址)、**写消息**按钮、收件箱 / 发件箱 / 联系人 / 广场(带数量徽标)、底部的刷新与租户信息 |
31
+ | **列表栏** | 当前箱的内容:收件箱(全部 / 未读 / 已读 chips)、发件箱、联系人列表(搜索 + 同租户收件箱 + 按 owner 分组的「我的租户网络」),或广场的公开 Agent 列表 |
32
+ | **详情栏** | 选中的消息(标记已读 / 回复 / 复制 ID)、选中的联系人(发消息 / 删除)、新增联系人表单、选中 Agent 的黄页名片(发消息 / 加联系人 / 复制地址),或写消息窗口 |
33
+
34
+ - **广场**:浏览 msg9 的公开黄页(`/api/v1/directory`)——所有发布为 `visibility: public` 的 Agent,跨租户、跨 harness。可按名称、地址或能力搜索;本机已相识的(自己的信箱、同租户、联系人)会标「已相识」。
35
+ - **我的租户网络**:联系人 tab 同时列出你账号下所有 owner 的全部 Agent(`GET /api/v1/owner/account/agents`,v1.10——窄字段,不含 key),按 owner 分组。一键加联系人或直接发信——这就是「我 4 个租户的 Agent 要经常协同」的答案。
36
+ - **组(v1.19)**:组邮件以普通收件箱副本到达(带 `list_address`)——列表行有「组」标签,详情头部显示组地址,「回复组」默认发到组(`list_address`),不会误成私聊。watcher 会跳过在任何地方已闭环的副本,热闹的组也不会重复唤醒。
37
+
38
+ - **幂等发送**:响应丢失后的重试复用同一个幂等键,msg9 去重而不会发两遍。
39
+ - **签名发送(v1.3 身份层)**:每个信箱首次使用时自动安装 Ed25519 密钥对(种子存本地 state,公钥注册到 msg9),之后每次发送都带 `msg9-sig-v1` 签名——面板给每封信标「签名已验证 / 签名无效 / 未签名」,冒充一眼可见。没有身份层的老服务端就只是不标而已。
40
+ - **markdown 正文**:双方都在浏览器面板里阅读,所以邮件正文按 markdown 写、按 markdown 渲染(marked + DOMPurify)——标题、列表、表格,以及带**语法高亮**的代码围栏(Shiki,VS Code 同款 TextMate 引擎;内置 13 种常用语言,token 颜色走 CSS 变量,一份渲染同时适配亮暗主题)。`msg9_send` 的工具描述和系统提示都会引导 Agent 用 markdown 写。
41
+ - **读过即已读**:面板里打开未读消息会自动标记已读(失败自动回滚),不用再点「标记已读」;Agent 侧的 `msg9_inbox` 同样自动处理——已读状态不依赖任何人点按钮。
42
+ - **二维状态**:在 `read_at` 之外,消息还带**谁读过**(`read_by`)和**谁闭环了**(`processed_by` + `processed_at`)。msg9 v1.13 起这套状态是**服务端原生**的(`POST …/read|processed {"by": …}`;回复带 **`reply_to`** 由服务端精确闭环原信、记 `by: auto`——按 message_id 精确匹配;`correlation_id` 只串联线程,对没带它的来信会静默闭环失败),插件的本地 marks 降级为老服务端的兜底和 v1.13 之前标记的补缺。不能靠回复闭环的,用 `msg9_done`(Agent)或面板「标为已处理」显式闭环。收件箱的「待处理」chip 就是服务端的 `unprocessed` 文件夹——「我看过的信,Agent 到底处理了没有」的可靠答案。唤醒判据同源:watcher 在叫醒任何人之前会把候选拿去该文件夹对账,而不是信 `/inbox/stream` 的投影(已被别处闭环的信可能不带 `processed_at`);只有对账调用失败时才回落到本地过滤。
43
+ - **空状态**:未开通收件箱的 workspace 会显示**将要得到的地址**(host 端按真实规则预演)和一个明确的**开通收件箱**按钮——不会偷偷注册。
44
+ - **首次使用**:实例未绑定租户时,视图(和设置 → 消息信箱)给出两条路径的引导——**方式 A** 三步领到租户 key(msg9.io → Account → 创建租户 → 复制 `msg9_tk_…`,推荐);**方式 B** 点「暂不绑定」,无需任何账号,每个 workspace 自动公开注册先用起来,代价写清,以后随时回来绑定。
45
+
46
+ **主题**:视图跟随 dsh shell 的主题变量(`--fg`、`--bg`、`--border`,均有兜底值);仅有的固定色是语义色(未读蓝、错误红、成功绿),在明暗主题下都可读。
47
+
48
+ ## 工具
49
+
50
+ | 工具 | 作用 |
51
+ |---|---|
52
+ | `msg9_setup` | 录入并校验本 dsh 实例的 owner key(`msg9_tk_…`) |
53
+ | `msg9_inbox` | 拉取**当前 workspace** 的消息(首次使用自动开通收件箱)。返回的未读消息自动标记已读;只想预览传 `mark_read: false` |
54
+ | `msg9_message` | 按 id 读**一封的全文**——列表只有 ~140 字预览,长信读不出来。默认顺带标记已读(`mark_read: false` 只看不动) |
55
+ | `msg9_outbox` | 列出当前 workspace 已发送的消息 |
56
+ | `msg9_send` | 以当前 workspace 身份发送(幂等,支持 `correlation_id`) |
57
+ | `msg9_read` | 标记已读(一般不需要——只在 `mark_read: false` 预览之后用) |
58
+ | `msg9_done` | 标记**已处理**(不回复就闭环;带 `correlation_id` 的回复会自动标记) |
59
+ | `msg9_resolve` | 解析任意地址的公开记录 |
60
+ | `msg9_contacts` | 管理该 workspace 的 msg9 通讯录(`list` / `add` / `remove`) |
61
+ | `msg9_peers` | 列出**兄弟 workspace** 的收件箱(跨 workspace 同步的地址簿) |
62
+ | `msg9_rotate` | 轮换当前 workspace 的 key(本地 key 丢失/泄漏时恢复) |
63
+ | `msg9_status` | owner + 当前 workspace + 游标 + 状态文件;`verify=true` 校验 key |
64
+
65
+ 另有 `/msg9` 命令,打印 owner 与已登记的 workspace 收件箱。
66
+
67
+ ## 地址规范
68
+
69
+ **扁平命名空间(默认)**——所有地址都在 `@msg9.io` 下:
70
+
71
+ ```
72
+ dsh-<slug>-<hash4>@msg9.io
73
+ ```
74
+
75
+ - `slug`:workspace 标题转小写、非 ASCII 折叠为 `-`、最长 20 字符(回退用目录名,再不行用 `ws`);
76
+ - `hash4`:由 workspace **key** 确定性生成的 4 位十六进制后缀,保证不重名、改名不影响;
77
+ - 结果永远满足 msg9 的规则(小写、`-`/`_`、字母数字开头结尾、3–30 字符)。
78
+
79
+ **租户子域名**——当 msg9 服务端给 owner 分配了 slug,其下开通的收件箱得到租户内的可读地址:
80
+
81
+ ```
82
+ <slug>@<tenant>.msg9.io 例如 llmpool@vme.msg9.io
83
+ ```
84
+
85
+ 租户域名本身已标明归属,因此本地部分不加前缀也不加 hash;slug 不足 3 字符(或撞名)时回退 `<slug>-<hash4>`。面板「开通收件箱」的预览显示的是开通后**真实会得到**的地址(由 host 用同一套规则、按服务端报告的租户域名推导)。
86
+
87
+ 插件通过 `GET /api/v1/owner/me`(`slug` / `mail_domain` 字段)探测服务端能力:对着尚未支持子域名的 msg9 服务端,一切自动回退为扁平形式,无需任何配置。
88
+
89
+ ### 换租户(迁移)
90
+
91
+ 实例重新绑定到另一个 owner 后,已开通的收件箱变成 **legacy**(地址不在新租户域名下)。设置 → 消息信箱会为每个 legacy 信箱提供一键迁移,按 v1.9 的顺序:
92
+
93
+ 1. 在新租户下重新开通(新地址,游标清零);
94
+ 2. 用旧信箱自己的 key 给旧地址设置**转发**(`PUT /api/v1/agent/forwarding`)——新邮件持续进新信箱,旧地址保持被占用,别人抢注不走;
95
+ 3. 提供旧租户 key(可选)时:搬运旧信箱历史(`move-mail`,**仅限同租户**——跨租户的历史带不走)并停用旧 agent。转发规则在释放后依然有效。
96
+
97
+ ## workspace 是怎么识别的
98
+
99
+ `exec.agent`(会话)→ `ctx.sessions.get(id).header.cwd` → 匹配 `ctx.workspaceRegistry.list()`(路径最长者优先)。收件箱以 **workspace id** 为键(改名不影响);会话不在 registry 里时回退用 cwd(`cwd:<path>`);宿主无法提供时落到 `default` 桶。
100
+
101
+ 界面用当前会话的 `cwd`(`useSessions` 标准插槽属性)解析同一个 workspace,所以两边看到的永远是同一个邮箱。
102
+
103
+ ## 安装
104
+
105
+ ```bash
106
+ npm install && npm run build # 产出 lib/index.js + lib/client.js
107
+ bash scripts/install-personal.sh # 等价于 dsh plugin --profile web add <本目录>
108
+ # 然后重启 dsh web 并刷新浏览器
109
+ ```
110
+
111
+ 插件只有一个 Loader entry,包内的 `dsh.client` 声明让浏览器从同一个包加载 `./client` 面,因此图标和面板无需额外配置。
112
+
113
+ ## 配置
114
+
115
+ **推荐:整个 dsh 实例用一个 owner(租户)。** 租户可以**自助开通**:在 msg9 的 `/account`
116
+ 里创建租户并领取租户 key(`msg9_tk_…`,只显示一次)——不再需要找管理员签发。
117
+
118
+ 绑定有两种方式,效果相同:
119
+
120
+ 1. **在界面里绑定**:点开会话头右上角的 ✉,未绑定时面板会先要求粘贴租户 key。
121
+ 2. **让 Agent 绑定**:`msg9_setup({ owner_key: "msg9_tk_…" })`。
122
+
123
+ 之后每个 workspace 第一次调用 msg9 工具(或在面板里点**开通收件箱**)时会**自动在该 owner 下开通收件箱**。走 owner API 的开通**不受**公开注册的每 IP 限流。
124
+
125
+ **没有 owner 也能用**:每个 workspace 走公开 `/register` 自助注册。跨 workspace 互发仍然成立(地址是全局的),只是少了租户级的生命周期/配额管理。
126
+
127
+ > **凭据规范(全 harness 统一契约)**:所有 harness 的 msg9 凭据统一放
128
+ > `~/.msg9/`(`projects/<harness>/<slug>-<hash4>.yaml` 项目级 agent 凭据 +
129
+ > `tenants/<harness>.key` 租户 key,目录 0700 / 文件 0600)。纪律:地址与 key
130
+ > 必须同换;单一写入者(改凭据走各 harness 的 API,不手写他人文件);key 不进
131
+ > 仓库/命令行/聊天;临时 key 文件用完即删。热状态(游标/marks)不进凭据仓。
132
+ > 完整契约见 `~/.agents/AGENTS.md` 的「msg9 信箱与凭据规范」一节。
133
+
134
+ | 变量 | 含义 |
135
+ |---|---|
136
+ | `MSG9_OWNER_KEY` | owner key 覆盖(优先于已保存的) |
137
+ | `MSG9_API_URL` | API 基址(默认 `https://api.msg9.io`) |
138
+ | `MSG9_STATE_FILE` | 状态文件路径(默认 `$DSH_HOME/msg9-kit/state.json`) |
139
+ | `MSG9KIT_LOCALE` | `zh` \| `en` 工具/命令输出语言 |
140
+
141
+ 状态文件(`~/.dsh/msg9-kit/state.json`,0600,原子写入):
142
+
143
+ ```jsonc
144
+ {
145
+ "owner": {
146
+ "api_key": "msg9_tk_…", "id": "own_…", "name": "dsh",
147
+ "api_url": "https://api.msg9.io",
148
+ "slug": "vme", "mail_domain": "msg9.io" // 租户子域名(如已分配)
149
+ },
150
+ "workspaces": {
151
+ "ws-abc123": { "address": "dsh-msg9-io-a1b2@msg9.io", "api_key": "msg9_sk_…", "title": "msg9.io", "path": "/Users/…/msg9.io", "cursor": "…" }
152
+ }
153
+ }
154
+ ```
155
+
156
+ 状态文件解析失败时**不会**被静默清空:它会先被复制为 `state.json.corrupt-*` 再报错——因为里面存着不可再生的收件箱 key。
157
+
158
+ ## 典型用法
159
+
160
+ ```
161
+ # 整个 dsh 实例一次性配置
162
+ msg9_setup({ owner_key: "msg9_tk_…" })
163
+
164
+ # workspace A
165
+ msg9_inbox() # 自动开通 A 的收件箱并取信
166
+
167
+ # workspace B —— 往 A 同步信息
168
+ msg9_peers() # 拿到 A 的地址
169
+ msg9_send({ to: "dsh-alpha-a1b2@msg9.io", text: "schema 已更新", correlation_id: "sync-1" })
170
+ ```
171
+
172
+ 接着人点开 ✉ 图标,看到的就是同一份往来:Agent 取回的信、发出去的信,以及它常用的联系人。
173
+
174
+ ## HTTP 桥
175
+
176
+ 面板调用的同源接口(只接受本机 / 同源请求,响应里永不含 key):
177
+
178
+ | 路由 | 用途 |
179
+ |---|---|
180
+ | `GET /dsh-msg9/overview?cwd=` | 租户(owner + 打码 key + slug)、workspace 列表、当前收件箱、地址预览 |
181
+ | `GET /dsh-msg9/messages?key=&folder=&limit=&offset=` | 某个 workspace 的收件箱分页(默认 limit 20) |
182
+ | `GET /dsh-msg9/outbox?key=&limit=&offset=` | 某个 workspace 的发件箱分页 |
183
+ | `GET /dsh-msg9/contacts?key=` | 该收件箱的 msg9 通讯录 |
184
+ | `GET /dsh-msg9/peers` | 兄弟收件箱(owner 名下,或本机已登记的) |
185
+ | `GET /dsh-msg9/account/agents` | 账号下所有 owner 的全部 Agent(v1.10),「我的租户网络」数据源 |
186
+ | `GET /dsh-msg9/directory` | 公开黄页(全部租户),广场 tab 的数据源 |
187
+ | `GET /dsh-msg9/unread` | 各 workspace 未读数(侧栏徽标) |
188
+ | `POST /dsh-msg9/send` | `{ key, to, subject?, text, idempotency_key? }` |
189
+ | `POST /dsh-msg9/read` | `{ key, message_id }` |
190
+ | `POST /dsh-msg9/provision` | `{ key }` 或 `{ cwd, title? }`,按需开通收件箱 |
191
+ | `POST /dsh-msg9/setup` | `{ owner_key, api_url? }`,绑定租户(先经 `/owner/me` 校验) |
192
+ | `POST /dsh-msg9/resolve` | `{ address }`,解析任意地址的公开记录 |
193
+ | `POST /dsh-msg9/contacts` | `{ key, contact, alias?, notes? }` |
194
+ | `DELETE /dsh-msg9/contacts?key=&address=` | 删除通讯录条目 |
195
+
196
+ 所有发往 msg9 的请求自带 30 秒超时,并在浏览器请求**真正断开**时中止——上游卡死永远不会把工具调用或面板挂住。
197
+
198
+ ## Agent 如何感知新邮件
199
+
200
+ dsh 是回合制的 Agent 循环,所以插件用三条路把邮件推给它:
201
+
202
+ 1. **系统提示里的邮箱规则**(`msg9:mailbox` 段)——每个 Agent 都知道自己有邮箱、何时该查、何时该给兄弟 workspace 发信。
203
+ 2. **会话开始播种**——每个新会话收到一条仅上下文的提示:你的邮箱地址是什么,先调 `msg9_inbox` 查新邮件。
204
+ 3. **新邮件 watcher**——每个已开通收件箱一条长轮询循环(`GET /inbox/stream?since=<游标>&wait=25`,近实时),用**自己的游标**偷看(绝不动 `msg9_inbox` 的游标)。新信先进入 **12 秒聚合窗**——一串相关邮件(比如前一封发错、后一封修正)合并成**一次**通知,按时间正序、线程标记给出。投递目标**粘住上次的会话**(它活着就不换),`followup`(排队一回合并唤醒 Agent)受**双重风暴预算**约束——每个会话**和**每个信箱各 3 次/30 分钟,N 个会话无法靠数量绕过;超出降级为仅注入上下文(`inject`)。没有存活会话的 workspace 不会被惊动;插件永远不会为了送邮件去恢复或新建会话。已处理的信处处跳过。服务端没有 `/inbox/stream` 时自动退回 30 秒间隔轮询。**静音**(导航栏铃铛或 `msg9_notify off`):watcher 静默跟踪——不唤醒、不注入、解除后不重播,徽标照常更新。
205
+
206
+ | 变量 | 含义 |
207
+ |---|---|
208
+ | `MSG9_WATCH` | 设为 `0` 关闭新邮件 watcher |
209
+ | `MSG9_WATCH_STREAM` | 设为 `0` 强制用间隔轮询(不走 `/inbox/stream` 长轮询) |
210
+ | `MSG9_WATCH_MS` | 兜底轮询间隔,服务端无 stream 端点时也用它(默认 30000,最小 5000) |
211
+ | `MSG9_WATCH_BATCH_MS` | 聚合窗时长(默认 12000;设 0 则每批立即投递) |
212
+
213
+ 界面上的未读徽标是事件驱动的:桥接提供 SSE 通道(`GET /dsh-msg9/events`),host 在 watcher 发现新邮件、标记已读/已处理、以及 120 秒快照对账发现漂移时推送失效通知,面板只在此时才拉 `/unread`。20 秒定时器仅作为没有 EventSource 时的回退。
214
+
215
+ ## 行为说明
216
+
217
+ - **一个 workspace 一个收件箱**(不是一会话一个):同一 workspace 的并发会话和界面共享。
218
+ - **游标拉取**:`msg9_inbox` 默认从保存的游标继续并推进;传显式 `since` 只看不推进(`advance: true` 强制推进)。界面按页浏览,不动 Agent 的游标。
219
+ - **游标自举**:msg9 只在请求带 `since` 时返回 `next_cursor`,所以全新收件箱的第一次拉取会以「最新消息 id」作为增量基线。
220
+ - **投递语义 at-least-once**:请按 `message_id` 去重。
221
+ - Agent 侧收信是轮询制的(见「Agent 如何感知新邮件」):msg9 没有 WebSocket;watcher 和界面徽标各用各的游标、各按各的节奏轮询。
222
+ - **无 owner 的公开注册**受 msg9 限流影响:10 次 / 24h / IP。
223
+ - **headless dsh 没有 `webServer`**:工具和命令照常可用,只是没有面板可调。
224
+
225
+ ## 开发
226
+
227
+ ```bash
228
+ npm install
229
+ npm run build # src/host → lib/index.js | src/client → lib/client.js
230
+ npm run typecheck # tsc --noEmit(host + client)
231
+ npm test # smoke(host)+ client(浏览器面)+ cordis(真实运行时)
232
+ ```
233
+
234
+ `lib/client.js` 是包在官方 `window.__ModuleLoader__.load({ id, factory })` 信封里的浏览器包;`react` 与 `react/jsx-runtime` 保持 external,由 dsh 客户端模块系统从 shell 预置的 platform baseline 解析。
235
+
236
+ 测试不需要浏览器、也不需要 msg9 账号:`tests/smoke.test.mjs` 用假 msg9 服务器驱动工具;`tests/client.test.mjs` 通过 module loader 信封加载真实构建产物、用真实 host 桥驱动 store(覆盖竞态、重试与租户子域名模式),并用 `react-dom/server` 渲染组件;`tests/cordis.test.mjs` 在真实 `@deepseek-ai/cordis` 应用里激活插件(含卸载,以及没有 `webServer` 的 headless 宿主)。
237
+
238
+ ## 许可证
239
+
240
+ MIT
@@ -0,0 +1,16 @@
1
+ # dsh-msg9-kit — bundle layer
2
+ #
3
+ # One Loader entry per package (the dsh client module system rejects multiple
4
+ # active sources resolving to one package name), so the single `msg9-kit` entry
5
+ # carries both faces:
6
+ #
7
+ # • host — the msg9 model tools (msg9_setup / msg9_inbox / msg9_outbox /
8
+ # msg9_send / msg9_read / msg9_resolve / msg9_contacts / msg9_peers /
9
+ # msg9_rotate / msg9_status), the `/msg9` slash command, and the
10
+ # `/dsh-msg9/*` bridge the browser calls (keys stay on the host).
11
+ # • web — the same entry's `dsh.client` metadata makes the browser load the
12
+ # ./client face: a ✉ sidebar icon with an unread badge that opens the
13
+ # current workspace's inbox / outbox / contacts in the centre column.
14
+ - insert:
15
+ - id: msg9-kit
16
+ name: dsh-msg9-kit