dsh-agentic-pet 0.4.8 → 0.4.10

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-Df8olC1E.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-CpRgkle-.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
@@ -37,8 +100,24 @@ const DEFAULT_SETTINGS = {
37
100
  touchReplyProbability: .3,
38
101
  touchReplyCooldownSeconds: 30,
39
102
  selfWakeBusyPolicy: "fire",
103
+ petToPetDelivery: "queue",
104
+ petMessageLimits: {
105
+ minutes5: 5,
106
+ minutes10: 8,
107
+ minutes30: 10,
108
+ minutes60: 15
109
+ },
40
110
  boundSessions: {}
41
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 },
42
121
  tts: { enabled: false },
43
122
  debug: { cdp: false }
44
123
  };
@@ -104,6 +183,18 @@ function mergeSettings(current, patch) {
104
183
  ...DEFAULT_SETTINGS.innerVoice,
105
184
  ...current.innerVoice
106
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
+ };
107
198
  const debug = {
108
199
  ...DEFAULT_SETTINGS.debug,
109
200
  ...current.debug
@@ -132,6 +223,33 @@ function mergeSettings(current, patch) {
132
223
  const replyCooldown = clampNumber(v.touchReplyCooldownSeconds, 0, 600);
133
224
  if (replyCooldown !== void 0) innerVoice.touchReplyCooldownSeconds = replyCooldown;
134
225
  if (v.selfWakeBusyPolicy === "defer" || v.selfWakeBusyPolicy === "fire") innerVoice.selfWakeBusyPolicy = v.selfWakeBusyPolicy;
226
+ if (v.petToPetDelivery === "immediate" || v.petToPetDelivery === "queue") innerVoice.petToPetDelivery = v.petToPetDelivery;
227
+ const patchLimits = v.petMessageLimits;
228
+ if (typeof patchLimits === "object" && patchLimits !== null) {
229
+ const merged = {
230
+ minutes5: 5,
231
+ minutes10: 8,
232
+ minutes30: 10,
233
+ minutes60: 15,
234
+ ...innerVoice.petMessageLimits
235
+ };
236
+ const base = {
237
+ minutes5: merged.minutes5 ?? 5,
238
+ minutes10: merged.minutes10 ?? 8,
239
+ minutes30: merged.minutes30 ?? 10,
240
+ minutes60: merged.minutes60 ?? 15
241
+ };
242
+ const clampLimit = (value, fallback) => {
243
+ const parsed = clampNumber(value, 0, 999);
244
+ return parsed === void 0 ? fallback : parsed;
245
+ };
246
+ innerVoice.petMessageLimits = {
247
+ minutes5: clampLimit(patchLimits.minutes5, base.minutes5),
248
+ minutes10: clampLimit(patchLimits.minutes10, base.minutes10),
249
+ minutes30: clampLimit(patchLimits.minutes30, base.minutes30),
250
+ minutes60: clampLimit(patchLimits.minutes60, base.minutes60)
251
+ };
252
+ }
135
253
  const bound = v.boundSessions;
136
254
  if (typeof bound === "object" && bound !== null) {
137
255
  const record = {};
@@ -139,6 +257,37 @@ function mergeSettings(current, patch) {
139
257
  innerVoice.boundSessions = record;
140
258
  }
141
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
+ }
142
291
  const patchDebug = patch.debug;
143
292
  if (typeof patchDebug === "object" && patchDebug !== null) {
144
293
  const d = patchDebug;
@@ -167,6 +316,9 @@ function mergeSettings(current, patch) {
167
316
  ...current,
168
317
  proactivity,
169
318
  innerVoice,
319
+ rhythm,
320
+ journal,
321
+ presenceSummary,
170
322
  tts,
171
323
  debug
172
324
  };
@@ -191,7 +343,7 @@ function openPathInFileManager(path) {
191
343
  * @param ctx - plugin context inside the runtime.
192
344
  * @param home - the dsh home holding the settings/ledger/manifest files.
193
345
  */
194
- function installSettingsPage(ctx, home, pet) {
346
+ function installSettingsPage(ctx, home, pet, control) {
195
347
  const windowPath = globalFilePath(home, "pet-window.json");
196
348
  /** Summarize EVERY live pet (id, online flag, normalized /status). */
197
349
  const petStatus = async () => {
@@ -204,6 +356,56 @@ function installSettingsPage(ctx, home, pet) {
204
356
  const readSettings = async () => {
205
357
  return mergeSettings(await readGlobalJson(home, "pet-settings.json"), {});
206
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
+ };
207
409
  const routeByPath = /* @__PURE__ */ new Map();
208
410
  const route = (method, path, handler) => {
209
411
  let handlers = routeByPath.get(path);
@@ -235,6 +437,18 @@ function installSettingsPage(ctx, home, pet) {
235
437
  }
236
438
  handlers.set(method, handler);
237
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
+ });
238
452
  route("GET", "/api/pet-settings/read", async (_req, res) => {
239
453
  const [settings, windowPosition, status] = await Promise.all([
240
454
  readSettings(),
@@ -280,6 +494,12 @@ function installSettingsPage(ctx, home, pet) {
280
494
  touchTotals
281
495
  });
282
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
+ });
283
503
  route("POST", "/api/pet-settings/update", async (req, res) => {
284
504
  const body = await readJsonBody(req);
285
505
  const patch = body === void 0 ? void 0 : body.patch;
@@ -293,11 +513,29 @@ function installSettingsPage(ctx, home, pet) {
293
513
  delete patch.names;
294
514
  }
295
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
+ }
296
533
  await writeGlobalJson(home, "pet-settings.json", next);
297
534
  ctx.emit("pet-settings/changed");
298
535
  writeJson(res, 200, {
299
536
  ok: true,
300
- settings: next
537
+ settings: next,
538
+ warnings
301
539
  });
302
540
  });
303
541
  route("POST", "/api/pet-settings/reset-affection", async (_req, res) => {
@@ -687,7 +925,6 @@ function apply(ctx) {
687
925
  return;
688
926
  }
689
927
  };
690
- const INSTALL_LOCK_STALE_MS = 18e5;
691
928
  const installLockPath = (pkgDir) => join(pkgDir, ".dsh-pet-install.lock");
692
929
  const acquireInstallLock = (pkgDir) => {
693
930
  const lock = installLockPath(pkgDir);
@@ -700,63 +937,162 @@ function apply(ctx) {
700
937
  try {
701
938
  write();
702
939
  return true;
940
+ } catch {}
941
+ let raw;
942
+ let mtimeMs = 0;
943
+ try {
944
+ raw = readFileSync(lock, "utf8");
945
+ mtimeMs = statSync(lock).mtimeMs;
703
946
  } catch {
704
947
  try {
705
- if (Date.now() - statSync(lock).mtimeMs > INSTALL_LOCK_STALE_MS) {
706
- unlinkSync(lock);
707
- write();
708
- return true;
709
- }
710
- } 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 {
711
969
  return false;
712
970
  }
713
971
  };
714
972
  const releaseInstallLock = (pkgDir) => {
973
+ const lock = installLockPath(pkgDir);
715
974
  try {
716
- unlinkSync(installLockPath(pkgDir));
975
+ const owner = parseInstallLock(readFileSync(lock, "utf8"))?.pid;
976
+ if (typeof owner === "number" && owner !== process.pid) return;
977
+ } catch {}
978
+ try {
979
+ unlinkSync(lock);
717
980
  } catch {}
718
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
+ };
719
988
  /**
720
989
  * Install the npm electron binary off the activation path. The package's
721
- * lazy installer is a ~250 MB download; running it synchronously (which
990
+ * lazy installer is a large download; running it synchronously (which
722
991
  * `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.
992
+ * progress in the spawn log. The lock is owner-liveness based: a host that
993
+ * dies mid-download never blocks the next activation.
724
994
  */
725
- const installNpmElectron = async (pkgDir) => {
995
+ const installNpmElectron = async (pkgDir, attempt) => {
726
996
  const installer = join(pkgDir, "install.js");
727
997
  if (!existsSync(installer)) {
728
998
  logCarrier(`electron binary missing and install.js not found: ${pkgDir}`);
729
- 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
+ };
730
1008
  }
731
1009
  if (!acquireInstallLock(pkgDir)) {
732
- logCarrier(`electron install already in progress (lock present): ${pkgDir}`);
733
- return;
1010
+ writeRuntimeStatus({
1011
+ state: "busy",
1012
+ updatedAt: Date.now()
1013
+ });
1014
+ return { kind: "busy" };
734
1015
  }
735
- 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}`);
736
1028
  return await new Promise((resolveInstall) => {
737
1029
  let settled = false;
738
- const finish = (value) => {
1030
+ let timeout;
1031
+ const finish = (result) => {
739
1032
  if (settled) return;
740
1033
  settled = true;
1034
+ if (timeout !== void 0) clearTimeout(timeout);
1035
+ activeInstallChild = void 0;
741
1036
  releaseInstallLock(pkgDir);
742
- 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);
743
1054
  };
744
1055
  let stderr = "";
745
1056
  let stdout = "";
746
1057
  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
- });
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);
760
1096
  child.stdout?.setEncoding("utf8");
761
1097
  child.stdout?.on("data", (chunk) => {
762
1098
  stdout = `${stdout}${chunk}`.slice(-2e3);
@@ -773,22 +1109,35 @@ function apply(ctx) {
773
1109
  });
774
1110
  child.on("error", (error) => {
775
1111
  logCarrier(`electron install failed to start: ${error.message}`);
776
- finish(void 0);
1112
+ finish({
1113
+ kind: "failed",
1114
+ error: error.message
1115
+ });
777
1116
  });
778
1117
  child.on("exit", (code) => {
779
1118
  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);
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
+ });
782
1125
  return;
783
1126
  }
784
1127
  const binary = npmElectronBinary(pkgDir);
785
1128
  if (binary === void 0) {
786
1129
  logCarrier(`electron install exited 0 but the binary is still missing: ${pkgDir}`);
787
- finish(void 0);
1130
+ finish({
1131
+ kind: "failed",
1132
+ error: "binary still missing after a successful install"
1133
+ });
788
1134
  return;
789
1135
  }
790
1136
  logCarrier(`electron installed: ${binary}`);
791
- finish(ensureElectronUsable(binary));
1137
+ finish({
1138
+ kind: "installed",
1139
+ binary: ensureElectronUsable(binary)
1140
+ });
792
1141
  });
793
1142
  });
794
1143
  };
@@ -865,20 +1214,86 @@ function apply(ctx) {
865
1214
  child.kill();
866
1215
  }, "pet-surface: carrier shutdown");
867
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
+ };
868
1275
  const spawnPetCarrier = (url) => {
869
1276
  const carrierDir = resolve(join(dirname(fileURLToPath(import.meta.url)), "..", "carrier"));
870
1277
  if (process.env.DSH_PET_NO_SPAWN === "1" || !existsSync(join(carrierDir, "package.json"))) return;
871
1278
  const resolution = resolveElectron();
872
1279
  if (resolution.binary !== void 0) {
873
- launchCarrier(url, carrierDir, resolution.binary);
1280
+ writeRuntimeStatus({
1281
+ state: "ready",
1282
+ updatedAt: Date.now()
1283
+ });
1284
+ launchOnce(url, carrierDir, resolution.binary);
874
1285
  return;
875
1286
  }
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
- });
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);
882
1297
  };
883
1298
  const settledReady = () => {
884
1299
  const url = authenticatedRoot();
@@ -891,8 +1306,12 @@ function apply(ctx) {
891
1306
  else settled.then(settledReady).catch(() => {});
892
1307
  const home = petHome();
893
1308
  const pet = installPetService(ctx, home);
894
- installPetDriver(ctx, home, pet);
895
- 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");
896
1315
  ctx.effect(() => () => {
897
1316
  readFile(petLocatorPath(), "utf8").then((raw) => {
898
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-Df8olC1E.js";
1
+ import { a as createUnregisteredPetHttp, n as logStamp, r as registerPetTools, s as petHome } from "./driver-CpRgkle-.js";
2
2
  import { appendFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  // lib/types/tools.js
@@ -21,8 +21,8 @@ const trace = (line) => {
21
21
  appendFile(join(petHome(), "whale598", "pet-driver.log"), `${logStamp()} pet-tools ${line}\n`, "utf8").catch(() => {});
22
22
  };
23
23
  /**
24
- * Register the pet tools (say/do/digest/interactions/affection/state/schedule_wake/
25
- * cancel_wake) for sessions mounted with the pet preset.
24
+ * Register the pet tools (say/inbox/do/digest/interactions/affection/state/
25
+ * schedule_wake/cancel_wake) for sessions mounted with the pet preset.
26
26
  *
27
27
  * The service instance is assembled HERE, not read from the main entry (a
28
28
  * Cordis service registered on the main row's fiber context is invisible to