dsh-as-mcp 0.53.0 → 0.54.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 CHANGED
@@ -6,6 +6,59 @@ plugin is pre-1.0, so a minor bump is a feature and a patch is a fix or docs.
6
6
  A Chinese summary of every release lives in [CHANGELOG.zh.md](./CHANGELOG.zh.md);
7
7
  this file stays the authoritative record.
8
8
 
9
+ ## 0.54.0
10
+
11
+ - Server push now covers the whole run instead of one turn-ending signal. The existing
12
+ `subscriptions/listen` stream carries a feed set as readable synthetic resources — no new tools,
13
+ so the surface stays at 35: `dsh://sessions` (add/remove/activity/status and subagent starts and
14
+ ends), `dsh://session/<id>/events` (the newest durable events projected to
15
+ `{seq, type, at, turn, step, summary, error}`, published on every append, at most 64 per read),
16
+ `dsh://session/<id>/assistant` (live `start`/`text`/`reasoning`/`tool`/`finish`/`end` frames with
17
+ a monotonic `seq`, sourced from `agent/assistant-stream`), `dsh://session/<id>/status` (the
18
+ `session_status` payload), `dsh://session/<id>/projections` (the allowlisted
19
+ `workingDirectory`/`worktree`/`goal`/`plan`/`todos`/`agentPreset`/`title` values), and
20
+ `dsh://jobs` / `dsh://jobs/<id>` (background shell jobs; the job tail read is non-consuming, so
21
+ watching it cannot steal bytes from `shell_output`). The `dsh://session/<id>/turn` settlement
22
+ resource from 0.52.86 remains and is now one feed among the set instead of the only one.
23
+ `resources/list` and `resources/templates/list` advertise the feeds; `dsh_info.notes` names them
24
+ too.
25
+ - Publishes are coalesced per URI at 150 ms (`PUSH_COALESCE_MS`): the first publish of a window
26
+ goes out immediately, later ones collapse into one trailing publish, and different URIs never
27
+ wait on each other — so a token stream costs at most one notification per window per URI instead
28
+ of one per token. Turn settlement stays deliberately immediate. The live tails are bounded in
29
+ process: assistant frames keep 256 KiB / 2 000 frames per session and a read returns at most 200
30
+ frames and roughly 32 KiB of text (newest first, `truncated` when older frames were dropped), an
31
+ events read stops at 64, a job read returns at most the newest 8 192 characters of a 16 KiB
32
+ retained tail, and the job feed polls about once a second only while somebody is subscribed. No
33
+ subscriber means no work: the collectors return early and no harness service is read, so a
34
+ fire-and-forget prompt with no listener costs what it did before. `session.scope: "owned"` gates
35
+ notifications as well as reads — a session this endpoint did not create or fork produces no
36
+ frame (its subagent sessions included: their parent's feeds publish, and their own feeds remain
37
+ readable through the owned tree) — so the push channel cannot be used to enumerate ids the
38
+ tools would refuse.
39
+ - A stateful 2025-era Streamable HTTP leg now sits beside the modern one. An `initialize` answers
40
+ with an `mcp-session-id` header; `POST`/`GET`/`DELETE` carrying it are served by a per-session
41
+ server and transport, and the long-lived `GET` with `Accept: text/event-stream` is the
42
+ notification channel — so `notifications/resources/updated` finally reaches 2025-era clients
43
+ that have no `subscriptions/listen`. Sessions are capped at 8 (least recently used evicted) and
44
+ closed after 30 minutes idle, answering `404 Session not found` so the client re-initializes.
45
+ Resumability is deliberately absent: `Last-Event-ID` replay is not implemented because every
46
+ notification is payload-less and a reconnect re-reads. Clients that never take the session id
47
+ keep the previous stateless serving unchanged — later `POST`s answered from a fresh stateless
48
+ instance, `GET`/`DELETE` still `405` (the stateless legacy GET is not a push channel; a stateful
49
+ session's GET is).
50
+ - `bin/mcp-stdio.mjs` opens that GET stream after a stateful `initialize` and forwards every
51
+ server notification and server→client request to stdout as one JSON line, reconnecting with
52
+ 500 ms→30 s backoff (a stream that delivered messages restarts at the base delay) and sending
53
+ the last SSE id it saw (harmless: the server ignores it). A stateless endpoint — no session id,
54
+ or a GET answered `404`/`405` — prints one push-disabled note and keeps the old request/reply
55
+ behavior. `src/mcp/feeds.ts` owns the collectors, reads and buffers, `src/mcp/push.ts` the
56
+ per-URI coalescing and the 2025 fanout, `src/mcp/http.ts` the stateful session manager;
57
+ `tests/feeds.test.ts`, `tests/feed-resources.test.ts`, `tests/legacy-http.test.ts` and
58
+ `tests/stdio-bridge.test.ts` pin each half, and both READMEs gain a "Server push and the two
59
+ transports" section with the era table, the feed table and the backpressure bounds. The tool
60
+ surface, the tool schemas and every configuration key are unchanged.
61
+
9
62
  ## 0.53.0
10
63
 
11
64
  - The endpoint now covers Git worktrees and the sidebar's session organization. Six new tools —
package/CHANGELOG.zh.md CHANGED
@@ -6,6 +6,13 @@
6
6
  - 两份文件的**版本标题必须完全一致**(集合与顺序),由 `tools/check-docs.mjs` 强制检查——发布时漏写中文条目会让检查失败,中文读者不会悄悄落后一个版本。
7
7
  - 版本号遵循 SemVer;项目仍是 pre-1.0,所以 minor 是功能,patch 是修复或文档。
8
8
 
9
+ ## 0.54.0
10
+
11
+ - 服务端推送从「只报一轮结束」扩展到整场运行,仍走现有的 `subscriptions/listen` 流,且不新增任何工具(工具面仍是 35 个):`dsh://sessions`(会话增删、活动/状态、subagent 起止)、`dsh://session/<id>/events`(最新持久事件投影为 `{seq, type, at, turn, step, summary, error}`,每次追加都发布,单次读取至多 64 条)、`dsh://session/<id>/assistant`(实时 `start`/`text`/`reasoning`/`tool`/`finish`/`end` 帧,`seq` 每会话单调递增)、`dsh://session/<id>/status`(与 `session_status` 同负载)、`dsh://session/<id>/projections`(白名单内的 `workingDirectory`/`worktree`/`goal`/`plan`/`todos`/`agentPreset`/`title`)、`dsh://jobs` 与 `dsh://jobs/<id>`(后台 shell 任务;任务尾部读取不消费,不会抢走 `shell_output` 的字节)。0.52.86 的 `dsh://session/<id>/turn` 结算资源保留,成为整个 feed 集合中的一项。`resources/list`/`resources/templates/list` 会公布这些 feed,`dsh_info.notes` 也会点名它们。
12
+ - 发布按 URI 以 150 ms(`PUSH_COALESCE_MS`)合并:窗口内第一次立即发出,同一个 URI 的后续发布合并为一次尾随发布,不同 URI 互不等待——一段 token 流因此对每个 URI 至多每个窗口一条通知,而不是每个 token 一条;轮次结算仍立即发布。实时尾部在进程内是有界的:assistant 每会话保留 256 KiB / 2,000 帧,单次读取至多 200 帧、约 32 KiB 文本(从新到旧,更旧的帧被丢弃时带 `truncated`);事件读取上限 64 条;任务读取至多返回 16 KiB 保留尾部里的最后 8,192 个字符;任务 feed 只在有人订阅时约每秒轮询一次。没有订阅者就什么都不做:采集器提前返回、不读任何 harness 服务,所以没有监听者的 fire-and-forget prompt 成本和以前一样。`session.scope: "owned"` 同样约束通知而不只是读取——本端点未创建或 fork 的会话不产生帧(其 subagent 会话亦然:父会话的 feed 照常发布,而这些 subagent 的 feed 仍按 owned 树可读)——推送通道因此无法用来枚举工具会拒绝的会话 id。
13
+ - 在现代协议之外新增 2025 时代有状态 Streamable HTTP 的一条腿:`initialize` 回复带 `mcp-session-id`,带该头的 `POST`/`GET`/`DELETE` 由每会话的 server 与 transport 服务,长期 `GET`(`Accept: text/event-stream`)就是通知通道——没有 `subscriptions/listen` 的 2025 客户端终于能收到 `notifications/resources/updated`。会话上限 8 个(淘汰最久未用),静默 30 分钟后关闭并回答 `404 Session not found` 让客户端重新 initialize;刻意不实现 `Last-Event-ID` 重放(通知不携带负载,重连后重读资源即可)。不接收会话 id 的客户端保持此前的 stateless 服务不变:之后的 `POST` 由全新 stateless 实例应答,`GET`/`DELETE` 仍是 `405`(stateless 的旧式 GET 不是推送通道,有状态会话的 GET 才是)。`bin/mcp-stdio.mjs` 在有状态 `initialize` 后打开这条 GET 流,把每条通知与任何服务端→客户端请求作为一行 JSON 写到 stdout,按 500 ms→30 s 退避重连并带上最后看到的 SSE id(服务端会忽略,发送无害);stateless 端点(没有会话 id,或 GET 回答 `404`/`405`)只打印一条「push disabled」提示,请求/应答行为不变。
14
+ - `src/mcp/feeds.ts` 负责采集器、读取与缓冲,`src/mcp/push.ts` 负责按 URI 合并与 2025 会话扇出,`src/mcp/http.ts` 负责有状态会话管理;`tests/feeds.test.ts`、`tests/feed-resources.test.ts`、`tests/legacy-http.test.ts`、`tests/stdio-bridge.test.ts` 分别钉住各半边。两份 README 新增「Server push and the two transports / 服务端推送与两种传输」章节:时代对照表、feed 表与背压上限,并更新兼容性与设计说明。工具面、工具 schema 与所有配置键均不变。
15
+
9
16
  ## 0.53.0
10
17
 
11
18
  - 新增 6 个 MCP 工具(`worktree_create`、`worktree_restore`、`session_archive`、`session_unarchive`、`session_pin`、`session_unpin`),工具面从 29 增至 35。`session_create` 新增 `worktree: true | { name, from }`:先在 workspace(或裸目录)里建好会话,因此带 `workspaceId` 创建的会话保持原工作区分组(只用 cwd 创建的会话不在任何组),然后再建 Git 分支、检出并设为会话的实时工作目录——与 DSH 界面「New worktree」选项同一套顺序;worktree 创建失败不回滚会话,回复用 `worktreeError` 说明(可用 `worktree_create` 重试),profile 没有 worktrees 服务时则在创建会话**之前**拒绝。`session_status` 报 `workingDirectory` 与 forge 面板的 `worktree` 记录(任一变化都会唤醒挂着的 `waitMs`),`session_list` 新增 `includeWorktree`,行上还带 `archived`/`pinned`。归档/置顶写的是 DSH 侧边栏动作所用的同一组 registry 集合:归档运行中的会话需要 `stopActivity: true`,日志保留,harness 依然没有删除会话。
package/README.md CHANGED
@@ -218,7 +218,9 @@ advertised models (`catalog.providerModels`): that asks the adapters, some of wh
218
218
  discovery, so it is opt-in and time-boxed per provider — and it is the reliable way to pick a valid
219
219
  `session_create.model` or `session_set_model.model`. Every catalog field is optional and independently
220
220
  guarded, and `catalog.unavailable` names whichever read could not be answered, so a missing entry means
221
- "this profile does not say" rather than "nothing is allowed".
221
+ "this profile does not say" rather than "nothing is allowed". Its `notes` also name the push feeds and the
222
+ two transports this endpoint serves, so a caller can find the subscription surface without reading this
223
+ README.
222
224
 
223
225
  ## Tools
224
226
 
@@ -228,7 +230,7 @@ schema-aware client can read the object directly instead of parsing JSON out of
228
230
 
229
231
  | Tool | What it does |
230
232
  | --- | --- |
231
- | `dsh_info` | Endpoint, version and uptime, token source, enabled groups, available harness services, usage/cost availability, the `catalog` of accepted providers, agent presets and sandbox mode, and `toolAvailability` — the tools that passed every rule (`availableTools`, comparable with `tools/list`), those this deployment cannot serve, and why. `probeSearch` additionally exercises a real search to say whether the index answers, rather than just whether the tool is enabled. |
233
+ | `dsh_info` | Endpoint, version and uptime, token source, enabled groups, available harness services, usage/cost availability, the `catalog` of accepted providers, agent presets and sandbox mode, `toolAvailability` — the tools that passed every rule (`availableTools`, comparable with `tools/list`), those this deployment cannot serve, and why — and the push feed URIs in `notes`. `probeSearch` additionally exercises a real search to say whether the index answers, rather than just whether the tool is enabled. |
232
234
  | `workspace_create` | Register a directory as a DSH workspace (creating it if needed; pass `createDirectory: false` to require that it already exists). |
233
235
  | `workspace_list` | Every registered workspace with id, path, title, directory status, and the sessions **attached to the registration** (created with `workspaceId`) — that count is not the number of sessions whose `cwd` happens to be the directory; `session_list { cwd }` answers that. |
234
236
  | `workspace_delete` | Unregister a workspace (files and session history untouched); idempotent. |
@@ -353,29 +355,107 @@ into its rows. The full host-side contract — service signatures, grouping proo
353
355
  the recommended order of operations — is `docs/WORKTREE-INTEGRATION-CONTRACT.md`; the capability
354
356
  audit behind this release is `docs/NEW-CAPABILITIES-0.2.2.md`.
355
357
 
356
- ## Turn notifications for modern clients
357
-
358
- `session_prompt { wait: false }` returns as soon as the message is admitted, so the universal
359
- reporting path is polling `session_status` (optionally with `waitMs`). A client speaking the modern
360
- **2026-07-28** MCP protocol can be *told* instead of polling, over that protocol's
361
- `subscriptions/listen` stream:
362
-
363
- 1. **Before prompting**, open one `subscriptions/listen` request and keep it open, naming the feed
364
- you want: `notifications: { resourceSubscriptions: ["dsh://session/<sessionId>/turn"] }`. The
365
- server answers `notifications/subscriptions/acknowledged` on that stream.
366
- 2. Call `session_prompt` with `wait: false`.
367
- 3. When that turn settles — completed, failed, cancelled, or gone without an ending — the server
368
- publishes `notifications/resources/updated` for that URI on the stream. `resources/read` on the
369
- same URI returns the same payload as `session_status`, including `failure` and `lastTurn`.
370
-
371
- Two bounds are deliberate. The stream must be open **before** the prompt is admitted: a prompt
372
- admitted while nothing is listening arms no watcher, so a later subscription may miss that event.
373
- And push exists only in the modern protocol: 2025-era clients — including `bin/mcp-stdio.mjs` and
374
- the bundled smoke/exercise scripts — have no listen method. They are unaffected: polling stays the
375
- universal path, and a fire-and-forget prompt with no subscriber costs exactly one durable read, as
376
- before. The watcher is the same wait a blocking `session_prompt` performs (same `promptTimeoutMs`
377
- bound, same cancellation behaviour), one per request id, and a watch that timed out publishes
378
- nothing rather than claiming an ending it never saw.
358
+ ## Server push and the two transports
359
+
360
+ An MCP server cannot call a client on its own, so every "something changed" travels as a
361
+ resource notification. This endpoint publishes one change-feed surface over both standard push
362
+ channels, and each notification is **payload-less**: it names a synthetic resource, and the
363
+ client reads that resource for the JSON. No tools were added for this — the surface is still the
364
+ 35 in the table above — and push has no configuration key: it is always on and costs nothing
365
+ while nobody subscribes.
366
+
367
+ | Client era | Handshake | Server→client channel | How push is requested |
368
+ | --- | --- | --- | --- |
369
+ | **2026-07-28 (modern)** | `server/discover`; every request carries its own `_meta` envelope plus the `mcp-protocol-version` / `mcp-method` headers, so there is no session | one long-lived `subscriptions/listen` stream | name the feeds in `notifications.resourceSubscriptions`; the server answers `notifications/subscriptions/acknowledged` and then publishes `notifications/resources/updated` |
370
+ | **2025-era Streamable HTTP** | `initialize` answers with an `Mcp-Session-Id`; later `POST`/`DELETE` carry it | that session's long-lived `GET` with `Accept: text/event-stream` | the endpoint advertises `resources.subscribe` and publishes its feeds to every live stateful session, so the same `notifications/resources/updated` frames arrive on the stream; `resources/read` fetches the payload |
371
+ | **stdio** (`bin/mcp-stdio.mjs`) | the bridge POSTs `initialize` over HTTP and keeps the issued session id | its own long-lived `GET` on that session | each notification, and any server→client request, is written to stdout as one JSON line; the channel reconnects with 500 ms→30 s backoff |
372
+
373
+ The bridge reacts to a stateless endpoint — an `initialize` that carried no session id, or a `GET`
374
+ answered `404`/`405` — by printing one push-disabled note and leaving request/reply untouched.
375
+
376
+ A concrete modern exchange (a 2025 client replaces steps 1–2 with the stateful handshake and its
377
+ GET stream; the payloads are identical):
378
+
379
+ ```json
380
+ // 1. open one stream and name the feeds. Session feeds exist with tools.session,
381
+ // the job feeds with tools.shell; resources/list and resources/templates/list
382
+ // advertise them.
383
+ { "jsonrpc": "2.0", "id": 1, "method": "subscriptions/listen",
384
+ "params": { "notifications": { "resourceSubscriptions": [
385
+ "dsh://sessions",
386
+ "dsh://session/<sessionId>/events",
387
+ "dsh://session/<sessionId>/assistant",
388
+ "dsh://jobs" ] } } }
389
+
390
+ // 2. the acknowledgement names the subset the server honored
391
+ { "jsonrpc": "2.0", "method": "notifications/subscriptions/acknowledged",
392
+ "params": { "notifications": { "resourceSubscriptions": [ … ] } } }
393
+
394
+ // 3. every change is one payload-less frame …
395
+ { "jsonrpc": "2.0", "method": "notifications/resources/updated",
396
+ "params": { "uri": "dsh://session/<sessionId>/events" } }
397
+
398
+ // 4. … and resources/read on that URI returns the data
399
+ { "jsonrpc": "2.0", "id": 2, "method": "resources/read",
400
+ "params": { "uri": "dsh://session/<sessionId>/events" } }
401
+ ```
402
+
403
+ | Feed URI | Payload | Published when |
404
+ | --- | --- | --- |
405
+ | `dsh://sessions` | the session list as `session_list` reports it, minus titles (`sessions`, `total`) | a session is added, removed, created or disposed, starts or stops running, changes activity or status, or a subagent starts or ends |
406
+ | `dsh://session/{id}/events` | the newest durable events, projected to `{seq, type, at, turn, step, summary, error}` with `asOfSeq` (at most 64 events per read; `summary` is a bounded preview and `error` marks a failed tool result or an abnormal turn end) | every durable append to that session |
407
+ | `dsh://session/{id}/assistant` | live assistant frames `{seq, at, turn, step, kind, text, done}` with `asOfSeq` and `truncated`; `kind` is `start`, `text`, `reasoning`, `tool`, `finish` or `end`, and `seq` is monotonic per session | while a model turn streams — a frame for its start and end, then one per text, reasoning or tool-call delta, plus the finish frame |
408
+ | `dsh://session/{id}/status` | the same payload as `session_status` | any new durable event for that session, or a change in the agent's status |
409
+ | `dsh://session/{id}/projections` | the allowlisted projection values: `workingDirectory`, `worktree`, `goal`, `plan`, `todos`, `agentPreset`, `title` | a turn ends, an agent preset is selected, a user message is appended, or the goal changes |
410
+ | `dsh://jobs` | every background shell job this endpoint started (`jobs`) | a job starts or settles, or its status or pending bytes change |
411
+ | `dsh://jobs/{id}` | one job plus its retained output tail (`tail`, `tailTruncated`) | its output tail grows or its status changes |
412
+ | `dsh://session/{id}/turn` | the same payload as `session_status` | that turn settles — the resource from 0.52.86, still published immediately rather than coalesced |
413
+
414
+ **Backpressure, so a token stream does not become a request storm.** Publishes are coalesced per
415
+ URI: the first one goes out at once, later ones for the same URI collapse into a single trailing
416
+ publish after 150 ms (`PUSH_COALESCE_MS`), and different URIs never wait on each other. A token
417
+ stream therefore costs at most one notification per 150 ms per URI rather than one per token.
418
+ Turn settlement is the exception — it is a discrete event a caller is waiting for, so it is
419
+ published immediately. A notification is payload-less, so a burst of deltas costs one
420
+ `resources/read`, not one reply-sized payload per token. The live feeds are bounded in process:
421
+ the assistant buffer keeps at most 256 KiB / 2,000 frames per session, and a read returns at most
422
+ 200 frames and roughly 32 KiB of text (newest first, with `truncated` when older frames were
423
+ dropped or not returned), an events read is capped at 64 events, and a job read returns at most
424
+ the last 8,192 characters of a tail retained at 16 KiB. The job feed is polled about once a
425
+ second, and only while somebody is subscribed: with no subscriber the collectors return early and
426
+ no harness service is read, so a fire-and-forget prompt with no listener costs exactly what it
427
+ did before. The job tail is deliberately non-consuming — `shell_output` still owns the delivery
428
+ buffer, so watching `dsh://jobs/{id}` cannot steal bytes from the caller that polls it.
429
+
430
+ **The 2025-era leg is stateful, and the old stateless behavior is preserved.** A 2025 `initialize`
431
+ now answers with an `mcp-session-id` header; requests carrying it are served by a per-session
432
+ server and transport, so `GET` (the notification stream) and `DELETE` (session teardown) work,
433
+ and `notifications/resources/updated` reaches a 2025 client at last. At most 8 sessions are kept:
434
+ the least recently used is evicted when a ninth opens, and a session silent for 30 minutes is
435
+ closed, after which its id answers `404 Session not found` and the client re-initializes. A
436
+ client that never takes the session id keeps the previous stateless serving unchanged — every later
437
+ `POST` (one that is not another `initialize`) is answered from a fresh instance — and its
438
+ `GET`/`DELETE` are `405`: the stateless legacy GET is **not** a push channel, a *stateful session's*
439
+ GET is. Push has no replay store: a dropped stream reconnects without a cursor (`Last-Event-ID` is
440
+ not honored), which is safe precisely because notifications carry no payload — the client re-reads
441
+ the resource it was told about.
442
+
443
+ **Security and scope.** Every request goes through the bearer check — the long-lived 2025 `GET`
444
+ stream and session `DELETE` included — so there is no unauthenticated push path; a rejected
445
+ request answers `401` with `WWW-Authenticate: Bearer realm="dsh-as-mcp"`. `session.scope:
446
+ "owned"` gates notifications as well as reads: only a session this endpoint created or forked
447
+ (the recorded owned ids) produces a frame, so the channel cannot be used to enumerate other
448
+ sessions' ids. An owned session's own subagent sessions therefore produce no frames either —
449
+ their parent's feeds publish, and their feeds remain readable because reads follow the owned
450
+ tree like the tools do. The `dsh://jobs` feeds are not session-scoped: they show the background
451
+ jobs this endpoint started.
452
+
453
+ One bound is unchanged and still deliberate: for the turn feed, the stream must be open **before**
454
+ the prompt is admitted; a prompt admitted while nothing is listening arms no watcher, so a later
455
+ subscription may miss that settlement. The watcher is the same wait a blocking `session_prompt`
456
+ performs (same `promptTimeoutMs` bound, same cancellation behaviour), one per request id, and a
457
+ watch that timed out publishes nothing rather than claiming an ending it never saw. Push does not
458
+ replace polling: a client that subscribes to nothing behaves exactly as before.
379
459
 
380
460
  ## Usage accounting
381
461
 
@@ -943,6 +1023,15 @@ but a deployment can switch the capability off.
943
1023
  included) *before* the plugin is imported; an incompatible install is refused unless the host
