dsh-agentic-pet 0.4.9 → 0.4.11

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/lib/index.js CHANGED
@@ -1,12 +1,75 @@
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-BZbhT92u.js";
1
+ import { _ as writePetName, c as parseClock, d as readAffectionRaw, f as readGlobalJson, g as writeGlobalJson, h as writeAffectionRaw, i as voiceBindingSnapshot, l as eventsFile, m as readPetName, o as installPetService, p as readJsonl, s as petHome, t as installPetDriver, u as globalFilePath, v as isPetPreset, y as patchTouchesElectronBinding } from "./driver-BiSFFYXw.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, statSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, 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";
9
9
  import { fileURLToPath } from "node:url";
10
+ const INSTALL_LOCK_MALFORMED_GRACE_MS = 6e4;
11
+ /** True when `pid` names a live process (EPERM means it exists but is not ours). */
12
+ function isProcessAlive(pid) {
13
+ if (!Number.isInteger(pid) || pid <= 0) return false;
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ } catch (error) {
18
+ return error.code === "EPERM";
19
+ }
20
+ }
21
+ /** Parse a lock file; `undefined` means empty/malformed. */
22
+ function parseInstallLock(raw) {
23
+ try {
24
+ const value = JSON.parse(raw);
25
+ return typeof value === "object" && value !== null ? value : void 0;
26
+ } catch {
27
+ return;
28
+ }
29
+ }
30
+ /**
31
+ * Whether an existing lock may be taken over by a new installer.
32
+ * A dead owner is stale immediately; a live owner keeps its lock until the
33
+ * absolute cap (which also bounds PID-reuse false positives). A malformed
34
+ * record gets a short grace for a concurrent writer, then goes stale.
35
+ */
36
+ function installLockStale(raw, mtimeMs, now, alive = isProcessAlive) {
37
+ if (now - mtimeMs > 18e5) return true;
38
+ const record = raw === void 0 ? void 0 : parseInstallLock(raw);
39
+ const pid = typeof record?.pid === "number" ? record.pid : void 0;
40
+ if (pid === void 0) return now - mtimeMs > INSTALL_LOCK_MALFORMED_GRACE_MS;
41
+ return !alive(pid);
42
+ }
43
+ // lib/types/electron-runtime-status.js
44
+ /**
45
+ * Persisted state of the Electron runtime bootstrap, shared by the installer
46
+ * path (writer) and the settings page (reader). The settings page is the only
47
+ * user-visible surface while the first install downloads ~158 MB, so this
48
+ * file is deliberately plain JSON in the pet home rather than process state.
49
+ * @module dsh-agentic-pet/electron-runtime-status
50
+ */
51
+ function electronRuntimeStatusPath(home) {
52
+ return join(home, "whale598", "electron-runtime.json");
53
+ }
54
+ function readElectronRuntimeStatus(home) {
55
+ try {
56
+ const parsed = JSON.parse(readFileSync(electronRuntimeStatusPath(home), "utf8"));
57
+ if (typeof parsed !== "object" || parsed === null) return void 0;
58
+ const status = parsed;
59
+ return typeof status.state === "string" ? status : void 0;
60
+ } catch {
61
+ return;
62
+ }
63
+ }
64
+ function writeElectronRuntimeStatus(home, status) {
65
+ try {
66
+ const path = electronRuntimeStatusPath(home);
67
+ mkdirSync(dirname(path), { recursive: true });
68
+ const tmp = `${path}.${String(process.pid)}.tmp`;
69
+ writeFileSync(tmp, `${JSON.stringify(status)}\n`, "utf8");
70
+ renameSync(tmp, path);
71
+ } catch {}
72
+ }
10
73
  // lib/types/settings-page.js
11
74
  /**
12
75
  * The settings page's host half: local HTTP routes backing the desktop
@@ -46,6 +109,15 @@ const DEFAULT_SETTINGS = {
46
109
  },
47
110
  boundSessions: {}
48
111
  },
112
+ rhythm: { quiet: null },
113
+ journal: {
114
+ enabled: true,
115
+ injectRecent: 3,
116
+ maxEntryChars: 120,
117
+ maxFileBytes: 262144,
118
+ allowUnboundAppend: false
119
+ },
120
+ presenceSummary: { enabled: false },
49
121
  tts: { enabled: false },
50
122
  debug: { cdp: false }
51
123
  };
@@ -111,6 +183,18 @@ function mergeSettings(current, patch) {
111
183
  ...DEFAULT_SETTINGS.innerVoice,
112
184
  ...current.innerVoice
113
185
  };
186
+ const rhythm = {
187
+ quiet: null,
188
+ ...current.rhythm
189
+ };
190
+ const journal = {
191
+ ...DEFAULT_SETTINGS.journal,
192
+ ...current.journal
193
+ };
194
+ const presenceSummary = {
195
+ enabled: false,
196
+ ...current.presenceSummary
197
+ };
114
198
  const debug = {
115
199
  ...DEFAULT_SETTINGS.debug,
116
200
  ...current.debug
@@ -173,6 +257,37 @@ function mergeSettings(current, patch) {
173
257
  innerVoice.boundSessions = record;
174
258
  }
175
259
  }
260
+ const patchRhythm = patch.rhythm;
261
+ if (typeof patchRhythm === "object" && patchRhythm !== null) {
262
+ const r = patchRhythm;
263
+ if (r.quiet === null) rhythm.quiet = null;
264
+ else if (typeof r.quiet === "object" && r.quiet !== null) {
265
+ const q = r.quiet;
266
+ const currentQuiet = current.rhythm?.quiet ?? void 0;
267
+ rhythm.quiet = {
268
+ from: typeof q.from === "string" && parseClock(q.from) !== void 0 ? q.from : currentQuiet?.from ?? "01:00",
269
+ to: typeof q.to === "string" && parseClock(q.to) !== void 0 ? q.to : currentQuiet?.to ?? "09:00",
270
+ allowTouchReply: typeof q.allowTouchReply === "boolean" ? q.allowTouchReply : currentQuiet?.allowTouchReply ?? true
271
+ };
272
+ }
273
+ }
274
+ const patchJournal = patch.journal;
275
+ if (typeof patchJournal === "object" && patchJournal !== null) {
276
+ const j = patchJournal;
277
+ if (typeof j.enabled === "boolean") journal.enabled = j.enabled;
278
+ const inject = clampNumber(j.injectRecent, 1, 20);
279
+ if (inject !== void 0) journal.injectRecent = inject;
280
+ const chars = clampNumber(j.maxEntryChars, 40, 1e3);
281
+ if (chars !== void 0) journal.maxEntryChars = chars;
282
+ const bytes = clampNumber(j.maxFileBytes, 4096, 4194304);
283
+ if (bytes !== void 0) journal.maxFileBytes = bytes;
284
+ if (typeof j.allowUnboundAppend === "boolean") journal.allowUnboundAppend = j.allowUnboundAppend;
285
+ }
286
+ const patchPresence = patch.presenceSummary;
287
+ if (typeof patchPresence === "object" && patchPresence !== null) {
288
+ const p = patchPresence;
289
+ if (typeof p.enabled === "boolean") presenceSummary.enabled = p.enabled;
290
+ }
176
291
  const patchDebug = patch.debug;
177
292
  if (typeof patchDebug === "object" && patchDebug !== null) {
178
293
  const d = patchDebug;
@@ -201,6 +316,9 @@ function mergeSettings(current, patch) {
201
316
  ...current,
202
317
  proactivity,
203
318
  innerVoice,
319
+ rhythm,
320
+ journal,
321
+ presenceSummary,
204
322
  tts,
205
323
  debug
206
324
  };
@@ -225,7 +343,7 @@ function openPathInFileManager(path) {
225
343
  * @param ctx - plugin context inside the runtime.
226
344
  * @param home - the dsh home holding the settings/ledger/manifest files.
227
345
  */
