@1e0zj/dsh-plugin-mall 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -15,7 +15,7 @@ Curated lists only show what has been reviewed and merged. This marketplace is o
15
15
  - **Anti-squatting** — an install prefers the npm tarball only when the registry entry's `repository` URL points back to the same GitHub repo; anything else falls back to the explicit `github:` spec.
16
16
  - **npm-first installs** — registry tarballs are smaller than whole-repo GitHub downloads and come with integrity checks. Lookups follow the registry pnpm actually installs from (profile `.npmrc` → `pnpm config get registry` → npmjs), so a mirror user keeps npm-first instead of silently falling back to whole-repo clones.
17
17
  - **Update management** — installed plugins are compared against the registry `latest`; one-click update per plugin.
18
- - **Conflict guard** — every install runs an isolated preflight first: the candidate is installed with scripts disabled into a throwaway directory and scanned against the live profile for loader-id collisions, double mounts, host-module shadowing and version/OS/peer ranges. A hard conflict is blocked, warnings require explicit confirmation. The profile's load-bearing files are snapshotted before `pnpm` touches them and restored on failure, and a host-independent CLI guards startup: launched through `guard launch`, a pending install is committed only after dsh survives a grace window, and rolled back + restarted once when it crashes inside it (see [Startup protection](#startup-protection-guard-cli)).
18
+ - **Conflict guard** — every install runs an isolated preflight first: the candidate is installed with scripts disabled into a throwaway directory and scanned against the live profile for loader-id collisions, double mounts, host-module shadowing and version/OS/peer ranges. A hard conflict is blocked, warnings require explicit confirmation. The profile's load-bearing files are snapshotted before `pnpm` touches them and restored on failure. A pending install is resolved on the next start, however you start it: this plugin runs recovery as it loads, which only happens because dsh booted far enough to compose the profile. Starting through `guard launch` adds a grace window on top, so a plugin that boots and then crashes seconds later is rolled back and restarted once (see [Startup protection](#startup-protection-guard-cli)).
19
19
  - **Resilience** — rate-limit circuit breaker, GitHub's 1000-result search window handled gracefully, `corepack enable pnpm` self-heal when pnpm is missing, one-click dsh restart (loopback-only, `allowRestart: false` to disable).
20
20
 
21
21
  ## Install
@@ -71,7 +71,15 @@ node <profile>/node_modules/@1e0zj/dsh-plugin-mall/src/cli.js guard add <spec> -
71
71
  node <profile>/node_modules/@1e0zj/dsh-plugin-mall/src/cli.js guard launch --profile web -- dsh web
