diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 66867456de..beb59250f2 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -47,6 +47,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` |
| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) |
+| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
@@ -67,7 +68,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index b30f3e1ac4..5020470377 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -49,6 +49,7 @@
| `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` |
| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) |
+| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
@@ -69,7 +70,7 @@
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md
index 2c1d1db11e..8ab5ae52d8 100644
--- a/docs/subsystems/forge.md
+++ b/docs/subsystems/forge.md
@@ -216,7 +216,7 @@ interface SessionChangeRequest {
```ts type-equiv
/**
- * Receives the complete visible list after a binding change or a polling result.
+ * Receives the complete visible list after a binding or provider change, or a polling result.
* @param changeRequests - every visible binding of the watched Session, in first-recorded order.
*/
type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void
@@ -338,7 +338,7 @@ dismiss(session: Session, url: string): void
* Watch a Session's visible change requests as an effect on the calling
* fiber. Every visible change request that a provider recognizes is polled
* through `ctx.forge` while the Session has a watcher. Listeners receive the
- * complete list after each later binding change or polling result; read the
+ * complete list after each later binding or provider change, or polling result; read the
* current list with {@link changeRequests}. Listeners must not throw; a
* thrown error is logged and skipped.
* @param session - Session to watch.
@@ -351,4 +351,26 @@ watch(session: Session, listener: SessionChangeRequestsListener): () => void
Types: [Session](session.md)
Source: [`packages/experimental/forge-session/src/index.ts`](../../packages/experimental/forge-session/src/index.ts)
+
+
+
+### `forge/*` events
+
+
+
+#### `forge/providers-changed` — parallel
+
+Announces a provider registration or withdrawal after the registry changes. Listener failures are logged without preventing other listeners from running.
+
+```ts cordis-catalog
+/**
+ * Announces a provider registration or withdrawal after the registry changes.
+ * Listener failures are logged without preventing other listeners from running.
+ * @param forge - family whose provider changed.
+ * @mode parallel
+ */
+'forge/providers-changed'(forge: string): void | Promise
+```
+
+Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental/forge/src/index.ts)
diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md
index d14d6a1f43..988bd358b1 100644
--- a/docs/subsystems/forge.zh.md
+++ b/docs/subsystems/forge.zh.md
@@ -216,7 +216,7 @@ interface SessionChangeRequest {
```ts type-equiv
/**
- * Receives the complete visible list after a binding change or a polling result.
+ * Receives the complete visible list after a binding or provider change, or a polling result.
* @param changeRequests - every visible binding of the watched Session, in first-recorded order.
*/
type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void
@@ -338,7 +338,7 @@ dismiss(session: Session, url: string): void
* Watch a Session's visible change requests as an effect on the calling
* fiber. Every visible change request that a provider recognizes is polled
* through `ctx.forge` while the Session has a watcher. Listeners receive the
- * complete list after each later binding change or polling result; read the
+ * complete list after each later binding or provider change, or polling result; read the
* current list with {@link changeRequests}. Listeners must not throw; a
* thrown error is logged and skipped.
* @param session - Session to watch.
@@ -351,4 +351,26 @@ watch(session: Session, listener: SessionChangeRequestsListener): () => void
Types: [Session](session.zh.md)
Source: [`packages/experimental/forge-session/src/index.ts`](../../packages/experimental/forge-session/src/index.ts)
+
+
+
+### `forge/*` events
+
+
+
+#### `forge/providers-changed` — parallel
+
+Announces a provider registration or withdrawal after the registry changes. Listener failures are logged without preventing other listeners from running.
+
+```ts cordis-catalog
+/**
+ * Announces a provider registration or withdrawal after the registry changes.
+ * Listener failures are logged without preventing other listeners from running.
+ * @param forge - family whose provider changed.
+ * @mode parallel
+ */
+'forge/providers-changed'(forge: string): void | Promise
+```
+
+Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental/forge/src/index.ts)
diff --git a/packages/experimental/forge-session/README.i18n.yaml b/packages/experimental/forge-session/README.i18n.yaml
index 1dd3eaaf22..8eac73087a 100644
--- a/packages/experimental/forge-session/README.i18n.yaml
+++ b/packages/experimental/forge-session/README.i18n.yaml
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 2f8a621b991260a1
/deepseek-ai-dsh-experimental-forge-session/use-this-package:
- en: 022ab6a18364aa44
- zh: adc91aef3f4273e3
+ en: 647d713c5c8e7900
+ zh: 5405b587e6cc27e7
/deepseek-ai-dsh-experimental-forge-session/understand-the-implementation:
en: ccc7ad69f5837528
zh: 369c08bf4f7e6e4d
diff --git a/packages/experimental/forge-session/README.md b/packages/experimental/forge-session/README.md
index a242d1b5a5..1d76ddd15c 100644
--- a/packages/experimental/forge-session/README.md
+++ b/packages/experimental/forge-session/README.md
@@ -34,7 +34,7 @@ Mount the service with the forge registry, a provider, and the Session projectio
- name: '@deepseek-ai/dsh-experimental-forge-session'
```
-`ctx.forgeSession.changeRequests(session)` returns the visible bindings in first-recorded order. Each entry carries the URL, its `origin` (`created`, `discovered`, or `tracked`), the provider reference or `null` when no provider serves the URL, and the latest polling result or `null`. `watch(session, listener)` is an effect on the calling fiber: while a Session has a watcher, every visible binding that a provider serves is polled, and listeners receive the complete list after each later binding change or polling result. `track(session, url)` binds a URL under the provider's canonical form, or restores a dismissed binding, and throws when no provider recognizes the URL. `dismiss(session, url)` hides a visible binding; other URLs leave the log unchanged.
+`ctx.forgeSession.changeRequests(session)` returns the visible bindings in first-recorded order. Each entry carries the URL, its `origin` (`created`, `discovered`, or `tracked`), the provider reference or `null` when no provider serves the URL, and the latest polling result or `null`. `watch(session, listener)` is an effect on the calling fiber: while a Session has a watcher, every visible binding that a provider serves is polled, and listeners receive the complete list after each later binding change, provider registration change, or polling result. Provider changes resynchronize existing watches, including URLs that were previously unserved. `track(session, url)` binds a URL under the provider's canonical form, or restores a dismissed binding, and throws when no provider recognizes the URL. `dismiss(session, url)` hides a visible binding; other URLs leave the log unchanged.
A `bash` or `pwsh` call binds evidence only from its first result, and only when the tool execution itself succeeded; the command's exit status does not matter. A command containing `gh pr create` or `gh pr new` binds every `/pull/` URL in its text output. A command containing `git push`, optionally with leading `-C`, `-c`, or long Git options, records each branch listed under a `To ` line as created, updated, forced, or already up to date; rejected and deleted refs, new tags, and explicit refs outside `refs/heads/` are skipped. For each pushed branch whose remote a provider serves, the service runs `ctx.forge.findByHead` within `discoveryTimeoutMs` and appends a `discover` record for every returned change request that is not bound. Dismissed bindings remain bound and are skipped; only `track` restores them. Every push repeats the lookup; failures are logged as warnings. Closing a Session cancels its unfinished lookups without adding bindings. Reopening it does not repeat those lookups; another push or `track` can bind the change request. URLs that differ only in scheme, letter case, query, fragment, or trailing slashes name the same binding.
diff --git a/packages/experimental/forge-session/README.zh.md b/packages/experimental/forge-session/README.zh.md
index 076b876b21..92f01a744a 100644
--- a/packages/experimental/forge-session/README.zh.md
+++ b/packages/experimental/forge-session/README.zh.md
@@ -34,7 +34,7 @@ kind: "package-reference"
- name: '@deepseek-ai/dsh-experimental-forge-session'
```
-`ctx.forgeSession.changeRequests(session)` 按首次记录的顺序返回可见的绑定。每一项包含 URL、其 `origin`(`created`、`discovered` 或 `tracked`)、提供方引用(没有提供方服务该 URL 时为 `null`)以及最近一次轮询结果(尚无结果时为 `null`)。`watch(session, listener)` 是调用方 fiber 上的 effect:Session 有监听方期间,提供方服务的每个可见绑定都会被轮询;此后每次绑定变化或轮询结果到达时,监听方都会收到完整列表。`track(session, url)` 以提供方的规范形式绑定 URL,或恢复已隐藏的绑定;没有提供方识别该 URL 时抛错。`dismiss(session, url)` 隐藏一个可见绑定;其他 URL 不会改变日志。
+`ctx.forgeSession.changeRequests(session)` 按首次记录的顺序返回可见的绑定。每一项包含 URL、其 `origin`(`created`、`discovered` 或 `tracked`)、提供方引用(没有提供方服务该 URL 时为 `null`)以及最近一次轮询结果(尚无结果时为 `null`)。`watch(session, listener)` 是调用方 fiber 上的 effect:Session 有监听方期间,提供方服务的每个可见绑定都会被轮询;此后每次绑定变化、提供方注册变化或轮询结果到达时,监听方都会收到完整列表。提供方变化会重新同步已有监听,包括此前没有提供方服务的 URL。`track(session, url)` 以提供方的规范形式绑定 URL,或恢复已隐藏的绑定;没有提供方识别该 URL 时抛错。`dismiss(session, url)` 隐藏一个可见绑定;其他 URL 不会改变日志。
`bash` 或 `pwsh` 调用只从其第一个结果中提取证据,且仅当工具执行本身成功时才提取;命令的退出状态不影响结果。包含 `gh pr create` 或 `gh pr new` 的命令会绑定其文本输出中的每个 `/pull/` URL。包含 `git push` 的命令(可带前置的 `-C`、`-c` 或 Git 长选项)会记录 `To ` 行下列出的每个已创建、已更新、已强制推送或已是最新的分支;被拒绝、已删除的引用、新标签,以及明确位于 `refs/heads/` 之外的引用会被跳过。对远端由提供方服务的每个被推送分支,服务在 `discoveryTimeoutMs` 内调用 `ctx.forge.findByHead`,并为返回的每个尚未绑定的变更请求追加一条 `discover` 记录。已隐藏的绑定仍视为已绑定而被跳过;只有 `track` 会恢复它们。每次推送都会重新查找;失败记录为警告日志。关闭 Session 会取消其未完成的查找,不追加绑定。重新打开不会补做这些查找;可再次推送或用 `track` 绑定变更请求。仅在协议、大小写、查询、片段或末尾斜杠上不同的 URL 指向同一个绑定。
diff --git a/packages/experimental/forge-session/src/index.ts b/packages/experimental/forge-session/src/index.ts
index 09ada55ce7..e3a55f3006 100644
--- a/packages/experimental/forge-session/src/index.ts
+++ b/packages/experimental/forge-session/src/index.ts
@@ -45,7 +45,7 @@ export class ForgeSessionService extends Service {
private readonly home: Context
private readonly discoveryTimeoutMs: number
- private readonly watches = new WeakMap()
+ private readonly watches = new Map()
private readonly discoveries = new Set>()
private readonly sessionLifetimes = new WeakMap()
private readonly lifetime = new AbortController()
@@ -106,7 +106,7 @@ export class ForgeSessionService extends Service {
* Watch a Session's visible change requests as an effect on the calling
* fiber. Every visible change request that a provider recognizes is polled
* through `ctx.forge` while the Session has a watcher. Listeners receive the
- * complete list after each later binding change or polling result; read the
+ * complete list after each later binding or provider change, or polling result; read the
* current list with {@link changeRequests}. Listeners must not throw; a
* thrown error is logged and skipped.
* @param session - Session to watch.
@@ -144,6 +144,12 @@ export class ForgeSessionService extends Service {
this.sessionLifetimes.get(session)?.abort(new Error('forge-session: Session was disposed'))
this.sessionLifetimes.delete(session)
})
+ this.home.on('forge/providers-changed', () => {
+ for (const watch of [...this.watches.values()]) {
+ this.sync(watch)
+ this.notify(watch)
+ }
+ })
}
private state(session: Session): ForgeChangeRequestsState {
diff --git a/packages/experimental/forge-session/src/types.ts b/packages/experimental/forge-session/src/types.ts
index b9c6083cb9..921bb525a5 100644
--- a/packages/experimental/forge-session/src/types.ts
+++ b/packages/experimental/forge-session/src/types.ts
@@ -73,7 +73,7 @@ export interface SessionChangeRequest {
}
/**
- * Receives the complete visible list after a binding change or a polling result.
+ * Receives the complete visible list after a binding or provider change, or a polling result.
* @param changeRequests - every visible binding of the watched Session, in first-recorded order.
*/
export type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void
diff --git a/packages/experimental/forge-session/tests/service.spec.ts b/packages/experimental/forge-session/tests/service.spec.ts
index 10f22b0091..df8796cce6 100644
--- a/packages/experimental/forge-session/tests/service.spec.ts
+++ b/packages/experimental/forge-session/tests/service.spec.ts
@@ -7,7 +7,7 @@ import type { Session } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import ForgeSessionService from '../src/index.ts'
import type { Config, SessionChangeRequest, SessionChangeRequestsListener } from '../src/index.ts'
-import { bench, demo, pull, pullUrl, pushOutput, shellTurn, snapshot } from './fixtures.ts'
+import { bench, demo, FakeProvider, pull, pullUrl, pushOutput, shellTurn, snapshot } from './fixtures.ts'
import type { Bench } from './fixtures.ts'
const contexts: Context[] = []
@@ -233,6 +233,38 @@ describe('forgeSession track and dismiss', () => {
})
describe('forgeSession watch', () => {
+ it('resynchronizes existing watches when providers arrive, leave, and return', async () => {
+ const ctx = new Context()
+ contexts.push(ctx)
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ await ctx.plugin(ForgeService)
+ await ctx.plugin(ForgeSessionService)
+ const session = ctx.sessions.create()
+ create(session, 'create', pullUrl(7))
+ const listener = vi.fn()
+ const stop = ctx.forgeSession.watch(session, listener)
+ expect(ctx.forgeSession.changeRequests(session)[0]?.ref).toBeNull()
+
+ const provider = new FakeProvider()
+ const register = () => ctx.plugin({ inject: ['forge'], apply(child: Context) { child.forge.register(provider) } })
+ const first = register()
+ await first
+ await vi.waitFor(() => { expect(lastList(listener)[0]?.update).toMatchObject({ snapshot: { ref: pull(7) } }) })
+ expect(provider.fetches).toEqual([[7]])
+ await first.dispose()
+ expect(lastList(listener)[0]).toMatchObject({ ref: null, update: null })
+
+ await register()
+ await vi.waitFor(() => { expect(provider.fetches).toEqual([[7], [7]]) })
+ expect(lastList(listener)[0]?.ref).toEqual(pull(7))
+ stop()
+ const calls = listener.mock.calls.length
+ ctx.emit('forge/providers-changed', 'fake')
+ expect(listener).toHaveBeenCalledTimes(calls)
+ expect(ctx.forge.latest(pull(7))).toBeUndefined()
+ })
+
function lastList(listener: Mock): readonly SessionChangeRequest[] {
return listener.mock.lastCall?.[0] ?? []
}
diff --git a/packages/experimental/forge/README.i18n.yaml b/packages/experimental/forge/README.i18n.yaml
index ed4391fd31..e5563d1e71 100644
--- a/packages/experimental/forge/README.i18n.yaml
+++ b/packages/experimental/forge/README.i18n.yaml
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 2f8a621b991260a1
/deepseek-ai-dsh-experimental-forge/use-this-package:
- en: 341eeed1ab1b3e82
- zh: 0b97ec8bb557a6c9
+ en: a16d7eb7b6abee1f
+ zh: e2b93e1aa95e2aaa
/deepseek-ai-dsh-experimental-forge/understand-the-implementation:
en: e193cb547c47069b
zh: faa517522402ea41
diff --git a/packages/experimental/forge/README.md b/packages/experimental/forge/README.md
index b8388c9371..7c71e843eb 100644
--- a/packages/experimental/forge/README.md
+++ b/packages/experimental/forge/README.md
@@ -32,7 +32,7 @@ Mount the registry with at least one provider package, such as the GitHub provid
- name: '@deepseek-ai/dsh-experimental-forge-github'
```
-`ctx.forge.register(provider)` is an effect on the calling fiber and returns an async disposer that cancels and drains that registration's calls. Results arriving after cancellation are discarded. One provider serves one forge family; registering a second provider for the same family throws. `repositoryFromRemote(url)` and `changeRequestFromUrl(url)` ask providers in registration order and return the first match. `changeRequestUrl(ref)` and `findByHead(repository, branch, signal)` delegate to the provider of the reference's forge and throw when none is registered.
+`ctx.forge.register(provider)` is an effect on the calling fiber and returns an async disposer that cancels and drains that registration's calls. Results arriving after cancellation are discarded. One provider serves one forge family; registering a second provider for the same family throws. Registration changes announce `forge/providers-changed`; registering a provider polls its existing watches immediately. `repositoryFromRemote(url)` and `changeRequestFromUrl(url)` ask providers in registration order and return the first match. `changeRequestUrl(ref)` and `findByHead(repository, branch, signal)` delegate to the provider of the reference's forge and throw when none is registered.
`ctx.forge.watch(ref, listener)` is also an effect. The first watcher of a change request polls it at once; `latest(ref)` returns the most recent result. Each forge has at most one fetch in flight, and that fetch includes every due change request of the forge. An open change request is polled every `activeIntervalMs` while checks are pending or mergeability is unknown, and every `idleIntervalMs` otherwise. Closed and merged change requests are not polled again. A failed read repeats after `activeIntervalMs` doubled for each consecutive failure, up to `maxBackoffMs`. Unregistering a provider settles its watched change requests as `unavailable`. Polling stops when the last watcher leaves. The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge) lists the defaults.
diff --git a/packages/experimental/forge/README.zh.md b/packages/experimental/forge/README.zh.md
index c6f915549c..c34d6b7f8f 100644
--- a/packages/experimental/forge/README.zh.md
+++ b/packages/experimental/forge/README.zh.md
@@ -32,7 +32,7 @@ kind: "package-reference"
- name: '@deepseek-ai/dsh-experimental-forge-github'
```
-`ctx.forge.register(provider)` 是调用方 fiber 上的 effect,返回异步 disposer,取消并等待该次注册的调用结束。取消后到达的结果会被丢弃。一个提供方服务一个 forge 家族;为同一家族注册第二个提供方会抛错。`repositoryFromRemote(url)` 与 `changeRequestFromUrl(url)` 按注册顺序询问提供方并返回第一个匹配结果。`changeRequestUrl(ref)` 与 `findByHead(repository, branch, signal)` 委托给引用所属 forge 的提供方;没有注册提供方时抛错。
+`ctx.forge.register(provider)` 是调用方 fiber 上的 effect,返回异步 disposer,取消并等待该次注册的调用结束。取消后到达的结果会被丢弃。一个提供方服务一个 forge 家族;为同一家族注册第二个提供方会抛错。注册变化会发出 `forge/providers-changed`;注册提供方会立即轮询其已有监听。`repositoryFromRemote(url)` 与 `changeRequestFromUrl(url)` 按注册顺序询问提供方并返回第一个匹配结果。`changeRequestUrl(ref)` 与 `findByHead(repository, branch, signal)` 委托给引用所属 forge 的提供方;没有注册提供方时抛错。
`ctx.forge.watch(ref, listener)` 同样是 effect。变更请求的第一个监听方会立即触发轮询;`latest(ref)` 返回最近一次结果。每个 forge 同时最多只有一次读取在进行,这次读取包含该 forge 所有到期的变更请求。打开状态的变更请求在检查未完成或可合并性未知时每 `activeIntervalMs` 轮询一次,否则每 `idleIntervalMs` 轮询一次。已关闭和已合并的变更请求不再轮询。读取失败后,重试间隔为 `activeIntervalMs` 按连续失败次数翻倍,最长 `maxBackoffMs`。注销提供方会让其被监听的变更请求以 `unavailable` 结束。最后一个监听方离开后轮询停止。默认值见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge)。
diff --git a/packages/experimental/forge/src/index.ts b/packages/experimental/forge/src/index.ts
index 2096795a3e..59342af71c 100644
--- a/packages/experimental/forge/src/index.ts
+++ b/packages/experimental/forge/src/index.ts
@@ -17,6 +17,15 @@ declare module '@deepseek-ai/cordis' {
interface Context {
forge: ForgeService
}
+ interface Events {
+ /**
+ * Announces a provider registration or withdrawal after the registry changes.
+ * Listener failures are logged without preventing other listeners from running.
+ * @param forge - family whose provider changed.
+ * @mode parallel
+ */
+ 'forge/providers-changed'(forge: string): void | Promise
+ }
}
type FetchResults = ReadonlyMap
@@ -99,12 +108,18 @@ export class ForgeService extends Service {
if (this.providers.has(provider.forge)) throw new Error(`forge: a provider for "${provider.forge}" is already registered`)
const registration: Registration = { provider, lifetime: new AbortController(), pending: new Set() }
this.providers.set(provider.forge, registration)
+ for (const watch of this.watches.values()) {
+ if (watch.ref.repository.forge === provider.forge) watch.dueAt = Date.now()
+ }
+ this.providersChanged(provider.forge)
+ this.schedule()
return async () => {
this.providers.delete(provider.forge)
registration.lifetime.abort(new Error(`forge: provider "${provider.forge}" was unregistered`))
for (const watch of [...this.watches.values()]) {
if (watch.ref.repository.forge === provider.forge) this.settle(watch, this.missing(provider.forge))
}
+ this.providersChanged(provider.forge)
this.schedule()
await Promise.allSettled(registration.pending)
}
@@ -205,6 +220,15 @@ export class ForgeService extends Service {
return this.registration(forge).provider
}
+ private providersChanged(forge: string): void {
+ if (this.lifetime.signal.aborted) return
+ const task = this.ctx.parallel('forge/providers-changed', forge).catch((error: unknown) => {
+ this.ctx.logger.warn(`forge: provider change listener failed: ${String(error)}`)
+ })
+ this.pending.add(task)
+ void task.then(() => this.pending.delete(task))
+ }
+
private registration(forge: string): Registration {
const registration = this.providers.get(forge)
if (registration === undefined) throw new Error(`forge: ${this.missing(forge).message}`)
diff --git a/packages/experimental/forge/tests/lifecycle.spec.ts b/packages/experimental/forge/tests/lifecycle.spec.ts
index c2c67f2a68..ee68356eb9 100644
--- a/packages/experimental/forge/tests/lifecycle.spec.ts
+++ b/packages/experimental/forge/tests/lifecycle.spec.ts
@@ -80,3 +80,14 @@ it('does not invoke a provider for an already cancelled lookup', async () => {
await expect(ctx.forge.findByHead(demo, 'topic', AbortSignal.abort(new Error('caller stopped')))).rejects.toThrow('caller stopped')
expect(lookup).not.toHaveBeenCalled()
})
+
+it('contains failed registry observers and still notifies other observers', async () => {
+ ctx = await forgeContext()
+ const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+ const notified = Promise.withResolvers()
+ ctx.on('forge/providers-changed', () => { throw new Error('observer failed') })
+ ctx.on('forge/providers-changed', (forge) => { notified.resolve(forge) })
+ await provide(ctx, new FakeProvider())
+ expect(await notified.promise).toBe('fake')
+ await vi.waitFor(() => { expect(warning).toHaveBeenCalledWith(expect.stringContaining('provider change listener failed')) })
+})
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index bbe7cd9ac3..957ef8da8f 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -1294,7 +1294,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'watch(session: Session, listener: SessionChangeRequestsListener): () => void',
- description: 'Watch a Session\'s visible change requests as an effect on the calling fiber. Every visible change request that a provider recognizes is polled through `ctx.forge` while the Session has a watcher. Listeners receive the complete list after each later binding change or polling result; read the current list with changeRequests. Listeners must not throw; a thrown error is logged and skipped.',
+ description: 'Watch a Session\'s visible change requests as an effect on the calling fiber. Every visible change request that a provider recognizes is polled through `ctx.forge` while the Session has a watcher. Listeners receive the complete list after each later binding or provider change, or polling result; read the current list with changeRequests. Listeners must not throw; a thrown error is logged and skipped.',
parameters: [{ name: 'session', description: 'Session to watch.' }, { name: 'listener', description: 'receives the complete visible list.' }],
returns: 'the disposer that stops this watch.',
},
@@ -4638,6 +4638,14 @@ export const EVENT_API: readonly EventApiEntry[] = [
description: 'Observe a durable cold feedback mutation without publishing a live Session. Observers run before write ownership is released and must not await another message-feedback operation for this Session. The payload is borrowed read-only; deep-clone it before transferring ownership (for example, to Session.fromRestore).',
parameters: [{ name: 'inspection', description: 'committed canonical prefix, including the feedback as its last event.' }],
},
+ {
+ name: 'forge/providers-changed',
+ mode: 'parallel',
+ signature: '\'forge/providers-changed\'(forge: string): void | Promise',
+ summary: 'Announces a provider registration or withdrawal after the registry changes.',
+ description: 'Announces a provider registration or withdrawal after the registry changes. Listener failures are logged without preventing other listeners from running.',
+ parameters: [{ name: 'forge', description: 'family whose provider changed.' }],
+ },
{
name: 'fs/edit-intent',
mode: 'waterfall',
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index 081026d7b5..75d28a915a 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -243,6 +243,7 @@ export const EVENT_SCOPE_PAGE: Record = {
'credentials': 'credentials.md',
'deepseek-account': 'credentials.md',
'domain': 'storage.md',
+ 'forge': 'forge.md',
'fs': 'filesystem.md',
'goal': 'goal.md',
'schedule': 'schedule.md',