944
1024
  grants an exact-version exemption (`dsh plugin allow-version`).
945
1025
  - **Node** `^22.19.0 || >=24.0.0`.
1026
+ - **MCP protocol eras.** One URL serves both the modern 2026-07-28 per-request protocol
1027
+ (`server/discover`, self-contained requests) and the 2025-era Streamable HTTP flow
1028
+ (`initialize` → `Mcp-Session-Id`, then stateful `POST`/`GET`/`DELETE`). A 2025 client that never
1029
+ takes the session id keeps the previous stateless serving unchanged, where `GET`/`DELETE` answer
1030
+ `405`; only a stateful session's `GET` is the push channel. Stateful sessions are capped at 8 and
1031
+ closed after 30 minutes idle (an evicted client re-initializes on the `404`), and the server does
1032
+ not implement resumability: `Last-Event-ID` replay is deliberately absent because every
1033
+ notification is payload-less, so a reconnecting client just re-reads the resource. The bundled
1034
+ `bin/mcp-stdio.mjs` uses the same stateful flow and forwards the stream's frames to stdout.
946
1035
  - The `@deepseek-ai/dsh` peer is declared **optional**, purely so `dsh plugin add` does not pull
947
1036
  a full harness install into every profile just to satisfy it. Optional only changes
948
1037
  installation — DSH's compatibility gate reads `peerDependencies` regardless of
@@ -1036,6 +1125,16 @@ but a deployment can switch the capability off.
1036
1125
  `session_pin`/`session_unpin` write the same registry sets the DSH sidebar's actions write, and
1037
1126
  `session_list` reports each row's `archived`/`pinned` flags. Archiving keeps the log and is
1038
1127
  reversible; nothing here removes a transcript.
1128
+ - **Push is a change signal, not a payload.** Every feed is a synthetic resource: a notification
1129
+ names one URI and `resources/read` returns the JSON, which is the only payload-carrying event a
1130
+ 2026-07-28 subscription can receive and the same shape a 2025 session's GET stream can carry
1131
+ unchanged. One publish surface feeds both transports — the modern handler's subscription bus and
1132
+ every live stateful 2025 session — so the two eras cannot disagree about which resource changed.
1133
+ Publishes are coalesced per URI at 150 ms (turn settlement immediate), the assistant buffer is
1134
+ bounded and only collects while a feed has subscribers, the job feed reads a retained,
1135
+ non-consuming tail, and a dropped stream is recoverable by one read because the notification
1136
+ itself carried nothing to lose. `session.scope: "owned"` applies to publishes as well as reads,
1137
+ so the push channel cannot be used to probe session ids the tools would refuse.
1039
1138
 
1040
1139
  ## Development
