dsh-agentic-pet 0.4.5 → 0.4.7

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-ZRl_L6yW.js";
2
2
  import { createRequire } from "node:module";
3
- import { spawn } from "node:child_process";
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";
@@ -618,49 +618,207 @@ function apply(ctx) {
618
618
  console.error(`pet-surface: could not write the locator file: ${error instanceof Error ? error.message : String(error)}`);
619
619
  });
620
620
  };
621
+ /** Carrier spawn diagnostics; the packaged host's stderr is not visible. */
622
+ const logCarrier = (line) => {
623
+ try {
624
+ appendFileSync(join(dirname(petLocatorPath()), "carrier-spawn.log"), `${(/* @__PURE__ */ new Date()).toISOString()} ${line}\n`, "utf8");
625
+ } catch {}
626
+ };
621
627
  /**
622
- * Auto-start the pet window. A bare Electron binary runs any app directory
623
- * (`electron.exe <dir>`), but a PACKAGED one (the installed desktop) loads
624
- * its own app and dies on its single-instance lock instead — so the host's
625
- * `process.execPath` is only a last resort and the resolution order is an
626
- * explicit env override, a binary staged beside this package, then the
627
- * `electron` npm dependency (its module resolves to the binary path).
628
- * The carrier's single-instance lock collapses duplicates.
628
+ * The desktop profile directory is Low-integrity (inheritable), so an
629
+ * electron.exe installed into it by pnpm inherits Low and Chromium crashes
630
+ * on spawn (exit 0x80000003). The host itself runs at Medium, so raising
631
+ * the label in place is enough; a later pnpm install that re-creates the
632
+ * files is healed the same way on the next activation.
629
633
  */
630
- const resolveElectronBinary = () => {
631
- if (process.env.DSH_PET_ELECTRON !== void 0 && process.env.DSH_PET_ELECTRON !== "") return process.env.DSH_PET_ELECTRON;
632
- const pkgRoot = resolve(join(dirname(fileURLToPath(import.meta.url)), ".."));
633
- const staged = join(pkgRoot, "electron-bin", process.platform === "win32" ? "electron.exe" : process.platform === "darwin" ? join("Electron.app", "Contents", "MacOS", "Electron") : "electron");
634
- if (existsSync(staged)) return staged;
634
+ const ensureElectronUsable = (binary) => {
635
+ if (process.platform !== "win32") return binary;
636
+ try {
637
+ const acl = execFileSync("icacls", [binary], {
638
+ encoding: "utf8",
639
+ windowsHide: true
640
+ });
641
+ if (!/Low Mandatory Level/i.test(acl)) return binary;
642
+ execFileSync("icacls", [
643
+ dirname(binary),
644
+ "/setintegritylevel",
645
+ "(OI)(CI)M",
646
+ "/T"
647
+ ], {
648
+ windowsHide: true,
649
+ stdio: "ignore",
650
+ timeout: 3e4
651
+ });
652
+ logCarrier(`raised electron integrity to Medium: ${dirname(binary)}`);
653
+ } catch (error) {
654
+ logCarrier(`could not raise electron integrity: ${error instanceof Error ? error.message : String(error)}`);
655
+ }
656
+ return binary;
657
+ };
658
+ /**
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.
663
+ */
664
+ const findNpmElectronPackage = () => {
635
665
  try {
636
666
  const require = createRequire(import.meta.url);
637
- const candidate = String(require("electron"));
638
- if (existsSync(candidate)) return candidate;
667
+ const pkgDir = dirname(require.resolve("electron/package.json"));
668
+ if (existsSync(join(pkgDir, "package.json"))) return pkgDir;
639
669
  } catch {}
640
670
  try {
641
671
  let dir = dirname(fileURLToPath(import.meta.url));
642
672
  for (let i = 0; i < 8; i += 1) {
643
673
  const pkgDir = join(dir, "node_modules", "electron");
644
- const exe = readFileSync(join(pkgDir, "path.txt"), "utf8").trim();
645
- const binary = join(pkgDir, "dist", exe);
646
- if (existsSync(binary)) return binary;
674
+ if (existsSync(join(pkgDir, "package.json"))) return pkgDir;
647
675
  const parent = dirname(dir);
648
676
  if (parent === dir) break;
649
677
  dir = parent;
650
678
  }
651
679
  } catch {}
652
- return process.versions.electron !== void 0 ? process.execPath : void 0;
653
680
  };
654
- /** Carrier spawn diagnostics; the packaged host's stderr is not visible. */
655
- const logCarrier = (line) => {
681
+ const npmElectronBinary = (pkgDir) => {
656
682
  try {
657
- appendFileSync(join(dirname(petLocatorPath()), "carrier-spawn.log"), `${(/* @__PURE__ */ new Date()).toISOString()} ${line}\n`, "utf8");
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));
658
717
  } catch {}
659
718
  };
660
- const spawnPetCarrier = (url) => {
661
- const carrierDir = resolve(join(dirname(fileURLToPath(import.meta.url)), "..", "carrier"));
662
- const electronBinary = resolveElectronBinary();
663
- if (process.env.DSH_PET_NO_SPAWN === "1" || electronBinary === void 0 || !existsSync(join(carrierDir, "package.json"))) return;
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) => {
664
822
  const launchArgs = [carrierDir];
665
823
  if (process.env.DSH_PET_NO_SANDBOX === "1") launchArgs.push("--no-sandbox");
666
824
  logCarrier(`spawn binary=${electronBinary} args=${JSON.stringify(launchArgs)} pid=${String(process.pid)}`);
@@ -707,6 +865,21 @@ function apply(ctx) {
707
865
  child.kill();
708
866
  }, "pet-surface: carrier shutdown");
709
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
+ };
710
883
  const settledReady = () => {
711
884
  const url = authenticatedRoot();
712
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,63 @@ 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
- }
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). */
5453
+ async function readAllPets(home) {
5454
+ let names = [];
5455
+ try {
5456
+ names = (await readdir(join(home, REGISTRY_DIR))).filter((name) => name.endsWith(".json"));
5457
+ } catch {
5458
+ return [];
5457
5459
  }
5458
5460
  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). */
5493
- 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;
5461
+ for (const name of names) {
5462
+ const id = name.slice(0, -5);
5463
+ if (id === "") continue;
5497
5464
  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;
5465
+ const raw = JSON.parse(await readFile(join(home, REGISTRY_DIR, name), "utf8"));
5466
+ const port = typeof raw.port === "number" ? raw.port : NaN;
5467
+ const pid = typeof raw.pid === "number" ? raw.pid : NaN;
5468
+ if (!Number.isFinite(port) || !Number.isFinite(pid)) continue;
5501
5469
  try {
5502
- process.kill(manifest.pid, 0);
5470
+ process.kill(pid, 0);
5503
5471
  } catch {
5504
5472
  continue;
5505
5473
  }
5506
- if (!await probeControlPlane(manifest.port)) continue;
5507
- pets.push({
5474
+ if (!await probeControlPlane(port)) continue;
5475
+ live.push({
5508
5476
  id,
5509
- port: manifest.port,
5510
- pid: manifest.pid,
5511
- since: 0
5477
+ port,
5478
+ pid,
5479
+ since: typeof raw.since === "number" ? raw.since : 0
5512
5480
  });
5513
5481
  } catch {}
5514
5482
  }
5515
- return pets;
5483
+ return bootOrder(live);
5516
5484
  }
5517
5485
  /**
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.
5486
+ * The default pet for UNNAMED targets: the earliest-booted live entry
5487
+ * (`pets[0]` — discovery's bootOrder guarantees start order, not listing
5488
+ * accident). State routing does NOT go through here anymore: an unbound
5489
+ * (or bound-but-offline) session broadcasts its state to every live pet —
5490
+ * the driver computes that audience itself. Used by the tools'
5491
+ * resolveTarget when neither a named pet nor the owning session resolves.
5522
5492
  * @returns the entry, or undefined when no pet is alive.
5523
5493
  */
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
- }
5494
+ function defaultPet(pets) {
5529
5495
  return pets[0];
5530
5496
  }
5531
5497
  /** The pets that receive an INTERACTION (all-notify mode): every live pet,
@@ -5834,34 +5800,39 @@ function installPetDriver(ctx, home, pet) {
5834
5800
  };
5835
5801
  const pushState = async (state) => {
5836
5802
  currentState = state;
5837
- const target = await pet.route(boundPetId);
5838
- if (target === void 0) return;
5803
+ const pets = await pet.list();
5804
+ const bound = pets.filter((pet) => pet.id === boundPetId);
5805
+ const audience = bound.length > 0 ? bound : pets;
5806
+ if (audience.length === 0) return;
5839
5807
  const autonomy = await readAutonomy();
5840
5808
  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
- })
5809
+ await Promise.all(audience.map(async (target) => {
5810
+ const last = lastPushedByPet.get(target.id);
5811
+ if (last !== void 0 && last.state === state && last.since === target.since && last.autonomy === autonomyKey) return;
5812
+ lastPushedByPet.set(target.id, {
5813
+ state,
5814
+ since: target.since,
5815
+ autonomy: autonomyKey
5856
5816
  });
5857
- if (!response.ok) {
5858
- console.error(`pet-surface driver: /state answered HTTP ${String(response.status)}`);
5817
+ try {
5818
+ const response = await fetch(`http://127.0.0.1:${String(target.port)}/state`, {
5819
+ method: "POST",
5820
+ headers: { "content-type": "application/json" },
5821
+ body: JSON.stringify({
5822
+ state,
5823
+ autonomy
5824
+ }),
5825
+ signal: AbortSignal.timeout(5e3)
5826
+ });
5827
+ if (!response.ok) {
5828
+ console.error(`pet-surface driver: /state answered HTTP ${String(response.status)}`);
5829
+ lastPushedByPet.delete(target.id);
5830
+ } else if (state === "error") lastPushedByPet.delete(target.id);
5831
+ } catch {
5832
+ console.error(`pet-surface driver: /state to ${target.id} failed`);
5859
5833
  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
- }
5834
+ }
5835
+ }));
5865
5836
  };
5866
5837
  events.on("api-session/status", (sessionId, running) => {
5867
5838
  if (isVoiceSession(sessionId)) return;
@@ -6471,7 +6442,8 @@ ${text}`;
6471
6442
  if (target !== void 0) fetch(`http://127.0.0.1:${String(target.port)}/action`, {
6472
6443
  method: "POST",
6473
6444
  headers: { "content-type": "application/json" },
6474
- body: JSON.stringify({ action: "celebrate" })
6445
+ body: JSON.stringify({ action: "celebrate" }),
6446
+ signal: AbortSignal.timeout(5e3)
6475
6447
  }).catch(() => {});
6476
6448
  }
6477
6449
  triggerTouchReply(petId, part);
@@ -6582,10 +6554,14 @@ function registerPetTools(ctx, home, pet) {
6582
6554
  }
6583
6555
  }
6584
6556
  const focus = await readFocus();
6585
- const target = (focus !== void 0 ? pets.find((entry) => entry.id === focus) : void 0) ?? routePet(pets, void 0);
6557
+ const target = (focus !== void 0 ? pets.find((entry) => entry.id === focus) : void 0) ?? defaultPet(pets);
6586
6558
  if (target === void 0) throw new Error("no pet is running");
6587
6559
  return target;
6588
6560
  };
6561
+ const requireOwnedSession = (exec) => {
6562
+ const sessionId = exec?.agent?.id;
6563
+ if (typeof sessionId === "string" && !voiceOwners.has(sessionId)) throw new Error("本会话没有绑定宠物(不是宠物自己的内心戏会话),pet 工具不分发。想和宠物说话,请用宠物表面的输入卡。");
6564
+ };
6589
6565
  const classify = (error) => {
6590
6566
  if (error instanceof Error) {
6591
6567
  if (/fetch failed|ECONNREFUSED|ENOTFOUND/i.test(error.message)) return /* @__PURE__ */ new Error(`offline: the pet is not reachable (${error.message})`);
@@ -6616,6 +6592,7 @@ function registerPetTools(ctx, home, pet) {
6616
6592
  }]
6617
6593
  },
6618
6594
  async execute(args, exec) {
6595
+ requireOwnedSession(exec);
6619
6596
  const text = String(args.text).slice(0, 200);
6620
6597
  console.log(`pet-surface driver: pet_say ${JSON.stringify(text).slice(0, 60)}`);
6621
6598
  try {
@@ -6661,6 +6638,7 @@ function registerPetTools(ctx, home, pet) {
6661
6638
  }]
6662
6639
  },
6663
6640
  async execute(args, exec) {
6641
+ requireOwnedSession(exec);
6664
6642
  try {
6665
6643
  const entry = await resolveTarget(args.pet, exec?.agent?.id);
6666
6644
  const requested = String(args.action);
@@ -6973,6 +6951,7 @@ function registerPetTools(ctx, home, pet) {
6973
6951
  }]
6974
6952
  },
6975
6953
  async execute(args, exec) {
6954
+ requireOwnedSession(exec);
6976
6955
  try {
6977
6956
  const entry = await resolveTarget(args.pet, exec?.agent?.id);
6978
6957
  const id = typeof args.id === "string" ? args.id.trim().slice(0, WAKE_PLAN_ID_MAX) : "";
@@ -7057,6 +7036,7 @@ function registerPetTools(ctx, home, pet) {
7057
7036
  }]
7058
7037
  },
7059
7038
  async execute(args, exec) {
7039
+ requireOwnedSession(exec);
7060
7040
  try {
7061
7041
  const entry = await resolveTarget(args.pet, exec?.agent?.id);
7062
7042
  const list = voiceWakePlans.get(entry.id) ?? [];
@@ -7168,8 +7148,8 @@ function registerPetTools(ctx, home, pet) {
7168
7148
  * The `ctx.petService` capability seam — the dsh three-roles pattern applied
7169
7149
  * inside the process boundary. Pets are separate processes by design, so this
7170
7150
  * 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
7151
+ * out-of-process HTTP control plane. Discovery (the whale598/pet-http.d/
7152
+ * registry), liveness (pid + two-layer probe), retry policy, and the
7173
7153
  * /status field matrix (per-implementation extras) are normalized HERE —
7174
7154
  * consumers (the driver, the model tools, the settings page, the upcoming
7175
7155
  * proactivity scheduler) never see registry files, ports, or raw payloads.
@@ -7189,11 +7169,12 @@ function petHome() {
7189
7169
  *
7190
7170
  * Implementations must honor these semantics:
7191
7171
  * - {@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.
7172
+ * and a frozen publisher (listening socket, silent HTTP) are both skipped,
7173
+ * in boot order (earliest `since` first).
7174
+ * - Interaction BROADCAST is not a route: see `notifyPets` in the registry
7175
+ * module for the all-notify view, and `defaultPet` for unnamed targets.
7176
+ * State audiences are computed by the driver (bound pet alive → that pet,
7177
+ * otherwise every live pet).
7197
7178
  * - {@link snapshot} never throws on an unreachable pet — it resolves with
7198
7179
  * `online: false`. Field-matrix differences stay here.
7199
7180
  * - {@link call} retries once through a fresh discovery pass, so a pet
@@ -7291,7 +7272,7 @@ function normalizeLastAction(raw) {
7291
7272
  }
7292
7273
  /**
7293
7274
  * HTTP control-plane provider: talks to pet processes over the frozen wire
7294
- * contract, folding legacy single-file manifests into the registry. A new
7275
+ * contract, discovering pets through the whale598/pet-http.d/ registry. A new
7295
7276
  * pet implementation is adopted by publishing the same contract — this
7296
7277
  * class does not change for it.
7297
7278
  */
@@ -7306,9 +7287,6 @@ var PetHttpService = class PetHttpService extends PetService {
7306
7287
  list() {
7307
7288
  return readAllPets(this.home);
7308
7289
  }
7309
- async route(boundPetId) {
7310
- return routePet(await this.list(), boundPetId);
7311
- }
7312
7290
  async snapshot(entry) {
7313
7291
  try {
7314
7292
  const response = await fetch(`http://127.0.0.1:${String(entry.port)}/status`, { signal: AbortSignal.timeout(1500) });
@@ -7348,25 +7326,27 @@ var PetHttpService = class PetHttpService extends PetService {
7348
7326
  async call(entry, path, init) {
7349
7327
  const { timeoutMs, retry } = callBudget(path);
7350
7328
  const attempts = retry ? 2 : 1;
7329
+ const callerSignal = init?.signal ?? void 0;
7351
7330
  for (let attempt = 0; attempt < attempts; attempt++) {
7352
7331
  const current = attempt === 0 ? entry : (await readAllPets(this.home)).find((pet) => pet.id === entry.id);
7353
7332
  if (current === void 0) throw new Error(`pet "${entry.id}" is no longer running`);
7333
+ let response;
7354
7334
  try {
7355
7335
  const timeout = AbortSignal.timeout(timeoutMs);
7356
- const callerSignal = init?.signal ?? void 0;
7357
7336
  const signal = callerSignal !== void 0 ? AbortSignal.any([callerSignal, timeout]) : timeout;
7358
- const response = await fetch(`http://127.0.0.1:${String(current.port)}${path}`, {
7337
+ response = await fetch(`http://127.0.0.1:${String(current.port)}${path}`, {
7359
7338
  ...init,
7360
7339
  signal
7361
7340
  });
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
7341
  } catch (error) {
7368
- if (attempt === attempts - 1) throw error;
7342
+ if (attempt === attempts - 1 || callerSignal?.aborted === true) throw error;
7343
+ continue;
7344
+ }
7345
+ if (!response.ok) {
7346
+ const detail = await response.text().catch(() => "");
7347
+ throw new Error(`pet control ${path} answered HTTP ${String(response.status)}${detail ? `: ${detail.slice(0, 300)}` : ""}`);
7369
7348
  }
7349
+ return await response.json();
7370
7350
  }
7371
7351
  throw new Error("unreachable");
7372
7352
  }
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-ZRl_L6yW.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,30 @@ 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). */
39
44
  export declare function readAllPets(home: string): Promise<PetEntry[]>;
40
45
  /**
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.
46
+ * The default pet for UNNAMED targets: the earliest-booted live entry
47
+ * (`pets[0]` — discovery's bootOrder guarantees start order, not listing
48
+ * accident). State routing does NOT go through here anymore: an unbound
49
+ * (or bound-but-offline) session broadcasts its state to every live pet —
50
+ * the driver computes that audience itself. Used by the tools'
51
+ * resolveTarget when neither a named pet nor the owning session resolves.
45
52
  * @returns the entry, or undefined when no pet is alive.
46
53
  */
47
- export declare function routePet(pets: PetEntry[], boundPetId: string | undefined): PetEntry | undefined;
54
+ export declare function defaultPet(pets: PetEntry[]): PetEntry | undefined;
48
55
  /** The pets that receive an INTERACTION (all-notify mode): every live pet,
49
56
  * the session-bound one included. The binding routes STATE — it must never
50
57
  * 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
  */
@@ -122,7 +121,6 @@ export declare class PetHttpService extends PetService {
122
121
  private static readonly CAPS_TTL_MS;
123
122
  constructor(ctx: Context, home?: string);
124
123
  list(): Promise<PetEntry[]>;
125
- route(boundPetId?: string): Promise<PetEntry | undefined>;
126
124
  snapshot(entry: PetEntry): Promise<PetSnapshot>;
127
125
  capabilities(entry: PetEntry): Promise<PetCapabilities>;
128
126
  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.5",
4
+ "version": "0.4.7",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },