dsh-ntr 0.2.0 → 0.3.0

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.
@@ -44,6 +44,12 @@ const MAX_BLOCKS = 400;
44
44
  const MAX_WATCHED = 6;
45
45
  /** Raw JSON budget for one event block; the cut is marked. */
46
46
  const RAW_MAX = 256_000;
47
+ /**
48
+ * How long a fetched update state stays fresh. The settings panel asks on every
49
+ * mount; without this, every open would hit the registry. A forced check (the
50
+ * card's own button) always goes out.
51
+ */
52
+ const PLUGIN_UPDATE_TTL_MS = 10 * 60 * 1000;
47
53
  /** Pretty-printed JSON of an agent message (never throws, cuts only at {@link RAW_MAX} with a marker). */
48
54
  export function jsonText(value) {
49
55
  let text;
@@ -279,6 +285,14 @@ export const DEFAULT_KNOWN_PRESETS = [
279
285
  defaultCommand: 'opencode',
280
286
  defaultArgs: ['acp'],
281
287
  },
288
+ {
289
+ id: 'cursor',
290
+ name: 'Cursor CLI',
291
+ description: 'Cursor CLI — run as `agent acp` over stdio (legacy alias `cursor-agent acp`)',
292
+ detected: false,
293
+ defaultCommand: 'agent',
294
+ defaultArgs: ['acp'],
295
+ },
282
296
  {
283
297
  id: 'qwen',
284
298
  name: 'Qwen Code',
@@ -394,6 +408,22 @@ export class AcpStore {
394
408
  cachedModelReads = new Set();
395
409
  /** Counts behind the last successful explicit re-detect, so a locale switch can re-render its summary. */
396
410
  detectCounts;
411
+ /** When {@link UiState.pluginUpdate} was last read from the host (epoch ms). */
412
+ pluginUpdateFetchedAt = 0;
413
+ /**
414
+ * The shared in-flight `plugin/update-check`. The settings section mounts
415
+ * twice (both entries) and the update popover asks too; without this, two
416
+ * mounts in the same tick each fire their own registry read.
417
+ */
418
+ pluginUpdateInflight;
419
+ /** Re-entry guard for `plugin/update-apply` (a double click is one install). */
420
+ pluginUpdateApplying = false;
421
+ /**
422
+ * Update read generation. An apply bumps it so a check that was already in
423
+ * flight (started before the install) cannot land afterwards and overwrite
424
+ * the install's verdict with the pre-install `available`.
425
+ */
426
+ pluginUpdateGeneration = 0;
397
427
  constructor(rpc, locale) {
398
428
  this.rpc = rpc;
399
429
  this.locale = locale;
@@ -533,7 +563,7 @@ export class AcpStore {
533
563
  /** Snapshot refresh used on panel open and after a stream outage. */
534
564
  async resync() {
535
565
  try {
536
- const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult] = await Promise.all([
566
+ const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult, versionResult] = await Promise.all([
537
567
  this.call('agents/list'),
538
568
  this.call('sessions/list'),
539
569
  // The plugin's OWN default ACP agent — a plugin-private id, not a DSH
@@ -547,6 +577,9 @@ export class AcpStore {
547
577
  // `undefined` (older half) hides the deadline card instead of showing a
548
578
  // number this browser made up.
549
579
  this.call('ui/approval-deadlines').catch(() => ({ deadlines: undefined })),
580
+ // The settings badge's data source; older halves answer `unknown
581
+ // endpoint`, which hides the badge.
582
+ this.call('plugin/version').catch(() => null),
550
583
  ]);
551
584
  const detectedAgents = (detectResult && detectResult.agents.length > 0)
552
585
  ? detectResult.agents
@@ -561,6 +594,7 @@ export class AcpStore {
561
594
  preset: presetResult?.preset ?? undefined,
562
595
  tagHalo: tagHaloResult?.halo ?? { ...DEFAULT_TAG_HALO },
563
596
  approvalDeadlines: deadlinesResult.deadlines,
597
+ pluginVersion: versionResult?.version !== undefined && versionResult.version !== '' ? versionResult.version : undefined,
564
598
  });
565
599
  const active = this.state.activeSessionId ?? sessions[0]?.sessionId;
566
600
  if (active !== undefined)
@@ -570,6 +604,104 @@ export class AcpStore {
570
604
  this.mutate({ notice: String(error instanceof Error ? error.message : error) });
571
605
  }
572
606
  }
607
+ /**
608
+ * Read the plugin's update state (`plugin/update-check`). Throttled to ten
609
+ * minutes unless `force`: the settings panel asks on mount, and every
610
+ * remount must not hit the registry.
611
+ *
612
+ * An older host serves no such endpoint (`unknown endpoint`). That is a
613
+ * normal degradation, not an error worth a notice: the answer is `undefined`
614
+ * and the panel simply keeps its update entry hidden.
615
+ */
616
+ async checkPluginUpdate(force = false) {
617
+ // One read for every caller while it is in flight, force included: the
618
+ // settings page and the popover mounting together must not hit the
619
+ // registry twice.
620
+ if (this.pluginUpdateInflight !== undefined)
621
+ return this.pluginUpdateInflight;
622
+ const current = this.state.pluginUpdate;
623
+ if (!force && current !== undefined && Date.now() - this.pluginUpdateFetchedAt < PLUGIN_UPDATE_TTL_MS) {
624
+ return current;
625
+ }
626
+ const generation = this.pluginUpdateGeneration;
627
+ const inflight = this.readPluginUpdate(generation);
628
+ this.pluginUpdateInflight = inflight;
629
+ try {
630
+ return await inflight;
631
+ }
632
+ finally {
633
+ this.pluginUpdateInflight = undefined;
634
+ }
635
+ }
636
+ async readPluginUpdate(generation) {
637
+ try {
638
+ const pluginUpdate = await this.call('plugin/update-check');
639
+ // An install started while this read was in flight: its answer is
640
+ // already stale, and the install's verdict owns the state now.
641
+ if (generation !== this.pluginUpdateGeneration)
642
+ return undefined;
643
+ this.pluginUpdateFetchedAt = Date.now();
644
+ this.mutate({ pluginUpdate });
645
+ return pluginUpdate;
646
+ }
647
+ catch {
648
+ return undefined;
649
+ }
650
+ }
651
+ /**
652
+ * Install one exact version (`plugin/update-apply`). The status flips to
653
+ * `applying` first so the badge answers the click; the host's answer then
654
+ * becomes the state.
655
+ *
656
+ * A failed call does NOT prove the install did not happen: on an HMR host
657
+ * the update uninstalls the plugin before reinstalling it, which can cut the
658
+ * response. Whatever the error, the disk may already carry the new version,
659
+ * so the state becomes `restart-required` (the one neutral verdict for "the
660
+ * running code is not the disk's") with `installed` dropped (unknown, not a
661
+ * fact to display) and the notice asks for a reload rather than restoring
662
+ * `available` and pretending nothing changed.
663
+ */
664
+ async applyPluginUpdate(version) {
665
+ if (this.pluginUpdateApplying)
666
+ return undefined;
667
+ this.pluginUpdateApplying = true;
668
+ // Any check already in flight predates the install; its answer must not
669
+ // land on top of the install's verdict.
670
+ this.pluginUpdateGeneration += 1;
671
+ const previous = this.state.pluginUpdate;
672
+ if (previous !== undefined) {
673
+ this.mutate({ pluginUpdate: { ...previous, status: 'applying' } });
674
+ }
675
+ try {
676
+ const pluginUpdate = await this.call('plugin/update-apply', { version });
677
+ this.pluginUpdateFetchedAt = Date.now();
678
+ this.mutate({ pluginUpdate });
679
+ return pluginUpdate;
680
+ }
681
+ catch (error) {
682
+ const message = error instanceof Error ? error.message : String(error);
683
+ // The disk state is UNKNOWN here: the install may have completed before
684
+ // the response was cut. Keep `latest` for display, drop `installed` so no
685
+ // surface can claim a disk version we never read, and reset the throttle
686
+ // so the next check actually asks the host instead of serving this
687
+ // unknown verdict for ten minutes.
688
+ const interrupted = previous !== undefined
689
+ ? { ...previous, status: 'restart-required', installed: undefined, message }
690
+ : { status: 'restart-required', current: this.state.pluginVersion ?? '?', message };
691
+ this.pluginUpdateFetchedAt = 0;
692
+ this.mutate({
693
+ pluginUpdate: interrupted,
694
+ notice: this.locale('更新请求已中断;请刷新页面确认当前版本。', 'The update request was interrupted. Reload the page to confirm the installed version.'),
695
+ });
696
+ return undefined;
697
+ }
698
+ finally {
699
+ // Invalidate reads that STARTED during the install: their "applying"
700
+ // answer would otherwise land on top of this call's final verdict.
701
+ this.pluginUpdateGeneration += 1;
702
+ this.pluginUpdateApplying = false;
703
+ }
704
+ }
573
705
  async detectAgents() {
574
706
  this.mutate({ detecting: true });
575
707
  try {