1041
1140
 
package/README.zh.md CHANGED
@@ -204,7 +204,8 @@ scheme 可以省略:`127.0.0.1:8790/mcp` 也能识别。
204
204
  `includeModels: true` 还会读每个路由声明的模型(`catalog.providerModels`):这会去问 adapter,所以默认关闭、
205
205
  每个 provider 都有超时,但这是选对 `session_create.model` 或 `session_set_model.model` 最可靠的办法。
206
206
  每个字段都是可选的、各自独立兜底,`catalog.unavailable` 会点名哪个读取没能回答,所以缺字段表示
207
- 「这个 profile 没说」,不是「什么都不允许」。
207
+ 「这个 profile 没说」,不是「什么都不允许」。它的 `notes` 里还会列出本端点的推送 feed 与两种传输,
208
+ 调用方不必读这份 README 也能找到订阅面。
208
209
 
209
210
  ## 工具
210
211
 
@@ -214,7 +215,7 @@ scheme 可以省略:`127.0.0.1:8790/mcp` 也能识别。
214
215
 
215
216
  | 工具 | 作用 |
216
217
  | --- | --- |
217
- | `dsh_info` | 端点、版本与运行时长、token 来源、已启用的工具组、可用的 harness 服务、用量/费用是否可用、可接受的 provider/agent preset/sandbox 模式 `catalog`,以及 `toolAvailability`(通过全部规则的 `availableTools`,可与 `tools/list` 对照;以及本部署无法服务的工具及原因);`probeSearch` 可验证搜索是否真的可用。 |
218
+ | `dsh_info` | 端点、版本与运行时长、token 来源、已启用的工具组、可用的 harness 服务、用量/费用是否可用、可接受的 provider/agent preset/sandbox 模式 `catalog`、`toolAvailability`(通过全部规则的 `availableTools`,可与 `tools/list` 对照;以及本部署无法服务的工具及原因),以及 `notes` 里的推送 feed URI;`probeSearch` 可验证搜索是否真的可用。 |
218
219
  | `workspace_create` | 把一个目录注册成 DSH 工作区(目录不存在时先创建;传 `createDirectory: false` 则要求它已存在)。 |
