wave-agent-sdk 1.2.0 → 1.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.
Files changed (175) hide show
  1. package/dist/agent.d.ts +58 -4
  2. package/dist/agent.js +91 -19
  3. package/dist/builtin/index.js +2 -0
  4. package/dist/builtin/skills/settings.js +1 -12
  5. package/dist/builtin/skills/wave-daemon.d.ts +1 -0
  6. package/dist/builtin/skills/wave-daemon.js +194 -0
  7. package/dist/constants/images.d.ts +26 -0
  8. package/dist/constants/images.js +26 -0
  9. package/dist/constants/index.d.ts +16 -0
  10. package/dist/constants/index.js +16 -0
  11. package/dist/constants/memory.d.ts +26 -0
  12. package/dist/constants/memory.js +34 -0
  13. package/dist/constants/messages.d.ts +11 -0
  14. package/dist/constants/messages.js +11 -0
  15. package/dist/constants/plugins.d.ts +8 -0
  16. package/dist/constants/plugins.js +8 -0
  17. package/dist/constants/tools.d.ts +1 -0
  18. package/dist/constants/tools.js +1 -0
  19. package/dist/core/plugin.d.ts +53 -13
  20. package/dist/core/plugin.js +134 -26
  21. package/dist/core/session.d.ts +1 -1
  22. package/dist/core/session.js +1 -1
  23. package/dist/exec/catalog.d.ts +140 -0
  24. package/dist/exec/catalog.js +470 -0
  25. package/dist/exec/catalogAnnouncement.d.ts +89 -0
  26. package/dist/exec/catalogAnnouncement.js +293 -0
  27. package/dist/exec/constants.d.ts +51 -0
  28. package/dist/exec/constants.js +51 -0
  29. package/dist/exec/execRuntime.d.ts +55 -0
  30. package/dist/exec/execRuntime.js +217 -0
  31. package/dist/exec/workerSource.d.ts +28 -0
  32. package/dist/exec/workerSource.js +299 -0
  33. package/dist/host/index.d.ts +23 -0
  34. package/dist/host/index.js +23 -0
  35. package/dist/index.d.ts +5 -0
  36. package/dist/index.js +6 -0
  37. package/dist/managers/MemoryRuleManager.d.ts +6 -0
  38. package/dist/managers/MemoryRuleManager.js +12 -0
  39. package/dist/managers/aiManager.d.ts +35 -1
  40. package/dist/managers/aiManager.js +190 -21
  41. package/dist/managers/backgroundTaskManager.js +14 -0
  42. package/dist/managers/hookManager.d.ts +13 -0
  43. package/dist/managers/hookManager.js +31 -4
  44. package/dist/managers/liveConfigManager.d.ts +33 -0
  45. package/dist/managers/liveConfigManager.js +103 -8
  46. package/dist/managers/lspManager.d.ts +9 -0
  47. package/dist/managers/lspManager.js +47 -18
  48. package/dist/managers/mcpManager.d.ts +45 -10
  49. package/dist/managers/mcpManager.js +103 -1
  50. package/dist/managers/messageManager.d.ts +48 -5
  51. package/dist/managers/messageManager.js +107 -21
  52. package/dist/managers/permissionManager.d.ts +40 -0
  53. package/dist/managers/permissionManager.js +63 -8
  54. package/dist/managers/pluginManager.d.ts +46 -2
  55. package/dist/managers/pluginManager.js +117 -11
  56. package/dist/managers/pluginScopeManager.d.ts +15 -2
  57. package/dist/managers/pluginScopeManager.js +20 -1
  58. package/dist/managers/skillManager.d.ts +19 -0
  59. package/dist/managers/skillManager.js +44 -0
  60. package/dist/managers/slashCommandManager.d.ts +10 -0
  61. package/dist/managers/slashCommandManager.js +35 -3
  62. package/dist/managers/subagentManager.d.ts +8 -0
  63. package/dist/managers/subagentManager.js +20 -0
  64. package/dist/managers/toolManager.d.ts +29 -3
  65. package/dist/managers/toolManager.js +87 -13
  66. package/dist/prompts/autoMemory.d.ts +9 -0
  67. package/dist/prompts/autoMemory.js +30 -31
  68. package/dist/prompts/autoMemoryExtraction.d.ts +4 -0
  69. package/dist/prompts/autoMemoryExtraction.js +8 -111
  70. package/dist/prompts/memoryTypes.d.ts +63 -0
  71. package/dist/prompts/memoryTypes.js +191 -0
  72. package/dist/services/GitService.d.ts +7 -0
  73. package/dist/services/GitService.js +23 -0
  74. package/dist/services/MarketplaceService.d.ts +101 -17
  75. package/dist/services/MarketplaceService.js +318 -119
  76. package/dist/services/artifactContent.d.ts +84 -0
  77. package/dist/services/artifactContent.js +204 -0
  78. package/dist/services/artifactSession.d.ts +6 -0
  79. package/dist/services/artifactSession.js +17 -0
  80. package/dist/services/autoMemoryService.js +5 -13
  81. package/dist/services/configurationService.d.ts +60 -9
  82. package/dist/services/configurationService.js +129 -54
  83. package/dist/services/contentSummarizer.d.ts +15 -0
  84. package/dist/services/contentSummarizer.js +45 -0
  85. package/dist/services/execAvailability.d.ts +9 -0
  86. package/dist/services/execAvailability.js +32 -0
  87. package/dist/services/fileWatcher.js +61 -6
  88. package/dist/services/initializationService.js +19 -15
  89. package/dist/services/interactionService.d.ts +9 -1
  90. package/dist/services/interactionService.js +28 -8
  91. package/dist/services/jsonlHandler.d.ts +84 -0
  92. package/dist/services/jsonlHandler.js +209 -14
  93. package/dist/services/memory.d.ts +3 -1
  94. package/dist/services/memory.js +13 -9
  95. package/dist/services/officialMarketplaceMirror.js +3 -2
  96. package/dist/services/pluginLoader.d.ts +12 -4
  97. package/dist/services/pluginLoader.js +38 -7
  98. package/dist/services/remoteSettingsService.js +16 -2
  99. package/dist/services/session.d.ts +74 -0
  100. package/dist/services/session.js +144 -3
  101. package/dist/services/sessionEntries.d.ts +2 -0
  102. package/dist/services/sessionEntries.js +20 -0
  103. package/dist/stdio/index.d.ts +3 -1
  104. package/dist/stdio/index.js +3 -1
  105. package/dist/stdio/notificationRouter.js +1 -0
  106. package/dist/stdio/stdioAgent.d.ts +14 -7
  107. package/dist/stdio/stdioAgent.js +19 -0
  108. package/dist/tools/artifactTool.js +406 -273
  109. package/dist/tools/bashTool.js +8 -6
  110. package/dist/tools/editTool.js +6 -3
  111. package/dist/tools/execTool.d.ts +2 -0
  112. package/dist/tools/execTool.js +165 -0
  113. package/dist/tools/grepTool.js +7 -1
  114. package/dist/tools/readTool.js +30 -2
  115. package/dist/tools/types.d.ts +34 -8
  116. package/dist/tools/webFetchTool.js +15 -166
  117. package/dist/tools/workflowTool.js +40 -8
  118. package/dist/tools/writeTool.js +6 -3
  119. package/dist/types/agent.d.ts +20 -5
  120. package/dist/types/configuration.d.ts +39 -1
  121. package/dist/types/marketplace.d.ts +40 -2
  122. package/dist/types/mcp.d.ts +39 -0
  123. package/dist/types/permissions.d.ts +22 -0
  124. package/dist/types/permissions.js +17 -0
  125. package/dist/types/plugins.d.ts +26 -2
  126. package/dist/types/skills.d.ts +15 -0
  127. package/dist/utils/constants.d.ts +10 -0
  128. package/dist/utils/constants.js +10 -0
  129. package/dist/utils/containerSetup.js +43 -0
  130. package/dist/utils/convertMessagesForAPI.d.ts +7 -1
  131. package/dist/utils/convertMessagesForAPI.js +64 -14
  132. package/dist/utils/fileChangeReminder.d.ts +20 -0
  133. package/dist/utils/fileChangeReminder.js +153 -0
  134. package/dist/utils/fileSearch.js +4 -3
  135. package/dist/utils/fileUtils.d.ts +33 -0
  136. package/dist/utils/fileUtils.js +81 -0
  137. package/dist/utils/frontmatterYaml.d.ts +33 -0
  138. package/dist/utils/frontmatterYaml.js +192 -0
  139. package/dist/utils/imageBudget.d.ts +85 -0
  140. package/dist/utils/imageBudget.js +109 -0
  141. package/dist/utils/imageDimensions.d.ts +83 -0
  142. package/dist/utils/imageDimensions.js +232 -0
  143. package/dist/utils/imageProcessor.d.ts +66 -0
  144. package/dist/utils/imageProcessor.js +84 -0
  145. package/dist/utils/imageRewrite.d.ts +29 -0
  146. package/dist/utils/imageRewrite.js +251 -0
  147. package/dist/utils/markdownParser.d.ts +5 -1
  148. package/dist/utils/markdownParser.js +9 -51
  149. package/dist/utils/mcpInstructions.d.ts +61 -0
  150. package/dist/utils/mcpInstructions.js +126 -0
  151. package/dist/utils/mcpUtils.d.ts +7 -0
  152. package/dist/utils/mcpUtils.js +11 -2
  153. package/dist/utils/memoryAge.d.ts +32 -0
  154. package/dist/utils/memoryAge.js +47 -0
  155. package/dist/utils/memoryEntrypoint.d.ts +20 -0
  156. package/dist/utils/memoryEntrypoint.js +49 -0
  157. package/dist/utils/memoryIndex.d.ts +30 -0
  158. package/dist/utils/memoryIndex.js +76 -0
  159. package/dist/utils/messageOperations.d.ts +6 -2
  160. package/dist/utils/messageOperations.js +40 -29
  161. package/dist/utils/nestedMemory.d.ts +22 -0
  162. package/dist/utils/nestedMemory.js +61 -0
  163. package/dist/utils/npmTarball.d.ts +19 -0
  164. package/dist/utils/npmTarball.js +92 -0
  165. package/dist/utils/pluginSource.d.ts +37 -0
  166. package/dist/utils/pluginSource.js +73 -0
  167. package/dist/utils/ripgrep.d.ts +18 -4
  168. package/dist/utils/ripgrep.js +56 -4
  169. package/dist/utils/runtimeDeps.d.ts +35 -0
  170. package/dist/utils/runtimeDeps.js +426 -0
  171. package/dist/utils/skillParser.js +22 -52
  172. package/dist/utils/subagentParser.js +39 -43
  173. package/dist/utils/userSettings.d.ts +90 -0
  174. package/dist/utils/userSettings.js +291 -0
  175. package/package.json +10 -7
