dsh-waifu-agents 0.2.0 → 0.2.2

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 +83 -5
  2. package/index.js +177 -28
  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 还在跑',
@@ -307,6 +310,7 @@ window.__ModuleLoader__.load({
307
310
  gone, so the strings are gone: a key left behind is a key a future
308
311
  reader will wire up again. */
309
312
  teamUnitPerson: '人',
313
+ teamUnresolved: '解析失败',
310
314
  teamUnitGroup: '组',
311
315
  teamGone: '已经不存在了',
312
316
  teamClipped: '超出上限被截断',
@@ -394,6 +398,9 @@ window.__ModuleLoader__.load({
394
398
  updateInstallingFrom: 'Installing from {registry} (source {index} of {total})…',
395
399
  updateApplying: 'Applying the configuration, please wait…',
396
400
  updateCancelling: 'Stopping the installation…',
401
+ updateRemoving: 'Removing the old version…',
402
+ updateReinstalling: 'Installing the new version…',
403
+ updateHandedOff: 'The plugin restarted with the update; confirm the version on the Plugins page.',
397
404
  updateWaiting: 'Installing — leave this window open',
398
405
  updateElapsed: '{s}s elapsed',
399
406
  updateQuiet: 'no progress report from the platform for {s}s, but pnpm is still running',
@@ -546,6 +553,7 @@ window.__ModuleLoader__.load({
546
553
  teamAddPersona: 'add persona',
547
554
  teamAddGroup: 'add group',
548
555
  teamUnitPerson: 'members',
556
+ teamUnresolved: 'unresolved',
549
557
  teamUnitGroup: 'group',
550
558
  teamGone: 'no longer exists',
551
559
  teamClipped: 'clipped by the cap',
@@ -1399,7 +1407,16 @@ window.__ModuleLoader__.load({
1399
1407
  const phase = String(job.phase ?? 'starting');
1400
1408
  let floor = 3;
1401
1409
  let ceiling = 10;
1402
- if (phase === 'installing') {
1410
+ if (phase === 'removing') {
1411
+ // The self-removal before the reinstall: brief, but not instant.
1412
+ floor = 6;
1413
+ ceiling = 16;
1414
+ } else if (phase === 'reinstalling') {
1415
+ // The fresh install after the self-removal, before the platform's own
1416
+ // per-registry phases take over and redraw the band.
1417
+ floor = 14;
1418
+ ceiling = 24;
1419
+ } else if (phase === 'installing') {
1403
1420
  // One band per registry tried, so a fallback to a second source is
1404
1421
  // visible as progress instead of as a bar going backwards.
1405
1422
  const index = Math.max(1, Number(job.attempt?.index ?? 1) || 1);
@@ -1429,10 +1446,13 @@ window.__ModuleLoader__.load({
1429
1446
  */
1430
1447
  function progressHeadline(job, t) {
1431
1448
  if (job === null || job === undefined) return '';
1449
+ if (job.handedOff === true) return t('updateHandedOff');
1432
1450
  if (job.done === true) {
1433
1451
  return typeof job.error === 'string' && job.error !== '' ? t('updateFailedTitle') : t('updateDoneTitle');
1434
1452
  }
1435
1453
  const phase = String(job.phase ?? 'starting');
1454
+ if (phase === 'removing') return t('updateRemoving');
1455
+ if (phase === 'reinstalling') return t('updateReinstalling');
1436
1456
  if (phase === 'installing') {
1437
1457
  const attempt = job.attempt;
1438
1458
  const total = Number(attempt?.total ?? 1) || 1;
@@ -1493,6 +1513,7 @@ window.__ModuleLoader__.load({
1493
1513
  .replace('{s}', String(seconds));
1494
1514
  }
1495
1515
  if (typeof job?.error === 'string' && job.error !== '') return t('updateStripFailed');
1516
+ if (job?.handedOff === true) return t('updateHandedOff');
1496
1517
  return t('updateStripDone').replace('{v}', String(job?.result?.version ?? job?.version ?? ''));
1497
1518
  }
1498
1519
 
@@ -1545,7 +1566,17 @@ window.__ModuleLoader__.load({
1545
1566
  { style: { ...L.dialog, maxWidth: '480px', width: '100%' } },
1546
1567
  h('span', { style: L.rowTitle }, t('updateProgressTitle')),
1547
1568
  h('div', { style: L.progressTrack }, h('div', { style: fill })),
1548
- h('span', { style: failed ? L.error : L.name }, failed ? String(job.error) : done ? progressOutcome(job, t) : progressHeadline(job, t)),
1569
+ h(
1570
+ 'span',
1571
+ { style: failed ? L.error : L.name },
1572
+ failed
1573
+ ? String(job.error)
1574
+ : job.handedOff === true
1575
+ ? t('updateHandedOff')
1576
+ : done
1577
+ ? progressOutcome(job, t)
1578
+ : progressHeadline(job, t),
1579
+ ),
1549
1580
  note === '' ? null : h('span', { style: L.meta }, note),
1550
1581
  exit === '' ? null : h('span', { style: L.meta }, exit),
1551
1582
  log === ''
@@ -2118,6 +2149,25 @@ window.__ModuleLoader__.load({
2118
2149
  (one.kind === 'group' && groups.includes(one.ref))
2119
2150
  ),
2120
2151
  );
2152
+ /*
2153
+ * The header counts PEOPLE, not picked rows: one group entry can carry
2154
+ * three wives, so `selection.length` printed 「1 人」 for a trio — the
2155
+ * exact number the user then corrected by hand. Resolved the same way
2156
+ * the Host expands: `group:` equality for group entries, the id for
2157
+ * persona entries (the picker only ever stores ids). A stale entry
2158
+ * resolves to nothing here, which is consistent with the grey block
2159
+ * below that says it is gone.
2160
+ */
2161
+ const picked = new Set();
2162
+ for (const one of draft.selection) {
2163
+ if (one.kind === 'group') {
2164
+ for (const row of rows) {
2165
+ if (one.ref !== '' && groupOf(row) === one.ref) picked.add(row.id);
2166
+ }
2167
+ } else if (rows.some((row) => row.id === one.ref)) {
2168
+ picked.add(one.ref);
2169
+ }
2170
+ }
2121
2171
  const named = String(draft.name ?? '') !== '';
2122
2172
  return h(
2123
2173
  'div',
@@ -2174,7 +2224,7 @@ window.__ModuleLoader__.load({
2174
2224
  'div',
2175
2225
  { style: L.sectionHead },
2176
2226
  h('span', { style: L.rowTitle }, t('teamSelection')),
2177
- h('span', { style: L.meta }, `${draft.selection.length} ${t('teamUnitPerson')}`),
2227
+ h('span', { style: L.meta }, `${picked.size} ${t('teamUnitPerson')}`),
2178
2228
  h('span', { style: L.hint }, t('teamSelectionHint')),
2179
2229
  ),
2180
2230
  draft.selection.length === 0
@@ -2326,7 +2376,19 @@ window.__ModuleLoader__.load({
2326
2376
  'div',
2327
2377
  { style: L.head },
2328
2378
  h('span', { style: L.name }, entry.name),
2329
- h('span', { style: L.meta }, `${entry.selection.length} ${t('teamUnitPerson')}`),
2379
+ /*
2380
+ * Resolved headcount, not selection entries: a team picked as one
2381
+ * group of three is 「3 人」, and a drifted group reports the
2382
+ * failure instead of a wrong number — the full error renders
2383
+ * right below in TeamPreview.
2384
+ */
2385
+ h(
2386
+ 'span',
2387
+ { style: L.meta },
2388
+ entry.preview?.ok === true
2389
+ ? `${entry.preview.members.length} ${t('teamUnitPerson')}`
2390
+ : t('teamUnresolved'),
2391
+ ),
2330
2392
  // No preset, no switch: a default has to name something the
2331
2393
  // registry holds, and this team is not registered yet (the sync
2332
2394
  // is in flight, or it failed). Saying which is `teamNoPreset`.
@@ -3323,7 +3385,23 @@ window.__ModuleLoader__.load({
3323
3385
  const poll = () => {
3324
3386
  post({ op: 'updateProgress', requestId })
3325
3387
  .then((payload) => {
3326
- if (!alive || payload.idle === true) return;
3388
+ if (!alive) return;
3389
+ if (payload.idle === true) {
3390
+ /*
3391
+ * The host no longer knows this job. That is the two-step
3392
+ * update succeeding the loud way: the removal plus reinstall
3393
+ * ended in an HMR reload, the module that owned the job is
3394
+ * gone, and the fresh module answering polls has never heard
3395
+ * of this requestId. Report it as a hand-off to the restart
3396
+ * instead of letting the bar sit on its last phase forever.
3397
+ */
3398
+ setJob((prev) =>
3399
+ prev !== null && prev !== undefined && prev.done !== true
3400
+ ? { ...prev, done: true, handedOff: true, error: null }
3401
+ : prev,
3402
+ );
3403
+ return;
3404
+ }
3327
3405
  setJob((prev) => (prev === null || prev === undefined ? prev : { ...prev, ...payload }));
3328
3406
  })
3329
3407
  .catch(() => {
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
@@ -4295,20 +4358,20 @@ export function apply(ctx, config = {}) {
4295
4358
  * Install the newest published version, through the same service the Plugins
4296
4359
  * page uses.
4297
4360
  *
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.
4361
+ * The exact version is passed, never a range. The spec it installs FROM
4362
+ * depends on what is already on disk (see the comment at the choice below):
4363
+ * an update goes through the new version's tarball URL — the spec shape the
4364
+ * HMR precheck cannot refuse — and a fresh install keeps the plain
4365
+ * `name@version`, whose install rewrites the profile's declared range.
4366
+ *
4367
+ * `minimumReleaseAge`: a registry-name install is subject to the 24h
4368
+ * freshness policy and must be recorded in the workspace's
4369
+ * `minimumReleaseAgeExclude` first, or the lockfile check refuses outright
4370
+ * with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`. A tarball URL install
4371
+ * fetches the package file directly and plausibly never consults publish
4372
+ * times — expected but NOT verified against the manager's pnpm invocation,
4373
+ * so the failure, should it come, is surfaced verbatim rather than promised
4374
+ * away.
4312
4375
  *
4313
4376
  * Returns as soon as the install is STARTED, not when it finishes, and hands
4314
4377
  * back the `requestId` that makes the platform publish progress for it. The
@@ -4375,6 +4438,25 @@ export function apply(ctx, config = {}) {
4375
4438
  },
4376
4439
  };
4377
4440
  }
4441
+ /*
4442
+ * Which spec to install from, decided by what is already on disk.
4443
+ *
4444
+ * An UPDATE of an installed package installs from the new version's
4445
+ * tarball URL: that is the one spec shape the HMR precheck cannot match by
4446
+ * name, so it reaches pnpm over the installed copy and the re-composition
4447
+ * replaces the running plugin in place — no removal, no fiber gamble. It
4448
+ * also rewrites the profile's saved dependency from a version range to the
4449
+ * exact URL, which is deliberate: the next update's URL differs again, so
4450
+ * no future install is ever refused as already-installed.
4451
+ *
4452
+ * A FRESH install (nothing readable on disk) keeps the registry name:
4453
+ * there is nothing installed for the precheck to refuse, and the saved
4454
+ * spec stays a plain dependency. If that judgement is wrong — the copy on
4455
+ * disk was unreadable, not absent — the name spec is refused with
4456
+ * `already-installed` and the two-step below takes over.
4457
+ */
4458
+ let spec = `${PACKAGE_NAME}@${version}`;
4459
+ if (before !== null) spec = await tarballUrlOf(config.registry, version);
4378
4460
  /*
4379
4461
  * The id is minted here, or accepted from the panel when it sent one. It
4380
4462
  * exists because the platform publishes progress ONLY for an installation
@@ -4411,25 +4493,92 @@ export function apply(ctx, config = {}) {
4411
4493
  */
4412
4494
  void (async () => {
4413
4495
  try {
4414
- const outcome = await manager.installBundle(
4415
- `${PACKAGE_NAME}@${version}`,
4416
- { enabled: true, requestId },
4417
- );
4418
- job.done = true;
4496
+ const outcome = await manager.installBundle(spec, {
4497
+ enabled: true,
4498
+ requestId,
4499
+ });
4419
4500
  if (outcome?.application === "failed") {
4501
+ /*
4502
+ * The tarball spec should never be refused with `already-installed`
4503
+ * (its package name is only known after pnpm runs), and a fresh
4504
+ * install has nothing installed to refuse. Landing here anyway
4505
+ * means the manager named the installed package from the spec
4506
+ * after all, or the disk judgement above was wrong — either way the
4507
+ * documented cross-version path is remove-then-install, and the
4508
+ * fiber that awaited the removal is never interrupted by the
4509
+ * resolution itself, so this fallback performs the two-step right
4510
+ * here: remove, then install the new version from the same fiber
4511
+ * under its plain registry name. Whether the host lets the fiber
4512
+ * survive its own removal is what real-machine use finds out; every
4513
+ * way it can die is caught below and reported with the manual path
4514
+ * as the last resort. Nothing here can touch user data — personas
4515
+ * and teams.json are outside the package.
4516
+ */
4517
+ const code = String(outcome.error?.code ?? outcome.error?.message ?? "");
4518
+ if (code.includes("already-installed")) {
4519
+ ctx.logger?.warn?.(
4520
+ `dsh-waifu-agents: installBundle refused ${version} (already-installed); performing remove-then-install.`,
4521
+ );
4522
+ job.phase = "removing";
4523
+ try {
4524
+ await manager.removeBundle(PACKAGE_NAME);
4525
+ } catch (removeFailure) {
4526
+ job.done = true;
4527
+ job.error = `卸载旧版本失败(${removeFailure?.message ?? removeFailure}),已安装的版本没有变动。请到「插件」页手动卸载后重新安装 ${version}。`;
4528
+ return;
4529
+ }
4530
+ /*
4531
+ * Still running — the fiber survived its own removal. The spec no
4532
+ * longer names an installed package, so the precheck lets the new
4533
+ * version through to pnpm.
4534
+ */
4535
+ job.phase = "reinstalling";
4536
+ let retry = null;
4537
+ try {
4538
+ retry = await manager.installBundle(`${PACKAGE_NAME}@${version}`, {
4539
+ enabled: true,
4540
+ requestId,
4541
+ });
4542
+ } catch (installFailure) {
4543
+ job.done = true;
4544
+ job.error = `旧版本已卸载,但安装 ${version} 失败:${installFailure?.message ?? installFailure}。请到「插件」页重新安装——人设目录(personas)和团队配置(teams.json)不在包里,不受影响。`;
4545
+ return;
4546
+ }
4547
+ if (retry?.application === "failed") {
4548
+ job.done = true;
4549
+ job.error = `旧版本已卸载,但安装 ${version} 失败:${retry.error?.message ?? retry.error?.code ?? JSON.stringify(retry.error)}。请到「插件」页重新安装——人设目录(personas)和团队配置(teams.json)不在包里,不受影响。`;
4550
+ if (job.log === "") job.log = String(retry?.packageResult?.output ?? "");
4551
+ return;
4552
+ }
4553
+ job.done = true;
4554
+ job.result = {
4555
+ ok: true,
4556
+ version,
4557
+ previous: before,
4558
+ application: retry?.application ?? null,
4559
+ changed: retry?.changed === true,
4560
+ installed: readOwnVersion(),
4561
+ log: String(retry?.packageResult?.output ?? ""),
4562
+ twoStep: true,
4563
+ };
4564
+ return;
4565
+ }
4566
+ job.done = true;
4420
4567
  job.error = `安装失败:${outcome.error?.message ?? outcome.error?.code ?? JSON.stringify(outcome.error)}`;
4421
4568
  // The manager's own transcript is the useful half of this failure,
4422
4569
  // and it is only here if the streaming feed never spoke.
4423
4570
  if (job.log === "") job.log = String(outcome?.packageResult?.output ?? "");
4424
4571
  return;
4425
4572
  }
4573
+ job.done = true;
4426
4574
  job.result = {
4427
4575
  ok: true,
4428
4576
  version,
4429
4577
  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.
4578
+ // Without HMR this is `restart-required`; with HMR a fresh install
4579
+ // (the two-step above included) may genuinely apply. The manager's
4580
+ // own word is passed through rather than a promise this code cannot
4581
+ // keep.
4433
4582
  application: outcome?.application ?? null,
4434
4583
  changed: outcome?.changed === true,
4435
4584
  installed: readOwnVersion(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-waifu-agents",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",