72
72
  ```
73
73
 
74
- **You must start dsh through the guard wrapper to get startup protection.** `guard launch` checks the profile's pending-install marker before starting the command after `--`:
74
+ **A plain `dsh web` already resolves pending installs.** The marketplace plugin runs
75
+ recovery when it loads: reaching that point proves dsh booted far enough to compose
76
+ the profile, so the pending marker is committed (or rolled back when the profile
77
+ fails validation). Without this a single install would wedge the profile — every
78
+ later install and uninstall refuses while a marker is outstanding.
79
+
80
+ **`guard launch` is still strictly better**, because it also covers what a plain
81
+ start cannot: a plugin that boots fine and then crashes seconds later. It checks the
82
+ profile's pending-install marker before starting the command after `--`:
75
83
 
76
84
  - **No pending install** — the command runs as-is, inheriting the terminal, and its exit code is preserved.
77
85
  - **Clearly broken on disk** — the profile is rolled back to its pre-install snapshot *before* launch, then the command starts on the restored state.
@@ -105,7 +113,7 @@ Limitations: the grace window is the probation period — a failure that only su
105
113
  - **防抢注**:仅当 npm registry 条目的 `repository` 指回同一 GitHub 仓库时才用 npm 安装,否则回退 `github:` 源
106
114
  - **npm 优先安装**:registry tarball 比整仓库下载更小且带完整性校验;查询用的 registry 跟随 pnpm 实际安装源(profile `.npmrc` → `pnpm config get registry` → npmjs),换了镜像也不会退化成整仓库克隆
107
115
  - **更新管理**:已装插件与 registry `latest` 比对,逐个一键更新
108
- - **冲突防护**:每次安装先跑隔离预检——候选包在一次性目录里以禁用脚本的方式装好后,对照 live profile 扫描 loader-id 冲突、重复挂载、宿主模块遮蔽和版本/OS/peer 范围;硬冲突直接拦截,警告需显式确认。安装前给 profile 的承重文件拍快照、失败即回滚;另有一个独立于宿主的 CLI 守启动——经 `guard launch` 启动的 dsh 只有在活过观察期后才提交 pending 安装,观察期内崩溃则回滚并原样重启一次(见下方「启动保护」)。
116
+ - **冲突防护**:每次安装先跑隔离预检——候选包在一次性目录里以禁用脚本的方式装好后,对照 live profile 扫描 loader-id 冲突、重复挂载、宿主模块遮蔽和版本/OS/peer 范围;硬冲突直接拦截,警告需显式确认。安装前给 profile 的承重文件拍快照、失败即回滚;pending 安装在下次启动时自动了结,**不挑启动方式**:本插件加载时就跑恢复,而能加载本身就证明 dsh 已经组装好 profile、活到了这一步。经 `guard launch` 启动则多一层观察期——插件启动几秒后才崩的情况也能回滚并原样重启一次(见下方「启动保护」)。
109
117
  - **工程韧性**:限流熔断、GitHub 5xx/超时退避重试(504 瞬时故障不再直达用户)、GitHub 1000 条搜索上限优雅处理、pnpm 缺失时 `corepack` 自愈、一键重启 dsh(仅 loopback,可 `allowRestart: false` 关闭)
110
118
 
111
119
  ## 安装
@@ -182,7 +190,13 @@ node <profile>/node_modules/@1e0zj/dsh-plugin-mall/src/cli.js guard add <spec> -
182
190
  node <profile>/node_modules/@1e0zj/dsh-plugin-mall/src/cli.js guard launch --profile web -- dsh web
183
191
  ```
184
192
 
185
- **必须经由 guard 包装器启动 dsh 才有启动期保护。** `guard launch` 在启动 `--` 之后的命令前检查该 profile 的 pending 安装标记:
193
+ **普通的 `dsh web` 就会了结 pending 安装。** 本插件加载时即执行恢复——能加载
194
+ 本身就证明 dsh 已经组装好 profile、启动到了这一步,于是提交 pending 标记
195
+ (profile 校验不过则回滚)。没有这一步的话,装完一个插件就会把 profile 卡住:
196
+ 只要标记还在,之后所有安装和卸载都会被拒绝。
197
+
198
+ **`guard launch` 仍然更强**,因为它覆盖普通启动覆盖不了的情况:插件启动正常、
199
+ 几秒后才崩。它在启动 `--` 之后的命令前检查该 profile 的 pending 安装标记:
186
200
 
187
201
  - **无 pending 安装** —— 命令原样运行(继承终端),透传退出码;
188
202
  - **静态校验明显过不了** —— 启动*之前*先把 profile 回滚到安装前快照,再在恢复后的状态上启动;
@@ -220,6 +234,12 @@ node <profile>/node_modules/@1e0zj/dsh-plugin-mall/src/cli.js guard launch --pro
220
234
  (这行会出现在任务日志里)。更新检查读的是 registry 的 `/latest` 端点,
221
235
  不经过该策略,所以两边看到的「最新版本」本就可能不同。
222
236
  首次安装(卡片按钮)不带版本,沿用 pnpm 的策略默认值即可。
237
+ - **一次点击一个任务,日志从第一毫秒开始流**:预检本身就是一个任务,点安装
238
+ 的瞬间就出现在面板里,隔离探针的 pnpm 输出实时写入——而不是让按钮干等几秒
239
+ 再冒出结果。预检通过后由安装任务接管同一条日志、撤掉预检条目,所以面板上
240
+ 始终只有一条记录。运行中只露最后 8 行,落定后折叠成「查看日志(N 行)」。
241
+ - **预检通过直接装**:verdict 为 safe 时不弹任何确认;只有有风险或被阻止才
242
+ 出面板内联卡片(原因列表 + 取消 / 继续),不用模态弹窗打断。
223
243
  - **安装期代码要用户点头**:pnpm 默认拦掉依赖的构建脚本,放行等于让那些命令