228
- function installSettingsPage(ctx, home, pet) {
346
+ function installSettingsPage(ctx, home, pet, control) {
229
347
  const windowPath = globalFilePath(home, "pet-window.json");
230
348
  /** Summarize EVERY live pet (id, online flag, normalized /status). */
231
349
  const petStatus = async () => {
@@ -238,6 +356,56 @@ function installSettingsPage(ctx, home, pet) {
238
356
  const readSettings = async () => {
239
357
  return mergeSettings(await readGlobalJson(home, "pet-settings.json"), {});
240
358
  };
359
+ /** Live header first, persisted header second. `checked:false` means the
360
+ * host exposed neither source — never turn that into a "missing session".
361
+ * `currentPreset` comes from the preset registry / projection cache, NOT
362
+ * from the immutable creation-time header field. */
363
+ const findSessionHeader = async (sessionId) => {
364
+ const getService = (name) => ctx.get(name);
365
+ const sessions = getService("sessions");
366
+ const agentPresets = getService("agentPresets");
367
+ const live = sessions?.get(sessionId);
368
+ if (live?.header !== void 0) {
369
+ let currentPreset;
370
+ try {
371
+ const preset = agentPresets?.presetForSession(live);
372
+ if (typeof preset === "string" && preset !== "") currentPreset = preset;
373
+ } catch {}
374
+ return {
375
+ header: live.header,
376
+ checked: true,
377
+ currentPreset
378
+ };
379
+ }
380
+ const persistence = getService("sessionPersistence");
381
+ if (persistence === void 0) return {
382
+ header: void 0,
383
+ checked: sessions !== void 0,
384
+ currentPreset: void 0
385
+ };
386
+ try {
387
+ const header = (await persistence.list()).find((snapshot) => snapshot.header?.id === sessionId)?.header;
388
+ let currentPreset;
389
+ if (header !== void 0) {
390
+ const cache = getService("sessionProjectionCache");
391
+ try {
392
+ const preset = cache?.cachedSnapshot(header)?.values?.agentPreset;
393
+ if (typeof preset === "string" && preset !== "") currentPreset = preset;
394
+ } catch {}
395
+ }
396
+ return {
397
+ header,
398
+ checked: true,
399
+ currentPreset
400
+ };
401
+ } catch {
402
+ return {
403
+ header: void 0,
404
+ checked: false,
405
+ currentPreset: void 0
406
+ };
407
+ }
408
+ };
241
409
  const routeByPath = /* @__PURE__ */ new Map();
242
410
  const route = (method, path, handler) => {
243
411
  let handlers = routeByPath.get(path);
@@ -269,6 +437,18 @@ function installSettingsPage(ctx, home, pet) {
269
437
  }
270
438
  handlers.set(method, handler);
271
439
  };
440
+ route("GET", "/api/pet-settings/binding-status", async (_req, res) => {
441
+ writeJson(res, 200, { status: voiceBindingSnapshot() });
442
+ });
443
+ route("POST", "/api/pet-settings/binding-test", async (req, res) => {
444
+ if (control === void 0) {
445
+ writeJson(res, 503, { error: "driver control unavailable" });
446
+ return;
447
+ }
448
+ const body = await readJsonBody(req);
449
+ const petId = typeof body?.petId === "string" && body.petId !== "" ? body.petId : "electron";
450
+ writeJson(res, 200, await control.testBinding(petId));
451
+ });
272
452
  route("GET", "/api/pet-settings/read", async (_req, res) => {
273
453
  const [settings, windowPosition, status] = await Promise.all([
274
454
  readSettings(),
@@ -314,6 +494,12 @@ function installSettingsPage(ctx, home, pet) {
314
494
  touchTotals
315
495
  });
316
496
  });
497
+ route("GET", "/api/pet-settings/electron-runtime", async (_req, res) => {
498
+ writeJson(res, 200, {
499
+ ok: true,
500
+ runtime: readElectronRuntimeStatus(home) ?? null
501
+ });
502
+ });
317
503
  route("POST", "/api/pet-settings/update", async (req, res) => {
318
504
  const body = await readJsonBody(req);
319
505
  const patch = body === void 0 ? void 0 : body.patch;
@@ -327,11 +513,29 @@ function installSettingsPage(ctx, home, pet) {
327
513
  delete patch.names;
328
514
  }
329
515
  const next = mergeSettings(await readSettings(), patch);
516
+ const warnings = [];
517
+ const patchTouchesBinding = patchTouchesElectronBinding(patch);
518
+ const bound = next.innerVoice?.boundSessions?.electron;
519
+ if (typeof bound === "string" && bound !== "") {
520
+ const found = await findSessionHeader(bound);
521
+ if (found.checked && found.header === void 0) warnings.push(`会话 ${bound} 不存在`);
522
+ else {
523
+ if (found.currentPreset !== void 0 && !isPetPreset(found.currentPreset)) {
524
+ if (patchTouchesBinding) {
525
+ writeJson(res, 400, { error: `这个会话的当前模式是「${found.currentPreset}」,没有 pet_say/pet_do。请改用宠物模式(「精简(仅表达)」或「普通(含工作工具)」)的会话,或新建一个宠物模式会话后再绑定。` });
526
+ return;
527
+ }
528
+ warnings.push("当前已保存的绑定会话不是宠物模式;本次保存已生效,但绑定不会有气泡回应。");
529
+ }
530
+ if (found.currentPreset === void 0 && found.header !== void 0) warnings.push("无法确认这个会话的当前模式;如果它不是宠物模式,绑定后不会有气泡回应。");
531
+ }
532
+ }
330
533
  await writeGlobalJson(home, "pet-settings.json", next);
331
534
  ctx.emit("pet-settings/changed");
332
535
  writeJson(res, 200, {
333
536
  ok: true,
334
- settings: next
537
+ settings: next,
538
+ warnings
335
539
  });
336
540
  });
337
541
  route("POST", "/api/pet-settings/reset-affection", async (_req, res) => {
@@ -721,7 +925,6 @@ function apply(ctx) {
721
925
  return;
722
926
  }
723
927
  };
724
- const INSTALL_LOCK_STALE_MS = 18e5;
725
928
  const installLockPath = (pkgDir) => join(pkgDir, ".dsh-pet-install.lock");
726
929
  const acquireInstallLock = (pkgDir) => {
727
930
  const lock = installLockPath(pkgDir);
@@ -734,63 +937,162 @@ function apply(ctx) {
734
937
  try {
735
938
  write();
736
939
  return true;
940
+ } catch {}
941
+ let raw;
942
+ let mtimeMs = 0;
943
+ try {
944
+ raw = readFileSync(lock, "utf8");
945
+ mtimeMs = statSync(lock).mtimeMs;
737
946
  } catch {
738
947
  try {
739
- if (Date.now() - statSync(lock).mtimeMs > INSTALL_LOCK_STALE_MS) {
740
- unlinkSync(lock);
741
- write();
742
- return true;
743
- }
744
- } catch {}
948
+ write();
949
+ return true;
950
+ } catch {
951
+ return false;
952
+ }
953
+ }
954
+ const owner = raw === void 0 ? void 0 : parseInstallLock(raw)?.pid;
955
+ const ownerPid = typeof owner === "number" ? owner : void 0;
956
+ if (!installLockStale(raw, mtimeMs, Date.now())) {
957
+ logCarrier(ownerPid === void 0 ? `electron install lock unreadable but fresh; waiting: ${lock}` : `electron install lock held by pid ${String(ownerPid)} (alive); waiting: ${lock}`);
958
+ return false;
959
+ }
960
+ const dead = ownerPid !== void 0 && !isProcessAlive(ownerPid);
961
+ logCarrier(`electron install lock stale (${ownerPid === void 0 ? "unreadable" : `pid ${String(ownerPid)} ${dead ? "dead" : "expired"}`}); taking over: ${lock}`);
962
+ try {
963
+ unlinkSync(lock);
964
+ } catch {}
965
+ try {
966
+ write();
967
+ return true;
968
+ } catch {
745
969
  return false;
746
970
  }
747
971
  };
748
972
  const releaseInstallLock = (pkgDir) => {
973
+ const lock = installLockPath(pkgDir);
974
+ try {
975
+ const owner = parseInstallLock(readFileSync(lock, "utf8"))?.pid;
976
+ if (typeof owner === "number" && owner !== process.pid) return;
977
+ } catch {}
749
978
  try {
750
- unlinkSync(installLockPath(pkgDir));
979
+ unlinkSync(lock);
751
980
  } catch {}
752
981
  };
982
+ const NPMMIRROR_ELECTRON = "https://npmmirror.com/mirrors/electron/";
983
+ const INSTALL_TIMEOUT_MS = 12e5;
984
+ let activeInstallChild;
985
+ const writeRuntimeStatus = (status) => {
986
+ writeElectronRuntimeStatus(petHome(), status);
987
+ };
753
988
  /**
754
989
  * Install the npm electron binary off the activation path. The package's
755
- * lazy installer is a ~250 MB download; running it synchronously (which
990
+ * lazy installer is a large download; running it synchronously (which
756
991
  * `require('electron')` does) freezes the host, so it runs as a child with
757
- * progress in the spawn log and a mirror hint on failure.
992
+ * progress in the spawn log. The lock is owner-liveness based: a host that
993
+ * dies mid-download never blocks the next activation.
758
994
  */
759
- const installNpmElectron = async (pkgDir) => {
995
+ const installNpmElectron = async (pkgDir, attempt) => {
760
996
  const installer = join(pkgDir, "install.js");
761
997
  if (!existsSync(installer)) {
762
998
  logCarrier(`electron binary missing and install.js not found: ${pkgDir}`);
763
- return;
999
+ writeRuntimeStatus({
1000
+ state: "unavailable",
1001
+ updatedAt: Date.now(),
1002
+ error: `install.js not found in ${pkgDir}`
1003
+ });
1004
+ return {
1005
+ kind: "unavailable",
1006
+ error: `install.js not found in ${pkgDir}`
1007
+ };
764
1008
  }
765
1009
  if (!acquireInstallLock(pkgDir)) {
766
- logCarrier(`electron install already in progress (lock present): ${pkgDir}`);
767
- return;
1010
+ writeRuntimeStatus({
1011
+ state: "busy",
1012
+ updatedAt: Date.now()
1013
+ });
1014
+ return { kind: "busy" };
768
1015
  }
769
- logCarrier(`electron binary missing; installing in background: ${pkgDir}`);
1016
+ const explicitMirror = (process.env.ELECTRON_MIRROR ?? process.env.npm_config_electron_mirror ?? "").trim();
1017
+ const mirror = explicitMirror !== "" ? explicitMirror : attempt % 2 === 0 ? NPMMIRROR_ELECTRON : void 0;
1018
+ const source = explicitMirror !== "" ? "explicit" : mirror === void 0 ? "github" : "npmmirror";
1019
+ const startedAt = Date.now();
1020
+ writeRuntimeStatus({
1021
+ state: "downloading",
1022
+ updatedAt: startedAt,
1023
+ startedAt,
1024
+ attempt: attempt + 1,
1025
+ source
1026
+ });
1027
+ logCarrier(`electron binary missing; installing in background (attempt ${String(attempt + 1)}, source=${source}): ${pkgDir}`);
770
1028
  return await new Promise((resolveInstall) => {
771
1029
  let settled = false;
772
- const finish = (value) => {
1030
+ let timeout;
1031
+ const finish = (result) => {
773
1032
  if (settled) return;
774
1033
  settled = true;
1034
+ if (timeout !== void 0) clearTimeout(timeout);
1035
+ activeInstallChild = void 0;
775
1036
  releaseInstallLock(pkgDir);
776
- resolveInstall(value);
1037
+ if (result.kind === "installed") writeRuntimeStatus({
1038
+ state: "ready",
1039
+ updatedAt: Date.now(),
1040
+ startedAt,
1041
+ finishedAt: Date.now(),
1042
+ attempt: attempt + 1,
1043
+ source
1044
+ });
1045
+ else if (result.kind === "failed") writeRuntimeStatus({
1046
+ state: "failed",
1047
+ updatedAt: Date.now(),
1048
+ startedAt,
1049
+ attempt: attempt + 1,
1050
+ source,
1051
+ error: result.error
1052
+ });
1053
+ resolveInstall(result);
777
1054
  };
778
1055
  let stderr = "";
779
1056
  let stdout = "";
780
1057
  let lastProgress = 0;
781
- const child = spawn(process.execPath, [installer], {
782
- cwd: pkgDir,
783
- env: {
784
- ...process.env,
785
- ELECTRON_RUN_AS_NODE: "1"
786
- },
787
- stdio: [
788
- "ignore",
789
- "pipe",
790
- "pipe"
791
- ],
792
- windowsHide: true
793
- });
1058
+ const env = {
1059
+ ...process.env,
1060
+ ELECTRON_RUN_AS_NODE: "1"
1061
+ };
1062
+ if (mirror === void 0) {
1063
+ delete env.ELECTRON_MIRROR;
1064
+ delete env.npm_config_electron_mirror;
1065
+ } else env.ELECTRON_MIRROR = mirror;
1066
+ let child;
1067
+ try {
1068
+ child = spawn(process.execPath, [installer], {
1069
+ cwd: pkgDir,
1070
+ env,
1071
+ stdio: [
1072
+ "ignore",
1073
+ "pipe",
1074
+ "pipe"
1075
+ ],
1076
+ windowsHide: true
1077
+ });
1078
+ } catch (error) {
1079
+ const message = error instanceof Error ? error.message : String(error);
1080
+ logCarrier(`electron install failed to start: ${message}`);
1081
+ finish({
1082
+ kind: "failed",
1083
+ error: message
1084
+ });
1085
+ return;
1086
+ }
1087
+ activeInstallChild = child;
1088
+ timeout = setTimeout(() => {
1089
+ logCarrier(`electron install timed out after ${String(INSTALL_TIMEOUT_MS / 6e4)} min (source=${source}); killing pid=${String(child.pid ?? "unknown")}`);
1090
+ child.kill();
1091
+ finish({
1092
+ kind: "failed",
1093
+ error: `timed out after ${String(INSTALL_TIMEOUT_MS / 6e4)} min`
1094
+ });
1095
+ }, INSTALL_TIMEOUT_MS);
794
1096
  child.stdout?.setEncoding("utf8");
795
1097
  child.stdout?.on("data", (chunk) => {
796
1098
  stdout = `${stdout}${chunk}`.slice(-2e3);
@@ -807,22 +1109,35 @@ function apply(ctx) {
807
1109
  });
808
1110
  child.on("error", (error) => {
809
1111
  logCarrier(`electron install failed to start: ${error.message}`);
810
- finish(void 0);
1112
+ finish({
1113
+ kind: "failed",
1114
+ error: error.message
1115
+ });
811
1116
  });
812
1117
  child.on("exit", (code) => {
813
1118
  if (code !== 0) {
814
- 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.`);
815
- finish(void 0);
1119
+ const detail = stderr.trim() || "no stderr";
1120
+ logCarrier(`electron install failed (exit ${String(code)}, source=${source}): ${detail}`);
1121
+ finish({
1122
+ kind: "failed",
1123
+ error: `exit ${String(code)}: ${detail}`
1124
+ });
816
1125
  return;
817
1126
  }
818
1127
  const binary = npmElectronBinary(pkgDir);
819
1128
  if (binary === void 0) {
820
1129
  logCarrier(`electron install exited 0 but the binary is still missing: ${pkgDir}`);
821
- finish(void 0);
1130
+ finish({
1131
+ kind: "failed",
1132
+ error: "binary still missing after a successful install"
1133
+ });
822
1134
  return;
823
1135
  }
824
1136
  logCarrier(`electron installed: ${binary}`);
825
- finish(ensureElectronUsable(binary));
1137
+ finish({
1138
+ kind: "installed",
1139
+ binary: ensureElectronUsable(binary)
1140
+ });
826
1141
  });
827
1142
  });
828
1143
  };
@@ -899,20 +1214,86 @@ function apply(ctx) {
899
1214
  child.kill();
900
1215
  }, "pet-surface: carrier shutdown");
901
1216
  };
1217
+ const RETRY_DELAYS_MS = [
1218
+ 3e4,
1219
+ 6e4,
1220
+ 12e4,
1221
+ 3e5
1222
+ ];
1223
+ let disposed = false;
1224
+ let carrierLaunched = false;
1225
+ let installInFlight = false;
1226
+ let installAttempt = 0;
1227
+ let retryTimer;
1228
+ const launchOnce = (url, carrierDir, binary) => {
1229
+ if (disposed || carrierLaunched) return;
1230
+ carrierLaunched = true;
1231
+ if (retryTimer !== void 0) {
1232
+ clearTimeout(retryTimer);
1233
+ retryTimer = void 0;
1234
+ }
1235
+ launchCarrier(url, carrierDir, binary);
1236
+ };
1237
+ const ensureCarrier = async (url, carrierDir, pkgDir) => {
1238
+ if (disposed || carrierLaunched) return;
1239
+ const resolution = resolveElectron();
1240
+ if (resolution.binary !== void 0) {
1241
+ writeRuntimeStatus({
1242
+ state: "ready",
1243
+ updatedAt: Date.now()
1244
+ });
1245
+ launchOnce(url, carrierDir, resolution.binary);
1246
+ return;
1247
+ }
1248
+ const nextPkgDir = resolution.npmPackageDir ?? pkgDir;
1249
+ if (installInFlight) return;
1250
+ installInFlight = true;
1251
+ const attempt = installAttempt;
1252
+ installAttempt += 1;
1253
+ const result = await installNpmElectron(nextPkgDir, attempt).catch((error) => ({
1254
+ kind: "failed",
1255
+ error: error instanceof Error ? error.message : String(error)
1256
+ }));
1257
+ installInFlight = false;
1258
+ if (disposed || carrierLaunched) return;
1259
+ if (result.kind === "installed") {
1260
+ launchOnce(url, carrierDir, result.binary);
1261
+ return;
1262
+ }
1263
+ if (result.kind === "unavailable") {
1264
+ logCarrier(`electron auto-install unavailable: ${result.error}`);
1265
+ return;
1266
+ }
1267
+ const reason = result.kind === "busy" ? "blocked by another activation" : `failed (${result.error})`;
1268
+ const delay = RETRY_DELAYS_MS[Math.min(installAttempt - 1, RETRY_DELAYS_MS.length - 1)];
1269
+ logCarrier(`electron install ${reason}; retrying in ${String(delay / 1e3)}s`);
1270
+ if (retryTimer !== void 0) clearTimeout(retryTimer);
1271
+ retryTimer = setTimeout(() => {
1272
+ ensureCarrier(url, carrierDir, nextPkgDir);
1273
+ }, delay);
1274
+ };
902
1275
  const spawnPetCarrier = (url) => {
903
1276
  const carrierDir = resolve(join(dirname(fileURLToPath(import.meta.url)), "..", "carrier"));
904
1277
  if (process.env.DSH_PET_NO_SPAWN === "1" || !existsSync(join(carrierDir, "package.json"))) return;
905
1278
  const resolution = resolveElectron();
906
1279
  if (resolution.binary !== void 0) {
907
- launchCarrier(url, carrierDir, resolution.binary);
1280
+ writeRuntimeStatus({
1281
+ state: "ready",
1282
+ updatedAt: Date.now()
1283
+ });
1284
+ launchOnce(url, carrierDir, resolution.binary);
908
1285
  return;
909
1286
  }
910
- if (resolution.npmPackageDir === void 0) return;
911
- installNpmElectron(resolution.npmPackageDir).then((binary) => {
912
- if (binary !== void 0) launchCarrier(url, carrierDir, binary);
913
- }).catch((error) => {
914
- logCarrier(`electron install failed: ${error instanceof Error ? error.message : String(error)}`);
915
- });
1287
+ if (resolution.npmPackageDir === void 0) {
1288
+ logCarrier("no electron binary and no npm electron package; cannot auto-install");
1289
+ writeRuntimeStatus({
1290
+ state: "unavailable",
1291
+ updatedAt: Date.now(),
1292
+ error: "electron package not installed"
1293
+ });
1294
+ return;
1295
+ }
1296
+ ensureCarrier(url, carrierDir, resolution.npmPackageDir);
916
1297
  };
917
1298
  const settledReady = () => {
918
1299
  const url = authenticatedRoot();
@@ -925,8 +1306,12 @@ function apply(ctx) {
925
1306
  else settled.then(settledReady).catch(() => {});
926
1307
  const home = petHome();
927
1308
  const pet = installPetService(ctx, home);
928
- installPetDriver(ctx, home, pet);
929
- installSettingsPage(ctx, home, pet);
1309
+ installSettingsPage(ctx, home, pet, installPetDriver(ctx, home, pet));
1310
+ ctx.effect(() => () => {
1311
+ disposed = true;
1312
+ if (retryTimer !== void 0) clearTimeout(retryTimer);
1313
+ activeInstallChild?.kill();
1314
+ }, "pet-surface: electron install cleanup");
930
1315
  ctx.effect(() => () => {
931
1316
  readFile(petLocatorPath(), "utf8").then((raw) => {
932
1317
  if (JSON.parse(raw).pid === process.pid) return unlink(petLocatorPath());
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-BZbhT92u.js";
1
+ import { a as createUnregisteredPetHttp, n as logStamp, r as registerPetTools, s as petHome } from "./driver-BiSFFYXw.js";
2
2
  import { appendFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  // lib/types/tools.js
@@ -6,6 +6,17 @@
6
6
  * catalog drift between pet implementations must die.
7
7
  * @module dsh-agentic-pet/actions
8
8
  */
9
+ /** One body-owned catalog entry. `tags` are THIS body's vocabulary — the
10
+ * driver never defines a cross-body enum. */
11
+ export interface ActionCatalogEntry {
12
+ readonly id: string;
13
+ readonly tags: readonly string[];
14
+ readonly energy?: 'low' | 'mid' | 'high';
15
+ readonly durationHintMs?: number;
16
+ readonly textInFrame?: boolean;
17
+ /** State animation that loops; wait cannot observe a meaningful end. */
18
+ readonly loop?: boolean;
19
+ }
9
20
  /** A pet's advertised capabilities. `contractVersion: null` = a legacy
10
21
  * implementation that predates the version field (v1 semantics: seven
11
22
  * endpoints, no /world). `actions: null` = the pet publishes no catalog (a
@@ -14,6 +25,10 @@
14
25
  export interface PetCapabilities {
15
26
  readonly contractVersion: number | null;
16
27
  readonly actions: readonly string[] | null;
28
+ readonly actionCatalog: readonly ActionCatalogEntry[] | null;
29
+ /** The body's own tag vocabulary, derived from its catalog when omitted. */
30
+ readonly actionTagVocabulary: readonly string[];
31
+ readonly tagged: boolean;
17
32
  }
18
33
  /** Defensive extraction from a raw /capabilities payload: implementations
19
34
  * may omit or mistype anything. */
@@ -39,3 +54,18 @@ export declare function resolveActionRequest(requested: string, catalog: readonl
39
54
  /** The error-message menu: long catalogs are previewed, with a pointer to
40
55
  * pet_state {actions:true} for the full list. */
41
56
  export declare function formatAvailableActions(available: readonly string[], cap?: number): string;
57
+ export type TagResolution = {
58
+ kind: 'ok';
59
+ action: string;
60
+ candidates: readonly string[];
61
+ } | {
62
+ kind: 'unknown';
63
+ availableTags: readonly string[];
64
+ availableActions: readonly string[];
65
+ };
66
+ /** Resolve a tag against the CURRENT body's own catalog. Multiple matches are
67
+ * a random draw (celebrate is never a tag candidate). No cross-body tag
68
+ * vocabulary is consulted. */
69
+ export declare function resolveTagRequest(tag: string, capabilities: PetCapabilities | null): TagResolution;
70
+ /** Tag menu for errors: an empty vocabulary names the fallback explicitly. */
71
+ export declare function formatAvailableTags(tags: readonly string[], cap?: number): string;