dsh-agent-plugins-market 0.5.3 → 0.6.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 (86) hide show
  1. package/README.md +29 -3
  2. package/README.zh.md +29 -3
  3. package/client/client.js +1378 -202
  4. package/client/client.js.map +1 -1
  5. package/lib/application/catalog.js +537 -71
  6. package/lib/application/details.js +39 -12
  7. package/lib/catalog/archive.js +341 -0
  8. package/lib/catalog/git.js +85 -12
  9. package/lib/catalog/legacy-root-migration.js +51 -0
  10. package/lib/catalog/lsp-spec.js +87 -0
  11. package/lib/catalog/manifests.js +84 -32
  12. package/lib/catalog/paths.js +65 -7
  13. package/lib/catalog/scan-pipeline.js +50 -0
  14. package/lib/catalog/scan-resolvers.js +402 -0
  15. package/lib/catalog/source-catalog.js +22 -8
  16. package/lib/catalog/suite-scanner.js +5 -176
  17. package/lib/catalog/surfaces.js +80 -30
  18. package/lib/contracts/lsp-status.js +2 -0
  19. package/lib/contracts/market.js +8 -1
  20. package/lib/index.js +118 -13
  21. package/lib/model/state.js +14 -1
  22. package/lib/model/types.js +21 -2
  23. package/lib/routes.js +88 -6
  24. package/lib/runtime/commands-mounts.js +6 -2
  25. package/lib/runtime/hooks-mounts.js +9 -6
  26. package/lib/runtime/lsp-direct-config.js +44 -0
  27. package/lib/runtime/lsp-mounts.js +236 -0
  28. package/lib/runtime/lsp-status.js +96 -0
  29. package/lib/runtime/mcp-backend.js +60 -0
  30. package/lib/runtime/mcp-client/bridge.js +126 -0
  31. package/lib/runtime/mcp-client/config.js +42 -0
  32. package/lib/runtime/mcp-client/connection.js +380 -0
  33. package/lib/runtime/mcp-client/host-seams.js +88 -0
  34. package/lib/runtime/mcp-client/json-schema-subset.js +181 -0
  35. package/lib/runtime/mcp-client/oauth.js +379 -0
  36. package/lib/runtime/mcp-client/tools.js +432 -0
  37. package/lib/runtime/mcp-client/transport.js +103 -0
  38. package/lib/runtime/mcp-config.js +196 -54
  39. package/lib/runtime/mcp-credentials.js +20 -0
  40. package/lib/runtime/mcp-mounts.js +237 -27
  41. package/lib/runtime/mcp-overrides.js +64 -6
  42. package/lib/runtime/mcp-redaction.js +92 -0
  43. package/lib/runtime/mcp-status.js +76 -32
  44. package/lib/runtime/reconciler.js +35 -1
  45. package/lib/runtime/regions.js +56 -0
  46. package/lib/types/application/catalog.d.ts +191 -14
  47. package/lib/types/application/details.d.ts +1 -6
  48. package/lib/types/application/queries.d.ts +28 -1
  49. package/lib/types/catalog/archive.d.ts +48 -0
  50. package/lib/types/catalog/git.d.ts +27 -3
  51. package/lib/types/catalog/legacy-root-migration.d.ts +14 -0
  52. package/lib/types/catalog/lsp-spec.d.ts +14 -0
  53. package/lib/types/catalog/manifests.d.ts +16 -1
  54. package/lib/types/catalog/paths.d.ts +23 -2
  55. package/lib/types/catalog/scan-pipeline.d.ts +76 -0
  56. package/lib/types/catalog/scan-resolvers.d.ts +81 -0
  57. package/lib/types/catalog/source-catalog.d.ts +5 -0
  58. package/lib/types/catalog/suite-scanner.d.ts +14 -7
  59. package/lib/types/catalog/surfaces.d.ts +3 -3
  60. package/lib/types/contracts/lsp-status.d.ts +53 -0
  61. package/lib/types/contracts/market.d.ts +55 -1
  62. package/lib/types/contracts/mcp-status.d.ts +14 -2
  63. package/lib/types/index.d.ts +24 -8
  64. package/lib/types/model/types.d.ts +52 -3
  65. package/lib/types/runtime/lsp-direct-config.d.ts +12 -0
  66. package/lib/types/runtime/lsp-mounts.d.ts +89 -0
  67. package/lib/types/runtime/lsp-status.d.ts +30 -0
  68. package/lib/types/runtime/mcp-backend.d.ts +49 -0
  69. package/lib/types/runtime/mcp-client/bridge.d.ts +37 -0
  70. package/lib/types/runtime/mcp-client/config.d.ts +123 -0
  71. package/lib/types/runtime/mcp-client/connection.d.ts +82 -0
  72. package/lib/types/runtime/mcp-client/host-seams.d.ts +89 -0
  73. package/lib/types/runtime/mcp-client/json-schema-subset.d.ts +58 -0
  74. package/lib/types/runtime/mcp-client/oauth.d.ts +121 -0
  75. package/lib/types/runtime/mcp-client/tools.d.ts +136 -0
  76. package/lib/types/runtime/mcp-client/transport.d.ts +36 -0
  77. package/lib/types/runtime/mcp-config.d.ts +29 -12
  78. package/lib/types/runtime/mcp-credentials.d.ts +9 -0
  79. package/lib/types/runtime/mcp-mounts.d.ts +56 -6
  80. package/lib/types/runtime/mcp-overrides.d.ts +23 -4
  81. package/lib/types/runtime/mcp-redaction.d.ts +7 -0
  82. package/lib/types/runtime/mcp-status.d.ts +4 -2
  83. package/lib/types/runtime/reconciler.d.ts +22 -4
  84. package/lib/types/runtime/regions.d.ts +37 -0
  85. package/package.json +15 -11
  86. package/schemas/1.0.0/mcp.schema.json +22 -0
@@ -7,18 +7,26 @@
7
7
  * project snapshots use their own persisted state files and share the same
8
8
  * source-selection and suite-scanning rules.
9
9
  */
10
- import { mkdir } from 'node:fs/promises';
10
+ import { mkdir, readdir, rename } from 'node:fs/promises';
11
11
  import { join } from 'node:path';
12
12
  import { repoName } from '../catalog/manifests.js';
13
- import { discoverSourceList } from '../catalog/source-catalog.js';
13
+ import { canonicalGitUrl } from '../catalog/scan-resolvers.js';
14
+ import { discoverSourceListWithNotes } from '../catalog/source-catalog.js';
14
15
  import { discoverSuitesInSource } from '../catalog/suite-scanner.js';
15
- import { gitClone, gitHead, gitPull, gitRemove } from '../catalog/git.js';
16
+ import { archiveInstall } from '../catalog/archive.js';
17
+ import { gitClone, gitCurrentBranch, gitHead, gitRemoteUrl, gitRemove, gitSetRemoteUrl, gitSync } from '../catalog/git.js';
16
18
  import { buildMcpStatus } from '../runtime/mcp-status.js';
17
- import { loadSuiteOverrides, saveSuiteOverrides } from '../runtime/mcp-overrides.js';
19
+ import { buildLspStatus } from '../runtime/lsp-status.js';
20
+ import { loadLspServers, saveLspServers } from '../runtime/lsp-direct-config.js';
21
+ import { loadSuiteOverrides, mergeOverridePatch, saveSuiteOverrides } from '../runtime/mcp-overrides.js';
22
+ import { probeHostMcpClient } from '../runtime/mcp-backend.js';
23
+ import { githubCloneUrl, resolveRegion } from '../runtime/regions.js';
24
+ import { readLocalePreference } from '../runtime/host-locale.js';
25
+ import { redactMcpOverrides } from '../runtime/mcp-redaction.js';
18
26
  import { buildSuiteDetail, readSkillContent } from './details.js';
19
- import { deriveSourceId, expandHome, isDirectory, resolveProjectRoot, sanitizeId, sourceCheckoutDir, sourcesDir, STATE_FILE_NAME } from '../catalog/paths.js';
27
+ import { deriveSourceIdCandidates, expandHome, isDirectory, pathExists, qualifiedSuiteId, resolveProjectRoot, sanitizeId, sourceCheckoutDir, sourcesDir, STATE_FILE_NAME } from '../catalog/paths.js';
20
28
  import { loadState, saveState, EMPTY_STATE } from '../model/state.js';