@@ -1,8 +1,11 @@
1
1
  import { promises as fs, existsSync, mkdirSync } from "fs";
2
2
  import * as path from "path";
3
3
  import * as crypto from "crypto";
4
+ import { AsyncLocalStorage } from "async_hooks";
4
5
  import { getPluginsDir } from "../utils/configPaths.js";
5
6
  import { GitService } from "./GitService.js";
7
+ import { parsePluginSource, describePluginSource, } from "../utils/pluginSource.js";
8
+ import { DEFAULT_PLUGIN_VERSION } from "../constants/plugins.js";
6
9
  import { ALLOW_OFFICIAL_MARKET_GIT_FALLBACK, fetchOfficialMarketplaceFromMirror, } from "./officialMarketplaceMirror.js";
7
10
  import { ConfigurationService } from "./configurationService.js";
8
11
  import { logger, logError, logWarn } from "../utils/globalLogger.js";
@@ -17,6 +20,8 @@ import { logger, logError, logWarn } from "../utils/globalLogger.js";
17
20
  */
18
21
  export class MarketplaceService {
19
22
  constructor(workdir = process.cwd(), configurationService = new ConfigurationService()) {
23
+ /** In-flight [refreshMarketplaces] call (single-flight, spec 插件市场 场景 13). */
24
+ this.refreshInFlight = null;
20
25
  this.workdir = workdir;
21
26
  this.configurationService = configurationService;
22
27
  this.pluginsDir = getPluginsDir();
@@ -59,7 +64,6 @@ export class MarketplaceService {
59
64
  continue;
60
65
  const config = {
61
66
  source: m.source,
62
- autoUpdate: m.autoUpdate,
63
67
  };
64
68
  await this.configurationService.addMarketplaceToScope(this.workdir, "user", m.name, config);
65
69
  }
@@ -82,12 +86,11 @@ export class MarketplaceService {
82
86
  // Add to user-scope settings if not already in any scope
83
87
  const scoped = this.configurationService.getMergedMarketplaces(this.workdir);
84
88
  if (!scoped[builtin.name]) {
85
- await this.configurationService.addMarketplaceToScope(this.workdir, "user", builtin.name, { source: builtin.source, autoUpdate: builtin.autoUpdate });
89
+ await this.configurationService.addMarketplaceToScope(this.workdir, "user", builtin.name, { source: builtin.source });
86
90
  }
87
91
  // Update cache: add entry + set builtinSeeded flag
88
92
  await this.updateCacheMarketplace(builtin.name, {
89
93
  source: builtin.source,
90
- autoUpdate: builtin.autoUpdate,
91
94
  isBuiltin: true,
92
95
  declaredScope: "builtin",
93
96
  });
@@ -133,10 +136,10 @@ export class MarketplaceService {
133
136
  }
134
137
  /**
135
138
  * Acquires a file-based lock (with PID tracking for stale lock detection) and executes the provided function.
136
- * Supports re-entrancy within the same process.
139
+ * Supports re-entrancy within the same call chain (see [lockChain]).
137
140
  */
138
141
  async withLock(fn) {
139
- if (MarketplaceService.isLockedInProcess) {
142
+ if (MarketplaceService.lockChain.getStore()) {
140
143
  return await fn();
141
144
  }
142
145
  let lockFd;
@@ -169,12 +172,10 @@ export class MarketplaceService {
169
172
  throw new Error(`Failed to acquire marketplace lock after ${maxRetries} retries. If no other wave-agent process is running, please delete ${this.lockPath}`);
170
173
  }
171
174
  await fs.writeFile(this.lockPath, String(process.pid), "utf-8");
172
- MarketplaceService.isLockedInProcess = true;
173
175
  try {
174
- return await fn();
176
+ return await MarketplaceService.lockChain.run(true, fn);
175
177
  }
176
178
  finally {
177
- MarketplaceService.isLockedInProcess = false;
178
179
  await lockFd.close();
179
180
  await fs.unlink(this.lockPath).catch(() => { });
180
181
  }
@@ -283,6 +284,36 @@ export class MarketplaceService {
283
284
  await fs.writeFile(tmpPath, JSON.stringify(registry, null, 2));
284
285
  await fs.rename(tmpPath, this.installedPluginsPath);
285
286
  }
287
+ /**
288
+ * Whether an install record belongs to the given install location.
289
+ *
290
+ * Records written before scopes were tracked carry no `scope`; they stand for
291
+ * "installed once on this machine" and match any scope, so uninstalling them
292
+ * keeps working (they are that plugin's only record). They still match by
293
+ * project path, so records of two different projects stay distinguishable.
294
+ */
295
+ matchesInstallLocation(entry, location) {
296
+ if (!entry.scope) {
297
+ return (entry.projectPath === undefined ||
298
+ entry.projectPath === location.projectPath);
299
+ }
300
+ return (entry.scope === location.scope &&
301
+ entry.projectPath === location.projectPath);
302
+ }
303
+ /**
304
+ * The install location a record is keyed by. Scope-less legacy records are
305
+ * treated as user-scope when they have to be re-created (e.g. by an update).
306
+ */
307
+ locationOf(entry) {
308
+ return { scope: entry.scope ?? "user", projectPath: entry.projectPath };
309
+ }
310
+ describeInstallLocations(entries) {
311
+ return entries
312
+ .map((entry) => entry.scope
313
+ ? `${entry.scope}${entry.projectPath ? ` (${entry.projectPath})` : ""}`
314
+ : "unscoped")
315
+ .join(", ");
316
+ }
286
317
  /**
287
318
  * Loads a marketplace manifest from a local path
288
319
  */
@@ -326,7 +357,6 @@ export class MarketplaceService {
326
357
  return {
327
358
  name,
328
359
  source: config.source,
329
- autoUpdate: config.autoUpdate ?? cache?.autoUpdate,
330
360
  lastUpdated: cache?.lastUpdated,
331
361
  isBuiltin: name === MarketplaceService.BUILTIN_MARKETPLACE.name,
332
362
  declaredScope,
@@ -346,6 +376,53 @@ export class MarketplaceService {
346
376
  return "builtin";
347
377
  return null;
348
378
  }
379
+ /**
380
+ * 已注册的市场(作用域配置 + 缓存注册表合并去重),用于添加前的判重
381
+ */
382
+ async listRegisteredMarketplaces() {
383
+ const scoped = this.configurationService.getMergedMarketplaces(this.workdir);
384
+ const registered = Object.entries(scoped).map(([name, config]) => ({
385
+ name,
386
+ source: config.source,
387
+ }));
388
+ const cache = await this.getCacheRegistry();
389
+ for (const entry of cache?.marketplaces ?? []) {
390
+ if (!registered.some((m) => m.name === entry.name)) {
391
+ registered.push({ name: entry.name, source: entry.source });
392
+ }
393
+ }
394
+ return registered;
395
+ }
396
+ /**
397
+ * 添加市场前判重(spec ecosystem/plugin「设置页插件市场」场景 16):来源重复或
398
+ * 清单声明的名称与已有市场同名时不允许添加。只传 source 时仅判来源(用于克隆
399
+ * 之前,避免为一个已存在的来源白克隆一次)。
400
+ */
401
+ async assertMarketplaceAddable(source, name) {
402
+ const registered = await this.listRegisteredMarketplaces();
403
+ const sameSource = registered.find((m) => MarketplaceService.isSameMarketplaceSource(m.source, source));
404
+ if (sameSource) {
405
+ throw new Error(`该市场来源已添加:${sameSource.name}`);
406
+ }
407
+ if (name && registered.some((m) => m.name === name)) {
408
+ throw new Error(`同名市场已存在:${name}`);
409
+ }
410
+ }
411
+ /** 两个市场来源是否指向同一处(同类型比 url/repo/path,忽略 ref) */
412
+ static isSameMarketplaceSource(a, b) {
413
+ if (a.source !== b.source)
414
+ return false;
415
+ if (a.source === "directory" && b.source === "directory") {
416
+ return a.path === b.path;
417
+ }
418
+ if (a.source === "github" && b.source === "github") {
419
+ return a.repo === b.repo;
420
+ }
421
+ if (a.source === "git" && b.source === "git") {
422
+ return a.url === b.url;
423
+ }
424
+ return false;
425
+ }
349
426
  /**
350
427
  * Adds a new marketplace (local directory, GitHub repo, or Git URL)
351
428
  */
@@ -368,6 +445,7 @@ export class MarketplaceService {
368
445
  const tempSource = isFullUrl
369
446
  ? { source: "git", url: urlOrRepo, ref }
370
447
  : { source: "github", repo: urlOrRepo, ref };
448
+ await this.assertMarketplaceAddable(tempSource);
371
449
  const targetPath = this.getMarketplacePath(tempSource);
372
450
  if (!existsSync(targetPath)) {
373
451
  try {
@@ -389,12 +467,15 @@ export class MarketplaceService {
389
467
  source: isFullUrl
390
468
  ? { source: "git", url: urlOrRepo, ref }
391
469
  : { source: "github", repo: urlOrRepo, ref },
392
- autoUpdate: false,
393
470
  lastUpdated: new Date().toISOString(),
394
471
  };
395
472
  }
396
473
  else {
397
474
  const absolutePath = path.resolve(input);
475
+ await this.assertMarketplaceAddable({
476
+ source: "directory",
477
+ path: absolutePath,
478
+ });
398
479
  let manifest;
399
480
  try {
400
481
  manifest = await this.loadMarketplaceManifest(absolutePath);
@@ -405,19 +486,18 @@ export class MarketplaceService {
405
486
  marketplace = {
406
487
  name: manifest.name,
407
488
  source: { source: "directory", path: absolutePath },
408
- autoUpdate: false,
409
489
  lastUpdated: new Date().toISOString(),
410
490
  };
411
491
  }
492
+ // 名称来自市场自身清单,加载后才知道 → 同名判重放在这里(来源判重已在分支内)
493
+ await this.assertMarketplaceAddable(marketplace.source, marketplace.name);
412
494
  const config = {
413
495
  source: marketplace.source,
414
- autoUpdate: marketplace.autoUpdate,
415
496
  };
416
497
  await this.configurationService.addMarketplaceToScope(this.workdir, scope, marketplace.name, config);
417
498
  // Update cache with metadata
418
499
  await this.updateCacheMarketplace(marketplace.name, {
419
500
  source: marketplace.source,
420
- autoUpdate: marketplace.autoUpdate,
421
501
  lastUpdated: marketplace.lastUpdated,
422
502
  });
423
503
  return marketplace;
@@ -497,8 +577,18 @@ export class MarketplaceService {
497
577
  }
498
578
  /**
499
579
  * Updates a specific marketplace or all marketplaces
580
+ *
581
+ * `refreshCheckout` (default true) pulls the checkout from its upstream; pass
582
+ * false to upgrade plugins against the current checkout only — the interactive
583
+ * "批量更新插件" path does that, because opening the plugin-marketplace surface
584
+ * already refreshed the checkout (spec 插件市场 A-013).
585
+ *
586
+ * Returns the number of installed plugins whose version actually changed
587
+ * during this run (0 = everything was already up to date) — the GUI hosts
588
+ * use it to tell "已更新 N 个插件" apart from "已是最新" (spec 插件市场 场景 13).
500
589
  */
501
590
  async updateMarketplace(name, options) {
591
+ const refreshCheckout = options?.refreshCheckout ?? true;
502
592
  return this.withLock(async () => {
503
593
  const marketplaces = await this.listMarketplaces();
504
594
  const toUpdate = name
@@ -509,65 +599,76 @@ export class MarketplaceService {
509
599
  }
510
600
  const isGitAvailable = await this.gitService.isGitAvailable();
511
601
  const errors = [];
602
+ let upgradedPlugins = 0;
512
603
  for (const marketplace of toUpdate) {
513
604
  try {
514
- // Builtin official marketplace: prefer the zip-snapshot mirror
515
- // (content-addressed zip over a plain HTTP base, no git/GitHub
516
- // needed). Only the builtin is special-cased by name — its
517
- // `source` stays "github" in settings/cache, so there is zero
518
- // data migration. On mirror failure (e.g. prod URL not yet live)
519
- // fall back to the git path unless the kill switch forbids it.
520
- const isOfficialBuiltin = marketplace.name === MarketplaceService.BUILTIN_MARKETPLACE.name;
521
- let mirrorUpdated = false;
522
- if (isOfficialBuiltin) {
523
- const targetPath = this.getMarketplacePath(marketplace.source);
524
- const mirrorSha = await fetchOfficialMarketplaceFromMirror(targetPath, this.marketplacesDir);
525
- if (mirrorSha !== null) {
526
- mirrorUpdated = true;
527
- }
528
- else if (!ALLOW_OFFICIAL_MARKET_GIT_FALLBACK) {
529
- logWarn(`Skipping update for official marketplace "${marketplace.name}": mirror fetch failed and git fallback is disabled.`);
530
- continue;
531
- }
532
- }
533
- if (marketplace.source.source === "github" ||
534
- marketplace.source.source === "git") {
535
- if (!mirrorUpdated) {
536
- if (!isGitAvailable) {
537
- logWarn(`Skipping update for Git/GitHub marketplace "${marketplace.name}" because Git is not installed.`);
538
- continue;
605
+ const targetPath = this.getMarketplacePath(marketplace.source);
606
+ if (refreshCheckout) {
607
+ // Builtin official marketplace: prefer the zip-snapshot mirror
608
+ // (content-addressed zip over a plain HTTP base, no git/GitHub
609
+ // needed). Only the builtin is special-cased by name — its
610
+ // `source` stays "github" in settings/cache, so there is zero
611
+ // data migration. Both envs' mirror channels are live and carry
612
+ // content (verified 2026-09-23); on mirror failure
613
+ // (network/timeout/404/bad content) fall back to the git path
614
+ // unless the kill switch forbids it.
615
+ const isOfficialBuiltin = marketplace.name === MarketplaceService.BUILTIN_MARKETPLACE.name;
616
+ let mirrorUpdated = false;
617
+ if (isOfficialBuiltin) {
618
+ const mirrorSha = await fetchOfficialMarketplaceFromMirror(targetPath, this.marketplacesDir);
619
+ if (mirrorSha !== null) {
620
+ mirrorUpdated = true;
539
621
  }
540
- const targetPath = this.getMarketplacePath(marketplace.source);
541
- if (existsSync(targetPath)) {
542
- await this.gitService.pull(targetPath);
622
+ else if (!ALLOW_OFFICIAL_MARKET_GIT_FALLBACK) {
623
+ logWarn(`Skipping update for official marketplace "${marketplace.name}": mirror fetch failed and git fallback is disabled.`);
624
+ continue;
543
625
  }
544
- else {
545
- let url;
546
- if (marketplace.source.source === "github") {
547
- url = marketplace.source.repo;
626
+ }
627
+ if (marketplace.source.source === "github" ||
628
+ marketplace.source.source === "git") {
629
+ if (!mirrorUpdated) {
630
+ if (!isGitAvailable) {
631
+ logWarn(`Skipping update for Git/GitHub marketplace "${marketplace.name}" because Git is not installed.`);
632
+ continue;
633
+ }
634
+ if (existsSync(targetPath)) {
635
+ await this.gitService.pull(targetPath);
548
636
  }
549
637
  else {
550
- url = marketplace.source.url;
638
+ let url;
639
+ if (marketplace.source.source === "github") {
640
+ url = marketplace.source.repo;
641
+ }
642
+ else {
643
+ url = marketplace.source.url;
644
+ }
645
+ await this.gitService.clone(url, targetPath, marketplace.source.ref);
551
646
  }
552
- await this.gitService.clone(url, targetPath, marketplace.source.ref);
553
647
  }
554
648
  }
555
649
  }
556
- const manifest = await this.loadMarketplaceManifest(this.getMarketplacePath(marketplace.source));
557
- marketplace.lastUpdated = new Date().toISOString();
558
- // Update cache metadata
559
- await this.updateCacheMarketplace(marketplace.name, {
560
- lastUpdated: marketplace.lastUpdated,
561
- });
650
+ const manifest = await this.loadMarketplaceManifest(targetPath);
651
+ // 只在真的刷了检出时记账:跳过刷新时把 lastUpdated 顶到当前时间会
652
+ // 让 CLI 的市场列表谎报「上次更新」时间。
653
+ if (refreshCheckout) {
654
+ marketplace.lastUpdated = new Date().toISOString();
655
+ // Update cache metadata
656
+ await this.updateCacheMarketplace(marketplace.name, {
657
+ lastUpdated: marketplace.lastUpdated,
658
+ });
659
+ }
562
660
  if (options?.updatePlugins) {
563
661
  const installedRegistry = await this.getInstalledPlugins();
564
662
  const pluginsToUpdate = installedRegistry.plugins.filter((p) => p.marketplace === marketplace.name);
663
+ // 升级前版本快照:安装完成后比对,只有版本真的变了的才算「已更新」
664
+ // (重新安装同版本 = 无更新,宿主据此提示「已是最新」)。
665
+ const versionBefore = new Map(pluginsToUpdate.map((p) => [p.name, p.version]));
565
666
  for (const plugin of pluginsToUpdate) {
566
667
  const pluginEntry = manifest.plugins.find((p) => p.name === plugin.name);
567
668
  if (!pluginEntry) {
568
669
  logger.warn(`Plugin "${plugin.name}" no longer found in marketplace "${marketplace.name}". Uninstalling...`);
569
670
  try {
570
- await this.uninstallPlugin(`${plugin.name}@${plugin.marketplace}`, plugin.projectPath);
671
+ await this.uninstallPlugin(`${plugin.name}@${plugin.marketplace}`, this.locationOf(plugin));
571
672
  }
572
673
  catch (error) {
573
674
  logError(`Failed to uninstall orphaned plugin "${plugin.name}" from marketplace "${marketplace.name}":`, error);
@@ -575,12 +676,21 @@ export class MarketplaceService {
575
676
  continue;
576
677
  }
577
678
  try {
578
- await this.installPlugin(`${plugin.name}@${plugin.marketplace}`, plugin.projectPath);
679
+ await this.installPlugin(`${plugin.name}@${plugin.marketplace}`, this.locationOf(plugin));
579
680
  }
580
681
  catch (error) {
581
682
  logError(`Failed to update plugin "${plugin.name}" from marketplace "${marketplace.name}":`, error);
582
683
  }
583
684
  }
685
+ const afterRegistry = await this.getInstalledPlugins();
686
+ for (const plugin of afterRegistry.plugins) {
687
+ if (plugin.marketplace !== marketplace.name)
688
+ continue;
689
+ const before = versionBefore.get(plugin.name);
690
+ if (before !== undefined && before !== plugin.version) {
691
+ upgradedPlugins++;
692
+ }
693
+ }
584
694
  }
585
695
  }
586
696
  catch (error) {
@@ -592,53 +702,63 @@ export class MarketplaceService {
592
702
  if (errors.length > 0) {
593
703
  throw new Error(`Some marketplaces failed to update:\n${errors.join("\n")}`);
594
704
  }
705
+ return upgradedPlugins;
595
706
  });
596
707
  }
597
708
  /**
598
- * Automatically updates all marketplaces that have auto-update enabled
709
+ * 交互界面的「批量更新插件」:按当前清单把该市场内所有已安装且有新版本的插件
710
+ * 升级到最新,**不拉取检出**——清单的新鲜度由打开插件市场界面时的那次刷新保证
711
+ * (spec 插件市场 A-013/场景 14)。检出尚未据此刷新的场景(无界面可依赖的非交互
712
+ * 命令)走 [updateMarketplace] 的默认行为。
599
713
  */
600
- async autoUpdateAll() {
601
- return this.withLock(async () => {
602
- const scopedMarketplaces = this.configurationService.getMergedMarketplaces(this.workdir);
603
- const toAutoUpdate = Object.entries(scopedMarketplaces)
604
- .filter(([, config]) => config.autoUpdate)
605
- .map(([name]) => name);
606
- for (const marketplaceName of toAutoUpdate) {
607
- try {
608
- await this.updateMarketplace(marketplaceName, {
609
- updatePlugins: true,
610
- });
611
- }
612
- catch (error) {
613
- logError(`Auto-update failed for marketplace "${marketplaceName}":`, error);
614
- }
615
- }
714
+ async updateMarketplacePlugins(name) {
715
+ return await this.updateMarketplace(name, {
716
+ updatePlugins: true,
717
+ refreshCheckout: false,
616
718
  });
617
719
  }
618
720
  /**
619
- * Toggles auto-update for a marketplace
721
+ * Refreshes the local checkout of every registered marketplace.
722
+ *
723
+ * Callers are the plugin-marketplace surfaces (GUI settings view / CLI plugin
724
+ * manager), refreshed when the user opens them — the agent-startup path no
725
+ * longer refreshes anything (spec 插件市场 A-013 场景 5).
726
+ *
727
+ * This is a fixed behavior with no opt-in/opt-out: `autoUpdate` no longer
728
+ * exists, so all marketplaces are refreshed alike (A-014). Refreshing only
729
+ * updates the checkout — installed plugins are never reinstalled here, so a
730
+ * newer plugin version surfaces as an explicit "更新" (per plugin) /
731
+ * "批量更新插件" (batch) action instead of being applied behind the user's
732
+ * back.
733
+ *
734
+ * Single-flight: a call landing while a refresh is running reuses it instead
735
+ * of pulling every checkout twice (spec 场景 13).
620
736
  */
621
- async toggleAutoUpdate(name, enabled) {
622
- return this.withLock(async () => {
623
- const declaringSource = this.getMarketplaceDeclaringSource(name);
624
- if (!declaringSource || declaringSource === "builtin") {
625
- throw new Error(`Marketplace ${name} not found`);
626
- }
627
- const scoped = this.configurationService.getScopedMarketplaces(this.workdir, declaringSource);
628
- const config = scoped[name];
629
- if (!config) {
630
- throw new Error(`Marketplace ${name} not found`);
737
+ async refreshMarketplaces() {
738
+ if (this.refreshInFlight)
739
+ return this.refreshInFlight;
740
+ this.refreshInFlight = this.withLock(async () => {
741
+ const marketplaces = this.configurationService.getMergedMarketplaces(this.workdir);
742
+ for (const marketplaceName of Object.keys(marketplaces)) {
743
+ try {
744
+ await this.updateMarketplace(marketplaceName);
745
+ }
746
+ catch (error) {
747
+ logError(`Marketplace refresh failed for "${marketplaceName}":`, error);
748
+ }
631
749
  }
632
- config.autoUpdate = enabled;
633
- await this.configurationService.addMarketplaceToScope(this.workdir, declaringSource, name, config);
634
- // Also update cache
635
- await this.updateCacheMarketplace(name, { autoUpdate: enabled });
750
+ }).finally(() => {
751
+ this.refreshInFlight = null;
636
752
  });
753
+ return this.refreshInFlight;
637
754
  }
638
755
  /**
639
756
  * Installs a plugin from a marketplace
757
+ *
758
+ * @param location 该次安装记账的位置(spec plugin A-015)。缺省只在内部调用
759
+ * (宿主加载时补装)出现,此时写入一条不带作用域的历史形态记录。
640
760
  */
641
- async installPlugin(pluginAtMarketplace, projectPath) {
761
+ async installPlugin(pluginAtMarketplace, location) {
642
762
  return this.withLock(async () => {
643
763
  const [pluginName, marketplaceName] = pluginAtMarketplace.split("@");
644
764
  if (!pluginName || !marketplaceName) {
@@ -655,25 +775,32 @@ export class MarketplaceService {
655
775
  if (!pluginEntry) {
656
776
  throw new Error(`Plugin ${pluginName} not found in marketplace ${marketplaceName}`);
657
777
  }
658
- const isGitSource = pluginEntry.source.startsWith("http://") ||
659
- pluginEntry.source.startsWith("https://") ||
660
- pluginEntry.source.startsWith("git@") ||
661
- pluginEntry.source.startsWith("ssh://");
778
+ const parsedSource = parsePluginSource(pluginEntry.source);
779
+ if (!parsedSource) {
780
+ throw new Error(`Plugin ${pluginName} in marketplace ${marketplaceName} declares an unsupported source: ${describePluginSource(pluginEntry.source)}`);
781
+ }
782
+ const isGitSource = parsedSource.kind === "git";
662
783
  let pluginSrcPath;
663
784
  let tempCloneDir;
664
785
  try {
665
- if (isGitSource) {
786
+ if (parsedSource.kind === "git") {
666
787
  tempCloneDir = path.join(this.tmpDir, `clone-${Date.now()}`);
667
- let url = pluginEntry.source;
668
- let ref;
669
- if (url.includes("#")) {
670
- [url, ref] = url.split("#");
788
+ await this.gitService.clone(parsedSource.url, tempCloneDir, parsedSource.ref);
789
+ if (parsedSource.sha) {
790
+ // Without this a `ref` would drift with its branch; a sha that cannot
791
+ // be checked out fails loudly instead of silently using the branch tip
792
+ // (spec plugin A-021 场景 3).
793
+ await this.gitService.checkout(tempCloneDir, parsedSource.sha);
794
+ }
795
+ pluginSrcPath = parsedSource.subdir
796
+ ? path.join(tempCloneDir, parsedSource.subdir)
797
+ : tempCloneDir;
798
+ if (parsedSource.subdir && !existsSync(pluginSrcPath)) {
799
+ throw new Error(`Subdirectory '${parsedSource.subdir}' not found in repository ${parsedSource.url}`);
671
800
  }
672
- await this.gitService.clone(url, tempCloneDir, ref);
673
- pluginSrcPath = tempCloneDir;
674
801
  }
675
802
  else {
676
- pluginSrcPath = path.resolve(marketplacePath, pluginEntry.source);
803
+ pluginSrcPath = path.resolve(marketplacePath, parsedSource.path);
677
804
  }
678
805
  let pluginManifestPath = path.join(pluginSrcPath, ".wave-plugin", "plugin.json");
679
806
  if (!existsSync(pluginManifestPath)) {
@@ -685,7 +812,7 @@ export class MarketplaceService {
685
812
  }
686
813
  const pluginManifestContent = await fs.readFile(pluginManifestPath, "utf-8");
687
814
  const pluginManifest = JSON.parse(pluginManifestContent);
688
- const version = pluginManifest.version || "1.0.0";
815
+ const version = pluginManifest.version || DEFAULT_PLUGIN_VERSION;
689
816
  const tmpPluginDir = path.join(this.tmpDir, `${pluginName}-${Date.now()}`);
690
817
  try {
691
818
  if (isGitSource) {
@@ -704,13 +831,14 @@ export class MarketplaceService {
704
831
  const installedRegistry = await this.getInstalledPlugins();
705
832
  const existingIndex = installedRegistry.plugins.findIndex((p) => p.name === pluginName &&
706
833
  p.marketplace === marketplaceName &&
707
- p.projectPath === projectPath);
834
+ (location ? this.matchesInstallLocation(p, location) : !p.scope));
708
835
  const installedPlugin = {
709
836
  name: pluginName,
710
837
  marketplace: marketplaceName,
711
838
  version,
712
839
  cachePath,
713
- projectPath,
840
+ scope: location?.scope,
841
+ projectPath: location?.projectPath,
714
842
  };
715
843
  if (existingIndex >= 0) {
716
844
  installedRegistry.plugins[existingIndex] = installedPlugin;
@@ -738,45 +866,116 @@ export class MarketplaceService {
738
866
  }
739
867
  /**
740
868
  * Uninstalls a plugin
869
+ *
870
+ * With a [location], only that scope's install record is removed — other
871
+ * scopes (including other projects' project/local records) keep theirs, and
872
+ * the shared cache directory is deleted only once no record references it
873
+ * (spec plugin A-015). Without one, every record of the plugin is removed
874
+ * (used by [updatePlugin] and the marketplace-level cleanup).
741
875
  */
742
- async uninstallPlugin(pluginAtMarketplace, projectPath) {
876
+ async uninstallPlugin(pluginAtMarketplace, location) {
743
877
  return this.withLock(async () => {
744
878
  const [pluginName, marketplaceName] = pluginAtMarketplace.split("@");
745
879
  if (!pluginName || !marketplaceName) {
746
880
  throw new Error("Invalid plugin format. Use name@marketplace");
747
881
  }
748
882
  const installedRegistry = await this.getInstalledPlugins();
749
- const pluginIndex = installedRegistry.plugins.findIndex((p) => p.name === pluginName &&
750
- p.marketplace === marketplaceName &&
751
- p.projectPath === projectPath);
752
- if (pluginIndex === -1) {
753
- throw new Error(`Plugin ${pluginName}@${marketplaceName} is not installed${projectPath ? ` for project ${projectPath}` : ""}`);
754
- }
755
- const pluginToRemove = installedRegistry.plugins[pluginIndex];
756
- installedRegistry.plugins.splice(pluginIndex, 1);
883
+ const allRecords = installedRegistry.plugins.filter((p) => p.name === pluginName && p.marketplace === marketplaceName);
884
+ const pluginToRemove = allRecords.filter((p) => !location || this.matchesInstallLocation(p, location));
885
+ if (pluginToRemove.length === 0) {
886
+ const installedAt = this.describeInstallLocations(allRecords);
887
+ throw new Error(location
888
+ ? `Plugin ${pluginName}@${marketplaceName} is not installed in ${location.scope} scope${location.projectPath ? ` for project ${location.projectPath}` : ""}${installedAt ? `. Installed at: ${installedAt}` : ""}`
889
+ : `Plugin ${pluginName}@${marketplaceName} is not installed`);
890
+ }
891
+ const removed = new Set(pluginToRemove);
892
+ installedRegistry.plugins = installedRegistry.plugins.filter((p) => !removed.has(p));
757
893
  await this.saveInstalledPlugins(installedRegistry);
758
- const isStillReferenced = installedRegistry.plugins.some((p) => p.cachePath === pluginToRemove.cachePath);
759
- if (!isStillReferenced && existsSync(pluginToRemove.cachePath)) {
760
- await fs.rm(pluginToRemove.cachePath, { recursive: true, force: true });
894
+ for (const plugin of pluginToRemove) {
895
+ const isStillReferenced = installedRegistry.plugins.some((p) => p.cachePath === plugin.cachePath);
896
+ if (!isStillReferenced && existsSync(plugin.cachePath)) {
897
+ await fs.rm(plugin.cachePath, { recursive: true, force: true });
898
+ }
899
+ }
900
+ });
901
+ }
902
+ /**
903
+ * Moves a plugin's install record to another location(「更换安装作用域」):
904
+ * the record currently at [from] (or the plugin's only record when [from] is
905
+ * omitted or unknown) is rewritten to [to], so that uninstalling the plugin
906
+ * at its new scope still finds it (spec plugin A-015). A duplicate record
907
+ * already at [to] wins and the moved one is dropped.
908
+ */
909
+ async relocatePlugin(pluginAtMarketplace, from, to) {
910
+ return this.withLock(async () => {
911
+ const [pluginName, marketplaceName] = pluginAtMarketplace.split("@");
912
+ if (!pluginName || !marketplaceName) {
913
+ throw new Error("Invalid plugin format. Use name@marketplace");
914
+ }
915
+ const installedRegistry = await this.getInstalledPlugins();
916
+ const records = installedRegistry.plugins.filter((p) => p.name === pluginName && p.marketplace === marketplaceName);
917
+ const source = from
918
+ ? records.find((p) => this.matchesInstallLocation(p, from))
919
+ : records[0];
920
+ if (!source)
921
+ return;
922
+ const duplicate = records.find((p) => p !== source && this.matchesInstallLocation(p, to));
923
+ if (duplicate) {
924
+ installedRegistry.plugins = installedRegistry.plugins.filter((p) => p !== source);
761
925
  }
926
+ else {
927
+ source.scope = to.scope;
928
+ source.projectPath = to.projectPath;
929
+ }
930
+ await this.saveInstalledPlugins(installedRegistry);
762
931
  });
763
932
  }
764
933
  /**
765
- * Updates a plugin (uninstall followed by install)
934
+ * Updates a plugin (uninstall followed by install). Every install record of
935
+ * the plugin is re-created at its original location, so the install scopes
936
+ * stay unchanged (spec plugin「设置页插件市场」场景 12 / A-015).
766
937
  */
767
938
  async updatePlugin(pluginAtMarketplace) {
768
939
  return this.withLock(async () => {
940
+ const [pluginName, marketplaceName] = pluginAtMarketplace.split("@");
941
+ const before = await this.getInstalledPlugins();
942
+ const locations = before.plugins
943
+ .filter((p) => p.name === pluginName && p.marketplace === marketplaceName)
944
+ .map((p) => this.locationOf(p));
769
945
  await this.uninstallPlugin(pluginAtMarketplace);
770
- return this.installPlugin(pluginAtMarketplace);
946
+ const installed = await this.installPlugin(pluginAtMarketplace, locations[0] ?? { scope: "user" });
947
+ if (locations.length > 1) {
948
+ const restored = await this.getInstalledPlugins();
949
+ for (const location of locations.slice(1)) {
950
+ restored.plugins.push({
951
+ name: installed.name,
952
+ marketplace: installed.marketplace,
953
+ version: installed.version,
954
+ cachePath: installed.cachePath,
955
+ scope: location.scope,
956
+ projectPath: location.projectPath,
957
+ });
958
+ }
959
+ await this.saveInstalledPlugins(restored);
960
+ }
961
+ return installed;
771
962
  });
772
963
  }
773
964
  }
774
- MarketplaceService.isLockedInProcess = false;
965
+ /**
966
+ * 已持有市场操作锁的调用链标记(重入判据)。
967
+ *
968
+ * 用 AsyncLocalStorage 而不是进程内布尔值:锁内发起的嵌套调用(如
969
+ * refreshMarketplaces → updateMarketplace → installPlugin)必须跳过再次加锁,
970
+ * 否则会自锁;但**另一条并发调用链**必须老老实实排队。布尔值无法区分这两者
971
+ * ——第二个并发调用会以为「已在锁内」而跳过锁直接执行,于是两个操作并行改同一
972
+ * 个市场检出(并发两次 `git pull` 同一检出必然失败,spec 插件市场 场景 14)。
973
+ */
974
+ MarketplaceService.lockChain = new AsyncLocalStorage();
775
975
  MarketplaceService.BUILTIN_MARKETPLACE = {
776
976
  name: "wave-plugins-official",
777
977
  source: {
778
978
  source: "github",
779
979
  repo: "netease-lcap/wave-plugins-official",
780
980
  },
781
- autoUpdate: true,
782
981
  };