219
220
  | `workspace_list` | 列出所有工作区:id、路径、标题、目录是否还在,以及**挂在该注册下**的会话数(用 `workspaceId` 创建的会话)——这不是"cwd 恰好是该目录"的会话数,后者由 `session_list { cwd }` 回答。 |
220
221
  | `workspace_delete` | 注销一个工作区(文件与会话历史不受影响);幂等。 |
@@ -323,27 +324,89 @@ profile 能读 session projection 时,`session_status` 会报 `workingDirector
323
324
  分组证明、失败矩阵与推荐操作顺序——见 `docs/WORKTREE-INTEGRATION-CONTRACT.md`;本次发布背后的能力审计是
324
325
  `docs/NEW-CAPABILITIES-0.2.2.md`。
325
326
 
326
- ## 现代客户端的 turn 结束通知
327
+ ## 服务端推送与两种传输
327
328
 
328
- `session_prompt { wait: false }` 在消息被接收后立刻返回,所以通用的上报路径是轮询
329
- `session_status`(可带 `waitMs`)。而说现代 **2026-07-28** MCP 协议的客户端不必轮询:它可以
330
- 用该协议的 `subscriptions/listen` 流被**主动告知**:
329
+ MCP 服务无法主动调用客户端,所以「有什么变了」只能以资源通知的形式送出去。本端点把同一套变更 feed 发布到
330
+ 两种标准的推送通道上,而且每条通知都**不携带负载**:它只点名一个合成资源,客户端再去读那个资源拿 JSON。
331
+ 这件事没有新增任何工具——工具面仍是上面表格里的 35 个——推送也没有配置键:它始终开启,且在无人订阅时
332
+ 零成本。
331
333
 
332
- 1. **先订阅再发 prompt**:开一个保持打开的 `subscriptions/listen` 请求,声明要听的 feed:
333
- `notifications: { resourceSubscriptions: ["dsh://session/<sessionId>/turn"] }`,服务端会在
334
- 该流上回 `notifications/subscriptions/acknowledged`。
335
- 2. 用 `wait: false` 调 `session_prompt`。
336
- 3. 这一轮结束时——完成、失败、被取消,或没有 `turn/end` 而消失——服务端在该流上推送
337
- `notifications/resources/updated`(带这个 URI)。对同一 URI 调 `resources/read` 会返回与
338
- `session_status` 相同的 JSON,包括 `failure` 与 `lastTurn`。
334
+ | 客户端时代 | 握手 | 服务端→客户端通道 | 如何请求推送 |
335
+ | --- | --- | --- | --- |
336
+ | **2026-07-28(现代)** | `server/discover`;每个请求自带 `_meta` 信封以及 `mcp-protocol-version` / `mcp-method` 头,因此没有会话 | 一条长期打开的 `subscriptions/listen` 流 | 在 `notifications.resourceSubscriptions` 里点名 feed;服务端回 `notifications/subscriptions/acknowledged`,之后推 `notifications/resources/updated` |
337
+ | **2025 时代 Streamable HTTP** | `initialize` 的回复带 `Mcp-Session-Id`;之后的 `POST`/`DELETE` 都带它 | 该会话长期打开的 `GET`(`Accept: text/event-stream`) | 端点宣布 `resources.subscribe`,并把 feed 发布给每个存活的 stateful 会话,因此同样的 `notifications/resources/updated` 帧会出现在这条流上;数据仍用 `resources/read` 取 |
338
+ | **stdio**(`bin/mcp-stdio.mjs`) | 桥接脚本经 HTTP 发 `initialize`,并保留服务端给出的会话 id | 它在同一会话上自己开一条长期 `GET` | 流上每条通知(以及任何服务端→客户端请求)都作为一行 JSON 写到 stdout;通道断开后按 500 ms→30 s 退避重连 |
339
339
 
340
- 有两条边界是有意为之。第一,流必须在 prompt 被接收**之前**打开:没有任何监听者时不会为
341
- fire-and-forget 的 prompt 安排 watcher,因此之后再订阅可能错过那一次事件。第二,推送只存在于
342
- 现代协议里:2025 客户端(包括 `bin/mcp-stdio.mjs` 与仓库自带的 smoke/exercise 脚本)没有
343
- listen 方法。它们不受影响:轮询仍是通用路径,没有订阅者的 fire-and-forget prompt 仍然只多花
344
- 一次持久日志读取。watcher 就是阻塞式 `session_prompt` 用的同一个等待(同样的
345
- `promptTimeoutMs` 上限、同样的取消行为),每个 request id 一个;等到超时的 watcher 什么也不推,
346
- 不会宣称一个它没看见的结束。
340
+ 遇到 stateless 端点时——`initialize` 没带会话 id,或 `GET` 回答 `404`/`405`——桥接脚本只打印一条
341
+ 「push disabled」提示,请求/应答行为不变。
342
+
343
+ 一个具体的现代交互(2025 客户端把第 1–2 步换成 stateful 握手与它的 GET 流;负载完全相同):
344
+
345
+ ```json
346
+ // 1. 开一条流并点名 feed。会话类 feed 需要 tools.session,任务类 feed 需要
347
+ // tools.shell;resources/list 与 resources/templates/list 会公布它们。
348
+ { "jsonrpc": "2.0", "id": 1, "method": "subscriptions/listen",
349
+ "params": { "notifications": { "resourceSubscriptions": [
350
+ "dsh://sessions",
351
+ "dsh://session/<sessionId>/events",
352
+ "dsh://session/<sessionId>/assistant",
353
+ "dsh://jobs" ] } } }
354
+
355
+ // 2. 确认里列出服务端真正接受的子集
356
+ { "jsonrpc": "2.0", "method": "notifications/subscriptions/acknowledged",
357
+ "params": { "notifications": { "resourceSubscriptions": [ … ] } } }
358
+
359
+ // 3. 每次变化就是一条不带负载的帧……
360
+ { "jsonrpc": "2.0", "method": "notifications/resources/updated",
361
+ "params": { "uri": "dsh://session/<sessionId>/events" } }
362
+
363
+ // 4. ……再对那个 URI 调 resources/read 拿数据
364
+ { "jsonrpc": "2.0", "id": 2, "method": "resources/read",
365
+ "params": { "uri": "dsh://session/<sessionId>/events" } }
366
+ ```
367
+
368
+ | feed URI | 负载 | 何时发布 |
369
+ | --- | --- | --- |
370
+ | `dsh://sessions` | 与 `session_list` 一致的会话列表,但不含标题(`sessions`、`total`) | 会话被新增、移除、创建或销毁,开始/停止运行,活动或状态变化,或 subagent 开始/结束 |
371
+ | `dsh://session/{id}/events` | 最新的持久事件,投影为 `{seq, type, at, turn, step, summary, error}` 并带 `asOfSeq`(单次读取至多 64 个事件;`summary` 是有界预览,`error` 标记失败的 tool 结果或异常结束的轮次) | 该会话每次持久追加 |
372
+ | `dsh://session/{id}/assistant` | 实时的 assistant 帧 `{seq, at, turn, step, kind, text, done}`,带 `asOfSeq` 与 `truncated`;`kind` 为 `start`、`text`、`reasoning`、`tool`、`finish` 或 `end`,`seq` 每会话单调递增 | 模型轮次流式输出期间——开始时一帧、结束时一帧,每个 text、reasoning 或 tool-call 增量各一帧,另有 finish 帧 |
373
+ | `dsh://session/{id}/status` | 与 `session_status` 相同的负载 | 该会话出现新的持久事件,或 agent 状态变化 |
374
+ | `dsh://session/{id}/projections` | 白名单内的 projection 值:`workingDirectory`、`worktree`、`goal`、`plan`、`todos`、`agentPreset`、`title` | 轮次结束、选定 agent preset、追加用户消息,或 goal 变化 |
375
+ | `dsh://jobs` | 本端点启动的每个后台 shell 任务(`jobs`) | 任务开始或结束,或其状态、待读字节数变化 |
376
+ | `dsh://jobs/{id}` | 单个任务及其保留的输出尾部(`tail`、`tailTruncated`) | 它的输出尾部增长或状态变化 |
377
+ | `dsh://session/{id}/turn` | 与 `session_status` 相同的负载 | 该轮结算——0.52.86 就有的资源,仍然立即发布、不参与合并 |
378
+
379
+ **背压:别让 token 流变成请求风暴。** 发布按 URI 合并:窗口内第一次立即发出,同一个 URI 的后续发布合并成
380
+ 150 ms(`PUSH_COALESCE_MS`)后的一次尾随发布,不同 URI 之间互不等待。因此一段 token 流对每个 URI 至多
381
+ 每 150 ms 产生一条通知,而不是每个 token 一条。轮次结算是例外——那是调用方正在等的离散事件,所以立即发布。
382
+ 通知不携带负载,所以一串增量只花一次 `resources/read`,而不是每个 token 一份与回复等大的负载。实时 feed 在
383
+ 进程内是有界的:assistant 缓冲每会话至多保留 256 KiB / 2,000 帧,单次读取至多返回 200 帧、约 32 KiB 文本
384
+ (从新到旧;更旧的帧被丢弃或未返回时带 `truncated`),事件读取上限 64 条,任务读取至多返回 16 KiB 保留尾部
385
+ 里的最后 8,192 个字符。任务 feed 大约每秒轮询一次,而且只在有人订阅时轮询:没有订阅者时采集器提前返回、
386
+ 不读任何 harness 服务,所以没有监听者的 fire-and-forget prompt 成本和以前完全一样。任务尾部刻意设计成
387
+ **不消费**:投递缓冲仍归 `shell_output`,因此盯 `dsh://jobs/{id}` 不会从轮询它的调用方那里偷走字节。
388
+
389
+ **2025 时代那条腿是有状态的,旧的 stateless 行为照旧保留。** 2025 的 `initialize` 现在会在回复里带
390
+ `mcp-session-id` 头;带这个头的请求由每会话的 server 与 transport 服务,所以 `GET`(通知流)与 `DELETE`
391
+ (会话拆除)都能用,`notifications/resources/updated` 也终于能到达 2025 客户端。同时最多保留 8 个会话:
392
+ 开第 9 个时淘汰最久未使用的那个;静默 30 分钟的会话会被关闭,之后它的 id 回答
393
+ `404 Session not found`,客户端重新 initialize。从不接收会话 id 的客户端保持此前的 stateless 服务不变——
394
+ 之后每个 `POST`(不是再次 `initialize` 的那种)都由一个全新实例应答——它的 `GET`/`DELETE` 仍是 `405`:
395
+ stateless 的旧式 GET **不是**推送通道,有状态会话的 GET 才是。推送没有重放存储:断开的流重连时没有游标
396
+ (不认 `Last-Event-ID`),这之所以安全,正是因为通知不携带负载——客户端重新读一遍它被告知的资源即可。
397
+
398
+ **安全与作用域。** 每个请求都要过 bearer 校验——包括长期打开的 2025 `GET` 流和会话 `DELETE`,所以不存在
399
+ 未鉴权的推送路径;被拒绝的请求返回 `401` 并带 `WWW-Authenticate: Bearer realm="dsh-as-mcp"`。
400
+ `session.scope: "owned"` 同样约束通知,而不只是读取:只有本端点创建或 fork 的会话(记录在 owned id 集合
401
+ 里的那些)才会产生帧,因此这条通道无法用来枚举其他会话的 id。于是一个 owned 会话自己的 subagent 会话也不
402
+ 会产生帧——它父会话的 feed 照常发布,而这些 subagent 的 feed 仍**可读**,因为读取和工具一样沿 owned 树
403
+ 放行。`dsh://jobs` 系列 feed 不按会话划分作用域:它们显示的是本端点启动的后台任务。
404
+
405
+ 有一条边界没有变,而且仍是有意为之:对 turn feed 来说,流必须在 prompt 被接收**之前**打开;在没有任何
406
+ 监听者时接收的 prompt 不会安排 watcher,之后再订阅可能错过那一次结算。watcher 就是阻塞式
407
+ `session_prompt` 用的同一个等待(同样的 `promptTimeoutMs` 上限、同样的取消行为),每个 request id 一个;
408
+ 等到超时的 watcher 什么也不推,不会宣称一个它没看见的结束。推送不取代轮询:什么都不订阅的客户端行为
409
+ 和以前完全一样。
347
410
 
