pinomad 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -89,7 +89,7 @@ bun run pair # 打印配对二维码(一次性,5 分钟过期)
89
89
  bun run host -- --remote-port 7422 --public-url https://pinomad.example.com
90
90
  ```
91
91
 
92
- 远程端口不接受 token:只有完成配对、公钥已登记在设备表里的客户端能连。
92
+ 远程端口不接受 token:只有完成配对、公钥已登记在设备表里的客户端能连。不开 `--remote-port` / `--relay` 时 `pair` 打印的是回环链接(回环端口 `/secure` 路径上的安全通道),只能给本机上的客户端用,比如桌面端 App。桌面端 App 可以配对多台宿主,在侧栏底部切换(一次只连一台)。App 会自动检查更新(GitHub Release 上的 `latest-mac.yml`,自动下载、不自动安装),下载完成后侧栏底部出现 "Restart to update",也可以在菜单里手动 Check for Updates…。
93
93
 
94
94
  </details>
95
95
 
@@ -138,7 +138,39 @@ Restart=always
138
138
  WantedBy=multi-user.target
139
139
  ```
140
140
 
141
- 升级:中继托管的 Web 客户端和宿主版本不一致时,客户端会显示需要更新;两边一起升级——宿主 `pinomad upgrade`,VPS 上 `npm i -g pinomad@latest` 后重启中继。宿主不在中继的 `--allow-host` 里时,宿主日志和 Web 警告会带着要补的 `--allow-host <hostId>`。
141
+ #### 容器平台(Zeabur 等)
142
+
143
+ 镜像在 `deploy/relay/Dockerfile`,装的是锁版本的 npm 发布包(`ARG PINOMAD_VERSION`)。平台终结 TLS 并转发到 `$PORT`(默认 8080),不需要 Caddy。中继把转接状态放在内存里,**只能跑一个实例**,不要开扩容。
144
+
145
+ | 环境变量 | 含义 |
146
+ |---|---|
147
+ | `PINOMAD_PUBLIC_ORIGIN` | 客户端打开的 https origin,比如 `https://nomad.example.com` |
148
+ | `PINOMAD_ALLOW_HOST` | 允许登记的 hostId,多个用空格分隔(每台宿主上跑 `pinomad relay-id`) |
149
+ | `ZBPACK_DOCKERFILE_PATH=Dockerfile` | **Zeabur 必加**:目录里只有一个 Dockerfile 时,Zeabur 会把它识别成静态站点、用 Caddy 托管文件,中继根本没启动;这个变量强制按 Dockerfile 构建(部署日志里应是 `PLANTYPE=docker`) |
150
+
151
+ Zeabur 上的步骤(在 Dedicated Server 上部署,用 CLI 上传目录,不接 GitHub):
152
+
153
+ ```sh
154
+ cd deploy/relay
155
+ zeabur deploy --project-id <项目 id> --create --name pinomad-relay # 首次:在项目里建出服务
156
+ # 之后重新部署同一个服务:
157
+ zeabur deploy --service-id <服务 id>
158
+ ```
159
+
160
+ 然后在服务上配好上面的环境变量(`PORT` 由平台注入,不用自己设),在 Domains 里绑定自定义域名,到 DNS 加一条记录:Dedicated Server 用 **A 记录指向服务器 IP**;Cloudflare 上建议**关掉代理(灰云)**,橙云会让国内访问多绕一圈,证书也由 Zeabur 自己签。域名状态变成 `PROVISIONED` 后,验证:
161
+
162
+ ```sh
163
+ curl -sI https://nomad.example.com/ # 200,中继托管的 Web 客户端
164
+ pinomad service install --relay https://nomad.example.com … # 宿主接上中继
165
+ ```
166
+
167
+ 宿主登记成功会在日志里写一行 `{"event":"relay","status":"registered",…}`,掉线重连时写 `"status":"connecting"`(`pinomad service logs` 查看);也可以从外面连设备入口 `wss://<origin>/c/<hostId>` 确认:宿主在线时连接保持打开(等握手),没登记会立刻以 `4604`(hostOffline)关闭。
168
+
169
+ 也可以从 GitHub 仓库部署:Root Directory 设 `deploy/relay`、Watch Paths 限 `deploy/relay/**`,其余同上。
170
+
171
+ #### 升级
172
+
173
+ 中继托管的 Web 客户端和宿主版本不一致时,客户端会显示需要更新;两边一起升级——宿主 `pinomad upgrade`,VPS 上 `npm i -g pinomad@latest` 后重启中继;容器部署则是改 Dockerfile 里的 `PINOMAD_VERSION`,再在 `deploy/relay` 下重新 `zeabur deploy --service-id <服务 id>`(从 GitHub 部署的话合进 main 即触发)。宿主不在中继的 `--allow-host` 里时,宿主日志和 Web 警告会带着要补的 `--allow-host <hostId>`。
142
174
 