21
- import { effectiveSurfaces, SUITE_SURFACE_KEYS } from '../model/types.js';
29
+ import { effectiveSurfaces, SUITE_SURFACE_KEYS, resolveSourceKind } from '../model/types.js';
22
30
  export class Catalog {
23
31
  options;
24
32
  state = EMPTY_STATE;
@@ -28,23 +36,102 @@ export class Catalog {
28
36
  listeners = new Set();
29
37
  revision = 0;
30
38
  userSnapshot;
39
+ userSnapshotExpiresAt = 0;
31
40
  userSnapshotPromise;
41
+ /**
42
+ * Discovery scan cache: per-sources-fingerprint scan results with a TTL.
43
+ * Install / enable / surface toggles re-derive the snapshot from cached
44
+ * discovery (cheap mapping) instead of re-walking every checkout — a full
45
+ * rescan of a 2,500-suite catalog costs ~1s and UI mutations happen in
46
+ * bursts. Content-changing mutations (add / update / remove / adopt /
47
+ * refresh / acquire) set {@link scanCacheDirty}, which bypasses the cache
48
+ * until the next scan; the TTL bounds staleness for in-place working-tree
49
+ * edits of local sources.
50
+ */
51
+ scanCache = new Map();
52
+ scanCacheDirty = true;
53
+ static SCAN_CACHE_TTL_MS = 30_000;
54
+ static SCAN_CACHE_MAX_ENTRIES = 8;
55
+ /**
56
+ * User-dimension snapshot TTL: bounded staleness for edits made outside the
57
+ * plugin (a new skill dropped into a local source's working tree, a hand
58
+ * edit in a project layout) becomes visible on the next read past the TTL
59
+ * instead of living until the next mutation. Configurable; a 0 value
60
+ * disables snapshot caching entirely.
61
+ */
62
+ userSnapshotTtlMs;
32
63
  /** Project-dimension snapshots keyed by project root, with TTL freshness. */
33
64
  projectSnapshots = new Map();
34
65
  projectSnapshotPromises = new Map();
35
66
  projectSnapshotTtlMs;
36
67
  /** Latest MCP mount diagnostics (suiteId -> reasons), fed by host reconcile. */
37
68
  mcpDiagnostics = [];
69
+ /** Latest LSP mount diagnostics source, wired by the host entry. */
70
+ lspStatusSource;
71
+ /** Credential-store seam for dropping a grant record (MCP re-authorize). */
72
+ credentialsStore;
38
73
  toolSnapshotProvider = () => [];
74
+ /** Mount-registry seam: flags a mount key for an explicit rebuild (re-authorize). */
75
+ mcpRemount = () => { };
76
+ /** Resolves the mount key owning one derived serverName; absent when the server is not mounted. */
77
+ mcpServerOwner = () => undefined;
78
+ /** Backend source (the host settings namespace scope); default is the built-in client. */
79
+ mcpBackendProvider = async () => 'builtin';
80
+ /** Backend writer (a scope update); absent until the settings service resolves. */
81
+ mcpBackendWriter = async () => {
82
+ throw new Error('the settings service is not mounted');
83
+ };
84
+ /** Download-region source (the host settings namespace scope). */
85
+ downloadRegionProvider = async () => 'auto';
39
86
  constructor(options) {
40
87
  this.options = options;
41
88
  this.statePath = join(options.userRoot, STATE_FILE_NAME);
42
89
  this.projectSnapshotTtlMs = options.projectSnapshotTtlMs ?? 5_000;
90
+ this.userSnapshotTtlMs = options.userSnapshotTtlMs ?? Catalog.SCAN_CACHE_TTL_MS;
43
91
  }
44
92
  /** Install the host tool snapshot provider used by the MCP status surface. */
45
93
  setMcpToolSnapshotProvider(provider) {
46
94
  this.toolSnapshotProvider = provider;
47
95
  }
96
+ /** Install the credential-store seam backing the MCP re-authorize action. */
97
+ setCredentialsStore(store) {
98
+ this.credentialsStore = store;
99
+ }
100
+ /**
101
+ * Drop one MCP server's OAuth grant record so the next mount re-runs the
102
+ * browser authorization — the path for "I picked too narrow a scope".
103
+ * @param serverName - the derived serverName whose folded form keys the record.
104
+ * @throws when the credentials service is not mounted.
105
+ */
106
+ async reauthorizeMcpServer(serverName) {
107
+ await this.enqueue(async () => {
108
+ await this.credentialsStore?.deleteGrantRecord(serverName);
109
+ // Dropping a grant changes no resolved config field, so a fingerprint
110
+ // reconcile would keep the live bridge — with its in-memory tokens —
111
+ // untouched. The serverName maps back to exactly one mount key (the
112
+ // registry reserved the name), so flag it for an explicit rebuild.
113
+ const key = this.mcpServerOwner(serverName);
114
+ if (key !== undefined)
115
+ this.mcpRemount?.(key.suiteId, key.serverKey);
116
+ await this.notifyChanged(true);
117
+ });
118
+ }
119
+ /**
120
+ * Install the MCP remount seam used by re-authorize: the registry flags the
121
+ * mount so the next reconcile tears the live bridge down and rebuilds it.
122
+ */
123
+ setMcpRemountHook(hook, serverOwner) {
124
+ this.mcpRemount = hook;
125
+ this.mcpServerOwner = serverOwner;
126
+ }
127
+ /** Whether the re-authorize action can run in this composition. */
128
+ mcpReauthorizeAvailable() {
129
+ return this.credentialsStore !== undefined;
130
+ }
131
+ /** Install the LSP mount status source used by the LSP status surface. */
132
+ setLspStatusSource(source) {
133
+ this.lspStatusSource = source;
134
+ }
48
135
  /** Subscribe to completed catalog mutations; returns an unsubscribe function. */
49
136
  subscribe(listener) {
50
137
  this.listeners.add(listener);
@@ -55,12 +142,31 @@ export class Catalog {
55
142
  const snapshot = await this.readUserCatalog();
56
143
  return buildMcpStatus(snapshot.suites, this.mcpDiagnostics, this.toolSnapshotProvider(), await this.allMcpOverrides());
57
144
  }
58
- /** One suite's persisted MCP overrides. */
59
- async mcpOverrides(suiteId) {
145
+ /** The LSP status surface: declared servers merged with mount diagnostics. */
146
+ async lspStatus() {
60
147
  const snapshot = await this.readUserCatalog();
61
- if (!snapshot.suites.some(suite => suite.id === suiteId))
62
- throw new Error(`suite "${suiteId}" not found`);
63
- return loadSuiteOverrides(this.options.dataRoot, suiteId);
148
+ const direct = await loadLspServers(this.options.dataRoot);
149
+ return buildLspStatus(snapshot.suites, this.lspStatusSource ?? { diagnosticsSnapshot: () => new Map(), hasLiveMounts: () => false }, direct);
150
+ }
151
+ /** The user's direct LSP server table (normalized specs). */
152
+ async lspServers() {
153
+ return (await loadLspServers(this.options.dataRoot)).servers;
154
+ }
155
+ /** Validate and persist the user's direct LSP server table. */
156
+ async setLspServers(raw) {
157
+ return this.enqueue(async () => {
158
+ const { servers } = await saveLspServers(this.options.dataRoot, raw);
159
+ await this.notifyChanged(true);
160
+ return servers;
161
+ });
162
+ }
163
+ /** One suite's persisted MCP overrides, addressed by qualified suite id. */
164
+ async mcpOverrides(sourceId, suiteId) {
165
+ const suiteKey = qualifiedSuiteId(sourceId, suiteId);
166
+ const snapshot = await this.readUserCatalog();
167
+ if (!snapshot.suites.some(suite => suite.sourceId === sourceId && suite.id === suiteId))
168
+ throw new Error(`suite "${suiteKey}" not found`);
169
+ return redactMcpOverrides(await loadSuiteOverrides(this.options.dataRoot, suiteKey));
64
170
  }
65
171
  /**
66
172
  * Set or clear one server's MCP override and remount it. `override === null`
@@ -69,43 +175,93 @@ export class Catalog {
69
175
  */
70
176
  async setMcpOverride(sourceId, suiteId, serverKey, override) {
71
177
  return this.enqueue(async () => {
178
+ const suiteKey = qualifiedSuiteId(sourceId, suiteId);
72
179
  const source = this.state.sources.find(entry => entry.id === sourceId);
73
180
  if (source === undefined)
74
181
  throw new Error(`unknown source "${sourceId}"`);
75
182
  const checkout = this.sourceCheckoutPath(source);
76
183
  if (!(await isDirectory(checkout)))
77
- await this.ensureClone(source);
78
- const suites = await discoverSuitesInSource(checkout, sourceId, 'user');
184
+ await this.acquire(source);
185
+ const suites = await discoverSuitesInSource(checkout, sourceId, 'user', source.url);
79
186
  const suite = suites.find(entry => entry.id === suiteId);
80
187
  if (suite === undefined)
81
188
  throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
82
189
  if (suite.mcp?.servers[serverKey] === undefined)
83
190
  throw new Error(`server "${serverKey}" is not defined by suite "${suiteId}"`);
84
- const overrides = await loadSuiteOverrides(this.options.dataRoot, suiteId);
191
+ // Override files are keyed by the qualified id so two sources' same-named
192
+ // suites never share one override record.
193
+ const overrides = await loadSuiteOverrides(this.options.dataRoot, suiteKey);
85
194
  if (override === null) {
86
195
  delete overrides[serverKey];
87
196
  }
88
197
  else {
89
- overrides[serverKey] = override;
198
+ // Merge onto the stored record: the UI never receives literal secret
199
+ // values, so a verbatim write would drop keys it simply redacted.
200
+ overrides[serverKey] = mergeOverridePatch(overrides[serverKey] ?? {}, override);
90
201
  }
91
- await saveSuiteOverrides(this.options.dataRoot, suiteId, overrides);
92
- this.notifyChanged();
202
+ await saveSuiteOverrides(this.options.dataRoot, suiteKey, overrides);
203
+ await this.notifyChanged(true);
93
204
  });
94
205
  }
95
- /** All persisted MCP overrides keyed by suite id (mount-time provider). */
206
+ /**
207
+ * Re-run the MCP reconcile pass: retries failed mounts and clears residual
208
+ * tools, without changing any catalog state.
209
+ */
210
+ async retryMounts() {
211
+ await this.notifyChanged(true);
212
+ }
213
+ /** The persisted backend choice without the host-client probe (mount-time provider). */
214
+ async mcpBackend() {
215
+ return this.mcpBackendProvider();
216
+ }
217
+ /**
218
+ * The active MCP mount backend plus a live probe of the host client, and
219
+ * the download-region setting with its locale-resolved effective route —
220
+ * everything the plugin-config card renders.
221
+ */
222
+ async mcpBackendInfo() {
223
+ const [backend, hostClient, regionSetting, locale] = await Promise.all([this.mcpBackendProvider(), probeHostMcpClient(), this.downloadRegionProvider(), readLocalePreference()]);
224
+ return { backend, hostClient, downloadRegion: { setting: regionSetting, effective: resolveRegion(regionSetting, locale) } };
225
+ }
226
+ /**
227
+ * Switch the MCP mount backend and remount every suite server through it.
228
+ * A host-client switch takes effect only where the host package resolves;
229
+ * unresolvable or unsupported transports surface as per-server diagnostics.
230
+ */
231
+ async setMcpBackend(backend) {
232
+ return this.enqueue(async () => {
233
+ await this.mcpBackendWriter(backend);
234
+ await this.notifyChanged(true);
235
+ });
236
+ }
237
+ /** Install the backend source (the host settings namespace's scope). */
238
+ setMcpBackendProvider(provider) {
239
+ this.mcpBackendProvider = provider;
240
+ }
241
+ /** Install the backend writer (a scope update on the same namespace). */
242
+ setMcpBackendWriter(writer) {
243
+ this.mcpBackendWriter = writer;
244
+ }
245
+ /** Install the download-region source (the host settings namespace scope). */
246
+ setDownloadRegionProvider(provider) {
247
+ this.downloadRegionProvider = provider;
248
+ }
249
+ /** All persisted MCP overrides keyed by qualified suite id (mount-time provider). */
96
250
  async allMcpOverrides() {
97
251
  const snapshot = await this.readUserCatalog();
98
252
  const map = new Map();
99
253
  for (const suite of snapshot.suites) {
100
- const overrides = await loadSuiteOverrides(this.options.dataRoot, suite.id);
254
+ const suiteKey = qualifiedSuiteId(suite.sourceId, suite.id);
255
+ const overrides = await loadSuiteOverrides(this.options.dataRoot, suiteKey);
101
256
  if (Object.keys(overrides).length > 0)
102
- map.set(suite.id, overrides);
257
+ map.set(suiteKey, overrides);
103
258
  }
104
259
  return map;
105
260
  }
106
261
  /** Load persisted user state once at plugin activation. */
107
262
  async load() {
108
263
  this.state = await loadState(this.statePath);
264
+ this.scanCacheDirty = true;
109
265
  this.invalidateSnapshot(false);
110
266
  }
111
267
  get sources() {
@@ -117,11 +273,16 @@ export class Catalog {
117
273
  }
118
274
  /** Read one coherent user-dimension snapshot, reusing in-flight discovery. */
119
275
  async readUserCatalog() {
120
- if (this.userSnapshot !== undefined)
276
+ // TTL 0 disables snapshot caching: every read observes fresh discovery,
277
+ // mirroring the project dimension's caching-disabled semantics.
278
+ if (this.userSnapshotTtlMs <= 0)
279
+ return this.buildSnapshot(this.state, 'user', this.options.userRoot);
280
+ if (this.userSnapshot !== undefined && Date.now() < this.userSnapshotExpiresAt)
121
281
  return this.userSnapshot;
122
282
  this.userSnapshotPromise ??= this.buildSnapshot(this.state, 'user', this.options.userRoot)
123
283
  .then(snapshot => {
124
284
  this.userSnapshot = snapshot;
285
+ this.userSnapshotExpiresAt = Date.now() + this.userSnapshotTtlMs;
125
286
  return snapshot;
126
287
  })
127
288
  .finally(() => {
@@ -153,7 +314,9 @@ export class Catalog {
153
314
  }
154
315
  async buildProjectSnapshot(projectRoot) {
155
316
  const state = await loadState(join(projectRoot, STATE_FILE_NAME));
156
- return this.buildSnapshot(state, 'project', projectRoot);
317
+ // TTL 0 means caching is disabled for this dimension: bypass the scan
318
+ // cache so every read observes the working tree as it stands.
319
+ return this.buildSnapshot(state, 'project', projectRoot, this.projectSnapshotTtlMs <= 0);
157
320
  }
158
321
  /** One suite's full detail for the market detail modal. */
159
322
  async suiteDetail(sourceId, suiteId) {
@@ -161,7 +324,8 @@ export class Catalog {
161
324
  const suite = snapshot.suites.find(entry => entry.sourceId === sourceId && entry.id === suiteId);
162
325
  if (suite === undefined)
163
326
  throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
164
- return buildSuiteDetail(suite, this.state.installed[installKey(sourceId, suiteId)], this.mcpDiagnostics, await loadSuiteOverrides(this.options.dataRoot, suiteId));
327
+ const suiteKey = qualifiedSuiteId(sourceId, suiteId);
328
+ return buildSuiteDetail(suite, this.state.installed[installKey(sourceId, suiteId)], this.mcpDiagnostics, await loadSuiteOverrides(this.options.dataRoot, suiteKey));
165
329
  }
166
330
  /** One skill's full SKILL.md text for the market detail modal. */
167
331
  async skillContent(sourceId, suiteId, skillName) {
@@ -178,14 +342,19 @@ export class Catalog {
178
342
  for (const source of snapshot.sources) {
179
343
  const inFlight = this.currentSourceState?.sourceId === source.id ? this.currentSourceState : undefined;
180
344
  const checkout = this.sourceCheckoutPath(source);
345
+ const kind = resolveSourceKind(source);
181
346
  let cloned = false;
182
347
  let lockCommit;
183
348
  let error;
184
- if (source.local === true) {
349
+ if (kind === 'local') {
185
350
  cloned = await isDirectory(checkout);
186
351
  if (!cloned)
187
352
  error = `local source directory ${checkout} is missing`;
188
353
  }
354
+ else if (kind === 'archive') {
355
+ cloned = await isDirectory(checkout);
356
+ lockCommit = this.headCache.get(source.id);
357
+ }
189
358
  else if (inFlight !== undefined) {
190
359
  // A mutation owns this source right now: do not race git against its checkout.
191
360
  cloned = inFlight.cloned || (await isDirectory(checkout));
@@ -209,14 +378,18 @@ export class Catalog {
209
378
  }
210
379
  }
211
380
  const sourceSuites = snapshot.suites.filter(suite => suite.sourceId === source.id);
381
+ const scanNotes = snapshot.scanNotes?.[source.id];
212
382
  sourceRows.push({
213
383
  id: source.id,
214
384
  url: source.url,
215
385
  ...(source.branch === undefined ? {} : { branch: source.branch }),
216
386
  ...(source.local === true ? { local: true } : {}),
387
+ kind,
388
+ ...(source.adopted === true ? { adopted: true } : {}),
217
389
  cloned,
218
390
  ...(lockCommit === undefined ? {} : { lockCommit }),
219
391
  ...(error === undefined ? {} : { error }),
392
+ ...(scanNotes === undefined ? {} : { scanNotes }),
220
393
  suiteIds: sourceSuites.map(suite => suite.id)
221
394
  });
222
395
  }
@@ -246,7 +419,8 @@ export class Catalog {
246
419
  installed: cards.filter(card => card.installed).length,
247
420
  enabled: cards.filter(card => card.enabled).length
248
421
  },
249
- roots: { user: this.options.userRoot, data: this.options.dataRoot }
422
+ roots: { user: this.options.userRoot, data: this.options.dataRoot },
423
+ unmanaged: await this.unmanagedSources()
250
424
  };
251
425
  }
252
426
  /** Enabled user-dimension suites from one user snapshot. */
@@ -263,25 +437,57 @@ export class Catalog {
263
437
  const state = dimension === 'user' && dimensionRoot === this.options.userRoot ? this.state : await loadState(join(dimensionRoot, STATE_FILE_NAME));
264
438
  return (await this.buildSnapshot(state, dimension, dimensionRoot)).suites;
265
439
  }
266
- /** Add a source and clone it immediately. */
440
+ /**
441
+ * Add a source and acquire it immediately (clone, download, or in-place).
442
+ *
443
+ * Git sources with an already-present checkout whose `origin` matches the
444
+ * input URL are adopted in place — the manual-clone repair path — so no
445
+ * second clone is made and no `-2` suffixed id is invented. Adopted
446
+ * checkouts are never deleted on source removal.
447
+ */
267
448
  async addSource(input) {
268
449
  return this.enqueue(async () => {
269
- const baseId = this.uniqueSourceId(deriveSourceId(input.url));
450
+ const kind = input.local === true ? 'local' : resolveSourceKind({ url: input.url, kind: input.kind });
451
+ if (kind === 'git') {
452
+ // Adoption first: a pre-existing checkout with a matching origin URL
453
+ // is registered as-is, skipping the clone entirely.
454
+ const adoptable = await this.findAdoptableCheckout(input.url, deriveSourceIdCandidates(input.url));
455
+ if (adoptable !== undefined) {
456
+ const source = {
457
+ id: adoptable,
458
+ url: input.url,
459
+ ...(input.branch === undefined ? {} : { branch: input.branch }),
460
+ kind: 'git',
461
+ adopted: true
462
+ };
463
+ const head = await tryHead(sourceCheckoutDir(this.options.userRoot, adoptable));
464
+ if (head !== undefined)
465
+ this.headCache.set(source.id, head);
466
+ this.state = { ...this.state, sources: [...this.state.sources, source] };
467
+ await saveState(this.statePath, this.state);
468
+ await this.notifyChanged();
469
+ return source;
470
+ }
471
+ }
472
+ const baseId = await this.pickSourceId(deriveSourceIdCandidates(input.url), kind === 'local');
270
473
  const source = {
271
474
  id: baseId,
272
475
  url: input.url,
273
476
  ...(input.branch === undefined ? {} : { branch: input.branch }),
274
- ...(input.local === true ? { local: true } : {})
477
+ ...(kind === 'local' ? { local: true } : { kind }),
478
+ ...(input.kind === 'archive' && input.sha256 !== undefined && input.sha256 !== '' ? { sha256: input.sha256 } : {})
275
479
  };
276
- const checkout = this.sourceCheckoutPath(source);
277
- if (input.local === true) {
480
+ let checkout = this.sourceCheckoutPath(source);
481
+ if (kind === 'local') {
278
482
  if (!(await isDirectory(checkout)))
279
483
  throw new Error(`local source directory ${checkout} is missing`);
280
484
  }
281
485
  else {
282
- this.beginSourceState(baseId, 'cloning', false);
486
+ this.beginSourceState(baseId, kind === 'archive' ? 'downloading' : 'cloning', false);
283
487
  try {
284
- await this.ensureClone(source);
488
+ const lock = await this.acquire(source);
489
+ if (lock !== undefined)
490
+ this.headCache.set(source.id, lock);
285
491
  }
286
492
  catch (error) {
287
493
  this.endSourceState();
@@ -289,17 +495,112 @@ export class Catalog {
289
495
  }
290
496
  this.updateSourceStep('reading');
291
497
  }
292
- source.id = this.uniqueSourceId(sanitizeId(await repoName(checkout)));
293
- const head = await tryHead(checkout);
294
- if (head !== undefined)
295
- this.headCache.set(source.id, head);
498
+ // The repo's own manifest names the source; move the checkout along so
499
+ // the registered id and its `.sources/` directory stay coherent. URL
500
+ // derived candidates stay as readable fallbacks before numeric suffixes.
501
+ const named = [...new Set([sanitizeId(await repoName(checkout)), ...deriveSourceIdCandidates(input.url)])];
502
+ const finalId = await this.pickSourceId(named, kind === 'local', kind === 'local' ? undefined : checkout);
503
+ if (kind !== 'local' && finalId !== baseId) {
504
+ const targetDir = sourceCheckoutDir(this.options.userRoot, finalId);
505
+ await rename(checkout, targetDir);
506
+ checkout = targetDir;
507
+ }
508
+ source.id = finalId;
509
+ if (resolveSourceKind(source) === 'git') {
510
+ const head = await tryHead(checkout);
511
+ if (head !== undefined)
512
+ this.headCache.set(source.id, head);
513
+ }
296
514
  this.endSourceState();
297
515
  this.state = { ...this.state, sources: [...this.state.sources, source] };
298
516
  await saveState(this.statePath, this.state);
299
- this.notifyChanged();
517
+ await this.notifyChanged();
300
518
  return source;
301
519
  });
302
520
  }
521
+ /**
522
+ * First candidate id whose `.sources/` checkout already exists and whose
523
+ * `origin` remote matches the input URL (canonical-form equality) — the
524
+ * signature of a manual clone of the very same repository. Registered ids
525
+ * and checkouts of differently-origined directories never adopt.
526
+ */
527
+ async findAdoptableCheckout(url, candidates) {
528
+ const registered = new Set(this.state.sources.map(source => source.id));
529
+ for (const candidate of candidates) {
530
+ if (registered.has(candidate))
531
+ continue;
532
+ const dir = sourceCheckoutDir(this.options.userRoot, candidate);
533
+ if (!(await isDirectory(dir)))
534
+ continue;
535
+ let origin;
536
+ try {
537
+ origin = await gitRemoteUrl(dir);
538
+ }
539
+ catch {
540
+ continue;
541
+ }
542
+ if (origin !== '' && canonicalGitUrl(origin) === canonicalGitUrl(url))
543
+ return candidate;
544
+ }
545
+ return undefined;
546
+ }
547
+ /** Register one unmanaged `.sources/` checkout as a source without touching its files. */
548
+ async adoptSource(id) {
549
+ return this.enqueue(async () => {
550
+ if (this.state.sources.some(source => source.id === id))
551
+ throw new Error(`source "${id}" is already registered`);
552
+ // The id becomes one path segment under `.sources/`: only flat safe
553
+ // names qualify. Anything else (separators, `..`, leading dots) could
554
+ // traverse out of the checkouts root and register an outside directory.
555
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(id) || id.includes('..')) {
556
+ throw new Error(`invalid checkout id "${id}" — use letters, digits, dots, dashes, or underscores`);
557
+ }
558
+ const dir = sourceCheckoutDir(this.options.userRoot, id);
559
+ if (!(await isDirectory(dir)))
560
+ throw new Error(`no checkout directory at ${dir}`);
561
+ let source;
562
+ try {
563
+ const origin = await gitRemoteUrl(dir);
564
+ source = { id, url: origin, kind: 'git', adopted: true };
565
+ const head = await tryHead(dir);
566
+ if (head !== undefined)
567
+ this.headCache.set(id, head);
568
+ }
569
+ catch {
570
+ // Not a git checkout: read it in place, like a local source.
571
+ source = { id, url: dir, local: true, adopted: true };
572
+ }
573
+ this.state = { ...this.state, sources: [...this.state.sources, source] };
574
+ await saveState(this.statePath, this.state);
575
+ await this.notifyChanged();
576
+ return source;
577
+ });
578
+ }
579
+ /** Unmanaged `.sources/` checkouts: present on disk, absent from state. */
580
+ async unmanagedSources() {
581
+ const checkoutRoot = sourcesDir(this.options.userRoot);
582
+ let entries;
583
+ try {
584
+ entries = await readdir(checkoutRoot, { withFileTypes: true });
585
+ }
586
+ catch {
587
+ return [];
588
+ }
589
+ const registered = new Set(this.state.sources.map(source => source.id));
590
+ const unmanaged = [];
591
+ for (const entry of entries) {
592
+ if (!entry.isDirectory() || entry.name.startsWith('.') || registered.has(entry.name))
593
+ continue;
594
+ const dir = join(checkoutRoot, entry.name);
595
+ try {
596
+ unmanaged.push({ id: entry.name, url: await gitRemoteUrl(dir) });
597
+ }
598
+ catch {
599
+ unmanaged.push({ id: entry.name });
600
+ }
601
+ }
602
+ return unmanaged;
603
+ }
303
604
  /** Progress snapshot of the source mutation currently in flight. */
304
605
  currentSourceState;
305
606
  /** Begin reporting progress for a source mutation. */
@@ -320,35 +621,64 @@ export class Catalog {
320
621
  const state = this.currentSourceState;
321
622
  return state === undefined ? { active: false, sourceId: '', step: '' } : { active: true, sourceId: state.sourceId, step: state.step };
322
623
  }
323
- uniqueSourceId(derived) {
324
- if (!this.state.sources.some(source => source.id === derived))
325
- return derived;
624
+ /**
625
+ * First candidate id not claimed by a registered source; for git candidates
626
+ * the checkout directory must also be free, so a stale or foreign directory
627
+ * under `.sources/` is never cloned over (it would fail `git clone` with a
628
+ * confusing "destination exists" error). `ownedCheckout` marks the directory
629
+ * the in-flight mutation already controls, keeping a rename onto it legal.
630
+ * Local sources skip the disk check: they read their own path in place and
631
+ * never occupy `.sources/`.
632
+ */
633
+ async pickSourceId(candidates, skipDisk, ownedCheckout) {
634
+ const free = async (id) => {
635
+ if (this.state.sources.some(source => source.id === id))
636
+ return false;
637
+ if (skipDisk)
638
+ return true;
639
+ const dir = sourceCheckoutDir(this.options.userRoot, id);
640
+ return dir === ownedCheckout || !(await pathExists(dir));
641
+ };
642
+ for (const candidate of candidates) {
643
+ if (await free(candidate))
644
+ return candidate;
645
+ }
326
646
  for (let suffix = 2;; suffix++) {
327
- const candidate = `${derived}-${suffix}`;
328
- if (!this.state.sources.some(source => source.id === candidate))
647
+ const candidate = `${candidates[0]}-${suffix}`;
648
+ if (await free(candidate))
329
649
  return candidate;
330
650
  }
331
651
  }
332
- /** Update one source's URL, branch, or local flag. */
652
+ /** Update one source's URL, branch, kind, or archive digest. */
333
653
  async updateSource(sourceId, patch) {
334
654
  return this.enqueue(async () => {
335
655
  const index = this.state.sources.findIndex(source => source.id === sourceId);
336
656
  if (index === -1)
337
657
  throw new Error(`unknown source "${sourceId}"`);
338
658
  const current = this.state.sources[index];
659
+ const nextLocal = patch.local !== undefined ? patch.local : current.local === true;
660
+ // A local source carries the legacy flag only; `kind` stays unwritten
661
+ // so the persisted shape matches the pre-kind records.
662
+ const nextKind = nextLocal ? undefined : (patch.kind ?? current.kind);
339
663
  const next = {
340
664
  id: sourceId,
341
665
  url: patch.url ?? current.url,
342
666
  ...(patch.branch !== undefined ? { branch: patch.branch } : current.branch === undefined ? {} : { branch: current.branch }),
343
- ...(patch.local !== undefined ? { local: patch.local } : current.local === undefined ? {} : { local: current.local })
667
+ ...(nextLocal ? { local: true } : {}),
668
+ ...(nextKind === undefined ? {} : { kind: nextKind }),
669
+ ...(patch.sha256 !== undefined ? (patch.sha256 === '' ? {} : { sha256: patch.sha256 }) : current.sha256 === undefined ? {} : { sha256: current.sha256 }),
670
+ ...(current.adopted === true ? { adopted: true } : {})
344
671
  };
345
- if (current.local !== true && patch.url !== undefined && patch.url !== current.url) {
672
+ const urlChanged = patch.url !== undefined && patch.url !== current.url;
673
+ // A URL change invalidates the old checkout — except for adopted (user
674
+ // cloned) and local sources, whose directories we never own.
675
+ if (urlChanged && current.local !== true && current.adopted !== true) {
346
676
  this.headCache.delete(sourceId);
347
677
  await gitRemove(sourceCheckoutDir(this.options.userRoot, sourceId));
348
678
  }
349
679
  this.state = { ...this.state, sources: this.state.sources.map((source, i) => (i === index ? next : source)) };
350
680
  await saveState(this.statePath, this.state);
351
- this.notifyChanged();
681
+ await this.notifyChanged();
352
682
  });
353
683
  }
354
684
  /** Remove a source, its install entries, and its managed checkout. */
@@ -362,9 +692,12 @@ export class Catalog {
362
692
  };
363
693
  await saveState(this.statePath, this.state);
364
694
  this.headCache.delete(sourceId);
365
- if (source === undefined || source.local !== true)
695
+ // Adopted checkouts (manual clones) and local paths are user-owned
696
+ // directories; only self-acquired checkouts are deleted.
697
+ const userOwned = source !== undefined && (source.local === true || source.adopted === true);
698
+ if (!userOwned)
366
699
  await gitRemove(sourceCheckoutDir(this.options.userRoot, sourceId));
367
- this.notifyChanged();
700
+ await this.notifyChanged();
368
701
  });
369
702
  }
370
703
  /** Refresh one source checkout, or every source when sourceId is omitted. */
@@ -372,18 +705,26 @@ export class Catalog {
372
705
  return this.enqueue(async () => {
373
706
  const targets = sourceId === undefined ? this.state.sources : this.state.sources.filter(source => source.id === sourceId);
374
707
  for (const source of targets) {
375
- if (source.local === true) {
708
+ const kind = resolveSourceKind(source);
709
+ if (kind === 'local') {
376
710
  if (!(await isDirectory(expandHome(source.url))))
377
711
  throw new Error(`local source directory ${expandHome(source.url)} is missing`);
378
712
  continue;
379
713
  }
380
714
  const checkout = sourceCheckoutDir(this.options.userRoot, source.id);
381
715
  this.headCache.delete(source.id);
382
- try {
383
- await gitHead(checkout);
716
+ if (kind === 'archive') {
717
+ // Re-download and swap; the fresh digest becomes the lock value.
718
+ const lock = await this.acquire(source);
719
+ if (lock !== undefined)
720
+ this.headCache.set(source.id, lock);
721
+ continue;
384
722
  }
385
- catch {
386
- await this.ensureClone(source);
723
+ // Adopted checkouts are user-owned working trees: no `reset --hard`
724
+ // (it would destroy uncommitted work), and no re-acquire either —
725
+ // a broken HEAD means the user repairs the checkout, not that the
726
+ // manager may clone or extract over their directory.
727
+ if (source.adopted === true) {
387
728
  try {
388
729
  this.headCache.set(source.id, await gitHead(checkout));
389
730
  }
@@ -392,7 +733,44 @@ export class Catalog {
392
733
  }
393
734
  continue;
394
735
  }
395
- await gitPull(checkout);
736
+ try {
737
+ await gitHead(checkout);
738
+ }
739
+ catch {
740
+ const lock = await this.acquire(source);
741
+ if (lock !== undefined) {
742
+ this.headCache.set(source.id, lock);
743
+ }
744
+ else {
745
+ try {
746
+ this.headCache.set(source.id, await gitHead(checkout));
747
+ }
748
+ catch {
749
+ // A failed HEAD read means the next overview re-probes.
750
+ }
751
+ }
752
+ continue;
753
+ }
754
+ // Shallow-friendly sync: fetch depth 1 into FETCH_HEAD, hard reset.
755
+ const branch = source.branch ?? (await gitCurrentBranch(checkout).catch(() => undefined));
756
+ // Keep origin on the region's route: a clone made before a region
757
+ // switch (or an explicit flip) re-points origin so the fetch follows.
758
+ // Only origins this plugin itself wrote (the direct or proxied
759
+ // source.url) are re-pointed — a foreign remote is left untouched.
760
+ if (source.kind === 'git' || (source.kind === undefined && source.local !== true)) {
761
+ try {
762
+ const region = resolveRegion(await this.downloadRegionProvider(), await readLocalePreference());
763
+ const routed = githubCloneUrl(region, source.url);
764
+ const origin = await gitRemoteUrl(checkout);
765
+ if (origin !== routed && (origin === source.url || origin === githubCloneUrl('china', source.url))) {
766
+ await gitSetRemoteUrl(checkout, routed);
767
+ }
768
+ }
769
+ catch {
770
+ // Origin reconciliation is best effort; plain sync still runs.
771
+ }
772
+ }
773
+ await gitSync(checkout, branch, this.options.git ?? {});
396
774
  try {
397
775
  this.headCache.set(source.id, await gitHead(checkout));
398
776
  }
@@ -400,7 +778,7 @@ export class Catalog {
400
778
  // A failed HEAD read means the next overview re-probes.
401
779
  }
402
780
  }
403
- this.notifyChanged();
781
+ await this.notifyChanged();
404
782
  });
405
783
  }
406
784
  /** Install a suite from a source and enable it. The client confirms the
@@ -412,16 +790,20 @@ export class Catalog {
412
790
  if (source === undefined)
413
791
  throw new Error(`unknown source "${sourceId}"`);
414
792
  const checkout = this.sourceCheckoutPath(source);
793
+ let freshLock;
415
794
  if (!(await isDirectory(checkout)))
416
- await this.ensureClone(source);
417
- const suites = await discoverSuitesInSource(checkout, sourceId, 'user');
795
+ freshLock = await this.acquire(source);
796
+ const suites = await discoverSuitesInSource(checkout, sourceId, 'user', source.url);
418
797
  const suite = suites.find(entry => entry.id === suiteId);
419
798
  if (suite === undefined)
420
799
  throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
421
800
  if (suite.remote !== undefined)
422
801
  throw new Error(`suite "${suiteId}" is a remote reference (${suite.remote.url}); add its repository as a source before installing`);
423
- await this.setInstalled(sourceId, suiteId, { enabled: true, installedAt: new Date().toISOString(), lockCommit: await tryHead(checkout) });
424
- this.notifyChanged();
802
+ // Archive (or tarball-fallback) sources have no git HEAD; their digest
803
+ // is the lock value.
804
+ const lockCommit = (await tryHead(checkout)) ?? freshLock ?? this.headCache.get(sourceId);
805
+ await this.setInstalled(sourceId, suiteId, { enabled: true, installedAt: new Date().toISOString(), lockCommit });
806
+ await this.notifyChanged(true);
425
807
  });
426
808
  }
427
809
  /** Uninstall a suite while retaining the source checkout. */
@@ -433,7 +815,7 @@ export class Catalog {
433
815
  const rest = Object.fromEntries(Object.entries(this.state.installed).filter(([entryKey]) => entryKey !== key));
434
816
  this.state = { ...this.state, installed: rest };
435
817
  await saveState(this.statePath, this.state);
436
- this.notifyChanged();
818
+ await this.notifyChanged(true);
437
819
  });
438
820
  }
439
821
  /** Enable or disable an installed suite. */
@@ -444,7 +826,7 @@ export class Catalog {
444
826
  if (entry === undefined)
445
827
  throw new Error(`suite "${suiteId}" is not installed`);
446
828
  await this.setInstalled(sourceId, suiteId, { ...entry, enabled });
447
- this.notifyChanged();
829
+ await this.notifyChanged(true);
448
830
  });
449
831
  }
450
832
  /** Enable or disable one runtime surface of an installed suite. */
@@ -458,7 +840,7 @@ export class Catalog {
458
840
  throw new Error(`surface "${surface}" is not toggleable`);
459
841
  const surfaces = { ...(entry.surfaces ?? {}), [surface]: enabled };
460
842
  await this.setInstalled(sourceId, suiteId, { ...entry, surfaces });
461
- this.notifyChanged();
843
+ await this.notifyChanged(true);
462
844
  });
463
845
  }
464
846
  /** Append config-seeded sources missing from user state and persist them. */
@@ -469,28 +851,88 @@ export class Catalog {
469
851
  return;
470
852
  this.state = { ...this.state, sources: [...this.state.sources, ...additions] };
471
853
  await saveState(this.statePath, this.state);
854
+ // New sources change the fingerprint, but their content arrives through
855
+ // an acquire, so mark the scan cache dirty conservatively.
856
+ this.scanCacheDirty = true;
472
857
  this.invalidateSnapshot(true);
473
858
  }
474
859
  async setInstalled(sourceId, suiteId, entry) {
475
860
  this.state = { ...this.state, installed: { ...this.state.installed, [installKey(sourceId, suiteId)]: entry } };
476
861
  await saveState(this.statePath, this.state);
477
862
  }
478
- async ensureClone(source) {
479
- if (source.local === true) {
863
+ /**
864
+ * Acquire one source's content by kind: clone (git), download+extract
865
+ * (archive), or an in-place existence check (local). Returns a lock value
866
+ * when the acquisition yields one (archive SHA-256, tarball fallback
867
+ * digest); git HEAD is read separately by the callers.
868
+ */
869
+ async acquire(source) {
870
+ const kind = resolveSourceKind(source);
871
+ if (kind === 'local') {
480
872
  if (!(await isDirectory(expandHome(source.url))))
481
873
  throw new Error(`local source directory ${expandHome(source.url)} is missing`);
482
- return;
874
+ return undefined;
483
875
  }
484
876
  const checkout = sourceCheckoutDir(this.options.userRoot, source.id);
485
877
  await mkdir(sourcesDir(this.options.userRoot), { recursive: true });
486
- await gitClone(source.url, source.branch, checkout);
878
+ if (kind === 'archive') {
879
+ const { sha256 } = await archiveInstall(source.url, checkout, {
880
+ ...(source.sha256 === undefined ? {} : { sha256: source.sha256 }),
881
+ ...(this.options.git?.timeoutMs === undefined ? {} : { timeoutMs: this.options.git.timeoutMs }),
882
+ ...(this.options.git?.allowHttpArchives === true ? { allowHttp: true } : {})
883
+ });
884
+ return sha256;
885
+ }
886
+ try {
887
+ // The region routes github.com clones through the China mirror prefix;
888
+ // the proxied URL becomes `origin`, so refreshes follow the same route.
889
+ const region = resolveRegion(await this.downloadRegionProvider(), await readLocalePreference());
890
+ await gitClone(githubCloneUrl(region, source.url), source.branch, checkout, this.options.git ?? {});
891
+ return undefined;
892
+ }
893
+ catch (error) {
894
+ if (this.options.git?.fallbackTarball !== true)
895
+ throw error;
896
+ const tarballUrl = codeloadTarballUrl(source.url, source.branch);
897
+ if (tarballUrl === undefined)
898
+ throw error;
899
+ // The codeload URL ends in a branch path, not `.tar.gz`, so the format
900
+ // must be stated explicitly — extension detection cannot see it.
901
+ const { sha256 } = await archiveInstall(tarballUrl, checkout, {
902
+ ...(this.options.git?.timeoutMs === undefined ? {} : { timeoutMs: this.options.git.timeoutMs }),
903
+ format: 'targz'
904
+ });
905
+ return sha256;
906
+ }
487
907
  }
488
908
  /** The filesystem location of one source. */
489
909
  sourceCheckoutPath(source) {
490
910
  return source.local === true ? expandHome(source.url) : sourceCheckoutDir(this.options.userRoot, source.id);
491
911
  }
492
- async buildSnapshot(state, dimension, dimensionRoot) {
493
- const discovered = await discoverSourceList(state.sources, dimension, dimensionRoot);
912
+ async buildSnapshot(state, dimension, dimensionRoot, skipScanCache = false) {
913
+ const fingerprint = JSON.stringify([dimension, dimensionRoot, state.sources]);
914
+ const cached = this.scanCache.get(fingerprint);
915
+ // The user snapshot's TTL also bounds scan reuse: a snapshot rebuild that
916
+ // replays a 30s scan cache would silently outlive its own staleness bound.
917
+ const scanCacheTtl = dimension === 'user' ? Math.min(this.userSnapshotTtlMs, Catalog.SCAN_CACHE_TTL_MS) : Catalog.SCAN_CACHE_TTL_MS;
918
+ const cacheFresh = !skipScanCache && !this.scanCacheDirty && cached !== undefined && Date.now() - cached.at < scanCacheTtl;
919
+ let discovered;
920
+ let scanNotes;
921
+ if (cacheFresh && cached !== undefined) {
922
+ discovered = cached.discovered;
923
+ scanNotes = cached.scanNotes;
924
+ }
925
+ else {
926
+ const result = await discoverSourceListWithNotes(state.sources, dimension, dimensionRoot);
927
+ discovered = result.suites;
928
+ scanNotes = result.scanNotes;
929
+ // The fresh scan satisfies the invalidation: content mutations mark the
930
+ // cache dirty, and the next scan (this one) makes it clean again.
931
+ this.scanCacheDirty = false;
932
+ if (this.scanCache.size >= Catalog.SCAN_CACHE_MAX_ENTRIES)
933
+ this.scanCache.clear();
934
+ this.scanCache.set(fingerprint, { at: Date.now(), discovered, scanNotes });
935
+ }
494
936
  const suites = discovered.map(suite => {
495
937
  const installed = state.installed[installKey(suite.sourceId, suite.id)];
496
938
  // Native project layouts (`.claude/`, `.agents/`) are the repository's
@@ -508,19 +950,29 @@ export class Catalog {
508
950
  revision: this.revision,
509
951
  sources: [...state.sources],
510
952
  suites,
511
- enabledSuites: suites.filter(suite => suite.enabled)
953
+ enabledSuites: suites.filter(suite => suite.enabled),
954
+ ...(Object.keys(scanNotes).length > 0 ? { scanNotes } : {})
512
955
  };
513
956
  }
514
957
  invalidateSnapshot(increment) {
515
958
  if (increment)
516
959
  this.revision++;
517
960
  this.userSnapshot = undefined;
961
+ this.userSnapshotExpiresAt = 0;
518
962
  this.userSnapshotPromise = undefined;
519
963
  this.projectSnapshots.clear();
520
964
  }
521
- notifyChanged() {
965
+ /**
966
+ * Invalidate snapshots and run the change pipeline. `keepScanCache` marks
967
+ * state-only mutations (install, enable, surface toggles, MCP overrides)
968
+ * whose inputs leave every checkout untouched, so the next snapshot
969
+ * re-derives from cached discovery instead of rescanning the filesystem.
970
+ */
971
+ async notifyChanged(keepScanCache = false) {
972
+ if (!keepScanCache)
973
+ this.scanCacheDirty = true;
522
974
  this.invalidateSnapshot(true);
523
- this.options.onChanged();
975
+ await this.options.onChanged();
524
976
  for (const listener of this.listeners)
525
977
  listener();
526
978
  }
@@ -542,3 +994,17 @@ async function tryHead(dir) {
542
994
  return undefined;
543
995
  }
544
996
  }
997
+ /**
998
+ * The codeload tarball URL of a GitHub repository, or undefined for any
999
+ * other host. `https://github.com/<owner>/<repo>` maps to
1000
+ * `https://codeload.github.com/<owner>/<repo>/tar.gz/refs/heads/<branch>`
1001
+ * (or `/tar.gz/HEAD` for the default branch) — the git-protocol fallback
1002
+ * when a clone cannot get through but plain HTTPS file download can.
1003
+ */
1004
+ export function codeloadTarballUrl(url, branch) {
1005
+ const match = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i.exec(url.trim());
1006
+ if (match === null)
1007
+ return undefined;
1008
+ const [, owner, repo] = match;
1009
+ return `https://codeload.github.com/${owner}/${repo}/tar.gz/${branch === undefined ? 'HEAD' : `refs/heads/${branch}`}`;
1010
+ }