348
411
  ## 用量统计
349
412
 
@@ -832,6 +895,13 @@ profile 推导同一份映射;不过配置 profile 的人应当能在一处读
832
895
  唯一那个运行时版本做 semver 比对(预发布版也参与比对);不兼容的安装会被拒载,除非宿主显式
833
896
  给出精确版本豁免(`dsh plugin allow-version`)。
834
897
  - **Node** `^22.19.0 || >=24.0.0`。
898
+ - **两种 MCP 协议时代。** 同一个 URL 同时服务现代 2026-07-28 的逐请求协议(`server/discover`、请求自包含)
899
+ 与 2025 时代的 Streamable HTTP 流程(`initialize` → `Mcp-Session-Id`,之后是有状态的
900
+ `POST`/`GET`/`DELETE`)。从不接收会话 id 的 2025 客户端保持此前的 stateless 服务不变,其中
901
+ `GET`/`DELETE` 回答 `405`;只有有状态会话的 `GET` 才是推送通道。有状态会话上限 8 个、静默 30 分钟后
902
+ 关闭(被淘汰的客户端会在 `404` 上重新 initialize),并且服务端不实现可恢复性:刻意不做
903
+ `Last-Event-ID` 重放,因为每条通知都不携带负载,重连的客户端重新读一次资源即可。随包的
904
+ `bin/mcp-stdio.mjs` 走同一套有状态流程,并把流上的帧转发到 stdout。
835
905
  - `@deepseek-ai/dsh` 这个 peer 声明为**可选**,纯粹是为了不让 `dsh plugin add` 为了满足它就把
