@zhin.js/adapter-sandbox 8.0.0 → 8.0.1

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
@@ -1,5 +1,25 @@
1
1
  # @zhin.js/adapter-process
2
2
 
3
+ ## 8.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - e9c6a73: Preserve WebSocket path ownership when a previous hot-reload generation stops after its replacement starts.
8
+ - 1fc78bc: Unify native platform Client access behind the literal `adapter` discriminant. Handlers infer both native events and Clients, while command, inbound/outbound middleware, and both Agent tool authoring surfaces expose the exact operation-scoped Client through a lazy `$client` getter. Definitions without `adapter` keep `$client` typed as `unknown`, and runtime dispatch rejects adapter mismatches before resolving the Client. Bundled platform tools now use this single path instead of model-provided endpoint ids and adapter-specific dependency wrappers. Every adapter registers one Client/EventMap contract, and protocol adapters including NapCat, Milky, OneBot and Satori now produce transport-independent Client objects rather than letting Endpoint instances impersonate Clients.
9
+ - Updated dependencies [4e8117c]
10
+ - Updated dependencies [54bfd6b]
11
+ - Updated dependencies [12025ee]
12
+ - Updated dependencies [09b14d6]
13
+ - Updated dependencies [1fc78bc]
14
+ - @zhin.js/adapter@1.2.1
15
+ - @zhin.js/core@1.5.14
16
+ - @zhin.js/host-http@1.0.13
17
+ - @zhin.js/logger@1.0.77
18
+ - zhin.js@6.0.14
19
+ - @zhin.js/feature-kit@1.0.13
20
+ - @zhin.js/page@1.0.13
21
+ - @zhin.js/client@2.1.12
22
+
3
23
  ## 8.0.0
4
24
 
5
25
  ### Patch Changes
package/README.md CHANGED
@@ -16,13 +16,17 @@ Zhin.js Sandbox 适配器,基于 WebSocket 的本地测试适配器;浏览
16
16
  pnpm add @zhin.js/adapter-sandbox
