kennel 0.2.6 → 0.2.7

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 (116) hide show
  1. package/README.md +9 -6
  2. package/dist/cli/src/plugins/external.d.ts +3 -0
  3. package/dist/cli/src/plugins/external.js +22 -10
  4. package/dist/cli/src/site/hot-viewer.js +45 -38
  5. package/dist/cli/src/site/viewer.js +13 -8
  6. package/dist/plugins/external.d.ts +3 -0
  7. package/dist/plugins/external.js +22 -10
  8. package/dist/site/hot-viewer.js +45 -38
  9. package/dist/site/viewer.js +13 -8
  10. package/kernel/apps/web/scripts/dev.mjs +4 -0
  11. package/kernel/apps/web/src/api/events.ts +60 -24
  12. package/kernel/apps/web/vite.config.ts +2 -0
  13. package/kernel/packages/notes/src/pages/notes-page.tsx +34 -1
  14. package/kernel/packages/notes/src/ui/folder-notes-list.tsx +10 -74
  15. package/kernel/packages/plugins/package.json +1 -1
  16. package/package.json +2 -2
  17. package/web/assets/{abnfDiagram-VRR7QNED-xd_g1uK3.js → abnfDiagram-VRR7QNED-CDr-U31F.js} +1 -1
  18. package/web/assets/app-DHGorUqZ.js +2 -0
  19. package/web/assets/app-options-CtkiC8GR.js +25 -0
  20. package/web/assets/{app-view-header-CNk5KnA-.js → app-view-header-DbQuD6_F.js} +1 -1
  21. package/web/assets/{arc-B0u5yBJA.js → arc-Cwz5OeLQ.js} +1 -1
  22. package/web/assets/{architectureDiagram-ZJ3FMSHR-DfxOAo8P.js → architectureDiagram-ZJ3FMSHR-igGC0b-J.js} +1 -1
  23. package/web/assets/{ask-tray-SscJecDY.js → ask-tray-y8nkSKNG.js} +1 -1
  24. package/web/assets/{blockDiagram-677ZJIJ3-Ci3Usxh6.js → blockDiagram-677ZJIJ3-D4SDltQ_.js} +1 -1
  25. package/web/assets/{c4Diagram-LMCZKHZV-BqgwIVgh.js → c4Diagram-LMCZKHZV-Br15uN-s.js} +1 -1
  26. package/web/assets/channel-DRnAhH31.js +1 -0
  27. package/web/assets/{chunk-2Q5K7J3B-CfX69l2U.js → chunk-2Q5K7J3B-B0RqrvJA.js} +1 -1
  28. package/web/assets/{chunk-32BRIVSS-CuFlzZkl.js → chunk-32BRIVSS-De9VBEyE.js} +1 -1
  29. package/web/assets/{chunk-5VM5RSS4-DidCMlJa.js → chunk-5VM5RSS4-YIMlZtW3.js} +1 -1
  30. package/web/assets/{chunk-EX3LRPZG-B5CDm1dS.js → chunk-EX3LRPZG-y9kGcrYi.js} +1 -1
  31. package/web/assets/{chunk-JWPE2WC7-D5fGV_sH.js → chunk-JWPE2WC7-D0A13HAu.js} +1 -1
  32. package/web/assets/{chunk-MOJQB5TN-ayhH5Q-H.js → chunk-MOJQB5TN-8v4jL8iu.js} +1 -1
  33. package/web/assets/{chunk-RYQCIY6F-C4BfOOZn.js → chunk-RYQCIY6F-Ri_iNmIp.js} +1 -1
  34. package/web/assets/{chunk-V7JOEXUC-DpUEIM9V.js → chunk-V7JOEXUC-b0mIbG_X.js} +1 -1
  35. package/web/assets/{chunk-VR4S4FIN-DoQuFFRh.js → chunk-VR4S4FIN-BkTZWt1n.js} +1 -1
  36. package/web/assets/{chunk-XXDRQBXY-CI9eiLwn.js → chunk-XXDRQBXY-D7muOXcd.js} +1 -1
  37. package/web/assets/classDiagram-OUVF2IWQ-BPkIpFlc.js +1 -0
  38. package/web/assets/classDiagram-v2-EOCWNBFH-BPkIpFlc.js +1 -0
  39. package/web/assets/{client-Dk-NBvJR.js → client-BMso7lHb.js} +2 -2
  40. package/web/assets/client-Bkl3gwZ5.js +17 -0
  41. package/web/assets/client-soK_bLWT.js +5 -0
  42. package/web/assets/{command-zkzH4hVz.js → command-CsN8TfJg.js} +1 -1
  43. package/web/assets/{copy-context-D5oiWrYC.js → copy-context-DmBNpffM.js} +1 -1
  44. package/web/assets/{cose-bilkent-JH36ORCC-BHRk1mO3.js → cose-bilkent-JH36ORCC-7hnQZylB.js} +1 -1
  45. package/web/assets/{cynefin-VYW2F7L2-C1B09TRE.js → cynefin-VYW2F7L2-CTbqXuvk.js} +1 -1
  46. package/web/assets/{cynefinDiagram-TSTJHNR4-B27qXQbv.js → cynefinDiagram-TSTJHNR4-CmIJtocX.js} +1 -1
  47. package/web/assets/{dagre-VKFMJZFB-B1qEi32g.js → dagre-VKFMJZFB-BLPHNs2Y.js} +1 -1
  48. package/web/assets/{diagram-FQU43EPY-BMkkgADD.js → diagram-FQU43EPY-L6FVWAuj.js} +1 -1
  49. package/web/assets/{diagram-G47NLZAW-Cb8_g1A5.js → diagram-G47NLZAW-Bb26dbIM.js} +1 -1
  50. package/web/assets/{diagram-NH7WQ7WH-B51wuc5m.js → diagram-NH7WQ7WH-BBauwdU-.js} +1 -1
  51. package/web/assets/{diagram-OA4YK3LP-xnsecbbI.js → diagram-OA4YK3LP-Dj_9FE7R.js} +1 -1
  52. package/web/assets/{diagram-WEI45ONY-DSZdyaAV.js → diagram-WEI45ONY-Co1MS5BO.js} +1 -1
  53. package/web/assets/{document-Dc2dftTW.js → document-_ETc5epn.js} +3 -3
  54. package/web/assets/{ebnfDiagram-CCIWWBDH-aTHmcUGy.js → ebnfDiagram-CCIWWBDH-COGAgW5z.js} +1 -1
  55. package/web/assets/{entities-CCUtpskY.js → entities-ByGFSWnQ.js} +1 -1
  56. package/web/assets/{erDiagram-Q63AITRT-CUUPBcvo.js → erDiagram-Q63AITRT-CRMKCr0m.js} +1 -1
  57. package/web/assets/{file-viewer-DJ3rsmef.js → file-viewer-Dqx7DbTw.js} +1 -1
  58. package/web/assets/{flowDiagram-23GEKE2U-aTQXx2Mh.js → flowDiagram-23GEKE2U-Drv72N5g.js} +1 -1
  59. package/web/assets/folder-filter-bar-Bs_uyU0g.js +4 -0
  60. package/web/assets/folder-image-BBNRcu1j.js +1 -0
  61. package/web/assets/{folder-landing-DCnzYXXY.js → folder-landing-0EXAzEpD.js} +3 -3
  62. package/web/assets/{folder-list-DvVkVjiR.js → folder-list-Do8Kl8uk.js} +1 -1
  63. package/web/assets/{folder-metadata-C9hMMzIH.js → folder-metadata-DncP3Vx2.js} +1 -1
  64. package/web/assets/folder-mobile-bar-D-XXKrip.js +1 -0
  65. package/web/assets/folder-plugin-view-DoRQa8Dz.js +3 -0
  66. package/web/assets/{ganttDiagram-NO4QXBWP-DiwdUp7u.js → ganttDiagram-NO4QXBWP-DHEt2_9K.js} +1 -1
  67. package/web/assets/{gitGraphDiagram-IHSO6WYX-BL2gG_IS.js → gitGraphDiagram-IHSO6WYX-Bt6XsYTq.js} +1 -1
  68. package/web/assets/{index-CUTtMSyJ.js → index-7JCizcu7.js} +2 -2
  69. package/web/assets/index-DY0VGBht.js +1 -0
  70. package/web/assets/{infoDiagram-FWYZ7A6U-B3n0zZpt.js → infoDiagram-FWYZ7A6U-961DCJAH.js} +1 -1
  71. package/web/assets/{ishikawaDiagram-FXEZZL3T-D7YytbEv.js → ishikawaDiagram-FXEZZL3T-ibSYK50l.js} +1 -1
  72. package/web/assets/{journeyDiagram-5HDEW3XC-BYwqEl3C.js → journeyDiagram-5HDEW3XC-Cd0ZPqw2.js} +1 -1
  73. package/web/assets/{kanban-definition-HUTT4EX6-BQHNJgXZ.js → kanban-definition-HUTT4EX6-Bu5skY8G.js} +1 -1
  74. package/web/assets/{linear-BPiQCLNk.js → linear-Cf8Xflqn.js} +1 -1
  75. package/web/assets/{mermaid.core-CD-KvBjV.js → mermaid.core-C9iHkrxr.js} +4 -4
  76. package/web/assets/{metadata-COJ7fZul.js → metadata-CLq-ZGmh.js} +1 -1
  77. package/web/assets/{mindmap-definition-LN4V7U3C-C1L_gyoj.js → mindmap-definition-LN4V7U3C-C5bK3O7k.js} +1 -1
  78. package/web/assets/{pegDiagram-2B236MQR-BDAfItqS.js → pegDiagram-2B236MQR-5rN2nkjz.js} +1 -1
  79. package/web/assets/{pieDiagram-ENE6RG2P-CkZYH3Vz.js → pieDiagram-ENE6RG2P-DnSBFuIb.js} +1 -1
  80. package/web/assets/popover-D7MTK-sz.js +9 -0
  81. package/web/assets/{project-search-dialog-B2oqMT6h.js → project-search-dialog-Bo1JWdqR.js} +1 -1
  82. package/web/assets/{quadrantDiagram-ABIIQ3AL-Dy8Al3YZ.js → quadrantDiagram-ABIIQ3AL-xinqVpVM.js} +1 -1
  83. package/web/assets/{railroadDiagram-RFXS5EU6-Dd9_yo7B.js → railroadDiagram-RFXS5EU6-GsASFEVa.js} +1 -1
  84. package/web/assets/{requirementDiagram-TGXJPOKE-DPhIUsbs.js → requirementDiagram-TGXJPOKE-DS5CqNNo.js} +1 -1
  85. package/web/assets/{sankeyDiagram-HTMAVEWB-B2QkYtub.js → sankeyDiagram-HTMAVEWB-DsnvjCXC.js} +1 -1
  86. package/web/assets/{search-C1J0-xiB.js → search-BA4r_Yoa.js} +1 -1
  87. package/web/assets/select-D4TpCgnU.js +30 -0
  88. package/web/assets/{selection-popup-qhlUFzGq.js → selection-popup-DIME-60e.js} +3 -3
  89. package/web/assets/{sequenceDiagram-DBY2YBRQ-Bx7fcZDx.js → sequenceDiagram-DBY2YBRQ-PI0WgJrf.js} +1 -1
  90. package/web/assets/{sizeCapture-X5ZJPWSS-CIXIhl_A.js → sizeCapture-X5ZJPWSS-Bpul3MSt.js} +1 -1
  91. package/web/assets/{stateDiagram-2N3HPSRC-DCrdAflB.js → stateDiagram-2N3HPSRC-DZEEP9Z2.js} +1 -1
  92. package/web/assets/stateDiagram-v2-6OUMAXLB-WAp7-BWX.js +1 -0
  93. package/web/assets/{swimlanes-5IMT3BWC-DA2vc7iZ.js → swimlanes-5IMT3BWC-_qVjNiCh.js} +2 -2
  94. package/web/assets/swimlanesDiagram-G3AALYLV-Dy4wBDSi.js +8 -0
  95. package/web/assets/{timeline-definition-FHXFAJF6-lKfJiV9V.js → timeline-definition-FHXFAJF6-CJev7rIB.js} +1 -1
  96. package/web/assets/use-folder-filter-hA6fa7nm.js +1 -0
  97. package/web/assets/{vennDiagram-L72KCM5P-DO1q2KzG.js → vennDiagram-L72KCM5P-BrTVvREy.js} +1 -1
  98. package/web/assets/{view-context-DXKM3-l1.js → view-context-Co01ON81.js} +35 -35
  99. package/web/assets/{viewer-capabilities-Cig_xM9u.js → viewer-capabilities-C_kjcYcl.js} +1 -1
  100. package/web/assets/{wardleyDiagram-EHGQE667-Dal9LkuZ.js → wardleyDiagram-EHGQE667-XwMSgrT5.js} +1 -1
  101. package/web/assets/{xychartDiagram-FW5EYKEG-B3dPLPII.js → xychartDiagram-FW5EYKEG-Be1kFxeC.js} +1 -1
  102. package/web/index.html +1 -1
  103. package/web/assets/app-CX20nNIa.js +0 -2
  104. package/web/assets/app-options-CkPJE5ep.js +0 -25
  105. package/web/assets/channel-X7WHyxxT.js +0 -1
  106. package/web/assets/classDiagram-OUVF2IWQ-BjsLQ3dz.js +0 -1
  107. package/web/assets/classDiagram-v2-EOCWNBFH-BjsLQ3dz.js +0 -1
  108. package/web/assets/client-7OWw5kW2.js +0 -6
  109. package/web/assets/client-DYj_td4Q.js +0 -17
  110. package/web/assets/folder-image-Bnv43tzP.js +0 -1
  111. package/web/assets/folder-mobile-bar-VoTL1GS0.js +0 -1
  112. package/web/assets/folder-plugin-view-Cvd804ZU.js +0 -6
  113. package/web/assets/popover-0oA2lLUV.js +0 -9
  114. package/web/assets/select-CtXC_PLC.js +0 -30
  115. package/web/assets/stateDiagram-v2-6OUMAXLB-0KCFLYeQ.js +0 -1
  116. package/web/assets/swimlanesDiagram-G3AALYLV-CAw3AV84.js +0 -8