143
175
  npm 装的实例和本机任何开发检出互不相干(各自的数据目录、进程、包路径都独立),所以在同一台机器上开发 PiNomad 不会碰到正在跑的服务。
144
176
 
@@ -160,8 +192,9 @@ pinomad upgrade [--force]
160
192
  - `restart` 先连上宿主读任务图,有未结束的任务就等它们跑完再重启(Ctrl-C 取消);`--force` 立即重启,交给 Durable 恢复。打包安装时 `upgrade` 把 `pinomad@latest` 装进运行包所在的 npm 全局前缀(非全局安装会拒绝执行)+ 同样的 restart;源码检出里(`bun run upgrade`)依次 `git pull --ff-only`、`bun install`、`bun run build`,工作区有未提交改动时拒绝执行。前面任何一步失败都直接停下,不重启,服务继续跑旧代码。
161
193
  - `uninstall` 只卸载服务,不动数据目录。
162
194
  - macOS:宿主以登录会话运行,机器重启后要能自动起来需在系统设置里打开自动登录,并为这台机器关掉睡眠。日志写到 `<数据目录>/logs/host.log`。
163
- - Linux:要在未登录时也运行需自己执行 `loginctl enable-linger`(install 检测到没开会提示);日志走 `journalctl --user`。
164
- - 服务模式下**回环端口也用 HTTP 提供构建好的 Web 客户端**。`pinomad link` 打印的链接按顺序探测:正在跑的 vite 开发服务器(5199)优先;否则宿主自己提供 Web 的端口;都没有则仍是 5199。开发时 `bun run start` + vite 照旧。
195
+ - Linux:要在未登录时也运行需自己执行 `loginctl enable-linger`(install 检测到没开会提示);日志走 `journalctl --user`。开着 ufw 时,局域网设备访问 `--remote-port` 要先放行,比如 `sudo ufw allow from 192.168.0.0/24 to any port 7422 proto tcp`(只走中继就不用开)。
196
+ - **服务绑定安装时的 node 路径**:单元里写的是 node 和包的绝对路径。用 mise / nvm 管理 Node 时,路径里带着具体版本号(`…/node/26.8.2/…`);换了 Node 版本、旧目录被删后服务就起不来,要用新版本重新 `npm i -g pinomad`,再跑一次 `pinomad service install …`。`pinomad upgrade` 只升 pinomad,装回原来的前缀,不受影响。
197
+ - 服务模式下**回环端口也用 HTTP 提供构建好的 Web 客户端**。npm 安装时 `pinomad link` 总是指向宿主自己提供的 Web;源码检出里按顺序探测:正在跑的 vite 开发服务器(5199)优先,否则宿主提供 Web 的端口,都没有则仍是 5199。开发时 `bun run start` + vite 照旧。
165
198
  - 服务在跑时 `bun run start` 会因为目录锁拿不到而失败(同一数据目录只有一个宿主);开发时用另一个 `--data-dir`,或先 `uninstall`。
166
199
 
167
200
  Windows 不在支持范围;其他平台跑 `service` 会直接报错退出。
@@ -173,3 +206,17 @@ bun run typecheck
173
206
  bun run test # vitest + Playwright,首次跑前先 bunx playwright install chromium
174
207
  bun run build
