dsh-agentic-pet 0.4.6 → 0.4.8

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.zh.md CHANGED
@@ -9,10 +9,16 @@ kind: "package-bundle"
9
9
 
10
10
  ## 简介
11
11
 
12
- `dsh-agentic-pet` 是 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 生态的桌面宠物插件:宠物以透明置顶窗常驻桌面,跟随 agent 会话状态(idle / thinking / working / error),与 web 界面竞速应答审批与提问(先答者赢),维护每宠独立的触摸好感账本,并向模型暴露表达工具(`pet_say`、`pet_do`、`pet_digest`、`pet_interactions`、`pet_state`)——模型可以借宠物说话、做动作、感知被触摸。说话内容可通过 [BreezeBlue](https://breezeblue.ai) TTS 朗读——carrier 侧合成每条 `/say` 文本,语音就绪后与气泡同时下发;合成失败或超时静默降级为只显示(桌面设置页可配)。多只宠物实现可以并存:目录注册表(`~/.dsh/pet-http.d/`)发现全部在线宠物,状态、交互、账本按宠路由。
12
+ `dsh-agentic-pet` 是 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 生态的桌面宠物插件:宠物以透明置顶窗常驻桌面,跟随 agent 会话状态(idle / thinking / working / error),与 web 界面竞速应答审批与提问(先答者赢),维护每宠独立的触摸好感账本,并向模型暴露表达工具(`pet_say`、`pet_do`、`pet_digest`、`pet_interactions`、`pet_state`、`pet_schedule_wake`、`pet_cancel_wake`)——模型可以借宠物说话、做动作、感知被触摸、给自己定作息。说话内容可通过 [BreezeBlue](https://breezeblue.ai) TTS 朗读——carrier 侧合成每条 `/say` 文本,语音就绪后与气泡同时下发;合成失败或超时静默降级为只显示(桌面设置页可配)。多只宠物实现可以并存:目录注册表(`~/.dsh/whale598/pet-http.d/`)发现全部在线宠物,状态、交互、账本按宠路由。
13
13
 
14
14
  宠物本身是零 DSH 知识的纯显示端。理解运行时的一切——事件收敛、交互瀑布、多宠路由、模型工具——都在插件的 driver 里,宠物只实现一个小型本地 HTTP 控制接口。任何讲同一 HTTP 契约的宠物实现都能被采纳,插件零改动。
15
15
 
16
+ ### 多宠会话路由(session ↔ 宠物)
17
+
18
+ - **宠自己的内心戏会话**(每宠一个:boot greeting 自动创建,或设置页「指定会话」手动绑定):driver 用 `voiceOwners` 映射表记录会话→宠物,模型在会话里的 `pet_say`/`pet_do` 不点名也自动落回这只宠;这些回合不画状态灯(呼吸不是工作状态)。
19
+ - **工作会话**:有绑定宠时状态灯只亮在它身上(宠物在交互竞速里答过题即赢得绑定);无绑定或绑定宠离线时,状态广播到全部在线宠。审批/问答永远全员广播。工作会话没有宠物工具,不涉及说话/动作的分发。
20
+ - **手动在侧栏开的宠物模式会话**:driver 不认识它(不在映射表里),按普通工作会话对待——状态灯上身,表演类工具(`pet_say`/`pet_do`/定撤闹钟)直接拒答、不分发。这不是预期用法:想跟宠物说话,请用宠物表面的输入卡,直达她自己的会话。
21
+
16
22
  ## 仓库结构
17
23
 
18
24
  | 路径 | 内容 |
package/lib/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { c as globalFilePath, d as readJsonl, f as readPetName, h as writePetName, i as installPetDriver, l as readAffectionRaw, m as writeGlobalJson, n as installPetService, p as writeAffectionRaw, r as petHome, s as eventsFile, u as readGlobalJson } from "./service-R8qwDx1Q.js";
1
+ import { c as globalFilePath, d as readJsonl, f as readPetName, h as writePetName, i as installPetDriver, l as readAffectionRaw, m as writeGlobalJson, n as installPetService, p as writeAffectionRaw, r as petHome, s as eventsFile, u as readGlobalJson } from "./service-Df8olC1E.js";
2
2
  import { createRequire } from "node:module";
3
3
  import { execFileSync, spawn } from "node:child_process";
4
4
  import { createHash } from "node:crypto";
5
- import { appendFileSync, existsSync, readFileSync } from "node:fs";
5
+ import { appendFileSync, existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { mkdir, readFile, rm, unlink, writeFile } from "node:fs/promises";
7
7
  import { homedir } from "node:os";
8
8
  import { dirname, extname, join, normalize, resolve, sep } from "node:path";
@@ -656,46 +656,169 @@ function apply(ctx) {
656
656
  return binary;
657
657
  };
658
658
  /**
659
- * Auto-start the pet window. A bare Electron binary runs any app directory
660
- * (`electron.exe <dir>`), but a PACKAGED one (the installed desktop) loads
661
- * its own app and dies on its single-instance lock instead — so the host's
662
- * `process.execPath` is only a last resort and the resolution order is an
663
- * explicit env override, a binary staged beside this package, then the
664
- * `electron` npm dependency (its module resolves to the binary path).
665
- * The carrier's single-instance lock collapses duplicates.
659
+ * Locate the npm `electron` package WITHOUT loading it. Loading the module
660
+ * (`require('electron')`) runs its lazy installer synchronously — a ~250 MB
661
+ * download inside the host's event loop — so only the manifest is resolved
662
+ * here; installing is a separate, background step.
666
663
  */
667
- const resolveElectronBinary = () => {
668
- const override = process.env.DSH_PET_ELECTRON;
669
- if (override !== void 0 && override !== "") {
670
- if (existsSync(override)) return override;
671
- logCarrier(`DSH_PET_ELECTRON is set but missing, falling back: ${override}`);
672
- }
673
- const pkgRoot = resolve(join(dirname(fileURLToPath(import.meta.url)), ".."));
674
- const staged = join(pkgRoot, "electron-bin", process.platform === "win32" ? "electron.exe" : process.platform === "darwin" ? join("Electron.app", "Contents", "MacOS", "Electron") : "electron");
675
- if (existsSync(staged)) return staged;
664
+ const findNpmElectronPackage = () => {
676
665
  try {
677
666
  const require = createRequire(import.meta.url);
678
- const candidate = String(require("electron"));
679
- if (existsSync(candidate)) return ensureElectronUsable(candidate);
667
+ const pkgDir = dirname(require.resolve("electron/package.json"));
668
+ if (existsSync(join(pkgDir, "package.json"))) return pkgDir;
680
669
  } catch {}
681
670
  try {
682
671
  let dir = dirname(fileURLToPath(import.meta.url));
683
672
  for (let i = 0; i < 8; i += 1) {
684
673
  const pkgDir = join(dir, "node_modules", "electron");
685
- const exe = readFileSync(join(pkgDir, "path.txt"), "utf8").trim();
686
- const binary = join(pkgDir, "dist", exe);
687
- if (existsSync(binary)) return ensureElectronUsable(binary);
674
+ if (existsSync(join(pkgDir, "package.json"))) return pkgDir;
688
675
  const parent = dirname(dir);
689
676
  if (parent === dir) break;
690
677
  dir = parent;
691
678
  }
692
679
  } catch {}
693
- return process.versions.electron !== void 0 ? process.execPath : void 0;
694
680
  };
695
- const spawnPetCarrier = (url) => {
696
- const carrierDir = resolve(join(dirname(fileURLToPath(import.meta.url)), "..", "carrier"));
697
- const electronBinary = resolveElectronBinary();
698
- if (process.env.DSH_PET_NO_SPAWN === "1" || electronBinary === void 0 || !existsSync(join(carrierDir, "package.json"))) return;
681
+ const npmElectronBinary = (pkgDir) => {
682
+ try {
683
+ const exe = readFileSync(join(pkgDir, "path.txt"), "utf8").trim();
684
+ const binary = join(pkgDir, "dist", exe);
685
+ return existsSync(binary) ? binary : void 0;
686
+ } catch {
687
+ return;
688
+ }
689
+ };
690
+ const INSTALL_LOCK_STALE_MS = 18e5;
691
+ const installLockPath = (pkgDir) => join(pkgDir, ".dsh-pet-install.lock");
692
+ const acquireInstallLock = (pkgDir) => {
693
+ const lock = installLockPath(pkgDir);
694
+ const write = () => {
695
+ writeFileSync(lock, JSON.stringify({
696
+ pid: process.pid,
697
+ at: Date.now()
698
+ }), { flag: "wx" });
699
+ };
700
+ try {
701
+ write();
702
+ return true;
703
+ } catch {
704
+ try {
705
+ if (Date.now() - statSync(lock).mtimeMs > INSTALL_LOCK_STALE_MS) {
706
+ unlinkSync(lock);
707
+ write();
708
+ return true;
709
+ }
710
+ } catch {}
711
+ return false;
712
+ }
713
+ };
714
+ const releaseInstallLock = (pkgDir) => {
715
+ try {
716
+ unlinkSync(installLockPath(pkgDir));
717
+ } catch {}
718
+ };
719
+ /**
720
+ * Install the npm electron binary off the activation path. The package's
721
+ * lazy installer is a ~250 MB download; running it synchronously (which
722
+ * `require('electron')` does) freezes the host, so it runs as a child with
723
+ * progress in the spawn log and a mirror hint on failure.
724
+ */
725
+ const installNpmElectron = async (pkgDir) => {
726
+ const installer = join(pkgDir, "install.js");
727
+ if (!existsSync(installer)) {
728
+ logCarrier(`electron binary missing and install.js not found: ${pkgDir}`);
729
+ return;
730
+ }
731
+ if (!acquireInstallLock(pkgDir)) {
732
+ logCarrier(`electron install already in progress (lock present): ${pkgDir}`);
733
+ return;
734
+ }
735
+ logCarrier(`electron binary missing; installing in background: ${pkgDir}`);
736
+ return await new Promise((resolveInstall) => {
737
+ let settled = false;
738
+ const finish = (value) => {
739
+ if (settled) return;
740
+ settled = true;
741
+ releaseInstallLock(pkgDir);
742
+ resolveInstall(value);
743
+ };
744
+ let stderr = "";
745
+ let stdout = "";
746
+ let lastProgress = 0;
747
+ const child = spawn(process.execPath, [installer], {
748
+ cwd: pkgDir,
749
+ env: {
750
+ ...process.env,
751
+ ELECTRON_RUN_AS_NODE: "1"
752
+ },
753
+ stdio: [
754
+ "ignore",
755
+ "pipe",
756
+ "pipe"
757
+ ],
758
+ windowsHide: true
759
+ });
760
+ child.stdout?.setEncoding("utf8");
761
+ child.stdout?.on("data", (chunk) => {
762
+ stdout = `${stdout}${chunk}`.slice(-2e3);
763
+ const now = Date.now();
764
+ if (now - lastProgress > 5e3) {
765
+ lastProgress = now;
766
+ const line = stdout.split(/\r?\n/).map((part) => part.trim()).filter(Boolean).pop();
767
+ if (line !== void 0) logCarrier(`electron install: ${line}`);
768
+ }
769
+ });
770
+ child.stderr?.setEncoding("utf8");
771
+ child.stderr?.on("data", (chunk) => {
772
+ stderr = `${stderr}${chunk}`.slice(-2e3);
773
+ });
774
+ child.on("error", (error) => {
775
+ logCarrier(`electron install failed to start: ${error.message}`);
776
+ finish(void 0);
777
+ });
778
+ child.on("exit", (code) => {
779
+ if (code !== 0) {
780
+ logCarrier(`electron install failed (exit ${String(code)}): ${stderr.trim() || "no stderr"}. Set ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ (or electron_mirror in .npmrc) and restart DSH; or run "node ${installer}" manually.`);
781
+ finish(void 0);
782
+ return;
783
+ }
784
+ const binary = npmElectronBinary(pkgDir);
785
+ if (binary === void 0) {
786
+ logCarrier(`electron install exited 0 but the binary is still missing: ${pkgDir}`);
787
+ finish(void 0);
788
+ return;
789
+ }
790
+ logCarrier(`electron installed: ${binary}`);
791
+ finish(ensureElectronUsable(binary));
792
+ });
793
+ });
794
+ };
795
+ /**
796
+ * Auto-start the pet window. A bare Electron binary runs any app directory
797
+ * (`electron.exe <dir>`), but a PACKAGED one (the installed desktop) loads
798
+ * its own app and dies on its single-instance lock instead — so the
799
+ * resolution order is an explicit env override, a binary staged beside this
800
+ * package, then the `electron` npm dependency. The carrier's single-instance
801
+ * lock collapses duplicates.
802
+ */
803
+ const resolveElectron = () => {
804
+ const override = process.env.DSH_PET_ELECTRON;
805
+ if (override !== void 0 && override !== "") {
806
+ if (existsSync(override)) return { binary: override };
807
+ logCarrier(`DSH_PET_ELECTRON is set but missing, falling back: ${override}`);
808
+ }
809
+ const pkgRoot = resolve(join(dirname(fileURLToPath(import.meta.url)), ".."));
810
+ const staged = join(pkgRoot, "electron-bin", process.platform === "win32" ? "electron.exe" : process.platform === "darwin" ? join("Electron.app", "Contents", "MacOS", "Electron") : "electron");
811
+ if (existsSync(staged)) return { binary: staged };
812
+ const npmPackageDir = findNpmElectronPackage();
813
+ if (npmPackageDir !== void 0) {
814
+ const binary = npmElectronBinary(npmPackageDir);
815
+ if (binary !== void 0) return { binary: ensureElectronUsable(binary) };
816
+ return { npmPackageDir };
817
+ }
818
+ logCarrier("no electron binary: no staged electron-bin, no npm electron dependency, and DSH_PET_ELECTRON is unset");
819
+ return {};
820
+ };
821
+ const launchCarrier = (url, carrierDir, electronBinary) => {
699
822
  const launchArgs = [carrierDir];
700
823
  if (process.env.DSH_PET_NO_SANDBOX === "1") launchArgs.push("--no-sandbox");
701
824
  logCarrier(`spawn binary=${electronBinary} args=${JSON.stringify(launchArgs)} pid=${String(process.pid)}`);
@@ -742,6 +865,21 @@ function apply(ctx) {
742
865
  child.kill();
743
866
  }, "pet-surface: carrier shutdown");
744
867
  };
868
+ const spawnPetCarrier = (url) => {
869
+ const carrierDir = resolve(join(dirname(fileURLToPath(import.meta.url)), "..", "carrier"));
870
+ if (process.env.DSH_PET_NO_SPAWN === "1" || !existsSync(join(carrierDir, "package.json"))) return;
871
+ const resolution = resolveElectron();
872
+ if (resolution.binary !== void 0) {
873
+ launchCarrier(url, carrierDir, resolution.binary);
874
+ return;
875
+ }
876
+ if (resolution.npmPackageDir === void 0) return;
877
+ installNpmElectron(resolution.npmPackageDir).then((binary) => {
878
+ if (binary !== void 0) launchCarrier(url, carrierDir, binary);
879
+ }).catch((error) => {
880
+ logCarrier(`electron install failed: ${error instanceof Error ? error.message : String(error)}`);
881
+ });
882
+ };
745
883
  const settledReady = () => {
746
884
  const url = authenticatedRoot();
747
885
  if (url === void 0) return;
@@ -5395,19 +5395,16 @@ function buildChatPrompt(text, mergedPlan) {
5395
5395
  }
5396
5396
  // lib/types/registry.js
5397
5397
  /**
5398
- * The pet registry: a directory of per-pet control manifests, replacing the
5399
- * last-writer-wins single files whose races caused repeated swap incidents
5400
- * (a dying carrier's late write burying a live pet's registration). Each pet
5401
- * implementation owns its own file under whale598/pet-http.d/ (legacy
5402
- * pet-http.d/ still scanned) and re-asserts it on a
5403
- * cadence; entries whose publisher pid is dead expire by omission. The
5404
- * driver reads the whole table and routes per the multi-pet design: the
5405
- * session's bound pet if alive, else the default pet (first alive entry),
5406
- * plus an all-notify broadcast mode for interactions.
5398
+ * The pet registry: a directory of per-pet control manifests. Each pet
5399
+ * implementation owns its own file under whale598/pet-http.d/ (named by its
5400
+ * pet id) and re-asserts it on a cadence; entries whose publisher pid is dead
5401
+ * expire by omission. The driver reads the whole table and routes per the
5402
+ * multi-pet design: a session's state rides its bound pet when that pet is
5403
+ * alive and broadcasts to every live pet otherwise; interactions broadcast to
5404
+ * all; the default pet (earliest-booted) resolves unnamed expression targets.
5407
5405
  * @module dsh-agentic-pet/registry
5408
5406
  */
5409
5407
  const REGISTRY_DIR = `${WHALE_DIR}/pet-http.d`;
5410
- const LEGACY_REGISTRY_DIR = "pet-http.d";
5411
5408
  /**
5412
5409
  * A live pet is one whose control plane ANSWERS, not merely one whose pid
5413
5410
  * exists — and not merely one whose port accepts TCP: a frozen pet (stuck
@@ -5438,94 +5435,65 @@ async function probeControlPlane(port) {
5438
5435
  return false;
5439
5436
  }
5440
5437
  }
5441
- /** Read every live entry in the registry. Dead-pid entries are skipped —
5442
- * they expire by omission the next time their file is rewritten — and so are
5443
- * silent entries (pid alive, control plane unresponsive). */
5444
- async function readLivePets(home) {
5445
- const candidates = /* @__PURE__ */ new Map();
5446
- for (const dir of [join(home, REGISTRY_DIR), join(home, LEGACY_REGISTRY_DIR)]) {
5447
- let names = [];
5448
- try {
5449
- names = (await readdir(dir)).filter((name) => name.endsWith(".json"));
5450
- } catch {
5451
- continue;
5452
- }
5453
- for (const name of names) {
5454
- const id = name.slice(0, -5);
5455
- if (id !== "" && !candidates.has(id)) candidates.set(id, join(dir, name));
5456
- }
5457
- }
5458
- const live = [];
5459
- for (const [id, file] of candidates) try {
5460
- const raw = JSON.parse(await readFile(file, "utf8"));
5461
- const port = typeof raw.port === "number" ? raw.port : NaN;
5462
- const pid = typeof raw.pid === "number" ? raw.pid : NaN;
5463
- if (!Number.isFinite(port) || !Number.isFinite(pid)) continue;
5464
- try {
5465
- process.kill(pid, 0);
5466
- } catch {
5467
- continue;
5468
- }
5469
- if (!await probeControlPlane(port)) continue;
5470
- live.push({
5471
- id,
5472
- port,
5473
- pid,
5474
- since: typeof raw.since === "number" ? raw.since : 0
5475
- });
5476
- } catch {}
5477
- return live;
5478
- }
5479
- /** Legacy single-file manifests folded into the registry: the electron
5480
- * carrier's dedicated file and the pure pet's shared file, identity-validated
5481
- * and probe-validated, so pre-registry publishers stay drivable. */
5482
- const LEGACY_SOURCES = [{
5483
- id: "electron",
5484
- file: "pet-http-electron.json",
5485
- identity: "electron"
5486
- }, {
5487
- id: "pure",
5488
- file: "pet-http.json",
5489
- identity: "python"
5490
- }];
5491
- /** Every live pet: registry entries plus legacy single-file manifests folded
5492
- * under their pet ids (registry entries win on id collision). */
5438
+ /**
5439
+ * The default-pet order: earliest booted first (`since` ascending). A missing
5440
+ * or mistyped `since` (0) reads as "unknown boot time" and sorts LAST — it
5441
+ * must never outrank a publisher that stated its boot time. This turns the
5442
+ * multi-pet design's de-facto rule ("the default pet is the first STARTED")
5443
+ * into an actual guarantee: discovery output IS boot order, so every
5444
+ * `pets[0]` consumer (defaultPet, pet_state's defaultPet) reads the
5445
+ * same pet. Stable sort: equal `since` values keep registry listing order.
5446
+ */
5447
+ function bootOrder(pets) {
5448
+ return [...pets].sort((a, b) => (a.since > 0 ? a.since : Number.POSITIVE_INFINITY) - (b.since > 0 ? b.since : Number.POSITIVE_INFINITY));
5449
+ }
5450
+ /** Every live pet in boot order. Dead-pid entries are skipped — they expire
5451
+ * by omission the next time their file is rewritten — and so are silent
5452
+ * entries (pid alive, control plane unresponsive). Probes run CONCURRENTLY:
5453
+ * wall time per pass is the slowest pet (≤800ms frozen budget), not the
5454
+ * serial sum. */
5493
5455
  async function readAllPets(home) {
5494
- const pets = await readLivePets(home);
5495
- for (const { id, file, identity } of LEGACY_SOURCES) {
5496
- if (pets.some((pet) => pet.id === id)) continue;
5456
+ let names = [];
5457
+ try {
5458
+ names = (await readdir(join(home, REGISTRY_DIR))).filter((name) => name.endsWith(".json"));
5459
+ } catch {
5460
+ return [];
5461
+ }
5462
+ return bootOrder((await Promise.all(names.map(async (name) => {
5463
+ const id = name.slice(0, -5);
5464
+ if (id === "") return null;
5497
5465
  try {
5498
- const manifest = JSON.parse(await readFile(join(home, file), "utf8"));
5499
- if (manifest.pet !== identity) continue;
5500
- if (typeof manifest.port !== "number" || typeof manifest.pid !== "number") continue;
5466
+ const raw = JSON.parse(await readFile(join(home, REGISTRY_DIR, name), "utf8"));
5467
+ const port = typeof raw.port === "number" ? raw.port : NaN;
5468
+ const pid = typeof raw.pid === "number" ? raw.pid : NaN;
5469
+ if (!Number.isFinite(port) || !Number.isFinite(pid)) return null;
5501
5470
  try {
5502
- process.kill(manifest.pid, 0);
5471
+ process.kill(pid, 0);
5503
5472
  } catch {
5504
- continue;
5473
+ return null;
5505
5474
  }
5506
- if (!await probeControlPlane(manifest.port)) continue;
5507
- pets.push({
5475
+ if (!await probeControlPlane(port)) return null;
5476
+ return {
5508
5477
  id,
5509
- port: manifest.port,
5510
- pid: manifest.pid,
5511
- since: 0
5512
- });
5513
- } catch {}
5514
- }
5515
- return pets;
5478
+ port,
5479
+ pid,
5480
+ since: typeof raw.since === "number" ? raw.since : 0
5481
+ };
5482
+ } catch {
5483
+ return null;
5484
+ }
5485
+ }))).filter((entry) => entry !== null));
5516
5486
  }
5517
5487
  /**
5518
- * Resolve the pet that should carry STATE for the given session, per the
5519
- * multi-pet design: the session's bound pet when alive, else the default pet
5520
- * (the first live entry — de facto "first started"). Single-pet deployments
5521
- * degenerate correctly: one live entry is both bound and default.
5488
+ * The default pet for UNNAMED targets: the earliest-booted live entry
5489
+ * (`pets[0]` — discovery's bootOrder guarantees start order, not listing
5490
+ * accident). State routing does NOT go through here anymore: an unbound
5491
+ * (or bound-but-offline) session broadcasts its state to every live pet —
5492
+ * the driver computes that audience itself. Used by the tools'
5493
+ * resolveTarget when neither a named pet nor the owning session resolves.
5522
5494
  * @returns the entry, or undefined when no pet is alive.
5523
5495
  */
5524
- function routePet(pets, boundPetId) {
5525
- if (boundPetId !== void 0) {
5526
- const bound = pets.find((pet) => pet.id === boundPetId);
5527
- if (bound !== void 0) return bound;
5528
- }
5496
+ function defaultPet(pets) {
5529
5497
  return pets[0];
5530
5498
  }
5531
5499
  /** The pets that receive an INTERACTION (all-notify mode): every live pet,
@@ -5834,34 +5802,39 @@ function installPetDriver(ctx, home, pet) {
5834
5802
  };
5835
5803
  const pushState = async (state) => {
5836
5804
  currentState = state;
5837
- const target = await pet.route(boundPetId);
5838
- if (target === void 0) return;
5805
+ const pets = await pet.list();
5806
+ const bound = pets.filter((pet) => pet.id === boundPetId);
5807
+ const audience = bound.length > 0 ? bound : pets;
5808
+ if (audience.length === 0) return;
5839
5809
  const autonomy = await readAutonomy();
5840
5810
  const autonomyKey = JSON.stringify(autonomy);
5841
- const last = lastPushedByPet.get(target.id);
5842
- if (last !== void 0 && last.state === state && last.since === target.since && last.autonomy === autonomyKey) return;
5843
- lastPushedByPet.set(target.id, {
5844
- state,
5845
- since: target.since,
5846
- autonomy: autonomyKey
5847
- });
5848
- try {
5849
- const response = await fetch(`http://127.0.0.1:${String(target.port)}/state`, {
5850
- method: "POST",
5851
- headers: { "content-type": "application/json" },
5852
- body: JSON.stringify({
5853
- state,
5854
- autonomy
5855
- })
5811
+ await Promise.all(audience.map(async (target) => {
5812
+ const last = lastPushedByPet.get(target.id);
5813
+ if (last !== void 0 && last.state === state && last.since === target.since && last.autonomy === autonomyKey) return;
5814
+ lastPushedByPet.set(target.id, {
5815
+ state,
5816
+ since: target.since,
5817
+ autonomy: autonomyKey
5856
5818
  });
5857
- if (!response.ok) {
5858
- console.error(`pet-surface driver: /state answered HTTP ${String(response.status)}`);
5819
+ try {
5820
+ const response = await fetch(`http://127.0.0.1:${String(target.port)}/state`, {
5821
+ method: "POST",
5822
+ headers: { "content-type": "application/json" },
5823
+ body: JSON.stringify({
5824
+ state,
5825
+ autonomy
5826
+ }),
5827
+ signal: AbortSignal.timeout(5e3)
5828
+ });
5829
+ if (!response.ok) {
5830
+ console.error(`pet-surface driver: /state answered HTTP ${String(response.status)}`);
5831
+ lastPushedByPet.delete(target.id);
5832
+ } else if (state === "error") lastPushedByPet.delete(target.id);
5833
+ } catch {
5834
+ console.error(`pet-surface driver: /state to ${target.id} failed`);
5859
5835
  lastPushedByPet.delete(target.id);
5860
- } else if (state === "error") lastPushedByPet.delete(target.id);
5861
- } catch {
5862
- console.error(`pet-surface driver: /state to ${target.id} failed`);
5863
- lastPushedByPet.delete(target.id);
5864
- }
5836
+ }
5837
+ }));
5865
5838
  };
5866
5839
  events.on("api-session/status", (sessionId, running) => {
5867
5840
  if (isVoiceSession(sessionId)) return;
@@ -6471,7 +6444,8 @@ ${text}`;
6471
6444
  if (target !== void 0) fetch(`http://127.0.0.1:${String(target.port)}/action`, {
6472
6445
  method: "POST",
6473
6446
  headers: { "content-type": "application/json" },
6474
- body: JSON.stringify({ action: "celebrate" })
6447
+ body: JSON.stringify({ action: "celebrate" }),
6448
+ signal: AbortSignal.timeout(5e3)
6475
6449
  }).catch(() => {});
6476
6450
  }
6477
6451
  triggerTouchReply(petId, part);
@@ -6582,10 +6556,14 @@ function registerPetTools(ctx, home, pet) {
6582
6556
  }
6583
6557
  }
6584
6558
  const focus = await readFocus();
6585
- const target = (focus !== void 0 ? pets.find((entry) => entry.id === focus) : void 0) ?? routePet(pets, void 0);
6559
+ const target = (focus !== void 0 ? pets.find((entry) => entry.id === focus) : void 0) ?? defaultPet(pets);
6586
6560
  if (target === void 0) throw new Error("no pet is running");
6587
6561
  return target;
6588
6562
  };
6563
+ const requireOwnedSession = (exec) => {
6564
+ const sessionId = exec?.agent?.id;
6565
+ if (typeof sessionId === "string" && !voiceOwners.has(sessionId)) throw new Error("本会话没有绑定宠物(不是宠物自己的内心戏会话),pet 工具不分发。想和宠物说话,请用宠物表面的输入卡。");
6566
+ };
6589
6567
  const classify = (error) => {
6590
6568
  if (error instanceof Error) {
6591
6569
  if (/fetch failed|ECONNREFUSED|ENOTFOUND/i.test(error.message)) return /* @__PURE__ */ new Error(`offline: the pet is not reachable (${error.message})`);
@@ -6616,6 +6594,7 @@ function registerPetTools(ctx, home, pet) {
6616
6594
  }]
6617
6595
  },
6618
6596
  async execute(args, exec) {
6597
+ requireOwnedSession(exec);
6619
6598
  const text = String(args.text).slice(0, 200);
6620
6599
  console.log(`pet-surface driver: pet_say ${JSON.stringify(text).slice(0, 60)}`);
6621
6600
  try {
@@ -6661,6 +6640,7 @@ function registerPetTools(ctx, home, pet) {
6661
6640
  }]
6662
6641
  },
6663
6642
  async execute(args, exec) {
6643
+ requireOwnedSession(exec);
6664
6644
  try {
6665
6645
  const entry = await resolveTarget(args.pet, exec?.agent?.id);
6666
6646
  const requested = String(args.action);
@@ -6973,6 +6953,7 @@ function registerPetTools(ctx, home, pet) {
6973
6953
  }]
6974
6954
  },
6975
6955
  async execute(args, exec) {
6956
+ requireOwnedSession(exec);
6976
6957
  try {
6977
6958
  const entry = await resolveTarget(args.pet, exec?.agent?.id);
6978
6959
  const id = typeof args.id === "string" ? args.id.trim().slice(0, WAKE_PLAN_ID_MAX) : "";
@@ -7057,6 +7038,7 @@ function registerPetTools(ctx, home, pet) {
7057
7038
  }]
7058
7039
  },
7059
7040
  async execute(args, exec) {
7041
+ requireOwnedSession(exec);
7060
7042
  try {
7061
7043
  const entry = await resolveTarget(args.pet, exec?.agent?.id);
7062
7044
  const list = voiceWakePlans.get(entry.id) ?? [];
@@ -7168,8 +7150,8 @@ function registerPetTools(ctx, home, pet) {
7168
7150
  * The `ctx.petService` capability seam — the dsh three-roles pattern applied
7169
7151
  * inside the process boundary. Pets are separate processes by design, so this
7170
7152
  * service is NOT the pet itself: it is the typed in-process seam over the
7171
- * out-of-process HTTP control plane. Discovery (pet-http.d/ registry plus
7172
- * legacy folding), liveness (pid + two-layer probe), retry policy, and the
7153
+ * out-of-process HTTP control plane. Discovery (the whale598/pet-http.d/
7154
+ * registry), liveness (pid + two-layer probe), retry policy, and the
7173
7155
  * /status field matrix (per-implementation extras) are normalized HERE —
7174
7156
  * consumers (the driver, the model tools, the settings page, the upcoming
7175
7157
  * proactivity scheduler) never see registry files, ports, or raw payloads.
@@ -7189,11 +7171,12 @@ function petHome() {
7189
7171
  *
7190
7172
  * Implementations must honor these semantics:
7191
7173
  * - {@link list} returns only pets whose control plane ANSWERS: a dead pid
7192
- * and a frozen publisher (listening socket, silent HTTP) are both skipped.
7193
- * - {@link route} resolves the STATE carrier for a session: the named bound
7194
- * pet when alive, else the default pet (the first live entry — de facto
7195
- * "first started"). Interaction BROADCAST is not a route; see
7196
- * `notifyPets` in the registry module for the all-notify view.
7174
+ * and a frozen publisher (listening socket, silent HTTP) are both skipped,
7175
+ * in boot order (earliest `since` first).
7176
+ * - Interaction BROADCAST is not a route: see `notifyPets` in the registry
7177
+ * module for the all-notify view, and `defaultPet` for unnamed targets.
7178
+ * State audiences are computed by the driver (bound pet alive → that pet,
7179
+ * otherwise every live pet).
7197
7180
  * - {@link snapshot} never throws on an unreachable pet — it resolves with
7198
7181
  * `online: false`. Field-matrix differences stay here.
7199
7182
  * - {@link call} retries once through a fresh discovery pass, so a pet
@@ -7291,7 +7274,7 @@ function normalizeLastAction(raw) {
7291
7274
  }
7292
7275
  /**
7293
7276
  * HTTP control-plane provider: talks to pet processes over the frozen wire
7294
- * contract, folding legacy single-file manifests into the registry. A new
7277
+ * contract, discovering pets through the whale598/pet-http.d/ registry. A new
7295
7278
  * pet implementation is adopted by publishing the same contract — this
7296
7279
  * class does not change for it.
7297
7280
  */
@@ -7299,15 +7282,23 @@ var PetHttpService = class PetHttpService extends PetService {
7299
7282
  home;
7300
7283
  capsCache = /* @__PURE__ */ new Map();
7301
7284
  static CAPS_TTL_MS = 3e4;
7285
+ listCache;
7286
+ static LIST_TTL_MS = 1e3;
7302
7287
  constructor(ctx, home = petHome()) {
7303
7288
  super(ctx);
7304
7289
  this.home = home;
7305
7290
  }
7291
+ /** Every live pet in boot order, served from the 1s single-flight cache:
7292
+ * concurrent callers share the running probe pass. */
7306
7293
  list() {
7307
- return readAllPets(this.home);
7308
- }
7309
- async route(boundPetId) {
7310
- return routePet(await this.list(), boundPetId);
7294
+ const hit = this.listCache;
7295
+ if (hit !== void 0 && Date.now() - hit.at < PetHttpService.LIST_TTL_MS) return hit.flight;
7296
+ const flight = readAllPets(this.home);
7297
+ this.listCache = {
7298
+ at: Date.now(),
7299
+ flight
7300
+ };
7301
+ return flight;
7311
7302
  }
7312
7303
  async snapshot(entry) {
7313
7304
  try {
@@ -7348,25 +7339,27 @@ var PetHttpService = class PetHttpService extends PetService {
7348
7339
  async call(entry, path, init) {
7349
7340
  const { timeoutMs, retry } = callBudget(path);
7350
7341
  const attempts = retry ? 2 : 1;
7342
+ const callerSignal = init?.signal ?? void 0;
7351
7343
  for (let attempt = 0; attempt < attempts; attempt++) {
7352
7344
  const current = attempt === 0 ? entry : (await readAllPets(this.home)).find((pet) => pet.id === entry.id);
7353
7345
  if (current === void 0) throw new Error(`pet "${entry.id}" is no longer running`);
7346
+ let response;
7354
7347
  try {
7355
7348
  const timeout = AbortSignal.timeout(timeoutMs);
7356
- const callerSignal = init?.signal ?? void 0;
7357
7349
  const signal = callerSignal !== void 0 ? AbortSignal.any([callerSignal, timeout]) : timeout;
7358
- const response = await fetch(`http://127.0.0.1:${String(current.port)}${path}`, {
7350
+ response = await fetch(`http://127.0.0.1:${String(current.port)}${path}`, {
7359
7351
  ...init,
7360
7352
  signal
7361
7353
  });
7362
- if (!response.ok) {
7363
- const detail = await response.text().catch(() => "");
7364
- throw new Error(`pet control ${path} answered HTTP ${String(response.status)}${detail ? `: ${detail.slice(0, 300)}` : ""}`);
7365
- }
7366
- return await response.json();
7367
7354
  } catch (error) {
7368
- if (attempt === attempts - 1) throw error;
7355
+ if (attempt === attempts - 1 || callerSignal?.aborted === true) throw error;
7356
+ continue;
7357
+ }
7358
+ if (!response.ok) {
7359
+ const detail = await response.text().catch(() => "");
7360
+ throw new Error(`pet control ${path} answered HTTP ${String(response.status)}${detail ? `: ${detail.slice(0, 300)}` : ""}`);
7369
7361
  }
7362
+ return await response.json();
7370
7363
  }
7371
7364
  throw new Error("unreachable");
7372
7365
  }
@@ -7411,6 +7404,7 @@ function createUnregisteredPetHttp(home = petHome()) {
7411
7404
  const instance = Object.create(PetHttpService.prototype);
7412
7405
  instance.home = home;
7413
7406
  instance.capsCache = /* @__PURE__ */ new Map();
7407
+ instance.listCache = void 0;
7414
7408
  return instance;
7415
7409
  }
7416
7410
  /**
package/lib/tools.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as logStamp, o as registerPetTools, r as petHome, t as createUnregisteredPetHttp } from "./service-R8qwDx1Q.js";
1
+ import { a as logStamp, o as registerPetTools, r as petHome, t as createUnregisteredPetHttp } from "./service-Df8olC1E.js";
2
2
  import { appendFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  // lib/types/tools.js
@@ -1,13 +1,11 @@
1
1
  /**
2
- * The pet registry: a directory of per-pet control manifests, replacing the
3
- * last-writer-wins single files whose races caused repeated swap incidents
4
- * (a dying carrier's late write burying a live pet's registration). Each pet
5
- * implementation owns its own file under whale598/pet-http.d/ (legacy
6
- * pet-http.d/ still scanned) and re-asserts it on a
7
- * cadence; entries whose publisher pid is dead expire by omission. The
8
- * driver reads the whole table and routes per the multi-pet design: the
9
- * session's bound pet if alive, else the default pet (first alive entry),
10
- * plus an all-notify broadcast mode for interactions.
2
+ * The pet registry: a directory of per-pet control manifests. Each pet
3
+ * implementation owns its own file under whale598/pet-http.d/ (named by its
4
+ * pet id) and re-asserts it on a cadence; entries whose publisher pid is dead
5
+ * expire by omission. The driver reads the whole table and routes per the
6
+ * multi-pet design: a session's state rides its bound pet when that pet is
7
+ * alive and broadcasts to every live pet otherwise; interactions broadcast to
8
+ * all; the default pet (earliest-booted) resolves unnamed expression targets.
11
9
  * @module dsh-agentic-pet/registry
12
10
  */
13
11
  /** One pet's published control facts. */
@@ -30,21 +28,32 @@ export interface PetEntry {
30
28
  * HTTP probe catches the frozen-but-listening zombies.
31
29
  */
32
30
  export declare function probeControlPlane(port: number): Promise<boolean>;
33
- /** Read every live entry in the registry. Dead-pid entries are skipped —
34
- * they expire by omission the next time their file is rewritten — and so are
35
- * silent entries (pid alive, control plane unresponsive). */
36
- export declare function readLivePets(home: string): Promise<PetEntry[]>;
37
- /** Every live pet: registry entries plus legacy single-file manifests folded
38
- * under their pet ids (registry entries win on id collision). */
31
+ /**
32
+ * The default-pet order: earliest booted first (`since` ascending). A missing
33
+ * or mistyped `since` (0) reads as "unknown boot time" and sorts LAST — it
34
+ * must never outrank a publisher that stated its boot time. This turns the
35
+ * multi-pet design's de-facto rule ("the default pet is the first STARTED")
36
+ * into an actual guarantee: discovery output IS boot order, so every
37
+ * `pets[0]` consumer (defaultPet, pet_state's defaultPet) reads the
38
+ * same pet. Stable sort: equal `since` values keep registry listing order.
39
+ */
40
+ export declare function bootOrder(pets: PetEntry[]): PetEntry[];
41
+ /** Every live pet in boot order. Dead-pid entries are skipped — they expire
42
+ * by omission the next time their file is rewritten — and so are silent
43
+ * entries (pid alive, control plane unresponsive). Probes run CONCURRENTLY:
44
+ * wall time per pass is the slowest pet (≤800ms frozen budget), not the
45
+ * serial sum. */
39
46
  export declare function readAllPets(home: string): Promise<PetEntry[]>;
40
47
  /**
41
- * Resolve the pet that should carry STATE for the given session, per the
42
- * multi-pet design: the session's bound pet when alive, else the default pet
43
- * (the first live entry — de facto "first started"). Single-pet deployments
44
- * degenerate correctly: one live entry is both bound and default.
48
+ * The default pet for UNNAMED targets: the earliest-booted live entry
49
+ * (`pets[0]` — discovery's bootOrder guarantees start order, not listing
50
+ * accident). State routing does NOT go through here anymore: an unbound
51
+ * (or bound-but-offline) session broadcasts its state to every live pet —
52
+ * the driver computes that audience itself. Used by the tools'
53
+ * resolveTarget when neither a named pet nor the owning session resolves.
45
54
  * @returns the entry, or undefined when no pet is alive.
46
55
  */
47
- export declare function routePet(pets: PetEntry[], boundPetId: string | undefined): PetEntry | undefined;
56
+ export declare function defaultPet(pets: PetEntry[]): PetEntry | undefined;
48
57
  /** The pets that receive an INTERACTION (all-notify mode): every live pet,
49
58
  * the session-bound one included. The binding routes STATE — it must never
50
59
  * mute interactions: excluding the bound pet would silence the only body
@@ -2,8 +2,8 @@
2
2
  * The `ctx.petService` capability seam — the dsh three-roles pattern applied
3
3
  * inside the process boundary. Pets are separate processes by design, so this
4
4
  * service is NOT the pet itself: it is the typed in-process seam over the
5
- * out-of-process HTTP control plane. Discovery (pet-http.d/ registry plus
6
- * legacy folding), liveness (pid + two-layer probe), retry policy, and the
5
+ * out-of-process HTTP control plane. Discovery (the whale598/pet-http.d/
6
+ * registry), liveness (pid + two-layer probe), retry policy, and the
7
7
  * /status field matrix (per-implementation extras) are normalized HERE —
8
8
  * consumers (the driver, the model tools, the settings page, the upcoming
9
9
  * proactivity scheduler) never see registry files, ports, or raw payloads.
@@ -77,11 +77,12 @@ export interface PetLastAction {
77
77
  *
78
78
  * Implementations must honor these semantics:
79
79
  * - {@link list} returns only pets whose control plane ANSWERS: a dead pid
80
- * and a frozen publisher (listening socket, silent HTTP) are both skipped.
81
- * - {@link route} resolves the STATE carrier for a session: the named bound
82
- * pet when alive, else the default pet (the first live entry — de facto
83
- * "first started"). Interaction BROADCAST is not a route; see
84
- * `notifyPets` in the registry module for the all-notify view.
80
+ * and a frozen publisher (listening socket, silent HTTP) are both skipped,
81
+ * in boot order (earliest `since` first).
82
+ * - Interaction BROADCAST is not a route: see `notifyPets` in the registry
83
+ * module for the all-notify view, and `defaultPet` for unnamed targets.
84
+ * State audiences are computed by the driver (bound pet alive → that pet,
85
+ * otherwise every live pet).
85
86
  * - {@link snapshot} never throws on an unreachable pet — it resolves with
86
87
  * `online: false`. Field-matrix differences stay here.
87
88
  * - {@link call} retries once through a fresh discovery pass, so a pet
@@ -90,11 +91,9 @@ export interface PetLastAction {
90
91
  */
91
92
  export declare abstract class PetService extends Service {
92
93
  constructor(ctx: Context);
93
- /** Every live pet (registry entries plus legacy manifests folded under
94
- * their pet ids). */
94
+ /** Every live pet in boot order (earliest `since` first — the default-pet
95
+ * rule is a discovery guarantee, not a listing accident). */
95
96
  abstract list(): Promise<PetEntry[]>;
96
- /** The STATE carrier: the bound pet when alive, else the default pet. */
97
- abstract route(boundPetId?: string): Promise<PetEntry | undefined>;
98
97
  /** The normalized mirror of one pet, or `online: false` when unreachable. */
99
98
  abstract snapshot(entry: PetEntry): Promise<PetSnapshot>;
100
99
  /** One JSON call to a pet endpoint, with a single fresh-discovery retry. */
@@ -112,7 +111,7 @@ export declare abstract class PetService extends Service {
112
111
  export declare function normalizeStatus(id: string, raw: Record<string, unknown>, online: boolean): PetSnapshot;
113
112
  /**
114
113
  * HTTP control-plane provider: talks to pet processes over the frozen wire
115
- * contract, folding legacy single-file manifests into the registry. A new
114
+ * contract, discovering pets through the whale598/pet-http.d/ registry. A new
116
115
  * pet implementation is adopted by publishing the same contract — this
117
116
  * class does not change for it.
118
117
  */
@@ -120,9 +119,12 @@ export declare class PetHttpService extends PetService {
120
119
  readonly home: string;
121
120
  private readonly capsCache;
122
121
  private static readonly CAPS_TTL_MS;
122
+ private listCache;
123
+ private static readonly LIST_TTL_MS;
123
124
  constructor(ctx: Context, home?: string);
125
+ /** Every live pet in boot order, served from the 1s single-flight cache:
126
+ * concurrent callers share the running probe pass. */
124
127
  list(): Promise<PetEntry[]>;
125
- route(boundPetId?: string): Promise<PetEntry | undefined>;
126
128
  snapshot(entry: PetEntry): Promise<PetSnapshot>;
127
129
  capabilities(entry: PetEntry): Promise<PetCapabilities>;
128
130
  call(entry: PetEntry, path: string, init?: RequestInit): Promise<unknown>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agentic-pet",
3
3
  "description": "The pet-surface bundle: serves the desktop pet renderer over the web composition, turning dsh into a surface the desktop pet attaches to as an authenticated client",
4
- "version": "0.4.6",
4
+ "version": "0.4.8",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },