dsh-skill-hub 0.3.23 → 0.3.25

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/lib/index.js CHANGED
@@ -5126,7 +5126,34 @@ function apply(ctx, config) {
5126
5126
  }
5127
5127
  return current();
5128
5128
  };
5129
+ /**
5130
+ * Whether this plugin's fiber is still mounted.
5131
+ *
5132
+ * Async work started from `apply()` — the stats wiring and the persistence
5133
+ * probe — can settle after the fiber unloaded: a config rewrite reloads the
5134
+ * plugin, and disable/enable tears it down. Service reads on an inactive
5135
+ * context throw instead of returning undefined, so every continuation
5136
+ * re-checks this before touching `ctx` (issue #19: a cold-start abort with
5137
+ * `cannot get required service "systemPrompt" in inactive context`).
5138
+ *
5139
+ * The generation counters cannot cover this: they only invalidate a build
5140
+ * superseded by a *newer* build, and teardown does not bump them.
5141
+ */
5142
+ const mounted = () => {
5143
+ const fiber = ctx.fiber;
5144
+ return fiber === void 0 || fiber.uid !== null;
5145
+ };
5146
+ /** Logger for continuations that may outlive the fiber (#19). */
5147
+ const logInfo = (message) => {
5148
+ if (mounted()) ctx.logger.info(message);
5149
+ };
5150
+ const logWarn = (message, error) => {
5151
+ if (!mounted()) return;
5152
+ if (error === void 0) ctx.logger.warn(message);
5153
+ else ctx.logger.warn(message, error);
5154
+ };
5129
5155
  const sync = () => {
5156
+ if (!mounted()) return;
5130
5157
  if (disposeSection !== void 0) {
5131
5158
  disposeSection();
5132
5159
  disposeSection = void 0;
@@ -5178,32 +5205,33 @@ function apply(ctx, config) {
5178
5205
  const agentsHome = process.env.DSH_AGENTS_HOME ?? join(homedir(), ".agents");
5179
5206
  for (const root of [join(home, "skills"), join(agentsHome, "skills")]) try {
5180
5207
  const c = await cleanupLeftoverImportDirs(root);
5181
- if (c > 0) ctx.logger.info(`[dsh-skill-hub] startup cleaned ${c} leftover import temp dir(s) in ${root}`);
5208
+ if (c > 0) logInfo(`[dsh-skill-hub] startup cleaned ${c} leftover import temp dir(s) in ${root}`);
5182
5209
  } catch (error) {
5183
- ctx.logger.warn("[dsh-skill-hub] startup cleanup failed", error);
5210
+ logWarn("[dsh-skill-hub] startup cleanup failed", error);
5184
5211
  }
5185
5212
  try {
5186
5213
  const reconciled = await reconcileDisabledSkills(store, home);
5187
- if (reconciled.length > 0) ctx.logger.info(`[dsh-skill-hub] startup reconciled ${reconciled.length} disabled skill record(s): ${reconciled.map((entry) => entry.name).join(", ")}`);
5214
+ if (reconciled.length > 0) logInfo(`[dsh-skill-hub] startup reconciled ${reconciled.length} disabled skill record(s): ${reconciled.map((entry) => entry.name).join(", ")}`);
5188
5215
  } catch (error) {
5189
- ctx.logger.warn("[dsh-skill-hub] startup disabled-skill reconcile failed", error);
5216
+ logWarn("[dsh-skill-hub] startup disabled-skill reconcile failed", error);
5190
5217
  }
5191
5218
  try {
5192
5219
  const pruned = await pruneStaleDisabledSkills(store, home);
5193
- if (pruned.repaired.length > 0 || pruned.removed.length > 0) ctx.logger.info(`[dsh-skill-hub] startup pruned disabled records: repaired ${pruned.repaired.length} (${pruned.repaired.map((entry) => entry.name).join(", ") || "-"}), removed ${pruned.removed.length} (${pruned.removed.join(", ") || "-"})`);
5220
+ if (pruned.repaired.length > 0 || pruned.removed.length > 0) logInfo(`[dsh-skill-hub] startup pruned disabled records: repaired ${pruned.repaired.length} (${pruned.repaired.map((entry) => entry.name).join(", ") || "-"}), removed ${pruned.removed.length} (${pruned.removed.join(", ") || "-"})`);
5194
5221
  } catch (error) {
5195
- ctx.logger.warn("[dsh-skill-hub] startup disabled-record prune failed", error);
5222
+ logWarn("[dsh-skill-hub] startup disabled-record prune failed", error);
5196
5223
  }
5197
5224
  })();
5198
5225
  (async () => {
5199
5226
  try {
5200
5227
  const legacy = await store.getConfig();
5228
+ if (!mounted()) return;
5201
5229
  if (Object.keys(legacy).length > 0 && Object.keys(saved()).length === 0) {
5202
5230
  const settings = settingsOf();
5203
5231
  if (settings !== void 0) await settings.update(ENTRY_ID, legacy);
5204
5232
  }
5205
5233
  } catch (error) {
5206
- ctx.logger.warn("[dsh-skill-hub] sidecar config migration into the settings namespace failed", error);
5234
+ logWarn("[dsh-skill-hub] sidecar config migration into the settings namespace failed", error);
5207
5235
  }
5208
5236
  })();
5209
5237
  let statsQuery;
@@ -5216,7 +5244,7 @@ function apply(ctx, config) {
5216
5244
  const cold = statsPersistence;
5217
5245
  (async () => {
5218
5246
  const checkpoint = await store.getSkillStatsState().catch(() => void 0);
5219
- if (generation !== statsGeneration) return;
5247
+ if (generation !== statsGeneration || !mounted()) return;
5220
5248
  const scanMinutes = () => {
5221
5249
  const value = current().statsScanMinutes;
5222
5250
  return typeof value === "number" && value >= 1 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsScanMinutes;
@@ -5247,11 +5275,11 @@ function apply(ctx, config) {
5247
5275
  },
5248
5276
  onCheckpoint: (next) => {
5249
5277
  store.saveSkillStatsState(next).catch((error) => {
5250
- ctx.logger.warn("[dsh-skill-hub] persisting skill-stats checkpoint failed", error);
5278
+ if (mounted()) ctx.logger.warn("[dsh-skill-hub] persisting skill-stats checkpoint failed", error);
5251
5279
  });
5252
5280
  }
5253
5281
  });
5254
- if (generation !== statsGeneration) return;
5282
+ if (generation !== statsGeneration || !mounted()) return;
5255
5283
  ctx.logger.info(`[dsh-skill-hub] stats seam: ${cold === void 0 ? "session-query (fallback)" : "session-persistence (cold)"}`);
5256
5284
  stats = reader;
5257
5285
  sync();
@@ -5265,15 +5293,16 @@ function apply(ctx, config) {
5265
5293
  const raw = pctx.sessionPersistence;
5266
5294
  const seam = asPersistenceSeam(raw);
5267
5295
  if (seam === void 0) {
5268
- ctx.logger.warn("[dsh-skill-hub] sessionPersistence shape mismatch, staying on query fallback");
5296
+ if (mounted()) ctx.logger.warn("[dsh-skill-hub] sessionPersistence shape mismatch, staying on query fallback");
5269
5297
  return;
5270
5298
  }
5271
5299
  try {
5272
5300
  await seam.list();
5273
5301
  } catch (error) {
5274
- ctx.logger.warn("[dsh-skill-hub] persistence seam probe failed, staying on query fallback", error);
5302
+ if (mounted()) ctx.logger.warn("[dsh-skill-hub] persistence seam probe failed, staying on query fallback", error);
5275
5303
  return;
5276
5304
  }
5305
+ if (!mounted()) return;
5277
5306
  statsPersistence = seam;
5278
5307
  wireStats();
5279
5308
  })();
@@ -7,6 +7,28 @@ import { type CatalogResponse, type ConfigResponse, type CreateRequest, type Cre
7
7
  export declare class SkillHubApiError extends Error {
8
8
  constructor(message: string);
9
9
  }
10
+ /**
11
+ * Resolve one route path against the page's base.
12
+ *
13
+ * Every route in this family is registered on the server as a root-absolute
14
+ * path (`/api/skill-hub/...`), which is right there but wrong in a browser
15
+ * behind a reverse proxy that owns a path prefix: the page loads from
16
+ * `https://host/app/dsh/` while a root-absolute URL drops the prefix entirely,
17
+ * so the gateway answers 404 HTML and `readJson` reports "invalid JSON
18
+ * response" (issue #18). Resolving against `document.baseURI` is the host's own
19
+ * convention — `dsh-client-connection` opens its browser carrier with
20
+ * `new URL(path.slice(1), document.baseURI)` — so a root deployment resolves to
21
+ * exactly the same absolute path while a prefixed one keeps its prefix, and the
22
+ * gateway strips it before forwarding: the server still receives
23
+ * `/api/skill-hub/*`.
24
+ *
25
+ * `SKILL_HUB_API_ROOT` must stay absolute (the same constant registers the
26
+ * routes on the server), which is why the prefix is applied here at the single
27
+ * outbound boundary rather than in the constants.
28
+ * @param path - one route path, root-absolute unless it is already a full URL.
29
+ * @returns the URL to fetch.
30
+ */
31
+ export declare function resolveApiUrl(path: string): string;
10
32
  /** The browser half's only data entry point. */
11
33
  export declare class SkillHubApi {
12
34
  /** One GET round trip (query already encoded by the caller). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-hub",
3
- "version": "0.3.23",
3
+ "version": "0.3.25",
4
4
  "description": "In-GUI skill hub for DeepSeek Harness (dsh): browse the full local skill catalog from the official ctx.skills registry (every root + third-party providers), toggle skills on/off, inspect bodies, surface frontmatter diagnostics, and scaffold new skills — plus a codex-style skill market (built-in catalog, upstream update checks, one-click update-all) with tracked source sync. The full manager beyond the read-only dsh-skill-manager browser.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Root-absolute route paths must resolve against the page's base, not the site
3
+ * root: behind a reverse proxy that owns a path prefix (`https://host/app/dsh/`)
4
+ * a root-absolute URL loses the prefix and the gateway answers 404 HTML — the
5
+ * `HTTP 404: invalid JSON response` banner in issue #18.
6
+ */
7
+
8
+ import { afterEach, describe, expect, it, vi } from 'vitest'
9
+ import { SkillHubApi, resolveApiUrl } from './api.ts'
10
+
11
+ describe('resolveApiUrl (issue #18)', () => {
12
+ afterEach(() => { vi.unstubAllGlobals() })
13
+
14
+ it('resolves to the site root for a plain deployment', () => {
15
+ vi.stubGlobal('document', { baseURI: 'http://127.0.0.1:3080/' })
16
+ expect(resolveApiUrl('/api/skill-hub/catalog')).toBe('http://127.0.0.1:3080/api/skill-hub/catalog')
17
+ })
18
+
19
+ it('keeps a reverse proxy path prefix', () => {
20
+ vi.stubGlobal('document', { baseURI: 'https://host.example.com/app/dsh/' })
21
+ expect(resolveApiUrl('/api/skill-hub/catalog')).toBe('https://host.example.com/app/dsh/api/skill-hub/catalog')
22
+ })
23
+
24
+ it('passes full URLs through and tolerates a missing document (SSR/tests)', () => {
25
+ expect(resolveApiUrl('https://cdn.example.com/x.json')).toBe('https://cdn.example.com/x.json')
26
+ expect(resolveApiUrl('/api/skill-hub/catalog')).toBe('/api/skill-hub/catalog')
27
+ })
28
+ })
29
+
30
+ describe('SkillHubApi outbound boundary (issue #18)', () => {
31
+ afterEach(() => { vi.unstubAllGlobals() })
32
+
33
+ it('sends every request through the resolved URL, prefix included', async () => {
34
+ vi.stubGlobal('document', { baseURI: 'https://host.example.com/app/dsh/' })
35
+ const seen: string[] = []
36
+ vi.stubGlobal('fetch', async (url: string) => {
37
+ seen.push(String(url))
38
+ return new Response(JSON.stringify({ ok: true, skills: [], disabled: [], diagnostics: [] }), {
39
+ status: 200,
40
+ headers: { 'content-type': 'application/json' },
41
+ })
42
+ })
43
+
44
+ const api = new SkillHubApi()
45
+ await api.catalog()
46
+
47
+ // One outbound path for the whole family: the prefix is applied once, here.
48
+ expect(seen).toHaveLength(1)
49
+ expect(seen[0]).toBe('https://host.example.com/app/dsh/api/skill-hub/catalog')
50
+ })
51
+ })
package/src/client/api.ts CHANGED
@@ -83,6 +83,37 @@ async function readJson<T>(response: Response): Promise<T> {
83
83
  return body as T
84
84
  }
85
85
 
86
+ /**
87
+ * Resolve one route path against the page's base.
88
+ *
89
+ * Every route in this family is registered on the server as a root-absolute
90
+ * path (`/api/skill-hub/...`), which is right there but wrong in a browser
91
+ * behind a reverse proxy that owns a path prefix: the page loads from
92
+ * `https://host/app/dsh/` while a root-absolute URL drops the prefix entirely,
93
+ * so the gateway answers 404 HTML and `readJson` reports "invalid JSON
94
+ * response" (issue #18). Resolving against `document.baseURI` is the host's own
95
+ * convention — `dsh-client-connection` opens its browser carrier with
96
+ * `new URL(path.slice(1), document.baseURI)` — so a root deployment resolves to
97
+ * exactly the same absolute path while a prefixed one keeps its prefix, and the
98
+ * gateway strips it before forwarding: the server still receives
99
+ * `/api/skill-hub/*`.
100
+ *
101
+ * `SKILL_HUB_API_ROOT` must stay absolute (the same constant registers the
102
+ * routes on the server), which is why the prefix is applied here at the single
103
+ * outbound boundary rather than in the constants.
104
+ * @param path - one route path, root-absolute unless it is already a full URL.
105
+ * @returns the URL to fetch.
106
+ */
107
+ export function resolveApiUrl(path: string): string {
108
+ if (!path.startsWith('/')) return path
109
+ if (typeof document === 'undefined') return path
110
+ try {
111
+ return new URL(path.slice(1), document.baseURI).href
112
+ } catch {
113
+ return path
114
+ }
115
+ }
116
+
86
117
  /**
87
118
  * Fetch with a hard timeout: a hung connection (proxy, stalled keep-alive,
88
119
  * host wedged) must never leave the panel stuck on "loading" forever — it
@@ -92,7 +123,7 @@ async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit, ms
92
123
  const controller = new AbortController()
93
124
  const timer = setTimeout(() => controller.abort(), ms)
94
125
  try {
95
- return await fetch(input, { ...init, signal: controller.signal })
126
+ return await fetch(typeof input === 'string' ? resolveApiUrl(input) : input, { ...init, signal: controller.signal })
96
127
  } finally {
97
128
  clearTimeout(timer)
98
129
  }
package/src/index.ts CHANGED
@@ -221,7 +221,40 @@ export function apply(ctx: Context, config?: Config): void {
221
221
  // (including the config route) stays mounted even with the master switch
222
222
  // off so the settings card can always read and re-enable the hub; the
223
223
  // business routes answer 503 while disabled.
224
+ /**
225
+ * Whether this plugin's fiber is still mounted.
226
+ *
227
+ * Async work started from `apply()` — the stats wiring and the persistence
228
+ * probe — can settle after the fiber unloaded: a config rewrite reloads the
229
+ * plugin, and disable/enable tears it down. Service reads on an inactive
230
+ * context throw instead of returning undefined, so every continuation
231
+ * re-checks this before touching `ctx` (issue #19: a cold-start abort with
232
+ * `cannot get required service "systemPrompt" in inactive context`).
233
+ *
234
+ * The generation counters cannot cover this: they only invalidate a build
235
+ * superseded by a *newer* build, and teardown does not bump them.
236
+ */
237
+ const mounted = (): boolean => {
238
+ // `fiber` has always been public on a Cordis context, but stay tolerant of
239
+ // a host whose Cordis predates it: absent means "we cannot tell", and the
240
+ // pre-#19 behaviour is the safer answer there.
241
+ const fiber = (ctx as { fiber?: { uid: number | null } }).fiber
242
+ return fiber === undefined || fiber.uid !== null
243
+ }
244
+
245
+ /** Logger for continuations that may outlive the fiber (#19). */
246
+ const logInfo = (message: string): void => {
247
+ if (mounted()) ctx.logger.info(message)
248
+ }
249
+ const logWarn = (message: string, error?: unknown): void => {
250
+ if (!mounted()) return
251
+ if (error === undefined) ctx.logger.warn(message)
252
+ else ctx.logger.warn(message, error)
253
+ }
254
+
224
255
  const sync = (): void => {
256
+ // A late stats continuation may still reach here after teardown (#19).
257
+ if (!mounted()) return
225
258
  if (disposeSection !== undefined) {
226
259
  disposeSection()
227
260
  disposeSection = undefined
@@ -304,9 +337,9 @@ export function apply(ctx: Context, config?: Config): void {
304
337
  for (const root of [join(home, 'skills'), join(agentsHome, 'skills')]) {
305
338
  try {
306
339
  const c = await cleanupLeftoverImportDirs(root)
307
- if (c > 0) ctx.logger.info(`[dsh-skill-hub] startup cleaned ${c} leftover import temp dir(s) in ${root}`)
340
+ if (c > 0) logInfo(`[dsh-skill-hub] startup cleaned ${c} leftover import temp dir(s) in ${root}`)
308
341
  } catch (error) {
309
- ctx.logger.warn('[dsh-skill-hub] startup cleanup failed', error)
342
+ logWarn('[dsh-skill-hub] startup cleanup failed', error)
310
343
  }
311
344
  }
312
345
  // 对账:磁盘上已有 .disabled、sidecar 却无记录(状态文件被恢复/手改、旧版本
@@ -314,32 +347,34 @@ export function apply(ctx: Context, config?: Config): void {
314
347
  try {
315
348
  const reconciled = await reconcileDisabledSkills(store, home)
316
349
  if (reconciled.length > 0) {
317
- ctx.logger.info(`[dsh-skill-hub] startup reconciled ${reconciled.length} disabled skill record(s): ${reconciled.map((entry) => entry.name).join(', ')}`)
350
+ logInfo(`[dsh-skill-hub] startup reconciled ${reconciled.length} disabled skill record(s): ${reconciled.map((entry) => entry.name).join(', ')}`)
318
351
  }
319
352
  } catch (error) {
320
- ctx.logger.warn('[dsh-skill-hub] startup disabled-skill reconcile failed', error)
353
+ logWarn('[dsh-skill-hub] startup disabled-skill reconcile failed', error)
321
354
  }
322
355
  // 反向对账:记录还在、文件已不在 → 修路径或删记录。不做这一步,那条记录会
323
356
  // 把名字永久锁死(启用/新建/删除三条路都失败)。
324
357
  try {
325
358
  const pruned = await pruneStaleDisabledSkills(store, home)
326
359
  if (pruned.repaired.length > 0 || pruned.removed.length > 0) {
327
- ctx.logger.info(`[dsh-skill-hub] startup pruned disabled records: repaired ${pruned.repaired.length} (${pruned.repaired.map((entry) => entry.name).join(', ') || '-'}), removed ${pruned.removed.length} (${pruned.removed.join(', ') || '-'})`)
360
+ logInfo(`[dsh-skill-hub] startup pruned disabled records: repaired ${pruned.repaired.length} (${pruned.repaired.map((entry) => entry.name).join(', ') || '-'}), removed ${pruned.removed.length} (${pruned.removed.join(', ') || '-'})`)
328
361
  }
329
362
  } catch (error) {
330
- ctx.logger.warn('[dsh-skill-hub] startup disabled-record prune failed', error)
363
+ logWarn('[dsh-skill-hub] startup disabled-record prune failed', error)
331
364
  }
332
365
  })()
333
366
 
334
367
  void (async () => {
335
368
  try {
336
369
  const legacy = await store.getConfig()
370
+ // The read is awaited; the plugin may have unloaded meanwhile (#19).
371
+ if (!mounted()) return
337
372
  if (Object.keys(legacy).length > 0 && Object.keys(saved()).length === 0) {
338
373
  const settings = settingsOf()
339
374
  if (settings !== undefined) await settings.update(ENTRY_ID, legacy as Record<string, unknown>)
340
375
  }
341
376
  } catch (error) {
342
- ctx.logger.warn('[dsh-skill-hub] sidecar config migration into the settings namespace failed', error)
377
+ logWarn('[dsh-skill-hub] sidecar config migration into the settings namespace failed', error)
343
378
  }
344
379
  })()
345
380
 
@@ -375,7 +410,7 @@ export function apply(ctx: Context, config?: Config): void {
375
410
  // 重启后面板秒出旧数、后台重扫。扫描间隔与滚动窗口都从设置命名空间
376
411
  // 实时读取——卡片里改完即生效,无需重启。
377
412
  const checkpoint = await store.getSkillStatsState().catch(() => undefined)
378
- if (generation !== statsGeneration) return
413
+ if (generation !== statsGeneration || !mounted()) return
379
414
  const scanMinutes = (): number => {
380
415
  const value = current().statsScanMinutes
381
416
  return typeof value === 'number' && value >= 1 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsScanMinutes
@@ -407,11 +442,11 @@ export function apply(ctx: Context, config?: Config): void {
407
442
  }),
408
443
  onCheckpoint: (next) => {
409
444
  void store.saveSkillStatsState(next).catch((error) => {
410
- ctx.logger.warn('[dsh-skill-hub] persisting skill-stats checkpoint failed', error)
445
+ if (mounted()) ctx.logger.warn('[dsh-skill-hub] persisting skill-stats checkpoint failed', error)
411
446
  })
412
447
  },
413
448
  })
414
- if (generation !== statsGeneration) return
449
+ if (generation !== statsGeneration || !mounted()) return
415
450
  ctx.logger.info(`[dsh-skill-hub] stats seam: ${cold === undefined ? 'session-query (fallback)' : 'session-persistence (cold)'}`)
416
451
  stats = reader
417
452
  sync()
@@ -427,15 +462,17 @@ export function apply(ctx: Context, config?: Config): void {
427
462
  const raw = (pctx as unknown as { sessionPersistence?: unknown }).sessionPersistence
428
463
  const seam = asPersistenceSeam(raw)
429
464
  if (seam === undefined) {
430
- ctx.logger.warn('[dsh-skill-hub] sessionPersistence shape mismatch, staying on query fallback')
465
+ if (mounted()) ctx.logger.warn('[dsh-skill-hub] sessionPersistence shape mismatch, staying on query fallback')
431
466
  return
432
467
  }
433
468
  try {
434
469
  await seam.list() // probe: headers only, must succeed before trusting the cold path
435
470
  } catch (error) {
436
- ctx.logger.warn('[dsh-skill-hub] persistence seam probe failed, staying on query fallback', error)
471
+ if (mounted()) ctx.logger.warn('[dsh-skill-hub] persistence seam probe failed, staying on query fallback', error)
437
472
  return
438
473
  }
474
+ // The probe is awaited, so the plugin may have unloaded meanwhile (#19).
475
+ if (!mounted()) return
439
476
  statsPersistence = seam
440
477
  wireStats()
441
478
  })()
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Plugin lifecycle: asynchronous work started from `apply()` must not touch the
3
+ * context after the fiber unloaded. Issue #19 was a cold-start abort —
4
+ * `cannot get required service "systemPrompt" in inactive context` — when the
5
+ * stats wiring settled after a config-driven reload had torn the plugin down.
6
+ */
7
+
8
+ import { mkdtemp, rm } from 'node:fs/promises'
9
+ import { tmpdir } from 'node:os'
10
+ import { join } from 'node:path'
11
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
12
+ import { apply } from './index.ts'
13
+
14
+ /** Minimal context double: records what the plugin registers, exposes a controllable fiber. */
15
+ function fakeHost() {
16
+ const sections: string[] = []
17
+ const routes: string[] = []
18
+ const injected: Array<(context: unknown) => void> = []
19
+ const fiber: { uid: number | null } = { uid: 1 }
20
+ const context = {
21
+ fiber,
22
+ logger: { info: () => {}, warn: () => {} },
23
+ /** No optional services (settings) and no Connection: every optional path degrades. */
24
+ get: () => undefined,
25
+ skills: {
26
+ registerProvider: () => () => {},
27
+ snapshot: async () => ({ skills: [], complete: true }),
28
+ get: async () => undefined,
29
+ },
30
+ systemPrompt: {
31
+ section: (spec: { name: string }) => {
32
+ sections.push(spec.name)
33
+ return () => {}
34
+ },
35
+ },
36
+ webServer: {
37
+ register: (route: { path: string }) => {
38
+ routes.push(route.path)
39
+ return () => {}
40
+ },
41
+ },
42
+ effect: (body: () => unknown) => {
43
+ const produced = body()
44
+ return () => {
45
+ if (typeof produced === 'function') (produced as () => void)()
46
+ }
47
+ },
48
+ inject: (_names: unknown, run: (child: unknown) => void) => { injected.push(run) },
49
+ }
50
+ return { context, sections, routes, fiber, injected }
51
+ }
52
+
53
+ /** The session-query seam the stats wiring accepts. */
54
+ const queryStub = {
55
+ listSessions: async () => [],
56
+ readSession: async () => ({ events: [] }),
57
+ }
58
+
59
+ describe('plugin lifecycle (issue #19)', () => {
60
+ let home: string
61
+ const originalHome = process.env.DSH_HOME
62
+
63
+ beforeEach(async () => {
64
+ home = await mkdtemp(join(tmpdir(), 'dsh-skill-hub-lifecycle-'))
65
+ process.env.DSH_HOME = home
66
+ })
67
+
68
+ afterEach(async () => {
69
+ if (originalHome === undefined) delete process.env.DSH_HOME
70
+ else process.env.DSH_HOME = originalHome
71
+ await rm(home, { recursive: true, force: true })
72
+ })
73
+
74
+ it('mounts the announced section and route family once for an active fiber', () => {
75
+ const host = fakeHost()
76
+ apply(host.context as never, undefined)
77
+ expect(host.sections).toEqual(['plugin:dsh-skill-hub'])
78
+ expect(host.routes).toContain('/api/skill-hub/catalog')
79
+ })
80
+
81
+ it('drops a late stats continuation instead of syncing on an inactive context', async () => {
82
+ const host = fakeHost()
83
+ apply(host.context as never, undefined)
84
+ const beforeUnload = host.sections.length
85
+
86
+ // The session-query service arrives: wireStats() starts an async build that
87
+ // first awaits the persisted scan checkpoint. The nested persistence probe
88
+ // is left unmounted (the child `inject` never calls back).
89
+ expect(host.injected).toHaveLength(1)
90
+ host.injected[0]!({ sessionQuery: queryStub, inject: () => {} })
91
+
92
+ // A config rewrite (or disable) unloads the fiber while that await is in
93
+ // flight — the exact window issue #19 aborted on.
94
+ host.fiber.uid = null
95
+ await new Promise((resolve) => setTimeout(resolve, 100))
96
+
97
+ // Without the guard the continuation called sync(), which re-registered the
98
+ // announcement on the dead context (and threw on a real host).
99
+ expect(host.sections).toHaveLength(beforeUnload)
100
+ })
101
+ })