175
208
  ```
209
+
210
+ ## 发布(ADR-0016)
211
+
212
+ 推 `v*` tag 触发 `.github/workflows/release.yml`,三个 job:`verify` 跑 typecheck / test / build、打包、`npm pack`、装包冒烟;`macos`(macos-15)签名 + 公证出 arm64 的 dmg/zip,挂镜像验证签名和公证票据,再跑一遍 `desktop:e2e`;`publish` 先把两者做成 **draft** GitHub Release(`PiNomad <version>`,自动生成 release notes,预发布 tag 打 `--prerelease`),上传 dmg / zip / blockmap / `latest-mac.yml` / `SHA256SUMS.txt`,然后 `npm publish`,全成了才解除 draft。`latest-mac.yml` 就是桌面端自动更新的更新源。带 `-` 的预发布 tag(`v0.2.0-beta.1`)发到 `next` 且不是 latest release,正式版发到 `latest`。发布用 npm trusted publishing(OIDC),仓库里没有 npm token。
213
+
214
+ macOS 签名和公证需要这些仓库 secrets:`MAC_CSC_LINK`(开发者证书的 base64 .p12)、`MAC_CSC_KEY_PASSWORD`(p12 密码)、`APPLE_API_KEY`(App Store Connect 的 .p8 内容)、`APPLE_API_KEY_ID`、`APPLE_API_ISSUER`、`APPLE_TEAM_ID`。`workflow_dispatch` 手动触发也跑完整签名公证(版本号 `0.0.0-dryrun`),只是不会走 `publish` job,方便干跑验证发布链。
215
+
216
+ ```sh
217
+ git tag v0.1.1
218
+ git push origin v0.1.1
219
+ ```
220
+
221
+ - 手动发布(流水线不可用时):`bun run build && bun run package -- --version <v>`,再在 `apps/cli/out` 里 `npm publish`。终端里的 npm 要先 `npm login`(`npm whoami` 能报出用户名);没登录时 npm 回的是 `404 Not Found` 而不是 401,容易误判成包名问题。
222
+ - 发布后几分钟内 registry 可能还没处理完(新包还会先出现一个 `0.0.0-stage` 占位版本);这期间查过的机器缓存了旧元数据,安装报 `ETARGET` / "No matching version" 时加 `--prefer-online`。
@@ -2,10 +2,10 @@
2
2
  import {
3
3
  main,
4
4
  restartService
5
- } from "./chunk-928atme2.js";
6
- import"./chunk-v7yf5752.js";
7
- import"./chunk-y73t2jmc.js";
8
- import"./chunk-wgn3cx5q.js";
5
+ } from "./chunk-d9q0dz8s.js";
6
+ import"./chunk-rkfdsacq.js";
7
+ import"./chunk-5h97zxag.js";
8
+ import"./chunk-h35fermn.js";
9
9
  import"./chunk-cjfmfyjb.js";
10
10
  import"./chunk-df32avw6.js";
11
11
  export {
@@ -1,9 +1,10 @@
1
1
  import {
2
- PROTOCOL_VERSION,
2
+ INVALID_FRAME_CLOSE_CODE,
3
+ PROTOCOL,
3
4
  UNAUTHORIZED_CLOSE_CODE,
4
5
  conversationStream,
5
6
  docStream
6
- } from "./chunk-wgn3cx5q.js";
7
+ } from "./chunk-h35fermn.js";
7
8
 
8
9
  // packages/protocol/src/remote-durable.ts
9
10
  import { applyImmutable } from "@earendil-works/chord/delta";
@@ -125,13 +126,19 @@ class UnauthorizedError extends Error {
125
126
  }
126
127
 
127
128
  class ProtocolMismatchError extends Error {
128
- host;
129
- client;
130
- constructor(host, client) {
131
- super(`Host protocol mismatch: host speaks ${host}, this client speaks ${client}`);
129
+ hostMajor;
130
+ clientMajor;
131
+ hostVersion;
132
+ direction;
133
+ constructor(hostMajor, clientMajor, hostVersion) {
134
+ const direction = hostMajor > clientMajor ? "client-older" : "host-older";
135
+ super(direction === "client-older" ? `Client out of date: the host speaks protocol major ${hostMajor}, this client speaks ${clientMajor}` : `Host out of date: this client speaks protocol major ${clientMajor}, the host speaks ${hostMajor}`);
132
136
  this.name = "ProtocolMismatchError";
133
- this.host = host;
134
- this.client = client;
137
+ this.hostMajor = hostMajor;
138
+ this.clientMajor = clientMajor;
139
+ if (hostVersion !== undefined)
140
+ this.hostVersion = hostVersion;
141
+ this.direction = direction;
135
142
  }
136
143
  }
137
144
 
@@ -141,6 +148,7 @@ class RemoteClient {
141
148
  #listeners = new Set;
142
149
  #values = new Map;
143
150
  #wanted = new Set(["conversations", "index", "devices", "mcp"]);
151
+ #fresh = new Set;
144
152
  #docs = [];
145
153
  #toolPresentations = {};
146
154
  #snapshotWaiters = new Map;
@@ -149,6 +157,7 @@ class RemoteClient {
149
157
  #connection;
150
158
  #state;
151
159
  #current;
160
+ #side;
152
161
  #nextCall = 1;
153
162
  #nextNotice = 1;
154
163
  #attempt = 0;
@@ -192,10 +201,16 @@ class RemoteClient {
192
201
  return;
193
202
  if (code === UNAUTHORIZED_CLOSE_CODE) {
194
203
  this.#closed = true;
195
- this.#update({ connection: "closed" });
204
+ this.#update({ connection: "closed", unauthorized: true });
196
205
  this.#notice("error", new UnauthorizedError().message);
197
206
  return;
198
207
  }
208
+ if (code === INVALID_FRAME_CLOSE_CODE) {
209
+ this.#closed = true;
210
+ this.#update({ connection: "closed" });
211
+ this.#notice("error", `The host rejected a malformed frame from this client${reason === undefined ? "" : `: ${reason}`}`);
212
+ return;
213
+ }
199
214
  this.#update({ connection: "reconnecting" });
200
215
  const { min, max } = this.#options.reconnectDelayMs ?? { min: 200, max: 2000 };
201
216
  const delay = Math.min(max, min * 2 ** this.#attempt++);
@@ -208,15 +223,27 @@ class RemoteClient {
208
223
  this.#connection?.send(JSON.stringify(frame));
209
224
  }
210
225
  #receive(frame) {
226
+ if (frame === null || typeof frame !== "object")
227
+ return;
211
228
  switch (frame.type) {
212
229
  case "hello": {
213
- if (frame.protocol !== PROTOCOL_VERSION) {
214
- const mismatch = new ProtocolMismatchError(frame.protocol, PROTOCOL_VERSION);
230
+ const announced = frame.protocol;
231
+ const hostMajor = typeof announced === "number" ? announced : announced !== null && typeof announced === "object" ? announced.major : undefined;
232
+ if (hostMajor !== PROTOCOL.major) {
233
+ const mismatch = new ProtocolMismatchError(typeof hostMajor === "number" ? hostMajor : Number.NaN, PROTOCOL.major, frame.hostVersion);
215
234
  this.#closed = true;
216
235
  if (this.#state === undefined) {
217
236
  this.#rejectReady(mismatch);
218
237
  } else {
219
- this.#update({ connection: "outdated" });
238
+ this.#update({
239
+ connection: "outdated",
240
+ protocolMismatch: {
241
+ hostMajor: mismatch.hostMajor,
242
+ clientMajor: mismatch.clientMajor,
243
+ ...mismatch.hostVersion === undefined ? {} : { hostVersion: mismatch.hostVersion },
244
+ direction: mismatch.direction
245
+ }
246
+ });
220
247
  this.#notice("error", mismatch.message);
221
248
  }
222
249
  this.#connection?.close();
@@ -225,9 +252,13 @@ class RemoteClient {
225
252
  this.#attempt = 0;
226
253
  this.#docs = frame.docs;
227
254
  this.#toolPresentations = frame.toolPresentations;
255
+ this.#fresh.clear();
228
256
  if (this.#current !== undefined)
229
257
  for (const stream of this.#conversationStreams(this.#current))
230
258
  this.#wanted.add(stream);
259
+ if (this.#side !== undefined)
260
+ for (const stream of this.#conversationStreams(this.#side))
261
+ this.#wanted.add(stream);
231
262
  for (const stream of this.#wanted)
232
263
  this.#send({ type: "subscribe", stream });
233
264
  if (this.#state !== undefined) {
@@ -246,6 +277,7 @@ class RemoteClient {
246
277
  if (!this.#wanted.has(frame.stream))
247
278
  return;
248
279
  this.#values.set(frame.stream, frame.value);
280
+ this.#fresh.add(frame.stream);
249
281
  this.#refresh();
250
282
  for (const waiter of this.#snapshotWaiters.get(frame.stream)?.splice(0) ?? [])
251
283
  waiter.resolve();
@@ -259,6 +291,7 @@ class RemoteClient {
259
291
  case "ended":
260
292
  this.#wanted.delete(frame.stream);
261
293
  this.#values.delete(frame.stream);
294
+ this.#fresh.delete(frame.stream);
262
295
  for (const waiter of this.#snapshotWaiters.get(frame.stream)?.splice(0) ?? []) {
263
296
  waiter.reject(new Error(`Stream ${frame.stream} ended: ${frame.reason}`));
264
297
  }
@@ -306,26 +339,66 @@ class RemoteClient {
306
339
  #conversationStreams(id) {
307
340
  return [conversationStream(id), ...this.#docs.map((doc) => docStream(doc.kind, id))];
308
341
  }
342
+ async#acquire(id) {
343
+ const missing = this.#conversationStreams(id).filter((stream) => !this.#wanted.has(stream));
344
+ const stale = this.#conversationStreams(id).filter((stream) => this.#wanted.has(stream) && !this.#fresh.has(stream));
345
+ const shown = Promise.all([...missing, ...stale].map((stream) => this.#snapshot(stream)));
346
+ for (const stream of missing) {
347
+ this.#wanted.add(stream);
348
+ this.#send({ type: "subscribe", stream });
349
+ }
350
+ await shown;
351
+ }
352
+ #release(id, other) {
353
+ const keep = new Set(other === undefined ? [] : this.#conversationStreams(other));
354
+ for (const stream of this.#conversationStreams(id)) {
355
+ if (keep.has(stream))
356
+ continue;
357
+ this.#wanted.delete(stream);
358
+ this.#values.delete(stream);
359
+ this.#fresh.delete(stream);
360
+ this.#send({ type: "unsubscribe", stream });
361
+ }
362
+ }
363
+ #descendsFrom(id, ancestor) {
364
+ const summaries = this.#values.get("conversations") ?? [];
365
+ const parentOf = new Map(summaries.map((summary) => [summary.id, summary.parent]));
366
+ let cursor = id;
367
+ for (let depth = 0;cursor !== undefined && depth < 8; depth++) {
368
+ if (cursor === ancestor)
369
+ return true;
370
+ cursor = parentOf.get(cursor);
371
+ }
372
+ return false;
373
+ }
374
+ #docsOf(id) {
375
+ return this.#docs.flatMap((doc) => {
376
+ const stream = docStream(doc.kind, id);
377
+ return this.#values.has(stream) ? [{ kind: doc.kind, presentation: doc.presentation, value: this.#values.get(stream) }] : [];
378
+ });
379
+ }
309
380
  #refresh() {
310
381
  if (this.#state === undefined)
311
382
  return;
312
383
  const current = this.#current;
384
+ const side = this.#side;
313
385
  const conversation = current === undefined ? undefined : this.#values.get(conversationStream(current));
314
386
  const summaries = this.#values.get("conversations") ?? [];
315
387
  const index = this.#values.get("index") ?? { projects: [], conversations: [] };
316
388
  const devices = this.#values.get("devices")?.devices ?? [];
317
389
  const tasks = this.#wanted.has("tasks") ? this.#values.get("tasks") : undefined;
318
- const docs = current === undefined ? [] : this.#docs.flatMap((doc) => {
319
- const stream = docStream(doc.kind, current);
320
- return this.#values.has(stream) ? [{ kind: doc.kind, presentation: doc.presentation, value: this.#values.get(stream) }] : [];
321
- });
322
390
  this.#update({
323
391
  conversation,
324
392
  organized: organize(index, summaries),
325
393
  home: current === undefined ? undefined : homeOf(index, summaries, current),
326
394
  checkout: current === undefined ? undefined : checkoutOf(index, summaries, current),
327
395
  tasks,
328
- docs,
396
+ docs: current === undefined ? [] : this.#docsOf(current),
397
+ side: side === undefined ? undefined : {
398
+ id: side,
399
+ conversation: this.#values.get(conversationStream(side)),
400
+ docs: this.#docsOf(side)
401
+ },
329
402
  devices,
330
403
  mcp: this.#values.get("mcp") ?? null
331
404
  });
@@ -396,15 +469,18 @@ class RemoteClient {
396
469
  await this.#call("archive", { conversationId: id, archived });
397
470
  if (archived && id === this.#current)
398
471
  this.#unshow();
472
+ if (archived && this.#side !== undefined && this.#descendsFrom(this.#side, id))
473
+ await this.#showSide(undefined);
399
474
  }),
400
- submit: (text, whenBusy) => this.#command(() => this.#call("submit", { conversationId: conversationId(), text, whenBusy, requestId: newRequestId() })),
475
+ submit: (text, whenBusy, target) => this.#command(() => this.#call("submit", { conversationId: target ?? conversationId(), text, whenBusy, requestId: newRequestId() })),
401
476
  compact: (instructions) => this.#command(() => this.#call("compact", { conversationId: conversationId(), ...instructions === undefined ? {} : { instructions } })),
402
- abort: () => {
403
- if (this.#current === undefined) {
477
+ abort: (target) => {
478
+ const targetConversation = target ?? this.#current;
479
+ if (targetConversation === undefined) {
404
480
  this.#notice("error", "No conversation selected");
405
481
  return Promise.resolve();
406
482
  }
407
- return this.#call("abort", { conversationId: conversationId() }).then(() => {}, (error) => this.#notice("error", error instanceof Error ? error.message : String(error)));
483
+ return this.#call("abort", { conversationId: targetConversation }).then(() => {}, (error) => this.#notice("error", error instanceof Error ? error.message : String(error)));
408
484
  },
409
485
  setThinkingLevel: (level) => this.#command(() => this.#call("setThinkingLevel", { conversationId: conversationId(), level })),
410
486
  setModel: (model) => this.#command(() => this.#call("setModel", { conversationId: conversationId(), model })),
@@ -421,16 +497,20 @@ class RemoteClient {
421
497
  await shown;
422
498
  }),
423
499
  switchConversation: (id) => this.#command(() => this.#switch(id)),
424
- fork: (entryId, prompt, removeTools) => this.#command(async () => {
500
+ showSide: (id) => this.#command(() => this.#showSide(id)),
501
+ fork: (entryId, prompt, removeTools, options) => this.#command(async () => {
425
502
  const { conversationId: forked } = await this.#call("fork", {
426
503
  conversationId: conversationId(),
427
504
  entryId,
428
505
  ...removeTools === undefined ? {} : { removeTools }
429
506
  });
430
- await this.#switch(forked);
507
+ if (options?.show === "side")
508
+ await this.#showSide(forked);
509
+ else
510
+ await this.#switch(forked);
431
511
  await this.#call("submit", { conversationId: forked, text: prompt, whenBusy: "followUp", requestId: newRequestId() });
432
512
  }),
433
- answer: (kind, requestId, answers) => this.#command(() => this.#call("answer", { conversationId: conversationId(), kind, requestId, answers })),
513
+ answer: (kind, requestId, answers, target) => this.#command(() => this.#call("answer", { conversationId: target ?? conversationId(), kind, requestId, answers })),
434
514
  changes: () => {
435
515
  const call = this.#commands.then(() => this.#call("changes", { conversationId: conversationId() }));
436
516
  this.#commands = call.then(() => {}, () => {});
@@ -448,19 +528,10 @@ class RemoteClient {
448
528
  const previous = this.#current;
449
529
  if (id === previous)
450
530
  return;
451
- const next = this.#conversationStreams(id);
452
- for (const stream of next)
453
- this.#wanted.add(stream);
454
- const shown = Promise.all(next.map((stream) => this.#snapshot(stream)));
455
- for (const stream of next)
456
- this.#send({ type: "subscribe", stream });
457
- await shown;
531
+ await this.#acquire(id);
458
532
  this.#current = id;
459
- for (const stream of previous === undefined ? [] : this.#conversationStreams(previous)) {
460
- this.#wanted.delete(stream);
461
- this.#values.delete(stream);
462
- this.#send({ type: "unsubscribe", stream });
463
- }
533
+ if (previous !== undefined)
534
+ this.#release(previous, this.#side);
464
535
  this.#refresh();
465
536
  }
466
537
  #unshow() {
@@ -468,11 +539,18 @@ class RemoteClient {
468
539
  if (previous === undefined)
469
540
  return;
470
541
  this.#current = undefined;
471
- for (const stream of this.#conversationStreams(previous)) {
472
- this.#wanted.delete(stream);
473
- this.#values.delete(stream);
474
- this.#send({ type: "unsubscribe", stream });
475
- }
542
+ this.#release(previous, this.#side);
543
+ this.#refresh();
544
+ }
545
+ async#showSide(id) {
546
+ const previous = this.#side;
547
+ if (id === previous)
548
+ return;
549
+ if (id !== undefined)
550
+ await this.#acquire(id);
551
+ this.#side = id;
552
+ if (previous !== undefined)
553
+ this.#release(previous, this.#current);
476
554
  this.#refresh();
477
555
  }
478
556
  #close() {
@@ -2,10 +2,10 @@ import {
2
2
  commandHint,
3
3
  hostEntry,
4
4
  packagedVersion
5
- } from "./chunk-v7yf5752.js";
5
+ } from "./chunk-rkfdsacq.js";
6
6
  import {
7
7
  connectRemoteDurable
8
- } from "./chunk-y73t2jmc.js";
8
+ } from "./chunk-5h97zxag.js";
9
9
  import {
10
10
  DEFAULT_DATA_DIR,
11
11
  hostAddress