dsh-waifu-agents 0.2.1 → 0.2.3

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.
Files changed (3) hide show
  1. package/client.js +82 -8
  2. package/index.js +347 -37
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -149,6 +149,9 @@ window.__ModuleLoader__.load({
149
149
  updateInstallingFrom: '正在从 {registry} 安装(第 {index}/{total} 个源)…',
150
150
  updateApplying: '正在应用配置,请稍候…',
151
151
  updateCancelling: '正在停止安装…',
152
+ updateRemoving: '正在卸载旧版本…',
153
+ updateReinstalling: '正在安装新版本…',
154
+ updateHandedOff: '插件已随更新重启;到「插件」页确认版本即可。',
152
155
  updateWaiting: '安装进行中,别关掉这个窗口',
153
156
  updateElapsed: '已用 {s} 秒',
154
157
  updateQuiet: '已经 {s} 秒没等到平台的进度回报,但 pnpm 还在跑',
@@ -256,12 +259,14 @@ window.__ModuleLoader__.load({
256
259
  purgeHint: '把本插件留在文件系统里的东西一次清掉:会话覆盖记录、全局默认人设指向。打开右边开关会连人设目录一起删。',
257
260
  purgeWithDir: '连人设目录一起删',
258
261
  purgeAskTitle: '彻底清理本插件的数据',
259
- purgeAskBody: '会删除:会话覆盖记录、全局默认人设指向。安装日志、pnpm 索引、pnpm-workspace.yaml 里的放行记录属于平台,这里不动它们,清理完会列给你。',
262
+ purgeAskBody: '会删除:会话覆盖记录、全局默认人设指向,以及 pnpm-workspace.yaml 里属于本包的放行记录。安装日志、pnpm 索引属于平台,这里不动它们,清理完会列给你。',
260
263
  purgeAskAck: '我知道这会清掉本插件的数据',
261
264
  updateBanner: '有新版本 {latest}(当前 {installed})',
262
265
  updateGo: '查看',
263
266
  checkUpdate: '检查更新',
264
267
  recheck: '重新检查更新',
268
+ updateUpToDate: '已是最新版本 {installed}',
269
+ updateCheckFailed: '检查更新失败',
265
270
  upToDate: '已是最新版本',
266
271
  canUpdateTo: '可更新到',
267
272
  nowVersion: '当前',
@@ -395,6 +400,9 @@ window.__ModuleLoader__.load({
395
400
  updateInstallingFrom: 'Installing from {registry} (source {index} of {total})…',
396
401
  updateApplying: 'Applying the configuration, please wait…',
397
402
  updateCancelling: 'Stopping the installation…',
403
+ updateRemoving: 'Removing the old version…',
404
+ updateReinstalling: 'Installing the new version…',
405
+ updateHandedOff: 'The plugin restarted with the update; confirm the version on the Plugins page.',
398
406
  updateWaiting: 'Installing — leave this window open',
399
407
  updateElapsed: '{s}s elapsed',
400
408
  updateQuiet: 'no progress report from the platform for {s}s, but pnpm is still running',
@@ -499,12 +507,14 @@ window.__ModuleLoader__.load({
499
507
  purgeHint: 'Remove everything this plugin left on disk in one go: the session override store and the global-default pointer. The switch on the right also deletes the persona directory.',
500
508
  purgeWithDir: 'Also delete the persona directory',
501
509
  purgeAskTitle: 'Purge this plugin\'s data',
502
- purgeAskBody: 'Deletes the session override store and the global-default pointer. The install logs, the pnpm store index and the release-age allow-list entry belong to the platform and are listed afterwards instead of touched.',
510
+ purgeAskBody: 'Deletes the session override store, the global-default pointer, and this package\'s own entries in pnpm-workspace.yaml\'s release-age allow-list. The install logs and the pnpm store index belong to the platform and are listed afterwards instead of touched.',
503
511
  purgeAskAck: 'I know this removes the plugin\'s data',
504
512
  updateBanner: 'Version {latest} is out (running {installed})',
505
513
  updateGo: 'Details',
506
514
  checkUpdate: 'Check for updates',
507
515
  recheck: 'Check again',
516
+ updateUpToDate: 'Already on the newest version {installed}',
517
+ updateCheckFailed: 'The update check failed',
508
518
  upToDate: 'Up to date',
509
519
  canUpdateTo: 'Update to',
510
520
  nowVersion: 'running',
@@ -1401,7 +1411,16 @@ window.__ModuleLoader__.load({
1401
1411
  const phase = String(job.phase ?? 'starting');
1402
1412
  let floor = 3;
1403
1413
  let ceiling = 10;
1404
- if (phase === 'installing') {
1414
+ if (phase === 'removing') {
1415
+ // The self-removal before the reinstall: brief, but not instant.
1416
+ floor = 6;
1417
+ ceiling = 16;
1418
+ } else if (phase === 'reinstalling') {
1419
+ // The fresh install after the self-removal, before the platform's own
1420
+ // per-registry phases take over and redraw the band.
1421
+ floor = 14;
1422
+ ceiling = 24;
1423
+ } else if (phase === 'installing') {
1405
1424
  // One band per registry tried, so a fallback to a second source is
1406
1425
  // visible as progress instead of as a bar going backwards.
1407
1426
  const index = Math.max(1, Number(job.attempt?.index ?? 1) || 1);
@@ -1431,10 +1450,13 @@ window.__ModuleLoader__.load({
1431
1450
  */
1432
1451
  function progressHeadline(job, t) {
1433
1452
  if (job === null || job === undefined) return '';
1453
+ if (job.handedOff === true) return t('updateHandedOff');
1434
1454
  if (job.done === true) {
1435
1455
  return typeof job.error === 'string' && job.error !== '' ? t('updateFailedTitle') : t('updateDoneTitle');
1436
1456
  }
1437
1457
  const phase = String(job.phase ?? 'starting');
1458
+ if (phase === 'removing') return t('updateRemoving');
1459
+ if (phase === 'reinstalling') return t('updateReinstalling');
1438
1460
  if (phase === 'installing') {
1439
1461
  const attempt = job.attempt;
1440
1462
  const total = Number(attempt?.total ?? 1) || 1;
@@ -1495,6 +1517,7 @@ window.__ModuleLoader__.load({
1495
1517
  .replace('{s}', String(seconds));
1496
1518
  }
1497
1519
  if (typeof job?.error === 'string' && job.error !== '') return t('updateStripFailed');
1520
+ if (job?.handedOff === true) return t('updateHandedOff');
1498
1521
  return t('updateStripDone').replace('{v}', String(job?.result?.version ?? job?.version ?? ''));
1499
1522
  }
1500
1523
 
@@ -1547,7 +1570,17 @@ window.__ModuleLoader__.load({
1547
1570
  { style: { ...L.dialog, maxWidth: '480px', width: '100%' } },
1548
1571
  h('span', { style: L.rowTitle }, t('updateProgressTitle')),
1549
1572
  h('div', { style: L.progressTrack }, h('div', { style: fill })),
1550
- h('span', { style: failed ? L.error : L.name }, failed ? String(job.error) : done ? progressOutcome(job, t) : progressHeadline(job, t)),
1573
+ h(
1574
+ 'span',
1575
+ { style: failed ? L.error : L.name },
1576
+ failed
1577
+ ? String(job.error)
1578
+ : job.handedOff === true
1579
+ ? t('updateHandedOff')
1580
+ : done
1581
+ ? progressOutcome(job, t)
1582
+ : progressHeadline(job, t),
1583
+ ),
1551
1584
  note === '' ? null : h('span', { style: L.meta }, note),
1552
1585
  exit === '' ? null : h('span', { style: L.meta }, exit),
1553
1586
  log === ''
@@ -2362,8 +2395,10 @@ window.__ModuleLoader__.load({
2362
2395
  ),
2363
2396
  // No preset, no switch: a default has to name something the
2364
2397
  // registry holds, and this team is not registered yet (the sync
2365
- // is in flight, or it failed). Saying which is `teamNoPreset`.
2366
- noPreset ? h('span', { style: L.hint }, t('teamNoPreset')) : null,
2398
+ // is in flight, or it failed). The disabled switch says it on
2399
+ // its own — an inline hint used to sit here and squeezed the
2400
+ // row out of shape on long copy, so it is gone; the refusal
2401
+ // stays said, in the editor, if someone forces the write.
2367
2402
  h('span', { style: L.push }),
2368
2403
  known
2369
2404
  ? h(SwitchBox, {
@@ -3356,7 +3391,23 @@ window.__ModuleLoader__.load({
3356
3391
  const poll = () => {
3357
3392
  post({ op: 'updateProgress', requestId })
3358
3393
  .then((payload) => {
3359
- if (!alive || payload.idle === true) return;
3394
+ if (!alive) return;
3395
+ if (payload.idle === true) {
3396
+ /*
3397
+ * The host no longer knows this job. That is the two-step
3398
+ * update succeeding the loud way: the removal plus reinstall
3399
+ * ended in an HMR reload, the module that owned the job is
3400
+ * gone, and the fresh module answering polls has never heard
3401
+ * of this requestId. Report it as a hand-off to the restart
3402
+ * instead of letting the bar sit on its last phase forever.
3403
+ */
3404
+ setJob((prev) =>
3405
+ prev !== null && prev !== undefined && prev.done !== true
3406
+ ? { ...prev, done: true, handedOff: true, error: null }
3407
+ : prev,
3408
+ );
3409
+ return;
3410
+ }
3360
3411
  setJob((prev) => (prev === null || prev === undefined ? prev : { ...prev, ...payload }));
3361
3412
  })
3362
3413
  .catch(() => {
@@ -4114,9 +4165,32 @@ window.__ModuleLoader__.load({
4114
4165
  async function checkUpdate() {
4115
4166
  setBusy(true);
4116
4167
  try {
4117
- setUpdate(await post({ op: 'updateCheck' }));
4168
+ const payload = await post({ op: 'updateCheck' });
4169
+ setUpdate(payload);
4170
+ /*
4171
+ * The banner re-renders from `update`, but a silent re-render is not
4172
+ * an answer: the person pressed a button and gets told, out loud,
4173
+ * which of the three outcomes happened — newer version, already
4174
+ * current, or the registry could not be reached.
4175
+ */
4176
+ if (payload?.updateAvailable === true) {
4177
+ setNote({
4178
+ kind: 'ok',
4179
+ text: t('updateBanner')
4180
+ .replace('{latest}', payload.latest ?? '')
4181
+ .replace('{installed}', payload.installed ?? payload.running ?? ''),
4182
+ });
4183
+ } else if (typeof payload?.error === 'string' && payload.error !== '') {
4184
+ setNote({ kind: 'error', text: `${t('updateCheckFailed')}:${payload.error}` });
4185
+ } else {
4186
+ setNote({
4187
+ kind: 'ok',
4188
+ text: t('updateUpToDate').replace('{installed}', payload?.installed ?? payload?.running ?? ''),
4189
+ });
4190
+ }
4118
4191
  } catch (failure) {
4119
4192
  setUpdate({ error: String(failure?.message ?? failure) });
4193
+ setNote({ kind: 'error', text: `${t('updateCheckFailed')}:${failure?.message ?? failure}` });
4120
4194
  } finally {
4121
4195
  setBusy(false);
4122
4196
  }
package/index.js CHANGED
@@ -40,12 +40,31 @@
40
40
  *
41
41
  * `updateApply` is what makes a published fix reachable without a shell: the
42
42
  * panel posts it, `latestVersion` asks the registry, and the install goes
43
- * through the very service the Plugins page uses — `pnpm add <name>@<version>`,
44
- * which also REWRITES the profile's version range. That range, not the plugin,
45
- * is what keeps an old copy installed: `^0.0.1` can never resolve `0.1.0`, so
46
- * every release used to need a hand-edited spec. An already-installed package
47
- * is never hot-reloaded — the manager answers `restart-required`, and so does
48
- * this op, which is why the panel says so out loud.
43
+ * through the very service the Plugins page uses. Three layers, tried in
44
+ * order:
45
+ *
46
+ * 1. TARBALL (updates of an installed package): the new version's tarball URL
47
+ * is the one spec shape the manager's HMR precheck cannot match by name —
48
+ * its package name is only known after pnpm runs — so the install reaches
49
+ * pnpm over the installed copy, and the HMR re-composition replaces the
50
+ * running plugin in place. No removal, no restart in the success path.
51
+ * This also rewrites the profile's saved dependency from a version range
52
+ * to the exact URL; the next update's URL differs again, so nothing is
53
+ * ever refused as already-installed on this road twice.
54
+ * 2. NAME (fresh installs, and the two-step's second half): the plain
55
+ * `name@version` spec keeps a fresh install's saved dependency a clean
56
+ * range.
57
+ * 3. TWO-STEP (fallback if the tarball road is somehow refused with
58
+ * `already-installed`): remove the running bundle, then install the new
59
+ * version from the same fiber — a resolved promise never interrupts the
60
+ * fiber that awaited it. Whether the fiber survives its own removal is
61
+ * what real-machine use finds out; every failure is caught and reported
62
+ * with the manual remove-then-install on the Plugins page as the last
63
+ * resort.
64
+ *
65
+ * The personas directory and `teams.json` live outside the package, so no
66
+ * path through any layer — success, half-done, or failure — touches user
67
+ * data.
49
68
  *
50
69
  * No configuration is required: `resolveDir` picks the directory from the row
51
70
  * config, then from `<home>/personas`, then — on a machine that does not have it
@@ -228,6 +247,50 @@ async function latestVersion(registry) {
228
247
  return { version: latest, registry: base };
229
248
  }
230
249
 
250
+ /**
251
+ * The tarball URL of a published version.
252
+ *
253
+ * This is the one spec shape the manager's HMR precheck CANNOT refuse with
254
+ * `already-installed`: the precheck matches specs it can name before pnpm
255
+ * runs — a registry name, a local directory, or the exact git/tarball spec the
256
+ * dependency was saved with — and a fresh tarball URL names a package only
257
+ * after pnpm has fetched it. Installing version N+1 from its own tarball
258
+ * therefore reaches pnpm over an installed version N, and the HMR
259
+ * re-composition replaces the running plugin in place (the documented
260
+ * behavior). The abbreviated packument names the URL authoritatively
261
+ * (`dist.tarball`), so one small GET asks the registry; a registry that will
262
+ * not answer falls back to the npm tarball path convention, which every
263
+ * registry this plugin can be pointed at follows
264
+ * (`<base>/<name>/-/<name>-<version>.tgz`). Constructing never throws, so this
265
+ * helper always returns a usable URL.
266
+ * @param {string} [registry] registry base URL; the row config may override it
267
+ * @param {string} version exact published version
268
+ * @returns {Promise<string>}
269
+ */
270
+ async function tarballUrlOf(registry, version) {
271
+ const base = String(registry ?? "").trim() || DEFAULT_REGISTRY;
272
+ const construct = () =>
273
+ new URL(
274
+ `${PACKAGE_NAME}/-/${PACKAGE_NAME}-${version}.tgz`,
275
+ base.endsWith("/") ? base : `${base}/`,
276
+ ).href;
277
+ if (typeof fetch !== "function") return construct();
278
+ try {
279
+ const url = new URL(PACKAGE_NAME, base.endsWith("/") ? base : `${base}/`)
280
+ .href;
281
+ const response = await fetch(url, {
282
+ headers: { accept: "application/vnd.npm.install-v1+json" },
283
+ signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS),
284
+ });
285
+ if (!response.ok) return construct();
286
+ const meta = await response.json();
287
+ const tarball = meta?.versions?.[version]?.dist?.tarball;
288
+ return typeof tarball === "string" && tarball !== "" ? tarball : construct();
289
+ } catch {
290
+ return construct();
291
+ }
292
+ }
293
+
231
294
  /**
232
295
  * The shared skeleton: a reserved name in the persona directory that is never a
233
296
  * wife. It is the frame a `template:`-using persona is poured into, with
@@ -2459,6 +2522,32 @@ export function apply(ctx, config = {}) {
2459
2522
  owned.set(id, { dispose, rev: registrationRev(skeleton, body) });
2460
2523
  }
2461
2524
 
2525
+ /**
2526
+ * Does this roster row carry OUR fingerprint?
2527
+ *
2528
+ * Both persona presets and team presets are registered through
2529
+ * `buildDefinition`, whose `plugins` rows include `@deepseek-ai/dsh-persona`
2530
+ * rewritten with `PERSONA_PLACEHOLDER` as the prefix — a combination nothing
2531
+ * else registers. A roster row matching it is this plugin's own previous
2532
+ * instance, not a foreign owner.
2533
+ *
2534
+ * The shape of `agentPresets.list()` rows is NOT promised to include the
2535
+ * registered `plugins` array; if it does not, this answers `false` and the
2536
+ * scan reports the conflict exactly as it did before — the fingerprint is
2537
+ * only ever evidence FOR a reclaim, never against a row.
2538
+ * @param {unknown} row - one entry of `ctx.agentPresets.list()`
2539
+ * @returns {boolean}
2540
+ */
2541
+ function isOwnPresetShape(row) {
2542
+ const plugins = row?.plugins;
2543
+ if (!Array.isArray(plugins)) return false;
2544
+ return plugins.some(
2545
+ (plugin) =>
2546
+ plugin?.name === "@deepseek-ai/dsh-persona" &&
2547
+ plugin?.config?.prefix === PERSONA_PLACEHOLDER,
2548
+ );
2549
+ }
2550
+
2462
2551
  /**
2463
2552
  * Register every file that is not already taken by this or another owner, and
2464
2553
  * RE-register the ones whose text changed since they were registered.
@@ -2476,9 +2565,10 @@ export function apply(ctx, config = {}) {
2476
2565
  * entry would be a lie that survives a fix.
2477
2566
  */
2478
2567
  foreign.clear();
2479
- const taken = new Set();
2568
+ const roster = new Map();
2480
2569
  try {
2481
- for (const preset of await ctx.agentPresets.list()) taken.add(preset.id);
2570
+ for (const preset of await ctx.agentPresets.list())
2571
+ roster.set(preset.id, preset);
2482
2572
  } catch (error) {
2483
2573
  return {
2484
2574
  added: [],
@@ -2526,14 +2616,40 @@ export function apply(ctx, config = {}) {
2526
2616
  }
2527
2617
  continue;
2528
2618
  }
2529
- if (taken.has(item.id)) {
2619
+ if (roster.has(item.id)) {
2530
2620
  /*
2531
- * `taken` counts OUR rows too, and an id that is mid-registration is
2621
+ * `roster` counts OUR rows too, and an id that is mid-registration is
2532
2622
  * still ours. Calling that a foreign conflict is how the panel ended up
2533
2623
  * accusing a wife of stealing her own id; a read that lands here simply
2534
2624
  * has nothing to do.
2535
2625
  */
2536
2626
  if (registering.has(item.id)) continue;
2627
+ /*
2628
+ * Ghost reclaim. Within one DSH process, an uninstall leaves the
2629
+ * preset table holding this plugin's old entries (observed on a
2630
+ * remove-then-reinstall with HMR: every row read 「被别的所有者占用」
2631
+ * until a restart cleared the table). Such a ghost is not another
2632
+ * owner — it is OUR previous instance, and it carries our fingerprint:
2633
+ * only this plugin registers presets whose plugins rows contain
2634
+ * `@deepseek-ai/dsh-persona` with our placeholder as the prefix.
2635
+ * Re-registering over it is therefore reclaiming, not stealing — and
2636
+ * if the registry refuses the duplicate, the catch falls through to
2637
+ * the same foreign report as before, so the attempt cannot make
2638
+ * anything worse.
2639
+ */
2640
+ if (isOwnPresetShape(roster.get(item.id))) {
2641
+ holdRegistration(item.id);
2642
+ try {
2643
+ await registerOne(item.id, parsed.meta, parsed.body);
2644
+ added.push(item.id);
2645
+ continue;
2646
+ } catch {
2647
+ // The registry refused the overwrite: report the conflict below,
2648
+ // exactly as if no reclaim had been attempted.
2649
+ } finally {
2650
+ releaseRegistration(item.id);
2651
+ }
2652
+ }
2537
2653
  foreign.add(item.id);
2538
2654
  skipped.push({ id: item.id, reason: "已经有人注册了这个 id" });
2539
2655
  continue;
@@ -4295,20 +4411,20 @@ export function apply(ctx, config = {}) {
4295
4411
  * Install the newest published version, through the same service the Plugins
4296
4412
  * page uses.
4297
4413
  *
4298
- * The exact version is passed, never a range. That matters twice over:
4299
- * `pnpm add <name>@<version>` is what REWRITES the profile's declared range to
4300
- * `^<version>` (a stale `^0.0.1` can never resolve `0.1.0`, which is the whole
4301
- * reason a fresh release used to install nothing), and a pinned version keeps
4302
- * the resolver from having to pick one out of a moving range.
4303
- *
4304
- * Pinning buys nothing from the 24h `minimumReleaseAge` policy: the install
4305
- * has to record the release in the workspace's `minimumReleaseAgeExclude`
4306
- * first (this profile has carried both a `dsh-waifu-agents@0.2.0` and a
4307
- * `dsh-waifu-agents@0.2.1` entry ever since those installs), and with no
4308
- * entry covering it the lockfile check refuses the install outright with
4309
- * `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`. So a fresh release can still be
4310
- * held back here for up to 24h; surface that failure rather than promising a
4311
- * bypass.
4414
+ * The exact version is passed, never a range. The spec it installs FROM
4415
+ * depends on what is already on disk (see the comment at the choice below):
4416
+ * an update goes through the new version's tarball URL — the spec shape the
4417
+ * HMR precheck cannot refuse — and a fresh install keeps the plain
4418
+ * `name@version`, whose install rewrites the profile's declared range.
4419
+ *
4420
+ * `minimumReleaseAge`: a registry-name install is subject to the 24h
4421
+ * freshness policy and must be recorded in the workspace's
4422
+ * `minimumReleaseAgeExclude` first, or the lockfile check refuses outright
4423
+ * with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`. A tarball URL install
4424
+ * fetches the package file directly and plausibly never consults publish
4425
+ * times — expected but NOT verified against the manager's pnpm invocation,
4426
+ * so the failure, should it come, is surfaced verbatim rather than promised
4427
+ * away.
4312
4428
  *
4313
4429
  * Returns as soon as the install is STARTED, not when it finishes, and hands
4314
4430
  * back the `requestId` that makes the platform publish progress for it. The
@@ -4375,6 +4491,25 @@ export function apply(ctx, config = {}) {
4375
4491
  },
4376
4492
  };
4377
4493
  }
4494
+ /*
4495
+ * Which spec to install from, decided by what is already on disk.
4496
+ *
4497
+ * An UPDATE of an installed package installs from the new version's
4498
+ * tarball URL: that is the one spec shape the HMR precheck cannot match by
4499
+ * name, so it reaches pnpm over the installed copy and the re-composition
4500
+ * replaces the running plugin in place — no removal, no fiber gamble. It
4501
+ * also rewrites the profile's saved dependency from a version range to the
4502
+ * exact URL, which is deliberate: the next update's URL differs again, so
4503
+ * no future install is ever refused as already-installed.
4504
+ *
4505
+ * A FRESH install (nothing readable on disk) keeps the registry name:
4506
+ * there is nothing installed for the precheck to refuse, and the saved
4507
+ * spec stays a plain dependency. If that judgement is wrong — the copy on
4508
+ * disk was unreadable, not absent — the name spec is refused with
4509
+ * `already-installed` and the two-step below takes over.
4510
+ */
4511
+ let spec = `${PACKAGE_NAME}@${version}`;
4512
+ if (before !== null) spec = await tarballUrlOf(config.registry, version);
4378
4513
  /*
4379
4514
  * The id is minted here, or accepted from the panel when it sent one. It
4380
4515
  * exists because the platform publishes progress ONLY for an installation
@@ -4411,25 +4546,92 @@ export function apply(ctx, config = {}) {
4411
4546
  */
4412
4547
  void (async () => {
4413
4548
  try {
4414
- const outcome = await manager.installBundle(
4415
- `${PACKAGE_NAME}@${version}`,
4416
- { enabled: true, requestId },
4417
- );
4418
- job.done = true;
4549
+ const outcome = await manager.installBundle(spec, {
4550
+ enabled: true,
4551
+ requestId,
4552
+ });
4419
4553
  if (outcome?.application === "failed") {
4554
+ /*
4555
+ * The tarball spec should never be refused with `already-installed`
4556
+ * (its package name is only known after pnpm runs), and a fresh
4557
+ * install has nothing installed to refuse. Landing here anyway
4558
+ * means the manager named the installed package from the spec
4559
+ * after all, or the disk judgement above was wrong — either way the
4560
+ * documented cross-version path is remove-then-install, and the
4561
+ * fiber that awaited the removal is never interrupted by the
4562
+ * resolution itself, so this fallback performs the two-step right
4563
+ * here: remove, then install the new version from the same fiber
4564
+ * under its plain registry name. Whether the host lets the fiber
4565
+ * survive its own removal is what real-machine use finds out; every
4566
+ * way it can die is caught below and reported with the manual path
4567
+ * as the last resort. Nothing here can touch user data — personas
4568
+ * and teams.json are outside the package.
4569
+ */
4570
+ const code = String(outcome.error?.code ?? outcome.error?.message ?? "");
4571
+ if (code.includes("already-installed")) {
4572
+ ctx.logger?.warn?.(
4573
+ `dsh-waifu-agents: installBundle refused ${version} (already-installed); performing remove-then-install.`,
4574
+ );
4575
+ job.phase = "removing";
4576
+ try {
4577
+ await manager.removeBundle(PACKAGE_NAME);
4578
+ } catch (removeFailure) {
4579
+ job.done = true;
4580
+ job.error = `卸载旧版本失败(${removeFailure?.message ?? removeFailure}),已安装的版本没有变动。请到「插件」页手动卸载后重新安装 ${version}。`;
4581
+ return;
4582
+ }
4583
+ /*
4584
+ * Still running — the fiber survived its own removal. The spec no
4585
+ * longer names an installed package, so the precheck lets the new
4586
+ * version through to pnpm.
4587
+ */
4588
+ job.phase = "reinstalling";
4589
+ let retry = null;
4590
+ try {
4591
+ retry = await manager.installBundle(`${PACKAGE_NAME}@${version}`, {
4592
+ enabled: true,
4593
+ requestId,
4594
+ });
4595
+ } catch (installFailure) {
4596
+ job.done = true;
4597
+ job.error = `旧版本已卸载,但安装 ${version} 失败:${installFailure?.message ?? installFailure}。请到「插件」页重新安装——人设目录(personas)和团队配置(teams.json)不在包里,不受影响。`;
4598
+ return;
4599
+ }
4600
+ if (retry?.application === "failed") {
4601
+ job.done = true;
4602
+ job.error = `旧版本已卸载,但安装 ${version} 失败:${retry.error?.message ?? retry.error?.code ?? JSON.stringify(retry.error)}。请到「插件」页重新安装——人设目录(personas)和团队配置(teams.json)不在包里,不受影响。`;
4603
+ if (job.log === "") job.log = String(retry?.packageResult?.output ?? "");
4604
+ return;
4605
+ }
4606
+ job.done = true;
4607
+ job.result = {
4608
+ ok: true,
4609
+ version,
4610
+ previous: before,
4611
+ application: retry?.application ?? null,
4612
+ changed: retry?.changed === true,
4613
+ installed: readOwnVersion(),
4614
+ log: String(retry?.packageResult?.output ?? ""),
4615
+ twoStep: true,
4616
+ };
4617
+ return;
4618
+ }
4619
+ job.done = true;
4420
4620
  job.error = `安装失败:${outcome.error?.message ?? outcome.error?.code ?? JSON.stringify(outcome.error)}`;
4421
4621
  // The manager's own transcript is the useful half of this failure,
4422
4622
  // and it is only here if the streaming feed never spoke.
4423
4623
  if (job.log === "") job.log = String(outcome?.packageResult?.output ?? "");
4424
4624
  return;
4425
4625
  }
4626
+ job.done = true;
4426
4627
  job.result = {
4427
4628
  ok: true,
4428
4629
  version,
4429
4630
  previous: before,
4430
- // The manager never hot-swaps an already-installed package, so this
4431
- // is `restart-required` in practice. Its own word is passed through
4432
- // rather than a promise this code cannot keep.
4631
+ // Without HMR this is `restart-required`; with HMR a fresh install
4632
+ // (the two-step above included) may genuinely apply. The manager's
4633
+ // own word is passed through rather than a promise this code cannot
4634
+ // keep.
4433
4635
  application: outcome?.application ?? null,
4434
4636
  changed: outcome?.changed === true,
4435
4637
  installed: readOwnVersion(),
@@ -5076,16 +5278,117 @@ export function apply(ctx, config = {}) {
5076
5278
  return { ok: true, order: ids, changed, reloaded: reloaded.reloaded ?? [] };
5077
5279
  }
5078
5280
 
5281
+ /**
5282
+ * Remove THIS package's entries from `minimumReleaseAgeExclude`.
5283
+ *
5284
+ * The list lives in a profile's `pnpm-workspace.yaml` — a file pnpm owns —
5285
+ * but every entry naming `dsh-waifu-agents` exists BECAUSE of this plugin:
5286
+ * pnpm wrote it to let our install past the 24h freshness policy. Deleting
5287
+ * our own lines is not reaching into another owner's file any more than
5288
+ * unregistering our presets is; the entries that name other packages, and
5289
+ * every other byte of the file, pass through untouched.
5290
+ *
5291
+ * The profile name is only visible when `DSH_PROFILE_DIR` reaches this
5292
+ * process; without it every profile is scanned, which is safe precisely
5293
+ * because the filter matches our name and nothing else. A profile that
5294
+ * never installed this plugin has no matching line and is left identical —
5295
+ * the write happens only when a line was actually removed.
5296
+ * @returns {{ cleaned: string[], failed: string[] }}
5297
+ */
5298
+ function cleanReleaseAgeExcludes() {
5299
+ const cleaned = [];
5300
+ const failed = [];
5301
+ const profileDir = process.env.DSH_PROFILE_DIR
5302
+ ? path.resolve(process.env.DSH_PROFILE_DIR)
5303
+ : null;
5304
+ let candidates;
5305
+ if (profileDir !== null) {
5306
+ candidates = [path.join(profileDir, "pnpm-workspace.yaml")];
5307
+ } else {
5308
+ const profiles = path.join(homeFromEnv(process.env).home, "profiles");
5309
+ try {
5310
+ candidates = fs
5311
+ .readdirSync(profiles, { withFileTypes: true })
5312
+ .filter((row) => row.isDirectory())
5313
+ .map((row) => path.join(profiles, row.name, "pnpm-workspace.yaml"));
5314
+ } catch {
5315
+ candidates = [];
5316
+ }
5317
+ }
5318
+ for (const full of candidates) {
5319
+ let raw;
5320
+ try {
5321
+ raw = fs.readFileSync(full, "utf8");
5322
+ } catch {
5323
+ continue; // no such file — a profile without pnpm state, nothing to do
5324
+ }
5325
+ const lines = raw.split("\n");
5326
+ const keep = [];
5327
+ let changed = false;
5328
+ for (let at = 0; at < lines.length; ) {
5329
+ const line = lines[at];
5330
+ if (!/^minimumReleaseAgeExclude\s*:/.test(line)) {
5331
+ keep.push(line);
5332
+ at += 1;
5333
+ continue;
5334
+ }
5335
+ // Flow form `key: [a, b]`: filter inside the brackets, drop the key
5336
+ // when nothing of ours was in it (then the line is reproduced as-is).
5337
+ const flow = /^minimumReleaseAgeExclude\s*:\s*\[(.*)\]\s*\r?$/.exec(line);
5338
+ if (flow !== null) {
5339
+ const items = flow[1]
5340
+ .split(",")
5341
+ .filter((item) => item.trim() !== "");
5342
+ const rest = items.filter((item) => !item.includes(PACKAGE_NAME));
5343
+ if (rest.length === items.length) {
5344
+ keep.push(line);
5345
+ } else {
5346
+ changed = true;
5347
+ if (rest.length > 0)
5348
+ keep.push(`minimumReleaseAgeExclude: [${rest.join(", ")}]`);
5349
+ }
5350
+ at += 1;
5351
+ continue;
5352
+ }
5353
+ // Block form: gather the list items that follow.
5354
+ const items = [];
5355
+ let cursor = at + 1;
5356
+ while (cursor < lines.length && /^\s+-\s/.test(lines[cursor])) {
5357
+ items.push(lines[cursor]);
5358
+ cursor += 1;
5359
+ }
5360
+ const rest = items.filter((item) => !item.includes(PACKAGE_NAME));
5361
+ if (rest.length === items.length) {
5362
+ keep.push(line, ...items);
5363
+ } else {
5364
+ changed = true;
5365
+ if (rest.length > 0) keep.push(line, ...rest);
5366
+ // zero left: the key goes too — an empty list is noise
5367
+ }
5368
+ at = cursor;
5369
+ }
5370
+ if (!changed) continue;
5371
+ try {
5372
+ writeAtomic(full, keep.join("\n"));
5373
+ cleaned.push(full);
5374
+ } catch (error) {
5375
+ failed.push(`${full}:${error?.message ?? error}`);
5376
+ }
5377
+ }
5378
+ return { cleaned, failed };
5379
+ }
5380
+
5079
5381
  /**
5080
5382
  * Undo everything this plugin put outside its own package.
5081
5383
  *
5082
5384
  * Only three things are ours and outlive an uninstall: the per-session
5083
5385
  * override store, the `selectedDefault` line this panel writes into the
5084
5386
  * agent-preset registry's config, and — only when explicitly asked — the
5085
- * persona directory itself. Everything else an install leaves behind belongs to
5086
- * the platform (the plugin-manager's operation logs, pnpm's store index, the
5087
- * release-age allow-list entry it wrote at install time), so this op names them
5088
- * instead of pretending to clean them.
5387
+ * persona directory itself. The freshness allow-list lines naming this
5388
+ * package are ours too (pnpm wrote them for OUR install), and are stripped.
5389
+ * Everything else an install leaves behind belongs to
5390
+ * the platform (the plugin-manager's operation logs, pnpm's store index),
5391
+ * so this op names them instead of pretending to clean them.
5089
5392
  * @param {{ dir?: boolean }} request
5090
5393
  */
5091
5394
  async function purge(request) {
@@ -5102,6 +5405,14 @@ export function apply(ctx, config = {}) {
5102
5405
  }
5103
5406
  }
5104
5407
  overrides = {};
5408
+ try {
5409
+ const excludes = cleanReleaseAgeExcludes();
5410
+ for (const full of excludes.cleaned)
5411
+ done.push(`清掉放行记录 ${full} 里属于本包的条目`);
5412
+ for (const message of excludes.failed) failed.push(`放行记录:${message}`);
5413
+ } catch (error) {
5414
+ failed.push(`放行记录:${error?.message ?? error}`);
5415
+ }
5105
5416
  try {
5106
5417
  const configEditor = ctx.get("configEditor");
5107
5418
  const entry =
@@ -5186,7 +5497,6 @@ export function apply(ctx, config = {}) {
5186
5497
  platform: [
5187
5498
  ".plugin-manager\\logs\\operation-*(安装日志,平台自己累积)",
5188
5499
  "pnpm 内容库索引:需要 pnpm store prune",
5189
- "pnpm-workspace.yaml 的 minimumReleaseAgeExclude(安装时 pnpm 自己写的放行记录)",
5190
5500
  ],
5191
5501
  };
5192
5502
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-waifu-agents",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "Registers every *.md in a persona directory as an agent preset, and manages them from the Plugins settings page.",
6
6
  "license": "MIT",