pinomad 0.1.1 → 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
 
@@ -209,7 +209,9 @@ bun run build
209
209
 
210
210
  ## 发布(ADR-0016)
211
211
 
212
- 推 `v*` tag 触发 `.github/workflows/release.yml`:跑完 typecheck / test / build、打包、`npm pack`、装包冒烟后才 `npm publish`。带 `-` 的预发布 tag(`v0.2.0-beta.1`)发到 `next`,正式版发到 `latest`。发布用 npm trusted publishing(OIDC),仓库里没有 npm token。
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,方便干跑验证发布链。
213
215
 
214
216
  ```sh
215
217
  git tag v0.1.1
@@ -2,10 +2,10 @@
2
2
  import {
3
3
  main,
4
4
  restartService
5
- } from "./chunk-zx77w1nm.js";
6
- import"./chunk-fpb6f9n5.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-fpb6f9n5.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