package/README.md CHANGED
@@ -116,14 +116,17 @@ diagnostics come from the declared schema and Markdown frontmatter. Pass
116
116
  list. See [Core folders and metadata indexing](../docs/core-folders.md) for
117
117
  patterns, parser support, navigation labels and refresh semantics.
118
118
 
119
- `kennel view` starts the source Vite viewer from a Kennel checkout when no
119
+ `kennel view` starts the source Vite viewer from a Kennel checkout, also when
120
120
  external plugins are installed. React and CSS changes use HMR. Changes to
121
121
  schema and content files cause a metadata refetch without a full page reload.
122
- When external plugins are installed, `kennel view` uses the composed static
123
- kernel so `view`, `pop`, `serve`, default, `core`, and `init` all use the same
124
- kernel key. `kennel serve` starts the packaged static viewer. The hot viewer
125
- uses the current directory when no folder is given. It also reuses a Kennel Vite
126
- server that already runs on the selected port.
122
+ The Vite viewer loads the installed external plugins and reports a
123
+ runtime-independent plugin-set key from `/api/health`. `kennel serve` starts
124
+ the packaged static viewer with its own runtime-specific cache key. The hot
125
+ viewer uses the current directory when no folder is given. It reuses a Kennel Vite
126
+ server on the selected port only when that server has the same kernel key. If
127
+ the plugin set changed, stop the running viewer and run the command again. A
128
+ local Vite viewer can open any local folder; the static viewer is reused only
129
+ for its own root or a folder below it.
127
130
 