224
244
  以用户的权限在其机器上运行(早于任何插件代码加载)——这个决定属于用户。
225
245
  所以被拦时安装**停下**,如实列出要批准的到底是什么:包名@版本、确切的
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -64,7 +64,8 @@ window.__ModuleLoader__.load({
64
64
  ".mkt_approvePkg{display:flex;flex-direction:column;gap:4px}",
65
65
  ".mkt_approveName{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin-right:8px;overflow-wrap:anywhere}",
66
66
  ".mkt_approveCmd{max-height:none;margin:0}",
67
- ".mkt_jobDone{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
67
+ ".mkt_jobDone{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:2px}",
68
+ ".mkt_logBlock{display:flex;flex-direction:column;gap:4px;align-items:flex-start}",
68
69
  ".mkt_issueList{list-style:none;display:flex;flex-direction:column;gap:8px;margin:0;padding:0}",
69
70
  ".mkt_issue{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 10px;background:var(--dsw-alias-bg-secondary,#fff)}",
70
71
  ".mkt_issueBlock{border-color:var(--dsw-alias-state-error-primary)}",
@@ -123,7 +124,9 @@ window.__ModuleLoader__.load({
123
124
  }
124
125
 
125
126
  // ── job polling ─────────────────────────────────────────────────────────
126
- function useJobPolling(call, onSettled, onApprovalToken) {
127
+ // onPreflightSettled:预检 job(kind=dsh-plugin-preflight)落定时回调,
128
+ // 携带 (spec, report)。safe 由调用方直接续装;有风险由调用方出内联卡片。
129
+ function useJobPolling(call, onSettled, onApprovalToken, onPreflightSettled) {
127
130
  var jobsRef = useRef({});
128
131
  var _jobs = useState({});
129
132
  var jobs = _jobs[0];
@@ -145,8 +148,15 @@ window.__ModuleLoader__.load({
145
148
  if (snapshot.needsApproval && snapshot.approvalToken && onApprovalToken) {
146
149
  try { onApprovalToken(snapshot.spec, snapshot.approvalToken); } catch (e) { /* best effort */ }
147
150
  }
148
- if (!wasTerminal && nowTerminal && onSettled) {
149
- try { onSettled(id, snapshot); } catch (e) { /* best effort */ }
151
+ if (!wasTerminal && nowTerminal) {
152
+ if (onSettled) {
153
+ try { onSettled(id, snapshot); } catch (e) { /* best effort */ }
154
+ }
155
+ // 预检 job 落定 → 把结论交给上层后继(只触发一次)
156
+ if (snapshot.kind === "dsh-plugin-preflight" && !old.preflightHandled && onPreflightSettled) {
157
+ jobsRef.current[id] = Object.assign({}, old, { preflightHandled: true });
158
+ try { onPreflightSettled(snapshot.spec, snapshot.extras, id); } catch (e) { /* best effort */ }
159
+ }
150
160
  }
151
161
  var output = (old.output || "") + (value.output || "");
152
162
  jobsRef.current = Object.assign({}, jobsRef.current, { [id]: Object.assign({}, old, {
@@ -154,6 +164,7 @@ window.__ModuleLoader__.load({
154
164
  detail: snapshot.detail,
155
165
  needsApproval: snapshot.needsApproval,
156
166
  approvalToken: snapshot.approvalToken,
167
+ kind: snapshot.kind,
157
168
  output: output,
158
169
  }) });
159
170
  setJobs(Object.assign({}, jobsRef.current));
@@ -162,9 +173,19 @@ window.__ModuleLoader__.load({
162
173
  }, 1200);
163
174
  return function () { clearInterval(timer); };
164
175
  }, [call, onSettled, onApprovalToken]);
165
- var track = useCallback(function (id, spec) {
166
- jobsRef.current = Object.assign({}, jobsRef.current, { [id]: { status: "running", spec: spec, output: "" } });
167
- setJobs(Object.assign({}, jobsRef.current));
176
+ // carryFromId:把上一阶段(预检)的日志接过来并撤掉它的条目。一次点击
177
+ // 只应该在面板里留下一个任务,日志连续——而不是 market-1 预检、
178
+ // market-2 安装两条并排,让人以为自己点了两次。
179
+ var track = useCallback(function (id, spec, carryFromId) {
180
+ var next = Object.assign({}, jobsRef.current);
181
+ var carried = "";
182
+ if (carryFromId && next[carryFromId]) {
183
+ carried = next[carryFromId].output || "";
184
+ delete next[carryFromId];
185
+ }
186
+ next[id] = { status: "running", spec: spec, output: carried };
187
+ jobsRef.current = next;
188
+ setJobs(Object.assign({}, next));
168
189
  }, []);
169
190
  var clear = useCallback(function () {
170
191
  jobsRef.current = {};
@@ -317,6 +338,52 @@ window.__ModuleLoader__.load({
317
338
  );
318
339
  }
319
340
 
341
+ function jobKindLabel(kind) {
342
+ if (kind === "dsh-plugin-preflight") return "预检 ";
343
+ if (kind === "dsh-plugin-uninstall") return "卸载 ";
344
+ return "安装 ";
345
+ }
346
+
347
+ function jobStatusLabel(status) {
348
+ if (status === "completed") return "完成";
349
+ if (status === "failed") return "失败";
350
+ if (status === "killed") return "已取消";
351
+ return "进行中";
352
+ }
353
+
354
+ function jobStatusClass(status) {
355
+ if (status === "completed") return "mkt_ok";
356
+ if (status === "failed" || status === "killed") return "mkt_error";
357
+ return "";
358
+ }
359
+
360
+ // 任务日志:跑的时候只露尾部几行(够看进度,不霸屏),落定后默认折叠
361
+ // ——成功的日志没人回头读,失败的才需要,点开即可。
362
+ var LOG_TAIL_LINES = 8;
363
+ function JobLog(props) {
364
+ var _open = useState(false);
365
+ var open = _open[0];
366
+ var setOpen = _open[1];
367
+ var text = String(props.output || "").trimEnd();
368
+ if (text.length === 0) return null;
369
+ var lines = text.split("\n");
370
+ var collapsed = props.done && !open;
371
+ var shown = (props.done || lines.length <= LOG_TAIL_LINES) ? lines : lines.slice(-LOG_TAIL_LINES);
372
+ // 展开/收起始终在日志块上方:放在下方的话,点开后按钮会被推到长长的
373
+ // 日志末尾,想收起还得先滚回去。
374
+ return h("div", { className: "mkt_logBlock" },
375
+ props.done
376
+ ? h("span", {
377
+ className: "mkt_link",
378
+ onClick: function () { setOpen(!open); },
379
+ }, open ? "收起日志" : "查看日志(" + lines.length + " 行)")
380
+ : lines.length > LOG_TAIL_LINES
381
+ ? h("div", { className: "mkt_meta" }, "只显示最后 " + LOG_TAIL_LINES + " 行")
382
+ : null,
383
+ collapsed ? null : h("pre", { className: "mkt_pre" }, shown.join("\n").slice(-4000))
384
+ );
385
+ }
386
+
320
387
  // ── jobs panel ──────────────────────────────────────────────────────────
321
388
  function JobsPanel(props) {
322
389
  var ids = Object.keys(props.jobs);
@@ -330,8 +397,13 @@ window.__ModuleLoader__.load({
330
397
  var job = props.jobs[id];
331
398
  var done = job.status === "completed" || job.status === "failed" || job.status === "killed";
332
399
  return h("div", { key: id, style: { display: "flex", flexDirection: "column", gap: "4px" } },
400
+ // 不再显示 market-N 这种内部 id——对用户没有意义,反而让人以为
401
+ // 自己点了两次。只说清「在做什么 · 到哪一步了」。
402
+ // 状态词单独上色(完成绿、失败/取消红),前半段保持灰——整行
403
+ // 都染色会喧宾夺主,只有结论需要一眼看见。
333
404
  h("div", { className: "mkt_meta" },
334
- id + " · " + clip(job.spec || "", 40) + " · " + (job.status || "running")),
405
+ jobKindLabel(job.kind) + clip(job.spec || "", 44) + " · ",
406
+ h("span", { className: jobStatusClass(job.status) }, jobStatusLabel(job.status))),
335
407
  job.needsApproval && job.needsApproval.length > 0
336
408
  ? h(ApprovalRequest, {
337
409
  needsApproval: job.needsApproval,
@@ -345,9 +417,10 @@ window.__ModuleLoader__.load({
345
417
  onDismiss: function () { props.onDismiss(id); },
346
418
  })
347
419
  : job.detail ? h("div", { className: "mkt_desc" }, job.detail) : null,
348
- done && job.status === "completed"
420
+ // 预检完成不提示重启——它没有改动任何东西,接下来才是安装。
421
+ // 也不再重复一个绿色「完成」:状态行已经写了「· 完成」。
422
+ done && job.status === "completed" && job.kind !== "dsh-plugin-preflight"
349
423
  ? h("div", { className: "mkt_jobDone" },
350
- h("span", { className: "mkt_ok" }, "完成"),
351
424
  h("button", {
352
425
  className: "mkt_btn mkt_btnPrimary mkt_btnSm",
353
426
  disabled: props.restarting === true,
@@ -356,7 +429,7 @@ window.__ModuleLoader__.load({
356
429
  : job.status === "failed" && !(job.needsApproval && job.needsApproval.length > 0)
357
430
  ? h("div", { className: "mkt_error" }, "失败,见下方输出")
358
431
  : null,
359
- job.output ? h("pre", { className: "mkt_pre" }, job.output.slice(-4000)) : null
432
+ h(JobLog, { output: job.output, done: done })
360
433
  );
361
434
  })
362
435
  );
@@ -584,7 +657,14 @@ window.__ModuleLoader__.load({
584
657
  }
585
658
  }, []);
586
659
 
587
- var polling = useJobPolling(call, onJobSettled, onApprovalToken);
660
+ // 预检落定的后继动作要用到下面才定义的 doRawInstall,用 ref 转接,
661
+ // 免得为了闭包顺序把整块逻辑往上搬。
662
+ var preflightHandlerRef = useRef(null);
663
+ var onPreflightSettled = useCallback(function (spec, report, jobId) {
664
+ if (preflightHandlerRef.current) preflightHandlerRef.current(spec, report, jobId);
665
+ }, []);
666
+
667
+ var polling = useJobPolling(call, onJobSettled, onApprovalToken, onPreflightSettled);
588
668
  var track = polling.track;
589
669
  var jobs = polling.jobs;
590
670
  var clearJobs = polling.clear;
@@ -595,7 +675,7 @@ window.__ModuleLoader__.load({
595
675
  // eslint-disable-next-line react-hooks/exhaustive-deps
596
676
  }, []);
597
677
 
598
- var doRawInstall = useCallback(function (spec, extra) {
678
+ var doRawInstall = useCallback(function (spec, extra, carryFromId) {
599
679
  setInstalling(function (prev) {
600
680
  var next = Object.assign({}, prev, { [spec]: true });
601
681
  return next;
@@ -603,7 +683,7 @@ window.__ModuleLoader__.load({
603
683
  setError(null);
604
684
  var payload = Object.assign({ spec: spec }, extra || {});
605
685
  call("install", payload).then(function (value) {
606
- track(value.jobId, spec);
686
+ track(value.jobId, spec, carryFromId);
607
687
  }).catch(function (e) {
608
688
  delete approvalTokensRef.current[spec];
609
689
  setError(errorText(e));
@@ -626,31 +706,33 @@ window.__ModuleLoader__.load({
626
706
  doRawInstall(spec, extra);
627
707
  }, [doRawInstall]);
628
708
 
629
- var preflightAndInstall = useCallback(function (spec) {
630
- delete approvalTokensRef.current[spec];
631
- setInstalling(function (prev) {
632
- var next = Object.assign({}, prev, { [spec]: true });
633
- return next;
634
- });
635
- setError(null);
636
- call("preflight", { spec: spec }).then(function (report) {
637
- // 检查通过直接装,不打扰;有风险才出内联卡片等人表态;被阻止
638
- // 只告知原因。任何路径都不再弹模态框。
709
+ // 预检落定后的去向:safe 直接续装,其余出内联风险卡片。
710
+ useEffect(function () {
711
+ preflightHandlerRef.current = function (spec, report, jobId) {
712
+ if (!report || !report.verdict) {
713
+ setError("预检没有返回结论,请重试");
714
+ return;
715
+ }
639
716
  if (report.verdict === "safe") {
640
- doRawInstall(spec, {});
717
+ // 安全:接过预检的日志、撤掉预检条目,面板上只剩这一个任务。
718
+ doRawInstall(spec, {}, jobId);
641
719
  } else {
642
- setPreflight({ spec: spec, report: report });
720
+ setPreflight({ spec: spec, report: report, jobId: jobId });
643
721
  }
722
+ };
723
+ }, [doRawInstall]);
724
+
725
+ // 点安装 = 立刻起一个预检 job,面板马上有东西看、日志实时流。
726
+ // 结论由轮询经 onPreflightSettled 交回上面的 handler,这里不等待。
727
+ var preflightAndInstall = useCallback(function (spec) {
728
+ delete approvalTokensRef.current[spec];
729
+ setError(null);
730
+ call("preflight", { spec: spec }).then(function (value) {
731
+ track(value.jobId, value.spec || spec);
644
732
  }).catch(function (e) {
645
733
  setError(errorText(e));
646
- }).finally(function () {
647
- setInstalling(function (prev) {
648
- var next = Object.assign({}, prev);
649
- delete next[spec];
650
- return next;
651
- });
652
734
  });
653
- }, [call, doRawInstall]);
735
+ }, [call, track]);
654
736
 
655
737
  var doInstall = useCallback(function (repo) {
656
738
  preflightAndInstall("github:" + repo);
@@ -777,8 +859,9 @@ window.__ModuleLoader__.load({
777
859
  busy: installing[preflight.spec] === true,
778
860
  onConfirm: function () {
779
861
  var spec = preflight.spec;
862
+ var carry = preflight.jobId;
780
863
  setPreflight(null);
781
- doRawInstall(spec, { acceptWarnings: true });
864
+ doRawInstall(spec, { acceptWarnings: true }, carry);
782
865
  },
783
866
  onClose: function () {
784
867
  if (preflight && preflight.spec) delete approvalTokensRef.current[preflight.spec];
package/src/index.js CHANGED
@@ -25,7 +25,7 @@ import { tmpdir } from "node:os";
25
25
  import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
26
26
  import { repoInfo, searchPlugins, verifyPlugins, cachedRepoManifest, fetchRawFile, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall, mapLimit, NETWORK_CONCURRENCY } from "./github.js";
27
27
  import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, assertSafeSpec, resolveRegistry, serializeCanonicalProof } from "./installer.js";
28
- import { preflightInstall, inspectRemoteCandidate } from "./guard.js";
28
+ import { preflightInstall, inspectRemoteCandidate, recoverProfile } from "./guard.js";
29
29
 
30
30
  export const name = "@1e0zj/dsh-plugin-mall";
31
31
  export const inject = ["tools", "jobs", "systemPrompt"];
@@ -194,7 +194,7 @@ function pinPreflight(profileDir, spec) {
194
194
  * Run the isolated preflight for a resolved install spec, reusing a fresh
195
195
  * cache entry ONLY if the profile fingerprint matches.
196
196
  */
197
- async function runPreflight({ profile, spec, force = false }) {
197
+ async function runPreflight({ profile, spec, force = false, onOutput }) {
198
198
  let profileDir;
199
199
  try {
200
200
  profileDir = resolveProfileDir(profile);
@@ -220,7 +220,7 @@ async function runPreflight({ profile, spec, force = false }) {
220
220
  return { report: validCached.report, profileDir, fingerprint: currentFingerprint };
221
221
  }
222
222
 
223
- const report = await preflightInstall({ profileDir, spec });
223
+ const report = await preflightInstall({ profileDir, spec, onOutput });
224
224
  preflightCache.set(key, {
225
225
  report,
226
226
  fingerprint: currentFingerprint,
@@ -858,6 +858,57 @@ export function createJobTracker({ producerFactory } = {}) {
858
858
  return id;
859
859
  },
860
860
 
861
+ /**
862
+ * A visible, log-streaming job for a phase that is not install/remove.
863
+ * Preflight above all: it used to run inside the RPC call before any job
864
+ * existed, so the panel sat empty for the seconds the probe took. Now the
865
+ * job appears the instant the user clicks and the probe's pnpm output
866
+ * streams into it. The verdict rides `extras` on the snapshot so the
867
+ * polling client can continue into the install (or raise a risk card).
868
+ * Cancel is advisory — the probe has no kill handle — so it just marks the
869
+ * record killed and discards the outcome; the throwaway probe directory
870
+ * lives in the system tmpdir and is reclaimed by the OS.
871
+ */
872
+ startCustom({ kind, label, profile, spec, surface = "browser", session, run }) {
873
+ const id = `market-${++trackerCounter}`;
874
+ const queue = [];
875
+ const push = (text) => { queue.push(String(text ?? "")); };
876
+ const record = {
877
+ id,
878
+ kind,
879
+ label,
880
+ profile,
881
+ spec,
882
+ surface,
883
+ session: session ?? "",
884
+ status: "running",
885
+ detail: undefined,
886
+ extras: undefined,
887
+ startedAt: Date.now(),
888
+ finishedAt: undefined,
889
+ cancelled: false,
890
+ readOutput: () => (queue.length === 0 ? "" : queue.splice(0).join("")),
891
+ };
892
+ (async () => {
893
+ try {
894
+ const outcome = await run(push);
895
+ if (record.cancelled) return; // 用户已放弃,结果作废
896
+ record.status = outcome?.status ?? "failed";
897
+ record.detail = outcome?.detail;
898
+ record.extras = outcome?.extras;
899
+ } catch (error) {
900
+ if (record.cancelled) return;
901
+ record.status = "failed";
902
+ record.detail = error?.message ?? String(error);
903
+ } finally {
904
+ if (!record.cancelled) record.finishedAt = Date.now();
905
+ }
906
+ })();
907
+ records.set(id, record);
908
+ prune();
909
+ return id;
910
+ },
911
+
861
912
  get(jobId, session) {
862
913
  const record = records.get(String(jobId));
863
914
  if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
@@ -871,11 +922,15 @@ export function createJobTracker({ producerFactory } = {}) {
871
922
  detail: record.detail,
872
923
  needsApproval: record.needsApproval,
873
924
  approvalToken: isSameSession ? record.approvalToken : undefined,
925
+ // extras(如预检结论)同样只对同一 session 可见,与 approvalToken 同规格。
926
+ extras: isSameSession ? record.extras : undefined,
874
927
  spec: record.spec,
875
928
  startedAt: record.startedAt,
876
929
  finishedAt: record.finishedAt,
877
930
  },
878
- output: typeof record.producer?.readOutput === "function" ? record.producer.readOutput() : "",
931
+ output: typeof record.readOutput === "function"
932
+ ? record.readOutput()
933
+ : typeof record.producer?.readOutput === "function" ? record.producer.readOutput() : "",
879
934
  };
880
935
  },
881
936
 
@@ -885,6 +940,13 @@ export function createJobTracker({ producerFactory } = {}) {
885
940
  if (record.surface === "browser" && (record.session === "" || record.session !== session)) {
886
941
  throw new Error("unauthorized to cancel job from another session");
887
942
  }
943
+ if (record.cancelled !== undefined) { // startCustom:无句柄可杀,立即标 killed 并作废结果
944
+ record.cancelled = true;
945
+ if (record.status === "running") {
946
+ record.status = "killed";
947
+ record.finishedAt = Date.now();
948
+ }
949
+ }
888
950
  if (typeof record.producer?.cancel === "function") {
889
951
  record.producer.cancel();
890
952
  }
@@ -1134,7 +1196,18 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1134
1196
  return rpcOk(listInstalled(profile));
1135
1197
  }
1136
1198
  case "preflight": {
1199
+ // 预检本身做成 job:点击安装的瞬间任务就出现在面板里,探针的 pnpm
1200
+ // 输出实时流入——此前预检阻塞在 RPC 里,面板数秒空白只有按钮干等。
1201
+ // 结论经 snapshot.extras 返回,轮询端据此后接安装或出风险卡片;
1202
+ // 缓存同时被填热,随后的 install 调用不再重复探装。
1137
1203
  const profile = String(payload?.profile ?? defaultProfile).trim();
1204
+ let session;
1205
+ try {
1206
+ // 与 install 同规格:job 归属浏览器 session,extras 才只对本人可见。
1207
+ session = requireBrowserSession(payload?.session);
1208
+ } catch (error) {
1209
+ return rpcFail(error);
1210
+ }
1138
1211
  let spec;
1139
1212
  try {
1140
1213
  spec = normalizeSpec(payload?.spec);
@@ -1144,9 +1217,22 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
1144
1217
  }
1145
1218
  try {
1146
1219
  const registry = await registryFor(profile, npmRegistry);
1147
- spec = await preferNpmSpec({ spec, registry, sources: rawSources });
1148
- const { report } = await runPreflight({ profile, spec });
1149
- return rpcOk(report);
1220
+ const resolved = await preferNpmSpec({ spec, registry, sources: rawSources });
1221
+ const jobId = tracker.startCustom({
1222
+ kind: "dsh-plugin-preflight",
1223
+ label: `preflight ${resolved}`,
1224
+ profile,
1225
+ spec: resolved,
1226
+ surface: "browser",
1227
+ session,
1228
+ run: async (push) => {
1229
+ push(`[dsh-plugin-mall] 预检 ${resolved}:隔离目录探装(脚本禁用)\n`);
1230
+ const { report } = await runPreflight({ profile, spec: resolved, onOutput: (text) => push(text) });
1231
+ push(`[dsh-plugin-mall] 预检结论:${report.verdict}\n`);
1232
+ return { status: "completed", detail: `预检完成:${report.verdict}`, extras: report };
1233
+ },
1234
+ });
1235
+ return rpcOk({ jobId, profile, spec: resolved });
1150
1236
  } catch (error) {
1151
1237
  return rpcFail(error);
1152
1238
  }
@@ -1363,6 +1449,31 @@ export function apply(ctx, config = {}) {
1363
1449
  const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, npmRegistry = "", rawSources = [] } = config;
1364
1450
  const token = process.env.GITHUB_TOKEN ?? process.env.DSH_MARKET_GITHUB_TOKEN;
1365
1451
 
1452
+ // Startup recovery. A pending install marker blocks every later install and
1453
+ // uninstall in that profile until something resolves it — and until now the
1454
+ // only thing that did was `guard launch`, a wrapper nobody uses: people type
1455
+ // `dsh web`. One install then wedged the profile permanently, with an error
1456
+ // telling users to run a CLI they have never heard of.
1457
+ //
1458
+ // Reaching `apply` IS the proof the pending install did not break the host:
1459
+ // this code only runs because dsh booted far enough to compose the profile
1460
+ // and load this plugin. So resolve the marker right here — recoverProfile
1461
+ // commits when the profile validates and rolls back when it does not. The
1462
+ // grace-window probation of `guard launch` stays strictly better (it also
1463
+ // catches a crash seconds later); this is the floor for a plain start.
1464
+ try {
1465
+ const result = recoverProfile(resolveProfileDir(defaultProfile));
1466
+ if (result.action === "committed") {
1467
+ console.log(`[dsh-plugin-mall] startup recovery: committed the pending install for profile "${defaultProfile}"`);
1468
+ } else if (result.action === "rolled-back") {
1469
+ console.warn(`[dsh-plugin-mall] startup recovery: rolled back the pending install for profile "${defaultProfile}" — ${result.reason ?? "profile failed validation"}`);
1470
+ }
1471
+ } catch (error) {
1472
+ // 恢复失败绝不能拖垮插件加载:报出来,让市场照常可用(用户还能手动
1473
+ // `dsh-plugin-guard guard recover`),而不是连界面都进不去。
1474
+ console.error("[dsh-plugin-mall] startup recovery failed:", error);
1475
+ }
1476
+
1366
1477
  ctx.systemPrompt.section({
1367
1478
  name: "tool:market",
1368
1479
  order: 120,