836
906
  一整个 harness 装进每个 profile。可选只影响安装行为——DSH 的兼容性检查不看
837
907
  `peerDependenciesMeta`,只读 `peerDependencies`,所以上面那条版本校验照样生效,一条都不少。
@@ -903,6 +973,13 @@ profile 推导同一份映射;不过配置 profile 的人应当能在一处读
903
973
  同样不提供。`session_archive`/`session_unarchive` 与 `session_pin`/`session_unpin` 写入的正是
904
974
  DSH 侧边栏动作写入的那组 registry 集合,`session_list` 会报出每行的 `archived`/`pinned` 标志;
905
975
  归档保留日志且可逆,这里没有任何操作会删掉对话记录。
976
+ - **推送只是变更信号,不是负载。** 每个 feed 都是一个合成资源:通知只点名一个 URI,`resources/read`
977
+ 返回 JSON——这既是 2026-07-28 订阅能收到的唯一一种带负载事件,也正是 2025 会话 GET 流可以原样承载的
978
+ 形状。同一套发布面同时喂两种传输——现代 handler 的订阅总线与每个存活的 stateful 2025 会话——所以两个
979
+ 时代不可能对「哪个资源变了」给出不同答案。发布按 URI 以 150 ms 合并(轮次结算立即发出),assistant
980
+ 缓冲有界且只在有订阅者时采集,任务 feed 读的是不消费的保留尾部;断开的流读一次即可恢复,因为通知本身
981
+ 没有可丢失的内容。`session.scope: "owned"` 同样作用于发布而不只是读取,因此推送通道无法被用来试探
982
+ 工具会拒绝的会话 id。
906
983
 
907
984
  ## 开发
908
985