128
131
  `kennel publish init cloudflare` is the exception for deploy wiring: it scaffolds
129
132
  a Cloudflare Worker email-code gate for an existing docs workspace by writing
@@ -26,6 +26,9 @@ export declare function readExternalPlugins(): ExternalPluginEntry[];
26
26
  export declare function installExternalPlugin(url: string, options?: {
27
27
  directory?: string;
28
28
  }): Promise<ExternalPluginEntry>;
29
+ /** Identifies the installed plugin set, independent of the Node runtime. */
30
+ export declare function hotKernelKey(plugins?: ExternalPluginEntry[]): string;
31
+ /** Identifies a runtime-specific static kernel cache entry. */
29
32
  export declare function kernelKey(plugins?: ExternalPluginEntry[]): string;
30
33
  export type ActiveKernel = {
31
34
  buildIdentity: string;
@@ -373,21 +373,33 @@ export async function installExternalPlugin(url, options = {}) {
373
373
  function cliRoot() {
374
374
  return resolve(dirname(fileURLToPath(import.meta.url)), "../..");
375
375
  }
376
+ function kernelPlugins(plugins) {
377
+ return plugins.map((plugin) => ({
378
+ url: plugin.url,
379
+ requestedRef: plugin.requestedRef ?? null,
380
+ packageName: plugin.packageName,
381
+ id: plugin.id,
382
+ directory: plugin.directory ?? null,
383
+ commit: plugin.commit,
384
+ trigger: plugin.trigger,
385
+ entries: plugin.entries,
386
+ }));
387
+ }
388
+ /** Identifies the installed plugin set, independent of the Node runtime. */
389
+ export function hotKernelKey(plugins = readExternalPlugins()) {
390
+ return hash(JSON.stringify({
391
+ buildIdentity: BUILD_IDENTITY,
392
+ schema: KENNEL_KERNEL_SCHEMA,
393
+ plugins: kernelPlugins(plugins),
394
+ }));
395
+ }
396
+ /** Identifies a runtime-specific static kernel cache entry. */
376
397
  export function kernelKey(plugins = readExternalPlugins()) {
377
398
  return hash(JSON.stringify({
378
399
  buildIdentity: BUILD_IDENTITY,
379
400
  schema: KENNEL_KERNEL_SCHEMA,
380
401
  runtime: BUILD_RUNTIME,
381
- plugins: plugins.map((plugin) => ({
382
- url: plugin.url,
383
- requestedRef: plugin.requestedRef ?? null,
384
- packageName: plugin.packageName,
385
- id: plugin.id,
386
- directory: plugin.directory ?? null,
387
- commit: plugin.commit,
388
- trigger: plugin.trigger,
389
- entries: plugin.entries,
390
- })),
402
+ plugins: kernelPlugins(plugins),
391
403
  }));
392
404
  }
393
405
  function kernelRoot(key) {
@@ -7,12 +7,15 @@ import { tmpdir } from 'node:os';
7
7
  import { dirname, relative, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { createKennelClient } from '../core/api-client.js';
10
+ import { hotKernelKey, readExternalPlugins } from '../plugins/external.js';
10
11
  function sourceRoot() {
11
12
  return resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
12
13
  }
13
14
  async function viewerStatus(url) {
14
15
  return createKennelClient({ baseUrl: url }).health({ signal: AbortSignal.timeout(1_000) })
15
- .then((body) => body.viewer === 'kennel-vite' || body.viewer === 'kennel-core' ? 'running' : 'other')
16
+ .then((body) => body.viewer === 'kennel-vite' || body.viewer === 'kennel-core'
17
+ ? { viewer: body.viewer, kernelKey: body.kernelKey }
18
+ : 'other')
16
19
  .catch((error) => {
17
20
  // HTTP rejection means a different service owns the port; connection and
18
21
  // timeout failures mean the viewer is unavailable, so startup may retry.
@@ -20,17 +23,17 @@ async function viewerStatus(url) {
20
23
  ? 'other' : 'unavailable';
21
24
  });
22
25
  }
23
- async function viewerRunning(url, attempts = 1, waitOnOther = false) {
26
+ async function runningViewer(url, attempts = 1, waitOnOther = false) {
24
27
  for (let attempt = 0; attempt < attempts; attempt += 1) {
25
28
  const status = await viewerStatus(url);
26
- if (status === 'running')
27
- return true;
29
+ if (typeof status === 'object')
30
+ return status;
28
31
  if (status === 'other' && !waitOnOther)
29
- return false;
32
+ return undefined;
30
33
  if (attempt + 1 < attempts)
31
34
  await new Promise((done) => setTimeout(done, 250));
32
35
  }
33
- return false;
36
+ return undefined;
34
37
  }
35
38
  async function portOpen(host, port) {
36
39
  return new Promise((done) => {
@@ -132,8 +135,20 @@ export async function hotViewer(options) {
132
135
  console.log(`bun ${[...args, ...(options.open ? ['--open', openPath] : [])].join(' ')}`);
133
136
  return;
134
137
  }
135
- const status = await viewerStatus(url);
136
- if (status === 'running') {
138
+ const plugins = readExternalPlugins();
139
+ // Must match the key apps/web/scripts/dev.mjs gives Vite as KENNEL_KERNEL_KEY.
140
+ const expectedKey = plugins.length > 0 ? hotKernelKey(plugins) : 'core';
141
+ // Reuse only a Vite viewer that serves the same plugin set. Anything else on
142
+ // the port is stale for this kernel and must be restarted, never reused.
143
+ const reuse = (running) => {
144
+ const restart = 'Stop that viewer (Ctrl+C in its terminal) and run this command again, or select another port.';
145
+ if (running.viewer !== 'kennel-vite') {
146
+ throw new Error(`Port ${options.port} runs the static Kennel viewer, not the hot viewer. ${restart}`);
147
+ }
148
+ if (running.kernelKey !== expectedKey) {
149
+ throw new Error(`The Kennel hot viewer on port ${options.port} uses kernel ${running.kernelKey}, `
150
+ + `but the installed plugins need kernel ${expectedKey}. ${restart}`);
151
+ }
137
152
  if (options.open) {
138
153
  // Do not stack another tab: the viewer is already open. Print the URL
139
154
  // so the caller can still get to it deliberately.
@@ -142,21 +157,19 @@ export async function hotViewer(options) {
142
157
  else {
143
158
  console.log('Kennel hot viewer is already running. Reusing it.');
144
159
  }
160
+ };
161
+ const status = await viewerStatus(url);
162
+ if (typeof status === 'object') {
163
+ reuse(status);
145
164
  return;
146
165
  }
147
166
  if (status === 'other') {
148
167
  throw new Error(`Port ${options.port} is used by another service. Stop it or select another port.`);
149
168
  }
150
169
  if (await portOpen(options.host, options.port)) {
151
- if (await viewerRunning(url, 20, true)) {
152
- if (options.open) {
153
- // Do not stack another tab: the viewer is already open. Print the URL
154
- // so the caller can still get to it deliberately.
155
- console.log(`Kennel hot viewer is already running at ${viewerUrl}`);
156
- }
157
- else {
158
- console.log('Kennel hot viewer is already running. Reusing it.');
159
- }
170
+ const running = await runningViewer(url, 20, true);
171
+ if (running) {
172
+ reuse(running);
160
173
  return;
161
174
  }
162
175
  throw new Error(`Port ${options.port} is used by another service. Stop it or select another port.`);
@@ -164,15 +177,9 @@ export async function hotViewer(options) {
164
177
  const lock = startupLock(options.host, options.port);
165
178
  let token = await acquireLock(lock);
166
179
  if (!token) {
167
- if (await viewerRunning(url, 40, true)) {
168
- if (options.open) {
169
- // Do not stack another tab: the viewer is already open. Print the URL
170
- // so the caller can still get to it deliberately.
171
- console.log(`Kennel hot viewer is already running at ${viewerUrl}`);
172
- }
173
- else {
174
- console.log('Kennel hot viewer is already running. Reusing it.');
175
- }
180
+ const running = await runningViewer(url, 40, true);
181
+ if (running) {
182
+ reuse(running);
176
183
  return;
177
184
  }
178
185
  await rm(lock, { force: true, recursive: true });
@@ -180,16 +187,10 @@ export async function hotViewer(options) {
180
187
  }
181
188
  if (!token)
182
189
  throw new Error(`Could not acquire the hot viewer startup lock for port ${options.port}.`);
183
- if (await viewerRunning(url)) {
190
+ const running = await runningViewer(url);
191
+ if (running) {
184
192
  await releaseLock(lock, token);
185
- if (options.open) {
186
- // Do not stack another tab: the viewer is already open. Print the URL
187
- // so the caller can still get to it deliberately.
188
- console.log(`Kennel hot viewer is already running at ${viewerUrl}`);
189
- }
190
- else {
191
- console.log('Kennel hot viewer is already running. Reusing it.');
192
- }
193
+ reuse(running);
193
194
  return;
194
195
  }
195
196
  console.log(`bun ${args.join(' ')}`);
@@ -220,9 +221,15 @@ export async function hotViewer(options) {
220
221
  done();
221
222
  return;
222
223
  }
223
- if (await viewerRunning(url, 20, true)) {
224
- console.log('Kennel hot viewer is already running. Reusing it.');
225
- done();
224
+ const existing = await runningViewer(url, 20, true);
225
+ if (existing) {
226
+ try {
227
+ reuse(existing);
228
+ done();
229
+ }
230
+ catch (error) {
231
+ fail(error);
232
+ }
226
233
  return;
227
234
  }
228
235
  fail(new Error(`Kennel hot viewer stopped with exit code ${code ?? 'unknown'}`));
@@ -259,17 +259,15 @@ function tailscaleHost() {
259
259
  export async function viewer(input) {
260
260
  const portExplicit = viewerPortWasExplicit(input);
261
261
  const options = splitViewerArgs(input);
262
- const { ensureKernel, readExternalPlugins } = await import("../plugins/external.js");
263
- const externalPlugins = readExternalPlugins();
264
- if (options.hot && externalPlugins.length === 0) {
262
+ if (options.hot) {
265
263
  const { hotViewer } = await import("./hot-viewer.js");
266
264
  await hotViewer(options);
267
265
  return;
268
266
  }
269
- if (options.hot && externalPlugins.length > 0) {
270
- console.log("Kennel hot viewer is disabled for external plugin kernels; using the composed static kernel.");
271
- }
267
+ const { ensureKernel, hotKernelKey, readExternalPlugins } = await import("../plugins/external.js");
268
+ const externalPlugins = readExternalPlugins();
272
269
  const kernel = await ensureKernel(externalPlugins);
270
+ const viteKernelKey = externalPlugins.length > 0 ? hotKernelKey(externalPlugins) : "core";
273
271
  const web = kernel.webRoot;
274
272
  if (!existsSync(resolve(web, "index.html"))) {
275
273
  throw new Error(`Kennel web app not found. Run the CLI build first.`);
@@ -298,9 +296,16 @@ export async function viewer(input) {
298
296
  .join(",");
299
297
  const initialViewerUrl = `http://${options.host}:${options.port}`;
300
298
  const existing = await runningViewer(initialViewerUrl);
299
+ // A local Vite viewer is not root-restricted, so it can open any local
300
+ // folder. The static kennel-core viewer is root-scoped: reuse it only for
301
+ // its own root or a descendant.
301
302
  if (existing &&
302
- existing.kernelKey === kernel.key &&
303
- isSameOrDescendant(existing.root, options.root)) {
303
+ ((existing.viewer === "kennel-vite" &&
304
+ !remote &&
305
+ existing.kernelKey === viteKernelKey) ||
306
+ (existing.viewer !== "kennel-vite" &&
307
+ existing.kernelKey === kernel.key &&
308
+ isSameOrDescendant(existing.root, options.root)))) {
304
309
  const viewerUrl = popUrl(displayHost, options.port, options);
305
310
  console.log(viewerUrl);
306
311
  console.log(`Kennel is already running at ${viewerUrl}`);
@@ -26,6 +26,9 @@ export declare function readExternalPlugins(): ExternalPluginEntry[];
26
26
  export declare function installExternalPlugin(url: string, options?: {
27
27
  directory?: string;
28
28
  }): Promise<ExternalPluginEntry>;
29
+ /** Identifies the installed plugin set, independent of the Node runtime. */
30
+ export declare function hotKernelKey(plugins?: ExternalPluginEntry[]): string;
31
+ /** Identifies a runtime-specific static kernel cache entry. */
29
32
  export declare function kernelKey(plugins?: ExternalPluginEntry[]): string;
30
33
  export type ActiveKernel = {
31
34
  buildIdentity: string;
@@ -373,21 +373,33 @@ export async function installExternalPlugin(url, options = {}) {
373
373
  function cliRoot() {
374
374
  return resolve(dirname(fileURLToPath(import.meta.url)), "../..");
375
375
  }
376
+ function kernelPlugins(plugins) {
377
+ return plugins.map((plugin) => ({
378
+ url: plugin.url,
379
+ requestedRef: plugin.requestedRef ?? null,
380
+ packageName: plugin.packageName,
381
+ id: plugin.id,
382
+ directory: plugin.directory ?? null,
383
+ commit: plugin.commit,
384
+ trigger: plugin.trigger,
385
+ entries: plugin.entries,
386
+ }));
387
+ }
388
+ /** Identifies the installed plugin set, independent of the Node runtime. */
389
+ export function hotKernelKey(plugins = readExternalPlugins()) {
390
+ return hash(JSON.stringify({
391
+ buildIdentity: BUILD_IDENTITY,
392
+ schema: KENNEL_KERNEL_SCHEMA,
393
+ plugins: kernelPlugins(plugins),
394
+ }));
395
+ }
396
+ /** Identifies a runtime-specific static kernel cache entry. */
376
397
  export function kernelKey(plugins = readExternalPlugins()) {
377
398
  return hash(JSON.stringify({
378
399
  buildIdentity: BUILD_IDENTITY,
379
400
  schema: KENNEL_KERNEL_SCHEMA,
380
401
  runtime: BUILD_RUNTIME,
381
- plugins: plugins.map((plugin) => ({
382
- url: plugin.url,
383
- requestedRef: plugin.requestedRef ?? null,
384
- packageName: plugin.packageName,
385
- id: plugin.id,
386
- directory: plugin.directory ?? null,
387
- commit: plugin.commit,
388
- trigger: plugin.trigger,
389
- entries: plugin.entries,
390
- })),
402
+ plugins: kernelPlugins(plugins),
391
403
  }));
392
404
  }
393
405
  function kernelRoot(key) {
@@ -7,12 +7,15 @@ import { tmpdir } from 'node:os';
7
7
  import { dirname, relative, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { createKennelClient } from '../core/api-client.js';
10
+ import { hotKernelKey, readExternalPlugins } from '../plugins/external.js';
10
11
  function sourceRoot() {
11
12
  return resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
12
13
  }
13
14
  async function viewerStatus(url) {
14
15
  return createKennelClient({ baseUrl: url }).health({ signal: AbortSignal.timeout(1_000) })
15
- .then((body) => body.viewer === 'kennel-vite' || body.viewer === 'kennel-core' ? 'running' : 'other')
16
+ .then((body) => body.viewer === 'kennel-vite' || body.viewer === 'kennel-core'
17
+ ? { viewer: body.viewer, kernelKey: body.kernelKey }
18
+ : 'other')
16
19
  .catch((error) => {
17
20
  // HTTP rejection means a different service owns the port; connection and
18
21
  // timeout failures mean the viewer is unavailable, so startup may retry.
@@ -20,17 +23,17 @@ async function viewerStatus(url) {
20
23
  ? 'other' : 'unavailable';
21
24
  });
22
25
  }
23
- async function viewerRunning(url, attempts = 1, waitOnOther = false) {
26
+ async function runningViewer(url, attempts = 1, waitOnOther = false) {
24
27
  for (let attempt = 0; attempt < attempts; attempt += 1) {
25
28
  const status = await viewerStatus(url);
26
- if (status === 'running')
27
- return true;
29
+ if (typeof status === 'object')
30
+ return status;
28
31
  if (status === 'other' && !waitOnOther)
29
- return false;
32
+ return undefined;
30
33
  if (attempt + 1 < attempts)
31
34
  await new Promise((done) => setTimeout(done, 250));
32
35
  }
33
- return false;
36
+ return undefined;
34
37
  }
35
38
  async function portOpen(host, port) {
36
39
  return new Promise((done) => {
@@ -132,8 +135,20 @@ export async function hotViewer(options) {
132
135
  console.log(`bun ${[...args, ...(options.open ? ['--open', openPath] : [])].join(' ')}`);
133
136
  return;
134
137
  }
135
- const status = await viewerStatus(url);
136
- if (status === 'running') {
138
+ const plugins = readExternalPlugins();
139
+ // Must match the key apps/web/scripts/dev.mjs gives Vite as KENNEL_KERNEL_KEY.
140
+ const expectedKey = plugins.length > 0 ? hotKernelKey(plugins) : 'core';
141
+ // Reuse only a Vite viewer that serves the same plugin set. Anything else on
142
+ // the port is stale for this kernel and must be restarted, never reused.
143
+ const reuse = (running) => {
144
+ const restart = 'Stop that viewer (Ctrl+C in its terminal) and run this command again, or select another port.';
145
+ if (running.viewer !== 'kennel-vite') {
146
+ throw new Error(`Port ${options.port} runs the static Kennel viewer, not the hot viewer. ${restart}`);
147
+ }
148
+ if (running.kernelKey !== expectedKey) {
149
+ throw new Error(`The Kennel hot viewer on port ${options.port} uses kernel ${running.kernelKey}, `
150
+ + `but the installed plugins need kernel ${expectedKey}. ${restart}`);
151
+ }
137
152
  if (options.open) {
138
153
  // Do not stack another tab: the viewer is already open. Print the URL
139
154
  // so the caller can still get to it deliberately.
@@ -142,21 +157,19 @@ export async function hotViewer(options) {
142
157
  else {
143
158
  console.log('Kennel hot viewer is already running. Reusing it.');
144
159
  }
160
+ };
161
+ const status = await viewerStatus(url);
162
+ if (typeof status === 'object') {
163
+ reuse(status);
145
164
  return;
146
165
  }
147
166
  if (status === 'other') {
148
167
  throw new Error(`Port ${options.port} is used by another service. Stop it or select another port.`);
149
168
  }
150
169
  if (await portOpen(options.host, options.port)) {
151
- if (await viewerRunning(url, 20, true)) {
152
- if (options.open) {
153
- // Do not stack another tab: the viewer is already open. Print the URL
154
- // so the caller can still get to it deliberately.
155
- console.log(`Kennel hot viewer is already running at ${viewerUrl}`);
156
- }
157
- else {
158
- console.log('Kennel hot viewer is already running. Reusing it.');
159
- }
170
+ const running = await runningViewer(url, 20, true);
171
+ if (running) {
172
+ reuse(running);
160
173
  return;
161
174
  }
162
175
  throw new Error(`Port ${options.port} is used by another service. Stop it or select another port.`);
@@ -164,15 +177,9 @@ export async function hotViewer(options) {
164
177
  const lock = startupLock(options.host, options.port);
165
178
  let token = await acquireLock(lock);
166
179
  if (!token) {
167
- if (await viewerRunning(url, 40, true)) {
168
- if (options.open) {
169
- // Do not stack another tab: the viewer is already open. Print the URL
170
- // so the caller can still get to it deliberately.
171
- console.log(`Kennel hot viewer is already running at ${viewerUrl}`);
172
- }
173
- else {
174
- console.log('Kennel hot viewer is already running. Reusing it.');
175
- }
180
+ const running = await runningViewer(url, 40, true);
181
+ if (running) {
182
+ reuse(running);
176
183
  return;
177
184
  }
178
185
  await rm(lock, { force: true, recursive: true });
@@ -180,16 +187,10 @@ export async function hotViewer(options) {
180
187
  }
181
188
  if (!token)
182
189
  throw new Error(`Could not acquire the hot viewer startup lock for port ${options.port}.`);
183
- if (await viewerRunning(url)) {
190
+ const running = await runningViewer(url);
191
+ if (running) {
184
192
  await releaseLock(lock, token);
185
- if (options.open) {
186
- // Do not stack another tab: the viewer is already open. Print the URL
187
- // so the caller can still get to it deliberately.
188
- console.log(`Kennel hot viewer is already running at ${viewerUrl}`);
189
- }
190
- else {
191
- console.log('Kennel hot viewer is already running. Reusing it.');
192
- }
193
+ reuse(running);
193
194
  return;
194
195
  }
195
196
  console.log(`bun ${args.join(' ')}`);
@@ -220,9 +221,15 @@ export async function hotViewer(options) {
220
221
  done();
221
222
  return;
222
223
  }
223
- if (await viewerRunning(url, 20, true)) {
224
- console.log('Kennel hot viewer is already running. Reusing it.');
225
- done();
224
+ const existing = await runningViewer(url, 20, true);
225
+ if (existing) {
226
+ try {
227
+ reuse(existing);
228
+ done();
229
+ }
230
+ catch (error) {
231
+ fail(error);
232
+ }
226
233
  return;
227
234
  }
228
235
  fail(new Error(`Kennel hot viewer stopped with exit code ${code ?? 'unknown'}`));
@@ -259,17 +259,15 @@ function tailscaleHost() {
259
259
  export async function viewer(input) {
260
260
  const portExplicit = viewerPortWasExplicit(input);
261
261
  const options = splitViewerArgs(input);
262
- const { ensureKernel, readExternalPlugins } = await import("../plugins/external.js");
263
- const externalPlugins = readExternalPlugins();
264
- if (options.hot && externalPlugins.length === 0) {
262
+ if (options.hot) {
265
263
  const { hotViewer } = await import("./hot-viewer.js");
266
264
  await hotViewer(options);
267
265
  return;
268
266
  }
269
- if (options.hot && externalPlugins.length > 0) {
270
- console.log("Kennel hot viewer is disabled for external plugin kernels; using the composed static kernel.");
271
- }
267
+ const { ensureKernel, hotKernelKey, readExternalPlugins } = await import("../plugins/external.js");
268
+ const externalPlugins = readExternalPlugins();
272
269
  const kernel = await ensureKernel(externalPlugins);
270
+ const viteKernelKey = externalPlugins.length > 0 ? hotKernelKey(externalPlugins) : "core";
273
271
  const web = kernel.webRoot;
274
272
  if (!existsSync(resolve(web, "index.html"))) {
275
273
  throw new Error(`Kennel web app not found. Run the CLI build first.`);
@@ -298,9 +296,16 @@ export async function viewer(input) {
298
296
  .join(",");
299
297
  const initialViewerUrl = `http://${options.host}:${options.port}`;
300
298
  const existing = await runningViewer(initialViewerUrl);
299
+ // A local Vite viewer is not root-restricted, so it can open any local
300
+ // folder. The static kennel-core viewer is root-scoped: reuse it only for
301
+ // its own root or a descendant.
301
302
  if (existing &&
302
- existing.kernelKey === kernel.key &&
303
- isSameOrDescendant(existing.root, options.root)) {
303
+ ((existing.viewer === "kennel-vite" &&
304
+ !remote &&
305
+ existing.kernelKey === viteKernelKey) ||
306
+ (existing.viewer !== "kennel-vite" &&
307
+ existing.kernelKey === kernel.key &&
308
+ isSameOrDescendant(existing.root, options.root)))) {
304
309
  const viewerUrl = popUrl(displayHost, options.port, options);
305
310
  console.log(viewerUrl);
306
311
  console.log(`Kennel is already running at ${viewerUrl}`);
@@ -5,6 +5,7 @@ import { dirname, join, resolve } from "node:path"
5
5
  import { fileURLToPath } from "node:url"
6
6
 
7
7
  import {
8
+ hotKernelKey,
8
9
  prepareExternalPluginModules,
9
10
  readExternalPlugins,
10
11
  } from "../../../cli/src/plugins/external.ts"
@@ -30,6 +31,9 @@ try {
30
31
  stdio: "inherit",
31
32
  env: {
32
33
  ...process.env,
34
+ // Reported by /api/health so the CLI reuses this viewer only for the
35
+ // same plugin set; "core" matches the CLI kernel without plugins.
36
+ KENNEL_KERNEL_KEY: plugins.length > 0 ? hotKernelKey(plugins) : "core",
33
37
  KENNEL_KERNEL_SOURCE_ROOT: workspace,
34
38
  KENNEL_HOST_NODE_MODULES: resolve(workspace, "cli/node_modules"),
35
39
  KENNEL_EXTERNAL_CLIENT_MODULE: modules.clientModule,
@@ -4,39 +4,75 @@ export function subscribeFolderEvent(
4
4
  folder: string | undefined,
5
5
  listener: () => void,
6
6
  ) {
7
- return subscribeApiEvent(event, (message) => {
8
- let data: unknown
9
- try {
10
- data = JSON.parse((message as MessageEvent<string>).data)
11
- } catch {
12
- return
13
- }
14
- if (
15
- data &&
16
- typeof data === "object" &&
17
- "path" in data &&
18
- data.path === folder
19
- )
20
- listener()
21
- })
7
+ return subscribeApiEvent(
8
+ event,
9
+ (message) => {
10
+ let data: unknown
11
+ try {
12
+ data = JSON.parse((message as MessageEvent<string>).data)
13
+ } catch {
14
+ return
15
+ }
16
+ if (
17
+ data &&
18
+ typeof data === "object" &&
19
+ "path" in data &&
20
+ data.path === folder
21
+ )
22
+ listener()
23
+ },
24
+ listener,
25
+ )
26
+ }
27
+
28
+ type Subscription = {
29
+ event: string
30
+ listener: (event: Event) => void
31
+ resume: () => void
22
32
  }
23
33
 
24
34
  let source: EventSource | undefined
25
- let subscribers = 0
26
- /** Share one SSE connection across mounted views; browser reconnects automatically. */
35
+ const subscriptions = new Set<Subscription>()
36
+
37
+ function connect() {
38
+ if (source || document.visibilityState !== "visible") return
39
+ source = new EventSource("/api/events")
40
+ for (const { event, listener } of subscriptions) {
41
+ source.addEventListener(event, listener)
42
+ }
43
+ }
44
+
45
+ function visibilityChanged() {
46
+ if (document.visibilityState !== "visible") {
47
+ source?.close()
48
+ source = undefined
49
+ return
50
+ }
51
+ connect()
52
+ // Hidden tabs missed notifications. Refresh each subscriber on return.
53
+ for (const subscription of subscriptions) subscription.resume()
54
+ }
55
+
56
+ /** Hidden tabs must not consume the browser's per-origin HTTP/1 connection limit. */
27
57
  export function subscribeApiEvent(
28
58
  event: string,
29
59
  listener: (event: Event) => void,
60
+ resume = () => listener(new Event(event)),
30
61
  ) {
31
- source ??= new EventSource("/api/events")
32
- const current = source
33
- subscribers++
34
- current.addEventListener(event, listener)
62
+ const subscription = { event, listener, resume }
63
+ subscriptions.add(subscription)
64
+ if (subscriptions.size === 1) {
65
+ document.addEventListener("visibilitychange", visibilityChanged)
66
+ }
67
+ if (source) source.addEventListener(event, listener)
68
+ else connect()
35
69
  return () => {
36
- current.removeEventListener(event, listener)
37
- if (--subscribers === 0) {
38
- current.close()
70
+ if (!subscriptions.delete(subscription)) return
71
+ source?.removeEventListener(event, listener)
72
+ if (subscriptions.size === 0) {
73
+ source?.close()
39
74
  source = undefined
75
+ document.removeEventListener("visibilitychange", visibilityChanged)
40
76
  }
41
77
  }
42
78
  }
@@ -215,6 +215,8 @@ export default defineConfig({
215
215
  corePlugin({
216
216
  remoteAccess,
217
217
  restrictToRoot: remoteAccess,
218
+ // /api/health reports this name so the CLI reuses only a hot viewer.
219
+ viewerName: "kennel-vite",
218
220
  root: process.env.KENNEL_VIEWER_ROOT || root,
219
221
  // `kennel pop <file>` (hot) locks document reads to exactly this file;
220
222
  // the static viewer applies the same lock in-process.