dsh-skill-hub 0.3.23 → 0.3.24

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/CONTRIBUTING.md CHANGED
@@ -39,7 +39,7 @@ src/client/locales/ dictionaries by view (common/skills/market/sources/detai
39
39
  ```bash
40
40
  npm install
41
41
  npm run typecheck # tsc --noEmit
42
- npm test # vitest (288 tests across 14 suites)
42
+ npm test # vitest (290 tests across 15 suites)
43
43
  npm run build # tsc declarations + tsdown bundles (lib/index.js + lib/client.js)
44
44
  ```
45
45
 
package/README.md CHANGED
@@ -108,7 +108,7 @@ Host uses only `ctx.skills.snapshot/get`, `ctx.webServer.register`, `ctx.systemP
108
108
 
109
109
  ```bash
110
110
  npm run typecheck # tsc --noEmit
111
- npm test # 288 tests, 14 suites
111
+ npm test # 290 tests, 15 suites
112
112
  npm run build # tsc + tsdown → lib/index.js + lib/client.js
113
113
  ```
114
114
 
package/README.zh.md CHANGED
@@ -108,7 +108,7 @@ GitHub 仓库 ──扫描/导入──▶ ~/.dsh/skills
108
108
 
109
109
  ```bash
110
110
  npm run typecheck # tsc --noEmit
111
- npm test # 288 tests, 14 suites
111
+ npm test # 290 tests, 15 suites
112
112
  npm run build # tsc + tsdown → lib/index.js + lib/client.js
113
113
  ```
114
114
 
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
  })();
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.24",
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": {
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
+ })