17
17
  ```
18
18
 
19
+ ## 前置条件
20
+
21
+ Sandbox 不需要外部账号。只需由 `zhin runtime start` 装配 HTTP Host,并确保浏览器能访问启动日志中的 Host 地址。
22
+
19
23
  ## 依赖
20
24
 
21
25
  ### Plugin Runtime(新,`zhin runtime start`)
22
26
 
23
27
  - `@zhin.js/adapter` — 约定式 `adapters/sandbox.ts`
24
28
  - `@zhin.js/host-http` — Root 提供的 `httpHostToken`(WebSocket `/sandbox` + Console HTTP)
25
- - `@zhin.js/core` — `messageGatewayToken` / ImRuntime 入站出站
29
+ - `@zhin.js/core` — `Endpoint.emit(...)` 入站、`outboundMessageToken` 出站
26
30
  - `@zhin.js/page` + `pages/index.tsx` — ADR 0046 约定页(`definePage`;路由 `/sandbox`)
27
31
 
28
32
  Root 在 `zhin runtime start` 时装载 `@zhin.js/host-http`、`ConsoleRuntime` 与
@@ -96,6 +100,15 @@ Sandbox 使用 JSON 消息格式:
96
100
  技能说明见 `agent/skills/sandbox.md`(本地沙箱调试约束)。
97
101
 
98
102
 
103
+ ## 故障排查
104
+
105
+ | 现象 | 排查 |
106
+ | --- | --- |
107
+ | Console 无法连接 | 以启动日志中的 Host、端口和 token 为准 |
108
+ | Sandbox 页面空白 | 检查 HTTP Host 是否因端口占用软降级,以及鉴权/CORS 错误 |
109
+ | 刷新后没有历史消息 | 核对 Endpoint 与频道,并检查 history RPC 与 recovery gap 日志 |
110
+ | 命令或工具未生效 | 在运行时能力页确认它已进入当前 generation |
111
+
99
112
  ## 许可证
100
113
 
101
114
  MIT License
@@ -3,7 +3,6 @@
3
3
  * Convention entry: discover `adapters/sandbox.ts` → defineAdapter.
4
4
  */
5
5
  import { defineAdapter } from 'zhin.js/adapter';
6
- import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
7
6
  import { httpHostToken } from '@zhin.js/host-http';
8
7
  import { SandboxWsEndpoint } from "../lib/endpoint.js";
9
8
  import { resolveSandboxEndpoint, } from "../lib/protocol.js";
@@ -19,8 +18,6 @@ export default defineAdapter({
19
18
  create(context) {
20
19
  return new SandboxWsEndpoint({
21
20
  id: context.id,
22
- gateway: context.use(messageGatewayToken),
23
- sideEvents: context.use(sideEventGatewayToken),
24
21
  http: context.use(httpHostToken),
25
22
  defaults: resolveSandboxEndpoint(context.config),
26
23
  });
@@ -2,7 +2,6 @@
2
2
  * Convention entry: discover `adapters/sandbox.ts` → defineAdapter.
3
3
  */
4
4
  import { defineAdapter } from 'zhin.js/adapter';
5
- import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
6
5
  import { httpHostToken } from '@zhin.js/host-http';
7
6
  import { SandboxWsEndpoint } from '../src/endpoint.js';
8
7
  import {
@@ -24,8 +23,6 @@ export default defineAdapter<SandboxAdapterConfig>({
24
23
  create(context) {
25
24
  return new SandboxWsEndpoint({
26
25
  id: context.id,
27
- gateway: context.use(messageGatewayToken),
28
- sideEvents: context.use(sideEventGatewayToken),
29
26
  http: context.use(httpHostToken),
30
27
  defaults: resolveSandboxEndpoint(context.config),
31
28
  });
@@ -0,0 +1,27 @@
1
+ import type { SandboxWsSocket } from './protocol.js';
2
+ export interface SandboxClientConnection {
3
+ readonly target: string;
4
+ readonly owner: string;
5
+ readonly socket: SandboxWsSocket;
6
+ readonly placeholder: boolean;
7
+ }
8
+ /** Direct view of the live Sandbox protocol clients owned by one Endpoint. */
9
+ export declare class SandboxClient {
10
+ private readonly resolvePath;
11
+ private readonly resolveConnections;
12
+ constructor(resolvePath: () => string, resolveConnections: () => Iterable<SandboxClientConnection>);
13
+ get path(): string;
14
+ connections(): readonly SandboxClientConnection[];
15
+ connection(target: string): SandboxClientConnection | undefined;
16
+ send(target: string, payload: string): void;
17
+ }
18
+ export type SandboxClientEventMap = Record<string, unknown>;
19
+ declare module '@zhin.js/feature-kit' {
20
+ interface AdapterClientRegistry {
21
+ readonly sandbox: {
22
+ readonly client: SandboxClient;
23
+ readonly events: SandboxClientEventMap;
24
+ };
25
+ }
26
+ }
27
+ export declare const sandboxClient: import("@zhin.js/adapter").EndpointClientToken<SandboxClient, SandboxClientEventMap>;
package/lib/client.js ADDED
@@ -0,0 +1,27 @@
1
+ import { defineEndpointClient } from 'zhin.js/adapter';
2
+ /** Direct view of the live Sandbox protocol clients owned by one Endpoint. */
3
+ export class SandboxClient {
4
+ resolvePath;
5
+ resolveConnections;
6
+ constructor(resolvePath, resolveConnections) {
7
+ this.resolvePath = resolvePath;
8
+ this.resolveConnections = resolveConnections;
9
+ }
10
+ get path() {
11
+ return this.resolvePath();
12
+ }
13
+ connections() {
14
+ return Object.freeze([...this.resolveConnections()]);
15
+ }
16
+ connection(target) {
17
+ return this.connections().find((connection) => connection.target === target);
18
+ }
19
+ send(target, payload) {
20
+ const connection = this.connection(target);
21
+ if (!connection || connection.placeholder) {
22
+ throw new Error(`Sandbox client ${target} is not connected`);
23
+ }
24
+ connection.socket.send(payload);
25
+ }
26
+ }
27
+ export const sandboxClient = defineEndpointClient('sandbox');
package/lib/endpoint.d.ts CHANGED
@@ -1,8 +1,9 @@
1
- import type { EndpointInstance, EndpointSendRequest } from 'zhin.js/adapter';
2
- import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
1
+ import { Endpoint } from 'zhin.js/adapter';
2
+ import { type EndpointSendRequest } from 'zhin.js/adapter';
3
3
  import type { HttpHost } from '@zhin.js/host-http';
4
4
  import type { CapabilityId } from 'zhin.js';
5
5
  import { type ResolvedSandboxBot } from './protocol.js';
6
+ import { SandboxClient } from './client.js';
6
7
  type ShellIsolationStatus = Readonly<{
7
8
  available: boolean;
8
9
  provider: 'docker';
@@ -19,8 +20,6 @@ export declare function createSandboxReadinessGate(probe: ShellIsolationProbe):
19
20
  }>;
20
21
  export interface SandboxEndpointOptions {
21
22
  readonly id: CapabilityId;
22
- readonly gateway: MessageGateway;
23
- readonly sideEvents?: SideEventGateway;
24
23
  readonly http: HttpHost;
25
24
  readonly defaults: ResolvedSandboxBot;
26
25
  }
@@ -28,8 +27,9 @@ export interface SandboxEndpointOptions {
28
27
  * Sandbox 是本地开发/测试面,无平台社交图谱(好友/群/频道),
29
28
  * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
30
29
  */
31
- export declare class SandboxWsEndpoint implements EndpointInstance {
30
+ export declare class SandboxWsEndpoint extends Endpoint<SandboxClient> {
32
31
  #private;
32
+ readonly client: SandboxClient;
33
33
  constructor(options: SandboxEndpointOptions);
34
34
  /** Live endpoint id (config `id`) — Console endpoint.list/resolve uses it. */
35
35
  get name(): string;
package/lib/endpoint.js CHANGED
@@ -1,16 +1,12 @@
1
+ import { Endpoint } from 'zhin.js/adapter';
1
2
  /**
2
- * SandboxWsEndpoint — WebSocket lifecycle and MessageGateway bridge for /sandbox.
3
+ * SandboxWsEndpoint — WebSocket lifecycle and OutboundMessageService bridge for /sandbox.
3
4
  */
4
5
  import { randomUUID } from 'node:crypto';
5
6
  import { execFile } from 'node:child_process';
6
7
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
7
8
  import { bindSandboxWsSocket, formatSandboxOutbound, parseSandboxWsPayload, sandboxInboundConversation, whenWsOpen, } from './protocol.js';
8
- /**
9
- * 多 sandbox endpoint 共用同一个 HttpHost 时,同 path 的所有 WS listener
10
- * 都会被回调(入站重复、出站互窜)。按 endpoint 名隔离挂载路径:
11
- * 首个占用 `/sandbox`(保持 Console 默认兼容),其余退到 `/sandbox/<name>`。
12
- * 认领记录按 HttpHost 隔离,endpoint stop() 时必须 release。
13
- */
9
+ import { SandboxClient } from './client.js';
14
10
  const claimedWsPaths = new WeakMap();
15
11
  function claimSandboxWsPath(http, name) {
16
12
  let claims = claimedWsPaths.get(http);
@@ -19,7 +15,7 @@ function claimSandboxWsPath(http, name) {
19
15
  claimedWsPaths.set(http, claims);
20
16
  }
21
17
  const candidates = ['/sandbox', `/sandbox/${encodeURIComponent(name)}`];
22
- let path = candidates.find((candidate) => !claims.has(candidate) || claims.get(candidate) === name);
18
+ let path = candidates.find((candidate) => !claims.has(candidate) || claims.get(candidate)?.name === name);
23
19
  if (!path) {
24
20
  let index = 2;
25
21
  path = `/sandbox/${encodeURIComponent(name)}-${index}`;
@@ -28,14 +24,19 @@ function claimSandboxWsPath(http, name) {
28
24
  path = `/sandbox/${encodeURIComponent(name)}-${index}`;
29
25
  }
30
26
  }
31
- claims.set(path, name);
27
+ const owner = Symbol(name);
28
+ const claim = claims.get(path) ?? { name, owners: new Set() };
29
+ claim.owners.add(owner);
30
+ claims.set(path, claim);
32
31
  const claimed = path;
33
32
  const registry = claims;
34
33
  return {
35
34
  path: claimed,
36
35
  release: () => {
37
- if (registry.get(claimed) === name)
36
+ claim.owners.delete(owner);
37
+ if (claim.owners.size === 0 && registry.get(claimed) === claim) {
38
38
  registry.delete(claimed);
39
+ }
39
40
  },
40
41
  };
41
42
  }
@@ -67,7 +68,8 @@ export function createSandboxReadinessGate(probe) {
67
68
  * Sandbox 是本地开发/测试面,无平台社交图谱(好友/群/频道),
68
69
  * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
69
70
  */
70
- export class SandboxWsEndpoint {
71
+ export class SandboxWsEndpoint extends Endpoint {
72
+ client;
71
73
  #logger;
72
74
  #options;
73
75
  #connections = new Map();
@@ -78,8 +80,15 @@ export class SandboxWsEndpoint {
78
80
  #open = false;
79
81
  #started = false;
80
82
  constructor(options) {
83
+ super();
81
84
  this.#logger = getAdapterLogger('sandbox', options.defaults.id);
82
85
  this.#options = options;
86
+ this.client = new SandboxClient(() => this.#wsPath, () => [...this.#connections.values()].map((connection) => ({
87
+ target: connection.target,
88
+ owner: connection.owner,
89
+ socket: connection.socket,
90
+ placeholder: connection.placeholder === true,
91
+ })));
83
92
  }
84
93
  /** Live endpoint id (config `id`) — Console endpoint.list/resolve uses it. */
85
94
  get name() {
@@ -198,6 +207,7 @@ export class SandboxWsEndpoint {
198
207
  }
199
208
  const release = bindSandboxWsSocket(socket, {
200
209
  onMessage: (raw) => {
210
+ void this.#emitPlatformEvent('message', raw);
201
211
  if (!canExecute) {
202
212
  socket.send(JSON.stringify({
203
213
  type: 'error',
@@ -224,7 +234,7 @@ export class SandboxWsEndpoint {
224
234
  }));
225
235
  // Don't gate on #open — inbound must always reach the gateway so
226
236
  // Command/AI dispatch and outbound replies work.
227
- void this.#options.gateway.receive({
237
+ void this.emit('message.receive', {
228
238
  conversation,
229
239
  ...(parsed.messageId ? { message: { conversation, id: parsed.messageId } } : {}),
230
240
  content: parsed.text,
@@ -248,6 +258,7 @@ export class SandboxWsEndpoint {
248
258
  });
249
259
  },
250
260
  onClose: () => {
261
+ void this.#emitPlatformEvent('connection.close', { target, owner });
251
262
  // Only drop the map entry if we still own this socket — a replace
252
263
  // may have already swapped in a newer connection for the same target.
253
264
  const current = this.#connections.get(target);
@@ -257,6 +268,7 @@ export class SandboxWsEndpoint {
257
268
  }
258
269
  },
259
270
  onError: (err) => {
271
+ void this.#emitPlatformEvent('connection.error', { target, owner, error: err });
260
272
  this.#logger.warn(formatCompact({
261
273
  op: 'sandbox_ws_error',
262
274
  target,
@@ -265,6 +277,7 @@ export class SandboxWsEndpoint {
265
277
  },
266
278
  });
267
279
  this.#connections.set(target, { target, owner, socket, release });
280
+ void this.#emitPlatformEvent('connection.open', { target, owner, socket });
268
281
  this.#logger.debug(formatCompact({ op: 'sandbox_ws_connected', target, owner }));
269
282
  this.#readiness.afterProbe((shellIsolation) => {
270
283
  const current = this.#connections.get(target);
@@ -303,6 +316,15 @@ export class SandboxWsEndpoint {
303
316
  placeholder: true,
304
317
  });
305
318
  }
319
+ async #emitPlatformEvent(name, event) {
320
+ await this.emitPlatform(name, event).catch((error) => {
321
+ this.#logger.warn(formatCompact({
322
+ op: 'sandbox_platform_event_failed',
323
+ event: name,
324
+ error: error instanceof Error ? error.message : String(error),
325
+ }));
326
+ });
327
+ }
306
328
  }
307
329
  function probeShellIsolation() {
308
330
  return new Promise((resolve) => {
package/lib/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { bindSandboxWsSocket, formatSandboxOutbound, normalizeSandboxOutboundSegments, parseSandboxWsPayload, resolveSandboxEndpoint, sandboxInboundConversation, whenWsOpen, type MessageElement, type MessageType, type ResolvedSandboxBot, type SandboxAdapterConfig, type SandboxWsSocket, } from './protocol.js';
2
+ export { SandboxClient, sandboxClient, type SandboxClientEventMap, type SandboxClientConnection, } from './client.js';
2
3
  export { SandboxWsEndpoint, type SandboxEndpointOptions, } from './endpoint.js';
package/lib/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { bindSandboxWsSocket, formatSandboxOutbound, normalizeSandboxOutboundSegments, parseSandboxWsPayload, resolveSandboxEndpoint, sandboxInboundConversation, whenWsOpen, } from './protocol.js';
2
+ export { SandboxClient, sandboxClient, } from './client.js';
2
3
  export { SandboxWsEndpoint, } from './endpoint.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-sandbox",
3
- "version": "8.0.0",
3
+ "version": "8.0.1",
4
4
  "description": "Zhin.js Sandbox adapter for Plugin Runtime (WebSocket /sandbox)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -48,14 +48,15 @@
48
48
  "dependencies": {
49
49
  "lucide-react": "^0.525.0",
50
50
  "react": "^19.2.8",
51
- "@zhin.js/adapter": "1.2.0",
52
- "@zhin.js/client": "2.1.11",
51
+ "@zhin.js/adapter": "1.2.1",
52
+ "@zhin.js/client": "2.1.12",
53
53
  "@zhin.js/console-contract": "1.0.1",
54
- "@zhin.js/core": "1.5.13",
55
- "@zhin.js/host-http": "1.0.12",
54
+ "@zhin.js/core": "1.5.14",
55
+ "@zhin.js/feature-kit": "1.0.13",
56
+ "@zhin.js/host-http": "1.0.13",
56
57
  "@zhin.js/im-contract": "1.0.4",
57
- "@zhin.js/logger": "1.0.76",
58
- "@zhin.js/page": "1.0.12"
58
+ "@zhin.js/logger": "1.0.77",
59
+ "@zhin.js/page": "1.0.13"
59
60
  },
60
61
  "devDependencies": {
61
62
  "@types/react": "^19.2.18",
@@ -63,19 +64,19 @@
63
64
  "typescript": "^6.0.3",
64
65
  "vitest": "^4.1.10",
65
66
  "ws": "^8.21.1",
66
- "@zhin.js/pagemanager": "2.0.20",
67
- "@zhin.js/runtime": "1.0.13",
68
- "zhin.js": "6.0.13"
67
+ "@zhin.js/pagemanager": "2.0.21",
68
+ "@zhin.js/runtime": "1.0.14",
69
+ "zhin.js": "6.0.14"
69
70
  },
70
71
  "peerDependencies": {
71
72
  "react": "^19.0.0",
72
- "@zhin.js/adapter": "1.2.0",
73
- "@zhin.js/client": "2.1.11",
73
+ "@zhin.js/adapter": "1.2.1",
74
+ "@zhin.js/client": "2.1.12",
74
75
  "@zhin.js/console-contract": "1.0.1",
75
- "@zhin.js/core": "1.5.13",
76
- "@zhin.js/host-http": "1.0.12",
77
- "@zhin.js/page": "1.0.12",
78
- "zhin.js": "6.0.13"
76
+ "@zhin.js/core": "1.5.14",
77
+ "@zhin.js/host-http": "1.0.13",
78
+ "@zhin.js/page": "1.0.13",
79
+ "zhin.js": "6.0.14"
79
80
  },
80
81
  "peerDependenciesMeta": {
81
82
  "react": {
package/src/client.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { SandboxWsSocket } from './protocol.js';
2
+ import { defineEndpointClient } from 'zhin.js/adapter';
3
+
4
+ export interface SandboxClientConnection {
5
+ readonly target: string;
6
+ readonly owner: string;
7
+ readonly socket: SandboxWsSocket;
8
+ readonly placeholder: boolean;
9
+ }
10
+
11
+ /** Direct view of the live Sandbox protocol clients owned by one Endpoint. */
12
+ export class SandboxClient {
13
+ constructor(
14
+ private readonly resolvePath: () => string,
15
+ private readonly resolveConnections: () => Iterable<SandboxClientConnection>,
16
+ ) {}
17
+
18
+ get path(): string {
19
+ return this.resolvePath();
20
+ }
21
+
22
+ connections(): readonly SandboxClientConnection[] {
23
+ return Object.freeze([...this.resolveConnections()]);
24
+ }
25
+
26
+ connection(target: string): SandboxClientConnection | undefined {
27
+ return this.connections().find((connection) => connection.target === target);
28
+ }
29
+
30
+ send(target: string, payload: string): void {
31
+ const connection = this.connection(target);
32
+ if (!connection || connection.placeholder) {
33
+ throw new Error(`Sandbox client ${target} is not connected`);
34
+ }
35
+ connection.socket.send(payload);
36
+ }
37
+ }
38
+
39
+ export type SandboxClientEventMap = Record<string, unknown>;
40
+
41
+ declare module '@zhin.js/feature-kit' {
42
+ interface AdapterClientRegistry {
43
+ readonly sandbox: { readonly client: SandboxClient; readonly events: SandboxClientEventMap };
44
+ }
45
+ }
46
+
47
+ export const sandboxClient = defineEndpointClient<SandboxClient, SandboxClientEventMap>('sandbox');
package/src/endpoint.ts CHANGED
@@ -1,10 +1,10 @@
1
+ import { Endpoint } from 'zhin.js/adapter';
1
2
  /**
2
- * SandboxWsEndpoint — WebSocket lifecycle and MessageGateway bridge for /sandbox.
3
+ * SandboxWsEndpoint — WebSocket lifecycle and OutboundMessageService bridge for /sandbox.
3
4
  */
4
5
  import { randomUUID } from 'node:crypto';
5
6
  import { execFile } from 'node:child_process';
6
- import type { EndpointInstance, EndpointSendRequest } from 'zhin.js/adapter';
7
- import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
7
+ import { type EndpointSendRequest } from 'zhin.js/adapter';
8
8
  import type { HttpHost, WsConnection } from '@zhin.js/host-http';
9
9
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
10
10
  import type { CapabilityId } from 'zhin.js';
@@ -17,6 +17,7 @@ import {
17
17
  type ResolvedSandboxBot,
18
18
  type SandboxWsSocket,
19
19
  } from './protocol.js';
20
+ import { SandboxClient, type SandboxClientConnection } from './client.js';
20
21
 
21
22
  /**
22
23
  * 多 sandbox endpoint 共用同一个 HttpHost 时,同 path 的所有 WS listener
@@ -24,7 +25,12 @@ import {
24
25
  * 首个占用 `/sandbox`(保持 Console 默认兼容),其余退到 `/sandbox/<name>`。
25
26
  * 认领记录按 HttpHost 隔离,endpoint stop() 时必须 release。
26
27
  */
27
- const claimedWsPaths = new WeakMap<HttpHost, Map<string, string>>();
28
+ interface SandboxWsPathClaim {
29
+ readonly name: string;
30
+ readonly owners: Set<symbol>;
31
+ }
32
+
33
+ const claimedWsPaths = new WeakMap<HttpHost, Map<string, SandboxWsPathClaim>>();
28
34
 
29
35
  function claimSandboxWsPath(
30
36
  http: HttpHost,
@@ -37,7 +43,7 @@ function claimSandboxWsPath(
37
43
  }
38
44
  const candidates = ['/sandbox', `/sandbox/${encodeURIComponent(name)}`];
39
45
  let path = candidates.find(
40
- (candidate) => !claims!.has(candidate) || claims!.get(candidate) === name,
46
+ (candidate) => !claims!.has(candidate) || claims!.get(candidate)?.name === name,
41
47
  );
42
48
  if (!path) {
43
49
  let index = 2;
@@ -47,13 +53,19 @@ function claimSandboxWsPath(
47
53
  path = `/sandbox/${encodeURIComponent(name)}-${index}`;
48
54
  }
49
55
  }
50
- claims.set(path, name);
56
+ const owner = Symbol(name);
57
+ const claim = claims.get(path) ?? { name, owners: new Set<symbol>() };
58
+ claim.owners.add(owner);
59
+ claims.set(path, claim);
51
60
  const claimed = path;
52
61
  const registry = claims;
53
62
  return {
54
63
  path: claimed,
55
64
  release: () => {
56
- if (registry.get(claimed) === name) registry.delete(claimed);
65
+ claim.owners.delete(owner);
66
+ if (claim.owners.size === 0 && registry.get(claimed) === claim) {
67
+ registry.delete(claimed);
68
+ }
57
69
  },
58
70
  };
59
71
  }
@@ -102,8 +114,6 @@ export function createSandboxReadinessGate(probe: ShellIsolationProbe): Readonly
102
114
 
103
115
  export interface SandboxEndpointOptions {
104
116
  readonly id: CapabilityId;
105
- readonly gateway: MessageGateway;
106
- readonly sideEvents?: SideEventGateway;
107
117
  readonly http: HttpHost;
108
118
  readonly defaults: ResolvedSandboxBot;
109
119
  }
@@ -112,7 +122,8 @@ export interface SandboxEndpointOptions {
112
122
  * Sandbox 是本地开发/测试面,无平台社交图谱(好友/群/频道),
113
123
  * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
114
124
  */
115
- export class SandboxWsEndpoint implements EndpointInstance {
125
+ export class SandboxWsEndpoint extends Endpoint<SandboxClient> {
126
+ readonly client: SandboxClient;
116
127
  readonly #logger!: ReturnType<typeof getAdapterLogger>;
117
128
 
118
129
  readonly #options: SandboxEndpointOptions;
@@ -125,8 +136,18 @@ export class SandboxWsEndpoint implements EndpointInstance {
125
136
  #started = false;
126
137
 
127
138
  constructor(options: SandboxEndpointOptions) {
139
+ super();
128
140
  this.#logger = getAdapterLogger('sandbox', options.defaults.id);
129
141
  this.#options = options;
142
+ this.client = new SandboxClient(
143
+ () => this.#wsPath,
144
+ () => [...this.#connections.values()].map((connection) => ({
145
+ target: connection.target,
146
+ owner: connection.owner,
147
+ socket: connection.socket,
148
+ placeholder: connection.placeholder === true,
149
+ } satisfies SandboxClientConnection)),
150
+ );
130
151
  }
131
152
 
132
153
  /** Live endpoint id (config `id`) — Console endpoint.list/resolve uses it. */
@@ -248,6 +269,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
248
269
  }
249
270
  const release = bindSandboxWsSocket(socket, {
250
271
  onMessage: (raw) => {
272
+ void this.#emitPlatformEvent('message', raw);
251
273
  if (!canExecute) {
252
274
  socket.send(JSON.stringify({
253
275
  type: 'error',
@@ -274,7 +296,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
274
296
  }));
275
297
  // Don't gate on #open — inbound must always reach the gateway so
276
298
  // Command/AI dispatch and outbound replies work.
277
- void this.#options.gateway.receive({
299
+ void this.emit('message.receive', {
278
300
  conversation,
279
301
  ...(parsed.messageId ? { message: { conversation, id: parsed.messageId } } : {}),
280
302
  content: parsed.text,
@@ -298,6 +320,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
298
320
  });
299
321
  },
300
322
  onClose: () => {
323
+ void this.#emitPlatformEvent('connection.close', { target, owner });
301
324
  // Only drop the map entry if we still own this socket — a replace
302
325
  // may have already swapped in a newer connection for the same target.
303
326
  const current = this.#connections.get(target);
@@ -307,6 +330,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
307
330
  }
308
331
  },
309
332
  onError: (err) => {
333
+ void this.#emitPlatformEvent('connection.error', { target, owner, error: err });
310
334
  this.#logger.warn(formatCompact({
311
335
  op: 'sandbox_ws_error',
312
336
  target,
@@ -315,6 +339,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
315
339
  },
316
340
  });
317
341
  this.#connections.set(target, { target, owner, socket, release });
342
+ void this.#emitPlatformEvent('connection.open', { target, owner, socket });
318
343
  this.#logger.debug(formatCompact({ op: 'sandbox_ws_connected', target, owner }));
319
344
  this.#readiness.afterProbe((shellIsolation) => {
320
345
  const current = this.#connections.get(target);
@@ -352,6 +377,16 @@ export class SandboxWsEndpoint implements EndpointInstance {
352
377
  placeholder: true,
353
378
  });
354
379
  }
380
+
381
+ async #emitPlatformEvent(name: string, event: unknown): Promise<void> {
382
+ await this.emitPlatform(name, event).catch((error) => {
383
+ this.#logger.warn(formatCompact({
384
+ op: 'sandbox_platform_event_failed',
385
+ event: name,
386
+ error: error instanceof Error ? error.message : String(error),
387
+ }));
388
+ });
389
+ }
355
390
  }
356
391
 
357
392
  function probeShellIsolation(): Promise<ShellIsolationStatus> {
package/src/index.ts CHANGED
@@ -13,6 +13,13 @@ export {
13
13
  type SandboxWsSocket,
14
14
  } from './protocol.js';
15
15
 
16
+ export {
17
+ SandboxClient,
18
+ sandboxClient,
19
+ type SandboxClientEventMap,
20
+ type SandboxClientConnection,
21
+ } from './client.js';
22
+
16
23
  export {
17
24
  SandboxWsEndpoint,
18
25
  type SandboxEndpointOptions,